Files
bitchat/bitchat/Services/WifiBulk/WifiBulkCrypto.swift
T
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

216 lines
8.3 KiB
Swift

//
// WifiBulkCrypto.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CryptoKit
import Foundation
/// Channel security for the Wi-Fi bulk data plane.
///
/// The TCP stream is encrypted and authenticated independently of TLS: both
/// endpoints exchanged random 32-byte tokens inside the established Noise
/// session, so only they can derive the ChaChaPoly channel key via
/// HKDF-SHA256 (domain "bitchat-bulk-v1", transferID as salt). A Bonjour-level
/// gatecrasher that connects to the listener cannot produce a single valid
/// frame and is disconnected.
///
/// Stream format: length-prefixed frames, each a ChaChaPoly sealed box in
/// combined form (12-byte nonce ‖ ciphertext ‖ 16-byte tag). Nonces are
/// structured, never random: [direction byte][3 zero bytes][8-byte BE counter],
/// and the reader requires the exact expected nonce for each frame, so frames
/// cannot be replayed, reordered, or reflected across directions.
enum WifiBulkCryptoError: Error, Equatable {
case invalidParameters
case frameTooLarge
case truncatedFrame
case nonceMismatch
case authenticationFailed
case emptyChunk
case payloadOverflow
case hashMismatch
}
enum WifiBulkFrameDirection: UInt8 {
/// Data chunks: counters 0, 1, 2, …
case senderToReceiver = 0x00
/// Counter 0 = client auth frame, counter 1 = final receipt.
case receiverToSender = 0x01
}
enum WifiBulkCrypto {
static let keyDomain = "bitchat-bulk-v1"
static let nonceLength = 12
static let tagLength = 16
/// AEAD overhead per frame body (nonce + tag).
static let frameOverhead = nonceLength + tagLength
/// 4-byte big-endian length prefix per frame.
static let framePrefixLength = 4
// MARK: Key derivation
/// Derives the ChaChaPoly channel key from the two Noise-exchanged tokens.
/// Deterministic: same tokens + transferID always yield the same key.
static func deriveKey(senderToken: Data, receiverToken: Data, transferID: Data) -> SymmetricKey? {
guard senderToken.count == WifiBulkWire.tokenLength,
receiverToken.count == WifiBulkWire.tokenLength,
transferID.count == WifiBulkWire.transferIDLength else {
return nil
}
var inputKeyMaterial = Data()
inputKeyMaterial.append(senderToken)
inputKeyMaterial.append(receiverToken)
return HKDF<SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: inputKeyMaterial),
salt: transferID,
info: Data(keyDomain.utf8),
outputByteCount: 32
)
}
// MARK: Frame sealing
static func nonceData(direction: WifiBulkFrameDirection, counter: UInt64) -> Data {
var nonce = Data(count: nonceLength)
nonce[0] = direction.rawValue
var counterBE = counter.bigEndian
withUnsafeBytes(of: &counterBE) { nonce.replaceSubrange(4..<nonceLength, with: $0) }
return nonce
}
/// Seals one frame body (nonce ‖ ciphertext ‖ tag), without length prefix.
static func sealFrameBody(
_ plaintext: Data,
direction: WifiBulkFrameDirection,
counter: UInt64,
key: SymmetricKey
) throws -> Data {
let nonce = try ChaChaPoly.Nonce(data: nonceData(direction: direction, counter: counter))
return try ChaChaPoly.seal(plaintext, using: key, nonce: nonce).combined
}
/// Opens one frame body, enforcing the exact expected nonce.
static func openFrameBody(
_ body: Data,
direction: WifiBulkFrameDirection,
counter: UInt64,
key: SymmetricKey
) throws -> Data {
guard body.count >= frameOverhead else { throw WifiBulkCryptoError.truncatedFrame }
guard body.prefix(nonceLength) == nonceData(direction: direction, counter: counter) else {
throw WifiBulkCryptoError.nonceMismatch
}
do {
let box = try ChaChaPoly.SealedBox(combined: body)
return try ChaChaPoly.open(box, using: key)
} catch {
throw WifiBulkCryptoError.authenticationFailed
}
}
/// Prefixes a frame body with its 4-byte big-endian length for the wire.
static func frameData(body: Data) -> Data {
var framed = Data(capacity: framePrefixLength + body.count)
var lengthBE = UInt32(body.count).bigEndian
withUnsafeBytes(of: &lengthBE) { framed.append(contentsOf: $0) }
framed.append(body)
return framed
}
// MARK: Control frames
/// First frame on the wire, receiver → sender: proves the connecting
/// client holds the Noise-exchanged secret before any data flows.
static func makeClientAuthFrameBody(transferID: Data, key: SymmetricKey) throws -> Data {
try sealFrameBody(transferID, direction: .receiverToSender, counter: 0, key: key)
}
static func validateClientAuthFrameBody(_ body: Data, transferID: Data, key: SymmetricKey) -> Bool {
(try? openFrameBody(body, direction: .receiverToSender, counter: 0, key: key)) == transferID
}
/// Final frame, receiver → sender: acknowledges the fully verified payload.
static func makeReceiptFrameBody(payloadHash: Data, key: SymmetricKey) throws -> Data {
try sealFrameBody(payloadHash, direction: .receiverToSender, counter: 1, key: key)
}
static func validateReceiptFrameBody(_ body: Data, payloadHash: Data, key: SymmetricKey) -> Bool {
(try? openFrameBody(body, direction: .receiverToSender, counter: 1, key: key)) == payloadHash
}
}
/// Incremental length-prefix parser for the frame stream. Bounded: bodies
/// larger than `maxBodyBytes` throw instead of buffering unboundedly.
final class WifiBulkFrameBuffer {
private var buffer = Data()
private let maxBodyBytes: Int
init(maxBodyBytes: Int) {
self.maxBodyBytes = maxBodyBytes
}
func append(_ data: Data) {
buffer.append(data)
}
/// Extracts the next complete frame body, or nil when more bytes are needed.
func nextFrameBody() throws -> Data? {
guard buffer.count >= WifiBulkCrypto.framePrefixLength else { return nil }
let length = buffer.prefix(WifiBulkCrypto.framePrefixLength).reduce(Int(0)) { ($0 << 8) | Int($1) }
guard length <= maxBodyBytes else { throw WifiBulkCryptoError.frameTooLarge }
guard buffer.count >= WifiBulkCrypto.framePrefixLength + length else { return nil }
let body = Data(buffer.dropFirst(WifiBulkCrypto.framePrefixLength).prefix(length))
buffer.removeFirst(WifiBulkCrypto.framePrefixLength + length)
return body
}
}
/// Receiver-side reassembly: opens sequential data frames, enforces the size
/// negotiated in the accepted offer, and verifies the final SHA-256.
final class WifiBulkPayloadAssembler {
private let key: SymmetricKey
private let expectedSize: Int
private let expectedHash: Data
private var received = Data()
private var counter: UInt64 = 0
/// Fails when the offer exceeds the receiver-enforced cap.
init?(key: SymmetricKey, expectedSize: UInt64, expectedHash: Data, sizeCap: Int) {
guard expectedSize > 0,
expectedSize <= UInt64(sizeCap),
expectedHash.count == WifiBulkWire.hashLength else {
return nil
}
self.key = key
self.expectedSize = Int(expectedSize)
self.expectedHash = expectedHash
}
var isComplete: Bool { received.count == expectedSize }
/// Consumes one sealed data frame body. Returns the verified payload when
/// the final byte arrives; throws on tampering, overflow, or hash mismatch.
func consume(frameBody: Data) throws -> Data? {
let chunk = try WifiBulkCrypto.openFrameBody(
frameBody,
direction: .senderToReceiver,
counter: counter,
key: key
)
guard !chunk.isEmpty else { throw WifiBulkCryptoError.emptyChunk }
counter += 1
guard received.count + chunk.count <= expectedSize else {
throw WifiBulkCryptoError.payloadOverflow
}
received.append(chunk)
guard isComplete else { return nil }
guard Data(SHA256.hash(data: received)) == expectedHash else {
throw WifiBulkCryptoError.hashMismatch
}
return received
}
}