From 16324c819f745381afab794bda0ec61d9119f87b Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:30:55 +0200 Subject: [PATCH] Bind Noise sessions to claimed peer identities; require signed leaves (#1432) Adds remote-static-key->peerID binding at Noise handshake completion (closes a mesh impersonation/MITM hole where a peer could complete a handshake under another peer's ID). Also hardens LEAVE handling to require a verified signature and suppresses relay of unverifiable leaves. --- bitchat/Noise/NoiseSecurityConstants.swift | 3 + bitchat/Noise/NoiseSessionError.swift | 1 + bitchat/Noise/NoiseSessionManager.swift | 164 +++++++++++---- .../Services/BLE/BLENoisePacketHandler.swift | 56 +++++- bitchat/Services/BLE/BLEService.swift | 69 ++++++- bitchat/Services/NoiseEncryptionService.swift | 19 +- bitchatTests/BLEServiceCoreTests.swift | 189 ++++++++++++++++++ bitchatTests/Noise/NoiseCoverageTests.swift | 11 +- .../Services/BLENoisePacketHandlerTests.swift | 40 +++- .../NoiseEncryptionServiceTests.swift | 161 ++++++++++++--- 10 files changed, 637 insertions(+), 76 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..ca2d8217 100644 --- a/bitchat/Noise/NoiseSessionManager.swift +++ b/bitchat/Noise/NoiseSessionManager.swift @@ -11,8 +11,18 @@ import CryptoKit import Foundation import BitFoundation +struct NoiseHandshakeProcessingResult { + let response: Data? + let didEstablishAuthenticatedSession: Bool +} + 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 +64,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 +75,11 @@ final class NoiseSessionManager { for (_, session) in sessions { session.reset() } + for (_, candidate) in responderCandidates { + candidate.reset() + } sessions.removeAll() + responderCandidates.removeAll() } } @@ -79,6 +96,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 +109,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 } @@ -98,61 +117,116 @@ final class NoiseSessionManager { } func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? { + try handleIncomingHandshakeWithResult( + from: peerID, + message: message + ).response + } + + /// Processes one exact handshake candidate and reports whether that + /// candidate completed authenticated establishment. The peer's retained + /// session may already be established while a replacement is only on + /// message one, so callers must not infer candidate completion from the + /// peer-level session table. + func handleIncomingHandshakeWithResult( + from peerID: PeerID, + message: Data + ) throws -> NoiseHandshakeProcessingResult { // 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 do { let response = try session.processHandshakeMessage(message) - // 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) + // Check the exact session that processed this message. A + // preserved peer-level session can remain established while a + // replacement candidate is still unauthenticated. + let didEstablishAuthenticatedSession = session.isEstablished() + if didEstablishAuthenticatedSession { + 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 + return NoiseHandshakeProcessingResult( + response: response, + didEstablishAuthenticatedSession: + didEstablishAuthenticatedSession + ) } 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 +238,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..674a7bd5 100644 --- a/bitchat/Services/BLE/BLENoisePacketHandler.swift +++ b/bitchat/Services/BLE/BLENoisePacketHandler.swift @@ -2,6 +2,11 @@ import BitFoundation import BitLogger import Foundation +struct BLENoiseHandshakeHandlingResult { + let processed: Bool + let didEstablishAuthenticatedSession: Bool +} + /// Narrow environment for `BLENoisePacketHandler`. /// /// All queue hops (collections barrier writes, main-actor UI notification) @@ -16,8 +21,11 @@ struct BLENoisePacketHandlerEnvironment { let messageTTL: UInt8 /// Current time source. let now: () -> Date - /// Processes an inbound handshake message, returning an optional response payload (crypto). - let processHandshakeMessage: (_ peerID: PeerID, _ message: Data) throws -> Data? + /// Processes an inbound handshake message, returning its optional response + /// and whether that exact candidate authenticated (crypto). + let processHandshakeMessage: + (_ peerID: PeerID, _ message: Data) throws + -> NoiseHandshakeProcessingResult /// Whether any Noise session (established or pending) exists for the peer (crypto). let hasNoiseSession: (PeerID) -> Bool /// Initiates a fresh Noise handshake with the peer (crypto + send). @@ -49,13 +57,28 @@ 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 { + handleHandshakeWithResult(packet, from: peerID).processed + } + + func handleHandshakeWithResult( + _ packet: BitchatPacket, + from peerID: PeerID + ) -> BLENoiseHandshakeHandlingResult { let env = environment // Use NoiseEncryptionService for handshake processing if PeerID(hexData: packet.recipientID) == env.localPeerID() { // Handshake is for us do { - if let response = try env.processHandshakeMessage(peerID, packet.payload) { + let result = try env.processHandshakeMessage( + peerID, + packet.payload + ) + if let response = result.response { // Send response let responsePacket = BitchatPacket( type: MessageType.noiseHandshake.rawValue, @@ -72,14 +95,39 @@ final class BLENoisePacketHandler { // Session establishment will trigger onPeerAuthenticated callback // which will send any pending messages at the right time + return BLENoiseHandshakeHandlingResult( + processed: true, + didEstablishAuthenticatedSession: + result.didEstablishAuthenticatedSession + ) + } 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 BLENoiseHandshakeHandlingResult( + processed: false, + didEstablishAuthenticatedSession: false + ) } catch { SecureLogger.error("Failed to process handshake: \(error)") // Try initiating a new handshake if !env.hasNoiseSession(peerID) { env.initiateHandshake(peerID) } + return BLENoiseHandshakeHandlingResult( + processed: false, + didEstablishAuthenticatedSession: false + ) } } + return BLENoiseHandshakeHandlingResult( + processed: false, + didEstablishAuthenticatedSession: false + ) } func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) { diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index f38e090a..af4bc601 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -1792,7 +1792,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) @@ -1809,6 +1846,7 @@ final class BLEService: NSObject { self.deliverTransportEvent(.peerDisconnected(peerID)) self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) } + return true } private func sendAnnounce(forceSend: Bool = false) { guard !isPanicSuspended else { return } @@ -2553,6 +2591,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( @@ -5084,7 +5128,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) @@ -5724,9 +5770,11 @@ 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 result = noisePacketHandler.handleHandshakeWithResult( + packet, + from: peerID + ) + if result.didEstablishAuthenticatedSession { markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID) } } @@ -5749,7 +5797,16 @@ extension BLEService { messageTTL: messageTTL, now: { Date() }, processHandshakeMessage: { [weak self] peerID, message in - try self?.noiseService.processHandshakeMessage(from: peerID, message: message) + guard let self else { + return NoiseHandshakeProcessingResult( + response: nil, + didEstablishAuthenticatedSession: false + ) + } + return try self.noiseService.processHandshakeMessageWithResult( + from: peerID, + message: message + ) }, hasNoiseSession: { [weak self] peerID in self?.noiseService.hasSession(with: peerID) ?? false diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 3e0aae6b..b5171b2a 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -664,6 +664,18 @@ final class NoiseEncryptionService { /// Process an incoming handshake message func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? { + try processHandshakeMessageWithResult( + from: peerID, + message: message + ).response + } + + /// Process an incoming handshake message and report whether the exact + /// session that consumed it completed authenticated establishment. + func processHandshakeMessageWithResult( + from peerID: PeerID, + message: Data + ) throws -> NoiseHandshakeProcessingResult { // Validate peer ID guard peerID.isValid else { @@ -685,11 +697,14 @@ final class NoiseEncryptionService { // For handshakes, we process the raw data directly without NoiseMessage wrapper // The Noise protocol handles its own message format - let responsePayload = try sessionManager.handleIncomingHandshake(from: peerID, message: message) + let result = try sessionManager.handleIncomingHandshakeWithResult( + from: peerID, + message: message + ) // Return raw response without wrapper - return responsePayload + return result } /// Check if we have an established session with a peer diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift index 28734f3d..a6a3a85b 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() @@ -440,6 +529,94 @@ struct BLEServiceCoreTests { #expect(outbound.count(ofType: .courierEnvelope) == 0) } + @Test + func replacementXXMessageOneWithPayloadCannotAuthenticateIngressLink() async throws { + let ble = makeService() + let victim = NoiseEncryptionService(keychain: MockKeychain()) + let victimPeerID = PeerID(publicKey: victim.getStaticPublicKeyData()) + + // Preserve a working victim session while an unauthenticated + // replacement candidate arrives on a newly bound physical link. + let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID) + let message2 = try #require( + try victim.processHandshakeMessage(from: ble.myPeerID, message: message1) + ) + let message3 = try #require( + try ble._test_noiseProcessHandshakeMessage( + from: victimPeerID, + message: message2 + ) + ) + _ = try victim.processHandshakeMessage( + from: ble.myPeerID, + message: message3 + ) + #expect(ble.canDeliverSecurely(to: victimPeerID)) + + let centralUUID = "central-replacement-xx-message-one" + ble._test_bindCentral(centralUUID, to: victimPeerID) + #expect( + !ble._test_isNoiseAuthenticatedCentral( + centralUUID, + for: victimPeerID + ) + ) + + // XX message one may legally carry a payload, so its length is not a + // reliable signal that the replacement handshake completed. + let unauthenticatedInitiator = NoiseHandshakeState( + role: .initiator, + pattern: .XX, + keychain: MockKeychain() + ) + let replacementMessage1 = try unauthenticatedInitiator.writeMessage( + payload: Data([0xA5]) + ) + #expect( + replacementMessage1.count + > NoiseSecurityConstants.xxInitialMessageSize + ) + + let packet = BitchatPacket( + type: MessageType.noiseHandshake.rawValue, + senderID: Data(hexString: victimPeerID.id) ?? Data(), + recipientID: Data(hexString: ble.myPeerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: replacementMessage1, + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + #expect( + ble._test_recordIngressIfNew( + packet: packet, + linkID: centralUUID + ) + ) + + let outbound = OutboundPacketTap() + ble._test_onOutboundPacket = outbound.record + ble._test_handlePacket( + packet, + fromPeerID: victimPeerID, + preseedPeer: false + ) + + // Waiting for the responder's message two proves the candidate was + // processed before checking its exact authentication result. + let candidateProcessed = await TestHelpers.waitUntil( + { outbound.count(ofType: .noiseHandshake) == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(candidateProcessed) + #expect( + !ble._test_isNoiseAuthenticatedCentral( + centralUUID, + for: victimPeerID + ) + ) + #expect(ble.canDeliverSecurely(to: victimPeerID)) + } + /// A legitimate rotation announce necessarily arrives on a link still /// bound to the OLD ID, so its registry upsert stores the new peer /// disconnected. The successful rebind must promote it: a healed @@ -854,6 +1031,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..0365de5b 100644 --- a/bitchatTests/Services/BLENoisePacketHandlerTests.swift +++ b/bitchatTests/Services/BLENoisePacketHandlerTests.swift @@ -8,6 +8,7 @@ struct BLENoisePacketHandlerTests { private final class Recorder { var handshakeResult: Result = .success(nil) + var handshakeAuthenticated = false var hasSession = false var decryptResult: Result = .success(Data()) @@ -38,7 +39,11 @@ struct BLENoisePacketHandlerTests { now: { now }, processHandshakeMessage: { peerID, message in recorder.processedHandshakes.append((peerID, message)) - return try recorder.handshakeResult.get() + return NoiseHandshakeProcessingResult( + response: try recorder.handshakeResult.get(), + didEstablishAuthenticatedSession: + recorder.handshakeAuthenticated + ) }, hasNoiseSession: { peerID in recorder.hasSessionQueries.append(peerID) @@ -110,6 +115,24 @@ struct BLENoisePacketHandlerTests { #expect(recorder.initiatedHandshakes.isEmpty) } + @Test + func handshakeResultPreservesExactCandidateAuthentication() { + let recorder = Recorder() + recorder.handshakeAuthenticated = true + let handler = makeHandler(recorder: recorder) + let packet = makeHandshakePacket( + recipientID: Data(hexString: localPeerID.id) + ) + + let result = handler.handleHandshakeWithResult( + packet, + from: remotePeerID + ) + + #expect(result.processed) + #expect(result.didEstablishAuthenticatedSession) + } + @Test func handshakeForAnotherPeerIsIgnored() { let recorder = Recorder() @@ -152,6 +175,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) } }