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

36 KiB
Raw Permalink Blame History

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 200s on its own host; a tenant-A cert 403s 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'
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 / restartonReceivedClientCertRequest 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-assemblerCaSigner.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.