# PLAN_RELAY_AUTH_ISOLATION — Auth & Tenant Isolation (P5) > **Plan P5 of 6** for the Rendezvous-Relay Service. Source of intent: > [`EXPLORE_RELAY_SERVICE.md`](./EXPLORE_RELAY_SERVICE.md) (§0 LOCKED DECISIONS are **CLOSED**). > Coordination point & frozen contracts: [`PLAN_RELAY_INDEX.md`](./PLAN_RELAY_INDEX.md) — this plan > **cites §3 (invariants) and §4 (FROZEN CONTRACTS) by name and MUST NOT redefine any of them locally.** > Conventions follow [`PLAN.md`](./PLAN.md) / [`PLAN_VOICE_COMMANDS.md`](./PLAN_VOICE_COMMANDS.md): > stable task IDs grouped into dependency waves, an `Owns:` disjoint-file list per task, > function-signature-level contracts, TDD (tests FIRST), explicit test cases (incl. negative/security), > per-task security notes. Progress logged in [`PROGRESS_LOG.md`](./PROGRESS_LOG.md) — **orchestrator-only > writer** (subagents return a ready-to-paste entry, G1). --- ## 0. Scope **This plan owns the "who are you / may you reach this host / prove it repeatedly" spine of the relay.** It is the *decision layer* — the byte/ciphertext path (P1), the accounts registry storage (P3), the crypto core (P4), and the browser UI (P6) call **into** P5 for every authorization decision but P5 never touches the byte path itself. **In scope (this plan, no overlap with the other five):** 1. **Human auth** — Passkey/WebAuthn **primary**, TOTP **fallback**, **NEVER SMS**, OIDC SSO for teams; **step-up auth before opening a session** (not just at login). 2. **Agent auth** — per-host **mTLS with SPIFFE-style short-lived, auto-rotating certs** (verification + rotation **policy**; DB stores only public keys, §4.2 `agent_pubkey`, INV4). *P3 owns the signing endpoint; P5 owns the cert profile, SPIFFE-ID scheme, verification, and rotation controller.* 3. **Capability tokens** on the WS upgrade — issuance + verification of §4.3 `CapabilityToken` (host + rights scope, INV15). 4. **Deny-by-default tenant authz** — `account_id` derived from the authenticated principal, **NEVER client-supplied** (INV3), enforced on **every connect AND reattach** (INV6). 5. **The hard cross-tenant isolation invariant** (INV1: A can never reach B) + a **permanent CI tripwire test** (device A → host B = **403**). 6. **Per-tenant rate-limits / quotas** (sessions, bytes-signalled, enrollments, connect attempts). 7. **Global + per-host revocation** that kills live tunnels in **seconds** (INV12). 8. **Immutable audit log with ZERO payload** (INV10) + cross-tenant-crossing alerts. **Explicitly NOT in scope (owned elsewhere — do not touch):** the mux frame format & data-plane forwarding (P1, §4.1); accounts/hosts/sessions/pairing_codes **table storage + issuance** and the mTLS **signing** endpoint (P3, §4.2/§4.5); E2E handshake & AEAD envelope (P4, §4.4); terminal bytes / any ANSI parsing (never — INV2/INV11); browser UI / SubtleCrypto / client-side preview render (P6). The base app `src/` and `public/` stay **byte-for-byte unchanged** (only P3's install step touches `ALLOWED_ORIGINS`). ### SECURITY INVARIANTS this plan enforces (from INDEX §3) Primary owner (ships the test): **INV1** (cross-tenant isolation + CI tripwire), **INV3** (`account_id` from authenticated principal, never client-supplied), **INV6** (deny-by-default on connect AND reattach), **INV10** (immutable zero-payload audit + cross-tenant alert), **INV12** (fast global/per-host revocation), **INV15** (capability token required on WS upgrade). Co-owner: **INV4** (per-host asymmetric identity — with P2/P3), **INV14** (mTLS SPIFFE short-lived rotating certs — with P2/P3). Consumes/relies on: **INV9** (secrets in env/manager, validated at startup, never logged) applies to every task. --- ## 1. Files & packages owned **All P5 runtime lives in a NEW top-level package `relay-auth/`** — a dependency-light authorization library (no `ws`, no terminal parser, no DOM) that P1's data plane and P3's control plane import read-only, exactly as `src/types.ts` is imported in the base app. It imports **`relay-contracts/` (§4) read-only** and never redefines a frozen contract. P5-local types that are NOT in §4 (WebAuthn credential, TOTP secret storage, OIDC identity, audit event, rate-limit policy, SPIFFE-ID) live here because §4 explicitly delegates the `webauthn_credentials` / `oidc_identities` tables and the audit/revocation mechanics to "P5-owned" (§4.2). | Path | Owner task | Role | |---|---|---| | `relay-auth/package.json`, `relay-auth/tsconfig.json`, `relay-auth/vitest.config.ts` | T1 | package scaffold | | `relay-auth/src/types.ts` | T1 | P5-local Zod schemas + TS types (auth records, audit event, rate policy, SPIFFE-ID) — **frozen after T1**, changed only via T1 | | `relay-auth/src/capability/issue.ts`, `relay-auth/src/capability/verify.ts`, `relay-auth/src/capability/device-proof.ts` | T2 | §4.3 `CapabilityToken` issue + verify (Ed25519 PASETO/JWS) incl. DPoP PoP + single-use; §4.4 `deviceAuthProof` sign/verify | | `relay-auth/src/authz/principal.ts`, `relay-auth/src/authz/decide.ts` | T3 | deny-by-default authz core (INV1/INV3/INV6) | | `relay-auth/src/audit/log.ts`, `relay-auth/src/audit/redact.ts` | T4 | immutable zero-payload audit sink + alert emitter | | `relay-auth/src/human/webauthn/register.ts`, `.../authenticate.ts` | T5 | Passkey/WebAuthn ceremonies | | `relay-auth/src/human/totp/totp.ts` | T6 | TOTP fallback (RFC 6238) | | `relay-auth/src/human/oidc/oidc.ts` | T7 | OIDC SSO (auth-code + PKCE) | | `relay-auth/src/human/stepup/stepup.ts` | T8 | step-up assertion before session open | | `relay-auth/src/agent/spiffe.ts`, `relay-auth/src/agent/verify-mtls.ts`, `relay-auth/src/agent/rotate.ts` | T9 | SPIFFE-ID scheme, cert-chain verification, rotation controller | | `relay-auth/src/revocation/revoke.ts`, `relay-auth/src/revocation/check.ts` | T10 | global + per-host revocation, live-tunnel kill signal | | `relay-auth/src/ratelimit/quota.ts` | T11 | per-tenant token-bucket + quota checks | | `relay-auth/src/enforce/onUpgrade.ts`, `relay-auth/src/enforce/onReattach.ts` | T12 | the two enforcement entry points P1/P3 call | | `relay-auth/test/**` | each task co-owns its own `*.test.ts` | unit/negative tests (TDD, tests FIRST) | | `relay-auth/test/tripwire/cross-tenant.test.ts`, `.github/workflows/relay-tripwire.yml` | T13 | **permanent CI tripwire** (INV1) | **Cross-plan storage/transport ports (interfaces, not tables):** P5 does **not** own Postgres/Redis storage or sockets — it defines narrow port interfaces (`HostRegistryPort`, `SessionRegistryPort`, `RevocationStore` incl. `consumeOnce`, `AuditSink`, `CredentialStore`, `TokenBucketStore`, and the push-only `RevocationBus`) that P3/P1 implement against §4.2 storage and the `relay:revocations` pub/sub channel. This keeps P5 pure/testable, keeps the immutable-record + atomic-swap ownership (INV8) inside P3, and keeps P5 **socket-free** (the live-tunnel teardown is P1 injecting a §4.1 frame in response to a published `KillSignal`, Finding-2). --- ## 2. Dependency waves ``` Wave A (foundations — parallel, file-disjoint) T1 relay-auth scaffold + P5-local types/schemas (v0.8: contracts only) │ ▼ Wave B (mechanisms — parallel after T1) T2 capability token T3 deny-by-default authz T4 audit log T9 mTLS/SPIFFE verify+rotate T10 revocation T11 rate-limit/quota (v0.9 core) T5 WebAuthn T6 TOTP T7 OIDC T8 step-up (v0.10 human auth) │ ▼ Wave C (enforcement + tripwire — converge) T12 enforce onUpgrade/onReattach ← T2,T3,T10,T11 (v0.9 CORE ships here) + T4,T8 (v0.10 AUGMENTATION: audit emit + step-up gate) T13 CI cross-tenant tripwire ← T12 (v0.9 unit variant = 403 only) + T4,T14 (v0.10 full-stack variant = audit+alert asserts) T14 audit-alert wiring ← T4,T12 (v0.10) ``` **Phasing note (Finding-8 reconciliation):** T12's v0.9 **core** depends only on T2/T3/T10/T11 (all v0.9), so it ships in v0.9. Its dependency on T4 (audit) and T8 (step-up) is a v0.10 **augmentation** wired through injected deps (`audit`, `stepUpPolicyFor`) that are no-op-safe in v0.9 — a v0.9 task never blocks on a v0.10 task. T13's v0.9 unit variant asserts only the 403 outcomes; the "audit event + alert fired" assertions are the v0.10 full-stack variant (needs T4 + T14). The wave diagram and every task `Depends:` line agree on this. **Cross-plan dependencies (reference other plans' task IDs where their plans define them):** - **P-INDEX §4** (`relay-contracts/`) is W0 for the whole program — frozen before P5's T1. - **T3/T12 depend on P3's** `HostRegistry`/`SessionRegistry` (the §4.2 tables) to look up `host.account_id` / `session.account_id`. P5 codes against the port interfaces; P3 supplies the impl. - **T9 depends on P3's mTLS signing endpoint** (§4.5 step 4 "sign short-lived cert") — P3 signs, P5 defines the profile it signs to and verifies + rotates it; and on **P2's** agent-side rotation client. - **T2/T12 are consumed by P1's WS upgrade** (§4.1 OPEN carries `capabilityTokenRef`) and by **P6's** token-request path (login → token). - **T10 publishes a `KillSignal` on the `RevocationBus`** (Redis pub/sub channel `relay:revocations`, §4.2); **P1** subscribes and injects a §4.1 `CLOSE`+RST (per stream) or `GOAWAY` (global) to tear the **live** mux tunnel down within `REVOCATION_PUSH_BUDGET_MS`; **P3** backs the store (`revoked:{jti}`, flip `hosts.status='revoked'`, set `revoked_at`) and the bus. P5 never touches a socket (Finding-2). - **T2 mints/verifies §4.4 `deviceAuthProof`** (`signDeviceAuthProof` / host-side `verifyDeviceProof`, bound to **`{ clientEphPub, clientNonce }`** per INDEX §6b) consumed by **P4's** E2E handshake as an **injected dep** (P4's `DeviceAuthProofProvider` + `createHostHandshake({ verifyDeviceProof, … })`), so P4 binds a device to an account via P5's `AuthenticatedPrincipal`, not its own ad-hoc derivation, and never imports crypto identity from `relay-auth` directly (Finding-7 / §6b). - **`hostContentSecret` delivery to browser devices (§4.5 / FIX 3).** P5 does **NOT** mint or wrap `hostContentSecret` — **P3 mints + wraps it at BIND, sealed to the host's enrolled Ed25519 `agent_pubkey`** (§4.5 `EnrollResult.hostContentSecret`), and **P2 unwraps it locally**. P5's role is the **authorized browser-device delivery gate**: an authorized device obtains `hostContentSecret` **via P5, unwrapped only after auth + step-up (T8)**, so it can re-derive P4's §4.4 `K_content` (`deriveContentKey`) and decrypt replayed ciphertext after a reload. Revoking the host/device (T10) invalidates the wrap → the secret can no longer be unwrapped for that host going forward (INV12). See T8. - **INV9** (secrets validated at startup, never logged) is enforced in every task's config loader. --- ## Wave A ### T1 · `relay-auth` scaffold + P5-local types/schemas · **v0.8 (contracts only)** - **Owns**: `relay-auth/package.json`, `relay-auth/tsconfig.json`, `relay-auth/vitest.config.ts`, `relay-auth/src/types.ts`, `relay-auth/test/types.test.ts` - **Depends**: `relay-contracts/` §4 frozen. **Parallel-safe**: root of all P5 tasks (freeze after T1, like `src/types.ts`). - **Why v0.8**: INDEX §1 v0.8 — "P4/P5 **write their contracts** (so v0.9/v0.10 don't reshape data) but ship no runtime." T1 is that contract freeze; no auth logic runs in v0.8 (the shared `clientToken` gate is P6's). - **Contracts** (P5-local — NOT in §4; §4 types are `import`ed from `relay-contracts`, never re-declared): ```ts // Re-export the frozen §4 types for P5 consumers (import, never redefine): export type { CapabilityToken, CapabilityRight } from 'relay-contracts' // §4.3 export type { HostRecord, HostStatus, PlanTier } from 'relay-contracts' // §4.2 // P5-local principal — the ONLY source of account_id in an authz decision (INV3): export type PrincipalKind = 'human' | 'agent' | 'share-grant' export interface AuthenticatedPrincipal { readonly kind: PrincipalKind readonly accountId: string // DERIVED from verified credential/cert — never from client input 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 successful step-up (T8 freshness); null = never } export type AuthMethod = 'passkey' | 'totp' | 'oidc' | 'mtls' | 'stepup' // FROZEN P5 CONVENTION for the §4.3 `CapabilityToken.sub` field (resolves former open-Q #3): // §4.3 defines `sub` as "principal id (account/device)". P5 (the token's sole issuer, T2) BINDS // `sub` to `principal.accountId` at issuance — the account is the authoritative unit for the // cross-tenant gate (INV1/INV3). Device identity is NOT carried in the token (it is not needed for // the tenant gate; step-up presence lives on the session `AuthenticatedPrincipal`, T8). This is a // semantic choice about the VALUE P5 writes into an existing frozen field — it does NOT redefine // §4.3. The single reader is T3's `accountIdFromToken` resolver (below). export const CAP_TOKEN_SUB_IS_ACCOUNT_ID = true as const // Step-up policy is a P5-local type (not in §4); T8 evaluates it, T12 enforces it: export interface StepUpPolicy { readonly maxAgeSeconds: number; readonly requiredMethod: AuthMethod } export interface WebAuthnCredential { // §4.2 "webauthn_credentials (P5-owned)" readonly credentialId: string; readonly accountId: string readonly publicKey: Uint8Array; readonly signCount: number readonly transports: readonly string[]; readonly createdAt: string } export interface OidcIdentity { // §4.2 "oidc_identities (P5-owned)" readonly issuer: string; readonly subject: string; readonly accountId: string } export interface TotpSecretRecord { // stored ENCRYPTED at rest (INV5); never SMS readonly accountId: string; readonly encSecret: Uint8Array; readonly confirmedAt: string | null } export interface AuditEvent { // INV10 — metadata only, ZERO payload 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: 'allow' | 'deny' readonly reason: string; readonly remoteAddrHash: string // salted, §4.1 remoteAddrHash // COMPILE-TIME GUARD: no field may carry keystrokes/output. Enforced by redact.ts (T4) + lint. } export type AuditAction = | 'attach' | 'reattach' | 'manage' | 'kill' | 'enroll' | 'revoke' | 'login' | 'stepup' | 'token-issue' | 'cross-tenant-attempt' export type SpiffeId = string // 'spiffe://relay./account//host/' export interface RateLimitPolicy { readonly connectPerMin: number; readonly enrollPerHour: number readonly maxConcurrentSessions: number; readonly maxPairedHosts: number // by PlanTier readonly preAuthPerMinPerIp: number // pre-principal throttle keyed on remoteAddrHash (T11, INV-DoS) readonly totpMaxFailsPerWindow: number // TOTP brute-force lockout (T6) readonly totpLockoutWindowSec: number } // Revocation kill-signal — FROZEN in relay-contracts §4.2 (FIX 4), NOT redefined here. The teardown // channel + signal shape were promoted OUT of relay-auth into relay-contracts so P1 can subscribe without // importing P5 (keeps the DAG acyclic). P5 (T10) only PUBLISHES; re-export the frozen types + constants: export type { RevocationScope, KillSignal, RevocationBus } from 'relay-contracts' // §4.2, FIX 4 — never redeclared export { RELAY_REVOCATIONS_CHANNEL, REVOCATION_PUSH_BUDGET_MS } from 'relay-contracts' // 'relay:revocations' · 2000ms // Read-only ports P3 implements (P5 stays storage-free & pure): export interface HostRegistryPort { getById(hostId: string): Promise } 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 of a connect-scoped token (T2/T4 leak-blast-radius control): consumeOnce(jti: string, exp: number): Promise // true = first use (proceed); false = replay (deny) } export interface AuditSink { append(e: AuditEvent): Promise } export interface TokenBucketStore { // P3 Redis; keys are OPAQUE to P5 take(key: string, refillPerSec: number, burst: number, now: number): Promise // false = throttled } // Revocation push bus — `RevocationBus` is FROZEN in relay-contracts §4.2 (FIX 4), re-exported above, NOT // redeclared. P5 (T10) only PUBLISHES immutable `KillSignal`s on `RELAY_REVOCATIONS_CHANNEL`; it never // touches sockets. P3/P1 implement the transport (Redis pub/sub channel `relay:revocations`, §4.2). See T10. // (frozen shape, for reference: interface RevocationBus { publish(signal: KillSignal): Promise }) ``` Every exported interface has a matching **Zod schema** (`WebAuthnCredentialSchema`, `AuditEventSchema`, …) for boundary validation (`coding-style.md` "Input Validation"). - **TDD steps**: (RED) `test/types.test.ts` asserts each Zod schema round-trips a valid record and **rejects** a forged one (extra `accountId` field on a client body is stripped; `AuditEvent` rejects any unknown key → zero-payload guard). (GREEN) write `types.ts`. (REFACTOR) file < 400 lines; split schemas into `types.ts` + `schemas.ts` if it crosses 400. - **Test cases**: valid `AuthenticatedPrincipal` parses; `AuditEvent` with an added `data`/`bytes` key → **throws** (INV10 tripwire at the type layer); `SpiffeId` regex accepts the account/host form and rejects a path-traversal (`../`) or wildcard; `CAP_TOKEN_SUB_IS_ACCOUNT_ID === true` (compile+runtime guard that the `sub`-binding convention is stated in exactly one place, consumed by T2 issuance and T3 `accountIdFromToken`). - **Security**: this task **defines** that `accountId` originates only inside `AuthenticatedPrincipal` (INV3) and that `CapabilityToken.sub` carries **that same `accountId`** (frozen P5 convention above) — so T3's cross-tenant gate compares two account ids derived from authenticated material, never a client field. No schema anywhere in P5 accepts a client-supplied `accountId`/`tenant_id` as authoritative. --- ## Wave B ### T2 · Capability token issue + verify (§4.3) + proof-of-possession + device-auth proof · **v0.9** - **Owns**: `relay-auth/src/capability/issue.ts`, `relay-auth/src/capability/verify.ts`, `relay-auth/src/capability/device-proof.ts`, `relay-auth/test/capability.test.ts`, `relay-auth/test/device-proof.test.ts` - **Depends**: T1. **Consumed by**: P1 WS upgrade (INV15), P6 login→token, T10 revocation, T12 enforce, **P4 E2E handshake** (host-side `verifyDeviceProof(proof, { clientEphPub, clientNonce })` validates §4.4 `ClientHello.deviceAuthProof`, injected into P4 — INDEX §6b / Finding-7 fix). - **Contract** — implements §4.3 verbatim (`CapabilityToken`, `CapabilityRight`, `verifyCapabilityToken`): ```ts import type { CapabilityToken, CapabilityRight } from 'relay-contracts' // §4.3, NOT redefined export interface IssueArgs { readonly principal: AuthenticatedPrincipal // sub := principal.accountId (INV3, T1 convention) readonly aud: string // subdomain (Host-confusion guard, INV1) readonly host: string // single host_id, verified owned by principal.accountId readonly rights: readonly CapabilityRight[] // least-privilege subset readonly ttlSeconds: number // CONNECT-scoped: clamped to [CONNECT_TOKEN_MIN_TTL_SEC=30, // CONNECT_TOKEN_MAX_TTL_SEC=60] — see TTL note below readonly cnfJkt: string // proof-of-possession: base64url SHA-256 of the client's // ephemeral public JWK (DPoP-style channel binding, Finding-4) } // NOTE: `cnfJkt` is embedded as an ADDITIONAL signed claim (`cnf.jkt`, RFC 7800) in the PASETO/JWS token // body. It ADDS a claim; it does NOT redefine the frozen §4.3 `CapabilityToken` interface (which stays the // authoritative semantic shape). Because it adds bytes that cross into P1/P6, it needs the same one-line // INDEX §4.3 confirmation as the token-format pick (§5 open-Q #1). Verifiers read it via `readCnfJkt()` // below, never by mutating the frozen type. Until confirmed, the shipped mitigations (short TTL + single-use // + memory-only client storage) already bound a leak; PoP is the belt-and-suspenders layer. export function readCnfJkt(token: CapabilityToken): string // reads the additive cnf.jkt claim off the verified token export function issueCapabilityToken(a: IssueArgs, signingKey: CryptoKey, now: number): Promise // §4.3 signature — kept VERBATIM (no added params → no contract drift). Verifies token sig/exp/aud only: export function verifyCapabilityToken(raw: string, expectedAud: string, now: number): Promise // DPoP proof-of-possession is a SEPARATE P5-local check (does NOT alter the §4.3 function). The presenting // connection signs (htm,htu,jti,now) with the ephemeral key whose JWK thumbprint == token `cnf.jkt`: export interface DpopContext { readonly proofJws: string; readonly htu: string; readonly htm: string } export function verifyDpopProof(token: CapabilityToken, dpop: DpopContext, now: number): Promise // false if cnf.jkt ≠ thumbprint(dpop signer) OR dpop replayed OR htu/htm mismatch export function subAccountId(token: CapabilityToken): string // returns token.sub (the accountId, T1 convention) ``` **TTL & single-use policy (Finding-4 — leaked-token blast-radius):** connect-scoped tokens are **short-TTL 30–60 s** (`issueCapabilityToken` clamps `ttlSeconds` to that range and rejects longer values) and **single-use**: T12 calls `RevocationStore.consumeOnce(jti, exp)` on the FIRST successful upgrade, so a replayed token (captured in a proxy/log before E2E ships, or a copy-pasted link) is refused on the 2nd use. Longer-lived reuse is obtained only by re-issuing a fresh token via the authenticated login path (P6), never by minting a long TTL. Tokens carry a **`cnf.jkt`** proof-of-possession binding (DPoP-style): the presenting connection must sign a DPoP proof with the ephemeral key it generated, so a bearer copy without that private key cannot upgrade. **P6 MUST hold the token + ephemeral key memory-only (never `localStorage`)**; residual XSS risk is documented in §3. **Device-auth proof (Finding-7 / INDEX §6b — SOLE issuer+verifier of §4.4 `deviceAuthProof`; P4 consumes as an injected dep and never re-derives account binding):** the binding param is **unified across P2/P4/P5 to `{ clientEphPub, clientNonce }`** (frozen §4.4 / INDEX §6b) — a **per-handshake, non-replayable** binding, NOT `transcriptHash` and NOT a static bearer token, so a captured proof cannot be replayed into a different freshly-keyed `client_hello`. P5 mints from the authenticated `AuthenticatedPrincipal` (the proof asserts `principal.accountId`); the tenant gate itself (INV1) is the capability-token cross-tenant check at `onUpgrade` (T3/T12), *upstream* of the handshake. ```ts // `relay-auth/src/capability/device-proof.ts` — the SOLE issuer/verifier of §4.4 ClientHello.deviceAuthProof. export type DeviceProofBinding = { readonly clientEphPub: Uint8Array; readonly clientNonce: Uint8Array } // == frozen §4.4 shape // Client-side minting — backs the §4.4 `DeviceAuthProofProvider.proofFor(hostId, binding)` P4 injects: export function signDeviceAuthProof( principal: AuthenticatedPrincipal, binding: DeviceProofBinding, signingKey: CryptoKey, now: number ): Promise // binds principal.accountId to THIS handshake's clientEphPub‖clientNonce (per-handshake, non-replayable) // Host-side verification — the injected `verifyDeviceProof` P4's createHostHandshake calls (§4.4 / §6b): export function verifyDeviceProof( proof: string, binding: DeviceProofBinding, now: number ): Promise // true iff P5-signed AND bound to THIS clientEphPub/clientNonce; a static/unbound or mis-bound proof → false ``` - **TDD (tests first)**: (RED) `capability.test.ts`, `device-proof.test.ts`. (GREEN) implement with Ed25519 PASETO v4.public / JWS EdDSA. (REFACTOR) no `console.log`; typed errors (`CapabilityError` machine reason); split `issue.ts`/`verify.ts`/`device-proof.ts` each < 200 lines. - **Test cases (incl. negative/security)**: - round-trip: issue→verify returns the same `host`, `rights`, `jti`; `subAccountId(token) === principal.accountId`. - **`sub` binding**: `issueCapabilityToken` sets `token.sub = principal.accountId` (never `principalId`, never a client value) — assert directly (feeds T3's cross-tenant gate). - **expired** (`exp < now`) → reject; **not-yet-valid** (`iat > now+skew`) → reject. - **over-long TTL**: `ttlSeconds > 60` → **reject at issue** (clamp/refuse; no long-lived reusable token). - **single-use**: 2nd `verify` after `consumeOnce` has marked the `jti` → reject (replay). - **proof-of-possession**: valid token + DPoP proof from a **different** key (thumbprint ≠ `cnf.jkt`) → reject; replayed DPoP proof (same jti+now reused) → reject. - **wrong `aud`** (token for `alice`, presented at `bob.term.`) → reject (INV1 Host-confusion). - **tampered payload** (flip a rights bit) → signature fails → reject. - **wildcard `host`** (`'*'`) → reject at issue time (single host only). - **rights escalation**: a token with `rights:['attach']` — `hasRight(token,'kill')` is `false` (INV15). - forged token signed by a different key → reject. - **device-auth proof (binding to `{clientEphPub, clientNonce}`, §6b)**: a proof minted for handshake A (its `clientEphPub`/`clientNonce`) replayed into handshake B with a **different** `clientEphPub` → `verifyDeviceProof(proof, bindingB, now)` returns **`false`** (per-handshake binding defeats replay); a static/unbound bearer proof → `false`; a proof tampered after signing → `false`. - **Security**: `sub`/`host`/`account` are taken from `IssueArgs.principal`, never from a client field (INV3). Token is short-TTL (30–60 s) + single-use + `jti`-revocable (INV12, via T10) + PoP-bound (`cnf.jkt`). `deviceAuthProof` is minted/verified **only here** from P5's `AuthenticatedPrincipal`, bound to **`{ clientEphPub, clientNonce }`** (§6b), so P4's handshake consumes P5's binding rather than inventing its own (Finding-7). Signing key loaded from secret manager, validated at startup, never logged (INV9). ### T3 · Deny-by-default tenant authz core (INV1/INV3/INV6) · **v0.9** - **Owns**: `relay-auth/src/authz/principal.ts`, `relay-auth/src/authz/decide.ts`, `relay-auth/test/authz.test.ts` - **Depends**: T1, T2; **P3** `HostRegistryPort`/`SessionRegistryPort` (§4.2). - **Contract**: ```ts export type AuthzOutcome = // `jti` = the jti of the VERIFIED capability token (resolves reconcile OQ5): P1's thin // adapter no longer verifies the token, so P5 surfaces it here for MuxOpen.capabilityTokenRef // (T10's per-jti splice index / single-device closeStream). P1 consumes read-only; never redefines. | { readonly ok: true; readonly principal: AuthenticatedPrincipal; readonly hostId: string; readonly jti: string } | { readonly ok: false; readonly status: 401 | 403; readonly reason: string } export interface ConnectRequest { readonly capabilityRaw: string // token from the WS upgrade (INV15) readonly expectedAud: string // subdomain the request arrived on readonly requestedHostId: string // client NAMES a host_id it claims to own — NOT account_id (INV3) readonly requiredRight: CapabilityRight } export interface ReattachRequest extends ConnectRequest { readonly sessionId: string } import type { DpopContext } from '../capability/verify' // T2 — proof-of-possession material (Finding-4), NOT redeclared // T3-LOCAL resolver — the SINGLE point that maps a verified token → its authoritative accountId. // Per the frozen T1 convention (CAP_TOKEN_SUB_IS_ACCOUNT_ID), `sub` IS the accountId; this is the // real field the cross-tenant gate compares (resolves former open-Q #3; replaces the undefined // `token.accountBoundFromSub`). Kept as a named function so the mapping is testable & greppable. export function accountIdFromToken(token: CapabilityToken): string // returns token.sub (validated non-empty) // The ONLY authorization path (INV1: no host resolved by raw addr/hostname): export function authorizeConnect( req: ConnectRequest, dpop: DpopContext, hosts: HostRegistryPort, revocation: RevocationStore, now: number ): Promise export function authorizeReattach( req: ReattachRequest, dpop: DpopContext, hosts: HostRegistryPort, sessions: SessionRegistryPort, revocation: RevocationStore, now: number ): Promise ``` **Decision algorithm (deny-by-default, early-return):** ``` token = await verifyCapabilityToken(req.capabilityRaw, req.expectedAud, now) // fail (sig/exp/aud) → 401 if (!(await verifyDpopProof(token, dpop, now))) return 401 // proof-of-possession (Finding-4) if (await revocation.isRevoked(token.jti)) return 403 // INV12 if (!token.rights.includes(req.requiredRight)) return 403 // INV15 least-priv if (token.host !== req.requestedHostId) return 403 // token scopes ONE host (INV1) host = await hosts.getById(req.requestedHostId); if (!host) return 403 if (host.status === 'revoked') return 403 // INV12 // account_id comes from the token's authenticated `sub`, host.account_id from the registry — // NEVER from a client field (INV3). accountIdFromToken() is the sole resolver (T1 convention): tokenAccountId = accountIdFromToken(token) // == token.sub == principal.accountId if (host.accountId !== tokenAccountId) return 403 // THE cross-tenant gate (INV1) // reattach ADDS: session must exist AND belong to same account (INV6 re-validate): if (reattach) { s = await sessions.getById(req.sessionId); if (!s || s.accountId !== host.accountId) return 403 } return { ok:true, principal, hostId: host.hostId } ``` There is **no `allow-if-unspecified` branch** — the function returns 403 unless every check passes. Note: single-use consumption (`RevocationStore.consumeOnce`) is done by T12 **after** a full allow, so a denied attempt does not burn the token; T3 stays a pure decision function. - **TDD (tests first)**: RED `authz.test.ts` with a fake `HostRegistryPort` seeded with host B owned by account B. GREEN implement. REFACTOR: `decide.ts` < 200 lines; each function < 50 lines; no nesting > 4. - **Test cases (security-critical)**: - **INV1 cross-tenant (THE tripwire, against the real `sub` field)**: a **validly signed** token whose `sub` (= account A) does **not** resolve to `host.accountId` (host owned by B) → `accountIdFromToken(token)` returns A, `host.accountId` is B, gate fails → **403**. This exercises the concrete field, not a placeholder. - **`accountIdFromToken` unit**: returns `token.sub`; throws on empty/malformed `sub` (deny-by-default input guard). - **INV6 reattach cross-tenant**: valid host-A token but `sessionId` owned by B → **403**. - **INV3 forged account**: a `ConnectRequest` shape carrying an extra `account_id` field is *type-impossible* (not in schema) → cannot influence the decision; assert the decision uses only `host.accountId` vs `accountIdFromToken(token)`, never any client-supplied value. - missing/invalid token → **401**; failing DPoP proof (PoP mismatch) → **401**; revoked `jti` → **403**; `requiredRight='kill'` with `attach`-only token → **403**; `token.host !== requestedHostId` → **403** (defeats IDOR by swapping the path host). - happy path: account A + own host + `attach` right + live host → `ok:true`. - **Security**: this is the single deny-by-default gate (INV1/INV3/INV6). Every branch that isn't an explicit allow is a deny. Never resolve a host by anything except a registry lookup keyed on the token-scoped `host_id`. ### T4 · Immutable zero-payload audit log (INV10) · **v0.10** - **Owns**: `relay-auth/src/audit/log.ts`, `relay-auth/src/audit/redact.ts`, `relay-auth/test/audit.test.ts` - **Depends**: T1; **P3** `AuditSink` (append-only Postgres table / WORM store). - **Contract**: ```ts export function buildAuditEvent(p: { action: AuditAction; principal: AuthenticatedPrincipal | null; hostId: string | null sessionId: string | null; jti: string | null; outcome: 'allow' | 'deny' reason: string; remoteAddrHash: string; now: number }): AuditEvent export function assertZeroPayload(e: AuditEvent): void // throws if any value looks like terminal bytes export async function audit(sink: AuditSink, e: AuditEvent): Promise // append-only; never update/delete ``` - **TDD**: RED `audit.test.ts`. GREEN implement `redact.ts` (`assertZeroPayload` rejects any string field > a metadata length cap or containing control/ESC bytes `\x1b`). REFACTOR keep < 200 lines. - **Test cases**: a well-formed `attach` allow event appends; a `deny` event records `reason`; **payload guard** — an event whose `reason` contains `\x1b[` or a >256-char blob → `assertZeroPayload` **throws** (INV10); the sink is append-only (a test double rejects `update`/`delete`). - **Security**: audit entries are metadata only — principal, host_id, action, ts, outcome (INV10). Terminal payload is *structurally* excluded (no field carries it). Immutable/append-only (INV8-adjacent). ### T5 · Passkey / WebAuthn (primary) · **v0.10** - **Owns**: `relay-auth/src/human/webauthn/register.ts`, `.../authenticate.ts`, `relay-auth/test/webauthn.test.ts` - **Depends**: T1; **P3** `CredentialStore` (persists `WebAuthnCredential`, §4.2 P5-owned table). Uses `@simplewebauthn/server` (battle-tested, per `development-workflow.md` reuse rule). - **Contract**: ```ts export function startRegistration(accountId: string, rpId: string): Promise export function finishRegistration(accountId: string, resp: RegistrationResponse, expectedChallenge: string, rpId: string, origin: string): Promise // verifies attestation export function startAuthentication(accountId: string, rpId: string): Promise export function finishAuthentication(cred: WebAuthnCredential, resp: AuthenticationResponse, expectedChallenge: string, rpId: string, origin: string): Promise ``` - **TDD / test cases**: challenge is single-use (replayed challenge → reject); **origin mismatch** (assertion from `evil.com`) → reject; **signCount regression** (counter goes backwards → cloned-authenticator signal) → reject; unknown `credentialId` → reject; happy path yields a principal with `amr:['passkey']`. - **Security**: Passkey is **primary** (phishing-resistant; the payload is a root-capable shell). `rpId` bound to the tenant subdomain. Never SMS. Challenges from CSPRNG, short TTL, never logged (INV9). ### T6 · TOTP fallback (RFC 6238, never SMS) · **v0.10** - **Owns**: `relay-auth/src/human/totp/totp.ts`, `relay-auth/test/totp.test.ts` - **Depends**: T1; T11 `TokenBucketStore` (attempt-lockout counter); `TotpSecretRecord` stored **encrypted** at rest (INV5). - **Contract**: ```ts export function generateTotpSecret(): { secret: Uint8Array; otpauthUri: string } export function verifyTotp(secret: Uint8Array, code: string, now: number, window?: number): boolean // ±1 step // Brute-force lockout (Finding-6): a 6-digit code has only 10^6 space; without throttling it is // guessable within one 30 s step. Called BEFORE verifyTotp; records failures; locks per-account. export function checkTotpAttemptRate( accountId: string, policy: RateLimitPolicy, store: TokenBucketStore, now: number ): Promise // false = locked out (deny without even checking the code) export function recordTotpFailure(accountId: string, store: TokenBucketStore, now: number): Promise ``` - **TDD / test cases**: known RFC-6238 vector passes; code from the previous 30 s step within `window` passes; two steps stale → fail; **replay guard** — the caller (T8) marks a consumed `(secret, step)` used, so the same code inside the window can't be reused; malformed code (non-numeric / wrong length) → `false`; **lockout (Finding-6)** — after `policy.totpMaxFailsPerWindow` (e.g. 5) consecutive bad codes within `policy.totpLockoutWindowSec`, `checkTotpAttemptRate` returns `false` (further attempts denied without hitting `verifyTotp`) and unlocks only after the window elapses (escalating backoff); a correct code after a failure below the threshold still succeeds. - **Security**: TOTP is a *fallback*, mandatory-second-factor style, **never SMS** (SIM-swap → shell = catastrophic, EXPLORE §4a.2). Secret encrypted at rest, decrypted only in-process (INV5/INV9). The per-account failed-attempt lockout (via T11's `TokenBucketStore`, keyed on the authenticated `accountId`, INV3) closes the brute-force hole — a 6-digit code is otherwise trivially guessable in one step window. ### T7 · OIDC SSO for teams · **v0.10** - **Owns**: `relay-auth/src/human/oidc/oidc.ts`, `relay-auth/test/oidc.test.ts` - **Depends**: T1; `OidcIdentity` store (P3). Auth-code flow **+ PKCE**, `state`/`nonce` validation. - **Contract**: ```ts export function buildAuthUrl(cfg: OidcConfig, state: string, nonce: string, codeChallenge: string): string export function exchangeCode(cfg: OidcConfig, code: string, codeVerifier: string): Promise export function verifyIdToken(cfg: OidcConfig, idToken: string, expectedNonce: string, now: number) : Promise // links issuer+subject → accountId (team mapping) ``` - **TDD / test cases**: `state` mismatch → reject (CSRF); `nonce` mismatch → reject (replay); expired/bad-`iss` id_token → reject; unknown issuer not in the allow-list → reject; happy path returns `OidcIdentity` mapped to the team account. - **Security**: PKCE mandatory; issuer allow-list from config (validated at startup, INV9); `account_id` derived from the verified `(iss, sub)` mapping, never from a client claim (INV3). ### T8 · Step-up auth before opening a session · **v0.10** - **Owns**: `relay-auth/src/human/stepup/stepup.ts`, `relay-auth/test/stepup.test.ts` - **Depends**: T1 (`StepUpPolicy` type lives there, imported not redefined); T5 (preferred) / T6; T2 (a session-open capability token is only issued after step-up passes). **Consumed by T12** (the enforcement path calls `needsStepUp` before returning `ok:true` — see T12 v0.10 augmentation). - **Contract** (`StepUpPolicy` imported from `relay-auth/src/types.ts`, T1 — NOT redefined here): ```ts import type { StepUpPolicy } from '../../types' // T1 export function policyForHost(host: HostRecord): StepUpPolicy // per-host step-up freshness requirement export function needsStepUp(principal: AuthenticatedPrincipal, policy: StepUpPolicy, now: number): boolean export function recordStepUp(principal: AuthenticatedPrincipal, method: AuthMethod, now: number): AuthenticatedPrincipal // immutable copy ``` `needsStepUp` is true when `principal.stepUpAt` is null OR `now - principal.stepUpAt > policy.maxAgeSeconds` OR `policy.requiredMethod ∉ principal.amr` — i.e. a *fresh login is not sufficient*; a recent step-up is required. `recordStepUp` returns a new principal with `stepUpAt = now` and `method` added to `amr`. - **TDD / test cases**: a principal freshly logged in (`authAt = now`) but with `stepUpAt = null` → `needsStepUp` **true** (login ≠ step-up — this is the exact case T12 must refuse, Finding-3); a principal whose `stepUpAt` is older than `maxAgeSeconds` → true; fresh passkey step-up (`stepUpAt = now`, requiredMethod in `amr`) → false; `recordStepUp` returns a **new** principal (immutability rule), original untouched; a token to **open a session** is refused by T12 unless `needsStepUp` is false. - **Security**: step-up gates **opening a session**, not just login (EXPLORE §4a.2 / INDEX §1 v0.10). This is the "before you drop into a live root-capable shell, re-assert presence" control. It is enforced in T12's `onUpgrade`/`onReattach` sequence (v0.10 augmentation), **not** merely asserted in prose (Finding-3). - **`hostContentSecret` browser-delivery gate (§4.5 / FIX 3).** A fresh step-up here is **also** the gate for releasing the §4.5 `hostContentSecret` to an authorized browser device: it is **unwrapped for the device only after `needsStepUp` is false**, so the device can re-derive P4's §4.4 `K_content` (`deriveContentKey`) and decrypt replayed ciphertext. **P5 does not mint/wrap it — P3 mints+wraps at BIND (sealed to `agent_pubkey`), P2 unwraps for the agent** — P5 only performs the step-up-gated release to browser devices; a revoked host/device (T10) can no longer obtain it (INV12). ### T9 · mTLS SPIFFE cert profile, verification & rotation (INV14/INV4) · **v0.9** - **Owns**: `relay-auth/src/agent/spiffe.ts`, `relay-auth/src/agent/verify-mtls.ts`, `relay-auth/src/agent/rotate.ts`, `relay-auth/test/mtls.test.ts` - **Depends**: T1; **P3** signing endpoint (§4.5 step 4 signs the cert P5 profiles); **P2** agent-side rotation client. P5 owns the **profile + verification + rotation policy**, not the CA private key (P3). - **Contract**: ```ts export function spiffeIdFor(accountId: string, hostId: string): SpiffeId // 'spiffe://relay./account//host/' export function parseSpiffeId(uri: SpiffeId): { accountId: string; hostId: string } export interface MtlsVerifyResult { readonly ok: boolean; readonly hostId?: string; readonly accountId?: string; readonly reason?: string } // Verify the presented client cert chain at the agent tunnel handshake: export function verifyAgentCert(leafPem: string, caChainPem: string, now: number, hosts: HostRegistryPort): Promise // SPIFFE-ID SAN must match a host in registry export interface RotationPlan { readonly renewAtSeconds: number; readonly certTtlSeconds: number } // TTL short (e.g. 1h), renew at ~50% export function shouldRotate(certNotAfter: number, now: number, plan: RotationPlan): boolean ``` - **TDD / test cases**: cert whose SPIFFE-ID SAN account/host **is not in the registry** → `ok:false` (CA never validates a pubkey not enrolled, INV4); **expired** leaf → refuse; **SPIFFE-ID mismatch** (cert for host X presented on host Y's tunnel) → refuse; `shouldRotate` true at ~50% TTL and true after expiry, false when fresh; rotation mid-tunnel is seamless (a new valid cert supersedes without dropping — asserted via the rotation-plan boundary). - **Security**: agents authenticate with a **per-host Ed25519 key + short-TTL rotating cert** (INV14); DB holds only the **public** key (INV4). Revocation = "stop renewing + kill tunnel" (T10) — no CRL/OCSP nightmare. mismatched/expired/foreign certs are refused deny-by-default. ### T10 · Global + per-host revocation, kills live tunnels in seconds (INV12) · **v0.9 (fast-push: v0.10)** - **Owns**: `relay-auth/src/revocation/revoke.ts`, `relay-auth/src/revocation/check.ts`, `relay-auth/test/revocation.test.ts` - **Depends**: T1 (`RevocationScope`/`KillSignal` types), T2; **P3** `RevocationStore` (Redis `revoked:{jti}`, §4.2; flip `hosts.status='revoked'`, set `revoked_at`) + `RevocationBus` (Redis pub/sub); **P1** subscribes to the bus and force-closes the live streams. - **Contract** (`RevocationScope`/`KillSignal` imported from T1 `types.ts`, NOT redefined here): ```ts // RevocationScope/KillSignal/RevocationBus + RELAY_REVOCATIONS_CHANNEL/REVOCATION_PUSH_BUDGET_MS are FROZEN // in relay-contracts §4.2 (FIX 4), re-exported by T1 types.ts — imported here, NEVER redefined: import type { RevocationScope, KillSignal, RevocationBus } from '../types' // T1 re-export of relay-contracts §4.2 import { RELAY_REVOCATIONS_CHANNEL, REVOCATION_PUSH_BUDGET_MS } from '../types' // 'relay:revocations' · 2000ms export function revoke( scope: RevocationScope, store: RevocationStore, hosts: HostRegistryPort, bus: RevocationBus, now: number ): Promise // Steps (ordered): 1) mark revoked in store (revoked:{jti} / hosts.status='revoked', revoked_at) // 2) stop cert renewal (T9 shouldRotate sees revoked → never renews) // 3) PUBLISH the KillSignal on RevocationBus ← the push channel (Finding-2) // 4) return the KillSignal export function isTokenRevoked(jti: string, store: RevocationStore): Promise export function killsScope(signal: KillSignal, hostAccountId: string, hostId: string): boolean // does this signal cover this stream? ``` **Live-tunnel teardown transport (resolves former open-Q #4, Finding-2; contract FROZEN in §4.2 by FIX 4):** revocation must tear down an **already-open** stream, not merely refuse the next connect. P5 stays socket-free: `revoke()` **publishes** an immutable `KillSignal` on the injected `RevocationBus`, whose transport is the **frozen `RELAY_REVOCATIONS_CHANNEL = 'relay:revocations'`** Redis pub/sub channel (relay-contracts §4.2, FIX 4 — P3/P1 back it). Every P1 relay node subscribes; on receipt it injects a **§4.1 control frame** for each affected stream — a per-stream `CLOSE` with `flags.RST` (abnormal close) for host/account scope, or a connection-level `GOAWAY` (streamId 0) for global drain — using the **existing frozen §4.1 frame format** (no new wire type, no P5↔socket coupling). `killsScope()` is the pure predicate P1 uses to decide which streams a signal covers (host match, account match, or global). Bounded latency target: **teardown within the frozen `REVOCATION_PUSH_BUDGET_MS` (= 2000 ms, §4.2)** of `revoke()` returning. **P3 OQ4's "control→node drain channel" reconciles to this exact channel** (FIX 4), so P3 and P5 publish to one named bus. - **TDD / test cases**: revoking a host → its capability `jti`s stop validating (T2 verify + T3 authz both now 403); **live-tunnel teardown (Finding-2)** — with a fake `RevocationBus` + a fake P1 subscriber holding an already-authorized open stream, `revoke()` publishes a `KillSignal` and the subscriber force-closes that stream (asserted via `killsScope` selecting it and an injected RST) **within `REVOCATION_PUSH_BUDGET_MS`** — i.e. the running shell is dropped, not left alive until the client happens to reconnect; a **subsequent reconnect is also refused** (INV12); global revocation covers all hosts (GOAWAY); account revocation covers only that account's hosts and no other tenant's (`killsScope` false for a foreign account); revocation is idempotent (re-publish is a no-op teardown). - **Security**: one control to kill all tunnels + stop cert issuance (INV12). Fast **push** path: Redis `revoked:{jti}` short-TTL keys (for the connect-time gate) **plus** a `relay:revocations` pub/sub `KillSignal` that the data plane (P1) turns into a §4.1 RST/GOAWAY, so an active session's socket drops in seconds — the live shell of a revoked account cannot keep streaming until token expiry. ### T11 · Rate-limits / quotas (pre-auth IP + per-tenant account) · **v0.9** - **Owns**: `relay-auth/src/ratelimit/quota.ts`, `relay-auth/test/quota.test.ts` - **Depends**: T1; `RateLimitPolicy` by `PlanTier` (§4.2); a `TokenBucketStore` port (P3 Redis, T1). - **Contract**: ```ts export function policyForPlan(plan: PlanTier): RateLimitPolicy export const PRE_AUTH_POLICY: RateLimitPolicy // fixed, plan-independent (no account known yet) // PRE-AUTH throttle (Finding-5): applied in T12 onUpgrade BEFORE token verification, keyed on the // salted client-IP hash (§4.1 remoteAddrHash) — the ONLY identifier available before a principal exists. // Blunts subdomain enumeration, capability-token-verifier brute-force, and WebAuthn/TOTP guessing DoS. export function checkPreAuthRate(remoteAddrHash: string, store: TokenBucketStore, now: number): Promise // PER-TENANT quotas (keyed on the authenticated accountId, INV3): export function checkConnectRate(accountId: string, policy: RateLimitPolicy, store: TokenBucketStore, now: number): Promise export function checkConcurrentSessions(accountId: string, active: number, policy: RateLimitPolicy): boolean export function checkEnrollRate(accountId: string, policy: RateLimitPolicy, store: TokenBucketStore, now: number): Promise ``` - **TDD / test cases**: **pre-auth (Finding-5)** — a single `remoteAddrHash` hammering the upgrade endpoint across **many distinct subdomains / `host_id`s with invalid or forged tokens** is throttled by `checkPreAuthRate` after `PRE_AUTH_POLICY.preAuthPerMinPerIp`, **independent of any account** (there is none yet), and the throttle refills after its window; connects beyond `connectPerMin` → `false` (429 at the caller); at limit → allowed, over → denied, refills after the window; concurrent sessions over `maxConcurrentSessions` → denied; enroll spam over `enrollPerHour` → denied; per-account limits are **per `accountId`** — account A hitting its limit does not affect account B; **cross-key isolation** — a pre-auth IP throttle and an account quota use disjoint key namespaces (no bleed). - **Security**: two layers. The **pre-auth IP throttle** stops enumeration/brute-force/DoS *before* a principal is established (Finding-5) — without it, an unauthenticated attacker could hammer the token verifier and WebAuthn/TOTP ceremonies unthrottled. The **per-tenant quotas** blunt authenticated DoS/enrollment abuse (EXPLORE §4e HIGH), keyed strictly on the authenticated `accountId` (INV3), never a client-supplied identifier. Pre-auth keys are the salted `remoteAddrHash` only (no raw IP at rest, INV5/INV9). --- ## Wave C ### T12 · Enforcement entry points (`onUpgrade` / `onReattach`) · **v0.9 core + v0.10 augmentation** - **Owns**: `relay-auth/src/enforce/onUpgrade.ts`, `relay-auth/src/enforce/onReattach.ts`, `relay-auth/test/enforce.test.ts` - **Depends**: **v0.9 core** → T2, T3, T10, T11 (this matches the Wave-C DAG; reconciled per Finding-8). **v0.10 augmentation** → T4 (audit emission) + T8 (step-up gate). The v0.9 build ships and passes with the audit/step-up hooks as **no-op-safe injected deps** (an `AuditSink` that appends and a step-up policy that returns "not required" when no policy is configured); v0.10 supplies the real audit sink and per-host step-up policy. No v0.9 task blocks on a v0.10 task. **Consumed by**: **P1** (calls `onUpgrade` on the WS handshake, INV15), **P3** (calls on connect/reattach). These are the *only* two functions the byte plane invokes. **Sole owner of the WS-upgrade authorization DECISION (INDEX §6a).** The full decision — Origin/CSWSH retained + capability verify + **DPoP proof-of-possession** + single-use `jti` burn + pre-auth throttle + step-up gate + per-tenant rate + deny-by-default cross-tenant gate + audit — lives **only** in P5 `onUpgrade`/`onReattach`. **P1 T8 `authorizeUpgrade` is a THIN ADAPTER that DELEGATES here** (§6a): it parses the upgrade (Origin, subprotocol token via §4.3 FIX 5, subdomain), builds the `UpgradeContext` — **including `dpop: DpopContext` and `activeSessionCount`** — calls P5, and maps the returned `AuthzOutcome` to a §4.1 `MuxOpen` or a 401/403 close. P1 holds **no** independent authz logic; P5 is the injected authorizer. - **Contract**: ```ts export interface UpgradeContext { readonly capabilityRaw: string; readonly originHeader: string; readonly expectedAud: string readonly requestedHostId: string; readonly requiredRight: CapabilityRight; readonly remoteAddrHash: string readonly activeSessionCount: number readonly dpop: DpopContext // proof-of-possession material (T2/T3, Finding-4) readonly principal: AuthenticatedPrincipal | null // session principal for the step-up freshness check (T8) } export interface EnforceDeps { readonly hosts: HostRegistryPort; readonly sessions: SessionRegistryPort readonly revocation: RevocationStore; readonly buckets: TokenBucketStore; readonly audit: AuditSink readonly stepUpPolicyFor: (host: HostRecord) => StepUpPolicy // v0.10; v0.9 injects a "never required" policy } // Retain Origin/CSWSH check AND require capability token (INV15) + deny-by-default authz (INV6): export function onUpgrade(ctx: UpgradeContext, deps: EnforceDeps, allowedOrigins: readonly string[], now: number): Promise export function onReattach(ctx: UpgradeContext & { sessionId: string }, deps: EnforceDeps, allowedOrigins: readonly string[], now: number): Promise ``` **Sequence (deny-by-default, audited), in order:** 1. **Origin/CSWSH**: `originHeader ∈ allowedOrigins` else **401** (retain base-app check, INV15). 2. **Pre-auth throttle (Finding-5)**: `checkPreAuthRate(ctx.remoteAddrHash, …)` **before any token work** — keyed on the IP hash, no principal yet; over limit → **429-equiv deny**. Blunts enumeration/brute-force. 3. **Per-tenant rate**: after a principal is known, `checkConnectRate(accountId, …)` (T11) → deny if over. 4. **Authz (deny-by-default)**: `authorizeConnect`/`authorizeReattach(req, ctx.dpop, …)` (T3) — verifies the capability token incl. **DPoP proof-of-possession**, then the INV1 cross-tenant gate. Fail → 401/403. 5. **Step-up gate (Finding-3, v0.10 augmentation)**: on authz **allow**, before returning `ok:true`, call `needsStepUp(ctx.principal, deps.stepUpPolicyFor(host), now)` (T8). If true → **403** + a `AuditEvent(action:'stepup', outcome:'deny')`. This is the exact guarantee T8 claims: fresh-login-but-stale-step-up is refused at `onUpgrade`, before a PTY is reachable. 6. **Single-use consume (Finding-4)**: on a fully-allowed connect, `revocation.consumeOnce(token.jti, exp)` — if it returns false (already used) → **403** (replayed token). (Reattach reuses the same session-scoped token semantics; connect-scoped tokens are burned here.) 7. **Audit + return**: on **deny** emit exactly one `deny` `AuditEvent` (T4; `cross-tenant-attempt` action when `host.accountId !== accountIdFromToken(token)`) and return the 401/403; on **allow** emit an `attach`/`reattach` allow event and return `ok:true`. No allow path writes payload (INV10). **Session-open boundary (resolves former open-Q #2, Finding-3):** the step-up gate and the whole authz decision run at the **WS upgrade** (`onUpgrade`), i.e. **before** the relay dials `ws://127.0.0.1:3000` and before any `attach` frame can reach a PTY — the base app's `attach`-first-frame is *downstream* of a stream that P1 only opens after `onUpgrade` returns `ok:true`. So "opening a session" is gated at the upgrade, not left to the attach frame; a stale-step-up principal never gets a live stream. - **TDD / test cases**: - **v0.9 core**: foreign Origin → **401** even with a valid token (base-app behavior retained, INV15); valid Origin, no token → 401; failing DPoP proof → 401 (Finding-4); valid Origin + token for host B while authed as A → **403** (INV1); pre-auth: one IP hammering many subdomains with bad tokens → throttled **before** token verification, independent of account (Finding-5); per-account rate-limited → 429-equiv deny; reattach to a foreign session → 403; replayed single-use token (2nd upgrade) → 403; happy path → `ok:true`. - **v0.10 augmentation**: valid Origin + valid capability token + authz allow, but principal with `stepUpAt=null` (fresh login only) and a host policy requiring step-up → **403** at `onUpgrade` (Finding-3, matches T8's claimed guarantee); after `recordStepUp`, same request → `ok:true`; every deny path writes exactly one `AuditEvent` (incl. a `cross-tenant-attempt` on A→B and a `stepup` deny on stale step-up); no allow path writes payload. - **Security**: this is where Origin + pre-auth throttle + capability token (PoP + single-use) + deny-by-default authz + step-up + per-tenant rate + audit compose. Origin check is **retained AND augmented** (INV15) — never weakened (INDEX §2 rule 6). The step-up check is on the enforcement path, not prose (Finding-3); the pre-auth throttle precedes token work (Finding-5). ### T13 · Permanent CI cross-tenant tripwire (INV1) · **v0.9** - **Owns**: `relay-auth/test/tripwire/cross-tenant.test.ts`, `.github/workflows/relay-tripwire.yml` - **Depends**: T12 (and, for the full-stack variant, P1's data plane + P3's registry — the unit variant uses in-memory port fakes so the tripwire runs even before P1/P3 integrate). - **Contract**: a self-contained test that seeds account A (host A) and account B (host B), authenticates as A, and asserts **every** A→B path returns **403**. Two variants (reconciled per Finding-8 phasing): - **v0.9 unit variant (403 outcomes only — the required CI check):** uses in-memory port fakes; asserts the 403s. Depends only on T12 core (T2/T3/T10/T11): - `onUpgrade` with A's token but `requestedHostId = hostB` → **403**. - `onReattach` with A's token but a `sessionId` owned by B → **403**. - fuzz: 1000 random UUIDv4 `host_id`s not owned by A → all **403** (guessing/reuse defense, INV1). - `aud` confusion: A's token replayed at `bob.term.` → **403**. - **v0.10 full-stack variant (audit + alert):** once T4 (audit) and T14 (alert) ship, additionally assert a `cross-tenant-attempt` audit event **and** an alert fired for each A→B attempt. These assertions are **deferred to v0.10** so the v0.9 unit tripwire does not depend on v0.10 tasks (Finding-8). - **CI**: `.github/workflows/relay-tripwire.yml` runs this on **every push/PR** and is a **required check** — a green build is impossible if cross-tenant isolation regresses. This is the permanent tripwire from EXPLORE §4b / INV1; it must never be deleted or skipped (a comment in the file states this). - **Security**: this is the load-bearing regression guard for the single non-negotiable invariant (INV1). If this test ever goes red, **block merge** (CRITICAL, `code-review.md`). ### T14 · Audit-alert wiring (cross-tenant crossing) · **v0.10** - **Owns**: `relay-auth/src/audit/alert.ts`, `relay-auth/test/alert.test.ts` - **Depends**: T4, T12. **Consumed by**: an ops alerting channel (P3/infra). - **Contract**: ```ts export interface Alerter { fire(kind: 'cross-tenant' | 'revocation' | 'auth-anomaly', e: AuditEvent): Promise } export function onAuditEvent(e: AuditEvent, alerter: Alerter): Promise // fires on action==='cross-tenant-attempt' ``` - **TDD / test cases**: a `cross-tenant-attempt` event fires a `cross-tenant` alert; an ordinary `attach` allow does **not** alert; alert payload carries metadata only (no terminal bytes, INV10). - **Security**: turns the INV1 tripwire into a **runtime** detector — an A→B attempt in production raises an operator alert immediately (EXPLORE §4e HIGH, INV10). --- ## 3. SECURITY (per-plan summary) | Control | Task(s) | Invariant | |---|---|---| | `account_id` only ever from the authenticated principal; no client field trusted | T1, T3, T12 | **INV3** | | Deny-by-default authz on connect **and** reattach (no allow-if-unspecified branch) | T3, T12 | **INV6** | | Cross-tenant isolation A↛B + fuzz + `aud`-confusion guard + **permanent CI tripwire** | T3, T12, **T13** | **INV1** | | Capability token required on WS upgrade; Origin/CSWSH retained AND augmented; least-privilege rights | T2, T12 | **INV15** | | Capability tokens: **short-TTL 30–60 s**, **single-use** (`consumeOnce`), **DPoP proof-of-possession** (`cnf.jkt`) so a leaked bearer copy can't upgrade; token memory-only in P6 | T2, T12 | **INV15** (leak blast-radius) | | §4.4 `deviceAuthProof` minted/verified only by P5 from `AuthenticatedPrincipal` (P4 consumes, never re-derives account binding) | T2 (→P4) | **INV3** | | **Pre-auth** IP/`remoteAddrHash`-keyed throttle before token verification (enumeration/brute-force/DoS) **plus** per-tenant quotas | T11, T12 | (DoS/abuse, §4e HIGH) | | TOTP failed-attempt **lockout/backoff** (per-account, via T11 bucket) closes the 10^6 brute-force hole | T6, T11 | (second-factor integrity) | | Live-tunnel **push teardown**: `revoke()` → `RevocationBus` → P1 injects §4.1 RST/GOAWAY within `REVOCATION_PUSH_BUDGET_MS`; not just next-connect refusal | T10 (→P1) | **INV12** | | Per-host asymmetric identity; DB stores only public keys; private key never leaves host | T9 (with P2/P3) | **INV4** | | mTLS SPIFFE short-TTL auto-rotating certs; CA never signs a non-enrolled pubkey | T9 (with P2/P3) | **INV14** | | Fast global/per-host revocation kills live tunnels in seconds | T10 | **INV12** | | Immutable, append-only, ZERO-payload audit + cross-tenant alert | T4, T12, T14 | **INV10** | | Passkey primary (phishing-resistant), TOTP fallback, **never SMS**, OIDC PKCE, step-up before session open | T5–T8 | (auth model, LOCKED §0.5) | | Per-tenant rate-limits/quotas keyed on authenticated `accountId` | T11 | (DoS/abuse, §4e HIGH) | | Secrets in env/secret-manager, validated at startup, never logged | all (config loaders) | **INV9** | **Residual risk — capability-token exfiltration (Finding-4, documented not hidden):** the payload behind a successful upgrade is a full, potentially root-capable shell. P5 shrinks the blast radius of a leaked token to near-zero — **short 30–60 s TTL**, **single-use** (`consumeOnce` burns the `jti` on first upgrade), and a **DPoP `cnf.jkt` proof-of-possession** so a bearer copy without the client's ephemeral private key cannot upgrade. The one residual vector is a browser **XSS** that both reads the in-memory token *and* uses the in-memory ephemeral key within the 30–60 s window; P6 MUST therefore hold both **memory-only (never `localStorage`/`sessionStorage`)** and P4's E2E (v0.10) further removes the plaintext-shell reward. This residual is accepted and tracked, not silently ignored. **Deliberate non-goals (owned elsewhere / structural):** P5 never touches terminal bytes → INV2/INV11 hold **by construction** (the package has no `ws`, no xterm, no ANSI parser). Immutable storage records + atomic snapshot swap (INV8) live in P3; P5 consumes read-only ports and returns immutable copies (e.g. `recordStepUp`). **Phasing recap (reconciled per Finding-8):** **v0.8** = T1 only (contracts frozen, no runtime — per INDEX §1). **v0.9** = T2 (capability tokens incl. PoP + single-use + `deviceAuthProof`), T3, T9, T10 (revoke + `RevocationBus` publish; **live push teardown** is available here, only the tighter latency SLO is tuned in v0.10), T11 (pre-auth IP throttle **+** per-tenant quotas), **T12 core** (Origin + capability verify + deny-by-default authz + pre-auth/rate limit), **T13 v0.9 unit variant** (A→B = 403 only) — per INDEX §1 v0.9. **v0.10** = T4, T5, T6 (TOTP + lockout), T7, T8 (step-up), T14, **T12 augmentation** (audit emit + step-up gate), **T13 v0.10 full-stack variant** (audit+alert asserts) (Passkey/WebAuthn primary + step-up + fast-revocation SLO + immutable zero-payload audit — per INDEX §1 v0.10). **No v0.9 task depends on a v0.10 task** — v0.10 items are injected-dep augmentations of already-shipping v0.9 code. --- ## 4. VERIFICATION ```bash # Unit + negative/security tests (TDD, tests FIRST), per task: npx vitest run capability # T2: sub:=accountId, expiry/aud/tamper/wildcard/rights-escalation, TTL≤60s clamp, single-use, DPoP PoP npx vitest run device-proof # T2: §4.4 deviceAuthProof bound to {clientEphPub,clientNonce}; replay-into-other-handshake/unbound/tampered → false (§6b/Finding-7) npx vitest run authz # T3: INV1 cross-tenant 403 via accountIdFromToken, INV6 reattach 403, INV3 no client account_id npx vitest run audit # T4: append-only + zero-payload guard (INV10) npx vitest run webauthn totp oidc stepup # T5–T8: origin/challenge/replay/TOTP lockout/step-up (stepUpAt) freshness npx vitest run mtls # T9: SPIFFE mismatch/expiry/non-enrolled → refuse (INV4/INV14) npx vitest run revocation # T10: revoke → token invalid + LIVE-TUNNEL push teardown (RevocationBus) within budget + reconnect refused (INV12) npx vitest run quota # T11: pre-auth IP throttle (no account) + per-tenant limits, no cross-tenant bleed npx vitest run enforce # T12: Origin+preAuth+token(PoP,single-use)+authz+step-up+rate+audit compose; foreign Origin 401 (INV15) # THE permanent tripwire — must be a REQUIRED CI check (INV1): npx vitest run tripwire/cross-tenant # A→B = 403 on connect AND reattach + fuzz + aud-confusion # (also runs in .github/workflows/relay-tripwire.yml on every push/PR — never skip/delete) # Whole-package gates: npx vitest run --coverage # 80%+ across relay-auth (testing.md) npm run typecheck # relay-auth strict tsc; relay-contracts (§4) imported read-only, unchanged npm run lint # asserts no console.log; no import of ws/xterm/ANSI parser (INV2/INV11) # Static tripwires (grep-level, run in CI): # - no schema/decision reads a client-supplied account_id/tenant_id as authoritative (INV3) # - no at-rest store holds a private key or raw token (INV4/INV5); DB has agent_pubkey only # - no secret material in any log line (INV9) ``` **Base-app regression obligation:** P5 adds **zero** code to `src/` / `public/` — the base app's 212 tests stay green untouched; the only relay-side config touch (`ALLOWED_ORIGINS` += subdomain) is P3's install step, not P5's. P5's Origin check in T12 *mirrors* the base-app CSWSH check at the relay edge (INV15) and must never weaken it. **Manual / integration verification (post-P1/P3 integration, v0.9+):** enroll two accounts on a single relay node; from a browser authenticated as A, attempt to open `bob.term.` and to reattach B's `sessionId` via A's token → both **403** with a `cross-tenant-attempt` alert in the audit log; revoke A's host → its live tunnel drops within seconds and reconnect is refused; let A's cert reach ~50% TTL → seamless rotation, no tunnel drop. --- ## 5. Open questions (for the orchestrator — subagents STOP + return `[!] BLOCKED`, never guess) **Resolved in this revision (were blocking; now closed in-plan — no longer subagent blockers):** - ~~**(#2) Session-open step-up boundary.**~~ **RESOLVED**: the authz decision and step-up gate run at the **WS upgrade** (`onUpgrade`, T12), *before* P1 dials `ws://127.0.0.1:3000` and before any `attach` frame can reach a PTY. See T12 "Session-open boundary". (Fixes Finding-3's boundary dependency.) - ~~**(#3) `CapabilityToken.sub` → `accountId` mapping.**~~ **RESOLVED**: the frozen §4.3 `sub` field carries the **`accountId`** by P5 convention (`CAP_TOKEN_SUB_IS_ACCOUNT_ID`, T1); T2 issuance sets `sub := principal.accountId`; T3's cross-tenant gate reads it via the sole resolver `accountIdFromToken(token)`. The former undefined `token.accountBoundFromSub` is deleted. This does **not** redefine §4.3 (it fixes the *value* P5 writes into an existing field). (Fixes Finding-1.) - ~~**(#4) Revocation propagation transport.**~~ **RESOLVED**: `revoke()` publishes a `KillSignal` on the injected `RevocationBus` (Redis pub/sub `relay:revocations`, §4.2); P1 injects a §4.1 `CLOSE`+RST / `GOAWAY` (existing frozen frame types) to tear down the **live** stream within `REVOCATION_PUSH_BUDGET_MS`. P5 stays socket-free. (Fixes Finding-2.) **Still open (need the orchestrator / cross-plan confirmation — do NOT guess):** 1. **Capability-token format**: §4.3 says "Ed25519-signed **PASETO/JWS**" — pick one concretely (recommend **PASETO v4.public**: no alg-confusion, no `alg:none` foot-gun). Needs a one-line INDEX §4.3 confirmation before T2 codes, since the token bytes cross into P1/P6. (The `cnf.jkt` PoP claim + DPoP proof added in T2 must be representable in whichever format is chosen — confirm alongside.) 2. **OIDC team→account mapping** (T7): how does a verified `(iss, sub)` map to an existing `accountId` (JIT provisioning vs pre-linked)? This is a P3 account-model question surfaced by P5.