# 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 / 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 `.<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; 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/<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.