getlantern/twiddle
An experimental TLS-shaped transport built from a genuine ClientHello harvested from a real browser. Fresh keys, two authentication carriers, and measured record shapes — without a TLS stack in the shipped transport.
Updated 5 September 2026 · Exactly what changes · Implementation & rollout status · The full-handshake carrier
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. The contents become opaque, but record sizes,
counts, timing, destination addresses, and connection history remain observable.
So the connection does not have to be TLS. It has to open like TLS. Everything past the ServerHello still needs measured shaping. A harvested ClientHello, a synthesised ServerHello, and an AEAD tunnel are the design — not a proof of indistinguishability. Active probing and traffic analysis remain part of the threat model.
| Record | Type | Fidelity required |
|---|---|---|
| ClientHello | 0x16 | exact — harvested from a real browser |
| ChangeCipherSpec | 0x14 | one fixed byte |
| ServerHello | 0x16 | 1215 B full / 1221 B resumed in the measured profiles |
| compatibility ChangeCipherSpec | 0x14 | fixed framing, before encrypted records |
| encrypted handshake, data & alerts | 0x17 | opaque contents; observable lengths, counts, and timing |
02 — the bytes
Below is a historical 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. The annotations describe current rewrites; the bytes are the original capture, not a freshly emitted hello. Read the complete field-by-field walkthrough.
Chrome 152 measurements no longer include server_padding (0x12e0) and do include a varying 206-byte 0xca34 body. This sample is not a current-browser fingerprint recommendation. Use fresh device captures.
This is the same 1,827-byte capture shown above: 19 extensions, SNI
localhost, no pre_shared_key, and no early_data.
Offsets below are decimal, zero-based, inclusive, and refer to the original record,
including its five-byte TLS header. Expand any entry for a quick explanation of what it
does, followed by Twiddle's treatment, nested fields, and original bytes.
The purpose descriptions explain the fields' roles in TLS, not a claim that Twiddle implements every advertised capability. For background, see TLS 1.3 (RFC 8446) and GREASE (RFC 8701).
“Copied” means the value survives, not its position: all 17 interior extensions are shuffled. Each extension's two-byte type and two-byte body length are serialized again. Lengths inside untouched payloads are copied; SNI and ECH builders regenerate their own nested lengths. Signature names follow the IANA registry. The behavior follows the source-pinned implementation below, not a complete TLS negotiation.
Capture SHA-256 915b694a5e04f7caf82d7d58168d2092af1d9df21ed2b69060f4d0cc97452006
What is GREASE? “Generate Random Extensions And Sustain Extensibility” means regularly sending reserved, deliberately unsupported values so servers and middleboxes do not become intolerant of new ones. Think of it as exercising the “ignore unfamiliar offers” path. It is not encryption or padding, and a server must not select a GREASE value as a real negotiated feature.
The captured cipher 4a4a, leading extension baba,
trailing extension 2a2a, group 6a6a, and version 1a1a
are redrawn from 0a0a, 1a1a, …, fafa. The group draw is reused for the
key-share group. The two extension draws must differ; otherwise draws can coincide,
including with their captured values. Neither GREASE extension payload changes: the
first stays empty, the last stays 00. This sample has no GREASE signature ID.
ECH GREASE is a separate mechanism: fe0d stays fixed while its body is rebuilt.
What does pre_shared_key do? In real TLS 1.3 it identifies a previously shared secret, commonly through a resumption ticket. A binder proves possession of that secret and binds the offer to the ClientHello. Twiddle borrows this structure for its own ticket authentication; it does not resume a genuine browser TLS session.
Resumption: append extension 0029 after the
trailing GREASE. For ticket length T and binder length B, its body is:
two-byte identities length T + 6; two-byte identity length T;
the supplied ticket's T bytes; four random age bytes; two-byte binders length
B + 1; one-byte binder length B; and B freshly computed
HMAC bytes. Its body length is T + B + 11, and the whole extension adds
T + B + 15 bytes. The output has 20 extensions.
Full: add no extension. Keep 19 extensions; put the 144-byte companion ticket at the start of the new ECH payload and retain random padding after it. The final 32-byte random field becomes the whole-hello authentication MAC. Neither mode emits the captured keys, captured random, or captured session ID.
Size accounting: with cover hostname length N
bytes and selected ECH payload length P, the full opening is
1827 + (N − 9) + (P − 240) bytes. Resumption adds T + B + 15.
For a nine-byte hostname, P = 144, T = 176, and B = 32,
that is 1,731 bytes full or 1,954 bytes resumed. These are size examples, not fixed outputs:
random values and interior extension order change every emission.
Import step: sanitizing this exact sample first changes
localhost to nine x bytes and zeros random and session ID.
Its legacy session-ticket body is already empty; there is no PSK or early-data extension
to remove. Per-connection processing then replaces those placeholders as described here.
03 — the rewrites
Twiddle transforms a captured ClientHello; it does not generate a browser
fingerprint from scratch. The list below describes the implementation at
c78665a.
Fields are modified only where present unless a mode explicitly adds or removes an extension.
| Field | What we do | Exact behavior |
|---|---|---|
| server_name | replace hostname | Use the configured cover SNI when provided. Recompute its nested lengths; do not compensate with padding to preserve the original message size. |
| random | fresh entropy / MAC | Fresh random bytes on the resumed path. On the full path, a 32-byte MAC binds the entire final ClientHello, including the key share and ECH padding. |
| legacy_session_id | fresh bytes, same length | Preserve the template's length, usually 32 bytes. The server echoes this value, so anything hidden here appears twice on the wire. |
| key_share: X25519 | fresh valid public key | The standalone X25519 entry is replaced with a newly generated public key. Twiddle retains its private key for the actual tunnel key agreement. |
| key_share: X25519MLKEM768 | fresh valid hybrid share | If offered, generate an ML-KEM-768 encapsulation key (1184 bytes) and an X25519 public key (32 bytes). These private keys are discarded: the advertised hybrid share does not give Twiddle post-quantum forward secrecy. |
| key_share: GREASE | zero placeholder contents | The measured GREASE entry carries one zero byte, not random data. The current fallback also zeroes unrecognized key-share groups; it does not implement arbitrary group key generation. |
| encrypted_client_hello | refresh outer GREASE ECH | Fresh one-byte config_id, fresh 32-byte enc, and random payload. Choose the payload length uniformly from 144, 176, 208, or 240 bytes (extension bodies: 186, 218, 250, or 282 bytes, excluding the four-byte extension header). Preserve the HPKE KDF/AEAD IDs. No ECH extension is added when absent; inner ECH is not regenerated. |
| GREASE identifiers | redraw from 16 values | Replace GREASE in cipher suites, extension types, supported groups, key-share group IDs, supported versions, and signature algorithms where present. Supported-group and key-share IDs match. The first and last GREASE extension IDs must differ; other slots may coincide. Ordinary IDs and list ordering stay unchanged. |
| extension order | shuffle the interior | Uniform Fisher–Yates permutation, with the leading GREASE pinned first and trailing GREASE pinned last. On resumption, pre_shared_key follows the trailing GREASE. |
| lengths & framing | serialize again | Recompute affected nested lengths, total extension length, handshake length, and record length. Emit one handshake record (0x16) with record version 0x0301 and ClientHello message type 0x01. Preserve the separate ClientHello legacy_version. |
Resumption-shaped opening. Replace any existing pre_shared_key
with one containing the supplied Twiddle credential ticket, a fresh random four-byte
obfuscated_ticket_age (not elapsed ticket age), and a newly computed 32- or
48-byte binder. The binder is Twiddle's PSK-derived HMAC over the final serialized hello
truncated at the binders, not an interoperable TLS binder calculation. This extension is last.
The credential ticket is supplied, not newly minted by each call to Twiddle.
Full-handshake-shaped opening. Remove pre_shared_key.
Put the 144-byte companion ticket at the start of the ECH payload and fill the remainder
with fresh random bytes. Zero ClientHello.random for the calculation, compute
a domain-separated HMAC-SHA256 over the entire serialized hello, then place that
32-byte MAC in random. A usable ECH carrier is required.
Ordinary cipher suites and their ordering, ALPN, non-GREASE supported groups, versions and signature algorithms, compression methods, and otherwise unhandled extension payloads are copied from the template. Extension ordering changes their offsets, not their contents.
This is an explicit rewrite list, not automatic browser-behavior discovery.
For example, Chrome's newer varying 0xca34 payload is currently copied, not
regenerated. Twiddle does not add or remove that extension, or 0x12e0, to
turn an old capture into a newer browser fingerprint.
When templates pass through Sanitize, replace the captured SNI with
same-length x characters, remove captured pre_shared_key and
early_data, empty the legacy session_ticket payload, and zero
random and the session-ID contents. These are import-time changes, separate
from the per-connection rewrites. Calling Twiddle directly on a raw capture
does not itself run this sanitizer.
Source: transformations · keys & resumption · full-handshake authentication · sanitization · serialization.
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. Twiddle uses that shape for its own ticket authenticator;
it does not establish an interoperable TLS session.
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. Opaque contents still need the lengths and surrounding structure of the selected cover profile.
| 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) |
The division of responsibilities resembles TLS 1.3 psk_dhe_ke:
authentication from the pre-shared key, forward secrecy from Diffie-Hellman.
Twiddle's session and record protocol is its own.
The merged full-handshake carrier
omits pre_shared_key. Its ticket travels in the GREASE ECH payload;
ClientHello.random carries a domain-separated MAC over the whole hello.
The X25519 key share still supplies forward secrecy.
TicketKey.Issue mints a resumed ticket and a 144-byte full ticket
for the same client, PSK, and issuance time. The shared timestamp matters: the replay gate rejects
tickets older than the newest one that client has presented. A Microsoft-sized 256-byte resumed
ticket cannot fit even the largest measured 240-byte ECH payload.
ContactMemory chooses full on first contact, then permits resumption after a
completed full opening. The default horizon is six hours; a reset, eviction, or process restart
asks for another full opening. Network-change callers should reset it explicitly.
Capability is not activation. Automatic selection falls back to resumption unless the credential has a companion ticket, the pool has an ECH carrier, and the cover has a measured full reply profile. An explicit full-handshake request fails if it cannot be supported. The built-in cover table has no full reply profile; per-egress probing is still required.
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 changed the design again: 26 of 636 observed connections resumed; a separate cold-process capture measured 13 of 485 (2.7%). These are samples, not a universal target ratio. The sharper requirement is a visible predecessor: do not always arrive at a new egress already claiming to resume. That motivated the full-handshake carrier and contact memory, rather than a knob tuned to 4.1%.
08 — freshness & identity
Prefer this device's current, sanitised browser captures; next try configuration-supplied captures. Sources are not merged. Mixed-build pools are partitioned and the majority build wins. The core requires an explicit opt-in for its embedded snapshot; lantern-box currently enables that fallback, so a working connection does not prove the fingerprint is fresh.
Sanitise before persistence: real browsing captures expose visited SNI names and may contain
a site's resumption ticket. The historical sample above was captured against localhost.
A cover is a complete profile, not a hostname pasted over arbitrary bytes. The consolidated cloud provisioning work requires a measured identity and keeps the dial address pinned to the provisioned egress IP after DNS validation. SNI/address consistency remains a deployment constraint, not something a plausible-looking ClientHello fixes.
Full-handshake design · ECH measurements · Chrome 152 capture log
09 — implementation ledger · 5 September 2026
| Layer | Status | What it means |
|---|---|---|
| Core transport | Merged | Full and resumed carriers, per-cover profiles, contact memory, ticket rotation, and a shared replay gate. Twiddle #3. |
| Record emission | Fixed on main | Twiddle #2 serializes sealing, nonce-sequence advancement, and socket writes. Concurrent Write/Close must neither reuse a nonce nor reorder records. Consumers must update their pin to include c78665a or a descendant. |
| lantern-box adapter | Released base | v0.0.123 wires companion tickets and contact memory; a disable_full_handshake switch is available. Full mode remains gated on a measured full profile. |
| Cloud provisioning | Open PR | lantern-cloud #3291 consolidates #3292: profile validation, per-client ticket pairs, and pinned egress dialing. This is not a deployment announcement. |
| UDP tunneling | Under review | lantern-box #321 adds UoT, matching Samizdat: connected UDP and multi-destination packets inside Twiddle's TCP stream. Both endpoints need the adapter update; this is not native UDP or QUIC transport. |
| Multiplexing & credential reuse | Open gap | The earlier #319 was closed without merging. The released adapter's credential reuse can conflict with the core's single-use replay gate during concurrent openings. |
Per-egress full-profile probing and client activation still need integration. The fixed
370-byte rotation records are a known cover-fidelity mismatch. The varying body of Chrome 152's
0xca34 extension is sampled from captures, not regenerated. A replay cache pushed to
its client-capacity limit can evict a still-valid entry. These bounds matter alongside packet
sizes, timing, and destination consistency.
The nonce-race fix does not make shutdown bounded: Close can still wait on a
stalled socket without a caller-supplied write deadline. Passing local and CI tests is evidence
about tested behavior, not a claim that every observer sees ordinary TLS.