Proxy traffic inside an ordinary TLS 1.3 and HTTP/2 connection to a real website, with the start of each tunnel connection shaped like real TLS.
A censor can watch a connection, or it can poke at the server. Samizdat is built so that both see a browser and a website. The client connects to the server's IP address and names a cover site in the TLS handshake. The server is not that site. It holds the key to tell its own clients from everyone else, and hands everyone else to the real cover site.
In late 2025, researchers at the University of Michigan and elsewhere recorded 326,531,763 real TLS flows at a regional ISP and built a model of how real TLS connections begin: the size and direction of each packet after the handshake. They then measured how far nine circumvention protocols stray from it, using KL divergence (in nats; 0 means identical).
Their conclusion: all of them are distinguishable from real-world TLS
, as early as the first post-handshake packet
.
Trojan, VMess, VLESS and XTLS Vision send unusually many 1,208-byte packets in their first three packets after the handshake. Real TLS does that at the first packet only 0.38% of the time. The cause is Go's dynamic TLS record sizing, which Xray leaves on.
CenKL §4, Fig. 3 a–dof XTLS Vision connections send a 1,208-byte packet first. Vision pads the inner TLS handshake to 900–1,400 bytes, and 38.4% of that range is above 1,208, so the padding makes the record-sizing tell more common, not less.
CenKL §4from NaïveProxy's first packets, and 27.81 from VLESS-over-gRPC. HTTP/2 tunnels open with a connection preface, SETTINGS and a CONNECT in a fixed pattern, which real TLS rarely produces. Samizdat's tunnel is HTTP/2 too.
CenKL §4, Fig. 3 f–gThe authors' countermeasure, ProxyShaper, reshapes a connection's first 10 TLS records with sizes and directions drawn from their model. Against VLESS, it cut a gradient-boosted classifier's precision from 0.977 to 0.830. It helps; it doesn't make a proxy invisible.
CenKL §5, Table 3Samizdat now does what ProxyShaper does, by default at both ends. Both sides derive a shared seed from the TLS session's exported keying material, draw the same ten-record schedule from CenKL's released order-1 Markov model, and send exactly those record sizes in those directions, carrying HTTP/2 data inside and padding the rest. It also turns off Go's dynamic record sizing and drops TLS session tickets, which added a tell of their own.
Nats of KL divergence from CenKL's size-and-direction model, first 10 packets after the handshake, 800 flows each on loopback, segmented at a 1,448-byte MSS. The floor isn't zero because 800 flows is a finite sample: real TLS drawn from the model itself scores 11.3. These are our measurements with the authors' released model, not the paper's; see samizdat#29.
The 1,448-byte cap matters. CenKL's model counts 1,460-byte packets, from senders without TCP timestamps. Linux servers send timestamps and fit 1,448 bytes, so a scheduled 1,460-byte record leaves as two packets, 1,448 and 12. The paper notes the same mismatch in its own testbed. Samizdat leaves sizes above 1,448 out of the schedule.
How samizdat's defaults compare with the protocols it's most often weighed against. Every claim about another protocol comes from its source code, its authors, or a peer-reviewed measurement, linked in the cell. Where no one has measured something, the table says so. Nobody outside Lantern has evaluated samizdat yet.
| Samizdat | VLESS + TLS | VLESS + Vision | REALITY | Trojan | NaïveProxy | |
|---|---|---|---|---|---|---|
| Client auth | X25519 secret with the client's key share; AES-GCM seals time and short ID into the SessionID, bound to the whole ClientHello | UUID, sent inside TLSXray docs | Same as VLESS | X25519 secret with the client's key share; AES-GCM seals version, time and short ID into the SessionID, bound to the whole ClientHelloREALITY tls.go | SHA-224 of the password, sent inside TLSTrojan protocol | HTTP proxy authorization, inside TLSNaïveProxy README |
| What a prober gets | Real cover site Raw bytes forwarded to it: its certificate, its TLS stack, its pages |
Operator's fallback A configured fallback server, behind the operator's own certificateinbound.go |
Operator's fallback Same as VLESS |
Real target site Raw bytes piped to the targettls.go |
Operator's fallback A preset endpoint, 127.0.0.1:80 by default, behind the operator's certificateTrojan protocol |
Operator's web server Caddy serving a site, behind the operator's certificateREADME |
| Replaying a captured handshake | Refused Accepted SessionIDs are remembered for 24–48 h (less past a million per window) and sent to the cover site if seen again; version 2 tags also expire after 12 h. Memory only: a restart forgets. |
Not applicable Auth is inside TLS, so a copied ClientHello carries none |
Not applicable | No replay cache The time check is off by default ( maxTimeDiff 0). A 2026 proof of concept tells REALITY apart by replaying handshakes.handshake_server_tls13.go · net4people #576 |
Not applicable | Not applicable |
| If the cover site is down | Clients still connect; only probes are affected | Not applicable | Not applicable | Clients fail too The server dials the target on every connection and closes the client's if that failstls.go |
Not applicable | Not applicable |
| Multiplexing | Always HTTP/2 CONNECT: one TLS connection carries up to 100 streams |
Off by default Mux is optionaloutbound.go |
Off Vision breaks mux connections that carry TCPoutbound.go |
Not recommended Its README pairs it with VisionREADME |
No One tunnel per request |
Yes HTTP/2 CONNECT |
| How connections start | Shaped First 10 records drawn from CenKL's model of real TLS; dynamic record sizing off. 12.4 nats against an 11.3 floor in our measurement. |
Unshaped Go's 1,208-byte records stand out in the first three packetsCenKL §4 |
Padded Inner handshake padded to 900–1,399 bytes; 71.7% of connections send a 1,208-byte first packetCenKL §4 · proxy.go |
Not measured CenKL didn't test REALITY; with Vision it carries Vision's padding |
Unshaped The same 1,208-byte recordsCenKL §4 |
Distinctive HTTP/2 opening adds 28.88 nats despite padding its first 8 reads and writesCenKL §4 |
| TLS-in-TLS detection (TPR) | Not measured Tunnelled TLS is still inside; HTTP/2 multiplexing is meant to blur it, most when several streams share the connection |
0.748Xue et al. 2024, Table 3 | 0.513 at 0.20% FPR, with a classifier built for VisionXue et al. 2024, §7.2 | Not measured | 0.737; 0.179 with 8 concurrent streams (trojan-go)Xue et al. 2024, Table 3 | 0.328Xue et al. 2024, Table 3 |
| Timing | Not shaped Optional 1–30 ms delay before each client write, which research suggests doesn't help (see Limits) |
Not shaped | Not shaped | Not shaped | Not shaped | Not shaped |
Xray-core read at c456141 and REALITY at 4a26e87 (November 2026). TPR = true positive rate of the attack in Xue et al., USENIX Security 2024; their deployment measured false positives over 110 million real flows. Neither paper evaluated REALITY.
Samizdat's version 2 authentication is REALITY's idea, implemented independently: an X25519 secret between the client's ordinary key share and the server's static key, an AES-GCM seal over a time and a short ID, and the whole ClientHello as associated data, so a copied tag fails in any other handshake. The differences are in what surrounds it: samizdat remembers what it has accepted, checks the time by default, doesn't need the cover site to be reachable for its own clients, multiplexes over HTTP/2, and shapes the start of the connection. REALITY hands authenticated clients a throwaway certificate signed with the shared key; samizdat serves its own and lets clients pin it.
The client takes an X25519 secret between its own key share and the server's public key, derives an AES-256-GCM key with HKDF over the ClientHello's random, and seals version ‖ time ‖ short ID with the rest of the ClientHello as associated data. The 32 bytes that come out replace the SessionID. Older clients send version 1, an HMAC tag; servers accept both until told otherwise.
A connection that doesn't authenticate is spliced, raw, to the cover site's real address. The server reads only the ClientHello it needs to check the tag, sends it on to the cover site, and copies bytes both ways, without rewriting them or terminating TLS, until both sides close or ten minutes pass.
Both ends export keying material from the finished TLS session. 32 bytes seed a ChaCha8 sampler that draws ten records' sizes and directions from the model; 8 bytes key an HMAC over the schedule, and its first 8 bytes are a marker that tells the server this client shapes. During the schedule each side sends exactly its scheduled records, carrying queued HTTP/2 bytes and padding. After the tenth, data passes straight through.
Every proxied connection is an HTTP/2 CONNECT stream on a long-lived TLS connection that carries up to 100 of them, so a censor counting TLS connections to an address sees few. Servers run samizdat's own lean HTTP/2 server, which batches frames into fewer TLS records and system calls: 66–90% less server CPU per gigabyte downloaded than Go's standard one in benchmarks, with the same frames on the wire.
Optionally, the ClientHello is written in pieces split around the SNI, with a short random delay, in the style of Geneva, so a middlebox that reads the SNI from a single packet often doesn't find it there. It's best effort: the split can fall just outside the hostname.
A server reads the first 8 bytes after the handshake. The shaping marker means a shaping client; an HTTP/2 preface means an older one, which is served exactly as before. New servers serve every client released so far; new clients need new servers.
No proxy that carries real traffic is invisible. These are the gaps we know about.
Samizdat is a Go library with no sing-box dependency, and an inbound and outbound in lantern-box. Lantern's apps use it today.
go get github.com/getlantern/samizdat
client, err := samizdat.NewClient(samizdat.ClientConfig{
ServerAddr: "203.0.113.7:443",
ServerName: "cover.example", // the cover site named in the handshake
PublicKey: serverPublicKey, // 32-byte X25519 public key
ShortID: shortID, // 8-byte client identifier
Fingerprint: "chrome",
})
conn, err := client.DialContext(ctx, "tcp", "example.com:443")
// lantern-box (sing-box) outbound
{
"type": "samizdat",
"tag": "samizdat-out",
"server": "203.0.113.7",
"server_port": 443,
"public_key": "<64 hex chars>",
"short_id": "<16 hex chars>",
"server_name": "cover.example",
"cert_spki_sha256": "<optional pin, 64 hex chars>"
}
Generate keys with go run ./cmd/samizdat-server --genkeys. Pick a cover site that's reachable from the server, serves TLS 1.3 with HTTP/2, and is unremarkable on the network you're getting around.