Skip to content

Commit 3c0b7de

Browse files
authored
Merge pull request #18 from HMAKT99/feature/multi-device-support
feat: multi-device support — unique service UUID, identify-on-reconnect, broadcast challenge
2 parents 5d71fdd + 6f726d6 commit 3c0b7de

7 files changed

Lines changed: 194 additions & 23 deletions

File tree

companion/TouchBridge/Core/BLEClient.swift

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ public class BLEClient: NSObject {
8282
/// The connected peripheral's UUID, if any.
8383
public var connectedPeripheralID: UUID? { connectedPeripheral?.identifier }
8484

85+
/// The BLE service UUID to scan for and connect to.
86+
/// Set to the paired Mac's unique service UUID after pairing.
87+
/// Defaults to the shared protocol UUID (used only during initial discovery).
88+
public var serviceUUID: String = TouchBridgeConstants.serviceUUID
89+
8590
public override init() {
8691
super.init()
8792
self.centralManager = CBCentralManager(
@@ -102,9 +107,9 @@ public class BLEClient: NSObject {
102107
return
103108
}
104109

105-
let serviceUUID = CBUUID(string: TouchBridgeConstants.serviceUUID)
110+
let targetUUID = CBUUID(string: serviceUUID)
106111
centralManager.scanForPeripherals(
107-
withServices: [serviceUUID],
112+
withServices: [targetUUID],
108113
options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
109114
)
110115
isScanning = true
@@ -188,7 +193,7 @@ public class BLEClient: NSObject {
188193

189194
private func discoverServices(for peripheral: CBPeripheral) {
190195
peripheral.delegate = self
191-
peripheral.discoverServices([CBUUID(string: TouchBridgeConstants.serviceUUID)])
196+
peripheral.discoverServices([CBUUID(string: serviceUUID)])
192197
}
193198

194199
private func subscribeToNotifications(for peripheral: CBPeripheral) {
@@ -316,7 +321,7 @@ extension BLEClient: CBPeripheralDelegate {
316321
}
317322

318323
guard let services = peripheral.services else { return }
319-
let targetUUID = CBUUID(string: TouchBridgeConstants.serviceUUID)
324+
let targetUUID = CBUUID(string: serviceUUID)
320325

321326
for service in services where service.uuid == targetUUID {
322327
peripheral.discoverCharacteristics(nil, for: service)

companion/TouchBridge/Core/CompanionCoordinator.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,45 @@ public final class CompanionCoordinator: NSObject, @unchecked Sendable {
149149
challengeHandler.sessionCrypto = crypto
150150

151151
logger.info("ECDH session established with Mac")
152+
153+
// Immediately identify ourselves to the daemon.
154+
// This allows the Mac to recognise us as a previously-paired device
155+
// without going through the full pairing ceremony again.
156+
sendIdentify(using: crypto)
152157
} catch {
153158
logger.error("ECDH failed: \(error.localizedDescription)")
154159
}
155160
}
156161

162+
/// Send an encrypted identify message to the Mac after ECDH.
163+
///
164+
/// Wire format: [version=1][type=6(identify)] + AES-GCM encrypted JSON
165+
/// The Mac decrypts it, looks up deviceID in the keychain, and marks
166+
/// this session as identified so it can receive challenges.
167+
private func sendIdentify(using crypto: SessionCryptoWrapper) {
168+
struct IdentifyPayload: Codable {
169+
let deviceID: String
170+
let deviceName: String
171+
}
172+
173+
do {
174+
let payload = IdentifyPayload(
175+
deviceID: deviceID,
176+
deviceName: UIDevice.current.name
177+
)
178+
let plaintext = try JSONEncoder().encode(payload)
179+
let encrypted = try crypto.encrypt(plaintext: plaintext)
180+
181+
var wireData = Data([1, 6]) // version=1, type=identify(6)
182+
wireData.append(encrypted)
183+
184+
_ = bleClient.sendPairingData(wireData)
185+
logger.info("Sent identify for device \(self.deviceID)")
186+
} catch {
187+
logger.error("Failed to send identify: \(error.localizedDescription)")
188+
}
189+
}
190+
157191
// MARK: - Pairing
158192

159193
/// Send pairing request to Mac with our signing public key.
@@ -243,6 +277,11 @@ extension CompanionCoordinator: BLEClientDelegate {
243277
// Store pairing info
244278
UserDefaults.standard.set(macID, forKey: "pairedMacID")
245279

280+
// Lock future BLE scans to this Mac's unique service UUID.
281+
// Without this, the app would scan for the generic protocol UUID
282+
// and connect to any TouchBridge Mac nearby (other people's Macs).
283+
bleClient.serviceUUID = macID
284+
246285
DispatchQueue.main.async {
247286
self.onPairingComplete?(macID)
248287
}

daemon/Sources/TouchBridgeCore/BLEServer.swift

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ public class BLEServer: NSObject {
7676
// RSSI proximity gate
7777
private let rssiThreshold: Int
7878

79+
// Per-Mac unique service UUID
80+
private let serviceUUID: String
81+
7982
public weak var delegate: BLEServerDelegate?
8083

8184
/// Whether the peripheral manager is powered on and ready.
@@ -84,8 +87,12 @@ public class BLEServer: NSObject {
8487
/// Whether we are currently advertising.
8588
public private(set) var isAdvertising: Bool = false
8689

87-
public init(rssiThreshold: Int = TouchBridgeConstants.defaultRSSIThreshold) {
90+
public init(
91+
rssiThreshold: Int = TouchBridgeConstants.defaultRSSIThreshold,
92+
serviceUUID: String = TouchBridgeConstants.serviceUUID
93+
) {
8894
self.rssiThreshold = rssiThreshold
95+
self.serviceUUID = serviceUUID
8996
super.init()
9097
self.peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
9198
}
@@ -100,7 +107,7 @@ public class BLEServer: NSObject {
100107
}
101108

102109
peripheralManager.startAdvertising([
103-
CBAdvertisementDataServiceUUIDsKey: [CBUUID(string: TouchBridgeConstants.serviceUUID)],
110+
CBAdvertisementDataServiceUUIDsKey: [CBUUID(string: serviceUUID)],
104111
CBAdvertisementDataLocalNameKey: "TouchBridge",
105112
])
106113
isAdvertising = true
@@ -181,7 +188,7 @@ public class BLEServer: NSObject {
181188
// MARK: - Private
182189

183190
private func buildService() {
184-
let serviceUUID = CBUUID(string: TouchBridgeConstants.serviceUUID)
191+
let serviceUUID = CBUUID(string: self.serviceUUID)
185192

186193
// Session key exchange: writable by central + notifiable
187194
sessionKeyChar = CBMutableCharacteristic(
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import Foundation
2+
import OSLog
3+
4+
/// Persistent daemon configuration stored in
5+
/// `~/Library/Application Support/TouchBridge/config.json`.
6+
///
7+
/// The most important field is `serviceUUID` — a UUID generated once at first run
8+
/// and never changed. This UUID is unique to this Mac and is used as the BLE
9+
/// service UUID, ensuring that only phones paired with *this* Mac connect to it.
10+
/// Without a unique service UUID every TouchBridge phone in the area would
11+
/// connect to every TouchBridge Mac (they all share the same protocol-level UUID).
12+
public struct DaemonConfig: Sendable {
13+
/// BLE service UUID unique to this Mac.
14+
public let serviceUUID: String
15+
16+
private static let logger = Logger(subsystem: "dev.touchbridge", category: "DaemonConfig")
17+
private static let filename = "config.json"
18+
19+
private struct Stored: Codable {
20+
let serviceUUID: String
21+
}
22+
23+
/// Load existing config or create a new one with a fresh service UUID.
24+
/// Always succeeds — falls back to the shared constant if the config
25+
/// directory is somehow unwritable (e.g. sandboxed test environment).
26+
public static func load() -> DaemonConfig {
27+
let home = FileManager.default.homeDirectoryForCurrentUser.path
28+
let dir = "\(home)/Library/Application Support/TouchBridge"
29+
let path = "\(dir)/\(filename)"
30+
31+
if let data = FileManager.default.contents(atPath: path),
32+
let stored = try? JSONDecoder().decode(Stored.self, from: data),
33+
!stored.serviceUUID.isEmpty {
34+
logger.info("Loaded service UUID: \(stored.serviceUUID)")
35+
return DaemonConfig(serviceUUID: stored.serviceUUID)
36+
}
37+
38+
let newUUID = UUID().uuidString
39+
logger.info("Generating new service UUID: \(newUUID)")
40+
41+
do {
42+
try FileManager.default.createDirectory(
43+
atPath: dir, withIntermediateDirectories: true, attributes: nil
44+
)
45+
let data = try JSONEncoder().encode(Stored(serviceUUID: newUUID))
46+
FileManager.default.createFile(atPath: path, contents: data, attributes: [
47+
.posixPermissions: 0o600
48+
])
49+
} catch {
50+
logger.error("Failed to persist config: \(error.localizedDescription) — using ephemeral UUID")
51+
}
52+
53+
return DaemonConfig(serviceUUID: newUUID)
54+
}
55+
56+
private init(serviceUUID: String) {
57+
self.serviceUUID = serviceUUID
58+
}
59+
}

daemon/Sources/TouchBridgeCore/DaemonCoordinator.swift

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,15 @@ public final class DaemonCoordinator: NSObject, PAMAuthHandler, @unchecked Senda
4242
auditLog: AuditLog = AuditLog(),
4343
challengeManager: ChallengeManager = ChallengeManager(),
4444
pairingManager: PairingManager? = nil,
45-
rssiThreshold: Int = TouchBridgeConstants.defaultRSSIThreshold
45+
rssiThreshold: Int = TouchBridgeConstants.defaultRSSIThreshold,
46+
serviceUUID: String = TouchBridgeConstants.serviceUUID
4647
) {
4748
self.keychainStore = keychainStore
4849
self.auditLog = auditLog
4950
self.challengeManager = challengeManager
50-
self.bleServer = BLEServer(rssiThreshold: rssiThreshold)
51+
self.bleServer = BLEServer(rssiThreshold: rssiThreshold, serviceUUID: serviceUUID)
5152

52-
let pm = pairingManager ?? PairingManager(keychainStore: keychainStore)
53+
let pm = pairingManager ?? PairingManager(keychainStore: keychainStore, serviceUUID: serviceUUID)
5354
self.pairingManager = pm
5455

5556
super.init()
@@ -126,18 +127,24 @@ public final class DaemonCoordinator: NSObject, PAMAuthHandler, @unchecked Senda
126127
}
127128
}
128129

129-
/// Authenticate a PAM request by issuing a challenge to a connected companion.
130+
/// Authenticate a PAM request by issuing challenges to all identified companion devices.
130131
///
131132
/// Called by `SocketServer` when a PAM module connects.
132-
/// Blocks (async) until the companion responds or the timeout expires.
133+
/// Broadcasts to every connected, paired device simultaneously.
134+
/// The first valid response wins — other challenges expire naturally.
135+
/// Blocks until a response arrives or the timeout expires.
133136
public func authenticateFromPAM(
134137
user: String,
135138
service: String,
136139
pid: Int,
137140
timeout: TimeInterval
138141
) async -> (success: Bool, reason: String?) {
139-
guard let centralID = readyCentrals.first else {
140-
logger.warning("PAM auth: no companion connected")
142+
// Only challenge sessions that have completed ECDH AND identified as a known paired device.
143+
// Unknown/anonymous centrals (e.g. stranger's phone that happens to be nearby) are excluded.
144+
let targets = readyCentrals.filter { sessions[$0]?.deviceID != nil }
145+
146+
guard !targets.isEmpty else {
147+
logger.warning("PAM auth: no identified companion connected (ready=\(self.readyCentrals.count))")
141148
await auditLog.log(AuditEntry(
142149
sessionID: UUID().uuidString,
143150
surface: "pam_\(service)",
@@ -148,28 +155,37 @@ public final class DaemonCoordinator: NSObject, PAMAuthHandler, @unchecked Senda
148155
return (false, "no_companion_connected")
149156
}
150157

151-
// Issue challenge and await result with timeout
158+
logger.info("PAM auth: broadcasting challenge to \(targets.count) device(s)")
159+
160+
// Issue challenges to all identified devices and race for the first valid response.
161+
// Each challengeID maps to the same continuation — first response wins, subsequent
162+
// responses find their entry already removed from pendingAuthentications (no-op).
152163
let result: ChallengeResult? = await withTaskGroup(of: ChallengeResult?.self) { group in
153164
group.addTask {
154-
// Issue challenge and wait for BLE response
155165
await withCheckedContinuation { (continuation: CheckedContinuation<ChallengeResult, Never>) in
156166
Task {
157-
guard let challengeID = await self.issueChallenge(to: centralID, reason: service) else {
167+
var issued = 0
168+
for centralID in targets {
169+
if let challengeID = await self.issueChallenge(to: centralID, reason: service) {
170+
self.pendingAuthentications[challengeID] = continuation
171+
issued += 1
172+
}
173+
}
174+
// If every issueChallenge call failed (BLE queue full, etc.) fail immediately.
175+
if issued == 0 {
158176
continuation.resume(returning: .unknownChallenge)
159-
return
160177
}
161-
self.pendingAuthentications[challengeID] = continuation
178+
// Otherwise: wait — the first device response resumes the continuation.
162179
}
163180
}
164181
}
165182

166183
group.addTask {
167-
// Timeout
184+
// Global timeout covers the entire broadcast, not per-device.
168185
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
169186
return nil
170187
}
171188

172-
// Return whichever finishes first
173189
let first = await group.next() ?? nil
174190
group.cancelAll()
175191
return first
@@ -370,4 +386,45 @@ extension DaemonCoordinator: BLEServerDelegate {
370386
}
371387
}
372388
}
389+
390+
// MARK: - Identify
391+
392+
/// Handle an encrypted identify message from a reconnecting companion.
393+
///
394+
/// A previously-paired device sends this after ECDH to tell the daemon its deviceID
395+
/// without going through a full pairing ceremony. The daemon looks up the deviceID in
396+
/// the keychain and, if found, marks the session as identified so it can receive challenges.
397+
private func handleIdentify(data: Data, from centralID: UUID) async throws {
398+
guard let session = sessions[centralID],
399+
let crypto = session.sessionCrypto else {
400+
logger.warning("Identify from \(centralID): no session crypto — ignoring")
401+
return
402+
}
403+
404+
// Strip the 2-byte wire header, then decrypt.
405+
let encryptedPayload = data.dropFirst(2)
406+
let plaintext = try crypto.decrypt(ciphertext: Data(encryptedPayload))
407+
408+
// The payload is a JSON object with deviceID and deviceName.
409+
// We use a local struct to avoid importing TouchBridgeProtocol's IdentifyMessage
410+
// (which is fine here since we're in the daemon — same package as DaemonCoordinator).
411+
let msg = try WireFormat.decodePayload(IdentifyMessage.self, from: plaintext)
412+
413+
// Verify this device is actually in the keychain (was paired at some point).
414+
guard (try? keychainStore.retrievePairedDevice(deviceID: msg.deviceID)) != nil else {
415+
logger.warning("Identify from \(centralID): unknown deviceID \(msg.deviceID) — ignoring")
416+
return
417+
}
418+
419+
sessions[centralID]?.deviceID = msg.deviceID
420+
logger.info("Identified \(msg.deviceName) (\(msg.deviceID)) on central \(centralID)")
421+
422+
await auditLog.log(AuditEntry(
423+
sessionID: centralID.uuidString,
424+
surface: "identify",
425+
companionDevice: msg.deviceName,
426+
deviceID: msg.deviceID,
427+
result: "IDENTIFIED"
428+
))
429+
}
373430
}

daemon/Sources/TouchBridgeCore/PairingManager.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public actor PairingManager {
3131

3232
private let keychainStore: KeychainStore
3333
private let macName: String
34+
private let serviceUUID: String
3435
private let tokenExpiry: TimeInterval
3536

3637
/// Active pairing token and its creation time.
@@ -39,10 +40,12 @@ public actor PairingManager {
3940
public init(
4041
keychainStore: KeychainStore,
4142
macName: String? = nil,
43+
serviceUUID: String = TouchBridgeConstants.serviceUUID,
4244
tokenExpiry: TimeInterval = 300 // 5 minutes
4345
) {
4446
self.keychainStore = keychainStore
4547
self.macName = macName ?? Host.current().localizedName ?? "Mac"
48+
self.serviceUUID = serviceUUID
4649
self.tokenExpiry = tokenExpiry
4750
}
4851

@@ -61,7 +64,7 @@ public actor PairingManager {
6164
activePairing = (token: token, createdAt: Date())
6265

6366
let payload = PairingPayload(
64-
serviceUUID: TouchBridgeConstants.serviceUUID,
67+
serviceUUID: serviceUUID,
6568
pairingToken: token,
6669
macName: macName
6770
)

daemon/Sources/touchbridged/main.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,9 @@ struct Serve: ParsableCommand {
121121
private func runNormalMode() throws {
122122
print("touchbridged v1.0.0 starting...")
123123

124+
let config = DaemonConfig.load()
124125
let policyEngine = PolicyEngine()
125-
let coordinator = DaemonCoordinator(rssiThreshold: rssiThreshold)
126+
let coordinator = DaemonCoordinator(rssiThreshold: rssiThreshold, serviceUUID: config.serviceUUID)
126127

127128
// Proximity auto-lock
128129
var proximityMonitor: ProximityMonitor?

0 commit comments

Comments
 (0)