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

872 lines
68 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.<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`):
```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<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.
```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<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 > 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.<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** `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 (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**:
```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<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 `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<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**:
```ts
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**:
```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<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**:
```ts
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):
```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.<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 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<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 `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<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_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<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 = 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.<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**:
```ts
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
```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 # 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.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.