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
+170 -26
View File
@@ -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 <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 {
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: <nonce><ciphertext>
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
}
}
}
}
+18 -5
View File
@@ -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
+1 -1
View File
@@ -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