mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 18:45:20 +00:00
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>
108 lines
4.2 KiB
Swift
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)
|
|
}
|
|
}
|