getlantern/twiddle

Ordinary
on the wire.

A censorship-resistant transport whose opening bytes are a genuine ClientHello harvested from a real browser. We change seven things in it. Everything else is exactly what Chrome sent.

A gray sedan on a motorway is not hidden. It is simply not worth pulling over —
and pulling it over means pulling over ten thousand identical cars.

01 — the opening

Why only the first two records matter

Every TLS 1.3 handshake message after the ServerHello is encrypted and framed as an application_data record. Measured against real servers, the entire observable structure of a TLS connection is four records and then opaque noise forever.

So the connection does not have to be TLS. It has to open like TLS. Everything past the ServerHello is shape, not protocol — which is why a harvested ClientHello, a synthesised ServerHello, and an ordinary AEAD tunnel are indistinguishable from the real thing.

RecordTypeFidelity required
ClientHello0x16exact — harvested from a real browser
ChangeCipherSpec0x14one fixed byte
ServerHello0x16structurally plausible — 1221 B, measured
everything after, forever0x17shape only — opaque to any observer

02 — the bytes

A real ClientHello, and what we touch

Below is an actual ClientHello captured from Chrome 151, byte for byte — of them. Hover any byte, or any field in the list, to see what it is and whether we change it. Notice how little is coloured.

copied verbatim twiddled length, recomputed appended by us

    03 — the seven

    Every field we change, and why

    FieldWhat we doWhy
    server_namerewritePoint the SNI at the cover domain this egress masquerades as. The only length change we make deliberately — it cascades through five nested length fields.
    randomre-randomise32 bytes of client entropy. Replaying a captured value would repeat one browser's nonce across every connection we make.
    legacy_session_idre-randomiseChrome sends 32 random bytes here on every hello measured. The server must echo it, so anything hidden here appears twice on the wire.
    key_sharefresh, valid keysA real X25519 and a real ML-KEM-768 key each time. Filling them with random bytes made live servers reply illegal_parameter — which would have handed a censor a replay distinguisher.
    encrypted_client_hellore-randomise + resizeChrome's GREASE ECH varies per connection in 32-byte buckets — 186, 218, 250, 282 — independently of SNI length. uTLS holds it constant, which is its own tell.
    extension ordershuffleChrome permutes the interior on every connection. Emitting a fixed order is something Chrome never does, so shuffling is fidelity, not obfuscation.
    pre_shared_keyappendCarries the authenticator. RFC 8446 requires it last, so appending is the only legal placement — and it turns a full hello into a resumption hello, which is what we want to look like.

    04 — authentication

    The binder is already a MAC over the opening

    RFC 8446 §4.2.11.2 defines the PSK binder as HMAC(binder_key, Transcript-Hash(Truncate(ClientHello))) — a MAC over the opening, keyed by a secret shared with the server. That is exactly the authenticator we needed, in the field TLS already provides for it. We are not injecting anything foreign; we are populating the slot the protocol defines.

    Both halves are opaque by specification: a ticket is server-chosen opaque data, and a binder is an HMAC under a key derived from it. No observer can validate either without the resumption secret. They are the only ClientHello fields unverifiable by construction rather than by convention.

    CarriesTLS 1.3 fieldSize
    forward secrecykey_share[X25519]a real ephemeral
    identity + pskticketAEAD ciphertext — uniform by construction
    proof of possessionbinder32 B (SHA-256) / 48 B (SHA-384)

    This is TLS 1.3 psk_dhe_ke exactly — authentication from the pre-shared key, forward secrecy from the Diffie-Hellman.

    05 — the reply

    A ServerHello we synthesise

    Five real servers were measured, and every one produced an identical layout. The size is set almost entirely by the post-quantum key share, whose server value is opaque bytes — so synthesising it is easy, and the number to hit is exact rather than a range.

    1215 Bfull handshake ServerHello, all five servers
    1221 Bresumed — the extra 6 B is pre_shared_key
    1120 Bof that is the X25519MLKEM768 key share
    ±0 Bvariance across google, cloudflare, microsoft, amazon, wikipedia

    Under this design the ML-KEM half never has to be real — it is opaque to any observer and both ends of the connection are ours. What must be right is what a censor can actually check: the group id, the total length, and the structure around them. The real key agreement rides in the X25519 half.

    06 — probe resistance

    Anything that fails to authenticate reaches a real site

    Because the ServerHello is synthesised, the egress cannot complete a genuine handshake with a peer it does not recognise. That makes the splitting egress load-bearing rather than optional hardening: an active prober must be forwarded to a real TLS server, byte for byte, or it has confirmed the transport by getting anything else.

    client egress cover site harvested ClientHello + authenticator verify binder AUTHENTICATED synthesised ServerHello · 1221 B flight — carries the next ticket AEAD tunnel, framed as 0x17 records ErrNotOurs replay every byte the peer sent, verbatim a real TLS handshake, from the actual site
    The egress never speaks for itself to a peer it cannot authenticate — a prober's own bytes arrive at a genuine server, and the reply is that server's.

    07 — measurement

    Numbers that changed the design

    Every decision here came from measuring real traffic rather than reasoning about it. Several measurements overturned what we had already built.

    8 / 8connections gave distinct extension orders — Chrome shuffles every time
    4.1%of real browsing connections are resumptions, across 254 origins — not the 70% we assumed
    26,802records profiled to fit the shaping layer
    1395 Bdominant server record size — 26% of all of them
    11.9×bandwidth amplification from pad-only shaping, before we measured it
    64 / 64captured hellos round-trip byte-for-byte through our parser

    The resumption number is the sharpest example. We designed a whole second mode around the assumption that most connections resume; measurement said 4.1%, and the mode was cut. Browsers open connections to an origin in a parallel burst, so every connection in the burst starts before any session ticket has arrived — one origin took 17 connections and resumed none of them.