mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 12:25:19 +00:00
Encrypt private media before fragmentation
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import CoreBluetooth
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
/// Wire-level coverage for finalized DM media. The sender encrypts one typed
|
||||
/// private-file payload, relays see only the outer Noise packet/fragments, and
|
||||
/// the receiver reassembles, decrypts, validates, persists, and delivers it.
|
||||
@Suite("Private media end to end", .serialized)
|
||||
struct PrivateMediaEndToEndTests {
|
||||
@Test
|
||||
func peerWithoutPrivateMediaCapabilityIsRejectedBeforeWireSend() async throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("private-media-capability-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let alice = makeService(baseDirectory: root.appendingPathComponent("alice", isDirectory: true))
|
||||
let bob = makeService(baseDirectory: root.appendingPathComponent("bob", isDirectory: true))
|
||||
alice._test_seedConnectedPeer(bob.myPeerID, nickname: "Old Bob", capabilities: [])
|
||||
bob._test_seedConnectedPeer(alice.myPeerID, nickname: "Alice", capabilities: .privateMedia)
|
||||
try establishSession(alice: alice, bob: bob)
|
||||
|
||||
let tap = PacketTap()
|
||||
alice._test_onOutboundPacket = tap.record
|
||||
let transferID = "unsupported-private-media-\(UUID().uuidString)"
|
||||
let cancellation = TransferCancellationRecorder(transferID: transferID)
|
||||
let cancellable = TransferProgressManager.shared.publisher.sink { cancellation.record($0) }
|
||||
let content = Data("%PDF-1.7\nprivate".utf8)
|
||||
alice.sendFilePrivate(
|
||||
BitchatFilePacket(
|
||||
fileName: "private.pdf",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "application/pdf",
|
||||
content: content
|
||||
),
|
||||
to: bob.myPeerID,
|
||||
transferId: transferID
|
||||
)
|
||||
|
||||
let rejected = await TestHelpers.waitUntil(
|
||||
{ cancellation.wasCancelled },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(rejected)
|
||||
let mediaWireTypes: Set<UInt8> = [
|
||||
MessageType.noiseEncrypted.rawValue,
|
||||
MessageType.fileTransfer.rawValue,
|
||||
MessageType.fragment.rawValue
|
||||
]
|
||||
#expect(tap.snapshot().allSatisfy { !mediaWireTypes.contains($0.type) })
|
||||
_ = cancellable
|
||||
}
|
||||
|
||||
@Test
|
||||
func privateJPEGIsOpaqueBeforeFragmentationAndDelivers() async throws {
|
||||
let marker = Data("JPEG_PRIVATE_MARKER_7f5e5eacb86f4b9a".utf8)
|
||||
let content = Data([0xFF, 0xD8, 0xFF, 0xE0])
|
||||
+ marker
|
||||
+ Data(repeating: 0x4A, count: 6 * 1024)
|
||||
try await assertPrivateMediaRoundTrip(
|
||||
fileName: "private.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
content: content,
|
||||
marker: marker,
|
||||
expectedMessagePrefix: "[image]"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func finalizedPrivateM4AIsOpaqueBeforeFragmentationAndDelivers() async throws {
|
||||
let marker = Data("M4A_PRIVATE_MARKER_e0cd431b61fb4a6c".utf8)
|
||||
let content = Data([0x00, 0x00, 0x00, 0x18])
|
||||
+ Data("ftypM4A ".utf8)
|
||||
+ marker
|
||||
+ Data(repeating: 0x4D, count: 6 * 1024)
|
||||
try await assertPrivateMediaRoundTrip(
|
||||
fileName: "voice_0011223344556677.m4a",
|
||||
mimeType: "audio/mp4",
|
||||
content: content,
|
||||
marker: marker,
|
||||
expectedMessagePrefix: "[voice]"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func privatePDFIsOpaqueBeforeFragmentationAndDelivers() async throws {
|
||||
let marker = Data("PDF_PRIVATE_MARKER_b333f84b8fc7478d".utf8)
|
||||
let content = Data("%PDF-1.7\n".utf8)
|
||||
+ marker
|
||||
+ Data(repeating: 0x50, count: 6 * 1024)
|
||||
try await assertPrivateMediaRoundTrip(
|
||||
fileName: "private.pdf",
|
||||
mimeType: "application/pdf",
|
||||
content: content,
|
||||
marker: marker,
|
||||
expectedMessagePrefix: "[file]"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func privateMediaAboveOrdinaryNoiseLimitUsesV2OuterPacketAndDelivers() async throws {
|
||||
let marker = Data("LARGE_PRIVATE_MARKER_1ec63f261a7041ee".utf8)
|
||||
let content = Data("%PDF-1.7\n".utf8)
|
||||
+ marker
|
||||
+ Data(repeating: 0x4C, count: 70 * 1024)
|
||||
try await assertPrivateMediaRoundTrip(
|
||||
fileName: "large-private.pdf",
|
||||
mimeType: "application/pdf",
|
||||
content: content,
|
||||
marker: marker,
|
||||
expectedMessagePrefix: "[file]",
|
||||
expectedOuterVersion: 2
|
||||
)
|
||||
}
|
||||
|
||||
private func assertPrivateMediaRoundTrip(
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
content: Data,
|
||||
marker: Data,
|
||||
expectedMessagePrefix: String,
|
||||
expectedOuterVersion: UInt8 = 2
|
||||
) async throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("private-media-e2e-\(UUID().uuidString)", isDirectory: true)
|
||||
let aliceRoot = root.appendingPathComponent("alice", isDirectory: true)
|
||||
let bobRoot = root.appendingPathComponent("bob", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let alice = makeService(baseDirectory: aliceRoot)
|
||||
let bob = makeService(baseDirectory: bobRoot)
|
||||
let tap = PacketTap()
|
||||
let delegate = MessageCaptureDelegate()
|
||||
alice._test_onOutboundPacket = tap.record
|
||||
bob.delegate = delegate
|
||||
|
||||
alice._test_seedConnectedPeer(bob.myPeerID, nickname: "Bob", capabilities: .privateMedia)
|
||||
bob._test_seedConnectedPeer(alice.myPeerID, nickname: "Alice", capabilities: .privateMedia)
|
||||
try establishSession(alice: alice, bob: bob)
|
||||
|
||||
let file = BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: mimeType,
|
||||
content: content
|
||||
)
|
||||
alice.sendFilePrivate(file, to: bob.myPeerID, transferId: "wire-\(UUID().uuidString)")
|
||||
|
||||
let fragmented = await TestHelpers.waitUntil(
|
||||
{ tap.hasCompleteFragmentTrain },
|
||||
timeout: 10
|
||||
)
|
||||
#expect(fragmented)
|
||||
|
||||
let outbound = tap.snapshot()
|
||||
let encryptedPackets = outbound.filter { $0.type == MessageType.noiseEncrypted.rawValue }
|
||||
let fragments = outbound
|
||||
.filter { $0.type == MessageType.fragment.rawValue }
|
||||
.sorted { fragmentIndex($0) < fragmentIndex($1) }
|
||||
|
||||
#expect(encryptedPackets.count == 1)
|
||||
#expect(encryptedPackets.first?.version == expectedOuterVersion)
|
||||
#expect(!fragments.isEmpty)
|
||||
#expect(outbound.allSatisfy { $0.type != MessageType.fileTransfer.rawValue })
|
||||
for packet in encryptedPackets + fragments {
|
||||
#expect(packet.payload.range(of: marker) == nil)
|
||||
#expect(packet.payload.range(of: content) == nil)
|
||||
}
|
||||
|
||||
// Real BLE delivers the train at the scheduler's paced interval. Feed
|
||||
// bounded batches here instead of enqueuing hundreds of synthetic
|
||||
// callbacks at once, which can exhaust libdispatch worker threads as
|
||||
// they wait on the fragment-assembly barrier.
|
||||
for batchStart in stride(from: 0, to: fragments.count, by: 16) {
|
||||
let batchEnd = min(batchStart + 16, fragments.count)
|
||||
for fragment in fragments[batchStart..<batchEnd] {
|
||||
bob._test_handlePacket(fragment, fromPeerID: alice.myPeerID)
|
||||
}
|
||||
await bob._test_drainFragmentPipeline()
|
||||
}
|
||||
|
||||
let delivered = await TestHelpers.waitUntil(
|
||||
{ delegate.snapshot().count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(delivered)
|
||||
|
||||
let message = try #require(delegate.snapshot().first)
|
||||
#expect(message.isPrivate)
|
||||
#expect(message.senderPeerID == alice.myPeerID)
|
||||
#expect(message.content.hasPrefix(expectedMessagePrefix))
|
||||
|
||||
let stored = recursivelyStoredFiles(under: bobRoot)
|
||||
#expect(stored.count == 1)
|
||||
let storedURL = try #require(stored.first)
|
||||
#expect(try Data(contentsOf: storedURL) == content)
|
||||
}
|
||||
|
||||
private func makeService(baseDirectory: URL) -> BLEService {
|
||||
let keychain = MockKeychain()
|
||||
return BLEService(
|
||||
keychain: keychain,
|
||||
idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()),
|
||||
identityManager: MockIdentityManager(keychain),
|
||||
initializeBluetoothManagers: false,
|
||||
incomingFileStore: BLEIncomingFileStore(baseDirectory: baseDirectory)
|
||||
)
|
||||
}
|
||||
|
||||
private func establishSession(alice: BLEService, bob: BLEService) throws {
|
||||
let first = try alice._test_noiseInitiateHandshake(with: bob.myPeerID)
|
||||
let second = try #require(
|
||||
try bob._test_noiseProcessHandshakeMessage(from: alice.myPeerID, message: first)
|
||||
)
|
||||
let third = try #require(
|
||||
try alice._test_noiseProcessHandshakeMessage(from: bob.myPeerID, message: second)
|
||||
)
|
||||
_ = try bob._test_noiseProcessHandshakeMessage(from: alice.myPeerID, message: third)
|
||||
#expect(alice.canDeliverSecurely(to: bob.myPeerID))
|
||||
#expect(bob.canDeliverSecurely(to: alice.myPeerID))
|
||||
}
|
||||
|
||||
private func recursivelyStoredFiles(under root: URL) -> [URL] {
|
||||
guard let enumerator = FileManager.default.enumerator(
|
||||
at: root,
|
||||
includingPropertiesForKeys: [.isRegularFileKey]
|
||||
) else { return [] }
|
||||
|
||||
return enumerator.compactMap { item in
|
||||
guard let url = item as? URL,
|
||||
(try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else {
|
||||
return nil
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fragmentIndex(_ packet: BitchatPacket) -> Int {
|
||||
guard packet.payload.count >= 10 else { return .max }
|
||||
return (Int(packet.payload[8]) << 8) | Int(packet.payload[9])
|
||||
}
|
||||
|
||||
private final class PacketTap: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var packets: [BitchatPacket] = []
|
||||
|
||||
func record(_ packet: BitchatPacket) {
|
||||
lock.lock()
|
||||
packets.append(packet)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func snapshot() -> [BitchatPacket] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return packets
|
||||
}
|
||||
|
||||
var hasCompleteFragmentTrain: Bool {
|
||||
let fragments = snapshot().filter { $0.type == MessageType.fragment.rawValue }
|
||||
guard let first = fragments.first, first.payload.count >= 12 else { return false }
|
||||
let total = (Int(first.payload[10]) << 8) | Int(first.payload[11])
|
||||
return total > 0 && fragments.count >= total
|
||||
}
|
||||
}
|
||||
|
||||
private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var messages: [BitchatMessage] = []
|
||||
|
||||
func didReceiveMessage(_ message: BitchatMessage) {
|
||||
lock.lock()
|
||||
messages.append(message)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func snapshot() -> [BitchatMessage] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return messages
|
||||
}
|
||||
|
||||
func didConnectToPeer(_ peerID: PeerID) {}
|
||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||
}
|
||||
|
||||
private final class TransferCancellationRecorder: @unchecked Sendable {
|
||||
private let transferID: String
|
||||
private let lock = NSLock()
|
||||
private var cancelled = false
|
||||
|
||||
init(transferID: String) {
|
||||
self.transferID = transferID
|
||||
}
|
||||
|
||||
func record(_ event: TransferProgressManager.Event) {
|
||||
guard case .cancelled(let id, _, _) = event, id == transferID else { return }
|
||||
lock.lock()
|
||||
cancelled = true
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
var wasCancelled: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return cancelled
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ struct BLEFileTransferHandlerTests {
|
||||
recorder.signatureVerifyCount += 1
|
||||
return recorder.signatureVerifies
|
||||
},
|
||||
localSigningPublicKey: { [sampleSigningKey] in sampleSigningKey },
|
||||
signedSenderDisplayName: { _, peerID in
|
||||
recorder.signedNameQueries.append(peerID)
|
||||
return recorder.signedName
|
||||
@@ -92,12 +93,11 @@ struct BLEFileTransferHandlerTests {
|
||||
@Test
|
||||
func selfEchoIsDropped() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.signatureVerifies = true
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3)
|
||||
|
||||
// The relay pipeline already suppresses self-originated packets, so the
|
||||
// handler reports "relayable" rather than treating the echo as forged.
|
||||
#expect(handler.handle(packet, from: localPeerID))
|
||||
#expect(!handler.handle(packet, from: localPeerID))
|
||||
|
||||
expectNoSideEffects(recorder)
|
||||
}
|
||||
@@ -120,7 +120,12 @@ struct BLEFileTransferHandlerTests {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8))
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: remotePeerID,
|
||||
mimeType: "application/pdf",
|
||||
content: Data("%PDF-1.7".utf8),
|
||||
hasSignature: false
|
||||
)
|
||||
|
||||
// Failed sender authentication must also stop the packet from being
|
||||
// relayed to downstream nodes.
|
||||
@@ -129,7 +134,7 @@ struct BLEFileTransferHandlerTests {
|
||||
// Broadcast files carry an attacker-controllable senderID, so — like
|
||||
// public messages — a connected-but-unverified peer must present a valid
|
||||
// packet signature. No signing key + no signed identity means dropped.
|
||||
#expect(recorder.signedNameQueries == [remotePeerID])
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
}
|
||||
@@ -153,12 +158,11 @@ struct BLEFileTransferHandlerTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func selfBroadcastReplayIsDeliveredWithoutSignatureCheck() throws {
|
||||
// Our own broadcast file replayed via gossip sync arrives with ttl==0
|
||||
// (so it is not treated as a self-echo) and cannot be verified against
|
||||
// the peer registry — it must still be accepted, matching
|
||||
// BLEPublicMessageHandler's self exemption.
|
||||
func signedSelfBroadcastReplayIsDelivered() throws {
|
||||
// Our own broadcast file replayed via gossip sync arrives with ttl==0;
|
||||
// it is verified against our local signing key before delivery.
|
||||
let recorder = Recorder()
|
||||
recorder.signatureVerifies = true
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: localPeerID,
|
||||
@@ -169,7 +173,7 @@ struct BLEFileTransferHandlerTests {
|
||||
|
||||
#expect(handler.handle(packet, from: localPeerID))
|
||||
|
||||
#expect(recorder.signatureVerifyCount == 0)
|
||||
#expect(recorder.signatureVerifyCount == 1)
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveredMessages.first?.sender == "Me")
|
||||
@@ -205,7 +209,8 @@ struct BLEFileTransferHandlerTests {
|
||||
sender: remotePeerID,
|
||||
mimeType: "audio/mp4",
|
||||
content: m4a,
|
||||
fileName: "voice_1122334455667788"
|
||||
fileName: "voice_1122334455667788",
|
||||
hasSignature: false
|
||||
)
|
||||
|
||||
// The spoofed note must be dropped locally AND not relayed onward.
|
||||
@@ -215,7 +220,7 @@ struct BLEFileTransferHandlerTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func privateFileFromConnectedUnverifiedPeerIsAccepted() throws {
|
||||
func rawDirectedFileWithoutVerifiableSignatureIsDroppedWithoutWriteOrRelay() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
@@ -223,23 +228,25 @@ struct BLEFileTransferHandlerTests {
|
||||
sender: remotePeerID,
|
||||
mimeType: "application/pdf",
|
||||
content: Data("%PDF-1.7".utf8),
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
hasSignature: false
|
||||
)
|
||||
|
||||
#expect(handler.handle(packet, from: remotePeerID))
|
||||
#expect(!handler.handle(packet, from: remotePeerID))
|
||||
|
||||
// Directed transfers keep the lenient connected-peer path (no broadcast
|
||||
// exposure); no signature check is required.
|
||||
#expect(recorder.signatureVerifyCount == 0)
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.quotaReservations.isEmpty)
|
||||
#expect(recorder.saveCalls.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func fileDirectedToAnotherPeerIsIgnored() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
||||
recorder.signatureVerifies = true
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: remotePeerID,
|
||||
@@ -260,7 +267,8 @@ struct BLEFileTransferHandlerTests {
|
||||
@Test
|
||||
func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true, signingPublicKey: sampleSigningKey)]
|
||||
recorder.signatureVerifies = true
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: remotePeerID,
|
||||
@@ -282,6 +290,56 @@ struct BLEFileTransferHandlerTests {
|
||||
#expect(recorder.deliveredMessages.first?.deliveryStatus == .delivered(to: "Me", at: Date(timeIntervalSince1970: 900)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func decryptedPrivateFileUsesValidationQuotaAndPrivateDeliveryWithoutRawSignature() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "secret.jpg",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
)
|
||||
let payload = try #require(file.encode())
|
||||
let timestamp = Date(timeIntervalSince1970: 1_234)
|
||||
|
||||
#expect(handler.handlePrivatePayload(payload, from: remotePeerID, timestamp: timestamp))
|
||||
|
||||
#expect(recorder.signatureVerifyCount == 0)
|
||||
#expect(recorder.signedNameQueries.isEmpty)
|
||||
#expect(recorder.trackedPackets.isEmpty)
|
||||
#expect(recorder.quotaReservations == [content.count])
|
||||
#expect(recorder.saveCalls.first?.data == content)
|
||||
#expect(recorder.lastSeenUpdates == [remotePeerID])
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
||||
#expect(recorder.deliveredMessages.first?.timestamp == timestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
func decryptedPrivateFileOverPayloadCapIsRejectedBeforeQuotaOrDiskWrite() {
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let oversizedCount = FileTransferLimits.maxPayloadBytes + 1
|
||||
var length = UInt32(oversizedCount).bigEndian
|
||||
var payload = Data([0x04]) // BitchatFilePacket CONTENT TLV
|
||||
withUnsafeBytes(of: &length) { payload.append(contentsOf: $0) }
|
||||
payload.append(Data(repeating: 0x41, count: oversizedCount))
|
||||
|
||||
#expect(!handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||
))
|
||||
|
||||
#expect(recorder.quotaReservations.isEmpty)
|
||||
#expect(recorder.saveCalls.isEmpty)
|
||||
#expect(recorder.lastSeenUpdates.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func malformedPayloadIsTrackedForSyncButDropped() {
|
||||
let recorder = Recorder()
|
||||
@@ -294,7 +352,7 @@ struct BLEFileTransferHandlerTests {
|
||||
recipientID: nil,
|
||||
timestamp: 900_000,
|
||||
payload: Data([0x01, 0x02, 0x03]),
|
||||
signature: nil,
|
||||
signature: Data(repeating: 0x5A, count: 64),
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
|
||||
@@ -403,7 +461,8 @@ struct BLEFileTransferHandlerTests {
|
||||
content: Data,
|
||||
ttl: UInt8 = TransportConfig.messageTTLDefault,
|
||||
recipientID: Data? = nil,
|
||||
fileName: String = "sample"
|
||||
fileName: String = "sample",
|
||||
hasSignature: Bool = true
|
||||
) throws -> BitchatPacket {
|
||||
let filePacket = BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
@@ -418,7 +477,7 @@ struct BLEFileTransferHandlerTests {
|
||||
recipientID: recipientID,
|
||||
timestamp: 900_000,
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
signature: hasSignature ? Data(repeating: 0x5A, count: 64) : nil,
|
||||
ttl: ttl
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,6 +117,35 @@ struct BLEFragmentAssemblyBufferTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func encryptedPrivateFileAssemblyGetsFramedFileHeadroom() throws {
|
||||
var buffer = BLEFragmentAssemblyBuffer()
|
||||
let fragmentID = Data(repeating: 0x15, count: 8)
|
||||
let first = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
|
||||
fragmentID: fragmentID,
|
||||
index: 0,
|
||||
total: 2,
|
||||
originalType: MessageType.noiseEncrypted.rawValue,
|
||||
fragmentData: Data(repeating: 0x01, count: FileTransferLimits.maxPayloadBytes)
|
||||
)))
|
||||
let second = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
|
||||
fragmentID: fragmentID,
|
||||
index: 1,
|
||||
total: 2,
|
||||
originalType: MessageType.noiseEncrypted.rawValue,
|
||||
fragmentData: Data([0x02])
|
||||
)))
|
||||
|
||||
_ = buffer.append(first, maxInFlightAssemblies: 8)
|
||||
let result = buffer.append(second, maxInFlightAssemblies: 8)
|
||||
|
||||
if case let .complete(_, data, _) = result {
|
||||
#expect(data.count == FileTransferLimits.maxPayloadBytes + 1)
|
||||
} else {
|
||||
Issue.record("Expected encrypted private-file assembly to use framed-file limit")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func removeExpiredDropsOldAssemblies() throws {
|
||||
var buffer = BLEFragmentAssemblyBuffer()
|
||||
|
||||
@@ -31,4 +31,23 @@ struct BLENoisePayloadFactoryTests {
|
||||
|
||||
#expect(payload == Data([NoisePayloadType.verifyChallenge.rawValue, 0xCA, 0xFE]))
|
||||
}
|
||||
|
||||
@Test
|
||||
func privateFilePayloadPrefixesCanonicalFilePacket() throws {
|
||||
let content = Data("%PDF-secret".utf8)
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "secret.pdf",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "application/pdf",
|
||||
content: content
|
||||
)
|
||||
|
||||
let payload = try #require(BLENoisePayloadFactory.privateFile(file))
|
||||
|
||||
#expect(payload.first == NoisePayloadType.privateFile.rawValue)
|
||||
let decoded = try #require(BitchatFilePacket.decode(Data(payload.dropFirst())))
|
||||
#expect(decoded.fileName == "secret.pdf")
|
||||
#expect(decoded.mimeType == "application/pdf")
|
||||
#expect(decoded.content == content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,10 @@ struct BLENoiseSessionQueuesTests {
|
||||
queues.appendTypedPayload(Data([0x01]), for: peerID)
|
||||
queues.appendTypedPayload(Data([0x02]), for: peerID)
|
||||
|
||||
#expect(queues.takeTypedPayloads(for: peerID) == [Data([0x01]), Data([0x02])])
|
||||
#expect(queues.takeTypedPayloads(for: peerID) == [
|
||||
BLEPendingTypedPayload(payload: Data([0x01]), transferId: nil),
|
||||
BLEPendingTypedPayload(payload: Data([0x02]), transferId: nil)
|
||||
])
|
||||
#expect(queues.takeTypedPayloads(for: peerID).isEmpty)
|
||||
#expect(queues.takePrivateMessages(for: peerID).map(\.messageID) == ["m1"])
|
||||
}
|
||||
@@ -64,4 +67,21 @@ struct BLENoiseSessionQueuesTests {
|
||||
|
||||
#expect(queues.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func transferIDSurvivesHandshakeQueueAndCanBeCancelledBeforeDrain() {
|
||||
let peerID = PeerID(str: "aaaaaaaaaaaaaaaa")
|
||||
var queues = BLENoiseSessionQueues()
|
||||
|
||||
queues.appendTypedPayload(Data([0x09, 0xAA]), transferId: "media-1", for: peerID)
|
||||
queues.appendTypedPayload(Data([0x01, 0xBB]), for: peerID)
|
||||
|
||||
let removed = queues.removeTypedPayload(transferId: "media-1")
|
||||
let removedAgain = queues.removeTypedPayload(transferId: "media-1")
|
||||
#expect(removed)
|
||||
#expect(!removedAgain)
|
||||
#expect(queues.takeTypedPayloads(for: peerID) == [
|
||||
BLEPendingTypedPayload(payload: Data([0x01, 0xBB]), transferId: nil)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,24 @@ struct BLEOutboundFragmentTransferSchedulerTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func explicitTransferIDReservesEncryptedPrivateFileFragments() {
|
||||
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||
let request = makeRequest(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
transferId: "private-media"
|
||||
)
|
||||
|
||||
let result = scheduler.submit(request, maxConcurrentTransfers: 1)
|
||||
|
||||
if case let .start(_, reservedTransferId) = result {
|
||||
#expect(reservedTransferId == "private-media")
|
||||
#expect(scheduler.activeCount == 1)
|
||||
} else {
|
||||
Issue.record("Expected encrypted private media to reserve its progress slot")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func submitQueuesFileTransferWhenSlotsAreFull() {
|
||||
var scheduler = BLEOutboundFragmentTransferScheduler()
|
||||
|
||||
@@ -126,6 +126,55 @@ struct NoiseEncryptionServiceTests {
|
||||
#expect(bob.getPeerFingerprint(bobPeerID) == nil)
|
||||
}
|
||||
|
||||
@Test("Large private-file payloads use the bounded Noise extension")
|
||||
func largePrivateFileNoiseRoundTrip() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(str: "0011223344556677")
|
||||
let bobPeerID = PeerID(str: "8899aabbccddeeff")
|
||||
try establishSessions(alice: alice, bob: bob, alicePeerID: alicePeerID, bobPeerID: bobPeerID)
|
||||
|
||||
let content = Data("%PDF-1.7\n".utf8) + Data(repeating: 0x51, count: 96 * 1024)
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "large-private.pdf",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "application/pdf",
|
||||
content: content
|
||||
)
|
||||
let typedPayload = try #require(BLENoisePayloadFactory.privateFile(file))
|
||||
#expect(typedPayload.count > NoiseSecurityConstants.maxMessageSize)
|
||||
#expect(typedPayload.first == NoisePayloadType.privateFile.rawValue)
|
||||
#expect(
|
||||
typedPayload.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize,
|
||||
"typedBytes=\(typedPayload.count) limit=\(NoiseSecurityConstants.maxPrivateFilePlaintextSize)"
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try alice.encrypt(typedPayload, for: alicePeerID)
|
||||
Issue.record("Ordinary Noise payload path must retain its 64 KiB ceiling")
|
||||
} catch NoiseSecurityError.messageTooLarge {
|
||||
// Expected: only the purpose-specific private-file API may extend it.
|
||||
}
|
||||
|
||||
let ciphertext: Data
|
||||
do {
|
||||
ciphertext = try alice.encryptPrivateFilePayload(typedPayload, for: alicePeerID)
|
||||
} catch {
|
||||
Issue.record("Private-file encryption failed: \(error)")
|
||||
return
|
||||
}
|
||||
let decrypted: Data
|
||||
do {
|
||||
decrypted = try bob.decrypt(ciphertext, from: bobPeerID)
|
||||
} catch {
|
||||
Issue.record("Private-file decryption failed: \(error); ciphertextBytes=\(ciphertext.count)")
|
||||
return
|
||||
}
|
||||
|
||||
#expect(ciphertext.range(of: content) == nil)
|
||||
#expect(decrypted == typedPayload)
|
||||
}
|
||||
|
||||
@Test("Encrypt without a session requests handshake and decrypt without session fails")
|
||||
func handshakeRequiredAndSessionNotEstablishedErrors() throws {
|
||||
let service = NoiseEncryptionService(keychain: MockKeychain())
|
||||
|
||||
@@ -105,6 +105,30 @@ struct TransferProgressManagerTests {
|
||||
#expect(manager.snapshot(id: transferID) == nil)
|
||||
_ = cancellable
|
||||
}
|
||||
|
||||
@Test("Preflight rejection publishes cancellation without a started state")
|
||||
@MainActor
|
||||
func rejectBeforeStartPublishesCancellation() async {
|
||||
let manager = TransferProgressManager()
|
||||
let transferID = "transfer-preflight-reject"
|
||||
var cancellable: AnyCancellable?
|
||||
let recorder = EventRecorder()
|
||||
|
||||
cancellable = manager.publisher.sink { event in
|
||||
if case .cancelled(let id, let sent, let total) = event {
|
||||
recorder.append("cancelled:\(id):\(sent):\(total)")
|
||||
}
|
||||
}
|
||||
|
||||
manager.rejectBeforeStart(id: transferID)
|
||||
|
||||
let didReceive = await TestHelpers.waitUntil({
|
||||
recorder.values == ["cancelled:\(transferID):0:0"]
|
||||
}, timeout: 5.0)
|
||||
#expect(didReceive)
|
||||
#expect(manager.snapshot(id: transferID) == nil)
|
||||
_ = cancellable
|
||||
}
|
||||
}
|
||||
|
||||
private final class EventRecorder: @unchecked Sendable {
|
||||
|
||||
Reference in New Issue
Block a user