Bind capability state to Noise generations

This commit is contained in:
jack
2026-07-12 11:42:42 -04:00
parent 6063d42500
commit 0288404e26
8 changed files with 556 additions and 123 deletions
+102 -16
View File
@@ -13,6 +13,10 @@ import BitFoundation
final class NoiseSessionManager { final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:] 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 /// A responder rehandshake must not evict a working transport session
/// before the candidate proves that its authenticated static key belongs /// before the candidate proves that its authenticated static key belongs
/// to the claimed wire ID. Candidates therefore live outside `sessions` /// 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) private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
// Callbacks // Callbacks
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)? var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey, UUID) -> Void)?
var onSessionFailed: ((PeerID, Error) -> Void)? var onSessionFailed: ((PeerID, Error) -> Void)?
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) { init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
@@ -59,6 +63,7 @@ final class NoiseSessionManager {
if let session = sessions.removeValue(forKey: peerID) { if let session = sessions.removeValue(forKey: peerID) {
session.reset() // Clear sensitive data before removing session.reset() // Clear sensitive data before removing
} }
sessionGenerations.removeValue(forKey: peerID)
if let candidate = responderCandidates.removeValue(forKey: peerID) { if let candidate = responderCandidates.removeValue(forKey: peerID) {
candidate.reset() candidate.reset()
} }
@@ -74,6 +79,7 @@ final class NoiseSessionManager {
candidate.reset() candidate.reset()
} }
sessions.removeAll() sessions.removeAll()
sessionGenerations.removeAll()
responderCandidates.removeAll() responderCandidates.removeAll()
} }
} }
@@ -91,12 +97,14 @@ final class NoiseSessionManager {
// Remove any existing non-established session // Remove any existing non-established session
if let existingSession = sessions[peerID], !existingSession.isEstablished() { if let existingSession = sessions[peerID], !existingSession.isEstablished() {
_ = sessions.removeValue(forKey: peerID) _ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
existingSession.reset() existingSession.reset()
} }
// Create new initiator session // Create new initiator session
let session = sessionFactory(peerID, .initiator) let session = sessionFactory(peerID, .initiator)
sessions[peerID] = session sessions[peerID] = session
sessionGenerations[peerID] = UUID()
do { do {
let handshakeData = try session.startHandshake() let handshakeData = try session.startHandshake()
@@ -104,6 +112,7 @@ final class NoiseSessionManager {
} catch { } catch {
// Clean up failed session // Clean up failed session
_ = sessions.removeValue(forKey: peerID) _ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
session.reset() session.reset()
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription)) SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
throw error throw error
@@ -112,8 +121,17 @@ final class NoiseSessionManager {
} }
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? { func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
// Process everything within the synchronized block to prevent race conditions // Process everything within the synchronized block to prevent race conditions.
return try managerQueue.sync(flags: .barrier) { // 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 session: NoiseSession
let isReplacementCandidate: Bool let isReplacementCandidate: Bool
@@ -144,9 +162,11 @@ final class NoiseSessionManager {
// No established transport state exists to preserve. A // No established transport state exists to preserve. A
// fresh initiation replaces the incomplete handshake. // fresh initiation replaces the incomplete handshake.
_ = sessions.removeValue(forKey: peerID) _ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
existing.reset() existing.reset()
let replacement = sessionFactory(peerID, .responder) let replacement = sessionFactory(peerID, .responder)
sessions[peerID] = replacement sessions[peerID] = replacement
sessionGenerations[peerID] = UUID()
session = replacement session = replacement
isReplacementCandidate = false isReplacementCandidate = false
} else { } else {
@@ -156,6 +176,7 @@ final class NoiseSessionManager {
} else { } else {
let newSession = sessionFactory(peerID, .responder) let newSession = sessionFactory(peerID, .responder)
sessions[peerID] = newSession sessions[peerID] = newSession
sessionGenerations[peerID] = UUID()
session = newSession session = newSession
isReplacementCandidate = false isReplacementCandidate = false
} }
@@ -165,6 +186,10 @@ final class NoiseSessionManager {
let response = try session.processHandshakeMessage(message) let response = try session.processHandshakeMessage(message)
// Check if session is established after processing // Check if session is established after processing
var establishedSession: (
remoteKey: Curve25519.KeyAgreement.PublicKey,
generation: UUID
)?
if session.isEstablished() { if session.isEstablished() {
guard let remoteKey = session.getRemoteStaticPublicKey(), guard let remoteKey = session.getRemoteStaticPublicKey(),
authenticatedRemoteKey(remoteKey, matches: peerID) else { authenticatedRemoteKey(remoteKey, matches: peerID) else {
@@ -174,18 +199,18 @@ final class NoiseSessionManager {
if isReplacementCandidate { if isReplacementCandidate {
_ = responderCandidates.removeValue(forKey: peerID) _ = responderCandidates.removeValue(forKey: peerID)
let previous = sessions.updateValue(session, forKey: peerID) let previous = sessions.updateValue(session, forKey: peerID)
sessionGenerations[peerID] = UUID()
if let previous, previous !== session { if let previous, previous !== session {
previous.reset() previous.reset()
} }
} }
guard let generation = sessionGenerations[peerID] else {
// Schedule callback outside the synchronized block to prevent deadlock throw NoiseEncryptionError.sessionNotEstablished
DispatchQueue.global().async { [weak self] in
self?.onSessionEstablished?(peerID, remoteKey)
} }
establishedSession = (remoteKey, generation)
} }
return response return (response, establishedSession)
} catch { } catch {
// A failed candidate is discarded without touching the // A failed candidate is discarded without touching the
// established session. Ordinary failed handshakes retain the // established session. Ordinary failed handshakes retain the
@@ -198,6 +223,7 @@ final class NoiseSessionManager {
} else if let storedSession = sessions[peerID], } else if let storedSession = sessions[peerID],
storedSession === session { storedSession === session {
_ = sessions.removeValue(forKey: peerID) _ = sessions.removeValue(forKey: peerID)
sessionGenerations.removeValue(forKey: peerID)
} }
session.reset() session.reset()
@@ -210,6 +236,11 @@ final class NoiseSessionManager {
throw error 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 /// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are
@@ -233,19 +264,74 @@ final class NoiseSessionManager {
// MARK: - Encryption/Decryption // MARK: - Encryption/Decryption
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data { func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
guard let session = getSession(for: peerID) else { try managerQueue.sync {
throw NoiseSessionError.sessionNotFound 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 { func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
guard let session = getSession(for: peerID) else { try decryptWithSessionGeneration(ciphertext, from: peerID).plaintext
throw NoiseSessionError.sessionNotFound }
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<Result>(
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 // MARK: - Key Management
@@ -2,6 +2,11 @@ import BitFoundation
import BitLogger import BitLogger
import Foundation import Foundation
struct BLENoiseDecryptionResult {
let plaintext: Data
let sessionGeneration: UUID
}
/// Narrow environment for `BLENoisePacketHandler`. /// Narrow environment for `BLENoisePacketHandler`.
/// ///
/// All queue hops (collections barrier writes, main-actor UI notification) /// 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). /// Updates the registry last-seen timestamp for the peer (async barrier write).
let updatePeerLastSeen: (PeerID) -> Void let updatePeerLastSeen: (PeerID) -> Void
/// Decrypts an encrypted payload from the peer (crypto). /// 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). /// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto).
let clearSession: (PeerID) -> Void let clearSession: (PeerID) -> Void
/// Consumes session-authenticated protocol state inside the transport. It /// Consumes session-authenticated protocol state inside the transport. It
/// must never escape to UI or Nostr payload dispatch. /// 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. /// Delivers `.noisePayloadReceived` to the UI as one main-actor hop.
let deliverNoisePayload: ( let deliverNoisePayload: (
_ peerID: PeerID, _ peerID: PeerID,
@@ -117,7 +126,8 @@ final class BLENoisePacketHandler {
env.updatePeerLastSeen(peerID) env.updatePeerLastSeen(peerID)
do { 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 } guard decrypted.count > 0 else { return }
// First byte indicates the payload type // 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) SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))", category: .session)
if noisePayloadType == .authenticatedPeerState { if noisePayloadType == .authenticatedPeerState {
env.handleAuthenticatedPeerState(peerID, Data(payloadData)) env.handleAuthenticatedPeerState(
peerID,
Data(payloadData),
decryption.sessionGeneration
)
return return
} }
+147 -83
View File
@@ -960,6 +960,7 @@ final class BLEService: NSObject {
} }
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
let normalizedPeerID = peerID.toShort()
let state: ( let state: (
capabilities: PeerCapabilities, capabilities: PeerCapabilities,
fingerprint: String?, fingerprint: String?,
@@ -967,7 +968,6 @@ final class BLEService: NSObject {
authenticatedState: BLEAuthenticatedPeerStateObservation?, authenticatedState: BLEAuthenticatedPeerStateObservation?,
timedOut: BLEPrivateMediaProofTimeoutMarker? timedOut: BLEPrivateMediaProofTimeoutMarker?
) = collectionsQueue.sync { ) = collectionsQueue.sync {
let normalizedPeerID = peerID.toShort()
let info = peerRegistry.info(for: normalizedPeerID) let info = peerRegistry.info(for: normalizedPeerID)
return ( return (
info?.capabilities ?? [], info?.capabilities ?? [],
@@ -977,6 +977,14 @@ final class BLEService: NSObject {
privateMediaProofTimeoutMarkers[normalizedPeerID] 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 { guard let fingerprint = state.fingerprint else {
// A raw fallback must be bound to the stable Noise key from a // 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) { 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))") SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))")
self?.messageQueue.async { [weak self] in 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 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 normalizedPeerID = peerID.toShort()
let generation = UUID() guard let transition = noiseService.withCurrentSessionGeneration(
let transition = collectionsQueue.sync(flags: .barrier) { for: normalizedPeerID,
() -> ( expected: generation,
watchdog: (fingerprint: String, nonce: UUID), {
rejected: [@MainActor (PrivateMediaSendPolicy) -> Void] collectionsQueue.sync(flags: .barrier) {
) in () -> (
let watchdogNonce = UUID() watchdog: (fingerprint: String, nonce: UUID)?,
privateMediaSessionGenerations[normalizedPeerID] = generation rejected: [@MainActor (PrivateMediaSendPolicy) -> Void]
authenticatedPeerStates.removeValue(forKey: normalizedPeerID) ) in
privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID) guard privateMediaSessionGenerations[normalizedPeerID] != generation else {
privateMediaProofWatchdogs[normalizedPeerID] = BLEPrivateMediaProofWatchdog( return (nil, [])
fingerprint: fingerprint, }
sessionGeneration: generation, let watchdogNonce = UUID()
timeoutNonce: watchdogNonce privateMediaSessionGenerations[normalizedPeerID] = generation
) authenticatedPeerStates.removeValue(forKey: normalizedPeerID)
authenticatedPeerStateSendProgress[normalizedPeerID] = privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID)
BLEAuthenticatedPeerStateSendProgress(sessionGeneration: generation) privateMediaProofWatchdogs[normalizedPeerID] = BLEPrivateMediaProofWatchdog(
fingerprint: fingerprint,
sessionGeneration: generation,
timeoutNonce: watchdogNonce
)
authenticatedPeerStateSendProgress[normalizedPeerID] =
BLEAuthenticatedPeerStateSendProgress(sessionGeneration: generation)
guard var pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] else { guard var pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] else {
return ((fingerprint, watchdogNonce), []) 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 { ) else { return }
pendingPrivateMediaPolicyResolutions.removeValue(forKey: normalizedPeerID)
return ((fingerprint, watchdogNonce), Array(pending.completions.values)) guard let watchdog = transition.watchdog else { return }
}
pending.sessionGeneration = generation
pending.timeoutNonce = watchdogNonce
pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending
return ((pending.fingerprint, watchdogNonce), [])
}
completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade) completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade)
schedulePrivateMediaProofTimeout( schedulePrivateMediaProofTimeout(
for: normalizedPeerID, for: normalizedPeerID,
fingerprint: transition.watchdog.fingerprint, fingerprint: watchdog.fingerprint,
sessionGeneration: generation, sessionGeneration: generation,
nonce: transition.watchdog.nonce nonce: watchdog.nonce
) )
// `onPeerAuthenticated` can fire while the initiator is returning XX // `onPeerAuthenticated` can fire while the initiator is returning XX
@@ -4383,7 +4409,11 @@ extension BLEService {
sendNoisePayload(payload, to: normalizedPeerID) 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() let normalizedPeerID = peerID.toShort()
guard let state = AuthenticatedPeerStatePacket.decode(from: payload) else { guard let state = AuthenticatedPeerStatePacket.decode(from: payload) else {
SecureLogger.warning( SecureLogger.warning(
@@ -4401,59 +4431,67 @@ extension BLEService {
) )
return return
} }
let generation = collectionsQueue.sync { guard let application = noiseService.withCurrentSessionGeneration(
privateMediaSessionGenerations[normalizedPeerID] for: normalizedPeerID,
} expected: generation,
guard let generation else { return } {
() -> (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 // The generation lease prevents rekey/session promotion from
// mutations are synchronous so no announce or send-policy race can // interleaving between validation and these durable mutations.
// observe a half-applied authenticated state. identityManager.bindAuthenticatedSigningPublicKey(
identityManager.bindAuthenticatedSigningPublicKey( state.signingPublicKey,
state.signingPublicKey, fingerprint: fingerprint
fingerprint: fingerprint )
) identityManager.upsertCryptographicIdentity(
identityManager.upsertCryptographicIdentity( fingerprint: fingerprint,
fingerprint: fingerprint, noisePublicKey: publicKey,
noisePublicKey: publicKey, signingPublicKey: state.signingPublicKey,
signingPublicKey: state.signingPublicKey, claimedNickname: nil
claimedNickname: nil )
) if state.capabilities.contains(.privateMedia) {
if state.capabilities.contains(.privateMedia) { identityManager.markPrivateMediaCapable(fingerprint: fingerprint)
identityManager.markPrivateMediaCapable(fingerprint: fingerprint) }
}
let completions = collectionsQueue.sync(flags: .barrier) { let completions = collectionsQueue.sync(flags: .barrier) {
() -> [@MainActor (PrivateMediaSendPolicy) -> Void] in () -> [@MainActor (PrivateMediaSendPolicy) -> Void] in
guard privateMediaSessionGenerations[normalizedPeerID] == generation else { guard privateMediaSessionGenerations[normalizedPeerID] == generation else {
return [] 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( ), application.accepted else { return }
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)
}
// One bounded echo makes initiator/responder proof ordering converge // One bounded echo makes initiator/responder proof ordering converge
// even when message 3 and the first proof take different mesh links. // even when message 3 and the first proof take different mesh links.
sendAuthenticatedPeerState(to: normalizedPeerID, echo: true) sendAuthenticatedPeerState(to: normalizedPeerID, echo: true)
let policy = privateMediaSendPolicy(to: normalizedPeerID) let policy = privateMediaSendPolicy(to: normalizedPeerID)
sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID) sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID)
completePrivateMediaPolicyResolution(completions, with: policy) completePrivateMediaPolicyResolution(application.completions, with: policy)
} }
private func noteNoiseSessionCleared(for peerID: PeerID) { private func noteNoiseSessionCleared(for peerID: PeerID) {
@@ -4543,7 +4581,22 @@ extension BLEService {
let encrypted: Data let encrypted: Data
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first) let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first)
if isPrivateFile { 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 { } else {
encrypted = try noiseService.encrypt(typedPayload, for: peerID) encrypted = try noiseService.encrypt(typedPayload, for: peerID)
} }
@@ -6680,13 +6733,24 @@ extension BLEService {
}, },
decrypt: { [weak self] payload, peerID in decrypt: { [weak self] payload, peerID in
guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished } 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 clearSession: { [weak self] peerID in
self?.clearNoiseSession(for: peerID) self?.clearNoiseSession(for: peerID)
}, },
handleAuthenticatedPeerState: { [weak self] peerID, payload in handleAuthenticatedPeerState: { [weak self] peerID, payload, generation in
self?.handleAuthenticatedPeerState(payload, from: peerID) self?.handleAuthenticatedPeerState(
payload,
from: peerID,
sessionGeneration: generation
)
}, },
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
if type == .privateFile { if type == .privateFile {
+69 -9
View File
@@ -165,7 +165,6 @@ final class NoiseEncryptionService {
// Peer fingerprints (SHA256 hash of static public key) // Peer fingerprints (SHA256 hash of static public key)
private var peerFingerprints: [PeerID: String] = [:] private var peerFingerprints: [PeerID: String] = [:]
private var fingerprintToPeerID: [String: PeerID] = [:] private var fingerprintToPeerID: [String: PeerID] = [:]
// Thread safety // Thread safety
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent) private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
@@ -183,6 +182,7 @@ final class NoiseEncryptionService {
// Callbacks // Callbacks
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication 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 var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
/// Automatic rekey removed the old session and produced XX message 1. /// Automatic rekey removed the old session and produced XX message 1.
/// The transport must clear session-scoped state and put these exact bytes /// 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) { init(keychain: KeychainManagerProtocol) {
self.keychain = keychain self.keychain = keychain
@@ -300,8 +312,12 @@ final class NoiseEncryptionService {
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain) self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
// Set up session callbacks // Set up session callbacks
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey) self?.handleSessionEstablished(
peerID: peerID,
remoteStaticKey: remoteStaticKey,
sessionGeneration: generation
)
} }
// Start session maintenance timer // Start session maintenance timer
@@ -735,7 +751,11 @@ final class NoiseEncryptionService {
/// messages retain the 64 KiB ceiling; this purpose-specific path permits /// messages retain the 64 KiB ceiling; this purpose-specific path permits
/// the bounded `BitchatFilePacket` envelope and refuses every other typed /// the bounded `BitchatFilePacket` envelope and refuses every other typed
/// payload so the larger allocation budget cannot become a generic bypass. /// 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), guard NoisePayloadType.isPrivateFile(rawValue: data.first),
NoiseSecurityValidator.validatePrivateFileMessageSize(data) else { NoiseSecurityValidator.validatePrivateFileMessageSize(data) else {
throw NoiseSecurityError.messageTooLarge throw NoiseSecurityError.messageTooLarge
@@ -752,11 +772,25 @@ final class NoiseEncryptionService {
// `maxPrivateFilePlaintextSize` already subtracts the cipher's fixed // `maxPrivateFilePlaintextSize` already subtracts the cipher's fixed
// nonce/tag overhead, so the result is bounded without a second copy. // 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) return try sessionManager.encrypt(data, for: peerID)
} }
/// Decrypt data from a specific peer /// Decrypt data from a specific peer
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data { 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. // Standard transport ciphertext has 20 bytes of nonce/tag overhead.
// A larger candidate is admitted only up to the framed-file ceiling; // A larger candidate is admitted only up to the framed-file ceiling;
// after authenticated decryption it must prove it is `.privateFile`. // after authenticated decryption it must prove it is `.privateFile`.
@@ -775,14 +809,14 @@ final class NoiseEncryptionService {
throw NoiseEncryptionError.sessionNotEstablished throw NoiseEncryptionError.sessionNotEstablished
} }
let decrypted = try sessionManager.decrypt(data, from: peerID) let result = try sessionManager.decryptWithSessionGeneration(data, from: peerID)
if !isStandardCiphertext { if !isStandardCiphertext {
guard NoisePayloadType.isPrivateFile(rawValue: decrypted.first), guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
NoiseSecurityValidator.validatePrivateFileMessageSize(decrypted) else { NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
throw NoiseSecurityError.messageTooLarge throw NoiseSecurityError.messageTooLarge
} }
} }
return decrypted return result
} }
// MARK: - Peer Management // 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<Result>(
for peerID: PeerID,
expected: UUID,
_ body: () -> Result
) -> Result? {
sessionManager.withCurrentSessionGeneration(
for: peerID,
expected: expected,
body
)
}
func clearEphemeralStateForPanic() { func clearEphemeralStateForPanic() {
sessionManager.removeAllSessions() sessionManager.removeAllSessions()
serviceQueue.sync(flags: .barrier) { serviceQueue.sync(flags: .barrier) {
@@ -816,7 +869,11 @@ final class NoiseEncryptionService {
// MARK: - Private Helpers // 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 // Calculate fingerprint
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint() let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
@@ -831,6 +888,9 @@ final class NoiseEncryptionService {
// Notify all handlers about authentication // Notify all handlers about authentication
serviceQueue.async { [weak self] in serviceQueue.async { [weak self] in
self?.onPeerAuthenticatedWithGenerationHandlers.forEach { handler in
handler(peerID, fingerprint, sessionGeneration)
}
self?.onPeerAuthenticatedHandlers.forEach { handler in self?.onPeerAuthenticatedHandlers.forEach { handler in
handler(peerID, fingerprint) handler(peerID, fingerprint)
} }
+155 -3
View File
@@ -542,8 +542,12 @@ struct NoiseCoverageTests {
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain) let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain) let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:) aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(
bobManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:) peerID:remoteKey:sessionGeneration:
)
bobManager.onSessionEstablished = establishedRecorder.recordEstablished(
peerID:remoteKey:sessionGeneration:
)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
@@ -658,6 +662,95 @@ struct NoiseCoverageTests {
#expect(rekeyedSession.getState() == .handshaking) #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<Data?>()
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") @Test("Secure noise sessions enforce limits and renegotiation thresholds")
func secureNoiseSessionsEnforceLimitsAndThresholds() throws { func secureNoiseSessionsEnforceLimitsAndThresholds() throws {
let initiator = SecureNoiseSession( let initiator = SecureNoiseSession(
@@ -852,7 +945,11 @@ private final class SessionCallbackRecorder: @unchecked Sendable {
return establishedEntries.map(\.0) return establishedEntries.map(\.0)
} }
func recordEstablished(peerID: PeerID, remoteKey: Curve25519.KeyAgreement.PublicKey) { func recordEstablished(
peerID: PeerID,
remoteKey: Curve25519.KeyAgreement.PublicKey,
sessionGeneration _: UUID
) {
lock.lock() lock.lock()
establishedEntries.append((peerID, remoteKey.rawRepresentation)) establishedEntries.append((peerID, remoteKey.rawRepresentation))
lock.unlock() lock.unlock()
@@ -874,3 +971,58 @@ private final class FailingNoiseSession: NoiseSession {
throw Error.synthetic 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<Value>: @unchecked Sendable {
private let lock = NSLock()
private let completed = DispatchSemaphore(value: 0)
private var storedResult: Result<Value, Error>?
func capture(_ operation: () throws -> Value) {
let result = Result(catching: operation)
lock.lock()
storedResult = result
lock.unlock()
completed.signal()
}
func wait(timeout: TimeInterval) -> Result<Value, Error>? {
guard completed.wait(timeout: .now() + timeout) == .success else { return nil }
lock.lock()
defer { lock.unlock() }
return storedResult
}
}
@@ -9,6 +9,7 @@ struct BLENoisePacketHandlerTests {
private final class Recorder { private final class Recorder {
var handshakeResult: Result<Data?, Error> = .success(nil) var handshakeResult: Result<Data?, Error> = .success(nil)
var hasSession = false var hasSession = false
let sessionGeneration = UUID()
var decryptResult: Result<Data, Error> = .success(Data()) var decryptResult: Result<Data, Error> = .success(Data())
var processedHandshakes: [(peerID: PeerID, message: Data)] = [] var processedHandshakes: [(peerID: PeerID, message: Data)] = []
@@ -18,7 +19,7 @@ struct BLENoisePacketHandlerTests {
var lastSeenUpdates: [PeerID] = [] var lastSeenUpdates: [PeerID] = []
var decryptCalls: [(payload: Data, peerID: PeerID)] = [] var decryptCalls: [(payload: Data, peerID: PeerID)] = []
var clearedSessions: [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)] = [] var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = []
/// Ordered side-effect log to assert recovery sequencing. /// Ordered side-effect log to assert recovery sequencing.
var events: [String] = [] var events: [String] = []
@@ -57,14 +58,17 @@ struct BLENoisePacketHandlerTests {
}, },
decrypt: { payload, peerID in decrypt: { payload, peerID in
recorder.decryptCalls.append((payload, peerID)) recorder.decryptCalls.append((payload, peerID))
return try recorder.decryptResult.get() return BLENoiseDecryptionResult(
plaintext: try recorder.decryptResult.get(),
sessionGeneration: recorder.sessionGeneration
)
}, },
clearSession: { peerID in clearSession: { peerID in
recorder.clearedSessions.append(peerID) recorder.clearedSessions.append(peerID)
recorder.events.append("clearSession") recorder.events.append("clearSession")
}, },
handleAuthenticatedPeerState: { peerID, payload in handleAuthenticatedPeerState: { peerID, payload, generation in
recorder.authenticatedPeerStates.append((peerID, payload)) recorder.authenticatedPeerStates.append((peerID, payload, generation))
}, },
deliverNoisePayload: { peerID, type, payload, timestamp in deliverNoisePayload: { peerID, type, payload, timestamp in
recorder.deliveries.append((peerID, type, payload, timestamp)) recorder.deliveries.append((peerID, type, payload, timestamp))
@@ -240,6 +244,7 @@ struct BLENoisePacketHandlerTests {
#expect(recorder.authenticatedPeerStates.count == 1) #expect(recorder.authenticatedPeerStates.count == 1)
#expect(recorder.authenticatedPeerStates.first?.peerID == remotePeerID) #expect(recorder.authenticatedPeerStates.first?.peerID == remotePeerID)
#expect(recorder.authenticatedPeerStates.first?.payload == Data([0x01, 0x02, 0x03])) #expect(recorder.authenticatedPeerStates.first?.payload == Data([0x01, 0x02, 0x03]))
#expect(recorder.authenticatedPeerStates.first?.generation == recorder.sessionGeneration)
#expect(recorder.deliveries.isEmpty) #expect(recorder.deliveries.isEmpty)
} }
@@ -96,13 +96,22 @@ struct NoiseEncryptionServiceTests {
let recorder = AuthenticationRecorder() let recorder = AuthenticationRecorder()
#expect(alice.onPeerAuthenticated == nil) #expect(alice.onPeerAuthenticated == nil)
#expect(bob.onPeerAuthenticatedWithGeneration == nil)
alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:)) alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:) bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:)
bob.onPeerAuthenticatedWithGeneration = recorder.record(
peerID:fingerprint:sessionGeneration:
)
try establishSessions(alice: alice, bob: bob) try establishSessions(alice: alice, bob: bob)
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0) let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0)
#expect(authenticated) #expect(authenticated)
let generationAuthenticated = await TestHelpers.waitUntil(
{ recorder.generationCount >= 1 },
timeout: 5.0
)
#expect(generationAuthenticated)
#expect(alice.hasEstablishedSession(with: bobPeerID)) #expect(alice.hasEstablishedSession(with: bobPeerID))
#expect(bob.hasEstablishedSession(with: alicePeerID)) #expect(bob.hasEstablishedSession(with: alicePeerID))
#expect(alice.hasSession(with: bobPeerID)) #expect(alice.hasSession(with: bobPeerID))
@@ -111,6 +120,7 @@ struct NoiseEncryptionServiceTests {
#expect(bob.getPeerPublicKeyData(alicePeerID)?.count == 32) #expect(bob.getPeerPublicKeyData(alicePeerID)?.count == 32)
#expect(alice.getPeerFingerprint(bobPeerID) != nil) #expect(alice.getPeerFingerprint(bobPeerID) != nil)
#expect(bob.getPeerFingerprint(alicePeerID) != nil) #expect(bob.getPeerFingerprint(alicePeerID) != nil)
#expect(recorder.generation(for: alicePeerID) == bob.sessionGeneration(for: alicePeerID))
let plaintext = Data("secret payload".utf8) let plaintext = Data("secret payload".utf8)
let ciphertext = try alice.encrypt(plaintext, for: bobPeerID) let ciphertext = try alice.encrypt(plaintext, for: bobPeerID)
@@ -244,6 +254,17 @@ struct NoiseEncryptionServiceTests {
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
try establishSessions(alice: alice, bob: bob) 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 emittedPeerID: PeerID?
var emittedMessage: Data? var emittedMessage: Data?
@@ -254,6 +275,17 @@ struct NoiseEncryptionServiceTests {
try alice._test_initiateAutomaticRekey(for: bobPeerID) try alice._test_initiateAutomaticRekey(for: bobPeerID)
#expect(emittedPeerID == 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) let message1 = try #require(emittedMessage)
#expect(!message1.isEmpty) #expect(!message1.isEmpty)
#expect(alice.hasSession(with: bobPeerID)) #expect(alice.hasSession(with: bobPeerID))
@@ -269,6 +301,7 @@ struct NoiseEncryptionServiceTests {
#expect(alice.hasEstablishedSession(with: bobPeerID)) #expect(alice.hasEstablishedSession(with: bobPeerID))
#expect(bob.hasEstablishedSession(with: alicePeerID)) #expect(bob.hasEstablishedSession(with: alicePeerID))
#expect(alice.sessionGeneration(for: bobPeerID) != originalGeneration)
} }
@Test("Large private-file payloads use the bounded Noise extension") @Test("Large private-file payloads use the bounded Noise extension")
@@ -411,6 +444,7 @@ struct NoiseEncryptionServiceTests {
private final class AuthenticationRecorder: @unchecked Sendable { private final class AuthenticationRecorder: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private var entries: [(PeerID, String)] = [] private var entries: [(PeerID, String)] = []
private var generationEntries: [(PeerID, UUID)] = []
var count: Int { var count: Int {
lock.lock() lock.lock()
@@ -418,9 +452,27 @@ private final class AuthenticationRecorder: @unchecked Sendable {
return entries.count return entries.count
} }
var generationCount: Int {
lock.lock()
defer { lock.unlock() }
return generationEntries.count
}
func record(peerID: PeerID, fingerprint: String) { func record(peerID: PeerID, fingerprint: String) {
lock.lock() lock.lock()
entries.append((peerID, fingerprint)) entries.append((peerID, fingerprint))
lock.unlock() 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
}
} }
+4 -4
View File
@@ -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 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 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 without a stable Noise key from a verified registry entry cannot use the
fallback. Keep fallback. Keep it only for the mixed-version migration, and remove it only
the fallback only for the mixed-version migration and remove it after after minimum-supported Android and iOS releases emit authenticated bit-8
supported Android and iOS releases advertise `privateMedia`. Never replace it `0x21` state and the legacy population has aged out. Never replace it with an
with an unsigned fallback, persist blanket consent, or send both forms. unsigned fallback, persist blanket consent, or send both forms.
Incoming clients accept all three migration-era shapes: Incoming clients accept all three migration-era shapes: