getlantern/twiddle

Ordinary
on the wire.

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

Opaque payloads. Observable shapes.

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.

RecordTypeFidelity required
ClientHello0x16exact — harvested from a real browser
ChangeCipherSpec0x14one fixed byte
ServerHello0x161215 B full / 1221 B resumed in the measured profiles
compatibility ChangeCipherSpec0x14fixed framing, before encrypted records
encrypted handshake, data & alerts0x17opaque contents; observable lengths, counts, and timing

02 — the bytes

A real ClientHello, and what we touch

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.

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.

copied verbatim twiddled length, recomputed appended by us

    03 — the rewrites

    Exactly what changes

    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.

    On every connection

    FieldWhat we doExact behavior
    server_namereplace hostnameUse the configured cover SNI when provided. Recompute its nested lengths; do not compensate with padding to preserve the original message size.
    randomfresh entropy / MACFresh 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_idfresh bytes, same lengthPreserve the template's length, usually 32 bytes. The server echoes this value, so anything hidden here appears twice on the wire.
    key_share: X25519fresh valid public keyThe standalone X25519 entry is replaced with a newly generated public key. Twiddle retains its private key for the actual tunnel key agreement.
    key_share: X25519MLKEM768fresh valid hybrid shareIf 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: GREASEzero placeholder contentsThe 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_hellorefresh outer GREASE ECHFresh 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 identifiersredraw from 16 valuesReplace 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 ordershuffle the interiorUniform Fisher–Yates permutation, with the leading GREASE pinned first and trailing GREASE pinned last. On resumption, pre_shared_key follows the trailing GREASE.
    lengths & framingserialize againRecompute 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.

    Authentication: two different final edits

    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.

    What stays unchanged

    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.

    Before that: import-time sanitization

    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

    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. 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.

    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)

    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.

    A first contact should not already be a resumption

    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

    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 auth + replay gate AUTHENTICATED ServerHello · 1215 / 1221 B measured encrypted reply flight 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. Replacement credentials are sent in two ticket-shaped records after both Finished stand-ins, outside the opening burst.

    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 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

    A pool is not a browser update strategy

    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

    What landed. What still needs work.

    LayerStatusWhat it means
    Core transportMergedFull and resumed carriers, per-cover profiles, contact memory, ticket rotation, and a shared replay gate. Twiddle #3.
    Record emissionFixed on mainTwiddle #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 adapterReleased basev0.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 provisioningOpen PRlantern-cloud #3291 consolidates #3292: profile validation, per-client ticket pairs, and pinned egress dialing. This is not a deployment announcement.
    UDP tunnelingUnder reviewlantern-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 reuseOpen gapThe 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.

    Known limits, not hidden guarantees

    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.