Skip to content

Censorship resistant protocol using latest research and practice.

Notifications You must be signed in to change notification settings

getlantern/samizdat

Repository files navigation

Samizdat

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.

Why Samizdat?

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.

Protocol Design

Single TLS Layer with HTTP/2 CONNECT

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

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.

Masquerade (Active Probe Resistance)

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.

Geneva-Inspired TCP Fragmentation

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.

Traffic Shaping

  • 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

H2 Multiplexing

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 Model

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

Installation

go get github.com/getlantern/samizdat

Usage

Client

import 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

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

Key Generation

go run ./cmd/samizdat-server --genkeys
# Output:
# Private key: <hex>
# Public key:  <hex>
# Short ID:    <hex>

Standalone Server

go run ./cmd/samizdat-server \
    -listen :443 \
    -domain ok.ru \
    -cert cert.pem \
    -key key.pem \
    -privkey <hex private key> \
    -shortid <hex short id>

Architecture

Samizdat is split across two repositories:

getlantern/samizdat (this repo)

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.

Dependencies

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

References

  • 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

Minimal End-to-End Example

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
}

License

See LICENSE file.

About

Censorship resistant protocol using latest research and practice.

Resources

Stars

Watchers

Forks

Packages

No packages published

Contributors 3

  •  
  •  
  •  

Languages