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:
1373
relay-e2e/package-lock.json
generated
Normal file
1373
relay-e2e/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
relay-e2e/package.json
Normal file
34
relay-e2e/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "relay-e2e",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "P4 — browser<->agent end-to-end encryption core (ciphertext-shuttle) for the rendezvous-relay service. Isomorphic (browser + Node >=20) AEAD/ECDH/HKDF crypto implementing the FROZEN relay-contracts §4.4 signatures (sealFrame/openFrame/createE2ESession/buildClientHandshake/deriveContentKey/...). No ws/pg/DOM-runtime/xterm imports. See docs/PLAN_RELAY_E2E.md.",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^1.2.1",
|
||||
"@noble/curves": "^1.8.1",
|
||||
"@noble/hashes": "^1.7.1",
|
||||
"relay-contracts": "file:../relay-contracts",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
38
relay-e2e/src/aead-key.ts
Normal file
38
relay-e2e/src/aead-key.ts
Normal 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
77
relay-e2e/src/aead.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
51
relay-e2e/src/crypto-provider.ts
Normal file
51
relay-e2e/src/crypto-provider.ts
Normal 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
31
relay-e2e/src/ed25519.ts
Normal 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
49
relay-e2e/src/envelope.ts
Normal 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
59
relay-e2e/src/errors.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
53
relay-e2e/src/fingerprint.ts
Normal file
53
relay-e2e/src/fingerprint.ts
Normal 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
246
relay-e2e/src/handshake.ts
Normal 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
53
relay-e2e/src/hkdf.ts
Normal 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
60
relay-e2e/src/index.ts
Normal 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
53
relay-e2e/src/keystore.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
45
relay-e2e/src/replay-key.ts
Normal file
45
relay-e2e/src/replay-key.ts
Normal 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
61
relay-e2e/src/sequence.ts
Normal 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
105
relay-e2e/src/session.ts
Normal 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)
|
||||
}
|
||||
41
relay-e2e/src/transcript.ts
Normal file
41
relay-e2e/src/transcript.ts
Normal 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
47
relay-e2e/src/x25519.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
72
relay-e2e/test/aead.test.ts
Normal file
72
relay-e2e/test/aead.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
45
relay-e2e/test/crypto-provider.test.ts
Normal file
45
relay-e2e/test/crypto-provider.test.ts
Normal 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])
|
||||
}
|
||||
60
relay-e2e/test/envelope.test.ts
Normal file
60
relay-e2e/test/envelope.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
51
relay-e2e/test/errors.test.ts
Normal file
51
relay-e2e/test/errors.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
56
relay-e2e/test/fingerprint.test.ts
Normal file
56
relay-e2e/test/fingerprint.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
174
relay-e2e/test/handshake.test.ts
Normal file
174
relay-e2e/test/handshake.test.ts
Normal 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
55
relay-e2e/test/helpers.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
51
relay-e2e/test/hkdf.test.ts
Normal file
51
relay-e2e/test/hkdf.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
BIN
relay-e2e/test/integration.test.ts
Normal file
BIN
relay-e2e/test/integration.test.ts
Normal file
Binary file not shown.
67
relay-e2e/test/keystore.test.ts
Normal file
67
relay-e2e/test/keystore.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
58
relay-e2e/test/replay-key.test.ts
Normal file
58
relay-e2e/test/replay-key.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
61
relay-e2e/test/sequence.test.ts
Normal file
61
relay-e2e/test/sequence.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
97
relay-e2e/test/session.test.ts
Normal file
97
relay-e2e/test/session.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
20
relay-e2e/test/vectors/aead.json
Normal file
20
relay-e2e/test/vectors/aead.json
Normal 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"
|
||||
}
|
||||
]
|
||||
7
relay-e2e/test/vectors/envelope.json
Normal file
7
relay-e2e/test/vectors/envelope.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"seq": "42",
|
||||
"wire": "000000000000002a0c000000111000000000000000000000002a79a800de55c674011c4d0c27e5f3ec51f993d647d16355bd686822cde82bd5b343",
|
||||
"nonce": "00000000000000000000002a",
|
||||
"ciphertext": "79a800de55c674011c4d0c27e5f3ec51f9",
|
||||
"tag": "93d647d16355bd686822cde82bd5b343"
|
||||
}
|
||||
4
relay-e2e/test/vectors/fingerprint.json
Normal file
4
relay-e2e/test/vectors/fingerprint.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"agentPubkey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
|
||||
"enrollFpr": "sha256:yipP5yf6rs8W7NEwqG4IhcVUDAU3U0BEUHHAZXVV_UI"
|
||||
}
|
||||
8
relay-e2e/test/vectors/hkdf.json
Normal file
8
relay-e2e/test/vectors/hkdf.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"sharedSecret": "abababababababababababababababababababababababababababababababab",
|
||||
"clientNonce": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||
"hostNonce": "0202020202020202020202020202020202020202020202020202020202020202",
|
||||
"master": "2949dafdb8800efa58bb2c7b8808c774e03aa581e02f1030f7233f381cbacab2",
|
||||
"c2h": "48cb497e84fb28d514479f7c6b3b7017b9db89db589168c57df456a270d8bf50",
|
||||
"h2c": "4184fe15fa77fbfda17ea39925ad6d2ca03d6cc225eacd637b45dafceed2f2f4"
|
||||
}
|
||||
40
relay-e2e/test/x25519.test.ts
Normal file
40
relay-e2e/test/x25519.test.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
22
relay-e2e/tsconfig.json
Normal file
22
relay-e2e/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitOverride": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
13
relay-e2e/vitest.config.ts
Normal file
13
relay-e2e/vitest.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/index.ts', '**/*.d.ts'],
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user