Files
web-terminal/relay-auth/src/types.ts
Yaojia Wang e7f3bd05f0 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>
2026-07-10 16:11:13 +02:00

241 lines
9.0 KiB
TypeScript

/**
* T1 · P5-local types + Zod schemas (frozen after T1, changed only via T1).
*
* Re-exports the frozen relay-contracts §4 shapes for P5 consumers (import, NEVER redefine)
* and declares the P5-local records that §4 explicitly delegates to "P5-owned"
* (webauthn_credentials / oidc_identities / audit / revocation policy / SPIFFE-ID).
*
* SECURITY (INV3): `accountId` originates ONLY inside `AuthenticatedPrincipal`, derived from a
* verified credential/cert — never from a client field. `CapabilityToken.sub` carries THAT SAME
* `accountId` (CAP_TOKEN_SUB_IS_ACCOUNT_ID). No schema here accepts a client-supplied
* `accountId`/`tenant_id` as authoritative.
*/
import { z } from 'zod'
// ── Re-export frozen §4 types (import, never redefine) ────────────────────────────────────────
export type { CapabilityToken, CapabilityRight } from 'relay-contracts' // §4.3
export type { HostRecord, HostStatus, PlanTier, SessionRecord } from 'relay-contracts' // §4.2
// Revocation kill-signal + bus are FROZEN in relay-contracts §4.2 (FIX 4) — re-export, never redeclare:
export type { RevocationScope, KillSignal, RevocationBus } from 'relay-contracts' // §4.2, FIX 4
export { RELAY_REVOCATIONS_CHANNEL, REVOCATION_PUSH_BUDGET_MS } from 'relay-contracts' // 'relay:revocations' · 2000ms
// ── Auth method / principal ───────────────────────────────────────────────────────────────────
export type PrincipalKind = 'human' | 'agent' | 'share-grant'
export type AuthMethod = 'passkey' | 'totp' | 'oidc' | 'mtls' | 'stepup'
/**
* The ONLY source of `accountId` in an authz decision (INV3). All fields are derived from
* authenticated material; `accountId` is NEVER read from client input.
*/
export interface AuthenticatedPrincipal {
readonly kind: PrincipalKind
readonly accountId: string
readonly principalId: string // device/credential id (WebAuthn credId, SPIFFE-ID, or grant jti)
readonly amr: readonly AuthMethod[] // methods satisfied this session (for step-up, T8)
readonly authAt: number // epoch seconds of last successful auth (login freshness)
readonly stepUpAt: number | null // epoch seconds of last step-up (T8 freshness); null = never
readonly stepUpMethod?: AuthMethod | null // method used for the LAST step-up (F1 method-binding); absent/null ⇒ none
}
/**
* FROZEN P5 CONVENTION (resolves open-Q #3): §4.3 `CapabilityToken.sub` carries
* `principal.accountId`. The single reader is T3's `accountIdFromToken`. This chooses the VALUE
* P5 writes into an existing frozen field — it does NOT redefine §4.3.
*/
export const CAP_TOKEN_SUB_IS_ACCOUNT_ID = true as const
export interface StepUpPolicy {
readonly required: boolean
readonly maxAgeSeconds: number
readonly requiredMethod: AuthMethod
}
// ── P5-owned auth records (§4.2 delegated to P5) ────────────────────────────────────────────────
export interface WebAuthnCredential {
readonly credentialId: string
readonly accountId: string
readonly publicKey: Uint8Array
readonly signCount: number
readonly transports: readonly string[]
readonly createdAt: string
}
export interface OidcIdentity {
readonly issuer: string
readonly subject: string
readonly accountId: string
}
export interface TotpSecretRecord {
readonly accountId: string
readonly encSecret: Uint8Array // encrypted at rest (INV5); never SMS
readonly confirmedAt: string | null
}
export type AuditAction =
| 'attach'
| 'reattach'
| 'manage'
| 'kill'
| 'enroll'
| 'revoke'
| 'login'
| 'stepup'
| 'token-issue'
| 'cross-tenant-attempt'
export type AuditOutcome = 'allow' | 'deny'
/** INV10 — metadata only, ZERO payload. No field may carry keystrokes/output (redact.ts + Zod guard). */
export interface AuditEvent {
readonly ts: string
readonly action: AuditAction
readonly principalId: string
readonly accountId: string
readonly hostId: string | null
readonly sessionId: string | null
readonly jti: string | null
readonly outcome: AuditOutcome
readonly reason: string
readonly remoteAddrHash: string
}
export type SpiffeId = string // 'spiffe://relay.<domain>/account/<accountId>/(host|device)/<id>'
export interface RateLimitPolicy {
readonly connectPerMin: number
readonly enrollPerHour: number
readonly maxConcurrentSessions: number
readonly maxPairedHosts: number
readonly preAuthPerMinPerIp: number // pre-principal throttle keyed on remoteAddrHash (T11)
readonly totpMaxFailsPerWindow: number
readonly totpLockoutWindowSec: number
}
// ── Read-only ports P3/P1 implement (P5 stays storage-free & pure) ──────────────────────────────
import type { HostRecord as _HostRecord } from 'relay-contracts'
export interface HostRegistryPort {
getById(hostId: string): Promise<_HostRecord | null>
}
export interface SessionRegistryPort {
getById(sessionId: string): Promise<{ hostId: string; accountId: string } | null>
}
export interface RevocationStore {
isRevoked(jti: string): Promise<boolean>
revokeJti(jti: string, exp: number): Promise<void>
/** single-use consumption: true = first use (proceed); false = replay (deny). */
consumeOnce(jti: string, exp: number): Promise<boolean>
}
export interface AuditSink {
append(e: AuditEvent): Promise<void>
}
export interface TokenBucketStore {
/** false = throttled. Keys are OPAQUE to P5. */
take(key: string, refillPerSec: number, burst: number, now: number): Promise<boolean>
}
// ── Zod schemas (boundary validation, coding-style "Input Validation") ──────────────────────────
export const PrincipalKindSchema = z.enum(['human', 'agent', 'share-grant'])
export const AuthMethodSchema = z.enum(['passkey', 'totp', 'oidc', 'mtls', 'stepup'])
export const AuthenticatedPrincipalSchema = z
.object({
kind: PrincipalKindSchema,
accountId: z.string().min(1),
principalId: z.string().min(1),
amr: z.array(AuthMethodSchema).readonly(),
authAt: z.number().int().nonnegative(),
stepUpAt: z.number().int().nonnegative().nullable(),
stepUpMethod: AuthMethodSchema.nullable().optional(),
})
.strict()
export const StepUpPolicySchema = z
.object({
required: z.boolean(),
maxAgeSeconds: z.number().int().nonnegative(),
requiredMethod: AuthMethodSchema,
})
.strict()
export const WebAuthnCredentialSchema = z
.object({
credentialId: z.string().min(1),
accountId: z.string().min(1),
publicKey: z.instanceof(Uint8Array),
signCount: z.number().int().nonnegative(),
transports: z.array(z.string()).readonly(),
createdAt: z.string().min(1),
})
.strict()
export const OidcIdentitySchema = z
.object({
issuer: z.string().min(1),
subject: z.string().min(1),
accountId: z.string().min(1),
})
.strict()
export const TotpSecretRecordSchema = z
.object({
accountId: z.string().min(1),
encSecret: z.instanceof(Uint8Array),
confirmedAt: z.string().nullable(),
})
.strict()
export const AuditActionSchema = z.enum([
'attach',
'reattach',
'manage',
'kill',
'enroll',
'revoke',
'login',
'stepup',
'token-issue',
'cross-tenant-attempt',
])
/**
* INV10 zero-payload guard at the type layer: `.strict()` REJECTS any unknown key (e.g. a
* `data`/`bytes` field carrying terminal output). Length/control-char checks are in redact.ts (T4).
*/
export const AuditEventSchema = z
.object({
ts: z.string().min(1),
action: AuditActionSchema,
principalId: z.string(),
accountId: z.string(),
hostId: z.string().nullable(),
sessionId: z.string().nullable(),
jti: z.string().nullable(),
outcome: z.enum(['allow', 'deny']),
reason: z.string(),
remoteAddrHash: z.string(),
})
.strict()
export const RateLimitPolicySchema = z
.object({
connectPerMin: z.number().int().positive(),
enrollPerHour: z.number().int().positive(),
maxConcurrentSessions: z.number().int().positive(),
maxPairedHosts: z.number().int().positive(),
preAuthPerMinPerIp: z.number().int().positive(),
totpMaxFailsPerWindow: z.number().int().positive(),
totpLockoutWindowSec: z.number().int().positive(),
})
.strict()
/**
* SPIFFE-ID validator: accepts BOTH the host arm `spiffe://relay.<domain>/account/<a>/host/<h>`
* and the device arm `.../account/<a>/device/<d>` (FIX H-cp-2 / H-native-6, added in lockstep
* with the control-plane device issuer). REJECTS path-traversal (`..`) or wildcard (`*`); the
* kind segment is exactly `host` or `device`. Segment chars are restricted (no `/`).
*/
const SPIFFE_ID_RE =
/^spiffe:\/\/relay\.[a-z0-9.-]+\/account\/[A-Za-z0-9_-]+\/(?:host|device)\/[A-Za-z0-9_-]+$/
export const SpiffeIdSchema = z
.string()
.regex(SPIFFE_ID_RE, 'invalid SPIFFE-ID form')
.refine((s) => !s.includes('..') && !s.includes('*'), 'path-traversal/wildcard rejected')