|
| 1 | +# MITM-DomainFronting outbound — architecture |
| 2 | + |
| 3 | +**Status**: design + first implementation slice (CA generator). Tracking issue: [getlantern/engineering#3482](https://github.com/getlantern/engineering/issues/3482). |
| 4 | + |
| 5 | +This document describes how lantern-box implements **client-only domain fronting** via local TLS MITM with a `NameConstraints`-scoped CA. The technique is a port of the patterniha/MITM-DomainFronting concept (now upstream in XTLS/Xray-core via PR #4348) into the sing-box outbound model, with material security hardening (`NameConstraints`, hardware-backed keys, 127.0.0.1-only binding, denylist, audit log). |
| 6 | + |
| 7 | +The "why" — including why the on-the-wire SNI cannot simply be byte-swapped (TLS transcript HMAC binding), and the threat-model analysis that motivates the hardening — lives on engineering#3482. **Read that first** if you haven't yet. This doc is the "how." |
| 8 | + |
| 9 | +## Scope |
| 10 | + |
| 11 | +In: Android, iOS, macOS, Windows, Linux (where lantern-box ships). |
| 12 | +Out: web extension / browser-only contexts; iOS without VPN entitlement; rooted vs unrooted parity is handled by the platform's own trust-store rules, not by us. |
| 13 | + |
| 14 | +The feature is **opt-in**, off by default. Users who opt in install a per-device, name-constrained CA into their trust store. Users who don't opt in are unaffected. |
| 15 | + |
| 16 | +## Component map |
| 17 | + |
| 18 | +```mermaid |
| 19 | +graph TB |
| 20 | + Browser["Browser / OS HTTPS client<br/>(trusts our scoped CA after opt-in)"] |
| 21 | + TUN["TUN inbound<br/>(VPN service)"] |
| 22 | + Router["sing-box router<br/>(SNI sniffing)"] |
| 23 | + Tunnel["outbound: tunnel<br/>→ Lantern proxies"] |
| 24 | + Direct["outbound: direct<br/>(local/private bypass)"] |
| 25 | + MITM["outbound: mitm-df (NEW)<br/>internal/mitmdf"] |
| 26 | + CA["internal/mitmca<br/>(NameConstraints CA,<br/>hardware-backed keys)"] |
| 27 | + CDN["CDN edge<br/>(Vercel / Fastly / Google /<br/>Netlify-on-CloudFront / GitHub)"] |
| 28 | +
|
| 29 | + Browser -->|TLS, SNI=destination| TUN |
| 30 | + TUN --> Router |
| 31 | + Router -->|matched front domain| MITM |
| 32 | + Router -->|everything else| Tunnel |
| 33 | + Router -->|private/local| Direct |
| 34 | + MITM -.uses.-> CA |
| 35 | + MITM -->|TLS w/<br/>fronted SNI on outer,<br/>real Host on inner| CDN |
| 36 | + Tunnel --> CDN |
| 37 | +``` |
| 38 | + |
| 39 | +## Package layout |
| 40 | + |
| 41 | +``` |
| 42 | +lantern-box/ |
| 43 | + internal/ |
| 44 | + mitmca/ ← NameConstraints CA generator + key storage |
| 45 | + ca.go ← CA struct, GenerateCA, SignLeaf, Serialize |
| 46 | + ca_test.go ← table-driven tests; cert verification against scoped SAN list |
| 47 | + keystore.go ← per-platform key storage abstraction (file-based default; |
| 48 | + hardware-backed via build tags: keystore_macos.go, |
| 49 | + keystore_android.go, keystore_windows.go) |
| 50 | + keystore_test.go |
| 51 | + mitmdf/ ← The MITM-DF outbound itself |
| 52 | + outbound.go ← sing-box outbound type; implements N.Dialer |
| 53 | + fronts.go ← Fronts table parsing, matcher |
| 54 | + session.go ← per-connection MITM session (TLS terminate ↔ TLS re-dial) |
| 55 | + pinner.go ← SPKI pinning for the outbound (proxy→CDN) TLS |
| 56 | + audit.go ← Audit log writer (every mint) |
| 57 | + *_test.go |
| 58 | + option/ |
| 59 | + mitmdf.go ← Config struct for "type": "mitm-df" outbounds |
| 60 | + docs/ |
| 61 | + mitm-df/ |
| 62 | + architecture.md ← this file |
| 63 | + operations.md ← runbook: weekly fronts validation, kill-switch path |
| 64 | + security.md ← residual-risk summary (link to engineering#3482) |
| 65 | +``` |
| 66 | + |
| 67 | +## Public Go types |
| 68 | + |
| 69 | +The `mitmca` package exposes a small API: |
| 70 | + |
| 71 | +```go |
| 72 | +package mitmca |
| 73 | + |
| 74 | +// CA is a name-constrained root CA used to sign leaf certificates for the |
| 75 | +// MITM-DF outbound's inbound TLS server. |
| 76 | +type CA struct { |
| 77 | + cert *x509.Certificate |
| 78 | + privateKey crypto.Signer // ECDSA P-256; may be hardware-backed |
| 79 | + permittedDomains []string // mirrors x509.NameConstraints.PermittedDNSDomains |
| 80 | +} |
| 81 | + |
| 82 | +// GenerateCA creates a fresh CA scoped to the given permitted DNS subtrees |
| 83 | +// (RFC 5280 §4.2.1.10 PermittedSubtrees). The private key is generated via |
| 84 | +// the supplied KeyStore — pass HardwareKeyStore on platforms that support it, |
| 85 | +// FileKeyStore otherwise. Validity defaults to 30 days; rotation is the |
| 86 | +// caller's responsibility. |
| 87 | +func GenerateCA(permittedDNS []string, ks KeyStore, validity time.Duration) (*CA, error) |
| 88 | + |
| 89 | +// LoadCA reloads a previously-saved CA from its serialized cert + the KeyStore. |
| 90 | +func LoadCA(certPEM []byte, ks KeyStore) (*CA, error) |
| 91 | + |
| 92 | +// CertPEM returns the CA cert in PEM form for trust-store installation. |
| 93 | +// The private key is intentionally not exposed; only the CA itself signs. |
| 94 | +func (c *CA) CertPEM() []byte |
| 95 | + |
| 96 | +// SignLeaf mints a leaf cert for the given SNI. The leaf SAN list is exactly |
| 97 | +// [sni]; the cert is short-lived (24h). Returns an error if the SNI does not |
| 98 | +// fall within the CA's permitted subtrees. |
| 99 | +func (c *CA) SignLeaf(sni string) (*tls.Certificate, error) |
| 100 | + |
| 101 | +// KeyStore abstracts private-key storage. Implementations: |
| 102 | +// - FileKeyStore: PKCS#8-encoded ECDSA on disk, mode 0600. |
| 103 | +// - HardwareKeyStore: per-platform Secure Enclave / Android Keystore / TPM. |
| 104 | +// Key material is never extractable; KeyStore.PrivateKey returns a signer |
| 105 | +// that calls into the hardware for each sign operation. |
| 106 | +type KeyStore interface { |
| 107 | + GenerateKey() (crypto.Signer, error) |
| 108 | + StoreKey(crypto.Signer) error |
| 109 | + LoadKey() (crypto.Signer, error) |
| 110 | + Erase() error |
| 111 | +} |
| 112 | +``` |
| 113 | + |
| 114 | +And the `mitmdf` package exposes: |
| 115 | + |
| 116 | +```go |
| 117 | +package mitmdf |
| 118 | + |
| 119 | +// Outbound is registered as sing-box outbound type "mitm-df". It owns a |
| 120 | +// local TLS server on 127.0.0.1:<configured port> plus per-front dial logic. |
| 121 | +type Outbound struct { |
| 122 | + ca *mitmca.CA |
| 123 | + fronts []FrontEntry |
| 124 | + deny DenyList |
| 125 | + audit *AuditLog |
| 126 | + pinner *Pinner |
| 127 | + // ... sing-box outbound machinery |
| 128 | +} |
| 129 | + |
| 130 | +// FrontEntry maps a set of inbound destination domains to a fronted SNI used |
| 131 | +// when dialing the CDN, plus the allowed SAN list the CDN's cert must satisfy. |
| 132 | +type FrontEntry struct { |
| 133 | + MatchDomains []string // sing-box domain matchers (geosite:, domain:, suffix:, ...) |
| 134 | + FrontedSNI string |
| 135 | + VerifySAN []string |
| 136 | + // optional: fallback list if FrontedSNI is itself blocked |
| 137 | + FrontedSNIFallbacks []string |
| 138 | +} |
| 139 | +``` |
| 140 | + |
| 141 | +## Config schema |
| 142 | + |
| 143 | +A working sing-box `outbounds[]` entry of type `mitm-df`: |
| 144 | + |
| 145 | +```jsonc |
| 146 | +{ |
| 147 | + "type": "mitm-df", |
| 148 | + "tag": "mitm-df", |
| 149 | + "listen_addr": "127.0.0.1:11777", |
| 150 | + "ca": { |
| 151 | + "cert_path": "$DATA_DIR/mitm-ca.crt", |
| 152 | + "key_storage": "auto" // "auto" = hardware if available else file |
| 153 | + }, |
| 154 | + "fronts": [ |
| 155 | + { |
| 156 | + "match_domains": ["geosite:google", "domain:googleapis.com"], |
| 157 | + "fronted_sni": "www.google.com", |
| 158 | + "verify_san": ["www.google.com", "dns.google", "www.googlevideo.com"] |
| 159 | + }, |
| 160 | + { |
| 161 | + "match_domains": ["geosite:vercel", "domain:nextjs.org"], |
| 162 | + "fronted_sni": "nextjs.org", |
| 163 | + "verify_san": ["nextjs.org", "vercel.com", "vercel.app", "react.dev"] |
| 164 | + }, |
| 165 | + { |
| 166 | + "match_domains": ["geosite:fastly", "geosite:reddit", "domain:github.com", |
| 167 | + "domain:raw.githubusercontent.com"], |
| 168 | + "fronted_sni": "www.python.org", |
| 169 | + "verify_san": ["www.python.org", "github.com", "reddit.com", |
| 170 | + "githubusercontent.com"] |
| 171 | + }, |
| 172 | + { |
| 173 | + "match_domains": ["geosite:netlify"], |
| 174 | + "fronted_sni": "kubernetes.io", |
| 175 | + "verify_san": ["kubernetes.io", "letsencrypt.org", "aws.amazon.com"] |
| 176 | + } |
| 177 | + ], |
| 178 | + "deny_domains": ["geosite:banks-ir", "geosite:gov-ir", "geosite:healthcare"], |
| 179 | + "audit_log_path": "$DATA_DIR/mitm-df-audit.log", |
| 180 | + "client_hello_fingerprint": "chrome" |
| 181 | +} |
| 182 | +``` |
| 183 | + |
| 184 | +Route rules that send matching traffic to it: |
| 185 | + |
| 186 | +```jsonc |
| 187 | +"route": { |
| 188 | + "rules": [ |
| 189 | + { "domain": ["geosite:google", "geosite:vercel", "geosite:fastly", |
| 190 | + "geosite:reddit", "geosite:netlify", "geosite:github"], |
| 191 | + "outbound": "mitm-df" }, |
| 192 | + { "ip_is_private": true, "outbound": "direct" }, |
| 193 | + { "outbound": "tunnel" } |
| 194 | + ] |
| 195 | +} |
| 196 | +``` |
| 197 | + |
| 198 | +## Request flow |
| 199 | + |
| 200 | +1. Browser opens TCP to `vercel.com:443` and emits a ClientHello with `SNI=vercel.com`. |
| 201 | +2. TUN inbound captures the packet; sing-box parses the ClientHello and extracts the SNI without breaking TLS. |
| 202 | +3. Router rule `domain:geosite:vercel` → `outbound: mitm-df`. |
| 203 | +4. The `mitm-df` outbound's local TLS server accepts the TCP stream (still containing the original ClientHello bytes). |
| 204 | +5. `tls.Config.GetCertificate(hello)` callback: |
| 205 | + - Reads `hello.ServerName = "vercel.com"`. |
| 206 | + - Checks against the `deny` list — block if matched. |
| 207 | + - Verifies `"vercel.com"` falls under one of the CA's `permittedDomains` (defense in depth — CA itself enforces this cryptographically too). |
| 208 | + - Calls `ca.SignLeaf("vercel.com")` to mint a 24-hour leaf cert. |
| 209 | + - Logs `{ts, sni, fronted_sni, decision}` to the audit log. |
| 210 | + - Returns the leaf as the cert for this handshake. |
| 211 | +6. Browser's TLS handshake completes against our server. Browser sends HTTP request inside. |
| 212 | +7. `mitmdf.session` reads the inner HTTP request, extracts the Host header. |
| 213 | +8. Front-selection: look up `vercel.com` in `fronts`; pick `fronted_sni: nextjs.org` and `verify_san: [...]`. |
| 214 | +9. Dial the CDN: TCP connect to the resolved IP of vercel.com, then a fresh outbound TLS handshake with `ServerName: "nextjs.org"` and a uTLS Chrome fingerprint (`utls` library). |
| 215 | +10. SPKI pin check on the CDN's cert: must chain to a known CDN intermediate and the leaf's SAN list must intersect `verify_san`. |
| 216 | +11. Send the inner HTTP request over the outbound TLS connection, **with `Host: vercel.com` preserved**. |
| 217 | +12. Pipe response back to browser over the inbound (CA-signed) TLS connection. |
| 218 | + |
| 219 | +## Security properties (reflected from engineering#3482) |
| 220 | + |
| 221 | +| Property | How it's enforced | |
| 222 | +|---|---| |
| 223 | +| CA can only sign certs for the supported CDN families | RFC 5280 `NameConstraints` on the root cert; `tls.Config.GetCertificate` also rejects outside `permittedDomains` before minting | |
| 224 | +| Private key not exfiltrable | `HardwareKeyStore` where supported (Secure Enclave, Android StrongBox, TPM); `FileKeyStore` is the fallback with mode 0600 + DPAPI/keyring wrap | |
| 225 | +| Compromise blast radius = one device | Per-device key generation; no bundled CA in the binary | |
| 226 | +| MITM port not reachable from network | Listener bound `127.0.0.1` only | |
| 227 | +| Sensitive domains never minted | `deny_domains` enforced in `GetCertificate` callback regardless of route rules | |
| 228 | +| Stale CA after uninstall | Uninstall flow surfaces trust-store removal; on supported platforms, automated where possible | |
| 229 | +| CA expiry forces rotation | 30-day validity; expiry triggers regeneration + re-install prompt | |
| 230 | +| Old platforms without `NameConstraints` enforcement | Outbound refuses to start on Android < 9 / Windows < 10; `deny_domains` is the only defense; this is opted out of by default | |
| 231 | + |
| 232 | +## Operational dependencies |
| 233 | + |
| 234 | +- **Config-server channel** for `fronts` table updates — same channel that pushes tracks/outbounds today (`getlantern/lantern-cloud` `/v1/config-new` endpoint). Updates apply without a binary release. |
| 235 | +- **Weekly CI validation job** in `getlantern/lantern-cloud` that connects to each `(fronted_sni, real_destination)` pair, checks SAN intersection, and opens a GH issue if a pairing breaks. |
| 236 | +- **Kill switch**: a flag in the config response (`mitm_df_enabled: false`) disables the outbound regardless of local config; takes effect on the next config fetch (~5 min). |
| 237 | + |
| 238 | +See `docs/mitm-df/operations.md` for the runbook (not yet written). |
| 239 | + |
| 240 | +## Implementation order |
| 241 | + |
| 242 | +Roughly the order we'll ship the foundation in: |
| 243 | + |
| 244 | +1. **`internal/mitmca`** — CA generator, name constraints, file-based keystore, tests. *Starting point of the first PR; lives behind no feature flag, ships even before the outbound is wired.* |
| 245 | +2. **`internal/mitmca/keystore_*.go`** — per-platform hardware-backed key storage. |
| 246 | +3. **`internal/mitmdf/fronts.go`** — fronts-table parsing and matcher; reusable against sing-box's domain matchers. |
| 247 | +4. **`internal/mitmdf/session.go`** — per-connection MITM session. |
| 248 | +5. **`internal/mitmdf/outbound.go`** — sing-box outbound registration. |
| 249 | +6. **`option/mitmdf.go`** — config schema. |
| 250 | +7. **Cross-repo wiring**: Flutter UI, config-server push channel, weekly validation CI. |
| 251 | + |
| 252 | +Each step has its own PR. This doc gets updated as the design evolves. |
0 commit comments