From a7551a304630b8ed1821e1e1233d7b83227b5bef Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 10 Jul 2026 20:51:50 +0200 Subject: [PATCH] Bind Noise sessions to claimed peer identities --- bitchat/Noise/NoiseSecurityConstants.swift | 3 + bitchat/Noise/NoiseSessionError.swift | 1 + bitchat/Noise/NoiseSessionManager.swift | 131 ++++++++++---- .../Services/BLE/BLENoisePacketHandler.swift | 18 +- bitchat/Services/BLE/BLEService.swift | 60 ++++++- bitchatTests/BLEServiceCoreTests.swift | 101 +++++++++++ bitchatTests/Noise/NoiseCoverageTests.swift | 11 +- .../Services/BLENoisePacketHandlerTests.swift | 15 ++ .../NoiseEncryptionServiceTests.swift | 161 +++++++++++++++--- 9 files changed, 436 insertions(+), 65 deletions(-) diff --git a/bitchat/Noise/NoiseSecurityConstants.swift b/bitchat/Noise/NoiseSecurityConstants.swift index 67a722b1..70b63eba 100644 --- a/bitchat/Noise/NoiseSecurityConstants.swift +++ b/bitchat/Noise/NoiseSecurityConstants.swift @@ -14,6 +14,9 @@ enum NoiseSecurityConstants { // Maximum handshake message size static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern + + // Noise XX message 1 contains only the initiator's 32-byte ephemeral key. + static let xxInitialMessageSize = 32 // Session timeout - sessions older than this should be renegotiated static let sessionTimeout: TimeInterval = 86400 // 24 hours diff --git a/bitchat/Noise/NoiseSessionError.swift b/bitchat/Noise/NoiseSessionError.swift index 24e172e7..18dc99b0 100644 --- a/bitchat/Noise/NoiseSessionError.swift +++ b/bitchat/Noise/NoiseSessionError.swift @@ -11,4 +11,5 @@ enum NoiseSessionError: Error, Equatable { case notEstablished case sessionNotFound case alreadyEstablished + case peerIdentityMismatch } diff --git a/bitchat/Noise/NoiseSessionManager.swift b/bitchat/Noise/NoiseSessionManager.swift index 619f84ac..7c14a991 100644 --- a/bitchat/Noise/NoiseSessionManager.swift +++ b/bitchat/Noise/NoiseSessionManager.swift @@ -13,6 +13,11 @@ import BitFoundation final class NoiseSessionManager { private var sessions: [PeerID: NoiseSession] = [:] + /// A responder rehandshake must not evict a working transport session + /// before the candidate proves that its authenticated static key belongs + /// to the claimed wire ID. Candidates therefore live outside `sessions` + /// until the XX handshake completes and the binding is validated. + private var responderCandidates: [PeerID: NoiseSession] = [:] private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent) @@ -54,6 +59,9 @@ final class NoiseSessionManager { if let session = sessions.removeValue(forKey: peerID) { session.reset() // Clear sensitive data before removing } + if let candidate = responderCandidates.removeValue(forKey: peerID) { + candidate.reset() + } } } @@ -62,7 +70,11 @@ final class NoiseSessionManager { for (_, session) in sessions { session.reset() } + for (_, candidate) in responderCandidates { + candidate.reset() + } sessions.removeAll() + responderCandidates.removeAll() } } @@ -79,6 +91,7 @@ final class NoiseSessionManager { // Remove any existing non-established session if let existingSession = sessions[peerID], !existingSession.isEstablished() { _ = sessions.removeValue(forKey: peerID) + existingSession.reset() } // Create new initiator session @@ -91,6 +104,7 @@ final class NoiseSessionManager { } catch { // Clean up failed session _ = sessions.removeValue(forKey: peerID) + session.reset() SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription)) throw error } @@ -100,39 +114,50 @@ final class NoiseSessionManager { func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? { // Process everything within the synchronized block to prevent race conditions return try managerQueue.sync(flags: .barrier) { - var shouldCreateNew = false - var existingSession: NoiseSession? = nil - - if let existing = sessions[peerID] { - // If we have an established session, the peer must have cleared their session - // for a good reason (e.g., decryption failure, restart, etc.) - // We should accept the new handshake to re-establish encryption - if existing.isEstablished() { - SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session) - _ = sessions.removeValue(forKey: peerID) - shouldCreateNew = true + let session: NoiseSession + let isReplacementCandidate: Bool + + if let candidate = responderCandidates[peerID] { + // A fresh XX message 1 supersedes an incomplete candidate, + // but never the established session it is trying to replace. + if message.count == NoiseSecurityConstants.xxInitialMessageSize { + candidate.reset() + let replacement = sessionFactory(peerID, .responder) + responderCandidates[peerID] = replacement + session = replacement } else { - // If we're in the middle of a handshake and receive a new initiation, - // reset and start fresh (the other side may have restarted) - if existing.getState() == .handshaking && message.count == 32 { - _ = sessions.removeValue(forKey: peerID) - shouldCreateNew = true - } else { - existingSession = existing - } + session = candidate + } + isReplacementCandidate = true + } else if let existing = sessions[peerID] { + if existing.isEstablished() { + SecureLogger.info( + "Validating replacement handshake from \(peerID) while preserving the established session", + category: .session + ) + let candidate = sessionFactory(peerID, .responder) + responderCandidates[peerID] = candidate + session = candidate + isReplacementCandidate = true + } else if existing.getState() == .handshaking, + message.count == NoiseSecurityConstants.xxInitialMessageSize { + // No established transport state exists to preserve. A + // fresh initiation replaces the incomplete handshake. + _ = sessions.removeValue(forKey: peerID) + existing.reset() + let replacement = sessionFactory(peerID, .responder) + sessions[peerID] = replacement + session = replacement + isReplacementCandidate = false + } else { + session = existing + isReplacementCandidate = false } } else { - shouldCreateNew = true - } - - // Get or create session - let session: NoiseSession - if shouldCreateNew { let newSession = sessionFactory(peerID, .responder) sessions[peerID] = newSession session = newSession - } else { - session = existingSession! + isReplacementCandidate = false } // Process the handshake message within the synchronized block @@ -141,18 +166,40 @@ final class NoiseSessionManager { // Check if session is established after processing if session.isEstablished() { - if let remoteKey = session.getRemoteStaticPublicKey() { - // Schedule callback outside the synchronized block to prevent deadlock - DispatchQueue.global().async { [weak self] in - self?.onSessionEstablished?(peerID, remoteKey) + guard let remoteKey = session.getRemoteStaticPublicKey(), + authenticatedRemoteKey(remoteKey, matches: peerID) else { + throw NoiseSessionError.peerIdentityMismatch + } + + if isReplacementCandidate { + _ = responderCandidates.removeValue(forKey: peerID) + let previous = sessions.updateValue(session, forKey: peerID) + if let previous, previous !== session { + previous.reset() } } + + // Schedule callback outside the synchronized block to prevent deadlock + DispatchQueue.global().async { [weak self] in + self?.onSessionEstablished?(peerID, remoteKey) + } } return response } catch { - // Reset the session on handshake failure so next attempt can start fresh - _ = sessions.removeValue(forKey: peerID) + // A failed candidate is discarded without touching the + // established session. Ordinary failed handshakes retain the + // historical cleanup behavior. + if isReplacementCandidate { + if let storedCandidate = responderCandidates[peerID], + storedCandidate === session { + _ = responderCandidates.removeValue(forKey: peerID) + } + } else if let storedSession = sessions[peerID], + storedSession === session { + _ = sessions.removeValue(forKey: peerID) + } + session.reset() // Schedule callback outside the synchronized block to prevent deadlock DispatchQueue.global().async { [weak self] in @@ -164,6 +211,24 @@ final class NoiseSessionManager { } } } + + /// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are + /// also accepted by internal callers when they exactly match the static + /// key. Non-wire identifiers remain available to protocol test harnesses; + /// BLE packet ingress always supplies a short hexadecimal ID. + private func authenticatedRemoteKey( + _ remoteKey: Curve25519.KeyAgreement.PublicKey, + matches claimedPeerID: PeerID + ) -> Bool { + let rawKey = remoteKey.rawRepresentation + if claimedPeerID.isShort { + return PeerID(publicKey: rawKey) == claimedPeerID + } + if let claimedNoiseKey = claimedPeerID.noiseKey { + return claimedNoiseKey == rawKey + } + return true + } // MARK: - Encryption/Decryption diff --git a/bitchat/Services/BLE/BLENoisePacketHandler.swift b/bitchat/Services/BLE/BLENoisePacketHandler.swift index bddbc053..dcced8b2 100644 --- a/bitchat/Services/BLE/BLENoisePacketHandler.swift +++ b/bitchat/Services/BLE/BLENoisePacketHandler.swift @@ -49,7 +49,11 @@ final class BLENoisePacketHandler { self.environment = environment } - func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) { + /// Returns true when the handshake message was processed successfully. + /// Callers use this to distinguish an authenticated replacement completion + /// from a rejected candidate while an older session remains established. + @discardableResult + func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { let env = environment // Use NoiseEncryptionService for handshake processing if PeerID(hexData: packet.recipientID) == env.localPeerID() { @@ -72,14 +76,26 @@ final class BLENoisePacketHandler { // Session establishment will trigger onPeerAuthenticated callback // which will send any pending messages at the right time + return true + } catch NoiseSessionError.peerIdentityMismatch { + // The candidate was already discarded by the session manager. + // Do not let a spoofed claimed ID trigger a fresh outbound + // handshake or recreate state for the attacker-selected ID. + SecureLogger.warning( + "Rejected Noise handshake whose static key does not match \(peerID.id.prefix(8))…", + category: .security + ) + return false } catch { SecureLogger.error("Failed to process handshake: \(error)") // Try initiating a new handshake if !env.hasNoiseSession(peerID) { env.initiateHandshake(peerID) } + return false } } + return false } func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) { diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 1f919a4a..59f7dc11 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -1618,7 +1618,44 @@ final class BLEService: NSObject { } } - private func handleLeave(_: BitchatPacket, from peerID: PeerID) { + /// Accept a leave only when the claimed sender proves possession of the + /// signing key bound by a verified announce. The persisted identity cache + /// keeps delayed/relayed leaves verifiable after the live registry entry + /// has aged out. + private func handleLeave(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { + let registrySigningKey = collectionsQueue.sync { + peerRegistry.info(for: peerID)?.signingPublicKey + } + let verifiedViaRegistry = registrySigningKey.map { + noiseService.verifyPacketSignature(packet, publicKey: $0) + } ?? false + let verifiedViaPersistedIdentity = !verifiedViaRegistry + && identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID).contains { identity in + PeerID(publicKey: identity.publicKey) == peerID + && identity.signingPublicKey.map { + noiseService.verifyPacketSignature(packet, publicKey: $0) + } == true + } + + guard verifiedViaRegistry || verifiedViaPersistedIdentity else { + SecureLogger.warning( + "🚫 Dropping leave with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…", + category: .security + ) + return false + } + + // A valid departure retires transport state too; otherwise + // canDeliverSecurely could remain true for a peer we just removed. + noiseService.clearSession(for: peerID) + readLinkState { _ in + let departedLinks = noiseAuthenticatedLinkOwners.compactMap { link, owner in + owner == peerID ? link : nil + } + for link in departedLinks { + noiseAuthenticatedLinkOwners.removeValue(forKey: link) + } + } _ = collectionsQueue.sync(flags: .barrier) { // Remove the peer when they leave peerRegistry.remove(peerID) @@ -1635,6 +1672,7 @@ final class BLEService: NSObject { self.deliverTransportEvent(.peerDisconnected(peerID)) self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) } + return true } private func sendAnnounce(forceSend: Bool = false) { // Throttle announces to prevent flooding @@ -2336,6 +2374,12 @@ extension BLEService { } } + func _test_isNoiseAuthenticatedCentral(_ centralUUID: String, for peerID: PeerID) -> Bool { + bleQueue.sync { + noiseAuthenticatedLinkOwners[.central(centralUUID)] == peerID + } + } + func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) { collectionsQueue.sync(flags: .barrier) { peerRegistry.upsert(BLEPeerInfo( @@ -4785,7 +4829,9 @@ extension BLEService { handleMeshPong(packet, from: senderID) case .leave: - handleLeave(packet, from: senderID) + // A forged leave must neither evict the claimed peer nor spread + // to downstream nodes. + guard handleLeave(packet, from: senderID) else { return } case .none: SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session) @@ -5426,8 +5472,14 @@ extension BLEService { private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) { let wasEstablished = noiseService.hasEstablishedSession(with: peerID) - noisePacketHandler.handleHandshake(packet, from: peerID) - if !wasEstablished, noiseService.hasEstablishedSession(with: peerID) { + let processed = noisePacketHandler.handleHandshake(packet, from: peerID) + let isEstablished = noiseService.hasEstablishedSession(with: peerID) + // XX message 1 is exactly the unauthenticated 32-byte ephemeral key. + // While replacing an existing session, do not authenticate its ingress + // link until a later message completes and validates the candidate. + let completedAuthenticatedHandshake = !wasEstablished + || packet.payload.count != NoiseSecurityConstants.xxInitialMessageSize + if processed, isEstablished, completedAuthenticatedHandshake { markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID) } } diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index a1d62cae..2c9b1107 100644 --- a/bitchatTests/BLEServiceCoreTests.swift +++ b/bitchatTests/BLEServiceCoreTests.swift @@ -99,6 +99,95 @@ struct BLEServiceCoreTests { #expect(ble.currentPeerSnapshots().isEmpty) } + @Test + func unsignedAndBadSignatureLeaveDoNotEvictOrRelayClaimedPeer() async throws { + let ble = makeService() + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let mallory = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let outbound = OutboundPacketTap() + ble._test_onOutboundPacket = outbound.record + + let unsigned = makeLeavePacket(sender: alicePeerID, marker: "unsigned") + ble._test_handlePacket( + unsigned, + fromPeerID: alicePeerID, + signingPublicKey: alice.getSigningPublicKeyData() + ) + + let unsignedRelayed = await TestHelpers.waitUntil( + { outbound.count(ofType: .leave) > 0 }, + timeout: TestConstants.shortTimeout + ) + #expect(!unsignedRelayed) + #expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID }) + + let badSignature = try #require( + mallory.signPacket(makeLeavePacket(sender: alicePeerID, marker: "bad-signature")) + ) + ble._test_handlePacket( + badSignature, + fromPeerID: alicePeerID, + signingPublicKey: alice.getSigningPublicKeyData() + ) + + let badSignatureRelayed = await TestHelpers.waitUntil( + { outbound.count(ofType: .leave) > 0 }, + timeout: TestConstants.shortTimeout + ) + #expect(!badSignatureRelayed) + #expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID }) + } + + @Test + func validSignedLeaveEvictsSessionAndRelays() async throws { + let ble = makeService() + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + + // Establish a real session so the leave regression also verifies that + // stale secure-delivery state is retired, not just the peer-list row. + let message1 = try ble._test_noiseInitiateHandshake(with: alicePeerID) + let message2 = try #require( + try alice.processHandshakeMessage(from: ble.myPeerID, message: message1) + ) + let message3 = try #require( + try ble._test_noiseProcessHandshakeMessage(from: alicePeerID, message: message2) + ) + _ = try alice.processHandshakeMessage(from: ble.myPeerID, message: message3) + #expect(ble.canDeliverSecurely(to: alicePeerID)) + let centralUUID = "central-valid-leave" + ble._test_bindCentral(centralUUID, to: alicePeerID) + ble._test_markNoiseAuthenticatedCentral(centralUUID, to: alicePeerID) + #expect(ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID)) + + let outbound = OutboundPacketTap() + ble._test_onOutboundPacket = outbound.record + let signedLeave = try #require( + alice.signPacket(makeLeavePacket(sender: alicePeerID, marker: "valid")) + ) + ble._test_handlePacket( + signedLeave, + fromPeerID: alicePeerID, + signingPublicKey: alice.getSigningPublicKeyData() + ) + + let evicted = await TestHelpers.waitUntil( + { + !ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID } + && !ble.canDeliverSecurely(to: alicePeerID) + && !ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID) + }, + timeout: TestConstants.longTimeout + ) + #expect(evicted) + let relayed = await TestHelpers.waitUntil( + { outbound.count(ofType: .leave) == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(relayed) + } + @Test func ingressAllowsRelayedSenderOnBoundLink() async throws { let ble = makeService() @@ -690,6 +779,18 @@ private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64 ) } +private func makeLeavePacket(sender: PeerID, marker: String) -> BitchatPacket { + BitchatPacket( + type: MessageType.leave.rawValue, + senderID: Data(hexString: sender.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data(marker.utf8), + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) +} + private final class PublicCaptureDelegate: BitchatDelegate { private let lock = NSLock() private(set) var publicMessages: [BitchatMessage] = [] diff --git a/bitchatTests/Noise/NoiseCoverageTests.swift b/bitchatTests/Noise/NoiseCoverageTests.swift index ab3b8641..4ff7a04a 100644 --- a/bitchatTests/Noise/NoiseCoverageTests.swift +++ b/bitchatTests/Noise/NoiseCoverageTests.swift @@ -12,8 +12,15 @@ struct NoiseCoverageTests { private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey() private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey() - private let alicePeerID = PeerID(str: "0011223344556677") - private let bobPeerID = PeerID(str: "8899aabbccddeeff") + // Manager test dictionaries are keyed by the remote peer. Keep the + // historical names, but derive each wire ID from the static key that the + // corresponding manager authenticates during the handshake. + private var alicePeerID: PeerID { + PeerID(publicKey: bobStaticKey.publicKey.rawRepresentation) + } + private var bobPeerID: PeerID { + PeerID(publicKey: aliceStaticKey.publicKey.rawRepresentation) + } private let charliePeerID = PeerID(str: "fedcba9876543210") @Test("Protocol metadata and handshake patterns expose expected values") diff --git a/bitchatTests/Services/BLENoisePacketHandlerTests.swift b/bitchatTests/Services/BLENoisePacketHandlerTests.swift index 0f40d316..42db3642 100644 --- a/bitchatTests/Services/BLENoisePacketHandlerTests.swift +++ b/bitchatTests/Services/BLENoisePacketHandlerTests.swift @@ -152,6 +152,21 @@ struct BLENoisePacketHandlerTests { #expect(recorder.initiatedHandshakes.isEmpty) } + @Test + func peerIdentityMismatchDoesNotRecreateHandshakeState() { + let recorder = Recorder() + recorder.handshakeResult = .failure(NoiseSessionError.peerIdentityMismatch) + recorder.hasSession = false + let handler = makeHandler(recorder: recorder) + let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id)) + + #expect(!handler.handleHandshake(packet, from: remotePeerID)) + + #expect(recorder.hasSessionQueries.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + #expect(recorder.broadcastPackets.isEmpty) + } + // MARK: Encrypted @Test diff --git a/bitchatTests/Services/NoiseEncryptionServiceTests.swift b/bitchatTests/Services/NoiseEncryptionServiceTests.swift index d4b0b79a..89f01446 100644 --- a/bitchatTests/Services/NoiseEncryptionServiceTests.swift +++ b/bitchatTests/Services/NoiseEncryptionServiceTests.swift @@ -91,39 +91,150 @@ struct NoiseEncryptionServiceTests { func handshakeEncryptionAndFingerprintLifecycle() async throws { let alice = NoiseEncryptionService(keychain: MockKeychain()) let bob = NoiseEncryptionService(keychain: MockKeychain()) - let alicePeerID = PeerID(str: "0011223344556677") - let bobPeerID = PeerID(str: "8899aabbccddeeff") + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) let recorder = AuthenticationRecorder() #expect(alice.onPeerAuthenticated == nil) alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:)) bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:) - try establishSessions(alice: alice, bob: bob, alicePeerID: alicePeerID, bobPeerID: bobPeerID) + try establishSessions(alice: alice, bob: bob) let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0) #expect(authenticated) - #expect(alice.hasEstablishedSession(with: alicePeerID)) - #expect(bob.hasEstablishedSession(with: bobPeerID)) - #expect(alice.hasSession(with: alicePeerID)) - #expect(bob.hasSession(with: bobPeerID)) - #expect(alice.getPeerPublicKeyData(alicePeerID)?.count == 32) - #expect(bob.getPeerPublicKeyData(bobPeerID)?.count == 32) - #expect(alice.getPeerFingerprint(alicePeerID) != nil) - #expect(bob.getPeerFingerprint(bobPeerID) != nil) + #expect(alice.hasEstablishedSession(with: bobPeerID)) + #expect(bob.hasEstablishedSession(with: alicePeerID)) + #expect(alice.hasSession(with: bobPeerID)) + #expect(bob.hasSession(with: alicePeerID)) + #expect(alice.getPeerPublicKeyData(bobPeerID)?.count == 32) + #expect(bob.getPeerPublicKeyData(alicePeerID)?.count == 32) + #expect(alice.getPeerFingerprint(bobPeerID) != nil) + #expect(bob.getPeerFingerprint(alicePeerID) != nil) let plaintext = Data("secret payload".utf8) - let ciphertext = try alice.encrypt(plaintext, for: alicePeerID) - let decrypted = try bob.decrypt(ciphertext, from: bobPeerID) + let ciphertext = try alice.encrypt(plaintext, for: bobPeerID) + let decrypted = try bob.decrypt(ciphertext, from: alicePeerID) #expect(decrypted == plaintext) - alice.clearSession(for: alicePeerID) - #expect(!alice.hasSession(with: alicePeerID)) - #expect(alice.getPeerFingerprint(alicePeerID) == nil) + alice.clearSession(for: bobPeerID) + #expect(!alice.hasSession(with: bobPeerID)) + #expect(alice.getPeerFingerprint(bobPeerID) == nil) bob.clearEphemeralStateForPanic() - #expect(!bob.hasSession(with: bobPeerID)) - #expect(bob.getPeerFingerprint(bobPeerID) == nil) + #expect(!bob.hasSession(with: alicePeerID)) + #expect(bob.getPeerFingerprint(alicePeerID) == nil) + } + + @Test("Handshake rejects a claimed peer ID that does not match the authenticated static key") + func handshakeRejectsClaimedPeerIDStaticKeyMismatch() async throws { + let receiver = NoiseEncryptionService(keychain: MockKeychain()) + let claimedAlice = NoiseEncryptionService(keychain: MockKeychain()) + let mallory = NoiseEncryptionService(keychain: MockKeychain()) + let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData()) + let claimedAlicePeerID = PeerID(publicKey: claimedAlice.getStaticPublicKeyData()) + let recorder = AuthenticationRecorder() + receiver.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:)) + + let message1 = try mallory.initiateHandshake(with: receiverPeerID) + let message2 = try #require( + try receiver.processHandshakeMessage(from: claimedAlicePeerID, message: message1) + ) + let message3 = try #require( + try mallory.processHandshakeMessage(from: receiverPeerID, message: message2) + ) + + do { + _ = try receiver.processHandshakeMessage(from: claimedAlicePeerID, message: message3) + Issue.record("Expected the authenticated Mallory key to be rejected for Alice's peer ID") + } catch let error as NoiseSessionError { + #expect(error == .peerIdentityMismatch) + } catch { + Issue.record("Unexpected mismatch error: \(error)") + } + + #expect(!receiver.hasSession(with: claimedAlicePeerID)) + let emittedAuthentication = await TestHelpers.waitUntil( + { recorder.count > 0 }, + timeout: TestConstants.shortTimeout + ) + #expect(!emittedAuthentication) + } + + @Test("Failed forged replacement preserves the established peer session") + func forgedReplacementPreservesEstablishedSession() async throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let receiver = NoiseEncryptionService(keychain: MockKeychain()) + let mallory = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData()) + let recorder = AuthenticationRecorder() + receiver.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:)) + + try establishSessions(alice: alice, bob: receiver) + let initialAuthentication = await TestHelpers.waitUntil( + { recorder.count == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(initialAuthentication) + + let before = try alice.encrypt(Data("before".utf8), for: receiverPeerID) + #expect(try receiver.decrypt(before, from: alicePeerID) == Data("before".utf8)) + + let forgedMessage1 = try mallory.initiateHandshake(with: receiverPeerID) + let forgedMessage2 = try #require( + try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage1) + ) + // The replacement has not authenticated yet; the working Alice + // transport session must remain available throughout the candidate. + #expect(receiver.hasEstablishedSession(with: alicePeerID)) + let forgedMessage3 = try #require( + try mallory.processHandshakeMessage(from: receiverPeerID, message: forgedMessage2) + ) + + do { + _ = try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage3) + Issue.record("Expected forged replacement to fail peer binding") + } catch let error as NoiseSessionError { + #expect(error == .peerIdentityMismatch) + } catch { + Issue.record("Unexpected replacement error: \(error)") + } + + #expect(receiver.hasEstablishedSession(with: alicePeerID)) + let after = try alice.encrypt(Data("after".utf8), for: receiverPeerID) + #expect(try receiver.decrypt(after, from: alicePeerID) == Data("after".utf8)) + let emittedReplacementAuthentication = await TestHelpers.waitUntil( + { recorder.count > 1 }, + timeout: TestConstants.shortTimeout + ) + #expect(!emittedReplacementAuthentication) + } + + @Test("Valid rehandshake atomically replaces the established session") + func validRehandshakeReplacesEstablishedSession() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let receiver = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData()) + + try establishSessions(alice: alice, bob: receiver) + alice.clearSession(for: receiverPeerID) + + let message1 = try alice.initiateHandshake(with: receiverPeerID) + let message2 = try #require( + try receiver.processHandshakeMessage(from: alicePeerID, message: message1) + ) + #expect(receiver.hasEstablishedSession(with: alicePeerID)) + let message3 = try #require( + try alice.processHandshakeMessage(from: receiverPeerID, message: message2) + ) + _ = try receiver.processHandshakeMessage(from: alicePeerID, message: message3) + + #expect(alice.hasEstablishedSession(with: receiverPeerID)) + #expect(receiver.hasEstablishedSession(with: alicePeerID)) + let ciphertext = try alice.encrypt(Data("new session".utf8), for: receiverPeerID) + #expect(try receiver.decrypt(ciphertext, from: alicePeerID) == Data("new session".utf8)) } @Test("Encrypt without a session requests handshake and decrypt without session fails") @@ -200,16 +311,16 @@ struct NoiseEncryptionServiceTests { private func establishSessions( alice: NoiseEncryptionService, - bob: NoiseEncryptionService, - alicePeerID: PeerID, - bobPeerID: PeerID + bob: NoiseEncryptionService ) throws { - let message1 = try alice.initiateHandshake(with: alicePeerID) - let response = try bob.processHandshakeMessage(from: bobPeerID, message: message1) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + let message1 = try alice.initiateHandshake(with: bobPeerID) + let response = try bob.processHandshakeMessage(from: alicePeerID, message: message1) let message2 = try #require(response, "Expected handshake response") - let final = try alice.processHandshakeMessage(from: alicePeerID, message: message2) + let final = try alice.processHandshakeMessage(from: bobPeerID, message: message2) let message3 = try #require(final, "Expected handshake final") - let finalMessage = try bob.processHandshakeMessage(from: bobPeerID, message: message3) + let finalMessage = try bob.processHandshakeMessage(from: alicePeerID, message: message3) #expect(finalMessage == nil) } }