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
+1611 -75

No files matched your search

+11
View File
@@ -10,6 +10,17 @@ export const ConfigSchema = z.object({
HOST: z.string().min(1).default("0.0.0.0"), HOST: z.string().min(1).default("0.0.0.0"),
DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, "必须是 postgres:// 连接串"), DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, "必须是 postgres:// 连接串"),
SECRET_MASTER_KEY: z.string().min(16, "SECRET_MASTER_KEY 至少 16 个字符"), SECRET_MASTER_KEY: z.string().min(16, "SECRET_MASTER_KEY 至少 16 个字符"),
/** 主密钥轮换(--role rotate-master-key)的新密钥;只在轮换命令里读取,完成后换成 SECRET_MASTER_KEY 并删掉 */
SECRET_MASTER_KEY_NEW: z.string().min(16, "SECRET_MASTER_KEY_NEW 至少 16 个字符").optional(),
/**
* Passkey 的 RP ID(浏览器地址栏里的主机名,例如 media.example.com;不含协议与端口)。
* 不设时取请求的 Host(反代后面取 X-Forwarded-Host);换域名后已登记的 Passkey 全部失效。
*/
WEBAUTHN_RP_ID: z.string().min(1).optional(),
/** Passkey 允许的来源,逗号分隔(例如 https://media.example.com);不设时接受主机名等于 / 属于 RP ID 的来源 */
WEBAUTHN_ORIGINS: z.string().min(1).optional(),
/** Passkey 注册时显示的站点名 */
WEBAUTHN_RP_NAME: z.string().min(1).default("UHD Media Tool"),
LOG_LEVEL: LogLevel.default("info"), LOG_LEVEL: LogLevel.default("info"),
/** 仅开发模式生效的管理员 Key */ /** 仅开发模式生效的管理员 Key */
DEV_ADMIN_KEY: z.string().min(8).optional(), DEV_ADMIN_KEY: z.string().min(8).optional(),
+7 -1
View File
@@ -110,7 +110,13 @@ export type AuthEnv = {
}; };
/** 免鉴权路径(相对 /api/v1):登录前就要能访问;带有效 cookie 时仍会挂上 actor */ /** 免鉴权路径(相对 /api/v1):登录前就要能访问;带有效 cookie 时仍会挂上 actor */
export const PUBLIC_AUTH_PATHS = ["/auth/setup", "/auth/login", "/auth/session"] as const; export const PUBLIC_AUTH_PATHS = [
"/auth/setup",
"/auth/login",
"/auth/session",
"/auth/passkeys/login/options",
"/auth/passkeys/login/verify",
] as const;
const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
/** last_seen_at 节流写库间隔 */ /** last_seen_at 节流写库间隔 */
const TOUCH_INTERVAL_MS = 60_000; const TOUCH_INTERVAL_MS = 60_000;
+13 -1
View File
@@ -8,6 +8,8 @@ import type { MappingExport } from "./ops.ts";
// migrate | preflight | probe // migrate | preflight | probe
// export-mappings --out <file.json> [--compare <基准清单.json>] 映射清单导出(可与基准比行数) // export-mappings --out <file.json> [--compare <基准清单.json>] 映射清单导出(可与基准比行数)
// read-only-on [--reason <文本>] 打开 automation.readOnlyReconcile // read-only-on [--reason <文本>] 打开 automation.readOnlyReconcile
// rotate-master-key 用 env SECRET_MASTER_KEY_NEW 重新加密 secret 表
// (逐行事务、幂等可重跑;步骤与回滚见 docs/runbook/auth.md)
// fsworker fsproxy 执行子进程(编译二进制里由 fsproxy 以 // fsworker fsproxy 执行子进程(编译二进制里由 fsproxy 以
// `<自身> --role fsworker` 拉起,stdin 一条请求) // `<自身> --role fsworker` 拉起,stdin 一条请求)
const Roles = [ const Roles = [
@@ -20,10 +22,11 @@ const Roles = [
"probe", "probe",
"export-mappings", "export-mappings",
"read-only-on", "read-only-on",
"rotate-master-key",
"fsworker", "fsworker",
] as const; ] as const;
type Role = (typeof Roles)[number]; type Role = (typeof Roles)[number];
const OneShot = ["migrate", "preflight", "export-mappings", "read-only-on"] as const; const OneShot = ["migrate", "preflight", "export-mappings", "read-only-on", "rotate-master-key"] as const;
type OneShotRole = (typeof OneShot)[number]; type OneShotRole = (typeof OneShot)[number];
const isOneShot = (r: Role): r is OneShotRole => (OneShot as readonly Role[]).includes(r); const isOneShot = (r: Role): r is OneShotRole => (OneShot as readonly Role[]).includes(r);
@@ -108,6 +111,15 @@ if (isOneShot(role)) {
async function runOneShot(r: OneShotRole): Promise<number> { async function runOneShot(r: OneShotRole): Promise<number> {
switch (r) { switch (r) {
case "rotate-master-key": {
if (!config.SECRET_MASTER_KEY_NEW) {
logger.error("缺少环境变量 SECRET_MASTER_KEY_NEW(新主密钥);SECRET_MASTER_KEY 保持旧密钥");
return 2;
}
const { rotateMasterKey } = await import("./modules/auth-settings/provided.ts");
const res = await rotateMasterKey({ db, logger }, config.SECRET_MASTER_KEY, config.SECRET_MASTER_KEY_NEW);
return res.failed.length ? 1 : 0;
}
case "migrate": { case "migrate": {
const { results } = await migrateToLatest(db); const { results } = await migrateToLatest(db);
for (const m of results ?? []) logger.info({ migration: m.migrationName, status: m.status }, "migration"); for (const m of results ?? []) logger.info({ migration: m.migrationName, status: m.status }, "migration");
+4 -1
View File
@@ -507,7 +507,10 @@ export async function goalSettled(env: AgentEnv, runId: string, modelText: strin
.set({ status: "waiting", summary: r.summary }) .set({ status: "waiting", summary: r.summary })
.where("id", "=", runId) .where("id", "=", runId)
.where("status", "in", ["running", "waiting"]) .where("status", "in", ["running", "waiting"])
.returning(["parent_run_id", sql<boolean>`(select status = 'running' from agent_run o where o.id = ${runId})`.as("was_running")]) .returning([
"parent_run_id",
sql<boolean>`(select status = 'running' from agent_run o where o.id = ${runId})`.as("was_running"),
])
.executeTakeFirst(); .executeTakeFirst();
// 子会话刚转 waiting(对父会话来说已有结果):唤醒挂起等待的父会话 // 子会话刚转 waiting(对父会话来说已有结果):唤醒挂起等待的父会话
if (u?.parent_run_id && u.was_running) await wakeParent(env.ctx, runId); if (u?.parent_run_id && u.was_running) await wakeParent(env.ctx, runId);
@@ -6,20 +6,36 @@ import { ApiException } from "../../http/errors.ts";
import type { ModuleApp, ServerContext } from "../types.ts"; import type { ModuleApp, ServerContext } from "../types.ts";
import { import {
adminActor, adminActor,
assertNotRateLimited,
type CreatedSession, type CreatedSession,
changePassword, changePassword,
createSession, createSession,
loadAdmin, loadAdmin,
recordAttempt,
revokeSession, revokeSession,
sessionInfo, sessionInfo,
setupAdmin, setupAdmin,
totpBegin, totpBegin,
totpDisable, totpDisable,
totpEnable, totpEnable,
totpRegenerateRecoveryCodes,
totpSetup,
totpStatus, totpStatus,
verifyLogin, verifyLogin,
} from "./auth-service.ts"; } from "./auth-service.ts";
import { audit, type Def, endpoint } from "./endpoint.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), // 登录门禁端点:/auth/setup、/auth/login、/auth/session 免鉴权(见 http/auth.ts PUBLIC_AUTH_PATHS),
// 其余需要已登录;改密与 TOTP 只接受控制台 cookie 会话。 // 其余需要已登录;改密与 TOTP 只接受控制台 cookie 会话。
@@ -36,8 +52,19 @@ const D = {
password: { ...Api.auth.password, tags }, password: { ...Api.auth.password, tags },
totpGet: { ...Api.auth.totpStatus, tags }, totpGet: { ...Api.auth.totpStatus, tags },
totpPost: { ...Api.auth.totp, 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>; } 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; 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"; 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 { function isHttps(c: Context<AuthEnv>): boolean {
if (new URL(c.req.url).protocol === "https:") return true; if (new URL(c.req.url).protocol === "https:") return true;
const remote = (c.env as { remoteAddr?: { hostname?: string } } | undefined)?.remoteAddr?.hostname; 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 s = await startSession(c, r.admin.id);
const actor = adminActor(r.admin); const actor = adminActor(r.admin);
await audit(ctx, actor, "admin.login_success", { type: "admin_session", id: s.id }, { ip: clientIp(c) }); 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) }; 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 }; 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); const admin = await loadAdmin(ctx.db);
if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化"); 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") { if (body.action === "begin") {
const r = await totpBegin(deps(), admin); 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") { 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 }); await audit(ctx, actor, "admin.totp_enable", { type: "admin_user", id: admin.id });
} else { return { status: await status(), recoveryCodes: codes };
}
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 totpDisable(deps(), admin, body);
await audit(ctx, actor, "admin.totp_disable", { type: "admin_user", id: admin.id }); 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;
} }
return { status: totpStatus(await loadAdmin(ctx.db)) }; 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 { 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 DB, type Kysely, sql } from "@umt/db";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { hashSessionToken } from "../../http/auth.ts"; import { hashSessionToken } from "../../http/auth.ts";
@@ -17,7 +17,8 @@ import {
// 管理员登录门禁的业务逻辑(照 docs/design/auth-reference.md): // 管理员登录门禁的业务逻辑(照 docs/design/auth-reference.md):
// setup token 一次性初始化、argon2id、服务端会话(sha256 存库、固定 TTL、软删除吊销)、 // setup token 一次性初始化、argon2id、服务端会话(sha256 存库、固定 TTL、软删除吊销)、
// IP + 账号双维度失败限速(落表)、改密吊销其它会话、可选 TOTP(防重放计数器)。 // IP + 账号双维度失败限速(落表)、改密吊销其它会话、可选 TOTP(防重放计数器)+ 一次性恢复码(argon2id 哈希存库)。
// Passkey 见 passkey.ts。
/** 固定会话时长 12 小时,无滑动续期 */ /** 固定会话时长 12 小时,无滑动续期 */
export const SESSION_TTL_MS = 12 * 3600_000; export const SESSION_TTL_MS = 12 * 3600_000;
@@ -197,11 +198,13 @@ export async function setupAdmin(
return admin; 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( export async function verifyLogin(
deps: AuthDeps, deps: AuthDeps,
input: { username: string; password: string; totpCode?: string | undefined }, input: { username: string; password: string; totpCode?: string | undefined; recoveryCode?: string | undefined },
ip: string, ip: string,
): Promise<LoginOutcome> { ): Promise<LoginOutcome> {
await assertNotRateLimited(deps.db, ip, input.username); 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" }); await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_password" });
throw new ApiException("unauthorized", "用户名或密码错误"); throw new ApiException("unauthorized", "用户名或密码错误");
} }
let secondFactor: "none" | "totp" | "recovery_code" = "none";
if (admin.totp_enabled_at && admin.totp_secret_ref) { if (admin.totp_enabled_at && admin.totp_secret_ref) {
// 密码正确但缺第二步:不计失败 // 密码正确但缺第二步:不计失败
if (!input.totpCode) return { status: "totp_required" }; if (!input.totpCode && !input.recoveryCode) return { status: "totp_required" };
if (input.totpCode) {
if (!(await consumeTotp(deps, admin, input.totpCode))) { if (!(await consumeTotp(deps, admin, input.totpCode))) {
await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_totp" }); await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_totp" });
throw new ApiException("unauthorized", "验证码错误"); 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 }); 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 计数器(条件更新防并发重放) */ /** 校验并消费 TOTP 计数器(条件更新防并发重放) */
@@ -267,23 +341,55 @@ export async function changePassword(
// ── TOTP 管理 ── // ── TOTP 管理 ──
export function totpStatus(admin: AdminRow | null) { export async function totpStatus(db: Kysely<DB>, admin: AdminRow | null): Promise<TotpStatus> {
return { enabled: !!admin?.totp_enabled_at, pending: !!admin?.totp_pending_secret_ref }; 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 已启用,先停用再重新绑定"); if (admin.totp_enabled_at) throw new ApiException("conflict", "TOTP 已启用,先停用再重新绑定");
const secret = base32Encode(randomBytes(20)); const secret = base32Encode(randomBytes(20));
const ref = await deps.secrets.create({ purpose: "other", plaintext: secret, label: `admin-totp:${randomToken(6)}` }); const ref = await deps.secrets.create({ purpose: "other", plaintext: secret, label: `admin-totp:${randomToken(6)}` });
const old = admin.totp_pending_secret_ref; const old = admin.totp_pending_secret_ref;
await deps.db.updateTable("admin_user").set({ totp_pending_secret_ref: ref }).where("id", "=", admin.id).execute(); 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(() => {}); if (old) await deps.secrets.remove(old).catch(() => {});
const label = encodeURIComponent(`${TOTP_ISSUER}:${admin.username ?? "admin"}`); const account = admin.username ?? "admin";
const uri = `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(TOTP_ISSUER)}&algorithm=SHA1&digits=6&period=30`; return { uri: otpauthUri(secret, account), secret, issuer: TOTP_ISSUER, account };
return { uri, secret };
} }
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 密钥"); if (!admin.totp_pending_secret_ref) throw new ApiException("precondition_failed", "先生成 TOTP 密钥");
const secret = await deps.secrets.resolve(admin.totp_pending_secret_ref); const secret = await deps.secrets.resolve(admin.totp_pending_secret_ref);
const counter = verifyTotp(secret, code, null); const counter = verifyTotp(secret, code, null);
@@ -300,6 +406,20 @@ export async function totpEnable(deps: AuthDeps, admin: AdminRow, code: string):
.where("id", "=", admin.id) .where("id", "=", admin.id)
.execute(); .execute();
if (old) await deps.secrets.remove(old).catch(() => {}); 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( export async function totpDisable(
@@ -311,13 +431,18 @@ export async function totpDisable(
if (!admin.password_hash || !(await verifyPassword(input.password, admin.password_hash))) { if (!admin.password_hash || !(await verifyPassword(input.password, admin.password_hash))) {
throw new ApiException("unauthorized", "密码错误"); 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; const ref = admin.totp_secret_ref;
await deps.db await deps.db
.updateTable("admin_user") .updateTable("admin_user")
.set({ totp_secret_ref: null, totp_enabled_at: null, totp_last_counter: null }) .set({ totp_secret_ref: null, totp_enabled_at: null, totp_last_counter: null })
.where("id", "=", admin.id) .where("id", "=", admin.id)
.execute(); .execute();
await deps.db.deleteFrom("admin_recovery_code").where("admin_user_id", "=", admin.id).execute();
await deps.secrets.remove(ref).catch(() => {}); await deps.secrets.remove(ref).catch(() => {});
} }
@@ -333,5 +458,8 @@ export async function sessionInfo(
actor: actor ?? null, actor: actor ?? null,
expiresAt: expiresAt?.toISOString() ?? null, expiresAt: expiresAt?.toISOString() ?? null,
totpEnabled: !!admin?.totp_enabled_at, 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 { DEFAULT_SETTINGS, GlobalSettings, type SecretPurpose, type SettingsKey } from "@umt/contracts";
import type { DB, Kysely } from "@umt/db"; import type { DB, Kysely } from "@umt/db";
import type { Logger } from "pino"; import type { Logger } from "pino";
@@ -12,7 +12,7 @@ import { deriveKey } from "./crypto.ts";
export const SECRET_ALGORITHM = "aes-256-gcm"; export const SECRET_ALGORITHM = "aes-256-gcm";
export const SECRET_KDF = "hkdf-sha256"; export const SECRET_KDF = "hkdf-sha256";
/** 当前主密钥版本;轮换主密钥时 +1,并用 rewrapAll 重新加密 */ /** 密文格式版本(HKDF info = umt/secret/v<版本>);主密钥轮换不改它,换哪把主密钥看 kdf_params.kid */
export const SECRET_KEY_VERSION = 1; export const SECRET_KEY_VERSION = 1;
/** 找不到 secret 或无法解密;消息里只有 ref,不含任何明文 */ /** 找不到 secret 或无法解密;消息里只有 ref,不含任何明文 */
@@ -52,9 +52,14 @@ interface Sealed {
ciphertext: Buffer; ciphertext: Buffer;
nonce: Buffer; nonce: Buffer;
authTag: 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 与用途:密文被挪到别的行/改用途即解密失败 */ /** 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; 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) { 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); const cipher = createCipheriv(SECRET_ALGORITHM, key, nonce);
cipher.setAAD(aad(id, purpose)); cipher.setAAD(aad(id, purpose));
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); 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( 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 / 进程间广播的设置变更事件 */ /** SSE / 进程间广播的设置变更事件 */
+6
View File
@@ -61,6 +61,12 @@ describe("Api 注册表鉴权标注", () => {
expect(session).toEqual( expect(session).toEqual(
[ [
"GET /auth/totp", "GET /auth/totp",
"GET /auth/totp/setup",
"GET /auth/passkeys",
"POST /auth/passkeys/register/options",
"POST /auth/passkeys/register/verify",
"PATCH /auth/passkeys/{id}",
"DELETE /auth/passkeys/{id}",
"GET /files/download", "GET /files/download",
"PATCH /approvals/{id}", "PATCH /approvals/{id}",
"POST /approvals", "POST /approvals",
@@ -0,0 +1,232 @@
import { sql } from "@umt/db";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { ensureSetupToken, MAX_FAILURES_PER_IP } from "../../src/modules/auth-settings/auth-service.ts";
import { totpAt, totpCounter } from "../../src/modules/auth-settings/crypto.ts";
import { resolveRp } from "../../src/modules/auth-settings/passkey.ts";
import { DEV_KEY, json } from "../helpers.ts";
import { Browser, bearer, createHarness, type Harness, pgAvailable } from "./harness.ts";
import { SoftAuthenticator } from "./soft-authenticator.ts";
// C1-B 账户与安全:TOTP 二维码 setup 端点、一次性恢复码、Passkey 注册 / 登录 / 命名 / 删除(真实 @simplewebauthn/server 校验)。
const hasPg = await pgAvailable();
const PASSWORD = "correct horse battery";
describe("resolveRp", () => {
it("配置优先;未配置时取请求主机名(去端口、转小写)", () => {
expect(resolveRp({}, "Media.Example.com:8443")).toMatchObject({ rpID: "media.example.com", origins: null });
expect(
resolveRp(
{ WEBAUTHN_RP_ID: "example.com", WEBAUTHN_ORIGINS: "https://a.example.com/, https://example.com" },
"x",
),
).toMatchObject({ rpID: "example.com", origins: ["https://a.example.com", "https://example.com"] });
expect(resolveRp({}, null).rpID).toBe("localhost");
});
});
describe.skipIf(!hasPg)("账户与安全:恢复码 / Passkey(真实 PG)", () => {
let h: Harness;
beforeAll(async () => {
h = await createHarness();
const token = await ensureSetupToken(h.ctx);
const r = await new Browser(h.app).req("POST", "/auth/setup", {
setupToken: token,
username: "admin",
password: PASSWORD,
});
expect(r.status).toBe(200);
}, 60_000);
afterAll(async () => {
await h?.close();
});
beforeEach(async () => {
await sql`delete from login_attempt`.execute(h.db);
});
const login = (b: Browser, extra: Record<string, unknown> = {}) =>
b.req("POST", "/auth/login", { username: "admin", password: PASSWORD, ...extra });
const audits = async (action: string) =>
(await sql<{ n: number }>`select count(*)::int as n from audit_event where action = ${action}`.execute(h.db))
.rows[0]!.n;
it("TOTP:GET /auth/totp/setup 给 otpauth URL(重复请求不换密钥)→ 启用返回 8 个恢复码 → 恢复码登录一次性 → 重新生成 → 用恢复码停用", async () => {
const a = new Browser(h.app);
expect((await login(a)).status).toBe(200);
// API Key 不能取密钥
expect((await bearer(h.app, DEV_KEY)("GET", "/auth/totp/setup")).status).toBe(403);
const s1 = (await json(await a.req("GET", "/auth/totp/setup"))).data;
expect(s1.otpauthUri).toMatch(/^otpauth:\/\/totp\/UHD%20Media%20Tool%3Aadmin\?secret=[A-Z2-7]+&issuer=/);
expect(s1).toMatchObject({ issuer: "UHD Media Tool", account: "admin" });
const s2 = (await json(await a.req("GET", "/auth/totp/setup"))).data;
expect(s2.secret).toBe(s1.secret);
expect((await json(await a.req("GET", "/auth/totp"))).data).toEqual({
enabled: false,
pending: true,
recoveryCodesRemaining: 0,
});
const code0 = totpAt(s1.secret, totpCounter());
const en = (await json(await a.req("POST", "/auth/totp", { action: "enable", code: code0 }))).data;
expect(en.recoveryCodes).toHaveLength(8);
expect(new Set(en.recoveryCodes).size).toBe(8);
for (const c of en.recoveryCodes) expect(c).toMatch(/^[A-Z2-9]{5}-[A-Z2-9]{5}$/);
expect(en.status).toMatchObject({ enabled: true, pending: false, recoveryCodesRemaining: 8 });
// 已开启:setup 端点 409
expect((await a.req("GET", "/auth/totp/setup")).status).toBe(409);
// 库里只存哈希
const rows = await sql<{ code_hash: string }>`select code_hash from admin_recovery_code`.execute(h.db);
expect(rows.rows).toHaveLength(8);
for (const r of rows.rows) {
expect(r.code_hash).toMatch(/^\$argon2id\$/);
expect(en.recoveryCodes.some((c: string) => r.code_hash.includes(c.replace("-", "")))).toBe(false);
}
// 恢复码登录:小写 + 去掉连字符也认;同一个码第二次失败
const rc: string = en.recoveryCodes[0];
const b = new Browser(h.app, "203.0.113.31");
expect((await json(await login(b))).data).toEqual({ status: "totp_required" });
const ok = await login(b, { recoveryCode: rc.replace("-", "").toLowerCase() });
expect(ok.status).toBe(200);
expect(b.cookies.has("umt_session")).toBe(true);
expect((await login(new Browser(h.app, "203.0.113.32"), { recoveryCode: rc })).status).toBe(401);
expect(await audits("admin.recovery_code_login")).toBe(1);
expect((await json(await a.req("GET", "/auth/totp"))).data.recoveryCodesRemaining).toBe(7);
const fails = await sql<{
n: number;
}>`select count(*)::int as n from login_attempt where reason = 'bad_recovery_code'`.execute(h.db);
expect(fails.rows[0]!.n).toBe(1);
// 重新生成:要密码;旧码全部作废
expect((await a.req("POST", "/auth/totp", { action: "recovery_codes", password: "wrong-password" })).status).toBe(
401,
);
const re = (await json(await a.req("POST", "/auth/totp", { action: "recovery_codes", password: PASSWORD }))).data;
expect(re.recoveryCodes).toHaveLength(8);
expect(re.status.recoveryCodesRemaining).toBe(8);
expect((await login(new Browser(h.app, "203.0.113.33"), { recoveryCode: en.recoveryCodes[1] })).status).toBe(401);
expect(await audits("admin.recovery_codes_regenerate")).toBe(1);
// 验证器丢了:用恢复码停用
const off = await a.req("POST", "/auth/totp", { action: "disable", password: PASSWORD, code: re.recoveryCodes[0] });
expect(off.status).toBe(200);
expect((await json(off)).data.status).toEqual({ enabled: false, pending: false, recoveryCodesRemaining: 0 });
expect(
(await sql<{ n: number }>`select count(*)::int as n from admin_recovery_code`.execute(h.db)).rows[0]!.n,
).toBe(0);
expect((await json(await login(new Browser(h.app, "203.0.113.34")))).data.status).toBe("ok");
});
it("Passkey:注册(挑战一次性)→ 多把、命名 → 登录页入口 → Passkey 登录建会话 → 重放 / 错来源 / 计数器回退拒绝 → 删除", async () => {
const a = new Browser(h.app);
expect((await login(a)).status).toBe(200);
expect((await json(await new Browser(h.app).req("GET", "/auth/session"))).data.passkeyEnabled).toBe(false);
// 没有 Passkey 时登录挑战 412
expect((await new Browser(h.app).req("POST", "/auth/passkeys/login/options")).status).toBe(412);
// API Key 不能登记
expect((await bearer(h.app, DEV_KEY)("POST", "/auth/passkeys/register/options")).status).toBe(403);
const reg = async (auth: SoftAuthenticator, name: string) => {
const o = (await json(await a.req("POST", "/auth/passkeys/register/options"))).data;
expect(o.options).toMatchObject({ rp: { id: "localhost", name: "UHD Media Tool" }, user: { name: "admin" } });
const res = await a.req("POST", "/auth/passkeys/register/verify", {
challengeId: o.challengeId,
name,
response: auth.register(o.options),
});
return { o, res };
};
const k1 = new SoftAuthenticator();
const r1 = await reg(k1, "MacBook 指纹");
expect(r1.res.status).toBe(201);
expect((await json(r1.res)).data).toMatchObject({ name: "MacBook 指纹", lastUsedAt: null });
// 同一个挑战不能再用
const again = await a.req("POST", "/auth/passkeys/register/verify", {
challengeId: r1.o.challengeId,
name: "x",
response: new SoftAuthenticator().register(r1.o.options),
});
expect(again.status).toBe(412);
// 来源不属于 RP ID → 拒绝
const o2 = (await json(await a.req("POST", "/auth/passkeys/register/options"))).data;
expect(o2.options.excludeCredentials.map((c: { id: string }) => c.id)).toEqual([k1.id]);
const evil = await a.req("POST", "/auth/passkeys/register/verify", {
challengeId: o2.challengeId,
name: "evil",
response: new SoftAuthenticator().register(o2.options, { origin: "https://evil.example" }),
});
expect(evil.status).toBe(401);
const k2 = new SoftAuthenticator();
expect((await reg(k2, "YubiKey")).res.status).toBe(201);
const list = (await json(await a.req("GET", "/auth/passkeys"))).data.items;
expect(list.map((p: { name: string }) => p.name)).toEqual(["MacBook 指纹", "YubiKey"]);
const renamed = await a.req("PATCH", `/auth/passkeys/${list[1].id}`, { name: "备用钥匙" });
expect((await json(renamed)).data.name).toBe("备用钥匙");
// 登录页:未登录也能知道有 Passkey
const guest = new Browser(h.app, "203.0.113.41");
expect((await json(await guest.req("GET", "/auth/session"))).data).toMatchObject({
authenticated: false,
passkeyEnabled: true,
});
const lo = (await json(await guest.req("POST", "/auth/passkeys/login/options"))).data;
expect(lo.options.allowCredentials.map((c: { id: string }) => c.id).sort()).toEqual([k1.id, k2.id].sort());
const assertion = k1.assert(lo.options);
const res = await guest.req("POST", "/auth/passkeys/login/verify", {
challengeId: lo.challengeId,
response: assertion,
});
expect(res.status).toBe(200);
expect((await json(res)).data).toMatchObject({
status: "ok",
session: { authenticated: true, passkeyEnabled: true },
});
expect(guest.cookies.has("umt_session")).toBe(true);
expect(guest.cookies.has("umt_csrf")).toBe(true);
expect((await guest.req("GET", "/settings")).status).toBe(200);
expect(await audits("admin.passkey_login")).toBe(1);
const used = (await json(await a.req("GET", "/auth/passkeys"))).data.items[0];
expect(used.lastUsedAt).toBeTruthy();
// 重放同一个断言(挑战已消费)→ 412;新挑战 + 旧计数器 → 401(计数器回退)
const replay = await new Browser(h.app, "203.0.113.42").req("POST", "/auth/passkeys/login/verify", {
challengeId: lo.challengeId,
response: assertion,
});
expect(replay.status).toBe(412);
const lo2 = (await json(await new Browser(h.app, "203.0.113.43").req("POST", "/auth/passkeys/login/options"))).data;
const stale = await new Browser(h.app, "203.0.113.43").req("POST", "/auth/passkeys/login/verify", {
challengeId: lo2.challengeId,
response: k1.assert(lo2.options, { counter: 1 }),
});
expect(stale.status).toBe(401);
// 没登记的钥匙 → 401
const lo3 = (await json(await new Browser(h.app, "203.0.113.44").req("POST", "/auth/passkeys/login/options"))).data;
const unknown = await new Browser(h.app, "203.0.113.44").req("POST", "/auth/passkeys/login/verify", {
challengeId: lo3.challengeId,
response: new SoftAuthenticator().assert(lo3.options),
});
expect(unknown.status).toBe(401);
// 删除:只剩一把
expect((await a.req("DELETE", `/auth/passkeys/${list[0].id}`)).status).toBe(200);
expect((await a.req("DELETE", `/auth/passkeys/${list[0].id}`)).status).toBe(404);
expect((await json(await a.req("GET", "/auth/passkeys"))).data.items).toHaveLength(1);
expect(await audits("admin.passkey_register")).toBe(2);
expect(await audits("admin.passkey_remove")).toBe(1);
});
it("Passkey 登录与密码登录共用 IP 限速", async () => {
const ip = "203.0.113.50";
for (let i = 0; i < MAX_FAILURES_PER_IP; i++) {
const o = (await json(await new Browser(h.app, ip).req("POST", "/auth/passkeys/login/options"))).data;
const r = await new Browser(h.app, ip).req("POST", "/auth/passkeys/login/verify", {
challengeId: o.challengeId,
response: new SoftAuthenticator().assert(o.options),
});
expect(r.status).toBe(401);
}
expect((await new Browser(h.app, ip).req("POST", "/auth/passkeys/login/options")).status).toBe(429);
expect((await login(new Browser(h.app, ip))).status).toBe(429);
});
});
+5 -1
View File
@@ -186,7 +186,11 @@ describe.skipIf(!hasPg)("管理员登录门禁(真实 PG)", () => {
it("TOTP:绑定 → 启用 → 登录需第二步(不计失败)→ 验证码防重放 → 停用", async () => { it("TOTP:绑定 → 启用 → 登录需第二步(不计失败)→ 验证码防重放 → 停用", async () => {
const a = new Browser(h.app); const a = new Browser(h.app);
await login(a); await login(a);
expect((await json(await a.req("GET", "/auth/totp"))).data).toEqual({ enabled: false, pending: false }); expect((await json(await a.req("GET", "/auth/totp"))).data).toEqual({
enabled: false,
pending: false,
recoveryCodesRemaining: 0,
});
const begin = (await json(await a.req("POST", "/auth/totp", { action: "begin" }))).data; const begin = (await json(await a.req("POST", "/auth/totp", { action: "begin" }))).data;
expect(begin.otpauthUri).toMatch(/^otpauth:\/\/totp\//); expect(begin.otpauthUri).toMatch(/^otpauth:\/\/totp\//);
const secret: string = begin.secret; const secret: string = begin.secret;
@@ -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);
});
@@ -0,0 +1,131 @@
import { createHash, generateKeyPairSync, type KeyObject, randomBytes, sign } from "node:crypto";
// 测试用软件 WebAuthn 认证器(ES256,attestation=none):生成 @simplewebauthn/browser 形状的
// RegistrationResponseJSON / AuthenticationResponseJSON,让服务端走真实的 @simplewebauthn/server 校验。
type Cbor = number | string | Uint8Array | Map<Cbor, Cbor> | Record<string, unknown>;
function head(major: number, n: number): number[] {
if (n < 24) return [(major << 5) | n];
if (n < 0x100) return [(major << 5) | 24, n];
if (n < 0x10000) return [(major << 5) | 25, n >> 8, n & 0xff];
return [(major << 5) | 26, (n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
}
/** 极简 CBOR 编码:整数、字节串、文本、map(够编 COSE 公钥与 attestationObject) */
export function cbor(v: Cbor): Uint8Array {
const out: number[] = [];
const enc = (x: Cbor) => {
if (typeof x === "number") out.push(...(x >= 0 ? head(0, x) : head(1, -1 - x)));
else if (typeof x === "string") {
const b = new TextEncoder().encode(x);
out.push(...head(3, b.length), ...b);
} else if (x instanceof Uint8Array) out.push(...head(2, x.length), ...x);
else if (x instanceof Map) {
out.push(...head(5, x.size));
for (const [k, val] of x) {
enc(k);
enc(val);
}
} else {
const entries = Object.entries(x);
out.push(...head(5, entries.length));
for (const [k, val] of entries) {
enc(k);
enc(val as Cbor);
}
}
};
enc(v);
return new Uint8Array(out);
}
const b64u = (b: Uint8Array | Buffer) => Buffer.from(b).toString("base64url");
const sha256 = (b: Uint8Array | string) => createHash("sha256").update(b).digest();
const u32 = (n: number) => {
const b = Buffer.alloc(4);
b.writeUInt32BE(n);
return b;
};
export class SoftAuthenticator {
readonly credentialId = randomBytes(16);
private readonly privateKey: KeyObject;
private readonly cose: Uint8Array;
counter = 0;
constructor(
readonly rpId = "localhost",
readonly origin = "http://localhost:5197",
) {
const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
this.privateKey = privateKey;
const jwk = publicKey.export({ format: "jwk" }) as { x: string; y: string };
this.cose = cbor(
new Map<Cbor, Cbor>([
[1, 2],
[3, -7],
[-1, 1],
[-2, Buffer.from(jwk.x, "base64url")],
[-3, Buffer.from(jwk.y, "base64url")],
]),
);
}
get id() {
return b64u(this.credentialId);
}
private clientData(type: string, challenge: string, origin: string) {
return Buffer.from(JSON.stringify({ type, challenge, origin, crossOrigin: false }));
}
/** navigator.credentials.create 的等价物 */
register(options: { challenge: string }, over: { origin?: string } = {}) {
const clientDataJSON = this.clientData("webauthn.create", options.challenge, over.origin ?? this.origin);
const idLen = Buffer.alloc(2);
idLen.writeUInt16BE(this.credentialId.length);
const authData = Buffer.concat([
sha256(this.rpId),
Buffer.from([0x45]), // UP | UV | AT
u32(this.counter),
Buffer.alloc(16), // aaguid
idLen,
this.credentialId,
this.cose,
]);
const attestationObject = cbor({ fmt: "none", attStmt: {}, authData: new Uint8Array(authData) });
return {
id: this.id,
rawId: this.id,
type: "public-key",
response: {
clientDataJSON: b64u(clientDataJSON),
attestationObject: b64u(attestationObject),
transports: ["internal"],
},
clientExtensionResults: {},
authenticatorAttachment: "platform",
};
}
/** navigator.credentials.get 的等价物;counter 自增(可传 counter 模拟克隆 / 回退) */
assert(options: { challenge: string }, over: { origin?: string; counter?: number } = {}) {
this.counter = over.counter ?? this.counter + 1;
const clientDataJSON = this.clientData("webauthn.get", options.challenge, over.origin ?? this.origin);
const authData = Buffer.concat([sha256(this.rpId), Buffer.from([0x05]), u32(this.counter)]);
const signature = sign("sha256", Buffer.concat([authData, sha256(clientDataJSON)]), this.privateKey);
return {
id: this.id,
rawId: this.id,
type: "public-key",
response: {
clientDataJSON: b64u(clientDataJSON),
authenticatorData: b64u(authData),
signature: b64u(signature),
},
clientExtensionResults: {},
authenticatorAttachment: "platform",
};
}
}
+2 -1
View File
@@ -131,7 +131,8 @@ describe.skipIf(!hasPg)("E11 Pi 子代理(agent.spawn / agent.await)", () => {
let e: E2E; let e: E2E;
beforeAll(async () => { beforeAll(async () => {
e = await createE2E("e11", { fakeScripts: [ e = await createE2E("e11", {
fakeScripts: [
parentScript, parentScript,
duneScript, duneScript,
oppieScript, oppieScript,
+2
View File
@@ -16,6 +16,7 @@
"@fontsource-variable/inter-tight": "^5.2.7", "@fontsource-variable/inter-tight": "^5.2.7",
"@fontsource-variable/space-grotesk": "^5.2.10", "@fontsource-variable/space-grotesk": "^5.2.10",
"@react-router/node": "7.12.0", "@react-router/node": "7.12.0",
"@simplewebauthn/browser": "^13.3.0",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@umt/contracts": "workspace:*", "@umt/contracts": "workspace:*",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -24,6 +25,7 @@
"isbot": "^5.1.31", "isbot": "^5.1.31",
"lucide-react": "^0.576.0", "lucide-react": "^0.576.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"qr-code-styling": "^1.9.2",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-day-picker": "^10.0.1", "react-day-picker": "^10.0.1",
+1
View File
@@ -20,6 +20,7 @@
"@earendil-works/pi-ai": "npm:@earendil-works/[email protected]", "@earendil-works/pi-ai": "npm:@earendil-works/[email protected]",
"@earendil-works/pi-ai/": "npm:/@earendil-works/[email protected]/", "@earendil-works/pi-ai/": "npm:/@earendil-works/[email protected]/",
"@hono/swagger-ui": "npm:@hono/[email protected]", "@hono/swagger-ui": "npm:@hono/[email protected]",
"@simplewebauthn/server": "npm:@simplewebauthn/[email protected]",
"@hono/zod-openapi": "npm:@hono/[email protected]", "@hono/zod-openapi": "npm:@hono/[email protected]",
"@types/node": "npm:@types/[email protected]", "@types/node": "npm:@types/[email protected]",
"@types/nunjucks": "npm:@types/[email protected]", "@types/nunjucks": "npm:@types/[email protected]",
Generated
+225 -33
View File
@@ -15,6 +15,8 @@
"npm:@react-router/dev@*": "[email protected][email protected][email protected]_@[email protected][email protected][email protected][email protected]", "npm:@react-router/dev@*": "[email protected][email protected][email protected]_@[email protected][email protected][email protected][email protected]",
"npm:@react-router/[email protected]": "[email protected][email protected][email protected]_@[email protected][email protected][email protected][email protected]", "npm:@react-router/[email protected]": "[email protected][email protected][email protected]_@[email protected][email protected][email protected][email protected]",
"npm:@react-router/[email protected]": "[email protected][email protected][email protected][email protected]", "npm:@react-router/[email protected]": "[email protected][email protected][email protected][email protected]",
"npm:@simplewebauthn/browser@^13.3.0": "13.3.0",
"npm:@simplewebauthn/[email protected]": "13.3.3",
"npm:@tailwindcss/vite@^4.1.13": "[email protected]_@[email protected][email protected]", "npm:@tailwindcss/vite@^4.1.13": "[email protected]_@[email protected][email protected]",
"npm:@tanstack/react-query@^5.90.21": "[email protected]", "npm:@tanstack/react-query@^5.90.21": "[email protected]",
"npm:@types/[email protected]": "24.19.0", "npm:@types/[email protected]": "24.19.0",
@@ -41,6 +43,7 @@
"npm:[email protected]": "10.3.1", "npm:[email protected]": "10.3.1",
"npm:[email protected]": "1.63.0", "npm:[email protected]": "1.63.0",
"npm:[email protected]": "3.4.9", "npm:[email protected]": "3.4.9",
"npm:qr-code-styling@^1.9.2": "1.9.2",
"npm:radix-ui@^1.4.3": "1.6.7_@[email protected]_@[email protected][email protected][email protected]", "npm:radix-ui@^1.4.3": "1.6.7_@[email protected]_@[email protected][email protected][email protected]",
"npm:react-day-picker@^10.0.1": "10.0.1_@[email protected][email protected]", "npm:react-day-picker@^10.0.1": "10.0.1_@[email protected][email protected]",
"npm:react-dom@^19.2.4": "[email protected]", "npm:react-dom@^19.2.4": "[email protected]",
@@ -119,7 +122,7 @@
"@smithy/fetch-http-handler", "@smithy/fetch-http-handler",
"@smithy/node-http-handler", "@smithy/node-http-handler",
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@aws-sdk/[email protected]": { "@aws-sdk/[email protected]": {
@@ -132,7 +135,7 @@
"@smithy/signature-v4", "@smithy/signature-v4",
"@smithy/types", "@smithy/types",
"bowser", "bowser",
"tslib" "tslib@2.8.1"
] ]
}, },
"@aws-sdk/[email protected]": { "@aws-sdk/[email protected]": {
@@ -142,7 +145,7 @@
"@aws-sdk/types", "@aws-sdk/types",
"@smithy/core", "@smithy/core",
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@aws-sdk/[email protected]": { "@aws-sdk/[email protected]": {
@@ -154,7 +157,7 @@
"@smithy/fetch-http-handler", "@smithy/fetch-http-handler",
"@smithy/node-http-handler", "@smithy/node-http-handler",
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@aws-sdk/[email protected]": { "@aws-sdk/[email protected]": {
@@ -172,7 +175,7 @@
"@smithy/core", "@smithy/core",
"@smithy/credential-provider-imds", "@smithy/credential-provider-imds",
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@aws-sdk/[email protected]": { "@aws-sdk/[email protected]": {
@@ -183,7 +186,7 @@
"@aws-sdk/types", "@aws-sdk/types",
"@smithy/core", "@smithy/core",
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@aws-sdk/[email protected]": { "@aws-sdk/[email protected]": {
@@ -199,7 +202,7 @@
"@smithy/core", "@smithy/core",
"@smithy/credential-provider-imds", "@smithy/credential-provider-imds",
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@aws-sdk/[email protected]": { "@aws-sdk/[email protected]": {
@@ -209,7 +212,7 @@
"@aws-sdk/types", "@aws-sdk/types",
"@smithy/core", "@smithy/core",
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@aws-sdk/[email protected]": { "@aws-sdk/[email protected]": {
@@ -221,7 +224,7 @@
"@aws-sdk/types", "@aws-sdk/types",
"@smithy/core", "@smithy/core",
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@aws-sdk/[email protected]": { "@aws-sdk/[email protected]": {
@@ -232,7 +235,7 @@
"@aws-sdk/types", "@aws-sdk/types",
"@smithy/core", "@smithy/core",
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@aws-sdk/[email protected]": { "@aws-sdk/[email protected]": {
@@ -241,7 +244,7 @@
"@aws-sdk/types", "@aws-sdk/types",
"@smithy/core", "@smithy/core",
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@aws-sdk/[email protected]": { "@aws-sdk/[email protected]": {
@@ -250,7 +253,7 @@
"@aws-sdk/types", "@aws-sdk/types",
"@smithy/core", "@smithy/core",
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@aws-sdk/[email protected]": { "@aws-sdk/[email protected]": {
@@ -262,7 +265,7 @@
"@smithy/fetch-http-handler", "@smithy/fetch-http-handler",
"@smithy/signature-v4", "@smithy/signature-v4",
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@aws-sdk/[email protected]": { "@aws-sdk/[email protected]": {
@@ -275,7 +278,7 @@
"@smithy/fetch-http-handler", "@smithy/fetch-http-handler",
"@smithy/node-http-handler", "@smithy/node-http-handler",
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@aws-sdk/[email protected]": { "@aws-sdk/[email protected]": {
@@ -284,7 +287,7 @@
"@aws-sdk/types", "@aws-sdk/types",
"@smithy/signature-v4", "@smithy/signature-v4",
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@aws-sdk/[email protected]": { "@aws-sdk/[email protected]": {
@@ -295,7 +298,7 @@
"@aws-sdk/types", "@aws-sdk/types",
"@smithy/core", "@smithy/core",
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@aws-sdk/[email protected]": { "@aws-sdk/[email protected]": {
@@ -306,21 +309,21 @@
"@aws-sdk/types", "@aws-sdk/types",
"@smithy/core", "@smithy/core",
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@aws-sdk/[email protected]": { "@aws-sdk/[email protected]": {
"integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==",
"dependencies": [ "dependencies": [
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@aws-sdk/[email protected]": { "@aws-sdk/[email protected]": {
"integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==",
"dependencies": [ "dependencies": [
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@aws/[email protected]": { "@aws/[email protected]": {
@@ -833,6 +836,9 @@
], ],
"scripts": true "scripts": true
}, },
"@hexagon/[email protected]": {
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="
},
"@hono/[email protected][email protected]": { "@hono/[email protected][email protected]": {
"integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==",
"dependencies": [ "dependencies": [
@@ -931,6 +937,9 @@
"@jridgewell/sourcemap-codec" "@jridgewell/sourcemap-codec"
] ]
}, },
"@levischuck/[email protected]": {
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="
},
"@mjackson/[email protected]": { "@mjackson/[email protected]": {
"integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==" "integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng=="
}, },
@@ -1008,6 +1017,132 @@
"@open-draft/[email protected]": { "@open-draft/[email protected]": {
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==" "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="
}, },
"@peculiar/[email protected]": {
"integrity": "sha512-KnNE4l033gbJ3ZzfYk7fJpSZwhd5xT+dPAnrB8bcwqylHiLN2zjZ0OyFao0LDIOI/ZdXRabV0n3Ww/KvpgZq6w==",
"dependencies": [
"@peculiar/asn1-schema",
"asn1js",
"[email protected]"
]
},
"@peculiar/[email protected]": {
"integrity": "sha512-4Ivz0fsyFofe0bslNsYcGN9+iETdHX4v+BFwIPr+K/BAt61sHXECP63ff9FntU9zcfVQELJFdUG9so44OjUlBQ==",
"dependencies": [
"@peculiar/asn1-schema",
"@peculiar/asn1-x509",
"@peculiar/asn1-x509-attr",
"asn1js",
"[email protected]"
]
},
"@peculiar/[email protected]": {
"integrity": "sha512-9dO6PcoJeI+1xJ4rG7eINMa6f9Ix/QdRs5UGZ0D7v/R3TzmsbVr5cqAgfYw4xgS9U54ouHYqEz0bIZBbM/UY1g==",
"dependencies": [
"@peculiar/asn1-schema",
"@peculiar/asn1-x509",
"asn1js",
"[email protected]"
]
},
"@peculiar/[email protected]": {
"integrity": "sha512-OD+HLlcV7wCL64w0rheX/C0J31DbJce0nf8ulv3UNftQ617JEZE3sQ4cMN6PotXWlJ1M5GQx90G3bywtdKOstw==",
"dependencies": [
"@peculiar/asn1-schema",
"@peculiar/asn1-x509",
"asn1js",
"[email protected]"
]
},
"@peculiar/[email protected]": {
"integrity": "sha512-btcH7lbcD4410qnjHvv7F0kEuMNKbX8CFAZSs9CDPWHKzT1byLPHYeR40AnUw9z4CzsijMY0daBFYUvVx75sdA==",
"dependencies": [
"@peculiar/asn1-cms",
"@peculiar/asn1-pkcs8",
"@peculiar/asn1-rsa",
"@peculiar/asn1-schema",
"asn1js",
"[email protected]"
]
},
"@peculiar/[email protected]": {
"integrity": "sha512-c7UghAGQdBfAHL9JdwcJkAc/lDJKNXfCdYXPXlf8BZdD/fVJjJnXKHxX4gyLhkFTvnGhME7caieSgc2ROtqYTQ==",
"dependencies": [
"@peculiar/asn1-schema",
"@peculiar/asn1-x509",
"asn1js",
"[email protected]"
]
},
"@peculiar/[email protected]": {
"integrity": "sha512-4R/zMgLSHbQIZRBwP1KzX6iuNHmW3xFWwu9TS9A00N60fmQqp9DkkqnQFyd0mAYMQTrQefuzQ/yP4U8sOLGUgQ==",
"dependencies": [
"@peculiar/asn1-cms",
"@peculiar/asn1-pfx",
"@peculiar/asn1-pkcs8",
"@peculiar/asn1-schema",
"@peculiar/asn1-x509",
"@peculiar/asn1-x509-attr",
"asn1js",
"[email protected]"
]
},
"@peculiar/[email protected]": {
"integrity": "sha512-kHwBLVMkm9ljlM521ovS7y4z4mr8xuHTdNyyoPlDvckBwFKSgs9HqKBryLBeqHqL2YpIgl+Vspl7kzNGG2phzA==",
"dependencies": [
"@peculiar/asn1-schema",
"@peculiar/asn1-x509",
"asn1js",
"[email protected]"
]
},
"@peculiar/[email protected]": {
"integrity": "sha512-Ez3wLKVjaxdsLcgeWN4OE31QkM7oBOgKuuBJxldRYAkfYw2C+8zJPcSd/SThnbhszsEOlAmoaS5kIIkN29fGKQ==",
"dependencies": [
"@peculiar/utils",
"asn1js",
"[email protected]"
]
},
"@peculiar/[email protected]": {
"integrity": "sha512-PlbL1a74RZiry7puPCZ5hdk/puvivxYJPbjadr2i7DBjHCJFEKCfTRPKL3DILeZFtzJjn95J5EBtSc4/TB9/6Q==",
"dependencies": [
"@peculiar/asn1-schema",
"@peculiar/asn1-x509",
"asn1js",
"[email protected]"
]
},
"@peculiar/[email protected]": {
"integrity": "sha512-NP3ecvN6zTDlwCvogIWL7MJN9AZNuskkIbnlhrlkB97Mn2EqirBUQ8GKzsOmdAL3pRvjGu1Uk/a4DNfiZrWxhw==",
"dependencies": [
"@peculiar/asn1-schema",
"@peculiar/utils",
"asn1js",
"[email protected]"
]
},
"@peculiar/[email protected]": {
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"dependencies": [
"[email protected]"
]
},
"@peculiar/[email protected]": {
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
"dependencies": [
"@peculiar/asn1-cms",
"@peculiar/asn1-csr",
"@peculiar/asn1-ecc",
"@peculiar/asn1-pkcs9",
"@peculiar/asn1-rsa",
"@peculiar/asn1-schema",
"@peculiar/asn1-x509",
"pvtsutils",
"reflect-metadata",
"[email protected]",
"tsyringe"
]
},
"@pinojs/[email protected]": { "@pinojs/[email protected]": {
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==" "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="
}, },
@@ -2260,6 +2395,22 @@
"@sec-ant/[email protected]": { "@sec-ant/[email protected]": {
"integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==" "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="
}, },
"@simplewebauthn/[email protected]": {
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="
},
"@simplewebauthn/[email protected]": {
"integrity": "sha512-LelX/lcy5cjc15A86i/aNxHhB5eU7dd20QsbP0VLAf9e38+SLlsnqCCyecx3xqfGofhmX05h1J9fKRYWxw+luA==",
"dependencies": [
"@hexagon/base64",
"@levischuck/tiny-cbor",
"@peculiar/asn1-android",
"@peculiar/asn1-ecc",
"@peculiar/asn1-rsa",
"@peculiar/asn1-schema",
"@peculiar/asn1-x509",
"@peculiar/x509"
]
},
"@sindresorhus/[email protected]": { "@sindresorhus/[email protected]": {
"integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==" "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="
}, },
@@ -2267,7 +2418,7 @@
"integrity": "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==", "integrity": "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==",
"dependencies": [ "dependencies": [
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@smithy/[email protected]": { "@smithy/[email protected]": {
@@ -2275,7 +2426,7 @@
"dependencies": [ "dependencies": [
"@smithy/core", "@smithy/core",
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@smithy/[email protected]": { "@smithy/[email protected]": {
@@ -2283,7 +2434,7 @@
"dependencies": [ "dependencies": [
"@smithy/core", "@smithy/core",
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@smithy/[email protected]": { "@smithy/[email protected]": {
@@ -2291,7 +2442,7 @@
"dependencies": [ "dependencies": [
"@smithy/core", "@smithy/core",
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@smithy/[email protected]": { "@smithy/[email protected]": {
@@ -2299,13 +2450,13 @@
"dependencies": [ "dependencies": [
"@smithy/core", "@smithy/core",
"@smithy/types", "@smithy/types",
"tslib" "tslib@2.8.1"
] ]
}, },
"@smithy/[email protected]": { "@smithy/[email protected]": {
"integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==",
"dependencies": [ "dependencies": [
"tslib" "tslib@2.8.1"
] ]
}, },
"@stablelib/[email protected]": { "@stablelib/[email protected]": {
@@ -2720,19 +2871,27 @@
"[email protected]": { "[email protected]": {
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"dependencies": [ "dependencies": [
"tslib" "tslib@2.8.1"
] ]
}, },
"[email protected]": { "[email protected]": {
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="
}, },
"[email protected]": {
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"dependencies": [
"pvtsutils",
"pvutils",
"[email protected]"
]
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==" "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="
}, },
"[email protected]": { "[email protected]": {
"integrity": "sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==", "integrity": "sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==",
"dependencies": [ "dependencies": [
"tslib" "tslib@2.8.1"
] ]
}, },
"[email protected]": { "[email protected]": {
@@ -4618,6 +4777,24 @@
"once" "once"
] ]
}, },
"[email protected]": {
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"dependencies": [
"[email protected]"
]
},
"[email protected]": {
"integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="
},
"[email protected]": {
"integrity": "sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==",
"dependencies": [
"qrcode-generator"
]
},
"[email protected]": {
"integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"dependencies": [ "dependencies": [
@@ -4756,7 +4933,7 @@
"@types/react", "@types/react",
"react", "react",
"react-style-singleton", "react-style-singleton",
"tslib" "tslib@2.8.1"
], ],
"optionalPeers": [ "optionalPeers": [
"@types/react" "@types/react"
@@ -4769,7 +4946,7 @@
"react", "react",
"react-remove-scroll-bar", "react-remove-scroll-bar",
"react-style-singleton", "react-style-singleton",
"tslib", "tslib@2.8.1",
"use-callback-ref", "use-callback-ref",
"use-sidecar" "use-sidecar"
], ],
@@ -4795,7 +4972,7 @@
"@types/react", "@types/react",
"get-nonce", "get-nonce",
"react", "react",
"tslib" "tslib@2.8.1"
], ],
"optionalPeers": [ "optionalPeers": [
"@types/react" "@types/react"
@@ -4820,7 +4997,7 @@
"esprima", "esprima",
"source-map", "source-map",
"tiny-invariant", "tiny-invariant",
"tslib" "tslib@2.8.1"
] ]
}, },
"[email protected][email protected][email protected][email protected]_@[email protected][email protected]": { "[email protected][email protected][email protected][email protected]_@[email protected][email protected]": {
@@ -4851,6 +5028,9 @@
"[email protected]": { "[email protected]": {
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
}, },
"[email protected]": {
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="
}, },
@@ -5278,9 +5458,18 @@
"strip-bom" "strip-bom"
] ]
}, },
"[email protected]": {
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
}, },
"[email protected]": {
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"dependencies": [
"[email protected]"
]
},
"[email protected]": { "[email protected]": {
"integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==" "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="
}, },
@@ -5359,7 +5548,7 @@
"dependencies": [ "dependencies": [
"@types/react", "@types/react",
"react", "react",
"tslib" "tslib@2.8.1"
], ],
"optionalPeers": [ "optionalPeers": [
"@types/react" "@types/react"
@@ -5371,7 +5560,7 @@
"@types/react", "@types/react",
"detect-node-es", "detect-node-es",
"react", "react",
"tslib" "tslib@2.8.1"
], ],
"optionalPeers": [ "optionalPeers": [
"@types/react" "@types/react"
@@ -5607,6 +5796,7 @@
"npm:@earendil-works/[email protected]", "npm:@earendil-works/[email protected]",
"npm:@hono/[email protected]", "npm:@hono/[email protected]",
"npm:@hono/[email protected]", "npm:@hono/[email protected]",
"npm:@simplewebauthn/[email protected]",
"npm:@types/[email protected]", "npm:@types/[email protected]",
"npm:@types/[email protected]", "npm:@types/[email protected]",
"npm:@types/[email protected]", "npm:@types/[email protected]",
@@ -5638,6 +5828,7 @@
"npm:@fontsource-variable/space-grotesk@^5.2.10", "npm:@fontsource-variable/space-grotesk@^5.2.10",
"npm:@react-router/[email protected]", "npm:@react-router/[email protected]",
"npm:@react-router/[email protected]", "npm:@react-router/[email protected]",
"npm:@simplewebauthn/browser@^13.3.0",
"npm:@tailwindcss/vite@^4.1.13", "npm:@tailwindcss/vite@^4.1.13",
"npm:@tanstack/react-query@^5.90.21", "npm:@tanstack/react-query@^5.90.21",
"npm:@types/react-dom@^19.2.3", "npm:@types/react-dom@^19.2.3",
@@ -5648,6 +5839,7 @@
"npm:isbot@^5.1.31", "npm:isbot@^5.1.31",
"npm:[email protected]", "npm:[email protected]",
"npm:next-themes@~0.4.6", "npm:next-themes@~0.4.6",
"npm:qr-code-styling@^1.9.2",
"npm:radix-ui@^1.4.3", "npm:radix-ui@^1.4.3",
"npm:react-day-picker@^10.0.1", "npm:react-day-picker@^10.0.1",
"npm:react-dom@^19.2.4", "npm:react-dom@^19.2.4",
+2 -2
View File
@@ -3,7 +3,7 @@
# root 启动:建好 DATA_DIR 子目录、按 PUID/PGID 修正属主,再用 gosu 降权重跑自己(二进制与 /app 保持 root 只读)。 # root 启动:建好 DATA_DIR 子目录、按 PUID/PGID 修正属主,再用 gosu 降权重跑自己(二进制与 /app 保持 root 只读)。
# serve(缺省):迁移 → 启动自检 → api、worker(io 组)、worker:agent(Agent 会话)三个进程互相看护。 # serve(缺省):迁移 → 启动自检 → api、worker(io 组)、worker:agent(Agent 会话)三个进程互相看护。
# 任一进程退出就停掉另一个并以非 0 退出,把"半死容器"变成整体退出,交给 restart 策略自愈。 # 任一进程退出就停掉另一个并以非 0 退出,把"半死容器"变成整体退出,交给 restart 策略自愈。
# backup / restore <备份目录> / migrate / preflight / export-mappings ...:一次性子命令(降权后执行)。 # backup / restore <备份目录> / migrate / preflight / export-mappings / rotate-master-key ...:一次性子命令(降权后执行)。
# 其它参数原样 exec(例如 bash)。 # 其它参数原样 exec(例如 bash)。
set -euo pipefail set -euo pipefail
@@ -34,7 +34,7 @@ case "$cmd" in
serve) ;; serve) ;;
backup) shift; exec /app/scripts/backup.sh "$@" ;; backup) shift; exec /app/scripts/backup.sh "$@" ;;
restore) shift; exec /app/scripts/restore.sh "$@" ;; restore) shift; exec /app/scripts/restore.sh "$@" ;;
migrate | preflight | export-mappings | read-only-on) shift; exec "$BIN" --role "$cmd" "$@" ;; migrate | preflight | export-mappings | read-only-on | rotate-master-key) shift; exec "$BIN" --role "$cmd" "$@" ;;
*) exec "$@" ;; *) exec "$@" ;;
esac esac
+35 -6
View File
@@ -14,6 +14,8 @@
```sql ```sql
update admin_user set username = null, password_hash = null, totp_secret_ref = null, totp_enabled_at = null; update admin_user set username = null, password_hash = null, totp_secret_ref = null, totp_enabled_at = null;
update admin_session set revoked_at = now(), revoke_reason = 'reset' where revoked_at is null; update admin_session set revoked_at = now(), revoke_reason = 'reset' where revoked_at is null;
delete from admin_recovery_code;
delete from admin_passkey;
``` ```
然后重启 api 进程,按日志里新的 setup token 重新初始化。 然后重启 api 进程,按日志里新的 setup token 重新初始化。
@@ -24,34 +26,61 @@ update admin_session set revoked_at = now(), revoke_reason = 'reset' where revok
|---|---|---| |---|---|---|
| `GET /auth/session` | 免 | `{initialized, authenticated, actor, expiresAt, totpEnabled}`;有会话时顺带补发 CSRF cookie | | `GET /auth/session` | 免 | `{initialized, authenticated, actor, expiresAt, totpEnabled}`;有会话时顺带补发 CSRF cookie |
| `POST /auth/setup` | 免 | 见上 | | `POST /auth/setup` | 免 | 见上 |
| `POST /auth/login` | 免 | `{username, password, totpCode?}` → `{status:"ok", session}` 或 `{status:"totp_required"}`(密码对但缺验证码,不计失败) | | `POST /auth/login` | 免 | `{username, password, totpCode? \| recoveryCode?}` → `{status:"ok", session}` 或 `{status:"totp_required"}`(密码对但缺验证码,不计失败) |
| `POST /auth/passkeys/login/options` | 免 | Passkey 登录挑战 `{challengeId, options}`(没有登记任何 Passkey 时 412) |
| `POST /auth/passkeys/login/verify` | 免 | `{challengeId, response}` → `{status:"ok", session}`(下发与密码登录相同的会话 cookie) |
| `POST /auth/logout` | 登录 | 软删除吊销当前会话并清 cookie | | `POST /auth/logout` | 登录 | 软删除吊销当前会话并清 cookie |
| `POST /auth/password` | 仅 cookie 会话 | 校验旧密码 → 改密 → **吊销其它所有会话**(保留当前) | | `POST /auth/password` | 仅 cookie 会话 | 校验旧密码 → 改密 → **吊销其它所有会话**(保留当前) |
| `GET/POST /auth/totp` | 仅 cookie 会话 | `{action:"begin"}` 返回 otpauth URI;`{action:"enable", code}`;`{action:"disable", password, code}` | | `GET /auth/totp` | 仅 cookie 会话 | `{enabled, pending, recoveryCodesRemaining}` |
| `GET /auth/totp/setup` | 仅 cookie 会话 | 待确认密钥 `{otpauthUri, secret, issuer, account}`,没有就生成、已有原样返回(刷新不作废已扫的码);已开启 409。控制台用 `qr-code-styling` 把 otpauthUri 渲染成二维码 |
| `POST /auth/totp` | 仅 cookie 会话 | `{action:"begin"}` 重新生成密钥;`{action:"enable", code}` 返回 8 个恢复码;`{action:"disable", password, code}`(code 可以是验证码或恢复码);`{action:"recovery_codes", password}` 重新生成恢复码 |
| `GET /auth/passkeys`、`POST /auth/passkeys/register/options`、`POST /auth/passkeys/register/verify {challengeId, name, response}`、`PATCH /auth/passkeys/{id} {name}`、`DELETE /auth/passkeys/{id}` | 仅 cookie 会话 | Passkey 列表 / 注册 / 重命名 / 删除 |
- 会话:32 字节随机 token 进 `umt_session` cookie(HttpOnly、SameSite=Lax、Path=/,HTTPS 时 Secure),库里只存 sha256;固定 12 小时,不续期;`last_seen_at` 60 秒节流更新。 - 会话:32 字节随机 token 进 `umt_session` cookie(HttpOnly、SameSite=Lax、Path=/,HTTPS 时 Secure),库里只存 sha256;固定 12 小时,不续期;`last_seen_at` 60 秒节流更新。
- CSRF:`umt_csrf` cookie(非 HttpOnly)= `HMAC-SHA256(key=会话 token, "umt/csrf/v1")`;cookie 会话的 POST/PUT/PATCH/DELETE 必须带 `x-csrf-token` 头等于该值,否则 403。Bearer 调用不校验 CSRF。 - CSRF:`umt_csrf` cookie(非 HttpOnly)= `HMAC-SHA256(key=会话 token, "umt/csrf/v1")`;cookie 会话的 POST/PUT/PATCH/DELETE 必须带 `x-csrf-token` 头等于该值,否则 403。Bearer 调用不校验 CSRF。
- 限速(落 `login_attempt` 表,多进程一致):15 分钟窗口内同 IP 失败 ≥5 次、或同账号失败 ≥15 次 → 429(账号维度取 3 倍,和 uhd_doc 一致,否则单个 IP 就能把管理员锁死)。窗口从最近一次成功之后算起;被拦下的请求不落表、不写审计。setup 失败也计入 IP 维度。 - 限速(落 `login_attempt` 表,多进程一致):15 分钟窗口内同 IP 失败 ≥5 次、或同账号失败 ≥15 次 → 429(账号维度取 3 倍,和 uhd_doc 一致,否则单个 IP 就能把管理员锁死)。窗口从最近一次成功之后算起;被拦下的请求不落表、不写审计。setup 失败也计入 IP 维度。
- 客户端 IP:直连对端是回环/私网时才信 `X-Real-IP` / `X-Forwarded-For` 最后一跳,否则用对端地址;部署在公网反代后面时反代必须覆盖这两个头。 - 客户端 IP:直连对端是回环/私网时才信 `X-Real-IP` / `X-Forwarded-For` 最后一跳,否则用对端地址;部署在公网反代后面时反代必须覆盖这两个头。
- 密码:argon2id(m=19456 KiB, t=2, p=1),用 `hash-wasm`(WASM)。没选 `@node-rs/argon2` / `@denosaurs/argon2`:都是原生库,需要 `--allow-ffi`,而编译后的二进制只授予 net/env/sys/read。输出标准 PHC 串,与 uhd_doc 的 Bun.password 互认。 - 密码:argon2id(m=19456 KiB, t=2, p=1),用 `hash-wasm`(WASM)。没选 `@node-rs/argon2` / `@denosaurs/argon2`:都是原生库,需要 `--allow-ffi`,而编译后的二进制只授予 net/env/sys/read。输出标准 PHC 串,与 uhd_doc 的 Bun.password 互认。
- TOTP:RFC 6238(SHA1/6 位/30 秒),密钥存 secret(purpose=other),`totp_last_counter` 防重放。Passkey 未做。 - TOTP:RFC 6238(SHA1/6 位/30 秒),密钥存 secret(purpose=other),`totp_last_counter` 防重放。
- 恢复码:开启 TOTP 时一次性生成 8 个(`XXXXX-XXXXX`,不含易混的 I/O/0/1),明文只在开启 / 重新生成的响应里出现一次,库里 `admin_recovery_code` 存 argon2id 哈希;登录第二步用 `recoveryCode` 代替 `totpCode`,用过即作废(条件更新,并发只成功一次),审计 `admin.recovery_code_login`。输错计入失败限速(reason=`bad_recovery_code`)。停用 TOTP 时恢复码一并删除。
- Passkey(`@simplewebauthn/server`,做法照 uhd_doc):替代密码的第一因子,登录成功直接建会话(不再要求 TOTP)。可以登记多把,各自命名;`admin_passkey` 存 COSE 公钥与签名计数器(计数器回退即拒绝,防克隆)。挑战存 `webauthn_challenge`,一次性、5 分钟有效。失败计入 IP 维度限速(账号维度记为 `(passkey)`),与密码登录共用 15 分钟窗口。
- RP ID:环境变量 `WEBAUTHN_RP_ID`(浏览器地址栏里的主机名,不含协议和端口);不设时取请求 Host(反代后面取 `X-Forwarded-Host`)。**换域名后已登记的 Passkey 全部失效**,需要用密码登录后重新登记。
- 来源:`WEBAUTHN_ORIGINS`(逗号分隔,例如 `https://media.example.com`);不设时接受主机名等于 RP ID 或其子域的来源。站点名 `WEBAUTHN_RP_NAME`(缺省 UHD Media Tool)。
- 浏览器只在安全上下文(HTTPS 或 localhost)里提供 Passkey;用 IP 访问时登录页不显示入口。
- 清理:worker 每小时删 7 天前的 `login_attempt`、过期/吊销超过 30 天的 `admin_session`。 - 清理:worker 每小时删 7 天前的 `login_attempt`、过期/吊销超过 30 天的 `admin_session`。
## 鉴权中间件(其他模块怎么用) ## 鉴权中间件(其他模块怎么用)
`/api/v1/*`(health 与上面三个免鉴权端点除外)统一经过 `apiKeyAuth`: `/api/v1/*`(health 与上面五个免鉴权端点除外)统一经过 `apiKeyAuth`:
- `Authorization: Bearer <key>` → API Key(开发模式另认 `DEV_ADMIN_KEY`,生产忽略); - `Authorization: Bearer <key>` → API Key(开发模式另认 `DEV_ADMIN_KEY`,生产忽略);
- 否则 `umt_session` cookie → 管理员会话(scopes 视为 `["admin"]`)。 - 否则 `umt_session` cookie → 管理员会话(scopes 视为 `["admin"]`)。
处理器里:`c.get("actor")` → `{ type: "admin" | "api_key", id, name, scopes }`(contracts `Actor`);`c.get("session")` 仅 cookie 会话有值。路由照旧挂 `requireScope("read" | "organize.read" | "organize.execute" | "admin")`,两种调用者一视同仁;只允许控制台会话的操作在 contracts 注册表里标 `auth: "session"`(路由封装据此自动挂 `requireAdminSession()`,API Key 包括 admin scope 与 `DEV_ADMIN_KEY` 一律 403),免鉴权端点标 `auth: "public"` 且必须与 `PUBLIC_AUTH_PATHS` 一致(`api-registry.test` 断言)。目前标 session 的有:`POST /auth/password`、`GET/POST /auth/totp`、`POST /approvals`、`PATCH /approvals/{id}`。旧的 `c.get("principal")` 仍可用(`kind` 多了 `admin_session`)。写审计时 actor_type:admin → `user`,API Key → `api_key`。 处理器里:`c.get("actor")` → `{ type: "admin" | "api_key", id, name, scopes }`(contracts `Actor`);`c.get("session")` 仅 cookie 会话有值。路由照旧挂 `requireScope("read" | "organize.read" | "organize.execute" | "admin")`,两种调用者一视同仁;只允许控制台会话的操作在 contracts 注册表里标 `auth: "session"`(路由封装据此自动挂 `requireAdminSession()`,API Key 包括 admin scope 与 `DEV_ADMIN_KEY` 一律 403),免鉴权端点标 `auth: "public"` 且必须与 `PUBLIC_AUTH_PATHS` 一致(`api-registry.test` 断言)。目前标 session 的有:`POST /auth/password`、`GET/POST /auth/totp`、`GET /auth/totp/setup`、`/auth/passkeys*`(登录两步除外)、`POST /approvals`、`PATCH /approvals/{id}`。旧的 `c.get("principal")` 仍可用(`kind` 多了 `admin_session`)。写审计时 actor_type:admin → `user`,API Key → `api_key`。
## 密钥服务 `ctx.secrets` ## 密钥服务 `ctx.secrets`
- `create({purpose, plaintext, label?}) → secretRef`、`resolve(ref) → 明文`、`replace(ref, plaintext)`(轮换)、`remove(ref)`。 - `create({purpose, plaintext, label?}) → secretRef`、`resolve(ref) → 明文`、`replace(ref, plaintext)`(轮换)、`remove(ref)`。
- AES-256-GCM;每条 secret 随机 16 字节盐经 HKDF-SHA256 从 `SECRET_MASTER_KEY` 派生独立密钥(info=`umt/secret/v1`),随机 12 字节 nonce,AAD 绑定 `(id, purpose)`(密文挪行/改用途即解密失败)。 - AES-256-GCM;每条 secret 随机 16 字节盐经 HKDF-SHA256 从 `SECRET_MASTER_KEY` 派生独立密钥(info=`umt/secret/v1`),随机 12 字节 nonce,AAD 绑定 `(id, purpose)`(密文挪行/改用途即解密失败)。
- 其它表只存 `secret_ref`;需要明文时现场 `resolve`,不缓存、不写日志、不进模型上下文。失败抛 `SecretError`(消息只含 ref)。 - 其它表只存 `secret_ref`;需要明文时现场 `resolve`,不缓存、不写日志、不进模型上下文。失败抛 `SecretError`(消息只含 ref)。
- `SECRET_MASTER_KEY` 与数据库备份分开保管;丢失即所有凭证不可解密(需要重新录入)。主密钥轮换(重新加密全表)尚未实现,`key_version` 已预留。 - `SECRET_MASTER_KEY` 与数据库备份分开保管;丢失即所有凭证不可解密(需要重新录入)。`kdf_params.kid` 记着加密用的主密钥指纹(HMAC 派生,不能反推密钥)。
### 主密钥轮换
一次性命令 `--role rotate-master-key`:用 `SECRET_MASTER_KEY` 解密、`SECRET_MASTER_KEY_NEW` 重新加密 secret 表。逐行一个事务(`select … for update` → 解密 → 新盐新 nonce 加密 → 写回);已经能被新密钥解开的行跳过,所以**中断后原样重跑即可**。每 50 行一条进度日志,结束写审计 `secret.master_key_rotate`(只有计数与新密钥指纹)。新旧密钥都解不开的行保持原样,在日志里列出 id,命令退出码 1。
1. 备份数据库(`docs/runbook/backup-restore.md`),旧密钥保留在原处。
2. 生成新密钥:`openssl rand -hex 32`,存进密码管理器。
3. 停掉 api 与 worker(轮换期间还在跑的进程只认旧密钥,读到已换过的行会解密失败):`docker compose stop app`。
4. 执行轮换,两个密钥同时给:
```sh
docker compose run --rm --no-deps -e SECRET_MASTER_KEY_NEW=<新密钥> app rotate-master-key
```
日志最后一条是"主密钥轮换完成"(退出码 0)才算完成;中断或报错就原样再执行一次。
5. 把 `.env` 里的 `SECRET_MASTER_KEY` 换成新密钥,删掉 `SECRET_MASTER_KEY_NEW`,启动:`docker compose up -d app`。到设置页各实例点"测试连接"确认凭证可用。
6. 旧密钥在确认无误、且新密钥之后做过一次备份以前**不要销毁**:旧备份只能用旧密钥解。
回滚(第 5 步之前任何时候,或启动后发现凭证不可用):停 app,新旧对调再跑一遍——`docker compose run --rm --no-deps -e SECRET_MASTER_KEY=<新密钥> -e SECRET_MASTER_KEY_NEW=<旧密钥> app rotate-master-key`(开发环境:`deno run -A --env-file=.env apps/server/src/main.ts --role rotate-master-key`),完成后 `.env` 保持旧密钥启动。也可以直接用第 1 步的备份恢复。
- 管理面:`GET/POST /secrets`、`PATCH /secrets/{id}`(轮换)、`DELETE /secrets/{id}`(仍被引用 → 409)。只返回元数据。 - 管理面:`GET/POST /secrets`、`PATCH /secrets/{id}`(轮换)、`DELETE /secrets/{id}`(仍被引用 → 409)。只返回元数据。
## 设置 `ctx.settings` ## 设置 `ctx.settings`
+70 -1
View File
@@ -1787,10 +1787,79 @@ export const Api = {
path: "/auth/totp", path: "/auth/totp",
scope: "admin", scope: "admin",
auth: "session", auth: "session",
summary: "TOTP 绑定/启用/停用", summary: "TOTP 绑定/启用/停用/重新生成恢复码",
body: auth.TotpAction, body: auth.TotpAction,
response: auth.TotpActionResult, response: auth.TotpActionResult,
}), }),
totpSetup: ep({
method: "get",
path: "/auth/totp/setup",
scope: "admin",
auth: "session",
summary: "待确认的 TOTP 密钥与 otpauth URL(前端渲染二维码)",
response: auth.TotpSetup,
}),
passkeys: ep({
method: "get",
path: "/auth/passkeys",
scope: "admin",
auth: "session",
summary: "已登记的 Passkey",
response: auth.PasskeyList,
}),
passkeyRegisterOptions: ep({
method: "post",
path: "/auth/passkeys/register/options",
scope: "admin",
auth: "session",
summary: "Passkey 注册挑战",
response: auth.PasskeyChallenge,
}),
passkeyRegister: ep({
method: "post",
path: "/auth/passkeys/register/verify",
scope: "admin",
auth: "session",
summary: "校验并登记 Passkey",
body: auth.PasskeyRegisterRequest,
response: auth.PasskeyInfo,
}),
passkeyRename: ep({
method: "patch",
path: "/auth/passkeys/{id}",
scope: "admin",
auth: "session",
summary: "重命名 Passkey",
params: IdParam,
body: auth.PasskeyRenameRequest,
response: auth.PasskeyInfo,
}),
passkeyDelete: ep({
method: "delete",
path: "/auth/passkeys/{id}",
scope: "admin",
auth: "session",
summary: "删除 Passkey",
params: IdParam,
response: OkResult,
}),
passkeyLoginOptions: ep({
method: "post",
path: "/auth/passkeys/login/options",
scope: "read",
auth: "public",
summary: "Passkey 登录挑战(免鉴权)",
response: auth.PasskeyChallenge,
}),
passkeyLogin: ep({
method: "post",
path: "/auth/passkeys/login/verify",
scope: "read",
auth: "public",
summary: "Passkey 登录(免鉴权;成功即下发会话)",
body: auth.PasskeyLoginRequest,
response: auth.LoginResult,
}),
}, },
// ── 事件 ── // ── 事件 ──
+95 -4
View File
@@ -43,12 +43,24 @@ export type AdminSetupRequest = z.infer<typeof AdminSetupRequest>;
/** 6 位 TOTP 码 */ /** 6 位 TOTP 码 */
export const TotpCode = z.string().regex(/^\d{6}$/, "验证码为 6 位数字"); export const TotpCode = z.string().regex(/^\d{6}$/, "验证码为 6 位数字");
/** 备用恢复码:10 位字母数字(不含易混的 I/O/0/1),展示为 XXXXX-XXXXX;输入时忽略大小写、空格与连字符 */
export const RecoveryCode = z
.string()
.trim()
.min(10)
.max(32)
.regex(/^[A-Za-z0-9\s-]+$/, "恢复码格式不正确");
/** 启用 TOTP 时一次性生成的恢复码个数 */
export const RECOVERY_CODE_COUNT = 8;
/** POST /auth/login */ /** POST /auth/login */
export const LoginRequest = z.object({ export const LoginRequest = z.object({
username: z.string().min(1).max(64), username: z.string().min(1).max(64),
password: z.string().min(1).max(256), password: z.string().min(1).max(256),
/** 开启 TOTP 后第二步提交 */ /** 开启 TOTP 后第二步提交 */
totpCode: TotpCode.optional(), totpCode: TotpCode.optional(),
/** 开启 TOTP 后第二步也可以用一次性恢复码代替验证码(用过即作废) */
recoveryCode: RecoveryCode.optional(),
}); });
export type LoginRequest = z.infer<typeof LoginRequest>; export type LoginRequest = z.infer<typeof LoginRequest>;
@@ -60,6 +72,8 @@ export const SessionInfo = z.object({
/** 仅 cookie 会话有值 */ /** 仅 cookie 会话有值 */
expiresAt: Timestamp.nullable(), expiresAt: Timestamp.nullable(),
totpEnabled: z.boolean(), totpEnabled: z.boolean(),
/** 已登记至少一把 Passkey(登录页据此显示"用 Passkey 登录") */
passkeyEnabled: z.boolean(),
}); });
export type SessionInfo = z.infer<typeof SessionInfo>; export type SessionInfo = z.infer<typeof SessionInfo>;
@@ -83,16 +97,40 @@ export const PasswordChangeResult = z.object({ revokedSessions: z.number().int()
export type PasswordChangeResult = z.infer<typeof PasswordChangeResult>; export type PasswordChangeResult = z.infer<typeof PasswordChangeResult>;
/** GET /auth/totp */ /** GET /auth/totp */
export const TotpStatus = z.object({ enabled: z.boolean(), pending: z.boolean() }); export const TotpStatus = z.object({
enabled: z.boolean(),
pending: z.boolean(),
/** 未用过的恢复码个数(未开启时为 0) */
recoveryCodesRemaining: z.number().int().nonnegative(),
});
export type TotpStatus = z.infer<typeof TotpStatus>; export type TotpStatus = z.infer<typeof TotpStatus>;
/** /**
* POST /auth/totp:begin 生成待确认密钥(返回 otpauth URI,只此一次);enable 用验证码确认; * GET /auth/totp/setup:待确认的 TOTP 密钥(没有就生成一个;已有未确认的原样返回,刷新页面不作废)。
* disable 需要当前密码 + 验证码 * 前端把 otpauthUri 渲染成二维码;已开启时 409。
*/
export const TotpSetup = z.object({
otpauthUri: z.string(),
/** base32 密钥(无法扫码时手动输入) */
secret: z.string(),
issuer: z.string(),
account: z.string(),
});
export type TotpSetup = z.infer<typeof TotpSetup>;
/**
* POST /auth/totp:begin 生成待确认密钥(返回 otpauth URI,只此一次);enable 用验证码确认(同时生成恢复码);
* disable 需要当前密码 + 验证码(或恢复码);recovery_codes 用当前密码重新生成恢复码(旧的全部作废)
*/ */
export const TotpAction = z.discriminatedUnion("action", [ export const TotpAction = z.discriminatedUnion("action", [
z.object({ action: z.literal("begin") }), z.object({ action: z.literal("begin") }),
z.object({ action: z.literal("enable"), code: TotpCode }), z.object({ action: z.literal("enable"), code: TotpCode }),
z.object({ action: z.literal("disable"), password: z.string().min(1).max(256), code: TotpCode }), z.object({
action: z.literal("disable"),
password: z.string().min(1).max(256),
code: z.union([TotpCode, RecoveryCode]),
}),
z.object({ action: z.literal("recovery_codes"), password: z.string().min(1).max(256) }),
]); ]);
export type TotpAction = z.infer<typeof TotpAction>; export type TotpAction = z.infer<typeof TotpAction>;
export const TotpActionResult = z.object({ export const TotpActionResult = z.object({
@@ -100,9 +138,55 @@ export const TotpActionResult = z.object({
/** 仅 begin 返回:otpauth://totp/... 与 base32 密钥 */ /** 仅 begin 返回:otpauth://totp/... 与 base32 密钥 */
otpauthUri: z.string().optional(), otpauthUri: z.string().optional(),
secret: z.string().optional(), secret: z.string().optional(),
/** 仅 enable / recovery_codes 返回:新生成的恢复码明文(只显示这一次,库里只存哈希) */
recoveryCodes: z.array(z.string()).optional(),
}); });
export type TotpActionResult = z.infer<typeof TotpActionResult>; export type TotpActionResult = z.infer<typeof TotpActionResult>;
// ── Passkey(WebAuthn,替代密码的第一因子;登录成功直接建会话,不再要求 TOTP) ──
export const PasskeyName = z.string().trim().min(1, "输入名称").max(60);
export const PasskeyInfo = z.object({
id: z.string(),
name: z.string(),
/** singleDevice / multiDevice(可同步) */
deviceType: z.string().nullable(),
backedUp: z.boolean(),
createdAt: Timestamp,
lastUsedAt: Timestamp.nullable(),
});
export type PasskeyInfo = z.infer<typeof PasskeyInfo>;
export const PasskeyList = z.object({ items: z.array(PasskeyInfo) });
export type PasskeyList = z.infer<typeof PasskeyList>;
/**
* 注册 / 登录挑战:options 原样交给 @simplewebauthn/browser(startRegistration / startAuthentication),
* challengeId 随校验请求带回(挑战存库、一次性、5 分钟有效)。
*/
export const PasskeyChallenge = z.object({
challengeId: z.string(),
/** WebAuthn 选项 JSON 由 @simplewebauthn 定义,合同里不重复建模 */
options: z.record(z.string(), z.unknown()),
});
export type PasskeyChallenge = z.infer<typeof PasskeyChallenge>;
const WebAuthnResponse = z.record(z.string(), z.unknown());
export const PasskeyRegisterRequest = z.object({
challengeId: z.string().uuid(),
name: PasskeyName,
/** startRegistration 的返回值(RegistrationResponseJSON) */
response: WebAuthnResponse,
});
export type PasskeyRegisterRequest = z.infer<typeof PasskeyRegisterRequest>;
export const PasskeyLoginRequest = z.object({
challengeId: z.string().uuid(),
/** startAuthentication 的返回值(AuthenticationResponseJSON) */
response: WebAuthnResponse,
});
export type PasskeyLoginRequest = z.infer<typeof PasskeyLoginRequest>;
export const PasskeyRenameRequest = z.object({ name: PasskeyName });
export type PasskeyRenameRequest = z.infer<typeof PasskeyRenameRequest>;
/** /**
* 审计动作枚举(审计写失败不影响主流程;被限速拦下的登录尝试不写审计)。 * 审计动作枚举(审计写失败不影响主流程;被限速拦下的登录尝试不写审计)。
* 新增动作只在这里加。 * 新增动作只在这里加。
@@ -114,6 +198,12 @@ export const AuditAction = z.enum([
"admin.password_change", "admin.password_change",
"admin.totp_enable", "admin.totp_enable",
"admin.totp_disable", "admin.totp_disable",
"admin.recovery_codes_regenerate",
"admin.recovery_code_login",
"admin.passkey_register",
"admin.passkey_rename",
"admin.passkey_remove",
"admin.passkey_login",
"api_key.create", "api_key.create",
"api_key.update", "api_key.update",
"api_key.revoke", "api_key.revoke",
@@ -121,6 +211,7 @@ export const AuditAction = z.enum([
"secret.create", "secret.create",
"secret.rotate", "secret.rotate",
"secret.delete", "secret.delete",
"secret.master_key_rotate",
"library_root.create", "library_root.create",
"library_root.update", "library_root.update",
"library_root.delete", "library_root.delete",