Samizdat is a censorship circumvention protocol that makes proxy traffic indistinguishable from a browser visiting a real website over HTTP/2. It is designed to defeat the full spectrum of modern DPI techniques deployed by nation-state censors, grounded in the latest academic research from FOCI, USENIX Security, ACM CCS, NDSS, and PETS.
The name comes from the Russian word самиздат — the clandestine copying and distribution of literature banned by the state.
Russian DPI infrastructure (TSPU) has evolved to block nearly every existing circumvention protocol. Live testing reveals the following active blocking techniques:
- First-packet entropy detection fingerprinting Shadowsocks and other encrypted protocols
- All UDP blocked at the network level
- TLS-over-TLS detection catching nested TLS tunnels with >70% accuracy
- 15-20KB data thresholds triggering inspection and blocking
- TLS connection count policing on port 443
- Active probing of suspected proxy servers
- Post-handshake fingerprinting of distinctive client info exchanges
- SNI extraction via TCP DPI to enforce domain blocklists
Samizdat defeats all of these simultaneously.
Samizdat uses a single standard TLS 1.3 connection with HTTP/2 multiplexed CONNECT tunnels — exactly what a browser does when using an HTTPS proxy. There is no TLS-over-TLS nesting.
Client Server
| |
| TCP SYN (Geneva frag on ClientHello) |
| TLS 1.3 ClientHello (uTLS Chrome) --> | SNI = cover site (e.g. ok.ru)
| auth embedded in SessionID | Server verifies auth tag
| <-- ServerHello + real cover cert | If auth fails: serve real website
| |
| HTTP/2 SETTINGS exchange -----------> | Standard h2 negotiation
| |
| H2 CONNECT target:port (stream N) --> | Proxy tunnel per stream
| <-- 200 OK (stream N) | Multiple tunnels multiplexed
| DATA frames <-------> DATA frames | With padding + timing jitter
Authentication uses a PSK-based HMAC scheme embedded in the TLS SessionID field:
- Pre-shared: server X25519 public key + 8-byte short ID
- Per-connection: PSK derived via
HKDF-SHA256(serverPubKey, shortID, "SAMIZDAT") - SessionID layout:
shortID(8) || nonce(8) || HMAC-SHA256(PSK, nonce)[:16] - If auth fails: server enters masquerade mode (see below)
The client's TLS ClientHello is generated by uTLS with a Chrome fingerprint, making it byte-identical to a real Chrome browser at the wire level.
When a non-Samizdat client connects — including active probes — the server operates as a transparent TCP proxy to the real masquerade domain:
Active Probe Samizdat Server Real Domain (ok.ru)
| | |
|-- ClientHello (SNI=ok.ru) --> | |
| (no valid auth tag) | |
| |-- forward raw ClientHello --> |
| |<-- ServerHello + real cert --- |
|<-- ServerHello + real cert -- | |
| | |
| (bidirectional TCP proxy until either side closes) |
The server does NOT parse or modify any TLS/HTTP content. It forwards raw bytes bidirectionally. This makes the server indistinguishable from the real domain at every protocol layer — same certificate, same TLS behavior, same HTTP responses, same timing. Inspired by tlsmasq.
The ClientHello is split across multiple TCP segments at the SNI field boundary with randomized delays between fragments. This forces the DPI to reassemble TCP segments to extract the SNI, defeating stateless SNI inspection deployed by TSPU.
- Padding: H2 DATA frames are padded to match Chrome's traffic size distribution (4 buckets: small 0-128B, medium 128-1KB, large 1-4KB, XL 4-16KB)
- Timing jitter: 1-30ms random delay on outgoing frames defeats cross-layer RTT fingerprinting (NDSS 2025)
- Threshold evasion: After ~14KB on a connection, padding ratio increases and noise frames are interleaved
All tunneled connections are multiplexed over a single TLS+H2 connection. This reduces the number of TLS connections to one, defeating connection count policing on port 443. Per USENIX Security 2024, multiplexing reduces fingerprinting accuracy by 70%.
| Threat | Paper/Source | Countermeasure |
|---|---|---|
| First-packet entropy (Shadowsocks) | IMC 2020 | Standard TLS 1.3 ClientHello via uTLS Chrome |
| Active probing (7 types) | IMC 2020 | TCP-level masquerade to real domain |
| TLS-over-TLS detection (>70%) | USENIX Sec 2024 | Single TLS layer; inner TLS records fragmented across H2 DATA frames |
| Cross-layer RTT fingerprinting | NDSS 2025 | 1-30ms timing jitter on DATA frames |
| TCP DPI / SNI extraction | TSPU, IMC 2022 | Geneva TCP fragmentation at SNI boundary |
| UDP blocking | Russia | TCP-only design |
| 15-20KB data threshold | net4people #490 | Adaptive chunking + noise frame interleaving |
| TLS conn-count policing on 443 | net4people #546 | H2 multiplexing (one connection) |
| Post-handshake CLIENTINFO fingerprint | Lantern logs | Client info encrypted in standard H2 headers |
| NaiveProxy MTU / RST_STREAM leaks | — | Standard Go H2; no custom framing |
| Trojan active probing (90% detection) | — | PSK auth in TLS handshake + TCP masquerade |
go get github.com/getlantern/samizdatimport samizdat "github.com/getlantern/samizdat"
client, err := samizdat.NewClient(samizdat.ClientConfig{
ServerAddr: "proxy.example.com:443",
ServerName: "ok.ru", // cover site SNI
PublicKey: serverPublicKey, // 32-byte X25519 public key
ShortID: shortID, // 8-byte pre-shared identifier
Fingerprint: "chrome", // uTLS fingerprint
Padding: true, // traffic shaping
Jitter: true, // timing jitter
TCPFragmentation: true, // Geneva-style fragmentation
})
// Dial through the proxy (multiplexed over one TLS+H2 connection)
conn, err := client.DialContext(ctx, "tcp", "example.com:443")server, err := samizdat.NewServer(samizdat.ServerConfig{
ListenAddr: ":443",
PrivateKey: serverPrivateKey, // 32-byte X25519 private key
ShortIDs: [][8]byte{shortID}, // allowed client IDs
CertPEM: certPEM, // TLS certificate
KeyPEM: keyPEM,
MasqueradeDomain: "ok.ru", // real domain for active probes
Handler: func(ctx context.Context, conn net.Conn, destination string) {
// Handle proxied connection to destination
},
})
server.ListenAndServe()go run ./cmd/samizdat-server --genkeys
# Output:
# Private key: <hex>
# Public key: <hex>
# Short ID: <hex>go run ./cmd/samizdat-server \
-listen :443 \
-domain ok.ru \
-cert cert.pem \
-key key.pem \
-privkey <hex private key> \
-shortid <hex short id>Samizdat is split across two repositories:
The standalone protocol library. Zero sing-box dependencies. Can be integrated into any Go application.
samizdat/
├── samizdat.go # Config types and defaults
├── auth.go # PSK-based HMAC authentication
├── client.go # Client dialer (uTLS + H2 CONNECT)
├── server.go # Server listener (auth check + H2 proxy)
├── h2transport.go # HTTP/2 multiplexed CONNECT transport
├── streamconn.go # net.Conn wrapper over H2 stream
├── connpool.go # H2 connection pooling
├── fragmenter.go # Geneva-inspired TCP fragmentation
├── shaper.go # Traffic shaping (padding + jitter)
├── masquerade.go # TCP-level transparent proxy for active probes
├── samizdat_test.go # Unit tests
├── integration_test.go # Client-server integration tests
└── cmd/samizdat-server/ # Standalone server binary
Thin wiring layer that registers samizdat as a sing-box outbound/inbound. Only adapter code — maps sing-box options to samizdat config.
golang.org/x/net/http2 # HTTP/2 implementation
golang.org/x/crypto/curve25519 # X25519 for key generation
golang.org/x/crypto/hkdf # Key derivation
github.com/refraction-networking/utls # Chrome TLS fingerprint
- Wang et al., "How China Detects and Blocks Shadowsocks," IMC 2020
- Wu et al., "Detecting TLS-over-TLS with Cross-Layer Analysis," USENIX Security 2024
- Xue et al., "Cross-Layer RTT Fingerprinting of Encrypted Tunnels," NDSS 2025
- Bock et al., "Geneva: Evolving Censorship Evasion Strategies," ACM CCS 2019
- Hoang et al., "Measuring I2P Censorship at a Global Scale," FOCI 2019
- net4people/bbs #490 — Russia DPI data threshold analysis
- net4people/bbs #546 — TLS connection count policing
The following is a self-contained program that starts a Samizdat server, connects a client through it, and sends data through the tunnel. It generates all credentials and a self-signed certificate on the fly.
package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"io"
"log"
"math/big"
"net"
"sync"
"time"
samizdat "github.com/getlantern/samizdat"
)
func main() {
// --- 1. Generate credentials ---
serverPriv, serverPub, err := samizdat.GenerateKeyPair()
if err != nil {
log.Fatal(err)
}
shortID, err := samizdat.GenerateShortID()
if err != nil {
log.Fatal(err)
}
certPEM, keyPEM := selfSignedCert()
// --- 2. Start a TCP echo server (the "destination" behind the proxy) ---
echoLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatal(err)
}
defer echoLn.Close()
go func() {
for {
c, err := echoLn.Accept()
if err != nil {
return
}
go func() {
defer c.Close()
io.Copy(c, c) // echo back everything
}()
}
}()
// --- 3. Start the Samizdat server ---
server, err := samizdat.NewServer(samizdat.ServerConfig{
ListenAddr: "127.0.0.1:0",
PrivateKey: serverPriv,
ShortIDs: [][8]byte{shortID},
CertPEM: certPEM,
KeyPEM: keyPEM,
Handler: func(ctx context.Context, conn net.Conn, destination string) {
defer conn.Close()
target, err := net.DialTimeout("tcp", destination, 5*time.Second)
if err != nil {
return
}
defer target.Close()
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); io.Copy(target, conn) }()
go func() { defer wg.Done(); io.Copy(conn, target) }()
wg.Wait()
},
})
if err != nil {
log.Fatal(err)
}
go server.ListenAndServe()
defer server.Close()
time.Sleep(100 * time.Millisecond) // wait for listener
// --- 4. Create a Samizdat client ---
client, err := samizdat.NewClient(samizdat.ClientConfig{
ServerAddr: server.Addr().String(),
ServerName: "localhost",
PublicKey: serverPub,
ShortID: shortID,
Fingerprint: "chrome",
Padding: true,
Jitter: true,
TCPFragmentation: false, // disabled for localhost test
})
if err != nil {
log.Fatal(err)
}
defer client.Close()
// --- 5. Dial through the tunnel to the echo server ---
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
conn, err := client.DialContext(ctx, "tcp", echoLn.Addr().String())
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// --- 6. Send and receive data ---
msg := []byte("Hello from the other side of the tunnel!")
if _, err := conn.Write(msg); err != nil {
log.Fatal(err)
}
buf := make([]byte, 256)
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
n, err := conn.Read(buf)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Sent: %s\n", msg)
fmt.Printf("Received: %s\n", buf[:n])
// Output:
// Sent: Hello from the other side of the tunnel!
// Received: Hello from the other side of the tunnel!
}
// selfSignedCert generates a self-signed TLS certificate for testing.
func selfSignedCert() (certPEM, keyPEM []byte) {
priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "localhost"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, _ := x509.MarshalECPrivateKey(priv)
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return
}See LICENSE file.