6 Commits

Author SHA1 Message Date
shuaiplus 3035a77579 chore: update version to 1.6.0 in package.json and app-version.ts 2026-06-10 17:05:32 +08:00
shuaiplus 28333f0e9b feat: update README to enhance PWA and Passkey features descriptions 2026-06-10 16:51:07 +08:00
shuaiplus 91320a4eba fix: persist offline unlock record during passkey PRF login
- Add fallbackKdfIterations parameter to completeLoginWithVaultKeys
- Save offline unlock record (email, profile, profileKey, kdfIterations)
  when completing vault-key-based login, ensuring offline unlock works
  after passkey (PRF) authentication
- Pass through fallbackIterations from performPasskeyLogin caller
- Add .reasonix/ to .gitignore
2026-06-10 13:44:43 +08:00
shuaiplus 19b96a7aca feat: add passkey unlock functionality and improve related error handling 2026-06-10 12:10:11 +08:00
shuaiplus 18e0396c0a feat: enhance account passkey functionality and improve error handling 2026-06-10 12:09:25 +08:00
shuaiplus 18d3490c4f 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.
2026-06-10 00:53:41 +08:00
42 changed files with 4234 additions and 199 deletions
+6
View File
@@ -49,3 +49,9 @@ settings.json
.claude/
NodeWarden-compat/
.codex-upstream/
.codex-upstream/bitwarden-server/
.codex-upstream/bitwarden-clients/
.codex-upstream/bitwarden-web/
.codex-upstream/bitwarden-browser/
.reasonix/
+25 -5
View File
@@ -34,16 +34,19 @@
| 能力 | Bitwarden | NodeWarden | 说明 |
|---|---|---|---|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** |
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** |
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
| Send | ✅ | ✅ | 支持文本与文件 Send |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份** |
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份OneDrive/Google Drive等)** |
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
| 登录 2FA | ✅ | ⚠️ 部分支持 | 当前仅支持用户级 TOTP |
| 登录 2FA | ✅ | ⚠️ 部分支持 | 支持TOTP和Passkey(作为第二因素) |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
---
@@ -110,10 +113,27 @@ npm run dev:kv
---
## 云端备份说明
## 主要特性
- 远程备份支持 **WebDAV****E3**
- 勾选“包含附件”后:
### PWA 渐进式 Web 应用
-**可安装到桌面** - 像原生应用一样运行
-**离线使用** - Service Worker 缓存,离线也能查看密码
-**App 快捷方式** - 快速启动保险库、TOTP代码
-**后台解密** - Web Worker 处理解密,不阻塞UI
### Passkey 无密码登录
-**WebAuthn/FIDO2 支持** - 使用指纹、Face ID等登录
-**PRF 密钥解锁** - Passkey 可直接解锁保险库
-**官方客户端兼容** - Chromium系浏览器扩展可用Passkey登录
-**多设备同步** - 支持iCloud、Google Password Manager等
### 云端备份说明
- 远程备份支持 **WebDAV****S3**
- 支持 **OneDrive**(通过Koofr)、**Google Drive**(通过Koofr)、**Cloudflare R2**、**Backblaze B2** 等
- 勾选”包含附件”后:
- ZIP 内仍只包含 `db.json``manifest.json`
- 真实附件单独存放在 `attachments/`
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
+30 -10
View File
@@ -37,16 +37,19 @@
| Capability | Bitwarden | NodeWarden | Notes |
|---|---|---|---|
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
| **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** |
| **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** |
| **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** |
| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients |
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
| Send | ✅ | ✅ | Supports both text and file Sends |
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
| **Cloud Backup Center** | ❌ | ✅ | **Scheduled backup to WebDAV / E3** |
| **Cloud Backup Center** | ❌ | ✅ | **WebDAV / S3 scheduled backup (OneDrive/Google Drive etc.)** |
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
| Multi-user | ✅ | ✅ | Invite-based registration |
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
| Login 2FA | ✅ | ⚠️ Partial | Currently only user-level TOTP |
| Login 2FA | ✅ | ⚠️ Partial | TOTP and Passkey (as second factor) |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
---
@@ -99,17 +102,34 @@ npm run dev:kv
---
## Cloud Backup Notes
## Key Features
- Remote backup supports **WebDAV** and **E3**
### PWA Progressive Web App
-**Install to desktop** - Runs like a native app
-**Offline usage** - Service Worker caching, view passwords offline
-**App shortcuts** - Quick launch vault, TOTP codes
-**Background decryption** - Web Worker handles decryption without blocking UI
### Passkey Passwordless Login
-**WebAuthn/FIDO2 support** - Login with fingerprint, Face ID, etc.
-**PRF key unlock** - Passkey can unlock vault directly
-**Official client compatibility** - Chromium browser extension supports Passkey login
-**Multi-device sync** - Supports iCloud, Google Password Manager, etc.
### Cloud Backup Notes
- Remote backup supports **WebDAV** and **S3**
- Supports **OneDrive** (via Koofr), **Google Drive** (via Koofr), **Cloudflare R2**, **Backblaze B2**, etc.
- When `Include attachments` is enabled:
- the ZIP still contains only `db.json` and `manifest.json`
- actual attachment files are stored separately under `attachments/`
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
- the ZIP still contains only `db.json` and `manifest.json`
- actual attachment files are stored separately under `attachments/`
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
- During remote restore:
- required attachment files are loaded from `attachments/` on demand
- missing attachments are skipped safely
- skipped attachments do not leave broken rows in the restored database
- required attachment files are loaded from `attachments/` on demand
- missing attachments are skipped safely
- skipped attachments do not leave broken rows in the restored database
---
+785
View File
@@ -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 keysetpasskey 只能认证账号,不能解开 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=<server assertion options token>`
- `deviceResponse=<JSON string>`
- 还会带 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,建议为了官方兼容先遵循 Bitwardenpasskey 的 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 里建议组合:
- tokenHMAC/JWT 样式,绑定 `scope``challenge``userId?``rpId``createdAt``expiresAt`
- D1 表或 KV:记录 challenge 是否使用过,至少字段 `challenge_hash``scope``user_id``expires_at``used_at`
- 登录 assertion options 是公开接口,不绑定 user idcreate/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/camelCaseNodeWarden 当前 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`
+38
View File
@@ -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,
+429 -127
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nodewarden",
"version": "1.5.2",
"version": "1.6.0",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus",
"license": "LGPL-3.0",
@@ -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",
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.5.2';
export const APP_VERSION = '1.6.0';
+488
View File
@@ -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<string, any> {
return body && typeof body === 'object' ? body as Record<string, any> : {};
}
async function readJsonBody(request: Request): Promise<Record<string, any> | null> {
try {
return parseBodyObject(await request.json());
} catch {
return null;
}
}
async function verifyUserSecret(
env: Env,
user: User,
body: Record<string, any>
): Promise<boolean> {
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<string, unknown> = {}): 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<string, any>): boolean {
return !!(body.encryptedUserKey && body.encryptedPublicKey && body.encryptedPrivateKey);
}
function readPrfKeySet(body: Record<string, any>): {
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<void> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<typeof readPrfKeySet>;
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<ReturnType<typeof verifyRegistrationResponse>>;
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<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
let prfKeySet: ReturnType<typeof readPrfKeySet>;
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<ReturnType<typeof assertAccountPasskeyCredential>>;
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<Response> {
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);
}
+124
View File
@@ -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<Response>
? 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<ReturnType<typeof assertAccountPasskeyCredential>>;
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;
+22 -4
View File
@@ -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<typeof option> => !!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,
+30
View File
@@ -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);
}
+7
View File
@@ -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);
}
+1
View File
@@ -66,6 +66,7 @@ const ALLOWED_METADATA_KEYS = new Set([
'skippedReason',
'replaceExisting',
'provider',
'prfStatus',
'fileName',
'fileBytes',
'bytes',
+22 -1
View File
@@ -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<string>(
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<string>();
const accountPasskeyCredentialIds = new Set<string>();
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)),
};
+21
View File
@@ -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,
@@ -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<Set<string>> {
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<void> {
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<void> {
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<AccountPasskeyCredential[]> {
await ensureAccountPasskeySchema(db);
const rows = await db
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at ASC')
.bind(userId)
.all<any>();
return (rows.results || []).map(mapCredentialRow);
}
export async function getAccountPasskeyCredentialById(
db: D1Database,
userId: string,
id: string
): Promise<AccountPasskeyCredential | null> {
await ensureAccountPasskeySchema(db);
const row = await db
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? AND id = ? LIMIT 1')
.bind(userId, id)
.first<any>();
return row ? mapCredentialRow(row) : null;
}
export async function getAccountPasskeyCredentialByCredentialId(
db: D1Database,
credentialId: string
): Promise<AccountPasskeyCredential | null> {
await ensureAccountPasskeySchema(db);
const row = await db
.prepare('SELECT * FROM webauthn_credentials WHERE credential_id = ? LIMIT 1')
.bind(credentialId)
.first<any>();
return row ? mapCredentialRow(row) : null;
}
export async function countAccountPasskeyCredentialsByUserId(
db: D1Database,
userId: string
): Promise<number> {
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<void> {
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<boolean> {
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<boolean> {
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<void> {
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<AccountPasskeyChallenge | null> {
await ensureAccountPasskeySchema(db);
const row = await db
.prepare('SELECT * FROM webauthn_challenges WHERE challenge_hash = ? AND scope = ? LIMIT 1')
.bind(challengeHash, scope)
.first<any>();
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 };
}
+14
View File
@@ -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)',
+96 -3
View File
@@ -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<boolean> {
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<void> {
await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential);
}
async getAccountPasskeyCredentialsByUserId(userId: string): Promise<AccountPasskeyCredential[]> {
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId);
}
async getAccountPasskeyCredentialById(userId: string, id: string): Promise<AccountPasskeyCredential | null> {
return findStoredAccountPasskeyCredentialById(this.db, userId, id);
}
async getAccountPasskeyCredentialByCredentialId(credentialId: string): Promise<AccountPasskeyCredential | null> {
return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId);
}
async countAccountPasskeyCredentialsByUserId(userId: string): Promise<number> {
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId);
}
async updateAccountPasskeyCounter(
userId: string,
credentialId: string,
counter: number,
updatedAt: string = new Date().toISOString()
): Promise<void> {
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<boolean> {
return updateStoredAccountPasskeyEncryption(
this.db,
userId,
credentialId,
encryptedUserKey,
encryptedPublicKey,
encryptedPrivateKey,
updatedAt
);
}
async deleteAccountPasskeyCredential(userId: string, id: string): Promise<boolean> {
return deleteStoredAccountPasskeyCredential(this.db, userId, id);
}
async saveAccountPasskeyChallenge(challenge: AccountPasskeyChallenge): Promise<void> {
await saveStoredAccountPasskeyChallenge(this.db, challenge);
}
async consumeAccountPasskeyChallenge(
challengeHash: string,
scope: AccountPasskeyChallengeScope,
userId: string | null,
nowMs: number = Date.now()
): Promise<AccountPasskeyChallenge | null> {
return consumeStoredAccountPasskeyChallenge(this.db, challengeHash, scope, userId, nowMs);
}
// --- Ciphers ---
async getCipher(id: string): Promise<Cipher | null> {
+45 -1
View File
@@ -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
+269
View File
@@ -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<CryptoKey> {
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
}
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
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<T>(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<string> {
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<string> {
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<AccountPasskeyTokenPayload | null> {
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<AccountPasskeyTokenPayload>(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<string>([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<AccountPasskeyCredential, 'supportsPrf' | 'encryptedUserKey' | 'encryptedPublicKey' | 'encryptedPrivateKey'>): 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<string, unknown> {
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<string, any> : null;
const response = input?.response && typeof input.response === 'object' ? input.response as Record<string, any> : 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<string, any> : null;
const response = input?.response && typeof input.response === 'object' ? input.response as Record<string, any> : 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;
}
+4 -2
View File
@@ -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, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>,
webAuthnPrfOption: WebAuthnPrfDecryptionOption | null = null
): UserDecryptionOptions {
return {
HasMasterPassword: true,
@@ -48,6 +49,7 @@ export function buildUserDecryptionOptions(
MasterPasswordUnlock: buildMasterPasswordUnlock(user),
TrustedDeviceOption: null,
KeyConnectorOption: null,
WebAuthnPrfOption: webAuthnPrfOption,
};
}
+95 -1
View File
@@ -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<AppPhase>(initialBootstrap.phase);
const [session, setSessionState] = useState<SessionState | null>(initialBootstrap.session);
@@ -201,6 +204,8 @@ export default function App() {
const [unlockPassword, setUnlockPassword] = useState('');
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null);
const [pendingPasskeyPassword, setPendingPasskeyPassword] = useState<PendingPasskeyPassword | null>(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,78 @@ 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 handlePasskeyUnlock() {
if (pendingAuthAction) return;
const expectedEmail = (profile?.email || session?.email || '').trim().toLowerCase();
if (!expectedEmail) return;
if (IS_DEMO_MODE) {
pushToast('warning', t('txt_demo_readonly_message'));
return;
}
setPendingAuthAction('passkey');
try {
const result = await performPasskeyLogin(defaultKdfIterations, expectedEmail);
if (result.kind === 'success') {
await finalizeLogin(result.login, t('txt_unlocked'));
return;
}
if (result.kind === 'password') {
pushToast('error', t('txt_account_passkey_direct_unlock_unavailable_error'));
return;
}
pushToast('error', result.message || t('txt_unlock_failed_master_password_is_incorrect'));
} catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_unlock_failed_master_password_is_incorrect'));
} 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 +1433,7 @@ export default function App() {
const accountSecurityActions = useAccountSecurityActions({
authedFetch,
profile,
session,
defaultKdfIterations,
disableTotpPassword,
clearDisableTotpDialog: () => {
@@ -1540,6 +1620,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 +1734,26 @@ 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()}
onSubmitPasskeyUnlock={() => void handlePasskeyUnlock()}
onSubmitPasskeyPassword={() => void handlePasskeyPasswordLogin()}
onSubmitRegister={() => void handleRegister()}
onSubmitUnlock={() => void handleUnlock()}
onGotoLogin={() => {
setPendingPasskeyPassword(null);
setPasskeyPassword('');
setPhase('login');
navigate('/login');
}}
@@ -1673,6 +1765,8 @@ export default function App() {
if (inviteCodeFromUrl) {
setRegisterValues((prev) => ({ ...prev, inviteCode: inviteCodeFromUrl }));
}
setPendingPasskeyPassword(null);
setPasskeyPassword('');
setPhase('register');
navigate('/register');
}}
+2 -1
View File
@@ -12,6 +12,7 @@ export interface AppConfirmState {
cancelText?: string;
hideCancel?: boolean;
onConfirm: () => void;
onCancel?: () => void;
}
interface AppGlobalOverlaysProps {
@@ -49,7 +50,7 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
cancelText={props.confirm?.cancelText}
hideCancel={props.confirm?.hideCancel}
onConfirm={() => props.confirm?.onConfirm()}
onCancel={props.onCancelConfirm}
onCancel={props.confirm?.onCancel || props.onCancelConfirm}
/>
<ConfirmDialog
+9 -1
View File
@@ -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<string>;
onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>;
onListAccountPasskeys: () => Promise<AccountPasskeyCredential[]>;
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
onRefreshAuthorizedDevices: () => Promise<void>;
@@ -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}
+57 -6
View File
@@ -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,25 @@ 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;
onSubmitPasskeyUnlock: () => void;
onSubmitPasskeyPassword: () => void;
onSubmitRegister: () => void;
onSubmitUnlock: () => void;
onGotoLogin: () => void;
@@ -77,8 +83,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') {
@@ -115,12 +123,21 @@ export default function AuthViews(props: AuthViewsProps) {
{props.unlockPreparing ? (
<p className="muted standalone-muted">{t('txt_loading')}</p>
) : null}
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || props.unlockPreparing || !props.unlockReady}>
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || passkeyBusy || props.unlockPreparing || !props.unlockReady}>
<Unlock size={16} className="btn-icon" />
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
</button>
<button
type="button"
className="btn btn-secondary full"
onClick={props.onSubmitPasskeyUnlock}
disabled={unlockBusy || passkeyBusy || props.unlockPreparing || !props.unlockReady}
>
<KeyRound size={16} className="btn-icon" />
{passkeyBusy ? t('txt_unlocking') : t('txt_unlock_with_passkey')}
</button>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy || passkeyBusy}>
<LogOut size={16} className="btn-icon" />
{t('txt_log_out')}
</button>
@@ -221,9 +238,37 @@ export default function AuthViews(props: AuthViewsProps) {
<form
onSubmit={(e) => {
e.preventDefault();
if (passkeyPasswordPending) {
props.onSubmitPasskeyPassword();
return;
}
props.onSubmitLogin();
}}
>
{passkeyPasswordPending ? (
<>
<p className="muted standalone-muted">{props.pendingPasskeyPasswordEmail}</p>
<input type="text" value={props.pendingPasskeyPasswordEmail || ''} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
<PasswordField
label={t('txt_master_password')}
value={props.passkeyPassword}
autoFocus
autoComplete="current-password"
placeholder={props.authPlaceholder}
onInput={props.onChangePasskeyPassword}
/>
<button type="submit" className="btn btn-primary full" disabled={loginBusy}>
<Unlock size={16} className="btn-icon" />
{loginBusy ? t('txt_unlocking') : t('txt_unlock')}
</button>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoLogin} disabled={loginBusy}>
<ArrowLeft size={16} className="btn-icon" />
{t('txt_back_to_login')}
</button>
</>
) : (
<>
<label className="field">
<span>{t('txt_email')}</span>
<input
@@ -256,15 +301,21 @@ export default function AuthViews(props: AuthViewsProps) {
: t('txt_show_password_hint')}
</button>
</div>
<button type="submit" className="btn btn-primary full" disabled={loginBusy}>
<button type="submit" className="btn btn-primary full" disabled={loginBusy || passkeyBusy}>
<LogIn size={16} className="btn-icon" />
{loginBusy ? t('txt_logging_in') : t('txt_log_in')}
</button>
<button type="button" className="btn btn-secondary full" onClick={props.onSubmitPasskey} disabled={loginBusy || passkeyBusy}>
<KeyRound size={16} className="btn-icon" />
{passkeyBusy ? t('txt_logging_in') : t('txt_login_with_passkey')}
</button>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister} disabled={loginBusy}>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister} disabled={loginBusy || passkeyBusy}>
<UserPlus size={16} className="btn-icon" />
{t('txt_create_account')}
</button>
</>
)}
</form>
</StandalonePageFrame>
</div>
+166 -7
View File
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import qrcode from 'qrcode-generator';
import type { Profile } from '@/lib/types';
import type { AccountPasskeyCredential, Profile } from '@/lib/types';
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
import ConfirmDialog from '@/components/ConfirmDialog';
@@ -18,11 +18,23 @@ interface SettingsPageProps {
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>;
onListAccountPasskeys: () => Promise<AccountPasskeyCredential[]>;
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
onNotify?: (type: 'success' | 'error', text: string) => void;
onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void;
}
type MasterPasswordPromptAction =
| 'recovery'
| 'apiKey'
| 'rotateApiKey'
| 'createPasskey'
| 'enablePasskeyDirectUnlock'
| 'deletePasskey';
const LOCK_TIMEOUT_OPTIONS = [
{ value: 1, labelKey: 'txt_timeout_1_minute' },
{ value: 5, labelKey: 'txt_timeout_5_minutes' },
@@ -74,9 +86,14 @@ export default function SettingsPage(props: SettingsPageProps) {
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
const [recoveryCode, setRecoveryCode] = useState('');
const [apiKey, setApiKey] = useState('');
const [accountPasskeys, setAccountPasskeys] = useState<AccountPasskeyCredential[]>([]);
const [accountPasskeysLoading, setAccountPasskeysLoading] = useState(false);
const [accountPasskeyName, setAccountPasskeyName] = useState(t('txt_account_passkey'));
const [accountPasskeyDirectUnlock, setAccountPasskeyDirectUnlock] = useState(false);
const [accountPasskeyPromptId, setAccountPasskeyPromptId] = useState<string | null>(null);
const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
const [masterPasswordPrompt, setMasterPasswordPrompt] = useState<null | 'recovery' | 'apiKey' | 'rotateApiKey'>(null);
const [masterPasswordPrompt, setMasterPasswordPrompt] = useState<MasterPasswordPromptAction | null>(null);
const [masterPasswordPromptValue, setMasterPasswordPromptValue] = useState('');
const [masterPasswordPromptSubmitting, setMasterPasswordPromptSubmitting] = useState(false);
const [selectedLocale, setSelectedLocale] = useState<Locale>(() => getLocale());
@@ -97,6 +114,10 @@ export default function SettingsPage(props: SettingsPageProps) {
setPasswordHint(props.profile.masterPasswordHint || '');
}, [props.profile.masterPasswordHint]);
useEffect(() => {
void refreshAccountPasskeys();
}, [props.profile.id]);
const qrDataUrl = useMemo(() => {
const qr = qrcode(0, 'M');
qr.addData(buildOtpUri(props.profile.email, secret));
@@ -115,14 +136,27 @@ export default function SettingsPage(props: SettingsPageProps) {
}
}
function openMasterPasswordPrompt(action: 'recovery' | 'apiKey' | 'rotateApiKey'): void {
async function refreshAccountPasskeys(): Promise<void> {
setAccountPasskeysLoading(true);
try {
setAccountPasskeys(await props.onListAccountPasskeys());
} catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_account_passkeys_load_failed'));
} finally {
setAccountPasskeysLoading(false);
}
}
function openMasterPasswordPrompt(action: MasterPasswordPromptAction, credentialId?: string): void {
setMasterPasswordPrompt(action);
setAccountPasskeyPromptId(credentialId || null);
setMasterPasswordPromptValue('');
}
function closeMasterPasswordPrompt(): void {
if (masterPasswordPromptSubmitting) return;
setMasterPasswordPrompt(null);
setAccountPasskeyPromptId(null);
setMasterPasswordPromptValue('');
}
@@ -139,13 +173,25 @@ export default function SettingsPage(props: SettingsPageProps) {
const key = await props.onGetApiKey(masterPassword);
setApiKey(key);
setApiKeyDialogOpen(true);
} else {
} else if (masterPasswordPrompt === 'rotateApiKey') {
const key = await props.onRotateApiKey(masterPassword);
setApiKey(key);
setApiKeyDialogOpen(true);
props.onNotify?.('success', t('txt_api_key_rotated'));
} else if (masterPasswordPrompt === 'createPasskey') {
const credential = await props.onCreateAccountPasskey(accountPasskeyName, masterPassword, accountPasskeyDirectUnlock);
if (credential) await refreshAccountPasskeys();
} else if (masterPasswordPrompt === 'enablePasskeyDirectUnlock') {
if (!accountPasskeyPromptId) throw new Error(t('txt_account_passkey_not_found'));
await props.onEnableAccountPasskeyDirectUnlock(accountPasskeyPromptId, masterPassword);
await refreshAccountPasskeys();
} else if (masterPasswordPrompt === 'deletePasskey') {
if (!accountPasskeyPromptId) throw new Error(t('txt_account_passkey_not_found'));
await props.onDeleteAccountPasskey(accountPasskeyPromptId, masterPassword);
await refreshAccountPasskeys();
}
setMasterPasswordPrompt(null);
setAccountPasskeyPromptId(null);
setMasterPasswordPromptValue('');
} catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_master_password_is_required_2'));
@@ -159,7 +205,19 @@ export default function SettingsPage(props: SettingsPageProps) {
? t('txt_view_recovery_code')
: masterPasswordPrompt === 'rotateApiKey'
? t('txt_rotate_api_key')
: t('txt_view_api_key');
: masterPasswordPrompt === 'createPasskey'
? t('txt_add_account_passkey')
: masterPasswordPrompt === 'enablePasskeyDirectUnlock'
? t('txt_enable_passkey_direct_unlock')
: masterPasswordPrompt === 'deletePasskey'
? t('txt_delete_account_passkey')
: t('txt_view_api_key');
function accountPasskeyStatusText(credential: AccountPasskeyCredential): string {
if (credential.prfStatus === 0) return t('txt_direct_unlock');
if (credential.prfStatus === 1) return t('txt_login_only');
return t('txt_prf_not_supported');
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return t('txt_dash');
@@ -345,6 +403,107 @@ export default function SettingsPage(props: SettingsPageProps) {
</div>
</section>
<section className="card settings-module account-passkeys-module">
<div className="settings-module-head">
<h3>{t('txt_account_passkeys')}</h3>
<button
type="button"
className="btn btn-secondary small"
disabled={accountPasskeysLoading}
title={t('txt_refresh')}
aria-label={t('txt_refresh')}
onClick={() => void refreshAccountPasskeys()}
>
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')}
</button>
</div>
<div className="field-grid">
<label className="field">
<span>{t('txt_passkey_name')}</span>
<input
className="input"
maxLength={128}
value={accountPasskeyName}
placeholder={t('txt_account_passkey_name_placeholder')}
onInput={(e) => setAccountPasskeyName((e.currentTarget as HTMLInputElement).value)}
/>
</label>
<div className="field account-passkey-mode-field">
<span>{t('txt_account_passkey_mode')}</span>
<label className="account-passkey-toggle">
<input
type="checkbox"
checked={accountPasskeyDirectUnlock}
onInput={(e) => setAccountPasskeyDirectUnlock((e.currentTarget as HTMLInputElement).checked)}
/>
<span>{t('txt_account_passkey_direct_unlock_mode')}</span>
</label>
<div className="field-help">
{accountPasskeyDirectUnlock ? t('txt_account_passkey_direct_unlock_help') : t('txt_account_passkey_login_only_help')}
</div>
</div>
</div>
<div className="actions">
<button
type="button"
className="btn btn-primary"
disabled={masterPasswordPromptSubmitting}
onClick={() => openMasterPasswordPrompt('createPasskey')}
>
<KeyRound size={14} className="btn-icon" />
{t('txt_add_account_passkey')}
</button>
</div>
<div className="account-passkeys-list">
{accountPasskeysLoading ? (
<div className="settings-module-placeholder">
<RefreshCw size={20} />
<span>{t('txt_loading')}</span>
</div>
) : accountPasskeys.length === 0 ? (
<div className="settings-module-placeholder">
<KeyRound size={20} />
<span>{t('txt_no_account_passkeys')}</span>
</div>
) : (
accountPasskeys.map((credential) => (
<div key={credential.id} className="account-passkey-row">
<div className="account-passkey-main">
<strong>{credential.name || t('txt_account_passkey')}</strong>
<small>{t('txt_created_value', { value: formatDateTime(credential.creationDate) })}</small>
</div>
<span className={`account-passkey-status account-passkey-status-${credential.prfStatus}`}>
{accountPasskeyStatusText(credential)}
</span>
<div className="actions account-passkey-actions">
{credential.prfStatus === 1 && (
<button
type="button"
className="btn btn-secondary small"
disabled={masterPasswordPromptSubmitting}
onClick={() => openMasterPasswordPrompt('enablePasskeyDirectUnlock', credential.id)}
>
<ShieldCheck size={14} className="btn-icon" />
{t('txt_enable_passkey_direct_unlock')}
</button>
)}
<button
type="button"
className="btn btn-danger small"
disabled={masterPasswordPromptSubmitting}
onClick={() => openMasterPasswordPrompt('deletePasskey', credential.id)}
>
<Trash2 size={14} className="btn-icon" />
{t('txt_delete')}
</button>
</div>
</div>
))
)}
</div>
</section>
<section className="settings-module sensitive-actions-module">
<div className="sensitive-actions-grid">
<div className="sensitive-action">
+116 -3
View File
@@ -4,27 +4,41 @@ import {
deleteAllAuthorizedDevices,
deleteAuthorizedDevice,
deriveLoginHash,
deleteAccountPasskey as deleteAccountPasskeyApi,
enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi,
getCurrentDeviceIdentifier,
getApiKey,
getAccountPasskeyAttestationOptions,
getAccountPasskeyUpdateAssertionOptions,
getTotpRecoveryCode,
listAccountPasskeys,
rotateApiKey,
revokeAuthorizedDeviceTrust,
revokeAllAuthorizedDeviceTrust,
saveAccountPasskey,
setTotp,
trustAuthorizedDevicePermanently,
updateAuthorizedDeviceName,
updateProfile,
} from '@/lib/api/auth';
import {
AccountPasskeyPrfUnavailableError,
assertAccountPasskey,
buildAccountPasskeyPrfKeySet,
buildAccountPasskeyPrfKeySetFromPrfKey,
createAccountPasskeyCredential,
} from '@/lib/account-passkeys';
import { t } from '@/lib/i18n';
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
import type { AuthedFetch } from '@/lib/api/shared';
import type { AuthorizedDevice, Profile } from '@/lib/types';
import type { AccountPasskeyCredential, AuthorizedDevice, Profile, SessionState } from '@/lib/types';
type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
interface UseAccountSecurityActionsOptions {
authedFetch: AuthedFetch;
profile: Profile | null;
session: SessionState | null;
defaultKdfIterations: number;
disableTotpPassword: string;
clearDisableTotpDialog: () => void;
@@ -40,6 +54,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
const {
authedFetch,
profile,
session,
defaultKdfIterations,
disableTotpPassword,
clearDisableTotpDialog,
@@ -52,7 +67,29 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
} = options;
return useMemo(
() => ({
() => {
function confirmSaveLoginOnlyAccountPasskey(): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const finish = (shouldSave: boolean) => {
if (settled) return;
settled = true;
onSetConfirm(null);
resolve(shouldSave);
};
onSetConfirm({
title: t('txt_account_passkey_direct_unlock_unavailable_title'),
message: t('txt_account_passkey_direct_unlock_unavailable_message'),
confirmText: t('txt_save_login_only_passkey'),
cancelText: t('txt_do_not_save'),
showIcon: true,
onConfirm: () => finish(true),
onCancel: () => finish(false),
});
});
}
return ({
async changePassword(currentPassword: string, nextPassword: string, nextPassword2: string) {
if (!profile) return;
if (!currentPassword || !nextPassword) {
@@ -170,6 +207,79 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
return key;
},
async listAccountPasskeys(): Promise<AccountPasskeyCredential[]> {
return listAccountPasskeys(authedFetch);
},
async createAccountPasskey(name: string, masterPassword: string, directUnlock: boolean): Promise<AccountPasskeyCredential | null> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalizedPassword = String(masterPassword || '');
if (!normalizedPassword) throw new Error(t('txt_master_password_is_required'));
const normalizedName = String(name || '').trim() || t('txt_account_passkey');
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
const options = await getAccountPasskeyAttestationOptions(authedFetch, derived.hash);
const pending = await createAccountPasskeyCredential(options);
let keySet = null;
let savedWithoutDirectUnlock = false;
if (directUnlock) {
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
try {
keySet = await buildAccountPasskeyPrfKeySet(pending, {
symEncKey: session.symEncKey,
symMacKey: session.symMacKey,
});
} catch (error) {
if (!(error instanceof AccountPasskeyPrfUnavailableError)) throw error;
const shouldSaveLoginOnly = await confirmSaveLoginOnlyAccountPasskey();
if (!shouldSaveLoginOnly) {
onNotify('warning', t('txt_account_passkey_not_saved'));
return null;
}
savedWithoutDirectUnlock = true;
}
}
const credential = await saveAccountPasskey(authedFetch, {
name: normalizedName,
token: pending.token,
deviceResponse: pending.request,
supportsPrf: keySet ? true : savedWithoutDirectUnlock ? false : pending.supportsPrf,
keySet,
});
onNotify('success', savedWithoutDirectUnlock ? t('txt_account_passkey_saved_login_only') : t('txt_account_passkey_saved'));
return credential;
},
async enableAccountPasskeyDirectUnlock(id: string, masterPassword: string): Promise<void> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
if (!String(id || '').trim()) throw new Error(t('txt_account_passkey_not_found'));
const normalizedPassword = String(masterPassword || '');
if (!normalizedPassword) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
const options = await getAccountPasskeyUpdateAssertionOptions(authedFetch, derived.hash, id);
const assertion = await assertAccountPasskey(options);
if (!assertion.prfKey) throw new Error(t('txt_account_passkey_prf_not_available'));
const keySet = await buildAccountPasskeyPrfKeySetFromPrfKey(assertion.prfKey, {
symEncKey: session.symEncKey,
symMacKey: session.symMacKey,
});
await enableAccountPasskeyDirectUnlockApi(authedFetch, {
token: assertion.token,
deviceResponse: assertion.deviceResponse,
keySet,
});
onNotify('success', t('txt_account_passkey_direct_unlock_enabled'));
},
async deleteAccountPasskey(id: string, masterPassword: string): Promise<void> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalizedPassword = String(masterPassword || '');
if (!normalizedPassword) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
await deleteAccountPasskeyApi(authedFetch, id, derived.hash);
onNotify('success', t('txt_account_passkey_deleted'));
},
async refreshAuthorizedDevices() {
await refetchAuthorizedDevices();
},
@@ -293,7 +403,8 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
},
});
},
}),
});
},
[
authedFetch,
clearDisableTotpDialog,
@@ -304,6 +415,8 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onProfileUpdated,
onSetConfirm,
profile,
session?.symEncKey,
session?.symMacKey,
refetchAuthorizedDevices,
refetchTotpStatus,
]
+384
View File
@@ -0,0 +1,384 @@
import { base64ToBytes, bytesToBase64, decryptBw, encryptBw, hkdfExpand, toBufferSource } from './crypto';
import { t } from './i18n';
import type { AccountPasskeyPrfOption } from './types';
const LOGIN_WITH_PRF_SALT = 'passwordless-login';
export interface AccountPasskeyAssertion {
token: string;
deviceResponse: Record<string, unknown>;
prfKey?: Uint8Array;
}
export interface PendingAccountPasskeyCredential {
token: string;
createOptions: PublicKeyCredentialCreationOptions;
deviceResponse: PublicKeyCredential;
request: Record<string, unknown>;
supportsPrf: boolean;
}
export interface AccountPasskeyPrfKeySet {
encryptedUserKey: string;
encryptedPublicKey: string;
encryptedPrivateKey: string;
}
export class AccountPasskeyPrfUnavailableError extends Error {
constructor() {
super(t('txt_account_passkey_direct_unlock_unavailable_error'));
this.name = 'AccountPasskeyPrfUnavailableError';
}
}
function bytesToBase64Url(bytes: Uint8Array): string {
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function base64UrlToBytes(value: string): Uint8Array {
const normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
return base64ToBytes(padded);
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return toBufferSource(bytes);
}
function cloneCreationOptions(options: any): PublicKeyCredentialCreationOptions {
if (!options || typeof options !== 'object') throw new Error(t('txt_invalid_passkey_creation_options'));
return {
...options,
challenge: toArrayBuffer(base64UrlToBytes(options.challenge)),
user: {
...options.user,
id: toArrayBuffer(base64UrlToBytes(options.user?.id)),
},
excludeCredentials: Array.isArray(options.excludeCredentials)
? options.excludeCredentials.map((credential: any) => ({
...credential,
id: toArrayBuffer(base64UrlToBytes(credential.id)),
}))
: undefined,
};
}
function cloneRequestOptions(options: any): PublicKeyCredentialRequestOptions {
if (!options || typeof options !== 'object') throw new Error(t('txt_invalid_passkey_assertion_options'));
return {
...options,
challenge: toArrayBuffer(base64UrlToBytes(options.challenge)),
allowCredentials: Array.isArray(options.allowCredentials)
? options.allowCredentials.map((credential: any) => ({
...credential,
id: toArrayBuffer(base64UrlToBytes(credential.id)),
}))
: options.allowCredentials,
};
}
async function getLoginWithPrfSalt(): Promise<Uint8Array> {
const hash = await crypto.subtle.digest('SHA-256', toBufferSource(new TextEncoder().encode(LOGIN_WITH_PRF_SALT)));
return new Uint8Array(hash);
}
function credentialIdToBase64Url(id: BufferSource): string | null {
try {
const bytes = id instanceof ArrayBuffer
? new Uint8Array(id)
: new Uint8Array(id.buffer, id.byteOffset, id.byteLength);
return bytesToBase64Url(bytes);
} catch {
return null;
}
}
type PrfEvalInput = { first: Uint8Array };
function buildLegacyPrfExtension(salt: Uint8Array): Record<string, unknown> {
const evalInput: PrfEvalInput = { first: salt };
return {
prf: {
eval: evalInput,
},
};
}
function buildCredentialPrfExtension(
salt: Uint8Array,
credentialIds: Array<string | null | undefined>
): Record<string, unknown> {
const evalInput = { first: salt };
const evalByCredential = credentialIds
.filter((id): id is string => !!id)
.reduce<Record<string, PrfEvalInput>>((out, id) => {
out[id] = evalInput;
return out;
}, {});
if (!Object.keys(evalByCredential).length) return buildLegacyPrfExtension(salt);
return {
prf: {
evalByCredential,
},
};
}
function withPrfExtension(
options: PublicKeyCredentialRequestOptions,
extension: Record<string, unknown>
): PublicKeyCredentialRequestOptions {
return {
...options,
extensions: {
...((options as any).extensions || {}),
...extension,
} as any,
};
}
function readPrfFirstResult(credential: PublicKeyCredential): ArrayBuffer | undefined {
const result = (credential.getClientExtensionResults() as any).prf?.results?.first;
return result instanceof ArrayBuffer ? result : undefined;
}
function hasPrfExtensionResult(credential: PublicKeyCredential): boolean {
return Object.prototype.hasOwnProperty.call(credential.getClientExtensionResults() as any, 'prf');
}
function shouldRetryWithLegacyPrf(error: unknown): boolean {
const name = error instanceof DOMException || error instanceof Error ? error.name : '';
return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError';
}
async function getPublicKeyCredentialWithPrf(
options: PublicKeyCredentialRequestOptions,
salt: Uint8Array,
credentialIds: string[] = []
): Promise<PublicKeyCredential> {
const attempts = credentialIds.length
? [
buildCredentialPrfExtension(salt, credentialIds),
buildLegacyPrfExtension(salt),
]
: [buildLegacyPrfExtension(salt)];
let lastCredential: PublicKeyCredential | null = null;
for (let index = 0; index < attempts.length; index += 1) {
try {
const credential = await navigator.credentials.get({
publicKey: withPrfExtension(options, attempts[index]),
});
if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_selected'));
}
lastCredential = credential;
if (readPrfFirstResult(credential) || hasPrfExtensionResult(credential) || index === attempts.length - 1) {
return credential;
}
} catch (error) {
if (index === attempts.length - 1 || !shouldRetryWithLegacyPrf(error)) {
if (lastCredential) return lastCredential;
throw error;
}
}
}
if (lastCredential) return lastCredential;
throw new Error(t('txt_no_passkey_selected'));
}
function prfCredentialIdsFromAllowCredentials(options: PublicKeyCredentialRequestOptions): string[] {
return (options.allowCredentials || [])
.map((credential) => credentialIdToBase64Url(credential.id))
.filter((id): id is string => !!id);
}
async function prfOutputToKey(prfOutput: ArrayBuffer): Promise<Uint8Array> {
const prf = new Uint8Array(prfOutput);
const enc = await hkdfExpand(prf, 'enc', 32);
const mac = await hkdfExpand(prf, 'mac', 32);
const out = new Uint8Array(64);
out.set(enc, 0);
out.set(mac, 32);
return out;
}
function publicKeyCredentialBase(credential: PublicKeyCredential): Record<string, unknown> {
return {
id: credential.id,
rawId: bytesToBase64Url(new Uint8Array(credential.rawId)),
type: credential.type,
extensions: {},
};
}
function assertionRequest(credential: PublicKeyCredential): Record<string, unknown> {
if (!(credential.response instanceof AuthenticatorAssertionResponse)) {
throw new Error(t('txt_invalid_passkey_assertion_response'));
}
return {
...publicKeyCredentialBase(credential),
response: {
authenticatorData: bytesToBase64Url(new Uint8Array(credential.response.authenticatorData)),
signature: bytesToBase64Url(new Uint8Array(credential.response.signature)),
clientDataJSON: bytesToBase64Url(new Uint8Array(credential.response.clientDataJSON)),
userHandle: credential.response.userHandle
? bytesToBase64Url(new Uint8Array(credential.response.userHandle))
: undefined,
},
};
}
function attestationRequest(credential: PublicKeyCredential): Record<string, unknown> {
if (!(credential.response instanceof AuthenticatorAttestationResponse)) {
throw new Error(t('txt_invalid_passkey_registration_response'));
}
const transports = typeof credential.response.getTransports === 'function'
? credential.response.getTransports()
: undefined;
return {
...publicKeyCredentialBase(credential),
response: {
attestationObject: bytesToBase64Url(new Uint8Array(credential.response.attestationObject)),
clientDataJson: bytesToBase64Url(new Uint8Array(credential.response.clientDataJSON)),
transports,
},
};
}
export async function assertAccountPasskey(
response: { options: unknown; token: string }
): Promise<AccountPasskeyAssertion> {
if (!window.PublicKeyCredential || !navigator.credentials) {
throw new Error(t('txt_passkey_browser_not_supported'));
}
const nativeOptions = cloneRequestOptions(response.options);
const credential = await getPublicKeyCredentialWithPrf(
nativeOptions,
await getLoginWithPrfSalt(),
prfCredentialIdsFromAllowCredentials(nativeOptions)
);
const prfResult = readPrfFirstResult(credential);
return {
token: response.token,
deviceResponse: assertionRequest(credential),
prfKey: prfResult ? await prfOutputToKey(prfResult) : undefined,
};
}
export async function createAccountPasskeyCredential(
response: { options: unknown; token: string }
): Promise<PendingAccountPasskeyCredential> {
if (!window.PublicKeyCredential || !navigator.credentials) {
throw new Error(t('txt_passkey_browser_not_supported'));
}
const nativeOptions = cloneCreationOptions(response.options);
(nativeOptions as any).extensions = {
...((nativeOptions as any).extensions || {}),
prf: {},
};
const credential = await navigator.credentials.create({ publicKey: nativeOptions });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_created'));
}
const supportsPrf = !!(credential.getClientExtensionResults() as any).prf?.enabled;
return {
token: response.token,
createOptions: nativeOptions,
deviceResponse: credential,
request: attestationRequest(credential),
supportsPrf,
};
}
function parseRsaEncryptedUserKey(value: string): Uint8Array {
const text = String(value || '').trim();
const [type, payload] = text.split('.');
if (type !== '4' || !payload) throw new Error(t('txt_unsupported_encrypted_user_key'));
return base64ToBytes(payload);
}
export async function buildAccountPasskeyPrfKeySet(
pending: PendingAccountPasskeyCredential,
userKey: { symEncKey: string; symMacKey: string }
): Promise<AccountPasskeyPrfKeySet> {
const rawId = new Uint8Array(pending.deviceResponse.rawId);
const credentialId = bytesToBase64Url(rawId);
const assertionOptions: PublicKeyCredentialRequestOptions = {
challenge: pending.createOptions?.challenge!,
rpId: pending.createOptions?.rp?.id,
allowCredentials: [{ id: toArrayBuffer(rawId), type: 'public-key' }],
timeout: pending.createOptions?.timeout,
userVerification: pending.createOptions?.authenticatorSelection?.userVerification,
};
const assertion = await getPublicKeyCredentialWithPrf(
assertionOptions,
await getLoginWithPrfSalt(),
[credentialId]
);
const prfResult = readPrfFirstResult(assertion);
if (!prfResult) {
throw new AccountPasskeyPrfUnavailableError();
}
return buildAccountPasskeyPrfKeySetFromPrfKey(await prfOutputToKey(prfResult), userKey);
}
export async function buildAccountPasskeyPrfKeySetFromPrfKey(
prfKey: Uint8Array,
userKey: { symEncKey: string; symMacKey: string }
): Promise<AccountPasskeyPrfKeySet> {
const userKeyBytes = new Uint8Array(64);
userKeyBytes.set(base64ToBytes(userKey.symEncKey), 0);
userKeyBytes.set(base64ToBytes(userKey.symMacKey), 32);
const pair = await crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-1',
},
true,
['encrypt', 'decrypt']
);
const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', pair.publicKey));
const privateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', pair.privateKey));
const encryptedUserKeyBytes = new Uint8Array(await crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
pair.publicKey,
toBufferSource(userKeyBytes)
));
return {
encryptedUserKey: `4.${bytesToBase64(encryptedUserKeyBytes)}`,
encryptedPublicKey: await encryptBw(publicKey, userKeyBytes.slice(0, 32), userKeyBytes.slice(32, 64)),
encryptedPrivateKey: await encryptBw(privateKey, prfKey.slice(0, 32), prfKey.slice(32, 64)),
};
}
export async function unlockVaultKeyWithAccountPasskeyPrf(
prfKey: Uint8Array,
option: AccountPasskeyPrfOption
): Promise<{ symEncKey: string; symMacKey: string }> {
const encryptedPrivateKey = option.EncryptedPrivateKey || option.encryptedPrivateKey || '';
const encryptedUserKey = option.EncryptedUserKey || option.encryptedUserKey || '';
if (!encryptedPrivateKey || !encryptedUserKey) {
throw new Error(t('txt_passkey_cannot_unlock_vault'));
}
const privateKeyBytes = await decryptBw(encryptedPrivateKey, prfKey.slice(0, 32), prfKey.slice(32, 64));
const privateKey = await crypto.subtle.importKey(
'pkcs8',
toBufferSource(privateKeyBytes),
{ name: 'RSA-OAEP', hash: 'SHA-1' },
false,
['decrypt']
);
const userKeyBytes = new Uint8Array(await crypto.subtle.decrypt(
{ name: 'RSA-OAEP' },
privateKey,
toBufferSource(parseRsaEncryptedUserKey(encryptedUserKey))
));
if (userKeyBytes.length < 64) throw new Error(t('txt_invalid_passkey_vault_key'));
return {
symEncKey: bytesToBase64(userKeyBytes.slice(0, 32)),
symMacKey: bytesToBase64(userKeyBytes.slice(32, 64)),
};
}
+165
View File
@@ -2,11 +2,13 @@ import { bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from '../cryp
import { t, translateServerError } from '../i18n';
import type { AuthorizedDevice } from '../types';
import type {
AccountPasskeyCredential,
Profile,
SessionState,
TokenError,
TokenSuccess,
} from '../types';
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
const SESSION_KEY = 'nodewarden.web.session.v4';
@@ -281,6 +283,40 @@ export async function loginWithPassword(
return json;
}
export async function getAccountPasskeyAssertionOptions(): Promise<{ options: unknown; token: string }> {
const resp = await fetch('/identity/accounts/webauthn/assertion-options');
if (!resp.ok) {
const json = await parseJson<TokenError>(resp);
throw new Error(translateServerError(json?.error_description || json?.error, t('txt_login_failed')));
}
const body = (await parseJson<{ options?: unknown; token?: string }>(resp)) || {};
if (!body.options || !body.token) throw new Error('Invalid passkey assertion options');
return { options: body.options, token: body.token };
}
export async function loginWithAccountPasskeyAssertion(assertion: AccountPasskeyAssertion): Promise<TokenSuccess | TokenError> {
const body = new URLSearchParams();
body.set('grant_type', 'webauthn');
body.set('token', assertion.token);
body.set('deviceResponse', JSON.stringify(assertion.deviceResponse));
body.set('scope', 'api offline_access');
body.set('deviceIdentifier', getOrCreateDeviceIdentifier());
body.set('deviceName', guessDeviceName());
body.set('deviceType', '14');
const resp = await fetch('/identity/connect/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
[WEB_SESSION_HEADER]: '1',
},
body: body.toString(),
});
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
if (!resp.ok) return json;
return json;
}
function isTransientRefreshStatus(status: number): boolean {
return status === 0 || status === 429 || status >= 500;
}
@@ -605,6 +641,135 @@ export async function verifyMasterPassword(
}
}
function normalizeAccountPasskeyCredential(raw: any): AccountPasskeyCredential {
return {
id: String(raw?.id || raw?.Id || ''),
name: String(raw?.name || raw?.Name || ''),
prfStatus: Number(raw?.prfStatus ?? raw?.PrfStatus ?? 2) as 0 | 1 | 2,
encryptedPublicKey: raw?.encryptedPublicKey ?? raw?.EncryptedPublicKey ?? null,
encryptedUserKey: raw?.encryptedUserKey ?? raw?.EncryptedUserKey ?? null,
creationDate: raw?.creationDate ?? raw?.CreationDate,
revisionDate: raw?.revisionDate ?? raw?.RevisionDate,
};
}
export async function listAccountPasskeys(authedFetch: AuthedFetch): Promise<AccountPasskeyCredential[]> {
const resp = await authedFetch('/api/webauthn');
if (!resp.ok) throw new Error('Failed to load account passkeys');
const body = (await parseJson<{ data?: unknown[]; Data?: unknown[] }>(resp)) || {};
const rows = Array.isArray(body.data) ? body.data : Array.isArray(body.Data) ? body.Data : [];
return rows.map(normalizeAccountPasskeyCredential).filter((item) => item.id);
}
export async function getAccountPasskeyAttestationOptions(
authedFetch: AuthedFetch,
masterPasswordHash: string
): Promise<{ options: unknown; token: string }> {
const resp = await authedFetch('/api/webauthn/attestation-options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
}
const body = (await parseJson<{ options?: unknown; token?: string }>(resp)) || {};
if (!body.options || !body.token) throw new Error('Invalid passkey creation options');
return { options: body.options, token: body.token };
}
export async function getAccountPasskeyUpdateAssertionOptions(
authedFetch: AuthedFetch,
masterPasswordHash: string,
credentialId?: string
): Promise<{ options: unknown; token: string }> {
const resp = await authedFetch('/api/webauthn/assertion-options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash, credentialId }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
}
const body = (await parseJson<{ options?: unknown; token?: string }>(resp)) || {};
if (!body.options || !body.token) throw new Error('Invalid passkey assertion options');
return { options: body.options, token: body.token };
}
export async function saveAccountPasskey(
authedFetch: AuthedFetch,
payload: {
name: string;
token: string;
deviceResponse: unknown;
supportsPrf: boolean;
keySet?: AccountPasskeyPrfKeySet | null;
}
): Promise<AccountPasskeyCredential> {
const resp = await authedFetch('/api/webauthn', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: payload.name,
token: payload.token,
deviceResponse: payload.deviceResponse,
supportsPrf: payload.supportsPrf,
encryptedUserKey: payload.keySet?.encryptedUserKey,
encryptedPublicKey: payload.keySet?.encryptedPublicKey,
encryptedPrivateKey: payload.keySet?.encryptedPrivateKey,
}),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_save_profile_failed')));
}
const body = await parseJson<unknown>(resp);
return normalizeAccountPasskeyCredential(body);
}
export async function enableAccountPasskeyDirectUnlock(
authedFetch: AuthedFetch,
payload: {
token: string;
deviceResponse: unknown;
keySet: AccountPasskeyPrfKeySet;
}
): Promise<void> {
const resp = await authedFetch('/api/webauthn', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: payload.token,
deviceResponse: payload.deviceResponse,
encryptedUserKey: payload.keySet.encryptedUserKey,
encryptedPublicKey: payload.keySet.encryptedPublicKey,
encryptedPrivateKey: payload.keySet.encryptedPrivateKey,
}),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_save_profile_failed')));
}
}
export async function deleteAccountPasskey(
authedFetch: AuthedFetch,
id: string,
masterPasswordHash: string
): Promise<void> {
const resp = await authedFetch(`/api/webauthn/${encodeURIComponent(id)}/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_delete_item_failed')));
}
}
export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise<number> {
const resp = await authedFetch('/api/accounts/revision-date');
if (!resp.ok) {
+1
View File
@@ -96,6 +96,7 @@ export interface AdminBackupImportCounts {
users: number;
domainSettings?: number;
userRevisions: number;
webauthnCredentials?: number;
folders: number;
ciphers: number;
attachments: number;
+118 -19
View File
@@ -1,15 +1,21 @@
import {
createAuthedFetch,
deriveLoginHashLocally,
getAccountPasskeyAssertionOptions,
getProfile,
loadProfileSnapshot,
loadSession,
loginWithAccountPasskeyAssertion,
loginWithPassword,
refreshAccessToken,
recoverTwoFactor,
registerAccount,
unlockVaultKey,
} from '@/lib/api/auth';
import {
assertAccountPasskey,
unlockVaultKeyWithAccountPasskeyPrf,
} from '@/lib/account-passkeys';
import { readInviteCodeFromUrl } from '@/lib/app-support';
import { t, translateServerError } from '@/lib/i18n';
import {
@@ -21,7 +27,7 @@ import {
unlockOfflineVaultWithMasterKey,
} from '@/lib/offline-auth';
import { probeNodeWardenService } from '@/lib/network-status';
import type { AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
import type { AccountPasskeyPrfOption, AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
export interface PendingTotp {
email: string;
@@ -30,6 +36,12 @@ export interface PendingTotp {
kdfIterations: number;
}
export interface PendingPasskeyPassword {
token: TokenSuccess;
email: string;
kdfIterations: number;
}
export type JwtUnsafeReason = 'missing' | 'default' | 'too_short';
export interface BootstrapAppResult {
@@ -61,6 +73,11 @@ export type PasswordLoginResult =
| { kind: 'totp'; pendingTotp: PendingTotp }
| { kind: 'error'; message: string };
export type PasskeyLoginResult =
| { kind: 'success'; login: CompletedLogin }
| { kind: 'password'; pendingPasskeyPassword: PendingPasskeyPassword }
| { kind: 'error'; message: string };
export interface RecoverTwoFactorResult {
login: CompletedLogin | null;
newRecoveryCode: string | null;
@@ -92,6 +109,7 @@ async function maybeRefreshSession(session: SessionState): Promise<SessionState
const refreshed = await refreshAccessToken(session);
if (!refreshed.ok) {
if (refreshed.transient) return session;
return session.accessToken && exp !== null && exp > nowSeconds ? session : null;
}
@@ -107,16 +125,6 @@ function browserReportsOffline(): boolean {
return typeof navigator !== 'undefined' && navigator.onLine === false;
}
function createTimeoutAbortController(timeoutMs: number): { controller: AbortController; cancel: () => void } | null {
if (typeof AbortController === 'undefined') return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
return {
controller,
cancel: () => clearTimeout(timer),
};
}
function readWindowBootstrap(): WebBootstrapResponse {
if (typeof window === 'undefined') return {};
const raw = (window as Window & { __NW_BOOT__?: WebBootstrapResponse }).__NW_BOOT__;
@@ -270,8 +278,10 @@ export async function hydrateLockedSession(
session: SessionState,
fallbackProfile: Profile | null = null
): Promise<{ session: SessionState | null; profile: Profile | null }> {
if (hasOfflineUnlockRecord(session.email)) {
const serviceReachable = await probeNodeWardenService();
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
let serviceReachable = true;
if (hasOfflineUnlock) {
serviceReachable = await probeNodeWardenService();
if (!serviceReachable) {
return {
session,
@@ -282,7 +292,7 @@ export async function hydrateLockedSession(
const refreshedSession = await maybeRefreshSession(session);
if (!refreshedSession?.accessToken) {
if (hasOfflineUnlockRecord(session.email)) {
if (hasOfflineUnlock && !serviceReachable) {
return {
session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
@@ -345,6 +355,43 @@ export async function completeLogin(
};
}
function readPasskeyPrfOption(token: TokenSuccess): AccountPasskeyPrfOption | null {
const options = (token.UserDecryptionOptions || token.userDecryptionOptions || null) as any;
return options?.WebAuthnPrfOption || options?.webAuthnPrfOption || null;
}
async function completeLoginWithVaultKeys(
token: TokenSuccess,
email: string,
keys: { symEncKey: string; symMacKey: string },
fallbackKdfIterations: number
): Promise<CompletedLogin> {
const normalizedEmail = email.trim().toLowerCase();
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
const baseSession: SessionState = {
accessToken: token.access_token,
refreshToken: token.refresh_token,
email: normalizedEmail,
authMode: token.web_session ? 'web-cookie' : 'token',
};
const tempFetch = createAuthedFetch(
() => baseSession,
() => {}
);
const profile = buildTransientProfile(token, normalizedEmail, fallbackProfile);
saveOfflineUnlockRecord({
email: normalizedEmail,
profile,
profileKey: profile.key,
kdfIterations: kdfIterationsFromLogin(token, fallbackKdfIterations),
});
return {
session: { ...baseSession, ...keys },
profile,
profilePromise: getProfile(tempFetch),
};
}
export async function performPasswordLogin(
email: string,
password: string,
@@ -380,6 +427,62 @@ export async function performPasswordLogin(
};
}
export async function performPasskeyLogin(fallbackIterations: number, expectedEmail?: string): Promise<PasskeyLoginResult> {
try {
const options = await getAccountPasskeyAssertionOptions();
const assertion = await assertAccountPasskey(options);
const token = await loginWithAccountPasskeyAssertion(assertion);
if (!('access_token' in token) || !token.access_token) {
const tokenError = token as { error_description?: string; error?: string };
return {
kind: 'error',
message: translateServerError(tokenError.error_description || tokenError.error, t('txt_login_failed')),
};
}
const email = (decodeAccessTokenClaims(token.access_token).email || '').trim().toLowerCase();
if (!email) {
return { kind: 'error', message: t('txt_login_failed') };
}
const normalizedExpectedEmail = String(expectedEmail || '').trim().toLowerCase();
if (normalizedExpectedEmail && email !== normalizedExpectedEmail) {
return { kind: 'error', message: t('txt_passkey_not_for_locked_account') };
}
const prfOption = readPasskeyPrfOption(token);
if (prfOption && assertion.prfKey) {
const keys = await unlockVaultKeyWithAccountPasskeyPrf(assertion.prfKey, prfOption);
return {
kind: 'success',
login: await completeLoginWithVaultKeys(token, email, keys, fallbackIterations),
};
}
return {
kind: 'password',
pendingPasskeyPassword: {
token,
email,
kdfIterations: kdfIterationsFromLogin(token, fallbackIterations),
},
};
} catch (error) {
return {
kind: 'error',
message: error instanceof Error ? translateServerError(error.message, error.message) : t('txt_login_failed'),
};
}
}
export async function completePasskeyPasswordLogin(
pending: PendingPasskeyPassword,
password: string
): Promise<CompletedLogin> {
const derived = await deriveLoginHashLocally(pending.email, password, pending.kdfIterations);
return completeLogin(pending.token, pending.email, derived.masterKey, pending.kdfIterations);
}
export async function performTotpLogin(
pendingTotp: PendingTotp,
totpCode: string,
@@ -479,22 +582,18 @@ export async function performUnlock(
}
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
const abortable = hasOfflineUnlock ? createTimeoutAbortController(2500) : null;
try {
token = await loginWithPassword(normalizedEmail, derived.hash, {
useRememberToken: true,
signal: abortable?.controller.signal,
});
} catch {
if (hasOfflineUnlock) {
if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) {
return unlockOffline();
}
return {
kind: 'error',
message: t('txt_unlock_failed_master_password_is_incorrect'),
};
} finally {
abortable?.cancel();
}
if ('access_token' in token && token.access_token) {
+43
View File
@@ -631,6 +631,49 @@ const en: Record<string, string> = {
"txt_passkey": "Passkey",
"txt_passkeys": "Passkeys",
"txt_passkey_created_at_value": "Created on {value}",
"txt_account_passkey": "Account passkey",
"txt_account_passkeys": "Account passkeys",
"txt_account_passkey_mode": "Unlock mode",
"txt_account_passkey_direct_unlock_mode": "Direct vault unlock",
"txt_account_passkey_direct_unlock_help": "Unlocks the vault with this passkey when PRF is available.",
"txt_account_passkey_login_only_help": "Verifies the account with passkey, then asks for master password.",
"txt_account_passkey_name_placeholder": "This device",
"txt_account_passkey_saved": "Account passkey saved",
"txt_account_passkey_deleted": "Account passkey deleted",
"txt_account_passkeys_load_failed": "Failed to load account passkeys",
"txt_account_passkey_not_found": "Account passkey not found",
"txt_account_passkey_prf_not_available": "This passkey cannot return a PRF key",
"txt_account_passkey_direct_unlock_enabled": "Direct vault unlock enabled",
"txt_account_passkey_direct_unlock_unavailable_title": "Direct unlock unavailable",
"txt_account_passkey_direct_unlock_unavailable_message": "This passkey did not return a PRF key, so it cannot unlock the vault directly. You can still save it for account login; unlocking the vault will require your master password.",
"txt_account_passkey_direct_unlock_unavailable_error": "This passkey cannot unlock the vault directly",
"txt_account_passkey_saved_login_only": "Account passkey saved for login only",
"txt_account_passkey_not_saved": "Account passkey was not saved",
"txt_save_login_only_passkey": "Save for login only",
"txt_do_not_save": "Do not save",
"txt_add_account_passkey": "Add account passkey",
"txt_delete_account_passkey": "Delete account passkey",
"txt_direct_unlock": "Direct unlock",
"txt_enable_passkey_direct_unlock": "Enable direct unlock",
"txt_login_only": "Login only",
"txt_login_with_passkey": "Log in with passkey",
"txt_unlock_with_passkey": "Unlock with passkey",
"txt_no_account_passkeys": "No account passkeys",
"txt_passkey_name": "Passkey name",
"txt_passkey_requires_master_password": "Passkey verified. Enter your master password to unlock the vault.",
"txt_passkey_not_for_locked_account": "This passkey is for a different account",
"txt_prf_not_supported": "PRF not supported",
"txt_invalid_passkey_creation_options": "Invalid passkey creation options",
"txt_invalid_passkey_assertion_options": "Invalid passkey verification options",
"txt_invalid_passkey_assertion_response": "Invalid passkey verification response",
"txt_invalid_passkey_registration_response": "Invalid passkey registration response",
"txt_passkey_browser_not_supported": "This browser does not support passkeys",
"txt_no_passkey_selected": "No passkey was selected",
"txt_no_passkey_created": "No passkey was created",
"txt_unsupported_encrypted_user_key": "Unsupported encrypted account key",
"txt_passkey_verification_failed": "Passkey verification failed",
"txt_passkey_cannot_unlock_vault": "This passkey cannot unlock this vault",
"txt_invalid_passkey_vault_key": "Invalid passkey vault key",
"txt_phone": "Phone",
"txt_please_input_email_and_password": "Please input email and password",
"txt_please_input_master_password": "Please input master password",
+43
View File
@@ -631,6 +631,49 @@ const es: Record<string, string> = {
"txt_passkey": "Clave de acceso",
"txt_passkeys": "Claves de acceso",
"txt_passkey_created_at_value": "Creado el {value}",
"txt_account_passkey": "Clave de acceso de cuenta",
"txt_account_passkeys": "Claves de acceso de cuenta",
"txt_account_passkey_mode": "Modo de desbloqueo",
"txt_account_passkey_direct_unlock_mode": "Desbloqueo directo",
"txt_account_passkey_direct_unlock_help": "Desbloquea la bóveda con esta clave de acceso cuando PRF está disponible.",
"txt_account_passkey_login_only_help": "Verifica la cuenta con una clave de acceso y luego pide la contraseña maestra.",
"txt_account_passkey_name_placeholder": "Este dispositivo",
"txt_account_passkey_saved": "Clave de acceso de cuenta guardada",
"txt_account_passkey_deleted": "Clave de acceso de cuenta eliminada",
"txt_account_passkeys_load_failed": "Error al cargar claves de acceso de cuenta",
"txt_account_passkey_not_found": "Clave de acceso de cuenta no encontrada",
"txt_account_passkey_prf_not_available": "Esta clave de acceso no puede devolver una clave PRF",
"txt_account_passkey_direct_unlock_enabled": "Desbloqueo directo activado",
"txt_account_passkey_direct_unlock_unavailable_title": "Desbloqueo directo no disponible",
"txt_account_passkey_direct_unlock_unavailable_message": "Esta clave de acceso no devolvió una clave PRF, por lo que no puede desbloquear la bóveda directamente. Aun así puede guardarla para iniciar sesión; para desbloquear la bóveda necesitará la contraseña maestra.",
"txt_account_passkey_direct_unlock_unavailable_error": "Esta clave de acceso no puede desbloquear la bóveda directamente",
"txt_account_passkey_saved_login_only": "Clave de acceso de cuenta guardada solo para inicio de sesión",
"txt_account_passkey_not_saved": "La clave de acceso de cuenta no se guardó",
"txt_save_login_only_passkey": "Guardar solo para inicio",
"txt_do_not_save": "No guardar",
"txt_add_account_passkey": "Añadir clave de acceso de cuenta",
"txt_delete_account_passkey": "Eliminar clave de acceso de cuenta",
"txt_direct_unlock": "Desbloqueo directo",
"txt_enable_passkey_direct_unlock": "Activar desbloqueo directo",
"txt_login_only": "Solo inicio de sesión",
"txt_login_with_passkey": "Iniciar sesión con clave de acceso",
"txt_unlock_with_passkey": "Desbloquear con clave de acceso",
"txt_no_account_passkeys": "Sin claves de acceso de cuenta",
"txt_passkey_name": "Nombre de la clave de acceso",
"txt_passkey_requires_master_password": "Clave de acceso verificada. Introduzca su contraseña maestra para desbloquear la bóveda.",
"txt_passkey_not_for_locked_account": "Esta clave de acceso pertenece a otra cuenta",
"txt_prf_not_supported": "PRF no compatible",
"txt_invalid_passkey_creation_options": "Opciones de creación de clave de acceso no válidas",
"txt_invalid_passkey_assertion_options": "Opciones de verificación de clave de acceso no válidas",
"txt_invalid_passkey_assertion_response": "Respuesta de verificación de clave de acceso no válida",
"txt_invalid_passkey_registration_response": "Respuesta de registro de clave de acceso no válida",
"txt_passkey_browser_not_supported": "Este navegador no admite claves de acceso",
"txt_no_passkey_selected": "No se seleccionó ninguna clave de acceso",
"txt_no_passkey_created": "No se creó ninguna clave de acceso",
"txt_unsupported_encrypted_user_key": "Clave de cuenta cifrada no compatible",
"txt_passkey_verification_failed": "Error al verificar la clave de acceso",
"txt_passkey_cannot_unlock_vault": "Esta clave de acceso no puede desbloquear esta bóveda",
"txt_invalid_passkey_vault_key": "Clave de bóveda de clave de acceso no válida",
"txt_phone": "Teléfono",
"txt_please_input_email_and_password": "Por favor, introduzca correo y contraseña",
"txt_please_input_master_password": "Por favor, introduzca contraseña maestra",
+44 -1
View File
@@ -367,7 +367,7 @@ const ru: Record<string, string> = {
"txt_delete_all_invite_codes_active_inactive": "Удалить все пригласительные коды (активные/неактивные)?",
"txt_delete_all_invites": "Удалить все приглашения",
"txt_delete_item": "Удалить элемент",
"txt_delete_passkey": "Удалить пароль",
"txt_delete_passkey": "Удалить ключ доступа",
"txt_delete_item_failed": "Удалить элемент не удалось",
"txt_permanent_delete_item_failed": "Не удалось окончательно удалить элемент",
"txt_delete_permanently": "Удалить навсегда",
@@ -631,6 +631,49 @@ const ru: Record<string, string> = {
"txt_passkey": "Ключ доступа",
"txt_passkeys": "Ключи доступа",
"txt_passkey_created_at_value": "Создано {value}",
"txt_account_passkey": "Ключ доступа аккаунта",
"txt_account_passkeys": "Ключи доступа аккаунта",
"txt_account_passkey_mode": "Режим разблокировки",
"txt_account_passkey_direct_unlock_mode": "Прямая разблокировка",
"txt_account_passkey_direct_unlock_help": "Разблокирует хранилище этим ключом доступа, когда доступен PRF.",
"txt_account_passkey_login_only_help": "Проверяет аккаунт ключом доступа, затем запрашивает мастер-пароль.",
"txt_account_passkey_name_placeholder": "Это устройство",
"txt_account_passkey_saved": "Ключ доступа аккаунта сохранен",
"txt_account_passkey_deleted": "Ключ доступа аккаунта удален",
"txt_account_passkeys_load_failed": "Не удалось загрузить ключи доступа аккаунта",
"txt_account_passkey_not_found": "Ключ доступа аккаунта не найден",
"txt_account_passkey_prf_not_available": "Этот ключ доступа не может вернуть PRF-ключ",
"txt_account_passkey_direct_unlock_enabled": "Прямая разблокировка включена",
"txt_account_passkey_direct_unlock_unavailable_title": "Прямая разблокировка недоступна",
"txt_account_passkey_direct_unlock_unavailable_message": "Этот ключ доступа не вернул PRF-ключ, поэтому не может напрямую разблокировать хранилище. Его все равно можно сохранить для входа в аккаунт; для разблокировки хранилища потребуется мастер-пароль.",
"txt_account_passkey_direct_unlock_unavailable_error": "Этот ключ доступа не может напрямую разблокировать хранилище",
"txt_account_passkey_saved_login_only": "Ключ доступа аккаунта сохранен только для входа",
"txt_account_passkey_not_saved": "Ключ доступа аккаунта не сохранен",
"txt_save_login_only_passkey": "Сохранить только для входа",
"txt_do_not_save": "Не сохранять",
"txt_add_account_passkey": "Добавить ключ доступа аккаунта",
"txt_delete_account_passkey": "Удалить ключ доступа аккаунта",
"txt_direct_unlock": "Прямая разблокировка",
"txt_enable_passkey_direct_unlock": "Включить прямую разблокировку",
"txt_login_only": "Только вход",
"txt_login_with_passkey": "Войти с ключом доступа",
"txt_unlock_with_passkey": "Разблокировать ключом доступа",
"txt_no_account_passkeys": "Нет ключей доступа аккаунта",
"txt_passkey_name": "Название ключа доступа",
"txt_passkey_requires_master_password": "Ключ доступа подтвержден. Введите мастер-пароль, чтобы разблокировать хранилище.",
"txt_passkey_not_for_locked_account": "Этот ключ доступа относится к другому аккаунту",
"txt_prf_not_supported": "PRF не поддерживается",
"txt_invalid_passkey_creation_options": "Недопустимые параметры создания ключа доступа",
"txt_invalid_passkey_assertion_options": "Недопустимые параметры проверки ключа доступа",
"txt_invalid_passkey_assertion_response": "Недопустимый ответ проверки ключа доступа",
"txt_invalid_passkey_registration_response": "Недопустимый ответ регистрации ключа доступа",
"txt_passkey_browser_not_supported": "Этот браузер не поддерживает ключи доступа",
"txt_no_passkey_selected": "Ключ доступа не выбран",
"txt_no_passkey_created": "Ключ доступа не создан",
"txt_unsupported_encrypted_user_key": "Неподдерживаемый зашифрованный ключ аккаунта",
"txt_passkey_verification_failed": "Не удалось проверить ключ доступа",
"txt_passkey_cannot_unlock_vault": "Этот ключ доступа не может разблокировать это хранилище",
"txt_invalid_passkey_vault_key": "Недопустимый ключ хранилища ключа доступа",
"txt_phone": "Телефон",
"txt_please_input_email_and_password": "Пожалуйста, введите адрес электронной почты и пароль",
"txt_please_input_master_password": "Пожалуйста, введите мастер-пароль",
+43
View File
@@ -631,6 +631,49 @@ const zhCN: Record<string, string> = {
"txt_passkey": "通行密钥",
"txt_passkeys": "通行密钥",
"txt_passkey_created_at_value": "创建于 {value}",
"txt_account_passkey": "账号通行密钥",
"txt_account_passkeys": "账号通行密钥",
"txt_account_passkey_mode": "解锁模式",
"txt_account_passkey_direct_unlock_mode": "直接解锁密码库",
"txt_account_passkey_direct_unlock_help": "支持 PRF 时,用这把通行密钥直接解锁密码库。",
"txt_account_passkey_login_only_help": "先用通行密钥验证账号,再输入主密码解锁。",
"txt_account_passkey_name_placeholder": "这台设备",
"txt_account_passkey_saved": "账号通行密钥已保存",
"txt_account_passkey_deleted": "账号通行密钥已删除",
"txt_account_passkeys_load_failed": "加载账号通行密钥失败",
"txt_account_passkey_not_found": "未找到账号通行密钥",
"txt_account_passkey_prf_not_available": "这把通行密钥无法返回 PRF 密钥",
"txt_account_passkey_direct_unlock_enabled": "已开启直接解锁",
"txt_account_passkey_direct_unlock_unavailable_title": "无法直接解锁",
"txt_account_passkey_direct_unlock_unavailable_message": "这把通行密钥没有返回 PRF 密钥,因此不能直接解锁密码库。你仍然可以把它保存为仅登录通行密钥;登录后需要输入主密码解锁。",
"txt_account_passkey_direct_unlock_unavailable_error": "这把通行密钥无法直接解锁密码库",
"txt_account_passkey_saved_login_only": "已保存为仅登录通行密钥",
"txt_account_passkey_not_saved": "通行密钥未保存",
"txt_save_login_only_passkey": "保存为仅登录",
"txt_do_not_save": "不保存",
"txt_add_account_passkey": "添加账号通行密钥",
"txt_delete_account_passkey": "删除账号通行密钥",
"txt_direct_unlock": "直接解锁",
"txt_enable_passkey_direct_unlock": "开启直接解锁",
"txt_login_only": "仅登录",
"txt_login_with_passkey": "使用通行密钥登录",
"txt_unlock_with_passkey": "使用通行密钥解锁",
"txt_no_account_passkeys": "暂无账号通行密钥",
"txt_passkey_name": "通行密钥名称",
"txt_passkey_requires_master_password": "通行密钥已验证,请输入主密码解锁密码库。",
"txt_passkey_not_for_locked_account": "这把通行密钥属于其他账号",
"txt_prf_not_supported": "不支持 PRF",
"txt_invalid_passkey_creation_options": "通行密钥创建选项无效",
"txt_invalid_passkey_assertion_options": "通行密钥验证选项无效",
"txt_invalid_passkey_assertion_response": "通行密钥验证响应无效",
"txt_invalid_passkey_registration_response": "通行密钥注册响应无效",
"txt_passkey_browser_not_supported": "当前浏览器不支持通行密钥",
"txt_no_passkey_selected": "未选择通行密钥",
"txt_no_passkey_created": "未创建通行密钥",
"txt_unsupported_encrypted_user_key": "不支持的加密账户密钥",
"txt_passkey_verification_failed": "通行密钥验证失败",
"txt_passkey_cannot_unlock_vault": "这把通行密钥无法解锁此密码库",
"txt_invalid_passkey_vault_key": "通行密钥密码库密钥无效",
"txt_phone": "电话",
"txt_please_input_email_and_password": "请输入邮箱和密码",
"txt_please_input_master_password": "请输入主密码",
+43
View File
@@ -631,6 +631,49 @@ const zhTW: Record<string, string> = {
"txt_passkey": "通行密鑰",
"txt_passkeys": "通行密鑰",
"txt_passkey_created_at_value": "創建於 {value}",
"txt_account_passkey": "賬號通行密鑰",
"txt_account_passkeys": "賬號通行密鑰",
"txt_account_passkey_mode": "解鎖模式",
"txt_account_passkey_direct_unlock_mode": "直接解鎖密碼庫",
"txt_account_passkey_direct_unlock_help": "支持 PRF 時,用這把通行密鑰直接解鎖密碼庫。",
"txt_account_passkey_login_only_help": "先用通行密鑰驗證賬號,再輸入主密碼解鎖。",
"txt_account_passkey_name_placeholder": "這台設備",
"txt_account_passkey_saved": "賬號通行密鑰已保存",
"txt_account_passkey_deleted": "賬號通行密鑰已刪除",
"txt_account_passkeys_load_failed": "加載賬號通行密鑰失敗",
"txt_account_passkey_not_found": "未找到賬號通行密鑰",
"txt_account_passkey_prf_not_available": "這把通行密鑰無法返回 PRF 密鑰",
"txt_account_passkey_direct_unlock_enabled": "已開啟直接解鎖",
"txt_account_passkey_direct_unlock_unavailable_title": "無法直接解鎖",
"txt_account_passkey_direct_unlock_unavailable_message": "這把通行密鑰沒有返回 PRF 密鑰,因此不能直接解鎖密碼庫。你仍然可以把它保存為僅登錄通行密鑰;登錄後需要輸入主密碼解鎖。",
"txt_account_passkey_direct_unlock_unavailable_error": "這把通行密鑰無法直接解鎖密碼庫",
"txt_account_passkey_saved_login_only": "已保存為僅登錄通行密鑰",
"txt_account_passkey_not_saved": "通行密鑰未保存",
"txt_save_login_only_passkey": "保存為僅登錄",
"txt_do_not_save": "不保存",
"txt_add_account_passkey": "添加賬號通行密鑰",
"txt_delete_account_passkey": "刪除賬號通行密鑰",
"txt_direct_unlock": "直接解鎖",
"txt_enable_passkey_direct_unlock": "開啟直接解鎖",
"txt_login_only": "僅登錄",
"txt_login_with_passkey": "使用通行密鑰登錄",
"txt_unlock_with_passkey": "使用通行密鑰解鎖",
"txt_no_account_passkeys": "暫無賬號通行密鑰",
"txt_passkey_name": "通行密鑰名稱",
"txt_passkey_requires_master_password": "通行密鑰已驗證,請輸入主密碼解鎖密碼庫。",
"txt_passkey_not_for_locked_account": "這把通行密鑰屬於其他賬號",
"txt_prf_not_supported": "不支持 PRF",
"txt_invalid_passkey_creation_options": "通行密鑰創建選項無效",
"txt_invalid_passkey_assertion_options": "通行密鑰驗證選項無效",
"txt_invalid_passkey_assertion_response": "通行密鑰驗證響應無效",
"txt_invalid_passkey_registration_response": "通行密鑰註冊響應無效",
"txt_passkey_browser_not_supported": "當前瀏覽器不支持通行密鑰",
"txt_no_passkey_selected": "未選擇通行密鑰",
"txt_no_passkey_created": "未創建通行密鑰",
"txt_unsupported_encrypted_user_key": "不支持的加密賬號密鑰",
"txt_passkey_verification_failed": "通行密鑰驗證失敗",
"txt_passkey_cannot_unlock_vault": "這把通行密鑰無法解鎖此密碼庫",
"txt_invalid_passkey_vault_key": "通行密鑰密碼庫密鑰無效",
"txt_phone": "電話",
"txt_please_input_email_and_password": "請輸入郵箱和密碼",
"txt_please_input_master_password": "請輸入主密碼",
+6 -4
View File
@@ -6,14 +6,14 @@ const listeners = new Set<(status: NetworkStatus) => void>();
let currentStatus: NetworkStatus = getInitialNetworkStatus();
let pendingProbe: Promise<boolean> | null = null;
let lastProbeAt = 0;
let lastProbeResult = false;
let lastProbeResult = currentStatus === 'online';
export function browserReportsOffline(): boolean {
return typeof navigator !== 'undefined' && navigator.onLine === false;
}
export function getInitialNetworkStatus(): NetworkStatus {
return 'offline';
return browserReportsOffline() ? 'offline' : 'online';
}
export function getCurrentNetworkStatus(): NetworkStatus {
@@ -51,7 +51,7 @@ export async function probeNodeWardenService(): Promise<boolean> {
: 0;
pendingProbe = (async () => {
const response = await fetch(`/api/web-bootstrap?statusProbe=${Date.now()}`, {
await fetch(`/api/web-bootstrap?statusProbe=${Date.now()}`, {
method: 'GET',
cache: 'no-store',
headers: {
@@ -61,7 +61,9 @@ export async function probeNodeWardenService(): Promise<boolean> {
},
signal: controller?.signal,
});
return response.ok;
// Any same-origin HTTP response proves the server is reachable. A 4xx/5xx
// response may be an application problem, but it is not offline mode.
return true;
})()
.catch(() => false)
.then((result) => {
+1 -1
View File
@@ -43,7 +43,7 @@ function parseRecord(raw: string | null): OfflineUnlockRecord | null {
name: email,
key: '',
privateKey: null,
role: 'user',
role: 'user' as const,
};
return {
version: 1,
+31
View File
@@ -328,6 +328,37 @@ export interface TokenError {
TwoFactorProviders?: unknown;
}
export interface AccountPasskeyCredential {
id: string;
name: string;
prfStatus: 0 | 1 | 2;
encryptedPublicKey?: string | null;
encryptedUserKey?: string | null;
creationDate?: string;
revisionDate?: string;
}
export interface AccountPasskeyAssertionOptionsResponse {
options: PublicKeyCredentialRequestOptions;
token: string;
}
export interface AccountPasskeyCreationOptionsResponse {
options: PublicKeyCredentialCreationOptions;
token: string;
}
export interface AccountPasskeyPrfOption {
EncryptedPrivateKey?: string;
EncryptedUserKey?: string;
CredentialId?: string;
Transports?: string[];
encryptedPrivateKey?: string;
encryptedUserKey?: string;
credentialId?: string;
transports?: string[];
}
export interface ToastMessage {
id: string;
type: 'success' | 'error' | 'warning';
+62
View File
@@ -1062,6 +1062,68 @@
color: var(--success);
}
.account-passkey-mode-field {
@apply min-w-0;
}
.account-passkey-toggle {
@apply flex min-h-[44px] items-center gap-2 rounded-lg border px-3 text-sm font-extrabold;
border-color: var(--line);
background: color-mix(in srgb, var(--panel) 92%, var(--panel-2));
color: var(--text);
}
.account-passkey-toggle input {
@apply h-4 w-4 shrink-0;
accent-color: var(--primary);
}
.account-passkeys-list {
@apply mt-3 grid gap-2;
}
.account-passkey-row {
@apply grid min-w-0 items-center gap-3 rounded-lg border p-3;
grid-template-columns: minmax(0, 1fr) auto auto;
border-color: var(--line);
background: var(--panel);
}
.account-passkey-main {
@apply grid min-w-0 gap-1;
}
.account-passkey-main strong,
.account-passkey-main small {
@apply min-w-0 overflow-hidden text-ellipsis whitespace-nowrap;
}
.account-passkey-main small {
color: var(--muted);
}
.account-passkey-status {
@apply inline-flex min-h-7 shrink-0 items-center rounded-full px-2.5 text-xs font-extrabold;
border: 1px solid var(--line);
color: var(--muted);
}
.account-passkey-status-0 {
border-color: color-mix(in srgb, var(--success) 28%, var(--line));
background: color-mix(in srgb, var(--success) 9%, var(--panel));
color: var(--success);
}
.account-passkey-status-1 {
border-color: color-mix(in srgb, var(--primary) 30%, var(--line));
background: color-mix(in srgb, var(--primary) 9%, var(--panel));
color: var(--primary-strong);
}
.account-passkey-actions {
@apply justify-end;
}
.settings-module-placeholder {
@apply flex min-h-[150px] flex-col items-center justify-center gap-3 text-base font-extrabold;
color: var(--muted);
+15
View File
@@ -933,6 +933,21 @@
gap: 7px;
}
.account-passkey-row {
grid-template-columns: 1fr;
gap: 8px;
padding: 10px;
}
.account-passkey-status {
justify-self: flex-start;
}
.account-passkey-actions,
.account-passkey-actions .btn {
width: 100%;
}
.settings-module .totp-grid {
gap: 8px;
margin-bottom: 8px;