Files
bitchat/bitchatTests/WifiBulk/WifiBulkMessagesTests.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

108 lines
4.2 KiB
Swift

//
// WifiBulkMessagesTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("WifiBulk offer/response TLV")
struct WifiBulkMessagesTests {
private func makeOffer(
fileSize: UInt64 = 300_000,
serviceName: String = "a1b2c3d4e5f60718a1b2c3d4e5f60718"
) -> WifiBulkOffer {
WifiBulkOffer(
transferID: Data(repeating: 0xAB, count: WifiBulkWire.transferIDLength),
fileSize: fileSize,
payloadHash: Data(repeating: 0xCD, count: WifiBulkWire.hashLength),
token: Data(repeating: 0xEF, count: WifiBulkWire.tokenLength),
serviceName: serviceName
)
}
@Test("Offer round-trips through TLV encoding")
func offerRoundTrip() throws {
let offer = makeOffer()
let encoded = try #require(offer.encode())
let decoded = try #require(WifiBulkOffer.decode(encoded))
#expect(decoded == offer)
}
@Test("Offer encode rejects malformed field lengths")
func offerEncodeRejectsBadFields() {
#expect(WifiBulkOffer(
transferID: Data(repeating: 1, count: 15), // short transferID
fileSize: 1,
payloadHash: Data(repeating: 2, count: 32),
token: Data(repeating: 3, count: 32),
serviceName: "x"
).encode() == nil)
#expect(makeOffer(serviceName: "").encode() == nil)
#expect(makeOffer(serviceName: String(repeating: "a", count: 64)).encode() == nil)
}
@Test("Offer decode rejects missing or wrong-length fields")
func offerDecodeRejectsMalformed() throws {
let encoded = try #require(makeOffer().encode())
// Truncation anywhere breaks a TLV boundary or drops a required field.
#expect(WifiBulkOffer.decode(encoded.dropLast(1)) == nil)
#expect(WifiBulkOffer.decode(encoded.prefix(3)) == nil)
#expect(WifiBulkOffer.decode(Data()) == nil)
// A wrong-length transferID TLV is ignored, leaving the field missing.
var mangled = Data([0x01, 0x00, 0x02, 0xAA, 0xBB]) // transferID of 2 bytes
mangled.append(encoded.dropFirst(3 + WifiBulkWire.transferIDLength))
#expect(WifiBulkOffer.decode(mangled) == nil)
}
@Test("Offer decode skips unknown TLVs for forward compatibility")
func offerDecodeSkipsUnknownTLVs() throws {
var encoded = try #require(makeOffer().encode())
encoded.append(contentsOf: [0x7F, 0x00, 0x03, 0x01, 0x02, 0x03]) // unknown type 0x7F
let decoded = try #require(WifiBulkOffer.decode(encoded))
#expect(decoded == makeOffer())
}
@Test("Accept response round-trips with token")
func acceptResponseRoundTrip() throws {
let response = WifiBulkResponse.accept(
transferID: Data(repeating: 0x11, count: WifiBulkWire.transferIDLength),
token: Data(repeating: 0x22, count: WifiBulkWire.tokenLength)
)
let encoded = try #require(response.encode())
let decoded = try #require(WifiBulkResponse.decode(encoded))
#expect(decoded == response)
#expect(decoded.accepted)
#expect(decoded.token?.count == WifiBulkWire.tokenLength)
}
@Test("Decline response round-trips without token")
func declineResponseRoundTrip() throws {
let response = WifiBulkResponse.decline(
transferID: Data(repeating: 0x11, count: WifiBulkWire.transferIDLength)
)
let encoded = try #require(response.encode())
let decoded = try #require(WifiBulkResponse.decode(encoded))
#expect(decoded == response)
#expect(!decoded.accepted)
#expect(decoded.token == nil)
}
@Test("Accept response without a token is rejected")
func acceptWithoutTokenRejected() throws {
// Hand-build: transferID + accepted=1, no token TLV.
var data = Data()
WifiBulkWire.appendTLV(0x01, value: Data(repeating: 0x11, count: 16), into: &data)
WifiBulkWire.appendTLV(0x02, value: Data([1]), into: &data)
#expect(WifiBulkResponse.decode(data) == nil)
}
}