Documentation (14 pages)
Wire Protocol
The Tallow Transfer Protocol enables end-to-end encrypted file transfer between two parties via an untrusted relay.
Protocol Overview
Sender Relay Receiver | | | |--- Join Room (hash) --->|<--- Join Room (hash) ---| | | | |<-------- KEM Handshake (ML-KEM-1024 + X25519) -->| | | | |--- FileOffer (encrypted metadata) ------------->| | | | |<-- Accept/Reject -------------------------------| | | | |--- Encrypted Chunks (AES-256-GCM) ------------>| |--- Encrypted Chunks ---------------------------->| |--- TransferComplete (Merkle root) -------------->| | | | |<-- Verification --------------------------------|Key Exchange
Room Creation
- Sender generates a six-character passcode (32-symbol confusable-free alphabet)
- Room ID = Argon2id memory-hard derivation of the passcode (each guess costs ~64 MiB × 3 passes); the passcode itself is never sent
- Both parties connect to the relay using the room ID
- The relay matches participants but never sees the passcode
Hybrid KEM Handshake
- Both parties exchange ML-KEM-1024 public keys and X25519 ephemeral keys
- ML-KEM-1024 encapsulation produces a post-quantum shared secret
- X25519 Diffie-Hellman produces a classical shared secret
- The KEM and PAKE secrets are combined via HKDF-SHA256 with domain separation, binding the transmitted KEM material into the derivation:
session_key = HKDF-SHA256( salt = BLAKE3(handshake transcript), ikm = kem_shared_secret || cpace_secret || BLAKE3(kem_ciphertext) || x25519_ephemeral_pub || transcript_hash, info = "tallow.session_key.kem_pake.v3")The hybrid approach ensures security even if one algorithm is broken.
Data Transfer
Chunking
- Files are split into 4 MiB chunks (default; the size adapts for small files)
- Each chunk is compressed with DEFLATE (
deflate-raw) before encryption — skippable for pre-compressed data - Compression can be disabled per-transfer or auto-detected per-chunk
Encryption
Each chunk is encrypted with AES-256-GCM:
- Key: 256-bit session key from the KEM handshake
- Nonce: 96-bit counter-based (monotonically increasing, guaranteed unique)
- AAD:
transfer_id || chunk_index.to_be_bytes()— binds each chunk to its position, preventing reordering attacks
Integrity
- Each chunk’s authentication tag is verified before processing
- A BLAKE3 Merkle tree is built over all chunks
- The final
TransferCompletemessage includes the Merkle root - Both sides compute and verify the Merkle root independently
Wire Format
Serialization
Messages are serialized with postcard (Serde-compatible, compact binary format) and framed with a 4-byte big-endian length prefix:
[4 bytes: length][N bytes: postcard-serialized message]Message Types
| Message | Direction | Purpose |
|---|---|---|
RoomJoin |
Both to Relay | Join a room by code hash |
RoomJoined |
Relay to Both | Confirm room membership |
KemPublicKey |
Both | Exchange KEM public keys |
KemCiphertext |
Responder to Initiator | KEM encapsulation result |
FileOffer |
Sender to Receiver | Encrypted file manifest |
FileAccept |
Receiver to Sender | Accept the transfer |
FileReject |
Receiver to Sender | Reject the transfer |
Chunk |
Sender to Receiver | Encrypted file chunk |
ChunkAck |
Receiver to Sender | Acknowledge received chunk |
TransferComplete |
Sender to Receiver | Final Merkle root |
ResumeInfo |
Both | Resume state for interrupted transfers |
ChatMessage |
Both | Encrypted chat message |
ClipboardData |
Both | Encrypted clipboard content |
Ping / Pong |
Both | Keep-alive |
Version Negotiation
Protocol version is exchanged on connection. The current version is v1. A TLV (Type-Length-Value) extension mechanism is reserved for future features without breaking backward compatibility.
Transfer Flow
Sliding Window
Chunks are sent using a sliding window of size 64:
- Sender sends up to 64 chunks without waiting for acknowledgement
- Receiver acknowledges each chunk as it is verified
- Sender advances the window as acknowledgements arrive
- This maximizes throughput on high-latency connections
Resume
If a transfer is interrupted:
- Both sides exchange
ResumeInfowith the manifest hash and list of verified chunks - The sender skips already-verified chunks
- The transfer continues from where it left off
- The final Merkle tree verification covers all chunks (original + resumed)
Transport
QUIC (Primary)
- Native QUIC via the
quinncrate - Multiplexed streams, 0-RTT, built-in TLS 1.3
- Keep-alive: 300s idle timeout + 15s keep-alive interval
- Default port: 4433
WebSocket (Browser)
- WebSocket transport for browser clients
- 4-byte length prefix bridging between WebSocket and QUIC
- Same room management and message format as QUIC
- Default port: 4434