# 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<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** 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<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 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<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 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<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 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).
