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:
96
agent/src/enroll/csr.ts
Normal file
96
agent/src/enroll/csr.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* PKCS#10 CSR over the Ed25519 identity — PLAN_RELAY_AGENT T4.
|
||||
*
|
||||
* Node has no built-in CSR generator, so this is a compact, self-contained DER encoder that
|
||||
* emits a standard PKCS#10 CertificationRequest signed with the in-process Ed25519 key (INV4 —
|
||||
* only the PUBLIC key + a signature leave; the private key is never serialized here).
|
||||
*
|
||||
* NOTE (cross-plan / open Q#1): the exact SPIFFE-style cert PROFILE (SAN = subdomain vs a SPIFFE
|
||||
* URI) is set by P3's CA. This builds the standard subject-CN PKCS#10 shape; when P3 freezes its
|
||||
* profile, extend `buildCsr`'s attributes here. That is the single integration point.
|
||||
*/
|
||||
import type { AgentIdentity } from '../keys/identity.js'
|
||||
|
||||
// --- minimal DER encoding helpers -------------------------------------------------------------
|
||||
|
||||
function derLen(len: number): Uint8Array {
|
||||
if (len < 0x80) return Uint8Array.from([len])
|
||||
const bytes: number[] = []
|
||||
let n = len
|
||||
while (n > 0) {
|
||||
bytes.unshift(n & 0xff)
|
||||
n >>= 8
|
||||
}
|
||||
return Uint8Array.from([0x80 | bytes.length, ...bytes])
|
||||
}
|
||||
|
||||
function tlv(tag: number, value: Uint8Array): Uint8Array {
|
||||
const len = derLen(value.length)
|
||||
const out = new Uint8Array(1 + len.length + value.length)
|
||||
out[0] = tag
|
||||
out.set(len, 1)
|
||||
out.set(value, 1 + len.length)
|
||||
return out
|
||||
}
|
||||
|
||||
function concat(chunks: readonly Uint8Array[]): Uint8Array {
|
||||
const total = chunks.reduce((s, c) => s + c.length, 0)
|
||||
const out = new Uint8Array(total)
|
||||
let off = 0
|
||||
for (const c of chunks) {
|
||||
out.set(c, off)
|
||||
off += c.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const SEQUENCE = 0x30
|
||||
const SET = 0x31
|
||||
const INTEGER = 0x02
|
||||
const BIT_STRING = 0x03
|
||||
const OID = 0x06
|
||||
const UTF8_STRING = 0x0c
|
||||
const CONTEXT_0 = 0xa0
|
||||
|
||||
// OID 2.5.4.3 (commonName) and 1.3.101.112 (Ed25519) as pre-encoded DER value bytes.
|
||||
const OID_CN = Uint8Array.from([0x55, 0x04, 0x03])
|
||||
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70])
|
||||
|
||||
/** SubjectPublicKeyInfo DER for a raw Ed25519 public key (fixed 44-byte structure). */
|
||||
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
|
||||
const algId = tlv(SEQUENCE, tlv(OID, OID_ED25519))
|
||||
const pubBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), raw]))
|
||||
return tlv(SEQUENCE, concat([algId, pubBits]))
|
||||
}
|
||||
|
||||
/** X.501 Name with a single CN=<subject> RDN. */
|
||||
function nameFromCn(cn: string): Uint8Array {
|
||||
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
|
||||
const rdn = tlv(SET, atv)
|
||||
return tlv(SEQUENCE, rdn)
|
||||
}
|
||||
|
||||
function toPem(der: Uint8Array, label: string): string {
|
||||
const b64 = Buffer.from(der).toString('base64')
|
||||
const lines = b64.match(/.{1,64}/g) ?? []
|
||||
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a PKCS#10 CSR (PEM) for `id` with subject CN=`subject`, signed by the Ed25519 key.
|
||||
* The private key is used in-process only; never serialized into the output (INV4).
|
||||
*/
|
||||
export function buildCsr(id: AgentIdentity, subject: string): string {
|
||||
const version = tlv(INTEGER, Uint8Array.from([0x00]))
|
||||
const name = nameFromCn(subject)
|
||||
const spki = spkiFromRawEd25519(id.publicKey)
|
||||
const attributes = tlv(CONTEXT_0, new Uint8Array(0)) // [0] IMPLICIT empty SET OF Attribute
|
||||
const requestInfo = tlv(SEQUENCE, concat([version, name, spki, attributes]))
|
||||
|
||||
const signature = id.sign(requestInfo) // Ed25519 over CertificationRequestInfo
|
||||
const sigAlg = tlv(SEQUENCE, tlv(OID, OID_ED25519))
|
||||
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
|
||||
|
||||
const csr = tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))
|
||||
return toPem(csr, 'CERTIFICATE REQUEST')
|
||||
}
|
||||
137
agent/src/enroll/pair.ts
Normal file
137
agent/src/enroll/pair.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* §4.5 pairing-code redemption (agent side) — PLAN_RELAY_AGENT T4.
|
||||
*
|
||||
* REDEEM: POST /enroll { code, agentPubkey, csr } (raw code NEVER persisted — INV5).
|
||||
* RETURN: the FROZEN EnrollResult (imported from relay-contracts, never redeclared), validated
|
||||
* with EnrollResultSchema at the boundary (untrusted external data). On success the FIX 3
|
||||
* `hostContentSecret` (wrapped to this agent's Ed25519 identity by P3) is UNWRAPPED in-process
|
||||
* and persisted 0600 via the keystore; the WRAPPED bytes are never stored, the unwrapped secret
|
||||
* is never logged/re-sent (INV5/INV9).
|
||||
*
|
||||
* Only the PUBLIC key + CSR leave the host (INV4).
|
||||
*/
|
||||
import { EnrollResultSchema, decodeBase64UrlBytes, encodeBase64UrlBytes } from 'relay-contracts'
|
||||
import type { EnrollResult } from 'relay-contracts'
|
||||
import type { AgentIdentity } from '../keys/identity.js'
|
||||
import type { Keystore } from '../keys/keystore.js'
|
||||
import { buildCsr } from './csr.js'
|
||||
|
||||
/** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */
|
||||
export type EnrollMode = 'token' | 'ed25519'
|
||||
export const DEFAULT_ENROLL_MODE: EnrollMode = 'ed25519'
|
||||
|
||||
export class EnrollError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'EnrollError'
|
||||
}
|
||||
}
|
||||
export class PairingCodeSpentError extends EnrollError {
|
||||
constructor() {
|
||||
super('pairing code already redeemed (single-use)')
|
||||
this.name = 'PairingCodeSpentError'
|
||||
}
|
||||
}
|
||||
export class PairingCodeExpiredError extends EnrollError {
|
||||
constructor() {
|
||||
super('pairing code expired')
|
||||
this.name = 'PairingCodeExpiredError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap the P3-wrapped `hostContentSecret` using the agent's enrollment identity (in-process).
|
||||
* P3's exact wrap scheme is not yet frozen (cross-plan integration point); this seam is injected
|
||||
* so the real unwrap drops in without touching the redeem flow. Default = passthrough.
|
||||
*/
|
||||
export type UnwrapContentSecret = (wrapped: Uint8Array, id: AgentIdentity) => Uint8Array
|
||||
const passthroughUnwrap: UnwrapContentSecret = (wrapped) => wrapped
|
||||
|
||||
export interface RedeemOptions {
|
||||
readonly fetchImpl?: typeof fetch
|
||||
readonly mode?: EnrollMode
|
||||
readonly agentToken?: string
|
||||
readonly unwrapContentSecret?: UnwrapContentSecret
|
||||
readonly subject?: string
|
||||
}
|
||||
|
||||
interface EnrollResponseJson {
|
||||
hostId: string
|
||||
subdomain: string
|
||||
cert: string
|
||||
caChain: string
|
||||
hostContentSecret: string // base64url over the wire
|
||||
}
|
||||
|
||||
function parseEnrollResult(json: unknown): EnrollResult {
|
||||
const j = json as Partial<EnrollResponseJson>
|
||||
if (typeof j.hostContentSecret !== 'string') {
|
||||
throw new EnrollError('enroll response missing hostContentSecret')
|
||||
}
|
||||
const candidate = {
|
||||
hostId: j.hostId,
|
||||
subdomain: j.subdomain,
|
||||
cert: j.cert,
|
||||
caChain: j.caChain,
|
||||
hostContentSecret: decodeBase64UrlBytes(j.hostContentSecret),
|
||||
}
|
||||
const result = EnrollResultSchema.safeParse(candidate)
|
||||
if (!result.success) {
|
||||
throw new EnrollError(`enroll response failed schema validation: ${result.error.message}`)
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem `code` at `enrollUrl`. Sends only pubkey + CSR (INV4); never persists the raw code
|
||||
* (INV5). Stores the returned cert + CA chain and the unwrapped hostContentSecret 0600.
|
||||
*/
|
||||
export async function redeemPairingCode(
|
||||
enrollUrl: string,
|
||||
code: string,
|
||||
id: AgentIdentity,
|
||||
ks: Keystore,
|
||||
opts: RedeemOptions = {},
|
||||
): Promise<EnrollResult> {
|
||||
const doFetch = opts.fetchImpl ?? fetch
|
||||
const mode = opts.mode ?? DEFAULT_ENROLL_MODE
|
||||
const unwrap = opts.unwrapContentSecret ?? passthroughUnwrap
|
||||
|
||||
const body =
|
||||
mode === 'token'
|
||||
? { code, agentToken: opts.agentToken ?? '' }
|
||||
: {
|
||||
code,
|
||||
agentPubkey: encodeBase64UrlBytes(id.publicKey),
|
||||
csr: buildCsr(id, opts.subject ?? 'web-terminal-agent'),
|
||||
}
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await doFetch(enrollUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
} catch (err) {
|
||||
throw new EnrollError(`enroll request failed: ${(err as Error).message}`)
|
||||
}
|
||||
|
||||
if (res.status === 409) throw new PairingCodeSpentError()
|
||||
if (res.status === 410) throw new PairingCodeExpiredError()
|
||||
if (!res.ok) throw new EnrollError(`enroll returned HTTP ${res.status}`)
|
||||
|
||||
let json: unknown
|
||||
try {
|
||||
json = await res.json()
|
||||
} catch (err) {
|
||||
throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`)
|
||||
}
|
||||
|
||||
const enroll = parseEnrollResult(json)
|
||||
ks.saveCert(enroll.cert, enroll.caChain)
|
||||
// FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored).
|
||||
const unwrapped = unwrap(enroll.hostContentSecret, id)
|
||||
ks.saveContentSecret(unwrapped)
|
||||
return enroll
|
||||
}
|
||||
Reference in New Issue
Block a user