diff --git a/Package.swift b/Package.swift index 9a24030e..9a6d43bc 100644 --- a/Package.swift +++ b/Package.swift @@ -49,7 +49,8 @@ let package = Package( "README.md" ], resources: [ - .process("Localization") + .process("Localization"), + .process("Noise") ] ) ] diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index cbc5e765..e062000b 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -95,6 +95,24 @@ ); target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; }; + C5E027A52ECCDFD700BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_macOS" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + Localization/PrimaryLocalizationKeys.json, + README.md, + ); + target = 47FF23248747DD7CB666CB91 /* bitchatTests_macOS */; + }; + C5E027A82ECCDFE200BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_iOS" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + Localization/PrimaryLocalizationKeys.json, + README.md, + ); + target = 6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -118,6 +136,10 @@ }; A6E32D412E762EAE0032EA8A /* bitchatTests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + C5E027A82ECCDFE200BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_iOS" target */, + C5E027A52ECCDFD700BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_macOS" target */, + ); path = bitchatTests; sourceTree = ""; }; @@ -213,6 +235,7 @@ buildConfigurationList = 1C27B5BA3DB46DDF0DBFEF62 /* Build configuration list for PBXNativeTarget "bitchatTests_macOS" */; buildPhases = ( 5C22AA7B9ACC5A861445C769 /* Sources */, + C5E027A42ECCDFD700BD6012 /* Resources */, ); buildRules = ( ); @@ -245,6 +268,7 @@ buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */; buildPhases = ( 865C8403EF02C089369A9FCB /* Sources */, + C5E027A72ECCDFE200BD6012 /* Resources */, ); buildRules = ( ); @@ -343,6 +367,16 @@ E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */, ); }; + C5E027A42ECCDFD700BD6012 /* Resources */ = { + isa = PBXResourcesBuildPhase; + files = ( + ); + }; + C5E027A72ECCDFE200BD6012 /* Resources */ = { + isa = PBXResourcesBuildPhase; + files = ( + ); + }; CD6E8F32BC38357473954F97 /* Resources */ = { isa = PBXResourcesBuildPhase; files = ( diff --git a/bitchat/Noise/NoiseProtocol.swift b/bitchat/Noise/NoiseProtocol.swift index f16b4afe..021dc9fc 100644 --- a/bitchat/Noise/NoiseProtocol.swift +++ b/bitchat/Noise/NoiseProtocol.swift @@ -222,7 +222,7 @@ final class NoiseCipherState { guard combinedPayload.count >= Self.NONCE_SIZE_BYTES else { return nil } - + // Extract 4-byte nonce (big-endian) let nonceData = combinedPayload.prefix(Self.NONCE_SIZE_BYTES) let extractedNonce = nonceData.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> UInt64 in @@ -233,18 +233,18 @@ final class NoiseCipherState { } return result } - + // Extract ciphertext (remaining bytes) let ciphertext = combinedPayload.dropFirst(Self.NONCE_SIZE_BYTES) - + return (nonce: extractedNonce, ciphertext: Data(ciphertext)) } - + /// Convert nonce to 4-byte array (big-endian) private func nonceToBytes(_ nonce: UInt64) -> Data { var bytes = Data(count: Self.NONCE_SIZE_BYTES) withUnsafeBytes(of: nonce.bigEndian) { ptr in - // Copy only the last 4 bytes from the 8-byte UInt64 + // Copy only the last 4 bytes from the 8-byte UInt64 let sourceBytes = ptr.bindMemory(to: UInt8.self) bytes.replaceSubrange(0.. let combinedPayload: Data if (useExtractedNonce) { @@ -287,7 +287,7 @@ final class NoiseCipherState { if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD { SecureLogger.warning("High nonce value detected: \(currentNonce) - consider rekeying", category: .encryption) } - + return combinedPayload } @@ -316,7 +316,7 @@ final class NoiseCipherState { SecureLogger.debug("Replay attack detected: nonce \(extractedNonce) rejected") throw NoiseError.replayDetected } - + // Split ciphertext and tag encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16) tag = actualCiphertext.suffix(16) @@ -451,13 +451,13 @@ final class NoiseSymmetricState { } } - func split() -> (NoiseCipherState, NoiseCipherState) { + func split(useExtractedNonce: Bool) -> (NoiseCipherState, NoiseCipherState) { let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2) let tempKey1 = SymmetricKey(data: output[0]) let tempKey2 = SymmetricKey(data: output[1]) - let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: true) - let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: true) + let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce) + let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce) return (c1, c2) } @@ -507,16 +507,24 @@ final class NoiseHandshakeState { private var messagePatterns: [[NoiseMessagePattern]] = [] private var currentPattern = 0 + // Test support: predetermined ephemeral keys for test vectors + private var predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey? + private var prologueData: Data + init( role: NoiseRole, pattern: NoisePattern, keychain: KeychainManagerProtocol, localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil, - remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil + remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil, + prologue: Data = Data(), + predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey? = nil ) { self.role = role self.pattern = pattern self.keychain = keychain + self.prologueData = prologue + self.predeterminedEphemeralKey = predeterminedEphemeralKey // Initialize static keys if let localKey = localStaticKey { @@ -537,8 +545,8 @@ final class NoiseHandshakeState { } private func mixPreMessageKeys() { - // Mix prologue (empty for XX pattern normally) - symmetricState.mixHash(Data()) // Empty prologue for XX pattern + // Mix prologue + symmetricState.mixHash(self.prologueData) // For XX pattern, no pre-message keys // For IK/NK patterns, we'd mix the responder's static key here switch pattern { @@ -556,15 +564,20 @@ final class NoiseHandshakeState { guard currentPattern < messagePatterns.count else { throw NoiseError.handshakeComplete } - + var messageBuffer = Data() let patterns = messagePatterns[currentPattern] for pattern in patterns { switch pattern { case .e: - // Generate ephemeral key - localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey() + // Generate ephemeral key (or use predetermined key for tests) + if let predetermined = predeterminedEphemeralKey { + localEphemeralPrivate = predetermined + predeterminedEphemeralKey = nil + } else { + localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey() + } localEphemeralPublic = localEphemeralPrivate!.publicKey messageBuffer.append(localEphemeralPublic!.rawRepresentation) symmetricState.mixHash(localEphemeralPublic!.rawRepresentation) @@ -652,7 +665,7 @@ final class NoiseHandshakeState { guard currentPattern < messagePatterns.count else { throw NoiseError.handshakeComplete } - + var buffer = message let patterns = messagePatterns[currentPattern] @@ -776,12 +789,12 @@ final class NoiseHandshakeState { return currentPattern >= messagePatterns.count } - func getTransportCiphers() throws -> (send: NoiseCipherState, receive: NoiseCipherState) { + func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState) { guard isHandshakeComplete() else { throw NoiseError.handshakeNotComplete } - let (c1, c2) = symmetricState.split() + let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce) // Initiator uses c1 for sending, c2 for receiving // Responder uses c2 for sending, c1 for receiving diff --git a/bitchat/Noise/NoiseSession.swift b/bitchat/Noise/NoiseSession.swift index 52e3dedd..8c84f85a 100644 --- a/bitchat/Noise/NoiseSession.swift +++ b/bitchat/Noise/NoiseSession.swift @@ -103,7 +103,7 @@ class NoiseSession { // Check if handshake is complete if handshake.isHandshakeComplete() { // Get transport ciphers - let (send, receive) = try handshake.getTransportCiphers() + let (send, receive) = try handshake.getTransportCiphers(useExtractedNonce: true) sendCipher = send receiveCipher = receive @@ -129,7 +129,7 @@ class NoiseSession { // Check if handshake is complete after writing if handshake.isHandshakeComplete() { // Get transport ciphers - let (send, receive) = try handshake.getTransportCiphers() + let (send, receive) = try handshake.getTransportCiphers(useExtractedNonce: true) sendCipher = send receiveCipher = receive diff --git a/bitchatTests/Noise/NoiseProtocolTests.swift b/bitchatTests/Noise/NoiseProtocolTests.swift index be4b15a3..6083e134 100644 --- a/bitchatTests/Noise/NoiseProtocolTests.swift +++ b/bitchatTests/Noise/NoiseProtocolTests.swift @@ -6,11 +6,53 @@ // For more information, see // -import Testing 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() @@ -61,7 +103,7 @@ struct NoiseProtocolTests { // Bob processes message 3 and completes handshake let finalMessage = try bobSession.processHandshakeMessage(message3!) - #expect(finalMessage == nil) // No more messages needed + #expect(finalMessage == nil) // No more messages needed #expect(bobSession.getState() == .established) // Verify both sessions are established @@ -69,8 +111,12 @@ struct NoiseProtocolTests { #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) + #expect( + aliceSession.getRemoteStaticPublicKey()?.rawRepresentation + == bobKey.publicKey.rawRepresentation) + #expect( + bobSession.getRemoteStaticPublicKey()?.rawRepresentation + == aliceKey.publicKey.rawRepresentation) } @Test func handshakeStateValidation() throws { @@ -98,7 +144,7 @@ struct NoiseProtocolTests { // Alice encrypts let ciphertext = try aliceSession.encrypt(plaintext) #expect(ciphertext != plaintext) - #expect(ciphertext.count > plaintext.count) // Should have overhead + #expect(ciphertext.count > plaintext.count) // Should have overhead // Bob decrypts let decrypted = try bobSession.decrypt(ciphertext) @@ -150,16 +196,16 @@ struct NoiseProtocolTests { @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) @@ -190,11 +236,13 @@ struct NoiseProtocolTests { #expect(message2 != nil) // Continue handshake - let message3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message2!) + let message3 = try aliceManager.handleIncomingHandshake( + from: alicePeerID, message: message2!) #expect(message3 != nil) // Complete handshake - let finalMessage = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message3!) + let finalMessage = try bobManager.handleIncomingHandshake( + from: bobPeerID, message: message3!) #expect(finalMessage == nil) // Both should have established sessions @@ -258,11 +306,19 @@ struct NoiseProtocolTests { @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 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) + 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) @@ -305,17 +361,20 @@ struct NoiseProtocolTests { _ = try aliceManager.decrypt(message2, from: alicePeerID) // Simulate Bob restart by creating new manager with same key - let bobManagerRestarted = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) + 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) + 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!) + let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake( + from: bobPeerID, message: newHandshake2!) #expect(newHandshake3 != nil) _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!) @@ -328,8 +387,10 @@ struct NoiseProtocolTests { @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) + 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) @@ -361,7 +422,8 @@ struct NoiseProtocolTests { let messageCount = 100 - try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) { completion in + 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..