# 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 **E1–E3**. ### 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`: 200–400 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 // X25519, per handshake, fresh export async function deriveSharedSecret(privateKey: CryptoKey, peerPublicKey: Uint8Array): Promise // 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** client→host and host→client 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 client→host frame back to the client as host→client 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 ``` 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 client‖host). - `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; onHostHello(msg, agentPubkey): Promise } // `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 } // provided by P2 (agent Ed25519 priv key) export interface HostVerifier { verify(pubkey: Uint8Array, transcript: Uint8Array, sig: Uint8Array): Promise } // 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 // 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 } 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 // 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 }): 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 client→host ciphertext back to the client as a host→client 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 host→client output** (interactive stream, forward-secret) → sealed under the **ephemeral `h2c`** subkey via `E2ESession.seal` (T9). Live client→host input → **`c2h`**. These die on reconnect (re-derived, INV forward secrecy). - **Every replay-bound host→client 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)**. - **Client→host input is NEVER sealed under `K_content`** (it is not replay-bound; only host→client 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 // } // 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_`) 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 client→host 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 T0–T12 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 client→host frame replayed as host→client 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 host→client 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 (CryptoKey→AeadKey, async→sync, `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).