Files
bitchat/bitchatTests/Services/BLEOutboundFragmentPlannerTests.swift
T
jackandGitHub 2d96fd99a1 Encrypt private media before BLE fragmentation (#1434)
Closes the last cleartext private-content path over BLE: private DM images/voice were sent as plaintext signed fileTransfer packets, TTL-relayed across the mesh, so every relay saw the full bytes. Now the complete BitchatFilePacket is encrypted as a single Noise AEAD message (inner type 0x20, matching Android) and the opaque ciphertext is fragmented. Adds an authenticated in-session capability proof (0x21 TLV: capabilities + Ed25519 key), TOFU-style downgrade pinning, a per-send consent dialog for the signed-cleartext fallback to legacy peers, and a cancellation/admission registry so cancel/delete cannot race a deferred cleartext send.

Android wire constants (0x20 / 0x21 / capability bit 8) confirmed shipping. The 256-fragment preflight cap applies only to the directed fileTransfer migration fallback; encrypted media to capable peers uses the full receiver ceiling.

Rebased over #1428/#1349: identity reads go through BLELocalIdentityStateStore; the session-bound authenticated signing-key check and the announce-path TOFU pin are kept as complementary checks. Full local suite green (1744+197 tests).
2026-07-26 12:17:22 +02:00

189 lines
6.9 KiB
Swift

import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("BLE outbound fragment planner tests")
struct BLEOutboundFragmentPlannerTests {
@Test("planner splits packets and preserves reassembled payload")
func plannerSplitsAndReassemblesPacket() throws {
let packet = makePacket(payload: makePayload(count: 384))
let request = BLEOutboundFragmentTransferRequest(
packet: packet,
pad: false,
maxChunk: 128,
directedPeer: nil,
transferId: nil
)
let plan = try #require(BLEOutboundFragmentPlanner.makePlan(
for: request,
defaultChunkSize: 256,
bleMaxMTU: 512,
fragmentID: Data(repeating: 0xA1, count: 8)
))
let headers = try plan.fragmentPackets.map { try #require(BLEFragmentHeader(packet: $0)) }
let reassembled = headers.reduce(into: Data()) { data, header in
data.append(header.fragmentData)
}
let decoded = try #require(BinaryProtocol.decode(reassembled))
#expect(plan.fragmentVersion == 1)
#expect(plan.chunkSize == 128)
#expect(plan.spacingMs == TransportConfig.bleFragmentSpacingMs)
#expect(headers.map(\.index) == Array(0..<headers.count))
#expect(decoded.type == packet.type)
#expect(decoded.payload == packet.payload)
#expect(plan.fragmentPackets.allSatisfy { $0.recipientID == nil })
}
@Test("directed fragments target the directed peer and use directed pacing")
func directedFragmentsUseDirectedRecipientAndSpacing() throws {
let directedPeer = PeerID(str: "8877665544332211")
let packet = makePacket(payload: makePayload(count: 256))
let request = BLEOutboundFragmentTransferRequest(
packet: packet,
pad: false,
maxChunk: 96,
directedPeer: directedPeer,
transferId: nil
)
let plan = try #require(BLEOutboundFragmentPlanner.makePlan(
for: request,
defaultChunkSize: 256,
bleMaxMTU: 512,
fragmentID: Data(repeating: 0xB2, count: 8)
))
#expect(plan.spacingMs == TransportConfig.bleFragmentSpacingDirectedMs)
#expect(plan.fragmentPackets.allSatisfy { $0.recipientID == Data(hexString: directedPeer.id) })
}
@Test("route-aware fragments use version two and route-sized chunking")
func routeAwareFragmentsUseVersionTwoAndRouteSizedChunking() throws {
let route = [
Data([0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17]),
Data([0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27])
]
let packet = makePacket(payload: makePayload(count: 192), route: route, isRSR: true)
let request = BLEOutboundFragmentTransferRequest(
packet: packet,
pad: false,
maxChunk: nil,
directedPeer: nil,
transferId: nil
)
let plan = try #require(BLEOutboundFragmentPlanner.makePlan(
for: request,
defaultChunkSize: 256,
bleMaxMTU: 128,
fragmentID: Data(repeating: 0xC3, count: 8)
))
let firstHeader = try #require(BLEFragmentHeader(packet: plan.fragmentPackets[0]))
#expect(plan.fragmentVersion == 2)
#expect(plan.chunkSize == 64)
#expect(firstHeader.fragmentData.count <= 64)
#expect(plan.fragmentPackets.allSatisfy { $0.route == route && $0.isRSR })
}
@Test("invalid fragment IDs do not produce a plan")
func invalidFragmentIDReturnsNil() {
let packet = makePacket(payload: makePayload(count: 128))
let request = BLEOutboundFragmentTransferRequest(
packet: packet,
pad: false,
maxChunk: nil,
directedPeer: nil,
transferId: nil
)
#expect(BLEOutboundFragmentPlanner.makePlan(
for: request,
defaultChunkSize: 256,
bleMaxMTU: 512,
fragmentID: Data(repeating: 0x00, count: 7)
) == nil)
}
@Test("private media v1 accepts exactly 256 fragments and rejects 257")
func privateMediaCrossPlatformFragmentBoundary() throws {
let maxPayload = makePayload(count: 160 * 1024, seed: 0xFACE_CAFE)
func plan(payloadCount: Int) throws -> BLEOutboundFragmentPlan {
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: "0011223344556677") ?? Data(),
recipientID: Data(hexString: "8877665544332211"),
timestamp: 0x0102030405,
payload: Data(maxPayload.prefix(payloadCount)),
signature: nil,
ttl: 3,
version: 2
)
return try #require(BLEOutboundFragmentPlanner.makePlan(
for: BLEOutboundFragmentTransferRequest(
packet: packet,
pad: false,
maxChunk: nil,
directedPeer: PeerID(str: "8877665544332211"),
transferId: "boundary"
),
defaultChunkSize: TransportConfig.bleDefaultFragmentSize,
bleMaxMTU: 512,
fragmentID: Data(repeating: 0xD4, count: 8)
))
}
func firstPlan(withAtLeast target: Int) throws -> BLEOutboundFragmentPlan {
var low = 1
var high = maxPayload.count
while low < high {
let mid = low + (high - low) / 2
if try plan(payloadCount: mid).totalFragments >= target {
high = mid
} else {
low = mid + 1
}
}
return try plan(payloadCount: low)
}
let at256 = try firstPlan(withAtLeast: 256)
let at257 = try firstPlan(withAtLeast: 257)
#expect(at256.totalFragments == 256)
#expect(BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at256))
#expect(at257.totalFragments == 257)
#expect(!BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible(at257))
}
private func makePacket(
payload: Data,
route: [Data]? = nil,
isRSR: Bool = false
) -> BitchatPacket {
BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]),
recipientID: nil,
timestamp: 0x0102030405,
payload: payload,
signature: nil,
ttl: 3,
route: route,
isRSR: isRSR
)
}
private func makePayload(count: Int, seed: UInt64 = 0xABCD_1234) -> Data {
var state = seed
return Data((0..<count).map { _ in
state = state &* 6364136223846793005 &+ 1442695040888963407
return UInt8(truncatingIfNeeded: state >> 32)
})
}
}