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

659 lines
60 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# PLAN_RELAY_E2E — End-to-End Encryption (ciphertext-shuttle) · P4
> **Plan P4 of the Rendezvous-Relay program.** Source of intent:
> [`EXPLORE_RELAY_SERVICE.md`](./EXPLORE_RELAY_SERVICE.md) §0 (LOCKED, decision 2 = E2E) and §4c/§4d;
> coordination point: [`PLAN_RELAY_INDEX.md`](./PLAN_RELAY_INDEX.md) — this plan **references its §3
> invariants and §4 FROZEN CONTRACTS by name and MUST NOT redefine any of them locally.**
> Conventions follow [`PLAN.md`](./PLAN.md) / [`PLAN_VOICE_COMMANDS.md`](./PLAN_VOICE_COMMANDS.md):
> stable task IDs grouped into dependency waves, `Owns:` disjoint-file lists, function-signature-level
> contracts, TDD (tests FIRST), explicit test cases incl. security/negative, per-task security notes.
> `PROGRESS_LOG.md` is **orchestrator-only** — a dispatched builder ends with a ready-to-paste entry (G1).
---
## 0. Scope
Owns **browser↔agent end-to-end encryption**, delivered as the shared crypto core package
**`relay-e2e/`** (INDEX §0 table) that is imported by both `agent/` (P2, host side) and the browser
bundle `relay-web/` (P6, client side). Concretely, P4 implements exactly the pieces the INDEX §5 P4
charter names, all **against the frozen §4 contracts**:
1. **Authenticated X25519 ECDH THROUGH the relay** — INDEX **§4.4** handshake (`ClientHello`/`HostHello`).
The relay forwards handshake messages as opaque **§4.1 `DATA`** frames and **cannot derive the key**
(INV2). Ephemeral X25519 + HKDF exactly as §4.4 specifies.
2. **Host-key pinning / TOFU bound at enrollment** — the browser verifies the host's `enroll_fpr`
(INDEX **§4.2** `hosts.enroll_fpr`); `HostHello.sig` = Ed25519 over the transcript by the host's
enrolled key (§4.2 `agent_pubkey`). Fingerprint mismatch ⇒ **ABORT** (no malicious-relay MITM, §4d).
3. **AEAD frame envelope** — INDEX **§4.4** `E2EEnvelope` (AES-256-GCM / XChaCha20-Poly1305), **deterministic
per-`seq` nonce + strictly monotonic `seq` + direction-scoped subkeys** for anti-replay/injection and
bidirectional-misuse resistance (INV13). `sealFrame`/`openFrame` per §4.4 — **the INDEX now freezes these
as `sealFrame(key: AeadKey, seq, plaintext): E2EEnvelope` / `openFrame(key: AeadKey, env, expectedSeq)`,
SYNCHRONOUS, with `AeadKey` (not `CryptoKey`), `aad = directionLabel‖seq`, and direction-split
`DirectionalKeys{c2h,h2c}` (INDEX §4.4, FIX 2)**. P4 **cites these verbatim from `relay-contracts` and
defines none of them locally** (the earlier drift/§9 Q4 amendment has landed).
4. **Session-key lifecycle** — derived on attach, **re-derived on refresh/reconnect**; ring-buffer
replay of *ciphertext* survives (EXPLORE §4c). The **recoverable-replay-key** design (T9/T10) is now a
**frozen §4.4 surface** (`ReplayKeyParams`, `deriveContentKey`, `sealReplayFrame`, `openReplayCiphertext`,
`REPLAY_KDF_INFO`; INDEX §4.4 + §4.5 `hostContentSecret`, FIX 3) — no longer an open §4.4 gap.
5. **Multi-device key distribution over an authenticated (account-derived) channel — NOT a plaintext QR.**
Each authorized device runs its own §4.4 handshake gated by an account-derived `deviceAuthProof`
(INV3); the key never travels in cleartext and no shoulder-surfable QR carries it (§4c).
6. **Documents the relay-sees-only-ciphertext guarantee** and the server features E2E kills — server-side
preview thumbnails / manage grid / search / recording move **CLIENT-SIDE** (rendered in the
key-holding browser, owned by P6). This plan ships the decrypt primitive P6 consumes; it does **not**
own the UI.
### Out of scope (explicit fences — other plans own these)
- **Transport framing / mux / routing** → P1 (§4.1). P4 payloads *ride inside* `DATA`; P4 never touches the header.
- **Human auth (Passkey/WebAuthn/OIDC), capability tokens, `deviceAuthProof` issuance+verification, revocation, audit** → P5 (§4.3). P4 *carries* `deviceAuthProof` and *shape-validates* it, but its cryptographic meaning and verification are P5's.
- **Enrollment / pairing / Ed25519 host keypair generation + storage** → P2 (§4.5) & P3 (§4.2). P4 *consumes* the enrolled pubkey (to verify `sig`) and the `enroll_fpr` (to pin); it does not generate or persist host identity keys.
- **Account/host/session records, Postgres/Redis** → P3 (§4.2).
- **Client-side preview UI, dashboard, xterm render** → P6. P4 exposes `E2ESession.open()`; P6 renders.
### Phasing (INDEX §1)
- **v0.8 (MVP):** P4 **writes its contracts / wire format and ships test vectors, but NO live crypto in the byte path** (INDEX §1: "P4/P5 write their contracts … but ship no runtime"). Deliverables: the `E2EEnvelope` wire codec, the isomorphic crypto provider, AEAD primitives, and fingerprint helpers — all fully tested with KATs so the `DATA` payload is shaped for ciphertext to drop in without reshaping (INDEX §1 "the frame path is designed so ciphertext drops in"). Wave **E0**.
- **v0.9:** no new P4 runtime (v0.9 stays plaintext; native mux + accounts land). P4 keeps E0 green against the native-mux `DATA` frame once P1 swaps substrate.
- **v0.10 (E2E-hardening — the non-negotiable phase, done BEFORE scaling users):** full browser↔agent E2E — handshake state machine, session AEAD, anti-replay, key lifecycle, multi-device. Waves **E1E3**.
### Security invariants this plan ENFORCES (INDEX §3)
- **INV2** — relay handles only ciphertext (P4 is the reason it *can*: opaque AEAD envelopes; no key at the relay).
- **INV5** — no plaintext/secret at rest (session keys are non-extractable `CryptoKey`s / zeroized buffers; ring buffer holds ciphertext only).
- **INV13** — anti-replay/injection: per-message nonce + strictly monotonic `seq`; replay/reorder/tamper rejected.
- **Consumes / reinforces:** INV1 (E2E makes cross-tenant buffer bleed *cryptographically* meaningless — B cannot open A's frames), INV3 (device bound to account via `deviceAuthProof`; no client-supplied identity in key derivation), INV9 (no secret material logged).
---
## 1. Package layout & file ownership
**All new code lives in `relay-e2e/`** (INDEX §0). Isomorphic ESM (browser + Node ≥20) — **zero `ws`/`pg`/DOM
ambient**, so `agent/` and `relay-web/` can both import it. Small cohesive files (`coding-style.md`: 200400
typical, 800 hard max). `relay-contracts/` is **read-only** to this plan (frozen §4 types + Zod validators).
| File | Wave / Phase | Role |
|------|------|------|
| `relay-e2e/package.json` | E0 / v0.8 | Package manifest; deps: `@noble/ciphers`, `@noble/curves`, `@noble/hashes` (audited, isomorphic, **synchronous** primitives); dev: `vitest`, `esbuild`, `typescript`. Depends on `relay-contracts` (workspace). |
| `relay-e2e/tsconfig.json` | E0 / v0.8 | Strict TS; `lib: ["ES2022","DOM"]` types-only (no DOM runtime use); dual ESM output. |
| `relay-e2e/src/index.ts` | E3 / v0.10 | Barrel — re-exports the public surface only (handshake factories, `E2ESession`, `sealFrame`/`openFrame`, envelope codec, fingerprint, errors). |
| `relay-e2e/src/errors.ts` | E0 / v0.8 | Typed error classes (§7). No `console`; callers handle. |
| `relay-e2e/src/crypto-provider.ts` | E0 / v0.8 | Isomorphic entropy + constant-time compare: `randomBytes`, `timingSafeEqual`, `getWebCrypto()` (browser `crypto.subtle` / Node `webcrypto.subtle`) for the async handshake KDF/ECDH path. |
| `relay-e2e/src/aead.ts` | E0 / v0.8 | Synchronous AEAD seal/open over `@noble/ciphers` (AES-256-GCM + XChaCha20-Poly1305); `nonceLength()`. **Sync** so it satisfies the frozen §4.4 `sealFrame`/`openFrame` *synchronous* signatures. |
| `relay-e2e/src/envelope.ts` | E0 / v0.8 | Deterministic wire codec for §4.4 `E2EEnvelope` (`encodeEnvelope`/`decodeEnvelope`) — the bytes that ride inside a §4.1 `DATA` payload. |
| `relay-e2e/src/fingerprint.ts` | E0 / v0.8 | `computeEnrollFpr` (matches §4.2 `enroll_fpr` format) + `verifyPinnedFingerprint` (timing-safe); TOFU pin-store interface. |
| `relay-e2e/src/x25519.ts` | E1 / v0.10 | Ephemeral X25519 keygen + ECDH shared secret (WebCrypto X25519 with `@noble/curves` fallback for parity/KATs). |
| `relay-e2e/src/hkdf.ts` | E1 / v0.10 | `deriveSessionKeys` — HKDF-SHA256 master per §4.4 (`salt = clientNonce‖hostNonce`, `info = "relay-e2e/v1"`), then a **direction-split** HKDF-Expand into two disjoint subkeys (`c2h`/`h2c`) so client-write and host-write never share key material (fixes bidirectional-AEAD misuse). |
| `relay-e2e/src/sequence.ts` | E1 / v0.10 | `SequenceGuard` — send-side monotonic `next()`, recv-side strict `accept()` (INV13); one guard **per direction**, paired 1:1 with that direction's subkey. |
| `relay-e2e/src/handshake.ts` | E2 / v0.10 | Client + host handshake state machines (§4.4): produce/consume `ClientHello`/`HostHello`, transcript sign/verify, derive `HandshakeResult`. |
| `relay-e2e/src/session.ts` | E2 / v0.10 | `sealFrame`/`openFrame` (§4.4 verbatim) + `E2ESession` wrapper (wire seal/open, seq guarding, `rederive`). |
| `relay-e2e/src/replay-key.ts` | E3 / v0.10 | Recoverable **content key** for ring-buffer ciphertext replay across reconnect (T9 / Open Question). |
| `relay-e2e/src/keystore.ts` | E3 / v0.10 | Multi-device adapters: TOFU `DevicePinStore` + `DeviceAuthProofProvider` (account-derived, from P5). No key transport in cleartext. |
| `relay-e2e/test/*.test.ts` | per task | Co-located vitest suites (one per src file) + KAT vectors + integration. |
| `relay-e2e/test/vectors/*.json` | E0+ | Known-answer test vectors (AEAD, HKDF, envelope, fingerprint) — frozen so agent & browser agree byte-for-byte. |
**Frozen imports (read-only, never redefined here):** from `relay-contracts` — the **full §4.4 surface the
INDEX froze under FIX 2/3**: `AeadAlg`, `AeadKey`, `SessionRole`, `ClientHello`, `HostHello`, `E2EEnvelope`,
`DirectionalKeys`, `HandshakeResult`, `ClientHandshake`, `E2ESession`, `sealFrame`, `openFrame`,
`createE2ESession`, `buildClientHandshake`, `AuthorizedDeviceContext`, `DeviceAuthProofProvider`, the HKDF
label constants (`HKDF_INFO`, `HKDF_INFO_C2H`, `HKDF_INFO_H2C`), and the replay surface (`ReplayKeyParams`,
`REPLAY_KDF_INFO`, `deriveContentKey`, `sealReplayFrame`, `openReplayCiphertext`); plus `CapabilityToken`
(ref only), `HostRecord.enrollFpr`, and the Zod schemas (`ClientHelloSchema`, `HostHelloSchema`,
`E2EEnvelopeSchema`). **`relay-contracts` owns the *shapes*; `relay-e2e/` owns the crypto *implementations*
and re-exports them (INDEX §2.1).** P4 **calls `.parse()` at every deserialize boundary**
(`coding-style.md` Input Validation) but **authors none of these shapes** (INDEX §4).
---
## 2. Waves & dependency ordering
```
relay-contracts/ (§4 FROZEN) ──read-only──► all P4 files
E0 Foundations (v0.8 — contracts + wire, NO live path) [parallel, file-disjoint]
T1 crypto-provider T2 aead T3 envelope T4 fingerprint T0 errors
│ (E0 primitives frozen + KAT'd before any handshake consumes them)
E1 Key agreement (v0.10) [parallel]
T5 x25519 T6 hkdf T7 sequence
E2 Handshake + session (v0.10)
T8 handshake (depends T5,T6,T4,errors) → T9 session (depends T2,T3,T6,T7,T8)
E3 Multi-device + integration (v0.10)
T10 replay-key T11 keystore → T12 barrel + cross-package integration + INV2 tripwire
```
**Cross-plan dependencies (reference other plans' task IDs where their tasks are defined):**
- **P1 TRANSPORT (§4.1):** E2E envelopes are the `DATA` payload. T12's integration test uses a P1 loopback stub; the byte layout must satisfy P1's `payloadLen ≤ maxFrameBytes`. *Depends on P1's mux-frame task.*
- **P2 AGENT (§4.5):** the **host** handshake (`createHostHandshake`) is wired into `agent/` and signs with the agent's enrolled Ed25519 **private** key (never in `relay-e2e`; injected via `HostSigner`). P2 also **seals every replay-bound host→client output under `K_content` via T10 `sealReplayFrame`** (see T10 frame-key routing) and **unwraps `hostContentSecret` from §4.5 `EnrollResult`** locally. *P2 imports P4's T8/T9/T10; P2's E2E-endpoint task depends on T8.*
- **P3 CONTROL PLANE (§4.2):** browser fetches the raw `agentPubkey` (and `enrollFpr`) from the host registry **over an independent TLS-authenticated channel** — this is the pubkey `onHostHello` verifies against and recomputes the fingerprint from; it is **never** read out of the relay-carried `HostHello` (finding #3). *P4 consumes the §4.2 shape; P3's registry task provides it; the independent-channel property is a hard security precondition of T8.*
- **P5 AUTH (§4.3, INV3):** issues + verifies a **per-handshake, non-replayable** `deviceAuthProof` **bound to `clientEphPub`/`clientNonce`** (finding #2) and delivers the **host-scoped** `hostContentSecret` wrapped to the agent identity (finding #5); gates the stream with a capability token *before* the handshake. P4 defines the field + Zod-validates it; **P5's device-proof task (binding semantics) and host-content-secret delivery/revocation are hard dependencies of T8/T10/T11.**
- **P6 FRONTEND:** imports T8 (`createClientHandshake`), T9 (`E2ESession`), T10 (`openReplayCiphertext`) for connect + decrypt + client-side preview. *P6's connect + preview tasks depend on T12's published barrel.*
---
## 3. E0 — Foundations (v0.8, no live crypto in the byte path)
### T0 · errors + package scaffold · v0.8
**Owns:** `relay-e2e/package.json`, `relay-e2e/tsconfig.json`, `relay-e2e/src/errors.ts`, `relay-e2e/test/errors.test.ts`
**Depends:** `relay-contracts` frozen (INDEX §4, W0).
Contracts:
```ts
export class E2EError extends Error { readonly code: string }
export class FingerprintMismatchError extends E2EError {} // pinned enroll_fpr != presented → MITM abort
export class AeadOpenError extends E2EError {} // AEAD tag verify failed → drop + tear down
export class ReplayError extends E2EError {} // seq non-monotonic / duplicate (INV13)
export class HandshakeStateError extends E2EError {} // illegal transition / wrong phase
export class EnvelopeFormatError extends E2EError {} // malformed wire bytes / bad version
```
TDD: assert each subclasses `E2EError`, carries a stable `code`, and (security) **its `message` never
embeds key/nonce/plaintext material** (INV9 — a test greps thrown messages for injected secret markers).
### T1 · crypto-provider (isomorphic entropy + constant-time) · v0.8
**Owns:** `relay-e2e/src/crypto-provider.ts`, `relay-e2e/test/crypto-provider.test.ts`
**Depends:** T0.
```ts
export function getWebCrypto(): SubtleCrypto // browser crypto.subtle | Node webcrypto.subtle; throws if absent
export function randomBytes(len: number): Uint8Array // CSPRNG (crypto.getRandomValues)
export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean // constant-time, length-safe
```
TDD (tests first):
- returns a subtle instance in both jsdom and node vitest environments (matrixed via `environment` pragma).
- `randomBytes(32)` length 32, two calls differ, never all-zero (statistical smoke).
- **Security:** `timingSafeEqual` returns `false` on length mismatch **without** short-circuit branch on
content length ordering; equal buffers → `true`; single-bit flip → `false`. (No `Buffer` dependency —
hand-rolled XOR-accumulate so it runs in the browser.)
- `getWebCrypto()` throws a typed `E2EError` (not a bare `TypeError`) when no WebCrypto — fail-fast (INV9-style boundary).
### T2 · AEAD primitives (synchronous) · v0.8
**Owns:** `relay-e2e/src/aead.ts`, `relay-e2e/test/aead.test.ts`, `relay-e2e/test/vectors/aead.json`
**Depends:** T0, T1. Imports `AeadAlg` **and `AeadKey`** from `relay-contracts` (frozen §4.4: `AeadAlg = 'aes-256-gcm' | 'xchacha20-poly1305'`; `AeadKey = { readonly __aeadKey: unique symbol }`, FIX 2).
```ts
export function nonceLength(alg: AeadAlg): 12 | 24 // 12 GCM · 24 XChaCha (§4.4)
export function tagLength(alg: AeadAlg): 16
export function importAeadKey(raw: Uint8Array, alg: AeadAlg): AeadKey // opaque wrapper over 32-byte key
export function aeadSeal(key: AeadKey, nonce: Uint8Array, plaintext: Uint8Array, aad: Uint8Array):
{ ciphertext: Uint8Array; tag: Uint8Array } // SYNC (noble) — split ct/tag per §4.4 E2EEnvelope
export function aeadOpen(key: AeadKey, nonce: Uint8Array, ciphertext: Uint8Array, tag: Uint8Array, aad: Uint8Array):
Uint8Array // throws AeadOpenError on tag failure
```
**Design note (load-bearing) — contract now FROZEN (FIX 2), no longer drift:** `AeadKey` is the **frozen
§4.4 opaque wrapper** over a 32-byte key (`type AeadKey = { readonly __aeadKey: unique symbol }`) — **imported
from `relay-contracts`, not declared here**. The INDEX §4.4 amendment (FIX 2) has landed: it swaps
`key: CryptoKey → AeadKey` and async → **synchronous**, so per-frame crypto uses **`@noble/ciphers` (audited,
synchronous, isomorphic — and XChaCha, which WebCrypto lacks)**. `importAeadKey` is P4's local *constructor*
that returns the frozen `AeadKey` type (an implementation detail permitted under INDEX §2.1: contracts own the
shape, `relay-e2e/` owns the impl). T2's `AeadKey` and T9's `sealFrame`/`openFrame` are therefore **unblocked
and cite §4.4 verbatim**. WebCrypto is reserved for the one-time handshake (T5/T6).
TDD:
- **KAT:** each RFC/reference vector in `aead.json` (GCM + XChaCha) seals to expected ct+tag and opens back.
- round-trip: `aeadOpen(aeadSeal(...))` === plaintext for random inputs, both algs, empty & 1 MiB plaintext.
- **Security (INV13 substrate):** flip one ciphertext bit → `AeadOpenError`; flip one AAD byte → `AeadOpenError`;
wrong key → `AeadOpenError`; nonce reuse is **not** silently allowed (documented; enforcement is T7/T9).
- `nonceLength` mismatch (12 given to XChaCha) → typed error, not a silent truncation.
### T3 · E2EEnvelope wire codec · v0.8
**Owns:** `relay-e2e/src/envelope.ts`, `relay-e2e/test/envelope.test.ts`, `relay-e2e/test/vectors/envelope.json`
**Depends:** T0, T2. Imports `E2EEnvelope`, `AeadAlg`, `E2EEnvelopeSchema` from `relay-contracts`.
Wire layout (P4-owned encoding of the frozen §4.4 `E2EEnvelope`; big-endian, rides inside one §4.1 `DATA` payload):
```
offset size field meaning
0 1 envVersion 0x01
1 1 aeadId 0x01 aes-256-gcm · 0x02 xchacha20-poly1305 (maps AeadAlg)
2 8 seq uint64 BE (bigint) — strictly monotonic per direction (INV13)
10 1 nonceLen 12 or 24 (must equal nonceLength(alg))
11 N nonce per-message unique
11+N var ciphertext AEAD ciphertext (length = total 11 N 16)
.. 16 tag AEAD auth tag (fixed 16)
```
```ts
export function encodeEnvelope(env: E2EEnvelope, alg: AeadAlg): Uint8Array
export function decodeEnvelope(buf: Uint8Array): { env: E2EEnvelope; alg: AeadAlg }
```
TDD:
- KAT vectors in `envelope.json` encode to exact bytes; decode reproduces the struct (byte-for-byte agent↔browser parity).
- round-trip `decodeEnvelope(encodeEnvelope(e))` deep-equals; `seq` survives as `bigint` (no float truncation past 2^53 — test `seq = 2n**63n - 1n`).
- **Negative:** wrong `envVersion``EnvelopeFormatError`; truncated buffer (< 27 bytes min) error; `nonceLen` inconsistent with `aeadId` error; declared length overrun (`nonceLen` says 24, buffer too short) error; unknown `aeadId` error. Every deserialize path runs `E2EEnvelopeSchema.parse` (validation at boundary).
- **Security:** codec **never** allocates based on an unchecked length field from the wire beyond `maxFrameBytes` (guards a decompression-bomb-style overrun; asserts a hard cap constant).
### T4 · fingerprint (host-key pin / TOFU) · v0.8
**Owns:** `relay-e2e/src/fingerprint.ts`, `relay-e2e/test/fingerprint.test.ts`, `relay-e2e/test/vectors/fingerprint.json`
**Depends:** T0, T1. Consumes §4.2 `enroll_fpr` format + §4.2 `agent_pubkey` (Ed25519 public key bytes).
```ts
export function computeEnrollFpr(agentPubkey: Uint8Array): string // 'sha256:' + base64url(SHA-256(pubkey)) — matches §4.2 enroll_fpr
export function verifyPinnedFingerprint(presentedPubkey: Uint8Array, pinnedFpr: string): boolean // timing-safe compare of fingerprints
export interface DevicePinStore { // browser persists TOFU pins (impl injected by P6)
get(hostId: string): string | null // pinned enroll_fpr for host, or null (first use → TOFU)
pin(hostId: string, enrollFpr: string): void // set once; changing an existing pin is the caller's decision
}
// resolvePin takes the RAW agentPubkey bytes (fetched over the independent P3/TLS channel — see T8) and
// recomputes the fingerprint locally; it MUST NOT accept a pre-computed fpr string, so the pin decision can
// never be short-circuited into a relay-influenced 'presented string == pinned string' comparison.
export function resolvePin(store: DevicePinStore, hostId: string, agentPubkey: Uint8Array):
{ outcome: 'tofu-first-use' | 'match' | 'mismatch'; computedFpr: string; pinnedFpr: string | null }
```
TDD:
- `computeEnrollFpr` matches KAT vectors and equals the value P3 stores as §4.2 `enroll_fpr` (shared vector file so P3's test and this test assert the *same* string).
- `verifyPinnedFingerprint` is timing-safe (delegates to T1); true on match, false on any-byte diff and on length diff.
- `resolvePin` **hashes the raw pubkey itself** (never trusts a caller-supplied fpr): empty store `tofu-first-use`, `computedFpr` returned to persist; matching pin `match`; **differing pin → `mismatch`** (malicious-relay / host-rotation MITM signal the browser MUST abort/prompt, never auto-repin).
- **Security:** `resolvePin` is given a pubkey whose locally-recomputed `computedFpr` differs from a (hypothetically relay-supplied) fpr string the function keys its decision only off `computedFpr`, proving there is no string-only comparison path.
- **Security:** a `mismatch` outcome never silently overwrites the stored pin (no TOFU downgrade). Fingerprint strings are compared as bytes, not with `===` on differing-length strings first (constant-time).
---
## 4. E1 — Key agreement (v0.10)
### T5 · x25519 ECDH · v0.10
**Owns:** `relay-e2e/src/x25519.ts`, `relay-e2e/test/x25519.test.ts`
**Depends:** T1.
```ts
export interface EphemeralKeyPair { readonly publicKey: Uint8Array; readonly privateKey: CryptoKey } // priv = NON-EXTRACTABLE (INV5)
export async function generateEphemeralKeyPair(): Promise<EphemeralKeyPair> // X25519, per handshake, fresh
export async function deriveSharedSecret(privateKey: CryptoKey, peerPublicKey: Uint8Array): Promise<Uint8Array> // 32-byte raw
```
TDD:
- classic ECDH agreement: two pairs derive the **same** shared secret from crossed pubkeys.
- **Security:** the private key material is a **non-extractable `CryptoKey`** assert `extractable === false`; a test proving `exportKey('raw', priv)` rejects (INV5: key never leaves as bytes).
- **Negative:** all-zero / low-order peer public key rejected (contributory behavior via WebCrypto; add explicit small-subgroup KAT from `@noble/curves` parity vectors).
- WebCrypto-vs-noble parity: same private scalar + peer point identical secret across both backends (guards a browser/node divergence).
### T6 · HKDF session-key derivation — DIRECTION-SPLIT subkeys · v0.10
**Owns:** `relay-e2e/src/hkdf.ts`, `relay-e2e/test/hkdf.test.ts`, `relay-e2e/test/vectors/hkdf.json`
**Depends:** T1, T5. Imports `AeadAlg` from `relay-contracts`.
**CRITICAL — one shared key across both directions is forbidden.** A single ECDH-derived key used for AEAD
sealing in **both** clienthost and hostclient is the classic bidirectional-AEAD misuse: with a
deterministic seq-derived nonce (T9), client-seq=N and host-seq=N produce an **identical (key, nonce)** pair
GCM nonce reuse two-time-pad key/plaintext recovery; and even with random nonces it lets a relay
**reflect** a captured clienthost frame back to the client as hostclient at the same seq and have it
decrypt. **Fix: derive two independent, direction-scoped subkeys** so client-write and host-read share one
key and host-write and client-read share the *other*, never the same. A reflected frame is opened under the
wrong subkey `AeadOpenError`.
```ts
// FROZEN in §4.4 (FIX 2) — imported from relay-contracts, NOT declared here:
// export interface DirectionalKeys { readonly c2h: AeadKey; readonly h2c: AeadKey }
// export const HKDF_INFO = 'relay-e2e/v1'; HKDF_INFO_C2H = 'relay-e2e/v1/c2h'; HKDF_INFO_H2C = 'relay-e2e/v1/h2c'
import type { DirectionalKeys } from 'relay-contracts'
import { HKDF_INFO, HKDF_INFO_C2H, HKDF_INFO_H2C } from 'relay-contracts'
// c2h = client→host (client seals / host opens); h2c = host→client (host seals / client opens).
// Master derivation is §4.4's frozen HKDF (salt = clientNonce‖hostNonce, info = HKDF_INFO), then a SECOND
// HKDF-Expand splits the master into two disjoint direction subkeys under the frozen c2h/h2c labels. The INDEX
// blessed these labels + the DirectionalKeys type (FIX 2), so this only IMPLEMENTS the frozen surface.
export function deriveSessionKeys(sharedSecret: Uint8Array, clientNonce: Uint8Array, hostNonce: Uint8Array, alg: AeadAlg):
Promise<DirectionalKeys>
```
TDD:
- KAT vectors (`hkdf.json`) reproduce the expected 32-byte **master** and both 32-byte **direction subkeys** for fixed secret+nonces+info+labels.
- deterministic: same inputs same `DirectionalKeys`; changing **either** nonce different keys (salt = both nonces, order fixed clienthost).
- `info` is exactly `"relay-e2e/v1"`, labels exactly `"relay-e2e/v1/c2h"` / `"relay-e2e/v1/h2c"` (guards a silent version/label drift that would desync agent/browser).
- **Security (direction disjointness):** `c2h !== h2c` bit-for-bit; each is 32 bytes; neither equals the master. A KAT asserts the exact label bytes so an implementer cannot collapse both directions to one key.
- **Security:** distinct `alg` still derives 32-byte subkeys; each subkey feeds T2 as an opaque `AeadKey` (never surfaced as a plain `Uint8Array` beyond the wrap boundary supports INV5).
### T7 · sequence guard (anti-replay/injection) · v0.10
**Owns:** `relay-e2e/src/sequence.ts`, `relay-e2e/test/sequence.test.ts`
**Depends:** T0.
```ts
export class SequenceGuard {
constructor(role: 'send' | 'recv')
next(): bigint // send: returns current seq (starting 0n), then increments — strictly monotonic per direction
accept(seq: bigint): void // recv: require seq === expected (last+1, from 0n); else throw ReplayError (out-of-order/dup/gap rejected, INV13)
readonly lastAccepted: bigint | null
}
```
**Design note:** the transport 4.1 mux over an ordered WS/TCP stream) delivers per-stream in order, so
INV13 is enforced as **strict successor** (`expected === last + 1`) a duplicate, a reorder, a gap, or a
rewind all raise `ReplayError`. There is deliberately **no sliding acceptance window** (a window would admit
reordered frames the spec says to reject).
TDD:
- send side: `next()` yields `0n,1n,2n…` strictly increasing; two guards never share state.
- recv side: in-order `0,1,2` accepted; **replay** of an accepted seq `ReplayError`; **reorder** (`0` then `2`) `ReplayError`; **rewind** (`5` then `4`) `ReplayError`; **gap** (`0` then `2`) `ReplayError`.
- bigint boundary: accepts up to `2n**64n 1n` without float error; wrap/overflow past u64 typed error (forces re-key rather than nonce/seq reuse).
---
## 5. E2 — Handshake + session (v0.10)
### T8 · handshake state machines (client + host) · v0.10
**Owns:** `relay-e2e/src/handshake.ts`, `relay-e2e/test/handshake.test.ts`
**Depends:** T4, T5, T6, T0. Imports `ClientHello`, `HostHello`, `ClientHelloSchema`, `HostHelloSchema`,
`HandshakeResult`, `ClientHandshake`, `DirectionalKeys`, `DeviceAuthProofProvider` from `relay-contracts` 4.4).
**Cross-plan:** consumes P2's `HostSigner` (Ed25519 over transcript) and P5's `DeviceAuthProofProvider`
4.4 / FIX 6b injected, P4 never imports crypto identity from `relay-auth`).
Implements the §4.4 message flow verbatim (`client_hello` `host_hello`), producing the **frozen §4.4**
`HandshakeResult`:
```ts
// FROZEN §4.4 (FIX 2), imported from relay-contracts — NOT redefined here:
// export interface HandshakeResult { readonly keys: DirectionalKeys; readonly aead: AeadAlg; readonly transcript: Uint8Array }
// export interface ClientHandshake { start(): Promise<ClientHello>; onHostHello(msg, agentPubkey): Promise<HandshakeResult> }
// `keys` = c2h/h2c disjoint subkeys from T6 deriveSessionKeys (NOT one shared key); `transcript` binds the sig.
export type HandshakePhase = 'init' | 'awaitHostHello' | 'awaitClientHello' | 'established' | 'aborted' // P4-internal state, not §4 wire
export interface HostSigner { sign(transcript: Uint8Array): Promise<Uint8Array> } // provided by P2 (agent Ed25519 priv key)
export interface HostVerifier { verify(pubkey: Uint8Array, transcript: Uint8Array, sig: Uint8Array): Promise<boolean> } // Ed25519
// CLIENT (browser, P6) — implements the FROZEN §4.4 `ClientHandshake` (start/onHostHello); `phase` is a
// P4-internal state field layered on the frozen shape (not part of the §4 contract).
export interface ClientHandshake {
readonly phase: HandshakePhase
// start() binds the device proof to THIS handshake: deviceAuthProof is produced over clientEphPub‖clientNonce
// (or embeds a fresh P5 challenge carried in those fields) so a captured proof cannot be replayed into
// another handshake with a different ephemeral key (fixes proof-replay by a malicious relay).
start(): Promise<ClientHello>
// agentPubkey is the RAW §4.2 Ed25519 host key, fetched by P6 from P3's host registry over an INDEPENDENT,
// TLS-authenticated channel — it is NOT read out of the relay-carried HostHello (which the relay can forge).
onHostHello(msg: HostHello, agentPubkey: Uint8Array): Promise<HandshakeResult>
}
export function createClientHandshake(deps: {
aeadOffer: readonly AeadAlg[]
deviceAuthProofProvider: DeviceAuthProofProvider // P5 — mints a per-handshake proof bound to the challenge (INV3)
verifier: HostVerifier
pinStore: DevicePinStore // T4 — TOFU/pin resolution over recomputed fingerprints
hostId: string // names the registry entry whose agentPubkey/pin is resolved
}): ClientHandshake
// HOST (agent, P2)
export interface HostHandshake {
readonly phase: HandshakePhase
readonly result: HandshakeResult | null
onClientHello(msg: ClientHello): Promise<HostHello> // verify proof binding, choose aead, sign transcript, derive keys
}
export function createHostHandshake(deps: {
signer: HostSigner // agent Ed25519 priv key (P2) — NEVER in relay-e2e
agentPubkey: Uint8Array // §4.2 agent_pubkey (for enrollFpr in host_hello)
supported: readonly AeadAlg[]
// verifyDeviceProof receives the proof AND the handshake binding material so it can assert the proof was
// minted for THIS clientEphPub/clientNonce — a static bearer proof (unbound) MUST be rejected (INV1/INV3).
verifyDeviceProof: (proof: string, binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array }) => Promise<boolean>
}): HostHandshake
```
**Host-key sourcing (anti-MITM, MUST be explicit — the pin string alone is NOT the defense).** `enrollFpr`
is a hash of a **public** key that a malicious relay also knows, so string-equality on it proves nothing.
`onHostHello` MUST: (a) receive the raw `agentPubkey` from the independent P3/TLS channel (never from the
relay-carried `HostHello`); (b) recompute `computeEnrollFpr(agentPubkey)` locally and assert it equals
**both** `msg.enrollFpr` **and** the `resolvePin` outcome for `hostId` any disagreement
`FingerprintMismatchError`, never a string-only fallback; (c) only then call
`verifier.verify(agentPubkey, transcript, msg.sig)`. The pubkey passed to `verify()` is always the
registry pubkey, never a value the relay can influence.
**Device-proof binding (anti-replay, MUST be explicit).** `ClientHello` carries **no signature** and is
forwarded by the relay in cleartext-to-the-relay (pre-key) as an opaque `DATA` payload, so a raw bearer
`deviceAuthProof` could be captured and replayed by the relay into its own freshly-keyed `client_hello`,
impersonating the device with no crypto broken. Therefore `deviceAuthProof` MUST be a **per-handshake,
non-replayable** value bound to `clientEphPub`/`clientNonce` (or a fresh P5-issued challenge carried in those
fields) never a static token. This is a **hard cross-plan requirement on P5** 9 Q3), and T8 owns the
gating call site, so the binding is asserted here even though issuance lives in P5.
TDD (tests first, with a stub `HostSigner`/`HostVerifier` using `@noble/curves` ed25519):
- **Happy path:** client.start host.onClientHello client.onHostHello both sides derive the **same** `DirectionalKeys` (`c2h`/`h2c`) and identical negotiated `aead`; relay (a passthrough spy in the test) **never** sees key material assert the spy's captured bytes decode only to `ClientHello`/`HostHello` structs, no secret.
- **AEAD negotiation:** host picks the strongest common alg from `aeadOffer`; empty intersection `HandshakeStateError` (no silent fallback to an unoffered alg).
- **MITM abort (headline):** a malicious relay swaps `hostEphPub`/`sig` `sig` fails against the **registry** `agentPubkey` `onHostHello` throws `FingerprintMismatchError`, `phase → 'aborted'`, **no key derived**.
- **Pubkey/fpr inconsistency (finding #3 regression guard):** `msg.enrollFpr` matches the pinned string, but the raw `agentPubkey` supplied for verification hashes to a *different* fpr abort with `FingerprintMismatchError`, **never** a string-only match; proves `verify()` is called with the registry pubkey, not a relay-supplied one.
- **TOFU first use:** empty pin store pin recorded (from the recomputed fpr) after a valid `host_hello`; a subsequent handshake whose registry pubkey hashes to a *different* fpr abort (no auto-repin).
- **deviceAuthProof binding (finding #2 regression guard):** a valid `deviceAuthProof` captured from handshake **A** (its own `clientEphPub`/`clientNonce`), replayed verbatim in handshake **B** with a *different* `clientEphPub`, is **rejected** by `verifyDeviceProof`/host `HandshakeStateError`, no session. A static/unbound bearer proof is likewise rejected.
- **deviceAuthProof gate (INV3):** host rejects a `client_hello` whose `deviceAuthProof` fails `verifyDeviceProof` `HandshakeStateError`; forged/absent proof never yields a session (deny-by-default).
- **State machine:** any out-of-order call (`onHostHello` before `start`, double `start`, `onClientHello` twice) `HandshakeStateError`; `phase` transitions are monotone and terminal at `established`/`aborted`.
- **Malformed input:** `onHostHello`/`onClientHello` run `HostHelloSchema`/`ClientHelloSchema.parse` first; bad shape typed error, no crypto attempted (fail-fast at boundary).
### T9 · session AEAD + sealFrame/openFrame + lifecycle · v0.10
**Owns:** `relay-e2e/src/session.ts`, `relay-e2e/test/session.test.ts`
**Depends:** T2, T3, T6, T7, T8. Imports `E2EEnvelope`, `AeadKey`, `SessionRole`, `DirectionalKeys`,
`HandshakeResult`, `E2ESession`, `createE2ESession`, `sealFrame`, `openFrame` from `relay-contracts` 4.4).
**Contract sync RESOLVED (FIX 2):** the INDEX §4.4 amendment has landed the frozen signatures are now
`sealFrame(key: AeadKey, seq, plaintext): E2EEnvelope` / `openFrame(key: AeadKey, env, expectedSeq): Uint8Array`,
**synchronous**, with `aad = directionLabel‖seq`. T9 **implements the frozen §4.4 surface verbatim** (via T2
noble AEAD) and **redefines none of it locally**; the earlier "pending amendment / §9 Q4" block is closed.
```ts
// §4.4 FROZEN (FIX 2) — imported from relay-contracts; T9 provides the SYNCHRONOUS implementation via T2 noble AEAD.
// Per-direction: the caller passes the correct DIRECTION subkey (c2h or h2c) — sealFrame/openFrame are
// single-key primitives; direction selection is the E2ESession's job (below).
export function sealFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope
// nonce = deterministic f(seq) (see Nonce discipline); aad = directionLabel‖seq (u64 BE)
export function openFrame(key: AeadKey, env: E2EEnvelope, expectedSeq: bigint): Uint8Array
// require env.seq === expectedSeq (INV13); recompute nonce = f(seq); AEAD verify with aad; failure → AeadOpenError
// Stateful directional wrapper (§4.4 FROZEN — SessionRole/E2ESession/createE2ESession imported, not redefined).
// It holds BOTH direction subkeys and a role, and MUST seal with its write subkey and open with its read subkey.
// export type SessionRole = 'client' | 'host'
// export interface E2ESession { role; seal(pt): Uint8Array; open(dataPayload): Uint8Array; rederive(next: HandshakeResult): void }
// export function createE2ESession(role: SessionRole, result: HandshakeResult): E2ESession
// seal: write subkey (client→c2h / host→h2c) → next send seq (T7) → sealFrame → encodeEnvelope
// open: read subkey (client→h2c / host→c2h) → decodeEnvelope → SequenceGuard.accept → openFrame
// rederive: re-key on refresh/reconnect; installs new DirectionalKeys; resets both seq guards
```
**Nonce discipline (INV13) — ONE pinned scheme, no random branch.** The nonce is **fully deterministic**:
`nonce = seq encoded big-endian, left-zero-padded to nonceLength(alg)` (equivalently `HKDF-Expand(nonceKey, seq)`).
The random-nonce option is **removed** for a long-lived high-frame-count terminal stream, 96-bit random
GCM nonces carry a non-trivial birthday-collision probability (NIST SP 800-38D bounds random-nonce
invocations per key well below 2^32), and a GCM nonce collision is catastrophic. Determinism is safe **only
because finding #1's direction split guarantees `seq` is unique per (direction subkey)**: client-write and
host-write use disjoint keys, so seq=N in each direction never collides on (key, nonce). `aad = directionLabel || seq`
binds both the direction and the sequence into the tag, so a frame can't be replayed under a different seq
**or reflected across directions**. A KAT asserts the nonce is bit-for-bit deterministic and never regenerated randomly.
TDD:
- round-trip: `open(seal(x)) === x` for both algs, empty/large payloads. (`seal` twice on the same plaintext at the same seq is impossible in-session because seq is monotonic; a KAT fixes seq and asserts the deterministic nonce.)
- **Direction split (finding #1 regression guards):**
- a frame sealed by the **client** `E2ESession` **cannot be opened by that same client's `open`** (read subkey write subkey) `AeadOpenError`.
- a relay **reflecting** a clienthost ciphertext back to the client as a hostclient frame at the same seq the client's `open` uses `h2c`, the frame was sealed under `c2h` `AeadOpenError` (reflection rejected).
- **Deterministic nonce KAT:** fixed key+seq exact nonce bytes; assert `sealFrame` never calls `randomBytes` (spy/stub asserts the CSPRNG is untouched on the seal path).
- **INV13 injection/replay:** capture a `seal` output, feed it to `open` twice 2nd is `ReplayError`; reorder two frames `ReplayError`; flip a ciphertext bit `AeadOpenError`; re-encode with a bumped `seq` `AeadOpenError` (aad mismatch).
- **Lifecycle:** `rederive` with a fresh `HandshakeResult` resets both seq guards to 0 and decrypts frames sealed under the new `DirectionalKeys`; frames sealed under the **old** keys fail to open post-rederive (forward isolation).
- **Cross-instance:** a browser (`client`) `E2ESession` opens what an agent (`host`) `E2ESession` sealed and vice-versa (isomorphic parity, correct direction subkeys), driven off shared vectors.
---
## 6. E3 — Multi-device, replay-key, integration (v0.10)
### T10 · recoverable replay content-key (ring-buffer ciphertext across reconnect) · v0.10
**Owns:** `relay-e2e/src/replay-key.ts`, `relay-e2e/test/replay-key.test.ts`
**Depends:** T2, T3, T6. **The two-key replay model is now a FROZEN §4.4/§4.5 surface (FIX 3) — T10 implements it; the former §9 open question is resolved (see §9).**
**Problem:** §4.4 derives `sessionKey` from **ephemeral** X25519 (forward secrecy) a *fresh* key on every
reconnect. But EXPLORE §4c requires **ring-buffer replay of ciphertext to survive a refresh** the
customer's unchanged base-app ring buffer stores whatever bytes the agent forwards, and a reconnecting
browser must decrypt them. Ciphertext sealed under an ephemeral key that no longer exists is undecryptable.
**Resolution (two-key model, now a FROZEN §4.4 surface — FIX 3 — that P4 implements, not invents):**
- **`K_content`** a **recoverable** per-session content key = `deriveContentKey(ReplayKeyParams)` =
`HKDF(hostContentSecret, salt = sessionId, info = REPLAY_KDF_INFO)` where `REPLAY_KDF_INFO = 'relay-e2e/replay/v1'`
(both frozen §4.4). **CRITICAL — the input is a HOST-SCOPED secret, not a raw account secret.** The host agent is
unattended and never performs a WebAuthn ceremony, so if it held a raw `accountRecoverableSecret` that would be a
long-lived, non-rotatable, **account-wide** secret in agent memory/disk a strictly larger blast radius than the
per-host Ed25519 identity (INV4) and a regression from the non-extractable-key pattern (T5). Instead, **§4.5 (FIX 3)
now assigns a single owner: P3 mints + wraps `hostContentSecret` at BIND, sealed to the host's enrolled Ed25519
`agent_pubkey`** (host-scoped, per-host; delivered in §4.5 `EnrollResult.hostContentSecret`). **P2 unwraps it
locally** with its enrollment private key (never leaves the host, INV4). The control plane can invalidate **that
specific wrap** on host/device revocation (INV12) without a full account-secret rotation. **Authorized browser
devices obtain the same `hostContentSecret` via P5** (unwrapped only after auth/step-up); each re-derives the
identical `K_content` decrypts replayed ciphertext after a refresh. Relay still sees only ciphertext (INV2).
- **`sessionKey` direction subkeys** 4.4 ephemeral `DirectionalKeys`) protect **live** frames (forward secrecy)
and are the authenticated channel over which nothing secret needs to travel (see T11).
```ts
// FROZEN §4.4 (FIX 3) — imported from relay-contracts; T10 provides the implementation. ReplayKeyParams
// bundles the KDF inputs so the shape is fixed once for P2 (seal) and P6 (open):
// export interface ReplayKeyParams { readonly hostContentSecret: Uint8Array; readonly sessionId: string; readonly alg: AeadAlg }
// export const REPLAY_KDF_INFO = 'relay-e2e/replay/v1'
// hostContentSecret is host-scoped (delivered at §4.5 enrollment wrapped to agent_pubkey by P3; NOT a raw
// account secret). Revoking the host/device invalidates the wrap so it can no longer be unwrapped going forward (INV12).
export function deriveContentKey(p: ReplayKeyParams): AeadKey // HKDF(hostContentSecret, salt=sessionId, info=REPLAY_KDF_INFO)
export function sealReplayFrame(k: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope // reuses T9 sealFrame under K_content — CALLED BY P2 (agent)
export function openReplayCiphertext(k: AeadKey, dataPayload: Uint8Array): Uint8Array // client-side replay/preview decrypt — CALLED BY P6 (browser)
```
**Cross-plan requirement — WHICH key seals WHICH frame (frozen routing; P2/P6 MUST honor it):**
- **Live hostclient output** (interactive stream, forward-secret) sealed under the **ephemeral `h2c`** subkey
via `E2ESession.seal` (T9). Live clienthost input **`c2h`**. These die on reconnect (re-derived, INV forward secrecy).
- **Every replay-bound hostclient output** i.e. any byte the agent forwards that the base-app ring buffer will
store for later scrollback replay / preview is **additionally sealed under `K_content`** via **`sealReplayFrame`
(owned/called by P2's agent-side E2E-endpoint task)**, so it survives a browser reload after the ephemeral subkeys
are gone. The reconnecting or second authorized device re-derives `K_content` (`deriveContentKey`) and decrypts the
stored ciphertext with **`openReplayCiphertext` (owned/called by P6's client-side preview/replay task)**.
- **Clienthost input is NEVER sealed under `K_content`** (it is not replay-bound; only hostclient output is
buffered for scrollback). This keeps `K_content` scoped to exactly the frames that must survive a reload.
TDD:
- **The load-bearing test:** device X seals output under `K_content`; the base-app ring buffer (a stub `Uint8Array[]`) stores the ciphertext; device X "reloads" (new object, no in-memory key), re-derives `K_content` from the same `hostContentSecret` + `sessionId`, and **decrypts the replayed ciphertext** "refresh and the session is still there" works under E2E.
- device Y (same account, authorized, holding the same `hostContentSecret`) re-derives the identical `K_content` decrypts (basis for multi-device mirror).
- **Revocation (INV12) merge-blocking for T10/T11:** after a host/device is revoked, the wrap no longer unwraps a revoked principal cannot obtain `hostContentSecret` cannot derive `K_content` for that host's sessions going forward. Test: given a "revoked" wrap stub, `deriveContentKey`'s input is unavailable no key derivable (P4 asserts the derivation is gated on the unwrap; the unwrap/revocation mechanism itself is P5/P2, a hard cross-plan dependency).
- **Security:** a device with a *different* / host-mismatched `hostContentSecret` derives a different key cannot open (cross-host + cross-account replay isolation, reinforces INV1); relay memory holding the ciphertext never yields plaintext (INV2); `K_content` is per-`sessionId` (no cross-session key reuse) and per-host (no cross-host reuse).
### T11 · multi-device key adapters (authenticated channel, NOT a QR) · v0.10
**Owns:** `relay-e2e/src/keystore.ts`, `relay-e2e/test/keystore.test.ts`
**Depends:** T4, T8, T10. **Cross-plan:** `DeviceAuthProofProvider` is implemented by P5; `DevicePinStore` by P6.
**Model (EXPLORE §4c / INV3):** multi-device does **not** ship a key over a scannable QR. **Each device runs
its own §4.4 handshake**, gated by an **account-derived `deviceAuthProof`**, and independently re-derives the
recoverable `K_content` (T10). No plaintext key material ever transits a shoulder-surfable or relay-readable
channel. This file provides the injectable adapters and the assembly:
```ts
// FROZEN §4.4 (FIX 2/6b) — imported from relay-contracts; T11 supplies the injectable adapters + assembly, NOT the shapes:
// export interface DeviceAuthProofProvider {
// proofFor(hostId: string, binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array }): Promise<string>
// }
// export interface AuthorizedDeviceContext {
// readonly deviceAuthProofProvider: DeviceAuthProofProvider // P5 — mints per-handshake proof bound to clientEphPub‖clientNonce
// readonly pinStore: DevicePinStore // P6 — TOFU/pin over recomputed fingerprints
// readonly hostContentSecret: Uint8Array // §4.5 host-scoped secret; NEVER logged, NEVER sent to relay
// }
// export function buildClientHandshake(ctx: AuthorizedDeviceContext, hostId: string, aeadOffer: readonly AeadAlg[]): ClientHandshake
import type { AuthorizedDeviceContext, DeviceAuthProofProvider } from 'relay-contracts'
import { buildClientHandshake } from 'relay-contracts' // re-exported from relay-e2e/ with the impl (INDEX §2.1)
// proofFor mints a PER-HANDSHAKE, non-replayable proof bound to the fresh clientEphPub/clientNonce (FIX 6b): the
// binding param `{ clientEphPub, clientNonce }` is unified across P2/P4/P5; the same hostId twice yields distinct,
// binding-scoped proofs — NOT a reusable bearer token.
```
TDD:
- two independent `AuthorizedDeviceContext`s (same account, same `hostContentSecret`) each complete a handshake with the host stub and each derive matching `K_content` both mirror the same session.
- **Security (INV3, per-handshake proof):** a context lacking a valid `deviceAuthProof` never yields a session (host rejects T8 gate); a proof minted for handshake A cannot be reused for handshake B (binding-scoped T8 replay guard). There is **no code path** that emits `hostContentSecret` or a derived key into any frame the relay forwards (static assertion + a spy test on all outbound bytes).
- **Revocation (INV12):** a context whose `hostContentSecret` unwrap has been revoked cannot derive `K_content` (mirrors T10's revocation gate).
- **No-QR assertion:** a documented negative test that the public surface exposes **no** function serializing a session/content key to a transferable string/QR blob (guards regression toward a plaintext-QR shortcut).
### T12 · barrel, build, cross-package integration + INV2 tripwire · v0.10
**Owns:** `relay-e2e/src/index.ts`, `relay-e2e/test/integration.test.ts`, `relay-e2e/test/vectors/*` (freeze), build config in `package.json`
**Depends:** T8, T9, T10, T11. **Cross-plan:** uses a **P1 loopback mux stub** as the relay.
Publishes the public surface (barrel) and proves the whole path end-to-end through a *simulated relay*:
```
client E2ESession ──DATA payload (ciphertext)──► [P1 mux stub = relay spy] ──► host E2ESession
```
TDD / integration:
- **Full loop:** client handshake host handshake through the relay spy `E2ESession` on each side seal "echo test", forward as a §4.1 `DATA` payload (assert `payloadLen ≤ maxFrameBytes`), host opens plaintext round-trips; reverse direction too.
- **INV2 tripwire (permanent):** inject a unique plaintext marker (`E2E_PLAINTEXT_CANARY_<uuid>`) as the sealed input; assert the marker appears **nowhere** in the relay spy's captured buffers, in any thrown error message, or in a serialized snapshot of the spy only ciphertext transits. This is P4's owned invariant test (INV2) and a merge-blocking tripwire.
- **INV11-adjacent static check:** a test asserting `relay-e2e/src/**` imports **no** xterm/ANSI/terminal parser and **no** `ws`/`pg`/DOM runtime (dependency-cruiser or a grep-based test) the package stays a pure crypto core.
- **Isomorphic build:** `esbuild` produces a browser ESM bundle and a Node ESM bundle; a smoke test loads each and runs one seal/open (guards a WebCrypto/`@noble` divergence between environments).
- **Vector freeze:** all `test/vectors/*.json` are asserted stable (a checksum test) so a future change that would desync the agent and browser encodings fails loudly.
---
## 7. Security section (per-plan)
P4 is the plan that *earns* the ciphertext-shuttle claim. Enforcement summary and the threats each control stops:
- **INV2 relay sees only ciphertext.** The relay forwards §4.1 `DATA` payloads that are §4.4 `E2EEnvelope`
bytes; the key is derived **only** on the two endpoints (T8 handshake). *Stops:* a compromised/curious relay
reading keystrokes (`sudo`/`.env`/**Claude tokens**) EXPLORE §4c. Proven by the T12 canary tripwire.
- **Direction-scoped AEAD keys (bidirectional-misuse defense).** The single ECDH secret is split into two
disjoint subkeys `c2h`/`h2c` (T6); each direction seals with its write subkey and opens with the other's
(T9). *Stops:* (a) GCM nonce reuse across directions under a deterministic seq-nonce (client-seq=N and
host-seq=N never share a key); (b) a relay **reflecting** a clienthost ciphertext back to the client it
opens under `h2c`, was sealed under `c2h` `AeadOpenError`. `aad = directionLabel || seq` binds both.
- **INV13 anti-replay/injection.** Deterministic per-`seq` nonce (T9, no random branch) + strictly
monotonic `seq` (T7) + `aad = directionLabel || seq`. *Stops:* a relay replaying a captured
`approve`/keystroke frame, reordering frames, or splicing frames from another stream/direction; any tamper
flips the AEAD tag (`AeadOpenError`) and tears the session down.
- **MITM by malicious relay.** `HostHello.sig` = Ed25519 over the transcript, verified against the raw
`agentPubkey` **fetched from P3's host registry over an independent TLS channel** (never read from the
relay-carried `HostHello`); `onHostHello` recomputes `computeEnrollFpr(agentPubkey)` and asserts it equals
**both** `msg.enrollFpr` and the browser-**pinned** value before verifying (T4/T8). *Stops:* the relay
substituting its own ephemeral key **or** feeding a forged pubkey to `verify()` a string-only fpr match is
never the defense; mismatch `FingerprintMismatchError`, no key derived 4d).
- **INV3 device bound to account, proof non-replayable.** The handshake carries a **per-handshake**
`deviceAuthProof` bound to `clientEphPub`/`clientNonce` (T8/T11); the host denies by default without it.
*Stops:* an authenticated-but-unauthorized device, a relay-forged identity, **and a relay replaying a
captured proof into its own freshly-keyed `client_hello`** (bound proof fails against the new ephemeral key).
The content key (T10) is **host-secret-bound**, so a foreign account/host can't decrypt replay ciphertext
(reinforces **INV1** cross-tenant buffer bleed becomes *cryptographically* impossible, EXPLORE §4b).
- **INV5 / INV9 no secret at rest / in logs.** Ephemeral X25519 privkeys are **non-extractable `CryptoKey`s**
(T5); session/content keys live only as opaque `AeadKey` wrappers; error messages are asserted to carry no
key/nonce/plaintext (T0); the ring buffer stores only ciphertext (T10). The **host-scoped** `hostContentSecret`
(not a raw account secret) is delivered wrapped to the agent's Ed25519 identity, is revocable per-host
(INV12), and is never sent to the relay and never logged (T10/T11).
- **Forward secrecy + recoverable replay, reconciled.** Live frames use the ephemeral `sessionKey` (forward
secret); replay-bound frames use the recoverable `K_content` (T10). This is the deliberate trade the product
makes to keep "refresh and the session survives" under E2E surfaced as an **Open Question** 8) because it
is the one point where §4.4 is silent.
- **Downgrade resistance.** AEAD negotiation picks the strongest common alg from the client offer; no fallback
to an unoffered/weaker alg (T8). Envelope `envVersion`/`HKDF_INFO` are pinned so a version-strip can't silently
weaken derivation.
- **What E2E kills (documented, handed to P6):** server-side preview thumbnails, manage-grid, server search,
and server recording **cannot exist** the relay can't render/scan ciphertext. P4 ships `openReplayCiphertext`
(T10) so P6 renders previews **client-side** in the key-holding browser. Ring-buffer replay + multi-device
mirror survive (relay stores/forwards ciphertext). This is the accepted §0 consequence, not a regression.
---
## 8. Verification
Run from repo root (workspace package `relay-e2e`):
```bash
npm -w relay-e2e run build # esbuild → browser ESM + node ESM bundles; tsc --noEmit strict
npm -w relay-e2e test # vitest: all T0T12 suites, jsdom + node environments
npm -w relay-e2e run test:coverage # enforce ≥80% (testing.md); crypto core targets ≥95% on aead/envelope/sequence/session
npm -w relay-e2e run test:vectors # KAT freeze check (aead/hkdf/envelope/fingerprint) — agent↔browser parity
npm -w relay-e2e run lint:isolation # dependency-cruiser: no xterm/ANSI/ws/pg/DOM-runtime imports (INV2/INV11)
```
Acceptance gates (all must pass before P4 merges / before v0.10 ships):
1. **INV2 tripwire (T12):** plaintext canary never appears in the relay spy's buffers/logs/snapshot. **Merge-blocking.**
2. **INV13 (T7/T9):** replay, reorder, tamper, and seq-slot attacks all rejected with typed errors; nonce is deterministic per `seq` (no random-nonce path).
3. **Direction split (T6/T9):** `c2h ≠ h2c`; a client-sealed frame cannot be opened by the client's own `open`; a reflected clienthost frame replayed as hostclient is rejected (`AeadOpenError`). **Merge-blocking.**
4. **MITM abort (T8):** raw `agentPubkey` is sourced from the independent P3/TLS channel; a pubkey/fpr inconsistency (pinned string matches but pubkey hashes differently) `FingerprintMismatchError`, no key derived, no string-only fallback; TOFU never auto-repins.
5. **INV3 proof binding (T8/T11):** absent/forged `deviceAuthProof` no session; a proof captured from handshake A replayed in handshake B (different `clientEphPub`) rejected; no code path serializes a key to a QR/string.
6. **INV5 (T5/T0):** ephemeral privkeys non-extractable (`exportKey` rejects); no secret in any thrown message.
7. **Replay survives refresh (T10):** ciphertext stored under `K_content` decrypts after a simulated reload and on a second authorized device; a foreign account/host cannot decrypt.
8. **Content-key revocation (T10/T11, INV12):** a revoked host/device cannot obtain `hostContentSecret` cannot derive `K_content` for that host's sessions going forward. **Merge-blocking before T10/T11.**
9. **Isomorphic parity (T9/T12):** the same vectors seal/open identically under WebCrypto (browser) and Node; browser bundle and node bundle both pass the smoke test.
10. **Coverage ≥80%** overall; crypto-critical files 95%; **zero `console.*`** in `src/` (`coding-style.md`).
---
## 9. Open questions (surface to orchestrator — do NOT guess, per CLAUDE.md / PLAN §0)
**All four blocking questions are now RESOLVED by the INDEX reconciliation pass (FIX 2/3/6b) the contracts
were frozen at the coordination point, so P4 cites them verbatim and none is a subagent blocker any longer:**
- ~~**(Q1) Ephemeral forward-secrecy vs recoverable-replay-key (the one §4.4 gap).**~~ **RESOLVED (FIX 3).**
The two-key model is now **frozen in §4.4/§4.5**: `ReplayKeyParams`, `REPLAY_KDF_INFO = 'relay-e2e/replay/v1'`,
`deriveContentKey(p): AeadKey`, `sealReplayFrame`, `openReplayCiphertext` 4.4) + `EnrollResult.hostContentSecret`
4.5). Live frames use the ephemeral `DirectionalKeys`; replay-bound hostclient output uses the recoverable
`K_content`. T10 implements the frozen surface; no P4-local shape.
- ~~**(Q2) Source of `hostContentSecret` host-scoped, not a raw account secret.**~~ **RESOLVED (FIX 3).**
§4.5 assigns a **single owner: P3 mints + wraps `hostContentSecret` at BIND, sealed to the host's enrolled
Ed25519 `agent_pubkey`** (host-scoped, per-host, per-host-revocable INV12), returned in `EnrollResult`.
**P2 unwraps locally** (private key never leaves the host, INV4); **authorized browser devices obtain it via
P5** after auth/step-up. P4 codes against the injected `hostContentSecret: Uint8Array` and assumes no mechanism.
The host-scoping + per-host revocability precondition for §8 gate 8 is now contractually guaranteed.
- ~~**(Q3) `deviceAuthProof` must be per-handshake and non-replayable.**~~ **RESOLVED (FIX 6b).** §4.4 +
INDEX §6b freeze the binding to **`{clientEphPub, clientNonce}`** and assign **sole issuance/verification to
P5** (`relay-auth/src/capability/device-proof.ts`). P4 consumes it as an **injected dependency** the §4.4
`DeviceAuthProofProvider.proofFor(hostId, { clientEphPub, clientNonce })` (client) and host-side
`verifyDeviceProof(proof, { clientEphPub, clientNonce })` and never imports crypto identity from `relay-auth`.
T8 owns the gating call site + the replay-rejection test 8 gate 5); the binding param is unified across P2/P4/P5.
- ~~**(Q4) Coordination-point amendments to INDEX §4.4 (CryptoKeyAeadKey, asyncsync, `aad=directionLabel‖seq`,
direction-subkey labels).**~~ **RESOLVED (FIX 2).** All landed in the frozen §4.4: `AeadKey` opaque wrapper,
**synchronous** `sealFrame`/`openFrame`, `aad = directionLabel‖seq`, `DirectionalKeys{c2h,h2c}` + the
`HKDF_INFO`/`HKDF_INFO_C2H`/`HKDF_INFO_H2C` labels. T2's `AeadKey` and T9's `sealFrame`/`openFrame` are
**unblocked and cite §4.4 verbatim** (they implement, they do not redefine INDEX §2.1).
**Residual (non-blocking) confirmations:** adding `@noble/ciphers`/`@noble/curves`/`@noble/hashes` to
`relay-e2e` deps is a battle-tested library choice (`development-workflow.md` "prefer libraries") and needs no
contract change; the crypto *implementations* of the §4.4 functions live here and are re-exported (INDEX §2.1).