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:
rootandClaude Opus 5.5 committed 2026-09-28 20:22:07 +08:00
1 parent 5c6f483420
commit b0e5d539ed
21 files changed
+1616 -80

No files matched your search

@@ -1,4 +1,4 @@
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto";
import { createCipheriv, createDecipheriv, createHmac, 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";
@@ -12,7 +12,7 @@ import { deriveKey } from "./crypto.ts";
export const SECRET_ALGORITHM = "aes-256-gcm";
export const SECRET_KDF = "hkdf-sha256";
/** 当前主密钥版本;轮换主密钥时 +1,并用 rewrapAll 重新加密 */
/** 密文格式版本(HKDF info = umt/secret/v<版本>);主密钥轮换不改它,换哪把主密钥看 kdf_params.kid */
export const SECRET_KEY_VERSION = 1;
/** 找不到 secret 或无法解密;消息里只有 ref,不含任何明文 */
@@ -52,9 +52,14 @@ interface Sealed {
ciphertext: Buffer;
nonce: Buffer;
authTag: Buffer;
kdfParams: { salt: string; info: string };
/** 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) {
@@ -72,7 +77,12 @@ function seal(masterKey: string, id: string, purpose: string, plaintext: string)
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 } };
return {
ciphertext,
nonce,
authTag: cipher.getAuthTag(),
kdfParams: { salt: salt.toString("base64"), info, kid: masterKeyId(masterKey) },
};
}
function open(
@@ -188,6 +198,108 @@ export function createSecretStore(deps: { db: Kysely<DB>; masterKey: string }):
};
}
// ── 主密钥轮换(--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 / 进程间广播的设置变更事件 */