feat(tunnel): zero-touch tunnel enrollment — control-plane PKI, host agent, iOS, nginx isolation
Customers install one command / log in once; hardware-generated keys never leave the device; CSRs return certs + subdomain; frpc + base-app run as durable services. No .p12, no manual cert import. Implements the MVP fast-path of docs/PLAN_TUNNEL_AUTOMATION.md. Control-plane / PKI (control-plane/): - ca/x509-assembler.ts: single KMS-signed real X.509 issuance primitive (Ed25519 + P-256) - ca/csr-ec.ts: P-256 PKCS#10 proof-of-possession (verifyCsrPoPEc) + CSR-key routing - ca/frpclient-issue.ts, ca/device-issue.ts: P-256 frp-client + device leaf signers - ca/rotate.ts + api/renew.ts: real-X.509 /renew + /device/:id/renew (mTLS current cert) - registry/devices.ts: device registry + per-account cap/rate-limit - auth/session.ts: device:enroll capability token mint/verify - api/device-enroll.ts: POST /device/enroll (ownership-gated, deny-by-default) - pairing/native-redeem.ts + shared gateAndConsumePairingCode; api/provision.ts native arm - boot/native-ca.ts + main.ts: wire two P-256 CAs + issuers + routers (dev / KMS fail-fast) Contracts: relay-contracts enroll right; relay-auth SPIFFE /device/ arm + spiffeIdFor(kind) Host agent (agent/): - transport/frpcToml.ts; provision/frpcBinary.ts + untar.ts (verify-download + traversal-safe extract) - keys P-256 keygen/CSR/loadIdentity; service two-unit install + BIND_HOST loopback S-GATE - net/loopbackLiteral.ts strict guard; health/probe.ts + transport/frpSupervise.ts; cli pair --install iOS (ios/Packages/ClientTLS): SecureEnclaveKey + CertificateSigningRequest + DeviceEnrollmentClient + Keychain enroll refactor (SecKey/Security.framework end-to-end, avoids the -25300 trap) Isolation (deploy/nginx): njs/getCertSub.js SAN parser + zone-anchored map -> 403 Verified: 758 tests green (control-plane 246, agent 267, relay-auth 133, relay-contracts 85, iOS ClientTLS 27), all tsc clean; real nginx+njs docker 403/200/400; Swift CSR accepted by the real control-plane verifier; frpc extract byte-identical to `tar -xO`. Cross-validation caught + fixed 5 real defects (1 critical, 4 high). Remaining = infra (KMS, nginx deploy, VPS frps, physical iPhone) per PROGRESS_LOG runbook. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
166
control-plane/src/auth/session.ts
Normal file
166
control-plane/src/auth/session.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* A4 (FIX C-native-1) — the MINIMAL device:enroll session-token subsystem + a login stub seam.
|
||||
*
|
||||
* `/device/enroll` is the one endpoint that cannot require a client cert (chicken-and-egg), so it is
|
||||
* gated by a short-lived, narrowly-scoped `device:enroll` bearer minted after the user's one-time
|
||||
* login. That bearer IS a §4.3 capability token with `rights:['enroll']` (the right added in A2a),
|
||||
* `sub = accountId`, a DISTINCT audience (`device-enroll`), and a MINUTES-scale TTL that is
|
||||
* deliberately SEPARATE from the 30–60s connect clamp.
|
||||
*
|
||||
* WHY NOT `issueCapabilityToken`: relay-auth's connect-token issuer hard-clamps TTL to ≤60s (the
|
||||
* Finding-4 blast-radius clamp) and requires a single-host + DPoP `cnf.jkt`. An enroll token is not
|
||||
* host-scoped and must live for minutes, so we mint via the SAME underlying primitive
|
||||
* (`signPaseto`, Ed25519 v4.public — relay-auth crypto, NO new crypto) and build the identical §4.3
|
||||
* body shape so the FROZEN verifier (`verifyCapabilityToken`) accepts it unchanged. Verification
|
||||
* therefore goes through the exact path the control-plane already uses (boot/verifier.ts), then
|
||||
* asserts the `enroll` right — deny-by-default on anything else.
|
||||
*
|
||||
* LOGIN is a STUB SEAM for the single-tenant MVP (`loginToAccountId`): it resolves an authenticated
|
||||
* credential to an `accountId`. A full end-user auth layer (RFC 8628 device grant / OIDC) layers on
|
||||
* here later WITHOUT changing the token shape or the verify path.
|
||||
*/
|
||||
import type { CapabilityRight, CapabilityToken } from 'relay-contracts'
|
||||
import { verifyCapabilityToken } from 'relay-auth'
|
||||
import { signPaseto } from 'relay-auth/src/crypto/paseto.js'
|
||||
import { randomBytes, randomUUID } from 'node:crypto'
|
||||
import { timingSafeEqualBytes } from '../util/bytes.js'
|
||||
|
||||
/** Distinct audience for device enrollment (Host-confusion guard — never a subdomain aud). */
|
||||
export const DEVICE_ENROLL_AUD = 'device-enroll'
|
||||
/** Default enroll-token TTL: 10 minutes (minutes-scale, separate from the 30–60s connect clamp). */
|
||||
export const DEFAULT_DEVICE_ENROLL_TTL_SEC = 10 * 60
|
||||
/** Floor: a device:enroll token must outlive the connect clamp to be meaningfully separate. */
|
||||
export const MIN_DEVICE_ENROLL_TTL_SEC = 60
|
||||
/** Ceiling: still short-lived (leaked-bearer blast radius). */
|
||||
export const MAX_DEVICE_ENROLL_TTL_SEC = 60 * 60
|
||||
|
||||
/** Enroll tokens are not host-scoped; the frozen §4.3 shape requires a non-empty `host` — sentinel. */
|
||||
const ENROLL_HOST_SENTINEL = 'device-enroll'
|
||||
|
||||
/** Uniform auth reject for the device-enroll surface. 401 = bad/missing token, 403 = lacks right. */
|
||||
export class DeviceEnrollAuthError extends Error {
|
||||
constructor(
|
||||
public readonly status: 401 | 403,
|
||||
message = 'device enrollment auth rejected',
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'DeviceEnrollAuthError'
|
||||
}
|
||||
}
|
||||
|
||||
/** The frozen §4.3 body + the additive `cnf.jkt` claim the verifier requires (RFC 7800). */
|
||||
interface EnrollTokenBody {
|
||||
readonly sub: string
|
||||
readonly aud: string
|
||||
readonly host: string
|
||||
readonly rights: readonly CapabilityRight[]
|
||||
readonly iat: number
|
||||
readonly exp: number
|
||||
readonly jti: string
|
||||
readonly cnf: { readonly jkt: string }
|
||||
}
|
||||
|
||||
export interface MintDeviceEnrollOpts {
|
||||
/** The session subsystem's Ed25519 signing key (WebCrypto). Never held globally (INV9). */
|
||||
readonly signingKey: CryptoKey
|
||||
readonly ttlSeconds?: number
|
||||
readonly now?: number // epoch seconds
|
||||
readonly aud?: string
|
||||
/** Optional DPoP thumbprint. Enroll-token PoP binding is deferred; a placeholder is used if absent. */
|
||||
readonly cnfJkt?: string
|
||||
readonly jti?: string
|
||||
}
|
||||
|
||||
function clampTtl(ttl: number): number {
|
||||
return Math.min(Math.max(ttl, MIN_DEVICE_ENROLL_TTL_SEC), MAX_DEVICE_ENROLL_TTL_SEC)
|
||||
}
|
||||
|
||||
/** A 43-char base64url placeholder satisfying the shared verifier's `cnf.jkt` (min-1) requirement. */
|
||||
function placeholderJkt(): string {
|
||||
return Buffer.from(randomBytes(32)).toString('base64url')
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a `device:enroll` capability token: `rights:['enroll']`, `sub = accountId`, `aud = device-enroll`,
|
||||
* minutes-scale TTL. Signed with the caller-supplied Ed25519 key via relay-auth's PASETO primitive.
|
||||
*/
|
||||
export async function mintDeviceEnrollToken(accountId: string, opts: MintDeviceEnrollOpts): Promise<string> {
|
||||
if (accountId.length === 0) throw new DeviceEnrollAuthError(401, 'empty accountId')
|
||||
const now = opts.now ?? Math.floor(Date.now() / 1000)
|
||||
const ttl = clampTtl(opts.ttlSeconds ?? DEFAULT_DEVICE_ENROLL_TTL_SEC)
|
||||
const body: EnrollTokenBody = {
|
||||
sub: accountId,
|
||||
aud: opts.aud ?? DEVICE_ENROLL_AUD,
|
||||
host: ENROLL_HOST_SENTINEL,
|
||||
rights: ['enroll'],
|
||||
iat: now,
|
||||
exp: now + ttl,
|
||||
jti: opts.jti ?? randomUUID(),
|
||||
cnf: { jkt: opts.cnfJkt ?? placeholderJkt() },
|
||||
}
|
||||
return signPaseto(body, opts.signingKey)
|
||||
}
|
||||
|
||||
/** Least-privilege check: the bearer must carry the `enroll` right, else 403 (uniform). */
|
||||
export function requireEnrollRight(token: CapabilityToken): void {
|
||||
if (!token.rights.includes('enroll')) {
|
||||
throw new DeviceEnrollAuthError(403, 'token scope lacks the enroll right')
|
||||
}
|
||||
}
|
||||
|
||||
export interface VerifyDeviceEnrollOpts {
|
||||
readonly now?: number // epoch seconds
|
||||
readonly aud?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a `device:enroll` bearer through the FROZEN §4.3 verifier (same path as boot/verifier.ts):
|
||||
* Ed25519 signature (startup key), `aud === device-enroll`, and `iat`/`exp` window — then assert the
|
||||
* `enroll` right. Returns `{ accountId }` (from the token `sub`, never a client field). Uniform reject:
|
||||
* any signature/audience/expiry failure → 401; a missing `enroll` right → 403.
|
||||
*/
|
||||
export async function verifyDeviceEnrollToken(
|
||||
raw: string,
|
||||
opts: VerifyDeviceEnrollOpts = {},
|
||||
): Promise<{ accountId: string }> {
|
||||
const aud = opts.aud ?? DEVICE_ENROLL_AUD
|
||||
const now = opts.now ?? Math.floor(Date.now() / 1000)
|
||||
let token: CapabilityToken
|
||||
try {
|
||||
token = await verifyCapabilityToken(raw, aud, now)
|
||||
} catch {
|
||||
throw new DeviceEnrollAuthError(401, 'device enroll token rejected')
|
||||
}
|
||||
requireEnrollRight(token)
|
||||
return { accountId: token.sub }
|
||||
}
|
||||
|
||||
/**
|
||||
* LOGIN STUB SEAM (single-tenant MVP). Resolves an authenticated credential to an `accountId`.
|
||||
* Deny-by-default: an empty credential, an unresolved credential, or an unconfigured seam all reject.
|
||||
* A full end-user auth layer (RFC 8628 device grant / OIDC) replaces the body here without touching
|
||||
* callers. Two supported bindings:
|
||||
* - `resolve(credential) → accountId | null` — an injectable resolver (the real login layer);
|
||||
* - `{ operatorCredential, accountId }` — the single operator credential of the MVP fleet.
|
||||
*/
|
||||
export interface LoginSeamConfig {
|
||||
readonly resolve?: (credential: string) => string | null
|
||||
readonly operatorCredential?: string
|
||||
readonly accountId?: string
|
||||
}
|
||||
|
||||
export function loginToAccountId(credential: string, config: LoginSeamConfig): string {
|
||||
if (credential.length === 0) throw new DeviceEnrollAuthError(401, 'login rejected')
|
||||
if (config.resolve !== undefined) {
|
||||
const acct = config.resolve(credential)
|
||||
if (acct === null || acct.length === 0) throw new DeviceEnrollAuthError(401, 'login rejected')
|
||||
return acct
|
||||
}
|
||||
if (config.operatorCredential !== undefined && config.accountId !== undefined) {
|
||||
const a = new TextEncoder().encode(credential)
|
||||
const b = new TextEncoder().encode(config.operatorCredential)
|
||||
if (timingSafeEqualBytes(a, b)) return config.accountId
|
||||
throw new DeviceEnrollAuthError(401, 'login rejected')
|
||||
}
|
||||
throw new DeviceEnrollAuthError(401, 'login not configured')
|
||||
}
|
||||
Reference in New Issue
Block a user