Files
bitchat/bitchatTests/WifiBulk/WifiBulkCryptoTests.swift
jackandClaude Fable 5 e4b3cff5fa Wi-Fi bulk transport: AWDL data plane for large media with BLE fallback
BLE stays the control plane: a sender with a queued file > 64 KiB to a
directly connected peer advertising the wifiBulk capability sends a
bulkTransferOffer (0x04) inside the established Noise session — transferID,
file size, payload SHA-256, a fresh 32-byte token, and a random per-transfer
Bonjour instance name. The receiver answers bulkTransferResponse (0x05) with
its own token half, then both sides meet on a per-transfer
_bitchat-bulk._tcp channel over peer-to-peer Wi-Fi (AWDL).

The TCP stream is secured independently of TLS: both tokens traveled inside
Noise, so only the two peers can derive the ChaChaPoly channel key
(HKDF-SHA256, domain "bitchat-bulk-v1", transferID as salt). Frames are
length-prefixed sealed boxes with structured direction+counter nonces; the
first frame must prove knowledge of the key or the client is disconnected,
and the final hash is verified against the offer before delivery.

Decline, timeout, or any mid-transfer error falls back to BLE fragmentation
exactly once, driving the same TransferProgressManager stream so the UI is
unchanged. Wi-Fi-negotiated transfers may carry up to 8 MiB (new
FileTransferLimits.maxWifiBulkPayloadBytes, enforced by the receiver from
the accepted offer); the BLE path keeps its existing caps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:35:40 +02:00

265 lines
11 KiB
Swift

//
// WifiBulkCryptoTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CryptoKit
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("WifiBulk channel crypto")
struct WifiBulkCryptoTests {
private static func keyHex(_ key: SymmetricKey) -> String {
key.withUnsafeBytes { Data($0).map { String(format: "%02x", $0) }.joined() }
}
private func makeKey() throws -> SymmetricKey {
try #require(WifiBulkCrypto.deriveKey(
senderToken: Data(repeating: 0x11, count: 32),
receiverToken: Data(repeating: 0x22, count: 32),
transferID: Data(repeating: 0x33, count: 16)
))
}
// MARK: HKDF derivation
@Test("HKDF derivation matches fixed vectors")
func hkdfVectors() throws {
// Vectors computed independently with CryptoKit HKDF<SHA256>,
// ikm = senderToken ‖ receiverToken, salt = transferID,
// info = "bitchat-bulk-v1", 32 bytes out.
let key1 = try makeKey()
#expect(Self.keyHex(key1) == "9ee6f4bf7753a8a9564d6760b7064e31657f1a6bcca2b3ff266bb975cc4f66eb")
let key2 = try #require(WifiBulkCrypto.deriveKey(
senderToken: Data((0..<32).map { UInt8($0) }),
receiverToken: Data((0..<32).map { UInt8(255 - $0) }),
transferID: Data((0..<16).map { UInt8($0 * 3) })
))
#expect(Self.keyHex(key2) == "432ebb559f2f546d632a91d53b5c25af36f15d1ba53917910a0041329dc0efd4")
}
@Test("HKDF derivation is order- and role-sensitive")
func hkdfRoleSensitivity() throws {
let a = Data(repeating: 0x11, count: 32)
let b = Data(repeating: 0x22, count: 32)
let tid = Data(repeating: 0x33, count: 16)
let forward = try #require(WifiBulkCrypto.deriveKey(senderToken: a, receiverToken: b, transferID: tid))
let reversed = try #require(WifiBulkCrypto.deriveKey(senderToken: b, receiverToken: a, transferID: tid))
#expect(Self.keyHex(forward) != Self.keyHex(reversed))
}
@Test("HKDF derivation rejects wrong-length inputs")
func hkdfRejectsBadLengths() {
let token = Data(repeating: 1, count: 32)
let tid = Data(repeating: 2, count: 16)
#expect(WifiBulkCrypto.deriveKey(senderToken: Data(count: 31), receiverToken: token, transferID: tid) == nil)
#expect(WifiBulkCrypto.deriveKey(senderToken: token, receiverToken: Data(count: 33), transferID: tid) == nil)
#expect(WifiBulkCrypto.deriveKey(senderToken: token, receiverToken: token, transferID: Data(count: 15)) == nil)
}
// MARK: Frame sealing
@Test("Frame seal/open round-trips")
func frameRoundTrip() throws {
let key = try makeKey()
let plaintext = Data((0..<1000).map { UInt8($0 % 251) })
let body = try WifiBulkCrypto.sealFrameBody(plaintext, direction: .senderToReceiver, counter: 7, key: key)
let opened = try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 7, key: key)
#expect(opened == plaintext)
}
@Test("Tampered frames are rejected")
func tamperRejection() throws {
let key = try makeKey()
var body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 0, key: key)
body[body.count - 1] ^= 0x01 // flip a tag bit
#expect(throws: WifiBulkCryptoError.authenticationFailed) {
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 0, key: key)
}
}
@Test("Frames cannot be replayed at another counter or reflected across directions")
func nonceBinding() throws {
let key = try makeKey()
let body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 3, key: key)
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 4, key: key)
}
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
try WifiBulkCrypto.openFrameBody(body, direction: .receiverToSender, counter: 3, key: key)
}
}
@Test("Frames sealed under a different key are rejected")
func wrongKeyRejection() throws {
let key = try makeKey()
let otherKey = try #require(WifiBulkCrypto.deriveKey(
senderToken: Data(repeating: 0x44, count: 32),
receiverToken: Data(repeating: 0x22, count: 32),
transferID: Data(repeating: 0x33, count: 16)
))
let body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 0, key: otherKey)
#expect(throws: WifiBulkCryptoError.authenticationFailed) {
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 0, key: key)
}
}
@Test("Auth and receipt control frames validate and reject forgeries")
func controlFrames() throws {
let key = try makeKey()
let transferID = Data(repeating: 0x33, count: 16)
let hash = Data(repeating: 0x55, count: 32)
let auth = try WifiBulkCrypto.makeClientAuthFrameBody(transferID: transferID, key: key)
#expect(WifiBulkCrypto.validateClientAuthFrameBody(auth, transferID: transferID, key: key))
#expect(!WifiBulkCrypto.validateClientAuthFrameBody(auth, transferID: Data(repeating: 0x34, count: 16), key: key))
var forgedAuth = auth
forgedAuth[forgedAuth.count - 1] ^= 0x01
#expect(!WifiBulkCrypto.validateClientAuthFrameBody(forgedAuth, transferID: transferID, key: key))
let receipt = try WifiBulkCrypto.makeReceiptFrameBody(payloadHash: hash, key: key)
#expect(WifiBulkCrypto.validateReceiptFrameBody(receipt, payloadHash: hash, key: key))
#expect(!WifiBulkCrypto.validateReceiptFrameBody(receipt, payloadHash: Data(repeating: 0x56, count: 32), key: key))
// An auth frame is not a receipt (distinct counter).
#expect(!WifiBulkCrypto.validateReceiptFrameBody(auth, payloadHash: hash, key: key))
}
// MARK: Frame buffer
@Test("Frame buffer reassembles frames from arbitrary byte boundaries")
func frameBufferReassembly() throws {
let bodyA = Data(repeating: 0xAA, count: 100)
let bodyB = Data(repeating: 0xBB, count: 5)
var stream = WifiBulkCrypto.frameData(body: bodyA)
stream.append(WifiBulkCrypto.frameData(body: bodyB))
let buffer = WifiBulkFrameBuffer(maxBodyBytes: 1024)
// Drip-feed 3 bytes at a time.
var extracted: [Data] = []
var index = stream.startIndex
while index < stream.endIndex {
let next = stream.index(index, offsetBy: 3, limitedBy: stream.endIndex) ?? stream.endIndex
buffer.append(Data(stream[index..<next]))
while let body = try buffer.nextFrameBody() {
extracted.append(body)
}
index = next
}
#expect(extracted == [bodyA, bodyB])
#expect(try buffer.nextFrameBody() == nil)
}
@Test("Frame buffer rejects oversized frame lengths without buffering them")
func frameBufferOversizeRejection() {
let buffer = WifiBulkFrameBuffer(maxBodyBytes: 64)
buffer.append(WifiBulkCrypto.frameData(body: Data(repeating: 1, count: 65)).prefix(8))
#expect(throws: WifiBulkCryptoError.frameTooLarge) {
_ = try buffer.nextFrameBody()
}
}
// MARK: Payload assembler
private func sealedChunks(_ payload: Data, chunkSize: Int, key: SymmetricKey) throws -> [Data] {
try stride(from: 0, to: payload.count, by: chunkSize).enumerated().map { index, offset in
try WifiBulkCrypto.sealFrameBody(
Data(payload[offset..<min(offset + chunkSize, payload.count)]),
direction: .senderToReceiver,
counter: UInt64(index),
key: key
)
}
}
@Test("Assembler reassembles and verifies a chunked payload")
func assemblerHappyPath() throws {
let key = try makeKey()
let payload = Data((0..<200_000).map { UInt8($0 % 253) })
let assembler = try #require(WifiBulkPayloadAssembler(
key: key,
expectedSize: UInt64(payload.count),
expectedHash: Data(SHA256.hash(data: payload)),
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
))
var result: Data?
for chunk in try sealedChunks(payload, chunkSize: 64 * 1024, key: key) {
result = try assembler.consume(frameBody: chunk)
}
#expect(result == payload)
}
@Test("Assembler rejects a payload whose final hash mismatches the offer")
func assemblerHashMismatch() throws {
let key = try makeKey()
let payload = Data(repeating: 0x77, count: 100_000)
let assembler = try #require(WifiBulkPayloadAssembler(
key: key,
expectedSize: UInt64(payload.count),
expectedHash: Data(repeating: 0, count: 32), // wrong hash
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
))
let chunks = try sealedChunks(payload, chunkSize: 64 * 1024, key: key)
_ = try assembler.consume(frameBody: chunks[0])
#expect(throws: WifiBulkCryptoError.hashMismatch) {
_ = try assembler.consume(frameBody: chunks[1])
}
}
@Test("Assembler rejects overflow beyond the offered size")
func assemblerOverflow() throws {
let key = try makeKey()
let payload = Data(repeating: 0x77, count: 1000)
let assembler = try #require(WifiBulkPayloadAssembler(
key: key,
expectedSize: 500, // offer promised less than the sender streams
expectedHash: Data(SHA256.hash(data: payload)),
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
))
let chunk = try WifiBulkCrypto.sealFrameBody(payload, direction: .senderToReceiver, counter: 0, key: key)
#expect(throws: WifiBulkCryptoError.payloadOverflow) {
_ = try assembler.consume(frameBody: chunk)
}
}
@Test("Assembler enforces the receiver-side size cap at construction")
func assemblerSizeCap() throws {
let key = try makeKey()
#expect(WifiBulkPayloadAssembler(
key: key,
expectedSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1,
expectedHash: Data(repeating: 0, count: 32),
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
) == nil)
#expect(WifiBulkPayloadAssembler(
key: key,
expectedSize: 0,
expectedHash: Data(repeating: 0, count: 32),
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
) == nil)
}
@Test("Assembler rejects out-of-order chunks")
func assemblerOutOfOrder() throws {
let key = try makeKey()
let payload = Data(repeating: 0x42, count: 100_000)
let assembler = try #require(WifiBulkPayloadAssembler(
key: key,
expectedSize: UInt64(payload.count),
expectedHash: Data(SHA256.hash(data: payload)),
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
))
let chunks = try sealedChunks(payload, chunkSize: 64 * 1024, key: key)
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
_ = try assembler.consume(frameBody: chunks[1]) // skip chunk 0
}
}
}