feat(auth): TOTP setup endpoint, recovery codes, passkeys and master key rotation
- GET /auth/totp/setup returns the pending secret and otpauth URL (created on first call, reused on refresh) for the console to render as a QR code. - Enabling TOTP issues 8 one-time recovery codes (argon2id hashes in admin_recovery_code); login accepts recoveryCode in place of totpCode, disable accepts either, and recovery_codes regenerates with the password. - Passkeys via @simplewebauthn/server: register/list/rename/delete under the console session, public login options/verify that reuse the login rate limit and session cookies. Challenges are one-time rows in webauthn_challenge; RP ID and origins come from WEBAUTHN_RP_ID / WEBAUTHN_ORIGINS, falling back to the request host. Signature counter regressions are rejected. SessionInfo gains passkeyEnabled. - --role rotate-master-key re-encrypts the secret table from SECRET_MASTER_KEY to SECRET_MASTER_KEY_NEW one row per transaction, skips rows already on the new key (resumable, swap keys to roll back), logs progress and audits secret.master_key_rotate. Secrets now record the master key fingerprint in kdf_params.kid. - Runbook: recovery codes, passkey configuration, rotation steps and rollback. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
5c6f483420
commit
b0e5d539ed
21 files changed
+1616
-80
No files matched your search
@@ -0,0 +1,101 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { sql } from "@umt/db";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createSecretStore,
|
||||
masterKeyId,
|
||||
rotateMasterKey,
|
||||
SecretError,
|
||||
} from "../../src/modules/auth-settings/provided.ts";
|
||||
import { memoryLogger } from "../helpers.ts";
|
||||
import { createHarness, type Harness, MASTER_KEY, PG_URL, pgAvailable } from "./harness.ts";
|
||||
|
||||
// 主密钥轮换(--role rotate-master-key):逐行事务重新加密、幂等重跑、解不开的行不动、新旧对调即回滚。
|
||||
// 每个用例一个临时 schema(harness),不碰开发库的 public。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const NEW_KEY = "rotated-master-key-abcdefghijkl";
|
||||
|
||||
describe.skipIf(!hasPg)("主密钥轮换(临时库)", () => {
|
||||
let h: Harness;
|
||||
beforeAll(async () => {
|
||||
h = await createHarness();
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("旧密钥 → 新密钥:全部换新、幂等、解不开的行原样保留;对调重跑即回滚", async () => {
|
||||
const oldStore = createSecretStore({ db: h.db, masterKey: MASTER_KEY });
|
||||
const refs: string[] = [];
|
||||
for (let i = 0; i < 7; i++)
|
||||
refs.push(await oldStore.create({ purpose: "site_cookie", plaintext: `cookie-${i}`, label: `s${i}` }));
|
||||
// 一条用别的密钥写的"坏行"(新旧都解不开)
|
||||
const bad = await createSecretStore({ db: h.db, masterKey: "some-unknown-master-key-xx" }).create({
|
||||
purpose: "other",
|
||||
plaintext: "lost",
|
||||
});
|
||||
const before = await sql<{
|
||||
id: string;
|
||||
ciphertext: Buffer;
|
||||
}>`select id, ciphertext from secret where id = ${bad}`.execute(h.db);
|
||||
|
||||
const { logger, lines } = memoryLogger();
|
||||
const r1 = await rotateMasterKey({ db: h.db, logger }, MASTER_KEY, NEW_KEY, { progressEvery: 3 });
|
||||
expect(r1).toEqual({ total: 8, rotated: 7, skipped: 0, failed: [bad] });
|
||||
// 进度日志:每 3 行一条 + 结尾;不含明文
|
||||
expect(lines.filter((l) => l.includes("主密钥轮换进度")).length).toBeGreaterThanOrEqual(3);
|
||||
expect(lines.some((l) => l.includes("cookie-"))).toBe(false);
|
||||
|
||||
const newStore = createSecretStore({ db: h.db, masterKey: NEW_KEY });
|
||||
for (let i = 0; i < refs.length; i++) expect(await newStore.resolve(refs[i]!)).toBe(`cookie-${i}`);
|
||||
await expect(oldStore.resolve(refs[0]!)).rejects.toBeInstanceOf(SecretError);
|
||||
const kids = await sql<{ kid: string }>`select kdf_params->>'kid' as kid from secret where id <> ${bad}`.execute(
|
||||
h.db,
|
||||
);
|
||||
expect(new Set(kids.rows.map((k) => k.kid))).toEqual(new Set([masterKeyId(NEW_KEY)]));
|
||||
const after = await sql<{ ciphertext: Buffer }>`select ciphertext from secret where id = ${bad}`.execute(h.db);
|
||||
expect(Buffer.compare(after.rows[0]!.ciphertext, before.rows[0]!.ciphertext)).toBe(0);
|
||||
|
||||
// 中断后重跑:已换过的跳过
|
||||
const r2 = await rotateMasterKey({ db: h.db, logger }, MASTER_KEY, NEW_KEY);
|
||||
expect(r2).toEqual({ total: 8, rotated: 0, skipped: 7, failed: [bad] });
|
||||
const audit = await sql<{ payload: { rotated: number } }>`
|
||||
select payload from audit_event where action = 'secret.master_key_rotate' order by at, id`.execute(h.db);
|
||||
expect(audit.rows.map((a) => a.payload.rotated)).toEqual([7, 0]);
|
||||
|
||||
// 回滚:新旧对调
|
||||
const back = await rotateMasterKey({ db: h.db, logger }, NEW_KEY, MASTER_KEY);
|
||||
expect(back).toMatchObject({ rotated: 7, skipped: 0 });
|
||||
expect(await oldStore.resolve(refs[3]!)).toBe("cookie-3");
|
||||
await expect(rotateMasterKey({ db: h.db, logger }, NEW_KEY, NEW_KEY)).rejects.toThrow(/相同/);
|
||||
await sql`delete from secret where id = ${bad}`.execute(h.db);
|
||||
});
|
||||
|
||||
it("命令行 --role rotate-master-key:读 SECRET_MASTER_KEY_NEW;缺少时退出码 2", async () => {
|
||||
const store = createSecretStore({ db: h.db, masterKey: MASTER_KEY });
|
||||
const ref = await store.create({ purpose: "downloader_password", plaintext: "qb-pass" });
|
||||
const main = fileURLToPath(new URL("../../src/main.ts", import.meta.url));
|
||||
const url = `${PG_URL}${PG_URL.includes("?") ? "&" : "?"}options=${encodeURIComponent(`-c search_path=${h.schema},public`)}`;
|
||||
const run = (extra: Record<string, string>) =>
|
||||
spawnSync("deno", ["run", "-A", main, "--role", "rotate-master-key"], {
|
||||
env: {
|
||||
PATH: process.env.PATH ?? "",
|
||||
HOME: process.env.HOME ?? "",
|
||||
DATABASE_URL: url,
|
||||
SECRET_MASTER_KEY: MASTER_KEY,
|
||||
LOG_LEVEL: "info",
|
||||
DATA_DIR: "/tmp",
|
||||
...extra,
|
||||
},
|
||||
encoding: "utf8",
|
||||
timeout: 120_000,
|
||||
});
|
||||
expect(run({}).status).toBe(2);
|
||||
const ok = run({ SECRET_MASTER_KEY_NEW: NEW_KEY });
|
||||
expect(ok.status, ok.stderr + ok.stdout).toBe(0);
|
||||
expect(ok.stdout).toContain("主密钥轮换完成");
|
||||
expect(await createSecretStore({ db: h.db, masterKey: NEW_KEY }).resolve(ref)).toBe("qb-pass");
|
||||
}, 180_000);
|
||||
});
|
||||
Reference in new issue
Block a user