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:
66
relay-contracts/src/base64url.ts
Normal file
66
relay-contracts/src/base64url.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Isomorphic base64url (RFC 4648 §5, no padding) over bytes and UTF-8 strings.
|
||||
* Hand-rolled (no Buffer / no atob) so both the Node relay (P1) and the browser
|
||||
* bundle (P6) import it unchanged and agree byte-for-byte on the §4.3 token subprotocol.
|
||||
*/
|
||||
import { ContractDecodeError } from './errors.js'
|
||||
|
||||
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
|
||||
const DECODE_MAP: Readonly<Record<string, number>> = Object.fromEntries(
|
||||
[...ALPHABET].map((ch, i) => [ch, i]),
|
||||
)
|
||||
|
||||
const textEncoder = new TextEncoder()
|
||||
const textDecoder = new TextDecoder('utf-8', { fatal: true })
|
||||
|
||||
/** Encode raw bytes to a base64url string (no `=` padding). */
|
||||
export function encodeBase64UrlBytes(bytes: Uint8Array): string {
|
||||
let out = ''
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const b0 = bytes[i]!
|
||||
const b1 = i + 1 < bytes.length ? bytes[i + 1]! : 0
|
||||
const b2 = i + 2 < bytes.length ? bytes[i + 2]! : 0
|
||||
const triple = (b0 << 16) | (b1 << 8) | b2
|
||||
const remaining = bytes.length - i
|
||||
out += ALPHABET[(triple >> 18) & 0x3f]
|
||||
out += ALPHABET[(triple >> 12) & 0x3f]
|
||||
if (remaining > 1) out += ALPHABET[(triple >> 6) & 0x3f]
|
||||
if (remaining > 2) out += ALPHABET[triple & 0x3f]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Decode a base64url string (padding tolerated) back to raw bytes. */
|
||||
export function decodeBase64UrlBytes(value: string): Uint8Array {
|
||||
const clean = value.replace(/=+$/, '')
|
||||
if (/[^A-Za-z0-9\-_]/.test(clean)) {
|
||||
throw new ContractDecodeError('invalid base64url: contains non-alphabet characters')
|
||||
}
|
||||
const bytes: number[] = []
|
||||
for (let i = 0; i < clean.length; i += 4) {
|
||||
const c0 = DECODE_MAP[clean[i]!]!
|
||||
const c1 = i + 1 < clean.length ? DECODE_MAP[clean[i + 1]!]! : 0
|
||||
const c2 = i + 2 < clean.length ? DECODE_MAP[clean[i + 2]!] : undefined
|
||||
const c3 = i + 3 < clean.length ? DECODE_MAP[clean[i + 3]!] : undefined
|
||||
const triple = (c0 << 18) | (c1 << 12) | ((c2 ?? 0) << 6) | (c3 ?? 0)
|
||||
bytes.push((triple >> 16) & 0xff)
|
||||
if (c2 !== undefined) bytes.push((triple >> 8) & 0xff)
|
||||
if (c3 !== undefined) bytes.push(triple & 0xff)
|
||||
}
|
||||
return Uint8Array.from(bytes)
|
||||
}
|
||||
|
||||
/** Encode a UTF-8 string to base64url. */
|
||||
export function encodeBase64UrlString(value: string): string {
|
||||
return encodeBase64UrlBytes(textEncoder.encode(value))
|
||||
}
|
||||
|
||||
/** Decode a base64url string to a UTF-8 string. */
|
||||
export function decodeBase64UrlString(value: string): string {
|
||||
try {
|
||||
return textDecoder.decode(decodeBase64UrlBytes(value))
|
||||
} catch (err) {
|
||||
if (err instanceof ContractDecodeError) throw err
|
||||
throw new ContractDecodeError('base64url payload is not valid UTF-8')
|
||||
}
|
||||
}
|
||||
83
relay-contracts/src/bytes.ts
Normal file
83
relay-contracts/src/bytes.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Big-endian (network order) integer read/write helpers shared by every binary codec.
|
||||
* Kept dependency-free (no Buffer) so the browser bundle (P6) imports it unchanged.
|
||||
*/
|
||||
import { ContractDecodeError, ContractEncodeError } from './errors.js'
|
||||
|
||||
export const U32_MAX = 0xffff_ffff
|
||||
export const U64_MAX = 0xffff_ffff_ffff_ffffn
|
||||
|
||||
/** Assert a value is a non-negative integer that fits in uint32; throw otherwise. */
|
||||
export function assertUint32(value: number, field: string): void {
|
||||
if (!Number.isInteger(value) || value < 0 || value > U32_MAX) {
|
||||
throw new ContractEncodeError(`${field} must be a uint32 (0..${U32_MAX}); got ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a uint32 big-endian into `out` at `offset`. Returns the next offset. */
|
||||
export function writeUint32BE(out: Uint8Array, offset: number, value: number): number {
|
||||
assertUint32(value, 'uint32')
|
||||
out[offset] = (value >>> 24) & 0xff
|
||||
out[offset + 1] = (value >>> 16) & 0xff
|
||||
out[offset + 2] = (value >>> 8) & 0xff
|
||||
out[offset + 3] = value & 0xff
|
||||
return offset + 4
|
||||
}
|
||||
|
||||
/** Read a uint32 big-endian from `buf` at `offset`. */
|
||||
export function readUint32BE(buf: Uint8Array, offset: number): number {
|
||||
if (offset + 4 > buf.length) {
|
||||
throw new ContractDecodeError(`truncated uint32 at offset ${offset} (len ${buf.length})`)
|
||||
}
|
||||
return (
|
||||
((buf[offset]! << 24) >>> 0) +
|
||||
(buf[offset + 1]! << 16) +
|
||||
(buf[offset + 2]! << 8) +
|
||||
buf[offset + 3]!
|
||||
)
|
||||
}
|
||||
|
||||
/** Write a uint64 big-endian (from a bigint) into `out` at `offset`. Returns next offset. */
|
||||
export function writeUint64BE(out: Uint8Array, offset: number, value: bigint): number {
|
||||
if (value < 0n || value > U64_MAX) {
|
||||
throw new ContractEncodeError(`uint64 out of range: ${value}`)
|
||||
}
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
out[offset + i] = Number(value & 0xffn)
|
||||
value >>= 8n
|
||||
}
|
||||
return offset + 8
|
||||
}
|
||||
|
||||
/** Read a uint64 big-endian as a bigint from `buf` at `offset`. */
|
||||
export function readUint64BE(buf: Uint8Array, offset: number): bigint {
|
||||
if (offset + 8 > buf.length) {
|
||||
throw new ContractDecodeError(`truncated uint64 at offset ${offset} (len ${buf.length})`)
|
||||
}
|
||||
let value = 0n
|
||||
for (let i = 0; i < 8; i++) {
|
||||
value = (value << 8n) | BigInt(buf[offset + i]!)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** Read a uint64 that must fit in a JS safe integer (§4.1 payloadLen safe-int guard). */
|
||||
export function readSafeUint64BE(buf: Uint8Array, offset: number, field: string): number {
|
||||
const value = readUint64BE(buf, offset)
|
||||
if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||
throw new ContractDecodeError(`${field} exceeds Number.MAX_SAFE_INTEGER: ${value}`)
|
||||
}
|
||||
return Number(value)
|
||||
}
|
||||
|
||||
/** Concatenate byte chunks into one Uint8Array. */
|
||||
export function concatBytes(chunks: readonly Uint8Array[]): Uint8Array {
|
||||
const total = chunks.reduce((n, c) => n + c.length, 0)
|
||||
const out = new Uint8Array(total)
|
||||
let offset = 0
|
||||
for (const c of chunks) {
|
||||
out.set(c, offset)
|
||||
offset += c.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
33
relay-contracts/src/capability/subprotocol.ts
Normal file
33
relay-contracts/src/capability/subprotocol.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* §4.3 FIX 5 — WS-upgrade capability-token transport over `Sec-WebSocket-Protocol`.
|
||||
*
|
||||
* The browser's native WebSocket API cannot set request headers, so the token rides the
|
||||
* subprotocol list: the client opens
|
||||
* new WebSocket(url, [APP_SUBPROTOCOL, TOKEN_SUBPROTOCOL_PREFIX + base64url(rawToken)])
|
||||
* The relay reads the token from the `term.token.<b64u>` entry, strips it before verify, and
|
||||
* echoes ONLY `term.relay.v1` back — echoing the token would leak the bearer secret.
|
||||
*/
|
||||
import { encodeBase64UrlString, decodeBase64UrlString } from '../base64url.js'
|
||||
|
||||
/** The app subprotocol — the ONLY value ever accepted/echoed in the handshake response. */
|
||||
export const APP_SUBPROTOCOL = 'term.relay.v1' as const
|
||||
|
||||
/** The token entry = this prefix + base64url(rawToken). */
|
||||
export const TOKEN_SUBPROTOCOL_PREFIX = 'term.token.' as const
|
||||
|
||||
/** Build the token subprotocol entry: `term.token.` + base64url(rawToken). */
|
||||
export function encodeTokenSubprotocol(rawToken: string): string {
|
||||
return TOKEN_SUBPROTOCOL_PREFIX + encodeBase64UrlString(rawToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the raw token from a subprotocol list: find the `term.token.` entry, strip the
|
||||
* prefix, base64url-decode. Returns null if absent (caller rejects with 401). Ignores the
|
||||
* app-subprotocol entry. Throws ContractDecodeError if the entry exists but is malformed.
|
||||
*/
|
||||
export function extractTokenFromSubprotocols(values: readonly string[]): string | null {
|
||||
const entry = values.find((v) => v.startsWith(TOKEN_SUBPROTOCOL_PREFIX))
|
||||
if (entry === undefined) return null
|
||||
const encoded = entry.slice(TOKEN_SUBPROTOCOL_PREFIX.length)
|
||||
return decodeBase64UrlString(encoded)
|
||||
}
|
||||
61
relay-contracts/src/capability/token.ts
Normal file
61
relay-contracts/src/capability/token.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* §4.3 Capability token — signed, stateless, short-TTL (Ed25519-signed PASETO/JWS).
|
||||
*
|
||||
* `sub`/`host` come from the authenticated principal + registry (INV3) — NEVER client input.
|
||||
* Scopes exactly one host and a least-privilege rights subset ('attach' ⊉ 'kill'). Revocable
|
||||
* by `jti` (INV12). relay-contracts owns the SHAPE + Zod validation; the signature VERIFY is
|
||||
* crypto owned by P5 (`relay-auth`) — `verifyCapabilityToken` is a frozen signature here.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { NotImplementedInContractsError } from '../errors.js'
|
||||
|
||||
/** Rights a token may carry (string-literal union, §4.3). */
|
||||
export type CapabilityRight = 'attach' | 'manage' | 'kill'
|
||||
export const CapabilityRightSchema = z.enum(['attach', 'manage', 'kill'])
|
||||
|
||||
export interface CapabilityToken {
|
||||
readonly sub: string // principal id — from authenticated session, NOT client
|
||||
readonly aud: string // subdomain this token is valid at (Host-confusion guard, INV1)
|
||||
readonly host: string // exact host_id scope — single host, never wildcard
|
||||
readonly rights: readonly CapabilityRight[] // least-privilege subset
|
||||
readonly iat: number
|
||||
readonly exp: number // short TTL
|
||||
readonly jti: string // unique id for revocation-list lookup
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape/claims validation for a DECODED token body (structural checks only — the Ed25519
|
||||
* signature is verified by P5). `rights` must be non-empty and unique.
|
||||
*/
|
||||
export const CapabilityTokenSchema = z
|
||||
.object({
|
||||
sub: z.string().min(1),
|
||||
aud: z.string().min(1),
|
||||
host: z.string().min(1),
|
||||
rights: z.array(CapabilityRightSchema).nonempty(),
|
||||
iat: z.number().int().nonnegative(),
|
||||
exp: z.number().int().nonnegative(),
|
||||
jti: z.string().min(1),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((tok, ctx) => {
|
||||
if (new Set(tok.rights).size !== tok.rights.length) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'rights must be unique' })
|
||||
}
|
||||
if (tok.exp <= tok.iat) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'exp must be after iat' })
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* FROZEN SIGNATURE (§4.3). Verifies the Ed25519 signature, `aud === expectedAud`, and
|
||||
* `now < exp`, returning the validated claims. The crypto IMPLEMENTATION lives in P5
|
||||
* `relay-auth`; relay-contracts owns only the shape. Do not call from relay-contracts.
|
||||
*/
|
||||
export function verifyCapabilityToken(
|
||||
_raw: string,
|
||||
_expectedAud: string,
|
||||
_now: number,
|
||||
): CapabilityToken {
|
||||
throw new NotImplementedInContractsError('verifyCapabilityToken', 'P5 relay-auth')
|
||||
}
|
||||
61
relay-contracts/src/e2e/crypto.ts
Normal file
61
relay-contracts/src/e2e/crypto.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* §4.4 E2E crypto — FROZEN SIGNATURES ONLY. The AEAD/ECDH/HKDF implementations live in P4
|
||||
* `relay-e2e/` (which re-exports these exact signatures); relay-contracts owns the SHAPE so
|
||||
* P2/P4/P6 type-check against one surface. Calling any of these from relay-contracts throws.
|
||||
*
|
||||
* Per PLAN_RELAY_INDEX §2.1: "Crypto implementations of the §4.4 functions live in P4's
|
||||
* relay-e2e/ … relay-contracts owns the shapes."
|
||||
*/
|
||||
import { NotImplementedInContractsError } from '../errors.js'
|
||||
import type {
|
||||
AeadKey,
|
||||
AuthorizedDeviceContext,
|
||||
AeadAlg,
|
||||
ClientHandshake,
|
||||
E2EEnvelope,
|
||||
E2ESession,
|
||||
HandshakeResult,
|
||||
ReplayKeyParams,
|
||||
SessionRole,
|
||||
} from './types.js'
|
||||
|
||||
const OWNER = 'P4 relay-e2e'
|
||||
|
||||
/** SYNCHRONOUS AEAD seal — aad = directionLabel‖seq (§4.4). Impl: P4. */
|
||||
export function sealFrame(_key: AeadKey, _seq: bigint, _plaintext: Uint8Array): E2EEnvelope {
|
||||
throw new NotImplementedInContractsError('sealFrame', OWNER)
|
||||
}
|
||||
|
||||
/** SYNCHRONOUS AEAD open — verifies tag + seq, returns plaintext (§4.4). Impl: P4. */
|
||||
export function openFrame(_key: AeadKey, _env: E2EEnvelope, _expectedSeq: bigint): Uint8Array {
|
||||
throw new NotImplementedInContractsError('openFrame', OWNER)
|
||||
}
|
||||
|
||||
/** Build a stateful directional session from a handshake result (§4.4). Impl: P4. */
|
||||
export function createE2ESession(_role: SessionRole, _result: HandshakeResult): E2ESession {
|
||||
throw new NotImplementedInContractsError('createE2ESession', OWNER)
|
||||
}
|
||||
|
||||
/** Client-side handshake factory P6 calls (§4.4). Impl: P4. */
|
||||
export function buildClientHandshake(
|
||||
_ctx: AuthorizedDeviceContext,
|
||||
_hostId: string,
|
||||
_aeadOffer: readonly AeadAlg[],
|
||||
): ClientHandshake {
|
||||
throw new NotImplementedInContractsError('buildClientHandshake', OWNER)
|
||||
}
|
||||
|
||||
/** FIX 3 recoverable replay content-key: HKDF(hostContentSecret, salt=sessionId, REPLAY_KDF_INFO). Impl: P4. */
|
||||
export function deriveContentKey(_p: ReplayKeyParams): AeadKey {
|
||||
throw new NotImplementedInContractsError('deriveContentKey', OWNER)
|
||||
}
|
||||
|
||||
/** FIX 3 agent seals replay-bound output under K_content (§4.4, P2 uses). Impl: P4. */
|
||||
export function sealReplayFrame(_k: AeadKey, _seq: bigint, _plaintext: Uint8Array): E2EEnvelope {
|
||||
throw new NotImplementedInContractsError('sealReplayFrame', OWNER)
|
||||
}
|
||||
|
||||
/** FIX 3 browser client-side replay/preview decrypt under K_content (§4.4, P6 uses). Impl: P4. */
|
||||
export function openReplayCiphertext(_k: AeadKey, _dataPayload: Uint8Array): Uint8Array {
|
||||
throw new NotImplementedInContractsError('openReplayCiphertext', OWNER)
|
||||
}
|
||||
95
relay-contracts/src/e2e/envelope.ts
Normal file
95
relay-contracts/src/e2e/envelope.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* §4.4 E2EEnvelope binary codec + deterministic nonce helper.
|
||||
*
|
||||
* relay-contracts owns the WIRE SHAPE of the envelope (so P2 agent and P6 browser frame it
|
||||
* identically); the AEAD that fills `ciphertext`/`tag` lives in P4. This is a pure byte layout.
|
||||
*
|
||||
* Envelope wire layout (big-endian), fixed 14-byte header then three variable regions:
|
||||
* offset size field
|
||||
* 0 8 seq uint64 (strictly monotonic per direction, INV13)
|
||||
* 8 1 nonceLen uint8
|
||||
* 9 4 ciphertextLen uint32
|
||||
* 13 1 tagLen uint8
|
||||
* 14 nonceLen nonce
|
||||
* .. ciphertextLen ciphertext
|
||||
* .. tagLen tag
|
||||
*/
|
||||
import { ContractDecodeError, ContractEncodeError } from '../errors.js'
|
||||
import {
|
||||
U32_MAX,
|
||||
concatBytes,
|
||||
readUint32BE,
|
||||
readUint64BE,
|
||||
writeUint32BE,
|
||||
writeUint64BE,
|
||||
} from '../bytes.js'
|
||||
import { NONCE_BYTES, type AeadAlg, type E2EEnvelope } from './types.js'
|
||||
|
||||
const ENVELOPE_HEADER_BYTES = 14
|
||||
const U8_MAX = 0xff
|
||||
|
||||
/**
|
||||
* Deterministic per-`seq` nonce = f(seq) (§4.4): the 8-byte big-endian sequence number in the
|
||||
* LAST 8 bytes of a zero-filled nonce of the alg's width. No random branch (INV13). The
|
||||
* direction-split subkeys (c2h/h2c) ensure the same (seq → nonce) never reuses a (key, nonce).
|
||||
*/
|
||||
export function nonceForSeq(seq: bigint, alg: AeadAlg): Uint8Array {
|
||||
const width = NONCE_BYTES[alg]
|
||||
const nonce = new Uint8Array(width)
|
||||
writeUint64BE(nonce, width - 8, seq)
|
||||
return nonce
|
||||
}
|
||||
|
||||
/** Encode an E2EEnvelope to its wire bytes. */
|
||||
export function encodeEnvelope(env: E2EEnvelope): Uint8Array {
|
||||
if (env.nonce.length > U8_MAX) {
|
||||
throw new ContractEncodeError(`nonce too long: ${env.nonce.length} > ${U8_MAX}`)
|
||||
}
|
||||
if (env.tag.length > U8_MAX) {
|
||||
throw new ContractEncodeError(`tag too long: ${env.tag.length} > ${U8_MAX}`)
|
||||
}
|
||||
if (env.ciphertext.length > U32_MAX) {
|
||||
throw new ContractEncodeError(`ciphertext too long: ${env.ciphertext.length} > ${U32_MAX}`)
|
||||
}
|
||||
const header = new Uint8Array(ENVELOPE_HEADER_BYTES)
|
||||
let offset = writeUint64BE(header, 0, env.seq)
|
||||
header[offset] = env.nonce.length
|
||||
offset += 1
|
||||
offset = writeUint32BE(header, offset, env.ciphertext.length)
|
||||
header[offset] = env.tag.length
|
||||
return concatBytes([header, env.nonce, env.ciphertext, env.tag])
|
||||
}
|
||||
|
||||
/** Decode wire bytes into an E2EEnvelope. Rejects truncated or trailing-byte inputs. */
|
||||
export function decodeEnvelope(buf: Uint8Array): E2EEnvelope {
|
||||
if (buf.length < ENVELOPE_HEADER_BYTES) {
|
||||
throw new ContractDecodeError(
|
||||
`envelope shorter than header: ${buf.length} < ${ENVELOPE_HEADER_BYTES}`,
|
||||
)
|
||||
}
|
||||
const seq = readUint64BE(buf, 0)
|
||||
const nonceLen = buf[8]!
|
||||
const ciphertextLen = readUint32BE(buf, 9)
|
||||
const tagLen = buf[13]!
|
||||
const total = ENVELOPE_HEADER_BYTES + nonceLen + ciphertextLen + tagLen
|
||||
if (buf.length !== total) {
|
||||
throw new ContractDecodeError(
|
||||
`envelope length mismatch: buffer ${buf.length}, header implies ${total}`,
|
||||
)
|
||||
}
|
||||
let offset = ENVELOPE_HEADER_BYTES
|
||||
const nonce = buf.slice(offset, offset + nonceLen)
|
||||
offset += nonceLen
|
||||
const ciphertext = buf.slice(offset, offset + ciphertextLen)
|
||||
offset += ciphertextLen
|
||||
const tag = buf.slice(offset, offset + tagLen)
|
||||
return { seq, nonce, ciphertext, tag }
|
||||
}
|
||||
|
||||
/** Read the uint32 ciphertext length directly (helper for callers that pre-parse). */
|
||||
export function readCiphertextLen(buf: Uint8Array): number {
|
||||
if (buf.length < ENVELOPE_HEADER_BYTES) {
|
||||
throw new ContractDecodeError('envelope truncated before ciphertextLen')
|
||||
}
|
||||
return readUint32BE(buf, 9)
|
||||
}
|
||||
126
relay-contracts/src/e2e/types.ts
Normal file
126
relay-contracts/src/e2e/types.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* §4.4 E2E type surface (FIX 2 + FIX 3). relay-contracts owns the SHAPES + the ENVELOPE
|
||||
* binary codec; the AEAD/ECDH crypto IMPLEMENTATIONS live in P4 `relay-e2e/` (which
|
||||
* re-exports these signatures). No plan redefines any of these locally.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
|
||||
/** AEAD algorithm (string-literal union, §4.4). */
|
||||
export type AeadAlg = 'aes-256-gcm' | 'xchacha20-poly1305'
|
||||
export const AeadAlgSchema = z.enum(['aes-256-gcm', 'xchacha20-poly1305'])
|
||||
|
||||
/** Nonce widths per AEAD (§4.4: 12B GCM / 24B XChaCha). */
|
||||
export const NONCE_BYTES: Readonly<Record<AeadAlg, number>> = {
|
||||
'aes-256-gcm': 12,
|
||||
'xchacha20-poly1305': 24,
|
||||
}
|
||||
|
||||
/** AEAD key length in bytes (both algs use a 256-bit key). */
|
||||
export const AEAD_KEY_BYTES = 32 as const
|
||||
|
||||
/**
|
||||
* Opaque wrapper over a 32-byte key (NOT a WebCrypto CryptoKey) — FIX 2. The `unique symbol`
|
||||
* makes it nominal so a raw Uint8Array can't be passed where an AeadKey is required.
|
||||
*/
|
||||
export type AeadKey = { readonly __aeadKey: unique symbol }
|
||||
|
||||
export type SessionRole = 'client' | 'host'
|
||||
|
||||
/** Direction-split subkeys (FIX 2): client seals c2h/opens h2c; host seals h2c/opens c2h. */
|
||||
export interface DirectionalKeys {
|
||||
readonly c2h: AeadKey
|
||||
readonly h2c: AeadKey
|
||||
}
|
||||
|
||||
/** HKDF label constants (§4.4) — frozen so both sides derive identical subkeys. */
|
||||
export const HKDF_INFO = 'relay-e2e/v1' as const
|
||||
export const HKDF_INFO_C2H = 'relay-e2e/v1/c2h' as const
|
||||
export const HKDF_INFO_H2C = 'relay-e2e/v1/h2c' as const
|
||||
|
||||
/** client_hello (§4.4). `deviceAuthProof` is minted by P5, shape-validated by P4 (FIX 6b). */
|
||||
export interface ClientHello {
|
||||
readonly clientEphPub: Uint8Array
|
||||
readonly clientNonce: Uint8Array
|
||||
readonly aeadOffer: readonly AeadAlg[]
|
||||
readonly deviceAuthProof: string
|
||||
}
|
||||
export const ClientHelloSchema = z
|
||||
.object({
|
||||
clientEphPub: z.instanceof(Uint8Array),
|
||||
clientNonce: z.instanceof(Uint8Array),
|
||||
aeadOffer: z.array(AeadAlgSchema).nonempty(),
|
||||
deviceAuthProof: z.string().min(1),
|
||||
})
|
||||
.strict()
|
||||
|
||||
/** host_hello (§4.4). `sig` = Ed25519(agent_pubkey) over the transcript. */
|
||||
export interface HostHello {
|
||||
readonly hostEphPub: Uint8Array
|
||||
readonly hostNonce: Uint8Array
|
||||
readonly aeadChoice: AeadAlg
|
||||
readonly enrollFpr: string
|
||||
readonly sig: Uint8Array
|
||||
}
|
||||
export const HostHelloSchema = z
|
||||
.object({
|
||||
hostEphPub: z.instanceof(Uint8Array),
|
||||
hostNonce: z.instanceof(Uint8Array),
|
||||
aeadChoice: AeadAlgSchema,
|
||||
enrollFpr: z.string().min(1),
|
||||
sig: z.instanceof(Uint8Array),
|
||||
})
|
||||
.strict()
|
||||
|
||||
/** Encrypted frame envelope carried inside every §4.1 DATA payload once E2E is live (§4.4). */
|
||||
export interface E2EEnvelope {
|
||||
readonly seq: bigint // strictly monotonic per direction (anti-replay, INV13)
|
||||
readonly nonce: Uint8Array // DETERMINISTIC f(seq)
|
||||
readonly ciphertext: Uint8Array // AEAD(subkey, plaintext, aad = directionLabel‖seq)
|
||||
readonly tag: Uint8Array // AEAD auth tag; failure ⇒ drop + tear down
|
||||
}
|
||||
|
||||
export interface HandshakeResult {
|
||||
readonly keys: DirectionalKeys
|
||||
readonly aead: AeadAlg
|
||||
readonly transcript: Uint8Array
|
||||
}
|
||||
|
||||
/** Stateful directional wrapper over a stream (holds BOTH subkeys + role + seq guards). */
|
||||
export interface E2ESession {
|
||||
readonly role: SessionRole
|
||||
seal(plaintext: Uint8Array): Uint8Array
|
||||
open(dataPayload: Uint8Array): Uint8Array
|
||||
rederive(next: HandshakeResult): void
|
||||
}
|
||||
|
||||
export interface ClientHandshake {
|
||||
start(): Promise<ClientHello>
|
||||
onHostHello(msg: HostHello, agentPubkey: Uint8Array): Promise<HandshakeResult>
|
||||
}
|
||||
|
||||
/** P4-internal helper (declared here only to type AuthorizedDeviceContext) — TOFU/pin store. */
|
||||
export interface DevicePinStore {
|
||||
get(hostId: string): Promise<string | null>
|
||||
pin(hostId: string, enrollFpr: string): Promise<void>
|
||||
}
|
||||
|
||||
export interface DeviceAuthProofProvider {
|
||||
proofFor(
|
||||
hostId: string,
|
||||
binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array },
|
||||
): Promise<string>
|
||||
}
|
||||
|
||||
export interface AuthorizedDeviceContext {
|
||||
readonly deviceAuthProofProvider: DeviceAuthProofProvider // P5 — per-handshake proof (FIX 6b)
|
||||
readonly pinStore: DevicePinStore // P6 — TOFU/pin over recomputed fingerprints
|
||||
readonly hostContentSecret: Uint8Array // §4.5 host-scoped secret; NEVER logged/sent to relay
|
||||
}
|
||||
|
||||
/** FIX 3 replay content-key derivation params (§4.4). */
|
||||
export interface ReplayKeyParams {
|
||||
readonly hostContentSecret: Uint8Array
|
||||
readonly sessionId: string
|
||||
readonly alg: AeadAlg
|
||||
}
|
||||
export const REPLAY_KDF_INFO = 'relay-e2e/replay/v1' as const
|
||||
37
relay-contracts/src/errors.ts
Normal file
37
relay-contracts/src/errors.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Shared explicit error types for relay-contracts.
|
||||
*
|
||||
* `coding-style.md`: "Never silently swallow errors" — every codec throws a typed,
|
||||
* descriptive error at the parse boundary instead of returning a partial/ambiguous value.
|
||||
*/
|
||||
|
||||
/** Thrown when raw bytes / strings fail to decode into a frozen contract shape. */
|
||||
export class ContractDecodeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ContractDecodeError'
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when a value violates a frozen contract invariant before encoding. */
|
||||
export class ContractEncodeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ContractEncodeError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by crypto-shaped functions whose *implementation* lives in another relay
|
||||
* package (P4 `relay-e2e/`, P5 `relay-auth/`). `relay-contracts` owns only the SHAPE
|
||||
* (signature + types) per PLAN_RELAY_INDEX §2.1; the runtime is provided downstream.
|
||||
*/
|
||||
export class NotImplementedInContractsError extends Error {
|
||||
constructor(fnName: string, owner: string) {
|
||||
super(
|
||||
`${fnName} is a frozen SIGNATURE only in relay-contracts; its crypto implementation lives in ${owner}. ` +
|
||||
`Import the implementation from that package — do not call it from relay-contracts.`,
|
||||
)
|
||||
this.name = 'NotImplementedInContractsError'
|
||||
}
|
||||
}
|
||||
33
relay-contracts/src/index.ts
Normal file
33
relay-contracts/src/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* relay-contracts — public barrel. The FROZEN shared surface (PLAN_RELAY_INDEX §4) imported
|
||||
* read-only by every relay plan (P1–P6). Dependency-free (only zod). Changed ONLY via the INDEX.
|
||||
*/
|
||||
|
||||
// Shared primitives
|
||||
export * from './errors.js'
|
||||
export * from './bytes.js'
|
||||
export * from './base64url.js'
|
||||
|
||||
// §4.1 Mux frame codec + stream lifecycle
|
||||
export * from './mux/types.js'
|
||||
export * from './mux/cbor.js'
|
||||
export * from './mux/frame.js'
|
||||
export * from './mux/open.js'
|
||||
export * from './mux/control.js'
|
||||
|
||||
// §4.2 Account / host data model + revocation teardown + INV8 DDL
|
||||
export * from './model/account.js'
|
||||
export * from './model/revocation.js'
|
||||
export * from './model/ddl.js'
|
||||
|
||||
// §4.3 Capability token + FIX 5 subprotocol transport
|
||||
export * from './capability/token.js'
|
||||
export * from './capability/subprotocol.js'
|
||||
|
||||
// §4.4 E2E type surface + envelope codec + crypto signatures (impls in P4)
|
||||
export * from './e2e/types.js'
|
||||
export * from './e2e/envelope.js'
|
||||
export * from './e2e/crypto.js'
|
||||
|
||||
// §4.5 Pairing / enrollment
|
||||
export * from './pairing/enroll.js'
|
||||
77
relay-contracts/src/model/account.ts
Normal file
77
relay-contracts/src/model/account.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* §4.2 Account / host data model — Zod schemas + inferred TS types.
|
||||
* Postgres = ownership source of truth; Redis = live location (RouteEntry).
|
||||
* Records are immutable (INV8): updates insert a new row / swap a snapshot.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
|
||||
/** account.status (string-literal union, §4.2). */
|
||||
export type AccountStatus = 'active' | 'suspended'
|
||||
/** account.plan tier (string-literal union, §4.2). */
|
||||
export type PlanTier = 'free' | 'personal' | 'pro' | 'team'
|
||||
/** host.status (string-literal union, §4.2). */
|
||||
export type HostStatus = 'online' | 'offline' | 'draining' | 'revoked'
|
||||
|
||||
export const AccountStatusSchema = z.enum(['active', 'suspended'])
|
||||
export const PlanTierSchema = z.enum(['free', 'personal', 'pro', 'team'])
|
||||
export const HostStatusSchema = z.enum(['online', 'offline', 'draining', 'revoked'])
|
||||
|
||||
/** ISO-8601 timestamptz as a string (control-plane serialization boundary). */
|
||||
const IsoTimestamp = z.string().datetime({ offset: true })
|
||||
/** Unguessable UUID identifiers (INV1: host_id never recycled). */
|
||||
const Uuid = z.string().uuid()
|
||||
|
||||
/** accounts row (§4.2). */
|
||||
export const AccountRecordSchema = z
|
||||
.object({
|
||||
accountId: Uuid,
|
||||
plan: PlanTierSchema,
|
||||
createdAt: IsoTimestamp,
|
||||
status: AccountStatusSchema,
|
||||
})
|
||||
.strict()
|
||||
.readonly()
|
||||
export type AccountRecord = z.infer<typeof AccountRecordSchema>
|
||||
|
||||
/**
|
||||
* hosts row (§4.2). `agentPubkey` is the Ed25519 PUBLIC key only (INV4); the private
|
||||
* key never leaves the host. `enrollFpr` is pinned by the browser for E2E TOFU (§4.4).
|
||||
*/
|
||||
export const HostRecordSchema = z
|
||||
.object({
|
||||
hostId: Uuid,
|
||||
accountId: Uuid,
|
||||
subdomain: z.string().min(1),
|
||||
agentPubkey: z.instanceof(Uint8Array),
|
||||
enrollFpr: z.string().min(1),
|
||||
status: HostStatusSchema,
|
||||
lastSeen: IsoTimestamp,
|
||||
createdAt: IsoTimestamp,
|
||||
revokedAt: IsoTimestamp.nullable(),
|
||||
})
|
||||
.strict()
|
||||
.readonly()
|
||||
export type HostRecord = z.infer<typeof HostRecordSchema>
|
||||
|
||||
/** sessions row (§4.2). `accountId` is DENORMALIZED for O(1) deny-by-default authz (INV3/INV6). */
|
||||
export const SessionRecordSchema = z
|
||||
.object({
|
||||
sessionId: Uuid,
|
||||
hostId: Uuid,
|
||||
accountId: Uuid,
|
||||
createdAt: IsoTimestamp,
|
||||
lastAttachAt: IsoTimestamp,
|
||||
})
|
||||
.strict()
|
||||
.readonly()
|
||||
export type SessionRecord = z.infer<typeof SessionRecordSchema>
|
||||
|
||||
/** Redis `route:{host_id}` value — live routing table, NOT source of truth (INV7). */
|
||||
export const RouteEntrySchema = z
|
||||
.object({
|
||||
relayNodeId: z.string().min(1),
|
||||
updatedAt: IsoTimestamp,
|
||||
})
|
||||
.strict()
|
||||
.readonly()
|
||||
export type RouteEntry = z.infer<typeof RouteEntrySchema>
|
||||
28
relay-contracts/src/model/ddl.ts
Normal file
28
relay-contracts/src/model/ddl.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* §4.2 INV8 version-table DDL contract — immutable records + atomic snapshot swap.
|
||||
*
|
||||
* Mutable columns (hosts.status/last_seen, sessions.last_attach_at) are NEVER updated in
|
||||
* place. A new version row is inserted into `host_versions` and the `hosts_current` pointer
|
||||
* is CAS-swapped, so a concurrent read sees whole-old or whole-new — never a torn read.
|
||||
*
|
||||
* These DDL strings are the FROZEN contract P3 must apply verbatim (the migration owner is
|
||||
* P3; relay-contracts owns the shape so every plan agrees on the version-chain semantics).
|
||||
*/
|
||||
|
||||
/** Immutable per-host snapshot rows (append-only; `supersedes` forms an audit chain). */
|
||||
export const HOST_VERSIONS_DDL = `CREATE TABLE IF NOT EXISTS host_versions (
|
||||
version_id uuid PRIMARY KEY,
|
||||
host_id uuid NOT NULL REFERENCES hosts(host_id),
|
||||
snapshot jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
supersedes uuid NULL REFERENCES host_versions(version_id)
|
||||
);` as const
|
||||
|
||||
/** CAS-swapped pointer to the live snapshot (atomic current-version indirection). */
|
||||
export const HOSTS_CURRENT_DDL = `CREATE TABLE IF NOT EXISTS hosts_current (
|
||||
host_id uuid PRIMARY KEY REFERENCES hosts(host_id),
|
||||
version_id uuid NOT NULL REFERENCES host_versions(version_id)
|
||||
);` as const
|
||||
|
||||
/** Ordered DDL statements for the INV8 version tables. */
|
||||
export const INV8_VERSION_TABLE_DDL = [HOST_VERSIONS_DDL, HOSTS_CURRENT_DDL] as const
|
||||
48
relay-contracts/src/model/revocation.ts
Normal file
48
relay-contracts/src/model/revocation.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* §4.2 FIX 4 — control→data-plane revocation teardown shapes (INV12).
|
||||
*
|
||||
* Frozen HERE (not in relay-auth) so P1 relay nodes can SUBSCRIBE without importing P5,
|
||||
* keeping the dependency DAG acyclic. P3/P5 PUBLISH a KillSignal on the one named bus;
|
||||
* every P1 relay node subscribes and injects a §4.1 CLOSE+RST (host/account scope) or a
|
||||
* connection-level GOAWAY (global scope) — no new frame type.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
|
||||
/** The single named control→data-plane Redis pub/sub channel (§4.2 FIX 4). */
|
||||
export const RELAY_REVOCATIONS_CHANNEL = 'relay:revocations' as const
|
||||
|
||||
/** Teardown budget: a live tunnel must drop within this many ms of revocation (INV12). */
|
||||
export const REVOCATION_PUSH_BUDGET_MS = 2000 as const
|
||||
|
||||
/** What a KillSignal targets (§4.2). */
|
||||
export type RevocationScope =
|
||||
| { readonly kind: 'host'; readonly hostId: string }
|
||||
| { readonly kind: 'account'; readonly accountId: string }
|
||||
| { readonly kind: 'global' }
|
||||
|
||||
export const RevocationScopeSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('host'), hostId: z.string().uuid() }).strict(),
|
||||
z.object({ kind: z.literal('account'), accountId: z.string().uuid() }).strict(),
|
||||
z.object({ kind: z.literal('global') }).strict(),
|
||||
])
|
||||
|
||||
/** Signal published on `relay:revocations` (§4.2). `reason` is metadata only, ZERO payload (INV10). */
|
||||
export interface KillSignal {
|
||||
readonly scope: RevocationScope
|
||||
readonly at: number // epoch seconds the revocation was issued
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
export const KillSignalSchema = z
|
||||
.object({
|
||||
scope: RevocationScopeSchema,
|
||||
at: z.number().int().nonnegative(),
|
||||
reason: z.string(),
|
||||
})
|
||||
.strict()
|
||||
.readonly()
|
||||
|
||||
/** Transport contract: P3/P1 implement the `relay:revocations` publish side. */
|
||||
export interface RevocationBus {
|
||||
publish(signal: KillSignal): Promise<void>
|
||||
}
|
||||
162
relay-contracts/src/mux/cbor.ts
Normal file
162
relay-contracts/src/mux/cbor.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Minimal deterministic CBOR (RFC 8949) subset — just enough for the §4.1 MuxOpen
|
||||
* payload: text strings (major 3), unsigned integers (major 0), and one flat map
|
||||
* (major 5). Hand-rolled (KISS / dependency-free) so the browser and agent bundles
|
||||
* agree byte-for-byte without pulling a full CBOR library.
|
||||
*
|
||||
* Determinism: maps are encoded in a caller-fixed key order (MuxOpen has a frozen
|
||||
* field order), giving a stable byte vector for KAT tests. Decoding is order-independent
|
||||
* (reads into an object, then Zod validates).
|
||||
*/
|
||||
import { ContractDecodeError, ContractEncodeError } from '../errors.js'
|
||||
import { concatBytes } from '../bytes.js'
|
||||
|
||||
export type CborScalar = string | number
|
||||
export type CborMap = Readonly<Record<string, CborScalar>>
|
||||
|
||||
const textEncoder = new TextEncoder()
|
||||
const textDecoder = new TextDecoder('utf-8', { fatal: true })
|
||||
|
||||
/** Encode a CBOR length/value head for the given major type (0..7). */
|
||||
function encodeHead(major: number, n: number): Uint8Array {
|
||||
if (!Number.isInteger(n) || n < 0) {
|
||||
throw new ContractEncodeError(`CBOR head requires a non-negative integer; got ${n}`)
|
||||
}
|
||||
const prefix = major << 5
|
||||
if (n < 24) return Uint8Array.of(prefix | n)
|
||||
if (n < 0x100) return Uint8Array.of(prefix | 24, n)
|
||||
if (n < 0x1_0000) return Uint8Array.of(prefix | 25, (n >> 8) & 0xff, n & 0xff)
|
||||
if (n < 0x1_0000_0000) {
|
||||
return Uint8Array.of(
|
||||
prefix | 26,
|
||||
(n >>> 24) & 0xff,
|
||||
(n >>> 16) & 0xff,
|
||||
(n >>> 8) & 0xff,
|
||||
n & 0xff,
|
||||
)
|
||||
}
|
||||
throw new ContractEncodeError(`CBOR head value too large for this subset: ${n}`)
|
||||
}
|
||||
|
||||
function encodeUint(n: number): Uint8Array {
|
||||
return encodeHead(0, n)
|
||||
}
|
||||
|
||||
function encodeText(s: string): Uint8Array {
|
||||
const utf8 = textEncoder.encode(s)
|
||||
return concatBytes([encodeHead(3, utf8.length), utf8])
|
||||
}
|
||||
|
||||
function encodeScalar(value: CborScalar): Uint8Array {
|
||||
if (typeof value === 'string') return encodeText(value)
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new ContractEncodeError(`CBOR scalar number must be a non-negative integer; got ${value}`)
|
||||
}
|
||||
return encodeUint(value)
|
||||
}
|
||||
throw new ContractEncodeError(`unsupported CBOR scalar type: ${typeof value}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a flat map in the caller-provided key order (deterministic).
|
||||
* `keyOrder` must list exactly the keys present in `map`.
|
||||
*/
|
||||
export function encodeCborMap(map: CborMap, keyOrder: readonly string[]): Uint8Array {
|
||||
const keys = Object.keys(map)
|
||||
if (keys.length !== keyOrder.length) {
|
||||
throw new ContractEncodeError(
|
||||
`CBOR map has ${keys.length} keys but keyOrder lists ${keyOrder.length}`,
|
||||
)
|
||||
}
|
||||
const chunks: Uint8Array[] = [encodeHead(5, keyOrder.length)]
|
||||
for (const key of keyOrder) {
|
||||
if (!Object.prototype.hasOwnProperty.call(map, key)) {
|
||||
throw new ContractEncodeError(`CBOR map missing key '${key}' named in keyOrder`)
|
||||
}
|
||||
chunks.push(encodeText(key))
|
||||
chunks.push(encodeScalar(map[key]!))
|
||||
}
|
||||
return concatBytes(chunks)
|
||||
}
|
||||
|
||||
interface Cursor {
|
||||
readonly buf: Uint8Array
|
||||
offset: number
|
||||
}
|
||||
|
||||
function readHead(c: Cursor): { major: number; value: number } {
|
||||
if (c.offset >= c.buf.length) {
|
||||
throw new ContractDecodeError('CBOR truncated: expected a head byte')
|
||||
}
|
||||
const first = c.buf[c.offset]!
|
||||
c.offset += 1
|
||||
const major = first >> 5
|
||||
const info = first & 0x1f
|
||||
if (info < 24) return { major, value: info }
|
||||
if (info === 24) {
|
||||
if (c.offset + 1 > c.buf.length) throw new ContractDecodeError('CBOR truncated uint8 arg')
|
||||
const v = c.buf[c.offset]!
|
||||
c.offset += 1
|
||||
return { major, value: v }
|
||||
}
|
||||
if (info === 25) {
|
||||
if (c.offset + 2 > c.buf.length) throw new ContractDecodeError('CBOR truncated uint16 arg')
|
||||
const v = (c.buf[c.offset]! << 8) | c.buf[c.offset + 1]!
|
||||
c.offset += 2
|
||||
return { major, value: v }
|
||||
}
|
||||
if (info === 26) {
|
||||
if (c.offset + 4 > c.buf.length) throw new ContractDecodeError('CBOR truncated uint32 arg')
|
||||
const v =
|
||||
((c.buf[c.offset]! << 24) >>> 0) +
|
||||
(c.buf[c.offset + 1]! << 16) +
|
||||
(c.buf[c.offset + 2]! << 8) +
|
||||
c.buf[c.offset + 3]!
|
||||
c.offset += 4
|
||||
return { major, value: v }
|
||||
}
|
||||
throw new ContractDecodeError(`unsupported CBOR additional-info ${info} in this subset`)
|
||||
}
|
||||
|
||||
function readScalar(c: Cursor): CborScalar {
|
||||
const { major, value } = readHead(c)
|
||||
if (major === 0) return value // unsigned int
|
||||
if (major === 3) {
|
||||
// text string
|
||||
const end = c.offset + value
|
||||
if (end > c.buf.length) throw new ContractDecodeError('CBOR truncated text string')
|
||||
const slice = c.buf.subarray(c.offset, end)
|
||||
c.offset = end
|
||||
try {
|
||||
return textDecoder.decode(slice)
|
||||
} catch {
|
||||
throw new ContractDecodeError('CBOR text string is not valid UTF-8')
|
||||
}
|
||||
}
|
||||
throw new ContractDecodeError(`unsupported CBOR scalar major type ${major} in this subset`)
|
||||
}
|
||||
|
||||
/** Decode a flat CBOR map into a plain object of scalars. Rejects trailing bytes. */
|
||||
export function decodeCborMap(buf: Uint8Array): Record<string, CborScalar> {
|
||||
const c: Cursor = { buf, offset: 0 }
|
||||
const { major, value: count } = readHead(c)
|
||||
if (major !== 5) {
|
||||
throw new ContractDecodeError(`expected CBOR map (major 5); got major ${major}`)
|
||||
}
|
||||
const out: Record<string, CborScalar> = {}
|
||||
for (let i = 0; i < count; i++) {
|
||||
const key = readScalar(c)
|
||||
if (typeof key !== 'string') {
|
||||
throw new ContractDecodeError('CBOR map key must be a text string')
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(out, key)) {
|
||||
throw new ContractDecodeError(`duplicate CBOR map key '${key}'`)
|
||||
}
|
||||
out[key] = readScalar(c)
|
||||
}
|
||||
if (c.offset !== buf.length) {
|
||||
throw new ContractDecodeError(`trailing bytes after CBOR map: ${buf.length - c.offset} extra`)
|
||||
}
|
||||
return out
|
||||
}
|
||||
64
relay-contracts/src/mux/control.ts
Normal file
64
relay-contracts/src/mux/control.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* §4.1 control-frame payload codecs: WINDOW_UPDATE and GOAWAY.
|
||||
*
|
||||
* Per §4.1's explicit byte spec these are fixed-width big-endian integers (NOT CBOR):
|
||||
* WINDOW_UPDATE payload = uint32 credit
|
||||
* GOAWAY payload = uint32 lastStreamId ‖ uint32 reason
|
||||
* (PLAN_RELAY_INDEX §2.1 loosely groups them under "CBOR payload codecs"; §4.1 is the
|
||||
* authoritative wire and wins — see report deviation note.)
|
||||
*/
|
||||
import { ContractDecodeError } from '../errors.js'
|
||||
import { assertUint32, readUint32BE, writeUint32BE } from '../bytes.js'
|
||||
import {
|
||||
GOAWAY_CODE_TO_REASON,
|
||||
GOAWAY_REASON_TO_CODE,
|
||||
type GoAwayReason,
|
||||
} from './types.js'
|
||||
|
||||
/** Map a GOAWAY wire code to its frozen reason label; throws on unknown code. */
|
||||
export function decodeGoAwayReason(code: number): GoAwayReason {
|
||||
const reason = GOAWAY_CODE_TO_REASON[code]
|
||||
if (reason === undefined) {
|
||||
throw new ContractDecodeError(`unknown GOAWAY reason code ${code}`)
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
/** WINDOW_UPDATE: encode a uint32 credit increment. */
|
||||
export function encodeWindowUpdate(credit: number): Uint8Array {
|
||||
assertUint32(credit, 'credit')
|
||||
const out = new Uint8Array(4)
|
||||
writeUint32BE(out, 0, credit)
|
||||
return out
|
||||
}
|
||||
|
||||
/** WINDOW_UPDATE: decode the uint32 credit increment. */
|
||||
export function decodeWindowUpdate(payload: Uint8Array): number {
|
||||
if (payload.length !== 4) {
|
||||
throw new ContractDecodeError(`WINDOW_UPDATE payload must be 4 bytes; got ${payload.length}`)
|
||||
}
|
||||
return readUint32BE(payload, 0)
|
||||
}
|
||||
|
||||
/** GOAWAY: encode uint32 lastStreamId ‖ uint32 reason-code. */
|
||||
export function encodeGoaway(lastStreamId: number, reason: GoAwayReason): Uint8Array {
|
||||
assertUint32(lastStreamId, 'lastStreamId')
|
||||
const code = GOAWAY_REASON_TO_CODE[reason]
|
||||
if (code === undefined) {
|
||||
throw new ContractDecodeError(`unknown GOAWAY reason label '${reason}'`)
|
||||
}
|
||||
const out = new Uint8Array(8)
|
||||
let offset = writeUint32BE(out, 0, lastStreamId)
|
||||
writeUint32BE(out, offset, code)
|
||||
return out
|
||||
}
|
||||
|
||||
/** GOAWAY: decode uint32 lastStreamId ‖ uint32 reason-code. */
|
||||
export function decodeGoaway(payload: Uint8Array): { lastStreamId: number; reason: GoAwayReason } {
|
||||
if (payload.length !== 8) {
|
||||
throw new ContractDecodeError(`GOAWAY payload must be 8 bytes; got ${payload.length}`)
|
||||
}
|
||||
const lastStreamId = readUint32BE(payload, 0)
|
||||
const reason = decodeGoAwayReason(readUint32BE(payload, 4))
|
||||
return { lastStreamId, reason }
|
||||
}
|
||||
97
relay-contracts/src/mux/frame.ts
Normal file
97
relay-contracts/src/mux/frame.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* §4.1 Mux frame codec — the 15-byte fixed header + opaque payload.
|
||||
*
|
||||
* Layout (big-endian):
|
||||
* offset size field
|
||||
* 0 1 version 0x01
|
||||
* 1 1 type 0x01 OPEN … 0x07 GOAWAY
|
||||
* 2 1 flags bit0 FIN · bit1 RST · others reserved=0
|
||||
* 3 4 streamId uint32 (0 = connection-level)
|
||||
* 7 8 payloadLen uint64 (safe-int guarded)
|
||||
* 15 var payload opaque bytes
|
||||
*/
|
||||
import { ContractDecodeError, ContractEncodeError } from '../errors.js'
|
||||
import { assertUint32, readSafeUint64BE, writeUint32BE, writeUint64BE } from '../bytes.js'
|
||||
import {
|
||||
FLAG_FIN,
|
||||
FLAG_RST,
|
||||
FLAG_RESERVED_MASK,
|
||||
MUX_CODE_TO_TYPE,
|
||||
MUX_HEADER_BYTES,
|
||||
MUX_TYPE_TO_CODE,
|
||||
MUX_VERSION,
|
||||
type MuxFrameHeader,
|
||||
} from './types.js'
|
||||
|
||||
function encodeFlags(fin: boolean, rst: boolean): number {
|
||||
return (fin ? FLAG_FIN : 0) | (rst ? FLAG_RST : 0)
|
||||
}
|
||||
|
||||
/** Encode a full mux frame. `header.payloadLen` MUST equal `payload.length`. */
|
||||
export function encodeMuxFrame(header: MuxFrameHeader, payload: Uint8Array): Uint8Array {
|
||||
if (header.version !== MUX_VERSION) {
|
||||
throw new ContractEncodeError(`unsupported mux version ${header.version} (expected ${MUX_VERSION})`)
|
||||
}
|
||||
const typeCode = MUX_TYPE_TO_CODE[header.type]
|
||||
if (typeCode === undefined) {
|
||||
throw new ContractEncodeError(`unknown mux frame type '${header.type}'`)
|
||||
}
|
||||
assertUint32(header.streamId, 'streamId')
|
||||
if (header.payloadLen !== payload.length) {
|
||||
throw new ContractEncodeError(
|
||||
`header.payloadLen (${header.payloadLen}) must equal payload.length (${payload.length})`,
|
||||
)
|
||||
}
|
||||
const out = new Uint8Array(MUX_HEADER_BYTES + payload.length)
|
||||
out[0] = MUX_VERSION
|
||||
out[1] = typeCode
|
||||
out[2] = encodeFlags(header.fin, header.rst)
|
||||
let offset = writeUint32BE(out, 3, header.streamId)
|
||||
offset = writeUint64BE(out, offset, BigInt(payload.length))
|
||||
out.set(payload, MUX_HEADER_BYTES)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Decode ONLY the 15-byte header (no payload slice). */
|
||||
export function decodeHeader(buf: Uint8Array): MuxFrameHeader {
|
||||
if (buf.length < MUX_HEADER_BYTES) {
|
||||
throw new ContractDecodeError(
|
||||
`mux frame shorter than header: ${buf.length} < ${MUX_HEADER_BYTES}`,
|
||||
)
|
||||
}
|
||||
const version = buf[0]!
|
||||
if (version !== MUX_VERSION) {
|
||||
throw new ContractDecodeError(`unsupported mux version ${version} (expected ${MUX_VERSION})`)
|
||||
}
|
||||
const type = MUX_CODE_TO_TYPE[buf[1]!]
|
||||
if (type === undefined) {
|
||||
throw new ContractDecodeError(`unknown mux frame type code 0x${buf[1]!.toString(16)}`)
|
||||
}
|
||||
const flags = buf[2]!
|
||||
if ((flags & FLAG_RESERVED_MASK) !== 0) {
|
||||
throw new ContractDecodeError(`reserved flag bits set: 0x${flags.toString(16)}`)
|
||||
}
|
||||
const streamId = ((buf[3]! << 24) >>> 0) + (buf[4]! << 16) + (buf[5]! << 8) + buf[6]!
|
||||
const payloadLen = readSafeUint64BE(buf, 7, 'payloadLen')
|
||||
return {
|
||||
version: MUX_VERSION,
|
||||
type,
|
||||
fin: (flags & FLAG_FIN) !== 0,
|
||||
rst: (flags & FLAG_RST) !== 0,
|
||||
streamId,
|
||||
payloadLen,
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode a full mux frame into its header + payload slice. Rejects a truncated payload. */
|
||||
export function decodeMuxFrame(buf: Uint8Array): { header: MuxFrameHeader; payload: Uint8Array } {
|
||||
const header = decodeHeader(buf)
|
||||
const end = MUX_HEADER_BYTES + header.payloadLen
|
||||
if (buf.length < end) {
|
||||
throw new ContractDecodeError(
|
||||
`truncated payload: have ${buf.length - MUX_HEADER_BYTES} bytes, header says ${header.payloadLen}`,
|
||||
)
|
||||
}
|
||||
const payload = buf.slice(MUX_HEADER_BYTES, end)
|
||||
return { header, payload }
|
||||
}
|
||||
41
relay-contracts/src/mux/open.ts
Normal file
41
relay-contracts/src/mux/open.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* §4.1 OPEN payload codec — CBOR of MuxOpen, Zod-guarded on decode.
|
||||
* Deterministic fixed field order so the byte vector is stable for KAT.
|
||||
*/
|
||||
import { ContractDecodeError } from '../errors.js'
|
||||
import { decodeCborMap, encodeCborMap, type CborMap } from './cbor.js'
|
||||
import { MuxOpenSchema, type MuxOpen } from './types.js'
|
||||
|
||||
/** Frozen deterministic CBOR key order for MuxOpen (§4.1). */
|
||||
export const MUX_OPEN_KEY_ORDER = [
|
||||
'streamId',
|
||||
'subdomain',
|
||||
'requestPath',
|
||||
'originHeader',
|
||||
'remoteAddrHash',
|
||||
'capabilityTokenRef',
|
||||
] as const
|
||||
|
||||
/** Encode a MuxOpen into its CBOR payload. Validates the shape before encoding. */
|
||||
export function encodeOpen(open: MuxOpen): Uint8Array {
|
||||
const parsed = MuxOpenSchema.parse(open)
|
||||
const map: CborMap = {
|
||||
streamId: parsed.streamId,
|
||||
subdomain: parsed.subdomain,
|
||||
requestPath: parsed.requestPath,
|
||||
originHeader: parsed.originHeader,
|
||||
remoteAddrHash: parsed.remoteAddrHash,
|
||||
capabilityTokenRef: parsed.capabilityTokenRef,
|
||||
}
|
||||
return encodeCborMap(map, MUX_OPEN_KEY_ORDER)
|
||||
}
|
||||
|
||||
/** Decode a CBOR OPEN payload into a validated MuxOpen (throws on bad shape). */
|
||||
export function decodeOpen(payload: Uint8Array): MuxOpen {
|
||||
const raw = decodeCborMap(payload)
|
||||
const result = MuxOpenSchema.safeParse(raw)
|
||||
if (!result.success) {
|
||||
throw new ContractDecodeError(`invalid MuxOpen payload: ${result.error.message}`)
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
106
relay-contracts/src/mux/types.ts
Normal file
106
relay-contracts/src/mux/types.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* §4.1 Mux frame format — shared types & frozen wire codes.
|
||||
*
|
||||
* Frame header is 15 bytes fixed (big-endian / network order), then `payloadLen` bytes.
|
||||
* All numeric wire codes below are FROZEN by PLAN_RELAY_INDEX §4.1 and MUST NOT be
|
||||
* redefined by any plan.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
|
||||
/** Fixed header size in bytes (§4.1). */
|
||||
export const MUX_HEADER_BYTES = 15 as const
|
||||
|
||||
/** Protocol version byte (§4.1: version = 0x01). */
|
||||
export const MUX_VERSION = 1 as const
|
||||
|
||||
/** Frame type as a string-literal union (NOT an enum), per §4.1. */
|
||||
export type MuxFrameType =
|
||||
| 'open'
|
||||
| 'data'
|
||||
| 'close'
|
||||
| 'ping'
|
||||
| 'pong'
|
||||
| 'windowUpdate'
|
||||
| 'goaway'
|
||||
|
||||
/** Frozen label ⇄ wire-code maps (§4.1: 0x01 OPEN … 0x07 GOAWAY). */
|
||||
export const MUX_TYPE_TO_CODE: Readonly<Record<MuxFrameType, number>> = {
|
||||
open: 0x01,
|
||||
data: 0x02,
|
||||
close: 0x03,
|
||||
ping: 0x04,
|
||||
pong: 0x05,
|
||||
windowUpdate: 0x06,
|
||||
goaway: 0x07,
|
||||
}
|
||||
|
||||
export const MUX_CODE_TO_TYPE: Readonly<Record<number, MuxFrameType>> = {
|
||||
0x01: 'open',
|
||||
0x02: 'data',
|
||||
0x03: 'close',
|
||||
0x04: 'ping',
|
||||
0x05: 'pong',
|
||||
0x06: 'windowUpdate',
|
||||
0x07: 'goaway',
|
||||
}
|
||||
|
||||
/** flags byte bit positions (§4.1: bit0 FIN, bit1 RST, others reserved=0). */
|
||||
export const FLAG_FIN = 0b0000_0001 as const
|
||||
export const FLAG_RST = 0b0000_0010 as const
|
||||
export const FLAG_RESERVED_MASK = 0b1111_1100 as const
|
||||
|
||||
/** streamId 0 is connection-level control (PING/PONG/GOAWAY/WINDOW_UPDATE for the whole link). */
|
||||
export const CONNECTION_STREAM_ID = 0 as const
|
||||
|
||||
export const UINT32_MAX = 0xffff_ffff as const
|
||||
|
||||
/** Frame header — 15 bytes fixed (§4.1). */
|
||||
export interface MuxFrameHeader {
|
||||
readonly version: 1
|
||||
readonly type: MuxFrameType
|
||||
readonly fin: boolean
|
||||
readonly rst: boolean
|
||||
readonly streamId: number // uint32; 0 = connection-level
|
||||
readonly payloadLen: number // uint64 (safe-int guarded ≤ maxFrameBytes)
|
||||
}
|
||||
|
||||
/** OPEN payload (relay→agent), carried as CBOR (§4.1). Carries NO terminal bytes. */
|
||||
export interface MuxOpen {
|
||||
readonly streamId: number
|
||||
readonly subdomain: string
|
||||
readonly requestPath: string // e.g. '/term?join=<id>' — opaque passthrough
|
||||
readonly originHeader: string // real browser Origin, validated end-to-end
|
||||
readonly remoteAddrHash: string // salted hash of client IP (audit only, INV10)
|
||||
readonly capabilityTokenRef: string // jti of the token that authorized this stream (P5)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zod schema for MuxOpen — validated at the decode boundary (§4.1, "Input Validation").
|
||||
* streamId is a uint32 > 0 (0 is reserved for connection-level control, never a stream OPEN).
|
||||
*/
|
||||
export const MuxOpenSchema = z
|
||||
.object({
|
||||
streamId: z.number().int().gt(0).max(UINT32_MAX),
|
||||
subdomain: z.string(),
|
||||
requestPath: z.string(),
|
||||
originHeader: z.string(),
|
||||
remoteAddrHash: z.string(),
|
||||
capabilityTokenRef: z.string(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
/** GOAWAY reason as a string-literal union (NOT an enum); wire code ⇄ label (§4.1). */
|
||||
export type GoAwayReason = 'operatorDrain' | 'revoked' | 'shutdown'
|
||||
|
||||
/** Frozen reason label ⇄ wire-code maps (§4.1: 1 operatorDrain · 2 revoked · 3 shutdown). */
|
||||
export const GOAWAY_REASON_TO_CODE: Readonly<Record<GoAwayReason, number>> = {
|
||||
operatorDrain: 1,
|
||||
revoked: 2,
|
||||
shutdown: 3,
|
||||
}
|
||||
|
||||
export const GOAWAY_CODE_TO_REASON: Readonly<Record<number, GoAwayReason>> = {
|
||||
1: 'operatorDrain',
|
||||
2: 'revoked',
|
||||
3: 'shutdown',
|
||||
}
|
||||
50
relay-contracts/src/pairing/enroll.ts
Normal file
50
relay-contracts/src/pairing/enroll.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* §4.5 Pairing / enrollment shapes. P3 issues pairing codes and returns EnrollResult at BIND;
|
||||
* P2 redeems. `hostContentSecret` (FIX 3) is wrapped to the agent's enrolled Ed25519 identity
|
||||
* by P3 and unwrapped locally by P2 (private key never leaves the host, INV4).
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Result of a successful `POST /enroll` (§4.5). `hostContentSecret` is the wrapped-to-agent
|
||||
* input to §4.4 `deriveContentKey`; NEVER logged, NEVER sent to the relay.
|
||||
*/
|
||||
export interface EnrollResult {
|
||||
readonly hostId: string
|
||||
readonly subdomain: string
|
||||
readonly cert: string
|
||||
readonly caChain: string
|
||||
readonly hostContentSecret: Uint8Array // FIX 3 — wrapped to agent_pubkey by P3 at BIND
|
||||
}
|
||||
|
||||
export const EnrollResultSchema = z
|
||||
.object({
|
||||
hostId: z.string().uuid(),
|
||||
subdomain: z.string().min(1),
|
||||
cert: z.string().min(1),
|
||||
caChain: z.string().min(1),
|
||||
hostContentSecret: z.instanceof(Uint8Array),
|
||||
})
|
||||
.strict()
|
||||
.readonly()
|
||||
|
||||
/**
|
||||
* pairing_codes row (§4.2/§4.5). Only the HASH of the single-use code is stored (INV5);
|
||||
* a non-null `redeemedAt` marks it spent (single-use, atomic compare-and-set at redeem).
|
||||
*/
|
||||
export interface PairingCodeRecord {
|
||||
readonly codeHash: string
|
||||
readonly accountId: string
|
||||
readonly expiresAt: string
|
||||
readonly redeemedAt: string | null
|
||||
}
|
||||
|
||||
export const PairingCodeRecordSchema = z
|
||||
.object({
|
||||
codeHash: z.string().min(1),
|
||||
accountId: z.string().uuid(),
|
||||
expiresAt: z.string().datetime({ offset: true }),
|
||||
redeemedAt: z.string().datetime({ offset: true }).nullable(),
|
||||
})
|
||||
.strict()
|
||||
.readonly()
|
||||
Reference in New Issue
Block a user