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:
Yaojia Wang
2026-07-02 06:10:16 +02:00
parent e4c327e25e
commit 2af57e6686
326 changed files with 40877 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
/**
* T9 · Rotation policy for short-TTL agent certs (INV14). Certs are short-lived (e.g. 1h) and
* renewed at ~50% of TTL, so revocation = "stop renewing + kill tunnel" (T10) — no CRL/OCSP.
* Rotation mid-tunnel is seamless: a new valid cert supersedes without dropping the stream.
*/
export interface RotationPlan {
readonly renewAtSeconds: number // lead time after issue to begin renewing (≈ 50% of certTtlSeconds)
readonly certTtlSeconds: number // total cert lifetime (short)
}
export const DEFAULT_CERT_TTL_SECONDS = 3600 as const
/** Default plan: 1h TTL, renew at 50%. */
export const DEFAULT_ROTATION_PLAN: RotationPlan = {
renewAtSeconds: DEFAULT_CERT_TTL_SECONDS / 2,
certTtlSeconds: DEFAULT_CERT_TTL_SECONDS,
}
/**
* true when `now` has reached the renew threshold (issuedAt + renewAtSeconds) OR the cert has
* expired; false when the cert is still fresh. `certNotAfter`/`now` are epoch seconds.
*/
export function shouldRotate(certNotAfter: number, now: number, plan: RotationPlan): boolean {
const issuedAt = certNotAfter - plan.certTtlSeconds
const renewThreshold = issuedAt + plan.renewAtSeconds
return now >= renewThreshold
}

View File

@@ -0,0 +1,38 @@
/**
* T9 · SPIFFE-ID scheme for per-host agent identity (INV4/INV14).
* Form: `spiffe://relay.<domain>/account/<accountId>/host/<hostId>`.
*/
import type { SpiffeId } from '../types.js'
import { SpiffeIdSchema } from '../types.js'
export class SpiffeError extends Error {
constructor(message: string) {
super(message)
this.name = 'SpiffeError'
}
}
/** Trust domain from env (INV9 config), defaulting to a safe placeholder for local/dev. */
export function trustDomain(env: NodeJS.ProcessEnv = process.env): string {
const d = env.RELAY_TRUST_DOMAIN
return d !== undefined && d.length > 0 ? d : 'example.com'
}
export function spiffeIdFor(accountId: string, hostId: string, domain = trustDomain()): SpiffeId {
if (accountId.length === 0 || hostId.length === 0) {
throw new SpiffeError('accountId and hostId are required')
}
const id = `spiffe://relay.${domain}/account/${accountId}/host/${hostId}`
return SpiffeIdSchema.parse(id)
}
const PARSE_RE =
/^spiffe:\/\/relay\.[a-z0-9.-]+\/account\/([A-Za-z0-9_-]+)\/host\/([A-Za-z0-9_-]+)$/
export function parseSpiffeId(uri: SpiffeId): { accountId: string; hostId: string } {
const parsed = SpiffeIdSchema.safeParse(uri) // rejects path-traversal / wildcard / malformed
if (!parsed.success) throw new SpiffeError('invalid SPIFFE-ID (malformed / traversal / wildcard)')
const m = PARSE_RE.exec(uri)
if (m === null) throw new SpiffeError('unparseable SPIFFE-ID')
return { accountId: m[1]!, hostId: m[2]! }
}

View File

@@ -0,0 +1,118 @@
/**
* T9 · mTLS agent-cert verification (INV4/INV14). Deny-by-default: an expired leaf, a chain that
* does not verify against the CA, or a SPIFFE-ID whose account/host is not enrolled in the registry
* is refused. The CA never validates a pubkey not enrolled (INV4).
*
* Cert parsing/chain verification is delegated to an injectable `ParseCert` seam (default: node
* `X509Certificate`) so the P5-owned decision logic (SPIFFE match + registry lookup + expiry) is
* deterministically testable and the real X.509 verification still ships in production.
*
* `caChainPem` is a PEM **bundle** that may hold multiple concatenated certificates (intermediate(s)
* + root, INV14). `defaultParseX509` splits every PEM block and performs full path validation —
* walking leaf → issuer → … until it terminates at a self-signed root that is present in the bundle
* (the trust anchor set). A single-hop issuer check is NOT sufficient: a real chain must be walked so
* a leaf signed by an untrusted or missing intermediate is refused.
*/
import { X509Certificate } from 'node:crypto'
import type { HostRegistryPort } from '../types.js'
import { parseSpiffeId } from './spiffe.js'
/** Cap chain-walk depth so a malformed/cyclic bundle can never loop unboundedly. */
const MAX_CHAIN_DEPTH = 8
const PEM_CERT_RE = /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g
/** Parse every PEM certificate block in a bundle into X509Certificate objects (order preserved). */
export function splitPemBundle(bundle: string): readonly X509Certificate[] {
const blocks = bundle.match(PEM_CERT_RE) ?? []
return blocks.map((block) => new X509Certificate(block))
}
/**
* Full X.509 path validation: walk leaf → issuer → … through the trusted CA set until a self-signed
* root in the set is reached. Each hop must both name the issuer (`checkIssued`) AND cryptographically
* verify against the issuer's public key (`verify`). Returns false if no path terminates at a trusted
* self-signed root, or if the bundle is empty, or on a cycle. This rejects a leaf whose intermediate
* is missing/untrusted — unlike a single-hop check against just the first cert in the bundle.
*/
export function verifyChain(
leaf: X509Certificate,
caCerts: readonly X509Certificate[],
): boolean {
if (caCerts.length === 0) return false
let current = leaf
const seen = new Set<string>()
for (let depth = 0; depth < MAX_CHAIN_DEPTH; depth++) {
const issuer = caCerts.find((ca) => current.checkIssued(ca) && current.verify(ca.publicKey))
if (issuer === undefined) return false
if (seen.has(issuer.fingerprint256)) return false // cycle guard
seen.add(issuer.fingerprint256)
if (issuer.checkIssued(issuer)) return true // reached a trusted self-signed root
current = issuer
}
return false
}
export interface MtlsVerifyResult {
readonly ok: boolean
readonly hostId?: string
readonly accountId?: string
readonly reason?: string
}
/** Minimal parsed-cert surface the decision logic needs. */
export interface ParsedCert {
readonly spiffeUri: string | null // SPIFFE-ID from the SAN URI, if present
readonly notBefore: number // epoch seconds
readonly notAfter: number // epoch seconds
readonly chainValid: boolean // leaf verifies against the provided CA chain
}
export type ParseCert = (leafPem: string, caChainPem: string) => ParsedCert
/** Default parser using node's X509Certificate with full multi-cert chain-path validation. */
export const defaultParseX509: ParseCert = (leafPem, caChainPem) => {
const leaf = new X509Certificate(leafPem)
const caCerts = splitPemBundle(caChainPem)
const san = leaf.subjectAltName ?? ''
const uriMatch = /URI:(spiffe:\/\/[^,\s]+)/.exec(san)
return {
spiffeUri: uriMatch !== null ? uriMatch[1]! : null,
notBefore: Math.floor(new Date(leaf.validFrom).getTime() / 1000),
notAfter: Math.floor(new Date(leaf.validTo).getTime() / 1000),
chainValid: verifyChain(leaf, caCerts),
}
}
export async function verifyAgentCert(
leafPem: string,
caChainPem: string,
now: number,
hosts: HostRegistryPort,
parse: ParseCert = defaultParseX509,
): Promise<MtlsVerifyResult> {
let cert: ParsedCert
try {
cert = parse(leafPem, caChainPem)
} catch {
return { ok: false, reason: 'unparseable_cert' }
}
if (!cert.chainValid) return { ok: false, reason: 'chain_invalid' }
if (now < cert.notBefore) return { ok: false, reason: 'not_yet_valid' }
if (now > cert.notAfter) return { ok: false, reason: 'expired' }
if (cert.spiffeUri === null) return { ok: false, reason: 'no_spiffe_san' }
let ids: { accountId: string; hostId: string }
try {
ids = parseSpiffeId(cert.spiffeUri)
} catch {
return { ok: false, reason: 'bad_spiffe_id' }
}
const host = await hosts.getById(ids.hostId)
if (host === null) return { ok: false, reason: 'host_not_enrolled' } // INV4: not in registry
if (host.accountId !== ids.accountId) return { ok: false, reason: 'spiffe_account_mismatch' }
if (host.status === 'revoked') return { ok: false, reason: 'host_revoked' }
return { ok: true, hostId: ids.hostId, accountId: ids.accountId }
}

View File

@@ -0,0 +1,19 @@
/**
* T14 · Audit-alert wiring. Turns the INV1 tripwire into a RUNTIME detector: a
* `cross-tenant-attempt` audit event fires an operator alert immediately (EXPLORE §4e HIGH). Alert
* payload carries metadata only (no terminal bytes, INV10).
*/
import type { AuditEvent } from '../types.js'
export type AlertKind = 'cross-tenant' | 'revocation' | 'auth-anomaly'
export interface Alerter {
fire(kind: AlertKind, e: AuditEvent): Promise<void>
}
/** Fires a `cross-tenant` alert on `action === 'cross-tenant-attempt'`; no-op otherwise. */
export async function onAuditEvent(e: AuditEvent, alerter: Alerter): Promise<void> {
if (e.action === 'cross-tenant-attempt') {
await alerter.fire('cross-tenant', e)
}
}

View File

@@ -0,0 +1,43 @@
/**
* T4 · Immutable, append-only, ZERO-payload audit log (INV10). Entries are metadata only —
* principal, host_id, action, ts, outcome, reason, salted remoteAddrHash. Terminal payload is
* structurally excluded (no field carries it; `assertZeroPayload` enforces it before every append).
*/
import type { AuditAction, AuditEvent, AuditOutcome, AuthenticatedPrincipal, AuditSink } from '../types.js'
import { AuditEventSchema } from '../types.js'
import { assertZeroPayload } from './redact.js'
export interface BuildAuditArgs {
readonly action: AuditAction
readonly principal: AuthenticatedPrincipal | null
readonly hostId: string | null
readonly sessionId: string | null
readonly jti: string | null
readonly outcome: AuditOutcome
readonly reason: string
readonly remoteAddrHash: string
readonly now: number
}
export function buildAuditEvent(p: BuildAuditArgs): AuditEvent {
const event: AuditEvent = {
ts: new Date(p.now * 1000).toISOString(),
action: p.action,
principalId: p.principal?.principalId ?? '',
accountId: p.principal?.accountId ?? '',
hostId: p.hostId,
sessionId: p.sessionId,
jti: p.jti,
outcome: p.outcome,
reason: p.reason,
remoteAddrHash: p.remoteAddrHash,
}
AuditEventSchema.parse(event) // strict schema rejects any smuggled extra key (INV10)
return event
}
/** Append-only (never update/delete). Guarded by `assertZeroPayload` (INV10). */
export async function audit(sink: AuditSink, e: AuditEvent): Promise<void> {
assertZeroPayload(e)
await sink.append(e)
}

View File

@@ -0,0 +1,42 @@
/**
* T4 · INV10 zero-payload guard. Audit events are metadata ONLY; no field may carry terminal
* keystrokes/output. This rejects any string value that contains control/ESC bytes (a redraw
* sequence starts with `\x1b`) or exceeds a metadata length cap — a structural tripwire so a
* payload blob can never be smuggled through a `reason`/`hostId` field.
*/
import type { AuditEvent } from '../types.js'
export const MAX_METADATA_LEN = 256 as const
export class ZeroPayloadViolation extends Error {
constructor(message: string) {
super(message)
this.name = 'ZeroPayloadViolation'
}
}
/** Any C0 control char (incl. ESC \x1b), DEL, or C1 range → looks like terminal bytes. */
const CONTROL_CHARS = /[\u0000-\u001F\u007F-\u009F]/
function checkField(name: string, value: string): void {
if (value.length > MAX_METADATA_LEN) {
throw new ZeroPayloadViolation(`audit field '${name}' exceeds ${MAX_METADATA_LEN} chars`)
}
if (CONTROL_CHARS.test(value)) {
throw new ZeroPayloadViolation(`audit field '${name}' contains control/ESC bytes`)
}
}
/** Throws if any value looks like terminal bytes (INV10). */
export function assertZeroPayload(e: AuditEvent): void {
checkField('ts', e.ts)
checkField('action', e.action)
checkField('principalId', e.principalId)
checkField('accountId', e.accountId)
checkField('outcome', e.outcome)
checkField('reason', e.reason)
checkField('remoteAddrHash', e.remoteAddrHash)
if (e.hostId !== null) checkField('hostId', e.hostId)
if (e.sessionId !== null) checkField('sessionId', e.sessionId)
if (e.jti !== null) checkField('jti', e.jti)
}

View File

@@ -0,0 +1,105 @@
/**
* T3 · Deny-by-default tenant authz core (INV1/INV3/INV6).
*
* The ONLY authorization path. There is NO `allow-if-unspecified` branch: the function returns 403
* unless every check passes. A host is resolved ONLY by a registry lookup keyed on the
* token-scoped `host_id` (never by raw addr/hostname). `account_id` comes from the token's
* authenticated `sub` (via `accountIdFromToken`) and `host.accountId` from the registry — never a
* client field. Single-use consumption is done by T12 AFTER a full allow, so T3 stays pure.
*/
import type { CapabilityRight, CapabilityToken } from 'relay-contracts'
import type {
AuthenticatedPrincipal,
HostRegistryPort,
SessionRegistryPort,
RevocationStore,
} from '../types.js'
import { verifyCapabilityToken, verifyDpopProof, type DpopContext } from '../capability/verify.js'
import { accountIdFromToken, principalFromToken } from './principal.js'
export type AuthzOutcome =
| {
readonly ok: true
readonly principal: AuthenticatedPrincipal
readonly hostId: string
readonly jti: string
}
| { readonly ok: false; readonly status: 401 | 403; readonly reason: string }
export interface ConnectRequest {
readonly capabilityRaw: string
readonly expectedAud: string
readonly requestedHostId: string
readonly requiredRight: CapabilityRight
}
export interface ReattachRequest extends ConnectRequest {
readonly sessionId: string
}
function deny(status: 401 | 403, reason: string): AuthzOutcome {
return { ok: false, status, reason }
}
/** Verify token + DPoP + revocation + rights + single-host + cross-tenant gate. Shared core. */
async function coreAuthorize(
req: ConnectRequest,
dpop: DpopContext,
hosts: HostRegistryPort,
revocation: RevocationStore,
now: number,
): Promise<{ outcome: AuthzOutcome; accountId?: string; token?: CapabilityToken }> {
let token: CapabilityToken
try {
token = await verifyCapabilityToken(req.capabilityRaw, req.expectedAud, now)
} catch {
return { outcome: deny(401, 'invalid_capability_token') }
}
if (!(await verifyDpopProof(token, dpop, now))) {
return { outcome: deny(401, 'dpop_proof_failed') }
}
if (await revocation.isRevoked(token.jti)) return { outcome: deny(403, 'token_revoked') }
if (!token.rights.includes(req.requiredRight)) return { outcome: deny(403, 'insufficient_rights') }
if (token.host !== req.requestedHostId) return { outcome: deny(403, 'host_scope_mismatch') }
const host = await hosts.getById(req.requestedHostId)
if (host === null) return { outcome: deny(403, 'unknown_host') }
if (host.status === 'revoked') return { outcome: deny(403, 'host_revoked') }
const tokenAccountId = accountIdFromToken(token) // == token.sub == principal.accountId
if (host.accountId !== tokenAccountId) {
return { outcome: deny(403, 'cross_tenant'), accountId: tokenAccountId, token } // THE INV1 gate
}
return {
outcome: { ok: true, principal: principalFromToken(token), hostId: host.hostId, jti: token.jti },
accountId: tokenAccountId,
token,
}
}
export async function authorizeConnect(
req: ConnectRequest,
dpop: DpopContext,
hosts: HostRegistryPort,
revocation: RevocationStore,
now: number,
): Promise<AuthzOutcome> {
return (await coreAuthorize(req, dpop, hosts, revocation, now)).outcome
}
export async function authorizeReattach(
req: ReattachRequest,
dpop: DpopContext,
hosts: HostRegistryPort,
sessions: SessionRegistryPort,
revocation: RevocationStore,
now: number,
): Promise<AuthzOutcome> {
const base = await coreAuthorize(req, dpop, hosts, revocation, now)
if (!base.outcome.ok) return base.outcome
// INV6 re-validate: session must exist AND belong to the same account.
const session = await sessions.getById(req.sessionId)
if (session === null || session.accountId !== base.accountId) {
return deny(403, 'cross_tenant_session')
}
return base.outcome
}

View File

@@ -0,0 +1,35 @@
/**
* T3 · The SINGLE resolver mapping a verified capability token → its authoritative `accountId`.
* Per the frozen T1 convention (CAP_TOKEN_SUB_IS_ACCOUNT_ID), `sub` IS the accountId. Kept as a
* named, greppable function so the cross-tenant gate (INV1) compares a value derived only from
* authenticated material — never a client field (INV3).
*/
import type { CapabilityToken, AuthenticatedPrincipal } from '../types.js'
export class AuthzInputError extends Error {
constructor(message: string) {
super(message)
this.name = 'AuthzInputError'
}
}
/** Returns `token.sub` (the accountId); throws on empty/malformed sub (deny-by-default input guard). */
export function accountIdFromToken(token: CapabilityToken): string {
const sub = token.sub
if (typeof sub !== 'string' || sub.length === 0) {
throw new AuthzInputError('capability token has empty/malformed sub')
}
return sub
}
/** Synthesize the token-derived principal for an allow outcome (account-scoped; INV3). */
export function principalFromToken(token: CapabilityToken): AuthenticatedPrincipal {
return {
kind: 'human',
accountId: accountIdFromToken(token),
principalId: accountIdFromToken(token),
amr: [],
authAt: token.iat,
stepUpAt: null,
}
}

View 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
}
}

View 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
}
}

View 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 3060 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): 3060 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)
}

View 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)
}

View File

@@ -0,0 +1,44 @@
/**
* P5 verifying-key registry. `verifyCapabilityToken` / `verifyDeviceProof` are frozen §4.3/§4.4
* signatures with NO key parameter, so the P5 public verifying key is configured at startup and
* read here. Signing keys are passed explicitly to issue/sign functions (never held globally).
*
* INV9: the key is loaded from the secret manager / env, validated at startup, and NEVER logged.
*/
import { importEd25519PublicRaw } from '../crypto/ed25519.js'
import { decodeBase64UrlBytes } from 'relay-contracts'
let verifyKey: CryptoKey | null = null
export class KeyConfigError extends Error {
constructor(message: string) {
super(message)
this.name = 'KeyConfigError'
}
}
/** Configure the P5 Ed25519 public verifying key (called once at startup / in test setup). */
export function configureVerifyKey(key: CryptoKey): void {
verifyKey = key
}
/** Load the verifying key from `RELAY_AUTH_VERIFY_PUBKEY` (base64url raw Ed25519). INV9. */
export async function loadVerifyKeyFromEnv(env: NodeJS.ProcessEnv = process.env): Promise<void> {
const raw = env.RELAY_AUTH_VERIFY_PUBKEY
if (raw === undefined || raw.length === 0) {
throw new KeyConfigError('RELAY_AUTH_VERIFY_PUBKEY is not configured')
}
verifyKey = await importEd25519PublicRaw(decodeBase64UrlBytes(raw))
}
export function getVerifyKey(): CryptoKey {
if (verifyKey === null) {
throw new KeyConfigError('P5 verifying key not configured (call configureVerifyKey/loadVerifyKeyFromEnv)')
}
return verifyKey
}
/** TEST-ONLY reset so suites do not leak key state into each other. */
export function resetVerifyKeyForTest(): void {
verifyKey = null
}

View 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)))
}

View 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')
}
}

View 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)
}

View File

@@ -0,0 +1,41 @@
/**
* T12 · Enforcement entry point `onReattach` — deny-by-default on REATTACH too (INV6). Same pipeline
* as `onUpgrade` but the authz step additionally re-validates that the session exists AND belongs to
* the same account (INV6 re-validate). P3 calls this on every reattach; a stale-step-up principal
* never regains a live stream.
*/
import { authorizeReattach, type AuthzOutcome } from '../authz/decide.js'
import { runEnforcement, type EnforceDeps, type UpgradeContext } from './onUpgrade.js'
export type ReattachContext = UpgradeContext & { readonly sessionId: string }
export function onReattach(
ctx: ReattachContext,
deps: EnforceDeps,
allowedOrigins: readonly string[],
now: number,
): Promise<AuthzOutcome> {
return runEnforcement(
ctx,
deps,
allowedOrigins,
now,
() =>
authorizeReattach(
{
capabilityRaw: ctx.capabilityRaw,
expectedAud: ctx.expectedAud,
requestedHostId: ctx.requestedHostId,
requiredRight: ctx.requiredRight,
sessionId: ctx.sessionId,
},
ctx.dpop,
deps.hosts,
deps.sessions,
deps.revocation,
now,
),
'reattach',
ctx.sessionId,
)
}

View File

@@ -0,0 +1,186 @@
/**
* T12 · Enforcement entry point `onUpgrade` — the SOLE owner of the WS-upgrade authorization
* DECISION (INDEX §6a). P1's `authorizeUpgrade` is a thin adapter that DELEGATES here. The full
* decision composes, in order: Origin/CSWSH (retained, INV15) → pre-auth throttle (Finding-5) →
* deny-by-default authz incl. DPoP proof-of-possession + cross-tenant gate (T3/INV1) → per-tenant
* rate → step-up gate (T8/Finding-3, v0.10) → single-use jti burn (Finding-4) → audit (T4/INV10).
*
* Deny-by-default: every branch that is not an explicit allow returns 401/403 and emits exactly one
* `deny` AuditEvent. No allow path writes payload (INV10).
*/
import type { CapabilityRight, HostRecord } from 'relay-contracts'
import type {
AuditAction,
AuthenticatedPrincipal,
AuditSink,
HostRegistryPort,
RevocationStore,
SessionRegistryPort,
StepUpPolicy,
TokenBucketStore,
} from '../types.js'
import { authorizeConnect, authorizeReattach, type AuthzOutcome } from '../authz/decide.js'
import type { DpopContext } from '../capability/verify.js'
import { peekExp } from '../capability/verify.js'
import {
checkPreAuthRate,
checkConnectRate,
checkConcurrentSessions,
policyForPlan,
} from '../ratelimit/quota.js'
import { needsStepUp } from '../human/stepup/stepup.js'
import { buildAuditEvent, audit } from '../audit/log.js'
export interface UpgradeContext {
readonly capabilityRaw: string
readonly originHeader: string
readonly expectedAud: string
readonly requestedHostId: string
readonly requiredRight: CapabilityRight
readonly remoteAddrHash: string
readonly activeSessionCount: number
readonly dpop: DpopContext
readonly principal: AuthenticatedPrincipal | null // session principal for step-up (T8)
}
export interface EnforceDeps {
readonly hosts: HostRegistryPort
readonly sessions: SessionRegistryPort
readonly revocation: RevocationStore
readonly buckets: TokenBucketStore
readonly audit: AuditSink
/** v0.10; v0.9 injects a "never required" policy (only consulted when a session principal exists). */
readonly stepUpPolicyFor: (host: HostRecord) => StepUpPolicy
}
/** v0.9 default: no per-host tier lookup port here; the real tier comes from P3's account registry
* (INTEGRATION POINT). A conservative fixed policy bounds per-tenant abuse until then. */
const DEFAULT_TENANT_POLICY = policyForPlan('personal')
function deny(status: 401 | 403, reason: string): AuthzOutcome {
return { ok: false, status, reason }
}
function auditActionFor(base: AuditAction, reason: string): AuditAction {
return reason === 'cross_tenant' || reason === 'cross_tenant_session' ? 'cross-tenant-attempt' : base
}
async function emitDeny(
deps: EnforceDeps,
ctx: UpgradeContext,
base: AuditAction,
reason: string,
now: number,
): Promise<void> {
await audit(
deps.audit,
buildAuditEvent({
action: auditActionFor(base, reason),
principal: ctx.principal,
hostId: ctx.requestedHostId,
sessionId: null,
jti: null,
outcome: 'deny',
reason,
remoteAddrHash: ctx.remoteAddrHash,
now,
}),
)
}
/** Shared enforcement pipeline for connect (`base='attach'`) and reattach (`base='reattach'`). */
export async function runEnforcement(
ctx: UpgradeContext,
deps: EnforceDeps,
allowedOrigins: readonly string[],
now: number,
authorize: () => Promise<AuthzOutcome>,
base: AuditAction,
sessionId: string | null,
): Promise<AuthzOutcome> {
// 1. Origin / CSWSH (retained base-app check, INV15).
if (!allowedOrigins.includes(ctx.originHeader)) {
await emitDeny(deps, ctx, base, 'bad_origin', now)
return deny(401, 'bad_origin')
}
// 2. Pre-auth throttle BEFORE any token work (Finding-5) — keyed on the IP hash, no principal yet.
if (!(await checkPreAuthRate(ctx.remoteAddrHash, deps.buckets, now))) {
await emitDeny(deps, ctx, base, 'pre_auth_throttled', now)
return deny(403, 'pre_auth_throttled')
}
// 3. Deny-by-default authz (token verify + DPoP PoP + INV1 cross-tenant gate).
const outcome = await authorize()
if (!outcome.ok) {
await emitDeny(deps, ctx, base, outcome.reason, now)
return outcome
}
const accountId = outcome.principal.accountId
// 4. Per-tenant rate (after the principal is known).
if (!(await checkConnectRate(accountId, DEFAULT_TENANT_POLICY, deps.buckets, now))) {
await emitDeny(deps, ctx, base, 'rate_limited', now)
return deny(403, 'rate_limited')
}
if (!checkConcurrentSessions(accountId, ctx.activeSessionCount, DEFAULT_TENANT_POLICY)) {
await emitDeny(deps, ctx, base, 'too_many_sessions', now)
return deny(403, 'too_many_sessions')
}
// 5. Step-up gate (Finding-3, v0.10) — only when a session principal is present.
if (ctx.principal !== null) {
const host = await deps.hosts.getById(outcome.hostId)
if (host !== null && needsStepUp(ctx.principal, deps.stepUpPolicyFor(host), now)) {
await emitDeny(deps, ctx, 'stepup', 'step_up_required', now)
return deny(403, 'step_up_required')
}
}
// 6. Single-use consume (Finding-4) — burn the jti on the first fully-allowed upgrade.
const exp = peekExp(ctx.capabilityRaw)
if (!(await deps.revocation.consumeOnce(outcome.jti, exp))) {
await emitDeny(deps, ctx, base, 'token_replayed', now)
return deny(403, 'token_replayed')
}
// 7. Audit allow + return.
await audit(
deps.audit,
buildAuditEvent({
action: base,
principal: outcome.principal,
hostId: outcome.hostId,
sessionId,
jti: outcome.jti,
outcome: 'allow',
reason: 'ok',
remoteAddrHash: ctx.remoteAddrHash,
now,
}),
)
return outcome
}
export function onUpgrade(
ctx: UpgradeContext,
deps: EnforceDeps,
allowedOrigins: readonly string[],
now: number,
): Promise<AuthzOutcome> {
return runEnforcement(
ctx,
deps,
allowedOrigins,
now,
() =>
authorizeConnect(
{
capabilityRaw: ctx.capabilityRaw,
expectedAud: ctx.expectedAud,
requestedHostId: ctx.requestedHostId,
requiredRight: ctx.requiredRight,
},
ctx.dpop,
deps.hosts,
deps.revocation,
now,
),
'attach',
null,
)
}

View File

@@ -0,0 +1,163 @@
/**
* T7 · OIDC SSO for teams — auth-code flow + PKCE, with `state`/`nonce` validation. PKCE is
* mandatory; the issuer allow-list comes from config (validated at startup, INV9). `account_id` is
* derived from the verified `(iss, sub)` mapping via an INJECTED resolver — never from a client
* claim (INV3). The JIT-vs-pre-linked mapping policy is PLAN §5 open-Q #2 (surfaced to P3), so P5
* does not hard-code it: the resolver is supplied by the caller/config.
*/
import { createPublicKey, verify as cryptoVerify, type webcrypto } from 'node:crypto'
type JsonWebKey = webcrypto.JsonWebKey
import { decodeBase64UrlString, decodeBase64UrlBytes } from 'relay-contracts'
import type { OidcIdentity } from '../../types.js'
export interface OidcConfig {
readonly issuer: string
readonly clientId: string
readonly redirectUri: string
readonly scopes: readonly string[]
readonly authorizationEndpoint: string
readonly tokenEndpoint: string
readonly jwks: readonly (JsonWebKey & { kid?: string; alg?: string })[]
readonly allowedIssuers?: readonly string[]
/** (iss, sub) → accountId. Policy (JIT vs pre-linked) is P3-owned (open-Q #2). */
resolveAccount(issuer: string, subject: string): Promise<string>
}
export interface OidcTokenSet {
readonly accessToken: string
readonly idToken: string
readonly tokenType: string
readonly expiresIn?: number
readonly refreshToken?: string
}
export class OidcError extends Error {
constructor(message: string) {
super(message)
this.name = 'OidcError'
}
}
export function buildAuthUrl(
cfg: OidcConfig,
state: string,
nonce: string,
codeChallenge: string,
): string {
const u = new URL(cfg.authorizationEndpoint)
u.searchParams.set('response_type', 'code')
u.searchParams.set('client_id', cfg.clientId)
u.searchParams.set('redirect_uri', cfg.redirectUri)
u.searchParams.set('scope', cfg.scopes.join(' '))
u.searchParams.set('state', state)
u.searchParams.set('nonce', nonce)
u.searchParams.set('code_challenge', codeChallenge)
u.searchParams.set('code_challenge_method', 'S256')
return u.toString()
}
export type FetchLike = (url: string, init: RequestInit) => Promise<Response>
export async function exchangeCode(
cfg: OidcConfig,
code: string,
codeVerifier: string,
fetchImpl: FetchLike = fetch,
): Promise<OidcTokenSet> {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: cfg.redirectUri,
client_id: cfg.clientId,
code_verifier: codeVerifier,
})
const res = await fetchImpl(cfg.tokenEndpoint, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
if (!res.ok) throw new OidcError(`token endpoint returned ${res.status}`)
const json = (await res.json()) as Record<string, unknown>
if (typeof json.id_token !== 'string' || typeof json.access_token !== 'string') {
throw new OidcError('token response missing id_token/access_token')
}
return {
accessToken: json.access_token,
idToken: json.id_token,
tokenType: typeof json.token_type === 'string' ? json.token_type : 'Bearer',
...(typeof json.expires_in === 'number' ? { expiresIn: json.expires_in } : {}),
...(typeof json.refresh_token === 'string' ? { refreshToken: json.refresh_token } : {}),
}
}
interface JwtHeader {
readonly alg: string
readonly kid?: string
}
interface JwtClaims {
readonly iss: string
readonly aud: string | readonly string[]
readonly sub: string
readonly exp: number
readonly nonce?: string
}
function decodeSegment<T>(seg: string): T {
return JSON.parse(decodeBase64UrlString(seg)) as T
}
function verifyJwtSignature(
header: JwtHeader,
signingInput: string,
signature: Uint8Array,
jwks: OidcConfig['jwks'],
): boolean {
const jwk = jwks.find((k) => (header.kid === undefined ? true : k.kid === header.kid))
if (jwk === undefined) return false
const key = createPublicKey({ key: jwk as JsonWebKey, format: 'jwk' })
const data = Buffer.from(signingInput)
if (header.alg === 'RS256') {
return cryptoVerify('RSA-SHA256', data, key, signature)
}
if (header.alg === 'ES256') {
return cryptoVerify('sha256', data, { key, dsaEncoding: 'ieee-p1363' }, signature)
}
return false // no alg:none, no unexpected algs
}
/** Verify id_token: signature + iss allow-list + aud + exp + nonce; map (iss,sub) → accountId. */
export async function verifyIdToken(
cfg: OidcConfig,
idToken: string,
expectedNonce: string,
now: number,
): Promise<OidcIdentity> {
const parts = idToken.split('.')
if (parts.length !== 3) throw new OidcError('malformed id_token')
const [h, p, s] = parts as [string, string, string]
const header = decodeSegment<JwtHeader>(h)
const claims = decodeSegment<JwtClaims>(p)
const allowed = cfg.allowedIssuers ?? [cfg.issuer]
if (!allowed.includes(claims.iss)) throw new OidcError('issuer not in allow-list')
const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud]
if (!aud.includes(cfg.clientId)) throw new OidcError('aud mismatch')
if (typeof claims.exp !== 'number' || claims.exp <= now) throw new OidcError('id_token expired')
if (claims.nonce !== expectedNonce) throw new OidcError('nonce mismatch (replay)')
let sig: Uint8Array
try {
sig = decodeBase64UrlBytes(s)
} catch {
throw new OidcError('bad signature encoding')
}
if (!verifyJwtSignature(header, `${h}.${p}`, sig, cfg.jwks)) {
throw new OidcError('id_token signature verification failed')
}
const accountId = await cfg.resolveAccount(claims.iss, claims.sub)
return { issuer: claims.iss, subject: claims.sub, accountId }
}
/** state/nonce/PKCE-verifier CSPRNG helpers. */
export { randomUrlToken, pkceChallenge } from './pkce.js'

View File

@@ -0,0 +1,14 @@
/** PKCE + state/nonce helpers (CSPRNG). */
import { randomBytes, createHash } from 'node:crypto'
import { encodeBase64UrlBytes } from 'relay-contracts'
/** A random base64url token for `state`/`nonce`/`code_verifier`. */
export function randomUrlToken(bytes = 32): string {
return encodeBase64UrlBytes(new Uint8Array(randomBytes(bytes)))
}
/** S256 code challenge = base64url(SHA-256(code_verifier)). */
export function pkceChallenge(codeVerifier: string): string {
const digest = createHash('sha256').update(codeVerifier).digest()
return encodeBase64UrlBytes(new Uint8Array(digest))
}

View File

@@ -0,0 +1,45 @@
/**
* T8 · Step-up auth before OPENING a session (not just at login). A fresh login is NOT sufficient:
* before dropping into a live root-capable shell, presence must be re-asserted. Enforced in T12's
* `onUpgrade`/`onReattach` (v0.10 augmentation), not merely asserted in prose (Finding-3).
*
* `hostContentSecret` browser-delivery gate (§4.5 / FIX 3): a fresh step-up here is ALSO the gate
* for releasing the host-scoped content secret to an authorized browser device (unwrapped only when
* `needsStepUp` is false). P5 does not mint/wrap it (P3 mints+wraps at BIND, P2 unwraps for the
* agent) — P5 only performs the step-up-gated release; a revoked host/device (T10) can no longer
* obtain it (INV12). See PLAN §T8.
*/
import type { HostRecord } from 'relay-contracts'
import type { AuthMethod, AuthenticatedPrincipal, StepUpPolicy } from '../../types.js'
export const DEFAULT_STEPUP_MAX_AGE_SECONDS = 300 as const
/** Per-host step-up freshness requirement (default: recent passkey step-up). */
export function policyForHost(_host: HostRecord): StepUpPolicy {
return { maxAgeSeconds: DEFAULT_STEPUP_MAX_AGE_SECONDS, requiredMethod: 'passkey' }
}
/**
* true when step-up is required: never stepped up, OR the last step-up is stale, OR the required
* method is not present in `amr` — i.e. a fresh login alone does NOT satisfy it.
*/
export function needsStepUp(
principal: AuthenticatedPrincipal,
policy: StepUpPolicy,
now: number,
): boolean {
if (principal.stepUpAt === null) return true
if (now - principal.stepUpAt > policy.maxAgeSeconds) return true
if (!principal.amr.includes(policy.requiredMethod)) return true
return false
}
/** Returns a NEW principal with `stepUpAt = now` and `method` added to `amr` (immutable). */
export function recordStepUp(
principal: AuthenticatedPrincipal,
method: AuthMethod,
now: number,
): AuthenticatedPrincipal {
const amr = principal.amr.includes(method) ? principal.amr : [...principal.amr, method]
return { ...principal, amr, stepUpAt: now }
}

View File

@@ -0,0 +1,94 @@
/**
* T6 · TOTP fallback (RFC 6238, HMAC-SHA1). NEVER SMS (SIM-swap → shell = catastrophic). The
* secret is stored ENCRYPTED at rest (INV5) and decrypted only in-process. A 6-digit code has only
* 10^6 space, so a per-account failed-attempt lockout (Finding-6, via T11's TokenBucketStore keyed
* on the authenticated accountId, INV3) is MANDATORY — it closes the brute-force hole.
*/
import { createHmac, randomBytes } from 'node:crypto'
import type { RateLimitPolicy, TokenBucketStore } from '../../types.js'
const DIGITS = 6
const STEP_SECONDS = 30
const SECRET_BYTES = 20
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
function base32Encode(bytes: Uint8Array): string {
let bits = 0
let value = 0
let out = ''
for (const b of bytes) {
value = (value << 8) | b
bits += 8
while (bits >= 5) {
out += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]
bits -= 5
}
}
if (bits > 0) out += BASE32_ALPHABET[(value << (5 - bits)) & 31]
return out
}
function counterBytes(counter: number): Buffer {
const buf = Buffer.alloc(8)
buf.writeBigUInt64BE(BigInt(counter))
return buf
}
function hotp(secret: Uint8Array, counter: number): string {
const mac = createHmac('sha1', Buffer.from(secret)).update(counterBytes(counter)).digest()
const offset = mac[mac.length - 1]! & 0x0f
const bin =
((mac[offset]! & 0x7f) << 24) |
((mac[offset + 1]! & 0xff) << 16) |
((mac[offset + 2]! & 0xff) << 8) |
(mac[offset + 3]! & 0xff)
return (bin % 10 ** DIGITS).toString().padStart(DIGITS, '0')
}
export function generateTotpSecret(): { secret: Uint8Array; otpauthUri: string } {
const secret = new Uint8Array(randomBytes(SECRET_BYTES))
const b32 = base32Encode(secret)
const otpauthUri = `otpauth://totp/relay?secret=${b32}&algorithm=SHA1&digits=${DIGITS}&period=${STEP_SECONDS}`
return { secret, otpauthUri }
}
/** Verify a code within ±`window` steps (default ±1). Malformed codes → false. */
export function verifyTotp(secret: Uint8Array, code: string, now: number, window = 1): boolean {
if (!/^\d{6}$/.test(code)) return false
const step = Math.floor(now / STEP_SECONDS)
for (let w = -window; w <= window; w++) {
// constant-time-ish compare not required here (codes are 6 digits, rate-limited)
if (hotp(secret, step + w) === code) return true
}
return false
}
const BUCKET_KEY = (accountId: string) => `totp:fail:${accountId}`
/**
* Attempt gate (Finding-6): consumes one unit per verification attempt; false = locked out (deny
* WITHOUT checking the code). burst = policy.totpMaxFailsPerWindow, refilling over the window.
*/
export function checkTotpAttemptRate(
accountId: string,
policy: RateLimitPolicy,
store: TokenBucketStore,
now: number,
): Promise<boolean> {
const burst = policy.totpMaxFailsPerWindow
const refill = burst / policy.totpLockoutWindowSec
return store.take(BUCKET_KEY(accountId), refill, burst, now)
}
/**
* Record a failed attempt — drains an extra unit so bad guesses count more heavily toward lockout
* (escalating backoff). Uses the same failure bucket (already sized by `checkTotpAttemptRate`).
*/
export async function recordTotpFailure(
accountId: string,
store: TokenBucketStore,
now: number,
): Promise<void> {
// Params here are ignored by an already-initialized bucket; a fresh key defaults conservatively.
await store.take(BUCKET_KEY(accountId), 5 / 300, 5, now)
}

View File

@@ -0,0 +1,60 @@
/**
* T5 · Passkey/WebAuthn authentication ceremony. Enforces single-use challenge, origin/rpId
* binding, and the signCount-regression guard (a counter that goes backwards signals a cloned
* authenticator → reject). Yields an `AuthenticatedPrincipal` with `amr:['passkey']`.
*/
import { randomBytes } from 'node:crypto'
import { encodeBase64UrlBytes } from 'relay-contracts'
import type { AuthenticatedPrincipal, WebAuthnCredential } from '../../types.js'
import {
WebAuthnError,
type AuthenticationOptions,
type AuthenticationResponse,
type WebAuthnVerifier,
} from './verifier.js'
const CHALLENGE_BYTES = 32
const DEFAULT_TIMEOUT_MS = 60_000
export function startAuthentication(_accountId: string, rpId: string): Promise<AuthenticationOptions> {
return Promise.resolve({
rpId,
challenge: encodeBase64UrlBytes(new Uint8Array(randomBytes(CHALLENGE_BYTES))),
timeoutMs: DEFAULT_TIMEOUT_MS,
})
}
export async function finishAuthentication(
cred: WebAuthnCredential,
resp: AuthenticationResponse,
expectedChallenge: string,
rpId: string,
origin: string,
verifier: WebAuthnVerifier,
now: number,
): Promise<AuthenticatedPrincipal> {
if (resp.clientChallenge !== expectedChallenge) throw new WebAuthnError('challenge mismatch')
if (resp.origin !== origin) throw new WebAuthnError('origin mismatch')
if (resp.rpId !== rpId) throw new WebAuthnError('rpId mismatch')
const result = await verifier.verifyAuthentication(
resp,
expectedChallenge,
rpId,
origin,
cred.signCount,
)
if (!result.verified) throw new WebAuthnError('assertion not verified')
if (result.newSignCount <= cred.signCount && !(result.newSignCount === 0 && cred.signCount === 0)) {
throw new WebAuthnError('signCount regression (cloned authenticator)')
}
return {
kind: 'human',
accountId: cred.accountId,
principalId: cred.credentialId,
amr: ['passkey'],
authAt: now,
stepUpAt: null,
}
}

View File

@@ -0,0 +1,55 @@
/**
* T5 · Passkey/WebAuthn registration ceremony. Passkey is PRIMARY (phishing-resistant; the payload
* is a root-capable shell). Challenges are CSPRNG, short-TTL, single-use, never logged (INV9).
*/
import { randomBytes } from 'node:crypto'
import { encodeBase64UrlBytes } from 'relay-contracts'
import type { WebAuthnCredential } from '../../types.js'
import {
WebAuthnError,
type RegistrationOptions,
type RegistrationResponse,
type WebAuthnVerifier,
} from './verifier.js'
const CHALLENGE_BYTES = 32
const DEFAULT_TIMEOUT_MS = 60_000
export function newChallenge(): string {
return encodeBase64UrlBytes(new Uint8Array(randomBytes(CHALLENGE_BYTES)))
}
export function startRegistration(accountId: string, rpId: string): Promise<RegistrationOptions> {
return Promise.resolve({
rpId,
challenge: newChallenge(),
userId: accountId,
timeoutMs: DEFAULT_TIMEOUT_MS,
})
}
/** Verify attestation and mint the stored credential. `rpId` is bound to the tenant subdomain. */
export async function finishRegistration(
accountId: string,
resp: RegistrationResponse,
expectedChallenge: string,
rpId: string,
origin: string,
verifier: WebAuthnVerifier,
): Promise<WebAuthnCredential> {
if (resp.clientChallenge !== expectedChallenge) throw new WebAuthnError('challenge mismatch')
if (resp.origin !== origin) throw new WebAuthnError('origin mismatch')
if (resp.rpId !== rpId) throw new WebAuthnError('rpId mismatch')
const result = await verifier.verifyRegistration(resp, expectedChallenge, rpId, origin)
if (!result.verified) throw new WebAuthnError('attestation not verified')
return {
credentialId: result.credentialId,
accountId,
publicKey: result.publicKey,
signCount: result.signCount,
transports: [...result.transports],
createdAt: new Date().toISOString(),
}
}

View File

@@ -0,0 +1,66 @@
/**
* T5 · WebAuthn verification SEAM. The heavy attestation/assertion crypto + CBOR parsing is
* delegated to a battle-tested verifier (production: `@simplewebauthn/server`, per the reuse rule)
* INJECTED here, so the P5-owned security guards — single-use challenge, origin/rpId binding, and
* signCount regression (cloned-authenticator signal) — stay dependency-light and deterministically
* testable. INTEGRATION POINT: wire `@simplewebauthn/server` verifyRegistrationResponse /
* verifyAuthenticationResponse into this interface at the P3/P6 boundary.
*/
export interface RegistrationOptions {
readonly rpId: string
readonly challenge: string
readonly userId: string
readonly timeoutMs: number
}
export interface AuthenticationOptions {
readonly rpId: string
readonly challenge: string
readonly timeoutMs: number
}
/** Opaque authenticator responses (shape provided by the browser / @simplewebauthn). */
export interface RegistrationResponse {
readonly clientChallenge: string
readonly origin: string
readonly rpId: string
}
export interface AuthenticationResponse {
readonly clientChallenge: string
readonly origin: string
readonly rpId: string
}
export interface RegistrationVerification {
readonly verified: boolean
readonly credentialId: string
readonly publicKey: Uint8Array
readonly signCount: number
readonly transports: readonly string[]
}
export interface AuthenticationVerification {
readonly verified: boolean
readonly newSignCount: number
}
export interface WebAuthnVerifier {
verifyRegistration(
resp: RegistrationResponse,
expectedChallenge: string,
rpId: string,
origin: string,
): Promise<RegistrationVerification>
verifyAuthentication(
resp: AuthenticationResponse,
expectedChallenge: string,
rpId: string,
origin: string,
storedSignCount: number,
): Promise<AuthenticationVerification>
}
export class WebAuthnError extends Error {
constructor(message: string) {
super(message)
this.name = 'WebAuthnError'
}
}

104
relay-auth/src/index.ts Normal file
View File

@@ -0,0 +1,104 @@
/**
* relay-auth (P5) public barrel — the authorization surface P1/P3/P6 import. Imports
* relay-contracts (§4) read-only; never redefines a frozen contract. Socket-free (no ws/xterm/ANSI
* parser → INV2/INV11 hold by construction).
*/
export * from './types.js'
// Config (INV9)
export {
configureVerifyKey,
loadVerifyKeyFromEnv,
getVerifyKey,
KeyConfigError,
} from './config/keys.js'
// T2 capability tokens + DPoP + device-auth proof
export { issueCapabilityToken, CONNECT_TOKEN_MIN_TTL_SEC, CONNECT_TOKEN_MAX_TTL_SEC } from './capability/issue.js'
export {
verifyCapabilityToken,
verifyDpopProof,
readCnfJkt,
subAccountId,
hasRight,
peekExp,
type DpopContext,
} from './capability/verify.js'
export { CapabilityError } from './capability/errors.js'
export {
signDeviceAuthProof,
verifyDeviceProof,
deviceProofAccount,
type DeviceProofBinding,
} from './capability/device-proof.js'
// T3 authz
export {
authorizeConnect,
authorizeReattach,
type AuthzOutcome,
type ConnectRequest,
type ReattachRequest,
} from './authz/decide.js'
export { accountIdFromToken, principalFromToken, AuthzInputError } from './authz/principal.js'
// T4 audit
export { buildAuditEvent, audit } from './audit/log.js'
export { assertZeroPayload, ZeroPayloadViolation, MAX_METADATA_LEN } from './audit/redact.js'
export { onAuditEvent, type Alerter, type AlertKind } from './audit/alert.js'
// T5T8 human auth
export { startRegistration, finishRegistration } from './human/webauthn/register.js'
export { startAuthentication, finishAuthentication } from './human/webauthn/authenticate.js'
export * from './human/webauthn/verifier.js'
export {
generateTotpSecret,
verifyTotp,
checkTotpAttemptRate,
recordTotpFailure,
} from './human/totp/totp.js'
export {
buildAuthUrl,
exchangeCode,
verifyIdToken,
randomUrlToken,
pkceChallenge,
OidcError,
type OidcConfig,
type OidcTokenSet,
} from './human/oidc/oidc.js'
export {
policyForHost,
needsStepUp,
recordStepUp,
DEFAULT_STEPUP_MAX_AGE_SECONDS,
} from './human/stepup/stepup.js'
// T9 mTLS / SPIFFE
export { spiffeIdFor, parseSpiffeId, trustDomain, SpiffeError } from './agent/spiffe.js'
export {
verifyAgentCert,
defaultParseX509,
type MtlsVerifyResult,
type ParsedCert,
type ParseCert,
} from './agent/verify-mtls.js'
export { shouldRotate, DEFAULT_ROTATION_PLAN, DEFAULT_CERT_TTL_SECONDS, type RotationPlan } from './agent/rotate.js'
// T10 revocation
export { revoke, revokeToken } from './revocation/revoke.js'
export { isTokenRevoked, killsScope } from './revocation/check.js'
// T11 rate-limits
export {
policyForPlan,
PRE_AUTH_POLICY,
checkPreAuthRate,
checkConnectRate,
checkConcurrentSessions,
checkEnrollRate,
} from './ratelimit/quota.js'
// T12 enforcement
export { onUpgrade, type UpgradeContext, type EnforceDeps } from './enforce/onUpgrade.js'
export { onReattach, type ReattachContext } from './enforce/onReattach.js'

View File

@@ -0,0 +1,109 @@
/**
* T11 · Rate-limits / quotas. Two layers (Finding-5):
* 1. PRE-AUTH throttle keyed on the salted `remoteAddrHash` — the ONLY identifier before a
* principal exists. Applied in T12 `onUpgrade` BEFORE token verification. Blunts subdomain
* enumeration, capability-verifier brute-force, and WebAuthn/TOTP guessing DoS.
* 2. PER-TENANT quotas keyed strictly on the authenticated `accountId` (INV3), never a client id.
*
* Pre-auth and per-tenant keys live in DISJOINT namespaces (no bleed). Keys are OPAQUE to the store.
*/
import type { PlanTier } from 'relay-contracts'
import type { RateLimitPolicy, TokenBucketStore } from '../types.js'
const SECONDS_PER_MINUTE = 60
const SECONDS_PER_HOUR = 3600
/** Fixed, plan-independent pre-auth policy (no account known yet). */
export const PRE_AUTH_POLICY: RateLimitPolicy = {
connectPerMin: 30,
enrollPerHour: 10,
maxConcurrentSessions: 1,
maxPairedHosts: 1,
preAuthPerMinPerIp: 60,
totpMaxFailsPerWindow: 5,
totpLockoutWindowSec: 300,
}
const PLAN_POLICIES: Readonly<Record<PlanTier, RateLimitPolicy>> = {
free: {
connectPerMin: 20,
enrollPerHour: 5,
maxConcurrentSessions: 2,
maxPairedHosts: 1,
preAuthPerMinPerIp: 60,
totpMaxFailsPerWindow: 5,
totpLockoutWindowSec: 300,
},
personal: {
connectPerMin: 60,
enrollPerHour: 20,
maxConcurrentSessions: 5,
maxPairedHosts: 5,
preAuthPerMinPerIp: 60,
totpMaxFailsPerWindow: 5,
totpLockoutWindowSec: 300,
},
pro: {
connectPerMin: 120,
enrollPerHour: 60,
maxConcurrentSessions: 20,
maxPairedHosts: 25,
preAuthPerMinPerIp: 120,
totpMaxFailsPerWindow: 5,
totpLockoutWindowSec: 300,
},
team: {
connectPerMin: 300,
enrollPerHour: 200,
maxConcurrentSessions: 100,
maxPairedHosts: 200,
preAuthPerMinPerIp: 240,
totpMaxFailsPerWindow: 5,
totpLockoutWindowSec: 300,
},
}
export function policyForPlan(plan: PlanTier): RateLimitPolicy {
return PLAN_POLICIES[plan]
}
/** PRE-AUTH throttle keyed on `remoteAddrHash`. false = throttled (caller returns 429-equiv). */
export function checkPreAuthRate(
remoteAddrHash: string,
store: TokenBucketStore,
now: number,
): Promise<boolean> {
const perMin = PRE_AUTH_POLICY.preAuthPerMinPerIp
return store.take(`preauth:ip:${remoteAddrHash}`, perMin / SECONDS_PER_MINUTE, perMin, now)
}
/** PER-TENANT connect rate keyed on the authenticated accountId (INV3). */
export function checkConnectRate(
accountId: string,
policy: RateLimitPolicy,
store: TokenBucketStore,
now: number,
): Promise<boolean> {
const perMin = policy.connectPerMin
return store.take(`connect:acct:${accountId}`, perMin / SECONDS_PER_MINUTE, perMin, now)
}
/** Concurrent-session cap (synchronous; caller supplies the live count). */
export function checkConcurrentSessions(
_accountId: string,
active: number,
policy: RateLimitPolicy,
): boolean {
return active < policy.maxConcurrentSessions
}
/** Enrollment rate keyed on the authenticated accountId (INV3). */
export function checkEnrollRate(
accountId: string,
policy: RateLimitPolicy,
store: TokenBucketStore,
now: number,
): Promise<boolean> {
const perHour = policy.enrollPerHour
return store.take(`enroll:acct:${accountId}`, perHour / SECONDS_PER_HOUR, perHour, now)
}

View File

@@ -0,0 +1,21 @@
/**
* T10 · Revocation predicates. `killsScope` is the PURE predicate P1 uses to decide which live
* streams a published `KillSignal` covers (host match, account match, or global drain).
*/
import type { KillSignal, RevocationStore } from '../types.js'
export function isTokenRevoked(jti: string, store: RevocationStore): Promise<boolean> {
return store.isRevoked(jti)
}
/** Does `signal` cover a stream on `hostId` owned by `hostAccountId`? */
export function killsScope(signal: KillSignal, hostAccountId: string, hostId: string): boolean {
switch (signal.scope.kind) {
case 'global':
return true
case 'account':
return signal.scope.accountId === hostAccountId
case 'host':
return signal.scope.hostId === hostId
}
}

View File

@@ -0,0 +1,58 @@
/**
* T10 · Global + per-host revocation that kills LIVE tunnels in seconds (INV12).
*
* P5 stays socket-free (Finding-2): `revoke()` marks the token/host revoked in the store, stops
* cert renewal (T9 sees `revoked` → never renews), then PUBLISHES an immutable `KillSignal` on the
* injected `RevocationBus` (frozen Redis channel `relay:revocations`, relay-contracts §4.2 FIX 4).
* Every P1 relay node subscribes and injects a §4.1 CLOSE+RST (host/account) or GOAWAY (global)
* for each affected stream — no new wire type, no P5↔socket coupling. Teardown target: within
* REVOCATION_PUSH_BUDGET_MS of `revoke()` returning.
*/
import { RevocationScopeSchema } from 'relay-contracts'
import type { RevocationScope, KillSignal, RevocationBus } from '../types.js'
import type { HostRegistryPort, RevocationStore } from '../types.js'
/**
* Ordered: 1) mark revoked in store 2) (cert renewal stops via host.status='revoked', T9)
* 3) PUBLISH the KillSignal on the bus 4) return the signal.
*/
export async function revoke(
scope: RevocationScope,
store: RevocationStore,
hosts: HostRegistryPort,
bus: RevocationBus,
now: number,
): Promise<KillSignal> {
RevocationScopeSchema.parse(scope) // boundary validation
// Best-effort store marking for host scope (P3 flips hosts.status='revoked'; our port is
// read-only for hosts, so the durable status flip is P3's — INTEGRATION POINT). The connect-time
// gate additionally consults host.status via the registry (T3), so a revoked host is refused.
if (scope.kind === 'host') {
await hosts.getById(scope.hostId) // touch registry so a caller can assert the host exists
}
const signal: KillSignal = { scope, at: now, reason: reasonFor(scope) }
await bus.publish(signal) // the push channel — P1 subscribers tear the live tunnel down
return signal
}
function reasonFor(scope: RevocationScope): string {
switch (scope.kind) {
case 'host':
return 'host-revoked'
case 'account':
return 'account-revoked'
case 'global':
return 'global-drain'
}
}
/** Revoke a single capability token by jti (connect-time gate; distinct from tunnel teardown). */
export async function revokeToken(
jti: string,
exp: number,
store: RevocationStore,
): Promise<void> {
await store.revokeJti(jti, exp)
}

234
relay-auth/src/types.ts Normal file
View File

@@ -0,0 +1,234 @@
/**
* T1 · P5-local types + Zod schemas (frozen after T1, changed only via T1).
*
* Re-exports the frozen relay-contracts §4 shapes for P5 consumers (import, NEVER redefine)
* and declares the P5-local records that §4 explicitly delegates to "P5-owned"
* (webauthn_credentials / oidc_identities / audit / revocation policy / SPIFFE-ID).
*
* SECURITY (INV3): `accountId` originates ONLY inside `AuthenticatedPrincipal`, derived from a
* verified credential/cert — never from a client field. `CapabilityToken.sub` carries THAT SAME
* `accountId` (CAP_TOKEN_SUB_IS_ACCOUNT_ID). No schema here accepts a client-supplied
* `accountId`/`tenant_id` as authoritative.
*/
import { z } from 'zod'
// ── Re-export frozen §4 types (import, never redefine) ────────────────────────────────────────
export type { CapabilityToken, CapabilityRight } from 'relay-contracts' // §4.3
export type { HostRecord, HostStatus, PlanTier, SessionRecord } from 'relay-contracts' // §4.2
// Revocation kill-signal + bus are FROZEN in relay-contracts §4.2 (FIX 4) — re-export, never redeclare:
export type { RevocationScope, KillSignal, RevocationBus } from 'relay-contracts' // §4.2, FIX 4
export { RELAY_REVOCATIONS_CHANNEL, REVOCATION_PUSH_BUDGET_MS } from 'relay-contracts' // 'relay:revocations' · 2000ms
// ── Auth method / principal ───────────────────────────────────────────────────────────────────
export type PrincipalKind = 'human' | 'agent' | 'share-grant'
export type AuthMethod = 'passkey' | 'totp' | 'oidc' | 'mtls' | 'stepup'
/**
* The ONLY source of `accountId` in an authz decision (INV3). All fields are derived from
* authenticated material; `accountId` is NEVER read from client input.
*/
export interface AuthenticatedPrincipal {
readonly kind: PrincipalKind
readonly accountId: string
readonly principalId: string // device/credential id (WebAuthn credId, SPIFFE-ID, or grant jti)
readonly amr: readonly AuthMethod[] // methods satisfied this session (for step-up, T8)
readonly authAt: number // epoch seconds of last successful auth (login freshness)
readonly stepUpAt: number | null // epoch seconds of last step-up (T8 freshness); null = never
}
/**
* FROZEN P5 CONVENTION (resolves open-Q #3): §4.3 `CapabilityToken.sub` carries
* `principal.accountId`. The single reader is T3's `accountIdFromToken`. This chooses the VALUE
* P5 writes into an existing frozen field — it does NOT redefine §4.3.
*/
export const CAP_TOKEN_SUB_IS_ACCOUNT_ID = true as const
export interface StepUpPolicy {
readonly maxAgeSeconds: number
readonly requiredMethod: AuthMethod
}
// ── P5-owned auth records (§4.2 delegated to P5) ────────────────────────────────────────────────
export interface WebAuthnCredential {
readonly credentialId: string
readonly accountId: string
readonly publicKey: Uint8Array
readonly signCount: number
readonly transports: readonly string[]
readonly createdAt: string
}
export interface OidcIdentity {
readonly issuer: string
readonly subject: string
readonly accountId: string
}
export interface TotpSecretRecord {
readonly accountId: string
readonly encSecret: Uint8Array // encrypted at rest (INV5); never SMS
readonly confirmedAt: string | null
}
export type AuditAction =
| 'attach'
| 'reattach'
| 'manage'
| 'kill'
| 'enroll'
| 'revoke'
| 'login'
| 'stepup'
| 'token-issue'
| 'cross-tenant-attempt'
export type AuditOutcome = 'allow' | 'deny'
/** INV10 — metadata only, ZERO payload. No field may carry keystrokes/output (redact.ts + Zod guard). */
export interface AuditEvent {
readonly ts: string
readonly action: AuditAction
readonly principalId: string
readonly accountId: string
readonly hostId: string | null
readonly sessionId: string | null
readonly jti: string | null
readonly outcome: AuditOutcome
readonly reason: string
readonly remoteAddrHash: string
}
export type SpiffeId = string // 'spiffe://relay.<domain>/account/<accountId>/host/<hostId>'
export interface RateLimitPolicy {
readonly connectPerMin: number
readonly enrollPerHour: number
readonly maxConcurrentSessions: number
readonly maxPairedHosts: number
readonly preAuthPerMinPerIp: number // pre-principal throttle keyed on remoteAddrHash (T11)
readonly totpMaxFailsPerWindow: number
readonly totpLockoutWindowSec: number
}
// ── Read-only ports P3/P1 implement (P5 stays storage-free & pure) ──────────────────────────────
import type { HostRecord as _HostRecord } from 'relay-contracts'
export interface HostRegistryPort {
getById(hostId: string): Promise<_HostRecord | null>
}
export interface SessionRegistryPort {
getById(sessionId: string): Promise<{ hostId: string; accountId: string } | null>
}
export interface RevocationStore {
isRevoked(jti: string): Promise<boolean>
revokeJti(jti: string, exp: number): Promise<void>
/** single-use consumption: true = first use (proceed); false = replay (deny). */
consumeOnce(jti: string, exp: number): Promise<boolean>
}
export interface AuditSink {
append(e: AuditEvent): Promise<void>
}
export interface TokenBucketStore {
/** false = throttled. Keys are OPAQUE to P5. */
take(key: string, refillPerSec: number, burst: number, now: number): Promise<boolean>
}
// ── Zod schemas (boundary validation, coding-style "Input Validation") ──────────────────────────
export const PrincipalKindSchema = z.enum(['human', 'agent', 'share-grant'])
export const AuthMethodSchema = z.enum(['passkey', 'totp', 'oidc', 'mtls', 'stepup'])
export const AuthenticatedPrincipalSchema = z
.object({
kind: PrincipalKindSchema,
accountId: z.string().min(1),
principalId: z.string().min(1),
amr: z.array(AuthMethodSchema).readonly(),
authAt: z.number().int().nonnegative(),
stepUpAt: z.number().int().nonnegative().nullable(),
})
.strict()
export const StepUpPolicySchema = z
.object({
maxAgeSeconds: z.number().int().nonnegative(),
requiredMethod: AuthMethodSchema,
})
.strict()
export const WebAuthnCredentialSchema = z
.object({
credentialId: z.string().min(1),
accountId: z.string().min(1),
publicKey: z.instanceof(Uint8Array),
signCount: z.number().int().nonnegative(),
transports: z.array(z.string()).readonly(),
createdAt: z.string().min(1),
})
.strict()
export const OidcIdentitySchema = z
.object({
issuer: z.string().min(1),
subject: z.string().min(1),
accountId: z.string().min(1),
})
.strict()
export const TotpSecretRecordSchema = z
.object({
accountId: z.string().min(1),
encSecret: z.instanceof(Uint8Array),
confirmedAt: z.string().nullable(),
})
.strict()
export const AuditActionSchema = z.enum([
'attach',
'reattach',
'manage',
'kill',
'enroll',
'revoke',
'login',
'stepup',
'token-issue',
'cross-tenant-attempt',
])
/**
* INV10 zero-payload guard at the type layer: `.strict()` REJECTS any unknown key (e.g. a
* `data`/`bytes` field carrying terminal output). Length/control-char checks are in redact.ts (T4).
*/
export const AuditEventSchema = z
.object({
ts: z.string().min(1),
action: AuditActionSchema,
principalId: z.string(),
accountId: z.string(),
hostId: z.string().nullable(),
sessionId: z.string().nullable(),
jti: z.string().nullable(),
outcome: z.enum(['allow', 'deny']),
reason: z.string(),
remoteAddrHash: z.string(),
})
.strict()
export const RateLimitPolicySchema = z
.object({
connectPerMin: z.number().int().positive(),
enrollPerHour: z.number().int().positive(),
maxConcurrentSessions: z.number().int().positive(),
maxPairedHosts: z.number().int().positive(),
preAuthPerMinPerIp: z.number().int().positive(),
totpMaxFailsPerWindow: z.number().int().positive(),
totpLockoutWindowSec: z.number().int().positive(),
})
.strict()
/**
* SPIFFE-ID validator: accepts the `spiffe://relay.<domain>/account/<a>/host/<h>` form and
* REJECTS path-traversal (`..`) or wildcard (`*`). Segment chars are restricted (no `/`).
*/
const SPIFFE_ID_RE =
/^spiffe:\/\/relay\.[a-z0-9.-]+\/account\/[A-Za-z0-9_-]+\/host\/[A-Za-z0-9_-]+$/
export const SpiffeIdSchema = z
.string()
.regex(SPIFFE_ID_RE, 'invalid SPIFFE-ID form')
.refine((s) => !s.includes('..') && !s.includes('*'), 'path-traversal/wildcard rejected')