Skip to content

Transport v3, phase 2b-iii: the return-routability cookie - #255

Draft
whoisaldo wants to merge 4 commits into
feat/transport-v3from
feat/transport-v3-p2b3
Draft

whoisaldo wants to merge 4 commits into
feat/transport-v3from
feat/transport-v3-p2b3

Conversation

@whoisaldo

@whoisaldo whoisaldo commented Nov 8, 2026 •

Copy link
Copy Markdown
Member

Based on feat/transport-v3, after #252 (merged). It also carries the review follow-ups for #252's application-type rule: draining-session test coverage and doc fixes in wire.rs, pairing.rs and client.rs.

Phase 2b-iii of transport v3: a return-routability cookie for message 1 of both the session and the pairing handshakes, in the style of WireGuard's cookie reply.

Behaviour

  • Under load, every message 1 needs a valid cookie for its exact source address (IP and port). Without one, the host does nothing except send a cookie reply. It doesn't:

    • read it (no Noise read and no Diffie-Hellman);
    • resend a cached message 2 or a cached Stale/TooNew/PairAgain answer;
    • use the live address's reserved budget;
    • touch any per-source window.

    A reply goes back only for a known dev_id or, for pairing, while a QR window is open; anything else is dropped as before. A valid cookie lifts no limit underneath it: an address flooding with its own cookie is still read 20 times a second.

  • Below load, nothing a replay or spoofed source can use up stands between the iPad and its answer.

    • Cached answers (message 2, and Stale/TooNew/PairAgain) go without a cookie only to the exact address (IP and port) their message 1 came from. Any other address gets a cookie reply until it comes back with a valid cookie, and then has its own 100 ms resend allowance (16 addresses, then one shared).
    • Reads before authentication are limited to 20 a second per IP. A message 1 the IP's limit refuses gets a cookie reply instead. One with a valid cookie is limited per exact address, so another port on the iPad's IP never uses up its reads. This applies to sessions and pairing.
    • The dev_id's Stale/TooNew/PairAgain budget: a message 1 over it without a cookie gets a cookie reply instead of being remembered unanswered. Message 1s with a valid cookie have a budget of their own.

    A cookie opens only under the handshake's key, so only the iPad reaches the limits a cookie leads to. The host-wide and per-IP budgets for cached answers to other sources are removed.

  • Load is 400 or more message 1 arrivals in this second or the one before (counted before any check), or, for sessions, half the host-wide Diffie-Hellman budget used this second.

  • The cookie is HMAC-SHA-256(secret, address)[..16]. The 32-byte secret rotates every 120 s, and the previous one is still accepted, so a cookie is good for at least 120 s; the iPad keeps one for 110 s.

  • The cookie reply is packet type 0x42 in the v2 envelope, 64 bytes:

    • a header with session_id 0, msg_seq 0 and payload_len 48;
    • the handshake's extension tag, then length 44;
    • a 12-byte nonce;
    • ChaCha20-Poly1305(key, nonce, cookie), with AAD SHA-256(message 1 without a cookie).

    The key is HKDF(salt "EM v3 cookie", K_dev, info dev_id) for a session, or HKDF(salt, QR PSK, info "pair") for pairing, so only the iPad that sent that message 1 can read its reply. Every message 1 the host answers is longer than 64 bytes.

  • Message 1 with a cookie ends in one more extension, EXT_COOKIE (0x00FF), length 16: HMAC-SHA-256(cookie, message 1 without it)[..16]. The authenticated part of message 1 is unchanged.

  • Cookie replies have no budget of their own. A budget would be the one thing a flood of spoofed message 1s for a known dev_id could use up to keep the genuine iPad from its cookie. Each reply is a fixed amount of symmetric work, smaller than what it answers, one per datagram received.

  • The reference clients. Client and PairingClient attach a held cookie to every message 1. When a reply arrives for the message 1 being sent, they keep the cookie and resend at once.

Compatibility with earlier v3 clients

v3 clients without cookie support still connect whenever the host isn't under load. While it is, they get cookie replies, drop them as an unknown packet type, and connect once the load has passed: about 2 s after a flood ends. When the host isn't under load, one more case: if a copy of such a client's message 1 reached the host first from another address, that attempt gets no message 2 (the client can't answer the cookie reply), and its next attempt, a new message 1 from its own address, connects as usual. No v3 client has been released; the iPad's v3 client implements cookies from the start, from proto/testdata/v3/cookie_vectors.txt.

Tests

  • Vectors (cookie_vectors.txt): addresses, cookies, both keys, mac2, message 1 with a cookie, and both replies. They're written by cookie_vectors.py, which uses Python's hashlib/hmac and an RFC 8439 ChaCha20-Poly1305 checked against RFC 8439 §2.8.2. Message 1 is the golden transcript's.
  • The cookie module:
    • reply AEAD (another key, another message 1, every flipped byte);
    • the strict reply parser;
    • the split edge cases (two cookies, wrong length or tag, trailing bytes);
    • port binding;
    • rotation, an idle jar, and a randomized check that every cookie is accepted for as long as the iPad keeps it.
  • Session table:
    • only a cookie reply under load;
    • nothing for unknown dev_ids;
    • a cookie works only from its own address;
    • forged MACs;
    • pending message 2 resends, cached Stale answers and the spoofed live address all need the cookie;
    • the load turning on and off;
    • the half-DH signal;
    • a valid cookie keeps the per-source limit.
  • A 5 s spoofed flood: 10,000 message 1s a second from 1,000,000 sources for the iPad's own dev_id, half of them replays of its genuine old message 1s. Once the load is on there are no reads and no Diffie-Hellman, only one smaller cookie reply per datagram, and the iPad reconnects through the cookie in the middle of it.
  • Shared limits below the load threshold: replays to a rebound iPad's old port never take its cached answer (8 of 8 retries); another port on the iPad's IP never uses up its reads, for sessions (4 of 4 connects) and pairing; replayed old message 1s never use up its Stale answer (4 of 4).
  • Replays below the load threshold: a captured message 1 replayed ten times a second (after 16 spoofed copies arrived first, for sessions; and for pairing, with the iPad's copy first and with a spoofed copy first). Every copy from elsewhere gets only a cookie reply, and every 250 ms retry of the iPad gets message 2. The resend and cached-answer tests check the cookie rule.
  • Client compatibility: a client without cookies connects before the flood and again after it.
  • Pairing: the gate and the cookie round trip, replies only the QR holder (and that message 1) can open, and pairing load.
  • Session client: an immediate resend with the cookie, retries carrying it, foreign or flipped replies dropped, and the cookie's lifetime.
  • Both modes: the three session-sim floods and the two pairing floods run with cookies off (the budgets underneath) and on (the round trip). Pairing floods now count only real message 2s.
  • The host's crash-at-every-step tests (commit, conflict refusal, remove, expiry, pairing write) run with and without cookies forced on, and check that the cookie path ran exactly when forced.
  • Fuzzing:
    • New v3_cookie: the session table or the pairing host with cookies forced, a genuine iPad, and replays, bit flips and forgeries from its own or other addresses across secret rotations. No message 1 from another address is read or answered with anything but a smaller cookie reply, unknown dev_ids get nothing, and the iPad takes a cookie only from a reply the host produced.
    • v3_parsers adds the reply parser and the cookie split.
  • Mutation checks: 23 rules, each mutated alone, each caught by at least one test:
    • the gate on either handshake;
    • MAC verification;
    • the port in the address;
    • both rotation rules;
    • cached answers ahead of the gate;
    • replies for unknown dev_ids;
    • the AAD;
    • both parts of the load signal;
    • the client's immediate resend and cookie lifetime;
    • the split;
    • the pairing client's open;
    • a cookie required for new addresses on session resends, pairing resends and cached answers;
    • the per-address read lanes (sessions and pairing), the cookie reply on an IP-limit refusal, the Stale lane, and the cookie reply over the Stale budget.

@whoisaldo whoisaldo added the 0.4 Part of the 0.4 release (transport v3) label Nov 8, 2026
Under load, a session or pairing message 1 must carry a valid cookie for
its exact source address (IP and port) before the host reads it, answers
it from a cache, resends message 2 or uses the live address's reserve.
Without one the host sends only a 64-byte cookie reply, for a known
dev_id or while a QR window is open, and does no Noise read and no
Diffie-Hellman.

- Cookie: HMAC-SHA-256(secret, address)[..16]; the 32-byte secret
  rotates every 120 s and the previous one is still accepted.
- Cookie reply: packet type 0x42, sealed with ChaCha20-Poly1305 under a
  key from K_dev or the QR PSK, with SHA-256 of the message 1 as AAD.
- Message 1 with a cookie: a trailing EXT_COOKIE extension carrying
  HMAC-SHA-256(cookie, message 1)[..16].
- Load: 400 message 1 arrivals in this second or the one before, or half
  the host-wide Diffie-Hellman budget in use.
- The reference session and pairing clients attach the cookie and resend
  at once when a reply arrives. Clients without cookie support connect
  whenever the host isn't under load.

Adds vectors written by an independent Python script, the v3_cookie
fuzz target, cookie parsers in v3_parsers, flood and rotation tests, and
runs the host's crash-at-every-step tests with cookies forced on.
A cached message 2, or a cached Stale, TooNew or PairAgain answer, now
goes without a cookie only to the exact address (IP and port) its
message 1 came from. Any other address gets a cookie reply until it
comes back with a valid cookie, whether or not the host is under load,
and then has its own resend allowance. This applies to both the session
and the pairing handshake.

A cookie opens only under the handshake's own key, so copies of a
message 1 replayed from other addresses, at any rate, no longer use any
allowance the iPad's retries need. The shared budget for cached answers
to other sources is removed, because only the key holder can reach it now.
Cached Stale, TooNew and PairAgain answers now have exact-address resend
allowances, the same as message 2: the address their message 1 came
from, or an address that came back with a valid cookie, each with its
own 100 ms allowance. The per-IP replay budget for them is removed.

Every other limit a replay or spoofed source could use up before the
iPad's own message 1 now falls back to the cookie:

- Message 1 reads (session and pairing): a message 1 that the IP's limit
  refuses gets a cookie reply. One with a valid cookie is limited per
  exact address instead, so another port on the iPad's IP never uses up
  its reads.
- The dev_id's Stale/TooNew/PairAgain budget: an unverified message 1
  over it gets a cookie reply instead of being remembered unanswered.
  Message 1s with a valid cookie have a budget of their own, and the
  table of remembered message 1s is sized for it.
- The reference client's test now covers a draining session: an unknown
  type isn't delivered and a Ping is, with draining set. It also checks
  that an unknown type on the live session still counts as liveness.
- SessionMessage::parse says callers deliver Ok(None) only for
  application inner types, and the inner-type constants are documented
  as a group (0x50-0x53, and v2's Bye at 0x13).
- The pairing read budget states its rate on average (up to twice that
  across a window boundary) and where the read cost was measured.
- The many-senders pairing test expects message 2 on every retry, and
  notes that more senders than the source table holds is the cookie's
  case.
@whoisaldo
whoisaldo force-pushed the feat/transport-v3-p2b3 branch from f289c41 to 5e2834d Compare November 8, 2026 22:10
@whoisaldo
whoisaldo changed the base branch from feat/transport-v3-pairing-limits to feat/transport-v3 November 8, 2026 22:10

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

0.4 Part of the 0.4 release (transport v3)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants