import Foundation import Testing import BitFoundation @testable import bitchat @Suite("NoiseEncryptionService Tests") 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 emittedMessage: Data? alice.onRekeyHandshakeReady = { peerID, message in emittedPeerID = peerID emittedMessage = message } 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 message1 = try #require(emittedMessage) #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("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("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.. UUID? { lock.lock() defer { lock.unlock() } return generationEntries.last { $0.0 == peerID }?.1 } }