From 242fd061b3b8d9514e0062403e88a9e6a20aaa75 Mon Sep 17 00:00:00 2001 From: ts00 Date: Tue, 28 Apr 2026 18:46:08 -0300 Subject: [PATCH] =?UTF-8?q?feat(slack-app=20phase=201):=20foundation=20?= =?UTF-8?q?=E2=80=94=20schema=20+=20encryption=20+=20LLM=20key=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the Slack app work (see docs/eng-plan-slack-app-v1.md). Backend-only, no Slack-facing surface yet. Lays the durable foundation that Phases 2+ build on. Schema (3 new tables, 3 new migrations): - 023_llm_keys: customer-supplied LLM provider keys, encrypted at rest. One row per (team_id|user_id, provider). Partial unique indexes enforce one-per-scope. - 024_slack_workspaces: 1-to-1 mapping of Slack workspace → Reflect team (or solo user). Bot token encrypted at rest. Soft-deleted via uninstalled_at. - 025_slack_conversation_state: per-thread short-term agent context, TTL'd to 24h. Encryption (src/llm-key-crypto.ts): - AES-256-GCM with a 12-byte random nonce per write. - Per-tenant sub-key derived via HKDF-SHA256(masterKey, salt=scopeId, info='reflect-memory:llm-key-v1') so a leaked encrypted blob is useless without the team_id/user_id. - Master key from env RM_LLM_KEY_ENCRYPTION_KEY (64 hex chars / 32 bytes). Lazy validation — service boots without it but LLM-key features are unavailable until set. - Auth tag (16-byte GCM tag) appended to ciphertext in the same BLOB. Service (src/llm-key-service.ts): - listLlmKeys / getLlmKeySummary / getLlmKeyPlaintext / setLlmKey (upsert = rotate) / deleteLlmKey. - Audit events for create / rotate / remove (last4 only — never the plaintext key). HTTP routes (admin-only, mirrors /admin/log-export gating): - GET /admin/llm-keys - PUT /admin/llm-keys { provider, key } - DELETE /admin/llm-keys/:provider Scope auto-resolved from caller's user record (team if present, else user). Tests (+20, total 295 → 315 all green): - HTTP: empty body, unsupported provider, agent-key 403, set echoes last4, rotate replaces with updated_at advance, double-delete 404, audit events recorded with last4 (and never plaintext). - Unit: encrypt/decrypt round-trip, wrong-scope decrypt fails (HKDF separation), tampered ciphertext fails (GCM), KeyScope validation, empty plaintext rejected, malformed master key rejected, missing master key rejected. Refs: parent memory d959bc61 (Eng Plan: Slack App v1). Made-with: Cursor --- schema.sql | 80 +++++++ src/index.ts | 93 ++++++++ src/llm-key-crypto.ts | 180 ++++++++++++++ src/llm-key-service.ts | 257 ++++++++++++++++++++ src/server.ts | 142 +++++++++++ tests/global-setup.ts | 4 + tests/integration/llm-keys.test.ts | 369 +++++++++++++++++++++++++++++ 7 files changed, 1125 insertions(+) create mode 100644 src/llm-key-crypto.ts create mode 100644 src/llm-key-service.ts create mode 100644 tests/integration/llm-keys.test.ts diff --git a/schema.sql b/schema.sql index 9958665..d71a129 100644 --- a/schema.sql +++ b/schema.sql @@ -242,6 +242,86 @@ CREATE TABLE tag_cluster_cache ( CREATE INDEX idx_tag_cluster_cache_user_scope ON tag_cluster_cache(user_id, scope); +-- ============================================================================= +-- LLM PROVIDER KEYS (migration 023) +-- ============================================================================= +-- One row per (team_id|user_id, provider). Keys encrypted at rest with +-- AES-256-GCM. Master key from RM_LLM_KEY_ENCRYPTION_KEY env, per-tenant +-- sub-key derived via HKDF-SHA256 with the team/user ID as salt. last4 stored +-- cleartext for UI display. See src/llm-key-crypto.ts. + +CREATE TABLE llm_keys ( + id TEXT PRIMARY KEY, + team_id TEXT REFERENCES teams(id) ON DELETE CASCADE, + user_id TEXT REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + key_encrypted BLOB NOT NULL, + key_nonce BLOB NOT NULL, + key_last4 TEXT NOT NULL, + created_by_user_id TEXT REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK ( + (team_id IS NOT NULL AND user_id IS NULL) OR + (team_id IS NULL AND user_id IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX idx_llm_keys_team_provider + ON llm_keys(team_id, provider) WHERE team_id IS NOT NULL; +CREATE UNIQUE INDEX idx_llm_keys_user_provider + ON llm_keys(user_id, provider) WHERE user_id IS NOT NULL; + +-- ============================================================================= +-- SLACK WORKSPACES (migration 024) +-- ============================================================================= +-- One row per Slack workspace install, mapped 1-to-1 to a Reflect team (or +-- a solo Reflect user). Bot token encrypted at rest using the same scheme as +-- llm_keys. Soft-deleted via uninstalled_at; the row is kept for audit +-- history. See docs/eng-plan-slack-app-v1.md. + +CREATE TABLE slack_workspaces ( + id TEXT PRIMARY KEY, + slack_team_id TEXT NOT NULL UNIQUE, + slack_team_name TEXT NOT NULL, + reflect_team_id TEXT REFERENCES teams(id), + reflect_user_id TEXT REFERENCES users(id), + bot_user_id TEXT NOT NULL, + bot_token_encrypted BLOB NOT NULL, + bot_token_nonce BLOB NOT NULL, + installed_by_user_id TEXT REFERENCES users(id), + installed_at TEXT NOT NULL, + uninstalled_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK ( + (reflect_team_id IS NOT NULL AND reflect_user_id IS NULL) OR + (reflect_team_id IS NULL AND reflect_user_id IS NOT NULL) + ) +); + +CREATE INDEX idx_slack_workspaces_team ON slack_workspaces(reflect_team_id); +CREATE INDEX idx_slack_workspaces_user ON slack_workspaces(reflect_user_id); + +-- ============================================================================= +-- SLACK CONVERSATION STATE (migration 025) +-- ============================================================================= +-- Per-Slack-thread short-term context so the agent sees the last few turns of +-- a conversation in a thread without re-fetching from Slack. TTL'd to 24h. + +CREATE TABLE slack_conversation_state ( + id TEXT PRIMARY KEY, + slack_workspace_id TEXT NOT NULL REFERENCES slack_workspaces(id) ON DELETE CASCADE, + channel_id TEXT NOT NULL, + thread_ts TEXT NOT NULL, + messages_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + UNIQUE(slack_workspace_id, channel_id, thread_ts) +); + +CREATE INDEX idx_slack_convo_expires ON slack_conversation_state(expires_at); + -- ============================================================================= -- RESERVED: Phase 3 -- Identity & Governance Primitives -- ============================================================================= diff --git a/src/index.ts b/src/index.ts index 263af76..aaca5e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -743,6 +743,99 @@ if (!db.prepare(`SELECT 1 FROM _migrations WHERE name = ?`).get(tagClusterCacheM ); } +// 023 — LLM provider keys. One row per (team_id|user_id, provider). Encrypted +// at rest with AES-256-GCM, master key from RM_LLM_KEY_ENCRYPTION_KEY env, +// per-tenant sub-key derived via HKDF-SHA256 with the team/user ID as salt. +// last4 stored cleartext for UI display. See src/llm-key-crypto.ts. +const llmKeysMigration = "023_llm_keys"; +if (!db.prepare(`SELECT 1 FROM _migrations WHERE name = ?`).get(llmKeysMigration)) { + db.exec(` + CREATE TABLE IF NOT EXISTS llm_keys ( + id TEXT PRIMARY KEY, + team_id TEXT REFERENCES teams(id) ON DELETE CASCADE, + user_id TEXT REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + key_encrypted BLOB NOT NULL, + key_nonce BLOB NOT NULL, + key_last4 TEXT NOT NULL, + created_by_user_id TEXT REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK ( + (team_id IS NOT NULL AND user_id IS NULL) OR + (team_id IS NULL AND user_id IS NOT NULL) + ) + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_keys_team_provider + ON llm_keys(team_id, provider) WHERE team_id IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_keys_user_provider + ON llm_keys(user_id, provider) WHERE user_id IS NOT NULL; + `); + db.prepare(`INSERT INTO _migrations (name, applied_at) VALUES (?, ?)`).run( + llmKeysMigration, + new Date().toISOString(), + ); +} + +// 024 — Slack workspace installs. One row per workspace, mapped to exactly +// one Reflect team (or one solo user). Bot token encrypted at rest using the +// same scheme as llm_keys. Soft-delete via uninstalled_at; the row is kept +// for audit history. See docs/eng-plan-slack-app-v1.md. +const slackWorkspacesMigration = "024_slack_workspaces"; +if (!db.prepare(`SELECT 1 FROM _migrations WHERE name = ?`).get(slackWorkspacesMigration)) { + db.exec(` + CREATE TABLE IF NOT EXISTS slack_workspaces ( + id TEXT PRIMARY KEY, + slack_team_id TEXT NOT NULL UNIQUE, + slack_team_name TEXT NOT NULL, + reflect_team_id TEXT REFERENCES teams(id), + reflect_user_id TEXT REFERENCES users(id), + bot_user_id TEXT NOT NULL, + bot_token_encrypted BLOB NOT NULL, + bot_token_nonce BLOB NOT NULL, + installed_by_user_id TEXT REFERENCES users(id), + installed_at TEXT NOT NULL, + uninstalled_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK ( + (reflect_team_id IS NOT NULL AND reflect_user_id IS NULL) OR + (reflect_team_id IS NULL AND reflect_user_id IS NOT NULL) + ) + ); + CREATE INDEX IF NOT EXISTS idx_slack_workspaces_team ON slack_workspaces(reflect_team_id); + CREATE INDEX IF NOT EXISTS idx_slack_workspaces_user ON slack_workspaces(reflect_user_id); + `); + db.prepare(`INSERT INTO _migrations (name, applied_at) VALUES (?, ?)`).run( + slackWorkspacesMigration, + new Date().toISOString(), + ); +} + +// 025 — Per-Slack-thread short-term context. Lets the agent see the last few +// turns of a conversation in a thread without re-fetching from Slack. TTL'd +// to 24h so the table stays small; deeper history comes from memories. +const slackConvoStateMigration = "025_slack_conversation_state"; +if (!db.prepare(`SELECT 1 FROM _migrations WHERE name = ?`).get(slackConvoStateMigration)) { + db.exec(` + CREATE TABLE IF NOT EXISTS slack_conversation_state ( + id TEXT PRIMARY KEY, + slack_workspace_id TEXT NOT NULL REFERENCES slack_workspaces(id) ON DELETE CASCADE, + channel_id TEXT NOT NULL, + thread_ts TEXT NOT NULL, + messages_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + UNIQUE(slack_workspace_id, channel_id, thread_ts) + ); + CREATE INDEX IF NOT EXISTS idx_slack_convo_expires ON slack_conversation_state(expires_at); + `); + db.prepare(`INSERT INTO _migrations (name, applied_at) VALUES (?, ?)`).run( + slackConvoStateMigration, + new Date().toISOString(), + ); +} + // Primary owner (RM_OWNER_EMAIL) anchors orphan consolidation and the legacy // single-tenant userId semantics. Additional admins come from RM_OWNER_EMAILS // (comma-separated). Both envs coexist: the singular is always included in the diff --git a/src/llm-key-crypto.ts b/src/llm-key-crypto.ts new file mode 100644 index 0000000..a054ec2 --- /dev/null +++ b/src/llm-key-crypto.ts @@ -0,0 +1,180 @@ +/** + * LLM provider key encryption. + * + * Stores customer-supplied LLM API keys (Anthropic, etc.) encrypted at rest. + * Risk model: keys are valuable but their abuse is bounded to financial loss + * for the customer's LLM provider account, not data exfiltration. We aim for + * "credibly secure," not "vault-grade." + * + * Scheme: + * - Master key from env `RM_LLM_KEY_ENCRYPTION_KEY` (64 hex chars = 32 bytes). + * - Per-tenant sub-key derived via HKDF-SHA256(masterKey, salt=scopeId, info) + * so a leaked encrypted blob alone is useless without knowing the team_id + * or user_id; rotation per tenant becomes trivial. + * - AES-256-GCM with a random 12-byte nonce per write. + * - Storage: `key_encrypted` BLOB = ciphertext || authTag (16-byte tag at + * the end); `key_nonce` BLOB = the 12-byte nonce; `key_last4` TEXT for UI. + * + * The master key is validated lazily on the first encrypt/decrypt call so + * the API can boot without it (only fails when a key feature is exercised). + */ + +import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto"; + +const MASTER_KEY_ENV = "RM_LLM_KEY_ENCRYPTION_KEY"; +const ALGORITHM = "aes-256-gcm"; +const NONCE_BYTES = 12; +const TAG_BYTES = 16; +const SUBKEY_BYTES = 32; +const HKDF_INFO = Buffer.from("reflect-memory:llm-key-v1"); + +let cachedMasterKey: Buffer | null = null; +let cachedMasterKeyError: Error | null = null; + +function loadMasterKey(): Buffer { + if (cachedMasterKey) return cachedMasterKey; + if (cachedMasterKeyError) throw cachedMasterKeyError; + + const raw = process.env[MASTER_KEY_ENV]; + if (!raw || raw.trim().length === 0) { + cachedMasterKeyError = new Error( + `${MASTER_KEY_ENV} is not set. ` + + `Generate one with \`openssl rand -hex 32\` and add it to your .env.`, + ); + throw cachedMasterKeyError; + } + + const trimmed = raw.trim(); + if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) { + cachedMasterKeyError = new Error( + `${MASTER_KEY_ENV} must be exactly 64 hex characters (32 bytes). ` + + `Got ${trimmed.length} characters.`, + ); + throw cachedMasterKeyError; + } + + cachedMasterKey = Buffer.from(trimmed, "hex"); + return cachedMasterKey; +} + +/** + * Forces the master key to be loaded and validated. Call once at boot if you + * want eager validation (fail-fast), or skip and let lazy validation kick in + * the first time anyone tries to encrypt/decrypt a key. + * + * Returns true if the key is loaded successfully, false otherwise (and logs + * a warning). Never throws so the API can boot in environments where LLM + * features are unused. + */ +export function tryValidateMasterKey(): boolean { + try { + loadMasterKey(); + return true; + } catch (err) { + console.warn( + `[llm-key-crypto] ${MASTER_KEY_ENV} not configured: ${err instanceof Error ? err.message : err}. ` + + `LLM key features will be unavailable until this is set.`, + ); + return false; + } +} + +/** + * Resets cached master-key state. Test-only helper; do not call from prod code. + */ +export function _resetMasterKeyCacheForTests(): void { + cachedMasterKey = null; + cachedMasterKeyError = null; +} + +export interface KeyScope { + /** Exactly one of teamId or userId must be set (matches the llm_keys CHECK constraint). */ + teamId?: string | null; + userId?: string | null; +} + +function scopeId(scope: KeyScope): string { + if (scope.teamId && scope.userId) { + throw new Error("KeyScope must have exactly one of teamId or userId, not both"); + } + if (scope.teamId) return `team:${scope.teamId}`; + if (scope.userId) return `user:${scope.userId}`; + throw new Error("KeyScope must have one of teamId or userId"); +} + +function deriveSubKey(scope: KeyScope): Buffer { + const master = loadMasterKey(); + const salt = Buffer.from(scopeId(scope), "utf8"); + const derived = hkdfSync("sha256", master, salt, HKDF_INFO, SUBKEY_BYTES); + return Buffer.from(derived); +} + +export interface EncryptedKey { + /** Ciphertext concatenated with the GCM auth tag (tag is the last 16 bytes). */ + ciphertext: Buffer; + /** 12-byte GCM nonce. Random per encrypt call. */ + nonce: Buffer; + /** Cleartext, for UI display only. Never includes the full key. */ + last4: string; +} + +/** + * Extracts the last 4 characters of the key for UI display. Strips trailing + * whitespace defensively. + */ +export function extractLast4(plaintext: string): string { + const trimmed = plaintext.trim(); + if (trimmed.length === 0) return ""; + return trimmed.slice(-4); +} + +/** + * Encrypts a plaintext LLM key for the given scope. Returns the ciphertext+tag, + * nonce, and last4 ready to persist. + */ +export function encryptLlmKey(plaintext: string, scope: KeyScope): EncryptedKey { + if (!plaintext || plaintext.trim().length === 0) { + throw new Error("Cannot encrypt empty LLM key"); + } + const subKey = deriveSubKey(scope); + const nonce = randomBytes(NONCE_BYTES); + const cipher = createCipheriv(ALGORITHM, subKey, nonce); + const ciphertext = Buffer.concat([ + cipher.update(plaintext.trim(), "utf8"), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + if (tag.length !== TAG_BYTES) { + throw new Error(`Unexpected GCM tag length: ${tag.length} (expected ${TAG_BYTES})`); + } + return { + ciphertext: Buffer.concat([ciphertext, tag]), + nonce, + last4: extractLast4(plaintext), + }; +} + +/** + * Decrypts an LLM key for the given scope. Throws if the scope doesn't match + * the one used to encrypt (HKDF derives a different sub-key) or if the + * ciphertext was tampered with (GCM auth tag mismatch). + */ +export function decryptLlmKey( + encrypted: { ciphertext: Buffer; nonce: Buffer }, + scope: KeyScope, +): string { + if (encrypted.nonce.length !== NONCE_BYTES) { + throw new Error(`Invalid nonce length: ${encrypted.nonce.length} (expected ${NONCE_BYTES})`); + } + if (encrypted.ciphertext.length < TAG_BYTES) { + throw new Error( + `Ciphertext too short to contain auth tag: ${encrypted.ciphertext.length} bytes`, + ); + } + const subKey = deriveSubKey(scope); + const ciphertext = encrypted.ciphertext.subarray(0, encrypted.ciphertext.length - TAG_BYTES); + const tag = encrypted.ciphertext.subarray(encrypted.ciphertext.length - TAG_BYTES); + const decipher = createDecipheriv(ALGORITHM, subKey, encrypted.nonce); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); +} diff --git a/src/llm-key-service.ts b/src/llm-key-service.ts new file mode 100644 index 0000000..3eb2166 --- /dev/null +++ b/src/llm-key-service.ts @@ -0,0 +1,257 @@ +/** + * LLM key service — CRUD for the `llm_keys` table. + * + * Wraps the encryption module so callers don't see plaintext unless they + * explicitly fetch a key for use (`getLlmKeyPlaintext`). All other reads + * return the non-secret summary (provider, last4, timestamps). + * + * Audit events are emitted for create / rotate / remove. + */ + +import { randomUUID } from "node:crypto"; +import type Database from "better-sqlite3"; + +import { recordAuditEvent } from "./audit-service.js"; +import { + decryptLlmKey, + encryptLlmKey, + type KeyScope, +} from "./llm-key-crypto.js"; + +export type LlmProvider = "anthropic"; + +export const SUPPORTED_PROVIDERS: ReadonlyArray = ["anthropic"]; + +export function isSupportedProvider(value: string): value is LlmProvider { + return (SUPPORTED_PROVIDERS as readonly string[]).includes(value); +} + +/** Public, non-secret view of a stored key. Safe to return to clients. */ +export interface LlmKeySummary { + provider: LlmProvider; + last4: string; + createdAt: string; + updatedAt: string; + createdByUserId: string | null; +} + +interface LlmKeyRow { + id: string; + team_id: string | null; + user_id: string | null; + provider: string; + key_encrypted: Buffer; + key_nonce: Buffer; + key_last4: string; + created_by_user_id: string | null; + created_at: string; + updated_at: string; +} + +function rowToSummary(row: LlmKeyRow): LlmKeySummary { + if (!isSupportedProvider(row.provider)) { + throw new Error(`Unsupported provider in llm_keys row: ${row.provider}`); + } + return { + provider: row.provider, + last4: row.key_last4, + createdAt: row.created_at, + updatedAt: row.updated_at, + createdByUserId: row.created_by_user_id, + }; +} + +function whereClauseForScope(scope: KeyScope): { sql: string; params: unknown[] } { + if (scope.teamId && !scope.userId) { + return { sql: "team_id = ? AND user_id IS NULL", params: [scope.teamId] }; + } + if (scope.userId && !scope.teamId) { + return { sql: "user_id = ? AND team_id IS NULL", params: [scope.userId] }; + } + throw new Error("KeyScope must have exactly one of teamId or userId"); +} + +/** + * Returns all keys for the given scope (non-secret summaries). + */ +export function listLlmKeys(db: Database.Database, scope: KeyScope): LlmKeySummary[] { + const where = whereClauseForScope(scope); + const rows = db + .prepare( + `SELECT * FROM llm_keys WHERE ${where.sql} ORDER BY provider ASC`, + ) + .all(...where.params) as LlmKeyRow[]; + return rows.map(rowToSummary); +} + +/** + * Returns the single key for (scope, provider), or null if not set. + * Non-secret summary only. + */ +export function getLlmKeySummary( + db: Database.Database, + scope: KeyScope, + provider: LlmProvider, +): LlmKeySummary | null { + const where = whereClauseForScope(scope); + const row = db + .prepare(`SELECT * FROM llm_keys WHERE ${where.sql} AND provider = ?`) + .get(...where.params, provider) as LlmKeyRow | undefined; + return row ? rowToSummary(row) : null; +} + +/** + * Returns the decrypted key plaintext for (scope, provider), or null if not set. + * Use only when actually about to call the LLM provider — never log the result, + * never return it to clients. + */ +export function getLlmKeyPlaintext( + db: Database.Database, + scope: KeyScope, + provider: LlmProvider, +): string | null { + const where = whereClauseForScope(scope); + const row = db + .prepare(`SELECT * FROM llm_keys WHERE ${where.sql} AND provider = ?`) + .get(...where.params, provider) as LlmKeyRow | undefined; + if (!row) return null; + return decryptLlmKey( + { ciphertext: row.key_encrypted, nonce: row.key_nonce }, + scope, + ); +} + +export interface SetLlmKeyOptions { + scope: KeyScope; + provider: LlmProvider; + plaintext: string; + /** Authenticated user setting the key (admin). */ + createdByUserId: string; + /** For audit events; passed straight through. */ + requestId?: string | null; +} + +/** + * Upserts a key for (scope, provider). If a row exists, it's replaced + * (rotate); if not, it's created. Emits the appropriate audit event. + * + * Returns the new summary. + */ +export function setLlmKey( + db: Database.Database, + options: SetLlmKeyOptions, +): LlmKeySummary { + const trimmed = options.plaintext.trim(); + if (trimmed.length === 0) { + throw new Error("Cannot set empty LLM key"); + } + + const encrypted = encryptLlmKey(trimmed, options.scope); + const now = new Date().toISOString(); + const where = whereClauseForScope(options.scope); + + const existing = db + .prepare(`SELECT id FROM llm_keys WHERE ${where.sql} AND provider = ?`) + .get(...where.params, options.provider) as { id: string } | undefined; + + if (existing) { + db.prepare( + `UPDATE llm_keys + SET key_encrypted = ?, key_nonce = ?, key_last4 = ?, + created_by_user_id = ?, updated_at = ? + WHERE id = ?`, + ).run( + encrypted.ciphertext, + encrypted.nonce, + encrypted.last4, + options.createdByUserId, + now, + existing.id, + ); + recordAuditEvent(db, { + userId: options.createdByUserId, + eventType: "llm_key.rotated", + requestId: options.requestId ?? null, + metadata: { + provider: options.provider, + scope_team_id: options.scope.teamId ?? null, + scope_user_id: options.scope.userId ?? null, + last4: encrypted.last4, + }, + }); + } else { + db.prepare( + `INSERT INTO llm_keys ( + id, team_id, user_id, provider, key_encrypted, key_nonce, key_last4, + created_by_user_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + randomUUID(), + options.scope.teamId ?? null, + options.scope.userId ?? null, + options.provider, + encrypted.ciphertext, + encrypted.nonce, + encrypted.last4, + options.createdByUserId, + now, + now, + ); + recordAuditEvent(db, { + userId: options.createdByUserId, + eventType: "llm_key.created", + requestId: options.requestId ?? null, + metadata: { + provider: options.provider, + scope_team_id: options.scope.teamId ?? null, + scope_user_id: options.scope.userId ?? null, + last4: encrypted.last4, + }, + }); + } + + const summary = getLlmKeySummary(db, options.scope, options.provider); + if (!summary) { + throw new Error("setLlmKey post-condition: key not found after upsert"); + } + return summary; +} + +export interface DeleteLlmKeyOptions { + scope: KeyScope; + provider: LlmProvider; + actorUserId: string; + requestId?: string | null; +} + +/** + * Deletes the key for (scope, provider). Returns true if a row was deleted, + * false if no key was set. Emits a `llm_key.removed` audit event when a row + * is actually deleted. + */ +export function deleteLlmKey( + db: Database.Database, + options: DeleteLlmKeyOptions, +): boolean { + const where = whereClauseForScope(options.scope); + const existing = db + .prepare(`SELECT id, key_last4 FROM llm_keys WHERE ${where.sql} AND provider = ?`) + .get(...where.params, options.provider) as + | { id: string; key_last4: string } + | undefined; + if (!existing) return false; + + db.prepare(`DELETE FROM llm_keys WHERE id = ?`).run(existing.id); + recordAuditEvent(db, { + userId: options.actorUserId, + eventType: "llm_key.removed", + requestId: options.requestId ?? null, + metadata: { + provider: options.provider, + scope_team_id: options.scope.teamId ?? null, + scope_user_id: options.scope.userId ?? null, + last4: existing.key_last4, + }, + }); + return true; +} diff --git a/src/server.ts b/src/server.ts index 5176445..be64afb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -28,6 +28,16 @@ import type { EventBroker, EventClient, MemoryEvent, MemoryEventType } from "./e import { createSsoVerifier, validateSsoConfig } from "./sso-auth.js"; import { recordAuditEvent, queryAuditEvents, countAuditEvents, exportAuditEvents } from "./audit-service.js"; import { buildLogExport, logExportFilename } from "./log-export-service.js"; +import { + deleteLlmKey, + getLlmKeySummary, + isSupportedProvider, + listLlmKeys, + setLlmKey, + SUPPORTED_PROVIDERS, + type LlmProvider, +} from "./llm-key-service.js"; +import { tryValidateMasterKey } from "./llm-key-crypto.js"; import { generateApiKey, listApiKeys, @@ -2010,6 +2020,138 @@ export async function createServer(config: ServerConfig): Promise the team's key. + // - Else (solo) -> the admin's user-level key. + // + // The plaintext key is never returned by these endpoints; only `last4`. + // + // GET /admin/llm-keys -> list summaries for the resolved scope + // PUT /admin/llm-keys -> set/rotate; body { provider, key } + // DELETE /admin/llm-keys/:provider -> remove + // =========================================================================== + + function resolveLlmKeyScope(uid: string): { teamId: string | null; userId: string | null } { + const teamId = getUserTeamId(uid); + return teamId + ? { teamId, userId: null } + : { teamId: null, userId: uid }; + } + + server.get( + "/admin/llm-keys", + { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, + async (request, reply) => { + if (!ownerUserIds.has(request.userId)) { + reply.code(403); + return { error: "Admin access required" }; + } + const scope = resolveLlmKeyScope(request.userId); + const keys = listLlmKeys(db, scope); + return { + scope: { team_id: scope.teamId, user_id: scope.userId }, + supported_providers: SUPPORTED_PROVIDERS, + keys: keys.map((k) => ({ + provider: k.provider, + last4: k.last4, + created_at: k.createdAt, + updated_at: k.updatedAt, + created_by_user_id: k.createdByUserId, + })), + }; + }, + ); + + server.put( + "/admin/llm-keys", + { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, + async (request, reply) => { + if (!ownerUserIds.has(request.userId)) { + reply.code(403); + return { error: "Admin access required" }; + } + const body = request.body as { provider?: unknown; key?: unknown }; + const providerRaw = typeof body?.provider === "string" ? body.provider.trim() : ""; + const keyRaw = typeof body?.key === "string" ? body.key.trim() : ""; + if (!providerRaw || !keyRaw) { + reply.code(400); + return { error: "provider and key are required" }; + } + if (!isSupportedProvider(providerRaw)) { + reply.code(400); + return { + error: `Unsupported provider "${providerRaw}". Supported: ${SUPPORTED_PROVIDERS.join(", ")}`, + }; + } + if (!tryValidateMasterKey()) { + reply.code(503); + return { + error: + "LLM key encryption is not configured on this instance. Set RM_LLM_KEY_ENCRYPTION_KEY (64 hex chars) and restart.", + }; + } + const scope = resolveLlmKeyScope(request.userId); + try { + const summary = setLlmKey(db, { + scope, + provider: providerRaw as LlmProvider, + plaintext: keyRaw, + createdByUserId: request.userId, + requestId: request.id, + }); + return { + provider: summary.provider, + last4: summary.last4, + created_at: summary.createdAt, + updated_at: summary.updatedAt, + }; + } catch (err) { + request.log.error({ err }, "Failed to set LLM key"); + reply.code(500); + return { error: "Failed to store LLM key" }; + } + }, + ); + + server.delete( + "/admin/llm-keys/:provider", + { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, + async (request, reply) => { + if (!ownerUserIds.has(request.userId)) { + reply.code(403); + return { error: "Admin access required" }; + } + const { provider } = request.params as { provider: string }; + if (!isSupportedProvider(provider)) { + reply.code(400); + return { + error: `Unsupported provider "${provider}". Supported: ${SUPPORTED_PROVIDERS.join(", ")}`, + }; + } + const scope = resolveLlmKeyScope(request.userId); + const summary = getLlmKeySummary(db, scope, provider as LlmProvider); + if (!summary) { + reply.code(404); + return { error: "Key not found" }; + } + deleteLlmKey(db, { + scope, + provider: provider as LlmProvider, + actorUserId: request.userId, + requestId: request.id, + }); + return { deleted: true, provider, last4: summary.last4 }; + }, + ); + // =========================================================================== // POST /memories -- Create a memory (user path) // =========================================================================== diff --git a/tests/global-setup.ts b/tests/global-setup.ts index 6e16ea6..9f2da88 100644 --- a/tests/global-setup.ts +++ b/tests/global-setup.ts @@ -73,6 +73,10 @@ export async function setup(): Promise { // Always-on in tests so log-export integration tests work; the // "disabled returns 404" path is covered by unit/source assertions. RM_LOG_SHARING_ENABLED: "true", + // 32-byte hex master key for AES-256-GCM LLM key encryption. + // Random per test-server boot so a leaked test fixture can't decrypt + // anything in another env. + RM_LLM_KEY_ENCRYPTION_KEY: randomHex(32), RM_DASHBOARD_URL: `http://127.0.0.1:${port + 1}`, STRIPE_SECRET_KEY: "sk_test_fake", STRIPE_WEBHOOK_SECRET: "whsec_test_fake", diff --git a/tests/integration/llm-keys.test.ts b/tests/integration/llm-keys.test.ts new file mode 100644 index 0000000..61ca518 --- /dev/null +++ b/tests/integration/llm-keys.test.ts @@ -0,0 +1,369 @@ +// /admin/llm-keys endpoint coverage: +// - Encryption round-trip (set then re-list shows last4) +// - Set requires admin (non-owner gets 403) +// - Unsupported provider rejected +// - Empty/missing body rejected +// - Rotate (PUT same provider twice) replaces, last4 updated +// - Delete removes; second delete is 404 +// - Audit events recorded for create/rotate/remove +// +// Plus a unit test of the pure encryption module to prove HKDF/scope +// mismatch correctly fails (defense against silent key reuse bugs). + +import { afterAll, describe, expect, it } from "vitest"; +import Database from "better-sqlite3"; +import { randomBytes } from "node:crypto"; +import { api, getTestServer, withAgentKey } from "../helpers"; +import { + _resetMasterKeyCacheForTests, + decryptLlmKey, + encryptLlmKey, + extractLast4, +} from "../../src/llm-key-crypto"; + +interface KeyListResponse { + scope: { team_id: string | null; user_id: string | null }; + supported_providers: string[]; + keys: Array<{ + provider: string; + last4: string; + created_at: string; + updated_at: string; + created_by_user_id: string | null; + }>; +} + +interface KeySetResponse { + provider: string; + last4: string; + created_at: string; + updated_at: string; +} + +interface KeyDeleteResponse { + deleted: boolean; + provider: string; + last4: string; +} + +// --------------------------------------------------------------------------- +// HTTP integration tests against the live test server +// --------------------------------------------------------------------------- + +async function deleteIfPresent(provider: string): Promise { + await api(`DELETE`, `/admin/llm-keys/${provider}`); +} + +describe("GET /admin/llm-keys (admin)", () => { + it("returns the supported providers + (initially) an empty list", async () => { + await deleteIfPresent("anthropic"); + const r = await api("GET", "/admin/llm-keys"); + expect(r.status).toBe(200); + expect(r.json.supported_providers).toContain("anthropic"); + expect(Array.isArray(r.json.keys)).toBe(true); + expect(r.json.keys.find((k) => k.provider === "anthropic")).toBeUndefined(); + }); + + it("403 for non-admin (agent key)", async () => { + const r = await api<{ error: string }>("GET", "/admin/llm-keys", { + token: withAgentKey("cursor"), + }); + expect(r.status).toBe(403); + }); +}); + +describe("PUT /admin/llm-keys (set + rotate)", () => { + it("400 when body is missing provider/key", async () => { + const r1 = await api<{ error: string }>("PUT", "/admin/llm-keys", { body: {} }); + expect(r1.status).toBe(400); + + const r2 = await api<{ error: string }>("PUT", "/admin/llm-keys", { + body: { provider: "anthropic" }, + }); + expect(r2.status).toBe(400); + + const r3 = await api<{ error: string }>("PUT", "/admin/llm-keys", { + body: { provider: "anthropic", key: "" }, + }); + expect(r3.status).toBe(400); + }); + + it("400 for unsupported provider", async () => { + const r = await api<{ error: string }>("PUT", "/admin/llm-keys", { + body: { provider: "openai", key: "sk-test-1234" }, + }); + expect(r.status).toBe(400); + expect(r.json.error).toMatch(/Unsupported provider/i); + }); + + it("403 for non-admin (agent key)", async () => { + const r = await api<{ error: string }>("PUT", "/admin/llm-keys", { + body: { provider: "anthropic", key: "sk-ant-test-1234" }, + token: withAgentKey("cursor"), + }); + expect(r.status).toBe(403); + }); + + it("creates a key on first set, last4 derived from input", async () => { + await deleteIfPresent("anthropic"); + const fakeKey = "sk-ant-api03-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-Z9zZ"; + const r = await api("PUT", "/admin/llm-keys", { + body: { provider: "anthropic", key: fakeKey }, + }); + expect(r.status).toBe(200); + expect(r.json.provider).toBe("anthropic"); + expect(r.json.last4).toBe("Z9zZ"); + expect(typeof r.json.created_at).toBe("string"); + expect(typeof r.json.updated_at).toBe("string"); + + const list = await api("GET", "/admin/llm-keys"); + const stored = list.json.keys.find((k) => k.provider === "anthropic"); + expect(stored).toBeDefined(); + expect(stored?.last4).toBe("Z9zZ"); + }); + + it("rotating a key replaces it, updated_at advances, last4 reflects new input", async () => { + const firstKey = "sk-ant-api03-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-1111"; + const secondKey = "sk-ant-api03-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb-2222"; + + await deleteIfPresent("anthropic"); + await api("PUT", "/admin/llm-keys", { + body: { provider: "anthropic", key: firstKey }, + }); + const before = await api("GET", "/admin/llm-keys"); + const beforeStored = before.json.keys.find((k) => k.provider === "anthropic"); + expect(beforeStored?.last4).toBe("1111"); + + // Wait a tick so updated_at can move (ISO timestamps tick at 1ms). + await new Promise((r) => setTimeout(r, 10)); + + const r = await api("PUT", "/admin/llm-keys", { + body: { provider: "anthropic", key: secondKey }, + }); + expect(r.status).toBe(200); + expect(r.json.last4).toBe("2222"); + + const after = await api("GET", "/admin/llm-keys"); + const afterStored = after.json.keys.find((k) => k.provider === "anthropic"); + expect(afterStored?.last4).toBe("2222"); + expect(afterStored?.created_at).toBe(beforeStored?.created_at); + expect(afterStored?.updated_at).not.toBe(beforeStored?.updated_at); + }); +}); + +describe("DELETE /admin/llm-keys/:provider", () => { + it("400 for unsupported provider", async () => { + const r = await api<{ error: string }>("DELETE", "/admin/llm-keys/openai"); + expect(r.status).toBe(400); + }); + + it("403 for non-admin (agent key)", async () => { + const r = await api<{ error: string }>("DELETE", "/admin/llm-keys/anthropic", { + token: withAgentKey("cursor"), + }); + expect(r.status).toBe(403); + }); + + it("removes the key and reports last4; second delete is 404", async () => { + await api("PUT", "/admin/llm-keys", { + body: { provider: "anthropic", key: "sk-ant-api03-deleteme-9999" }, + }); + + const first = await api("DELETE", "/admin/llm-keys/anthropic"); + expect(first.status).toBe(200); + expect(first.json.deleted).toBe(true); + expect(first.json.last4).toBe("9999"); + + const second = await api<{ error: string }>("DELETE", "/admin/llm-keys/anthropic"); + expect(second.status).toBe(404); + }); +}); + +describe("audit_events records llm_key.* events", () => { + it("create + rotate + remove all show up in audit_events", async () => { + await deleteIfPresent("anthropic"); + + // Snapshot current event_type counts so we measure deltas, not totals + // (the test server is shared across files). + const { dbPath } = getTestServer(); + const db = new Database(dbPath, { readonly: true }); + afterAll(() => { + try { + db.close(); + } catch { + /* best-effort cleanup */ + } + }); + + function countOf(eventType: string): number { + const row = db + .prepare( + `SELECT count(*) as n FROM audit_events WHERE event_type = ?`, + ) + .get(eventType) as { n: number }; + return row.n; + } + + const beforeCreated = countOf("llm_key.created"); + const beforeRotated = countOf("llm_key.rotated"); + const beforeRemoved = countOf("llm_key.removed"); + + await api("PUT", "/admin/llm-keys", { + body: { provider: "anthropic", key: "sk-ant-api03-audit-aaaa" }, + }); + await api("PUT", "/admin/llm-keys", { + body: { provider: "anthropic", key: "sk-ant-api03-audit-bbbb" }, + }); + await api("DELETE", "/admin/llm-keys/anthropic"); + + expect(countOf("llm_key.created")).toBe(beforeCreated + 1); + expect(countOf("llm_key.rotated")).toBe(beforeRotated + 1); + expect(countOf("llm_key.removed")).toBe(beforeRemoved + 1); + + // Confirm no audit metadata leaks the key plaintext. + const recent = db + .prepare( + `SELECT metadata FROM audit_events + WHERE event_type LIKE 'llm_key.%' ORDER BY created_at DESC LIMIT 5`, + ) + .all() as { metadata: string | null }[]; + for (const row of recent) { + if (!row.metadata) continue; + expect(row.metadata).not.toMatch(/sk-ant-api03-audit/); + // Each row should still carry the last4 for forensic value. + expect(row.metadata).toMatch(/last4/); + } + }); +}); + +// --------------------------------------------------------------------------- +// Pure encryption module unit tests +// --------------------------------------------------------------------------- + +describe("llm-key-crypto module (unit)", () => { + // Pin a deterministic master key for these tests so we don't rely on whatever + // is in process.env. Reset cache before/after. + const fixedMasterKey = randomBytes(32).toString("hex"); + let priorEnv: string | undefined; + + function pinKey() { + priorEnv = process.env.RM_LLM_KEY_ENCRYPTION_KEY; + process.env.RM_LLM_KEY_ENCRYPTION_KEY = fixedMasterKey; + _resetMasterKeyCacheForTests(); + } + + function restoreKey() { + if (priorEnv === undefined) delete process.env.RM_LLM_KEY_ENCRYPTION_KEY; + else process.env.RM_LLM_KEY_ENCRYPTION_KEY = priorEnv; + _resetMasterKeyCacheForTests(); + } + + it("encrypt then decrypt returns the original plaintext", () => { + pinKey(); + try { + const scope = { teamId: "team-abc", userId: null }; + const enc = encryptLlmKey("sk-ant-api03-secret-key-zzzz", scope); + expect(enc.last4).toBe("zzzz"); + expect(enc.nonce.length).toBe(12); + expect(enc.ciphertext.length).toBeGreaterThan(16); // payload + 16-byte tag + const dec = decryptLlmKey(enc, scope); + expect(dec).toBe("sk-ant-api03-secret-key-zzzz"); + } finally { + restoreKey(); + } + }); + + it("decrypt with the wrong scope throws (HKDF derives different sub-key)", () => { + pinKey(); + try { + const enc = encryptLlmKey("plaintext-1", { teamId: "team-A", userId: null }); + expect(() => + decryptLlmKey(enc, { teamId: "team-B", userId: null }), + ).toThrow(); + expect(() => decryptLlmKey(enc, { teamId: null, userId: "user-X" })).toThrow(); + } finally { + restoreKey(); + } + }); + + it("decrypt with the right scope but tampered ciphertext throws (GCM auth)", () => { + pinKey(); + try { + const scope = { teamId: "team-1", userId: null }; + const enc = encryptLlmKey("plaintext-2", scope); + // Flip a bit in the ciphertext. + const tampered = Buffer.from(enc.ciphertext); + tampered[0] = tampered[0] ^ 0x01; + expect(() => + decryptLlmKey({ ciphertext: tampered, nonce: enc.nonce }, scope), + ).toThrow(); + } finally { + restoreKey(); + } + }); + + it("rejects KeyScope with both teamId and userId", () => { + pinKey(); + try { + expect(() => + encryptLlmKey("foo", { teamId: "t", userId: "u" }), + ).toThrow(/exactly one/i); + } finally { + restoreKey(); + } + }); + + it("rejects KeyScope with neither teamId nor userId", () => { + pinKey(); + try { + expect(() => encryptLlmKey("foo", {})).toThrow(/teamId|userId/i); + } finally { + restoreKey(); + } + }); + + it("rejects empty plaintext", () => { + pinKey(); + try { + expect(() => + encryptLlmKey(" ", { teamId: "t", userId: null }), + ).toThrow(/empty/i); + } finally { + restoreKey(); + } + }); + + it("rejects malformed master key (not 64 hex chars)", () => { + priorEnv = process.env.RM_LLM_KEY_ENCRYPTION_KEY; + process.env.RM_LLM_KEY_ENCRYPTION_KEY = "deadbeef"; // too short + _resetMasterKeyCacheForTests(); + try { + expect(() => + encryptLlmKey("foo", { teamId: "t", userId: null }), + ).toThrow(/64 hex/i); + } finally { + restoreKey(); + } + }); + + it("rejects missing master key", () => { + priorEnv = process.env.RM_LLM_KEY_ENCRYPTION_KEY; + delete process.env.RM_LLM_KEY_ENCRYPTION_KEY; + _resetMasterKeyCacheForTests(); + try { + expect(() => + encryptLlmKey("foo", { teamId: "t", userId: null }), + ).toThrow(/RM_LLM_KEY_ENCRYPTION_KEY/); + } finally { + restoreKey(); + } + }); + + it("extractLast4 returns the last 4 chars of trimmed input, empty string when blank", () => { + expect(extractLast4("sk-anything-AbCd")).toBe("AbCd"); + expect(extractLast4(" sk-spaces-EfGh ")).toBe("EfGh"); + expect(extractLast4("")).toBe(""); + expect(extractLast4(" ")).toBe(""); + expect(extractLast4("xy")).toBe("xy"); // shorter than 4 returns full + }); +});