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.
36 KiB
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:enrollcapability 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):
- Stamp a
dNSNameSAN =<sub>.terminal.yaojia.wanginto every leaf (host frp-client + device). ThedNSName(subdomain) is the enforcement key at :8470 — NOT the SPIFFE URI[FIX H-2]. The SPIFFE URI SAN (identity/audit) is carried alongside. - At :8470, enforce cert-subject == requested Host/SNI,
403on mismatch, via njs parsing the SAN out of$ssl_client_cert(not fragile$ssl_client_s_dnRFC2253 string-matching).map+ singlereturn 403, neverif. - 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).
- Positive AND negative CI tests: a legit cert
200s on its own host; a tenant-A cert403s on a tenant-B host[FIX C-nginx].
- Stamp a
- B2 (before the first distrusting third party): per-tenant Ed25519 intermediate CAs under the root, each carrying X.509
nameConstraintspermitted-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' |
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_namewith no lookup. Simple, ships now. - Account-scoped (B2): device leaf carries
account/<a>; nginx gains asubdomain→accountmap (synced from control-plane, orauth_requestsubrequest) 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):
ca/x509-assembler.ts(KMS-signed TBS) +ca/csr-ec.ts(P-256 PoP) — foundation, unblocks all issuance.[FIX C-1]- Contract edits in lockstep:
enrollright, SPIFFE/device/arm,spiffeIdFor(kind), cross-track parser test.[FIX C-native-1, H-cp-2] - B1 nginx binding:
dNSNameSAN infrpclient-issue.ts+ njsgetCertSub+ map→403 + positive & negative CI tests.[FIX C-native-3] - Device path:
device-issue.ts+registry/devices.ts+api/device-enroll.ts(session-gated, tenant-bound, rate-limited). Attestation deferred. - Host device-grant:
oauth/device-grant.ts(hardened) →/enrollwithmachineIddedup. /renew(host + device, real X.509 via assembler) + rotation.[FIX H-host-3]ca/crl.ts+ revocation propagation to VPS (push CRL + trigger nginx-s reloadvia a small VPS-side webhook/agent — control-plane and VPS are different hosts).[FIX H-native-5]- 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_HOSTloopback is MANDATORY, not defaulted[FIX C-host-1]:buildInstallOptionsrejects/normalizes any non-loopbackBIND_HOST(throw on0.0.0.0/ non-127.0.0.1). Negative unit test: the emitted unit env can never be0.0.0.0. Gate install behind it (S-GATE).- Host key = P-256
[FIX H-host-2]: add a P-256 keygen branch alongsidekeys/identity.ts(Ed25519), becausefrp-client-CAis 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 carriesdNSName <sub>(the enforcement key) + URI SPIFFE.../host/<sub>; drop the inventedtenant/prefix. frps/nginx bind on thedNSNamelabel. - 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 assertzone==='terminal'(default is'term'); test emitted origin ishttps://<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,
403on 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→accountIdbound only at approve; no auto-approve viaverification_uri_complete(cross-device phishing);device:enrolltoken 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 viax509-assembler→CaSigner.sign(); raw keys never enter control-plane memory; boot fail-fast enforced. - BIND_HOST regression (CRITICAL): any
0.0.0.0bind 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/previeware 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:
- A1
ca/x509-assembler.ts(KMS-signed TBS) +ca/csr-ec.ts(P-256 PoP). (foundation — never rebuilt) - A2-min add
enrollright + minimal login→device:enrollsession token (stub OAuth acceptable; RFC 8628 layers on later). SPIFFE/device/arm + parser test. - A3
dNSNameSAN in both issuers + nginx njsgetCertSub+ map→403 + positive & negative CI tests. (the one isolation control — must be right from day one) - B1 frp-client P-256 signer; B2-host
frpcToml.tswriter; B3 verify-download frpc; B5 two-service install with BIND_HOST loopback S-GATE;pair <code> --install. - A4
POST /device/enroll(session-gated,dNSName <sub>, per-account rate-limit). A6-minPOST /device/:id/renew+ host/renewon real X.509. - C-iOS
SecureEnclaveKey+ manual CSR +DeviceEnrollmentClient+ refactorKeychainClientIdentityStoretoenroll(); reuseMutualTLSChallengeResponder/ClientIdentityunchanged. - Acceptance: paste pairing code → host onboards →
https://<sub>.terminal.yaojia.wangreachable; 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.