Skip to content
Documentation (14 pages)

Status: this is the published design and the configuration documented for self-hosters. The migration of the reference relay off IP-keyed limits is a relay-side change tracked in the crypto register (row A09-30 / D-20); the website documents the target configuration and the challenge spec, and the relay binary follow-up is explicitly pending, not silently omitted.

What runs on the reference relay today: its config has no rate_limit_mode key, so the proof-of-work path below is not armed — the relay still applies its documented IP-keyed join limiter (rate_limit = 100 room joins/min per IP plus 5 concurrent connections; see Self-hosting). Everything below is the target configuration, not the live behaviour.

Rate Limits (Proof-of-Work)

A privacy-preserving service cannot rate-limit by address. Keying abuse control on the client IP means every connection carries an identifier that can be logged, correlated and subpoenaed — the exact thing Tallow is built to avoid. So the reference control is a proof-of-work challenge (Hashcash-lite): the client spends a small, bounded amount of CPU before the relay will accept the connection, and the relay never needs to know who the client is.

Why not IP limits

Challenge specification (Hashcash-lite)

Algorithm. SHA-256 over a length-prefixed preamble:

pre_challenge = H( room_id || relay_epoch || client_nonce )
challenge = sha256( "tallow-pow-v1:" || pre_challenge || difficulty_bits )
accept iff leading_zero_bits( challenge ) >= difficulty_bits

Fields.

Field Size Notes
room_id 32 B the one-way room identifier; never the plaintext code
relay_epoch 8 B rotates every 300 s — bounds the replay window
client_nonce 16 B client-chosen, random; the search variable
difficulty_bits 1 B required leading zero bits, relay-advertised
counter 8 B monotonic search counter, included in the hash input

Difficulty. The relay advertises difficulty_bits per epoch. Baseline for the reference deployment is 20 bits (≈10⁶ hashes, well under a second in WASM on a mid-tier phone; ≈1 s on a single-threaded attacker with no parallelism advantage). The relay raises the advertised value — up to 28 bits — when it sees connection pressure, and lowers it back as pressure decays so the honest client cost stays bounded.

Verification. The relay recomputes the hash for the submitted (client_nonce, counter) and checks the leading-zero bound. Solutions are accepted only for the current epoch: relay_epoch is part of the preamble, so a solved challenge is valid for ≤300 s and is bound to one room_id. Each solution is single-use (a bloom filter over accepted (room_id, client_nonce) pairs covers an epoch, ≈2 MB at reference load).

Failure modes.

Condition Behaviour
No solution supplied 421 rate-limit: proof-of-work required + advertised difficulty
Solution below difficulty Rejected, difficulty re-advertised (no penalty, no drop)
Stale relay_epoch Rejected; the client re-solves against the current epoch
Replayed solution Rejected (single-use filter); connection continues to be challenged
Difficulty spikes past the client budget Client backs off with jittered retry; the relay never blocks a room for more than one epoch

Why this is privacy-safe. The challenge binds to the room and the epoch, which are already ephemeral and are destroyed with the room. Nothing in the challenge identifies the client beyond the socket it is already using, no address is stored, and solving a challenge leaves no correlatable artefact across epochs.

Reference relay configuration

The hardened configuration the reference relay targets — proof-of-work first, IP keying removed as the primary control:

# Rate limiting — abuse control without identity
rate_limit_mode = "pow" # "pow" (default) | "legacy_ip" (compat only)
pow_difficulty_bits = 20 # baseline; relay raises to <= 28 under load
pow_epoch_secs = 300 # solution validity window (and replay bound)
pow_single_use = true # reject replays within an epoch
# Legacy IP keying — kept ONLY as an explicit, documented exception.
# (A09-30: the published config no longer relies on this; leave it unset to
# run the privacy-preserving path.)
# max_connections_per_ip = 10