Skip to content

Commit 8b6083d

Browse files
myleshortonclaude
andcommitted
mitmca: NameConstraints-scoped CA + file keystore (foundation)
First slice of the MITM-DomainFronting outbound from engineering#3482. Lands the foundational crypto piece — a CA generator whose RFC 5280 NameConstraints extension cryptographically restricts the set of DNS names it can sign for — before any of the outbound machinery that will use it. What's in: - internal/mitmca/ca.go: CA struct, GenerateCA, LoadCA, SignLeaf, plus the in-Go pre-check that rejects out-of-subtree SNIs before even reaching the signer. - internal/mitmca/keystore.go: KeyStore interface and the default FileKeyStore implementation (PKCS#8 PEM, mode 0600, atomic rename). Per-platform hardware-backed implementations (Secure Enclave / Android StrongBox / TPM) satisfy the same interface and will land in follow-ups behind build tags. - internal/mitmca/ca_test.go: table-driven coverage for the constraint cases that matter — subdomain matching, subtree-suffix lookalikes ("notexample.com", "example.com.evil.com"), unconstrained-CA rejection on Load, fresh-vs-reloaded sign equivalence, file mode 0o600 (POSIX), Erase idempotence. - docs/mitm-df/architecture.md: design doc for the whole feature. Covers component map, package layout, public Go types, the sing-box outbound config schema, end-to-end request flow, and the security properties as enforced by this code. Not yet in (follow-ups): - internal/mitmdf: the outbound itself (TLS termination, fronts table, SPKI pinner, audit log, session loop). - option/mitmdf.go: sing-box config schema for type="mitm-df". - Hardware-backed keystores: keystore_darwin.go (Secure Enclave), keystore_android.go (StrongBox), keystore_windows.go (TPM/NCrypt), keystore_linux.go (TPM via PKCS#11 if available; fall back to file). - Flutter UI: opt-in toggle, per-platform CA-install flow, audit-log viewer. - Cross-repo: config-server push channel for fronts updates, weekly validation CI in lantern-cloud, kill-switch flag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9f277ec commit 8b6083d

4 files changed

Lines changed: 998 additions & 0 deletions

File tree

docs/mitm-df/architecture.md

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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

Comments
 (0)