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.
68 KiB
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 followPLAN.md/PLAN_VOICE_COMMANDS.md: stable task IDs grouped into dependency waves, anOwns:disjoint-file list per task, function-signature-level contracts, TDD (tests FIRST), explicit test cases (incl. negative/security), per-task security notes. Progress logged inPROGRESS_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):
- Human auth — Passkey/WebAuthn primary, TOTP fallback, NEVER SMS, OIDC SSO for teams; step-up auth before opening a session (not just at login).
- 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. - Capability tokens on the WS upgrade — issuance + verification of §4.3
CapabilityToken(host + rights scope, INV15). - Deny-by-default tenant authz —
account_idderived from the authenticated principal, NEVER client-supplied (INV3), enforced on every connect AND reattach (INV6). - The hard cross-tenant isolation invariant (INV1: A can never reach B) + a permanent CI tripwire test (device A → host B = 403).
- Per-tenant rate-limits / quotas (sessions, bytes-signalled, enrollments, connect attempts).
- Global + per-host revocation that kills live tunnels in seconds (INV12).
- 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 uphost.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
KillSignalon theRevocationBus(Redis pub/sub channelrelay:revocations, §4.2); P1 subscribes and injects a §4.1CLOSE+RST (per stream) orGOAWAY(global) to tear the live mux tunnel down withinREVOCATION_PUSH_BUDGET_MS; P3 backs the store (revoked:{jti}, fliphosts.status='revoked', setrevoked_at) and the bus. P5 never touches a socket (Finding-2). - T2 mints/verifies §4.4
deviceAuthProof(signDeviceAuthProof/ host-sideverifyDeviceProof, bound to{ clientEphPub, clientNonce }per INDEX §6b) consumed by P4's E2E handshake as an injected dep (P4'sDeviceAuthProofProvider+createHostHandshake({ verifyDeviceProof, … })), so P4 binds a device to an account via P5'sAuthenticatedPrincipal, not its own ad-hoc derivation, and never imports crypto identity fromrelay-authdirectly (Finding-7 / §6b). hostContentSecretdelivery to browser devices (§4.5 / FIX 3). P5 does NOT mint or wraphostContentSecret— P3 mints + wraps it at BIND, sealed to the host's enrolled Ed25519agent_pubkey(§4.5EnrollResult.hostContentSecret), and P2 unwraps it locally. P5's role is the authorized browser-device delivery gate: an authorized device obtainshostContentSecretvia P5, unwrapped only after auth + step-up (T8), so it can re-derive P4's §4.4K_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, likesrc/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
clientTokengate is P6's). - Contracts (P5-local — NOT in §4; §4 types are
imported fromrelay-contracts, never re-declared):Every exported interface has a matching Zod schema (// 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> })WebAuthnCredentialSchema,AuditEventSchema, …) for boundary validation (coding-style.md"Input Validation"). - TDD steps: (RED)
test/types.test.tsasserts each Zod schema round-trips a valid record and rejects a forged one (extraaccountIdfield on a client body is stripped;AuditEventrejects any unknown key → zero-payload guard). (GREEN) writetypes.ts. (REFACTOR) file < 400 lines; split schemas intotypes.ts+schemas.tsif it crosses 400. - Test cases: valid
AuthenticatedPrincipalparses;AuditEventwith an addeddata/byteskey → throws (INV10 tripwire at the type layer);SpiffeIdregex accepts the account/host form and rejects a path-traversal (../) or wildcard;CAP_TOKEN_SUB_IS_ACCOUNT_ID === true(compile+runtime guard that thesub-binding convention is stated in exactly one place, consumed by T2 issuance and T3accountIdFromToken). - Security: this task defines that
accountIdoriginates only insideAuthenticatedPrincipal(INV3) and thatCapabilityToken.subcarries that sameaccountId(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-suppliedaccountId/tenant_idas 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.4ClientHello.deviceAuthProof, injected into P4 — INDEX §6b / Finding-7 fix). - Contract — implements §4.3 verbatim (
CapabilityToken,CapabilityRight,verifyCapabilityToken):TTL & single-use policy (Finding-4 — leaked-token blast-radius): connect-scoped tokens are short-TTL 30–60 s (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)issueCapabilityTokenclampsttlSecondsto that range and rejects longer values) and single-use: T12 callsRevocationStore.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 acnf.jktproof-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 (neverlocalStorage); residual XSS risk is documented in §3. Device-auth proof (Finding-7 / INDEX §6b — SOLE issuer+verifier of §4.4deviceAuthProof; 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, NOTtranscriptHashand NOT a static bearer token, so a captured proof cannot be replayed into a different freshly-keyedclient_hello. P5 mints from the authenticatedAuthenticatedPrincipal(the proof assertsprincipal.accountId); the tenant gate itself (INV1) is the capability-token cross-tenant check atonUpgrade(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) noconsole.log; typed errors (CapabilityErrormachine reason); splitissue.ts/verify.ts/device-proof.tseach < 200 lines. - Test cases (incl. negative/security):
- round-trip: issue→verify returns the same
host,rights,jti;subAccountId(token) === principal.accountId. subbinding:issueCapabilityTokensetstoken.sub = principal.accountId(neverprincipalId, 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
verifyafterconsumeOncehas marked thejti→ 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 foralice, presented atbob.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')isfalse(INV15). - forged token signed by a different key → reject.
- device-auth proof (binding to
{clientEphPub, clientNonce}, §6b): a proof minted for handshake A (itsclientEphPub/clientNonce) replayed into handshake B with a differentclientEphPub→verifyDeviceProof(proof, bindingB, now)returnsfalse(per-handshake binding defeats replay); a static/unbound bearer proof →false; a proof tampered after signing →false.
- round-trip: issue→verify returns the same
- Security:
sub/host/accountare taken fromIssueArgs.principal, never from a client field (INV3). Token is short-TTL (30–60 s) + single-use +jti-revocable (INV12, via T10) + PoP-bound (cnf.jkt).deviceAuthProofis minted/verified only here from P5'sAuthenticatedPrincipal, 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:
Decision algorithm (deny-by-default, early-return):
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>There is notoken = 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 }allow-if-unspecifiedbranch — 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.tswith a fakeHostRegistryPortseeded 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
subfield): a validly signed token whosesub(= account A) does not resolve tohost.accountId(host owned by B) →accountIdFromToken(token)returns A,host.accountIdis B, gate fails → 403. This exercises the concrete field, not a placeholder. accountIdFromTokenunit: returnstoken.sub; throws on empty/malformedsub(deny-by-default input guard).- INV6 reattach cross-tenant: valid host-A token but
sessionIdowned by B → 403. - INV3 forged account: a
ConnectRequestshape carrying an extraaccount_idfield is type-impossible (not in schema) → cannot influence the decision; assert the decision uses onlyhost.accountIdvsaccountIdFromToken(token), never any client-supplied value. - missing/invalid token → 401; failing DPoP proof (PoP mismatch) → 401; revoked
jti→ 403;requiredRight='kill'withattach-only token → 403;token.host !== requestedHostId→ 403 (defeats IDOR by swapping the path host). - happy path: account A + own host +
attachright + live host →ok:true.
- INV1 cross-tenant (THE tripwire, against the real
- 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 implementredact.ts(assertZeroPayloadrejects any string field > a metadata length cap or containing control/ESC bytes\x1b). REFACTOR keep < 200 lines. - Test cases: a well-formed
attachallow event appends; adenyevent recordsreason; payload guard — an event whosereasoncontains\x1b[or a >256-char blob →assertZeroPayloadthrows (INV10); the sink is append-only (a test double rejectsupdate/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(persistsWebAuthnCredential, §4.2 P5-owned table). Uses@simplewebauthn/server(battle-tested, perdevelopment-workflow.mdreuse 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; unknowncredentialId→ reject; happy path yields a principal withamr:['passkey']. - Security: Passkey is primary (phishing-resistant; the payload is a root-capable shell).
rpIdbound 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);TotpSecretRecordstored 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
windowpasses; 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) — afterpolicy.totpMaxFailsPerWindow(e.g. 5) consecutive bad codes withinpolicy.totpLockoutWindowSec,checkTotpAttemptRatereturnsfalse(further attempts denied without hittingverifyTotp) 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 authenticatedaccountId, 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;
OidcIdentitystore (P3). Auth-code flow + PKCE,state/noncevalidation. - 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:
statemismatch → reject (CSRF);noncemismatch → reject (replay); expired/bad-issid_token → reject; unknown issuer not in the allow-list → reject; happy path returnsOidcIdentitymapped to the team account. - Security: PKCE mandatory; issuer allow-list from config (validated at startup, INV9);
account_idderived 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 (
StepUpPolicytype 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 callsneedsStepUpbefore returningok:true— see T12 v0.10 augmentation). - Contract (
StepUpPolicyimported fromrelay-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 copyneedsStepUpis true whenprincipal.stepUpAtis null ORnow - principal.stepUpAt > policy.maxAgeSecondsORpolicy.requiredMethod ∉ principal.amr— i.e. a fresh login is not sufficient; a recent step-up is required.recordStepUpreturns a new principal withstepUpAt = nowandmethodadded toamr. - TDD / test cases: a principal freshly logged in (
authAt = now) but withstepUpAt = null→needsStepUptrue (login ≠ step-up — this is the exact case T12 must refuse, Finding-3); a principal whosestepUpAtis older thanmaxAgeSeconds→ true; fresh passkey step-up (stepUpAt = now, requiredMethod inamr) → false;recordStepUpreturns a new principal (immutability rule), original untouched; a token to open a session is refused by T12 unlessneedsStepUpis 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/onReattachsequence (v0.10 augmentation), not merely asserted in prose (Finding-3). hostContentSecretbrowser-delivery gate (§4.5 / FIX 3). A fresh step-up here is also the gate for releasing the §4.5hostContentSecretto an authorized browser device: it is unwrapped for the device only afterneedsStepUpis false, so the device can re-derive P4's §4.4K_content(deriveContentKey) and decrypt replayed ciphertext. P5 does not mint/wrap it — P3 mints+wraps at BIND (sealed toagent_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 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;shouldRotatetrue 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/KillSignaltypes), T2; P3RevocationStore(Redisrevoked:{jti}, §4.2; fliphosts.status='revoked', setrevoked_at) +RevocationBus(Redis pub/sub); P1 subscribes to the bus and force-closes the live streams. - Contract (
RevocationScope/KillSignalimported from T1types.ts, NOT redefined here):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:// 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?revoke()publishes an immutableKillSignalon the injectedRevocationBus, whose transport is the frozenRELAY_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-streamCLOSEwithflags.RST(abnormal close) for host/account scope, or a connection-levelGOAWAY(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 frozenREVOCATION_PUSH_BUDGET_MS(= 2000 ms, §4.2) ofrevoke()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 fakeRevocationBus+ a fake P1 subscriber holding an already-authorized open stream,revoke()publishes aKillSignaland the subscriber force-closes that stream (asserted viakillsScopeselecting it and an injected RST) withinREVOCATION_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 (killsScopefalse 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 arelay:revocationspub/subKillSignalthat 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;
RateLimitPolicybyPlanTier(§4.2); aTokenBucketStoreport (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
remoteAddrHashhammering the upgrade endpoint across many distinct subdomains /host_ids with invalid or forged tokens is throttled bycheckPreAuthRateafterPRE_AUTH_POLICY.preAuthPerMinPerIp, independent of any account (there is none yet), and the throttle refills after its window; connects beyondconnectPerMin→false(429 at the caller); at limit → allowed, over → denied, refills after the window; concurrent sessions overmaxConcurrentSessions→ denied; enroll spam overenrollPerHour→ denied; per-account limits are peraccountId— 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 saltedremoteAddrHashonly (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
AuditSinkthat 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 (callsonUpgradeon 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
jtiburn + pre-auth throttle + step-up gate + per-tenant rate + deny-by-default cross-tenant gate + audit — lives only in P5onUpgrade/onReattach. P1 T8authorizeUpgradeis a THIN ADAPTER that DELEGATES here (§6a): it parses the upgrade (Origin, subprotocol token via §4.3 FIX 5, subdomain), builds theUpgradeContext— includingdpop: DpopContextandactiveSessionCount— calls P5, and maps the returnedAuthzOutcometo a §4.1MuxOpenor a 401/403 close. P1 holds no independent authz logic; P5 is the injected authorizer.
- capability verify + DPoP proof-of-possession + single-use
-
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:
- Origin/CSWSH:
originHeader ∈ allowedOriginselse 401 (retain base-app check, INV15). - 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. - Per-tenant rate: after a principal is known,
checkConnectRate(accountId, …)(T11) → deny if over. - 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. - Step-up gate (Finding-3, v0.10 augmentation): on authz allow, before returning
ok:true, callneedsStepUp(ctx.principal, deps.stepUpPolicyFor(host), now)(T8). If true → 403 + aAuditEvent(action:'stepup', outcome:'deny'). This is the exact guarantee T8 claims: fresh-login-but-stale-step-up is refused atonUpgrade, before a PTY is reachable. - 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.) - Audit + return: on deny emit exactly one
denyAuditEvent(T4;cross-tenant-attemptaction whenhost.accountId !== accountIdFromToken(token)) and return the 401/403; on allow emit anattach/reattachallow event and returnok: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 dialsws://127.0.0.1:3000and before anyattachframe can reach a PTY — the base app'sattach-first-frame is downstream of a stream that P1 only opens afteronUpgradereturnsok: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. - Origin/CSWSH:
-
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 atonUpgrade(Finding-3, matches T8's claimed guarantee); afterrecordStepUp, same request →ok:true; every deny path writes exactly oneAuditEvent(incl. across-tenant-attempton A→B and astepupdeny on stale step-up); no allow path writes payload.
- 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 →
-
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):
onUpgradewith A's token butrequestedHostId = hostB→ 403.onReattachwith A's token but asessionIdowned by B → 403.- fuzz: 1000 random UUIDv4
host_ids not owned by A → all 403 (guessing/reuse defense, INV1). audconfusion: A's token replayed atbob.term.<domain>→ 403.
- v0.10 full-stack variant (audit + alert): once T4 (audit) and T14 (alert) ship, additionally assert a
cross-tenant-attemptaudit 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).
- 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):
- CI:
.github/workflows/relay-tripwire.ymlruns 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-attemptevent fires across-tenantalert; an ordinaryattachallow does not alert; alert payload carries metadata only (no terminal bytes, INV10). - Security: turns the INV1 tripwire into a runtime detector — an A→B attempt in production raises an operator alert immediately (EXPLORE §4e HIGH, INV10).
3. SECURITY (per-plan summary)
| Control | Task(s) | Invariant |
|---|---|---|
account_id only ever from the authenticated principal; no client field trusted |
T1, T3, T12 | INV3 |
| Deny-by-default authz on connect and reattach (no allow-if-unspecified branch) | T3, T12 | INV6 |
Cross-tenant isolation A↛B + fuzz + aud-confusion guard + permanent CI tripwire |
T3, T12, T13 | INV1 |
| Capability token required on WS upgrade; Origin/CSWSH retained AND augmented; least-privilege rights | T2, T12 | INV15 |
Capability tokens: short-TTL 30–60 s, single-use (consumeOnce), DPoP proof-of-possession (cnf.jkt) so a leaked bearer copy can't upgrade; token memory-only in P6 |
T2, T12 | INV15 (leak blast-radius) |
§4.4 deviceAuthProof minted/verified only by P5 from AuthenticatedPrincipal (P4 consumes, never re-derives account binding) |
T2 (→P4) | INV3 |
Pre-auth IP/remoteAddrHash-keyed throttle before token verification (enumeration/brute-force/DoS) plus per-tenant quotas |
T11, T12 | (DoS/abuse, §4e HIGH) |
| TOTP failed-attempt lockout/backoff (per-account, via T11 bucket) closes the 10^6 brute-force hole | T6, T11 | (second-factor integrity) |
Live-tunnel push teardown: revoke() → RevocationBus → P1 injects §4.1 RST/GOAWAY within REVOCATION_PUSH_BUDGET_MS; not just next-connect refusal |
T10 (→P1) | INV12 |
| Per-host asymmetric identity; DB stores only public keys; private key never leaves host | T9 (with P2/P3) | INV4 |
| mTLS SPIFFE short-TTL auto-rotating certs; CA never signs a non-enrolled pubkey | T9 (with P2/P3) | INV14 |
| Fast global/per-host revocation kills live tunnels in seconds | T10 | INV12 |
| Immutable, append-only, ZERO-payload audit + cross-tenant alert | T4, T12, T14 | INV10 |
| Passkey primary (phishing-resistant), TOTP fallback, never SMS, OIDC PKCE, step-up before session open | T5–T8 | (auth model, LOCKED §0.5) |
Per-tenant rate-limits/quotas keyed on authenticated accountId |
T11 | (DoS/abuse, §4e HIGH) |
| Secrets in env/secret-manager, validated at startup, never logged | all (config loaders) | INV9 |
Residual risk — capability-token exfiltration (Finding-4, documented not hidden): the payload behind a
successful upgrade is a full, potentially root-capable shell. P5 shrinks the blast radius of a leaked token to
near-zero — short 30–60 s TTL, single-use (consumeOnce burns the jti on first upgrade), and a
DPoP cnf.jkt proof-of-possession so a bearer copy without the client's ephemeral private key cannot
upgrade. The one residual vector is a browser XSS that both reads the in-memory token and uses the
in-memory ephemeral key within the 30–60 s window; P6 MUST therefore hold both memory-only (never
localStorage/sessionStorage) and P4's E2E (v0.10) further removes the plaintext-shell reward. This
residual is accepted and tracked, not silently ignored.
Deliberate non-goals (owned elsewhere / structural): P5 never touches terminal bytes → INV2/INV11 hold
by construction (the package has no ws, no xterm, no ANSI parser). Immutable storage records + atomic
snapshot swap (INV8) live in P3; P5 consumes read-only ports and returns immutable copies (e.g. recordStepUp).
Phasing recap (reconciled per Finding-8): v0.8 = T1 only (contracts frozen, no runtime — per INDEX
§1). v0.9 = T2 (capability tokens incl. PoP + single-use + deviceAuthProof), T3, T9, T10 (revoke +
RevocationBus publish; live push teardown is available here, only the tighter latency SLO is tuned in
v0.10), T11 (pre-auth IP throttle + per-tenant quotas), T12 core (Origin + capability verify +
deny-by-default authz + pre-auth/rate limit), T13 v0.9 unit variant (A→B = 403 only) — per INDEX §1
v0.9. v0.10 = T4, T5, T6 (TOTP + lockout), T7, T8 (step-up), T14, T12 augmentation (audit emit +
step-up gate), T13 v0.10 full-stack variant (audit+alert asserts) (Passkey/WebAuthn primary + step-up +
fast-revocation SLO + immutable zero-payload audit — per INDEX §1 v0.10). No v0.9 task depends on a v0.10
task — v0.10 items are injected-dep augmentations of already-shipping v0.9 code.
4. VERIFICATION
# Unit + negative/security tests (TDD, tests FIRST), per task:
npx vitest run capability # T2: sub:=accountId, expiry/aud/tamper/wildcard/rights-escalation, TTL≤60s clamp, single-use, DPoP PoP
npx vitest run device-proof # T2: §4.4 deviceAuthProof bound to {clientEphPub,clientNonce}; replay-into-other-handshake/unbound/tampered → false (§6b/Finding-7)
npx vitest run authz # T3: INV1 cross-tenant 403 via accountIdFromToken, INV6 reattach 403, INV3 no client account_id
npx vitest run audit # T4: append-only + zero-payload guard (INV10)
npx vitest run webauthn totp oidc stepup # T5–T8: origin/challenge/replay/TOTP lockout/step-up (stepUpAt) freshness
npx vitest run mtls # T9: SPIFFE mismatch/expiry/non-enrolled → refuse (INV4/INV14)
npx vitest run revocation # T10: revoke → token invalid + LIVE-TUNNEL push teardown (RevocationBus) within budget + reconnect refused (INV12)
npx vitest run quota # T11: pre-auth IP throttle (no account) + per-tenant limits, no cross-tenant bleed
npx vitest run enforce # T12: Origin+preAuth+token(PoP,single-use)+authz+step-up+rate+audit compose; foreign Origin 401 (INV15)
# THE permanent tripwire — must be a REQUIRED CI check (INV1):
npx vitest run tripwire/cross-tenant # A→B = 403 on connect AND reattach + fuzz + aud-confusion
# (also runs in .github/workflows/relay-tripwire.yml on every push/PR — never skip/delete)
# Whole-package gates:
npx vitest run --coverage # 80%+ across relay-auth (testing.md)
npm run typecheck # relay-auth strict tsc; relay-contracts (§4) imported read-only, unchanged
npm run lint # asserts no console.log; no import of ws/xterm/ANSI parser (INV2/INV11)
# Static tripwires (grep-level, run in CI):
# - no schema/decision reads a client-supplied account_id/tenant_id as authoritative (INV3)
# - no at-rest store holds a private key or raw token (INV4/INV5); DB has agent_pubkey only
# - no secret material in any log line (INV9)
Base-app regression obligation: P5 adds zero code to src/ / public/ — the base app's 212 tests
stay green untouched; the only relay-side config touch (ALLOWED_ORIGINS += subdomain) is P3's install step,
not P5's. P5's Origin check in T12 mirrors the base-app CSWSH check at the relay edge (INV15) and must never
weaken it.
Manual / integration verification (post-P1/P3 integration, v0.9+): enroll two accounts on a single relay
node; from a browser authenticated as A, attempt to open bob.term.<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 dialsws://127.0.0.1:3000and before anyattachframe can reach a PTY. See T12 "Session-open boundary". (Fixes Finding-3's boundary dependency.)(#3)RESOLVED: the frozen §4.3CapabilityToken.sub→accountIdmapping.subfield carries theaccountIdby P5 convention (CAP_TOKEN_SUB_IS_ACCOUNT_ID, T1); T2 issuance setssub := principal.accountId; T3's cross-tenant gate reads it via the sole resolveraccountIdFromToken(token). The former undefinedtoken.accountBoundFromSubis 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 aKillSignalon the injectedRevocationBus(Redis pub/subrelay:revocations, §4.2); P1 injects a §4.1CLOSE+RST /GOAWAY(existing frozen frame types) to tear down the live stream withinREVOCATION_PUSH_BUDGET_MS. P5 stays socket-free. (Fixes Finding-2.)
Still open (need the orchestrator / cross-plan confirmation — do NOT guess):
- Capability-token format: §4.3 says "Ed25519-signed PASETO/JWS" — pick one concretely
(recommend PASETO v4.public: no alg-confusion, no
alg:nonefoot-gun). Needs a one-line INDEX §4.3 confirmation before T2 codes, since the token bytes cross into P1/P6. (Thecnf.jktPoP claim + DPoP proof added in T2 must be representable in whichever format is chosen — confirm alongside.) - OIDC team→account mapping (T7): how does a verified
(iss, sub)map to an existingaccountId(JIT provisioning vs pre-linked)? This is a P3 account-model question surfaced by P5.