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:
29
control-plane/src/util/bytes.ts
Normal file
29
control-plane/src/util/bytes.ts
Normal 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
|
||||
}
|
||||
119
control-plane/src/util/crypto.ts
Normal file
119
control-plane/src/util/crypto.ts
Normal 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 }
|
||||
44
control-plane/src/util/hash.ts
Normal file
44
control-plane/src/util/hash.ts
Normal 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
|
||||
}
|
||||
}
|
||||
10
control-plane/src/util/ids.ts
Normal file
10
control-plane/src/util/ids.ts
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user