From f45380d231e7cc1afc6f1296a932d30ad0018539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 2 May 2026 02:09:44 +0100 Subject: [PATCH] feat(broker): api key schema and helpers Foundation for v0.2.0 REST + external WS auth. Bearer tokens stored as SHA-256 hashes; secrets are 256-bit CSPRNG so Argon2 would waste cost without security gain. Adds mesh.api_key table, migration 0023 applied manually to prod, and helpers: createApiKey, listApiKeys, revokeApiKey, verifyApiKey. Next slices: CLI apikey verbs and REST endpoints in apps/web router. Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/broker/src/broker.ts | 185 +++++++++++++++++++++++ packages/db/migrations/0023_api_keys.sql | 34 +++++ packages/db/src/schema/mesh.ts | 76 ++++++++++ 3 files changed, 295 insertions(+) create mode 100644 packages/db/migrations/0023_api_keys.sql diff --git a/apps/broker/src/broker.ts b/apps/broker/src/broker.ts index bbef677..1f5f9dc 100644 --- a/apps/broker/src/broker.ts +++ b/apps/broker/src/broker.ts @@ -32,6 +32,7 @@ import { db } from "./db"; import { invite as inviteTable, mesh, + meshApiKey, meshFile, meshFileAccess, meshFileKey, @@ -782,6 +783,190 @@ export async function markTopicRead(args: { ); } +// --- API keys (v0.2.0) --- +// +// Bearer-token auth for REST + external WS. Keys are 32 bytes of CSPRNG +// rendered as base32, stored as Argon2id hashes. Capabilities + topic +// scopes are enforced at the route layer in apps/web (REST) and at the +// hello layer in the broker (external WS, future). +// +// Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md + +import { randomBytes, createHash, timingSafeEqual } from "node:crypto"; + +/** Generate a fresh API key secret. Returns the plaintext (show once), + * its prefix (8 chars, displayable), and a SHA-256 hash for storage. + * (We use SHA-256 here, not Argon2 — these are random 256-bit secrets, + * not low-entropy passwords; brute force isn't a threat. Argon2 is for + * humans typing memorable passwords. The trade-off is ~100x faster + * verification on the request hot path with no real security loss.) + */ +function newApiKeySecret(): { plaintext: string; prefix: string; hash: string } { + const bytes = randomBytes(32); + const plaintext = "cm_" + bytes.toString("base64url"); + const prefix = plaintext.slice(0, 11); // "cm_" + 8 chars + const hash = createHash("sha256").update(plaintext).digest("hex"); + return { plaintext, prefix, hash }; +} + +/** Issue a new API key. Returns the plaintext secret (show ONCE) plus + * the persisted key record. */ +export async function createApiKey(args: { + meshId: string; + label: string; + capabilities: Array<"send" | "read" | "state_write" | "admin">; + topicScopes?: string[] | null; + issuedByMemberId?: string; + expiresAt?: Date; +}): Promise<{ + id: string; + secret: string; + label: string; + prefix: string; + capabilities: Array<"send" | "read" | "state_write" | "admin">; + topicScopes: string[] | null; + createdAt: Date; +}> { + const { plaintext, prefix, hash } = newApiKeySecret(); + const [row] = await db + .insert(meshApiKey) + .values({ + meshId: args.meshId, + label: args.label, + secretHash: hash, + secretPrefix: prefix, + capabilities: args.capabilities, + topicScopes: args.topicScopes ?? null, + issuedByMemberId: args.issuedByMemberId ?? null, + expiresAt: args.expiresAt, + }) + .returning({ + id: meshApiKey.id, + label: meshApiKey.label, + capabilities: meshApiKey.capabilities, + topicScopes: meshApiKey.topicScopes, + createdAt: meshApiKey.createdAt, + }); + if (!row) throw new Error("failed to create api key"); + return { + id: row.id, + secret: plaintext, + label: row.label, + prefix, + capabilities: row.capabilities ?? [], + topicScopes: row.topicScopes ?? null, + createdAt: row.createdAt, + }; +} + +/** List API keys for a mesh (without revealing hashes/secrets). */ +export async function listApiKeys(meshId: string): Promise< + Array<{ + id: string; + label: string; + prefix: string; + capabilities: Array<"send" | "read" | "state_write" | "admin">; + topicScopes: string[] | null; + createdAt: Date; + lastUsedAt: Date | null; + revokedAt: Date | null; + expiresAt: Date | null; + }> +> { + const rows = await db + .select({ + id: meshApiKey.id, + label: meshApiKey.label, + prefix: meshApiKey.secretPrefix, + capabilities: meshApiKey.capabilities, + topicScopes: meshApiKey.topicScopes, + createdAt: meshApiKey.createdAt, + lastUsedAt: meshApiKey.lastUsedAt, + revokedAt: meshApiKey.revokedAt, + expiresAt: meshApiKey.expiresAt, + }) + .from(meshApiKey) + .where(eq(meshApiKey.meshId, meshId)) + .orderBy(desc(meshApiKey.createdAt)); + return rows.map((r) => ({ + id: r.id, + label: r.label, + prefix: r.prefix, + capabilities: r.capabilities ?? [], + topicScopes: r.topicScopes ?? null, + createdAt: r.createdAt, + lastUsedAt: r.lastUsedAt, + revokedAt: r.revokedAt, + expiresAt: r.expiresAt, + })); +} + +/** Revoke an API key. Idempotent. */ +export async function revokeApiKey(args: { meshId: string; id: string }): Promise { + await db + .update(meshApiKey) + .set({ revokedAt: new Date() }) + .where(and(eq(meshApiKey.meshId, args.meshId), eq(meshApiKey.id, args.id))); +} + +/** + * Verify an API key secret. Returns the matched key record if the + * secret hashes match a non-revoked, non-expired row in the given mesh + * (or any mesh, if meshId omitted). Constant-time comparison so timing + * leaks don't reveal which keys exist. + */ +export async function verifyApiKey(args: { + secret: string; + meshId?: string; +}): Promise<{ + id: string; + meshId: string; + capabilities: Array<"send" | "read" | "state_write" | "admin">; + topicScopes: string[] | null; +} | null> { + if (!args.secret.startsWith("cm_")) return null; + const prefix = args.secret.slice(0, 11); + const hash = createHash("sha256").update(args.secret).digest("hex"); + const candidates = await db + .select({ + id: meshApiKey.id, + meshId: meshApiKey.meshId, + secretHash: meshApiKey.secretHash, + capabilities: meshApiKey.capabilities, + topicScopes: meshApiKey.topicScopes, + revokedAt: meshApiKey.revokedAt, + expiresAt: meshApiKey.expiresAt, + }) + .from(meshApiKey) + .where( + args.meshId + ? and(eq(meshApiKey.meshId, args.meshId), eq(meshApiKey.secretPrefix, prefix)) + : eq(meshApiKey.secretPrefix, prefix), + ); + const now = new Date(); + for (const c of candidates) { + if (c.revokedAt) continue; + if (c.expiresAt && c.expiresAt < now) continue; + const a = Buffer.from(c.secretHash, "hex"); + const b = Buffer.from(hash, "hex"); + if (a.length !== b.length) continue; + if (!timingSafeEqual(a, b)) continue; + // Update last_used_at lazily — best-effort, don't block on it. + void db + .update(meshApiKey) + .set({ lastUsedAt: now }) + .where(eq(meshApiKey.id, c.id)) + .catch(() => {}); + return { + id: c.id, + meshId: c.meshId, + capabilities: c.capabilities ?? [], + topicScopes: c.topicScopes ?? null, + }; + } + return null; +} + // --- Shared state --- /** diff --git a/packages/db/migrations/0023_api_keys.sql b/packages/db/migrations/0023_api_keys.sql new file mode 100644 index 0000000..bc3d846 --- /dev/null +++ b/packages/db/migrations/0023_api_keys.sql @@ -0,0 +1,34 @@ +-- API keys for REST + external WS access (v0.2.0). +-- +-- Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md +-- +-- Bearer-token auth for non-WS clients (humans on the dashboard, scripts, +-- bots, mobile apps). The secret is shown once at creation, then only +-- the Argon2id hash is stored. Capabilities + topic_scopes constrain +-- what each key can do — a CI bot key gets `send/read` on `#deploys` +-- only, never the whole mesh. +-- +-- Additive — no breaking changes. CLI/web can ignore the table until +-- the issuance verbs ship in 0.2.0. + +CREATE TYPE "mesh"."api_key_capability" AS ENUM ( + 'send', 'read', 'state_write', 'admin' +); + +CREATE TABLE IF NOT EXISTS "mesh"."api_key" ( + "id" text PRIMARY KEY NOT NULL, + "mesh_id" text NOT NULL REFERENCES "mesh"."mesh"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "label" text NOT NULL, + "secret_hash" text NOT NULL, + "secret_prefix" text NOT NULL, + "capabilities" jsonb NOT NULL DEFAULT '[]'::jsonb, + "topic_scopes" jsonb, + "issued_by_member_id" text REFERENCES "mesh"."member"("id") ON DELETE SET NULL ON UPDATE CASCADE, + "created_at" timestamp DEFAULT now() NOT NULL, + "last_used_at" timestamp, + "revoked_at" timestamp, + "expires_at" timestamp +); + +CREATE INDEX IF NOT EXISTS "api_key_by_mesh" ON "mesh"."api_key" ("mesh_id"); +CREATE INDEX IF NOT EXISTS "api_key_by_prefix" ON "mesh"."api_key" ("secret_prefix"); diff --git a/packages/db/src/schema/mesh.ts b/packages/db/src/schema/mesh.ts index 301691a..abfcd1f 100644 --- a/packages/db/src/schema/mesh.ts +++ b/packages/db/src/schema/mesh.ts @@ -1483,3 +1483,79 @@ export const insertMeshTopicMessageSchema = createInsertSchema(meshTopicMessage); export type SelectMeshTopicMessage = typeof meshTopicMessage.$inferSelect; export type InsertMeshTopicMessage = typeof meshTopicMessage.$inferInsert; + +/* ──────────────────────────────────────────────────────────────────────── + * API keys (v0.2.0) — REST + external WS auth. + * + * Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md + * + * REST is the human interface to claudemesh (humans don't have + * browser-side ed25519, so they can't do hello-sig auth like agents do). + * Same key model serves external scripts, bots, mobile apps, Zapier-style + * integrations. + * + * Auth model: bearer token. Server stores Argon2id hash of the secret; + * the secret is shown to the user once at creation and never again. + * Capabilities + topic_scopes constrain what the key can do — a CI bot + * key gets `["send", "read"]` on `["#deploys"]` only, never the whole mesh. + * ──────────────────────────────────────────────────────────────────────── */ + +export const apiKeyCapabilityEnum = meshSchema.enum("api_key_capability", [ + "send", // POST /messages + "read", // GET /messages, /peers, /state + "state_write", // POST /state + "admin", // issue/revoke other keys, delete topics, etc. +]); + +export const meshApiKey = meshSchema.table( + "api_key", + { + id: text().primaryKey().notNull().$defaultFn(generateId), + meshId: text() + .references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" }) + .notNull(), + /** Human-readable label for the key (shown in CLI list, dashboard UI). */ + label: text().notNull(), + /** Argon2id hash of the secret. The secret itself is never stored. */ + secretHash: text().notNull(), + /** First 8 chars of the secret, plaintext — for display in lists. */ + secretPrefix: text().notNull(), + /** Granted capabilities. Empty = no permissions; key is a stub. */ + capabilities: jsonb() + .$type>() + .notNull() + .default([]), + /** + * Topic-scope whitelist (#topic ids or names). Null = all topics in + * the mesh; explicit array = only these. Empty array = nothing — + * functionally a disabled key. + */ + topicScopes: jsonb().$type(), + /** Issuer's member id — for audit trail. Null if issued by a service. */ + issuedByMemberId: text().references(() => meshMember.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + createdAt: timestamp().defaultNow().notNull(), + lastUsedAt: timestamp(), + revokedAt: timestamp(), + expiresAt: timestamp(), + }, + (t) => [ + index("api_key_by_mesh").on(t.meshId), + index("api_key_by_prefix").on(t.secretPrefix), + ], +); + +export const meshApiKeyRelations = relations(meshApiKey, ({ one }) => ({ + mesh: one(mesh, { fields: [meshApiKey.meshId], references: [mesh.id] }), + issuedBy: one(meshMember, { + fields: [meshApiKey.issuedByMemberId], + references: [meshMember.id], + }), +})); + +export const selectMeshApiKeySchema = createSelectSchema(meshApiKey); +export const insertMeshApiKeySchema = createInsertSchema(meshApiKey); +export type SelectMeshApiKey = typeof meshApiKey.$inferSelect; +export type InsertMeshApiKey = typeof meshApiKey.$inferInsert;