feat(server): W2-A login gate, secrets, settings, instances, audit, engine panel API and PG-backed SSE bus
- 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]>
This commit is contained in:
1 parent
07db0dac5b
commit
ff230e5da7
34 files changed
+4363
-78
No files matched your search
@@ -1,21 +1,222 @@
|
||||
import { DEFAULT_SETTINGS, GlobalSettings, type SettingsKey } from "@umt/contracts";
|
||||
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 提供的基础服务。W2-A 在这里替换实现,其它模块只用 ctx 上的接口。
|
||||
// auth-settings 模块向 ServerContext 提供的基础服务:ctx.secrets / ctx.settings。
|
||||
|
||||
/** 占位:W2-A 实现 AES-GCM 加密存取(secret 表,SECRET_MASTER_KEY) */
|
||||
export function createSecretStore(_deps: { db: Kysely<DB>; masterKey: string }): SecretStore {
|
||||
const notYet = async (): Promise<never> => {
|
||||
throw new Error("SecretStore 尚未实现(W2-A)");
|
||||
};
|
||||
return { resolve: notYet, create: notYet, replace: notYet, remove: notYet };
|
||||
// ── 密钥服务 ──
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
/** settings 表(key → jsonb)按分组合并缺省值;每次读库,不缓存(设置变更立即生效) */
|
||||
export function createSettingsReader(deps: { db: Kysely<DB>; logger: Logger }): SettingsReader {
|
||||
const all = async (): Promise<GlobalSettings> => {
|
||||
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> = {};
|
||||
@@ -29,5 +230,45 @@ export function createSettingsReader(deps: { db: Kysely<DB>; logger: Logger }):
|
||||
}
|
||||
return out as GlobalSettings;
|
||||
};
|
||||
return { all, get: async (key) => (await all())[key] };
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user