From 0288404e26f8567052493eff478de58b89eb2480 Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 12 Jul 2026 11:42:42 -0400 Subject: [PATCH] Bind capability state to Noise generations --- bitchat/Noise/NoiseSessionManager.swift | 118 +++++++-- .../Services/BLE/BLENoisePacketHandler.swift | 22 +- bitchat/Services/BLE/BLEService.swift | 230 +++++++++++------- bitchat/Services/NoiseEncryptionService.swift | 78 +++++- bitchatTests/Noise/NoiseCoverageTests.swift | 158 +++++++++++- .../Services/BLENoisePacketHandlerTests.swift | 13 +- .../NoiseEncryptionServiceTests.swift | 52 ++++ docs/PRIVATE-MEDIA-MIGRATION.md | 8 +- 8 files changed, 556 insertions(+), 123 deletions(-) diff --git a/bitchat/Noise/NoiseSessionManager.swift b/bitchat/Noise/NoiseSessionManager.swift index 26608238..e6860ff8 100644 --- a/bitchat/Noise/NoiseSessionManager.swift +++ b/bitchat/Noise/NoiseSessionManager.swift @@ -13,6 +13,10 @@ import BitFoundation final class NoiseSessionManager { private var sessions: [PeerID: NoiseSession] = [:] + /// Opaque identity for each exact entry in `sessions`. The generation is + /// created and removed under the same barrier as the session itself, so a + /// caller can never authenticate data with one session and lease another. + private var sessionGenerations: [PeerID: UUID] = [:] /// A responder rehandshake must not evict a working transport session /// before the candidate proves that its authenticated static key belongs /// to the claimed wire ID. Candidates therefore live outside `sessions` @@ -22,7 +26,7 @@ final class NoiseSessionManager { private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent) // Callbacks - var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)? + var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey, UUID) -> Void)? var onSessionFailed: ((PeerID, Error) -> Void)? init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) { @@ -59,6 +63,7 @@ final class NoiseSessionManager { if let session = sessions.removeValue(forKey: peerID) { session.reset() // Clear sensitive data before removing } + sessionGenerations.removeValue(forKey: peerID) if let candidate = responderCandidates.removeValue(forKey: peerID) { candidate.reset() } @@ -74,6 +79,7 @@ final class NoiseSessionManager { candidate.reset() } sessions.removeAll() + sessionGenerations.removeAll() responderCandidates.removeAll() } } @@ -91,12 +97,14 @@ final class NoiseSessionManager { // Remove any existing non-established session if let existingSession = sessions[peerID], !existingSession.isEstablished() { _ = sessions.removeValue(forKey: peerID) + sessionGenerations.removeValue(forKey: peerID) existingSession.reset() } // Create new initiator session let session = sessionFactory(peerID, .initiator) sessions[peerID] = session + sessionGenerations[peerID] = UUID() do { let handshakeData = try session.startHandshake() @@ -104,6 +112,7 @@ final class NoiseSessionManager { } catch { // Clean up failed session _ = sessions.removeValue(forKey: peerID) + sessionGenerations.removeValue(forKey: peerID) session.reset() SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription)) throw error @@ -112,8 +121,17 @@ final class NoiseSessionManager { } func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? { - // Process everything within the synchronized block to prevent race conditions - return try managerQueue.sync(flags: .barrier) { + // Process everything within the synchronized block to prevent race conditions. + // Return establishment metadata and publish the callback only after the + // manager barrier is released, avoiding both a deadlock and a window in + // which `processHandshakeMessage` returns before authentication state. + let result: ( + response: Data?, + establishedSession: ( + remoteKey: Curve25519.KeyAgreement.PublicKey, + generation: UUID + )? + ) = try managerQueue.sync(flags: .barrier) { let session: NoiseSession let isReplacementCandidate: Bool @@ -144,9 +162,11 @@ final class NoiseSessionManager { // No established transport state exists to preserve. A // fresh initiation replaces the incomplete handshake. _ = sessions.removeValue(forKey: peerID) + sessionGenerations.removeValue(forKey: peerID) existing.reset() let replacement = sessionFactory(peerID, .responder) sessions[peerID] = replacement + sessionGenerations[peerID] = UUID() session = replacement isReplacementCandidate = false } else { @@ -156,6 +176,7 @@ final class NoiseSessionManager { } else { let newSession = sessionFactory(peerID, .responder) sessions[peerID] = newSession + sessionGenerations[peerID] = UUID() session = newSession isReplacementCandidate = false } @@ -165,6 +186,10 @@ final class NoiseSessionManager { let response = try session.processHandshakeMessage(message) // Check if session is established after processing + var establishedSession: ( + remoteKey: Curve25519.KeyAgreement.PublicKey, + generation: UUID + )? if session.isEstablished() { guard let remoteKey = session.getRemoteStaticPublicKey(), authenticatedRemoteKey(remoteKey, matches: peerID) else { @@ -174,18 +199,18 @@ final class NoiseSessionManager { if isReplacementCandidate { _ = responderCandidates.removeValue(forKey: peerID) let previous = sessions.updateValue(session, forKey: peerID) + sessionGenerations[peerID] = UUID() if let previous, previous !== session { previous.reset() } } - - // Schedule callback outside the synchronized block to prevent deadlock - DispatchQueue.global().async { [weak self] in - self?.onSessionEstablished?(peerID, remoteKey) + guard let generation = sessionGenerations[peerID] else { + throw NoiseEncryptionError.sessionNotEstablished } + establishedSession = (remoteKey, generation) } - return response + return (response, establishedSession) } catch { // A failed candidate is discarded without touching the // established session. Ordinary failed handshakes retain the @@ -198,6 +223,7 @@ final class NoiseSessionManager { } else if let storedSession = sessions[peerID], storedSession === session { _ = sessions.removeValue(forKey: peerID) + sessionGenerations.removeValue(forKey: peerID) } session.reset() @@ -210,6 +236,11 @@ final class NoiseSessionManager { throw error } } + + if let established = result.establishedSession { + onSessionEstablished?(peerID, established.remoteKey, established.generation) + } + return result.response } /// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are @@ -233,19 +264,74 @@ final class NoiseSessionManager { // MARK: - Encryption/Decryption func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data { - guard let session = getSession(for: peerID) else { - throw NoiseSessionError.sessionNotFound + try managerQueue.sync { + guard let session = sessions[peerID] else { + throw NoiseSessionError.sessionNotFound + } + return try session.encrypt(plaintext) + } + } + + /// Encrypts only if `expected` still names the current established entry. + /// A rekey between capability proof and media encryption therefore fails + /// closed instead of sending on an unproven replacement session. + func encrypt( + _ plaintext: Data, + for peerID: PeerID, + expectedSessionGeneration expected: UUID + ) throws -> Data { + try managerQueue.sync { + guard let session = sessions[peerID], + session.isEstablished(), + sessionGenerations[peerID] == expected else { + throw NoiseEncryptionError.sessionNotEstablished + } + return try session.encrypt(plaintext) } - - return try session.encrypt(plaintext) } func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data { - guard let session = getSession(for: peerID) else { - throw NoiseSessionError.sessionNotFound + try decryptWithSessionGeneration(ciphertext, from: peerID).plaintext + } + + func sessionGeneration(for peerID: PeerID) -> UUID? { + managerQueue.sync { + guard sessions[peerID]?.isEstablished() == true else { return nil } + return sessionGenerations[peerID] + } + } + + /// Decrypts while holding the manager's read lease. Session promotion and + /// removal require its barrier, so the returned generation always names + /// the exact session object that authenticated these bytes. + func decryptWithSessionGeneration( + _ ciphertext: Data, + from peerID: PeerID + ) throws -> (plaintext: Data, sessionGeneration: UUID) { + try managerQueue.sync { + guard let session = sessions[peerID] else { + throw NoiseSessionError.sessionNotFound + } + guard session.isEstablished(), + let generation = sessionGenerations[peerID] else { + throw NoiseEncryptionError.sessionNotEstablished + } + return (try session.decrypt(ciphertext), generation) + } + } + + /// Runs a state commit under a read lease for the exact established + /// session. Rekey, replacement, and removal all need the same barrier. + func withCurrentSessionGeneration( + for peerID: PeerID, + expected: UUID, + _ body: () -> Result + ) -> Result? { + managerQueue.sync { + guard sessions[peerID]?.isEstablished() == true, + sessionGenerations[peerID] == expected else { return nil } + return body() } - - return try session.decrypt(ciphertext) } // MARK: - Key Management diff --git a/bitchat/Services/BLE/BLENoisePacketHandler.swift b/bitchat/Services/BLE/BLENoisePacketHandler.swift index e874600a..d6e4b79d 100644 --- a/bitchat/Services/BLE/BLENoisePacketHandler.swift +++ b/bitchat/Services/BLE/BLENoisePacketHandler.swift @@ -2,6 +2,11 @@ import BitFoundation import BitLogger import Foundation +struct BLENoiseDecryptionResult { + let plaintext: Data + let sessionGeneration: UUID +} + /// Narrow environment for `BLENoisePacketHandler`. /// /// All queue hops (collections barrier writes, main-actor UI notification) @@ -27,12 +32,16 @@ struct BLENoisePacketHandlerEnvironment { /// Updates the registry last-seen timestamp for the peer (async barrier write). let updatePeerLastSeen: (PeerID) -> Void /// Decrypts an encrypted payload from the peer (crypto). - let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> Data + let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> BLENoiseDecryptionResult /// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto). let clearSession: (PeerID) -> Void /// Consumes session-authenticated protocol state inside the transport. It /// must never escape to UI or Nostr payload dispatch. - let handleAuthenticatedPeerState: (_ peerID: PeerID, _ payload: Data) -> Void + let handleAuthenticatedPeerState: ( + _ peerID: PeerID, + _ payload: Data, + _ sessionGeneration: UUID + ) -> Void /// Delivers `.noisePayloadReceived` to the UI as one main-actor hop. let deliverNoisePayload: ( _ peerID: PeerID, @@ -117,7 +126,8 @@ final class BLENoisePacketHandler { env.updatePeerLastSeen(peerID) do { - let decrypted = try env.decrypt(packet.payload, peerID) + let decryption = try env.decrypt(packet.payload, peerID) + let decrypted = decryption.plaintext guard decrypted.count > 0 else { return } // First byte indicates the payload type @@ -132,7 +142,11 @@ final class BLENoisePacketHandler { SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))…", category: .session) if noisePayloadType == .authenticatedPeerState { - env.handleAuthenticatedPeerState(peerID, Data(payloadData)) + env.handleAuthenticatedPeerState( + peerID, + Data(payloadData), + decryption.sessionGeneration + ) return } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 2268c7b4..5979234d 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -960,6 +960,7 @@ final class BLEService: NSObject { } func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { + let normalizedPeerID = peerID.toShort() let state: ( capabilities: PeerCapabilities, fingerprint: String?, @@ -967,7 +968,6 @@ final class BLEService: NSObject { authenticatedState: BLEAuthenticatedPeerStateObservation?, timedOut: BLEPrivateMediaProofTimeoutMarker? ) = collectionsQueue.sync { - let normalizedPeerID = peerID.toShort() let info = peerRegistry.info(for: normalizedPeerID) return ( info?.capabilities ?? [], @@ -977,6 +977,14 @@ final class BLEService: NSObject { privateMediaProofTimeoutMarkers[normalizedPeerID] ) } + let currentNoiseGeneration = noiseService.sessionGeneration(for: normalizedPeerID) + + // A session replacement can happen before its authentication callback + // reaches messageQueue. Never reuse an observation from the previous + // transport generation during that window. + if state.sessionGeneration != currentNoiseGeneration { + return .awaitingCapabilityProof + } guard let fingerprint = state.fingerprint else { // A raw fallback must be bound to the stable Noise key from a @@ -4282,10 +4290,14 @@ extension BLEService { } private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) { - service.onPeerAuthenticated = { [weak self] peerID, fingerprint in + 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 - self?.handleNoisePeerAuthenticated(peerID: peerID, fingerprint: fingerprint) + self?.handleNoisePeerAuthenticated( + peerID: peerID, + fingerprint: fingerprint, + sessionGeneration: generation + ) } } service.onRekeyHandshakeReady = { [weak self] peerID, message in @@ -4297,45 +4309,59 @@ extension BLEService { } } - private func handleNoisePeerAuthenticated(peerID: PeerID, fingerprint: String) { + private func handleNoisePeerAuthenticated( + peerID: PeerID, + fingerprint: String, + sessionGeneration generation: UUID + ) { let normalizedPeerID = peerID.toShort() - let generation = UUID() - let transition = collectionsQueue.sync(flags: .barrier) { - () -> ( - watchdog: (fingerprint: String, nonce: UUID), - rejected: [@MainActor (PrivateMediaSendPolicy) -> Void] - ) in - let watchdogNonce = UUID() - privateMediaSessionGenerations[normalizedPeerID] = generation - authenticatedPeerStates.removeValue(forKey: normalizedPeerID) - privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID) - privateMediaProofWatchdogs[normalizedPeerID] = BLEPrivateMediaProofWatchdog( - fingerprint: fingerprint, - sessionGeneration: generation, - timeoutNonce: watchdogNonce - ) - authenticatedPeerStateSendProgress[normalizedPeerID] = - BLEAuthenticatedPeerStateSendProgress(sessionGeneration: generation) + guard let transition = noiseService.withCurrentSessionGeneration( + for: normalizedPeerID, + expected: generation, + { + collectionsQueue.sync(flags: .barrier) { + () -> ( + watchdog: (fingerprint: String, nonce: UUID)?, + rejected: [@MainActor (PrivateMediaSendPolicy) -> Void] + ) in + guard privateMediaSessionGenerations[normalizedPeerID] != generation else { + return (nil, []) + } + let watchdogNonce = UUID() + privateMediaSessionGenerations[normalizedPeerID] = generation + authenticatedPeerStates.removeValue(forKey: normalizedPeerID) + privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID) + privateMediaProofWatchdogs[normalizedPeerID] = BLEPrivateMediaProofWatchdog( + fingerprint: fingerprint, + sessionGeneration: generation, + timeoutNonce: watchdogNonce + ) + authenticatedPeerStateSendProgress[normalizedPeerID] = + BLEAuthenticatedPeerStateSendProgress(sessionGeneration: generation) - guard var pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] else { - return ((fingerprint, watchdogNonce), []) + guard var pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] else { + return ((fingerprint, watchdogNonce), []) + } + guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame else { + pendingPrivateMediaPolicyResolutions.removeValue(forKey: normalizedPeerID) + return ((fingerprint, watchdogNonce), Array(pending.completions.values)) + } + pending.sessionGeneration = generation + pending.timeoutNonce = watchdogNonce + pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending + return ((pending.fingerprint, watchdogNonce), []) + } } - guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame else { - pendingPrivateMediaPolicyResolutions.removeValue(forKey: normalizedPeerID) - return ((fingerprint, watchdogNonce), Array(pending.completions.values)) - } - pending.sessionGeneration = generation - pending.timeoutNonce = watchdogNonce - pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending - return ((pending.fingerprint, watchdogNonce), []) - } + ) else { return } + + guard let watchdog = transition.watchdog else { return } completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade) schedulePrivateMediaProofTimeout( for: normalizedPeerID, - fingerprint: transition.watchdog.fingerprint, + fingerprint: watchdog.fingerprint, sessionGeneration: generation, - nonce: transition.watchdog.nonce + nonce: watchdog.nonce ) // `onPeerAuthenticated` can fire while the initiator is returning XX @@ -4383,7 +4409,11 @@ extension BLEService { sendNoisePayload(payload, to: normalizedPeerID) } - private func handleAuthenticatedPeerState(_ payload: Data, from peerID: PeerID) { + private func handleAuthenticatedPeerState( + _ payload: Data, + from peerID: PeerID, + sessionGeneration generation: UUID + ) { let normalizedPeerID = peerID.toShort() guard let state = AuthenticatedPeerStatePacket.decode(from: payload) else { SecureLogger.warning( @@ -4401,59 +4431,67 @@ extension BLEService { ) return } - let generation = collectionsQueue.sync { - privateMediaSessionGenerations[normalizedPeerID] - } - guard let generation else { return } + guard let application = noiseService.withCurrentSessionGeneration( + for: normalizedPeerID, + expected: generation, + { + () -> (accepted: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) in + guard collectionsQueue.sync(execute: { + privateMediaSessionGenerations[normalizedPeerID] == generation + }) else { + return (false, []) + } - // Bind the Ed25519 key and capability pin before waking senders. Both - // mutations are synchronous so no announce or send-policy race can - // observe a half-applied authenticated state. - identityManager.bindAuthenticatedSigningPublicKey( - state.signingPublicKey, - fingerprint: fingerprint - ) - identityManager.upsertCryptographicIdentity( - fingerprint: fingerprint, - noisePublicKey: publicKey, - signingPublicKey: state.signingPublicKey, - claimedNickname: nil - ) - if state.capabilities.contains(.privateMedia) { - identityManager.markPrivateMediaCapable(fingerprint: fingerprint) - } + // The generation lease prevents rekey/session promotion from + // interleaving between validation and these durable mutations. + identityManager.bindAuthenticatedSigningPublicKey( + state.signingPublicKey, + fingerprint: fingerprint + ) + identityManager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: publicKey, + signingPublicKey: state.signingPublicKey, + claimedNickname: nil + ) + if state.capabilities.contains(.privateMedia) { + identityManager.markPrivateMediaCapable(fingerprint: fingerprint) + } - let completions = collectionsQueue.sync(flags: .barrier) { - () -> [@MainActor (PrivateMediaSendPolicy) -> Void] in - guard privateMediaSessionGenerations[normalizedPeerID] == generation else { - return [] + let completions = collectionsQueue.sync(flags: .barrier) { + () -> [@MainActor (PrivateMediaSendPolicy) -> Void] in + guard privateMediaSessionGenerations[normalizedPeerID] == generation else { + return [] + } + peerRegistry.bindAuthenticatedSigningPublicKey( + state.signingPublicKey, + for: normalizedPeerID + ) + authenticatedPeerStates[normalizedPeerID] = BLEAuthenticatedPeerStateObservation( + fingerprint: fingerprint, + sessionGeneration: generation, + capabilities: state.capabilities + ) + privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID) + privateMediaProofWatchdogs.removeValue(forKey: normalizedPeerID) + guard let pending = pendingPrivateMediaPolicyResolutions.removeValue( + forKey: normalizedPeerID + ), pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, + pending.sessionGeneration == generation else { + return [] + } + return Array(pending.completions.values) + } + return (true, completions) } - peerRegistry.bindAuthenticatedSigningPublicKey( - state.signingPublicKey, - for: normalizedPeerID - ) - authenticatedPeerStates[normalizedPeerID] = BLEAuthenticatedPeerStateObservation( - fingerprint: fingerprint, - sessionGeneration: generation, - capabilities: state.capabilities - ) - privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID) - privateMediaProofWatchdogs.removeValue(forKey: normalizedPeerID) - guard let pending = pendingPrivateMediaPolicyResolutions.removeValue( - forKey: normalizedPeerID - ), pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, - pending.sessionGeneration == generation else { - return [] - } - return Array(pending.completions.values) - } + ), application.accepted else { return } // One bounded echo makes initiator/responder proof ordering converge // even when message 3 and the first proof take different mesh links. sendAuthenticatedPeerState(to: normalizedPeerID, echo: true) let policy = privateMediaSendPolicy(to: normalizedPeerID) sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID) - completePrivateMediaPolicyResolution(completions, with: policy) + completePrivateMediaPolicyResolution(application.completions, with: policy) } private func noteNoiseSessionCleared(for peerID: PeerID) { @@ -4543,7 +4581,22 @@ extension BLEService { let encrypted: Data let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first) if isPrivateFile { - encrypted = try noiseService.encryptPrivateFilePayload(typedPayload, for: peerID) + let provenGeneration: UUID? = collectionsQueue.sync { + () -> UUID? in + guard let generation = privateMediaSessionGenerations[peerID], + let authenticated = authenticatedPeerStates[peerID], + authenticated.sessionGeneration == generation, + authenticated.capabilities.contains(.privateMedia) else { return nil } + return generation + } + guard let provenGeneration else { + throw NoiseEncryptionError.sessionNotEstablished + } + encrypted = try noiseService.encryptPrivateFilePayload( + typedPayload, + for: peerID, + sessionGeneration: provenGeneration + ) } else { encrypted = try noiseService.encrypt(typedPayload, for: peerID) } @@ -6680,13 +6733,24 @@ extension BLEService { }, decrypt: { [weak self] payload, peerID in guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished } - return try self.noiseService.decrypt(payload, from: peerID) + let result = try self.noiseService.decryptWithSessionGeneration( + payload, + from: peerID + ) + return BLENoiseDecryptionResult( + plaintext: result.plaintext, + sessionGeneration: result.sessionGeneration + ) }, clearSession: { [weak self] peerID in self?.clearNoiseSession(for: peerID) }, - handleAuthenticatedPeerState: { [weak self] peerID, payload in - self?.handleAuthenticatedPeerState(payload, from: peerID) + handleAuthenticatedPeerState: { [weak self] peerID, payload, generation in + self?.handleAuthenticatedPeerState( + payload, + from: peerID, + sessionGeneration: generation + ) }, deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in if type == .privateFile { diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index fe09274b..c61ae472 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -165,7 +165,6 @@ final class NoiseEncryptionService { // Peer fingerprints (SHA256 hash of static public key) private var peerFingerprints: [PeerID: String] = [:] private var fingerprintToPeerID: [String: PeerID] = [:] - // Thread safety private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent) @@ -183,6 +182,7 @@ final class NoiseEncryptionService { // Callbacks private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication + private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = [] var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake /// Automatic rekey removed the old session and produced XX message 1. /// The transport must clear session-scoped state and put these exact bytes @@ -206,6 +206,18 @@ final class NoiseEncryptionService { } } } + + /// Generation-aware authentication notifications are used by protocols + /// whose state must be bound to one exact Noise transport session. + var onPeerAuthenticatedWithGeneration: ((PeerID, String, UUID) -> Void)? { + get { nil } + set { + guard let handler = newValue else { return } + serviceQueue.async(flags: .barrier) { [weak self] in + self?.onPeerAuthenticatedWithGenerationHandlers.append(handler) + } + } + } init(keychain: KeychainManagerProtocol) { self.keychain = keychain @@ -300,8 +312,12 @@ final class NoiseEncryptionService { self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain) // Set up session callbacks - sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in - self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey) + sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in + self?.handleSessionEstablished( + peerID: peerID, + remoteStaticKey: remoteStaticKey, + sessionGeneration: generation + ) } // Start session maintenance timer @@ -735,7 +751,11 @@ final class NoiseEncryptionService { /// messages retain the 64 KiB ceiling; this purpose-specific path permits /// the bounded `BitchatFilePacket` envelope and refuses every other typed /// payload so the larger allocation budget cannot become a generic bypass. - func encryptPrivateFilePayload(_ data: Data, for peerID: PeerID) throws -> Data { + func encryptPrivateFilePayload( + _ data: Data, + for peerID: PeerID, + sessionGeneration: UUID? = nil + ) throws -> Data { guard NoisePayloadType.isPrivateFile(rawValue: data.first), NoiseSecurityValidator.validatePrivateFileMessageSize(data) else { throw NoiseSecurityError.messageTooLarge @@ -752,11 +772,25 @@ final class NoiseEncryptionService { // `maxPrivateFilePlaintextSize` already subtracts the cipher's fixed // nonce/tag overhead, so the result is bounded without a second copy. + if let sessionGeneration { + return try sessionManager.encrypt( + data, + for: peerID, + expectedSessionGeneration: sessionGeneration + ) + } return try sessionManager.encrypt(data, for: peerID) } /// Decrypt data from a specific peer func decrypt(_ data: Data, from peerID: PeerID) throws -> Data { + try decryptWithSessionGeneration(data, from: peerID).plaintext + } + + func decryptWithSessionGeneration( + _ data: Data, + from peerID: PeerID + ) throws -> (plaintext: Data, sessionGeneration: UUID) { // Standard transport ciphertext has 20 bytes of nonce/tag overhead. // A larger candidate is admitted only up to the framed-file ceiling; // after authenticated decryption it must prove it is `.privateFile`. @@ -775,14 +809,14 @@ final class NoiseEncryptionService { throw NoiseEncryptionError.sessionNotEstablished } - let decrypted = try sessionManager.decrypt(data, from: peerID) + let result = try sessionManager.decryptWithSessionGeneration(data, from: peerID) if !isStandardCiphertext { - guard NoisePayloadType.isPrivateFile(rawValue: decrypted.first), - NoiseSecurityValidator.validatePrivateFileMessageSize(decrypted) else { + guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first), + NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else { throw NoiseSecurityError.messageTooLarge } } - return decrypted + return result } // MARK: - Peer Management @@ -794,6 +828,25 @@ final class NoiseEncryptionService { } } + func sessionGeneration(for peerID: PeerID) -> UUID? { + sessionManager.sessionGeneration(for: peerID) + } + + /// Runs `body` while holding a read lease on the exact session generation. + /// Session insertion, replacement, and removal use the same manager + /// barrier, so they cannot interleave with an authenticated-state commit. + func withCurrentSessionGeneration( + for peerID: PeerID, + expected: UUID, + _ body: () -> Result + ) -> Result? { + sessionManager.withCurrentSessionGeneration( + for: peerID, + expected: expected, + body + ) + } + func clearEphemeralStateForPanic() { sessionManager.removeAllSessions() serviceQueue.sync(flags: .barrier) { @@ -816,7 +869,11 @@ final class NoiseEncryptionService { // MARK: - Private Helpers - private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) { + private func handleSessionEstablished( + peerID: PeerID, + remoteStaticKey: Curve25519.KeyAgreement.PublicKey, + sessionGeneration: UUID + ) { // Calculate fingerprint let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint() @@ -831,6 +888,9 @@ final class NoiseEncryptionService { // Notify all handlers about authentication serviceQueue.async { [weak self] in + self?.onPeerAuthenticatedWithGenerationHandlers.forEach { handler in + handler(peerID, fingerprint, sessionGeneration) + } self?.onPeerAuthenticatedHandlers.forEach { handler in handler(peerID, fingerprint) } diff --git a/bitchatTests/Noise/NoiseCoverageTests.swift b/bitchatTests/Noise/NoiseCoverageTests.swift index 83d8c3dc..61de0795 100644 --- a/bitchatTests/Noise/NoiseCoverageTests.swift +++ b/bitchatTests/Noise/NoiseCoverageTests.swift @@ -542,8 +542,12 @@ struct NoiseCoverageTests { let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain) let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain) - aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:) - bobManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:) + aliceManager.onSessionEstablished = establishedRecorder.recordEstablished( + peerID:remoteKey:sessionGeneration: + ) + bobManager.onSessionEstablished = establishedRecorder.recordEstablished( + peerID:remoteKey:sessionGeneration: + ) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) @@ -658,6 +662,95 @@ struct NoiseCoverageTests { #expect(rekeyedSession.getState() == .handshaking) } + @Test("A stale decrypt generation cannot commit across session promotion") + func staleDecryptGenerationCannotCommitAcrossPromotion() throws { + let aliceManager = NoiseSessionManager( + localStaticKey: aliceStaticKey, + keychain: keychain, + sessionFactory: { peerID, role in + BlockingDecryptNoiseSession( + peerID: peerID, + role: role, + keychain: self.keychain, + localStaticKey: self.aliceStaticKey + ) + } + ) + let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain) + try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) + + let oldSession = try #require( + aliceManager.getSession(for: alicePeerID) as? BlockingDecryptNoiseSession + ) + let oldGeneration = try #require(aliceManager.sessionGeneration(for: alicePeerID)) + + // Prepare a fully authenticated responder candidate without promoting + // it yet. Its final XX message is the exact operation that replaces + // the old `sessions[peerID]` entry. + let replacementInitiator = NoiseSession( + peerID: bobPeerID, + role: .initiator, + keychain: keychain, + localStaticKey: bobStaticKey + ) + let message1 = try replacementInitiator.startHandshake() + let message2 = try #require( + try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message1) + ) + let message3 = try #require(try replacementInitiator.processHandshakeMessage(message2)) + + let ciphertext = try bobManager.encrypt(Data("old session".utf8), for: bobPeerID) + oldSession.pauseNextDecrypt() + + let decryptResult = ConcurrentTestResult<(plaintext: Data, sessionGeneration: UUID)>() + DispatchQueue.global().async { + decryptResult.capture { + try aliceManager.decryptWithSessionGeneration(ciphertext, from: self.alicePeerID) + } + } + #expect(oldSession.waitForDecryptStart(timeout: 2)) + + let promotionStarted = DispatchSemaphore(value: 0) + let promotionResult = ConcurrentTestResult() + DispatchQueue.global().async { + promotionStarted.signal() + promotionResult.capture { + try aliceManager.handleIncomingHandshake(from: self.alicePeerID, message: message3) + } + } + #expect(promotionStarted.wait(timeout: .now() + 2) == .success) + #expect( + promotionResult.wait(timeout: 0.05) == nil, + "Promotion must wait for the exact decrypting-session lease" + ) + + oldSession.resumeDecrypt() + let decrypted = try #require(decryptResult.wait(timeout: 2)).get() + _ = try #require(promotionResult.wait(timeout: 2)).get() + + #expect(decrypted.plaintext == Data("old session".utf8)) + #expect(decrypted.sessionGeneration == oldGeneration) + #expect(aliceManager.sessionGeneration(for: alicePeerID) != oldGeneration) + #expect(throws: NoiseEncryptionError.sessionNotEstablished) { + try aliceManager.encrypt( + Data("stale send".utf8), + for: alicePeerID, + expectedSessionGeneration: oldGeneration + ) + } + + var staleCommitRan = false + let staleCommit = aliceManager.withCurrentSessionGeneration( + for: alicePeerID, + expected: decrypted.sessionGeneration + ) { + staleCommitRan = true + return true + } + #expect(staleCommit == nil) + #expect(!staleCommitRan) + } + @Test("Secure noise sessions enforce limits and renegotiation thresholds") func secureNoiseSessionsEnforceLimitsAndThresholds() throws { let initiator = SecureNoiseSession( @@ -852,7 +945,11 @@ private final class SessionCallbackRecorder: @unchecked Sendable { return establishedEntries.map(\.0) } - func recordEstablished(peerID: PeerID, remoteKey: Curve25519.KeyAgreement.PublicKey) { + func recordEstablished( + peerID: PeerID, + remoteKey: Curve25519.KeyAgreement.PublicKey, + sessionGeneration _: UUID + ) { lock.lock() establishedEntries.append((peerID, remoteKey.rawRepresentation)) lock.unlock() @@ -874,3 +971,58 @@ private final class FailingNoiseSession: NoiseSession { throw Error.synthetic } } + +private final class BlockingDecryptNoiseSession: NoiseSession, @unchecked Sendable { + private let controlLock = NSLock() + private var shouldPauseNextDecrypt = false + private let decryptStarted = DispatchSemaphore(value: 0) + private let resumeDecryptSemaphore = DispatchSemaphore(value: 0) + + func pauseNextDecrypt() { + controlLock.lock() + shouldPauseNextDecrypt = true + controlLock.unlock() + } + + func waitForDecryptStart(timeout: TimeInterval) -> Bool { + decryptStarted.wait(timeout: .now() + timeout) == .success + } + + func resumeDecrypt() { + resumeDecryptSemaphore.signal() + } + + override func decrypt(_ ciphertext: Data) throws -> Data { + controlLock.lock() + let shouldPause = shouldPauseNextDecrypt + shouldPauseNextDecrypt = false + controlLock.unlock() + + if shouldPause { + decryptStarted.signal() + resumeDecryptSemaphore.wait() + } + return try super.decrypt(ciphertext) + } +} + +private final class ConcurrentTestResult: @unchecked Sendable { + private let lock = NSLock() + private let completed = DispatchSemaphore(value: 0) + private var storedResult: Result? + + func capture(_ operation: () throws -> Value) { + let result = Result(catching: operation) + lock.lock() + storedResult = result + lock.unlock() + completed.signal() + } + + func wait(timeout: TimeInterval) -> Result? { + guard completed.wait(timeout: .now() + timeout) == .success else { return nil } + lock.lock() + defer { lock.unlock() } + return storedResult + } +} diff --git a/bitchatTests/Services/BLENoisePacketHandlerTests.swift b/bitchatTests/Services/BLENoisePacketHandlerTests.swift index 36b4b53d..cdd0f20b 100644 --- a/bitchatTests/Services/BLENoisePacketHandlerTests.swift +++ b/bitchatTests/Services/BLENoisePacketHandlerTests.swift @@ -9,6 +9,7 @@ struct BLENoisePacketHandlerTests { private final class Recorder { var handshakeResult: Result = .success(nil) var hasSession = false + let sessionGeneration = UUID() var decryptResult: Result = .success(Data()) var processedHandshakes: [(peerID: PeerID, message: Data)] = [] @@ -18,7 +19,7 @@ struct BLENoisePacketHandlerTests { var lastSeenUpdates: [PeerID] = [] var decryptCalls: [(payload: Data, peerID: PeerID)] = [] var clearedSessions: [PeerID] = [] - var authenticatedPeerStates: [(peerID: PeerID, payload: Data)] = [] + var authenticatedPeerStates: [(peerID: PeerID, payload: Data, generation: UUID)] = [] var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = [] /// Ordered side-effect log to assert recovery sequencing. var events: [String] = [] @@ -57,14 +58,17 @@ struct BLENoisePacketHandlerTests { }, decrypt: { payload, peerID in recorder.decryptCalls.append((payload, peerID)) - return try recorder.decryptResult.get() + return BLENoiseDecryptionResult( + plaintext: try recorder.decryptResult.get(), + sessionGeneration: recorder.sessionGeneration + ) }, clearSession: { peerID in recorder.clearedSessions.append(peerID) recorder.events.append("clearSession") }, - handleAuthenticatedPeerState: { peerID, payload in - recorder.authenticatedPeerStates.append((peerID, payload)) + handleAuthenticatedPeerState: { peerID, payload, generation in + recorder.authenticatedPeerStates.append((peerID, payload, generation)) }, deliverNoisePayload: { peerID, type, payload, timestamp in recorder.deliveries.append((peerID, type, payload, timestamp)) @@ -240,6 +244,7 @@ struct BLENoisePacketHandlerTests { #expect(recorder.authenticatedPeerStates.count == 1) #expect(recorder.authenticatedPeerStates.first?.peerID == remotePeerID) #expect(recorder.authenticatedPeerStates.first?.payload == Data([0x01, 0x02, 0x03])) + #expect(recorder.authenticatedPeerStates.first?.generation == recorder.sessionGeneration) #expect(recorder.deliveries.isEmpty) } diff --git a/bitchatTests/Services/NoiseEncryptionServiceTests.swift b/bitchatTests/Services/NoiseEncryptionServiceTests.swift index f32cf755..b6443a25 100644 --- a/bitchatTests/Services/NoiseEncryptionServiceTests.swift +++ b/bitchatTests/Services/NoiseEncryptionServiceTests.swift @@ -96,13 +96,22 @@ struct NoiseEncryptionServiceTests { let recorder = AuthenticationRecorder() #expect(alice.onPeerAuthenticated == nil) + #expect(bob.onPeerAuthenticatedWithGeneration == nil) alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:)) bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:) + bob.onPeerAuthenticatedWithGeneration = recorder.record( + peerID:fingerprint:sessionGeneration: + ) try establishSessions(alice: alice, bob: bob) let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0) #expect(authenticated) + let generationAuthenticated = await TestHelpers.waitUntil( + { recorder.generationCount >= 1 }, + timeout: 5.0 + ) + #expect(generationAuthenticated) #expect(alice.hasEstablishedSession(with: bobPeerID)) #expect(bob.hasEstablishedSession(with: alicePeerID)) #expect(alice.hasSession(with: bobPeerID)) @@ -111,6 +120,7 @@ struct NoiseEncryptionServiceTests { #expect(bob.getPeerPublicKeyData(alicePeerID)?.count == 32) #expect(alice.getPeerFingerprint(bobPeerID) != nil) #expect(bob.getPeerFingerprint(alicePeerID) != nil) + #expect(recorder.generation(for: alicePeerID) == bob.sessionGeneration(for: alicePeerID)) let plaintext = Data("secret payload".utf8) let ciphertext = try alice.encrypt(plaintext, for: bobPeerID) @@ -244,6 +254,17 @@ struct NoiseEncryptionServiceTests { let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) try establishSessions(alice: alice, bob: bob) + let originalGeneration = try #require(alice.sessionGeneration(for: bobPeerID)) + var leaseRan = false + let leased = alice.withCurrentSessionGeneration( + for: bobPeerID, + expected: originalGeneration + ) { + leaseRan = true + return true + } + #expect(leased == true) + #expect(leaseRan) var emittedPeerID: PeerID? var emittedMessage: Data? @@ -254,6 +275,17 @@ struct NoiseEncryptionServiceTests { try alice._test_initiateAutomaticRekey(for: bobPeerID) #expect(emittedPeerID == bobPeerID) + #expect(alice.sessionGeneration(for: bobPeerID) == nil) + leaseRan = false + let staleLease = alice.withCurrentSessionGeneration( + for: bobPeerID, + expected: originalGeneration + ) { + leaseRan = true + return true + } + #expect(staleLease == nil) + #expect(!leaseRan) let message1 = try #require(emittedMessage) #expect(!message1.isEmpty) #expect(alice.hasSession(with: bobPeerID)) @@ -269,6 +301,7 @@ struct NoiseEncryptionServiceTests { #expect(alice.hasEstablishedSession(with: bobPeerID)) #expect(bob.hasEstablishedSession(with: alicePeerID)) + #expect(alice.sessionGeneration(for: bobPeerID) != originalGeneration) } @Test("Large private-file payloads use the bounded Noise extension") @@ -411,6 +444,7 @@ struct NoiseEncryptionServiceTests { private final class AuthenticationRecorder: @unchecked Sendable { private let lock = NSLock() private var entries: [(PeerID, String)] = [] + private var generationEntries: [(PeerID, UUID)] = [] var count: Int { lock.lock() @@ -418,9 +452,27 @@ private final class AuthenticationRecorder: @unchecked Sendable { return entries.count } + var generationCount: Int { + lock.lock() + defer { lock.unlock() } + return generationEntries.count + } + func record(peerID: PeerID, fingerprint: String) { lock.lock() entries.append((peerID, fingerprint)) lock.unlock() } + + func record(peerID: PeerID, fingerprint _: String, sessionGeneration: UUID) { + lock.lock() + generationEntries.append((peerID, sessionGeneration)) + lock.unlock() + } + + func generation(for peerID: PeerID) -> UUID? { + lock.lock() + defer { lock.unlock() } + return generationEntries.last { $0.0 == peerID }?.1 + } } diff --git a/docs/PRIVATE-MEDIA-MIGRATION.md b/docs/PRIVATE-MEDIA-MIGRATION.md index f4e66217..fa0ba2f1 100644 --- a/docs/PRIVATE-MEDIA-MIGRATION.md +++ b/docs/PRIVATE-MEDIA-MIGRATION.md @@ -70,10 +70,10 @@ The fallback is signed and its signature is required on receive, so relays cannot forge its sender or contents. It is not confidential: relays can see the raw file TLV. The UI says this explicitly and asks on every send. A peer without a stable Noise key from a verified registry entry cannot use the -fallback. Keep -the fallback only for the mixed-version migration and remove it after -supported Android and iOS releases advertise `privateMedia`. Never replace it -with an unsigned fallback, persist blanket consent, or send both forms. +fallback. Keep it only for the mixed-version migration, and remove it only +after minimum-supported Android and iOS releases emit authenticated bit-8 +`0x21` state and the legacy population has aged out. Never replace it with an +unsigned fallback, persist blanket consent, or send both forms. Incoming clients accept all three migration-era shapes: