diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore index 2c9c938..d20c0fe 100644 --- a/.codegraph/.gitignore +++ b/.codegraph/.gitignore @@ -1,17 +1,5 @@ -# CodeGraph data files -# These are local to each machine and should not be committed - -# Database -*.db -*.db-wal -*.db-shm - -# Cache -cache/ - -# Logs -*.log - -# Hook markers -.dirty -*.pid +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/.gitignore b/.gitignore index b83ab26..71246f7 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ build/ .idea/ *.swp *.swo +docs/ # OS .DS_Store diff --git a/docs/passkey-login-research.md b/docs/passkey-login-research.md deleted file mode 100644 index 15780a7..0000000 --- a/docs/passkey-login-research.md +++ /dev/null @@ -1,785 +0,0 @@ -# NodeWarden Passkey 登录研究记录 - -记录日期:2026-06-09 -研究范围:NodeWarden 自己的 server、web 登录/注册链路,以及官方 Bitwarden server、web、browser extension 对账户 passkey 登录的实现方式。 - -## 结论先放前面 - -NodeWarden 现在已经有完整的主密码注册、主密码登录、刷新 token、2FA、设备记录、官方客户端兼容的 `UserDecryptionOptions`,也支持 vault item 里的 `login.fido2Credentials` 字段。但它还没有“账户 passkey 登录”。现有 `src/utils/passkey.ts` 只有 base64url、challenge、clientData 解析这类工具函数,不能完成 FIDO2/WebAuthn 服务端注册和认证验证。 - -要支持“自己的 web 用 passkey 登录”和“官方/自定义浏览器扩展也能 passkey 登录”,不能只加一个登录按钮。必须补齐四块: - -1. Server 端新增账户 WebAuthn credential 表、challenge/token 防重放机制、FIDO2 attestation/assertion 验证、`grant_type=webauthn`。 -2. Server 响应里按 Bitwarden 形状返回 PRF 解密材料:登录 token 响应用单个 `UserDecryptionOptions.WebAuthnPrfOption`,sync 响应用多个 `UserDecryption.WebAuthnPrfOptions`。 -3. NodeWarden web 新增 passkey 注册、管理、登录和 PRF 解锁 vault key 的客户端流程。 -4. 扩展兼容要跟官方 Bitwarden endpoint 和 response shape 对齐。官方 browser extension 当前只在 Chromium 系浏览器开放 passkey 登录,因为 Firefox/Safari 扩展环境还不能按官方代码需要的方式覆盖 RP ID。 - -下面按代码链路展开。 - -## 术语边界 - -这里有三个容易混淆的东西,文档后面严格区分: - -- 账户 passkey 登录:用户不用主密码,使用 WebAuthn/passkey 完成账号认证,并且用 PRF 解开 vault user key。官方 Bitwarden 叫 `WebAuthnLogin`。 -- Vault item 里的 passkey:某个登录条目保存网站 passkey/FIDO2 credential 数据,对应 NodeWarden 的 `cipher.login.fido2Credentials`。这是“保险库保存别的网站 passkey”,不是“登录 NodeWarden 账号”。 -- WebAuthn 2FA:主密码登录之后用安全密钥做第二因素。官方旧 web repo 里主要是这一类,不等于 passkey 登录。 - -## NodeWarden 现状 - -### 路由和入口 - -NodeWarden 后端是 Cloudflare Workers + D1。主入口 `src/index.ts` 初始化存储后进入 router。认证边界在: - -- `src/router-public.ts`:公开接口,包含 `/identity/connect/token`、`/identity/accounts/prelogin`、`/api/accounts/register`。 -- `src/router-authenticated.ts`:需要 access token 的接口,包含 profile、change password、TOTP、sync、vault、devices。 -- `src/handlers/identity.ts`:OAuth/token 兼容入口。 -- `src/handlers/accounts.ts`:注册、profile、密码变更、TOTP、API key 等账户接口。 - -目前公开路由没有: - -- `GET /identity/accounts/webauthn/assertion-options` -- `POST /identity/connect/token` 的 `grant_type=webauthn` -- `POST /api/webauthn/attestation-options` -- `POST /api/webauthn/assertion-options` -- `GET/POST/PUT /api/webauthn` - -### 注册链路 - -NodeWarden 自己 web 的注册入口在 `webapp/src/lib/api/auth.ts` 的 `registerAccount()`: - -- 使用邮箱作为 salt,用 PBKDF2 派生 master key。 -- 再用 PBKDF2(masterKey, password, 1) 得到 client master password hash。 -- 随机生成 64 字节 vault symmetric key。 -- 用 masterKey 经 HKDF 拆成 enc/mac,把 vault key 加密成 Bitwarden `Key`。 -- 生成 RSA-OAEP key pair,把 private key 用 vault symmetric key 加密。 -- POST `/api/accounts/register`,提交 `email`、`name`、`masterPasswordHash`、`key`、KDF 参数、invite code、`keys.publicKey`、`keys.encryptedPrivateKey`。 - -后端 `src/handlers/accounts.ts` 的 `handleRegister()`: - -- 第一个用户自动成为 admin,后续用户需要 invite。 -- 校验 `JWT_SECRET`、邮箱、KDF 下限、加密字符串形状、公钥/私钥。 -- 不直接保存 client hash,而是 `AuthService.hashPasswordServer(masterPasswordHash, email)` 后保存到 `users.master_password_hash`。 -- 保存 `users.key`、`users.private_key`、`users.public_key`、KDF 参数、`security_stamp`。 - -结论:账户 passkey 注册不是替代账号注册,而是“用户已登录后在安全设置里新增一个可登录 credential”。仍然需要已有 vault user key 来生成 PRF keyset。 - -### 主密码登录链路 - -NodeWarden 自己 web 的登录入口是 `webapp/src/lib/app-auth.ts` 的 `performPasswordLogin()`: - -- 先 `deriveLoginHashLocally()` 得到 masterKey 和 client hash。 -- 调 `loginWithPassword()` POST `/identity/connect/token`。 -- token 成功后 `completeLogin()` 用 `token.Key` 和本地 masterKey 解开 vault key。 -- 保存离线解锁记录。 - -`webapp/src/lib/api/auth.ts` 也有 `deriveLoginHash()` 和 `getPreloginKdfConfig()` 会调用 `/identity/accounts/prelogin`,但当前 `performPasswordLogin()` 走的是本地 fallback iterations。passkey 登录不应复用这条 masterKey 路径,因为 passkey 登录没有主密码,拿不到 password-derived masterKey。 - -后端 `src/handlers/identity.ts` 的 `handleToken()` 当前支持: - -- `grant_type=password` -- `grant_type=client_credentials` -- `grant_type=refresh_token` - -密码登录成功后会: - -- 验证 IP 登录频率和用户状态。 -- `AuthService.verifyPassword()` 验证 client hash。 -- 处理 TOTP 或 remember 2FA token。 -- 记录/更新 device。 -- 生成 access token 和 refresh token。 -- 返回 `Key`、`PrivateKey`、`AccountKeys`、KDF 参数、`UserDecryptionOptions`。 - -### UserDecryptionOptions 和 sync - -NodeWarden 的 `src/utils/user-decryption.ts` 当前只构造主密码解锁: - -- `HasMasterPassword: true` -- `MasterPasswordUnlock` -- `TrustedDeviceOption: null` -- `KeyConnectorOption: null` - -`src/types/index.ts` 的 sync 类型里预留了 `UserDecryption.WebAuthnPrfOption?: null`,但当前 `src/handlers/sync.ts` 实际只返回 `MasterPasswordUnlock`,没有账户 passkey PRF 解密选项。 - -passkey 登录必须新增两类 shape: - -- 登录 token 响应:`UserDecryptionOptions.WebAuthnPrfOption`,只返回本次认证所用 credential 的 PRF 解密材料。 -- sync 响应:`UserDecryption.WebAuthnPrfOptions`,返回该用户所有已启用 PRF keyset 的 passkey 解密材料,供官方客户端锁定/解锁和 key rotation 使用。 - -### 现有 passkey 相关代码 - -NodeWarden 已支持 vault item 里的 FIDO2/passkey 字段: - -- `src/types/index.ts`:`CipherLogin.fido2Credentials` -- `src/handlers/ciphers.ts`:读写 cipher 时保留/规范化 `fido2Credentials` -- `webapp/src/lib/api/vault.ts`:加密/解密 vault item 内的 `fido2Credentials` -- `webapp/src/lib/types.ts`:`CipherLoginPasskey` - -这部分是“保存网站 passkey”,不是账户登录。 - -`src/utils/passkey.ts` 只有: - -- `bytesToBase64Url()` -- `base64UrlToBytes()` -- `randomChallenge()` -- `parseClientDataJSON()` - -缺少的核心能力: - -- attestation verification -- assertion verification -- authenticator public key 格式处理 -- signature verification -- sign counter 更新 -- userHandle 与 user id 绑定验证 -- origin/RP ID 验证 -- challenge 过期和防重放 - -### 数据库和备份影响 - -NodeWarden schema 在这些地方需要同步: - -- `migrations/0001_init.sql` -- `src/services/storage-schema.ts` -- `wrangler.toml` migrations -- `src/services/backup-archive.ts` -- `src/services/backup-import.ts` -- `shared/backup-schema` 相关类型 - -当前表里没有账户 passkey credential,也没有 WebAuthn challenge 表。`devices` 表保存设备 trust/key 信息,不适合混入 passkey credential,因为 WebAuthn credential 需要自己的 public key、credential id、counter、AAGUID、PRF keyset 等字段。 - -## 官方 Bitwarden server 参考 - -上游代码位置: - -- `.codex-upstream/bitwarden-server` -- 研究时 HEAD:`574f3fd` - -官方 server 里也有两个 WebAuthn 概念: - -- 传统 WebAuthn 2FA:`TwoFactorController`、`WebAuthnTokenProvider` -- 账户 passkey 登录:`WebAuthnLogin` - -本项目要参考的是后者。 - -### 公开 passkey 登录入口 - -`src/Identity/Controllers/AccountsController.cs` - -- `GET /accounts/webauthn/assertion-options` -- 返回 `WebAuthnLoginAssertionOptionsResponseModel` -- response 包含: - - `options` - - `token` -- token 使用 `WebAuthnLoginAssertionOptionsTokenable` -- scope 为 `Authentication` -- token 生命周期约 17 分钟 - -`src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs` - -- 新增 OAuth extension grant:`grant_type=webauthn` -- 从 form 读取: - - `token` - - `deviceResponse` -- 解开 token,校验 scope 必须是 `Authentication` -- 反序列化 `AuthenticatorAssertionRawResponse` -- 调用 `AssertWebAuthnLoginCredential` -- 把成功认证的 credential 传给 `UserDecryptionOptionsBuilder.WithWebAuthnLoginCredential(credential)` -- 之后走通用登录成功逻辑,返回 access/refresh token 和账号加密状态。 - -`src/Identity/IdentityServer/ApiClient.cs` - -- official identity client 的 allowed grant types 包含 `WebAuthnGrantValidator.GrantType`。 - -`TwoFactorAuthenticationValidator` 里有一个重要行为:FIDO2 user verification 已经被视为第二因素,所以 passkey 登录成功后官方不会再要求额外 2FA。NodeWarden 之后需要明确策略:要兼容官方客户端,应把 passkey 登录视作已满足 2FA,否则官方 `LoginViaWebAuthnComponent` 会显示“不支持 passkey 2FA”的错误。 - -### 账户 passkey 管理接口 - -`src/Api/Auth/Controllers/WebAuthnController.cs` - -官方 authenticated API: - -- `GET /webauthn`:列出账户 passkey credentials。 -- `POST /webauthn/attestation-options`:主密码/secret verification 后生成 credential create options 和 token。 -- `POST /webauthn/assertion-options`:主密码/secret verification 后生成 assertion options 和 token,用于给已有 credential 启用/更新 PRF keyset。 -- `POST /webauthn`:保存新 credential。 -- `PUT /webauthn`:更新 credential 的 PRF encryption keyset。 -- `POST /webauthn/{id}/delete`:删除 credential。 - -官方创建 credential 时保存: - -- `name` -- `token` -- `deviceResponse` -- `supportsPrf` -- 可选 `encryptedUserKey` -- 可选 `encryptedPublicKey` -- 可选 `encryptedPrivateKey` - -官方最多允许 5 个账户 passkey credentials。 - -### 官方 WebAuthnCredential 表 - -`src/Core/Auth/Entities/WebAuthnCredential.cs` - -字段: - -- `Id` -- `UserId` -- `Name` -- `PublicKey` -- `CredentialId` -- `Counter` -- `Type` -- `AaGuid` -- `EncryptedUserKey` -- `EncryptedPrivateKey` -- `EncryptedPublicKey` -- `SupportsPrf` -- `CreationDate` -- `RevisionDate` - -SQLite migration:`util/SqliteMigrations/Migrations/20231213032045_WebAuthnLoginCredentials.cs` - -表名是 `WebAuthnCredential`,对 `User` 做 cascade delete,并按 `UserId` 建索引。 - -`GetPrfStatus()`: - -- `Unsupported`:`SupportsPrf` 为 false。 -- `Supported`:credential 支持 PRF,但还没有完整 encrypted keyset。 -- `Enabled`:`EncryptedUserKey`、`EncryptedPrivateKey`、`EncryptedPublicKey` 都存在。 - -### 官方创建和认证策略 - -`GetWebAuthnLoginCredentialCreateOptionsCommand.cs` - -- 使用 Fido2NetLib。 -- `user.id` 是用户 id bytes。 -- `user.name/displayName` 使用用户邮箱。 -- 排除当前用户已有 credential ids。 -- `residentKey: required` -- `userVerification: required` -- `attestation: none` - -`GetWebAuthnLoginCredentialAssertionOptionsCommand.cs` - -- `allowCredentials` 传空数组。 -- `userVerification: required` -- 空 allow list 代表使用 discoverable credentials,也就是 passkey 登录页可以不先输入邮箱。 - -`CreateWebAuthnLoginCredentialCommand.cs` - -- 限制每用户最多 5 个。 -- 检查 credential id 在该用户下不能重复。 -- FIDO `MakeNewCredentialAsync` 验证 attestation。 -- 保存 credential id/public key/counter/type/AAGUID/PRF keyset。 - -`AssertWebAuthnLoginCredentialCommand.cs` - -- 先用 challenge cache 防重放。 -- 从 assertion response 的 `userHandle` 解析出 user id。 -- 加载该用户所有 WebAuthn credentials。 -- 用 credential id 找到记录。 -- FIDO `MakeAssertionAsync` 验证签名、challenge、origin、RP ID、user verification。 -- 成功后更新 counter。 - -### 官方 PRF 解密协议 - -`src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs` - -`WebAuthnPrfDecryptionOption` 字段: - -- `EncryptedPrivateKey` -- `EncryptedUserKey` -- `CredentialId` -- `Transports` - -`src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs` - -- `WithWebAuthnLoginCredential()` 只在 credential 的 PRF status 是 `Enabled` 时加入 `WebAuthnPrfOption`。 -- 如果 credential 没有 PRF keyset,passkey 只能认证账号,不能解开 vault。 - -`src/Api/Vault/Models/Response/SyncResponseModel.cs` - -- sync response 会把所有 enabled PRF credentials 放进 `UserDecryption.WebAuthnPrfOptions`。 - -## 官方 Bitwarden web/browser client 参考 - -上游代码位置: - -- `.codex-upstream/bitwarden-clients` -- `.codex-upstream/bitwarden-browser` -- 两者研究时 HEAD 都是 `825f9be`,browser repo 内容和 clients monorepo 对应。 - -旧的 `.codex-upstream/bitwarden-web` 主要有 WebAuthn connector 和 2FA 设置页,没有现代账户 passkey 登录主流程。账户 passkey 登录应以 `bitwarden-clients` 为准。 - -### 登录按钮可见性 - -`libs/auth/src/angular/login/default-login-component.service.ts` - -- 默认只对 `ClientType.Web` 开启 passkey 登录。 - -`apps/browser/src/auth/popup/login/extension-login-component.service.ts` - -- browser extension 覆盖逻辑:只对 Chromium 开启。 -- 注释说明 Firefox 和 Safari 不能在扩展里覆盖 relying party ID。 -- 官方代码引用了 W3C webextensions issue 238、Mozilla bug 1956484、Apple forum thread 774351。 - -结论:NodeWarden 后端即使完全兼容官方 passkey API,官方扩展也只有 Chromium 系会显示 passkey 登录入口。 - -### Passkey 登录页 - -`libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts` - -流程: - -1. 进入 `/login-with-passkey` 后自动开始认证。 -2. 调 `webAuthnLoginService.getCredentialAssertionOptions()`。 -3. 调 `webAuthnLoginService.assertCredential(options)` 触发 `navigator.credentials.get()`。 -4. 调 `webAuthnLoginService.logIn(assertion)` 走 identity token grant。 -5. 如果 `authResult.requiresTwoFactor` 为 true,显示“客户端不支持 passkey 2FA”错误。 -6. 只有本地 `keyService.userKey$(authResult.userId)` 已经拿到 user key,才运行 login success handler。 -7. 成功路由: - - Web:`/vault` - - Browser:`/tabs/vault` - - Desktop:`/vault` - -Browser popout 下还会在成功后重新打开普通 popup 并关闭 popout。 - -### 客户端 passkey 登录请求 - -`libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts` - -- GET `${identityUrl}/accounts/webauthn/assertion-options` -- 如果 NodeWarden 的 identityUrl 是站点 origin + `/identity`,实际路径就是 `/identity/accounts/webauthn/assertion-options`。 - -`libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts` - -- `navigator.credentials.get({ publicKey: options })` -- 会主动加 PRF extension: - - salt 是 `SHA-256("passwordless-login")` - - extension shape 是 `extensions.prf.eval.first` -- 从 `credential.getClientExtensionResults().prf.results.first` 取 PRF 输出。 -- 用 `WebAuthnLoginPrfKeyService.createSymmetricKeyFromPrf()` 转成 PRF key。 -- 构造 `WebAuthnLoginAssertionResponseRequest`。 -- 明确检查 `deviceResponse.extensions` 里不能含 `prf`,避免把 PRF 输出泄漏给服务端。 - -`libs/common/src/auth/services/webauthn-login/webauthn-login-prf-key.service.ts` - -- salt 常量:`passwordless-login` -- 先 SHA-256。 -- 再用 HKDF expand 拆成 64 字节: - - `"enc"` 32 bytes - - `"mac"` 32 bytes - -`libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts` - -form encoded token 请求字段: - -- `grant_type=webauthn` -- `token=` -- `deviceResponse=` -- 还会带 common device request 字段。 - -`libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts` - -`deviceResponse` shape: - -- `id` -- `rawId` -- `type` -- `extensions: {}` -- `response.authenticatorData` -- `response.signature` -- `response.clientDataJSON` -- `response.userHandle` - -全部二进制字段使用 base64url。 - -### 客户端如何用 PRF 解 vault key - -`libs/auth/src/common/login-strategies/webauthn-login.strategy.ts` - -- `setMasterKey()` 是空实现,因为 passkey 登录没有主密码 masterKey。 -- `setUserKey()`: - - 如果 token response 有 `key`,保存为 master-key-encrypted user key,兼容主密码解锁。 - - 如果 `userDecryptionOptions.webAuthnPrfOption` 存在,且本地 assertion 得到了 `prfKey`: - 1. 用 PRF key unwrap `encryptedPrivateKey`。 - 2. 用 private key decapsulate `encryptedUserKey`。 - 3. 得到 user key,写入 `keyService`。 - -核心约束:服务端永远看不到 PRF 输出。服务端只保存和返回被 PRF 相关密钥加密后的 keyset。 - -### 官方 web 设置页注册 passkey - -`apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts` - -调用的 API: - -- `POST /webauthn/attestation-options` -- `POST /webauthn/assertion-options` -- `POST /webauthn` -- `GET /webauthn` -- `POST /webauthn/{id}/delete` -- `PUT /webauthn` - -`apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts` - -创建流程: - -1. 用户做 secret verification。 -2. 请求 attestation options。 -3. `navigator.credentials.create({ publicKey: options })`,并带 `extensions.prf = {}`。 -4. 从 client extension results 判断 `supportsPrf`。 -5. 如果要用于 vault encryption,再立即做一次 `navigator.credentials.get()`: - - `allowCredentials` 锁定刚创建的 credential。 - - 使用同一个 challenge、rpId、timeout、userVerification。 - - 带 PRF eval salt。 -6. 用 PRF key 和当前 user key 创建 rotateable keyset。 -7. 保存 credential,带上 `encryptedUserKey`、`encryptedPublicKey`、`encryptedPrivateKey`。 - -删除流程需要 secret verification。启用 encryption 的流程是对已有 credential 做 assertion,再创建并 PUT keyset。 - -`apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts` - -- `Enabled = 0` -- `Supported = 1` -- `Unsupported = 2` - -## NodeWarden 应实现的协议形状 - -### 公开登录流程 - -目标兼容官方客户端和 NodeWarden 自己 web: - -1. `GET /identity/accounts/webauthn/assertion-options` - - 生成 discoverable credential assertion options。 - - `allowCredentials: []` - - `userVerification: "required"` - - 返回 `{ options, token }`。 - - token 绑定 challenge、scope=`Authentication`、RP ID、origin/audience、过期时间。 - -2. Browser/web 调 `navigator.credentials.get()`。 - - NodeWarden 自己 web 也要使用 PRF extension。 - - PRF salt 必须和官方一致:`SHA-256("passwordless-login")`。 - -3. `POST /identity/connect/token` - - 支持 `grant_type=webauthn`。 - - 接收 `token`、`deviceResponse`、device fields。 - - 解 token,校验 challenge/scope/过期。 - - 验证 assertion。 - - 从 `userHandle` 找到 user id。 - - 从 credential id 找到 passkey record。 - - 更新 counter。 - - 记录/更新 device。 - - 返回 access/refresh token、`AccountKeys`、`UserDecryptionOptions.WebAuthnPrfOption`。 - -如果用户启用了 TOTP,建议为了官方兼容先遵循 Bitwarden:passkey 的 user verification 视作已满足第二因素。否则官方 passkey 登录页会进入 unsupported 2FA 错误状态。 - -### 账户 passkey 管理流程 - -建议对齐官方 API,同时在 NodeWarden 内部可挂到 `/api/webauthn`: - -- `GET /api/webauthn` -- `POST /api/webauthn/attestation-options` -- `POST /api/webauthn/assertion-options` -- `POST /api/webauthn` -- `PUT /api/webauthn` -- `POST /api/webauthn/:id/delete` - -为了官方客户端兼容,可能还需要接受无 `/api` 前缀的 aliases: - -- `/webauthn` -- `/webauthn/attestation-options` -- `/webauthn/assertion-options` -- `/webauthn/:id/delete` - -NodeWarden 自己 web 可以直接用 `/api/webauthn`,官方 web/browser 客户端会按它自己的 API base 组装 `/webauthn`。 - -### 建议新增表 - -按 NodeWarden 命名风格,建议用小写 snake_case: - -```sql -CREATE TABLE IF NOT EXISTS webauthn_credentials ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - public_key TEXT NOT NULL, - credential_id TEXT NOT NULL, - counter INTEGER NOT NULL DEFAULT 0, - type TEXT, - aa_guid TEXT, - transports TEXT, - encrypted_user_key TEXT, - encrypted_public_key TEXT, - encrypted_private_key TEXT, - supports_prf INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_user_credential - ON webauthn_credentials(user_id, credential_id); - -CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user - ON webauthn_credentials(user_id); -``` - -如果要更严格防止同一个 credential id 被跨用户重复注册,也可以加全局 unique index `credential_id`。官方代码至少检查同用户唯一;实际安全上更建议全局唯一,因为 credential id 本身应该唯一标识 authenticator credential。 - -PRF status 不必落库为枚举,可以由字段计算: - -- `supports_prf = 0` => `Unsupported` -- `supports_prf = 1` 且三段 encrypted key 不全 => `Supported` -- `supports_prf = 1` 且三段 encrypted key 全存在 => `Enabled` - -### Challenge/token 存储 - -官方 server 用 protected token 携带 options,再用 challenge cache 防重放。NodeWarden 在 Workers/D1 里建议组合: - -- token:HMAC/JWT 样式,绑定 `scope`、`challenge`、`userId?`、`rpId`、`createdAt`、`expiresAt`。 -- D1 表或 KV:记录 challenge 是否使用过,至少字段 `challenge_hash`、`scope`、`user_id`、`expires_at`、`used_at`。 -- 登录 assertion options 是公开接口,不绑定 user id;create/update/delete 管理流程应绑定 user id。 -- 验证成功后立即 mark used。 - -建议 scopes: - -- `Authentication` -- `CreateCredential` -- `UpdateKeySet` - -官方还有 `PrfRegistration` 语义,NodeWarden 可以用 `CreateCredential` 覆盖,只要 token 逻辑严谨即可。 - -### 服务端 WebAuthn 验证库 - -NodeWarden 当前没有 FIDO2/WebAuthn 服务端验证依赖。不要手写签名和 attestation 解析。 - -候选:`@simplewebauthn/server`。官方文档当前说明它提供 `generateRegistrationOptions`、`verifyRegistrationResponse`、`generateAuthenticationOptions`、`verifyAuthenticationResponse`,并记录了 RP ID、origin、credential public key、counter、transports 等数据结构。文档地址:https://simplewebauthn.dev/docs/packages/server - -注意:NodeWarden 跑在 Cloudflare Workers,不是普通 Node server。正式选库前需要做一次构建/runtime 验证,确认包不会依赖 Workers 不支持的 Node API。这个验证属于实现阶段,不在本研究文档里写测试程序。 - -## NodeWarden web 需要改的地方 - -### 登录页 - -当前登录 UI 在 `webapp/src/components/AuthViews.tsx`,状态和行为主要由 `webapp/src/App.tsx`、`webapp/src/lib/app-auth.ts` 管。 - -新增: - -- 登录页增加“使用 passkey 登录”按钮。 -- 新增 `performPasskeyLogin()`: - 1. GET `/identity/accounts/webauthn/assertion-options` - 2. 转换 server options 里的 base64url challenge/user id/credential id 为 ArrayBuffer。 - 3. `navigator.credentials.get()`,带 PRF salt。 - 4. POST `/identity/connect/token`,`grant_type=webauthn`。 - 5. 从 response 的 `UserDecryptionOptions.WebAuthnPrfOption` 取 encrypted keyset。 - 6. 用本地 PRF key 解出 user key。 - 7. 构造 `SessionState` 并进入 app。 - -不能复用 `completeLogin(token, email, masterKey, fallbackKdfIterations)`,因为它要求 masterKey。应新增 passkey 专用 complete 函数。 - -### 设置页 - -当前账户/安全相关 UI 在 `webapp/src/components/SettingsPage.tsx` 一带。 - -新增: - -- Passkey 列表。 -- 新建 passkey dialog。 -- 删除 passkey。 -- 对支持 PRF 但未启用 encryption 的 passkey,提供“启用用于登录解锁”的操作。 - -自己 web 的新建流程要和官方一致: - -1. 已登录状态下先验证主密码或现有 session secret。 -2. 请求 attestation options。 -3. `navigator.credentials.create()` 带 `extensions.prf = {}`。 -4. 如果用户希望这个 passkey 可直接解锁 vault,再对刚创建 credential 做一次 `navigator.credentials.get()` 获取 PRF 输出。 -5. 用 PRF key 加密/封装当前 user key,发送到 server 保存。 - -### 客户端加密能力 - -NodeWarden web 当前已经有: - -- PBKDF2 -- HKDF expand -- Bitwarden EncString 加解密 -- RSA-OAEP private key 加密 - -但 passkey PRF keyset 需要和官方策略对齐: - -- PRF key 是 64 字节 symmetric key,前 32 enc、后 32 mac。 -- `encryptedPrivateKey` 用 PRF key wrap 一个 decapsulation private key。 -- `encryptedUserKey` 用对应 public key encapsulate user key。 -- `encryptedPublicKey` 用于 key rotation。 - -这里需要认真复用或补齐 NodeWarden 现有 crypto helper,避免做出和官方客户端无法互解的 keyset。 - -## 扩展兼容要求 - -### 官方 browser extension - -官方 extension passkey 登录入口在: - -- `apps/browser/src/auth/popup/login/extension-login-component.service.ts` -- 只在 Chromium 开启。 - -如果要官方/派生扩展能对 NodeWarden passkey 登录: - -- identity URL 必须能访问 `/accounts/webauthn/assertion-options`。 -- token URL 必须支持 `grant_type=webauthn`。 -- API URL 必须能访问 `/webauthn` 管理接口。 -- response 大小写和字段名要同时照顾 PascalCase/camelCase,NodeWarden 当前 token response 已经在一些字段上双写,这个风格应继续沿用。 -- passkey 登录成功时必须返回可解开 vault 的 `webAuthnPrfOption`,否则官方组件虽然认证成功,也不会进入可用 vault。 - -### RP ID 和 origin - -自己的 web: - -- RP ID 通常是站点 host,例如 `vault.example.com`。 -- origin 是 `https://vault.example.com`。 - -官方 browser extension: - -- 扩展页面 origin 是 `chrome-extension://...`。 -- 官方之所以只开 Chromium,是因为 Chromium extension 具备它需要的 RP ID 覆盖能力。 -- NodeWarden server 验证 assertion 时必须允许正确的 origin/RP ID 组合。这里不能简单只接受当前 request origin,否则扩展登录会失败。 - -建议配置化: - -- `WEBAUTHN_RP_ID` -- `WEBAUTHN_RP_NAME` -- `WEBAUTHN_ALLOWED_ORIGINS` - -默认可以从 request URL 推导 web origin,但生产建议显式配置。 - -## 安全约束 - -- 所有账户 passkey 必须 `userVerification: required`。 -- 登录 assertion 使用 discoverable credential,`userHandle` 必须能解析成 user id 并和 credential 记录一致。 -- challenge 必须有过期时间和一次性使用标记。 -- PRF 输出绝不能传给 server,也不能写入日志。 -- token 里要绑定 scope,防止 attestation token 被拿去 authentication 用。 -- counter 要更新。遇到 counter 异常时至少记录 audit event,是否阻断要结合 multi-device passkey 现实处理。 -- 每用户 credential 数量限制建议沿用官方 5 个。 -- 删除/新增/启用 encryption 必须要求已登录用户二次验证。 -- 密码变更、user key rotation 后,所有 enabled PRF credentials 的 keyset 也要 rotation,否则 passkey 登录会解不开新 vault key。 -- 备份导出/导入必须包含账户 passkey 表,否则恢复后 passkey 登录会全部失效。 -- 审计日志建议新增: - - `auth.passkey.login.success` - - `auth.passkey.login.failed` - - `account.passkey.create` - - `account.passkey.delete` - - `account.passkey.encryption.enable` - - `account.passkey.rotate` - -## 建议实施顺序 - -### 第一阶段:后端基础 - -1. 新增 `webauthn_credentials` 和 challenge 表。 -2. 新增 storage repo。 -3. 接入 WebAuthn 服务端验证库。 -4. 实现 assertion options 和 `grant_type=webauthn`。 -5. token response 加 `WebAuthnPrfOption` shape。 - -这阶段先能让“已有手工塞入的 enabled credential”完成登录验证,但还不做 UI。 - -### 第二阶段:账户 passkey 管理 API - -1. 实现 `/api/webauthn` 和 `/webauthn` aliases。 -2. 实现 attestation options、save credential、list、delete、enable/update encryption。 -3. 加 audit event。 -4. 接入 backup export/import。 -5. sync response 加 `WebAuthnPrfOptions`。 - -### 第三阶段:NodeWarden 自己 web - -1. 登录页 passkey 按钮和 `performPasskeyLogin()`。 -2. Passkey 设置页。 -3. PRF keyset 创建、保存、删除、启用 encryption。 -4. 浏览器能力判断和错误提示。 - -### 第四阶段:扩展兼容 - -1. 用官方 browser extension 的 Chromium passkey 登录流程校对 endpoint。 -2. 校对 `/config` 里 identity/api/web vault URL。 -3. 校对 RP ID、allowed origins。 -4. 必要时加兼容字段或 alias route。 - -按用户要求,本阶段只需要代码跑通不报错;不在这里写可视化测试或测试程序。 - -## 待实现清单 - -- [ ] 设计并落库 `webauthn_credentials`。 -- [ ] 设计并落库 WebAuthn challenge/replay cache。 -- [ ] 选定并验证 Workers 可用的 WebAuthn server library。 -- [ ] `GET /identity/accounts/webauthn/assertion-options`。 -- [ ] `POST /identity/connect/token` 支持 `grant_type=webauthn`。 -- [ ] `UserDecryptionOptions.WebAuthnPrfOption`。 -- [ ] `UserDecryption.WebAuthnPrfOptions`。 -- [ ] `/api/webauthn` 管理接口。 -- [ ] `/webauthn` 官方客户端 alias。 -- [ ] NodeWarden web passkey 登录入口。 -- [ ] NodeWarden web passkey 管理页。 -- [ ] key rotation 时同步 rotate PRF keysets。 -- [ ] backup export/import 覆盖新表。 -- [ ] audit logs 覆盖 passkey 管理和登录。 - -## 关键文件索引 - -NodeWarden: - -- `src/router-public.ts` -- `src/router-authenticated.ts` -- `src/handlers/accounts.ts` -- `src/handlers/identity.ts` -- `src/handlers/sync.ts` -- `src/services/auth.ts` -- `src/services/storage-schema.ts` -- `src/services/storage-user-repo.ts` -- `src/services/storage-device-repo.ts` -- `src/utils/passkey.ts` -- `src/utils/user-decryption.ts` -- `src/types/index.ts` -- `webapp/src/lib/api/auth.ts` -- `webapp/src/lib/app-auth.ts` -- `webapp/src/components/AuthViews.tsx` -- `webapp/src/components/SettingsPage.tsx` - -Bitwarden server: - -- `.codex-upstream/bitwarden-server/src/Identity/Controllers/AccountsController.cs` -- `.codex-upstream/bitwarden-server/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs` -- `.codex-upstream/bitwarden-server/src/Identity/IdentityServer/ApiClient.cs` -- `.codex-upstream/bitwarden-server/src/Api/Auth/Controllers/WebAuthnController.cs` -- `.codex-upstream/bitwarden-server/src/Core/Auth/Entities/WebAuthnCredential.cs` -- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/GetWebAuthnLoginCredentialCreateOptionsCommand.cs` -- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/GetWebAuthnLoginCredentialAssertionOptionsCommand.cs` -- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/CreateWebAuthnLoginCredentialCommand.cs` -- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/AssertWebAuthnLoginCredentialCommand.cs` -- `.codex-upstream/bitwarden-server/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs` -- `.codex-upstream/bitwarden-server/util/SqliteMigrations/Migrations/20231213032045_WebAuthnLoginCredentials.cs` - -Bitwarden clients/browser: - -- `.codex-upstream/bitwarden-clients/libs/auth/src/angular/login/default-login-component.service.ts` -- `.codex-upstream/bitwarden-clients/apps/browser/src/auth/popup/login/extension-login-component.service.ts` -- `.codex-upstream/bitwarden-clients/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts` -- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts` -- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts` -- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login-prf-key.service.ts` -- `.codex-upstream/bitwarden-clients/libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts` -- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/request/webauthn-login-response.request.ts` -- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts` -- `.codex-upstream/bitwarden-clients/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts` -- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts` -- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts` -- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/save-credential.request.ts` -- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/enable-credential-encryption.request.ts` -- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-attestation-response.request.ts` -- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts` -- `.codex-upstream/bitwarden-clients/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts` -- `.codex-upstream/bitwarden-clients/libs/auth/src/common/models/domain/user-decryption-options.ts` -