From 6063d42500eff9d79e2d9cb278726d82c9c9b61e Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 12 Jul 2026 11:19:09 -0400 Subject: [PATCH] Authenticate private media capabilities in Noise --- bitchat/Identity/IdentityModels.swift | 14 +- .../Identity/SecureIdentityStateManager.swift | 35 + bitchat/Noise/NoiseSessionManager.swift | 4 +- bitchat/Protocols/BitchatProtocol.swift | 6 + bitchat/Protocols/Packets.swift | 83 +++ bitchat/Services/BLE/BLEAnnounceHandler.swift | 15 +- .../BLE/BLEAnnounceHandlingPolicy.swift | 10 +- .../Services/BLE/BLENoisePacketHandler.swift | 8 + .../Services/BLE/BLENoisePayloadFactory.swift | 5 + bitchat/Services/BLE/BLEPeerRegistry.swift | 8 + bitchat/Services/BLE/BLEService.swift | 653 +++++++++++++++--- bitchat/Services/NoiseEncryptionService.swift | 26 +- bitchat/Services/Transport.swift | 18 + bitchat/Services/TransportConfig.swift | 6 + .../ChatMediaTransferCoordinator.swift | 55 +- .../ChatTransportEventCoordinator.swift | 7 +- bitchat/ViewModels/NostrInboundPipeline.swift | 6 +- ...MediaTransferCoordinatorContextTests.swift | 33 + .../EndToEnd/PrivateMediaEndToEndTests.swift | 326 ++++++++- bitchatTests/Mocks/MockIdentityManager.swift | 10 + bitchatTests/Mocks/MockTransport.swift | 8 + bitchatTests/Noise/NoiseCoverageTests.swift | 3 +- bitchatTests/Protocols/PacketsTests.swift | 33 + .../Services/BLEAnnounceHandlerTests.swift | 2 + .../BLEAnnounceHandlingPolicyTests.swift | 17 + .../Services/BLENoisePacketHandlerTests.swift | 22 + .../BLENoisePayloadFactoryTests.swift | 12 + .../NoiseEncryptionServiceTests.swift | 34 + .../SecureIdentityStateManagerTests.swift | 25 + .../Services/UnifiedPeerServiceTests.swift | 2 + docs/PRIVATE-MEDIA-MIGRATION.md | 59 +- 31 files changed, 1412 insertions(+), 133 deletions(-) diff --git a/bitchat/Identity/IdentityModels.swift b/bitchat/Identity/IdentityModels.swift index f8c4e12b..4628a296 100644 --- a/bitchat/Identity/IdentityModels.swift +++ b/bitchat/Identity/IdentityModels.swift @@ -190,11 +190,17 @@ struct IdentityCache: Codable { // entries verified before this field exists sort as oldest) var verifiedAt: [String: Date]? = nil - // Stable Noise fingerprints that have advertised encrypted private media - // in a signature-verified announce. Optional for decoding caches written - // before this migration. Entries are monotonic until a panic wipe so an - // old/replayed announce cannot silently downgrade a peer to clear media. + // Stable Noise fingerprints that proved encrypted private-media support + // inside an authenticated Noise session. Optional for decoding caches + // written before this migration. Entries are monotonic until a panic wipe + // so an old/replayed announce cannot silently downgrade a peer. var privateMediaCapableFingerprints: Set? = nil + + // Noise-fingerprint -> Ed25519 announcement key, learned only from the + // authenticated peer-state payload. This prevents a self-signed announce + // containing a copied public Noise key from replacing a previously bound + // public-message signing identity. Optional for old cache compatibility. + var authenticatedSigningKeysByFingerprint: [String: Data]? = nil } // diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index 2eaed338..456414e7 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -141,6 +141,10 @@ protocol SecureIdentityStateManagerProtocol { func signingPublicKey(forFingerprint fingerprint: String) -> Data? func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] + // MARK: Noise-authenticated announcement identity + func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) + func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? + // MARK: Private-media downgrade protection func markPrivateMediaCapable(fingerprint: String) func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool @@ -405,6 +409,37 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { cache.privateMediaCapableFingerprints?.contains(fingerprint) == true } } + + // MARK: - Noise-authenticated announcement identity + + func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) { + guard signingPublicKey.count == AuthenticatedPeerStatePacket.signingPublicKeyLength, + !fingerprint.isEmpty else { return } + let bindAndPersist = { + var bindings = self.cache.authenticatedSigningKeysByFingerprint ?? [:] + let bindingChanged = bindings[fingerprint] != signingPublicKey + bindings[fingerprint] = signingPublicKey + self.cache.authenticatedSigningKeysByFingerprint = bindings + if var cryptoIdentity = self.cryptographicIdentities[fingerprint] { + cryptoIdentity.signingPublicKey = signingPublicKey + self.cryptographicIdentities[fingerprint] = cryptoIdentity + } + guard bindingChanged else { return } + self.saveIdentityCache() + } + if DispatchQueue.getSpecific(key: queueSpecificKey) != nil { + bindAndPersist() + } else { + queue.sync(flags: .barrier, execute: bindAndPersist) + } + } + + func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? { + guard !fingerprint.isEmpty else { return nil } + return queue.sync { + cache.authenticatedSigningKeysByFingerprint?[fingerprint] + } + } func updateSocialIdentity(_ identity: SocialIdentity) { queue.async(flags: .barrier) { diff --git a/bitchat/Noise/NoiseSessionManager.swift b/bitchat/Noise/NoiseSessionManager.swift index 7c14a991..26608238 100644 --- a/bitchat/Noise/NoiseSessionManager.swift +++ b/bitchat/Noise/NoiseSessionManager.swift @@ -272,11 +272,11 @@ final class NoiseSessionManager { } } - func initiateRekey(for peerID: PeerID) throws { + func initiateRekey(for peerID: PeerID) throws -> Data { // Remove old session removeSession(for: peerID) // Initiate new handshake - _ = try initiateHandshake(with: peerID) + return try initiateHandshake(with: peerID) } } diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 04a8b24d..b38ea251 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -83,6 +83,11 @@ enum NoisePayloadType: UInt8 { // Android client. The complete BitchatFilePacket is encrypted inside // Noise before the outer noiseEncrypted packet is fragmented. case privateFile = 0x20 + // Versioned peer state authenticated by the surrounding Noise session. + // This is intentionally distinct from the public announce: announce + // capabilities are discovery hints, while this payload proves possession + // of the advertised Noise static key before downgrade state is pinned. + case authenticatedPeerState = 0x21 // Verification (QR-based OOB binding) case verifyChallenge = 0x10 // Verification challenge case verifyResponse = 0x11 // Verification response @@ -112,6 +117,7 @@ enum NoisePayloadType: UInt8 { case .groupKeyUpdate: return "groupKeyUpdate" case .voiceFrame: return "voiceFrame" case .privateFile: return "privateFile" + case .authenticatedPeerState: return "authenticatedPeerState" case .verifyChallenge: return "verifyChallenge" case .verifyResponse: return "verifyResponse" case .vouch: return "vouch" diff --git a/bitchat/Protocols/Packets.swift b/bitchat/Protocols/Packets.swift index 1f691dd4..d45f7323 100644 --- a/bitchat/Protocols/Packets.swift +++ b/bitchat/Protocols/Packets.swift @@ -156,6 +156,89 @@ struct AnnouncementPacket { } } +/// State that is authoritative only because it is carried inside an +/// established Noise session. The public announce remains useful for +/// discovery, but its self-signature cannot prove possession of the copied +/// Noise public key it contains. +/// +/// Wire format (v1): +/// `[version=0x01][type][length][value]...` +/// - TLV `0x01`: canonical minimal little-endian `PeerCapabilities` +/// - TLV `0x02`: 32-byte Ed25519 signing public key +/// +/// Unknown TLVs are skipped for forward compatibility. Unknown versions, +/// duplicates, non-canonical capability fields, and malformed lengths are +/// rejected without changing authenticated state. +struct AuthenticatedPeerStatePacket: Equatable { + static let currentVersion: UInt8 = 1 + static let signingPublicKeyLength = 32 + + let capabilities: PeerCapabilities + let signingPublicKey: Data + + private enum TLVType: UInt8 { + case capabilities = 0x01 + case signingPublicKey = 0x02 + } + + func encode() -> Data? { + guard signingPublicKey.count == Self.signingPublicKeyLength else { return nil } + let capabilityBytes = capabilities.encoded() + guard !capabilityBytes.isEmpty, capabilityBytes.count <= 8 else { return nil } + + var data = Data([Self.currentVersion]) + data.append(TLVType.capabilities.rawValue) + data.append(UInt8(capabilityBytes.count)) + data.append(capabilityBytes) + data.append(TLVType.signingPublicKey.rawValue) + data.append(UInt8(signingPublicKey.count)) + data.append(signingPublicKey) + return data + } + + static func decode(from data: Data) -> AuthenticatedPeerStatePacket? { + guard data.first == Self.currentVersion else { return nil } + + var offset = 1 + var capabilities: PeerCapabilities? + var signingPublicKey: Data? + + while offset < data.count { + guard offset + 2 <= data.count else { return nil } + let typeRaw = data[offset] + let length = Int(data[offset + 1]) + offset += 2 + guard offset + length <= data.count else { return nil } + let value = Data(data[offset..<(offset + length)]) + offset += length + + guard let type = TLVType(rawValue: typeRaw) else { + continue + } + switch type { + case .capabilities: + guard capabilities == nil, + !value.isEmpty, + value.count <= 8 else { return nil } + let decoded = PeerCapabilities(encoded: value) + guard decoded.encoded() == value else { return nil } + capabilities = decoded + + case .signingPublicKey: + guard signingPublicKey == nil, + value.count == Self.signingPublicKeyLength else { return nil } + signingPublicKey = value + } + } + + guard let capabilities, let signingPublicKey else { return nil } + return AuthenticatedPeerStatePacket( + capabilities: capabilities, + signingPublicKey: signingPublicKey + ) + } +} + struct PrivateMessagePacket { let messageID: String let content: String diff --git a/bitchat/Services/BLE/BLEAnnounceHandler.swift b/bitchat/Services/BLE/BLEAnnounceHandler.swift index 306831ce..cbcd555c 100644 --- a/bitchat/Services/BLE/BLEAnnounceHandler.swift +++ b/bitchat/Services/BLE/BLEAnnounceHandler.swift @@ -16,6 +16,9 @@ struct BLEAnnounceHandlerEnvironment { let now: () -> Date /// Noise public key already recorded for the peer, if any (registry read). let existingNoisePublicKey: (PeerID) -> Data? + /// Ed25519 key previously bound to this Noise identity by an authenticated + /// peer-state payload, if any (persistent identity-state read). + let authenticatedSigningPublicKey: (_ noisePublicKey: Data) -> Data? /// Verifies the packet signature against the announced signing key. let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool /// Direct link state for the peer (BLE-queue read). @@ -130,11 +133,21 @@ final class BLEAnnounceHandler { hasSignature: hasSignature, signatureValid: signatureValid, existingNoisePublicKey: existingNoisePublicKey, - announcedNoisePublicKey: announcement.noisePublicKey + announcedNoisePublicKey: announcement.noisePublicKey, + authenticatedSigningPublicKey: env.authenticatedSigningPublicKey( + announcement.noisePublicKey + ), + announcedSigningPublicKey: announcement.signingPublicKey ) if case .reject(.keyMismatch) = trustDecision { SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security) } + if case .reject(.authenticatedSigningKeyMismatch) = trustDecision { + SecureLogger.warning( + "⚠️ Announce signing-key replacement rejected for Noise-authenticated peer \(peerID.id.prefix(8))…", + category: .security + ) + } let verifiedAnnounce = trustDecision.isVerified var isNewPeer = false diff --git a/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift b/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift index 9e81d9ce..c8219b23 100644 --- a/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift +++ b/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift @@ -56,6 +56,7 @@ enum BLEAnnounceTrustRejection: Equatable { case missingSignature case invalidSignature case keyMismatch + case authenticatedSigningKeyMismatch } enum BLEAnnounceTrustDecision: Equatable { @@ -72,12 +73,19 @@ enum BLEAnnounceTrustPolicy { hasSignature: Bool, signatureValid: Bool, existingNoisePublicKey: Data?, - announcedNoisePublicKey: Data + announcedNoisePublicKey: Data, + authenticatedSigningPublicKey: Data? = nil, + announcedSigningPublicKey: Data? = nil ) -> BLEAnnounceTrustDecision { if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey { return .reject(.keyMismatch) } + if let authenticatedSigningPublicKey, + announcedSigningPublicKey != authenticatedSigningPublicKey { + return .reject(.authenticatedSigningKeyMismatch) + } + guard hasSignature else { return .reject(.missingSignature) } diff --git a/bitchat/Services/BLE/BLENoisePacketHandler.swift b/bitchat/Services/BLE/BLENoisePacketHandler.swift index b71a5823..e874600a 100644 --- a/bitchat/Services/BLE/BLENoisePacketHandler.swift +++ b/bitchat/Services/BLE/BLENoisePacketHandler.swift @@ -30,6 +30,9 @@ struct BLENoisePacketHandlerEnvironment { let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> Data /// 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 /// Delivers `.noisePayloadReceived` to the UI as one main-actor hop. let deliverNoisePayload: ( _ peerID: PeerID, @@ -128,6 +131,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)) + return + } + let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts) } catch NoiseEncryptionError.sessionNotEstablished { diff --git a/bitchat/Services/BLE/BLENoisePayloadFactory.swift b/bitchat/Services/BLE/BLENoisePayloadFactory.swift index aa50b910..a414487b 100644 --- a/bitchat/Services/BLE/BLENoisePayloadFactory.swift +++ b/bitchat/Services/BLE/BLENoisePayloadFactory.swift @@ -22,6 +22,11 @@ enum BLENoisePayloadFactory { return typedPayload(.privateFile, payload: payload) } + static func authenticatedPeerState(_ state: AuthenticatedPeerStatePacket) -> Data? { + guard let payload = state.encode() else { return nil } + return typedPayload(.authenticatedPeerState, payload: payload) + } + static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data { var typed = Data([type.rawValue]) typed.append(payload) diff --git a/bitchat/Services/BLE/BLEPeerRegistry.swift b/bitchat/Services/BLE/BLEPeerRegistry.swift index c23f842f..dff18a2d 100644 --- a/bitchat/Services/BLE/BLEPeerRegistry.swift +++ b/bitchat/Services/BLE/BLEPeerRegistry.swift @@ -181,6 +181,14 @@ struct BLEPeerRegistry { peers[peerID] = peer } + /// Replaces the announcement signing key only after the surrounding Noise + /// session proved possession of this peer's static key. + mutating func bindAuthenticatedSigningPublicKey(_ key: Data, for peerID: PeerID) { + guard var peer = peers[peerID.toShort()] else { return } + peer.signingPublicKey = key + peers[peer.peerID] = peer + } + mutating func upsertVerifiedAnnounce( peerID: PeerID, nickname: String, diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 2160867c..2268c7b4 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -176,6 +176,36 @@ private final class BLEPrivateMediaTransferAdmissionRegistry { } } +private struct BLEAuthenticatedPeerStateObservation { + let fingerprint: String + let sessionGeneration: UUID + let capabilities: PeerCapabilities +} + +private struct BLEPrivateMediaProofTimeoutMarker { + let fingerprint: String + let sessionGeneration: UUID? +} + +private struct BLEPrivateMediaProofWatchdog { + let fingerprint: String + let sessionGeneration: UUID + let timeoutNonce: UUID +} + +private struct BLEPendingPrivateMediaPolicyResolution { + let fingerprint: String + var sessionGeneration: UUID? + var timeoutNonce: UUID + var completions: [UUID: @MainActor (PrivateMediaSendPolicy) -> Void] +} + +private struct BLEAuthenticatedPeerStateSendProgress { + let sessionGeneration: UUID + var sentInitial = false + var sentEcho = false +} + /// BLEService — Bluetooth Mesh Transport /// - Emits events exclusively via `BitchatDelegate` for UI. /// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`). @@ -307,6 +337,15 @@ final class BLEService: NSObject { private lazy var privateMediaTransferAdmissions = BLEPrivateMediaTransferAdmissionRegistry { [weak self] transferId in self?.handlePrivateMediaAdmissionExpiry(transferId) } + // All six maps below are protected by `collectionsQueue`. A fresh Noise + // authentication rotates the generation UUID, so stale proof timers and + // proof packets cannot classify a replacement session. + private var privateMediaSessionGenerations: [PeerID: UUID] = [:] + private var authenticatedPeerStates: [PeerID: BLEAuthenticatedPeerStateObservation] = [:] + private var privateMediaProofTimeoutMarkers: [PeerID: BLEPrivateMediaProofTimeoutMarker] = [:] + private var privateMediaProofWatchdogs: [PeerID: BLEPrivateMediaProofWatchdog] = [:] + private var pendingPrivateMediaPolicyResolutions: [PeerID: BLEPendingPrivateMediaPolicyResolution] = [:] + private var authenticatedPeerStateSendProgress: [PeerID: BLEAuthenticatedPeerStateSendProgress] = [:] private let incomingFileStore: BLEIncomingFileStore // Simple announce throttling @@ -599,7 +638,7 @@ final class BLEService: NSObject { pendingNoiseSessionQueues.removeAll() } - let cancelledTransfers = collectionsQueue.sync(flags: .barrier) { + let panicReset = collectionsQueue.sync(flags: .barrier) { pendingPeripheralWrites.removeAll() pendingNotifications.removeAll() let transfers = outboundFragmentTransfers.removeAll() @@ -608,15 +647,25 @@ final class BLEService: NSObject { ingressLinks.removeAll() recentTrafficTracker.removeAll() scheduledRelays.cancelAll() + let policyCompletions = pendingPrivateMediaPolicyResolutions.values.flatMap { + Array($0.completions.values) + } + pendingPrivateMediaPolicyResolutions.removeAll() + privateMediaSessionGenerations.removeAll() + authenticatedPeerStates.removeAll() + privateMediaProofTimeoutMarkers.removeAll() + privateMediaProofWatchdogs.removeAll() + authenticatedPeerStateSendProgress.removeAll() // Let the post-panic identity publish its fresh bundle promptly. lastPrekeyBundleSentAt = nil - return transfers + return (transfers, policyCompletions) } - for entry in cancelledTransfers { + for entry in panicReset.0 { entry.workItems.forEach { $0.cancel() } TransferProgressManager.shared.cancel(id: entry.id) } + completePrivateMediaPolicyResolution(panicReset.1, with: .blockedDowngrade) bleQueue.sync { pendingWriteBuffers.removeAll() @@ -913,64 +962,212 @@ final class BLEService: NSObject { func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { let state: ( capabilities: PeerCapabilities, - fingerprint: String? + fingerprint: String?, + sessionGeneration: UUID?, + authenticatedState: BLEAuthenticatedPeerStateObservation?, + timedOut: BLEPrivateMediaProofTimeoutMarker? ) = collectionsQueue.sync { - let info = peerRegistry.info(for: peerID.toShort()) + let normalizedPeerID = peerID.toShort() + let info = peerRegistry.info(for: normalizedPeerID) return ( info?.capabilities ?? [], - info?.noisePublicKey?.sha256Fingerprint() + info?.noisePublicKey?.sha256Fingerprint(), + privateMediaSessionGenerations[normalizedPeerID], + authenticatedPeerStates[normalizedPeerID], + privateMediaProofTimeoutMarkers[normalizedPeerID] ) } - if state.capabilities.contains(.privateMedia) { - return .encrypted - } - guard let fingerprint = state.fingerprint else { // A raw fallback must be bound to the stable Noise key from a // verified registry entry; a routing ID alone can rotate or be - // spoofed. Session authentication is required for pinning, below, - // but old clients may need the consented fallback before a session. + // spoofed. Without that key neither proof nor safe migration state + // can be attributed. return .blockedDowngrade } - // During the mixed-version migration, an unpinned peer is legacy - // eligible whether the capabilities TLV was absent or explicitly - // omitted this bit. Only a capability observation bound to a matching - // authenticated Noise session may make the no-bit state a downgrade. - return identityManager.hasObservedPrivateMediaCapability(fingerprint: fingerprint) - ? .blockedDowngrade - : .legacyRequiresConsent + + let wasPreviouslyCapable = identityManager.hasObservedPrivateMediaCapability( + fingerprint: fingerprint + ) + + if let authenticated = state.authenticatedState, + authenticated.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, + authenticated.sessionGeneration == state.sessionGeneration { + if authenticated.capabilities.contains(.privateMedia) { + return .encrypted + } + return wasPreviouslyCapable ? .blockedDowngrade : .legacyRequiresConsent + } + + if let timedOut = state.timedOut, + timedOut.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, + timedOut.sessionGeneration == state.sessionGeneration { + return wasPreviouslyCapable ? .blockedDowngrade : .legacyRequiresConsent + } + + // The announce bit is a discovery hint only. It can trigger a Noise + // handshake, but it cannot select encrypted media or create a durable + // pin because anyone can copy a public Noise key into a self-signed + // announce. A prior pin also re-confirms on each replacement session + // so an authenticated no-bit response becomes a visible downgrade. + if state.capabilities.contains(.privateMedia) || wasPreviouslyCapable { + return .awaitingCapabilityProof + } + + // Old clients that never advertised the bit remain eligible only for + // the explicit, invocation-scoped legacy consent path. + return .legacyRequiresConsent } - /// Pins private-media support only when the capability-bearing registry - /// identity and a completed Noise session authenticate the same static - /// key. A signed announce alone does not prove possession of that Noise - /// key and must never create a downgrade pin. - private func reconcilePrivateMediaCapabilityPin( - for peerID: PeerID, - authenticatedFingerprint: String? = nil + func resolvePrivateMediaSendPolicy( + to peerID: PeerID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void ) { let normalizedPeerID = peerID.toShort() - let advertisedFingerprint: String? = collectionsQueue.sync { - guard let info = peerRegistry.info(for: normalizedPeerID), - info.capabilities.contains(.privateMedia) else { return nil } - return info.noisePublicKey?.sha256Fingerprint() - } - guard let advertisedFingerprint else { return } + messageQueue.async { [weak self] in + guard let self else { return } + let immediate = self.privateMediaSendPolicy(to: normalizedPeerID) + guard immediate == .awaitingCapabilityProof else { + self.completePrivateMediaPolicyResolution([completion], with: immediate) + return + } - let sessionFingerprint = authenticatedFingerprint - ?? noiseService.getPeerFingerprint(normalizedPeerID) - guard let sessionFingerprint, - sessionFingerprint.caseInsensitiveCompare(advertisedFingerprint) == .orderedSame else { - if authenticatedFingerprint != nil { - SecureLogger.warning( - "Refusing private-media capability pin for \(normalizedPeerID.id.prefix(8))…: authenticated Noise key does not match verified announce", - category: .security + let fingerprint: String? = self.collectionsQueue.sync { + self.peerRegistry.info(for: normalizedPeerID)? + .noisePublicKey? + .sha256Fingerprint() + } + guard let fingerprint else { + self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade) + return + } + + let requestID = UUID() + let registration = self.collectionsQueue.sync(flags: .barrier) { + () -> (registered: Bool, shouldSchedule: Bool, nonce: UUID, generation: UUID?) in + let generation = self.privateMediaSessionGenerations[normalizedPeerID] + if var pending = self.pendingPrivateMediaPolicyResolutions[normalizedPeerID] { + guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, + pending.completions.count + < TransportConfig.privateMediaCapabilityProofWaitersPerPeerCap else { + return (false, false, UUID(), generation) + } + pending.completions[requestID] = completion + self.pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending + return (true, false, pending.timeoutNonce, pending.sessionGeneration) + } + + guard self.pendingPrivateMediaPolicyResolutions.count + < TransportConfig.privateMediaCapabilityProofPendingPeerCap else { + return (false, false, UUID(), generation) + } + let currentWatchdog = self.privateMediaProofWatchdogs[normalizedPeerID] + let reusesWatchdog = currentWatchdog?.fingerprint + .caseInsensitiveCompare(fingerprint) == .orderedSame + && currentWatchdog?.sessionGeneration == generation + let nonce: UUID + if reusesWatchdog, let currentWatchdog { + nonce = currentWatchdog.timeoutNonce + } else { + nonce = UUID() + } + self.pendingPrivateMediaPolicyResolutions[normalizedPeerID] = + BLEPendingPrivateMediaPolicyResolution( + fingerprint: fingerprint, + sessionGeneration: generation, + timeoutNonce: nonce, + completions: [requestID: completion] + ) + return (true, !reusesWatchdog, nonce, generation) + } + + guard registration.registered else { + self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade) + return + } + if registration.shouldSchedule { + self.schedulePrivateMediaProofTimeout( + for: normalizedPeerID, + fingerprint: fingerprint, + sessionGeneration: registration.generation, + nonce: registration.nonce ) } - return + + if !self.noiseService.hasEstablishedSession(with: normalizedPeerID) { + self.initiateNoiseHandshake(with: normalizedPeerID) + } } - identityManager.markPrivateMediaCapable(fingerprint: advertisedFingerprint) + } + + private func completePrivateMediaPolicyResolution( + _ completions: [@MainActor (PrivateMediaSendPolicy) -> Void], + with policy: PrivateMediaSendPolicy + ) { + guard !completions.isEmpty else { return } + Task { @MainActor in + completions.forEach { $0(policy) } + } + } + + private func schedulePrivateMediaProofTimeout( + for peerID: PeerID, + fingerprint: String, + sessionGeneration: UUID?, + nonce: UUID + ) { + messageQueue.asyncAfter( + deadline: .now() + TransportConfig.privateMediaCapabilityProofTimeoutSeconds + ) { [weak self] in + self?.handlePrivateMediaProofTimeout( + for: peerID, + fingerprint: fingerprint, + sessionGeneration: sessionGeneration, + nonce: nonce + ) + } + } + + private func handlePrivateMediaProofTimeout( + for peerID: PeerID, + fingerprint: String, + sessionGeneration: UUID?, + nonce: UUID + ) { + let expiration = collectionsQueue.sync(flags: .barrier) { + () -> (expired: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) in + let pending = pendingPrivateMediaPolicyResolutions[peerID] + let pendingMatches = pending?.timeoutNonce == nonce + && pending?.sessionGeneration == sessionGeneration + && pending?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame + let watchdog = privateMediaProofWatchdogs[peerID] + let watchdogMatches = sessionGeneration != nil + && watchdog?.timeoutNonce == nonce + && watchdog?.sessionGeneration == sessionGeneration + && watchdog?.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame + guard pendingMatches || watchdogMatches else { + return (false, []) + } + var completions: [@MainActor (PrivateMediaSendPolicy) -> Void] = [] + if pendingMatches, let pending { + completions = Array(pending.completions.values) + } + if pendingMatches { + pendingPrivateMediaPolicyResolutions.removeValue(forKey: peerID) + } + if watchdogMatches { + privateMediaProofWatchdogs.removeValue(forKey: peerID) + } + privateMediaProofTimeoutMarkers[peerID] = BLEPrivateMediaProofTimeoutMarker( + fingerprint: fingerprint, + sessionGeneration: sessionGeneration + ) + return (true, completions) + } + guard expiration.expired else { return } + let policy = privateMediaSendPolicy(to: peerID) + sendPendingNoisePayloadsAfterHandshake(for: peerID) + completePrivateMediaPolicyResolution(expiration.completions, with: policy) } /// Enables or disables a runtime-advertised capability bit (e.g. the @@ -1254,6 +1451,25 @@ final class BLEService: NSObject { case .encrypted: break + case .awaitingCapabilityProof: + // The UI coordinator resolves this state before calling the + // transport. Keep the transport guard fail-closed for direct + // callers and for a session replacement that races the call. + SecureLogger.warning( + "Private media held pending authenticated capability proof for \(targetID.id.prefix(8))…", + category: .security + ) + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String( + localized: "content.delivery.reason.private_media_capability_unresolved", + defaultValue: "Could not confirm encrypted media support", + comment: "Failure reason when private-media capability negotiation did not resolve" + ) + ) + self.privateMediaTransferAdmissions.finish(transferId) + return + case .legacyRequiresConsent: guard allowLegacyFallback else { SecureLogger.warning( @@ -2207,7 +2423,7 @@ final class BLEService: NSObject { // A valid departure retires transport state too; otherwise // canDeliverSecurely could remain true for a peer we just removed. - noiseService.clearSession(for: peerID) + clearNoiseSession(for: peerID) readLinkState { _ in let departedLinks = noiseAuthenticatedLinkOwners.compactMap { link, owner in owner == peerID ? link : nil @@ -2990,6 +3206,41 @@ extension BLEService { sendPendingNoisePayloadsAfterHandshake(for: peerID) } + func _test_hasPendingPrivateMediaPolicyResolution(for peerID: PeerID) -> Bool { + collectionsQueue.sync { + pendingPrivateMediaPolicyResolutions[peerID.toShort()] != nil + } + } + + func _test_forcePrivateMediaProofTimeout(for peerID: PeerID) { + let normalizedPeerID = peerID.toShort() + let target = collectionsQueue.sync { + () -> (fingerprint: String, generation: UUID?, nonce: UUID)? in + if let watchdog = privateMediaProofWatchdogs[normalizedPeerID] { + return ( + watchdog.fingerprint, + watchdog.sessionGeneration, + watchdog.timeoutNonce + ) + } + if let pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] { + return ( + pending.fingerprint, + pending.sessionGeneration, + pending.timeoutNonce + ) + } + return nil + } + guard let target else { return } + handlePrivateMediaProofTimeout( + for: normalizedPeerID, + fingerprint: target.fingerprint, + sessionGeneration: target.generation, + nonce: target.nonce + ) + } + func _test_privateMediaTransferState( transferId: String ) -> (admissionActive: Bool, pendingNoise: Bool, activeScheduler: Int, pendingScheduler: Int) { @@ -3036,6 +3287,17 @@ extension BLEService { } } + func _test_drainNoiseMessagePipeline() async { + let collectionsQueue = self.collectionsQueue + await withCheckedContinuation { continuation in + messageQueue.async(flags: .barrier) { + collectionsQueue.async(flags: .barrier) { + continuation.resume() + } + } + } + } + /// Builds an authenticated-session packet from an exact typed plaintext. /// Compatibility tests use this to model Android's deployed 0x20 file /// payload and the short-lived 0x09 prerelease payload without exposing a @@ -4023,20 +4285,208 @@ extension BLEService { service.onPeerAuthenticated = { [weak self] peerID, fingerprint in SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))…") self?.messageQueue.async { [weak self] in - self?.reconcilePrivateMediaCapabilityPin( - for: peerID, - authenticatedFingerprint: fingerprint - ) - #if DEBUG - self?._test_onPrivateMediaSessionReconciled?(peerID) - #endif - self?.sendPendingMessagesAfterHandshake(for: peerID) - self?.sendPendingNoisePayloadsAfterHandshake(for: peerID) - } - self?.messageQueue.async { [weak self] in - self?.sendAnnounce(forceSend: true) + self?.handleNoisePeerAuthenticated(peerID: peerID, fingerprint: fingerprint) } } + service.onRekeyHandshakeReady = { [weak self] peerID, message in + self?.messageQueue.async { [weak self] in + guard let self else { return } + self.noteNoiseSessionCleared(for: peerID) + self.broadcastNoiseHandshake(message, to: peerID) + } + } + } + + private func handleNoisePeerAuthenticated(peerID: PeerID, fingerprint: String) { + 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 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), []) + } + + completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade) + schedulePrivateMediaProofTimeout( + for: normalizedPeerID, + fingerprint: transition.watchdog.fingerprint, + sessionGeneration: generation, + nonce: transition.watchdog.nonce + ) + + // `onPeerAuthenticated` can fire while the initiator is returning XX + // message 3. This callback is queued behind the handshake handler, so + // message 3 is broadcast first. Both peers also send one idempotent + // echo after receiving the other's state to recover cross-link races. + sendAuthenticatedPeerState(to: normalizedPeerID, echo: false) + #if DEBUG + _test_onPrivateMediaSessionReconciled?(normalizedPeerID) + #endif + sendPendingMessagesAfterHandshake(for: normalizedPeerID) + sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID) + sendAnnounce(forceSend: true) + } + + private func sendAuthenticatedPeerState(to peerID: PeerID, echo: Bool) { + let normalizedPeerID = peerID.toShort() + let shouldSend = collectionsQueue.sync(flags: .barrier) { + guard let generation = privateMediaSessionGenerations[normalizedPeerID], + var progress = authenticatedPeerStateSendProgress[normalizedPeerID], + progress.sessionGeneration == generation else { return false } + if echo { + guard !progress.sentEcho else { return false } + progress.sentEcho = true + } else { + guard !progress.sentInitial else { return false } + progress.sentInitial = true + } + authenticatedPeerStateSendProgress[normalizedPeerID] = progress + return true + } + guard shouldSend else { return } + + let capabilities = collectionsQueue.sync { + PeerCapabilities.localSupported.union(runtimeCapabilities) + } + let state = AuthenticatedPeerStatePacket( + capabilities: capabilities, + signingPublicKey: noiseService.getSigningPublicKeyData() + ) + guard let payload = BLENoisePayloadFactory.authenticatedPeerState(state) else { + SecureLogger.error("Failed to encode authenticated peer state", category: .security) + return + } + sendNoisePayload(payload, to: normalizedPeerID) + } + + private func handleAuthenticatedPeerState(_ payload: Data, from peerID: PeerID) { + let normalizedPeerID = peerID.toShort() + guard let state = AuthenticatedPeerStatePacket.decode(from: payload) else { + SecureLogger.warning( + "Ignoring malformed authenticated peer state from \(normalizedPeerID.id.prefix(8))…", + category: .security + ) + return + } + guard let fingerprint = noiseService.getPeerFingerprint(normalizedPeerID), + let publicKey = noiseService.getPeerPublicKeyData(normalizedPeerID), + publicKey.sha256Fingerprint().caseInsensitiveCompare(fingerprint) == .orderedSame else { + SecureLogger.warning( + "Ignoring peer state without a matching authenticated Noise identity", + category: .security + ) + return + } + let generation = collectionsQueue.sync { + privateMediaSessionGenerations[normalizedPeerID] + } + guard let generation else { return } + + // 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) + } + + 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) + } + + // 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) + } + + private func noteNoiseSessionCleared(for peerID: PeerID) { + let normalizedPeerID = peerID.toShort() + let reset = collectionsQueue.sync(flags: .barrier) { + () -> (fingerprint: String, nonce: UUID)? in + privateMediaSessionGenerations.removeValue(forKey: normalizedPeerID) + authenticatedPeerStates.removeValue(forKey: normalizedPeerID) + privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID) + privateMediaProofWatchdogs.removeValue(forKey: normalizedPeerID) + authenticatedPeerStateSendProgress.removeValue(forKey: normalizedPeerID) + guard var pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] else { + return nil + } + let nonce = UUID() + pending.sessionGeneration = nil + pending.timeoutNonce = nonce + pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending + return (pending.fingerprint, nonce) + } + if let reset { + schedulePrivateMediaProofTimeout( + for: normalizedPeerID, + fingerprint: reset.fingerprint, + sessionGeneration: nil, + nonce: reset.nonce + ) + } + } + + private func clearNoiseSession(for peerID: PeerID) { + noiseService.clearSession(for: peerID) + noteNoiseSessionCleared(for: peerID) } /// Swaps `myPeerID`/`myPeerIDData` to match the current Noise identity. @@ -5054,22 +5504,24 @@ extension BLEService { do { let handshakeData = try noiseService.initiateHandshake(with: peerID) - - // Send handshake init - let packet = BitchatPacket( - type: MessageType.noiseHandshake.rawValue, - senderID: myPeerIDData, - recipientID: Data(hexString: peerID.id), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: handshakeData, - signature: nil, - ttl: messageTTL - ) - broadcastPacket(packet) + broadcastNoiseHandshake(handshakeData, to: peerID) } catch { SecureLogger.error("Failed to initiate handshake: \(error)") } } + + private func broadcastNoiseHandshake(_ handshakeData: Data, to peerID: PeerID) { + let packet = BitchatPacket( + type: MessageType.noiseHandshake.rawValue, + senderID: myPeerIDData, + recipientID: Data(hexString: peerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: handshakeData, + signature: nil, + ttl: messageTTL + ) + broadcastPacket(packet) + } private func sendPendingMessagesAfterHandshake(for peerID: PeerID) { // Atomically take all pending messages to process (prevents concurrent modification) @@ -5585,14 +6037,17 @@ extension BLEService { private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { let result = announceHandler.handle(packet, from: peerID) - // The verified announce and Noise handshake can arrive in either - // order. If authentication already completed, compare its static-key - // fingerprint with the newly upserted registry key before pinning; - // otherwise the session callback performs this reconciliation later. + // A capability bit in the public announce is only a discovery hint. + // Start authentication promptly for a directly connected candidate, + // but never pin or pre-queue private bytes until encrypted 0x21 state + // arrives from the completed Noise session. if let result, result.isVerified, - result.announcement.capabilities?.contains(.privateMedia) == true { - reconcilePrivateMediaCapabilityPin(for: result.peerID) + result.isDirectAnnounce, + result.announcement.capabilities?.contains(.privateMedia) == true, + privateMediaSendPolicy(to: result.peerID) == .awaitingCapabilityProof, + !noiseService.hasSession(with: result.peerID) { + initiateNoiseHandshake(with: result.peerID) } // A verified announce is the moment a signing key becomes bound to this @@ -5642,7 +6097,7 @@ extension BLEService { if noiseService.hasEstablishedSession(with: result.peerID) { // A session with no surviving authenticated link is stale; // force the current link to prove possession again. - noiseService.clearSession(for: result.peerID) + clearNoiseSession(for: result.peerID) } if !noiseService.hasSession(with: result.peerID) { initiateNoiseHandshake(with: result.peerID) @@ -5868,6 +6323,11 @@ extension BLEService { guard let self = self else { return nil } return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey } }, + authenticatedSigningPublicKey: { [weak self] noisePublicKey in + self?.identityManager.authenticatedSigningPublicKey( + forFingerprint: noisePublicKey.sha256Fingerprint() + ) + }, verifySignature: { [weak self] packet, signingPublicKey in self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false }, @@ -6223,7 +6683,10 @@ extension BLEService { return try self.noiseService.decrypt(payload, from: peerID) }, clearSession: { [weak self] peerID in - self?.noiseService.clearSession(for: peerID) + self?.clearNoiseSession(for: peerID) + }, + handleAuthenticatedPeerState: { [weak self] peerID, payload in + self?.handleAuthenticatedPeerState(payload, from: peerID) }, deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in if type == .privateFile { @@ -6256,10 +6719,42 @@ extension BLEService { guard !payloads.isEmpty else { return } SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID.id.prefix(8))… after handshake", category: .session) for pending in payloads { - let privateMediaTransferId: String? = { - guard NoisePayloadType.isPrivateFile(rawValue: pending.payload.first) else { return nil } - return pending.transferId - }() + let isPrivateMedia = NoisePayloadType.isPrivateFile(rawValue: pending.payload.first) + let privateMediaTransferId = isPrivateMedia ? pending.transferId : nil + + if isPrivateMedia { + switch privateMediaSendPolicy(to: peerID) { + case .encrypted: + break + + case .awaitingCapabilityProof: + // Handshake completion alone is insufficient. Put the + // exact payload back until authenticated 0x21 state + // arrives; that handler calls this drain again. + collectionsQueue.sync(flags: .barrier) { + pendingNoiseSessionQueues.appendTypedPayload( + pending.payload, + transferId: pending.transferId, + for: peerID + ) + } + continue + + case .legacyRequiresConsent, .blockedDowngrade: + if let transferId = pending.transferId { + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String( + localized: "content.delivery.reason.private_media_capability_unresolved", + defaultValue: "Could not confirm encrypted media support", + comment: "Failure reason when queued private media cannot be authenticated after handshake" + ) + ) + privateMediaTransferAdmissions.finish(transferId) + } + continue + } + } if let transferId = privateMediaTransferId, !privateMediaTransferAdmissions.isActive(transferId) { privateMediaTransferAdmissions.finish(transferId) diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 46527e68..fe09274b 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -184,6 +184,11 @@ final class NoiseEncryptionService { // Callbacks private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication 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 + /// on the wire; merely reporting "handshake required" strands the partial + /// initiator session because a second initiate call sees it already exists. + var onRekeyHandshakeReady: ((_ peerID: PeerID, _ message: Data) -> Void)? // Add a handler for peer authentication func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) { @@ -849,19 +854,26 @@ final class NoiseEncryptionService { let sessionsNeedingRekey = sessionManager.getSessionsNeedingRekey() for (peerID, needsRekey) in sessionsNeedingRekey where needsRekey { - - // Attempt to rekey the session do { - try sessionManager.initiateRekey(for: peerID) - SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security) - - // Signal that handshake is needed - onHandshakeRequired?(peerID) + try initiateAutomaticRekey(for: peerID) } catch { SecureLogger.error(error, context: "Failed to initiate rekey for peer: \(peerID)", category: .session) } } } + + private func initiateAutomaticRekey(for peerID: PeerID) throws { + let handshakeMessage = try sessionManager.initiateRekey(for: peerID) + SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security) + onRekeyHandshakeReady?(peerID, handshakeMessage) + onHandshakeRequired?(peerID) + } + + #if DEBUG + func _test_initiateAutomaticRekey(for peerID: PeerID) throws { + try initiateAutomaticRekey(for: peerID) + } + #endif deinit { stopRekeyTimer() diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 38868672..fd5c9338 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -88,6 +88,11 @@ enum TransportEvent: @unchecked Sendable { /// legacy consent. enum PrivateMediaSendPolicy: Equatable { case encrypted + /// A public announce hinted at encrypted media (or a prior authenticated + /// pin exists), but this exact Noise session has not yet supplied its + /// authenticated peer-state proof. Callers wait boundedly; they must not + /// pre-queue encrypted bytes or silently select the legacy path. + case awaitingCapabilityProof case legacyRequiresConsent case blockedDowngrade } @@ -224,6 +229,10 @@ protocol Transport: AnyObject { /// empty for peers that predate the capabilities TLV. func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy + func resolvePrivateMediaSendPolicy( + to peerID: PeerID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void + ) /// Sends an encoded vouch-attestation batch inside the Noise session. func sendVouchAttestations(_ payload: Data, to peerID: PeerID) /// Appends a peer-authenticated observer. Unlike @@ -295,6 +304,15 @@ extension Transport { func broadcastGroupMessage(_ envelope: Data) {} func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] } func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade } + func resolvePrivateMediaSendPolicy( + to peerID: PeerID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void + ) { + let policy = privateMediaSendPolicy(to: peerID) + Task { @MainActor in + completion(policy == .awaitingCapabilityProof ? .blockedDowngrade : policy) + } + } func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {} func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {} func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false } diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index f8e4f8c3..7e65eec2 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -9,6 +9,12 @@ enum TransportConfig { static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends + // Bounded wait for the session-authenticated capability proof used by + // private-media migration. Expiry never auto-sends clear bytes; it only + // resolves to the existing one-shot consent or downgrade-blocked path. + static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5 + static let privateMediaCapabilityProofPendingPeerCap: Int = 64 + static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16 static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays // Fragment relay TTL in sparse graphs; matches messageTTLDefault so media diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index 3ab4d765..2f7d4e05 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -57,6 +57,10 @@ protocol ChatMediaTransferContext: AnyObject { // MARK: Mesh file transfer func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy + func resolvePrivateMediaSendPolicy( + to peerID: PeerID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void + ) func requestLegacyPrivateMediaConsent( for peerID: PeerID, transferId: String, @@ -89,6 +93,13 @@ extension ChatViewModel: ChatMediaTransferContext { meshService.privateMediaSendPolicy(to: peerID) } + func resolvePrivateMediaSendPolicy( + to peerID: PeerID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void + ) { + meshService.resolvePrivateMediaSendPolicy(to: peerID, completion: completion) + } + func requestLegacyPrivateMediaConsent( for peerID: PeerID, transferId: String, @@ -350,7 +361,23 @@ final class ChatMediaTransferCoordinator { transferId: String, messageID: String ) { - switch context.privateMediaSendPolicy(to: peerID) { + continuePrivateMediaSend( + packet, + to: peerID, + transferId: transferId, + messageID: messageID, + policy: context.privateMediaSendPolicy(to: peerID) + ) + } + + private func continuePrivateMediaSend( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + messageID: String, + policy: PrivateMediaSendPolicy + ) { + switch policy { case .encrypted: context.sendFilePrivate( packet, @@ -359,6 +386,32 @@ final class ChatMediaTransferCoordinator { allowLegacyFallback: false ) + case .awaitingCapabilityProof: + context.resolvePrivateMediaSendPolicy(to: peerID) { [weak self] resolvedPolicy in + guard let self, + self.isRegisteredTransfer(transferId, messageID: messageID) else { + return + } + guard resolvedPolicy != .awaitingCapabilityProof else { + self.handleMediaSendFailure( + messageID: messageID, + reason: String( + localized: "content.delivery.reason.private_media_capability_unresolved", + defaultValue: "Could not confirm encrypted media support", + comment: "Failure reason when private-media capability negotiation did not resolve" + ) + ) + return + } + self.continuePrivateMediaSend( + packet, + to: peerID, + transferId: transferId, + messageID: messageID, + policy: resolvedPolicy + ) + } + case .legacyRequiresConsent: context.requestLegacyPrivateMediaConsent( for: peerID, diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index 5f219433..5ff2b3aa 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -408,10 +408,11 @@ private extension ChatTransportEventCoordinator { case .voiceFrame: context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp) - case .privateFile: + case .privateFile, .authenticatedPeerState: // BLEService validates and persists decrypted private files before - // emitting a normal `.messageReceived` event. No raw file bytes - // should cross this UI-facing typed-payload fallback. + // emitting a normal `.messageReceived` event, and consumes peer + // state inside the transport. Neither payload crosses this + // UI-facing typed-payload fallback. break } } diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index 6afc7a1f..3d297abf 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -307,7 +307,7 @@ final class NostrInboundPipeline { // claiming to be group traffic over Nostr is ignored. // Live voice is mesh-only: latency and relay cost make it // meaningless over Nostr. - case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile: + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState: break } } @@ -363,7 +363,7 @@ final class NostrInboundPipeline { // claiming to be group traffic over Nostr is ignored. // Live voice is mesh-only: latency and relay cost make it // meaningless over Nostr. - case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile: + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState: break } } @@ -446,7 +446,7 @@ final class NostrInboundPipeline { // in v1; group traffic over Nostr is ignored. // Live voice is mesh-only: latency and relay cost make it // meaningless over Nostr. - case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile: + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState: break } } diff --git a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift index c819eeca..3be90cf5 100644 --- a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift +++ b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift @@ -89,6 +89,7 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { private(set) var broadcastFileSends: [String] = [] private(set) var cancelledTransfers: [String] = [] var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted + var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy? private(set) var legacyConsentRequests: [( id: UUID, peerID: PeerID, @@ -103,6 +104,13 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { privateMediaPolicy } + func resolvePrivateMediaSendPolicy( + to peerID: PeerID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void + ) { + completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy) + } + func requestLegacyPrivateMediaConsent( for peerID: PeerID, transferId: String, @@ -463,6 +471,31 @@ struct ChatMediaTransferCoordinatorContextTests { #expect(context.privateFileLegacyAllowances == [true]) } + @Test @MainActor + func capabilityProofTimeoutTransitionsToConsentWithoutAutomaticRawSend() async throws { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let peerID = PeerID(str: "1020304050607080") + context.selectedPrivateChatPeer = peerID + context.privateMediaPolicy = .awaitingCapabilityProof + context.resolvedPrivateMediaPolicy = .legacyRequiresConsent + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("proof-timeout-consent-\(UUID().uuidString).m4a") + try Data("voice".utf8).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + coordinator.sendVoiceNote(at: url) + + let prompted = await TestHelpers.waitUntil( + { context.legacyConsentRequests.count == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(prompted) + #expect(context.privateFileSends.isEmpty) + context.resolveNextLegacyConsent(false) + #expect(context.privateFileSends.isEmpty) + } + @Test @MainActor func legacyConsentApprovalAfterCancelCannotSend() async throws { let context = MockChatMediaTransferContext() diff --git a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift index 70d974d8..8a8a4737 100644 --- a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift +++ b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift @@ -218,26 +218,40 @@ struct PrivateMediaEndToEndTests { capabilities: .privateMedia, noisePublicKey: bobKey ) - #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted) + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof) let bobFingerprint = bobKey.sha256Fingerprint() #expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint)) - try establishSession(alice: alice, bob: bob) + try await establishSession(alice: alice, bob: bob) let capabilityPinned = await TestHelpers.waitUntil( { identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint) }, timeout: TestConstants.longTimeout ) #expect(capabilityPinned) - // Model a later verified old/replayed announce for the same stable - // Noise identity. The current bit disappears, but the authenticated - // persistent pin wins. + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted) + + // A public no-bit announce cannot override state authenticated by the + // current session. A later authenticated no-bit state is a real + // downgrade and must block despite a caller offering legacy consent. alice._test_seedConnectedPeer( bob.myPeerID, nickname: "Bob", capabilities: [], noisePublicKey: bobKey ) + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted) + let authenticatedNoBit = try authenticatedPeerStatePacket( + from: bob, + to: alice, + capabilities: [] + ) + alice._test_handlePacket(authenticatedNoBit, fromPeerID: bob.myPeerID) + let downgradeObserved = await TestHelpers.waitUntil( + { alice.privateMediaSendPolicy(to: bob.myPeerID) == .blockedDowngrade }, + timeout: TestConstants.longTimeout + ) + #expect(downgradeObserved) #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .blockedDowngrade) let tap = PacketTap() @@ -307,7 +321,7 @@ struct PrivateMediaEndToEndTests { preseedPeer: false ) let advertised = await TestHelpers.waitUntil( - { alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted }, + { alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof }, timeout: TestConstants.longTimeout ) #expect(advertised) @@ -331,6 +345,181 @@ struct PrivateMediaEndToEndTests { #expect(remainedLegacyEligible) } + @Test + func copiedNoiseKeyPreannounceCannotPinWhenRealOwnerAuthenticates() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-copied-static-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let identity = MockIdentityManager(MockKeychain()) + let alice = makeService( + baseDirectory: root.appendingPathComponent("alice", isDirectory: true), + identityManager: identity + ) + let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) + let attacker = makeService(baseDirectory: root.appendingPathComponent("attacker", isDirectory: true)) + let bobKey = bob.noiseStaticPublicKeyData() + let bobFingerprint = bobKey.sha256Fingerprint() + + // Mallory copies Bob's public Noise key, advertises bit 8, supplies + // Mallory's Ed25519 key, and self-signs. This is internally consistent + // but does not prove possession of Bob's Noise private key. + let forged = try copiedStaticAnnounce( + claimedOwner: bob, + signedBy: attacker, + capabilities: .privateMedia + ) + alice._test_handlePacket(forged, fromPeerID: bob.myPeerID, preseedPeer: false) + let hintAccepted = await TestHelpers.waitUntil( + { alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof }, + timeout: TestConstants.longTimeout + ) + #expect(hintAccepted) + #expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint)) + + let proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob) + #expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint)) + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof) + + // Only Bob's encrypted state authorizes bit 8 and replaces the forged + // announcement signing key with Bob's Noise-authenticated Ed key. + alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID) + let pinned = await TestHelpers.waitUntil( + { identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint) }, + timeout: TestConstants.longTimeout + ) + #expect(pinned) + #expect(identity.authenticatedSigningPublicKey(forFingerprint: bobFingerprint) + == bob.noiseSigningPublicKeyData()) + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted) + + bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID) + alice._test_onOutboundPacket = nil + bob._test_onOutboundPacket = nil + } + + @Test + func droppedInitiatorProofConvergesViaSingleAuthenticatedEcho() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-proof-echo-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true)) + let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: .privateMedia, + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + bob._test_seedConnectedPeer( + alice.myPeerID, + nickname: "Alice", + capabilities: .privateMedia, + noisePublicKey: alice.noiseStaticPublicKeyData() + ) + + let initial = try await establishSessionCapturingPeerState(alice: alice, bob: bob) + // Model Alice's first proof racing ahead of Bob's message-3 handling + // and being dropped. Bob's proof reaches Alice; Alice must emit one + // idempotent echo that lets Bob converge without a new handshake. + _ = initial.alice + let echoTap = PacketTap() + alice._test_onOutboundPacket = echoTap.record + alice._test_handlePacket(initial.bob, fromPeerID: bob.myPeerID) + let echoed = await TestHelpers.waitUntil( + { echoTap.snapshot().contains { $0.type == MessageType.noiseEncrypted.rawValue } }, + timeout: TestConstants.longTimeout + ) + #expect(echoed) + let echo = try #require( + echoTap.snapshot().first { $0.type == MessageType.noiseEncrypted.rawValue } + ) + bob._test_handlePacket(echo, fromPeerID: alice.myPeerID) + let converged = await TestHelpers.waitUntil( + { + alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted + && bob.privateMediaSendPolicy(to: alice.myPeerID) == .encrypted + }, + timeout: TestConstants.longTimeout + ) + #expect(converged) + alice._test_onOutboundPacket = nil + bob._test_onOutboundPacket = nil + } + + @Test + func noProofTimeoutResolvesToConsentWithoutSendingRawMedia() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-proof-timeout-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true)) + let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Prerelease Bob", + capabilities: .privateMedia, + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + + _ = try await establishSessionCapturingPeerState(alice: alice, bob: bob) + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof) + let recorder = PrivateMediaPolicyRecorder() + alice.resolvePrivateMediaSendPolicy(to: bob.myPeerID) { recorder.record($0) } + let registered = await TestHelpers.waitUntil( + { alice._test_hasPendingPrivateMediaPolicyResolution(for: bob.myPeerID) }, + timeout: TestConstants.longTimeout + ) + #expect(registered) + alice._test_forcePrivateMediaProofTimeout(for: bob.myPeerID) + let resolved = await TestHelpers.waitUntil( + { recorder.snapshot() == .legacyRequiresConsent }, + timeout: TestConstants.longTimeout + ) + #expect(resolved) + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent) + #expect(recorder.snapshot() != .encrypted) + alice._test_onOutboundPacket = nil + bob._test_onOutboundPacket = nil + } + + @Test + func queuedPrivatePayloadWaitsForProofNotHandshakeCompletion() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-proof-drain-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true)) + let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: .privateMedia, + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + let proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob) + + let content = Data("proof-gated-private-file".utf8) + let file = BitchatFilePacket( + fileName: "proof.txt", + fileSize: UInt64(content.count), + mimeType: "text/plain", + content: content + ) + let payload = try #require(BLENoisePayloadFactory.privateFile(file)) + let transferID = "proof-gated-\(UUID().uuidString)" + alice._test_enqueuePendingNoisePayload(payload, transferId: transferID, for: bob.myPeerID) + alice._test_sendPendingNoisePayloadsAfterHandshake(for: bob.myPeerID) + + #expect(alice._test_privateMediaTransferState(transferId: transferID).pendingNoise) + alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID) + let drained = await TestHelpers.waitUntil( + { !alice._test_privateMediaTransferState(transferId: transferID).pendingNoise }, + timeout: TestConstants.longTimeout + ) + #expect(drained) + bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID) + alice._test_onOutboundPacket = nil + bob._test_onOutboundPacket = nil + } + @Test func authenticatedFingerprintMismatchCannotPoisonCapabilityPin() async throws { let root = FileManager.default.temporaryDirectory @@ -353,7 +542,7 @@ struct PrivateMediaEndToEndTests { capabilities: .privateMedia, noisePublicKey: impostorKey ) - try establishSession(alice: alice, bob: bob) + try await establishSession(alice: alice, bob: bob) let sessionReconciled = await TestHelpers.waitUntil( { reconciliations.contains(bob.myPeerID) }, @@ -373,7 +562,7 @@ struct PrivateMediaEndToEndTests { } @Test - func capabilityAnnounceAfterNoiseSessionPinsMatchingFingerprint() async throws { + func capabilityAnnounceAfterNoiseSessionStillRequiresEncryptedProof() async throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("private-media-race-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: root) } @@ -384,7 +573,7 @@ struct PrivateMediaEndToEndTests { ) let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) - try establishSession(alice: alice, bob: bob) + let proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob) let bobKey = bob.noiseStaticPublicKeyData() let capableAnnounce = try signedAnnounce( from: bob, @@ -396,6 +585,17 @@ struct PrivateMediaEndToEndTests { preseedPeer: false ) + let announceDidNotPin = await TestHelpers.waitUntil( + { alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof }, + timeout: TestConstants.longTimeout + ) + #expect(announceDidNotPin) + #expect(!identity.hasObservedPrivateMediaCapability( + fingerprint: bobKey.sha256Fingerprint() + )) + + alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID) + let pinned = await TestHelpers.waitUntil( { identity.hasObservedPrivateMediaCapability( @@ -405,6 +605,9 @@ struct PrivateMediaEndToEndTests { timeout: TestConstants.longTimeout ) #expect(pinned) + bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID) + alice._test_onOutboundPacket = nil + bob._test_onOutboundPacket = nil } @Test @@ -427,7 +630,7 @@ struct PrivateMediaEndToEndTests { nickname: "Old Carol", noisePublicKey: oldCarol.noiseStaticPublicKeyData() ) - try establishSession(alice: alice, bob: bob) + try await establishSession(alice: alice, bob: bob) var state: UInt64 = 0x1234_5678_9ABC_DEF0 let body = Data((0..<(130 * 1024)).map { _ in @@ -475,7 +678,7 @@ struct PrivateMediaEndToEndTests { defer { try? FileManager.default.removeItem(at: root) } let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true)) let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true)) - try establishSession(alice: alice, bob: bob) + try await establishSession(alice: alice, bob: bob) let transferID = "queued-encryption-failure-\(UUID().uuidString)" let rejections = TransferCancellationRecorder() @@ -615,7 +818,7 @@ struct PrivateMediaEndToEndTests { let bob = makeService(baseDirectory: bobRoot) let delegate = MessageCaptureDelegate() bob.delegate = delegate - try establishSession(alice: alice, bob: bob) + try await establishSession(alice: alice, bob: bob) let file = BitchatFilePacket( fileName: "\(directoryLabel).pdf", @@ -736,7 +939,6 @@ struct PrivateMediaEndToEndTests { let bob = makeService(baseDirectory: bobRoot) let tap = PacketTap() let delegate = MessageCaptureDelegate() - alice._test_onOutboundPacket = tap.record bob.delegate = delegate alice._test_seedConnectedPeer( @@ -751,7 +953,8 @@ struct PrivateMediaEndToEndTests { capabilities: .privateMedia, noisePublicKey: alice.noiseStaticPublicKeyData() ) - try establishSession(alice: alice, bob: bob) + try await establishSession(alice: alice, bob: bob) + alice._test_onOutboundPacket = tap.record let file = BitchatFilePacket( fileName: fileName, @@ -825,7 +1028,27 @@ struct PrivateMediaEndToEndTests { ) } - private func establishSession(alice: BLEService, bob: BLEService) throws { + private func establishSession(alice: BLEService, bob: BLEService) async throws { + let proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob) + bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID) + alice._test_handlePacket(proofs.bob, fromPeerID: bob.myPeerID) + // Fence the message/identity mutations without assuming either test + // seeded a registry entry (inbound-only tests intentionally do not). + await alice._test_drainNoiseMessagePipeline() + await bob._test_drainNoiseMessagePipeline() + alice._test_onOutboundPacket = nil + bob._test_onOutboundPacket = nil + } + + private func establishSessionCapturingPeerState( + alice: BLEService, + bob: BLEService + ) async throws -> (alice: BitchatPacket, bob: BitchatPacket) { + let aliceTap = PacketTap() + let bobTap = PacketTap() + alice._test_onOutboundPacket = aliceTap.record + bob._test_onOutboundPacket = bobTap.record + let first = try alice._test_noiseInitiateHandshake(with: bob.myPeerID) let second = try #require( try bob._test_noiseProcessHandshakeMessage(from: alice.myPeerID, message: first) @@ -836,6 +1059,35 @@ struct PrivateMediaEndToEndTests { _ = try bob._test_noiseProcessHandshakeMessage(from: alice.myPeerID, message: third) #expect(alice.canDeliverSecurely(to: bob.myPeerID)) #expect(bob.canDeliverSecurely(to: alice.myPeerID)) + + let emitted = await TestHelpers.waitUntil( + { + aliceTap.snapshot().contains { $0.type == MessageType.noiseEncrypted.rawValue } + && bobTap.snapshot().contains { $0.type == MessageType.noiseEncrypted.rawValue } + }, + timeout: TestConstants.longTimeout + ) + #expect(emitted) + let aliceProof = try #require( + aliceTap.snapshot().first { $0.type == MessageType.noiseEncrypted.rawValue } + ) + let bobProof = try #require( + bobTap.snapshot().first { $0.type == MessageType.noiseEncrypted.rawValue } + ) + return (aliceProof, bobProof) + } + + private func authenticatedPeerStatePacket( + from sender: BLEService, + to recipient: BLEService, + capabilities: PeerCapabilities + ) throws -> BitchatPacket { + let state = AuthenticatedPeerStatePacket( + capabilities: capabilities, + signingPublicKey: sender.noiseSigningPublicKeyData() + ) + let typed = try #require(BLENoisePayloadFactory.authenticatedPeerState(state)) + return try sender._test_makeEncryptedNoisePacket(typed, to: recipient.myPeerID) } private func signedAnnounce( @@ -862,6 +1114,33 @@ struct PrivateMediaEndToEndTests { return service.signPacketForBroadcast(unsigned) } + private func copiedStaticAnnounce( + claimedOwner: BLEService, + signedBy signer: BLEService, + capabilities: PeerCapabilities + ) throws -> BitchatPacket { + let announcement = AnnouncementPacket( + nickname: "Mallory-as-Bob", + noisePublicKey: claimedOwner.noiseStaticPublicKeyData(), + signingPublicKey: signer.noiseSigningPublicKeyData(), + directNeighbors: nil, + capabilities: capabilities + ) + let payload = try #require(announcement.encode()) + let unsigned = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: claimedOwner.myPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1_000), + payload: payload, + signature: nil, + // Relayed shape avoids the proactive direct-hint handshake in + // this deterministic test; it does not change signature validity. + ttl: TransportConfig.messageTTLDefault - 1 + ) + return signer.signPacketForBroadcast(unsigned) + } + private func recursivelyStoredFiles(under root: URL) -> [URL] { guard let enumerator = FileManager.default.enumerator( at: root, @@ -953,6 +1232,23 @@ private final class PeerIDRecorder: @unchecked Sendable { } } +private final class PrivateMediaPolicyRecorder: @unchecked Sendable { + private let lock = NSLock() + private var policy: PrivateMediaSendPolicy? + + func record(_ policy: PrivateMediaSendPolicy) { + lock.lock() + self.policy = policy + lock.unlock() + } + + func snapshot() -> PrivateMediaSendPolicy? { + lock.lock() + defer { lock.unlock() } + return policy + } +} + private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable { private let lock = NSLock() private var messages: [BitchatMessage] = [] diff --git a/bitchatTests/Mocks/MockIdentityManager.swift b/bitchatTests/Mocks/MockIdentityManager.swift index f3094ae6..930d57ff 100644 --- a/bitchatTests/Mocks/MockIdentityManager.swift +++ b/bitchatTests/Mocks/MockIdentityManager.swift @@ -15,6 +15,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { private var blockedNostrPubkeys: Set = [] private var socialIdentities: [String: SocialIdentity] = [:] private var privateMediaCapableFingerprints: Set = [] + private var authenticatedSigningKeys: [String: Data] = [:] init(_: KeychainManagerProtocol) {} @@ -90,6 +91,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { func clearAllIdentityData() { privateMediaCapableFingerprints.removeAll() + authenticatedSigningKeys.removeAll() } func removeEphemeralSession(peerID: PeerID) {} @@ -112,6 +114,14 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { privateMediaCapableFingerprints.contains(fingerprint) } + func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) { + authenticatedSigningKeys[fingerprint] = signingPublicKey + } + + func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? { + authenticatedSigningKeys[fingerprint] + } + // MARK: Vouching (transitive verification) private var vouchesByVouchee: [String: [VouchRecord]] = [:] diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index 102ee072..b383c760 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -205,6 +205,14 @@ final class MockTransport: Transport { privateMediaPolicies[peerID] ?? .encrypted } + func resolvePrivateMediaSendPolicy( + to peerID: PeerID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void + ) { + let policy = privateMediaPolicies[peerID] ?? .encrypted + Task { @MainActor in completion(policy) } + } + func cancelTransfer(_ transferId: String) { cancelledTransfers.append(transferId) } diff --git a/bitchatTests/Noise/NoiseCoverageTests.swift b/bitchatTests/Noise/NoiseCoverageTests.swift index 4ff7a04a..83d8c3dc 100644 --- a/bitchatTests/Noise/NoiseCoverageTests.swift +++ b/bitchatTests/Noise/NoiseCoverageTests.swift @@ -650,7 +650,8 @@ struct NoiseCoverageTests { try aliceManager.initiateHandshake(with: alicePeerID) } - try aliceManager.initiateRekey(for: alicePeerID) + let rekeyHandshake = try aliceManager.initiateRekey(for: alicePeerID) + #expect(!rekeyHandshake.isEmpty) let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID)) #expect(rekeyedSession !== establishedSession) diff --git a/bitchatTests/Protocols/PacketsTests.swift b/bitchatTests/Protocols/PacketsTests.swift index 2368925a..c837ee1d 100644 --- a/bitchatTests/Protocols/PacketsTests.swift +++ b/bitchatTests/Protocols/PacketsTests.swift @@ -145,6 +145,39 @@ struct PacketsTests { #expect(decoded.capabilities?.rawValue == 0x0180) } + @Test + func authenticatedPeerStateUsesVersionedCanonicalTLVs() throws { + let signingKey = Data(repeating: 0xA5, count: 32) + let packet = AuthenticatedPeerStatePacket( + capabilities: [.privateMedia, .vouch], + signingPublicKey: signingKey + ) + + var encoded = try #require(packet.encode()) + #expect(encoded.prefix(5) == Data([0x01, 0x01, 0x02, 0x20, 0x01])) + // Unknown TLVs are forward-compatible and do not alter v1 state. + encoded.append(makeTLV(type: 0x7F, value: Data([0xCA, 0xFE]))) + + #expect(AuthenticatedPeerStatePacket.decode(from: encoded) == packet) + } + + @Test + func authenticatedPeerStateRejectsMalformedAmbiguousOrUnknownVersion() { + let key = Data(repeating: 0x44, count: 32) + let capabilities = makeTLV(type: 0x01, value: Data([0x00, 0x01])) + let signing = makeTLV(type: 0x02, value: key) + + #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x02]) + capabilities + signing) == nil) + #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + signing) == nil) + #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + capabilities + signing) == nil) + #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01, 0x01, 0x00]) + signing) == nil) + // 0x0001 is non-minimal little endian; the canonical form is [0x01]. + #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data([0x01, 0x00])) + signing) == nil) + #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + makeTLV(type: 0x02, value: Data(key.dropLast()))) == nil) + #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + capabilities + Data(signing.dropLast())) == nil) + #expect(AuthenticatedPeerStatePacket.decode(from: Data([0x01]) + makeTLV(type: 0x01, value: Data(repeating: 0x01, count: 9)) + signing) == nil) + } + @Test func privateMessagePacketRejectsUnknownTypeAndTruncation() { let unknownTLV = Data([0x7F, 0x01, 0x41]) diff --git a/bitchatTests/Services/BLEAnnounceHandlerTests.swift b/bitchatTests/Services/BLEAnnounceHandlerTests.swift index 9623573a..c685d537 100644 --- a/bitchatTests/Services/BLEAnnounceHandlerTests.swift +++ b/bitchatTests/Services/BLEAnnounceHandlerTests.swift @@ -6,6 +6,7 @@ import Testing struct BLEAnnounceHandlerTests { private final class Recorder { var existingNoisePublicKey: Data? + var authenticatedSigningPublicKey: Data? var signatureValid = true var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false) var linkBoundToOtherPeer = false @@ -37,6 +38,7 @@ struct BLEAnnounceHandlerTests { messageTTL: TransportConfig.messageTTLDefault, now: { now }, existingNoisePublicKey: { _ in recorder.existingNoisePublicKey }, + authenticatedSigningPublicKey: { _ in recorder.authenticatedSigningPublicKey }, verifySignature: { packet, signingPublicKey in recorder.verifySignatureCalls.append((packet, signingPublicKey)) return recorder.signatureValid diff --git a/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift b/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift index c0226b81..b91b302e 100644 --- a/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift +++ b/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift @@ -169,6 +169,23 @@ struct BLEAnnounceHandlingPolicyTests { #expect(decision.isVerified) } + @Test + func trustPolicyRejectsSigningKeyReplacementAfterNoiseBinding() { + let noiseKey = Data(repeating: 0xCC, count: 32) + let boundSigningKey = Data(repeating: 0x11, count: 32) + + let decision = BLEAnnounceTrustPolicy.evaluate( + hasSignature: true, + signatureValid: true, + existingNoisePublicKey: noiseKey, + announcedNoisePublicKey: noiseKey, + authenticatedSigningPublicKey: boundSigningKey, + announcedSigningPublicKey: Data(repeating: 0x22, count: 32) + ) + + #expect(decision == .reject(.authenticatedSigningKeyMismatch)) + } + @Test func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() { let directNew = BLEAnnounceResponsePolicy.plan( diff --git a/bitchatTests/Services/BLENoisePacketHandlerTests.swift b/bitchatTests/Services/BLENoisePacketHandlerTests.swift index 42db3642..36b4b53d 100644 --- a/bitchatTests/Services/BLENoisePacketHandlerTests.swift +++ b/bitchatTests/Services/BLENoisePacketHandlerTests.swift @@ -18,6 +18,7 @@ struct BLENoisePacketHandlerTests { var lastSeenUpdates: [PeerID] = [] var decryptCalls: [(payload: Data, peerID: PeerID)] = [] var clearedSessions: [PeerID] = [] + var authenticatedPeerStates: [(peerID: PeerID, payload: Data)] = [] var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = [] /// Ordered side-effect log to assert recovery sequencing. var events: [String] = [] @@ -62,6 +63,9 @@ struct BLENoisePacketHandlerTests { recorder.clearedSessions.append(peerID) recorder.events.append("clearSession") }, + handleAuthenticatedPeerState: { peerID, payload in + recorder.authenticatedPeerStates.append((peerID, payload)) + }, deliverNoisePayload: { peerID, type, payload, timestamp in recorder.deliveries.append((peerID, type, payload, timestamp)) } @@ -221,6 +225,24 @@ struct BLENoisePacketHandlerTests { #expect(recorder.initiatedHandshakes.isEmpty) } + @Test + func authenticatedPeerStateIsConsumedByTransportNotDeliveredToUI() { + let recorder = Recorder() + recorder.decryptResult = .success(Data([ + NoisePayloadType.authenticatedPeerState.rawValue, + 0x01, 0x02, 0x03 + ])) + let handler = makeHandler(recorder: recorder) + let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id)) + + handler.handleEncrypted(packet, from: remotePeerID) + + #expect(recorder.authenticatedPeerStates.count == 1) + #expect(recorder.authenticatedPeerStates.first?.peerID == remotePeerID) + #expect(recorder.authenticatedPeerStates.first?.payload == Data([0x01, 0x02, 0x03])) + #expect(recorder.deliveries.isEmpty) + } + @Test func emptyDecryptedPayloadIsIgnored() { let recorder = Recorder() diff --git a/bitchatTests/Services/BLENoisePayloadFactoryTests.swift b/bitchatTests/Services/BLENoisePayloadFactoryTests.swift index 780112fc..c1722f8a 100644 --- a/bitchatTests/Services/BLENoisePayloadFactoryTests.swift +++ b/bitchatTests/Services/BLENoisePayloadFactoryTests.swift @@ -79,4 +79,16 @@ struct BLENoisePayloadFactoryTests { #expect(decoded.data == Data([0xCA, 0xFE])) #expect(decoded.encode().first == 0x20) } + + @Test + func authenticatedPeerStateUsesPermanent0x21Type() throws { + let state = AuthenticatedPeerStatePacket( + capabilities: .privateMedia, + signingPublicKey: Data(repeating: 0x77, count: 32) + ) + let encoded = try #require(BLENoisePayloadFactory.authenticatedPeerState(state)) + + #expect(encoded.first == 0x21) + #expect(AuthenticatedPeerStatePacket.decode(from: Data(encoded.dropFirst())) == state) + } } diff --git a/bitchatTests/Services/NoiseEncryptionServiceTests.swift b/bitchatTests/Services/NoiseEncryptionServiceTests.swift index 4d54e08d..f32cf755 100644 --- a/bitchatTests/Services/NoiseEncryptionServiceTests.swift +++ b/bitchatTests/Services/NoiseEncryptionServiceTests.swift @@ -237,6 +237,40 @@ struct NoiseEncryptionServiceTests { #expect(try receiver.decrypt(ciphertext, from: alicePeerID) == Data("new session".utf8)) } + @Test("Automatic rekey exposes and completes its exact handshake bytes") + func automaticRekeyHandshakeIsNotStranded() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData()) + try establishSessions(alice: alice, bob: bob) + + var emittedPeerID: PeerID? + var emittedMessage: Data? + alice.onRekeyHandshakeReady = { peerID, message in + emittedPeerID = peerID + emittedMessage = message + } + try alice._test_initiateAutomaticRekey(for: bobPeerID) + + #expect(emittedPeerID == bobPeerID) + let message1 = try #require(emittedMessage) + #expect(!message1.isEmpty) + #expect(alice.hasSession(with: bobPeerID)) + #expect(!alice.hasEstablishedSession(with: bobPeerID)) + + let message2 = try #require( + try bob.processHandshakeMessage(from: alicePeerID, message: message1) + ) + let message3 = try #require( + try alice.processHandshakeMessage(from: bobPeerID, message: message2) + ) + _ = try bob.processHandshakeMessage(from: alicePeerID, message: message3) + + #expect(alice.hasEstablishedSession(with: bobPeerID)) + #expect(bob.hasEstablishedSession(with: alicePeerID)) + } + @Test("Large private-file payloads use the bounded Noise extension") func largePrivateFileNoiseRoundTrip() throws { let alice = NoiseEncryptionService(keychain: MockKeychain()) diff --git a/bitchatTests/Services/SecureIdentityStateManagerTests.swift b/bitchatTests/Services/SecureIdentityStateManagerTests.swift index 0ce7035b..3be77ea1 100644 --- a/bitchatTests/Services/SecureIdentityStateManagerTests.swift +++ b/bitchatTests/Services/SecureIdentityStateManagerTests.swift @@ -427,6 +427,31 @@ final class SecureIdentityStateManagerTests: XCTestCase { XCTAssertTrue(cleared) } + func test_noiseAuthenticatedSigningKeyBindingPersistsAndPanicClearRemovesIt() async { + let keychain = MockKeychain() + let fingerprint = Data(repeating: 0x31, count: 32).sha256Fingerprint() + let firstKey = Data(repeating: 0x41, count: 32) + let rotatedKey = Data(repeating: 0x42, count: 32) + let manager = SecureIdentityStateManager(keychain) + + manager.bindAuthenticatedSigningPublicKey(firstKey, fingerprint: fingerprint) + XCTAssertEqual(manager.authenticatedSigningPublicKey(forFingerprint: fingerprint), firstKey) + // A later authenticated Noise session may legitimately rotate the + // announcement signing key. + manager.bindAuthenticatedSigningPublicKey(rotatedKey, fingerprint: fingerprint) + XCTAssertEqual(manager.authenticatedSigningPublicKey(forFingerprint: fingerprint), rotatedKey) + + manager.forceSave() + let reloaded = SecureIdentityStateManager(keychain) + XCTAssertEqual(reloaded.authenticatedSigningPublicKey(forFingerprint: fingerprint), rotatedKey) + + reloaded.clearAllIdentityData() + let cleared = await waitUntil { + reloaded.authenticatedSigningPublicKey(forFingerprint: fingerprint) == nil + } + XCTAssertTrue(cleared) + } + func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async { let keychain = FailingCacheSaveKeychain() let manager = SecureIdentityStateManager(keychain) diff --git a/bitchatTests/Services/UnifiedPeerServiceTests.swift b/bitchatTests/Services/UnifiedPeerServiceTests.swift index 5ac47ac3..4bc6f775 100644 --- a/bitchatTests/Services/UnifiedPeerServiceTests.swift +++ b/bitchatTests/Services/UnifiedPeerServiceTests.swift @@ -268,6 +268,8 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol { func markPrivateMediaCapable(fingerprint: String) {} func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { false } + func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {} + func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? { nil } func removeEphemeralSession(peerID: PeerID) {} diff --git a/docs/PRIVATE-MEDIA-MIGRATION.md b/docs/PRIVATE-MEDIA-MIGRATION.md index 6195fec0..f4e66217 100644 --- a/docs/PRIVATE-MEDIA-MIGRATION.md +++ b/docs/PRIVATE-MEDIA-MIGRATION.md @@ -11,8 +11,23 @@ peer's Noise session before BLE fragmentation. - iOS temporarily accepts `0x09`, which appeared in prerelease builds of the private-media change. Decoders canonicalize it to `privateFile`; they never emit it. -- A peer advertising `PeerCapabilities.privateMedia` receives one encrypted - `noiseEncrypted` transfer. +- `NoisePayloadType.authenticatedPeerState` is permanently assigned `0x21`. + It is emitted after every completed/rekeyed Noise XX session and echoed at + most once when the remote state arrives, so message-3/proof reordering over + different mesh links converges. This type is part of the protocol security + boundary and is not removed when the media migration ends. +- The `0x21` payload starts with version `0x01`, followed by one-byte + type/length/value fields. Version 1 requires canonical TLV `0x01` (the + minimal little-endian `PeerCapabilities` bitfield, 1-8 bytes) and TLV `0x02` + (the 32-byte Ed25519 announcement signing key). Duplicate required fields, + non-minimal capabilities, malformed lengths, missing fields, and unknown + versions are ignored without changing state. Unknown TLVs are skipped. +- The public `PeerCapabilities.privateMedia` announce bit is a discovery hint: + it starts a Noise handshake, but never selects encrypted sending or creates + a pin. A private transfer waits boundedly for the exact session's encrypted + `0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a + no-proof timeout reaches the explicit legacy-consent path for an unpinned + peer. No timeout automatically sends raw bytes. - An unpinned peer with a stable Noise key but without that capability is eligible for one signed, directed `fileTransfer`, matching the pre-migration wire form used by older iOS and @@ -20,22 +35,33 @@ peer's Noise session before BLE fragmentation. per-send warning that the file is not end-to-end encrypted and mesh relays can see it. The consent is consumed by that invocation and is never remembered. -- A signed announce never creates a pin by itself. The fingerprint is pinned - in the encrypted identity cache only after a completed Noise session - authenticates the same static key as the capability-bearing registry entry. - This comparison is performed in either event order: session then announce, - or announce then session. A later announce missing the bit is then treated - as a downgrade, and raw fallback is blocked even if a caller presents - legacy consent. +- A signed announce never creates a pin by itself: an attacker can copy a + victim's public Noise key, supply its own Ed25519 key and capability bits, + and self-sign an internally consistent announce. Only successfully + decrypted `0x21` state pins the authenticated Noise fingerprint and binds + the Ed25519 key used by later announces/public messages. A later valid + no-bit `0x21` is treated as a downgrade, and raw fallback is blocked even if + a caller presents legacy consent. Public no-bit announces cannot overwrite + current session-authenticated state. - During migration, both an absent capabilities TLV and an explicit TLV without `privateMedia` are legacy-eligible when that stable fingerprint is not pinned. This supports clients that added capability advertisement before encrypted media. Neither shape bypasses a previously authenticated pin. -The `0x09` receive alias may be removed only after every TestFlight/internal -build that emitted it has expired and the project's minimum-supported-client -policy excludes those builds. Track that release criterion explicitly; do not -remove the alias on an arbitrary calendar date. +Older clients decrypt and ignore unknown inner type `0x21`; they do not need to +understand it to continue using text or the warned legacy media path. They are +never inferred capable merely because the handshake succeeded. + +Removal gates are independent and must not share an arbitrary calendar date: + +- Remove the `0x09` receive alias only after every TestFlight/internal build + that emitted it has expired and minimum-supported-client policy excludes it. +- Remove the signed directed raw `0x22` fallback only after minimum-supported + iOS and Android clients emit authenticated bit-8 `0x21` state and the legacy + population has aged out. +- Nostr kind `1059` compatibility is a separate envelope migration. Its dual + publish/removal gate is not evidence that either BLE compatibility shape can + be removed. ## Security boundary @@ -61,9 +87,10 @@ Incoming clients accept all three migration-era shapes: Panic wipe clears the persistent capability pins together with the rest of the encrypted identity cache. -This migration path is BLE-only. Nostr private-media transport is unchanged -and remains a follow-up; do not infer the BLE consent fallback or capability -pin semantics for Nostr delivery. +This migration path is mesh-Noise-only (BLE and compatible direct mesh links). +Nostr private-media transport is unchanged and remains a follow-up. Nostr +inbound paths explicitly ignore `0x21`; do not infer the mesh consent fallback +or capability-pin semantics for Nostr delivery. ## Size interoperability