mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
* 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:
co-authored by
jack
Claude Opus 4.8
parent
35fb9fdd42
commit
d5cf64f99e
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user