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