- Admin login gate (docs/design/auth-reference.md): admin_user/admin_session/login_attempt
(migration 0015), one-time setup token logged once + conditional UPDATE, argon2id via
hash-wasm (WASM; compiled binary has no --allow-ffi), sha256-stored sessions in HttpOnly
SameSite=Lax cookie umt_session (fixed 12h TTL, soft revoke), HMAC-derived double-submit
CSRF, IP (5) + account (15) failure limits per 15 min, password change revokes other
sessions, optional TOTP with replay counter, AuditAction enum.
- Unified auth middleware: cookie session or Bearer API key -> c.var.actor
{type: admin|api_key, id, name, scopes}; requireScope works for both; requireAdminSession.
- ctx.secrets: AES-256-GCM, per-secret HKDF-SHA256 key from SECRET_MASTER_KEY, AAD bound
to (id, purpose); ctx.settings: 30s cache invalidated by settings.changed events.
- Endpoints: /auth/*, /settings, /api-keys, /secrets, /audit-events, library-roots,
downloader/media-server instances (+test), path-mappings (+verify),
PUT /sites/{id}/credentials, /engine/runs|stats|cancel (read-only openworkflow schema).
- SSE: PgEventBus (sse_event table, migration 0016, LISTEN/NOTIFY umt_events, catch-up by
seq, pruning, resync); EventBusPort is now async.
- Tests: login/rate limit/revocation/CSRF/TOTP, secrets round trip and no log leakage,
admin APIs against mock qB/Emby, SSE across two Deno subprocesses.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
275 lines
9.3 KiB
TypeScript
275 lines
9.3 KiB
TypeScript
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<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;
|
|
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<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,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
// ── 设置读取 ──
|
|
|
|
/** 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);
|
|
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<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);
|
|
},
|
|
};
|
|
}
|