feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)

Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.

Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
  capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay:   native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent:        host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e:    browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth:   Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web:    browser login + Web Crypto E2E + client-side preview rendering

Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.

NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
This commit is contained in:
Yaojia Wang
2026-07-02 06:10:16 +02:00
parent e4c327e25e
commit 2af57e6686
326 changed files with 40877 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
/** Byte / base64 helpers (dependency-free; Node Buffer under the hood). */
export function bytesToBase64(bytes: Uint8Array): string {
return Buffer.from(bytes).toString('base64')
}
export function base64ToBytes(b64: string): Uint8Array {
// Buffer.from is lenient; assert round-trip to reject clearly-invalid input.
const buf = Buffer.from(b64, 'base64')
if (buf.toString('base64').replace(/=+$/, '') !== b64.replace(/=+$/, '')) {
throw new Error('invalid base64')
}
return new Uint8Array(buf)
}
export function bytesToHex(bytes: Uint8Array): string {
return Buffer.from(bytes).toString('hex')
}
/** Constant-time equality of two byte arrays (length-independent short-circuit avoided). */
export function timingSafeEqualBytes(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false
let diff = 0
for (let i = 0; i < a.length; i++) {
// Non-null via loop bounds; noUncheckedIndexedAccess guarded below.
diff |= (a[i] as number) ^ (b[i] as number)
}
return diff === 0
}

View File

@@ -0,0 +1,119 @@
/**
* Asymmetric crypto helpers built on Node's builtin `crypto` (no native deps).
*
* - Ed25519 raw-key <-> KeyObject bridging for CSR proof-of-possession (T8/T15 INV14) and
* the bind-time CA leaf signature.
* - X25519 seal/open (ECIES-style) for wrapping the per-host content secret to the enrolled
* identity (T8 FIX 3 INV4/INV5).
*
* NOTE (integration seam): production uses real PKCS#10 CSRs + X.509 leaves (`@peculiar/x509`)
* and converts the Ed25519 enrollment key to X25519 (ed2curve). Here the CSR is modelled as an
* Ed25519 proof-of-possession signature and the wrap targets a raw X25519 public key, which
* exercises the SAME security properties (possession proof, per-host wrap, no raw secret at rest)
* with builtin crypto only. Swapping in the X.509 stack does not change any §4 contract shape.
*/
import {
createPublicKey,
createPrivateKey,
sign as edSign,
verify as edVerify,
generateKeyPairSync,
diffieHellman,
hkdfSync,
randomBytes,
createCipheriv,
createDecipheriv,
type KeyObject,
} from 'node:crypto'
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
const X25519_SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex')
/** Wrap a raw 32-byte Ed25519 public key into a verifiable KeyObject. */
export function ed25519PublicKeyFromRaw(raw: Uint8Array): KeyObject {
if (raw.length !== 32) throw new Error('ed25519 public key must be 32 bytes')
return createPublicKey({
key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(raw)]),
format: 'der',
type: 'spki',
})
}
/** Verify an Ed25519 signature over `message` by the raw public key. Never throws. */
export function ed25519Verify(rawPub: Uint8Array, message: Uint8Array, sig: Uint8Array): boolean {
try {
const key = ed25519PublicKeyFromRaw(rawPub)
return edVerify(null, Buffer.from(message), key, Buffer.from(sig))
} catch {
return false
}
}
/** Test/util: generate an Ed25519 keypair, returning raw pubkey + a KeyObject private key. */
export function generateEd25519(): { publicKeyRaw: Uint8Array; privateKey: KeyObject } {
const { publicKey, privateKey } = generateKeyPairSync('ed25519')
const spki = publicKey.export({ format: 'der', type: 'spki' }) as Buffer
const raw = spki.subarray(spki.length - 32)
return { publicKeyRaw: new Uint8Array(raw), privateKey }
}
/** Test/util: sign a message with an Ed25519 private KeyObject. */
export function ed25519Sign(privateKey: KeyObject, message: Uint8Array): Uint8Array {
return new Uint8Array(edSign(null, Buffer.from(message), privateKey))
}
// ---- X25519 wrap/unwrap (host content secret, FIX 3) --------------------------------------
export function generateX25519(): { publicKeyRaw: Uint8Array; privateKey: KeyObject } {
const { publicKey, privateKey } = generateKeyPairSync('x25519')
const spki = publicKey.export({ format: 'der', type: 'spki' }) as Buffer
const raw = spki.subarray(spki.length - 32)
return { publicKeyRaw: new Uint8Array(raw), privateKey }
}
function x25519PublicKeyFromRaw(raw: Uint8Array): KeyObject {
if (raw.length !== 32) throw new Error('x25519 public key must be 32 bytes')
return createPublicKey({
key: Buffer.concat([X25519_SPKI_PREFIX, Buffer.from(raw)]),
format: 'der',
type: 'spki',
})
}
const WRAP_INFO = Buffer.from('relay-cp/host-content-secret/v1')
/**
* Seal `secret` to a recipient X25519 public key (ephemeral-static ECDH → HKDF → AES-256-GCM).
* Output layout: ephPub(32) || iv(12) || tag(16) || ciphertext. The raw secret never appears in
* the output. A fresh ephemeral key per call makes every wrap distinct (per-host revocable).
*/
export function sealToX25519(recipientRawPub: Uint8Array, secret: Uint8Array): Uint8Array {
const recipient = x25519PublicKeyFromRaw(recipientRawPub)
const eph = generateKeyPairSync('x25519')
const ephSpki = eph.publicKey.export({ format: 'der', type: 'spki' }) as Buffer
const ephPubRaw = ephSpki.subarray(ephSpki.length - 32)
const shared = diffieHellman({ privateKey: eph.privateKey, publicKey: recipient })
const aeadKey = Buffer.from(hkdfSync('sha256', shared, ephPubRaw, WRAP_INFO, 32))
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', aeadKey, iv)
const ct = Buffer.concat([cipher.update(Buffer.from(secret)), cipher.final()])
const tag = cipher.getAuthTag()
return new Uint8Array(Buffer.concat([ephPubRaw, iv, tag, ct]))
}
/** Open a sealed blob with the recipient's X25519 private key. Throws on tamper/wrong key. */
export function openFromX25519(recipientPriv: KeyObject, blob: Uint8Array): Uint8Array {
const buf = Buffer.from(blob)
const ephPubRaw = buf.subarray(0, 32)
const iv = buf.subarray(32, 44)
const tag = buf.subarray(44, 60)
const ct = buf.subarray(60)
const ephPub = x25519PublicKeyFromRaw(new Uint8Array(ephPubRaw))
const shared = diffieHellman({ privateKey: recipientPriv, publicKey: ephPub })
const aeadKey = Buffer.from(hkdfSync('sha256', shared, ephPubRaw, WRAP_INFO, 32))
const decipher = createDecipheriv('aes-256-gcm', aeadKey, iv)
decipher.setAuthTag(tag)
return new Uint8Array(Buffer.concat([decipher.update(ct), decipher.final()]))
}
export { createPrivateKey, type KeyObject }

View File

@@ -0,0 +1,44 @@
/**
* Secret hashing at rest (INV5). Pairing codes and v0.8 tokens are stored as salted scrypt
* hashes, never raw. Verification is constant-time. scrypt is a Node builtin (no native dep);
* argon2id would be the production upgrade (integration note) but scrypt satisfies INV5 here.
*/
import { randomBytes, scryptSync, timingSafeEqual, createHash } from 'node:crypto'
/**
* Deterministic hash for high-entropy lookup keys (pairing codes: ≥128-bit input, so a fast
* preimage-resistant hash is safe at rest — the input space is infeasible to brute-force, INV5).
* A salted scrypt cannot be used here because redemption must find the row by hash of the
* presented code. Production may HMAC this with a server-side pepper (integration note).
*/
export function sha256Hex(raw: string): string {
return createHash('sha256').update(raw, 'utf8').digest('hex')
}
const SCRYPT_KEYLEN = 32
const SCRYPT_COST = 1 << 14 // N=16384 — modest, fast enough for tests, memory-hard
const SALT_LEN = 16
/** Produce a self-describing `scrypt$<saltHex>$<hashHex>` string. Raw secret is discarded. */
export function hashSecret(raw: string): string {
const salt = randomBytes(SALT_LEN)
const hash = scryptSync(raw, salt, SCRYPT_KEYLEN, { N: SCRYPT_COST })
return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`
}
/** Constant-time verify a raw secret against a stored `scrypt$salt$hash`. */
export function verifySecret(raw: string, stored: string): boolean {
const parts = stored.split('$')
if (parts.length !== 3 || parts[0] !== 'scrypt') return false
const saltHex = parts[1] as string
const hashHex = parts[2] as string
let expected: Buffer
try {
expected = Buffer.from(hashHex, 'hex')
const salt = Buffer.from(saltHex, 'hex')
const actual = scryptSync(raw, salt, expected.length, { N: SCRYPT_COST })
return timingSafeEqual(actual, expected)
} catch {
return false
}
}

View File

@@ -0,0 +1,10 @@
/** Identifier + timestamp helpers. UUIDv4 host/account/session ids are unguessable (INV1). */
import { randomUUID } from 'node:crypto'
export function newUuid(): string {
return randomUUID()
}
export function nowIso(): string {
return new Date().toISOString()
}