From 7faa9d7d4ac3268378583285f751c8280254e8f4 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 10 Jul 2026 21:00:54 +0200 Subject: [PATCH 1/2] Encrypt private media before fragmentation --- bitchat/Noise/NoiseSecurityConstants.swift | 19 ++ bitchat/Noise/NoiseSecurityValidator.swift | 13 + bitchat/Noise/SecureNoiseSession.swift | 15 +- bitchat/Protocols/BitchatProtocol.swift | 4 + .../Protocols/PeerCapabilities+Local.swift | 2 +- .../Services/BLE/BLEFileTransferHandler.swift | 161 +++++---- .../BLE/BLEFragmentAssemblyBuffer.swift | 5 +- .../Services/BLE/BLENoisePayloadFactory.swift | 5 + .../Services/BLE/BLENoiseSessionQueues.swift | 31 +- ...BLEOutboundFragmentTransferScheduler.swift | 3 +- bitchat/Services/BLE/BLEService.swift | 123 +++++-- bitchat/Services/NoiseEncryptionService.swift | 40 ++- .../Services/TransferProgressManager.swift | 12 + .../ChatTransportEventCoordinator.swift | 6 + bitchat/ViewModels/NostrInboundPipeline.swift | 6 +- .../EndToEnd/PrivateMediaEndToEndTests.swift | 312 ++++++++++++++++++ .../BLEFileTransferHandlerTests.swift | 107 ++++-- .../BLEFragmentAssemblyBufferTests.swift | 29 ++ .../BLENoisePayloadFactoryTests.swift | 19 ++ .../Services/BLENoiseSessionQueuesTests.swift | 22 +- ...tboundFragmentTransferSchedulerTests.swift | 18 + .../NoiseEncryptionServiceTests.swift | 49 +++ .../TransferProgressManagerTests.swift | 24 ++ .../BitFoundation/PeerCapabilities.swift | 3 + .../PeerCapabilitiesTests.swift | 3 +- 25 files changed, 893 insertions(+), 138 deletions(-) create mode 100644 bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift diff --git a/bitchat/Noise/NoiseSecurityConstants.swift b/bitchat/Noise/NoiseSecurityConstants.swift index 67a722b1..0c301c52 100644 --- a/bitchat/Noise/NoiseSecurityConstants.swift +++ b/bitchat/Noise/NoiseSecurityConstants.swift @@ -6,11 +6,30 @@ // For more information, see // +import BitFoundation import Foundation enum NoiseSecurityConstants { // Maximum message size to prevent memory exhaustion static let maxMessageSize = 65535 // 64KB as per Noise spec + + /// The extracted transport nonce (4 bytes) and Poly1305 tag (16 bytes) + /// added by `NoiseCipherState` around every transport plaintext. + static let transportCiphertextOverhead = 20 + + /// Private files are an explicit BitChat extension to the ordinary Noise + /// message-size ceiling. They remain bounded by the same framed-file cap + /// used by the binary and fragment decoders. Only the `.privateFile` + /// typed-payload path is allowed to use this larger budget. + private static let privateFileOuterPacketOverhead = + (BinaryProtocol.v1HeaderSize + 2) // v2 adds two length bytes + + BinaryProtocol.senderIDSize + + BinaryProtocol.recipientIDSize + static let maxPrivateFilePlaintextSize = FileTransferLimits.maxFramedFileBytes + - privateFileOuterPacketOverhead + - transportCiphertextOverhead + static let maxPrivateFileCiphertextSize = + maxPrivateFilePlaintextSize + transportCiphertextOverhead // Maximum handshake message size static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern diff --git a/bitchat/Noise/NoiseSecurityValidator.swift b/bitchat/Noise/NoiseSecurityValidator.swift index 355d8fd7..e9028199 100644 --- a/bitchat/Noise/NoiseSecurityValidator.swift +++ b/bitchat/Noise/NoiseSecurityValidator.swift @@ -14,6 +14,19 @@ struct NoiseSecurityValidator { static func validateMessageSize(_ data: Data) -> Bool { return data.count <= NoiseSecurityConstants.maxMessageSize } + + static func validateCiphertextSize(_ data: Data) -> Bool { + data.count <= NoiseSecurityConstants.maxMessageSize + + NoiseSecurityConstants.transportCiphertextOverhead + } + + static func validatePrivateFileMessageSize(_ data: Data) -> Bool { + data.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize + } + + static func validatePrivateFileCiphertextSize(_ data: Data) -> Bool { + data.count <= NoiseSecurityConstants.maxPrivateFileCiphertextSize + } /// Validate handshake message size static func validateHandshakeMessageSize(_ data: Data) -> Bool { diff --git a/bitchat/Noise/SecureNoiseSession.swift b/bitchat/Noise/SecureNoiseSession.swift index 0204b108..671fa30c 100644 --- a/bitchat/Noise/SecureNoiseSession.swift +++ b/bitchat/Noise/SecureNoiseSession.swift @@ -24,8 +24,12 @@ final class SecureNoiseSession: NoiseSession { throw NoiseSecurityError.sessionExhausted } - // Validate message size - guard NoiseSecurityValidator.validateMessageSize(plaintext) else { + // Ordinary Noise messages keep the protocol ceiling. Finalized media + // is the sole typed-payload extension and remains under the framed-file + // cap enforced again at the service and file-decoder layers. + let isPrivateFile = plaintext.first == NoisePayloadType.privateFile.rawValue + && NoiseSecurityValidator.validatePrivateFileMessageSize(plaintext) + guard NoiseSecurityValidator.validateMessageSize(plaintext) || isPrivateFile else { throw NoiseSecurityError.messageTooLarge } @@ -42,8 +46,11 @@ final class SecureNoiseSession: NoiseSession { throw NoiseSecurityError.sessionExpired } - // Validate message size - guard NoiseSecurityValidator.validateMessageSize(ciphertext) else { + // The payload type is encrypted, so a large candidate can only be + // bounded here; `NoiseEncryptionService.decrypt` authenticates it and + // then requires the resulting type to be `.privateFile`. + guard NoiseSecurityValidator.validateCiphertextSize(ciphertext) + || NoiseSecurityValidator.validatePrivateFileCiphertextSize(ciphertext) else { throw NoiseSecurityError.messageTooLarge } diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index 6c259f5f..b740787e 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -79,6 +79,9 @@ 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 // Verification (QR-based OOB binding) case verifyChallenge = 0x10 // Verification challenge case verifyResponse = 0x11 // Verification response @@ -93,6 +96,7 @@ enum NoisePayloadType: UInt8 { case .groupInvite: return "groupInvite" case .groupKeyUpdate: return "groupKeyUpdate" case .voiceFrame: return "voiceFrame" + case .privateFile: return "privateFile" case .verifyChallenge: return "verifyChallenge" case .verifyResponse: return "verifyResponse" case .vouch: return "vouch" diff --git a/bitchat/Protocols/PeerCapabilities+Local.swift b/bitchat/Protocols/PeerCapabilities+Local.swift index 819464b9..8cae9798 100644 --- a/bitchat/Protocols/PeerCapabilities+Local.swift +++ b/bitchat/Protocols/PeerCapabilities+Local.swift @@ -3,5 +3,5 @@ import BitFoundation extension PeerCapabilities { /// Capabilities this build advertises in its announce packets. /// Each feature adds its bit here when it ships. - static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups] + static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups, .privateMedia] } diff --git a/bitchat/Services/BLE/BLEFileTransferHandler.swift b/bitchat/Services/BLE/BLEFileTransferHandler.swift index 015a1967..e254e3b1 100644 --- a/bitchat/Services/BLE/BLEFileTransferHandler.swift +++ b/bitchat/Services/BLE/BLEFileTransferHandler.swift @@ -16,6 +16,8 @@ struct BLEFileTransferHandlerEnvironment { let peersSnapshot: () -> [PeerID: BLEPeerInfo] /// Verifies a packet's signature against a candidate signing key (registry path). let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool + /// Local signing key used to authenticate our own gossip-sync replays. + let localSigningPublicKey: () -> Data /// Resolves a display name from a verified packet signature for peers missing from the registry. let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String? /// Tracks the broadcast file packet for gossip sync. @@ -46,54 +48,105 @@ final class BLEFileTransferHandler { self.environment = environment } - /// Returns `false` when the packet fails sender authentication and must - /// not be relayed onward. Every other outcome returns `true`: files - /// directed to another peer are forwarded untouched, and local-only drops - /// (malformed payload, quota, save failure) don't affect multi-hop - /// delivery to nodes that may handle them fine. + /// Returns `false` when the raw packet fails sender authentication (or is + /// a live self-echo) and must not be relayed onward. Authentication runs + /// before the routing decision, so a forged directed packet cannot use a + /// node that is not its recipient as an unsigned forwarding hop. @discardableResult func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { let env = environment - if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true } - - guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else { - return true - } - + let localPeerID = env.localPeerID() let peersSnapshot = env.peersSnapshot() - guard let senderNickname = resolveSenderNickname( + + guard let senderNickname = authenticatedRawSenderNickname( packet: packet, from: peerID, - isBroadcast: !deliveryPlan.isPrivateMessage, peers: peersSnapshot, env: env ) else { - SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security) + SecureLogger.warning("🚫 Dropping raw file transfer with missing/invalid signature from \(peerID.id.prefix(8))…", category: .security) return false } + if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: localPeerID) { + return false + } + + guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: localPeerID) else { + return true + } + if deliveryPlan.shouldTrackForSync { env.trackPacketSeen(packet) } + _ = storeIncomingPayload( + packet.payload, + from: peerID, + senderNickname: senderNickname, + timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000), + isPrivate: deliveryPlan.isPrivateMessage, + env: env + ) + // Once authenticated, a local decode/quota/save failure is not proof + // that downstream nodes should be denied the valid signed packet. + return true + } + + /// Accepts a file packet only after it has been authenticated and + /// decrypted by the peer's Noise session. The inner packet deliberately + /// has no redundant signature: Noise supplies sender authentication and + /// confidentiality, while this handler retains the same validation, + /// quota, persistence, and UI-delivery behavior as public files. + @discardableResult + func handlePrivatePayload(_ payload: Data, from peerID: PeerID, timestamp: Date) -> Bool { + let env = environment + let peers = env.peersSnapshot() + let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer( + peerID: peerID, + localPeerID: env.localPeerID(), + localNickname: env.localNickname(), + peers: peers, + allowConnectedUnverified: true + ) ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID) + + return storeIncomingPayload( + payload, + from: peerID, + senderNickname: senderNickname, + timestamp: timestamp, + isPrivate: true, + env: env + ) + } + + private func storeIncomingPayload( + _ payload: Data, + from peerID: PeerID, + senderNickname: String, + timestamp: Date, + isPrivate: Bool, + env: BLEFileTransferHandlerEnvironment + ) -> Bool { + let filePacket: BitchatFilePacket let mime: MimeType - switch BLEIncomingFileValidator.validate(payload: packet.payload) { + switch BLEIncomingFileValidator.validate(payload: payload) { case .success(let acceptance): filePacket = acceptance.filePacket mime = acceptance.mime case .failure(.malformedPayload): SecureLogger.error("❌ Failed to decode file transfer payload", category: .session) - return true + return false case .failure(.payloadTooLarge(let bytes)): SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security) - return true + return false case .failure(.unsupportedMime(let mimeType, let bytes)): SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security) - return true + return false case .failure(.magicMismatch(let mime, let bytes, let prefixHex)): SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security) - return true + return false } // BCH-01-002: Enforce storage quota before saving @@ -106,28 +159,27 @@ final class BLEFileTransferHandler { mime.defaultExtension, mime.category.rawValue ) else { - return true + return false } - if deliveryPlan.isPrivateMessage { + if isPrivate { env.updatePeerLastSeen(peerID) } - let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) let message = BitchatMessage( sender: senderNickname, content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)", - timestamp: ts, + timestamp: timestamp, isRelay: false, originalSender: nil, - isPrivate: deliveryPlan.isPrivateMessage, + isPrivate: isPrivate, recipientNickname: nil, senderPeerID: peerID, // Received messages need an explicit status: BitchatMessage // defaults private messages to .sending, which the media views // render as an in-flight send (empty reveal mask, disabled tap). - deliveryStatus: deliveryPlan.isPrivateMessage - ? .delivered(to: env.localNickname(), at: ts) + deliveryStatus: isPrivate + ? .delivered(to: env.localNickname(), at: timestamp) : nil ) @@ -137,51 +189,38 @@ final class BLEFileTransferHandler { return true } - /// Resolves the authenticated display name for a file transfer's sender. - /// - /// Directed (private) transfers are addressed to us specifically and keep - /// the lenient connected-peer path. Broadcast transfers carry an - /// attacker-controllable `senderID` exactly like public messages and public - /// voice frames — registry membership alone is NOT proof of identity, so a - /// valid packet signature from the claimed sender is required before we - /// trust it. Without this, a peer that observed a public voice burst could - /// spoof a broadcast `voice_.m4a` note under the talker's ID and - /// overwrite the signature-verified live bubble with attacker audio. - private func resolveSenderNickname( + /// Every remaining raw file transfer is signed, regardless of whether it + /// is broadcast, addressed to us, or merely passing through. Registry + /// signing keys are preferred; persisted identities cover peers that have + /// rotated or are not currently present in the registry. + private func authenticatedRawSenderNickname( packet: BitchatPacket, from peerID: PeerID, - isBroadcast: Bool, peers: [PeerID: BLEPeerInfo], env: BLEFileTransferHandlerEnvironment ) -> String? { - guard isBroadcast else { - return BLEPeerSenderDisplayName.resolveKnownPeer( - peerID: peerID, - localPeerID: env.localPeerID(), - localNickname: env.localNickname(), - peers: peers, - allowConnectedUnverified: true - ) ?? env.signedSenderDisplayName(packet, peerID) - } + guard packet.signature != nil else { return nil } - // Our own broadcasts replayed back via gossip sync (ttl==0) are - // trivially authentic and cannot be verified against the peer registry - // or identity cache, so exempt self exactly as `BLEPublicMessageHandler` - // does. Verify against the signing key already in the - // (synchronously-updated) registry first, then fall back to the - // persisted-identity signature lookup for peers not yet cached there. - let isSelf = peerID == env.localPeerID() - let registrySigningKey = peers[peerID]?.signingPublicKey - let verifiedViaRegistry = !isSelf && (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false) - let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID) - guard isSelf || verifiedViaRegistry || signedDisplayName != nil else { return nil } + let localPeerID = env.localPeerID() + let candidateKey = peerID == localPeerID + ? env.localSigningPublicKey() + : peers[peerID]?.signingPublicKey + let verifiedWithKnownKey = candidateKey.map { + env.verifyPacketSignature(packet, $0) + } ?? false + let signedDisplayName = verifiedWithKnownKey + ? nil + : env.signedSenderDisplayName(packet, peerID) + guard verifiedWithKnownKey || signedDisplayName != nil else { return nil } return BLEPeerSenderDisplayName.resolveKnownPeer( peerID: peerID, - localPeerID: env.localPeerID(), + localPeerID: localPeerID, localNickname: env.localNickname(), peers: peers, - allowConnectedUnverified: false - ) ?? signedDisplayName + // The packet signature authenticates the announced peer; the old + // connected-but-unsigned leniency is not involved. + allowConnectedUnverified: true + ) ?? signedDisplayName ?? BLEPeerSenderDisplayName.anonymousNickname(for: peerID) } } diff --git a/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift b/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift index 9550e196..31656cb4 100644 --- a/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift +++ b/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift @@ -201,8 +201,11 @@ struct BLEFragmentAssemblyBuffer { } private static func assemblyLimit(for originalType: UInt8) -> Int { - if originalType == MessageType.fileTransfer.rawValue { + if originalType == MessageType.fileTransfer.rawValue + || originalType == MessageType.noiseEncrypted.rawValue { // Allow headroom for TLV metadata and binary framing overhead. + // A large noiseEncrypted packet can be an E2E-encrypted private + // file; its authenticated plaintext is validated after decrypt. return FileTransferLimits.maxFramedFileBytes } diff --git a/bitchat/Services/BLE/BLENoisePayloadFactory.swift b/bitchat/Services/BLE/BLENoisePayloadFactory.swift index 0aac077f..aa50b910 100644 --- a/bitchat/Services/BLE/BLENoisePayloadFactory.swift +++ b/bitchat/Services/BLE/BLENoisePayloadFactory.swift @@ -17,6 +17,11 @@ enum BLENoisePayloadFactory { typedPayload(.delivered, payload: Data(messageID.utf8)) } + static func privateFile(_ filePacket: BitchatFilePacket) -> Data? { + guard let payload = filePacket.encode() else { return nil } + return typedPayload(.privateFile, payload: payload) + } + static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data { var typed = Data([type.rawValue]) typed.append(payload) diff --git a/bitchat/Services/BLE/BLENoiseSessionQueues.swift b/bitchat/Services/BLE/BLENoiseSessionQueues.swift index 84eeaba8..4acb4d7b 100644 --- a/bitchat/Services/BLE/BLENoiseSessionQueues.swift +++ b/bitchat/Services/BLE/BLENoiseSessionQueues.swift @@ -6,9 +6,16 @@ struct BLEPendingPrivateMessage: Equatable { let messageID: String } +struct BLEPendingTypedPayload: Equatable { + let payload: Data + /// Present for app-initiated media so handshake queuing preserves the + /// fragment scheduler's progress/cancellation identity. + let transferId: String? +} + struct BLENoiseSessionQueues { private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:] - private var typedPayloadsByPeerID: [PeerID: [Data]] = [:] + private var typedPayloadsByPeerID: [PeerID: [BLEPendingTypedPayload]] = [:] var isEmpty: Bool { privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty @@ -34,13 +41,29 @@ struct BLENoiseSessionQueues { privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0) } - mutating func appendTypedPayload(_ payload: Data, for peerID: PeerID) { - typedPayloadsByPeerID[peerID, default: []].append(payload) + mutating func appendTypedPayload(_ payload: Data, transferId: String? = nil, for peerID: PeerID) { + typedPayloadsByPeerID[peerID, default: []].append( + BLEPendingTypedPayload(payload: payload, transferId: transferId) + ) } - mutating func takeTypedPayloads(for peerID: PeerID) -> [Data] { + mutating func takeTypedPayloads(for peerID: PeerID) -> [BLEPendingTypedPayload] { let payloads = typedPayloadsByPeerID[peerID] ?? [] typedPayloadsByPeerID.removeValue(forKey: peerID) return payloads } + + @discardableResult + mutating func removeTypedPayload(transferId: String) -> Bool { + for peerID in Array(typedPayloadsByPeerID.keys) { + guard var payloads = typedPayloadsByPeerID[peerID], + let index = payloads.firstIndex(where: { $0.transferId == transferId }) else { + continue + } + payloads.remove(at: index) + typedPayloadsByPeerID[peerID] = payloads.isEmpty ? nil : payloads + return true + } + return false + } } diff --git a/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift b/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift index 882e4978..722e55c4 100644 --- a/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift +++ b/bitchat/Services/BLE/BLEOutboundFragmentTransferScheduler.swift @@ -29,8 +29,9 @@ struct BLEOutboundFragmentTransferRequest { } var resolvedTransferId: String? { + if let transferId { return transferId } guard packet.type == MessageType.fileTransfer.rawValue else { return nil } - return transferId ?? packet.payload.sha256Hex() + return packet.payload.sha256Hex() } /// Content identity independent of the caller-chosen transfer ID: the diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 1f919a4a..61312878 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -131,7 +131,7 @@ final class BLEService: NSObject { // 5. Fragment Reassembly (necessary for messages > MTU) private var fragmentAssemblyBuffer = BLEFragmentAssemblyBuffer() private var outboundFragmentTransfers = BLEOutboundFragmentTransferScheduler() - private let incomingFileStore = BLEIncomingFileStore() + private let incomingFileStore: BLEIncomingFileStore // Simple announce throttling private var announceThrottle = BLEAnnounceThrottle() @@ -275,10 +275,12 @@ final class BLEService: NSObject { keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge, identityManager: SecureIdentityStateManagerProtocol, - initializeBluetoothManagers: Bool = true + initializeBluetoothManagers: Bool = true, + incomingFileStore: BLEIncomingFileStore = BLEIncomingFileStore() ) { self.keychain = keychain self.idBridge = idBridge + self.incomingFileStore = incomingFileStore noiseService = NoiseEncryptionService(keychain: keychain) self.identityManager = identityManager super.init() @@ -877,7 +879,9 @@ final class BLEService: NSObject { SecureLogger.debug("🛑 Removed pending transfer \(id.prefix(8))… before start", category: .session) case .missing: - break + if self.pendingNoiseSessionQueues.removeTypedPayload(transferId: transferId) { + SecureLogger.debug("🛑 Removed handshake-queued transfer \(transferId.prefix(8))…", category: .session) + } } } } @@ -935,35 +939,44 @@ final class BLEService: NSObject { func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) { messageQueue.async { [weak self] in guard let self = self else { return } - guard let payload = filePacket.encode() else { - SecureLogger.error("❌ Failed to encode file packet for private send", category: .session) - return - } - // Normalize to short form (SHA256-derived 16-hex) for wire protocol compatibility - // This ensures 64-hex Noise keys are converted to the canonical routing format let targetID = peerID.toShort() - guard let recipientData = Data(hexString: targetID.id) else { - SecureLogger.error("❌ Invalid recipient peer ID for file transfer: \(peerID.id.prefix(8))…", category: .session) + let supportsPrivateMedia = self.collectionsQueue.sync { + self.peerRegistry.capabilities(for: targetID).contains(.privateMedia) + } + guard supportsPrivateMedia else { + SecureLogger.warning( + "Private media not sent: \(targetID.id.prefix(8))… did not advertise encrypted-media support", + category: .security + ) + TransferProgressManager.shared.rejectBeforeStart(id: 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) + return + } + guard self.noiseService.hasEstablishedSession(with: targetID) else { + self.collectionsQueue.sync(flags: .barrier) { + self.pendingNoiseSessionQueues.appendTypedPayload( + typedPayload, + transferId: transferId, + for: targetID + ) + } + SecureLogger.debug("📥 Queued private file for \(targetID.id.prefix(8))… pending handshake", category: .session) + self.initiateNoiseHandshake(with: targetID) return } - var packet = BitchatPacket( - type: MessageType.fileTransfer.rawValue, - senderID: self.myPeerIDData, - recipientID: recipientData, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: payload, - signature: nil, - ttl: self.messageTTL, - version: 2 - ) - - if let signed = self.noiseService.signPacket(packet) { - packet = signed + do { + let packet = try self.makeEncryptedNoisePacket(typedPayload, to: targetID) + SecureLogger.debug("📁 Sending encrypted private file to \(targetID.id.prefix(8))… plaintextBytes=\(typedPayload.count)", category: .session) + self.broadcastPacket(packet, transferId: transferId) + } catch { + SecureLogger.error("❌ Failed to encrypt private file for \(targetID.id.prefix(8))…: \(error)", category: .security) + TransferProgressManager.shared.rejectBeforeStart(id: transferId) } - - SecureLogger.debug("📁 Sending private file transfer to \(peerID.id.prefix(8))… bytes=\(payload.count)", category: .session) - self.broadcastPacket(packet, transferId: transferId) } } @@ -1106,6 +1119,21 @@ final class BLEService: NSObject { sendFragmentedPacket(packetToSend, pad: padForBLE, maxChunk: nil, directedOnlyPeer: nil, transferId: transferId) return } + // App-initiated private media is already one opaque Noise ciphertext. + // Always fragment that outer packet so the existing transfer scheduler + // retains progress/cancel behavior without exposing the file TLVs. + if packetToSend.type == MessageType.noiseEncrypted.rawValue, + let transferId, + let recipientPeerID = PeerID(hexData: packetToSend.recipientID) { + sendFragmentedPacket( + packetToSend, + pad: padForBLE, + maxChunk: nil, + directedOnlyPeer: recipientPeerID, + transferId: transferId + ) + return + } guard let data = packetToSend.toBinaryData(padding: padForBLE) else { SecureLogger.error("❌ Failed to convert packet to binary data", category: .session) return @@ -1533,6 +1561,9 @@ final class BLEService: NSObject { verifyPacketSignature: { [weak self] packet, signingPublicKey in self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false }, + localSigningPublicKey: { [weak self] in + self?.noiseService.getSigningPublicKeyData() ?? Data() + }, signedSenderDisplayName: { [weak self] packet, peerID in self?.signedSenderDisplayName(for: packet, from: peerID) }, @@ -2336,7 +2367,11 @@ extension BLEService { } } - func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) { + func _test_seedConnectedPeer( + _ peerID: PeerID, + nickname: String, + capabilities: PeerCapabilities = [] + ) { collectionsQueue.sync(flags: .barrier) { peerRegistry.upsert(BLEPeerInfo( peerID: peerID, @@ -2345,7 +2380,8 @@ extension BLEService { noisePublicKey: nil, signingPublicKey: nil, isVerifiedNickname: true, - lastSeen: Date() + lastSeen: Date(), + capabilities: capabilities )) } } @@ -3399,7 +3435,13 @@ extension BLEService { } private func makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket { - let encrypted = try noiseService.encrypt(typedPayload, for: peerID) + let encrypted: Data + let isPrivateFile = typedPayload.first == NoisePayloadType.privateFile.rawValue + if isPrivateFile { + encrypted = try noiseService.encryptPrivateFilePayload(typedPayload, for: peerID) + } else { + encrypted = try noiseService.encrypt(typedPayload, for: peerID) + } return BitchatPacket( type: MessageType.noiseEncrypted.rawValue, senderID: myPeerIDData, @@ -3407,7 +3449,9 @@ extension BLEService { timestamp: UInt64(Date().timeIntervalSince1970 * 1000), payload: encrypted, signature: nil, - ttl: messageTTL + ttl: messageTTL, + // v1 has a 16-bit payload length; finalized media can exceed it. + version: isPrivateFile ? 2 : 1 ) } @@ -5472,6 +5516,14 @@ extension BLEService { self?.noiseService.clearSession(for: peerID) }, deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in + if type == .privateFile { + self?.fileTransferHandler.handlePrivatePayload( + payload, + from: peerID, + timestamp: timestamp + ) + return + } // Single main-actor hop delivering `.noisePayloadReceived`. self?.notifyUI { [weak self] in self?.deliverTransportEvent(.noisePayloadReceived( @@ -5488,14 +5540,17 @@ extension BLEService { // MARK: Helper Functions private func sendPendingNoisePayloadsAfterHandshake(for peerID: PeerID) { - let payloads = collectionsQueue.sync(flags: .barrier) { () -> [Data] in + let payloads = collectionsQueue.sync(flags: .barrier) { () -> [BLEPendingTypedPayload] in pendingNoiseSessionQueues.takeTypedPayloads(for: peerID) } guard !payloads.isEmpty else { return } SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID.id.prefix(8))… after handshake", category: .session) - for payload in payloads { + for pending in payloads { do { - broadcastPacket(try makeEncryptedNoisePacket(payload, to: peerID)) + broadcastPacket( + try makeEncryptedNoisePacket(pending.payload, to: peerID), + transferId: pending.transferId + ) } catch { SecureLogger.error("❌ Failed to send pending noise payload to \(peerID.id.prefix(8))…: \(error)") } diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 3e0aae6b..cbe162d2 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -725,11 +725,38 @@ final class NoiseEncryptionService { return try sessionManager.encrypt(data, for: peerID) } + + /// Encrypts a finalized private-media packet. Ordinary Noise application + /// messages retain the 64 KiB ceiling; this purpose-specific path permits + /// the bounded `BitchatFilePacket` envelope and refuses every other typed + /// payload so the larger allocation budget cannot become a generic bypass. + func encryptPrivateFilePayload(_ data: Data, for peerID: PeerID) throws -> Data { + guard data.first == NoisePayloadType.privateFile.rawValue, + NoiseSecurityValidator.validatePrivateFileMessageSize(data) else { + throw NoiseSecurityError.messageTooLarge + } + + guard rateLimiter.allowMessage(from: peerID) else { + throw NoiseSecurityError.rateLimitExceeded + } + + guard hasEstablishedSession(with: peerID) else { + onHandshakeRequired?(peerID) + throw NoiseEncryptionError.handshakeRequired + } + + // `maxPrivateFilePlaintextSize` already subtracts the cipher's fixed + // nonce/tag overhead, so the result is bounded without a second copy. + return try sessionManager.encrypt(data, for: peerID) + } /// Decrypt data from a specific peer func decrypt(_ data: Data, from peerID: PeerID) throws -> Data { - // Validate message size - guard NoiseSecurityValidator.validateMessageSize(data) else { + // Standard transport ciphertext has 20 bytes of nonce/tag overhead. + // A larger candidate is admitted only up to the framed-file ceiling; + // after authenticated decryption it must prove it is `.privateFile`. + let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data) + guard isStandardCiphertext || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data) else { throw NoiseSecurityError.messageTooLarge } @@ -743,7 +770,14 @@ final class NoiseEncryptionService { throw NoiseEncryptionError.sessionNotEstablished } - return try sessionManager.decrypt(data, from: peerID) + let decrypted = try sessionManager.decrypt(data, from: peerID) + if !isStandardCiphertext { + guard decrypted.first == NoisePayloadType.privateFile.rawValue, + NoiseSecurityValidator.validatePrivateFileMessageSize(decrypted) else { + throw NoiseSecurityError.messageTooLarge + } + } + return decrypted } // MARK: - Peer Management diff --git a/bitchat/Services/TransferProgressManager.swift b/bitchat/Services/TransferProgressManager.swift index 4c6a34d8..9d04ea83 100644 --- a/bitchat/Services/TransferProgressManager.swift +++ b/bitchat/Services/TransferProgressManager.swift @@ -49,6 +49,18 @@ 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) { + 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)) + } + } + func snapshot(id: String) -> (sent: Int, total: Int)? { var result: (sent: Int, total: Int)? queue.sync { diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index 1ec74850..5f219433 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -407,6 +407,12 @@ private extension ChatTransportEventCoordinator { case .voiceFrame: context.handleVoiceFramePayload(from: peerID, payload: payload, timestamp: timestamp) + + case .privateFile: + // BLEService validates and persists decrypted private files before + // emitting a normal `.messageReceived` event. No raw file bytes + // should cross this UI-facing typed-payload fallback. + break } } diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index bc5ae744..6afc7a1f 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -307,7 +307,7 @@ final class NostrInboundPipeline { // claiming to be group traffic over Nostr is ignored. // Live voice is mesh-only: latency and relay cost make it // meaningless over Nostr. - case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame: + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile: break } } @@ -363,7 +363,7 @@ final class NostrInboundPipeline { // claiming to be group traffic over Nostr is ignored. // Live voice is mesh-only: latency and relay cost make it // meaningless over Nostr. - case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame: + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile: break } } @@ -446,7 +446,7 @@ final class NostrInboundPipeline { // in v1; group traffic over Nostr is ignored. // Live voice is mesh-only: latency and relay cost make it // meaningless over Nostr. - case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame: + case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile: break } } diff --git a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift new file mode 100644 index 00000000..c4b42162 --- /dev/null +++ b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift @@ -0,0 +1,312 @@ +import BitFoundation +import Combine +import CoreBluetooth +import Foundation +import Testing +@testable import bitchat + +/// Wire-level coverage for finalized DM media. The sender encrypts one typed +/// private-file payload, relays see only the outer Noise packet/fragments, and +/// the receiver reassembles, decrypts, validates, persists, and delivers it. +@Suite("Private media end to end", .serialized) +struct PrivateMediaEndToEndTests { + @Test + func peerWithoutPrivateMediaCapabilityIsRejectedBeforeWireSend() 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 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) + try establishSession(alice: alice, bob: bob) + + 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) + alice.sendFilePrivate( + BitchatFilePacket( + fileName: "private.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ), + to: bob.myPeerID, + transferId: transferID + ) + + let rejected = await TestHelpers.waitUntil( + { cancellation.wasCancelled }, + 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) }) + _ = cancellable + } + + @Test + func privateJPEGIsOpaqueBeforeFragmentationAndDelivers() async throws { + let marker = Data("JPEG_PRIVATE_MARKER_7f5e5eacb86f4b9a".utf8) + let content = Data([0xFF, 0xD8, 0xFF, 0xE0]) + + marker + + Data(repeating: 0x4A, count: 6 * 1024) + try await assertPrivateMediaRoundTrip( + fileName: "private.jpg", + mimeType: "image/jpeg", + content: content, + marker: marker, + expectedMessagePrefix: "[image]" + ) + } + + @Test + func finalizedPrivateM4AIsOpaqueBeforeFragmentationAndDelivers() async throws { + let marker = Data("M4A_PRIVATE_MARKER_e0cd431b61fb4a6c".utf8) + let content = Data([0x00, 0x00, 0x00, 0x18]) + + Data("ftypM4A ".utf8) + + marker + + Data(repeating: 0x4D, count: 6 * 1024) + try await assertPrivateMediaRoundTrip( + fileName: "voice_0011223344556677.m4a", + mimeType: "audio/mp4", + content: content, + marker: marker, + expectedMessagePrefix: "[voice]" + ) + } + + @Test + func privatePDFIsOpaqueBeforeFragmentationAndDelivers() 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) + try await assertPrivateMediaRoundTrip( + fileName: "private.pdf", + mimeType: "application/pdf", + content: content, + marker: marker, + expectedMessagePrefix: "[file]" + ) + } + + @Test + func privateMediaAboveOrdinaryNoiseLimitUsesV2OuterPacketAndDelivers() async throws { + let marker = Data("LARGE_PRIVATE_MARKER_1ec63f261a7041ee".utf8) + let content = Data("%PDF-1.7\n".utf8) + + marker + + Data(repeating: 0x4C, count: 70 * 1024) + try await assertPrivateMediaRoundTrip( + fileName: "large-private.pdf", + mimeType: "application/pdf", + content: content, + marker: marker, + expectedMessagePrefix: "[file]", + expectedOuterVersion: 2 + ) + } + + private func assertPrivateMediaRoundTrip( + fileName: String, + mimeType: String, + content: Data, + marker: Data, + expectedMessagePrefix: String, + expectedOuterVersion: UInt8 = 2 + ) async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("private-media-e2e-\(UUID().uuidString)", isDirectory: true) + let aliceRoot = root.appendingPathComponent("alice", isDirectory: true) + let bobRoot = root.appendingPathComponent("bob", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let alice = makeService(baseDirectory: aliceRoot) + let bob = makeService(baseDirectory: bobRoot) + let tap = PacketTap() + let delegate = MessageCaptureDelegate() + 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) + try establishSession(alice: alice, bob: bob) + + let file = BitchatFilePacket( + fileName: fileName, + fileSize: UInt64(content.count), + mimeType: mimeType, + content: content + ) + alice.sendFilePrivate(file, to: bob.myPeerID, transferId: "wire-\(UUID().uuidString)") + + let fragmented = await TestHelpers.waitUntil( + { tap.hasCompleteFragmentTrain }, + timeout: 10 + ) + #expect(fragmented) + + let outbound = tap.snapshot() + let encryptedPackets = outbound.filter { $0.type == MessageType.noiseEncrypted.rawValue } + let fragments = outbound + .filter { $0.type == MessageType.fragment.rawValue } + .sorted { fragmentIndex($0) < fragmentIndex($1) } + + #expect(encryptedPackets.count == 1) + #expect(encryptedPackets.first?.version == expectedOuterVersion) + #expect(!fragments.isEmpty) + #expect(outbound.allSatisfy { $0.type != MessageType.fileTransfer.rawValue }) + for packet in encryptedPackets + fragments { + #expect(packet.payload.range(of: marker) == nil) + #expect(packet.payload.range(of: content) == nil) + } + + // Real BLE delivers the train at the scheduler's paced interval. Feed + // bounded batches here instead of enqueuing hundreds of synthetic + // callbacks at once, which can exhaust libdispatch worker threads as + // they wait on the fragment-assembly barrier. + for batchStart in stride(from: 0, to: fragments.count, by: 16) { + let batchEnd = min(batchStart + 16, fragments.count) + for fragment in fragments[batchStart.. BLEService { + let keychain = MockKeychain() + return BLEService( + keychain: keychain, + idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()), + identityManager: MockIdentityManager(keychain), + initializeBluetoothManagers: false, + incomingFileStore: BLEIncomingFileStore(baseDirectory: baseDirectory) + ) + } + + private func establishSession(alice: BLEService, bob: BLEService) throws { + let first = try alice._test_noiseInitiateHandshake(with: bob.myPeerID) + let second = try #require( + try bob._test_noiseProcessHandshakeMessage(from: alice.myPeerID, message: first) + ) + let third = try #require( + try alice._test_noiseProcessHandshakeMessage(from: bob.myPeerID, message: second) + ) + _ = try bob._test_noiseProcessHandshakeMessage(from: alice.myPeerID, message: third) + #expect(alice.canDeliverSecurely(to: bob.myPeerID)) + #expect(bob.canDeliverSecurely(to: alice.myPeerID)) + } + + private func recursivelyStoredFiles(under root: URL) -> [URL] { + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey] + ) else { return [] } + + return enumerator.compactMap { item in + guard let url = item as? URL, + (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else { + return nil + } + return url + } + } +} + +private func fragmentIndex(_ packet: BitchatPacket) -> Int { + guard packet.payload.count >= 10 else { return .max } + return (Int(packet.payload[8]) << 8) | Int(packet.payload[9]) +} + +private final class PacketTap: @unchecked Sendable { + private let lock = NSLock() + private var packets: [BitchatPacket] = [] + + func record(_ packet: BitchatPacket) { + lock.lock() + packets.append(packet) + lock.unlock() + } + + func snapshot() -> [BitchatPacket] { + lock.lock() + defer { lock.unlock() } + return packets + } + + var hasCompleteFragmentTrain: Bool { + let fragments = snapshot().filter { $0.type == MessageType.fragment.rawValue } + guard let first = fragments.first, first.payload.count >= 12 else { return false } + let total = (Int(first.payload[10]) << 8) | Int(first.payload[11]) + return total > 0 && fragments.count >= total + } +} + +private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable { + private let lock = NSLock() + private var messages: [BitchatMessage] = [] + + func didReceiveMessage(_ message: BitchatMessage) { + lock.lock() + messages.append(message) + lock.unlock() + } + + func snapshot() -> [BitchatMessage] { + lock.lock() + defer { lock.unlock() } + return messages + } + + func didConnectToPeer(_ peerID: PeerID) {} + func didDisconnectFromPeer(_ peerID: PeerID) {} + func didUpdatePeerList(_ peers: [PeerID]) {} + func didUpdateBluetoothState(_ state: CBManagerState) {} +} + +private final class TransferCancellationRecorder: @unchecked Sendable { + private let transferID: String + private let lock = NSLock() + private var cancelled = false + + init(transferID: String) { + self.transferID = transferID + } + + func record(_ event: TransferProgressManager.Event) { + guard case .cancelled(let id, _, _) = event, id == transferID else { return } + lock.lock() + cancelled = true + lock.unlock() + } + + var wasCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return cancelled + } +} diff --git a/bitchatTests/Services/BLEFileTransferHandlerTests.swift b/bitchatTests/Services/BLEFileTransferHandlerTests.swift index 170aba9a..a0f642db 100644 --- a/bitchatTests/Services/BLEFileTransferHandlerTests.swift +++ b/bitchatTests/Services/BLEFileTransferHandlerTests.swift @@ -33,6 +33,7 @@ struct BLEFileTransferHandlerTests { recorder.signatureVerifyCount += 1 return recorder.signatureVerifies }, + localSigningPublicKey: { [sampleSigningKey] in sampleSigningKey }, signedSenderDisplayName: { _, peerID in recorder.signedNameQueries.append(peerID) return recorder.signedName @@ -92,12 +93,11 @@ struct BLEFileTransferHandlerTests { @Test func selfEchoIsDropped() throws { let recorder = Recorder() + recorder.signatureVerifies = true let handler = makeHandler(recorder: recorder) let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3) - // The relay pipeline already suppresses self-originated packets, so the - // handler reports "relayable" rather than treating the echo as forged. - #expect(handler.handle(packet, from: localPeerID)) + #expect(!handler.handle(packet, from: localPeerID)) expectNoSideEffects(recorder) } @@ -120,7 +120,12 @@ struct BLEFileTransferHandlerTests { let recorder = Recorder() recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)] let handler = makeHandler(recorder: recorder) - let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8)) + let packet = try makeFileTransferPacket( + sender: remotePeerID, + mimeType: "application/pdf", + content: Data("%PDF-1.7".utf8), + hasSignature: false + ) // Failed sender authentication must also stop the packet from being // relayed to downstream nodes. @@ -129,7 +134,7 @@ struct BLEFileTransferHandlerTests { // Broadcast files carry an attacker-controllable senderID, so — like // public messages — a connected-but-unverified peer must present a valid // packet signature. No signing key + no signed identity means dropped. - #expect(recorder.signedNameQueries == [remotePeerID]) + #expect(recorder.signedNameQueries.isEmpty) #expect(recorder.trackedPackets.isEmpty) #expect(recorder.deliveredMessages.isEmpty) } @@ -153,12 +158,11 @@ struct BLEFileTransferHandlerTests { } @Test - func selfBroadcastReplayIsDeliveredWithoutSignatureCheck() throws { - // Our own broadcast file replayed via gossip sync arrives with ttl==0 - // (so it is not treated as a self-echo) and cannot be verified against - // the peer registry — it must still be accepted, matching - // BLEPublicMessageHandler's self exemption. + func signedSelfBroadcastReplayIsDelivered() throws { + // Our own broadcast file replayed via gossip sync arrives with ttl==0; + // it is verified against our local signing key before delivery. let recorder = Recorder() + recorder.signatureVerifies = true let handler = makeHandler(recorder: recorder) let packet = try makeFileTransferPacket( sender: localPeerID, @@ -169,7 +173,7 @@ struct BLEFileTransferHandlerTests { #expect(handler.handle(packet, from: localPeerID)) - #expect(recorder.signatureVerifyCount == 0) + #expect(recorder.signatureVerifyCount == 1) #expect(recorder.signedNameQueries.isEmpty) #expect(recorder.deliveredMessages.count == 1) #expect(recorder.deliveredMessages.first?.sender == "Me") @@ -205,7 +209,8 @@ struct BLEFileTransferHandlerTests { sender: remotePeerID, mimeType: "audio/mp4", content: m4a, - fileName: "voice_1122334455667788" + fileName: "voice_1122334455667788", + hasSignature: false ) // The spoofed note must be dropped locally AND not relayed onward. @@ -215,7 +220,7 @@ struct BLEFileTransferHandlerTests { } @Test - func privateFileFromConnectedUnverifiedPeerIsAccepted() throws { + func rawDirectedFileWithoutVerifiableSignatureIsDroppedWithoutWriteOrRelay() throws { let recorder = Recorder() recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)] let handler = makeHandler(recorder: recorder) @@ -223,23 +228,25 @@ struct BLEFileTransferHandlerTests { sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), - recipientID: Data(hexString: localPeerID.id) + recipientID: Data(hexString: localPeerID.id), + hasSignature: false ) - #expect(handler.handle(packet, from: remotePeerID)) + #expect(!handler.handle(packet, from: remotePeerID)) - // Directed transfers keep the lenient connected-peer path (no broadcast - // exposure); no signature check is required. #expect(recorder.signatureVerifyCount == 0) #expect(recorder.signedNameQueries.isEmpty) - #expect(recorder.deliveredMessages.count == 1) - #expect(recorder.deliveredMessages.first?.isPrivate == true) + #expect(recorder.trackedPackets.isEmpty) + #expect(recorder.quotaReservations.isEmpty) + #expect(recorder.saveCalls.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) } @Test func fileDirectedToAnotherPeerIsIgnored() throws { let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)] + recorder.signatureVerifies = true let handler = makeHandler(recorder: recorder) let packet = try makeFileTransferPacket( sender: remotePeerID, @@ -260,7 +267,8 @@ struct BLEFileTransferHandlerTests { @Test func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws { let recorder = Recorder() - recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)] + recorder.signatureVerifies = true let handler = makeHandler(recorder: recorder) let packet = try makeFileTransferPacket( sender: remotePeerID, @@ -282,6 +290,56 @@ struct BLEFileTransferHandlerTests { #expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900))) } + @Test + func decryptedPrivateFileUsesValidationQuotaAndPrivateDeliveryWithoutRawSignature() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + let handler = makeHandler(recorder: recorder) + let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128) + let file = BitchatFilePacket( + fileName: "secret.jpg", + fileSize: UInt64(content.count), + mimeType: "image/jpeg", + content: content + ) + let payload = try #require(file.encode()) + let timestamp = Date(timeIntervalSince1970: 1_234) + + #expect(handler.handlePrivatePayload(payload, from: remotePeerID, timestamp: timestamp)) + + #expect(recorder.signatureVerifyCount == 0) + #expect(recorder.signedNameQueries.isEmpty) + #expect(recorder.trackedPackets.isEmpty) + #expect(recorder.quotaReservations == [content.count]) + #expect(recorder.saveCalls.first?.data == content) + #expect(recorder.lastSeenUpdates == [remotePeerID]) + #expect(recorder.deliveredMessages.count == 1) + #expect(recorder.deliveredMessages.first?.isPrivate == true) + #expect(recorder.deliveredMessages.first?.timestamp == timestamp) + } + + @Test + func decryptedPrivateFileOverPayloadCapIsRejectedBeforeQuotaOrDiskWrite() { + let recorder = Recorder() + let handler = makeHandler(recorder: recorder) + let oversizedCount = FileTransferLimits.maxPayloadBytes + 1 + var length = UInt32(oversizedCount).bigEndian + var payload = Data([0x04]) // BitchatFilePacket CONTENT TLV + withUnsafeBytes(of: &length) { payload.append(contentsOf: $0) } + payload.append(Data(repeating: 0x41, count: oversizedCount)) + + #expect(!handler.handlePrivatePayload( + payload, + from: remotePeerID, + timestamp: Date(timeIntervalSince1970: 1_234) + )) + + #expect(recorder.quotaReservations.isEmpty) + #expect(recorder.saveCalls.isEmpty) + #expect(recorder.lastSeenUpdates.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) + } + @Test func malformedPayloadIsTrackedForSyncButDropped() { let recorder = Recorder() @@ -294,7 +352,7 @@ struct BLEFileTransferHandlerTests { recipientID: nil, timestamp: 900_000, payload: Data([0x01, 0x02, 0x03]), - signature: nil, + signature: Data(repeating: 0x5A, count: 64), ttl: TransportConfig.messageTTLDefault ) @@ -403,7 +461,8 @@ struct BLEFileTransferHandlerTests { content: Data, ttl: UInt8 = TransportConfig.messageTTLDefault, recipientID: Data? = nil, - fileName: String = "sample" + fileName: String = "sample", + hasSignature: Bool = true ) throws -> BitchatPacket { let filePacket = BitchatFilePacket( fileName: fileName, @@ -418,7 +477,7 @@ struct BLEFileTransferHandlerTests { recipientID: recipientID, timestamp: 900_000, payload: payload, - signature: nil, + signature: hasSignature ? Data(repeating: 0x5A, count: 64) : nil, ttl: ttl ) } diff --git a/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift b/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift index a4423ec9..ed4da0a1 100644 --- a/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift +++ b/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift @@ -117,6 +117,35 @@ struct BLEFragmentAssemblyBufferTests { } } + @Test + func encryptedPrivateFileAssemblyGetsFramedFileHeadroom() throws { + var buffer = BLEFragmentAssemblyBuffer() + let fragmentID = Data(repeating: 0x15, count: 8) + let first = try #require(BLEFragmentHeader(packet: makeFragmentPacket( + fragmentID: fragmentID, + index: 0, + total: 2, + originalType: MessageType.noiseEncrypted.rawValue, + fragmentData: Data(repeating: 0x01, count: FileTransferLimits.maxPayloadBytes) + ))) + let second = try #require(BLEFragmentHeader(packet: makeFragmentPacket( + fragmentID: fragmentID, + index: 1, + total: 2, + originalType: MessageType.noiseEncrypted.rawValue, + fragmentData: Data([0x02]) + ))) + + _ = buffer.append(first, maxInFlightAssemblies: 8) + let result = buffer.append(second, maxInFlightAssemblies: 8) + + if case let .complete(_, data, _) = result { + #expect(data.count == FileTransferLimits.maxPayloadBytes + 1) + } else { + Issue.record("Expected encrypted private-file assembly to use framed-file limit") + } + } + @Test func removeExpiredDropsOldAssemblies() throws { var buffer = BLEFragmentAssemblyBuffer() diff --git a/bitchatTests/Services/BLENoisePayloadFactoryTests.swift b/bitchatTests/Services/BLENoisePayloadFactoryTests.swift index ab743c9a..42150b6f 100644 --- a/bitchatTests/Services/BLENoisePayloadFactoryTests.swift +++ b/bitchatTests/Services/BLENoisePayloadFactoryTests.swift @@ -31,4 +31,23 @@ struct BLENoisePayloadFactoryTests { #expect(payload == Data([NoisePayloadType.verifyChallenge.rawValue, 0xCA, 0xFE])) } + + @Test + func privateFilePayloadPrefixesCanonicalFilePacket() throws { + let content = Data("%PDF-secret".utf8) + let file = BitchatFilePacket( + fileName: "secret.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ) + + let payload = try #require(BLENoisePayloadFactory.privateFile(file)) + + #expect(payload.first == NoisePayloadType.privateFile.rawValue) + let decoded = try #require(BitchatFilePacket.decode(Data(payload.dropFirst()))) + #expect(decoded.fileName == "secret.pdf") + #expect(decoded.mimeType == "application/pdf") + #expect(decoded.content == content) + } } diff --git a/bitchatTests/Services/BLENoiseSessionQueuesTests.swift b/bitchatTests/Services/BLENoiseSessionQueuesTests.swift index 33cb0fe7..ad516592 100644 --- a/bitchatTests/Services/BLENoiseSessionQueuesTests.swift +++ b/bitchatTests/Services/BLENoiseSessionQueuesTests.swift @@ -48,7 +48,10 @@ struct BLENoiseSessionQueuesTests { queues.appendTypedPayload(Data([0x01]), for: peerID) queues.appendTypedPayload(Data([0x02]), for: peerID) - #expect(queues.takeTypedPayloads(for: peerID) == [Data([0x01]), Data([0x02])]) + #expect(queues.takeTypedPayloads(for: peerID) == [ + BLEPendingTypedPayload(payload: Data([0x01]), transferId: nil), + BLEPendingTypedPayload(payload: Data([0x02]), transferId: nil) + ]) #expect(queues.takeTypedPayloads(for: peerID).isEmpty) #expect(queues.takePrivateMessages(for: peerID).map(\.messageID) == ["m1"]) } @@ -64,4 +67,21 @@ struct BLENoiseSessionQueuesTests { #expect(queues.isEmpty) } + + @Test + func transferIDSurvivesHandshakeQueueAndCanBeCancelledBeforeDrain() { + let peerID = PeerID(str: "aaaaaaaaaaaaaaaa") + var queues = BLENoiseSessionQueues() + + queues.appendTypedPayload(Data([0x09, 0xAA]), transferId: "media-1", for: peerID) + queues.appendTypedPayload(Data([0x01, 0xBB]), for: peerID) + + let removed = queues.removeTypedPayload(transferId: "media-1") + let removedAgain = queues.removeTypedPayload(transferId: "media-1") + #expect(removed) + #expect(!removedAgain) + #expect(queues.takeTypedPayloads(for: peerID) == [ + BLEPendingTypedPayload(payload: Data([0x01, 0xBB]), transferId: nil) + ]) + } } diff --git a/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift b/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift index 722f3e33..8ceb1ac9 100644 --- a/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift +++ b/bitchatTests/Services/BLEOutboundFragmentTransferSchedulerTests.swift @@ -20,6 +20,24 @@ struct BLEOutboundFragmentTransferSchedulerTests { } } + @Test + func explicitTransferIDReservesEncryptedPrivateFileFragments() { + var scheduler = BLEOutboundFragmentTransferScheduler() + let request = makeRequest( + type: MessageType.noiseEncrypted.rawValue, + transferId: "private-media" + ) + + let result = scheduler.submit(request, maxConcurrentTransfers: 1) + + if case let .start(_, reservedTransferId) = result { + #expect(reservedTransferId == "private-media") + #expect(scheduler.activeCount == 1) + } else { + Issue.record("Expected encrypted private media to reserve its progress slot") + } + } + @Test func submitQueuesFileTransferWhenSlotsAreFull() { var scheduler = BLEOutboundFragmentTransferScheduler() diff --git a/bitchatTests/Services/NoiseEncryptionServiceTests.swift b/bitchatTests/Services/NoiseEncryptionServiceTests.swift index d4b0b79a..25b8ebbe 100644 --- a/bitchatTests/Services/NoiseEncryptionServiceTests.swift +++ b/bitchatTests/Services/NoiseEncryptionServiceTests.swift @@ -126,6 +126,55 @@ struct NoiseEncryptionServiceTests { #expect(bob.getPeerFingerprint(bobPeerID) == nil) } + @Test("Large private-file payloads use the bounded Noise extension") + func largePrivateFileNoiseRoundTrip() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let alicePeerID = PeerID(str: "0011223344556677") + let bobPeerID = PeerID(str: "8899aabbccddeeff") + try establishSessions(alice: alice, bob: bob, alicePeerID: alicePeerID, bobPeerID: bobPeerID) + + let content = Data("%PDF-1.7\n".utf8) + Data(repeating: 0x51, count: 96 * 1024) + let file = BitchatFilePacket( + fileName: "large-private.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ) + let typedPayload = try #require(BLENoisePayloadFactory.privateFile(file)) + #expect(typedPayload.count > NoiseSecurityConstants.maxMessageSize) + #expect(typedPayload.first == NoisePayloadType.privateFile.rawValue) + #expect( + typedPayload.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize, + "typedBytes=\(typedPayload.count) limit=\(NoiseSecurityConstants.maxPrivateFilePlaintextSize)" + ) + + do { + _ = try alice.encrypt(typedPayload, for: alicePeerID) + Issue.record("Ordinary Noise payload path must retain its 64 KiB ceiling") + } catch NoiseSecurityError.messageTooLarge { + // Expected: only the purpose-specific private-file API may extend it. + } + + let ciphertext: Data + do { + ciphertext = try alice.encryptPrivateFilePayload(typedPayload, for: alicePeerID) + } catch { + Issue.record("Private-file encryption failed: \(error)") + return + } + let decrypted: Data + do { + decrypted = try bob.decrypt(ciphertext, from: bobPeerID) + } catch { + Issue.record("Private-file decryption failed: \(error); ciphertextBytes=\(ciphertext.count)") + return + } + + #expect(ciphertext.range(of: content) == nil) + #expect(decrypted == typedPayload) + } + @Test("Encrypt without a session requests handshake and decrypt without session fails") func handshakeRequiredAndSessionNotEstablishedErrors() throws { let service = NoiseEncryptionService(keychain: MockKeychain()) diff --git a/bitchatTests/Services/TransferProgressManagerTests.swift b/bitchatTests/Services/TransferProgressManagerTests.swift index 0bf1395f..de04e1cf 100644 --- a/bitchatTests/Services/TransferProgressManagerTests.swift +++ b/bitchatTests/Services/TransferProgressManagerTests.swift @@ -105,6 +105,30 @@ struct TransferProgressManagerTests { #expect(manager.snapshot(id: transferID) == nil) _ = cancellable } + + @Test("Preflight rejection publishes cancellation without a started state") + @MainActor + func rejectBeforeStartPublishesCancellation() async { + let manager = TransferProgressManager() + let transferID = "transfer-preflight-reject" + var cancellable: AnyCancellable? + 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)") + } + } + + manager.rejectBeforeStart(id: transferID) + + let didReceive = await TestHelpers.waitUntil({ + recorder.values == ["cancelled:\(transferID):0:0"] + }, timeout: 5.0) + #expect(didReceive) + #expect(manager.snapshot(id: transferID) == nil) + _ = cancellable + } } private final class EventRecorder: @unchecked Sendable { diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift index 167f9884..c2a5556c 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift @@ -24,6 +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. + public static let privateMedia = PeerCapabilities(rawValue: 1 << 8) /// Minimal little-endian byte encoding; always at least one byte so an /// empty set is distinguishable from an absent TLV. diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift index 1c83530f..49012128 100644 --- a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift @@ -16,11 +16,12 @@ struct PeerCapabilitiesTests { #expect(PeerCapabilities([]).encoded() == Data([0x00])) #expect(PeerCapabilities.prekeys.encoded() == Data([0x01])) #expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40])) + #expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01])) let high = PeerCapabilities(rawValue: 1 << 9) #expect(high.encoded() == Data([0x00, 0x02])) - let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics] + let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia] #expect(PeerCapabilities(encoded: all.encoded()) == all) #expect(PeerCapabilities(encoded: high.encoded()) == high) #expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == []) From 8c6b60c67591588858796da5816fbf202f7f5c13 Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 12 Jul 2026 10:09:33 -0400 Subject: [PATCH 2/2] 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