/** * CES-native SecureKeyBackend for **local mode only**. * * In local mode, CES runs as a child process of the assistant on the same * machine as the same user and can read/write the assistant's encrypted key * store file at `/protected/keys.enc`. * * This implementation replicates the encryption/decryption logic from the * assistant's `encrypted-store.ts` without importing assistant-internal * modules. Writes are needed for OAuth token refresh — when CES refreshes * an expired access token, the new token must be persisted back to the * encrypted store so subsequent reads (by both CES and the assistant) * see the updated value. * * Two store formats are supported: * * - **v2 (primary):** AES-256-GCM with a random 32-byte key stored at * `/protected/store.key`. The key is machine-independent — * any process that can read the key file can decrypt the store. * * - **v1 (legacy):** AES-256-GCM with a key derived from machine-specific * entropy via PBKDF2. The derivation includes `userInfo().username` and * `userInfo().homedir`, so the key is only correct when CES runs as the * same OS user as the assistant. * * **Managed-mode restriction (v1 only):** For legacy v1 stores, the * different container user identity produces a different PBKDF2-derived * key, causing silent decryption failures. v2 stores use a * UID-independent `store.key` file that can be shared via volume mount, * removing this technical barrier. Managed deployments currently use * `platform_oauth` handles exclusively as a policy choice (simpler * lifecycle, centralized token management). */ import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes, } from "node:crypto"; import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, } from "node:fs"; import { hostname, userInfo } from "node:os"; import { dirname, join } from "node:path"; import type { SecureKeyBackend, SecureKeyDeleteResult, } from "@vellumai/credential-storage"; // --------------------------------------------------------------------------- // Constants (must match assistant/src/security/encrypted-store.ts) // --------------------------------------------------------------------------- const ALGORITHM = "aes-256-gcm"; const KEY_LENGTH = 32; // bytes (256 bits) const IV_LENGTH = 16; // bytes (128 bits) const AUTH_TAG_LENGTH = 16; // bytes const PBKDF2_ITERATIONS = process.env.BUN_TEST === "1" ? 1 : 100_000; // --------------------------------------------------------------------------- // On-disk format (must match assistant/src/security/encrypted-store.ts) // --------------------------------------------------------------------------- interface EncryptedEntry { iv: string; tag: string; data: string; } interface StoreFileV1 { version: 1; salt: string; entries: Record; } interface StoreFileV2 { version: 2; entries: Record; } type StoreFile = StoreFileV1 | StoreFileV2; // --------------------------------------------------------------------------- // Store key file (v2 format) // --------------------------------------------------------------------------- const STORE_KEY_FILENAME = "store.key"; const KEYS_ENC_FILENAME = "keys.enc"; // --------------------------------------------------------------------------- // Security directory resolution // --------------------------------------------------------------------------- /** * Resolve the directory containing `keys.enc` and `store.key`. * * When `CREDENTIAL_SECURITY_DIR` is set, files are read from (and written to) * that directory directly. This allows Docker deployments to mount a separate * CES-only security volume. * * When the env var is unset, falls back to `/protected/` for * backwards compatibility. */ function resolveSecurityDir(vellumRoot: string): string { return process.env.CREDENTIAL_SECURITY_DIR || join(vellumRoot, "protected"); } /** * Read the v2 store key file from the security directory. * Returns the raw 32-byte key buffer, or null if the file is missing, * wrong size, or unreadable. */ function readStoreKey(vellumRoot: string): Buffer | null { try { const keyPath = join(resolveSecurityDir(vellumRoot), STORE_KEY_FILENAME); if (!existsSync(keyPath)) return null; const buf = readFileSync(keyPath); if (buf.length !== KEY_LENGTH) return null; return buf; } catch { return null; } } // --------------------------------------------------------------------------- // Auto-initialization helpers // --------------------------------------------------------------------------- /** * Read or generate the v2 store key. If the key file does not exist, * creates a new 32-byte random key and writes it atomically. */ function getOrReadStoreKey(vellumRoot: string): Buffer { const existing = readStoreKey(vellumRoot); if (existing) return existing; const securityDir = resolveSecurityDir(vellumRoot); mkdirSync(securityDir, { recursive: true }); const key = randomBytes(KEY_LENGTH); const keyPath = join(securityDir, STORE_KEY_FILENAME); const tmpPath = keyPath + `.tmp.${process.pid}`; writeFileSync(tmpPath, key, { mode: 0o600 }); chmodSync(tmpPath, 0o600); renameSync(tmpPath, keyPath); return key; } /** * Read an existing store or create a new empty v2 store. Returns the store * and the AES key needed to encrypt/decrypt entries. * * For existing v1 stores, throws so the caller can fall back to the legacy * PBKDF2 path (v1 stores cannot be auto-initialized with a store key). */ function getOrCreateStore( storePath: string, vellumRoot: string, ): { store: StoreFile; aesKey: Buffer } { const existing = readStore(storePath); if (existing) { if (existing.version === 1) { throw new Error("v1 store cannot be auto-initialized"); } const storeKey = readStoreKey(vellumRoot); if (!storeKey) { throw new Error("v2 store exists but store.key is missing or corrupt"); } return { store: existing, aesKey: storeKey }; } // No store exists — create a new empty v2 store const aesKey = getOrReadStoreKey(vellumRoot); const store: StoreFileV2 = { version: 2, entries: {} }; writeStore(store, storePath); return { store, aesKey }; } // --------------------------------------------------------------------------- // Machine entropy (must match assistant/src/security/encrypted-store.ts) // --------------------------------------------------------------------------- function getMachineEntropy(): string { const parts: string[] = []; try { parts.push(hostname()); } catch { parts.push("unknown-host"); } try { parts.push(userInfo().username); } catch { parts.push("unknown-user"); } parts.push(process.platform); parts.push(process.arch); try { parts.push(userInfo().homedir); } catch { parts.push("/tmp"); } return parts.join(":"); } function deriveKey(salt: Buffer, entropyOverride?: string): Buffer { const entropy = entropyOverride ?? getMachineEntropy(); return pbkdf2Sync(entropy, salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha512"); } // --------------------------------------------------------------------------- // Decrypt // --------------------------------------------------------------------------- function decrypt(entry: EncryptedEntry, key: Buffer): string { const iv = Buffer.from(entry.iv, "hex"); const tag = Buffer.from(entry.tag, "hex"); const data = Buffer.from(entry.data, "hex"); const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH, }); decipher.setAuthTag(tag); const decrypted = Buffer.concat([decipher.update(data), decipher.final()]); return decrypted.toString("utf-8"); } function encrypt(plaintext: string, key: Buffer): EncryptedEntry { const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH, }); const encrypted = Buffer.concat([ cipher.update(plaintext, "utf-8"), cipher.final(), ]); const tag = cipher.getAuthTag(); return { iv: iv.toString("hex"), tag: tag.toString("hex"), data: encrypted.toString("hex"), }; } // --------------------------------------------------------------------------- // Store writer // --------------------------------------------------------------------------- function writeStore(store: StoreFile, storePath: string): void { const protectedDir = dirname(storePath); mkdirSync(protectedDir, { recursive: true }); // Atomic write: write to temp file then rename to avoid partial/corrupt writes. // `flush: true` fsyncs the temp file before we return, so the ack the caller // receives follows a durable write and survives a crash within the window. const tmpPath = storePath + `.tmp.${process.pid}`; writeFileSync(tmpPath, JSON.stringify(store, null, 2), { mode: 0o600, flush: true, }); chmodSync(tmpPath, 0o600); renameSync(tmpPath, storePath); // Fsync the parent directory so the rename itself is durable. `{flush:true}` // syncs the temp file's contents, not the directory entry the rename updates, // so without this a host-level crash could still expose the previous keys.enc. // Best-effort: a directory-fsync failure must not fail the write. try { const dirFd = openSync(protectedDir, "r"); try { fsyncSync(dirFd); } finally { closeSync(dirFd); } } catch { // Directory fsync is a durability nicety, not required for correctness. } } // --------------------------------------------------------------------------- // Store reader // --------------------------------------------------------------------------- function readStore(storePath: string): StoreFile | null { try { const raw = readFileSync(storePath, "utf-8"); const parsed = JSON.parse(raw); if (typeof parsed.entries !== "object") return null; if (parsed.version === 1 && typeof parsed.salt === "string") { return parsed as StoreFileV1; } if (parsed.version === 2) { return parsed as StoreFileV2; } return null; } catch { return null; } } // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- /** * Thrown by `get()` / `list()` when the credential store is UNAVAILABLE — the * store file exists but cannot be read, the v2 `store.key` (or v1 machine * entropy) key material is missing, or an entry fails to decrypt. This is * deliberately DISTINCT from a credential being ABSENT (no store file yet, or * the store reads cleanly but lacks the requested key), which returns * `undefined` / `[]`. * * The distinction matters on CES cold start: for a brief window after a * (re)start the key material can be transiently unreadable (see the * `entropyGetter` note on {@link createLocalSecureKeyBackend} — "the file may * not exist at construction time but appears later"). Returning `undefined` * there reports a credential that EXISTS as "not found"; throwing instead * surfaces the true state to the RPC layer (a `HANDLER_ERROR` the daemon maps * to `unreachable`), so a transient read failure is never mistaken for a * missing credential. */ export class StoreUnavailableError extends Error { constructor(message: string) { super(message); this.name = "StoreUnavailableError"; } } // --------------------------------------------------------------------------- // Backend implementation // --------------------------------------------------------------------------- /** * Create a SecureKeyBackend backed by the assistant's encrypted key store. * * Supports `get`, `set`, and `delete` operations. `set` is needed for * persisting refreshed OAuth tokens. `delete` removes a key from the * encrypted store. * * @param vellumRoot - The Vellum root directory (e.g. `~/.vellum`). * @param options.entropyOverride - If provided, used instead of local * machine entropy for key derivation. In managed mode the CES sidecar * runs in a different container with a different hostname/user, so it * must use the assistant's entropy (read from the shared data mount) * to derive the same AES key. * @param options.entropyGetter - If provided, called on each `get()`/`set()` * to lazily resolve entropy. This handles the startup race where the * entropy file may not exist at construction time but appears later. * Takes precedence over `entropyOverride`. */ export function createLocalSecureKeyBackend( vellumRoot: string, options?: { entropyOverride?: string; entropyGetter?: () => string | undefined; }, ): SecureKeyBackend { const storePath = join(resolveSecurityDir(vellumRoot), KEYS_ENC_FILENAME); const staticEntropy = options?.entropyOverride; const entropyGetter = options?.entropyGetter; return { async get(key: string): Promise { const store = readStore(storePath); if (!store) { // No store file at all → the credential is genuinely ABSENT. A store // file that EXISTS but could not be read is UNAVAILABLE, not absent — // surface it rather than reporting a false "not found". if (existsSync(storePath)) { throw new StoreUnavailableError( "credential store exists but could not be read", ); } return undefined; } const entry = store.entries[key]; // The store read cleanly and has no such entry → genuinely not found. if (!entry) return undefined; let aesKey: Buffer; if (store.version === 2) { const storeKey = readStoreKey(vellumRoot); if (!storeKey) { // The entry exists but the key material to decrypt it is unavailable // (the cold-start race). Unavailable, NOT not-found. throw new StoreUnavailableError( "v2 store entry present but store.key is unavailable", ); } aesKey = storeKey; } else { // v1: derive key from machine entropy via PBKDF2. `entropy` is an // OPTIONAL override (managed mode); when undefined, deriveKey falls // back to local machine entropy — the normal local-mode path, so an // undefined value here is NOT "unavailable". A v1 cold-start race (the // override entropy file not yet present) yields the wrong key and // surfaces below as a decrypt failure → unavailable. const entropy = entropyGetter?.() ?? staticEntropy; const salt = Buffer.from(store.salt, "hex"); aesKey = deriveKey(salt, entropy); } // A decrypt failure means corrupt data or wrong key material — the // credential exists but we cannot produce its value. Let it propagate as // unavailable rather than swallowing it into a false "not found". return decrypt(entry, aesKey); }, // NOTE: read-modify-write without file locking. The atomic rename // (writeStore) prevents corruption from partial writes, but concurrent // set() calls can lose updates. The window is small in practice because // CES serialises refresh via RefreshDeduplicator. File locking is a // future improvement. async set(key: string, value: string): Promise { try { let store: StoreFile; let aesKey: Buffer; try { const result = getOrCreateStore(storePath, vellumRoot); store = result.store; aesKey = result.aesKey; } catch { // Fallback: v1 store or other error — try legacy PBKDF2 path const existing = readStore(storePath); if (!existing) return false; store = existing; if (store.version === 1) { const entropy = entropyGetter?.() ?? staticEntropy; const salt = Buffer.from(store.salt, "hex"); aesKey = deriveKey(salt, entropy); } else { return false; } } store.entries[key] = encrypt(value, aesKey); writeStore(store, storePath); return true; } catch { return false; } }, async delete(key: string): Promise { try { const store = readStore(storePath); if (!store) return "error"; if (!(key in store.entries)) return "not-found"; delete store.entries[key]; writeStore(store, storePath); return "deleted"; } catch { return "error"; } }, async list(): Promise { const store = readStore(storePath); if (!store) { // Mirror get(): a store file that exists but is unreadable is // UNAVAILABLE (surface it), not an empty credential set. if (existsSync(storePath)) { throw new StoreUnavailableError( "credential store exists but could not be read", ); } return []; } return Object.keys(store.entries); }, }; }