import { createCipheriv, createDecipheriv, createHmac, randomBytes, randomUUID } from "node:crypto"; import { DEFAULT_SETTINGS, GlobalSettings, type SecretPurpose, type SettingsKey, withStorageDefaults, } 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"; /** 密文格式版本(HKDF info = umt/secret/v<版本>);主密钥轮换不改它,换哪把主密钥看 kdf_params.kid */ 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; /** kid:主密钥指纹(HMAC 派生,不可逆),主密钥轮换时据此跳过已换过的行 */ kdfParams: { salt: string; info: string; kid: string }; } /** 主密钥指纹:HMAC-SHA256(masterKey, 固定标签) 取前 16 位十六进制;只用于区分密钥,不能反推 */ export const masterKeyId = (masterKey: string) => createHmac("sha256", Buffer.from(masterKey, "utf8")).update("umt/secret/kid").digest("hex").slice(0, 16); /** 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, kid: masterKeyId(masterKey) }, }; } 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, }; }, }; } // ── 主密钥轮换(--role rotate-master-key) ── export interface RotateMasterKeyResult { total: number; /** 本次用新密钥重新加密的行 */ rotated: number; /** 已经是新密钥加密的行(上次中断后重跑时跳过) */ skipped: number; /** 新旧密钥都解不开的行(id 列表;不改动) */ failed: string[]; } /** * 用新主密钥逐条重新加密 secret 表:每行一个事务(select … for update → 旧密钥解密 → 新盐新 nonce 加密 → 写回), * 已能被新密钥解开的行跳过,所以中断后原样重跑即可(幂等);新旧密钥对调再跑一遍就是回滚。 * 明文只在单行事务内存里出现,不写日志。 */ export async function rotateMasterKey( deps: { db: Kysely; logger: Logger }, oldKey: string, newKey: string, opts: { progressEvery?: number } = {}, ): Promise { if (oldKey === newKey) throw new Error("新旧主密钥相同,不需要轮换"); const { db, logger } = deps; const newKid = masterKeyId(newKey); const every = opts.progressEvery ?? 50; const ids = (await db.selectFrom("secret").select("id").orderBy("id").execute()).map((r) => r.id); const out: RotateMasterKeyResult = { total: ids.length, rotated: 0, skipped: 0, failed: [] }; logger.info({ total: ids.length, newKeyId: newKid }, "主密钥轮换开始"); let done = 0; for (const id of ids) { const r = await db.transaction().execute(async (trx) => { const row = await trx .selectFrom("secret") .select(["id", "purpose", "ciphertext", "nonce", "auth_tag", "kdf_params"]) .where("id", "=", id) .forUpdate() .executeTakeFirst(); if (!row) return "gone" as const; const tryOpen = (key: string) => { try { return open(key, row); } catch { return null; } }; if (tryOpen(newKey) !== null) return "skipped" as const; const plain = tryOpen(oldKey); if (plain === null) return "failed" as const; const sealed = seal(newKey, row.id, row.purpose, plain); await trx .updateTable("secret") .set({ ciphertext: sealed.ciphertext, nonce: sealed.nonce, auth_tag: sealed.authTag, kdf_params: JSON.stringify(sealed.kdfParams), rotated_at: new Date(), }) .where("id", "=", row.id) .execute(); return "rotated" as const; }); if (r === "rotated") out.rotated++; else if (r === "skipped") out.skipped++; else if (r === "failed") { out.failed.push(id); logger.warn({ secretId: id }, "这条 secret 新旧主密钥都解不开,已跳过(保持原样)"); } done++; if (done % every === 0 || done === ids.length) logger.info( { done, total: ids.length, rotated: out.rotated, skipped: out.skipped, failed: out.failed.length }, "主密钥轮换进度", ); } await db .insertInto("audit_event") .values({ actor_type: "system", actor_id: "rotate-master-key", action: "secret.master_key_rotate", target_type: "secret", target_id: null, payload: JSON.stringify({ total: out.total, rotated: out.rotated, skipped: out.skipped, failed: out.failed, newKeyId: newKid, }), }) .execute() .catch((err) => logger.warn({ err: { message: (err as Error).message } }, "主密钥轮换审计写入失败")); logger.info( { total: out.total, rotated: out.rotated, skipped: out.skipped, failed: out.failed.length }, out.failed.length ? "主密钥轮换结束,有解不开的行" : "主密钥轮换完成", ); return out; } // ── 设置读取 ── /** 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); // storage 是两层结构:逐层补缺省,旧库里缺子字段也能整体通过校验 const merged = key === "storage" ? withStorageDefaults(v) : 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); }, }; }