Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 90 additions & 16 deletions concierged.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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");
Expand All @@ -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<typeof createPrivateKey>;
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[] = [];
Expand Down Expand Up @@ -162,13 +217,31 @@ function handleResolve(params: Record<string, unknown>): 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<string, unknown>): 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). */
Expand All @@ -188,6 +261,7 @@ const METHODS: Record<string, MethodHandler> = {
register: handleRegister,
resolve: handleResolve,
list: handleList,
keys: handleKeys,
};

// ── Request handling ─────────────────────────────────────────────────────────
Expand Down
8 changes: 4 additions & 4 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions guest-room/daemon.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/**
* @module
* daemon.ts — shared utilities for door daemons.
*
* Every daemon follows the same pattern:
Expand All @@ -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<string, string | undefined>;

/** Function to determine the run directory for sockets. */
Expand Down
Loading
Loading