// // NoiseProtocolTests.swift // bitchatTests // // This is free and unencumbered software released into the public domain. // For more information, see // import CryptoKit import Foundation import Testing @testable import bitchat // MARK: - Test Vector Support struct NoiseTestVector: Codable { let protocol_name: String let init_prologue: String let init_static: String let init_ephemeral: String let init_psks: [String]? let resp_prologue: String let resp_static: String let resp_ephemeral: String let resp_psks: [String]? let handshake_hash: String? let messages: [TestMessage] struct TestMessage: Codable { let payload: String let ciphertext: String } } extension Data { init?(hex: String) { let cleaned = hex.replacingOccurrences(of: " ", with: "") guard cleaned.count % 2 == 0 else { return nil } var data = Data(capacity: cleaned.count / 2) var index = cleaned.startIndex while index < cleaned.endIndex { let nextIndex = cleaned.index(index, offsetBy: 2) guard let byte = UInt8(cleaned[index.. String { map { String(format: "%02x", $0) }.joined() } } struct NoiseProtocolTests { private let aliceKey = Curve25519.KeyAgreement.PrivateKey() private let bobKey = Curve25519.KeyAgreement.PrivateKey() private let mockKeychain = MockKeychain() private let alicePeerID = PeerID(str: UUID().uuidString) private let bobPeerID = PeerID(str: UUID().uuidString) private let aliceSession: NoiseSession private let bobSession: NoiseSession init() { aliceSession = NoiseSession( peerID: alicePeerID, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey ) bobSession = NoiseSession( peerID: bobPeerID, role: .responder, keychain: mockKeychain, localStaticKey: bobKey ) } // MARK: - Basic Handshake Tests @Test func xxPatternHandshake() throws { // Alice starts handshake (message 1) let message1 = try aliceSession.startHandshake() #expect(!message1.isEmpty) #expect(aliceSession.getState() == .handshaking) // Bob processes message 1 and creates message 2 let message2 = try bobSession.processHandshakeMessage(message1) #expect(message2 != nil) #expect(!message2!.isEmpty) #expect(bobSession.getState() == .handshaking) // Alice processes message 2 and creates message 3 let message3 = try aliceSession.processHandshakeMessage(message2!) #expect(message3 != nil) #expect(!message3!.isEmpty) #expect(aliceSession.getState() == .established) // Bob processes message 3 and completes handshake let finalMessage = try bobSession.processHandshakeMessage(message3!) #expect(finalMessage == nil) // No more messages needed #expect(bobSession.getState() == .established) // Verify both sessions are established #expect(aliceSession.isEstablished()) #expect(bobSession.isEstablished()) // Verify they have each other's static keys #expect( aliceSession.getRemoteStaticPublicKey()?.rawRepresentation == bobKey.publicKey.rawRepresentation) #expect( bobSession.getRemoteStaticPublicKey()?.rawRepresentation == aliceKey.publicKey.rawRepresentation) } @Test func handshakeStateValidation() throws { // Cannot process message before starting handshake #expect(throws: NoiseSessionError.invalidState) { try aliceSession.processHandshakeMessage(Data()) } // Start handshake _ = try aliceSession.startHandshake() // Cannot start handshake twice #expect(throws: NoiseSessionError.invalidState) { try aliceSession.startHandshake() } } // MARK: - Encryption/Decryption Tests @Test func basicEncryptionDecryption() throws { try performHandshake(initiator: aliceSession, responder: bobSession) let plaintext = "Hello, Bob!".data(using: .utf8)! // Alice encrypts let ciphertext = try aliceSession.encrypt(plaintext) #expect(ciphertext != plaintext) #expect(ciphertext.count > plaintext.count) // Should have overhead // Bob decrypts let decrypted = try bobSession.decrypt(ciphertext) #expect(decrypted == plaintext) } @Test func bidirectionalEncryption() throws { try performHandshake(initiator: aliceSession, responder: bobSession) // Alice -> Bob let aliceMessage = "Hello from Alice".data(using: .utf8)! let aliceCiphertext = try aliceSession.encrypt(aliceMessage) let bobReceived = try bobSession.decrypt(aliceCiphertext) #expect(bobReceived == aliceMessage) // Bob -> Alice let bobMessage = "Hello from Bob".data(using: .utf8)! let bobCiphertext = try bobSession.encrypt(bobMessage) let aliceReceived = try aliceSession.decrypt(bobCiphertext) #expect(aliceReceived == bobMessage) } @Test func largeMessageEncryption() throws { try performHandshake(initiator: aliceSession, responder: bobSession) // Create a large message let largeMessage = TestHelpers.generateRandomData(length: 100_000) // Encrypt and decrypt let ciphertext = try aliceSession.encrypt(largeMessage) let decrypted = try bobSession.decrypt(ciphertext) #expect(decrypted == largeMessage) } @Test func encryptionBeforeHandshake() { let plaintext = "test".data(using: .utf8)! #expect(throws: NoiseSessionError.notEstablished) { try aliceSession.encrypt(plaintext) } #expect(throws: NoiseSessionError.notEstablished) { try aliceSession.decrypt(plaintext) } } // MARK: - Session Manager Tests @Test func sessionManagerBasicOperations() throws { let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) #expect(manager.getSession(for: alicePeerID) == nil) _ = try manager.initiateHandshake(with: alicePeerID) #expect(manager.getSession(for: alicePeerID) != nil) // Get session let retrieved = manager.getSession(for: alicePeerID) #expect(retrieved != nil) // Remove session manager.removeSession(for: alicePeerID) #expect(manager.getSession(for: alicePeerID) == nil) } @Test func sessionManagerHandshakeInitiation() throws { let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) // Initiate handshake let handshakeData = try manager.initiateHandshake(with: alicePeerID) #expect(!handshakeData.isEmpty) // Session should exist let session = manager.getSession(for: alicePeerID) #expect(session != nil) #expect(session?.getState() == .handshaking) } @Test func sessionManagerIncomingHandshake() throws { let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) // Alice initiates let message1 = try aliceManager.initiateHandshake(with: alicePeerID) // Bob responds let message2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message1) #expect(message2 != nil) // Continue handshake let message3 = try aliceManager.handleIncomingHandshake( from: alicePeerID, message: message2!) #expect(message3 != nil) // Complete handshake let finalMessage = try bobManager.handleIncomingHandshake( from: bobPeerID, message: message3!) #expect(finalMessage == nil) // Both should have established sessions #expect(aliceManager.getSession(for: alicePeerID)?.isEstablished() == true) #expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true) } @Test func sessionManagerEncryptionDecryption() throws { let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) // Establish sessions try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) // Encrypt with manager let plaintext = "Test message".data(using: .utf8)! let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID) // Decrypt with manager let decrypted = try bobManager.decrypt(ciphertext, from: bobPeerID) #expect(decrypted == plaintext) } // MARK: - Security Tests @Test func tamperedCiphertextDetection() throws { try performHandshake(initiator: aliceSession, responder: bobSession) let plaintext = "Secret message".data(using: .utf8)! var ciphertext = try aliceSession.encrypt(plaintext) // Tamper with ciphertext ciphertext[ciphertext.count / 2] ^= 0xFF // Decryption should fail if #available(macOS 14.4, iOS 17.4, *) { #expect(throws: CryptoKitError.authenticationFailure) { try bobSession.decrypt(ciphertext) } } else { #expect(throws: (any Error).self) { try bobSession.decrypt(ciphertext) } } } @Test func replayPrevention() throws { try performHandshake(initiator: aliceSession, responder: bobSession) let plaintext = "Test message".data(using: .utf8)! let ciphertext = try aliceSession.encrypt(plaintext) // First decryption should succeed _ = try bobSession.decrypt(ciphertext) // Replaying the same ciphertext should fail #expect(throws: NoiseError.replayDetected) { try bobSession.decrypt(ciphertext) } } @Test func sessionIsolation() throws { // Create two separate session pairs let aliceSession1 = NoiseSession( peerID: PeerID(str: "peer1"), role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey) let bobSession1 = NoiseSession( peerID: PeerID(str: "alice1"), role: .responder, keychain: mockKeychain, localStaticKey: bobKey) let aliceSession2 = NoiseSession( peerID: PeerID(str: "peer2"), role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey) let bobSession2 = NoiseSession( peerID: PeerID(str: "alice2"), role: .responder, keychain: mockKeychain, localStaticKey: bobKey) // Establish both pairs try performHandshake(initiator: aliceSession1, responder: bobSession1) try performHandshake(initiator: aliceSession2, responder: bobSession2) // Encrypt with session 1 let plaintext = "Secret".data(using: .utf8)! let ciphertext1 = try aliceSession1.encrypt(plaintext) // Should not be able to decrypt with session 2 if #available(macOS 14.4, iOS 17.4, *) { #expect(throws: CryptoKitError.authenticationFailure) { try bobSession2.decrypt(ciphertext1) } } else { #expect(throws: (any Error).self) { try bobSession2.decrypt(ciphertext1) } } // But should work with correct session let decrypted = try bobSession1.decrypt(ciphertext1) #expect(decrypted == plaintext) } // MARK: - Session Recovery Tests @Test func peerRestartDetection() throws { // Establish initial sessions let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) // Exchange some messages to establish nonce state let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: alicePeerID) _ = try bobManager.decrypt(message1, from: bobPeerID) let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: bobPeerID) _ = try aliceManager.decrypt(message2, from: alicePeerID) // Simulate Bob restart by creating new manager with same key let bobManagerRestarted = NoiseSessionManager( localStaticKey: bobKey, keychain: mockKeychain) // Bob initiates new handshake after restart let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID) // Alice should accept the new handshake (clearing old session) let newHandshake2 = try aliceManager.handleIncomingHandshake( from: alicePeerID, message: newHandshake1) #expect(newHandshake2 != nil) // Complete the new handshake let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake( from: bobPeerID, message: newHandshake2!) #expect(newHandshake3 != nil) _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!) // Should be able to exchange messages with new sessions let testMessage = "After restart".data(using: .utf8)! let encrypted = try bobManagerRestarted.encrypt(testMessage, for: bobPeerID) let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID) #expect(decrypted == testMessage) } @Test func nonceDesynchronizationRecovery() throws { // Create two sessions let aliceSession = NoiseSession( peerID: alicePeerID, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey) let bobSession = NoiseSession( peerID: bobPeerID, role: .responder, keychain: mockKeychain, localStaticKey: bobKey) // Establish sessions try performHandshake(initiator: aliceSession, responder: bobSession) // Exchange messages to advance nonces for i in 0..<5 { let msg = try aliceSession.encrypt("Message \(i)".data(using: .utf8)!) _ = try bobSession.decrypt(msg) } // Simulate desynchronization by encrypting but not decrypting for i in 0..<3 { _ = try aliceSession.encrypt("Lost message \(i)".data(using: .utf8)!) } // With per-packet nonce carried, decryption should not throw here let desyncMessage = try aliceSession.encrypt("This now succeeds".data(using: .utf8)!) #expect(throws: Never.self) { try bobSession.decrypt(desyncMessage) } } @Test func concurrentEncryption() async throws { // Test thread safety of encryption operations let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) let messageCount = 100 try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) { completion in var encryptedMessages: [Int: Data] = [:] // Encrypt messages sequentially to avoid nonce races in manager for i in 0.. [NoiseTestVector] { // Try to load from test bundle let testBundle = Bundle(for: MockKeychain.self) guard let url = testBundle.url(forResource: "NoiseTestVectors", withExtension: "json") else { throw NSError( domain: "NoiseTests", code: 1, userInfo: [ NSLocalizedDescriptionKey: "Could not find NoiseTestVectors.json in test bundle" ]) } let data = try Data(contentsOf: url) return try JSONDecoder().decode([NoiseTestVector].self, from: data) } private func runTestVector(_ testVector: NoiseTestVector) throws { // Parse test inputs guard let initStatic = Data(hex: testVector.init_static), let initEphemeral = Data(hex: testVector.init_ephemeral), let respStatic = Data(hex: testVector.resp_static), let respEphemeral = Data(hex: testVector.resp_ephemeral), let prologue = Data(hex: testVector.init_prologue) else { throw NSError( domain: "NoiseTests", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to parse test vector hex strings"]) } let expectedHash = testVector.handshake_hash.flatMap { Data(hex: $0) } // Create keys guard let initStaticKey = try? Curve25519.KeyAgreement.PrivateKey( rawRepresentation: initStatic), let initEphemeralKey = try? Curve25519.KeyAgreement.PrivateKey( rawRepresentation: initEphemeral), let respStaticKey = try? Curve25519.KeyAgreement.PrivateKey( rawRepresentation: respStatic), let respEphemeralKey = try? Curve25519.KeyAgreement.PrivateKey( rawRepresentation: respEphemeral) else { throw NSError( domain: "NoiseTests", code: 3, userInfo: [NSLocalizedDescriptionKey: "Failed to create keys from test vectors"]) } let keychain = MockKeychain() // Create handshake states let initiatorHandshake = NoiseHandshakeState( role: .initiator, pattern: .XX, keychain: keychain, localStaticKey: initStaticKey, prologue: prologue, predeterminedEphemeralKey: initEphemeralKey ) let responderHandshake = NoiseHandshakeState( role: .responder, pattern: .XX, keychain: keychain, localStaticKey: respStaticKey, prologue: prologue, predeterminedEphemeralKey: respEphemeralKey ) // For XX pattern, we have 3 handshake messages, then transport messages // The test vector messages are ordered as: [msg1, msg2, msg3, transport1, transport2, ...] guard testVector.messages.count >= 3 else { throw NSError( domain: "NoiseTests", code: 5, userInfo: [NSLocalizedDescriptionKey: "Test vector must have at least 3 messages for XX pattern"]) } // Message 1: Initiator -> Responder (e) guard let payload1 = Data(hex: testVector.messages[0].payload), let expectedCiphertext1 = Data(hex: testVector.messages[0].ciphertext) else { throw NSError( domain: "NoiseTests", code: 4, userInfo: [NSLocalizedDescriptionKey: "Message 1: Failed to parse hex"]) } let msg1 = try initiatorHandshake.writeMessage(payload: payload1) #expect(!msg1.isEmpty, "Message 1 should not be empty") #expect(msg1 == expectedCiphertext1, "Message 1 ciphertext should match expected value. Got: \(msg1.hexString()), Expected: \(expectedCiphertext1.hexString())") let decrypted1 = try responderHandshake.readMessage(msg1) #expect(decrypted1 == payload1, "Message 1: Decrypted payload should match original") // Message 2: Responder -> Initiator (e, ee, s, es) guard let payload2 = Data(hex: testVector.messages[1].payload), let expectedCiphertext2 = Data(hex: testVector.messages[1].ciphertext) else { throw NSError( domain: "NoiseTests", code: 4, userInfo: [NSLocalizedDescriptionKey: "Message 2: Failed to parse hex"]) } let msg2 = try responderHandshake.writeMessage(payload: payload2) #expect(!msg2.isEmpty, "Message 2 should not be empty") #expect(msg2 == expectedCiphertext2, "Message 2 ciphertext should match expected value. Got: \(msg2.hexString()), Expected: \(expectedCiphertext2.hexString())") let decrypted2 = try initiatorHandshake.readMessage(msg2) #expect(decrypted2 == payload2, "Message 2: Decrypted payload should match original") // Message 3: Initiator -> Responder (s, se) guard let payload3 = Data(hex: testVector.messages[2].payload), let expectedCiphertext3 = Data(hex: testVector.messages[2].ciphertext) else { throw NSError( domain: "NoiseTests", code: 4, userInfo: [NSLocalizedDescriptionKey: "Message 3: Failed to parse hex"]) } let msg3 = try initiatorHandshake.writeMessage(payload: payload3) #expect(!msg3.isEmpty, "Message 3 should not be empty") #expect(msg3 == expectedCiphertext3, "Message 3 ciphertext should match expected value. Got: \(msg3.hexString()), Expected: \(expectedCiphertext3.hexString())") let decrypted3 = try responderHandshake.readMessage(msg3) #expect(decrypted3 == payload3, "Message 3: Decrypted payload should match original") // Verify handshake hash let initiatorHash = initiatorHandshake.getHandshakeHash() let responderHash = responderHandshake.getHandshakeHash() #expect(initiatorHash == responderHash, "Initiator and responder hashes should match") if let expectedHash = expectedHash { #expect( initiatorHash == expectedHash, "Handshake hash should match expected value from test vector. Got: \(initiatorHash.hexString()), Expected: \(expectedHash.hexString())") } // Get transport ciphers let (initSend, initRecv) = try initiatorHandshake.getTransportCiphers(useExtractedNonce: false) let (respSend, respRecv) = try responderHandshake.getTransportCiphers(useExtractedNonce: false) // Test transport messages (messages after the 3 handshake messages) for index in 3..= expectedMinimumCalls, "Expected at least \(expectedMinimumCalls) secureClear calls for DH secrets, got \(trackingKeychain.secureClearDataCallCount)" ) } @Test func encryptionWorksAfterSecureClear() throws { // Verify that encryption/decryption still works correctly after adding secureClear let trackingKeychain = TrackingMockKeychain() let aliceKey = Curve25519.KeyAgreement.PrivateKey() let bobKey = Curve25519.KeyAgreement.PrivateKey() let alice = NoiseSession( peerID: PeerID(str: "alice-test-enc"), role: .initiator, keychain: trackingKeychain, localStaticKey: aliceKey ) let bob = NoiseSession( peerID: PeerID(str: "bob-test-enc"), role: .responder, keychain: trackingKeychain, localStaticKey: bobKey ) // Perform handshake let msg1 = try alice.startHandshake() let msg2 = try bob.processHandshakeMessage(msg1)! let msg3 = try alice.processHandshakeMessage(msg2)! _ = try bob.processHandshakeMessage(msg3) // Verify both sessions are established #expect(alice.isEstablished()) #expect(bob.isEstablished()) // Verify secureClear was called (basic sanity check) #expect(trackingKeychain.secureClearDataCallCount > 0) // Test encryption from Alice to Bob let plaintext1 = "Hello from Alice after secureClear!".data(using: .utf8)! let ciphertext1 = try alice.encrypt(plaintext1) let decrypted1 = try bob.decrypt(ciphertext1) #expect(decrypted1 == plaintext1) // Test encryption from Bob to Alice let plaintext2 = "Hello from Bob after secureClear!".data(using: .utf8)! let ciphertext2 = try bob.encrypt(plaintext2) let decrypted2 = try alice.decrypt(ciphertext2) #expect(decrypted2 == plaintext2) // Test multiple messages to verify cipher state is correct for i in 1...10 { let msg = "Message \(i) from Alice".data(using: .utf8)! let cipher = try alice.encrypt(msg) let dec = try bob.decrypt(cipher) #expect(dec == msg) } } @Test func secureClearCalledInBothWriteAndReadPaths() throws { // Verify secureClear is called in both writeMessage and readMessage paths // We do this by checking the count increases at each step let aliceKeychain = TrackingMockKeychain() let bobKeychain = TrackingMockKeychain() let aliceKey = Curve25519.KeyAgreement.PrivateKey() let bobKey = Curve25519.KeyAgreement.PrivateKey() let alice = NoiseSession( peerID: PeerID(str: "alice-paths"), role: .initiator, keychain: aliceKeychain, localStaticKey: aliceKey ) let bob = NoiseSession( peerID: PeerID(str: "bob-paths"), role: .responder, keychain: bobKeychain, localStaticKey: bobKey ) // Message 1: Alice writes (e token only, no DH) let msg1 = try alice.startHandshake() let aliceCountAfterMsg1 = aliceKeychain.secureClearDataCallCount // No DH in message 1 for initiator #expect(aliceCountAfterMsg1 == 0, "No DH secrets in message 1 write") // Bob reads message 1 (no DH) and writes message 2 (ee, es DH operations) let msg2 = try bob.processHandshakeMessage(msg1)! let bobCountAfterMsg2 = bobKeychain.secureClearDataCallCount // Bob should have cleared secrets for: ee (read), es (read), ee (write), es (write) #expect(bobCountAfterMsg2 >= 2, "Bob should clear DH secrets when processing/writing message 2") // Alice reads message 2 (ee, es) and writes message 3 (se) let msg3 = try alice.processHandshakeMessage(msg2)! let aliceCountAfterMsg3 = aliceKeychain.secureClearDataCallCount // Alice should have cleared: ee (read), es (read), se (write) #expect(aliceCountAfterMsg3 >= 3, "Alice should clear DH secrets when processing/writing message 3") // Bob reads message 3 (se) _ = try bob.processHandshakeMessage(msg3) let bobFinalCount = bobKeychain.secureClearDataCallCount // Bob should have additionally cleared: se (read) #expect(bobFinalCount > bobCountAfterMsg2, "Bob should clear DH secrets when processing message 3") } }