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
80 changes: 80 additions & 0 deletions schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
-- =============================================================================
Expand Down
93 changes: 93 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
180 changes: 180 additions & 0 deletions src/llm-key-crypto.ts
Original file line number Diff line number Diff line change
@@ -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");
}
Loading
Loading