diff --git a/bitchat/App/ConversationUIModel.swift b/bitchat/App/ConversationUIModel.swift index 683a9dee..91f0afef 100644 --- a/bitchat/App/ConversationUIModel.swift +++ b/bitchat/App/ConversationUIModel.swift @@ -12,6 +12,7 @@ final class ConversationUIModel: ObservableObject { @Published private(set) var currentNickname: String @Published private(set) var isBatchingPublic = false @Published private(set) var canSendMediaInCurrentContext = true + @Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest? /// Who is talking live in the public mesh channel right now (floor /// courtesy: the composer mic tints "busy" while someone holds the floor). @Published private(set) var activeLiveVoiceTalker: String? @@ -153,6 +154,13 @@ final class ConversationUIModel: ObservableObject { chatViewModel.sendVoiceNote(at: url) } + func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) { + chatViewModel.resolveLegacyPrivateMediaConsent( + requestID: requestID, + approved: approved + ) + } + /// Capture backend for the mic gesture: live PTT when the current DM /// peer can hear it now, classic voice note otherwise. func makeVoiceCaptureSession() -> VoiceCaptureSession { @@ -193,6 +201,10 @@ final class ConversationUIModel: ObservableObject { .receive(on: DispatchQueue.main) .assign(to: &$activeLiveVoiceTalker) + chatViewModel.$legacyPrivateMediaConsentRequest + .receive(on: DispatchQueue.main) + .assign(to: &$legacyPrivateMediaConsentRequest) + conversations.$activeChannel .receive(on: DispatchQueue.main) .sink { [weak self] channel in diff --git a/bitchat/Identity/IdentityModels.swift b/bitchat/Identity/IdentityModels.swift index 1213b762..60a9d844 100644 --- a/bitchat/Identity/IdentityModels.swift +++ b/bitchat/Identity/IdentityModels.swift @@ -190,6 +190,18 @@ struct IdentityCache: Codable { // entries verified before this field exists sort as oldest) var verifiedAt: [String: Date]? = nil + // 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 + // Fingerprint -> Cryptographic identity (noise + pinned signing key). // Persisting the signing-key pin is security-critical: it must survive // app restarts so an attacker cannot replay a known peer's @@ -216,6 +228,8 @@ struct IdentityCache: Codable { vouchesByVouchee = try container.decodeIfPresent([String: [VouchRecord]].self, forKey: .vouchesByVouchee) vouchBatchSentAt = try container.decodeIfPresent([String: Date].self, forKey: .vouchBatchSentAt) verifiedAt = try container.decodeIfPresent([String: Date].self, forKey: .verifiedAt) + privateMediaCapableFingerprints = try container.decodeIfPresent(Set.self, forKey: .privateMediaCapableFingerprints) + authenticatedSigningKeysByFingerprint = try container.decodeIfPresent([String: Data].self, forKey: .authenticatedSigningKeysByFingerprint) cryptographicIdentities = try container.decodeIfPresent([String: CryptographicIdentity].self, forKey: .cryptographicIdentities) ?? [:] version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 } diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index 341319b0..966f210a 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -140,6 +140,14 @@ protocol SecureIdentityStateManagerProtocol { func markVouchBatchSent(to fingerprint: String, at date: Date) 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 } /// Singleton manager for secure identity state persistence and retrieval. @@ -158,6 +166,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { // Thread safety private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent) + private let queueSpecificKey = DispatchSpecificKey() // Pending-save coalescing flag. Reads/writes are serialized on `queue`. // @@ -225,6 +234,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { self.encryptionKey = loadedKey self.encryptionKeyIsEphemeral = keyIsEphemeral + queue.setSpecific(key: queueSpecificKey, value: 1) // Only read the persisted cache when we hold the real key; with an // ephemeral key the decrypt would fail and discard the real cache. @@ -432,6 +442,66 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { return cache.cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) } } } + + // MARK: - Private-media downgrade protection + + func markPrivateMediaCapable(fingerprint: String) { + guard !fingerprint.isEmpty else { return } + let insertAndPersist = { + var pinned = self.cache.privateMediaCapableFingerprints ?? [] + guard pinned.insert(fingerprint).inserted else { return } + self.cache.privateMediaCapableFingerprints = pinned + self.saveIdentityCache() + } + // Downgrade decisions can run immediately after an authenticated + // announce. Make the pin visible before returning; merely enqueueing a + // barrier leaves a cross-queue window where a replay can look legacy. + // The queue-specific fast path prevents self-deadlock if a future + // identity-state mutation records the capability from inside `queue`. + if DispatchQueue.getSpecific(key: queueSpecificKey) != nil { + insertAndPersist() + } else { + queue.sync(flags: .barrier, execute: insertAndPersist) + } + } + + func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { + guard !fingerprint.isEmpty else { return false } + return queue.sync { + 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.cache.cryptographicIdentities[fingerprint] { + cryptoIdentity.signingPublicKey = signingPublicKey + self.cache.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.sync(flags: .barrier) { diff --git a/bitchat/Models/NoisePayload.swift b/bitchat/Models/NoisePayload.swift index 9d88a991..2d2f3c67 100644 --- a/bitchat/Models/NoisePayload.swift +++ b/bitchat/Models/NoisePayload.swift @@ -30,7 +30,7 @@ struct NoisePayload { // Safely get the first byte let firstByte = data[data.startIndex] - guard let type = NoisePayloadType(rawValue: firstByte) else { + guard let type = NoisePayloadType.decoded(rawValue: firstByte) else { return nil } diff --git a/bitchat/Noise/NoiseSecurityConstants.swift b/bitchat/Noise/NoiseSecurityConstants.swift index 70b63eba..b19ff854 100644 --- a/bitchat/Noise/NoiseSecurityConstants.swift +++ b/bitchat/Noise/NoiseSecurityConstants.swift @@ -6,11 +6,30 @@ // For more information, see // +import BitFoundation import Foundation enum NoiseSecurityConstants { // Maximum message size to prevent memory exhaustion static let maxMessageSize = 65535 // 64KB as per Noise spec + + /// The extracted transport nonce (4 bytes) and Poly1305 tag (16 bytes) + /// added by `NoiseCipherState` around every transport plaintext. + static let transportCiphertextOverhead = 20 + + /// Private files are an explicit BitChat extension to the ordinary Noise + /// message-size ceiling. They remain bounded by the same framed-file cap + /// used by the binary and fragment decoders. Only the `.privateFile` + /// typed-payload path is allowed to use this larger budget. + private static let privateFileOuterPacketOverhead = + (BinaryProtocol.v1HeaderSize + 2) // v2 adds two length bytes + + BinaryProtocol.senderIDSize + + BinaryProtocol.recipientIDSize + static let maxPrivateFilePlaintextSize = FileTransferLimits.maxFramedFileBytes + - privateFileOuterPacketOverhead + - transportCiphertextOverhead + static let maxPrivateFileCiphertextSize = + maxPrivateFilePlaintextSize + transportCiphertextOverhead // Maximum handshake message size static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern diff --git a/bitchat/Noise/NoiseSecurityValidator.swift b/bitchat/Noise/NoiseSecurityValidator.swift index 355d8fd7..e9028199 100644 --- a/bitchat/Noise/NoiseSecurityValidator.swift +++ b/bitchat/Noise/NoiseSecurityValidator.swift @@ -14,6 +14,19 @@ struct NoiseSecurityValidator { static func validateMessageSize(_ data: Data) -> Bool { return data.count <= NoiseSecurityConstants.maxMessageSize } + + static func validateCiphertextSize(_ data: Data) -> Bool { + data.count <= NoiseSecurityConstants.maxMessageSize + + NoiseSecurityConstants.transportCiphertextOverhead + } + + static func validatePrivateFileMessageSize(_ data: Data) -> Bool { + data.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize + } + + static func validatePrivateFileCiphertextSize(_ data: Data) -> Bool { + data.count <= NoiseSecurityConstants.maxPrivateFileCiphertextSize + } /// Validate handshake message size static func validateHandshakeMessageSize(_ data: Data) -> Bool { diff --git a/bitchat/Noise/NoiseSessionManager.swift b/bitchat/Noise/NoiseSessionManager.swift index ca2d8217..a6279bce 100644 --- a/bitchat/Noise/NoiseSessionManager.swift +++ b/bitchat/Noise/NoiseSessionManager.swift @@ -18,6 +18,10 @@ struct NoiseHandshakeProcessingResult { final class NoiseSessionManager { private var sessions: [PeerID: NoiseSession] = [:] + /// Opaque identity for each exact entry in `sessions`. The generation is + /// created and removed under the same barrier as the session itself, so a + /// caller can never authenticate data with one session and lease another. + private var sessionGenerations: [PeerID: UUID] = [:] /// A responder rehandshake must not evict a working transport session /// before the candidate proves that its authenticated static key belongs /// to the claimed wire ID. Candidates therefore live outside `sessions` @@ -27,7 +31,7 @@ final class NoiseSessionManager { private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent) // Callbacks - var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)? + var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey, UUID) -> Void)? var onSessionFailed: ((PeerID, Error) -> Void)? init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) { @@ -64,6 +68,7 @@ final class NoiseSessionManager { if let session = sessions.removeValue(forKey: peerID) { session.reset() // Clear sensitive data before removing } + sessionGenerations.removeValue(forKey: peerID) if let candidate = responderCandidates.removeValue(forKey: peerID) { candidate.reset() } @@ -79,6 +84,7 @@ final class NoiseSessionManager { candidate.reset() } sessions.removeAll() + sessionGenerations.removeAll() responderCandidates.removeAll() } } @@ -96,12 +102,14 @@ final class NoiseSessionManager { // Remove any existing non-established session if let existingSession = sessions[peerID], !existingSession.isEstablished() { _ = sessions.removeValue(forKey: peerID) + sessionGenerations.removeValue(forKey: peerID) existingSession.reset() } // Create new initiator session let session = sessionFactory(peerID, .initiator) sessions[peerID] = session + sessionGenerations[peerID] = UUID() do { let handshakeData = try session.startHandshake() @@ -109,6 +117,7 @@ final class NoiseSessionManager { } catch { // Clean up failed session _ = sessions.removeValue(forKey: peerID) + sessionGenerations.removeValue(forKey: peerID) session.reset() SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription)) throw error @@ -132,8 +141,17 @@ final class NoiseSessionManager { from peerID: PeerID, message: Data ) throws -> NoiseHandshakeProcessingResult { - // Process everything within the synchronized block to prevent race conditions - return try managerQueue.sync(flags: .barrier) { + // Process everything within the synchronized block to prevent race conditions. + // Return establishment metadata and publish the callback only after the + // manager barrier is released, avoiding both a deadlock and a window in + // which `processHandshakeMessage` returns before authentication state. + let result: ( + response: Data?, + establishedSession: ( + remoteKey: Curve25519.KeyAgreement.PublicKey, + generation: UUID + )? + ) = try managerQueue.sync(flags: .barrier) { let session: NoiseSession let isReplacementCandidate: Bool @@ -164,9 +182,11 @@ final class NoiseSessionManager { // No established transport state exists to preserve. A // fresh initiation replaces the incomplete handshake. _ = sessions.removeValue(forKey: peerID) + sessionGenerations.removeValue(forKey: peerID) existing.reset() let replacement = sessionFactory(peerID, .responder) sessions[peerID] = replacement + sessionGenerations[peerID] = UUID() session = replacement isReplacementCandidate = false } else { @@ -176,6 +196,7 @@ final class NoiseSessionManager { } else { let newSession = sessionFactory(peerID, .responder) sessions[peerID] = newSession + sessionGenerations[peerID] = UUID() session = newSession isReplacementCandidate = false } @@ -187,8 +208,11 @@ final class NoiseSessionManager { // Check the exact session that processed this message. A // preserved peer-level session can remain established while a // replacement candidate is still unauthenticated. - let didEstablishAuthenticatedSession = session.isEstablished() - if didEstablishAuthenticatedSession { + var establishedSession: ( + remoteKey: Curve25519.KeyAgreement.PublicKey, + generation: UUID + )? + if session.isEstablished() { guard let remoteKey = session.getRemoteStaticPublicKey(), authenticatedRemoteKey(remoteKey, matches: peerID) else { throw NoiseSessionError.peerIdentityMismatch @@ -197,22 +221,18 @@ final class NoiseSessionManager { if isReplacementCandidate { _ = responderCandidates.removeValue(forKey: peerID) let previous = sessions.updateValue(session, forKey: peerID) + sessionGenerations[peerID] = UUID() if let previous, previous !== session { previous.reset() } } - - // Schedule callback outside the synchronized block to prevent deadlock - DispatchQueue.global().async { [weak self] in - self?.onSessionEstablished?(peerID, remoteKey) + guard let generation = sessionGenerations[peerID] else { + throw NoiseEncryptionError.sessionNotEstablished } + establishedSession = (remoteKey, generation) } - return NoiseHandshakeProcessingResult( - response: response, - didEstablishAuthenticatedSession: - didEstablishAuthenticatedSession - ) + return (response, establishedSession) } catch { // A failed candidate is discarded without touching the // established session. Ordinary failed handshakes retain the @@ -225,6 +245,7 @@ final class NoiseSessionManager { } else if let storedSession = sessions[peerID], storedSession === session { _ = sessions.removeValue(forKey: peerID) + sessionGenerations.removeValue(forKey: peerID) } session.reset() @@ -237,6 +258,14 @@ final class NoiseSessionManager { throw error } } + + if let established = result.establishedSession { + onSessionEstablished?(peerID, established.remoteKey, established.generation) + } + return NoiseHandshakeProcessingResult( + response: result.response, + didEstablishAuthenticatedSession: result.establishedSession != nil + ) } /// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are @@ -260,19 +289,74 @@ final class NoiseSessionManager { // MARK: - Encryption/Decryption func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data { - guard let session = getSession(for: peerID) else { - throw NoiseSessionError.sessionNotFound + try managerQueue.sync { + guard let session = sessions[peerID] else { + throw NoiseSessionError.sessionNotFound + } + return try session.encrypt(plaintext) + } + } + + /// Encrypts only if `expected` still names the current established entry. + /// A rekey between capability proof and media encryption therefore fails + /// closed instead of sending on an unproven replacement session. + func encrypt( + _ plaintext: Data, + for peerID: PeerID, + expectedSessionGeneration expected: UUID + ) throws -> Data { + try managerQueue.sync { + guard let session = sessions[peerID], + session.isEstablished(), + sessionGenerations[peerID] == expected else { + throw NoiseEncryptionError.sessionNotEstablished + } + return try session.encrypt(plaintext) } - - return try session.encrypt(plaintext) } func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data { - guard let session = getSession(for: peerID) else { - throw NoiseSessionError.sessionNotFound + try decryptWithSessionGeneration(ciphertext, from: peerID).plaintext + } + + func sessionGeneration(for peerID: PeerID) -> UUID? { + managerQueue.sync { + guard sessions[peerID]?.isEstablished() == true else { return nil } + return sessionGenerations[peerID] + } + } + + /// Decrypts while holding the manager's read lease. Session promotion and + /// removal require its barrier, so the returned generation always names + /// the exact session object that authenticated these bytes. + func decryptWithSessionGeneration( + _ ciphertext: Data, + from peerID: PeerID + ) throws -> (plaintext: Data, sessionGeneration: UUID) { + try managerQueue.sync { + guard let session = sessions[peerID] else { + throw NoiseSessionError.sessionNotFound + } + guard session.isEstablished(), + let generation = sessionGenerations[peerID] else { + throw NoiseEncryptionError.sessionNotEstablished + } + return (try session.decrypt(ciphertext), generation) + } + } + + /// Runs a state commit under a read lease for the exact established + /// session. Rekey, replacement, and removal all need the same barrier. + func withCurrentSessionGeneration( + for peerID: PeerID, + expected: UUID, + _ body: () -> Result + ) -> Result? { + managerQueue.sync { + guard sessions[peerID]?.isEstablished() == true, + sessionGenerations[peerID] == expected else { return nil } + return body() } - - return try session.decrypt(ciphertext) } // MARK: - Key Management @@ -299,11 +383,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/Noise/SecureNoiseSession.swift b/bitchat/Noise/SecureNoiseSession.swift index 0204b108..8f95842c 100644 --- a/bitchat/Noise/SecureNoiseSession.swift +++ b/bitchat/Noise/SecureNoiseSession.swift @@ -24,8 +24,12 @@ final class SecureNoiseSession: NoiseSession { throw NoiseSecurityError.sessionExhausted } - // Validate message size - guard NoiseSecurityValidator.validateMessageSize(plaintext) else { + // Ordinary Noise messages keep the protocol ceiling. Finalized media + // is the sole typed-payload extension and remains under the framed-file + // cap enforced again at the service and file-decoder layers. + let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: plaintext.first) + && NoiseSecurityValidator.validatePrivateFileMessageSize(plaintext) + guard NoiseSecurityValidator.validateMessageSize(plaintext) || isPrivateFile else { throw NoiseSecurityError.messageTooLarge } @@ -42,8 +46,11 @@ final class SecureNoiseSession: NoiseSession { throw NoiseSecurityError.sessionExpired } - // Validate message size - guard NoiseSecurityValidator.validateMessageSize(ciphertext) else { + // The payload type is encrypted, so a large candidate can only be + // bounded here; `NoiseEncryptionService.decrypt` authenticates it and + // then requires the resulting type to be `.privateFile`. + guard NoiseSecurityValidator.validateCiphertextSize(ciphertext) + || NoiseSecurityValidator.validatePrivateFileCiphertextSize(ciphertext) else { throw NoiseSecurityError.messageTooLarge } diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 6c259f5f..b38ea251 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -79,12 +79,35 @@ enum NoisePayloadType: UInt8 { case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update) // Live voice (push-to-talk) case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket) + // Finalized private media. `0x20` is the value already deployed by the + // 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 // Transitive verification (web of trust) case vouch = 0x12 // Batch of vouch attestations + /// #1434 briefly used 0x09 before release. Accept it while prerelease + /// builds age out, but never emit it. Decoders canonicalize both values to + /// `.privateFile` so the compatibility alias cannot leak into app logic. + static let prereleasePrivateFileRawValue: UInt8 = 0x09 + + static func decoded(rawValue: UInt8) -> NoisePayloadType? { + rawValue == prereleasePrivateFileRawValue ? .privateFile : Self(rawValue: rawValue) + } + + static func isPrivateFile(rawValue: UInt8?) -> Bool { + guard let rawValue else { return false } + return rawValue == privateFile.rawValue || rawValue == prereleasePrivateFileRawValue + } + var description: String { switch self { case .privateMessage: return "privateMessage" @@ -93,6 +116,8 @@ enum NoisePayloadType: UInt8 { case .groupInvite: return "groupInvite" 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/Protocols/PeerCapabilities+Local.swift b/bitchat/Protocols/PeerCapabilities+Local.swift index 819464b9..8cae9798 100644 --- a/bitchat/Protocols/PeerCapabilities+Local.swift +++ b/bitchat/Protocols/PeerCapabilities+Local.swift @@ -3,5 +3,5 @@ import BitFoundation extension PeerCapabilities { /// Capabilities this build advertises in its announce packets. /// Each feature adds its bit here when it ships. - static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups] + static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups, .privateMedia] } diff --git a/bitchat/Services/BLE/BLEAnnounceHandler.swift b/bitchat/Services/BLE/BLEAnnounceHandler.swift index 10b27f42..08b603a4 100644 --- a/bitchat/Services/BLE/BLEAnnounceHandler.swift +++ b/bitchat/Services/BLE/BLEAnnounceHandler.swift @@ -22,6 +22,9 @@ struct BLEAnnounceHandlerEnvironment { /// eviction; this fallback keeps the TOFU signing-key pin effective for /// returning peers. let persistedSigningPublicKey: (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). @@ -149,6 +152,9 @@ final class BLEAnnounceHandler { existingNoisePublicKey: existingPeerKeys.noisePublicKey, announcedNoisePublicKey: announcement.noisePublicKey, existingSigningPublicKey: existingPeerKeys.signingPublicKey, + authenticatedSigningPublicKey: env.authenticatedSigningPublicKey( + announcement.noisePublicKey + ), announcedSigningPublicKey: announcement.signingPublicKey ) if case .reject(.keyMismatch) = trustDecision { @@ -157,6 +163,12 @@ final class BLEAnnounceHandler { if case .reject(.signingKeyMismatch) = trustDecision { SecureLogger.warning("🚨 Announce signing-key mismatch for \(peerID.id.prefix(8))… β€” refusing to replace pinned signing key (possible impersonation attempt)", category: .security) } + if case .reject(.authenticatedSigningKeyMismatch) = trustDecision { + SecureLogger.warning( + "⚠️ Announce signing-key replacement rejected for Noise-authenticated peer \(peerID.id.prefix(8))…", + category: .security + ) + } var verifiedAnnounce = trustDecision.isVerified var isNewPeer = false diff --git a/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift b/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift index 03c80325..67712046 100644 --- a/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift +++ b/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift @@ -57,6 +57,7 @@ enum BLEAnnounceTrustRejection: Equatable { case invalidSignature case keyMismatch case signingKeyMismatch + case authenticatedSigningKeyMismatch } enum BLEAnnounceTrustDecision: Equatable { @@ -74,13 +75,22 @@ enum BLEAnnounceTrustPolicy { signatureValid: Bool, existingNoisePublicKey: Data?, announcedNoisePublicKey: Data, - existingSigningPublicKey: Data?, + existingSigningPublicKey: Data? = nil, + authenticatedSigningPublicKey: Data? = nil, announcedSigningPublicKey: Data ) -> BLEAnnounceTrustDecision { if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey { return .reject(.keyMismatch) } + // Strongest binding first: an Ed25519 key bound to this Noise identity + // inside an authenticated Noise session can never be replaced by a + // merely self-signed announce. + if let authenticatedSigningPublicKey, + announcedSigningPublicKey != authenticatedSigningPublicKey { + return .reject(.authenticatedSigningKeyMismatch) + } + // TOFU signing-key pinning. The packet signature only proves the // announce is self-consistent β€” it is verified against the Ed25519 key // carried *inside the same announce*. Since peerIDs derive from the diff --git a/bitchat/Services/BLE/BLEFileTransferHandler.swift b/bitchat/Services/BLE/BLEFileTransferHandler.swift index 015a1967..e254e3b1 100644 --- a/bitchat/Services/BLE/BLEFileTransferHandler.swift +++ b/bitchat/Services/BLE/BLEFileTransferHandler.swift @@ -16,6 +16,8 @@ struct BLEFileTransferHandlerEnvironment { let peersSnapshot: () -> [PeerID: BLEPeerInfo] /// Verifies a packet's signature against a candidate signing key (registry path). let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool + /// Local signing key used to authenticate our own gossip-sync replays. + let localSigningPublicKey: () -> Data /// Resolves a display name from a verified packet signature for peers missing from the registry. let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String? /// Tracks the broadcast file packet for gossip sync. @@ -46,54 +48,105 @@ final class BLEFileTransferHandler { self.environment = environment } - /// Returns `false` when the packet fails sender authentication and must - /// not be relayed onward. Every other outcome returns `true`: files - /// directed to another peer are forwarded untouched, and local-only drops - /// (malformed payload, quota, save failure) don't affect multi-hop - /// delivery to nodes that may handle them fine. + /// Returns `false` when the raw packet fails sender authentication (or is + /// a live self-echo) and must not be relayed onward. Authentication runs + /// before the routing decision, so a forged directed packet cannot use a + /// node that is not its recipient as an unsigned forwarding hop. @discardableResult func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { let env = environment - if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true } - - guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else { - return true - } - + let localPeerID = env.localPeerID() let peersSnapshot = env.peersSnapshot() - guard let senderNickname = resolveSenderNickname( + + guard let senderNickname = authenticatedRawSenderNickname( packet: packet, from: peerID, - isBroadcast: !deliveryPlan.isPrivateMessage, peers: peersSnapshot, env: env ) else { - SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security) + SecureLogger.warning("🚫 Dropping raw file transfer with missing/invalid signature from \(peerID.id.prefix(8))…", category: .security) return false } + if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: localPeerID) { + return false + } + + guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: localPeerID) else { + return true + } + if deliveryPlan.shouldTrackForSync { env.trackPacketSeen(packet) } + _ = storeIncomingPayload( + packet.payload, + from: peerID, + senderNickname: senderNickname, + timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000), + isPrivate: deliveryPlan.isPrivateMessage, + env: env + ) + // Once authenticated, a local decode/quota/save failure is not proof + // that downstream nodes should be denied the valid signed packet. + return true + } + + /// Accepts a file packet only after it has been authenticated and + /// decrypted by the peer's Noise session. The inner packet deliberately + /// has no redundant signature: Noise supplies sender authentication and + /// confidentiality, while this handler retains the same validation, + /// quota, persistence, and UI-delivery behavior as public files. + @discardableResult + func handlePrivatePayload(_ payload: Data, from peerID: PeerID, timestamp: Date) -> Bool { + let env = environment + let peers = env.peersSnapshot() + let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer( + peerID: peerID, + localPeerID: env.localPeerID(), + localNickname: env.localNickname(), + peers: peers, + allowConnectedUnverified: true + ) ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID) + + return storeIncomingPayload( + payload, + from: peerID, + senderNickname: senderNickname, + timestamp: timestamp, + isPrivate: true, + env: env + ) + } + + private func storeIncomingPayload( + _ payload: Data, + from peerID: PeerID, + senderNickname: String, + timestamp: Date, + isPrivate: Bool, + env: BLEFileTransferHandlerEnvironment + ) -> Bool { + let filePacket: BitchatFilePacket let mime: MimeType - switch BLEIncomingFileValidator.validate(payload: packet.payload) { + switch BLEIncomingFileValidator.validate(payload: payload) { case .success(let acceptance): filePacket = acceptance.filePacket mime = acceptance.mime case .failure(.malformedPayload): SecureLogger.error("❌ Failed to decode file transfer payload", category: .session) - return true + return false case .failure(.payloadTooLarge(let bytes)): SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security) - return true + return false case .failure(.unsupportedMime(let mimeType, let bytes)): SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security) - return true + return false case .failure(.magicMismatch(let mime, let bytes, let prefixHex)): SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security) - return true + return false } // BCH-01-002: Enforce storage quota before saving @@ -106,28 +159,27 @@ final class BLEFileTransferHandler { mime.defaultExtension, mime.category.rawValue ) else { - return true + return false } - if deliveryPlan.isPrivateMessage { + if isPrivate { env.updatePeerLastSeen(peerID) } - let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) let message = BitchatMessage( sender: senderNickname, content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)", - timestamp: ts, + timestamp: timestamp, isRelay: false, originalSender: nil, - isPrivate: deliveryPlan.isPrivateMessage, + isPrivate: isPrivate, recipientNickname: nil, senderPeerID: peerID, // Received messages need an explicit status: BitchatMessage // defaults private messages to .sending, which the media views // render as an in-flight send (empty reveal mask, disabled tap). - deliveryStatus: deliveryPlan.isPrivateMessage - ? .delivered(to: env.localNickname(), at: ts) + deliveryStatus: isPrivate + ? .delivered(to: env.localNickname(), at: timestamp) : nil ) @@ -137,51 +189,38 @@ final class BLEFileTransferHandler { return true } - /// Resolves the authenticated display name for a file transfer's sender. - /// - /// Directed (private) transfers are addressed to us specifically and keep - /// the lenient connected-peer path. Broadcast transfers carry an - /// attacker-controllable `senderID` exactly like public messages and public - /// voice frames β€” registry membership alone is NOT proof of identity, so a - /// valid packet signature from the claimed sender is required before we - /// trust it. Without this, a peer that observed a public voice burst could - /// spoof a broadcast `voice_.m4a` note under the talker's ID and - /// overwrite the signature-verified live bubble with attacker audio. - private func resolveSenderNickname( + /// Every remaining raw file transfer is signed, regardless of whether it + /// is broadcast, addressed to us, or merely passing through. Registry + /// signing keys are preferred; persisted identities cover peers that have + /// rotated or are not currently present in the registry. + private func authenticatedRawSenderNickname( packet: BitchatPacket, from peerID: PeerID, - isBroadcast: Bool, peers: [PeerID: BLEPeerInfo], env: BLEFileTransferHandlerEnvironment ) -> String? { - guard isBroadcast else { - return BLEPeerSenderDisplayName.resolveKnownPeer( - peerID: peerID, - localPeerID: env.localPeerID(), - localNickname: env.localNickname(), - peers: peers, - allowConnectedUnverified: true - ) ?? env.signedSenderDisplayName(packet, peerID) - } + guard packet.signature != nil else { return nil } - // Our own broadcasts replayed back via gossip sync (ttl==0) are - // trivially authentic and cannot be verified against the peer registry - // or identity cache, so exempt self exactly as `BLEPublicMessageHandler` - // does. Verify against the signing key already in the - // (synchronously-updated) registry first, then fall back to the - // persisted-identity signature lookup for peers not yet cached there. - let isSelf = peerID == env.localPeerID() - let registrySigningKey = peers[peerID]?.signingPublicKey - let verifiedViaRegistry = !isSelf && (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false) - let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID) - guard isSelf || verifiedViaRegistry || signedDisplayName != nil else { return nil } + let localPeerID = env.localPeerID() + let candidateKey = peerID == localPeerID + ? env.localSigningPublicKey() + : peers[peerID]?.signingPublicKey + let verifiedWithKnownKey = candidateKey.map { + env.verifyPacketSignature(packet, $0) + } ?? false + let signedDisplayName = verifiedWithKnownKey + ? nil + : env.signedSenderDisplayName(packet, peerID) + guard verifiedWithKnownKey || signedDisplayName != nil else { return nil } return BLEPeerSenderDisplayName.resolveKnownPeer( peerID: peerID, - localPeerID: env.localPeerID(), + localPeerID: localPeerID, localNickname: env.localNickname(), peers: peers, - allowConnectedUnverified: false - ) ?? signedDisplayName + // The packet signature authenticates the announced peer; the old + // connected-but-unsigned leniency is not involved. + allowConnectedUnverified: true + ) ?? signedDisplayName ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID) } } diff --git a/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift b/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift index 9550e196..31656cb4 100644 --- a/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift +++ b/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift @@ -201,8 +201,11 @@ struct BLEFragmentAssemblyBuffer { } private static func assemblyLimit(for originalType: UInt8) -> Int { - if originalType == MessageType.fileTransfer.rawValue { + if originalType == MessageType.fileTransfer.rawValue + || originalType == MessageType.noiseEncrypted.rawValue { // Allow headroom for TLV metadata and binary framing overhead. + // A large noiseEncrypted packet can be an E2E-encrypted private + // file; its authenticated plaintext is validated after decrypt. return FileTransferLimits.maxFramedFileBytes } diff --git a/bitchat/Services/BLE/BLENoisePacketHandler.swift b/bitchat/Services/BLE/BLENoisePacketHandler.swift index 674a7bd5..736632e9 100644 --- a/bitchat/Services/BLE/BLENoisePacketHandler.swift +++ b/bitchat/Services/BLE/BLENoisePacketHandler.swift @@ -7,6 +7,11 @@ struct BLENoiseHandshakeHandlingResult { let didEstablishAuthenticatedSession: Bool } +struct BLENoiseDecryptionResult { + let plaintext: Data + let sessionGeneration: UUID +} + /// Narrow environment for `BLENoisePacketHandler`. /// /// All queue hops (collections barrier writes, main-actor UI notification) @@ -35,9 +40,16 @@ struct BLENoisePacketHandlerEnvironment { /// Updates the registry last-seen timestamp for the peer (async barrier write). let updatePeerLastSeen: (PeerID) -> Void /// Decrypts an encrypted payload from the peer (crypto). - let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> Data + let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> BLENoiseDecryptionResult /// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto). let clearSession: (PeerID) -> Void + /// Consumes session-authenticated protocol state inside the transport. It + /// must never escape to UI or Nostr payload dispatch. + let handleAuthenticatedPeerState: ( + _ peerID: PeerID, + _ payload: Data, + _ sessionGeneration: UUID + ) -> Void /// Delivers `.noisePayloadReceived` to the UI as one main-actor hop. let deliverNoisePayload: ( _ peerID: PeerID, @@ -146,20 +158,30 @@ final class BLENoisePacketHandler { env.updatePeerLastSeen(peerID) do { - let decrypted = try env.decrypt(packet.payload, peerID) + let decryption = try env.decrypt(packet.payload, peerID) + let decrypted = decryption.plaintext guard decrypted.count > 0 else { return } // First byte indicates the payload type let payloadType = decrypted[0] let payloadData = decrypted.dropFirst() - guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else { + guard let noisePayloadType = NoisePayloadType.decoded(rawValue: payloadType) else { SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)") return } SecureLogger.debug("πŸ” Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))…", category: .session) + if noisePayloadType == .authenticatedPeerState { + env.handleAuthenticatedPeerState( + peerID, + Data(payloadData), + decryption.sessionGeneration + ) + 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 0aac077f..a414487b 100644 --- a/bitchat/Services/BLE/BLENoisePayloadFactory.swift +++ b/bitchat/Services/BLE/BLENoisePayloadFactory.swift @@ -17,6 +17,16 @@ enum BLENoisePayloadFactory { typedPayload(.delivered, payload: Data(messageID.utf8)) } + static func privateFile(_ filePacket: BitchatFilePacket) -> Data? { + guard let payload = filePacket.encode() else { return nil } + 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/BLENoiseSessionQueues.swift b/bitchat/Services/BLE/BLENoiseSessionQueues.swift index 84eeaba8..301e6bce 100644 --- a/bitchat/Services/BLE/BLENoiseSessionQueues.swift +++ b/bitchat/Services/BLE/BLENoiseSessionQueues.swift @@ -6,9 +6,16 @@ struct BLEPendingPrivateMessage: Equatable { let messageID: String } +struct BLEPendingTypedPayload: Equatable { + let payload: Data + /// Present for app-initiated media so handshake queuing preserves the + /// fragment scheduler's progress/cancellation identity. + let transferId: String? +} + struct BLENoiseSessionQueues { private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:] - private var typedPayloadsByPeerID: [PeerID: [Data]] = [:] + private var typedPayloadsByPeerID: [PeerID: [BLEPendingTypedPayload]] = [:] var isEmpty: Bool { privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty @@ -34,13 +41,35 @@ struct BLENoiseSessionQueues { privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0) } - mutating func appendTypedPayload(_ payload: Data, for peerID: PeerID) { - typedPayloadsByPeerID[peerID, default: []].append(payload) + mutating func appendTypedPayload(_ payload: Data, transferId: String? = nil, for peerID: PeerID) { + typedPayloadsByPeerID[peerID, default: []].append( + BLEPendingTypedPayload(payload: payload, transferId: transferId) + ) } - mutating func takeTypedPayloads(for peerID: PeerID) -> [Data] { + mutating func takeTypedPayloads(for peerID: PeerID) -> [BLEPendingTypedPayload] { let payloads = typedPayloadsByPeerID[peerID] ?? [] typedPayloadsByPeerID.removeValue(forKey: peerID) return payloads } + + func containsTypedPayload(transferId: String) -> Bool { + typedPayloadsByPeerID.values.contains { payloads in + payloads.contains { $0.transferId == transferId } + } + } + + @discardableResult + mutating func removeTypedPayload(transferId: String) -> Bool { + for peerID in Array(typedPayloadsByPeerID.keys) { + guard var payloads = typedPayloadsByPeerID[peerID], + let index = payloads.firstIndex(where: { $0.transferId == transferId }) else { + continue + } + payloads.remove(at: index) + typedPayloadsByPeerID[peerID] = payloads.isEmpty ? nil : payloads + return true + } + return false + } } diff --git a/bitchat/Services/BLE/BLEOutboundFragmentPlanner.swift b/bitchat/Services/BLE/BLEOutboundFragmentPlanner.swift index 9623c204..09a65616 100644 --- a/bitchat/Services/BLE/BLEOutboundFragmentPlanner.swift +++ b/bitchat/Services/BLE/BLEOutboundFragmentPlanner.swift @@ -17,6 +17,9 @@ struct BLEOutboundFragmentPlan { } enum BLEOutboundFragmentPlanner { + /// Current Android receivers reject fragment sets above 256. Private + /// media v1 treats that deployed ceiling as a cross-platform contract. + static let privateMediaV1MaxFragments = 256 private static let minimumChunkSize = 64 private static let fragmentIDLength = 8 @@ -71,6 +74,10 @@ enum BLEOutboundFragmentPlanner { ) } + static func isPrivateMediaV1Compatible(_ plan: BLEOutboundFragmentPlan) -> Bool { + plan.totalFragments <= privateMediaV1MaxFragments + } + private static func sizingPolicy( for packet: BitchatPacket, requestedMaxChunk: Int?, diff --git a/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift b/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift index 882e4978..722e55c4 100644 --- a/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift +++ b/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift @@ -29,8 +29,9 @@ struct BLEOutboundFragmentTransferRequest { } var resolvedTransferId: String? { + if let transferId { return transferId } guard packet.type == MessageType.fileTransfer.rawValue else { return nil } - return transferId ?? packet.payload.sha256Hex() + return packet.payload.sha256Hex() } /// Content identity independent of the caller-chosen transfer ID: the diff --git a/bitchat/Services/BLE/BLEPeerRegistry.swift b/bitchat/Services/BLE/BLEPeerRegistry.swift index 80a13831..679b419e 100644 --- a/bitchat/Services/BLE/BLEPeerRegistry.swift +++ b/bitchat/Services/BLE/BLEPeerRegistry.swift @@ -10,6 +10,9 @@ struct BLEPeerInfo: Equatable { var isVerifiedNickname: Bool var lastSeen: Date var capabilities: PeerCapabilities = [] + /// Distinguishes an old client that omitted the capabilities TLV from a + /// modern client that explicitly advertised a set without a given bit. + var capabilitiesWereExplicitlyAdvertised: Bool = false /// Rendezvous cell from the peer's announce when it advertises `.bridge`. var bridgeGeohash: String? } @@ -114,6 +117,10 @@ struct BLEPeerRegistry { peers[peerID.toShort()]?.capabilities ?? [] } + func capabilitiesWereExplicitlyAdvertised(for peerID: PeerID) -> Bool { + peers[peerID.toShort()]?.capabilitiesWereExplicitlyAdvertised == true + } + /// Peers whose last verified announce advertised the given capability. func peers(advertising capability: PeerCapabilities) -> [PeerID] { peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID) @@ -174,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 + } + /// Applies a verified announce to the registry. /// /// TOFU signing-key pinning: once a signing key has been bound to this @@ -189,7 +204,7 @@ struct BLEPeerRegistry { signingPublicKey: Data?, isConnected: Bool, now: Date, - capabilities: PeerCapabilities = [], + capabilities: PeerCapabilities? = nil, bridgeGeohash: String? = nil ) -> BLEPeerAnnounceUpdate? { let existing = peers[peerID] @@ -215,7 +230,8 @@ struct BLEPeerRegistry { signingPublicKey: signingPublicKey ?? existing?.signingPublicKey, isVerifiedNickname: true, lastSeen: now, - capabilities: capabilities, + capabilities: capabilities ?? [], + capabilitiesWereExplicitlyAdvertised: capabilities != nil, bridgeGeohash: bridgeGeohash ) diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 0053d879..f246d5cd 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -7,6 +7,205 @@ import Combine import UIKit #endif +/// Linearizes app-private-media admission against cancellation before work is +/// handed to the fragment scheduler. A transfer starts here synchronously, +/// before its `messageQueue` work item is enqueued; cancel/delete can therefore +/// leave a tombstone that the deferred work must observe. +/// +/// Active admissions and cancellation tombstones have independent count +/// bounds. Tombstones may age out or evict older tombstones; active entries +/// are never evicted under pressure. A one-hour active timeout is reported as +/// an explicit transfer failure and removes any handshake-queued payload. +private final class BLEPrivateMediaTransferAdmissionRegistry { + enum BeginResult: Equatable { + case admitted + case alreadyKnown + case capacityExhausted + } + + private enum State: Equatable { + case active + case cancelled + } + + private struct Entry { + var state: State + var updatedAt: Date + } + + private let lock = NSLock() + private let maxActiveEntries = 512 + private let maxCancelledTombstones = 512 + private let lifetime: TimeInterval = 60 * 60 + private let onActiveExpired: (String) -> Void + private var entries: [String: Entry] = [:] + + init(onActiveExpired: @escaping (String) -> Void) { + self.onActiveExpired = onActiveExpired + } + + func begin(_ transferId: String, now: Date = Date()) -> BeginResult { + guard !transferId.isEmpty else { return .alreadyKnown } + lock.lock() + let expiredActive = pruneLocked(now: now) + // Transfer IDs are invocation-unique. Never revive a cancellation or + // admit a duplicate invocation that reused an in-flight identifier. + let result: BeginResult + if entries[transferId] != nil { + result = .alreadyKnown + } else if activeCountLocked >= maxActiveEntries { + // Never evict an admitted transfer: doing so strands its UI + // placeholder with no completion event. Reject the newcomer and + // let the caller surface the bounded-pressure failure instead. + result = .capacityExhausted + } else { + entries[transferId] = Entry(state: .active, updatedAt: now) + result = .admitted + } + lock.unlock() + notifyExpired(expiredActive) + return result + } + + func cancel(_ transferId: String, now: Date = Date()) { + guard !transferId.isEmpty else { return } + lock.lock() + // Cancel the requested active entry before expiry pruning so a user + // cancellation wins over a simultaneous timeout notification. + entries[transferId] = Entry(state: .cancelled, updatedAt: now) + let expiredActive = pruneLocked(now: now) + trimCancelledTombstonesLocked() + lock.unlock() + notifyExpired(expiredActive) + } + + func isActive(_ transferId: String, now: Date = Date()) -> Bool { + lock.lock() + let expiredActive = pruneLocked(now: now) + let active = entries[transferId]?.state == .active + if active { + entries[transferId]?.updatedAt = now + } + lock.unlock() + notifyExpired(expiredActive) + return active + } + + /// Runs `body` while holding the admission lock. Callers use this at the + /// collections-queue append/submit boundary so cancellation and admission + /// have one deterministic order: whichever acquires this lock first wins. + func withActive( + _ transferId: String, + now: Date = Date(), + _ body: () -> Result + ) -> Result? { + lock.lock() + let expiredActive = pruneLocked(now: now) + guard entries[transferId]?.state == .active else { + lock.unlock() + notifyExpired(expiredActive) + return nil + } + entries[transferId]?.updatedAt = now + let result = body() + lock.unlock() + notifyExpired(expiredActive) + return result + } + + func finish(_ transferId: String) { + lock.lock() + entries.removeValue(forKey: transferId) + lock.unlock() + } + + var count: Int { + lock.lock() + let expiredActive = pruneLocked(now: Date()) + let result = entries.count + lock.unlock() + notifyExpired(expiredActive) + return result + } + + func prune(now: Date = Date()) { + lock.lock() + let expiredActive = pruneLocked(now: now) + lock.unlock() + notifyExpired(expiredActive) + } + + private var activeCountLocked: Int { + entries.values.reduce(into: 0) { count, entry in + if entry.state == .active { count += 1 } + } + } + + /// Removes stale tombstones silently and stale active admissions with a + /// caller-visible timeout notification. Must be called with `lock` held; + /// notifications are delivered only after the lock is released. + private func pruneLocked(now: Date) -> [String] { + var expiredActive: [String] = [] + let expiredEntries = entries.filter { + now.timeIntervalSince($0.value.updatedAt) > lifetime + } + for (transferId, entry) in expiredEntries { + if entry.state == .active { + expiredActive.append(transferId) + } + entries.removeValue(forKey: transferId) + } + trimCancelledTombstonesLocked() + return expiredActive + } + + private func trimCancelledTombstonesLocked() { + let cancelled = entries + .filter { $0.value.state == .cancelled } + .sorted { $0.value.updatedAt < $1.value.updatedAt } + let overflow = max(0, cancelled.count - maxCancelledTombstones) + for victim in cancelled.prefix(overflow) { + entries.removeValue(forKey: victim.key) + } + } + + private func notifyExpired(_ transferIds: [String]) { + for transferId in transferIds { + onActiveExpired(transferId) + } + } +} + +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`). @@ -108,6 +307,10 @@ final class BLEService: NSObject { /// before it hands a packet to `messageQueue`. var _test_beforeReceivePacketHandoff: (() -> Void)? var _test_onReceivePacketHandoff: (() -> Void)? + var _test_onPrivateMediaSessionReconciled: ((PeerID) -> Void)? + /// May block in tests to hold the serial message queue immediately before + /// the deferred private-media admission check. + var _test_beforePrivateMediaDeferredSend: ((String) -> Void)? #endif private var selfBroadcastTracker = BLESelfBroadcastTracker() private let meshTopology = MeshTopologyTracker() @@ -136,7 +339,19 @@ final class BLEService: NSObject { // 5. Fragment Reassembly (necessary for messages > MTU) private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer() private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler() - private let incomingFileStore = BLEIncomingFileStore() + 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 private let announceThrottle = BLEAnnounceThrottle() @@ -283,10 +498,12 @@ final class BLEService: NSObject { idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, initializeBluetoothManagers: Bool = true, + incomingFileStore: BLEIncomingFileStore = BLEIncomingFileStore(), startSuspendedForPanicRecovery: Bool = false ) { self.keychain = keychain self.idBridge = idBridge + self.incomingFileStore = incomingFileStore self.shouldInitializeBluetoothManagers = initializeBluetoothManagers self._isPanicSuspended = startSuspendedForPanicRecovery noiseService = NoiseEncryptionService(keychain: keychain) @@ -518,7 +735,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() @@ -527,12 +744,20 @@ final class BLEService: NSObject { ingressLinks.removeAll() recentTrafficTracker.removeAll() scheduledRelays.cancelAll() + // These callbacks belong to pre-panic transfer state. Invoking + // them would let queued UI work recreate or resend wiped media. + 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 } - for entry in cancelledTransfers { + for entry in panicReset { entry.workItems.forEach { $0.cancel() } TransferProgressManager.shared.cancel(id: entry.id) } @@ -881,6 +1106,225 @@ final class BLEService: NSObject { collectionsQueue.sync { peerRegistry.capabilities(for: peerID) } } + func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { + let normalizedPeerID = peerID.toShort() + let state: ( + capabilities: PeerCapabilities, + fingerprint: String?, + sessionGeneration: UUID?, + authenticatedState: BLEAuthenticatedPeerStateObservation?, + timedOut: BLEPrivateMediaProofTimeoutMarker? + ) = collectionsQueue.sync { + let info = peerRegistry.info(for: normalizedPeerID) + return ( + info?.capabilities ?? [], + info?.noisePublicKey?.sha256Fingerprint(), + privateMediaSessionGenerations[normalizedPeerID], + authenticatedPeerStates[normalizedPeerID], + privateMediaProofTimeoutMarkers[normalizedPeerID] + ) + } + let currentNoiseGeneration = noiseService.sessionGeneration(for: normalizedPeerID) + + // A session replacement can happen before its authentication callback + // reaches messageQueue. Never reuse an observation from the previous + // transport generation during that window. + if state.sessionGeneration != currentNoiseGeneration { + return .awaitingCapabilityProof + } + + guard let fingerprint = state.fingerprint else { + // 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. Without that key neither proof nor safe migration state + // can be attributed. + return .blockedDowngrade + } + + 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 + } + + func resolvePrivateMediaSendPolicy( + to peerID: PeerID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void + ) { + let normalizedPeerID = peerID.toShort() + 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 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 + ) + } + + if !self.noiseService.hasEstablishedSession(with: normalizedPeerID) { + self.initiateNoiseHandshake(with: normalizedPeerID) + } + } + } + + private func completePrivateMediaPolicyResolution( + _ completions: [@MainActor (PrivateMediaSendPolicy) -> Void], + with policy: PrivateMediaSendPolicy + ) { + guard !completions.isEmpty else { return } + notifyUI { + 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 /// internet-gateway toggle) and re-announces so peers learn promptly. /// Build-time bits stay in `PeerCapabilities.localSupported`. @@ -1008,7 +1452,28 @@ final class BLEService: NSObject { // MARK: Messaging + private func handlePrivateMediaAdmissionExpiry(_ transferId: String) { + // Expiry can be discovered from the BLE maintenance queue or while a + // caller already owns collectionsQueue. Cleanup is therefore + // fire-and-forget; never synchronously re-enter the collections lock. + collectionsQueue.async(flags: .barrier) { [weak self] in + _ = self?.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId) + } + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String( + localized: "content.delivery.reason.private_media_admission_expired", + defaultValue: "Media transfer timed out before it could start", + comment: "Failure reason when private-media admission expires before fragment scheduling" + ) + ) + } + func cancelTransfer(_ transferId: String) { + // Cancellation must become visible synchronously. Scheduler/pending- + // Noise cleanup remains asynchronous, but deferred private-media work + // cannot pass another admission boundary after this returns. + privateMediaTransferAdmissions.cancel(transferId) collectionsQueue.async(flags: .barrier) { [weak self] in guard let self = self else { return } @@ -1026,7 +1491,9 @@ final class BLEService: NSObject { SecureLogger.debug("πŸ›‘ Removed pending transfer \(id.prefix(8))… before start", category: .session) case .missing: - break + if self.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId) { + SecureLogger.debug("πŸ›‘ Removed handshake-queued transfer \(transferId.prefix(8))…", category: .session) + } } } } @@ -1083,41 +1550,255 @@ final class BLEService: NSObject { } func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) { + sendFilePrivate( + filePacket, + to: peerID, + transferId: transferId, + allowLegacyFallback: false + ) + } + + func sendFilePrivate( + _ filePacket: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + allowLegacyFallback: Bool + ) { + // Register before enqueueing onto messageQueue. This closes the window + // where cancel/delete could run first, observe no scheduler state, and + // then be followed by a deferred clear-media send. + switch privateMediaTransferAdmissions.begin(transferId) { + case .admitted: + break + + case .alreadyKnown: + SecureLogger.debug( + "Private media admission already cancelled or duplicated for \(transferId.prefix(8))…", + category: .security + ) + return + + case .capacityExhausted: + SecureLogger.warning( + "Private media admission capacity exhausted for \(transferId.prefix(8))…", + category: .security + ) + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String( + localized: "content.delivery.reason.private_media_admission_full", + defaultValue: "Too many media transfers are waiting; try again shortly", + comment: "Failure reason when too many private-media transfers are awaiting admission" + ) + ) + return + } messageQueue.async { [weak self] in guard let self = self else { return } - guard !self.isPanicSuspended else { return } - guard let payload = filePacket.encode() else { - SecureLogger.error("❌ Failed to encode file packet for private send", category: .session) + #if DEBUG + self._test_beforePrivateMediaDeferredSend?(transferId) + #endif + guard !self.isPanicSuspended else { + self.privateMediaTransferAdmissions.finish(transferId) + return + } + guard self.privateMediaTransferAdmissions.isActive(transferId) else { + self.privateMediaTransferAdmissions.finish(transferId) return } - // Normalize to short form (SHA256-derived 16-hex) for wire protocol compatibility - // This ensures 64-hex Noise keys are converted to the canonical routing format let targetID = peerID.toShort() - guard let recipientData = Data(hexString: targetID.id) else { - SecureLogger.error("❌ Invalid recipient peer ID for file transfer: \(peerID.id.prefix(8))…", category: .session) + switch self.privateMediaSendPolicy(to: targetID) { + 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( + "Private media blocked pending explicit legacy-clear consent for \(targetID.id.prefix(8))…", + category: .security + ) + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String( + localized: "content.delivery.reason.legacy_media_consent_required", + defaultValue: "Confirmation required before sending without end-to-end encryption", + comment: "Failure reason when a legacy private-media send lacks per-send consent" + ) + ) + self.privateMediaTransferAdmissions.finish(transferId) + return + } + // Migration path accepted by current Android and used by older + // iOS releases: preserve the directed raw file-transfer wire + // shape, but require the signature the receive path verifies. + // The allow flag belongs to this invocation only and is + // consumed here; a retry must obtain fresh user consent. + self.sendSignedLegacyPrivateFile( + filePacket, + to: targetID, + transferId: transferId + ) + return + + case .blockedDowngrade: + SecureLogger.warning( + "Private media downgrade blocked for \(targetID.id.prefix(8))…", + category: .security + ) + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String( + localized: "content.delivery.reason.private_media_downgrade_blocked", + defaultValue: "Encrypted media required; ask this contact to upgrade", + comment: "Failure reason when a peer that previously supported encrypted media appears to downgrade" + ) + ) + self.privateMediaTransferAdmissions.finish(transferId) + return + } + guard let typedPayload = BLENoisePayloadFactory.privateFile(filePacket) else { + SecureLogger.error("❌ Failed to encode file packet for private send", category: .session) + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String(localized: "content.delivery.reason.media_encoding_failed", defaultValue: "Failed to prepare media", comment: "Failure reason when private media cannot be encoded") + ) + self.privateMediaTransferAdmissions.finish(transferId) + return + } + guard self.noiseService.hasEstablishedSession(with: targetID) else { + let queued = self.collectionsQueue.sync(flags: .barrier) { + self.privateMediaTransferAdmissions.withActive(transferId) { + self.pendingNoiseSessionQueues.appendTypedPayload( + typedPayload, + transferId: transferId, + for: targetID + ) + return true + } ?? false + } + guard queued else { + self.privateMediaTransferAdmissions.finish(transferId) + return + } + SecureLogger.debug("πŸ“₯ Queued private file for \(targetID.id.prefix(8))… pending handshake", category: .session) + guard self.privateMediaTransferAdmissions.isActive(transferId) else { + self.collectionsQueue.sync(flags: .barrier) { + _ = self.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId) + } + self.privateMediaTransferAdmissions.finish(transferId) + return + } + self.initiateNoiseHandshake(with: targetID) return } - var packet = BitchatPacket( - type: MessageType.fileTransfer.rawValue, - senderID: self.myPeerIDData, - recipientID: recipientData, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: payload, - signature: nil, - ttl: self.messageTTL, - version: 2 - ) - - if let signed = self.noiseService.signPacket(packet) { - packet = signed + do { + guard self.privateMediaTransferAdmissions.isActive(transferId) else { + self.privateMediaTransferAdmissions.finish(transferId) + return + } + let packet = try self.makeEncryptedNoisePacket(typedPayload, to: targetID) + guard self.privateMediaTransferAdmissions.isActive(transferId) else { + self.privateMediaTransferAdmissions.finish(transferId) + return + } + SecureLogger.debug("πŸ“ Sending encrypted private file to \(targetID.id.prefix(8))… plaintextBytes=\(typedPayload.count)", category: .session) + self.broadcastPacket( + packet, + transferId: transferId, + requiresPrivateMediaAdmission: true + ) + } catch { + SecureLogger.error("❌ Failed to encrypt private file for \(targetID.id.prefix(8))…: \(error)", category: .security) + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer") + ) + self.privateMediaTransferAdmissions.finish(transferId) } - - SecureLogger.debug("πŸ“ Sending private file transfer to \(peerID.id.prefix(8))… bytes=\(payload.count)", category: .session) - self.broadcastPacket(packet, transferId: transferId) } } + /// Compatibility-only fallback for peers that have not advertised + /// encrypted private media. The payload is authenticated but visible to + /// relays, matching the pre-migration behavior until those clients upgrade. + private func sendSignedLegacyPrivateFile( + _ filePacket: BitchatFilePacket, + to targetID: PeerID, + transferId: String + ) { + guard privateMediaTransferAdmissions.isActive(transferId) else { + privateMediaTransferAdmissions.finish(transferId) + return + } + guard let payload = filePacket.encode(), + let recipientData = Data(hexString: targetID.id) else { + SecureLogger.error("❌ Failed to encode legacy private file transfer", category: .session) + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String(localized: "content.delivery.reason.media_encoding_failed", defaultValue: "Failed to prepare media", comment: "Failure reason when private media cannot be encoded") + ) + privateMediaTransferAdmissions.finish(transferId) + return + } + + let unsigned = BitchatPacket( + type: MessageType.fileTransfer.rawValue, + senderID: myPeerIDData, + recipientID: recipientData, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: messageTTL, + version: 2 + ) + guard let signed = noiseService.signPacket(unsigned) else { + SecureLogger.error("❌ Failed to sign legacy private file transfer", category: .security) + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String(localized: "content.delivery.reason.media_signing_failed", defaultValue: "Failed to authenticate media", comment: "Failure reason when a legacy private-media packet cannot be signed") + ) + privateMediaTransferAdmissions.finish(transferId) + return + } + + // Signing can be non-trivial; cancellation that won while it ran must + // still prevent the clear payload from reaching the broadcast path. + guard privateMediaTransferAdmissions.isActive(transferId) else { + privateMediaTransferAdmissions.finish(transferId) + return + } + + SecureLogger.warning( + "πŸ“ Sending signed legacy private file to \(targetID.id.prefix(8))…; peer has not advertised E2E media", + category: .security + ) + broadcastPacket( + signed, + transferId: transferId, + requiresPrivateMediaAdmission: true + ) + } + func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { // Hop like sendMessage: callers are often on the main actor, and the @@ -1238,8 +1919,26 @@ final class BLEService: NSObject { // MARK: - Packet Broadcasting - private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) { - guard !isPanicSuspended else { return } + private func broadcastPacket( + _ packet: BitchatPacket, + transferId: String? = nil, + requiresPrivateMediaAdmission: Bool = false + ) { + guard !isPanicSuspended else { + if requiresPrivateMediaAdmission, let transferId { + privateMediaTransferAdmissions.finish(transferId) + } + return + } + if requiresPrivateMediaAdmission { + guard let transferId, + privateMediaTransferAdmissions.isActive(transferId) else { + if let transferId { + privateMediaTransferAdmissions.finish(transferId) + } + return + } + } // Apply route if recipient exists (centralized route application) let packetToSend: BitchatPacket if let recipientPeerID = PeerID(hexData: packet.recipientID) { @@ -1248,14 +1947,112 @@ final class BLEService: NSObject { packetToSend = packet } + // Encode once using a small per-type padding policy, then delegate by type + let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type) + + // The 256-fragment ceiling exists to protect *current Android* + // receivers, which only ever receive private media over the directed + // raw-file migration fallback (they do not implement the encrypted + // 0x20 path). Encrypted private media (`noiseEncrypted`) is sent only to + // peers that advertised the `.privateMedia` capability β€” modern clients + // that assemble up to the full receiver ceiling (see + // `BLEFragmentAssemblyBuffer`'s 10,000-fragment guard) β€” so forcing them + // down to Android's 256 cap would needlessly reject iOSβ†’iOS photos in + // the ~120–512 KiB range that work today. Restrict the low cap to the + // migration fallback (directed `fileTransfer`); public media is + // unaffected. Run the same planner the scheduler will use, after route + // application, and reject before reserving a transfer slot or writing + // any fragment. + // TODO(#1434): negotiate an explicit per-peer fragment limit so a future + // Android client that adopts the encrypted 0x20 path but still caps its + // reassembler can advertise its own ceiling instead of relying on the + // capability/type proxy above. + if let transferId, + let recipientPeerID = PeerID(hexData: packetToSend.recipientID), + packetToSend.type == MessageType.fileTransfer.rawValue { + let compatibilityRequest = BLEOutboundFragmentTransferRequest( + packet: packetToSend, + pad: padForBLE, + maxChunk: nil, + directedPeer: recipientPeerID, + transferId: transferId + ) + guard let plan = BLEOutboundFragmentPlanner.makePlan( + for: compatibilityRequest, + defaultChunkSize: defaultFragmentSize, + bleMaxMTU: bleMaxMTU + ), BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(plan) else { + SecureLogger.warning( + "Private media rejected: exceeds cross-platform 256-fragment limit", + category: .security + ) + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String( + localized: "content.delivery.reason.private_media_too_many_fragments", + defaultValue: "File is too large for this contact's client (more than 256 mesh fragments)", + comment: "Failure reason when private media exceeds the Android-compatible fragment limit" + ) + ) + if requiresPrivateMediaAdmission { + privateMediaTransferAdmissions.finish(transferId) + } + return + } + } + + // Route planning and fragment preflight can take enough time for a + // user cancellation to win. Recheck before exposing even the test tap, + // then check atomically with scheduler admission below. + if requiresPrivateMediaAdmission { + guard let transferId, + privateMediaTransferAdmissions.isActive(transferId) else { + if let transferId { + privateMediaTransferAdmissions.finish(transferId) + } + return + } + } + #if DEBUG _test_onOutboundPacket?(packetToSend) #endif - - // Encode once using a small per-type padding policy, then delegate by type - let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type) + if packetToSend.type == MessageType.fileTransfer.rawValue { - sendFragmentedPacket(packetToSend, pad: padForBLE, maxChunk: nil, directedOnlyPeer: nil, transferId: transferId) + sendFragmentedPacket( + packetToSend, + pad: padForBLE, + maxChunk: nil, + directedOnlyPeer: nil, + transferId: transferId, + requiresPrivateMediaAdmission: requiresPrivateMediaAdmission + ) + return + } + // App-initiated private media is already one opaque Noise ciphertext. + // Always fragment that outer packet so the existing transfer scheduler + // retains progress/cancel behavior without exposing the file TLVs. + if packetToSend.type == MessageType.noiseEncrypted.rawValue, + let transferId, + let recipientPeerID = PeerID(hexData: packetToSend.recipientID) { + sendFragmentedPacket( + packetToSend, + pad: padForBLE, + maxChunk: nil, + directedOnlyPeer: recipientPeerID, + transferId: transferId, + requiresPrivateMediaAdmission: requiresPrivateMediaAdmission + ) + return + } + if requiresPrivateMediaAdmission { + if let transferId { + privateMediaTransferAdmissions.finish(transferId) + } + SecureLogger.error( + "Private media admission reached an unsupported non-directed packet shape", + category: .security + ) return } guard let data = packetToSend.toBinaryData(padding: padForBLE) else { @@ -1707,6 +2504,9 @@ final class BLEService: NSObject { verifyPacketSignature: { [weak self] packet, signingPublicKey in self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false }, + localSigningPublicKey: { [weak self] in + self?.noiseService.getSigningPublicKeyData() ?? Data() + }, signedSenderDisplayName: { [weak self] packet, peerID in self?.signedSenderDisplayName(for: packet, from: peerID) }, @@ -1821,7 +2621,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 @@ -2604,16 +3404,23 @@ extension BLEService { } } - func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) { + func _test_seedConnectedPeer( + _ peerID: PeerID, + nickname: String, + capabilities: PeerCapabilities? = nil, + noisePublicKey: Data? = nil + ) { collectionsQueue.sync(flags: .barrier) { peerRegistry.upsert(BLEPeerInfo( peerID: peerID, nickname: nickname, isConnected: true, - noisePublicKey: nil, + noisePublicKey: noisePublicKey, signingPublicKey: nil, isVerifiedNickname: true, - lastSeen: Date() + lastSeen: Date(), + capabilities: capabilities ?? [], + capabilitiesWereExplicitlyAdvertised: capabilities != nil )) } } @@ -2628,6 +3435,136 @@ extension BLEService { try noiseService.processHandshakeMessage(from: peerID, message: message) } + func _test_enqueuePendingNoisePayload( + _ payload: Data, + transferId: String, + for peerID: PeerID + ) { + guard privateMediaTransferAdmissions.begin(transferId) == .admitted else { return } + collectionsQueue.sync(flags: .barrier) { + pendingNoiseSessionQueues.appendTypedPayload( + payload, + transferId: transferId, + for: peerID + ) + } + } + + func _test_sendPendingNoisePayloadsAfterHandshake(for peerID: PeerID) { + 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) { + let scheduler = collectionsQueue.sync { + ( + pendingNoiseSessionQueues.containsTypedPayload(transferId: transferId), + outboundFragmentTransfers.activeCount, + outboundFragmentTransfers.pendingCount + ) + } + return ( + privateMediaTransferAdmissions.isActive(transferId), + scheduler.0, + scheduler.1, + scheduler.2 + ) + } + + func _test_privateMediaAdmissionEntryCount() -> Int { + privateMediaTransferAdmissions.count + } + + @discardableResult + func _test_beginPrivateMediaAdmission(_ transferId: String, now: Date) -> Bool { + privateMediaTransferAdmissions.begin(transferId, now: now) == .admitted + } + + func _test_isPrivateMediaAdmissionActive(_ transferId: String, now: Date) -> Bool { + privateMediaTransferAdmissions.isActive(transferId, now: now) + } + + func _test_finishPrivateMediaAdmission(_ transferId: String) { + privateMediaTransferAdmissions.finish(transferId) + } + + func _test_drainPrivateMediaSendPipeline() async { + let collectionsQueue = self.collectionsQueue + await withCheckedContinuation { continuation in + messageQueue.async { + collectionsQueue.async(flags: .barrier) { + continuation.resume() + } + } + } + } + + func _test_broadcastPrivateMediaPacket( + _ packet: BitchatPacket, + transferId: String + ) { + broadcastPacket( + packet, + transferId: transferId, + requiresPrivateMediaAdmission: true + ) + } + + 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 + /// production API that can emit the old value. + func _test_makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket { + try makeEncryptedNoisePacket(typedPayload, to: peerID) + } + static func _test_shouldRediscoverBitChatService( invalidatedServiceUUIDs: [CBUUID], cachedServiceUUIDs: [CBUUID]? @@ -3666,16 +4603,241 @@ extension BLEService { } private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) { - service.onPeerAuthenticated = { [weak self] peerID, fingerprint in + service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in SecureLogger.debug("πŸ” Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))…") self?.messageQueue.async { [weak self] in - self?.sendPendingMessagesAfterHandshake(for: peerID) - self?.sendPendingNoisePayloadsAfterHandshake(for: peerID) - } - self?.messageQueue.async { [weak self] in - self?.sendAnnounce(forceSend: true) + self?.handleNoisePeerAuthenticated( + peerID: peerID, + fingerprint: fingerprint, + sessionGeneration: generation + ) } } + 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, + sessionGeneration generation: UUID + ) { + let normalizedPeerID = peerID.toShort() + guard let transition = noiseService.withCurrentSessionGeneration( + for: normalizedPeerID, + expected: generation, + { + collectionsQueue.sync(flags: .barrier) { + () -> ( + watchdog: (fingerprint: String, nonce: UUID)?, + rejected: [@MainActor (PrivateMediaSendPolicy) -> Void] + ) in + guard privateMediaSessionGenerations[normalizedPeerID] != generation else { + return (nil, []) + } + let watchdogNonce = UUID() + privateMediaSessionGenerations[normalizedPeerID] = generation + authenticatedPeerStates.removeValue(forKey: normalizedPeerID) + privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID) + privateMediaProofWatchdogs[normalizedPeerID] = BLEPrivateMediaProofWatchdog( + fingerprint: fingerprint, + sessionGeneration: generation, + timeoutNonce: watchdogNonce + ) + authenticatedPeerStateSendProgress[normalizedPeerID] = + BLEAuthenticatedPeerStateSendProgress(sessionGeneration: generation) + + guard var pending = pendingPrivateMediaPolicyResolutions[normalizedPeerID] else { + return ((fingerprint, watchdogNonce), []) + } + guard pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame else { + pendingPrivateMediaPolicyResolutions.removeValue(forKey: normalizedPeerID) + return ((fingerprint, watchdogNonce), Array(pending.completions.values)) + } + pending.sessionGeneration = generation + pending.timeoutNonce = watchdogNonce + pendingPrivateMediaPolicyResolutions[normalizedPeerID] = pending + return ((pending.fingerprint, watchdogNonce), []) + } + } + ) else { return } + + guard let watchdog = transition.watchdog else { return } + + completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade) + schedulePrivateMediaProofTimeout( + for: normalizedPeerID, + fingerprint: watchdog.fingerprint, + sessionGeneration: generation, + nonce: 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, + sessionGeneration generation: UUID + ) { + 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 + } + guard let application = noiseService.withCurrentSessionGeneration( + for: normalizedPeerID, + expected: generation, + { + () -> (accepted: Bool, completions: [@MainActor (PrivateMediaSendPolicy) -> Void]) in + guard collectionsQueue.sync(execute: { + privateMediaSessionGenerations[normalizedPeerID] == generation + }) else { + return (false, []) + } + + // The generation lease prevents rekey/session promotion from + // interleaving between validation and these durable mutations. + identityManager.bindAuthenticatedSigningPublicKey( + state.signingPublicKey, + fingerprint: fingerprint + ) + identityManager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: publicKey, + signingPublicKey: state.signingPublicKey, + claimedNickname: nil + ) + if state.capabilities.contains(.privateMedia) { + identityManager.markPrivateMediaCapable(fingerprint: fingerprint) + } + + let completions = collectionsQueue.sync(flags: .barrier) { + () -> [@MainActor (PrivateMediaSendPolicy) -> Void] in + guard privateMediaSessionGenerations[normalizedPeerID] == generation else { + return [] + } + peerRegistry.bindAuthenticatedSigningPublicKey( + state.signingPublicKey, + for: normalizedPeerID + ) + authenticatedPeerStates[normalizedPeerID] = BLEAuthenticatedPeerStateObservation( + fingerprint: fingerprint, + sessionGeneration: generation, + capabilities: state.capabilities + ) + privateMediaProofTimeoutMarkers.removeValue(forKey: normalizedPeerID) + privateMediaProofWatchdogs.removeValue(forKey: normalizedPeerID) + guard let pending = pendingPrivateMediaPolicyResolutions.removeValue( + forKey: normalizedPeerID + ), pending.fingerprint.caseInsensitiveCompare(fingerprint) == .orderedSame, + pending.sessionGeneration == generation else { + return [] + } + return Array(pending.completions.values) + } + return (true, completions) + } + ), application.accepted else { return } + + // One bounded echo makes initiator/responder proof ordering converge + // even when message 3 and the first proof take different mesh links. + sendAuthenticatedPeerState(to: normalizedPeerID, echo: true) + let policy = privateMediaSendPolicy(to: normalizedPeerID) + sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID) + completePrivateMediaPolicyResolution(application.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. @@ -3730,7 +4892,28 @@ extension BLEService { } private func makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket { - let encrypted = try noiseService.encrypt(typedPayload, for: peerID) + let encrypted: Data + let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first) + if isPrivateFile { + let provenGeneration: UUID? = collectionsQueue.sync { + () -> UUID? in + guard let generation = privateMediaSessionGenerations[peerID], + let authenticated = authenticatedPeerStates[peerID], + authenticated.sessionGeneration == generation, + authenticated.capabilities.contains(.privateMedia) else { return nil } + return generation + } + guard let provenGeneration else { + throw NoiseEncryptionError.sessionNotEstablished + } + encrypted = try noiseService.encryptPrivateFilePayload( + typedPayload, + for: peerID, + sessionGeneration: provenGeneration + ) + } else { + encrypted = try noiseService.encrypt(typedPayload, for: peerID) + } return BitchatPacket( type: MessageType.noiseEncrypted.rawValue, senderID: myPeerIDData, @@ -3738,7 +4921,9 @@ extension BLEService { timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: encrypted, signature: nil, - ttl: messageTTL + ttl: messageTTL, + // v1 has a 16-bit payload length; finalized media can exceed it. + version: isPrivateFile ? 2 : 1 ) } @@ -4690,22 +5875,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) @@ -4771,7 +5958,8 @@ extension BLEService { directedOnlyPeer: PeerID? = nil, transferId: String? = nil, requireDirectPeerLink: Bool = false, - requireNoiseAuthenticatedPeerLink: Bool = false + requireNoiseAuthenticatedPeerLink: Bool = false, + requiresPrivateMediaAdmission: Bool = false ) -> Bool { let request = BLEOutboundFragmentTransferRequest( packet: packet, @@ -4783,8 +5971,34 @@ extension BLEService { requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink ) - let result = collectionsQueue.sync(flags: .barrier) { - outboundFragmentTransfers.submit(request, maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers) + let result: BLEOutboundFragmentTransferScheduler.SubmitResult? = collectionsQueue.sync(flags: .barrier) { + if requiresPrivateMediaAdmission { + guard let transferId else { return nil } + // This lock is taken while the scheduler is already protected + // by collectionsQueue. Cancellation takes the admission lock + // synchronously but never waits on collectionsQueue, avoiding + // lock inversion while giving submit/cancel one linear order. + return privateMediaTransferAdmissions.withActive(transferId) { + outboundFragmentTransfers.submit( + request, + maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers + ) + } + } + return outboundFragmentTransfers.submit( + request, + maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers + ) + } + guard let result else { + if let transferId, requiresPrivateMediaAdmission { + privateMediaTransferAdmissions.finish(transferId) + } + return false + } + if let transferId, requiresPrivateMediaAdmission { + // The scheduler now owns normal cancellation (active or pending). + privateMediaTransferAdmissions.finish(transferId) } return handleFragmentTransferSubmitResult(result) } @@ -4860,14 +6074,24 @@ extension BLEService { } } - let transferIdentifier: String? = { - guard let id = reservedTransferId else { return nil } - collectionsQueue.sync(flags: .barrier) { - _ = self.outboundFragmentTransfers.activateReservedTransfer(id: id, totalFragments: plan.totalFragments, workItems: []) + let transferIdentifier: String? + if let id = reservedTransferId { + let activated = collectionsQueue.sync(flags: .barrier) { + self.outboundFragmentTransfers.activateReservedTransfer( + id: id, + totalFragments: plan.totalFragments, + workItems: [] + ) } + // Cancellation may remove the reservation between submit and plan + // construction. Treat that as cancellation, not as permission to + // schedule an untracked fragment train. + guard activated else { return false } TransferProgressManager.shared.start(id: id, totalFragments: plan.totalFragments) - return id - }() + transferIdentifier = id + } else { + transferIdentifier = nil + } let sendFragment: (BitchatPacket) -> Bool = { [weak self] fragmentPacket in guard let self else { return false } @@ -5200,6 +6424,19 @@ extension BLEService { private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { let result = announceHandler.handle(packet, from: peerID) + // 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.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 // owner's noise key: retry any prekey bundle that raced ahead of it. if let result, result.isVerified { @@ -5247,7 +6484,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) @@ -5485,6 +6722,11 @@ extension BLEService { .compactMap { $0.signingPublicKey } .first }, + authenticatedSigningPublicKey: { [weak self] noisePublicKey in + self?.identityManager.authenticatedSigningPublicKey( + forFingerprint: noisePublicKey.sha256Fingerprint() + ) + }, verifySignature: { [weak self] packet, signingPublicKey in self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false }, @@ -5530,7 +6772,7 @@ extension BLEService { // pinned identity. Main's capabilities/bridgeGeohash are // preserved. now: now, - capabilities: announcement.capabilities ?? [], + capabilities: announcement.capabilities, bridgeGeohash: announcement.bridgeGeohash ) }, @@ -5850,12 +7092,34 @@ extension BLEService { }, decrypt: { [weak self] payload, peerID in guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished } - return try self.noiseService.decrypt(payload, from: peerID) + let result = try self.noiseService.decryptWithSessionGeneration( + payload, + from: peerID + ) + return BLENoiseDecryptionResult( + plaintext: result.plaintext, + sessionGeneration: result.sessionGeneration + ) }, clearSession: { [weak self] peerID in - self?.noiseService.clearSession(for: peerID) + self?.clearNoiseSession(for: peerID) + }, + handleAuthenticatedPeerState: { [weak self] peerID, payload, generation in + self?.handleAuthenticatedPeerState( + payload, + from: peerID, + sessionGeneration: generation + ) }, deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in + if type == .privateFile { + self?.fileTransferHandler.handlePrivatePayload( + payload, + from: peerID, + timestamp: timestamp + ) + return + } // Single main-actor hop delivering `.noisePayloadReceived`. self?.notifyUI { [weak self] in self?.deliverTransportEvent(.noisePayloadReceived( @@ -5872,16 +7136,83 @@ extension BLEService { // MARK: Helper Functions private func sendPendingNoisePayloadsAfterHandshake(for peerID: PeerID) { - let payloads = collectionsQueue.sync(flags: .barrier) { () -> [Data] in + let payloads = collectionsQueue.sync(flags: .barrier) { () -> [BLEPendingTypedPayload] in pendingNoiseSessionQueues.takeTypedPayloads(for: peerID) } guard !payloads.isEmpty else { return } SecureLogger.debug("πŸ“€ Sending \(payloads.count) pending noise payloads to \(peerID.id.prefix(8))… after handshake", category: .session) - for payload in payloads { + for pending in payloads { + 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) + continue + } do { - broadcastPacket(try makeEncryptedNoisePacket(payload, to: peerID)) + if let transferId = privateMediaTransferId, + !privateMediaTransferAdmissions.isActive(transferId) { + privateMediaTransferAdmissions.finish(transferId) + continue + } + let packet = try makeEncryptedNoisePacket(pending.payload, to: peerID) + if let transferId = privateMediaTransferId, + !privateMediaTransferAdmissions.isActive(transferId) { + privateMediaTransferAdmissions.finish(transferId) + continue + } + broadcastPacket( + packet, + transferId: pending.transferId, + requiresPrivateMediaAdmission: privateMediaTransferId != nil + ) } catch { SecureLogger.error("❌ Failed to send pending noise payload to \(peerID.id.prefix(8))…: \(error)") + if let transferId = pending.transferId { + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + reason: String( + localized: "content.delivery.reason.encryption_failed", + defaultValue: "Failed to encrypt media", + comment: "Failure reason shown when queued private media cannot be encrypted after handshake" + ) + ) + privateMediaTransferAdmissions.finish(transferId) + } } } } @@ -6042,6 +7373,11 @@ extension BLEService { private func performCleanup() { let now = Date() + + // Admission expiry is a visible transfer failure, never a silent + // eviction. The registry delivers notifications after releasing its + // lock, so this maintenance pass cannot deadlock a concurrent cancel. + privateMediaTransferAdmissions.prune(now: now) // Clean old processed messages efficiently messageDeduplicator.cleanup() diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index b5171b2a..54972f2e 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -165,7 +165,6 @@ final class NoiseEncryptionService { // Peer fingerprints (SHA256 hash of static public key) private var peerFingerprints: [PeerID: String] = [:] private var fingerprintToPeerID: [String: PeerID] = [:] - // Thread safety private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent) @@ -183,12 +182,18 @@ final class NoiseEncryptionService { // Callbacks private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication + private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = [] var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake + /// Automatic rekey removed the old session and produced XX message 1. + /// The transport must clear session-scoped state and put these exact bytes + /// 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) { - serviceQueue.async(flags: .barrier) { [weak self] in - self?.onPeerAuthenticatedHandlers.append(handler) + serviceQueue.sync(flags: .barrier) { + onPeerAuthenticatedHandlers.append(handler) } } @@ -201,6 +206,18 @@ final class NoiseEncryptionService { } } } + + /// Generation-aware authentication notifications are used by protocols + /// whose state must be bound to one exact Noise transport session. + var onPeerAuthenticatedWithGeneration: ((PeerID, String, UUID) -> Void)? { + get { nil } + set { + guard let handler = newValue else { return } + serviceQueue.sync(flags: .barrier) { + onPeerAuthenticatedWithGenerationHandlers.append(handler) + } + } + } init(keychain: KeychainManagerProtocol) { self.keychain = keychain @@ -295,8 +312,12 @@ final class NoiseEncryptionService { self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain) // Set up session callbacks - sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in - self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey) + sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in + self?.handleSessionEstablished( + peerID: peerID, + remoteStaticKey: remoteStaticKey, + sessionGeneration: generation + ) } // Start session maintenance timer @@ -740,11 +761,56 @@ final class NoiseEncryptionService { return try sessionManager.encrypt(data, for: peerID) } + + /// Encrypts a finalized private-media packet. Ordinary Noise application + /// messages retain the 64 KiB ceiling; this purpose-specific path permits + /// the bounded `BitchatFilePacket` envelope and refuses every other typed + /// payload so the larger allocation budget cannot become a generic bypass. + func encryptPrivateFilePayload( + _ data: Data, + for peerID: PeerID, + sessionGeneration: UUID? = nil + ) throws -> Data { + guard NoisePayloadType.isPrivateFile(rawValue: data.first), + NoiseSecurityValidator.validatePrivateFileMessageSize(data) else { + throw NoiseSecurityError.messageTooLarge + } + + guard rateLimiter.allowMessage(from: peerID) else { + throw NoiseSecurityError.rateLimitExceeded + } + + guard hasEstablishedSession(with: peerID) else { + onHandshakeRequired?(peerID) + throw NoiseEncryptionError.handshakeRequired + } + + // `maxPrivateFilePlaintextSize` already subtracts the cipher's fixed + // nonce/tag overhead, so the result is bounded without a second copy. + if let sessionGeneration { + return try sessionManager.encrypt( + data, + for: peerID, + expectedSessionGeneration: sessionGeneration + ) + } + return try sessionManager.encrypt(data, for: peerID) + } /// Decrypt data from a specific peer func decrypt(_ data: Data, from peerID: PeerID) throws -> Data { - // Validate message size - guard NoiseSecurityValidator.validateMessageSize(data) else { + try decryptWithSessionGeneration(data, from: peerID).plaintext + } + + func decryptWithSessionGeneration( + _ data: Data, + from peerID: PeerID + ) throws -> (plaintext: Data, sessionGeneration: UUID) { + // Standard transport ciphertext has 20 bytes of nonce/tag overhead. + // A larger candidate is admitted only up to the framed-file ceiling; + // after authenticated decryption it must prove it is `.privateFile`. + let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data) + guard isStandardCiphertext || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data) else { throw NoiseSecurityError.messageTooLarge } @@ -758,7 +824,14 @@ final class NoiseEncryptionService { throw NoiseEncryptionError.sessionNotEstablished } - return try sessionManager.decrypt(data, from: peerID) + let result = try sessionManager.decryptWithSessionGeneration(data, from: peerID) + if !isStandardCiphertext { + guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first), + NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else { + throw NoiseSecurityError.messageTooLarge + } + } + return result } // MARK: - Peer Management @@ -770,6 +843,25 @@ final class NoiseEncryptionService { } } + func sessionGeneration(for peerID: PeerID) -> UUID? { + sessionManager.sessionGeneration(for: peerID) + } + + /// Runs `body` while holding a read lease on the exact session generation. + /// Session insertion, replacement, and removal use the same manager + /// barrier, so they cannot interleave with an authenticated-state commit. + func withCurrentSessionGeneration( + for peerID: PeerID, + expected: UUID, + _ body: () -> Result + ) -> Result? { + sessionManager.withCurrentSessionGeneration( + for: peerID, + expected: expected, + body + ) + } + func clearEphemeralStateForPanic() { sessionManager.removeAllSessions() serviceQueue.sync(flags: .barrier) { @@ -792,24 +884,36 @@ final class NoiseEncryptionService { // MARK: - Private Helpers - private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) { + private func handleSessionEstablished( + peerID: PeerID, + remoteStaticKey: Curve25519.KeyAgreement.PublicKey, + sessionGeneration: UUID + ) { // Calculate fingerprint let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint() - // Store fingerprint mapping - serviceQueue.sync(flags: .barrier) { + // Registering handlers is synchronous, and this barrier snapshots them + // with the fingerprint update. Invoke the snapshot outside the queue: + // parallel Swift Testing workers must not block behind queued callback + // registration or allow a handler to re-enter serviceQueue. + let handlers: ( + generationAware: [(PeerID, String, UUID) -> Void], + legacy: [(PeerID, String) -> Void] + ) = serviceQueue.sync(flags: .barrier) { peerFingerprints[peerID] = fingerprint fingerprintToPeerID[fingerprint] = peerID + return (onPeerAuthenticatedWithGenerationHandlers, onPeerAuthenticatedHandlers) } // Log security event SecureLogger.info(.handshakeCompleted(peerID: peerID.id)) - // Notify all handlers about authentication - serviceQueue.async { [weak self] in - self?.onPeerAuthenticatedHandlers.forEach { handler in - handler(peerID, fingerprint) - } + // Notify all handlers about authentication. + handlers.generationAware.forEach { handler in + handler(peerID, fingerprint, sessionGeneration) + } + handlers.legacy.forEach { handler in + handler(peerID, fingerprint) } } @@ -830,19 +934,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/TransferProgressManager.swift b/bitchat/Services/TransferProgressManager.swift index 4c6a34d8..13c4c254 100644 --- a/bitchat/Services/TransferProgressManager.swift +++ b/bitchat/Services/TransferProgressManager.swift @@ -11,6 +11,7 @@ final class TransferProgressManager { case updated(id: String, sentFragments: Int, totalFragments: Int) case completed(id: String, totalFragments: Int) case cancelled(id: String, sentFragments: Int, totalFragments: Int) + case rejected(id: String, reason: String) } private let subject = PassthroughSubject() @@ -49,6 +50,17 @@ final class TransferProgressManager { } } + /// Fails a preflight check while keeping the outgoing placeholder visible + /// with an actionable reason instead of treating policy/size rejection as + /// a user cancellation. + func rejectBeforeStart(id: String, reason: String) { + queue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + self.states.removeValue(forKey: id) + self.subject.send(.rejected(id: id, reason: reason)) + } + } + func snapshot(id: String) -> (sent: Int, total: Int)? { var result: (sent: Int, total: Int)? queue.sync { diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index c7094bc1..fd5c9338 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -83,6 +83,20 @@ enum TransportEvent: @unchecked Sendable { case bluetoothStateUpdated(CBManagerState) } +/// Downgrade-safe decision for a private-media recipient. Callers ask before +/// prompting, and BLEService checks again when it consumes any one-shot +/// 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 +} + protocol TransportEventDelegate: AnyObject { @MainActor func didReceiveTransportEvent(_ event: TransportEvent) } @@ -163,6 +177,12 @@ protocol Transport: AnyObject { func sendDeliveryAck(for messageID: String, to peerID: PeerID) func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) + func sendFilePrivate( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + allowLegacyFallback: Bool + ) func cancelTransfer(_ transferId: String) // Live voice / push-to-talk (mesh transports only): one encoded @@ -208,6 +228,11 @@ protocol Transport: AnyObject { /// Capabilities the peer advertised in its last verified announce; /// 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 @@ -278,6 +303,16 @@ extension Transport { func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {} 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 } @@ -294,6 +329,15 @@ extension Transport { func currentMeshTopology() -> MeshTopologySnapshot? { nil } func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} + func sendFilePrivate( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + allowLegacyFallback: Bool + ) { + guard !allowLegacyFallback else { return } + sendFilePrivate(packet, to: peerID, transferId: transferId) + } func cancelTransfer(_ transferId: String) {} func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index a9d2db93..c6b6ba3e 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 4aec8033..70e990b1 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -6,6 +6,19 @@ import Foundation import UIKit #endif +struct LegacyPrivateMediaConsentRequest: Identifiable, Equatable { + let id: UUID + let peerID: PeerID + let peerName: String + let transferId: String + let messageID: String +} + +struct PendingLegacyPrivateMediaConsent { + let request: LegacyPrivateMediaConsentRequest + let completion: @MainActor (Bool) -> Void +} + /// The narrow surface `ChatMediaTransferCoordinator` needs from its owner. /// /// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the @@ -43,7 +56,24 @@ protocol ChatMediaTransferContext: AnyObject { func recordContentKey(_ key: String, timestamp: Date) // MARK: Mesh file transfer - func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) + func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy + func resolvePrivateMediaSendPolicy( + to peerID: PeerID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void + ) + func requestLegacyPrivateMediaConsent( + for peerID: PeerID, + transferId: String, + messageID: String, + completion: @escaping @MainActor (Bool) -> Void + ) + func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) + func sendFilePrivate( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + allowLegacyFallback: Bool + ) func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) func cancelTransfer(_ transferId: String) } @@ -59,8 +89,50 @@ extension ChatViewModel: ChatMediaTransferContext { // other contexts or satisfied by existing `ChatViewModel` members. The // members below flatten mesh service accesses. - func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { - meshService.sendFilePrivate(packet, to: peerID, transferId: transferId) + func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { + 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, + messageID: String, + completion: @escaping @MainActor (Bool) -> Void + ) { + enqueueLegacyPrivateMediaConsent( + for: peerID, + transferId: transferId, + messageID: messageID, + completion: completion + ) + } + + func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) { + invalidateLegacyPrivateMediaConsent( + transferId: transferId, + messageID: messageID + ) + } + + func sendFilePrivate( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + allowLegacyFallback: Bool + ) { + meshService.sendFilePrivate( + packet, + to: peerID, + transferId: transferId, + allowLegacyFallback: allowLegacyFallback + ) } func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { @@ -152,6 +224,7 @@ final class ChatMediaTransferCoordinator { private unowned let context: any ChatMediaTransferContext private let prepareImagePacket: @Sendable (URL) throws -> ChatPreparedImage private let imagePreparationBarrier = ImagePreparationBarrier() + private let prepareVoiceNotePacket: @Sendable (URL) throws -> BitchatFilePacket private(set) var transferIdToMessageIDs: [String: [String]] = [:] private(set) var messageIDToTransferId: [String: String] = [:] @@ -160,10 +233,14 @@ final class ChatMediaTransferCoordinator { context: any ChatMediaTransferContext, prepareImagePacket: @escaping @Sendable (URL) throws -> ChatPreparedImage = { try ChatMediaPreparation.prepareImagePacket(from: $0) + }, + prepareVoiceNotePacket: @escaping @Sendable (URL) throws -> BitchatFilePacket = { + try ChatMediaPreparation.prepareVoiceNotePacket(at: $0) } ) { self.context = context self.prepareImagePacket = prepareImagePacket + self.prepareVoiceNotePacket = prepareVoiceNotePacket } func sendVoiceNote(at url: URL) { @@ -181,22 +258,33 @@ final class ChatMediaTransferCoordinator { ) let messageID = message.id let transferId = makeTransferID(messageID: messageID) - let generation = imagePreparationBarrier.currentGeneration + // Own the transfer before detached preparation begins. Cancel/delete + // must be able to invalidate this exact invocation even while file I/O + // is still running off the main actor. + registerTransfer(transferId: transferId, messageID: messageID) + let prepareVoiceNotePacket = self.prepareVoiceNotePacket + let barrier = imagePreparationBarrier + let generation = barrier.currentGeneration - Task.detached(priority: .userInitiated) { [weak self] in + Task.detached(priority: .userInitiated) { [weak self, barrier] in do { let packet = try await runBlockingMediaPreparation { - try ChatMediaPreparation.prepareVoiceNotePacket(at: url) + try prepareVoiceNotePacket(url) } - await MainActor.run { [weak self] in + await MainActor.run { [weak self, barrier] in guard let self, - self.imagePreparationBarrier.isCurrent(generation) else { + barrier.isCurrent(generation), + self.isRegisteredTransfer(transferId, messageID: messageID) else { return } - self.registerTransfer(transferId: transferId, messageID: messageID) if let peerID = targetPeer { - self.context.sendFilePrivate(packet, to: peerID, transferId: transferId) + self.beginPrivateMediaSend( + packet, + to: peerID, + transferId: transferId, + messageID: messageID + ) } else { self.context.sendFileBroadcast(packet, transferId: transferId) } @@ -204,18 +292,20 @@ final class ChatMediaTransferCoordinator { } catch ChatMediaPreparationError.voiceNoteTooLarge(let size) { SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session) try? FileManager.default.removeItem(at: url) - await MainActor.run { [weak self] in + await MainActor.run { [weak self, barrier] in guard let self, - self.imagePreparationBarrier.isCurrent(generation) else { + barrier.isCurrent(generation), + self.isRegisteredTransfer(transferId, messageID: messageID) else { return } self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit")) } } catch { SecureLogger.error("Voice note send failed: \(error)", category: .session) - await MainActor.run { [weak self] in + await MainActor.run { [weak self, barrier] in guard let self, - self.imagePreparationBarrier.isCurrent(generation) else { + barrier.isCurrent(generation), + self.isRegisteredTransfer(transferId, messageID: messageID) else { return } self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent")) @@ -337,7 +427,12 @@ final class ChatMediaTransferCoordinator { let transferId = self.makeTransferID(messageID: messageID) self.registerTransfer(transferId: transferId, messageID: messageID) if let peerID = targetPeer { - self.context.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId) + self.beginPrivateMediaSend( + prepared.packet, + to: peerID, + transferId: transferId, + messageID: messageID + ) } else { self.context.sendFileBroadcast(prepared.packet, transferId: transferId) } @@ -403,17 +498,127 @@ final class ChatMediaTransferCoordinator { return message } + private func beginPrivateMediaSend( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + messageID: String + ) { + 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, + to: peerID, + transferId: transferId, + 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, + transferId: transferId, + messageID: messageID + ) { [weak self] approved in + guard let self else { return } + // Consent belongs to this exact placeholder/transfer. A late + // dialog callback after cancel/delete must never resurrect it. + guard self.messageIDToTransferId[messageID] == transferId, + self.transferIdToMessageIDs[transferId]?.contains(messageID) == true else { + return + } + guard approved else { + self.handleMediaSendFailure( + messageID: messageID, + reason: String( + localized: "content.delivery.reason.legacy_media_declined", + defaultValue: "Not sent without end-to-end encryption", + comment: "Failure reason after declining the warning for a legacy clear private-media send" + ) + ) + return + } + self.context.sendFilePrivate( + packet, + to: peerID, + transferId: transferId, + allowLegacyFallback: true + ) + } + + case .blockedDowngrade: + handleMediaSendFailure( + messageID: messageID, + reason: String( + localized: "content.delivery.reason.private_media_downgrade_blocked", + defaultValue: "Encrypted media required; ask this contact to upgrade", + comment: "Failure reason when a peer that previously supported encrypted media appears to downgrade" + ) + ) + } + } + func registerTransfer(transferId: String, messageID: String) { transferIdToMessageIDs[transferId, default: []].append(messageID) messageIDToTransferId[messageID] = transferId } + private func isRegisteredTransfer(_ transferId: String, messageID: String) -> Bool { + messageIDToTransferId[messageID] == transferId + && transferIdToMessageIDs[transferId]?.contains(messageID) == true + } + func makeTransferID(messageID: String) -> String { "\(messageID)-\(UUID().uuidString)" } func clearTransferMapping(for messageID: String) { guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return } + context.cancelLegacyPrivateMediaConsent( + transferId: transferId, + messageID: messageID + ) guard var queue = transferIdToMessageIDs[transferId] else { return } if !queue.isEmpty { @@ -448,6 +653,9 @@ final class ChatMediaTransferCoordinator { guard let messageID = transferIdToMessageIDs[id]?.first else { return } clearTransferMapping(for: messageID) context.removeMessage(withID: messageID, cleanupFile: true) + case .rejected(let id, let reason): + guard let messageID = transferIdToMessageIDs[id]?.first else { return } + handleMediaSendFailure(messageID: messageID, reason: reason) } } @@ -488,6 +696,13 @@ final class ChatMediaTransferCoordinator { } func deleteMediaMessage(messageID: String) { + // Delete is also a send cancellation. In particular, an approved + // legacy-clear send may still be waiting on BLEService.messageQueue; + // removing only the UI mapping would let that deferred work transmit. + if let transferId = messageIDToTransferId[messageID], + transferIdToMessageIDs[transferId]?.first == messageID { + context.cancelTransfer(transferId) + } clearTransferMapping(for: messageID) context.removeMessage(withID: messageID, cleanupFile: true) } diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index 1ec74850..5ff2b3aa 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -407,6 +407,13 @@ private extension ChatTransportEventCoordinator { case .voiceFrame: context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp) + + case .privateFile, .authenticatedPeerState: + // BLEService validates and persists decrypted private files before + // 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/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 21f04f08..fb80594b 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -375,6 +375,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele @Published var showBluetoothAlert = false @Published var bluetoothAlertMessage = "" @Published var bluetoothState: CBManagerState = .unknown + @Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest? + private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = [] private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) { if Thread.isMainThread { @@ -1268,6 +1270,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele mediaTransferCoordinator.resetForPanic() liveVoiceCoordinator.resetForPanic() + // Deny and release any clear-media confirmations before identities, + // message state, and local files are wiped. + cancelAllLegacyPrivateMediaConsents() + // Clear all messages (public timelines and private chats live in the // single-writer ConversationStore; the derived `messages` view and // the legacy mirror empty with it) @@ -1936,4 +1942,91 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele publicConversationCoordinator.sendHapticFeedback(for: message) } } + +@MainActor +extension ChatViewModel { + func enqueueLegacyPrivateMediaConsent( + for peerID: PeerID, + transferId: String, + messageID: String, + completion: @escaping @MainActor (Bool) -> Void + ) { + let request = LegacyPrivateMediaConsentRequest( + id: UUID(), + peerID: peerID, + peerName: nicknameForPeer(peerID), + transferId: transferId, + messageID: messageID + ) + pendingLegacyPrivateMediaConsents.append(PendingLegacyPrivateMediaConsent( + request: request, + completion: completion + )) + if legacyPrivateMediaConsentRequest == nil { + legacyPrivateMediaConsentRequest = request + } + } + + func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) { + // SwiftUI may report both the selected button and the presentation + // binding's dismissal. Resolve only the exact request that was shown; + // a duplicate callback for it must not consume the next queued send. + guard legacyPrivateMediaConsentRequest?.id == requestID, + pendingLegacyPrivateMediaConsents.first?.request.id == requestID else { + return + } + let resolved = pendingLegacyPrivateMediaConsents.removeFirst() + // Drive the boolean presentation state through false before showing + // the next queued per-send warning. Otherwise SwiftUI sees trueβ†’true, + // closes the first dialog, and never presents the second. + legacyPrivateMediaConsentRequest = nil + resolved.completion(approved) + presentNextLegacyPrivateMediaConsentDeferred() + } + + func invalidateLegacyPrivateMediaConsent(transferId: String, messageID: String) { + let invalidatedIDs = Set( + pendingLegacyPrivateMediaConsents.compactMap { pending -> UUID? in + let request = pending.request + return request.transferId == transferId && request.messageID == messageID + ? request.id + : nil + } + ) + guard !invalidatedIDs.isEmpty else { return } + + pendingLegacyPrivateMediaConsents.removeAll { + invalidatedIDs.contains($0.request.id) + } + if let currentID = legacyPrivateMediaConsentRequest?.id, + invalidatedIDs.contains(currentID) { + legacyPrivateMediaConsentRequest = nil + presentNextLegacyPrivateMediaConsentDeferred() + } + } + + func cancelAllLegacyPrivateMediaConsents() { + let pending = pendingLegacyPrivateMediaConsents + pendingLegacyPrivateMediaConsents.removeAll() + legacyPrivateMediaConsentRequest = nil + for item in pending { + item.completion(false) + } + } + + private func presentNextLegacyPrivateMediaConsentDeferred() { + guard legacyPrivateMediaConsentRequest == nil, + let nextRequestID = pendingLegacyPrivateMediaConsents.first?.request.id else { + return + } + DispatchQueue.main.async { [weak self] in + guard let self, + self.legacyPrivateMediaConsentRequest == nil, + self.pendingLegacyPrivateMediaConsents.first?.request.id == nextRequestID else { + return + } + self.legacyPrivateMediaConsentRequest = self.pendingLegacyPrivateMediaConsents[0].request + } + } +} // End of ChatViewModel class diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index 7dc6cbbc..85024436 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -397,7 +397,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: + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState: break } } @@ -489,7 +489,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: + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState: break } } diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index 0cbd27d1..b0e9507d 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -36,6 +36,7 @@ struct ContentPeopleSheetView: View { #endif var body: some View { + let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest NavigationStack { Group { if privateConversationModel.selectedPeerID != nil { @@ -97,6 +98,63 @@ struct ContentPeopleSheetView: View { } .themedSheetBackground() .foregroundColor(palette.primary) + .confirmationDialog( + String( + localized: "content.private_media.legacy_warning.title", + defaultValue: "Send without end-to-end encryption?", + comment: "Title warning before sending private media to an older client in a clear signed envelope" + ), + isPresented: Binding( + get: { legacyConsentRequest != nil }, + set: { isPresented in + if !isPresented, let requestID = legacyConsentRequest?.id { + conversationUIModel.resolveLegacyPrivateMediaConsent( + requestID: requestID, + approved: false + ) + } + } + ), + titleVisibility: .visible + ) { + Button( + String( + localized: "content.private_media.legacy_warning.send", + defaultValue: "send visible file", + comment: "Destructive confirmation action for one legacy clear private-media send" + ), + role: .destructive + ) { + if let requestID = legacyConsentRequest?.id { + conversationUIModel.resolveLegacyPrivateMediaConsent( + requestID: requestID, + approved: true + ) + } + } + Button("common.cancel", role: .cancel) { + if let requestID = legacyConsentRequest?.id { + conversationUIModel.resolveLegacyPrivateMediaConsent( + requestID: requestID, + approved: false + ) + } + } + } message: { + if let request = legacyConsentRequest { + Text( + String( + format: String( + localized: "content.private_media.legacy_warning.message", + defaultValue: "%@'s client does not advertise encrypted private media. This file will be signed but not end-to-end encrypted, so mesh relays can see it. Send this file anyway?", + comment: "Warning explaining the confidentiality loss for one legacy private-media send; parameter is the peer name" + ), + locale: .current, + request.peerName + ) + ) + } + } #if os(macOS) .frame(minWidth: 420, minHeight: 520) #endif diff --git a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift index 52371d06..0e9d5fe2 100644 --- a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift +++ b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift @@ -7,10 +7,9 @@ // `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / // `ChatPrivateConversationCoordinatorContextTests` exemplars. // -// Scope note: the async media-preparation pipelines (`ImageUtils`, -// `ChatMediaPreparation`) run real file/codec work and remain covered by -// `ChatMediaPreparationTests`; here we cover message enqueueing, transfer -// bookkeeping, and the blocked-context guards. +// Real file/codec work remains covered by `ChatMediaPreparationTests`. These +// tests inject a paused voice-note preparer to exercise cancellation ownership +// across the detached-preparation/MainActor boundary deterministically. // import Testing @@ -91,11 +90,72 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { // Mesh file transfer private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = [] + private(set) var privateFileLegacyAllowances: [Bool] = [] 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, + transferId: String, + messageID: String + )] = [] + private(set) var invalidatedLegacyConsents: [(transferId: String, messageID: String)] = [] + private var pendingLegacyConsentIDs: [UUID] = [] + private var legacyConsentCompletions: [UUID: @MainActor (Bool) -> Void] = [:] - func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { + func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { + privateMediaPolicy + } + + func resolvePrivateMediaSendPolicy( + to peerID: PeerID, + completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void + ) { + completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy) + } + + func requestLegacyPrivateMediaConsent( + for peerID: PeerID, + transferId: String, + messageID: String, + completion: @escaping @MainActor (Bool) -> Void + ) { + let id = UUID() + legacyConsentRequests.append((id, peerID, transferId, messageID)) + pendingLegacyConsentIDs.append(id) + legacyConsentCompletions[id] = completion + } + + func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) { + invalidatedLegacyConsents.append((transferId, messageID)) + let matchingIDs = Set(legacyConsentRequests.compactMap { request in + request.transferId == transferId && request.messageID == messageID + ? request.id + : nil + }) + pendingLegacyConsentIDs.removeAll { matchingIDs.contains($0) } + } + + func resolveNextLegacyConsent(_ approved: Bool) { + guard !pendingLegacyConsentIDs.isEmpty else { return } + let id = pendingLegacyConsentIDs.removeFirst() + legacyConsentCompletions[id]?(approved) + } + + func invokeLegacyConsentEvenIfInvalidated(id: UUID, approved: Bool) { + legacyConsentCompletions[id]?(approved) + } + + func sendFilePrivate( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + allowLegacyFallback: Bool + ) { privateFileSends.append((peerID, transferId)) + privateFileLegacyAllowances.append(allowLegacyFallback) } func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { @@ -107,6 +167,56 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { } } +private final class PausedVoiceNotePreparer: @unchecked Sendable { + private let condition = NSCondition() + private var started = false + private var released = false + private var finished = false + private let packet: BitchatFilePacket + + init() { + let content = Data("voice".utf8) + packet = BitchatFilePacket( + fileName: "paused.m4a", + fileSize: UInt64(content.count), + mimeType: "audio/mp4", + content: content + ) + } + + func prepare(_: URL) throws -> BitchatFilePacket { + condition.lock() + started = true + condition.broadcast() + while !released { + condition.wait() + } + finished = true + condition.broadcast() + condition.unlock() + return packet + } + + var hasStarted: Bool { + condition.lock() + defer { condition.unlock() } + return started + } + + var hasFinished: Bool { + condition.lock() + defer { condition.unlock() } + return finished + } + + func release() { + condition.lock() + released = true + condition.broadcast() + condition.unlock() + } +} + // MARK: - Coordinator Tests Against Mock Context /// Exercises `ChatMediaTransferCoordinator` against @@ -171,6 +281,14 @@ struct ChatMediaTransferCoordinatorContextTests { #expect(context.removedMessages.count == 1) #expect(context.removedMessages.first?.messageID == "m2") #expect(context.removedMessages.first?.cleanupFile == true) + + // A pre-start rejection keeps the placeholder visible and failed, + // including queued post-handshake encryption failures. + coordinator.registerTransfer(transferId: "t3", messageID: "m3") + coordinator.handleTransferEvent(.rejected(id: "t3", reason: "encryption failed")) + #expect(context.deliveryStatusUpdates.last?.messageID == "m3") + #expect(context.deliveryStatusUpdates.last?.status == .failed(reason: "encryption failed")) + #expect(coordinator.messageIDToTransferId["m3"] == nil) } @Test @MainActor @@ -301,6 +419,20 @@ struct ChatMediaTransferCoordinatorContextTests { )) } + @Test @MainActor + func deleteMediaMessage_cancelsApprovedTransferBeforeRemovingMapping() { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + coordinator.registerTransfer(transferId: "approved-delete", messageID: "message-delete") + + coordinator.deleteMediaMessage(messageID: "message-delete") + + #expect(context.cancelledTransfers == ["approved-delete"]) + #expect(coordinator.messageIDToTransferId["message-delete"] == nil) + #expect(context.removedMessages.map(\.messageID) == ["message-delete"]) + #expect(context.removedMessages.first?.cleanupFile == true) + } + @Test @MainActor func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws { let context = MockChatMediaTransferContext() @@ -319,6 +451,251 @@ struct ChatMediaTransferCoordinatorContextTests { #expect(context.appendedPublicMessages.isEmpty) #expect(coordinator.transferIdToMessageIDs.isEmpty) } + + @Test @MainActor + func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "5566778899aabbcc") + context.selectedPrivateChatPeer = peerID + let preparer = PausedVoiceNotePreparer() + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in try preparer.prepare(url) } + ) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("paused-private-\(UUID().uuidString).m4a") + try Data("voice".utf8).write(to: url) + defer { + preparer.release() + try? FileManager.default.removeItem(at: url) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil({ preparer.hasStarted }, timeout: TestConstants.longTimeout)) + let messageID = try #require(context.privateChats[peerID]?.first?.id) + let transferId = try #require(coordinator.messageIDToTransferId[messageID]) + + coordinator.cancelMediaSend(messageID: messageID) + preparer.release() + #expect(await TestHelpers.waitUntil({ preparer.hasFinished }, timeout: TestConstants.longTimeout)) + for _ in 0..<10 { await Task.yield() } + + #expect(context.cancelledTransfers == [transferId]) + #expect(context.privateFileSends.isEmpty) + #expect(context.broadcastFileSends.isEmpty) + #expect(coordinator.messageIDToTransferId[messageID] == nil) + #expect(coordinator.transferIdToMessageIDs[transferId] == nil) + #expect(context.removedMessages.map(\.messageID) == [messageID]) + } + + @Test @MainActor + func deletePublicVoiceNoteDuringDetachedPreparationCannotBroadcastOrRestoreMapping() async throws { + let context = MockChatMediaTransferContext() + let preparer = PausedVoiceNotePreparer() + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in try preparer.prepare(url) } + ) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("paused-public-\(UUID().uuidString).m4a") + try Data("voice".utf8).write(to: url) + defer { + preparer.release() + try? FileManager.default.removeItem(at: url) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil({ preparer.hasStarted }, timeout: TestConstants.longTimeout)) + let messageID = try #require(context.appendedPublicMessages.first?.message.id) + let transferId = try #require(coordinator.messageIDToTransferId[messageID]) + + coordinator.deleteMediaMessage(messageID: messageID) + preparer.release() + #expect(await TestHelpers.waitUntil({ preparer.hasFinished }, timeout: TestConstants.longTimeout)) + for _ in 0..<10 { await Task.yield() } + + #expect(context.cancelledTransfers == [transferId]) + #expect(context.broadcastFileSends.isEmpty) + #expect(context.privateFileSends.isEmpty) + #expect(coordinator.messageIDToTransferId[messageID] == nil) + #expect(coordinator.transferIdToMessageIDs[transferId] == nil) + #expect(context.removedMessages.map(\.messageID) == [messageID]) + } + + @Test @MainActor + func voicePreparationFailureMarksPlaceholderFailedAndClearsEarlyMapping() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "66778899aabbccdd") + context.selectedPrivateChatPeer = peerID + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { _ in + throw ChatMediaPreparationError.voiceNoteTooLarge(bytes: 999_999) + } + ) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("failing-private-\(UUID().uuidString).m4a") + try Data("voice".utf8).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil( + { + context.deliveryStatusUpdates.contains { update in + if case .failed = update.status { return true } + return false + } + }, + timeout: TestConstants.longTimeout + )) + let messageID = try #require(context.privateChats[peerID]?.first?.id) + + #expect(coordinator.messageIDToTransferId[messageID] == nil) + #expect(coordinator.transferIdToMessageIDs.isEmpty) + #expect(context.privateFileSends.isEmpty) + #expect(context.broadcastFileSends.isEmpty) + } + + @Test @MainActor + func legacyPrivateVoiceNoteWaitsForPerSendConsent() async throws { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.privateMediaPolicy = .legacyRequiresConsent + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("legacy-consent-\(UUID().uuidString).m4a") + try (Data([0x00, 0x00, 0x00, 0x18]) + Data("ftypM4A 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.legacyConsentRequests.map { $0.peerID } == [peerID]) + #expect(context.privateFileSends.isEmpty) + + context.resolveNextLegacyConsent(true) + + #expect(context.privateFileSends.count == 1) + #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() + let coordinator = ChatMediaTransferCoordinator(context: context) + let peerID = PeerID(str: "2233445566778899") + context.selectedPrivateChatPeer = peerID + context.privateMediaPolicy = .legacyRequiresConsent + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("legacy-cancel-\(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) + let request = try #require(context.legacyConsentRequests.first) + + coordinator.cancelMediaSend(messageID: request.messageID) + #expect(context.invalidatedLegacyConsents.contains { + $0.transferId == request.transferId && $0.messageID == request.messageID + }) + + // Model a stale framework callback that escaped active invalidation. + // The coordinator's transfer/message binding check is the final gate. + context.invokeLegacyConsentEvenIfInvalidated(id: request.id, approved: true) + #expect(context.privateFileSends.isEmpty) + #expect(coordinator.messageIDToTransferId[request.messageID] == nil) + } + + @Test @MainActor + func legacyConsentApprovalAfterDeleteCannotSend() async throws { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let peerID = PeerID(str: "33445566778899aa") + context.selectedPrivateChatPeer = peerID + context.privateMediaPolicy = .legacyRequiresConsent + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("legacy-delete-\(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) + let request = try #require(context.legacyConsentRequests.first) + + coordinator.deleteMediaMessage(messageID: request.messageID) + context.invokeLegacyConsentEvenIfInvalidated(id: request.id, approved: true) + + #expect(context.invalidatedLegacyConsents.contains { + $0.transferId == request.transferId && $0.messageID == request.messageID + }) + #expect(context.privateFileSends.isEmpty) + #expect(coordinator.messageIDToTransferId[request.messageID] == nil) + } + + @Test @MainActor + func pinnedPrivateMediaDowngradeNeverPromptsOrSends() async throws { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.privateMediaPolicy = .blockedDowngrade + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("blocked-downgrade-\(UUID().uuidString).m4a") + try Data("voice".utf8).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + coordinator.sendVoiceNote(at: url) + + let failed = await TestHelpers.waitUntil( + { context.deliveryStatusUpdates.contains { update in + if case .failed = update.status { return true } + return false + } }, + timeout: TestConstants.longTimeout + ) + #expect(failed) + #expect(context.legacyConsentRequests.isEmpty) + #expect(context.privateFileSends.isEmpty) + } } private final class PausedImagePreparer: @unchecked Sendable { diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index c8fe4306..e4a0ab85 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -1033,6 +1033,89 @@ struct ChatViewModelMediaTransferTests { #expect(viewModel.transferIdToMessageIDs.count == 1) } + @Test @MainActor + func legacyPrivateMediaConsentRequestsArePerSendAndQueued() async throws { + let (viewModel, _) = makeTestableViewModel() + let firstPeer = PeerID(str: "1111111111111111") + let secondPeer = PeerID(str: "2222222222222222") + var decisions: [Bool] = [] + + viewModel.enqueueLegacyPrivateMediaConsent( + for: firstPeer, + transferId: "transfer-1", + messageID: "message-1" + ) { decisions.append($0) } + viewModel.enqueueLegacyPrivateMediaConsent( + for: secondPeer, + transferId: "transfer-2", + messageID: "message-2" + ) { decisions.append($0) } + + #expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == firstPeer) + let firstRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id) + viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: true) + let showedSecond = await TestHelpers.waitUntil( + { viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer }, + timeout: TestConstants.longTimeout + ) + #expect(showedSecond) + let secondRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id) + + // A button action and the dialog binding may both resolve the first + // ID. The stale second callback must not consume the queued request. + viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: false) + #expect(decisions == [true]) + #expect(viewModel.legacyPrivateMediaConsentRequest?.id == secondRequestID) + + viewModel.resolveLegacyPrivateMediaConsent(requestID: secondRequestID, approved: false) + + #expect(decisions == [true, false]) + #expect(viewModel.legacyPrivateMediaConsentRequest == nil) + } + + @Test @MainActor + func invalidatingPresentedLegacyConsentAdvancesQueueAndStaleResolutionNoops() async throws { + let (viewModel, _) = makeTestableViewModel() + let firstPeer = PeerID(str: "3333333333333333") + let secondPeer = PeerID(str: "4444444444444444") + var decisions: [String] = [] + + viewModel.enqueueLegacyPrivateMediaConsent( + for: firstPeer, + transferId: "transfer-cancelled", + messageID: "message-cancelled" + ) { decisions.append("first:\($0)") } + viewModel.enqueueLegacyPrivateMediaConsent( + for: secondPeer, + transferId: "transfer-kept", + messageID: "message-kept" + ) { decisions.append("second:\($0)") } + + let cancelledRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id) + viewModel.invalidateLegacyPrivateMediaConsent( + transferId: "transfer-cancelled", + messageID: "message-cancelled" + ) + let advanced = await TestHelpers.waitUntil( + { viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer }, + timeout: TestConstants.longTimeout + ) + #expect(advanced) + #expect(decisions.isEmpty, "Invalidation drops the request rather than resolving its send") + + viewModel.resolveLegacyPrivateMediaConsent( + requestID: cancelledRequestID, + approved: true + ) + #expect(viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer) + #expect(decisions.isEmpty) + + let keptRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id) + viewModel.resolveLegacyPrivateMediaConsent(requestID: keptRequestID, approved: true) + #expect(decisions == ["second:true"]) + #expect(viewModel.legacyPrivateMediaConsentRequest == nil) + } + @Test @MainActor func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws { let (viewModel, transport) = makeTestableViewModel() diff --git a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift new file mode 100644 index 00000000..9b163223 --- /dev/null +++ b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift @@ -0,0 +1,1449 @@ +import BitFoundation +import Combine +import CoreBluetooth +import Foundation +import Testing +@testable import bitchat + +/// Wire-level coverage for finalized DM media. The sender encrypts one typed +/// private-file payload, relays see only the outer Noise packet/fragments, and +/// the receiver reassembles, decrypts, validates, persists, and delivers it. +@Suite("Private media end to end", .serialized) +struct PrivateMediaEndToEndTests { + @Test + func privateMediaCancellationTombstonesAreCountBounded() async { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-tombstone-bound-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let service = makeService(baseDirectory: root) + + for index in 0..<600 { + service.cancelTransfer("cancelled-before-admission-\(index)") + } + + #expect(service._test_privateMediaAdmissionEntryCount() <= 512) + await service._test_drainPrivateMediaSendPipeline() + } + + @Test + func privateMediaAdmissionCapacityRejectsNewcomerWithoutEvictingActiveTransfer() async { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-admission-capacity-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let service = makeService(baseDirectory: root) + let now = Date() + let activeIDs = (0..<512).map { "capacity-active-\($0)" } + for transferId in activeIDs { + #expect(service._test_beginPrivateMediaAdmission(transferId, now: now)) + } + defer { + for transferId in activeIDs { + service._test_finishPrivateMediaAdmission(transferId) + } + } + + let overflowID = "capacity-overflow-\(UUID().uuidString)" + let rejections = TransferCancellationRecorder() + let cancellable = TransferProgressManager.shared.publisher.sink { rejections.record($0) } + let content = Data("%PDF-1.7\ncapacity".utf8) + service.sendFilePrivate( + BitchatFilePacket( + fileName: "capacity.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ), + to: PeerID(str: "1122334455667788"), + transferId: overflowID, + allowLegacyFallback: true + ) + + #expect(await TestHelpers.waitUntil( + { rejections.contains(overflowID) }, + timeout: TestConstants.longTimeout + )) + #expect(rejections.reason(for: overflowID) != nil) + #expect(service._test_isPrivateMediaAdmissionActive(activeIDs[0], now: now)) + #expect(service._test_privateMediaAdmissionEntryCount() == 512) + _ = cancellable + } + + @Test + func expiredActivePrivateMediaAdmissionEmitsVisibleFailure() async { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-admission-expiry-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let service = makeService(baseDirectory: root) + let transferId = "expired-active-\(UUID().uuidString)" + let admittedAt = Date(timeIntervalSince1970: 1_000) + let rejections = TransferCancellationRecorder() + let cancellable = TransferProgressManager.shared.publisher.sink { rejections.record($0) } + + #expect(service._test_beginPrivateMediaAdmission(transferId, now: admittedAt)) + #expect(!service._test_isPrivateMediaAdmissionActive( + transferId, + now: admittedAt.addingTimeInterval(60 * 60 + 1) + )) + #expect(await TestHelpers.waitUntil( + { rejections.contains(transferId) }, + timeout: TestConstants.longTimeout + )) + #expect(rejections.reason(for: transferId) != nil) + #expect(service._test_privateMediaAdmissionEntryCount() == 0) + _ = cancellable + } + + @Test + func approvedLegacySendCancelledBeforeDeferredAdmissionDoesNotTransmit() async throws { + try await assertApprovedLegacySendCancelledBeforeAdmission(label: "cancel") + } + + @Test + func approvedLegacySendDeletedBeforeDeferredAdmissionDoesNotTransmit() async throws { + // ChatMediaTransferCoordinator.deleteMediaMessage now invokes this same + // synchronous transport cancellation before removing its mapping; its + // coordinator-level call is covered separately in the context tests. + try await assertApprovedLegacySendCancelledBeforeAdmission(label: "delete") + } + + @Test + func panicSuspensionFinishesAdmissionAtInitialDeferredSendBoundary() async { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-panic-deferred-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let service = makeService(baseDirectory: root) + let tap = PacketTap() + service._test_onOutboundPacket = tap.record + service.suspendForPanicReset() + defer { service.completePanicReset(restartServices: false) } + + let transferId = "panic-deferred-\(UUID().uuidString)" + let content = Data("%PDF-1.7\npanic-deferred".utf8) + service.sendFilePrivate( + BitchatFilePacket( + fileName: "panic-deferred.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ), + to: PeerID(str: "1122334455667788"), + transferId: transferId, + allowLegacyFallback: true + ) + await service._test_drainPrivateMediaSendPipeline() + + let state = service._test_privateMediaTransferState(transferId: transferId) + #expect(!state.admissionActive) + #expect(!state.pendingNoise) + #expect(state.activeScheduler == 0) + #expect(state.pendingScheduler == 0) + #expect(service._test_privateMediaAdmissionEntryCount() == 0) + #expect(tap.snapshot().isEmpty) + } + + @Test + func panicSuspensionFinishesAdmissionAtBroadcastBoundary() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-panic-broadcast-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let service = makeService(baseDirectory: root) + let tap = PacketTap() + service._test_onOutboundPacket = tap.record + service.suspendForPanicReset() + defer { service.completePanicReset(restartServices: false) } + + let transferId = "panic-broadcast-\(UUID().uuidString)" + #expect(service._test_beginPrivateMediaAdmission(transferId, now: Date())) + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: Data(hexString: service.myPeerID.id) ?? Data(), + recipientID: Data(hexString: "1122334455667788"), + timestamp: UInt64(Date().timeIntervalSince1970 * 1_000), + payload: Data([NoisePayloadType.privateFile.rawValue]), + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + + service._test_broadcastPrivateMediaPacket(packet, transferId: transferId) + + #expect(!service._test_isPrivateMediaAdmissionActive(transferId, now: Date())) + #expect(service._test_privateMediaAdmissionEntryCount() == 0) + #expect(tap.snapshot().isEmpty) + } + + @Test + func legacyFallbackRequiresPerSendConsentAndConsumesItOnce() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-capability-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let aliceRoot = root.appendingPathComponent("alice", isDirectory: true) + let bobRoot = root.appendingPathComponent("bob", isDirectory: true) + let alice = makeService(baseDirectory: aliceRoot) + let bob = makeService(baseDirectory: bobRoot) + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Old Bob", + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + + let tap = PacketTap() + let delegate = MessageCaptureDelegate() + alice._test_onOutboundPacket = tap.record + bob.delegate = delegate + let content = Data("%PDF-1.7\nprivate".utf8) + let file = BitchatFilePacket( + fileName: "private.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ) + let cancellations = TransferCancellationRecorder() + let cancellable = TransferProgressManager.shared.publisher.sink { cancellations.record($0) } + + let deniedID = "legacy-without-consent-\(UUID().uuidString)" + alice.sendFilePrivate( + file, + to: bob.myPeerID, + transferId: deniedID + ) + let denied = await TestHelpers.waitUntil( + { cancellations.contains(deniedID) }, + timeout: TestConstants.longTimeout + ) + #expect(denied) + #expect(tap.snapshot().allSatisfy { $0.type != MessageType.fileTransfer.rawValue }) + + let allowedID = "legacy-with-consent-\(UUID().uuidString)" + alice.sendFilePrivate( + file, + to: bob.myPeerID, + transferId: allowedID, + allowLegacyFallback: true + ) + + let sent = await TestHelpers.waitUntil( + { tap.snapshot().contains { $0.type == MessageType.fileTransfer.rawValue } }, + timeout: TestConstants.longTimeout + ) + #expect(sent) + + let outbound = tap.snapshot() + let rawTransfers = outbound.filter { $0.type == MessageType.fileTransfer.rawValue } + let raw = try #require(rawTransfers.first) + #expect(rawTransfers.count == 1, "Migration fallback must never dual-send") + #expect(outbound.allSatisfy { $0.type != MessageType.noiseEncrypted.rawValue }) + #expect(raw.recipientID == Data(hexString: bob.myPeerID.toShort().id)) + #expect(raw.signature?.count == 64) + #expect(BitchatFilePacket.decode(raw.payload)?.content == content) + + // Exercise the normal raw receive path with Alice's actual signing + // key. The migration fallback is accepted because it is directed and + // signed; the handler still rejects unsigned/forged raw transfers. + bob._test_handlePacket( + raw, + fromPeerID: alice.myPeerID, + signingPublicKey: alice.noiseSigningPublicKeyData() + ) + let delivered = await TestHelpers.waitUntil( + { delegate.snapshot().count == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(delivered) + #expect(delegate.snapshot().first?.isPrivate == true) + #expect(recursivelyStoredFiles(under: bobRoot).count == 1) + + // Consent is invocation-scoped, not a sticky peer preference. + let retryID = "legacy-retry-without-consent-\(UUID().uuidString)" + alice.sendFilePrivate(file, to: bob.myPeerID, transferId: retryID) + let retryDenied = await TestHelpers.waitUntil( + { cancellations.contains(retryID) }, + timeout: TestConstants.longTimeout + ) + #expect(retryDenied) + #expect(tap.snapshot().filter { $0.type == MessageType.fileTransfer.rawValue }.count == 1) + _ = cancellable + } + + @Test + func authenticatedPrivateMediaCapabilityPinsAgainstRawDowngrade() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-pin-\(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 bobKey = bob.noiseStaticPublicKeyData() + + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: .privateMedia, + noisePublicKey: bobKey + ) + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof) + let bobFingerprint = bobKey.sha256Fingerprint() + #expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint)) + + try await establishSession(alice: alice, bob: bob) + let capabilityPinned = await TestHelpers.waitUntil( + { identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint) }, + timeout: TestConstants.longTimeout + ) + #expect(capabilityPinned) + + #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() + alice._test_onOutboundPacket = tap.record + let transferID = "pinned-downgrade-\(UUID().uuidString)" + let cancellations = TransferCancellationRecorder() + let cancellable = TransferProgressManager.shared.publisher.sink { cancellations.record($0) } + let content = Data("%PDF-1.7\nblocked".utf8) + alice.sendFilePrivate( + BitchatFilePacket( + fileName: "blocked.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ), + to: bob.myPeerID, + transferId: transferID, + allowLegacyFallback: true + ) + + let blocked = await TestHelpers.waitUntil( + { cancellations.contains(transferID) }, + timeout: TestConstants.longTimeout + ) + #expect(blocked) + #expect(tap.snapshot().allSatisfy { $0.type != MessageType.fileTransfer.rawValue }) + _ = cancellable + } + + @Test + func unpinnedExplicitCapabilitiesWithoutPrivateMediaRequireConsent() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-explicit-capabilities-\(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: "Modern Bob", + capabilities: [], + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent) + } + + @Test + func capabilityAnnounceCannotPoisonPinWithoutMatchingNoiseAuthentication() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-poisoning-\(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 bobFingerprint = bob.noiseStaticPublicKeyData().sha256Fingerprint() + let capableAnnounce = try signedAnnounce( + from: bob, + capabilities: .privateMedia + ) + alice._test_handlePacket( + capableAnnounce, + fromPeerID: bob.myPeerID, + preseedPeer: false + ) + let advertised = await TestHelpers.waitUntil( + { alice.privateMediaSendPolicy(to: bob.myPeerID) == .awaitingCapabilityProof }, + timeout: TestConstants.longTimeout + ) + #expect(advertised) + // The production signed-announce path ran, but with no authenticated + // session it must remain a no-op. Querying policy is side-effect free. + #expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint)) + + let noBitAnnounce = try signedAnnounce( + from: bob, + capabilities: [] + ) + alice._test_handlePacket( + noBitAnnounce, + fromPeerID: bob.myPeerID, + preseedPeer: false + ) + let remainedLegacyEligible = await TestHelpers.waitUntil( + { alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent }, + timeout: TestConstants.longTimeout + ) + #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 panicDropsPendingPrivateMediaPolicyCompletion() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "private-media-policy-panic-\(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.suspendForPanicReset() + alice.resetIdentityForPanic( + currentNickname: "anon", + restartServices: false + ) + alice._test_forcePrivateMediaProofTimeout(for: bob.myPeerID) + await Task.yield() + + #expect(recorder.snapshot() == nil) + #expect( + !alice._test_hasPendingPrivateMediaPolicyResolution( + for: bob.myPeerID + ) + ) + } + + @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 + .appendingPathComponent("private-media-key-mismatch-\(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 impostor = makeService(baseDirectory: root.appendingPathComponent("impostor", isDirectory: true)) + let impostorKey = impostor.noiseStaticPublicKeyData() + let reconciliations = PeerIDRecorder() + alice._test_onPrivateMediaSessionReconciled = reconciliations.record + + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: .privateMedia, + noisePublicKey: impostorKey + ) + try await establishSession(alice: alice, bob: bob) + + let sessionReconciled = await TestHelpers.waitUntil( + { reconciliations.contains(bob.myPeerID) }, + timeout: TestConstants.longTimeout + ) + #expect(sessionReconciled) + #expect(!identity.hasObservedPrivateMediaCapability( + fingerprint: impostorKey.sha256Fingerprint() + )) + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: [], + noisePublicKey: impostorKey + ) + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent) + } + + @Test + func capabilityAnnounceAfterNoiseSessionStillRequiresEncryptedProof() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-race-\(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 proofs = try await establishSessionCapturingPeerState(alice: alice, bob: bob) + let bobKey = bob.noiseStaticPublicKeyData() + let capableAnnounce = try signedAnnounce( + from: bob, + capabilities: .privateMedia + ) + alice._test_handlePacket( + capableAnnounce, + fromPeerID: bob.myPeerID, + 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( + fingerprint: bobKey.sha256Fingerprint() + ) + }, + timeout: TestConstants.longTimeout + ) + #expect(pinned) + bob._test_handlePacket(proofs.alice, fromPeerID: alice.myPeerID) + alice._test_onOutboundPacket = nil + bob._test_onOutboundPacket = nil + } + + @Test + func consentedLegacySendRejectsAboveAndroidFragmentCapButEncryptedDoesNot() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-fragment-cap-\(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)) + let oldCarol = makeService(baseDirectory: root.appendingPathComponent("carol", isDirectory: true)) + + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: .privateMedia, + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + alice._test_seedConnectedPeer( + oldCarol.myPeerID, + nickname: "Old Carol", + noisePublicKey: oldCarol.noiseStaticPublicKeyData() + ) + try await establishSession(alice: alice, bob: bob) + + var state: UInt64 = 0x1234_5678_9ABC_DEF0 + let body = Data((0..<(130 * 1024)).map { _ in + state = state &* 6364136223846793005 &+ 1442695040888963407 + return UInt8(truncatingIfNeeded: state >> 32) + }) + let content = Data("%PDF-1.7\n".utf8) + body + let file = BitchatFilePacket( + fileName: "too-many-fragments.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ) + + let tap = PacketTap() + alice._test_onOutboundPacket = tap.record + let rejections = TransferCancellationRecorder() + let cancellable = TransferProgressManager.shared.publisher.sink { rejections.record($0) } + let encryptedID = "encrypted-over-256-\(UUID().uuidString)" + let legacyID = "legacy-over-256-\(UUID().uuidString)" + + alice.sendFilePrivate(file, to: bob.myPeerID, transferId: encryptedID) + alice.sendFilePrivate( + file, + to: oldCarol.myPeerID, + transferId: legacyID, + allowLegacyFallback: true + ) + + // The directed raw-file migration fallback (Android-style peer without + // the .privateMedia capability) still honors the 256-fragment ceiling. + let legacyRejected = await TestHelpers.waitUntil( + { rejections.contains(legacyID) }, + timeout: TestConstants.longTimeout + ) + #expect(legacyRejected) + #expect(rejections.reason(for: legacyID)?.contains("256") == true) + + // Encrypted private media to a .privateMedia-capable peer is NOT forced + // down to Android's 256 cap: it uses the full receiver ceiling and + // proceeds to fragment/emit (a 130 KiB file exceeds 256 fragments). + let encryptedEmitted = await TestHelpers.waitUntil( + { !tap.snapshot().isEmpty }, + timeout: TestConstants.longTimeout + ) + #expect(encryptedEmitted, "Encrypted send to a capable peer must not be blocked by the Android cap") + #expect(!rejections.contains(encryptedID)) + _ = cancellable + } + + @Test + func queuedPrivateEncryptionFailureRejectsBoundTransfer() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-queued-failure-\(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)) + try await establishSession(alice: alice, bob: bob) + + let transferID = "queued-encryption-failure-\(UUID().uuidString)" + let rejections = TransferCancellationRecorder() + let cancellable = TransferProgressManager.shared.publisher.sink { rejections.record($0) } + var oversizedTypedPayload = Data([NoisePayloadType.privateFile.rawValue]) + oversizedTypedPayload.append(Data( + repeating: 0x42, + count: NoiseSecurityConstants.maxPrivateFilePlaintextSize + )) + + alice._test_enqueuePendingNoisePayload( + oversizedTypedPayload, + transferId: transferID, + for: bob.myPeerID + ) + alice._test_sendPendingNoisePayloadsAfterHandshake(for: bob.myPeerID) + + let rejected = await TestHelpers.waitUntil( + { rejections.contains(transferID) }, + timeout: TestConstants.longTimeout + ) + #expect(rejected) + #expect(rejections.reason(for: transferID)?.isEmpty == false) + _ = cancellable + } + + @Test + func canonical0x20EncryptedFileIsAcceptedAcrossV1OuterPacket() async throws { + let content = Data("%PDF-1.7\nandroid-private".utf8) + try await assertInboundEncryptedPrivateMedia( + typeByte: 0x20, + content: content, + outerVersion: 1, + directoryLabel: "android-0x20" + ) + } + + @Test + func prerelease0x09LargeEncryptedFileIsAcceptedDuringMigration() async throws { + let content = Data("%PDF-1.7\nprerelease-private".utf8) + + Data(repeating: 0x39, count: 70 * 1024) + #expect(content.count > NoiseSecurityConstants.maxMessageSize) + try await assertInboundEncryptedPrivateMedia( + typeByte: NoisePayloadType.prereleasePrivateFileRawValue, + content: content, + outerVersion: 2, + directoryLabel: "prerelease-0x09" + ) + } + + @Test + func privateJPEGIsOpaqueBeforeFragmentationAndDelivers() async throws { + let marker = Data("JPEG_PRIVATE_MARKER_7f5e5eacb86f4b9a".utf8) + let content = Data([0xFF, 0xD8, 0xFF, 0xE0]) + + marker + + Data(repeating: 0x4A, count: 6 * 1024) + try await assertPrivateMediaRoundTrip( + fileName: "private.jpg", + mimeType: "image/jpeg", + content: content, + marker: marker, + expectedMessagePrefix: "[image]" + ) + } + + @Test + func finalizedPrivateM4AIsOpaqueBeforeFragmentationAndDelivers() async throws { + let marker = Data("M4A_PRIVATE_MARKER_e0cd431b61fb4a6c".utf8) + let content = Data([0x00, 0x00, 0x00, 0x18]) + + Data("ftypM4A ".utf8) + + marker + + Data(repeating: 0x4D, count: 6 * 1024) + try await assertPrivateMediaRoundTrip( + fileName: "voice_0011223344556677.m4a", + mimeType: "audio/mp4", + content: content, + marker: marker, + expectedMessagePrefix: "[voice]" + ) + } + + @Test + func capablePeerUsesCanonicalAndroid0x20EncryptedSend() async throws { + let marker = Data("PDF_PRIVATE_MARKER_b333f84b8fc7478d".utf8) + let content = Data("%PDF-1.7\n".utf8) + + marker + + Data(repeating: 0x50, count: 6 * 1024) + let file = BitchatFilePacket( + fileName: "private.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ) + #expect(BLENoisePayloadFactory.privateFile(file)?.first == 0x20) + try await assertPrivateMediaRoundTrip( + fileName: "private.pdf", + mimeType: "application/pdf", + content: content, + marker: marker, + expectedMessagePrefix: "[file]" + ) + } + + @Test + func privateMediaAboveOrdinaryNoiseLimitUsesV2OuterPacketAndDelivers() async throws { + let marker = Data("LARGE_PRIVATE_MARKER_1ec63f261a7041ee".utf8) + let content = Data("%PDF-1.7\n".utf8) + + marker + + Data(repeating: 0x4C, count: 70 * 1024) + try await assertPrivateMediaRoundTrip( + fileName: "large-private.pdf", + mimeType: "application/pdf", + content: content, + marker: marker, + expectedMessagePrefix: "[file]", + expectedOuterVersion: 2 + ) + } + + /// Models an already-established remote sender independently of the local + /// send policy. Exact Android b7f0b33d plaintext bytes are frozen in + /// `BLENoisePayloadFactoryTests`; this helper exercises the encrypted + /// inbound transport around that shared wire encoding. + private func assertInboundEncryptedPrivateMedia( + typeByte: UInt8, + content: Data, + outerVersion: UInt8, + directoryLabel: String + ) async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-\(directoryLabel)-\(UUID().uuidString)", isDirectory: true) + let aliceRoot = root.appendingPathComponent("alice", isDirectory: true) + let bobRoot = root.appendingPathComponent("bob", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let alice = makeService(baseDirectory: aliceRoot) + let bob = makeService(baseDirectory: bobRoot) + let delegate = MessageCaptureDelegate() + bob.delegate = delegate + try await establishSession(alice: alice, bob: bob) + + let file = BitchatFilePacket( + fileName: "\(directoryLabel).pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ) + let encodedFile = try #require(file.encode()) + var typedPayload = Data([typeByte]) + typedPayload.append(encodedFile) + + let encrypted = try alice._test_makeEncryptedNoisePacket(typedPayload, to: bob.myPeerID) + let remoteShapedPacket = BitchatPacket( + type: encrypted.type, + senderID: encrypted.senderID, + recipientID: encrypted.recipientID, + timestamp: encrypted.timestamp, + payload: encrypted.payload, + signature: nil, + ttl: encrypted.ttl, + version: outerVersion + ) + bob._test_handlePacket(remoteShapedPacket, fromPeerID: alice.myPeerID) + + let delivered = await TestHelpers.waitUntil( + { delegate.snapshot().count == 1 }, + timeout: TestConstants.longTimeout + ) + #expect(delivered) + #expect(delegate.snapshot().first?.isPrivate == true) + let stored = recursivelyStoredFiles(under: bobRoot) + #expect(stored.count == 1) + if let storedURL = stored.first { + #expect(try Data(contentsOf: storedURL) == content) + } + } + + private func assertApprovedLegacySendCancelledBeforeAdmission(label: String) async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-admission-\(label)-\(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: "Legacy Bob", + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + + let transferId = "approved-\(label)-\(UUID().uuidString)" + let gate = PrivateMediaDeferredSendGate() + let tap = PacketTap() + alice._test_onOutboundPacket = tap.record + alice._test_beforePrivateMediaDeferredSend = { id in + guard id == transferId else { return } + gate.pause() + } + defer { + gate.release() + alice._test_beforePrivateMediaDeferredSend = nil + } + + let content = Data("%PDF-1.7\ncancelled-before-admission".utf8) + alice.sendFilePrivate( + BitchatFilePacket( + fileName: "cancelled.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ), + to: bob.myPeerID, + transferId: transferId, + allowLegacyFallback: true + ) + + let paused = await TestHelpers.waitUntil( + { gate.hasPaused }, + timeout: TestConstants.longTimeout + ) + #expect(paused) + + // This is the transport action used by both cancel and delete. It must + // invalidate synchronously while messageQueue is still held above. + alice.cancelTransfer(transferId) + gate.release() + await alice._test_drainPrivateMediaSendPipeline() + + let state = alice._test_privateMediaTransferState(transferId: transferId) + #expect(!state.admissionActive) + #expect(!state.pendingNoise) + #expect(state.activeScheduler == 0) + #expect(state.pendingScheduler == 0) + #expect(await TestHelpers.waitUntil( + { alice._test_privateMediaAdmissionEntryCount() == 0 }, + timeout: TestConstants.longTimeout + )) + #expect(tap.snapshot().allSatisfy { + $0.type != MessageType.fileTransfer.rawValue + && $0.type != MessageType.noiseEncrypted.rawValue + }) + } + + private func assertPrivateMediaRoundTrip( + fileName: String, + mimeType: String, + content: Data, + marker: Data, + expectedMessagePrefix: String, + expectedOuterVersion: UInt8 = 2 + ) async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-e2e-\(UUID().uuidString)", isDirectory: true) + let aliceRoot = root.appendingPathComponent("alice", isDirectory: true) + let bobRoot = root.appendingPathComponent("bob", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let alice = makeService(baseDirectory: aliceRoot) + let bob = makeService(baseDirectory: bobRoot) + let tap = PacketTap() + let delegate = MessageCaptureDelegate() + bob.delegate = delegate + + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: .privateMedia, + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + bob._test_seedConnectedPeer( + alice.myPeerID, + nickname: "Alice", + capabilities: .privateMedia, + noisePublicKey: alice.noiseStaticPublicKeyData() + ) + try await establishSession(alice: alice, bob: bob) + alice._test_onOutboundPacket = tap.record + + let file = BitchatFilePacket( + fileName: fileName, + fileSize: UInt64(content.count), + mimeType: mimeType, + content: content + ) + alice.sendFilePrivate(file, to: bob.myPeerID, transferId: "wire-\(UUID().uuidString)") + + let fragmented = await TestHelpers.waitUntil( + { tap.hasCompleteFragmentTrain }, + timeout: 10 + ) + #expect(fragmented) + + let outbound = tap.snapshot() + let encryptedPackets = outbound.filter { $0.type == MessageType.noiseEncrypted.rawValue } + let fragments = outbound + .filter { $0.type == MessageType.fragment.rawValue } + .sorted { fragmentIndex($0) < fragmentIndex($1) } + + #expect(encryptedPackets.count == 1) + #expect(encryptedPackets.first?.version == expectedOuterVersion) + #expect(!fragments.isEmpty) + #expect(outbound.allSatisfy { $0.type != MessageType.fileTransfer.rawValue }) + for packet in encryptedPackets + fragments { + #expect(packet.payload.range(of: marker) == nil) + #expect(packet.payload.range(of: content) == nil) + } + + // Real BLE delivers the train at the scheduler's paced interval. Feed + // bounded batches here instead of enqueuing hundreds of synthetic + // callbacks at once, which can exhaust libdispatch worker threads as + // they wait on the fragment-assembly barrier. + for batchStart in stride(from: 0, to: fragments.count, by: 16) { + let batchEnd = min(batchStart + 16, fragments.count) + for fragment in fragments[batchStart.. BLEService { + let keychain = MockKeychain() + return BLEService( + keychain: keychain, + idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()), + identityManager: identityManager ?? MockIdentityManager(keychain), + initializeBluetoothManagers: false, + incomingFileStore: BLEIncomingFileStore(baseDirectory: baseDirectory) + ) + } + + 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) + ) + let third = try #require( + try alice._test_noiseProcessHandshakeMessage(from: bob.myPeerID, message: second) + ) + _ = 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( + from service: BLEService, + capabilities: PeerCapabilities? + ) throws -> BitchatPacket { + let announcement = AnnouncementPacket( + nickname: "Bob", + noisePublicKey: service.noiseStaticPublicKeyData(), + signingPublicKey: service.noiseSigningPublicKeyData(), + directNeighbors: nil, + capabilities: capabilities + ) + let payload = try #require(announcement.encode()) + let unsigned = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: service.myPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1_000), + payload: payload, + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + 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, + includingPropertiesForKeys: [.isRegularFileKey] + ) else { return [] } + + return enumerator.compactMap { item in + guard let url = item as? URL, + (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else { + return nil + } + return url + } + } +} + +private func fragmentIndex(_ packet: BitchatPacket) -> Int { + guard packet.payload.count >= 10 else { return .max } + return (Int(packet.payload[8]) << 8) | Int(packet.payload[9]) +} + +private final class PacketTap: @unchecked Sendable { + private let lock = NSLock() + private var packets: [BitchatPacket] = [] + + func record(_ packet: BitchatPacket) { + lock.lock() + packets.append(packet) + lock.unlock() + } + + func snapshot() -> [BitchatPacket] { + lock.lock() + defer { lock.unlock() } + return packets + } + + var hasCompleteFragmentTrain: Bool { + let fragments = snapshot().filter { $0.type == MessageType.fragment.rawValue } + guard let first = fragments.first, first.payload.count >= 12 else { return false } + let total = (Int(first.payload[10]) << 8) | Int(first.payload[11]) + return total > 0 && fragments.count >= total + } +} + +private final class PrivateMediaDeferredSendGate: @unchecked Sendable { + private let condition = NSCondition() + private var paused = false + private var released = false + + var hasPaused: Bool { + condition.lock() + defer { condition.unlock() } + return paused + } + + func pause() { + condition.lock() + paused = true + condition.broadcast() + while !released { + condition.wait() + } + condition.unlock() + } + + func release() { + condition.lock() + released = true + condition.broadcast() + condition.unlock() + } +} + +private final class PeerIDRecorder: @unchecked Sendable { + private let lock = NSLock() + private var peerIDs: [PeerID] = [] + + func record(_ peerID: PeerID) { + lock.lock() + peerIDs.append(peerID) + lock.unlock() + } + + func contains(_ peerID: PeerID) -> Bool { + lock.lock() + defer { lock.unlock() } + return peerIDs.contains(peerID) + } +} + +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] = [] + + func didReceiveMessage(_ message: BitchatMessage) { + lock.lock() + messages.append(message) + lock.unlock() + } + + func snapshot() -> [BitchatMessage] { + lock.lock() + defer { lock.unlock() } + return messages + } + + func didConnectToPeer(_ peerID: PeerID) {} + func didDisconnectFromPeer(_ peerID: PeerID) {} + func didUpdatePeerList(_ peers: [PeerID]) {} + func didUpdateBluetoothState(_ state: CBManagerState) {} +} + +private final class TransferCancellationRecorder: @unchecked Sendable { + private let lock = NSLock() + private var transferIDs: Set = [] + private var rejectionReasons: [String: String] = [:] + + func record(_ event: TransferProgressManager.Event) { + let id: String + switch event { + case .cancelled(let cancelledID, _, _): + id = cancelledID + case .rejected(let rejectedID, _): + id = rejectedID + case .started, .updated, .completed: + return + } + lock.lock() + transferIDs.insert(id) + if case .rejected(_, let reason) = event { + rejectionReasons[id] = reason + } + lock.unlock() + } + + func contains(_ transferID: String) -> Bool { + lock.lock() + defer { lock.unlock() } + return transferIDs.contains(transferID) + } + + + func reason(for transferID: String) -> String? { + lock.lock() + defer { lock.unlock() } + return rejectionReasons[transferID] + } +} diff --git a/bitchatTests/Mocks/MockIdentityManager.swift b/bitchatTests/Mocks/MockIdentityManager.swift index a3603017..930d57ff 100644 --- a/bitchatTests/Mocks/MockIdentityManager.swift +++ b/bitchatTests/Mocks/MockIdentityManager.swift @@ -14,6 +14,8 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { private var blockedFingerprints: Set = [] private var blockedNostrPubkeys: Set = [] private var socialIdentities: [String: SocialIdentity] = [:] + private var privateMediaCapableFingerprints: Set = [] + private var authenticatedSigningKeys: [String: Data] = [:] init(_: KeychainManagerProtocol) {} @@ -87,7 +89,10 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {} - func clearAllIdentityData() {} + func clearAllIdentityData() { + privateMediaCapableFingerprints.removeAll() + authenticatedSigningKeys.removeAll() + } func removeEphemeralSession(peerID: PeerID) {} @@ -101,6 +106,22 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { Set() } + func markPrivateMediaCapable(fingerprint: String) { + privateMediaCapableFingerprints.insert(fingerprint) + } + + func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { + 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 ad01a1c4..b383c760 100644 --- a/bitchatTests/Mocks/MockTransport.swift +++ b/bitchatTests/Mocks/MockTransport.swift @@ -36,6 +36,7 @@ final class MockTransport: Transport { private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = [] private(set) var sentBroadcastFiles: [(packet: BitchatFilePacket, transferID: String)] = [] private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = [] + private(set) var sentPrivateFileLegacyAllowances: [Bool] = [] private(set) var cancelledTransfers: [String] = [] private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] @@ -58,6 +59,7 @@ final class MockTransport: Transport { var peerNicknames: [PeerID: String] = [:] var peerFingerprints: [PeerID: String] = [:] var peerNoiseStates: [PeerID: LazyHandshakeState] = [:] + var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:] private let mockKeychain = MockKeychain() // MARK: - Transport Protocol Implementation @@ -186,6 +188,29 @@ final class MockTransport: Transport { func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { sentPrivateFiles.append((packet, peerID, transferId)) + sentPrivateFileLegacyAllowances.append(false) + } + + func sendFilePrivate( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + allowLegacyFallback: Bool + ) { + sentPrivateFiles.append((packet, peerID, transferId)) + sentPrivateFileLegacyAllowances.append(allowLegacyFallback) + } + + func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { + 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) { diff --git a/bitchatTests/Noise/NoiseCoverageTests.swift b/bitchatTests/Noise/NoiseCoverageTests.swift index 4ff7a04a..f1e85e4a 100644 --- a/bitchatTests/Noise/NoiseCoverageTests.swift +++ b/bitchatTests/Noise/NoiseCoverageTests.swift @@ -542,8 +542,12 @@ struct NoiseCoverageTests { let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain) let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain) - aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:) - bobManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:) + aliceManager.onSessionEstablished = establishedRecorder.recordEstablished( + peerID:remoteKey:sessionGeneration: + ) + bobManager.onSessionEstablished = establishedRecorder.recordEstablished( + peerID:remoteKey:sessionGeneration: + ) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) @@ -650,13 +654,121 @@ 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) #expect(rekeyedSession.getState() == .handshaking) } + @Test("A stale decrypt generation cannot commit across session promotion") + func staleDecryptGenerationCannotCommitAcrossPromotion() throws { + let aliceManager = NoiseSessionManager( + localStaticKey: aliceStaticKey, + keychain: keychain, + sessionFactory: { peerID, role in + BlockingDecryptNoiseSession( + peerID: peerID, + role: role, + keychain: self.keychain, + localStaticKey: self.aliceStaticKey + ) + } + ) + let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain) + try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) + + let oldSession = try #require( + aliceManager.getSession(for: alicePeerID) as? BlockingDecryptNoiseSession + ) + let oldGeneration = try #require(aliceManager.sessionGeneration(for: alicePeerID)) + + // Prepare a fully authenticated responder candidate without promoting + // it yet. Its final XX message is the exact operation that replaces + // the old `sessions[peerID]` entry. + let replacementInitiator = NoiseSession( + peerID: bobPeerID, + role: .initiator, + keychain: keychain, + localStaticKey: bobStaticKey + ) + let message1 = try replacementInitiator.startHandshake() + let message2 = try #require( + try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message1) + ) + let message3 = try #require(try replacementInitiator.processHandshakeMessage(message2)) + + let ciphertext = try bobManager.encrypt(Data("old session".utf8), for: bobPeerID) + oldSession.pauseNextDecrypt() + + let decryptResult = ConcurrentTestResult<(plaintext: Data, sessionGeneration: UUID)>() + var promotionResultForCleanup: ConcurrentTestResult? + defer { + // A failed startup requirement must not strand a late thread in + // the blocking test double after the test has returned. + oldSession.resumeDecrypt() + _ = decryptResult.wait(timeout: 5) + if let promotionResultForCleanup { + _ = promotionResultForCleanup.wait(timeout: 5) + } + } + + let decryptThread = Thread { + decryptResult.capture { + try aliceManager.decryptWithSessionGeneration(ciphertext, from: self.alicePeerID) + } + } + decryptThread.name = "NoiseCoverageTests.staleDecrypt.decrypt" + decryptThread.qualityOfService = .userInitiated + decryptThread.start() + try #require(oldSession.waitForDecryptStart(timeout: 5)) + + let promotionStarted = DispatchSemaphore(value: 0) + let promotionResult = ConcurrentTestResult() + promotionResultForCleanup = promotionResult + let promotionThread = Thread { + promotionStarted.signal() + promotionResult.capture { + try aliceManager.handleIncomingHandshake(from: self.alicePeerID, message: message3) + } + } + promotionThread.name = "NoiseCoverageTests.staleDecrypt.promote" + promotionThread.qualityOfService = .userInitiated + promotionThread.start() + try #require(promotionStarted.wait(timeout: .now() + 5) == .success) + #expect( + promotionResult.wait(timeout: 0.05) == nil, + "Promotion must wait for the exact decrypting-session lease" + ) + + oldSession.resumeDecrypt() + let decrypted = try #require(decryptResult.wait(timeout: 5)).get() + _ = try #require(promotionResult.wait(timeout: 5)).get() + + #expect(decrypted.plaintext == Data("old session".utf8)) + #expect(decrypted.sessionGeneration == oldGeneration) + #expect(aliceManager.sessionGeneration(for: alicePeerID) != oldGeneration) + #expect(throws: NoiseEncryptionError.sessionNotEstablished) { + try aliceManager.encrypt( + Data("stale send".utf8), + for: alicePeerID, + expectedSessionGeneration: oldGeneration + ) + } + + var staleCommitRan = false + let staleCommit = aliceManager.withCurrentSessionGeneration( + for: alicePeerID, + expected: decrypted.sessionGeneration + ) { + staleCommitRan = true + return true + } + #expect(staleCommit == nil) + #expect(!staleCommitRan) + } + @Test("Secure noise sessions enforce limits and renegotiation thresholds") func secureNoiseSessionsEnforceLimitsAndThresholds() throws { let initiator = SecureNoiseSession( @@ -851,7 +963,11 @@ private final class SessionCallbackRecorder: @unchecked Sendable { return establishedEntries.map(\.0) } - func recordEstablished(peerID: PeerID, remoteKey: Curve25519.KeyAgreement.PublicKey) { + func recordEstablished( + peerID: PeerID, + remoteKey: Curve25519.KeyAgreement.PublicKey, + sessionGeneration _: UUID + ) { lock.lock() establishedEntries.append((peerID, remoteKey.rawRepresentation)) lock.unlock() @@ -873,3 +989,62 @@ private final class FailingNoiseSession: NoiseSession { throw Error.synthetic } } + +private final class BlockingDecryptNoiseSession: NoiseSession, @unchecked Sendable { + private let controlLock = NSLock() + private var shouldPauseNextDecrypt = false + private let decryptStarted = DispatchSemaphore(value: 0) + private let resumeDecryptSemaphore = DispatchSemaphore(value: 0) + + func pauseNextDecrypt() { + controlLock.lock() + shouldPauseNextDecrypt = true + controlLock.unlock() + } + + func waitForDecryptStart(timeout: TimeInterval) -> Bool { + decryptStarted.wait(timeout: .now() + timeout) == .success + } + + func resumeDecrypt() { + resumeDecryptSemaphore.signal() + } + + override func decrypt(_ ciphertext: Data) throws -> Data { + controlLock.lock() + let shouldPause = shouldPauseNextDecrypt + shouldPauseNextDecrypt = false + controlLock.unlock() + + if shouldPause { + decryptStarted.signal() + resumeDecryptSemaphore.wait() + } + return try super.decrypt(ciphertext) + } +} + +private final class ConcurrentTestResult: @unchecked Sendable { + private let lock = NSLock() + private let completed = DispatchGroup() + private var storedResult: Result? + + init() { + completed.enter() + } + + func capture(_ operation: () throws -> Value) { + let result = Result(catching: operation) + lock.lock() + storedResult = result + lock.unlock() + completed.leave() + } + + func wait(timeout: TimeInterval) -> Result? { + guard completed.wait(timeout: .now() + timeout) == .success else { return nil } + lock.lock() + defer { lock.unlock() } + return storedResult + } +} diff --git a/bitchatTests/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 7911b4e2..d1589303 100644 --- a/bitchatTests/Services/BLEAnnounceHandlerTests.swift +++ b/bitchatTests/Services/BLEAnnounceHandlerTests.swift @@ -9,6 +9,7 @@ struct BLEAnnounceHandlerTests { var existingSigningPublicKey: Data? var persistedSigningPublicKey: Data? var persistedSigningKeyQueries: [PeerID] = [] + var authenticatedSigningPublicKey: Data? var signatureValid = true var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false) var linkBoundToOtherPeer = false @@ -44,6 +45,7 @@ struct BLEAnnounceHandlerTests { recorder.persistedSigningKeyQueries.append(peerID) return recorder.persistedSigningPublicKey }, + authenticatedSigningPublicKey: { _ in recorder.authenticatedSigningPublicKey }, verifySignature: { packet, signingPublicKey in recorder.verifySignatureCalls.append((packet, signingPublicKey)) return recorder.signatureValid @@ -703,6 +705,7 @@ struct BLEAnnounceHandlerTests { return (info?.noisePublicKey, info?.signingPublicKey) }, persistedSigningPublicKey: { _ in nil }, + authenticatedSigningPublicKey: { _ in nil }, verifySignature: { packet, signingPublicKey in victim.verifyPacketSignature(packet, publicKey: signingPublicKey) }, @@ -817,6 +820,7 @@ struct BLEAnnounceHandlerTests { .compactMap { $0.signingPublicKey } .first }, + authenticatedSigningPublicKey: { _ in nil }, verifySignature: { packet, signingPublicKey in victim.verifyPacketSignature(packet, publicKey: signingPublicKey) }, diff --git a/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift b/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift index 8004cc48..97fa211f 100644 --- a/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift +++ b/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift @@ -213,6 +213,23 @@ struct BLEAnnounceHandlingPolicyTests { #expect(decision == .verified) } + @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/BLEFileTransferHandlerTests.swift b/bitchatTests/Services/BLEFileTransferHandlerTests.swift index 5a898c58..6371387c 100644 --- a/bitchatTests/Services/BLEFileTransferHandlerTests.swift +++ b/bitchatTests/Services/BLEFileTransferHandlerTests.swift @@ -33,6 +33,7 @@ struct BLEFileTransferHandlerTests { recorder.signatureVerifyCount += 1 return recorder.signatureVerifies }, + localSigningPublicKey: { [sampleSigningKey] in sampleSigningKey }, signedSenderDisplayName: { _, peerID in recorder.signedNameQueries.append(peerID) return recorder.signedName @@ -92,12 +93,11 @@ struct BLEFileTransferHandlerTests { @Test func selfEchoIsDropped() throws { let recorder = Recorder() + recorder.signatureVerifies = true let handler = makeHandler(recorder: recorder) let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3) - // The relay pipeline already suppresses self-originated packets, so the - // handler reports "relayable" rather than treating the echo as forged. - #expect(handler.handle(packet, from: localPeerID)) + #expect(!handler.handle(packet, from: localPeerID)) expectNoSideEffects(recorder) } @@ -120,7 +120,12 @@ struct BLEFileTransferHandlerTests { let recorder = Recorder() recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)] let handler = makeHandler(recorder: recorder) - let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8)) + let packet = try makeFileTransferPacket( + sender: remotePeerID, + mimeType: "application/pdf", + content: Data("%PDF-1.7".utf8), + hasSignature: false + ) // Failed sender authentication must also stop the packet from being // relayed to downstream nodes. @@ -129,7 +134,7 @@ struct BLEFileTransferHandlerTests { // Broadcast files carry an attacker-controllable senderID, so β€” like // public messages β€” a connected-but-unverified peer must present a valid // packet signature. No signing key + no signed identity means dropped. - #expect(recorder.signedNameQueries == [remotePeerID]) + #expect(recorder.signedNameQueries.isEmpty) #expect(recorder.trackedPackets.isEmpty) #expect(recorder.deliveredMessages.isEmpty) } @@ -153,12 +158,11 @@ struct BLEFileTransferHandlerTests { } @Test - func selfBroadcastReplayIsDeliveredWithoutSignatureCheck() throws { - // Our own broadcast file replayed via gossip sync arrives with ttl==0 - // (so it is not treated as a self-echo) and cannot be verified against - // the peer registry β€” it must still be accepted, matching - // BLEPublicMessageHandler's self exemption. + func signedSelfBroadcastReplayIsDelivered() throws { + // Our own broadcast file replayed via gossip sync arrives with ttl==0; + // it is verified against our local signing key before delivery. let recorder = Recorder() + recorder.signatureVerifies = true let handler = makeHandler(recorder: recorder) let packet = try makeFileTransferPacket( sender: localPeerID, @@ -169,7 +173,7 @@ struct BLEFileTransferHandlerTests { #expect(handler.handle(packet, from: localPeerID)) - #expect(recorder.signatureVerifyCount == 0) + #expect(recorder.signatureVerifyCount == 1) #expect(recorder.signedNameQueries.isEmpty) #expect(recorder.deliveredMessages.count == 1) #expect(recorder.deliveredMessages.first?.sender == "Me") @@ -205,7 +209,8 @@ struct BLEFileTransferHandlerTests { sender: remotePeerID, mimeType: "audio/mp4", content: m4a, - fileName: "voice_1122334455667788" + fileName: "voice_1122334455667788", + hasSignature: false ) // The spoofed note must be dropped locally AND not relayed onward. @@ -215,7 +220,7 @@ struct BLEFileTransferHandlerTests { } @Test - func privateFileFromConnectedUnverifiedPeerIsAccepted() throws { + func rawDirectedFileWithoutVerifiableSignatureIsDroppedWithoutWriteOrRelay() throws { let recorder = Recorder() recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)] let handler = makeHandler(recorder: recorder) @@ -223,23 +228,25 @@ struct BLEFileTransferHandlerTests { sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), - recipientID: Data(hexString: localPeerID.id) + recipientID: Data(hexString: localPeerID.id), + hasSignature: false ) - #expect(handler.handle(packet, from: remotePeerID)) + #expect(!handler.handle(packet, from: remotePeerID)) - // Directed transfers keep the lenient connected-peer path (no broadcast - // exposure); no signature check is required. #expect(recorder.signatureVerifyCount == 0) #expect(recorder.signedNameQueries.isEmpty) - #expect(recorder.deliveredMessages.count == 1) - #expect(recorder.deliveredMessages.first?.isPrivate == true) + #expect(recorder.trackedPackets.isEmpty) + #expect(recorder.quotaReservations.isEmpty) + #expect(recorder.saveCalls.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) } @Test func fileDirectedToAnotherPeerIsIgnored() throws { let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)] + recorder.signatureVerifies = true let handler = makeHandler(recorder: recorder) let packet = try makeFileTransferPacket( sender: remotePeerID, @@ -260,7 +267,8 @@ struct BLEFileTransferHandlerTests { @Test func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws { let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)] + recorder.signatureVerifies = true let handler = makeHandler(recorder: recorder) let packet = try makeFileTransferPacket( sender: remotePeerID, @@ -282,6 +290,56 @@ struct BLEFileTransferHandlerTests { #expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900))) } + @Test + func decryptedPrivateFileUsesValidationQuotaAndPrivateDeliveryWithoutRawSignature() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + let handler = makeHandler(recorder: recorder) + let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128) + let file = BitchatFilePacket( + fileName: "secret.jpg", + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ) + let payload = try #require(file.encode()) + let timestamp = Date(timeIntervalSince1970: 1_234) + + #expect(handler.handlePrivatePayload(payload, from: remotePeerID, timestamp: timestamp)) + + #expect(recorder.signatureVerifyCount == 0) + #expect(recorder.signedNameQueries.isEmpty) + #expect(recorder.trackedPackets.isEmpty) + #expect(recorder.quotaReservations == [content.count]) + #expect(recorder.saveCalls.first?.data == content) + #expect(recorder.lastSeenUpdates == [remotePeerID]) + #expect(recorder.deliveredMessages.count == 1) + #expect(recorder.deliveredMessages.first?.isPrivate == true) + #expect(recorder.deliveredMessages.first?.timestamp == timestamp) + } + + @Test + func decryptedPrivateFileOverPayloadCapIsRejectedBeforeQuotaOrDiskWrite() { + let recorder = Recorder() + let handler = makeHandler(recorder: recorder) + let oversizedCount = FileTransferLimits.maxPayloadBytes + 1 + var length = UInt32(oversizedCount).bigEndian + var payload = Data([0x04]) // BitchatFilePacket CONTENT TLV + withUnsafeBytes(of: &length) { payload.append(contentsOf: $0) } + payload.append(Data(repeating: 0x41, count: oversizedCount)) + + #expect(!handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + + #expect(recorder.quotaReservations.isEmpty) + #expect(recorder.saveCalls.isEmpty) + #expect(recorder.lastSeenUpdates.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) + } + @Test func malformedPayloadIsTrackedForSyncButDropped() { let recorder = Recorder() @@ -294,7 +352,7 @@ struct BLEFileTransferHandlerTests { recipientID: nil, timestamp: 900_000, payload: Data([0x01, 0x02, 0x03]), - signature: nil, + signature: Data(repeating: 0x5A, count: 64), ttl: TransportConfig.messageTTLDefault ) @@ -529,7 +587,8 @@ struct BLEFileTransferHandlerTests { content: Data, ttl: UInt8 = TransportConfig.messageTTLDefault, recipientID: Data? = nil, - fileName: String = "sample" + fileName: String = "sample", + hasSignature: Bool = true ) throws -> BitchatPacket { let filePacket = BitchatFilePacket( fileName: fileName, @@ -544,7 +603,7 @@ struct BLEFileTransferHandlerTests { recipientID: recipientID, timestamp: 900_000, payload: payload, - signature: nil, + signature: hasSignature ? Data(repeating: 0x5A, count: 64) : nil, ttl: ttl ) } diff --git a/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift b/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift index a4423ec9..ed4da0a1 100644 --- a/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift +++ b/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift @@ -117,6 +117,35 @@ struct BLEFragmentAssemblyBufferTests { } } + @Test + func encryptedPrivateFileAssemblyGetsFramedFileHeadroom() throws { + var buffer = BLEFragmentAssemblyBuffer() + let fragmentID = Data(repeating: 0x15, count: 8) + let first = try #require(BLEFragmentHeader(packet: makeFragmentPacket( + fragmentID: fragmentID, + index: 0, + total: 2, + originalType: MessageType.noiseEncrypted.rawValue, + fragmentData: Data(repeating: 0x01, count: FileTransferLimits.maxPayloadBytes) + ))) + let second = try #require(BLEFragmentHeader(packet: makeFragmentPacket( + fragmentID: fragmentID, + index: 1, + total: 2, + originalType: MessageType.noiseEncrypted.rawValue, + fragmentData: Data([0x02]) + ))) + + _ = buffer.append(first, maxInFlightAssemblies: 8) + let result = buffer.append(second, maxInFlightAssemblies: 8) + + if case let .complete(_, data, _) = result { + #expect(data.count == FileTransferLimits.maxPayloadBytes + 1) + } else { + Issue.record("Expected encrypted private-file assembly to use framed-file limit") + } + } + @Test func removeExpiredDropsOldAssemblies() throws { var buffer = BLEFragmentAssemblyBuffer() diff --git a/bitchatTests/Services/BLENoisePacketHandlerTests.swift b/bitchatTests/Services/BLENoisePacketHandlerTests.swift index 0365de5b..0457f71a 100644 --- a/bitchatTests/Services/BLENoisePacketHandlerTests.swift +++ b/bitchatTests/Services/BLENoisePacketHandlerTests.swift @@ -10,6 +10,7 @@ struct BLENoisePacketHandlerTests { var handshakeResult: Result = .success(nil) var handshakeAuthenticated = false var hasSession = false + let sessionGeneration = UUID() var decryptResult: Result = .success(Data()) var processedHandshakes: [(peerID: PeerID, message: Data)] = [] @@ -19,6 +20,7 @@ struct BLENoisePacketHandlerTests { var lastSeenUpdates: [PeerID] = [] var decryptCalls: [(payload: Data, peerID: PeerID)] = [] var clearedSessions: [PeerID] = [] + var authenticatedPeerStates: [(peerID: PeerID, payload: Data, generation: UUID)] = [] var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = [] /// Ordered side-effect log to assert recovery sequencing. var events: [String] = [] @@ -61,12 +63,18 @@ struct BLENoisePacketHandlerTests { }, decrypt: { payload, peerID in recorder.decryptCalls.append((payload, peerID)) - return try recorder.decryptResult.get() + return BLENoiseDecryptionResult( + plaintext: try recorder.decryptResult.get(), + sessionGeneration: recorder.sessionGeneration + ) }, clearSession: { peerID in recorder.clearedSessions.append(peerID) recorder.events.append("clearSession") }, + handleAuthenticatedPeerState: { peerID, payload, generation in + recorder.authenticatedPeerStates.append((peerID, payload, generation)) + }, deliverNoisePayload: { peerID, type, payload, timestamp in recorder.deliveries.append((peerID, type, payload, timestamp)) } @@ -244,6 +252,25 @@ 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.authenticatedPeerStates.first?.generation == recorder.sessionGeneration) + #expect(recorder.deliveries.isEmpty) + } + @Test func emptyDecryptedPayloadIsIgnored() { let recorder = Recorder() diff --git a/bitchatTests/Services/BLENoisePayloadFactoryTests.swift b/bitchatTests/Services/BLENoisePayloadFactoryTests.swift index ab743c9a..c1722f8a 100644 --- a/bitchatTests/Services/BLENoisePayloadFactoryTests.swift +++ b/bitchatTests/Services/BLENoisePayloadFactoryTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import BitFoundation @testable import bitchat struct BLENoisePayloadFactoryTests { @@ -31,4 +32,63 @@ struct BLENoisePayloadFactoryTests { #expect(payload == Data([NoisePayloadType.verifyChallenge.rawValue, 0xCA, 0xFE])) } + + @Test + func privateFilePayloadPrefixesCanonicalFilePacket() throws { + let content = Data("%PDF-secret".utf8) + let file = BitchatFilePacket( + fileName: "secret.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ) + + let payload = try #require(BLENoisePayloadFactory.privateFile(file)) + + #expect(payload.first == 0x20, "Encrypted files must use Android's deployed wire value") + let decoded = try #require(BitchatFilePacket.decode(Data(payload.dropFirst()))) + #expect(decoded.fileName == "secret.pdf") + #expect(decoded.mimeType == "application/pdf") + #expect(decoded.content == content) + } + + @Test + func androidB7f0b33PrivateFilePlaintextFixtureIsByteCompatible() throws { + // Runtime-emitted by Android commit b7f0b33d from + // BitchatFilePacket("a.txt", 3, "text/plain", [01, 02, 03]) and + // NoisePayload(type = FILE_TRANSFER, data = file.encode()).encode(). + let fixtureHex = "20010005612e7478740200040000000303000a746578742f706c61696e0400000003010203" + let fixture = try #require(Data(hexString: fixtureHex)) + + let typed = try #require(NoisePayload.decode(fixture)) + #expect(typed.type == .privateFile) + let file = try #require(BitchatFilePacket.decode(typed.data)) + #expect(file.fileName == "a.txt") + #expect(file.fileSize == 3) + #expect(file.mimeType == "text/plain") + #expect(file.content == Data([0x01, 0x02, 0x03])) + #expect(BLENoisePayloadFactory.privateFile(file) == fixture) + } + + @Test + func prereleasePrivateFileTypeCanonicalizesOnDecode() throws { + let encoded = Data([NoisePayloadType.prereleasePrivateFileRawValue, 0xCA, 0xFE]) + let decoded = try #require(NoisePayload.decode(encoded)) + + #expect(decoded.type == .privateFile) + #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/BLENoiseSessionQueuesTests.swift b/bitchatTests/Services/BLENoiseSessionQueuesTests.swift index 33cb0fe7..a933118e 100644 --- a/bitchatTests/Services/BLENoiseSessionQueuesTests.swift +++ b/bitchatTests/Services/BLENoiseSessionQueuesTests.swift @@ -48,7 +48,10 @@ struct BLENoiseSessionQueuesTests { queues.appendTypedPayload(Data([0x01]), for: peerID) queues.appendTypedPayload(Data([0x02]), for: peerID) - #expect(queues.takeTypedPayloads(for: peerID) == [Data([0x01]), Data([0x02])]) + #expect(queues.takeTypedPayloads(for: peerID) == [ + BLEPendingTypedPayload(payload: Data([0x01]), transferId: nil), + BLEPendingTypedPayload(payload: Data([0x02]), transferId: nil) + ]) #expect(queues.takeTypedPayloads(for: peerID).isEmpty) #expect(queues.takePrivateMessages(for: peerID).map(\.messageID) == ["m1"]) } @@ -64,4 +67,21 @@ struct BLENoiseSessionQueuesTests { #expect(queues.isEmpty) } + + @Test + func transferIDSurvivesHandshakeQueueAndCanBeCancelledBeforeDrain() { + let peerID = PeerID(str: "aaaaaaaaaaaaaaaa") + var queues = BLENoiseSessionQueues() + + queues.appendTypedPayload(Data([0x20, 0xAA]), transferId: "media-1", for: peerID) + queues.appendTypedPayload(Data([0x01, 0xBB]), for: peerID) + + let removed = queues.removeTypedPayload(transferId: "media-1") + let removedAgain = queues.removeTypedPayload(transferId: "media-1") + #expect(removed) + #expect(!removedAgain) + #expect(queues.takeTypedPayloads(for: peerID) == [ + BLEPendingTypedPayload(payload: Data([0x01, 0xBB]), transferId: nil) + ]) + } } diff --git a/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift b/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift index d89260ed..c58d5bf5 100644 --- a/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift +++ b/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift @@ -108,6 +108,58 @@ struct BLEOutboundFragmentPlannerTests { ) == nil) } + @Test("private media v1 accepts exactly 256 fragments and rejects 257") + func privateMediaCrossPlatformFragmentBoundary() throws { + let maxPayload = makePayload(count: 160 * 1024, seed: 0xFACE_CAFE) + + func plan(payloadCount: Int) throws -> BLEOutboundFragmentPlan { + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: Data(hexString: "0011223344556677") ?? Data(), + recipientID: Data(hexString: "8877665544332211"), + timestamp: 0x0102030405, + payload: Data(maxPayload.prefix(payloadCount)), + signature: nil, + ttl: 3, + version: 2 + ) + return try #require(BLEOutboundFragmentPlanner.makePlan( + for: BLEOutboundFragmentTransferRequest( + packet: packet, + pad: false, + maxChunk: nil, + directedPeer: PeerID(str: "8877665544332211"), + transferId: "boundary" + ), + defaultChunkSize: TransportConfig.bleDefaultFragmentSize, + bleMaxMTU: 512, + fragmentID: Data(repeating: 0xD4, count: 8) + )) + } + + func firstPlan(withAtLeast target: Int) throws -> BLEOutboundFragmentPlan { + var low = 1 + var high = maxPayload.count + while low < high { + let mid = low + (high - low) / 2 + if try plan(payloadCount: mid).totalFragments >= target { + high = mid + } else { + low = mid + 1 + } + } + return try plan(payloadCount: low) + } + + let at256 = try firstPlan(withAtLeast: 256) + let at257 = try firstPlan(withAtLeast: 257) + + #expect(at256.totalFragments == 256) + #expect(BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at256)) + #expect(at257.totalFragments == 257) + #expect(!BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at257)) + } + private func makePacket( payload: Data, route: [Data]? = nil, diff --git a/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift b/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift index 722f3e33..8ceb1ac9 100644 --- a/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift +++ b/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift @@ -20,6 +20,24 @@ struct BLEOutboundFragmentTransferSchedulerTests { } } + @Test + func explicitTransferIDReservesEncryptedPrivateFileFragments() { + var scheduler = BLEOutboundFragmentTransferScheduler() + let request = makeRequest( + type: MessageType.noiseEncrypted.rawValue, + transferId: "private-media" + ) + + let result = scheduler.submit(request, maxConcurrentTransfers: 1) + + if case let .start(_, reservedTransferId) = result { + #expect(reservedTransferId == "private-media") + #expect(scheduler.activeCount == 1) + } else { + Issue.record("Expected encrypted private media to reserve its progress slot") + } + } + @Test func submitQueuesFileTransferWhenSlotsAreFull() { var scheduler = BLEOutboundFragmentTransferScheduler() diff --git a/bitchatTests/Services/BLEPeerRegistryTests.swift b/bitchatTests/Services/BLEPeerRegistryTests.swift index 2a8188d4..204c8a3a 100644 --- a/bitchatTests/Services/BLEPeerRegistryTests.swift +++ b/bitchatTests/Services/BLEPeerRegistryTests.swift @@ -123,6 +123,37 @@ struct BLEPeerRegistryTests { #expect(registry.info(for: peerID)?.signingPublicKey == signingKey) } + @Test("registry preserves absent versus explicit empty capabilities") + func capabilitiesPresenceIsPreserved() { + var registry = BLEPeerRegistry() + let oldPeer = PeerID(str: "1122334455667788") + let modernPeer = PeerID(str: "8877665544332211") + + _ = registry.upsertVerifiedAnnounce( + peerID: oldPeer, + nickname: "old", + noisePublicKey: Data(repeating: 0x11, count: 32), + signingPublicKey: Data(repeating: 0x12, count: 32), + isConnected: true, + now: Date(), + capabilities: nil + ) + _ = registry.upsertVerifiedAnnounce( + peerID: modernPeer, + nickname: "modern", + noisePublicKey: Data(repeating: 0x21, count: 32), + signingPublicKey: Data(repeating: 0x22, count: 32), + isConnected: true, + now: Date(), + capabilities: [] + ) + + #expect(registry.capabilities(for: oldPeer).isEmpty) + #expect(!registry.capabilitiesWereExplicitlyAdvertised(for: oldPeer)) + #expect(registry.capabilities(for: modernPeer).isEmpty) + #expect(registry.capabilitiesWereExplicitlyAdvertised(for: modernPeer)) + } + @Test("reachability keeps recent verified offline peers only when mesh is attached") func reachabilityRequiresMeshAttachmentForOfflinePeers() { let offlinePeer = PeerID(str: "1122334455667788") diff --git a/bitchatTests/Services/NoiseEncryptionServiceTests.swift b/bitchatTests/Services/NoiseEncryptionServiceTests.swift index 89f01446..b6443a25 100644 --- a/bitchatTests/Services/NoiseEncryptionServiceTests.swift +++ b/bitchatTests/Services/NoiseEncryptionServiceTests.swift @@ -96,13 +96,22 @@ struct NoiseEncryptionServiceTests { let recorder = AuthenticationRecorder() #expect(alice.onPeerAuthenticated == nil) + #expect(bob.onPeerAuthenticatedWithGeneration == nil) alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:)) bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:) + bob.onPeerAuthenticatedWithGeneration = recorder.record( + peerID:fingerprint:sessionGeneration: + ) try establishSessions(alice: alice, bob: bob) let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0) #expect(authenticated) + let generationAuthenticated = await TestHelpers.waitUntil( + { recorder.generationCount >= 1 }, + timeout: 5.0 + ) + #expect(generationAuthenticated) #expect(alice.hasEstablishedSession(with: bobPeerID)) #expect(bob.hasEstablishedSession(with: alicePeerID)) #expect(alice.hasSession(with: bobPeerID)) @@ -111,6 +120,7 @@ struct NoiseEncryptionServiceTests { #expect(bob.getPeerPublicKeyData(alicePeerID)?.count == 32) #expect(alice.getPeerFingerprint(bobPeerID) != nil) #expect(bob.getPeerFingerprint(alicePeerID) != nil) + #expect(recorder.generation(for: alicePeerID) == bob.sessionGeneration(for: alicePeerID)) let plaintext = Data("secret payload".utf8) let ciphertext = try alice.encrypt(plaintext, for: bobPeerID) @@ -237,6 +247,112 @@ 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) + let originalGeneration = try #require(alice.sessionGeneration(for: bobPeerID)) + var leaseRan = false + let leased = alice.withCurrentSessionGeneration( + for: bobPeerID, + expected: originalGeneration + ) { + leaseRan = true + return true + } + #expect(leased == true) + #expect(leaseRan) + + var emittedPeerID: PeerID? + var emittedMessage: Data? + alice.onRekeyHandshakeReady = { peerID, message in + emittedPeerID = peerID + emittedMessage = message + } + try alice._test_initiateAutomaticRekey(for: bobPeerID) + + #expect(emittedPeerID == bobPeerID) + #expect(alice.sessionGeneration(for: bobPeerID) == nil) + leaseRan = false + let staleLease = alice.withCurrentSessionGeneration( + for: bobPeerID, + expected: originalGeneration + ) { + leaseRan = true + return true + } + #expect(staleLease == nil) + #expect(!leaseRan) + let message1 = try #require(emittedMessage) + #expect(!message1.isEmpty) + #expect(alice.hasSession(with: bobPeerID)) + #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)) + #expect(alice.sessionGeneration(for: bobPeerID) != originalGeneration) + } + + @Test("Large private-file payloads use the bounded Noise extension") + func largePrivateFileNoiseRoundTrip() 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) + + let content = Data("%PDF-1.7\n".utf8) + Data(repeating: 0x51, count: 96 * 1024) + let file = BitchatFilePacket( + fileName: "large-private.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ) + let typedPayload = try #require(BLENoisePayloadFactory.privateFile(file)) + #expect(typedPayload.count > NoiseSecurityConstants.maxMessageSize) + #expect(typedPayload.first == NoisePayloadType.privateFile.rawValue) + #expect( + typedPayload.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize, + "typedBytes=\(typedPayload.count) limit=\(NoiseSecurityConstants.maxPrivateFilePlaintextSize)" + ) + + do { + _ = try alice.encrypt(typedPayload, for: bobPeerID) + Issue.record("Ordinary Noise payload path must retain its 64 KiB ceiling") + } catch NoiseSecurityError.messageTooLarge { + // Expected: only the purpose-specific private-file API may extend it. + } + + let ciphertext: Data + do { + ciphertext = try alice.encryptPrivateFilePayload(typedPayload, for: bobPeerID) + } catch { + Issue.record("Private-file encryption failed: \(error)") + return + } + let decrypted: Data + do { + decrypted = try bob.decrypt(ciphertext, from: alicePeerID) + } catch { + Issue.record("Private-file decryption failed: \(error); ciphertextBytes=\(ciphertext.count)") + return + } + + #expect(ciphertext.range(of: content) == nil) + #expect(decrypted == typedPayload) + } + @Test("Encrypt without a session requests handshake and decrypt without session fails") func handshakeRequiredAndSessionNotEstablishedErrors() throws { let service = NoiseEncryptionService(keychain: MockKeychain()) @@ -328,6 +444,7 @@ struct NoiseEncryptionServiceTests { private final class AuthenticationRecorder: @unchecked Sendable { private let lock = NSLock() private var entries: [(PeerID, String)] = [] + private var generationEntries: [(PeerID, UUID)] = [] var count: Int { lock.lock() @@ -335,9 +452,27 @@ private final class AuthenticationRecorder: @unchecked Sendable { return entries.count } + var generationCount: Int { + lock.lock() + defer { lock.unlock() } + return generationEntries.count + } + func record(peerID: PeerID, fingerprint: String) { lock.lock() entries.append((peerID, fingerprint)) lock.unlock() } + + func record(peerID: PeerID, fingerprint _: String, sessionGeneration: UUID) { + lock.lock() + generationEntries.append((peerID, sessionGeneration)) + lock.unlock() + } + + func generation(for peerID: PeerID) -> UUID? { + lock.lock() + defer { lock.unlock() } + return generationEntries.last { $0.0 == peerID }?.1 + } } diff --git a/bitchatTests/Services/SecureIdentityStateManagerTests.swift b/bitchatTests/Services/SecureIdentityStateManagerTests.swift index 3679cbcb..c56049bb 100644 --- a/bitchatTests/Services/SecureIdentityStateManagerTests.swift +++ b/bitchatTests/Services/SecureIdentityStateManagerTests.swift @@ -493,6 +493,59 @@ final class SecureIdentityStateManagerTests: XCTestCase { XCTAssertTrue(cleared) } + func test_privateMediaCapabilityPinPersistsMonotonicallyAndPanicClearRemovesIt() async { + let keychain = MockKeychain() + let fingerprint = Data(repeating: 0x42, count: 32).sha256Fingerprint() + let manager = SecureIdentityStateManager(keychain) + + XCTAssertFalse(manager.hasObservedPrivateMediaCapability(fingerprint: fingerprint)) + manager.markPrivateMediaCapable(fingerprint: fingerprint) + XCTAssertTrue( + manager.hasObservedPrivateMediaCapability(fingerprint: fingerprint), + "pin insertion must be synchronously visible to the next downgrade decision" + ) + + // Re-marking is idempotent, and the encrypted cache carries the pin + // across launches. + manager.markPrivateMediaCapable(fingerprint: fingerprint) + manager.forceSave() + let reloaded = SecureIdentityStateManager(keychain) + XCTAssertTrue(reloaded.hasObservedPrivateMediaCapability(fingerprint: fingerprint)) + + // ChatViewModel's panic path calls this same wipe after deleting + // keychain data; the in-memory pin must disappear immediately too. + reloaded.clearAllIdentityData() + let cleared = await waitUntil { + !reloaded.hasObservedPrivateMediaCapability(fingerprint: fingerprint) + } + 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/TransferProgressManagerTests.swift b/bitchatTests/Services/TransferProgressManagerTests.swift index 0bf1395f..9023c7f5 100644 --- a/bitchatTests/Services/TransferProgressManagerTests.swift +++ b/bitchatTests/Services/TransferProgressManagerTests.swift @@ -49,7 +49,7 @@ struct TransferProgressManagerTests { recorder.append("updated:\(id):\(sent):\(total)") case .completed(let id, let total): recorder.append("completed:\(id):\(total)") - case .cancelled: + case .cancelled, .rejected: break } } @@ -85,7 +85,7 @@ struct TransferProgressManagerTests { recorder.append("started:\(id):\(total)") case .cancelled(let id, let sent, let total): recorder.append("cancelled:\(id):\(sent):\(total)") - case .updated, .completed: + case .updated, .completed, .rejected: break } } @@ -105,6 +105,28 @@ struct TransferProgressManagerTests { #expect(manager.snapshot(id: transferID) == nil) _ = cancellable } + + @Test("Preflight policy rejection publishes a visible failure reason") + @MainActor + func rejectBeforeStartPublishesReason() async { + let manager = TransferProgressManager() + let transferID = "transfer-visible-reject" + let recorder = EventRecorder() + let cancellable = manager.publisher.sink { event in + if case .rejected(let id, let reason) = event { + recorder.append("rejected:\(id):\(reason)") + } + } + + manager.rejectBeforeStart(id: transferID, reason: "upgrade required") + + let didReceive = await TestHelpers.waitUntil({ + recorder.values == ["rejected:\(transferID):upgrade required"] + }, timeout: 5.0) + #expect(didReceive) + #expect(manager.snapshot(id: transferID) == nil) + _ = cancellable + } } private final class EventRecorder: @unchecked Sendable { diff --git a/bitchatTests/Services/UnifiedPeerServiceTests.swift b/bitchatTests/Services/UnifiedPeerServiceTests.swift index a81003ec..4bc6f775 100644 --- a/bitchatTests/Services/UnifiedPeerServiceTests.swift +++ b/bitchatTests/Services/UnifiedPeerServiceTests.swift @@ -266,6 +266,11 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol { verified.removeAll() } + 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) {} func setVerified(fingerprint: String, verified: Bool) { diff --git a/docs/PRIVATE-MEDIA-MIGRATION.md b/docs/PRIVATE-MEDIA-MIGRATION.md new file mode 100644 index 00000000..1f430797 --- /dev/null +++ b/docs/PRIVATE-MEDIA-MIGRATION.md @@ -0,0 +1,115 @@ +# Private-media wire migration + +Private files use the `BitchatFilePacket` TLV shared by iOS and Android. The +preferred direct-message wire form encrypts that complete TLV inside the +peer's Noise session before BLE fragmentation. + +## Wire values and capability + +- `NoisePayloadType.privateFile` is `0x20`, the value already deployed by the + Android client. New sends must use this value. +- iOS temporarily accepts `0x09`, which appeared in prerelease builds of the + private-media change. Decoders canonicalize it to `privateFile`; they never + emit it. +- `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 + accepted by current Android clients, only after the sender confirms a + 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: 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. + +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 + +The encrypted form provides Noise confidentiality and peer authentication. +The fallback is signed and its signature is required on receive, so relays +cannot forge its sender or contents. It is not confidential: relays can see +the raw file TLV. The UI says this explicitly and asks on every send. A peer +without a stable Noise key from a verified registry entry cannot use the +fallback. Keep it only for the mixed-version migration, and remove it only +after minimum-supported Android and iOS releases emit authenticated bit-8 +`0x21` state and the legacy population has aged out. Never replace it with an +unsigned fallback, persist blanket consent, or send both forms. + +Incoming clients accept all three migration-era shapes: + +| Sender | Inbound form | Result | +| --- | --- | --- | +| Current Android | Noise `0x20` | Decrypt and deliver | +| Prerelease iOS | Noise `0x09` | Decrypt, canonicalize, and deliver | +| Older client | Signed directed `fileTransfer` | Verify signature and deliver | +| Forged/unsigned raw sender | Directed `fileTransfer` | Reject | + +Panic wipe clears the persistent capability pins together with the rest of +the encrypted identity cache. + +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 + +iOS bounds inbound file content at 1 MiB and applies the expanded allocation +budget only after a large Noise ciphertext authenticates to `0x20` or the +temporary `0x09` alias. Ordinary Noise messages retain their 64 KiB limit. + +Current Android builds cap each reassembly at 256 fragments. Depending on the +negotiated BLE packet size and routing overhead, that is roughly 110-120 KiB, +well below iOS's absolute inbound ceiling. That cap only applies to those +receivers, which take private media exclusively over the directed raw-file +migration fallback (they do not implement the encrypted `0x20` path). +Private-media v1 therefore runs the actual route-aware BLE fragment planner +before a consented legacy send and rejects any plan above 256 fragments with a +visible failure. Encrypted sends go only to peers that advertised the +`privateMedia` capability β€” modern clients that reassemble up to the full +receiver ceiling (10,000 fragments) β€” so they are not held to Android's cap and +iOSβ†’iOS photos in the ~120-512 KiB range keep working. This fragment-count +contract, rather than a guessed byte threshold, stays correct as route overhead +changes. A future Android client that adopts `0x20` but still caps its +reassembler would need to negotiate an explicit per-peer fragment limit +(tracked as a #1434 follow-up). diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift index 167f9884..8438564b 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift @@ -24,6 +24,10 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable { /// (uplink/downlink carriers for mesh-only peers). Advertised alongside /// a `bridgeGeohash` TLV carrying the rendezvous cell. public static let bridge = PeerCapabilities(rawValue: 1 << 7) + /// Finalized direct-message media encrypted as Noise payload `0x20` + /// before outer BLE fragmentation. Peers that omit this bit require the + /// signed directed raw-file migration fallback. + public static let privateMedia = PeerCapabilities(rawValue: 1 << 8) /// Minimal little-endian byte encoding; always at least one byte so an /// empty set is distinguishable from an absent TLV. diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift index 1c83530f..49012128 100644 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift @@ -16,11 +16,12 @@ struct PeerCapabilitiesTests { #expect(PeerCapabilities([]).encoded() == Data([0x00])) #expect(PeerCapabilities.prekeys.encoded() == Data([0x01])) #expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40])) + #expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01])) let high = PeerCapabilities(rawValue: 1 << 9) #expect(high.encoded() == Data([0x00, 0x02])) - let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics] + let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia] #expect(PeerCapabilities(encoded: all.encoded()) == all) #expect(PeerCapabilities(encoded: high.encoded()) == high) #expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])