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:
Yaojia Wang
2026-07-02 06:10:16 +02:00
parent e4c327e25e
commit 2af57e6686
326 changed files with 40877 additions and 0 deletions

38
relay-e2e/src/aead-key.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* Internal opaque-key registry (P4 impl detail; NOT a re-declared contract shape).
*
* The frozen §4.4 `AeadKey` is a nominal phantom type (`{ readonly __aeadKey: unique symbol }`).
* At runtime we return an opaque, frozen handle and keep the real key material in a module-private
* WeakMap — the handle itself exposes NO bytes (supports INV5: no secret sitting on a passed object).
* `aadLabel` binds the direction/purpose into every frame's AEAD `aad = aadLabel‖seq`.
*/
import type { AeadAlg, AeadKey } from 'relay-contracts'
import { AEAD_KEY_BYTES } from 'relay-contracts'
import { E2EError } from './errors.js'
export interface AeadKeyMaterial {
readonly raw: Uint8Array
readonly alg: AeadAlg
readonly aadLabel: string
}
const REGISTRY = new WeakMap<object, AeadKeyMaterial>()
/** Wrap 32 raw key bytes + alg + aad label into the frozen opaque `AeadKey`. */
export function wrapAeadKey(raw: Uint8Array, alg: AeadAlg, aadLabel: string): AeadKey {
if (raw.length !== AEAD_KEY_BYTES) {
throw new E2EError('E2E_KEY_LENGTH', `AEAD key must be ${AEAD_KEY_BYTES} bytes; got ${raw.length}`)
}
const handle = Object.freeze({})
REGISTRY.set(handle, { raw: raw.slice(), alg, aadLabel })
return handle as unknown as AeadKey
}
/** Recover the key material for a handle previously produced by {@link wrapAeadKey}. */
export function unwrapAeadKey(key: AeadKey): AeadKeyMaterial {
const material = REGISTRY.get(key as unknown as object)
if (!material) {
throw new E2EError('E2E_BAD_KEY_HANDLE', 'value is not a relay-e2e AeadKey handle')
}
return material
}

77
relay-e2e/src/aead.ts Normal file
View File

@@ -0,0 +1,77 @@
/**
* T2 — synchronous AEAD primitives over @noble/ciphers (AES-256-GCM + XChaCha20-Poly1305).
*
* SYNCHRONOUS so it satisfies the frozen §4.4 `sealFrame`/`openFrame` synchronous signatures.
* @noble/ciphers is audited, isomorphic, and provides XChaCha (which WebCrypto lacks). Noble's
* one-shot AEAD returns `ciphertext‖tag`; §4.4 `E2EEnvelope` keeps them split, so we slice the
* fixed 16-byte tag off the tail on seal and re-join on open.
*/
import { gcm } from '@noble/ciphers/aes'
import { xchacha20poly1305 } from '@noble/ciphers/chacha'
import type { AeadAlg, AeadKey } from 'relay-contracts'
import { NONCE_BYTES } from 'relay-contracts'
import { AeadOpenError, E2EError } from './errors.js'
import { unwrapAeadKey, wrapAeadKey } from './aead-key.js'
const TAG_BYTES = 16 as const
/** Nonce width for an AEAD alg (12 GCM · 24 XChaCha, §4.4). */
export function nonceLength(alg: AeadAlg): 12 | 24 {
const width = NONCE_BYTES[alg]
return width as 12 | 24
}
/** Fixed AEAD tag length (16 bytes for both algs). */
export function tagLength(_alg: AeadAlg): 16 {
return TAG_BYTES
}
/** Construct the frozen opaque `AeadKey` from 32 raw bytes (P4-local constructor, INDEX §2.1). */
export function importAeadKey(raw: Uint8Array, alg: AeadAlg, aadLabel = ''): AeadKey {
return wrapAeadKey(raw, alg, aadLabel)
}
function cipherFor(alg: AeadAlg, raw: Uint8Array, nonce: Uint8Array, aad: Uint8Array) {
if (nonce.length !== nonceLength(alg)) {
throw new E2EError(
'E2E_NONCE_LENGTH',
`nonce length ${nonce.length} != required ${nonceLength(alg)} for ${alg}`,
)
}
return alg === 'aes-256-gcm' ? gcm(raw, nonce, aad) : xchacha20poly1305(raw, nonce, aad)
}
/** Seal plaintext; returns split ciphertext + 16-byte tag (§4.4 E2EEnvelope). */
export function aeadSeal(
key: AeadKey,
nonce: Uint8Array,
plaintext: Uint8Array,
aad: Uint8Array,
): { ciphertext: Uint8Array; tag: Uint8Array } {
const { raw, alg } = unwrapAeadKey(key)
const sealed = cipherFor(alg, raw, nonce, aad).encrypt(plaintext)
const cut = sealed.length - TAG_BYTES
return { ciphertext: sealed.slice(0, cut), tag: sealed.slice(cut) }
}
/** Open ciphertext+tag; throws {@link AeadOpenError} on any tag/aad/key/nonce mismatch. */
export function aeadOpen(
key: AeadKey,
nonce: Uint8Array,
ciphertext: Uint8Array,
tag: Uint8Array,
aad: Uint8Array,
): Uint8Array {
if (tag.length !== TAG_BYTES) {
throw new AeadOpenError(`tag length ${tag.length} != ${TAG_BYTES}`)
}
const { raw, alg } = unwrapAeadKey(key)
const joined = new Uint8Array(ciphertext.length + tag.length)
joined.set(ciphertext, 0)
joined.set(tag, ciphertext.length)
try {
return cipherFor(alg, raw, nonce, aad).decrypt(joined)
} catch {
throw new AeadOpenError()
}
}

View File

@@ -0,0 +1,51 @@
/**
* T1 — isomorphic crypto provider: entropy + constant-time compare + WebCrypto handle.
*
* Uses ONLY the WHATWG `globalThis.crypto` surface (present in browsers and Node >=20), so the
* package stays isomorphic with zero `node:crypto` import. `timingSafeEqual` is hand-rolled
* (no `Buffer`) so it runs unchanged in the browser bundle.
*/
import { CryptoUnavailableError } from './errors.js'
function globalCrypto(): Crypto {
const c = (globalThis as { crypto?: Crypto }).crypto
if (!c || typeof c.getRandomValues !== 'function') {
throw new CryptoUnavailableError('globalThis.crypto.getRandomValues is unavailable')
}
return c
}
/** Return the WebCrypto SubtleCrypto (browser `crypto.subtle` / Node `webcrypto.subtle`). */
export function getWebCrypto(): SubtleCrypto {
const subtle = globalCrypto().subtle
if (!subtle) {
throw new CryptoUnavailableError('WebCrypto SubtleCrypto is unavailable')
}
return subtle
}
/** CSPRNG bytes via `crypto.getRandomValues`. */
export function randomBytes(len: number): Uint8Array {
if (!Number.isInteger(len) || len < 0) {
throw new CryptoUnavailableError(`randomBytes length must be a non-negative integer; got ${len}`)
}
const out = new Uint8Array(len)
globalCrypto().getRandomValues(out)
return out
}
/**
* Constant-time, length-safe byte equality. Returns `false` on any length mismatch WITHOUT a
* content-dependent early exit: both branches XOR-accumulate over a fixed number of iterations.
*/
export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
const lenDiff = a.length ^ b.length
const n = Math.max(a.length, b.length)
let acc = lenDiff
for (let i = 0; i < n; i++) {
const av = i < a.length ? a[i]! : 0
const bv = i < b.length ? b[i]! : 0
acc |= av ^ bv
}
return acc === 0
}

31
relay-e2e/src/ed25519.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* Default Ed25519 signer/verifier over @noble/curves (isomorphic, browser-safe).
*
* The HOST signer used in production is P2's (backed by the agent's enrolled private key) and is
* INJECTED — relay-e2e never holds host identity keys. This default verifier is used by
* `buildClientHandshake` (browser side verifies the host's signature) and by tests as a stub host.
*/
import { ed25519 } from '@noble/curves/ed25519'
import type { HostSigner, HostVerifier } from './handshake.js'
/** A verifier that checks Ed25519 signatures with @noble/curves. */
export function nobleEd25519Verifier(): HostVerifier {
return {
async verify(pubkey: Uint8Array, transcript: Uint8Array, sig: Uint8Array): Promise<boolean> {
try {
return ed25519.verify(sig, transcript, pubkey)
} catch {
return false
}
},
}
}
/** Test/helper signer over a raw Ed25519 private scalar (NOT for production host use). */
export function nobleEd25519Signer(privateKey: Uint8Array): HostSigner {
return {
async sign(transcript: Uint8Array): Promise<Uint8Array> {
return ed25519.sign(transcript, privateKey)
},
}
}

49
relay-e2e/src/envelope.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* T3 — E2EEnvelope wire codec.
*
* RECONCILIATION: the frozen `relay-contracts` §4.4 surface ALREADY owns the envelope binary
* layout and the deterministic per-seq nonce (`encodeEnvelope`/`decodeEnvelope`/`nonceForSeq`,
* 14-byte header: seq(8)‖nonceLen(1)‖ciphertextLen(4)‖tagLen(1) then nonce‖ciphertext‖tag).
* The plan's stale T3 byte layout (envVersion/aeadId) is SUPERSEDED — relay-contracts owns the
* shape (INDEX §2.1), so P4 CONSUMES/RE-EXPORTS it and never redefines it. This module only adds
* a boundary guard that re-throws the contract's decode error as a P4-typed `EnvelopeFormatError`
* and enforces a hard frame-size cap (anti-overrun; the alg is carried by the key/session, not the
* wire, so there is no unchecked wire-length allocation).
*/
import type { E2EEnvelope } from 'relay-contracts'
import {
ContractDecodeError,
decodeEnvelope as decodeEnvelopeRaw,
encodeEnvelope as encodeEnvelopeRaw,
nonceForSeq,
} from 'relay-contracts'
import { EnvelopeFormatError } from './errors.js'
/** Hard upper bound on a decoded frame (guards a decompression-bomb-style length overrun). */
export const MAX_FRAME_BYTES = 4 * 1024 * 1024
export { nonceForSeq }
/** Encode a §4.4 E2EEnvelope to its frozen wire bytes (delegates to relay-contracts). */
export function encodeEnvelope(env: E2EEnvelope): Uint8Array {
const bytes = encodeEnvelopeRaw(env)
if (bytes.length > MAX_FRAME_BYTES) {
throw new EnvelopeFormatError(`encoded frame ${bytes.length} exceeds cap ${MAX_FRAME_BYTES}`)
}
return bytes
}
/** Decode wire bytes into a §4.4 E2EEnvelope; malformed input ⇒ typed `EnvelopeFormatError`. */
export function decodeEnvelope(buf: Uint8Array): E2EEnvelope {
if (buf.length > MAX_FRAME_BYTES) {
throw new EnvelopeFormatError(`frame ${buf.length} exceeds cap ${MAX_FRAME_BYTES}`)
}
try {
return decodeEnvelopeRaw(buf)
} catch (err) {
if (err instanceof ContractDecodeError) {
throw new EnvelopeFormatError(err.message)
}
throw err
}
}

59
relay-e2e/src/errors.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* T0 — typed error classes for relay-e2e (PLAN_RELAY_E2E §3 T0).
*
* Every error subclasses {@link E2EError} and carries a stable, greppable `code`. Per INV9,
* a thrown message MUST NEVER embed key/nonce/plaintext material — callers surface the code,
* not secret bytes. No `console` here; the caller decides how to handle.
*/
/** Base class for all relay-e2e crypto errors. */
export class E2EError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.name = new.target.name
this.code = code
}
}
/** Pinned enroll_fpr / registry pubkey disagreement ⇒ MITM abort (§4d). */
export class FingerprintMismatchError extends E2EError {
constructor(message = 'host fingerprint mismatch') {
super('E2E_FINGERPRINT_MISMATCH', message)
}
}
/** AEAD tag verification failed ⇒ drop the frame and tear the session down. */
export class AeadOpenError extends E2EError {
constructor(message = 'AEAD authentication failed') {
super('E2E_AEAD_OPEN', message)
}
}
/** Sequence number non-monotonic / duplicate / reordered / gapped (INV13). */
export class ReplayError extends E2EError {
constructor(message = 'sequence replay/reorder rejected') {
super('E2E_REPLAY', message)
}
}
/** Illegal handshake transition, wrong phase, or unusable negotiation. */
export class HandshakeStateError extends E2EError {
constructor(message = 'illegal handshake state transition') {
super('E2E_HANDSHAKE_STATE', message)
}
}
/** Malformed wire bytes / unsupported version / bad AEAD id. */
export class EnvelopeFormatError extends E2EError {
constructor(message = 'malformed envelope') {
super('E2E_ENVELOPE_FORMAT', message)
}
}
/** No WebCrypto / CSPRNG available in the host environment (fail-fast boundary). */
export class CryptoUnavailableError extends E2EError {
constructor(message = 'WebCrypto is unavailable in this environment') {
super('E2E_CRYPTO_UNAVAILABLE', message)
}
}

View File

@@ -0,0 +1,53 @@
/**
* T4 — host-key fingerprint (enroll_fpr) + TOFU pin resolution.
*
* `computeEnrollFpr` MUST match the §4.2 `enroll_fpr` format ('sha256:' + base64url(SHA-256(pubkey)))
* so P3's stored value and the browser's recomputed value are the same string. `resolvePin` takes
* the RAW agent pubkey (fetched over P3's independent TLS channel — see T8) and recomputes the
* fingerprint LOCALLY; it never accepts a precomputed fpr string, so the pin decision can never be
* short-circuited into a relay-influenced 'presented == pinned' comparison.
*/
import { sha256 } from '@noble/hashes/sha2'
import type { DevicePinStore } from 'relay-contracts'
import { encodeBase64UrlBytes } from 'relay-contracts'
import { timingSafeEqual } from './crypto-provider.js'
const FPR_PREFIX = 'sha256:'
const encoder = new TextEncoder()
/** 'sha256:' + base64url(SHA-256(agentPubkey)) — equals the value P3 stores as §4.2 enroll_fpr. */
export function computeEnrollFpr(agentPubkey: Uint8Array): string {
return FPR_PREFIX + encodeBase64UrlBytes(sha256(agentPubkey))
}
/** Timing-safe equality of two fingerprint strings (byte compare, no length-ordered short-circuit). */
export function verifyPinnedFingerprint(presentedPubkey: Uint8Array, pinnedFpr: string): boolean {
const computed = computeEnrollFpr(presentedPubkey)
return timingSafeEqual(encoder.encode(computed), encoder.encode(pinnedFpr))
}
export type PinOutcome = 'tofu-first-use' | 'match' | 'mismatch'
export interface ResolvePinResult {
readonly outcome: PinOutcome
readonly computedFpr: string
readonly pinnedFpr: string | null
}
/**
* Resolve the TOFU pin for a host using the RAW pubkey (recomputes the fpr itself). Never trusts a
* caller-supplied fpr string; keys its decision only off `computedFpr`. A `mismatch` NEVER auto-repins.
*/
export async function resolvePin(
store: DevicePinStore,
hostId: string,
agentPubkey: Uint8Array,
): Promise<ResolvePinResult> {
const computedFpr = computeEnrollFpr(agentPubkey)
const pinnedFpr = await store.get(hostId)
if (pinnedFpr === null) {
return { outcome: 'tofu-first-use', computedFpr, pinnedFpr: null }
}
const matches = timingSafeEqual(encoder.encode(computedFpr), encoder.encode(pinnedFpr))
return { outcome: matches ? 'match' : 'mismatch', computedFpr, pinnedFpr }
}

246
relay-e2e/src/handshake.ts Normal file
View File

@@ -0,0 +1,246 @@
/**
* T8 — client + host handshake state machines (§4.4 `client_hello` → `host_hello`).
*
* Anti-MITM (MUST be explicit): the pin string alone is NOT the defense — `enrollFpr` hashes a
* PUBLIC key a malicious relay also knows. `onHostHello` receives the raw `agentPubkey` from P3's
* INDEPENDENT TLS channel (never from the relay-carried HostHello), recomputes the fpr locally,
* asserts it equals BOTH `msg.enrollFpr` AND the pin outcome, and only then verifies the signature
* against that registry pubkey. Any disagreement ⇒ FingerprintMismatchError, no key derived.
*
* Anti-proof-replay: `deviceAuthProof` is bound to THIS handshake's `clientEphPub`/`clientNonce`
* (issued by P5, injected). The host's `verifyDeviceProof` receives that binding so a captured
* bearer proof replayed into a different ephemeral handshake is rejected (INV3).
*/
import type {
AeadAlg,
ClientHandshake,
ClientHello,
DeviceAuthProofProvider,
DevicePinStore,
HandshakeResult,
HostHello,
} from 'relay-contracts'
import { ClientHelloSchema, HostHelloSchema } from 'relay-contracts'
import { randomBytes } from './crypto-provider.js'
import { computeEnrollFpr, resolvePin, verifyPinnedFingerprint } from './fingerprint.js'
import { deriveSessionKeys } from './hkdf.js'
import { FingerprintMismatchError, HandshakeStateError } from './errors.js'
import { buildTranscript } from './transcript.js'
import { deriveSharedSecret, generateEphemeralKeyPair, type EphemeralKeyPair } from './x25519.js'
const NONCE_BYTES = 32
/** Fixed AEAD strength preference (strongest first). No fallback to an unoffered alg. */
export const AEAD_PREFERENCE: readonly AeadAlg[] = ['xchacha20-poly1305', 'aes-256-gcm']
export type HandshakePhase =
| 'init'
| 'awaitHostHello'
| 'awaitClientHello'
| 'established'
| 'aborted'
export interface HostSigner {
sign(transcript: Uint8Array): Promise<Uint8Array>
}
export interface HostVerifier {
verify(pubkey: Uint8Array, transcript: Uint8Array, sig: Uint8Array): Promise<boolean>
}
/** Pick the strongest alg present in both offers; null if the intersection is empty. */
function negotiate(offer: readonly AeadAlg[], supported: readonly AeadAlg[]): AeadAlg | null {
for (const alg of AEAD_PREFERENCE) {
if (offer.includes(alg) && supported.includes(alg)) return alg
}
return null
}
// ---------------------------------------------------------------------------
// CLIENT (browser, P6)
// ---------------------------------------------------------------------------
export interface ClientHandshakeWithPhase extends ClientHandshake {
readonly phase: HandshakePhase
}
export interface CreateClientHandshakeDeps {
readonly aeadOffer: readonly AeadAlg[]
readonly deviceAuthProofProvider: DeviceAuthProofProvider
readonly verifier: HostVerifier
readonly pinStore: DevicePinStore
readonly hostId: string
}
class ClientHandshakeImpl implements ClientHandshakeWithPhase {
#phase: HandshakePhase = 'init'
#eph: EphemeralKeyPair | null = null
#clientNonce: Uint8Array | null = null
constructor(private readonly deps: CreateClientHandshakeDeps) {}
get phase(): HandshakePhase {
return this.#phase
}
async start(): Promise<ClientHello> {
if (this.#phase !== 'init') {
throw new HandshakeStateError(`start() illegal in phase ${this.#phase}`)
}
const eph = await generateEphemeralKeyPair()
const clientNonce = randomBytes(NONCE_BYTES)
const deviceAuthProof = await this.deps.deviceAuthProofProvider.proofFor(this.deps.hostId, {
clientEphPub: eph.publicKey,
clientNonce,
})
this.#eph = eph
this.#clientNonce = clientNonce
this.#phase = 'awaitHostHello'
return {
clientEphPub: eph.publicKey,
clientNonce,
aeadOffer: this.deps.aeadOffer,
deviceAuthProof,
}
}
async onHostHello(msg: HostHello, agentPubkey: Uint8Array): Promise<HandshakeResult> {
if (this.#phase !== 'awaitHostHello' || !this.#eph || !this.#clientNonce) {
throw new HandshakeStateError(`onHostHello() illegal in phase ${this.#phase}`)
}
const parsed = HostHelloSchema.parse(msg)
// (a)+(b) Host-key sourcing: recompute fpr from the REGISTRY pubkey; assert it matches BOTH the
// relay-carried enrollFpr string AND the browser pin. Never a string-only fallback.
const computedFpr = computeEnrollFpr(agentPubkey)
if (!verifyPinnedFingerprint(agentPubkey, parsed.enrollFpr)) {
this.#phase = 'aborted'
throw new FingerprintMismatchError('host_hello enrollFpr != recomputed registry fpr')
}
const pin = await resolvePin(this.deps.pinStore, this.deps.hostId, agentPubkey)
if (pin.outcome === 'mismatch') {
this.#phase = 'aborted'
throw new FingerprintMismatchError('pinned fingerprint mismatch (no auto-repin)')
}
if (!this.deps.aeadOffer.includes(parsed.aeadChoice)) {
this.#phase = 'aborted'
throw new HandshakeStateError('host chose an AEAD alg not in the client offer')
}
const transcript = buildTranscript({
clientEphPub: this.#eph.publicKey,
clientNonce: this.#clientNonce,
aeadOffer: this.deps.aeadOffer,
hostEphPub: parsed.hostEphPub,
hostNonce: parsed.hostNonce,
aeadChoice: parsed.aeadChoice,
enrollFpr: computedFpr,
})
// (c) Verify signature against the REGISTRY pubkey (never a relay-supplied value).
const ok = await this.deps.verifier.verify(agentPubkey, transcript, parsed.sig)
if (!ok) {
this.#phase = 'aborted'
throw new FingerprintMismatchError('host_hello signature invalid')
}
// TOFU: record the pin only after a fully valid host_hello.
if (pin.outcome === 'tofu-first-use') {
await this.deps.pinStore.pin(this.deps.hostId, computedFpr)
}
const shared = await deriveSharedSecret(this.#eph.privateKey, parsed.hostEphPub)
const keys = await deriveSessionKeys(
shared,
this.#clientNonce,
parsed.hostNonce,
parsed.aeadChoice,
)
this.#phase = 'established'
return { keys, aead: parsed.aeadChoice, transcript }
}
}
export function createClientHandshake(deps: CreateClientHandshakeDeps): ClientHandshakeWithPhase {
return new ClientHandshakeImpl(deps)
}
// ---------------------------------------------------------------------------
// HOST (agent, P2)
// ---------------------------------------------------------------------------
export interface HostHandshake {
readonly phase: HandshakePhase
readonly result: HandshakeResult | null
onClientHello(msg: ClientHello): Promise<HostHello>
}
export interface CreateHostHandshakeDeps {
readonly signer: HostSigner
readonly agentPubkey: Uint8Array
readonly supported: readonly AeadAlg[]
readonly verifyDeviceProof: (
proof: string,
binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array },
) => Promise<boolean>
}
class HostHandshakeImpl implements HostHandshake {
#phase: HandshakePhase = 'init'
#result: HandshakeResult | null = null
constructor(private readonly deps: CreateHostHandshakeDeps) {}
get phase(): HandshakePhase {
return this.#phase
}
get result(): HandshakeResult | null {
return this.#result
}
async onClientHello(msg: ClientHello): Promise<HostHello> {
if (this.#phase !== 'init') {
throw new HandshakeStateError(`onClientHello() illegal in phase ${this.#phase}`)
}
const parsed = ClientHelloSchema.parse(msg)
// INV3 gate: proof MUST be bound to this handshake's ephemeral material.
const proofOk = await this.deps.verifyDeviceProof(parsed.deviceAuthProof, {
clientEphPub: parsed.clientEphPub,
clientNonce: parsed.clientNonce,
})
if (!proofOk) {
this.#phase = 'aborted'
throw new HandshakeStateError('deviceAuthProof failed verification (deny-by-default)')
}
const aeadChoice = negotiate(parsed.aeadOffer, this.deps.supported)
if (!aeadChoice) {
this.#phase = 'aborted'
throw new HandshakeStateError('no common AEAD algorithm')
}
const eph = await generateEphemeralKeyPair()
const hostNonce = randomBytes(NONCE_BYTES)
const enrollFpr = computeEnrollFpr(this.deps.agentPubkey)
const transcript = buildTranscript({
clientEphPub: parsed.clientEphPub,
clientNonce: parsed.clientNonce,
aeadOffer: parsed.aeadOffer,
hostEphPub: eph.publicKey,
hostNonce,
aeadChoice,
enrollFpr,
})
const sig = await this.deps.signer.sign(transcript)
const shared = await deriveSharedSecret(eph.privateKey, parsed.clientEphPub)
const keys = await deriveSessionKeys(shared, parsed.clientNonce, hostNonce, aeadChoice)
this.#result = { keys, aead: aeadChoice, transcript }
this.#phase = 'established'
return { hostEphPub: eph.publicKey, hostNonce, aeadChoice, enrollFpr, sig }
}
}
export function createHostHandshake(deps: CreateHostHandshakeDeps): HostHandshake {
return new HostHandshakeImpl(deps)
}

53
relay-e2e/src/hkdf.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* T6 — HKDF session-key derivation with DIRECTION-SPLIT subkeys.
*
* CRITICAL (finding #1): one shared AEAD key across both directions + a deterministic seq-nonce
* ⇒ client-seq=N and host-seq=N reuse an identical (key, nonce) ⇒ GCM catastrophe, and lets a
* relay reflect a c2h frame back as h2c. Fix: derive the §4.4 master, then HKDF-Expand it into two
* disjoint subkeys under the FROZEN labels so client-write/host-read share one key and
* host-write/client-read share the OTHER, never the same.
*/
import { hkdf } from '@noble/hashes/hkdf'
import { sha256 } from '@noble/hashes/sha2'
import type { AeadAlg, DirectionalKeys } from 'relay-contracts'
import { HKDF_INFO, HKDF_INFO_C2H, HKDF_INFO_H2C, AEAD_KEY_BYTES } from 'relay-contracts'
import { concatBytes } from 'relay-contracts'
import { wrapAeadKey } from './aead-key.js'
const encoder = new TextEncoder()
const EMPTY = new Uint8Array(0)
/** §4.4 master secret: HKDF-SHA256(ikm=sharedSecret, salt=clientNonce‖hostNonce, info=HKDF_INFO). */
export function deriveMaster(
sharedSecret: Uint8Array,
clientNonce: Uint8Array,
hostNonce: Uint8Array,
): Uint8Array {
const salt = concatBytes([clientNonce, hostNonce])
return hkdf(sha256, sharedSecret, salt, encoder.encode(HKDF_INFO), AEAD_KEY_BYTES)
}
/** Expand the master into a single direction subkey under the given frozen label. */
function expandSubkey(master: Uint8Array, label: string): Uint8Array {
return hkdf(sha256, master, EMPTY, encoder.encode(label), AEAD_KEY_BYTES)
}
/**
* Derive the direction-split `DirectionalKeys` (c2h / h2c) for a session. `salt` is BOTH nonces in
* fixed client‖host order, so swapping the nonce roles yields different keys; the two subkeys are
* disjoint by construction (distinct frozen labels).
*/
export async function deriveSessionKeys(
sharedSecret: Uint8Array,
clientNonce: Uint8Array,
hostNonce: Uint8Array,
alg: AeadAlg,
): Promise<DirectionalKeys> {
const master = deriveMaster(sharedSecret, clientNonce, hostNonce)
const c2h = expandSubkey(master, HKDF_INFO_C2H)
const h2c = expandSubkey(master, HKDF_INFO_H2C)
return {
c2h: wrapAeadKey(c2h, alg, 'c2h'),
h2c: wrapAeadKey(h2c, alg, 'h2c'),
}
}

60
relay-e2e/src/index.ts Normal file
View File

@@ -0,0 +1,60 @@
/**
* T12 — public barrel for relay-e2e. Re-exports the crypto IMPLEMENTATIONS of the frozen §4.4
* signatures (P4 owns the impls, relay-contracts owns the shapes; INDEX §2.1). No `ws`/`pg`/DOM
* runtime, no xterm/ANSI parser — a pure isomorphic crypto core (INV2/INV11).
*/
// Errors
export * from './errors.js'
// T1 crypto provider
export { getWebCrypto, randomBytes, timingSafeEqual } from './crypto-provider.js'
// T2 AEAD
export { nonceLength, tagLength, importAeadKey, aeadSeal, aeadOpen } from './aead.js'
// T3 envelope codec (frozen shape from relay-contracts, re-exported)
export { encodeEnvelope, decodeEnvelope, nonceForSeq, MAX_FRAME_BYTES } from './envelope.js'
// T4 fingerprint / TOFU
export {
computeEnrollFpr,
verifyPinnedFingerprint,
resolvePin,
type PinOutcome,
type ResolvePinResult,
} from './fingerprint.js'
// T5 X25519
export { generateEphemeralKeyPair, deriveSharedSecret, type EphemeralKeyPair } from './x25519.js'
// T6 HKDF direction-split
export { deriveSessionKeys, deriveMaster } from './hkdf.js'
// T7 sequence guard
export { SequenceGuard } from './sequence.js'
// T8 handshake
export {
createClientHandshake,
createHostHandshake,
AEAD_PREFERENCE,
type HandshakePhase,
type HostSigner,
type HostVerifier,
type ClientHandshakeWithPhase,
type CreateClientHandshakeDeps,
type HostHandshake,
type CreateHostHandshakeDeps,
} from './handshake.js'
export { nobleEd25519Verifier, nobleEd25519Signer } from './ed25519.js'
export { buildTranscript, type TranscriptParts } from './transcript.js'
// T9 session (frozen §4.4 sealFrame/openFrame/createE2ESession)
export { sealFrame, openFrame, createE2ESession } from './session.js'
// T10 recoverable replay content-key
export { deriveContentKey, sealReplayFrame, openReplayCiphertext } from './replay-key.js'
// T11 multi-device adapters (buildClientHandshake = frozen §4.4 factory)
export { buildClientHandshake, MemoryDevicePinStore } from './keystore.js'

53
relay-e2e/src/keystore.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* T11 — multi-device key adapters (authenticated channel, NOT a QR).
*
* 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` (P5) and independently re-derives the recoverable
* `K_content` (T10). No plaintext key material ever transits a shoulder-surfable/relay-readable
* channel. `buildClientHandshake` is the frozen §4.4 assembly: it wires the injected
* AuthorizedDeviceContext into `createClientHandshake` with the default Ed25519 verifier.
*
* There is deliberately NO function here (or anywhere in the public surface) that serializes a
* session/content key to a transferable string or QR blob (guards a plaintext-QR regression).
*/
import type {
AeadAlg,
AuthorizedDeviceContext,
ClientHandshake,
DevicePinStore,
} from 'relay-contracts'
import { nobleEd25519Verifier } from './ed25519.js'
import { createClientHandshake } from './handshake.js'
/** Frozen §4.4 factory P6 calls: build a client handshake from an authorized device context. */
export function buildClientHandshake(
ctx: AuthorizedDeviceContext,
hostId: string,
aeadOffer: readonly AeadAlg[],
): ClientHandshake {
return createClientHandshake({
aeadOffer,
deviceAuthProofProvider: ctx.deviceAuthProofProvider,
verifier: nobleEd25519Verifier(),
pinStore: ctx.pinStore,
hostId,
})
}
/**
* In-memory TOFU pin store (a `DevicePinStore` adapter). P6 injects a persistent implementation
* (localStorage/IndexedDB); this is the injectable default for Node hosts and tests. `pin` sets a
* value even if one exists — changing an existing pin is the CALLER's explicit decision (the
* handshake never auto-repins on a mismatch; see T8).
*/
export class MemoryDevicePinStore implements DevicePinStore {
#pins = new Map<string, string>()
async get(hostId: string): Promise<string | null> {
return this.#pins.get(hostId) ?? null
}
async pin(hostId: string, enrollFpr: string): Promise<void> {
this.#pins.set(hostId, enrollFpr)
}
}

View File

@@ -0,0 +1,45 @@
/**
* T10 — recoverable replay content-key (FROZEN §4.4/§4.5 FIX 3 surface; P4 implements it).
*
* §4.4 ephemeral session subkeys give forward secrecy but a FRESH key each reconnect, so ciphertext
* in the base-app ring buffer would be undecryptable after a refresh. `K_content` is a RECOVERABLE
* per-session key derived from the HOST-SCOPED `hostContentSecret` (NOT a raw account secret — the
* agent is unattended; a host-scoped, per-host-revocable secret bounds the blast radius, INV4/INV12).
* Any authorized device that obtains `hostContentSecret` re-derives the identical `K_content` and
* decrypts replayed ciphertext; the relay still sees only ciphertext (INV2).
*
* Frame routing (frozen, P2/P6 honor it): only replay-bound host→client output is additionally
* sealed under `K_content`; client→host input is NEVER sealed under it.
*/
import { hkdf } from '@noble/hashes/hkdf'
import { sha256 } from '@noble/hashes/sha2'
import type { AeadKey, E2EEnvelope, ReplayKeyParams } from 'relay-contracts'
import { AEAD_KEY_BYTES, REPLAY_KDF_INFO } from 'relay-contracts'
import { wrapAeadKey } from './aead-key.js'
import { decodeEnvelope } from './envelope.js'
import { openFrame, sealFrame } from './session.js'
const encoder = new TextEncoder()
/** HKDF(hostContentSecret, salt=sessionId, info=REPLAY_KDF_INFO) → per-session, per-host content key. */
export function deriveContentKey(p: ReplayKeyParams): AeadKey {
const raw = hkdf(
sha256,
p.hostContentSecret,
encoder.encode(p.sessionId),
encoder.encode(REPLAY_KDF_INFO),
AEAD_KEY_BYTES,
)
return wrapAeadKey(raw, p.alg, 'replay')
}
/** Agent (P2) seals a replay-bound host→client frame under K_content. */
export function sealReplayFrame(k: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope {
return sealFrame(k, seq, plaintext)
}
/** Browser (P6) decrypts a stored replay/preview frame under K_content. */
export function openReplayCiphertext(k: AeadKey, dataPayload: Uint8Array): Uint8Array {
const env = decodeEnvelope(dataPayload)
return openFrame(k, env, env.seq)
}

61
relay-e2e/src/sequence.ts Normal file
View File

@@ -0,0 +1,61 @@
/**
* T7 — sequence guard (anti-replay/injection, INV13).
*
* The transport (§4.1 mux over an ordered WS/TCP stream) delivers per-stream in order, so INV13 is
* enforced as STRICT SUCCESSOR: recv requires `seq === last + 1` (from 0). A duplicate, reorder,
* gap, or rewind all raise `ReplayError`. There is deliberately NO sliding acceptance window.
*/
import { ReplayError } from './errors.js'
const U64_MAX = 2n ** 64n - 1n
export class SequenceGuard {
readonly role: 'send' | 'recv'
#next: bigint = 0n
#lastAccepted: bigint | null = null
constructor(role: 'send' | 'recv') {
this.role = role
}
/** send-side: return the current seq, then increment (strictly monotonic per direction). */
next(): bigint {
if (this.role !== 'send') {
throw new ReplayError('next() called on a recv guard')
}
const seq = this.#next
if (seq > U64_MAX) {
throw new ReplayError('sequence exhausted u64 — re-key required')
}
this.#next += 1n
return seq
}
/** recv-side: require the strict successor; else throw ReplayError (dup/reorder/gap/rewind). */
accept(seq: bigint): void {
if (this.role !== 'recv') {
throw new ReplayError('accept() called on a send guard')
}
if (seq < 0n || seq > U64_MAX) {
throw new ReplayError(`sequence out of u64 range: ${seq}`)
}
const expected = this.#lastAccepted === null ? 0n : this.#lastAccepted + 1n
if (seq !== expected) {
throw new ReplayError(`out-of-order sequence: expected ${expected}, got ${seq}`)
}
this.#lastAccepted = seq
}
get lastAccepted(): bigint | null {
return this.#lastAccepted
}
/** Next expected recv seq (send guards report their next send seq). */
get expected(): bigint {
return this.role === 'send'
? this.#next
: this.#lastAccepted === null
? 0n
: this.#lastAccepted + 1n
}
}

105
relay-e2e/src/session.ts Normal file
View File

@@ -0,0 +1,105 @@
/**
* T9 — session AEAD: frozen §4.4 `sealFrame`/`openFrame` (SYNCHRONOUS) + the stateful directional
* `E2ESession`. Implements the frozen relay-contracts signatures verbatim via the T2 noble AEAD;
* redefines none of them.
*
* Nonce discipline (INV13): the nonce is FULLY DETERMINISTIC — `nonceForSeq(seq, alg)` (no random
* branch). Safe only because the direction split (T6) guarantees `seq` is unique per direction
* subkey. `aad = directionLabel‖seq` binds direction + sequence into the tag, so a frame can't be
* replayed under a different seq or reflected across directions.
*/
import type {
AeadKey,
E2EEnvelope,
E2ESession,
HandshakeResult,
SessionRole,
} from 'relay-contracts'
import { writeUint64BE } from 'relay-contracts'
import { aeadOpen, aeadSeal } from './aead.js'
import { unwrapAeadKey } from './aead-key.js'
import { timingSafeEqual } from './crypto-provider.js'
import { decodeEnvelope, encodeEnvelope, nonceForSeq } from './envelope.js'
import { AeadOpenError, ReplayError } from './errors.js'
import { SequenceGuard } from './sequence.js'
const encoder = new TextEncoder()
/** aad = directionLabel‖seq(u64 BE) — binds both direction and sequence into the AEAD tag. */
function buildAad(label: string, seq: bigint): Uint8Array {
const labelBytes = encoder.encode(label)
const out = new Uint8Array(labelBytes.length + 8)
out.set(labelBytes, 0)
writeUint64BE(out, labelBytes.length, seq)
return out
}
/** Frozen §4.4 seal: deterministic nonce = f(seq), aad = directionLabel‖seq. */
export function sealFrame(key: AeadKey, seq: bigint, plaintext: Uint8Array): E2EEnvelope {
const { alg, aadLabel } = unwrapAeadKey(key)
const nonce = nonceForSeq(seq, alg)
const aad = buildAad(aadLabel, seq)
const { ciphertext, tag } = aeadSeal(key, nonce, plaintext, aad)
return { seq, nonce, ciphertext, tag }
}
/** Frozen §4.4 open: require env.seq === expectedSeq (INV13), recompute nonce, AEAD-verify. */
export function openFrame(key: AeadKey, env: E2EEnvelope, expectedSeq: bigint): Uint8Array {
if (env.seq !== expectedSeq) {
throw new ReplayError(`unexpected seq: expected ${expectedSeq}, got ${env.seq}`)
}
const { alg, aadLabel } = unwrapAeadKey(key)
const nonce = nonceForSeq(expectedSeq, alg)
if (!timingSafeEqual(nonce, env.nonce)) {
throw new AeadOpenError('nonce does not match deterministic f(seq)')
}
const aad = buildAad(aadLabel, expectedSeq)
return aeadOpen(key, nonce, env.ciphertext, env.tag, aad)
}
interface Directional {
writeKey: AeadKey
readKey: AeadKey
}
function directionFor(role: SessionRole, result: HandshakeResult): Directional {
// c2h = client→host (client seals / host opens); h2c = host→client.
return role === 'client'
? { writeKey: result.keys.c2h, readKey: result.keys.h2c }
: { writeKey: result.keys.h2c, readKey: result.keys.c2h }
}
class E2ESessionImpl implements E2ESession {
readonly role: SessionRole
#dir: Directional
#sendGuard = new SequenceGuard('send')
#recvGuard = new SequenceGuard('recv')
constructor(role: SessionRole, result: HandshakeResult) {
this.role = role
this.#dir = directionFor(role, result)
}
seal(plaintext: Uint8Array): Uint8Array {
const seq = this.#sendGuard.next()
return encodeEnvelope(sealFrame(this.#dir.writeKey, seq, plaintext))
}
open(dataPayload: Uint8Array): Uint8Array {
const env = decodeEnvelope(dataPayload)
this.#recvGuard.accept(env.seq)
return openFrame(this.#dir.readKey, env, env.seq)
}
/** Re-key on refresh/reconnect: install new subkeys and reset BOTH seq guards to 0. */
rederive(next: HandshakeResult): void {
this.#dir = directionFor(this.role, next)
this.#sendGuard = new SequenceGuard('send')
this.#recvGuard = new SequenceGuard('recv')
}
}
/** Frozen §4.4 factory — build a stateful directional session from a handshake result. */
export function createE2ESession(role: SessionRole, result: HandshakeResult): E2ESession {
return new E2ESessionImpl(role, result)
}

View File

@@ -0,0 +1,41 @@
/**
* Deterministic handshake transcript builder. Both endpoints concatenate the SAME length-prefixed
* fields in the SAME fixed order (client_hello fields, then host_hello fields EXCLUDING `sig`), so
* the transcript the host signs is byte-identical to the one the client verifies. `deviceAuthProof`
* is excluded (it is verified separately and is not part of the signed key-agreement transcript).
*/
import type { AeadAlg } from 'relay-contracts'
import { concatBytes, writeUint32BE } from 'relay-contracts'
const encoder = new TextEncoder()
const DOMAIN = encoder.encode('relay-e2e/transcript/v1')
function lp(chunk: Uint8Array): Uint8Array {
const head = new Uint8Array(4)
writeUint32BE(head, 0, chunk.length)
return concatBytes([head, chunk])
}
export interface TranscriptParts {
readonly clientEphPub: Uint8Array
readonly clientNonce: Uint8Array
readonly aeadOffer: readonly AeadAlg[]
readonly hostEphPub: Uint8Array
readonly hostNonce: Uint8Array
readonly aeadChoice: AeadAlg
readonly enrollFpr: string
}
/** Build the byte-identical transcript both sides bind the Ed25519 signature to. */
export function buildTranscript(p: TranscriptParts): Uint8Array {
return concatBytes([
lp(DOMAIN),
lp(p.clientEphPub),
lp(p.clientNonce),
lp(encoder.encode(p.aeadOffer.join(','))),
lp(p.hostEphPub),
lp(p.hostNonce),
lp(encoder.encode(p.aeadChoice)),
lp(encoder.encode(p.enrollFpr)),
])
}

47
relay-e2e/src/x25519.ts Normal file
View File

@@ -0,0 +1,47 @@
/**
* T5 — ephemeral X25519 keygen + ECDH shared secret via WebCrypto.
*
* The private key is a NON-EXTRACTABLE `CryptoKey` (INV5): it never leaves as bytes —
* `exportKey('raw', priv)` rejects. Fresh per handshake (forward secrecy).
*/
import { getWebCrypto } from './crypto-provider.js'
import { E2EError } from './errors.js'
const ALG = 'X25519'
const SHARED_SECRET_BITS = 256
export interface EphemeralKeyPair {
readonly publicKey: Uint8Array
readonly privateKey: CryptoKey
}
/** Generate a fresh X25519 keypair; private key is non-extractable. */
export async function generateEphemeralKeyPair(): Promise<EphemeralKeyPair> {
const subtle = getWebCrypto()
const pair = (await subtle.generateKey({ name: ALG }, false, ['deriveBits'])) as CryptoKeyPair
const rawPub = await subtle.exportKey('raw', pair.publicKey)
return { publicKey: new Uint8Array(rawPub), privateKey: pair.privateKey }
}
/** Derive the 32-byte raw ECDH shared secret from our private key + the peer's raw public key. */
export async function deriveSharedSecret(
privateKey: CryptoKey,
peerPublicKey: Uint8Array,
): Promise<Uint8Array> {
const subtle = getWebCrypto()
// Copy into a fresh ArrayBuffer-backed view so it satisfies DOM `BufferSource` (not SharedArrayBuffer).
const peerRaw = new Uint8Array(peerPublicKey.length)
peerRaw.set(peerPublicKey)
let peer: CryptoKey
try {
peer = await subtle.importKey('raw', peerRaw, { name: ALG }, false, [])
} catch {
throw new E2EError('E2E_BAD_PEER_KEY', 'peer X25519 public key is invalid')
}
try {
const bits = await subtle.deriveBits({ name: ALG, public: peer }, privateKey, SHARED_SECRET_BITS)
return new Uint8Array(bits)
} catch {
throw new E2EError('E2E_ECDH_FAILED', 'X25519 ECDH derivation failed')
}
}