Require signed sender for broadcast file transfers (#1406 follow-up) (#1407)

* Require signed sender for broadcast file transfers (#1406 follow-up)

Broadcast file transfers trusted the packet's claimed senderID whenever
the peer was merely connected (resolveKnownPeer allowConnectedUnverified:
true), unlike public messages and public voice frames, which both require
a valid packet signature from the claimed sender.

Codex flagged the consequence on PR #1406: a peer that observed a public
voice burst could broadcast a spoofed voice_<burstID>.m4a note under the
talker's senderID, and ChatLiveVoiceCoordinator.absorbFinalizedVoiceNote
would replace the signature-verified live bubble with attacker audio
(senderPeerID + scope were the only bindings, both attacker-forgeable on
this path).

Bring broadcast file transfers up to the same bar as public messages:
verify the packet signature against the registry signing key, falling
back to the persisted-identity signature lookup, before trusting the
sender. Directed (private) transfers keep the lenient connected-peer path
— they are addressed to us specifically and carry no broadcast exposure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Exempt self broadcasts from the file-transfer signature gate

Review of #1407 caught a regression: our own broadcast files replayed via
gossip sync arrive with ttl==0 (so isSelfEcho does not drop them) and
cannot be verified against the peer registry or identity cache, so the new
broadcast signature guard would drop them. Mirror BLEPublicMessageHandler's
self exemption — self packets are trivially authentic — and add a
regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Stop relaying broadcast file packets that fail sender authentication

Codex review on #1407: the new signature gate dropped spoofed broadcast
files locally, but BLEService's .fileTransfer case still fell through to
scheduleRelayIfNeeded, so a forged file kept propagating to downstream
(possibly older, ungated) nodes. Have the handler report failed sender
authentication and skip the relay step, like invalid board posts and
voice frames. Local-only drops (malformed payload, quota, save failure)
and files directed to other peers still relay unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix CI hang: sign the max-size reassembly file transfer, unhang timeouts

Both CI runs on #1407 died at the 5-minute watchdog (SIGKILL, exit 137)
with the test process fully idle. Root cause was a pair of issues in
FragmentationTests:

- "Max-sized file transfer survives reassembly" injected an UNSIGNED
  broadcast file from an unknown peer, which the new broadcast
  signature gate now drops by design. Sign the packet and preseed the
  sender's signing key, mirroring the public-message reassembly tests.

- CaptureDelegate's wait helpers could never time out: the timeout task
  threw, but withThrowingTaskGroup then awaited the sibling child that
  was parked in a non-cancellable withCheckedContinuation, deadlocking
  the whole run (hang instead of a 5s failure). Resume the parked
  continuation from a cancellation handler so timeouts now fail fast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-08 11:07:40 +02:00
committed by GitHub
co-authored by jack Claude Opus 4.8
parent 35fb9fdd42
commit d5cf64f99e
4 changed files with 295 additions and 72 deletions
@@ -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 ?? "<empty>")' 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_<burstID>.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
}
}
+8 -2
View File
@@ -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)
@@ -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,7 +109,8 @@ struct FragmentationTests {
)
let encoded = try #require(filePacket.encode(), "File packet encoding failed")
let packet = BitchatPacket(
let packet = try #require(
signer.signPacket(BitchatPacket(
type: MessageType.fileTransfer.rawValue,
senderID: Data(hexString: remoteID.id) ?? Data(),
recipientID: nil,
@@ -113,6 +119,8 @@ struct FragmentationTests {
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,6 +273,11 @@ extension FragmentationTests {
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
// 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
@@ -279,6 +292,14 @@ extension FragmentationTests {
continuation.resume()
}
}
} onCancel: {
let continuation = self.withLock {
let parked = self.publicMessageContinuation
self.publicMessageContinuation = nil
return parked
}
continuation?.resume()
}
}
group.addTask {
try await Task.sleep(for: timeout)
@@ -304,6 +325,11 @@ extension FragmentationTests {
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
// 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
@@ -318,6 +344,14 @@ extension FragmentationTests {
continuation.resume()
}
}
} onCancel: {
let continuation = self.withLock {
let parked = self.receivedMessageContinuation
self.receivedMessageContinuation = nil
return parked
}
continuation?.resume()
}
}
group.addTask {
try await Task.sleep(for: timeout)
@@ -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_<burstID>.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