Files
web-terminal/docs/PLAN_RELAY_E2E.md
Yaojia Wang 2af57e6686 feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)
Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.

Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
  capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay:   native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent:        host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e:    browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth:   Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web:    browser login + Web Crypto E2E + client-side preview rendering

Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.

NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
2026-07-02 06:10:16 +02:00

60 KiB
Raw Permalink Blame History

PLAN_RELAY_E2E — End-to-End Encryption (ciphertext-shuttle) · P4

Plan P4 of the Rendezvous-Relay program. Source of intent: EXPLORE_RELAY_SERVICE.md §0 (LOCKED, decision 2 = E2E) and §4c/§4d; coordination point: 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_VOICE_COMMANDS.md: stable task IDs grouped into dependency waves, Owns: disjoint-file lists, function-signature-level contracts, TDD (tests FIRST), explicit test cases incl. security/negative, per-task security notes. PROGRESS_LOG.md is orchestrator-only — a dispatched builder ends with a ready-to-paste entry (G1).


0. Scope

Owns browser↔agent end-to-end encryption, delivered as the shared crypto core package relay-e2e/ (INDEX §0 table) that is imported by both agent/ (P2, host side) and the browser bundle relay-web/ (P6, client side). Concretely, P4 implements exactly the pieces the INDEX §5 P4 charter names, all against the frozen §4 contracts:

  1. Authenticated X25519 ECDH THROUGH the relay — INDEX §4.4 handshake (ClientHello/HostHello). The relay forwards handshake messages as opaque §4.1 DATA frames and cannot derive the key (INV2). Ephemeral X25519 + HKDF exactly as §4.4 specifies.
  2. Host-key pinning / TOFU bound at enrollment — the browser verifies the host's enroll_fpr (INDEX §4.2 hosts.enroll_fpr); HostHello.sig = Ed25519 over the transcript by the host's enrolled key (§4.2 agent_pubkey). Fingerprint mismatch ⇒ ABORT (no malicious-relay MITM, §4d).
  3. AEAD frame envelope — INDEX §4.4 E2EEnvelope (AES-256-GCM / XChaCha20-Poly1305), deterministic per-seq nonce + strictly monotonic seq + direction-scoped subkeys for anti-replay/injection and bidirectional-misuse resistance (INV13). sealFrame/openFrame per §4.4 — the INDEX now freezes these as sealFrame(key: AeadKey, seq, plaintext): E2EEnvelope / openFrame(key: AeadKey, env, expectedSeq), SYNCHRONOUS, with AeadKey (not CryptoKey), aad = directionLabel‖seq, and direction-split DirectionalKeys{c2h,h2c} (INDEX §4.4, FIX 2). P4 cites these verbatim from relay-contracts and defines none of them locally (the earlier drift/§9 Q4 amendment has landed).
  4. Session-key lifecycle — derived on attach, re-derived on refresh/reconnect; ring-buffer replay of ciphertext survives (EXPLORE §4c). The recoverable-replay-key design (T9/T10) is now a frozen §4.4 surface (ReplayKeyParams, deriveContentKey, sealReplayFrame, openReplayCiphertext, REPLAY_KDF_INFO; INDEX §4.4 + §4.5 hostContentSecret, FIX 3) — no longer an open §4.4 gap.
  5. Multi-device key distribution over an authenticated (account-derived) channel — NOT a plaintext QR. Each authorized device runs its own §4.4 handshake gated by an account-derived deviceAuthProof (INV3); the key never travels in cleartext and no shoulder-surfable QR carries it (§4c).
  6. Documents the relay-sees-only-ciphertext guarantee and the server features E2E kills — server-side preview thumbnails / manage grid / search / recording move CLIENT-SIDE (rendered in the key-holding browser, owned by P6). This plan ships the decrypt primitive P6 consumes; it does not own the UI.

Out of scope (explicit fences — other plans own these)

  • Transport framing / mux / routing → P1 (§4.1). P4 payloads ride inside DATA; P4 never touches the header.
  • Human auth (Passkey/WebAuthn/OIDC), capability tokens, deviceAuthProof issuance+verification, revocation, audit → P5 (§4.3). P4 carries deviceAuthProof and shape-validates it, but its cryptographic meaning and verification are P5's.
  • Enrollment / pairing / Ed25519 host keypair generation + storage → P2 (§4.5) & P3 (§4.2). P4 consumes the enrolled pubkey (to verify sig) and the enroll_fpr (to pin); it does not generate or persist host identity keys.
  • Account/host/session records, Postgres/Redis → P3 (§4.2).
  • Client-side preview UI, dashboard, xterm render → P6. P4 exposes E2ESession.open(); P6 renders.

Phasing (INDEX §1)

  • v0.8 (MVP): P4 writes its contracts / wire format and ships test vectors, but NO live crypto in the byte path (INDEX §1: "P4/P5 write their contracts … but ship no runtime"). Deliverables: the E2EEnvelope wire codec, the isomorphic crypto provider, AEAD primitives, and fingerprint helpers — all fully tested with KATs so the DATA payload is shaped for ciphertext to drop in without reshaping (INDEX §1 "the frame path is designed so ciphertext drops in"). Wave E0.
  • v0.9: no new P4 runtime (v0.9 stays plaintext; native mux + accounts land). P4 keeps E0 green against the native-mux DATA frame once P1 swaps substrate.
  • v0.10 (E2E-hardening — the non-negotiable phase, done BEFORE scaling users): full browser↔agent E2E — handshake state machine, session AEAD, anti-replay, key lifecycle, multi-device. Waves E1E3.

Security invariants this plan ENFORCES (INDEX §3)

  • INV2 — relay handles only ciphertext (P4 is the reason it can: opaque AEAD envelopes; no key at the relay).
  • INV5 — no plaintext/secret at rest (session keys are non-extractable CryptoKeys / zeroized buffers; ring buffer holds ciphertext only).
  • INV13 — anti-replay/injection: per-message nonce + strictly monotonic seq; replay/reorder/tamper rejected.
  • Consumes / reinforces: INV1 (E2E makes cross-tenant buffer bleed cryptographically meaningless — B cannot open A's frames), INV3 (device bound to account via deviceAuthProof; no client-supplied identity in key derivation), INV9 (no secret material logged).

1. Package layout & file ownership

All new code lives in relay-e2e/ (INDEX §0). Isomorphic ESM (browser + Node ≥20) — zero ws/pg/DOM ambient, so agent/ and relay-web/ can both import it. Small cohesive files (coding-style.md: 200400 typical, 800 hard max). relay-contracts/ is read-only to this plan (frozen §4 types + Zod validators).

File Wave / Phase Role
relay-e2e/package.json E0 / v0.8 Package manifest; deps: @noble/ciphers, @noble/curves, @noble/hashes (audited, isomorphic, synchronous primitives); dev: vitest, esbuild, typescript. Depends on relay-contracts (workspace).
relay-e2e/tsconfig.json E0 / v0.8 Strict TS; lib: ["ES2022","DOM"] types-only (no DOM runtime use); dual ESM output.
relay-e2e/src/index.ts E3 / v0.10 Barrel — re-exports the public surface only (handshake factories, E2ESession, sealFrame/openFrame, envelope codec, fingerprint, errors).
relay-e2e/src/errors.ts E0 / v0.8 Typed error classes (§7). No console; callers handle.
relay-e2e/src/crypto-provider.ts E0 / v0.8 Isomorphic entropy + constant-time compare: randomBytes, timingSafeEqual, getWebCrypto() (browser crypto.subtle / Node webcrypto.subtle) for the async handshake KDF/ECDH path.
relay-e2e/src/aead.ts E0 / v0.8 Synchronous AEAD seal/open over @noble/ciphers (AES-256-GCM + XChaCha20-Poly1305); nonceLength(). Sync so it satisfies the frozen §4.4 sealFrame/openFrame synchronous signatures.
relay-e2e/src/envelope.ts E0 / v0.8 Deterministic wire codec for §4.4 E2EEnvelope (encodeEnvelope/decodeEnvelope) — the bytes that ride inside a §4.1 DATA payload.
relay-e2e/src/fingerprint.ts E0 / v0.8 computeEnrollFpr (matches §4.2 enroll_fpr format) + verifyPinnedFingerprint (timing-safe); TOFU pin-store interface.
relay-e2e/src/x25519.ts E1 / v0.10 Ephemeral X25519 keygen + ECDH shared secret (WebCrypto X25519 with @noble/curves fallback for parity/KATs).
relay-e2e/src/hkdf.ts E1 / v0.10 deriveSessionKeys — HKDF-SHA256 master per §4.4 (salt = clientNonce‖hostNonce, info = "relay-e2e/v1"), then a direction-split HKDF-Expand into two disjoint subkeys (c2h/h2c) so client-write and host-write never share key material (fixes bidirectional-AEAD misuse).
relay-e2e/src/sequence.ts E1 / v0.10 SequenceGuard — send-side monotonic next(), recv-side strict accept() (INV13); one guard per direction, paired 1:1 with that direction's subkey.
relay-e2e/src/handshake.ts E2 / v0.10 Client + host handshake state machines (§4.4): produce/consume ClientHello/HostHello, transcript sign/verify, derive HandshakeResult.
relay-e2e/src/session.ts E2 / v0.10 sealFrame/openFrame (§4.4 verbatim) + E2ESession wrapper (wire seal/open, seq guarding, rederive).
relay-e2e/src/replay-key.ts E3 / v0.10 Recoverable content key for ring-buffer ciphertext replay across reconnect (T9 / Open Question).
relay-e2e/src/keystore.ts E3 / v0.10 Multi-device adapters: TOFU DevicePinStore + DeviceAuthProofProvider (account-derived, from P5). No key transport in cleartext.
relay-e2e/test/*.test.ts per task Co-located vitest suites (one per src file) + KAT vectors + integration.
relay-e2e/test/vectors/*.json E0+ Known-answer test vectors (AEAD, HKDF, envelope, fingerprint) — frozen so agent & browser agree byte-for-byte.

Frozen imports (read-only, never redefined here): from relay-contracts — the full §4.4 surface the INDEX froze under FIX 2/3: AeadAlg, AeadKey, SessionRole, ClientHello, HostHello, E2EEnvelope, DirectionalKeys, HandshakeResult, ClientHandshake, E2ESession, sealFrame, openFrame, createE2ESession, buildClientHandshake, AuthorizedDeviceContext, DeviceAuthProofProvider, the HKDF label constants (HKDF_INFO, HKDF_INFO_C2H, HKDF_INFO_H2C), and the replay surface (ReplayKeyParams, REPLAY_KDF_INFO, deriveContentKey, sealReplayFrame, openReplayCiphertext); plus CapabilityToken (ref only), HostRecord.enrollFpr, and the Zod schemas (ClientHelloSchema, HostHelloSchema, E2EEnvelopeSchema). relay-contracts owns the shapes; relay-e2e/ owns the crypto implementations and re-exports them (INDEX §2.1). P4 calls .parse() at every deserialize boundary (coding-style.md Input Validation) but authors none of these shapes (INDEX §4).


2. Waves & dependency ordering

relay-contracts/ (§4 FROZEN)  ──read-only──►  all P4 files

E0  Foundations (v0.8 — contracts + wire, NO live path)  [parallel, file-disjoint]
    T1 crypto-provider   T2 aead   T3 envelope   T4 fingerprint   T0 errors
        │  (E0 primitives frozen + KAT'd before any handshake consumes them)
        ▼
E1  Key agreement (v0.10)  [parallel]
    T5 x25519            T6 hkdf            T7 sequence
        │
        ▼
E2  Handshake + session (v0.10)
    T8 handshake  (depends T5,T6,T4,errors)  →  T9 session  (depends T2,T3,T6,T7,T8)
        │
        ▼
E3  Multi-device + integration (v0.10)
    T10 replay-key  T11 keystore   →   T12 barrel + cross-package integration + INV2 tripwire

Cross-plan dependencies (reference other plans' task IDs where their tasks are defined):

  • P1 TRANSPORT (§4.1): E2E envelopes are the DATA payload. T12's integration test uses a P1 loopback stub; the byte layout must satisfy P1's payloadLen ≤ maxFrameBytes. Depends on P1's mux-frame task.
  • P2 AGENT (§4.5): the host handshake (createHostHandshake) is wired into agent/ and signs with the agent's enrolled Ed25519 private key (never in relay-e2e; injected via HostSigner). P2 also seals every replay-bound host→client output under K_content via T10 sealReplayFrame (see T10 frame-key routing) and unwraps hostContentSecret from §4.5 EnrollResult locally. P2 imports P4's T8/T9/T10; P2's E2E-endpoint task depends on T8.
  • P3 CONTROL PLANE (§4.2): browser fetches the raw agentPubkey (and enrollFpr) from the host registry over an independent TLS-authenticated channel — this is the pubkey onHostHello verifies against and recomputes the fingerprint from; it is never read out of the relay-carried HostHello (finding #3). P4 consumes the §4.2 shape; P3's registry task provides it; the independent-channel property is a hard security precondition of T8.
  • P5 AUTH (§4.3, INV3): issues + verifies a per-handshake, non-replayable deviceAuthProof bound to clientEphPub/clientNonce (finding #2) and delivers the host-scoped hostContentSecret wrapped to the agent identity (finding #5); gates the stream with a capability token before the handshake. P4 defines the field + Zod-validates it; P5's device-proof task (binding semantics) and host-content-secret delivery/revocation are hard dependencies of T8/T10/T11.
  • P6 FRONTEND: imports T8 (createClientHandshake), T9 (E2ESession), T10 (openReplayCiphertext) for connect + decrypt + client-side preview. P6's connect + preview tasks depend on T12's published barrel.

3. E0 — Foundations (v0.8, no live crypto in the byte path)

T0 · errors + package scaffold · v0.8

Owns: relay-e2e/package.json, relay-e2e/tsconfig.json, relay-e2e/src/errors.ts, relay-e2e/test/errors.test.ts Depends: relay-contracts frozen (INDEX §4, W0).

Contracts:

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.

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

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)
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 envVersionEnvelopeFormatError; 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).

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.

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.

// 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.

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_hellohost_hello), producing the frozen §4.4 HandshakeResult:

// 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/sigsig fails against the registry agentPubkeyonHostHello 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 verifyDeviceProofHandshakeStateError; 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.

// §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 c2hAeadOpenError (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 seqAeadOpenError (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).
// 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:

// 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 AuthorizedDeviceContexts (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 c2hAeadOpenError. 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 CryptoKeys (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):

npm -w relay-e2e run build          # esbuild → browser ESM + node ESM bundles; tsc --noEmit strict
npm -w relay-e2e test               # vitest: all T0T12 suites, jsdom + node environments
npm -w relay-e2e run test:coverage  # enforce ≥80% (testing.md); crypto core targets ≥95% on aead/envelope/sequence/session
npm -w relay-e2e run test:vectors   # KAT freeze check (aead/hkdf/envelope/fingerprint) — agent↔browser parity
npm -w relay-e2e run lint:isolation # dependency-cruiser: no xterm/ANSI/ws/pg/DOM-runtime imports (INV2/INV11)

Acceptance gates (all must pass before P4 merges / before v0.10 ships):

  1. INV2 tripwire (T12): plaintext canary never appears in the relay spy's buffers/logs/snapshot. Merge-blocking.
  2. INV13 (T7/T9): replay, reorder, tamper, and seq-slot attacks all rejected with typed errors; nonce is deterministic per seq (no random-nonce path).
  3. Direction split (T6/T9): c2h ≠ h2c; a client-sealed frame cannot be opened by the client's own open; a reflected 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).