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()) == [])