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
@@ -6,20 +6,36 @@ import { ApiException } from "../../http/errors.ts";
|
||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||
import {
|
||||
adminActor,
|
||||
assertNotRateLimited,
|
||||
type CreatedSession,
|
||||
changePassword,
|
||||
createSession,
|
||||
loadAdmin,
|
||||
recordAttempt,
|
||||
revokeSession,
|
||||
sessionInfo,
|
||||
setupAdmin,
|
||||
totpBegin,
|
||||
totpDisable,
|
||||
totpEnable,
|
||||
totpRegenerateRecoveryCodes,
|
||||
totpSetup,
|
||||
totpStatus,
|
||||
verifyLogin,
|
||||
} from "./auth-service.ts";
|
||||
import { audit, type Def, endpoint } from "./endpoint.ts";
|
||||
import {
|
||||
authenticationOptions,
|
||||
deletePasskey,
|
||||
listPasskeys,
|
||||
type RpInfo,
|
||||
registrationOptions,
|
||||
renamePasskey,
|
||||
resolveRp,
|
||||
toPasskeyInfo,
|
||||
verifyAuthentication,
|
||||
verifyRegistration,
|
||||
} from "./passkey.ts";
|
||||
|
||||
// 登录门禁端点:/auth/setup、/auth/login、/auth/session 免鉴权(见 http/auth.ts PUBLIC_AUTH_PATHS),
|
||||
// 其余需要已登录;改密与 TOTP 只接受控制台 cookie 会话。
|
||||
@@ -36,8 +52,19 @@ const D = {
|
||||
password: { ...Api.auth.password, tags },
|
||||
totpGet: { ...Api.auth.totpStatus, tags },
|
||||
totpPost: { ...Api.auth.totp, tags },
|
||||
totpSetup: { ...Api.auth.totpSetup, tags },
|
||||
passkeys: { ...Api.auth.passkeys, tags },
|
||||
passkeyRegisterOptions: { ...Api.auth.passkeyRegisterOptions, tags },
|
||||
passkeyRegister: { ...Api.auth.passkeyRegister, tags },
|
||||
passkeyRename: { ...Api.auth.passkeyRename, tags },
|
||||
passkeyDelete: { ...Api.auth.passkeyDelete, tags },
|
||||
passkeyLoginOptions: { ...Api.auth.passkeyLoginOptions, tags },
|
||||
passkeyLogin: { ...Api.auth.passkeyLogin, tags },
|
||||
} satisfies Record<string, Def>;
|
||||
|
||||
/** Passkey 登录失败 / 限速计数用的账号维度键(免输入用户名) */
|
||||
const PASSKEY_ACCOUNT = "(passkey)";
|
||||
|
||||
const PRIVATE_IP = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1$|fc|fd|::ffff:(127|10|192\.168)\.)/i;
|
||||
|
||||
/**
|
||||
@@ -57,6 +84,13 @@ export function clientIp(c: Context<AuthEnv>): string {
|
||||
return xri || xff || remote || "unknown";
|
||||
}
|
||||
|
||||
/** 请求主机(反代后面才信 X-Forwarded-Host),Passkey 未配置 RP ID 时用它 */
|
||||
function requestHost(c: Context<AuthEnv>): string | null {
|
||||
const remote = (c.env as { remoteAddr?: { hostname?: string } } | undefined)?.remoteAddr?.hostname;
|
||||
const trusted = !remote || PRIVATE_IP.test(remote);
|
||||
return (trusted ? c.req.header("x-forwarded-host") : undefined) ?? c.req.header("host") ?? new URL(c.req.url).host;
|
||||
}
|
||||
|
||||
function isHttps(c: Context<AuthEnv>): boolean {
|
||||
if (new URL(c.req.url).protocol === "https:") return true;
|
||||
const remote = (c.env as { remoteAddr?: { hostname?: string } } | undefined)?.remoteAddr?.hostname;
|
||||
@@ -106,6 +140,10 @@ export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
const s = await startSession(c, r.admin.id);
|
||||
const actor = adminActor(r.admin);
|
||||
await audit(ctx, actor, "admin.login_success", { type: "admin_session", id: s.id }, { ip: clientIp(c) });
|
||||
if (r.secondFactor === "recovery_code") {
|
||||
const left = (await totpStatus(ctx.db, r.admin)).recoveryCodesRemaining;
|
||||
await audit(ctx, actor, "admin.recovery_code_login", { type: "admin_user", id: r.admin.id }, { remaining: left });
|
||||
}
|
||||
return { status: "ok" as const, session: await sessionInfo(ctx.db, actor, s.expiresAt) };
|
||||
});
|
||||
|
||||
@@ -146,22 +184,103 @@ export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
return { revokedSessions: revoked };
|
||||
});
|
||||
|
||||
endpoint(app, D.totpGet, async () => totpStatus(await loadAdmin(ctx.db)));
|
||||
endpoint(app, D.totpGet, async () => totpStatus(ctx.db, await loadAdmin(ctx.db)));
|
||||
|
||||
endpoint(app, D.totpPost, async ({ body, actor }) => {
|
||||
const initializedAdmin = async () => {
|
||||
const admin = await loadAdmin(ctx.db);
|
||||
if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化");
|
||||
return admin;
|
||||
};
|
||||
|
||||
endpoint(app, D.totpSetup, async () => {
|
||||
const r = await totpSetup(deps(), await initializedAdmin());
|
||||
return { otpauthUri: r.uri, secret: r.secret, issuer: r.issuer, account: r.account };
|
||||
});
|
||||
|
||||
endpoint(app, D.totpPost, async ({ body, actor }) => {
|
||||
const admin = await initializedAdmin();
|
||||
const status = async () => totpStatus(ctx.db, await loadAdmin(ctx.db));
|
||||
if (body.action === "begin") {
|
||||
const r = await totpBegin(deps(), admin);
|
||||
return { status: totpStatus(await loadAdmin(ctx.db)), otpauthUri: r.uri, secret: r.secret };
|
||||
return { status: await status(), otpauthUri: r.uri, secret: r.secret };
|
||||
}
|
||||
if (body.action === "enable") {
|
||||
await totpEnable(deps(), admin, body.code);
|
||||
const codes = await totpEnable(deps(), admin, body.code);
|
||||
await audit(ctx, actor, "admin.totp_enable", { type: "admin_user", id: admin.id });
|
||||
} else {
|
||||
await totpDisable(deps(), admin, body);
|
||||
await audit(ctx, actor, "admin.totp_disable", { type: "admin_user", id: admin.id });
|
||||
return { status: await status(), recoveryCodes: codes };
|
||||
}
|
||||
return { status: totpStatus(await loadAdmin(ctx.db)) };
|
||||
if (body.action === "recovery_codes") {
|
||||
const codes = await totpRegenerateRecoveryCodes(deps(), admin, body);
|
||||
await audit(ctx, actor, "admin.recovery_codes_regenerate", { type: "admin_user", id: admin.id });
|
||||
return { status: await status(), recoveryCodes: codes };
|
||||
}
|
||||
await totpDisable(deps(), admin, body);
|
||||
await audit(ctx, actor, "admin.totp_disable", { type: "admin_user", id: admin.id });
|
||||
return { status: await status() };
|
||||
});
|
||||
|
||||
// ── Passkey ──
|
||||
|
||||
const rp = (c: Context<AuthEnv>): RpInfo => resolveRp(ctx.config ?? {}, requestHost(c));
|
||||
|
||||
endpoint(app, D.passkeys, async ({ c }) => {
|
||||
const session = c.get("session");
|
||||
if (!session) throw new ApiException("forbidden", "只能在控制台登录后操作");
|
||||
return { items: (await listPasskeys(ctx.db, session.adminUserId)).map(toPasskeyInfo) };
|
||||
});
|
||||
|
||||
endpoint(app, D.passkeyRegisterOptions, async ({ c }) =>
|
||||
registrationOptions(ctx.db, await initializedAdmin(), rp(c)),
|
||||
);
|
||||
|
||||
endpoint(
|
||||
app,
|
||||
D.passkeyRegister,
|
||||
async ({ body, c, actor }) => {
|
||||
const admin = await initializedAdmin();
|
||||
const row = await verifyRegistration(ctx.db, admin, rp(c), body);
|
||||
await audit(ctx, actor, "admin.passkey_register", { type: "admin_passkey", id: row.id }, { name: row.name });
|
||||
return toPasskeyInfo(row);
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
|
||||
endpoint(app, D.passkeyRename, async ({ params, body, actor }) => {
|
||||
const admin = await initializedAdmin();
|
||||
const row = await renamePasskey(ctx.db, admin.id, params.id, body.name);
|
||||
await audit(ctx, actor, "admin.passkey_rename", { type: "admin_passkey", id: row.id }, { name: row.name });
|
||||
return toPasskeyInfo(row);
|
||||
});
|
||||
|
||||
endpoint(app, D.passkeyDelete, async ({ params, actor }) => {
|
||||
const admin = await initializedAdmin();
|
||||
const row = await deletePasskey(ctx.db, admin.id, params.id);
|
||||
await audit(ctx, actor, "admin.passkey_remove", { type: "admin_passkey", id: row.id }, { name: row.name });
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
endpoint(app, D.passkeyLoginOptions, async ({ c }) => {
|
||||
await assertNotRateLimited(ctx.db, clientIp(c), PASSKEY_ACCOUNT);
|
||||
return authenticationOptions(ctx.db, await initializedAdmin(), rp(c));
|
||||
});
|
||||
|
||||
// 与密码登录共用限速(IP + "(passkey)" 账号维度)与会话;Passkey 本身是强第一因子,不再要求 TOTP
|
||||
endpoint(app, D.passkeyLogin, async ({ body, c }) => {
|
||||
const ip = clientIp(c);
|
||||
await assertNotRateLimited(ctx.db, ip, PASSKEY_ACCOUNT);
|
||||
const admin = await initializedAdmin();
|
||||
let key: Awaited<ReturnType<typeof verifyAuthentication>>;
|
||||
try {
|
||||
key = await verifyAuthentication(ctx.db, admin, rp(c), body);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiException && (err.code === "unauthorized" || err.code === "bad_request"))
|
||||
await recordAttempt(ctx.db, { ip, username: PASSKEY_ACCOUNT, success: false, reason: "bad_passkey" });
|
||||
throw err;
|
||||
}
|
||||
await recordAttempt(ctx.db, { ip, username: PASSKEY_ACCOUNT, success: true });
|
||||
const s = await startSession(c, admin.id);
|
||||
const actor = adminActor(admin);
|
||||
await audit(ctx, actor, "admin.passkey_login", { type: "admin_session", id: s.id }, { ip, passkeyId: key.id });
|
||||
return { status: "ok" as const, session: await sessionInfo(ctx.db, actor, s.expiresAt) };
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { Actor, SessionInfo } from "@umt/contracts";
|
||||
import { type Actor, RECOVERY_CODE_COUNT, type SessionInfo, type TotpStatus } from "@umt/contracts";
|
||||
import { type DB, type Kysely, sql } from "@umt/db";
|
||||
import type { Logger } from "pino";
|
||||
import { hashSessionToken } from "../../http/auth.ts";
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
|
||||
// 管理员登录门禁的业务逻辑(照 docs/design/auth-reference.md):
|
||||
// setup token 一次性初始化、argon2id、服务端会话(sha256 存库、固定 TTL、软删除吊销)、
|
||||
// IP + 账号双维度失败限速(落表)、改密吊销其它会话、可选 TOTP(防重放计数器)。
|
||||
// IP + 账号双维度失败限速(落表)、改密吊销其它会话、可选 TOTP(防重放计数器)+ 一次性恢复码(argon2id 哈希存库)。
|
||||
// Passkey 见 passkey.ts。
|
||||
|
||||
/** 固定会话时长 12 小时,无滑动续期 */
|
||||
export const SESSION_TTL_MS = 12 * 3600_000;
|
||||
@@ -197,11 +198,13 @@ export async function setupAdmin(
|
||||
return admin;
|
||||
}
|
||||
|
||||
export type LoginOutcome = { status: "ok"; admin: AdminRow } | { status: "totp_required" };
|
||||
export type LoginOutcome =
|
||||
| { status: "ok"; admin: AdminRow; secondFactor: "none" | "totp" | "recovery_code" }
|
||||
| { status: "totp_required" };
|
||||
|
||||
export async function verifyLogin(
|
||||
deps: AuthDeps,
|
||||
input: { username: string; password: string; totpCode?: string | undefined },
|
||||
input: { username: string; password: string; totpCode?: string | undefined; recoveryCode?: string | undefined },
|
||||
ip: string,
|
||||
): Promise<LoginOutcome> {
|
||||
await assertNotRateLimited(deps.db, ip, input.username);
|
||||
@@ -216,16 +219,87 @@ export async function verifyLogin(
|
||||
await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_password" });
|
||||
throw new ApiException("unauthorized", "用户名或密码错误");
|
||||
}
|
||||
let secondFactor: "none" | "totp" | "recovery_code" = "none";
|
||||
if (admin.totp_enabled_at && admin.totp_secret_ref) {
|
||||
// 密码正确但缺第二步:不计失败
|
||||
if (!input.totpCode) return { status: "totp_required" };
|
||||
if (!(await consumeTotp(deps, admin, input.totpCode))) {
|
||||
await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_totp" });
|
||||
throw new ApiException("unauthorized", "验证码错误");
|
||||
if (!input.totpCode && !input.recoveryCode) return { status: "totp_required" };
|
||||
if (input.totpCode) {
|
||||
if (!(await consumeTotp(deps, admin, input.totpCode))) {
|
||||
await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_totp" });
|
||||
throw new ApiException("unauthorized", "验证码错误");
|
||||
}
|
||||
secondFactor = "totp";
|
||||
} else {
|
||||
if (!(await consumeRecoveryCode(deps.db, admin.id, input.recoveryCode ?? ""))) {
|
||||
await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_recovery_code" });
|
||||
throw new ApiException("unauthorized", "恢复码错误或已用过");
|
||||
}
|
||||
secondFactor = "recovery_code";
|
||||
}
|
||||
}
|
||||
await recordAttempt(deps.db, { ip, username: input.username, success: true });
|
||||
return { status: "ok", admin };
|
||||
return { status: "ok", admin, secondFactor };
|
||||
}
|
||||
|
||||
// ── 恢复码 ──
|
||||
|
||||
const RECOVERY_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
/** 恢复码归一化:去空格与连字符、转大写(展示为 XXXXX-XXXXX,输入时怎么写都行) */
|
||||
export const normalizeRecoveryCode = (code: string) => code.replace(/[\s-]+/g, "").toUpperCase();
|
||||
|
||||
function newRecoveryCode(): string {
|
||||
const bytes = randomBytes(10);
|
||||
let out = "";
|
||||
for (const b of bytes) out += RECOVERY_ALPHABET[b % RECOVERY_ALPHABET.length];
|
||||
return `${out.slice(0, 5)}-${out.slice(5)}`;
|
||||
}
|
||||
|
||||
/** 重新生成恢复码(旧的全部作废);返回明文(只此一次),库里只存 argon2id 哈希 */
|
||||
export async function regenerateRecoveryCodes(db: Kysely<DB>, adminUserId: string): Promise<string[]> {
|
||||
const codes = Array.from({ length: RECOVERY_CODE_COUNT }, newRecoveryCode);
|
||||
const hashes = await Promise.all(codes.map((c) => hashPassword(normalizeRecoveryCode(c))));
|
||||
await db.transaction().execute(async (trx) => {
|
||||
await trx.deleteFrom("admin_recovery_code").where("admin_user_id", "=", adminUserId).execute();
|
||||
await trx
|
||||
.insertInto("admin_recovery_code")
|
||||
.values(hashes.map((h) => ({ admin_user_id: adminUserId, code_hash: h })))
|
||||
.execute();
|
||||
});
|
||||
return codes;
|
||||
}
|
||||
|
||||
/** 校验并作废一个恢复码(条件更新:并发提交同一个码只有一个成功) */
|
||||
export async function consumeRecoveryCode(db: Kysely<DB>, adminUserId: string, code: string): Promise<boolean> {
|
||||
const norm = normalizeRecoveryCode(code);
|
||||
if (norm.length < 10) return false;
|
||||
const rows = await db
|
||||
.selectFrom("admin_recovery_code")
|
||||
.select(["id", "code_hash"])
|
||||
.where("admin_user_id", "=", adminUserId)
|
||||
.where("used_at", "is", null)
|
||||
.execute();
|
||||
for (const r of rows) {
|
||||
if (!(await verifyPassword(norm, r.code_hash))) continue;
|
||||
const u = await db
|
||||
.updateTable("admin_recovery_code")
|
||||
.set({ used_at: new Date() })
|
||||
.where("id", "=", r.id)
|
||||
.where("used_at", "is", null)
|
||||
.executeTakeFirst();
|
||||
return Number(u.numUpdatedRows) > 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function remainingRecoveryCodes(db: Kysely<DB>, adminUserId: string): Promise<number> {
|
||||
const r = await db
|
||||
.selectFrom("admin_recovery_code")
|
||||
.select(sql<number>`count(*)::int`.as("n"))
|
||||
.where("admin_user_id", "=", adminUserId)
|
||||
.where("used_at", "is", null)
|
||||
.executeTakeFirst();
|
||||
return r?.n ?? 0;
|
||||
}
|
||||
|
||||
/** 校验并消费 TOTP 计数器(条件更新防并发重放) */
|
||||
@@ -267,23 +341,55 @@ export async function changePassword(
|
||||
|
||||
// ── TOTP 管理 ──
|
||||
|
||||
export function totpStatus(admin: AdminRow | null) {
|
||||
return { enabled: !!admin?.totp_enabled_at, pending: !!admin?.totp_pending_secret_ref };
|
||||
export async function totpStatus(db: Kysely<DB>, admin: AdminRow | null): Promise<TotpStatus> {
|
||||
const enabled = !!admin?.totp_enabled_at;
|
||||
return {
|
||||
enabled,
|
||||
pending: !!admin?.totp_pending_secret_ref,
|
||||
recoveryCodesRemaining: enabled && admin ? await remainingRecoveryCodes(db, admin.id) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function totpBegin(deps: AuthDeps, admin: AdminRow): Promise<{ uri: string; secret: string }> {
|
||||
export interface TotpSetupInfo {
|
||||
uri: string;
|
||||
secret: string;
|
||||
issuer: string;
|
||||
account: string;
|
||||
}
|
||||
|
||||
function otpauthUri(secret: string, account: string): string {
|
||||
const label = encodeURIComponent(`${TOTP_ISSUER}:${account}`);
|
||||
return `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(TOTP_ISSUER)}&algorithm=SHA1&digits=6&period=30`;
|
||||
}
|
||||
|
||||
export async function totpBegin(deps: AuthDeps, admin: AdminRow): Promise<TotpSetupInfo> {
|
||||
if (admin.totp_enabled_at) throw new ApiException("conflict", "TOTP 已启用,先停用再重新绑定");
|
||||
const secret = base32Encode(randomBytes(20));
|
||||
const ref = await deps.secrets.create({ purpose: "other", plaintext: secret, label: `admin-totp:${randomToken(6)}` });
|
||||
const old = admin.totp_pending_secret_ref;
|
||||
await deps.db.updateTable("admin_user").set({ totp_pending_secret_ref: ref }).where("id", "=", admin.id).execute();
|
||||
if (old) await deps.secrets.remove(old).catch(() => {});
|
||||
const label = encodeURIComponent(`${TOTP_ISSUER}:${admin.username ?? "admin"}`);
|
||||
const uri = `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(TOTP_ISSUER)}&algorithm=SHA1&digits=6&period=30`;
|
||||
return { uri, secret };
|
||||
const account = admin.username ?? "admin";
|
||||
return { uri: otpauthUri(secret, account), secret, issuer: TOTP_ISSUER, account };
|
||||
}
|
||||
|
||||
export async function totpEnable(deps: AuthDeps, admin: AdminRow, code: string): Promise<void> {
|
||||
/** GET /auth/totp/setup:已有未确认的密钥就原样返回(刷新页面不作废已扫的码),没有才生成 */
|
||||
export async function totpSetup(deps: AuthDeps, admin: AdminRow): Promise<TotpSetupInfo> {
|
||||
if (admin.totp_enabled_at) throw new ApiException("conflict", "TOTP 已启用,先停用再重新绑定");
|
||||
if (admin.totp_pending_secret_ref) {
|
||||
try {
|
||||
const secret = await deps.secrets.resolve(admin.totp_pending_secret_ref);
|
||||
const account = admin.username ?? "admin";
|
||||
return { uri: otpauthUri(secret, account), secret, issuer: TOTP_ISSUER, account };
|
||||
} catch {
|
||||
// 待确认密钥读不出来(被删 / 主密钥不匹配):重新生成
|
||||
}
|
||||
}
|
||||
return totpBegin(deps, admin);
|
||||
}
|
||||
|
||||
/** 确认开启;返回新生成的恢复码明文(只此一次) */
|
||||
export async function totpEnable(deps: AuthDeps, admin: AdminRow, code: string): Promise<string[]> {
|
||||
if (!admin.totp_pending_secret_ref) throw new ApiException("precondition_failed", "先生成 TOTP 密钥");
|
||||
const secret = await deps.secrets.resolve(admin.totp_pending_secret_ref);
|
||||
const counter = verifyTotp(secret, code, null);
|
||||
@@ -300,6 +406,20 @@ export async function totpEnable(deps: AuthDeps, admin: AdminRow, code: string):
|
||||
.where("id", "=", admin.id)
|
||||
.execute();
|
||||
if (old) await deps.secrets.remove(old).catch(() => {});
|
||||
return await regenerateRecoveryCodes(deps.db, admin.id);
|
||||
}
|
||||
|
||||
/** 用当前密码重新生成恢复码(TOTP 必须已开启) */
|
||||
export async function totpRegenerateRecoveryCodes(
|
||||
deps: AuthDeps,
|
||||
admin: AdminRow,
|
||||
input: { password: string },
|
||||
): Promise<string[]> {
|
||||
if (!admin.totp_enabled_at) throw new ApiException("precondition_failed", "TOTP 未启用");
|
||||
if (!admin.password_hash || !(await verifyPassword(input.password, admin.password_hash))) {
|
||||
throw new ApiException("unauthorized", "密码错误");
|
||||
}
|
||||
return await regenerateRecoveryCodes(deps.db, admin.id);
|
||||
}
|
||||
|
||||
export async function totpDisable(
|
||||
@@ -311,13 +431,18 @@ export async function totpDisable(
|
||||
if (!admin.password_hash || !(await verifyPassword(input.password, admin.password_hash))) {
|
||||
throw new ApiException("unauthorized", "密码错误");
|
||||
}
|
||||
if (!(await consumeTotp(deps, admin, input.code))) throw new ApiException("unauthorized", "验证码错误");
|
||||
// 6 位数字按验证码校验,其余按恢复码(验证器丢了也能关)
|
||||
const ok = /^\d{6}$/.test(input.code)
|
||||
? await consumeTotp(deps, admin, input.code)
|
||||
: await consumeRecoveryCode(deps.db, admin.id, input.code);
|
||||
if (!ok) throw new ApiException("unauthorized", "验证码错误");
|
||||
const ref = admin.totp_secret_ref;
|
||||
await deps.db
|
||||
.updateTable("admin_user")
|
||||
.set({ totp_secret_ref: null, totp_enabled_at: null, totp_last_counter: null })
|
||||
.where("id", "=", admin.id)
|
||||
.execute();
|
||||
await deps.db.deleteFrom("admin_recovery_code").where("admin_user_id", "=", admin.id).execute();
|
||||
await deps.secrets.remove(ref).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -333,5 +458,8 @@ export async function sessionInfo(
|
||||
actor: actor ?? null,
|
||||
expiresAt: expiresAt?.toISOString() ?? null,
|
||||
totpEnabled: !!admin?.totp_enabled_at,
|
||||
passkeyEnabled: admin?.password_hash
|
||||
? !!(await db.selectFrom("admin_passkey").select("id").where("admin_user_id", "=", admin.id).executeTakeFirst())
|
||||
: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
type AuthenticationResponseJSON,
|
||||
type AuthenticatorTransportFuture,
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
type RegistrationResponseJSON,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import type { PasskeyChallenge, PasskeyInfo } from "@umt/contracts";
|
||||
import { type AdminPasskeyRow, type DB, type Kysely, sql } from "@umt/db";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { AdminRow } from "./auth-service.ts";
|
||||
|
||||
// Passkey(WebAuthn,照 uhd_doc src/server/admin-auth/passkey.ts):替代密码的第一因子,
|
||||
// 登录成功直接建会话(不再要求 TOTP)。@simplewebauthn/server 做两个 ceremony;
|
||||
// 挑战存 webauthn_challenge 表(一次性、5 分钟有效、条件更新消费),可以登记多把并命名。
|
||||
// RP ID 取配置 WEBAUTHN_RP_ID,不设时取请求主机名;来源取配置 WEBAUTHN_ORIGINS,不设时接受主机名属于 RP ID 的来源。
|
||||
|
||||
export const CHALLENGE_TTL_MS = 5 * 60_000;
|
||||
|
||||
export interface RpInfo {
|
||||
rpID: string;
|
||||
rpName: string;
|
||||
/** 配置的允许来源;null = 按 RP ID 校验客户端上报的来源 */
|
||||
origins: string[] | null;
|
||||
}
|
||||
|
||||
/** 由配置 + 请求主机名得到 RP 信息(纯函数,便于测试) */
|
||||
export function resolveRp(
|
||||
cfg: { WEBAUTHN_RP_ID?: string | undefined; WEBAUTHN_ORIGINS?: string | undefined; WEBAUTHN_RP_NAME?: string },
|
||||
requestHost: string | null | undefined,
|
||||
): RpInfo {
|
||||
const host = (requestHost ?? "").split(",")[0]?.trim() ?? "";
|
||||
const hostname = host
|
||||
.replace(/:\d+$/, "")
|
||||
.replace(/^\[|\]$/g, "")
|
||||
.toLowerCase();
|
||||
const rpID = cfg.WEBAUTHN_RP_ID?.trim().toLowerCase() || hostname || "localhost";
|
||||
const origins = cfg.WEBAUTHN_ORIGINS
|
||||
? cfg.WEBAUTHN_ORIGINS.split(",")
|
||||
.map((s) => s.trim().replace(/\/+$/, ""))
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
return { rpID, rpName: cfg.WEBAUTHN_RP_NAME ?? "UHD Media Tool", origins: origins?.length ? origins : null };
|
||||
}
|
||||
|
||||
/** 客户端上报的来源(clientDataJSON.origin);没配置允许来源时,主机名必须等于 RP ID 或是它的子域 */
|
||||
function expectedOrigins(rp: RpInfo, response: { response?: { clientDataJSON?: unknown } }): string[] {
|
||||
if (rp.origins) return rp.origins;
|
||||
let origin = "";
|
||||
try {
|
||||
const raw = String(response.response?.clientDataJSON ?? "");
|
||||
origin = String((JSON.parse(Buffer.from(raw, "base64url").toString("utf8")) as { origin?: unknown }).origin ?? "");
|
||||
} catch {
|
||||
throw new ApiException("bad_request", "Passkey 响应格式不正确");
|
||||
}
|
||||
let hostname = "";
|
||||
try {
|
||||
hostname = new URL(origin).hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
} catch {
|
||||
throw new ApiException("unauthorized", "Passkey 来源无效");
|
||||
}
|
||||
if (hostname !== rp.rpID && !hostname.endsWith(`.${rp.rpID}`))
|
||||
throw new ApiException("unauthorized", `Passkey 来源 ${origin} 与站点 ${rp.rpID} 不符`);
|
||||
return [origin];
|
||||
}
|
||||
|
||||
// ── 挑战 ──
|
||||
|
||||
async function saveChallenge(
|
||||
db: Kysely<DB>,
|
||||
purpose: "register" | "login",
|
||||
challenge: string,
|
||||
adminUserId: string | null,
|
||||
): Promise<string> {
|
||||
// 顺手清理过期挑战
|
||||
await db
|
||||
.deleteFrom("webauthn_challenge")
|
||||
.where("expires_at", "<", new Date(Date.now() - CHALLENGE_TTL_MS))
|
||||
.execute();
|
||||
const r = await db
|
||||
.insertInto("webauthn_challenge")
|
||||
.values({ purpose, challenge, admin_user_id: adminUserId, expires_at: new Date(Date.now() + CHALLENGE_TTL_MS) })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
return r.id;
|
||||
}
|
||||
|
||||
/** 取出并作废挑战(一次性);过期 / 用过 / 用途不符返回 null */
|
||||
async function takeChallenge(
|
||||
db: Kysely<DB>,
|
||||
id: string,
|
||||
purpose: "register" | "login",
|
||||
adminUserId: string | null,
|
||||
): Promise<string | null> {
|
||||
let q = db
|
||||
.updateTable("webauthn_challenge")
|
||||
.set({ used_at: new Date() })
|
||||
.where("id", "=", id)
|
||||
.where("purpose", "=", purpose)
|
||||
.where("used_at", "is", null)
|
||||
.where("expires_at", ">", new Date());
|
||||
if (adminUserId) q = q.where("admin_user_id", "=", adminUserId);
|
||||
const r = await q.returning("challenge").executeTakeFirst();
|
||||
return r?.challenge ?? null;
|
||||
}
|
||||
|
||||
// ── 登记的 Passkey ──
|
||||
|
||||
const transportsOf = (v: unknown): AuthenticatorTransportFuture[] =>
|
||||
Array.isArray(v) ? (v.filter((x) => typeof x === "string") as AuthenticatorTransportFuture[]) : [];
|
||||
|
||||
export function toPasskeyInfo(r: AdminPasskeyRow): PasskeyInfo {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
deviceType: r.device_type,
|
||||
backedUp: r.backed_up,
|
||||
createdAt: r.created_at.toISOString(),
|
||||
lastUsedAt: r.last_used_at?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPasskeys(db: Kysely<DB>, adminUserId: string): Promise<AdminPasskeyRow[]> {
|
||||
return await db
|
||||
.selectFrom("admin_passkey")
|
||||
.selectAll()
|
||||
.where("admin_user_id", "=", adminUserId)
|
||||
.orderBy("created_at")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function renamePasskey(db: Kysely<DB>, adminUserId: string, id: string, name: string) {
|
||||
const r = await db
|
||||
.updateTable("admin_passkey")
|
||||
.set({ name: name.trim() })
|
||||
.where("id", "=", id)
|
||||
.where("admin_user_id", "=", adminUserId)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
if (!r) throw new ApiException("not_found", "Passkey 不存在");
|
||||
return r;
|
||||
}
|
||||
|
||||
export async function deletePasskey(db: Kysely<DB>, adminUserId: string, id: string) {
|
||||
const r = await db
|
||||
.deleteFrom("admin_passkey")
|
||||
.where("id", "=", id)
|
||||
.where("admin_user_id", "=", adminUserId)
|
||||
.returning(["id", "name"])
|
||||
.executeTakeFirst();
|
||||
if (!r) throw new ApiException("not_found", "Passkey 不存在");
|
||||
return r;
|
||||
}
|
||||
|
||||
// ── 注册(需要控制台会话) ──
|
||||
|
||||
export async function registrationOptions(db: Kysely<DB>, admin: AdminRow, rp: RpInfo): Promise<PasskeyChallenge> {
|
||||
const existing = await listPasskeys(db, admin.id);
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: rp.rpName,
|
||||
rpID: rp.rpID,
|
||||
userName: admin.username ?? "admin",
|
||||
userID: new TextEncoder().encode(admin.id),
|
||||
attestationType: "none",
|
||||
excludeCredentials: existing.map((c) => ({ id: c.credential_id, transports: transportsOf(c.transports) })),
|
||||
authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" },
|
||||
});
|
||||
const challengeId = await saveChallenge(db, "register", options.challenge, admin.id);
|
||||
return { challengeId, options: options as unknown as Record<string, unknown> };
|
||||
}
|
||||
|
||||
export async function verifyRegistration(
|
||||
db: Kysely<DB>,
|
||||
admin: AdminRow,
|
||||
rp: RpInfo,
|
||||
input: { challengeId: string; name: string; response: Record<string, unknown> },
|
||||
): Promise<AdminPasskeyRow> {
|
||||
const response = input.response as unknown as RegistrationResponseJSON;
|
||||
const origins = expectedOrigins(rp, response);
|
||||
const challenge = await takeChallenge(db, input.challengeId, "register", admin.id);
|
||||
if (!challenge) throw new ApiException("precondition_failed", "注册挑战已过期或已用过,重新开始");
|
||||
let v: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
|
||||
try {
|
||||
v = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: rp.rpID,
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new ApiException("unauthorized", `Passkey 校验失败:${(err as Error).message}`);
|
||||
}
|
||||
if (!v.verified) throw new ApiException("unauthorized", "Passkey 校验失败");
|
||||
const { credential, credentialDeviceType, credentialBackedUp } = v.registrationInfo;
|
||||
try {
|
||||
return await db
|
||||
.insertInto("admin_passkey")
|
||||
.values({
|
||||
admin_user_id: admin.id,
|
||||
credential_id: credential.id,
|
||||
public_key: Buffer.from(credential.publicKey),
|
||||
counter: credential.counter,
|
||||
transports: JSON.stringify(credential.transports ?? response.response?.transports ?? []),
|
||||
device_type: credentialDeviceType,
|
||||
backed_up: credentialBackedUp,
|
||||
name: input.name.trim(),
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code === "23505") throw new ApiException("conflict", "这把 Passkey 已经登记过");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 登录(免鉴权) ──
|
||||
|
||||
export async function authenticationOptions(db: Kysely<DB>, admin: AdminRow, rp: RpInfo): Promise<PasskeyChallenge> {
|
||||
const creds = await listPasskeys(db, admin.id);
|
||||
if (!creds.length) throw new ApiException("precondition_failed", "还没有登记 Passkey,用密码登录");
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rp.rpID,
|
||||
userVerification: "preferred",
|
||||
allowCredentials: creds.map((c) => ({ id: c.credential_id, transports: transportsOf(c.transports) })),
|
||||
});
|
||||
const challengeId = await saveChallenge(db, "login", options.challenge, null);
|
||||
return { challengeId, options: options as unknown as Record<string, unknown> };
|
||||
}
|
||||
|
||||
/** 校验断言;成功后更新签名计数器与最近使用时间。失败抛 unauthorized / precondition_failed */
|
||||
export async function verifyAuthentication(
|
||||
db: Kysely<DB>,
|
||||
admin: AdminRow,
|
||||
rp: RpInfo,
|
||||
input: { challengeId: string; response: Record<string, unknown> },
|
||||
): Promise<AdminPasskeyRow> {
|
||||
const response = input.response as unknown as AuthenticationResponseJSON;
|
||||
const origins = expectedOrigins(rp, response);
|
||||
const challenge = await takeChallenge(db, input.challengeId, "login", null);
|
||||
if (!challenge) throw new ApiException("precondition_failed", "登录挑战已过期或已用过,重试");
|
||||
const cred = await db
|
||||
.selectFrom("admin_passkey")
|
||||
.selectAll()
|
||||
.where("admin_user_id", "=", admin.id)
|
||||
.where("credential_id", "=", String(response.id ?? ""))
|
||||
.executeTakeFirst();
|
||||
if (!cred) throw new ApiException("unauthorized", "这把 Passkey 没有登记");
|
||||
let v: Awaited<ReturnType<typeof verifyAuthenticationResponse>>;
|
||||
try {
|
||||
v = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: rp.rpID,
|
||||
credential: {
|
||||
id: cred.credential_id,
|
||||
publicKey: new Uint8Array(cred.public_key),
|
||||
counter: Number(cred.counter),
|
||||
transports: transportsOf(cred.transports),
|
||||
},
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new ApiException("unauthorized", `Passkey 校验失败:${(err as Error).message}`);
|
||||
}
|
||||
if (!v.verified) throw new ApiException("unauthorized", "Passkey 校验失败");
|
||||
// 计数器只增不减(条件更新:并发重放同一个断言只有一个生效;计数器恒为 0 的认证器照常放行)
|
||||
const next = v.authenticationInfo.newCounter;
|
||||
const u = await db
|
||||
.updateTable("admin_passkey")
|
||||
.set({
|
||||
counter: next,
|
||||
last_used_at: new Date(),
|
||||
device_type: v.authenticationInfo.credentialDeviceType,
|
||||
backed_up: v.authenticationInfo.credentialBackedUp,
|
||||
})
|
||||
.where("id", "=", cred.id)
|
||||
.where(sql<boolean>`(counter < ${next} or (counter = 0 and ${next} = 0))`)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
if (!u) throw new ApiException("unauthorized", "Passkey 签名计数器回退(可能被克隆),已拒绝");
|
||||
return u;
|
||||
}
|
||||
@@ -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