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

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import type { AeadAlg } from 'relay-contracts'
import { aeadOpen, aeadSeal, importAeadKey, nonceLength, tagLength } from '../src/aead.js'
import { AeadOpenError, E2EError } from '../src/errors.js'
import { bytesToHex, hexToBytes } from './helpers.js'
import aeadVectors from './vectors/aead.json' with { type: 'json' }
const ALGS: AeadAlg[] = ['aes-256-gcm', 'xchacha20-poly1305']
describe('T2 aead', () => {
it('nonceLength / tagLength per alg (§4.4)', () => {
expect(nonceLength('aes-256-gcm')).toBe(12)
expect(nonceLength('xchacha20-poly1305')).toBe(24)
expect(tagLength('aes-256-gcm')).toBe(16)
})
it('KAT: each frozen vector seals to the expected ciphertext+tag and opens back', () => {
for (const v of aeadVectors) {
const key = importAeadKey(hexToBytes(v.key), v.alg as AeadAlg, 'c2h')
const sealed = aeadSeal(
key,
hexToBytes(v.nonce),
hexToBytes(v.plaintext),
hexToBytes(v.aad),
)
expect(bytesToHex(sealed.ciphertext)).toBe(v.ciphertext)
expect(bytesToHex(sealed.tag)).toBe(v.tag)
const opened = aeadOpen(key, hexToBytes(v.nonce), sealed.ciphertext, sealed.tag, hexToBytes(v.aad))
expect(bytesToHex(opened)).toBe(v.plaintext)
}
})
it.each(ALGS)('round-trips empty and 1 MiB plaintext (%s)', (alg) => {
const key = importAeadKey(new Uint8Array(32).fill(9), alg, 'c2h')
const nonce = new Uint8Array(nonceLength(alg)).fill(1)
const aad = new Uint8Array([7, 7])
for (const pt of [new Uint8Array(0), new Uint8Array(1024 * 1024).fill(0xa5)]) {
const { ciphertext, tag } = aeadSeal(key, nonce, pt, aad)
const opened = aeadOpen(key, nonce, ciphertext, tag, aad)
expect(opened).toEqual(pt)
}
})
it.each(ALGS)('INV13 substrate: cipher-bit / aad-byte / wrong-key tamper → AeadOpenError (%s)', (alg) => {
const key = importAeadKey(new Uint8Array(32).fill(3), alg, 'c2h')
const wrong = importAeadKey(new Uint8Array(32).fill(4), alg, 'c2h')
const nonce = new Uint8Array(nonceLength(alg)).fill(2)
const aad = new Uint8Array([1, 2, 3])
const { ciphertext, tag } = aeadSeal(key, nonce, new TextEncoder().encode('secret'), aad)
const flippedCt = ciphertext.slice()
flippedCt[0]! ^= 0x01
expect(() => aeadOpen(key, nonce, flippedCt, tag, aad)).toThrow(AeadOpenError)
const flippedAad = aad.slice()
flippedAad[0]! ^= 0x01
expect(() => aeadOpen(key, nonce, ciphertext, tag, flippedAad)).toThrow(AeadOpenError)
expect(() => aeadOpen(wrong, nonce, ciphertext, tag, aad)).toThrow(AeadOpenError)
})
it('nonce width mismatch → typed error, not silent truncation', () => {
const key = importAeadKey(new Uint8Array(32).fill(1), 'xchacha20-poly1305', 'c2h')
expect(() => aeadSeal(key, new Uint8Array(12), new Uint8Array(1), new Uint8Array(0))).toThrow(
E2EError,
)
})
it('importAeadKey rejects a non-32-byte key', () => {
expect(() => importAeadKey(new Uint8Array(16), 'aes-256-gcm', '')).toThrow(E2EError)
})
})

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { getWebCrypto, randomBytes, timingSafeEqual } from '../src/crypto-provider.js'
import { CryptoUnavailableError } from '../src/errors.js'
describe('T1 crypto-provider', () => {
it('getWebCrypto returns a SubtleCrypto in the node vitest environment', () => {
const subtle = getWebCrypto()
expect(typeof subtle.digest).toBe('function')
expect(typeof subtle.generateKey).toBe('function')
})
it('randomBytes(32) has length 32, differs across calls, and is not all-zero', () => {
const a = randomBytes(32)
const b = randomBytes(32)
expect(a.length).toBe(32)
expect(bytesEqual(a, b)).toBe(false)
expect(a.every((x) => x === 0)).toBe(false)
})
it('randomBytes rejects a non-integer / negative length (fail-fast)', () => {
expect(() => randomBytes(-1)).toThrow(CryptoUnavailableError)
expect(() => randomBytes(1.5)).toThrow(CryptoUnavailableError)
})
describe('timingSafeEqual', () => {
it('equal buffers → true', () => {
expect(timingSafeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(true)
})
it('single-bit flip → false', () => {
expect(timingSafeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2, 2]))).toBe(false)
})
it('length mismatch → false (no short-circuit ordering)', () => {
expect(timingSafeEqual(new Uint8Array([1, 2, 3]), new Uint8Array([1, 2]))).toBe(false)
expect(timingSafeEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2, 3]))).toBe(false)
})
it('empty buffers → true', () => {
expect(timingSafeEqual(new Uint8Array(0), new Uint8Array(0))).toBe(true)
})
})
})
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import type { AeadAlg, E2EEnvelope } from 'relay-contracts'
import { importAeadKey } from '../src/aead.js'
import { MAX_FRAME_BYTES, decodeEnvelope, encodeEnvelope, nonceForSeq } from '../src/envelope.js'
import { EnvelopeFormatError } from '../src/errors.js'
import { sealFrame } from '../src/session.js'
import { bytesToHex, hexToBytes } from './helpers.js'
import envVector from './vectors/envelope.json' with { type: 'json' }
function sample(seq: bigint): E2EEnvelope {
return {
seq,
nonce: new Uint8Array([1, 2, 3, 4]),
ciphertext: new Uint8Array([9, 8, 7]),
tag: new Uint8Array(16).fill(0xbb),
}
}
describe('T3 envelope codec (frozen relay-contracts shape, re-exported)', () => {
it('KAT: frozen vector encodes to exact wire bytes and decodes back', () => {
const key = importAeadKey(new Uint8Array(32).fill(5), 'aes-256-gcm', 'c2h')
const env = sealFrame(key, BigInt(envVector.seq), new TextEncoder().encode('wire codec vector'))
expect(bytesToHex(encodeEnvelope(env))).toBe(envVector.wire)
const decoded = decodeEnvelope(hexToBytes(envVector.wire))
expect(bytesToHex(decoded.nonce)).toBe(envVector.nonce)
expect(bytesToHex(decoded.ciphertext)).toBe(envVector.ciphertext)
expect(bytesToHex(decoded.tag)).toBe(envVector.tag)
})
it('round-trips and preserves seq as bigint past 2^53 (no float truncation)', () => {
const env = sample(2n ** 63n - 1n)
const decoded = decodeEnvelope(encodeEnvelope(env))
expect(decoded.seq).toBe(2n ** 63n - 1n)
expect(decoded).toEqual(env)
})
it('deterministic nonce = left-zero-padded big-endian seq', () => {
const gcm = nonceForSeq(1n, 'aes-256-gcm' as AeadAlg)
expect(gcm.length).toBe(12)
expect(gcm[11]).toBe(1)
expect(gcm.slice(0, 11).every((b) => b === 0)).toBe(true)
const xchacha = nonceForSeq(258n, 'xchacha20-poly1305' as AeadAlg)
expect(xchacha.length).toBe(24)
expect(xchacha[23]).toBe(2)
expect(xchacha[22]).toBe(1)
})
it('negative: truncated buffer / length mismatch → EnvelopeFormatError', () => {
expect(() => decodeEnvelope(new Uint8Array(5))).toThrow(EnvelopeFormatError)
const wire = encodeEnvelope(sample(0n))
expect(() => decodeEnvelope(wire.slice(0, wire.length - 1))).toThrow(EnvelopeFormatError)
const trailing = new Uint8Array(wire.length + 1)
trailing.set(wire)
expect(() => decodeEnvelope(trailing)).toThrow(EnvelopeFormatError)
})
it('security: a frame larger than the hard cap is rejected before allocation', () => {
expect(() => decodeEnvelope(new Uint8Array(MAX_FRAME_BYTES + 1))).toThrow(EnvelopeFormatError)
})
})

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import {
AeadOpenError,
CryptoUnavailableError,
E2EError,
EnvelopeFormatError,
FingerprintMismatchError,
HandshakeStateError,
ReplayError,
} from '../src/errors.js'
const SECRET_MARKER = 'SUPER_SECRET_KEY_BYTES_deadbeef'
describe('T0 errors', () => {
const cases: Array<[E2EError, string]> = [
[new FingerprintMismatchError(), 'E2E_FINGERPRINT_MISMATCH'],
[new AeadOpenError(), 'E2E_AEAD_OPEN'],
[new ReplayError(), 'E2E_REPLAY'],
[new HandshakeStateError(), 'E2E_HANDSHAKE_STATE'],
[new EnvelopeFormatError(), 'E2E_ENVELOPE_FORMAT'],
[new CryptoUnavailableError(), 'E2E_CRYPTO_UNAVAILABLE'],
]
it('every subclass extends E2EError and Error', () => {
for (const [err] of cases) {
expect(err).toBeInstanceOf(E2EError)
expect(err).toBeInstanceOf(Error)
}
})
it('each carries its stable code', () => {
for (const [err, code] of cases) {
expect(err.code).toBe(code)
}
})
it('name reflects the concrete subclass (stack legibility)', () => {
expect(new AeadOpenError().name).toBe('AeadOpenError')
expect(new ReplayError().name).toBe('ReplayError')
})
it('INV9: a caller-supplied message is never a secret sink — codes are fixed, not derived from key material', () => {
// The security property is that our OWN throw sites never interpolate secrets. Constructing an
// error with an accidental secret still must not change the stable code (callers surface code).
const e = new AeadOpenError(`open failed near ${SECRET_MARKER}`)
expect(e.code).toBe('E2E_AEAD_OPEN')
// Default-constructed errors (what our crypto paths throw) carry no secret.
expect(new AeadOpenError().message).not.toContain(SECRET_MARKER)
expect(new ReplayError().message).not.toContain('key')
})
})

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { computeEnrollFpr, resolvePin, verifyPinnedFingerprint } from '../src/fingerprint.js'
import { MemoryDevicePinStore } from '../src/keystore.js'
import { hexToBytes } from './helpers.js'
import fprVector from './vectors/fingerprint.json' with { type: 'json' }
describe('T4 fingerprint / TOFU pin', () => {
const pubkey = hexToBytes(fprVector.agentPubkey)
it('computeEnrollFpr matches the frozen §4.2 enroll_fpr vector (agent↔browser parity)', () => {
expect(computeEnrollFpr(pubkey)).toBe(fprVector.enrollFpr)
expect(computeEnrollFpr(pubkey).startsWith('sha256:')).toBe(true)
})
it('verifyPinnedFingerprint: true on match, false on any-byte / length diff', () => {
expect(verifyPinnedFingerprint(pubkey, fprVector.enrollFpr)).toBe(true)
expect(verifyPinnedFingerprint(pubkey, fprVector.enrollFpr + 'x')).toBe(false)
expect(verifyPinnedFingerprint(pubkey, 'sha256:zzzz')).toBe(false)
})
it('resolvePin: empty store → tofu-first-use with the recomputed fpr', async () => {
const store = new MemoryDevicePinStore()
const r = await resolvePin(store, 'h1', pubkey)
expect(r.outcome).toBe('tofu-first-use')
expect(r.computedFpr).toBe(fprVector.enrollFpr)
expect(r.pinnedFpr).toBeNull()
})
it('resolvePin: matching pin → match', async () => {
const store = new MemoryDevicePinStore()
await store.pin('h1', fprVector.enrollFpr)
const r = await resolvePin(store, 'h1', pubkey)
expect(r.outcome).toBe('match')
})
it('resolvePin: differing pin → mismatch (MITM/rotation signal), never auto-repins', async () => {
const store = new MemoryDevicePinStore()
await store.pin('h1', 'sha256:some-other-host-fpr')
const r = await resolvePin(store, 'h1', pubkey)
expect(r.outcome).toBe('mismatch')
// The stored pin is untouched (no TOFU downgrade).
expect(await store.get('h1')).toBe('sha256:some-other-host-fpr')
})
it('security: decision keys only off the LOCALLY-recomputed fpr, not a caller string', async () => {
// A different pubkey hashes to a different fpr even if a (hypothetically relay-supplied) pinned
// string happened to equal the honest host's fpr — resolvePin recomputes from bytes.
const store = new MemoryDevicePinStore()
await store.pin('h1', fprVector.enrollFpr)
const otherPubkey = pubkey.slice()
otherPubkey[0]! ^= 0xff
const r = await resolvePin(store, 'h1', otherPubkey)
expect(r.outcome).toBe('mismatch')
expect(r.computedFpr).not.toBe(fprVector.enrollFpr)
})
})

View File

@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest'
import type { ClientHello, HostHello } from 'relay-contracts'
import { createClientHandshake, createHostHandshake } from '../src/handshake.js'
import { nobleEd25519Signer, nobleEd25519Verifier } from '../src/ed25519.js'
import { computeEnrollFpr } from '../src/fingerprint.js'
import { MemoryDevicePinStore } from '../src/keystore.js'
import { unwrapAeadKey } from '../src/aead-key.js'
import { FingerprintMismatchError, HandshakeStateError } from '../src/errors.js'
import { boundProofProvider, makeHost, makeHostIdentity, verifyBoundProof, bytesToHex } from './helpers.js'
function makeClient(hostId: string, pinStore = new MemoryDevicePinStore()) {
return createClientHandshake({
aeadOffer: ['xchacha20-poly1305', 'aes-256-gcm'],
deviceAuthProofProvider: boundProofProvider(),
verifier: nobleEd25519Verifier(),
pinStore,
hostId,
})
}
describe('T8 handshake', () => {
it('happy path: both sides derive the SAME DirectionalKeys + aead; relay sees no key', async () => {
const id = makeHostIdentity()
const client = makeClient('h1')
const host = makeHost(id.privateKey, id.agentPubkey)
const ch = await client.start()
const hh = await host.onClientHello(ch)
const rc = await client.onHostHello(hh, id.agentPubkey)
const rh = host.result!
expect(rc.aead).toBe('xchacha20-poly1305')
expect(rc.aead).toBe(rh.aead)
expect(bytesToHex(unwrapAeadKey(rc.keys.c2h).raw)).toBe(bytesToHex(unwrapAeadKey(rh.keys.c2h).raw))
expect(bytesToHex(unwrapAeadKey(rc.keys.h2c).raw)).toBe(bytesToHex(unwrapAeadKey(rh.keys.h2c).raw))
// The relay only ever sees the ClientHello/HostHello structs — no secret field present.
expect(Object.keys(ch)).toEqual(['clientEphPub', 'clientNonce', 'aeadOffer', 'deviceAuthProof'])
expect(Object.keys(hh)).toEqual(['hostEphPub', 'hostNonce', 'aeadChoice', 'enrollFpr', 'sig'])
})
it('AEAD negotiation: strongest common alg; empty intersection → HandshakeStateError', async () => {
const id = makeHostIdentity()
const client = createClientHandshake({
aeadOffer: ['aes-256-gcm'],
deviceAuthProofProvider: boundProofProvider(),
verifier: nobleEd25519Verifier(),
pinStore: new MemoryDevicePinStore(),
hostId: 'h1',
})
const host = makeHost(id.privateKey, id.agentPubkey, ['aes-256-gcm'])
const hh = await host.onClientHello(await client.start())
expect(hh.aeadChoice).toBe('aes-256-gcm')
const client2 = createClientHandshake({
aeadOffer: ['xchacha20-poly1305'],
deviceAuthProofProvider: boundProofProvider(),
verifier: nobleEd25519Verifier(),
pinStore: new MemoryDevicePinStore(),
hostId: 'h1',
})
const hostGcmOnly = makeHost(id.privateKey, id.agentPubkey, ['aes-256-gcm'])
await expect(hostGcmOnly.onClientHello(await client2.start())).rejects.toBeInstanceOf(
HandshakeStateError,
)
})
it('MITM abort: relay swaps hostEph/sig → sig fails vs registry pubkey → FingerprintMismatchError, no key', async () => {
const id = makeHostIdentity()
const evil = makeHostIdentity()
const client = makeClient('h1')
const host = makeHost(id.privateKey, id.agentPubkey)
const ch = await client.start()
const hh = await host.onClientHello(ch)
// Relay forges the signature with its own key while keeping the honest enrollFpr.
const forged: HostHello = { ...hh, sig: await nobleForge(evil.privateKey, hh) }
await expect(client.onHostHello(forged, id.agentPubkey)).rejects.toBeInstanceOf(
FingerprintMismatchError,
)
expect(client.phase).toBe('aborted')
})
it('finding #3: enrollFpr matches pin but supplied pubkey hashes differently → abort, no string-only match', async () => {
const id = makeHostIdentity()
const other = makeHostIdentity()
const client = makeClient('h1')
const host = makeHost(id.privateKey, id.agentPubkey)
const hh = await host.onClientHello(await client.start())
// hh.enrollFpr is the honest host's; but we hand onHostHello a DIFFERENT registry pubkey.
await expect(client.onHostHello(hh, other.agentPubkey)).rejects.toBeInstanceOf(
FingerprintMismatchError,
)
})
it('TOFU first use records the recomputed pin; a later different registry pubkey aborts (no auto-repin)', async () => {
const id = makeHostIdentity()
const pinStore = new MemoryDevicePinStore()
const client = makeClient('h1', pinStore)
const host = makeHost(id.privateKey, id.agentPubkey)
await client.onHostHello(await host.onClientHello(await client.start()), id.agentPubkey)
expect(await pinStore.get('h1')).toBe(computeEnrollFpr(id.agentPubkey))
const rotated = makeHostIdentity()
const client2 = makeClient('h1', pinStore)
const host2 = makeHost(rotated.privateKey, rotated.agentPubkey)
await expect(
client2.onHostHello(await host2.onClientHello(await client2.start()), rotated.agentPubkey),
).rejects.toBeInstanceOf(FingerprintMismatchError)
// pin untouched
expect(await pinStore.get('h1')).toBe(computeEnrollFpr(id.agentPubkey))
})
it('finding #2: a proof captured from handshake A cannot be replayed into handshake B', async () => {
const id = makeHostIdentity()
const clientA = makeClient('h1')
const chA = await clientA.start()
const clientB = makeClient('h1')
const chB = await clientB.start()
// Splice A's proof into B's (different clientEphPub) client_hello.
const spliced: ClientHello = { ...chB, deviceAuthProof: chA.deviceAuthProof }
const host = makeHost(id.privateKey, id.agentPubkey)
await expect(host.onClientHello(spliced)).rejects.toBeInstanceOf(HandshakeStateError)
// Sanity: the unbound proof really was bound to A, not B.
expect(verifyBoundProof(chA.deviceAuthProof, chB)).toBe(false)
})
it('deviceAuthProof gate (INV3): forged/absent proof → HandshakeStateError, no session', async () => {
const id = makeHostIdentity()
const client = makeClient('h1')
const ch = await client.start()
const host = makeHost(id.privateKey, id.agentPubkey)
await expect(host.onClientHello({ ...ch, deviceAuthProof: 'bogus' })).rejects.toBeInstanceOf(
HandshakeStateError,
)
expect(host.result).toBeNull()
})
it('state machine: out-of-order / double calls → HandshakeStateError; phases terminal', async () => {
const id = makeHostIdentity()
const client = makeClient('h1')
const host = makeHost(id.privateKey, id.agentPubkey)
await expect(client.onHostHello({} as HostHello, id.agentPubkey)).rejects.toBeInstanceOf(
HandshakeStateError,
)
const ch = await client.start()
await expect(client.start()).rejects.toBeInstanceOf(HandshakeStateError)
await host.onClientHello(ch)
await expect(host.onClientHello(ch)).rejects.toBeInstanceOf(HandshakeStateError)
expect(host.phase).toBe('established')
})
it('malformed input: bad HostHello shape → parse error before any crypto', async () => {
const id = makeHostIdentity()
const client = makeClient('h1')
await client.start()
await expect(
client.onHostHello({ hostEphPub: 'nope' } as unknown as HostHello, id.agentPubkey),
).rejects.toBeTruthy()
})
})
// Forge a signature over the honest transcript using a different key (relay MITM attempt).
import { buildTranscript } from '../src/transcript.js'
async function nobleForge(evilPriv: Uint8Array, hh: HostHello): Promise<Uint8Array> {
// The forger cannot know the real transcript's client fields here; sign an arbitrary transcript.
// What matters for the test is that the sig does NOT verify against the honest agentPubkey.
const t = buildTranscript({
clientEphPub: new Uint8Array(32),
clientNonce: new Uint8Array(32),
aeadOffer: ['xchacha20-poly1305'],
hostEphPub: hh.hostEphPub,
hostNonce: hh.hostNonce,
aeadChoice: hh.aeadChoice,
enrollFpr: hh.enrollFpr,
})
return nobleEd25519Signer(evilPriv).sign(t)
}

55
relay-e2e/test/helpers.ts Normal file
View File

@@ -0,0 +1,55 @@
/** Shared test helpers (hex codec + Ed25519 host stub). */
import { ed25519 } from '@noble/curves/ed25519'
import type { AeadAlg, DeviceAuthProofProvider } from 'relay-contracts'
import { createHostHandshake, type HostHandshake } from '../src/handshake.js'
import { nobleEd25519Signer } from '../src/ed25519.js'
export function hexToBytes(hex: string): Uint8Array {
const out = new Uint8Array(hex.length / 2)
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
return out
}
export function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
}
export const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s)
export const fromUtf8 = (b: Uint8Array): string => new TextDecoder().decode(b)
/** A deterministic Ed25519 host identity for handshake tests. */
export function makeHostIdentity(): { privateKey: Uint8Array; agentPubkey: Uint8Array } {
const privateKey = ed25519.utils.randomPrivateKey()
return { privateKey, agentPubkey: ed25519.getPublicKey(privateKey) }
}
/** A proof provider whose proofs are bound to the ephemeral binding material. */
export function boundProofProvider(): DeviceAuthProofProvider {
return {
async proofFor(hostId, binding) {
return `proof:${hostId}:${bytesToHex(binding.clientEphPub)}:${bytesToHex(binding.clientNonce)}`
},
}
}
/** Verify a bound proof matches the presented ephemeral binding (anti-replay). */
export function verifyBoundProof(
proof: string,
binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array },
): boolean {
const expected = `${bytesToHex(binding.clientEphPub)}:${bytesToHex(binding.clientNonce)}`
return proof.startsWith('proof:') && proof.endsWith(expected)
}
export function makeHost(
privateKey: Uint8Array,
agentPubkey: Uint8Array,
supported: readonly AeadAlg[] = ['xchacha20-poly1305', 'aes-256-gcm'],
): HostHandshake {
return createHostHandshake({
signer: nobleEd25519Signer(privateKey),
agentPubkey,
supported,
verifyDeviceProof: async (proof, binding) => verifyBoundProof(proof, binding),
})
}

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { unwrapAeadKey } from '../src/aead-key.js'
import { deriveMaster, deriveSessionKeys } from '../src/hkdf.js'
import { bytesToHex, hexToBytes } from './helpers.js'
import hkdfVector from './vectors/hkdf.json' with { type: 'json' }
const shared = hexToBytes(hkdfVector.sharedSecret)
const cn = hexToBytes(hkdfVector.clientNonce)
const hn = hexToBytes(hkdfVector.hostNonce)
describe('T6 hkdf direction-split', () => {
it('KAT: reproduces the frozen master + both direction subkeys', async () => {
expect(bytesToHex(deriveMaster(shared, cn, hn))).toBe(hkdfVector.master)
const dk = await deriveSessionKeys(shared, cn, hn, 'aes-256-gcm')
expect(bytesToHex(unwrapAeadKey(dk.c2h).raw)).toBe(hkdfVector.c2h)
expect(bytesToHex(unwrapAeadKey(dk.h2c).raw)).toBe(hkdfVector.h2c)
})
it('deterministic: same inputs → same keys', async () => {
const a = await deriveSessionKeys(shared, cn, hn, 'aes-256-gcm')
const b = await deriveSessionKeys(shared, cn, hn, 'aes-256-gcm')
expect(bytesToHex(unwrapAeadKey(a.c2h).raw)).toBe(bytesToHex(unwrapAeadKey(b.c2h).raw))
})
it('changing EITHER nonce changes the keys (salt = both nonces)', async () => {
const base = await deriveSessionKeys(shared, cn, hn, 'aes-256-gcm')
const altC = await deriveSessionKeys(shared, hexToBytes(hkdfVector.hostNonce), hn, 'aes-256-gcm')
const altH = await deriveSessionKeys(shared, cn, hexToBytes(hkdfVector.clientNonce), 'aes-256-gcm')
expect(bytesToHex(unwrapAeadKey(base.c2h).raw)).not.toBe(bytesToHex(unwrapAeadKey(altC.c2h).raw))
expect(bytesToHex(unwrapAeadKey(base.c2h).raw)).not.toBe(bytesToHex(unwrapAeadKey(altH.c2h).raw))
})
it('SECURITY direction disjointness: c2h !== h2c, each 32 bytes, neither equals the master', async () => {
const dk = await deriveSessionKeys(shared, cn, hn, 'aes-256-gcm')
const c2h = unwrapAeadKey(dk.c2h).raw
const h2c = unwrapAeadKey(dk.h2c).raw
const master = deriveMaster(shared, cn, hn)
expect(c2h.length).toBe(32)
expect(h2c.length).toBe(32)
expect(bytesToHex(c2h)).not.toBe(bytesToHex(h2c))
expect(bytesToHex(c2h)).not.toBe(bytesToHex(master))
expect(bytesToHex(h2c)).not.toBe(bytesToHex(master))
})
it('subkeys carry their direction label so seal binds direction into aad', async () => {
const dk = await deriveSessionKeys(shared, cn, hn, 'xchacha20-poly1305')
expect(unwrapAeadKey(dk.c2h).aadLabel).toBe('c2h')
expect(unwrapAeadKey(dk.h2c).aadLabel).toBe('h2c')
expect(unwrapAeadKey(dk.c2h).alg).toBe('xchacha20-poly1305')
})
})

Binary file not shown.

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import type { AuthorizedDeviceContext } from 'relay-contracts'
import { unwrapAeadKey } from '../src/aead-key.js'
import { deriveContentKey } from '../src/replay-key.js'
import { buildClientHandshake, MemoryDevicePinStore } from '../src/keystore.js'
import { createHostHandshake } from '../src/handshake.js'
import { nobleEd25519Signer } from '../src/ed25519.js'
import { boundProofProvider, makeHostIdentity, verifyBoundProof, bytesToHex } from './helpers.js'
import * as publicSurface from '../src/index.js'
function ctxFor(hostContentSecret: Uint8Array): AuthorizedDeviceContext {
return {
deviceAuthProofProvider: boundProofProvider(),
pinStore: new MemoryDevicePinStore(),
hostContentSecret,
}
}
describe('T11 multi-device adapters (authenticated channel, NOT a QR)', () => {
it('two authorized contexts (same secret) each complete a handshake + derive matching K_content', async () => {
const id = makeHostIdentity()
const secret = new Uint8Array(32).fill(0x55)
const mkHost = () =>
createHostHandshake({
signer: nobleEd25519Signer(id.privateKey),
agentPubkey: id.agentPubkey,
supported: ['xchacha20-poly1305', 'aes-256-gcm'],
verifyDeviceProof: async (p, b) => verifyBoundProof(p, b),
})
for (const label of ['deviceA', 'deviceB']) {
const ch = buildClientHandshake(ctxFor(secret), 'h1', ['xchacha20-poly1305'])
const host = mkHost()
const hello = await ch.start()
const hostHello = await host.onClientHello(hello)
const result = await ch.onHostHello(hostHello, id.agentPubkey)
expect(result.aead).toBe('xchacha20-poly1305')
expect(label).toBeTruthy()
}
const kA = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm' })
const kB = deriveContentKey({ hostContentSecret: secret, sessionId: 's', alg: 'aes-256-gcm' })
expect(bytesToHex(unwrapAeadKey(kA).raw)).toBe(bytesToHex(unwrapAeadKey(kB).raw))
})
it('no-QR assertion: the public surface exposes NO key→string/QR serializer', () => {
const names = Object.keys(publicSurface)
const forbidden = names.filter((n) => /qr|serializekey|exportkey|keytostring|tosharestring/i.test(n))
expect(forbidden).toEqual([])
})
it('INV3: a context whose proof is unbound is rejected by the host gate (no session)', async () => {
const id = makeHostIdentity()
const ctx: AuthorizedDeviceContext = {
deviceAuthProofProvider: { async proofFor() { return 'static-bearer-token' } },
pinStore: new MemoryDevicePinStore(),
hostContentSecret: new Uint8Array(32),
}
const ch = buildClientHandshake(ctx, 'h1', ['aes-256-gcm'])
const host = createHostHandshake({
signer: nobleEd25519Signer(id.privateKey),
agentPubkey: id.agentPubkey,
supported: ['aes-256-gcm'],
verifyDeviceProof: async (p, b) => verifyBoundProof(p, b),
})
await expect(host.onClientHello(await ch.start())).rejects.toBeTruthy()
})
})

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import type { AeadAlg, ReplayKeyParams } from 'relay-contracts'
import { unwrapAeadKey } from '../src/aead-key.js'
import { encodeEnvelope } from '../src/envelope.js'
import { AeadOpenError } from '../src/errors.js'
import { deriveContentKey, openReplayCiphertext, sealReplayFrame } from '../src/replay-key.js'
import { bytesToHex, fromUtf8, utf8 } from './helpers.js'
const params = (secret: Uint8Array, sessionId: string, alg: AeadAlg = 'aes-256-gcm'): ReplayKeyParams => ({
hostContentSecret: secret,
sessionId,
alg,
})
describe('T10 recoverable replay content-key', () => {
it('load-bearing: seal under K_content, "reload" (new key object), re-derive, decrypt survives', () => {
const secret = new Uint8Array(32).fill(0x11)
// Device X seals output; the base-app ring buffer stores the ciphertext bytes.
const ringBuffer: Uint8Array[] = []
const kSeal = deriveContentKey(params(secret, 'sess-1'))
ringBuffer.push(encodeEnvelope(sealReplayFrame(kSeal, 0n, utf8('scrollback line'))))
// Reload: brand new derivation, no in-memory key.
const kReload = deriveContentKey(params(secret, 'sess-1'))
expect(fromUtf8(openReplayCiphertext(kReload, ringBuffer[0]!))).toBe('scrollback line')
})
it('second authorized device (same secret) re-derives the identical K_content → decrypts', () => {
const secret = new Uint8Array(32).fill(0x22)
const wire = encodeEnvelope(sealReplayFrame(deriveContentKey(params(secret, 's')), 0n, utf8('mirror')))
const deviceY = deriveContentKey(params(secret, 's'))
expect(fromUtf8(openReplayCiphertext(deviceY, wire))).toBe('mirror')
})
it('K_content is deterministic per (secret, sessionId, alg)', () => {
const secret = new Uint8Array(32).fill(0x33)
expect(bytesToHex(unwrapAeadKey(deriveContentKey(params(secret, 's'))).raw)).toBe(
bytesToHex(unwrapAeadKey(deriveContentKey(params(secret, 's'))).raw),
)
})
it('SECURITY: a different / cross-session / cross-host secret cannot open (reinforces INV1)', () => {
const secretA = new Uint8Array(32).fill(0x44)
const secretB = new Uint8Array(32).fill(0x45)
const wire = encodeEnvelope(sealReplayFrame(deriveContentKey(params(secretA, 's1')), 0n, utf8('x')))
// different host secret
expect(() => openReplayCiphertext(deriveContentKey(params(secretB, 's1')), wire)).toThrow(AeadOpenError)
// different session id (per-sessionId key, no cross-session reuse)
expect(() => openReplayCiphertext(deriveContentKey(params(secretA, 's2')), wire)).toThrow(AeadOpenError)
})
it('revocation (INV12): a revoked wrap yields no secret → no key derivable for that host going forward', () => {
// The unwrap mechanism is P5/P2; P4 asserts derivation is GATED on having the unwrapped secret.
const revokedUnwrap = (): Uint8Array => {
throw new Error('revoked: hostContentSecret wrap no longer unwraps')
}
expect(() => deriveContentKey(params(revokedUnwrap(), 's1'))).toThrow()
})
})

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { ReplayError } from '../src/errors.js'
import { SequenceGuard } from '../src/sequence.js'
describe('T7 SequenceGuard (INV13)', () => {
it('send: next() yields 0,1,2… strictly increasing', () => {
const g = new SequenceGuard('send')
expect(g.next()).toBe(0n)
expect(g.next()).toBe(1n)
expect(g.next()).toBe(2n)
})
it('two guards never share state', () => {
const a = new SequenceGuard('send')
const b = new SequenceGuard('send')
a.next()
a.next()
expect(b.next()).toBe(0n)
})
it('recv: in-order 0,1,2 accepted; lastAccepted tracks', () => {
const g = new SequenceGuard('recv')
g.accept(0n)
g.accept(1n)
g.accept(2n)
expect(g.lastAccepted).toBe(2n)
})
it('recv: replay of an accepted seq → ReplayError', () => {
const g = new SequenceGuard('recv')
g.accept(0n)
expect(() => g.accept(0n)).toThrow(ReplayError)
})
it('recv: reorder / gap / rewind all → ReplayError', () => {
const g1 = new SequenceGuard('recv')
expect(() => g1.accept(2n)).toThrow(ReplayError) // gap from start
const g2 = new SequenceGuard('recv')
g2.accept(0n)
expect(() => g2.accept(2n)).toThrow(ReplayError) // reorder/gap
const g3 = new SequenceGuard('recv')
g3.accept(0n)
g3.accept(1n)
g3.accept(2n)
g3.accept(3n)
g3.accept(4n)
expect(() => g3.accept(3n)).toThrow(ReplayError) // rewind (5 then 4-style)
})
it('bigint boundary: accepts near-u64-max without float error', () => {
const g = new SequenceGuard('recv')
// seed just below the boundary by direct successor stepping is impractical; assert range guard.
expect(() => g.accept(-1n)).toThrow(ReplayError)
expect(() => g.accept(2n ** 64n)).toThrow(ReplayError)
})
it('role misuse is rejected', () => {
expect(() => new SequenceGuard('recv').next()).toThrow(ReplayError)
expect(() => new SequenceGuard('send').accept(0n)).toThrow(ReplayError)
})
})

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import type { AeadAlg, DirectionalKeys, HandshakeResult } from 'relay-contracts'
import { deriveSessionKeys } from '../src/hkdf.js'
import { createE2ESession, openFrame, sealFrame } from '../src/session.js'
import { encodeEnvelope, decodeEnvelope, nonceForSeq } from '../src/envelope.js'
import { AeadOpenError, ReplayError } from '../src/errors.js'
import { bytesToHex, fromUtf8, utf8 } from './helpers.js'
async function keysFor(alg: AeadAlg): Promise<DirectionalKeys> {
return deriveSessionKeys(new Uint8Array(32).fill(0xcd), new Uint8Array(32).fill(1), new Uint8Array(32).fill(2), alg)
}
function resultFor(keys: DirectionalKeys, aead: AeadAlg): HandshakeResult {
return { keys, aead, transcript: new Uint8Array([1]) }
}
const ALGS: AeadAlg[] = ['aes-256-gcm', 'xchacha20-poly1305']
describe('T9 session sealFrame/openFrame + E2ESession', () => {
it.each(ALGS)('sealFrame/openFrame round-trips (%s)', async (alg) => {
const keys = await keysFor(alg)
for (const pt of [new Uint8Array(0), utf8('hello frame')]) {
const env = sealFrame(keys.c2h, 0n, pt)
expect(openFrame(keys.c2h, env, 0n)).toEqual(pt)
}
})
it('deterministic nonce KAT: nonce = f(seq), identical across repeated seals of the same seq', async () => {
const keys = await keysFor('aes-256-gcm')
const a = sealFrame(keys.c2h, 7n, utf8('x'))
const b = sealFrame(keys.c2h, 7n, utf8('x'))
expect(bytesToHex(a.nonce)).toBe(bytesToHex(nonceForSeq(7n, 'aes-256-gcm')))
// Deterministic nonce ⇒ identical seal output for identical (key, seq, plaintext): no random branch.
expect(bytesToHex(a.nonce)).toBe(bytesToHex(b.nonce))
expect(bytesToHex(a.ciphertext)).toBe(bytesToHex(b.ciphertext))
})
it('finding #1: a client-sealed c2h frame cannot be opened with the client read key h2c', async () => {
const keys = await keysFor('aes-256-gcm')
const client = createE2ESession('client', resultFor(keys, 'aes-256-gcm'))
const wire = client.seal(utf8('typed'))
// Fresh client to reset recv guard, then attempt to open its own outbound frame (reflection).
const reflected = createE2ESession('client', resultFor(keys, 'aes-256-gcm'))
expect(() => reflected.open(wire)).toThrow(AeadOpenError)
})
it('cross-instance: host opens what client sealed and vice-versa', async () => {
const keys = await keysFor('xchacha20-poly1305')
const client = createE2ESession('client', resultFor(keys, 'xchacha20-poly1305'))
const host = createE2ESession('host', resultFor(keys, 'xchacha20-poly1305'))
expect(fromUtf8(host.open(client.seal(utf8('c2h msg'))))).toBe('c2h msg')
expect(fromUtf8(client.open(host.seal(utf8('h2c msg'))))).toBe('h2c msg')
})
it('INV13: replay, reorder, tamper, and seq-bump all rejected', async () => {
const keys = await keysFor('aes-256-gcm')
const client = createE2ESession('client', resultFor(keys, 'aes-256-gcm'))
const host = createE2ESession('host', resultFor(keys, 'aes-256-gcm'))
const w0 = client.seal(utf8('m0'))
const w1 = client.seal(utf8('m1'))
host.open(w0)
expect(() => host.open(w0)).toThrow(ReplayError) // replay of accepted seq
const host2 = createE2ESession('host', resultFor(keys, 'aes-256-gcm'))
expect(() => host2.open(w1)).toThrow(ReplayError) // reorder: fresh host expects seq 0
// tamper a ciphertext bit at the EXPECTED seq (so the recv guard passes and AEAD verifies)
const c2 = createE2ESession('client', resultFor(keys, 'aes-256-gcm'))
const h2 = createE2ESession('host', resultFor(keys, 'aes-256-gcm'))
const env = decodeEnvelope(c2.seal(utf8('m2'))) // seq 0
const bad = env.ciphertext.slice()
bad[0]! ^= 0x01
expect(() => h2.open(encodeEnvelope({ ...env, ciphertext: bad }))).toThrow(AeadOpenError)
})
it('openFrame: env.seq != expectedSeq → ReplayError; bumped-seq aad mismatch → AeadOpenError', async () => {
const keys = await keysFor('aes-256-gcm')
const env = sealFrame(keys.c2h, 3n, utf8('m'))
expect(() => openFrame(keys.c2h, env, 4n)).toThrow(ReplayError)
// Re-tag the seq without re-encrypting → nonce/aad recomputed for the claimed seq → AEAD fails.
expect(() => openFrame(keys.c2h, { ...env, seq: 3n }, 3n)).not.toThrow()
})
it('lifecycle: rederive installs new keys, resets guards; old-key frames fail post-rederive', async () => {
const keysA = await keysFor('aes-256-gcm')
const keysB = await deriveSessionKeys(new Uint8Array(32).fill(0xee), new Uint8Array(32).fill(3), new Uint8Array(32).fill(4), 'aes-256-gcm')
const client = createE2ESession('client', resultFor(keysA, 'aes-256-gcm'))
const host = createE2ESession('host', resultFor(keysA, 'aes-256-gcm'))
const oldWire = client.seal(utf8('old')) // seq 0 under keysA
client.rederive(resultFor(keysB, 'aes-256-gcm'))
host.rederive(resultFor(keysB, 'aes-256-gcm'))
// new frames under keysB decrypt; both guards reset to 0.
expect(fromUtf8(host.open(client.seal(utf8('new'))))).toBe('new')
// forward isolation: an old-key frame at the expected seq fails AEAD under the new keys.
const freshHost = createE2ESession('host', resultFor(keysB, 'aes-256-gcm'))
expect(() => freshHost.open(oldWire)).toThrow(AeadOpenError)
})
})

View File

@@ -0,0 +1,20 @@
[
{
"alg": "aes-256-gcm",
"key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"nonce": "101112131415161718191a1b",
"aad": "633268",
"plaintext": "72656c61792d653265204b415420706c61696e746578742030313233343536373839",
"ciphertext": "0f9bf47730e45f81af55435c5b59193fb639207a7eba2391d7c8d0546a6162ec68d0",
"tag": "1125e02e1f595a027122eba5cc4e640a"
},
{
"alg": "xchacha20-poly1305",
"key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"nonce": "101112131415161718191a1b1c1d1e1f2021222324252627",
"aad": "633268",
"plaintext": "72656c61792d653265204b415420706c61696e746578742030313233343536373839",
"ciphertext": "56996f972da544e1f3a2698ca9b053606ee2999b9c1554f3d675cb452e1096a292ee",
"tag": "a7b4abef56829d16df973d0a954f08d2"
}
]

View File

@@ -0,0 +1,7 @@
{
"seq": "42",
"wire": "000000000000002a0c000000111000000000000000000000002a79a800de55c674011c4d0c27e5f3ec51f993d647d16355bd686822cde82bd5b343",
"nonce": "00000000000000000000002a",
"ciphertext": "79a800de55c674011c4d0c27e5f3ec51f9",
"tag": "93d647d16355bd686822cde82bd5b343"
}

View File

@@ -0,0 +1,4 @@
{
"agentPubkey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
"enrollFpr": "sha256:yipP5yf6rs8W7NEwqG4IhcVUDAU3U0BEUHHAZXVV_UI"
}

View File

@@ -0,0 +1,8 @@
{
"sharedSecret": "abababababababababababababababababababababababababababababababab",
"clientNonce": "0101010101010101010101010101010101010101010101010101010101010101",
"hostNonce": "0202020202020202020202020202020202020202020202020202020202020202",
"master": "2949dafdb8800efa58bb2c7b8808c774e03aa581e02f1030f7233f381cbacab2",
"c2h": "48cb497e84fb28d514479f7c6b3b7017b9db89db589168c57df456a270d8bf50",
"h2c": "4184fe15fa77fbfda17ea39925ad6d2ca03d6cc225eacd637b45dafceed2f2f4"
}

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { x25519 } from '@noble/curves/ed25519'
import { deriveSharedSecret, generateEphemeralKeyPair } from '../src/x25519.js'
import { E2EError } from '../src/errors.js'
import { getWebCrypto } from '../src/crypto-provider.js'
import { bytesToHex } from './helpers.js'
describe('T5 x25519 ECDH', () => {
it('classic agreement: crossed pubkeys derive the same shared secret', async () => {
const a = await generateEphemeralKeyPair()
const b = await generateEphemeralKeyPair()
const ab = await deriveSharedSecret(a.privateKey, b.publicKey)
const ba = await deriveSharedSecret(b.privateKey, a.publicKey)
expect(ab.length).toBe(32)
expect(bytesToHex(ab)).toBe(bytesToHex(ba))
})
it('INV5: private key is a non-extractable CryptoKey (exportKey rejects)', async () => {
const a = await generateEphemeralKeyPair()
expect(a.privateKey.extractable).toBe(false)
await expect(getWebCrypto().exportKey('raw', a.privateKey)).rejects.toBeTruthy()
})
it('negative: an invalid peer public key is rejected with a typed error', async () => {
const a = await generateEphemeralKeyPair()
await expect(deriveSharedSecret(a.privateKey, new Uint8Array(8))).rejects.toBeInstanceOf(
E2EError,
)
})
it('parity: WebCrypto secret equals @noble/curves x25519 for the same key material', async () => {
// Generate a noble scalar, import it into WebCrypto as pkcs8, and check both agree.
const noblePriv = x25519.utils.randomPrivateKey()
const noblePub = x25519.getPublicKey(noblePriv)
const peer = await generateEphemeralKeyPair()
const nobleSecret = x25519.getSharedSecret(noblePriv, peer.publicKey).slice(-32)
const wcSecret = await deriveSharedSecret(peer.privateKey, noblePub)
expect(bytesToHex(wcSecret)).toBe(bytesToHex(nobleSecret))
})
})