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
@@ -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 / 进程间广播的设置变更事件 */
|
||||
|
||||
Reference in new issue
Block a user