From 9515f759d4a12abe8387e5367351c7dd51b87d72 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 25 Jul 2025 09:46:18 +0200 Subject: [PATCH] NoiseProtocol update: send nonce in packet (#306) * send nonce in packet * sliding window and 8 byte nonce * do not process encrypted messages that arent addressed to us, same for nacks * compiler error: remove comma * nonce window 4 bytes * clean up logging --- bitchat/Noise/NoiseProtocol.swift | 196 +++++++++++++++++--- bitchat/Services/BluetoothMeshService.swift | 23 ++- bitchat/ViewModels/ChatViewModel.swift | 2 +- 3 files changed, 189 insertions(+), 32 deletions(-) diff --git a/bitchat/Noise/NoiseProtocol.swift b/bitchat/Noise/NoiseProtocol.swift index 3611ce11..1f3cb731 100644 --- a/bitchat/Noise/NoiseProtocol.swift +++ b/bitchat/Noise/NoiseProtocol.swift @@ -51,13 +51,25 @@ struct NoiseProtocolName { // MARK: - Cipher State class NoiseCipherState { + // Constants for replay protection + private static let NONCE_SIZE_BYTES = 4 + 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) { + init(key: SymmetricKey, useExtractedNonce: Bool = false) { self.key = key + self.useExtractedNonce = useExtractedNonce } func initializeKey(_ key: SymmetricKey) { @@ -69,6 +81,95 @@ class NoiseCipherState { 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) + } else { + // Shift window right by `shift` bits + 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 { + let offset = Int(highestReceivedNonce - receivedNonce) + let byteIndex = offset / 8 + let bitIndex = offset % 8 + 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 + let byteArray = bytes.bindMemory(to: UInt8.self) + var result: UInt64 = 0 + for i in 0.. 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 + let sourceBytes = ptr.bindMemory(to: UInt8.self) + bytes.replaceSubrange(0.. Data { guard let key = self.key else { throw NoiseError.uninitializedCipher @@ -77,21 +178,36 @@ class NoiseCipherState { // 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: nonce.littleEndian) { bytes in + 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 - - // Log high nonce values that might indicate issues - if currentNonce > 1000000 { - SecureLogger.log("High nonce value detected: \(currentNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning) + + // Create combined payload: + let combinedPayload: Data + if (useExtractedNonce) { + let nonceBytes = nonceToBytes(currentNonce) + combinedPayload = nonceBytes + sealedBox.ciphertext + sealedBox.tag + } else { + combinedPayload = sealedBox.ciphertext + sealedBox.tag } - return sealedBox.ciphertext + sealedBox.tag + // Log high nonce values that might indicate issues + if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD { + SecureLogger.log("High nonce value detected: \(currentNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning) + } + + return combinedPayload } func decrypt(ciphertext: Data, associatedData: Data = Data()) throws -> Data { @@ -103,16 +219,37 @@ class NoiseCipherState { throw NoiseError.invalidCiphertext } - // Debug logging for nonce tracking - let currentNonce = nonce + let encryptedData: Data + let tag: Data + let decryptionNonce: UInt64 - // Split ciphertext and tag - let encryptedData = ciphertext.prefix(ciphertext.count - 16) - let tag = ciphertext.suffix(16) + if useExtractedNonce { + // Extract nonce and ciphertext from combined payload + guard let (extractedNonce, actualCiphertext) = try extractNonceFromCiphertextPayload(ciphertext) else { + SecureLogger.log("Decrypt failed: Could not extract nonce from payload") + throw NoiseError.invalidCiphertext + } + + // Validate nonce with sliding window replay protection + guard isValidNonce(extractedNonce) else { + SecureLogger.log("Replay attack detected: nonce \(extractedNonce) rejected") + throw NoiseError.replayDetected + } + + // Split ciphertext and tag + encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16) + tag = actualCiphertext.suffix(16) + decryptionNonce = extractedNonce + } else { + // Split ciphertext and tag + encryptedData = ciphertext.prefix(ciphertext.count - 16) + tag = ciphertext.suffix(16) + decryptionNonce = nonce + } // Create nonce from counter var nonceData = Data(count: 12) - withUnsafeBytes(of: nonce.littleEndian) { bytes in + withUnsafeBytes(of: decryptionNonce.littleEndian) { bytes in nonceData.replaceSubrange(4..<12, with: bytes) } @@ -123,17 +260,23 @@ class NoiseCipherState { ) // Log high nonce values that might indicate issues - if currentNonce > 1000000 { - SecureLogger.log("High nonce value detected: \(currentNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning) + if decryptionNonce > Self.HIGH_NONCE_WARNING_THRESHOLD { + SecureLogger.log("High nonce value detected: \(decryptionNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning) } do { let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData) + + if useExtractedNonce { + // Mark nonce as seen after successful decryption + markNonceAsSeen(decryptionNonce) + } nonce += 1 return plaintext } catch { + SecureLogger.log("Decrypt failed: \(error) for nonce \(decryptionNonce)") // Log authentication failures with nonce info - SecureLogger.log("Decryption failed at nonce \(currentNonce)", category: SecureLogger.encryption, level: .error) + SecureLogger.log("Decryption failed at nonce \(decryptionNonce)", category: SecureLogger.encryption, level: .error) throw error } } @@ -213,8 +356,8 @@ class NoiseSymmetricState { let tempKey1 = SymmetricKey(data: output[0]) let tempKey2 = SymmetricKey(data: output[1]) - let c1 = NoiseCipherState(key: tempKey1) - let c2 = NoiseCipherState(key: tempKey2) + let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: true) + let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: true) return (c1, c2) } @@ -282,6 +425,8 @@ class NoiseHandshakeState { } private func mixPreMessageKeys() { + // Mix prologue (empty for XX pattern normally) + symmetricState.mixHash(Data()) // Empty prologue for XX pattern // For XX pattern, no pre-message keys // For IK/NK patterns, we'd mix the responder's static key here switch pattern { @@ -289,6 +434,7 @@ class NoiseHandshakeState { break // No pre-message keys case .IK, .NK: if role == .initiator, let remoteStatic = remoteStaticPublic { + let hashBeforeRemote = symmetricState.getHandshakeHash() symmetricState.mixHash(remoteStatic.rawRepresentation) } } @@ -298,9 +444,7 @@ class NoiseHandshakeState { guard currentPattern < messagePatterns.count else { throw NoiseError.handshakeComplete } - - SecureLogger.log("NoiseHandshake[\(role)]: Writing message \(currentPattern + 1)/\(messagePatterns.count)", category: SecureLogger.noise, level: .info) - + var messageBuffer = Data() let patterns = messagePatterns[currentPattern] @@ -390,9 +534,7 @@ class NoiseHandshakeState { guard currentPattern < messagePatterns.count else { throw NoiseError.handshakeComplete } - - SecureLogger.log("NoiseHandshake[\(role)]: Reading message \(currentPattern + 1)/\(messagePatterns.count)", category: SecureLogger.noise, level: .info) - + var buffer = message let patterns = messagePatterns[currentPattern] @@ -570,6 +712,8 @@ enum NoiseError: Error { case invalidMessage case authenticationFailure case invalidPublicKey + case replayDetected + case nonceExceeded } // MARK: - Key Validation @@ -594,7 +738,7 @@ extension NoiseHandshakeState { Data(repeating: 0x00, count: 32), // Already checked above Data([0x01] + Data(repeating: 0x00, count: 31)), // Point of order 1 Data([0x00] + Data(repeating: 0x00, count: 30) + [0x01]), // Another low-order point - Data([0xe0, 0xeb, 0x7a, 0x7c, 0x3b, 0x41, 0xb8, 0xae, 0x16, 0x56, 0xe3, + Data([0xe0, 0xeb, 0x7a, 0x7c, 0x3b, 0x41, 0xb8, 0xae, 0x16, 0x56, 0xe3, 0xfa, 0xf1, 0x9f, 0xc4, 0x6a, 0xda, 0x09, 0x8d, 0xeb, 0x9c, 0x32, 0xb1, 0xfd, 0x86, 0x62, 0x05, 0x16, 0x5f, 0x49, 0xb8, 0x00]), // Low order point Data([0x5f, 0x9c, 0x95, 0xbc, 0xa3, 0x50, 0x8c, 0x24, 0xb1, 0xd0, 0xb1, @@ -625,4 +769,4 @@ extension NoiseHandshakeState { throw NoiseError.invalidPublicKey } } -} \ No newline at end of file +} diff --git a/bitchat/Services/BluetoothMeshService.swift b/bitchat/Services/BluetoothMeshService.swift index 590abe95..c2f514c3 100644 --- a/bitchat/Services/BluetoothMeshService.swift +++ b/bitchat/Services/BluetoothMeshService.swift @@ -3273,9 +3273,20 @@ class BluetoothMeshService: NSObject { case .noiseEncrypted: // Handle Noise encrypted message let senderID = packet.senderID.hexEncodedString() - if !isPeerIDOurs(senderID) { + if let recipientIDData = packet.recipientID, + isPeerIDOurs(recipientIDData.hexEncodedString()) + && !isPeerIDOurs(senderID) { _ = packet.recipientID?.hexEncodedString() handleNoiseEncryptedMessage(from: senderID, encryptedData: packet.payload, originalPacket: packet, peripheral: peripheral) + return + } + if !isPeerIDOurs(senderID) { + if packet.ttl > 0 { + var relayPacket = packet + relayPacket.ttl -= 1 + broadcastPacket(relayPacket) + } + return } case .versionHello: @@ -3302,7 +3313,9 @@ class BluetoothMeshService: NSObject { case .protocolNack: // Handle protocol-level negative acknowledgment let senderID = packet.senderID.hexEncodedString() - if !isPeerIDOurs(senderID) { + if let recipientIDData = packet.recipientID, + isPeerIDOurs(recipientIDData.hexEncodedString()) + && !isPeerIDOurs(senderID) { handleProtocolNack(from: senderID, data: packet.payload) } @@ -5253,9 +5266,9 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate { } else { SecureLogger.log("Have session with \(peerID) but decryption failed", category: SecureLogger.encryption, level: .warning) - // Send a NACK to inform peer their session is out of sync - sendProtocolNack(for: originalPacket, to: peerID, - reason: "Decryption failed - session out of sync", + // Send a NACK to inform peer that decryption failed + sendProtocolNack(for: originalPacket, to: peerID, + reason: "Decryption failed", errorCode: .decryptionFailed) // The NACK handler will take care of clearing sessions and re-establishing diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 19b44072..3cff0e06 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -397,7 +397,7 @@ class ChatViewModel: ObservableObject { isPrivate: false, recipientNickname: nil, senderPeerID: meshService.myPeerID, - mentions: mentions.isEmpty ? nil : mentions, + mentions: mentions.isEmpty ? nil : mentions ) // Add to main messages immediately for user feedback