getlantern/twiddle
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
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.
| Record | Type | Fidelity required |
|---|---|---|
| ClientHello | 0x16 | exact — harvested from a real browser |
| ChangeCipherSpec | 0x14 | one fixed byte |
| ServerHello | 0x16 | structurally plausible — 1221 B, measured |
| everything after, forever | 0x17 | shape only — opaque to any observer |
02 — the bytes
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.
03 — the seven
| Field | What we do | Why |
|---|---|---|
| server_name | rewrite | Point the SNI at the cover domain this egress masquerades as. The only length change we make deliberately — it cascades through five nested length fields. |
| random | re-randomise | 32 bytes of client entropy. Replaying a captured value would repeat one browser's nonce across every connection we make. |
| legacy_session_id | re-randomise | Chrome sends 32 random bytes here on every hello measured. The server must echo it, so anything hidden here appears twice on the wire. |
| key_share | fresh, valid keys | A 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_hello | re-randomise + resize | Chrome'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 order | shuffle | Chrome permutes the interior on every connection. Emitting a fixed order is something Chrome never does, so shuffling is fidelity, not obfuscation. |
| pre_shared_key | append | Carries 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
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.
| Carries | TLS 1.3 field | Size |
|---|---|---|
| forward secrecy | key_share[X25519] | a real ephemeral |
| identity + psk | ticket | AEAD ciphertext — uniform by construction |
| proof of possession | binder | 32 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
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.
pre_shared_keyUnder 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
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.
07 — measurement
Every decision here came from measuring real traffic rather than reasoning about it. Several measurements overturned what we had already built.
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.