From 0bc147cbc58365fe59f0d459dbad1ec060695a1e Mon Sep 17 00:00:00 2001 From: jeffusion Date: Thu, 5 Mar 2026 15:24:08 +0800 Subject: [PATCH] refactor: replace master.key file with ENCRYPTION_KEY env var and fix k8s deployment - Replace file-based master key (data/master.key) with ENCRYPTION_KEY env var (hex-encoded) - App now requires ENCRYPTION_KEY to start, removing MASTER_KEY_PATH entirely - Fix k8s: add missing gitea-assistant-data volume, replace PVC with hostPath for single-node - Fix k8s: change qdrant from StatefulSet+PVC to Deployment+hostPath - Add K8s Secret for ENCRYPTION_KEY injection - Update all tests, .env.example, and documentation --- .env.example | 2 +- README.md | 10 ++- docs/README.zh-CN.md | 10 ++- docs/design/pluggable-llm-providers.md | 21 +++-- k8s/gitea-assistant.yaml | 23 +++++- k8s/qdrant.yaml | 25 ++---- src/config/__tests__/config-manager.test.ts | 7 ++ src/controllers/__tests__/llm-config.test.ts | 17 ++-- src/crypto/__tests__/secrets.test.ts | 84 +++++++++----------- src/crypto/secrets.ts | 63 +++++---------- src/db/__tests__/secret-repo.test.ts | 17 ++-- src/llm/__tests__/gateway.test.ts | 17 ++-- 12 files changed, 129 insertions(+), 167 deletions(-) diff --git a/.env.example b/.env.example index 8ccc118..09f9316 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # 应用配置 PORT=3000 # DATABASE_PATH=./data/assistant.db # 可选,默认为 ./data/assistant.db -# MASTER_KEY_PATH=./data/master.key # 可选,默认为 ./data/master.key +ENCRYPTION_KEY= # 必填,运行 openssl rand -hex 32 生成 # 所有其他配置(Gitea连接、飞书通知、Webhook密钥、管理员密码、审查引擎、记忆系统等) # 均通过 Web 管理后台进行配置。 diff --git a/README.md b/README.md index e4acf5b..dfbd7a9 100644 --- a/README.md +++ b/README.md @@ -58,12 +58,14 @@ bun install Create a `.env` file with only infrastructure-level settings: ```bash -# Server port (the only required setting) +# Server port PORT=3000 -# Optional: custom data paths (defaults shown) +# REQUIRED: encryption key for API key storage (generate with: openssl rand -hex 32) +ENCRYPTION_KEY= + +# Optional: custom database path (default shown) # DATABASE_PATH=./data/assistant.db -# MASTER_KEY_PATH=./data/master.key ``` > **All other configuration** (Gitea connection, webhook secret, admin password, review engine, Feishu, memory settings, etc.) is managed through the **Admin Dashboard Web UI** at `http://your-server:3000`. On first boot, all settings are seeded with secure defaults automatically. @@ -102,7 +104,7 @@ Only infrastructure-level settings that must be known before the database is ini |----------|-------------|---------| | `PORT` | Server port | `5174` | | `DATABASE_PATH` | SQLite database file path | `./data/assistant.db` | -| `MASTER_KEY_PATH` | Encryption master key file path | `./data/master.key` | +| `ENCRYPTION_KEY` | **Required.** AES-256-GCM encryption key for API key storage (64 hex chars). Generate with `openssl rand -hex 32` | — | ### Web UI Configuration (Admin Dashboard) diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 1d24a6c..0c4ea94 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -58,12 +58,14 @@ bun install 创建 `.env` 文件,仅填写基础设施级别的配置: ```bash -# 服务端口(唯一必需的配置项) +# 服务端口 PORT=3000 -# 可选:自定义数据路径(以下为默认值) +# 必填:API Key 加密存储密钥(运行 openssl rand -hex 32 生成) +ENCRYPTION_KEY= + +# 可选:自定义数据库路径(以下为默认值) # DATABASE_PATH=./data/assistant.db -# MASTER_KEY_PATH=./data/master.key ``` > **所有其他配置**(Gitea 连接、Webhook 密钥、管理员密码、审查引擎、飞书、记忆系统等)均通过 **Web 管理后台** 在 `http://your-server:3000` 进行配置。首次启动时,所有设置会自动以安全的默认值进行初始化。 @@ -102,7 +104,7 @@ bun run start # 生产模式 |------|------|--------| | `PORT` | 服务端口 | `5174` | | `DATABASE_PATH` | SQLite 数据库文件路径 | `./data/assistant.db` | -| `MASTER_KEY_PATH` | 加密主密钥文件路径 | `./data/master.key` | +| `ENCRYPTION_KEY` | **必填。** AES-256-GCM 加密密钥,用于加密存储 API Key(64 位十六进制字符串)。运行 `openssl rand -hex 32` 生成 | — | ### Web 界面配置(管理后台) diff --git a/docs/design/pluggable-llm-providers.md b/docs/design/pluggable-llm-providers.md index 3dae252..08795c7 100644 --- a/docs/design/pluggable-llm-providers.md +++ b/docs/design/pluggable-llm-providers.md @@ -30,7 +30,7 @@ | **UI-Only 配置** | 所有业务配置仅通过 Web 管理后台设置,不再有环境变量覆盖层(仅保留极少数启动参数如 `PORT`、`WEBHOOK_SECRET`、`DATABASE_PATH`) | | **4 Provider 并存** | `openai_compatible`(现有兼容格式)、`openai_responses`(Responses API)、`anthropic`(Messages API)、`gemini`(generateContent API) | | **SQLite 持久化** | 使用 `bun:sqlite` 零依赖,单文件 `data/assistant.db` | -| **密钥应用层加密** | API Key 使用 AES-256-GCM 加密后存 DB;主密钥来自本地文件 `data/master.key`(首次启动自动生成,权限 600) | +| **密钥应用层加密** | API Key 使用 AES-256-GCM 加密后存 DB;主密钥通过环境变量 `ENCRYPTION_KEY` 传入(hex 编码,64 字符 = 32 字节),未设置则拒绝启动 | | **不做向前兼容** | 旧 JSON 配置文件方案直接废弃,新版本仅支持数据库配置 | ### 开源参考 @@ -170,7 +170,7 @@ CREATE INDEX idx_providers_enabled ON llm_providers(is_enabled); | `llm_providers.type` | 决定使用哪个 adapter 实现 | | `llm_providers.base_url` | `openai_compatible` 类型必填(用户自建代理地址);其他类型可选覆盖官方默认 endpoint | | `llm_providers.extra_config` | JSON 字段,存放 provider 特有参数。例如 Gemini 的 `projectId`、OpenAI 的 `organization`、Anthropic 的 `anthropic-version` header 等 | -| `llm_secrets.key_version` | 用于密钥轮换:当 `master.key` 更新后,启动时批量重加密所有 `key_version < current` 的记录 | +| `llm_secrets.key_version` | 用于密钥轮换:当 `ENCRYPTION_KEY` 更新后,启动时批量重加密所有 `key_version < current` 的记录 | | `model_role_assignments.role` | 业务角色枚举,对应代码中不同调用场景 | | `system_settings.is_sensitive` | 为 1 时 value 字段存密文(复用 `crypto/secrets.ts`),GET API 返回 masked | @@ -612,11 +612,10 @@ export function toGeminiTools(tools: LLMToolDefinition[]): object[]; ``` 启动流程: - 1. 检查 data/master.key 是否存在 - ├── 不存在 → crypto.randomBytes(32) 生成 - │ 写入文件,chmod 600(仅 owner 读写) - │ 日志输出: "Generated new master key at data/master.key" - └── 存在 → 读取 32 bytes + 1. 读取环境变量 ENCRYPTION_KEY(hex 编码,64 字符) + ├── 未设置或为空 → 抛出错误,拒绝启动 + ├── 长度不正确 → 抛出错误,提示需要 64 个十六进制字符 + └── 正确 → 解码为 32 字节 Buffer 2. 主密钥常驻内存(进程生命周期) 3. 绝对不写入日志、不暴露给 API ``` @@ -647,11 +646,11 @@ export function toGeminiTools(tools: LLMToolDefinition[]): object[]; ### 6.4 密钥轮换 ``` -场景: 管理员替换 data/master.key - 1. 启动时读取新 master key +场景: 管理员更换 ENCRYPTION_KEY + 1. 启动时读取新的 ENCRYPTION_KEY 环境变量 2. 查询所有 llm_secrets WHERE key_version < current_version 3. 逐条: 用旧 key 解密 → 用新 key 重加密 → 更新 key_version - 4. 如果旧 key 不可用(文件丢失)→ 启动报错,要求重新设置所有 API Key + 4. 如果旧 key 不可用(环境变量缺失)→ 启动报错,要求重新设置所有 API Key ``` --- @@ -798,7 +797,7 @@ Day 6.5: 旧代码清理完毕,文档更新,Ready for review | **Anthropic 无原生 JSON mode** | `response_format: json_object` 不可用,JSON 解析可能失败 | Adapter 内 prompt 注入 JSON 指令 + `JSON.parse()` 容错(正则提取 \`\`\`json\`\`\` 块 → 重试 parse) | | **Gemini function calling 格式差异大** | `functionDeclarations` 包装层级不同;`functionResponse` 嵌套在 `parts` 中 | `tool-converter.ts` 单独处理;finish reason 映射表全覆盖测试 | | **Embedding 维度变化导致 Qdrant 不兼容** | `src/review/memory/vector-store.ts` 硬编码 1536 维 | `model_role_assignments.role='embedding'` 变更时,UI 提示用户需重建 collection;或自动检测维度创建新 collection | -| **master.key 丢失** | 所有加密的 API Key 不可恢复 | 启动时检测密钥版本不匹配 → 报错并要求重新设置所有 API Key(trade-off:安全性 > 便利性) | +| **ENCRYPTION_KEY 丢失** | 所有加密的 API Key 不可恢复 | 启动时检测密钥版本不匹配 → 报错并要求重新设置所有 API Key(trade-off:安全性 > 便利性) | | **SQLite 并发写** | 多请求同时写入可能 SQLITE_BUSY | `bun:sqlite` 开启 WAL mode;写操作走单连接序列化;读可并行 | | **Provider SDK 版本冲突** | `openai`、`@anthropic-ai/sdk`、`@google/generative-ai` 三个 SDK 共存 | 各 adapter 独立 import,无交叉依赖;`package.json` 锁定主版本 | | **配置热更新** | UI 修改 provider 配置后,正在进行的审查仍用旧配置 | Gateway 缓存按 provider_id 粒度 invalidate;正在执行的请求不受影响(用的是已创建的实例),下次请求用新实例 | diff --git a/k8s/gitea-assistant.yaml b/k8s/gitea-assistant.yaml index e6b7278..afacb52 100644 --- a/k8s/gitea-assistant.yaml +++ b/k8s/gitea-assistant.yaml @@ -1,3 +1,18 @@ +--- +# Secret: sensitive configuration (create before deploying) +# Generate a 64-char hex key: openssl rand -hex 32 +apiVersion: v1 +kind: Secret +metadata: + name: gitea-assistant-secret + namespace: gitea-assistant + labels: + app.kubernetes.io/name: gitea-assistant + app.kubernetes.io/part-of: gitea-assistant +type: Opaque +stringData: + ENCRYPTION_KEY: "" # REQUIRED: run `openssl rand -hex 32` and paste here + --- # ConfigMap: only infrastructure-level env vars that must be known before DB init apiVersion: v1 @@ -43,6 +58,8 @@ spec: envFrom: - configMapRef: name: gitea-assistant-config + - secretRef: + name: gitea-assistant-secret resources: limits: memory: "512Mi" @@ -70,8 +87,10 @@ spec: failureThreshold: 3 volumes: - name: data - persistentVolumeClaim: - claimName: gitea-assistant-data + hostPath: + # Customize this path to match your node's storage layout + path: /opt/gitea-assistant/data + type: DirectoryOrCreate --- apiVersion: v1 diff --git a/k8s/qdrant.yaml b/k8s/qdrant.yaml index 4de7780..e08dc3e 100644 --- a/k8s/qdrant.yaml +++ b/k8s/qdrant.yaml @@ -1,22 +1,6 @@ ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: qdrant-data - namespace: gitea-assistant - labels: - app.kubernetes.io/name: qdrant - app.kubernetes.io/part-of: gitea-assistant -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 10Gi - --- apiVersion: apps/v1 -kind: StatefulSet +kind: Deployment metadata: name: qdrant namespace: gitea-assistant @@ -24,7 +8,6 @@ metadata: app.kubernetes.io/name: qdrant app.kubernetes.io/part-of: gitea-assistant spec: - serviceName: qdrant replicas: 1 selector: matchLabels: @@ -72,8 +55,10 @@ spec: failureThreshold: 3 volumes: - name: qdrant-storage - persistentVolumeClaim: - claimName: qdrant-data + hostPath: + # Customize this path to match your node's storage layout + path: /opt/gitea-assistant/qdrant + type: DirectoryOrCreate --- apiVersion: v1 diff --git a/src/config/__tests__/config-manager.test.ts b/src/config/__tests__/config-manager.test.ts index ad56fb3..aca89d4 100644 --- a/src/config/__tests__/config-manager.test.ts +++ b/src/config/__tests__/config-manager.test.ts @@ -34,10 +34,12 @@ function makeTmpDb(): string { describe('ConfigManager (DB backend)', () => { let dbPath: string; const savedDbPath = process.env.DATABASE_PATH; + const savedEncryptionKey = process.env.ENCRYPTION_KEY; beforeEach(() => { dbPath = makeTmpDb(); process.env.DATABASE_PATH = dbPath; + process.env.ENCRYPTION_KEY = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex'); initMasterKey(); initDatabase(); }); @@ -49,6 +51,11 @@ describe('ConfigManager (DB backend)', () => { } else { process.env.DATABASE_PATH = savedDbPath; } + if (savedEncryptionKey === undefined) { + delete process.env.ENCRYPTION_KEY; + } else { + process.env.ENCRYPTION_KEY = savedEncryptionKey; + } try { if (existsSync(dbPath)) unlinkSync(dbPath); } catch { /* ok */ } try { if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`); } catch { /* ok */ } try { if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`); } catch { /* ok */ } diff --git a/src/controllers/__tests__/llm-config.test.ts b/src/controllers/__tests__/llm-config.test.ts index f6d2b77..5b856bf 100644 --- a/src/controllers/__tests__/llm-config.test.ts +++ b/src/controllers/__tests__/llm-config.test.ts @@ -59,18 +59,16 @@ async function jsonRequest( describe('llm-config controller', () => { let dbPath: string; - let keyPath: string; let app: Hono; const savedDbPath = process.env.DATABASE_PATH; - const savedKeyPath = process.env.MASTER_KEY_PATH; + const savedEncryptionKey = process.env.ENCRYPTION_KEY; beforeEach(() => { const tmpDir = join(tmpdir(), `ctrl-test-${randomUUID()}`); mkdirSync(tmpDir, { recursive: true }); dbPath = join(tmpDir, 'test.db'); - keyPath = join(tmpDir, 'master.key'); process.env.DATABASE_PATH = dbPath; - process.env.MASTER_KEY_PATH = keyPath; + process.env.ENCRYPTION_KEY = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex'); initMasterKey(); initDatabase(); @@ -84,10 +82,10 @@ describe('llm-config controller', () => { } else { process.env.DATABASE_PATH = savedDbPath; } - if (savedKeyPath === undefined) { - delete process.env.MASTER_KEY_PATH; + if (savedEncryptionKey === undefined) { + delete process.env.ENCRYPTION_KEY; } else { - process.env.MASTER_KEY_PATH = savedKeyPath; + process.env.ENCRYPTION_KEY = savedEncryptionKey; } try { if (existsSync(dbPath)) unlinkSync(dbPath); @@ -104,11 +102,6 @@ describe('llm-config controller', () => { } catch { /* ok */ } - try { - if (existsSync(keyPath)) unlinkSync(keyPath); - } catch { - /* ok */ - } }); // ─── Provider CRUD ──────────────────────────────────────────────── diff --git a/src/crypto/__tests__/secrets.test.ts b/src/crypto/__tests__/secrets.test.ts index 752a77e..dde98b8 100644 --- a/src/crypto/__tests__/secrets.test.ts +++ b/src/crypto/__tests__/secrets.test.ts @@ -10,10 +10,6 @@ declare module 'bun:test' { // @ts-expect-error bun:test is provided by Bun at runtime import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { randomUUID } from 'node:crypto'; -import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; -import { mkdirSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; /** * Import a fresh secrets module to bypass module cache (same pattern as config-manager.test.ts). @@ -23,76 +19,58 @@ async function importFresh() { return await import(`../../crypto/secrets.ts?t=${Date.now()}-${randomUUID()}`); } +/** Generate a valid 64-char hex key for tests */ +function generateHexKey(): string { + return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex'); +} + describe('secrets — AES-256-GCM encryption', () => { - let tmpDir: string; - let keyPath: string; - const savedMasterKeyPath = process.env.MASTER_KEY_PATH; + let savedEncryptionKey: string | undefined; beforeEach(() => { - tmpDir = join(tmpdir(), `secrets-test-${randomUUID()}`); - mkdirSync(tmpDir, { recursive: true }); - keyPath = join(tmpDir, 'master.key'); - process.env.MASTER_KEY_PATH = keyPath; + savedEncryptionKey = process.env.ENCRYPTION_KEY; + delete process.env.ENCRYPTION_KEY; }); afterEach(() => { - if (savedMasterKeyPath === undefined) { - delete process.env.MASTER_KEY_PATH; + if (savedEncryptionKey === undefined) { + delete process.env.ENCRYPTION_KEY; } else { - process.env.MASTER_KEY_PATH = savedMasterKeyPath; - } - // Cleanup temp files - try { - if (existsSync(keyPath)) unlinkSync(keyPath); - } catch { - /* ok */ + process.env.ENCRYPTION_KEY = savedEncryptionKey; } }); // ─── Master Key Init ───────────────────────────────────────────────── describe('initMasterKey()', () => { - test('generates a new 32-byte key file when none exists', async () => { - const secrets = await importFresh(); - expect(existsSync(keyPath)).toBe(false); + test('loads key from ENCRYPTION_KEY env var (hex)', async () => { + process.env.ENCRYPTION_KEY = generateHexKey(); + const secrets = await importFresh(); secrets.initMasterKey(); - expect(existsSync(keyPath)).toBe(true); - const raw = readFileSync(keyPath); - expect(raw.length).toBe(32); expect(secrets.isMasterKeyReady()).toBe(true); }); - test('loads an existing key file without overwriting', async () => { - // Pre-create a 32-byte key - const existingKey = Buffer.from(crypto.getRandomValues(new Uint8Array(32))); - writeFileSync(keyPath, existingKey, { mode: 0o600 }); + test('throws if ENCRYPTION_KEY is not set', async () => { + delete process.env.ENCRYPTION_KEY; const secrets = await importFresh(); - secrets.initMasterKey(); - - const loaded = readFileSync(keyPath); - expect(Buffer.compare(loaded, existingKey)).toBe(0); - expect(secrets.isMasterKeyReady()).toBe(true); + expect(() => secrets.initMasterKey()).toThrow('ENCRYPTION_KEY env var is required'); }); - test('throws if key file is wrong length', async () => { - writeFileSync(keyPath, Buffer.alloc(16)); // Wrong length + test('throws if ENCRYPTION_KEY is wrong length', async () => { + process.env.ENCRYPTION_KEY = 'abcd'; // Only 2 bytes const secrets = await importFresh(); - expect(() => secrets.initMasterKey()).toThrow('16 bytes, expected 32'); + expect(() => secrets.initMasterKey()).toThrow('2 bytes'); }); - test('creates parent directories if needed', async () => { - const nestedPath = join(tmpDir, 'nested', 'deep', 'master.key'); - process.env.MASTER_KEY_PATH = nestedPath; + test('throws if ENCRYPTION_KEY is empty string', async () => { + process.env.ENCRYPTION_KEY = ''; const secrets = await importFresh(); - secrets.initMasterKey(); - - expect(existsSync(nestedPath)).toBe(true); - expect(readFileSync(nestedPath).length).toBe(32); + expect(() => secrets.initMasterKey()).toThrow('ENCRYPTION_KEY env var is required'); }); }); @@ -105,6 +83,7 @@ describe('secrets — AES-256-GCM encryption', () => { }); test('returns true after initMasterKey', async () => { + process.env.ENCRYPTION_KEY = generateHexKey(); const secrets = await importFresh(); secrets.initMasterKey(); expect(secrets.isMasterKeyReady()).toBe(true); @@ -120,6 +99,7 @@ describe('secrets — AES-256-GCM encryption', () => { }); test('roundtrip: encrypt then decrypt recovers plaintext', async () => { + process.env.ENCRYPTION_KEY = generateHexKey(); const secrets = await importFresh(); secrets.initMasterKey(); @@ -131,6 +111,7 @@ describe('secrets — AES-256-GCM encryption', () => { }); test('encrypts empty string', async () => { + process.env.ENCRYPTION_KEY = generateHexKey(); const secrets = await importFresh(); secrets.initMasterKey(); @@ -139,6 +120,7 @@ describe('secrets — AES-256-GCM encryption', () => { }); test('encrypts unicode content', async () => { + process.env.ENCRYPTION_KEY = generateHexKey(); const secrets = await importFresh(); secrets.initMasterKey(); @@ -148,6 +130,7 @@ describe('secrets — AES-256-GCM encryption', () => { }); test('encrypts long content', async () => { + process.env.ENCRYPTION_KEY = generateHexKey(); const secrets = await importFresh(); secrets.initMasterKey(); @@ -157,6 +140,7 @@ describe('secrets — AES-256-GCM encryption', () => { }); test('each encryption produces unique IV (different ciphertext)', async () => { + process.env.ENCRYPTION_KEY = generateHexKey(); const secrets = await importFresh(); secrets.initMasterKey(); @@ -172,6 +156,7 @@ describe('secrets — AES-256-GCM encryption', () => { }); test('encrypted payload has expected structure', async () => { + process.env.ENCRYPTION_KEY = generateHexKey(); const secrets = await importFresh(); secrets.initMasterKey(); @@ -188,6 +173,7 @@ describe('secrets — AES-256-GCM encryption', () => { }); test('tampered ciphertext fails decryption', async () => { + process.env.ENCRYPTION_KEY = generateHexKey(); const secrets = await importFresh(); secrets.initMasterKey(); @@ -199,6 +185,7 @@ describe('secrets — AES-256-GCM encryption', () => { }); test('tampered auth tag fails decryption', async () => { + process.env.ENCRYPTION_KEY = generateHexKey(); const secrets = await importFresh(); secrets.initMasterKey(); @@ -209,14 +196,15 @@ describe('secrets — AES-256-GCM encryption', () => { }); test('wrong master key fails decryption', async () => { + process.env.ENCRYPTION_KEY = generateHexKey(); const secrets1 = await importFresh(); secrets1.initMasterKey(); const encrypted = secrets1.encrypt('secret'); - // Create a different key - unlinkSync(keyPath); + // Use a different key + process.env.ENCRYPTION_KEY = generateHexKey(); const secrets2 = await importFresh(); - secrets2.initMasterKey(); // Generates a new key + secrets2.initMasterKey(); expect(() => secrets2.decrypt(encrypted)).toThrow(); }); diff --git a/src/crypto/secrets.ts b/src/crypto/secrets.ts index dcdf0e6..f87fec0 100644 --- a/src/crypto/secrets.ts +++ b/src/crypto/secrets.ts @@ -2,14 +2,13 @@ * AES-256-GCM encryption module for API key storage. * * Master key lifecycle: - * 1. On first startup, generate 32 random bytes → write to DATA_DIR/master.key (chmod 600) - * 2. On subsequent startups, read existing master.key + * 1. Read `ENCRYPTION_KEY` env var (hex-encoded, 64 hex chars = 32 bytes) + * 2. If not set, throw — the app refuses to start without an explicit key * 3. Key stays in memory for process lifetime; never logged or exposed via API + * + * Generate a key: `openssl rand -hex 32` */ -import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; - // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -17,7 +16,6 @@ import { dirname, resolve } from 'node:path'; const KEY_LENGTH = 32; // AES-256 const IV_LENGTH = 12; // GCM recommended nonce size - // --------------------------------------------------------------------------- // Master Key Management // --------------------------------------------------------------------------- @@ -25,45 +23,28 @@ const IV_LENGTH = 12; // GCM recommended nonce size let masterKey: Buffer | null = null; /** - * Resolve the master key file path. - * Defaults to `data/master.key` relative to CWD, overridable via `MASTER_KEY_PATH` env. - */ -function getMasterKeyPath(): string { - return resolve(process.env.MASTER_KEY_PATH || './data/master.key'); -} - -/** - * Initialize (load or generate) the master encryption key. + * Initialize the master encryption key from `ENCRYPTION_KEY` env var. * MUST be called once at application startup before any encrypt/decrypt operations. + * + * @throws If ENCRYPTION_KEY is not set or has wrong length */ export function initMasterKey(): void { - const keyPath = getMasterKeyPath(); - - if (existsSync(keyPath)) { - const raw = readFileSync(keyPath); - if (raw.length !== KEY_LENGTH) { - throw new Error( - `Master key at ${keyPath} is ${raw.length} bytes, expected ${KEY_LENGTH}. Delete the file and restart to generate a new key (all encrypted API keys will need to be re-entered).` - ); - } - masterKey = Buffer.from(raw); - console.log(`🔑 Master key loaded from ${keyPath}`); - } else { - const dir = dirname(keyPath); - mkdirSync(dir, { recursive: true }); - - const key = Buffer.from(crypto.getRandomValues(new Uint8Array(KEY_LENGTH))); - writeFileSync(keyPath, key, { mode: 0o600 }); - - try { - chmodSync(keyPath, 0o600); - } catch { - // chmod may fail on some filesystems (e.g. Windows); non-fatal - } - - masterKey = key; - console.log(`🔑 Generated new master key at ${keyPath}`); + const envKey = process.env.ENCRYPTION_KEY; + if (!envKey) { + throw new Error( + 'ENCRYPTION_KEY env var is required but not set. Generate one with: openssl rand -hex 32' + ); } + + const buf = Buffer.from(envKey, 'hex'); + if (buf.length !== KEY_LENGTH) { + throw new Error( + `ENCRYPTION_KEY env var is ${buf.length} bytes (decoded from hex), expected ${KEY_LENGTH}. Provide exactly 64 hex characters.` + ); + } + + masterKey = buf; + console.log('🔑 Master key loaded from ENCRYPTION_KEY env var'); } /** diff --git a/src/db/__tests__/secret-repo.test.ts b/src/db/__tests__/secret-repo.test.ts index 65b5202..0bb1b82 100644 --- a/src/db/__tests__/secret-repo.test.ts +++ b/src/db/__tests__/secret-repo.test.ts @@ -21,10 +21,9 @@ import { secretRepo } from '../repositories/secret-repo'; describe('secret-repo', () => { let dbPath: string; - let keyPath: string; let providerId: string; const savedDbPath = process.env.DATABASE_PATH; - const savedKeyPath = process.env.MASTER_KEY_PATH; + const savedEncryptionKey = process.env.ENCRYPTION_KEY; const providerInput: CreateProviderInput = { name: 'Test Provider', @@ -37,9 +36,8 @@ describe('secret-repo', () => { const tmpDir = join(tmpdir(), `db-test-${randomUUID()}`); mkdirSync(tmpDir, { recursive: true }); dbPath = join(tmpDir, 'test.db'); - keyPath = join(tmpDir, 'master.key'); process.env.DATABASE_PATH = dbPath; - process.env.MASTER_KEY_PATH = keyPath; + process.env.ENCRYPTION_KEY = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex'); initMasterKey(); initDatabase(); @@ -55,10 +53,10 @@ describe('secret-repo', () => { } else { process.env.DATABASE_PATH = savedDbPath; } - if (savedKeyPath === undefined) { - delete process.env.MASTER_KEY_PATH; + if (savedEncryptionKey === undefined) { + delete process.env.ENCRYPTION_KEY; } else { - process.env.MASTER_KEY_PATH = savedKeyPath; + process.env.ENCRYPTION_KEY = savedEncryptionKey; } try { if (existsSync(dbPath)) unlinkSync(dbPath); @@ -75,11 +73,6 @@ describe('secret-repo', () => { } catch { /* ok */ } - try { - if (existsSync(keyPath)) unlinkSync(keyPath); - } catch { - /* ok */ - } }); // ─── Set ────────────────────────────────────────────────────────── diff --git a/src/llm/__tests__/gateway.test.ts b/src/llm/__tests__/gateway.test.ts index d518d2a..b94e3a9 100644 --- a/src/llm/__tests__/gateway.test.ts +++ b/src/llm/__tests__/gateway.test.ts @@ -23,11 +23,10 @@ import { LLMGateway } from '../gateway'; describe('LLMGateway', () => { let dbPath: string; - let keyPath: string; let gateway: LLMGateway; let providerId: string; const savedDbPath = process.env.DATABASE_PATH; - const savedKeyPath = process.env.MASTER_KEY_PATH; + const savedEncryptionKey = process.env.ENCRYPTION_KEY; const providerInput: CreateProviderInput = { name: 'Test OpenAI', @@ -40,9 +39,8 @@ describe('LLMGateway', () => { const tmpDir = join(tmpdir(), `gw-test-${randomUUID()}`); mkdirSync(tmpDir, { recursive: true }); dbPath = join(tmpDir, 'test.db'); - keyPath = join(tmpDir, 'master.key'); process.env.DATABASE_PATH = dbPath; - process.env.MASTER_KEY_PATH = keyPath; + process.env.ENCRYPTION_KEY = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex'); initMasterKey(); initDatabase(); @@ -62,10 +60,10 @@ describe('LLMGateway', () => { } else { process.env.DATABASE_PATH = savedDbPath; } - if (savedKeyPath === undefined) { - delete process.env.MASTER_KEY_PATH; + if (savedEncryptionKey === undefined) { + delete process.env.ENCRYPTION_KEY; } else { - process.env.MASTER_KEY_PATH = savedKeyPath; + process.env.ENCRYPTION_KEY = savedEncryptionKey; } try { if (existsSync(dbPath)) unlinkSync(dbPath); @@ -82,11 +80,6 @@ describe('LLMGateway', () => { } catch { /* ok */ } - try { - if (existsSync(keyPath)) unlinkSync(keyPath); - } catch { - /* ok */ - } }); // ─── chatForRole: Error Cases ──────────────────────────────────────