feat(auth): TOTP setup endpoint, recovery codes, passkeys and master key rotation

- GET /auth/totp/setup returns the pending secret and otpauth URL (created
  on first call, reused on refresh) for the console to render as a QR code.
- Enabling TOTP issues 8 one-time recovery codes (argon2id hashes in
  admin_recovery_code); login accepts recoveryCode in place of totpCode,
  disable accepts either, and recovery_codes regenerates with the password.
- Passkeys via @simplewebauthn/server: register/list/rename/delete under
  the console session, public login options/verify that reuse the login
  rate limit and session cookies. Challenges are one-time rows in
  webauthn_challenge; RP ID and origins come from WEBAUTHN_RP_ID /
  WEBAUTHN_ORIGINS, falling back to the request host. Signature counter
  regressions are rejected. SessionInfo gains passkeyEnabled.
- --role rotate-master-key re-encrypts the secret table from
  SECRET_MASTER_KEY to SECRET_MASTER_KEY_NEW one row per transaction,
  skips rows already on the new key (resumable, swap keys to roll back),
  logs progress and audits secret.master_key_rotate. Secrets now record
  the master key fingerprint in kdf_params.kid.
- Runbook: recovery codes, passkey configuration, rotation steps and
  rollback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:22:07 +08:00
1 parent 5c6f483420
commit b0e5d539ed
21 files changed
+1616 -80

No files matched your search

+11
View File
@@ -10,6 +10,17 @@ export const ConfigSchema = z.object({
HOST: z.string().min(1).default("0.0.0.0"),
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(),