import Foundation import Testing import BitFoundation @testable import bitchat @Suite("NoiseEncryptionService Tests", .serialized) struct NoiseEncryptionServiceTests { @Test("Encryption status accessors cover all cases") func encryptionStatusAccessorsCoverAllCases() { #expect(EncryptionStatus.none.icon == "lock.slash") #expect(EncryptionStatus.noHandshake.icon == nil) #expect(EncryptionStatus.noiseHandshaking.icon == "lock.rotation") #expect(EncryptionStatus.noiseSecured.icon == "lock.fill") #expect(EncryptionStatus.noiseVerified.icon == "checkmark.seal.fill") #expect(!EncryptionStatus.none.description.isEmpty) #expect(!EncryptionStatus.noHandshake.description.isEmpty) #expect(!EncryptionStatus.noiseHandshaking.description.isEmpty) #expect(!EncryptionStatus.noiseSecured.description.isEmpty) #expect(!EncryptionStatus.noiseVerified.description.isEmpty) #expect(!EncryptionStatus.none.accessibilityDescription.isEmpty) #expect(!EncryptionStatus.noHandshake.accessibilityDescription.isEmpty) #expect(!EncryptionStatus.noiseHandshaking.accessibilityDescription.isEmpty) #expect(!EncryptionStatus.noiseSecured.accessibilityDescription.isEmpty) #expect(!EncryptionStatus.noiseVerified.accessibilityDescription.isEmpty) } @Test("Announce and packet signatures round-trip and detect tampering") func announceAndPacketSignaturesRoundTrip() throws { let service = NoiseEncryptionService(keychain: MockKeychain()) let signingPublicKey = service.getSigningPublicKeyData() let noisePublicKey = service.getStaticPublicKeyData() let signature = try #require( service.buildAnnounceSignature( peerID: Data([0xAA, 0xBB]), noiseKey: noisePublicKey, ed25519Key: signingPublicKey, nickname: "Alice", timestampMs: 12345 ), "Expected announce signature" ) #expect( service.verifyAnnounceSignature( signature: signature, peerID: Data([0xAA, 0xBB]), noiseKey: noisePublicKey, ed25519Key: signingPublicKey, nickname: "Alice", timestampMs: 12345, publicKey: signingPublicKey ) ) #expect( !service.verifyAnnounceSignature( signature: signature, peerID: Data([0xAA, 0xBB]), noiseKey: noisePublicKey, ed25519Key: signingPublicKey, nickname: "Mallory", timestampMs: 12345, publicKey: signingPublicKey ) ) #expect(!service.verifySignature(signature, for: Data("data".utf8), publicKey: Data([1, 2, 3]))) let packet = BitchatPacket( type: MessageType.announce.rawValue, senderID: Data([0, 1, 2, 3, 4, 5, 6, 7]), recipientID: nil, timestamp: 42, payload: Data("payload".utf8), signature: nil, ttl: 7 ) let signedPacket = try #require(service.signPacket(packet), "Expected signed packet") #expect(service.verifyPacketSignature(signedPacket, publicKey: signingPublicKey)) #expect(!service.verifyPacketSignature(packet, publicKey: signingPublicKey)) var tampered = signedPacket tampered.signature = Data(repeating: 0xFF, count: 64) #expect(!service.verifyPacketSignature(tampered, publicKey: signingPublicKey)) } @Test("Service-level handshake, encryption, and fingerprint lifecycle work") func handshakeEncryptionAndFingerprintLifecycle() async throws { let alice = NoiseEncryptionService(keychain: MockKeychain()) let bob = NoiseEncryptionService(keychain: MockKeychain()) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) let recorder = AuthenticationRecorder() #expect(alice.onPeerAuthenticated == nil) #expect(bob.onPeerAuthenticatedWithGeneration == nil) alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:)) bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:) bob.onPeerAuthenticatedWithGeneration = recorder.record( peerID:fingerprint:sessionGeneration: ) try establishSessions(alice: alice, bob: bob) let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0) #expect(authenticated) let generationAuthenticated = await TestHelpers.waitUntil( { recorder.generationCount >= 1 }, timeout: 5.0 ) #expect(generationAuthenticated) #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) #expect(recorder.generation(for: alicePeerID) == bob.sessionGeneration(for: alicePeerID)) let plaintext = Data("secret payload".utf8) let ciphertext = try alice.encrypt(plaintext, for: bobPeerID) let decrypted = try bob.decrypt(ciphertext, from: alicePeerID) #expect(decrypted == plaintext) alice.clearSession(for: bobPeerID) #expect(!alice.hasSession(with: bobPeerID)) #expect(alice.getPeerFingerprint(bobPeerID) == nil) bob.clearEphemeralStateForPanic() #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 reconnect restores the established peer session") func forgedReconnectRestoresEstablishedSession() 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) ) // Outbound/session-generation APIs fail closed while the old // transport is retained solely for receive and bounded rollback. #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 reconnect to fail peer binding") } catch let error as NoiseSessionError { #expect(error == .peerIdentityMismatch) } catch { Issue.record("Unexpected reconnect 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 ordinary rehandshake atomically replaces the established session") func validOrdinaryRehandshakeReplacesEstablishedSession() 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("Automatic rekey exposes and completes its exact handshake bytes") func automaticRekeyHandshakeIsNotStranded() throws { let alice = NoiseEncryptionService(keychain: MockKeychain()) let bob = NoiseEncryptionService(keychain: MockKeychain()) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) try establishSessions(alice: alice, bob: bob) let originalGeneration = try #require(alice.sessionGeneration(for: bobPeerID)) var leaseRan = false let leased = alice.withCurrentSessionGeneration( for: bobPeerID, expected: originalGeneration ) { leaseRan = true return true } #expect(leased == true) #expect(leaseRan) var emittedPeerID: PeerID? var emittedInitiation: NoiseHandshakeInitiation? alice.onRekeyHandshakeReady = { peerID, initiation in emittedPeerID = peerID emittedInitiation = initiation } try alice._test_initiateAutomaticRekey(for: bobPeerID) #expect(emittedPeerID == bobPeerID) #expect(alice.sessionGeneration(for: bobPeerID) == nil) leaseRan = false let staleLease = alice.withCurrentSessionGeneration( for: bobPeerID, expected: originalGeneration ) { leaseRan = true return true } #expect(staleLease == nil) #expect(!leaseRan) let initiation = try #require(emittedInitiation) let message1 = try #require( alice.claimHandshakeInitiation(initiation, for: bobPeerID) ) #expect(!message1.isEmpty) #expect(alice.hasSession(with: bobPeerID)) #expect(!alice.hasEstablishedSession(with: bobPeerID)) let message2 = try #require( try bob.processHandshakeMessage(from: alicePeerID, message: message1) ) let message3 = try #require( try alice.processHandshakeMessage(from: bobPeerID, message: message2) ) _ = try bob.processHandshakeMessage(from: alicePeerID, message: message3) #expect(alice.hasEstablishedSession(with: bobPeerID)) #expect(bob.hasEstablishedSession(with: alicePeerID)) #expect(alice.sessionGeneration(for: bobPeerID) != originalGeneration) } @Test("Large private-file payloads use the bounded Noise extension") func largePrivateFileNoiseRoundTrip() throws { let alice = NoiseEncryptionService(keychain: MockKeychain()) let bob = NoiseEncryptionService(keychain: MockKeychain()) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) try establishSessions(alice: alice, bob: bob) let content = Data("%PDF-1.7\n".utf8) + Data(repeating: 0x51, count: 96 * 1024) let file = BitchatFilePacket( fileName: "large-private.pdf", fileSize: UInt64(content.count), mimeType: "application/pdf", content: content ) let typedPayload = try #require(BLENoisePayloadFactory.privateFile(file)) #expect(typedPayload.count > NoiseSecurityConstants.maxMessageSize) #expect(typedPayload.first == NoisePayloadType.privateFile.rawValue) #expect( typedPayload.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize, "typedBytes=\(typedPayload.count) limit=\(NoiseSecurityConstants.maxPrivateFilePlaintextSize)" ) do { _ = try alice.encrypt(typedPayload, for: bobPeerID) Issue.record("Ordinary Noise payload path must retain its 64 KiB ceiling") } catch NoiseSecurityError.messageTooLarge { // Expected: only the purpose-specific private-file API may extend it. } let ciphertext: Data do { ciphertext = try alice.encryptPrivateFilePayload(typedPayload, for: bobPeerID) } catch { Issue.record("Private-file encryption failed: \(error)") return } let decrypted: Data do { decrypted = try bob.decrypt(ciphertext, from: alicePeerID) } catch { Issue.record("Private-file decryption failed: \(error); ciphertextBytes=\(ciphertext.count)") return } #expect(ciphertext.range(of: content) == nil) #expect(decrypted == typedPayload) } @Test("Concurrent BLE starts preserve one ordinary attempt") func duplicateHandshakeIfNeededPreservesFirstAttempt() throws { let alice = NoiseEncryptionService(keychain: MockKeychain()) let bob = NoiseEncryptionService(keychain: MockKeychain()) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) let starts = HandshakeInitiationRecorder() DispatchQueue.concurrentPerform(iterations: 20) { _ in do { starts.record( try alice.initiateHandshakeIfNeeded(with: bobPeerID) ) } catch { starts.record(error: error) } } #expect(starts.errorCount == 0) let attempt = try #require(starts.initiations.first) #expect(starts.initiations.count == 1) let message1 = try #require( alice.claimHandshakeInitiation(attempt, for: bobPeerID) ) let message2 = try #require( try bob.processHandshakeMessage(from: alicePeerID, message: message1) ) let message3 = try #require( try alice.processHandshakeMessage(from: bobPeerID, message: message2) ) _ = try bob.processHandshakeMessage(from: alicePeerID, message: message3) let ciphertext = try alice.encrypt( Data("one atomic start".utf8), for: bobPeerID ) #expect( try bob.decrypt(ciphertext, from: alicePeerID) == Data("one atomic start".utf8) ) } @Test("Restarted peer establishes against a retained ordinary session") func restartedPeerCompletesRetainedRemoteRehandshake() throws { let aliceKeychain = MockKeychain() let alice = NoiseEncryptionService(keychain: aliceKeychain) let bob = NoiseEncryptionService(keychain: MockKeychain()) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) try establishSessions(alice: alice, bob: bob) let restartedAlice = NoiseEncryptionService(keychain: aliceKeychain) let attempt = try #require( try restartedAlice.initiateHandshakeIfNeeded(with: bobPeerID) ) #expect( try restartedAlice.initiateHandshakeIfNeeded(with: bobPeerID) == nil ) let message1 = try #require( restartedAlice.claimHandshakeInitiation( attempt, for: bobPeerID ) ) let message2 = try #require( try bob.processHandshakeMessage(from: alicePeerID, message: message1) ) let message3 = try #require( try restartedAlice.processHandshakeMessage( from: bobPeerID, message: message2 ) ) _ = try bob.processHandshakeMessage(from: alicePeerID, message: message3) let forward = try restartedAlice.encrypt( Data("after restart".utf8), for: bobPeerID ) #expect( try bob.decrypt(forward, from: alicePeerID) == Data("after restart".utf8) ) } @Test("Crossed ordinary initiations choose one deterministic initiator") func crossedOrdinaryInitiationsResolveDeterministically() throws { let endpoints = orderedServices() let lowerAttempt = try #require( try endpoints.lower.initiateHandshakeIfNeeded( with: endpoints.higherPeerID ) ) let higherAttempt = try #require( try endpoints.higher.initiateHandshakeIfNeeded( with: endpoints.lowerPeerID ) ) let lowerMessage1 = try #require( endpoints.lower.claimHandshakeInitiation( lowerAttempt, for: endpoints.higherPeerID ) ) let higherMessage1 = try #require( endpoints.higher.claimHandshakeInitiation( higherAttempt, for: endpoints.lowerPeerID ) ) #expect( try endpoints.lower.processHandshakeMessage( from: endpoints.higherPeerID, message: higherMessage1 ) == nil ) let message2 = try #require( try endpoints.higher.processHandshakeMessage( from: endpoints.lowerPeerID, message: lowerMessage1 ) ) let message3 = try #require( try endpoints.lower.processHandshakeMessage( from: endpoints.higherPeerID, message: message2 ) ) _ = try endpoints.higher.processHandshakeMessage( from: endpoints.lowerPeerID, message: message3 ) let ciphertext = try endpoints.lower.encrypt( Data("crossed".utf8), for: endpoints.higherPeerID ) #expect( try endpoints.higher.decrypt( ciphertext, from: endpoints.lowerPeerID ) == Data("crossed".utf8) ) } @Test("Delayed losing message one cannot replace the fresh winner") func delayedCrossedInitiationIsSuppressed() throws { let endpoints = orderedServices() let lowerAttempt = try #require( try endpoints.lower.initiateHandshakeIfNeeded( with: endpoints.higherPeerID ) ) let higherAttempt = try #require( try endpoints.higher.initiateHandshakeIfNeeded( with: endpoints.lowerPeerID ) ) let lowerMessage1 = try #require( endpoints.lower.claimHandshakeInitiation( lowerAttempt, for: endpoints.higherPeerID ) ) let delayedHigherMessage1 = try #require( endpoints.higher.claimHandshakeInitiation( higherAttempt, for: endpoints.lowerPeerID ) ) let message2 = try #require( try endpoints.higher.processHandshakeMessage( from: endpoints.lowerPeerID, message: lowerMessage1 ) ) let message3 = try #require( try endpoints.lower.processHandshakeMessage( from: endpoints.higherPeerID, message: message2 ) ) #expect( try endpoints.lower.processHandshakeMessage( from: endpoints.higherPeerID, message: delayedHigherMessage1 ) == nil ) _ = try endpoints.higher.processHandshakeMessage( from: endpoints.lowerPeerID, message: message3 ) let ciphertext = try endpoints.higher.encrypt( Data("winner intact".utf8), for: endpoints.lowerPeerID ) #expect( try endpoints.lower.decrypt( ciphertext, from: endpoints.higherPeerID ) == Data("winner intact".utf8) ) } @Test("Automatic rekey token dies if an inbound initiation wins first") func automaticRekeyClaimsOnlyAtTransportHandoff() throws { let endpoints = orderedServices() try establishSessions( alice: endpoints.lower, bob: endpoints.higher ) var preparedRekey: NoiseHandshakeInitiation? endpoints.higher.onRekeyHandshakeReady = { _, initiation in preparedRekey = initiation } try endpoints.higher._test_initiateAutomaticRekey( for: endpoints.lowerPeerID ) let staleRekey = try #require(preparedRekey) let winningAttempt = try endpoints.lower.initiateReconnectHandshake( with: endpoints.higherPeerID ) let winningMessage1 = try #require( endpoints.lower.claimHandshakeInitiation( winningAttempt, for: endpoints.higherPeerID ) ) let message2 = try #require( try endpoints.higher.processHandshakeMessage( from: endpoints.lowerPeerID, message: winningMessage1 ) ) #expect( endpoints.higher.claimHandshakeInitiation( staleRekey, for: endpoints.lowerPeerID ) == nil ) let message3 = try #require( try endpoints.lower.processHandshakeMessage( from: endpoints.higherPeerID, message: message2 ) ) _ = try endpoints.higher.processHandshakeMessage( from: endpoints.lowerPeerID, message: message3 ) #expect( endpoints.higher.hasEstablishedSession( with: endpoints.lowerPeerID ) ) } @Test("Ordinary timeout produces exactly one bounded retry") func ordinaryInitiationTimeoutIsBounded() async throws { let service = NoiseEncryptionService( keychain: MockKeychain(), ordinaryHandshakeTimeout: 0.03 ) let peerID = PeerID(str: "1021324354657687") let recorder = HandshakeStartRecorder() service.onHandshakeRecoveryRequired = { [weak service] request in guard let service else { return } do { let payload = try claimPreparedRecoveryPayload( service, request: request ) recorder.recordTimeout() recorder.record(message: payload) } catch { recorder.record(error: error) } } let first = try #require( try service.initiateHandshakeIfNeeded( with: peerID, retryOnTimeout: true ) ) #expect( service.claimHandshakeInitiation(first, for: peerID) != nil ) let retried = await TestHelpers.waitUntil( { recorder.messages.count == 1 }, timeout: 1 ) #expect(retried) let retryExpired = await TestHelpers.waitUntil( { !service.hasSession(with: peerID) }, timeout: 1 ) #expect(retryExpired) #expect(recorder.timeoutCount == 1) #expect(recorder.errorCount == 0) } @Test("Claim gives an attempt a full on-wire timeout window") func handshakeClaimRearmsDeadline() async throws { let service = NoiseEncryptionService( keychain: MockKeychain(), ordinaryHandshakeTimeout: 0.08 ) let peerID = PeerID(str: "1021324354657687") let recorder = HandshakeStartRecorder() service.onHandshakeRecoveryRequired = { [weak service] request in service?.cancelHandshakeRecovery(request) recorder.recordTimeout() } let attempt = try #require( try service.initiateHandshakeIfNeeded( with: peerID, retryOnTimeout: true ) ) try? await Task.sleep(nanoseconds: 50_000_000) #expect( service.claimHandshakeInitiation(attempt, for: peerID) == attempt.payload ) try? await Task.sleep(nanoseconds: 45_000_000) #expect(service.hasSession(with: peerID)) #expect(recorder.timeoutCount == 0) let expired = await TestHelpers.waitUntil( { recorder.timeoutCount == 1 }, timeout: 1 ) #expect(expired) } @Test("Duplicate spoofed message one cannot extend rollback or repause during cooldown") func pacedMessageOneCannotHoldOutboundPaused() async throws { let alice = NoiseEncryptionService(keychain: MockKeychain()) let bob = NoiseEncryptionService( keychain: MockKeychain(), ordinaryResponderHandshakeTimeout: 0.06, ordinaryReconnectRollbackCooldown: 0.3 ) let mallory = NoiseEncryptionService(keychain: MockKeychain()) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) let recovery = HandshakeStartRecorder() bob.onHandshakeRecoveryRequired = { [weak bob] request in bob?.cancelHandshakeRecovery(request) recovery.recordTimeout() } try establishSessions(alice: alice, bob: bob) let spoofedMessage1 = try mallory.initiateHandshake(with: bobPeerID) _ = try #require( try bob.processHandshakeMessage( from: alicePeerID, message: spoofedMessage1 ) ) // Exercise replacement before yielding: the test runner may resume a // short sleep after the fixed responder deadline under parallel load. _ = try #require( try bob.processHandshakeMessage( from: alicePeerID, message: spoofedMessage1 ) ) let restored = await TestHelpers.waitUntil( { bob.hasEstablishedSession(with: alicePeerID) }, timeout: 1 ) #expect(restored) let callbackArrived = await TestHelpers.waitUntil( { recovery.timeoutCount == 1 }, timeout: 1 ) #expect(callbackArrived) // Still inside cooldown: the same unauthenticated initiation is // coalesced without removing the restored outbound generation. #expect( try bob.processHandshakeMessage( from: alicePeerID, message: spoofedMessage1 ) == nil ) #expect(bob.hasEstablishedSession(with: alicePeerID)) let ciphertext = try alice.encrypt( Data("not repaused".utf8), for: bobPeerID ) #expect( try bob.decrypt(ciphertext, from: alicePeerID) == Data("not repaused".utf8) ) try? await Task.sleep(nanoseconds: 100_000_000) #expect(recovery.timeoutCount == 1) } @Test("Lost reconnect message three restores then retries once") func lostReconnectCompletionGetsOneLocalRetry() async throws { let alice = NoiseEncryptionService( keychain: MockKeychain(), ordinaryHandshakeTimeout: 0.04 ) let bob = NoiseEncryptionService( keychain: MockKeychain(), ordinaryHandshakeTimeout: 0.04, ordinaryResponderHandshakeTimeout: 0.04 ) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) try establishSessions(alice: alice, bob: bob) alice.clearSession(for: bobPeerID) let recovery = HandshakeStartRecorder() bob.onHandshakeRecoveryRequired = { [weak bob] request in guard let bob else { return } do { recovery.recordTimeout() recovery.record( message: try claimPreparedRecoveryPayload( bob, request: request ) ) } catch { recovery.record(error: error) } } let message1 = try alice.initiateHandshake(with: bobPeerID) let message2 = try #require( try bob.processHandshakeMessage(from: alicePeerID, message: message1) ) _ = try #require( try alice.processHandshakeMessage(from: bobPeerID, message: message2) ) // Drop message 3. Bob restores its old receive-only transport and // initiates one bounded convergence retry; drop that message 1 too. let retryPrepared = await TestHelpers.waitUntil( { recovery.messages.count == 1 }, timeout: 1 ) #expect(retryPrepared) let retryExpired = await TestHelpers.waitUntil( { !bob.hasSession(with: alicePeerID) }, timeout: 1 ) #expect(retryExpired) #expect(recovery.timeoutCount == 1) #expect(recovery.errorCount == 0) } @Test("Deterministic responder recovers once from an always-yield peer") func yieldedResponderRecoversFromLegacyDoubleYield() async throws { let endpoints = orderedServices( ordinaryHandshakeTimeout: 0.08, ordinaryResponderHandshakeTimeout: 0.08 ) let modern = endpoints.higher let legacy = endpoints.lower let recovery = HandshakeStartRecorder() modern.onHandshakeRecoveryRequired = { [weak modern] request in guard let modern else { return } do { recovery.recordTimeout() recovery.record( message: try claimPreparedRecoveryPayload( modern, request: request ) ) } catch { recovery.record(error: error) } } let modernAttempt = try #require( try modern.initiateHandshakeIfNeeded( with: endpoints.lowerPeerID, retryOnTimeout: true ) ) let legacyAttempt = try #require( try legacy.initiateHandshakeIfNeeded( with: endpoints.higherPeerID ) ) let modernMessage1 = try #require( modern.claimHandshakeInitiation( modernAttempt, for: endpoints.lowerPeerID ) ) let legacyMessage1 = try #require( legacy.claimHandshakeInitiation( legacyAttempt, for: endpoints.higherPeerID ) ) let modernMessage2 = try #require( try modern.processHandshakeMessage( from: endpoints.lowerPeerID, message: legacyMessage1 ) ) // Released peers yielded regardless of ID. Clearing their local // initiator reproduces that role choice without changing wire bytes. legacy.clearSession(for: endpoints.higherPeerID) let legacyMessage2 = try #require( try legacy.processHandshakeMessage( from: endpoints.higherPeerID, message: modernMessage1 ) ) do { _ = try modern.processHandshakeMessage( from: endpoints.lowerPeerID, message: legacyMessage2 ) Issue.record("Expected the crossed responder message to fail") } catch is NoiseManagedHandshakeFailure { // The manager owns the single retry. } catch { Issue.record("Unexpected managed failure: \(error)") } do { _ = try legacy.processHandshakeMessage( from: endpoints.higherPeerID, message: modernMessage2 ) Issue.record("Expected the legacy responder message to fail") } catch { // Expected; this side did not own retry intent. } let didRecover = await TestHelpers.waitUntil( { recovery.messages.count == 1 }, timeout: 1 ) #expect(didRecover) let retryMessage1 = try #require(recovery.messages.first) let retryMessage2 = try #require( try legacy.processHandshakeMessage( from: endpoints.higherPeerID, message: retryMessage1 ) ) let retryMessage3 = try #require( try modern.processHandshakeMessage( from: endpoints.lowerPeerID, message: retryMessage2 ) ) _ = try legacy.processHandshakeMessage( from: endpoints.higherPeerID, message: retryMessage3 ) try? await Task.sleep(nanoseconds: 120_000_000) #expect(recovery.timeoutCount == 1) #expect(recovery.errorCount == 0) let ciphertext = try modern.encrypt( Data("legacy converged".utf8), for: endpoints.lowerPeerID ) #expect( try legacy.decrypt( ciphertext, from: endpoints.higherPeerID ) == Data("legacy converged".utf8) ) } @Test("Immediate legacy restart during completion grace converges once") func immediateLegacyRestartDuringCompletionGrace() async throws { let firstKeychain = MockKeychain() let secondKeychain = MockKeychain() let first = NoiseEncryptionService( keychain: firstKeychain, recentInitiatorCompletionGracePeriod: 0.03 ) let second = NoiseEncryptionService( keychain: secondKeychain, recentInitiatorCompletionGracePeriod: 0.03 ) let firstPeerID = PeerID(publicKey: first.getStaticPublicKeyData()) let secondPeerID = PeerID(publicKey: second.getStaticPublicKeyData()) let lower: NoiseEncryptionService let higher: NoiseEncryptionService let higherKeychain: MockKeychain let lowerPeerID: PeerID let higherPeerID: PeerID if firstPeerID < secondPeerID { lower = first lowerPeerID = firstPeerID higher = second higherKeychain = secondKeychain higherPeerID = secondPeerID } else { lower = second lowerPeerID = secondPeerID higher = first higherKeychain = firstKeychain higherPeerID = firstPeerID } try establishSessions(alice: lower, bob: higher) let restartedHigher = NoiseEncryptionService(keychain: higherKeychain) let recovery = HandshakeStartRecorder() lower.onHandshakeRecoveryRequired = { [weak lower] request in guard let lower else { return } do { recovery.recordTimeout() recovery.record( message: try claimPreparedRecoveryPayload( lower, request: request ) ) } catch { recovery.record(error: error) } } let restartAttempt = try #require( try restartedHigher.initiateHandshakeIfNeeded(with: lowerPeerID) ) let restartMessage1 = try #require( restartedHigher.claimHandshakeInitiation( restartAttempt, for: lowerPeerID ) ) #expect( try lower.processHandshakeMessage( from: higherPeerID, message: restartMessage1 ) == nil ) #expect( try lower.processHandshakeMessage( from: higherPeerID, message: restartMessage1 ) == nil ) #expect(lower.hasEstablishedSession(with: higherPeerID)) let requested = await TestHelpers.waitUntil( { recovery.messages.count == 1 }, timeout: 1 ) #expect(requested) let retryMessage1 = try #require(recovery.messages.first) let retryMessage2 = try #require( try restartedHigher.processHandshakeMessage( from: lowerPeerID, message: retryMessage1 ) ) let retryMessage3 = try #require( try lower.processHandshakeMessage( from: higherPeerID, message: retryMessage2 ) ) _ = try restartedHigher.processHandshakeMessage( from: lowerPeerID, message: retryMessage3 ) try? await Task.sleep(nanoseconds: 100_000_000) #expect(recovery.timeoutCount == 1) #expect(recovery.errorCount == 0) let ciphertext = try restartedHigher.encrypt( Data("restart converged".utf8), for: lowerPeerID ) #expect( try lower.decrypt(ciphertext, from: higherPeerID) == Data("restart converged".utf8) ) } @Test("Atomic reconnect retires old sending keys before message one") func atomicReconnectQueuesUntilOrdinaryHandshakeCompletes() throws { let alice = NoiseEncryptionService(keychain: MockKeychain()) let bob = NoiseEncryptionService(keychain: MockKeychain()) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) try establishSessions(alice: alice, bob: bob) let oldCiphertext = try alice.encrypt(Data("old transport".utf8), for: bobPeerID) #expect(try bob.decrypt(oldCiphertext, from: alicePeerID) == Data("old transport".utf8)) let initiation = try alice.initiateReconnectHandshake(with: bobPeerID) #expect(alice.hasSession(with: bobPeerID)) #expect(!alice.hasEstablishedSession(with: bobPeerID)) do { _ = try alice.encrypt(Data("must queue".utf8), for: bobPeerID) Issue.record("Expected encryption to wait for the reconnect") } catch NoiseEncryptionError.handshakeRequired { // Expected: BLE queues behind the ordinary handshaking session. } catch { Issue.record("Unexpected in-window encryption error: \(error)") } let message1 = try #require( alice.claimHandshakeInitiation(initiation, for: bobPeerID) ) bob.clearSession(for: alicePeerID) let message2 = try #require( try bob.processHandshakeMessage(from: alicePeerID, message: message1) ) let message3 = try #require( try alice.processHandshakeMessage(from: bobPeerID, message: message2) ) _ = try bob.processHandshakeMessage(from: alicePeerID, message: message3) let fresh = try alice.encrypt(Data("fresh transport".utf8), for: bobPeerID) #expect(try bob.decrypt(fresh, from: alicePeerID) == Data("fresh transport".utf8)) } @Test("Inbound reconnect quarantines old sending keys until identity proof") func inboundReconnectQuarantinesOldTransport() throws { let restarted = NoiseEncryptionService(keychain: MockKeychain()) let retained = NoiseEncryptionService(keychain: MockKeychain()) let restartedPeerID = PeerID(publicKey: restarted.getStaticPublicKeyData()) let retainedPeerID = PeerID(publicKey: retained.getStaticPublicKeyData()) try establishSessions(alice: restarted, bob: retained) let inFlightOldCiphertext = try restarted.encrypt( Data("old receive-only transport".utf8), for: retainedPeerID ) restarted.clearSession(for: retainedPeerID) let message1 = try restarted.initiateHandshake(with: retainedPeerID) let message2 = try #require( try retained.processHandshakeMessage( from: restartedPeerID, message: message1 ) ) #expect(!retained.hasEstablishedSession(with: restartedPeerID)) #expect( try retained.decrypt( inFlightOldCiphertext, from: restartedPeerID ) == Data("old receive-only transport".utf8) ) do { _ = try retained.encrypt( Data("must queue at responder".utf8), for: restartedPeerID ) Issue.record("Expected quarantined responder encryption to wait") } catch NoiseEncryptionError.handshakeRequired { // Expected. } catch { Issue.record("Unexpected quarantine encryption error: \(error)") } let message3 = try #require( try restarted.processHandshakeMessage( from: retainedPeerID, message: message2 ) ) _ = try retained.processHandshakeMessage( from: restartedPeerID, message: message3 ) let fresh = try retained.encrypt( Data("identity proved".utf8), for: restartedPeerID ) #expect( try restarted.decrypt(fresh, from: retainedPeerID) == Data("identity proved".utf8) ) } @Test("Malformed handshake bytes cannot tear down an established session") func establishedSessionIgnoresNonInitialHandshakeGarbage() throws { let alice = NoiseEncryptionService(keychain: MockKeychain()) let bob = NoiseEncryptionService(keychain: MockKeychain()) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) try establishSessions(alice: alice, bob: bob) #expect( try bob.processHandshakeMessage( from: alicePeerID, message: Data(repeating: 0xA5, count: 31) ) == nil ) #expect(bob.hasEstablishedSession(with: alicePeerID)) let ciphertext = try alice.encrypt( Data("session survived".utf8), for: bobPeerID ) #expect( try bob.decrypt(ciphertext, from: alicePeerID) == Data("session survived".utf8) ) } @Test("Forged reconnect restores the quarantined transport") func forgedReconnectRestoresQuarantinedTransport() throws { let alice = NoiseEncryptionService(keychain: MockKeychain()) let bob = NoiseEncryptionService(keychain: MockKeychain()) let mallory = NoiseEncryptionService(keychain: MockKeychain()) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) try establishSessions(alice: alice, bob: bob) let forgedMessage1 = try mallory.initiateHandshake(with: bobPeerID) let forgedMessage2 = try #require( try bob.processHandshakeMessage( from: alicePeerID, message: forgedMessage1 ) ) #expect(!bob.hasEstablishedSession(with: alicePeerID)) let forgedMessage3 = try #require( try mallory.processHandshakeMessage( from: bobPeerID, message: forgedMessage2 ) ) do { _ = try bob.processHandshakeMessage( from: alicePeerID, message: forgedMessage3 ) Issue.record("Expected forged static identity to be rejected") } catch NoiseSessionError.peerIdentityMismatch { // Expected; the manager restores the quarantined transport. } catch { Issue.record("Unexpected forged reconnect error: \(error)") } #expect(bob.hasEstablishedSession(with: alicePeerID)) let oldTransport = try alice.encrypt( Data("rollback survived".utf8), for: bobPeerID ) #expect( try bob.decrypt(oldTransport, from: alicePeerID) == Data("rollback survived".utf8) ) } @Test("Lost reconnect completion restores the quarantined transport") func timedOutReconnectRestoresQuarantinedTransport() async throws { let alice = NoiseEncryptionService(keychain: MockKeychain()) let bob = NoiseEncryptionService( keychain: MockKeychain(), ordinaryResponderHandshakeTimeout: 0.02 ) let mallory = NoiseEncryptionService(keychain: MockKeychain()) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) try establishSessions(alice: alice, bob: bob) let forgedMessage1 = try mallory.initiateHandshake(with: bobPeerID) _ = try #require( try bob.processHandshakeMessage( from: alicePeerID, message: forgedMessage1 ) ) #expect(!bob.hasEstablishedSession(with: alicePeerID)) try? await Task.sleep(nanoseconds: 100_000_000) #expect(bob.hasEstablishedSession(with: alicePeerID)) let oldTransport = try alice.encrypt( Data("timeout rollback".utf8), for: bobPeerID ) #expect( try bob.decrypt(oldTransport, from: alicePeerID) == Data("timeout rollback".utf8) ) } @Test("Failed reconnect authorization preserves the established transport") func failedReconnectAuthorizationPreservesSession() throws { let alice = NoiseEncryptionService(keychain: MockKeychain()) let bob = NoiseEncryptionService(keychain: MockKeychain()) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) try establishSessions(alice: alice, bob: bob) // The initiator exchange consumed two authorizations for this peer. for _ in 2.. ( lower: NoiseEncryptionService, lowerPeerID: PeerID, higher: NoiseEncryptionService, higherPeerID: PeerID ) { let first = NoiseEncryptionService( keychain: MockKeychain(), ordinaryHandshakeTimeout: ordinaryHandshakeTimeout, ordinaryResponderHandshakeTimeout: ordinaryResponderHandshakeTimeout ) let second = NoiseEncryptionService( keychain: MockKeychain(), ordinaryHandshakeTimeout: ordinaryHandshakeTimeout, ordinaryResponderHandshakeTimeout: ordinaryResponderHandshakeTimeout ) let firstPeerID = PeerID(publicKey: first.getStaticPublicKeyData()) let secondPeerID = PeerID(publicKey: second.getStaticPublicKeyData()) if firstPeerID < secondPeerID { return (first, firstPeerID, second, secondPeerID) } return (second, secondPeerID, first, firstPeerID) } private func claimPreparedRecoveryPayload( _ service: NoiseEncryptionService, request: NoiseHandshakeRecoveryRequest ) throws -> Data? { guard let preparation = try service.prepareHandshakeRecovery(request) else { return nil } switch preparation { case .ordinary(let initiation): return service.claimHandshakeInitiation( initiation, for: request.peerID ) case .transferred: return nil } } private final class AuthenticationRecorder: @unchecked Sendable { private let lock = NSLock() private var entries: [(PeerID, String)] = [] private var generationEntries: [(PeerID, UUID)] = [] var count: Int { lock.lock() defer { lock.unlock() } return entries.count } var generationCount: Int { lock.lock() defer { lock.unlock() } return generationEntries.count } func record(peerID: PeerID, fingerprint: String) { lock.lock() entries.append((peerID, fingerprint)) lock.unlock() } func record(peerID: PeerID, fingerprint _: String, sessionGeneration: UUID) { lock.lock() generationEntries.append((peerID, sessionGeneration)) lock.unlock() } func generation(for peerID: PeerID) -> UUID? { lock.lock() defer { lock.unlock() } return generationEntries.last { $0.0 == peerID }?.1 } } private final class HandshakeInitiationRecorder: @unchecked Sendable { private let lock = NSLock() private var storedInitiations: [NoiseHandshakeInitiation] = [] private var storedErrorCount = 0 var initiations: [NoiseHandshakeInitiation] { lock.lock() defer { lock.unlock() } return storedInitiations } var errorCount: Int { lock.lock() defer { lock.unlock() } return storedErrorCount } func record(_ initiation: NoiseHandshakeInitiation?) { guard let initiation else { return } lock.lock() storedInitiations.append(initiation) lock.unlock() } func record(error _: Error) { lock.lock() storedErrorCount += 1 lock.unlock() } } private final class HandshakeStartRecorder: @unchecked Sendable { private let lock = NSLock() private var storedMessages: [Data] = [] private var storedErrorCount = 0 private var storedTimeoutCount = 0 var messages: [Data] { lock.lock() defer { lock.unlock() } return storedMessages } var errorCount: Int { lock.lock() defer { lock.unlock() } return storedErrorCount } var timeoutCount: Int { lock.lock() defer { lock.unlock() } return storedTimeoutCount } func record(message: Data?) { guard let message else { return } lock.lock() storedMessages.append(message) lock.unlock() } func record(error _: Error) { lock.lock() storedErrorCount += 1 lock.unlock() } func recordTimeout() { lock.lock() storedTimeoutCount += 1 lock.unlock() } }