From 09a319fb0f00fdf64191eb1adb388a5dcd139355 Mon Sep 17 00:00:00 2001 From: Nadim Kobeissi Date: Tue, 18 Nov 2025 18:44:57 +0200 Subject: [PATCH 1/9] Initial work on adding Noise test vectors I only became aware halfway through working on these that `bitchatTests/Noise` exists, so the next step is to integrate them over there. --- bitchat/Noise/NoiseProtocol.swift | 228 ++++++++++++---------- bitchat/Noise/NoiseTestRunner.swift | 293 ++++++++++++++++++++++++++++ bitchat/Noise/NoiseTestVectors.json | 71 +++++++ bitchat/Noise/README.md | 13 ++ 4 files changed, 499 insertions(+), 106 deletions(-) create mode 100644 bitchat/Noise/NoiseTestRunner.swift create mode 100644 bitchat/Noise/NoiseTestVectors.json create mode 100644 bitchat/Noise/README.md diff --git a/bitchat/Noise/NoiseProtocol.swift b/bitchat/Noise/NoiseProtocol.swift index f16b4afe..1a8d0c6a 100644 --- a/bitchat/Noise/NoiseProtocol.swift +++ b/bitchat/Noise/NoiseProtocol.swift @@ -77,7 +77,9 @@ /// - Noise Specification: http://www.noiseprotocol.org/noise.html /// +#if !NOISE_TESTS import BitLogger +#endif import Foundation import CryptoKit @@ -115,7 +117,7 @@ struct NoiseProtocolName { let dh: String = "25519" // Curve25519 let cipher: String = "ChaChaPoly" // ChaCha20-Poly1305 let hash: String = "SHA256" // SHA-256 - + var fullName: String { "Noise_\(pattern)_\(dh)_\(cipher)_\(hash)" } @@ -133,59 +135,59 @@ final class NoiseCipherState { private static let REPLAY_WINDOW_SIZE = 1024 private static let REPLAY_WINDOW_BYTES = REPLAY_WINDOW_SIZE / 8 // 128 bytes private static let HIGH_NONCE_WARNING_THRESHOLD: UInt64 = 1_000_000_000 - + private var key: SymmetricKey? private var nonce: UInt64 = 0 private var useExtractedNonce: Bool = false - + // Sliding window replay protection (only used when useExtractedNonce = true) private var highestReceivedNonce: UInt64 = 0 private var replayWindow: [UInt8] = Array(repeating: 0, count: REPLAY_WINDOW_BYTES) - + init() {} - + init(key: SymmetricKey, useExtractedNonce: Bool = false) { self.key = key self.useExtractedNonce = useExtractedNonce } - + deinit { clearSensitiveData() } - + func initializeKey(_ key: SymmetricKey) { self.key = key self.nonce = 0 } - + func hasKey() -> Bool { return key != nil } - + // MARK: - Sliding Window Replay Protection - + /// Check if nonce is valid for replay protection private func isValidNonce(_ receivedNonce: UInt64) -> Bool { if receivedNonce + UInt64(Self.REPLAY_WINDOW_SIZE) <= highestReceivedNonce { return false // Too old, outside window } - + if receivedNonce > highestReceivedNonce { return true // Always accept newer nonces } - + let offset = Int(highestReceivedNonce - receivedNonce) let byteIndex = offset / 8 let bitIndex = offset % 8 - + return (replayWindow[byteIndex] & (1 << bitIndex)) == 0 // Not yet seen } - + /// Mark nonce as seen in replay window private func markNonceAsSeen(_ receivedNonce: UInt64) { if receivedNonce > highestReceivedNonce { let shift = Int(receivedNonce - highestReceivedNonce) - + if shift >= Self.REPLAY_WINDOW_SIZE { // Clear entire window - shift is too large replayWindow = Array(repeating: 0, count: Self.REPLAY_WINDOW_BYTES) @@ -194,18 +196,18 @@ final class NoiseCipherState { for i in stride(from: Self.REPLAY_WINDOW_BYTES - 1, through: 0, by: -1) { let sourceByteIndex = i - shift / 8 var newByte: UInt8 = 0 - + if sourceByteIndex >= 0 { newByte = replayWindow[sourceByteIndex] >> (shift % 8) if sourceByteIndex > 0 && shift % 8 != 0 { newByte |= replayWindow[sourceByteIndex - 1] << (8 - shift % 8) } } - + replayWindow[i] = newByte } } - + highestReceivedNonce = receivedNonce replayWindow[0] |= 1 // Mark most recent bit as seen } else { @@ -215,7 +217,7 @@ final class NoiseCipherState { replayWindow[byteIndex] |= (1 << bitIndex) } } - + /// Extract nonce from combined payload /// Returns tuple of (nonce, ciphertext) or nil if invalid private func extractNonceFromCiphertextPayload(_ combinedPayload: Data) throws -> (nonce: UInt64, ciphertext: Data)? { @@ -244,36 +246,36 @@ final class NoiseCipherState { 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.. Data { guard let key = self.key else { throw NoiseError.uninitializedCipher } - + // Debug logging for nonce tracking let currentNonce = nonce - + // Check if nonce exceeds 4-byte limit (UInt32 max value) guard nonce <= UInt64(UInt32.max) - 1 else { throw NoiseError.nonceExceeded } - + // Create nonce from counter var nonceData = Data(count: 12) withUnsafeBytes(of: currentNonce.littleEndian) { bytes in nonceData.replaceSubrange(4..<12, with: bytes) } - + let sealedBox = try ChaChaPoly.seal(plaintext, using: key, nonce: ChaChaPoly.Nonce(data: nonceData), authenticating: associatedData) // increment local nonce nonce += 1 - + // Create combined payload: let combinedPayload: Data if (useExtractedNonce) { @@ -282,35 +284,35 @@ final class NoiseCipherState { } else { combinedPayload = sealedBox.ciphertext + sealedBox.tag } - + // Log high nonce values that might indicate issues if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD { SecureLogger.warning("High nonce value detected: \(currentNonce) - consider rekeying", category: .encryption) } - + return combinedPayload } - + func decrypt(ciphertext: Data, associatedData: Data = Data()) throws -> Data { guard let key = self.key else { throw NoiseError.uninitializedCipher } - + guard ciphertext.count >= 16 else { throw NoiseError.invalidCiphertext } - + let encryptedData: Data let tag: Data let decryptionNonce: UInt64 - + if useExtractedNonce { // Extract nonce and ciphertext from combined payload guard let (extractedNonce, actualCiphertext) = try extractNonceFromCiphertextPayload(ciphertext) else { SecureLogger.debug("Decrypt failed: Could not extract nonce from payload") throw NoiseError.invalidCiphertext } - + // Validate nonce with sliding window replay protection guard isValidNonce(extractedNonce) else { SecureLogger.debug("Replay attack detected: nonce \(extractedNonce) rejected") @@ -327,27 +329,27 @@ final class NoiseCipherState { tag = ciphertext.suffix(16) decryptionNonce = nonce } - + // Create nonce from counter var nonceData = Data(count: 12) withUnsafeBytes(of: decryptionNonce.littleEndian) { bytes in nonceData.replaceSubrange(4..<12, with: bytes) } - + let sealedBox = try ChaChaPoly.SealedBox( nonce: ChaChaPoly.Nonce(data: nonceData), ciphertext: encryptedData, tag: tag ) - + // Log high nonce values that might indicate issues if decryptionNonce > Self.HIGH_NONCE_WARNING_THRESHOLD { SecureLogger.warning("High nonce value detected: \(decryptionNonce) - consider rekeying", category: .encryption) } - + do { let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData) - + if useExtractedNonce { // Mark nonce as seen after successful decryption markNonceAsSeen(decryptionNonce) @@ -361,16 +363,16 @@ final class NoiseCipherState { throw error } } - + /// Securely clear sensitive cryptographic data from memory func clearSensitiveData() { // Clear the symmetric key key = nil - + // Reset nonce nonce = 0 highestReceivedNonce = 0 - + // Clear replay window for i in 0.. Data { return hash } - + func hasCipherKey() -> Bool { return cipherState.hasKey() } - + func encryptAndHash(_ plaintext: Data) throws -> Data { if cipherState.hasKey() { let ciphertext = try cipherState.encrypt(plaintext: plaintext, associatedData: hash) @@ -439,7 +441,7 @@ final class NoiseSymmetricState { return plaintext } } - + func decryptAndHash(_ ciphertext: Data) throws -> Data { if cipherState.hasKey() { let plaintext = try cipherState.decrypt(ciphertext: ciphertext, associatedData: hash) @@ -450,26 +452,26 @@ final class NoiseSymmetricState { return ciphertext } } - + func split() -> (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) - + return (c1, c2) } - + // HKDF implementation private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] { let tempKey = HMAC.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey)) let tempKeyData = Data(tempKey) - + var outputs: [Data] = [] var currentOutput = Data() - + for i in 1...numOutputs { currentOutput = Data(HMAC.authenticationCode( for: currentOutput + Data([UInt8(i)]), @@ -477,7 +479,7 @@ final class NoiseSymmetricState { )) outputs.append(currentOutput) } - + return outputs } } @@ -493,52 +495,61 @@ final class NoiseHandshakeState { private let pattern: NoisePattern private let keychain: KeychainManagerProtocol private var symmetricState: NoiseSymmetricState - + // Keys private var localStaticPrivate: Curve25519.KeyAgreement.PrivateKey? private var localStaticPublic: Curve25519.KeyAgreement.PublicKey? private var localEphemeralPrivate: Curve25519.KeyAgreement.PrivateKey? private var localEphemeralPublic: Curve25519.KeyAgreement.PublicKey? - + private var remoteStaticPublic: Curve25519.KeyAgreement.PublicKey? private var remoteEphemeralPublic: Curve25519.KeyAgreement.PublicKey? - + // Message patterns 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 { self.localStaticPrivate = localKey self.localStaticPublic = localKey.publicKey } self.remoteStaticPublic = remoteStaticKey - + // Initialize protocol name let protocolName = NoiseProtocolName(pattern: pattern.patternName) self.symmetricState = NoiseSymmetricState(protocolName: protocolName.fullName) - + // Initialize message patterns self.messagePatterns = pattern.messagePatterns - + // Mix pre-message keys according to pattern mixPreMessageKeys() } - + 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 { @@ -551,24 +562,29 @@ final class NoiseHandshakeState { } } } - + func writeMessage(payload: Data = Data()) throws -> Data { 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) - + case .s: // Send static key (encrypted if cipher is initialized) guard let staticPublic = localStaticPublic else { @@ -576,7 +592,7 @@ final class NoiseHandshakeState { } let encrypted = try symmetricState.encryptAndHash(staticPublic.rawRepresentation) messageBuffer.append(encrypted) - + case .ee: // DH(local ephemeral, remote ephemeral) guard let localEphemeral = localEphemeralPrivate, @@ -588,7 +604,7 @@ final class NoiseHandshakeState { symmetricState.mixKey(sharedData) // Clear sensitive shared secret keychain.secureClear(&sharedData) - + case .es: // DH(ephemeral, static) - direction depends on role if role == .initiator { @@ -606,7 +622,7 @@ final class NoiseHandshakeState { let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral) symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) } - + case .se: // DH(static, ephemeral) - direction depends on role if role == .initiator { @@ -624,7 +640,7 @@ final class NoiseHandshakeState { let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic) symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) } - + case .ss: // DH(static, static) guard let localStatic = localStaticPrivate, @@ -638,24 +654,24 @@ final class NoiseHandshakeState { keychain.secureClear(&sharedData) } } - + // Encrypt payload let encryptedPayload = try symmetricState.encryptAndHash(payload) messageBuffer.append(encryptedPayload) - + currentPattern += 1 return messageBuffer } - + func readMessage(_ message: Data, expectedPayloadLength: Int = 0) throws -> Data { - + guard currentPattern < messagePatterns.count else { throw NoiseError.handshakeComplete } - + var buffer = message let patterns = messagePatterns[currentPattern] - + for pattern in patterns { switch pattern { case .e: @@ -665,7 +681,7 @@ final class NoiseHandshakeState { } let ephemeralData = buffer.prefix(32) buffer = buffer.dropFirst(32) - + do { remoteEphemeralPublic = try NoiseHandshakeState.validatePublicKey(ephemeralData) } catch { @@ -673,7 +689,7 @@ final class NoiseHandshakeState { throw NoiseError.invalidMessage } symmetricState.mixHash(ephemeralData) - + case .s: // Read static key (may be encrypted) let keyLength = symmetricState.hasCipherKey() ? 48 : 32 // 32 + 16 byte tag if encrypted @@ -689,20 +705,20 @@ final class NoiseHandshakeState { SecureLogger.error(.authenticationFailed(peerID: "Unknown - handshake")) throw NoiseError.authenticationFailure } - + case .ee, .es, .se, .ss: // Same DH operations as in writeMessage try performDHOperation(pattern) } } - + // Decrypt payload let payload = try symmetricState.decryptAndHash(buffer) currentPattern += 1 - + return payload } - + private func performDHOperation(_ pattern: NoiseMessagePattern) throws { switch pattern { case .ee: @@ -712,7 +728,7 @@ final class NoiseHandshakeState { } let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral) symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) - + case .es: if role == .initiator { guard let localEphemeral = localEphemeralPrivate, @@ -735,7 +751,7 @@ final class NoiseHandshakeState { // Clear sensitive shared secret keychain.secureClear(&sharedData) } - + case .se: if role == .initiator { guard let localStatic = localStaticPrivate, @@ -758,7 +774,7 @@ final class NoiseHandshakeState { // Clear sensitive shared secret keychain.secureClear(&sharedData) } - + case .ss: guard let localStatic = localStaticPrivate, let remoteStatic = remoteStaticPublic else { @@ -766,32 +782,32 @@ final class NoiseHandshakeState { } let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic) symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) - + case .e, .s: break } } - + func isHandshakeComplete() -> Bool { return currentPattern >= messagePatterns.count } - + func getTransportCiphers() throws -> (send: NoiseCipherState, receive: NoiseCipherState) { guard isHandshakeComplete() else { throw NoiseError.handshakeNotComplete } - + let (c1, c2) = symmetricState.split() - + // Initiator uses c1 for sending, c2 for receiving // Responder uses c2 for sending, c1 for receiving return role == .initiator ? (c1, c2) : (c2, c1) } - + func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? { return remoteStaticPublic } - + func getHandshakeHash() -> Data { return symmetricState.getHandshakeHash() } @@ -807,7 +823,7 @@ extension NoisePattern { case .NK: return "NK" } } - + var messagePatterns: [[NoiseMessagePattern]] { switch self { case .XX: @@ -856,12 +872,12 @@ extension NoiseHandshakeState { guard keyData.count == 32 else { throw NoiseError.invalidPublicKey } - + // Check for all-zero key (point at infinity) if keyData.allSatisfy({ $0 == 0 }) { throw NoiseError.invalidPublicKey } - + // Check for low-order points that could enable small subgroup attacks // These are the known bad points for Curve25519 let lowOrderPoints: [Data] = [ @@ -882,13 +898,13 @@ extension NoiseHandshakeState { 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point ] - + // Check against known bad points if lowOrderPoints.contains(keyData) { SecureLogger.warning("Low-order point detected", category: .security) throw NoiseError.invalidPublicKey } - + // Try to create the key - CryptoKit will validate curve points internally do { let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyData) diff --git a/bitchat/Noise/NoiseTestRunner.swift b/bitchat/Noise/NoiseTestRunner.swift new file mode 100644 index 00000000..da8a05e7 --- /dev/null +++ b/bitchat/Noise/NoiseTestRunner.swift @@ -0,0 +1,293 @@ +import CryptoKit +import Foundation + +// MARK: - Minimal Mocks for Dependencies + +protocol KeychainManagerProtocol { + func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool + func getIdentityKey(forKey key: String) -> Data? + func deleteIdentityKey(forKey key: String) -> Bool + func deleteAllKeychainData() -> Bool + func secureClear(_ data: inout Data) + func secureClear(_ string: inout String) + func verifyIdentityKeyExists() -> Bool +} + +struct MockKeychain: KeychainManagerProtocol { + func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { true } + func getIdentityKey(forKey key: String) -> Data? { nil } + func deleteIdentityKey(forKey key: String) -> Bool { true } + func deleteAllKeychainData() -> Bool { true } + func secureClear(_ data: inout Data) { data.resetBytes(in: 0.. Bool { true } +} + +enum SecureLogCategory { case encryption, security } + +struct SecureLogger { + static func debug(_ message: String, category: SecureLogCategory? = nil) {} + static func info(_ message: Any) {} + static func warning(_ message: String, category: SecureLogCategory) {} + static func error(_ message: Any, category: SecureLogCategory? = nil) {} + static func logKeyOperation(_ op: String, keyType: String, success: Bool) {} +} + +enum SecureLogEvent { + case handshakeCompleted(peerID: String) + case sessionExpired(peerID: String) + case authenticationFailed(peerID: String) +} + +extension SecureLogger { + static func error(_ event: SecureLogEvent) {} + static func info(_ event: SecureLogEvent) {} +} + +// MARK: - Test Vector Structure + +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 + } +} + +// MARK: - Helper Extensions + +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() + } +} + +// MARK: - Test Runner + +func runNoiseTests() { + print("=== Noise Protocol Test Vector Runner ===\n") + + // Load test vectors + guard let testData = try? Data(contentsOf: URL(fileURLWithPath: "NoiseTestVectors.json")), + let testVectors = try? JSONDecoder().decode([NoiseTestVector].self, from: testData) + else { + print("❌ Failed to load test vectors") + exit(1) + } + + print("Found \(testVectors.count) test vector(s)\n") + + for (index, testVector) in testVectors.enumerated() { + print("=== Test Vector \(index + 1) ===") + print("Protocol: \(testVector.protocol_name)") + runSingleTest(testVector) + print("") + } + + print("=== All Test Vectors Passed! ===") +} + +func runSingleTest(_ testVector: NoiseTestVector) { + + // 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 { + print("❌ Failed to parse test vector hex strings") + exit(1) + } + + 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 { + print("❌ Failed to create keys from test vectors") + exit(1) + } + + 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 + ) + + print("\n--- Handshake Phase ---") + + // Message 1: Initiator -> Responder (e) + guard let msg1 = try? initiatorHandshake.writeMessage() else { + print("❌ Failed to write message 1") + exit(1) + } + print("✓ Message 1: Initiator sent ephemeral (\(msg1.count) bytes)") + + guard (try? responderHandshake.readMessage(msg1)) != nil else { + print("❌ Failed to read message 1") + exit(1) + } + print("✓ Message 1: Responder received") + + // Message 2: Responder -> Initiator (e, ee, s, es) + guard let msg2 = try? responderHandshake.writeMessage() else { + print("❌ Failed to write message 2") + exit(1) + } + print("✓ Message 2: Responder sent (\(msg2.count) bytes)") + + guard (try? initiatorHandshake.readMessage(msg2)) != nil else { + print("❌ Failed to read message 2") + exit(1) + } + print("✓ Message 2: Initiator received") + + // Message 3: Initiator -> Responder (s, se) + guard let msg3 = try? initiatorHandshake.writeMessage() else { + print("❌ Failed to write message 3") + exit(1) + } + print("✓ Message 3: Initiator sent (\(msg3.count) bytes)") + + guard (try? responderHandshake.readMessage(msg3)) != nil else { + print("❌ Failed to read message 3") + exit(1) + } + print("✓ Message 3: Responder received") + + // Verify handshake hash + let initiatorHash = initiatorHandshake.getHandshakeHash() + let responderHash = responderHandshake.getHandshakeHash() + + if initiatorHash != responderHash { + print("❌ Initiator and responder hashes don't match!") + exit(1) + } + + if let expectedHash = expectedHash { + if initiatorHash == expectedHash { + print("✓ Handshake hash verified") + } else { + print("⚠️ Handshake hash differs from test vector (may be implementation-specific)") + } + } else { + print("✓ Handshake complete") + } + + // Get transport ciphers + guard let (initSend, initRecv) = try? initiatorHandshake.getTransportCiphers(), + let (respSend, respRecv) = try? responderHandshake.getTransportCiphers() + else { + print("❌ Failed to split to transport ciphers") + exit(1) + } + + print("\n--- Transport Phase ---") + + // Test transport messages + var passedMessages = 0 + for (index, testMsg) in testVector.messages.enumerated() { + guard let payload = Data(hex: testMsg.payload) else { + print("❌ Message \(index + 1): Failed to parse payload hex") + exit(1) + } + + // Alternate between initiator and responder sending + let (sender, receiver): (NoiseCipherState, NoiseCipherState) + let direction: String + if index % 2 == 0 { + sender = initSend + receiver = respRecv + direction = "Initiator → Responder" + } else { + sender = respSend + receiver = initRecv + direction = "Responder → Initiator" + } + + // Encrypt + guard let ciphertext = try? sender.encrypt(plaintext: payload) else { + print("❌ Message \(index + 1): Encryption failed") + exit(1) + } + + // Decrypt + guard let decrypted = try? receiver.decrypt(ciphertext: ciphertext) else { + print("❌ Message \(index + 1): Decryption failed") + print(" Ciphertext: \(ciphertext.hexString())") + exit(1) + } + + if decrypted == payload { + print( + "✓ Message \(index + 1) (\(direction)): Encrypt/decrypt successful (\(payload.count) bytes)" + ) + passedMessages += 1 + } else { + print("❌ Message \(index + 1): Decrypted payload mismatch!") + print(" Expected: \(payload.hexString())") + print(" Got: \(decrypted.hexString())") + exit(1) + } + } + + print("✓ Test Passed!") + print(" Handshake: ✓") + print(" Transport Messages: \(passedMessages)/\(testVector.messages.count) ✓") +} + +// MARK: - Main Entry Point + +@main +struct NoiseTestRunner { + static func main() { + runNoiseTests() + } +} diff --git a/bitchat/Noise/NoiseTestVectors.json b/bitchat/Noise/NoiseTestVectors.json new file mode 100644 index 00000000..1ced9298 --- /dev/null +++ b/bitchat/Noise/NoiseTestVectors.json @@ -0,0 +1,71 @@ +[ + { + "protocol_name": "Noise_XX_25519_ChaChaPoly_SHA256", + "init_prologue": "4a6f686e2047616c74", + "init_static": "e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1", + "init_ephemeral": "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a", + "resp_prologue": "4a6f686e2047616c74", + "resp_static": "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893", + "resp_ephemeral": "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b", + "handshake_hash": "c8e5f64e846193be2a834104c2a009868d6c9f3bd3c186299888b488b2f1f58e", + "messages": [ + { + "payload": "4c756477696720766f6e204d69736573", + "ciphertext": "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c79444c756477696720766f6e204d69736573" + }, + { + "payload": "4d757272617920526f746862617264", + "ciphertext": "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f14480884381cbad1f276e038c48378ffce2b65285e08d6b68aaa3629a5a8639392490e5b9bd5269c2f1e4f488ed8831161f19b7815528f8982ffe09be9b5c412f8a0db50f8814c7194e83f23dbd8d162c9326ad" + }, + { + "payload": "462e20412e20486179656b", + "ciphertext": "c7195ffacac1307ff99046f219750fc47693e23c3cb08b89c2af808b444850a80ae475b9df0f169ae80a89be0865b57f58c9fea0d4ec82a286427402f113e4b6ae769a1d95941d49b25030" + }, + { + "payload": "4361726c204d656e676572", + "ciphertext": "96763ed773f8e47bb3712f0e29b3060ffc956ffc146cee53d5e1df" + }, + { + "payload": "4a65616e2d426170746973746520536179", + "ciphertext": "3e40f15f6f3a46ae446b253bf8b1d9ffb6ed9b174d272328ff91a7e2e5c79c07f5" + }, + { + "payload": "457567656e2042f6686d20766f6e2042617765726b", + "ciphertext": "eb3f3515110702e047a6c9da4478b6ead94873c11c0f2d710ddb3f09fce024b3a58502ae3f" + } + ] + }, + { + "protocol_name": "Noise_XX_25519_ChaChaPoly_SHA256", + "init_prologue": "5468657265206973206e6f20726967687420616e642077726f6e672e2054686572652773206f6e6c792066756e20616e6420626f72696e672e", + "init_psks": [], + "init_static": "7dec208517a3b81a2861d7a71266d5d6dc944c5a8816634a86fe63198a0148ee", + "init_ephemeral": "a32daf21e93c0131495ce1d903181fde81cc46937daaeb990bae7c992709421e", + "resp_prologue": "5468657265206973206e6f20726967687420616e642077726f6e672e2054686572652773206f6e6c792066756e20616e6420626f72696e672e", + "resp_psks": [], + "resp_static": "4d0aed5098e3b4ef20357e9f686ce66204c792b358da2e475017d6c485304881", + "resp_ephemeral": "4eece0f195d026db035ff987597c429d3ad3bcc2944df37d649528951b2a27c5", + "messages": [ + { + "payload": "d03c489139e645d0711a3c9e810d776b46a84912463fafa87b884eebf242dc34", + "ciphertext": "f9fa868ba97ab8a2686deccfaad5a484ee10a5bb85e3d1dce015a84797f92818d03c489139e645d0711a3c9e810d776b46a84912463fafa87b884eebf242dc34" + }, + { + "payload": "d8190a92f7dc0c93dbea9118ba8055751fb7c6590c416ffbd419964132b99a85", + "ciphertext": "8c4e6fdb7d09d501a86f7eca5c234522751706ed409182c05cdf5f827d4dae47b81c6c5f43b025692c24391eefee725c17d8cb0fbe3e4abb8aedf42c4fd2592d4ea48ac08989d6ae8b4adae08b2c34087c808c7aa55a63c02b0fab9e930612336bd43eaea04d3c670a0a146691aa9cc9d357872320dc735dbc48580cffb553db" + }, + { + "payload": "77891b19dcb92ef7c055b672c4a5aa7fdf1c84146b8b303459022729473ce254", + "ciphertext": "933ca6b5ed60df3df66121f0ab49a09e49efa45c613a86a3cecbf4c535cef2f83f72b42837b18e3572f2fdc2b74c331e2368a545cef54bdca081678ab0e9dd5348122459e0c034c851984d88ce610963d43cde6cfe73a67fbd5a63e8bfca96d0" + }, + { + "payload": "d7efdf988072881941db045a42882433817555128fbf5663e56081712ec7d212", + "ciphertext": "54ef0ff0629e1aaa7685a2806ab111cba76b52331f2642276736f415868eacb69ab2577f3bda0cbf72f879685f6ed25f" + }, + { + "payload": "dd7bf01a588bafb52c6cfba952e5d8fe35cc2b3f92b4730ae2474615157345ce", + "ciphertext": "356be70f110306d5c699bb834bb9d58d909e325924dfbec972e406e6f294dc63e1daebefe8a62a334facc8048ab4ad66" + } + ] + } +] diff --git a/bitchat/Noise/README.md b/bitchat/Noise/README.md new file mode 100644 index 00000000..55cbeab8 --- /dev/null +++ b/bitchat/Noise/README.md @@ -0,0 +1,13 @@ +# Unnamed Noise_XX_25519_ChaChaPoly_SHA256 Implementation in Swift + +## Running Tests + +This implementation comes with a bare-minimum test harness, intended to ensure that it at least passes the test vectors as stipulated by both [cacophony and snow](https://github.com/mcginty/snow/tree/main/tests). + +In order to run the tests: + +```bash +swiftc -o NoiseTestRunner -D NOISE_TESTS NoiseTestRunner.swift NoiseProtocol.swift ../Utils/Data+SHA256.swift ../Protocols/BinaryEncodingUtils.swift +./NoiseTestRunner +rm NoiseTestRunner +``` From 109b7d0e03d1c8c0fec1adee6db48a950cd61902 Mon Sep 17 00:00:00 2001 From: Nadim Kobeissi Date: Tue, 18 Nov 2025 19:15:54 +0200 Subject: [PATCH 2/9] Move new Noise test vectors into XCTests (WIP) --- bitchat.xcodeproj/project.pbxproj | 34 + bitchat/Noise/NoiseProtocol.swift | 2 - bitchat/Noise/NoiseTestRunner.swift | 293 ----- bitchat/Noise/NoiseTestVectors.json | 71 -- bitchatTests/Noise/NoiseProtocolTests.swift | 1250 +++++++++++-------- bitchatTests/Noise/NoiseTestVectors.json | 71 ++ 6 files changed, 838 insertions(+), 883 deletions(-) delete mode 100644 bitchat/Noise/NoiseTestRunner.swift delete mode 100644 bitchat/Noise/NoiseTestVectors.json create mode 100644 bitchatTests/Noise/NoiseTestVectors.json 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 1a8d0c6a..465ddae0 100644 --- a/bitchat/Noise/NoiseProtocol.swift +++ b/bitchat/Noise/NoiseProtocol.swift @@ -77,9 +77,7 @@ /// - Noise Specification: http://www.noiseprotocol.org/noise.html /// -#if !NOISE_TESTS import BitLogger -#endif import Foundation import CryptoKit diff --git a/bitchat/Noise/NoiseTestRunner.swift b/bitchat/Noise/NoiseTestRunner.swift deleted file mode 100644 index da8a05e7..00000000 --- a/bitchat/Noise/NoiseTestRunner.swift +++ /dev/null @@ -1,293 +0,0 @@ -import CryptoKit -import Foundation - -// MARK: - Minimal Mocks for Dependencies - -protocol KeychainManagerProtocol { - func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool - func getIdentityKey(forKey key: String) -> Data? - func deleteIdentityKey(forKey key: String) -> Bool - func deleteAllKeychainData() -> Bool - func secureClear(_ data: inout Data) - func secureClear(_ string: inout String) - func verifyIdentityKeyExists() -> Bool -} - -struct MockKeychain: KeychainManagerProtocol { - func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool { true } - func getIdentityKey(forKey key: String) -> Data? { nil } - func deleteIdentityKey(forKey key: String) -> Bool { true } - func deleteAllKeychainData() -> Bool { true } - func secureClear(_ data: inout Data) { data.resetBytes(in: 0.. Bool { true } -} - -enum SecureLogCategory { case encryption, security } - -struct SecureLogger { - static func debug(_ message: String, category: SecureLogCategory? = nil) {} - static func info(_ message: Any) {} - static func warning(_ message: String, category: SecureLogCategory) {} - static func error(_ message: Any, category: SecureLogCategory? = nil) {} - static func logKeyOperation(_ op: String, keyType: String, success: Bool) {} -} - -enum SecureLogEvent { - case handshakeCompleted(peerID: String) - case sessionExpired(peerID: String) - case authenticationFailed(peerID: String) -} - -extension SecureLogger { - static func error(_ event: SecureLogEvent) {} - static func info(_ event: SecureLogEvent) {} -} - -// MARK: - Test Vector Structure - -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 - } -} - -// MARK: - Helper Extensions - -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() - } -} - -// MARK: - Test Runner - -func runNoiseTests() { - print("=== Noise Protocol Test Vector Runner ===\n") - - // Load test vectors - guard let testData = try? Data(contentsOf: URL(fileURLWithPath: "NoiseTestVectors.json")), - let testVectors = try? JSONDecoder().decode([NoiseTestVector].self, from: testData) - else { - print("❌ Failed to load test vectors") - exit(1) - } - - print("Found \(testVectors.count) test vector(s)\n") - - for (index, testVector) in testVectors.enumerated() { - print("=== Test Vector \(index + 1) ===") - print("Protocol: \(testVector.protocol_name)") - runSingleTest(testVector) - print("") - } - - print("=== All Test Vectors Passed! ===") -} - -func runSingleTest(_ testVector: NoiseTestVector) { - - // 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 { - print("❌ Failed to parse test vector hex strings") - exit(1) - } - - 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 { - print("❌ Failed to create keys from test vectors") - exit(1) - } - - 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 - ) - - print("\n--- Handshake Phase ---") - - // Message 1: Initiator -> Responder (e) - guard let msg1 = try? initiatorHandshake.writeMessage() else { - print("❌ Failed to write message 1") - exit(1) - } - print("✓ Message 1: Initiator sent ephemeral (\(msg1.count) bytes)") - - guard (try? responderHandshake.readMessage(msg1)) != nil else { - print("❌ Failed to read message 1") - exit(1) - } - print("✓ Message 1: Responder received") - - // Message 2: Responder -> Initiator (e, ee, s, es) - guard let msg2 = try? responderHandshake.writeMessage() else { - print("❌ Failed to write message 2") - exit(1) - } - print("✓ Message 2: Responder sent (\(msg2.count) bytes)") - - guard (try? initiatorHandshake.readMessage(msg2)) != nil else { - print("❌ Failed to read message 2") - exit(1) - } - print("✓ Message 2: Initiator received") - - // Message 3: Initiator -> Responder (s, se) - guard let msg3 = try? initiatorHandshake.writeMessage() else { - print("❌ Failed to write message 3") - exit(1) - } - print("✓ Message 3: Initiator sent (\(msg3.count) bytes)") - - guard (try? responderHandshake.readMessage(msg3)) != nil else { - print("❌ Failed to read message 3") - exit(1) - } - print("✓ Message 3: Responder received") - - // Verify handshake hash - let initiatorHash = initiatorHandshake.getHandshakeHash() - let responderHash = responderHandshake.getHandshakeHash() - - if initiatorHash != responderHash { - print("❌ Initiator and responder hashes don't match!") - exit(1) - } - - if let expectedHash = expectedHash { - if initiatorHash == expectedHash { - print("✓ Handshake hash verified") - } else { - print("⚠️ Handshake hash differs from test vector (may be implementation-specific)") - } - } else { - print("✓ Handshake complete") - } - - // Get transport ciphers - guard let (initSend, initRecv) = try? initiatorHandshake.getTransportCiphers(), - let (respSend, respRecv) = try? responderHandshake.getTransportCiphers() - else { - print("❌ Failed to split to transport ciphers") - exit(1) - } - - print("\n--- Transport Phase ---") - - // Test transport messages - var passedMessages = 0 - for (index, testMsg) in testVector.messages.enumerated() { - guard let payload = Data(hex: testMsg.payload) else { - print("❌ Message \(index + 1): Failed to parse payload hex") - exit(1) - } - - // Alternate between initiator and responder sending - let (sender, receiver): (NoiseCipherState, NoiseCipherState) - let direction: String - if index % 2 == 0 { - sender = initSend - receiver = respRecv - direction = "Initiator → Responder" - } else { - sender = respSend - receiver = initRecv - direction = "Responder → Initiator" - } - - // Encrypt - guard let ciphertext = try? sender.encrypt(plaintext: payload) else { - print("❌ Message \(index + 1): Encryption failed") - exit(1) - } - - // Decrypt - guard let decrypted = try? receiver.decrypt(ciphertext: ciphertext) else { - print("❌ Message \(index + 1): Decryption failed") - print(" Ciphertext: \(ciphertext.hexString())") - exit(1) - } - - if decrypted == payload { - print( - "✓ Message \(index + 1) (\(direction)): Encrypt/decrypt successful (\(payload.count) bytes)" - ) - passedMessages += 1 - } else { - print("❌ Message \(index + 1): Decrypted payload mismatch!") - print(" Expected: \(payload.hexString())") - print(" Got: \(decrypted.hexString())") - exit(1) - } - } - - print("✓ Test Passed!") - print(" Handshake: ✓") - print(" Transport Messages: \(passedMessages)/\(testVector.messages.count) ✓") -} - -// MARK: - Main Entry Point - -@main -struct NoiseTestRunner { - static func main() { - runNoiseTests() - } -} diff --git a/bitchat/Noise/NoiseTestVectors.json b/bitchat/Noise/NoiseTestVectors.json deleted file mode 100644 index 1ced9298..00000000 --- a/bitchat/Noise/NoiseTestVectors.json +++ /dev/null @@ -1,71 +0,0 @@ -[ - { - "protocol_name": "Noise_XX_25519_ChaChaPoly_SHA256", - "init_prologue": "4a6f686e2047616c74", - "init_static": "e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1", - "init_ephemeral": "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a", - "resp_prologue": "4a6f686e2047616c74", - "resp_static": "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893", - "resp_ephemeral": "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b", - "handshake_hash": "c8e5f64e846193be2a834104c2a009868d6c9f3bd3c186299888b488b2f1f58e", - "messages": [ - { - "payload": "4c756477696720766f6e204d69736573", - "ciphertext": "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c79444c756477696720766f6e204d69736573" - }, - { - "payload": "4d757272617920526f746862617264", - "ciphertext": "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f14480884381cbad1f276e038c48378ffce2b65285e08d6b68aaa3629a5a8639392490e5b9bd5269c2f1e4f488ed8831161f19b7815528f8982ffe09be9b5c412f8a0db50f8814c7194e83f23dbd8d162c9326ad" - }, - { - "payload": "462e20412e20486179656b", - "ciphertext": "c7195ffacac1307ff99046f219750fc47693e23c3cb08b89c2af808b444850a80ae475b9df0f169ae80a89be0865b57f58c9fea0d4ec82a286427402f113e4b6ae769a1d95941d49b25030" - }, - { - "payload": "4361726c204d656e676572", - "ciphertext": "96763ed773f8e47bb3712f0e29b3060ffc956ffc146cee53d5e1df" - }, - { - "payload": "4a65616e2d426170746973746520536179", - "ciphertext": "3e40f15f6f3a46ae446b253bf8b1d9ffb6ed9b174d272328ff91a7e2e5c79c07f5" - }, - { - "payload": "457567656e2042f6686d20766f6e2042617765726b", - "ciphertext": "eb3f3515110702e047a6c9da4478b6ead94873c11c0f2d710ddb3f09fce024b3a58502ae3f" - } - ] - }, - { - "protocol_name": "Noise_XX_25519_ChaChaPoly_SHA256", - "init_prologue": "5468657265206973206e6f20726967687420616e642077726f6e672e2054686572652773206f6e6c792066756e20616e6420626f72696e672e", - "init_psks": [], - "init_static": "7dec208517a3b81a2861d7a71266d5d6dc944c5a8816634a86fe63198a0148ee", - "init_ephemeral": "a32daf21e93c0131495ce1d903181fde81cc46937daaeb990bae7c992709421e", - "resp_prologue": "5468657265206973206e6f20726967687420616e642077726f6e672e2054686572652773206f6e6c792066756e20616e6420626f72696e672e", - "resp_psks": [], - "resp_static": "4d0aed5098e3b4ef20357e9f686ce66204c792b358da2e475017d6c485304881", - "resp_ephemeral": "4eece0f195d026db035ff987597c429d3ad3bcc2944df37d649528951b2a27c5", - "messages": [ - { - "payload": "d03c489139e645d0711a3c9e810d776b46a84912463fafa87b884eebf242dc34", - "ciphertext": "f9fa868ba97ab8a2686deccfaad5a484ee10a5bb85e3d1dce015a84797f92818d03c489139e645d0711a3c9e810d776b46a84912463fafa87b884eebf242dc34" - }, - { - "payload": "d8190a92f7dc0c93dbea9118ba8055751fb7c6590c416ffbd419964132b99a85", - "ciphertext": "8c4e6fdb7d09d501a86f7eca5c234522751706ed409182c05cdf5f827d4dae47b81c6c5f43b025692c24391eefee725c17d8cb0fbe3e4abb8aedf42c4fd2592d4ea48ac08989d6ae8b4adae08b2c34087c808c7aa55a63c02b0fab9e930612336bd43eaea04d3c670a0a146691aa9cc9d357872320dc735dbc48580cffb553db" - }, - { - "payload": "77891b19dcb92ef7c055b672c4a5aa7fdf1c84146b8b303459022729473ce254", - "ciphertext": "933ca6b5ed60df3df66121f0ab49a09e49efa45c613a86a3cecbf4c535cef2f83f72b42837b18e3572f2fdc2b74c331e2368a545cef54bdca081678ab0e9dd5348122459e0c034c851984d88ce610963d43cde6cfe73a67fbd5a63e8bfca96d0" - }, - { - "payload": "d7efdf988072881941db045a42882433817555128fbf5663e56081712ec7d212", - "ciphertext": "54ef0ff0629e1aaa7685a2806ab111cba76b52331f2642276736f415868eacb69ab2577f3bda0cbf72f879685f6ed25f" - }, - { - "payload": "dd7bf01a588bafb52c6cfba952e5d8fe35cc2b3f92b4730ae2474615157345ce", - "ciphertext": "356be70f110306d5c699bb834bb9d58d909e325924dfbec972e406e6f294dc63e1daebefe8a62a334facc8048ab4ad66" - } - ] - } -] diff --git a/bitchatTests/Noise/NoiseProtocolTests.swift b/bitchatTests/Noise/NoiseProtocolTests.swift index be4b15a3..a6652093 100644 --- a/bitchatTests/Noise/NoiseProtocolTests.swift +++ b/bitchatTests/Noise/NoiseProtocolTests.swift @@ -6,527 +6,743 @@ // For more information, see // -import Testing import CryptoKit import Foundation +import Testing + @testable import bitchat -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) +// MARK: - Test Vector Support - #expect(manager.getSession(for: alicePeerID) == nil) +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] - _ = 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.. 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 + ) + + // Message 1: Initiator -> Responder (e) + let msg1 = try initiatorHandshake.writeMessage() + #expect(!msg1.isEmpty, "Message 1 should not be empty") + + _ = try responderHandshake.readMessage(msg1) + + // Message 2: Responder -> Initiator (e, ee, s, es) + let msg2 = try responderHandshake.writeMessage() + #expect(!msg2.isEmpty, "Message 2 should not be empty") + + _ = try initiatorHandshake.readMessage(msg2) + + // Message 3: Initiator -> Responder (s, se) + let msg3 = try initiatorHandshake.writeMessage() + #expect(!msg3.isEmpty, "Message 3 should not be empty") + + _ = try responderHandshake.readMessage(msg3) + + // 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") + } + + // Get transport ciphers + let (initSend, initRecv) = try initiatorHandshake.getTransportCiphers() + let (respSend, respRecv) = try responderHandshake.getTransportCiphers() + + // Test transport messages + for (index, testMsg) in testVector.messages.enumerated() { + guard let payload = Data(hex: testMsg.payload) else { + throw NSError( + domain: "NoiseTests", code: 4, + userInfo: [ + NSLocalizedDescriptionKey: + "Message \(index + 1): Failed to parse payload hex" + ]) + } + + // Alternate between initiator and responder sending + let (sender, receiver): (NoiseCipherState, NoiseCipherState) + if index % 2 == 0 { + sender = initSend + receiver = respRecv + } else { + sender = respSend + receiver = initRecv + } + + // Encrypt + let ciphertext = try sender.encrypt(plaintext: payload) + + // Decrypt + let decrypted = try receiver.decrypt(ciphertext: ciphertext) + + #expect( + decrypted == payload, + "Message \(index + 1): Decrypted payload should match original") + } + } } diff --git a/bitchatTests/Noise/NoiseTestVectors.json b/bitchatTests/Noise/NoiseTestVectors.json new file mode 100644 index 00000000..4c595465 --- /dev/null +++ b/bitchatTests/Noise/NoiseTestVectors.json @@ -0,0 +1,71 @@ +[ + { + "protocol_name": "Noise_XX_25519_ChaChaPoly_SHA256", + "init_prologue": "4a6f686e2047616c74", + "init_static": "e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1", + "init_ephemeral": "893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a", + "resp_prologue": "4a6f686e2047616c74", + "resp_static": "4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893", + "resp_ephemeral": "bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b", + "handshake_hash": "c8e5f64e846193be2a834104c2a009868d6c9f3bd3c186299888b488b2f1f58e", + "messages": [ + { + "payload": "4c756477696720766f6e204d69736573", + "ciphertext": "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c79444c756477696720766f6e204d69736573" + }, + { + "payload": "4d757272617920526f746862617264", + "ciphertext": "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f14480884381cbad1f276e038c48378ffce2b65285e08d6b68aaa3629a5a8639392490e5b9bd5269c2f1e4f488ed8831161f19b7815528f8982ffe09be9b5c412f8a0db50f8814c7194e83f23dbd8d162c9326ad" + }, + { + "payload": "462e20412e20486179656b", + "ciphertext": "c7195ffacac1307ff99046f219750fc47693e23c3cb08b89c2af808b444850a80ae475b9df0f169ae80a89be0865b57f58c9fea0d4ec82a286427402f113e4b6ae769a1d95941d49b25030" + }, + { + "payload": "4361726c204d656e676572", + "ciphertext": "96763ed773f8e47bb3712f0e29b3060ffc956ffc146cee53d5e1df" + }, + { + "payload": "4a65616e2d426170746973746520536179", + "ciphertext": "3e40f15f6f3a46ae446b253bf8b1d9ffb6ed9b174d272328ff91a7e2e5c79c07f5" + }, + { + "payload": "457567656e2042f6686d20766f6e2042617765726b", + "ciphertext": "eb3f3515110702e047a6c9da4478b6ead94873c11c0f2d710ddb3f09fce024b3a58502ae3f" + } + ] + }, + { + "protocol_name": "Noise_XX_25519_ChaChaPoly_SHA256", + "init_prologue": "5468657265206973206e6f20726967687420616e642077726f6e672e2054686572652773206f6e6c792066756e20616e6420626f72696e672e", + "init_psks": [], + "init_static": "7dec208517a3b81a2861d7a71266d5d6dc944c5a8816634a86fe63198a0148ee", + "init_ephemeral": "a32daf21e93c0131495ce1d903181fde81cc46937daaeb990bae7c992709421e", + "resp_prologue": "5468657265206973206e6f20726967687420616e642077726f6e672e2054686572652773206f6e6c792066756e20616e6420626f72696e672e", + "resp_psks": [], + "resp_static": "4d0aed5098e3b4ef20357e9f686ce66204c792b358da2e475017d6c485304881", + "resp_ephemeral": "4eece0f195d026db035ff987597c429d3ad3bcc2944df37d649528951b2a27c5", + "messages": [ + { + "payload": "d03c489139e645d0711a3c9e810d776b46a84912463fafa87b884eebf242dc34", + "ciphertext": "f9fa868ba97ab8a2686deccfaad5a484ee10a5bb85e3d1dce015a84797f92818d03c489139e645d0711a3c9e810d776b46a84912463fafa87b884eebf242dc34" + }, + { + "payload": "d8190a92f7dc0c93dbea9118ba8055751fb7c6590c416ffbd419964132b99a85", + "ciphertext": "8c4e6fdb7d09d501a86f7eca5c234522751706ed409182c05cdf5f827d4dae47b81c6c5f43b025692c24391eefee725c17d8cb0fbe3e4abb8aedf42c4fd2592d4ea48ac08989d6ae8b4adae08b2c34087c808c7aa55a63c02b0fab9e930612336bd43eaea04d3c670a0a146691aa9cc9d357872320dc735dbc48580cffb553db" + }, + { + "payload": "77891b19dcb92ef7c055b672c4a5aa7fdf1c84146b8b303459022729473ce254", + "ciphertext": "933ca6b5ed60df3df66121f0ab49a09e49efa45c613a86a3cecbf4c535cef2f83f72b42837b18e3572f2fdc2b74c331e2368a545cef54bdca081678ab0e9dd5348122459e0c034c851984d88ce610963d43cde6cfe73a67fbd5a63e8bfca96d0" + }, + { + "payload": "d7efdf988072881941db045a42882433817555128fbf5663e56081712ec7d212", + "ciphertext": "54ef0ff0629e1aaa7685a2806ab111cba76b52331f2642276736f415868eacb69ab2577f3bda0cbf72f879685f6ed25f" + }, + { + "payload": "dd7bf01a588bafb52c6cfba952e5d8fe35cc2b3f92b4730ae2474615157345ce", + "ciphertext": "356be70f110306d5c699bb834bb9d58d909e325924dfbec972e406e6f294dc63e1daebefe8a62a334facc8048ab4ad66" + } + ] + } +] From 1876e85f8bd6b4788602ddd2b8d57091748bc52f Mon Sep 17 00:00:00 2001 From: Nadim Kobeissi Date: Tue, 18 Nov 2025 19:20:16 +0200 Subject: [PATCH 3/9] Move new Noise test vectors into XCTests (WIP) --- bitchat/Noise/NoiseProtocol.swift | 213 +++++++++++++++--------------- 1 file changed, 106 insertions(+), 107 deletions(-) diff --git a/bitchat/Noise/NoiseProtocol.swift b/bitchat/Noise/NoiseProtocol.swift index 465ddae0..df92bae8 100644 --- a/bitchat/Noise/NoiseProtocol.swift +++ b/bitchat/Noise/NoiseProtocol.swift @@ -115,7 +115,7 @@ struct NoiseProtocolName { let dh: String = "25519" // Curve25519 let cipher: String = "ChaChaPoly" // ChaCha20-Poly1305 let hash: String = "SHA256" // SHA-256 - + var fullName: String { "Noise_\(pattern)_\(dh)_\(cipher)_\(hash)" } @@ -133,59 +133,59 @@ final class NoiseCipherState { private static let REPLAY_WINDOW_SIZE = 1024 private static let REPLAY_WINDOW_BYTES = REPLAY_WINDOW_SIZE / 8 // 128 bytes private static let HIGH_NONCE_WARNING_THRESHOLD: UInt64 = 1_000_000_000 - + private var key: SymmetricKey? private var nonce: UInt64 = 0 private var useExtractedNonce: Bool = false - + // Sliding window replay protection (only used when useExtractedNonce = true) private var highestReceivedNonce: UInt64 = 0 private var replayWindow: [UInt8] = Array(repeating: 0, count: REPLAY_WINDOW_BYTES) - + init() {} - + init(key: SymmetricKey, useExtractedNonce: Bool = false) { self.key = key self.useExtractedNonce = useExtractedNonce } - + deinit { clearSensitiveData() } - + func initializeKey(_ key: SymmetricKey) { self.key = key self.nonce = 0 } - + func hasKey() -> Bool { return key != nil } - + // MARK: - Sliding Window Replay Protection - + /// Check if nonce is valid for replay protection private func isValidNonce(_ receivedNonce: UInt64) -> Bool { if receivedNonce + UInt64(Self.REPLAY_WINDOW_SIZE) <= highestReceivedNonce { return false // Too old, outside window } - + if receivedNonce > highestReceivedNonce { return true // Always accept newer nonces } - + let offset = Int(highestReceivedNonce - receivedNonce) let byteIndex = offset / 8 let bitIndex = offset % 8 - + return (replayWindow[byteIndex] & (1 << bitIndex)) == 0 // Not yet seen } - + /// Mark nonce as seen in replay window private func markNonceAsSeen(_ receivedNonce: UInt64) { if receivedNonce > highestReceivedNonce { let shift = Int(receivedNonce - highestReceivedNonce) - + if shift >= Self.REPLAY_WINDOW_SIZE { // Clear entire window - shift is too large replayWindow = Array(repeating: 0, count: Self.REPLAY_WINDOW_BYTES) @@ -194,18 +194,18 @@ final class NoiseCipherState { for i in stride(from: Self.REPLAY_WINDOW_BYTES - 1, through: 0, by: -1) { let sourceByteIndex = i - shift / 8 var newByte: UInt8 = 0 - + if sourceByteIndex >= 0 { newByte = replayWindow[sourceByteIndex] >> (shift % 8) if sourceByteIndex > 0 && shift % 8 != 0 { newByte |= replayWindow[sourceByteIndex - 1] << (8 - shift % 8) } } - + replayWindow[i] = newByte } } - + highestReceivedNonce = receivedNonce replayWindow[0] |= 1 // Mark most recent bit as seen } else { @@ -215,14 +215,14 @@ final class NoiseCipherState { replayWindow[byteIndex] |= (1 << bitIndex) } } - + /// Extract nonce from combined payload /// Returns tuple of (nonce, ciphertext) or nil if invalid private func extractNonceFromCiphertextPayload(_ combinedPayload: Data) throws -> (nonce: UInt64, ciphertext: Data)? { 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,13 +233,13 @@ 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) @@ -250,30 +250,30 @@ final class NoiseCipherState { } return bytes } - + func encrypt(plaintext: Data, associatedData: Data = Data()) throws -> Data { guard let key = self.key else { throw NoiseError.uninitializedCipher } - + // Debug logging for nonce tracking let currentNonce = nonce - + // Check if nonce exceeds 4-byte limit (UInt32 max value) guard nonce <= UInt64(UInt32.max) - 1 else { throw NoiseError.nonceExceeded } - + // Create nonce from counter var nonceData = Data(count: 12) withUnsafeBytes(of: currentNonce.littleEndian) { bytes in nonceData.replaceSubrange(4..<12, with: bytes) } - + let sealedBox = try ChaChaPoly.seal(plaintext, using: key, nonce: ChaChaPoly.Nonce(data: nonceData), authenticating: associatedData) // increment local nonce nonce += 1 - + // Create combined payload: let combinedPayload: Data if (useExtractedNonce) { @@ -282,41 +282,41 @@ final class NoiseCipherState { } else { combinedPayload = sealedBox.ciphertext + sealedBox.tag } - + // Log high nonce values that might indicate issues if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD { SecureLogger.warning("High nonce value detected: \(currentNonce) - consider rekeying", category: .encryption) } - + return combinedPayload } - + func decrypt(ciphertext: Data, associatedData: Data = Data()) throws -> Data { guard let key = self.key else { throw NoiseError.uninitializedCipher } - + guard ciphertext.count >= 16 else { throw NoiseError.invalidCiphertext } - + let encryptedData: Data let tag: Data let decryptionNonce: UInt64 - + if useExtractedNonce { // Extract nonce and ciphertext from combined payload guard let (extractedNonce, actualCiphertext) = try extractNonceFromCiphertextPayload(ciphertext) else { SecureLogger.debug("Decrypt failed: Could not extract nonce from payload") throw NoiseError.invalidCiphertext } - + // Validate nonce with sliding window replay protection guard isValidNonce(extractedNonce) else { 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) @@ -327,27 +327,27 @@ final class NoiseCipherState { tag = ciphertext.suffix(16) decryptionNonce = nonce } - + // Create nonce from counter var nonceData = Data(count: 12) withUnsafeBytes(of: decryptionNonce.littleEndian) { bytes in nonceData.replaceSubrange(4..<12, with: bytes) } - + let sealedBox = try ChaChaPoly.SealedBox( nonce: ChaChaPoly.Nonce(data: nonceData), ciphertext: encryptedData, tag: tag ) - + // Log high nonce values that might indicate issues if decryptionNonce > Self.HIGH_NONCE_WARNING_THRESHOLD { SecureLogger.warning("High nonce value detected: \(decryptionNonce) - consider rekeying", category: .encryption) } - + do { let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData) - + if useExtractedNonce { // Mark nonce as seen after successful decryption markNonceAsSeen(decryptionNonce) @@ -361,16 +361,16 @@ final class NoiseCipherState { throw error } } - + /// Securely clear sensitive cryptographic data from memory func clearSensitiveData() { // Clear the symmetric key key = nil - + // Reset nonce nonce = 0 highestReceivedNonce = 0 - + // Clear replay window for i in 0.. Data { return hash } - + func hasCipherKey() -> Bool { return cipherState.hasKey() } - + func encryptAndHash(_ plaintext: Data) throws -> Data { if cipherState.hasKey() { let ciphertext = try cipherState.encrypt(plaintext: plaintext, associatedData: hash) @@ -439,7 +439,7 @@ final class NoiseSymmetricState { return plaintext } } - + func decryptAndHash(_ ciphertext: Data) throws -> Data { if cipherState.hasKey() { let plaintext = try cipherState.decrypt(ciphertext: ciphertext, associatedData: hash) @@ -450,26 +450,26 @@ final class NoiseSymmetricState { return ciphertext } } - + func split() -> (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) - + return (c1, c2) } - + // HKDF implementation private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] { let tempKey = HMAC.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey)) let tempKeyData = Data(tempKey) - + var outputs: [Data] = [] var currentOutput = Data() - + for i in 1...numOutputs { currentOutput = Data(HMAC.authenticationCode( for: currentOutput + Data([UInt8(i)]), @@ -477,7 +477,7 @@ final class NoiseSymmetricState { )) outputs.append(currentOutput) } - + return outputs } } @@ -493,24 +493,24 @@ final class NoiseHandshakeState { private let pattern: NoisePattern private let keychain: KeychainManagerProtocol private var symmetricState: NoiseSymmetricState - + // Keys private var localStaticPrivate: Curve25519.KeyAgreement.PrivateKey? private var localStaticPublic: Curve25519.KeyAgreement.PublicKey? private var localEphemeralPrivate: Curve25519.KeyAgreement.PrivateKey? private var localEphemeralPublic: Curve25519.KeyAgreement.PublicKey? - + private var remoteStaticPublic: Curve25519.KeyAgreement.PublicKey? private var remoteEphemeralPublic: Curve25519.KeyAgreement.PublicKey? - + // Message patterns 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, @@ -523,28 +523,27 @@ final class NoiseHandshakeState { self.role = role self.pattern = pattern self.keychain = keychain - self.prologueData = prologue self.predeterminedEphemeralKey = predeterminedEphemeralKey - + // Initialize static keys if let localKey = localStaticKey { self.localStaticPrivate = localKey self.localStaticPublic = localKey.publicKey } self.remoteStaticPublic = remoteStaticKey - + // Initialize protocol name let protocolName = NoiseProtocolName(pattern: pattern.patternName) self.symmetricState = NoiseSymmetricState(protocolName: protocolName.fullName) - + // Initialize message patterns self.messagePatterns = pattern.messagePatterns - + // Mix pre-message keys according to pattern mixPreMessageKeys() } - + private func mixPreMessageKeys() { // Mix prologue symmetricState.mixHash(self.prologueData) @@ -560,15 +559,15 @@ final class NoiseHandshakeState { } } } - + func writeMessage(payload: Data = Data()) throws -> Data { guard currentPattern < messagePatterns.count else { throw NoiseError.handshakeComplete } - + var messageBuffer = Data() let patterns = messagePatterns[currentPattern] - + for pattern in patterns { switch pattern { case .e: @@ -582,7 +581,7 @@ final class NoiseHandshakeState { localEphemeralPublic = localEphemeralPrivate!.publicKey messageBuffer.append(localEphemeralPublic!.rawRepresentation) symmetricState.mixHash(localEphemeralPublic!.rawRepresentation) - + case .s: // Send static key (encrypted if cipher is initialized) guard let staticPublic = localStaticPublic else { @@ -590,7 +589,7 @@ final class NoiseHandshakeState { } let encrypted = try symmetricState.encryptAndHash(staticPublic.rawRepresentation) messageBuffer.append(encrypted) - + case .ee: // DH(local ephemeral, remote ephemeral) guard let localEphemeral = localEphemeralPrivate, @@ -602,7 +601,7 @@ final class NoiseHandshakeState { symmetricState.mixKey(sharedData) // Clear sensitive shared secret keychain.secureClear(&sharedData) - + case .es: // DH(ephemeral, static) - direction depends on role if role == .initiator { @@ -620,7 +619,7 @@ final class NoiseHandshakeState { let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral) symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) } - + case .se: // DH(static, ephemeral) - direction depends on role if role == .initiator { @@ -638,7 +637,7 @@ final class NoiseHandshakeState { let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic) symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) } - + case .ss: // DH(static, static) guard let localStatic = localStaticPrivate, @@ -652,24 +651,24 @@ final class NoiseHandshakeState { keychain.secureClear(&sharedData) } } - + // Encrypt payload let encryptedPayload = try symmetricState.encryptAndHash(payload) messageBuffer.append(encryptedPayload) - + currentPattern += 1 return messageBuffer } - + func readMessage(_ message: Data, expectedPayloadLength: Int = 0) throws -> Data { - + guard currentPattern < messagePatterns.count else { throw NoiseError.handshakeComplete } - + var buffer = message let patterns = messagePatterns[currentPattern] - + for pattern in patterns { switch pattern { case .e: @@ -679,7 +678,7 @@ final class NoiseHandshakeState { } let ephemeralData = buffer.prefix(32) buffer = buffer.dropFirst(32) - + do { remoteEphemeralPublic = try NoiseHandshakeState.validatePublicKey(ephemeralData) } catch { @@ -687,7 +686,7 @@ final class NoiseHandshakeState { throw NoiseError.invalidMessage } symmetricState.mixHash(ephemeralData) - + case .s: // Read static key (may be encrypted) let keyLength = symmetricState.hasCipherKey() ? 48 : 32 // 32 + 16 byte tag if encrypted @@ -703,20 +702,20 @@ final class NoiseHandshakeState { SecureLogger.error(.authenticationFailed(peerID: "Unknown - handshake")) throw NoiseError.authenticationFailure } - + case .ee, .es, .se, .ss: // Same DH operations as in writeMessage try performDHOperation(pattern) } } - + // Decrypt payload let payload = try symmetricState.decryptAndHash(buffer) currentPattern += 1 - + return payload } - + private func performDHOperation(_ pattern: NoiseMessagePattern) throws { switch pattern { case .ee: @@ -726,7 +725,7 @@ final class NoiseHandshakeState { } let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral) symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) - + case .es: if role == .initiator { guard let localEphemeral = localEphemeralPrivate, @@ -749,7 +748,7 @@ final class NoiseHandshakeState { // Clear sensitive shared secret keychain.secureClear(&sharedData) } - + case .se: if role == .initiator { guard let localStatic = localStaticPrivate, @@ -772,7 +771,7 @@ final class NoiseHandshakeState { // Clear sensitive shared secret keychain.secureClear(&sharedData) } - + case .ss: guard let localStatic = localStaticPrivate, let remoteStatic = remoteStaticPublic else { @@ -780,32 +779,32 @@ final class NoiseHandshakeState { } let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic) symmetricState.mixKey(shared.withUnsafeBytes { Data($0) }) - + case .e, .s: break } } - + func isHandshakeComplete() -> Bool { return currentPattern >= messagePatterns.count } - + func getTransportCiphers() throws -> (send: NoiseCipherState, receive: NoiseCipherState) { guard isHandshakeComplete() else { throw NoiseError.handshakeNotComplete } - + let (c1, c2) = symmetricState.split() - + // Initiator uses c1 for sending, c2 for receiving // Responder uses c2 for sending, c1 for receiving return role == .initiator ? (c1, c2) : (c2, c1) } - + func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? { return remoteStaticPublic } - + func getHandshakeHash() -> Data { return symmetricState.getHandshakeHash() } @@ -821,7 +820,7 @@ extension NoisePattern { case .NK: return "NK" } } - + var messagePatterns: [[NoiseMessagePattern]] { switch self { case .XX: @@ -870,12 +869,12 @@ extension NoiseHandshakeState { guard keyData.count == 32 else { throw NoiseError.invalidPublicKey } - + // Check for all-zero key (point at infinity) if keyData.allSatisfy({ $0 == 0 }) { throw NoiseError.invalidPublicKey } - + // Check for low-order points that could enable small subgroup attacks // These are the known bad points for Curve25519 let lowOrderPoints: [Data] = [ @@ -896,13 +895,13 @@ extension NoiseHandshakeState { 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point ] - + // Check against known bad points if lowOrderPoints.contains(keyData) { SecureLogger.warning("Low-order point detected", category: .security) throw NoiseError.invalidPublicKey } - + // Try to create the key - CryptoKit will validate curve points internally do { let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyData) From 209ccb4ade9890f0103eb384741143abd0f62c55 Mon Sep 17 00:00:00 2001 From: Nadim Kobeissi Date: Tue, 18 Nov 2025 19:21:29 +0200 Subject: [PATCH 4/9] Remove obsolete README --- bitchat/Noise/README.md | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 bitchat/Noise/README.md diff --git a/bitchat/Noise/README.md b/bitchat/Noise/README.md deleted file mode 100644 index 55cbeab8..00000000 --- a/bitchat/Noise/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Unnamed Noise_XX_25519_ChaChaPoly_SHA256 Implementation in Swift - -## Running Tests - -This implementation comes with a bare-minimum test harness, intended to ensure that it at least passes the test vectors as stipulated by both [cacophony and snow](https://github.com/mcginty/snow/tree/main/tests). - -In order to run the tests: - -```bash -swiftc -o NoiseTestRunner -D NOISE_TESTS NoiseTestRunner.swift NoiseProtocol.swift ../Utils/Data+SHA256.swift ../Protocols/BinaryEncodingUtils.swift -./NoiseTestRunner -rm NoiseTestRunner -``` From cf528b0dafa123928e4cc28d8495d3a724d3a118 Mon Sep 17 00:00:00 2001 From: Nadim Kobeissi Date: Tue, 18 Nov 2025 19:22:19 +0200 Subject: [PATCH 5/9] Move new Noise test vectors into XCTests (WIP) --- bitchat.xcodeproj/project.pbxproj | 19 +- bitchatTests/Noise/NoiseProtocolTests.swift | 1448 +++++++++---------- 2 files changed, 736 insertions(+), 731 deletions(-) diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index e062000b..8a5b6b1a 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -581,10 +581,11 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; - CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = N762Z3YT9R; INFOPLIST_FILE = bitchatTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -593,6 +594,7 @@ ); MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).tests"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = macosx; SWIFT_VERSION = "$(SWIFT_VERSION)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat"; @@ -643,10 +645,10 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; CODE_SIGN_ENTITLEMENTS = "bitchat/bitchat-macOS.entitlements"; - CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; @@ -659,6 +661,7 @@ MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; + PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; SDKROOT = macosx; SWIFT_VERSION = "$(SWIFT_VERSION)"; @@ -735,10 +738,11 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; CODE_SIGN_ENTITLEMENTS = "bitchat/bitchat-macOS.entitlements"; - CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = N762Z3YT9R; ENABLE_PREVIEWS = NO; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; @@ -749,8 +753,9 @@ ); MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MARKETING_VERSION = "$(MARKETING_VERSION)"; - PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; + PRODUCT_BUNDLE_IDENTIFIER = nadimkobeissi.wowie.zowie; PRODUCT_NAME = bitchat; + PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; SDKROOT = macosx; SWIFT_VERSION = "$(SWIFT_VERSION)"; diff --git a/bitchatTests/Noise/NoiseProtocolTests.swift b/bitchatTests/Noise/NoiseProtocolTests.swift index a6652093..3b790642 100644 --- a/bitchatTests/Noise/NoiseProtocolTests.swift +++ b/bitchatTests/Noise/NoiseProtocolTests.swift @@ -15,734 +15,734 @@ import Testing // 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 - } + 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() - } + 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 - ) - - // Message 1: Initiator -> Responder (e) - let msg1 = try initiatorHandshake.writeMessage() - #expect(!msg1.isEmpty, "Message 1 should not be empty") - - _ = try responderHandshake.readMessage(msg1) - - // Message 2: Responder -> Initiator (e, ee, s, es) - let msg2 = try responderHandshake.writeMessage() - #expect(!msg2.isEmpty, "Message 2 should not be empty") - - _ = try initiatorHandshake.readMessage(msg2) - - // Message 3: Initiator -> Responder (s, se) - let msg3 = try initiatorHandshake.writeMessage() - #expect(!msg3.isEmpty, "Message 3 should not be empty") - - _ = try responderHandshake.readMessage(msg3) - - // 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") - } - - // Get transport ciphers - let (initSend, initRecv) = try initiatorHandshake.getTransportCiphers() - let (respSend, respRecv) = try responderHandshake.getTransportCiphers() - - // Test transport messages - for (index, testMsg) in testVector.messages.enumerated() { - guard let payload = Data(hex: testMsg.payload) else { - throw NSError( - domain: "NoiseTests", code: 4, - userInfo: [ - NSLocalizedDescriptionKey: - "Message \(index + 1): Failed to parse payload hex" - ]) - } - - // Alternate between initiator and responder sending - let (sender, receiver): (NoiseCipherState, NoiseCipherState) - if index % 2 == 0 { - sender = initSend - receiver = respRecv - } else { - sender = respSend - receiver = initRecv - } - - // Encrypt - let ciphertext = try sender.encrypt(plaintext: payload) - - // Decrypt - let decrypted = try receiver.decrypt(ciphertext: ciphertext) - - #expect( - decrypted == payload, - "Message \(index + 1): Decrypted payload should match original") - } - } + + 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 + ) + + // Message 1: Initiator -> Responder (e) + let msg1 = try initiatorHandshake.writeMessage() + #expect(!msg1.isEmpty, "Message 1 should not be empty") + + _ = try responderHandshake.readMessage(msg1) + + // Message 2: Responder -> Initiator (e, ee, s, es) + let msg2 = try responderHandshake.writeMessage() + #expect(!msg2.isEmpty, "Message 2 should not be empty") + + _ = try initiatorHandshake.readMessage(msg2) + + // Message 3: Initiator -> Responder (s, se) + let msg3 = try initiatorHandshake.writeMessage() + #expect(!msg3.isEmpty, "Message 3 should not be empty") + + _ = try responderHandshake.readMessage(msg3) + + // 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") + } + + // Get transport ciphers + let (initSend, initRecv) = try initiatorHandshake.getTransportCiphers() + let (respSend, respRecv) = try responderHandshake.getTransportCiphers() + + // Test transport messages + for (index, testMsg) in testVector.messages.enumerated() { + guard let payload = Data(hex: testMsg.payload) else { + throw NSError( + domain: "NoiseTests", code: 4, + userInfo: [ + NSLocalizedDescriptionKey: + "Message \(index + 1): Failed to parse payload hex" + ]) + } + + // Alternate between initiator and responder sending + let (sender, receiver): (NoiseCipherState, NoiseCipherState) + if index % 2 == 0 { + sender = initSend + receiver = respRecv + } else { + sender = respSend + receiver = initRecv + } + + // Encrypt + let ciphertext = try sender.encrypt(plaintext: payload) + + // Decrypt + let decrypted = try receiver.decrypt(ciphertext: ciphertext) + + #expect( + decrypted == payload, + "Message \(index + 1): Decrypted payload should match original") + } + } } From f37acf7e4bbc97d03f4008d43333f1027b76fa41 Mon Sep 17 00:00:00 2001 From: Nadim Kobeissi Date: Tue, 18 Nov 2025 21:31:55 +0200 Subject: [PATCH 6/9] Finish up Noise tests --- bitchat/Noise/NoiseProtocol.swift | 10 +-- bitchat/Noise/NoiseSession.swift | 4 +- bitchatTests/Noise/NoiseProtocolTests.swift | 96 +++++++++++++++------ 3 files changed, 77 insertions(+), 33 deletions(-) diff --git a/bitchat/Noise/NoiseProtocol.swift b/bitchat/Noise/NoiseProtocol.swift index df92bae8..021dc9fc 100644 --- a/bitchat/Noise/NoiseProtocol.swift +++ b/bitchat/Noise/NoiseProtocol.swift @@ -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) } @@ -789,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 3b790642..6083e134 100644 --- a/bitchatTests/Noise/NoiseProtocolTests.swift +++ b/bitchatTests/Noise/NoiseProtocolTests.swift @@ -679,23 +679,59 @@ struct NoiseProtocolTests { predeterminedEphemeralKey: respEphemeralKey ) - // Message 1: Initiator -> Responder (e) - let msg1 = try initiatorHandshake.writeMessage() - #expect(!msg1.isEmpty, "Message 1 should not be empty") + // For XX pattern, we have 3 handshake messages, then transport messages + // The test vector messages are ordered as: [msg1, msg2, msg3, transport1, transport2, ...] - _ = try responderHandshake.readMessage(msg1) + 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) - let msg2 = try responderHandshake.writeMessage() - #expect(!msg2.isEmpty, "Message 2 should not be empty") + 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"]) + } - _ = try initiatorHandshake.readMessage(msg2) + 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) - let msg3 = try initiatorHandshake.writeMessage() - #expect(!msg3.isEmpty, "Message 3 should not be empty") + 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"]) + } - _ = try responderHandshake.readMessage(msg3) + 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() @@ -706,16 +742,18 @@ struct NoiseProtocolTests { if let expectedHash = expectedHash { #expect( initiatorHash == expectedHash, - "Handshake hash should match expected value from test vector") + "Handshake hash should match expected value from test vector. Got: \(initiatorHash.hexString()), Expected: \(expectedHash.hexString())") } // Get transport ciphers - let (initSend, initRecv) = try initiatorHandshake.getTransportCiphers() - let (respSend, respRecv) = try responderHandshake.getTransportCiphers() - - // Test transport messages - for (index, testMsg) in testVector.messages.enumerated() { - guard let payload = Data(hex: testMsg.payload) else { + 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.. Date: Tue, 18 Nov 2025 21:53:27 +0200 Subject: [PATCH 7/9] Fix unintended changes to project.pbxproj --- bitchat.xcodeproj/project.pbxproj | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 8a5b6b1a..25428d15 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -581,11 +581,10 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = N762Z3YT9R; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = bitchatTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -594,7 +593,6 @@ ); MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).tests"; - PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = macosx; SWIFT_VERSION = "$(SWIFT_VERSION)"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bitchat.app/Contents/MacOS/bitchat"; @@ -645,10 +643,10 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; CODE_SIGN_ENTITLEMENTS = "bitchat/bitchat-macOS.entitlements"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; @@ -738,11 +736,10 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGNING_REQUIRED = YES; CODE_SIGN_ENTITLEMENTS = "bitchat/bitchat-macOS.entitlements"; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; COMBINE_HIDPI_IMAGES = YES; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = N762Z3YT9R; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = NO; INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = bitchat; @@ -753,9 +750,8 @@ ); MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MARKETING_VERSION = "$(MARKETING_VERSION)"; - PRODUCT_BUNDLE_IDENTIFIER = nadimkobeissi.wowie.zowie; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; - PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; SDKROOT = macosx; SWIFT_VERSION = "$(SWIFT_VERSION)"; From e4cbf382ceff25c921782f1a6c06c118e8436fe7 Mon Sep 17 00:00:00 2001 From: Nadim Kobeissi Date: Tue, 18 Nov 2025 21:53:54 +0200 Subject: [PATCH 8/9] Fix unintended changes to project.pbxproj --- bitchat.xcodeproj/project.pbxproj | 1 - 1 file changed, 1 deletion(-) diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 25428d15..e062000b 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -659,7 +659,6 @@ MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = bitchat; - PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; SDKROOT = macosx; SWIFT_VERSION = "$(SWIFT_VERSION)"; From 5aefb05a8b71c8e23dedfc3d6228e20bd8f1a881 Mon Sep 17 00:00:00 2001 From: Nadim Kobeissi Date: Tue, 18 Nov 2025 21:58:46 +0200 Subject: [PATCH 9/9] Fix Noise test vector compatibility with `swift test` --- Package.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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") ] ) ]