@@ -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}
0 commit comments