399 lines
14 KiB
TypeScript
399 lines
14 KiB
TypeScript
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<string, unknown>;
|
|
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<void>;
|
|
meta(secretRef: string): Promise<SecretMeta | null>;
|
|
}
|
|
|
|
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<DB>; 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<string, unknown>,
|
|
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<DB>; logger: Logger },
|
|
oldKey: string,
|
|
newKey: string,
|
|
opts: { progressEvery?: number } = {},
|
|
): Promise<RotateMasterKeyResult> {
|
|
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<GlobalSettings>;
|
|
}
|
|
|
|
/**
|
|
* settings 表(key → jsonb)按分组合并缺省值后校验;表里值非法时回退缺省并记警告。
|
|
* 结果缓存 ttlMs(默认 30s);收到 settings.changed 事件(经 PG 总线,跨进程)立即失效。
|
|
*/
|
|
export function createSettingsReader(deps: {
|
|
db: Kysely<DB>;
|
|
logger: Logger;
|
|
events?: EventBusPort;
|
|
ttlMs?: number;
|
|
}): CachedSettingsReader {
|
|
const ttl = deps.ttlMs ?? 30_000;
|
|
let cache: { at: number; value: Promise<GlobalSettings> } | null = null;
|
|
const listeners = new Set<(keys: SettingsKey[]) => void>();
|
|
|
|
const load = async (): Promise<GlobalSettings> => {
|
|
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<string, unknown> = {};
|
|
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<GlobalSettings> => {
|
|
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);
|
|
},
|
|
};
|
|
}
|