From 2d6abbeb314f126f6df484316be6fd0e4aca9e52 Mon Sep 17 00:00:00 2001 From: Robert DeLanghe Date: Sat, 27 Jun 2026 19:42:21 -0400 Subject: [PATCH 1/4] chore: bump guest-room pin to 46333d0 (signGrant/verifyGrant + IssuerKeys) --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 8fa0e73..869dcbb 100644 --- a/flake.nix +++ b/flake.nix @@ -10,7 +10,7 @@ inputs.nixpkgs.url = "github:NixOS/nixpkgs/9f11f828c213641c2369a9f1fa31fe31557e3156"; - inputs.guest-room.url = "github:bounded-systems/guest-room/5bc85b634a0a8d698243ba3b708f0420516308ec"; + inputs.guest-room.url = "github:bounded-systems/guest-room/46333d06dc0a8e0bd452993f72910124d5d2920d"; inputs.guest-room.flake = false; inputs.door-kit.url = "github:bounded-systems/door-kit/a3ae40e5075e3dbded3db9a0d345f842984a646b"; inputs.door-kit.flake = false; From 0c66ebe8e8d577ed090ffa66b3515bfd0749de4e Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:42:22 -0400 Subject: [PATCH 2/4] chore(lock): guest-room 46333d0 --- flake.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flake.lock b/flake.lock index 15a8c67..ea473d7 100644 --- a/flake.lock +++ b/flake.lock @@ -20,17 +20,17 @@ "guest-room": { "flake": false, "locked": { - "lastModified": 1781557771, - "narHash": "sha256-2iXN95ClKEQGM+KPN9sEcMQ8zn/gZAMfAkGS5ri8Zao=", + "lastModified": 1782603449, + "narHash": "sha256-8pcfArUHAvuUkA686mFe7gHu38Pc1BOFKLRjBFFI13Q=", "owner": "bounded-systems", "repo": "guest-room", - "rev": "5bc85b634a0a8d698243ba3b708f0420516308ec", + "rev": "46333d06dc0a8e0bd452993f72910124d5d2920d", "type": "github" }, "original": { "owner": "bounded-systems", "repo": "guest-room", - "rev": "5bc85b634a0a8d698243ba3b708f0420516308ec", + "rev": "46333d06dc0a8e0bd452993f72910124d5d2920d", "type": "github" } }, From 84b099ade9293a6527fbbef01aaa83fe05aff49e Mon Sep 17 00:00:00 2001 From: Robert DeLanghe <1240090+bdelanghe@users.noreply.github.com> Date: Sat, 27 Jun 2026 19:42:33 -0400 Subject: [PATCH 3/4] chore(mirror): sync guest-room (signGrant/verifyGrant + IssuerKeys) --- guest-room/daemon.ts | 3 + guest-room/mod.ts | 194 ++++++++++++++++++++++++++++++++++++----- guest-room/protocol.ts | 193 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 360 insertions(+), 30 deletions(-) diff --git a/guest-room/daemon.ts b/guest-room/daemon.ts index 5fc1ff7..a13def3 100644 --- a/guest-room/daemon.ts +++ b/guest-room/daemon.ts @@ -1,4 +1,5 @@ /** + * @module * daemon.ts — shared utilities for door daemons. * * Every daemon follows the same pattern: @@ -14,7 +15,9 @@ */ import { mkdirSync, unlinkSync } from "node:fs"; +import process from "node:process"; +/** Environment variables (a map of names to values or undefined). */ export type Env = Record; /** Function to determine the run directory for sockets. */ diff --git a/guest-room/mod.ts b/guest-room/mod.ts index ec253e0..3ce5e39 100644 --- a/guest-room/mod.ts +++ b/guest-room/mod.ts @@ -1,24 +1,27 @@ -// guest-room — a guest-agnostic room+door capability runtime. -// -// This module knows nothing about any particular guest: no guest identity, no -// image, no container runtime. A ROOM is walls plus a furnished set of DOORS; a -// DOOR is a single (name, socket) capability brokered by a daemon that holds the -// authority the room never does. A door may be ATTENUATED — narrowed by opaque -// caveats the broker enforces — and attenuation is append-only, so a door can -// only ever be handed onward equally or more restricted, never wider. The room -// hands its guest a RULEBOOK keyed to exactly the doors present — a card per -// granted door (how to use it, and any restriction on it) and a card per denied -// door (there is no rule; do not attempt). -// -// A consumer supplies the door CATALOG and the room bundles; guest-room resolves -// grants, derives the honest granted/denied surface, and renders the rulebook -// lines. The consumer keeps its own launch mechanics (which runtime, which -// image, how state mounts) — those are the guest, not the room. -// -// Extraction note: this directory is a self-contained internal dependency. When -// it graduates to its own repo, it moves as-is and consumers flip the import -// path; nothing here names a guest — a test enforces that the engine source is -// guest-agnostic, so the seam can't silently re-couple. +/** + * @module + * guest-room — a guest-agnostic room+door capability runtime. + * + * This module knows nothing about any particular guest: no guest identity, no + * image, no container runtime. A ROOM is walls plus a furnished set of DOORS; a + * DOOR is a single (name, socket) capability brokered by a daemon that holds the + * authority the room never does. A door may be ATTENUATED — narrowed by opaque + * caveats the broker enforces — and attenuation is append-only, so a door can + * only ever be handed onward equally or more restricted, never wider. The room + * hands its guest a RULEBOOK keyed to exactly the doors present — a card per + * granted door (how to use it, and any restriction on it) and a card per denied + * door (there is no rule; do not attempt). + * + * A consumer supplies the door CATALOG and the room bundles; guest-room resolves + * grants, derives the honest granted/denied surface, and renders the rulebook + * lines. The consumer keeps its own launch mechanics (which runtime, which + * image, how state mounts) — those are the guest, not the room. + * + * Extraction note: this directory is a self-contained internal dependency. When + * it graduates to its own repo, it moves as-is and consumers flip the import + * path; nothing here names a guest — a test enforces that the engine source is + * guest-agnostic, so the seam can't silently re-couple. + */ /** A door name lands in a mount path (`/run/.sock`) and an env var, so it * must be path-safe — no `/`, no `..`, no injection into the mount spec. */ @@ -68,6 +71,7 @@ export function transportString(t: DoorTransport): string { } } +/** Environment variables (a map of names to values or undefined). */ export type Env = Record; /** Default host socket for a daemon, private-dir-first. Pure (no I/O) so door @@ -237,6 +241,118 @@ export function checkCaveats( return { ok: true }; } +// ── Signed grants (authority in transit) ───────────────────────────────────── +// On a unix transport the held reference IS the authority (you can't reach a +// socket you weren't handed). Across a vsock/tcp boundary you can't pass an fd, +// so reachability stops being authority and the authority must travel IN the +// grant: a signature the SERVING room verifies before honoring a call. See the +// consuming product's ADR-CAPABILITY-TRANSPORT and CONCIERGE.md §7. +// +// The engine stays key-agnostic: signing/verification are INJECTED functions +// (the issuer's signer lives outside the engine, in a dedicated signing door). +// The engine owns +// only the CANONICAL BYTES and the binding checks, so issuer and verifier agree +// on exactly what a signature covers. A signed grant is a bearer token, so it is +// bound to an `audience` (which room may present it), an `exp`, and a `nonce`. + +/** The binding a signature covers alongside the grant's authority: who may + * present it, until when, a freshness nonce, and the issuer key id (the + * verifier selects the matching public key). */ +export type GrantBinding = { + audience: string; // room id permitted to present this grant + exp: number; // expiry, epoch ms + nonce: string; // single-use freshness token + keyId: string; // issuer key identity +}; + +/** A DoorGrant plus the issuer binding + signature that make it authority in + * transit. `host` (broker-side) is deliberately NOT signed — only the granted + * reference and its constraints are. */ +export type SignedGrant = DoorGrant & { binding: GrantBinding; signature: string }; + +export type GrantVerdict = { ok: true } | { ok: false; reason: string }; + +/** The canonical bytes a grant signature covers: the AUTHORITY-bearing fields + * (name, the guest reference being granted, sorted caveats) plus the full + * binding. Cosmetic fields (grants/use/env) and the broker-side `host` are + * excluded, so re-describing or re-homing a door cannot change what was signed. + * Issuer and verifier MUST compute these identically — hence one shared fn. */ +export function grantSigningBytes(grant: DoorGrant, binding: GrantBinding): string { + return JSON.stringify({ + name: grant.name, + guest: grant.guest, + caveats: [...(grant.caveats ?? [])].sort(), + binding, + }); +} + +/** Attach an issuer binding + signature to a grant. `sign` is injected — the + * engine never holds a key. */ +export function signGrant( + grant: DoorGrant, + binding: GrantBinding, + sign: (data: string) => string, +): SignedGrant { + return { ...grant, binding, signature: sign(grantSigningBytes(grant, binding)) }; +} + +/** Verify a signed grant at the SERVING room before honoring a call. Order: + * signature (over the canonical bytes) → audience match → expiry. `verify` is + * injected (the verifier holds the issuer pubkey for `grant.binding.keyId`). + * Single-use of the nonce needs cross-call state, so it is the caller's job; + * `verifyGrant` reports the binding it accepted so the caller can record it. + * Pair with `checkCaveats` for full enforcement (signature THEN caveats). */ +export function verifyGrant( + grant: SignedGrant, + ctx: { audience: string; now: number }, + verify: (data: string, signature: string) => boolean, +): GrantVerdict { + if (!grant.signature || !grant.binding) return { ok: false, reason: "unsigned" }; + if (!verify(grantSigningBytes(grant, grant.binding), grant.signature)) { + return { ok: false, reason: "bad-signature" }; + } + if (grant.binding.audience !== ctx.audience) return { ok: false, reason: "audience-mismatch" }; + if (ctx.now > grant.binding.exp) return { ok: false, reason: "expired" }; + return { ok: true }; +} + +// ── Issuer keys (keyless, published-key verification) ──────────────────────── +// A signed grant names its issuer key by `kid` (binding.keyId). Rather than +// pre-share a secret, a verifier holds the issuer's PUBLISHED public keys — a +// set the issuer can rotate (publish a new key, retire an old one). The verifier +// selects the key the grant names and validates against it: no shared secret, +// identity-by-published-key. This is the keyless model the project's release +// tooling already uses, adapted to a door system — the key set travels over a +// door, not an HTTPS discovery endpoint. The engine models only the set + +// selection; the crypto stays injected. + +/** One published issuer public key, selected by `kid`. */ +export type IssuerKey = { kid: string; publicKeyPem: string }; + +/** An issuer's published key set. Multiple entries support rotation/overlap. */ +export type IssuerKeys = { keys: IssuerKey[] }; + +/** Select the public key a grant names (`binding.keyId`); null if unknown. */ +export function resolveIssuerKey(keys: IssuerKeys, kid: string): IssuerKey | null { + return keys.keys.find((k) => k.kid === kid) ?? null; +} + +/** Verify a signed grant against an issuer's PUBLISHED key set (no shared + * secret): resolve `binding.keyId` in `keys`, then apply the same checks as + * `verifyGrant`. `verifyWith` is injected — (data, signature, publicKeyPem) → + * bool. An unknown `kid` fails closed (`unknown-key`). */ +export function verifyGrantWithKeys( + grant: SignedGrant, + ctx: { audience: string; now: number }, + keys: IssuerKeys, + verifyWith: (data: string, signature: string, publicKeyPem: string) => boolean, +): GrantVerdict { + if (!grant.signature || !grant.binding) return { ok: false, reason: "unsigned" }; + const key = resolveIssuerKey(keys, grant.binding.keyId); + if (!key) return { ok: false, reason: "unknown-key" }; + return verifyGrant(grant, ctx, (d, s) => verifyWith(d, s, key.publicKeyPem)); +} + // ── Room attenuation ───────────────────────────────────────────────────────── // attenuate() narrows ONE door handed onward; this is the same rule lifted to // the SET of doors a parent hands to a sub-room. A child set attenuates from its @@ -252,6 +368,7 @@ export type AttenuationViolation = | { door: string; reason: "absent-in-parent" } | { door: string; reason: "widened-caveats"; dropped: string[] }; +/** The verdict of `attenuatesDoors`: true if child is a valid attenuation of parent, false with violations. */ export type AttenuationVerdict = | { ok: true } | { ok: false; violations: AttenuationViolation[] }; @@ -316,6 +433,41 @@ export function resolveProvider( return attenuate(live[0]!.door, want); } +// ── Confinement ────────────────────────────────────────────────────────────── +// The property the whole design turns on: a capability never becomes durable +// authority owned by its holder. Authority is valid only while a live provider +// backs it, and only within that provider's ceiling — so it dies with the +// workcell (the lease lapses) and can never be captured wider than it was lent. +// This is the engine-side statement of "an agent does not become a new actor +// type": a held grant is a capability checked against the live registry, never a +// standing property of whoever holds it. +// +// TCB note: this proves the ALGEBRA of confinement (lease-gated + ceiling-bound) +// purely, and it is RELATIVE TO THE REGISTRY: it trusts each provider's declared +// ceiling. It does not prove (a) the runtime cannot stash a socket fd past +// teardown, nor (b) that a provider's ceiling was legitimate to register. Both +// reduce to the broker/substrate, not this engine: workcell isolation, the lease +// clock, and PROVIDER ADMISSION (who may register a door, and how its ceiling is +// bounded) are the broker's. A too-wide ceiling makes confinement vacuous at that +// root — the engine will faithfully attenuate from broken authority. The gap is +// named, not closed: see docs/authority-and-attenuation.md. + +/** Is `held` a capability the concierge could legitimately have handed out at + * `now` — i.e. backed by a LIVE provider for `capability` and no wider than that + * provider's ceiling? False once every backing lease has lapsed (the capability + * does not outlive its provider) or if `held` widened past the ceiling (it was + * never a derivation the concierge could grant). Mirrors `resolveProvider`'s + * guarantee from the verification side: what was handed out stays confined. */ +export function isConfined( + held: DoorGrant, + entries: ProviderEntry[], + capability: string, + now: number, +): boolean { + const live = liveProviders(entries, capability, now); + return live.some((p) => attenuatesDoors([held], [p.door]).ok); +} + /** Expand a named room to its door grants. Throws (fail closed, not a silent * empty launch) if the room is unknown — a typo must never widen authority. */ export function expandRoom( diff --git a/guest-room/protocol.ts b/guest-room/protocol.ts index a0024b5..a90b269 100644 --- a/guest-room/protocol.ts +++ b/guest-room/protocol.ts @@ -1,4 +1,5 @@ /** + * @module * door-protocol.ts — the generic door protocol (JSON-over-socket). * * Every door daemon speaks the same wire format: @@ -10,16 +11,62 @@ * logic (what methods they handle), not the envelope parsing. */ -import type { Socket } from "bun"; +import { Buffer } from "node:buffer"; +import { createHmac } from "node:crypto"; + +// This module's server half is transport-neutral; its client half (`call`) +// targets Bun's socket API. Rather than depend on `@types/bun` (a bare "bun" +// specifier JSR cannot resolve), we declare the minimal structural surface we +// touch locally — Bun's real types are structurally assignable, so consumers on +// Bun are unaffected and the module carries no unresolvable dependency. + +/** The subset of a connection socket the server handlers use: a per-connection + * `data` bag plus `write`. Bun's `Socket` is structurally assignable. */ +export interface DoorSocket { + /** Per-connection state bag (context data). */ + data: Cx; + /** Write data (string or bytes) to the socket; returns bytes written. */ + write(data: string | Uint8Array): number; +} + +/** The subset of a Bun client socket `call` uses. */ +interface ClientSocket { + write(data: string | Uint8Array): number; + end(): void; +} + +/** Bun's runtime global, declared locally with only the shape `call` needs (so + * the module type-checks without @types/bun). At runtime this is Bun's global, + * so `call` runs under the Bun runtime. */ +declare const Bun: { + connect(options: { + unix: string; + socket: { + data(socket: ClientSocket, chunk: Uint8Array): void; + open(socket: ClientSocket): void; + error(socket: ClientSocket, error: Error): void; + close(socket: ClientSocket): void; + }; + }): Promise<{ catch(onrejected: (reason: unknown) => void): unknown }>; +}; // ── Protocol types (shared across all doors) ──────────────────────────────── +/** JSON-RPC 2.0-like request envelope: method call with id, method name, and optional params. */ export type RequestEnvelope = { id: string; method: string; params?: Record; + /** Optional per-request authenticator. On a unix socket the kernel vouches for + * the peer (filesystem perms + peer credentials), so this is unused; on a + * tcp/vsock door — where the kernel gives no peer identity — the broker can + * require it (see {@link tokenAuthorizer}) so "only the intended guest can + * knock" survives the move off the filesystem. A bearer token today; an + * HMAC-over-the-request later, same field. */ + auth?: string; }; +/** Response envelope: result (if ok=true) or error object (if ok=false). */ export type ResponseEnvelope = { id: string; ok: boolean; @@ -39,14 +86,102 @@ export function err(id: string, code: string, message: string): ResponseEnvelope return { id, ok: false, error: { code, message } }; } +// ── Authentication helpers ─────────────────────────────────────────────────── +// A unix-socket door is authenticated by the kernel: filesystem permissions gate +// it and the broker can read the peer's credentials. A tcp/vsock door has no such +// gate — anyone who can route to it can connect — so the authority a unix socket +// carried for free has to be reconstructed on the wire. These give a broker a +// minimal, fail-closed way to do that. The grammar is intentionally tiny; the +// engine of policy stays the broker's. + +/** Constant-time string equality: compares every byte regardless of where the + * first difference falls, so comparison time doesn't leak how much of a secret + * matched. Unequal lengths return false without short-circuiting on content. */ +export function constantTimeEqual(a: string, b: string): boolean { + const ab = Buffer.from(a, "utf-8"); + const bb = Buffer.from(b, "utf-8"); + let diff = ab.length ^ bb.length; + const len = Math.max(ab.length, bb.length); + for (let i = 0; i < len; i++) diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0); + return diff === 0; +} + +/** A bearer-token {@link RequestAuthorizer}: accept a request iff it carries + * exactly `expected` in its `auth` field (constant-time compared). This is the + * per-launch token a tcp/vsock door needs so only the intended guest can knock — + * the wire-level stand-in for the kernel peer-authentication a unix socket gives + * for free. It proves possession of the secret, but the secret travels on every + * request, so a captured request can be REPLAYED verbatim. {@link hmacAuthorizer} + * closes that — same signature, stronger proof. */ +export function tokenAuthorizer(expected: string): RequestAuthorizer { + return (req) => typeof req.auth === "string" && constantTimeEqual(req.auth, expected); +} + +/** The bytes an HMAC signs: the request's identity-bearing content, with object + * keys sorted so client and server canonicalize identically. `auth` itself is + * excluded (it's the signature). Including `id` (a fresh per-request UUID) binds + * the signature to THIS request, which is what makes replay detectable. */ +export function canonicalRequest(req: RequestEnvelope): string { + return stableStringify({ id: req.id, method: req.method, params: req.params ?? {} }); +} + +/** Deterministic JSON: object keys sorted recursively, so the same logical value + * always serializes to the same string (plain JSON.stringify is key-order + * dependent, which would make two equal requests produce different MACs). */ +function stableStringify(v: unknown): string { + if (v === null || typeof v !== "object") return JSON.stringify(v) ?? "null"; + if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]"; + const o = v as Record; + return "{" + Object.keys(o).sort().map((k) => JSON.stringify(k) + ":" + stableStringify(o[k])).join(",") + "}"; +} + +/** A client-side signer for `call`'s `sign` option: produces the hex HMAC-SHA256 + * of {@link canonicalRequest} under the per-launch `key`. Pair it with + * {@link hmacAuthorizer} holding the same key on the broker. */ +export function hmacSigner(key: string): (req: RequestEnvelope) => string { + return (req) => createHmac("sha256", key).update(canonicalRequest(req)).digest("hex"); +} + +/** An HMAC-per-request {@link RequestAuthorizer}: accept a request iff its `auth` + * is a valid HMAC-SHA256 of the request content under `key` AND its `id` has not + * been seen before. The key never travels — only a signature over THIS request — + * so a captured request can't be reused (the signature is right, but its `id` is + * already spent) and can't be tampered (any change to method/params breaks the + * MAC). Replay state is the broker's: a bounded FIFO of recent ids (`replayCap`, + * default 4096); on a per-launch key this is all the window you need. */ +export function hmacAuthorizer(key: string, opts: { replayCap?: number } = {}): RequestAuthorizer { + const cap = opts.replayCap ?? 4096; + const seen = new Set(); + const order: string[] = []; + return (req) => { + if (typeof req.auth !== "string") return false; + const expected = createHmac("sha256", key).update(canonicalRequest(req)).digest("hex"); + if (!constantTimeEqual(req.auth, expected)) return false; + if (seen.has(req.id)) return false; // replay: this request was already served + seen.add(req.id); + order.push(req.id); + if (order.length > cap) seen.delete(order.shift()!); + return true; + }; +} + // ── Connection handler ────────────────────────────────────────────────────── +/** A method handler: takes params and returns a result (sync or async). */ export type MethodHandler = ( params: Record, ) => Promise | unknown; +/** Registry of method handlers (name → handler). */ export type MethodRegistry = Record; +/** Decides whether a parsed request is allowed to be served at all — checked + * BEFORE method dispatch, so an unauthenticated peer reaches no handler. The + * broker supplies it (the policy is the broker's); when omitted, no + * authentication is required and every well-formed request is dispatched (the + * unix-socket default, where the kernel already authenticated the peer). */ +export type RequestAuthorizer = (req: RequestEnvelope) => boolean; + /** * Create socket handlers for a door daemon. * @@ -60,11 +195,12 @@ export function createDoorHandlers( name: string, methods: MethodRegistry, log: (level: "INFO" | "ERR" | "ALLOW" | "DENY" | "WARN", msg: string) => void, + authorize?: RequestAuthorizer, ): { - open: (socket: Socket) => void; - data: (socket: Socket, chunk: Uint8Array) => void; - close: (socket: Socket) => void; - error: (socket: Socket, error: Error) => void; + open: (socket: DoorSocket) => void; + data: (socket: DoorSocket, chunk: Uint8Array) => void; + close: (socket: DoorSocket) => void; + error: (socket: DoorSocket, error: Error) => void; } { return { open(socket) { @@ -78,7 +214,7 @@ export function createDoorHandlers( for (const line of lines) { if (!line.trim()) continue; - const response = await handleLine(line, methods, log); + const response = await handleLine(line, methods, log, authorize); socket.write(JSON.stringify(response) + "\n"); } }, @@ -97,6 +233,7 @@ async function handleLine( line: string, methods: MethodRegistry, log: (level: "INFO" | "ERR" | "ALLOW" | "DENY" | "WARN", msg: string) => void, + authorize?: RequestAuthorizer, ): Promise { let req: RequestEnvelope; try { @@ -105,6 +242,15 @@ async function handleLine( return err("?", "PARSE_ERROR", "invalid JSON"); } + // Authenticate before dispatch — an unauthorized peer must reach no handler. + // Fail-closed: if the broker required auth, a request that doesn't satisfy it + // is denied without leaking why (no method/handler probing). When no authorizer + // is configured, every well-formed request proceeds (unix-socket default). + if (authorize && !authorize(req)) { + log("DENY", `unauthenticated request: ${req.method}`); + return err(req.id, "UNAUTHENTICATED", "request rejected"); + } + const handler = methods[req.method]; if (!handler) { log("ERR", `unknown method: ${req.method}`); @@ -124,23 +270,52 @@ async function handleLine( // ── Client helper ─────────────────────────────────────────────────────────── /** - * Send a request to a door daemon and wait for the response. + * Resolve a door endpoint to a Bun.connect target. A leading "/" (optionally + * `unix://`) is a unix socket path; otherwise `host:port` (optionally `tcp://`) + * is a TCP target — so the same client reaches a mounted unix socket (Linux/pod) + * or a host-gateway / pod-local TCP port (e.g. macOS, where virtiofs can't share + * a unix socket across the host↔VM boundary). A path containing ":" stays unix. + */ +function connectTarget(endpoint: string): { unix: string } | { hostname: string; port: number } { + const stripped = endpoint.replace(/^unix:\/\//, ""); + if (!stripped.startsWith("/")) { + const m = stripped.replace(/^tcp:\/\//, "").match(/^([^/\s]+):(\d{1,5})$/); + if (m) return { hostname: m[1]!, port: Number(m[2]) }; + } + return { unix: stripped }; +} + +/** + * Send a request to a door daemon and wait for the response. The endpoint is a + * unix socket path or a `host:port` TCP target (see {@link connectTarget}). + * + * Authenticate against a broker that fronts a tcp/vsock door: pass a fixed + * `opts.auth` bearer token (matched by {@link tokenAuthorizer}), or — preferred — + * `opts.sign` to compute a per-request signature over the assembled envelope + * (use {@link hmacSigner}, matched by {@link hmacAuthorizer}). `sign` takes + * precedence over `auth`. Both are harmless (ignored) on an unauthenticated unix + * door. * * @example * const result = await call("/run/myservice.sock", "greet", { name: "world" }); + * const viaTcp = await call("host.containers.internal:3002", "greet", { name: "world" }, { auth: token }); + * const signed = await call("host.containers.internal:3002", "greet", { name: "world" }, { sign: hmacSigner(key) }); */ export async function call( - socketPath: string, + endpoint: string, method: string, params: Record = {}, + opts: { auth?: string; sign?: (req: RequestEnvelope) => string } = {}, ): Promise { const id = crypto.randomUUID(); const req: RequestEnvelope = { id, method, params }; + const auth = opts.sign ? opts.sign(req) : opts.auth; + if (auth !== undefined) req.auth = auth; return new Promise((resolve, reject) => { let buffer = ""; const socket = Bun.connect({ - unix: socketPath, + ...connectTarget(endpoint), socket: { data(_socket, chunk) { buffer += Buffer.from(chunk).toString("utf-8"); From eff0d1345af2047ed689ab96f883e20d14c7052b Mon Sep 17 00:00:00 2001 From: Robert DeLanghe Date: Sat, 27 Jun 2026 19:45:31 -0400 Subject: [PATCH 4/4] feat: concierge mints SIGNED grants + publishes its keys (issuer side) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The concierge mints grants, so it signs them. resolve now returns a SignedGrant bound to the caller (audience), a short expiry, and a nonce, signed with an Ed25519 grant key (generated + persisted on first use). A new `keys` method publishes the issuer's public key set (kid-indexed) for keyless verification by serving rooms (verifyGrantWithKeys) — no shared secret. Un-stubs the Phase-1 null signature (CONCIERGE.md §7 / the transport-split ADR). Tests: resolve mints a grant that verifies against the published keys, and a different audience cannot present it. 11 pass. Remaining: the serving-room verify step in each door daemon + key fetch. Co-Authored-By: Claude Opus 4.8 (1M context) --- concierged.ts | 106 +++++++++++++++++++++++++++++++++------ tests/concierged.test.ts | 29 ++++++++--- 2 files changed, 113 insertions(+), 22 deletions(-) diff --git a/concierged.ts b/concierged.ts index 435e078..fc957e3 100644 --- a/concierged.ts +++ b/concierged.ts @@ -13,21 +13,28 @@ * the guest-room engine (resolveProvider); this daemon owns the mutable * registry, the clock, and policy ordering. See CONCIERGE.md. * - * ⚠️ PHASE 1 — PLUMBING, NOT A BOUNDARY (CONCIERGE.md §9). - * A door determines AVAILABILITY, not authority. The boundary is the serving - * room verifying a SIGNED grant (audience/exp/nonce-bound) before honoring it — - * and signing lives in prx, which is not wired yet. So in Phase 1: `resolve` - * returns the binding fields but the signature is STUBBED and nothing verifies - * it. Treat introduction here as routing/plumbing — do NOT claim non-bypassable - * introduction until Phase 2 wires prx's signer + the verify step. + * AUTHORITY: a door determines AVAILABILITY, not authority. The concierge MINTS + * a grant, so it SIGNS it: `resolve` returns a SignedGrant bound to the caller + * (audience), a short expiry, and a nonce. The `keys` method publishes the + * issuer's public key set; a serving room verifies the signed grant + * (verifyGrantWithKeys, keyed by `kid`) before honoring a call — no shared + * secret, reachability is not authority (CONCIERGE.md §7 / the transport-split + * ADR). The remaining half is the serving-room verify step in each door daemon. * * Usage: * concierged serve # foreground, default socket * concierged serve --socket /path.sock # custom socket path */ -import { existsSync, mkdirSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; +import { + createHash, + sign as edSign, + generateKeyPairSync, + createPrivateKey, + createPublicKey, +} from "node:crypto"; import type { Socket } from "bun"; import { @@ -44,9 +51,12 @@ import { liveProviders, attenuate, unix, + signGrant, DOOR_NAME_RE, type DoorGrant, type ProviderEntry, + type GrantBinding, + type IssuerKeys, } from "./guest-room/mod.ts"; const log = createLogger("concierged"); @@ -61,10 +71,55 @@ const VERSION = "0.1.0"; const DEFAULT_LEASE_SEC = 30; const MAX_LEASE_SEC = 300; -/** How long an issued grant's binding is valid (the `exp` a Phase-2 signature - * covers). Short: a grant is introduced just-in-time, not hoarded. */ +/** How long an issued grant's binding is valid (the `exp` the signature covers). + * Short: a grant is introduced just-in-time, not hoarded. */ const GRANT_TTL_SEC = 60; +// ── Grant signer (the issuer; keyless verification by the serving room) ─────── +// The concierge MINTS grants, so it signs them: a resolved grant is returned as +// a SignedGrant the serving room verifies (verifyGrantWithKeys) before honoring +// a call. Verifiers hold the issuer's PUBLISHED key — served by `keys` below — +// keyed by `kid`; no shared secret (CONCIERGE.md §7, the transport-split ADR). +// Ed25519, generated on first use and persisted with private perms. + +const GRANT_KEY_PATH = + process.env.CONCIERGE_GRANT_KEY ?? `${process.env.HOME ?? "/tmp"}/.claude-box/concierge-grant.key`; + +type SigningKey = { + privateKey: ReturnType; + publicKeyPem: string; + keyId: string; +}; +let signingKey: SigningKey | null = null; + +/** Load (or generate-and-persist) the issuer's Ed25519 grant-signing key. The + * keyId is a stable digest of the public key — the `kid` a SignedGrant names + * and the `keys` door publishes. */ +function getSigningKey(): SigningKey { + if (signingKey) return signingKey; + let privateKeyPem: string; + let publicKeyPem: string; + if (existsSync(GRANT_KEY_PATH)) { + privateKeyPem = readFileSync(GRANT_KEY_PATH, "utf-8"); + publicKeyPem = createPublicKey(privateKeyPem).export({ type: "spki", format: "pem" }) as string; + } else { + const kp = generateKeyPairSync("ed25519"); + privateKeyPem = kp.privateKey.export({ type: "pkcs8", format: "pem" }) as string; + publicKeyPem = kp.publicKey.export({ type: "spki", format: "pem" }) as string; + mkdirSync(dirname(GRANT_KEY_PATH), { recursive: true }); + writeFileSync(GRANT_KEY_PATH, privateKeyPem, { mode: 0o600 }); + log("INFO", `generated grant signing key at ${GRANT_KEY_PATH}`); + } + const keyId = createHash("sha256").update(publicKeyPem).digest("hex").slice(0, 16); + signingKey = { privateKey: createPrivateKey(privateKeyPem), publicKeyPem, keyId }; + return signingKey; +} + +/** Sign canonical grant bytes with the issuer key (injected into signGrant). */ +function signData(data: string): string { + return edSign(null, Buffer.from(data), getSigningKey().privateKey).toString("base64"); +} + // ── Registry (the mutable state the engine's pure resolve runs over) ────────── const registry: ProviderEntry[] = []; @@ -162,13 +217,31 @@ function handleResolve(params: Record): unknown { throw { code: "CAPABILITY_UNAVAILABLE", message: `no live provider for "${capability}"` }; } - log("ALLOW", `resolve ${capability}${want.length ? ` want[${want.join("; ")}]` : ""} → ${(grant.guest as { path: string }).path}`); - return { - door: grant, - // The to-be-signed envelope. Phase 1: sig === null (prx signs in Phase 2), - // and no party verifies — so this is NOT a non-bypassable boundary yet. - binding: { audience, exp: now() + GRANT_TTL_SEC * 1000, nonce: crypto.randomUUID(), sig: null }, + // Mint a SIGNED grant: bind it to the caller (audience), a short expiry, and a + // fresh nonce, then sign. The serving room verifies (verifyGrantWithKeys) + // before honoring a call — reachability is not authority. + const key = getSigningKey(); + const binding: GrantBinding = { + audience: audience ?? "", + exp: now() + GRANT_TTL_SEC * 1000, + nonce: crypto.randomUUID(), + keyId: key.keyId, }; + const signed = signGrant(grant, binding, signData); + + log("ALLOW", `resolve ${capability}${want.length ? ` want[${want.join("; ")}]` : ""} → ${(grant.guest as { path: string }).path}`); + // The SignedGrant carries `binding` + `signature` inline; `binding` is also + // returned at top level for consumers that read it directly. + return { door: signed, binding: signed.binding }; +} + +/** Publish the issuer's public key set (keyless verification). A serving room + * fetches this once, caches it, and verifies signed grants against the key the + * grant names by `kid`. Rotation = serve an additional key here. */ +function handleKeys(_params: Record): unknown { + const key = getSigningKey(); + const keys: IssuerKeys = { keys: [{ kid: key.keyId, publicKeyPem: key.publicKeyPem }] }; + return keys; } /** Discovery: the capabilities currently served (one row per live capability). */ @@ -188,6 +261,7 @@ const METHODS: Record = { register: handleRegister, resolve: handleResolve, list: handleList, + keys: handleKeys, }; // ── Request handling ───────────────────────────────────────────────────────── diff --git a/tests/concierged.test.ts b/tests/concierged.test.ts index af82a32..633ee80 100644 --- a/tests/concierged.test.ts +++ b/tests/concierged.test.ts @@ -8,7 +8,9 @@ * nix run nixpkgs#bun -- test tests/concierged.test.ts */ import { test, expect, describe, beforeEach } from "bun:test"; +import { createPublicKey, verify as edVerify } from "node:crypto"; import { handleRequest, registry } from "../concierged.ts"; +import { verifyGrantWithKeys, type SignedGrant, type IssuerKeys } from "../guest-room/mod.ts"; const rpc = async (method: string, params: Record = {}) => handleRequest(JSON.stringify({ id: "t", method, params })); @@ -34,14 +36,29 @@ describe("concierged register/resolve", () => { expect(resp.error?.code).toBe("CAPABILITY_UNAVAILABLE"); }); - test("PHASE 1: resolve carries an unsigned binding (sig null) — not a boundary yet", async () => { + test("resolve mints a SIGNED grant that verifies against the published keys", async () => { await rpc("register", { capability: "scout", door: "/run/scoutd.sock" }); const resp = await rpc("resolve", { capability: "scout", audience: "box-42" }); - const binding = (resp.result as { binding: { audience: string; exp: number; nonce: string; sig: null } }).binding; - expect(binding.sig).toBeNull(); // prx signs in Phase 2; until then nothing verifies - expect(binding.audience).toBe("box-42"); - expect(typeof binding.exp).toBe("number"); - expect(typeof binding.nonce).toBe("string"); + const door = (resp.result as { door: SignedGrant }).door; + + // Bound to the caller, short-lived, nonce-fresh, names the issuer key. + expect(door.binding.audience).toBe("box-42"); + expect(typeof door.binding.exp).toBe("number"); + expect(typeof door.binding.nonce).toBe("string"); + expect(door.binding.keyId.length).toBeGreaterThan(0); + expect(door.signature.length).toBeGreaterThan(0); + + // The serving room verifies it against the issuer's PUBLISHED key set (keyless). + const keys = (await rpc("keys")).result as IssuerKeys; + const verifyWith = (d: string, s: string, pem: string): boolean => + edVerify(null, Buffer.from(d), createPublicKey(pem), Buffer.from(s, "base64")); + expect(verifyGrantWithKeys(door, { audience: "box-42", now: Date.now() }, keys, verifyWith)).toEqual({ ok: true }); + + // ...and a different room cannot present it (audience binding). + expect(verifyGrantWithKeys(door, { audience: "other", now: Date.now() }, keys, verifyWith)).toEqual({ + ok: false, + reason: "audience-mismatch", + }); }); test("resolve attenuates by the caller's want, never wider than the ceiling", async () => {