Files
web-terminal/docs/PLAN_RELAY_AUTH_ISOLATION.md
Yaojia Wang 2af57e6686 feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)
Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.

Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
  capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay:   native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent:        host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e:    browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth:   Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web:    browser login + Web Crypto E2E + client-side preview rendering

Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.

NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
2026-07-02 06:10:16 +02:00

68 KiB
Raw Permalink Blame History

PLAN_RELAY_AUTH_ISOLATION — Auth & Tenant Isolation (P5)

Plan P5 of 6 for the Rendezvous-Relay Service. Source of intent: EXPLORE_RELAY_SERVICE.md (§0 LOCKED DECISIONS are CLOSED). Coordination point & frozen contracts: 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_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.mdorchestrator-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 authzaccount_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 hostContentSecretP3 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 imported from relay-contracts, never re-declared):
    // 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.<domain>/account/<accountId>/host/<hostId>'
    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<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 of a connect-scoped token (T2/T4 leak-blast-radius control):
      consumeOnce(jti: string, exp: number): Promise<boolean>   // true = first use (proceed); false = replay (deny)
    }
    export interface AuditSink { append(e: AuditEvent): Promise<void> }
    export interface TokenBucketStore {                          // P3 Redis; keys are OPAQUE to P5
      take(key: string, refillPerSec: number, burst: number, now: number): Promise<boolean> // 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<void> })
    
    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):
    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<string>
    // §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<CapabilityToken>
    // 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<boolean>
      // 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 3060 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.
    // `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<string>   // 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<boolean>  // 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 > 60reject 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.<domain>) → 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 clientEphPubverifyDeviceProof(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 (3060 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:
    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<AuthzOutcome>
    export function authorizeReattach(
      req: ReattachRequest, dpop: DpopContext, hosts: HostRegistryPort, sessions: SessionRegistryPort,
      revocation: RevocationStore, now: number
    ): Promise<AuthzOutcome>
    
    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 jti403; requiredRight='kill' with attach-only token → 403; token.host !== requestedHostId403 (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:
    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<void> // 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:
    export function startRegistration(accountId: string, rpId: string): Promise<RegistrationOptions>
    export function finishRegistration(accountId: string, resp: RegistrationResponse, expectedChallenge: string,
      rpId: string, origin: string): Promise<WebAuthnCredential>          // verifies attestation
    export function startAuthentication(accountId: string, rpId: string): Promise<AuthenticationOptions>
    export function finishAuthentication(cred: WebAuthnCredential, resp: AuthenticationResponse,
      expectedChallenge: string, rpId: string, origin: string): Promise<AuthenticatedPrincipal>
    
  • 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:
    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<boolean>   // false = locked out (deny without even checking the code)
    export function recordTotpFailure(accountId: string, store: TokenBucketStore, now: number): Promise<void>
    
  • 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:
    export function buildAuthUrl(cfg: OidcConfig, state: string, nonce: string, codeChallenge: string): string
    export function exchangeCode(cfg: OidcConfig, code: string, codeVerifier: string): Promise<OidcTokenSet>
    export function verifyIdToken(cfg: OidcConfig, idToken: string, expectedNonce: string, now: number)
      : Promise<OidcIdentity>   // 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):
    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 = nullneedsStepUp 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:
    export function spiffeIdFor(accountId: string, hostId: string): SpiffeId // 'spiffe://relay.<domain>/account/<a>/host/<h>'
    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<MtlsVerifyResult>  // 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 registryok: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):
    // 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<KillSignal>
    // 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<boolean>
    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 jtis 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:
    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<boolean>
    // PER-TENANT quotas (keyed on the authenticated accountId, INV3):
    export function checkConnectRate(accountId: string, policy: RateLimitPolicy, store: TokenBucketStore, now: number): Promise<boolean>
    export function checkConcurrentSessions(accountId: string, active: number, policy: RateLimitPolicy): boolean
    export function checkEnrollRate(accountId: string, policy: RateLimitPolicy, store: TokenBucketStore, now: number): Promise<boolean>
    
  • TDD / test cases: pre-auth (Finding-5) — a single remoteAddrHash hammering the upgrade endpoint across many distinct subdomains / host_ids 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 connectPerMinfalse (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 UpgradeContextincluding 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:

    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<AuthzOutcome>
    export function onReattach(ctx: UpgradeContext & { sessionId: string }, deps: EnforceDeps, allowedOrigins: readonly string[], now: number): Promise<AuthzOutcome>
    

    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 = hostB403.
      • onReattach with A's token but a sessionId owned by B → 403.
      • fuzz: 1000 random UUIDv4 host_ids not owned by A → all 403 (guessing/reuse defense, INV1).
      • aud confusion: A's token replayed at bob.term.<domain>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:
    export interface Alerter { fire(kind: 'cross-tenant' | 'revocation' | 'auth-anomaly', e: AuditEvent): Promise<void> }
    export function onAuditEvent(e: AuditEvent, alerter: Alerter): Promise<void> // 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 3060 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 T5T8 (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 3060 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 3060 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

# 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   # T5T8: 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.<domain> 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.subaccountId 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.