import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto"; import { DEFAULT_SETTINGS, GlobalSettings, type SecretPurpose, type SettingsKey } from "@umt/contracts"; import type { DB, Kysely } from "@umt/db"; import type { Logger } from "pino"; import type { EventBusPort } from "../../http/sse.ts"; import type { SecretStore, SettingsReader } from "../types.ts"; import { deriveKey } from "./crypto.ts"; // auth-settings 模块向 ServerContext 提供的基础服务:ctx.secrets / ctx.settings。 // ── 密钥服务 ── export const SECRET_ALGORITHM = "aes-256-gcm"; export const SECRET_KDF = "hkdf-sha256"; /** 当前主密钥版本;轮换主密钥时 +1,并用 rewrapAll 重新加密 */ export const SECRET_KEY_VERSION = 1; /** 找不到 secret 或无法解密;消息里只有 ref,不含任何明文 */ export class SecretError extends Error { constructor( readonly kind: "not_found" | "decrypt_failed", readonly secretRef: string, ) { super( kind === "not_found" ? `secret ${secretRef} 不存在` : `secret ${secretRef} 解密失败(主密钥不匹配或密文被篡改)`, ); this.name = "SecretError"; } } export interface SecretMeta { id: string; name: string; purpose: SecretPurpose; algorithm: string; kdf: string; kdfParams: Record; keyVersion: number; rotatedAt: Date | null; createdAt: Date; updatedAt: Date; } /** SecretStore(put/get/rotate/delete = create/resolve/replace/remove)+ 管理面需要的元数据操作 */ export interface SecretService extends SecretStore { /** 同 replace:换新明文(新盐、新 nonce),记 rotated_at */ rotate(secretRef: string, plaintext: string): Promise; meta(secretRef: string): Promise; } interface Sealed { ciphertext: Buffer; nonce: Buffer; authTag: Buffer; kdfParams: { salt: string; info: string }; } /** AAD 绑定行 id 与用途:密文被挪到别的行/改用途即解密失败 */ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; function assertRef(ref: string) { if (!UUID_RE.test(ref)) throw new SecretError("not_found", String(ref).slice(0, 64)); } const aad = (id: string, purpose: string) => Buffer.from(`umt-secret:${id}:${purpose}`, "utf8"); const infoFor = (keyVersion: number) => `umt/secret/v${keyVersion}`; function seal(masterKey: string, id: string, purpose: string, plaintext: string): Sealed { const salt = randomBytes(16); const info = infoFor(SECRET_KEY_VERSION); const key = deriveKey(masterKey, salt, info); const nonce = randomBytes(12); const cipher = createCipheriv(SECRET_ALGORITHM, key, nonce); cipher.setAAD(aad(id, purpose)); const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); return { ciphertext, nonce, authTag: cipher.getAuthTag(), kdfParams: { salt: salt.toString("base64"), info } }; } function open( masterKey: string, row: { id: string; purpose: string; ciphertext: Buffer; nonce: Buffer; auth_tag: Buffer | null; kdf_params: unknown }, ): string { const params = (row.kdf_params ?? {}) as { salt?: string; info?: string }; if (!params.salt || !params.info || !row.auth_tag) throw new SecretError("decrypt_failed", row.id); try { const key = deriveKey(masterKey, Buffer.from(params.salt, "base64"), params.info); const decipher = createDecipheriv(SECRET_ALGORITHM, key, row.nonce); decipher.setAAD(aad(row.id, row.purpose)); decipher.setAuthTag(row.auth_tag); return Buffer.concat([decipher.update(row.ciphertext), decipher.final()]).toString("utf8"); } catch { throw new SecretError("decrypt_failed", row.id); } } /** * secret 表 + AES-256-GCM:每条 secret 用随机盐经 HKDF-SHA256 从 SECRET_MASTER_KEY 派生独立密钥, * 随机 12 字节 nonce,AAD 绑定 (id, purpose)。明文只在返回值里出现,不缓存、不打日志。 */ export function createSecretStore(deps: { db: Kysely; masterKey: string }): SecretService { const { db, masterKey } = deps; const replace = async (secretRef: string, plaintext: string) => { assertRef(secretRef); const row = await db.selectFrom("secret").select(["id", "purpose"]).where("id", "=", secretRef).executeTakeFirst(); if (!row) throw new SecretError("not_found", secretRef); const s = seal(masterKey, row.id, row.purpose, plaintext); await db .updateTable("secret") .set({ ciphertext: s.ciphertext, nonce: s.nonce, auth_tag: s.authTag, kdf_params: JSON.stringify(s.kdfParams), key_version: SECRET_KEY_VERSION, rotated_at: new Date(), }) .where("id", "=", secretRef) .execute(); }; return { async create({ purpose, plaintext, label }) { const id = randomUUID(); const s = seal(masterKey, id, purpose, plaintext); await db .insertInto("secret") .values({ id, name: label ?? `${purpose}:${id}`, purpose, ciphertext: s.ciphertext, nonce: s.nonce, auth_tag: s.authTag, algorithm: SECRET_ALGORITHM, kdf: SECRET_KDF, kdf_params: JSON.stringify(s.kdfParams), key_version: SECRET_KEY_VERSION, }) .execute(); return id; }, async resolve(secretRef) { assertRef(secretRef); const row = await db .selectFrom("secret") .select(["id", "purpose", "ciphertext", "nonce", "auth_tag", "kdf_params"]) .where("id", "=", secretRef) .executeTakeFirst(); if (!row) throw new SecretError("not_found", secretRef); return open(masterKey, row); }, replace, rotate: replace, async remove(secretRef) { if (!UUID_RE.test(secretRef)) return; await db.deleteFrom("secret").where("id", "=", secretRef).execute(); }, async meta(secretRef) { if (!UUID_RE.test(secretRef)) return null; const r = await db .selectFrom("secret") .select([ "id", "name", "purpose", "algorithm", "kdf", "kdf_params", "key_version", "rotated_at", "created_at", "updated_at", ]) .where("id", "=", secretRef) .executeTakeFirst(); if (!r) return null; return { id: r.id, name: r.name, purpose: r.purpose as SecretPurpose, algorithm: r.algorithm, kdf: r.kdf, kdfParams: (r.kdf_params ?? {}) as Record, keyVersion: r.key_version, rotatedAt: r.rotated_at, createdAt: r.created_at, updatedAt: r.updated_at, }; }, }; } // ── 设置读取 ── /** SSE / 进程间广播的设置变更事件 */ export const SETTINGS_CHANGED_EVENT = "settings.changed"; export interface CachedSettingsReader extends SettingsReader { /** 丢弃缓存(本进程写库后立即调用;其它进程经 settings.changed 事件失效) */ invalidate(): void; /** 订阅变更(失效后触发,参数为变更的分组);返回退订函数 */ onChange(fn: (keys: SettingsKey[]) => void): () => void; /** 绕过缓存读库 */ fresh(): Promise; } /** * settings 表(key → jsonb)按分组合并缺省值后校验;表里值非法时回退缺省并记警告。 * 结果缓存 ttlMs(默认 30s);收到 settings.changed 事件(经 PG 总线,跨进程)立即失效。 */ export function createSettingsReader(deps: { db: Kysely; logger: Logger; events?: EventBusPort; ttlMs?: number; }): CachedSettingsReader { const ttl = deps.ttlMs ?? 30_000; let cache: { at: number; value: Promise } | null = null; const listeners = new Set<(keys: SettingsKey[]) => void>(); const load = async (): Promise => { const rows = await deps.db.selectFrom("settings").select(["key", "value"]).execute(); const stored = new Map(rows.map((r) => [r.key, r.value])); const out: Record = {}; for (const key of Object.keys(DEFAULT_SETTINGS) as SettingsKey[]) { const def = DEFAULT_SETTINGS[key]; const v = stored.get(key); const merged = v && typeof v === "object" && !Array.isArray(v) ? { ...def, ...(v as object) } : def; const parsed = GlobalSettings.shape[key].safeParse(merged); if (!parsed.success) deps.logger.warn({ key }, "settings 值不合法,回退缺省"); out[key] = parsed.success ? parsed.data : def; } return out as GlobalSettings; }; const all = (): Promise => { const now = Date.now(); if (!cache || now - cache.at > ttl) { const value = load(); cache = { at: now, value }; // 读失败不缓存 value.catch(() => { if (cache?.value === value) cache = null; }); } return cache.value; }; const invalidate = () => { cache = null; }; deps.events?.subscribe((e) => { if (e.type !== SETTINGS_CHANGED_EVENT) return; invalidate(); const keys = ((e.payload as { keys?: SettingsKey[] } | null)?.keys ?? []) as SettingsKey[]; for (const l of listeners) { try { l(keys); } catch (err) { deps.logger.warn({ err }, "settings 变更监听器异常"); } } }); return { all, get: async (key) => (await all())[key], invalidate, fresh: load, onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); }, }; }