Files
web-terminal/docs/PLAN_RELAY_CONTROLPLANE.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

822 lines
62 KiB
Markdown
Raw 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.

# P3 — Control Plane & Accounts — Implementation Plan
> **Plan of the 6** (see [`PLAN_RELAY_INDEX.md`](./PLAN_RELAY_INDEX.md) §5 charter for P3).
> Owns the **stateful, low-QPS, strongly-consistent** half of the relay: the account & host
> **registries**, **pairing-code** issuance+redemption, **subdomain** assignment, the **live routing
> table**, **provisioning/deprovisioning** APIs, **billing-metering** hooks, the **mTLS cert-signing**
> service (bind-time only), and **graceful relay-node drain** coordination.
> **Source of intent**: [`EXPLORE_RELAY_SERVICE.md`](./EXPLORE_RELAY_SERVICE.md) §0 (LOCKED, closed) + §3
> "control plane vs data plane". **Coordination point**: this plan **imports** the FROZEN SHARED
> CONTRACTS from INDEX §4 read-only and **redefines none of them**. Conventions follow
> [`PLAN.md`](./PLAN.md) / [`PLAN_VOICE_COMMANDS.md`](./PLAN_VOICE_COMMANDS.md): stable task IDs in
> dependency waves, `Owns:` disjoint files, function-signature contracts, TDD (tests FIRST), explicit
> test cases (incl. security/negative), per-task security notes. `PROGRESS_LOG.md` is
> orchestrator-only (subagents return a ready-to-paste entry, G1).
---
## 0. Scope, boundaries, and enforced invariants
**In scope (this plan and no other):**
- Account & host **registries** — Postgres, **immutable records** (INDEX §4.2 schema is frozen; TS
`HostRecord`/`RouteEntry`/`HostStatus`/`PlanTier` imported from `relay-contracts/`).
- **Pairing** issuance + redemption + BIND (INDEX §4.5) — atomic single-use `redeemed_at` CAS.
- Per-tenant **subdomain assignment** (stable across reconnects).
- **Live routing table** — Redis `route:{host_id} → RouteEntry` with heartbeat TTL; **Postgres is the
ownership source of truth, Redis is only "where the live tunnel is right now"** (EXPLORE §3, INV7).
- **Provisioning / deprovisioning** HTTP APIs (create/suspend/delete account; add/revoke host).
- **Billing-metering hooks** — paired hosts + concurrent viewers (EXPLORE §6 cost drivers).
- **mTLS cert-signing at BIND** (INDEX §4.5 step 4) — the CA verifies **CSR proof-of-possession** and
**only** signs pubkeys already in the host registry (the INV14 registry-gate half), using a
**KMS/HSM-held, non-exportable intermediate** key (§3.1); verification cadence/rotation policy is
**P5's**, see §7.
- **Graceful relay-node drain** coordination — emit `GOAWAY` (INDEX §4.1) via the node registry.
- **Immutable audit substrate** — append-only, zero-payload writer (INV10) that P5 also calls.
**Explicitly NOT in scope (other plans — do not touch):** the mux frame codec & byte path (**P1**); the
host-agent (**P2**); the E2E crypto handshake/AEAD (**P4**); human Passkey auth, capability-token
*issuance/signing-key*, deny-by-default *enforcement at the WS upgrade*, per-tenant rate-limit
enforcement, revocation *policy*, and cross-tenant CI tripwire (**P5**); browser UI (**P6**). This plan
**consumes** `verifyCapabilityToken` (§4.3) to authorize its own admin APIs but does **not** own the
signing key — that is P5.
**Enforced invariants (INDEX §3):** **INV1** (cross-tenant isolation — ownership join is the only host
resolution path), **INV3** (`account_id` from the authenticated principal, never client-supplied),
**INV4** (per-host asymmetric identity — DB stores only public keys), **INV5** (no plaintext/secret at
rest — hash pairing codes/tokens, no shell bytes), **INV6** (deny-by-default authz on connect AND
reattach — registry provides the ownership fact), **INV8** (immutable records + atomic snapshot swap),
**INV10** (immutable audit log, zero payload), **INV12** (fast revocation kills live tunnels). Each task
lists the subset it ships a test for.
**Package:** all new code lives in a **new top-level `control-plane/` package** — the physically
separate control-plane half mandated by INDEX §0 ("`term-relay/` … physically split") and EXPLORE §3.
No edits to `src/`, `public/`, `agent/`, `relay-e2e/`, or `relay-web/`. `relay-contracts/` is imported
read-only.
---
## 1. Files & ownership (all under `control-plane/`)
| File | Task | Role |
|------|------|------|
| `control-plane/package.json`, `tsconfig.json`, `vitest.config.ts` | T1 | scaffold; deps `pg ioredis zod fastify` (+ dev `vitest @types/pg`) |
| `control-plane/src/env.ts` | T1 | startup secret/config validation (Zod), fail-fast (INV9) |
| `control-plane/db/migrations/*.sql` | T2 | Postgres DDL for §4.2 `accounts/hosts/sessions/pairing_codes` + `relay_nodes`, `metering_samples`, `audit_log` |
| `control-plane/src/db/pool.ts` | T2 | pg pool + typed `query` wrapper (parameterized only) |
| `control-plane/src/model/records.ts` | T2 | TS record types mirroring §4.2 SQL **verbatim** (`AccountRecord`, `SessionRecord`, `PairingCodeRecord`); re-exports `HostRecord`/`HostStatus`/`PlanTier`/`RouteEntry` from `relay-contracts/` |
| `control-plane/src/registry/accounts.ts` | T3 | immutable account repository |
| `control-plane/src/registry/hosts.ts` | T4 | immutable host repository (ownership source of truth) |
| `control-plane/src/registry/sessions.ts` | T5 | session repository (denormalized `account_id` for O(1) authz) |
| `control-plane/src/subdomain/assign.ts` | T6 | subdomain allocation + collision/validation |
| `control-plane/src/pairing/issue.ts` | T7 | pairing-code issuance |
| `control-plane/src/pairing/redeem.ts` | T8 | atomic single-use redemption + BIND + cert-sign hook + mint/wrap `hostContentSecret` (FIX 3) |
| `control-plane/src/ca/sign.ts` | T8 | bind-time mTLS leaf signer (registry-gated, INV14 half) |
| `control-plane/src/node-auth/identity.ts` | T9 | relay-node identity middleware — derives `nodeId` from the mTLS client-cert subject (never a request field) |
| `control-plane/src/routing/table.ts` | T9 | Redis routing table (`route:{host_id}`, heartbeat TTL) |
| `control-plane/src/routing/nodes.ts` | T10 | relay-node registry + drain coordination (GOAWAY dispatch) |
| `control-plane/src/api/provision.ts` | T11 | provisioning/deprovisioning HTTP routes (incl. v0.9 minimal deprovision) |
| `control-plane/src/api/authz.ts` | T11 | admin-API principal derivation (INV3) — consumes `verifyCapabilityToken` |
| `control-plane/src/deprovision/deprovision.ts` | T11 | v0.9 minimal deprovision (status→'revoked' + dropRoute); no fast tunnel-kill (that is T13/v0.10) |
| `control-plane/src/metering/collect.ts` | T12 | metering ingestion + immutable rollup |
| `control-plane/src/revoke/revoke.ts` | T13 | global + per-host revocation (INV12) |
| `control-plane/src/audit/log.ts` | T14 | immutable zero-payload audit writer (P5 also imports) |
| `control-plane/src/ca/rotate.ts` | T15 | CA leaf **renewal** (`renewHostLeaf`) — registry-gated, distinct file from T8 signer |
| `control-plane/src/boot/ca-wiring.ts` | T15 | CA-key / secret-manager bootstrap + KMS-signer factory; imported by `main.ts` (T11) |
| `control-plane/src/main.ts` | T11 | Fastify bootstrap; wires env → pools → routes; imports `boot/ca-wiring.ts` (T15) |
| `control-plane/test/**` | each | per-module unit + integration tests (Testcontainers PG/Redis) |
| `control-plane/src/flat-store.ts`, `control-plane/db/flat.sqlite.sql` | T0 | **v0.8** flat SQLite `{accountId, subdomain, agentToken(hash), clientToken(hash)}` shim + manual-provision CLI |
Each `src/*.ts` stays 200400 lines (800 hard max, `coding-style.md`). `records.ts` is the local
frozen-shape source for P3-internal types; if a shared field is needed elsewhere it is promoted into
`relay-contracts/` **via the INDEX**, never redeclared (open question OQ1).
---
## 2. Waves (dependency order)
```
CP0 scaffold + schema (blocks all) T0(v0.8 flat) · T1 · T2
CP1 registries (immutable repos) T3 accounts · T4 hosts · T5 sessions · T6 subdomain
│ (T3/T4/T5/T6 file-disjoint → parallel)
CP2 pairing T7 issue · T8 redeem+BIND+CA
CP3 live routing & node coordination T9 routing table (+ node-identity mw) · T10 node registry+drain
CP4 provisioning API & metering T11 provision API (+ v0.9 minimal deprovision) · T12 metering
CP5 hardening (v0.10) T13 fast revocation · T14 audit · T15 cert-rotation (ca/rotate.ts) + secret bootstrap
```
**Phase tags:** T0 = **v0.8**. T1T2 scaffold in v0.8, Postgres DDL lands **v0.9**. T3T12 = **v0.9**.
T13T15 = **v0.10**. (The v0.8 flat store is retired the moment T3/T4 land; the provisioning API in
v0.8 is a manual CLI, promoted to HTTP in T11.) **No v0.9 task depends on a v0.10 task** — T11's
`DELETE /hosts/:id` deprovision ships a **self-contained v0.9 tear-down** (`deprovision/deprovision.ts`:
host status→`'revoked'` + `dropRoute`, T4+T9 only); the *fast, within-seconds tunnel-kill / cert-stop*
hardening (INV12) is a **later, additive** upgrade in T13/v0.10 and is **not** a prerequisite for the
v0.9 route to exist. See finding-driven split in §6/§7/§8.
**Cross-plan dependencies:**
- T8 redemption is the server side of **P2** `POST /enroll` (agent generates the keypair + CSR). T8 also
**mints + wraps `hostContentSecret`** (FIX 3, INDEX §4.5) to the enrolled `agent_pubkey` and returns it in
`EnrollResult`; **P2** unwraps it locally with its enrollment private key (INV4); **P5** later hands the
same host-scoped secret to authorized browser devices (unwrapped only after auth/step-up) so they
re-derive the identical `K_content` (§4.4 `deriveContentKey`) for replay/preview decrypt.
- **T9/T10/T12 authenticate the calling relay node** by its **mTLS client-cert identity** (the
`nodeId`/`hostId` in every such RPC is derived from the verified cert subject, **never** a request
field — the relay-node↔control-plane analog of INV3). This mirrors P1's node-side mTLS credential;
the **node service-cert issuance/rotation** is a **P5/P1 boundary** — see **OQ5**.
- T9/T10 are read by **P1** (data plane looks up `route:{host_id}`; obeys `GOAWAY`).
- **T10/T13 tear down live tunnels over the FROZEN control→data-plane bus** (INDEX §4.2 FIX 4): P3
PUBLISHES a `KillSignal` on the Redis pub/sub channel `RELAY_REVOCATIONS_CHANNEL` (`'relay:revocations'`)
via `RevocationBus.publish`; **every P1 relay node SUBSCRIBES** and injects the existing §4.1
`CLOSE`+`RST` / `GOAWAY` wire (no new frame type) within `REVOCATION_PUSH_BUDGET_MS` (2000ms). **Drain
(T10) and revoke (T13) use the SAME named channel.** OQ4 is **RESOLVED** by this freeze — there is no
separate undefined "P1 dispatcher".
- T11 admin authz consumes **P5**'s `verifyCapabilityToken` (§4.3) and the P5 signing key (env).
- T13 shares the Redis `revoked:{jti}` key with **P5**; T14's writer is imported by **P5**.
- T4's `enroll_fpr` is read by **P4** (browser pins it for E2E TOFU, §4.4).
---
## 3. CP0 — scaffold & schema
### T0 · v0.8 flat SQLite store + manual-provision CLI `[v0.8]`
- **Owns**: `control-plane/src/flat-store.ts`, `control-plane/db/flat.sqlite.sql`, `control-plane/bin/provision.ts`
- **Depends**: none · **Parallel-safe**: with everything (retired by T3/T4)
- **Contract** (INDEX §1 v0.8 "flat SQLite account table"):
```ts
export interface FlatAccount { // v0.8 ONLY — no immutable-record ceremony yet
readonly accountId: string; readonly subdomain: string
readonly agentTokenHash: string; readonly clientTokenHash: string
}
export function provisionFlat(subdomain: string): Promise<{ accountId: string; agentToken: string; clientToken: string }>
export function lookupBySubdomain(subdomain: string): Promise<FlatAccount | null>
export function verifyAgentToken(subdomain: string, raw: string): Promise<boolean> // constant-time compare of hashes
```
- **TDD**: RED test asserts `provisionFlat` stores **hashes only** (grep row → no raw token, INV5);
`verifyAgentToken` true for correct / false for wrong; wrong subdomain → null.
- **Security**: tokens hashed (argon2id/scrypt), constant-time compare, raw returned once at mint.
Enforces INV5. This is the throwaway MVP gate; **all** its guarantees are re-shipped properly in CP1.
### T1 · package scaffold + startup env validation `[v0.8→v0.9]`
- **Owns**: `control-plane/package.json`, `tsconfig.json`, `vitest.config.ts`, `control-plane/src/env.ts`
- **Depends**: none · **Parallel-safe**: after this, all others
- **Contract**:
```ts
export interface ControlPlaneEnv {
readonly pgUrl: string; readonly redisUrl: string
readonly capabilitySignPubkey: Uint8Array // P5 signing PUBLIC key, to VERIFY admin tokens (INV3)
readonly caIntermediateKmsKeyRef: string // KMS/HSM key REF for the INTERMEDIATE leaf-signing key
// (non-exportable; signing = KMS sign() call, never a raw
// private key loaded into process memory — see §3.1 CA arch)
readonly caIntermediateCertPath: string // intermediate cert (public) + chain up to the offline root
readonly nodeMtlsTrustBundlePath: string // CA bundle used to VERIFY relay-node client certs (T9 node-auth)
readonly baseDomain: string // 'term.<domain>' for subdomain assembly
readonly heartbeatTtlSec: number
readonly pairingTtlSec: number // pairing-code TTL; DEFAULT 600 (10 min) if unset (§5 T7)
readonly pairingMaxRedeemAttempts: number // per-code lockout threshold; DEFAULT 5 (§5 T7/T8)
}
export function loadEnv(source: NodeJS.ProcessEnv): ControlPlaneEnv // Zod-validated; THROWS on missing/invalid
```
- **TDD**: RED — `loadEnv({})` throws listing every missing key; a bad `pgUrl` throws; a valid map parses;
`pairingTtlSec`/`pairingMaxRedeemAttempts` default to 600/5 when unset; **CA-policy startup check**
boot fails fast if the KMS key referenced by `caIntermediateKmsKeyRef` cannot be resolved OR its key
policy does not restrict `sign` to the control-plane service principal (assert both failure paths throw
before the server binds a port).
- **Security**: INV9 — secrets come from env/secret-manager, **validated at startup, fail-fast, never
logged** (log only key *names* on failure, never values). The CA key is referenced by a **KMS/HSM ref**
and is **never inlined and never loaded raw** into control-plane memory (§3.1).
### 3.1 CA key architecture (Finding-3 hardening — the highest-value secret in the plane)
The mTLS CA can mint a valid identity for **any host across every tenant**, so its custody is specified
explicitly (it must not get *less* rigor than pairing-code hashing):
- **Two-tier hierarchy.** An **offline root CA** (kept off any online host — air-gapped / HSM, never in a
running process) signs a **short-lived intermediate** used for day-to-day leaf signing. The control
plane holds only the **intermediate**, never the root.
- **Non-exportable KMS/HSM key.** The intermediate signing key is a **non-exportable KMS/HSM key**; every
`signHostLeaf`/`renewHostLeaf` (T8/T15) performs a **KMS `sign()` call** — the raw private key is
**never** loaded into control-plane process memory or written to disk (this is stricter than "by
reference": the process can invoke signing but cannot read the key). The KMS key policy MUST restrict
the `sign` operation to the control-plane **service principal only**; T1 fails fast at startup if the
policy is broader (see TDD above).
- **Intermediate rotation cadence.** The intermediate is rotated on a fixed cadence (default **30 days**,
env-tunable) with overlap so in-flight leaves stay valid; rotation is coordinated by T15
(`boot/ca-wiring.ts` provides the KMS-signer factory; `ca/rotate.ts` re-issues leaves under the current
intermediate).
- **Compromise runbook (documented, referenced by the incident process).** *Intermediate compromise*
revoke the intermediate at the root, mint a new intermediate, force **all hosts to renew** (T15
`renewHostLeaf`) under the new intermediate; leaves signed by the old intermediate fail mTLS verify
(P5). *Root compromise* (worst case) → stand up a new root out-of-band, re-issue intermediate,
**force every host to re-enroll** (new pairing round, T7/T8); old chain distrusted at the relay edge.
- Boundary note: **leaf issuance/renewal (signing) is P3**; **rotation *cadence policy* + mTLS *handshake
verification* are P5/INV14** — this plan owns only the KMS-backed signing operation and the startup
key-policy check (OQ2).
### T2 · Postgres DDL + typed pool + record types `[v0.9]`
- **Owns**: `control-plane/db/migrations/*.sql`, `control-plane/src/db/pool.ts`, `control-plane/src/model/records.ts`
- **Depends**: T1
- **DDL**: transcribe INDEX **§4.2 verbatim** — `accounts`, `hosts`, `sessions`, `pairing_codes` — plus
P3-owned `relay_nodes(node_id pk, addr, status, last_seen)`, `metering_samples`, `audit_log` (§4/§7),
plus the **INV8 companion version tables** `account_status_versions(account_id, version, status,
changed_at, changed_by, PRIMARY KEY(account_id, version))` and `host_status_versions(host_id, version,
status, revoked_at, changed_at, changed_by, PRIMARY KEY(host_id, version))`, and a monotonic
`status_version int NOT NULL DEFAULT 1` current-pointer column added to `accounts`/`hosts` (§4 INV8
discipline; pending INDEX promotion, **OQ6**). `host_id`/`account_id`/`session_id` = `uuid` default
`gen_random_uuid()`; `subdomain UNIQUE`; `agent_pubkey bytea`; lifecycle/business columns follow the
versioned-pointer swap (no bare `UPDATE` of a business field without a matching `*_status_versions`
insert in the same txn — see §4 discipline + T3/T4). Liveness columns (`last_seen`, `last_attach_at`)
are advisory freshness caches, updated by a coalesced write, **not** versioned.
- **Contract**:
```ts
export interface AccountRecord { // mirrors §4.2 accounts VERBATIM (not in §4 TS block; OQ1)
readonly accountId: string; readonly plan: PlanTier
readonly createdAt: string; readonly status: 'active' | 'suspended'
}
export interface SessionRecord { // mirrors §4.2 sessions
readonly sessionId: string; readonly hostId: string; readonly accountId: string
readonly createdAt: string; readonly lastAttachAt: string
}
export interface PairingCodeRecord { // mirrors §4.2 pairing_codes (code_hash only, INV5)
readonly codeHash: string; readonly accountId: string
readonly expiresAt: string; readonly redeemedAt: string | null
}
export { HostRecord, HostStatus, PlanTier, RouteEntry } from 'relay-contracts' // §4.2 — NOT redefined
export function query<T>(sql: string, params: readonly unknown[]): Promise<readonly T[]> // parameterized ONLY
```
- **TDD** (Testcontainers PG): migration applies clean; `subdomain` UNIQUE rejects a dup insert;
`pairing_codes.code_hash` is PK; a raw string in place of a `$n` param is impossible (wrapper signature
forbids interpolation).
- **Security**: **parameterized queries only** (no string concat → no SQLi); `agent_pubkey` is a
**public** key (INV4); pairing code stored **hashed** (INV5). Enforces INV4, INV5.
---
## 4. CP1 — registries (immutable repositories)
**INV8 discipline (all of T3T5) — reconciled with the FROZEN §4.2 schema (Finding-6).** The frozen
§4.2 DDL declares `accounts`/`hosts`/`sessions` as **single-PK rows** (`host_id`/`account_id`/
`session_id` is the PK), so a *second* row with the same PK is impossible — versioning cannot be done by
re-inserting into those tables, and this plan does **not** silently invent `*_versions` tables (§4.2 is
frozen; that change must go through the INDEX). The discipline is therefore split by field class:
- **Lifecycle/business fields** (`accounts.status`, `hosts.status`, `hosts.revoked_at`) — versioned via
**companion append-only tables** `account_status_versions(account_id, version, status, changed_at,
changed_by)` and `host_status_versions(host_id, version, status, revoked_at, changed_at, changed_by)`,
with the base-table column acting as the **current pointer**. A status change is **one transaction**:
`INSERT … status_versions (next version)` **then** `UPDATE base SET status/revoked_at = new,
status_version = next` — the single-row pointer `UPDATE` is the only atomic write, and a reader either
sees the old (status, version) pair or the new one, never a torn mix. **Prior snapshots stay readable
from the `*_versions` table** (this is what the T3/T4 "old snapshot still retrievable" tests assert —
against `*_status_versions`, not a duplicate base row). These two companion tables are **added to
§4.2 and to T2's DDL** as a P3-local extension pending INDEX promotion — see **OQ6**.
- **High-frequency liveness fields** (`hosts.last_seen`, `sessions.last_attach_at`) are **NOT versioned**
— one row per heartbeat/attach would be unbounded write amplification. Per INV7, live location/liveness
lives in **Redis** (`route:{host_id}` heartbeat-TTL, T9); the Postgres `last_seen`/`last_attach_at`
columns are **advisory freshness caches** updated by a bounded, coalesced write and are **explicitly
exempt** from the "prior snapshot readable" assertion. T3/T4/T5 tests assert immutability/versioning
**only** for lifecycle/business fields, never for liveness columns.
No `UPDATE … SET <lifecycle_field>` happens without a matching `*_versions` insert in the same txn.
### T3 · account registry `[v0.9]`
- **Owns**: `control-plane/src/registry/accounts.ts`
- **Depends**: T2
- **Contract**:
```ts
export function createAccount(plan: PlanTier): Promise<AccountRecord> // new immutable row
export function getAccount(accountId: string): Promise<AccountRecord | null>
export function setAccountStatus(accountId: string, status: 'active' | 'suspended'): Promise<AccountRecord> // NEW snapshot, atomic swap
```
- **TDD**: `createAccount` returns unguessable uuid + `status:'active'` (writes `status_version=1` + the
seed `account_status_versions` row); `setAccountStatus` performs the single-txn `INSERT
account_status_versions(next) + UPDATE accounts SET status,status_version=next` and returns the **new**
record while the **prior `(status, version)` remains readable from `account_status_versions`** (assert
old snapshot retrievable **from the versions table**, not a duplicate base row); concurrent
`setAccountStatus` → one whole-or-nothing winner, versions strictly monotonic, no torn read (INV8).
- **Security**: INV8. `accountId` unguessable + never recycled (INV1 precondition).
### T4 · host registry (ownership source of truth) `[v0.9]`
- **Owns**: `control-plane/src/registry/hosts.ts`
- **Depends**: T2, T3
- **Contract** (returns the frozen §4.2 `HostRecord`):
```ts
export function bindHost(input: { // called ONLY from pairing BIND (T8), never from client input
readonly accountId: string; readonly subdomain: string
readonly agentPubkey: Uint8Array; readonly enrollFpr: string
}): Promise<HostRecord>
export function getHost(hostId: string): Promise<HostRecord | null>
export function getHostBySubdomain(subdomain: string): Promise<HostRecord | null>
export function listHosts(accountId: string): Promise<readonly HostRecord[]> // ownership scoped
export function setHostStatus(hostId: string, status: HostStatus): Promise<HostRecord> // NEW snapshot
export function ownsHost(accountId: string, hostId: string): Promise<boolean> // INV1/INV6 ownership join
```
- **TDD**: `bindHost` stores **only** the public key (grep row → no private material, INV4);
`enroll_fpr` = fingerprint of `agent_pubkey`; **`ownsHost(A, hostOwnedByB)` → false** (the INV1
ownership fact this plan supplies to P5's tripwire); `getHostBySubdomain` resolves the stable name;
`setHostStatus('revoked')` performs the single-txn `INSERT host_status_versions(next) + UPDATE hosts
SET status='revoked', revoked_at=now(), status_version=next` — prior status is still readable from
`host_status_versions` (feeds INV12). Assert liveness `last_seen` updates do **not** create a version
row (advisory cache, INV7).
- **Security**: **INV1** (host is resolvable only via `host_id`/`subdomain` bound to an account — **no
raw address/port/hostname resolution path exists in this module**), **INV4** (pubkey only), **INV6**
(`ownsHost` is the deny-by-default ownership predicate), **INV8**.
### T5 · session registry `[v0.9]`
- **Owns**: `control-plane/src/registry/sessions.ts`
- **Depends**: T2, T4
- **Contract**:
```ts
export function recordSession(input: { readonly sessionId: string; readonly hostId: string }): Promise<SessionRecord>
// account_id is DENORMALIZED from the host at write time — NEVER supplied by the caller/client (INV3)
export function getSession(sessionId: string): Promise<SessionRecord | null>
export function sessionAccount(sessionId: string): Promise<string | null> // O(1) authz fact for reattach (INV6)
```
- **TDD**: `recordSession` derives `account_id` from `hosts` (ignores any caller-passed account); a
reattach lookup for a `session_id` owned by another account resolves to that account's id (so P5's
reattach check denies) — assert `sessionAccount` never echoes a client-supplied value; unknown
`session_id` → null.
- **Security**: **INV3** (account denormalized from host, never client), **INV6** (reattach
re-validation fact).
### T6 · subdomain assignment `[v0.9]`
- **Owns**: `control-plane/src/subdomain/assign.ts`
- **Depends**: T2, T4
- **Contract**:
```ts
export function normalizeSubdomain(raw: string): string // lowercase, RFC-1123 label, strip invalid
export function isValidSubdomain(raw: string): boolean // 163 chars, [a-z0-9-], not leading/trailing '-'
export function assignSubdomain(accountId: string, requested?: string): Promise<string> // UNIQUE, atomic reserve
export function assembleFqdn(subdomain: string, baseDomain: string): string // 'alice' + 'term.x' → 'alice.term.x'
```
- **TDD**: reserved words (`www`, `api`, `admin`, `_`) rejected; UNIQUE collision → deterministic retry
or 409 (never silently reassign an existing tenant's name); `assignSubdomain` is atomic under
concurrency (two callers, same requested → exactly one wins); host-header-confusion inputs
(`a.b`, `A LICE`, `../`) rejected by `isValidSubdomain`.
- **Security**: subdomain is the browser-origin isolation boundary (EXPLORE §3) — a malformed/duplicate
label must never collapse two tenants into one origin. Supports **INV1**. Validate at boundary
(`coding-style.md`).
---
## 5. CP2 — pairing (INDEX §4.5)
### T7 · pairing-code issuance `[v0.9]`
- **Owns**: `control-plane/src/pairing/issue.ts`
- **Depends**: T2, T3 · **Cross-plan**: issued from an authenticated human session (**P5** provides the
principal; INV3 — `accountId` from the session, never a request field)
- **Contract** (INDEX §4.5 step 1):
```ts
export interface IssuedPairing { readonly code: string; readonly expiresAt: string } // raw code returned ONCE
export function issuePairingCode(accountId: string): Promise<IssuedPairing>
// mints single-use SHORT-TTL code; stores { code_hash, account_id, expires_at, redeem_attempts:0 };
// raw code NEVER stored (INV5). Code format is FIXED (see below) — the entropy claim and the display
// format are reconciled here so they cannot contradict.
```
**Entropy/display reconciliation (Finding-4 — resolves the `ABCD-1234` ≈ 32-bit contradiction).** A
`ABCD-1234` shape (4 letters + 4 digits) is only ~32 bits and is **rejected as the wire code**. The
pairing code is the **sole credential** gating `/enroll` (not capability-token gated, T11), so it is
defined as **26 Crockford-base32 characters = 130 bits of real entropy**, displayed grouped for humans as
**`XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-X`** (6 groups). This is a paste/QR-first credential, not a
type-from-memory PIN. (INDEX §4.5's `pair ABCD-1234` is an **illustrative CLI placeholder**, not a
frozen code format — §4.5 freezes only `code_hash` storage, no typed raw-code contract — so P3, the
issuer, defines the normative format here without redefining any §4 contract.) **In addition**
(defense-in-depth, since brute force of `/enroll` is P3's own attack
surface even before P5's general limiter), redemption is throttled per code (T8): a strict,
**code-scoped exponential lockout** on failed `redeemPairingCode` attempts, independent of P5's
per-tenant rate-limiter. TTL is **short by default**`pairingTtlSec` defaults to **600s (10 min)** (T1),
not left purely env-open.
- **TDD**: issued code is **≥ 128-bit entropy** (assert the sampled character space × length; reject a
32-bit `ABCD-1234` shape); display grouping round-trips to the canonical 130-bit value; DB row stores
**`code_hash` only** (grep → no raw code, INV5) with `redeem_attempts = 0`; `expires_at` = now +
`pairingTtlSec` and defaults to a 10-minute window when the env var is unset; two issues → two distinct
codes.
- **Security**: INV5 (hash at rest), INV3 (account from authenticated principal). ≥128-bit entropy +
short default TTL + code-scoped redeem lockout (T8) make offline/online guessing infeasible within the
leak window (EXPLORE §4a).
### T8 · pairing-code redemption + BIND + bind-time cert `[v0.9]`
- **Owns**: `control-plane/src/pairing/redeem.ts`, `control-plane/src/ca/sign.ts`
- **Depends**: T4, T6, T7 · **Cross-plan**: server side of **P2** `POST /enroll { code, agentPubkey, csr }`
- **Contract** (INDEX §4.5 steps 35):
```ts
// EnrollResult is FROZEN in INDEX §4.5 — IMPORTED from relay-contracts, NOT redefined locally (FIX 3).
// Shape cited verbatim: { hostId, subdomain, cert: string, caChain: string, hostContentSecret: Uint8Array }.
// - cert/caChain are PEM strings: redeem encodes signHostLeaf's DER (Uint8Array / Uint8Array[]) to PEM
// before assembling the frozen EnrollResult (§4.5 field types are the authority).
// - hostContentSecret (FIX 3): per-host CSPRNG secret, WRAPPED to the enrolled Ed25519 agent_pubkey.
import type { EnrollResult } from 'relay-contracts' // §4.5 frozen shape — do not shadow
export function redeemPairingCode(input: {
readonly code: string; readonly agentPubkey: Uint8Array; readonly csr: Uint8Array
}): Promise<EnrollResult> // ATOMIC single-use: compare-and-set redeemed_at in ONE txn (no double-spend).
// BIND (INDEX §4.5 step 4) also MINTS + WRAPS the host-scoped content secret and returns it in
// EnrollResult: after bindHost + signHostLeaf, call mintHostContentSecret(agentPubkey) and set
// EnrollResult.hostContentSecret = the wrapped blob (FIX 3). Single owner of minting = P3 (§4.5).
// CODE-SCOPED LOCKOUT (Finding-4): a failed match atomically increments pairing_codes.redeem_attempts;
// once it reaches `pairingMaxRedeemAttempts` (default 5, T1) the code is locked (treated as spent) with
// exponential backoff between attempts — independent of P5's per-tenant rate limiter. Lockout is keyed
// by `code_hash`, so brute-forcing one victim's code cannot be spread across accounts.
// control-plane/src/ca/sign.ts — registry-gated leaf signer (KMS-backed, §3.1):
export function signHostLeaf(hostId: string, agentPubkey: Uint8Array, csr: Uint8Array): Promise<{ cert: Uint8Array; caChain: readonly Uint8Array[] }>
// ORDER OF CHECKS (all must pass, same reject path):
// 1. PROOF-OF-POSSESSION — verify the CSR's own PKCS#10 self-signature against the pubkey it carries
// (standard PKI: the CA proves the requester holds the matching private key BEFORE signing).
// 2. the CSR's embedded pubkey == the caller-supplied `agentPubkey` (no substitution).
// 3. (hostId, agentPubkey) is an ACTIVE row in the host registry (INV14 registry-gate).
// A failure at ANY step rejects with the SAME error path — a valid-but-unregistered CSR and a
// registered-but-invalid-signature CSR are both refused. Signing itself = KMS sign() (§3.1), never a
// raw CA private key in memory. Rotation/renewal CADENCE + mTLS handshake verify = P5 (§7).
// control-plane/src/pairing/redeem.ts — FIX 3: mint + wrap the host-scoped content secret at BIND.
export function mintHostContentSecret(agentPubkey: Uint8Array): Promise<Uint8Array>
// Generate a per-host 32-byte CSPRNG secret and SEAL/WRAP it to the host's enrolled Ed25519 agent_pubkey
// (host-scoped — NOT a raw account-wide secret). The returned wrapped blob is EnrollResult.hostContentSecret;
// P2 unwraps it locally with the enrollment private key (never leaves the host, INV4). The control plane
// persists ONLY the wrapped blob (a per-host, per-wrap artifact the CP can invalidate on host/device
// revocation, INV12) — the raw secret is NEVER stored, NEVER logged (INV5/INV9). Feeds §4.4 deriveContentKey.
```
- **TDD**:
- **Happy path**: valid unexpired unredeemed code → `bindHost` runs, subdomain assigned, `redeemed_at`
set, `EnrollResult` returned with `cert`/`caChain` as PEM strings, a cert whose subject pubkey ==
`agentPubkey`, and a non-empty `hostContentSecret` (frozen §4.5 fields all present).
- **hostContentSecret mint/wrap (FIX 3, security)**: `EnrollResult.hostContentSecret` is present and is
the **wrapped** blob (grep PG/logs → the raw 32-byte secret NEVER appears, INV5/INV9); two enrollments
(or two hosts) yield **distinct** wrapped secrets (per-host, per-wrap → per-host revocable, INV12);
the wrap targets the enrolled `agent_pubkey` (P2 can unwrap with the matching private key, no other host can).
- **Double-spend (security)**: two concurrent `redeemPairingCode` with the same code → **exactly one**
succeeds, the other → `already_redeemed` (CAS on `redeemed_at`).
- **Expired** code → reject; **unknown** code → reject; **CSR pubkey ≠ body `agentPubkey`** → reject.
- **CSR proof-of-possession (security)**: a CSR whose PKCS#10 **self-signature does not validate**
against its **own embedded pubkey****reject**, *independent of registry state* — assert this holds
**even when that pubkey happens to be a registered, active host** (so a signature-forged CSR for a
real registered key cannot obtain a leaf). Reject uses the same error path as the INV14 gate.
- **INV14 gate**: `signHostLeaf` for a pubkey not in the registry → **throws** (assert the CA cannot be
tricked into signing an unbound key), and the KMS `sign()` is never invoked when any check fails.
- **INV5**: redemption matches by **hash** of the presented code, never a stored raw code.
- **Lockout (security)**: after `pairingMaxRedeemAttempts` (default 5) failed redemptions against the
**same `code_hash`**, the next attempt — **even with the correct code** — is refused as locked
(`too_many_attempts`), and the counter increment is atomic under concurrency (no attempt escapes the
count via a race). Assert lockout is per-`code_hash`, not per-account/IP.
- **Security**: INV5, INV14 (registry-gated signing incl. CSR proof-of-possession), INV8 (BIND writes
immutable host row), INV3 (account taken from the pairing row, not from the agent's request).
Redemption is **atomic + single-use** with a **code-scoped brute-force lockout** (INDEX §4.5).
---
## 6. CP3 — live routing table & node coordination
### T9 · Redis routing table + relay-node identity `[v0.9]`
- **Owns**: `control-plane/src/routing/table.ts`, `control-plane/src/node-auth/identity.ts`
- **Depends**: T2, T4 · **Cross-plan**: **read by P1** (data plane splices browser → node holding tunnel);
relay-node **service certs** issued/rotated by **P5/P1** — see **OQ5**
- **Contract** (INDEX §4.2 Redis `route:{host_id}`, INV7):
```ts
// control-plane/src/node-auth/identity.ts — the relay-node↔control-plane trust boundary (analog of INV3)
export interface NodeIdentity { readonly nodeId: string } // derived from the mTLS client-cert SUBJECT
export function nodeIdentityFromRequest(req: FastifyRequest): NodeIdentity
// reads the VERIFIED relay-node mTLS client-cert subject (SPIFFE-style node id) from the TLS session;
// THROWS 401 if the connection is not mutually authenticated. The `nodeId` is NEVER taken from a body/
// query/header field — a request that carries a `nodeId` payload has it IGNORED for identity.
// control-plane/src/routing/table.ts — every mutation is scoped to the AUTHENTICATED node identity:
export function upsertRoute(caller: NodeIdentity, hostId: string, entry: RouteEntry, ttlSec: number): Promise<void>
// entry.relayNodeId MUST equal caller.nodeId (a node can only route hosts to ITSELF); mismatch → reject
export function heartbeatRoute(caller: NodeIdentity, hostId: string, ttlSec: number): Promise<void> // refresh TTL (~<15s)
export function resolveRoute(hostId: string): Promise<RouteEntry | null> // Redis lookup; null ⇒ offline
export function dropRoute(caller: NodeIdentity, hostId: string): Promise<void> // only the holding node (or drain/T13)
```
- **TDD** (Testcontainers Redis): `upsertRoute` then `resolveRoute` returns the entry; after `ttlSec`
with no heartbeat → `resolveRoute` null (heartbeat-TTL expiry = liveness, INV7); `heartbeatRoute` from
a `caller.nodeId` that is **not** the current route holder is rejected/no-ops (a stale/foreign node
can't hijack a tenant's live tunnel); **node-identity (security)**: `upsertRoute` whose
`entry.relayNodeId ≠ caller.nodeId`**reject**, and `nodeIdentityFromRequest` on a
non-mutually-authenticated connection → **401** (assert a caller cannot inject a route for a
`hostId`/`nodeId` it is not the authenticated identity for, even if it guesses the ids); `dropRoute`
removes it.
- **Security**: **INV7** — Redis holds only *location*, never ownership or secrets; a missing/expired key
fails **closed** (host treated offline, not "route anywhere"). Postgres remains ownership truth. The
`nodeId` is **derived from the caller's authenticated mTLS identity, never a request field** — the
relay-node↔control-plane analog of INV3, closing the "any reachable caller can redirect a tunnel" gap.
### T10 · relay-node registry + graceful drain `[v0.9]`
- **Owns**: `control-plane/src/routing/nodes.ts`
- **Depends**: T9 · **Cross-plan**: requests **P1** `GOAWAY` (INDEX §4.1) by PUBLISHING a `KillSignal` on
the FROZEN `relay:revocations` bus (INDEX §4.2 FIX 4 — the **SAME** channel T13 revoke uses); P1 nodes
subscribe, emit the §4.1 `GOAWAY`, agents reconnect. **OQ4 RESOLVED.**
- **Contract** (EXPLORE §3 "graceful drain"). Every mutation is scoped to the caller's authenticated
`NodeIdentity` (T9) — a node can only register/heartbeat/drain **itself**; admin-initiated drain arrives
through the T11 admin API (capability-token authorized), not as a bare `nodeId` field:
```ts
export type NodeStatus = 'active' | 'draining'
export function registerNode(caller: NodeIdentity, addr: string): Promise<void> // nodeId = caller.nodeId
export function nodeHeartbeat(caller: NodeIdentity): Promise<void>
export function beginDrain(caller: NodeIdentity): Promise<{ readonly hostIds: readonly string[] }>
// SELF-drain only: marks caller.nodeId 'draining', enumerates its live routes; then a KillSignal is
// published on the frozen relay:revocations bus (INDEX §4.2 FIX 4) so P1 emits GOAWAY(lastStreamId,
// reason) for those streams. A caller cannot drain a node other than its own authenticated identity.
export function completeDrain(caller: NodeIdentity): Promise<void> // after routes re-homed → deregister self
// Operator-initiated drain (an admin draining a named node for maintenance) is a T11 admin route: it
// runs its own capability-token authz (rights ⊇ 'manage') and THEN invokes the same drain path with a
// server-derived NodeIdentity — the untrusted request never supplies a bare nodeId to this module.
```
- **TDD**: `beginDrain` flips status to `draining` and returns exactly the host_ids routed to that node;
a drained node stops receiving new `upsertRoute` targeting it; **node-identity (security)** — a
`registerNode`/`nodeHeartbeat`/self-`beginDrain` whose `nodeId` is not the caller's authenticated
identity (and not a `manage`-scoped admin) is **rejected** (a forged/guessed `nodeId` cannot drain or
re-register another node); **PTY-survival assertion** — draining a node does not touch any Postgres
host/session row (the running task lives on the customer machine, EXPLORE §3); `completeDrain`
deregisters only after routes moved.
- **Security**: INV7 (stateless data plane — drain loses nothing durable). Drain uses the frozen §4.1
`GOAWAY` semantics; this module **does not** encode the frame (that is P1) — it only decides *which*
hosts drain and PUBLISHES a `KillSignal` on the **frozen `relay:revocations` bus** (INDEX §4.2 FIX 4,
the SAME channel T13 uses) that every P1 node subscribes to and translates into the §4.1 `GOAWAY`.
**OQ4 RESOLVED** — the channel is now a FROZEN SHARED CONTRACT in the INDEX, not an assumed dispatcher.
`nodeId` in every call is the authenticated mTLS identity (Finding-2), never a trusted request field.
---
## 7. CP4 — provisioning API & metering
### T11 · provisioning / deprovisioning HTTP API `[v0.9]`
- **Owns**: `control-plane/src/api/provision.ts`, `control-plane/src/api/authz.ts`,
`control-plane/src/deprovision/deprovision.ts`, `control-plane/src/main.ts`
- **Depends**: T3, T4, T6, T7, T9 · **Cross-plan**: authz consumes **P5** `verifyCapabilityToken` (§4.3)
- **Phasing note (Finding-7):** the v0.9 `DELETE /hosts/:id` is **self-contained in v0.9** — it calls the
**v0.9 minimal deprovision** in `deprovision/deprovision.ts` (`deprovisionHost`: `setHostStatus(hostId,
'revoked')` via T4 + `dropRoute` via T9), which is enough to tear the host down for accounting/UX. It
does **NOT** depend on T13 (v0.10). The **fast, within-seconds tunnel-kill + cert-stop** hardening
(INV12) is an **additive upgrade** delivered by T13 in v0.10; when T13 lands, `DELETE /hosts/:id`
delegates to `revokeHost` for the seconds-latency path. No v0.9 task depends on a v0.10 task.
```ts
// control-plane/src/deprovision/deprovision.ts — v0.9 minimal tear-down (superseded by T13 fast path)
export function deprovisionHost(principal: AdminPrincipal, hostId: string): Promise<void>
// requires ownsHost(principal.accountId, hostId); setHostStatus('revoked') (T4 versioned) + dropRoute (T9).
// Best-effort tunnel drop only; NOT the INV12 within-seconds guarantee (that is T13/v0.10).
```
- **Contract**:
```ts
// control-plane/src/api/authz.ts
export interface AdminPrincipal { readonly accountId: string; readonly rights: readonly CapabilityRight[] }
export function principalFromRequest(req: FastifyRequest): AdminPrincipal
// derives accountId from the VERIFIED capability token / authenticated session ONLY (INV3);
// any body/query field named account_id/tenant_id is IGNORED for authz decisions.
// control-plane/src/api/provision.ts (all routes deny-by-default; scope every op to principal.accountId)
// POST /accounts -> create account (admin-scoped)
// POST /accounts/:id/pairing-codes -> issuePairingCode(principal.accountId) (T7)
// POST /accounts/:id/status -> suspend/reactivate (T3)
// GET /accounts/:id/hosts -> listHosts(principal.accountId) (T4, ownership-scoped)
// DELETE /hosts/:hostId -> v0.9: deprovisionHost (T11) ; v0.10: revokeHost (T13 fast path)
// — both require ownsHost(principal.accountId, hostId)
// POST /enroll -> redeemPairingCode (T8) — agent leg, NOT capability-token gated
export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync
```
- **TDD**:
- **INV3/INV1 (security, the headline test)**: request authenticated as account **A** with body
`{ account_id: B }` targeting a host owned by **B****403**; the forged `account_id` is never read.
- `principalFromRequest` with a missing/expired/foreign-`aud` token → 401 (delegates to
`verifyCapabilityToken`); scope `attach` cannot hit a `manage`/`kill` route (rights subset check).
- `DELETE /hosts/:id` where `!ownsHost(principal.accountId, id)` → 403 (not 404-leak vs 403 — pick 403
per INDEX INV1 tripwire wording).
- Every route validates its body with **Zod** at the boundary; malformed → 400.
- **Security**: **INV3** (principal-derived account), **INV6** (deny-by-default; every route re-checks
ownership), **INV1** (ownership join on host-targeting routes). No route resolves a host by
user-supplied address. `/enroll` is the one unauthenticated-by-capability-token route (it *is* the
auth-bootstrap) but is guarded by the single-use pairing code (T8).
### T12 · billing-metering hooks `[v0.9]`
- **Owns**: `control-plane/src/metering/collect.ts`
- **Depends**: T4, T9 · **Cross-plan**: **P1** relay nodes POST viewer-count samples (they alone see
live streams)
- **Contract** (EXPLORE §6 — meter **paired hosts + concurrent viewers**, NOT bandwidth). The sample's
origin `nodeId` is the caller's **authenticated mTLS identity** (T9 `NodeIdentity`), never a body field,
and the sample's `accountId` is **re-derived server-side** from the host registry — the node-supplied
`accountId` is treated as untrusted and validated, not trusted (INV1/INV3):
```ts
export interface MeteringSample { // pushed by relay nodes; immutable append
readonly hostId: string
readonly concurrentViewers: number; readonly sampledAt: string
// NO client/node-supplied accountId or nodeId — both are derived server-side (see below)
}
export function ingestSample(caller: NodeIdentity, sample: MeteringSample): Promise<void>
// sets nodeId = caller.nodeId; derives accountId via ownsHost/getHost(sample.hostId); append-only
// INSERT (INV8) — never updates. A hostId the caller cannot substantiate against the registry is rejected.
export function pairedHostCount(accountId: string): Promise<number> // from host registry (non-revoked)
export function rollupUsage(accountId: string, from: string, to: string): Promise<{
readonly pairedHostPeak: number; readonly viewerPeak: number; readonly viewerHours: number
}>
```
- **TDD**: `ingestSample` inserts a new immutable row (no `UPDATE`) with `nodeId` set to the authenticated
caller and `accountId` derived from the host registry; `pairedHostCount` excludes `status:'revoked'`
hosts; `rollupUsage` computes peak concurrent viewers + viewer-hours over a window; **forged-attribution
(security)**: a sample for a `hostId` whose derived owner differs from any node-hinted account, or from
an unauthenticated caller, is **rejected** (metering can't be forged or attributed cross-tenant, INV1;
a node cannot mint usage against another tenant); malformed sample → Zod 400.
- **Security**: INV8 (append-only immutable samples), INV1 (attribution derived from ownership, never
node-asserted), INV3-analog (nodeId from authenticated identity). Zero terminal payload in samples
(INV10-adjacent) — counts and timestamps only.
---
## 8. CP5 — hardening (v0.10)
### T13 · revocation (global + per-host) `[v0.10]`
- **Owns**: `control-plane/src/revoke/revoke.ts`
- **Depends**: T4, T9, T10 · **Cross-plan**: shares Redis `revoked:{jti}` with **P5**; the **within-seconds
tunnel-kill** step PUBLISHES a `KillSignal` on the FROZEN `relay:revocations` bus (INDEX §4.2 FIX 4) via
`RevocationBus.publish` — the **SAME named channel** T10's drain uses; every P1 node subscribes and
injects the §4.1 `CLOSE`+`RST` within `REVOCATION_PUSH_BUDGET_MS` (2000ms). **P5 also publishes** on this
bus (token revocation); **P3 owns** host/account revocation publishes. **OQ4 RESOLVED.**
- **Contract** (INDEX §4.2 `revoked:{jti}`, INV12):
```ts
// revoke.ts IMPORTS KillSignal / RevocationScope / RevocationBus / RELAY_REVOCATIONS_CHANNEL /
// REVOCATION_PUSH_BUDGET_MS from relay-contracts (INDEX §4.2 FIX 4) — NONE redefined locally. It
// constructs the RevocationBus over the Redis pub/sub client and only PUBLISHES (P1 owns the subscriber).
// KillSignal.reason is metadata only (INV10, zero payload).
export function revokeHost(hostId: string): Promise<void>
// 1) host status -> 'revoked' + revoked_at (T4, versioned snapshot) 2) dropRoute (T9)
// 3) bus.publish({ scope: { kind: 'host', hostId }, at, reason }) on RELAY_REVOCATIONS_CHANNEL
// ('relay:revocations') — P1 subscribers inject the §4.1 CLOSE+RST (immediate, distinct from the
// graceful GOAWAY) within REVOCATION_PUSH_BUDGET_MS (2000ms) 4) stop future cert signing for its
// pubkey (T8 CA gate + T15 renew gate now refuse) — all within seconds (INV12)
export function revokeAccount(accountId: string): Promise<void>
// revoke every host of the account; bus.publish({ scope: { kind: 'account', accountId }, at, reason })
// — one account-scoped KillSignal; P1 tears down all of the account's live streams (§4.2 FIX 4)
export function revokeToken(jti: string, expUnix: number): Promise<void> // SET revoked:{jti} EX (exp-now)
export function isTokenRevoked(jti: string): Promise<boolean>
```
- **TDD** (security): after `revokeHost`, `resolveRoute(hostId)` → null AND a **`KillSignal { scope:{ kind:
'host', hostId } }` is published on `relay:revocations`** (assert a test subscriber receives it, so a P1
node would tear the tunnel down within `REVOCATION_PUSH_BUDGET_MS`) AND the host's next reconnect is
refused (status `revoked`) AND `signHostLeaf` refuses its pubkey (T8 gate) — all observable within a
bounded time; `revokeAccount` publishes one **account-scoped** `KillSignal` and cascades to all hosts;
`revokeToken` → `isTokenRevoked` true and stops validating (INV12); a revoked host's capability tokens
stop authorizing (via `revoked:{jti}` + status). Assert the published `KillSignal.reason` carries **no**
terminal payload (INV10).
- **Security**: **INV12** (fast tunnel-killing revocation), **INV8** (revocation writes an immutable
snapshot, doesn't mutate). Revocation is idempotent.
### T14 · immutable audit log (zero payload) `[v0.10]`
- **Owns**: `control-plane/src/audit/log.ts`
- **Depends**: T2 · **Cross-plan**: **imported by P5** for attach/manage/kill security events (P3 owns the
*substrate + control-plane events*; P5 owns *connect-time event semantics + cross-tenant alerts*)
- **Contract** (INV10):
```ts
export type AuditAction = 'account.create' | 'account.suspend' | 'host.bind' | 'host.revoke'
| 'pairing.issue' | 'pairing.redeem' | 'subdomain.assign' | 'node.drain'
| 'attach' | 'manage' | 'kill' | 'revoke' // the last four written by P5 through this API
export interface AuditEntry {
readonly action: AuditAction; readonly principalId: string; readonly accountId: string
readonly hostId: string | null; readonly ts: string; readonly meta: Readonly<Record<string, string>>
// NO terminal payload, NO keystrokes/output, NO secret — metadata only (INV10)
}
export function writeAuditEvent(entry: AuditEntry): Promise<void> // append-only INSERT; immutable
export function queryAudit(accountId: string, from: string, to: string): Promise<readonly AuditEntry[]>
```
- **TDD** (security): a `writeAuditEvent` call carrying a `meta` value that looks like shell output is
**rejected/stripped** by a payload guard (assert no field can hold >N bytes of opaque data / a known
plaintext marker never lands in `audit_log`); entries are append-only (no `UPDATE`/`DELETE` path
exists); every control-plane mutation in T3/T4/T6/T7/T8/T10/T13 emits exactly one audit entry.
- **Security**: **INV10** — immutable, **zero-payload**, metadata-only. The writer is the single funnel
P5 uses so cross-tenant alerts (P5-owned) sit atop the same append-only store.
### T15 · cert-rotation coordination + secret hardening `[v0.10]`
- **Owns**: `control-plane/src/ca/rotate.ts` (renewal), `control-plane/src/boot/ca-wiring.ts`
(CA-key/secret-manager bootstrap + KMS-signer factory). **Disjoint from T8** (`ca/sign.ts`) **and T11**
(`main.ts`): `main.ts` merely *imports* `boot/ca-wiring.ts`; T15 edits **no** file owned by another task.
The KMS-signer factory in `boot/ca-wiring.ts` is the shared primitive both `ca/sign.ts` (T8) and
`ca/rotate.ts` (T15) call — neither loads a raw key (§3.1).
- **Depends**: T8, T1 · **Cross-plan**: rotation *cadence + mTLS verify* is **P5/INV14**; P3 provides the
registry-gated **signing** only
- **Contract**:
```ts
// control-plane/src/boot/ca-wiring.ts
export interface CaSigner { sign(tbsCert: Uint8Array): Promise<Uint8Array> } // wraps KMS sign(); no raw key
export function buildCaSigner(env: ControlPlaneEnv): Promise<CaSigner>
// resolves caIntermediateKmsKeyRef; FAILS FAST if the KMS key is unresolvable OR its policy does not
// restrict `sign` to the control-plane service principal (§3.1 startup check, INV9).
// control-plane/src/ca/rotate.ts
export function renewHostLeaf(hostId: string, csr: Uint8Array): Promise<{ cert: Uint8Array; caChain: readonly Uint8Array[] }>
// re-sign a SHORT-TTL leaf for an already-bound, non-revoked host under the CURRENT intermediate.
// Same guards as signHostLeaf (T8): (1) verify the CSR PKCS#10 SELF-SIGNATURE (proof-of-possession)
// against its embedded pubkey; (2) that pubkey == the host's registered agent_pubkey; (3) host is
// active/non-revoked. Any failure → same reject path. Signing = CaSigner.sign() (KMS), never a raw key.
```
- **TDD**: `renewHostLeaf` for an active host with a valid CSR → fresh short-TTL cert (same subject
pubkey, signed under the current intermediate); a CSR whose **self-signature does not validate** →
reject even for an active host (proof-of-possession, Finding-1); for a **revoked** host → throws (a
drained/revoked host cannot renew, closing the INV12+INV14 loop); `buildCaSigner` **fails fast** at
startup if `caIntermediateKmsKeyRef` is unresolvable OR its KMS policy is broader than the
control-plane service principal (§3.1, INV9); assert renewal invokes `CaSigner.sign()` and never loads
a raw private key.
- **Security**: **INV14** (short-lived, registry-gated leaf with CSR proof-of-possession; CA never signs
an unbound/revoked pubkey), **INV9** (CA intermediate key held in KMS/HSM by reference, signing via
`sign()` call, validated + policy-checked at startup, never logged, never at rest in code — §3.1).
---
## 9. SECURITY section (per-plan summary)
| INV | How this plan enforces it | Owning task(s) / test |
|-----|---------------------------|------------------------|
| **INV1** | Host is resolvable **only** via `host_id`/`subdomain` bound to an account; `ownsHost` is the sole ownership predicate; no address/port/hostname resolution path exists. | T4 `ownsHost`, T6 subdomain validation, T11 403-on-forged-account |
| **INV3** | `account_id` derived from the authenticated principal / pairing row / host row — **never** a client field. **Relay-node analog:** `nodeId` in T9/T10/T12 is derived from the caller's authenticated mTLS identity, never a request field. | T5, T7, T11 `principalFromRequest`; T9 `nodeIdentityFromRequest`, T10/T12 node-auth |
| **INV4** | DB stores **public** Ed25519 keys only; private key generated on the host (P2), never received. | T2 schema, T4 `bindHost`, T8 CSR check |
| **INV5** | Pairing codes + v0.8 tokens hashed at rest; no raw secret, no shell bytes stored. | T0, T2, T7 |
| **INV6** | Deny-by-default: every admin route + reattach re-validates ownership; default reject. | T4 `ownsHost`, T5 `sessionAccount`, T11 |
| **INV7** | Redis routing = *location only*, heartbeat-TTL, fails closed; Postgres = ownership truth; nodes hold no durable state. | T9, T10 |
| **INV8** | Immutable lifecycle fields versioned via `*_status_versions` companion tables + atomic pointer-swap (prior snapshot readable); liveness fields are advisory caches (Redis is truth, INV7); append-only metering/audit. | T2, T3, T4, T5, T12, T14 |
| **INV9** | Secrets from env/secret-manager, Zod-validated at startup, fail-fast, never logged. **CA intermediate key held in KMS/HSM (non-exportable); signing via `sign()` call, never loaded raw; startup key-policy check.** | T1, T15 (§3.1) |
| **INV10** | Append-only, metadata-only audit writer with a payload guard; single funnel for P5 events. | T14 |
| **INV12** | v0.9 `deprovisionHost` (status→revoked + dropRoute) for tear-down; v0.10 `revokeHost/Account/Token` drop route + kill tunnel by publishing a `KillSignal` on the frozen `relay:revocations` bus (INDEX §4.2 FIX 4) + stop cert signing within seconds. | T11, T13, T15 |
| **INV14 (registry half)** | CA verifies **CSR proof-of-possession (PKCS#10 self-signature)** then signs **only** pubkeys active in the host registry; refuses revoked/absent. | T8, T15 |
**Not owned here (delegated, cited to avoid overlap):** capability-token **signing key** + WS-upgrade
enforcement + per-tenant **rate-limit enforcement** + the **CI cross-tenant tripwire** + human Passkey
auth = **P5**; the mux frame codec + `GOAWAY` wire encoding = **P1**; E2E crypto = **P4**.
---
## 10. VERIFICATION
```bash
# unit + integration (Testcontainers spins ephemeral Postgres + Redis)
cd control-plane && npm test # full P3 suite
npx vitest run registry # T3T5 immutable-record + ownership-join tests
npx vitest run pairing # T7 ≥128-bit entropy + T8 atomic single-use / double-spend / CSR proof-of-possession / code-scoped lockout
npx vitest run routing # T9 heartbeat-TTL liveness + node-identity (forged nodeId rejected) + T10 drain (PTY-survival)
npx vitest run api/provision # T11 INV3/INV1 403-on-forged-account (headline security test)
npx vitest run metering # T12 append-only + cross-tenant attribution reject
npx vitest run revoke # T13 revoke → route dropped + KillSignal on relay:revocations + reconnect refused (INV12)
npx vitest run audit # T14 zero-payload guard + append-only
npm run typecheck # relay-contracts §4 imported read-only; no local redefinition
npx vitest run --coverage # 80%+ (coding-style/testing.md)
# at-rest secret/plaintext scans (INV5/INV9/INV10 tripwires — run in CI)
scripts/scan-at-rest.sh # grep PG/Redis/disk dumps for raw pairing codes, private keys, shell markers → MUST be empty
```
**Manual / cross-plan integration (v0.9 gate):** issue a pairing code (T7) → redeem from a real agent
(P2) → `route:{host_id}` appears in Redis (T9, written under the node's authenticated mTLS identity) → P1
resolves it → `DELETE /hosts/:id` deprovisions (T11 minimal path: status→revoked + dropRoute) → reconnect
refused. **NOTE:** graceful node drain (T10) and the within-seconds tunnel-kill (T13) both PUBLISH a
`KillSignal` on the now-**frozen** `relay:revocations` bus (INDEX §4.2 FIX 4); the end-to-end integration
asserts a P1 subscriber tears the live tunnel down within `REVOCATION_PUSH_BUDGET_MS` (2000ms) — **OQ4
RESOLVED**, no longer a gate. **v0.10 gate:** `renewHostLeaf` rotates a live cert with no downtime; a
revoked host cannot renew; audit log shows every lifecycle event with **zero** terminal payload.
---
## 11. OPEN QUESTIONS (return to orchestrator — do NOT guess, per CLAUDE.md)
- **OQ1 — TS shapes for `AccountRecord`/`SessionRecord`/`PairingCodeRecord`.** INDEX §4.2 freezes the
**SQL** for these but gives TS interfaces only for `HostRecord`/`RouteEntry`. P5/P6/metering will want
the account/session TS shapes too. **Should they be promoted into `relay-contracts/` §4.2 (via the
INDEX) rather than mirrored locally in `control-plane/src/model/records.ts`?** Until decided, this plan
mirrors them locally, matching the frozen SQL column-for-column.
- **OQ2 — mTLS cert authority ownership split.** INDEX §5 assigns the "mTLS cert authority" to **P3**,
but INV14's primary owners are listed as **P5, P2**. This plan takes the defensible split: **P3 owns
the registry-gated *signing* (bind + renew), P5 owns rotation cadence + handshake verification.**
Confirm this boundary so T8/T15 and P5 don't both implement rotation.
- **OQ3 — Concurrent-viewer sampling source.** Metering (T12) needs live viewer counts that only the
**P1** data plane observes. Confirm the push contract (relay node → `POST /metering/samples`) and its
cadence lives in P1's charter, with P3 only ingesting.
- **OQ4 — RESOLVED (INDEX §4.2 FIX 4).** The control-plane→relay-node teardown channel is now a **FROZEN
SHARED CONTRACT**: the Redis pub/sub channel `RELAY_REVOCATIONS_CHANNEL = 'relay:revocations'` carrying a
`KillSignal { scope, at, reason }` (`RevocationScope = { kind:'host' } | { kind:'account' } | { kind:
'global' }`), published via `RevocationBus.publish` and **promoted into `relay-contracts`** so P3 never
imports P5 (DAG stays acyclic). **P3/P5 publish; every P1 node subscribes** and injects the existing §4.1
`CLOSE`+`RST` (host/account scope) or connection-level `GOAWAY` (global scope) — **no new wire type** —
within `REVOCATION_PUSH_BUDGET_MS = 2000`. **T10 drain and T13 revoke use this SAME named channel** (this
plan cites it verbatim, redefining nothing). **Residual:** the frozen `RevocationScope` has no `node`
kind and no graceful-vs-immediate discriminator, so a single-node *operator* drain (T10) is expressed via
per-host `KillSignal`s (with a drain `reason`) rather than one node-scoped signal — flagged back to the
INDEX in case a `node` scope or a `mode: 'graceful' | 'immediate'` field is wanted later (an additive,
INDEX-owned §4.2 change; not blocking). *(Originally raised by review Finding-8.)*
- **OQ5 — Relay-node service-cert issuance/rotation owner (BLOCKS T9/T10/T12 node-auth).** T9's
`nodeIdentityFromRequest` derives `nodeId` from a **verified relay-node mTLS client cert**, and
T9/T10/T12 reject any bare request-supplied `nodeId`. Who **issues + rotates** those node service certs
(SPIFFE-style), and what trust bundle (`nodeMtlsTrustBundlePath`, T1) the control plane pins to verify
them, is a **P5/P1 boundary** not yet frozen. Confirm the owner and the node-cert SVID format so the
control plane's verifier and the node's presented identity agree. *(Raised by review Finding-2.)*
- **OQ6 — INV8 companion version tables must be promoted into frozen §4.2 (BLOCKS T2/T3/T4 as written).**
§4.2 freezes `accounts`/`hosts`/`sessions` as single-PK rows, which cannot carry versioned snapshots.
This plan adds `account_status_versions` / `host_status_versions` + a `status_version` pointer column to
satisfy INV8 without in-place mutation of lifecycle fields (§4 discipline). Because §4.2 is frozen,
these tables + column **must be promoted into INDEX §4.2** (like OQ1) rather than added only locally in
T2's DDL. Confirm promotion; also confirm the ruling that high-frequency liveness fields (`last_seen`,
`last_attach_at`) are **advisory caches exempt from versioning** (live truth is Redis, INV7) — so
T3/T4/T5 do NOT ship "prior snapshot readable" assertions for liveness columns. *(Raised by review
Finding-6.)*
```