/** * 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./account//host/' 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 revokeJti(jti: string, exp: number): Promise /** single-use consumption: true = first use (proceed); false = replay (deny). */ consumeOnce(jti: string, exp: number): Promise } export interface AuditSink { append(e: AuditEvent): Promise } export interface TokenBucketStore { /** false = throttled. Keys are OPAQUE to P5. */ take(key: string, refillPerSec: number, burst: number, now: number): Promise } // ── 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 the `spiffe://relay./account//host/` form and * REJECTS path-traversal (`..`) or wildcard (`*`). Segment chars are restricted (no `/`). */ const SPIFFE_ID_RE = /^spiffe:\/\/relay\.[a-z0-9.-]+\/account\/[A-Za-z0-9_-]+\/host\/[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')