From 1876e85f8bd6b4788602ddd2b8d57091748bc52f Mon Sep 17 00:00:00 2001 From: Nadim Kobeissi Date: Tue, 18 Nov 2025 19:20:16 +0200 Subject: [PATCH] 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)