Files
web-terminal/docs/PLAN_TUNNEL_AUTOMATION.md
Yaojia Wang 31054450fc docs: zero-touch tunnel enrollment automation plan (research + 3-track design + cross-validation)
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.
2026-07-08 18:57:19 +02:00

315 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 = `<sub>.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 / 24h7d 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 `.<tid>.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/<sub>` |
| **Device** (data path) | `device-CA` | **P-256** (Secure Enclave / Android Keystore are P-256 ONLY) | nginx :8470 `ssl_verify_client` + CRL | `.../device/<did>` |
Both are **separate from** the existing Ed25519 **relay agent-CA** (`ca/issue.ts createRealLeafSigner`, trust domain `relay.<domain>`). 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 <sub>.terminal.yaojia.wang (+ URI spiffe .../device/<did>)
▼ 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 <sub>` + URI `.../host/<sub>`; 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 <sub>` + URI `.../device/<did>`; EKU clientAuth; CA:false; 24h7d 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 3060s connect clamp**. `[FIX C-native-1]` |
| relay-auth `types.ts` + `agent/spiffe.ts` + verify-mtls | **CONTRACT EDIT (lockstep)** | Add a `/device/<d>` 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 { "~^(?<s>[^:]+):(?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 = <sub>`; 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/<a>`; 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 <sub>` (the enforcement key) + URI SPIFFE `.../host/<sub>`; 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://<sub>.terminal.<domain>`.
**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=<sub> localIP=127.0.0.1 localPort=<PORT>`. 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 <CODE> --install` → keygen → CSR → enroll → write frpc.toml + base-app env → install both units → start → print `https://<sub>.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 "<pinned-sha256> /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 <sub>` 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 <code> --install`.
5. **A4** `POST /device/enroll` (session-gated, `dNSName <sub>`, 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://<sub>.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.