mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
Compare commits
35 Commits
04ebfc7021
...
v1.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3035a77579 | |||
| 28333f0e9b | |||
| 91320a4eba | |||
| 19b96a7aca | |||
| 18e0396c0a | |||
| 18d3490c4f | |||
| 615caf5946 | |||
| 1a10df4a18 | |||
| d4749d3f82 | |||
| 5ed7c949c1 | |||
| af70cab766 | |||
| bfea5d0a1c | |||
| cda654e1c3 | |||
| 1ee7b0f31b | |||
| 2d2cbea530 | |||
| 4f5d992f10 | |||
| 667afa305b | |||
| 85bd2fa4bf | |||
| fd9707c396 | |||
| 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,10 @@ 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/
|
||||||
|
|||||||
@@ -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 名复用已有附件,不会每次全量重传
|
||||||
|
|||||||
+30
-10
@@ -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`
|
||||||
|
|
||||||
@@ -198,6 +198,44 @@ CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
|
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,
|
||||||
|
|||||||
Generated
+429
-127
File diff suppressed because it is too large
Load Diff
+6
-5
@@ -1,22 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.5.2",
|
"version": "1.6.0",
|
||||||
"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.0';
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -731,7 +731,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) {
|
||||||
|
|||||||
+379
-235
@@ -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));
|
||||||
|
|||||||
+132
-23
@@ -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,6 +141,14 @@ function optionalEncString(value: unknown): string | null {
|
|||||||
return isValidEncString(value) ? value.trim() : null;
|
return isValidEncString(value) ? value.trim() : 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[]
|
||||||
@@ -161,20 +181,68 @@ 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', 'password', 'totp', 'uri']);
|
||||||
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 normalizeCipherLoginUrisForCompatibility(
|
||||||
|
uris: any,
|
||||||
|
options: { requiresUriChecksum?: boolean; preserveRepairableUris?: boolean } = {}
|
||||||
|
): any[] | null {
|
||||||
|
if (!Array.isArray(uris) || uris.length === 0) return null;
|
||||||
|
const out: any[] = [];
|
||||||
|
|
||||||
|
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 && hasChecksum) {
|
||||||
|
out.push(next);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUri && !hasChecksum) {
|
||||||
|
if (options.preserveRepairableUris) {
|
||||||
|
// Preserve the encrypted URI so NodeWarden Web can decrypt it and repair
|
||||||
|
// the missing checksum. Dropping it here makes the URI appear lost and
|
||||||
|
// can turn a display-only compatibility issue into data loss on save.
|
||||||
|
out.push({ ...next, uriChecksum: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Bitwarden browser clients using the SDK drop item-key encrypted URIs
|
||||||
|
// whose checksum is missing/invalid. User-key encrypted legacy/import
|
||||||
|
// entries bypass this validation and can safely keep the URI.
|
||||||
|
if (options.requiresUriChecksum) continue;
|
||||||
|
out.push({ ...next, uriChecksum: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChecksum || hasMatch) {
|
||||||
|
out.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.length ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
function hasMissingLoginUriChecksum(cipher: Cipher): boolean {
|
function hasMissingLoginUriChecksum(cipher: Cipher): boolean {
|
||||||
if (!cipher.key || !cipher.login || typeof cipher.login !== 'object') return false;
|
if (!cipher.key || !cipher.login || typeof cipher.login !== 'object') return false;
|
||||||
const uris = (cipher.login as any).uris;
|
const uris = (cipher.login as any).uris;
|
||||||
@@ -255,6 +323,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,11 +578,17 @@ 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 normalizedLogin = normalizeCipherLoginForCompatibility(
|
||||||
|
(passthrough as any).login ?? null,
|
||||||
|
!!responseCipherKey,
|
||||||
|
!!options.preserveRepairableUris
|
||||||
|
);
|
||||||
const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']);
|
const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']);
|
||||||
const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [
|
const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [
|
||||||
'title',
|
'title',
|
||||||
@@ -529,6 +611,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 +642,11 @@ 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,
|
||||||
encryptedFor: (passthrough as any).encryptedFor ?? null,
|
encryptedFor: (passthrough as any).encryptedFor ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -596,10 +682,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 +706,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 +741,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 +762,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);
|
||||||
@@ -694,9 +786,10 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
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,18 +823,30 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!shouldPreserveRepairableCipherUris(request) && incomingLogin.present && hasMissingLoginUriChecksum(existingCipher)) {
|
||||||
|
return errorResponse('This item has login URIs that must be repaired in NodeWarden Web before updating from this client. Open NodeWarden Web once, then resync.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
const nextType = Number(cipherData.type) || existingCipher.type;
|
const nextType = Number(cipherData.type) || existingCipher.type;
|
||||||
|
|
||||||
// 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 +854,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 +862,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;
|
||||||
@@ -795,9 +903,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
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 +933,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
|||||||
});
|
});
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
cipherToResponse(cipher, [])
|
cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,7 +1008,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 +1047,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 +1091,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 +1124,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 +1146,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))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+126
-2
@@ -15,6 +15,10 @@ 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';
|
||||||
|
|
||||||
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;
|
||||||
@@ -227,7 +231,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
const twoFactorToken = body.twoFactorToken;
|
const twoFactorToken = body.twoFactorToken;
|
||||||
const twoFactorProvider = body.twoFactorProvider;
|
const twoFactorProvider = body.twoFactorProvider;
|
||||||
const twoFactorRemember = body.twoFactorRemember;
|
const twoFactorRemember = body.twoFactorRemember;
|
||||||
const loginIdentifier = `${clientIdentifier}:${email}`;
|
const loginIdentifier = clientIdentifier;
|
||||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||||
|
|
||||||
if (!email || !passwordHash) {
|
if (!email || !passwordHash) {
|
||||||
@@ -423,6 +427,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 +554,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);
|
||||||
|
|||||||
+33
-7
@@ -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 };
|
||||||
|
|||||||
@@ -66,6 +66,14 @@ 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';
|
||||||
|
|
||||||
export async function handleAuthenticatedRoute(
|
export async function handleAuthenticatedRoute(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -131,6 +139,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);
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-93
@@ -9,6 +9,7 @@ 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,
|
||||||
@@ -77,82 +78,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 +116,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 +145,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 +181,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 +269,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 +335,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);
|
||||||
}
|
}
|
||||||
@@ -452,6 +423,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ const ALLOWED_METADATA_KEYS = new Set([
|
|||||||
'skippedReason',
|
'skippedReason',
|
||||||
'replaceExisting',
|
'replaceExisting',
|
||||||
'provider',
|
'provider',
|
||||||
|
'prfStatus',
|
||||||
'fileName',
|
'fileName',
|
||||||
'fileBytes',
|
'fileBytes',
|
||||||
'bytes',
|
'bytes',
|
||||||
|
|||||||
+23
-22
@@ -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,7 @@ export interface BackupPayload {
|
|||||||
folders: SqlRow[];
|
folders: SqlRow[];
|
||||||
ciphers: SqlRow[];
|
ciphers: SqlRow[];
|
||||||
attachments: SqlRow[];
|
attachments: SqlRow[];
|
||||||
|
webauthn_credentials?: SqlRow[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +301,7 @@ 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 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 +374,22 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildBackupArchive(
|
export async function buildBackupArchive(
|
||||||
@@ -390,7 +408,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] = 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 +416,7 @@ export async function buildBackupArchive(
|
|||||||
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, user_id, 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'),
|
||||||
]);
|
]);
|
||||||
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
||||||
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
||||||
@@ -425,6 +444,7 @@ 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,
|
||||||
},
|
},
|
||||||
includes: {
|
includes: {
|
||||||
attachments: includeAttachments,
|
attachments: includeAttachments,
|
||||||
@@ -447,6 +467,7 @@ export async function buildBackupArchive(
|
|||||||
folders: folderRows,
|
folders: folderRows,
|
||||||
ciphers: cipherRows,
|
ciphers: cipherRows,
|
||||||
attachments: exportedAttachmentRows,
|
attachments: exportedAttachmentRows,
|
||||||
|
webauthn_credentials: accountPasskeyRows,
|
||||||
}, null, BACKUP_JSON_INDENT)),
|
}, null, BACKUP_JSON_INDENT)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -409,13 +409,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 +435,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 +442,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,7 @@ type BackupTableName =
|
|||||||
| 'users'
|
| 'users'
|
||||||
| 'domain_settings'
|
| 'domain_settings'
|
||||||
| 'user_revisions'
|
| 'user_revisions'
|
||||||
|
| 'webauthn_credentials'
|
||||||
| 'folders'
|
| 'folders'
|
||||||
| 'ciphers'
|
| 'ciphers'
|
||||||
| 'attachments';
|
| 'attachments';
|
||||||
@@ -33,6 +34,7 @@ const BACKUP_TABLES: BackupTableName[] = [
|
|||||||
'users',
|
'users',
|
||||||
'domain_settings',
|
'domain_settings',
|
||||||
'user_revisions',
|
'user_revisions',
|
||||||
|
'webauthn_credentials',
|
||||||
'folders',
|
'folders',
|
||||||
'ciphers',
|
'ciphers',
|
||||||
'attachments',
|
'attachments',
|
||||||
@@ -49,6 +51,7 @@ export interface BackupImportResultBody {
|
|||||||
users: number;
|
users: number;
|
||||||
domainSettings: number;
|
domainSettings: number;
|
||||||
userRevisions: number;
|
userRevisions: number;
|
||||||
|
webauthnCredentials: number;
|
||||||
folders: number;
|
folders: number;
|
||||||
ciphers: number;
|
ciphers: number;
|
||||||
attachments: number;
|
attachments: number;
|
||||||
@@ -168,6 +171,7 @@ 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 domain_settings',
|
'DELETE FROM domain_settings',
|
||||||
'DELETE FROM user_revisions',
|
'DELETE FROM user_revisions',
|
||||||
'DELETE FROM users',
|
'DELETE FROM users',
|
||||||
@@ -292,6 +296,7 @@ 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 || []),
|
||||||
|
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 +634,16 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
|||||||
true
|
true
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('webauthn_credentials'),
|
||||||
|
buildInsertStatements(
|
||||||
|
db,
|
||||||
|
tableName('webauthn_credentials'),
|
||||||
|
['id', 'user_id', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'],
|
||||||
|
payload.webauthn_credentials || []
|
||||||
|
)
|
||||||
|
);
|
||||||
await runInsertBatch(
|
await runInsertBatch(
|
||||||
db,
|
db,
|
||||||
tableName('folders'),
|
tableName('folders'),
|
||||||
@@ -697,6 +712,7 @@ 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,
|
||||||
|
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 +735,7 @@ 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,
|
||||||
|
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 +776,7 @@ 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,
|
||||||
|
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 +853,7 @@ 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,
|
||||||
|
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 +876,7 @@ 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,
|
||||||
|
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 +923,7 @@ 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,
|
||||||
|
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 = {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -114,6 +114,20 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
|||||||
'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)',
|
||||||
|
|
||||||
|
|||||||
+96
-4
@@ -1,4 +1,4 @@
|
|||||||
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain } from '../types';
|
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential } from '../types';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { ensureStorageSchema } from './storage-schema';
|
import { ensureStorageSchema } from './storage-schema';
|
||||||
import {
|
import {
|
||||||
@@ -115,6 +115,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 +134,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-09-account-passkeys';
|
||||||
|
const REQUIRED_ACCOUNT_PASSKEY_TABLES = ['webauthn_credentials', 'webauthn_challenges'] as const;
|
||||||
|
|
||||||
// D1-backed storage.
|
// D1-backed storage.
|
||||||
// Contract:
|
// Contract:
|
||||||
@@ -153,6 +166,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 hasAccountPasskeyTables(): Promise<boolean> {
|
||||||
|
const placeholders = REQUIRED_ACCOUNT_PASSKEY_TABLES.map(() => '?').join(', ');
|
||||||
|
const result = await this.db
|
||||||
|
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${placeholders})`)
|
||||||
|
.bind(...REQUIRED_ACCOUNT_PASSKEY_TABLES)
|
||||||
|
.all<{ name: string }>();
|
||||||
|
const found = new Set((result.results || []).map((row) => row.name));
|
||||||
|
return REQUIRED_ACCOUNT_PASSKEY_TABLES.every((table) => found.has(table));
|
||||||
|
}
|
||||||
|
|
||||||
private sqlChunkSize(fixedBindCount: number): number {
|
private sqlChunkSize(fixedBindCount: number): number {
|
||||||
return Math.max(
|
return Math.max(
|
||||||
1,
|
1,
|
||||||
@@ -196,7 +219,10 @@ export class StorageService {
|
|||||||
|
|
||||||
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
|
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.hasAccountPasskeyTables())
|
||||||
|
: 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 +349,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 +578,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
|
||||||
);
|
);
|
||||||
|
|||||||
+46
-2
@@ -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,6 +237,37 @@ 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;
|
||||||
@@ -372,6 +406,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 +421,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 +541,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>
|
||||||
|
|||||||
@@ -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" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+122
-13
@@ -25,8 +25,8 @@ import {
|
|||||||
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 +37,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 +57,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 {
|
||||||
@@ -146,7 +150,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 +173,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 +204,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);
|
||||||
@@ -431,6 +439,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 +485,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 +542,78 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handlePasskeyLogin() {
|
||||||
|
if (pendingAuthAction) return;
|
||||||
|
if (IS_DEMO_MODE) {
|
||||||
|
pushToast('warning', t('txt_demo_readonly_message'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingAuthAction('passkey');
|
||||||
|
try {
|
||||||
|
const result = await performPasskeyLogin(defaultKdfIterations);
|
||||||
|
if (result.kind === 'success') {
|
||||||
|
await finalizeLogin(result.login);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.kind === 'password') {
|
||||||
|
setPendingPasskeyPassword(result.pendingPasskeyPassword);
|
||||||
|
setLoginValues({ email: result.pendingPasskeyPassword.email, password: '' });
|
||||||
|
setPasskeyPassword('');
|
||||||
|
pushToast('warning', t('txt_passkey_requires_master_password'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushToast('error', result.message || t('txt_login_failed'));
|
||||||
|
} catch (error) {
|
||||||
|
pushToast('error', error instanceof Error ? error.message : t('txt_login_failed'));
|
||||||
|
} finally {
|
||||||
|
setPendingAuthAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePasskeyUnlock() {
|
||||||
|
if (pendingAuthAction) return;
|
||||||
|
const expectedEmail = (profile?.email || session?.email || '').trim().toLowerCase();
|
||||||
|
if (!expectedEmail) return;
|
||||||
|
if (IS_DEMO_MODE) {
|
||||||
|
pushToast('warning', t('txt_demo_readonly_message'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingAuthAction('passkey');
|
||||||
|
try {
|
||||||
|
const result = await performPasskeyLogin(defaultKdfIterations, expectedEmail);
|
||||||
|
if (result.kind === 'success') {
|
||||||
|
await finalizeLogin(result.login, t('txt_unlocked'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.kind === 'password') {
|
||||||
|
pushToast('error', t('txt_account_passkey_direct_unlock_unavailable_error'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushToast('error', result.message || t('txt_unlock_failed_master_password_is_incorrect'));
|
||||||
|
} catch (error) {
|
||||||
|
pushToast('error', error instanceof Error ? error.message : t('txt_unlock_failed_master_password_is_incorrect'));
|
||||||
|
} finally {
|
||||||
|
setPendingAuthAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePasskeyPasswordLogin() {
|
||||||
|
if (pendingAuthAction || !pendingPasskeyPassword) return;
|
||||||
|
if (!passkeyPassword) {
|
||||||
|
pushToast('error', t('txt_please_input_master_password'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingAuthAction('login');
|
||||||
|
try {
|
||||||
|
const login = await completePasskeyPasswordLogin(pendingPasskeyPassword, passkeyPassword);
|
||||||
|
await finalizeLogin(login);
|
||||||
|
} catch (error) {
|
||||||
|
pushToast('error', error instanceof Error ? error.message : t('txt_unlock_failed_master_password_is_incorrect'));
|
||||||
|
} finally {
|
||||||
|
setPendingAuthAction(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleTotpVerify() {
|
async function handleTotpVerify() {
|
||||||
if (totpSubmitting) return;
|
if (totpSubmitting) return;
|
||||||
if (!pendingTotp) return;
|
if (!pendingTotp) return;
|
||||||
@@ -744,6 +827,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 +992,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 +1003,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 +1032,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({
|
||||||
@@ -1011,7 +1095,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 +1165,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 +1198,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 +1433,7 @@ export default function App() {
|
|||||||
const accountSecurityActions = useAccountSecurityActions({
|
const accountSecurityActions = useAccountSecurityActions({
|
||||||
authedFetch,
|
authedFetch,
|
||||||
profile,
|
profile,
|
||||||
|
session,
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
disableTotpPassword,
|
disableTotpPassword,
|
||||||
clearDisableTotpDialog: () => {
|
clearDisableTotpDialog: () => {
|
||||||
@@ -1525,6 +1620,10 @@ 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,
|
||||||
onLockTimeoutChange: setLockTimeoutMinutes,
|
onLockTimeoutChange: setLockTimeoutMinutes,
|
||||||
onSessionTimeoutActionChange: setSessionTimeoutAction,
|
onSessionTimeoutActionChange: setSessionTimeoutAction,
|
||||||
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
||||||
@@ -1635,18 +1734,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 +1765,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');
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -237,6 +238,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, 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,10 @@ 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>;
|
||||||
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>;
|
||||||
@@ -261,6 +265,10 @@ 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}
|
||||||
onLockTimeoutChange={props.onLockTimeoutChange}
|
onLockTimeoutChange={props.onLockTimeoutChange}
|
||||||
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
||||||
onNotify={props.onNotify}
|
onNotify={props.onNotify}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,86 @@
|
|||||||
|
import { Wifi, WifiOff } from 'lucide-preact';
|
||||||
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import {
|
||||||
|
browserReportsOffline,
|
||||||
|
getCurrentNetworkStatus,
|
||||||
|
probeNodeWardenService,
|
||||||
|
setCurrentNetworkStatus,
|
||||||
|
subscribeNetworkStatus,
|
||||||
|
type NetworkStatus,
|
||||||
|
} from '@/lib/network-status';
|
||||||
|
|
||||||
|
const STATUS_CHECK_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
function statusLabel(status: NetworkStatus): string {
|
||||||
|
if (status === 'online') return t('txt_online');
|
||||||
|
return t('txt_offline');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NetworkStatusBadge() {
|
||||||
|
const [status, setStatus] = useState<NetworkStatus>(getCurrentNetworkStatus);
|
||||||
|
const label = statusLabel(status);
|
||||||
|
const Icon = status === 'online' ? Wifi : WifiOff;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let timer = 0;
|
||||||
|
|
||||||
|
const checkService = async () => {
|
||||||
|
if (browserReportsOffline()) {
|
||||||
|
setCurrentNetworkStatus('offline');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reachable = await probeNodeWardenService();
|
||||||
|
if (!cancelled) {
|
||||||
|
setCurrentNetworkStatus(reachable ? 'online' : 'offline');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleNextCheck = () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
void checkService().finally(scheduleNextCheck);
|
||||||
|
}, STATUS_CHECK_INTERVAL_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOnline = () => {
|
||||||
|
void checkService();
|
||||||
|
};
|
||||||
|
const handleOffline = () => {
|
||||||
|
setCurrentNetworkStatus('offline');
|
||||||
|
};
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'visible') void checkService();
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = subscribeNetworkStatus(setStatus);
|
||||||
|
void checkService().finally(scheduleNextCheck);
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
window.addEventListener('focus', handleOnline);
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unsubscribe();
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
window.removeEventListener('focus', handleOnline);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`network-status-badge ${status}`}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<Icon size={14} aria-hidden="true" />
|
||||||
|
<span className="network-status-label">{label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { 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, 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';
|
||||||
|
|
||||||
@@ -18,11 +18,23 @@ 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>;
|
||||||
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' },
|
||||||
@@ -74,9 +86,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 +114,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 +136,27 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openMasterPasswordPrompt(action: 'recovery' | 'apiKey' | 'rotateApiKey'): void {
|
async function refreshAccountPasskeys(): Promise<void> {
|
||||||
|
setAccountPasskeysLoading(true);
|
||||||
|
try {
|
||||||
|
setAccountPasskeys(await props.onListAccountPasskeys());
|
||||||
|
} catch (error) {
|
||||||
|
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_account_passkeys_load_failed'));
|
||||||
|
} finally {
|
||||||
|
setAccountPasskeysLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMasterPasswordPrompt(action: MasterPasswordPromptAction, credentialId?: string): void {
|
||||||
setMasterPasswordPrompt(action);
|
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 +173,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,7 +205,19 @@ 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 accountPasskeyStatusText(credential: AccountPasskeyCredential): string {
|
||||||
|
if (credential.prfStatus === 0) return t('txt_direct_unlock');
|
||||||
|
if (credential.prfStatus === 1) return t('txt_login_only');
|
||||||
|
return t('txt_prf_not_supported');
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(value: string | null | undefined): string {
|
function formatDateTime(value: string | null | undefined): string {
|
||||||
if (!value) return t('txt_dash');
|
if (!value) return t('txt_dash');
|
||||||
@@ -345,6 +403,107 @@ 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>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
|||||||
@@ -716,6 +716,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 +731,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 +745,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 +766,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 +784,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 +795,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 +831,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 +847,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 +866,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 +882,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 +898,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 +914,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 +931,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 +947,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 +963,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -224,6 +224,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 +302,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 +452,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 +482,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 +560,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 +594,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 +616,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 +638,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 +657,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 +676,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 +694,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 +717,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 +748,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 +777,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 +796,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 +814,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 +834,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 +860,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 +891,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 +913,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 +930,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 +952,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;
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import { base64ToBytes, bytesToBase64, decryptBw, encryptBw, hkdfExpand, toBufferSource } from './crypto';
|
||||||
|
import { t } from './i18n';
|
||||||
|
import type { AccountPasskeyPrfOption } from './types';
|
||||||
|
|
||||||
|
const LOGIN_WITH_PRF_SALT = 'passwordless-login';
|
||||||
|
|
||||||
|
export interface AccountPasskeyAssertion {
|
||||||
|
token: string;
|
||||||
|
deviceResponse: Record<string, unknown>;
|
||||||
|
prfKey?: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingAccountPasskeyCredential {
|
||||||
|
token: string;
|
||||||
|
createOptions: PublicKeyCredentialCreationOptions;
|
||||||
|
deviceResponse: PublicKeyCredential;
|
||||||
|
request: Record<string, unknown>;
|
||||||
|
supportsPrf: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountPasskeyPrfKeySet {
|
||||||
|
encryptedUserKey: string;
|
||||||
|
encryptedPublicKey: string;
|
||||||
|
encryptedPrivateKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AccountPasskeyPrfUnavailableError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super(t('txt_account_passkey_direct_unlock_unavailable_error'));
|
||||||
|
this.name = 'AccountPasskeyPrfUnavailableError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64Url(bytes: Uint8Array): string {
|
||||||
|
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlToBytes(value: string): Uint8Array {
|
||||||
|
const normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padded = normalized + '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
|
||||||
|
return base64ToBytes(padded);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||||
|
return toBufferSource(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneCreationOptions(options: any): PublicKeyCredentialCreationOptions {
|
||||||
|
if (!options || typeof options !== 'object') throw new Error(t('txt_invalid_passkey_creation_options'));
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
challenge: toArrayBuffer(base64UrlToBytes(options.challenge)),
|
||||||
|
user: {
|
||||||
|
...options.user,
|
||||||
|
id: toArrayBuffer(base64UrlToBytes(options.user?.id)),
|
||||||
|
},
|
||||||
|
excludeCredentials: Array.isArray(options.excludeCredentials)
|
||||||
|
? options.excludeCredentials.map((credential: any) => ({
|
||||||
|
...credential,
|
||||||
|
id: toArrayBuffer(base64UrlToBytes(credential.id)),
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneRequestOptions(options: any): PublicKeyCredentialRequestOptions {
|
||||||
|
if (!options || typeof options !== 'object') throw new Error(t('txt_invalid_passkey_assertion_options'));
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
challenge: toArrayBuffer(base64UrlToBytes(options.challenge)),
|
||||||
|
allowCredentials: Array.isArray(options.allowCredentials)
|
||||||
|
? options.allowCredentials.map((credential: any) => ({
|
||||||
|
...credential,
|
||||||
|
id: toArrayBuffer(base64UrlToBytes(credential.id)),
|
||||||
|
}))
|
||||||
|
: options.allowCredentials,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLoginWithPrfSalt(): Promise<Uint8Array> {
|
||||||
|
const hash = await crypto.subtle.digest('SHA-256', toBufferSource(new TextEncoder().encode(LOGIN_WITH_PRF_SALT)));
|
||||||
|
return new Uint8Array(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
function credentialIdToBase64Url(id: BufferSource): string | null {
|
||||||
|
try {
|
||||||
|
const bytes = id instanceof ArrayBuffer
|
||||||
|
? new Uint8Array(id)
|
||||||
|
: new Uint8Array(id.buffer, id.byteOffset, id.byteLength);
|
||||||
|
return bytesToBase64Url(bytes);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrfEvalInput = { first: Uint8Array };
|
||||||
|
|
||||||
|
function buildLegacyPrfExtension(salt: Uint8Array): Record<string, unknown> {
|
||||||
|
const evalInput: PrfEvalInput = { first: salt };
|
||||||
|
return {
|
||||||
|
prf: {
|
||||||
|
eval: evalInput,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCredentialPrfExtension(
|
||||||
|
salt: Uint8Array,
|
||||||
|
credentialIds: Array<string | null | undefined>
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const evalInput = { first: salt };
|
||||||
|
const evalByCredential = credentialIds
|
||||||
|
.filter((id): id is string => !!id)
|
||||||
|
.reduce<Record<string, PrfEvalInput>>((out, id) => {
|
||||||
|
out[id] = evalInput;
|
||||||
|
return out;
|
||||||
|
}, {});
|
||||||
|
if (!Object.keys(evalByCredential).length) return buildLegacyPrfExtension(salt);
|
||||||
|
return {
|
||||||
|
prf: {
|
||||||
|
evalByCredential,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withPrfExtension(
|
||||||
|
options: PublicKeyCredentialRequestOptions,
|
||||||
|
extension: Record<string, unknown>
|
||||||
|
): PublicKeyCredentialRequestOptions {
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
extensions: {
|
||||||
|
...((options as any).extensions || {}),
|
||||||
|
...extension,
|
||||||
|
} as any,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPrfFirstResult(credential: PublicKeyCredential): ArrayBuffer | undefined {
|
||||||
|
const result = (credential.getClientExtensionResults() as any).prf?.results?.first;
|
||||||
|
return result instanceof ArrayBuffer ? result : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPrfExtensionResult(credential: PublicKeyCredential): boolean {
|
||||||
|
return Object.prototype.hasOwnProperty.call(credential.getClientExtensionResults() as any, 'prf');
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRetryWithLegacyPrf(error: unknown): boolean {
|
||||||
|
const name = error instanceof DOMException || error instanceof Error ? error.name : '';
|
||||||
|
return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPublicKeyCredentialWithPrf(
|
||||||
|
options: PublicKeyCredentialRequestOptions,
|
||||||
|
salt: Uint8Array,
|
||||||
|
credentialIds: string[] = []
|
||||||
|
): Promise<PublicKeyCredential> {
|
||||||
|
const attempts = credentialIds.length
|
||||||
|
? [
|
||||||
|
buildCredentialPrfExtension(salt, credentialIds),
|
||||||
|
buildLegacyPrfExtension(salt),
|
||||||
|
]
|
||||||
|
: [buildLegacyPrfExtension(salt)];
|
||||||
|
let lastCredential: PublicKeyCredential | null = null;
|
||||||
|
for (let index = 0; index < attempts.length; index += 1) {
|
||||||
|
try {
|
||||||
|
const credential = await navigator.credentials.get({
|
||||||
|
publicKey: withPrfExtension(options, attempts[index]),
|
||||||
|
});
|
||||||
|
if (!(credential instanceof PublicKeyCredential)) {
|
||||||
|
throw new Error(t('txt_no_passkey_selected'));
|
||||||
|
}
|
||||||
|
lastCredential = credential;
|
||||||
|
if (readPrfFirstResult(credential) || hasPrfExtensionResult(credential) || index === attempts.length - 1) {
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (index === attempts.length - 1 || !shouldRetryWithLegacyPrf(error)) {
|
||||||
|
if (lastCredential) return lastCredential;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastCredential) return lastCredential;
|
||||||
|
throw new Error(t('txt_no_passkey_selected'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function prfCredentialIdsFromAllowCredentials(options: PublicKeyCredentialRequestOptions): string[] {
|
||||||
|
return (options.allowCredentials || [])
|
||||||
|
.map((credential) => credentialIdToBase64Url(credential.id))
|
||||||
|
.filter((id): id is string => !!id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prfOutputToKey(prfOutput: ArrayBuffer): Promise<Uint8Array> {
|
||||||
|
const prf = new Uint8Array(prfOutput);
|
||||||
|
const enc = await hkdfExpand(prf, 'enc', 32);
|
||||||
|
const mac = await hkdfExpand(prf, 'mac', 32);
|
||||||
|
const out = new Uint8Array(64);
|
||||||
|
out.set(enc, 0);
|
||||||
|
out.set(mac, 32);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicKeyCredentialBase(credential: PublicKeyCredential): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: bytesToBase64Url(new Uint8Array(credential.rawId)),
|
||||||
|
type: credential.type,
|
||||||
|
extensions: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertionRequest(credential: PublicKeyCredential): Record<string, unknown> {
|
||||||
|
if (!(credential.response instanceof AuthenticatorAssertionResponse)) {
|
||||||
|
throw new Error(t('txt_invalid_passkey_assertion_response'));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...publicKeyCredentialBase(credential),
|
||||||
|
response: {
|
||||||
|
authenticatorData: bytesToBase64Url(new Uint8Array(credential.response.authenticatorData)),
|
||||||
|
signature: bytesToBase64Url(new Uint8Array(credential.response.signature)),
|
||||||
|
clientDataJSON: bytesToBase64Url(new Uint8Array(credential.response.clientDataJSON)),
|
||||||
|
userHandle: credential.response.userHandle
|
||||||
|
? bytesToBase64Url(new Uint8Array(credential.response.userHandle))
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function attestationRequest(credential: PublicKeyCredential): Record<string, unknown> {
|
||||||
|
if (!(credential.response instanceof AuthenticatorAttestationResponse)) {
|
||||||
|
throw new Error(t('txt_invalid_passkey_registration_response'));
|
||||||
|
}
|
||||||
|
const transports = typeof credential.response.getTransports === 'function'
|
||||||
|
? credential.response.getTransports()
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
...publicKeyCredentialBase(credential),
|
||||||
|
response: {
|
||||||
|
attestationObject: bytesToBase64Url(new Uint8Array(credential.response.attestationObject)),
|
||||||
|
clientDataJson: bytesToBase64Url(new Uint8Array(credential.response.clientDataJSON)),
|
||||||
|
transports,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertAccountPasskey(
|
||||||
|
response: { options: unknown; token: string }
|
||||||
|
): Promise<AccountPasskeyAssertion> {
|
||||||
|
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||||
|
throw new Error(t('txt_passkey_browser_not_supported'));
|
||||||
|
}
|
||||||
|
const nativeOptions = cloneRequestOptions(response.options);
|
||||||
|
const credential = await getPublicKeyCredentialWithPrf(
|
||||||
|
nativeOptions,
|
||||||
|
await getLoginWithPrfSalt(),
|
||||||
|
prfCredentialIdsFromAllowCredentials(nativeOptions)
|
||||||
|
);
|
||||||
|
const prfResult = readPrfFirstResult(credential);
|
||||||
|
return {
|
||||||
|
token: response.token,
|
||||||
|
deviceResponse: assertionRequest(credential),
|
||||||
|
prfKey: prfResult ? await prfOutputToKey(prfResult) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAccountPasskeyCredential(
|
||||||
|
response: { options: unknown; token: string }
|
||||||
|
): Promise<PendingAccountPasskeyCredential> {
|
||||||
|
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||||
|
throw new Error(t('txt_passkey_browser_not_supported'));
|
||||||
|
}
|
||||||
|
const nativeOptions = cloneCreationOptions(response.options);
|
||||||
|
(nativeOptions as any).extensions = {
|
||||||
|
...((nativeOptions as any).extensions || {}),
|
||||||
|
prf: {},
|
||||||
|
};
|
||||||
|
const credential = await navigator.credentials.create({ publicKey: nativeOptions });
|
||||||
|
if (!(credential instanceof PublicKeyCredential)) {
|
||||||
|
throw new Error(t('txt_no_passkey_created'));
|
||||||
|
}
|
||||||
|
const supportsPrf = !!(credential.getClientExtensionResults() as any).prf?.enabled;
|
||||||
|
return {
|
||||||
|
token: response.token,
|
||||||
|
createOptions: nativeOptions,
|
||||||
|
deviceResponse: credential,
|
||||||
|
request: attestationRequest(credential),
|
||||||
|
supportsPrf,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRsaEncryptedUserKey(value: string): Uint8Array {
|
||||||
|
const text = String(value || '').trim();
|
||||||
|
const [type, payload] = text.split('.');
|
||||||
|
if (type !== '4' || !payload) throw new Error(t('txt_unsupported_encrypted_user_key'));
|
||||||
|
return base64ToBytes(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildAccountPasskeyPrfKeySet(
|
||||||
|
pending: PendingAccountPasskeyCredential,
|
||||||
|
userKey: { symEncKey: string; symMacKey: string }
|
||||||
|
): Promise<AccountPasskeyPrfKeySet> {
|
||||||
|
const rawId = new Uint8Array(pending.deviceResponse.rawId);
|
||||||
|
const credentialId = bytesToBase64Url(rawId);
|
||||||
|
const assertionOptions: PublicKeyCredentialRequestOptions = {
|
||||||
|
challenge: pending.createOptions?.challenge!,
|
||||||
|
rpId: pending.createOptions?.rp?.id,
|
||||||
|
allowCredentials: [{ id: toArrayBuffer(rawId), type: 'public-key' }],
|
||||||
|
timeout: pending.createOptions?.timeout,
|
||||||
|
userVerification: pending.createOptions?.authenticatorSelection?.userVerification,
|
||||||
|
};
|
||||||
|
const assertion = await getPublicKeyCredentialWithPrf(
|
||||||
|
assertionOptions,
|
||||||
|
await getLoginWithPrfSalt(),
|
||||||
|
[credentialId]
|
||||||
|
);
|
||||||
|
const prfResult = readPrfFirstResult(assertion);
|
||||||
|
if (!prfResult) {
|
||||||
|
throw new AccountPasskeyPrfUnavailableError();
|
||||||
|
}
|
||||||
|
return buildAccountPasskeyPrfKeySetFromPrfKey(await prfOutputToKey(prfResult), userKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildAccountPasskeyPrfKeySetFromPrfKey(
|
||||||
|
prfKey: Uint8Array,
|
||||||
|
userKey: { symEncKey: string; symMacKey: string }
|
||||||
|
): Promise<AccountPasskeyPrfKeySet> {
|
||||||
|
const userKeyBytes = new Uint8Array(64);
|
||||||
|
userKeyBytes.set(base64ToBytes(userKey.symEncKey), 0);
|
||||||
|
userKeyBytes.set(base64ToBytes(userKey.symMacKey), 32);
|
||||||
|
|
||||||
|
const pair = await crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: 'RSA-OAEP',
|
||||||
|
modulusLength: 2048,
|
||||||
|
publicExponent: new Uint8Array([1, 0, 1]),
|
||||||
|
hash: 'SHA-1',
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', pair.publicKey));
|
||||||
|
const privateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', pair.privateKey));
|
||||||
|
const encryptedUserKeyBytes = new Uint8Array(await crypto.subtle.encrypt(
|
||||||
|
{ name: 'RSA-OAEP' },
|
||||||
|
pair.publicKey,
|
||||||
|
toBufferSource(userKeyBytes)
|
||||||
|
));
|
||||||
|
|
||||||
|
return {
|
||||||
|
encryptedUserKey: `4.${bytesToBase64(encryptedUserKeyBytes)}`,
|
||||||
|
encryptedPublicKey: await encryptBw(publicKey, userKeyBytes.slice(0, 32), userKeyBytes.slice(32, 64)),
|
||||||
|
encryptedPrivateKey: await encryptBw(privateKey, prfKey.slice(0, 32), prfKey.slice(32, 64)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlockVaultKeyWithAccountPasskeyPrf(
|
||||||
|
prfKey: Uint8Array,
|
||||||
|
option: AccountPasskeyPrfOption
|
||||||
|
): Promise<{ symEncKey: string; symMacKey: string }> {
|
||||||
|
const encryptedPrivateKey = option.EncryptedPrivateKey || option.encryptedPrivateKey || '';
|
||||||
|
const encryptedUserKey = option.EncryptedUserKey || option.encryptedUserKey || '';
|
||||||
|
if (!encryptedPrivateKey || !encryptedUserKey) {
|
||||||
|
throw new Error(t('txt_passkey_cannot_unlock_vault'));
|
||||||
|
}
|
||||||
|
const privateKeyBytes = await decryptBw(encryptedPrivateKey, prfKey.slice(0, 32), prfKey.slice(32, 64));
|
||||||
|
const privateKey = await crypto.subtle.importKey(
|
||||||
|
'pkcs8',
|
||||||
|
toBufferSource(privateKeyBytes),
|
||||||
|
{ name: 'RSA-OAEP', hash: 'SHA-1' },
|
||||||
|
false,
|
||||||
|
['decrypt']
|
||||||
|
);
|
||||||
|
const userKeyBytes = new Uint8Array(await crypto.subtle.decrypt(
|
||||||
|
{ name: 'RSA-OAEP' },
|
||||||
|
privateKey,
|
||||||
|
toBufferSource(parseRsaEncryptedUserKey(encryptedUserKey))
|
||||||
|
));
|
||||||
|
if (userKeyBytes.length < 64) throw new Error(t('txt_invalid_passkey_vault_key'));
|
||||||
|
return {
|
||||||
|
symEncKey: bytesToBase64(userKeyBytes.slice(0, 32)),
|
||||||
|
symMacKey: bytesToBase64(userKeyBytes.slice(32, 64)),
|
||||||
|
};
|
||||||
|
}
|
||||||
+177
-1
@@ -2,11 +2,13 @@ import { bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from '../cryp
|
|||||||
import { t, translateServerError } from '../i18n';
|
import { 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 { 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 +97,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 +241,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 +271,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 +283,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;
|
||||||
}
|
}
|
||||||
@@ -450,9 +494,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 +506,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 +532,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 +641,135 @@ export async function verifyMasterPassword(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAccountPasskeyCredential(raw: any): AccountPasskeyCredential {
|
||||||
|
return {
|
||||||
|
id: String(raw?.id || raw?.Id || ''),
|
||||||
|
name: String(raw?.name || raw?.Name || ''),
|
||||||
|
prfStatus: Number(raw?.prfStatus ?? raw?.PrfStatus ?? 2) as 0 | 1 | 2,
|
||||||
|
encryptedPublicKey: raw?.encryptedPublicKey ?? raw?.EncryptedPublicKey ?? null,
|
||||||
|
encryptedUserKey: raw?.encryptedUserKey ?? raw?.EncryptedUserKey ?? null,
|
||||||
|
creationDate: raw?.creationDate ?? raw?.CreationDate,
|
||||||
|
revisionDate: raw?.revisionDate ?? raw?.RevisionDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAccountPasskeys(authedFetch: AuthedFetch): Promise<AccountPasskeyCredential[]> {
|
||||||
|
const resp = await authedFetch('/api/webauthn');
|
||||||
|
if (!resp.ok) throw new Error('Failed to load account passkeys');
|
||||||
|
const body = (await parseJson<{ data?: unknown[]; Data?: unknown[] }>(resp)) || {};
|
||||||
|
const rows = Array.isArray(body.data) ? body.data : Array.isArray(body.Data) ? body.Data : [];
|
||||||
|
return rows.map(normalizeAccountPasskeyCredential).filter((item) => item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccountPasskeyAttestationOptions(
|
||||||
|
authedFetch: AuthedFetch,
|
||||||
|
masterPasswordHash: string
|
||||||
|
): Promise<{ options: unknown; token: string }> {
|
||||||
|
const resp = await authedFetch('/api/webauthn/attestation-options', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ masterPasswordHash }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await parseJson<TokenError>(resp);
|
||||||
|
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
|
||||||
|
}
|
||||||
|
const body = (await parseJson<{ options?: unknown; token?: string }>(resp)) || {};
|
||||||
|
if (!body.options || !body.token) throw new Error('Invalid passkey creation options');
|
||||||
|
return { options: body.options, token: body.token };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccountPasskeyUpdateAssertionOptions(
|
||||||
|
authedFetch: AuthedFetch,
|
||||||
|
masterPasswordHash: string,
|
||||||
|
credentialId?: string
|
||||||
|
): Promise<{ options: unknown; token: string }> {
|
||||||
|
const resp = await authedFetch('/api/webauthn/assertion-options', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ masterPasswordHash, credentialId }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await parseJson<TokenError>(resp);
|
||||||
|
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
|
||||||
|
}
|
||||||
|
const body = (await parseJson<{ options?: unknown; token?: string }>(resp)) || {};
|
||||||
|
if (!body.options || !body.token) throw new Error('Invalid passkey assertion options');
|
||||||
|
return { options: body.options, token: body.token };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAccountPasskey(
|
||||||
|
authedFetch: AuthedFetch,
|
||||||
|
payload: {
|
||||||
|
name: string;
|
||||||
|
token: string;
|
||||||
|
deviceResponse: unknown;
|
||||||
|
supportsPrf: boolean;
|
||||||
|
keySet?: AccountPasskeyPrfKeySet | null;
|
||||||
|
}
|
||||||
|
): Promise<AccountPasskeyCredential> {
|
||||||
|
const resp = await authedFetch('/api/webauthn', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: payload.name,
|
||||||
|
token: payload.token,
|
||||||
|
deviceResponse: payload.deviceResponse,
|
||||||
|
supportsPrf: payload.supportsPrf,
|
||||||
|
encryptedUserKey: payload.keySet?.encryptedUserKey,
|
||||||
|
encryptedPublicKey: payload.keySet?.encryptedPublicKey,
|
||||||
|
encryptedPrivateKey: payload.keySet?.encryptedPrivateKey,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await parseJson<TokenError>(resp);
|
||||||
|
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_save_profile_failed')));
|
||||||
|
}
|
||||||
|
const body = await parseJson<unknown>(resp);
|
||||||
|
return normalizeAccountPasskeyCredential(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enableAccountPasskeyDirectUnlock(
|
||||||
|
authedFetch: AuthedFetch,
|
||||||
|
payload: {
|
||||||
|
token: string;
|
||||||
|
deviceResponse: unknown;
|
||||||
|
keySet: AccountPasskeyPrfKeySet;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const resp = await authedFetch('/api/webauthn', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: payload.token,
|
||||||
|
deviceResponse: payload.deviceResponse,
|
||||||
|
encryptedUserKey: payload.keySet.encryptedUserKey,
|
||||||
|
encryptedPublicKey: payload.keySet.encryptedPublicKey,
|
||||||
|
encryptedPrivateKey: payload.keySet.encryptedPrivateKey,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await parseJson<TokenError>(resp);
|
||||||
|
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_save_profile_failed')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAccountPasskey(
|
||||||
|
authedFetch: AuthedFetch,
|
||||||
|
id: string,
|
||||||
|
masterPasswordHash: string
|
||||||
|
): Promise<void> {
|
||||||
|
const resp = await authedFetch(`/api/webauthn/${encodeURIComponent(id)}/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ masterPasswordHash }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await parseJson<TokenError>(resp);
|
||||||
|
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_delete_item_failed')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise<number> {
|
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) {
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export interface AdminBackupImportCounts {
|
|||||||
users: number;
|
users: number;
|
||||||
domainSettings?: number;
|
domainSettings?: number;
|
||||||
userRevisions: number;
|
userRevisions: 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: [] };
|
||||||
|
|||||||
+331
-21
@@ -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',
|
||||||
|
|||||||
+205
-8
@@ -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,26 @@ 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);
|
||||||
|
let serviceReachable = true;
|
||||||
|
if (hasOfflineUnlock) {
|
||||||
|
serviceReachable = await probeNodeWardenService();
|
||||||
|
if (!serviceReachable) {
|
||||||
|
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 && !serviceReachable) {
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||||
|
};
|
||||||
|
}
|
||||||
return { session: null, profile: null };
|
return { session: null, profile: null };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -272,7 +322,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 +342,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 +404,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 +416,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 +427,62 @@ export async function performPasswordLogin(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function performPasskeyLogin(fallbackIterations: number, expectedEmail?: string): Promise<PasskeyLoginResult> {
|
||||||
|
try {
|
||||||
|
const options = await getAccountPasskeyAssertionOptions();
|
||||||
|
const assertion = await assertAccountPasskey(options);
|
||||||
|
const token = await loginWithAccountPasskeyAssertion(assertion);
|
||||||
|
|
||||||
|
if (!('access_token' in token) || !token.access_token) {
|
||||||
|
const tokenError = token as { error_description?: string; error?: string };
|
||||||
|
return {
|
||||||
|
kind: 'error',
|
||||||
|
message: translateServerError(tokenError.error_description || tokenError.error, t('txt_login_failed')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = (decodeAccessTokenClaims(token.access_token).email || '').trim().toLowerCase();
|
||||||
|
if (!email) {
|
||||||
|
return { kind: 'error', message: t('txt_login_failed') };
|
||||||
|
}
|
||||||
|
const normalizedExpectedEmail = String(expectedEmail || '').trim().toLowerCase();
|
||||||
|
if (normalizedExpectedEmail && email !== normalizedExpectedEmail) {
|
||||||
|
return { kind: 'error', message: t('txt_passkey_not_for_locked_account') };
|
||||||
|
}
|
||||||
|
|
||||||
|
const prfOption = readPasskeyPrfOption(token);
|
||||||
|
if (prfOption && assertion.prfKey) {
|
||||||
|
const keys = await unlockVaultKeyWithAccountPasskeyPrf(assertion.prfKey, prfOption);
|
||||||
|
return {
|
||||||
|
kind: 'success',
|
||||||
|
login: await completeLoginWithVaultKeys(token, email, keys, fallbackIterations),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'password',
|
||||||
|
pendingPasskeyPassword: {
|
||||||
|
token,
|
||||||
|
email,
|
||||||
|
kdfIterations: kdfIterationsFromLogin(token, fallbackIterations),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
kind: 'error',
|
||||||
|
message: error instanceof Error ? translateServerError(error.message, error.message) : t('txt_login_failed'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completePasskeyPasswordLogin(
|
||||||
|
pending: PendingPasskeyPassword,
|
||||||
|
password: string
|
||||||
|
): Promise<CompletedLogin> {
|
||||||
|
const derived = await deriveLoginHashLocally(pending.email, password, pending.kdfIterations);
|
||||||
|
return completeLogin(pending.token, pending.email, derived.masterKey, pending.kdfIterations);
|
||||||
|
}
|
||||||
|
|
||||||
export async function performTotpLogin(
|
export async function performTotpLogin(
|
||||||
pendingTotp: PendingTotp,
|
pendingTotp: PendingTotp,
|
||||||
totpCode: string,
|
totpCode: string,
|
||||||
@@ -342,7 +493,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 +512,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 +548,58 @@ 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) {
|
||||||
|
if (browserReportsOffline()) {
|
||||||
|
return unlockOffline();
|
||||||
|
}
|
||||||
|
const serviceReachable = await probeNodeWardenService();
|
||||||
|
if (!serviceReachable) {
|
||||||
|
return unlockOffline();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||||
|
try {
|
||||||
|
token = await loginWithPassword(normalizedEmail, derived.hash, {
|
||||||
|
useRememberToken: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) {
|
||||||
|
return unlockOffline();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'error',
|
||||||
|
message: t('txt_unlock_failed_master_password_is_incorrect'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if ('access_token' in token && token.access_token) {
|
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 +611,7 @@ export async function performUnlock(
|
|||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
passwordHash: derived.hash,
|
passwordHash: derived.hash,
|
||||||
masterKey: derived.masterKey,
|
masterKey: derived.masterKey,
|
||||||
|
kdfIterations: derived.kdfIterations,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,12 +189,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,
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -631,6 +631,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",
|
||||||
@@ -735,6 +778,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",
|
||||||
|
|||||||
@@ -631,6 +631,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",
|
||||||
@@ -735,6 +778,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",
|
||||||
|
|||||||
@@ -367,7 +367,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": "Удалить навсегда",
|
||||||
@@ -631,6 +631,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": "Пожалуйста, введите мастер-пароль",
|
||||||
@@ -735,6 +778,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": "Синхронизировать хранилище",
|
||||||
|
|||||||
@@ -631,6 +631,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": "请输入主密码",
|
||||||
@@ -735,6 +778,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": "同步",
|
||||||
|
|||||||
@@ -631,6 +631,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": "請輸入主密碼",
|
||||||
@@ -735,6 +778,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": "同步",
|
||||||
|
|||||||
@@ -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,81 @@
|
|||||||
|
export type NetworkStatus = 'online' | 'offline';
|
||||||
|
|
||||||
|
const STATUS_PROBE_TIMEOUT_MS = 3500;
|
||||||
|
const STATUS_PROBE_CACHE_MS = 5000;
|
||||||
|
const listeners = new Set<(status: NetworkStatus) => void>();
|
||||||
|
let currentStatus: NetworkStatus = getInitialNetworkStatus();
|
||||||
|
let pendingProbe: Promise<boolean> | null = null;
|
||||||
|
let lastProbeAt = 0;
|
||||||
|
let lastProbeResult = currentStatus === 'online';
|
||||||
|
|
||||||
|
export function browserReportsOffline(): boolean {
|
||||||
|
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInitialNetworkStatus(): NetworkStatus {
|
||||||
|
return browserReportsOffline() ? 'offline' : 'online';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentNetworkStatus(): NetworkStatus {
|
||||||
|
return currentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCurrentNetworkStatus(status: NetworkStatus): void {
|
||||||
|
if (currentStatus === status) return;
|
||||||
|
currentStatus = status;
|
||||||
|
for (const listener of Array.from(listeners)) {
|
||||||
|
listener(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeNetworkStatus(listener: (status: NetworkStatus) => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeNodeWardenService(): Promise<boolean> {
|
||||||
|
if (browserReportsOffline()) {
|
||||||
|
setCurrentNetworkStatus('offline');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (pendingProbe) return pendingProbe;
|
||||||
|
if (now - lastProbeAt < STATUS_PROBE_CACHE_MS) return lastProbeResult;
|
||||||
|
|
||||||
|
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||||
|
const timer = controller
|
||||||
|
? window.setTimeout(() => controller.abort(), STATUS_PROBE_TIMEOUT_MS)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
pendingProbe = (async () => {
|
||||||
|
await fetch(`/api/web-bootstrap?statusProbe=${Date.now()}`, {
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Pragma: 'no-cache',
|
||||||
|
},
|
||||||
|
signal: controller?.signal,
|
||||||
|
});
|
||||||
|
// Any same-origin HTTP response proves the server is reachable. A 4xx/5xx
|
||||||
|
// response may be an application problem, but it is not offline mode.
|
||||||
|
return true;
|
||||||
|
})()
|
||||||
|
.catch(() => false)
|
||||||
|
.then((result) => {
|
||||||
|
lastProbeAt = Date.now();
|
||||||
|
lastProbeResult = result;
|
||||||
|
setCurrentNetworkStatus(result ? 'online' : 'offline');
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (timer) window.clearTimeout(timer);
|
||||||
|
pendingProbe = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return pendingProbe;
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { deriveLoginHashLocally, unlockVaultKey } from '@/lib/api/auth';
|
||||||
|
import type { Profile, SessionState, TokenSuccess } from '@/lib/types';
|
||||||
|
|
||||||
|
const OFFLINE_UNLOCK_KEY = 'nodewarden.web.offline-unlock.v1';
|
||||||
|
|
||||||
|
interface OfflineUnlockRecord {
|
||||||
|
version: 1;
|
||||||
|
email: string;
|
||||||
|
profile: Profile;
|
||||||
|
profileKey: string;
|
||||||
|
kdfIterations: number;
|
||||||
|
savedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEmail(email: string | null | undefined): string {
|
||||||
|
return String(email || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripOfflineProfile(profile: Profile): Profile {
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
email: normalizeEmail(profile.email),
|
||||||
|
key: '',
|
||||||
|
privateKey: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRecord(raw: string | null): OfflineUnlockRecord | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<OfflineUnlockRecord>;
|
||||||
|
const email = normalizeEmail(parsed.email);
|
||||||
|
const profileKey = String(parsed.profileKey || '').trim();
|
||||||
|
const iterations = Number(parsed.kdfIterations || 0);
|
||||||
|
if (parsed.version !== 1 || !email || !profileKey || !Number.isFinite(iterations) || iterations <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const profile = parsed.profile && typeof parsed.profile === 'object'
|
||||||
|
? stripOfflineProfile(parsed.profile as Profile)
|
||||||
|
: {
|
||||||
|
id: '',
|
||||||
|
email,
|
||||||
|
name: email,
|
||||||
|
key: '',
|
||||||
|
privateKey: null,
|
||||||
|
role: 'user' as const,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
email,
|
||||||
|
profile,
|
||||||
|
profileKey,
|
||||||
|
kdfIterations: iterations,
|
||||||
|
savedAt: Number(parsed.savedAt || 0) || 0,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRecord(): OfflineUnlockRecord | null {
|
||||||
|
if (typeof localStorage === 'undefined') return null;
|
||||||
|
return parseRecord(localStorage.getItem(OFFLINE_UNLOCK_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasOfflineUnlockRecord(email?: string | null): boolean {
|
||||||
|
const record = readRecord();
|
||||||
|
if (!record) return false;
|
||||||
|
const normalized = normalizeEmail(email);
|
||||||
|
return !normalized || record.email === normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOfflineUnlockKdfIterations(email?: string | null): number | null {
|
||||||
|
const record = readRecord();
|
||||||
|
if (!record) return null;
|
||||||
|
const normalized = normalizeEmail(email);
|
||||||
|
if (normalized && record.email !== normalized) return null;
|
||||||
|
return record.kdfIterations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadOfflineProfileSnapshot(email?: string | null): Profile | null {
|
||||||
|
const record = readRecord();
|
||||||
|
if (!record) return null;
|
||||||
|
const normalized = normalizeEmail(email);
|
||||||
|
if (normalized && record.email !== normalized) return null;
|
||||||
|
return stripOfflineProfile(record.profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveOfflineUnlockRecord(args: {
|
||||||
|
email: string;
|
||||||
|
profile: Profile;
|
||||||
|
profileKey: string;
|
||||||
|
kdfIterations: number;
|
||||||
|
}): void {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
const email = normalizeEmail(args.email || args.profile.email);
|
||||||
|
const profileKey = String(args.profileKey || '').trim();
|
||||||
|
const kdfIterations = Number(args.kdfIterations || 0);
|
||||||
|
if (!email || !profileKey || !Number.isFinite(kdfIterations) || kdfIterations <= 0) return;
|
||||||
|
const record: OfflineUnlockRecord = {
|
||||||
|
version: 1,
|
||||||
|
email,
|
||||||
|
profile: stripOfflineProfile({ ...args.profile, email }),
|
||||||
|
profileKey,
|
||||||
|
kdfIterations,
|
||||||
|
savedAt: Date.now(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(OFFLINE_UNLOCK_KEY, JSON.stringify(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearOfflineUnlockRecord(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(OFFLINE_UNLOCK_KEY);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage failures during logout cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlockOfflineVault(
|
||||||
|
session: SessionState,
|
||||||
|
profile: Profile | null,
|
||||||
|
password: string
|
||||||
|
): Promise<{ session: SessionState; profile: Profile }> {
|
||||||
|
const record = readRecord();
|
||||||
|
const email = normalizeEmail(profile?.email || session.email);
|
||||||
|
if (!record || record.email !== email) {
|
||||||
|
throw new Error('Offline unlock is not available on this device.');
|
||||||
|
}
|
||||||
|
const derived = await deriveLoginHashLocally(record.email, password, record.kdfIterations);
|
||||||
|
return unlockOfflineVaultWithMasterKey(session, profile, derived.masterKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlockOfflineVaultWithMasterKey(
|
||||||
|
session: SessionState,
|
||||||
|
profile: Profile | null,
|
||||||
|
masterKey: Uint8Array
|
||||||
|
): Promise<{ session: SessionState; profile: Profile }> {
|
||||||
|
const record = readRecord();
|
||||||
|
const email = normalizeEmail(profile?.email || session.email);
|
||||||
|
if (!record || record.email !== email) {
|
||||||
|
throw new Error('Offline unlock is not available on this device.');
|
||||||
|
}
|
||||||
|
const keys = await unlockVaultKey(record.profileKey, masterKey);
|
||||||
|
const { accessToken: _accessToken, refreshToken: _refreshToken, ...offlineSession } = session;
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
...offlineSession,
|
||||||
|
email: record.email,
|
||||||
|
...keys,
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
...stripOfflineProfile(record.profile),
|
||||||
|
key: record.profileKey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kdfIterationsFromLogin(token: TokenSuccess, fallbackIterations: number): number {
|
||||||
|
const value = Number(token.KdfIterations || fallbackIterations || 600000);
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : 600000;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export function registerNodeWardenServiceWorker(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (!('serviceWorker' in navigator)) return;
|
||||||
|
if (import.meta.env.DEV) return;
|
||||||
|
|
||||||
|
const register = () => {
|
||||||
|
void navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {
|
||||||
|
// PWA support is progressive enhancement; the vault still works without it.
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
register();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', register, { once: true });
|
||||||
|
}
|
||||||
@@ -328,6 +328,37 @@ export interface TokenError {
|
|||||||
TwoFactorProviders?: unknown;
|
TwoFactorProviders?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AccountPasskeyCredential {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
prfStatus: 0 | 1 | 2;
|
||||||
|
encryptedPublicKey?: string | null;
|
||||||
|
encryptedUserKey?: string | null;
|
||||||
|
creationDate?: string;
|
||||||
|
revisionDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountPasskeyAssertionOptionsResponse {
|
||||||
|
options: PublicKeyCredentialRequestOptions;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountPasskeyCreationOptionsResponse {
|
||||||
|
options: PublicKeyCredentialCreationOptions;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountPasskeyPrfOption {
|
||||||
|
EncryptedPrivateKey?: string;
|
||||||
|
EncryptedUserKey?: string;
|
||||||
|
CredentialId?: string;
|
||||||
|
Transports?: string[];
|
||||||
|
encryptedPrivateKey?: string;
|
||||||
|
encryptedUserKey?: string;
|
||||||
|
credentialId?: string;
|
||||||
|
transports?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToastMessage {
|
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),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Send } from './types';
|
import type { Send } from './types';
|
||||||
|
import { getCurrentNetworkStatus } from './network-status';
|
||||||
import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt';
|
import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt';
|
||||||
|
|
||||||
type WorkerSuccess<T> = { id: number; ok: true; result: T };
|
type WorkerSuccess<T> = { id: number; ok: true; result: T };
|
||||||
@@ -12,6 +13,7 @@ const pending = new Map<number, { resolve: (value: any) => void; reject: (error:
|
|||||||
function getWorker(): Worker | null {
|
function getWorker(): Worker | null {
|
||||||
if (typeof Worker === 'undefined') return null;
|
if (typeof Worker === 'undefined') return null;
|
||||||
if (worker) return worker;
|
if (worker) return worker;
|
||||||
|
if (getCurrentNetworkStatus() === 'offline') return null;
|
||||||
worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' });
|
worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' });
|
||||||
worker.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => {
|
worker.addEventListener('message', (event: MessageEvent<WorkerResponse<unknown>>) => {
|
||||||
const message = event.data;
|
const message = event.data;
|
||||||
|
|||||||
+4
-3
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -451,7 +451,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.auth-card h1 {
|
.auth-card h1 {
|
||||||
@apply m-0 mb-1 text-center;
|
@apply m-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.standalone-eyebrow {
|
.standalone-eyebrow {
|
||||||
@@ -486,7 +486,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.standalone-title {
|
.standalone-title {
|
||||||
@apply m-0 mb-1 text-left text-3xl font-bold leading-tight tracking-normal;
|
@apply m-0 text-center text-3xl font-bold leading-tight tracking-normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-title-row {
|
||||||
|
@apply relative mb-1 flex min-h-9 items-center justify-center;
|
||||||
|
padding-inline: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.standalone-title-row > .network-status-badge {
|
||||||
|
@apply absolute right-0 top-1/2;
|
||||||
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.standalone-muted {
|
.standalone-muted {
|
||||||
|
|||||||
@@ -11,14 +11,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
@apply h-12 w-full rounded-xl border px-3.5 py-2.5 text-base text-ink outline-none transition;
|
@apply h-12 w-full rounded-xl border px-3.5 py-2.5 text-base leading-normal text-ink outline-none transition;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-color: rgba(74, 103, 150, 0.34);
|
border-color: rgba(74, 103, 150, 0.34);
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
|
||||||
}
|
}
|
||||||
|
|
||||||
select.input {
|
select.input {
|
||||||
@apply pr-[42px];
|
@apply py-0 pr-[42px];
|
||||||
|
line-height: 1.5;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
-moz-appearance: none;
|
-moz-appearance: none;
|
||||||
|
|||||||
@@ -1062,6 +1062,68 @@
|
|||||||
color: var(--success);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-passkey-mode-field {
|
||||||
|
@apply min-w-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-passkey-toggle {
|
||||||
|
@apply flex min-h-[44px] items-center gap-2 rounded-lg border px-3 text-sm font-extrabold;
|
||||||
|
border-color: var(--line);
|
||||||
|
background: color-mix(in srgb, var(--panel) 92%, var(--panel-2));
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-passkey-toggle input {
|
||||||
|
@apply h-4 w-4 shrink-0;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-passkeys-list {
|
||||||
|
@apply mt-3 grid gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-passkey-row {
|
||||||
|
@apply grid min-w-0 items-center gap-3 rounded-lg border p-3;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
border-color: var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-passkey-main {
|
||||||
|
@apply grid min-w-0 gap-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-passkey-main strong,
|
||||||
|
.account-passkey-main small {
|
||||||
|
@apply min-w-0 overflow-hidden text-ellipsis whitespace-nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-passkey-main small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-passkey-status {
|
||||||
|
@apply inline-flex min-h-7 shrink-0 items-center rounded-full px-2.5 text-xs font-extrabold;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-passkey-status-0 {
|
||||||
|
border-color: color-mix(in srgb, var(--success) 28%, var(--line));
|
||||||
|
background: color-mix(in srgb, var(--success) 9%, var(--panel));
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-passkey-status-1 {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 30%, var(--line));
|
||||||
|
background: color-mix(in srgb, var(--primary) 9%, var(--panel));
|
||||||
|
color: var(--primary-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-passkey-actions {
|
||||||
|
@apply justify-end;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-module-placeholder {
|
.settings-module-placeholder {
|
||||||
@apply flex min-h-[150px] flex-col items-center justify-center gap-3 text-base font-extrabold;
|
@apply flex min-h-[150px] flex-col items-center justify-center gap-3 text-base font-extrabold;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
@@ -54,6 +54,10 @@
|
|||||||
@apply text-2xl;
|
@apply text-2xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.standalone-title-row {
|
||||||
|
padding-inline: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
.standalone-footer {
|
.standalone-footer {
|
||||||
@apply text-xs leading-[1.4];
|
@apply text-xs leading-[1.4];
|
||||||
}
|
}
|
||||||
@@ -128,6 +132,14 @@
|
|||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-actions > .network-status-badge {
|
||||||
|
@apply h-8 px-2 text-[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions > .network-status-badge svg {
|
||||||
|
@apply m-0;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-sidebar-toggle,
|
.mobile-sidebar-toggle,
|
||||||
.mobile-lock-btn {
|
.mobile-lock-btn {
|
||||||
@apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 p-0 text-[0];
|
@apply inline-flex h-9 w-9 min-w-9 justify-center gap-0 p-0 text-[0];
|
||||||
@@ -898,6 +910,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-module select.input {
|
.settings-module select.input {
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
padding-right: 30px;
|
padding-right: 30px;
|
||||||
background-position:
|
background-position:
|
||||||
calc(100% - 15px) calc(50% - 3px),
|
calc(100% - 15px) calc(50% - 3px),
|
||||||
@@ -919,6 +933,21 @@
|
|||||||
gap: 7px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-passkey-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-passkey-status {
|
||||||
|
justify-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-passkey-actions,
|
||||||
|
.account-passkey-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-module .totp-grid {
|
.settings-module .totp-grid {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|||||||
@@ -44,6 +44,31 @@
|
|||||||
@apply flex items-center gap-2.5;
|
@apply flex items-center gap-2.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.network-status-badge {
|
||||||
|
@apply inline-flex h-[30px] shrink-0 select-none items-center gap-1.5 rounded-full border px-2.5 text-xs font-bold leading-none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-status-badge svg {
|
||||||
|
@apply shrink-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-status-badge.online {
|
||||||
|
background: color-mix(in srgb, var(--success) 10%, var(--panel));
|
||||||
|
border-color: color-mix(in srgb, var(--success) 36%, var(--line));
|
||||||
|
color: color-mix(in srgb, var(--success) 78%, var(--text));
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-status-badge.offline {
|
||||||
|
background: color-mix(in srgb, var(--warning) 13%, var(--panel));
|
||||||
|
border-color: color-mix(in srgb, var(--warning) 42%, var(--line));
|
||||||
|
color: color-mix(in srgb, var(--warning) 82%, var(--text));
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-actions > .network-status-badge {
|
||||||
|
@apply h-[34px] px-3;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-tabbar {
|
.mobile-tabbar {
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
+203
-1
@@ -1,10 +1,212 @@
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import preact from '@preact/preset-vite';
|
import preact from '@preact/preset-vite';
|
||||||
import { defineConfig, type Plugin } from 'vite';
|
import { defineConfig, type Plugin } from 'vite';
|
||||||
|
|
||||||
const rootDir = fileURLToPath(new URL('.', import.meta.url));
|
const rootDir = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
|
function buildServiceWorkerSource(precacheUrls: string[], version: string): string {
|
||||||
|
return `const CACHE_VERSION = ${JSON.stringify(`nodewarden-pwa-${version}`)};
|
||||||
|
const APP_SHELL_CACHE = \`\${CACHE_VERSION}-shell\`;
|
||||||
|
const RUNTIME_CACHE = 'nodewarden-pwa-runtime-v1';
|
||||||
|
|
||||||
|
const PRECACHE_URLS = ${JSON.stringify(precacheUrls, null, 2)};
|
||||||
|
const CRITICAL_SHELL_URLS = ['/', '/index.html'];
|
||||||
|
const STATIC_PATH_RE = /^\\/(?:assets\\/|payment-logos\\/|icon-|logo-|favicon|apple-touch-icon|nodewarden-|manifest\\.webmanifest$)/;
|
||||||
|
const NEVER_CACHE_PATH_RE = /^\\/(?:api|identity|setup|config|notifications|icons|\\.well-known|cdn-cgi)(?:\\/|$)/;
|
||||||
|
const OFFLINE_FALLBACK_HTML = '<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>NodeWarden</title><style>html,body{height:100%;margin:0;background:#eef4ff;color:#0f172a;font-family:ui-sans-serif,system-ui,sans-serif}.boot-screen{min-height:100%;display:grid;place-items:center;padding:24px;box-sizing:border-box}.boot-card{width:min(420px,100%);display:grid;gap:12px;justify-items:center;padding:28px;border:1px solid rgba(148,163,184,.35);border-radius:22px;background:rgba(255,255,255,.86);box-shadow:0 20px 45px rgba(15,23,42,.1)}.boot-logo{width:74px;height:58px;object-fit:contain}.boot-title{font-weight:700}.boot-sub{color:#475569;text-align:center;font-size:14px;line-height:1.5}</style></head><body><div class="boot-screen"><div class="boot-card"><img class="boot-logo" src="/nodewarden-logo.svg" alt=""><div class="boot-title">NodeWarden</div><div class="boot-sub">Offline cache is not ready on this device. Open NodeWarden once while online, then try offline again.</div></div></div></body></html>';
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(APP_SHELL_CACHE)
|
||||||
|
.then(async (cache) => {
|
||||||
|
await cache.addAll(CRITICAL_SHELL_URLS);
|
||||||
|
const nonCriticalUrls = PRECACHE_URLS.filter((url) => !CRITICAL_SHELL_URLS.includes(url));
|
||||||
|
await Promise.allSettled(nonCriticalUrls.map((url) => cache.add(url)));
|
||||||
|
})
|
||||||
|
.then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys()
|
||||||
|
.then((keys) => Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => key.startsWith('nodewarden-pwa-') && key.endsWith('-shell') && key !== APP_SHELL_CACHE)
|
||||||
|
.map((key) => caches.delete(key))
|
||||||
|
))
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function isSameOriginHttpGet(request) {
|
||||||
|
if (request.method !== 'GET') return false;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
return url.origin === self.location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCacheableResponse(response) {
|
||||||
|
return response && response.ok && (response.type === 'basic' || response.type === 'default');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshNavigationCache(request) {
|
||||||
|
const cache = await caches.open(APP_SHELL_CACHE);
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (isCacheableResponse(response)) {
|
||||||
|
await cache.put('/', response.clone());
|
||||||
|
await cache.put('/index.html', response.clone());
|
||||||
|
await warmStaticDependencies(response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function warmStaticDependencies(response) {
|
||||||
|
try {
|
||||||
|
const html = await response.text();
|
||||||
|
const runtimeCache = await caches.open(RUNTIME_CACHE);
|
||||||
|
const urls = Array.from(html.matchAll(/\\b(?:src|href)=["']([^"']+)["']/g))
|
||||||
|
.map((match) => {
|
||||||
|
try {
|
||||||
|
return new URL(match[1], self.location.origin);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((url) => url && url.origin === self.location.origin && STATIC_PATH_RE.test(url.pathname))
|
||||||
|
.map((url) => url.pathname + url.search);
|
||||||
|
await Promise.allSettled(Array.from(new Set(urls)).map((url) => runtimeCache.add(url)));
|
||||||
|
await trimRuntimeCache(runtimeCache, 120);
|
||||||
|
} catch {
|
||||||
|
// Dependency warming is best-effort; never slow or break navigation for it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appShellNavigation(request) {
|
||||||
|
const cache = await caches.open(APP_SHELL_CACHE);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
return (
|
||||||
|
(await cache.match(request, { ignoreSearch: true }))
|
||||||
|
|| (await cache.match(url.pathname, { ignoreSearch: true }))
|
||||||
|
|| (await cache.match('/'))
|
||||||
|
|| (await cache.match('/index.html'))
|
||||||
|
|| new Response(OFFLINE_FALLBACK_HTML, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'text/html; charset=UTF-8' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trimRuntimeCache(cache, maxEntries) {
|
||||||
|
const keys = await cache.keys();
|
||||||
|
if (keys.length <= maxEntries) return;
|
||||||
|
await Promise.all(keys.slice(0, keys.length - maxEntries).map((key) => cache.delete(key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheFirst(request) {
|
||||||
|
const shellCache = await caches.open(APP_SHELL_CACHE);
|
||||||
|
const cachedShell = await shellCache.match(request);
|
||||||
|
if (cachedShell) return cachedShell;
|
||||||
|
|
||||||
|
const runtimeCache = await caches.open(RUNTIME_CACHE);
|
||||||
|
const cachedRuntime = await runtimeCache.match(request);
|
||||||
|
if (cachedRuntime) return cachedRuntime;
|
||||||
|
|
||||||
|
const legacyRuntime = await matchLegacyRuntimeCache(request);
|
||||||
|
if (legacyRuntime) return legacyRuntime;
|
||||||
|
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (isCacheableResponse(response)) {
|
||||||
|
void runtimeCache.put(request, response.clone()).then(() => trimRuntimeCache(runtimeCache, 120));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function matchLegacyRuntimeCache(request) {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
for (const key of keys) {
|
||||||
|
if (key === RUNTIME_CACHE || !key.startsWith('nodewarden-pwa-') || !key.endsWith('-runtime')) continue;
|
||||||
|
const cache = await caches.open(key);
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const request = event.request;
|
||||||
|
if (!isSameOriginHttpGet(request)) return;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (NEVER_CACHE_PATH_RE.test(url.pathname)) return;
|
||||||
|
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
event.respondWith(appShellNavigation(request));
|
||||||
|
if (navigator.onLine !== false) {
|
||||||
|
event.waitUntil(refreshNavigationCache(request));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (STATIC_PATH_RE.test(url.pathname) || request.destination === 'script' || request.destination === 'style' || request.destination === 'font' || request.destination === 'image' || request.destination === 'worker') {
|
||||||
|
event.respondWith(cacheFirst(request));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCacheVersion(isDemo: boolean, urls: string[]): string {
|
||||||
|
const digest = createHash('sha256')
|
||||||
|
.update(`${isDemo ? 'demo' : 'app'}\n${urls.join('\n')}`)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 16);
|
||||||
|
return `${isDemo ? 'demo' : 'app'}-${digest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pwaServiceWorkerPlugin(isDemo: boolean): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'nodewarden-pwa-service-worker',
|
||||||
|
generateBundle(_, bundle) {
|
||||||
|
const urls = new Set<string>([
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
'/vault',
|
||||||
|
'/manifest.webmanifest',
|
||||||
|
'/nodewarden-logo.svg',
|
||||||
|
'/nodewarden-logo-bg.svg',
|
||||||
|
'/nodewarden-wordmark.svg',
|
||||||
|
'/favicon.ico',
|
||||||
|
'/favicon-32.png',
|
||||||
|
'/apple-touch-icon.png',
|
||||||
|
'/icon-192.png',
|
||||||
|
'/icon-512.png',
|
||||||
|
'/logo-64.png',
|
||||||
|
]);
|
||||||
|
const buildUrls = new Set<string>(urls);
|
||||||
|
|
||||||
|
for (const [fileName, output] of Object.entries(bundle)) {
|
||||||
|
if (output.type !== 'chunk' && output.type !== 'asset') continue;
|
||||||
|
if (fileName === 'sw.js' || fileName === 'robots.txt') continue;
|
||||||
|
if (fileName.endsWith('.map')) continue;
|
||||||
|
buildUrls.add(`/${fileName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedUrls = Array.from(buildUrls).sort();
|
||||||
|
const version = buildCacheVersion(isDemo, Array.from(buildUrls).sort());
|
||||||
|
this.emitFile({
|
||||||
|
type: 'asset',
|
||||||
|
fileName: 'sw.js',
|
||||||
|
source: buildServiceWorkerSource(sortedUrls, version),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function searchIndexPolicyPlugin(isDemo: boolean): Plugin {
|
function searchIndexPolicyPlugin(isDemo: boolean): Plugin {
|
||||||
return {
|
return {
|
||||||
name: 'nodewarden-search-index-policy',
|
name: 'nodewarden-search-index-policy',
|
||||||
@@ -59,7 +261,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
root: rootDir,
|
root: rootDir,
|
||||||
plugins: [preact(), searchIndexPolicyPlugin(isDemo), resourcePriorityPlugin(isDemo)],
|
plugins: [preact(), searchIndexPolicyPlugin(isDemo), resourcePriorityPlugin(isDemo), pwaServiceWorkerPlugin(isDemo)],
|
||||||
define: {
|
define: {
|
||||||
__NODEWARDEN_DEMO__: JSON.stringify(isDemo),
|
__NODEWARDEN_DEMO__: JSON.stringify(isDemo),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ name = "nodewarden"
|
|||||||
main = "src/index.ts"
|
main = "src/index.ts"
|
||||||
compatibility_date = "2024-01-01"
|
compatibility_date = "2024-01-01"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
command = "npm run build"
|
||||||
|
|
||||||
[assets]
|
[assets]
|
||||||
binding = "ASSETS"
|
binding = "ASSETS"
|
||||||
directory = "./dist"
|
directory = "./dist"
|
||||||
@@ -19,9 +22,17 @@ database_name = "nodewarden-db"
|
|||||||
name = "NOTIFICATIONS_HUB"
|
name = "NOTIFICATIONS_HUB"
|
||||||
class_name = "NotificationsHub"
|
class_name = "NotificationsHub"
|
||||||
|
|
||||||
|
[[durable_objects.bindings]]
|
||||||
|
name = "BACKUP_TRANSFER_RUNNER"
|
||||||
|
class_name = "BackupTransferRunner"
|
||||||
|
|
||||||
[[kv_namespaces]]
|
[[kv_namespaces]]
|
||||||
binding = "ATTACHMENTS_KV"
|
binding = "ATTACHMENTS_KV"
|
||||||
|
|
||||||
[[migrations]]
|
[[migrations]]
|
||||||
tag = "v1-notifications-hub"
|
tag = "v1-notifications-hub"
|
||||||
new_sqlite_classes = [ "NotificationsHub" ]
|
new_sqlite_classes = [ "NotificationsHub" ]
|
||||||
|
|
||||||
|
[[migrations]]
|
||||||
|
tag = "v2-backup-transfer-runner"
|
||||||
|
new_sqlite_classes = [ "BackupTransferRunner" ]
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ name = "nodewarden"
|
|||||||
main = "src/index.ts"
|
main = "src/index.ts"
|
||||||
compatibility_date = "2024-01-01"
|
compatibility_date = "2024-01-01"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
command = "npm run build"
|
||||||
|
|
||||||
[assets]
|
[assets]
|
||||||
binding = "ASSETS"
|
binding = "ASSETS"
|
||||||
directory = "./dist"
|
directory = "./dist"
|
||||||
@@ -19,6 +22,10 @@ database_name = "nodewarden-db"
|
|||||||
name = "NOTIFICATIONS_HUB"
|
name = "NOTIFICATIONS_HUB"
|
||||||
class_name = "NotificationsHub"
|
class_name = "NotificationsHub"
|
||||||
|
|
||||||
|
[[durable_objects.bindings]]
|
||||||
|
name = "BACKUP_TRANSFER_RUNNER"
|
||||||
|
class_name = "BackupTransferRunner"
|
||||||
|
|
||||||
[[r2_buckets]]
|
[[r2_buckets]]
|
||||||
binding = "ATTACHMENTS"
|
binding = "ATTACHMENTS"
|
||||||
bucket_name = "nodewarden-attachments"
|
bucket_name = "nodewarden-attachments"
|
||||||
@@ -26,3 +33,7 @@ bucket_name = "nodewarden-attachments"
|
|||||||
[[migrations]]
|
[[migrations]]
|
||||||
tag = "v1-notifications-hub"
|
tag = "v1-notifications-hub"
|
||||||
new_sqlite_classes = [ "NotificationsHub" ]
|
new_sqlite_classes = [ "NotificationsHub" ]
|
||||||
|
|
||||||
|
[[migrations]]
|
||||||
|
tag = "v2-backup-transfer-runner"
|
||||||
|
new_sqlite_classes = [ "BackupTransferRunner" ]
|
||||||
|
|||||||
Reference in New Issue
Block a user