feat(auth): TOTP setup endpoint, recovery codes, passkeys and master key rotation
- GET /auth/totp/setup returns the pending secret and otpauth URL (created on first call, reused on refresh) for the console to render as a QR code. - Enabling TOTP issues 8 one-time recovery codes (argon2id hashes in admin_recovery_code); login accepts recoveryCode in place of totpCode, disable accepts either, and recovery_codes regenerates with the password. - Passkeys via @simplewebauthn/server: register/list/rename/delete under the console session, public login options/verify that reuse the login rate limit and session cookies. Challenges are one-time rows in webauthn_challenge; RP ID and origins come from WEBAUTHN_RP_ID / WEBAUTHN_ORIGINS, falling back to the request host. Signature counter regressions are rejected. SessionInfo gains passkeyEnabled. - --role rotate-master-key re-encrypts the secret table from SECRET_MASTER_KEY to SECRET_MASTER_KEY_NEW one row per transaction, skips rows already on the new key (resumable, swap keys to roll back), logs progress and audits secret.master_key_rotate. Secrets now record the master key fingerprint in kdf_params.kid. - Runbook: recovery codes, passkey configuration, rotation steps and rollback. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
5c6f483420
commit
b0e5d539ed
21 files changed
+1611
-75
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
+13
-1
@@ -8,6 +8,8 @@ import type { MappingExport } from "./ops.ts";
|
||||
// migrate | preflight | probe
|
||||
// export-mappings --out <file.json> [--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<number> {
|
||||
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");
|
||||
|
||||
@@ -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<boolean>`(select status = 'running' from agent_run o where o.id = ${runId})`.as("was_running")])
|
||||
.returning([
|
||||
"parent_run_id",
|
||||
sql<boolean>`(select status = 'running' from agent_run o where o.id = ${runId})`.as("was_running"),
|
||||
])
|
||||
.executeTakeFirst();
|
||||
// 子会话刚转 waiting(对父会话来说已有结果):唤醒挂起等待的父会话
|
||||
if (u?.parent_run_id && u.was_running) await wakeParent(env.ctx, runId);
|
||||
|
||||
@@ -6,20 +6,36 @@ import { ApiException } from "../../http/errors.ts";
|
||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||
import {
|
||||
adminActor,
|
||||
assertNotRateLimited,
|
||||
type CreatedSession,
|
||||
changePassword,
|
||||
createSession,
|
||||
loadAdmin,
|
||||
recordAttempt,
|
||||
revokeSession,
|
||||
sessionInfo,
|
||||
setupAdmin,
|
||||
totpBegin,
|
||||
totpDisable,
|
||||
totpEnable,
|
||||
totpRegenerateRecoveryCodes,
|
||||
totpSetup,
|
||||
totpStatus,
|
||||
verifyLogin,
|
||||
} from "./auth-service.ts";
|
||||
import { audit, type Def, endpoint } from "./endpoint.ts";
|
||||
import {
|
||||
authenticationOptions,
|
||||
deletePasskey,
|
||||
listPasskeys,
|
||||
type RpInfo,
|
||||
registrationOptions,
|
||||
renamePasskey,
|
||||
resolveRp,
|
||||
toPasskeyInfo,
|
||||
verifyAuthentication,
|
||||
verifyRegistration,
|
||||
} from "./passkey.ts";
|
||||
|
||||
// 登录门禁端点:/auth/setup、/auth/login、/auth/session 免鉴权(见 http/auth.ts PUBLIC_AUTH_PATHS),
|
||||
// 其余需要已登录;改密与 TOTP 只接受控制台 cookie 会话。
|
||||
@@ -36,8 +52,19 @@ const D = {
|
||||
password: { ...Api.auth.password, tags },
|
||||
totpGet: { ...Api.auth.totpStatus, tags },
|
||||
totpPost: { ...Api.auth.totp, tags },
|
||||
totpSetup: { ...Api.auth.totpSetup, tags },
|
||||
passkeys: { ...Api.auth.passkeys, tags },
|
||||
passkeyRegisterOptions: { ...Api.auth.passkeyRegisterOptions, tags },
|
||||
passkeyRegister: { ...Api.auth.passkeyRegister, tags },
|
||||
passkeyRename: { ...Api.auth.passkeyRename, tags },
|
||||
passkeyDelete: { ...Api.auth.passkeyDelete, tags },
|
||||
passkeyLoginOptions: { ...Api.auth.passkeyLoginOptions, tags },
|
||||
passkeyLogin: { ...Api.auth.passkeyLogin, tags },
|
||||
} satisfies Record<string, Def>;
|
||||
|
||||
/** Passkey 登录失败 / 限速计数用的账号维度键(免输入用户名) */
|
||||
const PASSKEY_ACCOUNT = "(passkey)";
|
||||
|
||||
const PRIVATE_IP = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1$|fc|fd|::ffff:(127|10|192\.168)\.)/i;
|
||||
|
||||
/**
|
||||
@@ -57,6 +84,13 @@ export function clientIp(c: Context<AuthEnv>): string {
|
||||
return xri || xff || remote || "unknown";
|
||||
}
|
||||
|
||||
/** 请求主机(反代后面才信 X-Forwarded-Host),Passkey 未配置 RP ID 时用它 */
|
||||
function requestHost(c: Context<AuthEnv>): string | null {
|
||||
const remote = (c.env as { remoteAddr?: { hostname?: string } } | undefined)?.remoteAddr?.hostname;
|
||||
const trusted = !remote || PRIVATE_IP.test(remote);
|
||||
return (trusted ? c.req.header("x-forwarded-host") : undefined) ?? c.req.header("host") ?? new URL(c.req.url).host;
|
||||
}
|
||||
|
||||
function isHttps(c: Context<AuthEnv>): boolean {
|
||||
if (new URL(c.req.url).protocol === "https:") return true;
|
||||
const remote = (c.env as { remoteAddr?: { hostname?: string } } | undefined)?.remoteAddr?.hostname;
|
||||
@@ -106,6 +140,10 @@ export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
const s = await startSession(c, r.admin.id);
|
||||
const actor = adminActor(r.admin);
|
||||
await audit(ctx, actor, "admin.login_success", { type: "admin_session", id: s.id }, { ip: clientIp(c) });
|
||||
if (r.secondFactor === "recovery_code") {
|
||||
const left = (await totpStatus(ctx.db, r.admin)).recoveryCodesRemaining;
|
||||
await audit(ctx, actor, "admin.recovery_code_login", { type: "admin_user", id: r.admin.id }, { remaining: left });
|
||||
}
|
||||
return { status: "ok" as const, session: await sessionInfo(ctx.db, actor, s.expiresAt) };
|
||||
});
|
||||
|
||||
@@ -146,22 +184,103 @@ export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) {
|
||||
return { revokedSessions: revoked };
|
||||
});
|
||||
|
||||
endpoint(app, D.totpGet, async () => totpStatus(await loadAdmin(ctx.db)));
|
||||
endpoint(app, D.totpGet, async () => totpStatus(ctx.db, await loadAdmin(ctx.db)));
|
||||
|
||||
endpoint(app, D.totpPost, async ({ body, actor }) => {
|
||||
const initializedAdmin = async () => {
|
||||
const admin = await loadAdmin(ctx.db);
|
||||
if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化");
|
||||
return admin;
|
||||
};
|
||||
|
||||
endpoint(app, D.totpSetup, async () => {
|
||||
const r = await totpSetup(deps(), await initializedAdmin());
|
||||
return { otpauthUri: r.uri, secret: r.secret, issuer: r.issuer, account: r.account };
|
||||
});
|
||||
|
||||
endpoint(app, D.totpPost, async ({ body, actor }) => {
|
||||
const admin = await initializedAdmin();
|
||||
const status = async () => totpStatus(ctx.db, await loadAdmin(ctx.db));
|
||||
if (body.action === "begin") {
|
||||
const r = await totpBegin(deps(), admin);
|
||||
return { status: totpStatus(await loadAdmin(ctx.db)), otpauthUri: r.uri, secret: r.secret };
|
||||
return { status: await status(), otpauthUri: r.uri, secret: r.secret };
|
||||
}
|
||||
if (body.action === "enable") {
|
||||
await totpEnable(deps(), admin, body.code);
|
||||
const codes = await totpEnable(deps(), admin, body.code);
|
||||
await audit(ctx, actor, "admin.totp_enable", { type: "admin_user", id: admin.id });
|
||||
} else {
|
||||
return { status: await status(), recoveryCodes: codes };
|
||||
}
|
||||
if (body.action === "recovery_codes") {
|
||||
const codes = await totpRegenerateRecoveryCodes(deps(), admin, body);
|
||||
await audit(ctx, actor, "admin.recovery_codes_regenerate", { type: "admin_user", id: admin.id });
|
||||
return { status: await status(), recoveryCodes: codes };
|
||||
}
|
||||
await totpDisable(deps(), admin, body);
|
||||
await audit(ctx, actor, "admin.totp_disable", { type: "admin_user", id: admin.id });
|
||||
return { status: await status() };
|
||||
});
|
||||
|
||||
// ── Passkey ──
|
||||
|
||||
const rp = (c: Context<AuthEnv>): RpInfo => resolveRp(ctx.config ?? {}, requestHost(c));
|
||||
|
||||
endpoint(app, D.passkeys, async ({ c }) => {
|
||||
const session = c.get("session");
|
||||
if (!session) throw new ApiException("forbidden", "只能在控制台登录后操作");
|
||||
return { items: (await listPasskeys(ctx.db, session.adminUserId)).map(toPasskeyInfo) };
|
||||
});
|
||||
|
||||
endpoint(app, D.passkeyRegisterOptions, async ({ c }) =>
|
||||
registrationOptions(ctx.db, await initializedAdmin(), rp(c)),
|
||||
);
|
||||
|
||||
endpoint(
|
||||
app,
|
||||
D.passkeyRegister,
|
||||
async ({ body, c, actor }) => {
|
||||
const admin = await initializedAdmin();
|
||||
const row = await verifyRegistration(ctx.db, admin, rp(c), body);
|
||||
await audit(ctx, actor, "admin.passkey_register", { type: "admin_passkey", id: row.id }, { name: row.name });
|
||||
return toPasskeyInfo(row);
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
|
||||
endpoint(app, D.passkeyRename, async ({ params, body, actor }) => {
|
||||
const admin = await initializedAdmin();
|
||||
const row = await renamePasskey(ctx.db, admin.id, params.id, body.name);
|
||||
await audit(ctx, actor, "admin.passkey_rename", { type: "admin_passkey", id: row.id }, { name: row.name });
|
||||
return toPasskeyInfo(row);
|
||||
});
|
||||
|
||||
endpoint(app, D.passkeyDelete, async ({ params, actor }) => {
|
||||
const admin = await initializedAdmin();
|
||||
const row = await deletePasskey(ctx.db, admin.id, params.id);
|
||||
await audit(ctx, actor, "admin.passkey_remove", { type: "admin_passkey", id: row.id }, { name: row.name });
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
endpoint(app, D.passkeyLoginOptions, async ({ c }) => {
|
||||
await assertNotRateLimited(ctx.db, clientIp(c), PASSKEY_ACCOUNT);
|
||||
return authenticationOptions(ctx.db, await initializedAdmin(), rp(c));
|
||||
});
|
||||
|
||||
// 与密码登录共用限速(IP + "(passkey)" 账号维度)与会话;Passkey 本身是强第一因子,不再要求 TOTP
|
||||
endpoint(app, D.passkeyLogin, async ({ body, c }) => {
|
||||
const ip = clientIp(c);
|
||||
await assertNotRateLimited(ctx.db, ip, PASSKEY_ACCOUNT);
|
||||
const admin = await initializedAdmin();
|
||||
let key: Awaited<ReturnType<typeof verifyAuthentication>>;
|
||||
try {
|
||||
key = await verifyAuthentication(ctx.db, admin, rp(c), body);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiException && (err.code === "unauthorized" || err.code === "bad_request"))
|
||||
await recordAttempt(ctx.db, { ip, username: PASSKEY_ACCOUNT, success: false, reason: "bad_passkey" });
|
||||
throw err;
|
||||
}
|
||||
return { status: totpStatus(await loadAdmin(ctx.db)) };
|
||||
await recordAttempt(ctx.db, { ip, username: PASSKEY_ACCOUNT, success: true });
|
||||
const s = await startSession(c, admin.id);
|
||||
const actor = adminActor(admin);
|
||||
await audit(ctx, actor, "admin.passkey_login", { type: "admin_session", id: s.id }, { ip, passkeyId: key.id });
|
||||
return { status: "ok" as const, session: await sessionInfo(ctx.db, actor, s.expiresAt) };
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { Actor, SessionInfo } from "@umt/contracts";
|
||||
import { type Actor, RECOVERY_CODE_COUNT, type SessionInfo, type TotpStatus } from "@umt/contracts";
|
||||
import { type DB, type Kysely, sql } from "@umt/db";
|
||||
import type { Logger } from "pino";
|
||||
import { hashSessionToken } from "../../http/auth.ts";
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
|
||||
// 管理员登录门禁的业务逻辑(照 docs/design/auth-reference.md):
|
||||
// setup token 一次性初始化、argon2id、服务端会话(sha256 存库、固定 TTL、软删除吊销)、
|
||||
// IP + 账号双维度失败限速(落表)、改密吊销其它会话、可选 TOTP(防重放计数器)。
|
||||
// IP + 账号双维度失败限速(落表)、改密吊销其它会话、可选 TOTP(防重放计数器)+ 一次性恢复码(argon2id 哈希存库)。
|
||||
// Passkey 见 passkey.ts。
|
||||
|
||||
/** 固定会话时长 12 小时,无滑动续期 */
|
||||
export const SESSION_TTL_MS = 12 * 3600_000;
|
||||
@@ -197,11 +198,13 @@ export async function setupAdmin(
|
||||
return admin;
|
||||
}
|
||||
|
||||
export type LoginOutcome = { status: "ok"; admin: AdminRow } | { status: "totp_required" };
|
||||
export type LoginOutcome =
|
||||
| { status: "ok"; admin: AdminRow; secondFactor: "none" | "totp" | "recovery_code" }
|
||||
| { status: "totp_required" };
|
||||
|
||||
export async function verifyLogin(
|
||||
deps: AuthDeps,
|
||||
input: { username: string; password: string; totpCode?: string | undefined },
|
||||
input: { username: string; password: string; totpCode?: string | undefined; recoveryCode?: string | undefined },
|
||||
ip: string,
|
||||
): Promise<LoginOutcome> {
|
||||
await assertNotRateLimited(deps.db, ip, input.username);
|
||||
@@ -216,16 +219,87 @@ export async function verifyLogin(
|
||||
await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_password" });
|
||||
throw new ApiException("unauthorized", "用户名或密码错误");
|
||||
}
|
||||
let secondFactor: "none" | "totp" | "recovery_code" = "none";
|
||||
if (admin.totp_enabled_at && admin.totp_secret_ref) {
|
||||
// 密码正确但缺第二步:不计失败
|
||||
if (!input.totpCode) return { status: "totp_required" };
|
||||
if (!input.totpCode && !input.recoveryCode) return { status: "totp_required" };
|
||||
if (input.totpCode) {
|
||||
if (!(await consumeTotp(deps, admin, input.totpCode))) {
|
||||
await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_totp" });
|
||||
throw new ApiException("unauthorized", "验证码错误");
|
||||
}
|
||||
secondFactor = "totp";
|
||||
} else {
|
||||
if (!(await consumeRecoveryCode(deps.db, admin.id, input.recoveryCode ?? ""))) {
|
||||
await recordAttempt(deps.db, { ip, username: input.username, success: false, reason: "bad_recovery_code" });
|
||||
throw new ApiException("unauthorized", "恢复码错误或已用过");
|
||||
}
|
||||
secondFactor = "recovery_code";
|
||||
}
|
||||
}
|
||||
await recordAttempt(deps.db, { ip, username: input.username, success: true });
|
||||
return { status: "ok", admin };
|
||||
return { status: "ok", admin, secondFactor };
|
||||
}
|
||||
|
||||
// ── 恢复码 ──
|
||||
|
||||
const RECOVERY_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
/** 恢复码归一化:去空格与连字符、转大写(展示为 XXXXX-XXXXX,输入时怎么写都行) */
|
||||
export const normalizeRecoveryCode = (code: string) => code.replace(/[\s-]+/g, "").toUpperCase();
|
||||
|
||||
function newRecoveryCode(): string {
|
||||
const bytes = randomBytes(10);
|
||||
let out = "";
|
||||
for (const b of bytes) out += RECOVERY_ALPHABET[b % RECOVERY_ALPHABET.length];
|
||||
return `${out.slice(0, 5)}-${out.slice(5)}`;
|
||||
}
|
||||
|
||||
/** 重新生成恢复码(旧的全部作废);返回明文(只此一次),库里只存 argon2id 哈希 */
|
||||
export async function regenerateRecoveryCodes(db: Kysely<DB>, adminUserId: string): Promise<string[]> {
|
||||
const codes = Array.from({ length: RECOVERY_CODE_COUNT }, newRecoveryCode);
|
||||
const hashes = await Promise.all(codes.map((c) => hashPassword(normalizeRecoveryCode(c))));
|
||||
await db.transaction().execute(async (trx) => {
|
||||
await trx.deleteFrom("admin_recovery_code").where("admin_user_id", "=", adminUserId).execute();
|
||||
await trx
|
||||
.insertInto("admin_recovery_code")
|
||||
.values(hashes.map((h) => ({ admin_user_id: adminUserId, code_hash: h })))
|
||||
.execute();
|
||||
});
|
||||
return codes;
|
||||
}
|
||||
|
||||
/** 校验并作废一个恢复码(条件更新:并发提交同一个码只有一个成功) */
|
||||
export async function consumeRecoveryCode(db: Kysely<DB>, adminUserId: string, code: string): Promise<boolean> {
|
||||
const norm = normalizeRecoveryCode(code);
|
||||
if (norm.length < 10) return false;
|
||||
const rows = await db
|
||||
.selectFrom("admin_recovery_code")
|
||||
.select(["id", "code_hash"])
|
||||
.where("admin_user_id", "=", adminUserId)
|
||||
.where("used_at", "is", null)
|
||||
.execute();
|
||||
for (const r of rows) {
|
||||
if (!(await verifyPassword(norm, r.code_hash))) continue;
|
||||
const u = await db
|
||||
.updateTable("admin_recovery_code")
|
||||
.set({ used_at: new Date() })
|
||||
.where("id", "=", r.id)
|
||||
.where("used_at", "is", null)
|
||||
.executeTakeFirst();
|
||||
return Number(u.numUpdatedRows) > 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function remainingRecoveryCodes(db: Kysely<DB>, adminUserId: string): Promise<number> {
|
||||
const r = await db
|
||||
.selectFrom("admin_recovery_code")
|
||||
.select(sql<number>`count(*)::int`.as("n"))
|
||||
.where("admin_user_id", "=", adminUserId)
|
||||
.where("used_at", "is", null)
|
||||
.executeTakeFirst();
|
||||
return r?.n ?? 0;
|
||||
}
|
||||
|
||||
/** 校验并消费 TOTP 计数器(条件更新防并发重放) */
|
||||
@@ -267,23 +341,55 @@ export async function changePassword(
|
||||
|
||||
// ── TOTP 管理 ──
|
||||
|
||||
export function totpStatus(admin: AdminRow | null) {
|
||||
return { enabled: !!admin?.totp_enabled_at, pending: !!admin?.totp_pending_secret_ref };
|
||||
export async function totpStatus(db: Kysely<DB>, admin: AdminRow | null): Promise<TotpStatus> {
|
||||
const enabled = !!admin?.totp_enabled_at;
|
||||
return {
|
||||
enabled,
|
||||
pending: !!admin?.totp_pending_secret_ref,
|
||||
recoveryCodesRemaining: enabled && admin ? await remainingRecoveryCodes(db, admin.id) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function totpBegin(deps: AuthDeps, admin: AdminRow): Promise<{ uri: string; secret: string }> {
|
||||
export interface TotpSetupInfo {
|
||||
uri: string;
|
||||
secret: string;
|
||||
issuer: string;
|
||||
account: string;
|
||||
}
|
||||
|
||||
function otpauthUri(secret: string, account: string): string {
|
||||
const label = encodeURIComponent(`${TOTP_ISSUER}:${account}`);
|
||||
return `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(TOTP_ISSUER)}&algorithm=SHA1&digits=6&period=30`;
|
||||
}
|
||||
|
||||
export async function totpBegin(deps: AuthDeps, admin: AdminRow): Promise<TotpSetupInfo> {
|
||||
if (admin.totp_enabled_at) throw new ApiException("conflict", "TOTP 已启用,先停用再重新绑定");
|
||||
const secret = base32Encode(randomBytes(20));
|
||||
const ref = await deps.secrets.create({ purpose: "other", plaintext: secret, label: `admin-totp:${randomToken(6)}` });
|
||||
const old = admin.totp_pending_secret_ref;
|
||||
await deps.db.updateTable("admin_user").set({ totp_pending_secret_ref: ref }).where("id", "=", admin.id).execute();
|
||||
if (old) await deps.secrets.remove(old).catch(() => {});
|
||||
const label = encodeURIComponent(`${TOTP_ISSUER}:${admin.username ?? "admin"}`);
|
||||
const uri = `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(TOTP_ISSUER)}&algorithm=SHA1&digits=6&period=30`;
|
||||
return { uri, secret };
|
||||
const account = admin.username ?? "admin";
|
||||
return { uri: otpauthUri(secret, account), secret, issuer: TOTP_ISSUER, account };
|
||||
}
|
||||
|
||||
export async function totpEnable(deps: AuthDeps, admin: AdminRow, code: string): Promise<void> {
|
||||
/** GET /auth/totp/setup:已有未确认的密钥就原样返回(刷新页面不作废已扫的码),没有才生成 */
|
||||
export async function totpSetup(deps: AuthDeps, admin: AdminRow): Promise<TotpSetupInfo> {
|
||||
if (admin.totp_enabled_at) throw new ApiException("conflict", "TOTP 已启用,先停用再重新绑定");
|
||||
if (admin.totp_pending_secret_ref) {
|
||||
try {
|
||||
const secret = await deps.secrets.resolve(admin.totp_pending_secret_ref);
|
||||
const account = admin.username ?? "admin";
|
||||
return { uri: otpauthUri(secret, account), secret, issuer: TOTP_ISSUER, account };
|
||||
} catch {
|
||||
// 待确认密钥读不出来(被删 / 主密钥不匹配):重新生成
|
||||
}
|
||||
}
|
||||
return totpBegin(deps, admin);
|
||||
}
|
||||
|
||||
/** 确认开启;返回新生成的恢复码明文(只此一次) */
|
||||
export async function totpEnable(deps: AuthDeps, admin: AdminRow, code: string): Promise<string[]> {
|
||||
if (!admin.totp_pending_secret_ref) throw new ApiException("precondition_failed", "先生成 TOTP 密钥");
|
||||
const secret = await deps.secrets.resolve(admin.totp_pending_secret_ref);
|
||||
const counter = verifyTotp(secret, code, null);
|
||||
@@ -300,6 +406,20 @@ export async function totpEnable(deps: AuthDeps, admin: AdminRow, code: string):
|
||||
.where("id", "=", admin.id)
|
||||
.execute();
|
||||
if (old) await deps.secrets.remove(old).catch(() => {});
|
||||
return await regenerateRecoveryCodes(deps.db, admin.id);
|
||||
}
|
||||
|
||||
/** 用当前密码重新生成恢复码(TOTP 必须已开启) */
|
||||
export async function totpRegenerateRecoveryCodes(
|
||||
deps: AuthDeps,
|
||||
admin: AdminRow,
|
||||
input: { password: string },
|
||||
): Promise<string[]> {
|
||||
if (!admin.totp_enabled_at) throw new ApiException("precondition_failed", "TOTP 未启用");
|
||||
if (!admin.password_hash || !(await verifyPassword(input.password, admin.password_hash))) {
|
||||
throw new ApiException("unauthorized", "密码错误");
|
||||
}
|
||||
return await regenerateRecoveryCodes(deps.db, admin.id);
|
||||
}
|
||||
|
||||
export async function totpDisable(
|
||||
@@ -311,13 +431,18 @@ export async function totpDisable(
|
||||
if (!admin.password_hash || !(await verifyPassword(input.password, admin.password_hash))) {
|
||||
throw new ApiException("unauthorized", "密码错误");
|
||||
}
|
||||
if (!(await consumeTotp(deps, admin, input.code))) throw new ApiException("unauthorized", "验证码错误");
|
||||
// 6 位数字按验证码校验,其余按恢复码(验证器丢了也能关)
|
||||
const ok = /^\d{6}$/.test(input.code)
|
||||
? await consumeTotp(deps, admin, input.code)
|
||||
: await consumeRecoveryCode(deps.db, admin.id, input.code);
|
||||
if (!ok) throw new ApiException("unauthorized", "验证码错误");
|
||||
const ref = admin.totp_secret_ref;
|
||||
await deps.db
|
||||
.updateTable("admin_user")
|
||||
.set({ totp_secret_ref: null, totp_enabled_at: null, totp_last_counter: null })
|
||||
.where("id", "=", admin.id)
|
||||
.execute();
|
||||
await deps.db.deleteFrom("admin_recovery_code").where("admin_user_id", "=", admin.id).execute();
|
||||
await deps.secrets.remove(ref).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -333,5 +458,8 @@ export async function sessionInfo(
|
||||
actor: actor ?? null,
|
||||
expiresAt: expiresAt?.toISOString() ?? null,
|
||||
totpEnabled: !!admin?.totp_enabled_at,
|
||||
passkeyEnabled: admin?.password_hash
|
||||
? !!(await db.selectFrom("admin_passkey").select("id").where("admin_user_id", "=", admin.id).executeTakeFirst())
|
||||
: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
type AuthenticationResponseJSON,
|
||||
type AuthenticatorTransportFuture,
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
type RegistrationResponseJSON,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import type { PasskeyChallenge, PasskeyInfo } from "@umt/contracts";
|
||||
import { type AdminPasskeyRow, type DB, type Kysely, sql } from "@umt/db";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { AdminRow } from "./auth-service.ts";
|
||||
|
||||
// Passkey(WebAuthn,照 uhd_doc src/server/admin-auth/passkey.ts):替代密码的第一因子,
|
||||
// 登录成功直接建会话(不再要求 TOTP)。@simplewebauthn/server 做两个 ceremony;
|
||||
// 挑战存 webauthn_challenge 表(一次性、5 分钟有效、条件更新消费),可以登记多把并命名。
|
||||
// RP ID 取配置 WEBAUTHN_RP_ID,不设时取请求主机名;来源取配置 WEBAUTHN_ORIGINS,不设时接受主机名属于 RP ID 的来源。
|
||||
|
||||
export const CHALLENGE_TTL_MS = 5 * 60_000;
|
||||
|
||||
export interface RpInfo {
|
||||
rpID: string;
|
||||
rpName: string;
|
||||
/** 配置的允许来源;null = 按 RP ID 校验客户端上报的来源 */
|
||||
origins: string[] | null;
|
||||
}
|
||||
|
||||
/** 由配置 + 请求主机名得到 RP 信息(纯函数,便于测试) */
|
||||
export function resolveRp(
|
||||
cfg: { WEBAUTHN_RP_ID?: string | undefined; WEBAUTHN_ORIGINS?: string | undefined; WEBAUTHN_RP_NAME?: string },
|
||||
requestHost: string | null | undefined,
|
||||
): RpInfo {
|
||||
const host = (requestHost ?? "").split(",")[0]?.trim() ?? "";
|
||||
const hostname = host
|
||||
.replace(/:\d+$/, "")
|
||||
.replace(/^\[|\]$/g, "")
|
||||
.toLowerCase();
|
||||
const rpID = cfg.WEBAUTHN_RP_ID?.trim().toLowerCase() || hostname || "localhost";
|
||||
const origins = cfg.WEBAUTHN_ORIGINS
|
||||
? cfg.WEBAUTHN_ORIGINS.split(",")
|
||||
.map((s) => s.trim().replace(/\/+$/, ""))
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
return { rpID, rpName: cfg.WEBAUTHN_RP_NAME ?? "UHD Media Tool", origins: origins?.length ? origins : null };
|
||||
}
|
||||
|
||||
/** 客户端上报的来源(clientDataJSON.origin);没配置允许来源时,主机名必须等于 RP ID 或是它的子域 */
|
||||
function expectedOrigins(rp: RpInfo, response: { response?: { clientDataJSON?: unknown } }): string[] {
|
||||
if (rp.origins) return rp.origins;
|
||||
let origin = "";
|
||||
try {
|
||||
const raw = String(response.response?.clientDataJSON ?? "");
|
||||
origin = String((JSON.parse(Buffer.from(raw, "base64url").toString("utf8")) as { origin?: unknown }).origin ?? "");
|
||||
} catch {
|
||||
throw new ApiException("bad_request", "Passkey 响应格式不正确");
|
||||
}
|
||||
let hostname = "";
|
||||
try {
|
||||
hostname = new URL(origin).hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
} catch {
|
||||
throw new ApiException("unauthorized", "Passkey 来源无效");
|
||||
}
|
||||
if (hostname !== rp.rpID && !hostname.endsWith(`.${rp.rpID}`))
|
||||
throw new ApiException("unauthorized", `Passkey 来源 ${origin} 与站点 ${rp.rpID} 不符`);
|
||||
return [origin];
|
||||
}
|
||||
|
||||
// ── 挑战 ──
|
||||
|
||||
async function saveChallenge(
|
||||
db: Kysely<DB>,
|
||||
purpose: "register" | "login",
|
||||
challenge: string,
|
||||
adminUserId: string | null,
|
||||
): Promise<string> {
|
||||
// 顺手清理过期挑战
|
||||
await db
|
||||
.deleteFrom("webauthn_challenge")
|
||||
.where("expires_at", "<", new Date(Date.now() - CHALLENGE_TTL_MS))
|
||||
.execute();
|
||||
const r = await db
|
||||
.insertInto("webauthn_challenge")
|
||||
.values({ purpose, challenge, admin_user_id: adminUserId, expires_at: new Date(Date.now() + CHALLENGE_TTL_MS) })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
return r.id;
|
||||
}
|
||||
|
||||
/** 取出并作废挑战(一次性);过期 / 用过 / 用途不符返回 null */
|
||||
async function takeChallenge(
|
||||
db: Kysely<DB>,
|
||||
id: string,
|
||||
purpose: "register" | "login",
|
||||
adminUserId: string | null,
|
||||
): Promise<string | null> {
|
||||
let q = db
|
||||
.updateTable("webauthn_challenge")
|
||||
.set({ used_at: new Date() })
|
||||
.where("id", "=", id)
|
||||
.where("purpose", "=", purpose)
|
||||
.where("used_at", "is", null)
|
||||
.where("expires_at", ">", new Date());
|
||||
if (adminUserId) q = q.where("admin_user_id", "=", adminUserId);
|
||||
const r = await q.returning("challenge").executeTakeFirst();
|
||||
return r?.challenge ?? null;
|
||||
}
|
||||
|
||||
// ── 登记的 Passkey ──
|
||||
|
||||
const transportsOf = (v: unknown): AuthenticatorTransportFuture[] =>
|
||||
Array.isArray(v) ? (v.filter((x) => typeof x === "string") as AuthenticatorTransportFuture[]) : [];
|
||||
|
||||
export function toPasskeyInfo(r: AdminPasskeyRow): PasskeyInfo {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
deviceType: r.device_type,
|
||||
backedUp: r.backed_up,
|
||||
createdAt: r.created_at.toISOString(),
|
||||
lastUsedAt: r.last_used_at?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPasskeys(db: Kysely<DB>, adminUserId: string): Promise<AdminPasskeyRow[]> {
|
||||
return await db
|
||||
.selectFrom("admin_passkey")
|
||||
.selectAll()
|
||||
.where("admin_user_id", "=", adminUserId)
|
||||
.orderBy("created_at")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function renamePasskey(db: Kysely<DB>, adminUserId: string, id: string, name: string) {
|
||||
const r = await db
|
||||
.updateTable("admin_passkey")
|
||||
.set({ name: name.trim() })
|
||||
.where("id", "=", id)
|
||||
.where("admin_user_id", "=", adminUserId)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
if (!r) throw new ApiException("not_found", "Passkey 不存在");
|
||||
return r;
|
||||
}
|
||||
|
||||
export async function deletePasskey(db: Kysely<DB>, adminUserId: string, id: string) {
|
||||
const r = await db
|
||||
.deleteFrom("admin_passkey")
|
||||
.where("id", "=", id)
|
||||
.where("admin_user_id", "=", adminUserId)
|
||||
.returning(["id", "name"])
|
||||
.executeTakeFirst();
|
||||
if (!r) throw new ApiException("not_found", "Passkey 不存在");
|
||||
return r;
|
||||
}
|
||||
|
||||
// ── 注册(需要控制台会话) ──
|
||||
|
||||
export async function registrationOptions(db: Kysely<DB>, admin: AdminRow, rp: RpInfo): Promise<PasskeyChallenge> {
|
||||
const existing = await listPasskeys(db, admin.id);
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: rp.rpName,
|
||||
rpID: rp.rpID,
|
||||
userName: admin.username ?? "admin",
|
||||
userID: new TextEncoder().encode(admin.id),
|
||||
attestationType: "none",
|
||||
excludeCredentials: existing.map((c) => ({ id: c.credential_id, transports: transportsOf(c.transports) })),
|
||||
authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" },
|
||||
});
|
||||
const challengeId = await saveChallenge(db, "register", options.challenge, admin.id);
|
||||
return { challengeId, options: options as unknown as Record<string, unknown> };
|
||||
}
|
||||
|
||||
export async function verifyRegistration(
|
||||
db: Kysely<DB>,
|
||||
admin: AdminRow,
|
||||
rp: RpInfo,
|
||||
input: { challengeId: string; name: string; response: Record<string, unknown> },
|
||||
): Promise<AdminPasskeyRow> {
|
||||
const response = input.response as unknown as RegistrationResponseJSON;
|
||||
const origins = expectedOrigins(rp, response);
|
||||
const challenge = await takeChallenge(db, input.challengeId, "register", admin.id);
|
||||
if (!challenge) throw new ApiException("precondition_failed", "注册挑战已过期或已用过,重新开始");
|
||||
let v: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
|
||||
try {
|
||||
v = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: rp.rpID,
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new ApiException("unauthorized", `Passkey 校验失败:${(err as Error).message}`);
|
||||
}
|
||||
if (!v.verified) throw new ApiException("unauthorized", "Passkey 校验失败");
|
||||
const { credential, credentialDeviceType, credentialBackedUp } = v.registrationInfo;
|
||||
try {
|
||||
return await db
|
||||
.insertInto("admin_passkey")
|
||||
.values({
|
||||
admin_user_id: admin.id,
|
||||
credential_id: credential.id,
|
||||
public_key: Buffer.from(credential.publicKey),
|
||||
counter: credential.counter,
|
||||
transports: JSON.stringify(credential.transports ?? response.response?.transports ?? []),
|
||||
device_type: credentialDeviceType,
|
||||
backed_up: credentialBackedUp,
|
||||
name: input.name.trim(),
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code === "23505") throw new ApiException("conflict", "这把 Passkey 已经登记过");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 登录(免鉴权) ──
|
||||
|
||||
export async function authenticationOptions(db: Kysely<DB>, admin: AdminRow, rp: RpInfo): Promise<PasskeyChallenge> {
|
||||
const creds = await listPasskeys(db, admin.id);
|
||||
if (!creds.length) throw new ApiException("precondition_failed", "还没有登记 Passkey,用密码登录");
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rp.rpID,
|
||||
userVerification: "preferred",
|
||||
allowCredentials: creds.map((c) => ({ id: c.credential_id, transports: transportsOf(c.transports) })),
|
||||
});
|
||||
const challengeId = await saveChallenge(db, "login", options.challenge, null);
|
||||
return { challengeId, options: options as unknown as Record<string, unknown> };
|
||||
}
|
||||
|
||||
/** 校验断言;成功后更新签名计数器与最近使用时间。失败抛 unauthorized / precondition_failed */
|
||||
export async function verifyAuthentication(
|
||||
db: Kysely<DB>,
|
||||
admin: AdminRow,
|
||||
rp: RpInfo,
|
||||
input: { challengeId: string; response: Record<string, unknown> },
|
||||
): Promise<AdminPasskeyRow> {
|
||||
const response = input.response as unknown as AuthenticationResponseJSON;
|
||||
const origins = expectedOrigins(rp, response);
|
||||
const challenge = await takeChallenge(db, input.challengeId, "login", null);
|
||||
if (!challenge) throw new ApiException("precondition_failed", "登录挑战已过期或已用过,重试");
|
||||
const cred = await db
|
||||
.selectFrom("admin_passkey")
|
||||
.selectAll()
|
||||
.where("admin_user_id", "=", admin.id)
|
||||
.where("credential_id", "=", String(response.id ?? ""))
|
||||
.executeTakeFirst();
|
||||
if (!cred) throw new ApiException("unauthorized", "这把 Passkey 没有登记");
|
||||
let v: Awaited<ReturnType<typeof verifyAuthenticationResponse>>;
|
||||
try {
|
||||
v = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: rp.rpID,
|
||||
credential: {
|
||||
id: cred.credential_id,
|
||||
publicKey: new Uint8Array(cred.public_key),
|
||||
counter: Number(cred.counter),
|
||||
transports: transportsOf(cred.transports),
|
||||
},
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new ApiException("unauthorized", `Passkey 校验失败:${(err as Error).message}`);
|
||||
}
|
||||
if (!v.verified) throw new ApiException("unauthorized", "Passkey 校验失败");
|
||||
// 计数器只增不减(条件更新:并发重放同一个断言只有一个生效;计数器恒为 0 的认证器照常放行)
|
||||
const next = v.authenticationInfo.newCounter;
|
||||
const u = await db
|
||||
.updateTable("admin_passkey")
|
||||
.set({
|
||||
counter: next,
|
||||
last_used_at: new Date(),
|
||||
device_type: v.authenticationInfo.credentialDeviceType,
|
||||
backed_up: v.authenticationInfo.credentialBackedUp,
|
||||
})
|
||||
.where("id", "=", cred.id)
|
||||
.where(sql<boolean>`(counter < ${next} or (counter = 0 and ${next} = 0))`)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
if (!u) throw new ApiException("unauthorized", "Passkey 签名计数器回退(可能被克隆),已拒绝");
|
||||
return u;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:crypto";
|
||||
import { createCipheriv, createDecipheriv, createHmac, randomBytes, randomUUID } from "node:crypto";
|
||||
import { DEFAULT_SETTINGS, GlobalSettings, type SecretPurpose, type SettingsKey } from "@umt/contracts";
|
||||
import type { DB, Kysely } from "@umt/db";
|
||||
import type { Logger } from "pino";
|
||||
@@ -12,7 +12,7 @@ import { deriveKey } from "./crypto.ts";
|
||||
|
||||
export const SECRET_ALGORITHM = "aes-256-gcm";
|
||||
export const SECRET_KDF = "hkdf-sha256";
|
||||
/** 当前主密钥版本;轮换主密钥时 +1,并用 rewrapAll 重新加密 */
|
||||
/** 密文格式版本(HKDF info = umt/secret/v<版本>);主密钥轮换不改它,换哪把主密钥看 kdf_params.kid */
|
||||
export const SECRET_KEY_VERSION = 1;
|
||||
|
||||
/** 找不到 secret 或无法解密;消息里只有 ref,不含任何明文 */
|
||||
@@ -52,9 +52,14 @@ interface Sealed {
|
||||
ciphertext: Buffer;
|
||||
nonce: Buffer;
|
||||
authTag: Buffer;
|
||||
kdfParams: { salt: string; info: string };
|
||||
/** kid:主密钥指纹(HMAC 派生,不可逆),主密钥轮换时据此跳过已换过的行 */
|
||||
kdfParams: { salt: string; info: string; kid: string };
|
||||
}
|
||||
|
||||
/** 主密钥指纹:HMAC-SHA256(masterKey, 固定标签) 取前 16 位十六进制;只用于区分密钥,不能反推 */
|
||||
export const masterKeyId = (masterKey: string) =>
|
||||
createHmac("sha256", Buffer.from(masterKey, "utf8")).update("umt/secret/kid").digest("hex").slice(0, 16);
|
||||
|
||||
/** AAD 绑定行 id 与用途:密文被挪到别的行/改用途即解密失败 */
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
function assertRef(ref: string) {
|
||||
@@ -72,7 +77,12 @@ function seal(masterKey: string, id: string, purpose: string, plaintext: string)
|
||||
const cipher = createCipheriv(SECRET_ALGORITHM, key, nonce);
|
||||
cipher.setAAD(aad(id, purpose));
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
return { ciphertext, nonce, authTag: cipher.getAuthTag(), kdfParams: { salt: salt.toString("base64"), info } };
|
||||
return {
|
||||
ciphertext,
|
||||
nonce,
|
||||
authTag: cipher.getAuthTag(),
|
||||
kdfParams: { salt: salt.toString("base64"), info, kid: masterKeyId(masterKey) },
|
||||
};
|
||||
}
|
||||
|
||||
function open(
|
||||
@@ -188,6 +198,108 @@ export function createSecretStore(deps: { db: Kysely<DB>; masterKey: string }):
|
||||
};
|
||||
}
|
||||
|
||||
// ── 主密钥轮换(--role rotate-master-key) ──
|
||||
|
||||
export interface RotateMasterKeyResult {
|
||||
total: number;
|
||||
/** 本次用新密钥重新加密的行 */
|
||||
rotated: number;
|
||||
/** 已经是新密钥加密的行(上次中断后重跑时跳过) */
|
||||
skipped: number;
|
||||
/** 新旧密钥都解不开的行(id 列表;不改动) */
|
||||
failed: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 用新主密钥逐条重新加密 secret 表:每行一个事务(select … for update → 旧密钥解密 → 新盐新 nonce 加密 → 写回),
|
||||
* 已能被新密钥解开的行跳过,所以中断后原样重跑即可(幂等);新旧密钥对调再跑一遍就是回滚。
|
||||
* 明文只在单行事务内存里出现,不写日志。
|
||||
*/
|
||||
export async function rotateMasterKey(
|
||||
deps: { db: Kysely<DB>; logger: Logger },
|
||||
oldKey: string,
|
||||
newKey: string,
|
||||
opts: { progressEvery?: number } = {},
|
||||
): Promise<RotateMasterKeyResult> {
|
||||
if (oldKey === newKey) throw new Error("新旧主密钥相同,不需要轮换");
|
||||
const { db, logger } = deps;
|
||||
const newKid = masterKeyId(newKey);
|
||||
const every = opts.progressEvery ?? 50;
|
||||
const ids = (await db.selectFrom("secret").select("id").orderBy("id").execute()).map((r) => r.id);
|
||||
const out: RotateMasterKeyResult = { total: ids.length, rotated: 0, skipped: 0, failed: [] };
|
||||
logger.info({ total: ids.length, newKeyId: newKid }, "主密钥轮换开始");
|
||||
let done = 0;
|
||||
for (const id of ids) {
|
||||
const r = await db.transaction().execute(async (trx) => {
|
||||
const row = await trx
|
||||
.selectFrom("secret")
|
||||
.select(["id", "purpose", "ciphertext", "nonce", "auth_tag", "kdf_params"])
|
||||
.where("id", "=", id)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (!row) return "gone" as const;
|
||||
const tryOpen = (key: string) => {
|
||||
try {
|
||||
return open(key, row);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
if (tryOpen(newKey) !== null) return "skipped" as const;
|
||||
const plain = tryOpen(oldKey);
|
||||
if (plain === null) return "failed" as const;
|
||||
const sealed = seal(newKey, row.id, row.purpose, plain);
|
||||
await trx
|
||||
.updateTable("secret")
|
||||
.set({
|
||||
ciphertext: sealed.ciphertext,
|
||||
nonce: sealed.nonce,
|
||||
auth_tag: sealed.authTag,
|
||||
kdf_params: JSON.stringify(sealed.kdfParams),
|
||||
rotated_at: new Date(),
|
||||
})
|
||||
.where("id", "=", row.id)
|
||||
.execute();
|
||||
return "rotated" as const;
|
||||
});
|
||||
if (r === "rotated") out.rotated++;
|
||||
else if (r === "skipped") out.skipped++;
|
||||
else if (r === "failed") {
|
||||
out.failed.push(id);
|
||||
logger.warn({ secretId: id }, "这条 secret 新旧主密钥都解不开,已跳过(保持原样)");
|
||||
}
|
||||
done++;
|
||||
if (done % every === 0 || done === ids.length)
|
||||
logger.info(
|
||||
{ done, total: ids.length, rotated: out.rotated, skipped: out.skipped, failed: out.failed.length },
|
||||
"主密钥轮换进度",
|
||||
);
|
||||
}
|
||||
await db
|
||||
.insertInto("audit_event")
|
||||
.values({
|
||||
actor_type: "system",
|
||||
actor_id: "rotate-master-key",
|
||||
action: "secret.master_key_rotate",
|
||||
target_type: "secret",
|
||||
target_id: null,
|
||||
payload: JSON.stringify({
|
||||
total: out.total,
|
||||
rotated: out.rotated,
|
||||
skipped: out.skipped,
|
||||
failed: out.failed,
|
||||
newKeyId: newKid,
|
||||
}),
|
||||
})
|
||||
.execute()
|
||||
.catch((err) => logger.warn({ err: { message: (err as Error).message } }, "主密钥轮换审计写入失败"));
|
||||
logger.info(
|
||||
{ total: out.total, rotated: out.rotated, skipped: out.skipped, failed: out.failed.length },
|
||||
out.failed.length ? "主密钥轮换结束,有解不开的行" : "主密钥轮换完成",
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── 设置读取 ──
|
||||
|
||||
/** SSE / 进程间广播的设置变更事件 */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { sql } from "@umt/db";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { ensureSetupToken, MAX_FAILURES_PER_IP } from "../../src/modules/auth-settings/auth-service.ts";
|
||||
import { totpAt, totpCounter } from "../../src/modules/auth-settings/crypto.ts";
|
||||
import { resolveRp } from "../../src/modules/auth-settings/passkey.ts";
|
||||
import { DEV_KEY, json } from "../helpers.ts";
|
||||
import { Browser, bearer, createHarness, type Harness, pgAvailable } from "./harness.ts";
|
||||
import { SoftAuthenticator } from "./soft-authenticator.ts";
|
||||
|
||||
// C1-B 账户与安全:TOTP 二维码 setup 端点、一次性恢复码、Passkey 注册 / 登录 / 命名 / 删除(真实 @simplewebauthn/server 校验)。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const PASSWORD = "correct horse battery";
|
||||
|
||||
describe("resolveRp", () => {
|
||||
it("配置优先;未配置时取请求主机名(去端口、转小写)", () => {
|
||||
expect(resolveRp({}, "Media.Example.com:8443")).toMatchObject({ rpID: "media.example.com", origins: null });
|
||||
expect(
|
||||
resolveRp(
|
||||
{ WEBAUTHN_RP_ID: "example.com", WEBAUTHN_ORIGINS: "https://a.example.com/, https://example.com" },
|
||||
"x",
|
||||
),
|
||||
).toMatchObject({ rpID: "example.com", origins: ["https://a.example.com", "https://example.com"] });
|
||||
expect(resolveRp({}, null).rpID).toBe("localhost");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasPg)("账户与安全:恢复码 / Passkey(真实 PG)", () => {
|
||||
let h: Harness;
|
||||
beforeAll(async () => {
|
||||
h = await createHarness();
|
||||
const token = await ensureSetupToken(h.ctx);
|
||||
const r = await new Browser(h.app).req("POST", "/auth/setup", {
|
||||
setupToken: token,
|
||||
username: "admin",
|
||||
password: PASSWORD,
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await sql`delete from login_attempt`.execute(h.db);
|
||||
});
|
||||
|
||||
const login = (b: Browser, extra: Record<string, unknown> = {}) =>
|
||||
b.req("POST", "/auth/login", { username: "admin", password: PASSWORD, ...extra });
|
||||
const audits = async (action: string) =>
|
||||
(await sql<{ n: number }>`select count(*)::int as n from audit_event where action = ${action}`.execute(h.db))
|
||||
.rows[0]!.n;
|
||||
|
||||
it("TOTP:GET /auth/totp/setup 给 otpauth URL(重复请求不换密钥)→ 启用返回 8 个恢复码 → 恢复码登录一次性 → 重新生成 → 用恢复码停用", async () => {
|
||||
const a = new Browser(h.app);
|
||||
expect((await login(a)).status).toBe(200);
|
||||
// API Key 不能取密钥
|
||||
expect((await bearer(h.app, DEV_KEY)("GET", "/auth/totp/setup")).status).toBe(403);
|
||||
const s1 = (await json(await a.req("GET", "/auth/totp/setup"))).data;
|
||||
expect(s1.otpauthUri).toMatch(/^otpauth:\/\/totp\/UHD%20Media%20Tool%3Aadmin\?secret=[A-Z2-7]+&issuer=/);
|
||||
expect(s1).toMatchObject({ issuer: "UHD Media Tool", account: "admin" });
|
||||
const s2 = (await json(await a.req("GET", "/auth/totp/setup"))).data;
|
||||
expect(s2.secret).toBe(s1.secret);
|
||||
expect((await json(await a.req("GET", "/auth/totp"))).data).toEqual({
|
||||
enabled: false,
|
||||
pending: true,
|
||||
recoveryCodesRemaining: 0,
|
||||
});
|
||||
|
||||
const code0 = totpAt(s1.secret, totpCounter());
|
||||
const en = (await json(await a.req("POST", "/auth/totp", { action: "enable", code: code0 }))).data;
|
||||
expect(en.recoveryCodes).toHaveLength(8);
|
||||
expect(new Set(en.recoveryCodes).size).toBe(8);
|
||||
for (const c of en.recoveryCodes) expect(c).toMatch(/^[A-Z2-9]{5}-[A-Z2-9]{5}$/);
|
||||
expect(en.status).toMatchObject({ enabled: true, pending: false, recoveryCodesRemaining: 8 });
|
||||
// 已开启:setup 端点 409
|
||||
expect((await a.req("GET", "/auth/totp/setup")).status).toBe(409);
|
||||
// 库里只存哈希
|
||||
const rows = await sql<{ code_hash: string }>`select code_hash from admin_recovery_code`.execute(h.db);
|
||||
expect(rows.rows).toHaveLength(8);
|
||||
for (const r of rows.rows) {
|
||||
expect(r.code_hash).toMatch(/^\$argon2id\$/);
|
||||
expect(en.recoveryCodes.some((c: string) => r.code_hash.includes(c.replace("-", "")))).toBe(false);
|
||||
}
|
||||
|
||||
// 恢复码登录:小写 + 去掉连字符也认;同一个码第二次失败
|
||||
const rc: string = en.recoveryCodes[0];
|
||||
const b = new Browser(h.app, "203.0.113.31");
|
||||
expect((await json(await login(b))).data).toEqual({ status: "totp_required" });
|
||||
const ok = await login(b, { recoveryCode: rc.replace("-", "").toLowerCase() });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(b.cookies.has("umt_session")).toBe(true);
|
||||
expect((await login(new Browser(h.app, "203.0.113.32"), { recoveryCode: rc })).status).toBe(401);
|
||||
expect(await audits("admin.recovery_code_login")).toBe(1);
|
||||
expect((await json(await a.req("GET", "/auth/totp"))).data.recoveryCodesRemaining).toBe(7);
|
||||
const fails = await sql<{
|
||||
n: number;
|
||||
}>`select count(*)::int as n from login_attempt where reason = 'bad_recovery_code'`.execute(h.db);
|
||||
expect(fails.rows[0]!.n).toBe(1);
|
||||
|
||||
// 重新生成:要密码;旧码全部作废
|
||||
expect((await a.req("POST", "/auth/totp", { action: "recovery_codes", password: "wrong-password" })).status).toBe(
|
||||
401,
|
||||
);
|
||||
const re = (await json(await a.req("POST", "/auth/totp", { action: "recovery_codes", password: PASSWORD }))).data;
|
||||
expect(re.recoveryCodes).toHaveLength(8);
|
||||
expect(re.status.recoveryCodesRemaining).toBe(8);
|
||||
expect((await login(new Browser(h.app, "203.0.113.33"), { recoveryCode: en.recoveryCodes[1] })).status).toBe(401);
|
||||
expect(await audits("admin.recovery_codes_regenerate")).toBe(1);
|
||||
|
||||
// 验证器丢了:用恢复码停用
|
||||
const off = await a.req("POST", "/auth/totp", { action: "disable", password: PASSWORD, code: re.recoveryCodes[0] });
|
||||
expect(off.status).toBe(200);
|
||||
expect((await json(off)).data.status).toEqual({ enabled: false, pending: false, recoveryCodesRemaining: 0 });
|
||||
expect(
|
||||
(await sql<{ n: number }>`select count(*)::int as n from admin_recovery_code`.execute(h.db)).rows[0]!.n,
|
||||
).toBe(0);
|
||||
expect((await json(await login(new Browser(h.app, "203.0.113.34")))).data.status).toBe("ok");
|
||||
});
|
||||
|
||||
it("Passkey:注册(挑战一次性)→ 多把、命名 → 登录页入口 → Passkey 登录建会话 → 重放 / 错来源 / 计数器回退拒绝 → 删除", async () => {
|
||||
const a = new Browser(h.app);
|
||||
expect((await login(a)).status).toBe(200);
|
||||
expect((await json(await new Browser(h.app).req("GET", "/auth/session"))).data.passkeyEnabled).toBe(false);
|
||||
// 没有 Passkey 时登录挑战 412
|
||||
expect((await new Browser(h.app).req("POST", "/auth/passkeys/login/options")).status).toBe(412);
|
||||
// API Key 不能登记
|
||||
expect((await bearer(h.app, DEV_KEY)("POST", "/auth/passkeys/register/options")).status).toBe(403);
|
||||
|
||||
const reg = async (auth: SoftAuthenticator, name: string) => {
|
||||
const o = (await json(await a.req("POST", "/auth/passkeys/register/options"))).data;
|
||||
expect(o.options).toMatchObject({ rp: { id: "localhost", name: "UHD Media Tool" }, user: { name: "admin" } });
|
||||
const res = await a.req("POST", "/auth/passkeys/register/verify", {
|
||||
challengeId: o.challengeId,
|
||||
name,
|
||||
response: auth.register(o.options),
|
||||
});
|
||||
return { o, res };
|
||||
};
|
||||
const k1 = new SoftAuthenticator();
|
||||
const r1 = await reg(k1, "MacBook 指纹");
|
||||
expect(r1.res.status).toBe(201);
|
||||
expect((await json(r1.res)).data).toMatchObject({ name: "MacBook 指纹", lastUsedAt: null });
|
||||
// 同一个挑战不能再用
|
||||
const again = await a.req("POST", "/auth/passkeys/register/verify", {
|
||||
challengeId: r1.o.challengeId,
|
||||
name: "x",
|
||||
response: new SoftAuthenticator().register(r1.o.options),
|
||||
});
|
||||
expect(again.status).toBe(412);
|
||||
// 来源不属于 RP ID → 拒绝
|
||||
const o2 = (await json(await a.req("POST", "/auth/passkeys/register/options"))).data;
|
||||
expect(o2.options.excludeCredentials.map((c: { id: string }) => c.id)).toEqual([k1.id]);
|
||||
const evil = await a.req("POST", "/auth/passkeys/register/verify", {
|
||||
challengeId: o2.challengeId,
|
||||
name: "evil",
|
||||
response: new SoftAuthenticator().register(o2.options, { origin: "https://evil.example" }),
|
||||
});
|
||||
expect(evil.status).toBe(401);
|
||||
const k2 = new SoftAuthenticator();
|
||||
expect((await reg(k2, "YubiKey")).res.status).toBe(201);
|
||||
const list = (await json(await a.req("GET", "/auth/passkeys"))).data.items;
|
||||
expect(list.map((p: { name: string }) => p.name)).toEqual(["MacBook 指纹", "YubiKey"]);
|
||||
const renamed = await a.req("PATCH", `/auth/passkeys/${list[1].id}`, { name: "备用钥匙" });
|
||||
expect((await json(renamed)).data.name).toBe("备用钥匙");
|
||||
|
||||
// 登录页:未登录也能知道有 Passkey
|
||||
const guest = new Browser(h.app, "203.0.113.41");
|
||||
expect((await json(await guest.req("GET", "/auth/session"))).data).toMatchObject({
|
||||
authenticated: false,
|
||||
passkeyEnabled: true,
|
||||
});
|
||||
const lo = (await json(await guest.req("POST", "/auth/passkeys/login/options"))).data;
|
||||
expect(lo.options.allowCredentials.map((c: { id: string }) => c.id).sort()).toEqual([k1.id, k2.id].sort());
|
||||
const assertion = k1.assert(lo.options);
|
||||
const res = await guest.req("POST", "/auth/passkeys/login/verify", {
|
||||
challengeId: lo.challengeId,
|
||||
response: assertion,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect((await json(res)).data).toMatchObject({
|
||||
status: "ok",
|
||||
session: { authenticated: true, passkeyEnabled: true },
|
||||
});
|
||||
expect(guest.cookies.has("umt_session")).toBe(true);
|
||||
expect(guest.cookies.has("umt_csrf")).toBe(true);
|
||||
expect((await guest.req("GET", "/settings")).status).toBe(200);
|
||||
expect(await audits("admin.passkey_login")).toBe(1);
|
||||
const used = (await json(await a.req("GET", "/auth/passkeys"))).data.items[0];
|
||||
expect(used.lastUsedAt).toBeTruthy();
|
||||
|
||||
// 重放同一个断言(挑战已消费)→ 412;新挑战 + 旧计数器 → 401(计数器回退)
|
||||
const replay = await new Browser(h.app, "203.0.113.42").req("POST", "/auth/passkeys/login/verify", {
|
||||
challengeId: lo.challengeId,
|
||||
response: assertion,
|
||||
});
|
||||
expect(replay.status).toBe(412);
|
||||
const lo2 = (await json(await new Browser(h.app, "203.0.113.43").req("POST", "/auth/passkeys/login/options"))).data;
|
||||
const stale = await new Browser(h.app, "203.0.113.43").req("POST", "/auth/passkeys/login/verify", {
|
||||
challengeId: lo2.challengeId,
|
||||
response: k1.assert(lo2.options, { counter: 1 }),
|
||||
});
|
||||
expect(stale.status).toBe(401);
|
||||
// 没登记的钥匙 → 401
|
||||
const lo3 = (await json(await new Browser(h.app, "203.0.113.44").req("POST", "/auth/passkeys/login/options"))).data;
|
||||
const unknown = await new Browser(h.app, "203.0.113.44").req("POST", "/auth/passkeys/login/verify", {
|
||||
challengeId: lo3.challengeId,
|
||||
response: new SoftAuthenticator().assert(lo3.options),
|
||||
});
|
||||
expect(unknown.status).toBe(401);
|
||||
|
||||
// 删除:只剩一把
|
||||
expect((await a.req("DELETE", `/auth/passkeys/${list[0].id}`)).status).toBe(200);
|
||||
expect((await a.req("DELETE", `/auth/passkeys/${list[0].id}`)).status).toBe(404);
|
||||
expect((await json(await a.req("GET", "/auth/passkeys"))).data.items).toHaveLength(1);
|
||||
expect(await audits("admin.passkey_register")).toBe(2);
|
||||
expect(await audits("admin.passkey_remove")).toBe(1);
|
||||
});
|
||||
|
||||
it("Passkey 登录与密码登录共用 IP 限速", async () => {
|
||||
const ip = "203.0.113.50";
|
||||
for (let i = 0; i < MAX_FAILURES_PER_IP; i++) {
|
||||
const o = (await json(await new Browser(h.app, ip).req("POST", "/auth/passkeys/login/options"))).data;
|
||||
const r = await new Browser(h.app, ip).req("POST", "/auth/passkeys/login/verify", {
|
||||
challengeId: o.challengeId,
|
||||
response: new SoftAuthenticator().assert(o.options),
|
||||
});
|
||||
expect(r.status).toBe(401);
|
||||
}
|
||||
expect((await new Browser(h.app, ip).req("POST", "/auth/passkeys/login/options")).status).toBe(429);
|
||||
expect((await login(new Browser(h.app, ip))).status).toBe(429);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { sql } from "@umt/db";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createSecretStore,
|
||||
masterKeyId,
|
||||
rotateMasterKey,
|
||||
SecretError,
|
||||
} from "../../src/modules/auth-settings/provided.ts";
|
||||
import { memoryLogger } from "../helpers.ts";
|
||||
import { createHarness, type Harness, MASTER_KEY, PG_URL, pgAvailable } from "./harness.ts";
|
||||
|
||||
// 主密钥轮换(--role rotate-master-key):逐行事务重新加密、幂等重跑、解不开的行不动、新旧对调即回滚。
|
||||
// 每个用例一个临时 schema(harness),不碰开发库的 public。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
const NEW_KEY = "rotated-master-key-abcdefghijkl";
|
||||
|
||||
describe.skipIf(!hasPg)("主密钥轮换(临时库)", () => {
|
||||
let h: Harness;
|
||||
beforeAll(async () => {
|
||||
h = await createHarness();
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("旧密钥 → 新密钥:全部换新、幂等、解不开的行原样保留;对调重跑即回滚", async () => {
|
||||
const oldStore = createSecretStore({ db: h.db, masterKey: MASTER_KEY });
|
||||
const refs: string[] = [];
|
||||
for (let i = 0; i < 7; i++)
|
||||
refs.push(await oldStore.create({ purpose: "site_cookie", plaintext: `cookie-${i}`, label: `s${i}` }));
|
||||
// 一条用别的密钥写的"坏行"(新旧都解不开)
|
||||
const bad = await createSecretStore({ db: h.db, masterKey: "some-unknown-master-key-xx" }).create({
|
||||
purpose: "other",
|
||||
plaintext: "lost",
|
||||
});
|
||||
const before = await sql<{
|
||||
id: string;
|
||||
ciphertext: Buffer;
|
||||
}>`select id, ciphertext from secret where id = ${bad}`.execute(h.db);
|
||||
|
||||
const { logger, lines } = memoryLogger();
|
||||
const r1 = await rotateMasterKey({ db: h.db, logger }, MASTER_KEY, NEW_KEY, { progressEvery: 3 });
|
||||
expect(r1).toEqual({ total: 8, rotated: 7, skipped: 0, failed: [bad] });
|
||||
// 进度日志:每 3 行一条 + 结尾;不含明文
|
||||
expect(lines.filter((l) => l.includes("主密钥轮换进度")).length).toBeGreaterThanOrEqual(3);
|
||||
expect(lines.some((l) => l.includes("cookie-"))).toBe(false);
|
||||
|
||||
const newStore = createSecretStore({ db: h.db, masterKey: NEW_KEY });
|
||||
for (let i = 0; i < refs.length; i++) expect(await newStore.resolve(refs[i]!)).toBe(`cookie-${i}`);
|
||||
await expect(oldStore.resolve(refs[0]!)).rejects.toBeInstanceOf(SecretError);
|
||||
const kids = await sql<{ kid: string }>`select kdf_params->>'kid' as kid from secret where id <> ${bad}`.execute(
|
||||
h.db,
|
||||
);
|
||||
expect(new Set(kids.rows.map((k) => k.kid))).toEqual(new Set([masterKeyId(NEW_KEY)]));
|
||||
const after = await sql<{ ciphertext: Buffer }>`select ciphertext from secret where id = ${bad}`.execute(h.db);
|
||||
expect(Buffer.compare(after.rows[0]!.ciphertext, before.rows[0]!.ciphertext)).toBe(0);
|
||||
|
||||
// 中断后重跑:已换过的跳过
|
||||
const r2 = await rotateMasterKey({ db: h.db, logger }, MASTER_KEY, NEW_KEY);
|
||||
expect(r2).toEqual({ total: 8, rotated: 0, skipped: 7, failed: [bad] });
|
||||
const audit = await sql<{ payload: { rotated: number } }>`
|
||||
select payload from audit_event where action = 'secret.master_key_rotate' order by at, id`.execute(h.db);
|
||||
expect(audit.rows.map((a) => a.payload.rotated)).toEqual([7, 0]);
|
||||
|
||||
// 回滚:新旧对调
|
||||
const back = await rotateMasterKey({ db: h.db, logger }, NEW_KEY, MASTER_KEY);
|
||||
expect(back).toMatchObject({ rotated: 7, skipped: 0 });
|
||||
expect(await oldStore.resolve(refs[3]!)).toBe("cookie-3");
|
||||
await expect(rotateMasterKey({ db: h.db, logger }, NEW_KEY, NEW_KEY)).rejects.toThrow(/相同/);
|
||||
await sql`delete from secret where id = ${bad}`.execute(h.db);
|
||||
});
|
||||
|
||||
it("命令行 --role rotate-master-key:读 SECRET_MASTER_KEY_NEW;缺少时退出码 2", async () => {
|
||||
const store = createSecretStore({ db: h.db, masterKey: MASTER_KEY });
|
||||
const ref = await store.create({ purpose: "downloader_password", plaintext: "qb-pass" });
|
||||
const main = fileURLToPath(new URL("../../src/main.ts", import.meta.url));
|
||||
const url = `${PG_URL}${PG_URL.includes("?") ? "&" : "?"}options=${encodeURIComponent(`-c search_path=${h.schema},public`)}`;
|
||||
const run = (extra: Record<string, string>) =>
|
||||
spawnSync("deno", ["run", "-A", main, "--role", "rotate-master-key"], {
|
||||
env: {
|
||||
PATH: process.env.PATH ?? "",
|
||||
HOME: process.env.HOME ?? "",
|
||||
DATABASE_URL: url,
|
||||
SECRET_MASTER_KEY: MASTER_KEY,
|
||||
LOG_LEVEL: "info",
|
||||
DATA_DIR: "/tmp",
|
||||
...extra,
|
||||
},
|
||||
encoding: "utf8",
|
||||
timeout: 120_000,
|
||||
});
|
||||
expect(run({}).status).toBe(2);
|
||||
const ok = run({ SECRET_MASTER_KEY_NEW: NEW_KEY });
|
||||
expect(ok.status, ok.stderr + ok.stdout).toBe(0);
|
||||
expect(ok.stdout).toContain("主密钥轮换完成");
|
||||
expect(await createSecretStore({ db: h.db, masterKey: NEW_KEY }).resolve(ref)).toBe("qb-pass");
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { createHash, generateKeyPairSync, type KeyObject, randomBytes, sign } from "node:crypto";
|
||||
|
||||
// 测试用软件 WebAuthn 认证器(ES256,attestation=none):生成 @simplewebauthn/browser 形状的
|
||||
// RegistrationResponseJSON / AuthenticationResponseJSON,让服务端走真实的 @simplewebauthn/server 校验。
|
||||
|
||||
type Cbor = number | string | Uint8Array | Map<Cbor, Cbor> | Record<string, unknown>;
|
||||
|
||||
function head(major: number, n: number): number[] {
|
||||
if (n < 24) return [(major << 5) | n];
|
||||
if (n < 0x100) return [(major << 5) | 24, n];
|
||||
if (n < 0x10000) return [(major << 5) | 25, n >> 8, n & 0xff];
|
||||
return [(major << 5) | 26, (n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
|
||||
}
|
||||
|
||||
/** 极简 CBOR 编码:整数、字节串、文本、map(够编 COSE 公钥与 attestationObject) */
|
||||
export function cbor(v: Cbor): Uint8Array {
|
||||
const out: number[] = [];
|
||||
const enc = (x: Cbor) => {
|
||||
if (typeof x === "number") out.push(...(x >= 0 ? head(0, x) : head(1, -1 - x)));
|
||||
else if (typeof x === "string") {
|
||||
const b = new TextEncoder().encode(x);
|
||||
out.push(...head(3, b.length), ...b);
|
||||
} else if (x instanceof Uint8Array) out.push(...head(2, x.length), ...x);
|
||||
else if (x instanceof Map) {
|
||||
out.push(...head(5, x.size));
|
||||
for (const [k, val] of x) {
|
||||
enc(k);
|
||||
enc(val);
|
||||
}
|
||||
} else {
|
||||
const entries = Object.entries(x);
|
||||
out.push(...head(5, entries.length));
|
||||
for (const [k, val] of entries) {
|
||||
enc(k);
|
||||
enc(val as Cbor);
|
||||
}
|
||||
}
|
||||
};
|
||||
enc(v);
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
const b64u = (b: Uint8Array | Buffer) => Buffer.from(b).toString("base64url");
|
||||
const sha256 = (b: Uint8Array | string) => createHash("sha256").update(b).digest();
|
||||
const u32 = (n: number) => {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32BE(n);
|
||||
return b;
|
||||
};
|
||||
|
||||
export class SoftAuthenticator {
|
||||
readonly credentialId = randomBytes(16);
|
||||
private readonly privateKey: KeyObject;
|
||||
private readonly cose: Uint8Array;
|
||||
counter = 0;
|
||||
|
||||
constructor(
|
||||
readonly rpId = "localhost",
|
||||
readonly origin = "http://localhost:5197",
|
||||
) {
|
||||
const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
||||
this.privateKey = privateKey;
|
||||
const jwk = publicKey.export({ format: "jwk" }) as { x: string; y: string };
|
||||
this.cose = cbor(
|
||||
new Map<Cbor, Cbor>([
|
||||
[1, 2],
|
||||
[3, -7],
|
||||
[-1, 1],
|
||||
[-2, Buffer.from(jwk.x, "base64url")],
|
||||
[-3, Buffer.from(jwk.y, "base64url")],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
get id() {
|
||||
return b64u(this.credentialId);
|
||||
}
|
||||
|
||||
private clientData(type: string, challenge: string, origin: string) {
|
||||
return Buffer.from(JSON.stringify({ type, challenge, origin, crossOrigin: false }));
|
||||
}
|
||||
|
||||
/** navigator.credentials.create 的等价物 */
|
||||
register(options: { challenge: string }, over: { origin?: string } = {}) {
|
||||
const clientDataJSON = this.clientData("webauthn.create", options.challenge, over.origin ?? this.origin);
|
||||
const idLen = Buffer.alloc(2);
|
||||
idLen.writeUInt16BE(this.credentialId.length);
|
||||
const authData = Buffer.concat([
|
||||
sha256(this.rpId),
|
||||
Buffer.from([0x45]), // UP | UV | AT
|
||||
u32(this.counter),
|
||||
Buffer.alloc(16), // aaguid
|
||||
idLen,
|
||||
this.credentialId,
|
||||
this.cose,
|
||||
]);
|
||||
const attestationObject = cbor({ fmt: "none", attStmt: {}, authData: new Uint8Array(authData) });
|
||||
return {
|
||||
id: this.id,
|
||||
rawId: this.id,
|
||||
type: "public-key",
|
||||
response: {
|
||||
clientDataJSON: b64u(clientDataJSON),
|
||||
attestationObject: b64u(attestationObject),
|
||||
transports: ["internal"],
|
||||
},
|
||||
clientExtensionResults: {},
|
||||
authenticatorAttachment: "platform",
|
||||
};
|
||||
}
|
||||
|
||||
/** navigator.credentials.get 的等价物;counter 自增(可传 counter 模拟克隆 / 回退) */
|
||||
assert(options: { challenge: string }, over: { origin?: string; counter?: number } = {}) {
|
||||
this.counter = over.counter ?? this.counter + 1;
|
||||
const clientDataJSON = this.clientData("webauthn.get", options.challenge, over.origin ?? this.origin);
|
||||
const authData = Buffer.concat([sha256(this.rpId), Buffer.from([0x05]), u32(this.counter)]);
|
||||
const signature = sign("sha256", Buffer.concat([authData, sha256(clientDataJSON)]), this.privateKey);
|
||||
return {
|
||||
id: this.id,
|
||||
rawId: this.id,
|
||||
type: "public-key",
|
||||
response: {
|
||||
clientDataJSON: b64u(clientDataJSON),
|
||||
authenticatorData: b64u(authData),
|
||||
signature: b64u(signature),
|
||||
},
|
||||
clientExtensionResults: {},
|
||||
authenticatorAttachment: "platform",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@earendil-works/pi-ai": "npm:@earendil-works/[email protected]",
|
||||
"@earendil-works/pi-ai/": "npm:/@earendil-works/[email protected]/",
|
||||
"@hono/swagger-ui": "npm:@hono/[email protected]",
|
||||
"@simplewebauthn/server": "npm:@simplewebauthn/[email protected]",
|
||||
"@hono/zod-openapi": "npm:@hono/[email protected]",
|
||||
"@types/node": "npm:@types/[email protected]",
|
||||
"@types/nunjucks": "npm:@types/[email protected]",
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"npm:@react-router/dev@*": "[email protected][email protected][email protected]_@[email protected][email protected][email protected][email protected]",
|
||||
"npm:@react-router/[email protected]": "[email protected][email protected][email protected]_@[email protected][email protected][email protected][email protected]",
|
||||
"npm:@react-router/[email protected]": "[email protected][email protected][email protected][email protected]",
|
||||
"npm:@simplewebauthn/browser@^13.3.0": "13.3.0",
|
||||
"npm:@simplewebauthn/[email protected]": "13.3.3",
|
||||
"npm:@tailwindcss/vite@^4.1.13": "[email protected]_@[email protected][email protected]",
|
||||
"npm:@tanstack/react-query@^5.90.21": "[email protected]",
|
||||
"npm:@types/[email protected]": "24.19.0",
|
||||
@@ -41,6 +43,7 @@
|
||||
"npm:[email protected]": "10.3.1",
|
||||
"npm:[email protected]": "1.63.0",
|
||||
"npm:[email protected]": "3.4.9",
|
||||
"npm:qr-code-styling@^1.9.2": "1.9.2",
|
||||
"npm:radix-ui@^1.4.3": "1.6.7_@[email protected]_@[email protected][email protected][email protected]",
|
||||
"npm:react-day-picker@^10.0.1": "10.0.1_@[email protected][email protected]",
|
||||
"npm:react-dom@^19.2.4": "[email protected]",
|
||||
@@ -119,7 +122,7 @@
|
||||
"@smithy/fetch-http-handler",
|
||||
"@smithy/node-http-handler",
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@aws-sdk/[email protected]": {
|
||||
@@ -132,7 +135,7 @@
|
||||
"@smithy/signature-v4",
|
||||
"@smithy/types",
|
||||
"bowser",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@aws-sdk/[email protected]": {
|
||||
@@ -142,7 +145,7 @@
|
||||
"@aws-sdk/types",
|
||||
"@smithy/core",
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@aws-sdk/[email protected]": {
|
||||
@@ -154,7 +157,7 @@
|
||||
"@smithy/fetch-http-handler",
|
||||
"@smithy/node-http-handler",
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@aws-sdk/[email protected]": {
|
||||
@@ -172,7 +175,7 @@
|
||||
"@smithy/core",
|
||||
"@smithy/credential-provider-imds",
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@aws-sdk/[email protected]": {
|
||||
@@ -183,7 +186,7 @@
|
||||
"@aws-sdk/types",
|
||||
"@smithy/core",
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@aws-sdk/[email protected]": {
|
||||
@@ -199,7 +202,7 @@
|
||||
"@smithy/core",
|
||||
"@smithy/credential-provider-imds",
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@aws-sdk/[email protected]": {
|
||||
@@ -209,7 +212,7 @@
|
||||
"@aws-sdk/types",
|
||||
"@smithy/core",
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@aws-sdk/[email protected]": {
|
||||
@@ -221,7 +224,7 @@
|
||||
"@aws-sdk/types",
|
||||
"@smithy/core",
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@aws-sdk/[email protected]": {
|
||||
@@ -232,7 +235,7 @@
|
||||
"@aws-sdk/types",
|
||||
"@smithy/core",
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@aws-sdk/[email protected]": {
|
||||
@@ -241,7 +244,7 @@
|
||||
"@aws-sdk/types",
|
||||
"@smithy/core",
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@aws-sdk/[email protected]": {
|
||||
@@ -250,7 +253,7 @@
|
||||
"@aws-sdk/types",
|
||||
"@smithy/core",
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@aws-sdk/[email protected]": {
|
||||
@@ -262,7 +265,7 @@
|
||||
"@smithy/fetch-http-handler",
|
||||
"@smithy/signature-v4",
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@aws-sdk/[email protected]": {
|
||||
@@ -275,7 +278,7 @@
|
||||
"@smithy/fetch-http-handler",
|
||||
"@smithy/node-http-handler",
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@aws-sdk/[email protected]": {
|
||||
@@ -284,7 +287,7 @@
|
||||
"@aws-sdk/types",
|
||||
"@smithy/signature-v4",
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@aws-sdk/[email protected]": {
|
||||
@@ -295,7 +298,7 @@
|
||||
"@aws-sdk/types",
|
||||
"@smithy/core",
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@aws-sdk/[email protected]": {
|
||||
@@ -306,21 +309,21 @@
|
||||
"@aws-sdk/types",
|
||||
"@smithy/core",
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@aws-sdk/[email protected]": {
|
||||
"integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==",
|
||||
"dependencies": [
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@aws-sdk/[email protected]": {
|
||||
"integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==",
|
||||
"dependencies": [
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@aws/[email protected]": {
|
||||
@@ -833,6 +836,9 @@
|
||||
],
|
||||
"scripts": true
|
||||
},
|
||||
"@hexagon/[email protected]": {
|
||||
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="
|
||||
},
|
||||
"@hono/[email protected][email protected]": {
|
||||
"integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==",
|
||||
"dependencies": [
|
||||
@@ -931,6 +937,9 @@
|
||||
"@jridgewell/sourcemap-codec"
|
||||
]
|
||||
},
|
||||
"@levischuck/[email protected]": {
|
||||
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="
|
||||
},
|
||||
"@mjackson/[email protected]": {
|
||||
"integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng=="
|
||||
},
|
||||
@@ -1008,6 +1017,132 @@
|
||||
"@open-draft/[email protected]": {
|
||||
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="
|
||||
},
|
||||
"@peculiar/[email protected]": {
|
||||
"integrity": "sha512-KnNE4l033gbJ3ZzfYk7fJpSZwhd5xT+dPAnrB8bcwqylHiLN2zjZ0OyFao0LDIOI/ZdXRabV0n3Ww/KvpgZq6w==",
|
||||
"dependencies": [
|
||||
"@peculiar/asn1-schema",
|
||||
"asn1js",
|
||||
"[email protected]"
|
||||
]
|
||||
},
|
||||
"@peculiar/[email protected]": {
|
||||
"integrity": "sha512-4Ivz0fsyFofe0bslNsYcGN9+iETdHX4v+BFwIPr+K/BAt61sHXECP63ff9FntU9zcfVQELJFdUG9so44OjUlBQ==",
|
||||
"dependencies": [
|
||||
"@peculiar/asn1-schema",
|
||||
"@peculiar/asn1-x509",
|
||||
"@peculiar/asn1-x509-attr",
|
||||
"asn1js",
|
||||
"[email protected]"
|
||||
]
|
||||
},
|
||||
"@peculiar/[email protected]": {
|
||||
"integrity": "sha512-9dO6PcoJeI+1xJ4rG7eINMa6f9Ix/QdRs5UGZ0D7v/R3TzmsbVr5cqAgfYw4xgS9U54ouHYqEz0bIZBbM/UY1g==",
|
||||
"dependencies": [
|
||||
"@peculiar/asn1-schema",
|
||||
"@peculiar/asn1-x509",
|
||||
"asn1js",
|
||||
"[email protected]"
|
||||
]
|
||||
},
|
||||
"@peculiar/[email protected]": {
|
||||
"integrity": "sha512-OD+HLlcV7wCL64w0rheX/C0J31DbJce0nf8ulv3UNftQ617JEZE3sQ4cMN6PotXWlJ1M5GQx90G3bywtdKOstw==",
|
||||
"dependencies": [
|
||||
"@peculiar/asn1-schema",
|
||||
"@peculiar/asn1-x509",
|
||||
"asn1js",
|
||||
"[email protected]"
|
||||
]
|
||||
},
|
||||
"@peculiar/[email protected]": {
|
||||
"integrity": "sha512-btcH7lbcD4410qnjHvv7F0kEuMNKbX8CFAZSs9CDPWHKzT1byLPHYeR40AnUw9z4CzsijMY0daBFYUvVx75sdA==",
|
||||
"dependencies": [
|
||||
"@peculiar/asn1-cms",
|
||||
"@peculiar/asn1-pkcs8",
|
||||
"@peculiar/asn1-rsa",
|
||||
"@peculiar/asn1-schema",
|
||||
"asn1js",
|
||||
"[email protected]"
|
||||
]
|
||||
},
|
||||
"@peculiar/[email protected]": {
|
||||
"integrity": "sha512-c7UghAGQdBfAHL9JdwcJkAc/lDJKNXfCdYXPXlf8BZdD/fVJjJnXKHxX4gyLhkFTvnGhME7caieSgc2ROtqYTQ==",
|
||||
"dependencies": [
|
||||
"@peculiar/asn1-schema",
|
||||
"@peculiar/asn1-x509",
|
||||
"asn1js",
|
||||
"[email protected]"
|
||||
]
|
||||
},
|
||||
"@peculiar/[email protected]": {
|
||||
"integrity": "sha512-4R/zMgLSHbQIZRBwP1KzX6iuNHmW3xFWwu9TS9A00N60fmQqp9DkkqnQFyd0mAYMQTrQefuzQ/yP4U8sOLGUgQ==",
|
||||
"dependencies": [
|
||||
"@peculiar/asn1-cms",
|
||||
"@peculiar/asn1-pfx",
|
||||
"@peculiar/asn1-pkcs8",
|
||||
"@peculiar/asn1-schema",
|
||||
"@peculiar/asn1-x509",
|
||||
"@peculiar/asn1-x509-attr",
|
||||
"asn1js",
|
||||
"[email protected]"
|
||||
]
|
||||
},
|
||||
"@peculiar/[email protected]": {
|
||||
"integrity": "sha512-kHwBLVMkm9ljlM521ovS7y4z4mr8xuHTdNyyoPlDvckBwFKSgs9HqKBryLBeqHqL2YpIgl+Vspl7kzNGG2phzA==",
|
||||
"dependencies": [
|
||||
"@peculiar/asn1-schema",
|
||||
"@peculiar/asn1-x509",
|
||||
"asn1js",
|
||||
"[email protected]"
|
||||
]
|
||||
},
|
||||
"@peculiar/[email protected]": {
|
||||
"integrity": "sha512-Ez3wLKVjaxdsLcgeWN4OE31QkM7oBOgKuuBJxldRYAkfYw2C+8zJPcSd/SThnbhszsEOlAmoaS5kIIkN29fGKQ==",
|
||||
"dependencies": [
|
||||
"@peculiar/utils",
|
||||
"asn1js",
|
||||
"[email protected]"
|
||||
]
|
||||
},
|
||||
"@peculiar/[email protected]": {
|
||||
"integrity": "sha512-PlbL1a74RZiry7puPCZ5hdk/puvivxYJPbjadr2i7DBjHCJFEKCfTRPKL3DILeZFtzJjn95J5EBtSc4/TB9/6Q==",
|
||||
"dependencies": [
|
||||
"@peculiar/asn1-schema",
|
||||
"@peculiar/asn1-x509",
|
||||
"asn1js",
|
||||
"[email protected]"
|
||||
]
|
||||
},
|
||||
"@peculiar/[email protected]": {
|
||||
"integrity": "sha512-NP3ecvN6zTDlwCvogIWL7MJN9AZNuskkIbnlhrlkB97Mn2EqirBUQ8GKzsOmdAL3pRvjGu1Uk/a4DNfiZrWxhw==",
|
||||
"dependencies": [
|
||||
"@peculiar/asn1-schema",
|
||||
"@peculiar/utils",
|
||||
"asn1js",
|
||||
"[email protected]"
|
||||
]
|
||||
},
|
||||
"@peculiar/[email protected]": {
|
||||
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
|
||||
"dependencies": [
|
||||
"[email protected]"
|
||||
]
|
||||
},
|
||||
"@peculiar/[email protected]": {
|
||||
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
|
||||
"dependencies": [
|
||||
"@peculiar/asn1-cms",
|
||||
"@peculiar/asn1-csr",
|
||||
"@peculiar/asn1-ecc",
|
||||
"@peculiar/asn1-pkcs9",
|
||||
"@peculiar/asn1-rsa",
|
||||
"@peculiar/asn1-schema",
|
||||
"@peculiar/asn1-x509",
|
||||
"pvtsutils",
|
||||
"reflect-metadata",
|
||||
"[email protected]",
|
||||
"tsyringe"
|
||||
]
|
||||
},
|
||||
"@pinojs/[email protected]": {
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="
|
||||
},
|
||||
@@ -2260,6 +2395,22 @@
|
||||
"@sec-ant/[email protected]": {
|
||||
"integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="
|
||||
},
|
||||
"@simplewebauthn/[email protected]": {
|
||||
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="
|
||||
},
|
||||
"@simplewebauthn/[email protected]": {
|
||||
"integrity": "sha512-LelX/lcy5cjc15A86i/aNxHhB5eU7dd20QsbP0VLAf9e38+SLlsnqCCyecx3xqfGofhmX05h1J9fKRYWxw+luA==",
|
||||
"dependencies": [
|
||||
"@hexagon/base64",
|
||||
"@levischuck/tiny-cbor",
|
||||
"@peculiar/asn1-android",
|
||||
"@peculiar/asn1-ecc",
|
||||
"@peculiar/asn1-rsa",
|
||||
"@peculiar/asn1-schema",
|
||||
"@peculiar/asn1-x509",
|
||||
"@peculiar/x509"
|
||||
]
|
||||
},
|
||||
"@sindresorhus/[email protected]": {
|
||||
"integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="
|
||||
},
|
||||
@@ -2267,7 +2418,7 @@
|
||||
"integrity": "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==",
|
||||
"dependencies": [
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@smithy/[email protected]": {
|
||||
@@ -2275,7 +2426,7 @@
|
||||
"dependencies": [
|
||||
"@smithy/core",
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@smithy/[email protected]": {
|
||||
@@ -2283,7 +2434,7 @@
|
||||
"dependencies": [
|
||||
"@smithy/core",
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@smithy/[email protected]": {
|
||||
@@ -2291,7 +2442,7 @@
|
||||
"dependencies": [
|
||||
"@smithy/core",
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@smithy/[email protected]": {
|
||||
@@ -2299,13 +2450,13 @@
|
||||
"dependencies": [
|
||||
"@smithy/core",
|
||||
"@smithy/types",
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@smithy/[email protected]": {
|
||||
"integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==",
|
||||
"dependencies": [
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"@stablelib/[email protected]": {
|
||||
@@ -2720,19 +2871,27 @@
|
||||
"[email protected]": {
|
||||
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||
"dependencies": [
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"[email protected]": {
|
||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="
|
||||
},
|
||||
"[email protected]": {
|
||||
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
|
||||
"dependencies": [
|
||||
"pvtsutils",
|
||||
"pvutils",
|
||||
"[email protected]"
|
||||
]
|
||||
},
|
||||
"[email protected]": {
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="
|
||||
},
|
||||
"[email protected]": {
|
||||
"integrity": "sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==",
|
||||
"dependencies": [
|
||||
"tslib"
|
||||
"tslib@2.8.1"
|
||||
]
|
||||
},
|
||||
"[email protected]": {
|
||||
@@ -4618,6 +4777,24 @@
|
||||
"once"
|
||||
]
|
||||
},
|
||||
"[email protected]": {
|
||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||
"dependencies": [
|
||||
"[email protected]"
|
||||
]
|
||||
},
|
||||
"[email protected]": {
|
||||
"integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="
|
||||
},
|
||||
"[email protected]": {
|
||||
"integrity": "sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==",
|
||||
"dependencies": [
|
||||
"qrcode-generator"
|
||||
]
|
||||
},
|
||||
"[email protected]": {
|
||||
"integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw=="
|
||||
},
|
||||
"[email protected]": {
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"[email protected][email protected][email protected][email protected]_@[email protected][email protected]": {
|
||||
@@ -4851,6 +5028,9 @@
|
||||
"[email protected]": {
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
|
||||
},
|
||||
"[email protected]": {
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
|
||||
},
|
||||
"[email protected]": {
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="
|
||||
},
|
||||
@@ -5278,9 +5458,18 @@
|
||||
"strip-bom"
|
||||
]
|
||||
},
|
||||
"[email protected]": {
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
|
||||
},
|
||||
"[email protected]": {
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
},
|
||||
"[email protected]": {
|
||||
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
||||
"dependencies": [
|
||||
"[email protected]"
|
||||
]
|
||||
},
|
||||
"[email protected]": {
|
||||
"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/[email protected]",
|
||||
"npm:@hono/[email protected]",
|
||||
"npm:@hono/[email protected]",
|
||||
"npm:@simplewebauthn/[email protected]",
|
||||
"npm:@types/[email protected]",
|
||||
"npm:@types/[email protected]",
|
||||
"npm:@types/[email protected]",
|
||||
@@ -5638,6 +5828,7 @@
|
||||
"npm:@fontsource-variable/space-grotesk@^5.2.10",
|
||||
"npm:@react-router/[email protected]",
|
||||
"npm:@react-router/[email protected]",
|
||||
"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:[email protected]",
|
||||
"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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+35
-6
@@ -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 <key>` → 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`
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
|
||||
// ── 事件 ──
|
||||
|
||||
@@ -43,12 +43,24 @@ export type AdminSetupRequest = z.infer<typeof AdminSetupRequest>;
|
||||
/** 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<typeof LoginRequest>;
|
||||
|
||||
@@ -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<typeof SessionInfo>;
|
||||
|
||||
@@ -83,16 +97,40 @@ export const PasswordChangeResult = z.object({ revokedSessions: z.number().int()
|
||||
export type PasswordChangeResult = z.infer<typeof PasswordChangeResult>;
|
||||
|
||||
/** GET /auth/totp */
|
||||
export const TotpStatus = z.object({ enabled: z.boolean(), pending: z.boolean() });
|
||||
export const TotpStatus = z.object({
|
||||
enabled: z.boolean(),
|
||||
pending: z.boolean(),
|
||||
/** 未用过的恢复码个数(未开启时为 0) */
|
||||
recoveryCodesRemaining: z.number().int().nonnegative(),
|
||||
});
|
||||
export type TotpStatus = z.infer<typeof TotpStatus>;
|
||||
|
||||
/**
|
||||
* 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<typeof TotpSetup>;
|
||||
|
||||
/**
|
||||
* 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<typeof TotpAction>;
|
||||
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<typeof TotpActionResult>;
|
||||
|
||||
// ── Passkey(WebAuthn,替代密码的第一因子;登录成功直接建会话,不再要求 TOTP) ──
|
||||
|
||||
export const PasskeyName = z.string().trim().min(1, "输入名称").max(60);
|
||||
export const PasskeyInfo = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
/** singleDevice / multiDevice(可同步) */
|
||||
deviceType: z.string().nullable(),
|
||||
backedUp: z.boolean(),
|
||||
createdAt: Timestamp,
|
||||
lastUsedAt: Timestamp.nullable(),
|
||||
});
|
||||
export type PasskeyInfo = z.infer<typeof PasskeyInfo>;
|
||||
export const PasskeyList = z.object({ items: z.array(PasskeyInfo) });
|
||||
export type PasskeyList = z.infer<typeof PasskeyList>;
|
||||
|
||||
/**
|
||||
* 注册 / 登录挑战:options 原样交给 @simplewebauthn/browser(startRegistration / startAuthentication),
|
||||
* challengeId 随校验请求带回(挑战存库、一次性、5 分钟有效)。
|
||||
*/
|
||||
export const PasskeyChallenge = z.object({
|
||||
challengeId: z.string(),
|
||||
/** WebAuthn 选项 JSON 由 @simplewebauthn 定义,合同里不重复建模 */
|
||||
options: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
export type PasskeyChallenge = z.infer<typeof PasskeyChallenge>;
|
||||
|
||||
const WebAuthnResponse = z.record(z.string(), z.unknown());
|
||||
export const PasskeyRegisterRequest = z.object({
|
||||
challengeId: z.string().uuid(),
|
||||
name: PasskeyName,
|
||||
/** startRegistration 的返回值(RegistrationResponseJSON) */
|
||||
response: WebAuthnResponse,
|
||||
});
|
||||
export type PasskeyRegisterRequest = z.infer<typeof PasskeyRegisterRequest>;
|
||||
export const PasskeyLoginRequest = z.object({
|
||||
challengeId: z.string().uuid(),
|
||||
/** startAuthentication 的返回值(AuthenticationResponseJSON) */
|
||||
response: WebAuthnResponse,
|
||||
});
|
||||
export type PasskeyLoginRequest = z.infer<typeof PasskeyLoginRequest>;
|
||||
export const PasskeyRenameRequest = z.object({ name: PasskeyName });
|
||||
export type PasskeyRenameRequest = z.infer<typeof PasskeyRenameRequest>;
|
||||
|
||||
/**
|
||||
* 审计动作枚举(审计写失败不影响主流程;被限速拦下的登录尝试不写审计)。
|
||||
* 新增动作只在这里加。
|
||||
@@ -114,6 +198,12 @@ export const AuditAction = z.enum([
|
||||
"admin.password_change",
|
||||
"admin.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",
|
||||
|
||||
Reference in new issue
Block a user