From b0e5d539edbe8d67501e190d6ac40efa20cade8a Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 20:22:07 +0800 Subject: [PATCH] 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 --- apps/server/src/config.ts | 11 + apps/server/src/http/auth.ts | 8 +- apps/server/src/main.ts | 14 +- apps/server/src/modules/agent/runner.ts | 5 +- .../src/modules/auth-settings/auth-routes.ts | 135 ++++++++- .../src/modules/auth-settings/auth-service.ts | 162 ++++++++-- .../src/modules/auth-settings/passkey.ts | 286 ++++++++++++++++++ .../src/modules/auth-settings/provided.ts | 120 +++++++- apps/server/test/api-registry.test.ts | 6 + .../auth-settings/account-security.test.ts | 232 ++++++++++++++ apps/server/test/auth-settings/login.test.ts | 6 +- .../auth-settings/rotate-master-key.test.ts | 101 +++++++ .../test/auth-settings/soft-authenticator.ts | 131 ++++++++ apps/server/test/e2e/e11-subagents.test.ts | 3 +- apps/web/package.json | 2 + deno.json | 1 + deno.lock | 258 ++++++++++++++-- docker/entrypoint.sh | 4 +- docs/runbook/auth.md | 41 ++- packages/contracts/src/api.ts | 71 ++++- packages/contracts/src/auth.ts | 99 +++++- 21 files changed, 1616 insertions(+), 80 deletions(-) create mode 100644 apps/server/src/modules/auth-settings/passkey.ts create mode 100644 apps/server/test/auth-settings/account-security.test.ts create mode 100644 apps/server/test/auth-settings/rotate-master-key.test.ts create mode 100644 apps/server/test/auth-settings/soft-authenticator.ts diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index e92b140b..ef1133e3 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -10,6 +10,17 @@ export const ConfigSchema = z.object({ HOST: z.string().min(1).default("0.0.0.0"), DATABASE_URL: z.string().regex(/^postgres(ql)?:\/\//, "必须是 postgres:// 连接串"), 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"), /** 仅开发模式生效的管理员 Key */ DEV_ADMIN_KEY: z.string().min(8).optional(), diff --git a/apps/server/src/http/auth.ts b/apps/server/src/http/auth.ts index 30734eb2..f35dd64a 100644 --- a/apps/server/src/http/auth.ts +++ b/apps/server/src/http/auth.ts @@ -110,7 +110,13 @@ export type AuthEnv = { }; /** 免鉴权路径(相对 /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"]); /** last_seen_at 节流写库间隔 */ const TOUCH_INTERVAL_MS = 60_000; diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 7b0520f6..899466d1 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -8,6 +8,8 @@ import type { MappingExport } from "./ops.ts"; // migrate | preflight | probe // export-mappings --out [--compare <基准清单.json>] 映射清单导出(可与基准比行数) // read-only-on [--reason <文本>] 打开 automation.readOnlyReconcile +// rotate-master-key 用 env SECRET_MASTER_KEY_NEW 重新加密 secret 表 +// (逐行事务、幂等可重跑;步骤与回滚见 docs/runbook/auth.md) // fsworker fsproxy 执行子进程(编译二进制里由 fsproxy 以 // `<自身> --role fsworker` 拉起,stdin 一条请求) const Roles = [ @@ -20,10 +22,11 @@ const Roles = [ "probe", "export-mappings", "read-only-on", + "rotate-master-key", "fsworker", ] as const; 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]; 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 { 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": { const { results } = await migrateToLatest(db); for (const m of results ?? []) logger.info({ migration: m.migrationName, status: m.status }, "migration"); diff --git a/apps/server/src/modules/agent/runner.ts b/apps/server/src/modules/agent/runner.ts index bc144e6d..2d1495ed 100644 --- a/apps/server/src/modules/agent/runner.ts +++ b/apps/server/src/modules/agent/runner.ts @@ -507,7 +507,10 @@ export async function goalSettled(env: AgentEnv, runId: string, modelText: strin .set({ status: "waiting", summary: r.summary }) .where("id", "=", runId) .where("status", "in", ["running", "waiting"]) - .returning(["parent_run_id", sql`(select status = 'running' from agent_run o where o.id = ${runId})`.as("was_running")]) + .returning([ + "parent_run_id", + sql`(select status = 'running' from agent_run o where o.id = ${runId})`.as("was_running"), + ]) .executeTakeFirst(); // 子会话刚转 waiting(对父会话来说已有结果):唤醒挂起等待的父会话 if (u?.parent_run_id && u.was_running) await wakeParent(env.ctx, runId); diff --git a/apps/server/src/modules/auth-settings/auth-routes.ts b/apps/server/src/modules/auth-settings/auth-routes.ts index cf485013..73d03b56 100644 --- a/apps/server/src/modules/auth-settings/auth-routes.ts +++ b/apps/server/src/modules/auth-settings/auth-routes.ts @@ -6,20 +6,36 @@ import { ApiException } from "../../http/errors.ts"; import type { ModuleApp, ServerContext } from "../types.ts"; import { adminActor, + assertNotRateLimited, type CreatedSession, changePassword, createSession, loadAdmin, + recordAttempt, revokeSession, sessionInfo, setupAdmin, totpBegin, totpDisable, totpEnable, + totpRegenerateRecoveryCodes, + totpSetup, totpStatus, verifyLogin, } from "./auth-service.ts"; import { audit, type Def, endpoint } from "./endpoint.ts"; +import { + authenticationOptions, + deletePasskey, + listPasskeys, + type RpInfo, + registrationOptions, + renamePasskey, + resolveRp, + toPasskeyInfo, + verifyAuthentication, + verifyRegistration, +} from "./passkey.ts"; // 登录门禁端点:/auth/setup、/auth/login、/auth/session 免鉴权(见 http/auth.ts PUBLIC_AUTH_PATHS), // 其余需要已登录;改密与 TOTP 只接受控制台 cookie 会话。 @@ -36,8 +52,19 @@ const D = { password: { ...Api.auth.password, tags }, totpGet: { ...Api.auth.totpStatus, tags }, totpPost: { ...Api.auth.totp, tags }, + totpSetup: { ...Api.auth.totpSetup, tags }, + passkeys: { ...Api.auth.passkeys, tags }, + passkeyRegisterOptions: { ...Api.auth.passkeyRegisterOptions, tags }, + passkeyRegister: { ...Api.auth.passkeyRegister, tags }, + passkeyRename: { ...Api.auth.passkeyRename, tags }, + passkeyDelete: { ...Api.auth.passkeyDelete, tags }, + passkeyLoginOptions: { ...Api.auth.passkeyLoginOptions, tags }, + passkeyLogin: { ...Api.auth.passkeyLogin, tags }, } satisfies Record; +/** Passkey 登录失败 / 限速计数用的账号维度键(免输入用户名) */ +const PASSKEY_ACCOUNT = "(passkey)"; + const PRIVATE_IP = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1$|fc|fd|::ffff:(127|10|192\.168)\.)/i; /** @@ -57,6 +84,13 @@ export function clientIp(c: Context): string { return xri || xff || remote || "unknown"; } +/** 请求主机(反代后面才信 X-Forwarded-Host),Passkey 未配置 RP ID 时用它 */ +function requestHost(c: Context): 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): boolean { if (new URL(c.req.url).protocol === "https:") return true; const remote = (c.env as { remoteAddr?: { hostname?: string } } | undefined)?.remoteAddr?.hostname; @@ -106,6 +140,10 @@ export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) { const s = await startSession(c, r.admin.id); const actor = adminActor(r.admin); await audit(ctx, actor, "admin.login_success", { type: "admin_session", id: s.id }, { ip: clientIp(c) }); + if (r.secondFactor === "recovery_code") { + const left = (await totpStatus(ctx.db, r.admin)).recoveryCodesRemaining; + await audit(ctx, actor, "admin.recovery_code_login", { type: "admin_user", id: r.admin.id }, { remaining: left }); + } return { status: "ok" as const, session: await sessionInfo(ctx.db, actor, s.expiresAt) }; }); @@ -146,22 +184,103 @@ export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) { return { revokedSessions: revoked }; }); - endpoint(app, D.totpGet, async () => totpStatus(await loadAdmin(ctx.db))); + endpoint(app, D.totpGet, async () => totpStatus(ctx.db, await loadAdmin(ctx.db))); - endpoint(app, D.totpPost, async ({ body, actor }) => { + const initializedAdmin = async () => { const admin = await loadAdmin(ctx.db); if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化"); + return admin; + }; + + endpoint(app, D.totpSetup, async () => { + const r = await totpSetup(deps(), await initializedAdmin()); + return { otpauthUri: r.uri, secret: r.secret, issuer: r.issuer, account: r.account }; + }); + + endpoint(app, D.totpPost, async ({ body, actor }) => { + const admin = await initializedAdmin(); + const status = async () => totpStatus(ctx.db, await loadAdmin(ctx.db)); if (body.action === "begin") { const r = await totpBegin(deps(), admin); - return { status: totpStatus(await loadAdmin(ctx.db)), otpauthUri: r.uri, secret: r.secret }; + return { status: await status(), otpauthUri: r.uri, secret: r.secret }; } if (body.action === "enable") { - await totpEnable(deps(), admin, body.code); + const codes = await totpEnable(deps(), admin, body.code); await audit(ctx, actor, "admin.totp_enable", { type: "admin_user", id: admin.id }); - } else { - await totpDisable(deps(), admin, body); - await audit(ctx, actor, "admin.totp_disable", { type: "admin_user", id: admin.id }); + return { status: await status(), recoveryCodes: codes }; } - return { status: totpStatus(await loadAdmin(ctx.db)) }; + if (body.action === "recovery_codes") { + const codes = await totpRegenerateRecoveryCodes(deps(), admin, body); + await audit(ctx, actor, "admin.recovery_codes_regenerate", { type: "admin_user", id: admin.id }); + return { status: await status(), recoveryCodes: codes }; + } + await totpDisable(deps(), admin, body); + await audit(ctx, actor, "admin.totp_disable", { type: "admin_user", id: admin.id }); + return { status: await status() }; + }); + + // ── Passkey ── + + const rp = (c: Context): 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>; + try { + key = await verifyAuthentication(ctx.db, admin, rp(c), body); + } catch (err) { + if (err instanceof ApiException && (err.code === "unauthorized" || err.code === "bad_request")) + await recordAttempt(ctx.db, { ip, username: PASSKEY_ACCOUNT, success: false, reason: "bad_passkey" }); + throw err; + } + await recordAttempt(ctx.db, { ip, username: PASSKEY_ACCOUNT, success: true }); + const s = await startSession(c, admin.id); + const actor = adminActor(admin); + await audit(ctx, actor, "admin.passkey_login", { type: "admin_session", id: s.id }, { ip, passkeyId: key.id }); + return { status: "ok" as const, session: await sessionInfo(ctx.db, actor, s.expiresAt) }; }); } diff --git a/apps/server/src/modules/auth-settings/auth-service.ts b/apps/server/src/modules/auth-settings/auth-service.ts index db997593..342f5442 100644 --- a/apps/server/src/modules/auth-settings/auth-service.ts +++ b/apps/server/src/modules/auth-settings/auth-service.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import type { Actor, SessionInfo } from "@umt/contracts"; +import { type Actor, RECOVERY_CODE_COUNT, type SessionInfo, type TotpStatus } from "@umt/contracts"; import { type DB, type Kysely, sql } from "@umt/db"; import type { Logger } from "pino"; import { hashSessionToken } from "../../http/auth.ts"; @@ -17,7 +17,8 @@ import { // 管理员登录门禁的业务逻辑(照 docs/design/auth-reference.md): // setup token 一次性初始化、argon2id、服务端会话(sha256 存库、固定 TTL、软删除吊销)、 -// IP + 账号双维度失败限速(落表)、改密吊销其它会话、可选 TOTP(防重放计数器)。 +// IP + 账号双维度失败限速(落表)、改密吊销其它会话、可选 TOTP(防重放计数器)+ 一次性恢复码(argon2id 哈希存库)。 +// Passkey 见 passkey.ts。 /** 固定会话时长 12 小时,无滑动续期 */ export const SESSION_TTL_MS = 12 * 3600_000; @@ -197,11 +198,13 @@ export async function setupAdmin( return admin; } -export type LoginOutcome = { status: "ok"; admin: AdminRow } | { status: "totp_required" }; +export type LoginOutcome = + | { status: "ok"; admin: AdminRow; secondFactor: "none" | "totp" | "recovery_code" } + | { status: "totp_required" }; export async function verifyLogin( deps: AuthDeps, - input: { username: string; password: string; totpCode?: string | undefined }, + input: { username: string; password: string; totpCode?: string | undefined; recoveryCode?: string | undefined }, ip: string, ): Promise { await assertNotRateLimited(deps.db, ip, input.username); @@ -216,16 +219,87 @@ export async function verifyLogin( await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_password" }); throw new ApiException("unauthorized", "用户名或密码错误"); } + let secondFactor: "none" | "totp" | "recovery_code" = "none"; if (admin.totp_enabled_at && admin.totp_secret_ref) { // 密码正确但缺第二步:不计失败 - if (!input.totpCode) return { status: "totp_required" }; - if (!(await consumeTotp(deps, admin, input.totpCode))) { - await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_totp" }); - throw new ApiException("unauthorized", "验证码错误"); + if (!input.totpCode && !input.recoveryCode) return { status: "totp_required" }; + if (input.totpCode) { + if (!(await consumeTotp(deps, admin, input.totpCode))) { + await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_totp" }); + throw new ApiException("unauthorized", "验证码错误"); + } + secondFactor = "totp"; + } else { + if (!(await consumeRecoveryCode(deps.db, admin.id, input.recoveryCode ?? ""))) { + await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_recovery_code" }); + throw new ApiException("unauthorized", "恢复码错误或已用过"); + } + secondFactor = "recovery_code"; } } await recordAttempt(deps.db, { ip, username: input.username, success: true }); - return { status: "ok", admin }; + return { status: "ok", admin, secondFactor }; +} + +// ── 恢复码 ── + +const RECOVERY_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + +/** 恢复码归一化:去空格与连字符、转大写(展示为 XXXXX-XXXXX,输入时怎么写都行) */ +export const normalizeRecoveryCode = (code: string) => code.replace(/[\s-]+/g, "").toUpperCase(); + +function newRecoveryCode(): string { + const bytes = randomBytes(10); + let out = ""; + for (const b of bytes) out += RECOVERY_ALPHABET[b % RECOVERY_ALPHABET.length]; + return `${out.slice(0, 5)}-${out.slice(5)}`; +} + +/** 重新生成恢复码(旧的全部作废);返回明文(只此一次),库里只存 argon2id 哈希 */ +export async function regenerateRecoveryCodes(db: Kysely, adminUserId: string): Promise { + 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, adminUserId: string, code: string): Promise { + 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, adminUserId: string): Promise { + const r = await db + .selectFrom("admin_recovery_code") + .select(sql`count(*)::int`.as("n")) + .where("admin_user_id", "=", adminUserId) + .where("used_at", "is", null) + .executeTakeFirst(); + return r?.n ?? 0; } /** 校验并消费 TOTP 计数器(条件更新防并发重放) */ @@ -267,23 +341,55 @@ export async function changePassword( // ── TOTP 管理 ── -export function totpStatus(admin: AdminRow | null) { - return { enabled: !!admin?.totp_enabled_at, pending: !!admin?.totp_pending_secret_ref }; +export async function totpStatus(db: Kysely, admin: AdminRow | null): Promise { + 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 { if (admin.totp_enabled_at) throw new ApiException("conflict", "TOTP 已启用,先停用再重新绑定"); const secret = base32Encode(randomBytes(20)); const ref = await deps.secrets.create({ purpose: "other", plaintext: secret, label: `admin-totp:${randomToken(6)}` }); const old = admin.totp_pending_secret_ref; await deps.db.updateTable("admin_user").set({ totp_pending_secret_ref: ref }).where("id", "=", admin.id).execute(); if (old) await deps.secrets.remove(old).catch(() => {}); - const label = encodeURIComponent(`${TOTP_ISSUER}:${admin.username ?? "admin"}`); - const uri = `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(TOTP_ISSUER)}&algorithm=SHA1&digits=6&period=30`; - return { uri, secret }; + const account = admin.username ?? "admin"; + return { uri: otpauthUri(secret, account), secret, issuer: TOTP_ISSUER, account }; } -export async function totpEnable(deps: AuthDeps, admin: AdminRow, code: string): Promise { +/** GET /auth/totp/setup:已有未确认的密钥就原样返回(刷新页面不作废已扫的码),没有才生成 */ +export async function totpSetup(deps: AuthDeps, admin: AdminRow): Promise { + 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 { if (!admin.totp_pending_secret_ref) throw new ApiException("precondition_failed", "先生成 TOTP 密钥"); const secret = await deps.secrets.resolve(admin.totp_pending_secret_ref); const counter = verifyTotp(secret, code, null); @@ -300,6 +406,20 @@ export async function totpEnable(deps: AuthDeps, admin: AdminRow, code: string): .where("id", "=", admin.id) .execute(); if (old) await deps.secrets.remove(old).catch(() => {}); + return await regenerateRecoveryCodes(deps.db, admin.id); +} + +/** 用当前密码重新生成恢复码(TOTP 必须已开启) */ +export async function totpRegenerateRecoveryCodes( + deps: AuthDeps, + admin: AdminRow, + input: { password: string }, +): Promise { + if (!admin.totp_enabled_at) throw new ApiException("precondition_failed", "TOTP 未启用"); + if (!admin.password_hash || !(await verifyPassword(input.password, admin.password_hash))) { + throw new ApiException("unauthorized", "密码错误"); + } + return await regenerateRecoveryCodes(deps.db, admin.id); } export async function totpDisable( @@ -311,13 +431,18 @@ export async function totpDisable( if (!admin.password_hash || !(await verifyPassword(input.password, admin.password_hash))) { throw new ApiException("unauthorized", "密码错误"); } - if (!(await consumeTotp(deps, admin, input.code))) throw new ApiException("unauthorized", "验证码错误"); + // 6 位数字按验证码校验,其余按恢复码(验证器丢了也能关) + const ok = /^\d{6}$/.test(input.code) + ? await consumeTotp(deps, admin, input.code) + : await consumeRecoveryCode(deps.db, admin.id, input.code); + if (!ok) throw new ApiException("unauthorized", "验证码错误"); const ref = admin.totp_secret_ref; await deps.db .updateTable("admin_user") .set({ totp_secret_ref: null, totp_enabled_at: null, totp_last_counter: null }) .where("id", "=", admin.id) .execute(); + await deps.db.deleteFrom("admin_recovery_code").where("admin_user_id", "=", admin.id).execute(); await deps.secrets.remove(ref).catch(() => {}); } @@ -333,5 +458,8 @@ export async function sessionInfo( actor: actor ?? null, expiresAt: expiresAt?.toISOString() ?? null, totpEnabled: !!admin?.totp_enabled_at, + passkeyEnabled: admin?.password_hash + ? !!(await db.selectFrom("admin_passkey").select("id").where("admin_user_id", "=", admin.id).executeTakeFirst()) + : false, }; } diff --git a/apps/server/src/modules/auth-settings/passkey.ts b/apps/server/src/modules/auth-settings/passkey.ts new file mode 100644 index 00000000..386bace7 --- /dev/null +++ b/apps/server/src/modules/auth-settings/passkey.ts @@ -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, + purpose: "register" | "login", + challenge: string, + adminUserId: string | null, +): Promise { + // 顺手清理过期挑战 + 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, + id: string, + purpose: "register" | "login", + adminUserId: string | null, +): Promise { + 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, adminUserId: string): Promise { + return await db + .selectFrom("admin_passkey") + .selectAll() + .where("admin_user_id", "=", adminUserId) + .orderBy("created_at") + .execute(); +} + +export async function renamePasskey(db: Kysely, 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, 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, admin: AdminRow, rp: RpInfo): Promise { + 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 }; +} + +export async function verifyRegistration( + db: Kysely, + admin: AdminRow, + rp: RpInfo, + input: { challengeId: string; name: string; response: Record }, +): Promise { + 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>; + 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, admin: AdminRow, rp: RpInfo): Promise { + 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 }; +} + +/** 校验断言;成功后更新签名计数器与最近使用时间。失败抛 unauthorized / precondition_failed */ +export async function verifyAuthentication( + db: Kysely, + admin: AdminRow, + rp: RpInfo, + input: { challengeId: string; response: Record }, +): Promise { + 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>; + 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`(counter < ${next} or (counter = 0 and ${next} = 0))`) + .returningAll() + .executeTakeFirst(); + if (!u) throw new ApiException("unauthorized", "Passkey 签名计数器回退(可能被克隆),已拒绝"); + return u; +} diff --git a/apps/server/src/modules/auth-settings/provided.ts b/apps/server/src/modules/auth-settings/provided.ts index 019e9c2b..8cb2f91b 100644 --- a/apps/server/src/modules/auth-settings/provided.ts +++ b/apps/server/src/modules/auth-settings/provided.ts @@ -1,4 +1,4 @@ -import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto"; +import { createCipheriv, createDecipheriv, createHmac, randomBytes, randomUUID } from "node:crypto"; import { DEFAULT_SETTINGS, GlobalSettings, type SecretPurpose, type SettingsKey } from "@umt/contracts"; import type { DB, Kysely } from "@umt/db"; import type { Logger } from "pino"; @@ -12,7 +12,7 @@ import { deriveKey } from "./crypto.ts"; export const SECRET_ALGORITHM = "aes-256-gcm"; export const SECRET_KDF = "hkdf-sha256"; -/** 当前主密钥版本;轮换主密钥时 +1,并用 rewrapAll 重新加密 */ +/** 密文格式版本(HKDF info = umt/secret/v<版本>);主密钥轮换不改它,换哪把主密钥看 kdf_params.kid */ export const SECRET_KEY_VERSION = 1; /** 找不到 secret 或无法解密;消息里只有 ref,不含任何明文 */ @@ -52,9 +52,14 @@ interface Sealed { ciphertext: Buffer; nonce: Buffer; authTag: Buffer; - kdfParams: { salt: string; info: string }; + /** kid:主密钥指纹(HMAC 派生,不可逆),主密钥轮换时据此跳过已换过的行 */ + kdfParams: { salt: string; info: string; kid: string }; } +/** 主密钥指纹:HMAC-SHA256(masterKey, 固定标签) 取前 16 位十六进制;只用于区分密钥,不能反推 */ +export const masterKeyId = (masterKey: string) => + createHmac("sha256", Buffer.from(masterKey, "utf8")).update("umt/secret/kid").digest("hex").slice(0, 16); + /** AAD 绑定行 id 与用途:密文被挪到别的行/改用途即解密失败 */ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; function assertRef(ref: string) { @@ -72,7 +77,12 @@ function seal(masterKey: string, id: string, purpose: string, plaintext: string) const cipher = createCipheriv(SECRET_ALGORITHM, key, nonce); cipher.setAAD(aad(id, purpose)); const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); - return { ciphertext, nonce, authTag: cipher.getAuthTag(), kdfParams: { salt: salt.toString("base64"), info } }; + return { + ciphertext, + nonce, + authTag: cipher.getAuthTag(), + kdfParams: { salt: salt.toString("base64"), info, kid: masterKeyId(masterKey) }, + }; } function open( @@ -188,6 +198,108 @@ export function createSecretStore(deps: { db: Kysely; 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; logger: Logger }, + oldKey: string, + newKey: string, + opts: { progressEvery?: number } = {}, +): Promise { + 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 / 进程间广播的设置变更事件 */ diff --git a/apps/server/test/api-registry.test.ts b/apps/server/test/api-registry.test.ts index 13c40632..74bb9447 100644 --- a/apps/server/test/api-registry.test.ts +++ b/apps/server/test/api-registry.test.ts @@ -61,6 +61,12 @@ describe("Api 注册表鉴权标注", () => { expect(session).toEqual( [ "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", "PATCH /approvals/{id}", "POST /approvals", diff --git a/apps/server/test/auth-settings/account-security.test.ts b/apps/server/test/auth-settings/account-security.test.ts new file mode 100644 index 00000000..bf97d26d --- /dev/null +++ b/apps/server/test/auth-settings/account-security.test.ts @@ -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 = {}) => + 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); + }); +}); diff --git a/apps/server/test/auth-settings/login.test.ts b/apps/server/test/auth-settings/login.test.ts index 186d5ceb..e03e5fb3 100644 --- a/apps/server/test/auth-settings/login.test.ts +++ b/apps/server/test/auth-settings/login.test.ts @@ -186,7 +186,11 @@ describe.skipIf(!hasPg)("管理员登录门禁(真实 PG)", () => { it("TOTP:绑定 → 启用 → 登录需第二步(不计失败)→ 验证码防重放 → 停用", async () => { const a = new Browser(h.app); 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; expect(begin.otpauthUri).toMatch(/^otpauth:\/\/totp\//); const secret: string = begin.secret; diff --git a/apps/server/test/auth-settings/rotate-master-key.test.ts b/apps/server/test/auth-settings/rotate-master-key.test.ts new file mode 100644 index 00000000..38f5227c --- /dev/null +++ b/apps/server/test/auth-settings/rotate-master-key.test.ts @@ -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) => + 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); +}); diff --git a/apps/server/test/auth-settings/soft-authenticator.ts b/apps/server/test/auth-settings/soft-authenticator.ts new file mode 100644 index 00000000..f475162b --- /dev/null +++ b/apps/server/test/auth-settings/soft-authenticator.ts @@ -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 | Record; + +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([ + [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", + }; + } +} diff --git a/apps/server/test/e2e/e11-subagents.test.ts b/apps/server/test/e2e/e11-subagents.test.ts index 54a4307c..ac69ec35 100644 --- a/apps/server/test/e2e/e11-subagents.test.ts +++ b/apps/server/test/e2e/e11-subagents.test.ts @@ -131,7 +131,8 @@ describe.skipIf(!hasPg)("E11 Pi 子代理(agent.spawn / agent.await)", () => { let e: E2E; beforeAll(async () => { - e = await createE2E("e11", { fakeScripts: [ + e = await createE2E("e11", { + fakeScripts: [ parentScript, duneScript, oppieScript, diff --git a/apps/web/package.json b/apps/web/package.json index 7511c6d4..316fc5a1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,6 +16,7 @@ "@fontsource-variable/inter-tight": "^5.2.7", "@fontsource-variable/space-grotesk": "^5.2.10", "@react-router/node": "7.12.0", + "@simplewebauthn/browser": "^13.3.0", "@tanstack/react-query": "^5.90.21", "@umt/contracts": "workspace:*", "class-variance-authority": "^0.7.1", @@ -24,6 +25,7 @@ "isbot": "^5.1.31", "lucide-react": "^0.576.0", "next-themes": "^0.4.6", + "qr-code-styling": "^1.9.2", "radix-ui": "^1.4.3", "react": "^19.2.4", "react-day-picker": "^10.0.1", diff --git a/deno.json b/deno.json index 331fdecf..56596eab 100644 --- a/deno.json +++ b/deno.json @@ -20,6 +20,7 @@ "@earendil-works/pi-ai": "npm:@earendil-works/pi-ai@0.87.1", "@earendil-works/pi-ai/": "npm:/@earendil-works/pi-ai@0.87.1/", "@hono/swagger-ui": "npm:@hono/swagger-ui@0.6.1", + "@simplewebauthn/server": "npm:@simplewebauthn/server@13.3.3", "@hono/zod-openapi": "npm:@hono/zod-openapi@1.6.3", "@types/node": "npm:@types/node@24.19.0", "@types/nunjucks": "npm:@types/nunjucks@3.2.6", diff --git a/deno.lock b/deno.lock index e8c12067..2385626d 100644 --- a/deno.lock +++ b/deno.lock @@ -15,6 +15,8 @@ "npm:@react-router/dev@*": "7.12.0_react-router@7.12.0_typescript@7.0.2_vite@7.3.6_@types+node@24.19.0_react@19.3.0_react-dom@19.3.0_yaml@2.9.1", "npm:@react-router/dev@7.12.0": "7.12.0_react-router@7.12.0_typescript@7.0.2_vite@7.3.6_@types+node@24.19.0_react@19.3.0_react-dom@19.3.0_yaml@2.9.1", "npm:@react-router/node@7.12.0": "7.12.0_react-router@7.12.0_typescript@7.0.2_react@19.3.0_react-dom@19.3.0", + "npm:@simplewebauthn/browser@^13.3.0": "13.3.0", + "npm:@simplewebauthn/server@13.3.3": "13.3.3", "npm:@tailwindcss/vite@^4.1.13": "4.3.3_vite@7.3.6_@types+node@24.19.0_yaml@2.9.1", "npm:@tanstack/react-query@^5.90.21": "5.104.0_react@19.3.0", "npm:@types/node@24.19.0": "24.19.0", @@ -41,6 +43,7 @@ "npm:pino@10.3.1": "10.3.1", "npm:playwright-core@1.63.0": "1.63.0", "npm:postgres@3.4.9": "3.4.9", + "npm:qr-code-styling@^1.9.2": "1.9.2", "npm:radix-ui@^1.4.3": "1.6.7_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0", "npm:react-day-picker@^10.0.1": "10.0.1_@types+react@19.3.0_react@19.3.0", "npm:react-dom@^19.2.4": "19.3.0_react@19.3.0", @@ -119,7 +122,7 @@ "@smithy/fetch-http-handler", "@smithy/node-http-handler", "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@aws-sdk/core@3.978.1": { @@ -132,7 +135,7 @@ "@smithy/signature-v4", "@smithy/types", "bowser", - "tslib" + "tslib@2.8.1" ] }, "@aws-sdk/credential-provider-env@3.972.72": { @@ -142,7 +145,7 @@ "@aws-sdk/types", "@smithy/core", "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@aws-sdk/credential-provider-http@3.972.74": { @@ -154,7 +157,7 @@ "@smithy/fetch-http-handler", "@smithy/node-http-handler", "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@aws-sdk/credential-provider-ini@3.973.17": { @@ -172,7 +175,7 @@ "@smithy/core", "@smithy/credential-provider-imds", "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@aws-sdk/credential-provider-login@3.972.79": { @@ -183,7 +186,7 @@ "@aws-sdk/types", "@smithy/core", "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@aws-sdk/credential-provider-node@3.972.84": { @@ -199,7 +202,7 @@ "@smithy/core", "@smithy/credential-provider-imds", "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@aws-sdk/credential-provider-process@3.972.72": { @@ -209,7 +212,7 @@ "@aws-sdk/types", "@smithy/core", "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@aws-sdk/credential-provider-sso@3.973.16": { @@ -221,7 +224,7 @@ "@aws-sdk/types", "@smithy/core", "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@aws-sdk/credential-provider-web-identity@3.972.78": { @@ -232,7 +235,7 @@ "@aws-sdk/types", "@smithy/core", "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@aws-sdk/eventstream-handler-node@3.972.35": { @@ -241,7 +244,7 @@ "@aws-sdk/types", "@smithy/core", "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@aws-sdk/middleware-eventstream@3.972.30": { @@ -250,7 +253,7 @@ "@aws-sdk/types", "@smithy/core", "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@aws-sdk/middleware-websocket@3.972.54": { @@ -262,7 +265,7 @@ "@smithy/fetch-http-handler", "@smithy/signature-v4", "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@aws-sdk/nested-clients@3.997.46": { @@ -275,7 +278,7 @@ "@smithy/fetch-http-handler", "@smithy/node-http-handler", "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@aws-sdk/signature-v4-multi-region@3.996.47": { @@ -284,7 +287,7 @@ "@aws-sdk/types", "@smithy/signature-v4", "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@aws-sdk/token-providers@3.1127.0": { @@ -295,7 +298,7 @@ "@aws-sdk/types", "@smithy/core", "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@aws-sdk/token-providers@3.1138.0": { @@ -306,21 +309,21 @@ "@aws-sdk/types", "@smithy/core", "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@aws-sdk/types@3.974.6": { "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", "dependencies": [ "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@aws-sdk/xml-builder@3.972.41": { "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", "dependencies": [ "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@aws/lambda-invoke-store@0.3.0": { @@ -833,6 +836,9 @@ ], "scripts": true }, + "@hexagon/base64@1.1.28": { + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==" + }, "@hono/node-server@2.1.1_hono@4.13.9": { "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", "dependencies": [ @@ -931,6 +937,9 @@ "@jridgewell/sourcemap-codec" ] }, + "@levischuck/tiny-cbor@0.2.11": { + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==" + }, "@mjackson/node-fetch-server@0.2.0": { "integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==" }, @@ -1008,6 +1017,132 @@ "@open-draft/until@2.1.0": { "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==" }, + "@peculiar/asn1-android@2.9.5": { + "integrity": "sha512-KnNE4l033gbJ3ZzfYk7fJpSZwhd5xT+dPAnrB8bcwqylHiLN2zjZ0OyFao0LDIOI/ZdXRabV0n3Ww/KvpgZq6w==", + "dependencies": [ + "@peculiar/asn1-schema", + "asn1js", + "tslib@2.8.1" + ] + }, + "@peculiar/asn1-cms@2.9.5": { + "integrity": "sha512-4Ivz0fsyFofe0bslNsYcGN9+iETdHX4v+BFwIPr+K/BAt61sHXECP63ff9FntU9zcfVQELJFdUG9so44OjUlBQ==", + "dependencies": [ + "@peculiar/asn1-schema", + "@peculiar/asn1-x509", + "@peculiar/asn1-x509-attr", + "asn1js", + "tslib@2.8.1" + ] + }, + "@peculiar/asn1-csr@2.9.5": { + "integrity": "sha512-9dO6PcoJeI+1xJ4rG7eINMa6f9Ix/QdRs5UGZ0D7v/R3TzmsbVr5cqAgfYw4xgS9U54ouHYqEz0bIZBbM/UY1g==", + "dependencies": [ + "@peculiar/asn1-schema", + "@peculiar/asn1-x509", + "asn1js", + "tslib@2.8.1" + ] + }, + "@peculiar/asn1-ecc@2.9.5": { + "integrity": "sha512-OD+HLlcV7wCL64w0rheX/C0J31DbJce0nf8ulv3UNftQ617JEZE3sQ4cMN6PotXWlJ1M5GQx90G3bywtdKOstw==", + "dependencies": [ + "@peculiar/asn1-schema", + "@peculiar/asn1-x509", + "asn1js", + "tslib@2.8.1" + ] + }, + "@peculiar/asn1-pfx@2.9.5": { + "integrity": "sha512-btcH7lbcD4410qnjHvv7F0kEuMNKbX8CFAZSs9CDPWHKzT1byLPHYeR40AnUw9z4CzsijMY0daBFYUvVx75sdA==", + "dependencies": [ + "@peculiar/asn1-cms", + "@peculiar/asn1-pkcs8", + "@peculiar/asn1-rsa", + "@peculiar/asn1-schema", + "asn1js", + "tslib@2.8.1" + ] + }, + "@peculiar/asn1-pkcs8@2.9.5": { + "integrity": "sha512-c7UghAGQdBfAHL9JdwcJkAc/lDJKNXfCdYXPXlf8BZdD/fVJjJnXKHxX4gyLhkFTvnGhME7caieSgc2ROtqYTQ==", + "dependencies": [ + "@peculiar/asn1-schema", + "@peculiar/asn1-x509", + "asn1js", + "tslib@2.8.1" + ] + }, + "@peculiar/asn1-pkcs9@2.9.5": { + "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", + "tslib@2.8.1" + ] + }, + "@peculiar/asn1-rsa@2.9.5": { + "integrity": "sha512-kHwBLVMkm9ljlM521ovS7y4z4mr8xuHTdNyyoPlDvckBwFKSgs9HqKBryLBeqHqL2YpIgl+Vspl7kzNGG2phzA==", + "dependencies": [ + "@peculiar/asn1-schema", + "@peculiar/asn1-x509", + "asn1js", + "tslib@2.8.1" + ] + }, + "@peculiar/asn1-schema@2.9.5": { + "integrity": "sha512-Ez3wLKVjaxdsLcgeWN4OE31QkM7oBOgKuuBJxldRYAkfYw2C+8zJPcSd/SThnbhszsEOlAmoaS5kIIkN29fGKQ==", + "dependencies": [ + "@peculiar/utils", + "asn1js", + "tslib@2.8.1" + ] + }, + "@peculiar/asn1-x509-attr@2.9.5": { + "integrity": "sha512-PlbL1a74RZiry7puPCZ5hdk/puvivxYJPbjadr2i7DBjHCJFEKCfTRPKL3DILeZFtzJjn95J5EBtSc4/TB9/6Q==", + "dependencies": [ + "@peculiar/asn1-schema", + "@peculiar/asn1-x509", + "asn1js", + "tslib@2.8.1" + ] + }, + "@peculiar/asn1-x509@2.9.5": { + "integrity": "sha512-NP3ecvN6zTDlwCvogIWL7MJN9AZNuskkIbnlhrlkB97Mn2EqirBUQ8GKzsOmdAL3pRvjGu1Uk/a4DNfiZrWxhw==", + "dependencies": [ + "@peculiar/asn1-schema", + "@peculiar/utils", + "asn1js", + "tslib@2.8.1" + ] + }, + "@peculiar/utils@2.0.3": { + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "dependencies": [ + "tslib@2.8.1" + ] + }, + "@peculiar/x509@1.14.3": { + "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", + "tslib@2.8.1", + "tsyringe" + ] + }, "@pinojs/redact@0.4.0": { "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==" }, @@ -2260,6 +2395,22 @@ "@sec-ant/readable-stream@0.4.1": { "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==" }, + "@simplewebauthn/browser@13.3.0": { + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==" + }, + "@simplewebauthn/server@13.3.3": { + "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/merge-streams@4.0.0": { "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==" }, @@ -2267,7 +2418,7 @@ "integrity": "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==", "dependencies": [ "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@smithy/credential-provider-imds@4.5.2": { @@ -2275,7 +2426,7 @@ "dependencies": [ "@smithy/core", "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@smithy/fetch-http-handler@5.8.0": { @@ -2283,7 +2434,7 @@ "dependencies": [ "@smithy/core", "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@smithy/node-http-handler@4.12.1": { @@ -2291,7 +2442,7 @@ "dependencies": [ "@smithy/core", "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@smithy/signature-v4@5.7.4": { @@ -2299,13 +2450,13 @@ "dependencies": [ "@smithy/core", "@smithy/types", - "tslib" + "tslib@2.8.1" ] }, "@smithy/types@4.19.0": { "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", "dependencies": [ - "tslib" + "tslib@2.8.1" ] }, "@stablelib/base64@1.0.1": { @@ -2720,19 +2871,27 @@ "aria-hidden@1.2.6": { "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", "dependencies": [ - "tslib" + "tslib@2.8.1" ] }, "asap@2.0.6": { "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" }, + "asn1js@3.0.10": { + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dependencies": [ + "pvtsutils", + "pvutils", + "tslib@2.8.1" + ] + }, "assertion-error@2.0.1": { "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==" }, "ast-types@0.16.3": { "integrity": "sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==", "dependencies": [ - "tslib" + "tslib@2.8.1" ] }, "atomic-sleep@1.0.0": { @@ -4618,6 +4777,24 @@ "once" ] }, + "pvtsutils@1.3.6": { + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dependencies": [ + "tslib@2.8.1" + ] + }, + "pvutils@1.2.0": { + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==" + }, + "qr-code-styling@1.9.2": { + "integrity": "sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==", + "dependencies": [ + "qrcode-generator" + ] + }, + "qrcode-generator@1.5.2": { + "integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==" + }, "qs@6.16.0": { "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "dependencies": [ @@ -4756,7 +4933,7 @@ "@types/react", "react", "react-style-singleton", - "tslib" + "tslib@2.8.1" ], "optionalPeers": [ "@types/react" @@ -4769,7 +4946,7 @@ "react", "react-remove-scroll-bar", "react-style-singleton", - "tslib", + "tslib@2.8.1", "use-callback-ref", "use-sidecar" ], @@ -4795,7 +4972,7 @@ "@types/react", "get-nonce", "react", - "tslib" + "tslib@2.8.1" ], "optionalPeers": [ "@types/react" @@ -4820,7 +4997,7 @@ "esprima", "source-map", "tiny-invariant", - "tslib" + "tslib@2.8.1" ] }, "recharts@3.10.1_react@19.3.0_react-dom@19.3.0_react-is@19.3.0_@types+react@19.3.0_redux@5.0.1": { @@ -4851,6 +5028,9 @@ "redux@5.0.1": { "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, + "reflect-metadata@0.2.2": { + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" + }, "require-directory@2.1.1": { "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" }, @@ -5278,9 +5458,18 @@ "strip-bom" ] }, + "tslib@1.14.1": { + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, "tslib@2.8.1": { "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "tsyringe@4.10.0": { + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "dependencies": [ + "tslib@1.14.1" + ] + }, "tw-animate-css@1.4.0": { "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==" }, @@ -5359,7 +5548,7 @@ "dependencies": [ "@types/react", "react", - "tslib" + "tslib@2.8.1" ], "optionalPeers": [ "@types/react" @@ -5371,7 +5560,7 @@ "@types/react", "detect-node-es", "react", - "tslib" + "tslib@2.8.1" ], "optionalPeers": [ "@types/react" @@ -5607,6 +5796,7 @@ "npm:@earendil-works/pi-ai@0.87.1", "npm:@hono/swagger-ui@0.6.1", "npm:@hono/zod-openapi@1.6.3", + "npm:@simplewebauthn/server@13.3.3", "npm:@types/node@24.19.0", "npm:@types/nunjucks@3.2.6", "npm:@types/pg@8.23.1", @@ -5638,6 +5828,7 @@ "npm:@fontsource-variable/space-grotesk@^5.2.10", "npm:@react-router/dev@7.12.0", "npm:@react-router/node@7.12.0", + "npm:@simplewebauthn/browser@^13.3.0", "npm:@tailwindcss/vite@^4.1.13", "npm:@tanstack/react-query@^5.90.21", "npm:@types/react-dom@^19.2.3", @@ -5648,6 +5839,7 @@ "npm:isbot@^5.1.31", "npm:lucide-react@0.576", "npm:next-themes@~0.4.6", + "npm:qr-code-styling@^1.9.2", "npm:radix-ui@^1.4.3", "npm:react-day-picker@^10.0.1", "npm:react-dom@^19.2.4", diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 7919f1da..f3f0f8b1 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -3,7 +3,7 @@ # root 启动:建好 DATA_DIR 子目录、按 PUID/PGID 修正属主,再用 gosu 降权重跑自己(二进制与 /app 保持 root 只读)。 # serve(缺省):迁移 → 启动自检 → api、worker(io 组)、worker:agent(Agent 会话)三个进程互相看护。 # 任一进程退出就停掉另一个并以非 0 退出,把"半死容器"变成整体退出,交给 restart 策略自愈。 -# backup / restore <备份目录> / migrate / preflight / export-mappings ...:一次性子命令(降权后执行)。 +# backup / restore <备份目录> / migrate / preflight / export-mappings / rotate-master-key ...:一次性子命令(降权后执行)。 # 其它参数原样 exec(例如 bash)。 set -euo pipefail @@ -34,7 +34,7 @@ case "$cmd" in serve) ;; backup) shift; exec /app/scripts/backup.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 "$@" ;; esac diff --git a/docs/runbook/auth.md b/docs/runbook/auth.md index e4d4e52d..c74f52b7 100644 --- a/docs/runbook/auth.md +++ b/docs/runbook/auth.md @@ -14,6 +14,8 @@ ```sql 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; +delete from admin_recovery_code; +delete from admin_passkey; ``` 然后重启 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 | | `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/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 秒节流更新。 - 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 维度。 - 客户端 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 互认。 -- 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`。 ## 鉴权中间件(其他模块怎么用) -`/api/v1/*`(health 与上面三个免鉴权端点除外)统一经过 `apiKeyAuth`: +`/api/v1/*`(health 与上面五个免鉴权端点除外)统一经过 `apiKeyAuth`: - `Authorization: Bearer ` → API Key(开发模式另认 `DEV_ADMIN_KEY`,生产忽略); - 否则 `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` - `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)`(密文挪行/改用途即解密失败)。 - 其它表只存 `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)。只返回元数据。 ## 设置 `ctx.settings` diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index bb33174d..c5327aa0 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -1787,10 +1787,79 @@ export const Api = { path: "/auth/totp", scope: "admin", auth: "session", - summary: "TOTP 绑定/启用/停用", + summary: "TOTP 绑定/启用/停用/重新生成恢复码", body: auth.TotpAction, 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, + }), }, // ── 事件 ── diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 3308b8d9..8a77820b 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -43,12 +43,24 @@ export type AdminSetupRequest = z.infer; /** 6 位 TOTP 码 */ 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 */ export const LoginRequest = z.object({ username: z.string().min(1).max(64), password: z.string().min(1).max(256), /** 开启 TOTP 后第二步提交 */ totpCode: TotpCode.optional(), + /** 开启 TOTP 后第二步也可以用一次性恢复码代替验证码(用过即作废) */ + recoveryCode: RecoveryCode.optional(), }); export type LoginRequest = z.infer; @@ -60,6 +72,8 @@ export const SessionInfo = z.object({ /** 仅 cookie 会话有值 */ expiresAt: Timestamp.nullable(), totpEnabled: z.boolean(), + /** 已登记至少一把 Passkey(登录页据此显示"用 Passkey 登录") */ + passkeyEnabled: z.boolean(), }); export type SessionInfo = z.infer; @@ -83,16 +97,40 @@ export const PasswordChangeResult = z.object({ revokedSessions: z.number().int() export type PasswordChangeResult = z.infer; /** 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; + /** - * POST /auth/totp:begin 生成待确认密钥(返回 otpauth URI,只此一次);enable 用验证码确认; - * disable 需要当前密码 + 验证码 + * GET /auth/totp/setup:待确认的 TOTP 密钥(没有就生成一个;已有未确认的原样返回,刷新页面不作废)。 + * 前端把 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; + +/** + * POST /auth/totp:begin 生成待确认密钥(返回 otpauth URI,只此一次);enable 用验证码确认(同时生成恢复码); + * disable 需要当前密码 + 验证码(或恢复码);recovery_codes 用当前密码重新生成恢复码(旧的全部作废) */ export const TotpAction = z.discriminatedUnion("action", [ z.object({ action: z.literal("begin") }), 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; export const TotpActionResult = z.object({ @@ -100,9 +138,55 @@ export const TotpActionResult = z.object({ /** 仅 begin 返回:otpauth://totp/... 与 base32 密钥 */ otpauthUri: z.string().optional(), secret: z.string().optional(), + /** 仅 enable / recovery_codes 返回:新生成的恢复码明文(只显示这一次,库里只存哈希) */ + recoveryCodes: z.array(z.string()).optional(), }); export type TotpActionResult = z.infer; +// ── 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; +export const PasskeyList = z.object({ items: z.array(PasskeyInfo) }); +export type PasskeyList = z.infer; + +/** + * 注册 / 登录挑战: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; + +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; +export const PasskeyLoginRequest = z.object({ + challengeId: z.string().uuid(), + /** startAuthentication 的返回值(AuthenticationResponseJSON) */ + response: WebAuthnResponse, +}); +export type PasskeyLoginRequest = z.infer; +export const PasskeyRenameRequest = z.object({ name: PasskeyName }); +export type PasskeyRenameRequest = z.infer; + /** * 审计动作枚举(审计写失败不影响主流程;被限速拦下的登录尝试不写审计)。 * 新增动作只在这里加。 @@ -114,6 +198,12 @@ export const AuditAction = z.enum([ "admin.password_change", "admin.totp_enable", "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.update", "api_key.revoke", @@ -121,6 +211,7 @@ export const AuditAction = z.enum([ "secret.create", "secret.rotate", "secret.delete", + "secret.master_key_rotate", "library_root.create", "library_root.update", "library_root.delete",