diff --git a/bitchat/Services/BLE/BLEFileTransferHandler.swift b/bitchat/Services/BLE/BLEFileTransferHandler.swift index 177741f8..015a1967 100644 --- a/bitchat/Services/BLE/BLEFileTransferHandler.swift +++ b/bitchat/Services/BLE/BLEFileTransferHandler.swift @@ -14,6 +14,8 @@ struct BLEFileTransferHandlerEnvironment { let localNickname: () -> String /// Snapshot of known peers keyed by ID (registry read). let peersSnapshot: () -> [PeerID: BLEPeerInfo] + /// Verifies a packet's signature against a candidate signing key (registry path). + let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool /// 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. @@ -44,25 +46,32 @@ final class BLEFileTransferHandler { self.environment = environment } - func handle(_ packet: BitchatPacket, from peerID: PeerID) { + /// 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. + @discardableResult + func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { let env = environment - if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return } - - let peersSnapshot = env.peersSnapshot() - guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer( - peerID: peerID, - localPeerID: env.localPeerID(), - localNickname: env.localNickname(), - peers: peersSnapshot, - allowConnectedUnverified: true - ) ?? env.signedSenderDisplayName(packet, peerID) else { - SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security) - return - } + if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true } guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else { - return + return true } + + let peersSnapshot = env.peersSnapshot() + guard let senderNickname = resolveSenderNickname( + 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) + return false + } + if deliveryPlan.shouldTrackForSync { env.trackPacketSeen(packet) } @@ -75,16 +84,16 @@ final class BLEFileTransferHandler { mime = acceptance.mime case .failure(.malformedPayload): SecureLogger.error("❌ Failed to decode file transfer payload", category: .session) - return + return true case .failure(.payloadTooLarge(let bytes)): SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security) - return + return true 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 + return true 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 + return true } // BCH-01-002: Enforce storage quota before saving @@ -97,7 +106,7 @@ final class BLEFileTransferHandler { mime.defaultExtension, mime.category.rawValue ) else { - return + return true } if deliveryPlan.isPrivateMessage { @@ -125,5 +134,54 @@ final class BLEFileTransferHandler { SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session) env.deliverMessage(message) + 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( + 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) + } + + // 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 } + + return BLEPeerSenderDisplayName.resolveKnownPeer( + peerID: peerID, + localPeerID: env.localPeerID(), + localNickname: env.localNickname(), + peers: peers, + allowConnectedUnverified: false + ) ?? signedDisplayName } } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index b4b5aaea..88cb0be2 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -1232,7 +1232,7 @@ final class BLEService: NSObject { return nil } - private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) { + private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { fileTransferHandler.handle(packet, from: peerID) } @@ -1251,6 +1251,9 @@ final class BLEService: NSObject { guard let self = self else { return [:] } return self.collectionsQueue.sync { self.peerRegistry.snapshotByID } }, + verifyPacketSignature: { [weak self] packet, signingPublicKey in + self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false + }, signedSenderDisplayName: { [weak self] packet, peerID in self?.signedSenderDisplayName(for: packet, from: peerID) }, @@ -4031,7 +4034,10 @@ extension BLEService { handleFragment(packet, from: senderID) case .fileTransfer: - handleFileTransfer(packet, from: senderID) + // Broadcast files that fail sender authentication must not spread + // to downstream (possibly older, ungated) nodes; skip the relay + // step below, like invalid board posts and voice frames. + guard handleFileTransfer(packet, from: senderID) else { return } case .courierEnvelope: handleCourierEnvelope(packet, from: peerID) diff --git a/bitchatTests/Fragmentation/FragmentationTests.swift b/bitchatTests/Fragmentation/FragmentationTests.swift index 53b4f0a7..1052b012 100644 --- a/bitchatTests/Fragmentation/FragmentationTests.swift +++ b/bitchatTests/Fragmentation/FragmentationTests.swift @@ -94,6 +94,11 @@ struct FragmentationTests { let capture = CaptureDelegate() ble.delegate = capture + // Broadcast file transfers must carry a valid sender signature (same + // gate as public messages), so sign the packet and preseed the + // sender's signing key into the registry. + let signer = NoiseEncryptionService(keychain: MockKeychain()) + let signingKey = signer.getSigningPublicKeyData() let remoteID = PeerID(str: "CAFEBABECAFEBABE") let fileContent = Data(repeating: 0x42, count: FileTransferLimits.maxPayloadBytes) let filePacket = BitchatFilePacket( @@ -104,15 +109,18 @@ struct FragmentationTests { ) let encoded = try #require(filePacket.encode(), "File packet encoding failed") - let packet = BitchatPacket( - type: MessageType.fileTransfer.rawValue, - senderID: Data(hexString: remoteID.id) ?? Data(), - recipientID: nil, - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: encoded, - signature: nil, - ttl: 7, - version: 2 + let packet = try #require( + signer.signPacket(BitchatPacket( + type: MessageType.fileTransfer.rawValue, + senderID: Data(hexString: remoteID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: encoded, + signature: nil, + ttl: 7, + version: 2 + )), + "Failed to sign file transfer packet" ) let fragments = fragmentPacket(packet, fragmentSize: 4096, pad: false) @@ -122,7 +130,7 @@ struct FragmentationTests { if i > 0 { try await Task.sleep(for: .milliseconds(5)) } - ble._test_handlePacket(fragment, fromPeerID: remoteID) + ble._test_handlePacket(fragment, fromPeerID: remoteID, signingPublicKey: signingKey) } try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(5)) @@ -265,19 +273,32 @@ extension FragmentationTests { try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { - await withCheckedContinuation { continuation in - let shouldResumeImmediately = self.withLock { - // Recheck count after acquiring lock to avoid race condition - // where message arrives between initial check and continuation install - if self._publicMessages.count >= count { - return true + // withCheckedContinuation itself is not cancellable, so hook + // group.cancelAll() to resume the parked continuation — + // otherwise a timeout leaves the group awaiting this child + // forever and the test run hangs instead of failing. + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let shouldResumeImmediately = self.withLock { + // Recheck count after acquiring lock to avoid race condition + // where message arrives between initial check and continuation install + if self._publicMessages.count >= count { + return true + } + self.publicMessageContinuation = continuation + return false + } + if shouldResumeImmediately { + continuation.resume() } - self.publicMessageContinuation = continuation - return false } - if shouldResumeImmediately { - continuation.resume() + } onCancel: { + let continuation = self.withLock { + let parked = self.publicMessageContinuation + self.publicMessageContinuation = nil + return parked } + continuation?.resume() } } group.addTask { @@ -304,19 +325,32 @@ extension FragmentationTests { try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { - await withCheckedContinuation { continuation in - let shouldResumeImmediately = self.withLock { - // Recheck count after acquiring lock to avoid race condition - // where message arrives between initial check and continuation install - if self._receivedMessages.count >= count { - return true + // withCheckedContinuation itself is not cancellable, so hook + // group.cancelAll() to resume the parked continuation — + // otherwise a timeout leaves the group awaiting this child + // forever and the test run hangs instead of failing. + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + let shouldResumeImmediately = self.withLock { + // Recheck count after acquiring lock to avoid race condition + // where message arrives between initial check and continuation install + if self._receivedMessages.count >= count { + return true + } + self.receivedMessageContinuation = continuation + return false + } + if shouldResumeImmediately { + continuation.resume() } - self.receivedMessageContinuation = continuation - return false } - if shouldResumeImmediately { - continuation.resume() + } onCancel: { + let continuation = self.withLock { + let parked = self.receivedMessageContinuation + self.receivedMessageContinuation = nil + return parked } + continuation?.resume() } } group.addTask { diff --git a/bitchatTests/Services/BLEFileTransferHandlerTests.swift b/bitchatTests/Services/BLEFileTransferHandlerTests.swift index 2e3d2450..d277674d 100644 --- a/bitchatTests/Services/BLEFileTransferHandlerTests.swift +++ b/bitchatTests/Services/BLEFileTransferHandlerTests.swift @@ -8,8 +8,10 @@ struct BLEFileTransferHandlerTests { var localNickname = "Me" var peers: [PeerID: BLEPeerInfo] = [:] var signedName: String? + var signatureVerifies = false var saveResult: URL? = URL(fileURLWithPath: "/tmp/files/incoming/sample.pdf") + var signatureVerifyCount = 0 var signedNameQueries: [PeerID] = [] var trackedPackets: [BitchatPacket] = [] var quotaReservations: [Int] = [] @@ -20,12 +22,17 @@ struct BLEFileTransferHandlerTests { private let localPeerID = PeerID(str: "0102030405060708") private let remotePeerID = PeerID(str: "1122334455667788") + private let sampleSigningKey = Data(repeating: 0xAB, count: 32) private func makeHandler(recorder: Recorder) -> BLEFileTransferHandler { let environment = BLEFileTransferHandlerEnvironment( localPeerID: { [localPeerID] in localPeerID }, localNickname: { recorder.localNickname }, peersSnapshot: { recorder.peers }, + verifyPacketSignature: { _, _ in + recorder.signatureVerifyCount += 1 + return recorder.signatureVerifies + }, signedSenderDisplayName: { _, peerID in recorder.signedNameQueries.append(peerID) return recorder.signedName @@ -53,13 +60,15 @@ struct BLEFileTransferHandlerTests { @Test func broadcastFileFromVerifiedPeerIsSavedAndDelivered() 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 content = Data("%PDF-1.7".utf8) let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: content) - handler.handle(packet, from: remotePeerID) + #expect(handler.handle(packet, from: remotePeerID)) + #expect(recorder.signatureVerifyCount == 1) #expect(recorder.signedNameQueries.isEmpty) #expect(recorder.trackedPackets.count == 1) #expect(recorder.quotaReservations == [content.count]) @@ -86,7 +95,9 @@ struct BLEFileTransferHandlerTests { let handler = makeHandler(recorder: recorder) let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3) - handler.handle(packet, from: localPeerID) + // 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)) expectNoSideEffects(recorder) } @@ -97,7 +108,7 @@ struct BLEFileTransferHandlerTests { let handler = makeHandler(recorder: recorder) let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8)) - handler.handle(packet, from: remotePeerID) + #expect(!handler.handle(packet, from: remotePeerID)) #expect(recorder.signedNameQueries == [remotePeerID]) #expect(recorder.trackedPackets.isEmpty) @@ -105,20 +116,126 @@ struct BLEFileTransferHandlerTests { } @Test - func connectedUnverifiedPeerIsAccepted() throws { + func broadcastFromConnectedUnverifiedPeerWithoutSignatureIsDropped() throws { 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)) - handler.handle(packet, from: remotePeerID) + // Failed sender authentication must also stop the packet from being + // relayed to downstream nodes. + #expect(!handler.handle(packet, from: remotePeerID)) - // Unlike public messages, file transfers accept connected-but-unverified peers. - #expect(recorder.signedNameQueries.isEmpty) + // 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.trackedPackets.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) + } + + @Test + func broadcastFromConnectedUnverifiedPeerWithSignedIdentityIsAccepted() throws { + let recorder = Recorder() + // Connected but nickname not yet verified and no registry signing key — + // the persisted-identity signature lookup still authenticates the + // sender, so the transfer is accepted under that verified name. + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)] + recorder.signedName = "Bob" + let handler = makeHandler(recorder: recorder) + let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8)) + + #expect(handler.handle(packet, from: remotePeerID)) + + #expect(recorder.signedNameQueries == [remotePeerID]) #expect(recorder.deliveredMessages.count == 1) #expect(recorder.deliveredMessages.first?.sender == "Bob") } + @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. + let recorder = Recorder() + let handler = makeHandler(recorder: recorder) + let packet = try makeFileTransferPacket( + sender: localPeerID, + mimeType: "application/pdf", + content: Data("%PDF-1.7".utf8), + ttl: 0 + ) + + #expect(handler.handle(packet, from: localPeerID)) + + #expect(recorder.signatureVerifyCount == 0) + #expect(recorder.signedNameQueries.isEmpty) + #expect(recorder.deliveredMessages.count == 1) + #expect(recorder.deliveredMessages.first?.sender == "Me") + } + + @Test + func broadcastFromPeerNotInRegistryAcceptedViaSignedIdentity() throws { + let recorder = Recorder() + recorder.signedName = "Carol" + let handler = makeHandler(recorder: recorder) + let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8)) + + #expect(handler.handle(packet, from: remotePeerID)) + + // Peer absent from the registry: fall back to the persisted-identity + // signature lookup (mirrors BLEPublicMessageHandler). + #expect(recorder.signedNameQueries == [remotePeerID]) + #expect(recorder.deliveredMessages.count == 1) + #expect(recorder.deliveredMessages.first?.sender == "Carol") + } + + @Test + func spoofedBroadcastVoiceNoteWithoutSignatureIsDropped() throws { + // Regression for the PR #1406 finding: an in-range peer that observed a + // public voice burst tries to overwrite the live bubble by broadcasting + // a `voice_.m4a` note under the talker's senderID. Without a + // valid signature the note never reaches the coordinator's absorption. + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Mallory", isVerified: false, isConnected: true)] + let handler = makeHandler(recorder: recorder) + let m4a = Data([0x00, 0x00, 0x00, 0x18]) + Data("ftypM4A ".utf8) + let packet = try makeFileTransferPacket( + sender: remotePeerID, + mimeType: "audio/mp4", + content: m4a, + fileName: "voice_1122334455667788" + ) + + // The spoofed note must be dropped locally AND not relayed onward. + #expect(!handler.handle(packet, from: remotePeerID)) + + #expect(recorder.deliveredMessages.isEmpty) + } + + @Test + func privateFileFromConnectedUnverifiedPeerIsAccepted() throws { + 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), + recipientID: Data(hexString: localPeerID.id) + ) + + #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) + } + @Test func fileDirectedToAnotherPeerIsIgnored() throws { let recorder = Recorder() @@ -131,7 +248,8 @@ struct BLEFileTransferHandlerTests { recipientID: Data(hexString: "AABBCCDDEEFF0011") ) - handler.handle(packet, from: remotePeerID) + // Not for us, but it must keep relaying toward the real recipient. + #expect(handler.handle(packet, from: remotePeerID)) #expect(recorder.trackedPackets.isEmpty) #expect(recorder.quotaReservations.isEmpty) @@ -151,7 +269,7 @@ struct BLEFileTransferHandlerTests { recipientID: Data(hexString: localPeerID.id) ) - handler.handle(packet, from: remotePeerID) + #expect(handler.handle(packet, from: remotePeerID)) // Directed transfers are not tracked for gossip sync. #expect(recorder.trackedPackets.isEmpty) @@ -167,7 +285,8 @@ struct BLEFileTransferHandlerTests { @Test func malformedPayloadIsTrackedForSyncButDropped() { 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 = BitchatPacket( type: MessageType.fileTransfer.rawValue, @@ -179,7 +298,8 @@ struct BLEFileTransferHandlerTests { ttl: TransportConfig.messageTTLDefault ) - handler.handle(packet, from: remotePeerID) + // Local decode failures are not proof of forgery; the packet stays relayable. + #expect(handler.handle(packet, from: remotePeerID)) // Sync tracking happens before payload validation, matching the original order. #expect(recorder.trackedPackets.count == 1) @@ -191,11 +311,12 @@ struct BLEFileTransferHandlerTests { @Test func unsupportedMimeIsDroppedBeforeQuotaAndSave() 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, mimeType: nil, content: Data([0x4D, 0x5A, 0x00, 0x00])) - handler.handle(packet, from: remotePeerID) + #expect(handler.handle(packet, from: remotePeerID)) #expect(recorder.trackedPackets.count == 1) #expect(recorder.quotaReservations.isEmpty) @@ -206,12 +327,14 @@ struct BLEFileTransferHandlerTests { @Test func saveFailureSkipsDelivery() 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 recorder.saveResult = nil let handler = makeHandler(recorder: recorder) let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8)) - handler.handle(packet, from: remotePeerID) + // A local save failure must not stop the mesh relay. + #expect(handler.handle(packet, from: remotePeerID)) #expect(recorder.quotaReservations.count == 1) #expect(recorder.saveCalls.count == 1) @@ -232,14 +355,15 @@ struct BLEFileTransferHandlerTests { _ peerID: PeerID, nickname: String, isVerified: Bool, - isConnected: Bool = true + isConnected: Bool = true, + signingPublicKey: Data? = nil ) -> BLEPeerInfo { BLEPeerInfo( peerID: peerID, nickname: nickname, isConnected: isConnected, noisePublicKey: nil, - signingPublicKey: nil, + signingPublicKey: signingPublicKey, isVerifiedNickname: isVerified, lastSeen: Date(timeIntervalSince1970: 999) ) @@ -250,10 +374,11 @@ struct BLEFileTransferHandlerTests { mimeType: String?, content: Data, ttl: UInt8 = TransportConfig.messageTTLDefault, - recipientID: Data? = nil + recipientID: Data? = nil, + fileName: String = "sample" ) throws -> BitchatPacket { let filePacket = BitchatFilePacket( - fileName: "sample", + fileName: fileName, fileSize: UInt64(content.count), mimeType: mimeType, content: content