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:
111
relay-auth/src/capability/device-proof.ts
Normal file
111
relay-auth/src/capability/device-proof.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* T2 · §4.4 `ClientHello.deviceAuthProof` — the SOLE issuer/verifier (Finding-7 / INDEX §6b).
|
||||
*
|
||||
* P5 mints from the authenticated `AuthenticatedPrincipal` (asserts `principal.accountId`), bound to
|
||||
* `{ clientEphPub, clientNonce }` — a per-handshake, non-replayable binding (NOT a static bearer,
|
||||
* NOT transcriptHash). P4 consumes `verifyDeviceProof` as an injected dep and never re-derives the
|
||||
* account binding. A captured proof replayed into a different freshly-keyed client_hello → false.
|
||||
*/
|
||||
import { encodeBase64UrlBytes, decodeBase64UrlBytes } from 'relay-contracts'
|
||||
import type { AuthenticatedPrincipal } from '../types.js'
|
||||
import { signEd25519, verifyEd25519 } from '../crypto/ed25519.js'
|
||||
import { getVerifyKey } from '../config/keys.js'
|
||||
|
||||
export type DeviceProofBinding = {
|
||||
readonly clientEphPub: Uint8Array
|
||||
readonly clientNonce: Uint8Array
|
||||
}
|
||||
|
||||
const DOMAIN = 'relay-auth/device-auth/v1'
|
||||
const PROOF_MAX_AGE_SEC = 120
|
||||
|
||||
interface DeviceProofPayload {
|
||||
readonly acct: string
|
||||
readonly iat: number
|
||||
}
|
||||
|
||||
function concatAll(pieces: readonly Uint8Array[]): Uint8Array {
|
||||
let total = 0
|
||||
for (const p of pieces) total += p.length
|
||||
const out = new Uint8Array(total)
|
||||
let off = 0
|
||||
for (const p of pieces) {
|
||||
out.set(p, off)
|
||||
off += p.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function le32(n: number): Uint8Array {
|
||||
const out = new Uint8Array(4)
|
||||
out[0] = n & 0xff
|
||||
out[1] = (n >>> 8) & 0xff
|
||||
out[2] = (n >>> 16) & 0xff
|
||||
out[3] = (n >>> 24) & 0xff
|
||||
return out
|
||||
}
|
||||
|
||||
/** Domain-separated signed message = DOMAIN ‖ acct ‖ len(ceph)‖ceph ‖ len(cnon)‖cnon ‖ iat. */
|
||||
function proofMessage(acct: string, binding: DeviceProofBinding, iat: number): Uint8Array {
|
||||
const enc = new TextEncoder()
|
||||
return concatAll([
|
||||
enc.encode(DOMAIN),
|
||||
enc.encode(acct),
|
||||
le32(binding.clientEphPub.length),
|
||||
binding.clientEphPub,
|
||||
le32(binding.clientNonce.length),
|
||||
binding.clientNonce,
|
||||
le32(iat),
|
||||
])
|
||||
}
|
||||
|
||||
/** Client-side minting — backs the §4.4 `DeviceAuthProofProvider.proofFor` P4 injects. */
|
||||
export async function signDeviceAuthProof(
|
||||
principal: AuthenticatedPrincipal,
|
||||
binding: DeviceProofBinding,
|
||||
signingKey: CryptoKey,
|
||||
now: number,
|
||||
): Promise<string> {
|
||||
const payload: DeviceProofPayload = { acct: principal.accountId, iat: now }
|
||||
const message = proofMessage(payload.acct, binding, payload.iat)
|
||||
const sig = await signEd25519(signingKey, message)
|
||||
const p = encodeBase64UrlBytes(new TextEncoder().encode(JSON.stringify(payload)))
|
||||
return `${p}.${encodeBase64UrlBytes(sig)}`
|
||||
}
|
||||
|
||||
/** Host-side verification — the injected `verifyDeviceProof` P4's createHostHandshake calls. */
|
||||
export async function verifyDeviceProof(
|
||||
proof: string,
|
||||
binding: DeviceProofBinding,
|
||||
now: number,
|
||||
): Promise<boolean> {
|
||||
const parts = proof.split('.')
|
||||
if (parts.length !== 2) return false
|
||||
const [p, s] = parts as [string, string]
|
||||
let payload: DeviceProofPayload
|
||||
let sig: Uint8Array
|
||||
try {
|
||||
payload = JSON.parse(new TextDecoder().decode(decodeBase64UrlBytes(p))) as DeviceProofPayload
|
||||
sig = decodeBase64UrlBytes(s)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (typeof payload.acct !== 'string' || payload.acct.length === 0) return false
|
||||
if (typeof payload.iat !== 'number' || Math.abs(now - payload.iat) > PROOF_MAX_AGE_SEC) return false
|
||||
const message = proofMessage(payload.acct, binding, payload.iat)
|
||||
return verifyEd25519(getVerifyKey(), sig, message)
|
||||
}
|
||||
|
||||
/** Read the account a proof asserts (only meaningful after `verifyDeviceProof` returns true). */
|
||||
export function deviceProofAccount(proof: string): string | null {
|
||||
const parts = proof.split('.')
|
||||
if (parts.length !== 2) return null
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
new TextDecoder().decode(decodeBase64UrlBytes(parts[0]!)),
|
||||
) as DeviceProofPayload
|
||||
return typeof payload.acct === 'string' ? payload.acct : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
22
relay-auth/src/capability/errors.ts
Normal file
22
relay-auth/src/capability/errors.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** Typed capability errors carrying a machine-readable reason (no console.log; explicit errors). */
|
||||
export type CapabilityErrorReason =
|
||||
| 'ttl_too_long'
|
||||
| 'ttl_too_short'
|
||||
| 'wildcard_host'
|
||||
| 'empty_sub'
|
||||
| 'malformed'
|
||||
| 'expired'
|
||||
| 'not_yet_valid'
|
||||
| 'aud_mismatch'
|
||||
| 'bad_signature'
|
||||
| 'replayed'
|
||||
| 'no_cnf'
|
||||
|
||||
export class CapabilityError extends Error {
|
||||
readonly reason: CapabilityErrorReason
|
||||
constructor(reason: CapabilityErrorReason, message?: string) {
|
||||
super(message ?? reason)
|
||||
this.name = 'CapabilityError'
|
||||
this.reason = reason
|
||||
}
|
||||
}
|
||||
65
relay-auth/src/capability/issue.ts
Normal file
65
relay-auth/src/capability/issue.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* T2 · §4.3 capability-token ISSUE. `sub`/`host`/`account` come from `IssueArgs.principal`,
|
||||
* never a client field (INV3). Connect-scoped: short TTL 30–60 s (Finding-4 leak blast-radius),
|
||||
* single host (no wildcard), least-privilege rights, DPoP `cnf.jkt` proof-of-possession binding.
|
||||
*/
|
||||
import type { CapabilityRight } from 'relay-contracts'
|
||||
import { CapabilityRightSchema } from 'relay-contracts'
|
||||
import type { AuthenticatedPrincipal } from '../types.js'
|
||||
import { signPaseto } from '../crypto/paseto.js'
|
||||
import { CapabilityError } from './errors.js'
|
||||
|
||||
/** Connect-scoped TTL clamp (Finding-4): 30–60 s; longer values are REFUSED at issue. */
|
||||
export const CONNECT_TOKEN_MIN_TTL_SEC = 30 as const
|
||||
export const CONNECT_TOKEN_MAX_TTL_SEC = 60 as const
|
||||
|
||||
export interface IssueArgs {
|
||||
readonly principal: AuthenticatedPrincipal // sub := principal.accountId (INV3, T1 convention)
|
||||
readonly aud: string // subdomain (Host-confusion guard, INV1)
|
||||
readonly host: string // single host_id, verified owned by principal.accountId
|
||||
readonly rights: readonly CapabilityRight[] // least-privilege subset
|
||||
readonly ttlSeconds: number // clamped to [30, 60]
|
||||
readonly cnfJkt: string // base64url SHA-256 JWK thumbprint of the client's ephemeral public key
|
||||
}
|
||||
|
||||
/** Internal token body = frozen §4.3 fields + the ADDITIVE `cnf.jkt` PoP claim (RFC 7800). */
|
||||
export interface TokenBody {
|
||||
readonly sub: string
|
||||
readonly aud: string
|
||||
readonly host: string
|
||||
readonly rights: readonly CapabilityRight[]
|
||||
readonly iat: number
|
||||
readonly exp: number
|
||||
readonly jti: string
|
||||
readonly cnf: { readonly jkt: string }
|
||||
}
|
||||
|
||||
function randomJti(): string {
|
||||
return globalThis.crypto.randomUUID()
|
||||
}
|
||||
|
||||
export async function issueCapabilityToken(
|
||||
a: IssueArgs,
|
||||
signingKey: CryptoKey,
|
||||
now: number,
|
||||
): Promise<string> {
|
||||
if (a.principal.accountId.length === 0) throw new CapabilityError('empty_sub')
|
||||
if (a.host === '*' || a.host.length === 0) throw new CapabilityError('wildcard_host')
|
||||
if (a.ttlSeconds > CONNECT_TOKEN_MAX_TTL_SEC) throw new CapabilityError('ttl_too_long')
|
||||
for (const r of a.rights) CapabilityRightSchema.parse(r)
|
||||
if (a.rights.length === 0) throw new CapabilityError('malformed', 'rights must be non-empty')
|
||||
if (a.cnfJkt.length === 0) throw new CapabilityError('no_cnf')
|
||||
|
||||
const ttl = Math.max(a.ttlSeconds, CONNECT_TOKEN_MIN_TTL_SEC)
|
||||
const body: TokenBody = {
|
||||
sub: a.principal.accountId, // INV3: the account is the authoritative cross-tenant unit
|
||||
aud: a.aud,
|
||||
host: a.host,
|
||||
rights: [...new Set(a.rights)],
|
||||
iat: now,
|
||||
exp: now + ttl,
|
||||
jti: randomJti(),
|
||||
cnf: { jkt: a.cnfJkt },
|
||||
}
|
||||
return signPaseto(body, signingKey)
|
||||
}
|
||||
175
relay-auth/src/capability/verify.ts
Normal file
175
relay-auth/src/capability/verify.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* T2 · §4.3 capability-token VERIFY + DPoP proof-of-possession.
|
||||
*
|
||||
* `verifyCapabilityToken` keeps the FROZEN §4.3 signature verbatim (raw, expectedAud, now) — the
|
||||
* verifying key comes from the startup registry (config/keys.ts), never a parameter. The additive
|
||||
* `cnf.jkt` PoP claim (Finding-4) is NOT part of the frozen `CapabilityToken` shape, so it is kept
|
||||
* in a WeakMap keyed on the returned token object and read via `readCnfJkt` — the frozen type is
|
||||
* never mutated.
|
||||
*/
|
||||
import type { CapabilityToken } from 'relay-contracts'
|
||||
import { CapabilityTokenSchema, encodeBase64UrlBytes, decodeBase64UrlBytes } from 'relay-contracts'
|
||||
import { z } from 'zod'
|
||||
import { verifyPaseto, peekPasetoClaims } from '../crypto/paseto.js'
|
||||
import { verifyEd25519, importEd25519PublicRaw } from '../crypto/ed25519.js'
|
||||
import { jwkThumbprint } from '../crypto/thumbprint.js'
|
||||
import { getVerifyKey } from '../config/keys.js'
|
||||
import { CapabilityError } from './errors.js'
|
||||
|
||||
/** Allowed clock skew for `iat` (seconds). */
|
||||
export const CLOCK_SKEW_SEC = 5 as const
|
||||
/** DPoP proof freshness window (seconds). */
|
||||
export const DPOP_MAX_AGE_SEC = 30 as const
|
||||
|
||||
const CnfSchema = z.object({ jkt: z.string().min(1) }).strict()
|
||||
|
||||
/** Ties an additive `cnf.jkt` PoP binding to a verified token object without mutating §4.3. */
|
||||
const cnfByToken = new WeakMap<CapabilityToken, string>()
|
||||
|
||||
/** Read the additive `cnf.jkt` PoP claim off a verified token (Finding-4). */
|
||||
export function readCnfJkt(token: CapabilityToken): string {
|
||||
const jkt = cnfByToken.get(token)
|
||||
if (jkt === undefined) throw new CapabilityError('no_cnf')
|
||||
return jkt
|
||||
}
|
||||
|
||||
/** Returns `token.sub` (the accountId, T1 convention). */
|
||||
export function subAccountId(token: CapabilityToken): string {
|
||||
return token.sub
|
||||
}
|
||||
|
||||
/** Read `exp` WITHOUT verifying — only safe after a prior successful verify (e.g. for consumeOnce). */
|
||||
export function peekExp(raw: string): number {
|
||||
const claims = peekPasetoClaims(raw) as { exp?: unknown }
|
||||
if (typeof claims.exp !== 'number') throw new CapabilityError('malformed')
|
||||
return claims.exp
|
||||
}
|
||||
|
||||
/**
|
||||
* FROZEN §4.3 signature. Verifies Ed25519 signature (startup key), `aud === expectedAud`, and
|
||||
* `now < exp` / `iat` skew; returns the validated §4.3 claims. Rejects deny-by-default.
|
||||
*/
|
||||
export async function verifyCapabilityToken(
|
||||
raw: string,
|
||||
expectedAud: string,
|
||||
now: number,
|
||||
): Promise<CapabilityToken> {
|
||||
let claims: unknown
|
||||
try {
|
||||
claims = await verifyPaseto(raw, getVerifyKey())
|
||||
} catch {
|
||||
throw new CapabilityError('bad_signature')
|
||||
}
|
||||
const obj = claims as Record<string, unknown>
|
||||
const cnf = CnfSchema.safeParse(obj.cnf)
|
||||
if (!cnf.success) throw new CapabilityError('no_cnf')
|
||||
const { cnf: _cnf, ...core } = obj
|
||||
const parsed = CapabilityTokenSchema.safeParse(core)
|
||||
if (!parsed.success) throw new CapabilityError('malformed')
|
||||
const token = parsed.data as CapabilityToken
|
||||
if (token.aud !== expectedAud) throw new CapabilityError('aud_mismatch')
|
||||
if (token.iat > now + CLOCK_SKEW_SEC) throw new CapabilityError('not_yet_valid')
|
||||
if (token.exp <= now) throw new CapabilityError('expired')
|
||||
cnfByToken.set(token, cnf.data.jkt)
|
||||
return token
|
||||
}
|
||||
|
||||
// ── DPoP proof-of-possession ────────────────────────────────────────────────────────────────────
|
||||
export interface DpopContext {
|
||||
readonly proofJws: string
|
||||
readonly htu: string
|
||||
readonly htm: string
|
||||
}
|
||||
|
||||
interface DpopHeader {
|
||||
readonly typ: string
|
||||
readonly jwk: { readonly crv: string; readonly kty: string; readonly x: string }
|
||||
}
|
||||
interface DpopPayload {
|
||||
readonly htu: string
|
||||
readonly htm: string
|
||||
readonly jti: string
|
||||
readonly iat: number
|
||||
}
|
||||
|
||||
/** Bounded in-memory replay cache for DPoP `jti`s (single-process verifier). */
|
||||
const seenDpopJti = new Map<string, number>()
|
||||
function rememberDpop(jti: string, exp: number, now: number): boolean {
|
||||
for (const [k, e] of seenDpopJti) if (e < now) seenDpopJti.delete(k)
|
||||
if (seenDpopJti.has(jti)) return false
|
||||
seenDpopJti.set(jti, exp)
|
||||
return true
|
||||
}
|
||||
|
||||
/** TEST-ONLY: clear the DPoP replay cache between suites. */
|
||||
export function resetDpopCacheForTest(): void {
|
||||
seenDpopJti.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a DPoP proof: the presenting connection signed (htu, htm, jti, iat) with the ephemeral
|
||||
* key whose JWK thumbprint MUST equal the token's `cnf.jkt`. false if thumbprint mismatch, replay,
|
||||
* or htu/htm/age mismatch (Finding-4).
|
||||
*/
|
||||
export async function verifyDpopProof(
|
||||
token: CapabilityToken,
|
||||
dpop: DpopContext,
|
||||
now: number,
|
||||
): Promise<boolean> {
|
||||
const expectedJkt = readCnfJkt(token)
|
||||
const parts = dpop.proofJws.split('.')
|
||||
if (parts.length !== 3) return false
|
||||
const [h, p, s] = parts as [string, string, string]
|
||||
let header: DpopHeader
|
||||
let payload: DpopPayload
|
||||
try {
|
||||
header = JSON.parse(new TextDecoder().decode(decodeBase64UrlBytes(h))) as DpopHeader
|
||||
payload = JSON.parse(new TextDecoder().decode(decodeBase64UrlBytes(p))) as DpopPayload
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (header.jwk?.kty !== 'OKP' || header.jwk?.crv !== 'Ed25519') return false
|
||||
let rawPub: Uint8Array
|
||||
try {
|
||||
rawPub = decodeBase64UrlBytes(header.jwk.x)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if ((await jwkThumbprint(rawPub)) !== expectedJkt) return false
|
||||
if (payload.htu !== dpop.htu || payload.htm !== dpop.htm) return false
|
||||
if (typeof payload.iat !== 'number' || Math.abs(now - payload.iat) > DPOP_MAX_AGE_SEC) return false
|
||||
const signed = new TextEncoder().encode(`${h}.${p}`)
|
||||
const pubKey = await importEd25519PublicRaw(rawPub)
|
||||
let sig: Uint8Array
|
||||
try {
|
||||
sig = decodeBase64UrlBytes(s)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (!(await verifyEd25519(pubKey, sig, signed))) return false
|
||||
return rememberDpop(payload.jti, payload.iat + DPOP_MAX_AGE_SEC, now)
|
||||
}
|
||||
|
||||
/** Build a DPoP proof (client-side helper; also used by tests). Signs with the ephemeral key. */
|
||||
export async function buildDpopProof(
|
||||
ephemeralPrivate: CryptoKey,
|
||||
ephemeralPublicRaw: Uint8Array,
|
||||
ctx: { htu: string; htm: string; jti: string; iat: number },
|
||||
): Promise<string> {
|
||||
const header: DpopHeader = {
|
||||
typ: 'dpop+ed25519',
|
||||
jwk: { crv: 'Ed25519', kty: 'OKP', x: encodeBase64UrlBytes(ephemeralPublicRaw) },
|
||||
}
|
||||
const payload: DpopPayload = { htu: ctx.htu, htm: ctx.htm, jti: ctx.jti, iat: ctx.iat }
|
||||
const enc = (o: unknown) => encodeBase64UrlBytes(new TextEncoder().encode(JSON.stringify(o)))
|
||||
const h = enc(header)
|
||||
const p = enc(payload)
|
||||
const { signEd25519 } = await import('../crypto/ed25519.js')
|
||||
const sig = await signEd25519(ephemeralPrivate, new TextEncoder().encode(`${h}.${p}`))
|
||||
return `${h}.${p}.${encodeBase64UrlBytes(sig)}`
|
||||
}
|
||||
|
||||
/** Least-privilege check (INV15). */
|
||||
export function hasRight(token: CapabilityToken, right: CapabilityToken['rights'][number]): boolean {
|
||||
return token.rights.includes(right)
|
||||
}
|
||||
Reference in New Issue
Block a user