From 18d3490c4fc3b923678ba40c5f9b669b524a1970 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Wed, 10 Jun 2026 00:53:41 +0800 Subject: [PATCH] feat: implement account passkey functionality - Added functions for managing account passkeys including creation, listing, updating, and deletion. - Introduced login methods using account passkeys with options for direct unlock and login-only modes. - Enhanced error handling and response parsing for passkey-related API calls. - Updated UI styles for account passkey management components. - Added new translations for account passkey features in multiple languages. - Modified network status handling to improve service reachability checks. --- .gitignore | 4 + docs/passkey-login-research.md | 785 ++++++++++++++++++ migrations/0001_init.sql | 38 + package-lock.json | 556 ++++++++++--- package.json | 1 + src/handlers/account-passkeys.ts | 488 +++++++++++ src/handlers/identity.ts | 124 +++ src/handlers/sync.ts | 26 +- src/router-authenticated.ts | 30 + src/router-public.ts | 7 + src/services/audit-events.ts | 1 + src/services/backup-archive.ts | 23 +- src/services/backup-import.ts | 21 + src/services/storage-account-passkey-repo.ts | 331 ++++++++ src/services/storage-schema.ts | 14 + src/services/storage.ts | 99 ++- src/types/index.ts | 46 +- src/utils/account-passkeys.ts | 269 ++++++ src/utils/user-decryption.ts | 6 +- webapp/src/App.tsx | 68 +- webapp/src/components/AppMainRoutes.tsx | 10 +- webapp/src/components/AuthViews.tsx | 46 +- webapp/src/components/SettingsPage.tsx | 173 +++- webapp/src/hooks/useAccountSecurityActions.ts | 80 +- webapp/src/lib/account-passkeys.ts | 308 +++++++ webapp/src/lib/api/auth.ts | 165 ++++ webapp/src/lib/api/backup.ts | 1 + webapp/src/lib/app-auth.ts | 126 ++- webapp/src/lib/i18n/locales/en.ts | 23 + webapp/src/lib/i18n/locales/es.ts | 23 + webapp/src/lib/i18n/locales/ru.ts | 23 + webapp/src/lib/i18n/locales/zh-CN.ts | 23 + webapp/src/lib/i18n/locales/zh-TW.ts | 23 + webapp/src/lib/network-status.ts | 10 +- webapp/src/lib/offline-auth.ts | 2 +- webapp/src/lib/types.ts | 31 + webapp/src/styles/management.css | 62 ++ webapp/src/styles/responsive.css | 15 + 38 files changed, 3907 insertions(+), 174 deletions(-) create mode 100644 docs/passkey-login-research.md create mode 100644 src/handlers/account-passkeys.ts create mode 100644 src/services/storage-account-passkey-repo.ts create mode 100644 src/utils/account-passkeys.ts create mode 100644 webapp/src/lib/account-passkeys.ts diff --git a/.gitignore b/.gitignore index d521294..c236c6b 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,7 @@ settings.json .claude/ NodeWarden-compat/ .codex-upstream/ +.codex-upstream/bitwarden-server/ +.codex-upstream/bitwarden-clients/ +.codex-upstream/bitwarden-web/ +.codex-upstream/bitwarden-browser/ diff --git a/docs/passkey-login-research.md b/docs/passkey-login-research.md new file mode 100644 index 0000000..15780a7 --- /dev/null +++ b/docs/passkey-login-research.md @@ -0,0 +1,785 @@ +# 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` + diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index 92c61d2..a800fd1 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -198,6 +198,44 @@ CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens ( CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier); +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_credential_id + ON webauthn_credentials(credential_id); +CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user + ON webauthn_credentials(user_id); +CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated + ON webauthn_credentials(user_id, updated_at); + +CREATE TABLE IF NOT EXISTS webauthn_challenges ( + challenge_hash TEXT PRIMARY KEY, + scope TEXT NOT NULL, + user_id TEXT, + expires_at INTEGER NOT NULL, + used_at INTEGER, + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires + ON webauthn_challenges(expires_at); +CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope + ON webauthn_challenges(user_id, scope); + -- Rate limiting CREATE TABLE IF NOT EXISTS login_attempts_ip ( ip TEXT PRIMARY KEY, diff --git a/package-lock.json b/package-lock.json index f1548f2..4601533 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "LGPL-3.0", "dependencies": { "@noble/hashes": "^2.0.1", + "@simplewebauthn/server": "^13.3.1", "@tanstack/react-query": "^5.90.21", "@zip.js/zip.js": "^2.8.22", "fflate": "^0.8.2", @@ -392,24 +393,24 @@ } }, "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.4.2", - "resolved": "https://registry.npmmirror.com/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", - "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", "dev": true, "license": "MIT OR Apache-2.0", "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/@cloudflare/unenv-preset": { - "version": "2.15.0", - "resolved": "https://registry.npmmirror.com/@cloudflare/unenv-preset/-/unenv-preset-2.15.0.tgz", - "integrity": "sha512-EGYmJaGZKWl+X8tXxcnx4v2bOZSjQeNI5dWFeXivgX9+YCT69AkzHHwlNbVpqtEUTbew8eQurpyOpeN8fg00nw==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", "dev": true, "license": "MIT OR Apache-2.0", "peerDependencies": { "unenv": "2.0.0-rc.24", - "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" + "workerd": ">1.20260305.0 <2.0.0-0" }, "peerDependenciesMeta": { "workerd": { @@ -418,9 +419,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260301.1", - "resolved": "https://registry.npmmirror.com/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260301.1.tgz", - "integrity": "sha512-+kJvwociLrvy1JV9BAvoSVsMEIYD982CpFmo/yMEvBwxDIjltYsLTE8DLi0mCkGsQ8Ygidv2fD9wavzXeiY7OQ==", + "version": "1.20260603.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260603.1.tgz", + "integrity": "sha512-cEXDWu6V3ZrpmwWkM4OJE9AeXjdAgOY5rh8EHhcBVCuP5rxnzUbPzLtrVOHx0UUUAcCrFq0Xsa6mZKL1VUZsKQ==", "cpu": [ "x64" ], @@ -435,9 +436,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260301.1", - "resolved": "https://registry.npmmirror.com/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260301.1.tgz", - "integrity": "sha512-PPIetY3e67YBr9O4UhILK8nbm5TqUDl14qx4rwFNrRSBOvlzuczzbd4BqgpAtbGVFxKp1PWpjAnBvGU/OI/tLQ==", + "version": "1.20260603.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260603.1.tgz", + "integrity": "sha512-uBPK4LaWJNbbCYwPnUAehlHbbVulhVZPZsdcAhBPfZhHb3QAuAEPAQepO/P67R3V6Cni4YGx1fLbL8A5wwoaNA==", "cpu": [ "arm64" ], @@ -452,9 +453,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260301.1", - "resolved": "https://registry.npmmirror.com/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260301.1.tgz", - "integrity": "sha512-Gu5vaVTZuYl3cHa+u5CDzSVDBvSkfNyuAHi6Mdfut7TTUdcb3V5CIcR/mXRSyMXzEy9YxEWIfdKMxOMBjupvYQ==", + "version": "1.20260603.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260603.1.tgz", + "integrity": "sha512-ht9l6/8Tk7Rp6kA4S9oFZ4X8u0VjnnFdmU/6B3fnABYKREYTKh2RdOqXqXxcp5eNJseireKnWik/hQOPK1CutQ==", "cpu": [ "x64" ], @@ -469,9 +470,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260301.1", - "resolved": "https://registry.npmmirror.com/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260301.1.tgz", - "integrity": "sha512-igL1pkyCXW6GiGpjdOAvqMi87UW0LMc/+yIQe/CSzuZJm5GzXoAMrwVTkCFnikk6JVGELrM5x0tGYlxa0sk5Iw==", + "version": "1.20260603.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260603.1.tgz", + "integrity": "sha512-LJZ6x00rAjSrobV4m0ZW0TpH5ilBbKcWBzlH+y+KOUsIE/CpTuhAzKV43TbSnFLRX5+jrWKiz2v0hO91lPXy6A==", "cpu": [ "arm64" ], @@ -486,9 +487,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260301.1", - "resolved": "https://registry.npmmirror.com/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260301.1.tgz", - "integrity": "sha512-Q0wMJ4kcujXILwQKQFc1jaYamVsNvjuECzvRrTI8OxGFMx2yq9aOsswViE4X1gaS2YQQ5u0JGwuGi5WdT1Lt7A==", + "version": "1.20260603.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260603.1.tgz", + "integrity": "sha512-DvwqkXMAJRPoDN4PxapAwhlz/6ouD+6R1ttbAEK3cWD/QBvFF5STx7Ds/9Irf+rBly3np3uHWkeX+wZnNFEuzA==", "cpu": [ "x64" ], @@ -503,15 +504,15 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "4.20260305.0", - "resolved": "https://registry.npmmirror.com/@cloudflare/workers-types/-/workers-types-4.20260305.0.tgz", - "integrity": "sha512-sCgPFnQ03SVpC2OVW8wysONLZW/A8hlp9Mq2ckG/h1oId4kr9NawA6vUiOmOjCWRn2hIohejBYVQ+Vu20rCdKA==", + "version": "4.20260609.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260609.1.tgz", + "integrity": "sha512-krGHtwSApCFBjTe1NTx/TFQ0P5i/bHGQOqCPnCLssb8rOKaAG4JkPFJZsossr0z/ZTMnpP2Tid5jWju+/i0hCA==", "dev": true, "license": "MIT OR Apache-2.0" }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", - "resolved": "https://registry.npmmirror.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "dev": true, "license": "MIT", @@ -523,9 +524,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", + "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", "dev": true, "license": "MIT", "optional": true, @@ -975,9 +976,15 @@ "node": ">=18" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmmirror.com/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@img/colour": { "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/@img/colour/-/colour-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "dev": true, "license": "MIT", @@ -987,7 +994,7 @@ }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.34.5", - "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" @@ -1010,7 +1017,7 @@ }, "node_modules/@img/sharp-darwin-x64": { "version": "0.34.5", - "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" @@ -1033,7 +1040,7 @@ }, "node_modules/@img/sharp-libvips-darwin-arm64": { "version": "1.2.4", - "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" @@ -1050,7 +1057,7 @@ }, "node_modules/@img/sharp-libvips-darwin-x64": { "version": "1.2.4", - "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" @@ -1067,12 +1074,15 @@ }, "node_modules/@img/sharp-libvips-linux-arm": { "version": "1.2.4", - "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1084,12 +1094,15 @@ }, "node_modules/@img/sharp-libvips-linux-arm64": { "version": "1.2.4", - "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1101,12 +1114,15 @@ }, "node_modules/@img/sharp-libvips-linux-ppc64": { "version": "1.2.4", - "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1118,12 +1134,15 @@ }, "node_modules/@img/sharp-libvips-linux-riscv64": { "version": "1.2.4", - "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1135,12 +1154,15 @@ }, "node_modules/@img/sharp-libvips-linux-s390x": { "version": "1.2.4", - "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1152,12 +1174,15 @@ }, "node_modules/@img/sharp-libvips-linux-x64": { "version": "1.2.4", - "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1169,12 +1194,15 @@ }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { "version": "1.2.4", - "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1186,12 +1214,15 @@ }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { "version": "1.2.4", - "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1203,12 +1234,15 @@ }, "node_modules/@img/sharp-linux-arm": { "version": "0.34.5", - "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1226,12 +1260,15 @@ }, "node_modules/@img/sharp-linux-arm64": { "version": "0.34.5", - "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1249,12 +1286,15 @@ }, "node_modules/@img/sharp-linux-ppc64": { "version": "0.34.5", - "resolved": "https://registry.npmmirror.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1272,12 +1312,15 @@ }, "node_modules/@img/sharp-linux-riscv64": { "version": "0.34.5", - "resolved": "https://registry.npmmirror.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1295,12 +1338,15 @@ }, "node_modules/@img/sharp-linux-s390x": { "version": "0.34.5", - "resolved": "https://registry.npmmirror.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1318,12 +1364,15 @@ }, "node_modules/@img/sharp-linux-x64": { "version": "0.34.5", - "resolved": "https://registry.npmmirror.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1341,12 +1390,15 @@ }, "node_modules/@img/sharp-linuxmusl-arm64": { "version": "0.34.5", - "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1364,12 +1416,15 @@ }, "node_modules/@img/sharp-linuxmusl-x64": { "version": "0.34.5", - "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1387,7 +1442,7 @@ }, "node_modules/@img/sharp-wasm32": { "version": "0.34.5", - "resolved": "https://registry.npmmirror.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" @@ -1407,7 +1462,7 @@ }, "node_modules/@img/sharp-win32-arm64": { "version": "0.34.5", - "resolved": "https://registry.npmmirror.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "cpu": [ "arm64" @@ -1427,7 +1482,7 @@ }, "node_modules/@img/sharp-win32-ia32": { "version": "0.34.5", - "resolved": "https://registry.npmmirror.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" @@ -1447,7 +1502,7 @@ }, "node_modules/@img/sharp-win32-x64": { "version": "0.34.5", - "resolved": "https://registry.npmmirror.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" @@ -1528,7 +1583,7 @@ }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.9", - "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "dev": true, "license": "MIT", @@ -1537,6 +1592,12 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@noble/hashes": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-2.0.1.tgz", @@ -1587,9 +1648,177 @@ "node": ">= 8" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-android/-/asn1-android-2.7.0.tgz", + "integrity": "sha512-iD3VskhVQnM4nE3PN9cBdPTR7JrqZy3FYk+uD2CeG6DUqKoANqaEfx0f7izPmW+Qm5JBM35ek+viLCmjy18ByQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz", + "integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "@peculiar/asn1-x509-attr": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz", + "integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz", + "integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz", + "integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.7.0", + "@peculiar/asn1-pkcs8": "^2.7.0", + "@peculiar/asn1-rsa": "^2.7.0", + "@peculiar/asn1-schema": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz", + "integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz", + "integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.7.0", + "@peculiar/asn1-pfx": "^2.7.0", + "@peculiar/asn1-pkcs8": "^2.7.0", + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "@peculiar/asn1-x509-attr": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz", + "integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz", + "integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz", + "integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz", + "integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmmirror.com/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", - "resolved": "https://registry.npmmirror.com/@poppinss/colors/-/colors-4.1.6.tgz", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", "dev": true, "license": "MIT", @@ -1599,7 +1828,7 @@ }, "node_modules/@poppinss/dumper": { "version": "0.6.5", - "resolved": "https://registry.npmmirror.com/@poppinss/dumper/-/dumper-0.6.5.tgz", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", "dev": true, "license": "MIT", @@ -1611,7 +1840,7 @@ }, "node_modules/@poppinss/exception": { "version": "1.2.3", - "resolved": "https://registry.npmmirror.com/@poppinss/exception/-/exception-1.2.3.tgz", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", "dev": true, "license": "MIT" @@ -1694,9 +1923,9 @@ } }, "node_modules/@prefresh/vite/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -2079,9 +2308,28 @@ "win32" ] }, + "node_modules/@simplewebauthn/server": { + "version": "13.3.1", + "resolved": "https://registry.npmmirror.com/@simplewebauthn/server/-/server-13.3.1.tgz", + "integrity": "sha512-GV/oM/qeycWn8p42JZIMJBsXWQcNFg+nJFzeQTnMA4gN8mXg0+HZFWJerHg8ZN/zlveMS3iV1wzuFpOVWS/46w==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@sindresorhus/is": { "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/@sindresorhus/is/-/is-7.2.0.tgz", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", "dev": true, "license": "MIT", @@ -2093,9 +2341,9 @@ } }, "node_modules/@speed-highlight/core": { - "version": "1.2.14", - "resolved": "https://registry.npmmirror.com/@speed-highlight/core/-/core-1.2.14.tgz", - "integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.16.tgz", + "integrity": "sha512-yNm/fYEcnpRjYduLMaddTK9XKYil6xB88+qFg79ZdZhHu1PadfoQmFW7pVTx7FZqMBNcUuThiAhxhENgtAO2/w==", "dev": true, "license": "CC0-1.0" }, @@ -2194,6 +2442,20 @@ "dev": true, "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmmirror.com/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -2417,7 +2679,7 @@ }, "node_modules/cookie": { "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "dev": true, "license": "MIT", @@ -2492,7 +2754,7 @@ }, "node_modules/detect-libc": { "version": "2.1.2", - "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", @@ -2595,7 +2857,7 @@ }, "node_modules/error-stack-parser-es": { "version": "1.0.5", - "resolved": "https://registry.npmmirror.com/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", "dev": true, "license": "MIT", @@ -2954,7 +3216,7 @@ }, "node_modules/kleur": { "version": "4.1.5", - "resolved": "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", "dev": true, "license": "MIT", @@ -3056,24 +3318,24 @@ } }, "node_modules/miniflare": { - "version": "4.20260301.1", - "resolved": "https://registry.npmmirror.com/miniflare/-/miniflare-4.20260301.1.tgz", - "integrity": "sha512-fqkHx0QMKswRH9uqQQQOU/RoaS3Wjckxy3CUX3YGJr0ZIMu7ObvI+NovdYi6RIsSPthNtq+3TPmRNxjeRiasog==", + "version": "4.20260603.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260603.0.tgz", + "integrity": "sha512-+kMQYB82gC8MPOuojHur3icQsUeZUEJ+Sphuo5rVC3Ri9txBLAW/mH33b9OVrpmkogQeaaqPS4tPtugJZhk5Kw==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", - "sharp": "^0.34.5", - "undici": "7.18.2", - "workerd": "1.20260301.1", - "ws": "8.18.0", + "sharp": "0.34.5", + "undici": "7.24.8", + "workerd": "1.20260603.1", + "ws": "8.20.1", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/mitt": { @@ -3102,9 +3364,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -3214,7 +3476,7 @@ }, "node_modules/pathe": { "version": "2.0.3", - "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" @@ -3227,9 +3489,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -3260,9 +3522,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -3280,7 +3542,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3425,6 +3687,24 @@ "url": "https://opencollective.com/preact" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmmirror.com/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qrcode-generator": { "version": "2.0.4", "resolved": "https://registry.npmmirror.com/qrcode-generator/-/qrcode-generator-2.0.4.tgz", @@ -3498,6 +3778,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmmirror.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/regexparam": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/regexparam/-/regexparam-3.0.0.tgz", @@ -3620,9 +3906,9 @@ } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", + "integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", "dev": true, "license": "ISC", "bin": { @@ -3634,7 +3920,7 @@ }, "node_modules/sharp": { "version": "0.34.5", - "resolved": "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "dev": true, "hasInstallScript": true, @@ -3742,7 +4028,7 @@ }, "node_modules/supports-color": { "version": "10.2.2", - "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-10.2.2.tgz", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", "dev": true, "license": "MIT", @@ -3868,9 +4154,7 @@ "version": "2.8.1", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.21.0", @@ -3892,6 +4176,24 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmmirror.com/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", @@ -3907,9 +4209,9 @@ } }, "node_modules/undici": { - "version": "7.18.2", - "resolved": "https://registry.npmmirror.com/undici/-/undici-7.18.2.tgz", - "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", "dev": true, "license": "MIT", "engines": { @@ -3925,7 +4227,7 @@ }, "node_modules/unenv": { "version": "2.0.0-rc.24", - "resolved": "https://registry.npmmirror.com/unenv/-/unenv-2.0.0-rc.24.tgz", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", @@ -3981,9 +4283,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "dev": true, "license": "MIT", "dependencies": { @@ -4074,9 +4376,9 @@ } }, "node_modules/workerd": { - "version": "1.20260301.1", - "resolved": "https://registry.npmmirror.com/workerd/-/workerd-1.20260301.1.tgz", - "integrity": "sha512-oterQ1IFd3h7PjCfT4znSFOkJCvNQ6YMOyZ40YsnO3nrSpgB4TbJVYWFOnyJAw71/RQuupfVqZZWKvsy8GO3fw==", + "version": "1.20260603.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260603.1.tgz", + "integrity": "sha512-NPcbhI1++CS+fnELyXtsIR52en+5kwr/OrKeiQeYXGy10HxmPdsQBv9N+DU7hJIOOmBHhOGAAsoGDjyiQ2YCaA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -4087,11 +4389,11 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260301.1", - "@cloudflare/workerd-darwin-arm64": "1.20260301.1", - "@cloudflare/workerd-linux-64": "1.20260301.1", - "@cloudflare/workerd-linux-arm64": "1.20260301.1", - "@cloudflare/workerd-windows-64": "1.20260301.1" + "@cloudflare/workerd-darwin-64": "1.20260603.1", + "@cloudflare/workerd-darwin-arm64": "1.20260603.1", + "@cloudflare/workerd-linux-64": "1.20260603.1", + "@cloudflare/workerd-linux-arm64": "1.20260603.1", + "@cloudflare/workerd-windows-64": "1.20260603.1" } }, "node_modules/wouter": { @@ -4109,33 +4411,33 @@ } }, "node_modules/wrangler": { - "version": "4.71.0", - "resolved": "https://registry.npmmirror.com/wrangler/-/wrangler-4.71.0.tgz", - "integrity": "sha512-j6pSGAncOLNQDRzqtp0EqzYj52CldDP7uz/C9cxVrIgqa5p+cc0b4pIwnapZZAGv9E1Loa3tmPD0aXonH7KTkw==", + "version": "4.98.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.98.0.tgz", + "integrity": "sha512-cXfFUuF4rMIvE0hiMnXjEAB27ERryaCgquBJdUoPIjFzYYE1rbRdMUkEdQ18qDPUtsPvhJdqxLntixT9OfSzQw==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { - "@cloudflare/kv-asset-handler": "0.4.2", - "@cloudflare/unenv-preset": "2.15.0", + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", - "miniflare": "4.20260301.1", + "miniflare": "4.20260603.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260301.1" + "workerd": "1.20260603.1" }, "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "optionalDependencies": { - "fsevents": "~2.3.2" + "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260226.1" + "@cloudflare/workers-types": "^4.20260603.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -4144,9 +4446,9 @@ } }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmmirror.com/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "dev": true, "license": "MIT", "engines": { @@ -4190,7 +4492,7 @@ }, "node_modules/youch": { "version": "4.1.0-beta.10", - "resolved": "https://registry.npmmirror.com/youch/-/youch-4.1.0-beta.10.tgz", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", "dev": true, "license": "MIT", @@ -4204,7 +4506,7 @@ }, "node_modules/youch-core": { "version": "0.3.3", - "resolved": "https://registry.npmmirror.com/youch-core/-/youch-core-0.3.3.tgz", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", "dev": true, "license": "MIT", diff --git a/package.json b/package.json index b5edcff..c39298a 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ }, "dependencies": { "@noble/hashes": "^2.0.1", + "@simplewebauthn/server": "^13.3.1", "@tanstack/react-query": "^5.90.21", "@zip.js/zip.js": "^2.8.22", "fflate": "^0.8.2", diff --git a/src/handlers/account-passkeys.ts b/src/handlers/account-passkeys.ts new file mode 100644 index 0000000..321c7b5 --- /dev/null +++ b/src/handlers/account-passkeys.ts @@ -0,0 +1,488 @@ +import { + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, +} from '@simplewebauthn/server'; +import type { AccountPasskeyChallengeScope, AccountPasskeyCredential, Env, User } from '../types'; +import { StorageService } from '../services/storage'; +import { AuthService } from '../services/auth'; +import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response'; +import { generateUUID } from '../utils/uuid'; +import { bytesToBase64Url } from '../utils/passkey'; +import { + accountPasskeyCredentialToResponse, + accountPasskeyPrfStatus, + accountPasskeyTokenTtlMs, + buildWebAuthnPrfOption, + createAccountPasskeyToken, + getAccountPasskeyRpConfig, + isSerializedEncString, + normalizeAccountPasskeyName, + normalizeAuthenticationResponse, + normalizeRegistrationResponse, + normalizeTransports, + sha256Base64Url, + toSimpleWebAuthnCredential, + userHandleToUserId, + userIdToWebAuthnUserId, + verifyAccountPasskeyToken, +} from '../utils/account-passkeys'; +import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events'; + +const MAX_ACCOUNT_PASSKEYS = 5; + +function parseBodyObject(body: unknown): Record { + return body && typeof body === 'object' ? body as Record : {}; +} + +async function readJsonBody(request: Request): Promise | null> { + try { + return parseBodyObject(await request.json()); + } catch { + return null; + } +} + +async function verifyUserSecret( + env: Env, + user: User, + body: Record +): Promise { + const secret = String(body.masterPasswordHash || body.master_password_hash || body.secret || body.password || '').trim(); + if (!secret) return false; + const storedHash = String(user.masterPasswordHash || '').trim(); + if (!storedHash) return false; + const auth = new AuthService(env); + return auth.verifyPassword(secret, storedHash, user.email); +} + +function logAccountPasskeyHandlerError(stage: string, error: unknown, details: Record = {}): void { + const err = error instanceof Error ? error : null; + console.error('Account passkey handler failed', { + stage, + name: err?.name || typeof error, + message: err?.message || String(error), + stack: err?.stack, + ...details, + }); +} + +function passkeySetupStageMessage(stage: string): string { + if (stage === 'verify_master_password') return 'verifying master password'; + if (stage === 'load_existing_credentials') return 'loading existing passkeys'; + if (stage === 'generate_options') return 'generating passkey options'; + if (stage === 'save_challenge') return 'saving passkey challenge'; + if (stage === 'create_token') return 'creating passkey challenge token'; + return 'preparing passkey setup'; +} + +function hasCompletePrfKeySet(body: Record): boolean { + return !!(body.encryptedUserKey && body.encryptedPublicKey && body.encryptedPrivateKey); +} + +function readPrfKeySet(body: Record): { + encryptedUserKey: string | null; + encryptedPublicKey: string | null; + encryptedPrivateKey: string | null; +} { + if (!hasCompletePrfKeySet(body)) { + return { encryptedUserKey: null, encryptedPublicKey: null, encryptedPrivateKey: null }; + } + const encryptedUserKey = String(body.encryptedUserKey).trim(); + const encryptedPublicKey = String(body.encryptedPublicKey).trim(); + const encryptedPrivateKey = String(body.encryptedPrivateKey).trim(); + if (!isSerializedEncString(encryptedUserKey) || !isSerializedEncString(encryptedPublicKey) || !isSerializedEncString(encryptedPrivateKey)) { + throw new Error('Invalid encrypted key set'); + } + return { encryptedUserKey, encryptedPublicKey, encryptedPrivateKey }; +} + +async function saveChallenge( + storage: StorageService, + scope: AccountPasskeyChallengeScope, + challenge: string, + userId: string | null +): Promise { + const now = Date.now(); + await storage.saveAccountPasskeyChallenge({ + challengeHash: await sha256Base64Url(challenge), + scope, + userId, + expiresAt: now + accountPasskeyTokenTtlMs(scope), + usedAt: null, + createdAt: now, + }); +} + +export async function handleGetAccountPasskeyAssertionOptions(request: Request, env: Env): Promise { + const storage = new StorageService(env.DB); + const { rpId } = getAccountPasskeyRpConfig(request, env); + const options = await generateAuthenticationOptions({ + rpID: rpId, + allowCredentials: [], + userVerification: 'required', + timeout: 60000, + }); + await saveChallenge(storage, 'Authentication', options.challenge, null); + const token = await createAccountPasskeyToken(env, { + scope: 'Authentication', + challenge: options.challenge, + userId: null, + rpId, + }); + return jsonResponse({ options, token, object: 'webAuthnLoginAssertionOptions', Object: 'webAuthnLoginAssertionOptions' }); +} + +export async function assertAccountPasskeyCredential( + request: Request, + env: Env, + storage: StorageService, + input: { + token: string; + deviceResponse: unknown; + scope: 'Authentication' | 'UpdateKeySet'; + expectedUserId?: string | null; + } +): Promise<{ user: User; credential: AccountPasskeyCredential }> { + const payload = await verifyAccountPasskeyToken(env, input.token, input.scope); + if (!payload) { + throw new Error('Passkey challenge token is invalid or expired'); + } + if (input.expectedUserId !== undefined && payload.userId !== input.expectedUserId) { + throw new Error('Passkey challenge token does not match this user'); + } + + const response = normalizeAuthenticationResponse(input.deviceResponse); + if (!response) { + throw new Error('Invalid passkey assertion response'); + } + + const challengeHash = await sha256Base64Url(payload.challenge); + const consumed = await storage.consumeAccountPasskeyChallenge( + challengeHash, + input.scope, + payload.userId, + Date.now() + ); + if (!consumed) { + throw new Error('Passkey challenge has expired or was already used'); + } + + const credential = await storage.getAccountPasskeyCredentialByCredentialId(response.rawId); + if (!credential) { + throw new Error('Passkey is not registered for this server'); + } + if (payload.userId && credential.userId !== payload.userId) { + throw new Error('Passkey does not belong to this user'); + } + + const userHandleUserId = userHandleToUserId(response.response.userHandle); + const resolvedUserId = payload.userId || userHandleUserId || credential.userId; + if (!resolvedUserId || resolvedUserId !== credential.userId) { + throw new Error('Passkey user handle does not match this credential'); + } + + const user = await storage.getUserById(resolvedUserId); + if (!user || user.status !== 'active') { + throw new Error('Passkey user is not available'); + } + + const { origins } = getAccountPasskeyRpConfig(request, env); + const verification = await verifyAuthenticationResponse({ + response, + expectedChallenge: payload.challenge, + expectedOrigin: origins, + expectedRPID: payload.rpId, + credential: toSimpleWebAuthnCredential(credential), + requireUserVerification: true, + advancedFIDOConfig: { userVerification: 'required' }, + }); + if (!verification.verified || !verification.authenticationInfo.userVerified) { + throw new Error('Passkey assertion could not be verified'); + } + + await storage.updateAccountPasskeyCounter( + credential.userId, + credential.credentialId, + verification.authenticationInfo.newCounter, + new Date().toISOString() + ); + credential.counter = verification.authenticationInfo.newCounter; + return { user, credential }; +} + +export async function handleGetAccountPasskeyCredentials(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId); + return jsonResponse({ + data: credentials.map(accountPasskeyCredentialToResponse), + Data: credentials.map(accountPasskeyCredentialToResponse), + object: 'list', + Object: 'list', + continuationToken: null, + ContinuationToken: null, + }); +} + +export async function handleGetAccountPasskeyAttestationOptions(request: Request, env: Env, userId: string, user: User): Promise { + const body = await readJsonBody(request); + if (!body) return errorResponse('Invalid request payload', 400); + + let stage = 'verify_master_password'; + try { + if (!(await verifyUserSecret(env, user, body))) { + return errorResponse('Master password verification failed', 400); + } + + const storage = new StorageService(env.DB); + stage = 'load_existing_credentials'; + const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId); + if (credentials.length >= MAX_ACCOUNT_PASSKEYS) { + return errorResponse('Maximum passkey count reached', 400); + } + + const { rpId, rpName } = getAccountPasskeyRpConfig(request, env); + stage = 'generate_options'; + const options = await generateRegistrationOptions({ + rpID: rpId, + rpName, + userID: Uint8Array.from(userIdToWebAuthnUserId(user.id)), + userName: user.email, + userDisplayName: user.name || user.email, + attestationType: 'none', + timeout: 60000, + excludeCredentials: credentials.map((credential) => ({ + id: credential.credentialId, + transports: (credential.transports || undefined) as any, + })), + authenticatorSelection: { + residentKey: 'required', + requireResidentKey: true, + userVerification: 'required', + }, + }); + (options as any).extensions = { + ...((options as any).extensions || {}), + prf: {}, + }; + stage = 'save_challenge'; + await saveChallenge(storage, 'CreateCredential', options.challenge, userId); + stage = 'create_token'; + const token = await createAccountPasskeyToken(env, { + scope: 'CreateCredential', + challenge: options.challenge, + userId, + rpId, + }); + return jsonResponse({ options, token, object: 'webauthnCredentialCreateOptions', Object: 'webauthnCredentialCreateOptions' }); + } catch (error) { + logAccountPasskeyHandlerError(stage, error, { userId }); + return errorResponse(`Passkey setup failed while ${passkeySetupStageMessage(stage)}`, 500); + } +} + +export async function handleGetAccountPasskeyUpdateAssertionOptions(request: Request, env: Env, userId: string, user: User): Promise { + const body = await readJsonBody(request); + if (!body) return errorResponse('Invalid request payload', 400); + if (!(await verifyUserSecret(env, user, body))) { + return errorResponse('Master password verification failed', 400); + } + + const storage = new StorageService(env.DB); + let credentials = await storage.getAccountPasskeyCredentialsByUserId(userId); + const requestedId = String(body.credentialId || body.id || '').trim(); + if (requestedId) { + credentials = credentials.filter((credential) => credential.id === requestedId); + if (!credentials.length) return errorResponse('Account passkey not found', 404); + } + if (!credentials.length) return errorResponse('No account passkeys registered', 404); + + const { rpId } = getAccountPasskeyRpConfig(request, env); + const options = await generateAuthenticationOptions({ + rpID: rpId, + allowCredentials: credentials.map((credential) => ({ + id: credential.credentialId, + transports: (credential.transports || undefined) as any, + })), + userVerification: 'required', + timeout: 60000, + }); + await saveChallenge(storage, 'UpdateKeySet', options.challenge, userId); + const token = await createAccountPasskeyToken(env, { + scope: 'UpdateKeySet', + challenge: options.challenge, + userId, + rpId, + }); + return jsonResponse({ options, token, object: 'webAuthnLoginAssertionOptions', Object: 'webAuthnLoginAssertionOptions' }); +} + +export async function handleCreateAccountPasskeyCredential(request: Request, env: Env, userId: string): Promise { + const body = await readJsonBody(request); + if (!body) return errorResponse('Invalid request payload', 400); + + const storage = new StorageService(env.DB); + const payload = await verifyAccountPasskeyToken(env, String(body.token || ''), 'CreateCredential'); + if (!payload || payload.userId !== userId) { + return errorResponse('Passkey challenge token is invalid or expired', 400); + } + + const challengeHash = await sha256Base64Url(payload.challenge); + const consumed = await storage.consumeAccountPasskeyChallenge(challengeHash, 'CreateCredential', userId, Date.now()); + if (!consumed) { + return errorResponse('Passkey challenge has expired or was already used', 400); + } + + const currentCount = await storage.countAccountPasskeyCredentialsByUserId(userId); + if (currentCount >= MAX_ACCOUNT_PASSKEYS) { + return errorResponse('Maximum passkey count reached', 400); + } + + let prfKeySet: ReturnType; + try { + prfKeySet = readPrfKeySet(body); + } catch { + return errorResponse('Invalid encrypted passkey key set', 400); + } + + const registrationResponse = normalizeRegistrationResponse(body.deviceResponse); + if (!registrationResponse) { + return errorResponse('Invalid passkey registration response', 400); + } + + const { origins } = getAccountPasskeyRpConfig(request, env); + let verification: Awaited>; + try { + verification = await verifyRegistrationResponse({ + response: registrationResponse, + expectedChallenge: payload.challenge, + expectedOrigin: origins, + expectedRPID: payload.rpId, + requireUserPresence: true, + requireUserVerification: true, + }); + } catch { + return errorResponse('Passkey registration could not be verified', 400); + } + if (!verification.verified) { + return errorResponse('Passkey registration could not be verified', 400); + } + + const existing = await storage.getAccountPasskeyCredentialByCredentialId(verification.registrationInfo.credential.id); + if (existing) { + return errorResponse('Passkey is already registered', 409); + } + + const now = new Date().toISOString(); + const supportsPrf = !!body.supportsPrf || hasCompletePrfKeySet(body); + const transports = normalizeTransports(registrationResponse.response.transports); + const credential: AccountPasskeyCredential = { + id: generateUUID(), + userId, + name: normalizeAccountPasskeyName(body.name), + publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey), + credentialId: verification.registrationInfo.credential.id, + counter: verification.registrationInfo.credential.counter, + type: verification.registrationInfo.credentialType || 'public-key', + aaGuid: verification.registrationInfo.aaguid || null, + transports, + encryptedUserKey: prfKeySet.encryptedUserKey, + encryptedPublicKey: prfKeySet.encryptedPublicKey, + encryptedPrivateKey: prfKeySet.encryptedPrivateKey, + supportsPrf, + createdAt: now, + updatedAt: now, + }; + + await storage.saveAccountPasskeyCredential(credential); + await safeWriteAuditEvent(env, { + actorUserId: userId, + action: 'account.passkey.create', + category: 'security', + level: 'info', + targetType: 'accountPasskey', + targetId: credential.id, + metadata: { + prfStatus: accountPasskeyPrfStatus(credential), + ...auditRequestMetadata(request), + }, + }); + + return jsonResponse(accountPasskeyCredentialToResponse(credential)); +} + +export async function handleUpdateAccountPasskeyEncryption(request: Request, env: Env, userId: string): Promise { + const body = await readJsonBody(request); + if (!body) return errorResponse('Invalid request payload', 400); + + let prfKeySet: ReturnType; + try { + prfKeySet = readPrfKeySet(body); + } catch { + return errorResponse('Invalid encrypted passkey key set', 400); + } + if (!prfKeySet.encryptedUserKey || !prfKeySet.encryptedPublicKey || !prfKeySet.encryptedPrivateKey) { + return errorResponse('Encrypted passkey key set is required', 400); + } + + const storage = new StorageService(env.DB); + let assertion: Awaited>; + try { + assertion = await assertAccountPasskeyCredential(request, env, storage, { + token: String(body.token || ''), + deviceResponse: body.deviceResponse, + scope: 'UpdateKeySet', + expectedUserId: userId, + }); + } catch (error) { + return errorResponse(error instanceof Error ? error.message : 'Passkey assertion failed', 400); + } + + const updated = await storage.updateAccountPasskeyEncryption( + userId, + assertion.credential.credentialId, + prfKeySet.encryptedUserKey, + prfKeySet.encryptedPublicKey, + prfKeySet.encryptedPrivateKey + ); + if (!updated) return errorResponse('Passkey not found', 404); + + await safeWriteAuditEvent(env, { + actorUserId: userId, + action: 'account.passkey.encryption.enable', + category: 'security', + level: 'info', + targetType: 'accountPasskey', + targetId: assertion.credential.id, + metadata: auditRequestMetadata(request), + }); + return jsonResponse({ success: true }); +} + +export async function handleDeleteAccountPasskeyCredential(request: Request, env: Env, userId: string, credentialId: string, user: User): Promise { + const body = await readJsonBody(request); + if (!body) return errorResponse('Invalid request payload', 400); + if (!(await verifyUserSecret(env, user, body))) { + return errorResponse('Master password verification failed', 400); + } + + const storage = new StorageService(env.DB); + const deleted = await storage.deleteAccountPasskeyCredential(userId, credentialId); + if (!deleted) return errorResponse('Passkey not found', 404); + + await safeWriteAuditEvent(env, { + actorUserId: userId, + action: 'account.passkey.delete', + category: 'security', + level: 'info', + targetType: 'accountPasskey', + targetId: credentialId, + metadata: auditRequestMetadata(request), + }); + return jsonResponse({ success: true }); +} + +export function buildAccountPasskeyTokenUserDecryptionOption(credential: AccountPasskeyCredential) { + return buildWebAuthnPrfOption(credential); +} diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index bee9e34..e60f534 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -15,6 +15,10 @@ import { buildUserDecryptionOptions, } from '../utils/user-decryption'; import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events'; +import { + assertAccountPasskeyCredential, + buildAccountPasskeyTokenUserDecryptionOption, +} from './account-passkeys'; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; @@ -423,6 +427,126 @@ export async function handleToken(request: Request, env: Env): Promise ? withWebRefreshCookie(request, baseResponse, refreshToken) : baseResponse; + } else if (grantType === 'webauthn') { + const loginIdentifier = clientIdentifier; + const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); + if (!loginCheck.allowed) { + return identityErrorResponse( + `Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`, + 'TooManyRequests', + 429 + ); + } + + const token = String(body.token || '').trim(); + let deviceResponse: unknown = body.deviceResponse; + if (typeof deviceResponse === 'string') { + try { + deviceResponse = JSON.parse(deviceResponse); + } catch { + return identityErrorResponse('Invalid passkey response', 'invalid_request', 400); + } + } + if (!token || !deviceResponse) { + return identityErrorResponse('Passkey token and deviceResponse are required', 'invalid_request', 400); + } + + let asserted: Awaited>; + try { + asserted = await assertAccountPasskeyCredential(request, env, storage, { + token, + deviceResponse, + scope: 'Authentication', + }); + } catch (error) { + await rateLimit.recordFailedLogin(loginIdentifier); + await safeWriteAuditEvent(env, { + actorUserId: null, + action: 'auth.passkey.login.failed', + category: 'auth', + level: 'warn', + targetType: 'accountPasskey', + targetId: null, + metadata: { + grantType, + reason: error instanceof Error ? error.message : 'assertion_failed', + ...auditRequestMetadata(request), + }, + }); + return identityErrorResponse('Passkey is invalid. Try again', 'invalid_grant', 400); + } + + const { user, credential } = asserted; + if (user.status !== 'active') { + await rateLimit.recordFailedLogin(loginIdentifier); + return identityErrorResponse('Account is disabled', 'invalid_grant', 400); + } + + const deviceInfo = readAuthRequestDeviceInfo(body, request); + const deviceSession = await resolveDeviceSession(storage, user.id, deviceInfo); + if (deviceSession) { + await storage.upsertDevice( + user.id, + deviceSession.identifier, + deviceInfo.deviceName, + deviceInfo.deviceType, + deviceSession.sessionStamp + ); + } + + await rateLimit.clearLoginAttempts(loginIdentifier); + + const accessToken = await auth.generateAccessToken(user, deviceSession); + const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); + const accountKeys = buildAccountKeys(user); + const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential); + const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption); + await safeWriteAuditEvent(env, { + actorUserId: user.id, + action: 'auth.passkey.login.success', + category: 'auth', + level: 'info', + targetType: 'accountPasskey', + targetId: credential.id, + metadata: { + grantType, + webSession: shouldUseWebSession(request), + deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier, + deviceType: deviceInfo.deviceType, + ...auditRequestMetadata(request), + }, + }); + + const response: TokenResponse = { + access_token: accessToken, + expires_in: LIMITS.auth.accessTokenTtlSeconds, + token_type: 'Bearer', + ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }), + Key: user.key, + PrivateKey: user.privateKey, + AccountKeys: accountKeys, + accountKeys: accountKeys, + Kdf: user.kdfType, + KdfIterations: user.kdfIterations, + KdfMemory: user.kdfMemory, + KdfParallelism: user.kdfParallelism, + ForcePasswordReset: false, + ResetMasterPassword: false, + MasterPasswordPolicy: { + Object: 'masterPasswordPolicy', + }, + ApiUseKeyConnector: false, + scope: 'api offline_access', + unofficialServer: true, + UserDecryptionOptions: userDecryptionOptions, + userDecryptionOptions: userDecryptionOptions, + }; + + const baseResponse = jsonResponse(response); + return shouldUseWebSession(request) + ? withWebRefreshCookie(request, baseResponse, refreshToken) + : baseResponse; + } else if (grantType === 'client_credentials') { // Login with client credentials const clientId = body.client_id; diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index b3763bf..86cd097 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -10,6 +10,7 @@ import { buildUserDecryptionOptions, } from '../utils/user-decryption'; import { buildDomainsResponse } from '../services/domain-rules'; +import { buildWebAuthnPrfOption } from '../utils/account-passkeys'; // CONTRACT: // /api/sync reuses cipherToResponse() as the single cipher response shaper. @@ -20,13 +21,14 @@ function buildSyncCacheRequest( request: Request, userId: string, revisionDate: string, + accountPasskeyCacheTag: string, excludeDomains: boolean, excludeSends: boolean, preserveRepairableUris: boolean ): Request { const url = new URL(request.url); const cacheUrl = new URL( - `/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}/${preserveRepairableUris ? '1' : '0'}`, + `/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${encodeURIComponent(accountPasskeyCacheTag)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}/${preserveRepairableUris ? '1' : '0'}`, url.origin ); return new Request(cacheUrl.toString(), { method: 'GET' }); @@ -57,8 +59,19 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr return errorResponse('User not found', 404); } - const revisionDate = await storage.getRevisionDate(userId); - const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains, excludeSends, preserveRepairableUris); + const [revisionDate, accountPasskeys] = await Promise.all([ + storage.getRevisionDate(userId), + storage.getAccountPasskeyCredentialsByUserId(userId), + ]); + const accountPasskeyCacheTag = accountPasskeys + .map((credential) => [ + credential.id, + credential.updatedAt, + credential.supportsPrf ? '1' : '0', + credential.encryptedUserKey && credential.encryptedPublicKey && credential.encryptedPrivateKey ? '1' : '0', + ].join(':')) + .join(','); + const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, accountPasskeyCacheTag, excludeDomains, excludeSends, preserveRepairableUris); const cachedResponse = await readSyncCache(cacheRequest); if (cachedResponse) { return cachedResponse; @@ -72,7 +85,10 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId), ]); const accountKeys = buildAccountKeys(user); - const userDecryptionOptions = buildUserDecryptionOptions(user); + const webAuthnPrfOptions = accountPasskeys + .map(buildWebAuthnPrfOption) + .filter((option): option is NonNullable => !!option); + const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null); const profile: ProfileResponse = { id: user.id, @@ -138,6 +154,8 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock, TrustedDeviceOption: null, KeyConnectorOption: null, + WebAuthnPrfOption: webAuthnPrfOptions[0] || null, + WebAuthnPrfOptions: webAuthnPrfOptions, Object: 'userDecryption', }, UserDecryptionOptions: userDecryptionOptions, diff --git a/src/router-authenticated.ts b/src/router-authenticated.ts index 36672a2..2cdcd41 100644 --- a/src/router-authenticated.ts +++ b/src/router-authenticated.ts @@ -66,6 +66,14 @@ import { import { handleAuthenticatedDeviceRoute } from './router-devices'; import { handleAdminRoute } from './router-admin'; import { handleGetDomains, handleUpdateDomains } from './handlers/domains'; +import { + handleCreateAccountPasskeyCredential, + handleDeleteAccountPasskeyCredential, + handleGetAccountPasskeyAttestationOptions, + handleGetAccountPasskeyCredentials, + handleGetAccountPasskeyUpdateAssertionOptions, + handleUpdateAccountPasskeyEncryption, +} from './handlers/account-passkeys'; export async function handleAuthenticatedRoute( request: Request, @@ -131,6 +139,28 @@ export async function handleAuthenticatedRoute( return handleRotateApiKey(request, env, userId); } + if (path === '/api/webauthn' || path === '/webauthn') { + if (method === 'GET') return handleGetAccountPasskeyCredentials(request, env, userId); + if (method === 'POST') return handleCreateAccountPasskeyCredential(request, env, userId); + if (method === 'PUT') return handleUpdateAccountPasskeyEncryption(request, env, userId); + return errorResponse('Method not allowed', 405); + } + + if ((path === '/api/webauthn/attestation-options' || path === '/webauthn/attestation-options') && method === 'POST') { + return handleGetAccountPasskeyAttestationOptions(request, env, userId, currentUser); + } + + if ((path === '/api/webauthn/assertion-options' || path === '/webauthn/assertion-options') && method === 'POST') { + return handleGetAccountPasskeyUpdateAssertionOptions(request, env, userId, currentUser); + } + + const accountPasskeyDeleteMatch = + path.match(/^\/api\/webauthn\/([^/]+)\/delete$/i) || + path.match(/^\/webauthn\/([^/]+)\/delete$/i); + if (accountPasskeyDeleteMatch && method === 'POST') { + return handleDeleteAccountPasskeyCredential(request, env, userId, accountPasskeyDeleteMatch[1], currentUser); + } + if (path === '/api/sync' && method === 'GET') { return handleSync(request, env, userId); } diff --git a/src/router-public.ts b/src/router-public.ts index c6d626d..5f11120 100644 --- a/src/router-public.ts +++ b/src/router-public.ts @@ -9,6 +9,7 @@ import { } from './handlers/sends'; import { handleKnownDevice } from './handlers/devices'; import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity'; +import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys'; import { handleRegister, handleGetPasswordHint, @@ -422,6 +423,12 @@ export async function handlePublicRoute( return handlePrelogin(request, env); } + if (path === '/identity/accounts/webauthn/assertion-options' && method === 'GET') { + const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute); + if (blocked) return blocked; + return handleGetAccountPasskeyAssertionOptions(request, env); + } + if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') { return handleRecoverTwoFactor(request, env); } diff --git a/src/services/audit-events.ts b/src/services/audit-events.ts index 37573ac..4e21947 100644 --- a/src/services/audit-events.ts +++ b/src/services/audit-events.ts @@ -66,6 +66,7 @@ const ALLOWED_METADATA_KEYS = new Set([ 'skippedReason', 'replaceExisting', 'provider', + 'prfStatus', 'fileName', 'fileBytes', 'bytes', diff --git a/src/services/backup-archive.ts b/src/services/backup-archive.ts index dc48959..2c22cd6 100644 --- a/src/services/backup-archive.ts +++ b/src/services/backup-archive.ts @@ -67,6 +67,7 @@ export interface BackupPayload { folders: SqlRow[]; ciphers: SqlRow[]; attachments: SqlRow[]; + webauthn_credentials?: SqlRow[]; }; } @@ -300,6 +301,7 @@ export function validateBackupPayloadContents( const folderRows = ensureRowArray(payload.db.folders, 'folders'); const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers'); const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments'); + const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials'); const externalAttachmentKeys = new Set( options.allowExternalAttachmentBlobs ? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`) @@ -372,6 +374,22 @@ export function validateBackupPayloadContents( throw new Error(`Backup archive is missing required file: attachments/${cipherId}/${id}.bin`); } } + + const accountPasskeyIds = new Set(); + const accountPasskeyCredentialIds = new Set(); + for (const row of accountPasskeyRows) { + const id = String(row.id || '').trim(); + const userId = String(row.user_id || '').trim(); + const credentialId = String(row.credential_id || '').trim(); + const publicKey = String(row.public_key || '').trim(); + if (!id || !userIds.has(userId) || !credentialId || !publicKey) { + throw new Error('Backup archive contains an invalid account passkey row'); + } + if (accountPasskeyIds.has(id)) throw new Error(`Backup archive contains duplicate account passkey id: ${id}`); + if (accountPasskeyCredentialIds.has(credentialId)) throw new Error(`Backup archive contains duplicate account passkey credential id: ${credentialId}`); + accountPasskeyIds.add(id); + accountPasskeyCredentialIds.add(credentialId); + } } export async function buildBackupArchive( @@ -390,7 +408,7 @@ export async function buildBackupArchive( includeAttachments, }); const encoder = new TextEncoder(); - const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([ + const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows] = await Promise.all([ queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'), queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings ORDER BY user_id ASC'), @@ -398,6 +416,7 @@ export async function buildBackupArchive( queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'), + queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'), ]); const exportedConfigRows = sanitizeConfigRowsForExport(configRows); const exportedAttachmentRows = includeAttachments ? attachmentRows : []; @@ -425,6 +444,7 @@ export async function buildBackupArchive( folders: folderRows.length, ciphers: cipherRows.length, attachments: exportedAttachmentRows.length, + webauthn_credentials: accountPasskeyRows.length, }, includes: { attachments: includeAttachments, @@ -447,6 +467,7 @@ export async function buildBackupArchive( folders: folderRows, ciphers: cipherRows, attachments: exportedAttachmentRows, + webauthn_credentials: accountPasskeyRows, }, null, BACKUP_JSON_INDENT)), }; diff --git a/src/services/backup-import.ts b/src/services/backup-import.ts index 22bd09a..d39b80f 100644 --- a/src/services/backup-import.ts +++ b/src/services/backup-import.ts @@ -24,6 +24,7 @@ type BackupTableName = | 'users' | 'domain_settings' | 'user_revisions' + | 'webauthn_credentials' | 'folders' | 'ciphers' | 'attachments'; @@ -33,6 +34,7 @@ const BACKUP_TABLES: BackupTableName[] = [ 'users', 'domain_settings', 'user_revisions', + 'webauthn_credentials', 'folders', 'ciphers', 'attachments', @@ -49,6 +51,7 @@ export interface BackupImportResultBody { users: number; domainSettings: number; userRevisions: number; + webauthnCredentials: number; folders: number; ciphers: number; attachments: number; @@ -168,6 +171,7 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[] 'DELETE FROM attachments', 'DELETE FROM ciphers', 'DELETE FROM folders', + 'DELETE FROM webauthn_credentials', 'DELETE FROM domain_settings', 'DELETE FROM user_revisions', 'DELETE FROM users', @@ -292,6 +296,7 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload[' })), domain_settings: cloneRows(payload.domain_settings || []), user_revisions: cloneRows(payload.user_revisions || []), + webauthn_credentials: cloneRows(payload.webauthn_credentials || []), folders: cloneRows(payload.folders || []), ciphers: cloneRows(payload.ciphers || []).map((row) => ({ ...row, @@ -629,6 +634,16 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us true ) ); + await runInsertBatch( + db, + tableName('webauthn_credentials'), + buildInsertStatements( + db, + tableName('webauthn_credentials'), + ['id', 'user_id', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'], + payload.webauthn_credentials || [] + ) + ); await runInsertBatch( db, tableName('folders'), @@ -697,6 +712,7 @@ export async function importBackupArchiveBytes( users: (db.users || []).length, domain_settings: (db.domain_settings || []).length, user_revisions: (db.user_revisions || []).length, + webauthn_credentials: (db.webauthn_credentials || []).length, folders: (db.folders || []).length, ciphers: (db.ciphers || []).length, attachments: (db.attachments || []).length, @@ -719,6 +735,7 @@ export async function importBackupArchiveBytes( users: (db.users || []).length, domain_settings: (db.domain_settings || []).length, user_revisions: (db.user_revisions || []).length, + webauthn_credentials: (db.webauthn_credentials || []).length, folders: (db.folders || []).length, ciphers: (db.ciphers || []).length, attachments: restored.restoredAttachments.length, @@ -759,6 +776,7 @@ export async function importBackupArchiveBytes( users: (db.users || []).length, domainSettings: (db.domain_settings || []).length, userRevisions: (db.user_revisions || []).length, + webauthnCredentials: (db.webauthn_credentials || []).length, folders: (db.folders || []).length, ciphers: (db.ciphers || []).length, attachments: restored.restoredAttachments.length, @@ -835,6 +853,7 @@ export async function importRemoteBackupArchiveBytes( users: (db.users || []).length, domain_settings: (db.domain_settings || []).length, user_revisions: (db.user_revisions || []).length, + webauthn_credentials: (db.webauthn_credentials || []).length, folders: (db.folders || []).length, ciphers: (db.ciphers || []).length, attachments: (db.attachments || []).length, @@ -857,6 +876,7 @@ export async function importRemoteBackupArchiveBytes( users: (db.users || []).length, domain_settings: (db.domain_settings || []).length, user_revisions: (db.user_revisions || []).length, + webauthn_credentials: (db.webauthn_credentials || []).length, folders: (db.folders || []).length, ciphers: (db.ciphers || []).length, attachments: restored.restoredAttachments.length, @@ -903,6 +923,7 @@ export async function importRemoteBackupArchiveBytes( users: (db.users || []).length, domainSettings: (db.domain_settings || []).length, userRevisions: (db.user_revisions || []).length, + webauthnCredentials: (db.webauthn_credentials || []).length, folders: (db.folders || []).length, ciphers: (db.ciphers || []).length, attachments: restored.restoredAttachments.length, diff --git a/src/services/storage-account-passkey-repo.ts b/src/services/storage-account-passkey-repo.ts new file mode 100644 index 0000000..57ea45d --- /dev/null +++ b/src/services/storage-account-passkey-repo.ts @@ -0,0 +1,331 @@ +import type { AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential } from '../types'; + +type SafeBindFn = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement; + +let accountPasskeySchemaReady = false; + +const ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS = [ + { name: 'id', sql: 'id TEXT' }, + { name: 'user_id', sql: "user_id TEXT NOT NULL DEFAULT ''" }, + { name: 'name', sql: "name TEXT NOT NULL DEFAULT 'Account passkey'" }, + { name: 'public_key', sql: "public_key TEXT NOT NULL DEFAULT ''" }, + { name: 'credential_id', sql: "credential_id TEXT NOT NULL DEFAULT ''" }, + { name: 'counter', sql: 'counter INTEGER NOT NULL DEFAULT 0' }, + { name: 'type', sql: 'type TEXT' }, + { name: 'aa_guid', sql: 'aa_guid TEXT' }, + { name: 'transports', sql: 'transports TEXT' }, + { name: 'encrypted_user_key', sql: 'encrypted_user_key TEXT' }, + { name: 'encrypted_public_key', sql: 'encrypted_public_key TEXT' }, + { name: 'encrypted_private_key', sql: 'encrypted_private_key TEXT' }, + { name: 'supports_prf', sql: 'supports_prf INTEGER NOT NULL DEFAULT 0' }, + { name: 'created_at', sql: "created_at TEXT NOT NULL DEFAULT ''" }, + { name: 'updated_at', sql: "updated_at TEXT NOT NULL DEFAULT ''" }, +] as const; + +const ACCOUNT_PASSKEY_CHALLENGE_COLUMNS = [ + 'challenge_hash', + 'scope', + 'user_id', + 'expires_at', + 'used_at', + 'created_at', +] as const; + +async function tableColumns(db: D1Database, tableName: 'webauthn_credentials' | 'webauthn_challenges'): Promise> { + const result = await db.prepare(`PRAGMA table_info(${tableName})`).all<{ name: string }>(); + return new Set((result.results || []).map((row) => String(row.name || '').trim()).filter(Boolean)); +} + +async function ensureAccountPasskeySchema(db: D1Database): Promise { + if (accountPasskeySchemaReady) return; + + await db + .prepare( + '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)' + ) + .run(); + let credentialColumns = await tableColumns(db, 'webauthn_credentials'); + for (const column of ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS) { + if (!credentialColumns.has(column.name)) { + await db.prepare(`ALTER TABLE webauthn_credentials ADD COLUMN ${column.sql}`).run(); + } + } + credentialColumns = await tableColumns(db, 'webauthn_credentials'); + if (!credentialColumns.has('credential_id')) { + throw new Error('webauthn_credentials schema is missing credential_id'); + } + await db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_id ON webauthn_credentials(id)').run(); + await db.prepare('CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)').run(); + await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)').run(); + await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)').run(); + + await db + .prepare( + 'CREATE TABLE IF NOT EXISTS webauthn_challenges (' + + 'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)' + ) + .run(); + const challengeColumns = await tableColumns(db, 'webauthn_challenges'); + const challengeSchemaComplete = ACCOUNT_PASSKEY_CHALLENGE_COLUMNS.every((column) => challengeColumns.has(column)); + if (!challengeSchemaComplete) { + await db.prepare('DROP TABLE IF EXISTS webauthn_challenges').run(); + await db + .prepare( + 'CREATE TABLE webauthn_challenges (' + + 'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)' + ) + .run(); + } + await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)').run(); + await db.prepare('CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope ON webauthn_challenges(user_id, scope)').run(); + + accountPasskeySchemaReady = true; +} + +function parseTransports(value: string | null): string[] | null { + if (!value) return null; + try { + const parsed = JSON.parse(value); + if (!Array.isArray(parsed)) return null; + return parsed.map((item) => String(item || '').trim()).filter(Boolean); + } catch { + return null; + } +} + +function mapCredentialRow(row: { + id: string; + user_id: string; + name: string; + public_key: string; + credential_id: string; + counter: number; + type: string | null; + aa_guid: string | null; + transports: string | null; + encrypted_user_key: string | null; + encrypted_public_key: string | null; + encrypted_private_key: string | null; + supports_prf: number; + created_at: string; + updated_at: string; +}): AccountPasskeyCredential { + return { + id: row.id, + userId: row.user_id, + name: row.name, + publicKey: row.public_key, + credentialId: row.credential_id, + counter: Number(row.counter || 0), + type: row.type ?? null, + aaGuid: row.aa_guid ?? null, + transports: parseTransports(row.transports), + encryptedUserKey: row.encrypted_user_key ?? null, + encryptedPublicKey: row.encrypted_public_key ?? null, + encryptedPrivateKey: row.encrypted_private_key ?? null, + supportsPrf: !!row.supports_prf, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function mapChallengeRow(row: { + challenge_hash: string; + scope: AccountPasskeyChallengeScope; + user_id: string | null; + expires_at: number; + used_at: number | null; + created_at: number; +}): AccountPasskeyChallenge { + return { + challengeHash: row.challenge_hash, + scope: row.scope, + userId: row.user_id ?? null, + expiresAt: Number(row.expires_at || 0), + usedAt: row.used_at == null ? null : Number(row.used_at), + createdAt: Number(row.created_at || 0), + }; +} + +export async function saveAccountPasskeyCredential( + db: D1Database, + safeBind: SafeBindFn, + credential: AccountPasskeyCredential +): Promise { + await ensureAccountPasskeySchema(db); + await safeBind( + db.prepare( + 'INSERT INTO webauthn_credentials(' + + 'id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, ' + + 'encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at' + + ') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + + 'ON CONFLICT(id) DO UPDATE SET ' + + 'name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' + + 'type=excluded.type, aa_guid=excluded.aa_guid, transports=excluded.transports, encrypted_user_key=excluded.encrypted_user_key, ' + + 'encrypted_public_key=excluded.encrypted_public_key, encrypted_private_key=excluded.encrypted_private_key, supports_prf=excluded.supports_prf, updated_at=excluded.updated_at' + ), + credential.id, + credential.userId, + credential.name, + credential.publicKey, + credential.credentialId, + credential.counter, + credential.type, + credential.aaGuid, + credential.transports ? JSON.stringify(credential.transports) : null, + credential.encryptedUserKey, + credential.encryptedPublicKey, + credential.encryptedPrivateKey, + credential.supportsPrf ? 1 : 0, + credential.createdAt, + credential.updatedAt + ).run(); +} + +export async function listAccountPasskeyCredentialsByUserId( + db: D1Database, + userId: string +): Promise { + await ensureAccountPasskeySchema(db); + const rows = await db + .prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at ASC') + .bind(userId) + .all(); + return (rows.results || []).map(mapCredentialRow); +} + +export async function getAccountPasskeyCredentialById( + db: D1Database, + userId: string, + id: string +): Promise { + await ensureAccountPasskeySchema(db); + const row = await db + .prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? AND id = ? LIMIT 1') + .bind(userId, id) + .first(); + return row ? mapCredentialRow(row) : null; +} + +export async function getAccountPasskeyCredentialByCredentialId( + db: D1Database, + credentialId: string +): Promise { + await ensureAccountPasskeySchema(db); + const row = await db + .prepare('SELECT * FROM webauthn_credentials WHERE credential_id = ? LIMIT 1') + .bind(credentialId) + .first(); + return row ? mapCredentialRow(row) : null; +} + +export async function countAccountPasskeyCredentialsByUserId( + db: D1Database, + userId: string +): Promise { + await ensureAccountPasskeySchema(db); + const row = await db + .prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ?') + .bind(userId) + .first<{ count: number }>(); + return Number(row?.count || 0); +} + +export async function updateAccountPasskeyCounter( + db: D1Database, + userId: string, + credentialId: string, + counter: number, + updatedAt: string +): Promise { + await ensureAccountPasskeySchema(db); + await db + .prepare('UPDATE webauthn_credentials SET counter = ?, updated_at = ? WHERE user_id = ? AND credential_id = ?') + .bind(counter, updatedAt, userId, credentialId) + .run(); +} + +export async function updateAccountPasskeyEncryption( + db: D1Database, + userId: string, + credentialId: string, + encryptedUserKey: string, + encryptedPublicKey: string, + encryptedPrivateKey: string, + updatedAt: string +): Promise { + await ensureAccountPasskeySchema(db); + const result = await db + .prepare( + 'UPDATE webauthn_credentials SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, supports_prf = 1, updated_at = ? ' + + 'WHERE user_id = ? AND credential_id = ?' + ) + .bind(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey, updatedAt, userId, credentialId) + .run(); + return Number(result.meta.changes || 0) > 0; +} + +export async function deleteAccountPasskeyCredential( + db: D1Database, + userId: string, + id: string +): Promise { + await ensureAccountPasskeySchema(db); + const result = await db + .prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ?') + .bind(userId, id) + .run(); + return Number(result.meta.changes || 0) > 0; +} + +export async function saveAccountPasskeyChallenge( + db: D1Database, + challenge: AccountPasskeyChallenge +): Promise { + await ensureAccountPasskeySchema(db); + await db.prepare('DELETE FROM webauthn_challenges WHERE expires_at < ? OR used_at IS NOT NULL').bind(Date.now()).run(); + await db + .prepare( + 'INSERT INTO webauthn_challenges(challenge_hash, scope, user_id, expires_at, used_at, created_at) VALUES(?, ?, ?, ?, ?, ?) ' + + 'ON CONFLICT(challenge_hash) DO UPDATE SET scope=excluded.scope, user_id=excluded.user_id, expires_at=excluded.expires_at, used_at=excluded.used_at, created_at=excluded.created_at' + ) + .bind( + challenge.challengeHash, + challenge.scope, + challenge.userId, + challenge.expiresAt, + challenge.usedAt, + challenge.createdAt + ) + .run(); +} + +export async function consumeAccountPasskeyChallenge( + db: D1Database, + challengeHash: string, + scope: AccountPasskeyChallengeScope, + userId: string | null, + nowMs: number +): Promise { + await ensureAccountPasskeySchema(db); + const row = await db + .prepare('SELECT * FROM webauthn_challenges WHERE challenge_hash = ? AND scope = ? LIMIT 1') + .bind(challengeHash, scope) + .first(); + if (!row) return null; + const challenge = mapChallengeRow(row); + if (challenge.usedAt != null || challenge.expiresAt < nowMs) return null; + if (userId !== null && challenge.userId !== userId) return null; + if (userId === null && challenge.userId !== null) return null; + + const result = await db + .prepare('UPDATE webauthn_challenges SET used_at = ? WHERE challenge_hash = ? AND used_at IS NULL') + .bind(nowMs, challengeHash) + .run(); + if (Number(result.meta.changes || 0) <= 0) return null; + return { ...challenge, usedAt: nowMs }; +} diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts index 6e801cb..35e5043 100644 --- a/src/services/storage-schema.ts +++ b/src/services/storage-schema.ts @@ -114,6 +114,20 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)', + '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_credential_id ON webauthn_credentials(credential_id)', + 'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)', + 'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)', + + 'CREATE TABLE IF NOT EXISTS webauthn_challenges (' + + 'challenge_hash TEXT PRIMARY KEY, scope TEXT NOT NULL, user_id TEXT, expires_at INTEGER NOT NULL, used_at INTEGER, created_at INTEGER NOT NULL)', + 'CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)', + 'CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user_scope ON webauthn_challenges(user_id, scope)', + 'CREATE TABLE IF NOT EXISTS login_attempts_ip (' + 'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)', diff --git a/src/services/storage.ts b/src/services/storage.ts index 6cf1208..4248150 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,4 +1,4 @@ -import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain } from '../types'; +import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential } from '../types'; import { LIMITS } from '../config/limits'; import { ensureStorageSchema } from './storage-schema'; import { @@ -115,6 +115,18 @@ import { getUserDomainSettings as getStoredUserDomainSettings, saveUserDomainSettings as saveStoredUserDomainSettings, } from './storage-domain-rules-repo'; +import { + consumeAccountPasskeyChallenge as consumeStoredAccountPasskeyChallenge, + countAccountPasskeyCredentialsByUserId as countStoredAccountPasskeyCredentialsByUserId, + deleteAccountPasskeyCredential as deleteStoredAccountPasskeyCredential, + getAccountPasskeyCredentialByCredentialId as findStoredAccountPasskeyCredentialByCredentialId, + getAccountPasskeyCredentialById as findStoredAccountPasskeyCredentialById, + listAccountPasskeyCredentialsByUserId as listStoredAccountPasskeyCredentialsByUserId, + saveAccountPasskeyChallenge as saveStoredAccountPasskeyChallenge, + saveAccountPasskeyCredential as saveStoredAccountPasskeyCredential, + updateAccountPasskeyCounter as updateStoredAccountPasskeyCounter, + updateAccountPasskeyEncryption as updateStoredAccountPasskeyEncryption, +} from './storage-account-passkey-repo'; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; @@ -122,7 +134,8 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // changes. Existing D1 installs only rerun ensureStorageSchema() when this value // differs from config.schema.version. -const STORAGE_SCHEMA_VERSION = '2026-05-14-lightweight-audit-logs'; +const STORAGE_SCHEMA_VERSION = '2026-06-09-account-passkeys'; +const REQUIRED_ACCOUNT_PASSKEY_TABLES = ['webauthn_credentials', 'webauthn_challenges'] as const; // D1-backed storage. // Contract: @@ -153,6 +166,16 @@ export class StorageService { return stmt.bind(...values.map(v => v === undefined ? null : v)); } + private async hasAccountPasskeyTables(): Promise { + const placeholders = REQUIRED_ACCOUNT_PASSKEY_TABLES.map(() => '?').join(', '); + const result = await this.db + .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${placeholders})`) + .bind(...REQUIRED_ACCOUNT_PASSKEY_TABLES) + .all<{ name: string }>(); + const found = new Set((result.results || []).map((row) => row.name)); + return REQUIRED_ACCOUNT_PASSKEY_TABLES.every((table) => found.has(table)); + } + private sqlChunkSize(fixedBindCount: number): number { return Math.max( 1, @@ -196,7 +219,10 @@ export class StorageService { await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run(); const schemaVersion = await getStoredConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY); - if (schemaVersion !== STORAGE_SCHEMA_VERSION) { + const schemaMissingRequiredTables = schemaVersion === STORAGE_SCHEMA_VERSION + ? !(await this.hasAccountPasskeyTables()) + : true; + if (schemaVersion !== STORAGE_SCHEMA_VERSION || schemaMissingRequiredTables) { await ensureStorageSchema(this.db); await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION); } @@ -323,6 +349,73 @@ export class StorageService { await this.updateRevisionDate(userId); } + // --- Account passkeys / WebAuthn login credentials --- + + async saveAccountPasskeyCredential(credential: AccountPasskeyCredential): Promise { + await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential); + } + + async getAccountPasskeyCredentialsByUserId(userId: string): Promise { + return listStoredAccountPasskeyCredentialsByUserId(this.db, userId); + } + + async getAccountPasskeyCredentialById(userId: string, id: string): Promise { + return findStoredAccountPasskeyCredentialById(this.db, userId, id); + } + + async getAccountPasskeyCredentialByCredentialId(credentialId: string): Promise { + return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId); + } + + async countAccountPasskeyCredentialsByUserId(userId: string): Promise { + return countStoredAccountPasskeyCredentialsByUserId(this.db, userId); + } + + async updateAccountPasskeyCounter( + userId: string, + credentialId: string, + counter: number, + updatedAt: string = new Date().toISOString() + ): Promise { + await updateStoredAccountPasskeyCounter(this.db, userId, credentialId, counter, updatedAt); + } + + async updateAccountPasskeyEncryption( + userId: string, + credentialId: string, + encryptedUserKey: string, + encryptedPublicKey: string, + encryptedPrivateKey: string, + updatedAt: string = new Date().toISOString() + ): Promise { + return updateStoredAccountPasskeyEncryption( + this.db, + userId, + credentialId, + encryptedUserKey, + encryptedPublicKey, + encryptedPrivateKey, + updatedAt + ); + } + + async deleteAccountPasskeyCredential(userId: string, id: string): Promise { + return deleteStoredAccountPasskeyCredential(this.db, userId, id); + } + + async saveAccountPasskeyChallenge(challenge: AccountPasskeyChallenge): Promise { + await saveStoredAccountPasskeyChallenge(this.db, challenge); + } + + async consumeAccountPasskeyChallenge( + challengeHash: string, + scope: AccountPasskeyChallengeScope, + userId: string | null, + nowMs: number = Date.now() + ): Promise { + return consumeStoredAccountPasskeyChallenge(this.db, challengeHash, scope, userId, nowMs); + } + // --- Ciphers --- async getCipher(id: string): Promise { diff --git a/src/types/index.ts b/src/types/index.ts index ec03972..448f4d1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -11,6 +11,9 @@ export interface Env { // Optional fallback for attachment/send file storage (no credit card required). ATTACHMENTS_KV?: KVNamespace; JWT_SECRET: string; + WEBAUTHN_RP_ID?: string; + WEBAUTHN_RP_NAME?: string; + WEBAUTHN_ALLOWED_ORIGINS?: string; } export type UserRole = 'admin' | 'user'; @@ -234,6 +237,37 @@ export interface Device { updatedAt: string; } +export type AccountPasskeyPrfStatus = 0 | 1 | 2; + +export interface AccountPasskeyCredential { + id: string; + userId: string; + name: string; + publicKey: string; + credentialId: string; + counter: number; + type: string | null; + aaGuid: string | null; + transports: string[] | null; + encryptedUserKey: string | null; + encryptedPublicKey: string | null; + encryptedPrivateKey: string | null; + supportsPrf: boolean; + createdAt: string; + updatedAt: string; +} + +export type AccountPasskeyChallengeScope = 'Authentication' | 'CreateCredential' | 'UpdateKeySet'; + +export interface AccountPasskeyChallenge { + challengeHash: string; + scope: AccountPasskeyChallengeScope; + userId: string | null; + expiresAt: number; + usedAt: number | null; + createdAt: number; +} + export interface DevicePendingAuthRequest { id: string; creationDate: string; @@ -372,6 +406,14 @@ export interface MasterPasswordUnlock { Object: string; } +export interface WebAuthnPrfDecryptionOption { + EncryptedPrivateKey: string; + EncryptedUserKey: string; + CredentialId: string; + Transports: string[]; + Object?: string; +} + export interface UserDecryptionOptions { HasMasterPassword: boolean; Object: string; @@ -379,6 +421,7 @@ export interface UserDecryptionOptions { MasterPasswordUnlock: MasterPasswordUnlock; TrustedDeviceOption: null; KeyConnectorOption: null; + WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null; } // API Response types @@ -498,7 +541,8 @@ export interface SyncResponse { MasterPasswordUnlock: MasterPasswordUnlock | null; TrustedDeviceOption?: null; KeyConnectorOption?: null; - WebAuthnPrfOption?: null; + WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null; + WebAuthnPrfOptions?: WebAuthnPrfDecryptionOption[]; Object?: string; } | null; // PascalCase for desktop/browser clients diff --git a/src/utils/account-passkeys.ts b/src/utils/account-passkeys.ts new file mode 100644 index 0000000..07a47df --- /dev/null +++ b/src/utils/account-passkeys.ts @@ -0,0 +1,269 @@ +import type { + AuthenticationResponseJSON, + AuthenticatorTransportFuture, + RegistrationResponseJSON, + WebAuthnCredential, +} from '@simplewebauthn/server'; +import type { + AccountPasskeyChallengeScope, + AccountPasskeyCredential, + AccountPasskeyPrfStatus, + Env, + WebAuthnPrfDecryptionOption, +} from '../types'; +import { base64UrlToBytes, bytesToBase64Url } from './passkey'; + +const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1'; +const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000; +const ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS = 7 * 60 * 1000; +const DEFAULT_RP_NAME = 'NodeWarden'; + +interface AccountPasskeyTokenPayload { + typ: typeof ACCOUNT_PASSKEY_TOKEN_TYPE; + scope: AccountPasskeyChallengeScope; + challenge: string; + userId: string | null; + rpId: string; + iat: number; + exp: number; +} + +function textBytes(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +async function importHmacKey(secret: string): Promise { + return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']); +} + +async function hmacSha256(secret: string, data: string): Promise { + const key = await importHmacKey(secret); + return new Uint8Array(await crypto.subtle.sign('HMAC', key, textBytes(data))); +} + +function encodeJson(value: unknown): string { + return bytesToBase64Url(textBytes(JSON.stringify(value))); +} + +function decodeJson(value: string): T | null { + try { + return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))) as T; + } catch { + return null; + } +} + +export async function sha256Base64Url(value: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', textBytes(value)); + return bytesToBase64Url(new Uint8Array(digest)); +} + +export function accountPasskeyTokenTtlMs(scope: AccountPasskeyChallengeScope): number { + return scope === 'CreateCredential' ? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS : ACCOUNT_PASSKEY_TOKEN_TTL_MS; +} + +export async function createAccountPasskeyToken( + env: Env, + input: { + scope: AccountPasskeyChallengeScope; + challenge: string; + userId?: string | null; + rpId: string; + ttlMs?: number; + } +): Promise { + const now = Date.now(); + const payload: AccountPasskeyTokenPayload = { + typ: ACCOUNT_PASSKEY_TOKEN_TYPE, + scope: input.scope, + challenge: input.challenge, + userId: input.userId ?? null, + rpId: input.rpId, + iat: now, + exp: now + (input.ttlMs ?? accountPasskeyTokenTtlMs(input.scope)), + }; + const header = { alg: 'HS256', typ: 'JWT' }; + const data = `${encodeJson(header)}.${encodeJson(payload)}`; + const signature = bytesToBase64Url(await hmacSha256(env.JWT_SECRET, data)); + return `${data}.${signature}`; +} + +export async function verifyAccountPasskeyToken( + env: Env, + token: string, + scope: AccountPasskeyChallengeScope +): Promise { + try { + const parts = String(token || '').split('.'); + if (parts.length !== 3) return null; + const data = `${parts[0]}.${parts[1]}`; + const expected = await hmacSha256(env.JWT_SECRET, data); + const actual = base64UrlToBytes(parts[2]); + if (actual.length !== expected.length) return null; + let diff = 0; + for (let i = 0; i < actual.length; i += 1) diff |= actual[i] ^ expected[i]; + if (diff !== 0) return null; + + const payload = decodeJson(parts[1]); + if (!payload || payload.typ !== ACCOUNT_PASSKEY_TOKEN_TYPE || payload.scope !== scope) return null; + if (!payload.challenge || !payload.rpId || !Number.isFinite(payload.exp)) return null; + if (payload.exp < Date.now()) return null; + return payload; + } catch { + return null; + } +} + +export function getAccountPasskeyRpConfig(request: Request, env: Env): { rpId: string; rpName: string; origins: string[] } { + const url = new URL(request.url); + const configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim(); + const rpId = configuredRpId || url.hostname; + const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME; + const configuredOrigins = String(env.WEBAUTHN_ALLOWED_ORIGINS || '') + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean); + const origins = new Set([url.origin, ...configuredOrigins]); + const requestOrigin = request.headers.get('Origin'); + if ( + requestOrigin + && ( + requestOrigin.startsWith('chrome-extension://') + || requestOrigin.startsWith('moz-extension://') + || requestOrigin.startsWith('safari-web-extension://') + ) + ) { + origins.add(requestOrigin); + } + return { rpId, rpName, origins: Array.from(origins) }; +} + +export function userIdToWebAuthnUserId(userId: string): Uint8Array { + return textBytes(userId); +} + +export function userHandleToUserId(userHandle: string | undefined): string | null { + if (!userHandle) return null; + try { + const decoded = new TextDecoder().decode(base64UrlToBytes(userHandle)); + return decoded.trim() || null; + } catch { + return null; + } +} + +export function accountPasskeyPrfStatus(credential: Pick): AccountPasskeyPrfStatus { + if (!credential.supportsPrf) return 2; + if (credential.encryptedUserKey && credential.encryptedPublicKey && credential.encryptedPrivateKey) return 0; + return 1; +} + +export function buildWebAuthnPrfOption( + credential: AccountPasskeyCredential +): WebAuthnPrfDecryptionOption | null { + if (accountPasskeyPrfStatus(credential) !== 0) return null; + return { + EncryptedPrivateKey: credential.encryptedPrivateKey!, + EncryptedUserKey: credential.encryptedUserKey!, + CredentialId: credential.credentialId, + Transports: credential.transports || [], + Object: 'webAuthnPrfDecryptionOption', + }; +} + +export function accountPasskeyCredentialToResponse(credential: AccountPasskeyCredential): Record { + const prfStatus = accountPasskeyPrfStatus(credential); + return { + Id: credential.id, + id: credential.id, + Name: credential.name, + name: credential.name, + PrfStatus: prfStatus, + prfStatus, + EncryptedPublicKey: credential.encryptedPublicKey, + encryptedPublicKey: credential.encryptedPublicKey, + EncryptedUserKey: credential.encryptedUserKey, + encryptedUserKey: credential.encryptedUserKey, + CreationDate: credential.createdAt, + RevisionDate: credential.updatedAt, + Object: 'webauthnCredential', + object: 'webauthnCredential', + }; +} + +export function toSimpleWebAuthnCredential(credential: AccountPasskeyCredential): WebAuthnCredential { + return { + id: credential.credentialId, + publicKey: Uint8Array.from(base64UrlToBytes(credential.publicKey)), + counter: credential.counter, + transports: (credential.transports || undefined) as AuthenticatorTransportFuture[] | undefined, + }; +} + +export function normalizeRegistrationResponse(raw: unknown): RegistrationResponseJSON | null { + const input = raw && typeof raw === 'object' ? raw as Record : null; + const response = input?.response && typeof input.response === 'object' ? input.response as Record : null; + if (!input || !response) return null; + const clientDataJSON = response.clientDataJSON || response.clientDataJson; + if (!input.id || !input.rawId || !clientDataJSON || !response.attestationObject) return null; + return { + id: String(input.id), + rawId: String(input.rawId), + type: 'public-key', + authenticatorAttachment: input.authenticatorAttachment, + clientExtensionResults: input.clientExtensionResults || input.extensions || {}, + response: { + attestationObject: String(response.attestationObject), + clientDataJSON: String(clientDataJSON), + authenticatorData: response.authenticatorData ? String(response.authenticatorData) : undefined, + transports: Array.isArray(response.transports) ? response.transports.map(String) as AuthenticatorTransportFuture[] : undefined, + publicKey: response.publicKey ? String(response.publicKey) : undefined, + publicKeyAlgorithm: typeof response.publicKeyAlgorithm === 'number' ? response.publicKeyAlgorithm : undefined, + }, + }; +} + +export function normalizeAuthenticationResponse(raw: unknown): AuthenticationResponseJSON | null { + const input = raw && typeof raw === 'object' ? raw as Record : null; + const response = input?.response && typeof input.response === 'object' ? input.response as Record : null; + if (!input || !response) return null; + const clientDataJSON = response.clientDataJSON || response.clientDataJson; + if (!input.id || !input.rawId || !clientDataJSON || !response.authenticatorData || !response.signature) return null; + return { + id: String(input.id), + rawId: String(input.rawId), + type: 'public-key', + authenticatorAttachment: input.authenticatorAttachment, + clientExtensionResults: input.clientExtensionResults || input.extensions || {}, + response: { + authenticatorData: String(response.authenticatorData), + clientDataJSON: String(clientDataJSON), + signature: String(response.signature), + userHandle: response.userHandle ? String(response.userHandle) : undefined, + }, + }; +} + +export function normalizeAccountPasskeyName(value: unknown): string { + const normalized = String(value || '').trim(); + return (normalized || 'Account passkey').slice(0, 128); +} + +export function normalizeTransports(value: unknown): string[] | null { + if (!Array.isArray(value)) return null; + const transports = value.map((item) => String(item || '').trim()).filter(Boolean); + return transports.length ? transports.slice(0, 12) : null; +} + +export function isSerializedEncString(value: unknown): value is string { + const text = String(value || '').trim(); + if (!text) return false; + const parts = text.split('.'); + if (parts.length !== 2) return false; + const type = Number(parts[0]); + const bodyParts = parts[1].split('|'); + if (type === 2) return bodyParts.length === 3 && bodyParts.every(Boolean); + if (type === 3 || type === 4) return bodyParts.length === 1 && !!bodyParts[0]; + if (type === 5 || type === 6) return bodyParts.length === 2 && bodyParts.every(Boolean); + return false; +} diff --git a/src/utils/user-decryption.ts b/src/utils/user-decryption.ts index 5baf9f6..497b783 100644 --- a/src/utils/user-decryption.ts +++ b/src/utils/user-decryption.ts @@ -1,4 +1,4 @@ -import { User, UserDecryptionOptions } from '../types'; +import { User, UserDecryptionOptions, WebAuthnPrfDecryptionOption } from '../types'; function normalizeOptionalPublicKey(value: unknown): string { if (value == null) return ''; @@ -40,7 +40,8 @@ export function buildMasterPasswordUnlock( } export function buildUserDecryptionOptions( - user: Pick + user: Pick, + webAuthnPrfOption: WebAuthnPrfDecryptionOption | null = null ): UserDecryptionOptions { return { HasMasterPassword: true, @@ -48,6 +49,7 @@ export function buildUserDecryptionOptions( MasterPasswordUnlock: buildMasterPasswordUnlock(user), TrustedDeviceOption: null, KeyConnectorOption: null, + WebAuthnPrfOption: webAuthnPrfOption, }; } diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 0b9e078..1234eeb 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -37,13 +37,16 @@ import { bootstrapAppSession, type CompletedLogin, readInitialAppBootstrapState, + completePasskeyPasswordLogin, performPasswordLogin, + performPasskeyLogin, performRecoverTwoFactorLogin, performRegistration, performTotpLogin, hydrateLockedSession, performUnlock, type JwtUnsafeReason, + type PendingPasskeyPassword, type PendingTotp, } from '@/lib/app-auth'; import useAccountSecurityActions from '@/hooks/useAccountSecurityActions'; @@ -170,7 +173,7 @@ export default function App() { [initialBootstrap] ); const queryClient = useQueryClient(); - const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null); + const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'passkey' | 'register' | 'unlock' | null>(null); const [location, navigate] = useLocation(); const [phase, setPhase] = useState(initialBootstrap.phase); const [session, setSessionState] = useState(initialBootstrap.session); @@ -201,6 +204,8 @@ export default function App() { const [unlockPassword, setUnlockPassword] = useState(''); const [pendingTotp, setPendingTotp] = useState(null); const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null); + const [pendingPasskeyPassword, setPendingPasskeyPassword] = useState(null); + const [passkeyPassword, setPasskeyPassword] = useState(''); const [totpCode, setTotpCode] = useState(''); const [rememberDevice, setRememberDevice] = useState(true); const [totpSubmitting, setTotpSubmitting] = useState(false); @@ -480,7 +485,9 @@ export default function App() { setUnlockPreparing(false); setPendingTotp(null); setPendingTotpMode(null); + setPendingPasskeyPassword(null); setTotpCode(''); + setPasskeyPassword(''); setUnlockPassword(''); setPhase('app'); if (location === '/' || location === '/login' || location === '/register' || location === '/lock') { @@ -535,6 +542,51 @@ export default function App() { } } + async function handlePasskeyLogin() { + if (pendingAuthAction) return; + if (IS_DEMO_MODE) { + pushToast('warning', t('txt_demo_readonly_message')); + return; + } + setPendingAuthAction('passkey'); + try { + const result = await performPasskeyLogin(defaultKdfIterations); + if (result.kind === 'success') { + await finalizeLogin(result.login); + return; + } + if (result.kind === 'password') { + setPendingPasskeyPassword(result.pendingPasskeyPassword); + setLoginValues({ email: result.pendingPasskeyPassword.email, password: '' }); + setPasskeyPassword(''); + pushToast('warning', t('txt_passkey_requires_master_password')); + return; + } + pushToast('error', result.message || t('txt_login_failed')); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : t('txt_login_failed')); + } finally { + setPendingAuthAction(null); + } + } + + async function handlePasskeyPasswordLogin() { + if (pendingAuthAction || !pendingPasskeyPassword) return; + if (!passkeyPassword) { + pushToast('error', t('txt_please_input_master_password')); + return; + } + setPendingAuthAction('login'); + try { + const login = await completePasskeyPasswordLogin(pendingPasskeyPassword, passkeyPassword); + await finalizeLogin(login); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : t('txt_unlock_failed_master_password_is_incorrect')); + } finally { + setPendingAuthAction(null); + } + } + async function handleTotpVerify() { if (totpSubmitting) return; if (!pendingTotp) return; @@ -1354,6 +1406,7 @@ export default function App() { const accountSecurityActions = useAccountSecurityActions({ authedFetch, profile, + session, defaultKdfIterations, disableTotpPassword, clearDisableTotpDialog: () => { @@ -1540,6 +1593,10 @@ export default function App() { onGetRecoveryCode: accountSecurityActions.getRecoveryCode, onGetApiKey: accountSecurityActions.getApiKey, onRotateApiKey: accountSecurityActions.rotateApiKey, + onListAccountPasskeys: accountSecurityActions.listAccountPasskeys, + onCreateAccountPasskey: accountSecurityActions.createAccountPasskey, + onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock, + onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey, onLockTimeoutChange: setLockTimeoutMinutes, onSessionTimeoutActionChange: setSessionTimeoutAction, onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices, @@ -1650,18 +1707,25 @@ export default function App() { unlockReady={!!session?.email} unlockPreparing={unlockPreparing} loginValues={loginValues} + pendingPasskeyPasswordEmail={pendingPasskeyPassword?.email || null} + passkeyPassword={passkeyPassword} registerValues={registerValues} registrationInviteRequired={registrationInviteRequired} unlockPassword={unlockPassword} emailForLock={profile?.email || session?.email || ''} loginHintLoading={loginHintState.loading} onChangeLogin={setLoginValues} + onChangePasskeyPassword={setPasskeyPassword} onChangeRegister={setRegisterValues} onChangeUnlock={setUnlockPassword} onSubmitLogin={() => void handleLogin()} + onSubmitPasskey={() => void handlePasskeyLogin()} + onSubmitPasskeyPassword={() => void handlePasskeyPasswordLogin()} onSubmitRegister={() => void handleRegister()} onSubmitUnlock={() => void handleUnlock()} onGotoLogin={() => { + setPendingPasskeyPassword(null); + setPasskeyPassword(''); setPhase('login'); navigate('/login'); }} @@ -1673,6 +1737,8 @@ export default function App() { if (inviteCodeFromUrl) { setRegisterValues((prev) => ({ ...prev, inviteCode: inviteCodeFromUrl })); } + setPendingPasskeyPassword(null); + setPasskeyPassword(''); setPhase('register'); navigate('/register'); }} diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index 8601e5c..fc0b91f 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -8,7 +8,7 @@ import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSett import type { AuditLogFilters } from '@/lib/api/admin'; import type { CiphersImportPayload } from '@/lib/api/vault'; import { t } from '@/lib/i18n'; -import type { AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types'; +import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types'; import type { ExportRequest } from '@/lib/export-formats'; const VaultPage = lazy(() => import('@/components/VaultPage')); @@ -112,6 +112,10 @@ export interface AppMainRoutesProps { onGetRecoveryCode: (masterPassword: string) => Promise; onGetApiKey: (masterPassword: string) => Promise; onRotateApiKey: (masterPassword: string) => Promise; + onListAccountPasskeys: () => Promise; + onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise; + onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise; + onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise; onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void; onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void; onRefreshAuthorizedDevices: () => Promise; @@ -261,6 +265,10 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { onGetRecoveryCode={props.onGetRecoveryCode} onGetApiKey={props.onGetApiKey} onRotateApiKey={props.onRotateApiKey} + onListAccountPasskeys={props.onListAccountPasskeys} + onCreateAccountPasskey={props.onCreateAccountPasskey} + onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock} + onDeleteAccountPasskey={props.onDeleteAccountPasskey} onLockTimeoutChange={props.onLockTimeoutChange} onSessionTimeoutActionChange={props.onSessionTimeoutActionChange} onNotify={props.onNotify} diff --git a/webapp/src/components/AuthViews.tsx b/webapp/src/components/AuthViews.tsx index e53bd04..b8c16ed 100644 --- a/webapp/src/components/AuthViews.tsx +++ b/webapp/src/components/AuthViews.tsx @@ -1,5 +1,5 @@ import { useState } from 'preact/hooks'; -import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact'; +import { ArrowLeft, Eye, EyeOff, KeyRound, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact'; import NetworkStatusBadge from '@/components/NetworkStatusBadge'; import StandalonePageFrame from '@/components/StandalonePageFrame'; import { t } from '@/lib/i18n'; @@ -23,19 +23,24 @@ interface AuthViewsProps { relaxedLoginInput?: boolean; authPlaceholder?: string; unlockPlaceholder?: string; - pendingAction: 'login' | 'register' | 'unlock' | null; + pendingAction: 'login' | 'passkey' | 'register' | 'unlock' | null; unlockReady: boolean; unlockPreparing: boolean; loginValues: LoginValues; + pendingPasskeyPasswordEmail?: string | null; + passkeyPassword: string; registerValues: RegisterValues; registrationInviteRequired?: boolean; unlockPassword: string; emailForLock: string; loginHintLoading: boolean; onChangeLogin: (next: LoginValues) => void; + onChangePasskeyPassword: (password: string) => void; onChangeRegister: (next: RegisterValues) => void; onChangeUnlock: (password: string) => void; onSubmitLogin: () => void; + onSubmitPasskey: () => void; + onSubmitPasskeyPassword: () => void; onSubmitRegister: () => void; onSubmitUnlock: () => void; onGotoLogin: () => void; @@ -77,8 +82,10 @@ function PasswordField(props: { export default function AuthViews(props: AuthViewsProps) { const loginBusy = props.pendingAction === 'login'; + const passkeyBusy = props.pendingAction === 'passkey'; const registerBusy = props.pendingAction === 'register'; const unlockBusy = props.pendingAction === 'unlock'; + const passkeyPasswordPending = !!props.pendingPasskeyPasswordEmail; const showInviteCodeField = props.registrationInviteRequired !== false || !!props.registerValues.inviteCode.trim(); if (props.mode === 'locked') { @@ -221,9 +228,37 @@ export default function AuthViews(props: AuthViewsProps) {
{ e.preventDefault(); + if (passkeyPasswordPending) { + props.onSubmitPasskeyPassword(); + return; + } props.onSubmitLogin(); }} > + {passkeyPasswordPending ? ( + <> +

{props.pendingPasskeyPasswordEmail}

+ + + +
{t('txt_or')}
+ + + ) : ( + <>