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
This commit is contained in:
callebtc
2025-07-25 09:46:18 +02:00
committed by GitHub
parent 809e222a31
commit 9515f759d4
3 changed files with 189 additions and 32 deletions
+163 -19
View File
@@ -51,13 +51,25 @@ struct NoiseProtocolName {
// MARK: - Cipher State // MARK: - Cipher State
class NoiseCipherState { 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 key: SymmetricKey?
private var nonce: UInt64 = 0 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() {}
init(key: SymmetricKey) { init(key: SymmetricKey, useExtractedNonce: Bool = false) {
self.key = key self.key = key
self.useExtractedNonce = useExtractedNonce
} }
func initializeKey(_ key: SymmetricKey) { func initializeKey(_ key: SymmetricKey) {
@@ -69,6 +81,95 @@ class NoiseCipherState {
return key != nil 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 <nonce><ciphertext>
/// 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..<Self.NONCE_SIZE_BYTES {
result = (result << 8) | UInt64(byteArray[i])
}
return result
}
// Extract ciphertext (remaining bytes)
let ciphertext = combinedPayload.dropFirst(Self.NONCE_SIZE_BYTES)
return (nonce: extractedNonce, ciphertext: Data(ciphertext))
}
/// Convert nonce to 4-byte array (big-endian)
private func nonceToBytes(_ nonce: UInt64) -> Data {
var bytes = Data(count: Self.NONCE_SIZE_BYTES)
withUnsafeBytes(of: nonce.bigEndian) { ptr in
// Copy only the last 4 bytes from the 8-byte UInt64
let sourceBytes = ptr.bindMemory(to: UInt8.self)
bytes.replaceSubrange(0..<Self.NONCE_SIZE_BYTES, with: sourceBytes.suffix(Self.NONCE_SIZE_BYTES))
}
return bytes
}
func encrypt(plaintext: Data, associatedData: Data = Data()) throws -> Data { func encrypt(plaintext: Data, associatedData: Data = Data()) throws -> Data {
guard let key = self.key else { guard let key = self.key else {
throw NoiseError.uninitializedCipher throw NoiseError.uninitializedCipher
@@ -77,21 +178,36 @@ class NoiseCipherState {
// Debug logging for nonce tracking // Debug logging for nonce tracking
let currentNonce = nonce 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 // Create nonce from counter
var nonceData = Data(count: 12) var nonceData = Data(count: 12)
withUnsafeBytes(of: nonce.littleEndian) { bytes in withUnsafeBytes(of: currentNonce.littleEndian) { bytes in
nonceData.replaceSubrange(4..<12, with: bytes) nonceData.replaceSubrange(4..<12, with: bytes)
} }
let sealedBox = try ChaChaPoly.seal(plaintext, using: key, nonce: ChaChaPoly.Nonce(data: nonceData), authenticating: associatedData) let sealedBox = try ChaChaPoly.seal(plaintext, using: key, nonce: ChaChaPoly.Nonce(data: nonceData), authenticating: associatedData)
// increment local nonce
nonce += 1 nonce += 1
// Create combined payload: <nonce><ciphertext>
let combinedPayload: Data
if (useExtractedNonce) {
let nonceBytes = nonceToBytes(currentNonce)
combinedPayload = nonceBytes + sealedBox.ciphertext + sealedBox.tag
} else {
combinedPayload = sealedBox.ciphertext + sealedBox.tag
}
// Log high nonce values that might indicate issues // Log high nonce values that might indicate issues
if currentNonce > 1000000 { if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
SecureLogger.log("High nonce value detected: \(currentNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning) SecureLogger.log("High nonce value detected: \(currentNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning)
} }
return sealedBox.ciphertext + sealedBox.tag return combinedPayload
} }
func decrypt(ciphertext: Data, associatedData: Data = Data()) throws -> Data { func decrypt(ciphertext: Data, associatedData: Data = Data()) throws -> Data {
@@ -103,16 +219,37 @@ class NoiseCipherState {
throw NoiseError.invalidCiphertext throw NoiseError.invalidCiphertext
} }
// Debug logging for nonce tracking let encryptedData: Data
let currentNonce = nonce let tag: Data
let decryptionNonce: UInt64
// Split ciphertext and tag if useExtractedNonce {
let encryptedData = ciphertext.prefix(ciphertext.count - 16) // Extract nonce and ciphertext from combined payload
let tag = ciphertext.suffix(16) 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 // Create nonce from counter
var nonceData = Data(count: 12) var nonceData = Data(count: 12)
withUnsafeBytes(of: nonce.littleEndian) { bytes in withUnsafeBytes(of: decryptionNonce.littleEndian) { bytes in
nonceData.replaceSubrange(4..<12, with: bytes) nonceData.replaceSubrange(4..<12, with: bytes)
} }
@@ -123,17 +260,23 @@ class NoiseCipherState {
) )
// Log high nonce values that might indicate issues // Log high nonce values that might indicate issues
if currentNonce > 1000000 { if decryptionNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
SecureLogger.log("High nonce value detected: \(currentNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning) SecureLogger.log("High nonce value detected: \(decryptionNonce) - consider rekeying", category: SecureLogger.encryption, level: .warning)
} }
do { do {
let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData) let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData)
if useExtractedNonce {
// Mark nonce as seen after successful decryption
markNonceAsSeen(decryptionNonce)
}
nonce += 1 nonce += 1
return plaintext return plaintext
} catch { } catch {
SecureLogger.log("Decrypt failed: \(error) for nonce \(decryptionNonce)")
// Log authentication failures with nonce info // 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 throw error
} }
} }
@@ -213,8 +356,8 @@ class NoiseSymmetricState {
let tempKey1 = SymmetricKey(data: output[0]) let tempKey1 = SymmetricKey(data: output[0])
let tempKey2 = SymmetricKey(data: output[1]) let tempKey2 = SymmetricKey(data: output[1])
let c1 = NoiseCipherState(key: tempKey1) let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: true)
let c2 = NoiseCipherState(key: tempKey2) let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: true)
return (c1, c2) return (c1, c2)
} }
@@ -282,6 +425,8 @@ class NoiseHandshakeState {
} }
private func mixPreMessageKeys() { 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 XX pattern, no pre-message keys
// For IK/NK patterns, we'd mix the responder's static key here // For IK/NK patterns, we'd mix the responder's static key here
switch pattern { switch pattern {
@@ -289,6 +434,7 @@ class NoiseHandshakeState {
break // No pre-message keys break // No pre-message keys
case .IK, .NK: case .IK, .NK:
if role == .initiator, let remoteStatic = remoteStaticPublic { if role == .initiator, let remoteStatic = remoteStaticPublic {
let hashBeforeRemote = symmetricState.getHandshakeHash()
symmetricState.mixHash(remoteStatic.rawRepresentation) symmetricState.mixHash(remoteStatic.rawRepresentation)
} }
} }
@@ -299,8 +445,6 @@ class NoiseHandshakeState {
throw NoiseError.handshakeComplete throw NoiseError.handshakeComplete
} }
SecureLogger.log("NoiseHandshake[\(role)]: Writing message \(currentPattern + 1)/\(messagePatterns.count)", category: SecureLogger.noise, level: .info)
var messageBuffer = Data() var messageBuffer = Data()
let patterns = messagePatterns[currentPattern] let patterns = messagePatterns[currentPattern]
@@ -391,8 +535,6 @@ class NoiseHandshakeState {
throw NoiseError.handshakeComplete throw NoiseError.handshakeComplete
} }
SecureLogger.log("NoiseHandshake[\(role)]: Reading message \(currentPattern + 1)/\(messagePatterns.count)", category: SecureLogger.noise, level: .info)
var buffer = message var buffer = message
let patterns = messagePatterns[currentPattern] let patterns = messagePatterns[currentPattern]
@@ -570,6 +712,8 @@ enum NoiseError: Error {
case invalidMessage case invalidMessage
case authenticationFailure case authenticationFailure
case invalidPublicKey case invalidPublicKey
case replayDetected
case nonceExceeded
} }
// MARK: - Key Validation // MARK: - Key Validation
+17 -4
View File
@@ -3273,9 +3273,20 @@ class BluetoothMeshService: NSObject {
case .noiseEncrypted: case .noiseEncrypted:
// Handle Noise encrypted message // Handle Noise encrypted message
let senderID = packet.senderID.hexEncodedString() let senderID = packet.senderID.hexEncodedString()
if !isPeerIDOurs(senderID) { if let recipientIDData = packet.recipientID,
isPeerIDOurs(recipientIDData.hexEncodedString())
&& !isPeerIDOurs(senderID) {
_ = packet.recipientID?.hexEncodedString() _ = packet.recipientID?.hexEncodedString()
handleNoiseEncryptedMessage(from: senderID, encryptedData: packet.payload, originalPacket: packet, peripheral: peripheral) 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: case .versionHello:
@@ -3302,7 +3313,9 @@ class BluetoothMeshService: NSObject {
case .protocolNack: case .protocolNack:
// Handle protocol-level negative acknowledgment // Handle protocol-level negative acknowledgment
let senderID = packet.senderID.hexEncodedString() let senderID = packet.senderID.hexEncodedString()
if !isPeerIDOurs(senderID) { if let recipientIDData = packet.recipientID,
isPeerIDOurs(recipientIDData.hexEncodedString())
&& !isPeerIDOurs(senderID) {
handleProtocolNack(from: senderID, data: packet.payload) handleProtocolNack(from: senderID, data: packet.payload)
} }
@@ -5253,9 +5266,9 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
} else { } else {
SecureLogger.log("Have session with \(peerID) but decryption failed", category: SecureLogger.encryption, level: .warning) 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 // Send a NACK to inform peer that decryption failed
sendProtocolNack(for: originalPacket, to: peerID, sendProtocolNack(for: originalPacket, to: peerID,
reason: "Decryption failed - session out of sync", reason: "Decryption failed",
errorCode: .decryptionFailed) errorCode: .decryptionFailed)
// The NACK handler will take care of clearing sessions and re-establishing // The NACK handler will take care of clearing sessions and re-establishing
+1 -1
View File
@@ -397,7 +397,7 @@ class ChatViewModel: ObservableObject {
isPrivate: false, isPrivate: false,
recipientNickname: nil, recipientNickname: nil,
senderPeerID: meshService.myPeerID, senderPeerID: meshService.myPeerID,
mentions: mentions.isEmpty ? nil : mentions, mentions: mentions.isEmpty ? nil : mentions
) )
// Add to main messages immediately for user feedback // Add to main messages immediately for user feedback