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
151 changes: 108 additions & 43 deletions src/llm-key-crypto.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
/**
* LLM provider key encryption.
* Symmetric encryption for sensitive operational secrets — LLM provider keys,
* Slack bot tokens, and any future per-tenant credential we need to round-trip
* through SQLite.
*
* 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."
* Risk model: secrets are valuable but their abuse is bounded (financial loss
* for the customer's LLM provider account; access to a single Slack workspace
* for the bot token). 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.
* Single env var covers all uses; sub-keys are namespaced by salt.
* - Per-tenant sub-key derived via HKDF-SHA256(masterKey, salt=saltString,
* info) so a leaked encrypted blob alone is useless without knowing the
* tenant identifier; 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.
* - Storage: `ciphertext` BLOB = encrypted payload || authTag (16-byte tag
* at the end); `nonce` BLOB = the 12-byte nonce.
*
* 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).
* the API can boot without it (only fails when a feature that needs it is
* exercised). Domain wrappers like the LLM key service and the Slack
* workspace service build their own salt strings, e.g. `team:<uuid>` or
* `slack:<slack_team_id>`, and call encryptString / decryptString.
*/

import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
Expand Down Expand Up @@ -87,39 +92,25 @@ export function _resetMasterKeyCacheForTests(): void {
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");
function deriveSubKey(saltString: string): Buffer {
if (!saltString || saltString.trim().length === 0) {
throw new Error("Salt string is required for key derivation");
}
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 salt = Buffer.from(saltString, "utf8");
const derived = hkdfSync("sha256", master, salt, HKDF_INFO, SUBKEY_BYTES);
return Buffer.from(derived);
}

export interface EncryptedKey {
export interface EncryptedBlob {
/** 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
* Extracts the last 4 characters of a string for UI display. Strips trailing
* whitespace defensively.
*/
export function extractLast4(plaintext: string): string {
Expand All @@ -129,14 +120,15 @@ export function extractLast4(plaintext: string): string {
}

/**
* Encrypts a plaintext LLM key for the given scope. Returns the ciphertext+tag,
* nonce, and last4 ready to persist.
* Generic primitive: encrypts a plaintext string with a sub-key derived from
* the given salt. Domain wrappers (LLM keys, Slack tokens) build their own
* salt strings, e.g. `team:<uuid>` or `slack:<slack_team_id>`.
*/
export function encryptLlmKey(plaintext: string, scope: KeyScope): EncryptedKey {
export function encryptString(plaintext: string, saltString: string): EncryptedBlob {
if (!plaintext || plaintext.trim().length === 0) {
throw new Error("Cannot encrypt empty LLM key");
throw new Error("Cannot encrypt empty string");
}
const subKey = deriveSubKey(scope);
const subKey = deriveSubKey(saltString);
const nonce = randomBytes(NONCE_BYTES);
const cipher = createCipheriv(ALGORITHM, subKey, nonce);
const ciphertext = Buffer.concat([
Expand All @@ -150,18 +142,17 @@ export function encryptLlmKey(plaintext: string, scope: KeyScope): EncryptedKey
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).
* Generic primitive: decrypts a string previously encrypted with encryptString
* using the same salt. Throws if the salt doesn't match (HKDF derives a
* different sub-key) or if the ciphertext was tampered with (GCM auth tag).
*/
export function decryptLlmKey(
export function decryptString(
encrypted: { ciphertext: Buffer; nonce: Buffer },
scope: KeyScope,
saltString: string,
): string {
if (encrypted.nonce.length !== NONCE_BYTES) {
throw new Error(`Invalid nonce length: ${encrypted.nonce.length} (expected ${NONCE_BYTES})`);
Expand All @@ -171,10 +162,84 @@ export function decryptLlmKey(
`Ciphertext too short to contain auth tag: ${encrypted.ciphertext.length} bytes`,
);
}
const subKey = deriveSubKey(scope);
const subKey = deriveSubKey(saltString);
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");
}

// ---------------------------------------------------------------------------
// LLM key domain wrappers
// ---------------------------------------------------------------------------

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 llmKeySalt(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");
}

export interface EncryptedKey extends EncryptedBlob {
/** Cleartext, for UI display only. Never includes the full key. */
last4: string;
}

/**
* 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 {
const blob = encryptString(plaintext, llmKeySalt(scope));
return { ...blob, 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 {
return decryptString(encrypted, llmKeySalt(scope));
}

// ---------------------------------------------------------------------------
// Slack bot token domain wrappers
// ---------------------------------------------------------------------------

/**
* Encrypts a Slack bot token (xoxb-...) for the given Slack team. The Slack
* team ID is used as the HKDF salt so each workspace's bot token is encrypted
* under a different sub-key.
*/
export function encryptSlackBotToken(plaintext: string, slackTeamId: string): EncryptedBlob {
if (!slackTeamId || slackTeamId.trim().length === 0) {
throw new Error("slackTeamId is required for Slack bot token encryption");
}
return encryptString(plaintext, `slack:${slackTeamId}`);
}

/**
* Decrypts a Slack bot token previously encrypted with encryptSlackBotToken.
*/
export function decryptSlackBotToken(
encrypted: { ciphertext: Buffer; nonce: Buffer },
slackTeamId: string,
): string {
if (!slackTeamId || slackTeamId.trim().length === 0) {
throw new Error("slackTeamId is required for Slack bot token decryption");
}
return decryptString(encrypted, `slack:${slackTeamId}`);
}
Loading
Loading