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.
60 KiB
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 followPLAN.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.mdis 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:
- Authenticated X25519 ECDH THROUGH the relay — INDEX §4.4 handshake (
ClientHello/HostHello). The relay forwards handshake messages as opaque §4.1DATAframes and cannot derive the key (INV2). Ephemeral X25519 + HKDF exactly as §4.4 specifies. - Host-key pinning / TOFU bound at enrollment — the browser verifies the host's
enroll_fpr(INDEX §4.2hosts.enroll_fpr);HostHello.sig= Ed25519 over the transcript by the host's enrolled key (§4.2agent_pubkey). Fingerprint mismatch ⇒ ABORT (no malicious-relay MITM, §4d). - AEAD frame envelope — INDEX §4.4
E2EEnvelope(AES-256-GCM / XChaCha20-Poly1305), deterministic per-seqnonce + strictly monotonicseq+ direction-scoped subkeys for anti-replay/injection and bidirectional-misuse resistance (INV13).sealFrame/openFrameper §4.4 — the INDEX now freezes these assealFrame(key: AeadKey, seq, plaintext): E2EEnvelope/openFrame(key: AeadKey, env, expectedSeq), SYNCHRONOUS, withAeadKey(notCryptoKey),aad = directionLabel‖seq, and direction-splitDirectionalKeys{c2h,h2c}(INDEX §4.4, FIX 2). P4 cites these verbatim fromrelay-contractsand defines none of them locally (the earlier drift/§9 Q4 amendment has landed). - 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.5hostContentSecret, FIX 3) — no longer an open §4.4 gap. - 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). - 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,
deviceAuthProofissuance+verification, revocation, audit → P5 (§4.3). P4 carriesdeviceAuthProofand 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 theenroll_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
E2EEnvelopewire codec, the isomorphic crypto provider, AEAD primitives, and fingerprint helpers — all fully tested with KATs so theDATApayload 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
DATAframe 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
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: 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
DATApayload. T12's integration test uses a P1 loopback stub; the byte layout must satisfy P1'spayloadLen ≤ maxFrameBytes. Depends on P1's mux-frame task. - P2 AGENT (§4.5): the host handshake (
createHostHandshake) is wired intoagent/and signs with the agent's enrolled Ed25519 private key (never inrelay-e2e; injected viaHostSigner). P2 also seals every replay-bound host→client output underK_contentvia T10sealReplayFrame(see T10 frame-key routing) and unwrapshostContentSecretfrom §4.5EnrollResultlocally. 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(andenrollFpr) from the host registry over an independent TLS-authenticated channel — this is the pubkeyonHostHelloverifies against and recomputes the fingerprint from; it is never read out of the relay-carriedHostHello(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
deviceAuthProofbound toclientEphPub/clientNonce(finding #2) and delivers the host-scopedhostContentSecretwrapped 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
environmentpragma). randomBytes(32)length 32, two calls differ, never all-zero (statistical smoke).- Security:
timingSafeEqualreturnsfalseon length mismatch without short-circuit branch on content length ordering; equal buffers →true; single-bit flip →false. (NoBufferdependency — hand-rolled XOR-accumulate so it runs in the browser.) getWebCrypto()throws a typedE2EError(not a bareTypeError) 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). nonceLengthmismatch (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.jsonencode to exact bytes; decode reproduces the struct (byte-for-byte agent↔browser parity). - round-trip
decodeEnvelope(encodeEnvelope(e))deep-equals;seqsurvives asbigint(no float truncation past 2^53 — testseq = 2n**63n - 1n). - Negative: wrong
envVersion→EnvelopeFormatError; truncated buffer (< 27 bytes min) → error;nonceLeninconsistent withaeadId→ error; declared length overrun (nonceLensays 24, buffer too short) → error; unknownaeadId→ error. Every deserialize path runsE2EEnvelopeSchema.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:
computeEnrollFprmatches KAT vectors and equals the value P3 stores as §4.2enroll_fpr(shared vector file so P3's test and this test assert the same string).verifyPinnedFingerprintis timing-safe (delegates to T1); true on match, false on any-byte diff and on length diff.resolvePinhashes the raw pubkey itself (never trusts a caller-supplied fpr): empty store →tofu-first-use,computedFprreturned to persist; matching pin →match; differing pin →mismatch(malicious-relay / host-rotation MITM signal — the browser MUST abort/prompt, never auto-repin).- Security:
resolvePinis given a pubkey whose locally-recomputedcomputedFprdiffers from a (hypothetically relay-supplied) fpr string → the function keys its decision only offcomputedFpr, proving there is no string-only comparison path. - Security: a
mismatchoutcome 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— assertextractable === false; a test provingexportKey('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/curvesparity 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). infois 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 !== h2cbit-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
algstill derives 32-byte subkeys; each subkey feeds T2 as an opaqueAeadKey(never surfaced as a plainUint8Arraybeyond 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()yields0n,1n,2n…strictly increasing; two guards never share state. - recv side: in-order
0,1,2accepted; replay of an accepted seq →ReplayError; reorder (0then2) →ReplayError; rewind (5then4) →ReplayError; gap (0then2) →ReplayError. - bigint boundary: accepts up to
2n**64n − 1nwithout 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:
// 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 negotiatedaead; relay (a passthrough spy in the test) never sees key material — assert the spy's captured bytes decode only toClientHello/HostHellostructs, 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→sigfails against the registryagentPubkey→onHostHellothrowsFingerprintMismatchError,phase → 'aborted', no key derived. - Pubkey/fpr inconsistency (finding #3 regression guard):
msg.enrollFprmatches the pinned string, but the rawagentPubkeysupplied for verification hashes to a different fpr → abort withFingerprintMismatchError, never a string-only match; provesverify()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
deviceAuthProofcaptured from handshake A (its ownclientEphPub/clientNonce), replayed verbatim in handshake B with a differentclientEphPub, is rejected byverifyDeviceProof/host →HandshakeStateError, no session. A static/unbound bearer proof is likewise rejected. - deviceAuthProof gate (INV3): host rejects a
client_hellowhosedeviceAuthProoffailsverifyDeviceProof→HandshakeStateError; forged/absent proof never yields a session (deny-by-default). - State machine: any out-of-order call (
onHostHellobeforestart, doublestart,onClientHellotwice) →HandshakeStateError;phasetransitions are monotone and terminal atestablished/aborted. - Malformed input:
onHostHello/onClientHellorunHostHelloSchema/ClientHelloSchema.parsefirst; 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)) === xfor both algs, empty/large payloads. (sealtwice 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
E2ESessioncannot be opened by that same client'sopen(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
openusesh2c, the frame was sealed underc2h→AeadOpenError(reflection rejected).
- a frame sealed by the client
- Deterministic nonce KAT: fixed key+seq → exact nonce bytes; assert
sealFramenever callsrandomBytes(spy/stub asserts the CSPRNG is untouched on the seal path). - INV13 injection/replay: capture a
sealoutput, feed it toopentwice → 2nd isReplayError; reorder two frames →ReplayError; flip a ciphertext bit →AeadOpenError; re-encode with a bumpedseq→AeadOpenError(aad mismatch). - Lifecycle:
rederivewith a freshHandshakeResultresets both seq guards to 0 and decrypts frames sealed under the newDirectionalKeys; frames sealed under the old keys fail to open post-rederive (forward isolation). - Cross-instance: a browser (
client)E2ESessionopens what an agent (host)E2ESessionsealed 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)whereREPLAY_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 rawaccountRecoverableSecretthat 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 + wrapshostContentSecretat BIND, sealed to the host's enrolled Ed25519agent_pubkey(host-scoped, per-host; delivered in §4.5EnrollResult.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 samehostContentSecretvia P5 (unwrapped only after auth/step-up); each re-derives the identicalK_content→ decrypts replayed ciphertext after a refresh. Relay still sees only ciphertext (INV2).sessionKeydirection subkeys (§4.4 ephemeralDirectionalKeys) — 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
h2csubkey viaE2ESession.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_contentviasealReplayFrame(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-derivesK_content(deriveContentKey) and decrypts the stored ciphertext withopenReplayCiphertext(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 keepsK_contentscoped 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 stubUint8Array[]) stores the ciphertext; device X "reloads" (new object, no in-memory key), re-derivesK_contentfrom the samehostContentSecret+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 identicalK_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 deriveK_contentfor 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
hostContentSecretderives a different key → cannot open (cross-host + cross-account replay isolation, reinforces INV1); relay memory holding the ciphertext never yields plaintext (INV2);K_contentis 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, samehostContentSecret) each complete a handshake with the host stub and each derive matchingK_content→ both mirror the same session. - Security (INV3, per-handshake proof): a context lacking a valid
deviceAuthProofnever 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 emitshostContentSecretor a derived key into any frame the relay forwards (static assertion + a spy test on all outbound bytes). - Revocation (INV12): a context whose
hostContentSecretunwrap has been revoked cannot deriveK_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 →
E2ESessionon each side → seal "echo test", forward as a §4.1DATApayload (assertpayloadLen ≤ 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 nows/pg/DOM runtime (dependency-cruiser or a grep-based test) — the package stays a pure crypto core. - Isomorphic build:
esbuildproduces a browser ESM bundle and a Node ESM bundle; a smoke test loads each and runs one seal/open (guards a WebCrypto/@nobledivergence between environments). - Vector freeze: all
test/vectors/*.jsonare 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
DATApayloads that are §4.4E2EEnvelopebytes; 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 underh2c, was sealed underc2h→AeadOpenError.aad = directionLabel || seqbinds both. - INV13 — anti-replay/injection. Deterministic per-
seqnonce (T9, no random branch) + strictly monotonicseq(T7) +aad = directionLabel || seq. Stops: a relay replaying a capturedapprove/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 rawagentPubkeyfetched from P3's host registry over an independent TLS channel (never read from the relay-carriedHostHello);onHostHellorecomputescomputeEnrollFpr(agentPubkey)and asserts it equals bothmsg.enrollFprand the browser-pinned value before verifying (T4/T8). Stops: the relay substituting its own ephemeral key or feeding a forged pubkey toverify()— 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
deviceAuthProofbound toclientEphPub/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-keyedclient_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 opaqueAeadKeywrappers; error messages are asserted to carry no key/nonce/plaintext (T0); the ring buffer stores only ciphertext (T10). The host-scopedhostContentSecret(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 recoverableK_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_INFOare 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 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):
- INV2 tripwire (T12): plaintext canary never appears in the relay spy's buffers/logs/snapshot. Merge-blocking.
- INV13 (T7/T9): replay, reorder, tamper, and seq-slot attacks all rejected with typed errors; nonce is deterministic per
seq(no random-nonce path). - Direction split (T6/T9):
c2h ≠ h2c; a client-sealed frame cannot be opened by the client's ownopen; a reflected client→host frame replayed as host→client is rejected (AeadOpenError). Merge-blocking. - MITM abort (T8): raw
agentPubkeyis 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. - INV3 proof binding (T8/T11): absent/forged
deviceAuthProof→ no session; a proof captured from handshake A replayed in handshake B (differentclientEphPub) → rejected; no code path serializes a key to a QR/string. - INV5 (T5/T0): ephemeral privkeys non-extractable (
exportKeyrejects); no secret in any thrown message. - Replay survives refresh (T10): ciphertext stored under
K_contentdecrypts after a simulated reload and on a second authorized device; a foreign account/host cannot decrypt. - Content-key revocation (T10/T11, INV12): a revoked host/device cannot obtain
hostContentSecret→ cannot deriveK_contentfor that host's sessions going forward. Merge-blocking before T10/T11. - 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.
- Coverage ≥80% overall; crypto-critical files ≥95%; zero
console.*insrc/(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 ephemeralDirectionalKeys; replay-bound host→client output uses the recoverableK_content. T10 implements the frozen surface; no P4-local shape.(Q2) Source ofRESOLVED (FIX 3). §4.5 assigns a single owner: P3 mints + wrapshostContentSecret— host-scoped, not a raw account secret.hostContentSecretat BIND, sealed to the host's enrolled Ed25519agent_pubkey(host-scoped, per-host, per-host-revocable — INV12), returned inEnrollResult. 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 injectedhostContentSecret: Uint8Arrayand assumes no mechanism. The host-scoping + per-host revocability precondition for §8 gate 8 is now contractually guaranteed.(Q3)RESOLVED (FIX 6b). §4.4 + INDEX §6b freeze the binding todeviceAuthProofmust be per-handshake and non-replayable.{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.4DeviceAuthProofProvider.proofFor(hostId, { clientEphPub, clientNonce })(client) and host-sideverifyDeviceProof(proof, { clientEphPub, clientNonce })— and never imports crypto identity fromrelay-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,RESOLVED (FIX 2). All landed in the frozen §4.4:aad=directionLabel‖seq, direction-subkey labels).AeadKeyopaque wrapper, synchronoussealFrame/openFrame,aad = directionLabel‖seq,DirectionalKeys{c2h,h2c}+ theHKDF_INFO/HKDF_INFO_C2H/HKDF_INFO_H2Clabels. T2'sAeadKeyand T9'ssealFrame/openFrameare 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).