From 8c6b60c67591588858796da5816fbf202f7f5c13 Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 12 Jul 2026 10:09:33 -0400 Subject: [PATCH] Harden private media migration compatibility --- bitchat/App/ConversationUIModel.swift | 12 + bitchat/Identity/IdentityModels.swift | 6 + .../Identity/SecureIdentityStateManager.swift | 35 + bitchat/Models/NoisePayload.swift | 2 +- bitchat/Noise/SecureNoiseSession.swift | 2 +- bitchat/Protocols/BitchatProtocol.swift | 21 +- .../Services/BLE/BLENoisePacketHandler.swift | 2 +- .../Services/BLE/BLENoiseSessionQueues.swift | 6 + .../BLE/BLEOutboundFragmentPlanner.swift | 7 + bitchat/Services/BLE/BLEPeerRegistry.swift | 12 +- bitchat/Services/BLE/BLEService.swift | 769 +++++++++++++++++- bitchat/Services/NoiseEncryptionService.swift | 4 +- .../Services/TransferProgressManager.swift | 14 +- bitchat/Services/Transport.swift | 26 + .../ChatMediaTransferCoordinator.swift | 191 ++++- bitchat/ViewModels/ChatViewModel.swift | 93 +++ bitchat/Views/ContentSheetViews.swift | 58 ++ ...MediaTransferCoordinatorContextTests.swift | 354 +++++++- .../ChatViewModelExtensionsTests.swift | 83 ++ .../EndToEnd/PrivateMediaEndToEndTests.swift | 765 ++++++++++++++++- bitchatTests/Mocks/MockIdentityManager.swift | 13 +- bitchatTests/Mocks/MockTransport.swift | 17 + .../BLENoisePayloadFactoryTests.swift | 31 +- .../Services/BLENoiseSessionQueuesTests.swift | 2 +- .../BLEOutboundFragmentPlannerTests.swift | 52 ++ .../Services/BLEPeerRegistryTests.swift | 31 + .../SecureIdentityStateManagerTests.swift | 28 + .../TransferProgressManagerTests.swift | 22 +- .../Services/UnifiedPeerServiceTests.swift | 3 + docs/PRIVATE-MEDIA-MIGRATION.md | 80 ++ .../BitFoundation/PeerCapabilities.swift | 5 +- 31 files changed, 2627 insertions(+), 119 deletions(-) create mode 100644 docs/PRIVATE-MEDIA-MIGRATION.md 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 0d8dc475..f8c4e12b 100644 --- a/bitchat/Identity/IdentityModels.swift +++ b/bitchat/Identity/IdentityModels.swift @@ -189,6 +189,12 @@ struct IdentityCache: Codable { // Fingerprint -> when we verified it (orders outgoing vouch batches; // entries verified before this field exists sort as oldest) var verifiedAt: [String: Date]? = nil + + // Stable Noise fingerprints that have advertised encrypted private media + // in a signature-verified announce. Optional for decoding caches written + // before this migration. Entries are monotonic until a panic wipe so an + // old/replayed announce cannot silently downgrade a peer to clear media. + var privateMediaCapableFingerprints: Set? = nil } // diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index 7c68a01b..2eaed338 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -140,6 +140,10 @@ 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: Private-media downgrade protection + func markPrivateMediaCapable(fingerprint: String) + func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool } /// Singleton manager for secure identity state persistence and retrieval. @@ -157,6 +161,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`. // Persistence is done with a fire-and-forget `queue.async(.barrier)` rather @@ -214,6 +219,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. @@ -370,6 +376,35 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { return 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 + } + } func updateSocialIdentity(_ identity: SocialIdentity) { queue.async(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/SecureNoiseSession.swift b/bitchat/Noise/SecureNoiseSession.swift index 671fa30c..8f95842c 100644 --- a/bitchat/Noise/SecureNoiseSession.swift +++ b/bitchat/Noise/SecureNoiseSession.swift @@ -27,7 +27,7 @@ final class SecureNoiseSession: NoiseSession { // 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 = plaintext.first == NoisePayloadType.privateFile.rawValue + let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: plaintext.first) && NoiseSecurityValidator.validatePrivateFileMessageSize(plaintext) guard NoiseSecurityValidator.validateMessageSize(plaintext) || isPrivateFile else { throw NoiseSecurityError.messageTooLarge diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index b740787e..04a8b24d 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -79,15 +79,30 @@ 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. The complete BitchatFilePacket is encrypted - // inside Noise before the outer noiseEncrypted packet is fragmented. - case privateFile = 0x09 + // 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 // 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" diff --git a/bitchat/Services/BLE/BLENoisePacketHandler.swift b/bitchat/Services/BLE/BLENoisePacketHandler.swift index bddbc053..5a87ddd2 100644 --- a/bitchat/Services/BLE/BLENoisePacketHandler.swift +++ b/bitchat/Services/BLE/BLENoisePacketHandler.swift @@ -105,7 +105,7 @@ final class BLENoisePacketHandler { 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 } diff --git a/bitchat/Services/BLE/BLENoiseSessionQueues.swift b/bitchat/Services/BLE/BLENoiseSessionQueues.swift index 4acb4d7b..301e6bce 100644 --- a/bitchat/Services/BLE/BLENoiseSessionQueues.swift +++ b/bitchat/Services/BLE/BLENoiseSessionQueues.swift @@ -53,6 +53,12 @@ struct BLENoiseSessionQueues { 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) { 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/BLEPeerRegistry.swift b/bitchat/Services/BLE/BLEPeerRegistry.swift index 792107d8..c23f842f 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) @@ -181,7 +188,7 @@ struct BLEPeerRegistry { signingPublicKey: Data?, isConnected: Bool, now: Date, - capabilities: PeerCapabilities = [], + capabilities: PeerCapabilities? = nil, bridgeGeohash: String? = nil ) -> BLEPeerAnnounceUpdate { let existing = peers[peerID] @@ -199,7 +206,8 @@ struct BLEPeerRegistry { signingPublicKey: 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 61312878..d2f47256 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -7,6 +7,175 @@ 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) + } + } +} + /// BLEService — Bluetooth Mesh Transport /// - Emits events exclusively via `BitchatDelegate` for UI. /// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`). @@ -104,6 +273,10 @@ final class BLEService: NSObject { // Test-only tap on the outbound pipeline so multi-node tests can ferry // packets between in-process service instances. var _test_onOutboundPacket: ((BitchatPacket) -> 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() @@ -131,6 +304,9 @@ final class BLEService: NSObject { // 5. Fragment Reassembly (necessary for messages > MTU) private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer() private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler() + private lazy var privateMediaTransferAdmissions = BLEPrivateMediaTransferAdmissionRegistry { [weak self] transferId in + self?.handlePrivateMediaAdmissionExpiry(transferId) + } private let incomingFileStore: BLEIncomingFileStore // Simple announce throttling @@ -734,6 +910,69 @@ final class BLEService: NSObject { collectionsQueue.sync { peerRegistry.capabilities(for: peerID) } } + func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { + let state: ( + capabilities: PeerCapabilities, + fingerprint: String? + ) = collectionsQueue.sync { + let info = peerRegistry.info(for: peerID.toShort()) + return ( + info?.capabilities ?? [], + info?.noisePublicKey?.sha256Fingerprint() + ) + } + + if state.capabilities.contains(.privateMedia) { + return .encrypted + } + + guard let fingerprint = state.fingerprint else { + // A raw fallback must be bound to the stable Noise key from a + // verified registry entry; a routing ID alone can rotate or be + // spoofed. Session authentication is required for pinning, below, + // but old clients may need the consented fallback before a session. + return .blockedDowngrade + } + // During the mixed-version migration, an unpinned peer is legacy + // eligible whether the capabilities TLV was absent or explicitly + // omitted this bit. Only a capability observation bound to a matching + // authenticated Noise session may make the no-bit state a downgrade. + return identityManager.hasObservedPrivateMediaCapability(fingerprint: fingerprint) + ? .blockedDowngrade + : .legacyRequiresConsent + } + + /// Pins private-media support only when the capability-bearing registry + /// identity and a completed Noise session authenticate the same static + /// key. A signed announce alone does not prove possession of that Noise + /// key and must never create a downgrade pin. + private func reconcilePrivateMediaCapabilityPin( + for peerID: PeerID, + authenticatedFingerprint: String? = nil + ) { + let normalizedPeerID = peerID.toShort() + let advertisedFingerprint: String? = collectionsQueue.sync { + guard let info = peerRegistry.info(for: normalizedPeerID), + info.capabilities.contains(.privateMedia) else { return nil } + return info.noisePublicKey?.sha256Fingerprint() + } + guard let advertisedFingerprint else { return } + + let sessionFingerprint = authenticatedFingerprint + ?? noiseService.getPeerFingerprint(normalizedPeerID) + guard let sessionFingerprint, + sessionFingerprint.caseInsensitiveCompare(advertisedFingerprint) == .orderedSame else { + if authenticatedFingerprint != nil { + SecureLogger.warning( + "Refusing private-media capability pin for \(normalizedPeerID.id.prefix(8))…: authenticated Noise key does not match verified announce", + category: .security + ) + } + return + } + identityManager.markPrivateMediaCapable(fingerprint: advertisedFingerprint) + } + /// 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`. @@ -861,7 +1100,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 } @@ -937,49 +1197,232 @@ 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 } - let targetID = peerID.toShort() - let supportsPrivateMedia = self.collectionsQueue.sync { - self.peerRegistry.capabilities(for: targetID).contains(.privateMedia) + #if DEBUG + self._test_beforePrivateMediaDeferredSend?(transferId) + #endif + guard self.privateMediaTransferAdmissions.isActive(transferId) else { + self.privateMediaTransferAdmissions.finish(transferId) + return } - guard supportsPrivateMedia else { + let targetID = peerID.toShort() + switch self.privateMediaSendPolicy(to: targetID) { + case .encrypted: + break + + 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 not sent: \(targetID.id.prefix(8))… did not advertise encrypted-media support", + "Private media downgrade blocked for \(targetID.id.prefix(8))…", category: .security ) - TransferProgressManager.shared.rejectBeforeStart(id: transferId) + 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) + 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 { - self.collectionsQueue.sync(flags: .barrier) { - self.pendingNoiseSessionQueues.appendTypedPayload( - typedPayload, - transferId: transferId, - for: targetID - ) + 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 } 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) + 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) + 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) } } } + /// 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 @@ -1100,7 +1543,21 @@ final class BLEService: NSObject { // MARK: - Packet Broadcasting - private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) { + private func broadcastPacket( + _ packet: BitchatPacket, + transferId: String? = nil, + requiresPrivateMediaAdmission: Bool = false + ) { + 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) { @@ -1109,14 +1566,75 @@ 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) + + // Cross-platform private-media v1 is bounded by Android's deployed + // 256-fragment receive cap. Run the same planner the scheduler will + // use, after route application, for both encrypted and consented raw + // migration sends. Reject before reserving a transfer slot or writing + // any fragment; public media is intentionally unaffected. + if let transferId, + let recipientPeerID = PeerID(hexData: packetToSend.recipientID), + packetToSend.type == MessageType.noiseEncrypted.rawValue + || 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. @@ -1130,7 +1648,18 @@ final class BLEService: NSObject { pad: padForBLE, maxChunk: nil, directedOnlyPeer: recipientPeerID, - transferId: transferId + 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 } @@ -2370,18 +2899,20 @@ extension BLEService { func _test_seedConnectedPeer( _ peerID: PeerID, nickname: String, - capabilities: PeerCapabilities = [] + 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(), - capabilities: capabilities + capabilities: capabilities ?? [], + capabilitiesWereExplicitlyAdvertised: capabilities != nil )) } } @@ -2396,6 +2927,79 @@ 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_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() + } + } + } + } + + /// 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]? @@ -3375,6 +3979,13 @@ extension BLEService { service.onPeerAuthenticated = { [weak self] peerID, fingerprint in SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))…") self?.messageQueue.async { [weak self] in + self?.reconcilePrivateMediaCapabilityPin( + for: peerID, + authenticatedFingerprint: fingerprint + ) + #if DEBUG + self?._test_onPrivateMediaSessionReconciled?(peerID) + #endif self?.sendPendingMessagesAfterHandshake(for: peerID) self?.sendPendingNoisePayloadsAfterHandshake(for: peerID) } @@ -3436,7 +4047,7 @@ extension BLEService { private func makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket { let encrypted: Data - let isPrivateFile = typedPayload.first == NoisePayloadType.privateFile.rawValue + let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first) if isPrivateFile { encrypted = try noiseService.encryptPrivateFilePayload(typedPayload, for: peerID) } else { @@ -4480,7 +5091,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, @@ -4492,8 +5104,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) } @@ -4569,14 +5207,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 } @@ -4891,6 +5539,16 @@ extension BLEService { private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { let result = announceHandler.handle(packet, from: peerID) + // The verified announce and Noise handshake can arrive in either + // order. If authentication already completed, compare its static-key + // fingerprint with the newly upserted registry key before pinning; + // otherwise the session callback performs this reconciliation later. + if let result, + result.isVerified, + result.announcement.capabilities?.contains(.privateMedia) == true { + reconcilePrivateMediaCapabilityPin(for: 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 { @@ -5201,7 +5859,7 @@ extension BLEService { signingPublicKey: announcement.signingPublicKey, isConnected: isConnected, now: now, - capabilities: announcement.capabilities ?? [], + capabilities: announcement.capabilities, bridgeGeohash: announcement.bridgeGeohash ) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) }, @@ -5546,13 +6204,45 @@ extension BLEService { guard !payloads.isEmpty else { return } SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID.id.prefix(8))… after handshake", category: .session) for pending in payloads { + let privateMediaTransferId: String? = { + guard NoisePayloadType.isPrivateFile(rawValue: pending.payload.first) else { return nil } + return pending.transferId + }() + if let transferId = privateMediaTransferId, + !privateMediaTransferAdmissions.isActive(transferId) { + privateMediaTransferAdmissions.finish(transferId) + continue + } do { + 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( - try makeEncryptedNoisePacket(pending.payload, to: peerID), - transferId: pending.transferId + 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) + } } } } @@ -5713,6 +6403,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 cbe162d2..46527e68 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -731,7 +731,7 @@ final class NoiseEncryptionService { /// the bounded `BitchatFilePacket` envelope and refuses every other typed /// payload so the larger allocation budget cannot become a generic bypass. func encryptPrivateFilePayload(_ data: Data, for peerID: PeerID) throws -> Data { - guard data.first == NoisePayloadType.privateFile.rawValue, + guard NoisePayloadType.isPrivateFile(rawValue: data.first), NoiseSecurityValidator.validatePrivateFileMessageSize(data) else { throw NoiseSecurityError.messageTooLarge } @@ -772,7 +772,7 @@ final class NoiseEncryptionService { let decrypted = try sessionManager.decrypt(data, from: peerID) if !isStandardCiphertext { - guard decrypted.first == NoisePayloadType.privateFile.rawValue, + guard NoisePayloadType.isPrivateFile(rawValue: decrypted.first), NoiseSecurityValidator.validatePrivateFileMessageSize(decrypted) else { throw NoiseSecurityError.messageTooLarge } diff --git a/bitchat/Services/TransferProgressManager.swift b/bitchat/Services/TransferProgressManager.swift index 9d04ea83..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,15 +50,14 @@ final class TransferProgressManager { } } - /// Reject a transfer before fragment scheduling (for example, when the - /// remote build did not advertise the required wire capability). Unlike - /// `cancel`, this still emits an event when no progress state exists yet, - /// allowing the UI to remove its already-created sending placeholder. - func rejectBeforeStart(id: String) { + /// 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 } - let state = self.states.removeValue(forKey: id) ?? (sent: 0, total: 0) - self.subject.send(.cancelled(id: id, sentFragments: state.sent, totalFragments: state.total)) + self.states.removeValue(forKey: id) + self.subject.send(.rejected(id: id, reason: reason)) } } diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index c7094bc1..38868672 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -83,6 +83,15 @@ 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 + case legacyRequiresConsent + case blockedDowngrade +} + protocol TransportEventDelegate: AnyObject { @MainActor func didReceiveTransportEvent(_ event: TransportEvent) } @@ -163,6 +172,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 +223,7 @@ 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 /// 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 +294,7 @@ 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 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 +311,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/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index bf0009da..3ab4d765 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,20 @@ 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 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 +85,43 @@ 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 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) { @@ -75,12 +136,19 @@ extension ChatViewModel: ChatMediaTransferContext { @MainActor final class ChatMediaTransferCoordinator { private unowned let context: any ChatMediaTransferContext + private let prepareVoiceNotePacket: @Sendable (URL) throws -> BitchatFilePacket private(set) var transferIdToMessageIDs: [String: [String]] = [:] private(set) var messageIDToTransferId: [String: String] = [:] - init(context: any ChatMediaTransferContext) { + init( + context: any ChatMediaTransferContext, + prepareVoiceNotePacket: @escaping @Sendable (URL) throws -> BitchatFilePacket = { + try ChatMediaPreparation.prepareVoiceNotePacket(at: $0) + } + ) { self.context = context + self.prepareVoiceNotePacket = prepareVoiceNotePacket } func sendVoiceNote(at url: URL) { @@ -98,16 +166,28 @@ final class ChatMediaTransferCoordinator { ) let messageID = message.id let transferId = makeTransferID(messageID: messageID) + // 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 Task.detached(priority: .userInitiated) { [weak self] in do { - let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url) + let packet = try prepareVoiceNotePacket(url) await MainActor.run { [weak self] in - guard let self else { return } - self.registerTransfer(transferId: transferId, messageID: messageID) + guard let self, + self.isRegisteredTransfer(transferId, messageID: messageID) else { + return + } 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) } @@ -116,13 +196,19 @@ final class ChatMediaTransferCoordinator { SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session) try? FileManager.default.removeItem(at: url) await MainActor.run { [weak self] in - guard let self else { return } + guard let self, + 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 - guard let self else { return } + guard let self, + 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")) } } @@ -193,7 +279,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) } @@ -253,17 +344,85 @@ final class ChatMediaTransferCoordinator { return message } + private func beginPrivateMediaSend( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + messageID: String + ) { + switch context.privateMediaSendPolicy(to: peerID) { + case .encrypted: + context.sendFilePrivate( + packet, + to: peerID, + transferId: transferId, + allowLegacyFallback: false + ) + + 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 { @@ -298,6 +457,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) } } @@ -338,6 +500,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/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 24caaa86..5d9aa14a 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -347,6 +347,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 { @@ -1156,6 +1158,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele func panicClearAllData() { // Messages are processed immediately - nothing to flush + // 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) @@ -1804,4 +1810,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/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 60932a5a..c819eeca 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 @@ -86,11 +85,64 @@ 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 + 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 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) { @@ -102,6 +154,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 @@ -166,6 +268,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 @@ -188,6 +298,20 @@ struct ChatMediaTransferCoordinatorContextTests { #expect(coordinator.messageIDToTransferId.isEmpty) } + @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() @@ -206,4 +330,224 @@ 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 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) + } } diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index 0097a368..e92bd0be 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -1048,6 +1048,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 index c4b42162..70d974d8 100644 --- a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift +++ b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift @@ -11,47 +11,521 @@ import Testing @Suite("Private media end to end", .serialized) struct PrivateMediaEndToEndTests { @Test - func peerWithoutPrivateMediaCapabilityIsRejectedBeforeWireSend() async throws { + 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 legacyFallbackRequiresPerSendConsentAndConsumesItOnce() async throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("private-media-capability-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: root) } - let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true)) + 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)) - alice._test_seedConnectedPeer(bob.myPeerID, nickname: "Old Bob", capabilities: []) - bob._test_seedConnectedPeer(alice.myPeerID, nickname: "Alice", capabilities: .privateMedia) + let bobKey = bob.noiseStaticPublicKeyData() + + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: .privateMedia, + noisePublicKey: bobKey + ) + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted) + let bobFingerprint = bobKey.sha256Fingerprint() + #expect(!identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint)) + try establishSession(alice: alice, bob: bob) + let capabilityPinned = await TestHelpers.waitUntil( + { identity.hasObservedPrivateMediaCapability(fingerprint: bobFingerprint) }, + timeout: TestConstants.longTimeout + ) + #expect(capabilityPinned) + + // Model a later verified old/replayed announce for the same stable + // Noise identity. The current bit disappears, but the authenticated + // persistent pin wins. + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: [], + noisePublicKey: bobKey + ) + #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .blockedDowngrade) let tap = PacketTap() alice._test_onOutboundPacket = tap.record - let transferID = "unsupported-private-media-\(UUID().uuidString)" - let cancellation = TransferCancellationRecorder(transferID: transferID) - let cancellable = TransferProgressManager.shared.publisher.sink { cancellation.record($0) } - let content = Data("%PDF-1.7\nprivate".utf8) + 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: "private.pdf", + fileName: "blocked.pdf", fileSize: UInt64(content.count), mimeType: "application/pdf", content: content ), to: bob.myPeerID, - transferId: transferID + 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) == .encrypted }, + 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 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 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 capabilityAnnounceAfterNoiseSessionPinsMatchingFingerprint() 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)) + + try establishSession(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 pinned = await TestHelpers.waitUntil( + { + identity.hasObservedPrivateMediaCapability( + fingerprint: bobKey.sha256Fingerprint() + ) + }, + timeout: TestConstants.longTimeout + ) + #expect(pinned) + } + + @Test + func encryptedAndConsentedLegacySendsRejectAboveAndroidFragmentCap() 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 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 + ) + + let bothRejected = await TestHelpers.waitUntil( + { rejections.contains(encryptedID) && rejections.contains(legacyID) }, + timeout: TestConstants.longTimeout + ) + #expect(bothRejected) + #expect(rejections.reason(for: encryptedID)?.contains("256") == true) + #expect(rejections.reason(for: legacyID)?.contains("256") == true) + #expect(tap.snapshot().isEmpty, "No outer packet or fragment may be exposed before size rejection") + _ = 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 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( - { cancellation.wasCancelled }, + { rejections.contains(transferID) }, timeout: TestConstants.longTimeout ) #expect(rejected) - let mediaWireTypes: Set = [ - MessageType.noiseEncrypted.rawValue, - MessageType.fileTransfer.rawValue, - MessageType.fragment.rawValue - ] - #expect(tap.snapshot().allSatisfy { !mediaWireTypes.contains($0.type) }) + #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) @@ -84,11 +558,18 @@ struct PrivateMediaEndToEndTests { } @Test - func privatePDFIsOpaqueBeforeFragmentationAndDelivers() async throws { + 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", @@ -114,6 +595,129 @@ struct PrivateMediaEndToEndTests { ) } + /// 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 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, @@ -135,8 +739,18 @@ struct PrivateMediaEndToEndTests { alice._test_onOutboundPacket = tap.record bob.delegate = delegate - alice._test_seedConnectedPeer(bob.myPeerID, nickname: "Bob", capabilities: .privateMedia) - bob._test_seedConnectedPeer(alice.myPeerID, nickname: "Alice", capabilities: .privateMedia) + 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 establishSession(alice: alice, bob: bob) let file = BitchatFilePacket( @@ -197,12 +811,15 @@ struct PrivateMediaEndToEndTests { #expect(try Data(contentsOf: storedURL) == content) } - private func makeService(baseDirectory: URL) -> BLEService { + private func makeService( + baseDirectory: URL, + identityManager: SecureIdentityStateManagerProtocol? = nil + ) -> BLEService { let keychain = MockKeychain() return BLEService( keychain: keychain, idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()), - identityManager: MockIdentityManager(keychain), + identityManager: identityManager ?? MockIdentityManager(keychain), initializeBluetoothManagers: false, incomingFileStore: BLEIncomingFileStore(baseDirectory: baseDirectory) ) @@ -221,6 +838,30 @@ struct PrivateMediaEndToEndTests { #expect(bob.canDeliverSecurely(to: alice.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 recursivelyStoredFiles(under root: URL) -> [URL] { guard let enumerator = FileManager.default.enumerator( at: root, @@ -266,6 +907,52 @@ private final class PacketTap: @unchecked Sendable { } } +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 MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable { private let lock = NSLock() private var messages: [BitchatMessage] = [] @@ -289,24 +976,38 @@ private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable } private final class TransferCancellationRecorder: @unchecked Sendable { - private let transferID: String private let lock = NSLock() - private var cancelled = false - - init(transferID: String) { - self.transferID = transferID - } + private var transferIDs: Set = [] + private var rejectionReasons: [String: String] = [:] func record(_ event: TransferProgressManager.Event) { - guard case .cancelled(let id, _, _) = event, id == transferID else { return } + let id: String + switch event { + case .cancelled(let cancelledID, _, _): + id = cancelledID + case .rejected(let rejectedID, _): + id = rejectedID + case .started, .updated, .completed: + return + } lock.lock() - cancelled = true + transferIDs.insert(id) + if case .rejected(_, let reason) = event { + rejectionReasons[id] = reason + } lock.unlock() } - var wasCancelled: Bool { + func contains(_ transferID: String) -> Bool { lock.lock() defer { lock.unlock() } - return cancelled + 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..f3094ae6 100644 --- a/bitchatTests/Mocks/MockIdentityManager.swift +++ b/bitchatTests/Mocks/MockIdentityManager.swift @@ -14,6 +14,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { private var blockedFingerprints: Set = [] private var blockedNostrPubkeys: Set = [] private var socialIdentities: [String: SocialIdentity] = [:] + private var privateMediaCapableFingerprints: Set = [] init(_: KeychainManagerProtocol) {} @@ -87,7 +88,9 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {} - func clearAllIdentityData() {} + func clearAllIdentityData() { + privateMediaCapableFingerprints.removeAll() + } func removeEphemeralSession(peerID: PeerID) {} @@ -101,6 +104,14 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol { Set() } + func markPrivateMediaCapable(fingerprint: String) { + privateMediaCapableFingerprints.insert(fingerprint) + } + + func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { + privateMediaCapableFingerprints.contains(fingerprint) + } + // MARK: Vouching (transitive verification) private var vouchesByVouchee: [String: [VouchRecord]] = [:] diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift index ad01a1c4..102ee072 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,21 @@ 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 cancelTransfer(_ transferId: String) { diff --git a/bitchatTests/Services/BLENoisePayloadFactoryTests.swift b/bitchatTests/Services/BLENoisePayloadFactoryTests.swift index 42150b6f..780112fc 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 { @@ -44,10 +45,38 @@ struct BLENoisePayloadFactoryTests { let payload = try #require(BLENoisePayloadFactory.privateFile(file)) - #expect(payload.first == NoisePayloadType.privateFile.rawValue) + #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) + } } diff --git a/bitchatTests/Services/BLENoiseSessionQueuesTests.swift b/bitchatTests/Services/BLENoiseSessionQueuesTests.swift index ad516592..a933118e 100644 --- a/bitchatTests/Services/BLENoiseSessionQueuesTests.swift +++ b/bitchatTests/Services/BLENoiseSessionQueuesTests.swift @@ -73,7 +73,7 @@ struct BLENoiseSessionQueuesTests { let peerID = PeerID(str: "aaaaaaaaaaaaaaaa") var queues = BLENoiseSessionQueues() - queues.appendTypedPayload(Data([0x09, 0xAA]), transferId: "media-1", for: peerID) + queues.appendTypedPayload(Data([0x20, 0xAA]), transferId: "media-1", for: peerID) queues.appendTypedPayload(Data([0x01, 0xBB]), for: peerID) let removed = queues.removeTypedPayload(transferId: "media-1") 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/BLEPeerRegistryTests.swift b/bitchatTests/Services/BLEPeerRegistryTests.swift index 01b69eae..470b4a84 100644 --- a/bitchatTests/Services/BLEPeerRegistryTests.swift +++ b/bitchatTests/Services/BLEPeerRegistryTests.swift @@ -42,6 +42,37 @@ struct BLEPeerRegistryTests { #expect(registry.info(for: peerID)?.nickname == "alice-renamed") } + @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/SecureIdentityStateManagerTests.swift b/bitchatTests/Services/SecureIdentityStateManagerTests.swift index a280d02e..0ce7035b 100644 --- a/bitchatTests/Services/SecureIdentityStateManagerTests.swift +++ b/bitchatTests/Services/SecureIdentityStateManagerTests.swift @@ -399,6 +399,34 @@ 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_forceSave_withFailingCacheWriteDoesNotPersistCache() async { let keychain = FailingCacheSaveKeychain() let manager = SecureIdentityStateManager(keychain) diff --git a/bitchatTests/Services/TransferProgressManagerTests.swift b/bitchatTests/Services/TransferProgressManagerTests.swift index de04e1cf..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 } } @@ -106,24 +106,22 @@ struct TransferProgressManagerTests { _ = cancellable } - @Test("Preflight rejection publishes cancellation without a started state") + @Test("Preflight policy rejection publishes a visible failure reason") @MainActor - func rejectBeforeStartPublishesCancellation() async { + func rejectBeforeStartPublishesReason() async { let manager = TransferProgressManager() - let transferID = "transfer-preflight-reject" - var cancellable: AnyCancellable? + let transferID = "transfer-visible-reject" let recorder = EventRecorder() - - cancellable = manager.publisher.sink { event in - if case .cancelled(let id, let sent, let total) = event { - recorder.append("cancelled:\(id):\(sent):\(total)") + let cancellable = manager.publisher.sink { event in + if case .rejected(let id, let reason) = event { + recorder.append("rejected:\(id):\(reason)") } } - manager.rejectBeforeStart(id: transferID) + manager.rejectBeforeStart(id: transferID, reason: "upgrade required") let didReceive = await TestHelpers.waitUntil({ - recorder.values == ["cancelled:\(transferID):0:0"] + recorder.values == ["rejected:\(transferID):upgrade required"] }, timeout: 5.0) #expect(didReceive) #expect(manager.snapshot(id: transferID) == nil) diff --git a/bitchatTests/Services/UnifiedPeerServiceTests.swift b/bitchatTests/Services/UnifiedPeerServiceTests.swift index a81003ec..5ac47ac3 100644 --- a/bitchatTests/Services/UnifiedPeerServiceTests.swift +++ b/bitchatTests/Services/UnifiedPeerServiceTests.swift @@ -266,6 +266,9 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol { verified.removeAll() } + func markPrivateMediaCapable(fingerprint: String) {} + func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool { false } + 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..6195fec0 --- /dev/null +++ b/docs/PRIVATE-MEDIA-MIGRATION.md @@ -0,0 +1,80 @@ +# 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. +- A peer advertising `PeerCapabilities.privateMedia` receives one encrypted + `noiseEncrypted` transfer. +- 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. The fingerprint is pinned + in the encrypted identity cache only after a completed Noise session + authenticates the same static key as the capability-bearing registry entry. + This comparison is performed in either event order: session then announce, + or announce then session. A later announce missing the bit is then treated + as a downgrade, and raw fallback is blocked even if a caller presents + legacy consent. +- During migration, both an absent capabilities TLV and an explicit TLV + without `privateMedia` are legacy-eligible when that stable fingerprint is + not pinned. This supports clients that added capability advertisement before + encrypted media. Neither shape bypasses a previously authenticated pin. + +The `0x09` receive alias may be removed only after every TestFlight/internal +build that emitted it has expired and the project's minimum-supported-client +policy excludes those builds. Track that release criterion explicitly; do not +remove the alias on an arbitrary calendar date. + +## 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 +the fallback only for the mixed-version migration and remove it after +supported Android and iOS releases advertise `privateMedia`. Never replace it +with an unsigned fallback, persist blanket consent, or send both forms. + +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 BLE-only. Nostr private-media transport is unchanged +and remains a follow-up; do not infer the BLE consent fallback or capability +pin semantics for Nostr delivery. + +## 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. Private-media v1 therefore runs the +actual route-aware BLE fragment planner before both encrypted and consented +legacy sends and rejects any plan above 256 fragments with a visible failure. +This fragment-count contract, rather than a guessed byte threshold, stays +correct as route overhead changes. diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift index c2a5556c..8438564b 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift @@ -24,8 +24,9 @@ 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 a `.privateFile` Noise - /// payload before outer BLE fragmentation. + /// 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