mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 02:25:19 +00:00
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:
@@ -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
|
||||||
|
|
||||||
|
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
|
// Split ciphertext and tag
|
||||||
let encryptedData = ciphertext.prefix(ciphertext.count - 16)
|
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
|
||||||
let tag = ciphertext.suffix(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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user