mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +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>
423 lines
17 KiB
Swift
423 lines
17 KiB
Swift
//
|
|
// FragmentationTests.swift
|
|
// bitchatTests
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
import CoreBluetooth
|
|
import BitFoundation
|
|
@testable import bitchat
|
|
|
|
@Suite("Fragmentation Tests", .serialized)
|
|
struct FragmentationTests {
|
|
|
|
@Test("Reassembly from fragments delivers a public message")
|
|
func reassemblyFromFragmentsDeliversPublicMessage() async throws {
|
|
let ble = makeBLEService()
|
|
let capture = CaptureDelegate()
|
|
ble.delegate = capture
|
|
|
|
// Construct a big SIGNED public packet (3KB) from a remote sender. Public
|
|
// messages must carry a valid signature, so the reassembled packet is
|
|
// signed and the sender's signing key is preseeded into the registry.
|
|
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
|
let signingKey = signer.getSigningPublicKeyData()
|
|
let remoteShortID = PeerID(str: "1122334455667788")
|
|
let original = try #require(
|
|
signer.signPacket(makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)),
|
|
"Failed to sign public packet"
|
|
)
|
|
|
|
// Use a small fragment size to ensure multiple pieces
|
|
let fragments = fragmentPacket(original, fragmentSize: 400)
|
|
|
|
// Shuffle fragments to simulate out-of-order arrival
|
|
let shuffled = fragments.shuffled()
|
|
|
|
// Send fragments sequentially with small delays (no fire-and-forget Tasks)
|
|
for (i, fragment) in shuffled.enumerated() {
|
|
if i > 0 {
|
|
try await Task.sleep(for: .milliseconds(5))
|
|
}
|
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey)
|
|
}
|
|
|
|
// Wait for delegate callback with proper timeout
|
|
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(5))
|
|
|
|
#expect(capture.publicMessages.count == 1)
|
|
#expect(capture.publicMessages.first?.content.count == 3_000)
|
|
}
|
|
|
|
@Test("Duplicate fragment does not break reassembly")
|
|
func duplicateFragmentDoesNotBreakReassembly() async throws {
|
|
let ble = makeBLEService()
|
|
let capture = CaptureDelegate()
|
|
ble.delegate = capture
|
|
|
|
let signer = NoiseEncryptionService(keychain: MockKeychain())
|
|
let signingKey = signer.getSigningPublicKeyData()
|
|
let remoteShortID = PeerID(str: "A1B2C3D4E5F60708")
|
|
let original = try #require(
|
|
signer.signPacket(makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)),
|
|
"Failed to sign public packet"
|
|
)
|
|
var frags = fragmentPacket(original, fragmentSize: 300)
|
|
|
|
// Duplicate one fragment
|
|
if let dup = frags.first {
|
|
frags.insert(dup, at: 1)
|
|
}
|
|
|
|
// Send fragments sequentially with small delays (no fire-and-forget Tasks)
|
|
for (i, fragment) in frags.enumerated() {
|
|
if i > 0 {
|
|
try await Task.sleep(for: .milliseconds(5))
|
|
}
|
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID, signingPublicKey: signingKey)
|
|
}
|
|
|
|
// Wait for delegate callback with proper timeout
|
|
try await capture.waitForPublicMessages(count: 1, timeout: .seconds(5))
|
|
|
|
#expect(capture.publicMessages.count == 1)
|
|
#expect(capture.publicMessages.first?.content.count == 2048)
|
|
}
|
|
|
|
@Test("Max-sized file transfer survives reassembly")
|
|
func maxSizedFileTransferSurvivesReassembly() async throws {
|
|
let ble = makeBLEService()
|
|
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(
|
|
fileName: "limit.bin",
|
|
fileSize: UInt64(fileContent.count),
|
|
mimeType: "application/octet-stream",
|
|
content: fileContent
|
|
)
|
|
let encoded = try #require(filePacket.encode(), "File packet encoding failed")
|
|
|
|
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)
|
|
#expect(!fragments.isEmpty)
|
|
|
|
for (i, fragment) in fragments.enumerated() {
|
|
if i > 0 {
|
|
try await Task.sleep(for: .milliseconds(5))
|
|
}
|
|
ble._test_handlePacket(fragment, fromPeerID: remoteID, signingPublicKey: signingKey)
|
|
}
|
|
|
|
try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(5))
|
|
|
|
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
|
|
#expect(message.content.hasPrefix("[file]"))
|
|
|
|
if let fileName = message.content.split(separator: " ").last {
|
|
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
let filesRoot = base.appendingPathComponent("files", isDirectory: true)
|
|
let incoming = filesRoot.appendingPathComponent("files/incoming", isDirectory: true)
|
|
let url = incoming.appendingPathComponent(String(fileName))
|
|
try? FileManager.default.removeItem(at: url)
|
|
}
|
|
}
|
|
|
|
@Test("Invalid fragment header is ignored")
|
|
func invalidFragmentHeaderIsIgnored() async throws {
|
|
let ble = makeBLEService()
|
|
let capture = CaptureDelegate()
|
|
ble.delegate = capture
|
|
|
|
let remoteShortID = PeerID(str: "0011223344556677")
|
|
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 1000)
|
|
let fragments = fragmentPacket(original, fragmentSize: 250)
|
|
|
|
// Corrupt one fragment: make payload too short (header incomplete)
|
|
var corrupted = fragments
|
|
if !corrupted.isEmpty {
|
|
var p = corrupted[0]
|
|
p = BitchatPacket(
|
|
type: p.type,
|
|
senderID: p.senderID,
|
|
recipientID: p.recipientID,
|
|
timestamp: p.timestamp,
|
|
payload: Data([0x00, 0x01, 0x02]), // invalid header
|
|
signature: nil,
|
|
ttl: p.ttl
|
|
)
|
|
corrupted[0] = p
|
|
}
|
|
|
|
for (i, fragment) in corrupted.enumerated() {
|
|
if i > 0 {
|
|
try await Task.sleep(for: .milliseconds(5))
|
|
}
|
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
|
}
|
|
|
|
// Allow async processing
|
|
try await sleep(0.5)
|
|
|
|
// Should not deliver since one fragment is invalid and reassembly can't complete
|
|
#expect(capture.publicMessages.isEmpty)
|
|
}
|
|
}
|
|
|
|
extension FragmentationTests {
|
|
private func makeBLEService() -> BLEService {
|
|
let mockKeychain = MockKeychain()
|
|
let mockIdentityManager = MockIdentityManager(mockKeychain)
|
|
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
|
|
|
return BLEService(
|
|
keychain: mockKeychain,
|
|
idBridge: idBridge,
|
|
identityManager: mockIdentityManager,
|
|
initializeBluetoothManagers: false
|
|
)
|
|
}
|
|
|
|
/// Thread-safe delegate that supports awaiting message delivery
|
|
private final class CaptureDelegate: BitchatDelegate, @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var _publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
|
|
private var _receivedMessages: [BitchatMessage] = []
|
|
private var publicMessageContinuation: CheckedContinuation<Void, Never>?
|
|
private var receivedMessageContinuation: CheckedContinuation<Void, Never>?
|
|
private var expectedPublicMessageCount: Int = 0
|
|
private var expectedReceivedMessageCount: Int = 0
|
|
|
|
private func withLock<T>(_ body: () -> T) -> T {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return body()
|
|
}
|
|
|
|
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] {
|
|
withLock { _publicMessages }
|
|
}
|
|
|
|
var receivedMessages: [BitchatMessage] {
|
|
withLock { _receivedMessages }
|
|
}
|
|
|
|
func didReceiveMessage(_ message: BitchatMessage) {
|
|
lock.lock()
|
|
_receivedMessages.append(message)
|
|
let count = _receivedMessages.count
|
|
let expected = expectedReceivedMessageCount
|
|
let continuation = receivedMessageContinuation
|
|
lock.unlock()
|
|
|
|
if count >= expected, let cont = continuation {
|
|
lock.lock()
|
|
receivedMessageContinuation = nil
|
|
lock.unlock()
|
|
cont.resume()
|
|
}
|
|
}
|
|
|
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
|
lock.lock()
|
|
_publicMessages.append((peerID, nickname, content))
|
|
let count = _publicMessages.count
|
|
let expected = expectedPublicMessageCount
|
|
let continuation = publicMessageContinuation
|
|
lock.unlock()
|
|
|
|
if count >= expected, let cont = continuation {
|
|
lock.lock()
|
|
publicMessageContinuation = nil
|
|
lock.unlock()
|
|
cont.resume()
|
|
}
|
|
}
|
|
|
|
/// Waits for the specified number of public messages to be received
|
|
func waitForPublicMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
|
|
let isAlreadySatisfied = withLock { () -> Bool in
|
|
if _publicMessages.count >= count {
|
|
return true
|
|
}
|
|
expectedPublicMessageCount = count
|
|
return false
|
|
}
|
|
if isAlreadySatisfied {
|
|
return
|
|
}
|
|
|
|
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
|
|
// 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()
|
|
}
|
|
}
|
|
} onCancel: {
|
|
let continuation = self.withLock {
|
|
let parked = self.publicMessageContinuation
|
|
self.publicMessageContinuation = nil
|
|
return parked
|
|
}
|
|
continuation?.resume()
|
|
}
|
|
}
|
|
group.addTask {
|
|
try await Task.sleep(for: timeout)
|
|
throw CancellationError()
|
|
}
|
|
try await group.next()
|
|
group.cancelAll()
|
|
}
|
|
}
|
|
|
|
/// Waits for the specified number of received messages
|
|
func waitForReceivedMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
|
|
let isAlreadySatisfied = withLock { () -> Bool in
|
|
if _receivedMessages.count >= count {
|
|
return true
|
|
}
|
|
expectedReceivedMessageCount = count
|
|
return false
|
|
}
|
|
if isAlreadySatisfied {
|
|
return
|
|
}
|
|
|
|
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
|
|
// 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()
|
|
}
|
|
}
|
|
} onCancel: {
|
|
let continuation = self.withLock {
|
|
let parked = self.receivedMessageContinuation
|
|
self.receivedMessageContinuation = nil
|
|
return parked
|
|
}
|
|
continuation?.resume()
|
|
}
|
|
}
|
|
group.addTask {
|
|
try await Task.sleep(for: timeout)
|
|
throw CancellationError()
|
|
}
|
|
try await group.next()
|
|
group.cancelAll()
|
|
}
|
|
}
|
|
|
|
func didConnectToPeer(_ peerID: PeerID) {}
|
|
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
|
func didUpdatePeerList(_ peers: [PeerID]) {}
|
|
func isFavorite(fingerprint: String) -> Bool { false }
|
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
|
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
|
func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
|
|
}
|
|
|
|
// Helper: build a large message packet (unencrypted public message)
|
|
private func makeLargePublicPacket(senderShortHex: PeerID, size: Int) -> BitchatPacket {
|
|
let content = String(repeating: "A", count: size)
|
|
let payload = Data(content.utf8)
|
|
let pkt = BitchatPacket(
|
|
type: MessageType.message.rawValue,
|
|
senderID: Data(hexString: senderShortHex.id) ?? Data(),
|
|
recipientID: nil,
|
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
|
payload: payload,
|
|
signature: nil,
|
|
ttl: 7
|
|
)
|
|
return pkt
|
|
}
|
|
|
|
// Helper: fragment a packet using the same header format BLEService expects
|
|
private func fragmentPacket(_ packet: BitchatPacket, fragmentSize: Int, fragmentID: Data? = nil, pad: Bool = true) -> [BitchatPacket] {
|
|
guard let fullData = packet.toBinaryData(padding: pad) else { return [] }
|
|
let fid = fragmentID ?? Data((0..<8).map { _ in UInt8.random(in: 0...255) })
|
|
let chunks: [Data] = stride(from: 0, to: fullData.count, by: fragmentSize).map { off in
|
|
Data(fullData[off..<min(off + fragmentSize, fullData.count)])
|
|
}
|
|
let total = UInt16(chunks.count)
|
|
var packets: [BitchatPacket] = []
|
|
for (i, chunk) in chunks.enumerated() {
|
|
var payload = Data()
|
|
payload.append(fid)
|
|
var idxBE = UInt16(i).bigEndian
|
|
var totBE = total.bigEndian
|
|
withUnsafeBytes(of: &idxBE) { payload.append(contentsOf: $0) }
|
|
withUnsafeBytes(of: &totBE) { payload.append(contentsOf: $0) }
|
|
payload.append(packet.type)
|
|
payload.append(chunk)
|
|
let fpkt = BitchatPacket(
|
|
type: MessageType.fragment.rawValue,
|
|
senderID: packet.senderID,
|
|
recipientID: packet.recipientID,
|
|
timestamp: packet.timestamp,
|
|
payload: payload,
|
|
signature: nil,
|
|
ttl: packet.ttl
|
|
)
|
|
packets.append(fpkt)
|
|
}
|
|
return packets
|
|
}
|
|
}
|