mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
Compare commits
19 Commits
192071e4a7
..
v1.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3035a77579 | |||
| 28333f0e9b | |||
| 91320a4eba | |||
| 19b96a7aca | |||
| 18e0396c0a | |||
| 18d3490c4f | |||
| 615caf5946 | |||
| 1a10df4a18 | |||
| d4749d3f82 | |||
| 5ed7c949c1 | |||
| af70cab766 | |||
| bfea5d0a1c | |||
| cda654e1c3 | |||
| 1ee7b0f31b | |||
| 2d2cbea530 | |||
| 4f5d992f10 | |||
| 667afa305b | |||
| 85bd2fa4bf | |||
| fd9707c396 |
@@ -0,0 +1,17 @@
|
||||
# CodeGraph data files
|
||||
# These are local to each machine and should not be committed
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Cache
|
||||
cache/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Hook markers
|
||||
.dirty
|
||||
*.pid
|
||||
@@ -40,6 +40,7 @@ npm-debug.log*
|
||||
|
||||
tmp/
|
||||
.tmp/
|
||||
.tmp-bitwarden-clients/
|
||||
|
||||
nodewarden.wiki/
|
||||
wiki/
|
||||
@@ -48,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/
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,785 @@
|
||||
# NodeWarden Passkey 登录研究记录
|
||||
|
||||
记录日期:2026-06-09
|
||||
研究范围:NodeWarden 自己的 server、web 登录/注册链路,以及官方 Bitwarden server、web、browser extension 对账户 passkey 登录的实现方式。
|
||||
|
||||
## 结论先放前面
|
||||
|
||||
NodeWarden 现在已经有完整的主密码注册、主密码登录、刷新 token、2FA、设备记录、官方客户端兼容的 `UserDecryptionOptions`,也支持 vault item 里的 `login.fido2Credentials` 字段。但它还没有“账户 passkey 登录”。现有 `src/utils/passkey.ts` 只有 base64url、challenge、clientData 解析这类工具函数,不能完成 FIDO2/WebAuthn 服务端注册和认证验证。
|
||||
|
||||
要支持“自己的 web 用 passkey 登录”和“官方/自定义浏览器扩展也能 passkey 登录”,不能只加一个登录按钮。必须补齐四块:
|
||||
|
||||
1. Server 端新增账户 WebAuthn credential 表、challenge/token 防重放机制、FIDO2 attestation/assertion 验证、`grant_type=webauthn`。
|
||||
2. Server 响应里按 Bitwarden 形状返回 PRF 解密材料:登录 token 响应用单个 `UserDecryptionOptions.WebAuthnPrfOption`,sync 响应用多个 `UserDecryption.WebAuthnPrfOptions`。
|
||||
3. NodeWarden web 新增 passkey 注册、管理、登录和 PRF 解锁 vault key 的客户端流程。
|
||||
4. 扩展兼容要跟官方 Bitwarden endpoint 和 response shape 对齐。官方 browser extension 当前只在 Chromium 系浏览器开放 passkey 登录,因为 Firefox/Safari 扩展环境还不能按官方代码需要的方式覆盖 RP ID。
|
||||
|
||||
下面按代码链路展开。
|
||||
|
||||
## 术语边界
|
||||
|
||||
这里有三个容易混淆的东西,文档后面严格区分:
|
||||
|
||||
- 账户 passkey 登录:用户不用主密码,使用 WebAuthn/passkey 完成账号认证,并且用 PRF 解开 vault user key。官方 Bitwarden 叫 `WebAuthnLogin`。
|
||||
- Vault item 里的 passkey:某个登录条目保存网站 passkey/FIDO2 credential 数据,对应 NodeWarden 的 `cipher.login.fido2Credentials`。这是“保险库保存别的网站 passkey”,不是“登录 NodeWarden 账号”。
|
||||
- WebAuthn 2FA:主密码登录之后用安全密钥做第二因素。官方旧 web repo 里主要是这一类,不等于 passkey 登录。
|
||||
|
||||
## NodeWarden 现状
|
||||
|
||||
### 路由和入口
|
||||
|
||||
NodeWarden 后端是 Cloudflare Workers + D1。主入口 `src/index.ts` 初始化存储后进入 router。认证边界在:
|
||||
|
||||
- `src/router-public.ts`:公开接口,包含 `/identity/connect/token`、`/identity/accounts/prelogin`、`/api/accounts/register`。
|
||||
- `src/router-authenticated.ts`:需要 access token 的接口,包含 profile、change password、TOTP、sync、vault、devices。
|
||||
- `src/handlers/identity.ts`:OAuth/token 兼容入口。
|
||||
- `src/handlers/accounts.ts`:注册、profile、密码变更、TOTP、API key 等账户接口。
|
||||
|
||||
目前公开路由没有:
|
||||
|
||||
- `GET /identity/accounts/webauthn/assertion-options`
|
||||
- `POST /identity/connect/token` 的 `grant_type=webauthn`
|
||||
- `POST /api/webauthn/attestation-options`
|
||||
- `POST /api/webauthn/assertion-options`
|
||||
- `GET/POST/PUT /api/webauthn`
|
||||
|
||||
### 注册链路
|
||||
|
||||
NodeWarden 自己 web 的注册入口在 `webapp/src/lib/api/auth.ts` 的 `registerAccount()`:
|
||||
|
||||
- 使用邮箱作为 salt,用 PBKDF2 派生 master key。
|
||||
- 再用 PBKDF2(masterKey, password, 1) 得到 client master password hash。
|
||||
- 随机生成 64 字节 vault symmetric key。
|
||||
- 用 masterKey 经 HKDF 拆成 enc/mac,把 vault key 加密成 Bitwarden `Key`。
|
||||
- 生成 RSA-OAEP key pair,把 private key 用 vault symmetric key 加密。
|
||||
- POST `/api/accounts/register`,提交 `email`、`name`、`masterPasswordHash`、`key`、KDF 参数、invite code、`keys.publicKey`、`keys.encryptedPrivateKey`。
|
||||
|
||||
后端 `src/handlers/accounts.ts` 的 `handleRegister()`:
|
||||
|
||||
- 第一个用户自动成为 admin,后续用户需要 invite。
|
||||
- 校验 `JWT_SECRET`、邮箱、KDF 下限、加密字符串形状、公钥/私钥。
|
||||
- 不直接保存 client hash,而是 `AuthService.hashPasswordServer(masterPasswordHash, email)` 后保存到 `users.master_password_hash`。
|
||||
- 保存 `users.key`、`users.private_key`、`users.public_key`、KDF 参数、`security_stamp`。
|
||||
|
||||
结论:账户 passkey 注册不是替代账号注册,而是“用户已登录后在安全设置里新增一个可登录 credential”。仍然需要已有 vault user key 来生成 PRF keyset。
|
||||
|
||||
### 主密码登录链路
|
||||
|
||||
NodeWarden 自己 web 的登录入口是 `webapp/src/lib/app-auth.ts` 的 `performPasswordLogin()`:
|
||||
|
||||
- 先 `deriveLoginHashLocally()` 得到 masterKey 和 client hash。
|
||||
- 调 `loginWithPassword()` POST `/identity/connect/token`。
|
||||
- token 成功后 `completeLogin()` 用 `token.Key` 和本地 masterKey 解开 vault key。
|
||||
- 保存离线解锁记录。
|
||||
|
||||
`webapp/src/lib/api/auth.ts` 也有 `deriveLoginHash()` 和 `getPreloginKdfConfig()` 会调用 `/identity/accounts/prelogin`,但当前 `performPasswordLogin()` 走的是本地 fallback iterations。passkey 登录不应复用这条 masterKey 路径,因为 passkey 登录没有主密码,拿不到 password-derived masterKey。
|
||||
|
||||
后端 `src/handlers/identity.ts` 的 `handleToken()` 当前支持:
|
||||
|
||||
- `grant_type=password`
|
||||
- `grant_type=client_credentials`
|
||||
- `grant_type=refresh_token`
|
||||
|
||||
密码登录成功后会:
|
||||
|
||||
- 验证 IP 登录频率和用户状态。
|
||||
- `AuthService.verifyPassword()` 验证 client hash。
|
||||
- 处理 TOTP 或 remember 2FA token。
|
||||
- 记录/更新 device。
|
||||
- 生成 access token 和 refresh token。
|
||||
- 返回 `Key`、`PrivateKey`、`AccountKeys`、KDF 参数、`UserDecryptionOptions`。
|
||||
|
||||
### UserDecryptionOptions 和 sync
|
||||
|
||||
NodeWarden 的 `src/utils/user-decryption.ts` 当前只构造主密码解锁:
|
||||
|
||||
- `HasMasterPassword: true`
|
||||
- `MasterPasswordUnlock`
|
||||
- `TrustedDeviceOption: null`
|
||||
- `KeyConnectorOption: null`
|
||||
|
||||
`src/types/index.ts` 的 sync 类型里预留了 `UserDecryption.WebAuthnPrfOption?: null`,但当前 `src/handlers/sync.ts` 实际只返回 `MasterPasswordUnlock`,没有账户 passkey PRF 解密选项。
|
||||
|
||||
passkey 登录必须新增两类 shape:
|
||||
|
||||
- 登录 token 响应:`UserDecryptionOptions.WebAuthnPrfOption`,只返回本次认证所用 credential 的 PRF 解密材料。
|
||||
- sync 响应:`UserDecryption.WebAuthnPrfOptions`,返回该用户所有已启用 PRF keyset 的 passkey 解密材料,供官方客户端锁定/解锁和 key rotation 使用。
|
||||
|
||||
### 现有 passkey 相关代码
|
||||
|
||||
NodeWarden 已支持 vault item 里的 FIDO2/passkey 字段:
|
||||
|
||||
- `src/types/index.ts`:`CipherLogin.fido2Credentials`
|
||||
- `src/handlers/ciphers.ts`:读写 cipher 时保留/规范化 `fido2Credentials`
|
||||
- `webapp/src/lib/api/vault.ts`:加密/解密 vault item 内的 `fido2Credentials`
|
||||
- `webapp/src/lib/types.ts`:`CipherLoginPasskey`
|
||||
|
||||
这部分是“保存网站 passkey”,不是账户登录。
|
||||
|
||||
`src/utils/passkey.ts` 只有:
|
||||
|
||||
- `bytesToBase64Url()`
|
||||
- `base64UrlToBytes()`
|
||||
- `randomChallenge()`
|
||||
- `parseClientDataJSON()`
|
||||
|
||||
缺少的核心能力:
|
||||
|
||||
- attestation verification
|
||||
- assertion verification
|
||||
- authenticator public key 格式处理
|
||||
- signature verification
|
||||
- sign counter 更新
|
||||
- userHandle 与 user id 绑定验证
|
||||
- origin/RP ID 验证
|
||||
- challenge 过期和防重放
|
||||
|
||||
### 数据库和备份影响
|
||||
|
||||
NodeWarden schema 在这些地方需要同步:
|
||||
|
||||
- `migrations/0001_init.sql`
|
||||
- `src/services/storage-schema.ts`
|
||||
- `wrangler.toml` migrations
|
||||
- `src/services/backup-archive.ts`
|
||||
- `src/services/backup-import.ts`
|
||||
- `shared/backup-schema` 相关类型
|
||||
|
||||
当前表里没有账户 passkey credential,也没有 WebAuthn challenge 表。`devices` 表保存设备 trust/key 信息,不适合混入 passkey credential,因为 WebAuthn credential 需要自己的 public key、credential id、counter、AAGUID、PRF keyset 等字段。
|
||||
|
||||
## 官方 Bitwarden server 参考
|
||||
|
||||
上游代码位置:
|
||||
|
||||
- `.codex-upstream/bitwarden-server`
|
||||
- 研究时 HEAD:`574f3fd`
|
||||
|
||||
官方 server 里也有两个 WebAuthn 概念:
|
||||
|
||||
- 传统 WebAuthn 2FA:`TwoFactorController`、`WebAuthnTokenProvider`
|
||||
- 账户 passkey 登录:`WebAuthnLogin`
|
||||
|
||||
本项目要参考的是后者。
|
||||
|
||||
### 公开 passkey 登录入口
|
||||
|
||||
`src/Identity/Controllers/AccountsController.cs`
|
||||
|
||||
- `GET /accounts/webauthn/assertion-options`
|
||||
- 返回 `WebAuthnLoginAssertionOptionsResponseModel`
|
||||
- response 包含:
|
||||
- `options`
|
||||
- `token`
|
||||
- token 使用 `WebAuthnLoginAssertionOptionsTokenable`
|
||||
- scope 为 `Authentication`
|
||||
- token 生命周期约 17 分钟
|
||||
|
||||
`src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs`
|
||||
|
||||
- 新增 OAuth extension grant:`grant_type=webauthn`
|
||||
- 从 form 读取:
|
||||
- `token`
|
||||
- `deviceResponse`
|
||||
- 解开 token,校验 scope 必须是 `Authentication`
|
||||
- 反序列化 `AuthenticatorAssertionRawResponse`
|
||||
- 调用 `AssertWebAuthnLoginCredential`
|
||||
- 把成功认证的 credential 传给 `UserDecryptionOptionsBuilder.WithWebAuthnLoginCredential(credential)`
|
||||
- 之后走通用登录成功逻辑,返回 access/refresh token 和账号加密状态。
|
||||
|
||||
`src/Identity/IdentityServer/ApiClient.cs`
|
||||
|
||||
- official identity client 的 allowed grant types 包含 `WebAuthnGrantValidator.GrantType`。
|
||||
|
||||
`TwoFactorAuthenticationValidator` 里有一个重要行为:FIDO2 user verification 已经被视为第二因素,所以 passkey 登录成功后官方不会再要求额外 2FA。NodeWarden 之后需要明确策略:要兼容官方客户端,应把 passkey 登录视作已满足 2FA,否则官方 `LoginViaWebAuthnComponent` 会显示“不支持 passkey 2FA”的错误。
|
||||
|
||||
### 账户 passkey 管理接口
|
||||
|
||||
`src/Api/Auth/Controllers/WebAuthnController.cs`
|
||||
|
||||
官方 authenticated API:
|
||||
|
||||
- `GET /webauthn`:列出账户 passkey credentials。
|
||||
- `POST /webauthn/attestation-options`:主密码/secret verification 后生成 credential create options 和 token。
|
||||
- `POST /webauthn/assertion-options`:主密码/secret verification 后生成 assertion options 和 token,用于给已有 credential 启用/更新 PRF keyset。
|
||||
- `POST /webauthn`:保存新 credential。
|
||||
- `PUT /webauthn`:更新 credential 的 PRF encryption keyset。
|
||||
- `POST /webauthn/{id}/delete`:删除 credential。
|
||||
|
||||
官方创建 credential 时保存:
|
||||
|
||||
- `name`
|
||||
- `token`
|
||||
- `deviceResponse`
|
||||
- `supportsPrf`
|
||||
- 可选 `encryptedUserKey`
|
||||
- 可选 `encryptedPublicKey`
|
||||
- 可选 `encryptedPrivateKey`
|
||||
|
||||
官方最多允许 5 个账户 passkey credentials。
|
||||
|
||||
### 官方 WebAuthnCredential 表
|
||||
|
||||
`src/Core/Auth/Entities/WebAuthnCredential.cs`
|
||||
|
||||
字段:
|
||||
|
||||
- `Id`
|
||||
- `UserId`
|
||||
- `Name`
|
||||
- `PublicKey`
|
||||
- `CredentialId`
|
||||
- `Counter`
|
||||
- `Type`
|
||||
- `AaGuid`
|
||||
- `EncryptedUserKey`
|
||||
- `EncryptedPrivateKey`
|
||||
- `EncryptedPublicKey`
|
||||
- `SupportsPrf`
|
||||
- `CreationDate`
|
||||
- `RevisionDate`
|
||||
|
||||
SQLite migration:`util/SqliteMigrations/Migrations/20231213032045_WebAuthnLoginCredentials.cs`
|
||||
|
||||
表名是 `WebAuthnCredential`,对 `User` 做 cascade delete,并按 `UserId` 建索引。
|
||||
|
||||
`GetPrfStatus()`:
|
||||
|
||||
- `Unsupported`:`SupportsPrf` 为 false。
|
||||
- `Supported`:credential 支持 PRF,但还没有完整 encrypted keyset。
|
||||
- `Enabled`:`EncryptedUserKey`、`EncryptedPrivateKey`、`EncryptedPublicKey` 都存在。
|
||||
|
||||
### 官方创建和认证策略
|
||||
|
||||
`GetWebAuthnLoginCredentialCreateOptionsCommand.cs`
|
||||
|
||||
- 使用 Fido2NetLib。
|
||||
- `user.id` 是用户 id bytes。
|
||||
- `user.name/displayName` 使用用户邮箱。
|
||||
- 排除当前用户已有 credential ids。
|
||||
- `residentKey: required`
|
||||
- `userVerification: required`
|
||||
- `attestation: none`
|
||||
|
||||
`GetWebAuthnLoginCredentialAssertionOptionsCommand.cs`
|
||||
|
||||
- `allowCredentials` 传空数组。
|
||||
- `userVerification: required`
|
||||
- 空 allow list 代表使用 discoverable credentials,也就是 passkey 登录页可以不先输入邮箱。
|
||||
|
||||
`CreateWebAuthnLoginCredentialCommand.cs`
|
||||
|
||||
- 限制每用户最多 5 个。
|
||||
- 检查 credential id 在该用户下不能重复。
|
||||
- FIDO `MakeNewCredentialAsync` 验证 attestation。
|
||||
- 保存 credential id/public key/counter/type/AAGUID/PRF keyset。
|
||||
|
||||
`AssertWebAuthnLoginCredentialCommand.cs`
|
||||
|
||||
- 先用 challenge cache 防重放。
|
||||
- 从 assertion response 的 `userHandle` 解析出 user id。
|
||||
- 加载该用户所有 WebAuthn credentials。
|
||||
- 用 credential id 找到记录。
|
||||
- FIDO `MakeAssertionAsync` 验证签名、challenge、origin、RP ID、user verification。
|
||||
- 成功后更新 counter。
|
||||
|
||||
### 官方 PRF 解密协议
|
||||
|
||||
`src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs`
|
||||
|
||||
`WebAuthnPrfDecryptionOption` 字段:
|
||||
|
||||
- `EncryptedPrivateKey`
|
||||
- `EncryptedUserKey`
|
||||
- `CredentialId`
|
||||
- `Transports`
|
||||
|
||||
`src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs`
|
||||
|
||||
- `WithWebAuthnLoginCredential()` 只在 credential 的 PRF status 是 `Enabled` 时加入 `WebAuthnPrfOption`。
|
||||
- 如果 credential 没有 PRF keyset,passkey 只能认证账号,不能解开 vault。
|
||||
|
||||
`src/Api/Vault/Models/Response/SyncResponseModel.cs`
|
||||
|
||||
- sync response 会把所有 enabled PRF credentials 放进 `UserDecryption.WebAuthnPrfOptions`。
|
||||
|
||||
## 官方 Bitwarden web/browser client 参考
|
||||
|
||||
上游代码位置:
|
||||
|
||||
- `.codex-upstream/bitwarden-clients`
|
||||
- `.codex-upstream/bitwarden-browser`
|
||||
- 两者研究时 HEAD 都是 `825f9be`,browser repo 内容和 clients monorepo 对应。
|
||||
|
||||
旧的 `.codex-upstream/bitwarden-web` 主要有 WebAuthn connector 和 2FA 设置页,没有现代账户 passkey 登录主流程。账户 passkey 登录应以 `bitwarden-clients` 为准。
|
||||
|
||||
### 登录按钮可见性
|
||||
|
||||
`libs/auth/src/angular/login/default-login-component.service.ts`
|
||||
|
||||
- 默认只对 `ClientType.Web` 开启 passkey 登录。
|
||||
|
||||
`apps/browser/src/auth/popup/login/extension-login-component.service.ts`
|
||||
|
||||
- browser extension 覆盖逻辑:只对 Chromium 开启。
|
||||
- 注释说明 Firefox 和 Safari 不能在扩展里覆盖 relying party ID。
|
||||
- 官方代码引用了 W3C webextensions issue 238、Mozilla bug 1956484、Apple forum thread 774351。
|
||||
|
||||
结论:NodeWarden 后端即使完全兼容官方 passkey API,官方扩展也只有 Chromium 系会显示 passkey 登录入口。
|
||||
|
||||
### Passkey 登录页
|
||||
|
||||
`libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts`
|
||||
|
||||
流程:
|
||||
|
||||
1. 进入 `/login-with-passkey` 后自动开始认证。
|
||||
2. 调 `webAuthnLoginService.getCredentialAssertionOptions()`。
|
||||
3. 调 `webAuthnLoginService.assertCredential(options)` 触发 `navigator.credentials.get()`。
|
||||
4. 调 `webAuthnLoginService.logIn(assertion)` 走 identity token grant。
|
||||
5. 如果 `authResult.requiresTwoFactor` 为 true,显示“客户端不支持 passkey 2FA”错误。
|
||||
6. 只有本地 `keyService.userKey$(authResult.userId)` 已经拿到 user key,才运行 login success handler。
|
||||
7. 成功路由:
|
||||
- Web:`/vault`
|
||||
- Browser:`/tabs/vault`
|
||||
- Desktop:`/vault`
|
||||
|
||||
Browser popout 下还会在成功后重新打开普通 popup 并关闭 popout。
|
||||
|
||||
### 客户端 passkey 登录请求
|
||||
|
||||
`libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts`
|
||||
|
||||
- GET `${identityUrl}/accounts/webauthn/assertion-options`
|
||||
- 如果 NodeWarden 的 identityUrl 是站点 origin + `/identity`,实际路径就是 `/identity/accounts/webauthn/assertion-options`。
|
||||
|
||||
`libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts`
|
||||
|
||||
- `navigator.credentials.get({ publicKey: options })`
|
||||
- 会主动加 PRF extension:
|
||||
- salt 是 `SHA-256("passwordless-login")`
|
||||
- extension shape 是 `extensions.prf.eval.first`
|
||||
- 从 `credential.getClientExtensionResults().prf.results.first` 取 PRF 输出。
|
||||
- 用 `WebAuthnLoginPrfKeyService.createSymmetricKeyFromPrf()` 转成 PRF key。
|
||||
- 构造 `WebAuthnLoginAssertionResponseRequest`。
|
||||
- 明确检查 `deviceResponse.extensions` 里不能含 `prf`,避免把 PRF 输出泄漏给服务端。
|
||||
|
||||
`libs/common/src/auth/services/webauthn-login/webauthn-login-prf-key.service.ts`
|
||||
|
||||
- salt 常量:`passwordless-login`
|
||||
- 先 SHA-256。
|
||||
- 再用 HKDF expand 拆成 64 字节:
|
||||
- `"enc"` 32 bytes
|
||||
- `"mac"` 32 bytes
|
||||
|
||||
`libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts`
|
||||
|
||||
form encoded token 请求字段:
|
||||
|
||||
- `grant_type=webauthn`
|
||||
- `token=<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,建议为了官方兼容先遵循 Bitwarden:passkey 的 user verification 视作已满足第二因素。否则官方 passkey 登录页会进入 unsupported 2FA 错误状态。
|
||||
|
||||
### 账户 passkey 管理流程
|
||||
|
||||
建议对齐官方 API,同时在 NodeWarden 内部可挂到 `/api/webauthn`:
|
||||
|
||||
- `GET /api/webauthn`
|
||||
- `POST /api/webauthn/attestation-options`
|
||||
- `POST /api/webauthn/assertion-options`
|
||||
- `POST /api/webauthn`
|
||||
- `PUT /api/webauthn`
|
||||
- `POST /api/webauthn/:id/delete`
|
||||
|
||||
为了官方客户端兼容,可能还需要接受无 `/api` 前缀的 aliases:
|
||||
|
||||
- `/webauthn`
|
||||
- `/webauthn/attestation-options`
|
||||
- `/webauthn/assertion-options`
|
||||
- `/webauthn/:id/delete`
|
||||
|
||||
NodeWarden 自己 web 可以直接用 `/api/webauthn`,官方 web/browser 客户端会按它自己的 API base 组装 `/webauthn`。
|
||||
|
||||
### 建议新增表
|
||||
|
||||
按 NodeWarden 命名风格,建议用小写 snake_case:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
credential_id TEXT NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
type TEXT,
|
||||
aa_guid TEXT,
|
||||
transports TEXT,
|
||||
encrypted_user_key TEXT,
|
||||
encrypted_public_key TEXT,
|
||||
encrypted_private_key TEXT,
|
||||
supports_prf INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_user_credential
|
||||
ON webauthn_credentials(user_id, credential_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
|
||||
ON webauthn_credentials(user_id);
|
||||
```
|
||||
|
||||
如果要更严格防止同一个 credential id 被跨用户重复注册,也可以加全局 unique index `credential_id`。官方代码至少检查同用户唯一;实际安全上更建议全局唯一,因为 credential id 本身应该唯一标识 authenticator credential。
|
||||
|
||||
PRF status 不必落库为枚举,可以由字段计算:
|
||||
|
||||
- `supports_prf = 0` => `Unsupported`
|
||||
- `supports_prf = 1` 且三段 encrypted key 不全 => `Supported`
|
||||
- `supports_prf = 1` 且三段 encrypted key 全存在 => `Enabled`
|
||||
|
||||
### Challenge/token 存储
|
||||
|
||||
官方 server 用 protected token 携带 options,再用 challenge cache 防重放。NodeWarden 在 Workers/D1 里建议组合:
|
||||
|
||||
- token:HMAC/JWT 样式,绑定 `scope`、`challenge`、`userId?`、`rpId`、`createdAt`、`expiresAt`。
|
||||
- D1 表或 KV:记录 challenge 是否使用过,至少字段 `challenge_hash`、`scope`、`user_id`、`expires_at`、`used_at`。
|
||||
- 登录 assertion options 是公开接口,不绑定 user id;create/update/delete 管理流程应绑定 user id。
|
||||
- 验证成功后立即 mark used。
|
||||
|
||||
建议 scopes:
|
||||
|
||||
- `Authentication`
|
||||
- `CreateCredential`
|
||||
- `UpdateKeySet`
|
||||
|
||||
官方还有 `PrfRegistration` 语义,NodeWarden 可以用 `CreateCredential` 覆盖,只要 token 逻辑严谨即可。
|
||||
|
||||
### 服务端 WebAuthn 验证库
|
||||
|
||||
NodeWarden 当前没有 FIDO2/WebAuthn 服务端验证依赖。不要手写签名和 attestation 解析。
|
||||
|
||||
候选:`@simplewebauthn/server`。官方文档当前说明它提供 `generateRegistrationOptions`、`verifyRegistrationResponse`、`generateAuthenticationOptions`、`verifyAuthenticationResponse`,并记录了 RP ID、origin、credential public key、counter、transports 等数据结构。文档地址:https://simplewebauthn.dev/docs/packages/server
|
||||
|
||||
注意:NodeWarden 跑在 Cloudflare Workers,不是普通 Node server。正式选库前需要做一次构建/runtime 验证,确认包不会依赖 Workers 不支持的 Node API。这个验证属于实现阶段,不在本研究文档里写测试程序。
|
||||
|
||||
## NodeWarden web 需要改的地方
|
||||
|
||||
### 登录页
|
||||
|
||||
当前登录 UI 在 `webapp/src/components/AuthViews.tsx`,状态和行为主要由 `webapp/src/App.tsx`、`webapp/src/lib/app-auth.ts` 管。
|
||||
|
||||
新增:
|
||||
|
||||
- 登录页增加“使用 passkey 登录”按钮。
|
||||
- 新增 `performPasskeyLogin()`:
|
||||
1. GET `/identity/accounts/webauthn/assertion-options`
|
||||
2. 转换 server options 里的 base64url challenge/user id/credential id 为 ArrayBuffer。
|
||||
3. `navigator.credentials.get()`,带 PRF salt。
|
||||
4. POST `/identity/connect/token`,`grant_type=webauthn`。
|
||||
5. 从 response 的 `UserDecryptionOptions.WebAuthnPrfOption` 取 encrypted keyset。
|
||||
6. 用本地 PRF key 解出 user key。
|
||||
7. 构造 `SessionState` 并进入 app。
|
||||
|
||||
不能复用 `completeLogin(token, email, masterKey, fallbackKdfIterations)`,因为它要求 masterKey。应新增 passkey 专用 complete 函数。
|
||||
|
||||
### 设置页
|
||||
|
||||
当前账户/安全相关 UI 在 `webapp/src/components/SettingsPage.tsx` 一带。
|
||||
|
||||
新增:
|
||||
|
||||
- Passkey 列表。
|
||||
- 新建 passkey dialog。
|
||||
- 删除 passkey。
|
||||
- 对支持 PRF 但未启用 encryption 的 passkey,提供“启用用于登录解锁”的操作。
|
||||
|
||||
自己 web 的新建流程要和官方一致:
|
||||
|
||||
1. 已登录状态下先验证主密码或现有 session secret。
|
||||
2. 请求 attestation options。
|
||||
3. `navigator.credentials.create()` 带 `extensions.prf = {}`。
|
||||
4. 如果用户希望这个 passkey 可直接解锁 vault,再对刚创建 credential 做一次 `navigator.credentials.get()` 获取 PRF 输出。
|
||||
5. 用 PRF key 加密/封装当前 user key,发送到 server 保存。
|
||||
|
||||
### 客户端加密能力
|
||||
|
||||
NodeWarden web 当前已经有:
|
||||
|
||||
- PBKDF2
|
||||
- HKDF expand
|
||||
- Bitwarden EncString 加解密
|
||||
- RSA-OAEP private key 加密
|
||||
|
||||
但 passkey PRF keyset 需要和官方策略对齐:
|
||||
|
||||
- PRF key 是 64 字节 symmetric key,前 32 enc、后 32 mac。
|
||||
- `encryptedPrivateKey` 用 PRF key wrap 一个 decapsulation private key。
|
||||
- `encryptedUserKey` 用对应 public key encapsulate user key。
|
||||
- `encryptedPublicKey` 用于 key rotation。
|
||||
|
||||
这里需要认真复用或补齐 NodeWarden 现有 crypto helper,避免做出和官方客户端无法互解的 keyset。
|
||||
|
||||
## 扩展兼容要求
|
||||
|
||||
### 官方 browser extension
|
||||
|
||||
官方 extension passkey 登录入口在:
|
||||
|
||||
- `apps/browser/src/auth/popup/login/extension-login-component.service.ts`
|
||||
- 只在 Chromium 开启。
|
||||
|
||||
如果要官方/派生扩展能对 NodeWarden passkey 登录:
|
||||
|
||||
- identity URL 必须能访问 `/accounts/webauthn/assertion-options`。
|
||||
- token URL 必须支持 `grant_type=webauthn`。
|
||||
- API URL 必须能访问 `/webauthn` 管理接口。
|
||||
- response 大小写和字段名要同时照顾 PascalCase/camelCase,NodeWarden 当前 token response 已经在一些字段上双写,这个风格应继续沿用。
|
||||
- passkey 登录成功时必须返回可解开 vault 的 `webAuthnPrfOption`,否则官方组件虽然认证成功,也不会进入可用 vault。
|
||||
|
||||
### RP ID 和 origin
|
||||
|
||||
自己的 web:
|
||||
|
||||
- RP ID 通常是站点 host,例如 `vault.example.com`。
|
||||
- origin 是 `https://vault.example.com`。
|
||||
|
||||
官方 browser extension:
|
||||
|
||||
- 扩展页面 origin 是 `chrome-extension://...`。
|
||||
- 官方之所以只开 Chromium,是因为 Chromium extension 具备它需要的 RP ID 覆盖能力。
|
||||
- NodeWarden server 验证 assertion 时必须允许正确的 origin/RP ID 组合。这里不能简单只接受当前 request origin,否则扩展登录会失败。
|
||||
|
||||
建议配置化:
|
||||
|
||||
- `WEBAUTHN_RP_ID`
|
||||
- `WEBAUTHN_RP_NAME`
|
||||
- `WEBAUTHN_ALLOWED_ORIGINS`
|
||||
|
||||
默认可以从 request URL 推导 web origin,但生产建议显式配置。
|
||||
|
||||
## 安全约束
|
||||
|
||||
- 所有账户 passkey 必须 `userVerification: required`。
|
||||
- 登录 assertion 使用 discoverable credential,`userHandle` 必须能解析成 user id 并和 credential 记录一致。
|
||||
- challenge 必须有过期时间和一次性使用标记。
|
||||
- PRF 输出绝不能传给 server,也不能写入日志。
|
||||
- token 里要绑定 scope,防止 attestation token 被拿去 authentication 用。
|
||||
- counter 要更新。遇到 counter 异常时至少记录 audit event,是否阻断要结合 multi-device passkey 现实处理。
|
||||
- 每用户 credential 数量限制建议沿用官方 5 个。
|
||||
- 删除/新增/启用 encryption 必须要求已登录用户二次验证。
|
||||
- 密码变更、user key rotation 后,所有 enabled PRF credentials 的 keyset 也要 rotation,否则 passkey 登录会解不开新 vault key。
|
||||
- 备份导出/导入必须包含账户 passkey 表,否则恢复后 passkey 登录会全部失效。
|
||||
- 审计日志建议新增:
|
||||
- `auth.passkey.login.success`
|
||||
- `auth.passkey.login.failed`
|
||||
- `account.passkey.create`
|
||||
- `account.passkey.delete`
|
||||
- `account.passkey.encryption.enable`
|
||||
- `account.passkey.rotate`
|
||||
|
||||
## 建议实施顺序
|
||||
|
||||
### 第一阶段:后端基础
|
||||
|
||||
1. 新增 `webauthn_credentials` 和 challenge 表。
|
||||
2. 新增 storage repo。
|
||||
3. 接入 WebAuthn 服务端验证库。
|
||||
4. 实现 assertion options 和 `grant_type=webauthn`。
|
||||
5. token response 加 `WebAuthnPrfOption` shape。
|
||||
|
||||
这阶段先能让“已有手工塞入的 enabled credential”完成登录验证,但还不做 UI。
|
||||
|
||||
### 第二阶段:账户 passkey 管理 API
|
||||
|
||||
1. 实现 `/api/webauthn` 和 `/webauthn` aliases。
|
||||
2. 实现 attestation options、save credential、list、delete、enable/update encryption。
|
||||
3. 加 audit event。
|
||||
4. 接入 backup export/import。
|
||||
5. sync response 加 `WebAuthnPrfOptions`。
|
||||
|
||||
### 第三阶段:NodeWarden 自己 web
|
||||
|
||||
1. 登录页 passkey 按钮和 `performPasskeyLogin()`。
|
||||
2. Passkey 设置页。
|
||||
3. PRF keyset 创建、保存、删除、启用 encryption。
|
||||
4. 浏览器能力判断和错误提示。
|
||||
|
||||
### 第四阶段:扩展兼容
|
||||
|
||||
1. 用官方 browser extension 的 Chromium passkey 登录流程校对 endpoint。
|
||||
2. 校对 `/config` 里 identity/api/web vault URL。
|
||||
3. 校对 RP ID、allowed origins。
|
||||
4. 必要时加兼容字段或 alias route。
|
||||
|
||||
按用户要求,本阶段只需要代码跑通不报错;不在这里写可视化测试或测试程序。
|
||||
|
||||
## 待实现清单
|
||||
|
||||
- [ ] 设计并落库 `webauthn_credentials`。
|
||||
- [ ] 设计并落库 WebAuthn challenge/replay cache。
|
||||
- [ ] 选定并验证 Workers 可用的 WebAuthn server library。
|
||||
- [ ] `GET /identity/accounts/webauthn/assertion-options`。
|
||||
- [ ] `POST /identity/connect/token` 支持 `grant_type=webauthn`。
|
||||
- [ ] `UserDecryptionOptions.WebAuthnPrfOption`。
|
||||
- [ ] `UserDecryption.WebAuthnPrfOptions`。
|
||||
- [ ] `/api/webauthn` 管理接口。
|
||||
- [ ] `/webauthn` 官方客户端 alias。
|
||||
- [ ] NodeWarden web passkey 登录入口。
|
||||
- [ ] NodeWarden web passkey 管理页。
|
||||
- [ ] key rotation 时同步 rotate PRF keysets。
|
||||
- [ ] backup export/import 覆盖新表。
|
||||
- [ ] audit logs 覆盖 passkey 管理和登录。
|
||||
|
||||
## 关键文件索引
|
||||
|
||||
NodeWarden:
|
||||
|
||||
- `src/router-public.ts`
|
||||
- `src/router-authenticated.ts`
|
||||
- `src/handlers/accounts.ts`
|
||||
- `src/handlers/identity.ts`
|
||||
- `src/handlers/sync.ts`
|
||||
- `src/services/auth.ts`
|
||||
- `src/services/storage-schema.ts`
|
||||
- `src/services/storage-user-repo.ts`
|
||||
- `src/services/storage-device-repo.ts`
|
||||
- `src/utils/passkey.ts`
|
||||
- `src/utils/user-decryption.ts`
|
||||
- `src/types/index.ts`
|
||||
- `webapp/src/lib/api/auth.ts`
|
||||
- `webapp/src/lib/app-auth.ts`
|
||||
- `webapp/src/components/AuthViews.tsx`
|
||||
- `webapp/src/components/SettingsPage.tsx`
|
||||
|
||||
Bitwarden server:
|
||||
|
||||
- `.codex-upstream/bitwarden-server/src/Identity/Controllers/AccountsController.cs`
|
||||
- `.codex-upstream/bitwarden-server/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs`
|
||||
- `.codex-upstream/bitwarden-server/src/Identity/IdentityServer/ApiClient.cs`
|
||||
- `.codex-upstream/bitwarden-server/src/Api/Auth/Controllers/WebAuthnController.cs`
|
||||
- `.codex-upstream/bitwarden-server/src/Core/Auth/Entities/WebAuthnCredential.cs`
|
||||
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/GetWebAuthnLoginCredentialCreateOptionsCommand.cs`
|
||||
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/GetWebAuthnLoginCredentialAssertionOptionsCommand.cs`
|
||||
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/CreateWebAuthnLoginCredentialCommand.cs`
|
||||
- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/AssertWebAuthnLoginCredentialCommand.cs`
|
||||
- `.codex-upstream/bitwarden-server/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs`
|
||||
- `.codex-upstream/bitwarden-server/util/SqliteMigrations/Migrations/20231213032045_WebAuthnLoginCredentials.cs`
|
||||
|
||||
Bitwarden clients/browser:
|
||||
|
||||
- `.codex-upstream/bitwarden-clients/libs/auth/src/angular/login/default-login-component.service.ts`
|
||||
- `.codex-upstream/bitwarden-clients/apps/browser/src/auth/popup/login/extension-login-component.service.ts`
|
||||
- `.codex-upstream/bitwarden-clients/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts`
|
||||
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts`
|
||||
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts`
|
||||
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login-prf-key.service.ts`
|
||||
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts`
|
||||
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/request/webauthn-login-response.request.ts`
|
||||
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts`
|
||||
- `.codex-upstream/bitwarden-clients/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts`
|
||||
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts`
|
||||
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts`
|
||||
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/save-credential.request.ts`
|
||||
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/enable-credential-encryption.request.ts`
|
||||
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-attestation-response.request.ts`
|
||||
- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts`
|
||||
- `.codex-upstream/bitwarden-clients/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts`
|
||||
- `.codex-upstream/bitwarden-clients/libs/auth/src/common/models/domain/user-decryption-options.ts`
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+429
-127
File diff suppressed because it is too large
Load Diff
+6
-5
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"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",
|
||||
"main": "src/index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm run build && wrangler dev -c wrangler.toml",
|
||||
"dev:kv": "npm run build && wrangler dev -c wrangler.kv.toml",
|
||||
"dev": "wrangler dev -c wrangler.toml",
|
||||
"dev:kv": "wrangler dev -c wrangler.kv.toml",
|
||||
"dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174",
|
||||
"build": "vite build --config webapp/vite.config.ts",
|
||||
"build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs",
|
||||
"domains:sync": "node scripts/sync-global-domains.mjs",
|
||||
"i18n": "node scripts/i18n-validate.cjs",
|
||||
"i18n:validate": "node scripts/i18n-validate.cjs",
|
||||
"deploy": "npm run build && wrangler deploy",
|
||||
"deploy:kv": "npm run build && wrangler deploy -c wrangler.kv.toml",
|
||||
"deploy": "wrangler deploy",
|
||||
"deploy:kv": "node scripts/ensure-kv.cjs && wrangler deploy -c wrangler.kv.toml",
|
||||
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
|
||||
},
|
||||
"keywords": [
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Make `deploy:kv` idempotent across repeated builds.
|
||||
*
|
||||
* KV namespaces are referenced in wrangler config by account-scoped `id`, not
|
||||
* by name. The template ships without an id so fresh accounts can provision one
|
||||
* on first deploy. In non-interactive builds, wrangler may try to create the
|
||||
* same namespace again on later builds and fail with code 10014.
|
||||
*/
|
||||
const { execSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const CONFIG = path.resolve(__dirname, '..', 'wrangler.kv.toml');
|
||||
const BINDING = 'ATTACHMENTS_KV';
|
||||
|
||||
const wrangler = (args) =>
|
||||
execSync(`npx wrangler ${args}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] });
|
||||
|
||||
function bindingBlockHasId(toml) {
|
||||
const blocks = toml.match(/\[\[kv_namespaces\]\][^[]*/g) || [];
|
||||
const block = blocks.find((entry) => new RegExp(`binding\\s*=\\s*"${BINDING}"`).test(entry));
|
||||
return block ? /^\s*id\s*=/m.test(block) : false;
|
||||
}
|
||||
|
||||
function expectedTitle(toml) {
|
||||
const name = (toml.match(/^\s*name\s*=\s*"([^"]+)"/m) || [])[1] || 'worker';
|
||||
return `${name}-${BINDING.toLowerCase().replace(/_/g, '-')}`;
|
||||
}
|
||||
|
||||
function resolveId(title) {
|
||||
const list = JSON.parse(wrangler('kv namespace list'));
|
||||
const hit =
|
||||
list.find((namespace) => namespace.title === title) ||
|
||||
list.find((namespace) => typeof namespace.title === 'string' && namespace.title.endsWith('attachments-kv'));
|
||||
if (hit) {
|
||||
console.log(`[ensure-kv] reusing existing namespace "${hit.title}" (${hit.id})`);
|
||||
return hit.id;
|
||||
}
|
||||
|
||||
const out = wrangler(`kv namespace create "${title}"`);
|
||||
const id = (out.match(/id\s*=\s*"([0-9a-fA-F]{32})"/) || [])[1];
|
||||
if (!id) throw new Error(`[ensure-kv] could not parse new namespace id from:\n${out}`);
|
||||
console.log(`[ensure-kv] created namespace "${title}" (${id})`);
|
||||
return id;
|
||||
}
|
||||
|
||||
function main() {
|
||||
let toml = fs.readFileSync(CONFIG, 'utf8');
|
||||
if (bindingBlockHasId(toml)) {
|
||||
console.log(`[ensure-kv] ${BINDING} already pinned in wrangler.kv.toml; nothing to do`);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = resolveId(expectedTitle(toml));
|
||||
toml = toml.replace(
|
||||
new RegExp(`(\\[\\[kv_namespaces\\]\\]\\s*\\n\\s*binding\\s*=\\s*"${BINDING}")`),
|
||||
`$1\nid = "${id}"`
|
||||
);
|
||||
fs.writeFileSync(CONFIG, toml);
|
||||
console.log('[ensure-kv] pinned id into wrangler.kv.toml for this build');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = '1.5.2';
|
||||
export const APP_VERSION = '1.6.0';
|
||||
|
||||
@@ -149,9 +149,10 @@
|
||||
// Single source of truth for /config.version and /api/version.
|
||||
// /config.version 与 /api/version 的统一版本号来源。
|
||||
bitwardenServerVersion: '2026.4.1',
|
||||
// Advertise official per-cipher item-key encryption support only after
|
||||
// NodeWarden can guarantee key/field consistency across all write paths.
|
||||
// 在所有写入路径都能保证 cipher.key 与字段密文一致之前,不向官方客户端声明支持逐项密钥加密。
|
||||
cipherKeyEncryptionFeatureEnabled: false,
|
||||
// Official 2026.4.x clients need this flag to receive and use cipher.key.
|
||||
// Hiding existing item keys makes item-key encrypted vault data unreadable.
|
||||
// 官方 2026.4.x 客户端需要该开关来接收并使用 cipher.key。
|
||||
// 隐藏已有逐项密钥会导致逐项密钥加密的密码库数据无法解密。
|
||||
cipherKeyEncryptionFeatureEnabled: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
import type { Env } from '../types';
|
||||
import type { BackupDestinationRecord } from '../services/backup-config';
|
||||
import {
|
||||
BACKUP_SCHEDULER_WINDOW_MINUTES,
|
||||
requireBackupDestination,
|
||||
hasBackupSlotBetween,
|
||||
isBackupDueNow,
|
||||
loadBackupSettings,
|
||||
} from '../services/backup-config';
|
||||
import {
|
||||
createRemoteBackupTransferSession,
|
||||
downloadRemoteBackupFile,
|
||||
ensureRemoteRestoreCandidate,
|
||||
} from '../services/backup-uploader';
|
||||
import { getBlobObject } from '../services/blob-store';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from './notifications-hub';
|
||||
import {
|
||||
executeConfiguredBackup,
|
||||
importAndAuditRemoteBackupFile,
|
||||
} from '../handlers/backup';
|
||||
import { verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
|
||||
import { zipSync } from 'fflate';
|
||||
|
||||
const BACKUP_JOB_STATE_KEY = 'backup.job.state.v1';
|
||||
const BACKUP_JOB_LEASE_MS = 10 * 60 * 1000;
|
||||
const BACKUP_JOB_HEARTBEAT_MS = 30 * 1000;
|
||||
|
||||
interface BackupJobState {
|
||||
token: string;
|
||||
reason: string;
|
||||
acquiredAt: string;
|
||||
touchedAt: string;
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
interface RemoteAttachmentChunkRequest {
|
||||
destination: BackupDestinationRecord;
|
||||
attachments: Array<{
|
||||
blobName: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RemoteAttachmentDownloadRequest {
|
||||
destination: BackupDestinationRecord;
|
||||
blobName?: string | null;
|
||||
}
|
||||
|
||||
interface RemoteAttachmentBatchDownloadRequest {
|
||||
destination: BackupDestinationRecord;
|
||||
blobNames?: string[] | null;
|
||||
}
|
||||
|
||||
interface ConfiguredBackupRunRequest {
|
||||
actorUserId?: string | null;
|
||||
auditMetadata?: Record<string, unknown> | null;
|
||||
destinationId?: string | null;
|
||||
targetDeviceIdentifier?: string | null;
|
||||
trigger?: 'manual' | 'scheduled';
|
||||
}
|
||||
|
||||
interface RemoteBackupRestoreRequest {
|
||||
actorUserId?: string | null;
|
||||
allowChecksumMismatch?: boolean;
|
||||
auditMetadata?: Record<string, unknown> | null;
|
||||
destinationId?: string | null;
|
||||
path?: string | null;
|
||||
replaceExisting?: boolean;
|
||||
targetDeviceIdentifier?: string | null;
|
||||
}
|
||||
|
||||
function badRequest(message: string, status: number = 400): Response {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export class BackupTransferRunner {
|
||||
private lastHeartbeatAt = 0;
|
||||
|
||||
constructor(
|
||||
private readonly state: DurableObjectState,
|
||||
private readonly env: Env
|
||||
) {
|
||||
}
|
||||
|
||||
private async acquireJob(reason: string): Promise<string | null> {
|
||||
const nowMs = Date.now();
|
||||
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
|
||||
if (current?.expiresAtMs && current.expiresAtMs > nowMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = crypto.randomUUID();
|
||||
const nowIso = new Date(nowMs).toISOString();
|
||||
await this.state.storage.put<BackupJobState>(BACKUP_JOB_STATE_KEY, {
|
||||
token,
|
||||
reason,
|
||||
acquiredAt: nowIso,
|
||||
touchedAt: nowIso,
|
||||
expiresAtMs: nowMs + BACKUP_JOB_LEASE_MS,
|
||||
});
|
||||
this.lastHeartbeatAt = 0;
|
||||
return token;
|
||||
}
|
||||
|
||||
private async touchJob(token: string): Promise<void> {
|
||||
const nowMs = Date.now();
|
||||
if (nowMs - this.lastHeartbeatAt < BACKUP_JOB_HEARTBEAT_MS) return;
|
||||
this.lastHeartbeatAt = nowMs;
|
||||
|
||||
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
|
||||
if (current?.token !== token) return;
|
||||
|
||||
await this.state.storage.put<BackupJobState>(BACKUP_JOB_STATE_KEY, {
|
||||
...current,
|
||||
touchedAt: new Date(nowMs).toISOString(),
|
||||
expiresAtMs: nowMs + BACKUP_JOB_LEASE_MS,
|
||||
});
|
||||
}
|
||||
|
||||
private async releaseJob(token: string): Promise<void> {
|
||||
const current = await this.state.storage.get<BackupJobState>(BACKUP_JOB_STATE_KEY);
|
||||
if (current?.token === token) {
|
||||
await this.state.storage.delete(BACKUP_JOB_STATE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
private async runConfiguredBackup(request: Request): Promise<Response> {
|
||||
let body: ConfiguredBackupRunRequest;
|
||||
try {
|
||||
body = await request.json<ConfiguredBackupRunRequest>();
|
||||
} catch {
|
||||
return badRequest('Backup run payload is invalid');
|
||||
}
|
||||
|
||||
const trigger = body.trigger === 'scheduled' ? 'scheduled' : 'manual';
|
||||
const actorUserId = String(body.actorUserId || '').trim() || null;
|
||||
if (trigger === 'manual' && !actorUserId) {
|
||||
return badRequest('Manual backup run requires an actor');
|
||||
}
|
||||
|
||||
const token = await this.acquireJob(`${trigger}:${actorUserId || 'system'}`);
|
||||
if (!token) {
|
||||
return badRequest('Another backup run is already in progress', 409);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.touchJob(token);
|
||||
const storage = new StorageService(this.env.DB);
|
||||
const progress = actorUserId
|
||||
? async (event: {
|
||||
operation: 'backup-remote-run';
|
||||
step: string;
|
||||
fileName: string;
|
||||
stageTitle: string;
|
||||
stageDetail: string;
|
||||
done?: boolean;
|
||||
ok?: boolean;
|
||||
error?: string | null;
|
||||
}) => {
|
||||
await notifyUserBackupProgress(
|
||||
this.env,
|
||||
actorUserId,
|
||||
event,
|
||||
String(body.targetDeviceIdentifier || '').trim() || null
|
||||
);
|
||||
}
|
||||
: null;
|
||||
|
||||
const result = await executeConfiguredBackup(
|
||||
this.env,
|
||||
storage,
|
||||
actorUserId,
|
||||
trigger,
|
||||
body.destinationId || null,
|
||||
() => this.touchJob(token),
|
||||
progress,
|
||||
body.auditMetadata || null
|
||||
);
|
||||
const settings = await loadBackupSettings(storage, this.env, 'UTC');
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
object: 'backup-runner-result',
|
||||
result,
|
||||
settings,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : 'Backup run failed', 500);
|
||||
} finally {
|
||||
await this.releaseJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
private async runScheduledBackups(): Promise<Response> {
|
||||
const token = await this.acquireJob('scheduled');
|
||||
if (!token) {
|
||||
return badRequest('Another backup run is already in progress', 409);
|
||||
}
|
||||
|
||||
let completed = 0;
|
||||
try {
|
||||
await this.touchJob(token);
|
||||
const storage = new StorageService(this.env.DB);
|
||||
let scanStartMs = Date.now();
|
||||
|
||||
while (true) {
|
||||
await this.touchJob(token);
|
||||
const settings = await loadBackupSettings(storage, this.env, 'UTC');
|
||||
const now = new Date();
|
||||
const dueDestinations = settings.destinations.filter((destination) =>
|
||||
isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)
|
||||
|| hasBackupSlotBetween(destination, new Date(scanStartMs), now)
|
||||
);
|
||||
|
||||
if (!dueDestinations.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
scanStartMs = now.getTime();
|
||||
for (const destination of dueDestinations) {
|
||||
await this.touchJob(token);
|
||||
await executeConfiguredBackup(
|
||||
this.env,
|
||||
storage,
|
||||
null,
|
||||
'scheduled',
|
||||
destination.id,
|
||||
() => this.touchJob(token)
|
||||
);
|
||||
completed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
completed,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : 'Scheduled backup failed', 500);
|
||||
} finally {
|
||||
await this.releaseJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreRemoteBackup(request: Request): Promise<Response> {
|
||||
let body: RemoteBackupRestoreRequest;
|
||||
try {
|
||||
body = await request.json<RemoteBackupRestoreRequest>();
|
||||
} catch {
|
||||
return badRequest('Remote restore payload is invalid');
|
||||
}
|
||||
|
||||
const actorUserId = String(body.actorUserId || '').trim() || null;
|
||||
if (!actorUserId) {
|
||||
return badRequest('Remote restore requires an actor');
|
||||
}
|
||||
|
||||
const token = await this.acquireJob(`restore:${actorUserId}`);
|
||||
if (!token) {
|
||||
return badRequest('Another backup or restore run is already in progress', 409);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.touchJob(token);
|
||||
const storage = new StorageService(this.env.DB);
|
||||
const settings = await loadBackupSettings(storage, this.env, 'UTC');
|
||||
const destination = requireBackupDestination(settings, body.destinationId || null);
|
||||
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||
const restoreFileNameFromPath = path.split('/').pop() || path;
|
||||
const targetDeviceIdentifier = String(body.targetDeviceIdentifier || '').trim() || null;
|
||||
const replaceExisting = !!body.replaceExisting;
|
||||
|
||||
await notifyUserBackupRestoreProgress(
|
||||
this.env,
|
||||
actorUserId,
|
||||
{
|
||||
operation: 'backup-restore',
|
||||
source: 'remote',
|
||||
step: 'remote_fetch_archive',
|
||||
fileName: restoreFileNameFromPath,
|
||||
stageTitle: 'txt_backup_restore_progress_remote_fetch_title',
|
||||
stageDetail: 'txt_backup_restore_progress_remote_fetch_detail',
|
||||
replaceExisting,
|
||||
},
|
||||
targetDeviceIdentifier
|
||||
);
|
||||
|
||||
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
||||
if (!checksumOk && !body.allowChecksumMismatch) {
|
||||
return badRequest('Remote backup file checksum does not match its filename');
|
||||
}
|
||||
|
||||
const result = await importAndAuditRemoteBackupFile(
|
||||
this.env,
|
||||
storage,
|
||||
actorUserId,
|
||||
remoteFile,
|
||||
destination,
|
||||
path,
|
||||
replaceExisting,
|
||||
!checksumOk,
|
||||
body.auditMetadata || null,
|
||||
targetDeviceIdentifier
|
||||
);
|
||||
|
||||
return new Response(JSON.stringify(result.result), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return badRequest(error instanceof Error ? error.message : 'Remote backup restore failed', 500);
|
||||
} finally {
|
||||
await this.releaseJob(token);
|
||||
}
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
if (request.method !== 'POST') {
|
||||
return badRequest('Not found', 404);
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/run-configured-backup') {
|
||||
return this.runConfiguredBackup(request);
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/run-scheduled-backups') {
|
||||
return this.runScheduledBackups();
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/restore-remote-backup') {
|
||||
return this.restoreRemoteBackup(request);
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/download-remote-attachment') {
|
||||
let body: RemoteAttachmentDownloadRequest;
|
||||
try {
|
||||
body = await request.json<RemoteAttachmentDownloadRequest>();
|
||||
} catch {
|
||||
return badRequest('Remote attachment download payload is invalid');
|
||||
}
|
||||
const blobName = String(body?.blobName || '').trim();
|
||||
if (!body?.destination || !blobName) {
|
||||
return badRequest('Remote attachment download payload is invalid');
|
||||
}
|
||||
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
|
||||
if (!file) {
|
||||
return badRequest('Remote attachment not found', 404);
|
||||
}
|
||||
return new Response(file.bytes, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': file.contentType || 'application/octet-stream',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === '/internal/download-remote-attachment-batch') {
|
||||
let body: RemoteAttachmentBatchDownloadRequest;
|
||||
try {
|
||||
body = await request.json<RemoteAttachmentBatchDownloadRequest>();
|
||||
} catch {
|
||||
return badRequest('Remote attachment batch download payload is invalid');
|
||||
}
|
||||
const blobNames = Array.from(new Set(
|
||||
(Array.isArray(body?.blobNames) ? body.blobNames : [])
|
||||
.map((blobName) => String(blobName || '').trim())
|
||||
.filter(Boolean)
|
||||
));
|
||||
if (!body?.destination || !blobNames.length || blobNames.length > 40) {
|
||||
return badRequest('Remote attachment batch download payload is invalid');
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const entries: Array<{ blobName: string; path: string }> = [];
|
||||
const files: Record<string, Uint8Array> = {};
|
||||
for (let i = 0; i < blobNames.length; i += 1) {
|
||||
const blobName = blobNames[i];
|
||||
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
|
||||
if (!file) continue;
|
||||
const path = `files/${i}.bin`;
|
||||
entries.push({ blobName, path });
|
||||
files[path] = file.bytes;
|
||||
}
|
||||
files['manifest.json'] = encoder.encode(JSON.stringify({ version: 1, entries }));
|
||||
|
||||
return new Response(zipSync(files), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname !== '/internal/upload-attachment-chunk') {
|
||||
return badRequest('Not found', 404);
|
||||
}
|
||||
|
||||
let body: RemoteAttachmentChunkRequest;
|
||||
try {
|
||||
body = await request.json<RemoteAttachmentChunkRequest>();
|
||||
} catch {
|
||||
return badRequest('Attachment chunk payload is invalid');
|
||||
}
|
||||
|
||||
if (!body?.destination || !Array.isArray(body.attachments)) {
|
||||
return badRequest('Attachment chunk payload is invalid');
|
||||
}
|
||||
|
||||
const remoteSession = createRemoteBackupTransferSession(body.destination);
|
||||
let uploaded = 0;
|
||||
|
||||
for (const attachment of body.attachments) {
|
||||
const blobName = String(attachment?.blobName || '').trim();
|
||||
if (!blobName) {
|
||||
return badRequest('Attachment chunk payload is invalid');
|
||||
}
|
||||
|
||||
const object = await getBlobObject(this.env, blobName);
|
||||
if (!object) {
|
||||
return badRequest(`Attachment blob missing for ${blobName}`, 409);
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
|
||||
await remoteSession.putFile(`attachments/${blobName}`, bytes, {
|
||||
contentType: object.contentType,
|
||||
});
|
||||
uploaded += 1;
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
uploaded,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+379
-235
@@ -1,21 +1,20 @@
|
||||
import type { Env, User } from '../types';
|
||||
import { errorResponse, jsonResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import {
|
||||
type BackupArchiveBundle,
|
||||
buildBackupArchive,
|
||||
inspectBackupArchiveFileNameChecksum,
|
||||
parseBackupArchive,
|
||||
verifyBackupArchiveFileNameChecksum,
|
||||
} from '../services/backup-archive';
|
||||
import {
|
||||
type BackupDestinationRecord,
|
||||
type BackupSettingsInput,
|
||||
BACKUP_SCHEDULER_WINDOW_MINUTES,
|
||||
type BackupSettings,
|
||||
type WebDavBackupDestination,
|
||||
getBackupLocalDateKey,
|
||||
getDefaultBackupSettings,
|
||||
getBackupSettingsRepairState,
|
||||
hasBackupSlotBetween,
|
||||
isBackupDueNow,
|
||||
loadBackupSettings,
|
||||
normalizeBackupSettingsInput,
|
||||
normalizeImportedBackupSettings,
|
||||
@@ -31,6 +30,7 @@ import {
|
||||
} from '../services/backup-import';
|
||||
import {
|
||||
type RemoteBackupTransferSession,
|
||||
type RemoteBackupFile,
|
||||
createRemoteBackupTransferSession,
|
||||
deleteRemoteBackupFile,
|
||||
downloadRemoteBackupFile,
|
||||
@@ -43,6 +43,7 @@ import { StorageService } from '../services/storage';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
import { getBlobObject } from '../services/blob-store';
|
||||
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
|
||||
import { unzipSync } from 'fflate';
|
||||
|
||||
function isAdmin(user: User): boolean {
|
||||
return user.role === 'admin' && user.status === 'active';
|
||||
@@ -86,102 +87,6 @@ function getBackupDestinationSummary(destination: BackupDestinationRecord | null
|
||||
};
|
||||
}
|
||||
|
||||
const BACKUP_RUNNER_LOCK_KEY = 'backup.runner.lock.v1';
|
||||
const BACKUP_RUNNER_LEASE_MS = 10 * 60 * 1000;
|
||||
const BACKUP_RUNNER_HEARTBEAT_MS = 30 * 1000;
|
||||
|
||||
// CONTRACT:
|
||||
// The runner lock is a config-row lease, not a queue. It only prevents two
|
||||
// backup/restore jobs from overlapping. Manual runs return conflict when the
|
||||
// lease is held; scheduled runs skip quietly. Never export this row in backups.
|
||||
interface BackupRunnerLease {
|
||||
token: string;
|
||||
touch: () => Promise<void>;
|
||||
release: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function acquireBackupRunnerLease(env: Env, reason: string): Promise<BackupRunnerLease | null> {
|
||||
const token = generateUUID();
|
||||
const nowMs = Date.now();
|
||||
const expiresAtMs = nowMs + BACKUP_RUNNER_LEASE_MS;
|
||||
const value = JSON.stringify({
|
||||
token,
|
||||
reason,
|
||||
acquiredAt: new Date(nowMs).toISOString(),
|
||||
touchedAt: new Date(nowMs).toISOString(),
|
||||
expiresAtMs,
|
||||
});
|
||||
const result = await env.DB
|
||||
.prepare(
|
||||
`INSERT INTO config(key, value) VALUES(?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
WHERE COALESCE(CAST(json_extract(config.value, '$.expiresAtMs') AS INTEGER), 0) <= ?`
|
||||
)
|
||||
.bind(BACKUP_RUNNER_LOCK_KEY, value, nowMs)
|
||||
.run();
|
||||
|
||||
if ((result.meta?.changes || 0) < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
touch: async () => {
|
||||
const nextNowMs = Date.now();
|
||||
const nextValue = JSON.stringify({
|
||||
token,
|
||||
reason,
|
||||
acquiredAt: new Date(nowMs).toISOString(),
|
||||
touchedAt: new Date(nextNowMs).toISOString(),
|
||||
expiresAtMs: nextNowMs + BACKUP_RUNNER_LEASE_MS,
|
||||
});
|
||||
await env.DB
|
||||
.prepare(
|
||||
`UPDATE config
|
||||
SET value = ?
|
||||
WHERE key = ?
|
||||
AND json_extract(value, '$.token') = ?`
|
||||
)
|
||||
.bind(nextValue, BACKUP_RUNNER_LOCK_KEY, token)
|
||||
.run();
|
||||
},
|
||||
release: async () => {
|
||||
await env.DB
|
||||
.prepare(
|
||||
`DELETE FROM config
|
||||
WHERE key = ?
|
||||
AND json_extract(value, '$.token') = ?`
|
||||
)
|
||||
.bind(BACKUP_RUNNER_LOCK_KEY, token)
|
||||
.run();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function withBackupRunnerLease<T>(
|
||||
env: Env,
|
||||
reason: string,
|
||||
task: (keepAlive: () => Promise<void>) => Promise<T>
|
||||
): Promise<T | null> {
|
||||
const lease = await acquireBackupRunnerLease(env, reason);
|
||||
if (!lease) return null;
|
||||
|
||||
let lastHeartbeatAt = 0;
|
||||
const keepAlive = async () => {
|
||||
const nowMs = Date.now();
|
||||
if (nowMs - lastHeartbeatAt < BACKUP_RUNNER_HEARTBEAT_MS) return;
|
||||
lastHeartbeatAt = nowMs;
|
||||
await lease.touch();
|
||||
};
|
||||
|
||||
try {
|
||||
await keepAlive();
|
||||
return await task(keepAlive);
|
||||
} finally {
|
||||
await lease.release();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureBackupBlobName(value: string): string {
|
||||
const normalized = String(value || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||
if (!normalized) {
|
||||
@@ -201,6 +106,37 @@ interface RemoteAttachmentIndexPayload {
|
||||
blobs: Record<string, { sizeBytes: number; updatedAt: string }>;
|
||||
}
|
||||
|
||||
const REMOTE_ATTACHMENT_SYNC_EXTERNAL_SUBREQUEST_LIMIT = 50;
|
||||
const REMOTE_ATTACHMENT_SYNC_SUBREQUEST_RESERVE = 6;
|
||||
const REMOTE_ATTACHMENT_SYNC_MAX_WEB_DAV_BATCH_SIZE = 18;
|
||||
const REMOTE_ATTACHMENT_SYNC_MAX_S3_BATCH_SIZE = 40;
|
||||
const REMOTE_ATTACHMENT_RESTORE_BATCH_SIZE = 40;
|
||||
|
||||
function countRemotePathSegments(value: string): number {
|
||||
return String(value || '').replace(/\\/g, '/').split('/').filter(Boolean).length;
|
||||
}
|
||||
|
||||
function getRemoteAttachmentSyncBatchSize(destination: BackupDestinationRecord): number {
|
||||
if (destination.type === 's3') {
|
||||
return REMOTE_ATTACHMENT_SYNC_MAX_S3_BATCH_SIZE;
|
||||
}
|
||||
|
||||
const remotePath = String((destination.destination as WebDavBackupDestination).remotePath || '');
|
||||
const fixedWebDavDirectoryCalls = countRemotePathSegments(remotePath) + 1; // remotePath plus the shared "attachments" dir.
|
||||
const available = REMOTE_ATTACHMENT_SYNC_EXTERNAL_SUBREQUEST_LIMIT
|
||||
- REMOTE_ATTACHMENT_SYNC_SUBREQUEST_RESERVE
|
||||
- fixedWebDavDirectoryCalls;
|
||||
|
||||
if (available < 2) {
|
||||
throw new Error('WebDAV remote backup path is too deep for safe attachment batching');
|
||||
}
|
||||
|
||||
return Math.max(1, Math.min(
|
||||
REMOTE_ATTACHMENT_SYNC_MAX_WEB_DAV_BATCH_SIZE,
|
||||
Math.floor(available / 2)
|
||||
));
|
||||
}
|
||||
|
||||
async function loadRemoteAttachmentIndex(session: RemoteBackupTransferSession): Promise<Map<string, number>> {
|
||||
try {
|
||||
const file = await session.download(REMOTE_ATTACHMENT_INDEX_PATH);
|
||||
@@ -256,7 +192,39 @@ async function saveRemoteAttachmentIndex(
|
||||
});
|
||||
}
|
||||
|
||||
async function executeConfiguredBackup(
|
||||
async function uploadRemoteAttachmentChunk(
|
||||
env: Env,
|
||||
destination: BackupDestinationRecord,
|
||||
attachments: Array<{ blobName: string }>
|
||||
): Promise<void> {
|
||||
if (!attachments.length) return;
|
||||
const id = env.BACKUP_TRANSFER_RUNNER.idFromName('remote-attachment-sync');
|
||||
const stub = env.BACKUP_TRANSFER_RUNNER.get(id);
|
||||
const response = await stub.fetch('https://backup-transfer/internal/upload-attachment-chunk', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
destination,
|
||||
attachments,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
let message = `Attachment sync failed: ${response.status}`;
|
||||
try {
|
||||
const payload = await response.json<{ error?: string }>();
|
||||
if (payload?.error) {
|
||||
message = payload.error;
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse failures and preserve the status-based error.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeConfiguredBackup(
|
||||
env: Env,
|
||||
storage: StorageService,
|
||||
actorUserId: string | null,
|
||||
@@ -331,25 +299,20 @@ async function executeConfiguredBackup(
|
||||
if (destination.includeAttachments) {
|
||||
await touchLease();
|
||||
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
|
||||
let attachmentIndexChanged = false;
|
||||
for (const attachment of archive.manifest.attachmentBlobs || []) {
|
||||
const pendingAttachments = (archive.manifest.attachmentBlobs || [])
|
||||
.filter((attachment) => remoteAttachmentIndex.get(attachment.blobName) !== attachment.sizeBytes);
|
||||
const attachmentSyncBatchSize = getRemoteAttachmentSyncBatchSize(destination);
|
||||
for (let i = 0; i < pendingAttachments.length; i += attachmentSyncBatchSize) {
|
||||
await touchLease();
|
||||
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
|
||||
continue;
|
||||
}
|
||||
const remotePath = `attachments/${attachment.blobName}`;
|
||||
const object = await getBlobObject(env, attachment.blobName);
|
||||
if (!object) {
|
||||
throw new Error(`Attachment blob missing for ${attachment.blobName}`);
|
||||
}
|
||||
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
|
||||
await remoteSession.putFile(remotePath, bytes, {
|
||||
contentType: object.contentType,
|
||||
});
|
||||
remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes);
|
||||
attachmentIndexChanged = true;
|
||||
const chunk = pendingAttachments
|
||||
.slice(i, i + attachmentSyncBatchSize)
|
||||
.map((attachment) => ({ blobName: attachment.blobName }));
|
||||
await uploadRemoteAttachmentChunk(env, destination, chunk);
|
||||
}
|
||||
if (attachmentIndexChanged) {
|
||||
if (pendingAttachments.length) {
|
||||
for (const attachment of pendingAttachments) {
|
||||
remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes);
|
||||
}
|
||||
await touchLease();
|
||||
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
|
||||
}
|
||||
@@ -474,14 +437,293 @@ async function executeConfiguredBackup(
|
||||
}
|
||||
}
|
||||
|
||||
interface DurableBackupRunResponse {
|
||||
result: {
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
remotePath: string;
|
||||
provider: string;
|
||||
};
|
||||
settings: BackupSettings;
|
||||
}
|
||||
|
||||
async function runConfiguredBackupInDurableObject(
|
||||
env: Env,
|
||||
payload: {
|
||||
actorUserId: string | null;
|
||||
auditMetadata?: Record<string, unknown> | null;
|
||||
destinationId?: string | null;
|
||||
targetDeviceIdentifier?: string | null;
|
||||
trigger: 'manual' | 'scheduled';
|
||||
}
|
||||
): Promise<DurableBackupRunResponse | null> {
|
||||
const id = env.BACKUP_TRANSFER_RUNNER.idFromName('configured-backup-runner');
|
||||
const stub = env.BACKUP_TRANSFER_RUNNER.get(id);
|
||||
const response = await stub.fetch('https://backup-transfer/internal/run-configured-backup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (response.status === 409) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
let message = `Backup run failed: ${response.status}`;
|
||||
try {
|
||||
const body = await response.json<{ error?: string }>();
|
||||
if (body?.error) message = body.error;
|
||||
} catch {
|
||||
// Preserve the status-based message when the DO returns a non-JSON error.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
const body = await response.json<DurableBackupRunResponse>();
|
||||
if (!body?.result || !body?.settings) {
|
||||
throw new Error('Backup run response is invalid');
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function runScheduledBackupsInDurableObject(env: Env): Promise<void> {
|
||||
const id = env.BACKUP_TRANSFER_RUNNER.idFromName('configured-backup-runner');
|
||||
const stub = env.BACKUP_TRANSFER_RUNNER.get(id);
|
||||
const response = await stub.fetch('https://backup-transfer/internal/run-scheduled-backups', {
|
||||
method: 'POST',
|
||||
});
|
||||
if (response.status === 409) {
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
let message = `Scheduled backup failed: ${response.status}`;
|
||||
try {
|
||||
const body = await response.json<{ error?: string }>();
|
||||
if (body?.error) message = body.error;
|
||||
} catch {
|
||||
// Preserve the status-based message when the DO returns a non-JSON error.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadRemoteAttachmentViaDurableObject(
|
||||
env: Env,
|
||||
destination: BackupDestinationRecord,
|
||||
blobName: string
|
||||
): Promise<Uint8Array | null> {
|
||||
const id = env.BACKUP_TRANSFER_RUNNER.idFromName('remote-attachment-restore');
|
||||
const stub = env.BACKUP_TRANSFER_RUNNER.get(id);
|
||||
const response = await stub.fetch('https://backup-transfer/internal/download-remote-attachment', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
destination,
|
||||
blobName,
|
||||
}),
|
||||
});
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Remote attachment download failed: ${response.status}`);
|
||||
}
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
async function downloadRemoteAttachmentBatchViaDurableObject(
|
||||
env: Env,
|
||||
destination: BackupDestinationRecord,
|
||||
blobNames: string[]
|
||||
): Promise<Map<string, Uint8Array>> {
|
||||
const names = Array.from(new Set(blobNames.map((blobName) => String(blobName || '').trim()).filter(Boolean)));
|
||||
const result = new Map<string, Uint8Array>();
|
||||
if (!names.length) return result;
|
||||
|
||||
const id = env.BACKUP_TRANSFER_RUNNER.idFromName('remote-attachment-restore');
|
||||
const stub = env.BACKUP_TRANSFER_RUNNER.get(id);
|
||||
const response = await stub.fetch('https://backup-transfer/internal/download-remote-attachment-batch', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
destination,
|
||||
blobNames: names,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Remote attachment batch download failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const files = unzipSync(new Uint8Array(await response.arrayBuffer()));
|
||||
const manifestBytes = files['manifest.json'];
|
||||
if (!manifestBytes) return result;
|
||||
const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as {
|
||||
entries?: Array<{ blobName?: string; path?: string }>;
|
||||
};
|
||||
for (const entry of manifest.entries || []) {
|
||||
const blobName = String(entry.blobName || '').trim();
|
||||
const path = String(entry.path || '').trim();
|
||||
const bytes = path ? files[path] : null;
|
||||
if (blobName && bytes) {
|
||||
result.set(blobName, bytes);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function collectExternalRemoteAttachmentBlobNames(archiveBytes: Uint8Array): string[] {
|
||||
const parsed = parseBackupArchive(archiveBytes, { allowExternalAttachmentBlobs: true });
|
||||
const refs = new Map(
|
||||
(parsed.payload.manifest.attachmentBlobs || [])
|
||||
.map((item) => [`${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}`, item])
|
||||
);
|
||||
const names: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const row of parsed.payload.db.attachments || []) {
|
||||
const cipherId = String(row.cipher_id || '').trim();
|
||||
const attachmentId = String(row.id || '').trim();
|
||||
const inlinePath = `attachments/${cipherId}/${attachmentId}.bin`;
|
||||
if (parsed.files[inlinePath]) continue;
|
||||
const ref = refs.get(`${cipherId}/${attachmentId}`);
|
||||
const blobName = String(ref?.blobName || '').trim();
|
||||
if (blobName && !seen.has(blobName)) {
|
||||
seen.add(blobName);
|
||||
names.push(blobName);
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
function toImportStatusCode(message: string): number {
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes('checksum')) return 400;
|
||||
if (lower.includes('invalid backup') || lower.includes('invalid json')) return 400;
|
||||
if (lower.includes('fresh instance')) return 409;
|
||||
if (lower.includes('not configured') || lower.includes('kv')) return 409;
|
||||
return 500;
|
||||
}
|
||||
|
||||
export async function importAndAuditRemoteBackupFile(
|
||||
env: Env,
|
||||
storage: StorageService,
|
||||
actorUserId: string,
|
||||
remoteFile: RemoteBackupFile,
|
||||
destination: BackupDestinationRecord,
|
||||
remotePath: string,
|
||||
replaceExisting: boolean,
|
||||
checksumMismatchAccepted: boolean,
|
||||
auditMetadata: Record<string, unknown> | null = null,
|
||||
targetDeviceIdentifier: string | null = null
|
||||
): Promise<BackupImportExecutionResult> {
|
||||
const restoreFileName = remoteFile.fileName || remotePath.split('/').pop() || remotePath;
|
||||
const externalAttachmentBlobNames = collectExternalRemoteAttachmentBlobNames(remoteFile.bytes);
|
||||
const externalAttachmentCache = new Map<string, Uint8Array | null>();
|
||||
const progress: BackupRestoreProgressReporter = async (event) => {
|
||||
await notifyUserBackupRestoreProgress(
|
||||
env,
|
||||
actorUserId,
|
||||
{
|
||||
operation: 'backup-restore',
|
||||
...event,
|
||||
},
|
||||
targetDeviceIdentifier
|
||||
);
|
||||
};
|
||||
const result = await importRemoteBackupArchiveBytes(
|
||||
remoteFile.bytes,
|
||||
env,
|
||||
actorUserId,
|
||||
replaceExisting,
|
||||
{
|
||||
loadAttachment: async (blobName) => {
|
||||
const normalized = String(blobName || '').trim();
|
||||
if (!normalized) return null;
|
||||
if (externalAttachmentCache.has(normalized)) {
|
||||
return externalAttachmentCache.get(normalized) || null;
|
||||
}
|
||||
|
||||
const start = Math.max(0, externalAttachmentBlobNames.indexOf(normalized));
|
||||
const batchNames = externalAttachmentBlobNames
|
||||
.slice(start, start + REMOTE_ATTACHMENT_RESTORE_BATCH_SIZE)
|
||||
.filter((name) => !externalAttachmentCache.has(name));
|
||||
if (!batchNames.includes(normalized)) {
|
||||
batchNames.unshift(normalized);
|
||||
}
|
||||
|
||||
try {
|
||||
const batch = await downloadRemoteAttachmentBatchViaDurableObject(env, destination, batchNames);
|
||||
for (const name of batchNames) {
|
||||
externalAttachmentCache.set(name, batch.get(name) || null);
|
||||
}
|
||||
} catch {
|
||||
externalAttachmentCache.set(normalized, await downloadRemoteAttachmentViaDurableObject(env, destination, normalized).catch(() => null));
|
||||
}
|
||||
return externalAttachmentCache.get(normalized) || null;
|
||||
},
|
||||
},
|
||||
progress,
|
||||
restoreFileName
|
||||
);
|
||||
await writeAuditLog(storage, result.auditActorUserId, 'admin.backup.import', 'backup', null, {
|
||||
users: result.result.imported.users,
|
||||
ciphers: result.result.imported.ciphers,
|
||||
attachments: result.result.imported.attachmentFiles,
|
||||
skippedAttachments: result.result.skipped.attachments,
|
||||
skippedReason: result.result.skipped.reason,
|
||||
replaceExisting,
|
||||
...getBackupDestinationSummary(destination),
|
||||
remotePath,
|
||||
bytes: remoteFile.bytes.byteLength,
|
||||
trigger: 'remote',
|
||||
checksumMismatchAccepted,
|
||||
...(auditMetadata || {}),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function restoreRemoteBackupInDurableObject(
|
||||
env: Env,
|
||||
payload: {
|
||||
actorUserId: string;
|
||||
allowChecksumMismatch?: boolean;
|
||||
auditMetadata?: Record<string, unknown> | null;
|
||||
destinationId?: string | null;
|
||||
path: string;
|
||||
replaceExisting?: boolean;
|
||||
targetDeviceIdentifier?: string | null;
|
||||
}
|
||||
): Promise<BackupImportExecutionResult['result'] | null> {
|
||||
const id = env.BACKUP_TRANSFER_RUNNER.idFromName('configured-backup-runner');
|
||||
const stub = env.BACKUP_TRANSFER_RUNNER.get(id);
|
||||
const response = await stub.fetch('https://backup-transfer/internal/restore-remote-backup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (response.status === 409) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
let message = `Remote backup restore failed: ${response.status}`;
|
||||
try {
|
||||
const body = await response.json<{ error?: string }>();
|
||||
if (body?.error) message = body.error;
|
||||
} catch {
|
||||
// Preserve the status-based message when the DO returns a non-JSON error.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return response.json<BackupImportExecutionResult['result']>();
|
||||
}
|
||||
|
||||
async function runImportAndAudit(
|
||||
env: Env,
|
||||
request: Request,
|
||||
@@ -526,30 +768,7 @@ async function runImportAndAudit(
|
||||
}
|
||||
|
||||
export async function runScheduledBackupIfDue(env: Env): Promise<void> {
|
||||
await withBackupRunnerLease(env, 'scheduled', async (keepAlive) => {
|
||||
const storage = new StorageService(env.DB);
|
||||
let scanStartMs = Date.now();
|
||||
|
||||
while (true) {
|
||||
await keepAlive();
|
||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||
const now = new Date();
|
||||
const dueDestinations = settings.destinations.filter((destination) =>
|
||||
isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)
|
||||
|| hasBackupSlotBetween(destination, new Date(scanStartMs), now)
|
||||
);
|
||||
|
||||
if (!dueDestinations.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
scanStartMs = now.getTime();
|
||||
for (const destination of dueDestinations) {
|
||||
await keepAlive();
|
||||
await executeConfiguredBackup(env, storage, null, 'scheduled', destination.id, keepAlive);
|
||||
}
|
||||
}
|
||||
});
|
||||
await runScheduledBackupsInDurableObject(env);
|
||||
}
|
||||
|
||||
export async function handleGetAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||
@@ -661,33 +880,12 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
|
||||
return errorResponse('Backup run payload is invalid', 400);
|
||||
}
|
||||
|
||||
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||
const progress = async (event: {
|
||||
operation: 'backup-remote-run';
|
||||
step: string;
|
||||
fileName: string;
|
||||
stageTitle: string;
|
||||
stageDetail: string;
|
||||
done?: boolean;
|
||||
ok?: boolean;
|
||||
error?: string | null;
|
||||
}) => {
|
||||
await notifyUserBackupProgress(env, actorUser.id, event, targetDeviceIdentifier);
|
||||
};
|
||||
const outcome = await withBackupRunnerLease(env, `manual:${actorUser.id}`, async (keepAlive) => {
|
||||
const storage = new StorageService(env.DB);
|
||||
const result = await executeConfiguredBackup(
|
||||
env,
|
||||
storage,
|
||||
actorUser.id,
|
||||
'manual',
|
||||
body?.destinationId || null,
|
||||
keepAlive,
|
||||
progress,
|
||||
auditRequestMetadata(request)
|
||||
);
|
||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||
return { result, settings };
|
||||
const outcome = await runConfiguredBackupInDurableObject(env, {
|
||||
actorUserId: actorUser.id,
|
||||
auditMetadata: auditRequestMetadata(request),
|
||||
destinationId: body?.destinationId || null,
|
||||
targetDeviceIdentifier: String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null,
|
||||
trigger: 'manual',
|
||||
});
|
||||
if (!outcome) {
|
||||
return errorResponse('Another backup run is already in progress', 409);
|
||||
@@ -803,76 +1001,22 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env,
|
||||
return errorResponse('Remote restore payload is invalid', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
try {
|
||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||
const destination = requireBackupDestination(settings, body.destinationId || null);
|
||||
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||
const restoreFileNameFromPath = path.split('/').pop() || path;
|
||||
await notifyUserBackupRestoreProgress(
|
||||
env,
|
||||
actorUser.id,
|
||||
{
|
||||
operation: 'backup-restore',
|
||||
source: 'remote',
|
||||
step: 'remote_fetch_archive',
|
||||
fileName: restoreFileNameFromPath,
|
||||
stageTitle: 'txt_backup_restore_progress_remote_fetch_title',
|
||||
stageDetail: 'txt_backup_restore_progress_remote_fetch_detail',
|
||||
replaceExisting: !!body.replaceExisting,
|
||||
},
|
||||
targetDeviceIdentifier
|
||||
);
|
||||
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
||||
if (!checksumOk && !body.allowChecksumMismatch) {
|
||||
return errorResponse('Remote backup file checksum does not match its filename', 400);
|
||||
const imported = await restoreRemoteBackupInDurableObject(env, {
|
||||
actorUserId: actorUser.id,
|
||||
allowChecksumMismatch: !!body.allowChecksumMismatch,
|
||||
auditMetadata: auditRequestMetadata(request),
|
||||
destinationId: body.destinationId || null,
|
||||
path,
|
||||
replaceExisting: !!body.replaceExisting,
|
||||
targetDeviceIdentifier,
|
||||
});
|
||||
if (!imported) {
|
||||
return errorResponse('Another backup or restore run is already in progress', 409);
|
||||
}
|
||||
const restoreFileName = remoteFile.fileName || path.split('/').pop() || path;
|
||||
const progress: BackupRestoreProgressReporter = async (event) => {
|
||||
await notifyUserBackupRestoreProgress(
|
||||
env,
|
||||
actorUser.id,
|
||||
{
|
||||
operation: 'backup-restore',
|
||||
...event,
|
||||
},
|
||||
targetDeviceIdentifier
|
||||
);
|
||||
};
|
||||
const imported = await (async () => {
|
||||
const storage = new StorageService(env.DB);
|
||||
const result = await importRemoteBackupArchiveBytes(
|
||||
remoteFile.bytes,
|
||||
env,
|
||||
actorUser.id,
|
||||
!!body.replaceExisting,
|
||||
{
|
||||
loadAttachment: async (blobName) => {
|
||||
const file = await downloadRemoteBackupFile(destination, `attachments/${blobName}`).catch(() => null);
|
||||
return file?.bytes || null;
|
||||
},
|
||||
},
|
||||
progress,
|
||||
restoreFileName
|
||||
);
|
||||
await writeAuditLog(storage, result.auditActorUserId, 'admin.backup.import', 'backup', null, {
|
||||
users: result.result.imported.users,
|
||||
ciphers: result.result.imported.ciphers,
|
||||
attachments: result.result.imported.attachmentFiles,
|
||||
skippedAttachments: result.result.skipped.attachments,
|
||||
skippedReason: result.result.skipped.reason,
|
||||
replaceExisting: !!body.replaceExisting,
|
||||
...getBackupDestinationSummary(destination),
|
||||
remotePath: path,
|
||||
bytes: remoteFile.bytes.byteLength,
|
||||
trigger: 'remote',
|
||||
checksumMismatchAccepted: !checksumOk,
|
||||
}, request);
|
||||
return result;
|
||||
})();
|
||||
return jsonResponse(imported.result);
|
||||
return jsonResponse(imported);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Remote backup restore failed';
|
||||
return errorResponse(message, toImportStatusCode(message));
|
||||
|
||||
+70
-30
@@ -10,7 +10,6 @@ import {
|
||||
Attachment,
|
||||
PasswordHistory,
|
||||
} from '../types';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
@@ -25,6 +24,18 @@ import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'
|
||||
// unknown/future client fields by default, then override only server-owned
|
||||
// fields. Any change to cipher response shape must be checked against /api/sync,
|
||||
// attachments, import/export, and current official clients.
|
||||
export interface CipherResponseOptions {
|
||||
preserveRepairableUris?: boolean;
|
||||
}
|
||||
|
||||
export function shouldPreserveRepairableCipherUris(request: Request): boolean {
|
||||
return request.headers.get('X-NodeWarden-Web') === '1';
|
||||
}
|
||||
|
||||
function cipherResponseOptionsForRequest(request: Request): CipherResponseOptions {
|
||||
return { preserveRepairableUris: shouldPreserveRepairableCipherUris(request) };
|
||||
}
|
||||
|
||||
function normalizeOptionalId(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
const normalized = String(value).trim();
|
||||
@@ -131,12 +142,10 @@ function optionalEncString(value: unknown): string | null {
|
||||
}
|
||||
|
||||
function shouldAcceptCipherKey(value: unknown): boolean {
|
||||
if (LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled) return true;
|
||||
return optionalEncString(value) === null;
|
||||
return value == null || value === '' || isValidEncString(value);
|
||||
}
|
||||
|
||||
function normalizeCipherKeyForStorage(value: unknown): string | null {
|
||||
if (!LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled) return null;
|
||||
return optionalEncString(value);
|
||||
}
|
||||
|
||||
@@ -172,14 +181,18 @@ export function normalizeCipherLoginForStorage(login: any): any {
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCipherLoginForCompatibility(login: any, requiresUriChecksum: boolean = false): any {
|
||||
export function normalizeCipherLoginForCompatibility(
|
||||
login: any,
|
||||
requiresUriChecksum: boolean = false,
|
||||
preserveRepairableUris: boolean = false
|
||||
): any {
|
||||
const normalized = normalizeCipherLoginForStorage(login);
|
||||
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
|
||||
const next = sanitizeEncryptedObject(normalized, ['username', 'password', 'totp', 'uri']);
|
||||
if (!next) return null;
|
||||
next.uris = normalizeCipherLoginUrisForCompatibility(next.uris, {
|
||||
hasLegacyLoginUri: isValidEncString(next.uri),
|
||||
requiresUriChecksum,
|
||||
preserveRepairableUris,
|
||||
});
|
||||
next.fido2Credentials = normalizeFido2CredentialsForCompatibility(next.fido2Credentials);
|
||||
return next;
|
||||
@@ -187,7 +200,7 @@ export function normalizeCipherLoginForCompatibility(login: any, requiresUriChec
|
||||
|
||||
function normalizeCipherLoginUrisForCompatibility(
|
||||
uris: any,
|
||||
options: { hasLegacyLoginUri?: boolean; requiresUriChecksum?: boolean } = {}
|
||||
options: { requiresUriChecksum?: boolean; preserveRepairableUris?: boolean } = {}
|
||||
): any[] | null {
|
||||
if (!Array.isArray(uris) || uris.length === 0) return null;
|
||||
const out: any[] = [];
|
||||
@@ -207,11 +220,18 @@ function normalizeCipherLoginUrisForCompatibility(
|
||||
}
|
||||
|
||||
if (hasUri && !hasChecksum) {
|
||||
// Bitwarden browser clients using the SDK can fail the whole vault load
|
||||
// when an item-key encrypted URI has no encrypted checksum. The server
|
||||
// cannot derive the checksum, so expose the item without the bad URI.
|
||||
if (options.requiresUriChecksum || options.hasLegacyLoginUri) continue;
|
||||
out.push({ ...next, uri: null, uriChecksum: null });
|
||||
if (options.preserveRepairableUris) {
|
||||
// Preserve the encrypted URI so NodeWarden Web can decrypt it and repair
|
||||
// the missing checksum. Dropping it here makes the URI appear lost and
|
||||
// can turn a display-only compatibility issue into data loss on save.
|
||||
out.push({ ...next, uriChecksum: null });
|
||||
continue;
|
||||
}
|
||||
// Bitwarden browser clients using the SDK drop item-key encrypted URIs
|
||||
// whose checksum is missing/invalid. User-key encrypted legacy/import
|
||||
// entries bypass this validation and can safely keep the URI.
|
||||
if (options.requiresUriChecksum) continue;
|
||||
out.push({ ...next, uriChecksum: null });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -558,14 +578,17 @@ export function isCipherResponseSyncCompatible(cipher: CipherResponse): boolean
|
||||
// survive a round-trip without code changes.
|
||||
export function cipherToResponse(
|
||||
cipher: Cipher,
|
||||
attachments: Attachment[] = []
|
||||
attachments: Attachment[] = [],
|
||||
options: CipherResponseOptions = {}
|
||||
): CipherResponse {
|
||||
// Strip internal-only fields that must not appear in the API response
|
||||
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
|
||||
const responseCipherKey = LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled
|
||||
? optionalEncString(cipher.key)
|
||||
: null;
|
||||
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, !!responseCipherKey);
|
||||
const responseCipherKey = optionalEncString(cipher.key);
|
||||
const normalizedLogin = normalizeCipherLoginForCompatibility(
|
||||
(passthrough as any).login ?? null,
|
||||
!!responseCipherKey,
|
||||
!!options.preserveRepairableUris
|
||||
);
|
||||
const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']);
|
||||
const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [
|
||||
'title',
|
||||
@@ -659,10 +682,11 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
||||
);
|
||||
|
||||
// Build responses only for the current page to keep pagination cheap.
|
||||
const responseOptions = cipherResponseOptionsForRequest(request);
|
||||
const cipherResponses: CipherResponse[] = [];
|
||||
for (const cipher of filteredCiphers) {
|
||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||
cipherResponses.push(cipherToResponse(cipher, attachments, responseOptions));
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
@@ -682,8 +706,9 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
|
||||
}
|
||||
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
const responseOptions = cipherResponseOptionsForRequest(request);
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, attachments)
|
||||
cipherToResponse(cipher, attachments, responseOptions)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -761,9 +786,10 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
const responseOptions = cipherResponseOptionsForRequest(request);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, []),
|
||||
cipherToResponse(cipher, [], responseOptions),
|
||||
200
|
||||
);
|
||||
}
|
||||
@@ -797,6 +823,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||
const incomingRevisionDate = readCipherRevisionDate(cipherData);
|
||||
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData);
|
||||
const preserveRevisionDate =
|
||||
shouldPreserveRepairableCipherUris(request)
|
||||
&& (body.preserveRevisionDate === true || cipherData.preserveRevisionDate === true);
|
||||
|
||||
if (incomingKey.present && !shouldAcceptCipherKey(incomingKey.value)) {
|
||||
return errorResponse('Cipher key encryption is not supported by this server. Resync the client and try again.', 400);
|
||||
@@ -806,13 +835,18 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
|
||||
}
|
||||
|
||||
if (!shouldPreserveRepairableCipherUris(request) && incomingLogin.present && hasMissingLoginUriChecksum(existingCipher)) {
|
||||
return errorResponse('This item has login URIs that must be repaired in NodeWarden Web before updating from this client. Open NodeWarden Web once, then resync.', 400);
|
||||
}
|
||||
|
||||
const nextType = Number(cipherData.type) || existingCipher.type;
|
||||
|
||||
// Opaque passthrough: merge existing stored data with ALL incoming client fields.
|
||||
// Unknown/future fields from the client are preserved; server-controlled fields are protected.
|
||||
const { preserveRevisionDate: _preserveRevisionDate, PreserveRevisionDate: _pascalPreserveRevisionDate, ...cipherDataWithoutFlags } = cipherData;
|
||||
const cipher: Cipher = {
|
||||
...existingCipher, // start with all existing stored data (including unknowns)
|
||||
...cipherData, // overlay all client data (including new/unknown fields)
|
||||
...cipherDataWithoutFlags, // overlay all client data (including new/unknown fields)
|
||||
// Server-controlled fields (never from client)
|
||||
id: existingCipher.id,
|
||||
userId: existingCipher.userId,
|
||||
@@ -820,14 +854,19 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
favorite: cipherData.favorite ?? existingCipher.favorite,
|
||||
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
||||
createdAt: existingCipher.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedAt: preserveRevisionDate ? existingCipher.updatedAt : new Date().toISOString(),
|
||||
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
|
||||
deletedAt: existingCipher.deletedAt,
|
||||
};
|
||||
if (incomingFolderId.present) {
|
||||
cipher.folderId = normalizeOptionalId(incomingFolderId.value);
|
||||
}
|
||||
cipher.key = normalizeCipherKeyForStorage(incomingKey.present ? incomingKey.value : existingCipher.key);
|
||||
if (incomingKey.present) {
|
||||
const normalizedIncomingKey = normalizeCipherKeyForStorage(incomingKey.value);
|
||||
cipher.key = normalizedIncomingKey || normalizeCipherKeyForStorage(existingCipher.key);
|
||||
} else {
|
||||
cipher.key = normalizeCipherKeyForStorage(existingCipher.key);
|
||||
}
|
||||
cipher.login = nextType === 1 ? (incomingLogin.present ? (incomingLogin.value ?? null) : (existingCipher.login ?? null)) : null;
|
||||
cipher.secureNote = nextType === 2 ? (incomingSecureNote.present ? (incomingSecureNote.value ?? null) : (existingCipher.secureNote ?? null)) : null;
|
||||
cipher.card = nextType === 3 ? (incomingCard.present ? (incomingCard.value ?? null) : (existingCipher.card ?? null)) : null;
|
||||
@@ -864,9 +903,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
const responseOptions = cipherResponseOptionsForRequest(request);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, attachments)
|
||||
cipherToResponse(cipher, attachments, responseOptions)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -893,7 +933,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
||||
});
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [])
|
||||
cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -968,7 +1008,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [])
|
||||
cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1007,7 +1047,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [])
|
||||
cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1051,7 +1091,7 @@ async function buildCipherListResponse(
|
||||
|
||||
return jsonResponse({
|
||||
data: ciphers.map((cipher) =>
|
||||
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [])
|
||||
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], cipherResponseOptionsForRequest(request))
|
||||
),
|
||||
object: 'list',
|
||||
continuationToken: null,
|
||||
@@ -1084,7 +1124,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
|
||||
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, attachments)
|
||||
cipherToResponse(cipher, attachments, cipherResponseOptionsForRequest(request))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1106,7 +1146,7 @@ export async function handleUnarchiveCipher(request: Request, env: Env, userId:
|
||||
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, attachments)
|
||||
cipherToResponse(cipher, attachments, cipherResponseOptionsForRequest(request))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+33
-7
@@ -1,7 +1,7 @@
|
||||
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { errorResponse } from '../utils/response';
|
||||
import { cipherToResponse, isCipherResponseSyncCompatible } from './ciphers';
|
||||
import { cipherToResponse, isCipherResponseSyncCompatible, shouldPreserveRepairableCipherUris } from './ciphers';
|
||||
import { sendToResponse } from './sends';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import {
|
||||
@@ -10,16 +10,25 @@ 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.
|
||||
// Filtering invalid cipher responses here protects clients from stored rows that
|
||||
// would otherwise make official apps fail after an HTTP 200 sync.
|
||||
// Keep this aligned with src/handlers/ciphers.ts when adding new vault fields.
|
||||
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean, excludeSends: boolean): Request {
|
||||
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'}`,
|
||||
`/__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' });
|
||||
@@ -43,14 +52,26 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||
const excludeSendsParam = url.searchParams.get('excludeSends');
|
||||
const excludeSends = excludeSendsParam !== null && /^(1|true|yes)$/i.test(excludeSendsParam);
|
||||
const preserveRepairableUris = shouldPreserveRepairableCipherUris(request);
|
||||
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) {
|
||||
return errorResponse('User not found', 404);
|
||||
}
|
||||
|
||||
const revisionDate = await storage.getRevisionDate(userId);
|
||||
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains, excludeSends);
|
||||
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;
|
||||
@@ -64,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,
|
||||
@@ -93,7 +117,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
|
||||
const cipherResponses: CipherResponse[] = [];
|
||||
for (const cipher of ciphers) {
|
||||
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []);
|
||||
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris });
|
||||
if (isCipherResponseSyncCompatible(response)) {
|
||||
cipherResponses.push(response);
|
||||
}
|
||||
@@ -130,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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Env } from './types';
|
||||
import { NotificationsHub } from './durable/notifications-hub';
|
||||
import { BackupTransferRunner } from './durable/backup-transfer-runner';
|
||||
import { handleRequest } from './router';
|
||||
import { StorageService } from './services/storage';
|
||||
import { applyCors, jsonResponse } from './utils/response';
|
||||
@@ -127,3 +128,4 @@ export default {
|
||||
};
|
||||
|
||||
export { NotificationsHub };
|
||||
export { BackupTransferRunner };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ const ALLOWED_METADATA_KEYS = new Set([
|
||||
'skippedReason',
|
||||
'replaceExisting',
|
||||
'provider',
|
||||
'prfStatus',
|
||||
'fileName',
|
||||
'fileBytes',
|
||||
'bytes',
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
+46
-1
@@ -2,6 +2,7 @@
|
||||
export interface Env {
|
||||
DB: D1Database;
|
||||
NOTIFICATIONS_HUB: DurableObjectNamespace;
|
||||
BACKUP_TRANSFER_RUNNER: DurableObjectNamespace;
|
||||
ASSETS?: {
|
||||
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
||||
};
|
||||
@@ -10,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';
|
||||
@@ -233,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;
|
||||
@@ -371,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;
|
||||
@@ -378,6 +421,7 @@ export interface UserDecryptionOptions {
|
||||
MasterPasswordUnlock: MasterPasswordUnlock;
|
||||
TrustedDeviceOption: null;
|
||||
KeyConnectorOption: null;
|
||||
WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
|
||||
}
|
||||
|
||||
// API Response types
|
||||
@@ -497,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/nodewarden-logo-bg.svg" />
|
||||
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="NodeWarden" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<title>NodeWarden</title>
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "NodeWarden",
|
||||
"short_name": "NodeWarden",
|
||||
"description": "A lightweight Bitwarden-compatible vault for Cloudflare Workers.",
|
||||
"id": "/",
|
||||
"start_url": "/vault",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
|
||||
"orientation": "any",
|
||||
"background_color": "#eef4ff",
|
||||
"theme_color": "#0f172a",
|
||||
"categories": ["security", "productivity", "utilities"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Vault",
|
||||
"short_name": "Vault",
|
||||
"url": "/vault",
|
||||
"icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }]
|
||||
},
|
||||
{
|
||||
"name": "TOTP Codes",
|
||||
"short_name": "TOTP",
|
||||
"url": "/vault/totp",
|
||||
"icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
+118
-14
@@ -26,7 +26,7 @@ import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers,
|
||||
import { getDomainRules, saveDomainRules } from '@/lib/api/domains';
|
||||
import { getSends } from '@/lib/api/send';
|
||||
import { repairCipherKeyMismatches, repairCipherUriChecksums } from '@/lib/api/vault';
|
||||
import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
|
||||
import { getCachedVaultCoreSnapshot, invalidateVaultCoreSyncSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
|
||||
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
||||
import {
|
||||
parseSignalRTextFrames,
|
||||
@@ -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';
|
||||
@@ -54,6 +57,7 @@ import { useToastManager } from '@/hooks/useToastManager';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
||||
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
||||
import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
|
||||
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
|
||||
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
|
||||
import {
|
||||
@@ -169,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);
|
||||
@@ -200,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);
|
||||
@@ -433,6 +439,7 @@ export default function App() {
|
||||
(async () => {
|
||||
const boot = await bootstrapAppSession(initialBootstrap);
|
||||
if (!mounted) return;
|
||||
if (sessionRef.current?.symEncKey || sessionRef.current?.symMacKey) return;
|
||||
setDefaultKdfIterations(boot.defaultKdfIterations);
|
||||
setRegistrationInviteRequired(boot.registrationInviteRequired);
|
||||
setJwtWarning(boot.jwtWarning);
|
||||
@@ -478,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') {
|
||||
@@ -533,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;
|
||||
@@ -746,6 +827,7 @@ export default function App() {
|
||||
setConfirm(null);
|
||||
setSession(null);
|
||||
clearProfileSnapshot();
|
||||
clearOfflineUnlockRecord();
|
||||
setProfile(null);
|
||||
setUnlockPreparing(false);
|
||||
setPendingTotp(null);
|
||||
@@ -910,7 +992,7 @@ export default function App() {
|
||||
const vaultCoreQuery = useQuery({
|
||||
queryKey: ['vault-core', vaultCacheKey],
|
||||
queryFn: () => loadVaultCoreSyncSnapshot(authedFetch, vaultCacheKey),
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && !!vaultCacheKey,
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!vaultCacheKey,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const encryptedVaultCore = vaultCoreQuery.data || cachedVaultCore;
|
||||
@@ -921,7 +1003,7 @@ export default function App() {
|
||||
const sendsQuery = useQuery({
|
||||
queryKey: sendsQueryKey,
|
||||
queryFn: () => getSends(authedFetch),
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && location === '/sends' && !encryptedSendsFromSync,
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && location === '/sends' && !encryptedSendsFromSync,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const encryptedSends = sendsQuery.data || encryptedSendsFromSync;
|
||||
@@ -950,13 +1032,13 @@ export default function App() {
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ['admin-users', vaultCacheKey],
|
||||
queryFn: () => listAdminUsers(authedFetch),
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const invitesQuery = useQuery({
|
||||
queryKey: ['admin-invites', vaultCacheKey],
|
||||
queryFn: () => listAdminInvites(authedFetch),
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const totpStatusQuery = useQuery({
|
||||
@@ -1013,7 +1095,7 @@ export default function App() {
|
||||
useQuery({
|
||||
queryKey: ['admin-backup-settings', vaultCacheKey],
|
||||
queryFn: () => backupActions.loadSettings(),
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -1083,15 +1165,22 @@ export default function App() {
|
||||
setDecryptedFolders(result.folders);
|
||||
setDecryptedCiphers(result.ciphers);
|
||||
setVaultInitialDecryptDone(true);
|
||||
if (!session.accessToken) return;
|
||||
const repairKey = `${session.accessToken}:${encryptedCiphers.map((cipher) => `${cipher.id}:${cipher.revisionDate || ''}`).join(',')}`;
|
||||
if (uriChecksumRepairAttemptRef.current !== repairKey) {
|
||||
uriChecksumRepairAttemptRef.current = repairKey;
|
||||
void Promise.all([
|
||||
repairCipherKeyMismatches(authedFetch, session, result.ciphers),
|
||||
repairCipherUriChecksums(authedFetch, session, result.ciphers),
|
||||
])
|
||||
.then(([keyMismatchCount, uriChecksumCount]) => {
|
||||
if (keyMismatchCount + uriChecksumCount > 0) void refetchVaultCoreData();
|
||||
void repairCipherKeyMismatches(authedFetch, session, result.ciphers)
|
||||
.then(async (keyMismatchCount) => {
|
||||
if (keyMismatchCount > 0) {
|
||||
await invalidateVaultCoreSyncSnapshot(vaultCacheKey);
|
||||
void refetchVaultCoreData();
|
||||
return;
|
||||
}
|
||||
const uriChecksumCount = await repairCipherUriChecksums(authedFetch, session, result.ciphers);
|
||||
if (uriChecksumCount > 0) {
|
||||
await invalidateVaultCoreSyncSnapshot(vaultCacheKey);
|
||||
void refetchVaultCoreData();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort compatibility repair must not interrupt normal vault loading.
|
||||
@@ -1109,7 +1198,7 @@ export default function App() {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [session?.symEncKey, session?.symMacKey, encryptedFolders, encryptedCiphers]);
|
||||
}, [session?.symEncKey, session?.symMacKey, vaultCacheKey, encryptedFolders, encryptedCiphers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (IS_DEMO_MODE) return;
|
||||
@@ -1344,6 +1433,7 @@ export default function App() {
|
||||
const accountSecurityActions = useAccountSecurityActions({
|
||||
authedFetch,
|
||||
profile,
|
||||
session,
|
||||
defaultKdfIterations,
|
||||
disableTotpPassword,
|
||||
clearDisableTotpDialog: () => {
|
||||
@@ -1530,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,
|
||||
@@ -1640,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');
|
||||
}}
|
||||
@@ -1663,6 +1765,8 @@ export default function App() {
|
||||
if (inviteCodeFromUrl) {
|
||||
setRegisterValues((prev) => ({ ...prev, inviteCode: inviteCodeFromUrl }));
|
||||
}
|
||||
setPendingPasskeyPassword(null);
|
||||
setPasskeyPassword('');
|
||||
setPhase('register');
|
||||
navigate('/register');
|
||||
}}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ComponentChildren } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Link } from 'wouter';
|
||||
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
|
||||
import ThemeSwitch from '@/components/ThemeSwitch';
|
||||
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
|
||||
import { t } from '@/lib/i18n';
|
||||
@@ -237,6 +238,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<span className="mobile-page-title">{props.currentPageTitle}</span>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<NetworkStatusBadge />
|
||||
<div className="user-chip">
|
||||
<ShieldUser size={16} />
|
||||
<span>{props.profile?.email}</span>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -22,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;
|
||||
@@ -76,14 +83,16 @@ 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') {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<StandalonePageFrame title={t('txt_unlock_vault')}>
|
||||
<StandalonePageFrame title={t('txt_unlock_vault')} titleAccessory={<NetworkStatusBadge />}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -114,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>
|
||||
@@ -132,7 +150,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
if (props.mode === 'register') {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<StandalonePageFrame title={t('txt_create_account')}>
|
||||
<StandalonePageFrame title={t('txt_create_account')} titleAccessory={<NetworkStatusBadge />}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -216,13 +234,41 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<StandalonePageFrame title={t('txt_log_in')}>
|
||||
<StandalonePageFrame title={t('txt_log_in')} titleAccessory={<NetworkStatusBadge />}>
|
||||
<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
|
||||
@@ -255,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>
|
||||
|
||||
@@ -91,6 +91,7 @@ const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [
|
||||
'lastpass',
|
||||
'dashlane_csv',
|
||||
'dashlane_json',
|
||||
'keepass_csv',
|
||||
'keepass_xml',
|
||||
'keepassx_csv',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Wifi, WifiOff } from 'lucide-preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
browserReportsOffline,
|
||||
getCurrentNetworkStatus,
|
||||
probeNodeWardenService,
|
||||
setCurrentNetworkStatus,
|
||||
subscribeNetworkStatus,
|
||||
type NetworkStatus,
|
||||
} from '@/lib/network-status';
|
||||
|
||||
const STATUS_CHECK_INTERVAL_MS = 30_000;
|
||||
|
||||
function statusLabel(status: NetworkStatus): string {
|
||||
if (status === 'online') return t('txt_online');
|
||||
return t('txt_offline');
|
||||
}
|
||||
|
||||
export default function NetworkStatusBadge() {
|
||||
const [status, setStatus] = useState<NetworkStatus>(getCurrentNetworkStatus);
|
||||
const label = statusLabel(status);
|
||||
const Icon = status === 'online' ? Wifi : WifiOff;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer = 0;
|
||||
|
||||
const checkService = async () => {
|
||||
if (browserReportsOffline()) {
|
||||
setCurrentNetworkStatus('offline');
|
||||
return;
|
||||
}
|
||||
const reachable = await probeNodeWardenService();
|
||||
if (!cancelled) {
|
||||
setCurrentNetworkStatus(reachable ? 'online' : 'offline');
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleNextCheck = () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
void checkService().finally(scheduleNextCheck);
|
||||
}, STATUS_CHECK_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const handleOnline = () => {
|
||||
void checkService();
|
||||
};
|
||||
const handleOffline = () => {
|
||||
setCurrentNetworkStatus('offline');
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') void checkService();
|
||||
};
|
||||
|
||||
const unsubscribe = subscribeNetworkStatus(setStatus);
|
||||
void checkService().finally(scheduleNextCheck);
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
window.addEventListener('focus', handleOnline);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
window.removeEventListener('focus', handleOnline);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`network-status-badge ${status}`}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Icon size={14} aria-hidden="true" />
|
||||
<span className="network-status-label">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { APP_VERSION } from '@shared/app-version';
|
||||
interface StandalonePageFrameProps {
|
||||
title: string;
|
||||
eyebrow?: ComponentChildren;
|
||||
titleAccessory?: ComponentChildren;
|
||||
children: ComponentChildren;
|
||||
}
|
||||
|
||||
@@ -19,7 +20,10 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
|
||||
|
||||
<div className="auth-card">
|
||||
{props.eyebrow && <div className="standalone-eyebrow">{props.eyebrow}</div>}
|
||||
<h1 className="standalone-title">{props.title}</h1>
|
||||
<div className="standalone-title-row">
|
||||
<h1 className="standalone-title">{props.title}</h1>
|
||||
{props.titleAccessory}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -716,6 +716,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setAttachmentQueue([]);
|
||||
setRemovedAttachmentIds({});
|
||||
if (isMobileLayout) setMobilePanel('detail');
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -729,6 +731,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setPendingDelete(null);
|
||||
cancelEdit();
|
||||
if (isMobileLayout) setMobilePanel('list');
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -741,6 +745,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
if (isMobileLayout && selectedCipherId === cipher.id) {
|
||||
setMobilePanel('list');
|
||||
}
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -760,6 +766,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
}
|
||||
setSelectedMap({});
|
||||
setBulkDeleteOpen(false);
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -776,6 +784,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
await props.onBulkMove(ids, folderId);
|
||||
setSelectedMap({});
|
||||
setMoveOpen(false);
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -785,6 +795,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await props.onRefresh();
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -819,6 +831,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
await props.onCreateFolder(newFolderName);
|
||||
setCreateFolderOpen(false);
|
||||
setNewFolderName('');
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -833,6 +847,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setSidebarFilter({ kind: 'all' });
|
||||
}
|
||||
setPendingDeleteFolder(null);
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -850,6 +866,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
await props.onRenameFolder(pendingRenameFolder.id, nextName);
|
||||
setPendingRenameFolder(null);
|
||||
setRenameFolderName('');
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -864,6 +882,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
try {
|
||||
await props.onBulkRestore(ids);
|
||||
setSelectedMap({});
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -878,6 +898,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
if (isMobileLayout && selectedCipherId === pendingArchive.id) {
|
||||
setMobilePanel('list');
|
||||
}
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -892,6 +914,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
delete next[cipher.id];
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -907,6 +931,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
await props.onBulkArchive(ids);
|
||||
setSelectedMap({});
|
||||
setBulkArchiveOpen(false);
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -921,6 +947,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
try {
|
||||
await props.onBulkUnarchive(ids);
|
||||
setSelectedMap({});
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -935,6 +963,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
||||
setSidebarFilter({ kind: 'all' });
|
||||
}
|
||||
setDeleteAllFoldersOpen(false);
|
||||
} catch {
|
||||
// The action layer already shows the user-facing error toast.
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
subscribeWebsiteIconStatus,
|
||||
} from '@/lib/website-icon-cache';
|
||||
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
|
||||
import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status';
|
||||
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
||||
|
||||
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
||||
@@ -26,8 +27,11 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
|
||||
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
|
||||
const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : ''));
|
||||
const [networkStatus, setNetworkStatus] = useState(getCurrentNetworkStatus);
|
||||
const demoIconUrl = SHOULD_LOAD_DEMO_BRAND_ICONS && host ? demoBrandIconUrl(host) : '';
|
||||
|
||||
useEffect(() => subscribeNetworkStatus(setNetworkStatus), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!host) {
|
||||
setShouldLoad(true);
|
||||
@@ -77,9 +81,10 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||
useEffect(() => {
|
||||
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
|
||||
if (demoIconUrl) return;
|
||||
if (networkStatus !== 'online') return;
|
||||
if (!host || !src || !shouldLoad || status !== 'idle') return;
|
||||
beginWebsiteIconLoad(host, src);
|
||||
}, [demoIconUrl, host, src, shouldLoad, status]);
|
||||
}, [demoIconUrl, host, networkStatus, src, shouldLoad, status]);
|
||||
|
||||
if (demoIconUrl) {
|
||||
return (
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -224,6 +224,56 @@ function optimisticCipherFromDraft(draft: VaultDraft, current?: Cipher | null):
|
||||
return next;
|
||||
}
|
||||
|
||||
function isEncryptedFieldUnresolved(raw: unknown, decrypted: unknown): boolean {
|
||||
const encrypted = String(raw || '').trim();
|
||||
if (!looksLikeCipherString(encrypted)) return false;
|
||||
const plain = String(decrypted || '').trim();
|
||||
return !plain || looksLikeCipherString(plain);
|
||||
}
|
||||
|
||||
function hasUnresolvedCipherData(cipher: Cipher): boolean {
|
||||
const checks: Array<[unknown, unknown]> = [
|
||||
[cipher.name, cipher.decName],
|
||||
[cipher.notes, cipher.decNotes],
|
||||
[cipher.login?.username, cipher.login?.decUsername],
|
||||
[cipher.login?.password, cipher.login?.decPassword],
|
||||
[cipher.login?.totp, cipher.login?.decTotp],
|
||||
...(cipher.login?.uris || []).map((uri) => [uri.uri, uri.decUri] as [unknown, unknown]),
|
||||
[cipher.card?.cardholderName, cipher.card?.decCardholderName],
|
||||
[cipher.card?.number, cipher.card?.decNumber],
|
||||
[cipher.card?.brand, cipher.card?.decBrand],
|
||||
[cipher.card?.expMonth, cipher.card?.decExpMonth],
|
||||
[cipher.card?.expYear, cipher.card?.decExpYear],
|
||||
[cipher.card?.code, cipher.card?.decCode],
|
||||
[cipher.identity?.title, cipher.identity?.decTitle],
|
||||
[cipher.identity?.firstName, cipher.identity?.decFirstName],
|
||||
[cipher.identity?.middleName, cipher.identity?.decMiddleName],
|
||||
[cipher.identity?.lastName, cipher.identity?.decLastName],
|
||||
[cipher.identity?.username, cipher.identity?.decUsername],
|
||||
[cipher.identity?.company, cipher.identity?.decCompany],
|
||||
[cipher.identity?.ssn, cipher.identity?.decSsn],
|
||||
[cipher.identity?.passportNumber, cipher.identity?.decPassportNumber],
|
||||
[cipher.identity?.licenseNumber, cipher.identity?.decLicenseNumber],
|
||||
[cipher.identity?.email, cipher.identity?.decEmail],
|
||||
[cipher.identity?.phone, cipher.identity?.decPhone],
|
||||
[cipher.identity?.address1, cipher.identity?.decAddress1],
|
||||
[cipher.identity?.address2, cipher.identity?.decAddress2],
|
||||
[cipher.identity?.address3, cipher.identity?.decAddress3],
|
||||
[cipher.identity?.city, cipher.identity?.decCity],
|
||||
[cipher.identity?.state, cipher.identity?.decState],
|
||||
[cipher.identity?.postalCode, cipher.identity?.decPostalCode],
|
||||
[cipher.identity?.country, cipher.identity?.decCountry],
|
||||
[cipher.sshKey?.privateKey, cipher.sshKey?.decPrivateKey],
|
||||
[cipher.sshKey?.publicKey, cipher.sshKey?.decPublicKey],
|
||||
[cipher.sshKey?.keyFingerprint || cipher.sshKey?.fingerprint, cipher.sshKey?.decFingerprint],
|
||||
...(cipher.fields || []).flatMap((field) => [
|
||||
[field.name, field.decName] as [unknown, unknown],
|
||||
[field.value, field.decValue] as [unknown, unknown],
|
||||
]),
|
||||
];
|
||||
return checks.some(([raw, decrypted]) => isEncryptedFieldUnresolved(raw, decrypted));
|
||||
}
|
||||
|
||||
export default function useVaultSendActions(options: UseVaultSendActionsOptions) {
|
||||
const {
|
||||
authedFetch,
|
||||
@@ -252,6 +302,11 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
await Promise.all([refetchCiphers(), refetchFolders(), refetchSends()]);
|
||||
};
|
||||
|
||||
const requireOnlineWrite = () => {
|
||||
if (session?.accessToken) return;
|
||||
throw new Error(t('txt_offline_vault_readonly'));
|
||||
};
|
||||
|
||||
const syncVaultCoreInBackground = (options?: { includeFolders?: boolean }) => {
|
||||
const tasks: Promise<unknown>[] = [Promise.resolve(refetchCiphers())];
|
||||
if (options?.includeFolders) {
|
||||
@@ -397,6 +452,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
|
||||
async createVaultItem(draft: VaultDraft, attachments: File[] = []) {
|
||||
if (!session) return;
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
const optimistic = optimisticCipherFromDraft(draft, null);
|
||||
patchDecryptedCiphers((prev) => [optimistic, ...prev.filter((cipher) => cipher.id !== optimistic.id)]);
|
||||
try {
|
||||
@@ -421,6 +482,15 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
|
||||
async updateVaultItem(cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) {
|
||||
if (!session) return;
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
if (hasUnresolvedCipherData(cipher)) {
|
||||
throw new Error(t('txt_decrypt_failed_2'));
|
||||
}
|
||||
const addFiles = Array.isArray(options?.addFiles) ? options.addFiles : [];
|
||||
const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : [];
|
||||
const previousCipher: Cipher = {
|
||||
@@ -490,6 +560,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async deleteVaultItem(cipher: Cipher) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
const previousCipher = { ...cipher };
|
||||
if (cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt) {
|
||||
try {
|
||||
@@ -518,6 +594,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async archiveVaultItem(cipher: Cipher) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
const previousCipher = { ...cipher };
|
||||
const archivedDate = new Date().toISOString();
|
||||
patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate, deletedDate: null, revisionDate: archivedDate }));
|
||||
@@ -534,6 +616,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async unarchiveVaultItem(cipher: Cipher) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
const previousCipher = { ...cipher };
|
||||
const revisionDate = new Date().toISOString();
|
||||
patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate: null, revisionDate }));
|
||||
@@ -550,6 +638,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkDeleteVaultItems(ids: string[]) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkDeleteCiphers(authedFetch, ids);
|
||||
const deletedDate = new Date().toISOString();
|
||||
@@ -563,6 +657,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkArchiveVaultItems(ids: string[]) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkArchiveCiphers(authedFetch, ids);
|
||||
const archivedDate = new Date().toISOString();
|
||||
@@ -576,6 +676,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkUnarchiveVaultItems(ids: string[]) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkUnarchiveCiphers(authedFetch, ids);
|
||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate: null }));
|
||||
@@ -588,6 +694,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkMoveVaultItems(ids: string[], folderId: string | null) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkMoveCiphers(authedFetch, ids, folderId);
|
||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, folderId }));
|
||||
@@ -605,6 +717,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
onNotify('error', t('txt_folder_name_is_required'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||
const created = await createFolder(authedFetch, session, folderName);
|
||||
@@ -630,6 +748,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
onNotify('error', t('txt_folder_not_found'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await deleteFolder(authedFetch, id);
|
||||
patchFolderBatch([id], () => null);
|
||||
@@ -653,6 +777,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
onNotify('error', t('txt_folder_name_is_required'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||
await updateFolder(authedFetch, session, id, nextName);
|
||||
@@ -666,6 +796,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkRestoreVaultItems(ids: string[]) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkRestoreCiphers(authedFetch, ids);
|
||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate: null }));
|
||||
@@ -678,6 +814,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkPermanentDeleteVaultItems(ids: string[]) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkPermanentDeleteCiphers(authedFetch, ids);
|
||||
patchCipherBatch(ids, () => null);
|
||||
@@ -692,6 +834,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
async bulkDeleteFolders(folderIds: string[]) {
|
||||
const ids = Array.from(new Set(folderIds.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!ids.length) return;
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkDeleteFolders(authedFetch, ids);
|
||||
const removedIds = new Set(ids);
|
||||
@@ -712,6 +860,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
|
||||
async createSend(draft: SendDraft, autoCopyLink: boolean) {
|
||||
if (!session) return;
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const fileName = draft.type === 'file' ? String(draft.file?.name || '').trim() : '';
|
||||
if (fileName) {
|
||||
@@ -737,6 +891,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
|
||||
async updateSend(send: Send, draft: SendDraft, autoCopyLink: boolean) {
|
||||
if (!session) return;
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const updated = await updateSend(authedFetch, session, send, draft);
|
||||
await refetchSends();
|
||||
@@ -753,6 +913,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async deleteSend(send: Send) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await deleteSend(authedFetch, send.id);
|
||||
await refetchSends();
|
||||
@@ -764,6 +930,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
},
|
||||
|
||||
async bulkDeleteSends(ids: string[]) {
|
||||
try {
|
||||
requireOnlineWrite();
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await bulkDeleteSends(authedFetch, ids);
|
||||
await refetchSends();
|
||||
@@ -780,6 +952,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
||||
attachments: ImportAttachmentFile[] = []
|
||||
): Promise<ImportResultSummary> {
|
||||
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
||||
requireOnlineWrite();
|
||||
|
||||
const mode = options.folderMode || 'original';
|
||||
const targetFolderId = (options.targetFolderId || '').trim() || null;
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
+177
-1
@@ -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';
|
||||
@@ -95,6 +97,12 @@ export function loadSession(): SessionState | null {
|
||||
authMode: 'web-cookie',
|
||||
};
|
||||
}
|
||||
if (parsed.authMode === 'token' && parsed.email && !parsed.accessToken && !parsed.refreshToken) {
|
||||
return {
|
||||
email: parsed.email,
|
||||
authMode: 'token',
|
||||
};
|
||||
}
|
||||
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null;
|
||||
return {
|
||||
accessToken: parsed.accessToken,
|
||||
@@ -233,6 +241,7 @@ export async function loginWithPassword(
|
||||
totpCode?: string;
|
||||
rememberDevice?: boolean;
|
||||
useRememberToken?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<TokenSuccess | TokenError> {
|
||||
const body = new URLSearchParams();
|
||||
@@ -262,6 +271,7 @@ export async function loginWithPassword(
|
||||
[WEB_SESSION_HEADER]: '1',
|
||||
},
|
||||
body: body.toString(),
|
||||
signal: options?.signal,
|
||||
});
|
||||
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
||||
if (resp.ok) {
|
||||
@@ -273,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;
|
||||
}
|
||||
@@ -450,9 +494,10 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
||||
};
|
||||
|
||||
const session = getSession();
|
||||
if (!session?.accessToken) throw new Error('Unauthorized');
|
||||
if (!session?.accessToken) throw new Error(t('txt_offline_vault_readonly'));
|
||||
const headers = new Headers(init.headers || {});
|
||||
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
||||
headers.set('X-NodeWarden-Web', '1');
|
||||
|
||||
let resp = await retryableRequest(headers);
|
||||
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
|
||||
@@ -461,6 +506,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
||||
if (latest?.accessToken && latest.accessToken !== session.accessToken) {
|
||||
const latestHeaders = new Headers(init.headers || {});
|
||||
latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`);
|
||||
latestHeaders.set('X-NodeWarden-Web', '1');
|
||||
resp = await retryableRequest(latestHeaders);
|
||||
if (resp.status !== 401) return resp;
|
||||
}
|
||||
@@ -486,6 +532,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
||||
|
||||
const retryHeaders = new Headers(init.headers || {});
|
||||
retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`);
|
||||
retryHeaders.set('X-NodeWarden-Web', '1');
|
||||
resp = await retryableRequest(retryHeaders);
|
||||
return resp;
|
||||
};
|
||||
@@ -594,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) {
|
||||
|
||||
@@ -96,6 +96,7 @@ export interface AdminBackupImportCounts {
|
||||
users: number;
|
||||
domainSettings?: number;
|
||||
userRevisions: number;
|
||||
webauthnCredentials?: number;
|
||||
folders: number;
|
||||
ciphers: number;
|
||||
attachments: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Cipher, Folder, Send } from '../types';
|
||||
import { getVaultRevisionDate } from './auth';
|
||||
import { loadCachedVaultCoreSnapshot, saveCachedVaultCoreSnapshot, type VaultCoreSnapshot } from '../vault-cache';
|
||||
import { clearCachedVaultCoreSnapshot, loadCachedVaultCoreSnapshot, saveCachedVaultCoreSnapshot, type VaultCoreSnapshot } from '../vault-cache';
|
||||
import { parseJson, type AuthedFetch } from './shared';
|
||||
|
||||
interface VaultSyncResponse {
|
||||
@@ -43,6 +43,14 @@ export async function getCachedVaultCoreSnapshot(cacheKey: string): Promise<Vaul
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function invalidateVaultCoreSyncSnapshot(cacheKey: string): Promise<void> {
|
||||
const normalizedKey = String(cacheKey || '').trim();
|
||||
if (!normalizedKey) return;
|
||||
pendingVaultCoreRequests.delete(normalizedKey);
|
||||
memoryVaultCoreCache.delete(normalizedKey);
|
||||
await clearCachedVaultCoreSnapshot(normalizedKey);
|
||||
}
|
||||
|
||||
export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise<VaultCoreSnapshot> {
|
||||
const normalizedKey = String(cacheKey || '').trim();
|
||||
if (!normalizedKey) return { ciphers: [], folders: [], sends: [] };
|
||||
|
||||
+42
-21
@@ -819,13 +819,15 @@ async function repairCipherLoginUris(
|
||||
continue;
|
||||
}
|
||||
|
||||
let clearUri = String(entry.decUri || '').trim();
|
||||
if (!clearUri || looksLikeCipherString(clearUri)) {
|
||||
try {
|
||||
clearUri = (await decryptStr(rawUri, enc, mac)).trim();
|
||||
} catch {
|
||||
uris.push({ ...encryptedEntry });
|
||||
continue;
|
||||
let clearUri = '';
|
||||
let rawUriUsesCurrentKey = false;
|
||||
try {
|
||||
clearUri = (await decryptStr(rawUri, enc, mac)).trim();
|
||||
rawUriUsesCurrentKey = !!clearUri;
|
||||
} catch {
|
||||
const fallbackUri = String(entry.decUri || '').trim();
|
||||
if (fallbackUri && !looksLikeCipherString(fallbackUri)) {
|
||||
clearUri = fallbackUri;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -845,15 +847,20 @@ async function repairCipherLoginUris(
|
||||
}
|
||||
}
|
||||
|
||||
if (currentChecksumOk) {
|
||||
if (currentChecksumOk && rawUriUsesCurrentKey) {
|
||||
uris.push({ ...encryptedEntry });
|
||||
continue;
|
||||
}
|
||||
|
||||
const repairedUri = rawUriUsesCurrentKey ? rawUri : await encryptTextValue(clearUri, enc, mac);
|
||||
const repairedChecksum = currentChecksumOk
|
||||
? rawChecksum
|
||||
: await encryptTextValue(expectedChecksum, enc, mac);
|
||||
|
||||
uris.push({
|
||||
...encryptedEntry,
|
||||
uri: rawUri,
|
||||
uriChecksum: await encryptTextValue(expectedChecksum, enc, mac),
|
||||
uri: repairedUri || rawUri,
|
||||
uriChecksum: repairedChecksum,
|
||||
match: typeof entry.match === 'number' && Number.isFinite(entry.match) ? entry.match : null,
|
||||
});
|
||||
changed = true;
|
||||
@@ -889,15 +896,22 @@ export async function repairCipherUriChecksums(
|
||||
let repaired = 0;
|
||||
|
||||
for (const cipher of ciphers) {
|
||||
if (!cipher?.id || cipher.type !== 1 || !looksLikeCipherString(cipher.key) || !cipher.login || !Array.isArray(cipher.login.uris)) continue;
|
||||
let itemKey: Uint8Array;
|
||||
try {
|
||||
itemKey = await decryptBw(String(cipher.key).trim(), userEnc, userMac);
|
||||
} catch {
|
||||
continue;
|
||||
if (!cipher?.id || cipher.type !== 1 || !cipher.login || !Array.isArray(cipher.login.uris)) continue;
|
||||
let keys: { enc: Uint8Array; mac: Uint8Array; key: string | null } = {
|
||||
enc: userEnc,
|
||||
mac: userMac,
|
||||
key: null,
|
||||
};
|
||||
if (looksLikeCipherString(cipher.key)) {
|
||||
let itemKey: Uint8Array;
|
||||
try {
|
||||
itemKey = await decryptBw(String(cipher.key).trim(), userEnc, userMac);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (itemKey.length < 64) continue;
|
||||
keys = { enc: itemKey.slice(0, 32), mac: itemKey.slice(32, 64), key: String(cipher.key).trim() };
|
||||
}
|
||||
if (itemKey.length < 64) continue;
|
||||
const keys = { enc: itemKey.slice(0, 32), mac: itemKey.slice(32, 64), key: String(cipher.key).trim() };
|
||||
const repair = await repairCipherLoginUris(cipher, keys.enc, keys.mac);
|
||||
if (!repair.changed) continue;
|
||||
|
||||
@@ -912,9 +926,10 @@ export async function repairCipherUriChecksums(
|
||||
fields: Array.isArray(cipher.fields)
|
||||
? cipher.fields.map(({ decName: _decName, decValue: _decValue, ...field }) => field)
|
||||
: null,
|
||||
key: keys.key,
|
||||
lastKnownRevisionDate: cipher.revisionDate ?? null,
|
||||
preserveRevisionDate: true,
|
||||
};
|
||||
if (keys.key) payload.key = keys.key;
|
||||
|
||||
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
|
||||
method: 'PUT',
|
||||
@@ -1077,7 +1092,9 @@ export async function repairCipherKeyMismatches(
|
||||
if (!cipher?.id || !looksLikeCipherString(cipher.key)) continue;
|
||||
if (!(await hasItemKeyFieldMismatch(cipher, userEnc, userMac))) continue;
|
||||
if (hasUnresolvedEncryptedFields(cipher)) continue;
|
||||
await updateCipher(authedFetch, session, cipher, draftFromDecryptedCipher(cipher));
|
||||
await updateCipher(authedFetch, session, cipher, draftFromDecryptedCipher(cipher), {
|
||||
preserveRevisionDate: true,
|
||||
});
|
||||
repaired += 1;
|
||||
}
|
||||
|
||||
@@ -1211,9 +1228,13 @@ export async function updateCipher(
|
||||
authedFetch: AuthedFetch,
|
||||
session: SessionState,
|
||||
cipher: Cipher,
|
||||
draft: VaultDraft
|
||||
draft: VaultDraft,
|
||||
extraPayload?: Record<string, unknown>
|
||||
): Promise<Cipher> {
|
||||
const payload = await buildCipherPayload(session, draft, cipher);
|
||||
if (extraPayload) {
|
||||
Object.assign(payload, extraPayload);
|
||||
}
|
||||
|
||||
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
|
||||
method: 'PUT',
|
||||
|
||||
+205
-8
@@ -1,23 +1,45 @@
|
||||
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 type { AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
||||
import {
|
||||
getOfflineUnlockKdfIterations,
|
||||
hasOfflineUnlockRecord,
|
||||
kdfIterationsFromLogin,
|
||||
loadOfflineProfileSnapshot,
|
||||
saveOfflineUnlockRecord,
|
||||
unlockOfflineVaultWithMasterKey,
|
||||
} from '@/lib/offline-auth';
|
||||
import { probeNodeWardenService } from '@/lib/network-status';
|
||||
import type { AccountPasskeyPrfOption, AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
||||
|
||||
export interface PendingTotp {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
masterKey: Uint8Array;
|
||||
kdfIterations: number;
|
||||
}
|
||||
|
||||
export interface PendingPasskeyPassword {
|
||||
token: TokenSuccess;
|
||||
email: string;
|
||||
kdfIterations: number;
|
||||
}
|
||||
|
||||
export type JwtUnsafeReason = 'missing' | 'default' | 'too_short';
|
||||
@@ -51,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;
|
||||
@@ -82,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;
|
||||
}
|
||||
|
||||
@@ -93,6 +121,10 @@ async function maybeRefreshSession(session: SessionState): Promise<SessionState
|
||||
};
|
||||
}
|
||||
|
||||
function browserReportsOffline(): boolean {
|
||||
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||
}
|
||||
|
||||
function readWindowBootstrap(): WebBootstrapResponse {
|
||||
if (typeof window === 'undefined') return {};
|
||||
const raw = (window as Window & { __NW_BOOT__?: WebBootstrapResponse }).__NW_BOOT__;
|
||||
@@ -246,8 +278,26 @@ export async function hydrateLockedSession(
|
||||
session: SessionState,
|
||||
fallbackProfile: Profile | null = null
|
||||
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
||||
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
|
||||
let serviceReachable = true;
|
||||
if (hasOfflineUnlock) {
|
||||
serviceReachable = await probeNodeWardenService();
|
||||
if (!serviceReachable) {
|
||||
return {
|
||||
session,
|
||||
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const refreshedSession = await maybeRefreshSession(session);
|
||||
if (!refreshedSession?.accessToken) {
|
||||
if (hasOfflineUnlock && !serviceReachable) {
|
||||
return {
|
||||
session,
|
||||
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||
};
|
||||
}
|
||||
return { session: null, profile: null };
|
||||
}
|
||||
try {
|
||||
@@ -272,7 +322,8 @@ export async function hydrateLockedSession(
|
||||
export async function completeLogin(
|
||||
token: TokenSuccess,
|
||||
email: string,
|
||||
masterKey: Uint8Array
|
||||
masterKey: Uint8Array,
|
||||
fallbackKdfIterations: number
|
||||
): Promise<CompletedLogin> {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
|
||||
@@ -291,6 +342,49 @@ export async function completeLogin(
|
||||
throw new Error('Missing profile key');
|
||||
}
|
||||
const keys = await unlockVaultKey(profile.key, masterKey);
|
||||
saveOfflineUnlockRecord({
|
||||
email: normalizedEmail,
|
||||
profile,
|
||||
profileKey: profile.key,
|
||||
kdfIterations: kdfIterationsFromLogin(token, fallbackKdfIterations),
|
||||
});
|
||||
return {
|
||||
session: { ...baseSession, ...keys },
|
||||
profile,
|
||||
profilePromise: getProfile(tempFetch),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -310,7 +404,7 @@ export async function performPasswordLogin(
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return {
|
||||
kind: 'success',
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey),
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -322,6 +416,7 @@ export async function performPasswordLogin(
|
||||
email: normalizedEmail,
|
||||
passwordHash: derived.hash,
|
||||
masterKey: derived.masterKey,
|
||||
kdfIterations: derived.kdfIterations,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -332,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,
|
||||
@@ -342,7 +493,7 @@ export async function performTotpLogin(
|
||||
rememberDevice,
|
||||
});
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey);
|
||||
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations);
|
||||
}
|
||||
const tokenError = token as { error_description?: string; error?: string };
|
||||
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
|
||||
@@ -361,7 +512,7 @@ export async function performRecoverTwoFactorLogin(
|
||||
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return {
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey),
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
||||
newRecoveryCode: recovered.newRecoveryCode || null,
|
||||
};
|
||||
}
|
||||
@@ -397,13 +548,58 @@ export async function performUnlock(
|
||||
fallbackIterations: number
|
||||
): Promise<PasswordLoginResult> {
|
||||
const normalizedEmail = (profile?.email || session.email).trim().toLowerCase();
|
||||
const derived = await deriveLoginHashLocally(normalizedEmail, password, fallbackIterations);
|
||||
const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: true });
|
||||
const offlineIterations = getOfflineUnlockKdfIterations(normalizedEmail);
|
||||
const hasOfflineUnlock = !!offlineIterations;
|
||||
const kdfIterations = offlineIterations || fallbackIterations;
|
||||
const derived = await deriveLoginHashLocally(normalizedEmail, password, kdfIterations);
|
||||
const unlockOffline = async (): Promise<PasswordLoginResult> => {
|
||||
try {
|
||||
const offline = await unlockOfflineVaultWithMasterKey(session, profile, derived.masterKey);
|
||||
return {
|
||||
kind: 'success',
|
||||
login: {
|
||||
session: offline.session,
|
||||
profile: offline.profile,
|
||||
profilePromise: Promise.resolve(offline.profile),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
kind: 'error',
|
||||
message: t('txt_unlock_failed_master_password_is_incorrect'),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (hasOfflineUnlock) {
|
||||
if (browserReportsOffline()) {
|
||||
return unlockOffline();
|
||||
}
|
||||
const serviceReachable = await probeNodeWardenService();
|
||||
if (!serviceReachable) {
|
||||
return unlockOffline();
|
||||
}
|
||||
}
|
||||
|
||||
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
try {
|
||||
token = await loginWithPassword(normalizedEmail, derived.hash, {
|
||||
useRememberToken: true,
|
||||
});
|
||||
} catch {
|
||||
if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) {
|
||||
return unlockOffline();
|
||||
}
|
||||
return {
|
||||
kind: 'error',
|
||||
message: t('txt_unlock_failed_master_password_is_incorrect'),
|
||||
};
|
||||
}
|
||||
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return {
|
||||
kind: 'success',
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey),
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -415,6 +611,7 @@ export async function performUnlock(
|
||||
email: normalizedEmail,
|
||||
passwordHash: derived.hash,
|
||||
masterKey: derived.masterKey,
|
||||
kdfIterations: derived.kdfIterations,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { decryptStr, decryptBw } from './crypto';
|
||||
import { looksLikeCipherString } from './app-support';
|
||||
import type { Cipher } from './types';
|
||||
|
||||
async function decryptCipherField(
|
||||
@@ -22,7 +23,7 @@ async function decryptCipherField(
|
||||
// Preserve the old raw fallback for fields that are genuinely unreadable.
|
||||
}
|
||||
}
|
||||
return value;
|
||||
return looksLikeCipherString(value) ? '' : value;
|
||||
}
|
||||
|
||||
export async function decryptSingleCipher(
|
||||
|
||||
@@ -189,12 +189,15 @@ function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
|
||||
const login = cipher.login;
|
||||
out.login = login
|
||||
? {
|
||||
...cloneValue(login),
|
||||
username: login.username ?? null,
|
||||
password: login.password ?? null,
|
||||
totp: login.totp ?? null,
|
||||
uris: Array.isArray(login.uris)
|
||||
? login.uris.map((uri) => ({
|
||||
...cloneValue(uri),
|
||||
uri: uri?.uri ?? null,
|
||||
uriChecksum: uri?.uriChecksum ?? null,
|
||||
match: (uri as { match?: unknown })?.match ?? null,
|
||||
}))
|
||||
: [],
|
||||
|
||||
@@ -62,6 +62,15 @@ const localeLoaders: Record<Locale, () => Promise<{ default: MessageTable }>> =
|
||||
es: () => import('./i18n/locales/es'),
|
||||
};
|
||||
|
||||
function localeToHtmlLang(value: Locale): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function syncDocumentLanguage(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.documentElement.lang = localeToHtmlLang(locale);
|
||||
}
|
||||
|
||||
async function loadLocaleMessages(next: Locale): Promise<MessageTable> {
|
||||
const cached = loadedMessages.get(next);
|
||||
if (cached) return cached;
|
||||
@@ -84,6 +93,8 @@ export async function initI18n(): Promise<void> {
|
||||
console.error('Failed to load locale, falling back to English:', error);
|
||||
locale = 'en';
|
||||
activeMessages = await loadFallbackMessages();
|
||||
} finally {
|
||||
syncDocumentLanguage();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +154,7 @@ export async function setLocale(next: Locale): Promise<void> {
|
||||
}
|
||||
locale = next;
|
||||
activeMessages = nextMessages;
|
||||
syncDocumentLanguage();
|
||||
try {
|
||||
localStorage.setItem(LOCALE_STORAGE_KEY, next);
|
||||
} catch {
|
||||
|
||||
@@ -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",
|
||||
@@ -735,6 +778,7 @@ const en: Record<string, string> = {
|
||||
"txt_status": "Status",
|
||||
"txt_online": "Online",
|
||||
"txt_offline": "Offline",
|
||||
"txt_offline_vault_readonly": "Offline mode is read-only. Connect to NodeWarden before changing your vault.",
|
||||
"txt_submit": "Submit",
|
||||
"txt_sync": "Sync",
|
||||
"txt_sync_vault": "Sync Vault",
|
||||
|
||||
@@ -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",
|
||||
@@ -735,6 +778,7 @@ const es: Record<string, string> = {
|
||||
"txt_status": "Estado",
|
||||
"txt_online": "En línea",
|
||||
"txt_offline": "Sin conexión",
|
||||
"txt_offline_vault_readonly": "El modo sin conexión es de solo lectura. Conecta con NodeWarden antes de cambiar la bóveda.",
|
||||
"txt_submit": "Enviar",
|
||||
"txt_sync": "Sincronizar",
|
||||
"txt_sync_vault": "Sincronizar bóveda",
|
||||
|
||||
@@ -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": "Пожалуйста, введите мастер-пароль",
|
||||
@@ -735,6 +778,7 @@ const ru: Record<string, string> = {
|
||||
"txt_status": "Статус",
|
||||
"txt_online": "Онлайн",
|
||||
"txt_offline": "Офлайн",
|
||||
"txt_offline_vault_readonly": "Автономный режим доступен только для чтения. Подключитесь к NodeWarden, чтобы изменить хранилище.",
|
||||
"txt_submit": "Отправить",
|
||||
"txt_sync": "Синхронизировать",
|
||||
"txt_sync_vault": "Синхронизировать хранилище",
|
||||
|
||||
@@ -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": "请输入主密码",
|
||||
@@ -735,6 +778,7 @@ const zhCN: Record<string, string> = {
|
||||
"txt_status": "状态",
|
||||
"txt_online": "在线",
|
||||
"txt_offline": "离线",
|
||||
"txt_offline_vault_readonly": "当前为离线模式,只能查看密码库。连接到 NodeWarden 后才能修改。",
|
||||
"txt_submit": "提交",
|
||||
"txt_sync": "同步",
|
||||
"txt_sync_vault": "同步",
|
||||
|
||||
@@ -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": "請輸入主密碼",
|
||||
@@ -735,6 +778,7 @@ const zhTW: Record<string, string> = {
|
||||
"txt_status": "狀態",
|
||||
"txt_online": "在線",
|
||||
"txt_offline": "離線",
|
||||
"txt_offline_vault_readonly": "目前為離線模式,只能查看密碼庫。連線到 NodeWarden 後才能修改。",
|
||||
"txt_submit": "提交",
|
||||
"txt_sync": "同步",
|
||||
"txt_sync_vault": "同步",
|
||||
|
||||
@@ -23,6 +23,7 @@ export const IMPORT_SOURCES = [
|
||||
{ id: 'lastpass', label: 'LastPass (csv)' },
|
||||
{ id: 'dashlane_csv', label: 'Dashlane (csv)' },
|
||||
{ id: 'dashlane_json', label: 'Dashlane (json)' },
|
||||
{ id: 'keepass_csv', label: 'KeePass 1.x (csv)' },
|
||||
{ id: 'keepass_xml', label: 'KeePass 2 (xml)' },
|
||||
{ id: 'keepassx_csv', label: 'KeePassX (csv)' },
|
||||
{ id: 'arc_csv', label: 'Arc (csv)' },
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface BitwardenFolderInput {
|
||||
|
||||
export interface BitwardenUriInput {
|
||||
uri?: string | null;
|
||||
uriChecksum?: string | null;
|
||||
match?: number | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -198,6 +198,7 @@ export function parseEncryptrCsv(textRaw: string): CiphersImportPayload {
|
||||
export function parseKeePassXCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
const standardColumns = new Set(['Group', 'Title', 'Username', 'Password', 'URL', 'Notes', 'TOTP']);
|
||||
for (const row of rows) {
|
||||
if (!txt(row.Title)) continue;
|
||||
const cipher = makeLoginCipher();
|
||||
@@ -209,12 +210,34 @@ export function parseKeePassXCsv(textRaw: string): CiphersImportPayload {
|
||||
login.totp = val(row.TOTP);
|
||||
const uri = normalizeUri(row.URL || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
if (standardColumns.has(key)) continue;
|
||||
processKvp(cipher, key, value, false);
|
||||
}
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
addFolder(result, txt(row.Group).replace(/^Root\//, ''), idx);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseKeePassCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
for (const row of rows) {
|
||||
if (!txt(row.Account)) continue;
|
||||
const cipher = makeLoginCipher();
|
||||
cipher.name = val(row.Account, '--');
|
||||
cipher.notes = val(row.Comments);
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
login.username = val(row['Login Name']);
|
||||
login.password = val(row.Password);
|
||||
const uri = normalizeUri(row['Web Site'] || '');
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
result.ciphers.push(cipher);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseLastPassCsv(textRaw: string): CiphersImportPayload {
|
||||
const rows = parseCsv(textRaw);
|
||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||
@@ -350,7 +373,8 @@ export function parseKeePassXml(textRaw: string): CiphersImportPayload {
|
||||
const cipher = makeLoginCipher();
|
||||
for (const s of qd(entry, 'String')) {
|
||||
const key = txt(qd(s, 'Key')[0]?.textContent);
|
||||
const value = txt(qd(s, 'Value')[0]?.textContent);
|
||||
const valueNode = qd(s, 'Value')[0];
|
||||
const value = txt(valueNode?.textContent);
|
||||
if (!value) continue;
|
||||
const login = cipher.login as Record<string, unknown>;
|
||||
if (key === 'Title') cipher.name = value;
|
||||
@@ -361,6 +385,11 @@ export function parseKeePassXml(textRaw: string): CiphersImportPayload {
|
||||
login.uris = uri ? [{ uri, match: null }] : null;
|
||||
} else if (key === 'otp') login.totp = value.replace('key=', '');
|
||||
else if (key === 'Notes') cipher.notes = `${txt(cipher.notes)}${txt(cipher.notes) ? '\n' : ''}${value}`;
|
||||
else {
|
||||
const hidden = ['True', 'true', '1'].includes(valueNode?.getAttribute('ProtectInMemory') || '')
|
||||
|| ['True', 'true', '1'].includes(valueNode?.getAttribute('Protected') || '');
|
||||
processKvp(cipher, key, value, hidden);
|
||||
}
|
||||
}
|
||||
const idx = result.ciphers.push(cipher) - 1;
|
||||
if (!isRoot && folder >= 0) result.folderRelationships.push({ key: idx, value: folder });
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
parseDashlaneCsv,
|
||||
parseDashlaneJson,
|
||||
parseEncryptrCsv,
|
||||
parseKeePassCsv,
|
||||
parseKeePassXCsv,
|
||||
parseKeePassXml,
|
||||
parseLastPassCsv,
|
||||
@@ -75,6 +76,7 @@ const IMPORT_SOURCE_PARSERS: Record<ImportSourceId, (textRaw: string) => Ciphers
|
||||
lastpass: parseLastPassCsv,
|
||||
dashlane_csv: parseDashlaneCsv,
|
||||
dashlane_json: parseDashlaneJson,
|
||||
keepass_csv: parseKeePassCsv,
|
||||
keepass_xml: parseKeePassXml,
|
||||
keepassx_csv: parseKeePassXCsv,
|
||||
arc_csv: parseArcCsv,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
export type NetworkStatus = 'online' | 'offline';
|
||||
|
||||
const STATUS_PROBE_TIMEOUT_MS = 3500;
|
||||
const STATUS_PROBE_CACHE_MS = 5000;
|
||||
const listeners = new Set<(status: NetworkStatus) => void>();
|
||||
let currentStatus: NetworkStatus = getInitialNetworkStatus();
|
||||
let pendingProbe: Promise<boolean> | null = null;
|
||||
let lastProbeAt = 0;
|
||||
let lastProbeResult = currentStatus === 'online';
|
||||
|
||||
export function browserReportsOffline(): boolean {
|
||||
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||
}
|
||||
|
||||
export function getInitialNetworkStatus(): NetworkStatus {
|
||||
return browserReportsOffline() ? 'offline' : 'online';
|
||||
}
|
||||
|
||||
export function getCurrentNetworkStatus(): NetworkStatus {
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
export function setCurrentNetworkStatus(status: NetworkStatus): void {
|
||||
if (currentStatus === status) return;
|
||||
currentStatus = status;
|
||||
for (const listener of Array.from(listeners)) {
|
||||
listener(status);
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeNetworkStatus(listener: (status: NetworkStatus) => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export async function probeNodeWardenService(): Promise<boolean> {
|
||||
if (browserReportsOffline()) {
|
||||
setCurrentNetworkStatus('offline');
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (pendingProbe) return pendingProbe;
|
||||
if (now - lastProbeAt < STATUS_PROBE_CACHE_MS) return lastProbeResult;
|
||||
|
||||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||
const timer = controller
|
||||
? window.setTimeout(() => controller.abort(), STATUS_PROBE_TIMEOUT_MS)
|
||||
: 0;
|
||||
|
||||
pendingProbe = (async () => {
|
||||
await fetch(`/api/web-bootstrap?statusProbe=${Date.now()}`, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
Pragma: 'no-cache',
|
||||
},
|
||||
signal: controller?.signal,
|
||||
});
|
||||
// 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) => {
|
||||
lastProbeAt = Date.now();
|
||||
lastProbeResult = result;
|
||||
setCurrentNetworkStatus(result ? 'online' : 'offline');
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
if (timer) window.clearTimeout(timer);
|
||||
pendingProbe = null;
|
||||
});
|
||||
|
||||
return pendingProbe;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { deriveLoginHashLocally, unlockVaultKey } from '@/lib/api/auth';
|
||||
import type { Profile, SessionState, TokenSuccess } from '@/lib/types';
|
||||
|
||||
const OFFLINE_UNLOCK_KEY = 'nodewarden.web.offline-unlock.v1';
|
||||
|
||||
interface OfflineUnlockRecord {
|
||||
version: 1;
|
||||
email: string;
|
||||
profile: Profile;
|
||||
profileKey: string;
|
||||
kdfIterations: number;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
function normalizeEmail(email: string | null | undefined): string {
|
||||
return String(email || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function stripOfflineProfile(profile: Profile): Profile {
|
||||
return {
|
||||
...profile,
|
||||
email: normalizeEmail(profile.email),
|
||||
key: '',
|
||||
privateKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRecord(raw: string | null): OfflineUnlockRecord | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<OfflineUnlockRecord>;
|
||||
const email = normalizeEmail(parsed.email);
|
||||
const profileKey = String(parsed.profileKey || '').trim();
|
||||
const iterations = Number(parsed.kdfIterations || 0);
|
||||
if (parsed.version !== 1 || !email || !profileKey || !Number.isFinite(iterations) || iterations <= 0) {
|
||||
return null;
|
||||
}
|
||||
const profile = parsed.profile && typeof parsed.profile === 'object'
|
||||
? stripOfflineProfile(parsed.profile as Profile)
|
||||
: {
|
||||
id: '',
|
||||
email,
|
||||
name: email,
|
||||
key: '',
|
||||
privateKey: null,
|
||||
role: 'user' as const,
|
||||
};
|
||||
return {
|
||||
version: 1,
|
||||
email,
|
||||
profile,
|
||||
profileKey,
|
||||
kdfIterations: iterations,
|
||||
savedAt: Number(parsed.savedAt || 0) || 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readRecord(): OfflineUnlockRecord | null {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
return parseRecord(localStorage.getItem(OFFLINE_UNLOCK_KEY));
|
||||
}
|
||||
|
||||
export function hasOfflineUnlockRecord(email?: string | null): boolean {
|
||||
const record = readRecord();
|
||||
if (!record) return false;
|
||||
const normalized = normalizeEmail(email);
|
||||
return !normalized || record.email === normalized;
|
||||
}
|
||||
|
||||
export function getOfflineUnlockKdfIterations(email?: string | null): number | null {
|
||||
const record = readRecord();
|
||||
if (!record) return null;
|
||||
const normalized = normalizeEmail(email);
|
||||
if (normalized && record.email !== normalized) return null;
|
||||
return record.kdfIterations;
|
||||
}
|
||||
|
||||
export function loadOfflineProfileSnapshot(email?: string | null): Profile | null {
|
||||
const record = readRecord();
|
||||
if (!record) return null;
|
||||
const normalized = normalizeEmail(email);
|
||||
if (normalized && record.email !== normalized) return null;
|
||||
return stripOfflineProfile(record.profile);
|
||||
}
|
||||
|
||||
export function saveOfflineUnlockRecord(args: {
|
||||
email: string;
|
||||
profile: Profile;
|
||||
profileKey: string;
|
||||
kdfIterations: number;
|
||||
}): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
const email = normalizeEmail(args.email || args.profile.email);
|
||||
const profileKey = String(args.profileKey || '').trim();
|
||||
const kdfIterations = Number(args.kdfIterations || 0);
|
||||
if (!email || !profileKey || !Number.isFinite(kdfIterations) || kdfIterations <= 0) return;
|
||||
const record: OfflineUnlockRecord = {
|
||||
version: 1,
|
||||
email,
|
||||
profile: stripOfflineProfile({ ...args.profile, email }),
|
||||
profileKey,
|
||||
kdfIterations,
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
localStorage.setItem(OFFLINE_UNLOCK_KEY, JSON.stringify(record));
|
||||
}
|
||||
|
||||
export function clearOfflineUnlockRecord(): void {
|
||||
try {
|
||||
localStorage.removeItem(OFFLINE_UNLOCK_KEY);
|
||||
} catch {
|
||||
// Ignore storage failures during logout cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
export async function unlockOfflineVault(
|
||||
session: SessionState,
|
||||
profile: Profile | null,
|
||||
password: string
|
||||
): Promise<{ session: SessionState; profile: Profile }> {
|
||||
const record = readRecord();
|
||||
const email = normalizeEmail(profile?.email || session.email);
|
||||
if (!record || record.email !== email) {
|
||||
throw new Error('Offline unlock is not available on this device.');
|
||||
}
|
||||
const derived = await deriveLoginHashLocally(record.email, password, record.kdfIterations);
|
||||
return unlockOfflineVaultWithMasterKey(session, profile, derived.masterKey);
|
||||
}
|
||||
|
||||
export async function unlockOfflineVaultWithMasterKey(
|
||||
session: SessionState,
|
||||
profile: Profile | null,
|
||||
masterKey: Uint8Array
|
||||
): Promise<{ session: SessionState; profile: Profile }> {
|
||||
const record = readRecord();
|
||||
const email = normalizeEmail(profile?.email || session.email);
|
||||
if (!record || record.email !== email) {
|
||||
throw new Error('Offline unlock is not available on this device.');
|
||||
}
|
||||
const keys = await unlockVaultKey(record.profileKey, masterKey);
|
||||
const { accessToken: _accessToken, refreshToken: _refreshToken, ...offlineSession } = session;
|
||||
return {
|
||||
session: {
|
||||
...offlineSession,
|
||||
email: record.email,
|
||||
...keys,
|
||||
},
|
||||
profile: {
|
||||
...stripOfflineProfile(record.profile),
|
||||
key: record.profileKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function kdfIterationsFromLogin(token: TokenSuccess, fallbackIterations: number): number {
|
||||
const value = Number(token.KdfIterations || fallbackIterations || 600000);
|
||||
return Number.isFinite(value) && value > 0 ? value : 600000;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export function registerNodeWardenServiceWorker(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
if (import.meta.env.DEV) return;
|
||||
|
||||
const register = () => {
|
||||
void navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {
|
||||
// PWA support is progressive enhancement; the vault still works without it.
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
register();
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('load', register, { once: true });
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { base64ToBytes, decryptBw, decryptStr } from './crypto';
|
||||
import { deriveSendKeyParts } from './app-support';
|
||||
import { deriveSendKeyParts, looksLikeCipherString } from './app-support';
|
||||
import type { Cipher, Folder, Send } from './types';
|
||||
|
||||
export interface DecryptVaultCoreArgs {
|
||||
@@ -38,7 +38,7 @@ async function decryptField(
|
||||
try {
|
||||
return await decryptStr(value, enc, mac);
|
||||
} catch {
|
||||
return value;
|
||||
return looksLikeCipherString(value) ? '' : value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ async function decryptCipherField(
|
||||
// Preserve the old raw fallback for fields that are genuinely unreadable.
|
||||
}
|
||||
}
|
||||
return value;
|
||||
return looksLikeCipherString(value) ? '' : value;
|
||||
}
|
||||
|
||||
async function decryptFieldWithSource(
|
||||
@@ -88,7 +88,7 @@ async function decryptFieldWithSource(
|
||||
// Keep plain fallback.
|
||||
}
|
||||
}
|
||||
return { text: raw, source: 'plain' };
|
||||
return { text: looksLikeCipherString(raw) ? '' : raw, source: 'plain' };
|
||||
}
|
||||
|
||||
export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<DecryptVaultCoreResult> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Send } from './types';
|
||||
import { getCurrentNetworkStatus } from './network-status';
|
||||
import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt';
|
||||
|
||||
type WorkerSuccess<T> = { id: number; ok: true; result: T };
|
||||
@@ -12,6 +13,7 @@ const pending = new Map<number, { resolve: (value: any) => void; reject: (error:
|
||||
function getWorker(): Worker | null {
|
||||
if (typeof Worker === 'undefined') return null;
|
||||
if (worker) return worker;
|
||||
if (getCurrentNetworkStatus() === 'offline') return null;
|
||||
worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' });
|
||||
worker.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => {
|
||||
const message = event.data;
|
||||
|
||||
+4
-3
@@ -2,6 +2,7 @@ import { render } from 'preact';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import { initI18n } from './lib/i18n';
|
||||
import { registerNodeWardenServiceWorker } from './lib/pwa';
|
||||
import './tailwind.css';
|
||||
import './styles.css';
|
||||
|
||||
@@ -16,6 +17,7 @@ const queryClient = new QueryClient({
|
||||
});
|
||||
|
||||
const root = document.getElementById('root')!;
|
||||
root.setAttribute('translate', 'no');
|
||||
|
||||
function renderApp(): void {
|
||||
render(
|
||||
@@ -26,8 +28,7 @@ function renderApp(): void {
|
||||
);
|
||||
}
|
||||
|
||||
renderApp();
|
||||
|
||||
void initI18n().then(() => {
|
||||
void initI18n().finally(() => {
|
||||
renderApp();
|
||||
registerNodeWardenServiceWorker();
|
||||
});
|
||||
|
||||
@@ -451,7 +451,7 @@
|
||||
}
|
||||
|
||||
.auth-card h1 {
|
||||
@apply m-0 mb-1 text-center;
|
||||
@apply m-0;
|
||||
}
|
||||
|
||||
.standalone-eyebrow {
|
||||
@@ -486,7 +486,17 @@
|
||||
}
|
||||
|
||||
.standalone-title {
|
||||
@apply m-0 mb-1 text-left text-3xl font-bold leading-tight tracking-normal;
|
||||
@apply m-0 text-center text-3xl font-bold leading-tight tracking-normal;
|
||||
}
|
||||
|
||||
.standalone-title-row {
|
||||
@apply relative mb-1 flex min-h-9 items-center justify-center;
|
||||
padding-inline: 84px;
|
||||
}
|
||||
|
||||
.standalone-title-row > .network-status-badge {
|
||||
@apply absolute right-0 top-1/2;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.standalone-muted {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -54,6 +54,10 @@
|
||||
@apply text-2xl;
|
||||
}
|
||||
|
||||
.standalone-title-row {
|
||||
padding-inline: 68px;
|
||||
}
|
||||
|
||||
.standalone-footer {
|
||||
@apply text-xs leading-[1.4];
|
||||
}
|
||||
@@ -128,6 +132,14 @@
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.topbar-actions > .network-status-badge {
|
||||
@apply h-8 px-2 text-[0];
|
||||
}
|
||||
|
||||
.topbar-actions > .network-status-badge svg {
|
||||
@apply m-0;
|
||||
}
|
||||
|
||||
.mobile-sidebar-toggle,
|
||||
.mobile-lock-btn {
|
||||
@apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 p-0 text-[0];
|
||||
@@ -921,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;
|
||||
|
||||
@@ -44,6 +44,31 @@
|
||||
@apply flex items-center gap-2.5;
|
||||
}
|
||||
|
||||
.network-status-badge {
|
||||
@apply inline-flex h-[30px] shrink-0 select-none items-center gap-1.5 rounded-full border px-2.5 text-xs font-bold leading-none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.network-status-badge svg {
|
||||
@apply shrink-0;
|
||||
}
|
||||
|
||||
.network-status-badge.online {
|
||||
background: color-mix(in srgb, var(--success) 10%, var(--panel));
|
||||
border-color: color-mix(in srgb, var(--success) 36%, var(--line));
|
||||
color: color-mix(in srgb, var(--success) 78%, var(--text));
|
||||
}
|
||||
|
||||
.network-status-badge.offline {
|
||||
background: color-mix(in srgb, var(--warning) 13%, var(--panel));
|
||||
border-color: color-mix(in srgb, var(--warning) 42%, var(--line));
|
||||
color: color-mix(in srgb, var(--warning) 82%, var(--text));
|
||||
}
|
||||
|
||||
.topbar-actions > .network-status-badge {
|
||||
@apply h-[34px] px-3;
|
||||
}
|
||||
|
||||
.mobile-tabbar {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
+203
-1
@@ -1,10 +1,212 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import preact from '@preact/preset-vite';
|
||||
import { defineConfig, type Plugin } from 'vite';
|
||||
|
||||
const rootDir = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
function buildServiceWorkerSource(precacheUrls: string[], version: string): string {
|
||||
return `const CACHE_VERSION = ${JSON.stringify(`nodewarden-pwa-${version}`)};
|
||||
const APP_SHELL_CACHE = \`\${CACHE_VERSION}-shell\`;
|
||||
const RUNTIME_CACHE = 'nodewarden-pwa-runtime-v1';
|
||||
|
||||
const PRECACHE_URLS = ${JSON.stringify(precacheUrls, null, 2)};
|
||||
const CRITICAL_SHELL_URLS = ['/', '/index.html'];
|
||||
const STATIC_PATH_RE = /^\\/(?:assets\\/|payment-logos\\/|icon-|logo-|favicon|apple-touch-icon|nodewarden-|manifest\\.webmanifest$)/;
|
||||
const NEVER_CACHE_PATH_RE = /^\\/(?:api|identity|setup|config|notifications|icons|\\.well-known|cdn-cgi)(?:\\/|$)/;
|
||||
const OFFLINE_FALLBACK_HTML = '<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>NodeWarden</title><style>html,body{height:100%;margin:0;background:#eef4ff;color:#0f172a;font-family:ui-sans-serif,system-ui,sans-serif}.boot-screen{min-height:100%;display:grid;place-items:center;padding:24px;box-sizing:border-box}.boot-card{width:min(420px,100%);display:grid;gap:12px;justify-items:center;padding:28px;border:1px solid rgba(148,163,184,.35);border-radius:22px;background:rgba(255,255,255,.86);box-shadow:0 20px 45px rgba(15,23,42,.1)}.boot-logo{width:74px;height:58px;object-fit:contain}.boot-title{font-weight:700}.boot-sub{color:#475569;text-align:center;font-size:14px;line-height:1.5}</style></head><body><div class="boot-screen"><div class="boot-card"><img class="boot-logo" src="/nodewarden-logo.svg" alt=""><div class="boot-title">NodeWarden</div><div class="boot-sub">Offline cache is not ready on this device. Open NodeWarden once while online, then try offline again.</div></div></div></body></html>';
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(APP_SHELL_CACHE)
|
||||
.then(async (cache) => {
|
||||
await cache.addAll(CRITICAL_SHELL_URLS);
|
||||
const nonCriticalUrls = PRECACHE_URLS.filter((url) => !CRITICAL_SHELL_URLS.includes(url));
|
||||
await Promise.allSettled(nonCriticalUrls.map((url) => cache.add(url)));
|
||||
})
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(
|
||||
keys
|
||||
.filter((key) => key.startsWith('nodewarden-pwa-') && key.endsWith('-shell') && key !== APP_SHELL_CACHE)
|
||||
.map((key) => caches.delete(key))
|
||||
))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
function isSameOriginHttpGet(request) {
|
||||
if (request.method !== 'GET') return false;
|
||||
const url = new URL(request.url);
|
||||
return url.origin === self.location.origin;
|
||||
}
|
||||
|
||||
function isCacheableResponse(response) {
|
||||
return response && response.ok && (response.type === 'basic' || response.type === 'default');
|
||||
}
|
||||
|
||||
async function refreshNavigationCache(request) {
|
||||
const cache = await caches.open(APP_SHELL_CACHE);
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (isCacheableResponse(response)) {
|
||||
await cache.put('/', response.clone());
|
||||
await cache.put('/index.html', response.clone());
|
||||
await warmStaticDependencies(response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function warmStaticDependencies(response) {
|
||||
try {
|
||||
const html = await response.text();
|
||||
const runtimeCache = await caches.open(RUNTIME_CACHE);
|
||||
const urls = Array.from(html.matchAll(/\\b(?:src|href)=["']([^"']+)["']/g))
|
||||
.map((match) => {
|
||||
try {
|
||||
return new URL(match[1], self.location.origin);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((url) => url && url.origin === self.location.origin && STATIC_PATH_RE.test(url.pathname))
|
||||
.map((url) => url.pathname + url.search);
|
||||
await Promise.allSettled(Array.from(new Set(urls)).map((url) => runtimeCache.add(url)));
|
||||
await trimRuntimeCache(runtimeCache, 120);
|
||||
} catch {
|
||||
// Dependency warming is best-effort; never slow or break navigation for it.
|
||||
}
|
||||
}
|
||||
|
||||
async function appShellNavigation(request) {
|
||||
const cache = await caches.open(APP_SHELL_CACHE);
|
||||
const url = new URL(request.url);
|
||||
return (
|
||||
(await cache.match(request, { ignoreSearch: true }))
|
||||
|| (await cache.match(url.pathname, { ignoreSearch: true }))
|
||||
|| (await cache.match('/'))
|
||||
|| (await cache.match('/index.html'))
|
||||
|| new Response(OFFLINE_FALLBACK_HTML, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html; charset=UTF-8' },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function trimRuntimeCache(cache, maxEntries) {
|
||||
const keys = await cache.keys();
|
||||
if (keys.length <= maxEntries) return;
|
||||
await Promise.all(keys.slice(0, keys.length - maxEntries).map((key) => cache.delete(key)));
|
||||
}
|
||||
|
||||
async function cacheFirst(request) {
|
||||
const shellCache = await caches.open(APP_SHELL_CACHE);
|
||||
const cachedShell = await shellCache.match(request);
|
||||
if (cachedShell) return cachedShell;
|
||||
|
||||
const runtimeCache = await caches.open(RUNTIME_CACHE);
|
||||
const cachedRuntime = await runtimeCache.match(request);
|
||||
if (cachedRuntime) return cachedRuntime;
|
||||
|
||||
const legacyRuntime = await matchLegacyRuntimeCache(request);
|
||||
if (legacyRuntime) return legacyRuntime;
|
||||
|
||||
const response = await fetch(request);
|
||||
if (isCacheableResponse(response)) {
|
||||
void runtimeCache.put(request, response.clone()).then(() => trimRuntimeCache(runtimeCache, 120));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function matchLegacyRuntimeCache(request) {
|
||||
const keys = await caches.keys();
|
||||
for (const key of keys) {
|
||||
if (key === RUNTIME_CACHE || !key.startsWith('nodewarden-pwa-') || !key.endsWith('-runtime')) continue;
|
||||
const cache = await caches.open(key);
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const request = event.request;
|
||||
if (!isSameOriginHttpGet(request)) return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (NEVER_CACHE_PATH_RE.test(url.pathname)) return;
|
||||
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(appShellNavigation(request));
|
||||
if (navigator.onLine !== false) {
|
||||
event.waitUntil(refreshNavigationCache(request));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (STATIC_PATH_RE.test(url.pathname) || request.destination === 'script' || request.destination === 'style' || request.destination === 'font' || request.destination === 'image' || request.destination === 'worker') {
|
||||
event.respondWith(cacheFirst(request));
|
||||
}
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
function buildCacheVersion(isDemo: boolean, urls: string[]): string {
|
||||
const digest = createHash('sha256')
|
||||
.update(`${isDemo ? 'demo' : 'app'}\n${urls.join('\n')}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
return `${isDemo ? 'demo' : 'app'}-${digest}`;
|
||||
}
|
||||
|
||||
function pwaServiceWorkerPlugin(isDemo: boolean): Plugin {
|
||||
return {
|
||||
name: 'nodewarden-pwa-service-worker',
|
||||
generateBundle(_, bundle) {
|
||||
const urls = new Set<string>([
|
||||
'/',
|
||||
'/index.html',
|
||||
'/vault',
|
||||
'/manifest.webmanifest',
|
||||
'/nodewarden-logo.svg',
|
||||
'/nodewarden-logo-bg.svg',
|
||||
'/nodewarden-wordmark.svg',
|
||||
'/favicon.ico',
|
||||
'/favicon-32.png',
|
||||
'/apple-touch-icon.png',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
'/logo-64.png',
|
||||
]);
|
||||
const buildUrls = new Set<string>(urls);
|
||||
|
||||
for (const [fileName, output] of Object.entries(bundle)) {
|
||||
if (output.type !== 'chunk' && output.type !== 'asset') continue;
|
||||
if (fileName === 'sw.js' || fileName === 'robots.txt') continue;
|
||||
if (fileName.endsWith('.map')) continue;
|
||||
buildUrls.add(`/${fileName}`);
|
||||
}
|
||||
|
||||
const sortedUrls = Array.from(buildUrls).sort();
|
||||
const version = buildCacheVersion(isDemo, Array.from(buildUrls).sort());
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: 'sw.js',
|
||||
source: buildServiceWorkerSource(sortedUrls, version),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function searchIndexPolicyPlugin(isDemo: boolean): Plugin {
|
||||
return {
|
||||
name: 'nodewarden-search-index-policy',
|
||||
@@ -59,7 +261,7 @@ export default defineConfig(({ mode }) => {
|
||||
|
||||
return {
|
||||
root: rootDir,
|
||||
plugins: [preact(), searchIndexPolicyPlugin(isDemo), resourcePriorityPlugin(isDemo)],
|
||||
plugins: [preact(), searchIndexPolicyPlugin(isDemo), resourcePriorityPlugin(isDemo), pwaServiceWorkerPlugin(isDemo)],
|
||||
define: {
|
||||
__NODEWARDEN_DEMO__: JSON.stringify(isDemo),
|
||||
},
|
||||
|
||||
@@ -2,6 +2,9 @@ name = "nodewarden"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2024-01-01"
|
||||
|
||||
[build]
|
||||
command = "npm run build"
|
||||
|
||||
[assets]
|
||||
binding = "ASSETS"
|
||||
directory = "./dist"
|
||||
@@ -19,9 +22,17 @@ database_name = "nodewarden-db"
|
||||
name = "NOTIFICATIONS_HUB"
|
||||
class_name = "NotificationsHub"
|
||||
|
||||
[[durable_objects.bindings]]
|
||||
name = "BACKUP_TRANSFER_RUNNER"
|
||||
class_name = "BackupTransferRunner"
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "ATTACHMENTS_KV"
|
||||
|
||||
[[migrations]]
|
||||
tag = "v1-notifications-hub"
|
||||
new_sqlite_classes = [ "NotificationsHub" ]
|
||||
|
||||
[[migrations]]
|
||||
tag = "v2-backup-transfer-runner"
|
||||
new_sqlite_classes = [ "BackupTransferRunner" ]
|
||||
|
||||
@@ -2,6 +2,9 @@ name = "nodewarden"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2024-01-01"
|
||||
|
||||
[build]
|
||||
command = "npm run build"
|
||||
|
||||
[assets]
|
||||
binding = "ASSETS"
|
||||
directory = "./dist"
|
||||
@@ -19,6 +22,10 @@ database_name = "nodewarden-db"
|
||||
name = "NOTIFICATIONS_HUB"
|
||||
class_name = "NotificationsHub"
|
||||
|
||||
[[durable_objects.bindings]]
|
||||
name = "BACKUP_TRANSFER_RUNNER"
|
||||
class_name = "BackupTransferRunner"
|
||||
|
||||
[[r2_buckets]]
|
||||
binding = "ATTACHMENTS"
|
||||
bucket_name = "nodewarden-attachments"
|
||||
@@ -26,3 +33,7 @@ bucket_name = "nodewarden-attachments"
|
||||
[[migrations]]
|
||||
tag = "v1-notifications-hub"
|
||||
new_sqlite_classes = [ "NotificationsHub" ]
|
||||
|
||||
[[migrations]]
|
||||
tag = "v2-backup-transfer-runner"
|
||||
new_sqlite_classes = [ "BackupTransferRunner" ]
|
||||
|
||||
Reference in New Issue
Block a user