Files
UHD_Media_Tool/apps/server/src/modules/auth-settings/provided.ts
T

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);
},
};
}