mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
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
This commit is contained in:
@@ -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 管理后台进行配置。
|
||||
|
||||
10
README.md
10
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)
|
||||
|
||||
|
||||
@@ -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 界面配置(管理后台)
|
||||
|
||||
|
||||
@@ -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;正在执行的请求不受影响(用的是已创建的实例),下次请求用新实例 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ──────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user