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:
50
relay-auth/src/crypto/ed25519.ts
Normal file
50
relay-auth/src/crypto/ed25519.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Ed25519 primitives over WebCrypto (`globalThis.crypto.subtle`) — no native binary, no external
|
||||
* dependency. Keys are `CryptoKey` (matching the plan's frozen signatures). Node 18.4+ / all
|
||||
* browsers expose Ed25519 in SubtleCrypto.
|
||||
*/
|
||||
const subtle = globalThis.crypto.subtle
|
||||
|
||||
const ED25519 = { name: 'Ed25519' } as const
|
||||
|
||||
/** Coerce to an ArrayBuffer-backed view (WebCrypto's `BufferSource` requires `Uint8Array<ArrayBuffer>`). */
|
||||
function ab(u: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||
const out = new Uint8Array(u.byteLength)
|
||||
out.set(u)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Local key-pair shape (avoids the DOM-only `CryptoKeyPair` global name under the Node lib set). */
|
||||
export interface Ed25519KeyPair {
|
||||
readonly publicKey: CryptoKey
|
||||
readonly privateKey: CryptoKey
|
||||
}
|
||||
|
||||
export async function generateEd25519KeyPair(): Promise<Ed25519KeyPair> {
|
||||
return (await subtle.generateKey(ED25519, true, ['sign', 'verify'])) as Ed25519KeyPair
|
||||
}
|
||||
|
||||
export async function signEd25519(privateKey: CryptoKey, data: Uint8Array): Promise<Uint8Array> {
|
||||
const sig = await subtle.sign(ED25519, privateKey, ab(data))
|
||||
return new Uint8Array(sig)
|
||||
}
|
||||
|
||||
export async function verifyEd25519(
|
||||
publicKey: CryptoKey,
|
||||
signature: Uint8Array,
|
||||
data: Uint8Array,
|
||||
): Promise<boolean> {
|
||||
return subtle.verify(ED25519, publicKey, ab(signature), ab(data))
|
||||
}
|
||||
|
||||
export async function importEd25519PublicRaw(raw: Uint8Array): Promise<CryptoKey> {
|
||||
return subtle.importKey('raw', ab(raw), ED25519, true, ['verify'])
|
||||
}
|
||||
|
||||
export async function exportEd25519PublicRaw(publicKey: CryptoKey): Promise<Uint8Array> {
|
||||
return new Uint8Array(await subtle.exportKey('raw', publicKey))
|
||||
}
|
||||
|
||||
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
||||
return new Uint8Array(await subtle.digest('SHA-256', ab(data)))
|
||||
}
|
||||
101
relay-auth/src/crypto/paseto.ts
Normal file
101
relay-auth/src/crypto/paseto.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Minimal PASETO v4.public token (Ed25519) — the format the plan explicitly RECOMMENDS for the
|
||||
* §4.3 capability token (open-Q #1: "recommend PASETO v4.public: no alg-confusion, no `alg:none`
|
||||
* foot-gun"). Self-contained: no external dependency, no native binary. The header is fixed
|
||||
* (`v4.public.`) so there is NO `alg` field to confuse — the algorithm is Ed25519 by construction.
|
||||
*
|
||||
* INTEGRATION NOTE: the concrete token-format pick (PASETO v4.public vs JWS EdDSA) awaits a
|
||||
* one-line PLAN_RELAY_INDEX §4.3 confirmation (plan §5 open-Q #1). This implements the plan's
|
||||
* stated recommendation; swapping to JWS EdDSA would touch only this file + verify.ts.
|
||||
*/
|
||||
import { encodeBase64UrlBytes, decodeBase64UrlBytes } from 'relay-contracts'
|
||||
import { signEd25519, verifyEd25519 } from './ed25519.js'
|
||||
|
||||
const HEADER = 'v4.public.'
|
||||
const SIG_BYTES = 64
|
||||
const textEncoder = new TextEncoder()
|
||||
const textDecoder = new TextDecoder('utf-8', { fatal: true })
|
||||
|
||||
/** Little-endian uint64 (PASETO PAE length prefix; MSB of the high word is cleared per spec). */
|
||||
function le64(n: number): Uint8Array {
|
||||
const out = new Uint8Array(8)
|
||||
let value = n
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (i === 7) value &= 0x7f
|
||||
out[i] = value & 0xff
|
||||
value = Math.floor(value / 256)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** PASETO Pre-Authentication Encoding (PAE) of a list of byte pieces. */
|
||||
function pae(pieces: readonly Uint8Array[]): Uint8Array {
|
||||
const parts: Uint8Array[] = [le64(pieces.length)]
|
||||
for (const piece of pieces) {
|
||||
parts.push(le64(piece.length))
|
||||
parts.push(piece)
|
||||
}
|
||||
let total = 0
|
||||
for (const p of parts) total += p.length
|
||||
const out = new Uint8Array(total)
|
||||
let off = 0
|
||||
for (const p of parts) {
|
||||
out.set(p, off)
|
||||
off += p.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||||
const out = new Uint8Array(a.length + b.length)
|
||||
out.set(a, 0)
|
||||
out.set(b, a.length)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Sign an arbitrary JSON-serializable claims object into a `v4.public.` token string. */
|
||||
export async function signPaseto(claims: unknown, privateKey: CryptoKey): Promise<string> {
|
||||
const message = textEncoder.encode(JSON.stringify(claims))
|
||||
const header = textEncoder.encode(HEADER)
|
||||
const m2 = pae([header, message, new Uint8Array(0), new Uint8Array(0)])
|
||||
const sig = await signEd25519(privateKey, m2)
|
||||
return HEADER + encodeBase64UrlBytes(concat(message, sig))
|
||||
}
|
||||
|
||||
export class PasetoError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'PasetoError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify a `v4.public.` token's Ed25519 signature and return the decoded JSON claims. */
|
||||
export async function verifyPaseto(token: string, publicKey: CryptoKey): Promise<unknown> {
|
||||
if (!token.startsWith(HEADER)) throw new PasetoError('bad PASETO header')
|
||||
const body = decodeBase64UrlBytes(token.slice(HEADER.length))
|
||||
if (body.length < SIG_BYTES) throw new PasetoError('truncated PASETO body')
|
||||
const message = body.subarray(0, body.length - SIG_BYTES)
|
||||
const sig = body.subarray(body.length - SIG_BYTES)
|
||||
const header = textEncoder.encode(HEADER)
|
||||
const m2 = pae([header, message, new Uint8Array(0), new Uint8Array(0)])
|
||||
const ok = await verifyEd25519(publicKey, sig, m2)
|
||||
if (!ok) throw new PasetoError('signature verification failed')
|
||||
try {
|
||||
return JSON.parse(textDecoder.decode(message))
|
||||
} catch {
|
||||
throw new PasetoError('claims are not valid JSON')
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode claims WITHOUT verifying the signature (only safe AFTER a prior verify — e.g. read exp). */
|
||||
export function peekPasetoClaims(token: string): unknown {
|
||||
if (!token.startsWith(HEADER)) throw new PasetoError('bad PASETO header')
|
||||
const body = decodeBase64UrlBytes(token.slice(HEADER.length))
|
||||
if (body.length < SIG_BYTES) throw new PasetoError('truncated PASETO body')
|
||||
const message = body.subarray(0, body.length - SIG_BYTES)
|
||||
try {
|
||||
return JSON.parse(textDecoder.decode(message))
|
||||
} catch {
|
||||
throw new PasetoError('claims are not valid JSON')
|
||||
}
|
||||
}
|
||||
24
relay-auth/src/crypto/thumbprint.ts
Normal file
24
relay-auth/src/crypto/thumbprint.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* RFC 7638 / RFC 8037 JWK thumbprint for an Ed25519 (OKP) public key — the `cnf.jkt`
|
||||
* proof-of-possession binding (DPoP-style, Finding-4). Members are serialized in the
|
||||
* REQUIRED lexicographic order `crv,kty,x` with no whitespace.
|
||||
*/
|
||||
import { encodeBase64UrlBytes } from 'relay-contracts'
|
||||
import { sha256 } from './ed25519.js'
|
||||
|
||||
/** JWK `x` value (base64url of the 32-byte raw Ed25519 public key). */
|
||||
export function ed25519PublicJwkX(rawPublic: Uint8Array): string {
|
||||
return encodeBase64UrlBytes(rawPublic)
|
||||
}
|
||||
|
||||
/** Canonical Ed25519 public JWK JSON (RFC 7638 member ordering: crv, kty, x). */
|
||||
export function ed25519PublicJwkJson(rawPublic: Uint8Array): string {
|
||||
return JSON.stringify({ crv: 'Ed25519', kty: 'OKP', x: ed25519PublicJwkX(rawPublic) })
|
||||
}
|
||||
|
||||
/** base64url(SHA-256(canonical-JWK)) — the `jkt` thumbprint. */
|
||||
export async function jwkThumbprint(rawPublic: Uint8Array): Promise<string> {
|
||||
const json = ed25519PublicJwkJson(rawPublic)
|
||||
const digest = await sha256(new TextEncoder().encode(json))
|
||||
return encodeBase64UrlBytes(digest)
|
||||
}
|
||||
Reference in New Issue
Block a user