From 31054450fc62f84cfe419f6c471aad124942c03b Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 8 Jul 2026 18:57:19 +0200 Subject: [PATCH] docs: zero-touch tunnel enrollment automation plan (research + 3-track design + cross-validation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-agent plan (11 agents, 70 web sources): fully automate cert enrollment so customers never handle certs/keys. Chosen: authenticated-CSR REST (k8s TLS-bootstrap model) + RFC 8628 device grant for host bootstrap; hardware keygen (Secure Enclave / Android Keystore) + CSR for clients; Model-A→B via dNSName-SAN==Host binding at nginx :8470 (njs) + per-tenant name-constrained intermediate CAs; short-lived certs + passive revocation. Reuses control-plane CA, S2 install tooling, iOS ClientTLS. --- docs/PLAN_TUNNEL_AUTOMATION.md | 315 +++++++++++++++++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 docs/PLAN_TUNNEL_AUTOMATION.md diff --git a/docs/PLAN_TUNNEL_AUTOMATION.md b/docs/PLAN_TUNNEL_AUTOMATION.md new file mode 100644 index 0000000..958f284 --- /dev/null +++ b/docs/PLAN_TUNNEL_AUTOMATION.md @@ -0,0 +1,315 @@ +# Master Plan — Zero-Touch Tunnel Enrollment (customers never handle certs/keys) + +> Status: design locked for build. Supersedes the manual M1+M2 bring-up. Target: a customer installs the host agent with **one command / one click** and installs the app and **logs in once** — keys are generated in hardware locally, CSRs go to the control-plane, certs + subdomain come back, frpc + base-app run as durable services. **No .p12, no AirDrop, no manual import; private keys never leave the device.** +> +> This plan integrates the four research digests and **resolves every CRITICAL/HIGH cross-validation finding** (each resolution is tagged `[FIX C-#/H-#]` inline and summarized in §6). + +--- + +## 1. Overview — chosen protocol & PKI model + +### 1.1 Enrollment protocol: keep the bespoke authenticated-CSR REST API (do **not** adopt EST/SCEP/CMP/ACME wholesale) + +The control-plane already **is** the recommended pattern: a real X.509 CA + PKCS#10 CSR parse/verify + provisioning endpoints, gated by a single-use bootstrap credential. This is exactly the **Kubernetes CSR-API / kubelet TLS-bootstrap model** (bootstrap token → authenticated CSR → signer issues a short-lived cert), the proven design for human-free enrollment. We harden it rather than bolt on a heavyweight protocol. + +- **HOST bootstrap** = single-use, short-TTL pairing code, minted via **OAuth 2.0 Device Authorization Grant (RFC 8628)** so the human just approves in the already-logged-in app — no copy-paste secret, public client, no `client_secret`. +- **DEVICE bootstrap** = the user's **account login session** (a narrowly-scoped `device:enroll` capability token), mirroring the same "bootstrap credential, not the cert" rule (chicken-and-egg: the one endpoint that can't require a client cert). +- **Proof-of-possession** = the CSR self-signature over the embedded public key; the CA verifies it before signing (existing `ca/csr.ts`, extended to EC P-256). +- **Attestation** = best-effort challenge layer (Android Key Attestation chain→Google root; iOS App Attest), verified **off-device**; records `attestationLevel`. Mirrors the accepted "best-effort FCM" decision. Becomes **mandatory before a second distrusting tenant**. +- Keep **EST (RFC 7030)** as the only "standard" worth adopting later (HTTPS+mTLS `/simpleenroll` — smallest delta) if we ever need an off-the-shelf client. **Reject SCEP** (shared secret, weak crypto, no attestation/revocation) and **vanilla ACME** (validates domain control, not device/user identity; only ACME device-attest-01 draft is relevant). + +Citations: kubelet TLS bootstrapping & CSR API (kubernetes.io); RFC 8628 device grant (datatracker/oauth.net); EST vs SCEP vs CMP (keyfactor, codegic, sslinsights); Android/Apple attestation (developer.android.com/source.android.com, developer.apple.com App Attest); AKS bootstrap-token exploit (synacktiv). + +### 1.2 PKI model: **Model A now (single closed fleet), Model B before the 2nd customer** — with the load-bearing control shipped in B1 immediately + +Model A = one shared device-CA = **any valid cert reaches any subdomain** (skeleton key, zero cross-tenant isolation). This is acceptable only for our own closed device set. The **load-bearing fix is not a fancier CA — it is binding each cert to its subdomain at the one proxy that terminates TLS (nginx :8470)**. + +Staged: + +- **B1 (cheap, ship first, even on the shared CA):** + 1. Stamp a **`dNSName` SAN = `.terminal.yaojia.wang`** into every leaf (host frp-client + device). **The `dNSName` (subdomain) is the enforcement key at :8470 — NOT the SPIFFE URI** `[FIX H-2]`. The SPIFFE URI SAN (identity/audit) is carried alongside. + 2. At :8470, enforce **cert-subject == requested Host/SNI**, `403` on mismatch, via **njs** parsing the SAN out of `$ssl_client_cert` (not fragile `$ssl_client_s_dn` RFC2253 string-matching). `map` + single `return 403`, never `if`. + 3. **Short-lived certs (24h host / 24h–7d device) + silent rotation** as the primary revocation mechanism ("passive revocation" = stop renewing → dies within TTL). Keep the CRL at :8470 as the emergency fast-kill. **No OCSP** (deprecated ecosystem-wide 2025). + 4. **Positive AND negative CI tests**: a legit cert `200`s on its own host; a tenant-A cert **`403`s** on a tenant-B host `[FIX C-nginx]`. +- **B2 (before the first distrusting third party):** per-tenant Ed25519 **intermediate CAs** under the root, each carrying X.509 `nameConstraints` permitted-subtree for `..terminal.yaojia.wang` — belt (proxy SAN==Host) and suspenders (PKI: CA physically can't have signed a cross-tenant SAN). **Attestation becomes mandatory here.** + +Citations: X.509 name constraints RFC 5280 §4.2.1.10 (sysadmins.lv, systemoverlord); SPIFFE X.509-SVID URI-SAN (paloaltonetworks, cockroachlabs); passive revocation & short-lived certs (smallstep, Let's Encrypt 47-day→7-day roadmap); OCSP deprecation (thesslstore, Let's Encrypt Aug-2025 shutdown); nginx client-cert enforcement (nginx.org ngx_http_ssl_module, mtask.github.io). + +### 1.3 Two distinct trust roots (do not conflate) `[FIX C-2, H-host-2]` + +| Role | CA | Key alg | Gate | SPIFFE segment | +|---|---|---|---|---| +| **Host frp-client** (control channel) | `frp-client-CA` | **P-256** (matches VPS-scaffolded `gen-device-ca.sh`, "P-256 never Ed25519") | frps :7000 control mTLS + shared token | `.../host/` | +| **Device** (data path) | `device-CA` | **P-256** (Secure Enclave / Android Keystore are P-256 ONLY) | nginx :8470 `ssl_verify_client` + CRL | `.../device/` | + +Both are **separate from** the existing Ed25519 **relay agent-CA** (`ca/issue.ts createRealLeafSigner`, trust domain `relay.`). The relay signer is a **template, not a reuse** for these two. + +--- + +## 2. Architecture (ASCII) + +### 2.1 Enrollment flows + +``` +HOST ONBOARD (one command / one click, RFC 8628 device grant → authenticated CSR) +──────────────────────────────────────────────────────────────────────────────── + installer (download-verify-run; carries ONLY a short-TTL token, NO key) + │ 1. POST /oauth/device/authorize {client_id:"host-agent",scope:"host.enroll"} + │ ← {device_code,user_code:"WDJB-MJHT",verification_uri,interval,expires_in:900} + │ 2. human approves in already-logged-in app: + │ POST /oauth/device/approve {user_code} [Bearer account-session] → 204 + │ (server binds device_code→accountId, mints single-use pairing code) + │ 3. poll POST /oauth/device/token {grant_type:device_code, device_code} + │ ← 400 authorization_pending | 400 slow_down | 200 {enroll_token=pairing code} + │ 4. generate P-256 host key LOCALLY ──► build PKCS#10 CSR + │ POST /enroll {code:enroll_token, machineId, agentPubkey, csr} + │ ← 201 {hostId, subdomain:"alice", cert(frp-client leaf), caChain} + │ SAN: dNSName alice.terminal.yaojia.wang (+ URI spiffe .../host/alice) + ▼ 5. write frpc.toml + base-app env (BIND_HOST=127.0.0.1 MANDATORY, + ALLOWED_ORIGINS=https://alice.terminal.yaojia.wang) → install 2 services + control-plane ── mints via frp-client-CA P-256 signer ── discards pairing code + +DEVICE ENROLL (kills .p12/AirDrop — login → hardware keygen → CSR → cert) +──────────────────────────────────────────────────────────────────────────── + app (after one-time login → holds device:enroll capability token) + │ 1. POST /device/attest/challenge [Bearer] ← {challenge, expires_in:120} + │ 2. generate NON-EXPORTABLE P-256 key in Secure Enclave / StrongBox→TEE + │ 3. build PKCS#10 CSR signed IN HARDWARE (+ best-effort attestation object) + │ 4. POST /device/enroll {csr, keyAlg:"ec-p256", accountScope, attestation?} + │ [Bearer device:enroll] + │ ← 201 {deviceId, cert(device leaf), caChain, notAfter, renewAfter} + │ SAN: dNSName .terminal.yaojia.wang (+ URI spiffe .../device/) + ▼ 5. store cert against hardware key → assemble SecIdentity / KeyStore entry + (private key NEVER left the device) +``` + +### 2.2 Connection flow (unchanged data path, now cert→Host bound) + +``` + device app / WebView VPS 8.138.1.192 host machine + ───────────────── ────────────── ──────────── + mTLS client cert ─TLS(SNI)──► nginx stream (ssl_preread) + (device leaf) │ SNI *.terminal.yaojia.wang + ▼ + nginx http :8470 ── TERMINATES TLS ──┐ + ssl_verify_client on (device-CA) │ THE ONE AND + ssl_crl device.crl │ ONLY AUTH GATE + js_set $cert_sub (njs parses SAN) │ (base app has + map "$cert_sub:$ssl_server_name" │ no login) + → 403 on mismatch [Model B B1] │ + │ (WS upgrade, proxy_read_timeout 3600s) + ▼ + frps vhost :7080 ──► frpc ──► base app 127.0.0.1:PORT + frp-client leaf ─TLS(SNI frp.terminal…)─► frps control :7000 (mTLS frp-client-CA + token) +``` + +--- + +## 3. The three tracks + +### 3.1 Track A — Control-plane / PKI (server-side issuance + isolation) + +**Decision that unblocks everything (`[FIX C-1 / H-cp-1]`, KMS vs real-X.509):** +`@peculiar/x509`'s `X509CertificateGenerator.create` needs a `signingKey: CryptoKey` and **cannot** delegate to an async KMS callback — so real-X.509-via-@peculiar and KMS-non-exportable-signing are mutually exclusive as built. **We choose path (a): build a small `ca/x509-assembler.ts` that DER-encodes the TBSCertificate ourselves and signs it via `CaSigner.sign()` (KMS/non-extractable).** All issuers (host, device, renew) route through it, so the KMS fail-fast in `boot/ca-wiring.ts` stays truthful. (Fallback path (b), documented but not chosen: accept an in-process non-extractable intermediate key and **remove** the KMS fail-fast — only if KMS TBS-signing proves infeasible in a spike. Ship exactly one; never claim both.) + +**Components (REUSE / EXTEND / NEW):** + +| File | Action | Purpose | +|---|---|---| +| `ca/x509-assembler.ts` | **NEW** | DER TBSCertificate builder, signs via `CaSigner.sign()` (KMS). Single issuance primitive for all leaves + CRL. `[FIX C-1]` | +| `ca/csr.ts` | REUSE | Ed25519 PoP for the relay path (untouched). | +| `ca/csr-ec.ts` | **NEW** | ECDSA-P256 PKCS#10 parse + self-sig PoP (`@peculiar` `req.verify()` handles ECDSA). Unblocks all hardware-bound device/host P-256 CSRs. | +| `ca/issue.ts` | TEMPLATE | Copy shape only; **do not reuse Ed25519 signing** for the two new CAs. | +| `ca/frpclient-issue.ts` | **NEW** | P-256 host frp-client leaf off `frp-client-CA`; SAN `dNSName ` + URI `.../host/`; EKU clientAuth; 24h TTL; `CLOCK_SKEW_SEC=60` backdated `notBefore`. | +| `ca/device-issue.ts` | **NEW** | P-256 device leaf off `device-CA` (loaded as a **separate hot signer module**, custody decided in §5); SAN `dNSName ` + URI `.../device/`; EKU clientAuth; CA:false; 24h–7d TTL. `[FIX C-2]` | +| `ca/rotate.ts` | UPGRADE | Port off the DEV-placeholder JSON blob onto `x509-assembler` so `/renew` returns **real** X.509. `[FIX H-host-3]` | +| `ca/crl.ts` | **NEW** | Signed X.509 CRL per CA (`frp-client.crl`, `device.crl`; per-tenant in B2). Owns the CRL database so it stays authoritative. `[FIX H-native-5]` | +| `ca/intermediates.ts` | **NEW (B2)** | Per-tenant name-constrained Ed25519 intermediates; `intermediateForAccount(accountId)`. | +| `registry/devices.ts` | **NEW** | `{deviceId, accountId, subdomainScope, ecPubkey, serial, status, notAfter, attestationLevel}`, versioned status snapshots, per-account **device cap + rate limit**. Mirror `registry/hosts.ts`. | +| `registry/hosts.ts` | EXTEND | Dedup on a **stable `machineId`** (not ephemeral `enrollFpr`) so reinstall re-binds the same subdomain. `[FIX M-cp-idempotent]` | +| `api/provision.ts` | EXTEND | `/enroll` accepts `machineId`; returns `hostContentSecret: null` for native-tunnel enrollments (kill the dead E2E-relay secret) `[FIX L-host-hcs]`. Add **`POST /renew`** wired to `renewHostLeaf` (net-new route — did not exist) `[FIX H-host-3]`. Inject zone `'terminal'` explicitly. | +| `api/device-enroll.ts` | **NEW** | `POST /device/enroll`, `POST /device/:id/renew` (mTLS current cert), `POST /device/attest/challenge`; bearer-gated (`aud=device-enroll`), Zod-validated, per-account rate-limited, deny-by-default. | +| `oauth/device-grant.ts` | **NEW** | RFC 8628 `/authorize` `/approve` `/token`; high-entropy `user_code` + attempt lockout; per-account+per-IP rate limits + `slow_down`; bind `device_code→accountId` only at approve; ≤15m single-use; show client+scope on approval; **no auto-approve via `verification_uri_complete`** (cross-device phishing). `[FIX M-cp-devicegrant]` | +| `auth/session.ts` + relay-contracts | **NEW / CONTRACT EDIT** | **Build the end-user login → session-token subsystem** (none exists today; accounts are admin-`manage`-only). Add **`enroll`** to `CapabilityRightSchema` (the enum `authz.ts` consumes). Mint a `device:enroll`-scoped token **separate from the 30–60s connect clamp**. `[FIX C-native-1]` | +| relay-auth `types.ts` + `agent/spiffe.ts` + verify-mtls | **CONTRACT EDIT (lockstep)** | Add a `/device/` arm to `SpiffeIdSchema` + `PARSE_RE`; generalize `spiffeIdFor(accountId, id, kind:'host'|'device')`; agree the canonical trust domain (`terminal.yaojia.wang`, drop the `relay.` divergence). **Cross-track contract test**: control-plane issuer SAN parses under relay-auth's parser for both kinds. `[FIX H-cp-2, H-native-6]` | +| `attest/apple.ts`, `attest/android.ts` | **NEW** | Off-device App Attest / Android Key-Attestation chain→root, challenge-bound; pin + rotate Apple/Google roots; best-effort, records level. | +| `deploy/nginx` njs `getCertSub` + map | **NEW** | `js_set $cert_sub getCertSub` (hand-rolled ASN.1 SAN extractor over `$ssl_client_cert`); `map "$cert_sub:$ssl_server_name" $ok { "~^(?[^:]+):(?P=s)\." 1; default 0; }` — **`getCertSub` MUST return the leftmost label** (`alice`), verified by the positive test. `[FIX C-native-3]` | + +**Isolation granularity `[FIX H-native-4]`:** the tenant unit is the **account**, which may own **many** subdomains. Two supported bindings, choose per deployment: +- **Per-host device cert (v1 default):** device leaf `dNSName = `; app enrolls a cert per host it reaches; nginx compares SAN-label to `$ssl_server_name` with no lookup. Simple, ships now. +- **Account-scoped (B2):** device leaf carries `account/`; nginx gains a **`subdomain→account` map** (synced from control-plane, or `auth_request` subrequest) and compares account. Chosen when a device must roam an account's hosts. + +**Endpoint shapes** (abridged; all Zod-validated, uniform reject): +``` +POST /oauth/device/authorize {client_id,scope} → {device_code,user_code,verification_uri,verification_uri_complete,interval,expires_in} +POST /oauth/device/approve {user_code} [Bearer session] → 204 +POST /oauth/device/token {grant_type,device_code,client_id} → {enroll_token,account_id,expires_in} | 400 authorization_pending|slow_down +POST /enroll {code,machineId,agentPubkey,csr} → 201 {hostId,subdomain,cert,caChain,hostContentSecret:null} +POST /renew {csr} [mTLS current frp-client cert] → 201 {cert,caChain,notAfter} # re-stamps SAME subdomain (no smuggling) +POST /device/attest/challenge [Bearer] → {challenge,expires_in:120} +POST /device/enroll {csr,keyAlg:"ec-p256",subdomain|accountScope,deviceName,attestation?} [Bearer device:enroll] + → 201 {deviceId,cert,caChain,notBefore,notAfter,renewAfter} +POST /device/:id/renew {csr} [mTLS current device cert] → 201 +DELETE /devices/:id [Bearer manage] → 204 # mark revoked + append serial to device.crl + push+reload +DELETE /hosts/:id [Bearer manage] → 204 # revoke + append serial to frp-client.crl + push+reload +GET /crl/device.crl | /crl/frp-client.crl (DER) # nginx/frps ssl_crl +``` + +**Build order (A):** +1. `ca/x509-assembler.ts` (KMS-signed TBS) + `ca/csr-ec.ts` (P-256 PoP) — foundation, unblocks all issuance. `[FIX C-1]` +2. Contract edits in lockstep: `enroll` right, SPIFFE `/device/` arm, `spiffeIdFor(kind)`, cross-track parser test. `[FIX C-native-1, H-cp-2]` +3. B1 nginx binding: `dNSName` SAN in `frpclient-issue.ts` + njs `getCertSub` + map→403 + **positive & negative CI tests**. `[FIX C-native-3]` +4. Device path: `device-issue.ts` + `registry/devices.ts` + `api/device-enroll.ts` (session-gated, tenant-bound, rate-limited). Attestation deferred. +5. Host device-grant: `oauth/device-grant.ts` (hardened) → `/enroll` with `machineId` dedup. +6. `/renew` (host + device, real X.509 via assembler) + rotation. `[FIX H-host-3]` +7. `ca/crl.ts` + revocation propagation to VPS (push CRL + trigger nginx `-s reload` via a small VPS-side webhook/agent — control-plane and VPS are different hosts). `[FIX H-native-5]` +8. Attestation verifiers; then B2 intermediates + per-tenant CRL/CA selection by `$ssl_server_name`. + +### 3.2 Track B — Host agent (zero-touch onboard, durable services, self-heal) + +~70% built in `agent/`; **re-target from the E2E rendezvous relay to the native frp tunnel**, not a rewrite. + +**Resolutions baked in:** +- **`BIND_HOST` loopback is MANDATORY, not defaulted `[FIX C-host-1]`:** `buildInstallOptions` **rejects/normalizes** any non-loopback `BIND_HOST` (throw on `0.0.0.0` / non-`127.0.0.1`). Negative unit test: the emitted unit env can never be `0.0.0.0`. Gate install behind it (S-GATE). +- **Host key = P-256 `[FIX H-host-2]`:** add a P-256 keygen branch alongside `keys/identity.ts` (Ed25519), because `frp-client-CA` is P-256 and frps (Go TLS) client-cert must match. Bring-up test: valid token + wrong/no frp-client cert → **refused at control handshake**. +- **One binding grammar `[FIX H-host-4]`:** frp-client cert carries `dNSName ` (the enforcement key) + URI SPIFFE `.../host/`; drop the invented `tenant/` prefix. frps/nginx bind on the `dNSName` label. +- **Model A declared in writing for v1 `[FIX H-host-5]`:** the "leaf-A refused for subdomain-B" negative test is **marked blocked on the deferred frps NewProxy authz plugin**; v1 = single-owner fleet. +- **`zone:'terminal'` explicit `[FIX L-host-zone]`:** native installs assert `zone==='terminal'` (default is `'term'`); test emitted origin is `https://.terminal.`. + +**Components:** + +| File | Action | Purpose | +|---|---|---| +| `keys/identity.ts` | EXTEND | Add local **P-256** keygen (private key never serialized off-host). | +| `keys/keystore.ts` | REUSE | 0600 store; refuses group/world-readable stateDir; holds distinct **frp-client** key/cert (never device certs — different track). | +| `enroll/csr.ts` | REUSE/EXTEND | Self-contained PKCS#10 DER encoder; add P-256 signing. | +| `enroll/pair.ts` | REUSE | `POST /enroll {code,machineId,agentPubkey,csr}` → stores cert 0600. | +| `identity/machineId.ts` | **NEW** | Stable install-id persisted 0600 for reinstall dedup. `[FIX M-cp-idempotent]` | +| `transport/frpcToml.ts` | **NEW** | `buildNativeFrpcToml`: `serverAddr=8.138.1.192, serverPort=443, transport.tls.serverName=frp.terminal.yaojia.wang, transport.tls.disableCustomTLSFirstByte=true, certFile/keyFile=keystore, trustedCaFile`, `[[proxies]] type=http subdomain= localIP=127.0.0.1 localPort=`. Retain loopback-only assertion (anti-SSRF). Supersedes the retired v0.8 `[common]/tls_enable` shape. | +| `provision/frpcBinary.ts` | **NEW** | OS/arch detect → download **pinned** frpc release **to disk** → verify **SHA-256 + signature BEFORE exec** → place in bin dir. Never `curl\|sh` streaming a secret, never `-k`. Mirror `dist/buildBinary.ts` verify discipline. | +| `service/install.ts` + `launchd.ts` + `systemd.ts` | EXTEND | **Parameterize label + ExecStart** so **two distinct units** emit: base-app (`node dist/server.js`) and agent (`web-terminal-agent run`, which supervises frpc as a child). Route base-app env to the base-app unit. `[FIX M-host-2service]` | +| `service/originConfig.ts` | REUSE | ALLOWED_ORIGINS merge, `zone:'terminal'`, never weakens existing origins. | +| `service/winsvc.ts` | **NEW** | Windows WinSW/nssm writer: non-admin scope, env injection, `USE_TMUX=0`. Defer behind macOS/Linux M3 if timeboxed. | +| `transport/frpSupervise.ts` | REUSE | frpc child spawn + `backoff`/`heartbeat` restart-with-backoff. | +| `health/probe.ts` | **NEW** | frpc alive, base-app loopback `GET http://127.0.0.1:PORT`, frpc "start proxy success", cert-not-near-expiry. `status` prints non-secret identifiers only (INV9). | +| `certs/rotation.ts` | REUSE/HARDEN | Renew via `POST /renew {csr}` (same key) at ~2/3 TTL; **403→revoked→teardown**; **expired-past-window → full re-enroll fallback** (offline > TTL). `[FIX H-host-3 dependency]` | +| `lifecycle/revocation.ts` | REUSE | Revoked-host teardown. | +| `update/autoupdate.ts` | **NEW** | Poll signed version manifest → verify (same SHA-256+sig gate) → atomic swap → service restart → **rollback on failed health**. | +| `cli.ts` | REUSE/EXTEND | `pair --install` → keygen → CSR → enroll → write frpc.toml + base-app env → install both units → start → print `https://.terminal.yaojia.wang`. Native mode branches to frpc supervision, **not** the old `runTunnel` rendezvous path. | + +**Install UX (download-verify-run):** +``` +curl -fsSL https://get.terminal.yaojia.wang/install.sh -o /tmp/wt.sh \ + && echo " /tmp/wt.sh" | shasum -a 256 -c \ + && sh /tmp/wt.sh --code ABCD-1234 # one-click alt: app "Add this machine" → device-code → prefilled cmd / signed .pkg/.msi +``` + +**Build order (B):** H0 VPS prereqs green → H1 frp-client P-256 signer + refused-without-cert test → H2 frpc.toml writer (TDD vs PLAN_NATIVE_TUNNEL §4 reference) → H3 frpc binary verify-download → H4 run-loop supervises frpc + loopback health → **H5 two-service install + BIND_HOST loopback tripwire (S-GATE)** → H6 `/renew` wiring + expired→re-enroll fallback → H7 Windows → H8 autoupdate → H9 RFC 8628 bootstrap → H10 one-command installer + runbook → **Milestone M3**. + +### 3.3 Track C — Native client (iOS / Android / desktop): kill the .p12 + +After **one-time login**, generate a **non-exportable EC P-256** key in hardware, CSR signed by it, `POST /device/enroll`, store the returned leaf against the hardware key, present the assembled identity on the **existing** mTLS path unchanged. + +**Critical iOS discipline `[FIX C-native, ClientTLS trap]`:** stay in **SecKey/Security-framework end-to-end**; do **NOT** use CryptoKit `SecureEnclave.P256.Signing.PrivateKey` (and never `SecKeyCreateWithData` with the SE token — it silently mints a *different* key → `SecItemCopyMatching(kSecClassIdentity)` fails with `errSecItemNotFound -25300`). Build the CSR by DER-encoding `CertificationRequestInfo` and signing with `SecKeyCreateSignature(.ecdsaSignatureMessageX962SHA256)`. + +**Components:** + +| Platform | File | Action | +|---|---|---| +| iOS | `SecureEnclaveKey.swift` | **NEW** `SecKeyCreateRandomKey` + `kSecAttrTokenIDSecureEnclave` + `kSecAttrKeyTypeECSECPrimeRandom`(256) + `kSecAttrIsPermanent` + `SecAccessControl`. Stays a `SecKey`. | +| iOS | `CertificateSigningRequest.swift` | **NEW** manual DER PKCS#10, `SecKeyCreateSignature(.ecdsaSignatureMessageX962SHA256)`. | +| iOS | `DeviceEnrollmentClient.swift` | **NEW** `POST /device/enroll` (+App Attest) with account bearer; drives rotation timer. | +| iOS | `KeychainClientIdentityStore.swift` | **MODIFY** swap "store p12+passphrase" for `enroll()/renew()/remove()`: keep SE key, `SecItemAdd(kSecClassCertificate)`, `loadIdentity` via `SecItemCopyMatching(kSecClassIdentity)`. `MutualTLSChallengeResponder`, `ClientIdentity`, `urlCredential(.forSession)` **reused UNCHANGED**. | +| iOS | `PKCS12Importer.swift` | RETAIN as legacy/migration import only (dual-trust window). | +| Android | keygen + CSR | **NEW** `KeyGenParameterSpec` over `AndroidKeyStore` (PURPOSE_SIGN, EC P-256, SHA-256, `setIsStrongBoxBacked(true)` with `StrongBoxUnavailableException`→TEE fallback, `setAttestationChallenge(nonce)`); BouncyCastle `JcaPKCS10CertificationRequestBuilder` + `JcaContentSignerBuilder("SHA256withECDSA")` delegating to the Keystore key; `KeyInfo.getSecurityLevel()` reported to server. | +| Android | store + present | **MODIFY** `KeyStore.setKeyEntry(alias, privKey, null, chain)`; present via existing `X509KeyManager` (native WSS) and `WebViewClient.onReceivedClientCertRequest.proceed(privateKey, chain)`. **On rotation, recreate the WebView / restart** — `onReceivedClientCertRequest` memoizes proceed/cancel per host:port for the app lifetime. | +| Android | api-client device-enroll route | **NEW** mirror routes; POST CSR+attestation with account bearer. | +| Desktop | `desktop/src/main.ts` (Electron) | **NEW** P-256 key in OS keychain marked non-exportable + CSR + import cert; present via `app.on('select-client-certificate', …, cb(match))` with `preventDefault()`; **send full chain** to dodge intermediate-stripping (#28553). `[FIX M-native-desktop]` **Goal downgraded for desktop to "OS-keychain non-exportable, best-effort (no hardware attestation)"** — Chromium sources certs only from the OS store and an in-process key is not TEE-bound. | +| Shared | rotation scheduler | **NEW** renew at ~2/3 TTL from the SAME hardware key; mTLS-cancel classified as expired/revoked → prompt re-login → fresh enroll. **Rotation forces connection teardown**, not just a stored-leaf swap. | + +**Build order (C):** 1 P-256 CSR verify (server, from Track A) → 2 `device-issue`+registry+`/device/enroll` (bearer-gated, negative tests) → 3 iOS keygen→CSR→SecItemAdd→SecIdentity roundtrip (real HW/entitlement) → 4 App Attest (layer on) → 5 Android StrongBox→TEE + BouncyCastle + WebView → 6 Electron OS-keychain best-effort → 7 Model B SAN binding + negative CI test → 8 legacy .p12 dual-trust migration window. + +--- + +## 4. Cross-track dependency graph & milestones + +``` + ┌─────────────────────────── Track A (control-plane) ───────────────────────────┐ + A1 x509-assembler(KMS) + csr-ec ──► A3 nginx B1 binding (dNSName SAN + njs + tests) + │ │ │ + │ ▼ ▼ + │ A2 contract edits (enforcement key = dNSName, NOT SPIFFE URI) + │ (enroll right, SPIFFE /device/, session subsystem) + ▼ │ │ + A5 device-grant (RFC8628) ├──────────────┐ │ + │ ▼ ▼ ▼ + │ A4 device-enroll ─── C: native clients (iOS→Android→desktop) + ▼ │ + B1 frp-client P-256 signer │ + │ ▼ + B (host agent) ───► A6 /renew (real X.509) ───► A7 CRL + VPS reload + │ │ + ▼ ▼ + B5 two-service install + BIND_HOST S-GATE A8 B2 per-tenant intermediates + attestation-mandatory +``` + +**Blocking edges:** A1 blocks all issuance. A2 (session subsystem + `enroll` right) blocks A4/C. A3 (nginx binding) blocks any multi-tenant claim. B1 (frp-client P-256 CA) blocks host connect. A6 (`/renew`) blocks rotation-as-revocation for both B and C. + +**Milestones:** +- **M-MVP** (§7): one automated host + one automated iOS client, single tenant (Model A), no attestation, no CRL — but **BIND_HOST loopback + nginx cert→Host binding + positive/negative test already in**. +- **M3 (host):** fresh machine → one command → reachable subdomain, both services durable across reboot, zero manual cert handling. +- **M-Device:** iOS + Android enroll/rotate/revoke end-to-end; App Attest / Key-Attestation best-effort. +- **M-B2:** per-tenant intermediates + mandatory attestation + per-tenant CRL — **gate for onboarding a 2nd distrusting customer.** + +--- + +## 5. Security / threat model + +- **Tenant isolation (skeleton-key risk):** Model A = any valid cert reaches any subdomain. **Mitigation:** B1 nginx cert→Host binding (njs SAN parse, `403` on mismatch) is the single load-bearing control and **ships in the MVP**; positive+negative CI tests guard silent regression. B2 name-constrained per-tenant intermediates make cross-tenant SANs structurally un-issuable. **Attestation mandatory before the 2nd tenant** (else a leaked session bearer mints a hardware-unbound cert). `[FIX C-native-3, H-native-4]` +- **Bootstrap abuse (the one un-cert-gated surface):** single-use, short-TTL, per-account **and per-IP** rate-limited pairing codes / device-grant / session bearers; high-entropy `user_code` + lockout; `device_code→accountId` bound only at approve; no auto-approve via `verification_uri_complete` (cross-device phishing); `device:enroll` token scope separate from the connect clamp and short-lived. Leaked bootstrap ≠ unlimited certs (per-account cap). `[FIX C-native-1, M-cp-devicegrant]` +- **Key protection:** host P-256 key generated locally (0600); device P-256 key **non-exportable in Secure Enclave / StrongBox→TEE**; desktop best-effort OS-keychain non-exportable (documented weaker guarantee). Only pubkey+CSR leave. Attestation verified **off-device** against pinned Apple/Google roots incl. root rotation/status lists. **CA key custody `[FIX C-2]`:** the now-hot device-CA + frp-client-CA private keys live behind KMS/HSM via `x509-assembler`→`CaSigner.sign()`; raw keys never enter control-plane memory; boot fail-fast enforced. +- **BIND_HOST regression (CRITICAL):** any `0.0.0.0` bind serves an unauth'd LAN shell bypassing the sole gate. **Mitigation:** loopback mandatory, non-loopback rejected, tripwire test, install S-GATE. `[FIX C-host-1]` +- **Rotation / revocation:** short-lived certs + silent renewal = **passive revocation** (stop renewing → dies within TTL) is primary. CRL at nginx (`ssl_crl`, auto-refresh + SIGHUP; push from control-plane which owns the single CRL DB) is the emergency fast-kill. **No OCSP.** Size TTL against a CA-downtime budget (24h TTL ≈ 8h safe downtime); expired-past-window → full re-enroll. Dual-trust window for the 825d→short-lived and Model A→B cutovers. `[FIX M-cp-crl, H-native-5, M-native-ttl]` +- **Supply chain:** frpc + agent binaries pinned, SHA-256 + signature verified **to disk before exec**; named signing authority for the version manifest; rollback on failed post-swap health. +- **Residual (documented, host-agent can't fix):** in Model A, `/hook*` and `/live-sessions/:id/preview` are reachable by any cert-holder in the shared set — onboarding a host exposes read of its terminals to the shared device set until B2. + +--- + +## 6. Risk register (every CRITICAL/HIGH finding → resolution) + +| # | Sev | Finding | Resolution in this plan | +|---|---|---|---| +| C-1 | HIGH | Real-X.509 (@peculiar) vs KMS non-exportable mutually exclusive | **Path (a): `ca/x509-assembler.ts`** DER-encodes TBS, signs via KMS `CaSigner.sign()`; all issuers route through it; boot fail-fast stays true (§3.1). | +| C-native-1 | CRITICAL | No login/bootstrap to gate `/device/enroll`; authorizer can't express `device:enroll` | Build **login→session subsystem** + RFC 8628; add **`enroll`** to `CapabilityRightSchema`; separate short-lived `device:enroll` token (§3.1 A2). | +| C-2 | CRITICAL | "Reuse CA" conflates Ed25519 relay-CA with P-256 device-CA; device-CA must go hot | **Net-new P-256 `device-issue.ts` + `frpclient-issue.ts`** as separate signer modules; key custody behind KMS/HSM; roots kept distinct (§1.3, §5). | +| C-native-3 | CRITICAL | nginx does ZERO cert→Host binding; CN-only leaves | **B1 njs SAN binding + `dNSName` SAN mandated (no CN-only) + positive & negative CI tests** — hard MVP prerequisite (§1.2, §3.1). | +| C-host-1 | CRITICAL | `BIND_HOST=0.0.0.0` defaulted not rejected | **Reject/normalize non-loopback**, tripwire test, install S-GATE (§3.2). | +| H-cp-2 / H-native-6 | HIGH | Device SPIFFE SAN breaks frozen contract; trust-domain inconsistency | **Lockstep contract edit**: SpiffeIdSchema+PARSE_RE `/device/` arm, `spiffeIdFor(kind)`, verify-mtls + njs, cross-track parser test; canonical trust domain (§3.1 A2). | +| H-host-2 | HIGH | Ed25519 vs P-256 frp-client-CA contradiction | Host keygen gains **P-256 branch**; frp-client-CA is P-256; refused-without-cert bring-up test (§3.2 H1). | +| H-host-3 | HIGH | `/renew` route doesn't exist; rotate.ts is DEV placeholder | **Wire `POST /renew`** (host+device) on real `x509-assembler`; PoP-bound + rate-limited; expired→re-enroll fallback (§3.1 A6, §3.2 H6). | +| H-host-4 | HIGH | Invented SPIFFE binding ≠ nginx CN/OU | **One grammar**: `dNSName ` is the enforcement key everywhere; njs parses it (§1.2, §3.2). | +| H-host-5 | HIGH | Model B over-claim vs plan deferral | **Declare Model A for v1**; per-host isolation test marked blocked on deferred frps authz plugin (§3.2). | +| H-native-4 | HIGH | Isolation granularity: account owns many subdomains | Per-host device cert (v1) **or** account-scoped SAN + nginx `subdomain→account` map (B2) (§3.1). | +| H-native-5 | HIGH | Dual issuance desyncs revocation; cross-host reload | **Single issuance authority owns the CRL DB**; explicit push-CRL + VPS reload webhook (§3.1 A7, §5). | +| M-* / L-* | MED/LOW | idempotent reinstall, CRL ordering, device-grant hardening, two-service env, zone `term`, dead `hostContentSecret`, desktop guarantee, WebView cache, TTL/uptime | All resolved inline: `machineId` dedup, CRL gated before 3rd-party, RFC 8628 hardening, parameterized units, explicit `terminal` zone, `hostContentSecret:null`, desktop best-effort downgrade, WebView teardown-on-rotate, TTL vs downtime budget (§3, §5). | + +--- + +## 7. MVP fast-path (shortest route to 1 automated host + 1 automated client, single tenant — layers in without rework) + +**Goal:** a fresh Mac/Linux host and one iPhone, single tenant (our own account, Model A), **zero manual cert handling**, keys never leave the device — with the two irreversible safety controls already in so nothing is rebuilt later. + +**Non-negotiable even in MVP** (these are the load-bearing, hard-to-retrofit pieces): **BIND_HOST loopback mandatory** + **nginx cert→Host `dNSName` binding with positive & negative tests**. Everything else is deferrable. + +**Deliberately deferred to post-MVP (no rework):** attestation, CRL (rely on 24h passive revocation only, single closed tenant), B2 intermediates, Android, desktop, Windows, autoupdate, RFC 8628 (use a **manually pasted single-use pairing code** first — the device grant wraps the same `/enroll` later). + +**MVP steps:** +1. **A1** `ca/x509-assembler.ts` (KMS-signed TBS) + `ca/csr-ec.ts` (P-256 PoP). *(foundation — never rebuilt)* +2. **A2-min** add `enroll` right + minimal login→`device:enroll` session token (stub OAuth acceptable; RFC 8628 layers on later). SPIFFE `/device/` arm + parser test. +3. **A3** `dNSName` SAN in both issuers + nginx njs `getCertSub` + map→403 + **positive & negative CI tests**. *(the one isolation control — must be right from day one)* +4. **B1** frp-client **P-256** signer; **B2-host** `frpcToml.ts` writer; **B3** verify-download frpc; **B5** **two-service install with BIND_HOST loopback S-GATE**; `pair --install`. +5. **A4** `POST /device/enroll` (session-gated, `dNSName `, per-account rate-limit). **A6-min** `POST /device/:id/renew` + host `/renew` on real X.509. +6. **C-iOS** `SecureEnclaveKey` + manual CSR + `DeviceEnrollmentClient` + refactor `KeychainClientIdentityStore` to `enroll()`; reuse `MutualTLSChallengeResponder`/`ClientIdentity` unchanged. +7. **Acceptance:** paste pairing code → host onboards → `https://.terminal.yaojia.wang` reachable; iPhone logs in → enclave keygen → enroll → mTLS connects; **negative test: a cert for host-A gets 403 on host-B**; kill -9 each service → supervisor restarts; reboot → both up in ~30s. + +**Layering guarantee:** MVP endpoints, SANs, CA roots, session-token shape, and the nginx binding are the **final** ones — attestation, CRL, RFC 8628, B2 intermediates, and the remaining platforms attach to these seams without changing them. \ No newline at end of file