Files
rootandClaude Opus 5.5 ff230e5da7 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]>
2026-09-27 19:59:54 +08:00

98 lines
4.8 KiB
TypeScript

import { sql } from "@umt/db";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
base32Decode,
base32Encode,
hashPassword,
totpAt,
verifyPassword,
verifyTotp,
} from "../../src/modules/auth-settings/crypto.ts";
import { createSecretStore, SecretError } from "../../src/modules/auth-settings/provided.ts";
import { createHarness, type Harness, MASTER_KEY, pgAvailable } from "./harness.ts";
describe("密码学原语", () => {
it("argon2id(hash-wasm)输出标准 PHC 串,校验正反", async () => {
const h = await hashPassword("pässwörd-123");
expect(h).toMatch(/^\$argon2id\$v=19\$m=19456,t=2,p=1\$/);
expect(await verifyPassword("pässwörd-123", h)).toBe(true);
expect(await verifyPassword("password-123", h)).toBe(false);
expect(await verifyPassword("x", "not-a-hash")).toBe(false);
});
it("TOTP:RFC 6238 测试向量(SHA1)+ 防重放", () => {
const secret = base32Encode(Buffer.from("12345678901234567890"));
expect(base32Decode(secret).toString()).toBe("12345678901234567890");
// RFC 6238 附录 B:T=59s → 94287082(8 位),6 位取后 6 位
expect(totpAt(secret, 1)).toBe("287082");
expect(totpAt(secret, Math.floor(1111111109 / 30))).toBe("081804");
const now = 1_700_000_000_000;
const c = Math.floor(now / 30_000);
expect(verifyTotp(secret, totpAt(secret, c), null, now)).toBe(c);
expect(verifyTotp(secret, totpAt(secret, c - 1), null, now)).toBe(c - 1);
expect(verifyTotp(secret, totpAt(secret, c), c, now)).toBeNull();
expect(verifyTotp(secret, totpAt(secret, c + 3), null, now)).toBeNull();
});
});
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("SecretStore(真实 PG)", () => {
let h: Harness;
beforeAll(async () => {
h = await createHarness();
}, 60_000);
afterAll(async () => {
await h?.close();
});
it("create/resolve/replace(rotate)/remove 往返;库里无明文;每次加密盐与 nonce 不同", async () => {
const s = h.ctx.secrets;
const ref = await s.create({ purpose: "site_cookie", plaintext: "uid=1; pass=topsecret" });
expect(await s.resolve(ref)).toBe("uid=1; pass=topsecret");
const raw = await sql<{ ciphertext: Buffer; nonce: Buffer; kdf_params: { salt: string } }>`
select ciphertext, nonce, kdf_params from secret where id = ${ref}`.execute(h.db);
const before = raw.rows[0]!;
expect(before.ciphertext.toString("utf8")).not.toContain("topsecret");
await s.replace(ref, "uid=1; pass=rotated");
expect(await s.resolve(ref)).toBe("uid=1; pass=rotated");
const after = (
await sql<{ nonce: Buffer; kdf_params: { salt: string }; rotated_at: Date | null }>`
select nonce, kdf_params, rotated_at from secret where id = ${ref}`.execute(h.db)
).rows[0]!;
expect(after.nonce.equals(before.nonce)).toBe(false);
expect(after.kdf_params.salt).not.toBe(before.kdf_params.salt);
expect(after.rotated_at).toBeTruthy();
await s.remove(ref);
await expect(s.resolve(ref)).rejects.toBeInstanceOf(SecretError);
});
it("主密钥不对 / 密文挪到别的行 / 改用途 → 解密失败,错误信息不含明文", async () => {
const s = h.ctx.secrets;
const a = await s.create({ purpose: "site_api_key", plaintext: "alpha-plain" });
const b = await s.create({ purpose: "site_api_key", plaintext: "bravo-plain" });
const other = createSecretStore({ db: h.db, masterKey: `${MASTER_KEY}-other` });
await expect(other.resolve(a)).rejects.toThrow(/解密失败/);
await sql`update secret set ciphertext = (select ciphertext from secret where id = ${a}),
nonce = (select nonce from secret where id = ${a}), auth_tag = (select auth_tag from secret where id = ${a}),
kdf_params = (select kdf_params from secret where id = ${a}) where id = ${b}`.execute(h.db);
const err = await s.resolve(b).catch((e: Error) => e);
expect(err).toBeInstanceOf(SecretError);
expect(String((err as Error).message)).not.toMatch(/alpha|bravo/);
await sql`update secret set purpose = 'other' where id = ${a}`.execute(h.db);
await expect(s.resolve(a)).rejects.toThrow(/解密失败/);
await expect(s.resolve("not-a-uuid")).rejects.toThrow(/不存在/);
});
it("resolve 出的明文经 logger 记录时也不会以敏感字段名泄漏;SecretError 进日志只有 ref", async () => {
const ref = await h.ctx.secrets.create({ purpose: "downloader_password", plaintext: "qb-pass-777" });
const password = await h.ctx.secrets.resolve(ref);
h.ctx.logger.info({ password, secretRef: ref }, "连接下载器");
const err = await h.ctx.secrets.resolve("00000000-0000-4000-8000-000000000000").catch((e: Error) => e);
h.ctx.logger.error({ err }, "取凭证失败");
const out = h.lines.join("\n");
expect(out).not.toContain("qb-pass-777");
expect(out).toContain("[REDACTED]");
});
});