Preserve early Noise ciphertext across reconnects

This commit is contained in:
jack
2026-07-25 20:40:35 +02:00
parent 7c8ef3be2a
commit 05b57c878f
7 changed files with 1363 additions and 38 deletions
@@ -1,5 +1,6 @@
import BitFoundation
import BitLogger
import CryptoKit
import Foundation
struct BLENoiseDecryptionResult {
@@ -25,6 +26,8 @@ struct BLENoisePacketHandlerEnvironment {
let processHandshakeMessage: (_ peerID: PeerID, _ message: Data) throws -> Data?
/// Whether any Noise session (established or pending) exists for the peer (crypto).
let hasNoiseSession: (PeerID) -> Bool
/// Whether an inbound ordinary XX responder is waiting for message 3.
let isAwaitingResponderHandshakeCompletion: (PeerID) -> Bool
/// Initiates a fresh Noise handshake with the peer (crypto + send).
let initiateHandshake: (PeerID) -> Void
/// Broadcasts a packet on the mesh (caller is already on the message queue).
@@ -55,7 +58,28 @@ struct BLENoisePacketHandlerEnvironment {
/// processing (with response), encrypted payload decryption and dispatch,
/// and session recovery on decrypt failure.
final class BLENoisePacketHandler {
private struct DeferredCiphertext {
let packet: BitchatPacket
let receivedAt: Date
}
/// Early post-handshake packets are normally tiny control messages or
/// queued DMs. Keep the recovery surface deliberately small so an
/// unauthenticated half-handshake cannot create an unbounded memory queue.
private static let maxDeferredPacketsPerPeer = 4
private static let maxDeferredPacketsGlobal = 32
/// One legacy sender can immediately follow message 3 with the largest
/// valid private-file ciphertext and has no application-level retry. Keep
/// room for that packet plus a small control-message budget.
private static let maxDeferredBytes =
NoiseSecurityConstants.maxPrivateFileCiphertextSize + 256 * 1024
private static let deferredLifetime =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
private let environment: BLENoisePacketHandlerEnvironment
private let deferredLock = NSLock()
private var deferredCiphertexts: [PeerID: [DeferredCiphertext]] = [:]
private var deferredCiphertextBytes = 0
init(environment: BLENoisePacketHandlerEnvironment) {
self.environment = environment
@@ -86,8 +110,8 @@ final class BLENoisePacketHandler {
env.broadcastPacket(responsePacket)
}
// Session establishment will trigger onPeerAuthenticated callback
// which will send any pending messages at the right time
// The serialized authentication callback installs transport
// state before it drains any bounded early ciphertext.
return true
} catch let managedFailure as NoiseManagedHandshakeFailure {
SecureLogger.error(
@@ -116,6 +140,20 @@ final class BLENoisePacketHandler {
}
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
handleEncrypted(packet, from: peerID, isDeferredRetry: false)
}
/// Called by the transport's serialized authentication callback after it
/// has installed state for the promoted or restored session generation.
func handleSessionAuthenticated(_ peerID: PeerID) {
drainDeferredCiphertextsIfReady(for: peerID)
}
private func handleEncrypted(
_ packet: BitchatPacket,
from peerID: PeerID,
isDeferredRetry: Bool
) {
let env = environment
guard let recipientID = PeerID(hexData: packet.recipientID) else {
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
@@ -157,19 +195,205 @@ final class BLENoisePacketHandler {
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
} catch NoiseEncryptionError.transportGenerationNotReady {
if isDeferredRetry {
SecureLogger.warning(
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because its authenticated transport generation changed again",
category: .session
)
return
}
// The manager promoted or restored keys before BLE's serialized
// callback installed generation-bound transport state. The
// manager rejected this before decrypting, so replay is safe.
deferCiphertext(packet, from: peerID)
} catch NoiseEncryptionError.sessionNotEstablished {
if isDeferredRetry {
SecureLogger.warning(
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because the authenticated session is unavailable",
category: .session
)
return
}
// We received an encrypted message before establishing a session with this peer.
// Trigger a handshake so future messages can be decrypted.
// An initiator may already have sent message 3 followed by this
// ciphertext, with BLE delivering the ciphertext first.
if env.isAwaitingResponderHandshakeCompletion(peerID) {
deferCiphertext(packet, from: peerID)
return
}
// Otherwise trigger a handshake so future messages can decrypt.
SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake")
if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID)
}
} catch {
if isDeferredRetry {
// An early packet cannot tear down the authenticated session
// merely because its single bounded retry still fails.
SecureLogger.warning(
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… after retry failed: \(error)",
category: .session
)
return
}
// A responder may retain an older transport as receive-only
// rollback state while ordinary XX waits for message 3. New-key
// ciphertext can fail against those retained receive keys first.
if env.isAwaitingResponderHandshakeCompletion(peerID) {
if isDeferrableEarlyHandshakeFailure(error) {
deferCiphertext(packet, from: peerID)
} else {
SecureLogger.warning(
"Dropping invalid Noise ciphertext from \(peerID.id.prefix(8))… while responder handshake is completing: \(error)",
category: .session
)
}
return
}
if isDropOnlyCiphertextFailure(error) {
// The packet is attacker-controlled and did not prove a
// transport-state failure. Never let malformed, replayed,
// forged, oversized, or rate-limited bytes evict working keys.
SecureLogger.warning(
"Dropping rejected Noise ciphertext from \(peerID.id.prefix(8))… without clearing its session: \(error)",
category: .security
)
return
}
// Decryption failed - clear the corrupted session and re-initiate handshake
// This handles cases where session state got out of sync (nonce mismatch, etc.)
// Only local/session lifecycle failures reach this path.
SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake")
env.clearSession(peerID)
env.initiateHandshake(peerID)
}
}
private func isDeferrableEarlyHandshakeFailure(_ error: Error) -> Bool {
if let noiseError = error as? NoiseError {
switch noiseError {
case .authenticationFailure, .replayDetected:
return true
default:
return false
}
}
if let cryptoError = error as? CryptoKitError,
case .authenticationFailure = cryptoError {
return true
}
return false
}
private func isDropOnlyCiphertextFailure(_ error: Error) -> Bool {
if let securityError = error as? NoiseSecurityError {
switch securityError {
case .messageTooLarge, .rateLimitExceeded, .invalidPeerID:
return true
case .sessionExpired, .sessionExhausted:
return false
}
}
if let noiseError = error as? NoiseError {
switch noiseError {
case .invalidCiphertext, .authenticationFailure, .replayDetected:
return true
case .uninitializedCipher, .handshakeComplete,
.handshakeNotComplete, .missingLocalStaticKey,
.missingKeys, .invalidMessage, .invalidPublicKey,
.nonceExceeded:
return false
}
}
return error is CryptoKitError
}
private func deferCiphertext(_ packet: BitchatPacket, from peerID: PeerID) {
guard NoiseSecurityValidator.validatePrivateFileCiphertextSize(
packet.payload
) else {
SecureLogger.warning(
"Dropping oversized early Noise ciphertext from \(peerID.id.prefix(8))",
category: .security
)
return
}
let now = environment.now()
deferredLock.lock()
defer { deferredLock.unlock() }
purgeExpiredCiphertextsLocked(now: now)
let peerCount = deferredCiphertexts[peerID]?.count ?? 0
let globalCount = deferredCiphertexts.values.reduce(0) {
$0 + $1.count
}
guard peerCount < Self.maxDeferredPacketsPerPeer,
globalCount < Self.maxDeferredPacketsGlobal,
deferredCiphertextBytes + packet.payload.count
<= Self.maxDeferredBytes else {
SecureLogger.warning(
"Dropping early Noise ciphertext from \(peerID.id.prefix(8))… because the handshake buffer is full",
category: .security
)
return
}
deferredCiphertexts[peerID, default: []].append(
DeferredCiphertext(packet: packet, receivedAt: now)
)
deferredCiphertextBytes += packet.payload.count
SecureLogger.debug(
"Deferring early Noise ciphertext from \(peerID.id.prefix(8))… until responder handshake completion",
category: .session
)
}
private func drainDeferredCiphertextsIfReady(for peerID: PeerID) {
let env = environment
guard !env.isAwaitingResponderHandshakeCompletion(peerID),
env.hasNoiseSession(peerID) else {
return
}
let now = env.now()
deferredLock.lock()
purgeExpiredCiphertextsLocked(now: now)
let deferred = deferredCiphertexts.removeValue(forKey: peerID) ?? []
deferredCiphertextBytes -= deferred.reduce(0) {
$0 + $1.packet.payload.count
}
deferredLock.unlock()
guard !deferred.isEmpty else { return }
SecureLogger.debug(
"Retrying \(deferred.count) early Noise ciphertext packet(s) from \(peerID.id.prefix(8))… after handshake completion",
category: .session
)
for item in deferred {
handleEncrypted(item.packet, from: peerID, isDeferredRetry: true)
}
}
private func purgeExpiredCiphertextsLocked(now: Date) {
for peerID in Array(deferredCiphertexts.keys) {
guard let items = deferredCiphertexts[peerID] else { continue }
let retained = items.filter {
now.timeIntervalSince($0.receivedAt) <= Self.deferredLifetime
}
guard retained.count != items.count else { continue }
deferredCiphertextBytes -= items.reduce(0) {
$0 + $1.packet.payload.count
}
deferredCiphertextBytes += retained.reduce(0) {
$0 + $1.packet.payload.count
}
if retained.isEmpty {
deferredCiphertexts.removeValue(forKey: peerID)
} else {
deferredCiphertexts[peerID] = retained
}
}
}
}
+93 -12
View File
@@ -3556,6 +3556,28 @@ extension BLEService {
}
}
/// Replays the current generation's ready callback. Restore tests use
/// this to prove same-generation reconciliation is idempotent.
func _test_reconcileCurrentNoiseSession(for peerID: PeerID) {
let normalizedPeerID = peerID.toShort()
messageQueue.async(flags: .barrier) { [weak self] in
guard let self,
let generation = self.noiseService.sessionGeneration(
for: normalizedPeerID
),
let fingerprint = self.noiseService.getPeerFingerprint(
normalizedPeerID
) else {
return
}
self.handleNoisePeerAuthenticated(
peerID: normalizedPeerID,
fingerprint: fingerprint,
sessionGeneration: generation
)
}
}
/// Builds an authenticated-session packet from an exact typed plaintext.
/// Compatibility tests use this to model Android's deployed 0x20 file
/// payload and the short-lived 0x09 prerelease payload without exposing a
@@ -4649,7 +4671,10 @@ extension BLEService {
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in
SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))")
self?.messageQueue.async { [weak self] in
// Authentication can be reported while an initiator is still
// returning XX message 3. Serialize generation-bound state and
// every post-handshake drain behind the handshake packet handler.
self?.messageQueue.async(flags: .barrier) { [weak self] in
self?.handleNoisePeerAuthenticated(
peerID: peerID,
fingerprint: fingerprint,
@@ -4724,7 +4749,9 @@ extension BLEService {
}
service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation in
guard let self, let service else { return }
self.messageQueue.async { [weak self, weak service] in
// The manager makes restored keys visible atomically. Reconcile
// transport state and queued sends as the next serialized phase.
self.messageQueue.async(flags: .barrier) { [weak self, weak service] in
guard let self,
let service,
self.noiseService === service,
@@ -4792,7 +4819,16 @@ extension BLEService {
}
) else { return }
guard let watchdog = transition.watchdog else { return }
guard let watchdog = transition.watchdog else {
// A quarantined transport restored the same cryptographic
// generation. Its capability proof and announce state never
// became stale; only work queued while outbound keys were paused
// needs one idempotent ready transition.
noisePacketHandler.handleSessionAuthenticated(normalizedPeerID)
sendPendingMessagesAfterHandshake(for: normalizedPeerID)
sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID)
return
}
completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade)
schedulePrivateMediaProofTimeout(
@@ -4801,6 +4837,10 @@ extension BLEService {
sessionGeneration: generation,
nonce: watchdog.nonce
)
// Cross-link delivery can put ciphertext sent immediately after
// message 3 ahead of message 3 itself. Retry the bounded queue only
// after this generation's transport state has been fully installed.
noisePacketHandler.handleSessionAuthenticated(normalizedPeerID)
// `onPeerAuthenticated` can fire while the initiator is returning XX
// message 3. This callback is queued behind the handshake handler, so
@@ -6415,7 +6455,12 @@ extension BLEService {
// MARK: Packet Reception
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) {
// Call directly if already on messageQueue, otherwise dispatch
let isNoisePacket = packet.type == MessageType.noiseHandshake.rawValue
|| packet.type == MessageType.noiseEncrypted.rawValue
// Capture the panic lifecycle at the first off-messageQueue handoff.
// Noise packets still enter through a barrier so handshake promotion,
// quarantine, and encrypted delivery share one ordered session.
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
guard let lifecycleGeneration =
capturePanicLifecycleGeneration() else {
@@ -6424,7 +6469,8 @@ extension BLEService {
#if DEBUG
_test_beforeReceivePacketHandoff?()
#endif
messageQueue.async { [weak self] in
let flags: DispatchWorkItemFlags = isNoisePacket ? .barrier : []
messageQueue.async(flags: flags) { [weak self] in
guard let self,
self.isCurrentPanicLifecycleGeneration(
lifecycleGeneration
@@ -6434,11 +6480,34 @@ extension BLEService {
#if DEBUG
self._test_onReceivePacketHandoff?()
#endif
self.handleReceivedPacket(packet, from: peerID)
self.handleReceivedPacketOnQueue(packet, from: peerID)
}
return
}
if isNoisePacket {
guard let lifecycleGeneration =
capturePanicLifecycleGeneration() else {
return
}
messageQueue.async(flags: .barrier) { [weak self] in
guard let self,
self.isCurrentPanicLifecycleGeneration(
lifecycleGeneration
) else {
return
}
self.handleReceivedPacketOnQueue(packet, from: peerID)
}
} else {
handleReceivedPacketOnQueue(packet, from: peerID)
}
}
private func handleReceivedPacketOnQueue(
_ packet: BitchatPacket,
from peerID: PeerID
) {
let context = BLEReceivePipeline.context(for: packet, localPeerID: myPeerID)
let senderID = context.senderID
let messageID = context.messageID
@@ -7217,11 +7286,11 @@ extension BLEService {
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
let processed = noisePacketHandler.handleHandshake(packet, from: peerID)
let isEstablished = noiseService.hasEstablishedSession(with: peerID)
if wasEstablished,
packet.payload.count == NoiseSecurityConstants.xxInitialMessageSize,
!isEstablished {
noteNoiseSessionCleared(for: peerID)
}
// An inbound message 1 quarantines the old transport receive-only.
// Keep its generation-bound BLE state intact: the manager's new
// handshaking generation already gates every outbound policy, while
// a rollback can become ready again without repeating capability
// proof or announce side effects.
// XX message 1 is exactly the unauthenticated 32-byte ephemeral key.
// During an ordinary reconnect, do not authenticate its ingress link
// until a later message completes and proves the responder identity.
@@ -7255,6 +7324,11 @@ extension BLEService {
hasNoiseSession: { [weak self] peerID in
self?.noiseService.hasSession(with: peerID) ?? false
},
isAwaitingResponderHandshakeCompletion: { [weak self] peerID in
self?.noiseService.isAwaitingResponderHandshakeCompletion(
with: peerID
) ?? false
},
initiateHandshake: { [weak self] peerID in
self?.initiateNoiseHandshake(with: peerID)
},
@@ -7268,7 +7342,14 @@ extension BLEService {
guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished }
let result = try self.noiseService.decryptWithSessionGeneration(
payload,
from: peerID
from: peerID,
establishedGenerationIsReady: { generation in
self.collectionsQueue.sync {
self.privateMediaSessionGenerations[
peerID.toShort()
] == generation
}
}
)
return BLENoiseDecryptionResult(
plaintext: result.plaintext,