Compare commits
54 Commits
04ebfc7021
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f2704fd41 | |||
| 7e0406f751 | |||
| d5c2ab2b0f | |||
| 9e0908f43c | |||
| 7b3be2c819 | |||
| a8183166ac | |||
| f6169b7610 | |||
| 493f901ec1 | |||
| b4dfb0409b | |||
| a06cb0ed71 | |||
| b0242265f4 | |||
| b444c0f4b8 | |||
| b1b25fe678 | |||
| 7cf2ab7c88 | |||
| 1918735520 | |||
| c652cc1533 | |||
| e9aef72df7 | |||
| 9adb24d4bb | |||
| 563570e3e0 | |||
| 3035a77579 | |||
| 28333f0e9b | |||
| 91320a4eba | |||
| 19b96a7aca | |||
| 18e0396c0a | |||
| 18d3490c4f | |||
| 615caf5946 | |||
| 1a10df4a18 | |||
| d4749d3f82 | |||
| 5ed7c949c1 | |||
| af70cab766 | |||
| bfea5d0a1c | |||
| cda654e1c3 | |||
| 1ee7b0f31b | |||
| 2d2cbea530 | |||
| 4f5d992f10 | |||
| 667afa305b | |||
| 85bd2fa4bf | |||
| fd9707c396 | |||
| 192071e4a7 | |||
| fcf7c80daa | |||
| ed9251c014 | |||
| a75955ca6d | |||
| 03f7fbf601 | |||
| a63336764f | |||
| f56d7f01ca | |||
| 8ff60aed24 | |||
| 749de4e2e1 | |||
| ea9e238aa7 | |||
| 22d267f5bc | |||
| 18eefd1174 | |||
| d468745841 | |||
| 970621c459 | |||
| 385a873e65 | |||
| 56185ecb69 |
@@ -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/
|
.tmp/
|
||||||
|
.tmp-bitwarden-clients/
|
||||||
|
|
||||||
nodewarden.wiki/
|
nodewarden.wiki/
|
||||||
wiki/
|
wiki/
|
||||||
@@ -47,3 +48,16 @@ AGENTS.md
|
|||||||
settings.json
|
settings.json
|
||||||
.claude/
|
.claude/
|
||||||
NodeWarden-compat/
|
NodeWarden-compat/
|
||||||
|
.codex-upstream/
|
||||||
|
.codex-upstream/bitwarden-server/
|
||||||
|
.codex-upstream/bitwarden-clients/
|
||||||
|
.codex-upstream/bitwarden-web/
|
||||||
|
.codex-upstream/bitwarden-browser/
|
||||||
|
|
||||||
|
.reasonix/
|
||||||
|
|
||||||
|
# Compatibility analysis documents
|
||||||
|
BITWARDEN_COMPATIBILITY_ANALYSIS.md
|
||||||
|
.mcp.json
|
||||||
|
opencode.jsonc
|
||||||
|
.cursor/
|
||||||
|
|||||||
@@ -34,16 +34,19 @@
|
|||||||
| 能力 | Bitwarden | NodeWarden | 说明 |
|
| 能力 | Bitwarden | NodeWarden | 说明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
||||||
|
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** |
|
||||||
|
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** |
|
||||||
|
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
|
||||||
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
|
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
|
||||||
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
||||||
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
||||||
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
||||||
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份** |
|
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份(OneDrive/Google Drive等)** |
|
||||||
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
|
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
|
||||||
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
|
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
|
||||||
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
|
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
|
||||||
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
||||||
| 登录 2FA | ✅ | ⚠️ 部分支持 | 当前仅支持用户级 TOTP |
|
| 登录 2FA | ✅ | ⚠️ 部分支持 | 支持TOTP和Passkey(作为第二因素) |
|
||||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
|
| 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`
|
- ZIP 内仍只包含 `db.json` 与 `manifest.json`
|
||||||
- 真实附件单独存放在 `attachments/`
|
- 真实附件单独存放在 `attachments/`
|
||||||
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
|
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
|
||||||
|
|||||||
@@ -37,16 +37,19 @@
|
|||||||
| Capability | Bitwarden | NodeWarden | Notes |
|
| Capability | Bitwarden | NodeWarden | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
|
| 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 |
|
| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients |
|
||||||
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
|
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
|
||||||
| Send | ✅ | ✅ | Supports both text and file Sends |
|
| Send | ✅ | ✅ | Supports both text and file Sends |
|
||||||
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
|
| 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** |
|
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
|
||||||
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
|
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
|
||||||
| Multi-user | ✅ | ✅ | Invite-based registration |
|
| Multi-user | ✅ | ✅ | Invite-based registration |
|
||||||
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
|
| 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 |
|
| 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:
|
- When `Include attachments` is enabled:
|
||||||
- the ZIP still contains only `db.json` and `manifest.json`
|
- the ZIP still contains only `db.json` and `manifest.json`
|
||||||
- actual attachment files are stored separately under `attachments/`
|
- actual attachment files are stored separately under `attachments/`
|
||||||
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
|
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
|
||||||
- During remote restore:
|
- During remote restore:
|
||||||
- required attachment files are loaded from `attachments/` on demand
|
- required attachment files are loaded from `attachments/` on demand
|
||||||
- missing attachments are skipped safely
|
- missing attachments are skipped safely
|
||||||
- skipped attachments do not leave broken rows in the restored database
|
- 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`
|
||||||
|
|
||||||
@@ -188,6 +188,33 @@ CREATE TABLE IF NOT EXISTS devices (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
|
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at);
|
CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_requests (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
organization_id TEXT,
|
||||||
|
type INTEGER NOT NULL,
|
||||||
|
request_device_identifier TEXT NOT NULL,
|
||||||
|
request_device_type INTEGER NOT NULL,
|
||||||
|
request_ip_address TEXT,
|
||||||
|
request_country_name TEXT,
|
||||||
|
response_device_identifier TEXT,
|
||||||
|
access_code TEXT NOT NULL,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
key TEXT,
|
||||||
|
master_password_hash TEXT,
|
||||||
|
approved INTEGER,
|
||||||
|
creation_date TEXT NOT NULL,
|
||||||
|
response_date TEXT,
|
||||||
|
authentication_date TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_requests_user_created
|
||||||
|
ON auth_requests(user_id, creation_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_requests_user_pending
|
||||||
|
ON auth_requests(user_id, approved, response_date, authentication_date, creation_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_requests_device_pending
|
||||||
|
ON auth_requests(user_id, request_device_identifier, creation_date);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
@@ -198,6 +225,44 @@ CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
|
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
|
||||||
ON trusted_two_factor_device_tokens(user_id, device_identifier);
|
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
|
-- Rate limiting
|
||||||
CREATE TABLE IF NOT EXISTS login_attempts_ip (
|
CREATE TABLE IF NOT EXISTS login_attempts_ip (
|
||||||
ip TEXT PRIMARY KEY,
|
ip TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.5.2",
|
"version": "1.6.1",
|
||||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||||
"author": "shuaiplus",
|
"author": "shuaiplus",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run build && wrangler dev -c wrangler.toml",
|
"dev": "wrangler dev -c wrangler.toml",
|
||||||
"dev:kv": "npm run build && wrangler dev -c wrangler.kv.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",
|
"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": "vite build --config webapp/vite.config.ts",
|
||||||
"build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs",
|
"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",
|
"domains:sync": "node scripts/sync-global-domains.mjs",
|
||||||
"i18n": "node scripts/i18n-validate.cjs",
|
"i18n": "node scripts/i18n-validate.cjs",
|
||||||
"i18n:validate": "node scripts/i18n-validate.cjs",
|
"i18n:validate": "node scripts/i18n-validate.cjs",
|
||||||
"deploy": "npm run build && wrangler deploy",
|
"deploy": "wrangler deploy",
|
||||||
"deploy:kv": "npm run build && wrangler deploy -c wrangler.kv.toml",
|
"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"
|
"deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
|
"@simplewebauthn/server": "^13.3.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@zip.js/zip.js": "^2.8.22",
|
"@zip.js/zip.js": "^2.8.22",
|
||||||
"fflate": "^0.8.2",
|
"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.1';
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
|
|||||||
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
||||||
|
|
||||||
export type BackupDestinationType = 's3' | 'webdav';
|
export type BackupDestinationType = 's3' | 'webdav';
|
||||||
|
export type S3BackupAddressingStyle = 'path-style' | 'virtual-hosted-style';
|
||||||
|
|
||||||
export interface S3BackupDestination {
|
export interface S3BackupDestination {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
bucket: string;
|
bucket: string;
|
||||||
|
addressingStyle: S3BackupAddressingStyle;
|
||||||
region: string;
|
region: string;
|
||||||
accessKeyId: string;
|
accessKeyId: string;
|
||||||
secretAccessKey: string;
|
secretAccessKey: string;
|
||||||
@@ -103,6 +105,7 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
|
|||||||
return {
|
return {
|
||||||
endpoint: '',
|
endpoint: '',
|
||||||
bucket: '',
|
bucket: '',
|
||||||
|
addressingStyle: 'path-style',
|
||||||
region: BACKUP_DEFAULT_S3_REGION,
|
region: BACKUP_DEFAULT_S3_REGION,
|
||||||
accessKeyId: '',
|
accessKeyId: '',
|
||||||
secretAccessKey: '',
|
secretAccessKey: '',
|
||||||
|
|||||||
@@ -44,6 +44,9 @@
|
|||||||
// Public read-only request budget per IP per minute.
|
// Public read-only request budget per IP per minute.
|
||||||
// 公开只读接口每 IP 每分钟请求配额。
|
// 公开只读接口每 IP 每分钟请求配额。
|
||||||
publicReadRequestsPerMinute: 120,
|
publicReadRequestsPerMinute: 120,
|
||||||
|
// Public website icon proxy budget per IP per minute.
|
||||||
|
// 公开网站图标代理每 IP 每分钟请求配额。
|
||||||
|
publicIconRequestsPerMinute: 500,
|
||||||
// Sensitive public/auth request budget per IP per minute.
|
// Sensitive public/auth request budget per IP per minute.
|
||||||
// 敏感公开/认证接口每 IP 每分钟请求配额。
|
// 敏感公开/认证接口每 IP 每分钟请求配额。
|
||||||
sensitivePublicRequestsPerMinute: 30,
|
sensitivePublicRequestsPerMinute: 30,
|
||||||
@@ -145,6 +148,11 @@
|
|||||||
compatibility: {
|
compatibility: {
|
||||||
// Single source of truth for /config.version and /api/version.
|
// Single source of truth for /config.version and /api/version.
|
||||||
// /config.version 与 /api/version 的统一版本号来源。
|
// /config.version 与 /api/version 的统一版本号来源。
|
||||||
bitwardenServerVersion: '2026.1.0',
|
bitwardenServerVersion: '2026.4.1',
|
||||||
|
// 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;
|
} 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,17 @@ const SIGNALR_RECORD_SEPARATOR = 0x1e;
|
|||||||
const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]);
|
const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]);
|
||||||
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
||||||
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
||||||
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
|
||||||
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
|
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
|
||||||
|
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
|
||||||
|
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
|
||||||
|
|
||||||
type HubProtocol = 'json' | 'messagepack';
|
type HubProtocol = 'json' | 'messagepack';
|
||||||
|
type HubKind = 'user' | 'anonymous-auth-request';
|
||||||
|
|
||||||
interface WsAttachment {
|
interface WsAttachment {
|
||||||
userId: string;
|
kind: HubKind;
|
||||||
|
userId: string | null;
|
||||||
|
authRequestId: string | null;
|
||||||
handshakeComplete: boolean;
|
handshakeComplete: boolean;
|
||||||
protocol: HubProtocol;
|
protocol: HubProtocol;
|
||||||
deviceIdentifier: string | null;
|
deviceIdentifier: string | null;
|
||||||
@@ -137,11 +141,12 @@ function frameSignalRBinary(payload: Uint8Array): Uint8Array {
|
|||||||
function buildSignalRJsonInvocation(
|
function buildSignalRJsonInvocation(
|
||||||
updateType: number,
|
updateType: number,
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
contextId: string | null
|
contextId: string | null,
|
||||||
|
target: string = 'ReceiveMessage'
|
||||||
): string {
|
): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
type: 1,
|
type: 1,
|
||||||
target: 'ReceiveMessage',
|
target,
|
||||||
arguments: [
|
arguments: [
|
||||||
{
|
{
|
||||||
ContextId: contextId,
|
ContextId: contextId,
|
||||||
@@ -155,7 +160,8 @@ function buildSignalRJsonInvocation(
|
|||||||
function buildSignalRMessagePackInvocation(
|
function buildSignalRMessagePackInvocation(
|
||||||
updateType: number,
|
updateType: number,
|
||||||
messagePayload: Record<string, unknown>,
|
messagePayload: Record<string, unknown>,
|
||||||
contextId: string | null
|
contextId: string | null,
|
||||||
|
target: string = 'ReceiveMessage'
|
||||||
): Uint8Array {
|
): Uint8Array {
|
||||||
// SignalR MessagePack hub protocol uses an array-based invocation shape:
|
// SignalR MessagePack hub protocol uses an array-based invocation shape:
|
||||||
// [type, headers, invocationId, target, arguments]
|
// [type, headers, invocationId, target, arguments]
|
||||||
@@ -163,7 +169,7 @@ function buildSignalRMessagePackInvocation(
|
|||||||
1,
|
1,
|
||||||
{},
|
{},
|
||||||
null,
|
null,
|
||||||
'ReceiveMessage',
|
target,
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
ContextId: contextId,
|
ContextId: contextId,
|
||||||
@@ -213,6 +219,20 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/internal/auth-request-response' && request.method === 'POST') {
|
||||||
|
const body = (await request.json().catch(() => null)) as {
|
||||||
|
userId?: string;
|
||||||
|
authRequestId?: string;
|
||||||
|
contextId?: string | null;
|
||||||
|
} | null;
|
||||||
|
const userId = String(body?.userId || '').trim();
|
||||||
|
const authRequestId = String(body?.authRequestId || '').trim();
|
||||||
|
if (!userId || !authRequestId) return new Response('Invalid auth request notification', { status: 400 });
|
||||||
|
|
||||||
|
this.broadcastAuthRequestResponse(userId, authRequestId, String(body?.contextId || '').trim() || null);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === '/internal/online' && request.method === 'GET') {
|
if (url.pathname === '/internal/online' && request.method === 'GET') {
|
||||||
return new Response(JSON.stringify({ deviceIdentifiers: this.getOnlineDeviceIdentifiers() }), {
|
return new Response(JSON.stringify({ deviceIdentifiers: this.getOnlineDeviceIdentifiers() }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -222,7 +242,7 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname !== '/notifications/hub') {
|
if (url.pathname !== '/notifications/hub' && url.pathname !== '/notifications/anonymous-hub') {
|
||||||
return new Response('Not found', { status: 404 });
|
return new Response('Not found', { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,8 +252,13 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
|
|
||||||
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
|
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
|
||||||
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
|
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
|
||||||
|
const requestAuthRequestId = String(url.searchParams.get('nw_auth_request_id') || '').trim() || null;
|
||||||
|
const isAnonymousAuthRequestHub = url.pathname === '/notifications/anonymous-hub';
|
||||||
|
|
||||||
if (!requestUserId) {
|
if (!isAnonymousAuthRequestHub && !requestUserId) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
if (isAnonymousAuthRequestHub && !requestAuthRequestId) {
|
||||||
return new Response('Unauthorized', { status: 401 });
|
return new Response('Unauthorized', { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +273,9 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
this.ctx.acceptWebSocket(server, tags);
|
this.ctx.acceptWebSocket(server, tags);
|
||||||
|
|
||||||
server.serializeAttachment({
|
server.serializeAttachment({
|
||||||
userId: requestUserId,
|
kind: isAnonymousAuthRequestHub ? 'anonymous-auth-request' : 'user',
|
||||||
|
userId: isAnonymousAuthRequestHub ? null : requestUserId,
|
||||||
|
authRequestId: requestAuthRequestId,
|
||||||
handshakeComplete: false,
|
handshakeComplete: false,
|
||||||
protocol: 'messagepack',
|
protocol: 'messagepack',
|
||||||
deviceIdentifier: requestDeviceIdentifier,
|
deviceIdentifier: requestDeviceIdentifier,
|
||||||
@@ -274,7 +301,6 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
attachment.handshakeComplete = true;
|
attachment.handshakeComplete = true;
|
||||||
ws.serializeAttachment(attachment);
|
ws.serializeAttachment(attachment);
|
||||||
ws.send(SIGNALR_HANDSHAKE_ACK);
|
ws.send(SIGNALR_HANDSHAKE_ACK);
|
||||||
this.broadcastDeviceStatus(attachment.userId);
|
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore malformed pre-handshake payloads.
|
// Ignore malformed pre-handshake payloads.
|
||||||
@@ -293,26 +319,22 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
|
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
|
||||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
void ws;
|
||||||
const shouldBroadcast = !!attachment?.handshakeComplete;
|
void code;
|
||||||
if (shouldBroadcast && attachment?.userId) {
|
void reason;
|
||||||
this.broadcastDeviceStatus(attachment.userId);
|
void wasClean;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
|
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
|
||||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
void ws;
|
||||||
const shouldBroadcast = !!attachment?.handshakeComplete;
|
void error;
|
||||||
if (shouldBroadcast && attachment?.userId) {
|
|
||||||
this.broadcastDeviceStatus(attachment.userId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getOnlineDeviceIdentifiers(): string[] {
|
private getOnlineDeviceIdentifiers(): string[] {
|
||||||
const out = new Set<string>();
|
const out = new Set<string>();
|
||||||
for (const ws of this.ctx.getWebSockets()) {
|
for (const ws of this.ctx.getWebSockets()) {
|
||||||
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
if (!attachment?.handshakeComplete || !attachment.deviceIdentifier) continue;
|
if (!attachment?.handshakeComplete || attachment.kind !== 'user' || !attachment.deviceIdentifier) continue;
|
||||||
out.add(attachment.deviceIdentifier);
|
out.add(attachment.deviceIdentifier);
|
||||||
}
|
}
|
||||||
return Array.from(out);
|
return Array.from(out);
|
||||||
@@ -349,16 +371,45 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private broadcastDeviceStatus(userId: string): void {
|
private broadcastAuthRequestResponse(userId: string, authRequestId: string, contextId: string | null): void {
|
||||||
this.broadcastMessage(
|
for (const ws of this.ctx.getWebSockets()) {
|
||||||
SIGNALR_UPDATE_TYPE_DEVICE_STATUS,
|
const attachment = ws.deserializeAttachment() as WsAttachment | null;
|
||||||
{
|
if (
|
||||||
|
!attachment?.handshakeComplete ||
|
||||||
|
attachment.kind !== 'anonymous-auth-request' ||
|
||||||
|
attachment.authRequestId !== authRequestId
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
UserId: userId,
|
UserId: userId,
|
||||||
Date: new Date().toISOString(),
|
Id: authRequestId,
|
||||||
},
|
};
|
||||||
null,
|
try {
|
||||||
null
|
if (attachment.protocol === 'json') {
|
||||||
);
|
ws.send(buildSignalRJsonInvocation(
|
||||||
|
SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE,
|
||||||
|
payload,
|
||||||
|
contextId,
|
||||||
|
'AuthRequestResponseRecieved'
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
ws.send(buildSignalRMessagePackInvocation(
|
||||||
|
SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE,
|
||||||
|
payload,
|
||||||
|
contextId,
|
||||||
|
'AuthRequestResponseRecieved'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
ws.close(1011, 'Notification send failed');
|
||||||
|
} catch {
|
||||||
|
// ignore close races
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,13 +443,59 @@ export async function getOnlineUserDevices(env: Env, userId: string): Promise<st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function notifyAuthRequestResponse(
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
authRequestId: string,
|
||||||
|
contextId?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const id = env.NOTIFICATIONS_HUB.idFromName(authRequestId);
|
||||||
|
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||||
|
await stub.fetch('https://notifications/internal/auth-request-response', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
userId,
|
||||||
|
authRequestId,
|
||||||
|
contextId: contextId || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to broadcast auth request response notification:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserAuthRequest(
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
authRequestId: string,
|
||||||
|
contextId?: string | null
|
||||||
|
): void {
|
||||||
|
waitUntil(notifyUserUpdate(
|
||||||
|
env,
|
||||||
|
userId,
|
||||||
|
SIGNALR_UPDATE_TYPE_AUTH_REQUEST,
|
||||||
|
new Date().toISOString(),
|
||||||
|
contextId ?? null,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
UserId: userId,
|
||||||
|
Id: authRequestId,
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
async function notifyUserUpdate(
|
async function notifyUserUpdate(
|
||||||
env: Env,
|
env: Env,
|
||||||
userId: string,
|
userId: string,
|
||||||
updateType: number,
|
updateType: number,
|
||||||
revisionDate: string,
|
revisionDate: string,
|
||||||
contextId: string | null,
|
contextId: string | null,
|
||||||
targetDeviceIdentifier: string | null
|
targetDeviceIdentifier: string | null,
|
||||||
|
payloadOverride?: Record<string, unknown> | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
|
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
|
||||||
@@ -414,7 +511,7 @@ async function notifyUserUpdate(
|
|||||||
contextId: contextId || null,
|
contextId: contextId || null,
|
||||||
updateType,
|
updateType,
|
||||||
targetDeviceIdentifier: targetDeviceIdentifier || null,
|
targetDeviceIdentifier: targetDeviceIdentifier || null,
|
||||||
payload: {
|
payload: payloadOverride || {
|
||||||
UserId: userId,
|
UserId: userId,
|
||||||
Date: revisionDate,
|
Date: revisionDate,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@ import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
|||||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||||
import { buildAccountKeys } from '../utils/user-decryption';
|
import { buildAccountKeys } from '../utils/user-decryption';
|
||||||
|
|
||||||
|
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||||
|
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
|
||||||
// CONTRACT:
|
// CONTRACT:
|
||||||
// users.master_password_hash is server-side login verification only. It does
|
// users.master_password_hash is server-side login verification only. It does
|
||||||
// not decrypt vault data. Password changes must keep encrypted user key material,
|
// not decrypt vault data. Password changes must keep encrypted user key material,
|
||||||
@@ -64,6 +68,77 @@ function normalizeTotpSecret(input: string): string {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function randomBase32Secret(length: number = 32): string {
|
||||||
|
const bytes = new Uint8Array(length);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
let out = '';
|
||||||
|
for (const byte of bytes) {
|
||||||
|
out += TOTP_BASE32_ALPHABET[byte % TOTP_BASE32_ALPHABET.length];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlEncodeBytes(data: Uint8Array): string {
|
||||||
|
const base64 = btoa(String.fromCharCode(...data));
|
||||||
|
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlDecodeBytes(input: string): Uint8Array {
|
||||||
|
let base64 = input.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
while (base64.length % 4) base64 += '=';
|
||||||
|
const binary = atob(base64);
|
||||||
|
const out = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
new TextEncoder().encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
return new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTotpUserVerificationToken(env: Env, user: User, key: string): Promise<string> {
|
||||||
|
const payload = {
|
||||||
|
sub: user.id,
|
||||||
|
key,
|
||||||
|
stamp: user.securityStamp,
|
||||||
|
exp: Date.now() + TOTP_USER_VERIFICATION_TOKEN_TTL_MS,
|
||||||
|
};
|
||||||
|
const payloadB64 = base64UrlEncodeBytes(new TextEncoder().encode(JSON.stringify(payload)));
|
||||||
|
const signatureB64 = base64UrlEncodeBytes(await hmacSha256(env.JWT_SECRET, payloadB64));
|
||||||
|
return `${payloadB64}.${signatureB64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyTotpUserVerificationToken(env: Env, user: User, key: string, token: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const [payloadB64, signatureB64] = String(token || '').split('.');
|
||||||
|
if (!payloadB64 || !signatureB64) return false;
|
||||||
|
const expected = base64UrlEncodeBytes(await hmacSha256(env.JWT_SECRET, payloadB64));
|
||||||
|
if (expected !== signatureB64) return false;
|
||||||
|
const payload = JSON.parse(new TextDecoder().decode(base64UrlDecodeBytes(payloadB64))) as {
|
||||||
|
sub?: string;
|
||||||
|
key?: string;
|
||||||
|
stamp?: string;
|
||||||
|
exp?: number;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
payload.sub === user.id &&
|
||||||
|
payload.key === key &&
|
||||||
|
payload.stamp === user.securityStamp &&
|
||||||
|
typeof payload.exp === 'number' &&
|
||||||
|
payload.exp >= Date.now()
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRecoveryCodeInput(input: string): string {
|
function normalizeRecoveryCodeInput(input: string): string {
|
||||||
return String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
|
return String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
|
||||||
}
|
}
|
||||||
@@ -91,6 +166,23 @@ async function verifyUserSecret(
|
|||||||
return auth.verifyPassword(normalized, user.masterPasswordHash, user.email);
|
return auth.verifyPassword(normalized, user.masterPasswordHash, user.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readBodyString(body: Record<string, unknown>, names: string[]): string {
|
||||||
|
for (const name of names) {
|
||||||
|
const value = body[name];
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRequestBody(request: Request): Promise<Record<string, unknown>> {
|
||||||
|
const contentType = request.headers.get('content-type') || '';
|
||||||
|
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
return Object.fromEntries(formData.entries()) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return await request.json();
|
||||||
|
}
|
||||||
|
|
||||||
function toProfile(user: User, env: Env): ProfileResponse {
|
function toProfile(user: User, env: Env): ProfileResponse {
|
||||||
void env;
|
void env;
|
||||||
const accountKeys = buildAccountKeys(user);
|
const accountKeys = buildAccountKeys(user);
|
||||||
@@ -592,6 +684,164 @@ export async function handleGetTotpStatus(request: Request, env: Env, userId: st
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function twoFactorProviderResponse(type: number, enabled: boolean): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
Enabled: enabled,
|
||||||
|
Type: type,
|
||||||
|
Object: 'twoFactorProvider',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function twoFactorAuthenticatorResponse(
|
||||||
|
enabled: boolean,
|
||||||
|
key: string,
|
||||||
|
userVerificationToken?: string
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
Enabled: enabled,
|
||||||
|
Key: key,
|
||||||
|
UserVerificationToken: userVerificationToken ?? null,
|
||||||
|
Object: 'twoFactorAuthenticator',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/two-factor
|
||||||
|
export async function handleGetTwoFactorProviders(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
const data = user.totpSecret
|
||||||
|
? [twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true)]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
Data: data,
|
||||||
|
ContinuationToken: null,
|
||||||
|
Object: 'list',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/two-factor/get-authenticator
|
||||||
|
export async function handleGetTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = await readRequestBody(request);
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
||||||
|
const verified = await verifyUserSecret(auth, user, secret);
|
||||||
|
if (!verified) return errorResponse('User verification failed.', 400);
|
||||||
|
|
||||||
|
const key = normalizeTotpSecret(user.totpSecret || '') || randomBase32Secret();
|
||||||
|
const userVerificationToken = await createTotpUserVerificationToken(env, user, key);
|
||||||
|
return jsonResponse(twoFactorAuthenticatorResponse(!!user.totpSecret, key, userVerificationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/two-factor/authenticator
|
||||||
|
export async function handlePutTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = await readRequestBody(request);
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = normalizeTotpSecret(readBodyString(body, ['key', 'Key']));
|
||||||
|
const token = readBodyString(body, ['token', 'Token']).trim();
|
||||||
|
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
|
||||||
|
if (!key || !token || !userVerificationToken) {
|
||||||
|
return errorResponse('Key, token and userVerificationToken are required', 400);
|
||||||
|
}
|
||||||
|
if (!await verifyTotpUserVerificationToken(env, user, key, userVerificationToken)) {
|
||||||
|
return errorResponse('User verification failed.', 400);
|
||||||
|
}
|
||||||
|
if (!isTotpEnabled(key)) return errorResponse('Invalid TOTP secret', 400);
|
||||||
|
if (!await verifyTotpToken(key, token)) return errorResponse('Invalid token.', 400);
|
||||||
|
|
||||||
|
user.totpSecret = key;
|
||||||
|
if (!user.totpRecoveryCode) {
|
||||||
|
user.totpRecoveryCode = createRecoveryCode();
|
||||||
|
}
|
||||||
|
user.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveUser(user);
|
||||||
|
await storage.deleteRefreshTokensByUserId(user.id);
|
||||||
|
AuthService.invalidateUserCache(user.id);
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: 'account.totp.enable',
|
||||||
|
category: 'security',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
metadata: auditRequestMetadata(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse(twoFactorAuthenticatorResponse(true, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/two-factor/authenticator and PUT/POST /api/two-factor/disable
|
||||||
|
export async function handleDisableTwoFactorProvider(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = await readRequestBody(request);
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeRaw = body.type ?? body.Type ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR;
|
||||||
|
const type = typeof typeRaw === 'number' ? typeRaw : Number.parseInt(String(typeRaw), 10);
|
||||||
|
if (type !== TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
||||||
|
return errorResponse('Two-factor provider is not supported by this server.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = normalizeTotpSecret(readBodyString(body, ['key', 'Key']));
|
||||||
|
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
|
||||||
|
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
||||||
|
let verified = false;
|
||||||
|
if (key && userVerificationToken) {
|
||||||
|
verified = await verifyTotpUserVerificationToken(env, user, key, userVerificationToken);
|
||||||
|
}
|
||||||
|
if (!verified) {
|
||||||
|
verified = await verifyUserSecret(auth, user, secret);
|
||||||
|
}
|
||||||
|
if (!verified) return errorResponse('User verification failed.', 400);
|
||||||
|
|
||||||
|
user.totpSecret = null;
|
||||||
|
user.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveUser(user);
|
||||||
|
await storage.deleteRefreshTokensByUserId(user.id);
|
||||||
|
AuthService.invalidateUserCache(user.id);
|
||||||
|
await writeAuditEvent(storage, {
|
||||||
|
actorUserId: user.id,
|
||||||
|
action: 'account.totp.disable',
|
||||||
|
category: 'security',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: user.id,
|
||||||
|
metadata: auditRequestMetadata(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, false));
|
||||||
|
}
|
||||||
|
|
||||||
// PUT /api/accounts/totp
|
// PUT /api/accounts/totp
|
||||||
// enable: { enabled: true, secret: "...", token: "123456" }
|
// enable: { enabled: true, secret: "...", token: "123456" }
|
||||||
// disable: { enabled: false, masterPasswordHash: "..." }
|
// disable: { enabled: false, masterPasswordHash: "..." }
|
||||||
@@ -699,7 +949,9 @@ export async function handleGetTotpRecoveryCode(request: Request, env: Env, user
|
|||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
|
Code: user.totpRecoveryCode,
|
||||||
code: user.totpRecoveryCode,
|
code: user.totpRecoveryCode,
|
||||||
|
Object: 'twoFactorRecover',
|
||||||
object: 'twoFactorRecover',
|
object: 'twoFactorRecover',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -731,7 +983,7 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
|
|||||||
if (!clientIdentifier) {
|
if (!clientIdentifier) {
|
||||||
return errorResponse('Client IP is required', 403);
|
return errorResponse('Client IP is required', 403);
|
||||||
}
|
}
|
||||||
const recoverLimitKey = `${clientIdentifier}:recover-2fa:${email || 'unknown'}`;
|
const recoverLimitKey = `${clientIdentifier}:recover-2fa`;
|
||||||
|
|
||||||
const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey);
|
const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey);
|
||||||
if (!recoverAttemptCheck.allowed) {
|
if (!recoverAttemptCheck.allowed) {
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import type { AuthRequestRecord, AuthRequestType, Env } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { readAuthRequestDeviceInfo, readActingDeviceIdentifier } from '../utils/device';
|
||||||
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
|
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
||||||
|
import { notifyAuthRequestResponse, notifyUserAuthRequest } from '../durable/notifications-hub';
|
||||||
|
|
||||||
|
const AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK = 0;
|
||||||
|
const AUTH_REQUEST_TYPE_UNLOCK = 1;
|
||||||
|
const AUTH_REQUEST_TYPE_ADMIN_APPROVAL = 2;
|
||||||
|
|
||||||
|
function normalizeText(value: unknown, maxLength: number): string {
|
||||||
|
return String(value ?? '').trim().slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientIp(request: Request): string | null {
|
||||||
|
return (
|
||||||
|
request.headers.get('CF-Connecting-IP') ||
|
||||||
|
request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCountryName(request: Request): string | null {
|
||||||
|
return request.headers.get('CF-IPCountry') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceTypeName(type: number): string {
|
||||||
|
const names: Record<number, string> = {
|
||||||
|
0: 'Android',
|
||||||
|
1: 'iOS',
|
||||||
|
2: 'Chrome Extension',
|
||||||
|
3: 'Firefox Extension',
|
||||||
|
4: 'Opera Extension',
|
||||||
|
5: 'Edge Extension',
|
||||||
|
6: 'Windows Desktop',
|
||||||
|
7: 'macOS Desktop',
|
||||||
|
8: 'Linux Desktop',
|
||||||
|
9: 'Chrome',
|
||||||
|
10: 'Firefox',
|
||||||
|
11: 'Opera',
|
||||||
|
12: 'Edge',
|
||||||
|
13: 'Internet Explorer',
|
||||||
|
14: 'Unknown Browser',
|
||||||
|
15: 'Android',
|
||||||
|
16: 'Windows UWP',
|
||||||
|
17: 'Safari',
|
||||||
|
18: 'Vivaldi',
|
||||||
|
19: 'Vivaldi Extension',
|
||||||
|
20: 'Safari Extension',
|
||||||
|
21: 'SDK',
|
||||||
|
22: 'Server',
|
||||||
|
23: 'Windows CLI',
|
||||||
|
24: 'macOS CLI',
|
||||||
|
25: 'Linux CLI',
|
||||||
|
26: 'DuckDuckGo',
|
||||||
|
};
|
||||||
|
return names[type] || `Device ${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOrigin(request: Request): string {
|
||||||
|
return new URL(request.url).host;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAuthRequestResponse(request: Request, authRequest: AuthRequestRecord, requestDeviceId?: string | null) {
|
||||||
|
return {
|
||||||
|
id: authRequest.id,
|
||||||
|
Id: authRequest.id,
|
||||||
|
publicKey: authRequest.publicKey,
|
||||||
|
PublicKey: authRequest.publicKey,
|
||||||
|
requestDeviceIdentifier: authRequest.requestDeviceIdentifier,
|
||||||
|
RequestDeviceIdentifier: authRequest.requestDeviceIdentifier,
|
||||||
|
requestDeviceTypeValue: authRequest.requestDeviceType,
|
||||||
|
RequestDeviceTypeValue: authRequest.requestDeviceType,
|
||||||
|
requestDeviceType: deviceTypeName(authRequest.requestDeviceType),
|
||||||
|
RequestDeviceType: deviceTypeName(authRequest.requestDeviceType),
|
||||||
|
requestIpAddress: authRequest.requestIpAddress,
|
||||||
|
RequestIpAddress: authRequest.requestIpAddress,
|
||||||
|
requestCountryName: authRequest.requestCountryName,
|
||||||
|
RequestCountryName: authRequest.requestCountryName,
|
||||||
|
key: authRequest.key,
|
||||||
|
Key: authRequest.key,
|
||||||
|
masterPasswordHash: authRequest.masterPasswordHash,
|
||||||
|
MasterPasswordHash: authRequest.masterPasswordHash,
|
||||||
|
creationDate: authRequest.creationDate,
|
||||||
|
CreationDate: authRequest.creationDate,
|
||||||
|
responseDate: authRequest.responseDate,
|
||||||
|
ResponseDate: authRequest.responseDate,
|
||||||
|
requestApproved: authRequest.approved ?? false,
|
||||||
|
RequestApproved: authRequest.approved ?? false,
|
||||||
|
requestDeviceId: requestDeviceId ?? null,
|
||||||
|
RequestDeviceId: requestDeviceId ?? null,
|
||||||
|
origin: buildOrigin(request),
|
||||||
|
Origin: buildOrigin(request),
|
||||||
|
object: 'auth-request',
|
||||||
|
Object: 'auth-request',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listResponse<T>(data: T[]) {
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
Data: data,
|
||||||
|
object: 'list',
|
||||||
|
Object: 'list',
|
||||||
|
continuationToken: null,
|
||||||
|
ContinuationToken: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(request: Request): Promise<Record<string, any> | null> {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
return body && typeof body === 'object' ? body as Record<string, any> : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBodyValue(body: Record<string, any>, names: string[]): unknown {
|
||||||
|
for (const name of names) {
|
||||||
|
if (body[name] !== undefined) return body[name];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedAuthRequestType(value: number): value is AuthRequestType {
|
||||||
|
return value === AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK || value === AUTH_REQUEST_TYPE_UNLOCK || value === AUTH_REQUEST_TYPE_ADMIN_APPROVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCreateAuthRequest(request: Request, env: Env): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
|
|
||||||
|
const email = normalizeText(readBodyValue(body, ['email', 'Email']), 320).toLowerCase();
|
||||||
|
const publicKey = normalizeText(readBodyValue(body, ['publicKey', 'PublicKey']), 8192);
|
||||||
|
const accessCode = normalizeText(readBodyValue(body, ['accessCode', 'AccessCode']), 25);
|
||||||
|
const requestedType = Number(readBodyValue(body, ['type', 'Type']));
|
||||||
|
const type = Number.isFinite(requestedType) ? requestedType : AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK;
|
||||||
|
const deviceInfo = readAuthRequestDeviceInfo(
|
||||||
|
{
|
||||||
|
deviceIdentifier: normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128),
|
||||||
|
deviceName: normalizeText(readBodyValue(body, ['deviceName', 'DeviceName']), 128),
|
||||||
|
deviceType: String(readBodyValue(body, ['deviceType', 'DeviceType']) ?? ''),
|
||||||
|
},
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!email || !publicKey || !accessCode || !deviceInfo.deviceIdentifier) {
|
||||||
|
return errorResponse('Email, public key, device identifier, and access code are required.', 400);
|
||||||
|
}
|
||||||
|
if (!isSupportedAuthRequestType(type) || type === AUTH_REQUEST_TYPE_ADMIN_APPROVAL) {
|
||||||
|
return errorResponse('Invalid auth request type.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await storage.getUser(email);
|
||||||
|
if (!user || user.status !== 'active') {
|
||||||
|
return errorResponse('User or known device not found.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.pruneExpiredAuthRequests();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const authRequest: AuthRequestRecord = {
|
||||||
|
id: generateUUID(),
|
||||||
|
userId: user.id,
|
||||||
|
organizationId: null,
|
||||||
|
type,
|
||||||
|
requestDeviceIdentifier: deviceInfo.deviceIdentifier,
|
||||||
|
requestDeviceType: deviceInfo.deviceType,
|
||||||
|
requestIpAddress: getClientIp(request),
|
||||||
|
requestCountryName: getCountryName(request),
|
||||||
|
responseDeviceIdentifier: null,
|
||||||
|
accessCode,
|
||||||
|
publicKey,
|
||||||
|
key: null,
|
||||||
|
masterPasswordHash: null,
|
||||||
|
approved: null,
|
||||||
|
creationDate: now,
|
||||||
|
responseDate: null,
|
||||||
|
authenticationDate: null,
|
||||||
|
};
|
||||||
|
await storage.createAuthRequest(authRequest);
|
||||||
|
notifyUserAuthRequest(env, user.id, authRequest.id, deviceInfo.deviceIdentifier);
|
||||||
|
return jsonResponse(toAuthRequestResponse(request, authRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const authRequest = await storage.getAuthRequestById(id);
|
||||||
|
if (!authRequest || authRequest.userId !== userId) return errorResponse('Not found', 404);
|
||||||
|
return jsonResponse(toAuthRequestResponse(request, authRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetAuthRequestResponse(request: Request, env: Env, id: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const accessCode = normalizeText(url.searchParams.get('code'), 25);
|
||||||
|
const authRequest = await storage.getAuthRequestById(id);
|
||||||
|
if (!authRequest || authRequest.accessCode !== accessCode || isAuthRequestExpired(authRequest)) {
|
||||||
|
return errorResponse('Not found', 404);
|
||||||
|
}
|
||||||
|
return jsonResponse(toAuthRequestResponse(request, authRequest));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleListAuthRequests(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const authRequests = await storage.listAuthRequestsByUserId(userId);
|
||||||
|
return jsonResponse(listResponse(authRequests.map((authRequest) => toAuthRequestResponse(request, authRequest))));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleListPendingAuthRequests(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
await storage.pruneExpiredAuthRequests();
|
||||||
|
const authRequests = await storage.listPendingAuthRequestsByUserId(userId);
|
||||||
|
const rows = await Promise.all(authRequests.map(async (authRequest) => {
|
||||||
|
const device = await storage.getDevice(userId, authRequest.requestDeviceIdentifier);
|
||||||
|
return toAuthRequestResponse(request, authRequest, device?.deviceIdentifier ?? authRequest.requestDeviceIdentifier);
|
||||||
|
}));
|
||||||
|
return jsonResponse(listResponse(rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUpdateAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
if (!body) return errorResponse('Invalid request payload', 400);
|
||||||
|
|
||||||
|
const authRequest = await storage.getAuthRequestById(id);
|
||||||
|
if (!authRequest || authRequest.userId !== userId || isAuthRequestExpired(authRequest)) {
|
||||||
|
return errorResponse('Not found', 404);
|
||||||
|
}
|
||||||
|
if (authRequest.approved !== null || authRequest.responseDate || authRequest.authenticationDate) {
|
||||||
|
return errorResponse('Auth request has already been answered.', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestForUser = await storage.listPendingAuthRequestsByUserId(userId);
|
||||||
|
const latestForDevice = latestForUser.find((item) => item.requestDeviceIdentifier === authRequest.requestDeviceIdentifier);
|
||||||
|
if (latestForDevice?.id !== authRequest.id) {
|
||||||
|
return errorResponse('This request is no longer valid. Make sure to approve the most recent request.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const approved = Boolean(readBodyValue(body, ['requestApproved', 'RequestApproved']));
|
||||||
|
const key = normalizeText(readBodyValue(body, ['key', 'Key']), 20000);
|
||||||
|
const masterPasswordHash = normalizeText(readBodyValue(body, ['masterPasswordHash', 'MasterPasswordHash']), 20000) || null;
|
||||||
|
const responseDeviceIdentifier =
|
||||||
|
normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128) ||
|
||||||
|
readActingDeviceIdentifier(request) ||
|
||||||
|
'web';
|
||||||
|
|
||||||
|
if (approved && !key) {
|
||||||
|
return errorResponse('Encrypted key is required to approve the request.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await storage.updateAuthRequestResponse(id, userId, {
|
||||||
|
approved,
|
||||||
|
responseDeviceIdentifier,
|
||||||
|
key,
|
||||||
|
masterPasswordHash,
|
||||||
|
});
|
||||||
|
if (!updated) return errorResponse('Auth request has already been answered.', 409);
|
||||||
|
const updatedRequest = await storage.getAuthRequestById(id);
|
||||||
|
// Match Bitwarden upstream behavior: only approval wakes the originating anonymous
|
||||||
|
// client. Denials are not pushed to avoid leaking that a login attempt was rejected.
|
||||||
|
if (approved) {
|
||||||
|
await notifyAuthRequestResponse(env, userId, id);
|
||||||
|
}
|
||||||
|
return jsonResponse(toAuthRequestResponse(request, updatedRequest || authRequest));
|
||||||
|
}
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
import type { Env, User } from '../types';
|
import type { Env, User } from '../types';
|
||||||
import { errorResponse, jsonResponse } from '../utils/response';
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
|
||||||
import {
|
import {
|
||||||
type BackupArchiveBundle,
|
type BackupArchiveBundle,
|
||||||
buildBackupArchive,
|
buildBackupArchive,
|
||||||
inspectBackupArchiveFileNameChecksum,
|
inspectBackupArchiveFileNameChecksum,
|
||||||
|
parseBackupArchive,
|
||||||
verifyBackupArchiveFileNameChecksum,
|
verifyBackupArchiveFileNameChecksum,
|
||||||
} from '../services/backup-archive';
|
} from '../services/backup-archive';
|
||||||
import {
|
import {
|
||||||
type BackupDestinationRecord,
|
type BackupDestinationRecord,
|
||||||
type BackupSettingsInput,
|
type BackupSettingsInput,
|
||||||
BACKUP_SCHEDULER_WINDOW_MINUTES,
|
type BackupSettings,
|
||||||
|
type WebDavBackupDestination,
|
||||||
getBackupLocalDateKey,
|
getBackupLocalDateKey,
|
||||||
getDefaultBackupSettings,
|
getDefaultBackupSettings,
|
||||||
getBackupSettingsRepairState,
|
getBackupSettingsRepairState,
|
||||||
hasBackupSlotBetween,
|
|
||||||
isBackupDueNow,
|
|
||||||
loadBackupSettings,
|
loadBackupSettings,
|
||||||
normalizeBackupSettingsInput,
|
normalizeBackupSettingsInput,
|
||||||
normalizeImportedBackupSettings,
|
normalizeImportedBackupSettings,
|
||||||
@@ -31,6 +30,7 @@ import {
|
|||||||
} from '../services/backup-import';
|
} from '../services/backup-import';
|
||||||
import {
|
import {
|
||||||
type RemoteBackupTransferSession,
|
type RemoteBackupTransferSession,
|
||||||
|
type RemoteBackupFile,
|
||||||
createRemoteBackupTransferSession,
|
createRemoteBackupTransferSession,
|
||||||
deleteRemoteBackupFile,
|
deleteRemoteBackupFile,
|
||||||
downloadRemoteBackupFile,
|
downloadRemoteBackupFile,
|
||||||
@@ -43,6 +43,7 @@ import { StorageService } from '../services/storage';
|
|||||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||||
import { getBlobObject } from '../services/blob-store';
|
import { getBlobObject } from '../services/blob-store';
|
||||||
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
|
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
|
||||||
|
import { unzipSync } from 'fflate';
|
||||||
|
|
||||||
function isAdmin(user: User): boolean {
|
function isAdmin(user: User): boolean {
|
||||||
return user.role === 'admin' && user.status === 'active';
|
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 {
|
function ensureBackupBlobName(value: string): string {
|
||||||
const normalized = String(value || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
const normalized = String(value || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -201,6 +106,37 @@ interface RemoteAttachmentIndexPayload {
|
|||||||
blobs: Record<string, { sizeBytes: number; updatedAt: string }>;
|
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>> {
|
async function loadRemoteAttachmentIndex(session: RemoteBackupTransferSession): Promise<Map<string, number>> {
|
||||||
try {
|
try {
|
||||||
const file = await session.download(REMOTE_ATTACHMENT_INDEX_PATH);
|
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,
|
env: Env,
|
||||||
storage: StorageService,
|
storage: StorageService,
|
||||||
actorUserId: string | null,
|
actorUserId: string | null,
|
||||||
@@ -331,25 +299,20 @@ async function executeConfiguredBackup(
|
|||||||
if (destination.includeAttachments) {
|
if (destination.includeAttachments) {
|
||||||
await touchLease();
|
await touchLease();
|
||||||
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
|
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
|
||||||
let attachmentIndexChanged = false;
|
const pendingAttachments = (archive.manifest.attachmentBlobs || [])
|
||||||
for (const attachment of 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();
|
await touchLease();
|
||||||
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
|
const chunk = pendingAttachments
|
||||||
continue;
|
.slice(i, i + attachmentSyncBatchSize)
|
||||||
}
|
.map((attachment) => ({ blobName: attachment.blobName }));
|
||||||
const remotePath = `attachments/${attachment.blobName}`;
|
await uploadRemoteAttachmentChunk(env, destination, chunk);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
if (attachmentIndexChanged) {
|
if (pendingAttachments.length) {
|
||||||
|
for (const attachment of pendingAttachments) {
|
||||||
|
remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes);
|
||||||
|
}
|
||||||
await touchLease();
|
await touchLease();
|
||||||
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
|
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 {
|
function toImportStatusCode(message: string): number {
|
||||||
const lower = message.toLowerCase();
|
const lower = message.toLowerCase();
|
||||||
|
if (lower.includes('checksum')) return 400;
|
||||||
if (lower.includes('invalid backup') || lower.includes('invalid json')) return 400;
|
if (lower.includes('invalid backup') || lower.includes('invalid json')) return 400;
|
||||||
if (lower.includes('fresh instance')) return 409;
|
if (lower.includes('fresh instance')) return 409;
|
||||||
if (lower.includes('not configured') || lower.includes('kv')) return 409;
|
if (lower.includes('not configured') || lower.includes('kv')) return 409;
|
||||||
return 500;
|
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(
|
async function runImportAndAudit(
|
||||||
env: Env,
|
env: Env,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -526,30 +768,7 @@ async function runImportAndAudit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runScheduledBackupIfDue(env: Env): Promise<void> {
|
export async function runScheduledBackupIfDue(env: Env): Promise<void> {
|
||||||
await withBackupRunnerLease(env, 'scheduled', async (keepAlive) => {
|
await runScheduledBackupsInDurableObject(env);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleGetAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
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);
|
return errorResponse('Backup run payload is invalid', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
const outcome = await runConfiguredBackupInDurableObject(env, {
|
||||||
const progress = async (event: {
|
actorUserId: actorUser.id,
|
||||||
operation: 'backup-remote-run';
|
auditMetadata: auditRequestMetadata(request),
|
||||||
step: string;
|
destinationId: body?.destinationId || null,
|
||||||
fileName: string;
|
targetDeviceIdentifier: String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null,
|
||||||
stageTitle: string;
|
trigger: 'manual',
|
||||||
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 };
|
|
||||||
});
|
});
|
||||||
if (!outcome) {
|
if (!outcome) {
|
||||||
return errorResponse('Another backup run is already in progress', 409);
|
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);
|
return errorResponse('Remote restore payload is invalid', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
try {
|
try {
|
||||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
|
||||||
const destination = requireBackupDestination(settings, body.destinationId || null);
|
|
||||||
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||||
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||||
const restoreFileNameFromPath = path.split('/').pop() || path;
|
const imported = await restoreRemoteBackupInDurableObject(env, {
|
||||||
await notifyUserBackupRestoreProgress(
|
actorUserId: actorUser.id,
|
||||||
env,
|
allowChecksumMismatch: !!body.allowChecksumMismatch,
|
||||||
actorUser.id,
|
auditMetadata: auditRequestMetadata(request),
|
||||||
{
|
destinationId: body.destinationId || null,
|
||||||
operation: 'backup-restore',
|
path,
|
||||||
source: 'remote',
|
replaceExisting: !!body.replaceExisting,
|
||||||
step: 'remote_fetch_archive',
|
targetDeviceIdentifier,
|
||||||
fileName: restoreFileNameFromPath,
|
});
|
||||||
stageTitle: 'txt_backup_restore_progress_remote_fetch_title',
|
if (!imported) {
|
||||||
stageDetail: 'txt_backup_restore_progress_remote_fetch_detail',
|
return errorResponse('Another backup or restore run is already in progress', 409);
|
||||||
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 restoreFileName = remoteFile.fileName || path.split('/').pop() || path;
|
return jsonResponse(imported);
|
||||||
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);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Remote backup restore failed';
|
const message = error instanceof Error ? error.message : 'Remote backup restore failed';
|
||||||
return errorResponse(message, toImportStatusCode(message));
|
return errorResponse(message, toImportStatusCode(message));
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'
|
|||||||
// unknown/future client fields by default, then override only server-owned
|
// unknown/future client fields by default, then override only server-owned
|
||||||
// fields. Any change to cipher response shape must be checked against /api/sync,
|
// fields. Any change to cipher response shape must be checked against /api/sync,
|
||||||
// attachments, import/export, and current official clients.
|
// 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 {
|
function normalizeOptionalId(value: unknown): string | null {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
const normalized = String(value).trim();
|
const normalized = String(value).trim();
|
||||||
@@ -129,15 +141,32 @@ function optionalEncString(value: unknown): string | null {
|
|||||||
return isValidEncString(value) ? value.trim() : null;
|
return isValidEncString(value) ? value.trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionalEncStringWithin(value: unknown, maxLength: number): string | null {
|
||||||
|
const normalized = optionalEncString(value);
|
||||||
|
if (!normalized) return null;
|
||||||
|
return normalized.length <= maxLength ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldAcceptCipherKey(value: unknown): boolean {
|
||||||
|
return value == null || value === '' || isValidEncString(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCipherKeyForStorage(value: unknown): string | null {
|
||||||
|
return optionalEncString(value);
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeEncryptedObject<T extends Record<string, any>>(
|
function sanitizeEncryptedObject<T extends Record<string, any>>(
|
||||||
source: T | null | undefined,
|
source: T | null | undefined,
|
||||||
encryptedKeys: readonly string[]
|
encryptedKeys: readonly string[] | Record<string, number>
|
||||||
): T | null {
|
): T | null {
|
||||||
if (!source || typeof source !== 'object') return source ?? null;
|
if (!source || typeof source !== 'object') return source ?? null;
|
||||||
const next: Record<string, any> = { ...source };
|
const next: Record<string, any> = { ...source };
|
||||||
for (const key of encryptedKeys) {
|
const entries = Array.isArray(encryptedKeys)
|
||||||
|
? encryptedKeys.map((key) => [key, 10000] as const)
|
||||||
|
: Object.entries(encryptedKeys);
|
||||||
|
for (const [key, maxLength] of entries) {
|
||||||
if (!Object.prototype.hasOwnProperty.call(next, key)) continue;
|
if (!Object.prototype.hasOwnProperty.call(next, key)) continue;
|
||||||
next[key] = optionalEncString(next[key]);
|
next[key] = optionalEncStringWithin(next[key], maxLength);
|
||||||
}
|
}
|
||||||
return next as T;
|
return next as T;
|
||||||
}
|
}
|
||||||
@@ -161,28 +190,90 @@ export function normalizeCipherLoginForStorage(login: any): any {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeCipherLoginForCompatibility(login: any): any {
|
export function normalizeCipherLoginForCompatibility(
|
||||||
|
login: any,
|
||||||
|
requiresUriChecksum: boolean = false,
|
||||||
|
preserveRepairableUris: boolean = false
|
||||||
|
): any {
|
||||||
const normalized = normalizeCipherLoginForStorage(login);
|
const normalized = normalizeCipherLoginForStorage(login);
|
||||||
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
|
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
|
||||||
const next = sanitizeEncryptedObject(normalized, ['username', 'password', 'totp', 'uri']);
|
const next = sanitizeEncryptedObject(normalized, {
|
||||||
|
username: 1000,
|
||||||
|
password: 5000,
|
||||||
|
totp: 1000,
|
||||||
|
uri: 10000,
|
||||||
|
});
|
||||||
if (!next) return null;
|
if (!next) return null;
|
||||||
next.uris = Array.isArray(next.uris)
|
next.uris = normalizeCipherLoginUrisForCompatibility(next.uris, {
|
||||||
? next.uris
|
requiresUriChecksum,
|
||||||
.map((uri: any) => sanitizeEncryptedObject(uri, ['uri', 'uriChecksum']))
|
preserveRepairableUris,
|
||||||
.filter((uri: any) => !!uri && (uri.uri || uri.uriChecksum || uri.match != null))
|
});
|
||||||
: null;
|
|
||||||
next.fido2Credentials = normalizeFido2CredentialsForCompatibility(next.fido2Credentials);
|
next.fido2Credentials = normalizeFido2CredentialsForCompatibility(next.fido2Credentials);
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasMissingLoginUriChecksum(cipher: Cipher): boolean {
|
function normalizeCipherLoginUrisForCompatibility(
|
||||||
if (!cipher.key || !cipher.login || typeof cipher.login !== 'object') return false;
|
uris: any,
|
||||||
const uris = (cipher.login as any).uris;
|
options: { requiresUriChecksum?: boolean; preserveRepairableUris?: boolean } = {}
|
||||||
if (!Array.isArray(uris)) return false;
|
): any[] | null {
|
||||||
return uris.some((uri: any) => {
|
if (!Array.isArray(uris) || uris.length === 0) return null;
|
||||||
if (!uri || typeof uri !== 'object') return false;
|
const out: any[] = [];
|
||||||
return isValidEncString(uri.uri) && !isValidEncString(uri.uriChecksum);
|
|
||||||
});
|
for (const uri of uris) {
|
||||||
|
if (!uri || typeof uri !== 'object') continue;
|
||||||
|
const next = sanitizeEncryptedObject(uri, ['uri', 'uriChecksum']);
|
||||||
|
if (!next) continue;
|
||||||
|
|
||||||
|
const hasUri = isValidEncString(next.uri);
|
||||||
|
const hasChecksum = isValidEncString(next.uriChecksum);
|
||||||
|
const hasMatch = next.match != null;
|
||||||
|
|
||||||
|
if (hasUri && String(next.uri).trim().length > 10000) continue;
|
||||||
|
if (hasChecksum && String(next.uriChecksum).trim().length > 10000) {
|
||||||
|
next.uriChecksum = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUri && isValidEncString(next.uriChecksum)) {
|
||||||
|
out.push(next);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUri && !hasChecksum) {
|
||||||
|
// Official Bitwarden treats UriChecksum as nullable encrypted metadata.
|
||||||
|
// Keep the URI intact and let clients that can repair checksums do so.
|
||||||
|
out.push({ ...next, uriChecksum: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChecksum || hasMatch) {
|
||||||
|
out.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.length ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCipherEncryptedFieldsForCompatibility(cipher: Cipher): string | null {
|
||||||
|
if (cipher.name != null && !optionalEncStringWithin(cipher.name, 1000)) return 'Cipher name must be an encrypted string up to 1000 characters.';
|
||||||
|
if (cipher.notes != null && !optionalEncStringWithin(cipher.notes, 10000)) return 'Cipher notes must be an encrypted string up to 10000 characters.';
|
||||||
|
|
||||||
|
const login = cipher.login as any;
|
||||||
|
if (login && typeof login === 'object') {
|
||||||
|
if (login.username != null && !optionalEncStringWithin(login.username, 1000)) return 'Login username must be an encrypted string up to 1000 characters.';
|
||||||
|
if (login.password != null && !optionalEncStringWithin(login.password, 5000)) return 'Login password must be an encrypted string up to 5000 characters.';
|
||||||
|
if (login.totp != null && !optionalEncStringWithin(login.totp, 1000)) return 'Login TOTP must be an encrypted string up to 1000 characters.';
|
||||||
|
if (login.uri != null && !optionalEncStringWithin(login.uri, 10000)) return 'Login URI must be an encrypted string up to 10000 characters.';
|
||||||
|
|
||||||
|
if (Array.isArray(login.uris)) {
|
||||||
|
for (const uri of login.uris) {
|
||||||
|
if (!uri || typeof uri !== 'object') continue;
|
||||||
|
if (uri.uri != null && !optionalEncStringWithin(uri.uri, 10000)) return 'Login URI must be an encrypted string up to 10000 characters.';
|
||||||
|
if (uri.uriChecksum != null && !optionalEncStringWithin(uri.uriChecksum, 10000)) return 'Login URI checksum must be an encrypted string up to 10000 characters.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null {
|
function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null {
|
||||||
@@ -255,6 +346,14 @@ export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCipherSecureNoteForCompatibility(secureNote: any): CipherSecureNote | null {
|
||||||
|
if (!secureNote || typeof secureNote !== 'object') return null;
|
||||||
|
const type = Number(secureNote?.type ?? secureNote?.Type ?? 0);
|
||||||
|
return {
|
||||||
|
type: Number.isFinite(type) ? type : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Format attachments for API response
|
// Format attachments for API response
|
||||||
export function formatAttachments(attachments: Attachment[]): any[] | null {
|
export function formatAttachments(attachments: Attachment[]): any[] | null {
|
||||||
if (attachments.length === 0) return null;
|
if (attachments.length === 0) return null;
|
||||||
@@ -502,12 +601,25 @@ export function isCipherResponseSyncCompatible(cipher: CipherResponse): boolean
|
|||||||
// survive a round-trip without code changes.
|
// survive a round-trip without code changes.
|
||||||
export function cipherToResponse(
|
export function cipherToResponse(
|
||||||
cipher: Cipher,
|
cipher: Cipher,
|
||||||
attachments: Attachment[] = []
|
attachments: Attachment[] = [],
|
||||||
|
options: CipherResponseOptions = {}
|
||||||
): CipherResponse {
|
): CipherResponse {
|
||||||
// Strip internal-only fields that must not appear in the API response
|
// Strip internal-only fields that must not appear in the API response
|
||||||
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
|
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
|
||||||
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null);
|
const responseCipherKey = optionalEncString(cipher.key);
|
||||||
const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']);
|
const normalizedLogin = normalizeCipherLoginForCompatibility(
|
||||||
|
(passthrough as any).login ?? null,
|
||||||
|
!!responseCipherKey,
|
||||||
|
!!options.preserveRepairableUris
|
||||||
|
);
|
||||||
|
const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, {
|
||||||
|
cardholderName: 1000,
|
||||||
|
brand: 1000,
|
||||||
|
number: 1000,
|
||||||
|
expMonth: 1000,
|
||||||
|
expYear: 1000,
|
||||||
|
code: 1000,
|
||||||
|
});
|
||||||
const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [
|
const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [
|
||||||
'title',
|
'title',
|
||||||
'firstName',
|
'firstName',
|
||||||
@@ -529,6 +641,9 @@ export function cipherToResponse(
|
|||||||
'licenseNumber',
|
'licenseNumber',
|
||||||
]);
|
]);
|
||||||
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
||||||
|
const normalizedSecureNote = Number(cipher.type) === 2
|
||||||
|
? normalizeCipherSecureNoteForCompatibility((passthrough as any).secureNote ?? null) ?? { type: 0 }
|
||||||
|
: null;
|
||||||
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
|
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -557,10 +672,12 @@ export function cipherToResponse(
|
|||||||
login: normalizedLogin,
|
login: normalizedLogin,
|
||||||
card: normalizedCard,
|
card: normalizedCard,
|
||||||
identity: normalizedIdentity,
|
identity: normalizedIdentity,
|
||||||
|
secureNote: normalizedSecureNote,
|
||||||
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
|
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
|
||||||
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
|
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
|
||||||
sshKey: normalizedSshKey,
|
sshKey: normalizedSshKey,
|
||||||
key: optionalEncString(cipher.key),
|
key: responseCipherKey,
|
||||||
|
data: typeof (passthrough as any).data === 'string' ? (passthrough as any).data : null,
|
||||||
encryptedFor: (passthrough as any).encryptedFor ?? null,
|
encryptedFor: (passthrough as any).encryptedFor ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -596,10 +713,11 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Build responses only for the current page to keep pagination cheap.
|
// Build responses only for the current page to keep pagination cheap.
|
||||||
|
const responseOptions = cipherResponseOptionsForRequest(request);
|
||||||
const cipherResponses: CipherResponse[] = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of filteredCiphers) {
|
for (const cipher of filteredCiphers) {
|
||||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
cipherResponses.push(cipherToResponse(cipher, attachments, responseOptions));
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
@@ -619,8 +737,9 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
|
const responseOptions = cipherResponseOptionsForRequest(request);
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, attachments)
|
cipherToResponse(cipher, attachments, responseOptions)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,6 +772,10 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
|
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
|
||||||
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||||
|
|
||||||
|
if (createKey.present && !shouldAcceptCipherKey(createKey.value)) {
|
||||||
|
return errorResponse('Cipher key encryption is not supported by this server. Resync the client and try again.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
// Opaque passthrough: spread ALL client fields to preserve unknown/future ones,
|
// Opaque passthrough: spread ALL client fields to preserve unknown/future ones,
|
||||||
// then override only server-controlled fields.
|
// then override only server-controlled fields.
|
||||||
@@ -670,7 +793,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
};
|
};
|
||||||
cipher.folderId = createFolderId.present ? normalizeOptionalId(createFolderId.value) : normalizeOptionalId(cipher.folderId);
|
cipher.folderId = createFolderId.present ? normalizeOptionalId(createFolderId.value) : normalizeOptionalId(cipher.folderId);
|
||||||
cipher.key = createKey.present ? (createKey.value ?? null) : (cipher.key ?? null);
|
cipher.key = normalizeCipherKeyForStorage(createKey.present ? createKey.value : cipher.key);
|
||||||
cipher.login = createLogin.present ? (createLogin.value ?? null) : (cipher.login ?? null);
|
cipher.login = createLogin.present ? (createLogin.value ?? null) : (cipher.login ?? null);
|
||||||
cipher.card = createCard.present ? (createCard.value ?? null) : (cipher.card ?? null);
|
cipher.card = createCard.present ? (createCard.value ?? null) : (cipher.card ?? null);
|
||||||
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
|
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
|
||||||
@@ -680,6 +803,8 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
||||||
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
||||||
normalizeCipherForStorage(cipher);
|
normalizeCipherForStorage(cipher);
|
||||||
|
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
|
||||||
|
if (compatibilityError) return errorResponse(compatibilityError, 400);
|
||||||
|
|
||||||
// Prevent referencing a folder owned by another user.
|
// Prevent referencing a folder owned by another user.
|
||||||
if (cipher.folderId) {
|
if (cipher.folderId) {
|
||||||
@@ -687,16 +812,13 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
if (!folderOk) return errorResponse('Folder not found', 404);
|
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasMissingLoginUriChecksum(cipher)) {
|
|
||||||
return errorResponse('Login URI checksum is required for item-key encrypted ciphers. Refresh NodeWarden and save the item again.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
const responseOptions = cipherResponseOptionsForRequest(request);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, []),
|
cipherToResponse(cipher, [], responseOptions),
|
||||||
200
|
200
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -730,6 +852,13 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||||
const incomingRevisionDate = readCipherRevisionDate(cipherData);
|
const incomingRevisionDate = readCipherRevisionDate(cipherData);
|
||||||
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(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);
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasAttachmentMigrationMetadata && isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
|
if (!hasAttachmentMigrationMetadata && isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
|
||||||
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
|
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
|
||||||
@@ -739,9 +868,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
|
|
||||||
// Opaque passthrough: merge existing stored data with ALL incoming client fields.
|
// Opaque passthrough: merge existing stored data with ALL incoming client fields.
|
||||||
// Unknown/future fields from the client are preserved; server-controlled fields are protected.
|
// Unknown/future fields from the client are preserved; server-controlled fields are protected.
|
||||||
|
const { preserveRevisionDate: _preserveRevisionDate, PreserveRevisionDate: _pascalPreserveRevisionDate, ...cipherDataWithoutFlags } = cipherData;
|
||||||
const cipher: Cipher = {
|
const cipher: Cipher = {
|
||||||
...existingCipher, // start with all existing stored data (including unknowns)
|
...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)
|
// Server-controlled fields (never from client)
|
||||||
id: existingCipher.id,
|
id: existingCipher.id,
|
||||||
userId: existingCipher.userId,
|
userId: existingCipher.userId,
|
||||||
@@ -749,7 +879,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
favorite: cipherData.favorite ?? existingCipher.favorite,
|
favorite: cipherData.favorite ?? existingCipher.favorite,
|
||||||
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
||||||
createdAt: existingCipher.createdAt,
|
createdAt: existingCipher.createdAt,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: preserveRevisionDate ? existingCipher.updatedAt : new Date().toISOString(),
|
||||||
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
|
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
|
||||||
deletedAt: existingCipher.deletedAt,
|
deletedAt: existingCipher.deletedAt,
|
||||||
};
|
};
|
||||||
@@ -757,7 +887,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
cipher.folderId = normalizeOptionalId(incomingFolderId.value);
|
cipher.folderId = normalizeOptionalId(incomingFolderId.value);
|
||||||
}
|
}
|
||||||
if (incomingKey.present) {
|
if (incomingKey.present) {
|
||||||
cipher.key = incomingKey.value ?? null;
|
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.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.secureNote = nextType === 2 ? (incomingSecureNote.present ? (incomingSecureNote.value ?? null) : (existingCipher.secureNote ?? null)) : null;
|
||||||
@@ -779,6 +912,8 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
cipher.fields = null;
|
cipher.fields = null;
|
||||||
}
|
}
|
||||||
normalizeCipherForStorage(cipher);
|
normalizeCipherForStorage(cipher);
|
||||||
|
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
|
||||||
|
if (compatibilityError) return errorResponse(compatibilityError, 400);
|
||||||
|
|
||||||
// Prevent referencing a folder owned by another user.
|
// Prevent referencing a folder owned by another user.
|
||||||
if (cipher.folderId) {
|
if (cipher.folderId) {
|
||||||
@@ -786,18 +921,15 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
if (!folderOk) return errorResponse('Folder not found', 404);
|
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasMissingLoginUriChecksum(cipher)) {
|
|
||||||
return errorResponse('Login URI checksum is required for item-key encrypted ciphers. Refresh NodeWarden and save the item again.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
await syncIncomingAttachmentMetadata(storage, cipher.id, cipherData);
|
await syncIncomingAttachmentMetadata(storage, cipher.id, cipherData);
|
||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
|
const responseOptions = cipherResponseOptionsForRequest(request);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, attachments)
|
cipherToResponse(cipher, attachments, responseOptions)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,7 +956,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
|||||||
});
|
});
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [])
|
cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,7 +1031,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
|||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [])
|
cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -938,7 +1070,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
|||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [])
|
cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -982,7 +1114,7 @@ async function buildCipherListResponse(
|
|||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
data: ciphers.map((cipher) =>
|
data: ciphers.map((cipher) =>
|
||||||
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [])
|
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], cipherResponseOptionsForRequest(request))
|
||||||
),
|
),
|
||||||
object: 'list',
|
object: 'list',
|
||||||
continuationToken: null,
|
continuationToken: null,
|
||||||
@@ -1015,7 +1147,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
|
|||||||
|
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, attachments)
|
cipherToResponse(cipher, attachments, cipherResponseOptionsForRequest(request))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1037,7 +1169,7 @@ export async function handleUnarchiveCipher(request: Request, env: Env, userId:
|
|||||||
|
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, attachments)
|
cipherToResponse(cipher, attachments, cipherResponseOptionsForRequest(request))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,21 @@ import {
|
|||||||
buildUserDecryptionOptions,
|
buildUserDecryptionOptions,
|
||||||
} from '../utils/user-decryption';
|
} from '../utils/user-decryption';
|
||||||
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
||||||
|
import {
|
||||||
|
assertAccountPasskeyCredential,
|
||||||
|
buildAccountPasskeyTokenUserDecryptionOption,
|
||||||
|
} from './account-passkeys';
|
||||||
|
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
||||||
|
|
||||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||||
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
||||||
|
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
|
||||||
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
|
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
|
||||||
// Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code.
|
// Some UI surfaces use -1 for the recovery-code settings dialog. Login itself follows
|
||||||
// Keep request parsing backward-compatible with historical provider values (8 / 100).
|
// the official Identity provider enum (RecoveryCode = 8), while request parsing remains
|
||||||
|
// compatible with older/local provider values.
|
||||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
|
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
|
||||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY = 8;
|
|
||||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
|
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
|
||||||
|
|
||||||
function resolveTotpSecret(userSecret: string | null): string | null {
|
function resolveTotpSecret(userSecret: string | null): string | null {
|
||||||
@@ -72,6 +78,14 @@ function constantTimeEquals(a: string, b: string): boolean {
|
|||||||
return diff === 0;
|
return diff === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readBodyValue(body: Record<string, string>, names: string[]): string | undefined {
|
||||||
|
for (const name of names) {
|
||||||
|
const value = body[name];
|
||||||
|
if (value != null) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
|
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
|
||||||
const isHttps = new URL(request.url).protocol === 'https:';
|
const isHttps = new URL(request.url).protocol === 'https:';
|
||||||
const parts = [
|
const parts = [
|
||||||
@@ -126,12 +140,13 @@ function buildPreloginResponse(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response {
|
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
|
||||||
const providers = includeRecoveryCode
|
// Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only.
|
||||||
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE]
|
// Clients expose recovery-code entry points themselves; Android 2026.4 fails to
|
||||||
: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
|
// parse the challenge if an unknown recovery provider key such as "8" is included.
|
||||||
const providers2: Record<string, null> = {};
|
const providers = [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
|
||||||
for (const provider of providers) providers2[provider] = null;
|
const providers2: Record<string, { Email: null }> = {};
|
||||||
|
for (const provider of providers) providers2[provider] = { Email: null };
|
||||||
const customResponse = {
|
const customResponse = {
|
||||||
TwoFactorProviders: providers,
|
TwoFactorProviders: providers,
|
||||||
TwoFactorProviders2: providers2,
|
TwoFactorProviders2: providers2,
|
||||||
@@ -224,10 +239,11 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
// Login with password
|
// Login with password
|
||||||
const email = body.username?.toLowerCase();
|
const email = body.username?.toLowerCase();
|
||||||
const passwordHash = body.password;
|
const passwordHash = body.password;
|
||||||
const twoFactorToken = body.twoFactorToken;
|
const authRequestId = readBodyValue(body, ['authRequest', 'AuthRequest']);
|
||||||
const twoFactorProvider = body.twoFactorProvider;
|
const twoFactorToken = readBodyValue(body, ['twoFactorToken', 'TwoFactorToken']);
|
||||||
const twoFactorRemember = body.twoFactorRemember;
|
const twoFactorProvider = readBodyValue(body, ['twoFactorProvider', 'TwoFactorProvider']);
|
||||||
const loginIdentifier = `${clientIdentifier}:${email}`;
|
const twoFactorRemember = readBodyValue(body, ['twoFactorRemember', 'TwoFactorRemember']);
|
||||||
|
const loginIdentifier = clientIdentifier;
|
||||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||||
|
|
||||||
if (!email || !passwordHash) {
|
if (!email || !passwordHash) {
|
||||||
@@ -268,11 +284,31 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
|
let validatedAuthRequestId: string | null = null;
|
||||||
|
let valid = false;
|
||||||
|
const normalizedAuthRequestId = String(authRequestId || '').trim();
|
||||||
|
if (normalizedAuthRequestId) {
|
||||||
|
const authRequest = await storage.getAuthRequestById(normalizedAuthRequestId);
|
||||||
|
valid = !!(
|
||||||
|
authRequest &&
|
||||||
|
authRequest.userId === user.id &&
|
||||||
|
authRequest.type === 0 &&
|
||||||
|
authRequest.approved === true &&
|
||||||
|
authRequest.responseDate &&
|
||||||
|
!authRequest.authenticationDate &&
|
||||||
|
!isAuthRequestExpired(authRequest) &&
|
||||||
|
constantTimeEquals(authRequest.accessCode, passwordHash)
|
||||||
|
);
|
||||||
|
if (valid) {
|
||||||
|
validatedAuthRequestId = authRequest!.id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
|
||||||
|
}
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
await safeWriteAuditEvent(env, {
|
await safeWriteAuditEvent(env, {
|
||||||
actorUserId: user.id,
|
actorUserId: user.id,
|
||||||
action: 'auth.login.failed.bad_password',
|
action: normalizedAuthRequestId ? 'auth.login.failed.bad_auth_request' : 'auth.login.failed.bad_password',
|
||||||
category: 'auth',
|
category: 'auth',
|
||||||
level: 'warn',
|
level: 'warn',
|
||||||
targetType: 'user',
|
targetType: 'user',
|
||||||
@@ -294,7 +330,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
let trustedTwoFactorTokenToReturn: string | undefined;
|
let trustedTwoFactorTokenToReturn: string | undefined;
|
||||||
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
|
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
|
||||||
if (effectiveTotpSecret) {
|
if (effectiveTotpSecret) {
|
||||||
const canUseRecoveryCode = !!user.totpRecoveryCode;
|
|
||||||
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
|
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
|
||||||
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
|
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
|
||||||
let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
|
let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
|
||||||
@@ -304,7 +339,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
|
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
|
||||||
// respond with a 2FA challenge payload.
|
// respond with a 2FA challenge payload.
|
||||||
if (!hasProvider || !hasToken) {
|
if (!hasProvider || !hasToken) {
|
||||||
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
|
return twoFactorRequiredResponse('Two factor required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
let passedByRememberToken = false;
|
let passedByRememberToken = false;
|
||||||
@@ -319,7 +354,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
|
|
||||||
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
|
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
|
||||||
if (!passedByRememberToken) {
|
if (!passedByRememberToken) {
|
||||||
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
|
return twoFactorRequiredResponse('Two factor required.');
|
||||||
}
|
}
|
||||||
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
|
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
|
||||||
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
|
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
|
||||||
@@ -328,7 +363,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE ||
|
normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE ||
|
||||||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY) ||
|
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE) ||
|
||||||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST)
|
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST)
|
||||||
) {
|
) {
|
||||||
if (!recoveryCodeEquals(normalizedTwoFactorToken, user.totpRecoveryCode)) {
|
if (!recoveryCodeEquals(normalizedTwoFactorToken, user.totpRecoveryCode)) {
|
||||||
@@ -371,6 +406,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
|
|
||||||
// Successful login - clear failed attempts
|
// Successful login - clear failed attempts
|
||||||
await rateLimit.clearLoginAttempts(loginIdentifier);
|
await rateLimit.clearLoginAttempts(loginIdentifier);
|
||||||
|
if (validatedAuthRequestId) {
|
||||||
|
await storage.markAuthRequestAuthenticated(validatedAuthRequestId);
|
||||||
|
}
|
||||||
|
|
||||||
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||||
@@ -423,6 +461,126 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||||
: baseResponse;
|
: 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') {
|
} else if (grantType === 'client_credentials') {
|
||||||
// Login with client credentials
|
// Login with client credentials
|
||||||
const clientId = body.client_id;
|
const clientId = body.client_id;
|
||||||
@@ -430,7 +588,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
const scope = body.scope;
|
const scope = body.scope;
|
||||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||||
|
|
||||||
const loginIdentifier = `${clientIdentifier}:${clientId}`;
|
const loginIdentifier = clientIdentifier;
|
||||||
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
|
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
|
||||||
if (!parmValid) {
|
if (!parmValid) {
|
||||||
return identityErrorResponse('Parameter error', 'invalid_request', 400);
|
return identityErrorResponse('Parameter error', 'invalid_request', 400);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { errorResponse, jsonResponse } from '../utils/response';
|
|||||||
import { readActingDeviceIdentifier } from '../utils/device';
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility } from './ciphers';
|
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility, validateCipherEncryptedFieldsForCompatibility } from './ciphers';
|
||||||
|
|
||||||
// Bitwarden client import request format
|
// Bitwarden client import request format
|
||||||
interface CiphersImportRequest {
|
interface CiphersImportRequest {
|
||||||
@@ -252,6 +252,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
};
|
};
|
||||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||||
|
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
|
||||||
|
if (compatibilityError) {
|
||||||
|
return errorResponse(`Cipher ${i + 1}: ${compatibilityError}`, 400);
|
||||||
|
}
|
||||||
|
|
||||||
cipherRows.push(cipher);
|
cipherRows.push(cipher);
|
||||||
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
|
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
|
||||||
|
|||||||
@@ -56,3 +56,18 @@ export async function handleNotificationsHub(request: Request, env: Env): Promis
|
|||||||
}
|
}
|
||||||
return stub.fetch(new Request(forwardedUrl.toString(), request));
|
return stub.fetch(new Request(forwardedUrl.toString(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function handleAnonymousNotificationsHub(request: Request, env: Env): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const authRequestId = String(url.searchParams.get('Token') || url.searchParams.get('token') || '').trim();
|
||||||
|
if (!authRequestId) return errorResponse('Token is required', 400);
|
||||||
|
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
|
||||||
|
return errorResponse('Expected websocket', 426);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = env.NOTIFICATIONS_HUB.idFromName(authRequestId);
|
||||||
|
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||||
|
const forwardedUrl = new URL(request.url);
|
||||||
|
forwardedUrl.searchParams.set('nw_auth_request_id', authRequestId);
|
||||||
|
return stub.fetch(new Request(forwardedUrl.toString(), request));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
|
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { errorResponse } from '../utils/response';
|
import { errorResponse } from '../utils/response';
|
||||||
import { cipherToResponse, isCipherResponseSyncCompatible } from './ciphers';
|
import { cipherToResponse, isCipherResponseSyncCompatible, shouldPreserveRepairableCipherUris } from './ciphers';
|
||||||
import { sendToResponse } from './sends';
|
import { sendToResponse } from './sends';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import {
|
import {
|
||||||
@@ -10,16 +10,25 @@ import {
|
|||||||
buildUserDecryptionOptions,
|
buildUserDecryptionOptions,
|
||||||
} from '../utils/user-decryption';
|
} from '../utils/user-decryption';
|
||||||
import { buildDomainsResponse } from '../services/domain-rules';
|
import { buildDomainsResponse } from '../services/domain-rules';
|
||||||
|
import { buildWebAuthnPrfOption } from '../utils/account-passkeys';
|
||||||
|
|
||||||
// CONTRACT:
|
// CONTRACT:
|
||||||
// /api/sync reuses cipherToResponse() as the single cipher response shaper.
|
// /api/sync reuses cipherToResponse() as the single cipher response shaper.
|
||||||
// Filtering invalid cipher responses here protects clients from stored rows that
|
// Filtering invalid cipher responses here protects clients from stored rows that
|
||||||
// would otherwise make official apps fail after an HTTP 200 sync.
|
// would otherwise make official apps fail after an HTTP 200 sync.
|
||||||
// Keep this aligned with src/handlers/ciphers.ts when adding new vault fields.
|
// 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 url = new URL(request.url);
|
||||||
const cacheUrl = new 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
|
url.origin
|
||||||
);
|
);
|
||||||
return new Request(cacheUrl.toString(), { method: 'GET' });
|
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 excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||||
const excludeSendsParam = url.searchParams.get('excludeSends');
|
const excludeSendsParam = url.searchParams.get('excludeSends');
|
||||||
const excludeSends = excludeSendsParam !== null && /^(1|true|yes)$/i.test(excludeSendsParam);
|
const excludeSends = excludeSendsParam !== null && /^(1|true|yes)$/i.test(excludeSendsParam);
|
||||||
|
const preserveRepairableUris = shouldPreserveRepairableCipherUris(request);
|
||||||
|
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return errorResponse('User not found', 404);
|
return errorResponse('User not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const revisionDate = await storage.getRevisionDate(userId);
|
const [revisionDate, accountPasskeys] = await Promise.all([
|
||||||
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains, excludeSends);
|
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);
|
const cachedResponse = await readSyncCache(cacheRequest);
|
||||||
if (cachedResponse) {
|
if (cachedResponse) {
|
||||||
return 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),
|
excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId),
|
||||||
]);
|
]);
|
||||||
const accountKeys = buildAccountKeys(user);
|
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 = {
|
const profile: ProfileResponse = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -93,7 +117,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
|
|
||||||
const cipherResponses: CipherResponse[] = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of ciphers) {
|
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)) {
|
if (isCipherResponseSyncCompatible(response)) {
|
||||||
cipherResponses.push(response);
|
cipherResponses.push(response);
|
||||||
}
|
}
|
||||||
@@ -130,6 +154,8 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
|
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
|
||||||
TrustedDeviceOption: null,
|
TrustedDeviceOption: null,
|
||||||
KeyConnectorOption: null,
|
KeyConnectorOption: null,
|
||||||
|
WebAuthnPrfOption: webAuthnPrfOptions[0] || null,
|
||||||
|
WebAuthnPrfOptions: webAuthnPrfOptions,
|
||||||
Object: 'userDecryption',
|
Object: 'userDecryption',
|
||||||
},
|
},
|
||||||
UserDecryptionOptions: userDecryptionOptions,
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Env } from './types';
|
import { Env } from './types';
|
||||||
import { NotificationsHub } from './durable/notifications-hub';
|
import { NotificationsHub } from './durable/notifications-hub';
|
||||||
|
import { BackupTransferRunner } from './durable/backup-transfer-runner';
|
||||||
import { handleRequest } from './router';
|
import { handleRequest } from './router';
|
||||||
import { StorageService } from './services/storage';
|
import { StorageService } from './services/storage';
|
||||||
import { applyCors, jsonResponse } from './utils/response';
|
import { applyCors, jsonResponse } from './utils/response';
|
||||||
@@ -127,3 +128,4 @@ export default {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export { NotificationsHub };
|
export { NotificationsHub };
|
||||||
|
export { BackupTransferRunner };
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import {
|
|||||||
handleGetTotpStatus,
|
handleGetTotpStatus,
|
||||||
handleSetTotpStatus,
|
handleSetTotpStatus,
|
||||||
handleGetTotpRecoveryCode,
|
handleGetTotpRecoveryCode,
|
||||||
|
handleGetTwoFactorProviders,
|
||||||
|
handleGetTwoFactorAuthenticator,
|
||||||
|
handlePutTwoFactorAuthenticator,
|
||||||
|
handleDisableTwoFactorProvider,
|
||||||
handleGetApiKey,
|
handleGetApiKey,
|
||||||
handleRotateApiKey,
|
handleRotateApiKey,
|
||||||
} from './handlers/accounts';
|
} from './handlers/accounts';
|
||||||
@@ -66,6 +70,20 @@ import {
|
|||||||
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
||||||
import { handleAdminRoute } from './router-admin';
|
import { handleAdminRoute } from './router-admin';
|
||||||
import { handleGetDomains, handleUpdateDomains } from './handlers/domains';
|
import { handleGetDomains, handleUpdateDomains } from './handlers/domains';
|
||||||
|
import {
|
||||||
|
handleCreateAccountPasskeyCredential,
|
||||||
|
handleDeleteAccountPasskeyCredential,
|
||||||
|
handleGetAccountPasskeyAttestationOptions,
|
||||||
|
handleGetAccountPasskeyCredentials,
|
||||||
|
handleGetAccountPasskeyUpdateAssertionOptions,
|
||||||
|
handleUpdateAccountPasskeyEncryption,
|
||||||
|
} from './handlers/account-passkeys';
|
||||||
|
import {
|
||||||
|
handleGetAuthRequest,
|
||||||
|
handleListAuthRequests,
|
||||||
|
handleListPendingAuthRequests,
|
||||||
|
handleUpdateAuthRequest,
|
||||||
|
} from './handlers/auth-requests';
|
||||||
|
|
||||||
export async function handleAuthenticatedRoute(
|
export async function handleAuthenticatedRoute(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -111,6 +129,25 @@ export async function handleAuthenticatedRoute(
|
|||||||
return handleGetTotpRecoveryCode(request, env, userId);
|
return handleGetTotpRecoveryCode(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === '/api/two-factor') {
|
||||||
|
if (method === 'GET') return handleGetTwoFactorProviders(request, env, userId);
|
||||||
|
return errorResponse('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/two-factor/get-authenticator' && method === 'POST') {
|
||||||
|
return handleGetTwoFactorAuthenticator(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/two-factor/authenticator') {
|
||||||
|
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId);
|
||||||
|
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
|
||||||
|
return errorResponse('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/two-factor/disable' && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return handleDisableTwoFactorProvider(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
||||||
return handleGetRevisionDate(request, env, userId);
|
return handleGetRevisionDate(request, env, userId);
|
||||||
}
|
}
|
||||||
@@ -131,6 +168,28 @@ export async function handleAuthenticatedRoute(
|
|||||||
return handleRotateApiKey(request, env, userId);
|
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') {
|
if (path === '/api/sync' && method === 'GET') {
|
||||||
return handleSync(request, env, userId);
|
return handleSync(request, env, userId);
|
||||||
}
|
}
|
||||||
@@ -232,8 +291,21 @@ export async function handleAuthenticatedRoute(
|
|||||||
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
|
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path.startsWith('/api/auth-requests')) {
|
if (path === '/api/auth-requests' || path === '/api/auth-requests/') {
|
||||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
if (method === 'GET') return handleListAuthRequests(request, env, userId);
|
||||||
|
return errorResponse('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/auth-requests/pending') {
|
||||||
|
if (method === 'GET') return handleListPendingAuthRequests(request, env, userId);
|
||||||
|
return errorResponse('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authRequestMatch = path.match(/^\/api\/auth-requests\/([a-f0-9-]+)$/i);
|
||||||
|
if (authRequestMatch) {
|
||||||
|
if (method === 'GET') return handleGetAuthRequest(request, env, userId, authRequestMatch[1]);
|
||||||
|
if (method === 'PUT') return handleUpdateAuthRequest(request, env, userId, authRequestMatch[1]);
|
||||||
|
return errorResponse('Method not allowed', 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/collections' || path.startsWith('/api/collections/')) {
|
if (path === '/api/collections' || path.startsWith('/api/collections/')) {
|
||||||
|
|||||||
@@ -9,14 +9,20 @@ import {
|
|||||||
} from './handlers/sends';
|
} from './handlers/sends';
|
||||||
import { handleKnownDevice } from './handlers/devices';
|
import { handleKnownDevice } from './handlers/devices';
|
||||||
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
||||||
|
import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys';
|
||||||
import {
|
import {
|
||||||
handleRegister,
|
handleRegister,
|
||||||
handleGetPasswordHint,
|
handleGetPasswordHint,
|
||||||
handleRecoverTwoFactor,
|
handleRecoverTwoFactor,
|
||||||
} from './handlers/accounts';
|
} from './handlers/accounts';
|
||||||
|
import {
|
||||||
|
handleCreateAuthRequest,
|
||||||
|
handleGetAuthRequestResponse,
|
||||||
|
} from './handlers/auth-requests';
|
||||||
import { handlePublicDownloadAttachment } from './handlers/attachments';
|
import { handlePublicDownloadAttachment } from './handlers/attachments';
|
||||||
import { handlePublicUploadAttachment } from './handlers/attachments';
|
import { handlePublicUploadAttachment } from './handlers/attachments';
|
||||||
import {
|
import {
|
||||||
|
handleAnonymousNotificationsHub,
|
||||||
handleNotificationsHub,
|
handleNotificationsHub,
|
||||||
handleNotificationsNegotiate,
|
handleNotificationsNegotiate,
|
||||||
} from './handlers/notifications';
|
} from './handlers/notifications';
|
||||||
@@ -77,82 +83,6 @@ function handleMissingWebsiteIcon(): Response {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPrivateIpv4(hostname: string): boolean {
|
|
||||||
const parts = hostname.split('.').map((part) => Number(part));
|
|
||||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
|
|
||||||
const [a, b] = parts;
|
|
||||||
return (
|
|
||||||
a === 10 ||
|
|
||||||
a === 127 ||
|
|
||||||
(a === 169 && b === 254) ||
|
|
||||||
(a === 172 && b >= 16 && b <= 31) ||
|
|
||||||
(a === 192 && b === 168) ||
|
|
||||||
a === 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBlockedChangePasswordHost(hostname: string): boolean {
|
|
||||||
const normalized = hostname.toLowerCase().replace(/\.+$/, '');
|
|
||||||
return (
|
|
||||||
normalized === 'localhost' ||
|
|
||||||
normalized.endsWith('.localhost') ||
|
|
||||||
normalized.endsWith('.local') ||
|
|
||||||
normalized === '::1' ||
|
|
||||||
normalized.startsWith('[') ||
|
|
||||||
isPrivateIpv4(normalized)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePublicHttpUrl(rawUri: string | null): URL | null {
|
|
||||||
if (!rawUri) return null;
|
|
||||||
try {
|
|
||||||
const url = new URL(rawUri);
|
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
|
||||||
if (isBlockedChangePasswordHost(url.hostname)) return null;
|
|
||||||
return url;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleChangePasswordUri(request: Request): Promise<Response> {
|
|
||||||
const sourceUrl = parsePublicHttpUrl(new URL(request.url).searchParams.get('uri'));
|
|
||||||
if (!sourceUrl) {
|
|
||||||
return jsonResponse({ uri: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
const wellKnownUrl = new URL('/.well-known/change-password', sourceUrl.origin);
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), ICON_UPSTREAM_TIMEOUT_MS);
|
|
||||||
try {
|
|
||||||
const response = await fetch(wellKnownUrl.toString(), {
|
|
||||||
method: 'GET',
|
|
||||||
redirect: 'manual',
|
|
||||||
signal: controller.signal,
|
|
||||||
cf: {
|
|
||||||
cacheEverything: true,
|
|
||||||
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
|
||||||
},
|
|
||||||
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
|
|
||||||
|
|
||||||
if (response.status < 300 || response.status >= 400) {
|
|
||||||
return jsonResponse({ uri: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
const location = response.headers.get('Location');
|
|
||||||
if (!location) return jsonResponse({ uri: null });
|
|
||||||
|
|
||||||
const targetUrl = parsePublicHttpUrl(new URL(location, wellKnownUrl).toString());
|
|
||||||
if (!targetUrl) return jsonResponse({ uri: null });
|
|
||||||
|
|
||||||
return jsonResponse({ uri: targetUrl.toString() });
|
|
||||||
} catch {
|
|
||||||
return jsonResponse({ uri: null });
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildIconServiceBase(origin: string): string {
|
function buildIconServiceBase(origin: string): string {
|
||||||
return `${origin}/icons`;
|
return `${origin}/icons`;
|
||||||
}
|
}
|
||||||
@@ -191,7 +121,7 @@ function buildConfigResponse(origin: string) {
|
|||||||
_icon_service_url: buildIconServiceTemplate(origin),
|
_icon_service_url: buildIconServiceTemplate(origin),
|
||||||
_icon_service_csp: buildIconServiceCsp(origin),
|
_icon_service_csp: buildIconServiceCsp(origin),
|
||||||
featureStates: {
|
featureStates: {
|
||||||
'cipher-key-encryption': true,
|
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
|
||||||
'duo-redirect': true,
|
'duo-redirect': true,
|
||||||
'email-verification': true,
|
'email-verification': true,
|
||||||
'pm-19051-send-email-verification': false,
|
'pm-19051-send-email-verification': false,
|
||||||
@@ -220,6 +150,7 @@ function normalizeIconHost(rawHost: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ICON_UPSTREAM_TIMEOUT_MS = 2500;
|
const ICON_UPSTREAM_TIMEOUT_MS = 2500;
|
||||||
|
const ICON_MAX_BUFFER_BYTES = 256 * 1024;
|
||||||
const BITWARDEN_DEFAULT_GLOBE_ICON_BYTES = 500;
|
const BITWARDEN_DEFAULT_GLOBE_ICON_BYTES = 500;
|
||||||
const BITWARDEN_DEFAULT_GLOBE_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783';
|
const BITWARDEN_DEFAULT_GLOBE_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783';
|
||||||
|
|
||||||
@@ -255,6 +186,55 @@ async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
|
|||||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPositiveContentLength(headers: Headers): number | null {
|
||||||
|
const raw = headers.get('Content-Length');
|
||||||
|
if (!raw) return null;
|
||||||
|
const value = Number(raw);
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readIconBytes(response: Response, maxBytes: number): Promise<ArrayBuffer | null> {
|
||||||
|
if (!response.body) return null;
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
let timedOut = false;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
void reader.cancel().catch(() => undefined);
|
||||||
|
}, ICON_UPSTREAM_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
if (!value) continue;
|
||||||
|
|
||||||
|
totalBytes += value.byteLength;
|
||||||
|
if (totalBytes > maxBytes) {
|
||||||
|
await reader.cancel().catch(() => undefined);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timedOut || totalBytes === 0) return null;
|
||||||
|
|
||||||
|
const output = new ArrayBuffer(totalBytes);
|
||||||
|
const bytes = new Uint8Array(output);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
bytes.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
function iconResponse(body: BodyInit | null, contentType: string | null): Response {
|
function iconResponse(body: BodyInit | null, contentType: string | null): Response {
|
||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -294,19 +274,19 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
|
|||||||
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
||||||
if (!contentType.startsWith('image/')) continue;
|
if (!contentType.startsWith('image/')) continue;
|
||||||
|
|
||||||
if (!source.rejectImage) {
|
const contentLength = getPositiveContentLength(resp.headers);
|
||||||
return iconResponse(resp.body, resp.headers.get('Content-Type'));
|
if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
|
||||||
}
|
|
||||||
|
|
||||||
const contentLength = Number(resp.headers.get('Content-Length') || '');
|
const bytes = await readIconBytes(resp, ICON_MAX_BUFFER_BYTES);
|
||||||
if (Number.isFinite(contentLength) && contentLength > 0 && contentLength !== source.rejectImage.byteLength) {
|
if (!bytes) continue;
|
||||||
return iconResponse(resp.body, resp.headers.get('Content-Type'));
|
if (
|
||||||
|
source.rejectImage &&
|
||||||
|
bytes.byteLength === source.rejectImage.byteLength &&
|
||||||
|
(await sha256Hex(bytes)) === source.rejectImage.sha256
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bytes = await resp.arrayBuffer();
|
|
||||||
if (bytes.byteLength === 0) continue;
|
|
||||||
if (bytes.byteLength === source.rejectImage.byteLength && (await sha256Hex(bytes)) === source.rejectImage.sha256) continue;
|
|
||||||
|
|
||||||
return iconResponse(bytes, resp.headers.get('Content-Type'));
|
return iconResponse(bytes, resp.headers.get('Content-Type'));
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
@@ -360,14 +340,10 @@ export async function handlePublicRoute(
|
|||||||
return jsonResponse(await buildWebBootstrapResponse(env));
|
return jsonResponse(await buildWebBootstrapResponse(env));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/icons/change-password-uri' && method === 'GET') {
|
|
||||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
|
||||||
if (blocked) return blocked;
|
|
||||||
return handleChangePasswordUri(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||||
if (iconMatch && method === 'GET') {
|
if (iconMatch && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
||||||
return handleWebsiteIcon(iconMatch[1], fallbackMode);
|
return handleWebsiteIcon(iconMatch[1], fallbackMode);
|
||||||
}
|
}
|
||||||
@@ -419,6 +395,19 @@ export async function handlePublicRoute(
|
|||||||
return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]);
|
return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/auth-requests' || path === '/api/auth-requests/') && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleCreateAuthRequest(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authRequestResponseMatch = path.match(/^\/api\/auth-requests\/([a-f0-9-]+)\/response$/i);
|
||||||
|
if (authRequestResponseMatch && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleGetAuthRequestResponse(request, env, authRequestResponseMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/identity/connect/token' && method === 'POST') {
|
if (path === '/identity/connect/token' && method === 'POST') {
|
||||||
return handleToken(request, env);
|
return handleToken(request, env);
|
||||||
}
|
}
|
||||||
@@ -452,6 +441,12 @@ export async function handlePublicRoute(
|
|||||||
return handlePrelogin(request, env);
|
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') {
|
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
|
||||||
return handleRecoverTwoFactor(request, env);
|
return handleRecoverTwoFactor(request, env);
|
||||||
}
|
}
|
||||||
@@ -500,5 +495,9 @@ export async function handlePublicRoute(
|
|||||||
if (path === '/notifications/hub' && method === 'GET') {
|
if (path === '/notifications/hub' && method === 'GET') {
|
||||||
return handleNotificationsHub(request, env);
|
return handleNotificationsHub(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === '/notifications/anonymous-hub' && method === 'GET') {
|
||||||
|
return handleAnonymousNotificationsHub(request, env);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ const ALLOWED_METADATA_KEYS = new Set([
|
|||||||
'skippedReason',
|
'skippedReason',
|
||||||
'replaceExisting',
|
'replaceExisting',
|
||||||
'provider',
|
'provider',
|
||||||
|
'prfStatus',
|
||||||
'fileName',
|
'fileName',
|
||||||
'fileBytes',
|
'fileBytes',
|
||||||
'bytes',
|
'bytes',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { StorageService } from './storage';
|
|||||||
// The client already does heavy PBKDF2 (600k iterations).
|
// The client already does heavy PBKDF2 (600k iterations).
|
||||||
// This second layer only needs to be non-trivial, not expensive.
|
// This second layer only needs to be non-trivial, not expensive.
|
||||||
const SERVER_HASH_ITERATIONS = 100_000;
|
const SERVER_HASH_ITERATIONS = 100_000;
|
||||||
|
const SERVER_HASH_PREFIX = '$s$';
|
||||||
const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000;
|
const AUTH_CONTEXT_CACHE_TTL_MS = 15 * 1000;
|
||||||
|
|
||||||
interface CachedUserEntry {
|
interface CachedUserEntry {
|
||||||
@@ -133,7 +134,7 @@ export class AuthService {
|
|||||||
|
|
||||||
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
|
// Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
|
||||||
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
|
// Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
|
||||||
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes.
|
// Result is prefixed to distinguish server-hashed credentials from invalid legacy rows.
|
||||||
async hashPasswordServer(clientHash: string, email: string): Promise<string> {
|
async hashPasswordServer(clientHash: string, email: string): Promise<string> {
|
||||||
const keyMaterial = await crypto.subtle.importKey(
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
@@ -151,19 +152,16 @@ export class AuthService {
|
|||||||
const bytes = new Uint8Array(bits);
|
const bytes = new Uint8Array(bits);
|
||||||
let binary = '';
|
let binary = '';
|
||||||
for (const b of bytes) binary += String.fromCharCode(b);
|
for (const b of bytes) binary += String.fromCharCode(b);
|
||||||
return '$s$' + btoa(binary);
|
return SERVER_HASH_PREFIX + btoa(binary);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify password: hash the input the same way, then constant-time compare.
|
// Verify password: new rows use server-side hashing; legacy rows store the raw client hash.
|
||||||
async verifyPassword(inputHash: string, storedHash: string, email?: string): Promise<boolean> {
|
async verifyPassword(inputHash: string, storedHash: string, email: string): Promise<boolean> {
|
||||||
// New server-hashed passwords are prefixed with "$s$".
|
if (!storedHash.startsWith(SERVER_HASH_PREFIX)) {
|
||||||
// Legacy accounts (created before the upgrade) store raw client hashes without prefix.
|
return this.constantTimeEquals(inputHash, storedHash);
|
||||||
if (email && storedHash.startsWith('$s$')) {
|
|
||||||
const serverHash = await this.hashPasswordServer(inputHash, email);
|
|
||||||
return this.constantTimeEquals(serverHash, storedHash);
|
|
||||||
}
|
}
|
||||||
// Legacy path: direct constant-time comparison of raw client hashes.
|
const serverHash = await this.hashPasswordServer(inputHash, email);
|
||||||
return this.constantTimeEquals(inputHash, storedHash);
|
return this.constantTimeEquals(serverHash, storedHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
private constantTimeEquals(a: string, b: string): boolean {
|
private constantTimeEquals(a: string, b: string): boolean {
|
||||||
@@ -254,19 +252,22 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let device: { identifier: string; sessionStamp: string } | null = null;
|
let device: { identifier: string; sessionStamp: string } | null = null;
|
||||||
if (record.deviceIdentifier) {
|
if (!record.deviceIdentifier || !record.deviceSessionStamp) {
|
||||||
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
if (!boundDevice) {
|
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||||
await this.storage.deleteRefreshToken(refreshToken);
|
|
||||||
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
|
||||||
}
|
|
||||||
if (!record.deviceSessionStamp || boundDevice.sessionStamp !== record.deviceSessionStamp) {
|
|
||||||
await this.storage.deleteRefreshToken(refreshToken);
|
|
||||||
return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
|
||||||
}
|
|
||||||
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
|
||||||
|
if (!boundDevice) {
|
||||||
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
|
return { ok: false, reason: 'device_missing', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||||
|
}
|
||||||
|
if (boundDevice.sessionStamp !== record.deviceSessionStamp) {
|
||||||
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
|
return { ok: false, reason: 'device_session_mismatch', userId: user.id, deviceIdentifier: record.deviceIdentifier };
|
||||||
|
}
|
||||||
|
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
|
||||||
|
|
||||||
const accessToken = await this.generateAccessToken(user, device);
|
const accessToken = await this.generateAccessToken(user, device);
|
||||||
return { ok: true, accessToken, user, device };
|
return { ok: true, accessToken, user, device };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export interface BackupPayload {
|
|||||||
folders: SqlRow[];
|
folders: SqlRow[];
|
||||||
ciphers: SqlRow[];
|
ciphers: SqlRow[];
|
||||||
attachments: SqlRow[];
|
attachments: SqlRow[];
|
||||||
|
webauthn_credentials?: SqlRow[];
|
||||||
|
trusted_two_factor_device_tokens?: SqlRow[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +302,8 @@ export function validateBackupPayloadContents(
|
|||||||
const folderRows = ensureRowArray(payload.db.folders, 'folders');
|
const folderRows = ensureRowArray(payload.db.folders, 'folders');
|
||||||
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
||||||
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
||||||
|
const accountPasskeyRows = ensureRowArray(payload.db.webauthn_credentials || [], 'webauthn_credentials');
|
||||||
|
const trustedTwoFactorTokenRows = ensureRowArray(payload.db.trusted_two_factor_device_tokens || [], 'trusted_two_factor_device_tokens');
|
||||||
const externalAttachmentKeys = new Set<string>(
|
const externalAttachmentKeys = new Set<string>(
|
||||||
options.allowExternalAttachmentBlobs
|
options.allowExternalAttachmentBlobs
|
||||||
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
|
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
|
||||||
@@ -372,6 +376,37 @@ export function validateBackupPayloadContents(
|
|||||||
throw new Error(`Backup archive is missing required file: attachments/${cipherId}/${id}.bin`);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trustedTwoFactorTokens = new Set<string>();
|
||||||
|
for (const row of trustedTwoFactorTokenRows) {
|
||||||
|
const token = String(row.token || '').trim();
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
const deviceIdentifier = String(row.device_identifier || '').trim();
|
||||||
|
const expiresAt = Number(row.expires_at || 0);
|
||||||
|
if (!token || !userIds.has(userId) || !deviceIdentifier || !Number.isFinite(expiresAt) || expiresAt <= 0) {
|
||||||
|
throw new Error('Backup archive contains an invalid trusted two-factor device token row');
|
||||||
|
}
|
||||||
|
if (trustedTwoFactorTokens.has(token)) {
|
||||||
|
throw new Error(`Backup archive contains duplicate trusted two-factor device token: ${token}`);
|
||||||
|
}
|
||||||
|
trustedTwoFactorTokens.add(token);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildBackupArchive(
|
export async function buildBackupArchive(
|
||||||
@@ -390,7 +425,7 @@ export async function buildBackupArchive(
|
|||||||
includeAttachments,
|
includeAttachments,
|
||||||
});
|
});
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
|
const [configRows, userRows, domainSettingsRows, revisionRows, folderRows, cipherRows, attachmentRows, accountPasskeyRows, trustedTwoFactorTokenRows] = await Promise.all([
|
||||||
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
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 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'),
|
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 +433,8 @@ 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, 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, 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, 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'),
|
||||||
|
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
|
||||||
]);
|
]);
|
||||||
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
||||||
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
||||||
@@ -425,6 +462,8 @@ export async function buildBackupArchive(
|
|||||||
folders: folderRows.length,
|
folders: folderRows.length,
|
||||||
ciphers: cipherRows.length,
|
ciphers: cipherRows.length,
|
||||||
attachments: exportedAttachmentRows.length,
|
attachments: exportedAttachmentRows.length,
|
||||||
|
webauthn_credentials: accountPasskeyRows.length,
|
||||||
|
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows.length,
|
||||||
},
|
},
|
||||||
includes: {
|
includes: {
|
||||||
attachments: includeAttachments,
|
attachments: includeAttachments,
|
||||||
@@ -447,6 +486,8 @@ export async function buildBackupArchive(
|
|||||||
folders: folderRows,
|
folders: folderRows,
|
||||||
ciphers: cipherRows,
|
ciphers: cipherRows,
|
||||||
attachments: exportedAttachmentRows,
|
attachments: exportedAttachmentRows,
|
||||||
|
webauthn_credentials: accountPasskeyRows,
|
||||||
|
trusted_two_factor_device_tokens: trustedTwoFactorTokenRows,
|
||||||
}, null, BACKUP_JSON_INDENT)),
|
}, null, BACKUP_JSON_INDENT)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type BackupRuntimeState,
|
type BackupRuntimeState,
|
||||||
type BackupScheduleConfig,
|
type BackupScheduleConfig,
|
||||||
type BackupSettings,
|
type BackupSettings,
|
||||||
|
type S3BackupAddressingStyle,
|
||||||
type S3BackupDestination,
|
type S3BackupDestination,
|
||||||
type WebDavBackupDestination,
|
type WebDavBackupDestination,
|
||||||
createBackupRandomId,
|
createBackupRandomId,
|
||||||
@@ -35,6 +36,7 @@ export type {
|
|||||||
BackupRuntimeState,
|
BackupRuntimeState,
|
||||||
BackupScheduleConfig,
|
BackupScheduleConfig,
|
||||||
BackupSettings,
|
BackupSettings,
|
||||||
|
S3BackupAddressingStyle,
|
||||||
S3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
} from '../../shared/backup-schema';
|
} from '../../shared/backup-schema';
|
||||||
@@ -109,6 +111,9 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
|||||||
const source = isPlainObject(value) ? value : {};
|
const source = isPlainObject(value) ? value : {};
|
||||||
const endpoint = asTrimmedString(source.endpoint);
|
const endpoint = asTrimmedString(source.endpoint);
|
||||||
const bucket = asTrimmedString(source.bucket);
|
const bucket = asTrimmedString(source.bucket);
|
||||||
|
const addressingStyleRaw = asTrimmedString(source.addressingStyle);
|
||||||
|
const addressingStyle: S3BackupAddressingStyle =
|
||||||
|
addressingStyleRaw === 'virtual-hosted-style' ? 'virtual-hosted-style' : 'path-style';
|
||||||
const accessKeyId = asTrimmedString(source.accessKeyId);
|
const accessKeyId = asTrimmedString(source.accessKeyId);
|
||||||
const secretAccessKey = asTrimmedString(source.secretAccessKey);
|
const secretAccessKey = asTrimmedString(source.secretAccessKey);
|
||||||
const region = asTrimmedString(source.region) || 'auto';
|
const region = asTrimmedString(source.region) || 'auto';
|
||||||
@@ -131,6 +136,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
|||||||
return {
|
return {
|
||||||
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
|
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
|
||||||
bucket,
|
bucket,
|
||||||
|
addressingStyle,
|
||||||
region,
|
region,
|
||||||
accessKeyId,
|
accessKeyId,
|
||||||
secretAccessKey,
|
secretAccessKey,
|
||||||
@@ -409,13 +415,6 @@ export async function loadBackupSettings(storage: StorageService, env: Env, fall
|
|||||||
|
|
||||||
export async function saveBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
|
export async function saveBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
|
||||||
const users = await storage.getAllUsers();
|
const users = await storage.getAllUsers();
|
||||||
const hasPortableAdmins = users.some(
|
|
||||||
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
|
||||||
);
|
|
||||||
if (!hasPortableAdmins) {
|
|
||||||
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, serializeBackupSettings(settings));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||||
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
|
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
|
||||||
}
|
}
|
||||||
@@ -442,12 +441,6 @@ export async function normalizeImportedBackupSettingsValue(
|
|||||||
try {
|
try {
|
||||||
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||||
const settings = parseBackupSettings(decrypted, fallbackTimezone);
|
const settings = parseBackupSettings(decrypted, fallbackTimezone);
|
||||||
const hasPortableAdmins = users.some(
|
|
||||||
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
|
||||||
);
|
|
||||||
if (!hasPortableAdmins) {
|
|
||||||
return serializeBackupSettings(settings);
|
|
||||||
}
|
|
||||||
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||||
} catch {
|
} catch {
|
||||||
// Keep imported portable recovery data intact until an admin signs in and repairs it.
|
// Keep imported portable recovery data intact until an admin signs in and repairs it.
|
||||||
@@ -455,12 +448,6 @@ export async function normalizeImportedBackupSettingsValue(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const settings = parseBackupSettings(raw, fallbackTimezone);
|
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||||
const hasPortableAdmins = users.some(
|
|
||||||
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
|
||||||
);
|
|
||||||
if (!hasPortableAdmins) {
|
|
||||||
return serializeBackupSettings(settings);
|
|
||||||
}
|
|
||||||
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ type BackupTableName =
|
|||||||
| 'users'
|
| 'users'
|
||||||
| 'domain_settings'
|
| 'domain_settings'
|
||||||
| 'user_revisions'
|
| 'user_revisions'
|
||||||
|
| 'trusted_two_factor_device_tokens'
|
||||||
|
| 'webauthn_credentials'
|
||||||
| 'folders'
|
| 'folders'
|
||||||
| 'ciphers'
|
| 'ciphers'
|
||||||
| 'attachments';
|
| 'attachments';
|
||||||
@@ -33,6 +35,8 @@ const BACKUP_TABLES: BackupTableName[] = [
|
|||||||
'users',
|
'users',
|
||||||
'domain_settings',
|
'domain_settings',
|
||||||
'user_revisions',
|
'user_revisions',
|
||||||
|
'trusted_two_factor_device_tokens',
|
||||||
|
'webauthn_credentials',
|
||||||
'folders',
|
'folders',
|
||||||
'ciphers',
|
'ciphers',
|
||||||
'attachments',
|
'attachments',
|
||||||
@@ -49,6 +53,8 @@ export interface BackupImportResultBody {
|
|||||||
users: number;
|
users: number;
|
||||||
domainSettings: number;
|
domainSettings: number;
|
||||||
userRevisions: number;
|
userRevisions: number;
|
||||||
|
trustedTwoFactorDeviceTokens: number;
|
||||||
|
webauthnCredentials: number;
|
||||||
folders: number;
|
folders: number;
|
||||||
ciphers: number;
|
ciphers: number;
|
||||||
attachments: number;
|
attachments: number;
|
||||||
@@ -168,6 +174,8 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
|
|||||||
'DELETE FROM attachments',
|
'DELETE FROM attachments',
|
||||||
'DELETE FROM ciphers',
|
'DELETE FROM ciphers',
|
||||||
'DELETE FROM folders',
|
'DELETE FROM folders',
|
||||||
|
'DELETE FROM webauthn_credentials',
|
||||||
|
'DELETE FROM trusted_two_factor_device_tokens',
|
||||||
'DELETE FROM domain_settings',
|
'DELETE FROM domain_settings',
|
||||||
'DELETE FROM user_revisions',
|
'DELETE FROM user_revisions',
|
||||||
'DELETE FROM users',
|
'DELETE FROM users',
|
||||||
@@ -292,6 +300,8 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
|
|||||||
})),
|
})),
|
||||||
domain_settings: cloneRows(payload.domain_settings || []),
|
domain_settings: cloneRows(payload.domain_settings || []),
|
||||||
user_revisions: cloneRows(payload.user_revisions || []),
|
user_revisions: cloneRows(payload.user_revisions || []),
|
||||||
|
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
|
||||||
|
webauthn_credentials: cloneRows(payload.webauthn_credentials || []),
|
||||||
folders: cloneRows(payload.folders || []),
|
folders: cloneRows(payload.folders || []),
|
||||||
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
@@ -629,6 +639,26 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
|||||||
true
|
true
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('trusted_two_factor_device_tokens'),
|
||||||
|
buildInsertStatements(
|
||||||
|
db,
|
||||||
|
tableName('trusted_two_factor_device_tokens'),
|
||||||
|
['token', 'user_id', 'device_identifier', 'expires_at'],
|
||||||
|
payload.trusted_two_factor_device_tokens || []
|
||||||
|
)
|
||||||
|
);
|
||||||
|
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(
|
await runInsertBatch(
|
||||||
db,
|
db,
|
||||||
tableName('folders'),
|
tableName('folders'),
|
||||||
@@ -697,6 +727,8 @@ export async function importBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domain_settings: (db.domain_settings || []).length,
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
|
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
attachments: (db.attachments || []).length,
|
attachments: (db.attachments || []).length,
|
||||||
@@ -719,6 +751,8 @@ export async function importBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domain_settings: (db.domain_settings || []).length,
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
|
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
attachments: restored.restoredAttachments.length,
|
attachments: restored.restoredAttachments.length,
|
||||||
@@ -759,6 +793,8 @@ export async function importBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domainSettings: (db.domain_settings || []).length,
|
domainSettings: (db.domain_settings || []).length,
|
||||||
userRevisions: (db.user_revisions || []).length,
|
userRevisions: (db.user_revisions || []).length,
|
||||||
|
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
|
webauthnCredentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
attachments: restored.restoredAttachments.length,
|
attachments: restored.restoredAttachments.length,
|
||||||
@@ -835,6 +871,8 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domain_settings: (db.domain_settings || []).length,
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
|
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
attachments: (db.attachments || []).length,
|
attachments: (db.attachments || []).length,
|
||||||
@@ -857,6 +895,8 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domain_settings: (db.domain_settings || []).length,
|
domain_settings: (db.domain_settings || []).length,
|
||||||
user_revisions: (db.user_revisions || []).length,
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
trusted_two_factor_device_tokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
|
webauthn_credentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
attachments: restored.restoredAttachments.length,
|
attachments: restored.restoredAttachments.length,
|
||||||
@@ -903,6 +943,8 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
users: (db.users || []).length,
|
users: (db.users || []).length,
|
||||||
domainSettings: (db.domain_settings || []).length,
|
domainSettings: (db.domain_settings || []).length,
|
||||||
userRevisions: (db.user_revisions || []).length,
|
userRevisions: (db.user_revisions || []).length,
|
||||||
|
trustedTwoFactorDeviceTokens: (db.trusted_two_factor_device_tokens || []).length,
|
||||||
|
webauthnCredentials: (db.webauthn_credentials || []).length,
|
||||||
folders: (db.folders || []).length,
|
folders: (db.folders || []).length,
|
||||||
ciphers: (db.ciphers || []).length,
|
ciphers: (db.ciphers || []).length,
|
||||||
attachments: restored.restoredAttachments.length,
|
attachments: restored.restoredAttachments.length,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type { Env, User } from '../types';
|
|||||||
// server's scheduled backup runner.
|
// server's scheduled backup runner.
|
||||||
// - portable: AES-GCM encrypted with a random DEK; that DEK is RSA-wrapped for
|
// - portable: AES-GCM encrypted with a random DEK; that DEK is RSA-wrapped for
|
||||||
// active admin public keys so settings can be repaired after restore/migration.
|
// active admin public keys so settings can be repaired after restore/migration.
|
||||||
|
// Historical/imported databases may not have usable admin public keys; in that
|
||||||
|
// case portable.wraps is empty but the runtime ciphertext is still encrypted.
|
||||||
//
|
//
|
||||||
// New admin-entered provider secrets, such as mail API keys, should use this
|
// New admin-entered provider secrets, such as mail API keys, should use this
|
||||||
// pattern or a deliberately documented replacement. Do not store provider
|
// pattern or a deliberately documented replacement. Do not store provider
|
||||||
@@ -186,9 +188,6 @@ export async function encryptBackupSettingsEnvelope(
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const eligibleUsers = getEligiblePortableUsers(users);
|
const eligibleUsers = getEligiblePortableUsers(users);
|
||||||
if (!eligibleUsers.length) {
|
|
||||||
throw new Error('No active administrator public keys are available for backup settings recovery');
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
|
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
|
||||||
const runtime = await encryptAesGcm(encoder.encode(plaintext), runtimeKey);
|
const runtime = await encryptAesGcm(encoder.encode(plaintext), runtimeKey);
|
||||||
@@ -205,18 +204,22 @@ export async function encryptBackupSettingsEnvelope(
|
|||||||
|
|
||||||
const wraps: BackupSettingsPortableWrap[] = [];
|
const wraps: BackupSettingsPortableWrap[] = [];
|
||||||
for (const user of eligibleUsers) {
|
for (const user of eligibleUsers) {
|
||||||
const publicKey = await importPortablePublicKey(user.publicKey!);
|
try {
|
||||||
const wrappedKey = new Uint8Array(
|
const publicKey = await importPortablePublicKey(user.publicKey!);
|
||||||
await crypto.subtle.encrypt(
|
const wrappedKey = new Uint8Array(
|
||||||
{ name: PORTABLE_ALGORITHM },
|
await crypto.subtle.encrypt(
|
||||||
publicKey,
|
{ name: PORTABLE_ALGORITHM },
|
||||||
portableDek
|
publicKey,
|
||||||
)
|
portableDek
|
||||||
);
|
)
|
||||||
wraps.push({
|
);
|
||||||
userId: user.id,
|
wraps.push({
|
||||||
wrappedKey: bytesToBase64(wrappedKey),
|
userId: user.id,
|
||||||
});
|
wrappedKey: bytesToBase64(wrappedKey),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Keep runtime settings usable even if an imported admin key is malformed.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const envelope: BackupSettingsEnvelopeV2 = {
|
const envelope: BackupSettingsEnvelopeV2 = {
|
||||||
|
|||||||
@@ -448,8 +448,27 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean {
|
||||||
|
const hostname = endpoint.hostname.toLowerCase();
|
||||||
|
const bucketName = bucket.trim().toLowerCase();
|
||||||
|
return !!bucketName && (hostname === bucketName || hostname.startsWith(`${bucketName}.`));
|
||||||
|
}
|
||||||
|
|
||||||
function s3BucketBaseUrl(config: S3BackupDestination): URL {
|
function s3BucketBaseUrl(config: S3BackupDestination): URL {
|
||||||
return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`);
|
const endpoint = new URL(config.endpoint.replace(/\/+$/, ''));
|
||||||
|
const bucket = config.bucket.trim();
|
||||||
|
|
||||||
|
if (config.addressingStyle === 'virtual-hosted-style') {
|
||||||
|
if (isBucketHostedS3Endpoint(endpoint, bucket)) return endpoint;
|
||||||
|
endpoint.hostname = `${bucket}.${endpoint.hostname}`;
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URL(`${endpoint.toString().replace(/\/+$/, '')}/${encodeURIComponent(bucket)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function s3ObjectUrl(config: S3BackupDestination, objectKey: string): URL {
|
||||||
|
return new URL(`${s3BucketBaseUrl(config).toString().replace(/\/+$/, '')}/${encodePathSegments(objectKey)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string {
|
function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string {
|
||||||
@@ -501,7 +520,7 @@ async function putToS3(
|
|||||||
options: RemoteBackupFilePutOptions = {}
|
options: RemoteBackupFilePutOptions = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
const url = s3ObjectUrl(config, objectKey);
|
||||||
const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType);
|
const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -594,7 +613,7 @@ async function downloadFromS3(config: S3BackupDestination, relativePath: string)
|
|||||||
throw new Error('Please select a backup file');
|
throw new Error('Please select a backup file');
|
||||||
}
|
}
|
||||||
const objectKey = normalizeS3ObjectKey(config, normalized);
|
const objectKey = normalizeS3ObjectKey(config, normalized);
|
||||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
const url = s3ObjectUrl(config, objectKey);
|
||||||
const response = await signedS3Request(config, 'GET', url);
|
const response = await signedS3Request(config, 'GET', url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`S3 download failed: ${response.status}`);
|
throw new Error(`S3 download failed: ${response.status}`);
|
||||||
@@ -610,7 +629,7 @@ async function downloadFromS3(config: S3BackupDestination, relativePath: string)
|
|||||||
|
|
||||||
async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> {
|
async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> {
|
||||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
const url = s3ObjectUrl(config, objectKey);
|
||||||
const response = await signedS3Request(config, 'DELETE', url);
|
const response = await signedS3Request(config, 'DELETE', url);
|
||||||
if (!response.ok && response.status !== 404) {
|
if (!response.ok && response.status !== 404) {
|
||||||
throw new Error(`S3 delete failed: ${response.status}`);
|
throw new Error(`S3 delete failed: ${response.status}`);
|
||||||
@@ -619,7 +638,7 @@ async function deleteFromS3(config: S3BackupDestination, relativePath: string):
|
|||||||
|
|
||||||
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
|
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
|
||||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||||
const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
const url = s3ObjectUrl(config, objectKey);
|
||||||
const response = await signedS3Request(config, 'HEAD', url);
|
const response = await signedS3Request(config, 'HEAD', url);
|
||||||
if (response.status === 404) return false;
|
if (response.status === 404) return false;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { AuthRequestRecord, AuthRequestType } from '../types';
|
||||||
|
|
||||||
|
const AUTH_REQUEST_EXPIRATION_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
function mapAuthRequestRow(row: any): AuthRequestRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
organizationId: row.organization_id ?? null,
|
||||||
|
type: Number(row.type) as AuthRequestType,
|
||||||
|
requestDeviceIdentifier: row.request_device_identifier,
|
||||||
|
requestDeviceType: Number(row.request_device_type ?? 14),
|
||||||
|
requestIpAddress: row.request_ip_address ?? null,
|
||||||
|
requestCountryName: row.request_country_name ?? null,
|
||||||
|
responseDeviceIdentifier: row.response_device_identifier ?? null,
|
||||||
|
accessCode: row.access_code,
|
||||||
|
publicKey: row.public_key,
|
||||||
|
key: row.key ?? null,
|
||||||
|
masterPasswordHash: row.master_password_hash ?? null,
|
||||||
|
approved: row.approved == null ? null : Number(row.approved) === 1,
|
||||||
|
creationDate: row.creation_date,
|
||||||
|
responseDate: row.response_date ?? null,
|
||||||
|
authenticationDate: row.authentication_date ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthRequestExpired(request: AuthRequestRecord, nowMs: number = Date.now()): boolean {
|
||||||
|
return new Date(request.creationDate).getTime() + AUTH_REQUEST_EXPIRATION_MS <= nowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTH_REQUEST_SELECT =
|
||||||
|
'SELECT id, user_id, organization_id, type, request_device_identifier, request_device_type, request_ip_address, request_country_name, ' +
|
||||||
|
'response_device_identifier, access_code, public_key, key, master_password_hash, approved, creation_date, response_date, authentication_date ' +
|
||||||
|
'FROM auth_requests';
|
||||||
|
|
||||||
|
export async function createAuthRequest(db: D1Database, request: AuthRequestRecord): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO auth_requests(' +
|
||||||
|
'id, user_id, organization_id, type, request_device_identifier, request_device_type, request_ip_address, request_country_name, ' +
|
||||||
|
'response_device_identifier, access_code, public_key, key, master_password_hash, approved, creation_date, response_date, authentication_date' +
|
||||||
|
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
request.id,
|
||||||
|
request.userId,
|
||||||
|
request.organizationId,
|
||||||
|
request.type,
|
||||||
|
request.requestDeviceIdentifier,
|
||||||
|
request.requestDeviceType,
|
||||||
|
request.requestIpAddress,
|
||||||
|
request.requestCountryName,
|
||||||
|
request.responseDeviceIdentifier,
|
||||||
|
request.accessCode,
|
||||||
|
request.publicKey,
|
||||||
|
request.key,
|
||||||
|
request.masterPasswordHash,
|
||||||
|
request.approved == null ? null : (request.approved ? 1 : 0),
|
||||||
|
request.creationDate,
|
||||||
|
request.responseDate,
|
||||||
|
request.authenticationDate
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuthRequestById(db: D1Database, id: string): Promise<AuthRequestRecord | null> {
|
||||||
|
const row = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE id = ? LIMIT 1`).bind(id).first<any>();
|
||||||
|
return row ? mapAuthRequestRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAuthRequestsByUserId(db: D1Database, userId: string): Promise<AuthRequestRecord[]> {
|
||||||
|
const res = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE user_id = ? ORDER BY creation_date DESC`).bind(userId).all<any>();
|
||||||
|
return (res.results || []).map(mapAuthRequestRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPendingAuthRequestsByUserId(db: D1Database, userId: string, nowMs: number = Date.now()): Promise<AuthRequestRecord[]> {
|
||||||
|
const cutoff = new Date(nowMs - AUTH_REQUEST_EXPIRATION_MS).toISOString();
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT ar.id, ar.user_id, ar.organization_id, ar.type, ar.request_device_identifier, ar.request_device_type, ar.request_ip_address, ar.request_country_name, ' +
|
||||||
|
'ar.response_device_identifier, ar.access_code, ar.public_key, ar.key, ar.master_password_hash, ar.approved, ar.creation_date, ar.response_date, ar.authentication_date ' +
|
||||||
|
'FROM auth_requests ar ' +
|
||||||
|
'JOIN (' +
|
||||||
|
' SELECT request_device_identifier, MAX(creation_date) AS latest_creation_date ' +
|
||||||
|
' FROM auth_requests ' +
|
||||||
|
' WHERE user_id = ? AND type IN (0, 1) AND approved IS NULL AND response_date IS NULL AND authentication_date IS NULL AND creation_date >= ? ' +
|
||||||
|
' GROUP BY request_device_identifier' +
|
||||||
|
') latest ON latest.request_device_identifier = ar.request_device_identifier AND latest.latest_creation_date = ar.creation_date ' +
|
||||||
|
'WHERE ar.user_id = ? AND ar.type IN (0, 1) AND ar.approved IS NULL AND ar.response_date IS NULL AND ar.authentication_date IS NULL ' +
|
||||||
|
'ORDER BY ar.creation_date DESC'
|
||||||
|
)
|
||||||
|
.bind(userId, cutoff, userId)
|
||||||
|
.all<any>();
|
||||||
|
return (res.results || []).map(mapAuthRequestRow).filter((request) => !isAuthRequestExpired(request, nowMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAuthRequestResponse(
|
||||||
|
db: D1Database,
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
update: {
|
||||||
|
approved: boolean;
|
||||||
|
responseDeviceIdentifier: string;
|
||||||
|
key?: string | null;
|
||||||
|
masterPasswordHash?: string | null;
|
||||||
|
responseDate?: string;
|
||||||
|
}
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
'UPDATE auth_requests SET approved = ?, response_device_identifier = ?, key = ?, master_password_hash = ?, response_date = ? ' +
|
||||||
|
'WHERE id = ? AND user_id = ? AND approved IS NULL AND response_date IS NULL AND authentication_date IS NULL'
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
update.approved ? 1 : 0,
|
||||||
|
update.responseDeviceIdentifier,
|
||||||
|
update.approved ? (update.key ?? null) : null,
|
||||||
|
update.approved ? (update.masterPasswordHash ?? null) : null,
|
||||||
|
update.responseDate || new Date().toISOString(),
|
||||||
|
id,
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAuthRequestAuthenticated(db: D1Database, id: string, authenticationDate: string = new Date().toISOString()): Promise<boolean> {
|
||||||
|
const result = await db
|
||||||
|
.prepare('UPDATE auth_requests SET authentication_date = ? WHERE id = ? AND authentication_date IS NULL')
|
||||||
|
.bind(authenticationDate, id)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pruneExpiredAuthRequests(db: D1Database, nowMs: number = Date.now()): Promise<number> {
|
||||||
|
const cutoff = new Date(nowMs - AUTH_REQUEST_EXPIRATION_MS).toISOString();
|
||||||
|
const result = await db.prepare('DELETE FROM auth_requests WHERE creation_date < ?').bind(cutoff).run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
@@ -28,13 +28,6 @@ export async function getRefreshTokenRecord(
|
|||||||
db: D1Database,
|
db: D1Database,
|
||||||
refreshTokenKey: RefreshTokenKeyFn,
|
refreshTokenKey: RefreshTokenKeyFn,
|
||||||
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
|
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
|
||||||
saveRefreshTokenRecord: (
|
|
||||||
token: string,
|
|
||||||
userId: string,
|
|
||||||
expiresAtMs?: number,
|
|
||||||
deviceIdentifier?: string | null,
|
|
||||||
deviceSessionStamp?: string | null
|
|
||||||
) => Promise<void>,
|
|
||||||
deleteRefreshTokenRecord: (token: string) => Promise<void>,
|
deleteRefreshTokenRecord: (token: string) => Promise<void>,
|
||||||
token: string
|
token: string
|
||||||
): Promise<RefreshTokenRecord | null> {
|
): Promise<RefreshTokenRecord | null> {
|
||||||
@@ -42,39 +35,11 @@ export async function getRefreshTokenRecord(
|
|||||||
await maybeCleanupExpiredRefreshTokens(now);
|
await maybeCleanupExpiredRefreshTokens(now);
|
||||||
const tokenKey = await refreshTokenKey(token);
|
const tokenKey = await refreshTokenKey(token);
|
||||||
|
|
||||||
let row = await db
|
const row = await db
|
||||||
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
|
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
|
||||||
.bind(tokenKey)
|
.bind(tokenKey)
|
||||||
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
|
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
const legacyRow = await db
|
|
||||||
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
|
|
||||||
.bind(token)
|
|
||||||
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
|
|
||||||
|
|
||||||
if (legacyRow) {
|
|
||||||
if (legacyRow.expires_at && legacyRow.expires_at < now) {
|
|
||||||
await deleteRefreshTokenRecord(token);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
await saveRefreshTokenRecord(
|
|
||||||
token,
|
|
||||||
legacyRow.user_id,
|
|
||||||
legacyRow.expires_at,
|
|
||||||
legacyRow.device_identifier ?? null,
|
|
||||||
legacyRow.device_session_stamp ?? null
|
|
||||||
);
|
|
||||||
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
|
|
||||||
return {
|
|
||||||
userId: legacyRow.user_id,
|
|
||||||
expiresAt: legacyRow.expires_at,
|
|
||||||
deviceIdentifier: legacyRow.device_identifier ?? null,
|
|
||||||
deviceSessionStamp: legacyRow.device_session_stamp ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
if (row.expires_at && row.expires_at < now) {
|
if (row.expires_at && row.expires_at < now) {
|
||||||
await deleteRefreshTokenRecord(token);
|
await deleteRefreshTokenRecord(token);
|
||||||
|
|||||||
@@ -109,11 +109,34 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
|
'ALTER TABLE devices ADD COLUMN last_seen_at TEXT',
|
||||||
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
|
'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS auth_requests (' +
|
||||||
|
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' +
|
||||||
|
'request_ip_address TEXT, request_country_name TEXT, response_device_identifier TEXT, access_code TEXT NOT NULL, public_key TEXT NOT NULL, key TEXT, master_password_hash TEXT, ' +
|
||||||
|
'approved INTEGER, creation_date TEXT NOT NULL, response_date TEXT, authentication_date TEXT, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_auth_requests_user_created ON auth_requests(user_id, creation_date)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_auth_requests_user_pending ON auth_requests(user_id, approved, response_date, authentication_date, creation_date)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_auth_requests_device_pending ON auth_requests(user_id, request_device_identifier, creation_date)',
|
||||||
|
|
||||||
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
|
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
|
||||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
'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 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 (' +
|
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
|
||||||
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
||||||
|
|
||||||
|
|||||||
@@ -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, AuthRequestRecord } from '../types';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { ensureStorageSchema } from './storage-schema';
|
import { ensureStorageSchema } from './storage-schema';
|
||||||
import {
|
import {
|
||||||
@@ -103,6 +103,15 @@ import {
|
|||||||
updateDeviceKeys as updateStoredDeviceKeys,
|
updateDeviceKeys as updateStoredDeviceKeys,
|
||||||
updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice,
|
updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice,
|
||||||
} from './storage-device-repo';
|
} from './storage-device-repo';
|
||||||
|
import {
|
||||||
|
createAuthRequest as createStoredAuthRequest,
|
||||||
|
getAuthRequestById as findStoredAuthRequestById,
|
||||||
|
listAuthRequestsByUserId as listStoredAuthRequestsByUserId,
|
||||||
|
listPendingAuthRequestsByUserId as listStoredPendingAuthRequestsByUserId,
|
||||||
|
markAuthRequestAuthenticated as markStoredAuthRequestAuthenticated,
|
||||||
|
pruneExpiredAuthRequests as pruneStoredExpiredAuthRequests,
|
||||||
|
updateAuthRequestResponse as updateStoredAuthRequestResponse,
|
||||||
|
} from './storage-auth-request-repo';
|
||||||
import {
|
import {
|
||||||
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
|
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
|
||||||
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
|
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
|
||||||
@@ -115,6 +124,18 @@ import {
|
|||||||
getUserDomainSettings as getStoredUserDomainSettings,
|
getUserDomainSettings as getStoredUserDomainSettings,
|
||||||
saveUserDomainSettings as saveStoredUserDomainSettings,
|
saveUserDomainSettings as saveStoredUserDomainSettings,
|
||||||
} from './storage-domain-rules-repo';
|
} 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 TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||||
@@ -122,7 +143,8 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
|||||||
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
||||||
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
||||||
// differs from config.schema.version.
|
// differs from config.schema.version.
|
||||||
const STORAGE_SCHEMA_VERSION = '2026-05-14-lightweight-audit-logs';
|
const STORAGE_SCHEMA_VERSION = '2026-06-12-auth-requests';
|
||||||
|
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const;
|
||||||
|
|
||||||
// D1-backed storage.
|
// D1-backed storage.
|
||||||
// Contract:
|
// Contract:
|
||||||
@@ -153,6 +175,16 @@ export class StorageService {
|
|||||||
return stmt.bind(...values.map(v => v === undefined ? null : v));
|
return stmt.bind(...values.map(v => v === undefined ? null : v));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async hasRequiredSchemaTables(): Promise<boolean> {
|
||||||
|
const placeholders = REQUIRED_SCHEMA_TABLES.map(() => '?').join(', ');
|
||||||
|
const result = await this.db
|
||||||
|
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${placeholders})`)
|
||||||
|
.bind(...REQUIRED_SCHEMA_TABLES)
|
||||||
|
.all<{ name: string }>();
|
||||||
|
const found = new Set((result.results || []).map((row) => row.name));
|
||||||
|
return REQUIRED_SCHEMA_TABLES.every((table) => found.has(table));
|
||||||
|
}
|
||||||
|
|
||||||
private sqlChunkSize(fixedBindCount: number): number {
|
private sqlChunkSize(fixedBindCount: number): number {
|
||||||
return Math.max(
|
return Math.max(
|
||||||
1,
|
1,
|
||||||
@@ -196,7 +228,10 @@ export class StorageService {
|
|||||||
|
|
||||||
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
|
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);
|
const schemaVersion = await getStoredConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY);
|
||||||
if (schemaVersion !== STORAGE_SCHEMA_VERSION) {
|
const schemaMissingRequiredTables = schemaVersion === STORAGE_SCHEMA_VERSION
|
||||||
|
? !(await this.hasRequiredSchemaTables())
|
||||||
|
: true;
|
||||||
|
if (schemaVersion !== STORAGE_SCHEMA_VERSION || schemaMissingRequiredTables) {
|
||||||
await ensureStorageSchema(this.db);
|
await ensureStorageSchema(this.db);
|
||||||
await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION);
|
await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION);
|
||||||
}
|
}
|
||||||
@@ -323,6 +358,73 @@ export class StorageService {
|
|||||||
await this.updateRevisionDate(userId);
|
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 ---
|
// --- Ciphers ---
|
||||||
|
|
||||||
async getCipher(id: string): Promise<Cipher | null> {
|
async getCipher(id: string): Promise<Cipher | null> {
|
||||||
@@ -485,7 +587,6 @@ export class StorageService {
|
|||||||
this.db,
|
this.db,
|
||||||
this.refreshTokenKey.bind(this),
|
this.refreshTokenKey.bind(this),
|
||||||
this.maybeCleanupExpiredRefreshTokens.bind(this),
|
this.maybeCleanupExpiredRefreshTokens.bind(this),
|
||||||
this.saveRefreshToken.bind(this),
|
|
||||||
this.deleteRefreshToken.bind(this),
|
this.deleteRefreshToken.bind(this),
|
||||||
token
|
token
|
||||||
);
|
);
|
||||||
@@ -624,6 +725,45 @@ export class StorageService {
|
|||||||
return deleteStoredDevicesByUserId(this.db, userId);
|
return deleteStoredDevicesByUserId(this.db, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Auth requests / Login with device ---
|
||||||
|
|
||||||
|
async createAuthRequest(request: AuthRequestRecord): Promise<void> {
|
||||||
|
await createStoredAuthRequest(this.db, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAuthRequestById(id: string): Promise<AuthRequestRecord | null> {
|
||||||
|
return findStoredAuthRequestById(this.db, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> {
|
||||||
|
return listStoredAuthRequestsByUserId(this.db, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPendingAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> {
|
||||||
|
return listStoredPendingAuthRequestsByUserId(this.db, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAuthRequestResponse(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
update: {
|
||||||
|
approved: boolean;
|
||||||
|
responseDeviceIdentifier: string;
|
||||||
|
key?: string | null;
|
||||||
|
masterPasswordHash?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<boolean> {
|
||||||
|
return updateStoredAuthRequestResponse(this.db, id, userId, update);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAuthRequestAuthenticated(id: string): Promise<boolean> {
|
||||||
|
return markStoredAuthRequestAuthenticated(this.db, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async pruneExpiredAuthRequests(): Promise<number> {
|
||||||
|
return pruneStoredExpiredAuthRequests(this.db);
|
||||||
|
}
|
||||||
|
|
||||||
async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise<TrustedDeviceTokenSummary[]> {
|
async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise<TrustedDeviceTokenSummary[]> {
|
||||||
return listStoredTrustedTokenSummaries(this.db, userId);
|
return listStoredTrustedTokenSummaries(this.db, userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
export interface Env {
|
export interface Env {
|
||||||
DB: D1Database;
|
DB: D1Database;
|
||||||
NOTIFICATIONS_HUB: DurableObjectNamespace;
|
NOTIFICATIONS_HUB: DurableObjectNamespace;
|
||||||
|
BACKUP_TRANSFER_RUNNER: DurableObjectNamespace;
|
||||||
ASSETS?: {
|
ASSETS?: {
|
||||||
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
||||||
};
|
};
|
||||||
@@ -10,7 +11,9 @@ export interface Env {
|
|||||||
// Optional fallback for attachment/send file storage (no credit card required).
|
// Optional fallback for attachment/send file storage (no credit card required).
|
||||||
ATTACHMENTS_KV?: KVNamespace;
|
ATTACHMENTS_KV?: KVNamespace;
|
||||||
JWT_SECRET: string;
|
JWT_SECRET: string;
|
||||||
TOTP_SECRET?: string;
|
WEBAUTHN_RP_ID?: string;
|
||||||
|
WEBAUTHN_RP_NAME?: string;
|
||||||
|
WEBAUTHN_ALLOWED_ORIGINS?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserRole = 'admin' | 'user';
|
export type UserRole = 'admin' | 'user';
|
||||||
@@ -234,11 +237,64 @@ export interface Device {
|
|||||||
updatedAt: string;
|
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 {
|
export interface DevicePendingAuthRequest {
|
||||||
id: string;
|
id: string;
|
||||||
creationDate: string;
|
creationDate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AuthRequestType = 0 | 1 | 2;
|
||||||
|
|
||||||
|
export interface AuthRequestRecord {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
organizationId: string | null;
|
||||||
|
type: AuthRequestType;
|
||||||
|
requestDeviceIdentifier: string;
|
||||||
|
requestDeviceType: number;
|
||||||
|
requestIpAddress: string | null;
|
||||||
|
requestCountryName: string | null;
|
||||||
|
responseDeviceIdentifier: string | null;
|
||||||
|
accessCode: string;
|
||||||
|
publicKey: string;
|
||||||
|
key: string | null;
|
||||||
|
masterPasswordHash: string | null;
|
||||||
|
approved: boolean | null;
|
||||||
|
creationDate: string;
|
||||||
|
responseDate: string | null;
|
||||||
|
authenticationDate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeviceResponse {
|
export interface DeviceResponse {
|
||||||
id: string;
|
id: string;
|
||||||
userId?: string | null;
|
userId?: string | null;
|
||||||
@@ -372,6 +428,14 @@ export interface MasterPasswordUnlock {
|
|||||||
Object: string;
|
Object: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WebAuthnPrfDecryptionOption {
|
||||||
|
EncryptedPrivateKey: string;
|
||||||
|
EncryptedUserKey: string;
|
||||||
|
CredentialId: string;
|
||||||
|
Transports: string[];
|
||||||
|
Object?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserDecryptionOptions {
|
export interface UserDecryptionOptions {
|
||||||
HasMasterPassword: boolean;
|
HasMasterPassword: boolean;
|
||||||
Object: string;
|
Object: string;
|
||||||
@@ -379,6 +443,7 @@ export interface UserDecryptionOptions {
|
|||||||
MasterPasswordUnlock: MasterPasswordUnlock;
|
MasterPasswordUnlock: MasterPasswordUnlock;
|
||||||
TrustedDeviceOption: null;
|
TrustedDeviceOption: null;
|
||||||
KeyConnectorOption: null;
|
KeyConnectorOption: null;
|
||||||
|
WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API Response types
|
// API Response types
|
||||||
@@ -498,7 +563,8 @@ export interface SyncResponse {
|
|||||||
MasterPasswordUnlock: MasterPasswordUnlock | null;
|
MasterPasswordUnlock: MasterPasswordUnlock | null;
|
||||||
TrustedDeviceOption?: null;
|
TrustedDeviceOption?: null;
|
||||||
KeyConnectorOption?: null;
|
KeyConnectorOption?: null;
|
||||||
WebAuthnPrfOption?: null;
|
WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null;
|
||||||
|
WebAuthnPrfOptions?: WebAuthnPrfDecryptionOption[];
|
||||||
Object?: string;
|
Object?: string;
|
||||||
} | null;
|
} | null;
|
||||||
// PascalCase for desktop/browser clients
|
// 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 {
|
function normalizeOptionalPublicKey(value: unknown): string {
|
||||||
if (value == null) return '';
|
if (value == null) return '';
|
||||||
@@ -40,7 +40,8 @@ export function buildMasterPasswordUnlock(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildUserDecryptionOptions(
|
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 {
|
): UserDecryptionOptions {
|
||||||
return {
|
return {
|
||||||
HasMasterPassword: true,
|
HasMasterPassword: true,
|
||||||
@@ -48,6 +49,7 @@ export function buildUserDecryptionOptions(
|
|||||||
MasterPasswordUnlock: buildMasterPasswordUnlock(user),
|
MasterPasswordUnlock: buildMasterPasswordUnlock(user),
|
||||||
TrustedDeviceOption: null,
|
TrustedDeviceOption: null,
|
||||||
KeyConnectorOption: null,
|
KeyConnectorOption: null,
|
||||||
|
WebAuthnPrfOption: webAuthnPrfOption,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,12 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/nodewarden-logo-bg.svg" />
|
<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="alternate icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<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>
|
<title>NodeWarden</title>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 619 B |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Full-bleed background for any/maskable -->
|
||||||
|
<rect width="512" height="512" fill="#116FF9"/>
|
||||||
|
<!-- Logo scaled to ~50% centered in safe zone (inner 66% = Android adaptive icon guideline) -->
|
||||||
|
<g transform="translate(256,256) scale(0.5) translate(-380,-380)">
|
||||||
|
<path d="M386.5 183C497.785 183 588 271.2 588 380C588 419.877 575.879 456.986 555.046 488H17.6816C16.5766 481.834 16 475.484 16 469C16 413.617 58.0774 368.061 112.008 362.558C108.771 353.989 107 344.701 107 335C107 291.922 141.922 257 185 257C198.365 257 210.945 260.362 221.94 266.286C258.437 215.895 318.539 183 386.5 183Z" fill="#F6821F"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M92.6568 91.0069C88.7796 262.923 101.55 381.119 143.869 469.459C186.188 557.799 258.092 616.353 372.665 668.892C485.877 616.354 556.929 557.802 598.746 469.461C640.564 381.12 653.181 262.923 649.35 91.0069H92.6568ZM539.796 432.933C570.479 365.533 581.347 278.379 582.419 153.939L582.422 153.432H377.661V593.786L378.405 593.364C458.602 547.962 509.101 500.36 539.796 432.933Z" fill="white"/>
|
||||||
|
<path d="M604.465 305C680.976 305 743 367.233 743 444C743 459.378 740.509 474.172 735.913 488H379V423.553C391.721 397.751 418.287 380 449 380C459.483 380 469.482 382.068 478.613 385.818C500.559 338.11 548.658 305 604.465 305Z" fill="#FD9C33"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -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" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useLocation } from 'wouter';
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import AppAuthenticatedShell from '@/components/AppAuthenticatedShell';
|
import AppAuthenticatedShell from '@/components/AppAuthenticatedShell';
|
||||||
import AppGlobalOverlays, { type AppConfirmState } from '@/components/AppGlobalOverlays';
|
import AppGlobalOverlays, { type AppConfirmState } from '@/components/AppGlobalOverlays';
|
||||||
|
import AuthRequestApprovalDialog from '@/components/AuthRequestApprovalDialog';
|
||||||
import AuthViews from '@/components/AuthViews';
|
import AuthViews from '@/components/AuthViews';
|
||||||
import NotFoundPage from '@/components/NotFoundPage';
|
import NotFoundPage from '@/components/NotFoundPage';
|
||||||
import PublicSendPage from '@/components/PublicSendPage';
|
import PublicSendPage from '@/components/PublicSendPage';
|
||||||
@@ -22,11 +23,17 @@ import {
|
|||||||
saveSession,
|
saveSession,
|
||||||
stripProfileSecrets,
|
stripProfileSecrets,
|
||||||
} from '@/lib/api/auth';
|
} from '@/lib/api/auth';
|
||||||
|
import {
|
||||||
|
encryptSessionUserKeyForAuthRequest,
|
||||||
|
isPendingAuthRequest,
|
||||||
|
listPendingAuthRequests,
|
||||||
|
respondToAuthRequest,
|
||||||
|
} from '@/lib/api/auth-requests';
|
||||||
import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin';
|
import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin';
|
||||||
import { getDomainRules, saveDomainRules } from '@/lib/api/domains';
|
import { getDomainRules, saveDomainRules } from '@/lib/api/domains';
|
||||||
import { getSends } from '@/lib/api/send';
|
import { getSends } from '@/lib/api/send';
|
||||||
import { repairCipherUriChecksums } from '@/lib/api/vault';
|
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 { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
||||||
import {
|
import {
|
||||||
parseSignalRTextFrames,
|
parseSignalRTextFrames,
|
||||||
@@ -37,13 +44,16 @@ import {
|
|||||||
bootstrapAppSession,
|
bootstrapAppSession,
|
||||||
type CompletedLogin,
|
type CompletedLogin,
|
||||||
readInitialAppBootstrapState,
|
readInitialAppBootstrapState,
|
||||||
|
completePasskeyPasswordLogin,
|
||||||
performPasswordLogin,
|
performPasswordLogin,
|
||||||
|
performPasskeyLogin,
|
||||||
performRecoverTwoFactorLogin,
|
performRecoverTwoFactorLogin,
|
||||||
performRegistration,
|
performRegistration,
|
||||||
performTotpLogin,
|
performTotpLogin,
|
||||||
hydrateLockedSession,
|
hydrateLockedSession,
|
||||||
performUnlock,
|
performUnlock,
|
||||||
type JwtUnsafeReason,
|
type JwtUnsafeReason,
|
||||||
|
type PendingPasskeyPassword,
|
||||||
type PendingTotp,
|
type PendingTotp,
|
||||||
} from '@/lib/app-auth';
|
} from '@/lib/app-auth';
|
||||||
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
|
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
|
||||||
@@ -54,6 +64,7 @@ import { useToastManager } from '@/hooks/useToastManager';
|
|||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
||||||
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
||||||
|
import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
|
||||||
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
|
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
|
||||||
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
|
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
|
||||||
import {
|
import {
|
||||||
@@ -70,7 +81,7 @@ import {
|
|||||||
createDemoMainRoutesProps,
|
createDemoMainRoutesProps,
|
||||||
} from '@/lib/demo';
|
} from '@/lib/demo';
|
||||||
import type { AdminBackupSettings } from '@/lib/api/backup';
|
import type { AdminBackupSettings } from '@/lib/api/backup';
|
||||||
import type { AdminInvite, AdminUser, AppPhase, AuditLogSettings, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
|
import type { AdminInvite, AdminUser, AppPhase, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
|
||||||
import type { VaultCoreSnapshot } from '@/lib/vault-cache';
|
import type { VaultCoreSnapshot } from '@/lib/vault-cache';
|
||||||
|
|
||||||
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
|
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
|
||||||
@@ -90,6 +101,8 @@ const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.fil
|
|||||||
const SETTINGS_HOME_ROUTE = '/settings';
|
const SETTINGS_HOME_ROUTE = '/settings';
|
||||||
const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
|
const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
|
||||||
const SETTINGS_DOMAIN_RULES_ROUTE = '/settings/domain-rules';
|
const SETTINGS_DOMAIN_RULES_ROUTE = '/settings/domain-rules';
|
||||||
|
const DEVICE_MANAGEMENT_ROUTE = '/settings/security/device-management';
|
||||||
|
const LEGACY_DEVICE_MANAGEMENT_ROUTE = '/security/devices';
|
||||||
const AUTH_ROUTE_PATHS = ['/', '/login', '/register', '/lock', '/recover-2fa'] as const;
|
const AUTH_ROUTE_PATHS = ['/', '/login', '/register', '/lock', '/recover-2fa'] as const;
|
||||||
const APP_ROUTE_PATHS = [
|
const APP_ROUTE_PATHS = [
|
||||||
'/',
|
'/',
|
||||||
@@ -98,7 +111,8 @@ const APP_ROUTE_PATHS = [
|
|||||||
'/sends',
|
'/sends',
|
||||||
'/admin',
|
'/admin',
|
||||||
'/logs',
|
'/logs',
|
||||||
'/security/devices',
|
LEGACY_DEVICE_MANAGEMENT_ROUTE,
|
||||||
|
DEVICE_MANAGEMENT_ROUTE,
|
||||||
'/backup',
|
'/backup',
|
||||||
'/settings',
|
'/settings',
|
||||||
SETTINGS_ACCOUNT_ROUTE,
|
SETTINGS_ACCOUNT_ROUTE,
|
||||||
@@ -146,7 +160,9 @@ function resolveSystemTheme(): 'light' | 'dark' {
|
|||||||
|
|
||||||
function readLockTimeoutMinutes(): LockTimeoutMinutes {
|
function readLockTimeoutMinutes(): LockTimeoutMinutes {
|
||||||
if (typeof window === 'undefined') return 15;
|
if (typeof window === 'undefined') return 15;
|
||||||
const value = Number(window.localStorage.getItem(LOCK_TIMEOUT_STORAGE_KEY));
|
const stored = window.localStorage.getItem(LOCK_TIMEOUT_STORAGE_KEY);
|
||||||
|
if (stored === null || stored.trim() === '') return 15;
|
||||||
|
const value = Number(stored);
|
||||||
return LOCK_TIMEOUT_VALUES.has(value as LockTimeoutMinutes) ? (value as LockTimeoutMinutes) : 15;
|
return LOCK_TIMEOUT_VALUES.has(value as LockTimeoutMinutes) ? (value as LockTimeoutMinutes) : 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +183,7 @@ export default function App() {
|
|||||||
[initialBootstrap]
|
[initialBootstrap]
|
||||||
);
|
);
|
||||||
const queryClient = useQueryClient();
|
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 [location, navigate] = useLocation();
|
||||||
const [phase, setPhase] = useState<AppPhase>(initialBootstrap.phase);
|
const [phase, setPhase] = useState<AppPhase>(initialBootstrap.phase);
|
||||||
const [session, setSessionState] = useState<SessionState | null>(initialBootstrap.session);
|
const [session, setSessionState] = useState<SessionState | null>(initialBootstrap.session);
|
||||||
@@ -198,6 +214,8 @@ export default function App() {
|
|||||||
const [unlockPassword, setUnlockPassword] = useState('');
|
const [unlockPassword, setUnlockPassword] = useState('');
|
||||||
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
||||||
const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | 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 [totpCode, setTotpCode] = useState('');
|
||||||
const [rememberDevice, setRememberDevice] = useState(true);
|
const [rememberDevice, setRememberDevice] = useState(true);
|
||||||
const [totpSubmitting, setTotpSubmitting] = useState(false);
|
const [totpSubmitting, setTotpSubmitting] = useState(false);
|
||||||
@@ -205,6 +223,8 @@ export default function App() {
|
|||||||
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
|
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
|
||||||
const [disableTotpPassword, setDisableTotpPassword] = useState('');
|
const [disableTotpPassword, setDisableTotpPassword] = useState('');
|
||||||
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
|
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
|
||||||
|
const [authRequestDialogDismissedId, setAuthRequestDialogDismissedId] = useState<string | null>(null);
|
||||||
|
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
|
||||||
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
||||||
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
|
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
|
||||||
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
|
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
|
||||||
@@ -431,6 +451,7 @@ export default function App() {
|
|||||||
(async () => {
|
(async () => {
|
||||||
const boot = await bootstrapAppSession(initialBootstrap);
|
const boot = await bootstrapAppSession(initialBootstrap);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
if (sessionRef.current?.symEncKey || sessionRef.current?.symMacKey) return;
|
||||||
setDefaultKdfIterations(boot.defaultKdfIterations);
|
setDefaultKdfIterations(boot.defaultKdfIterations);
|
||||||
setRegistrationInviteRequired(boot.registrationInviteRequired);
|
setRegistrationInviteRequired(boot.registrationInviteRequired);
|
||||||
setJwtWarning(boot.jwtWarning);
|
setJwtWarning(boot.jwtWarning);
|
||||||
@@ -476,7 +497,9 @@ export default function App() {
|
|||||||
setUnlockPreparing(false);
|
setUnlockPreparing(false);
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
setPendingTotpMode(null);
|
setPendingTotpMode(null);
|
||||||
|
setPendingPasskeyPassword(null);
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
|
setPasskeyPassword('');
|
||||||
setUnlockPassword('');
|
setUnlockPassword('');
|
||||||
setPhase('app');
|
setPhase('app');
|
||||||
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
|
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
|
||||||
@@ -531,6 +554,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() {
|
async function handleTotpVerify() {
|
||||||
if (totpSubmitting) return;
|
if (totpSubmitting) return;
|
||||||
if (!pendingTotp) return;
|
if (!pendingTotp) return;
|
||||||
@@ -744,6 +839,7 @@ export default function App() {
|
|||||||
setConfirm(null);
|
setConfirm(null);
|
||||||
setSession(null);
|
setSession(null);
|
||||||
clearProfileSnapshot();
|
clearProfileSnapshot();
|
||||||
|
clearOfflineUnlockRecord();
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
setUnlockPreparing(false);
|
setUnlockPreparing(false);
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
@@ -908,7 +1004,7 @@ export default function App() {
|
|||||||
const vaultCoreQuery = useQuery({
|
const vaultCoreQuery = useQuery({
|
||||||
queryKey: ['vault-core', vaultCacheKey],
|
queryKey: ['vault-core', vaultCacheKey],
|
||||||
queryFn: () => loadVaultCoreSyncSnapshot(authedFetch, 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,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
const encryptedVaultCore = vaultCoreQuery.data || cachedVaultCore;
|
const encryptedVaultCore = vaultCoreQuery.data || cachedVaultCore;
|
||||||
@@ -919,7 +1015,7 @@ export default function App() {
|
|||||||
const sendsQuery = useQuery({
|
const sendsQuery = useQuery({
|
||||||
queryKey: sendsQueryKey,
|
queryKey: sendsQueryKey,
|
||||||
queryFn: () => getSends(authedFetch),
|
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,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
const encryptedSends = sendsQuery.data || encryptedSendsFromSync;
|
const encryptedSends = sendsQuery.data || encryptedSendsFromSync;
|
||||||
@@ -948,13 +1044,13 @@ export default function App() {
|
|||||||
const usersQuery = useQuery({
|
const usersQuery = useQuery({
|
||||||
queryKey: ['admin-users', vaultCacheKey],
|
queryKey: ['admin-users', vaultCacheKey],
|
||||||
queryFn: () => listAdminUsers(authedFetch),
|
queryFn: () => listAdminUsers(authedFetch),
|
||||||
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
const invitesQuery = useQuery({
|
const invitesQuery = useQuery({
|
||||||
queryKey: ['admin-invites', vaultCacheKey],
|
queryKey: ['admin-invites', vaultCacheKey],
|
||||||
queryFn: () => listAdminInvites(authedFetch),
|
queryFn: () => listAdminInvites(authedFetch),
|
||||||
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
const totpStatusQuery = useQuery({
|
const totpStatusQuery = useQuery({
|
||||||
@@ -976,6 +1072,52 @@ export default function App() {
|
|||||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
const pendingAuthRequestsQuery = useQuery({
|
||||||
|
queryKey: ['auth-requests-pending', vaultCacheKey || session?.email],
|
||||||
|
queryFn: () => listPendingAuthRequests(authedFetch, profile?.email || session?.email || ''),
|
||||||
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!(profile?.email || session?.email),
|
||||||
|
staleTime: 5_000,
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
refetchIntervalInBackground: true,
|
||||||
|
});
|
||||||
|
const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest);
|
||||||
|
const latestPendingAuthRequest = pendingAuthRequests[0] || null;
|
||||||
|
const authRequestDialogOpen = !!latestPendingAuthRequest && latestPendingAuthRequest.id !== authRequestDialogDismissedId;
|
||||||
|
|
||||||
|
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
|
||||||
|
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||||
|
setAuthRequestSubmittingId(authRequest.id);
|
||||||
|
try {
|
||||||
|
const key = await encryptSessionUserKeyForAuthRequest(session, authRequest);
|
||||||
|
await respondToAuthRequest(authedFetch, authRequest.id, {
|
||||||
|
key,
|
||||||
|
masterPasswordHash: null,
|
||||||
|
deviceIdentifier: getCurrentDeviceIdentifier(),
|
||||||
|
requestApproved: true,
|
||||||
|
});
|
||||||
|
setAuthRequestDialogDismissedId(null);
|
||||||
|
pushToast('success', t('txt_auth_request_approved'));
|
||||||
|
await pendingAuthRequestsQuery.refetch();
|
||||||
|
} finally {
|
||||||
|
setAuthRequestSubmittingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function denyAuthRequest(authRequest: AuthRequest): Promise<void> {
|
||||||
|
setAuthRequestSubmittingId(authRequest.id);
|
||||||
|
try {
|
||||||
|
await respondToAuthRequest(authedFetch, authRequest.id, {
|
||||||
|
deviceIdentifier: getCurrentDeviceIdentifier(),
|
||||||
|
requestApproved: false,
|
||||||
|
});
|
||||||
|
setAuthRequestDialogDismissedId(null);
|
||||||
|
pushToast('success', t('txt_auth_request_denied'));
|
||||||
|
await pendingAuthRequestsQuery.refetch();
|
||||||
|
} finally {
|
||||||
|
setAuthRequestSubmittingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleSaveDomainRules(customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]): Promise<void> {
|
function handleSaveDomainRules(customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]): Promise<void> {
|
||||||
const equivalentDomains = customEquivalentDomains.filter((rule) => !rule.excluded).map((rule) => rule.domains);
|
const equivalentDomains = customEquivalentDomains.filter((rule) => !rule.excluded).map((rule) => rule.domains);
|
||||||
const excludedGlobalTypes = new Set(excludedGlobalEquivalentDomains);
|
const excludedGlobalTypes = new Set(excludedGlobalEquivalentDomains);
|
||||||
@@ -1011,7 +1153,7 @@ export default function App() {
|
|||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ['admin-backup-settings', vaultCacheKey],
|
queryKey: ['admin-backup-settings', vaultCacheKey],
|
||||||
queryFn: () => backupActions.loadSettings(),
|
queryFn: () => backupActions.loadSettings(),
|
||||||
enabled: !IS_DEMO_MODE && phase === 'app' && isAdmin && vaultInitialDecryptDone,
|
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1081,12 +1223,22 @@ export default function App() {
|
|||||||
setDecryptedFolders(result.folders);
|
setDecryptedFolders(result.folders);
|
||||||
setDecryptedCiphers(result.ciphers);
|
setDecryptedCiphers(result.ciphers);
|
||||||
setVaultInitialDecryptDone(true);
|
setVaultInitialDecryptDone(true);
|
||||||
|
if (!session.accessToken) return;
|
||||||
const repairKey = `${session.accessToken}:${encryptedCiphers.map((cipher) => `${cipher.id}:${cipher.revisionDate || ''}`).join(',')}`;
|
const repairKey = `${session.accessToken}:${encryptedCiphers.map((cipher) => `${cipher.id}:${cipher.revisionDate || ''}`).join(',')}`;
|
||||||
if (uriChecksumRepairAttemptRef.current !== repairKey) {
|
if (uriChecksumRepairAttemptRef.current !== repairKey) {
|
||||||
uriChecksumRepairAttemptRef.current = repairKey;
|
uriChecksumRepairAttemptRef.current = repairKey;
|
||||||
void repairCipherUriChecksums(authedFetch, session, result.ciphers)
|
void repairCipherKeyMismatches(authedFetch, session, result.ciphers)
|
||||||
.then((count) => {
|
.then(async (keyMismatchCount) => {
|
||||||
if (count > 0) void refetchVaultCoreData();
|
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(() => {
|
.catch(() => {
|
||||||
// Best-effort compatibility repair must not interrupt normal vault loading.
|
// Best-effort compatibility repair must not interrupt normal vault loading.
|
||||||
@@ -1104,7 +1256,7 @@ export default function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [session?.symEncKey, session?.symMacKey, encryptedFolders, encryptedCiphers]);
|
}, [session?.symEncKey, session?.symMacKey, vaultCacheKey, encryptedFolders, encryptedCiphers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (IS_DEMO_MODE) return;
|
if (IS_DEMO_MODE) return;
|
||||||
@@ -1339,6 +1491,7 @@ export default function App() {
|
|||||||
const accountSecurityActions = useAccountSecurityActions({
|
const accountSecurityActions = useAccountSecurityActions({
|
||||||
authedFetch,
|
authedFetch,
|
||||||
profile,
|
profile,
|
||||||
|
session,
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
disableTotpPassword,
|
disableTotpPassword,
|
||||||
clearDisableTotpDialog: () => {
|
clearDisableTotpDialog: () => {
|
||||||
@@ -1382,7 +1535,7 @@ export default function App() {
|
|||||||
const isKnownAppRoute = APP_ROUTES.has(routeLocation) || isPublicSendRoute || isImportHashRoute;
|
const isKnownAppRoute = APP_ROUTES.has(routeLocation) || isPublicSendRoute || isImportHashRoute;
|
||||||
const isUnknownRoute = isMalformedSendRoute || (phase === 'app' ? !isKnownAppRoute : !isKnownAuthRoute && !APP_ROUTES.has(routeLocation));
|
const isUnknownRoute = isMalformedSendRoute || (phase === 'app' ? !isKnownAppRoute : !isKnownAuthRoute && !APP_ROUTES.has(routeLocation));
|
||||||
const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation);
|
const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation);
|
||||||
const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends');
|
const showSidebarToggle = mobileLayout && location === '/sends';
|
||||||
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
|
const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type');
|
||||||
const demoDomainRules = useMemo<DomainRules>(() => ({
|
const demoDomainRules = useMemo<DomainRules>(() => ({
|
||||||
equivalentDomains: [
|
equivalentDomains: [
|
||||||
@@ -1414,7 +1567,7 @@ export default function App() {
|
|||||||
if (location === '/sends') return t('nav_sends');
|
if (location === '/sends') return t('nav_sends');
|
||||||
if (location === '/admin') return t('nav_admin_panel');
|
if (location === '/admin') return t('nav_admin_panel');
|
||||||
if (location === '/logs') return t('nav_log_center');
|
if (location === '/logs') return t('nav_log_center');
|
||||||
if (location === '/security/devices') return t('nav_device_management');
|
if (location === LEGACY_DEVICE_MANAGEMENT_ROUTE || location === DEVICE_MANAGEMENT_ROUTE) return t('nav_device_management');
|
||||||
if (location === SETTINGS_DOMAIN_RULES_ROUTE) return t('nav_domain_rules');
|
if (location === SETTINGS_DOMAIN_RULES_ROUTE) return t('nav_domain_rules');
|
||||||
if (location === '/backup') return t('nav_backup_strategy');
|
if (location === '/backup') return t('nav_backup_strategy');
|
||||||
if (isImportRoute) return t('nav_import_export');
|
if (isImportRoute) return t('nav_import_export');
|
||||||
@@ -1423,6 +1576,16 @@ export default function App() {
|
|||||||
return t('nav_my_vault');
|
return t('nav_my_vault');
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'app') return;
|
||||||
|
if (!hashPath.startsWith('/')) return;
|
||||||
|
if (normalizedHashPath !== DEVICE_MANAGEMENT_ROUTE && normalizedHashPath !== LEGACY_DEVICE_MANAGEMENT_ROUTE) return;
|
||||||
|
if (typeof window !== 'undefined' && typeof window.history?.replaceState === 'function') {
|
||||||
|
window.history.replaceState(null, '', DEVICE_MANAGEMENT_ROUTE);
|
||||||
|
}
|
||||||
|
if (location !== DEVICE_MANAGEMENT_ROUTE) navigate(DEVICE_MANAGEMENT_ROUTE);
|
||||||
|
}, [phase, hashPath, normalizedHashPath, location, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
|
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
|
||||||
}, [phase, location, isPublicSendRoute, navigate]);
|
}, [phase, location, isPublicSendRoute, navigate]);
|
||||||
@@ -1525,6 +1688,17 @@ export default function App() {
|
|||||||
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
||||||
onGetApiKey: accountSecurityActions.getApiKey,
|
onGetApiKey: accountSecurityActions.getApiKey,
|
||||||
onRotateApiKey: accountSecurityActions.rotateApiKey,
|
onRotateApiKey: accountSecurityActions.rotateApiKey,
|
||||||
|
onListAccountPasskeys: accountSecurityActions.listAccountPasskeys,
|
||||||
|
onCreateAccountPasskey: accountSecurityActions.createAccountPasskey,
|
||||||
|
onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock,
|
||||||
|
onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey,
|
||||||
|
pendingAuthRequests,
|
||||||
|
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isFetching,
|
||||||
|
onRefreshPendingAuthRequests: async () => {
|
||||||
|
await pendingAuthRequestsQuery.refetch();
|
||||||
|
},
|
||||||
|
onApproveAuthRequest: approveAuthRequest,
|
||||||
|
onDenyAuthRequest: denyAuthRequest,
|
||||||
onLockTimeoutChange: setLockTimeoutMinutes,
|
onLockTimeoutChange: setLockTimeoutMinutes,
|
||||||
onSessionTimeoutActionChange: setSessionTimeoutAction,
|
onSessionTimeoutActionChange: setSessionTimeoutAction,
|
||||||
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
||||||
@@ -1635,18 +1809,26 @@ export default function App() {
|
|||||||
unlockReady={!!session?.email}
|
unlockReady={!!session?.email}
|
||||||
unlockPreparing={unlockPreparing}
|
unlockPreparing={unlockPreparing}
|
||||||
loginValues={loginValues}
|
loginValues={loginValues}
|
||||||
|
pendingPasskeyPasswordEmail={pendingPasskeyPassword?.email || null}
|
||||||
|
passkeyPassword={passkeyPassword}
|
||||||
registerValues={registerValues}
|
registerValues={registerValues}
|
||||||
registrationInviteRequired={registrationInviteRequired}
|
registrationInviteRequired={registrationInviteRequired}
|
||||||
unlockPassword={unlockPassword}
|
unlockPassword={unlockPassword}
|
||||||
emailForLock={profile?.email || session?.email || ''}
|
emailForLock={profile?.email || session?.email || ''}
|
||||||
loginHintLoading={loginHintState.loading}
|
loginHintLoading={loginHintState.loading}
|
||||||
onChangeLogin={setLoginValues}
|
onChangeLogin={setLoginValues}
|
||||||
|
onChangePasskeyPassword={setPasskeyPassword}
|
||||||
onChangeRegister={setRegisterValues}
|
onChangeRegister={setRegisterValues}
|
||||||
onChangeUnlock={setUnlockPassword}
|
onChangeUnlock={setUnlockPassword}
|
||||||
onSubmitLogin={() => void handleLogin()}
|
onSubmitLogin={() => void handleLogin()}
|
||||||
|
onSubmitPasskey={() => void handlePasskeyLogin()}
|
||||||
|
onSubmitPasskeyUnlock={() => void handlePasskeyUnlock()}
|
||||||
|
onSubmitPasskeyPassword={() => void handlePasskeyPasswordLogin()}
|
||||||
onSubmitRegister={() => void handleRegister()}
|
onSubmitRegister={() => void handleRegister()}
|
||||||
onSubmitUnlock={() => void handleUnlock()}
|
onSubmitUnlock={() => void handleUnlock()}
|
||||||
onGotoLogin={() => {
|
onGotoLogin={() => {
|
||||||
|
setPendingPasskeyPassword(null);
|
||||||
|
setPasskeyPassword('');
|
||||||
setPhase('login');
|
setPhase('login');
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
}}
|
}}
|
||||||
@@ -1658,6 +1840,8 @@ export default function App() {
|
|||||||
if (inviteCodeFromUrl) {
|
if (inviteCodeFromUrl) {
|
||||||
setRegisterValues((prev) => ({ ...prev, inviteCode: inviteCodeFromUrl }));
|
setRegisterValues((prev) => ({ ...prev, inviteCode: inviteCodeFromUrl }));
|
||||||
}
|
}
|
||||||
|
setPendingPasskeyPassword(null);
|
||||||
|
setPasskeyPassword('');
|
||||||
setPhase('register');
|
setPhase('register');
|
||||||
navigate('/register');
|
navigate('/register');
|
||||||
}}
|
}}
|
||||||
@@ -1759,6 +1943,24 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
disableTotpSubmitting={disableTotpSubmitting}
|
disableTotpSubmitting={disableTotpSubmitting}
|
||||||
/>
|
/>
|
||||||
|
<AuthRequestApprovalDialog
|
||||||
|
open={authRequestDialogOpen}
|
||||||
|
authRequest={latestPendingAuthRequest}
|
||||||
|
submitting={!!authRequestSubmittingId}
|
||||||
|
onApprove={() => {
|
||||||
|
if (!latestPendingAuthRequest) return;
|
||||||
|
void approveAuthRequest(latestPendingAuthRequest).catch((error) => {
|
||||||
|
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onDeny={() => {
|
||||||
|
if (!latestPendingAuthRequest) return;
|
||||||
|
void denyAuthRequest(latestPendingAuthRequest).catch((error) => {
|
||||||
|
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onClose={() => setAuthRequestDialogDismissedId(latestPendingAuthRequest?.id || null)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ComponentChildren } from 'preact';
|
|||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { Link } from 'wouter';
|
import { Link } from 'wouter';
|
||||||
import AppMainRoutes from '@/components/AppMainRoutes';
|
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||||
|
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
|
||||||
import ThemeSwitch from '@/components/ThemeSwitch';
|
import ThemeSwitch from '@/components/ThemeSwitch';
|
||||||
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
|
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
@@ -46,6 +47,9 @@ function isAdminProfile(profile: Profile | null): boolean {
|
|||||||
return String(profile?.role || '').toLowerCase() === 'admin';
|
return String(profile?.role || '').toLowerCase() === 'admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEVICE_MANAGEMENT_ROUTE = '/settings/security/device-management';
|
||||||
|
const LEGACY_DEVICE_MANAGEMENT_ROUTE = '/security/devices';
|
||||||
|
|
||||||
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
|
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
|
||||||
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
|
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
|
||||||
const isDomainRulesRoute = props.location === '/settings/domain-rules';
|
const isDomainRulesRoute = props.location === '/settings/domain-rules';
|
||||||
@@ -54,7 +58,8 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
|
const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
|
||||||
const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules';
|
const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules';
|
||||||
const dataActive = props.location === '/backup' || props.isImportRoute;
|
const dataActive = props.location === '/backup' || props.isImportRoute;
|
||||||
const managementActive = props.location === '/admin' || props.location === '/security/devices' || props.location === '/logs';
|
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
|
||||||
|
const managementActive = props.location === '/admin' || deviceManagementActive || props.location === '/logs';
|
||||||
const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode);
|
const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode);
|
||||||
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
|
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
|
||||||
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
|
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -176,7 +181,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
{renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))}
|
{renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))}
|
||||||
{isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
|
{isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
|
||||||
{isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))}
|
{isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))}
|
||||||
{renderSideLink('/security/devices', props.location === '/security/devices', <MonitorSmartphone size={16} />, t('nav_device_management'))}
|
{renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -221,7 +226,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
<>
|
<>
|
||||||
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
|
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
|
||||||
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
|
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
|
||||||
{renderSubLink('/security/devices', props.location === '/security/devices', t('nav_device_management'))}
|
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -237,6 +242,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
<span className="mobile-page-title">{props.currentPageTitle}</span>
|
<span className="mobile-page-title">{props.currentPageTitle}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-actions">
|
<div className="topbar-actions">
|
||||||
|
<NetworkStatusBadge />
|
||||||
<div className="user-chip">
|
<div className="user-chip">
|
||||||
<ShieldUser size={16} />
|
<ShieldUser size={16} />
|
||||||
<span>{props.profile?.email}</span>
|
<span>{props.profile?.email}</span>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface AppConfirmState {
|
|||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
hideCancel?: boolean;
|
hideCancel?: boolean;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppGlobalOverlaysProps {
|
interface AppGlobalOverlaysProps {
|
||||||
@@ -49,7 +50,7 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
|||||||
cancelText={props.confirm?.cancelText}
|
cancelText={props.confirm?.cancelText}
|
||||||
hideCancel={props.confirm?.hideCancel}
|
hideCancel={props.confirm?.hideCancel}
|
||||||
onConfirm={() => props.confirm?.onConfirm()}
|
onConfirm={() => props.confirm?.onConfirm()}
|
||||||
onCancel={props.onCancelConfirm}
|
onCancel={props.confirm?.onCancel || props.onCancelConfirm}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSett
|
|||||||
import type { AuditLogFilters } from '@/lib/api/admin';
|
import type { AuditLogFilters } from '@/lib/api/admin';
|
||||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||||
import { t } from '@/lib/i18n';
|
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, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||||
import type { ExportRequest } from '@/lib/export-formats';
|
import type { ExportRequest } from '@/lib/export-formats';
|
||||||
|
|
||||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||||
@@ -112,6 +112,15 @@ export interface AppMainRoutesProps {
|
|||||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||||
onRotateApiKey: (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>;
|
||||||
|
pendingAuthRequests: AuthRequest[];
|
||||||
|
pendingAuthRequestsLoading: boolean;
|
||||||
|
onRefreshPendingAuthRequests: () => Promise<void>;
|
||||||
|
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||||
|
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||||
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||||
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
||||||
onRefreshAuthorizedDevices: () => Promise<void>;
|
onRefreshAuthorizedDevices: () => Promise<void>;
|
||||||
@@ -149,6 +158,7 @@ export interface AppMainRoutesProps {
|
|||||||
|
|
||||||
export default function AppMainRoutes(props: AppMainRoutesProps) {
|
export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||||
const importRoutePaths = [props.importRoute, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
|
const importRoutePaths = [props.importRoute, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
|
||||||
|
const deviceManagementRoutePaths = ['/security/devices', '/settings/security/device-management'] as const;
|
||||||
const isAdmin = String(props.profile?.role || '').toLowerCase() === 'admin';
|
const isAdmin = String(props.profile?.role || '').toLowerCase() === 'admin';
|
||||||
const importPageContent = (
|
const importPageContent = (
|
||||||
<Suspense fallback={<RouteContentFallback />}>
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
@@ -261,6 +271,15 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
onGetRecoveryCode={props.onGetRecoveryCode}
|
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||||
onGetApiKey={props.onGetApiKey}
|
onGetApiKey={props.onGetApiKey}
|
||||||
onRotateApiKey={props.onRotateApiKey}
|
onRotateApiKey={props.onRotateApiKey}
|
||||||
|
onListAccountPasskeys={props.onListAccountPasskeys}
|
||||||
|
onCreateAccountPasskey={props.onCreateAccountPasskey}
|
||||||
|
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
|
||||||
|
onDeleteAccountPasskey={props.onDeleteAccountPasskey}
|
||||||
|
pendingAuthRequests={props.pendingAuthRequests}
|
||||||
|
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
||||||
|
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
||||||
|
onApproveAuthRequest={props.onApproveAuthRequest}
|
||||||
|
onDenyAuthRequest={props.onDenyAuthRequest}
|
||||||
onLockTimeoutChange={props.onLockTimeoutChange}
|
onLockTimeoutChange={props.onLockTimeoutChange}
|
||||||
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
||||||
onNotify={props.onNotify}
|
onNotify={props.onNotify}
|
||||||
@@ -279,7 +298,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
<SettingsIcon size={18} />
|
<SettingsIcon size={18} />
|
||||||
<span>{t('nav_account_settings')}</span>
|
<span>{t('nav_account_settings')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/security/devices" className="mobile-settings-link">
|
<Link href="/settings/security/device-management" className="mobile-settings-link">
|
||||||
<Shield size={18} />
|
<Shield size={18} />
|
||||||
<span>{t('nav_device_management')}</span>
|
<span>{t('nav_device_management')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -319,32 +338,39 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
<LoadingState card lines={4} />
|
<LoadingState card lines={4} />
|
||||||
) : null}
|
) : null}
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/security/devices">
|
{deviceManagementRoutePaths.map((path) => (
|
||||||
<div className="stack">
|
<Route key={path} path={path}>
|
||||||
{props.mobileLayout && (
|
<div className="stack">
|
||||||
<div className="mobile-settings-subhead">
|
{props.mobileLayout && (
|
||||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
<div className="mobile-settings-subhead">
|
||||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||||
{t('txt_back')}
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||||
</button>
|
{t('txt_back')}
|
||||||
</div>
|
</button>
|
||||||
)}
|
</div>
|
||||||
<Suspense fallback={<RouteContentFallback />}>
|
)}
|
||||||
<SecurityDevicesPage
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
devices={props.authorizedDevices}
|
<SecurityDevicesPage
|
||||||
loading={props.authorizedDevicesLoading}
|
devices={props.authorizedDevices}
|
||||||
error={props.authorizedDevicesError}
|
loading={props.authorizedDevicesLoading}
|
||||||
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
error={props.authorizedDevicesError}
|
||||||
onRenameDevice={props.onRenameAuthorizedDevice}
|
pendingAuthRequests={props.pendingAuthRequests}
|
||||||
onRevokeTrust={props.onRevokeDeviceTrust}
|
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
||||||
onTrustPermanently={props.onTrustDevicePermanently}
|
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
||||||
onRemoveDevice={props.onRemoveDevice}
|
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
||||||
onRevokeAll={props.onRevokeAllDeviceTrust}
|
onApproveAuthRequest={props.onApproveAuthRequest}
|
||||||
onRemoveAll={props.onRemoveAllDevices}
|
onDenyAuthRequest={props.onDenyAuthRequest}
|
||||||
/>
|
onRenameDevice={props.onRenameAuthorizedDevice}
|
||||||
</Suspense>
|
onRevokeTrust={props.onRevokeDeviceTrust}
|
||||||
</div>
|
onTrustPermanently={props.onTrustDevicePermanently}
|
||||||
</Route>
|
onRemoveDevice={props.onRemoveDevice}
|
||||||
|
onRevokeAll={props.onRevokeAllDeviceTrust}
|
||||||
|
onRemoveAll={props.onRemoveAllDevices}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</Route>
|
||||||
|
))}
|
||||||
<Route path="/settings/domain-rules">
|
<Route path="/settings/domain-rules">
|
||||||
<div className="stack domain-rules-route">
|
<div className="stack domain-rules-route">
|
||||||
{props.mobileLayout && (
|
{props.mobileLayout && (
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { ShieldCheck, ShieldX } from 'lucide-preact';
|
||||||
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import type { AuthRequest } from '@/lib/types';
|
||||||
|
|
||||||
|
interface AuthRequestApprovalDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
authRequest: AuthRequest | null;
|
||||||
|
submitting: boolean;
|
||||||
|
onApprove: () => void;
|
||||||
|
onDeny: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null | undefined): string {
|
||||||
|
if (!value) return t('txt_dash');
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return value;
|
||||||
|
return parsed.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthRequestApprovalDialog(props: AuthRequestApprovalDialogProps) {
|
||||||
|
const authRequest = props.authRequest;
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
open={props.open && !!authRequest}
|
||||||
|
title={t('txt_approve_device_login')}
|
||||||
|
message={t('txt_auth_request_approve_message')}
|
||||||
|
confirmText={props.submitting ? t('txt_approving') : t('txt_approve')}
|
||||||
|
cancelText={t('txt_later')}
|
||||||
|
confirmDisabled={props.submitting || !authRequest}
|
||||||
|
cancelDisabled={props.submitting}
|
||||||
|
onConfirm={props.onApprove}
|
||||||
|
onCancel={props.onClose}
|
||||||
|
afterActions={(
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger dialog-btn"
|
||||||
|
disabled={props.submitting || !authRequest}
|
||||||
|
onClick={props.onDeny}
|
||||||
|
>
|
||||||
|
<ShieldX size={14} className="btn-icon" />
|
||||||
|
{t('txt_deny')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{authRequest && (
|
||||||
|
<div className="auth-request-details">
|
||||||
|
<div className="auth-request-device">
|
||||||
|
<ShieldCheck size={18} />
|
||||||
|
<div>
|
||||||
|
<strong>{authRequest.requestDeviceType || t('txt_unknown_device')}</strong>
|
||||||
|
<small>{authRequest.requestDeviceIdentifier}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="auth-request-kv">
|
||||||
|
<span>{t('txt_created')}</span>
|
||||||
|
<strong>{formatDateTime(authRequest.creationDate)}</strong>
|
||||||
|
</div>
|
||||||
|
{authRequest.requestIpAddress && (
|
||||||
|
<div className="auth-request-kv">
|
||||||
|
<span>{t('txt_ip_address')}</span>
|
||||||
|
<strong>{authRequest.requestIpAddress}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="auth-request-fingerprint">
|
||||||
|
<span>{t('txt_fingerprint_phrase')}</span>
|
||||||
|
<strong>{authRequest.fingerprintPhrase || t('txt_dash')}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ConfirmDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'preact/hooks';
|
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 StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -22,19 +23,25 @@ interface AuthViewsProps {
|
|||||||
relaxedLoginInput?: boolean;
|
relaxedLoginInput?: boolean;
|
||||||
authPlaceholder?: string;
|
authPlaceholder?: string;
|
||||||
unlockPlaceholder?: string;
|
unlockPlaceholder?: string;
|
||||||
pendingAction: 'login' | 'register' | 'unlock' | null;
|
pendingAction: 'login' | 'passkey' | 'register' | 'unlock' | null;
|
||||||
unlockReady: boolean;
|
unlockReady: boolean;
|
||||||
unlockPreparing: boolean;
|
unlockPreparing: boolean;
|
||||||
loginValues: LoginValues;
|
loginValues: LoginValues;
|
||||||
|
pendingPasskeyPasswordEmail?: string | null;
|
||||||
|
passkeyPassword: string;
|
||||||
registerValues: RegisterValues;
|
registerValues: RegisterValues;
|
||||||
registrationInviteRequired?: boolean;
|
registrationInviteRequired?: boolean;
|
||||||
unlockPassword: string;
|
unlockPassword: string;
|
||||||
emailForLock: string;
|
emailForLock: string;
|
||||||
loginHintLoading: boolean;
|
loginHintLoading: boolean;
|
||||||
onChangeLogin: (next: LoginValues) => void;
|
onChangeLogin: (next: LoginValues) => void;
|
||||||
|
onChangePasskeyPassword: (password: string) => void;
|
||||||
onChangeRegister: (next: RegisterValues) => void;
|
onChangeRegister: (next: RegisterValues) => void;
|
||||||
onChangeUnlock: (password: string) => void;
|
onChangeUnlock: (password: string) => void;
|
||||||
onSubmitLogin: () => void;
|
onSubmitLogin: () => void;
|
||||||
|
onSubmitPasskey: () => void;
|
||||||
|
onSubmitPasskeyUnlock: () => void;
|
||||||
|
onSubmitPasskeyPassword: () => void;
|
||||||
onSubmitRegister: () => void;
|
onSubmitRegister: () => void;
|
||||||
onSubmitUnlock: () => void;
|
onSubmitUnlock: () => void;
|
||||||
onGotoLogin: () => void;
|
onGotoLogin: () => void;
|
||||||
@@ -76,14 +83,16 @@ function PasswordField(props: {
|
|||||||
|
|
||||||
export default function AuthViews(props: AuthViewsProps) {
|
export default function AuthViews(props: AuthViewsProps) {
|
||||||
const loginBusy = props.pendingAction === 'login';
|
const loginBusy = props.pendingAction === 'login';
|
||||||
|
const passkeyBusy = props.pendingAction === 'passkey';
|
||||||
const registerBusy = props.pendingAction === 'register';
|
const registerBusy = props.pendingAction === 'register';
|
||||||
const unlockBusy = props.pendingAction === 'unlock';
|
const unlockBusy = props.pendingAction === 'unlock';
|
||||||
|
const passkeyPasswordPending = !!props.pendingPasskeyPasswordEmail;
|
||||||
const showInviteCodeField = props.registrationInviteRequired !== false || !!props.registerValues.inviteCode.trim();
|
const showInviteCodeField = props.registrationInviteRequired !== false || !!props.registerValues.inviteCode.trim();
|
||||||
|
|
||||||
if (props.mode === 'locked') {
|
if (props.mode === 'locked') {
|
||||||
return (
|
return (
|
||||||
<div className="auth-page">
|
<div className="auth-page">
|
||||||
<StandalonePageFrame title={t('txt_unlock_vault')}>
|
<StandalonePageFrame title={t('txt_unlock_vault')} titleAccessory={<NetworkStatusBadge />}>
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -114,12 +123,21 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
{props.unlockPreparing ? (
|
{props.unlockPreparing ? (
|
||||||
<p className="muted standalone-muted">{t('txt_loading')}</p>
|
<p className="muted standalone-muted">{t('txt_loading')}</p>
|
||||||
) : null}
|
) : 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" />
|
<Unlock size={16} className="btn-icon" />
|
||||||
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
|
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
|
||||||
</button>
|
</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>
|
<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" />
|
<LogOut size={16} className="btn-icon" />
|
||||||
{t('txt_log_out')}
|
{t('txt_log_out')}
|
||||||
</button>
|
</button>
|
||||||
@@ -132,7 +150,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
if (props.mode === 'register') {
|
if (props.mode === 'register') {
|
||||||
return (
|
return (
|
||||||
<div className="auth-page">
|
<div className="auth-page">
|
||||||
<StandalonePageFrame title={t('txt_create_account')}>
|
<StandalonePageFrame title={t('txt_create_account')} titleAccessory={<NetworkStatusBadge />}>
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -216,13 +234,41 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-page">
|
<div className="auth-page">
|
||||||
<StandalonePageFrame title={t('txt_log_in')}>
|
<StandalonePageFrame title={t('txt_log_in')} titleAccessory={<NetworkStatusBadge />}>
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (passkeyPasswordPending) {
|
||||||
|
props.onSubmitPasskeyPassword();
|
||||||
|
return;
|
||||||
|
}
|
||||||
props.onSubmitLogin();
|
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">
|
<label className="field">
|
||||||
<span>{t('txt_email')}</span>
|
<span>{t('txt_email')}</span>
|
||||||
<input
|
<input
|
||||||
@@ -255,15 +301,21 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
: t('txt_show_password_hint')}
|
: t('txt_show_password_hint')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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" />
|
<LogIn size={16} className="btn-icon" />
|
||||||
{loginBusy ? t('txt_logging_in') : t('txt_log_in')}
|
{loginBusy ? t('txt_logging_in') : t('txt_log_in')}
|
||||||
</button>
|
</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>
|
<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" />
|
<UserPlus size={16} className="btn-icon" />
|
||||||
{t('txt_create_account')}
|
{t('txt_create_account')}
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</StandalonePageFrame>
|
</StandalonePageFrame>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
const [present, setPresent] = useState(props.open);
|
const [present, setPresent] = useState(props.open);
|
||||||
const [closing, setClosing] = useState(false);
|
const [closing, setClosing] = useState(false);
|
||||||
const cardRef = useRef<HTMLFormElement | null>(null);
|
const cardRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
const maskPointerStartedRef = useRef(false);
|
||||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||||
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
||||||
const titleId = `${dialogId}-title`;
|
const titleId = `${dialogId}-title`;
|
||||||
@@ -176,8 +177,11 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
return createPortal((
|
return createPortal((
|
||||||
<div
|
<div
|
||||||
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
maskPointerStartedRef.current = event.target === event.currentTarget;
|
||||||
|
}}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (event.target !== event.currentTarget || !canDismiss) return;
|
if (event.target !== event.currentTarget || !maskPointerStartedRef.current || !canDismiss) return;
|
||||||
props.onCancel();
|
props.onCancel();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [
|
|||||||
'lastpass',
|
'lastpass',
|
||||||
'dashlane_csv',
|
'dashlane_csv',
|
||||||
'dashlane_json',
|
'dashlane_json',
|
||||||
|
'keepass_csv',
|
||||||
'keepass_xml',
|
'keepass_xml',
|
||||||
'keepassx_csv',
|
'keepassx_csv',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export function CardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="skeleton-card">
|
||||||
|
<div className="skeleton-avatar" />
|
||||||
|
<div className="skeleton-content">
|
||||||
|
<div className="skeleton-line skeleton-line-lg" />
|
||||||
|
<div className="skeleton-line" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListSkeleton({ count = 5 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div key={i} className="skeleton-list-item">
|
||||||
|
<div className="skeleton-icon" />
|
||||||
|
<div className="skeleton-content">
|
||||||
|
<div className="skeleton-line skeleton-line-md" />
|
||||||
|
<div className="skeleton-line skeleton-line-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="skeleton-page">
|
||||||
|
<div className="skeleton-header">
|
||||||
|
<div className="skeleton-line skeleton-line-xl" />
|
||||||
|
</div>
|
||||||
|
<div className="skeleton-body">
|
||||||
|
<ListSkeleton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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 timer = 0;
|
||||||
|
|
||||||
|
const checkService = async () => {
|
||||||
|
if (browserReportsOffline()) {
|
||||||
|
setCurrentNetworkStatus('offline');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await probeNodeWardenService();
|
||||||
|
};
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
import { RefreshCw, ShieldCheck, ShieldX } from 'lucide-preact';
|
||||||
|
import LoadingState from '@/components/LoadingState';
|
||||||
|
import type { AuthRequest } from '@/lib/types';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface PendingAuthRequestsPanelProps {
|
||||||
|
pendingAuthRequests: AuthRequest[];
|
||||||
|
pendingAuthRequestsLoading: boolean;
|
||||||
|
onRefreshPendingAuthRequests: () => Promise<void>;
|
||||||
|
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||||
|
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||||
|
className?: string;
|
||||||
|
loadingVariant?: 'placeholder' | 'compact';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null | undefined): string {
|
||||||
|
if (!value) return t('txt_dash');
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? t('txt_dash') : date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PendingAuthRequestsPanel(props: PendingAuthRequestsPanelProps) {
|
||||||
|
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
|
||||||
|
if (authRequestSubmittingId) return;
|
||||||
|
setAuthRequestSubmittingId(authRequest.id);
|
||||||
|
try {
|
||||||
|
await props.onApproveAuthRequest(authRequest);
|
||||||
|
} finally {
|
||||||
|
setAuthRequestSubmittingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function denyAuthRequest(authRequest: AuthRequest): Promise<void> {
|
||||||
|
if (authRequestSubmittingId) return;
|
||||||
|
setAuthRequestSubmittingId(authRequest.id);
|
||||||
|
try {
|
||||||
|
await props.onDenyAuthRequest(authRequest);
|
||||||
|
} finally {
|
||||||
|
setAuthRequestSubmittingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={props.className || 'card settings-module'}>
|
||||||
|
<div className="settings-module-head">
|
||||||
|
<h3>{t('txt_pending_device_logins')}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
disabled={props.pendingAuthRequestsLoading}
|
||||||
|
onClick={() => void props.onRefreshPendingAuthRequests()}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className="btn-icon" />
|
||||||
|
{t('txt_refresh')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="account-passkeys-list">
|
||||||
|
{props.pendingAuthRequestsLoading && props.pendingAuthRequests.length === 0 ? (
|
||||||
|
props.loadingVariant === 'compact' ? (
|
||||||
|
<LoadingState lines={2} compact />
|
||||||
|
) : (
|
||||||
|
<div className="settings-module-placeholder">
|
||||||
|
<RefreshCw size={20} />
|
||||||
|
<span>{t('txt_loading')}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : props.pendingAuthRequests.length === 0 ? (
|
||||||
|
<div className="settings-module-placeholder">
|
||||||
|
<ShieldCheck size={20} />
|
||||||
|
<span>{t('txt_no_pending_device_logins')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
props.pendingAuthRequests.map((authRequest) => (
|
||||||
|
<div key={authRequest.id} className="account-passkey-row auth-request-row">
|
||||||
|
<div className="account-passkey-main">
|
||||||
|
<strong>{authRequest.requestDeviceType || t('txt_unknown_device')}</strong>
|
||||||
|
<small>{authRequest.requestDeviceIdentifier}</small>
|
||||||
|
<small>{t('txt_created_value', { value: formatDateTime(authRequest.creationDate) })}</small>
|
||||||
|
</div>
|
||||||
|
<span className="auth-request-fingerprint-inline">
|
||||||
|
{authRequest.fingerprintPhrase || t('txt_dash')}
|
||||||
|
</span>
|
||||||
|
<div className="actions account-passkey-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary small"
|
||||||
|
disabled={!!authRequestSubmittingId}
|
||||||
|
onClick={() => void approveAuthRequest(authRequest)}
|
||||||
|
>
|
||||||
|
<ShieldCheck size={14} className="btn-icon" />
|
||||||
|
{authRequestSubmittingId === authRequest.id ? t('txt_approving') : t('txt_approve')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger small"
|
||||||
|
disabled={!!authRequestSubmittingId}
|
||||||
|
onClick={() => void denyAuthRequest(authRequest)}
|
||||||
|
>
|
||||||
|
<ShieldX size={14} className="btn-icon" />
|
||||||
|
{t('txt_deny')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,14 +2,20 @@ import { useState } from 'preact/hooks';
|
|||||||
import { Clock3, Pencil, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
|
import { Clock3, Pencil, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { AuthorizedDevice } from '@/lib/types';
|
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
|
||||||
|
import type { AuthRequest, AuthorizedDevice } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
interface SecurityDevicesPageProps {
|
interface SecurityDevicesPageProps {
|
||||||
devices: AuthorizedDevice[];
|
devices: AuthorizedDevice[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
|
pendingAuthRequests: AuthRequest[];
|
||||||
|
pendingAuthRequestsLoading: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
onRefreshPendingAuthRequests: () => Promise<void>;
|
||||||
|
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||||
|
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||||
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||||
onRevokeTrust: (device: AuthorizedDevice) => void;
|
onRevokeTrust: (device: AuthorizedDevice) => void;
|
||||||
onTrustPermanently: (device: AuthorizedDevice) => void;
|
onTrustPermanently: (device: AuthorizedDevice) => void;
|
||||||
@@ -72,6 +78,16 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
|
<PendingAuthRequestsPanel
|
||||||
|
className="card"
|
||||||
|
loadingVariant="compact"
|
||||||
|
pendingAuthRequests={props.pendingAuthRequests}
|
||||||
|
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
||||||
|
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
||||||
|
onApproveAuthRequest={props.onApproveAuthRequest}
|
||||||
|
onDenyAuthRequest={props.onDenyAuthRequest}
|
||||||
|
/>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
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 { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import qrcode from 'qrcode-generator';
|
import qrcode from 'qrcode-generator';
|
||||||
import type { Profile } from '@/lib/types';
|
import type { AccountPasskeyCredential, AuthRequest, Profile } from '@/lib/types';
|
||||||
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
|
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
|
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
|
||||||
|
|
||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
profile: Profile;
|
profile: Profile;
|
||||||
@@ -18,11 +19,28 @@ interface SettingsPageProps {
|
|||||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||||
onRotateApiKey: (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>;
|
||||||
|
pendingAuthRequests: AuthRequest[];
|
||||||
|
pendingAuthRequestsLoading: boolean;
|
||||||
|
onRefreshPendingAuthRequests: () => Promise<void>;
|
||||||
|
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||||
|
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||||
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||||
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => 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 = [
|
const LOCK_TIMEOUT_OPTIONS = [
|
||||||
{ value: 1, labelKey: 'txt_timeout_1_minute' },
|
{ value: 1, labelKey: 'txt_timeout_1_minute' },
|
||||||
{ value: 5, labelKey: 'txt_timeout_5_minutes' },
|
{ value: 5, labelKey: 'txt_timeout_5_minutes' },
|
||||||
@@ -64,6 +82,13 @@ function clearLegacyTotpSetupSecrets(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null | undefined): string {
|
||||||
|
if (!value) return t('txt_dash');
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return t('txt_dash');
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage(props: SettingsPageProps) {
|
export default function SettingsPage(props: SettingsPageProps) {
|
||||||
const [currentPassword, setCurrentPassword] = useState('');
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
@@ -74,9 +99,14 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
||||||
const [recoveryCode, setRecoveryCode] = useState('');
|
const [recoveryCode, setRecoveryCode] = useState('');
|
||||||
const [apiKey, setApiKey] = 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 [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
|
||||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = 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 [masterPasswordPromptValue, setMasterPasswordPromptValue] = useState('');
|
||||||
const [masterPasswordPromptSubmitting, setMasterPasswordPromptSubmitting] = useState(false);
|
const [masterPasswordPromptSubmitting, setMasterPasswordPromptSubmitting] = useState(false);
|
||||||
const [selectedLocale, setSelectedLocale] = useState<Locale>(() => getLocale());
|
const [selectedLocale, setSelectedLocale] = useState<Locale>(() => getLocale());
|
||||||
@@ -97,6 +127,10 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
setPasswordHint(props.profile.masterPasswordHint || '');
|
setPasswordHint(props.profile.masterPasswordHint || '');
|
||||||
}, [props.profile.masterPasswordHint]);
|
}, [props.profile.masterPasswordHint]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshAccountPasskeys();
|
||||||
|
}, [props.profile.id]);
|
||||||
|
|
||||||
const qrDataUrl = useMemo(() => {
|
const qrDataUrl = useMemo(() => {
|
||||||
const qr = qrcode(0, 'M');
|
const qr = qrcode(0, 'M');
|
||||||
qr.addData(buildOtpUri(props.profile.email, secret));
|
qr.addData(buildOtpUri(props.profile.email, secret));
|
||||||
@@ -115,14 +149,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);
|
setMasterPasswordPrompt(action);
|
||||||
|
setAccountPasskeyPromptId(credentialId || null);
|
||||||
setMasterPasswordPromptValue('');
|
setMasterPasswordPromptValue('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeMasterPasswordPrompt(): void {
|
function closeMasterPasswordPrompt(): void {
|
||||||
if (masterPasswordPromptSubmitting) return;
|
if (masterPasswordPromptSubmitting) return;
|
||||||
setMasterPasswordPrompt(null);
|
setMasterPasswordPrompt(null);
|
||||||
|
setAccountPasskeyPromptId(null);
|
||||||
setMasterPasswordPromptValue('');
|
setMasterPasswordPromptValue('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,13 +186,25 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
const key = await props.onGetApiKey(masterPassword);
|
const key = await props.onGetApiKey(masterPassword);
|
||||||
setApiKey(key);
|
setApiKey(key);
|
||||||
setApiKeyDialogOpen(true);
|
setApiKeyDialogOpen(true);
|
||||||
} else {
|
} else if (masterPasswordPrompt === 'rotateApiKey') {
|
||||||
const key = await props.onRotateApiKey(masterPassword);
|
const key = await props.onRotateApiKey(masterPassword);
|
||||||
setApiKey(key);
|
setApiKey(key);
|
||||||
setApiKeyDialogOpen(true);
|
setApiKeyDialogOpen(true);
|
||||||
props.onNotify?.('success', t('txt_api_key_rotated'));
|
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);
|
setMasterPasswordPrompt(null);
|
||||||
|
setAccountPasskeyPromptId(null);
|
||||||
setMasterPasswordPromptValue('');
|
setMasterPasswordPromptValue('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_master_password_is_required_2'));
|
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_master_password_is_required_2'));
|
||||||
@@ -159,13 +218,18 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
? t('txt_view_recovery_code')
|
? t('txt_view_recovery_code')
|
||||||
: masterPasswordPrompt === 'rotateApiKey'
|
: masterPasswordPrompt === 'rotateApiKey'
|
||||||
? t('txt_rotate_api_key')
|
? 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 formatDateTime(value: string | null | undefined): string {
|
function accountPasskeyStatusText(credential: AccountPasskeyCredential): string {
|
||||||
if (!value) return t('txt_dash');
|
if (credential.prfStatus === 0) return t('txt_direct_unlock');
|
||||||
const parsed = new Date(value);
|
if (credential.prfStatus === 1) return t('txt_login_only');
|
||||||
if (Number.isNaN(parsed.getTime())) return value;
|
return t('txt_prf_not_supported');
|
||||||
return parsed.toLocaleString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function changeLocale(next: Locale): Promise<void> {
|
async function changeLocale(next: Locale): Promise<void> {
|
||||||
@@ -345,6 +409,115 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
|
|
||||||
|
<PendingAuthRequestsPanel
|
||||||
|
pendingAuthRequests={props.pendingAuthRequests}
|
||||||
|
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
||||||
|
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
||||||
|
onApproveAuthRequest={props.onApproveAuthRequest}
|
||||||
|
onDenyAuthRequest={props.onDenyAuthRequest}
|
||||||
|
/>
|
||||||
|
|
||||||
<section className="settings-module sensitive-actions-module">
|
<section className="settings-module sensitive-actions-module">
|
||||||
<div className="sensitive-actions-grid">
|
<div className="sensitive-actions-grid">
|
||||||
<div className="sensitive-action">
|
<div className="sensitive-action">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { APP_VERSION } from '@shared/app-version';
|
|||||||
interface StandalonePageFrameProps {
|
interface StandalonePageFrameProps {
|
||||||
title: string;
|
title: string;
|
||||||
eyebrow?: ComponentChildren;
|
eyebrow?: ComponentChildren;
|
||||||
|
titleAccessory?: ComponentChildren;
|
||||||
children: ComponentChildren;
|
children: ComponentChildren;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +20,10 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
|
|||||||
|
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
{props.eyebrow && <div className="standalone-eyebrow">{props.eyebrow}</div>}
|
{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}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ export default function ToastHost({ toasts, onClose }: ToastHostProps) {
|
|||||||
{toasts.map((toast) => (
|
{toasts.map((toast) => (
|
||||||
<li key={toast.id} className={`toast-item ${toast.type}`}>
|
<li key={toast.id} className={`toast-item ${toast.type}`}>
|
||||||
<div className="toast-text">{toast.text}</div>
|
<div className="toast-text">{toast.text}</div>
|
||||||
<button type="button" className="toast-close" onClick={() => onClose(toast.id)}>
|
<button type="button" className="toast-close" onClick={() => onClose(toast.id)} aria-label="关闭通知">
|
||||||
x
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
||||||
|
<path d="M3 3l8 8M11 3l-8 8" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div className="toast-progress" />
|
<div className="toast-progress" />
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { t } from '@/lib/i18n';
|
|||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
import WebsiteIcon from '@/components/vault/WebsiteIcon';
|
import WebsiteIcon from '@/components/vault/WebsiteIcon';
|
||||||
import { isCipherVisibleInNormalVault } from '@/components/vault/vault-page-helpers';
|
import { formatTotp, isCipherVisibleInNormalVault } from '@/components/vault/vault-page-helpers';
|
||||||
|
|
||||||
interface TotpCodesPageProps {
|
interface TotpCodesPageProps {
|
||||||
ciphers: Cipher[];
|
ciphers: Cipher[];
|
||||||
@@ -26,13 +26,6 @@ function getTotpTimeState(): { windowId: number; remain: number } {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTotp(code: string): string {
|
|
||||||
if (!code) return code;
|
|
||||||
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
|
|
||||||
if (code.length < 6) return code;
|
|
||||||
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,14 @@ import {
|
|||||||
createEmptyDraft,
|
createEmptyDraft,
|
||||||
creationTimeValue,
|
creationTimeValue,
|
||||||
draftFromCipher,
|
draftFromCipher,
|
||||||
buildCipherDuplicateSignature,
|
buildCipherDuplicateSignatures,
|
||||||
firstCipherUri,
|
firstCipherUri,
|
||||||
firstPasskeyCreationTime,
|
firstPasskeyCreationTime,
|
||||||
isCipherVisibleInArchive,
|
isCipherVisibleInArchive,
|
||||||
isCipherVisibleInNormalVault,
|
isCipherVisibleInNormalVault,
|
||||||
isCipherVisibleInTrash,
|
isCipherVisibleInTrash,
|
||||||
sortTimeValue,
|
sortTimeValue,
|
||||||
|
type DuplicateDetectionMode,
|
||||||
type SidebarFilter,
|
type SidebarFilter,
|
||||||
type VaultSortMode,
|
type VaultSortMode,
|
||||||
} from '@/components/vault/vault-page-helpers';
|
} from '@/components/vault/vault-page-helpers';
|
||||||
@@ -79,6 +80,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const [sortMenuOpen, setSortMenuOpen] = useState(false);
|
const [sortMenuOpen, setSortMenuOpen] = useState(false);
|
||||||
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
|
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
|
||||||
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
|
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
|
||||||
|
const [duplicateMode, setDuplicateMode] = useState<DuplicateDetectionMode>('exact');
|
||||||
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
||||||
const [selectedCipherId, setSelectedCipherId] = useState('');
|
const [selectedCipherId, setSelectedCipherId] = useState('');
|
||||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||||
@@ -338,16 +340,41 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
|
|
||||||
const duplicateSignatureInfo = useMemo(() => {
|
const duplicateSignatureInfo = useMemo(() => {
|
||||||
if (sidebarFilter.kind !== 'duplicates') return null;
|
if (sidebarFilter.kind !== 'duplicates') return null;
|
||||||
const byId = new Map<string, string>();
|
const byId = new Map<string, string[]>();
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
for (const cipher of props.ciphers) {
|
for (const cipher of props.ciphers) {
|
||||||
if (!isCipherVisibleInNormalVault(cipher)) continue;
|
if (!isCipherVisibleInNormalVault(cipher)) continue;
|
||||||
const signature = buildCipherDuplicateSignature(cipher);
|
const signatures = Array.from(new Set(buildCipherDuplicateSignatures(cipher, duplicateMode)));
|
||||||
byId.set(cipher.id, signature);
|
byId.set(cipher.id, signatures);
|
||||||
counts.set(signature, (counts.get(signature) || 0) + 1);
|
for (const signature of signatures) {
|
||||||
|
counts.set(signature, (counts.get(signature) || 0) + 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { byId, counts };
|
return { byId, counts };
|
||||||
}, [props.ciphers, sidebarFilter.kind]);
|
}, [props.ciphers, sidebarFilter.kind, duplicateMode]);
|
||||||
|
|
||||||
|
const duplicateGroupIndexById = useMemo(() => {
|
||||||
|
if (!duplicateSignatureInfo) return new Map<string, number>();
|
||||||
|
const groupKeyById = new Map<string, string>();
|
||||||
|
const groupKeys = new Set<string>();
|
||||||
|
for (const cipher of props.ciphers) {
|
||||||
|
const groupKey = (duplicateSignatureInfo.byId.get(cipher.id) || [])
|
||||||
|
.filter((signature) => (duplicateSignatureInfo.counts.get(signature) || 0) >= 2)
|
||||||
|
.sort()[0];
|
||||||
|
if (!groupKey) continue;
|
||||||
|
groupKeyById.set(cipher.id, groupKey);
|
||||||
|
groupKeys.add(groupKey);
|
||||||
|
}
|
||||||
|
const groupIndexByKey = new Map<string, number>();
|
||||||
|
Array.from(groupKeys).sort().forEach((groupKey, index) => {
|
||||||
|
groupIndexByKey.set(groupKey, index % 64);
|
||||||
|
});
|
||||||
|
const byId = new Map<string, number>();
|
||||||
|
for (const [cipherId, groupKey] of groupKeyById.entries()) {
|
||||||
|
byId.set(cipherId, groupIndexByKey.get(groupKey) || 0);
|
||||||
|
}
|
||||||
|
return byId;
|
||||||
|
}, [props.ciphers, duplicateSignatureInfo]);
|
||||||
|
|
||||||
const filteredCiphers = useMemo(() => {
|
const filteredCiphers = useMemo(() => {
|
||||||
const next = props.ciphers.filter((cipher) => {
|
const next = props.ciphers.filter((cipher) => {
|
||||||
@@ -358,8 +385,11 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
if (!isCipherVisibleInArchive(cipher)) return false;
|
if (!isCipherVisibleInArchive(cipher)) return false;
|
||||||
} else {
|
} else {
|
||||||
if (!isCipherVisibleInNormalVault(cipher)) return false;
|
if (!isCipherVisibleInNormalVault(cipher)) return false;
|
||||||
if (sidebarFilter.kind === 'duplicates' && ((duplicateSignatureInfo?.counts.get(duplicateSignatureInfo.byId.get(cipher.id) || '') || 0) < 2)) {
|
if (sidebarFilter.kind === 'duplicates') {
|
||||||
return false;
|
const signatures = duplicateSignatureInfo?.byId.get(cipher.id) || [];
|
||||||
|
if (!signatures.some((signature) => (duplicateSignatureInfo?.counts.get(signature) || 0) >= 2)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
|
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
|
||||||
if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false;
|
if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false;
|
||||||
@@ -404,8 +434,9 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const sidebarFilterKey = useMemo(() => {
|
const sidebarFilterKey = useMemo(() => {
|
||||||
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
|
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
|
||||||
if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`;
|
if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`;
|
||||||
|
if (sidebarFilter.kind === 'duplicates') return `duplicates:${duplicateMode}`;
|
||||||
return sidebarFilter.kind;
|
return sidebarFilter.kind;
|
||||||
}, [sidebarFilter]);
|
}, [sidebarFilter, duplicateMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setListScrollTop(0);
|
setListScrollTop(0);
|
||||||
@@ -419,6 +450,10 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
}
|
}
|
||||||
}, [sidebarFilter.kind, sortMode]);
|
}, [sidebarFilter.kind, sortMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
|
||||||
|
}, [sidebarFilter.kind, duplicateMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreating) return;
|
if (isCreating) return;
|
||||||
if (!filteredCiphers.length) {
|
if (!filteredCiphers.length) {
|
||||||
@@ -716,6 +751,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
setAttachmentQueue([]);
|
setAttachmentQueue([]);
|
||||||
setRemovedAttachmentIds({});
|
setRemovedAttachmentIds({});
|
||||||
if (isMobileLayout) setMobilePanel('detail');
|
if (isMobileLayout) setMobilePanel('detail');
|
||||||
|
} catch {
|
||||||
|
// The action layer already shows the user-facing error toast.
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -729,6 +766,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
setPendingDelete(null);
|
setPendingDelete(null);
|
||||||
cancelEdit();
|
cancelEdit();
|
||||||
if (isMobileLayout) setMobilePanel('list');
|
if (isMobileLayout) setMobilePanel('list');
|
||||||
|
} catch {
|
||||||
|
// The action layer already shows the user-facing error toast.
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -741,6 +780,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
if (isMobileLayout && selectedCipherId === cipher.id) {
|
if (isMobileLayout && selectedCipherId === cipher.id) {
|
||||||
setMobilePanel('list');
|
setMobilePanel('list');
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// The action layer already shows the user-facing error toast.
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -760,6 +801,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
}
|
}
|
||||||
setSelectedMap({});
|
setSelectedMap({});
|
||||||
setBulkDeleteOpen(false);
|
setBulkDeleteOpen(false);
|
||||||
|
} catch {
|
||||||
|
// The action layer already shows the user-facing error toast.
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -776,6 +819,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
await props.onBulkMove(ids, folderId);
|
await props.onBulkMove(ids, folderId);
|
||||||
setSelectedMap({});
|
setSelectedMap({});
|
||||||
setMoveOpen(false);
|
setMoveOpen(false);
|
||||||
|
} catch {
|
||||||
|
// The action layer already shows the user-facing error toast.
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -785,6 +830,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await props.onRefresh();
|
await props.onRefresh();
|
||||||
|
} catch {
|
||||||
|
// The action layer already shows the user-facing error toast.
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -819,6 +866,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
await props.onCreateFolder(newFolderName);
|
await props.onCreateFolder(newFolderName);
|
||||||
setCreateFolderOpen(false);
|
setCreateFolderOpen(false);
|
||||||
setNewFolderName('');
|
setNewFolderName('');
|
||||||
|
} catch {
|
||||||
|
// The action layer already shows the user-facing error toast.
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -833,6 +882,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
setSidebarFilter({ kind: 'all' });
|
setSidebarFilter({ kind: 'all' });
|
||||||
}
|
}
|
||||||
setPendingDeleteFolder(null);
|
setPendingDeleteFolder(null);
|
||||||
|
} catch {
|
||||||
|
// The action layer already shows the user-facing error toast.
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -850,6 +901,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
await props.onRenameFolder(pendingRenameFolder.id, nextName);
|
await props.onRenameFolder(pendingRenameFolder.id, nextName);
|
||||||
setPendingRenameFolder(null);
|
setPendingRenameFolder(null);
|
||||||
setRenameFolderName('');
|
setRenameFolderName('');
|
||||||
|
} catch {
|
||||||
|
// The action layer already shows the user-facing error toast.
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -864,6 +917,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
try {
|
try {
|
||||||
await props.onBulkRestore(ids);
|
await props.onBulkRestore(ids);
|
||||||
setSelectedMap({});
|
setSelectedMap({});
|
||||||
|
} catch {
|
||||||
|
// The action layer already shows the user-facing error toast.
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -878,6 +933,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
if (isMobileLayout && selectedCipherId === pendingArchive.id) {
|
if (isMobileLayout && selectedCipherId === pendingArchive.id) {
|
||||||
setMobilePanel('list');
|
setMobilePanel('list');
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// The action layer already shows the user-facing error toast.
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -892,6 +949,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
delete next[cipher.id];
|
delete next[cipher.id];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
} catch {
|
||||||
|
// The action layer already shows the user-facing error toast.
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -907,6 +966,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
await props.onBulkArchive(ids);
|
await props.onBulkArchive(ids);
|
||||||
setSelectedMap({});
|
setSelectedMap({});
|
||||||
setBulkArchiveOpen(false);
|
setBulkArchiveOpen(false);
|
||||||
|
} catch {
|
||||||
|
// The action layer already shows the user-facing error toast.
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -921,6 +982,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
try {
|
try {
|
||||||
await props.onBulkUnarchive(ids);
|
await props.onBulkUnarchive(ids);
|
||||||
setSelectedMap({});
|
setSelectedMap({});
|
||||||
|
} catch {
|
||||||
|
// The action layer already shows the user-facing error toast.
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -935,6 +998,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
setSidebarFilter({ kind: 'all' });
|
setSidebarFilter({ kind: 'all' });
|
||||||
}
|
}
|
||||||
setDeleteAllFoldersOpen(false);
|
setDeleteAllFoldersOpen(false);
|
||||||
|
} catch {
|
||||||
|
// The action layer already shows the user-facing error toast.
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -954,10 +1019,11 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
|
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
|
||||||
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
|
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
|
||||||
const handleSelectDuplicates = useCallback(() => {
|
const handleSelectDuplicates = useCallback(() => {
|
||||||
|
if (duplicateMode !== 'exact') return;
|
||||||
const map: Record<string, boolean> = {};
|
const map: Record<string, boolean> = {};
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const cipher of filteredCiphers) {
|
for (const cipher of filteredCiphers) {
|
||||||
const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher);
|
const signature = duplicateSignatureInfo?.byId.get(cipher.id)?.[0] || buildCipherDuplicateSignatures(cipher, 'exact')[0];
|
||||||
if (seen.has(signature)) {
|
if (seen.has(signature)) {
|
||||||
map[cipher.id] = true;
|
map[cipher.id] = true;
|
||||||
continue;
|
continue;
|
||||||
@@ -965,7 +1031,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
seen.add(signature);
|
seen.add(signature);
|
||||||
}
|
}
|
||||||
setSelectedMap(map);
|
setSelectedMap(map);
|
||||||
}, [filteredCiphers, duplicateSignatureInfo]);
|
}, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
|
||||||
const handleSelectAll = useCallback(() => {
|
const handleSelectAll = useCallback(() => {
|
||||||
const map: Record<string, boolean> = {};
|
const map: Record<string, boolean> = {};
|
||||||
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
||||||
@@ -1049,13 +1115,16 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
busy={busy}
|
busy={busy}
|
||||||
loading={props.loading}
|
loading={props.loading}
|
||||||
error={props.error}
|
error={props.error}
|
||||||
|
folders={props.folders}
|
||||||
searchInput={searchInput}
|
searchInput={searchInput}
|
||||||
sortMode={sortMode}
|
sortMode={sortMode}
|
||||||
sortMenuOpen={sortMenuOpen}
|
sortMenuOpen={sortMenuOpen}
|
||||||
|
duplicateMode={duplicateMode}
|
||||||
selectedCount={selectedCount}
|
selectedCount={selectedCount}
|
||||||
totalCipherCount={totalCipherCount}
|
totalCipherCount={totalCipherCount}
|
||||||
filteredCiphers={filteredCiphers}
|
filteredCiphers={filteredCiphers}
|
||||||
visibleCiphers={visibleCiphers}
|
visibleCiphers={visibleCiphers}
|
||||||
|
duplicateGroupIndexById={duplicateGroupIndexById}
|
||||||
virtualRange={virtualRange}
|
virtualRange={virtualRange}
|
||||||
selectedCipherId={selectedCipherId}
|
selectedCipherId={selectedCipherId}
|
||||||
selectedMap={selectedMap}
|
selectedMap={selectedMap}
|
||||||
@@ -1072,6 +1141,8 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
onSearchCompositionEnd={handleSearchCompositionEnd}
|
onSearchCompositionEnd={handleSearchCompositionEnd}
|
||||||
onToggleSortMenu={handleToggleSortMenu}
|
onToggleSortMenu={handleToggleSortMenu}
|
||||||
onSelectSortMode={handleSelectSortMode}
|
onSelectSortMode={handleSelectSortMode}
|
||||||
|
onDuplicateModeChange={setDuplicateMode}
|
||||||
|
onChangeFilter={setSidebarFilter}
|
||||||
onSyncVault={handleSyncVault}
|
onSyncVault={handleSyncVault}
|
||||||
onOpenBulkDelete={handleOpenBulkDelete}
|
onOpenBulkDelete={handleOpenBulkDelete}
|
||||||
onSelectDuplicates={handleSelectDuplicates}
|
onSelectDuplicates={handleSelectDuplicates}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { CloudUpload, Save, Trash2 } from 'lucide-preact';
|
|||||||
import type {
|
import type {
|
||||||
BackupDestinationRecord,
|
BackupDestinationRecord,
|
||||||
RemoteBackupBrowserResponse,
|
RemoteBackupBrowserResponse,
|
||||||
|
S3BackupAddressingStyle,
|
||||||
S3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
} from '@/lib/api/backup';
|
} from '@/lib/api/backup';
|
||||||
@@ -401,7 +402,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
|
|
||||||
{props.selectedDestination.type === 's3' ? (
|
{props.selectedDestination.type === 's3' ? (
|
||||||
<div className="field-grid">
|
<div className="field-grid">
|
||||||
<label className="field field-span-2">
|
<label className="field">
|
||||||
<span>{t('txt_backup_s3_endpoint')}</span>
|
<span>{t('txt_backup_s3_endpoint')}</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
@@ -417,6 +418,24 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_s3_addressing_style')}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={(props.selectedDestination.destination as S3BackupDestination).addressingStyle || 'path-style'}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onChange={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as S3BackupDestination),
|
||||||
|
addressingStyle: (event.currentTarget as HTMLSelectElement).value as S3BackupAddressingStyle,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
<option value="path-style">{t('txt_backup_s3_addressing_path_style')}</option>
|
||||||
|
<option value="virtual-hosted-style">{t('txt_backup_s3_addressing_virtual_hosted_style')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_backup_s3_bucket')}</span>
|
<span>{t('txt_backup_s3_bucket')}</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,15 +1,40 @@
|
|||||||
import type { RefObject } from 'preact';
|
import type { ComponentChildren, RefObject } from 'preact';
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { memo } from 'preact/compat';
|
import { memo } from 'preact/compat';
|
||||||
import { createPortal } from 'preact/compat';
|
import { createPortal } from 'preact/compat';
|
||||||
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
|
import {
|
||||||
|
Archive,
|
||||||
|
ArrowUpDown,
|
||||||
|
Check,
|
||||||
|
CheckCheck,
|
||||||
|
ChevronDown,
|
||||||
|
Copy,
|
||||||
|
CreditCard,
|
||||||
|
Folder as FolderIcon,
|
||||||
|
FolderInput,
|
||||||
|
FolderX,
|
||||||
|
Globe,
|
||||||
|
KeyRound,
|
||||||
|
LayoutGrid,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
|
ShieldUser,
|
||||||
|
Star,
|
||||||
|
StickyNote,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from 'lucide-preact';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher, Folder } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
CreateTypeIcon,
|
CreateTypeIcon,
|
||||||
getCreateTypeOptions,
|
getCreateTypeOptions,
|
||||||
|
getDuplicateDetectionOptions,
|
||||||
getVaultSortOptions,
|
getVaultSortOptions,
|
||||||
VaultListIcon,
|
VaultListIcon,
|
||||||
|
type DuplicateDetectionMode,
|
||||||
type SidebarFilter,
|
type SidebarFilter,
|
||||||
type VaultSortMode,
|
type VaultSortMode,
|
||||||
} from '@/components/vault/vault-page-helpers';
|
} from '@/components/vault/vault-page-helpers';
|
||||||
@@ -25,13 +50,16 @@ interface VaultListPanelProps {
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
|
folders: Folder[];
|
||||||
searchInput: string;
|
searchInput: string;
|
||||||
sortMode: VaultSortMode;
|
sortMode: VaultSortMode;
|
||||||
sortMenuOpen: boolean;
|
sortMenuOpen: boolean;
|
||||||
|
duplicateMode: DuplicateDetectionMode;
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
totalCipherCount: number;
|
totalCipherCount: number;
|
||||||
filteredCiphers: Cipher[];
|
filteredCiphers: Cipher[];
|
||||||
visibleCiphers: Cipher[];
|
visibleCiphers: Cipher[];
|
||||||
|
duplicateGroupIndexById: Map<string, number>;
|
||||||
virtualRange: VirtualRange;
|
virtualRange: VirtualRange;
|
||||||
selectedCipherId: string;
|
selectedCipherId: string;
|
||||||
selectedMap: Record<string, boolean>;
|
selectedMap: Record<string, boolean>;
|
||||||
@@ -48,6 +76,8 @@ interface VaultListPanelProps {
|
|||||||
onSearchCompositionEnd: (value: string) => void;
|
onSearchCompositionEnd: (value: string) => void;
|
||||||
onToggleSortMenu: () => void;
|
onToggleSortMenu: () => void;
|
||||||
onSelectSortMode: (value: VaultSortMode) => void;
|
onSelectSortMode: (value: VaultSortMode) => void;
|
||||||
|
onDuplicateModeChange: (value: DuplicateDetectionMode) => void;
|
||||||
|
onChangeFilter: (filter: SidebarFilter) => void;
|
||||||
onSyncVault: () => void;
|
onSyncVault: () => void;
|
||||||
onOpenBulkDelete: () => void;
|
onOpenBulkDelete: () => void;
|
||||||
onSelectDuplicates: () => void;
|
onSelectDuplicates: () => void;
|
||||||
@@ -69,15 +99,28 @@ interface CipherListItemProps {
|
|||||||
cipher: Cipher;
|
cipher: Cipher;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
|
duplicateGroupIndex: number | null;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
onToggleSelected: (cipherId: string, checked: boolean) => void;
|
onToggleSelected: (cipherId: string, checked: boolean) => void;
|
||||||
onSelectCipher: (cipherId: string) => void;
|
onSelectCipher: (cipherId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MobileFilterMenuKey = 'duplicate' | 'menu' | 'type' | 'folder';
|
||||||
|
|
||||||
|
interface MobileFilterOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
icon: ComponentChildren;
|
||||||
|
active: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) {
|
const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) {
|
||||||
|
const duplicateGroupHue = props.duplicateGroupIndex === null ? null : (props.duplicateGroupIndex * 137.508) % 360;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`list-item ${props.selected ? 'active' : ''}`}
|
className={`list-item ${props.selected ? 'active' : ''} ${duplicateGroupHue === null ? '' : 'duplicate-group-item'}`}
|
||||||
|
style={duplicateGroupHue === null ? undefined : { '--duplicate-group-hue': `${duplicateGroupHue}deg` }}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
if (target.closest('.row-check')) return;
|
if (target.closest('.row-check')) return;
|
||||||
@@ -107,13 +150,116 @@ const CipherListItem = memo(function CipherListItem(props: CipherListItemProps)
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function VaultListPanel(props: VaultListPanelProps) {
|
export default function VaultListPanel(props: VaultListPanelProps) {
|
||||||
|
const [mobileFilterOpen, setMobileFilterOpen] = useState<MobileFilterMenuKey | null>(null);
|
||||||
|
const mobileFilterRef = useRef<HTMLDivElement | null>(null);
|
||||||
const createTypeOptions = getCreateTypeOptions();
|
const createTypeOptions = getCreateTypeOptions();
|
||||||
|
const duplicateDetectionOptions = getDuplicateDetectionOptions();
|
||||||
const vaultSortOptions = getVaultSortOptions();
|
const vaultSortOptions = getVaultSortOptions();
|
||||||
const createMenu = (
|
const duplicateModeOptions: MobileFilterOption[] = duplicateDetectionOptions.map((option) => ({
|
||||||
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
|
value: option.value,
|
||||||
|
label: option.label,
|
||||||
|
icon: option.value === 'login-site' ? <Globe size={14} /> : option.value === 'exact' ? <Copy size={14} /> : <KeyRound size={14} />,
|
||||||
|
active: props.duplicateMode === option.value,
|
||||||
|
onSelect: () => props.onDuplicateModeChange(option.value),
|
||||||
|
}));
|
||||||
|
const menuFilterOptions: MobileFilterOption[] = [
|
||||||
|
{ value: 'all', label: t('txt_all_items'), icon: <LayoutGrid size={14} />, active: props.sidebarFilter.kind === 'all', onSelect: () => props.onChangeFilter({ kind: 'all' }) },
|
||||||
|
{ value: 'favorite', label: t('txt_favorites'), icon: <Star size={14} />, active: props.sidebarFilter.kind === 'favorite', onSelect: () => props.onChangeFilter({ kind: 'favorite' }) },
|
||||||
|
{ value: 'archive', label: t('txt_archive'), icon: <Archive size={14} />, active: props.sidebarFilter.kind === 'archive', onSelect: () => props.onChangeFilter({ kind: 'archive' }) },
|
||||||
|
{ value: 'trash', label: t('txt_trash'), icon: <Trash2 size={14} />, active: props.sidebarFilter.kind === 'trash', onSelect: () => props.onChangeFilter({ kind: 'trash' }) },
|
||||||
|
{ value: 'duplicates', label: t('txt_duplicates'), icon: <Copy size={14} />, active: props.sidebarFilter.kind === 'duplicates', onSelect: () => props.onChangeFilter({ kind: 'duplicates' }) },
|
||||||
|
];
|
||||||
|
const typeMobileFilterOptions: MobileFilterOption[] = [
|
||||||
|
{ value: 'login', label: t('txt_login'), icon: <Globe size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'login', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'login' }) },
|
||||||
|
{ value: 'card', label: t('txt_card'), icon: <CreditCard size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'card', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'card' }) },
|
||||||
|
{ value: 'identity', label: t('txt_identity'), icon: <ShieldUser size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'identity', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'identity' }) },
|
||||||
|
{ value: 'note', label: t('txt_note'), icon: <StickyNote size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'note', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'note' }) },
|
||||||
|
{ value: 'ssh', label: t('txt_ssh_key'), icon: <KeyRound size={14} />, active: props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'ssh', onSelect: () => props.onChangeFilter({ kind: 'type', value: 'ssh' }) },
|
||||||
|
];
|
||||||
|
const folderMobileFilterOptions: MobileFilterOption[] = [
|
||||||
|
{ value: '__none__', label: t('txt_no_folder'), icon: <FolderX size={14} />, active: props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === null, onSelect: () => props.onChangeFilter({ kind: 'folder', folderId: null }) },
|
||||||
|
...props.folders.map((folder) => ({
|
||||||
|
value: folder.id,
|
||||||
|
label: folder.decName || folder.name || folder.id,
|
||||||
|
icon: <FolderIcon size={14} />,
|
||||||
|
active: props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === folder.id,
|
||||||
|
onSelect: () => props.onChangeFilter({ kind: 'folder', folderId: folder.id }),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const menuFilterSelected = menuFilterOptions.find((option) => option.active);
|
||||||
|
const typeFilterSelected = typeMobileFilterOptions.find((option) => option.active);
|
||||||
|
const folderFilterSelected = folderMobileFilterOptions.find((option) => option.active);
|
||||||
|
const duplicateModeSelected = duplicateModeOptions.find((option) => option.active);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPointerDown = (event: Event) => {
|
||||||
|
if (!mobileFilterOpen) return;
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (mobileFilterRef.current && target && !mobileFilterRef.current.contains(target)) {
|
||||||
|
setMobileFilterOpen(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setMobileFilterOpen(null);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', onPointerDown);
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [mobileFilterOpen]);
|
||||||
|
|
||||||
|
const renderMobileFilterMenu = (
|
||||||
|
key: MobileFilterMenuKey,
|
||||||
|
label: string,
|
||||||
|
selected: MobileFilterOption | undefined,
|
||||||
|
fallbackIcon: ComponentChildren,
|
||||||
|
options: MobileFilterOption[]
|
||||||
|
) => (
|
||||||
|
<div className="mobile-vault-filter-control">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary small mobile-fab-trigger"
|
className={`mobile-vault-filter-trigger ${mobileFilterOpen === key ? 'active' : ''}`}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={mobileFilterOpen === key}
|
||||||
|
onClick={() => setMobileFilterOpen((open) => open === key ? null : key)}
|
||||||
|
>
|
||||||
|
<span className="mobile-vault-filter-trigger-icon">{selected?.icon || fallbackIcon}</span>
|
||||||
|
<span className="mobile-vault-filter-trigger-label">{selected?.label || label}</span>
|
||||||
|
<ChevronDown size={13} className="mobile-vault-filter-chevron" />
|
||||||
|
</button>
|
||||||
|
{mobileFilterOpen === key && (
|
||||||
|
<div className="sort-menu mobile-vault-filter-menu" role="menu">
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`sort-menu-item mobile-vault-filter-item ${option.active ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
option.onSelect();
|
||||||
|
setMobileFilterOpen(null);
|
||||||
|
}}
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={option.active}
|
||||||
|
>
|
||||||
|
<span className="mobile-vault-filter-item-main">
|
||||||
|
<span className="mobile-vault-filter-item-icon">{option.icon}</span>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</span>
|
||||||
|
{option.active ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const createMenu = (
|
||||||
|
<div className={`create-menu-wrap ${props.isMobileLayout ? 'mobile-fab-wrap' : 'desktop-create-menu-wrap'}`} ref={props.createMenuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-primary small ${props.isMobileLayout ? 'mobile-fab-trigger' : 'desktop-create-trigger'}`}
|
||||||
aria-label={t('txt_add')}
|
aria-label={t('txt_add')}
|
||||||
title={t('txt_add')}
|
title={t('txt_add')}
|
||||||
onClick={props.onToggleCreateMenu}
|
onClick={props.onToggleCreateMenu}
|
||||||
@@ -135,108 +281,127 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="list-col">
|
<section className="list-col">
|
||||||
<div className="list-head">
|
<div className="list-toolbar-stack" ref={mobileFilterRef}>
|
||||||
<div className="search-input-wrap">
|
<div className={`list-head ${props.selectedCount > 0 ? 'selection-mode' : ''}`}>
|
||||||
<input
|
{props.selectedCount > 0 ? (
|
||||||
className="search-input"
|
<>
|
||||||
placeholder={t('txt_search_your_secure_vault')}
|
{props.sidebarFilter.kind !== 'duplicates' && (
|
||||||
value={props.searchInput}
|
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
||||||
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
||||||
onCompositionStart={props.onSearchCompositionStart}
|
|
||||||
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key !== 'Escape' || !props.searchInput) return;
|
|
||||||
e.preventDefault();
|
|
||||||
props.onClearSearch();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{!!props.searchInput && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="search-clear-btn"
|
|
||||||
aria-label={t('txt_clear_search')}
|
|
||||||
title={t('txt_clear_search_esc')}
|
|
||||||
onClick={props.onClearSearch}
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`btn btn-secondary small sort-trigger ${props.sortMenuOpen ? 'active' : ''}`}
|
|
||||||
aria-label={t('txt_sort')}
|
|
||||||
title={t('txt_sort')}
|
|
||||||
onClick={props.onToggleSortMenu}
|
|
||||||
>
|
|
||||||
<ArrowUpDown size={14} className="btn-icon" />
|
|
||||||
</button>
|
|
||||||
{props.sortMenuOpen && (
|
|
||||||
<div className="sort-menu">
|
|
||||||
{vaultSortOptions.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
className={`sort-menu-item ${props.sortMode === option.value ? 'active' : ''}`}
|
|
||||||
onClick={() => props.onSelectSortMode(option.value)}
|
|
||||||
>
|
|
||||||
<span>{option.label}</span>
|
|
||||||
{props.sortMode === option.value ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
)}
|
||||||
</div>
|
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
|
||||||
|
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
||||||
|
</button>
|
||||||
|
{props.sidebarFilter.kind === 'trash' && (
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{props.sidebarFilter.kind === 'archive' && (
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
|
||||||
|
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
|
||||||
|
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
|
||||||
|
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-danger small" disabled={props.busy} onClick={props.onOpenBulkDelete}>
|
||||||
|
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="search-input-wrap">
|
||||||
|
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
|
||||||
|
<div className="duplicate-mode-head-menu">
|
||||||
|
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className="search-input"
|
||||||
|
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
|
||||||
|
value={props.searchInput}
|
||||||
|
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
onCompositionStart={props.onSearchCompositionStart}
|
||||||
|
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== 'Escape' || !props.searchInput) return;
|
||||||
|
e.preventDefault();
|
||||||
|
props.onClearSearch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!!props.searchInput && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="search-clear-btn"
|
||||||
|
aria-label={t('txt_clear_search')}
|
||||||
|
title={t('txt_clear_search_esc')}
|
||||||
|
onClick={props.onClearSearch}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
|
||||||
|
<div className="duplicate-mode-head-menu">
|
||||||
|
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-secondary small sort-trigger sort-trigger-labeled ${props.sortMenuOpen ? 'active' : ''}`}
|
||||||
|
aria-label={t('txt_sort')}
|
||||||
|
title={t('txt_sort')}
|
||||||
|
onClick={props.onToggleSortMenu}
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={14} className="btn-icon" /> <span>{t('txt_sort')}</span>
|
||||||
|
</button>
|
||||||
|
{props.sortMenuOpen && (
|
||||||
|
<div className="sort-menu">
|
||||||
|
{vaultSortOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`sort-menu-item ${props.sortMode === option.value ? 'active' : ''}`}
|
||||||
|
onClick={() => props.onSelectSortMode(option.value)}
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{props.sortMode === option.value ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
||||||
|
</button>
|
||||||
|
{!props.isMobileLayout && props.sidebarFilter !== undefined && createMenu}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="list-count" title={t('txt_total_items_count', { count: props.totalCipherCount })}>
|
{props.isMobileLayout && (
|
||||||
{t('txt_total_items_count', { count: props.totalCipherCount })}
|
<div className="mobile-vault-filter-row" aria-label={t('txt_filter')}>
|
||||||
</div>
|
{renderMobileFilterMenu('menu', t('txt_menu'), menuFilterSelected, <LayoutGrid size={14} />, menuFilterOptions)}
|
||||||
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
|
{renderMobileFilterMenu('type', t('txt_type'), typeFilterSelected, <Globe size={14} />, typeMobileFilterOptions)}
|
||||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
{renderMobileFilterMenu('folder', t('txt_folder'), folderFilterSelected, <FolderIcon size={14} />, folderMobileFilterOptions)}
|
||||||
</button>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="toolbar actions">
|
{!props.selectedCount && props.isMobileLayout && props.sidebarFilter.kind !== 'duplicates' && typeof document !== 'undefined' && props.mobileFabVisible
|
||||||
{props.sidebarFilter.kind === 'duplicates' && (
|
? createPortal(createMenu, document.body)
|
||||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}>
|
: null}
|
||||||
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{props.selectedCount > 0 && props.sidebarFilter.kind === 'trash' && (
|
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
|
|
||||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{props.selectedCount > 0 && props.sidebarFilter.kind === 'archive' && (
|
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
|
|
||||||
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
|
|
||||||
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
|
|
||||||
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{props.selectedCount > 0 && (
|
|
||||||
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
|
|
||||||
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
|
|
||||||
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
|
||||||
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
|
||||||
</button>
|
|
||||||
{props.isMobileLayout && typeof document !== 'undefined'
|
|
||||||
? props.mobileFabVisible ? createPortal(createMenu, document.body) : null
|
|
||||||
: createMenu}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
||||||
{props.loading && !props.filteredCiphers.length && <LoadingState lines={7} compact />}
|
{props.loading && !props.filteredCiphers.length && <LoadingState lines={7} compact />}
|
||||||
{!props.loading && !!props.error && !props.filteredCiphers.length && (
|
{!props.loading && !!props.error && !props.filteredCiphers.length && (
|
||||||
@@ -255,6 +420,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
cipher={cipher}
|
cipher={cipher}
|
||||||
selected={props.selectedCipherId === cipher.id}
|
selected={props.selectedCipherId === cipher.id}
|
||||||
checked={!!props.selectedMap[cipher.id]}
|
checked={!!props.selectedMap[cipher.id]}
|
||||||
|
duplicateGroupIndex={props.sidebarFilter.kind === 'duplicates' ? props.duplicateGroupIndexById.get(cipher.id) ?? null : null}
|
||||||
subtitle={props.listSubtitle(cipher)}
|
subtitle={props.listSubtitle(cipher)}
|
||||||
onToggleSelected={props.onToggleSelected}
|
onToggleSelected={props.onToggleSelected}
|
||||||
onSelectCipher={props.onSelectCipher}
|
onSelectCipher={props.onSelectCipher}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
subscribeWebsiteIconStatus,
|
subscribeWebsiteIconStatus,
|
||||||
} from '@/lib/website-icon-cache';
|
} from '@/lib/website-icon-cache';
|
||||||
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
|
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
|
||||||
|
import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status';
|
||||||
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
||||||
|
|
||||||
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
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 [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
|
||||||
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
|
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
|
||||||
const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : ''));
|
const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : ''));
|
||||||
|
const [networkStatus, setNetworkStatus] = useState(getCurrentNetworkStatus);
|
||||||
const demoIconUrl = SHOULD_LOAD_DEMO_BRAND_ICONS && host ? demoBrandIconUrl(host) : '';
|
const demoIconUrl = SHOULD_LOAD_DEMO_BRAND_ICONS && host ? demoBrandIconUrl(host) : '';
|
||||||
|
|
||||||
|
useEffect(() => subscribeNetworkStatus(setNetworkStatus), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!host) {
|
if (!host) {
|
||||||
setShouldLoad(true);
|
setShouldLoad(true);
|
||||||
@@ -77,9 +81,10 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
|
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
|
||||||
if (demoIconUrl) return;
|
if (demoIconUrl) return;
|
||||||
|
if (networkStatus !== 'online') return;
|
||||||
if (!host || !src || !shouldLoad || status !== 'idle') return;
|
if (!host || !src || !shouldLoad || status !== 'idle') return;
|
||||||
beginWebsiteIconLoad(host, src);
|
beginWebsiteIconLoad(host, src);
|
||||||
}, [demoIconUrl, host, src, shouldLoad, status]);
|
}, [demoIconUrl, host, networkStatus, src, shouldLoad, status]);
|
||||||
|
|
||||||
if (demoIconUrl) {
|
if (demoIconUrl) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ import {
|
|||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
|
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
|
||||||
|
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
||||||
|
import { normalizeEquivalentDomain } from '@shared/domain-normalize';
|
||||||
import WebsiteIcon from './WebsiteIcon';
|
import WebsiteIcon from './WebsiteIcon';
|
||||||
|
|
||||||
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
||||||
export type VaultSortMode = 'edited' | 'created' | 'name';
|
export type VaultSortMode = 'edited' | 'created' | 'name';
|
||||||
|
export type DuplicateDetectionMode = 'exact' | 'login-site' | 'login-credentials' | 'password';
|
||||||
export type SidebarFilter =
|
export type SidebarFilter =
|
||||||
| { kind: 'all' }
|
| { kind: 'all' }
|
||||||
| { kind: 'favorite' }
|
| { kind: 'favorite' }
|
||||||
@@ -126,6 +129,16 @@ export const FOLDER_SORT_STORAGE_KEY = 'nodewarden.folder-sort.v1';
|
|||||||
export const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
|
export const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
|
||||||
export const VAULT_LIST_ROW_HEIGHT = 74;
|
export const VAULT_LIST_ROW_HEIGHT = 74;
|
||||||
export const VAULT_LIST_OVERSCAN = 10;
|
export const VAULT_LIST_OVERSCAN = 10;
|
||||||
|
|
||||||
|
export function getDuplicateDetectionOptions(): Array<{ value: DuplicateDetectionMode; label: string }> {
|
||||||
|
return [
|
||||||
|
{ value: 'exact', label: t('txt_duplicate_mode_exact') },
|
||||||
|
{ value: 'login-site', label: t('txt_duplicate_mode_login_site') },
|
||||||
|
{ value: 'login-credentials', label: t('txt_duplicate_mode_login_credentials') },
|
||||||
|
{ value: 'password', label: t('txt_duplicate_mode_password') },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function getVaultSortOptions(): Array<{ value: VaultSortMode; label: string }> {
|
export function getVaultSortOptions(): Array<{ value: VaultSortMode; label: string }> {
|
||||||
return [
|
return [
|
||||||
{ value: 'edited', label: t('txt_sort_last_edited') },
|
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||||
@@ -242,7 +255,7 @@ export function toBooleanFieldValue(raw: string): boolean {
|
|||||||
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
||||||
}
|
}
|
||||||
|
|
||||||
export { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
export { firstCipherUri, hostFromUri, websiteIconUrl };
|
||||||
|
|
||||||
export function createEmptyLoginUri(): VaultDraftLoginUri {
|
export function createEmptyLoginUri(): VaultDraftLoginUri {
|
||||||
return { uri: '', match: null, originalUri: '', extra: {} };
|
return { uri: '', match: null, originalUri: '', extra: {} };
|
||||||
@@ -257,6 +270,30 @@ function valueOrFallback(value: string | null | undefined): string {
|
|||||||
return String(value || '');
|
return String(value || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function duplicateLoginUsername(cipher: Cipher): string {
|
||||||
|
return valueOrFallback(cipher.login?.decUsername ?? cipher.login?.username).trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateLoginPassword(cipher: Cipher): string {
|
||||||
|
return valueOrFallback(cipher.login?.decPassword ?? cipher.login?.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateLoginSites(cipher: Cipher): string[] {
|
||||||
|
const sites = new Set<string>();
|
||||||
|
for (const uri of cipher.login?.uris || []) {
|
||||||
|
const raw = valueOrFallback(uri.decUri ?? uri.uri).trim();
|
||||||
|
if (!raw) continue;
|
||||||
|
const host = hostFromUri(raw).trim().toLowerCase().replace(/^www\./, '');
|
||||||
|
const site = normalizeEquivalentDomain(raw) || host;
|
||||||
|
if (site) sites.add(site);
|
||||||
|
}
|
||||||
|
return Array.from(sites).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateSignature(parts: string[]): string {
|
||||||
|
return JSON.stringify(parts);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildCipherDuplicateSignature(cipher: Cipher): string {
|
export function buildCipherDuplicateSignature(cipher: Cipher): string {
|
||||||
const normalized = {
|
const normalized = {
|
||||||
type: Number(cipher.type || 1),
|
type: Number(cipher.type || 1),
|
||||||
@@ -326,13 +363,30 @@ export function buildCipherDuplicateSignature(cipher: Cipher): string {
|
|||||||
linkedId: field.linkedId ?? null,
|
linkedId: field.linkedId ?? null,
|
||||||
})),
|
})),
|
||||||
passwordHistory: (cipher.passwordHistory || []).map((entry) => ({
|
passwordHistory: (cipher.passwordHistory || []).map((entry) => ({
|
||||||
password: valueOrFallback(entry.password),
|
password: valueOrFallback(entry.decPassword ?? entry.password),
|
||||||
lastUsedDate: valueOrFallback(entry.lastUsedDate),
|
lastUsedDate: valueOrFallback(entry.lastUsedDate),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
return JSON.stringify(normalized);
|
return JSON.stringify(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildCipherDuplicateSignatures(cipher: Cipher, mode: DuplicateDetectionMode): string[] {
|
||||||
|
if (mode === 'exact') return [buildCipherDuplicateSignature(cipher)];
|
||||||
|
if (Number(cipher.type || 1) !== 1 || !cipher.login) return [];
|
||||||
|
|
||||||
|
const username = duplicateLoginUsername(cipher);
|
||||||
|
const password = duplicateLoginPassword(cipher);
|
||||||
|
if (mode === 'password') {
|
||||||
|
return password ? [duplicateSignature(['password', password])] : [];
|
||||||
|
}
|
||||||
|
if (!username || !password) return [];
|
||||||
|
if (mode === 'login-credentials') {
|
||||||
|
return [duplicateSignature(['login-credentials', username, password])];
|
||||||
|
}
|
||||||
|
|
||||||
|
return duplicateLoginSites(cipher).map((site) => duplicateSignature(['login-site', site, username, password]));
|
||||||
|
}
|
||||||
|
|
||||||
export function createEmptyDraft(type: number): VaultDraft {
|
export function createEmptyDraft(type: number): VaultDraft {
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
@@ -453,8 +507,9 @@ export function maskSecret(value: string): string {
|
|||||||
export function formatTotp(code: string): string {
|
export function formatTotp(code: string): string {
|
||||||
if (!code) return code;
|
if (!code) return code;
|
||||||
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
|
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
|
||||||
if (code.length < 6) return code;
|
if (code.length <= 4) return code;
|
||||||
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
if (code.length === 8) return `${code.slice(0, 4)} ${code.slice(4)}`;
|
||||||
|
return code.replace(/(.{3})(?=.)/g, '$1 ');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatHistoryTime(value: string | null | undefined): string {
|
export function formatHistoryTime(value: string | null | undefined): string {
|
||||||
|
|||||||
@@ -4,27 +4,41 @@ import {
|
|||||||
deleteAllAuthorizedDevices,
|
deleteAllAuthorizedDevices,
|
||||||
deleteAuthorizedDevice,
|
deleteAuthorizedDevice,
|
||||||
deriveLoginHash,
|
deriveLoginHash,
|
||||||
|
deleteAccountPasskey as deleteAccountPasskeyApi,
|
||||||
|
enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi,
|
||||||
getCurrentDeviceIdentifier,
|
getCurrentDeviceIdentifier,
|
||||||
getApiKey,
|
getApiKey,
|
||||||
|
getAccountPasskeyAttestationOptions,
|
||||||
|
getAccountPasskeyUpdateAssertionOptions,
|
||||||
getTotpRecoveryCode,
|
getTotpRecoveryCode,
|
||||||
|
listAccountPasskeys,
|
||||||
rotateApiKey,
|
rotateApiKey,
|
||||||
revokeAuthorizedDeviceTrust,
|
revokeAuthorizedDeviceTrust,
|
||||||
revokeAllAuthorizedDeviceTrust,
|
revokeAllAuthorizedDeviceTrust,
|
||||||
|
saveAccountPasskey,
|
||||||
setTotp,
|
setTotp,
|
||||||
trustAuthorizedDevicePermanently,
|
trustAuthorizedDevicePermanently,
|
||||||
updateAuthorizedDeviceName,
|
updateAuthorizedDeviceName,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
} from '@/lib/api/auth';
|
} from '@/lib/api/auth';
|
||||||
|
import {
|
||||||
|
AccountPasskeyPrfUnavailableError,
|
||||||
|
assertAccountPasskey,
|
||||||
|
buildAccountPasskeyPrfKeySet,
|
||||||
|
buildAccountPasskeyPrfKeySetFromPrfKey,
|
||||||
|
createAccountPasskeyCredential,
|
||||||
|
} from '@/lib/account-passkeys';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
|
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
|
||||||
import type { AuthedFetch } from '@/lib/api/shared';
|
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;
|
type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||||
|
|
||||||
interface UseAccountSecurityActionsOptions {
|
interface UseAccountSecurityActionsOptions {
|
||||||
authedFetch: AuthedFetch;
|
authedFetch: AuthedFetch;
|
||||||
profile: Profile | null;
|
profile: Profile | null;
|
||||||
|
session: SessionState | null;
|
||||||
defaultKdfIterations: number;
|
defaultKdfIterations: number;
|
||||||
disableTotpPassword: string;
|
disableTotpPassword: string;
|
||||||
clearDisableTotpDialog: () => void;
|
clearDisableTotpDialog: () => void;
|
||||||
@@ -40,6 +54,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
const {
|
const {
|
||||||
authedFetch,
|
authedFetch,
|
||||||
profile,
|
profile,
|
||||||
|
session,
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
disableTotpPassword,
|
disableTotpPassword,
|
||||||
clearDisableTotpDialog,
|
clearDisableTotpDialog,
|
||||||
@@ -52,7 +67,29 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
return useMemo(
|
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) {
|
async changePassword(currentPassword: string, nextPassword: string, nextPassword2: string) {
|
||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
if (!currentPassword || !nextPassword) {
|
if (!currentPassword || !nextPassword) {
|
||||||
@@ -170,6 +207,79 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
return key;
|
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() {
|
async refreshAuthorizedDevices() {
|
||||||
await refetchAuthorizedDevices();
|
await refetchAuthorizedDevices();
|
||||||
},
|
},
|
||||||
@@ -293,7 +403,8 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
[
|
[
|
||||||
authedFetch,
|
authedFetch,
|
||||||
clearDisableTotpDialog,
|
clearDisableTotpDialog,
|
||||||
@@ -304,6 +415,8 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
onProfileUpdated,
|
onProfileUpdated,
|
||||||
onSetConfirm,
|
onSetConfirm,
|
||||||
profile,
|
profile,
|
||||||
|
session?.symEncKey,
|
||||||
|
session?.symMacKey,
|
||||||
refetchAuthorizedDevices,
|
refetchAuthorizedDevices,
|
||||||
refetchTotpStatus,
|
refetchTotpStatus,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ExportRequest, ZipAttachmentEntry } from '@/lib/export-formats';
|
|||||||
import {
|
import {
|
||||||
attachNodeWardenEncryptedAttachmentPayload,
|
attachNodeWardenEncryptedAttachmentPayload,
|
||||||
buildAccountEncryptedBitwardenJsonString,
|
buildAccountEncryptedBitwardenJsonString,
|
||||||
|
buildBitwardenCsvString,
|
||||||
buildBitwardenZipBytes,
|
buildBitwardenZipBytes,
|
||||||
buildExportFileName,
|
buildExportFileName,
|
||||||
buildNodeWardenAttachmentRecords,
|
buildNodeWardenAttachmentRecords,
|
||||||
@@ -224,6 +225,56 @@ function optimisticCipherFromDraft(draft: VaultDraft, current?: Cipher | null):
|
|||||||
return next;
|
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) {
|
export default function useVaultSendActions(options: UseVaultSendActionsOptions) {
|
||||||
const {
|
const {
|
||||||
authedFetch,
|
authedFetch,
|
||||||
@@ -252,6 +303,11 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
await Promise.all([refetchCiphers(), refetchFolders(), refetchSends()]);
|
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 syncVaultCoreInBackground = (options?: { includeFolders?: boolean }) => {
|
||||||
const tasks: Promise<unknown>[] = [Promise.resolve(refetchCiphers())];
|
const tasks: Promise<unknown>[] = [Promise.resolve(refetchCiphers())];
|
||||||
if (options?.includeFolders) {
|
if (options?.includeFolders) {
|
||||||
@@ -397,6 +453,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
|
|
||||||
async createVaultItem(draft: VaultDraft, attachments: File[] = []) {
|
async createVaultItem(draft: VaultDraft, attachments: File[] = []) {
|
||||||
if (!session) return;
|
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);
|
const optimistic = optimisticCipherFromDraft(draft, null);
|
||||||
patchDecryptedCiphers((prev) => [optimistic, ...prev.filter((cipher) => cipher.id !== optimistic.id)]);
|
patchDecryptedCiphers((prev) => [optimistic, ...prev.filter((cipher) => cipher.id !== optimistic.id)]);
|
||||||
try {
|
try {
|
||||||
@@ -421,6 +483,15 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
|
|
||||||
async updateVaultItem(cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) {
|
async updateVaultItem(cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) {
|
||||||
if (!session) return;
|
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 addFiles = Array.isArray(options?.addFiles) ? options.addFiles : [];
|
||||||
const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : [];
|
const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : [];
|
||||||
const previousCipher: Cipher = {
|
const previousCipher: Cipher = {
|
||||||
@@ -490,6 +561,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
},
|
},
|
||||||
|
|
||||||
async deleteVaultItem(cipher: Cipher) {
|
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 };
|
const previousCipher = { ...cipher };
|
||||||
if (cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt) {
|
if (cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt) {
|
||||||
try {
|
try {
|
||||||
@@ -518,6 +595,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
},
|
},
|
||||||
|
|
||||||
async archiveVaultItem(cipher: Cipher) {
|
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 previousCipher = { ...cipher };
|
||||||
const archivedDate = new Date().toISOString();
|
const archivedDate = new Date().toISOString();
|
||||||
patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate, deletedDate: null, revisionDate: archivedDate }));
|
patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate, deletedDate: null, revisionDate: archivedDate }));
|
||||||
@@ -534,6 +617,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
},
|
},
|
||||||
|
|
||||||
async unarchiveVaultItem(cipher: Cipher) {
|
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 previousCipher = { ...cipher };
|
||||||
const revisionDate = new Date().toISOString();
|
const revisionDate = new Date().toISOString();
|
||||||
patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate: null, revisionDate }));
|
patchCipherBatch([cipher.id], (current) => ({ ...current, archivedDate: null, revisionDate }));
|
||||||
@@ -550,6 +639,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
},
|
},
|
||||||
|
|
||||||
async bulkDeleteVaultItems(ids: string[]) {
|
async bulkDeleteVaultItems(ids: string[]) {
|
||||||
|
try {
|
||||||
|
requireOnlineWrite();
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await bulkDeleteCiphers(authedFetch, ids);
|
await bulkDeleteCiphers(authedFetch, ids);
|
||||||
const deletedDate = new Date().toISOString();
|
const deletedDate = new Date().toISOString();
|
||||||
@@ -563,6 +658,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
},
|
},
|
||||||
|
|
||||||
async bulkArchiveVaultItems(ids: string[]) {
|
async bulkArchiveVaultItems(ids: string[]) {
|
||||||
|
try {
|
||||||
|
requireOnlineWrite();
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await bulkArchiveCiphers(authedFetch, ids);
|
await bulkArchiveCiphers(authedFetch, ids);
|
||||||
const archivedDate = new Date().toISOString();
|
const archivedDate = new Date().toISOString();
|
||||||
@@ -576,6 +677,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
},
|
},
|
||||||
|
|
||||||
async bulkUnarchiveVaultItems(ids: string[]) {
|
async bulkUnarchiveVaultItems(ids: string[]) {
|
||||||
|
try {
|
||||||
|
requireOnlineWrite();
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await bulkUnarchiveCiphers(authedFetch, ids);
|
await bulkUnarchiveCiphers(authedFetch, ids);
|
||||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate: null }));
|
patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate: null }));
|
||||||
@@ -588,6 +695,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
},
|
},
|
||||||
|
|
||||||
async bulkMoveVaultItems(ids: string[], folderId: string | null) {
|
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 {
|
try {
|
||||||
await bulkMoveCiphers(authedFetch, ids, folderId);
|
await bulkMoveCiphers(authedFetch, ids, folderId);
|
||||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, folderId }));
|
patchCipherBatch(ids, (cipher) => ({ ...cipher, folderId }));
|
||||||
@@ -605,6 +718,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
onNotify('error', t('txt_folder_name_is_required'));
|
onNotify('error', t('txt_folder_name_is_required'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
requireOnlineWrite();
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||||
const created = await createFolder(authedFetch, session, folderName);
|
const created = await createFolder(authedFetch, session, folderName);
|
||||||
@@ -630,6 +749,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
onNotify('error', t('txt_folder_not_found'));
|
onNotify('error', t('txt_folder_not_found'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
requireOnlineWrite();
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await deleteFolder(authedFetch, id);
|
await deleteFolder(authedFetch, id);
|
||||||
patchFolderBatch([id], () => null);
|
patchFolderBatch([id], () => null);
|
||||||
@@ -653,6 +778,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
onNotify('error', t('txt_folder_name_is_required'));
|
onNotify('error', t('txt_folder_name_is_required'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
requireOnlineWrite();
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||||
await updateFolder(authedFetch, session, id, nextName);
|
await updateFolder(authedFetch, session, id, nextName);
|
||||||
@@ -666,6 +797,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
},
|
},
|
||||||
|
|
||||||
async bulkRestoreVaultItems(ids: string[]) {
|
async bulkRestoreVaultItems(ids: string[]) {
|
||||||
|
try {
|
||||||
|
requireOnlineWrite();
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await bulkRestoreCiphers(authedFetch, ids);
|
await bulkRestoreCiphers(authedFetch, ids);
|
||||||
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate: null }));
|
patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate: null }));
|
||||||
@@ -678,6 +815,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
},
|
},
|
||||||
|
|
||||||
async bulkPermanentDeleteVaultItems(ids: string[]) {
|
async bulkPermanentDeleteVaultItems(ids: string[]) {
|
||||||
|
try {
|
||||||
|
requireOnlineWrite();
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await bulkPermanentDeleteCiphers(authedFetch, ids);
|
await bulkPermanentDeleteCiphers(authedFetch, ids);
|
||||||
patchCipherBatch(ids, () => null);
|
patchCipherBatch(ids, () => null);
|
||||||
@@ -692,6 +835,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
async bulkDeleteFolders(folderIds: string[]) {
|
async bulkDeleteFolders(folderIds: string[]) {
|
||||||
const ids = Array.from(new Set(folderIds.map((id) => String(id || '').trim()).filter(Boolean)));
|
const ids = Array.from(new Set(folderIds.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
|
try {
|
||||||
|
requireOnlineWrite();
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await bulkDeleteFolders(authedFetch, ids);
|
await bulkDeleteFolders(authedFetch, ids);
|
||||||
const removedIds = new Set(ids);
|
const removedIds = new Set(ids);
|
||||||
@@ -712,6 +861,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
|
|
||||||
async createSend(draft: SendDraft, autoCopyLink: boolean) {
|
async createSend(draft: SendDraft, autoCopyLink: boolean) {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
try {
|
||||||
|
requireOnlineWrite();
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const fileName = draft.type === 'file' ? String(draft.file?.name || '').trim() : '';
|
const fileName = draft.type === 'file' ? String(draft.file?.name || '').trim() : '';
|
||||||
if (fileName) {
|
if (fileName) {
|
||||||
@@ -737,6 +892,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
|
|
||||||
async updateSend(send: Send, draft: SendDraft, autoCopyLink: boolean) {
|
async updateSend(send: Send, draft: SendDraft, autoCopyLink: boolean) {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
try {
|
||||||
|
requireOnlineWrite();
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const updated = await updateSend(authedFetch, session, send, draft);
|
const updated = await updateSend(authedFetch, session, send, draft);
|
||||||
await refetchSends();
|
await refetchSends();
|
||||||
@@ -753,6 +914,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
},
|
},
|
||||||
|
|
||||||
async deleteSend(send: Send) {
|
async deleteSend(send: Send) {
|
||||||
|
try {
|
||||||
|
requireOnlineWrite();
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await deleteSend(authedFetch, send.id);
|
await deleteSend(authedFetch, send.id);
|
||||||
await refetchSends();
|
await refetchSends();
|
||||||
@@ -764,6 +931,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
},
|
},
|
||||||
|
|
||||||
async bulkDeleteSends(ids: string[]) {
|
async bulkDeleteSends(ids: string[]) {
|
||||||
|
try {
|
||||||
|
requireOnlineWrite();
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_offline_vault_readonly'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await bulkDeleteSends(authedFetch, ids);
|
await bulkDeleteSends(authedFetch, ids);
|
||||||
await refetchSends();
|
await refetchSends();
|
||||||
@@ -780,6 +953,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
attachments: ImportAttachmentFile[] = []
|
attachments: ImportAttachmentFile[] = []
|
||||||
): Promise<ImportResultSummary> {
|
): Promise<ImportResultSummary> {
|
||||||
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
||||||
|
requireOnlineWrite();
|
||||||
|
|
||||||
const mode = options.folderMode || 'original';
|
const mode = options.folderMode || 'original';
|
||||||
const targetFolderId = (options.targetFolderId || '').trim() || null;
|
const targetFolderId = (options.targetFolderId || '').trim() || null;
|
||||||
@@ -1017,6 +1191,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
mimeType: 'application/json',
|
mimeType: 'application/json',
|
||||||
bytes: new TextEncoder().encode(await getPlainJson()),
|
bytes: new TextEncoder().encode(await getPlainJson()),
|
||||||
};
|
};
|
||||||
|
} else if (format === 'bitwarden_csv') {
|
||||||
|
result = {
|
||||||
|
fileName: buildExportFileName(format),
|
||||||
|
mimeType: 'text/csv;charset=utf-8',
|
||||||
|
bytes: new TextEncoder().encode(buildBitwardenCsvString(await getPlainJsonDoc())),
|
||||||
|
};
|
||||||
} else if (format === 'bitwarden_encrypted_json') {
|
} else if (format === 'bitwarden_encrypted_json') {
|
||||||
if (request.encryptedJsonMode === 'password') {
|
if (request.encryptedJsonMode === 'password') {
|
||||||
const plainJson = await getPlainJson();
|
const plainJson = await getPlainJson();
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { base64ToBytes, bytesToBase64, hkdfExpand, toBufferSource } from '@/lib/crypto';
|
||||||
|
import { EFFLongWordList } from '@/lib/fingerprint-wordlist';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import type { AuthRequest, ListResponse, SessionState } from '@/lib/types';
|
||||||
|
import type { AuthedFetch } from './shared';
|
||||||
|
import { parseErrorMessage, parseJson } from './shared';
|
||||||
|
|
||||||
|
function readResponseProperty<T>(source: Record<string, any>, camel: string, pascal: string, fallback: T): T {
|
||||||
|
return (source[camel] ?? source[pascal] ?? fallback) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAuthRequest(raw: Record<string, any>): AuthRequest {
|
||||||
|
return {
|
||||||
|
id: String(readResponseProperty(raw, 'id', 'Id', '')),
|
||||||
|
publicKey: String(readResponseProperty(raw, 'publicKey', 'PublicKey', '')),
|
||||||
|
requestDeviceType: readResponseProperty(raw, 'requestDeviceType', 'RequestDeviceType', null),
|
||||||
|
requestDeviceTypeValue: readResponseProperty(raw, 'requestDeviceTypeValue', 'RequestDeviceTypeValue', null),
|
||||||
|
requestDeviceIdentifier: String(readResponseProperty(raw, 'requestDeviceIdentifier', 'RequestDeviceIdentifier', '')),
|
||||||
|
requestIpAddress: readResponseProperty(raw, 'requestIpAddress', 'RequestIpAddress', null),
|
||||||
|
requestCountryName: readResponseProperty(raw, 'requestCountryName', 'RequestCountryName', null),
|
||||||
|
key: readResponseProperty(raw, 'key', 'Key', null),
|
||||||
|
creationDate: String(readResponseProperty(raw, 'creationDate', 'CreationDate', '')),
|
||||||
|
requestApproved: readResponseProperty(raw, 'requestApproved', 'RequestApproved', null),
|
||||||
|
responseDate: readResponseProperty(raw, 'responseDate', 'ResponseDate', null),
|
||||||
|
deviceId: readResponseProperty(raw, 'deviceId', 'DeviceId', null),
|
||||||
|
requestDeviceId: readResponseProperty(raw, 'requestDeviceId', 'RequestDeviceId', null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withFingerprintPhrase(email: string, request: AuthRequest): Promise<AuthRequest> {
|
||||||
|
if (!request.publicKey) return request;
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
...request,
|
||||||
|
fingerprintPhrase: await getFingerprintPhrase(email, base64ToBytes(request.publicKey)),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPendingAuthRequests(authedFetch: AuthedFetch, email: string): Promise<AuthRequest[]> {
|
||||||
|
const resp = await authedFetch('/api/auth-requests/pending');
|
||||||
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_auth_requests_load_failed')));
|
||||||
|
const body = await parseJson<ListResponse<Record<string, any>> & { Data?: Record<string, any>[] }>(resp);
|
||||||
|
const rows = (body?.data || body?.Data || []).map(normalizeAuthRequest);
|
||||||
|
return Promise.all(rows.map((row) => withFingerprintPhrase(email, row)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function respondToAuthRequest(
|
||||||
|
authedFetch: AuthedFetch,
|
||||||
|
requestId: string,
|
||||||
|
payload: {
|
||||||
|
key?: string | null;
|
||||||
|
masterPasswordHash?: string | null;
|
||||||
|
deviceIdentifier: string;
|
||||||
|
requestApproved: boolean;
|
||||||
|
}
|
||||||
|
): Promise<AuthRequest> {
|
||||||
|
const resp = await authedFetch(`/api/auth-requests/${encodeURIComponent(requestId)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_auth_request_update_failed')));
|
||||||
|
const body = await parseJson<Record<string, any>>(resp);
|
||||||
|
if (!body) throw new Error(t('txt_auth_request_update_failed'));
|
||||||
|
return normalizeAuthRequest(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPendingAuthRequest(request: AuthRequest): boolean {
|
||||||
|
if (!request.id || !request.creationDate) return false;
|
||||||
|
if (request.responseDate) return false;
|
||||||
|
const createdAt = new Date(request.creationDate).getTime();
|
||||||
|
if (!Number.isFinite(createdAt)) return true;
|
||||||
|
return Date.now() - createdAt < 15 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptSessionUserKeyForAuthRequest(session: SessionState, authRequest: AuthRequest): Promise<string> {
|
||||||
|
if (!session.symEncKey || !session.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
||||||
|
if (!authRequest.publicKey) throw new Error(t('txt_auth_request_missing_public_key'));
|
||||||
|
|
||||||
|
const userKeyBytes = new Uint8Array(64);
|
||||||
|
userKeyBytes.set(base64ToBytes(session.symEncKey), 0);
|
||||||
|
userKeyBytes.set(base64ToBytes(session.symMacKey), 32);
|
||||||
|
const publicKey = await crypto.subtle.importKey(
|
||||||
|
'spki',
|
||||||
|
toBufferSource(base64ToBytes(authRequest.publicKey)),
|
||||||
|
{ name: 'RSA-OAEP', hash: 'SHA-1' },
|
||||||
|
false,
|
||||||
|
['encrypt']
|
||||||
|
);
|
||||||
|
const encryptedBytes = new Uint8Array(await crypto.subtle.encrypt(
|
||||||
|
{ name: 'RSA-OAEP' },
|
||||||
|
publicKey,
|
||||||
|
toBufferSource(userKeyBytes)
|
||||||
|
));
|
||||||
|
return `4.${bytesToBase64(encryptedBytes)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFingerprintPhrase(email: string, publicKey: Uint8Array): Promise<string> {
|
||||||
|
const keyFingerprint = new Uint8Array(await crypto.subtle.digest('SHA-256', toBufferSource(publicKey)));
|
||||||
|
const userFingerprint = await hkdfExpand(keyFingerprint, email.toLowerCase(), 32);
|
||||||
|
return hashPhrase(userFingerprint).join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashPhrase(hash: Uint8Array, minimumEntropy = 64): string[] {
|
||||||
|
const entropyPerWord = Math.log(EFFLongWordList.length) / Math.log(2);
|
||||||
|
let numWords = Math.ceil(minimumEntropy / entropyPerWord);
|
||||||
|
if (numWords * entropyPerWord > hash.length * 4) {
|
||||||
|
throw new Error('Output entropy of hash function is too small');
|
||||||
|
}
|
||||||
|
|
||||||
|
let hashNumber = 0n;
|
||||||
|
for (const byte of hash) {
|
||||||
|
hashNumber = (hashNumber * 256n) + BigInt(byte);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phrase: string[] = [];
|
||||||
|
const wordCount = BigInt(EFFLongWordList.length);
|
||||||
|
while (numWords > 0) {
|
||||||
|
const remainder = Number(hashNumber % wordCount);
|
||||||
|
hashNumber /= wordCount;
|
||||||
|
phrase.push(EFFLongWordList[remainder]);
|
||||||
|
numWords -= 1;
|
||||||
|
}
|
||||||
|
return phrase;
|
||||||
|
}
|
||||||
@@ -2,11 +2,14 @@ import { bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from '../cryp
|
|||||||
import { t, translateServerError } from '../i18n';
|
import { t, translateServerError } from '../i18n';
|
||||||
import type { AuthorizedDevice } from '../types';
|
import type { AuthorizedDevice } from '../types';
|
||||||
import type {
|
import type {
|
||||||
|
AccountPasskeyCredential,
|
||||||
Profile,
|
Profile,
|
||||||
SessionState,
|
SessionState,
|
||||||
TokenError,
|
TokenError,
|
||||||
TokenSuccess,
|
TokenSuccess,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
|
||||||
|
import { recordNodeWardenReachable, recordNodeWardenUnreachable } from '../network-status';
|
||||||
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
||||||
|
|
||||||
const SESSION_KEY = 'nodewarden.web.session.v4';
|
const SESSION_KEY = 'nodewarden.web.session.v4';
|
||||||
@@ -95,6 +98,12 @@ export function loadSession(): SessionState | null {
|
|||||||
authMode: 'web-cookie',
|
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;
|
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null;
|
||||||
return {
|
return {
|
||||||
accessToken: parsed.accessToken,
|
accessToken: parsed.accessToken,
|
||||||
@@ -233,6 +242,7 @@ export async function loginWithPassword(
|
|||||||
totpCode?: string;
|
totpCode?: string;
|
||||||
rememberDevice?: boolean;
|
rememberDevice?: boolean;
|
||||||
useRememberToken?: boolean;
|
useRememberToken?: boolean;
|
||||||
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
): Promise<TokenSuccess | TokenError> {
|
): Promise<TokenSuccess | TokenError> {
|
||||||
const body = new URLSearchParams();
|
const body = new URLSearchParams();
|
||||||
@@ -262,6 +272,7 @@ export async function loginWithPassword(
|
|||||||
[WEB_SESSION_HEADER]: '1',
|
[WEB_SESSION_HEADER]: '1',
|
||||||
},
|
},
|
||||||
body: body.toString(),
|
body: body.toString(),
|
||||||
|
signal: options?.signal,
|
||||||
});
|
});
|
||||||
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
@@ -273,6 +284,40 @@ export async function loginWithPassword(
|
|||||||
return json;
|
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 {
|
function isTransientRefreshStatus(status: number): boolean {
|
||||||
return status === 0 || status === 429 || status >= 500;
|
return status === 0 || status === 429 || status >= 500;
|
||||||
}
|
}
|
||||||
@@ -430,6 +475,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(input, { ...init, headers });
|
const response = await fetch(input, { ...init, headers });
|
||||||
|
recordNodeWardenReachable();
|
||||||
if (response.status !== 429 && (response.status < 500 || response.status >= 600)) {
|
if (response.status !== 429 && (response.status < 500 || response.status >= 600)) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -440,6 +486,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (attempt === maxAttempts - 1) {
|
if (attempt === maxAttempts - 1) {
|
||||||
|
recordNodeWardenUnreachable();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -450,9 +497,10 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
};
|
};
|
||||||
|
|
||||||
const session = getSession();
|
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 || {});
|
const headers = new Headers(init.headers || {});
|
||||||
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
||||||
|
headers.set('X-NodeWarden-Web', '1');
|
||||||
|
|
||||||
let resp = await retryableRequest(headers);
|
let resp = await retryableRequest(headers);
|
||||||
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
|
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
|
||||||
@@ -461,6 +509,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
if (latest?.accessToken && latest.accessToken !== session.accessToken) {
|
if (latest?.accessToken && latest.accessToken !== session.accessToken) {
|
||||||
const latestHeaders = new Headers(init.headers || {});
|
const latestHeaders = new Headers(init.headers || {});
|
||||||
latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`);
|
latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`);
|
||||||
|
latestHeaders.set('X-NodeWarden-Web', '1');
|
||||||
resp = await retryableRequest(latestHeaders);
|
resp = await retryableRequest(latestHeaders);
|
||||||
if (resp.status !== 401) return resp;
|
if (resp.status !== 401) return resp;
|
||||||
}
|
}
|
||||||
@@ -486,6 +535,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
|
|
||||||
const retryHeaders = new Headers(init.headers || {});
|
const retryHeaders = new Headers(init.headers || {});
|
||||||
retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`);
|
retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`);
|
||||||
|
retryHeaders.set('X-NodeWarden-Web', '1');
|
||||||
resp = await retryableRequest(retryHeaders);
|
resp = await retryableRequest(retryHeaders);
|
||||||
return resp;
|
return resp;
|
||||||
};
|
};
|
||||||
@@ -594,6 +644,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> {
|
export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise<number> {
|
||||||
const resp = await authedFetch('/api/accounts/revision-date');
|
const resp = await authedFetch('/api/accounts/revision-date');
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
BackupRuntimeState,
|
BackupRuntimeState,
|
||||||
BackupScheduleConfig,
|
BackupScheduleConfig,
|
||||||
BackupSettings as AdminBackupSettings,
|
BackupSettings as AdminBackupSettings,
|
||||||
|
S3BackupAddressingStyle,
|
||||||
S3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
} from '@shared/backup-schema';
|
} from '@shared/backup-schema';
|
||||||
@@ -26,6 +27,7 @@ export type {
|
|||||||
BackupRuntimeState,
|
BackupRuntimeState,
|
||||||
BackupScheduleConfig,
|
BackupScheduleConfig,
|
||||||
AdminBackupSettings,
|
AdminBackupSettings,
|
||||||
|
S3BackupAddressingStyle,
|
||||||
S3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
};
|
};
|
||||||
@@ -96,6 +98,8 @@ export interface AdminBackupImportCounts {
|
|||||||
users: number;
|
users: number;
|
||||||
domainSettings?: number;
|
domainSettings?: number;
|
||||||
userRevisions: number;
|
userRevisions: number;
|
||||||
|
trustedTwoFactorDeviceTokens?: number;
|
||||||
|
webauthnCredentials?: number;
|
||||||
folders: number;
|
folders: number;
|
||||||
ciphers: number;
|
ciphers: number;
|
||||||
attachments: number;
|
attachments: number;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Cipher, Folder, Send } from '../types';
|
import type { Cipher, Folder, Send } from '../types';
|
||||||
import { getVaultRevisionDate } from './auth';
|
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';
|
import { parseJson, type AuthedFetch } from './shared';
|
||||||
|
|
||||||
interface VaultSyncResponse {
|
interface VaultSyncResponse {
|
||||||
@@ -43,6 +43,14 @@ export async function getCachedVaultCoreSnapshot(cacheKey: string): Promise<Vaul
|
|||||||
return snapshot;
|
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> {
|
export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise<VaultCoreSnapshot> {
|
||||||
const normalizedKey = String(cacheKey || '').trim();
|
const normalizedKey = String(cacheKey || '').trim();
|
||||||
if (!normalizedKey) return { ciphers: [], folders: [], sends: [] };
|
if (!normalizedKey) return { ciphers: [], folders: [], sends: [] };
|
||||||
|
|||||||
@@ -496,8 +496,11 @@ async function encryptPasswordHistory(
|
|||||||
const out: CipherPasswordHistoryEntry[] = [];
|
const out: CipherPasswordHistoryEntry[] = [];
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const rawPassword = String(entry?.password || '');
|
const rawPassword = String(entry?.password || '');
|
||||||
|
const hasDecryptedPassword = typeof entry?.decPassword === 'string';
|
||||||
const plainPassword = entry?.decPassword ?? rawPassword;
|
const plainPassword = entry?.decPassword ?? rawPassword;
|
||||||
const encryptedPassword = looksLikeCipherString(rawPassword)
|
const encryptedPassword = hasDecryptedPassword
|
||||||
|
? await encryptTextValue(plainPassword, enc, mac)
|
||||||
|
: looksLikeCipherString(rawPassword)
|
||||||
? rawPassword
|
? rawPassword
|
||||||
: await encryptTextValue(plainPassword, enc, mac);
|
: await encryptTextValue(plainPassword, enc, mac);
|
||||||
if (!encryptedPassword) continue;
|
if (!encryptedPassword) continue;
|
||||||
@@ -510,6 +513,133 @@ async function encryptPasswordHistory(
|
|||||||
return out.length ? out : null;
|
return out.length ? out : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function plainCipherValue(decrypted: unknown, raw: unknown = ''): string {
|
||||||
|
if (typeof decrypted === 'string' && !looksLikeCipherString(decrypted)) return decrypted;
|
||||||
|
const value = String(raw ?? '');
|
||||||
|
return looksLikeCipherString(value) ? '' : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftFromDecryptedCipher(cipher: Cipher): VaultDraft {
|
||||||
|
const type = Number(cipher.type || 1) || 1;
|
||||||
|
const draft: VaultDraft = {
|
||||||
|
type,
|
||||||
|
name: plainCipherValue(cipher.decName, cipher.name).trim() || 'Untitled',
|
||||||
|
notes: plainCipherValue(cipher.decNotes, cipher.notes),
|
||||||
|
favorite: !!cipher.favorite,
|
||||||
|
reprompt: Number(cipher.reprompt || 0) === 1,
|
||||||
|
folderId: cipher.folderId || '',
|
||||||
|
loginUsername: '',
|
||||||
|
loginPassword: '',
|
||||||
|
loginTotp: '',
|
||||||
|
loginUris: [{ uri: '', match: null, originalUri: '', extra: {} }],
|
||||||
|
loginFido2Credentials: [],
|
||||||
|
cardholderName: '',
|
||||||
|
cardNumber: '',
|
||||||
|
cardBrand: '',
|
||||||
|
cardExpMonth: '',
|
||||||
|
cardExpYear: '',
|
||||||
|
cardCode: '',
|
||||||
|
identTitle: '',
|
||||||
|
identFirstName: '',
|
||||||
|
identMiddleName: '',
|
||||||
|
identLastName: '',
|
||||||
|
identUsername: '',
|
||||||
|
identCompany: '',
|
||||||
|
identSsn: '',
|
||||||
|
identPassportNumber: '',
|
||||||
|
identLicenseNumber: '',
|
||||||
|
identEmail: '',
|
||||||
|
identPhone: '',
|
||||||
|
identAddress1: '',
|
||||||
|
identAddress2: '',
|
||||||
|
identAddress3: '',
|
||||||
|
identCity: '',
|
||||||
|
identState: '',
|
||||||
|
identPostalCode: '',
|
||||||
|
identCountry: '',
|
||||||
|
sshPrivateKey: '',
|
||||||
|
sshPublicKey: '',
|
||||||
|
sshFingerprint: '',
|
||||||
|
customFields: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
draft.customFields = (cipher.fields || [])
|
||||||
|
.map((field) => ({
|
||||||
|
type: parseFieldType(field.type ?? 0),
|
||||||
|
label: plainCipherValue(field.decName, field.name).trim(),
|
||||||
|
value: plainCipherValue(field.decValue, field.value),
|
||||||
|
}))
|
||||||
|
.filter((field) => field.label);
|
||||||
|
|
||||||
|
if (type === 1 && cipher.login) {
|
||||||
|
draft.loginUsername = plainCipherValue(cipher.login.decUsername, cipher.login.username);
|
||||||
|
draft.loginPassword = plainCipherValue(cipher.login.decPassword, cipher.login.password);
|
||||||
|
draft.loginTotp = plainCipherValue(cipher.login.decTotp, cipher.login.totp);
|
||||||
|
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
|
||||||
|
? cipher.login.fido2Credentials.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
|
||||||
|
: [];
|
||||||
|
const seenUris = new Set<string>();
|
||||||
|
const uris = (cipher.login.uris || [])
|
||||||
|
.map((entry) => {
|
||||||
|
const uri = plainCipherValue(entry.decUri, entry.uri).trim();
|
||||||
|
const extra = { ...(entry as Record<string, unknown>) };
|
||||||
|
delete extra.uri;
|
||||||
|
delete extra.uriChecksum;
|
||||||
|
delete extra.match;
|
||||||
|
delete extra.decUri;
|
||||||
|
return {
|
||||||
|
uri,
|
||||||
|
match: typeof entry.match === 'number' && Number.isFinite(entry.match) ? entry.match : null,
|
||||||
|
originalUri: '',
|
||||||
|
extra,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((entry) => {
|
||||||
|
if (!entry.uri) return false;
|
||||||
|
const key = entry.uri.toLowerCase();
|
||||||
|
if (seenUris.has(key)) return false;
|
||||||
|
seenUris.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
draft.loginUris = uris.length ? uris : draft.loginUris;
|
||||||
|
} else if (type === 3 && cipher.card) {
|
||||||
|
draft.cardholderName = plainCipherValue(cipher.card.decCardholderName, cipher.card.cardholderName);
|
||||||
|
draft.cardNumber = plainCipherValue(cipher.card.decNumber, cipher.card.number);
|
||||||
|
draft.cardBrand = plainCipherValue(cipher.card.decBrand, cipher.card.brand);
|
||||||
|
draft.cardExpMonth = plainCipherValue(cipher.card.decExpMonth, cipher.card.expMonth);
|
||||||
|
draft.cardExpYear = plainCipherValue(cipher.card.decExpYear, cipher.card.expYear);
|
||||||
|
draft.cardCode = plainCipherValue(cipher.card.decCode, cipher.card.code);
|
||||||
|
} else if (type === 4 && cipher.identity) {
|
||||||
|
draft.identTitle = plainCipherValue(cipher.identity.decTitle, cipher.identity.title);
|
||||||
|
draft.identFirstName = plainCipherValue(cipher.identity.decFirstName, cipher.identity.firstName);
|
||||||
|
draft.identMiddleName = plainCipherValue(cipher.identity.decMiddleName, cipher.identity.middleName);
|
||||||
|
draft.identLastName = plainCipherValue(cipher.identity.decLastName, cipher.identity.lastName);
|
||||||
|
draft.identUsername = plainCipherValue(cipher.identity.decUsername, cipher.identity.username);
|
||||||
|
draft.identCompany = plainCipherValue(cipher.identity.decCompany, cipher.identity.company);
|
||||||
|
draft.identSsn = plainCipherValue(cipher.identity.decSsn, cipher.identity.ssn);
|
||||||
|
draft.identPassportNumber = plainCipherValue(cipher.identity.decPassportNumber, cipher.identity.passportNumber);
|
||||||
|
draft.identLicenseNumber = plainCipherValue(cipher.identity.decLicenseNumber, cipher.identity.licenseNumber);
|
||||||
|
draft.identEmail = plainCipherValue(cipher.identity.decEmail, cipher.identity.email);
|
||||||
|
draft.identPhone = plainCipherValue(cipher.identity.decPhone, cipher.identity.phone);
|
||||||
|
draft.identAddress1 = plainCipherValue(cipher.identity.decAddress1, cipher.identity.address1);
|
||||||
|
draft.identAddress2 = plainCipherValue(cipher.identity.decAddress2, cipher.identity.address2);
|
||||||
|
draft.identAddress3 = plainCipherValue(cipher.identity.decAddress3, cipher.identity.address3);
|
||||||
|
draft.identCity = plainCipherValue(cipher.identity.decCity, cipher.identity.city);
|
||||||
|
draft.identState = plainCipherValue(cipher.identity.decState, cipher.identity.state);
|
||||||
|
draft.identPostalCode = plainCipherValue(cipher.identity.decPostalCode, cipher.identity.postalCode);
|
||||||
|
draft.identCountry = plainCipherValue(cipher.identity.decCountry, cipher.identity.country);
|
||||||
|
} else if (type === 5 && cipher.sshKey) {
|
||||||
|
draft.sshPrivateKey = plainCipherValue(cipher.sshKey.decPrivateKey, cipher.sshKey.privateKey);
|
||||||
|
draft.sshPublicKey = plainCipherValue(cipher.sshKey.decPublicKey, cipher.sshKey.publicKey);
|
||||||
|
draft.sshFingerprint = plainCipherValue(
|
||||||
|
cipher.sshKey.decFingerprint,
|
||||||
|
cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
async function buildUpdatedPasswordHistory(
|
async function buildUpdatedPasswordHistory(
|
||||||
cipher: Cipher | null,
|
cipher: Cipher | null,
|
||||||
draft: VaultDraft,
|
draft: VaultDraft,
|
||||||
@@ -689,13 +819,15 @@ async function repairCipherLoginUris(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let clearUri = String(entry.decUri || '').trim();
|
let clearUri = '';
|
||||||
if (!clearUri || looksLikeCipherString(clearUri)) {
|
let rawUriUsesCurrentKey = false;
|
||||||
try {
|
try {
|
||||||
clearUri = (await decryptStr(rawUri, enc, mac)).trim();
|
clearUri = (await decryptStr(rawUri, enc, mac)).trim();
|
||||||
} catch {
|
rawUriUsesCurrentKey = !!clearUri;
|
||||||
uris.push({ ...encryptedEntry });
|
} catch {
|
||||||
continue;
|
const fallbackUri = String(entry.decUri || '').trim();
|
||||||
|
if (fallbackUri && !looksLikeCipherString(fallbackUri)) {
|
||||||
|
clearUri = fallbackUri;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,15 +847,20 @@ async function repairCipherLoginUris(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentChecksumOk) {
|
if (currentChecksumOk && rawUriUsesCurrentKey) {
|
||||||
uris.push({ ...encryptedEntry });
|
uris.push({ ...encryptedEntry });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const repairedUri = rawUriUsesCurrentKey ? rawUri : await encryptTextValue(clearUri, enc, mac);
|
||||||
|
const repairedChecksum = currentChecksumOk
|
||||||
|
? rawChecksum
|
||||||
|
: await encryptTextValue(expectedChecksum, enc, mac);
|
||||||
|
|
||||||
uris.push({
|
uris.push({
|
||||||
...encryptedEntry,
|
...encryptedEntry,
|
||||||
uri: rawUri,
|
uri: repairedUri || rawUri,
|
||||||
uriChecksum: await encryptTextValue(expectedChecksum, enc, mac),
|
uriChecksum: repairedChecksum,
|
||||||
match: typeof entry.match === 'number' && Number.isFinite(entry.match) ? entry.match : null,
|
match: typeof entry.match === 'number' && Number.isFinite(entry.match) ? entry.match : null,
|
||||||
});
|
});
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -759,15 +896,22 @@ export async function repairCipherUriChecksums(
|
|||||||
let repaired = 0;
|
let repaired = 0;
|
||||||
|
|
||||||
for (const cipher of ciphers) {
|
for (const cipher of ciphers) {
|
||||||
if (!cipher?.id || cipher.type !== 1 || !looksLikeCipherString(cipher.key) || !cipher.login || !Array.isArray(cipher.login.uris)) continue;
|
if (!cipher?.id || cipher.type !== 1 || !cipher.login || !Array.isArray(cipher.login.uris)) continue;
|
||||||
let itemKey: Uint8Array;
|
let keys: { enc: Uint8Array; mac: Uint8Array; key: string | null } = {
|
||||||
try {
|
enc: userEnc,
|
||||||
itemKey = await decryptBw(String(cipher.key).trim(), userEnc, userMac);
|
mac: userMac,
|
||||||
} catch {
|
key: null,
|
||||||
continue;
|
};
|
||||||
|
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);
|
const repair = await repairCipherLoginUris(cipher, keys.enc, keys.mac);
|
||||||
if (!repair.changed) continue;
|
if (!repair.changed) continue;
|
||||||
|
|
||||||
@@ -782,9 +926,10 @@ export async function repairCipherUriChecksums(
|
|||||||
fields: Array.isArray(cipher.fields)
|
fields: Array.isArray(cipher.fields)
|
||||||
? cipher.fields.map(({ decName: _decName, decValue: _decValue, ...field }) => field)
|
? cipher.fields.map(({ decName: _decName, decValue: _decValue, ...field }) => field)
|
||||||
: null,
|
: null,
|
||||||
key: keys.key,
|
|
||||||
lastKnownRevisionDate: cipher.revisionDate ?? null,
|
lastKnownRevisionDate: cipher.revisionDate ?? null,
|
||||||
|
preserveRevisionDate: true,
|
||||||
};
|
};
|
||||||
|
if (keys.key) payload.key = keys.key;
|
||||||
|
|
||||||
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
|
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -798,6 +943,164 @@ export async function repairCipherUriChecksums(
|
|||||||
return repaired;
|
return repaired;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCipherKeyMismatchProbes(cipher: Cipher): string[] {
|
||||||
|
const candidates = [
|
||||||
|
cipher.name,
|
||||||
|
cipher.notes,
|
||||||
|
cipher.login?.username,
|
||||||
|
cipher.login?.password,
|
||||||
|
cipher.login?.totp,
|
||||||
|
...(cipher.login?.uris || []).map((uri) => uri.uri),
|
||||||
|
cipher.card?.cardholderName,
|
||||||
|
cipher.card?.number,
|
||||||
|
cipher.identity?.title,
|
||||||
|
cipher.identity?.firstName,
|
||||||
|
cipher.sshKey?.privateKey,
|
||||||
|
...(cipher.fields || []).flatMap((field) => [field.name, field.value]),
|
||||||
|
];
|
||||||
|
const probes: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const value of candidates) {
|
||||||
|
const probe = String(value || '').trim();
|
||||||
|
if (!looksLikeCipherString(probe) || seen.has(probe)) continue;
|
||||||
|
seen.add(probe);
|
||||||
|
probes.push(probe);
|
||||||
|
}
|
||||||
|
return probes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isResolvedEncryptedField(raw: unknown, decrypted: unknown): boolean {
|
||||||
|
const encrypted = String(raw || '').trim();
|
||||||
|
if (!looksLikeCipherString(encrypted)) return true;
|
||||||
|
const plain = typeof decrypted === 'string' ? decrypted.trim() : '';
|
||||||
|
return !!plain && !looksLikeCipherString(plain);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUnresolvedEncryptedFields(cipher: Cipher): boolean {
|
||||||
|
const fido2EncryptedFields = (cipher.login?.fido2Credentials || []).flatMap((credential) => [
|
||||||
|
credential?.credentialId,
|
||||||
|
credential?.keyType,
|
||||||
|
credential?.keyAlgorithm,
|
||||||
|
credential?.keyCurve,
|
||||||
|
credential?.keyValue,
|
||||||
|
credential?.rpId,
|
||||||
|
credential?.rpName,
|
||||||
|
credential?.userHandle,
|
||||||
|
credential?.userName,
|
||||||
|
credential?.userDisplayName,
|
||||||
|
credential?.counter,
|
||||||
|
credential?.discoverable,
|
||||||
|
]);
|
||||||
|
|
||||||
|
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],
|
||||||
|
]),
|
||||||
|
...(cipher.passwordHistory || []).map((entry) => [entry.password, entry.decPassword] as [unknown, unknown]),
|
||||||
|
...fido2EncryptedFields.map((value) => [value, undefined] as [unknown, unknown]),
|
||||||
|
];
|
||||||
|
|
||||||
|
return checks.some(([raw, decrypted]) => !isResolvedEncryptedField(raw, decrypted));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasItemKeyFieldMismatch(
|
||||||
|
cipher: Cipher,
|
||||||
|
userEnc: Uint8Array,
|
||||||
|
userMac: Uint8Array
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!looksLikeCipherString(cipher.key)) return false;
|
||||||
|
const probes = getCipherKeyMismatchProbes(cipher);
|
||||||
|
if (probes.length === 0) return false;
|
||||||
|
|
||||||
|
let itemKey: Uint8Array;
|
||||||
|
try {
|
||||||
|
itemKey = await decryptBw(String(cipher.key).trim(), userEnc, userMac);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (itemKey.length < 64) return false;
|
||||||
|
|
||||||
|
const itemEnc = itemKey.slice(0, 32);
|
||||||
|
const itemMac = itemKey.slice(32, 64);
|
||||||
|
for (const probe of probes) {
|
||||||
|
try {
|
||||||
|
await decryptStr(probe, itemEnc, itemMac);
|
||||||
|
continue;
|
||||||
|
} catch {
|
||||||
|
// Try the legacy user-key field path below.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await decryptStr(probe, userEnc, userMac);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// Keep scanning in case another field reveals a repairable mismatch.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repairCipherKeyMismatches(
|
||||||
|
authedFetch: AuthedFetch,
|
||||||
|
session: SessionState,
|
||||||
|
ciphers: Cipher[]
|
||||||
|
): Promise<number> {
|
||||||
|
if (!session.symEncKey || !session.symMacKey || !Array.isArray(ciphers) || ciphers.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userEnc = base64ToBytes(session.symEncKey);
|
||||||
|
const userMac = base64ToBytes(session.symMacKey);
|
||||||
|
let repaired = 0;
|
||||||
|
|
||||||
|
for (const cipher of ciphers) {
|
||||||
|
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), {
|
||||||
|
preserveRevisionDate: true,
|
||||||
|
});
|
||||||
|
repaired += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return repaired;
|
||||||
|
}
|
||||||
|
|
||||||
async function buildCipherPayload(
|
async function buildCipherPayload(
|
||||||
session: SessionState,
|
session: SessionState,
|
||||||
draft: VaultDraft,
|
draft: VaultDraft,
|
||||||
@@ -841,6 +1144,9 @@ async function buildCipherPayload(
|
|||||||
cipher?.login && typeof cipher.login === 'object'
|
cipher?.login && typeof cipher.login === 'object'
|
||||||
? { ...(cipher.login as Record<string, unknown>) }
|
? { ...(cipher.login as Record<string, unknown>) }
|
||||||
: {};
|
: {};
|
||||||
|
delete existingLogin.decUsername;
|
||||||
|
delete existingLogin.decPassword;
|
||||||
|
delete existingLogin.decTotp;
|
||||||
payload.login = {
|
payload.login = {
|
||||||
...existingLogin,
|
...existingLogin,
|
||||||
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
|
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
|
||||||
@@ -922,9 +1228,13 @@ export async function updateCipher(
|
|||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
session: SessionState,
|
session: SessionState,
|
||||||
cipher: Cipher,
|
cipher: Cipher,
|
||||||
draft: VaultDraft
|
draft: VaultDraft,
|
||||||
|
extraPayload?: Record<string, unknown>
|
||||||
): Promise<Cipher> {
|
): Promise<Cipher> {
|
||||||
const payload = await buildCipherPayload(session, draft, cipher);
|
const payload = await buildCipherPayload(session, draft, cipher);
|
||||||
|
if (extraPayload) {
|
||||||
|
Object.assign(payload, extraPayload);
|
||||||
|
}
|
||||||
|
|
||||||
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
|
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -1,23 +1,45 @@
|
|||||||
import {
|
import {
|
||||||
createAuthedFetch,
|
createAuthedFetch,
|
||||||
deriveLoginHashLocally,
|
deriveLoginHashLocally,
|
||||||
|
getAccountPasskeyAssertionOptions,
|
||||||
getProfile,
|
getProfile,
|
||||||
loadProfileSnapshot,
|
loadProfileSnapshot,
|
||||||
loadSession,
|
loadSession,
|
||||||
|
loginWithAccountPasskeyAssertion,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
recoverTwoFactor,
|
recoverTwoFactor,
|
||||||
registerAccount,
|
registerAccount,
|
||||||
unlockVaultKey,
|
unlockVaultKey,
|
||||||
} from '@/lib/api/auth';
|
} from '@/lib/api/auth';
|
||||||
|
import {
|
||||||
|
assertAccountPasskey,
|
||||||
|
unlockVaultKeyWithAccountPasskeyPrf,
|
||||||
|
} from '@/lib/account-passkeys';
|
||||||
import { readInviteCodeFromUrl } from '@/lib/app-support';
|
import { readInviteCodeFromUrl } from '@/lib/app-support';
|
||||||
import { t, translateServerError } from '@/lib/i18n';
|
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 {
|
export interface PendingTotp {
|
||||||
email: string;
|
email: string;
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
masterKey: Uint8Array;
|
masterKey: Uint8Array;
|
||||||
|
kdfIterations: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingPasskeyPassword {
|
||||||
|
token: TokenSuccess;
|
||||||
|
email: string;
|
||||||
|
kdfIterations: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type JwtUnsafeReason = 'missing' | 'default' | 'too_short';
|
export type JwtUnsafeReason = 'missing' | 'default' | 'too_short';
|
||||||
@@ -51,6 +73,11 @@ export type PasswordLoginResult =
|
|||||||
| { kind: 'totp'; pendingTotp: PendingTotp }
|
| { kind: 'totp'; pendingTotp: PendingTotp }
|
||||||
| { kind: 'error'; message: string };
|
| { kind: 'error'; message: string };
|
||||||
|
|
||||||
|
export type PasskeyLoginResult =
|
||||||
|
| { kind: 'success'; login: CompletedLogin }
|
||||||
|
| { kind: 'password'; pendingPasskeyPassword: PendingPasskeyPassword }
|
||||||
|
| { kind: 'error'; message: string };
|
||||||
|
|
||||||
export interface RecoverTwoFactorResult {
|
export interface RecoverTwoFactorResult {
|
||||||
login: CompletedLogin | null;
|
login: CompletedLogin | null;
|
||||||
newRecoveryCode: string | null;
|
newRecoveryCode: string | null;
|
||||||
@@ -82,6 +109,7 @@ async function maybeRefreshSession(session: SessionState): Promise<SessionState
|
|||||||
|
|
||||||
const refreshed = await refreshAccessToken(session);
|
const refreshed = await refreshAccessToken(session);
|
||||||
if (!refreshed.ok) {
|
if (!refreshed.ok) {
|
||||||
|
if (refreshed.transient) return session;
|
||||||
return session.accessToken && exp !== null && exp > nowSeconds ? session : null;
|
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 {
|
function readWindowBootstrap(): WebBootstrapResponse {
|
||||||
if (typeof window === 'undefined') return {};
|
if (typeof window === 'undefined') return {};
|
||||||
const raw = (window as Window & { __NW_BOOT__?: WebBootstrapResponse }).__NW_BOOT__;
|
const raw = (window as Window & { __NW_BOOT__?: WebBootstrapResponse }).__NW_BOOT__;
|
||||||
@@ -246,8 +278,22 @@ export async function hydrateLockedSession(
|
|||||||
session: SessionState,
|
session: SessionState,
|
||||||
fallbackProfile: Profile | null = null
|
fallbackProfile: Profile | null = null
|
||||||
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
||||||
|
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
|
||||||
|
if (hasOfflineUnlock && browserReportsOffline()) {
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const refreshedSession = await maybeRefreshSession(session);
|
const refreshedSession = await maybeRefreshSession(session);
|
||||||
if (!refreshedSession?.accessToken) {
|
if (!refreshedSession?.accessToken) {
|
||||||
|
if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) {
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||||
|
};
|
||||||
|
}
|
||||||
return { session: null, profile: null };
|
return { session: null, profile: null };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -272,7 +318,8 @@ export async function hydrateLockedSession(
|
|||||||
export async function completeLogin(
|
export async function completeLogin(
|
||||||
token: TokenSuccess,
|
token: TokenSuccess,
|
||||||
email: string,
|
email: string,
|
||||||
masterKey: Uint8Array
|
masterKey: Uint8Array,
|
||||||
|
fallbackKdfIterations: number
|
||||||
): Promise<CompletedLogin> {
|
): Promise<CompletedLogin> {
|
||||||
const normalizedEmail = email.trim().toLowerCase();
|
const normalizedEmail = email.trim().toLowerCase();
|
||||||
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
|
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
|
||||||
@@ -291,6 +338,49 @@ export async function completeLogin(
|
|||||||
throw new Error('Missing profile key');
|
throw new Error('Missing profile key');
|
||||||
}
|
}
|
||||||
const keys = await unlockVaultKey(profile.key, masterKey);
|
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 {
|
return {
|
||||||
session: { ...baseSession, ...keys },
|
session: { ...baseSession, ...keys },
|
||||||
profile,
|
profile,
|
||||||
@@ -310,7 +400,7 @@ export async function performPasswordLogin(
|
|||||||
if ('access_token' in token && token.access_token) {
|
if ('access_token' in token && token.access_token) {
|
||||||
return {
|
return {
|
||||||
kind: 'success',
|
kind: 'success',
|
||||||
login: await completeLogin(token, normalizedEmail, derived.masterKey),
|
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +412,7 @@ export async function performPasswordLogin(
|
|||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
passwordHash: derived.hash,
|
passwordHash: derived.hash,
|
||||||
masterKey: derived.masterKey,
|
masterKey: derived.masterKey,
|
||||||
|
kdfIterations: derived.kdfIterations,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -332,6 +423,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(
|
export async function performTotpLogin(
|
||||||
pendingTotp: PendingTotp,
|
pendingTotp: PendingTotp,
|
||||||
totpCode: string,
|
totpCode: string,
|
||||||
@@ -342,7 +489,7 @@ export async function performTotpLogin(
|
|||||||
rememberDevice,
|
rememberDevice,
|
||||||
});
|
});
|
||||||
if ('access_token' in token && token.access_token) {
|
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 };
|
const tokenError = token as { error_description?: string; error?: string };
|
||||||
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
|
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
|
||||||
@@ -361,7 +508,7 @@ export async function performRecoverTwoFactorLogin(
|
|||||||
|
|
||||||
if ('access_token' in token && token.access_token) {
|
if ('access_token' in token && token.access_token) {
|
||||||
return {
|
return {
|
||||||
login: await completeLogin(token, normalizedEmail, derived.masterKey),
|
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
||||||
newRecoveryCode: recovered.newRecoveryCode || null,
|
newRecoveryCode: recovered.newRecoveryCode || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -397,13 +544,52 @@ export async function performUnlock(
|
|||||||
fallbackIterations: number
|
fallbackIterations: number
|
||||||
): Promise<PasswordLoginResult> {
|
): Promise<PasswordLoginResult> {
|
||||||
const normalizedEmail = (profile?.email || session.email).trim().toLowerCase();
|
const normalizedEmail = (profile?.email || session.email).trim().toLowerCase();
|
||||||
const derived = await deriveLoginHashLocally(normalizedEmail, password, fallbackIterations);
|
const offlineIterations = getOfflineUnlockKdfIterations(normalizedEmail);
|
||||||
const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: true });
|
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 && browserReportsOffline()) {
|
||||||
|
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) {
|
if ('access_token' in token && token.access_token) {
|
||||||
return {
|
return {
|
||||||
kind: 'success',
|
kind: 'success',
|
||||||
login: await completeLogin(token, normalizedEmail, derived.masterKey),
|
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,6 +601,7 @@ export async function performUnlock(
|
|||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
passwordHash: derived.hash,
|
passwordHash: derived.hash,
|
||||||
masterKey: derived.masterKey,
|
masterKey: derived.masterKey,
|
||||||
|
kdfIterations: derived.kdfIterations,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,6 +220,25 @@ function normalizeTotpSecret(secret: string): string {
|
|||||||
return secret.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
|
return secret.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readOtpAuthParam(raw: string, name: string): string {
|
||||||
|
const queryStart = raw.indexOf('?');
|
||||||
|
if (queryStart < 0) return '';
|
||||||
|
const fragmentStart = raw.indexOf('#', queryStart + 1);
|
||||||
|
const query = raw.slice(queryStart + 1, fragmentStart > queryStart ? fragmentStart : undefined);
|
||||||
|
for (const part of query.split('&')) {
|
||||||
|
const eq = part.indexOf('=');
|
||||||
|
const key = eq >= 0 ? part.slice(0, eq) : part;
|
||||||
|
if (key.trim().toLowerCase() !== name.toLowerCase()) continue;
|
||||||
|
const value = eq >= 0 ? part.slice(eq + 1) : '';
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value.replace(/\+/g, ' '));
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function parseSteamSecret(raw: string): string {
|
function parseSteamSecret(raw: string): string {
|
||||||
const match = raw.trim().match(/^steam:\/\/([^/?#]+)(?:[/?#].*)?$/i);
|
const match = raw.trim().match(/^steam:\/\/([^/?#]+)(?:[/?#].*)?$/i);
|
||||||
if (!match?.[1]) return '';
|
if (!match?.[1]) return '';
|
||||||
@@ -276,7 +295,8 @@ function parseTotpConfig(raw: string): TotpConfig {
|
|||||||
if (/^otpauth:\/\//i.test(s)) {
|
if (/^otpauth:\/\//i.test(s)) {
|
||||||
try {
|
try {
|
||||||
const u = new URL(s);
|
const u = new URL(s);
|
||||||
if (u.hostname.toLowerCase() !== 'totp') {
|
const otpType = u.hostname.toLowerCase();
|
||||||
|
if (otpType !== 'totp') {
|
||||||
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
||||||
}
|
}
|
||||||
const label = decodeURIComponent((u.pathname || '').replace(/^\/+/, '')).toLowerCase();
|
const label = decodeURIComponent((u.pathname || '').replace(/^\/+/, '')).toLowerCase();
|
||||||
@@ -291,7 +311,16 @@ function parseTotpConfig(raw: string): TotpConfig {
|
|||||||
period: parseTotpPositiveInt(u.searchParams.get('period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
|
period: parseTotpPositiveInt(u.searchParams.get('period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
const issuer = readOtpAuthParam(s, 'issuer').trim().toLowerCase();
|
||||||
|
const algorithm = readOtpAuthParam(s, 'algorithm').trim().toLowerCase();
|
||||||
|
const steam = issuer === 'steam' || algorithm === 'steam';
|
||||||
|
return {
|
||||||
|
secret: normalizeTotpSecret(readOtpAuthParam(s, 'secret')),
|
||||||
|
steam,
|
||||||
|
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(algorithm),
|
||||||
|
digits: steam ? 5 : parseTotpPositiveInt(readOtpAuthParam(s, 'digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
|
||||||
|
period: parseTotpPositiveInt(readOtpAuthParam(s, 'period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { secret: normalizeTotpSecret(s), steam: false, ...DEFAULT_TOTP_CONFIG };
|
return { secret: normalizeTotpSecret(s), steam: false, ...DEFAULT_TOTP_CONFIG };
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
import { decryptStr, decryptBw } from './crypto';
|
import { decryptStr, decryptBw } from './crypto';
|
||||||
|
import { looksLikeCipherString } from './app-support';
|
||||||
import type { Cipher } from './types';
|
import type { Cipher } from './types';
|
||||||
|
|
||||||
async function decryptField(
|
async function decryptCipherField(
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
enc: Uint8Array,
|
itemEnc: Uint8Array,
|
||||||
mac: Uint8Array,
|
itemMac: Uint8Array,
|
||||||
|
userEnc: Uint8Array,
|
||||||
|
userMac: Uint8Array,
|
||||||
|
canFallbackToUserKey: boolean,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (!value || typeof value !== 'string') return '';
|
if (!value || typeof value !== 'string') return '';
|
||||||
try { return await decryptStr(value, enc, mac); } catch { return value; }
|
try {
|
||||||
|
return await decryptStr(value, itemEnc, itemMac);
|
||||||
|
} catch {
|
||||||
|
// Try the legacy user-key path for mixed key/field ciphers.
|
||||||
|
}
|
||||||
|
if (canFallbackToUserKey) {
|
||||||
|
try {
|
||||||
|
return await decryptStr(value, userEnc, userMac);
|
||||||
|
} catch {
|
||||||
|
// Preserve the old raw fallback for fields that are genuinely unreadable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return looksLikeCipherString(value) ? '' : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decryptSingleCipher(
|
export async function decryptSingleCipher(
|
||||||
@@ -17,29 +33,35 @@ export async function decryptSingleCipher(
|
|||||||
): Promise<Cipher> {
|
): Promise<Cipher> {
|
||||||
let itemEnc = userEnc;
|
let itemEnc = userEnc;
|
||||||
let itemMac = userMac;
|
let itemMac = userMac;
|
||||||
|
let usesItemKey = false;
|
||||||
if (encrypted.key) {
|
if (encrypted.key) {
|
||||||
try {
|
try {
|
||||||
const itemKey = await decryptBw(encrypted.key, userEnc, userMac);
|
const itemKey = await decryptBw(encrypted.key, userEnc, userMac);
|
||||||
itemEnc = itemKey.slice(0, 32);
|
if (itemKey.length >= 64) {
|
||||||
itemMac = itemKey.slice(32, 64);
|
itemEnc = itemKey.slice(0, 32);
|
||||||
|
itemMac = itemKey.slice(32, 64);
|
||||||
|
usesItemKey = true;
|
||||||
|
}
|
||||||
} catch { /* keep user key */ }
|
} catch { /* keep user key */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canFallbackToUserKey = usesItemKey;
|
||||||
|
|
||||||
const decrypted: Cipher = {
|
const decrypted: Cipher = {
|
||||||
...encrypted,
|
...encrypted,
|
||||||
decName: await decryptField(encrypted.name, itemEnc, itemMac),
|
decName: await decryptCipherField(encrypted.name, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decNotes: await decryptField(encrypted.notes, itemEnc, itemMac),
|
decNotes: await decryptCipherField(encrypted.notes, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (encrypted.login) {
|
if (encrypted.login) {
|
||||||
decrypted.login = {
|
decrypted.login = {
|
||||||
...encrypted.login,
|
...encrypted.login,
|
||||||
decUsername: await decryptField(encrypted.login.username, itemEnc, itemMac),
|
decUsername: await decryptCipherField(encrypted.login.username, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decPassword: await decryptField(encrypted.login.password, itemEnc, itemMac),
|
decPassword: await decryptCipherField(encrypted.login.password, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decTotp: await decryptField(encrypted.login.totp, itemEnc, itemMac),
|
decTotp: await decryptCipherField(encrypted.login.totp, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
uris: await Promise.all((encrypted.login.uris || []).map(async (u) => ({
|
uris: await Promise.all((encrypted.login.uris || []).map(async (u) => ({
|
||||||
...u,
|
...u,
|
||||||
decUri: await decryptField(u.uri, itemEnc, itemMac),
|
decUri: await decryptCipherField(u.uri, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
}))),
|
}))),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -48,7 +70,7 @@ export async function decryptSingleCipher(
|
|||||||
decrypted.passwordHistory = await Promise.all(
|
decrypted.passwordHistory = await Promise.all(
|
||||||
encrypted.passwordHistory.map(async (entry) => ({
|
encrypted.passwordHistory.map(async (entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
decPassword: await decryptField(entry?.password, itemEnc, itemMac),
|
decPassword: await decryptCipherField(entry?.password, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -56,36 +78,36 @@ export async function decryptSingleCipher(
|
|||||||
if (encrypted.card) {
|
if (encrypted.card) {
|
||||||
decrypted.card = {
|
decrypted.card = {
|
||||||
...encrypted.card,
|
...encrypted.card,
|
||||||
decCardholderName: await decryptField(encrypted.card.cardholderName, itemEnc, itemMac),
|
decCardholderName: await decryptCipherField(encrypted.card.cardholderName, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decNumber: await decryptField(encrypted.card.number, itemEnc, itemMac),
|
decNumber: await decryptCipherField(encrypted.card.number, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decBrand: await decryptField(encrypted.card.brand, itemEnc, itemMac),
|
decBrand: await decryptCipherField(encrypted.card.brand, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decExpMonth: await decryptField(encrypted.card.expMonth, itemEnc, itemMac),
|
decExpMonth: await decryptCipherField(encrypted.card.expMonth, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decExpYear: await decryptField(encrypted.card.expYear, itemEnc, itemMac),
|
decExpYear: await decryptCipherField(encrypted.card.expYear, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decCode: await decryptField(encrypted.card.code, itemEnc, itemMac),
|
decCode: await decryptCipherField(encrypted.card.code, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (encrypted.identity) {
|
if (encrypted.identity) {
|
||||||
decrypted.identity = {
|
decrypted.identity = {
|
||||||
...encrypted.identity,
|
...encrypted.identity,
|
||||||
decTitle: await decryptField(encrypted.identity.title, itemEnc, itemMac),
|
decTitle: await decryptCipherField(encrypted.identity.title, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decFirstName: await decryptField(encrypted.identity.firstName, itemEnc, itemMac),
|
decFirstName: await decryptCipherField(encrypted.identity.firstName, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decMiddleName: await decryptField(encrypted.identity.middleName, itemEnc, itemMac),
|
decMiddleName: await decryptCipherField(encrypted.identity.middleName, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decLastName: await decryptField(encrypted.identity.lastName, itemEnc, itemMac),
|
decLastName: await decryptCipherField(encrypted.identity.lastName, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decUsername: await decryptField(encrypted.identity.username, itemEnc, itemMac),
|
decUsername: await decryptCipherField(encrypted.identity.username, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decCompany: await decryptField(encrypted.identity.company, itemEnc, itemMac),
|
decCompany: await decryptCipherField(encrypted.identity.company, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decSsn: await decryptField(encrypted.identity.ssn, itemEnc, itemMac),
|
decSsn: await decryptCipherField(encrypted.identity.ssn, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decPassportNumber: await decryptField(encrypted.identity.passportNumber, itemEnc, itemMac),
|
decPassportNumber: await decryptCipherField(encrypted.identity.passportNumber, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decLicenseNumber: await decryptField(encrypted.identity.licenseNumber, itemEnc, itemMac),
|
decLicenseNumber: await decryptCipherField(encrypted.identity.licenseNumber, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decEmail: await decryptField(encrypted.identity.email, itemEnc, itemMac),
|
decEmail: await decryptCipherField(encrypted.identity.email, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decPhone: await decryptField(encrypted.identity.phone, itemEnc, itemMac),
|
decPhone: await decryptCipherField(encrypted.identity.phone, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decAddress1: await decryptField(encrypted.identity.address1, itemEnc, itemMac),
|
decAddress1: await decryptCipherField(encrypted.identity.address1, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decAddress2: await decryptField(encrypted.identity.address2, itemEnc, itemMac),
|
decAddress2: await decryptCipherField(encrypted.identity.address2, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decAddress3: await decryptField(encrypted.identity.address3, itemEnc, itemMac),
|
decAddress3: await decryptCipherField(encrypted.identity.address3, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decCity: await decryptField(encrypted.identity.city, itemEnc, itemMac),
|
decCity: await decryptCipherField(encrypted.identity.city, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decState: await decryptField(encrypted.identity.state, itemEnc, itemMac),
|
decState: await decryptCipherField(encrypted.identity.state, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decPostalCode: await decryptField(encrypted.identity.postalCode, itemEnc, itemMac),
|
decPostalCode: await decryptCipherField(encrypted.identity.postalCode, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decCountry: await decryptField(encrypted.identity.country, itemEnc, itemMac),
|
decCountry: await decryptCipherField(encrypted.identity.country, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,11 +115,11 @@ export async function decryptSingleCipher(
|
|||||||
const fingerprint = encrypted.sshKey.keyFingerprint || encrypted.sshKey.fingerprint || '';
|
const fingerprint = encrypted.sshKey.keyFingerprint || encrypted.sshKey.fingerprint || '';
|
||||||
decrypted.sshKey = {
|
decrypted.sshKey = {
|
||||||
...encrypted.sshKey,
|
...encrypted.sshKey,
|
||||||
decPrivateKey: await decryptField(encrypted.sshKey.privateKey, itemEnc, itemMac),
|
decPrivateKey: await decryptCipherField(encrypted.sshKey.privateKey, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decPublicKey: await decryptField(encrypted.sshKey.publicKey, itemEnc, itemMac),
|
decPublicKey: await decryptCipherField(encrypted.sshKey.publicKey, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
keyFingerprint: fingerprint || null,
|
keyFingerprint: fingerprint || null,
|
||||||
fingerprint: fingerprint || null,
|
fingerprint: fingerprint || null,
|
||||||
decFingerprint: await decryptField(fingerprint, itemEnc, itemMac),
|
decFingerprint: await decryptCipherField(fingerprint, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,8 +127,8 @@ export async function decryptSingleCipher(
|
|||||||
decrypted.fields = await Promise.all(
|
decrypted.fields = await Promise.all(
|
||||||
encrypted.fields.map(async (field) => ({
|
encrypted.fields.map(async (field) => ({
|
||||||
...field,
|
...field,
|
||||||
decName: await decryptField(field.name, itemEnc, itemMac),
|
decName: await decryptCipherField(field.name, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decValue: await decryptField(field.value, itemEnc, itemMac),
|
decValue: await decryptCipherField(field.value, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ configureZipJs({ useWebWorkers: false });
|
|||||||
|
|
||||||
export const EXPORT_FORMATS = [
|
export const EXPORT_FORMATS = [
|
||||||
{ id: 'bitwarden_json', label: 'Bitwarden (vault as json)' },
|
{ id: 'bitwarden_json', label: 'Bitwarden (vault as json)' },
|
||||||
|
{ id: 'bitwarden_csv', label: 'Bitwarden (vault as csv)' },
|
||||||
{ id: 'bitwarden_encrypted_json', label: 'Bitwarden (encrypted vault as json)' },
|
{ id: 'bitwarden_encrypted_json', label: 'Bitwarden (encrypted vault as json)' },
|
||||||
{ id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' },
|
{ id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' },
|
||||||
{ id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' },
|
{ id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' },
|
||||||
@@ -70,6 +71,31 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return !!value && typeof value === 'object';
|
return !!value && typeof value === 'object';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function csvText(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCsvCell(value: unknown): string {
|
||||||
|
const text = csvText(value);
|
||||||
|
if (!/[",\r\n]/.test(text)) return text;
|
||||||
|
return `"${text.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCsvString(rows: string[][]): string {
|
||||||
|
return `${rows.map((row) => row.map(escapeCsvCell).join(',')).join('\r\n')}\r\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSingleRowCsvString(values: string[]): string {
|
||||||
|
return values.map(escapeCsvCell).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
function isCipherString(value: string): boolean {
|
function isCipherString(value: string): boolean {
|
||||||
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
||||||
}
|
}
|
||||||
@@ -189,12 +215,15 @@ function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
|
|||||||
const login = cipher.login;
|
const login = cipher.login;
|
||||||
out.login = login
|
out.login = login
|
||||||
? {
|
? {
|
||||||
|
...cloneValue(login),
|
||||||
username: login.username ?? null,
|
username: login.username ?? null,
|
||||||
password: login.password ?? null,
|
password: login.password ?? null,
|
||||||
totp: login.totp ?? null,
|
totp: login.totp ?? null,
|
||||||
uris: Array.isArray(login.uris)
|
uris: Array.isArray(login.uris)
|
||||||
? login.uris.map((uri) => ({
|
? login.uris.map((uri) => ({
|
||||||
|
...cloneValue(uri),
|
||||||
uri: uri?.uri ?? null,
|
uri: uri?.uri ?? null,
|
||||||
|
uriChecksum: uri?.uriChecksum ?? null,
|
||||||
match: (uri as { match?: unknown })?.match ?? null,
|
match: (uri as { match?: unknown })?.match ?? null,
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
@@ -380,6 +409,106 @@ export async function buildPlainBitwardenJsonString(args: BuildPlainJsonArgs): P
|
|||||||
return JSON.stringify(doc, null, 2);
|
return JSON.stringify(doc, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BITWARDEN_CSV_HEADERS = [
|
||||||
|
'folder',
|
||||||
|
'favorite',
|
||||||
|
'type',
|
||||||
|
'name',
|
||||||
|
'notes',
|
||||||
|
'fields',
|
||||||
|
'reprompt',
|
||||||
|
'login_uri',
|
||||||
|
'login_username',
|
||||||
|
'login_password',
|
||||||
|
'login_totp',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function bitwardenCsvType(type: number): 'login' | 'note' {
|
||||||
|
return type === 1 ? 'login' : 'note';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceTypeLabel(type: number): string {
|
||||||
|
if (type === 3) return 'card';
|
||||||
|
if (type === 4) return 'identity';
|
||||||
|
if (type === 5) return 'sshKey';
|
||||||
|
if (type === 2) return 'note';
|
||||||
|
return `type ${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendFieldLine(lines: string[], name: unknown, value: unknown): void {
|
||||||
|
const key = csvText(name).trim();
|
||||||
|
const text = csvText(value);
|
||||||
|
if (!key || !text) return;
|
||||||
|
lines.push(`${key}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRecordFieldLines(lines: string[], prefix: string, value: unknown): void {
|
||||||
|
if (!isRecord(value)) return;
|
||||||
|
for (const [key, fieldValue] of Object.entries(value)) {
|
||||||
|
appendFieldLine(lines, `${prefix}.${key}`, fieldValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBitwardenCsvFields(item: Record<string, unknown>, type: number): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const fields = Array.isArray(item.fields) ? item.fields : [];
|
||||||
|
for (const field of fields) {
|
||||||
|
if (!isRecord(field)) continue;
|
||||||
|
appendFieldLine(lines, field.name, field.value);
|
||||||
|
}
|
||||||
|
if (type !== 1 && type !== 2) {
|
||||||
|
appendFieldLine(lines, 'nodewardenType', sourceTypeLabel(type));
|
||||||
|
appendRecordFieldLines(lines, sourceTypeLabel(type), item[sourceTypeLabel(type)]);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFolderNameById(foldersRaw: unknown): Map<string, string> {
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
const folders = Array.isArray(foldersRaw) ? foldersRaw : [];
|
||||||
|
for (const folder of folders) {
|
||||||
|
if (!isRecord(folder)) continue;
|
||||||
|
const id = csvText(folder.id).trim();
|
||||||
|
if (!id) continue;
|
||||||
|
out.set(id, csvText(folder.name));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBitwardenCsvLoginUri(login: Record<string, unknown> | null): string {
|
||||||
|
const uris = Array.isArray(login?.uris) ? login.uris : [];
|
||||||
|
return buildSingleRowCsvString(uris
|
||||||
|
.map((uri) => (isRecord(uri) ? csvText(uri.uri).trim() : ''))
|
||||||
|
.filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBitwardenCsvString(bitwardenJsonDoc: Record<string, unknown>): string {
|
||||||
|
const folderNameById = buildFolderNameById(bitwardenJsonDoc.folders);
|
||||||
|
const rows: string[][] = [[...BITWARDEN_CSV_HEADERS]];
|
||||||
|
const items = Array.isArray(bitwardenJsonDoc.items) ? bitwardenJsonDoc.items : [];
|
||||||
|
for (const itemRaw of items) {
|
||||||
|
if (!isRecord(itemRaw)) continue;
|
||||||
|
const type = normalizeNumber(itemRaw.type, 1);
|
||||||
|
const isLogin = type === 1;
|
||||||
|
const login = isRecord(itemRaw.login) ? itemRaw.login : null;
|
||||||
|
const folderId = csvText(itemRaw.folderId).trim();
|
||||||
|
rows.push([
|
||||||
|
folderNameById.get(folderId) || '',
|
||||||
|
itemRaw.favorite ? '1' : '0',
|
||||||
|
bitwardenCsvType(type),
|
||||||
|
csvText(itemRaw.name) || '--',
|
||||||
|
csvText(itemRaw.notes),
|
||||||
|
buildBitwardenCsvFields(itemRaw, type),
|
||||||
|
String(normalizeNumber(itemRaw.reprompt, 0)),
|
||||||
|
isLogin ? buildBitwardenCsvLoginUri(login) : '',
|
||||||
|
isLogin ? csvText(login?.username) : '',
|
||||||
|
isLogin ? csvText(login?.password) : '',
|
||||||
|
isLogin ? csvText(login?.totp) : '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return `\uFEFF${buildCsvString(rows)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
|
export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
|
||||||
const userEnc = base64ToBytes(args.userEncB64);
|
const userEnc = base64ToBytes(args.userEncB64);
|
||||||
const userMac = base64ToBytes(args.userMacB64);
|
const userMac = base64ToBytes(args.userMacB64);
|
||||||
@@ -563,11 +692,13 @@ function nowStamp(now = new Date()): string {
|
|||||||
export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string {
|
export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string {
|
||||||
const stamp = nowStamp();
|
const stamp = nowStamp();
|
||||||
if (
|
if (
|
||||||
|
format === 'bitwarden_csv' ||
|
||||||
format === 'bitwarden_json' ||
|
format === 'bitwarden_json' ||
|
||||||
format === 'bitwarden_encrypted_json' ||
|
format === 'bitwarden_encrypted_json' ||
|
||||||
format === 'nodewarden_json' ||
|
format === 'nodewarden_json' ||
|
||||||
format === 'nodewarden_encrypted_json'
|
format === 'nodewarden_encrypted_json'
|
||||||
) {
|
) {
|
||||||
|
if (format === 'bitwarden_csv') return `bitwarden_export_${stamp}.csv`;
|
||||||
if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`;
|
if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`;
|
||||||
return `bitwarden_export_${stamp}.json`;
|
return `bitwarden_export_${stamp}.json`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ const localeLoaders: Record<Locale, () => Promise<{ default: MessageTable }>> =
|
|||||||
es: () => import('./i18n/locales/es'),
|
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> {
|
async function loadLocaleMessages(next: Locale): Promise<MessageTable> {
|
||||||
const cached = loadedMessages.get(next);
|
const cached = loadedMessages.get(next);
|
||||||
if (cached) return cached;
|
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);
|
console.error('Failed to load locale, falling back to English:', error);
|
||||||
locale = 'en';
|
locale = 'en';
|
||||||
activeMessages = await loadFallbackMessages();
|
activeMessages = await loadFallbackMessages();
|
||||||
|
} finally {
|
||||||
|
syncDocumentLanguage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +154,7 @@ export async function setLocale(next: Locale): Promise<void> {
|
|||||||
}
|
}
|
||||||
locale = next;
|
locale = next;
|
||||||
activeMessages = nextMessages;
|
activeMessages = nextMessages;
|
||||||
|
syncDocumentLanguage();
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(LOCALE_STORAGE_KEY, next);
|
localStorage.setItem(LOCALE_STORAGE_KEY, next);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -267,6 +267,9 @@ const en: Record<string, string> = {
|
|||||||
"txt_backup_webdav_password": "WebDAV Password",
|
"txt_backup_webdav_password": "WebDAV Password",
|
||||||
"txt_backup_webdav_path": "Remote Folder",
|
"txt_backup_webdav_path": "Remote Folder",
|
||||||
"txt_backup_s3_endpoint": "S3 Endpoint",
|
"txt_backup_s3_endpoint": "S3 Endpoint",
|
||||||
|
"txt_backup_s3_addressing_style": "S3 Addressing Style",
|
||||||
|
"txt_backup_s3_addressing_path_style": "path-style (default)",
|
||||||
|
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
|
||||||
"txt_backup_s3_bucket": "Bucket",
|
"txt_backup_s3_bucket": "Bucket",
|
||||||
"txt_backup_s3_region": "Region",
|
"txt_backup_s3_region": "Region",
|
||||||
"txt_backup_s3_access_key": "Access Key",
|
"txt_backup_s3_access_key": "Access Key",
|
||||||
@@ -447,6 +450,11 @@ const en: Record<string, string> = {
|
|||||||
"txt_favorite": "Favorite",
|
"txt_favorite": "Favorite",
|
||||||
"txt_favorites": "Favorites",
|
"txt_favorites": "Favorites",
|
||||||
"txt_duplicates": "Duplicates",
|
"txt_duplicates": "Duplicates",
|
||||||
|
"txt_duplicate_detection_mode": "Match by",
|
||||||
|
"txt_duplicate_mode_exact": "Exact item",
|
||||||
|
"txt_duplicate_mode_login_site": "Site + username + password",
|
||||||
|
"txt_duplicate_mode_login_credentials": "Username + password",
|
||||||
|
"txt_duplicate_mode_password": "Reused password",
|
||||||
"txt_field": "Field",
|
"txt_field": "Field",
|
||||||
"txt_field_label": "Field Label",
|
"txt_field_label": "Field Label",
|
||||||
"txt_field_label_is_required": "Field label is required.",
|
"txt_field_label_is_required": "Field label is required.",
|
||||||
@@ -631,6 +639,49 @@ const en: Record<string, string> = {
|
|||||||
"txt_passkey": "Passkey",
|
"txt_passkey": "Passkey",
|
||||||
"txt_passkeys": "Passkeys",
|
"txt_passkeys": "Passkeys",
|
||||||
"txt_passkey_created_at_value": "Created on {value}",
|
"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_phone": "Phone",
|
||||||
"txt_please_input_email_and_password": "Please input email and password",
|
"txt_please_input_email_and_password": "Please input email and password",
|
||||||
"txt_please_input_master_password": "Please input master password",
|
"txt_please_input_master_password": "Please input master password",
|
||||||
@@ -707,6 +758,7 @@ const en: Record<string, string> = {
|
|||||||
"txt_search_sends": "Search sends...",
|
"txt_search_sends": "Search sends...",
|
||||||
"txt_session_refresh_failed": "Session refresh failed. Please sign in again.",
|
"txt_session_refresh_failed": "Session refresh failed. Please sign in again.",
|
||||||
"txt_search_your_secure_vault": "Search your secure vault...",
|
"txt_search_your_secure_vault": "Search your secure vault...",
|
||||||
|
"txt_search_items_count": "Search within {count} items...",
|
||||||
"txt_clear_search": "Clear search",
|
"txt_clear_search": "Clear search",
|
||||||
"txt_clear_search_esc": "Clear search (Esc)",
|
"txt_clear_search_esc": "Clear search (Esc)",
|
||||||
"txt_sort": "Sort",
|
"txt_sort": "Sort",
|
||||||
@@ -735,6 +787,7 @@ const en: Record<string, string> = {
|
|||||||
"txt_status": "Status",
|
"txt_status": "Status",
|
||||||
"txt_online": "Online",
|
"txt_online": "Online",
|
||||||
"txt_offline": "Offline",
|
"txt_offline": "Offline",
|
||||||
|
"txt_offline_vault_readonly": "Offline mode is read-only. Connect to NodeWarden before changing your vault.",
|
||||||
"txt_submit": "Submit",
|
"txt_submit": "Submit",
|
||||||
"txt_sync": "Sync",
|
"txt_sync": "Sync",
|
||||||
"txt_sync_vault": "Sync Vault",
|
"txt_sync_vault": "Sync Vault",
|
||||||
@@ -1128,7 +1181,22 @@ const en: Record<string, string> = {
|
|||||||
"txt_target": "Target",
|
"txt_target": "Target",
|
||||||
"txt_time": "Time",
|
"txt_time": "Time",
|
||||||
"txt_time_range": "Time range",
|
"txt_time_range": "Time range",
|
||||||
"txt_remove_domain": "Remove domain"
|
"txt_remove_domain": "Remove domain",
|
||||||
|
"txt_approve_device_login": "Approve device login",
|
||||||
|
"txt_auth_request_approve_message": "Unlock Bitwarden on your device or approve from the web app. Before approving, make sure the fingerprint phrase matches the one below.",
|
||||||
|
"txt_approve": "Approve",
|
||||||
|
"txt_approving": "Approving...",
|
||||||
|
"txt_deny": "Deny",
|
||||||
|
"txt_later": "Later",
|
||||||
|
"txt_pending_device_logins": "Pending device logins",
|
||||||
|
"txt_no_pending_device_logins": "No pending device logins",
|
||||||
|
"txt_fingerprint_phrase": "Fingerprint phrase",
|
||||||
|
"txt_auth_requests_load_failed": "Failed to load device login requests",
|
||||||
|
"txt_auth_request_update_failed": "Failed to update device login request",
|
||||||
|
"txt_auth_request_approved": "Device login approved",
|
||||||
|
"txt_auth_request_denied": "Device login denied",
|
||||||
|
"txt_auth_request_missing_public_key": "Device login request is missing a public key",
|
||||||
|
"txt_ip_address": "IP address"
|
||||||
};
|
};
|
||||||
|
|
||||||
export default en;
|
export default en;
|
||||||
|
|||||||
@@ -267,6 +267,9 @@ const es: Record<string, string> = {
|
|||||||
"txt_backup_webdav_password": "Contraseña WebDAV",
|
"txt_backup_webdav_password": "Contraseña WebDAV",
|
||||||
"txt_backup_webdav_path": "Carpeta remota",
|
"txt_backup_webdav_path": "Carpeta remota",
|
||||||
"txt_backup_s3_endpoint": "Endpoint S3",
|
"txt_backup_s3_endpoint": "Endpoint S3",
|
||||||
|
"txt_backup_s3_addressing_style": "Estilo de direccionamiento S3",
|
||||||
|
"txt_backup_s3_addressing_path_style": "path-style (predeterminado)",
|
||||||
|
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
|
||||||
"txt_backup_s3_bucket": "Bucket S3",
|
"txt_backup_s3_bucket": "Bucket S3",
|
||||||
"txt_backup_s3_region": "Región",
|
"txt_backup_s3_region": "Región",
|
||||||
"txt_backup_s3_access_key": "Clave de acceso",
|
"txt_backup_s3_access_key": "Clave de acceso",
|
||||||
@@ -447,6 +450,11 @@ const es: Record<string, string> = {
|
|||||||
"txt_favorite": "Favorito",
|
"txt_favorite": "Favorito",
|
||||||
"txt_favorites": "Favoritos",
|
"txt_favorites": "Favoritos",
|
||||||
"txt_duplicates": "Duplicados",
|
"txt_duplicates": "Duplicados",
|
||||||
|
"txt_duplicate_detection_mode": "Coincidir por",
|
||||||
|
"txt_duplicate_mode_exact": "Elemento exacto",
|
||||||
|
"txt_duplicate_mode_login_site": "Sitio + usuario + contraseña",
|
||||||
|
"txt_duplicate_mode_login_credentials": "Usuario + contraseña",
|
||||||
|
"txt_duplicate_mode_password": "Contraseña reutilizada",
|
||||||
"txt_field": "Campo",
|
"txt_field": "Campo",
|
||||||
"txt_field_label": "Etiqueta del campo",
|
"txt_field_label": "Etiqueta del campo",
|
||||||
"txt_field_label_is_required": "La etiqueta del campo es obligatoria.",
|
"txt_field_label_is_required": "La etiqueta del campo es obligatoria.",
|
||||||
@@ -631,6 +639,49 @@ const es: Record<string, string> = {
|
|||||||
"txt_passkey": "Clave de acceso",
|
"txt_passkey": "Clave de acceso",
|
||||||
"txt_passkeys": "Claves de acceso",
|
"txt_passkeys": "Claves de acceso",
|
||||||
"txt_passkey_created_at_value": "Creado el {value}",
|
"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_phone": "Teléfono",
|
||||||
"txt_please_input_email_and_password": "Por favor, introduzca correo y contraseña",
|
"txt_please_input_email_and_password": "Por favor, introduzca correo y contraseña",
|
||||||
"txt_please_input_master_password": "Por favor, introduzca contraseña maestra",
|
"txt_please_input_master_password": "Por favor, introduzca contraseña maestra",
|
||||||
@@ -707,6 +758,7 @@ const es: Record<string, string> = {
|
|||||||
"txt_search_sends": "Buscar envíos...",
|
"txt_search_sends": "Buscar envíos...",
|
||||||
"txt_session_refresh_failed": "Error al actualizar la sesión. Inicia sesión de nuevo.",
|
"txt_session_refresh_failed": "Error al actualizar la sesión. Inicia sesión de nuevo.",
|
||||||
"txt_search_your_secure_vault": "Buscar en su bóveda segura...",
|
"txt_search_your_secure_vault": "Buscar en su bóveda segura...",
|
||||||
|
"txt_search_items_count": "Buscar entre {count} elementos...",
|
||||||
"txt_clear_search": "Limpiar búsqueda",
|
"txt_clear_search": "Limpiar búsqueda",
|
||||||
"txt_clear_search_esc": "Limpiar búsqueda (Esc)",
|
"txt_clear_search_esc": "Limpiar búsqueda (Esc)",
|
||||||
"txt_sort": "Ordenar",
|
"txt_sort": "Ordenar",
|
||||||
@@ -735,6 +787,7 @@ const es: Record<string, string> = {
|
|||||||
"txt_status": "Estado",
|
"txt_status": "Estado",
|
||||||
"txt_online": "En línea",
|
"txt_online": "En línea",
|
||||||
"txt_offline": "Sin conexión",
|
"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_submit": "Enviar",
|
||||||
"txt_sync": "Sincronizar",
|
"txt_sync": "Sincronizar",
|
||||||
"txt_sync_vault": "Sincronizar bóveda",
|
"txt_sync_vault": "Sincronizar bóveda",
|
||||||
@@ -1128,7 +1181,22 @@ const es: Record<string, string> = {
|
|||||||
"txt_target": "Destino",
|
"txt_target": "Destino",
|
||||||
"txt_time": "Hora",
|
"txt_time": "Hora",
|
||||||
"txt_time_range": "Rango de tiempo",
|
"txt_time_range": "Rango de tiempo",
|
||||||
"txt_remove_domain": "Quitar dominio"
|
"txt_remove_domain": "Quitar dominio",
|
||||||
|
"txt_approve_device_login": "Aprobar inicio de sesión con dispositivo",
|
||||||
|
"txt_auth_request_approve_message": "Desbloquee Bitwarden en su dispositivo o apruebe desde la aplicación web. Antes de aprobar, asegúrese de que la frase de huella coincida con la siguiente.",
|
||||||
|
"txt_fingerprint_phrase": "Frase de huella",
|
||||||
|
"txt_ip_address": "Dirección IP",
|
||||||
|
"txt_approve": "Aprobar",
|
||||||
|
"txt_approving": "Aprobando...",
|
||||||
|
"txt_deny": "Denegar",
|
||||||
|
"txt_later": "Más tarde",
|
||||||
|
"txt_pending_device_logins": "Inicios de sesión con dispositivo pendientes",
|
||||||
|
"txt_no_pending_device_logins": "No hay inicios de sesión con dispositivo pendientes",
|
||||||
|
"txt_auth_requests_load_failed": "No se pudieron cargar las solicitudes de inicio de sesión con dispositivo",
|
||||||
|
"txt_auth_request_update_failed": "No se pudo actualizar la solicitud de inicio de sesión con dispositivo",
|
||||||
|
"txt_auth_request_approved": "Inicio de sesión con dispositivo aprobado",
|
||||||
|
"txt_auth_request_denied": "Inicio de sesión con dispositivo denegado",
|
||||||
|
"txt_auth_request_missing_public_key": "La solicitud de inicio de sesión con dispositivo no incluye una clave pública"
|
||||||
};
|
};
|
||||||
|
|
||||||
export default es;
|
export default es;
|
||||||
|
|||||||
@@ -267,6 +267,9 @@ const ru: Record<string, string> = {
|
|||||||
"txt_backup_webdav_password": "Пароль WebDAV",
|
"txt_backup_webdav_password": "Пароль WebDAV",
|
||||||
"txt_backup_webdav_path": "Удаленная папка",
|
"txt_backup_webdav_path": "Удаленная папка",
|
||||||
"txt_backup_s3_endpoint": "S3 endpoint",
|
"txt_backup_s3_endpoint": "S3 endpoint",
|
||||||
|
"txt_backup_s3_addressing_style": "Стиль адресации S3",
|
||||||
|
"txt_backup_s3_addressing_path_style": "path-style (по умолчанию)",
|
||||||
|
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
|
||||||
"txt_backup_s3_bucket": "Бакет",
|
"txt_backup_s3_bucket": "Бакет",
|
||||||
"txt_backup_s3_region": "Регион",
|
"txt_backup_s3_region": "Регион",
|
||||||
"txt_backup_s3_access_key": "Ключ доступа",
|
"txt_backup_s3_access_key": "Ключ доступа",
|
||||||
@@ -367,7 +370,7 @@ const ru: Record<string, string> = {
|
|||||||
"txt_delete_all_invite_codes_active_inactive": "Удалить все пригласительные коды (активные/неактивные)?",
|
"txt_delete_all_invite_codes_active_inactive": "Удалить все пригласительные коды (активные/неактивные)?",
|
||||||
"txt_delete_all_invites": "Удалить все приглашения",
|
"txt_delete_all_invites": "Удалить все приглашения",
|
||||||
"txt_delete_item": "Удалить элемент",
|
"txt_delete_item": "Удалить элемент",
|
||||||
"txt_delete_passkey": "Удалить пароль",
|
"txt_delete_passkey": "Удалить ключ доступа",
|
||||||
"txt_delete_item_failed": "Удалить элемент не удалось",
|
"txt_delete_item_failed": "Удалить элемент не удалось",
|
||||||
"txt_permanent_delete_item_failed": "Не удалось окончательно удалить элемент",
|
"txt_permanent_delete_item_failed": "Не удалось окончательно удалить элемент",
|
||||||
"txt_delete_permanently": "Удалить навсегда",
|
"txt_delete_permanently": "Удалить навсегда",
|
||||||
@@ -447,6 +450,11 @@ const ru: Record<string, string> = {
|
|||||||
"txt_favorite": "Любимый",
|
"txt_favorite": "Любимый",
|
||||||
"txt_favorites": "Избранное",
|
"txt_favorites": "Избранное",
|
||||||
"txt_duplicates": "Дубликаты",
|
"txt_duplicates": "Дубликаты",
|
||||||
|
"txt_duplicate_detection_mode": "Сравнивать по",
|
||||||
|
"txt_duplicate_mode_exact": "Полное совпадение",
|
||||||
|
"txt_duplicate_mode_login_site": "Сайт + логин + пароль",
|
||||||
|
"txt_duplicate_mode_login_credentials": "Логин + пароль",
|
||||||
|
"txt_duplicate_mode_password": "Повтор пароля",
|
||||||
"txt_field": "Поле",
|
"txt_field": "Поле",
|
||||||
"txt_field_label": "Метка поля",
|
"txt_field_label": "Метка поля",
|
||||||
"txt_field_label_is_required": "Метка поля обязательна.",
|
"txt_field_label_is_required": "Метка поля обязательна.",
|
||||||
@@ -631,6 +639,49 @@ const ru: Record<string, string> = {
|
|||||||
"txt_passkey": "Ключ доступа",
|
"txt_passkey": "Ключ доступа",
|
||||||
"txt_passkeys": "Ключи доступа",
|
"txt_passkeys": "Ключи доступа",
|
||||||
"txt_passkey_created_at_value": "Создано {value}",
|
"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_phone": "Телефон",
|
||||||
"txt_please_input_email_and_password": "Пожалуйста, введите адрес электронной почты и пароль",
|
"txt_please_input_email_and_password": "Пожалуйста, введите адрес электронной почты и пароль",
|
||||||
"txt_please_input_master_password": "Пожалуйста, введите мастер-пароль",
|
"txt_please_input_master_password": "Пожалуйста, введите мастер-пароль",
|
||||||
@@ -707,6 +758,7 @@ const ru: Record<string, string> = {
|
|||||||
"txt_search_sends": "Поиск отправляет...",
|
"txt_search_sends": "Поиск отправляет...",
|
||||||
"txt_session_refresh_failed": "Не удалось обновить сеанс. Войдите снова.",
|
"txt_session_refresh_failed": "Не удалось обновить сеанс. Войдите снова.",
|
||||||
"txt_search_your_secure_vault": "Найдите свое безопасное хранилище...",
|
"txt_search_your_secure_vault": "Найдите свое безопасное хранилище...",
|
||||||
|
"txt_search_items_count": "Поиск по {count} элементам...",
|
||||||
"txt_clear_search": "Очистить поиск",
|
"txt_clear_search": "Очистить поиск",
|
||||||
"txt_clear_search_esc": "Очистить поиск (Esc)",
|
"txt_clear_search_esc": "Очистить поиск (Esc)",
|
||||||
"txt_sort": "Сортировать",
|
"txt_sort": "Сортировать",
|
||||||
@@ -735,6 +787,7 @@ const ru: Record<string, string> = {
|
|||||||
"txt_status": "Статус",
|
"txt_status": "Статус",
|
||||||
"txt_online": "Онлайн",
|
"txt_online": "Онлайн",
|
||||||
"txt_offline": "Офлайн",
|
"txt_offline": "Офлайн",
|
||||||
|
"txt_offline_vault_readonly": "Автономный режим доступен только для чтения. Подключитесь к NodeWarden, чтобы изменить хранилище.",
|
||||||
"txt_submit": "Отправить",
|
"txt_submit": "Отправить",
|
||||||
"txt_sync": "Синхронизировать",
|
"txt_sync": "Синхронизировать",
|
||||||
"txt_sync_vault": "Синхронизировать хранилище",
|
"txt_sync_vault": "Синхронизировать хранилище",
|
||||||
@@ -1128,7 +1181,22 @@ const ru: Record<string, string> = {
|
|||||||
"txt_target": "Цель",
|
"txt_target": "Цель",
|
||||||
"txt_time": "Время",
|
"txt_time": "Время",
|
||||||
"txt_time_range": "Период",
|
"txt_time_range": "Период",
|
||||||
"txt_remove_domain": "Удалить домен"
|
"txt_remove_domain": "Удалить домен",
|
||||||
|
"txt_approve_device_login": "Подтвердить вход с устройства",
|
||||||
|
"txt_auth_request_approve_message": "Разблокируйте Bitwarden на устройстве или подтвердите вход через веб-приложение. Перед подтверждением убедитесь, что фраза отпечатка совпадает с указанной ниже.",
|
||||||
|
"txt_fingerprint_phrase": "Фраза отпечатка",
|
||||||
|
"txt_ip_address": "IP-адрес",
|
||||||
|
"txt_approve": "Подтвердить",
|
||||||
|
"txt_approving": "Подтверждение...",
|
||||||
|
"txt_deny": "Отклонить",
|
||||||
|
"txt_later": "Позже",
|
||||||
|
"txt_pending_device_logins": "Ожидающие входы с устройств",
|
||||||
|
"txt_no_pending_device_logins": "Нет ожидающих входов с устройств",
|
||||||
|
"txt_auth_requests_load_failed": "Не удалось загрузить запросы входа с устройств",
|
||||||
|
"txt_auth_request_update_failed": "Не удалось обновить запрос входа с устройства",
|
||||||
|
"txt_auth_request_approved": "Вход с устройства подтвержден",
|
||||||
|
"txt_auth_request_denied": "Вход с устройства отклонен",
|
||||||
|
"txt_auth_request_missing_public_key": "В запросе входа с устройства отсутствует открытый ключ"
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ru;
|
export default ru;
|
||||||
|
|||||||
@@ -267,6 +267,9 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_backup_webdav_password": "WebDAV 密码",
|
"txt_backup_webdav_password": "WebDAV 密码",
|
||||||
"txt_backup_webdav_path": "远程目录",
|
"txt_backup_webdav_path": "远程目录",
|
||||||
"txt_backup_s3_endpoint": "S3 端点",
|
"txt_backup_s3_endpoint": "S3 端点",
|
||||||
|
"txt_backup_s3_addressing_style": "S3 寻址方式",
|
||||||
|
"txt_backup_s3_addressing_path_style": "path-style(默认)",
|
||||||
|
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
|
||||||
"txt_backup_s3_bucket": "存储桶",
|
"txt_backup_s3_bucket": "存储桶",
|
||||||
"txt_backup_s3_region": "区域",
|
"txt_backup_s3_region": "区域",
|
||||||
"txt_backup_s3_access_key": "访问密钥",
|
"txt_backup_s3_access_key": "访问密钥",
|
||||||
@@ -447,6 +450,11 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_favorite": "收藏",
|
"txt_favorite": "收藏",
|
||||||
"txt_favorites": "收藏",
|
"txt_favorites": "收藏",
|
||||||
"txt_duplicates": "重复项",
|
"txt_duplicates": "重复项",
|
||||||
|
"txt_duplicate_detection_mode": "匹配方式",
|
||||||
|
"txt_duplicate_mode_exact": "完全相同",
|
||||||
|
"txt_duplicate_mode_login_site": "网站+账号+密码",
|
||||||
|
"txt_duplicate_mode_login_credentials": "账号+密码",
|
||||||
|
"txt_duplicate_mode_password": "密码复用",
|
||||||
"txt_field": "字段",
|
"txt_field": "字段",
|
||||||
"txt_field_label": "字段标签",
|
"txt_field_label": "字段标签",
|
||||||
"txt_field_label_is_required": "字段标签不能为空",
|
"txt_field_label_is_required": "字段标签不能为空",
|
||||||
@@ -631,6 +639,49 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_passkey": "通行密钥",
|
"txt_passkey": "通行密钥",
|
||||||
"txt_passkeys": "通行密钥",
|
"txt_passkeys": "通行密钥",
|
||||||
"txt_passkey_created_at_value": "创建于 {value}",
|
"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_phone": "电话",
|
||||||
"txt_please_input_email_and_password": "请输入邮箱和密码",
|
"txt_please_input_email_and_password": "请输入邮箱和密码",
|
||||||
"txt_please_input_master_password": "请输入主密码",
|
"txt_please_input_master_password": "请输入主密码",
|
||||||
@@ -707,6 +758,7 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_search_sends": "搜索 Send...",
|
"txt_search_sends": "搜索 Send...",
|
||||||
"txt_session_refresh_failed": "会话刷新失败,请重新登录",
|
"txt_session_refresh_failed": "会话刷新失败,请重新登录",
|
||||||
"txt_search_your_secure_vault": "搜索你的密码库...",
|
"txt_search_your_secure_vault": "搜索你的密码库...",
|
||||||
|
"txt_search_items_count": "共 {count} 项中搜索...",
|
||||||
"txt_clear_search": "清空搜索",
|
"txt_clear_search": "清空搜索",
|
||||||
"txt_clear_search_esc": "清空搜索(Esc)",
|
"txt_clear_search_esc": "清空搜索(Esc)",
|
||||||
"txt_sort": "排序",
|
"txt_sort": "排序",
|
||||||
@@ -735,6 +787,7 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_status": "状态",
|
"txt_status": "状态",
|
||||||
"txt_online": "在线",
|
"txt_online": "在线",
|
||||||
"txt_offline": "离线",
|
"txt_offline": "离线",
|
||||||
|
"txt_offline_vault_readonly": "当前为离线模式,只能查看密码库。连接到 NodeWarden 后才能修改。",
|
||||||
"txt_submit": "提交",
|
"txt_submit": "提交",
|
||||||
"txt_sync": "同步",
|
"txt_sync": "同步",
|
||||||
"txt_sync_vault": "同步",
|
"txt_sync_vault": "同步",
|
||||||
@@ -1128,7 +1181,22 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_target": "目标",
|
"txt_target": "目标",
|
||||||
"txt_time": "时间",
|
"txt_time": "时间",
|
||||||
"txt_time_range": "时间范围",
|
"txt_time_range": "时间范围",
|
||||||
"txt_remove_domain": "移除域名"
|
"txt_remove_domain": "移除域名",
|
||||||
|
"txt_approve_device_login": "批准设备登录",
|
||||||
|
"txt_auth_request_approve_message": "解锁您设备上的 Bitwarden,或通过网页 App 批准。批准前,请确保指纹短语与下面的相匹配。",
|
||||||
|
"txt_approve": "批准",
|
||||||
|
"txt_approving": "正在批准...",
|
||||||
|
"txt_deny": "拒绝",
|
||||||
|
"txt_later": "稍后",
|
||||||
|
"txt_pending_device_logins": "待处理设备登录",
|
||||||
|
"txt_no_pending_device_logins": "没有待处理设备登录",
|
||||||
|
"txt_fingerprint_phrase": "指纹短语",
|
||||||
|
"txt_auth_requests_load_failed": "加载设备登录请求失败",
|
||||||
|
"txt_auth_request_update_failed": "更新设备登录请求失败",
|
||||||
|
"txt_auth_request_approved": "已批准设备登录",
|
||||||
|
"txt_auth_request_denied": "已拒绝设备登录",
|
||||||
|
"txt_auth_request_missing_public_key": "设备登录请求缺少公钥",
|
||||||
|
"txt_ip_address": "IP 地址"
|
||||||
};
|
};
|
||||||
|
|
||||||
export default zhCN;
|
export default zhCN;
|
||||||
|
|||||||
@@ -267,6 +267,9 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_backup_webdav_password": "WebDAV 密碼",
|
"txt_backup_webdav_password": "WebDAV 密碼",
|
||||||
"txt_backup_webdav_path": "遠程目錄",
|
"txt_backup_webdav_path": "遠程目錄",
|
||||||
"txt_backup_s3_endpoint": "S3 端點",
|
"txt_backup_s3_endpoint": "S3 端點",
|
||||||
|
"txt_backup_s3_addressing_style": "S3 定址方式",
|
||||||
|
"txt_backup_s3_addressing_path_style": "path-style(預設)",
|
||||||
|
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
|
||||||
"txt_backup_s3_bucket": "儲存桶",
|
"txt_backup_s3_bucket": "儲存桶",
|
||||||
"txt_backup_s3_region": "區域",
|
"txt_backup_s3_region": "區域",
|
||||||
"txt_backup_s3_access_key": "存取金鑰",
|
"txt_backup_s3_access_key": "存取金鑰",
|
||||||
@@ -447,6 +450,11 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_favorite": "收藏",
|
"txt_favorite": "收藏",
|
||||||
"txt_favorites": "收藏",
|
"txt_favorites": "收藏",
|
||||||
"txt_duplicates": "重複項",
|
"txt_duplicates": "重複項",
|
||||||
|
"txt_duplicate_detection_mode": "匹配方式",
|
||||||
|
"txt_duplicate_mode_exact": "完全相同",
|
||||||
|
"txt_duplicate_mode_login_site": "網站+帳號+密碼",
|
||||||
|
"txt_duplicate_mode_login_credentials": "帳號+密碼",
|
||||||
|
"txt_duplicate_mode_password": "密碼重複使用",
|
||||||
"txt_field": "字段",
|
"txt_field": "字段",
|
||||||
"txt_field_label": "字段標籤",
|
"txt_field_label": "字段標籤",
|
||||||
"txt_field_label_is_required": "字段標籤不能為空",
|
"txt_field_label_is_required": "字段標籤不能為空",
|
||||||
@@ -631,6 +639,49 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_passkey": "通行密鑰",
|
"txt_passkey": "通行密鑰",
|
||||||
"txt_passkeys": "通行密鑰",
|
"txt_passkeys": "通行密鑰",
|
||||||
"txt_passkey_created_at_value": "創建於 {value}",
|
"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_phone": "電話",
|
||||||
"txt_please_input_email_and_password": "請輸入郵箱和密碼",
|
"txt_please_input_email_and_password": "請輸入郵箱和密碼",
|
||||||
"txt_please_input_master_password": "請輸入主密碼",
|
"txt_please_input_master_password": "請輸入主密碼",
|
||||||
@@ -707,6 +758,7 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_search_sends": "搜索 Send...",
|
"txt_search_sends": "搜索 Send...",
|
||||||
"txt_session_refresh_failed": "會話刷新失敗,請重新登入",
|
"txt_session_refresh_failed": "會話刷新失敗,請重新登入",
|
||||||
"txt_search_your_secure_vault": "搜索你的密碼庫...",
|
"txt_search_your_secure_vault": "搜索你的密碼庫...",
|
||||||
|
"txt_search_items_count": "在共 {count} 項中搜索...",
|
||||||
"txt_clear_search": "清空搜索",
|
"txt_clear_search": "清空搜索",
|
||||||
"txt_clear_search_esc": "清空搜索(Esc)",
|
"txt_clear_search_esc": "清空搜索(Esc)",
|
||||||
"txt_sort": "排序",
|
"txt_sort": "排序",
|
||||||
@@ -735,6 +787,7 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_status": "狀態",
|
"txt_status": "狀態",
|
||||||
"txt_online": "在線",
|
"txt_online": "在線",
|
||||||
"txt_offline": "離線",
|
"txt_offline": "離線",
|
||||||
|
"txt_offline_vault_readonly": "目前為離線模式,只能查看密碼庫。連線到 NodeWarden 後才能修改。",
|
||||||
"txt_submit": "提交",
|
"txt_submit": "提交",
|
||||||
"txt_sync": "同步",
|
"txt_sync": "同步",
|
||||||
"txt_sync_vault": "同步",
|
"txt_sync_vault": "同步",
|
||||||
@@ -1128,7 +1181,22 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_target": "目標",
|
"txt_target": "目標",
|
||||||
"txt_time": "時間",
|
"txt_time": "時間",
|
||||||
"txt_time_range": "時間範圍",
|
"txt_time_range": "時間範圍",
|
||||||
"txt_remove_domain": "移除域名"
|
"txt_remove_domain": "移除域名",
|
||||||
|
"txt_approve_device_login": "批准裝置登入",
|
||||||
|
"txt_auth_request_approve_message": "解鎖您裝置上的 Bitwarden,或透過網頁 App 批准。批准前,請確保指紋短語與下面的相符。",
|
||||||
|
"txt_fingerprint_phrase": "指紋短語",
|
||||||
|
"txt_ip_address": "IP 位址",
|
||||||
|
"txt_approve": "批准",
|
||||||
|
"txt_approving": "正在批准...",
|
||||||
|
"txt_deny": "拒絕",
|
||||||
|
"txt_later": "稍後",
|
||||||
|
"txt_pending_device_logins": "待處理裝置登入",
|
||||||
|
"txt_no_pending_device_logins": "沒有待處理裝置登入",
|
||||||
|
"txt_auth_requests_load_failed": "載入裝置登入請求失敗",
|
||||||
|
"txt_auth_request_update_failed": "更新裝置登入請求失敗",
|
||||||
|
"txt_auth_request_approved": "已批准裝置登入",
|
||||||
|
"txt_auth_request_denied": "已拒絕裝置登入",
|
||||||
|
"txt_auth_request_missing_public_key": "裝置登入請求缺少公鑰"
|
||||||
};
|
};
|
||||||
|
|
||||||
export default zhTW;
|
export default zhTW;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const IMPORT_SOURCES = [
|
|||||||
{ id: 'lastpass', label: 'LastPass (csv)' },
|
{ id: 'lastpass', label: 'LastPass (csv)' },
|
||||||
{ id: 'dashlane_csv', label: 'Dashlane (csv)' },
|
{ id: 'dashlane_csv', label: 'Dashlane (csv)' },
|
||||||
{ id: 'dashlane_json', label: 'Dashlane (json)' },
|
{ id: 'dashlane_json', label: 'Dashlane (json)' },
|
||||||
|
{ id: 'keepass_csv', label: 'KeePass 1.x (csv)' },
|
||||||
{ id: 'keepass_xml', label: 'KeePass 2 (xml)' },
|
{ id: 'keepass_xml', label: 'KeePass 2 (xml)' },
|
||||||
{ id: 'keepassx_csv', label: 'KeePassX (csv)' },
|
{ id: 'keepassx_csv', label: 'KeePassX (csv)' },
|
||||||
{ id: 'arc_csv', label: 'Arc (csv)' },
|
{ id: 'arc_csv', label: 'Arc (csv)' },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface BitwardenFolderInput {
|
|||||||
|
|
||||||
export interface BitwardenUriInput {
|
export interface BitwardenUriInput {
|
||||||
uri?: string | null;
|
uri?: string | null;
|
||||||
|
uriChecksum?: string | null;
|
||||||
match?: number | null;
|
match?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ export function parseEncryptrCsv(textRaw: string): CiphersImportPayload {
|
|||||||
export function parseKeePassXCsv(textRaw: string): CiphersImportPayload {
|
export function parseKeePassXCsv(textRaw: string): CiphersImportPayload {
|
||||||
const rows = parseCsv(textRaw);
|
const rows = parseCsv(textRaw);
|
||||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||||
|
const standardColumns = new Set(['Group', 'Title', 'Username', 'Password', 'URL', 'Notes', 'TOTP']);
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!txt(row.Title)) continue;
|
if (!txt(row.Title)) continue;
|
||||||
const cipher = makeLoginCipher();
|
const cipher = makeLoginCipher();
|
||||||
@@ -209,12 +210,34 @@ export function parseKeePassXCsv(textRaw: string): CiphersImportPayload {
|
|||||||
login.totp = val(row.TOTP);
|
login.totp = val(row.TOTP);
|
||||||
const uri = normalizeUri(row.URL || '');
|
const uri = normalizeUri(row.URL || '');
|
||||||
login.uris = uri ? [{ uri, match: null }] : null;
|
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;
|
const idx = result.ciphers.push(cipher) - 1;
|
||||||
addFolder(result, txt(row.Group).replace(/^Root\//, ''), idx);
|
addFolder(result, txt(row.Group).replace(/^Root\//, ''), idx);
|
||||||
}
|
}
|
||||||
return result;
|
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 {
|
export function parseLastPassCsv(textRaw: string): CiphersImportPayload {
|
||||||
const rows = parseCsv(textRaw);
|
const rows = parseCsv(textRaw);
|
||||||
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
|
||||||
@@ -350,7 +373,8 @@ export function parseKeePassXml(textRaw: string): CiphersImportPayload {
|
|||||||
const cipher = makeLoginCipher();
|
const cipher = makeLoginCipher();
|
||||||
for (const s of qd(entry, 'String')) {
|
for (const s of qd(entry, 'String')) {
|
||||||
const key = txt(qd(s, 'Key')[0]?.textContent);
|
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;
|
if (!value) continue;
|
||||||
const login = cipher.login as Record<string, unknown>;
|
const login = cipher.login as Record<string, unknown>;
|
||||||
if (key === 'Title') cipher.name = value;
|
if (key === 'Title') cipher.name = value;
|
||||||
@@ -361,6 +385,11 @@ export function parseKeePassXml(textRaw: string): CiphersImportPayload {
|
|||||||
login.uris = uri ? [{ uri, match: null }] : null;
|
login.uris = uri ? [{ uri, match: null }] : null;
|
||||||
} else if (key === 'otp') login.totp = value.replace('key=', '');
|
} else if (key === 'otp') login.totp = value.replace('key=', '');
|
||||||
else if (key === 'Notes') cipher.notes = `${txt(cipher.notes)}${txt(cipher.notes) ? '\n' : ''}${value}`;
|
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;
|
const idx = result.ciphers.push(cipher) - 1;
|
||||||
if (!isRoot && folder >= 0) result.folderRelationships.push({ key: idx, value: folder });
|
if (!isRoot && folder >= 0) result.folderRelationships.push({ key: idx, value: folder });
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
parseDashlaneCsv,
|
parseDashlaneCsv,
|
||||||
parseDashlaneJson,
|
parseDashlaneJson,
|
||||||
parseEncryptrCsv,
|
parseEncryptrCsv,
|
||||||
|
parseKeePassCsv,
|
||||||
parseKeePassXCsv,
|
parseKeePassXCsv,
|
||||||
parseKeePassXml,
|
parseKeePassXml,
|
||||||
parseLastPassCsv,
|
parseLastPassCsv,
|
||||||
@@ -75,6 +76,7 @@ const IMPORT_SOURCE_PARSERS: Record<ImportSourceId, (textRaw: string) => Ciphers
|
|||||||
lastpass: parseLastPassCsv,
|
lastpass: parseLastPassCsv,
|
||||||
dashlane_csv: parseDashlaneCsv,
|
dashlane_csv: parseDashlaneCsv,
|
||||||
dashlane_json: parseDashlaneJson,
|
dashlane_json: parseDashlaneJson,
|
||||||
|
keepass_csv: parseKeePassCsv,
|
||||||
keepass_xml: parseKeePassXml,
|
keepass_xml: parseKeePassXml,
|
||||||
keepassx_csv: parseKeePassXCsv,
|
keepassx_csv: parseKeePassXCsv,
|
||||||
arc_csv: parseArcCsv,
|
arc_csv: parseArcCsv,
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
export type NetworkStatus = 'online' | 'offline';
|
||||||
|
|
||||||
|
const STATUS_PROBE_TIMEOUT_MS = 8000;
|
||||||
|
const STATUS_PROBE_CACHE_MS = 5000;
|
||||||
|
const PROBE_FAILURES_BEFORE_OFFLINE = 2;
|
||||||
|
const listeners = new Set<(status: NetworkStatus) => void>();
|
||||||
|
let currentStatus: NetworkStatus = getInitialNetworkStatus();
|
||||||
|
let pendingProbe: Promise<boolean> | null = null;
|
||||||
|
let lastProbeAt = 0;
|
||||||
|
let lastProbeResult = currentStatus === 'online';
|
||||||
|
let consecutiveProbeFailures = 0;
|
||||||
|
|
||||||
|
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 function recordNodeWardenReachable(): void {
|
||||||
|
consecutiveProbeFailures = 0;
|
||||||
|
lastProbeResult = true;
|
||||||
|
setCurrentNetworkStatus('online');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordNodeWardenUnreachable(): void {
|
||||||
|
lastProbeResult = false;
|
||||||
|
consecutiveProbeFailures += 1;
|
||||||
|
if (browserReportsOffline() || consecutiveProbeFailures >= PROBE_FAILURES_BEFORE_OFFLINE) {
|
||||||
|
setCurrentNetworkStatus('offline');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeNodeWardenService(): Promise<boolean> {
|
||||||
|
if (browserReportsOffline()) {
|
||||||
|
consecutiveProbeFailures = PROBE_FAILURES_BEFORE_OFFLINE;
|
||||||
|
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();
|
||||||
|
if (result) {
|
||||||
|
recordNodeWardenReachable();
|
||||||
|
} else {
|
||||||
|
recordNodeWardenUnreachable();
|
||||||
|
}
|
||||||
|
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,54 @@ export interface TokenError {
|
|||||||
TwoFactorProviders?: unknown;
|
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 AuthRequest {
|
||||||
|
id: string;
|
||||||
|
publicKey: string;
|
||||||
|
requestDeviceType?: string | null;
|
||||||
|
requestDeviceTypeValue?: number | null;
|
||||||
|
requestDeviceIdentifier: string;
|
||||||
|
requestIpAddress?: string | null;
|
||||||
|
requestCountryName?: string | null;
|
||||||
|
key?: string | null;
|
||||||
|
creationDate: string;
|
||||||
|
requestApproved?: boolean | null;
|
||||||
|
responseDate?: string | null;
|
||||||
|
deviceId?: string | null;
|
||||||
|
requestDeviceId?: string | null;
|
||||||
|
fingerprintPhrase?: 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 {
|
export interface ToastMessage {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'success' | 'error' | 'warning';
|
type: 'success' | 'error' | 'warning';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { base64ToBytes, decryptBw, decryptStr } from './crypto';
|
import { base64ToBytes, decryptBw, decryptStr } from './crypto';
|
||||||
import { deriveSendKeyParts } from './app-support';
|
import { deriveSendKeyParts, looksLikeCipherString } from './app-support';
|
||||||
import type { Cipher, Folder, Send } from './types';
|
import type { Cipher, Folder, Send } from './types';
|
||||||
|
|
||||||
export interface DecryptVaultCoreArgs {
|
export interface DecryptVaultCoreArgs {
|
||||||
@@ -38,10 +38,34 @@ async function decryptField(
|
|||||||
try {
|
try {
|
||||||
return await decryptStr(value, enc, mac);
|
return await decryptStr(value, enc, mac);
|
||||||
} catch {
|
} catch {
|
||||||
return value;
|
return looksLikeCipherString(value) ? '' : value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function decryptCipherField(
|
||||||
|
value: string | null | undefined,
|
||||||
|
itemEnc: Uint8Array,
|
||||||
|
itemMac: Uint8Array,
|
||||||
|
userEnc: Uint8Array,
|
||||||
|
userMac: Uint8Array,
|
||||||
|
canFallbackToUserKey: boolean
|
||||||
|
): Promise<string> {
|
||||||
|
if (!value || typeof value !== 'string') return '';
|
||||||
|
try {
|
||||||
|
return await decryptStr(value, itemEnc, itemMac);
|
||||||
|
} catch {
|
||||||
|
// Try the legacy user-key path for mixed key/field ciphers.
|
||||||
|
}
|
||||||
|
if (canFallbackToUserKey) {
|
||||||
|
try {
|
||||||
|
return await decryptStr(value, userEnc, userMac);
|
||||||
|
} catch {
|
||||||
|
// Preserve the old raw fallback for fields that are genuinely unreadable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return looksLikeCipherString(value) ? '' : value;
|
||||||
|
}
|
||||||
|
|
||||||
async function decryptFieldWithSource(
|
async function decryptFieldWithSource(
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
itemEnc: Uint8Array,
|
itemEnc: Uint8Array,
|
||||||
@@ -64,7 +88,7 @@ async function decryptFieldWithSource(
|
|||||||
// Keep plain fallback.
|
// Keep plain fallback.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { text: raw, source: 'plain' };
|
return { text: looksLikeCipherString(raw) ? '' : raw, source: 'plain' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<DecryptVaultCoreResult> {
|
export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<DecryptVaultCoreResult> {
|
||||||
@@ -82,32 +106,38 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<Decr
|
|||||||
args.ciphers.map(async (cipher) => {
|
args.ciphers.map(async (cipher) => {
|
||||||
let itemEnc = userEnc;
|
let itemEnc = userEnc;
|
||||||
let itemMac = userMac;
|
let itemMac = userMac;
|
||||||
|
let usesItemKey = false;
|
||||||
if (cipher.key) {
|
if (cipher.key) {
|
||||||
try {
|
try {
|
||||||
const itemKey = await decryptBw(cipher.key, userEnc, userMac);
|
const itemKey = await decryptBw(cipher.key, userEnc, userMac);
|
||||||
itemEnc = itemKey.slice(0, 32);
|
if (itemKey.length >= 64) {
|
||||||
itemMac = itemKey.slice(32, 64);
|
itemEnc = itemKey.slice(0, 32);
|
||||||
|
itemMac = itemKey.slice(32, 64);
|
||||||
|
usesItemKey = true;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Keep user key fallback.
|
// Keep user key fallback.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemUsesUserKey = sameBytes(itemEnc, userEnc) && sameBytes(itemMac, userMac);
|
const itemUsesUserKey = sameBytes(itemEnc, userEnc) && sameBytes(itemMac, userMac);
|
||||||
|
const canFallbackToUserKey = usesItemKey;
|
||||||
const nextCipher: Cipher = {
|
const nextCipher: Cipher = {
|
||||||
...cipher,
|
...cipher,
|
||||||
decName: await decryptField(cipher.name || '', itemEnc, itemMac),
|
decName: await decryptCipherField(cipher.name || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decNotes: await decryptField(cipher.notes || '', itemEnc, itemMac),
|
decNotes: await decryptCipherField(cipher.notes || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (cipher.login) {
|
if (cipher.login) {
|
||||||
nextCipher.login = {
|
nextCipher.login = {
|
||||||
...cipher.login,
|
...cipher.login,
|
||||||
decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac),
|
decUsername: await decryptCipherField(cipher.login.username || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac),
|
decPassword: await decryptCipherField(cipher.login.password || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac),
|
decTotp: await decryptCipherField(cipher.login.totp || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
uris: await Promise.all(
|
uris: await Promise.all(
|
||||||
(cipher.login.uris || []).map(async (uri) => ({
|
(cipher.login.uris || []).map(async (uri) => ({
|
||||||
...uri,
|
...uri,
|
||||||
decUri: await decryptField(uri.uri || '', itemEnc, itemMac),
|
decUri: await decryptCipherField(uri.uri || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
}))
|
}))
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -117,7 +147,7 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<Decr
|
|||||||
nextCipher.passwordHistory = await Promise.all(
|
nextCipher.passwordHistory = await Promise.all(
|
||||||
cipher.passwordHistory.map(async (entry) => ({
|
cipher.passwordHistory.map(async (entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
decPassword: await decryptField(entry?.password || '', itemEnc, itemMac),
|
decPassword: await decryptCipherField(entry?.password || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -125,36 +155,36 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<Decr
|
|||||||
if (cipher.card) {
|
if (cipher.card) {
|
||||||
nextCipher.card = {
|
nextCipher.card = {
|
||||||
...cipher.card,
|
...cipher.card,
|
||||||
decCardholderName: await decryptField(cipher.card.cardholderName || '', itemEnc, itemMac),
|
decCardholderName: await decryptCipherField(cipher.card.cardholderName || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decNumber: await decryptField(cipher.card.number || '', itemEnc, itemMac),
|
decNumber: await decryptCipherField(cipher.card.number || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decBrand: await decryptField(cipher.card.brand || '', itemEnc, itemMac),
|
decBrand: await decryptCipherField(cipher.card.brand || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decExpMonth: await decryptField(cipher.card.expMonth || '', itemEnc, itemMac),
|
decExpMonth: await decryptCipherField(cipher.card.expMonth || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decExpYear: await decryptField(cipher.card.expYear || '', itemEnc, itemMac),
|
decExpYear: await decryptCipherField(cipher.card.expYear || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decCode: await decryptField(cipher.card.code || '', itemEnc, itemMac),
|
decCode: await decryptCipherField(cipher.card.code || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cipher.identity) {
|
if (cipher.identity) {
|
||||||
nextCipher.identity = {
|
nextCipher.identity = {
|
||||||
...cipher.identity,
|
...cipher.identity,
|
||||||
decTitle: await decryptField(cipher.identity.title || '', itemEnc, itemMac),
|
decTitle: await decryptCipherField(cipher.identity.title || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decFirstName: await decryptField(cipher.identity.firstName || '', itemEnc, itemMac),
|
decFirstName: await decryptCipherField(cipher.identity.firstName || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decMiddleName: await decryptField(cipher.identity.middleName || '', itemEnc, itemMac),
|
decMiddleName: await decryptCipherField(cipher.identity.middleName || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decLastName: await decryptField(cipher.identity.lastName || '', itemEnc, itemMac),
|
decLastName: await decryptCipherField(cipher.identity.lastName || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decUsername: await decryptField(cipher.identity.username || '', itemEnc, itemMac),
|
decUsername: await decryptCipherField(cipher.identity.username || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decCompany: await decryptField(cipher.identity.company || '', itemEnc, itemMac),
|
decCompany: await decryptCipherField(cipher.identity.company || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decSsn: await decryptField(cipher.identity.ssn || '', itemEnc, itemMac),
|
decSsn: await decryptCipherField(cipher.identity.ssn || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decPassportNumber: await decryptField(cipher.identity.passportNumber || '', itemEnc, itemMac),
|
decPassportNumber: await decryptCipherField(cipher.identity.passportNumber || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decLicenseNumber: await decryptField(cipher.identity.licenseNumber || '', itemEnc, itemMac),
|
decLicenseNumber: await decryptCipherField(cipher.identity.licenseNumber || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decEmail: await decryptField(cipher.identity.email || '', itemEnc, itemMac),
|
decEmail: await decryptCipherField(cipher.identity.email || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decPhone: await decryptField(cipher.identity.phone || '', itemEnc, itemMac),
|
decPhone: await decryptCipherField(cipher.identity.phone || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decAddress1: await decryptField(cipher.identity.address1 || '', itemEnc, itemMac),
|
decAddress1: await decryptCipherField(cipher.identity.address1 || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decAddress2: await decryptField(cipher.identity.address2 || '', itemEnc, itemMac),
|
decAddress2: await decryptCipherField(cipher.identity.address2 || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decAddress3: await decryptField(cipher.identity.address3 || '', itemEnc, itemMac),
|
decAddress3: await decryptCipherField(cipher.identity.address3 || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decCity: await decryptField(cipher.identity.city || '', itemEnc, itemMac),
|
decCity: await decryptCipherField(cipher.identity.city || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decState: await decryptField(cipher.identity.state || '', itemEnc, itemMac),
|
decState: await decryptCipherField(cipher.identity.state || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decPostalCode: await decryptField(cipher.identity.postalCode || '', itemEnc, itemMac),
|
decPostalCode: await decryptCipherField(cipher.identity.postalCode || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decCountry: await decryptField(cipher.identity.country || '', itemEnc, itemMac),
|
decCountry: await decryptCipherField(cipher.identity.country || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,11 +192,11 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<Decr
|
|||||||
const encryptedFingerprint = cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint || '';
|
const encryptedFingerprint = cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint || '';
|
||||||
nextCipher.sshKey = {
|
nextCipher.sshKey = {
|
||||||
...cipher.sshKey,
|
...cipher.sshKey,
|
||||||
decPrivateKey: await decryptField(cipher.sshKey.privateKey || '', itemEnc, itemMac),
|
decPrivateKey: await decryptCipherField(cipher.sshKey.privateKey || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decPublicKey: await decryptField(cipher.sshKey.publicKey || '', itemEnc, itemMac),
|
decPublicKey: await decryptCipherField(cipher.sshKey.publicKey || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
keyFingerprint: encryptedFingerprint || null,
|
keyFingerprint: encryptedFingerprint || null,
|
||||||
fingerprint: encryptedFingerprint || null,
|
fingerprint: encryptedFingerprint || null,
|
||||||
decFingerprint: await decryptField(encryptedFingerprint, itemEnc, itemMac),
|
decFingerprint: await decryptCipherField(encryptedFingerprint, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,8 +204,8 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<Decr
|
|||||||
nextCipher.fields = await Promise.all(
|
nextCipher.fields = await Promise.all(
|
||||||
cipher.fields.map(async (field) => ({
|
cipher.fields.map(async (field) => ({
|
||||||
...field,
|
...field,
|
||||||
decName: await decryptField(field.name || '', itemEnc, itemMac),
|
decName: await decryptCipherField(field.name || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
decValue: await decryptField(field.value || '', itemEnc, itemMac),
|
decValue: await decryptCipherField(field.value || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { render } from 'preact';
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { initI18n } from './lib/i18n';
|
import { initI18n } from './lib/i18n';
|
||||||
|
import { registerNodeWardenServiceWorker } from './lib/pwa';
|
||||||
import './tailwind.css';
|
import './tailwind.css';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ const queryClient = new QueryClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const root = document.getElementById('root')!;
|
const root = document.getElementById('root')!;
|
||||||
|
root.setAttribute('translate', 'no');
|
||||||
|
|
||||||
function renderApp(): void {
|
function renderApp(): void {
|
||||||
render(
|
render(
|
||||||
@@ -26,8 +28,7 @@ function renderApp(): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderApp();
|
void initI18n().finally(() => {
|
||||||
|
|
||||||
void initI18n().then(() => {
|
|
||||||
renderApp();
|
renderApp();
|
||||||
|
registerNodeWardenServiceWorker();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
@import './styles/management.css';
|
@import './styles/management.css';
|
||||||
@import './styles/overlays.css';
|
@import './styles/overlays.css';
|
||||||
@import './styles/motion.css';
|
@import './styles/motion.css';
|
||||||
|
@import './styles/skeleton.css';
|
||||||
@import './styles/responsive.css';
|
@import './styles/responsive.css';
|
||||||
@import './styles/dark.css';
|
@import './styles/dark.css';
|
||||||
|
|
||||||
@@ -595,7 +596,7 @@ h4 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-head {
|
.list-head {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-count {
|
.list-count {
|
||||||
@@ -809,6 +810,101 @@ h4 {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Typography refinement: stronger scan targets for dense vault/admin surfaces. */
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--font-base);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-link,
|
||||||
|
.side-group-trigger,
|
||||||
|
.side-sub-link,
|
||||||
|
.tree-btn,
|
||||||
|
.mobile-settings-link,
|
||||||
|
.backup-destination-item,
|
||||||
|
.backup-browser-entry,
|
||||||
|
.sort-menu-item,
|
||||||
|
.create-menu-item,
|
||||||
|
.nav-layout-option {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-link.active,
|
||||||
|
.side-group-trigger.active,
|
||||||
|
.side-sub-link.active,
|
||||||
|
.tree-btn.active,
|
||||||
|
.mobile-tab.active,
|
||||||
|
.mobile-settings-link.active,
|
||||||
|
.nav-layout-option.active {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title,
|
||||||
|
.list-count,
|
||||||
|
.field > span,
|
||||||
|
.table th,
|
||||||
|
.dialog-warning-kicker,
|
||||||
|
.backup-recommendation-group-title {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: var(--font-base);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-sub,
|
||||||
|
.detail-sub,
|
||||||
|
.backup-destination-meta,
|
||||||
|
.totp-code-username,
|
||||||
|
.field-help,
|
||||||
|
.settings-field-note {
|
||||||
|
font-size: var(--font-sm);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn,
|
||||||
|
.input,
|
||||||
|
.search-input,
|
||||||
|
.user-chip,
|
||||||
|
.network-status-badge {
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-danger,
|
||||||
|
.btn.full,
|
||||||
|
.topbar-actions .btn,
|
||||||
|
.network-status-badge {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h4,
|
||||||
|
.settings-module h3,
|
||||||
|
.section-head h3,
|
||||||
|
.section-head h4,
|
||||||
|
.detail-title,
|
||||||
|
.totp-code-name,
|
||||||
|
.backup-destination-name,
|
||||||
|
.mobile-sidebar-title {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.toast-item,
|
.toast-item,
|
||||||
.dialog-card {
|
.dialog-card {
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@@ -973,6 +1069,7 @@ h4 {
|
|||||||
.list-panel {
|
.list-panel {
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
margin-top: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-item {
|
.list-item {
|
||||||
|
|||||||