Compare commits

...
63 Commits
Author SHA1 Message Date
shuaiplus b093c01fd7 chore: update version to 1.7.4 in package.json, package-lock.json, and app-version.ts 2026-07-12 22:13:33 +08:00
shuaiplus fa611dc843 fix: require master password for admin and wipe-device actions
Gate invite management, user ban/delete, and delete-all-devices behind
masterPasswordHash verification, matching backup step-up auth. The web UI
prompts for the master password in the shared confirm dialog.
2026-07-12 20:43:27 +08:00
shuaiplus 3c581d1fb1 fix: block IPv6 loopback in backup destination URL checks
Expand compressed IPv6 hostnames before the private-address allowlist so
forms like ::1 cannot bypass SSRF protection for WebDAV/S3 backup endpoints.
Also reject IPv4-mapped addresses written as ::ffff:hex:hex.
2026-07-12 20:21:45 +08:00
shuaiplus fb376797d2 feat: update PasswordGeneratorPage styles and improve layout responsiveness 2026-07-12 01:59:15 +08:00
shuaiplus 99b50275a6 feat: add Password Security feature with scanning and reporting capabilities 2026-07-12 01:50:21 +08:00
shuaiplus dfc98008cb Add password generator feature and update localization files 2026-07-11 18:49:26 +08:00
shuaiplus b472121f43 feat: add contributors section and star history chart to README files 2026-07-11 17:25:53 +08:00
DomainmasteriandGitHub 9caa064488 Add German (de), French (fr), Italian (it), and Swedish (sv) initial translations (#303) 2026-07-11 17:06:41 +08:00
shuaiplus aae614a079 feat: add offline mode notice and related styles to enhance user experience during offline access 2026-07-10 22:24:06 +08:00
shuaiplus 0e46cd371f Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-07-10 14:24:15 +08:00
shuaiplus db31792cef feat: add fill-assist, assetlinks check, and web-bootstrap to worker-handled paths 2026-07-10 14:23:45 +08:00
shuaiplus 8c65cb2e80 feat: update FIDO2 origins and enable direct unlock for account passkeys 2026-07-10 14:22:24 +08:00
shuaiplus 14dff8ee6a feat: disable new-device verification and update related logic across services 2026-07-10 13:00:32 +08:00
dependabot[bot]andShuai 8d399f431b chore(deps-dev): bump vite from 7.3.5 to 8.1.3
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.5 to 8.1.3.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.1.3/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.1.3
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 23:53:06 +08:00
shuaiplus bb3f866220 feat: add audit log demo data and passkey/API stub overrides 2026-07-09 20:51:12 +08:00
shuaiplusandShuai 39d9df78ea feat: add functionality to select unique items from duplicates in Vault components 2026-07-09 20:21:45 +08:00
rootphantomerandShuai a1b12fc447 feat: add duplicate detection demo data to dev:demo mode 2026-07-09 20:21:45 +08:00
rootphantomerandShuai 099217062a fix: group duplicates by color and sort A-Z within groups 2026-07-09 20:21:45 +08:00
DomainmasteriandGitHub dd90d7b8b8 Add Finnish (fi) translation (#299)
* Add Finnish (fi) locale file for nodewarden web vault translation
2026-07-09 19:00:33 +08:00
rootphantomerandShuai 525b773cf4 fix: preserve dialog title during closing animation to prevent flash 2026-07-09 18:53:01 +08:00
shuaiplus 04cb475935 feat: enhance date formatting in PublicSendPage and SendsPage components 2026-07-08 19:31:11 +08:00
ShuaiandGitHub e063f45cd9 Update FUNDING.yml 2026-07-08 11:59:17 +08:00
ShuaiandGitHub e10920d142 Create FUNDING.yml 2026-07-07 19:55:15 +08:00
shuaiplus b07edb0850 fix: correct directory name in .gitignore and remove nodewarden-wiki subproject 2026-07-07 19:48:40 +08:00
shuaiplus 58a86ae8fd docs: update feature comparison table for clarity and consistency 2026-07-07 18:36:35 +08:00
shuaiplus b986af86dc docs: add Fill-assist row to feature comparison table
Align English README with README_ZH for POST /fill-assist client support.
2026-07-07 14:18:19 +08:00
shuaiplus 8e33f92b33 docs: make English the default README, move Chinese to README_ZH
- README.md is now the English version (GitHub default)
- Add README_ZH.md for Chinese documentation
- Remove redundant README_EN.md
2026-07-07 13:53:11 +08:00
shuaiplus a0f832e8a5 chore: release v1.7.3 2026-07-07 01:43:14 +08:00
shuaiplus 8a5b210a1d Improve Bitwarden-compatible TOTP handling 2026-07-07 01:27:55 +08:00
shuaiplus ebc8e8e340 Replace remove button with trash icon positioned inside password field 2026-07-07 00:35:16 +08:00
shuaiplus a870142b7b Show password indicator and masked password field for encrypted sends
- Add password and authType fields to Send type
- Show lock icon in send list for password-protected sends
- Display masked dots in password field when editing a send that has a password set
- Add Remove button to clear existing password
2026-07-07 00:31:56 +08:00
shuaiplus a366acbac0 Revert redirect guard in backup uploader 2026-07-06 23:54:17 +08:00
shuaiplus 57c5ef9da6 Remove WEBSITE_ICONS_ENABLED and always enable website icons 2026-07-06 22:56:36 +08:00
shuaiplus f532d3ace3 Preserve WebAuthn credential purpose in backups 2026-07-06 22:25:02 +08:00
shuaiplus cc4a830be8 Harden backup and download token flows 2026-07-06 19:06:24 +08:00
shuaiplus c6438747e3 Harden 2FA disable and website icon privacy 2026-07-06 18:45:54 +08:00
shuaiplus 5c8f01be59 Harden backup blob and remote endpoint handling 2026-07-06 18:17:55 +08:00
shuaiplus 7ac6ae50bb Harden auth requests and backup endpoints 2026-07-06 18:04:23 +08:00
shuaiplus ace00e8e74 Harden WebAuthn extension origins 2026-07-06 17:43:57 +08:00
shuaiplus 51428461a8 fix: cap multipart backup and upload requests 2026-07-06 15:38:59 +08:00
shuaiplus 23c53bd1af fix: validate anonymous notification hub requests 2026-07-06 15:38:52 +08:00
shuaiplus ae168bea31 fix rate limit reset bypasses 2026-07-06 14:24:01 +08:00
shuaiplus 00e0ec0892 fix(backup): redact destination secrets in settings 2026-07-06 13:50:49 +08:00
shuaiplus 2df43ccdb0 fix(auth): revoke current access token session 2026-07-06 13:49:06 +08:00
shuaiplus fd46dffc34 fix: align push relay installation requests 2026-07-06 02:09:35 +08:00
shuaiplus 56b301f2d1 fix: align fill assist compatibility 2026-07-06 02:09:28 +08:00
shuaiplus f0e523376c fix: add admin auth request compatibility 2026-07-06 01:35:26 +08:00
shuaiplus 8b2f98b847 fix: add Bitwarden device registration endpoints 2026-07-06 01:35:19 +08:00
shuaiplus cde4555add fix: return unsupported for email and kdf flows 2026-07-06 01:35:12 +08:00
shuaiplus 1bad32fd90 fix: keep remembered 2fa token on bad password 2026-07-06 01:07:26 +08:00
shuaiplus e376a840c2 fix: add device verification settings endpoints 2026-07-06 00:59:34 +08:00
shuaiplus 9de0d3bd87 fix: clarify extended cipher type icons 2026-07-06 00:56:57 +08:00
shuaiplus 109593da90 feat: support Bitwarden extended cipher types 2026-07-06 00:44:17 +08:00
shuaiplus 01ff627ac6 fix(i18n): localize audit logs and new validation messages 2026-07-05 23:45:26 +08:00
shuaiplus d028b194e7 fix(admin): record audit clears and passkey 2fa status 2026-07-05 23:45:18 +08:00
shuaiplus c53d71fc28 feat(settings): move device management into settings 2026-07-05 23:44:54 +08:00
shuaiplus 6e722205b1 fix(totp): validate qr image uploads 2026-07-05 23:44:44 +08:00
shuaiplus cf14704d99 fix(import): validate payloads and zip entries 2026-07-05 23:44:36 +08:00
shuaiplus 0cef6a04e9 fix(backup): verify remote deletes and validate archives 2026-07-05 23:44:25 +08:00
shuaiplus 8c481a1564 fix(send): refresh routes and gate file access 2026-07-05 23:44:07 +08:00
shuaiplus d9a36fefe6 fix(security): harden auth and request limits 2026-07-05 23:43:49 +08:00
shuaiplus 12af18e3a3 feat: update device management link handling in navigation 2026-07-05 15:41:27 +08:00
shuaiplus d8cc88d9c0 feat: add UUID normalization functions and enhance WebAuthn response handling 2026-07-05 15:37:02 +08:00
115 changed files with 21654 additions and 1691 deletions
+2
View File
@@ -0,0 +1,2 @@
custom:
- https://nodewarden.app/sponsor
+1 -1
View File
@@ -43,7 +43,7 @@ tmp/
.tmp/
.tmp-bitwarden-clients/
nodewarden.wiki/
nodewarden-wiki/
wiki/
AGENTS.md
settings.json
+76 -110
View File
@@ -3,95 +3,100 @@
</p>
<p align="center">
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
Bitwarden-compatible server running on Cloudflare Workers
</p>
<p align="center">
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
</p>
<p align="center">
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
</p>
<p align="center">
<a href="./README_EN.md">English</a> |
<a href="./CONTRIBUTING.md">贡献指南</a>
<a href="./README_ZH.md">中文</a> |
<a href="./CONTRIBUTING.md">Contributing</a> |
<a href="https://nodewarden.app">Official wiki</a>
</p>
> **免责声明**
> 本项目仅供学习与交流使用,请定期备份你的密码库。
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
> **Disclaimer**
> This project is for learning and discussion purposes only. Please back up your vault regularly.
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
---
## Bitwarden 官方服务端能力对比
## Feature comparison with the official Bitwarden server
| 能力 | Bitwarden | NodeWarden | 说明 |
| Feature | Bitwarden Free | NodeWarden | Notes |
|---|---|---|---|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** |
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** |
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
| 实时同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
| Send | ✅ | ✅ | 支持文本与文件 Send |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份(OneDrive/Google Drive等)** |
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
| TOTP / Steam TOTP | | ✅ | `steam://` 支持 |
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
| 登录 2FA | ✅ | ⚠️ 部分支持 | 支持TOTP和Passkey(作为第二因素) |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
| Web vault | ✅ | ✅ | **Original Web Vault UI** |
| TOTP | ❌ | ✅ | Includes `steam://` support |
| **PWA / offline** | ❌ | ✅ | **Installable, offline** |
| **Passkey login** | ✅ | ✅ | **passwordless auth** |
| API keys | ✅ | ✅ | CLI keys; create and rotate |
| Login 2FA | ✅ | ✅ | TOTP, YubiKey, Passkey |
| 2FA recovery codes | ✅ | ✅ | One-time 2FA disable codes |
| Real-time push sync | ✅ | ✅ | All device sync |
| Attachments / Send | ✅ | ✅ | Cloudflare R2 or KV |
| Import / export | ✅ | ✅ | Bitwarden JSON / CSV / **ZIP** |
| **Cloud backup center** | | ✅ | **Scheduled WebDAV / S3 incrementals** |
| Device management | ✅ | ✅ | **Remove devices; trust controls** |
| Login requests | ✅ | ✅ | **Cross-device login approval/unlock** |
| **Multi-user** | ✅ | ✅ | Invite-code registration |
| Domain rules | ✅ | ✅ | Equivalent domains, global exclusions |
| Fill-assist | ✅ | ✅ | `POST /fill-assist`|
| Organizations / collections / roles | ✅ | ❌ | Not implemented |
| SSO / SCIM / directory | ✅ | ❌ | Not implemented |
---
## 已测试客户端
## Tested clients
- ✅ Windows 桌面端
-手机 App
-浏览器扩展
- ✅ Linux 桌面端
- ⚠️ macOS 桌面端尚未完整验证
- ✅ Windows desktop
-Mobile app
-Browser extension
- ✅ Linux desktop
- ⚠️ macOS desktop not fully verified yet
---
## 可视化快速部署
## Visual quick deploy
1. Fork NodeWarden 仓库到自己的 GitHub 账号
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
3. 选择 Continue with GitHub 并选择你的仓库
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
5. 等部署完成后,打开生成的 Workers 域名
1. Fork the NodeWarden repository to your GitHub account
2. Open [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
3. Choose **Continue with GitHub** and select your fork
4. Set **build command** to `npm run build` and **deploy command** to `npm run deploy`
- For KV mode, change the deploy command to `npm run deploy:kv`
5. After deployment finishes, open the generated Workers URL
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
- The default Workers hostname may be unreachable on some networks. To use a custom domain, add it in [Workers settings](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings).
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
- If the site reports a missing `JWT_SECRET`, add it as a **Secret** in Workers settings. In production use a random string of at least 32 characters; do not use temporary or example values.
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml` `wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
- In this flow you hand code to Cloudflare to build and deploy. `wrangler.toml` or `wrangler.kv.toml` in the repo defines binding names; the Worker initializes the D1 schema on first request—no manual SQL upload.
> [!TIP]
> 默认R2与可选KV的区别:
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
> Default R2 vs optional KV:
> | Storage | Card required | Max single attachment / Send file | Free tier |
> |---|---|---|---|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
> | KV | 不需要 | 25 MiBCloudflare限制) | 1 GB |
> | R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
> | KV | No | 25 MiB (Cloudflare limit) | 1 GB |
## 更新方法:
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork``Update branch`
- 自动:进入你的 Fork 仓库 ➜ `Actions``Sync upstream``Enable workflow`,会在每天凌晨 3 点自动同步上游。
## How to update
- Manual: open your fork on GitHub; when the sync banner appears, click **Sync fork****Update branch**
## CLI 部署
## CLI deploy
```powershell
git clone https://github.com/shuaiplus/NodeWarden.git
@@ -100,85 +105,46 @@ cd NodeWarden
npm install
npx wrangler login
# 默认:R2 模式
# Default: R2 mode
npm run deploy
# 可选:KV 模式
# Optional: KV mode
npm run deploy:kv
# 本地开发
# Local development
npm run dev
npm run dev:kv
```
---
## 主要特性
### PWA 渐进式 Web 应用
-**可安装到桌面** - 像原生应用一样运行
-**离线使用** - Service Worker 缓存,离线也能查看密码
-**App 快捷方式** - 快速启动保险库、TOTP代码
-**后台解密** - Web Worker 处理解密,不阻塞UI
### Passkey 无密码登录
-**WebAuthn/FIDO2 支持** - 使用指纹、Face ID等登录
-**PRF 密钥解锁** - Passkey 可直接解锁保险库
-**官方客户端兼容** - Chromium系浏览器扩展可用Passkey登录
-**多设备同步** - 支持iCloud、Google Password Manager等
### 云端备份说明
- 远程备份支持 **WebDAV****S3**
- 支持 **OneDrive**(通过Koofr)、**Google Drive**(通过Koofr)、**Cloudflare R2**、**Backblaze B2** 等
- 勾选”包含附件”后:
- ZIP 内仍只包含 `db.json``manifest.json`
- 真实附件单独存放在 `attachments/`
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
- 远程还原时:
- 会从 `attachments/` 目录按需读取附件
- 缺失的附件会被安全跳过
- 被跳过的附件不会在恢复后的数据库中留下脏记录
---
## 导入 / 导出
当前支持的导入来源包括:
- Bitwarden JSON
- Bitwarden CSV
- Bitwarden 密码库 + 附件 ZIP
- NodeWarden JSON
- 网页导入器里可见的多种浏览器 / 密码管理器格式
当前支持的导出方式包括:
- Bitwarden JSON
- Bitwarden 加密 JSON
- 带附件的 ZIP 导出
- NodeWarden JSON 系列
- 备份中心中的实例级完整手动导出
---
## 开源协议
## License
LGPL-3.0 License
---
## 致谢
## Credits
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
- [Bitwarden](https://bitwarden.com/) - Original design and clients
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
---
## Contributors
<a href="https://github.com/shuaiplus/nodewarden/graphs/contributors">
<img src="https://contrib.rocks/image?repo=shuaiplus/nodewarden" alt="NodeWarden contributors" />
</a>
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
<a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
</picture>
</a>
-172
View File
@@ -1,172 +0,0 @@
<p align="center">
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
</p>
<p align="center">
Bitwarden-compatible server running on Cloudflare Workers
</p>
<p align="center">
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
</p>
<p align="center">
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
</p>
<p align="center">
<a href="./README.md">中文说明</a> |
<a href="./CONTRIBUTING.md">Contributing</a>
</p>
> **Disclaimer**
>
> This project is for learning and discussion purposes only. Please back up your vault regularly.
>
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
---
## Feature Comparison with the Official Bitwarden Server
| Capability | Bitwarden | NodeWarden | Notes |
|---|---|---|---|
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
| **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** |
| **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** |
| **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** |
| Real-time sync | ✅ | ✅ | Web, browser extension, desktop, and mobile clients stay in sync in real time |
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
| Send | ✅ | ✅ | Supports both text and file Sends |
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
| **Cloud Backup Center** | ❌ | ✅ | **WebDAV / S3 scheduled backup (OneDrive/Google Drive etc.)** |
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
| Multi-user | ✅ | ✅ | Invite-based registration |
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
| Login 2FA | ✅ | ⚠️ Partial | TOTP and Passkey (as second factor) |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
---
## Tested Clients
- ✅ Windows desktop client
- ✅ Mobile app
- ✅ Browser extension
- ✅ Linux desktop client
- ⚠️ macOS desktop client has not been fully verified yet
---
## Web Deploy
1. Fork this repository. If this project helps you, consider giving it a Star.
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> continue.
3. R2 is used by default. If R2 is not enabled on your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
4. Deploy and open the generated URL.
| Storage | Card required | Single attachment / Send file limit | Free tier |
|---|---|---|---|
| R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
> [!TIP]
> How to keep your fork updated:
> - Manual: open your fork on GitHub, click `Sync fork`, then `Update branch`
> - Automatic: go to your fork -> `Actions` -> `Sync upstream` -> `Enable workflow`; it will sync upstream automatically every day at 3 AM
## CLI Deploy
```powershell
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
npm install
npx wrangler login
# Default: R2 mode
npm run deploy
# Optional: KV mode
npm run deploy:kv
# Local development
npm run dev
npm run dev:kv
```
---
## Key Features
### PWA Progressive Web App
-**Install to desktop** - Runs like a native app
-**Offline usage** - Service Worker caching, view passwords offline
-**App shortcuts** - Quick launch vault, TOTP codes
-**Background decryption** - Web Worker handles decryption without blocking UI
### Passkey Passwordless Login
-**WebAuthn/FIDO2 support** - Login with fingerprint, Face ID, etc.
-**PRF key unlock** - Passkey can unlock vault directly
-**Official client compatibility** - Chromium browser extension supports Passkey login
-**Multi-device sync** - Supports iCloud, Google Password Manager, etc.
### Cloud Backup Notes
- Remote backup supports **WebDAV** and **S3**
- Supports **OneDrive** (via Koofr), **Google Drive** (via Koofr), **Cloudflare R2**, **Backblaze B2**, etc.
- When `Include attachments` is enabled:
- the ZIP still contains only `db.json` and `manifest.json`
- actual attachment files are stored separately under `attachments/`
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
- During remote restore:
- required attachment files are loaded from `attachments/` on demand
- missing attachments are skipped safely
- skipped attachments do not leave broken rows in the restored database
---
## Import / Export
Current supported import sources include:
- Bitwarden JSON
- Bitwarden CSV
- Bitwarden vault + attachments ZIP
- NodeWarden JSON
- Multiple browser / password-manager formats available in the web import selector
Current supported export formats include:
- Bitwarden JSON
- Bitwarden encrypted JSON
- ZIP export with attachments
- NodeWarden JSON variants
- Full manual instance export from the backup center
---
## License
LGPL-3.0 License
---
## Credits
- [Bitwarden](https://bitwarden.com/) - Original design and clients
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
+149
View File
@@ -0,0 +1,149 @@
<p align="center">
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
</p>
<p align="center">
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
</p>
<p align="center">
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
</p>
<p align="center">
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
</p>
<p align="center">
<a href="./README.md">English</a> |
<a href="./CONTRIBUTING.md">贡献指南</a> |
<a href="https://nodewarden.app">官方wiki</a>
</p>
> **免责声明**
> 本项目仅供学习与交流使用,请定期备份你的密码库。
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
---
## 与 Bitwarden 官方服务端能力对比
| 能力 | Bitwarden免费版 | NodeWarden | 说明 |
|---|---|---|---|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
| TOTP | ❌ | ✅ | 包括 `steam://` 支持 |
| **PWA / 离线使用** | ❌ | ✅ | **可安装、离线使用、App快捷方式** |
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
| API 密钥 | ✅ | ✅ | 供bitwarden cli使用,支持获取和轮换 |
| 登录 2FA | ✅ | ✅ | 支持 TOTP、YubiKey、Passkey |
| 2FA 恢复码 | ✅ | ✅ | 一次性恢复码用于禁用 2FA |
| 实时推送同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
| 附件 / Send| ✅ | ✅ | Cloudflare R2 或 KV |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时增量备份** |
| 设备管理 | ✅ | ✅ | **删除设备、撤销信任、永久信任** |
| 登录请求 | ✅ | ✅ | **多端免密登录审批、跨设备解锁请求** |
| **多用户使用** | ✅ | ✅ | 支持邀请码注册 |
| 域名规则 | ✅ | ✅ | 自定义等效域名、全局域名排除 |
| Fill-assist | ✅ | ✅ | `POST /fill-assist` 辅助客户端自动填充;不能绕过保险库解锁 |
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
---
## 已测试客户端
- ✅ Windows 桌面端
- ✅ 手机 App
- ✅ 浏览器扩展
- ✅ Linux 桌面端
- ⚠️ macOS 桌面端尚未完整验证
---
## 可视化快速部署
1. Fork NodeWarden 仓库到自己的 GitHub 账号
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
3. 选择 Continue with GitHub 并选择你的仓库
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
5. 等部署完成后,打开生成的 Workers 域名
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml``wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
> [!TIP]
> 默认R2与可选KV的区别:
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
> |---|---|---|---|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
> | KV | 不需要 | 25 MiBCloudflare限制) | 1 GB |
## 更新方法:
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork``Update branch`
## CLI 部署
```powershell
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
npm install
npx wrangler login
# 默认:R2 模式
npm run deploy
# 可选:KV 模式
npm run deploy:kv
# 本地开发
npm run dev
npm run dev:kv
```
---
## 开源协议
LGPL-3.0 License
---
## 致谢
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
---
## 贡献者
<a href="https://github.com/shuaiplus/nodewarden/graphs/contributors">
<img src="https://contrib.rocks/image?repo=shuaiplus/nodewarden" alt="NodeWarden contributors" />
</a>
## Star History
<a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
</picture>
</a>
+1 -1
View File
@@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS users (
security_stamp TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active',
verify_devices INTEGER NOT NULL DEFAULT 1,
verify_devices INTEGER NOT NULL DEFAULT 0,
totp_secret TEXT,
totp_recovery_code TEXT,
api_key TEXT,
+672 -412
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "nodewarden",
"version": "1.7.2",
"version": "1.7.4",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus",
"license": "LGPL-3.0",
@@ -58,7 +58,7 @@
"tailwindcss": "^3.4.19",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"vite": "^7.3.1",
"vite": "^8.1.3",
"wrangler": "^4.105.0"
},
"dependencies": {
+5
View File
@@ -13,6 +13,11 @@ const localeFiles = [
['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'],
['ru', 'ru.ts', 'ru', 'Russian'],
['es', 'es.ts', 'es', 'Spanish'],
['fi', 'fi.ts', 'fi', 'Finnish'],
['de', 'de.ts', 'de', 'German'],
['fr', 'fr.ts', 'fr', 'French'],
['it', 'it.ts', 'it', 'Italian'],
['sv', 'sv.ts', 'sv', 'Swedish'],
];
function readLocale(fileName, variableName) {
@@ -0,0 +1,38 @@
import { normalizeBackupEndpointUrl } from '../src/services/backup-config.ts';
import fs from 'node:fs';
const scratch = process.env.SCRATCH || '.';
const cases = [
'http://127.0.0.1',
'http://169.254.169.254',
'http://[::1]',
'http://[0:0:0:0:0:0:0:1]',
'http://[::2]',
'http://[::]',
'http://[fe80::1]',
'http://[fc00::1]',
'https://example.com',
];
const out = [];
for (const url of cases) {
try {
const normalized = normalizeBackupEndpointUrl(url, 'WebDAV server URL');
out.push({ url, allowed: true, normalized });
} catch (e) {
out.push({ url, allowed: false, error: e instanceof Error ? e.message : String(e) });
}
}
const path = `${scratch}/poc-normalizeBackupEndpointUrl.json`;
fs.writeFileSync(path, JSON.stringify(out, null, 2));
console.log(JSON.stringify(out, null, 2));
// Security expectation: IPv6 loopback must NOT be allowed.
const loopback = out.find((row) => row.url === 'http://[::1]');
if (loopback?.allowed) {
console.error('FINDING_CONFIRMED: normalizeBackupEndpointUrl accepts http://[::1]');
process.exitCode = 2;
} else {
console.log('IPv6 loopback rejected as expected');
}
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.7.2';
export const APP_VERSION = '1.7.4';
+3
View File
@@ -62,6 +62,9 @@
// Refresh-token grant budget per IP per minute.
// refresh_token 授权每 IP 每分钟请求配额。
refreshTokenRequestsPerMinute: 30,
// Passwordless/auth-request creation budget per IP/email/device per minute.
// 免密/设备审批请求创建接口每 IP/邮箱/设备每分钟配额。
authRequestRequestsPerMinute: 5,
// Fixed window size for API rate limiting in seconds.
// API 限流固定窗口大小(秒)。
apiWindowSeconds: 60,
+4 -4
View File
@@ -19,7 +19,7 @@ import {
executeConfiguredBackup,
importAndAuditRemoteBackupFile,
} from '../handlers/backup';
import { verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
import { isSafeBackupAttachmentBlobName, verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
import { zipSync } from 'fflate';
const BACKUP_JOB_STATE_KEY = 'backup.job.state.v1';
@@ -372,7 +372,7 @@ export class BackupTransferRunner {
return badRequest('Remote attachment download payload is invalid');
}
const blobName = String(body?.blobName || '').trim();
if (!body?.destination || !blobName) {
if (!body?.destination || !isSafeBackupAttachmentBlobName(blobName)) {
return badRequest('Remote attachment download payload is invalid');
}
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
@@ -398,7 +398,7 @@ export class BackupTransferRunner {
const blobNames = Array.from(new Set(
(Array.isArray(body?.blobNames) ? body.blobNames : [])
.map((blobName) => String(blobName || '').trim())
.filter(Boolean)
.filter(isSafeBackupAttachmentBlobName)
));
if (!body?.destination || !blobNames.length || blobNames.length > 40) {
return badRequest('Remote attachment batch download payload is invalid');
@@ -446,7 +446,7 @@ export class BackupTransferRunner {
for (const attachment of body.attachments) {
const blobName = String(attachment?.blobName || '').trim();
if (!blobName) {
if (!isSafeBackupAttachmentBlobName(blobName)) {
return badRequest('Attachment chunk payload is invalid');
}
+88 -43
View File
@@ -42,6 +42,9 @@ function looksLikeEncString(value: string): boolean {
*/
function validateKdfParams(kdfType: number | undefined, kdfIterations: number | undefined, kdfMemory?: number | undefined, kdfParallelism?: number | undefined): string | null {
const type = kdfType ?? 0;
if (type !== 0 && type !== 1) {
return 'KDF type must be PBKDF2-SHA256 or Argon2id';
}
if (type === 0) {
// PBKDF2-SHA256: minimum 100 000 iterations
if (typeof kdfIterations === 'number' && kdfIterations < 100_000) {
@@ -349,7 +352,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
securityStamp: generateUUID(),
role: 'user',
status: 'active',
verifyDevices: true,
verifyDevices: false, // new-device verification requires email delivery (not available)
totpSecret: null,
totpRecoveryCode: null,
yubikeyKey1: null,
@@ -448,7 +451,7 @@ export async function handleGetPasswordHint(request: Request, env: Env): Promise
}
const rateLimit = new RateLimitService(env.DB);
const minuteBudget = await rateLimit.consumeBudgetWithWindow(
const minuteBudget = await rateLimit.consumeStrictBudgetWithWindow(
`${clientIdentifier}:password-hint`,
LIMITS.rateLimit.passwordHintRequestsPerMinute,
60
@@ -470,7 +473,7 @@ export async function handleGetPasswordHint(request: Request, env: Env): Promise
);
}
const hourlyBudget = await rateLimit.consumeBudgetWithWindow(
const hourlyBudget = await rateLimit.consumeStrictBudgetWithWindow(
`${clientIdentifier}:password-hint-hour`,
LIMITS.rateLimit.passwordHintRequestsPerHour,
60 * 60
@@ -550,51 +553,31 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
}
// PUT/POST /api/accounts/verify-devices
// New-device verification requires an email delivery channel which NodeWarden
// does not provide. This endpoint always rejects the request so clients receive
// clear feedback that the feature is unavailable rather than silently ignoring
// the user's preference.
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: {
secret?: string;
masterPasswordHash?: string;
verifyDevices?: boolean;
VerifyDevices?: boolean;
};
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const verifyDevices = typeof body.verifyDevices === 'boolean' ? body.verifyDevices : body.VerifyDevices;
if (typeof verifyDevices !== 'boolean') {
return errorResponse('verifyDevices must be true or false', 400);
}
const verified = await verifyUserSecret(auth, user, body.secret || body.masterPasswordHash);
if (!verified) {
return errorResponse('User verification failed.', 400);
}
user.verifyDevices = verifyDevices;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
// Log the attempt for audit purposes, but do not change state.
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.verify_devices.update',
action: 'account.verify_devices.update.rejected',
category: 'security',
level: 'security',
level: 'info',
targetType: 'user',
targetId: user.id,
metadata: {
verifyDevices: user.verifyDevices,
reason: 'new-device verification is not supported (no email delivery channel)',
...auditRequestMetadata(request),
},
});
return new Response(null, { status: 200 });
return errorResponse('New device verification is not available on this server. Enable TOTP or WebAuthn two-factor authentication instead.', 400);
}
// GET /api/accounts/keys
@@ -734,6 +717,11 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
const nextKdfParallelism = body.kdfParallelism ?? readNestedNumber(body, ['unlockData', 'kdf', 'parallelism']);
const kdfErr = validateKdfParams(nextKdf, nextKdfIterations, nextKdfMemory, nextKdfParallelism);
if (kdfErr) return errorResponse(kdfErr, 400);
const shouldUpdateHint = typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null;
const nextMasterPasswordHint = shouldUpdateHint ? normalizeMasterPasswordHint(body.masterPasswordHint) : undefined;
if (nextMasterPasswordHint && nextMasterPasswordHint.length > 120) {
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
}
user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email);
if (nextKey) user.key = nextKey;
@@ -743,8 +731,8 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
if (typeof nextKdfIterations === 'number') user.kdfIterations = nextKdfIterations;
if (typeof nextKdfMemory === 'number') user.kdfMemory = nextKdfMemory;
if (typeof nextKdfParallelism === 'number') user.kdfParallelism = nextKdfParallelism;
if (typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null) {
user.masterPasswordHint = body.masterPasswordHint;
if (shouldUpdateHint) {
user.masterPasswordHint = nextMasterPasswordHint ?? null;
}
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString();
@@ -811,6 +799,21 @@ function yubiKeyResponse(user: User): Record<string, unknown> {
};
}
// New-device verification requires an email delivery channel to send OTP
// challenges to unknown devices. NodeWarden does not integrate with an email
// provider, so this feature is intentionally unavailable. The settings
// response always reports disabled regardless of any legacy DB value.
function deviceVerificationSettingsResponse(_user: User): Record<string, unknown> {
return {
Enabled: false,
enabled: false,
VerifyDevices: false,
verifyDevices: false,
Object: 'deviceVerificationSettings',
object: 'deviceVerificationSettings',
};
}
async function yubiKeySettingsResponse(storage: StorageService, env: Env, user: User): Promise<Record<string, unknown>> {
const credentials = await getStoredYubicoCredentials(storage, env);
return {
@@ -885,6 +888,56 @@ export async function handleGetTwoFactorYubiKey(request: Request, env: Env, user
return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
}
// POST /api/two-factor/get-device-verification-settings
export async function handleGetDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
return jsonResponse(deviceVerificationSettingsResponse(user));
}
// PUT/POST /api/two-factor/device-verification-settings
// New-device verification is not supported (no email delivery channel).
// Reject any attempt to enable it; always return disabled state.
export async function handlePutDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const rawEnabled = body.enabled ?? body.Enabled ?? body.verifyDevices ?? body.VerifyDevices;
// Log the attempt for audit purposes — never change state.
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.verify_devices.update.rejected',
category: 'security',
level: 'info',
targetType: 'user',
targetId: user.id,
metadata: {
requested: rawEnabled,
reason: 'new-device verification is not supported (no email delivery channel)',
source: 'two-factor.device-verification-settings',
...auditRequestMetadata(request),
},
});
if (rawEnabled === true) {
return errorResponse('New device verification is not available on this server. Enable TOTP or WebAuthn two-factor authentication instead.', 400);
}
// Setting to false is the only supported state — return it.
return jsonResponse(deviceVerificationSettingsResponse(user));
}
// PUT/POST /api/two-factor/authenticator
export async function handlePutTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
@@ -1089,16 +1142,8 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
return errorResponse('Two-factor provider is not supported by this server.', 400);
}
const key = normalizeTotpSecret(readBodyString(body, ['key', 'Key']));
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
let verified = false;
if (key && userVerificationToken) {
verified = await verifyTotpUserVerificationToken(env, user, key, userVerificationToken);
}
if (!verified) {
verified = await verifyUserSecret(auth, user, secret);
}
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
+58 -18
View File
@@ -9,6 +9,34 @@ function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active';
}
async function requireMasterPasswordHash(
env: Env,
actorUser: User,
masterPasswordHash: unknown
): Promise<Response | null> {
const normalized = String(masterPasswordHash || '').trim();
if (!normalized) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
if (!valid) {
return errorResponse('Invalid password', 400);
}
return null;
}
async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
try {
const body = await request.json();
return body && typeof body === 'object' && !Array.isArray(body)
? body as Record<string, unknown>
: {};
} catch {
return {};
}
}
function randomHex(bytes: number): string {
const data = crypto.getRandomValues(new Uint8Array(bytes));
return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join('');
@@ -69,18 +97,22 @@ export async function handleAdminListUsers(
const storage = new StorageService(env.DB);
const users = await storage.getAllUsers();
return jsonResponse({
data: users.map(user => ({
const data = await Promise.all(users.map(async user => {
const hasTwoFactorPasskey = await storage.countAccountPasskeyCredentialsByUserId(user.id, 'twoFactor') > 0;
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
status: user.status,
twoFactorEnabled: !!user.totpSecret || Boolean(user.yubikeyKey1 || user.yubikeyKey2 || user.yubikeyKey3 || user.yubikeyKey4 || user.yubikeyKey5),
twoFactorEnabled: !!user.totpSecret || Boolean(user.yubikeyKey1 || user.yubikeyKey2 || user.yubikeyKey3 || user.yubikeyKey4 || user.yubikeyKey5) || hasTwoFactorPasskey,
creationDate: user.createdAt,
revisionDate: user.updatedAt,
object: 'user',
})),
};
}));
return jsonResponse({
data,
object: 'list',
continuationToken: null,
});
@@ -183,6 +215,9 @@ export async function handleAdminClearAuditLogs(
}
const storage = new StorageService(env.DB);
const deleted = await storage.clearAuditLogs();
await writeAuditLog(storage, actorUser.id, 'admin.audit.clear', 'auditLog', null, {
deleted,
}, request);
return jsonResponse({ object: 'auditLogClear', deleted });
}
@@ -197,14 +232,11 @@ export async function handleAdminCreateInvite(
}
const storage = new StorageService(env.DB);
let body: { expiresInHours?: number } = {};
try {
body = await request.json();
} catch {
body = {};
}
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const expiresInHours = Number.isFinite(body.expiresInHours)
const expiresInHours = Number.isFinite(Number(body.expiresInHours))
? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours))))
: 24 * 7;
const now = new Date();
@@ -259,6 +291,10 @@ export async function handleAdminDeleteInvite(
return errorResponse('Forbidden', 403);
}
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB);
const deleted = await storage.deleteInvite(code);
if (!deleted) {
@@ -281,6 +317,10 @@ export async function handleAdminDeleteAllInvites(
return errorResponse('Forbidden', 403);
}
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB);
const url = new URL(request.url);
if (url.searchParams.get('scope') === 'invalid') {
@@ -311,12 +351,9 @@ export async function handleAdminSetUserStatus(
return errorResponse('Forbidden', 403);
}
let body: { status?: string };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null;
if (!nextStatus) {
@@ -359,7 +396,6 @@ export async function handleAdminDeleteUser(
actorUser: User,
targetUserId: string
): Promise<Response> {
void request;
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
@@ -367,6 +403,10 @@ export async function handleAdminDeleteUser(
return errorResponse('You cannot delete yourself', 400);
}
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB);
const target = await storage.getUserById(targetUserId);
if (!target) {
+9 -6
View File
@@ -124,6 +124,10 @@ async function processAttachmentUpload(
}
const path = getAttachmentObjectKey(cipherId, attachment.id);
if (await getBlobObject(env, path)) {
return errorResponse('Attachment file has already been uploaded', 409);
}
try {
await putBlobObject(env, path, upload.body, {
size: upload.size,
@@ -435,17 +439,16 @@ export async function handlePublicDownloadAttachment(
}
const path = getAttachmentObjectKey(cipherId, attachmentId);
const object = await getBlobObject(env, path);
if (!object) {
return errorResponse('Attachment file not found', 404);
}
const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp);
if (!firstUse) {
return errorResponse('Invalid or expired token', 401);
}
const object = await getBlobObject(env, path);
if (!object) {
return errorResponse('Attachment file not found', 404);
}
return new Response(object.body, {
headers: {
'Content-Type': sanitizeDownloadContentType(object.contentType),
+97 -4
View File
@@ -5,6 +5,8 @@ import { readAuthRequestDeviceInfo, readActingDeviceIdentifier } from '../utils/
import { errorResponse, jsonResponse } from '../utils/response';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import { notifyAuthRequestResponse, notifyUserAuthRequest } from '../durable/notifications-hub';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { LIMITS } from '../config/limits';
const AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK = 0;
const AUTH_REQUEST_TYPE_UNLOCK = 1;
@@ -94,8 +96,8 @@ function toAuthRequestResponse(request: Request, authRequest: AuthRequestRecord,
RequestCountryName: authRequest.requestCountryName,
key: authRequest.key,
Key: authRequest.key,
masterPasswordHash: authRequest.masterPasswordHash,
MasterPasswordHash: authRequest.masterPasswordHash,
masterPasswordHash: null,
MasterPasswordHash: null,
creationDate: authRequest.creationDate,
CreationDate: authRequest.creationDate,
responseDate: authRequest.responseDate,
@@ -131,6 +133,30 @@ async function readJsonBody(request: Request): Promise<Record<string, any> | nul
}
}
async function enforceAuthRequestCreateRateLimit(
request: Request,
env: Env,
email: string,
deviceIdentifier: string
): Promise<Response | null> {
const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) return errorResponse('Client IP is required', 403);
const rateLimit = new RateLimitService(env.DB);
const limit = LIMITS.rateLimit.authRequestRequestsPerMinute;
const encodedEmail = encodeURIComponent(email || 'missing');
const encodedDevice = encodeURIComponent(deviceIdentifier || 'missing');
const budgets = await Promise.all([
rateLimit.consumeStrictBudget(`auth-request:ip:${clientIdentifier}`, limit),
rateLimit.consumeStrictBudget(`auth-request:email:${encodedEmail}`, limit),
rateLimit.consumeStrictBudget(`auth-request:device:${encodedDevice}`, limit),
]);
const blocked = budgets.find((budget) => !budget.allowed);
if (!blocked) return null;
return errorResponse('Too many authentication requests. Try again later.', 429);
}
function readBodyValue(body: Record<string, any>, names: string[]): unknown {
for (const name of names) {
if (body[name] !== undefined) return body[name];
@@ -164,6 +190,8 @@ export async function handleCreateAuthRequest(request: Request, env: Env): Promi
if (!email || !publicKey || !accessCode || !deviceInfo.deviceIdentifier) {
return errorResponse('Email, public key, device identifier, and access code are required.', 400);
}
const rateLimitResponse = await enforceAuthRequestCreateRateLimit(request, env, email, deviceInfo.deviceIdentifier);
if (rateLimitResponse) return rateLimitResponse;
if (!isSupportedAuthRequestType(type) || type === AUTH_REQUEST_TYPE_ADMIN_APPROVAL) {
return errorResponse('Invalid auth request type.', 400);
}
@@ -199,6 +227,72 @@ export async function handleCreateAuthRequest(request: Request, env: Env): Promi
return jsonResponse(toAuthRequestResponse(request, authRequest));
}
export async function handleCreateAdminAuthRequest(
request: Request,
env: Env,
userId: string,
userEmail: string
): Promise<Response> {
const storage = new StorageService(env.DB);
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
const email = normalizeText(readBodyValue(body, ['email', 'Email']), 320).toLowerCase() || userEmail.toLowerCase();
const publicKey = normalizeText(readBodyValue(body, ['publicKey', 'PublicKey']), 8192);
const accessCode = normalizeText(readBodyValue(body, ['accessCode', 'AccessCode']), 25);
const requestedType = Number(readBodyValue(body, ['type', 'Type']));
const deviceInfo = readAuthRequestDeviceInfo(
{
deviceIdentifier: normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128),
deviceName: normalizeText(readBodyValue(body, ['deviceName', 'DeviceName']), 128),
deviceType: String(readBodyValue(body, ['deviceType', 'DeviceType']) ?? ''),
},
request
);
if (requestedType !== AUTH_REQUEST_TYPE_ADMIN_APPROVAL) {
return errorResponse('Invalid AuthRequestType. Expected AdminApproval.', 400);
}
if (email !== userEmail.toLowerCase()) {
return errorResponse('Email does not match authenticated user.', 400);
}
if (!publicKey || !accessCode || !deviceInfo.deviceIdentifier) {
return errorResponse('Public key, device identifier, and access code are required.', 400);
}
const rateLimitResponse = await enforceAuthRequestCreateRateLimit(request, env, email, deviceInfo.deviceIdentifier);
if (rateLimitResponse) return rateLimitResponse;
const user = await storage.getUserById(userId);
if (!user || user.status !== 'active') {
return errorResponse('User not found.', 404);
}
await storage.pruneExpiredAuthRequests();
const now = new Date().toISOString();
const authRequest: AuthRequestRecord = {
id: generateUUID(),
userId: user.id,
organizationId: null,
type: AUTH_REQUEST_TYPE_ADMIN_APPROVAL,
requestDeviceIdentifier: deviceInfo.deviceIdentifier,
requestDeviceType: deviceInfo.deviceType,
requestIpAddress: getClientIp(request),
requestCountryName: getCountryName(request),
responseDeviceIdentifier: null,
accessCode,
publicKey,
key: null,
masterPasswordHash: null,
approved: null,
creationDate: now,
responseDate: null,
authenticationDate: null,
};
await storage.createAuthRequest(authRequest);
notifyUserAuthRequest(env, user.id, authRequest.id, deviceInfo.deviceIdentifier);
return jsonResponse(toAuthRequestResponse(request, authRequest));
}
export async function handleGetAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
@@ -255,7 +349,6 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
const approved = Boolean(readBodyValue(body, ['requestApproved', 'RequestApproved']));
const key = normalizeText(readBodyValue(body, ['key', 'Key']), 20000);
const masterPasswordHash = normalizeText(readBodyValue(body, ['masterPasswordHash', 'MasterPasswordHash']), 20000) || null;
const responseDeviceIdentifier =
normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128) ||
readActingDeviceIdentifier(request) ||
@@ -272,7 +365,7 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
approved,
responseDeviceIdentifier,
key,
masterPasswordHash,
masterPasswordHash: null,
});
if (!updated) return errorResponse('Auth request has already been answered.', 409);
const updatedRequest = await storage.getAuthRequestByIdForUser(id, userId);
+90 -16
View File
@@ -2,8 +2,10 @@ import type { Env, User } from '../types';
import { errorResponse, jsonResponse } from '../utils/response';
import {
type BackupArchiveBundle,
MAX_BACKUP_ARCHIVE_BYTES,
buildBackupArchive,
inspectBackupArchiveFileNameChecksum,
isSafeBackupAttachmentBlobName,
parseBackupArchive,
verifyBackupArchiveFileNameChecksum,
} from '../services/backup-archive';
@@ -18,6 +20,7 @@ import {
loadBackupSettings,
normalizeBackupSettingsInput,
normalizeImportedBackupSettings,
redactBackupSettingsSecrets,
repairBackupSettings,
requireBackupDestination,
saveBackupSettings,
@@ -45,6 +48,7 @@ import { AuthService } from '../services/auth';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { getBlobObject } from '../services/blob-store';
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
import { getMultipartRequestMaxBytes } from '../utils/direct-upload';
import { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token';
import { unzipSync } from 'fflate';
@@ -52,6 +56,14 @@ function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active';
}
function parseRequestContentLength(request: Request): number | null {
const raw = request.headers.get('content-length');
if (!raw) return null;
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) return null;
return Math.floor(value);
}
async function requireBackupUserVerification(actorUser: User, masterPasswordHash: string, env: Env): Promise<Response | null> {
const normalized = String(masterPasswordHash || '').trim();
if (!normalized) {
@@ -129,11 +141,18 @@ function ensureBackupBlobName(value: string): string {
if (!normalized) {
throw new Error('Backup attachment blob is required');
}
const parts = normalized.split('/').filter(Boolean);
if (!parts.length || parts.some((part) => part === '.' || part === '..')) {
if (!isSafeBackupAttachmentBlobName(normalized)) {
throw new Error('Backup attachment blob is invalid');
}
return parts.join('/');
return normalized;
}
function contentDispositionBackup(fileName: string | null | undefined): string {
const fallback = 'nodewarden_backup.zip';
const value = String(fileName || fallback)
.replace(/[\\/\r\n"]/g, '_')
.trim() || fallback;
return `attachment; filename="${value}"`;
}
const REMOTE_ATTACHMENT_INDEX_PATH = 'attachments/.nodewarden-attachment-index.v1.json';
@@ -654,6 +673,7 @@ function collectExternalRemoteAttachmentBlobNames(archiveBytes: Uint8Array): str
if (parsed.files[inlinePath]) continue;
const ref = refs.get(`${cipherId}/${attachmentId}`);
const blobName = String(ref?.blobName || '').trim();
if (!isSafeBackupAttachmentBlobName(blobName)) continue;
if (blobName && !seen.has(blobName)) {
seen.add(blobName);
names.push(blobName);
@@ -666,6 +686,7 @@ function collectExternalRemoteAttachmentBlobNames(archiveBytes: Uint8Array): str
function toImportStatusCode(message: string): number {
const lower = message.toLowerCase();
if (lower.includes('checksum')) return 400;
if (lower.includes('invalid remote backup path') || lower.includes('please select a backup zip file')) return 409;
if (lower.includes('invalid backup') || lower.includes('invalid json')) return 400;
if (lower.includes('fresh instance')) return 409;
if (lower.includes('not configured') || lower.includes('kv')) return 409;
@@ -849,7 +870,7 @@ export async function handleGetAdminBackupSettings(request: Request, env: Env, a
const storage = new StorageService(env.DB);
try {
const settings = await loadBackupSettings(storage, env, 'UTC');
return jsonResponse(settings);
return jsonResponse(redactBackupSettingsSecrets(settings));
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Backup settings could not be loaded', 409);
}
@@ -888,7 +909,7 @@ export async function handleUpdateAdminBackupSettings(request: Request, env: Env
destinationCount: next.destinations.length,
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
}, request);
return jsonResponse(next);
return jsonResponse(redactBackupSettingsSecrets(next));
}
export async function handleGetAdminBackupSettingsRepairState(request: Request, env: Env, actorUser: User): Promise<Response> {
@@ -941,7 +962,7 @@ export async function handleRepairAdminBackupSettings(request: Request, env: Env
destinationCount: next.destinations.length,
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
}, request);
return jsonResponse(next);
return jsonResponse(redactBackupSettingsSecrets(next));
}
export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
@@ -978,7 +999,7 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
provider: outcome.result.provider,
remotePath: outcome.result.remotePath,
},
settings: outcome.settings,
settings: redactBackupSettingsSecrets(outcome.settings),
});
} catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500);
@@ -1028,8 +1049,9 @@ export async function handleDownloadAdminRemoteBackup(request: Request, env: Env
status: 200,
headers: {
'Content-Type': remoteFile.contentType || 'application/zip',
'Content-Disposition': `attachment; filename="${remoteFile.fileName}"`,
'Content-Disposition': contentDispositionBackup(remoteFile.fileName),
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
},
});
} catch (error) {
@@ -1040,12 +1062,21 @@ export async function handleDownloadAdminRemoteBackup(request: Request, env: Env
export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
try {
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
} catch {
return errorResponse('Remote backup integrity payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB);
try {
const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url);
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const destination = requireBackupDestination(settings, body.destinationId || null);
const remoteFile = await downloadRemoteBackupFile(destination, path);
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
return jsonResponse({
@@ -1063,12 +1094,21 @@ export async function handleInspectAdminRemoteBackup(request: Request, env: Env,
export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
try {
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
} catch {
return errorResponse('Remote backup delete payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB);
try {
const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url);
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const destination = requireBackupDestination(settings, body.destinationId || null);
await deleteRemoteBackupFile(destination, path);
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
...getBackupDestinationSummary(destination),
@@ -1196,8 +1236,9 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${archive.fileName}"`,
'Content-Disposition': contentDispositionBackup(archive.fileName),
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
},
});
}
@@ -1207,7 +1248,28 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
try {
const url = new URL(request.url);
const blobName = ensureBackupBlobName(url.searchParams.get('blobName') || '');
let input: { blobName?: unknown; masterPasswordHash?: unknown } = {};
if (request.method === 'POST') {
try {
input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>();
} catch {
return errorResponse('Backup attachment download payload is invalid', 400);
}
} else {
input = {
blobName: url.searchParams.get('blobName') || '',
masterPasswordHash: url.searchParams.get('masterPasswordHash') || '',
};
}
const verificationError = await requireBackupUserVerification(
actorUser,
String(input.masterPasswordHash || ''),
env
);
if (verificationError) return verificationError;
const blobName = ensureBackupBlobName(String(input.blobName || ''));
const object = await getBlobObject(env, blobName);
if (!object) {
return errorResponse('Backup attachment blob not found', 404);
@@ -1228,6 +1290,15 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
export async function handleAdminImportBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
const contentType = request.headers.get('Content-Type') || '';
if (!contentType.includes('multipart/form-data')) {
return errorResponse('Content-Type must be multipart/form-data', 400);
}
const declaredSize = parseRequestContentLength(request);
if (declaredSize !== null && declaredSize > getMultipartRequestMaxBytes(MAX_BACKUP_ARCHIVE_BYTES)) {
return errorResponse(`Backup file too large. Maximum size is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))}MB`, 413);
}
let formData: FormData;
try {
formData = await request.formData();
@@ -1239,6 +1310,9 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) {
return errorResponse('Backup file is required', 400);
}
if ('size' in file && typeof (file as File).size === 'number' && (file as File).size > MAX_BACKUP_ARCHIVE_BYTES) {
return errorResponse(`Backup file too large. Maximum size is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))}MB`, 413);
}
const verificationError = await requireBackupUserVerification(actorUser, String(formData.get('masterPasswordHash') || ''), env);
if (verificationError) return verificationError;
+90 -2
View File
@@ -7,6 +7,9 @@ import {
CipherResponse,
CipherSecureNote,
CipherSshKey,
CipherBankAccount,
CipherDriversLicense,
CipherPassport,
Attachment,
PasswordHistory,
} from '../types';
@@ -254,6 +257,49 @@ function sanitizeEncryptedObject<T extends Record<string, any>>(
return next as T;
}
const BANK_ACCOUNT_ENCRYPTED_KEYS = [
'bankName',
'nameOnAccount',
'accountType',
'accountNumber',
'routingNumber',
'branchNumber',
'pin',
'swiftCode',
'iban',
'bankContactPhone',
] as const;
const DRIVERS_LICENSE_ENCRYPTED_KEYS = [
'firstName',
'middleName',
'lastName',
'dateOfBirth',
'licenseNumber',
'issuingCountry',
'issuingState',
'issueDate',
'expirationDate',
'issuingAuthority',
'licenseClass',
] as const;
const PASSPORT_ENCRYPTED_KEYS = [
'surname',
'givenName',
'dateOfBirth',
'sex',
'birthPlace',
'nationality',
'issuingCountry',
'passportNumber',
'passportType',
'nationalIdentificationNumber',
'issuingAuthority',
'issueDate',
'expirationDate',
] as const;
function normalizeCipherForStorage(cipher: Cipher): Cipher {
cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
@@ -376,6 +422,20 @@ export function validateCipherEncryptedFieldsForCompatibility(cipher: Cipher): s
if (fingerprint != null && !isValidEncString(fingerprint)) return 'SSH key fingerprint must be an encrypted string.';
}
const typedEncryptedObjects: Array<[string, any, readonly string[]]> = [
['Bank account', (cipher as any).bankAccount, BANK_ACCOUNT_ENCRYPTED_KEYS],
['Drivers license', (cipher as any).driversLicense, DRIVERS_LICENSE_ENCRYPTED_KEYS],
['Passport', (cipher as any).passport, PASSPORT_ENCRYPTED_KEYS],
];
for (const [label, source, keys] of typedEncryptedObjects) {
if (!source || typeof source !== 'object') continue;
for (const key of keys) {
if (source[key] != null && !optionalEncStringWithin(source[key], 10000)) {
return `${label} ${key} must be an encrypted string.`;
}
}
}
// Validate password history — each password must be an encrypted string.
if (Array.isArray(cipher.passwordHistory)) {
for (const entry of cipher.passwordHistory) {
@@ -752,7 +812,20 @@ export function cipherToResponse(
'licenseNumber',
]);
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
const normalizedSecureNote = Number(cipher.type) === 2
const normalizedBankAccount = sanitizeEncryptedObject(
(passthrough as any).bankAccount ?? null,
BANK_ACCOUNT_ENCRYPTED_KEYS
);
const normalizedDriversLicense = sanitizeEncryptedObject(
(passthrough as any).driversLicense ?? null,
DRIVERS_LICENSE_ENCRYPTED_KEYS
);
const normalizedPassport = sanitizeEncryptedObject(
(passthrough as any).passport ?? null,
PASSPORT_ENCRYPTED_KEYS
);
const responseType = Number(cipher.type) || 1;
const normalizedSecureNote = responseType === 2
? normalizeCipherSecureNoteForCompatibility((passthrough as any).secureNote ?? null) ?? { type: 0 }
: null;
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
@@ -763,7 +836,7 @@ export function cipherToResponse(
...passthrough,
// Server-computed / enforced fields (always override)
folderId: normalizeResponseFolderId(cipher.folderId, options.validFolderIds),
type: Number(cipher.type) || 1,
type: responseType,
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
creationDate: createdAt,
@@ -785,6 +858,9 @@ export function cipherToResponse(
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
sshKey: normalizedSshKey,
bankAccount: responseType === 6 ? normalizedBankAccount : null,
driversLicense: responseType === 7 ? normalizedDriversLicense : null,
passport: responseType === 8 ? normalizedPassport : null,
key: responseCipherKey,
data: typeof (passthrough as any).data === 'string' ? (passthrough as any).data : null,
encryptedFor: (passthrough as any).encryptedFor ?? null,
@@ -880,6 +956,9 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
const createIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
const createSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const createBankAccount = readCipherProp<CipherBankAccount | null>(cipherData, ['bankAccount', 'BankAccount']);
const createDriversLicense = readCipherProp<CipherDriversLicense | null>(cipherData, ['driversLicense', 'DriversLicense']);
const createPassport = readCipherProp<CipherPassport | null>(cipherData, ['passport', 'Passport']);
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
if (createKey.present && !shouldAcceptCipherKey(createKey.value)) {
@@ -909,6 +988,9 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null);
cipher.sshKey = createSshKey.present ? (createSshKey.value ?? null) : (cipher.sshKey ?? null);
cipher.bankAccount = createBankAccount.present ? (createBankAccount.value ?? null) : ((cipher as any).bankAccount ?? null);
cipher.driversLicense = createDriversLicense.present ? (createDriversLicense.value ?? null) : ((cipher as any).driversLicense ?? null);
cipher.passport = createPassport.present ? (createPassport.value ?? null) : ((cipher as any).passport ?? null);
cipher.passwordHistory = createPasswordHistory.present ? (createPasswordHistory.value ?? null) : (cipher.passwordHistory ?? null);
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
@@ -960,6 +1042,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
const incomingIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
const incomingSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const incomingBankAccount = readCipherProp<CipherBankAccount | null>(cipherData, ['bankAccount', 'BankAccount']);
const incomingDriversLicense = readCipherProp<CipherDriversLicense | null>(cipherData, ['driversLicense', 'DriversLicense']);
const incomingPassport = readCipherProp<CipherPassport | null>(cipherData, ['passport', 'Passport']);
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
const incomingRevisionDate = readCipherRevisionDate(cipherData);
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData);
@@ -1008,6 +1093,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
cipher.card = nextType === 3 ? (incomingCard.present ? (incomingCard.value ?? null) : (existingCipher.card ?? null)) : null;
cipher.identity = nextType === 4 ? (incomingIdentity.present ? (incomingIdentity.value ?? null) : (existingCipher.identity ?? null)) : null;
cipher.sshKey = nextType === 5 ? (incomingSshKey.present ? (incomingSshKey.value ?? null) : (existingCipher.sshKey ?? null)) : null;
cipher.bankAccount = nextType === 6 ? (incomingBankAccount.present ? (incomingBankAccount.value ?? null) : ((existingCipher as any).bankAccount ?? null)) : null;
cipher.driversLicense = nextType === 7 ? (incomingDriversLicense.present ? (incomingDriversLicense.value ?? null) : ((existingCipher as any).driversLicense ?? null)) : null;
cipher.passport = nextType === 8 ? (incomingPassport.present ? (incomingPassport.value ?? null) : ((existingCipher as any).passport ?? null)) : null;
if (incomingPasswordHistory.present) {
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
}
+96 -2
View File
@@ -6,7 +6,7 @@ import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'
import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay';
import { StorageService } from '../services/storage';
import { errorResponse, jsonResponse } from '../utils/response';
import { readKnownDeviceProbe } from '../utils/device';
import { readAuthRequestDeviceInfo, readKnownDeviceProbe } from '../utils/device';
import { generateUUID } from '../utils/uuid';
const PERMANENT_TRUST_EXPIRES_AT_MS = Date.UTC(2099, 11, 31, 23, 59, 59);
@@ -125,6 +125,85 @@ function parseDeviceName(value: unknown): string {
return String(value || '').trim().slice(0, 128);
}
function parseDeviceType(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value));
const parsed = Number.parseInt(String(value ?? ''), 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}
// POST /api/devices
export async function handleRegisterDevice(request: Request, env: Env, userId: string): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
const identifier = normalizeIdentifier(body.identifier ?? body.Identifier ?? body.deviceIdentifier ?? body.DeviceIdentifier);
const name = parseDeviceName(body.name ?? body.Name ?? body.deviceName ?? body.DeviceName) || 'Unknown device';
const type = parseDeviceType(body.type ?? body.Type ?? body.deviceType ?? body.DeviceType);
if (!identifier || type == null) return errorResponse('Device identifier and type are required', 400);
const storage = new StorageService(env.DB);
await storage.upsertDevice(userId, identifier, name, type, undefined, parseKeysBody(body));
const pushToken = String(body.pushToken ?? body.PushToken ?? '').trim();
if (pushToken) {
const device = await storage.getDevice(userId, identifier);
const pushUuid = device?.pushUuid || generateUUID();
const updated = await storage.updateDevicePushToken(userId, identifier, pushUuid, pushToken);
if (updated) {
await registerMobilePushDevice(env, {
userId,
deviceIdentifier: identifier,
type,
pushUuid,
pushToken,
});
}
}
const device = await storage.getDevice(userId, identifier);
if (!device) return errorResponse('Device registration failed', 500);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.register',
category: 'device',
level: 'info',
targetType: 'device',
targetId: identifier,
metadata: auditRequestMetadata(request),
});
return jsonResponse(buildDeviceResponse(device));
}
// POST /api/devices/lost-trust
export async function handleReportLostTrust(request: Request, env: Env, userId: string): Promise<Response> {
const body = await readJsonBody(request) || {};
const deviceInfo = readAuthRequestDeviceInfo(
{
deviceIdentifier: String(body.identifier ?? body.Identifier ?? body.deviceIdentifier ?? body.DeviceIdentifier ?? ''),
deviceName: String(body.name ?? body.Name ?? body.deviceName ?? body.DeviceName ?? ''),
deviceType: String(body.type ?? body.Type ?? body.deviceType ?? body.DeviceType ?? ''),
},
request
);
if (!deviceInfo.deviceIdentifier) return errorResponse('Please provide a device identifier', 400);
const storage = new StorageService(env.DB);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.lost_trust',
category: 'device',
level: 'warn',
targetType: 'device',
targetId: deviceInfo.deviceIdentifier,
metadata: {
deviceIdentifier: deviceInfo.deviceIdentifier,
deviceType: deviceInfo.deviceType,
...auditRequestMetadata(request),
},
});
return new Response(null, { status: 200 });
}
// GET /api/devices/knowndevice
// Compatible with Bitwarden/Vaultwarden behavior:
// - X-Request-Email: base64url(email) without padding
@@ -385,11 +464,26 @@ export async function handleUpdateDeviceName(
// DELETE /api/devices
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let masterPasswordHash = '';
try {
const body = await request.json() as { masterPasswordHash?: string };
masterPasswordHash = String(body?.masterPasswordHash || '').trim();
} catch {
masterPasswordHash = '';
}
if (!masterPasswordHash) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const passwordValid = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
if (!passwordValid) {
return errorResponse('Invalid password', 400);
}
const [removedTrusted, removedSessions, removedDevices] = await Promise.all([
storage.deleteTrustedTwoFactorTokensByUserId(userId),
storage.deleteRefreshTokensByUserId(userId),
+48 -4
View File
@@ -1,19 +1,46 @@
const EMPTY_FORMS_FILENAME = 'forms.v1.json';
const EMPTY_FORMS_SCHEMA_FILENAME = 'forms.v1.schema.json';
const EMPTY_FORMS_CID = 'sha256:189fa7c9bcf8951e65c18b5d9feacf74a5223c75e01667c4235388cbc67091fe';
const EMPTY_FORMS_BODY = JSON.stringify({
schemaVersion: '1.0.0',
hosts: {},
});
const EMPTY_FORMS_SCHEMA_BODY = JSON.stringify({
$schema: 'https://json-schema.org/draft/2020-12/schema',
title: 'Bitwarden Fill Assist Forms v1',
type: 'object',
required: ['schemaVersion', 'hosts'],
properties: {
schemaVersion: { type: 'string' },
hosts: { type: 'object' },
},
additionalProperties: true,
});
const EMPTY_MANIFEST_BODY = JSON.stringify({
buildId: 'nodewarden-empty-fill-assist-v1',
timestamp: '2026-07-06T00:00:00.000Z',
gitSha: 'nodewarden',
maps: {
forms: {
v1: {
filename: EMPTY_FORMS_FILENAME,
cid: 'sha256:nodewarden-empty-fill-assist-v1',
cid: EMPTY_FORMS_CID,
schema: EMPTY_FORMS_SCHEMA_FILENAME,
deprecated: false,
},
},
},
});
const DIGITAL_ASSET_LINK_CHECK_BODY = JSON.stringify({
linked: false,
maxAge: '86400s',
debugString: 'No matching digital asset link policy is configured for this server.',
});
function fillAssistJsonResponse(body: string): Response {
return new Response(body, {
status: 200,
@@ -24,13 +51,30 @@ function fillAssistJsonResponse(body: string): Response {
});
}
function normalizeFilename(filename: string): string {
const raw = String(filename || '').trim();
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
export function handleFillAssistManifest(): Response {
return fillAssistJsonResponse(EMPTY_MANIFEST_BODY);
}
export function handleFillAssistForms(filename: string): Response {
if (String(filename || '').trim() !== EMPTY_FORMS_FILENAME) {
return new Response('Not found', { status: 404 });
const normalized = normalizeFilename(filename);
if (normalized === EMPTY_FORMS_FILENAME) {
return fillAssistJsonResponse(EMPTY_FORMS_BODY);
}
return fillAssistJsonResponse(EMPTY_FORMS_BODY);
if (normalized === EMPTY_FORMS_SCHEMA_FILENAME) {
return fillAssistJsonResponse(EMPTY_FORMS_SCHEMA_BODY);
}
return new Response('Not found', { status: 404 });
}
export function handleDigitalAssetLinkCheck(): Response {
return fillAssistJsonResponse(DIGITAL_ASSET_LINK_CHECK_BODY);
}
+45 -6
View File
@@ -122,6 +122,16 @@ function readBodyValue(body: Record<string, string>, names: string[]): string |
return undefined;
}
async function sha256Hex(value: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
}
async function loginRateLimitKey(clientIdentifier: string, grantType: string, subject: string): Promise<string> {
const subjectHash = await sha256Hex(`${grantType}:${String(subject || '').trim() || 'unknown'}`);
return `${clientIdentifier}:login:${grantType}:${subjectHash}`;
}
async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise<YubicoApiCredentials | null> {
const fromEnv = yubicoCredentialsFromEnv(env);
if (fromEnv) return fromEnv;
@@ -163,6 +173,30 @@ function withWebRefreshCookie(request: Request, response: Response, refreshToken
});
}
async function revokePresentedAccessTokenSession(request: Request, env: Env, storage: StorageService): Promise<void> {
const authHeader = request.headers.get('Authorization');
if (!authHeader) return;
const auth = new AuthService(env);
const verified = await auth.verifyAccessTokenWithUser(authHeader);
if (!verified) return;
const deviceIdentifier = String(verified.payload.did || '').trim();
if (deviceIdentifier) {
const nextSessionStamp = generateUUID();
await storage.rotateDeviceSessionStamp(verified.user.id, deviceIdentifier, nextSessionStamp);
await storage.deleteRefreshTokensByDevice(verified.user.id, deviceIdentifier);
AuthService.invalidateDeviceCache(verified.user.id, deviceIdentifier);
return;
}
verified.user.securityStamp = generateUUID();
verified.user.updatedAt = new Date().toISOString();
await storage.saveUser(verified.user);
await storage.deleteRefreshTokensByUserId(verified.user.id);
AuthService.invalidateUserCache(verified.user.id);
}
function buildPreloginResponse(
email: string,
kdfType: number,
@@ -319,13 +353,13 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const twoFactorToken = readBodyValue(body, ['twoFactorToken', 'TwoFactorToken']);
const twoFactorProvider = readBodyValue(body, ['twoFactorProvider', 'TwoFactorProvider']);
const twoFactorRemember = readBodyValue(body, ['twoFactorRemember', 'TwoFactorRemember']);
const loginIdentifier = clientIdentifier;
const deviceInfo = readAuthRequestDeviceInfo(body, request);
if (!email || !passwordHash) {
// Bitwarden clients expect OAuth-style error fields.
return identityErrorResponse('Email and password are required', 'invalid_request', 400);
}
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, email);
// Check login lockout before user lookup to reduce user-enumeration signal
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
@@ -584,7 +618,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
: baseResponse;
} else if (grantType === 'webauthn') {
const loginIdentifier = clientIdentifier;
const token = String(body.token || '').trim();
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, token || 'missing-token');
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
if (!loginCheck.allowed) {
return identityErrorResponse(
@@ -594,7 +629,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
);
}
const token = String(body.token || '').trim();
let deviceResponse: unknown = body.deviceResponse;
if (typeof deviceResponse === 'string') {
try {
@@ -712,11 +746,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const scope = body.scope;
const deviceInfo = readAuthRequestDeviceInfo(body, request);
const loginIdentifier = clientIdentifier;
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
if (!parmValid) {
return identityErrorResponse('Parameter error', 'invalid_request', 400);
}
const uid = clientId.slice(5);
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, uid);
// Check login lockout before user lookup to reduce user-enumeration signal
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
@@ -728,7 +763,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
);
}
const uid = clientId.slice(5);
const user = await storage.getUserById(uid);
if (!user) {
await rateLimit.recordFailedLogin(loginIdentifier);
@@ -871,7 +905,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
passwordHashB64,
password,
rateLimit,
`${clientIdentifier}:send-password`
clientIdentifier
);
if ('error' in result) {
return result.error;
@@ -1010,6 +1044,11 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
// RFC 7009 allows returning 200 even if token is unknown.
export async function handleRevocation(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
try {
await revokePresentedAccessTokenSession(request, env, storage);
} catch {
// RFC 7009 revocation is best-effort and should not reveal token state.
}
let body: Record<string, string>;
const contentType = request.headers.get('content-type') || '';
+27 -7
View File
@@ -17,6 +17,9 @@ interface CiphersImportRequest {
favorite?: boolean;
reprompt?: number;
sshKey?: any | null;
bankAccount?: any | null;
driversLicense?: any | null;
passport?: any | null;
key?: string | null;
login?: {
uris?: Array<{ uri: string | null; uriChecksum?: string | null; match?: number | null }> | null;
@@ -92,6 +95,12 @@ function readAliasedImportProp<T = unknown>(source: any, aliases: string[]): T |
return undefined;
}
function normalizeOptionalId(value: unknown): string | null {
if (value == null) return null;
const normalized = String(value).trim();
return normalized ? normalized : null;
}
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
for (let i = 0; i < statements.length; i += chunkSize) {
const chunk = statements.slice(i, i + chunkSize);
@@ -112,9 +121,9 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
return errorResponse('Invalid JSON', 400);
}
const folders = importData.folders || [];
const ciphers = importData.ciphers || [];
const folderRelationships = importData.folderRelationships || [];
const folders = Array.isArray(importData.folders) ? importData.folders : [];
const ciphers = Array.isArray(importData.ciphers) ? importData.ciphers : [];
const folderRelationships = Array.isArray(importData.folderRelationships) ? importData.folderRelationships : [];
if (folders.length + ciphers.length > LIMITS.performance.importItemLimit) {
return errorResponse(`Import exceeds maximum of ${LIMITS.performance.importItemLimit} items`, 400);
@@ -128,13 +137,14 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
const folderRows: Folder[] = [];
for (let i = 0; i < folders.length; i++) {
const importedFolder = folders[i] && typeof folders[i] === 'object' ? folders[i] : null;
const folderId = generateUUID();
folderIdMap.set(i, folderId);
const folder: Folder = {
id: folderId,
userId: userId,
name: folders[i].name,
name: typeof importedFolder?.name === 'string' && importedFolder.name ? importedFolder.name : 'Folder',
createdAt: now,
updatedAt: now,
};
@@ -157,24 +167,31 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
// Build cipher index -> folder id mapping from relationships
const cipherFolderMap = new Map<number, string>();
for (const rel of folderRelationships) {
if (!rel || typeof rel !== 'object') continue;
const folderId = folderIdMap.get(rel.value);
if (folderId) {
cipherFolderMap.set(rel.key, folderId);
}
}
const existingFolderIds = new Set((await storage.getAllFolders(userId)).map((folder) => folder.id));
// Create ciphers
const cipherRows: Cipher[] = [];
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
for (let i = 0; i < ciphers.length; i++) {
const c = ciphers[i];
const folderId = cipherFolderMap.get(i) || readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']) || null;
const c = ciphers[i] && typeof ciphers[i] === 'object' ? ciphers[i] : {} as CiphersImportRequest['ciphers'][number];
const importedFolderId = normalizeOptionalId(readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']));
const folderId = cipherFolderMap.get(i) || (importedFolderId && existingFolderIds.has(importedFolderId) ? importedFolderId : null);
const sourceIdRaw = String(c?.id ?? '').trim();
const sourceId = sourceIdRaw || null;
const login = readAliasedImportProp<any | null>(c, ['login', 'Login']);
const card = readAliasedImportProp<any | null>(c, ['card', 'Card']);
const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']);
const secureNote = readAliasedImportProp<any | null>(c, ['secureNote', 'SecureNote']);
const sshKey = readAliasedImportProp<any | null>(c, ['sshKey', 'SshKey']);
const bankAccount = readAliasedImportProp<any | null>(c, ['bankAccount', 'BankAccount']);
const driversLicense = readAliasedImportProp<any | null>(c, ['driversLicense', 'DriversLicense']);
const passport = readAliasedImportProp<any | null>(c, ['passport', 'Passport']);
const fields = readAliasedImportProp<any[] | null>(c, ['fields', 'Fields']);
const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']);
const key = readAliasedImportProp<string | null>(c, ['key', 'Key']);
@@ -244,7 +261,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
})) || null,
passwordHistory: passwordHistory ?? null,
reprompt: c.reprompt ?? 0,
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
sshKey: normalizeCipherSshKeyForCompatibility(sshKey ?? null),
bankAccount: bankAccount ?? null,
driversLicense: driversLicense ?? null,
passport: passport ?? null,
key: key ?? null,
createdAt: now,
updatedAt: now,
+8
View File
@@ -1,4 +1,6 @@
import { AuthService } from '../services/auth';
import { StorageService } from '../services/storage';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import type { Env, JWTPayload } from '../types';
import { errorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
@@ -65,6 +67,12 @@ export async function handleAnonymousNotificationsHub(request: Request, env: Env
return errorResponse('Expected websocket', 426);
}
const storage = new StorageService(env.DB);
const authRequest = await storage.getAuthRequestById(authRequestId);
if (!authRequest || isAuthRequestExpired(authRequest)) {
return errorResponse('Not found', 404);
}
const id = env.NOTIFICATIONS_HUB.idFromName(authRequestId);
const stub = env.NOTIFICATIONS_HUB.get(id);
const forwardedUrl = new URL(request.url);
+29 -5
View File
@@ -8,6 +8,7 @@ import { LIMITS } from '../config/limits';
import {
getBlobStorageMaxBytes,
getSendFileObjectKey,
getBlobObject,
putBlobObject,
deleteBlobObject,
} from '../services/blob-store';
@@ -34,6 +35,8 @@ import {
} from './sends-shared';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
const SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE = 'Send email verification is not supported by this server.';
async function writeSendAudit(
storage: StorageService,
request: Request,
@@ -82,8 +85,13 @@ async function processSendFileUpload(
return upload;
}
const path = getSendFileObjectKey(send.id, fileId);
if (await getBlobObject(env, path)) {
return errorResponse('Send file has already been uploaded', 409);
}
try {
await putBlobObject(env, getSendFileObjectKey(send.id, fileId), upload.body, {
await putBlobObject(env, path, upload.body, {
size: upload.size,
contentType: upload.contentType,
customMetadata: {
@@ -210,11 +218,17 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
if (authTypeRaw.present && requestedAuthType === null) {
return errorResponse('Invalid authType', 400);
}
if (requestedAuthType === SendAuthType.Email) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
const normalizedEmails = normalizeEmails(emailsRaw.value);
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
return errorResponse('Invalid emails', 400);
}
if (normalizedEmails) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
const now = new Date().toISOString();
const send: Send = {
@@ -334,11 +348,17 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
if (authTypeRaw.present && requestedAuthType === null) {
return errorResponse('Invalid authType', 400);
}
if (requestedAuthType === SendAuthType.Email) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
const normalizedEmails = normalizeEmails(emailsRaw.value);
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
return errorResponse('Invalid emails', 400);
}
if (normalizedEmails) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
const now = new Date().toISOString();
const send: Send = {
@@ -592,10 +612,11 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
if (parsedAuthType === null) {
return errorResponse('Invalid authType', 400);
}
send.authType = parsedAuthType;
if (parsedAuthType !== SendAuthType.Email) {
send.emails = null;
if (parsedAuthType === SendAuthType.Email) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
send.authType = parsedAuthType;
send.emails = null;
}
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
@@ -604,10 +625,13 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
if (emailsRaw.value !== null && normalizedEmails === null) {
return errorResponse('Invalid emails', 400);
}
if (normalizedEmails) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
send.emails = normalizedEmails;
if (send.emails) {
send.authType = SendAuthType.Email;
} else if (send.authType === SendAuthType.Email) {
} else if (Number(send.authType) === SendAuthType.Email) {
send.authType = SendAuthType.None;
}
}
+21 -10
View File
@@ -68,7 +68,7 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str
if (!clientIdentifier) {
return errorResponse('Client IP is required', 403);
}
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier, send.id);
sendPasswordRateLimit = new RateLimitService(env.DB);
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
if (!sendPasswordCheck.allowed) {
@@ -142,7 +142,7 @@ export async function handleAccessSendFile(
if (!clientIdentifier) {
return errorResponse('Client IP is required', 403);
}
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier, send.id);
sendPasswordRateLimit = new RateLimitService(env.DB);
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
if (!sendPasswordCheck.allowed) {
@@ -290,19 +290,27 @@ export async function handleDownloadSendFile(
}
const storage = new StorageService(env.DB);
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
if (!object) {
return errorResponse('Send file not found', 404);
}
const send = await storage.getSend(sendId);
const data = send ? parseStoredSendData(send) : {};
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
if (!send || !isSendAvailable(send) || send.type !== SendType.File) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
const data = parseStoredSendData(send);
const expectedFileId = typeof data.id === 'string' ? data.id : null;
if (!expectedFileId || expectedFileId !== fileId) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
if (!firstUse) {
return errorResponse('Invalid or expired token', 401);
}
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
if (!object) {
return errorResponse('Send file not found', 404);
}
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
return new Response(object.body, {
headers: {
'Content-Type': sanitizeDownloadContentType(object.contentType),
@@ -320,7 +328,7 @@ export async function issueSendAccessToken(
passwordHashB64?: string | null,
password?: string | null,
rateLimit?: RateLimitService,
sendPasswordLimitIpKey?: string
clientIdentifier?: string
): Promise<{ token: string } | { error: Response }> {
const jwt = getSafeJwtSecret(env);
if (!jwt.ok) {
@@ -360,11 +368,14 @@ export async function issueSendAccessToken(
Object: 'error',
},
},
400
501
),
};
}
const sendPasswordLimitIpKey =
rateLimit && clientIdentifier ? sendPasswordLimitKey(clientIdentifier, send.id) : null;
if (send.passwordHash) {
if (rateLimit && sendPasswordLimitIpKey) {
const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
+16 -4
View File
@@ -155,7 +155,15 @@ export function formatSize(bytes: number): string {
export function parseDate(raw: unknown): Date | null {
if (typeof raw !== 'string' || !raw.trim()) return null;
const date = new Date(raw);
let value = raw.trim();
if (!/[zZ]$/.test(value) && !/[+\-]\d{2}:?\d{2}$/.test(value)) {
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)) {
value += 'Z';
} else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(value)) {
value = value.replace(' ', 'T') + 'Z';
}
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date;
}
@@ -434,8 +442,8 @@ export type PublicSendAccessValidationResult =
| { ok: true }
| { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' };
export function sendPasswordLimitKey(clientIdentifier: string): string {
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}`;
export function sendPasswordLimitKey(clientIdentifier: string, sendId: string): string {
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}:${String(sendId || '').trim() || 'unknown-send'}`;
}
function sendPasswordLockMessage(retryAfterSeconds: number): string {
@@ -464,7 +472,11 @@ export function sendPasswordLockedOAuthResponse(retryAfterSeconds: number): Resp
export async function validatePublicSendAccess(send: Send, body: unknown): Promise<PublicSendAccessValidationResult> {
if (hasEmailAuth(send)) {
return { ok: false, response: errorResponse(SEND_INACCESSIBLE_MSG, 404), reason: 'email_auth_unsupported' };
return {
ok: false,
response: errorResponse('Send email verification is not supported by this server.', 501),
reason: 'email_auth_unsupported',
};
}
if (!send.passwordHash) return { ok: true };
+6 -3
View File
@@ -24,8 +24,11 @@ function isWorkerHandledPath(path: string): boolean {
path.startsWith('/api/') ||
path.startsWith('/identity/') ||
path.startsWith('/icons/') ||
path.startsWith('/fill-assist/') ||
path.startsWith('/notifications/') ||
path.startsWith('/.well-known/') ||
path === '/v1/assetlinks:check' ||
path === '/web-bootstrap' ||
path === '/config' ||
path === '/api/config' ||
path === '/api/version'
@@ -89,7 +92,7 @@ export default {
const normalizedRequest = normalizeRequestUrl(request);
const assetResponse = await maybeServeAsset(normalizedRequest, env);
if (assetResponse) {
return applyCors(normalizedRequest, assetResponse);
return applyCors(normalizedRequest, assetResponse, env);
}
await ensureDatabaseInitialized(env);
@@ -107,11 +110,11 @@ export default {
},
500
);
return applyCors(normalizedRequest, resp);
return applyCors(normalizedRequest, resp, env);
}
const resp = await handleRequest(normalizedRequest, env);
return applyCors(normalizedRequest, resp);
return applyCors(normalizedRequest, resp, env);
},
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
+2 -2
View File
@@ -26,7 +26,7 @@ export async function handleAdminBackupRoute(
return handleAdminExportBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/blob' && method === 'GET') {
if (path === '/api/admin/backup/blob' && (method === 'GET' || method === 'POST')) {
return handleDownloadAdminBackupAttachment(request, env, actorUser);
}
@@ -54,7 +54,7 @@ export async function handleAdminBackupRoute(
return handleDownloadAdminRemoteBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/remote/integrity' && method === 'GET') {
if (path === '/api/admin/backup/remote/integrity' && method === 'POST') {
return handleInspectAdminRemoteBackup(request, env, actorUser);
}
+24
View File
@@ -13,6 +13,23 @@ import {
handleAdminClearAuditLogs,
} from './handlers/admin';
import { handleAdminBackupRoute } from './router-admin-backup';
import { errorResponse } from './utils/response';
function isKnownAdminPath(path: string): boolean {
return (
path === '/api/admin/users' ||
path === '/api/admin/logs' ||
path === '/api/admin/logs/settings' ||
path === '/api/admin/invites' ||
path.startsWith('/api/admin/backup') ||
/^\/api\/admin\/invites\/[^/]+$/i.test(path) ||
/^\/api\/admin\/users\/[a-f0-9-]+(?:\/status)?$/i.test(path)
);
}
function isActiveAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active';
}
export async function handleAdminRoute(
request: Request,
@@ -21,6 +38,13 @@ export async function handleAdminRoute(
path: string,
method: string
): Promise<Response | null> {
if (!isKnownAdminPath(path)) {
return null;
}
if (!isActiveAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
if (path === '/api/admin/users' && method === 'GET') {
return handleAdminListUsers(request, env, actorUser);
}
+55 -4
View File
@@ -1,5 +1,5 @@
import type { Env, User } from './types';
import { errorResponse, jsonResponse } from './utils/response';
import { errorResponse, jsonResponse, unsupportedResponse } from './utils/response';
import {
handleGetProfile,
handleUpdateProfile,
@@ -19,6 +19,8 @@ import {
handlePutTwoFactorYubiKey,
handlePutTwoFactorYubiKeyConfig,
handleBootstrapTwoFactorYubiKeyConfig,
handleGetDeviceVerificationSettings,
handlePutDeviceVerificationSettings,
handleDisableTwoFactorProvider,
handleGetApiKey,
handleRotateApiKey,
@@ -88,6 +90,7 @@ import {
handleUpdateAccountPasskeyEncryption,
} from './handlers/account-passkeys';
import {
handleCreateAdminAuthRequest,
handleGetAuthRequest,
handleListAuthRequests,
handleListPendingAuthRequests,
@@ -114,6 +117,40 @@ export async function handleAuthenticatedRoute(
}
}
if ((path === '/api/accounts/kdf' || path === '/accounts/kdf') && (method === 'POST' || method === 'PUT')) {
return unsupportedResponse('KDF changes are not supported by this server.');
}
const mailBackedAccountPaths = new Set([
'/api/accounts/email-token',
'/accounts/email-token',
'/api/accounts/verify-email',
'/accounts/verify-email',
'/api/accounts/verify-email-token',
'/accounts/verify-email-token',
'/api/accounts/request-otp',
'/accounts/request-otp',
'/api/accounts/verify-otp',
'/accounts/verify-otp',
]);
if (mailBackedAccountPaths.has(path) && (method === 'POST' || method === 'PUT')) {
return unsupportedResponse('Email delivery is not supported by this server.');
}
const emailTwoFactorPaths = new Set([
'/api/two-factor/get-email',
'/two-factor/get-email',
'/api/two-factor/send-email',
'/two-factor/send-email',
'/api/two-factor/send-email-login',
'/two-factor/send-email-login',
'/api/two-factor/email',
'/two-factor/email',
]);
if (emailTwoFactorPaths.has(path) && (method === 'POST' || method === 'PUT' || method === 'DELETE')) {
return unsupportedResponse('Email two-step login is not supported by this server.');
}
if (path === '/api/accounts/profile') {
if (method === 'GET') return handleGetProfile(request, env, userId);
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
@@ -153,6 +190,15 @@ export async function handleAuthenticatedRoute(
return handleGetTwoFactorYubiKey(request, env, userId);
}
if (path === '/api/two-factor/get-device-verification-settings' && method === 'POST') {
return handleGetDeviceVerificationSettings(request, env, userId);
}
if (path === '/api/two-factor/device-verification-settings') {
if (method === 'PUT' || method === 'POST') return handlePutDeviceVerificationSettings(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if (path === '/api/two-factor/get-webauthn' && method === 'POST') {
return handleGetTwoFactorWebAuthn(request, env, userId, currentUser);
}
@@ -334,17 +380,22 @@ export async function handleAuthenticatedRoute(
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
}
if (path === '/api/auth-requests' || path === '/api/auth-requests/') {
if (path === '/api/auth-requests' || path === '/api/auth-requests/' || path === '/auth-requests' || path === '/auth-requests/') {
if (method === 'GET') return handleListAuthRequests(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if (path === '/api/auth-requests/pending') {
if (path === '/api/auth-requests/pending' || path === '/auth-requests/pending') {
if (method === 'GET') return handleListPendingAuthRequests(request, env, userId);
return errorResponse('Method not allowed', 405);
}
const authRequestMatch = path.match(/^\/api\/auth-requests\/([a-f0-9-]+)$/i);
if (path === '/api/auth-requests/admin-request' || path === '/auth-requests/admin-request') {
if (method === 'POST') return handleCreateAdminAuthRequest(request, env, userId, currentUser.email);
return errorResponse('Method not allowed', 405);
}
const authRequestMatch = path.match(/^\/(?:api\/)?auth-requests\/([a-f0-9-]+)$/i);
if (authRequestMatch) {
if (method === 'GET') return handleGetAuthRequest(request, env, userId, authRequestMatch[1]);
if (method === 'PUT') return handleUpdateAuthRequest(request, env, userId, authRequestMatch[1]);
+7
View File
@@ -18,6 +18,8 @@ import {
handleUpdateDeviceToken,
handleUpdateDeviceWebPushAuth,
handleClearDeviceToken,
handleRegisterDevice,
handleReportLostTrust,
} from './handlers/devices';
function devicesPath(pattern: string): RegExp {
@@ -33,10 +35,15 @@ export async function handleAuthenticatedDeviceRoute(
): Promise<Response | null> {
if (path === '/api/devices' || path === '/devices') {
if (method === 'GET') return handleGetDevices(request, env, userId);
if (method === 'POST') return handleRegisterDevice(request, env, userId);
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
return null;
}
if ((path === '/api/devices/lost-trust' || path === '/devices/lost-trust') && method === 'POST') {
return handleReportLostTrust(request, env, userId);
}
if (path === '/api/devices/authorized' || path === '/devices/authorized') {
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
+57 -7
View File
@@ -7,7 +7,11 @@ import {
handleDownloadSendFile,
} from './handlers/sends';
import { handleKnownDevice } from './handlers/devices';
import { handleFillAssistForms, handleFillAssistManifest } from './handlers/fill-assist';
import {
handleDigitalAssetLinkCheck,
handleFillAssistForms,
handleFillAssistManifest,
} from './handlers/fill-assist';
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys';
import {
@@ -28,9 +32,10 @@ import {
} from './handlers/notifications';
import { handlePublicUploadSendFile } from './handlers/sends';
import { isSafeWebsiteIconContentType } from './utils/content-type';
import { jsonResponse } from './utils/response';
import { jsonResponse, unsupportedResponse } from './utils/response';
import { StorageService } from './services/storage';
import type { Env } from './types';
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
type JwtUnsafeReason = 'missing' | 'too_short' | null;
@@ -40,6 +45,12 @@ export interface WebBootstrapResponse {
jwtUnsafeReason: JwtUnsafeReason;
jwtSecretMinLength: number;
registrationInviteRequired: boolean;
webAuthnAllowedOrigins: string[];
websiteIconsEnabled: boolean;
}
function isWebsiteIconProxyEnabled(env: Env): boolean {
return true;
}
function isSameOriginWriteRequest(request: Request): boolean {
@@ -97,7 +108,7 @@ function buildIconServiceCsp(origin: string): string {
}
function buildConfigResponse(origin: string) {
const fillAssistBase = `${origin}/fill-assist`;
const fillAssistBase = `${origin}/fill-assist/`;
return {
version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden',
@@ -251,7 +262,11 @@ function iconResponse(body: BodyInit | null, contentType: string | null): Respon
});
}
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
async function handleWebsiteIcon(env: Env, host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
if (!isWebsiteIconProxyEnabled(env)) {
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
}
const normalizedHost = normalizeIconHost(host);
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
@@ -318,6 +333,8 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
jwtUnsafeReason,
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
registrationInviteRequired: userCount > 0,
webAuthnAllowedOrigins: getConfiguredWebAuthnAllowedOrigins(env),
websiteIconsEnabled: isWebsiteIconProxyEnabled(env),
};
}
@@ -350,6 +367,12 @@ export async function handlePublicRoute(
return handleFillAssistManifest();
}
if ((path === '/v1/assetlinks:check' || path === '/api/v1/assetlinks:check') && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return handleDigitalAssetLinkCheck();
}
const fillAssistFormsMatch = path.match(/^\/fill-assist\/([^/]+)$/i);
if (fillAssistFormsMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
@@ -362,7 +385,7 @@ export async function handlePublicRoute(
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';
return handleWebsiteIcon(iconMatch[1], fallbackMode);
return handleWebsiteIcon(env, iconMatch[1], fallbackMode);
}
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
@@ -412,13 +435,13 @@ export async function handlePublicRoute(
return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]);
}
if ((path === '/api/auth-requests' || path === '/api/auth-requests/') && method === 'POST') {
if ((path === '/api/auth-requests' || path === '/api/auth-requests/' || path === '/auth-requests' || path === '/auth-requests/') && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleCreateAuthRequest(request, env);
}
const authRequestResponseMatch = path.match(/^\/api\/auth-requests\/([a-f0-9-]+)\/response$/i);
const authRequestResponseMatch = path.match(/^\/(?:api\/)?auth-requests\/([a-f0-9-]+)\/response$/i);
if (authRequestResponseMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
@@ -465,9 +488,34 @@ export async function handlePublicRoute(
}
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleRecoverTwoFactor(request, env);
}
const publicMailBackedPaths = new Set([
'/api/accounts/resend-new-device-otp',
'/accounts/resend-new-device-otp',
'/api/accounts/register/send-verification-email',
'/accounts/register/send-verification-email',
'/identity/accounts/register/send-verification-email',
'/api/accounts/register/verification-email-clicked',
'/accounts/register/verification-email-clicked',
'/identity/accounts/register/verification-email-clicked',
'/api/accounts/register/finish',
'/accounts/register/finish',
'/identity/accounts/register/finish',
'/api/accounts/verify-email-token',
'/accounts/verify-email-token',
'/api/two-factor/send-email-login',
'/two-factor/send-email-login',
]);
if (publicMailBackedPaths.has(path) && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return unsupportedResponse('Email delivery is not supported by this server.');
}
if (path === '/api/accounts/password-hint' && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
@@ -514,6 +562,8 @@ export async function handlePublicRoute(
}
if (path === '/notifications/anonymous-hub' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleAnonymousNotificationsHub(request, env);
}
return null;
+74 -11
View File
@@ -20,6 +20,7 @@ function canServeWithUnsafeJwtSecret(path: string, method: string): boolean {
if (method === 'GET' && path === '/.well-known/appspecific/com.chrome.devtools.json') return true;
if (method === 'GET' && path === '/fill-assist/manifest.json') return true;
if (method === 'GET' && /^\/fill-assist\/[^/]+$/i.test(path)) return true;
if (method === 'GET' && (path === '/v1/assetlinks:check' || path === '/api/v1/assetlinks:check')) return true;
if (method === 'GET' && /^\/icons\/[^/]+\/icon\.png$/i.test(path)) return true;
return false;
}
@@ -36,6 +37,70 @@ function isImportBypassRequest(request: Request, path: string, method: string):
return false;
}
const BODY_LIMIT_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
function isLargeUploadPath(path: string): boolean {
return (
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) ||
path === '/api/admin/backup/import'
);
}
async function enforceRequestBodyLimit(
request: Request,
path: string,
method: string
): Promise<Request | Response> {
if (!BODY_LIMIT_METHODS.has(method) || isLargeUploadPath(path) || !request.body) {
return request;
}
const contentLengthRaw = request.headers.get('Content-Length');
if (contentLengthRaw) {
const contentLength = Number(contentLengthRaw);
if (Number.isFinite(contentLength) && contentLength > LIMITS.request.maxBodyBytes) {
return errorResponse('Request body too large', 413);
}
if (Number.isFinite(contentLength) && contentLength >= 0) {
return request;
}
}
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
total += value.byteLength;
if (total > LIMITS.request.maxBodyBytes) {
try {
await reader.cancel();
} catch {
// Ignore cancellation races after the oversized body is rejected.
}
return errorResponse('Request body too large', 413);
}
chunks.push(value);
}
const body = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return new Request(request.url, {
method: request.method,
headers: request.headers,
body,
redirect: request.redirect,
});
}
export async function handleRequest(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
@@ -60,7 +125,10 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
}
const rateLimit = new RateLimitService(env.DB);
const check = await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests);
const shouldUseStrictBudget = category === 'public-sensitive' || category === 'register';
const check = shouldUseStrictBudget
? await rateLimit.consumeStrictBudget(`${clientId}:${category}`, maxRequests)
: await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests);
if (check.allowed) return null;
return new Response(
@@ -80,20 +148,15 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
}
if (method === 'OPTIONS') {
return handleCors(request);
return handleCors(request, env);
}
try {
const isLargeUploadPath =
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) ||
path === '/api/admin/backup/import';
if (!isLargeUploadPath) {
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
if (contentLength > LIMITS.request.maxBodyBytes) {
return errorResponse('Request body too large', 413);
}
const bodyLimitResult = await enforceRequestBodyLimit(request, path, method);
if (bodyLimitResult instanceof Response) {
return bodyLimitResult;
}
request = bodyLimitResult;
const secretIssue = jwtSecretUnsafeReason(env);
if (secretIssue && !canServeWithUnsafeJwtSecret(path, method)) {
+68 -7
View File
@@ -1,4 +1,4 @@
import { zipSync, unzipSync } from 'fflate';
import { zipSync, unzipSync, type UnzipFileInfo } from 'fflate';
import type { Env } from '../types';
import { APP_VERSION } from '../../shared/app-version';
import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config';
@@ -28,10 +28,11 @@ const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
// Prefer store-only ZIP entries over heavier compression to keep exports reliable.
const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
const BACKUP_JSON_INDENT = 2;
const MAX_BACKUP_ARCHIVE_BYTES = 64 * 1024 * 1024;
export const MAX_BACKUP_ARCHIVE_BYTES = 64 * 1024 * 1024;
const MAX_BACKUP_ARCHIVE_ENTRY_COUNT = 10_000;
const MAX_BACKUP_EXTRACTED_BYTES = 64 * 1024 * 1024;
const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024;
const MAX_BACKUP_PATH_SEGMENT_LENGTH = 128;
export interface BackupManifest {
formatVersion: 1;
@@ -186,6 +187,61 @@ function validateArchiveSize(bytes: Uint8Array): void {
}
}
function isSafeBackupPathSegment(value: string): boolean {
if (!value || value.length > MAX_BACKUP_PATH_SEGMENT_LENGTH) return false;
if (value === '.' || value === '..') return false;
return /^[A-Za-z0-9._-]+$/.test(value);
}
export function isSafeBackupAttachmentBlobName(value: unknown): boolean {
const normalized = String(value ?? '').trim();
const parts = normalized.split('/');
return parts.length === 2 && parts.every(isSafeBackupPathSegment);
}
function isSafeBackupAttachmentEntryName(value: string): boolean {
if (!value.startsWith('attachments/') || !value.endsWith('.bin')) return false;
const relative = value.slice('attachments/'.length, -'.bin'.length);
return isSafeBackupAttachmentBlobName(relative);
}
function validateBackupEntryName(name: string): void {
const normalized = String(name || '').trim();
if (normalized !== name || !normalized) {
throw new Error('Backup archive contains an invalid file name');
}
if (normalized.includes('\\') || normalized.includes('\0') || normalized.startsWith('/') || normalized.includes('//')) {
throw new Error(`Backup archive contains an unsafe file name: ${normalized}`);
}
if (normalized !== 'manifest.json' && normalized !== 'db.json' && !isSafeBackupAttachmentEntryName(normalized)) {
throw new Error(`Backup archive contains an unsupported file: ${normalized}`);
}
}
function createBackupUnzipFilter(): (file: UnzipFileInfo) => boolean {
let entryCount = 0;
let totalOriginalBytes = 0;
return (file: UnzipFileInfo): boolean => {
entryCount += 1;
if (entryCount > MAX_BACKUP_ARCHIVE_ENTRY_COUNT) {
throw new Error('Backup archive contains too many files');
}
validateBackupEntryName(file.name);
const originalSize = Number(file.originalSize);
if (!Number.isFinite(originalSize) || originalSize < 0) {
throw new Error(`Backup archive contains an invalid file size: ${file.name}`);
}
if (file.name === 'db.json' && originalSize > MAX_BACKUP_DB_JSON_BYTES) {
throw new Error('Backup archive database payload is too large');
}
totalOriginalBytes += originalSize;
if (totalOriginalBytes > MAX_BACKUP_EXTRACTED_BYTES) {
throw new Error('Backup archive expands beyond the current restore limit');
}
return true;
};
}
function getRequiredZipEntries(db: BackupPayload['db']): string[] {
const entries: string[] = [];
for (const row of db.attachments) {
@@ -223,8 +279,11 @@ export function parseBackupArchive(
validateArchiveSize(bytes);
let zipped: Record<string, Uint8Array>;
try {
zipped = unzipSync(bytes);
} catch {
zipped = unzipSync(bytes, { filter: createBackupUnzipFilter() });
} catch (error) {
if (error instanceof Error && error.message.startsWith('Backup archive ')) {
throw error;
}
throw new Error('Invalid backup archive');
}
@@ -235,6 +294,7 @@ export function parseBackupArchive(
let totalExtractedBytes = 0;
for (const entry of entryNames) {
validateBackupEntryName(entry);
const entryBytes = zipped[entry];
totalExtractedBytes += entryBytes.byteLength;
if (entry === 'db.json' && entryBytes.byteLength > MAX_BACKUP_DB_JSON_BYTES) {
@@ -368,7 +428,7 @@ export function validateBackupPayloadContents(
for (const row of attachmentRows) {
const id = String(row.id || '').trim();
const cipherId = String(row.cipher_id || '').trim();
if (!id || !cipherId || !cipherIds.has(cipherId)) {
if (!id || !cipherId || !isSafeBackupPathSegment(id) || !isSafeBackupPathSegment(cipherId) || !cipherIds.has(cipherId)) {
throw new Error('Backup archive contains an invalid attachment row');
}
const attachmentPath = `attachments/${cipherId}/${id}.bin`;
@@ -382,9 +442,10 @@ export function validateBackupPayloadContents(
for (const row of accountPasskeyRows) {
const id = String(row.id || '').trim();
const userId = String(row.user_id || '').trim();
const purpose = row.purpose == null ? 'login' : String(row.purpose || '').trim();
const credentialId = String(row.credential_id || '').trim();
const publicKey = String(row.public_key || '').trim();
if (!id || !userIds.has(userId) || !credentialId || !publicKey) {
if (!id || !userIds.has(userId) || !credentialId || !publicKey || (purpose !== 'login' && purpose !== 'twoFactor')) {
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}`);
@@ -433,7 +494,7 @@ export async function buildBackupArchive(
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
]);
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
+218 -5
View File
@@ -28,6 +28,7 @@ import {
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
const BACKUP_RUNTIME_CONFIG_KEY = 'backup.runtime.v1';
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
export const REDACTED_BACKUP_SECRET = '********';
const MAX_BACKUP_DESTINATIONS = 24;
export type {
@@ -67,6 +68,163 @@ function normalizePath(value: unknown): string {
return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
}
function normalizeHostnameForPolicy(hostname: string): string {
return hostname.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
}
function parseIpv4Address(hostname: string): number[] | null {
const parts = hostname.split('.');
if (parts.length !== 4) return null;
const octets = parts.map((part) => {
if (!/^\d{1,3}$/.test(part)) return -1;
const value = Number(part);
return Number.isInteger(value) && value >= 0 && value <= 255 ? value : -1;
});
return octets.every((value) => value >= 0) ? octets : null;
}
function isBlockedIpv4Address(octets: number[]): boolean {
const [a, b, c] = octets;
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && (b === 0 || b === 168)) ||
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
(a === 203 && b === 0 && c === 113) ||
a >= 224
);
}
/**
* Expand a hostname-form IPv6 literal to eight 4-digit hextets.
* Needed so compressed forms like "::1" are not misclassified by a naive
* "first non-empty hextet" check (which would read "1" and miss loopback).
*/
function expandIpv6Address(hostname: string): string[] | null {
const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, '');
if (!normalized.includes(':')) return null;
if (normalized.includes('.')) {
// IPv4-embedded forms are handled separately by the caller.
return null;
}
if ((normalized.match(/::/g) || []).length > 1) return null;
const sides = normalized.split('::');
const left = sides[0] ? sides[0].split(':').filter((part) => part.length > 0) : [];
const right = sides.length > 1 && sides[1] ? sides[1].split(':').filter((part) => part.length > 0) : [];
if (left.length + right.length > 8) return null;
if (sides.length === 1 && left.length !== 8) return null;
const missing = 8 - left.length - right.length;
if (sides.length > 1 && missing < 0) return null;
const middle = sides.length > 1 ? Array.from({ length: missing }, () => '0') : [];
const parts = [...left, ...middle, ...right];
if (parts.length !== 8) return null;
const hextets: string[] = [];
for (const part of parts) {
if (!/^[0-9a-f]{1,4}$/i.test(part)) return null;
hextets.push(part.padStart(4, '0'));
}
return hextets;
}
function isBlockedIpv6Address(hostname: string): boolean {
if (!hostname.includes(':')) return false;
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
// IPv4-mapped dotted form: ::ffff:127.0.0.1
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i);
if (mappedIpv4) {
const octets = parseIpv4Address(mappedIpv4[1]);
return !octets || isBlockedIpv4Address(octets);
}
// IPv4-mapped hex form produced by some URL parsers: ::ffff:7f00:1
const mappedHex = normalized.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
if (mappedHex) {
const hi = Number.parseInt(mappedHex[1], 16);
const lo = Number.parseInt(mappedHex[2], 16);
if (!Number.isFinite(hi) || !Number.isFinite(lo)) return true;
const octets = [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff];
return isBlockedIpv4Address(octets);
}
const hextets = expandIpv6Address(normalized);
if (!hextets) return true;
const firstHextet = Number.parseInt(hextets[0], 16);
if (!Number.isFinite(firstHextet)) return true;
// After expansion, loopback (::1) and unspecified (::) have first hextet 0.
return (
firstHextet === 0 ||
(firstHextet & 0xfe00) === 0xfc00 ||
(firstHextet & 0xffc0) === 0xfe80 ||
(firstHextet & 0xff00) === 0xff00 ||
hextets.join(':').startsWith('2001:0db8:')
);
}
function assertBackupEndpointHostAllowed(hostname: string, label: string): void {
const normalized = normalizeHostnameForPolicy(hostname);
if (!normalized) throw new Error(`${label} host is required`);
if (
normalized === 'localhost' ||
normalized === 'localhost.localdomain' ||
normalized.endsWith('.localhost.localdomain') ||
normalized.endsWith('.localhost') ||
normalized.endsWith('.local') ||
normalized.endsWith('.home.arpa') ||
normalized.endsWith('.internal') ||
normalized.endsWith('.lan') ||
normalized === 'metadata.google.internal' ||
normalized === 'localtest.me' ||
normalized.endsWith('.localtest.me') ||
normalized === 'lvh.me' ||
normalized.endsWith('.lvh.me') ||
normalized === 'vcap.me' ||
normalized.endsWith('.vcap.me') ||
normalized === 'nip.io' ||
normalized.endsWith('.nip.io') ||
normalized === 'sslip.io' ||
normalized.endsWith('.sslip.io') ||
normalized === 'xip.io' ||
normalized.endsWith('.xip.io')
) {
throw new Error(`${label} host is not allowed`);
}
const ipv4 = parseIpv4Address(normalized);
if (ipv4 && isBlockedIpv4Address(ipv4)) {
throw new Error(`${label} host is not allowed`);
}
if (isBlockedIpv6Address(normalized)) {
throw new Error(`${label} host is not allowed`);
}
}
export function normalizeBackupEndpointUrl(value: string, label: string): string {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(`${label} must be a valid URL`);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`${label} must start with http:// or https://`);
}
if (parsed.username || parsed.password) {
throw new Error(`${label} must not include credentials`);
}
if (parsed.search || parsed.hash) {
throw new Error(`${label} must not include query or fragment`);
}
assertBackupEndpointHostAllowed(parsed.hostname, label);
return parsed.toString().replace(/\/+$/, '');
}
function assertValidTimeZone(timezone: string): string {
try {
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
@@ -122,7 +280,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
if (!allowIncomplete || endpoint) {
if (!endpoint) throw new Error('S3 endpoint is required');
if (!/^https?:\/\//i.test(endpoint)) throw new Error('S3 endpoint must start with http:// or https://');
normalizeBackupEndpointUrl(endpoint, 'S3 endpoint');
}
if (!allowIncomplete || bucket) {
if (!bucket) throw new Error('S3 bucket is required');
@@ -135,7 +293,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
}
return {
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
endpoint: endpoint ? normalizeBackupEndpointUrl(endpoint, 'S3 endpoint') : '',
bucket,
addressingStyle,
region,
@@ -154,7 +312,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
if (!allowIncomplete || baseUrl) {
if (!baseUrl) throw new Error('WebDAV server URL is required');
if (!/^https?:\/\//i.test(baseUrl)) throw new Error('WebDAV server URL must start with http:// or https://');
normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL');
}
if (!allowIncomplete || username) {
if (!username) throw new Error('WebDAV username is required');
@@ -164,7 +322,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
}
return {
baseUrl: baseUrl ? baseUrl.replace(/\/+$/, '') : '',
baseUrl: baseUrl ? normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL') : '',
username,
password,
remotePath,
@@ -180,6 +338,32 @@ function normalizeDestination(
return normalizeWebDavDestination(destination, allowIncomplete);
}
function shouldPreserveBackupSecret(value: unknown): boolean {
if (value === undefined || value === null) return true;
const raw = String(value);
return raw === '' || raw === REDACTED_BACKUP_SECRET;
}
function withPreservedDestinationSecret(
destinationType: BackupDestinationType,
inputDestination: unknown,
previous: BackupDestinationRecord | undefined
): unknown {
const source = isPlainObject(inputDestination) ? { ...inputDestination } : {};
if (destinationType === 's3') {
const previousDestination = previous?.type === 's3' ? previous.destination as S3BackupDestination : null;
if (shouldPreserveBackupSecret(source.secretAccessKey)) {
source.secretAccessKey = previousDestination?.secretAccessKey || '';
}
} else {
const previousDestination = previous?.type === 'webdav' ? previous.destination as WebDavBackupDestination : null;
if (shouldPreserveBackupSecret(source.password)) {
source.password = previousDestination?.password || '';
}
}
return source;
}
function normalizeRuntime(value: unknown): BackupRuntimeState {
const source = isPlainObject(value) ? value : {};
const asIso = (input: unknown): string | null => {
@@ -250,7 +434,11 @@ function normalizeDestinationRecord(
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
};
const destination = normalizeDestination(type, input.destination, !schedule.enabled);
const destination = normalizeDestination(
type,
withPreservedDestinationSecret(type, input.destination, previous),
!schedule.enabled
);
return {
id,
@@ -432,6 +620,31 @@ export function serializeBackupSettings(settings: BackupSettings): string {
return JSON.stringify(stripRuntimeFromSettings(settings));
}
export function redactBackupSettingsSecrets(settings: BackupSettings): BackupSettings {
return {
destinations: settings.destinations.map((destination) => {
if (destination.type === 's3') {
const config = destination.destination as S3BackupDestination;
return {
...destination,
destination: {
...config,
secretAccessKey: config.secretAccessKey ? REDACTED_BACKUP_SECRET : '',
},
};
}
const config = destination.destination as WebDavBackupDestination;
return {
...destination,
destination: {
...config,
password: config.password ? REDACTED_BACKUP_SECRET : '',
},
};
}),
};
}
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
const mergeRuntime = async (settings: BackupSettings): Promise<BackupSettings> => (
+25 -6
View File
@@ -4,6 +4,7 @@ import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from
import {
type BackupManifestAttachmentBlob,
type BackupPayload,
isSafeBackupAttachmentBlobName,
parseBackupArchive,
validateBackupPayloadContents,
} from './backup-archive';
@@ -253,6 +254,10 @@ function cloneRows(rows: SqlRow[]): SqlRow[] {
return rows.map((row) => ({ ...row }));
}
function normalizeAccountPasskeyPurpose(value: unknown): 'login' | 'twoFactor' {
return value == null ? 'login' : String(value).trim() === 'twoFactor' ? 'twoFactor' : 'login';
}
function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] {
let replaced = false;
const nextRows = rows.map((row) => {
@@ -296,13 +301,16 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
users: cloneRows(payload.users || []).map((row) => ({
...row,
verify_devices: row.verify_devices ?? 1,
verify_devices: row.verify_devices ?? 0,
yubikey_nfc: row.yubikey_nfc ?? 0,
})),
domain_settings: cloneRows(payload.domain_settings || []),
user_revisions: cloneRows(payload.user_revisions || []),
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
webauthn_credentials: cloneRows(payload.webauthn_credentials || []),
webauthn_credentials: cloneRows(payload.webauthn_credentials || []).map((row) => ({
...row,
purpose: normalizeAccountPasskeyPurpose(row.purpose),
})),
folders: cloneRows(payload.folders || []),
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
...row,
@@ -462,9 +470,20 @@ async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record
}
function buildAttachmentBlobLookup(manifest: BackupPayload['manifest']): Map<string, BackupManifestAttachmentBlob> {
return new Map(
(manifest.attachmentBlobs || []).map((item) => [`${item.cipherId}/${item.attachmentId}`, item])
);
const lookup = new Map<string, BackupManifestAttachmentBlob>();
for (const item of manifest.attachmentBlobs || []) {
const cipherId = String(item.cipherId || '').trim();
const attachmentId = String(item.attachmentId || '').trim();
const blobName = String(item.blobName || '').trim();
if (!cipherId || !attachmentId || !isSafeBackupAttachmentBlobName(blobName)) continue;
lookup.set(`${cipherId}/${attachmentId}`, {
...item,
cipherId,
attachmentId,
blobName,
});
}
return lookup;
}
async function prepareRemoteAttachmentPayload(
@@ -656,7 +675,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
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'],
['id', 'user_id', 'purpose', '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 || []
)
);
+6 -5
View File
@@ -3,6 +3,7 @@ import {
BackupDestinationType,
S3BackupDestination,
WebDavBackupDestination,
normalizeBackupEndpointUrl,
} from './backup-config';
export interface BackupUploadResult {
@@ -215,7 +216,7 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
if (destination.type === 'webdav') {
const config = destination.destination as WebDavBackupDestination;
if (!String(config.baseUrl || '').trim()) throw new Error('WebDAV server URL is required');
if (!/^https?:\/\//i.test(String(config.baseUrl || '').trim())) throw new Error('WebDAV server URL must start with http:// or https://');
normalizeBackupEndpointUrl(String(config.baseUrl || '').trim(), 'WebDAV server URL');
if (!String(config.username || '').trim()) throw new Error('WebDAV username is required');
if (!String(config.password || '')) throw new Error('WebDAV password is required');
return;
@@ -223,7 +224,7 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
if (destination.type === 's3') {
const config = destination.destination as S3BackupDestination;
if (!String(config.endpoint || '').trim()) throw new Error('S3 endpoint is required');
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('S3 endpoint must start with http:// or https://');
normalizeBackupEndpointUrl(String(config.endpoint || '').trim(), 'S3 endpoint');
if (!String(config.bucket || '').trim()) throw new Error('S3 bucket is required');
if (!String(config.accessKeyId || '').trim()) throw new Error('S3 access key is required');
if (!String(config.secretAccessKey || '')) throw new Error('S3 secret key is required');
@@ -252,7 +253,7 @@ async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, aut
Authorization: authHeader,
},
});
if ([200, 201, 204, 301, 302, 405].includes(response.status)) continue;
if ([200, 201, 204, 405].includes(response.status)) continue;
throw new Error(`WebDAV directory creation failed: ${response.status}`);
}
}
@@ -275,7 +276,7 @@ async function ensureWebDavDirectoryCached(
Authorization: authHeader,
},
});
if ([200, 201, 204, 301, 302, 405].includes(response.status)) {
if ([200, 201, 204, 405].includes(response.status)) {
ensuredDirectories.add(current);
continue;
}
@@ -518,7 +519,7 @@ async function signedS3Request(
config.region || 'auto'
);
return fetch(url.toString(), {
return fetch(url, {
method,
headers: {
Authorization: authorization,
+4 -21
View File
@@ -62,27 +62,10 @@ export async function ensurePushInstallationCredentials(db: D1Database): Promise
method: 'POST',
headers: {
accept: 'application/json',
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
'cache-control': 'no-cache',
'content-type': 'application/json',
origin: 'https://bitwarden.com',
pragma: 'no-cache',
priority: 'u=1, i',
referer: 'https://bitwarden.com/host/',
'sec-ch-ua': '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-site',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
},
body: JSON.stringify({
formName: 'request_host',
url: '/host/',
locale: 'zh-CN',
email: randomInstallationEmail(),
region: 'us',
}),
},
'Failed to request Bitwarden push installation:'
@@ -94,9 +77,9 @@ export async function ensurePushInstallationCredentials(db: D1Database): Promise
return null;
}
const body = (await response.json().catch(() => null)) as { id?: string; key?: string; enabled?: boolean } | null;
const id = String(body?.id || '').trim();
const key = String(body?.key || '').trim();
const body = (await response.json().catch(() => null)) as { id?: string; Id?: string; key?: string; Key?: string; enabled?: boolean; Enabled?: boolean } | null;
const id = String(body?.id || body?.Id || '').trim();
const key = String(body?.key || body?.Key || '').trim();
if (!id || !key) {
console.error('Bitwarden push installation response did not include id/key');
return null;
@@ -234,7 +217,7 @@ export async function registerMobilePushDevice(
export async function unregisterMobilePushDevice(env: Env, pushUuid: string | null | undefined): Promise<boolean> {
const normalized = String(pushUuid || '').trim();
if (!normalized) return false;
return postToPushRelay(env, `/push/delete/${encodeURIComponent(normalized)}`);
return postToPushRelay(env, '/push/delete', { id: normalized });
}
export async function notifyMobilePush(
+86
View File
@@ -3,6 +3,7 @@ import { LIMITS } from '../config/limits';
// Rate limiting service.
// - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence).
// - API budgets: Cloudflare Cache API (high volume, auto-expires, zero D1 writes).
// - Strict budgets: D1-backed fixed windows for low-volume anonymous sensitive endpoints.
const CONFIG = {
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
@@ -12,11 +13,14 @@ const CONFIG = {
export class RateLimitService {
private static loginIpTableReady = false;
private static strictBudgetTableReady = false;
private static lastLoginIpCleanupAt = 0;
private static lastStrictBudgetCleanupAt = 0;
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability;
private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
private static readonly STRICT_BUDGET_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
constructor(private db: D1Database) {}
@@ -58,6 +62,35 @@ export class RateLimitService {
RateLimitService.loginIpTableReady = true;
}
private async ensureStrictBudgetTable(): Promise<void> {
if (RateLimitService.strictBudgetTableReady) return;
await this.db
.prepare(
'CREATE TABLE IF NOT EXISTS rate_limit_buckets (' +
'bucket_key TEXT PRIMARY KEY, ' +
'count INTEGER NOT NULL, ' +
'expires_at INTEGER NOT NULL, ' +
'updated_at INTEGER NOT NULL' +
')'
)
.run();
await this.db
.prepare('CREATE INDEX IF NOT EXISTS idx_rate_limit_buckets_expires ON rate_limit_buckets(expires_at)')
.run();
RateLimitService.strictBudgetTableReady = true;
}
private async maybeCleanupStrictBudgets(nowMs: number): Promise<void> {
if (!this.shouldRunCleanup(RateLimitService.lastStrictBudgetCleanupAt, RateLimitService.STRICT_BUDGET_CLEANUP_INTERVAL_MS)) {
return;
}
await this.db.prepare('DELETE FROM rate_limit_buckets WHERE expires_at < ?').bind(nowMs).run();
RateLimitService.lastStrictBudgetCleanupAt = nowMs;
}
async checkLoginAttempt(ip: string): Promise<{
allowed: boolean;
remainingAttempts: number;
@@ -174,6 +207,59 @@ export class RateLimitService {
return { allowed: true, remaining: Math.max(0, maxRequests - count) };
}
async consumeStrictBudget(
identifier: string,
maxRequests: number
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeStrictBudgetWithWindow(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS);
}
async consumeStrictBudgetWithWindow(
identifier: string,
maxRequests: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
await this.ensureStrictBudgetTable();
const key = String(identifier || '').trim() || 'unknown';
const max = Math.max(1, Math.floor(maxRequests));
const windowSize = Math.max(1, Math.floor(windowSeconds));
const nowMs = Date.now();
const nowSec = Math.floor(nowMs / 1000);
const windowStart = nowSec - (nowSec % windowSize);
const windowEndMs = (windowStart + windowSize) * 1000;
const retryAfterSeconds = Math.max(1, Math.ceil((windowEndMs - nowMs) / 1000));
const bucketKey = `${key}:${windowStart}`;
await this.maybeCleanupStrictBudgets(nowMs);
await this.db
.prepare(
'INSERT OR IGNORE INTO rate_limit_buckets(bucket_key, count, expires_at, updated_at) VALUES(?, 0, ?, ?)'
)
.bind(bucketKey, windowEndMs, nowMs)
.run();
const update = await this.db
.prepare(
'UPDATE rate_limit_buckets SET count = count + 1, expires_at = ?, updated_at = ? ' +
'WHERE bucket_key = ? AND count < ?'
)
.bind(windowEndMs, nowMs, bucketKey, max)
.run();
const allowed = Number(update.meta?.changes ?? 0) > 0;
const row = await this.db
.prepare('SELECT count FROM rate_limit_buckets WHERE bucket_key = ?')
.bind(bucketKey)
.first<{ count: number }>();
const count = Math.max(0, Number(row?.count || 0));
if (!allowed) {
return { allowed: false, remaining: 0, retryAfterSeconds };
}
return { allowed: true, remaining: Math.max(0, max - count) };
}
// General-purpose fixed-window budget.
// Callers supply an identifier (must be unique per rate-limit category) and the
// per-window maximum. This single method replaces all previous specialised
+1 -1
View File
@@ -268,7 +268,7 @@ export async function updateAccountPasskeyEncryption(
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 = ?'
"WHERE user_id = ? AND credential_id = ? AND purpose = 'login'"
)
.bind(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey, updatedAt, userId, credentialId)
.run();
+14
View File
@@ -97,6 +97,20 @@ export async function touchDeviceLastSeen(
return Number(result.meta.changes ?? 0) > 0;
}
export async function rotateDeviceSessionStamp(
db: D1Database,
userId: string,
deviceIdentifier: string,
sessionStamp: string
): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare('UPDATE devices SET session_stamp = ?, updated_at = ? WHERE user_id = ? AND device_identifier = ?')
.bind(sessionStamp, now, userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function updateDeviceKeys(
db: D1Database,
userId: string,
+2 -2
View File
@@ -14,11 +14,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 0, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT',
+6 -2
View File
@@ -93,9 +93,13 @@ export async function incrementSendAccessCount(db: D1Database, sendId: string):
const result = await db
.prepare(
'UPDATE sends SET access_count = access_count + 1, updated_at = ? ' +
'WHERE id = ? AND (max_access_count IS NULL OR access_count < max_access_count)'
'WHERE id = ? ' +
'AND disabled = 0 ' +
'AND (max_access_count IS NULL OR access_count < max_access_count) ' +
'AND (expiration_date IS NULL OR expiration_date > ?) ' +
'AND deletion_date > ?'
)
.bind(now, sendId)
.bind(now, sendId, now, now)
.run();
return (result.meta.changes ?? 0) > 0;
}
+1 -1
View File
@@ -23,7 +23,7 @@ function mapUserRow(row: any): User {
securityStamp: row.security_stamp,
role: row.role === 'admin' ? 'admin' : 'user',
status: row.status === 'banned' ? 'banned' : 'active',
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
verifyDevices: row.verify_devices == null ? false : !!row.verify_devices,
totpSecret: row.totp_secret ?? null,
totpRecoveryCode: row.totp_recovery_code ?? null,
yubikeyKey1: row.yubikey_key1 ?? null,
+5
View File
@@ -109,6 +109,7 @@ import {
isKnownDevice as getKnownStoredDevice,
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
rotateDeviceSessionStamp as rotateStoredDeviceSessionStamp,
touchDeviceLastSeen as touchStoredDeviceLastSeen,
upsertDevice as saveStoredDevice,
updateDeviceName as updateStoredDeviceName,
@@ -761,6 +762,10 @@ export class StorageService {
return findStoredDevice(this.db, userId, deviceIdentifier);
}
async rotateDeviceSessionStamp(userId: string, deviceIdentifier: string, sessionStamp: string): Promise<boolean> {
return rotateStoredDeviceSessionStamp(this.db, userId, deviceIdentifier, sessionStamp);
}
async updateDeviceKeys(
userId: string,
deviceIdentifier: string,
+56
View File
@@ -124,6 +124,10 @@ export enum CipherType {
SecureNote = 2,
Card = 3,
Identity = 4,
SSHKey = 5,
BankAccount = 6,
DriversLicense = 7,
Passport = 8,
}
export interface CipherLoginUri {
@@ -158,6 +162,52 @@ export interface CipherSshKey {
keyFingerprint: string;
}
export interface CipherBankAccount {
bankName: string | null;
nameOnAccount: string | null;
accountType: string | null;
accountNumber: string | null;
routingNumber: string | null;
branchNumber: string | null;
pin: string | null;
swiftCode: string | null;
iban: string | null;
bankContactPhone: string | null;
[key: string]: any;
}
export interface CipherDriversLicense {
firstName: string | null;
middleName: string | null;
lastName: string | null;
dateOfBirth: string | null;
licenseNumber: string | null;
issuingCountry: string | null;
issuingState: string | null;
issueDate: string | null;
expirationDate: string | null;
issuingAuthority: string | null;
licenseClass: string | null;
[key: string]: any;
}
export interface CipherPassport {
surname: string | null;
givenName: string | null;
dateOfBirth: string | null;
sex: string | null;
birthPlace: string | null;
nationality: string | null;
issuingCountry: string | null;
passportNumber: string | null;
passportType: string | null;
nationalIdentificationNumber: string | null;
issuingAuthority: string | null;
issueDate: string | null;
expirationDate: string | null;
[key: string]: any;
}
export interface CipherIdentity {
title: string | null;
firstName: string | null;
@@ -208,6 +258,9 @@ export interface Cipher {
identity: CipherIdentity | null;
secureNote: CipherSecureNote | null;
sshKey: CipherSshKey | null;
bankAccount?: CipherBankAccount | null;
driversLicense?: CipherDriversLicense | null;
passport?: CipherPassport | null;
fields: CipherField[] | null;
passwordHistory: PasswordHistory[] | null;
reprompt: number;
@@ -547,6 +600,9 @@ export interface CipherResponse {
identity: CipherIdentity | null;
secureNote: CipherSecureNote | null;
sshKey: CipherSshKey | null;
bankAccount: CipherBankAccount | null;
driversLicense: CipherDriversLicense | null;
passport: CipherPassport | null;
fields: CipherField[] | null;
passwordHistory: PasswordHistory[] | null;
reprompt: number;
+57 -29
View File
@@ -12,6 +12,7 @@ import type {
WebAuthnPrfDecryptionOption,
} from '../types';
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
import { getConfiguredWebAuthnAllowedOrigins } from './origins';
const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1';
const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000;
@@ -32,6 +33,44 @@ function textBytes(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
function hexByte(value: number): string {
return value.toString(16).padStart(2, '0');
}
function dotNetGuidBytesToUuid(bytes: Uint8Array): string | null {
if (bytes.length !== 16) return null;
return [
[bytes[3], bytes[2], bytes[1], bytes[0]].map(hexByte).join(''),
[bytes[5], bytes[4]].map(hexByte).join(''),
[bytes[7], bytes[6]].map(hexByte).join(''),
[bytes[8], bytes[9]].map(hexByte).join(''),
Array.from(bytes.slice(10, 16)).map(hexByte).join(''),
].join('-');
}
function uuidToDotNetGuidBytes(value: string): Uint8Array | null {
const match = String(value || '').trim().match(
/^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i
);
if (!match) return null;
const hex = match.slice(1).join('');
const bytes = new Uint8Array(16);
for (let i = 0; i < 16; i += 1) {
bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return new Uint8Array([
bytes[3], bytes[2], bytes[1], bytes[0],
bytes[5], bytes[4],
bytes[7], bytes[6],
bytes[8], bytes[9],
bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
]);
}
function normalizeWebAuthnBase64(value: unknown): string {
return String(value || '').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
async function importHmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
}
@@ -121,33 +160,22 @@ export function getAccountPasskeyRpConfig(request: Request, env: Env): { rpId: s
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 configuredOrigins = getConfiguredWebAuthnAllowedOrigins(env);
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);
return uuidToDotNetGuidBytes(userId) || textBytes(userId);
}
export function userHandleToUserId(userHandle: string | undefined): string | null {
if (!userHandle) return null;
try {
const decoded = new TextDecoder().decode(base64UrlToBytes(userHandle));
const bytes = base64UrlToBytes(userHandle);
const officialGuid = dotNetGuidBytesToUuid(bytes);
if (officialGuid) return officialGuid;
const decoded = new TextDecoder().decode(bytes);
return decoded.trim() || null;
} catch {
return null;
@@ -209,17 +237,17 @@ export function normalizeRegistrationResponse(raw: unknown): RegistrationRespons
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),
id: normalizeWebAuthnBase64(input.id),
rawId: normalizeWebAuthnBase64(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,
attestationObject: normalizeWebAuthnBase64(response.attestationObject),
clientDataJSON: normalizeWebAuthnBase64(clientDataJSON),
authenticatorData: response.authenticatorData ? normalizeWebAuthnBase64(response.authenticatorData) : undefined,
transports: Array.isArray(response.transports) ? response.transports.map(String) as AuthenticatorTransportFuture[] : undefined,
publicKey: response.publicKey ? String(response.publicKey) : undefined,
publicKey: response.publicKey ? normalizeWebAuthnBase64(response.publicKey) : undefined,
publicKeyAlgorithm: typeof response.publicKeyAlgorithm === 'number' ? response.publicKeyAlgorithm : undefined,
},
};
@@ -232,16 +260,16 @@ export function normalizeAuthenticationResponse(raw: unknown): AuthenticationRes
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),
id: normalizeWebAuthnBase64(input.id),
rawId: normalizeWebAuthnBase64(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,
authenticatorData: normalizeWebAuthnBase64(response.authenticatorData),
clientDataJSON: normalizeWebAuthnBase64(clientDataJSON),
signature: normalizeWebAuthnBase64(response.signature),
userHandle: response.userHandle ? normalizeWebAuthnBase64(response.userHandle) : undefined,
},
};
}
+10
View File
@@ -19,6 +19,8 @@ interface ParseDirectUploadOptions {
fileNameMismatchMessage?: string;
}
const MULTIPART_FORMDATA_OVERHEAD_BYTES = 256 * 1024;
export function buildDirectUploadUrl(request: Request, path: string, token: string): string {
const version = '2023-11-03';
const expiresAt = '2099-12-31T23:59:59Z';
@@ -34,6 +36,10 @@ export function getSafeJwtSecret(env: Env): string | null {
return secret;
}
export function getMultipartRequestMaxBytes(maxFileSize: number): number {
return maxFileSize + MULTIPART_FORMDATA_OVERHEAD_BYTES;
}
function parseContentLength(request: Request): number | null {
const raw = request.headers.get('content-length');
if (!raw) return null;
@@ -59,6 +65,10 @@ export async function parseDirectUploadPayload(
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('multipart/form-data')) {
const declaredSize = parseContentLength(request);
if (declaredSize !== null && declaredSize > getMultipartRequestMaxBytes(maxFileSize)) {
return errorResponse(tooLargeMessage, 413);
}
const formData = await request.formData();
const file = formData.get('data') as File | null;
if (!file) {
+50
View File
@@ -0,0 +1,50 @@
import type { Env } from '../types';
// Keep this list aligned with Bitwarden server's default FIDO2 origins.
// These are the stable store IDs for the official Chromium-based extensions.
export const OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS = [
'chrome-extension://nngceckbapebfimnlniiiahkandclblb',
'chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh',
'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn',
] as const;
export function normalizeOrigin(value: unknown): string | null {
const raw = String(value || '').trim();
if (!raw) return null;
try {
const url = new URL(raw);
if (!url.protocol || !url.host) return null;
return `${url.protocol}//${url.host}`;
} catch {
return null;
}
}
export function isBrowserExtensionOrigin(origin: unknown): boolean {
const normalized = normalizeOrigin(origin);
return !!normalized && (
normalized.startsWith('chrome-extension://')
|| normalized.startsWith('moz-extension://')
|| normalized.startsWith('safari-web-extension://')
);
}
export function getConfiguredWebAuthnAllowedOrigins(
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
): string[] {
const seen = new Set<string>(OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS);
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
const origin = normalizeOrigin(item);
if (origin) seen.add(origin);
}
return Array.from(seen);
}
export function isConfiguredWebAuthnAllowedOrigin(
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>,
origin: unknown
): boolean {
const normalized = normalizeOrigin(origin);
return !!normalized && getConfiguredWebAuthnAllowedOrigins(env).includes(normalized);
}
+3 -1
View File
@@ -30,7 +30,9 @@ export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
verifyDevices: user.verifyDevices !== false,
// New-device verification is not supported without an email delivery channel.
// Always report disabled so clients do not present a false security posture.
verifyDevices: false,
role: user.role,
status: user.status,
object: 'profile',
+25 -18
View File
@@ -1,4 +1,10 @@
import { LIMITS } from '../config/limits';
import type { Env } from '../types';
import {
isBrowserExtensionOrigin,
isConfiguredWebAuthnAllowedOrigin,
normalizeOrigin,
} from './origins';
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
const DEFAULT_CORS_HEADERS = [
@@ -18,35 +24,31 @@ const DEFAULT_CORS_HEADERS = [
'X-NodeWarden-Web-Session',
];
function isExtensionOrigin(origin: string): boolean {
return (
origin.startsWith('chrome-extension://')
|| origin.startsWith('moz-extension://')
|| origin.startsWith('safari-web-extension://')
);
}
function isWildcardCorsPath(path: string): boolean {
return (
path.startsWith('/icons/')
|| path.startsWith('/fill-assist/')
|| path === '/v1/assetlinks:check'
|| path === '/api/v1/assetlinks:check'
|| path === '/config'
|| path === '/api/config'
|| path === '/api/version'
);
}
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } {
function getCorsPolicy(request: Request, env: Env): { allowOrigin: string | null; allowCredentials: boolean } {
const url = new URL(request.url);
const origin = request.headers.get('Origin');
if (!origin) {
const originHeader = request.headers.get('Origin');
if (!originHeader) {
return isWildcardCorsPath(url.pathname)
? { allowOrigin: '*', allowCredentials: false }
: { allowOrigin: null, allowCredentials: false };
}
const origin = normalizeOrigin(originHeader);
if (origin === url.origin) {
return { allowOrigin: origin, allowCredentials: true };
}
if (isExtensionOrigin(origin)) {
if (isBrowserExtensionOrigin(origin) && isConfiguredWebAuthnAllowedOrigin(env, origin)) {
return { allowOrigin: origin, allowCredentials: true };
}
if (isWildcardCorsPath(url.pathname)) {
@@ -55,7 +57,7 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
return { allowOrigin: null, allowCredentials: false };
}
function buildCorsHeaders(request: Request): Record<string, string> {
function buildCorsHeaders(request: Request, env: Env): Record<string, string> {
const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '')
.split(',')
.map((value) => value.trim())
@@ -69,7 +71,7 @@ function buildCorsHeaders(request: Request): Record<string, string> {
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
};
const corsPolicy = getCorsPolicy(request);
const corsPolicy = getCorsPolicy(request, env);
if (corsPolicy.allowOrigin) {
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
if (corsPolicy.allowCredentials) {
@@ -83,7 +85,8 @@ function buildCorsHeaders(request: Request): Record<string, string> {
export function applyCors(
request: Request,
response: Response
response: Response,
env: Env
): Response {
// WebSocket upgrade responses must be returned untouched.
const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
@@ -92,7 +95,7 @@ export function applyCors(
}
const headers = new Headers(response.headers);
const corsHeaders = buildCorsHeaders(request);
const corsHeaders = buildCorsHeaders(request, env);
for (const [k, v] of Object.entries(corsHeaders)) {
headers.set(k, v);
}
@@ -136,6 +139,10 @@ export function errorResponse(message: string, status: number = 400): Response {
);
}
export function unsupportedResponse(message: string = 'This feature is not supported by this server.'): Response {
return errorResponse(message, 501);
}
// Identity endpoint error response (for /identity/connect/token)
export function identityErrorResponse(message: string, error: string = 'invalid_grant', status: number = 400): Response {
return jsonResponse(
@@ -152,10 +159,10 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
}
// Handle CORS preflight
export function handleCors(request: Request): Response {
export function handleCors(request: Request, env: Env): Response {
return new Response(null, {
status: 204,
headers: buildCorsHeaders(request),
headers: buildCorsHeaders(request, env),
});
}
+1 -1
View File
@@ -9,7 +9,7 @@
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
connect-src 'self';
connect-src 'self' https://api.pwnedpasswords.com;
font-src 'self';
form-action 'self';
base-uri 'self';
+82 -6
View File
@@ -170,6 +170,7 @@
(function () {
var params = new URLSearchParams(window.location.search);
var sentSuccess = false;
var allowedParentOriginsPromise = null;
var text = pickText(params.get("locale") || navigator.language || "en");
document.documentElement.lang = params.get("locale") || navigator.language || "en";
@@ -227,6 +228,81 @@
return out;
}
function normalizeOrigin(value) {
if (!value) return "";
try {
var url = new URL(value);
if (!url.protocol || !url.host) return "";
return url.protocol + "//" + url.host;
} catch (_error) {
return "";
}
}
function isExtensionOrigin(origin) {
return (
origin.indexOf("chrome-extension://") === 0 ||
origin.indexOf("moz-extension://") === 0 ||
origin.indexOf("safari-web-extension://") === 0
);
}
function allowedParentOrigins() {
if (allowedParentOriginsPromise) return allowedParentOriginsPromise;
allowedParentOriginsPromise = fetch("/api/web-bootstrap", {
headers: { Accept: "application/json" },
credentials: "omit",
}).then(function (response) {
if (!response.ok) return [];
return response.json();
}).then(function (body) {
var origins = Array.isArray(body && body.webAuthnAllowedOrigins)
? body.webAuthnAllowedOrigins
: [];
return origins.map(normalizeOrigin).filter(Boolean);
}).catch(function () {
return [];
});
return allowedParentOriginsPromise;
}
function trustedParentOrigin(allowedOrigins) {
var parent = decodeRepeated(params.get("parent"));
if (!parent) return "";
var parentOrigin = normalizeOrigin(parent);
if (!parentOrigin) return "";
if (parentOrigin === window.location.origin) {
return parentOrigin;
}
if (isExtensionOrigin(parentOrigin) && allowedOrigins.indexOf(parentOrigin) >= 0) {
return parentOrigin;
}
return "";
}
function safeShallowCopy(source) {
var copy = {};
if (!source || typeof source !== "object") return copy;
Object.keys(source).forEach(function (key) {
if (key === "__proto__" || key === "prototype" || key === "constructor") return;
copy[key] = source[key];
});
return copy;
}
async function postResult(message) {
var parentOrigin = trustedParentOrigin(await allowedParentOrigins());
if (parentOrigin) {
if (window.opener && !window.opener.closed) {
window.opener.postMessage(message, parentOrigin);
}
if (window.parent && window.parent !== window) {
window.parent.postMessage(message, parentOrigin);
}
}
window.postMessage(message, window.location.origin);
}
function showMessage(kind, message) {
msgEl.textContent = String(message || "");
msgEl.className = "msg show " + kind;
@@ -279,13 +355,13 @@
function normalizeOptions(options) {
if (!options || typeof options !== "object") throw new Error("Cannot parse data.");
var copy = Object.assign({}, options);
var copy = safeShallowCopy(options);
copy.challenge = bytesFromBase64Url(copy.challenge);
if (Array.isArray(copy.allowCredentials)) {
copy.allowCredentials = copy.allowCredentials.map(function (credential) {
return Object.assign({}, credential, {
id: bytesFromBase64Url(credential.id),
});
var next = safeShallowCopy(credential);
next.id = bytesFromBase64Url(credential && credential.id);
return next;
});
}
return copy;
@@ -327,11 +403,11 @@
if (!(credential instanceof PublicKeyCredential)) {
throw new Error("No security key was selected.");
}
window.postMessage({
await postResult({
command: "webAuthnResult",
data: credentialToDataString(credential),
remember: rememberEl.checked,
}, "*");
});
sentSuccess = true;
showMessage("success", text.success);
} catch (error) {
+31 -10
View File
@@ -68,6 +68,7 @@ import { t } from '@/lib/i18n';
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
import { clearPasswordSecurityCache } from '@/lib/password-security-cache';
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
import {
@@ -111,6 +112,8 @@ const APP_ROUTE_PATHS = [
'/',
'/vault',
'/vault/totp',
'/security/password-health',
'/generator',
'/sends',
'/admin',
'/logs',
@@ -228,6 +231,7 @@ export default function App() {
hint: null,
});
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
const [hashPathRaw, setHashPathRaw] = useState(() => (typeof window !== 'undefined' ? window.location.hash || '' : ''));
const [unlockPassword, setUnlockPassword] = useState('');
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null);
@@ -295,15 +299,16 @@ export default function App() {
}, [pushToast]);
useEffect(() => {
const syncInviteFromUrl = () => {
const syncUrlState = () => {
setInviteCodeFromUrl(readInviteCodeFromUrl());
setHashPathRaw(window.location.hash || '');
};
syncInviteFromUrl();
window.addEventListener('hashchange', syncInviteFromUrl);
window.addEventListener('popstate', syncInviteFromUrl);
syncUrlState();
window.addEventListener('hashchange', syncUrlState);
window.addEventListener('popstate', syncUrlState);
return () => {
window.removeEventListener('hashchange', syncInviteFromUrl);
window.removeEventListener('popstate', syncInviteFromUrl);
window.removeEventListener('hashchange', syncUrlState);
window.removeEventListener('popstate', syncUrlState);
};
}, []);
@@ -383,6 +388,10 @@ export default function App() {
}
}, [phase, profile, session]);
useEffect(() => {
if (phase !== 'app') clearPasswordSecurityCache();
}, [phase]);
useEffect(() => {
if (typeof window === 'undefined') return;
window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes));
@@ -869,6 +878,7 @@ export default function App() {
setDecryptedFolders([]);
setDecryptedCiphers([]);
setDecryptedSends([]);
clearPasswordSecurityCache();
setUnlockPassword('');
setPendingTotp(null);
setPendingTotpMode(null);
@@ -890,6 +900,7 @@ export default function App() {
setSession(null);
clearProfileSnapshot();
clearOfflineUnlockRecord();
clearPasswordSecurityCache();
setProfile(null);
setUnlockPreparing(false);
setPendingTotp(null);
@@ -1165,7 +1176,6 @@ export default function App() {
const key = await encryptSessionUserKeyForAuthRequest(session, authRequest);
await respondToAuthRequest(authedFetch, authRequest.id, {
key,
masterPasswordHash: null,
deviceIdentifier: getCurrentDeviceIdentifier(),
requestApproved: true,
});
@@ -1846,6 +1856,8 @@ export default function App() {
});
const adminActions = useAdminActions({
authedFetch,
email: String(profile?.email || session?.email || ''),
defaultKdfIterations,
onNotify: pushToast,
onSetConfirm: setConfirm,
refetchUsers: usersQuery.refetch,
@@ -1862,7 +1874,6 @@ export default function App() {
await pendingAuthRequestsQuery.refetch();
};
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0];
const trimmedHashPath = hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, '');
@@ -1901,13 +1912,17 @@ export default function App() {
const mobilePrimaryRoute =
location === '/sends'
? '/sends'
: location === '/generator'
? '/generator'
: location === '/vault/totp'
? '/vault/totp'
: location === '/vault'
? '/vault'
: '/settings';
const currentPageTitle = (() => {
if (location === '/security/password-health') return t('txt_password_security');
if (location === '/vault/totp') return t('txt_verification_code');
if (location === '/generator') return t('txt_password_generator');
if (location === '/sends') return t('nav_sends');
if (location === '/admin') return t('nav_admin_panel');
if (location === '/logs') return t('nav_log_center');
@@ -2120,8 +2135,14 @@ export default function App() {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress);
},
onInspectRemoteBackup: backupActions.inspectRemoteBackup,
onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
onInspectRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.inspectRemoteBackup(hash, destinationId, path);
},
onDeleteRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.deleteRemoteBackup(hash, destinationId, path);
},
onRestoreRemoteBackup: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.restoreRemoteBackup(hash, destinationId, path, replaceExisting);
@@ -1,4 +1,4 @@
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, Globe2, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact';
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldCheck, ShieldUser, SlidersHorizontal, Sparkles, Users } from 'lucide-preact';
import type { ComponentChildren } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
import { Link } from 'wouter';
@@ -55,11 +55,12 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
const isDomainRulesRoute = props.location === '/settings/domain-rules';
const isLogRoute = props.location === '/logs';
const isAdmin = isAdminProfile(props.profile);
const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules';
const dataActive = props.location === '/backup' || props.isImportRoute;
const vaultActive = props.location === '/vault' || props.location === '/vault/totp' || props.location === '/security/password-health';
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
const managementActive = props.location === '/admin' || deviceManagementActive || props.location === '/logs';
const settingsActive = props.location === '/settings' || props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules' || deviceManagementActive;
const flatSettingsActive = settingsActive && !deviceManagementActive;
const dataActive = props.location === '/backup' || props.isImportRoute;
const managementActive = props.location === '/admin' || props.location === '/logs';
const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode);
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
@@ -174,14 +175,15 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<>
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
{renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))}
{renderSideLink('/security/password-health', props.location === '/security/password-health', <ShieldCheck size={16} />, t('nav_password_security'))}
{renderSideLink('/generator', props.location === '/generator', <Sparkles size={16} />, t('nav_generator'))}
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
{renderSideLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, <SettingsIcon size={16} />, t('nav_account_settings'))}
{renderSideLink('/settings/domain-rules', props.location === '/settings/domain-rules', <Globe2 size={16} />, t('nav_domain_rules'))}
{renderSideLink('/settings', flatSettingsActive, <SettingsIcon size={16} />, t('txt_settings'))}
{renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
{isAdmin && renderSideLink('/backup', props.location === '/backup', <Cloud size={16} />, t('nav_backup_strategy'))}
{renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))}
{isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
{isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))}
{renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
</>
);
@@ -195,8 +197,10 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<>
{renderSubLink('/vault', props.location === '/vault', t('nav_vault_items'))}
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
{renderSubLink('/security/password-health', props.location === '/security/password-health', t('nav_password_security'))}
</>
)}
{renderSideLink('/generator', props.location === '/generator', <Sparkles size={16} />, t('nav_generator'))}
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
{renderNavGroup(
'settings',
@@ -206,6 +210,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<>
{renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))}
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))}
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
</>
)}
{renderNavGroup(
@@ -226,7 +231,6 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<>
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
</>
)}
</>
@@ -327,6 +331,10 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<Clock3 size={18} />
<span>{t('txt_verification_code')}</span>
</Link>
<Link href="/generator" className={`mobile-tab ${props.mobilePrimaryRoute === '/generator' ? 'active' : ''}`}>
<Sparkles size={18} />
<span>{t('nav_generator')}</span>
</Link>
<Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}>
<SendIcon size={18} />
<span>{t('nav_sends')}</span>
+33 -4
View File
@@ -12,7 +12,9 @@ export interface AppConfirmState {
confirmText?: string;
cancelText?: string;
hideCancel?: boolean;
onConfirm: () => void;
/** When true, dialog shows a master-password field and passes it to onConfirm. */
requireMasterPassword?: boolean;
onConfirm: (masterPassword?: string) => void;
onCancel?: () => void;
}
@@ -63,6 +65,7 @@ function twoFactorProviderLabel(providerType: number): string {
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
const [methodChooserOpen, setMethodChooserOpen] = useState(false);
const [confirmPassword, setConfirmPassword] = useState('');
const availableProviders = useMemo(
() => uniqueSupportedProviders(props.pendingTotpAvailableProviders),
[props.pendingTotpAvailableProviders]
@@ -70,11 +73,16 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType);
const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY;
const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN;
const requireMasterPassword = !!props.confirm?.requireMasterPassword;
useEffect(() => {
setMethodChooserOpen(false);
}, [props.pendingTotpOpen, props.pendingTotpProviderType]);
useEffect(() => {
setConfirmPassword('');
}, [props.confirm?.title, props.confirm?.message, requireMasterPassword]);
return (
<>
<ConfirmDialog
@@ -86,9 +94,30 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
confirmText={props.confirm?.confirmText}
cancelText={props.confirm?.cancelText}
hideCancel={props.confirm?.hideCancel}
onConfirm={() => props.confirm?.onConfirm()}
onCancel={props.confirm?.onCancel || props.onCancelConfirm}
/>
confirmDisabled={requireMasterPassword && !confirmPassword.trim()}
onConfirm={() => {
if (requireMasterPassword && !confirmPassword.trim()) return;
props.confirm?.onConfirm(requireMasterPassword ? confirmPassword : undefined);
setConfirmPassword('');
}}
onCancel={() => {
setConfirmPassword('');
(props.confirm?.onCancel || props.onCancelConfirm)();
}}
>
{requireMasterPassword && (
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
autoComplete="current-password"
value={confirmPassword}
onInput={(e) => setConfirmPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
)}
</ConfirmDialog>
<ConfirmDialog
open={props.pendingTotpOpen}
+64 -36
View File
@@ -1,7 +1,7 @@
import { lazy, Suspense } from 'preact/compat';
import { useEffect } from 'preact/hooks';
import { Link, Route, Switch } from 'wouter';
import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldCheck, ShieldUser } from 'lucide-preact';
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
import LoadingState from '@/components/LoadingState';
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
@@ -13,6 +13,8 @@ import type { ExportRequest } from '@/lib/export-formats';
const VaultPage = lazy(() => import('@/components/VaultPage'));
const SendsPage = lazy(() => import('@/components/SendsPage'));
const PasswordGeneratorPage = lazy(() => import('@/components/PasswordGeneratorPage'));
const PasswordSecurityPage = lazy(() => import('@/components/PasswordSecurityPage'));
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage'));
@@ -168,8 +170,8 @@ export interface AppMainRoutesProps {
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onInspectRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
}
@@ -207,6 +209,16 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
return (
<Switch>
<Route path="/security/password-health">
<Suspense fallback={<RouteContentFallback />}>
<PasswordSecurityPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} />
</Suspense>
</Route>
<Route path="/generator">
<Suspense fallback={<RouteContentFallback />}>
<PasswordGeneratorPage />
</Suspense>
</Route>
<Route path="/sends">
<Suspense fallback={<RouteContentFallback />}>
<SendsPage
@@ -320,43 +332,59 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
</Route>
<Route path="/settings">
{props.profile ? (
<section className="card mobile-settings-card">
<div className="mobile-settings-links">
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
<SettingsIcon size={18} />
<span>{t('nav_account_settings')}</span>
</Link>
<Link href="/settings/security/device-management" className="mobile-settings-link">
<Shield size={18} />
<span>{t('nav_device_management')}</span>
</Link>
<Link href="/settings/domain-rules" className="mobile-settings-link">
<Globe2 size={18} />
<span>{t('nav_domain_rules')}</span>
</Link>
<Link href={props.importRoute} className="mobile-settings-link">
<ArrowUpDown size={18} />
<span>{t('nav_import_export')}</span>
</Link>
{isAdmin && (
<Link href="/admin" className="mobile-settings-link">
<ShieldUser size={18} />
<span>{t('nav_admin_panel')}</span>
<section className="card mobile-settings-card settings-home-card">
<div className="settings-home-section">
<h3>{t('txt_settings')}</h3>
<div className="mobile-settings-links">
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
<SettingsIcon size={18} />
<span>{t('nav_account_settings')}</span>
</Link>
)}
{isAdmin && (
<Link href="/logs" className="mobile-settings-link">
<FileClock size={18} />
<span>{t('nav_log_center')}</span>
<Link href="/security/password-health" className="mobile-settings-link">
<ShieldCheck size={18} />
<span>{t('nav_password_security')}</span>
</Link>
)}
{isAdmin && (
<Link href="/backup" className="mobile-settings-link">
<Cloud size={18} />
<span>{t('nav_backup_strategy')}</span>
<Link href="/settings/security/device-management" className="mobile-settings-link">
<Shield size={18} />
<span>{t('nav_device_management')}</span>
</Link>
)}
<Link href="/settings/domain-rules" className="mobile-settings-link">
<Globe2 size={18} />
<span>{t('nav_domain_rules')}</span>
</Link>
</div>
</div>
<div className="settings-home-section">
<h3>{t('nav_group_data_backup')}</h3>
<div className="mobile-settings-links">
<Link href={props.importRoute} className="mobile-settings-link">
<ArrowUpDown size={18} />
<span>{t('nav_import_export')}</span>
</Link>
{isAdmin && (
<Link href="/backup" className="mobile-settings-link">
<Cloud size={18} />
<span>{t('nav_backup_strategy')}</span>
</Link>
)}
</div>
</div>
{isAdmin && (
<div className="settings-home-section">
<h3>{t('nav_group_management')}</h3>
<div className="mobile-settings-links">
<Link href="/admin" className="mobile-settings-link">
<ShieldUser size={18} />
<span>{t('nav_admin_panel')}</span>
</Link>
<Link href="/logs" className="mobile-settings-link">
<FileClock size={18} />
<span>{t('nav_log_center')}</span>
</Link>
</div>
</div>
)}
<div className="settings-home-spacer" />
<button type="button" className="btn btn-secondary mobile-settings-logout" onClick={props.onLogout}>
<LogOut size={14} className="btn-icon" />
{t('txt_sign_out')}
+35 -2
View File
@@ -1,8 +1,9 @@
import { useState } from 'preact/hooks';
import { ArrowLeft, Eye, EyeOff, KeyRound, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
import { useEffect, useState } from 'preact/hooks';
import { AlertTriangle, ArrowLeft, Eye, EyeOff, KeyRound, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n';
import { getCurrentNetworkStatus, subscribeNetworkStatus, type NetworkStatus } from '@/lib/network-status';
interface LoginValues {
email: string;
@@ -81,6 +82,36 @@ function PasswordField(props: {
);
}
function OfflineModeNotice() {
const [status, setStatus] = useState<NetworkStatus>(getCurrentNetworkStatus);
useEffect(() => subscribeNetworkStatus(setStatus), []);
if (status !== 'offline') return null;
return (
<div className="offline-mode-notice" role="alert" aria-live="assertive">
<div>
<strong>{t('txt_offline_mode_notice_title')}</strong>
<div className="offline-shortcut-list">
<div className="offline-shortcut-row">
<span className="offline-shortcut-label">{t('txt_offline_mode_notice_windows')}</span>
<span className="offline-shortcut-value">
<span className="offline-shortcut-chord"><kbd>Ctrl</kbd><span>+</span><kbd>F5</kbd></span>
</span>
</div>
<div className="offline-shortcut-row">
<span className="offline-shortcut-label">{t('txt_offline_mode_notice_macos')}</span>
<span className="offline-shortcut-value">
<span className="offline-shortcut-chord"><kbd>Command</kbd><span>+</span><kbd>Shift</kbd><span>+</span><kbd>R</kbd></span>
</span>
</div>
</div>
</div>
</div>
);
}
export default function AuthViews(props: AuthViewsProps) {
const loginBusy = props.pendingAction === 'login';
const passkeyBusy = props.pendingAction === 'passkey';
@@ -99,6 +130,7 @@ export default function AuthViews(props: AuthViewsProps) {
props.onSubmitUnlock();
}}
>
<OfflineModeNotice />
<p className="muted standalone-muted">{props.emailForLock}</p>
<input type="text" value={props.emailForLock} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
<PasswordField
@@ -245,6 +277,7 @@ export default function AuthViews(props: AuthViewsProps) {
props.onSubmitLogin();
}}
>
<OfflineModeNotice />
{passkeyPasswordPending ? (
<>
<p className="muted standalone-muted">{props.pendingPasskeyPasswordEmail}</p>
+130 -89
View File
@@ -42,8 +42,8 @@ interface BackupCenterPageProps {
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onInspectRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
@@ -60,6 +60,7 @@ type PendingBackupVerification =
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
| { action: 'runRemoteBackup' }
| { action: 'downloadRemote'; path: string }
| { action: 'deleteRemote'; destinationId: string; path: string }
| { action: 'restoreRemote'; path: string; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult };
interface BackupProgressPhase {
@@ -204,6 +205,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
const [pendingBackupVerification, setPendingBackupVerification] = useState<PendingBackupVerification | null>(null);
const [backupPasswordValue, setBackupPasswordValue] = useState('');
const [backupPasswordError, setBackupPasswordError] = useState('');
const [backupPasswordSubmitting, setBackupPasswordSubmitting] = useState(false);
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
@@ -245,11 +247,29 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
? t('txt_backup_save_settings')
: pendingBackupVerification?.action === 'runRemoteBackup'
? t('txt_backup_run_manual')
: pendingBackupVerification?.action === 'downloadRemote'
? t('txt_backup_remote_download')
: pendingBackupVerification?.action === 'restoreRemote'
? t('txt_backup_import')
: t('txt_backup_import');
: pendingBackupVerification?.action === 'downloadRemote'
? t('txt_backup_remote_download')
: pendingBackupVerification?.action === 'deleteRemote'
? t('txt_delete')
: pendingBackupVerification?.action === 'restoreRemote'
? t('txt_backup_import')
: t('txt_backup_import');
function openBackupPasswordPrompt(request: PendingBackupVerification): void {
setPendingBackupVerification(request);
setBackupPasswordValue('');
setBackupPasswordError('');
}
function showActionError(error: unknown, fallback: string): string {
const message = error instanceof Error ? error.message : fallback;
setLocalError(message);
if (backupPasswordSubmitting || pendingBackupVerification) {
setBackupPasswordError(message);
}
props.onNotify('error', message);
return message;
}
useEffect(() => {
let cancelled = false;
@@ -472,8 +492,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
return verifyBackupFileIntegrity(bytes, file.name || '');
}
async function inspectRemoteBackupFile(destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
const payload = await props.onInspectRemoteBackup(destinationId, path);
async function inspectRemoteBackupFile(masterPassword: string, destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
const payload = await props.onInspectRemoteBackup(masterPassword, destinationId, path);
return {
source: 'remote',
path,
@@ -502,12 +522,11 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete),
};
setPendingBackupVerification({ action: 'deleteDestination', destinationId: destinationIdToDelete, settings: nextSettings });
setBackupPasswordValue('');
openBackupPasswordPrompt({ action: 'deleteDestination', destinationId: destinationIdToDelete, settings: nextSettings });
setConfirmDeleteDestinationOpen(false);
}
async function executeDeleteDestination(masterPassword: string, destinationIdToDelete: string, payload: AdminBackupSettings) {
async function executeDeleteDestination(masterPassword: string, destinationIdToDelete: string, payload: AdminBackupSettings): Promise<boolean> {
setSavingSettings(true);
setLocalError('');
try {
@@ -527,10 +546,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setSelectedDestinationId(nextSelected);
setConfirmDeleteDestinationOpen(false);
props.onNotify('success', t('txt_backup_destination_deleted'));
return true;
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed');
setLocalError(message);
props.onNotify('error', message);
showActionError(error, t('txt_backup_settings_save_failed'));
return false;
} finally {
setSavingSettings(false);
}
@@ -538,22 +557,21 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
async function handleExport() {
if (exporting) return;
setPendingBackupVerification({ action: 'export' });
setBackupPasswordValue('');
openBackupPasswordPrompt({ action: 'export' });
}
async function executeExport(masterPassword: string) {
async function executeExport(masterPassword: string): Promise<boolean> {
setLocalError('');
setExporting(true);
try {
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
await props.onExport(masterPassword, exportIncludeAttachments);
props.onNotify('success', t('txt_backup_export_success'));
return true;
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
setLocalError(message);
props.onNotify('error', message);
showActionError(error, t('txt_backup_export_failed'));
window.setTimeout(() => clearRestoreProgress(), 1200);
return false;
} finally {
setExporting(false);
}
@@ -571,13 +589,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
props.onNotify('error', message);
return;
}
setPendingBackupVerification({
openBackupPasswordPrompt({
action: 'import',
replaceExisting,
allowChecksumMismatch,
knownIntegrity,
});
setBackupPasswordValue('');
}
async function executeLocalRestore(
@@ -585,13 +602,14 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
) {
if (importing) return;
): Promise<boolean> {
if (importing) return false;
if (!selectedFile) {
const message = t('txt_backup_file_required');
setLocalError(message);
setBackupPasswordError(message);
props.onNotify('error', message);
return;
return false;
}
setLocalError('');
setConfirmLocalRestoreOpen(false);
@@ -614,17 +632,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(false);
resetPendingIntegrityWarning();
return true;
} catch (error) {
if (!replaceExisting && isReplaceRequiredError(error)) {
clearRestoreProgress();
setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(true);
return;
return true;
}
const message = error instanceof Error ? error.message : t('txt_backup_restore_failed');
setLocalError(message);
props.onNotify('error', message);
showActionError(error, t('txt_backup_restore_failed'));
window.setTimeout(() => clearRestoreProgress(), 1200);
return false;
} finally {
setImporting(false);
}
@@ -632,11 +650,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
async function handleSaveSettings() {
if (savingSettings) return;
setPendingBackupVerification({ action: 'saveSettings' });
setBackupPasswordValue('');
openBackupPasswordPrompt({ action: 'saveSettings' });
}
async function executeSaveSettings(masterPassword: string) {
async function executeSaveSettings(masterPassword: string): Promise<boolean> {
const payload = buildSettingsPayloadForSelectedDestination();
const destinationIdToInvalidate = selectedDestinationId;
setSavingSettings(true);
@@ -656,10 +673,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
}
setSelectedDestinationId(nextSelected);
props.onNotify('success', t('txt_backup_settings_saved'));
return true;
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed');
setLocalError(message);
props.onNotify('error', message);
showActionError(error, t('txt_backup_settings_save_failed'));
return false;
} finally {
setSavingSettings(false);
}
@@ -678,12 +695,11 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
async function handleRunRemoteBackup() {
if (!selectedDestination || runningRemoteBackup) return;
setPendingBackupVerification({ action: 'runRemoteBackup' });
setBackupPasswordValue('');
openBackupPasswordPrompt({ action: 'runRemoteBackup' });
}
async function executeRunRemoteBackup(masterPassword: string) {
if (!selectedDestination) return;
async function executeRunRemoteBackup(masterPassword: string): Promise<boolean> {
if (!selectedDestination) return false;
setRunningRemoteBackup(true);
setLocalError('');
try {
@@ -697,32 +713,31 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setSelectedDestinationId(selectedDestination.id);
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName }));
return true;
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
setLocalError(message);
props.onNotify('error', message);
showActionError(error, t('txt_backup_remote_run_failed'));
window.setTimeout(() => clearRestoreProgress(), 1200);
return false;
} finally {
setRunningRemoteBackup(false);
}
}
async function handleDownloadRemote(path: string) {
setPendingBackupVerification({ action: 'downloadRemote', path });
setBackupPasswordValue('');
openBackupPasswordPrompt({ action: 'downloadRemote', path });
}
async function executeDownloadRemote(masterPassword: string, path: string) {
if (!savedSelectedDestination) return;
async function executeDownloadRemote(masterPassword: string, path: string): Promise<boolean> {
if (!savedSelectedDestination) return false;
setDownloadingRemotePath(path);
setDownloadingRemotePercent(null);
setLocalError('');
try {
await props.onDownloadRemoteBackup(masterPassword, savedSelectedDestination.id, path, setDownloadingRemotePercent);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed');
setLocalError(message);
props.onNotify('error', message);
showActionError(error, t('txt_backup_remote_download_failed'));
return false;
} finally {
setDownloadingRemotePath('');
setDownloadingRemotePercent(null);
@@ -732,18 +747,24 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
async function handleDeleteRemote(path: string) {
if (deletingRemotePath) return;
if (!savedSelectedDestination) return;
openBackupPasswordPrompt({ action: 'deleteRemote', destinationId: savedSelectedDestination.id, path });
setConfirmRemoteDeleteOpen(false);
}
async function executeDeleteRemote(masterPassword: string, destinationId: string, path: string): Promise<boolean> {
if (deletingRemotePath) return false;
setDeletingRemotePath(path);
setLocalError('');
try {
await props.onDeleteRemoteBackup(savedSelectedDestination.id, path);
await props.onDeleteRemoteBackup(masterPassword, destinationId, path);
setConfirmRemoteDeleteOpen(false);
setPendingRemoteDeletePath('');
await loadRemoteBrowser(savedSelectedDestination.id, currentRemoteBrowserPath, { force: true });
await loadRemoteBrowser(destinationId, remoteBrowserPathByDestination[destinationId] || '', { force: true });
props.onNotify('success', t('txt_backup_remote_delete_success'));
return true;
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_delete_failed');
setLocalError(message);
props.onNotify('error', message);
showActionError(error, t('txt_backup_remote_delete_failed'));
return false;
} finally {
setDeletingRemotePath('');
}
@@ -779,19 +800,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
if (!savedSelectedDestination) return;
setLocalError('');
resetPendingIntegrityWarning();
try {
const integrity = await inspectRemoteBackupFile(savedSelectedDestination.id, path);
if (!integrity.result.matches) {
setPendingRestoreIntegrity(integrity);
setConfirmIntegrityWarningOpen(true);
return;
}
await runRemoteRestore(path, false, false, integrity.result);
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed');
setLocalError(message);
props.onNotify('error', message);
}
await runRemoteRestore(path, false);
}
async function runRemoteRestore(
@@ -802,14 +811,13 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
) {
if (restoringRemotePath) return;
if (!savedSelectedDestination) return;
setPendingBackupVerification({
openBackupPasswordPrompt({
action: 'restoreRemote',
path,
replaceExisting,
allowChecksumMismatch,
knownIntegrity,
});
setBackupPasswordValue('');
}
async function executeRemoteRestore(
@@ -818,15 +826,31 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
) {
if (restoringRemotePath) return;
if (!savedSelectedDestination) return;
): Promise<boolean> {
if (restoringRemotePath) return false;
if (!savedSelectedDestination) return false;
setConfirmRemoteReplaceOpen(false);
setConfirmIntegrityWarningOpen(false);
setRestoringRemotePath(path);
setLocalError('');
try {
const integrity = knownIntegrity ? { result: knownIntegrity } : await inspectRemoteBackupFile(savedSelectedDestination.id, path);
const integrity = knownIntegrity
? { result: knownIntegrity }
: await inspectRemoteBackupFile(masterPassword, savedSelectedDestination.id, path);
if (!allowChecksumMismatch && !integrity.result.matches) {
setPendingRestoreIntegrity(
'source' in integrity
? integrity
: {
source: 'remote',
path,
fileName: path.split('/').pop() || path,
result: integrity.result,
}
);
setConfirmIntegrityWarningOpen(true);
return true;
}
startRestoreProgress('backup-restore', path.split('/').pop() || path, {
source: 'remote',
delayMs: replaceExisting ? 480 : 1400,
@@ -840,17 +864,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const skippedMessage = buildSkippedImportMessage(result);
if (skippedMessage) props.onNotify('warning', skippedMessage);
resetPendingIntegrityWarning();
return true;
} catch (error) {
if (!replaceExisting && isReplaceRequiredError(error)) {
setPendingRemoteRestorePath(path);
setConfirmRemoteReplaceOpen(true);
clearRestoreProgress();
return;
return true;
}
const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed');
setLocalError(message);
props.onNotify('error', message);
showActionError(error, t('txt_backup_remote_restore_failed'));
window.setTimeout(() => clearRestoreProgress(), 1200);
return false;
} finally {
setRestoringRemotePath('');
}
@@ -861,31 +885,38 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const masterPassword = backupPasswordValue;
if (!request || backupPasswordSubmitting) return;
if (!masterPassword.trim()) {
props.onNotify('error', t('txt_master_password_is_required'));
setBackupPasswordError(t('txt_master_password_is_required'));
return;
}
setBackupPasswordSubmitting(true);
setPendingBackupVerification(null);
setBackupPasswordValue('');
setBackupPasswordError('');
let succeeded = false;
try {
if (request.action === 'export') {
await executeExport(masterPassword);
succeeded = await executeExport(masterPassword);
} else if (request.action === 'saveSettings') {
await executeSaveSettings(masterPassword);
succeeded = await executeSaveSettings(masterPassword);
} else if (request.action === 'deleteDestination') {
await executeDeleteDestination(masterPassword, request.destinationId, request.settings);
succeeded = await executeDeleteDestination(masterPassword, request.destinationId, request.settings);
} else if (request.action === 'import') {
await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
succeeded = await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
} else if (request.action === 'runRemoteBackup') {
await executeRunRemoteBackup(masterPassword);
succeeded = await executeRunRemoteBackup(masterPassword);
} else if (request.action === 'downloadRemote') {
await executeDownloadRemote(masterPassword, request.path);
succeeded = await executeDownloadRemote(masterPassword, request.path);
} else if (request.action === 'deleteRemote') {
succeeded = await executeDeleteRemote(masterPassword, request.destinationId, request.path);
} else if (request.action === 'restoreRemote') {
await executeRemoteRestore(masterPassword, request.path, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
succeeded = await executeRemoteRestore(masterPassword, request.path, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
}
} finally {
setBackupPasswordSubmitting(false);
}
if (succeeded) {
setPendingBackupVerification(null);
setBackupPasswordValue('');
setBackupPasswordError('');
}
}
return (
@@ -1031,17 +1062,27 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
if (backupPasswordSubmitting) return;
setPendingBackupVerification(null);
setBackupPasswordValue('');
setBackupPasswordError('');
}}
>
<label className="field">
<span>{t('txt_master_password')}</span>
<input
id="backup-master-password"
className="input"
type="password"
autoComplete="current-password"
value={backupPasswordValue}
onInput={(event) => setBackupPasswordValue((event.currentTarget as HTMLInputElement).value)}
aria-invalid={!!backupPasswordError}
aria-describedby={backupPasswordError ? 'backup-master-password-error' : undefined}
onInput={(event) => {
setBackupPasswordValue((event.currentTarget as HTMLInputElement).value);
if (backupPasswordError) setBackupPasswordError('');
}}
/>
{backupPasswordError ? (
<div id="backup-master-password-error" className="local-error" role="alert">{backupPasswordError}</div>
) : null}
</label>
</ConfirmDialog>
+3 -1
View File
@@ -87,6 +87,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
const cardRef = useRef<HTMLFormElement | null>(null);
const maskPointerStartedRef = useRef(false);
const restoreFocusRef = useRef<HTMLElement | null>(null);
const lastTitleRef = useRef<ComponentChildren>(props.title);
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
const titleId = `${dialogId}-title`;
const messageId = `${dialogId}-message`;
@@ -95,6 +96,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
useEffect(() => {
if (props.open) {
lastTitleRef.current = props.title;
setPresent(true);
setClosing(false);
return;
@@ -228,7 +230,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
<X size={18} />
</button>
)}
<h3 id={titleId} className="dialog-title">{props.title}</h3>
<h3 id={titleId} className="dialog-title">{props.open ? props.title : lastTitleRef.current}</h3>
{hasMessage && <div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>}
{props.children}
{!props.hideConfirm && (
+133 -4
View File
@@ -1,7 +1,7 @@
import { useState } from 'preact/hooks';
import { argon2idAsync } from '@noble/hashes/argon2.js';
import { createPortal } from 'preact/compat';
import { strFromU8, unzipSync } from 'fflate';
import { strFromU8, unzipSync, type UnzipFileInfo } from 'fflate';
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
import { Download, FileUp } from 'lucide-preact';
import ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog';
@@ -96,6 +96,12 @@ const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [
'keepassx_csv',
];
const MAX_IMPORT_ZIP_BYTES = 256 * 1024 * 1024;
const MAX_IMPORT_ZIP_ENTRY_COUNT = 10_000;
const MAX_IMPORT_TEXT_ENTRY_BYTES = 32 * 1024 * 1024;
const MAX_IMPORT_ATTACHMENT_BYTES = 100 * 1024 * 1024;
const MAX_IMPORT_ATTACHMENT_TOTAL_BYTES = 512 * 1024 * 1024;
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
@@ -171,8 +177,85 @@ function isZipPayload(bytes: Uint8Array): boolean {
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
}
function formatMiB(bytes: number): string {
return String(Math.floor(bytes / (1024 * 1024)));
}
function zipEntryName(rawName: unknown): string {
return String(rawName || '').trim().replace(/\\/g, '/');
}
function assertSafeZipEntryName(name: string): void {
if (!name || name.includes('\0') || name.startsWith('/') || name.includes('//')) {
throw new Error(t('txt_import_zip_unsafe_file_name'));
}
const parts = name.split('/');
if (parts.some((part) => part === '.' || part === '..')) {
throw new Error(t('txt_import_zip_unsafe_file_name'));
}
}
function assertImportZipSize(bytes: number): void {
if (bytes > MAX_IMPORT_ZIP_BYTES) {
throw new Error(t('txt_import_zip_too_large', { size: formatMiB(MAX_IMPORT_ZIP_BYTES) }));
}
}
function assertImportTextFileSize(bytes: number): void {
if (bytes > MAX_IMPORT_TEXT_ENTRY_BYTES) {
throw new Error(t('txt_import_file_too_large', { size: formatMiB(MAX_IMPORT_TEXT_ENTRY_BYTES) }));
}
}
function assertImportEntrySize(size: number, maxBytes: number): void {
if (size > maxBytes) {
throw new Error(t('txt_import_zip_entry_too_large', { size: formatMiB(maxBytes) }));
}
}
function isImportTextZipCandidate(source: ImportSourceId, name: string): boolean {
const lower = name.toLowerCase();
if (source === 'onepassword_1pux') {
return lower.endsWith('/export.data') || lower === 'export.data' || lower.endsWith('/export.json') || lower === 'export.json' || lower.endsWith('.json');
}
return lower.endsWith('/protonpass.json') || lower === 'protonpass.json' || lower.endsWith('/export.json') || lower === 'export.json' || lower.endsWith('.json');
}
function createImportTextZipFilter(source: ImportSourceId): (file: UnzipFileInfo) => boolean {
let entryCount = 0;
let totalTextBytes = 0;
return (entry: UnzipFileInfo): boolean => {
entryCount += 1;
if (entryCount > MAX_IMPORT_ZIP_ENTRY_COUNT) {
throw new Error(t('txt_import_zip_too_many_files'));
}
const name = zipEntryName(entry.name);
assertSafeZipEntryName(name);
if (!isImportTextZipCandidate(source, name)) return false;
const originalSize = Number(entry.originalSize);
if (!Number.isFinite(originalSize) || originalSize < 0) {
throw new Error(t('txt_import_zip_entry_too_large', { size: formatMiB(MAX_IMPORT_TEXT_ENTRY_BYTES) }));
}
assertImportEntrySize(originalSize, MAX_IMPORT_TEXT_ENTRY_BYTES);
totalTextBytes += originalSize;
if (totalTextBytes > MAX_IMPORT_TEXT_ENTRY_BYTES) {
throw new Error(t('txt_import_zip_expands_too_large', { size: formatMiB(MAX_IMPORT_TEXT_ENTRY_BYTES) }));
}
return true;
};
}
function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
const unzipped = unzipSync(bytes);
assertImportZipSize(bytes.byteLength);
const unzippedRaw = unzipSync(bytes, { filter: createImportTextZipFilter(source) });
const unzipped: Record<string, Uint8Array> = {};
for (const [rawName, entryBytes] of Object.entries(unzippedRaw)) {
const name = zipEntryName(rawName);
assertSafeZipEntryName(name);
assertImportEntrySize(entryBytes.byteLength, MAX_IMPORT_TEXT_ENTRY_BYTES);
unzipped[name] = entryBytes;
}
const fileNames = Object.keys(unzipped);
if (!fileNames.length) throw new Error(t('txt_import_empty_zip_archive'));
@@ -189,10 +272,13 @@ function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
async function readImportText(file: File, source: ImportSourceId): Promise<string> {
if (source !== 'onepassword_1pux' && source !== 'protonpass_json') {
assertImportTextFileSize(file.size);
return file.text();
}
assertImportZipSize(file.size);
const bytes = new Uint8Array(await file.arrayBuffer());
if (isZipPayload(bytes)) return readZipText(bytes, source);
assertImportTextFileSize(bytes.byteLength);
return new TextDecoder().decode(bytes);
}
@@ -211,34 +297,77 @@ function looksLikeZipPasswordError(error: unknown): boolean {
return message.includes('password') || message.includes('encrypted');
}
function bitwardenZipAttachmentMatch(name: string): RegExpMatchArray | null {
return name.match(/^attachments\/([^/]+)\/(.+)$/i);
}
function zipJsEntrySize(entry: unknown): number | null {
const size = Number((entry as { uncompressedSize?: unknown })?.uncompressedSize);
return Number.isFinite(size) && size >= 0 ? size : null;
}
function validateBitwardenZipEntries(entries: Awaited<ReturnType<ZipReader<unknown>['getEntries']>>): void {
if (entries.length > MAX_IMPORT_ZIP_ENTRY_COUNT) {
throw new Error(t('txt_import_zip_too_many_files'));
}
let totalAttachmentBytes = 0;
for (const entry of entries) {
if (entry.directory) continue;
const name = zipEntryName(entry.filename);
assertSafeZipEntryName(name);
const lower = name.toLowerCase();
const size = zipJsEntrySize(entry);
if (lower === 'data.json' && size != null) {
assertImportEntrySize(size, MAX_IMPORT_TEXT_ENTRY_BYTES);
} else if (bitwardenZipAttachmentMatch(name) && size != null) {
assertImportEntrySize(size, MAX_IMPORT_ATTACHMENT_BYTES);
totalAttachmentBytes += size;
if (totalAttachmentBytes > MAX_IMPORT_ATTACHMENT_TOTAL_BYTES) {
throw new Error(t('txt_import_zip_expands_too_large', { size: formatMiB(MAX_IMPORT_ATTACHMENT_TOTAL_BYTES) }));
}
}
}
}
async function readBitwardenZipPayload(
file: File,
passwordRaw: string
): Promise<{ jsonText: string; attachments: ImportAttachmentFile[] }> {
const password = String(passwordRaw || '').trim();
assertImportZipSize(file.size);
const reader = new ZipReader(new BlobReader(file), { useWebWorkers: false });
try {
const entries = await reader.getEntries();
if (!entries.length) throw new Error(t('txt_import_empty_zip_archive'));
validateBitwardenZipEntries(entries);
let jsonText = '';
let totalAttachmentBytes = 0;
const attachments: ImportAttachmentFile[] = [];
const options = password ? { password } : undefined;
for (const entry of entries) {
if (entry.directory) continue;
const name = String(entry.filename || '').trim().replace(/\\/g, '/');
const name = zipEntryName(entry.filename);
if (!name) continue;
assertSafeZipEntryName(name);
const bytes = await entry.getData(new Uint8ArrayWriter(), options);
const lower = name.toLowerCase();
if (lower === 'data.json') {
assertImportEntrySize(bytes.byteLength, MAX_IMPORT_TEXT_ENTRY_BYTES);
jsonText = new TextDecoder().decode(bytes);
continue;
}
const attachmentMatch = name.match(/^attachments\/([^/]+)\/(.+)$/i);
const attachmentMatch = bitwardenZipAttachmentMatch(name);
if (!attachmentMatch) continue;
assertImportEntrySize(bytes.byteLength, MAX_IMPORT_ATTACHMENT_BYTES);
totalAttachmentBytes += bytes.byteLength;
if (totalAttachmentBytes > MAX_IMPORT_ATTACHMENT_TOTAL_BYTES) {
throw new Error(t('txt_import_zip_expands_too_large', { size: formatMiB(MAX_IMPORT_ATTACHMENT_TOTAL_BYTES) }));
}
const sourceCipherId = String(attachmentMatch[1] || '').trim() || null;
const fileName = String(attachmentMatch[2] || '').trim() || 'attachment.bin';
attachments.push({
+11 -2
View File
@@ -129,6 +129,10 @@ function formatReason(reason: string): string {
return translatedOrHumanized(keyFor('txt_log_reason_', reason), reason);
}
function formatTargetType(type: string): string {
return translatedOrHumanized(keyFor('txt_log_target_type_', type), type);
}
function formatTime(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
@@ -148,11 +152,16 @@ function formatMetaValueForKey(key: string, value: unknown): string {
return translatedOrHumanized(keyFor('txt_log_trigger_', value), value);
}
if (key === 'type' && typeof value === 'string') {
return translatedOrHumanized(keyFor('txt_log_target_type_', value), value);
return formatTargetType(value);
}
return formatMetaValue(value);
}
function formatLogTarget(log: AuditLogEntry, metadata: Record<string, unknown>): string {
const targetEmail = typeof metadata.targetEmail === 'string' ? metadata.targetEmail : '';
return log.targetUserEmail || targetEmail || log.targetId || (log.targetType ? formatTargetType(log.targetType) : t('txt_dash'));
}
function iconForCategory(category: AuditLogCategory) {
if (category === 'auth') return <ShieldAlert size={16} />;
if (category === 'security') return <UserRound size={16} />;
@@ -550,7 +559,7 @@ export default function LogCenterPage(props: LogCenterPageProps) {
<div><span>{t('txt_time')}</span><strong>{formatTime(selectedLog.createdAt)}</strong></div>
<div><span>{t('txt_log_category')}</span><strong>{t(`txt_log_category_${selectedCategory}`)}</strong></div>
<div><span>{t('txt_actor')}</span><strong>{selectedLog.actorEmail || selectedLog.actorUserId || t('txt_dash')}</strong></div>
<div><span>{t('txt_target')}</span><strong>{selectedLog.targetUserEmail || String(selectedMetadata.targetEmail || '') || selectedLog.targetId || selectedLog.targetType || t('txt_dash')}</strong></div>
<div><span>{t('txt_target')}</span><strong>{formatLogTarget(selectedLog, selectedMetadata)}</strong></div>
</div>
<div className="log-detail-json">
<h4>{t('txt_metadata')}</h4>
@@ -0,0 +1,247 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Check, Copy, Minus, Plus, RefreshCw, ShieldCheck } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import { EFFLongWordList } from '@/lib/eff-word-list';
import { t } from '@/lib/i18n';
type GeneratorMode = 'password' | 'passphrase';
interface PasswordOptions {
length: number;
uppercase: boolean;
lowercase: boolean;
numbers: boolean;
special: boolean;
minNumbers: number;
minSpecial: number;
avoidAmbiguous: boolean;
}
interface PassphraseOptions {
words: number;
separator: string;
capitalize: boolean;
includeNumber: boolean;
}
const SETTINGS_KEY = 'nodewarden.passwordGenerator.settings.v1';
const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz';
const DIGITS = '0123456789';
const SPECIAL = '!@#$%^&*';
const AMBIGUOUS = new Set(['I', 'L', 'O', 'l', 'o', '0', '1']);
const defaultPasswordOptions: PasswordOptions = {
length: 14,
uppercase: true,
lowercase: true,
numbers: true,
special: false,
minNumbers: 1,
minSpecial: 1,
avoidAmbiguous: false,
};
const defaultPassphraseOptions: PassphraseOptions = {
words: 6,
separator: '-',
capitalize: false,
includeNumber: false,
};
function clamp(value: unknown, minimum: number, maximum: number, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, Math.round(parsed))) : fallback;
}
function readSettings(): { mode: GeneratorMode; password: PasswordOptions; passphrase: PassphraseOptions } {
try {
const stored = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') as Partial<{ mode: GeneratorMode; password: Partial<PasswordOptions>; passphrase: Partial<PassphraseOptions> }>;
return {
mode: stored.mode === 'passphrase' ? 'passphrase' : 'password',
password: {
...defaultPasswordOptions,
...stored.password,
length: clamp(stored.password?.length, 5, 128, defaultPasswordOptions.length),
minNumbers: clamp(stored.password?.minNumbers, 0, 9, defaultPasswordOptions.minNumbers),
minSpecial: clamp(stored.password?.minSpecial, 0, 9, defaultPasswordOptions.minSpecial),
},
passphrase: {
...defaultPassphraseOptions,
...stored.passphrase,
words: clamp(stored.passphrase?.words, 3, 20, defaultPassphraseOptions.words),
separator: String(stored.passphrase?.separator ?? defaultPassphraseOptions.separator).slice(0, 1),
},
};
} catch {
return { mode: 'password', password: defaultPasswordOptions, passphrase: defaultPassphraseOptions };
}
}
function randomIndex(length: number): number {
const range = 0x1_0000_0000;
const upperBound = Math.floor(range / length) * length;
const buffer = new Uint32Array(1);
do crypto.getRandomValues(buffer); while (buffer[0] >= upperBound);
return buffer[0] % length;
}
function pick(characters: string): string {
return characters[randomIndex(characters.length)];
}
function shuffle(value: string[]): string[] {
for (let index = value.length - 1; index > 0; index -= 1) {
const next = randomIndex(index + 1);
[value[index], value[next]] = [value[next], value[index]];
}
return value;
}
function filtered(characters: string, avoidAmbiguous: boolean): string {
return avoidAmbiguous ? characters.split('').filter((character) => !AMBIGUOUS.has(character)).join('') : characters;
}
function generatePassword(options: PasswordOptions): string {
const sets: Array<{ chars: string; minimum: number }> = [];
if (options.uppercase) sets.push({ chars: filtered(UPPERCASE, options.avoidAmbiguous), minimum: 1 });
if (options.lowercase) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 });
if (options.numbers) sets.push({ chars: filtered(DIGITS, options.avoidAmbiguous), minimum: options.minNumbers });
if (options.special) sets.push({ chars: SPECIAL, minimum: options.minSpecial });
if (!sets.length) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 });
const minimumLength = sets.reduce((total, set) => total + set.minimum, 0);
const length = Math.max(options.length, minimumLength, 5);
const allCharacters = sets.map((set) => set.chars).join('');
const characters = sets.flatMap((set) => Array.from({ length: set.minimum }, () => pick(set.chars)));
while (characters.length < length) characters.push(pick(allCharacters));
return shuffle(characters).join('');
}
function generatePassphrase(options: PassphraseOptions): string {
const words = Array.from({ length: options.words }, () => EFFLongWordList[randomIndex(EFFLongWordList.length)]);
if (options.capitalize) {
for (let index = 0; index < words.length; index += 1) words[index] = words[index][0].toUpperCase() + words[index].slice(1);
}
if (options.includeNumber) words[randomIndex(words.length)] += String(randomIndex(10));
return words.join(options.separator);
}
function strengthLabel(mode: GeneratorMode, value: string): { label: string; score: number } {
const score = mode === 'password' ? Math.min(4, Math.max(1, Math.floor(value.length / 5))) : Math.min(4, Math.max(1, Math.floor(value.split(/[-_. ]/).filter(Boolean).length / 2)));
return { score, label: t(['txt_password_strength_weak', 'txt_password_strength_fair', 'txt_password_strength_good', 'txt_password_strength_strong'][score - 1]) };
}
export default function PasswordGeneratorPage() {
const initial = useMemo(readSettings, []);
const [mode, setMode] = useState<GeneratorMode>(initial.mode);
const [passwordOptions, setPasswordOptions] = useState<PasswordOptions>(initial.password);
const [passphraseOptions, setPassphraseOptions] = useState<PassphraseOptions>(initial.passphrase);
const [seed, setSeed] = useState(0);
const [copied, setCopied] = useState(false);
const generated = useMemo(
() => (mode === 'password' ? generatePassword(passwordOptions) : generatePassphrase(passphraseOptions)),
[mode, passwordOptions, passphraseOptions, seed]
);
const strength = useMemo(() => strengthLabel(mode, generated), [generated, mode]);
useEffect(() => {
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ mode, password: passwordOptions, passphrase: passphraseOptions }));
} catch {
// The generator remains fully usable when browser storage is unavailable.
}
}, [mode, passwordOptions, passphraseOptions]);
const regenerate = () => {
setCopied(false);
setSeed((value) => value + 1);
};
const copy = async () => {
await copyTextToClipboard(generated, { onSuccess: () => setCopied(true), onError: () => setCopied(false) });
window.setTimeout(() => setCopied(false), 1600);
};
const changePasswordOption = <K extends keyof PasswordOptions>(key: K, value: PasswordOptions[K]) => {
setPasswordOptions((current) => ({ ...current, [key]: value }));
setCopied(false);
};
const changePassphraseOption = <K extends keyof PassphraseOptions>(key: K, value: PassphraseOptions[K]) => {
setPassphraseOptions((current) => ({ ...current, [key]: value }));
setCopied(false);
};
return (
<section className="generator-page" aria-label={t('txt_password_generator')}>
<div className="generator-layout">
<section className="generator-output-card" aria-live="polite">
<div className="settings-category-tabs" role="tablist" aria-label={t('txt_generator_type')}>
<button type="button" role="tab" aria-selected={mode === 'password'} className={`settings-category-tab ${mode === 'password' ? 'active' : ''}`} onClick={() => setMode('password')}>{t('txt_password')}</button>
<button type="button" role="tab" aria-selected={mode === 'passphrase'} className={`settings-category-tab ${mode === 'passphrase' ? 'active' : ''}`} onClick={() => setMode('passphrase')}>{t('txt_passphrase')}</button>
</div>
<output className="generator-value" aria-label={t('txt_generated_password')}>{generated}</output>
<div className="generator-strength-row">
<div className="generator-strength" aria-label={`${t('txt_password_strength')}: ${strength.label}`}>
{[1, 2, 3, 4].map((level) => <span key={level} className={level <= strength.score ? `active level-${strength.score}` : ''} />)}
</div>
<span><ShieldCheck size={15} /> {strength.label}</span>
</div>
<div className="actions generator-actions">
<button type="button" className="btn btn-primary" onClick={regenerate}><RefreshCw size={16} className="btn-icon" />{t('txt_regenerate')}</button>
<button type="button" className="btn btn-secondary" onClick={() => void copy()}><Copy size={16} className="btn-icon" />{copied ? t('txt_copied') : t('txt_copy')}</button>
</div>
<p className="generator-security-note"><Check size={15} />{t('txt_generator_security_note')}</p>
</section>
<section className="generator-options-card" aria-labelledby="generator-options-title">
<h2 id="generator-options-title">{t('txt_options')}</h2>
{mode === 'password' ? (
<>
<GeneratorNumberStepper id="length" label={t('txt_generator_length')} value={passwordOptions.length} minimum={5} maximum={128} fallback={14} onChange={(value) => changePasswordOption('length', value)} />
<fieldset className="generator-option-group"><legend>{t('txt_generator_character_types')}</legend>
<GeneratorToggle checked={passwordOptions.uppercase} onChange={(checked) => changePasswordOption('uppercase', checked)} label={t('txt_generator_uppercase')} />
<GeneratorToggle checked={passwordOptions.lowercase} onChange={(checked) => changePasswordOption('lowercase', checked)} label={t('txt_generator_lowercase')} />
<GeneratorToggle checked={passwordOptions.numbers} onChange={(checked) => changePasswordOption('numbers', checked)} label={t('txt_generator_numbers')} />
{passwordOptions.numbers && <GeneratorNumberStepper id="min-numbers" compact label={t('txt_generator_minimum')} value={passwordOptions.minNumbers} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minNumbers', value)} />}
<GeneratorToggle checked={passwordOptions.special} onChange={(checked) => changePasswordOption('special', checked)} label={t('txt_generator_special')} />
{passwordOptions.special && <GeneratorNumberStepper id="min-special" compact label={t('txt_generator_minimum')} value={passwordOptions.minSpecial} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minSpecial', value)} />}
</fieldset>
<GeneratorToggle checked={passwordOptions.avoidAmbiguous} onChange={(checked) => changePasswordOption('avoidAmbiguous', checked)} label={t('txt_generator_avoid_ambiguous')} />
</>
) : (
<>
<GeneratorNumberStepper id="words" label={t('txt_generator_words')} value={passphraseOptions.words} minimum={3} maximum={20} fallback={6} onChange={(value) => changePassphraseOption('words', value)} />
<label className="generator-number-field" htmlFor="generator-separator"><span>{t('txt_generator_separator')}</span><input id="generator-separator" className="input" type="text" maxLength={1} value={passphraseOptions.separator} onInput={(event) => changePassphraseOption('separator', event.currentTarget.value.slice(0, 1))} /></label>
<div className="generator-option-group">
<GeneratorToggle checked={passphraseOptions.capitalize} onChange={(checked) => changePassphraseOption('capitalize', checked)} label={t('txt_generator_capitalize')} />
<GeneratorToggle checked={passphraseOptions.includeNumber} onChange={(checked) => changePassphraseOption('includeNumber', checked)} label={t('txt_generator_include_number')} />
</div>
</>
)}
</section>
</div>
</section>
);
}
function GeneratorToggle(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) {
return <label className="generator-toggle"><input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.currentTarget.checked)} /><span aria-hidden="true" /><strong>{props.label}</strong></label>;
}
function GeneratorNumberStepper(props: { id: string; label: string; value: number; minimum: number; maximum: number; fallback: number; compact?: boolean; onChange: (value: number) => void }) {
const id = `generator-stepper-${props.id}`;
const setValue = (value: number) => props.onChange(clamp(value, props.minimum, props.maximum, props.fallback));
return (
<div className={`generator-number-field ${props.compact ? 'compact' : ''}`}>
<label htmlFor={id}>{props.label}</label>
<div className="generator-stepper">
<button type="button" aria-label={`${props.label} -`} disabled={props.value <= props.minimum} onClick={() => setValue(props.value - 1)}><Minus size={15} /></button>
<input id={id} className="input" type="text" inputMode="numeric" pattern="[0-9]*" value={props.value} onInput={(event) => setValue(Number(event.currentTarget.value))} />
<button type="button" aria-label={`${props.label} +`} disabled={props.value >= props.maximum} onClick={() => setValue(props.value + 1)}><Plus size={15} /></button>
</div>
</div>
);
}
@@ -0,0 +1,169 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { AlertTriangle, CheckCircle2, ExternalLink, Eye, EyeOff, RefreshCw, ScanSearch, ShieldAlert, ShieldCheck, Unplug } from 'lucide-preact';
import { Link } from 'wouter';
import { maskSecret } from '@/components/vault/vault-page-helpers';
import { getPasswordSecurityState, readPasswordSecurityState, startPasswordSecurityScan, subscribePasswordSecurityState } from '@/lib/password-security-cache';
import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types';
interface PasswordSecurityPageProps {
ciphers: Cipher[];
loading: boolean;
}
type PasswordSecurityFilter = 'exposed' | 'reused' | 'weak' | 'all';
function vaultFingerprint(ciphers: Cipher[]): string {
return JSON.stringify(ciphers.map((cipher) => ({
id: cipher.id,
type: cipher.type,
revisionDate: cipher.revisionDate || '',
deletedDate: cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt || '',
})));
}
function formatCheckedAt(value: number): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(value);
}
export default function PasswordSecurityPage(props: PasswordSecurityPageProps) {
const fingerprint = vaultFingerprint(props.ciphers);
const [securityState, setSecurityState] = useState(() => getPasswordSecurityState(fingerprint));
const [filter, setFilter] = useState<PasswordSecurityFilter>('all');
const [revealedPasswordIds, setRevealedPasswordIds] = useState<Set<string>>(() => new Set());
useEffect(() => {
setSecurityState(getPasswordSecurityState(fingerprint));
setFilter('all');
setRevealedPasswordIds(new Set());
return subscribePasswordSecurityState(() => {
const next = readPasswordSecurityState(fingerprint);
if (next) setSecurityState(next);
});
}, [fingerprint]);
const { report, scannedAt, scanning, progress, scanError } = securityState;
const eligibleCount = useMemo(
() => props.ciphers.filter((cipher) => Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword).length,
[props.ciphers],
);
const ciphersById = useMemo(() => new Map(props.ciphers.map((cipher) => [cipher.id, cipher])), [props.ciphers]);
const filteredItems = useMemo(() => {
if (!report || filter === 'all') return report?.items || [];
if (filter === 'exposed') return report.items.filter((item) => (item.exposedCount || 0) > 0);
if (filter === 'reused') return report.items.filter((item) => item.reusedCount > 1);
return report.items.filter((item) => item.weak);
}, [filter, report]);
const allPasswordsVisible = !!report?.items.length && report.items.every((item) => revealedPasswordIds.has(item.cipherId));
const togglePasswordVisibility = (cipherId: string) => {
setRevealedPasswordIds((current) => {
const next = new Set(current);
if (next.has(cipherId)) next.delete(cipherId);
else next.add(cipherId);
return next;
});
};
const toggleAllPasswordVisibility = () => {
if (!report) return;
setRevealedPasswordIds(allPasswordsVisible ? new Set() : new Set(report.items.map((item) => item.cipherId)));
};
const scan = () => {
setRevealedPasswordIds(new Set());
setFilter('all');
startPasswordSecurityScan(fingerprint, props.ciphers);
};
return (
<section className="password-security-page" aria-label={t('txt_password_security')}>
<div className="password-security-intro card">
<div className="password-security-intro-icon"><ShieldCheck size={22} /></div>
<div>
<h2>{t('txt_password_security')}</h2>
<p>{t('txt_password_security_privacy')}</p>
{scannedAt && <p className="password-security-checked-at">{t('txt_password_security_last_checked', { value: formatCheckedAt(scannedAt) })}</p>}
</div>
<div className="password-security-intro-actions">
{report && <button type="button" className="btn btn-secondary password-security-toggle-all" onClick={toggleAllPasswordVisibility}>
{allPasswordsVisible ? <EyeOff size={16} className="btn-icon" /> : <Eye size={16} className="btn-icon" />}
{allPasswordsVisible ? t('txt_password_security_hide_all') : t('txt_password_security_show_all')}
</button>}
<button type="button" className="btn btn-primary password-security-scan" disabled={props.loading || scanning || eligibleCount === 0} onClick={scan}>
{scanning ? <RefreshCw size={16} className="btn-icon spin" /> : <ScanSearch size={16} className="btn-icon" />}
{scanning ? t('txt_checking_password_security') : report ? t('txt_recheck_password_security') : t('txt_check_password_security')}
</button>
</div>
</div>
{!report && !scanning && !props.loading && (
<div className="password-security-empty card">
<ShieldCheck size={26} aria-hidden="true" />
<strong>{eligibleCount ? t('txt_password_security_ready') : t('txt_password_security_no_login')}</strong>
<span>{eligibleCount ? t('txt_password_security_manual') : t('txt_password_security_no_login_help')}</span>
</div>
)}
{(scanning || report) && (
<div className="password-security-summary" aria-live="polite">
<SecurityMetric icon={<ShieldAlert size={18} />} tone="danger" label={t('txt_exposed_passwords')} value={report?.exposedCount ?? 0} active={filter === 'exposed'} disabled={!report} onClick={() => setFilter('exposed')} />
<SecurityMetric icon={<AlertTriangle size={18} />} tone="warning" label={t('txt_reused_passwords')} value={report?.reusedCount ?? 0} active={filter === 'reused'} disabled={!report} onClick={() => setFilter('reused')} />
<SecurityMetric icon={<AlertTriangle size={18} />} tone="warning" label={t('txt_weak_passwords')} value={report?.weakCount ?? 0} active={filter === 'weak'} disabled={!report} onClick={() => setFilter('weak')} />
<SecurityMetric icon={<CheckCircle2 size={18} />} tone="primary" label={t('txt_passwords_checked')} value={`${scanning ? progress.checked : report?.checkedCount || 0} / ${scanning ? progress.total : report?.eligibleCount || 0}`} active={filter === 'all'} disabled={!report} onClick={() => setFilter('all')} />
</div>
)}
{scanError && <div className="password-security-notice warning card" role="alert"><Unplug size={16} />{t('txt_password_security_check_failed')}</div>}
{report && (
<section className="password-security-results card">
{report.unavailableCount > 0 && (
<div className="password-security-notice warning"><Unplug size={16} />{t('txt_password_security_unavailable', { count: report.unavailableCount })}</div>
)}
{!report.items.length ? (
<div className="password-security-empty compact"><CheckCircle2 size={25} /><strong>{t('txt_no_password_risks')}</strong></div>
) : !filteredItems.length ? (
<div className="password-security-empty compact"><CheckCircle2 size={25} /><strong>{t('txt_no_password_risks_in_filter')}</strong></div>
) : (
<div className="password-security-list">
{filteredItems.map((item) => {
const cipher = ciphersById.get(item.cipherId);
const name = String(cipher?.decName || cipher?.name || '');
const password = String(cipher?.login?.decPassword || '');
const passwordVisible = revealedPasswordIds.has(item.cipherId);
return <article className="password-security-item" key={item.cipherId}>
<div className="password-security-item-main">
<div className="password-security-item-header">
<strong>{name || t('txt_no_name')}</strong>
<div className="password-security-badges">
{item.exposedCount === null && <span className="risk-badge muted">{t('txt_password_security_not_checked')}</span>}
{(item.exposedCount || 0) > 0 && <span className="risk-badge danger">{t('txt_password_security_exposed_short', { count: item.exposedCount || 0 })}</span>}
{item.weak && <span className="risk-badge weak">{t('txt_password_security_weak_short')}</span>}
{item.reusedCount > 1 && <span className="risk-badge reused">{t('txt_password_security_reused_short')}</span>}
</div>
</div>
<span className="password-security-password">{passwordVisible ? password : maskSecret(password)}</span>
</div>
<div className="password-security-item-actions">
<button type="button" className="btn btn-secondary small" onClick={() => togglePasswordVisibility(item.cipherId)}>
{passwordVisible ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
{passwordVisible ? t('txt_hide') : t('txt_reveal')}
</button>
<Link href={`/vault?cipher=${encodeURIComponent(item.cipherId)}`} className="btn btn-secondary small password-security-open">
<ExternalLink size={14} className="btn-icon" />{t('txt_password_security_jump')}
</Link>
</div>
</article>;
})}
</div>
)}
</section>
)}
</section>
);
}
function SecurityMetric(props: { icon: preact.ComponentChildren; tone: 'danger' | 'warning' | 'primary'; label: string; value: string | number; active: boolean; disabled: boolean; onClick: () => void }) {
return <button type="button" className={`password-security-metric ${props.tone}`} aria-pressed={props.active} disabled={props.disabled} onClick={props.onClick}><span>{props.icon}</span><div><strong>{props.value}</strong><small>{props.label}</small></div></button>;
}
+8 -1
View File
@@ -53,6 +53,13 @@ function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
}
function formatSendDate(value: string | null | undefined): string {
if (!value) return '';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '';
return parsed.toLocaleString();
}
function optionalString(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
@@ -283,7 +290,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
</button>
</div>
)}
{!!sendData.expirationDate && <p className="muted">{t('txt_expires_at_value', { value: sendData.expirationDate })}</p>}
{!!sendData.expirationDate && <p className="muted">{t('txt_expires_at_value', { value: formatSendDate(sendData.expirationDate) })}</p>}
</>
)}
+30 -9
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Lock, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import LoadingState from '@/components/LoadingState';
import type { Send, SendDraft } from '@/lib/types';
@@ -32,6 +32,13 @@ function daysFromNow(iso: string | null | undefined, fallback: number): string {
return String(Math.max(days, 0));
}
function formatSendDate(value: string | null | undefined): string {
if (!value) return t('txt_dash');
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return t('txt_dash');
return parsed.toLocaleString();
}
function buildDefaultDraft(): SendDraft {
return {
type: 'text',
@@ -43,6 +50,7 @@ function buildDefaultDraft(): SendDraft {
expirationDays: '0',
maxAccessCount: '',
password: '',
hasPassword: false,
disabled: false,
};
}
@@ -59,6 +67,7 @@ function draftFromSend(send: Send): SendDraft {
expirationDays: daysFromNow(send.expirationDate, 0),
maxAccessCount: send.maxAccessCount !== null && send.maxAccessCount !== undefined ? String(send.maxAccessCount) : '',
password: '',
hasPassword: !!send.password,
disabled: !!send.disabled,
};
}
@@ -380,6 +389,7 @@ export default function SendsPage(props: SendsPageProps) {
<div className="list-text">
<span className="list-title" title={send.decName || t('txt_no_name')}>{send.decName || t('txt_no_name')}</span>
<span className="list-sub">
{!!send.password && <><Lock size={12} className="inline-icon" /> </>}
{Number(send.type) === 1 ? t('txt_file') : t('txt_text')} - {t('txt_accessed_count_times', { count: send.accessCount || 0 })}
</span>
</div>
@@ -471,12 +481,23 @@ export default function SendsPage(props: SendsPageProps) {
</label>
<label className="field">
<span>{t('txt_password')}</span>
<div className="password-wrap">
<input className="input" type={showPassword ? 'text' : 'password'} value={draft.password} onInput={(e) => setDraft({ ...draft, password: (e.currentTarget as HTMLInputElement).value })} />
<button type="button" className="password-toggle" onClick={() => setShowPassword((v) => !v)}>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{draft.hasPassword ? (
<div className="password-wrap">
<input className="input" type="password" value="••••••••" disabled />
{!isCreating && (
<button type="button" className="password-toggle text-red-600 hover:text-red-700" onClick={() => setDraft({ ...draft, hasPassword: false, password: '' })} title={t('txt_remove')}>
<Trash2 size={16} />
</button>
)}
</div>
) : (
<div className="password-wrap">
<input className="input" type={showPassword ? 'text' : 'password'} value={draft.password} onInput={(e) => setDraft({ ...draft, password: (e.currentTarget as HTMLInputElement).value })} />
<button type="button" className="password-toggle" onClick={() => setShowPassword((v) => !v)}>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
)}
</label>
<label className="field field-span-2">
<span>{t('txt_notes')}</span>
@@ -523,8 +544,8 @@ export default function SendsPage(props: SendsPageProps) {
<div className="card stagger-item stagger-delay-2">
<h4>{t('txt_send_details')}</h4>
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{selectedSend.expirationDate || t('txt_dash')}</strong></div>
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{formatSendDate(selectedSend.deletionDate)}</strong></div>
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{formatSendDate(selectedSend.expirationDate)}</strong></div>
</div>
<div className="card">
+2 -2
View File
@@ -130,7 +130,7 @@ export default function SettingsPage(props: SettingsPageProps) {
const [accountPasskeys, setAccountPasskeys] = useState<AccountPasskeyCredential[]>([]);
const [accountPasskeysLoading, setAccountPasskeysLoading] = useState(false);
const [accountPasskeyName, setAccountPasskeyName] = useState(t('txt_account_passkey'));
const [accountPasskeyDirectUnlock, setAccountPasskeyDirectUnlock] = useState(false);
const [accountPasskeyDirectUnlock, setAccountPasskeyDirectUnlock] = useState(true);
const [accountPasskeyPromptId, setAccountPasskeyPromptId] = useState<string | null>(null);
const [createPasskeyDialogOpen, setCreatePasskeyDialogOpen] = useState(false);
const [createPasskeyMasterPassword, setCreatePasskeyMasterPassword] = useState('');
@@ -509,7 +509,7 @@ export default function SettingsPage(props: SettingsPageProps) {
setCreatePasskeyDialogOpen(false);
setCreatePasskeyMasterPassword('');
setAccountPasskeyName(t('txt_account_passkey'));
setAccountPasskeyDirectUnlock(false);
setAccountPasskeyDirectUnlock(true);
}
async function submitCreatePasskeyDialog(): Promise<void> {
+18 -26
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Clipboard, Globe } from 'lucide-preact';
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
import { calcTotpNow } from '@/lib/crypto';
import { calcTotpNow, type TotpCodeResult } from '@/lib/crypto';
import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types';
import LoadingState from '@/components/LoadingState';
@@ -14,17 +14,9 @@ interface TotpCodesPageProps {
onNotify: (type: 'success' | 'error', text: string) => void;
}
const TOTP_PERIOD_SECONDS = 30;
const TOTP_RING_RADIUS = 14;
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
const TOTP_REFRESH_BATCH_SIZE = 16;
function getTotpTimeState(): { windowId: number; remain: number } {
const epoch = Math.floor(Date.now() / 1000);
return {
windowId: Math.floor(epoch / TOTP_PERIOD_SECONDS),
remain: TOTP_PERIOD_SECONDS - (epoch % TOTP_PERIOD_SECONDS),
};
}
function TotpListIcon({ cipher }: { cipher: Cipher }) {
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
@@ -32,13 +24,15 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
interface TotpRowProps {
cipher: Cipher;
live: { code: string; remain: number } | null;
live: TotpCodeResult | null;
onCopy: (value: string) => void;
}
function TotpRow(props: TotpRowProps) {
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
const username = props.cipher.login?.decUsername || '';
const period = Math.max(1, props.live?.period || 30);
const progress = props.live ? Math.max(0, Math.min(period, props.live.remain)) / period : 0;
return (
<div className="totp-code-row">
@@ -69,8 +63,7 @@ function TotpRow(props: TotpRowProps) {
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
strokeDashoffset: String(
TOTP_RING_CIRCUMFERENCE -
TOTP_RING_CIRCUMFERENCE *
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
TOTP_RING_CIRCUMFERENCE * progress
),
}}
/>
@@ -86,8 +79,7 @@ function TotpRow(props: TotpRowProps) {
}
export default function TotpCodesPage(props: TotpCodesPageProps) {
const [totpCodes, setTotpCodes] = useState<Record<string, string | null>>({});
const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain);
const [totpCodes, setTotpCodes] = useState<Record<string, TotpCodeResult | null>>({});
const [columnCount, setColumnCount] = useState(1);
const listRef = useRef<HTMLDivElement | null>(null);
@@ -120,11 +112,10 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
let stopped = false;
let activeRun = 0;
let timer = 0;
let currentWindowId = -1;
const refreshCodes = async () => {
const runId = ++activeRun;
const nextCodes: Record<string, string | null> = {};
const nextCodes: Record<string, TotpCodeResult | null> = {};
for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) {
if (stopped || runId !== activeRun) return;
const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE);
@@ -132,7 +123,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
batch.map(async (cipher) => {
try {
const next = await calcTotpNow(cipher.login?.decTotp || '');
return [cipher.id, next?.code || null] as const;
return [cipher.id, next] as const;
} catch {
return [cipher.id, null] as const;
}
@@ -146,15 +137,20 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
if (stopped || runId !== activeRun) return;
setTotpCodes((prev) => {
let changed = false;
const next: Record<string, string | null> = { ...prev };
const next: Record<string, TotpCodeResult | null> = { ...prev };
for (const id of Object.keys(next)) {
if (id in nextCodes) continue;
delete next[id];
changed = true;
}
for (const [id, code] of Object.entries(nextCodes)) {
if (next[id] === code) continue;
next[id] = code;
for (const [id, live] of Object.entries(nextCodes)) {
const prevLive = next[id];
if (
prevLive?.code === live?.code &&
prevLive?.remain === live?.remain &&
prevLive?.period === live?.period
) continue;
next[id] = live;
changed = true;
}
return changed ? next : prev;
@@ -162,10 +158,6 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
};
const tick = () => {
const next = getTotpTimeState();
setRemainingSeconds((prev) => (prev === next.remain ? prev : next.remain));
if (next.windowId === currentWindowId) return;
currentWindowId = next.windowId;
void refreshCodes();
};
@@ -215,7 +207,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
<TotpRow
key={cipher.id}
cipher={cipher}
live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null}
live={totpCodes[cipher.id] || null}
onCopy={(value) => void copyToClipboard(value)}
/>
))}
+117 -4
View File
@@ -12,23 +12,26 @@ import {
cardListSubtitle,
FOLDER_SORT_STORAGE_KEY,
VAULT_SORT_STORAGE_KEY,
bankAccountListSubtitle,
cipherTypeKey,
cipherTypeLabel,
createEmptyDraft,
creationTimeValue,
draftFromCipher,
driversLicenseListSubtitle,
buildCipherDuplicateSignatures,
firstCipherUri,
firstPasskeyCreationTime,
isCipherVisibleInArchive,
isCipherVisibleInNormalVault,
isCipherVisibleInTrash,
passportListSubtitle,
sortTimeValue,
type DuplicateDetectionMode,
type SidebarFilter,
type VaultSortMode,
} from '@/components/vault/vault-page-helpers';
import { calcTotpNow } from '@/lib/crypto';
import { calcTotpNow, type TotpCodeResult } from '@/lib/crypto';
import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh';
import { ChevronLeft } from 'lucide-preact';
import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
@@ -84,6 +87,7 @@ export default function VaultPage(props: VaultPageProps) {
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
const [selectedCipherId, setSelectedCipherId] = useState('');
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
const pendingFocusCipherIdRef = useRef<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [createMenuOpen, setCreateMenuOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
@@ -106,7 +110,7 @@ export default function VaultPage(props: VaultPageProps) {
const [renameFolderName, setRenameFolderName] = useState('');
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
const [totpLive, setTotpLive] = useState<TotpCodeResult | null>(null);
const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({});
const [attachmentQueue, setAttachmentQueue] = useState<File[]>([]);
const [removedAttachmentIds, setRemovedAttachmentIds] = useState<Record<string, boolean>>({});
@@ -308,10 +312,21 @@ export default function VaultPage(props: VaultPageProps) {
const name = String(cipher.decName || cipher.name || '');
const username = String(cipher.login?.decUsername || '');
const uri = firstCipherUri(cipher);
const typedText = [
cipher.bankAccount?.decBankName,
cipher.bankAccount?.decNameOnAccount,
cipher.bankAccount?.decAccountNumber,
cipher.driversLicense?.decLicenseNumber,
cipher.driversLicense?.decFirstName,
cipher.driversLicense?.decLastName,
cipher.passport?.decPassportNumber,
cipher.passport?.decGivenName,
cipher.passport?.decSurname,
].filter(Boolean).join('\n');
const cipherId = String(cipher.id || '').trim();
meta.set(cipher.id, {
name,
searchText: `${cipherId}\n${cipherId.replace(/-/g, '')}\n${name}\n${username}\n${uri}`.toLowerCase(),
searchText: `${cipherId}\n${cipherId.replace(/-/g, '')}\n${name}\n${username}\n${uri}\n${typedText}`.toLowerCase(),
firstUri: uri,
typeKey: cipherTypeKey(Number(cipher.type || 1)),
sortTime: sortTimeValue(cipher),
@@ -405,7 +420,36 @@ export default function VaultPage(props: VaultPageProps) {
return !!meta?.searchText.includes(searchQuery);
});
// Pre-compute group min name for duplicates group ordering
const groupMinName = new Map<string, string>();
if (sidebarFilter.kind === 'duplicates' && duplicateSignatureInfo) {
for (const cipher of next) {
const gk = (duplicateSignatureInfo.byId.get(cipher.id) || [])
.filter(s => (duplicateSignatureInfo.counts.get(s) || 0) >= 2)
.sort()[0] || '';
if (!gk) continue;
const name = cipherMetaById.get(cipher.id)?.name || '';
const cur = groupMinName.get(gk);
if (!cur || nameCollator.compare(name, cur) < 0) groupMinName.set(gk, name);
}
}
next.sort((a, b) => {
// Duplicates view: group by color, sort A-Z within each group
if (sidebarFilter.kind === 'duplicates' && duplicateSignatureInfo) {
const gk = (id: string) => (duplicateSignatureInfo.byId.get(id) || [])
.filter(s => (duplicateSignatureInfo.counts.get(s) || 0) >= 2)
.sort()[0] || '';
const gA = gk(a.id), gB = gk(b.id);
if (gA !== gB) return !gA ? 1 : !gB ? -1 : nameCollator.compare(
groupMinName.get(gA) || '', groupMinName.get(gB) || ''
) || (gA < gB ? -1 : 1);
return nameCollator.compare(
cipherMetaById.get(a.id)?.name || '',
cipherMetaById.get(b.id)?.name || ''
) || String(a.id || '').localeCompare(String(b.id || ''));
}
const metaA = cipherMetaById.get(a.id);
const metaB = cipherMetaById.get(b.id);
if (sortMode === 'edited') {
@@ -454,8 +498,59 @@ export default function VaultPage(props: VaultPageProps) {
if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
}, [sidebarFilter.kind, duplicateMode]);
useEffect(() => {
if (typeof window === 'undefined') return;
const focusId = String(new URLSearchParams(window.location.search || '').get('cipher') || '').trim();
if (!focusId) return;
pendingFocusCipherIdRef.current = focusId;
}, []);
useEffect(() => {
const focusId = pendingFocusCipherIdRef.current;
if (!focusId) return;
const cipher = cipherById.get(focusId);
if (!cipher) {
if (!props.loading && props.ciphers.length > 0) pendingFocusCipherIdRef.current = null;
return;
}
const nextFilter: SidebarFilter = isCipherVisibleInTrash(cipher)
? { kind: 'trash' }
: isCipherVisibleInArchive(cipher)
? { kind: 'archive' }
: { kind: 'all' };
setSidebarFilter((prev) => (prev.kind === nextFilter.kind ? prev : nextFilter));
setSearchInput('');
setSearchQuery('');
setIsEditing(false);
setIsCreating(false);
setDraft(null);
}, [cipherById, props.ciphers.length, props.loading]);
useEffect(() => {
if (isCreating) return;
const focusId = pendingFocusCipherIdRef.current;
if (focusId) {
if (!filteredCipherIds.has(focusId)) return;
setSelectedCipherId(focusId);
setRepromptApprovedCipherId(null);
setShowPassword(false);
setHiddenFieldVisibleMap({});
if (isMobileLayout) setMobilePanel('detail');
setMobileSidebarOpen(false);
pendingFocusCipherIdRef.current = null;
if (typeof window !== 'undefined' && typeof window.history?.replaceState === 'function') {
const url = new URL(window.location.href);
if (url.searchParams.has('cipher')) {
url.searchParams.delete('cipher');
const next = `${url.pathname}${url.search}${url.hash}`;
window.history.replaceState(null, '', next || '/vault');
}
}
return;
}
if (!filteredCiphers.length) {
if (selectedCipherId) setSelectedCipherId('');
return;
@@ -463,7 +558,7 @@ export default function VaultPage(props: VaultPageProps) {
if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) {
setSelectedCipherId(filteredCiphers[0].id);
}
}, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating]);
}, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating, isMobileLayout]);
const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]);
const virtualRange = useMemo(() => {
@@ -542,6 +637,9 @@ const folderName = useCallback((id: string | null | undefined): string => {
if (Number(cipher.type || 1) === 3) {
return cardListSubtitle(cipher);
}
if (Number(cipher.type || 1) === 6) return bankAccountListSubtitle(cipher);
if (Number(cipher.type || 1) === 7) return driversLicenseListSubtitle(cipher);
if (Number(cipher.type || 1) === 8) return passportListSubtitle(cipher);
return cipherTypeLabel(Number(cipher.type || 1));
}, [cipherMetaById]);
@@ -1032,6 +1130,20 @@ const folderName = useCallback((id: string | null | undefined): string => {
}
setSelectedMap(map);
}, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
const handleSelectUniqueFromDuplicates = useCallback(() => {
const map: Record<string, boolean> = {};
const seen = new Set<number>();
for (const cipher of filteredCiphers) {
const groupIndex = duplicateGroupIndexById.get(cipher.id);
if (groupIndex === undefined) continue;
if (seen.has(groupIndex)) {
map[cipher.id] = true;
} else {
seen.add(groupIndex);
}
}
setSelectedMap(map);
}, [filteredCiphers, duplicateGroupIndexById]);
const handleSelectAll = useCallback(() => {
const map: Record<string, boolean> = {};
for (const cipher of filteredCiphers) map[cipher.id] = true;
@@ -1146,6 +1258,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
onSyncVault={handleSyncVault}
onOpenBulkDelete={handleOpenBulkDelete}
onSelectDuplicates={handleSelectDuplicates}
onSelectUniqueFromDuplicates={handleSelectUniqueFromDuplicates}
onSelectAll={handleSelectAll}
onToggleCreateMenu={handleToggleCreateMenu}
onStartCreate={startCreate}
+108 -7
View File
@@ -1,12 +1,13 @@
import { createPortal } from 'preact/compat';
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { AlertTriangle, Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RefreshCw, RotateCcw, ShieldCheck, ShieldAlert, Trash2, X } from 'lucide-preact';
import { useDialogLifecycle } from '@/components/ConfirmDialog';
import type { TotpCodeResult } from '@/lib/crypto';
import { checkPasswordLeaked, type PasswordBreachResult } from '@/lib/password-security';
import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n';
import {
CardBrandIcon,
TOTP_PERIOD_SECONDS,
TOTP_RING_CIRCUMFERENCE,
VaultListIcon,
copyToClipboard,
@@ -21,11 +22,15 @@ import {
toBooleanFieldValue,
} from '@/components/vault/vault-page-helpers';
function isAbortError(error: unknown): boolean {
return !!error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError';
}
interface VaultDetailViewProps {
selectedCipher: Cipher;
repromptApprovedCipherId: string | null;
showPassword: boolean;
totpLive: { code: string; remain: number } | null;
totpLive: TotpCodeResult | null;
passkeyCreatedAt: string | null;
hiddenFieldVisibleMap: Record<number, boolean>;
folderName: (id: string | null | undefined) => string;
@@ -42,6 +47,11 @@ interface VaultDetailViewProps {
onUnarchive: (cipher: Cipher) => void | Promise<void>;
}
function totpProgress(live: TotpCodeResult | null): number {
const period = Math.max(1, live?.period || 30);
return live ? Math.max(0, Math.min(period, live.remain)) / period : 0;
}
function PasswordHistoryDialog(props: {
open: boolean;
entries: Array<{ password: string; lastUsedDate: string | null }>;
@@ -85,6 +95,9 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false);
const [breachResult, setBreachResult] = useState<PasswordBreachResult | null>(null);
const [checkingBreach, setCheckingBreach] = useState(false);
const breachControllerRef = useRef<AbortController | null>(null);
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
const isDeleted = isCipherDeleted(props.selectedCipher);
const passwordHistoryEntries = useMemo(
@@ -98,9 +111,39 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
[props.selectedCipher.passwordHistory]
);
useEffect(() => {
breachControllerRef.current?.abort();
breachControllerRef.current = null;
setShowSshPrivateKey(false);
setPasswordHistoryOpen(false);
}, [props.selectedCipher.id]);
setBreachResult(null);
setCheckingBreach(false);
return () => {
breachControllerRef.current?.abort();
breachControllerRef.current = null;
};
}, [props.selectedCipher.id, props.selectedCipher.login?.decPassword]);
const checkBreach = async () => {
const password = String(props.selectedCipher.login?.decPassword || '');
if (!password) return;
breachControllerRef.current?.abort();
const controller = new AbortController();
breachControllerRef.current = controller;
setCheckingBreach(true);
setBreachResult(null);
try {
const result = await checkPasswordLeaked(password, fetch, controller.signal);
if (controller.signal.aborted) return;
setBreachResult(result);
} catch (error) {
if (controller.signal.aborted || isAbortError(error)) return;
setBreachResult({ count: null, available: false });
} finally {
if (breachControllerRef.current === controller) {
breachControllerRef.current = null;
setCheckingBreach(false);
}
}
};
const formatDownloadLabel = (attachmentId: string) => {
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
@@ -167,8 +210,18 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.login?.decPassword || '')}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
<button type="button" className="btn btn-secondary small" disabled={checkingBreach || !props.selectedCipher.login?.decPassword} onClick={() => void checkBreach()}>
{checkingBreach ? <RefreshCw size={14} className="btn-icon spin" /> : <ShieldCheck size={14} className="btn-icon" />}
{checkingBreach ? t('txt_checking_password_security') : t('txt_check_password_breach')}
</button>
</div>
</div>
{breachResult && (
<div className={`password-breach-inline ${breachResult.available ? (breachResult.count ? 'danger' : 'safe') : 'warning'}`} role="status">
{breachResult.available ? (breachResult.count ? <ShieldAlert size={15} /> : <ShieldCheck size={15} />) : <AlertTriangle size={15} />}
<span>{breachResult.available ? (breachResult.count ? t('txt_password_exposed_count', { count: breachResult.count }) : t('txt_password_not_found_in_breaches')) : t('txt_password_security_check_failed')}</span>
</div>
)}
{!!props.selectedCipher.login.decTotp && (
<div className="kv-row">
<span className="kv-label">{t('txt_totp')}</span>
@@ -191,8 +244,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
strokeDashoffset: String(
TOTP_RING_CIRCUMFERENCE -
TOTP_RING_CIRCUMFERENCE *
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.totpLive?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
TOTP_RING_CIRCUMFERENCE * totpProgress(props.totpLive)
),
}}
/>
@@ -327,6 +379,55 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
</div>
)}
{props.selectedCipher.bankAccount && (
<div className="card">
<h4>{t('txt_bank_account_details')}</h4>
<div className="kv-line"><span>{t('txt_bank_name')}</span><strong>{props.selectedCipher.bankAccount.decBankName || ''}</strong></div>
<div className="kv-line"><span>{t('txt_name_on_account')}</span><strong>{props.selectedCipher.bankAccount.decNameOnAccount || ''}</strong></div>
<div className="kv-line"><span>{t('txt_account_type')}</span><strong>{props.selectedCipher.bankAccount.decAccountType || ''}</strong></div>
<div className="kv-line"><span>{t('txt_account_number')}</span><strong>{props.selectedCipher.bankAccount.decAccountNumber || ''}</strong></div>
<div className="kv-line"><span>{t('txt_routing_number')}</span><strong>{props.selectedCipher.bankAccount.decRoutingNumber || ''}</strong></div>
<div className="kv-line"><span>{t('txt_branch_number')}</span><strong>{props.selectedCipher.bankAccount.decBranchNumber || ''}</strong></div>
<div className="kv-line"><span>{t('txt_pin')}</span><strong>{props.selectedCipher.bankAccount.decPin || ''}</strong></div>
<div className="kv-line"><span>{t('txt_swift_code')}</span><strong>{props.selectedCipher.bankAccount.decSwiftCode || ''}</strong></div>
<div className="kv-line"><span>{t('txt_iban')}</span><strong>{props.selectedCipher.bankAccount.decIban || ''}</strong></div>
<div className="kv-line"><span>{t('txt_bank_contact_phone')}</span><strong>{props.selectedCipher.bankAccount.decBankContactPhone || ''}</strong></div>
</div>
)}
{props.selectedCipher.driversLicense && (
<div className="card">
<h4>{t('txt_drivers_license_details')}</h4>
<div className="kv-line"><span>{t('txt_name')}</span><strong>{[props.selectedCipher.driversLicense.decFirstName, props.selectedCipher.driversLicense.decMiddleName, props.selectedCipher.driversLicense.decLastName].filter(Boolean).join(' ')}</strong></div>
<div className="kv-line"><span>{t('txt_date_of_birth')}</span><strong>{props.selectedCipher.driversLicense.decDateOfBirth || ''}</strong></div>
<div className="kv-line"><span>{t('txt_license_number')}</span><strong>{props.selectedCipher.driversLicense.decLicenseNumber || ''}</strong></div>
<div className="kv-line"><span>{t('txt_issuing_country')}</span><strong>{props.selectedCipher.driversLicense.decIssuingCountry || ''}</strong></div>
<div className="kv-line"><span>{t('txt_issuing_state')}</span><strong>{props.selectedCipher.driversLicense.decIssuingState || ''}</strong></div>
<div className="kv-line"><span>{t('txt_issue_date')}</span><strong>{props.selectedCipher.driversLicense.decIssueDate || ''}</strong></div>
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{props.selectedCipher.driversLicense.decExpirationDate || ''}</strong></div>
<div className="kv-line"><span>{t('txt_issuing_authority')}</span><strong>{props.selectedCipher.driversLicense.decIssuingAuthority || ''}</strong></div>
<div className="kv-line"><span>{t('txt_license_class')}</span><strong>{props.selectedCipher.driversLicense.decLicenseClass || ''}</strong></div>
</div>
)}
{props.selectedCipher.passport && (
<div className="card">
<h4>{t('txt_passport_details')}</h4>
<div className="kv-line"><span>{t('txt_name')}</span><strong>{[props.selectedCipher.passport.decGivenName, props.selectedCipher.passport.decSurname].filter(Boolean).join(' ')}</strong></div>
<div className="kv-line"><span>{t('txt_date_of_birth')}</span><strong>{props.selectedCipher.passport.decDateOfBirth || ''}</strong></div>
<div className="kv-line"><span>{t('txt_sex')}</span><strong>{props.selectedCipher.passport.decSex || ''}</strong></div>
<div className="kv-line"><span>{t('txt_birth_place')}</span><strong>{props.selectedCipher.passport.decBirthPlace || ''}</strong></div>
<div className="kv-line"><span>{t('txt_nationality')}</span><strong>{props.selectedCipher.passport.decNationality || ''}</strong></div>
<div className="kv-line"><span>{t('txt_issuing_country')}</span><strong>{props.selectedCipher.passport.decIssuingCountry || ''}</strong></div>
<div className="kv-line"><span>{t('txt_passport_number')}</span><strong>{props.selectedCipher.passport.decPassportNumber || ''}</strong></div>
<div className="kv-line"><span>{t('txt_passport_type')}</span><strong>{props.selectedCipher.passport.decPassportType || ''}</strong></div>
<div className="kv-line"><span>{t('txt_national_id_number')}</span><strong>{props.selectedCipher.passport.decNationalIdentificationNumber || ''}</strong></div>
<div className="kv-line"><span>{t('txt_issuing_authority')}</span><strong>{props.selectedCipher.passport.decIssuingAuthority || ''}</strong></div>
<div className="kv-line"><span>{t('txt_issue_date')}</span><strong>{props.selectedCipher.passport.decIssueDate || ''}</strong></div>
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{props.selectedCipher.passport.decExpirationDate || ''}</strong></div>
</div>
)}
{!!(props.selectedCipher.decNotes || '').trim() && (
<div className="card">
<h4>{t('txt_notes')}</h4>
+72 -3
View File
@@ -4,6 +4,7 @@ import { ArrowDown, ArrowUp, CheckCheck, Download, Paperclip, Plus, QrCode, Refr
import jsQR from 'jsqr';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useDialogLifecycle } from '@/components/ConfirmDialog';
import { normalizeTotpInput } from '@/lib/crypto';
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
import { t } from '@/lib/i18n';
import { cardBrand } from '@/lib/import-format-shared';
@@ -67,6 +68,8 @@ interface WebsiteRowProps {
onRemove: (index: number) => void;
}
const TOTP_QR_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
function WebsiteRow(props: WebsiteRowProps) {
const websiteMatchOptions = getWebsiteMatchOptions();
@@ -159,9 +162,9 @@ export default function VaultEditor(props: VaultEditorProps) {
};
const applyTotpQrValue = (value: string) => {
const trimmed = value.trim();
if (!trimmed) return false;
props.onUpdateDraft({ loginTotp: trimmed });
const normalized = normalizeTotpInput(value);
if (!normalized) return false;
props.onUpdateDraft({ loginTotp: normalized });
setTotpQrStatus(t('txt_totp_qr_scanned'));
setTotpQrOpen(false);
return true;
@@ -208,6 +211,14 @@ export default function VaultEditor(props: VaultEditorProps) {
const handleTotpQrFile = async (file: File | null) => {
if (!file) return;
if (file.type && !file.type.startsWith('image/')) {
setTotpQrStatus(t('txt_totp_qr_invalid_image_type'));
return;
}
if (file.size > TOTP_QR_IMAGE_MAX_BYTES) {
setTotpQrStatus(t('txt_totp_qr_image_too_large'));
return;
}
setTotpQrBusy(true);
setTotpQrStatus(t('txt_totp_qr_scanning'));
let bitmap: ImageBitmap | null = null;
@@ -579,6 +590,64 @@ export default function VaultEditor(props: VaultEditorProps) {
</div>
)}
{props.draft.type === 6 && (
<div className="card">
<h4>{t('txt_bank_account_details')}</h4>
<div className="field-grid">
<label className="field"><span>{t('txt_bank_name')}</span><input className="input" value={props.draft.bankName} onInput={(e) => props.onUpdateDraft({ bankName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_name_on_account')}</span><input className="input" value={props.draft.bankNameOnAccount} onInput={(e) => props.onUpdateDraft({ bankNameOnAccount: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_account_type')}</span><input className="input" value={props.draft.bankAccountType} onInput={(e) => props.onUpdateDraft({ bankAccountType: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_account_number')}</span><input className="input" value={props.draft.bankAccountNumber} onInput={(e) => props.onUpdateDraft({ bankAccountNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_routing_number')}</span><input className="input" value={props.draft.bankRoutingNumber} onInput={(e) => props.onUpdateDraft({ bankRoutingNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_branch_number')}</span><input className="input" value={props.draft.bankBranchNumber} onInput={(e) => props.onUpdateDraft({ bankBranchNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_pin')}</span><input className="input" value={props.draft.bankPin} onInput={(e) => props.onUpdateDraft({ bankPin: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_swift_code')}</span><input className="input" value={props.draft.bankSwiftCode} onInput={(e) => props.onUpdateDraft({ bankSwiftCode: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_iban')}</span><input className="input" value={props.draft.bankIban} onInput={(e) => props.onUpdateDraft({ bankIban: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_bank_contact_phone')}</span><input className="input" value={props.draft.bankContactPhone} onInput={(e) => props.onUpdateDraft({ bankContactPhone: (e.currentTarget as HTMLInputElement).value })} /></label>
</div>
</div>
)}
{props.draft.type === 7 && (
<div className="card">
<h4>{t('txt_drivers_license_details')}</h4>
<div className="field-grid">
<label className="field"><span>{t('txt_first_name')}</span><input className="input" value={props.draft.licenseFirstName} onInput={(e) => props.onUpdateDraft({ licenseFirstName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_middle_name')}</span><input className="input" value={props.draft.licenseMiddleName} onInput={(e) => props.onUpdateDraft({ licenseMiddleName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_last_name')}</span><input className="input" value={props.draft.licenseLastName} onInput={(e) => props.onUpdateDraft({ licenseLastName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_date_of_birth')}</span><input className="input" value={props.draft.licenseDateOfBirth} onInput={(e) => props.onUpdateDraft({ licenseDateOfBirth: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_license_number')}</span><input className="input" value={props.draft.licenseNumber} onInput={(e) => props.onUpdateDraft({ licenseNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_issuing_country')}</span><input className="input" value={props.draft.licenseIssuingCountry} onInput={(e) => props.onUpdateDraft({ licenseIssuingCountry: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_issuing_state')}</span><input className="input" value={props.draft.licenseIssuingState} onInput={(e) => props.onUpdateDraft({ licenseIssuingState: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_issue_date')}</span><input className="input" value={props.draft.licenseIssueDate} onInput={(e) => props.onUpdateDraft({ licenseIssueDate: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_expiration_date')}</span><input className="input" value={props.draft.licenseExpirationDate} onInput={(e) => props.onUpdateDraft({ licenseExpirationDate: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_issuing_authority')}</span><input className="input" value={props.draft.licenseIssuingAuthority} onInput={(e) => props.onUpdateDraft({ licenseIssuingAuthority: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_license_class')}</span><input className="input" value={props.draft.licenseClass} onInput={(e) => props.onUpdateDraft({ licenseClass: (e.currentTarget as HTMLInputElement).value })} /></label>
</div>
</div>
)}
{props.draft.type === 8 && (
<div className="card">
<h4>{t('txt_passport_details')}</h4>
<div className="field-grid">
<label className="field"><span>{t('txt_surname')}</span><input className="input" value={props.draft.passportSurname} onInput={(e) => props.onUpdateDraft({ passportSurname: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_given_name')}</span><input className="input" value={props.draft.passportGivenName} onInput={(e) => props.onUpdateDraft({ passportGivenName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_date_of_birth')}</span><input className="input" value={props.draft.passportDateOfBirth} onInput={(e) => props.onUpdateDraft({ passportDateOfBirth: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_sex')}</span><input className="input" value={props.draft.passportSex} onInput={(e) => props.onUpdateDraft({ passportSex: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_birth_place')}</span><input className="input" value={props.draft.passportBirthPlace} onInput={(e) => props.onUpdateDraft({ passportBirthPlace: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_nationality')}</span><input className="input" value={props.draft.passportNationality} onInput={(e) => props.onUpdateDraft({ passportNationality: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_issuing_country')}</span><input className="input" value={props.draft.passportIssuingCountry} onInput={(e) => props.onUpdateDraft({ passportIssuingCountry: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_passport_number')}</span><input className="input" value={props.draft.passportNumber} onInput={(e) => props.onUpdateDraft({ passportNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_passport_type')}</span><input className="input" value={props.draft.passportType} onInput={(e) => props.onUpdateDraft({ passportType: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_national_id_number')}</span><input className="input" value={props.draft.passportNationalIdentificationNumber} onInput={(e) => props.onUpdateDraft({ passportNationalIdentificationNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_issuing_authority')}</span><input className="input" value={props.draft.passportIssuingAuthority} onInput={(e) => props.onUpdateDraft({ passportIssuingAuthority: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_issue_date')}</span><input className="input" value={props.draft.passportIssueDate} onInput={(e) => props.onUpdateDraft({ passportIssueDate: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_expiration_date')}</span><input className="input" value={props.draft.passportExpirationDate} onInput={(e) => props.onUpdateDraft({ passportExpirationDate: (e.currentTarget as HTMLInputElement).value })} /></label>
</div>
</div>
)}
<div className="card">
<div className="section-head attachment-head">
<h4>{t('txt_attachments')}</h4>
+43 -33
View File
@@ -81,6 +81,7 @@ interface VaultListPanelProps {
onSyncVault: () => void;
onOpenBulkDelete: () => void;
onSelectDuplicates: () => void;
onSelectUniqueFromDuplicates: () => void;
onSelectAll: () => void;
onToggleCreateMenu: () => void;
onStartCreate: (type: number) => void;
@@ -319,40 +320,43 @@ export default function VaultListPanel(props: VaultListPanelProps) {
</>
) : (
<>
<div className="search-input-wrap">
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
<div className="duplicate-mode-head-menu">
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
<div className="duplicate-mode-head-menu mobile-duplicate-toolbar">
<div className="mobile-duplicate-mode-select-wrap">
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
</div>
) : (
<>
<input
className="search-input"
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
value={props.searchInput}
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
onCompositionStart={props.onSearchCompositionStart}
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key !== 'Escape' || !props.searchInput) return;
e.preventDefault();
props.onClearSearch();
}}
/>
{!!props.searchInput && (
<button
type="button"
className="search-clear-btn"
aria-label={t('txt_clear_search')}
title={t('txt_clear_search_esc')}
onClick={props.onClearSearch}
>
<X size={14} />
</button>
)}
</>
)}
</div>
<button type="button" className="btn btn-secondary small" onClick={props.onSelectUniqueFromDuplicates}>
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
</button>
</div>
) : (
<div className="search-input-wrap">
<input
className="search-input"
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
value={props.searchInput}
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
onCompositionStart={props.onSearchCompositionStart}
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key !== 'Escape' || !props.searchInput) return;
e.preventDefault();
props.onClearSearch();
}}
/>
{!!props.searchInput && (
<button
type="button"
className="search-clear-btn"
aria-label={t('txt_clear_search')}
title={t('txt_clear_search_esc')}
onClick={props.onClearSearch}
>
<X size={14} />
</button>
)}
</div>
)}
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
<div className="duplicate-mode-head-menu">
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
@@ -387,7 +391,13 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
</button>
{!props.isMobileLayout && props.sidebarFilter !== undefined && createMenu}
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout ? (
<button type="button" className="btn btn-secondary small" onClick={props.onSelectUniqueFromDuplicates}>
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
</button>
) : (
!props.isMobileLayout && props.sidebarFilter !== undefined && createMenu
)}
</>
)}
</div>
@@ -3,6 +3,7 @@ import type { RefObject } from 'preact';
import {
Archive,
ArrowUpDown,
BookUser,
Check,
Copy,
CreditCard,
@@ -10,15 +11,19 @@ import {
FolderPlus,
FolderX,
Globe,
IdCard,
KeyRound,
Landmark,
LayoutGrid,
Pencil,
ShieldUser,
ShieldCheck,
Star,
StickyNote,
Trash2,
X,
} from 'lucide-preact';
import { Link } from 'wouter';
import type { Folder } from '@/lib/types';
import { t } from '@/lib/i18n';
import { getFolderSortOptions, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers';
@@ -92,6 +97,9 @@ export default function VaultSidebar(props: VaultSidebarProps) {
</div>
)}
<div className="sidebar-block">
<Link href="/security/password-health" className="tree-btn">
<ShieldCheck size={14} className="tree-icon" /> <span className="tree-label">{t('nav_password_security')}</span>
</Link>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'all' })}>
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">{t('txt_all_items')}</span>
</button>
@@ -117,9 +125,18 @@ export default function VaultSidebar(props: VaultSidebarProps) {
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'card' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'card' })}>
<CreditCard size={14} className="tree-icon" /> <span className="tree-label">{t('txt_card')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'bank' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'bank' })}>
<Landmark size={14} className="tree-icon" /> <span className="tree-label">{t('txt_bank_account')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'identity' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'identity' })}>
<ShieldUser size={14} className="tree-icon" /> <span className="tree-label">{t('txt_identity')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'license' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'license' })}>
<IdCard size={14} className="tree-icon" /> <span className="tree-label">{t('txt_drivers_license')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'passport' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'passport' })}>
<BookUser size={14} className="tree-icon" /> <span className="tree-label">{t('txt_passport')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'note' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'note' })}>
<StickyNote size={14} className="tree-icon" /> <span className="tree-label">{t('txt_note')}</span>
</button>
+8 -5
View File
@@ -10,6 +10,7 @@ import {
} from '@/lib/website-icon-cache';
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status';
import { areWebsiteIconsEnabled } from '@/lib/website-icon-settings';
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
@@ -22,7 +23,8 @@ interface WebsiteIconProps {
export default function WebsiteIcon(props: WebsiteIconProps) {
const host = useMemo(() => hostFromUri(firstCipherUri(props.cipher)), [props.cipher]);
const src = host ? websiteIconUrl(host) : '';
const iconsEnabled = areWebsiteIconsEnabled();
const src = iconsEnabled && host ? websiteIconUrl(host) : '';
const nodeRef = useRef<HTMLSpanElement | null>(null);
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
@@ -33,7 +35,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
useEffect(() => subscribeNetworkStatus(setNetworkStatus), []);
useEffect(() => {
if (!host) {
if (!host || !iconsEnabled) {
setShouldLoad(true);
setStatus('idle');
setImageUrl('');
@@ -47,7 +49,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
setStatus(next);
setImageUrl(getWebsiteIconImageUrl(host));
});
}, [host]);
}, [host, iconsEnabled]);
useEffect(() => {
if (!host || shouldLoad || status === 'loaded' || status === 'error') return;
@@ -81,10 +83,11 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
useEffect(() => {
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
if (demoIconUrl) return;
if (!iconsEnabled) return;
if (networkStatus !== 'online') return;
if (!host || !src || !shouldLoad || status !== 'idle') return;
beginWebsiteIconLoad(host, src);
}, [demoIconUrl, host, networkStatus, src, shouldLoad, status]);
}, [demoIconUrl, host, iconsEnabled, networkStatus, src, shouldLoad, status]);
if (demoIconUrl) {
return (
@@ -100,7 +103,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
);
}
if (!host || status === 'error') {
if (!host || !iconsEnabled || status === 'error') {
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
}
@@ -1,9 +1,12 @@
import { useMemo } from 'preact/hooks';
import {
BookUser,
CreditCard,
FileKey2,
Globe,
IdCard,
KeyRound,
Landmark,
ShieldUser,
StickyNote,
} from 'lucide-preact';
@@ -14,7 +17,7 @@ import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils
import { normalizeEquivalentDomain } from '@shared/domain-normalize';
import WebsiteIcon from './WebsiteIcon';
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh' | 'bank' | 'license' | 'passport';
export type VaultSortMode = 'edited' | 'created' | 'name';
export type DuplicateDetectionMode = 'exact' | 'login-site' | 'login-credentials' | 'password';
export type SidebarFilter =
@@ -98,6 +101,32 @@ export function cardListSubtitle(cipher: Cipher): string {
return cipherTypeLabel(3);
}
export function bankAccountListSubtitle(cipher: Cipher): string {
const bankName = valueOrFallback(cipher.bankAccount?.decBankName ?? cipher.bankAccount?.bankName).trim();
const accountType = valueOrFallback(cipher.bankAccount?.decAccountType ?? cipher.bankAccount?.accountType).trim();
const accountNumber = valueOrFallback(cipher.bankAccount?.decAccountNumber ?? cipher.bankAccount?.accountNumber).replace(/\D/g, '');
const last4 = accountNumber.length >= 4 ? accountNumber.slice(-4) : '';
return [bankName, accountType, last4 ? `*${last4}` : ''].filter(Boolean).join(', ') || cipherTypeLabel(6);
}
export function driversLicenseListSubtitle(cipher: Cipher): string {
const licenseNumber = valueOrFallback(cipher.driversLicense?.decLicenseNumber ?? cipher.driversLicense?.licenseNumber).trim();
const name = [
valueOrFallback(cipher.driversLicense?.decFirstName ?? cipher.driversLicense?.firstName).trim(),
valueOrFallback(cipher.driversLicense?.decLastName ?? cipher.driversLicense?.lastName).trim(),
].filter(Boolean).join(' ');
return licenseNumber || name || cipherTypeLabel(7);
}
export function passportListSubtitle(cipher: Cipher): string {
const passportNumber = valueOrFallback(cipher.passport?.decPassportNumber ?? cipher.passport?.passportNumber).trim();
const name = [
valueOrFallback(cipher.passport?.decGivenName ?? cipher.passport?.givenName).trim(),
valueOrFallback(cipher.passport?.decSurname ?? cipher.passport?.surname).trim(),
].filter(Boolean).join(' ');
return passportNumber || name || cipherTypeLabel(8);
}
export function CardBrandIcon({ brand }: { brand?: string | null }) {
const display = displayCardBrand(brand);
const key = display.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'generic';
@@ -118,7 +147,10 @@ export function getCreateTypeOptions(): TypeOption[] {
return [
{ type: 1, label: t('txt_login') },
{ type: 3, label: t('txt_card') },
{ type: 6, label: t('txt_bank_account') },
{ type: 4, label: t('txt_identity') },
{ type: 7, label: t('txt_drivers_license') },
{ type: 8, label: t('txt_passport') },
{ type: 2, label: t('txt_note') },
{ type: 5, label: t('txt_ssh_key') },
];
@@ -175,8 +207,7 @@ export function getWebsiteMatchOptions(): Array<{ value: number | null; label: s
];
}
export const TOTP_PERIOD_SECONDS = 30;
export const TOTP_RING_RADIUS = 14;
const TOTP_RING_RADIUS = 14;
export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
export function CreateTypeIcon({ type }: { type: number }) {
@@ -185,6 +216,9 @@ export function CreateTypeIcon({ type }: { type: number }) {
if (type === 4) return <ShieldUser size={15} />;
if (type === 2) return <StickyNote size={15} />;
if (type === 5) return <KeyRound size={15} />;
if (type === 6) return <Landmark size={15} />;
if (type === 7) return <IdCard size={15} />;
if (type === 8) return <BookUser size={15} />;
return <FileKey2 size={15} />;
}
@@ -193,7 +227,11 @@ export function cipherTypeKey(type: number): TypeFilter {
if (type === 3) return 'card';
if (type === 4) return 'identity';
if (type === 2) return 'note';
return 'ssh';
if (type === 5) return 'ssh';
if (type === 6) return 'bank';
if (type === 7) return 'license';
if (type === 8) return 'passport';
return 'note';
}
function cipherDeletedValue(cipher: Cipher): boolean {
@@ -230,6 +268,9 @@ export function cipherTypeLabel(type: number): string {
if (type === 4) return t('txt_identity');
if (type === 2) return t('txt_secure_note');
if (type === 5) return t('txt_ssh_key');
if (type === 6) return t('txt_bank_account');
if (type === 7) return t('txt_drivers_license');
if (type === 8) return t('txt_passport');
return t('txt_item');
}
@@ -239,6 +280,9 @@ export function TypeIcon({ type }: { type: number }) {
if (type === 4) return <ShieldUser size={18} />;
if (type === 2) return <StickyNote size={18} />;
if (type === 5) return <KeyRound size={18} />;
if (type === 6) return <Landmark size={18} />;
if (type === 7) return <IdCard size={18} />;
if (type === 8) return <BookUser size={18} />;
return <FileKey2 size={18} />;
}
@@ -355,6 +399,52 @@ export function buildCipherDuplicateSignature(cipher: Cipher): string {
fingerprint: valueOrFallback(cipher.sshKey.decFingerprint ?? cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint),
}
: null,
bankAccount: cipher.bankAccount
? {
bankName: valueOrFallback(cipher.bankAccount.decBankName ?? cipher.bankAccount.bankName),
nameOnAccount: valueOrFallback(cipher.bankAccount.decNameOnAccount ?? cipher.bankAccount.nameOnAccount),
accountType: valueOrFallback(cipher.bankAccount.decAccountType ?? cipher.bankAccount.accountType),
accountNumber: valueOrFallback(cipher.bankAccount.decAccountNumber ?? cipher.bankAccount.accountNumber),
routingNumber: valueOrFallback(cipher.bankAccount.decRoutingNumber ?? cipher.bankAccount.routingNumber),
branchNumber: valueOrFallback(cipher.bankAccount.decBranchNumber ?? cipher.bankAccount.branchNumber),
pin: valueOrFallback(cipher.bankAccount.decPin ?? cipher.bankAccount.pin),
swiftCode: valueOrFallback(cipher.bankAccount.decSwiftCode ?? cipher.bankAccount.swiftCode),
iban: valueOrFallback(cipher.bankAccount.decIban ?? cipher.bankAccount.iban),
bankContactPhone: valueOrFallback(cipher.bankAccount.decBankContactPhone ?? cipher.bankAccount.bankContactPhone),
}
: null,
driversLicense: cipher.driversLicense
? {
firstName: valueOrFallback(cipher.driversLicense.decFirstName ?? cipher.driversLicense.firstName),
middleName: valueOrFallback(cipher.driversLicense.decMiddleName ?? cipher.driversLicense.middleName),
lastName: valueOrFallback(cipher.driversLicense.decLastName ?? cipher.driversLicense.lastName),
dateOfBirth: valueOrFallback(cipher.driversLicense.decDateOfBirth ?? cipher.driversLicense.dateOfBirth),
licenseNumber: valueOrFallback(cipher.driversLicense.decLicenseNumber ?? cipher.driversLicense.licenseNumber),
issuingCountry: valueOrFallback(cipher.driversLicense.decIssuingCountry ?? cipher.driversLicense.issuingCountry),
issuingState: valueOrFallback(cipher.driversLicense.decIssuingState ?? cipher.driversLicense.issuingState),
issueDate: valueOrFallback(cipher.driversLicense.decIssueDate ?? cipher.driversLicense.issueDate),
expirationDate: valueOrFallback(cipher.driversLicense.decExpirationDate ?? cipher.driversLicense.expirationDate),
issuingAuthority: valueOrFallback(cipher.driversLicense.decIssuingAuthority ?? cipher.driversLicense.issuingAuthority),
licenseClass: valueOrFallback(cipher.driversLicense.decLicenseClass ?? cipher.driversLicense.licenseClass),
}
: null,
passport: cipher.passport
? {
surname: valueOrFallback(cipher.passport.decSurname ?? cipher.passport.surname),
givenName: valueOrFallback(cipher.passport.decGivenName ?? cipher.passport.givenName),
dateOfBirth: valueOrFallback(cipher.passport.decDateOfBirth ?? cipher.passport.dateOfBirth),
sex: valueOrFallback(cipher.passport.decSex ?? cipher.passport.sex),
birthPlace: valueOrFallback(cipher.passport.decBirthPlace ?? cipher.passport.birthPlace),
nationality: valueOrFallback(cipher.passport.decNationality ?? cipher.passport.nationality),
issuingCountry: valueOrFallback(cipher.passport.decIssuingCountry ?? cipher.passport.issuingCountry),
passportNumber: valueOrFallback(cipher.passport.decPassportNumber ?? cipher.passport.passportNumber),
passportType: valueOrFallback(cipher.passport.decPassportType ?? cipher.passport.passportType),
nationalIdentificationNumber: valueOrFallback(cipher.passport.decNationalIdentificationNumber ?? cipher.passport.nationalIdentificationNumber),
issuingAuthority: valueOrFallback(cipher.passport.decIssuingAuthority ?? cipher.passport.issuingAuthority),
issueDate: valueOrFallback(cipher.passport.decIssueDate ?? cipher.passport.issueDate),
expirationDate: valueOrFallback(cipher.passport.decExpirationDate ?? cipher.passport.expirationDate),
}
: null,
secureNoteType: cipher.secureNote?.type ?? null,
fields: (cipher.fields || []).map((field) => ({
type: field.type ?? null,
@@ -427,6 +517,40 @@ export function createEmptyDraft(type: number): VaultDraft {
sshPrivateKey: '',
sshPublicKey: '',
sshFingerprint: '',
bankName: '',
bankNameOnAccount: '',
bankAccountType: '',
bankAccountNumber: '',
bankRoutingNumber: '',
bankBranchNumber: '',
bankPin: '',
bankSwiftCode: '',
bankIban: '',
bankContactPhone: '',
licenseFirstName: '',
licenseMiddleName: '',
licenseLastName: '',
licenseDateOfBirth: '',
licenseNumber: '',
licenseIssuingCountry: '',
licenseIssuingState: '',
licenseIssueDate: '',
licenseExpirationDate: '',
licenseIssuingAuthority: '',
licenseClass: '',
passportSurname: '',
passportGivenName: '',
passportDateOfBirth: '',
passportSex: '',
passportBirthPlace: '',
passportNationality: '',
passportIssuingCountry: '',
passportNumber: '',
passportType: '',
passportNationalIdentificationNumber: '',
passportIssuingAuthority: '',
passportIssueDate: '',
passportExpirationDate: '',
customFields: [],
};
}
@@ -490,6 +614,46 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
draft.sshPublicKey = cipher.sshKey.decPublicKey || '';
draft.sshFingerprint = cipher.sshKey.decFingerprint || '';
}
if (cipher.bankAccount) {
draft.bankName = cipher.bankAccount.decBankName || '';
draft.bankNameOnAccount = cipher.bankAccount.decNameOnAccount || '';
draft.bankAccountType = cipher.bankAccount.decAccountType || '';
draft.bankAccountNumber = cipher.bankAccount.decAccountNumber || '';
draft.bankRoutingNumber = cipher.bankAccount.decRoutingNumber || '';
draft.bankBranchNumber = cipher.bankAccount.decBranchNumber || '';
draft.bankPin = cipher.bankAccount.decPin || '';
draft.bankSwiftCode = cipher.bankAccount.decSwiftCode || '';
draft.bankIban = cipher.bankAccount.decIban || '';
draft.bankContactPhone = cipher.bankAccount.decBankContactPhone || '';
}
if (cipher.driversLicense) {
draft.licenseFirstName = cipher.driversLicense.decFirstName || '';
draft.licenseMiddleName = cipher.driversLicense.decMiddleName || '';
draft.licenseLastName = cipher.driversLicense.decLastName || '';
draft.licenseDateOfBirth = cipher.driversLicense.decDateOfBirth || '';
draft.licenseNumber = cipher.driversLicense.decLicenseNumber || '';
draft.licenseIssuingCountry = cipher.driversLicense.decIssuingCountry || '';
draft.licenseIssuingState = cipher.driversLicense.decIssuingState || '';
draft.licenseIssueDate = cipher.driversLicense.decIssueDate || '';
draft.licenseExpirationDate = cipher.driversLicense.decExpirationDate || '';
draft.licenseIssuingAuthority = cipher.driversLicense.decIssuingAuthority || '';
draft.licenseClass = cipher.driversLicense.decLicenseClass || '';
}
if (cipher.passport) {
draft.passportSurname = cipher.passport.decSurname || '';
draft.passportGivenName = cipher.passport.decGivenName || '';
draft.passportDateOfBirth = cipher.passport.decDateOfBirth || '';
draft.passportSex = cipher.passport.decSex || '';
draft.passportBirthPlace = cipher.passport.decBirthPlace || '';
draft.passportNationality = cipher.passport.decNationality || '';
draft.passportIssuingCountry = cipher.passport.decIssuingCountry || '';
draft.passportNumber = cipher.passport.decPassportNumber || '';
draft.passportType = cipher.passport.decPassportType || '';
draft.passportNationalIdentificationNumber = cipher.passport.decNationalIdentificationNumber || '';
draft.passportIssuingAuthority = cipher.passport.decIssuingAuthority || '';
draft.passportIssueDate = cipher.passport.decIssueDate || '';
draft.passportExpirationDate = cipher.passport.decExpirationDate || '';
}
draft.customFields = (cipher.fields || []).map((field) => ({
type: parseFieldType(field.type),
label: field.decName || '',
@@ -561,13 +561,18 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
openRemoveAllDevices() {
onSetConfirm({
title: t('txt_remove_all_devices'),
message: t('txt_remove_all_devices_and_sign_out_all_sessions'),
message: `${t('txt_remove_all_devices_and_sign_out_all_sessions')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true,
onConfirm: () => {
requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null);
void (async () => {
try {
await deleteAllAuthorizedDevices(authedFetch);
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalizedPassword = String(masterPassword || '');
if (!normalizedPassword.trim()) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
await deleteAllAuthorizedDevices(authedFetch, derived.hash);
onNotify('success', t('txt_all_devices_removed'));
onLogoutNow();
} catch (error) {
+80 -28
View File
@@ -1,5 +1,6 @@
import { useMemo } from 'preact/hooks';
import { createInvite, deleteAllInvites, deleteInvalidInvites, deleteInvite, deleteUser, setUserStatus } from '@/lib/api/admin';
import { deriveLoginHash } from '@/lib/api/auth';
import { t } from '@/lib/i18n';
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
import type { AuthedFetch } from '@/lib/api/shared';
@@ -8,6 +9,8 @@ type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
interface UseAdminActionsOptions {
authedFetch: AuthedFetch;
email: string;
defaultKdfIterations: number;
onNotify: Notify;
onSetConfirm: (next: AppConfirmState | null) => void;
refetchUsers: () => Promise<unknown>;
@@ -15,7 +18,24 @@ interface UseAdminActionsOptions {
}
export default function useAdminActions(options: UseAdminActionsOptions) {
const { authedFetch, onNotify, onSetConfirm, refetchUsers, refetchInvites } = options;
const {
authedFetch,
email,
defaultKdfIterations,
onNotify,
onSetConfirm,
refetchUsers,
refetchInvites,
} = options;
async function withMasterPasswordHash(masterPassword: string | undefined): Promise<string> {
const normalizedEmail = String(email || '').trim().toLowerCase();
const normalizedPassword = String(masterPassword || '');
if (!normalizedEmail) throw new Error(t('txt_profile_unavailable'));
if (!normalizedPassword.trim()) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(normalizedEmail, normalizedPassword, defaultKdfIterations);
return derived.hash;
}
return useMemo(
() => ({
@@ -26,35 +46,61 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
},
async createInvite(hours: number) {
try {
await createInvite(authedFetch, hours);
await refetchInvites();
onNotify('success', t('txt_invite_created'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed'));
}
onSetConfirm({
title: t('txt_create_timed_invite'),
message: t('txt_enter_master_password_to_continue'),
requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null);
void (async () => {
try {
const hash = await withMasterPasswordHash(masterPassword);
await createInvite(authedFetch, hours, hash);
await refetchInvites();
onNotify('success', t('txt_invite_created'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed'));
}
})();
},
});
},
async toggleUserStatus(userId: string, status: 'active' | 'banned') {
try {
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
await refetchUsers();
onNotify('success', t('txt_user_status_updated'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed'));
}
const nextStatus = status === 'active' ? 'banned' : 'active';
onSetConfirm({
title: nextStatus === 'banned' ? t('txt_ban') : t('txt_unban'),
message: t('txt_enter_master_password_to_continue'),
danger: nextStatus === 'banned',
requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null);
void (async () => {
try {
const hash = await withMasterPasswordHash(masterPassword);
await setUserStatus(authedFetch, userId, nextStatus, hash);
await refetchUsers();
onNotify('success', t('txt_user_status_updated'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed'));
}
})();
},
});
},
async deleteInvite(code: string) {
onSetConfirm({
title: t('txt_delete_invite'),
message: t('txt_delete_invite_confirm_message'),
message: `${t('txt_delete_invite_confirm_message')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true,
onConfirm: () => {
requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null);
void (async () => {
try {
await deleteInvite(authedFetch, code);
const hash = await withMasterPasswordHash(masterPassword);
await deleteInvite(authedFetch, code, hash);
await refetchInvites();
onNotify('success', t('txt_invite_deleted'));
} catch (error) {
@@ -68,13 +114,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
async deleteInvalidInvites() {
onSetConfirm({
title: t('txt_delete_invalid_invites'),
message: t('txt_delete_invalid_invites_confirm_message'),
message: `${t('txt_delete_invalid_invites_confirm_message')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true,
onConfirm: () => {
requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null);
void (async () => {
try {
await deleteInvalidInvites(authedFetch);
const hash = await withMasterPasswordHash(masterPassword);
await deleteInvalidInvites(authedFetch, hash);
await refetchInvites();
onNotify('success', t('txt_invalid_invites_deleted'));
} catch (error) {
@@ -88,13 +136,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
async deleteAllInvites() {
onSetConfirm({
title: t('txt_delete_all_invites'),
message: t('txt_delete_all_invite_codes_active_inactive'),
message: `${t('txt_delete_all_invite_codes_active_inactive')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true,
onConfirm: () => {
requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null);
void (async () => {
try {
await deleteAllInvites(authedFetch);
const hash = await withMasterPasswordHash(masterPassword);
await deleteAllInvites(authedFetch, hash);
await refetchInvites();
onNotify('success', t('txt_all_invites_deleted'));
} catch (error) {
@@ -108,13 +158,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
async deleteUser(userId: string) {
onSetConfirm({
title: t('txt_delete_user'),
message: t('txt_delete_this_user_and_all_user_data'),
message: `${t('txt_delete_this_user_and_all_user_data')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true,
onConfirm: () => {
requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null);
void (async () => {
try {
await deleteUser(authedFetch, userId);
const hash = await withMasterPasswordHash(masterPassword);
await deleteUser(authedFetch, userId, hash);
await refetchUsers();
onNotify('success', t('txt_user_deleted'));
} catch (error) {
@@ -125,6 +177,6 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
});
},
}),
[authedFetch, onNotify, onSetConfirm, refetchInvites, refetchUsers]
[authedFetch, defaultKdfIterations, email, onNotify, onSetConfirm, refetchInvites, refetchUsers]
);
}
+4 -4
View File
@@ -82,12 +82,12 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
},
async inspectRemoteBackup(destinationId: string, path: string) {
return inspectRemoteBackupIntegrity(authedFetch, destinationId, path);
async inspectRemoteBackup(masterPasswordHash: string, destinationId: string, path: string) {
return inspectRemoteBackupIntegrity(authedFetch, masterPasswordHash, destinationId, path);
},
async deleteRemoteBackup(destinationId: string, path: string) {
await deleteRemoteBackup(authedFetch, destinationId, path);
async deleteRemoteBackup(masterPasswordHash: string, destinationId: string, path: string) {
await deleteRemoteBackup(authedFetch, masterPasswordHash, destinationId, path);
},
async restoreRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) {
+32 -118
View File
@@ -16,6 +16,7 @@ export interface PendingAccountPasskeyCredential {
deviceResponse: PublicKeyCredential;
request: Record<string, unknown>;
supportsPrf: boolean;
prfKey?: Uint8Array;
}
export interface AccountPasskeyPrfKeySet {
@@ -82,20 +83,9 @@ async function getLoginWithPrfSalt(): Promise<Uint8Array> {
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> {
function buildPrfExtension(salt: Uint8Array): Record<string, unknown> {
const evalInput: PrfEvalInput = { first: salt };
return {
prf: {
@@ -104,34 +94,23 @@ function buildLegacyPrfExtension(salt: Uint8Array): Record<string, unknown> {
};
}
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: PublicKeyCredentialCreationOptions,
salt: Uint8Array
): PublicKeyCredentialCreationOptions;
function withPrfExtension(
options: PublicKeyCredentialRequestOptions,
extension: Record<string, unknown>
): PublicKeyCredentialRequestOptions {
salt: Uint8Array
): PublicKeyCredentialRequestOptions;
function withPrfExtension(
options: PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions,
salt: Uint8Array
): PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions {
return {
...options,
extensions: {
...((options as any).extensions || {}),
...extension,
...buildPrfExtension(salt),
} as any,
};
}
@@ -154,70 +133,17 @@ function readPrfFirstResult(credential: PublicKeyCredential): ArrayBuffer | unde
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';
}
function shouldRetryCreateWithoutPrf(error: unknown): boolean {
const name = error instanceof DOMException || error instanceof Error ? error.name : '';
const message = error instanceof DOMException || error instanceof Error ? error.message : '';
return (
name === 'NotSupportedError' ||
name === 'SyntaxError' ||
name === 'TypeError' ||
(name === 'UnknownError' && /transient/i.test(message))
);
}
async function canRequestPrfExtension(): Promise<boolean> {
if (/\bFirefox\//i.test(navigator.userAgent)) return false;
return true;
}
async function getPublicKeyCredentialWithPrf(
options: PublicKeyCredentialRequestOptions,
salt: Uint8Array,
credentialIds: string[] = []
salt: Uint8Array
): 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;
}
}
const credential = await navigator.credentials.get({
publicKey: withPrfExtension(options, salt),
});
if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_selected'));
}
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);
return credential;
}
async function prfOutputToKey(prfOutput: ArrayBuffer): Promise<Uint8Array> {
@@ -282,8 +208,7 @@ export async function assertAccountPasskey(
const nativeOptions = cloneRequestOptions(response.options);
const credential = await getPublicKeyCredentialWithPrf(
nativeOptions,
await getLoginWithPrfSalt(),
prfCredentialIdsFromAllowCredentials(nativeOptions)
await getLoginWithPrfSalt()
);
const prfResult = readPrfFirstResult(credential);
return {
@@ -309,34 +234,22 @@ export async function createAccountPasskeyCredential(
}
return credential;
};
let credential: PublicKeyCredential;
if (requestPrf && await canRequestPrfExtension()) {
const prfOptions: PublicKeyCredentialCreationOptions = {
...noPrfOptions,
extensions: {
...((noPrfOptions as any).extensions || {}),
prf: {},
} as any,
};
try {
credential = await createWithOptions(prfOptions);
} catch (error) {
if (!shouldRetryCreateWithoutPrf(error)) throw error;
credential = await createWithOptions(noPrfOptions);
}
} else {
credential = await createWithOptions(noPrfOptions);
}
const prfSalt = requestPrf ? await getLoginWithPrfSalt() : null;
const credential = await createWithOptions(
prfSalt ? withPrfExtension(noPrfOptions, prfSalt) : noPrfOptions
);
if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_created'));
}
const supportsPrf = !!(credential.getClientExtensionResults() as any).prf?.enabled;
const prfResult = readPrfFirstResult(credential);
const supportsPrf = !!prfResult || (credential.getClientExtensionResults() as any).prf?.enabled === true;
return {
token: response.token,
createOptions: nativeOptions,
deviceResponse: credential,
request: attestationRequest(credential),
supportsPrf,
prfKey: prfResult ? await prfOutputToKey(prfResult) : undefined,
};
}
@@ -373,8 +286,10 @@ export async function buildAccountPasskeyPrfKeySet(
pending: PendingAccountPasskeyCredential,
userKey: { symEncKey: string; symMacKey: string }
): Promise<AccountPasskeyPrfKeySet> {
if (pending.prfKey) {
return buildAccountPasskeyPrfKeySetFromPrfKey(pending.prfKey, userKey);
}
const rawId = new Uint8Array(pending.deviceResponse.rawId);
const credentialId = bytesToBase64Url(rawId);
const assertionOptions: PublicKeyCredentialRequestOptions = {
challenge: pending.createOptions?.challenge!,
rpId: pending.createOptions?.rp?.id,
@@ -384,8 +299,7 @@ export async function buildAccountPasskeyPrfKeySet(
};
const assertion = await getPublicKeyCredentialWithPrf(
assertionOptions,
await getLoginWithPrfSalt(),
[credentialId]
await getLoginWithPrfSalt()
);
const prfResult = readPrfFirstResult(assertion);
if (!prfResult) {
+29 -12
View File
@@ -15,45 +15,62 @@ export async function listAdminInvites(authedFetch: AuthedFetch): Promise<AdminI
return body?.data || [];
}
export async function createInvite(authedFetch: AuthedFetch, hours: number): Promise<void> {
export async function createInvite(authedFetch: AuthedFetch, hours: number, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/admin/invites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expiresInHours: hours }),
body: JSON.stringify({ expiresInHours: hours, masterPasswordHash }),
});
if (!resp.ok) throw new Error('Create invite failed');
}
export async function deleteInvite(authedFetch: AuthedFetch, code: string): Promise<void> {
const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, { method: 'DELETE' });
export async function deleteInvite(authedFetch: AuthedFetch, code: string, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete invite failed');
}
export async function deleteInvalidInvites(authedFetch: AuthedFetch): Promise<void> {
const resp = await authedFetch('/api/admin/invites?scope=invalid', { method: 'DELETE' });
export async function deleteInvalidInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/admin/invites?scope=invalid', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete invalid invites failed');
}
export async function deleteAllInvites(authedFetch: AuthedFetch): Promise<void> {
const resp = await authedFetch('/api/admin/invites', { method: 'DELETE' });
export async function deleteAllInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/admin/invites', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete all invites failed');
}
export async function setUserStatus(
authedFetch: AuthedFetch,
userId: string,
status: 'active' | 'banned'
status: 'active' | 'banned',
masterPasswordHash: string
): Promise<void> {
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
body: JSON.stringify({ status, masterPasswordHash }),
});
if (!resp.ok) throw new Error('Update user status failed');
}
export async function deleteUser(authedFetch: AuthedFetch, userId: string): Promise<void> {
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' });
export async function deleteUser(authedFetch: AuthedFetch, userId: string, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete user failed');
}
-1
View File
@@ -52,7 +52,6 @@ export async function respondToAuthRequest(
requestId: string,
payload: {
key?: string | null;
masterPasswordHash?: string | null;
deviceIdentifier: string;
requestApproved: boolean;
}
+27 -10
View File
@@ -89,11 +89,29 @@ function clearRememberTwoFactorToken(): void {
localStorage.removeItem(TOTP_REMEMBER_TOKEN_KEY);
}
function hasTwoFactorChallenge(error: TokenError): boolean {
const providers = error.TwoFactorProviders ?? error.CustomResponse?.TwoFactorProviders;
const providers2 = error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
if (Array.isArray(providers)) return providers.length > 0;
if (providers && typeof providers === 'object') return Object.keys(providers as Record<string, unknown>).length > 0;
if (Array.isArray(providers2)) return providers2.length > 0;
if (providers2 && typeof providers2 === 'object') return Object.keys(providers2 as Record<string, unknown>).length > 0;
return providers != null || providers2 != null;
}
export function loadSession(): SessionState | null {
try {
const raw = localStorage.getItem(SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<SessionState> & Partial<PersistedSessionState>;
if (parsed.email && (parsed.accessToken || parsed.refreshToken)) {
const authMode = parsed.authMode === 'web-cookie' ? 'web-cookie' : 'token';
saveSession({ email: parsed.email, authMode });
return {
email: parsed.email,
authMode,
};
}
if (parsed.authMode === 'web-cookie' && parsed.email) {
return {
email: parsed.email,
@@ -106,13 +124,7 @@ export function loadSession(): SessionState | null {
authMode: 'token',
};
}
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null;
return {
accessToken: parsed.accessToken,
refreshToken: parsed.refreshToken,
email: parsed.email,
authMode: 'token',
};
return null;
} catch {
return null;
}
@@ -280,7 +292,7 @@ export async function loginWithPassword(
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
if (resp.ok) {
saveRememberTwoFactorToken((json as TokenSuccess).TwoFactorToken);
} else if (rememberedToken) {
} else if (rememberedToken && hasTwoFactorChallenge(json)) {
clearRememberTwoFactorToken();
}
if (!resp.ok) return json;
@@ -390,6 +402,7 @@ export async function revokeCurrentSession(session: SessionState | null): Promis
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(session?.accessToken ? { Authorization: `Bearer ${session.accessToken}` } : {}),
...(session?.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
},
body: body.toString(),
@@ -1127,8 +1140,12 @@ export async function updateAuthorizedDeviceName(
if (!resp.ok) throw new Error(t('txt_update_device_note_failed'));
}
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> {
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/devices', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
}
+20 -13
View File
@@ -196,11 +196,14 @@ export async function exportAdminBackup(
export async function downloadAdminBackupAttachmentBlob(
authedFetch: AuthedFetch,
blobName: string
blobName: string,
masterPasswordHash: string
): Promise<Uint8Array> {
const params = new URLSearchParams();
params.set('blobName', blobName);
const resp = await authedFetch(`/api/admin/backup/blob?${params.toString()}`, { method: 'GET' });
const resp = await authedFetch('/api/admin/backup/blob', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ blobName, masterPasswordHash }),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
return new Uint8Array(await resp.arrayBuffer());
}
@@ -246,7 +249,7 @@ export async function buildCompleteAdminBackupExport(
stageDetail: 'txt_backup_export_progress_fetch_attachments_detail',
});
for (const attachment of manifest.attachmentBlobs || []) {
const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName);
const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName, masterPasswordHash);
zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes;
}
@@ -403,25 +406,29 @@ export async function verifyBackupFileIntegrity(bytes: Uint8Array, fileName: str
export async function deleteRemoteBackup(
authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId: string,
path: string
): Promise<void> {
const params = new URLSearchParams();
params.set('destinationId', destinationId);
params.set('path', path);
const resp = await authedFetch(`/api/admin/backup/remote/file?${params.toString()}`, { method: 'DELETE' });
const resp = await authedFetch('/api/admin/backup/remote/file', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destinationId, path, masterPasswordHash }),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_delete_failed')));
}
export async function inspectRemoteBackupIntegrity(
authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId: string,
path: string
): Promise<RemoteBackupIntegrityResponse> {
const params = new URLSearchParams();
params.set('destinationId', destinationId);
params.set('path', path);
const resp = await authedFetch(`/api/admin/backup/remote/integrity?${params.toString()}`, { method: 'GET' });
const resp = await authedFetch('/api/admin/backup/remote/integrity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destinationId, path, masterPasswordHash }),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
const body = await parseJson<RemoteBackupIntegrityResponse>(resp);
if (!body?.integrity || !body?.fileName) throw new Error(t('txt_backup_remote_invalid_response'));
+198
View File
@@ -513,6 +513,30 @@ async function encryptTextValue(value: string, enc: Uint8Array, mac: Uint8Array)
return encryptBw(new TextEncoder().encode(s), enc, mac);
}
function stripDecodedObjectFields(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const out: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
if (/^dec[A-Z]/.test(key)) continue;
out[key] = item;
}
return out;
}
async function encryptObjectFields(
existing: unknown,
entries: Array<[string, string]>,
draft: VaultDraft,
enc: Uint8Array,
mac: Uint8Array
): Promise<Record<string, unknown>> {
const out = stripDecodedObjectFields(existing);
for (const [fieldName, draftKey] of entries) {
out[fieldName] = await encryptTextValue(String((draft as unknown as Record<string, unknown>)[draftKey] || ''), enc, mac);
}
return out;
}
async function encryptPasswordHistory(
entries: CipherPasswordHistoryEntry[] | null | undefined,
enc: Uint8Array,
@@ -587,6 +611,40 @@ function draftFromDecryptedCipher(cipher: Cipher): VaultDraft {
sshPrivateKey: '',
sshPublicKey: '',
sshFingerprint: '',
bankName: '',
bankNameOnAccount: '',
bankAccountType: '',
bankAccountNumber: '',
bankRoutingNumber: '',
bankBranchNumber: '',
bankPin: '',
bankSwiftCode: '',
bankIban: '',
bankContactPhone: '',
licenseFirstName: '',
licenseMiddleName: '',
licenseLastName: '',
licenseDateOfBirth: '',
licenseNumber: '',
licenseIssuingCountry: '',
licenseIssuingState: '',
licenseIssueDate: '',
licenseExpirationDate: '',
licenseIssuingAuthority: '',
licenseClass: '',
passportSurname: '',
passportGivenName: '',
passportDateOfBirth: '',
passportSex: '',
passportBirthPlace: '',
passportNationality: '',
passportIssuingCountry: '',
passportNumber: '',
passportType: '',
passportNationalIdentificationNumber: '',
passportIssuingAuthority: '',
passportIssueDate: '',
passportExpirationDate: '',
customFields: [],
};
@@ -662,6 +720,43 @@ function draftFromDecryptedCipher(cipher: Cipher): VaultDraft {
cipher.sshKey.decFingerprint,
cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint
);
} else if (type === 6 && cipher.bankAccount) {
draft.bankName = plainCipherValue(cipher.bankAccount.decBankName, cipher.bankAccount.bankName);
draft.bankNameOnAccount = plainCipherValue(cipher.bankAccount.decNameOnAccount, cipher.bankAccount.nameOnAccount);
draft.bankAccountType = plainCipherValue(cipher.bankAccount.decAccountType, cipher.bankAccount.accountType);
draft.bankAccountNumber = plainCipherValue(cipher.bankAccount.decAccountNumber, cipher.bankAccount.accountNumber);
draft.bankRoutingNumber = plainCipherValue(cipher.bankAccount.decRoutingNumber, cipher.bankAccount.routingNumber);
draft.bankBranchNumber = plainCipherValue(cipher.bankAccount.decBranchNumber, cipher.bankAccount.branchNumber);
draft.bankPin = plainCipherValue(cipher.bankAccount.decPin, cipher.bankAccount.pin);
draft.bankSwiftCode = plainCipherValue(cipher.bankAccount.decSwiftCode, cipher.bankAccount.swiftCode);
draft.bankIban = plainCipherValue(cipher.bankAccount.decIban, cipher.bankAccount.iban);
draft.bankContactPhone = plainCipherValue(cipher.bankAccount.decBankContactPhone, cipher.bankAccount.bankContactPhone);
} else if (type === 7 && cipher.driversLicense) {
draft.licenseFirstName = plainCipherValue(cipher.driversLicense.decFirstName, cipher.driversLicense.firstName);
draft.licenseMiddleName = plainCipherValue(cipher.driversLicense.decMiddleName, cipher.driversLicense.middleName);
draft.licenseLastName = plainCipherValue(cipher.driversLicense.decLastName, cipher.driversLicense.lastName);
draft.licenseDateOfBirth = plainCipherValue(cipher.driversLicense.decDateOfBirth, cipher.driversLicense.dateOfBirth);
draft.licenseNumber = plainCipherValue(cipher.driversLicense.decLicenseNumber, cipher.driversLicense.licenseNumber);
draft.licenseIssuingCountry = plainCipherValue(cipher.driversLicense.decIssuingCountry, cipher.driversLicense.issuingCountry);
draft.licenseIssuingState = plainCipherValue(cipher.driversLicense.decIssuingState, cipher.driversLicense.issuingState);
draft.licenseIssueDate = plainCipherValue(cipher.driversLicense.decIssueDate, cipher.driversLicense.issueDate);
draft.licenseExpirationDate = plainCipherValue(cipher.driversLicense.decExpirationDate, cipher.driversLicense.expirationDate);
draft.licenseIssuingAuthority = plainCipherValue(cipher.driversLicense.decIssuingAuthority, cipher.driversLicense.issuingAuthority);
draft.licenseClass = plainCipherValue(cipher.driversLicense.decLicenseClass, cipher.driversLicense.licenseClass);
} else if (type === 8 && cipher.passport) {
draft.passportSurname = plainCipherValue(cipher.passport.decSurname, cipher.passport.surname);
draft.passportGivenName = plainCipherValue(cipher.passport.decGivenName, cipher.passport.givenName);
draft.passportDateOfBirth = plainCipherValue(cipher.passport.decDateOfBirth, cipher.passport.dateOfBirth);
draft.passportSex = plainCipherValue(cipher.passport.decSex, cipher.passport.sex);
draft.passportBirthPlace = plainCipherValue(cipher.passport.decBirthPlace, cipher.passport.birthPlace);
draft.passportNationality = plainCipherValue(cipher.passport.decNationality, cipher.passport.nationality);
draft.passportIssuingCountry = plainCipherValue(cipher.passport.decIssuingCountry, cipher.passport.issuingCountry);
draft.passportNumber = plainCipherValue(cipher.passport.decPassportNumber, cipher.passport.passportNumber);
draft.passportType = plainCipherValue(cipher.passport.decPassportType, cipher.passport.passportType);
draft.passportNationalIdentificationNumber = plainCipherValue(cipher.passport.decNationalIdentificationNumber, cipher.passport.nationalIdentificationNumber);
draft.passportIssuingAuthority = plainCipherValue(cipher.passport.decIssuingAuthority, cipher.passport.issuingAuthority);
draft.passportIssueDate = plainCipherValue(cipher.passport.decIssueDate, cipher.passport.issueDate);
draft.passportExpirationDate = plainCipherValue(cipher.passport.decExpirationDate, cipher.passport.expirationDate);
}
return draft;
@@ -983,6 +1078,10 @@ function getCipherKeyMismatchProbes(cipher: Cipher): string[] {
cipher.identity?.title,
cipher.identity?.firstName,
cipher.sshKey?.privateKey,
cipher.bankAccount?.bankName,
cipher.bankAccount?.accountNumber,
cipher.driversLicense?.licenseNumber,
cipher.passport?.passportNumber,
...(cipher.fields || []).flatMap((field) => [field.name, field.value]),
];
const probes: string[] = [];
@@ -1053,6 +1152,40 @@ function hasUnresolvedEncryptedFields(cipher: Cipher): boolean {
[cipher.sshKey?.privateKey, cipher.sshKey?.decPrivateKey],
[cipher.sshKey?.publicKey, cipher.sshKey?.decPublicKey],
[cipher.sshKey?.keyFingerprint || cipher.sshKey?.fingerprint, cipher.sshKey?.decFingerprint],
[cipher.bankAccount?.bankName, cipher.bankAccount?.decBankName],
[cipher.bankAccount?.nameOnAccount, cipher.bankAccount?.decNameOnAccount],
[cipher.bankAccount?.accountType, cipher.bankAccount?.decAccountType],
[cipher.bankAccount?.accountNumber, cipher.bankAccount?.decAccountNumber],
[cipher.bankAccount?.routingNumber, cipher.bankAccount?.decRoutingNumber],
[cipher.bankAccount?.branchNumber, cipher.bankAccount?.decBranchNumber],
[cipher.bankAccount?.pin, cipher.bankAccount?.decPin],
[cipher.bankAccount?.swiftCode, cipher.bankAccount?.decSwiftCode],
[cipher.bankAccount?.iban, cipher.bankAccount?.decIban],
[cipher.bankAccount?.bankContactPhone, cipher.bankAccount?.decBankContactPhone],
[cipher.driversLicense?.firstName, cipher.driversLicense?.decFirstName],
[cipher.driversLicense?.middleName, cipher.driversLicense?.decMiddleName],
[cipher.driversLicense?.lastName, cipher.driversLicense?.decLastName],
[cipher.driversLicense?.dateOfBirth, cipher.driversLicense?.decDateOfBirth],
[cipher.driversLicense?.licenseNumber, cipher.driversLicense?.decLicenseNumber],
[cipher.driversLicense?.issuingCountry, cipher.driversLicense?.decIssuingCountry],
[cipher.driversLicense?.issuingState, cipher.driversLicense?.decIssuingState],
[cipher.driversLicense?.issueDate, cipher.driversLicense?.decIssueDate],
[cipher.driversLicense?.expirationDate, cipher.driversLicense?.decExpirationDate],
[cipher.driversLicense?.issuingAuthority, cipher.driversLicense?.decIssuingAuthority],
[cipher.driversLicense?.licenseClass, cipher.driversLicense?.decLicenseClass],
[cipher.passport?.surname, cipher.passport?.decSurname],
[cipher.passport?.givenName, cipher.passport?.decGivenName],
[cipher.passport?.dateOfBirth, cipher.passport?.decDateOfBirth],
[cipher.passport?.sex, cipher.passport?.decSex],
[cipher.passport?.birthPlace, cipher.passport?.decBirthPlace],
[cipher.passport?.nationality, cipher.passport?.decNationality],
[cipher.passport?.issuingCountry, cipher.passport?.decIssuingCountry],
[cipher.passport?.passportNumber, cipher.passport?.decPassportNumber],
[cipher.passport?.passportType, cipher.passport?.decPassportType],
[cipher.passport?.nationalIdentificationNumber, cipher.passport?.decNationalIdentificationNumber],
[cipher.passport?.issuingAuthority, cipher.passport?.decIssuingAuthority],
[cipher.passport?.issueDate, cipher.passport?.decIssueDate],
[cipher.passport?.expirationDate, cipher.passport?.decExpirationDate],
...(cipher.fields || []).flatMap((field) => [
[field.name, field.decName] as [unknown, unknown],
[field.value, field.decValue] as [unknown, unknown],
@@ -1157,6 +1290,9 @@ async function buildCipherPayload(
identity: null,
secureNote: null,
sshKey: null,
bankAccount: null,
driversLicense: null,
passport: null,
fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
passwordHistory: await encryptPasswordHistory(cipher?.passwordHistory, keys.enc, keys.mac),
};
@@ -1222,11 +1358,73 @@ async function buildCipherPayload(
} else if (type === 5) {
const encryptedFingerprint = await encryptTextValue(draft.sshFingerprint, keys.enc, keys.mac);
payload.sshKey = {
...stripDecodedObjectFields(cipher?.sshKey),
privateKey: await encryptTextValue(draft.sshPrivateKey, keys.enc, keys.mac),
publicKey: await encryptTextValue(draft.sshPublicKey, keys.enc, keys.mac),
keyFingerprint: encryptedFingerprint,
fingerprint: encryptedFingerprint,
};
} else if (type === 6) {
payload.bankAccount = await encryptObjectFields(
cipher?.bankAccount,
[
['bankName', 'bankName'],
['nameOnAccount', 'bankNameOnAccount'],
['accountType', 'bankAccountType'],
['accountNumber', 'bankAccountNumber'],
['routingNumber', 'bankRoutingNumber'],
['branchNumber', 'bankBranchNumber'],
['pin', 'bankPin'],
['swiftCode', 'bankSwiftCode'],
['iban', 'bankIban'],
['bankContactPhone', 'bankContactPhone'],
],
draft,
keys.enc,
keys.mac
);
} else if (type === 7) {
payload.driversLicense = await encryptObjectFields(
cipher?.driversLicense,
[
['firstName', 'licenseFirstName'],
['middleName', 'licenseMiddleName'],
['lastName', 'licenseLastName'],
['dateOfBirth', 'licenseDateOfBirth'],
['licenseNumber', 'licenseNumber'],
['issuingCountry', 'licenseIssuingCountry'],
['issuingState', 'licenseIssuingState'],
['issueDate', 'licenseIssueDate'],
['expirationDate', 'licenseExpirationDate'],
['issuingAuthority', 'licenseIssuingAuthority'],
['licenseClass', 'licenseClass'],
],
draft,
keys.enc,
keys.mac
);
} else if (type === 8) {
payload.passport = await encryptObjectFields(
cipher?.passport,
[
['surname', 'passportSurname'],
['givenName', 'passportGivenName'],
['dateOfBirth', 'passportDateOfBirth'],
['sex', 'passportSex'],
['birthPlace', 'passportBirthPlace'],
['nationality', 'passportNationality'],
['issuingCountry', 'passportIssuingCountry'],
['passportNumber', 'passportNumber'],
['passportType', 'passportType'],
['nationalIdentificationNumber', 'passportNationalIdentificationNumber'],
['issuingAuthority', 'passportIssuingAuthority'],
['issueDate', 'passportIssueDate'],
['expirationDate', 'passportExpirationDate'],
],
draft,
keys.enc,
keys.mac
);
} else if (type === 2) {
payload.secureNote = { type: 0 };
}
+15 -2
View File
@@ -27,6 +27,7 @@ import {
unlockOfflineVaultWithMasterKey,
} from '@/lib/offline-auth';
import { probeNodeWardenService } from '@/lib/network-status';
import { setWebsiteIconsEnabled } from '@/lib/website-icon-settings';
import type { AccountPasskeyPrfOption, AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
export interface PendingTotp {
@@ -51,6 +52,7 @@ export type JwtUnsafeReason = 'missing' | 'too_short';
export interface BootstrapAppResult {
defaultKdfIterations: number;
registrationInviteRequired?: boolean;
websiteIconsEnabled: boolean;
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
session: SessionState | null;
profile: Profile | null;
@@ -61,6 +63,7 @@ export interface BootstrapAppResult {
export interface InitialAppBootstrapState {
defaultKdfIterations: number;
registrationInviteRequired?: boolean;
websiteIconsEnabled: boolean;
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
session: SessionState | null;
phase: AppPhase;
@@ -229,10 +232,11 @@ function readWindowBootstrap(): WebBootstrapResponse {
return raw && typeof raw === 'object' ? raw : {};
}
function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialAppBootstrapState, 'defaultKdfIterations' | 'registrationInviteRequired' | 'jwtWarning'> {
function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialAppBootstrapState, 'defaultKdfIterations' | 'registrationInviteRequired' | 'websiteIconsEnabled' | 'jwtWarning'> {
const defaultKdfIterations = Number(boot.defaultKdfIterations || 600000);
const registrationInviteRequired =
typeof boot.registrationInviteRequired === 'boolean' ? boot.registrationInviteRequired : undefined;
const websiteIconsEnabled = boot.websiteIconsEnabled !== false;
const jwtUnsafeReason = boot.jwtUnsafeReason || null;
const jwtWarning = jwtUnsafeReason
? {
@@ -244,6 +248,7 @@ function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialApp
return {
defaultKdfIterations,
registrationInviteRequired,
websiteIconsEnabled,
jwtWarning,
};
}
@@ -304,7 +309,8 @@ function resolveUnauthenticatedPhase(registrationInviteRequired: boolean | undef
}
export function readInitialAppBootstrapState(): InitialAppBootstrapState {
const { defaultKdfIterations, registrationInviteRequired, jwtWarning } = normalizeBootstrapResponse(readWindowBootstrap());
const { defaultKdfIterations, registrationInviteRequired, websiteIconsEnabled, jwtWarning } = normalizeBootstrapResponse(readWindowBootstrap());
setWebsiteIconsEnabled(websiteIconsEnabled);
const session = loadSession();
const hasInviteCode = !!readInviteCodeFromUrl();
const unauthenticatedPhase = hasInviteCode ? 'register' : 'login';
@@ -312,6 +318,7 @@ export function readInitialAppBootstrapState(): InitialAppBootstrapState {
return {
defaultKdfIterations,
registrationInviteRequired,
websiteIconsEnabled,
jwtWarning,
session,
phase: jwtWarning ? 'login' : session ? 'locked' : resolveUnauthenticatedPhase(registrationInviteRequired, unauthenticatedPhase),
@@ -323,12 +330,15 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
const normalizedBoot = normalizeBootstrapResponse(remoteBoot);
const defaultKdfIterations = normalizedBoot.defaultKdfIterations || initial.defaultKdfIterations;
const registrationInviteRequired = normalizedBoot.registrationInviteRequired ?? initial.registrationInviteRequired;
const websiteIconsEnabled = normalizedBoot.websiteIconsEnabled !== false;
setWebsiteIconsEnabled(websiteIconsEnabled);
const jwtWarning = normalizedBoot.jwtWarning ?? initial.jwtWarning;
if (jwtWarning) {
return {
defaultKdfIterations,
registrationInviteRequired,
websiteIconsEnabled,
jwtWarning,
session: null,
profile: null,
@@ -341,6 +351,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
return {
defaultKdfIterations,
registrationInviteRequired,
websiteIconsEnabled,
jwtWarning: null,
session: null,
profile: null,
@@ -353,6 +364,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
return {
defaultKdfIterations,
registrationInviteRequired,
websiteIconsEnabled,
jwtWarning: null,
session: loaded,
profile: cachedProfile,
@@ -364,6 +376,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
return {
defaultKdfIterations,
registrationInviteRequired,
websiteIconsEnabled,
jwtWarning: null,
session: loaded,
profile: null,
+219 -27
View File
@@ -259,17 +259,33 @@ interface TotpConfig {
period: number;
}
interface GoogleAuthenticatorMigrationTotp {
secret: string;
name: string;
issuer: string;
algorithm: TotpHashAlgorithm;
digits: number;
period: number;
}
const DEFAULT_TOTP_CONFIG: Omit<TotpConfig, 'secret' | 'steam'> = {
algorithm: 'SHA-1',
digits: 6,
period: 30,
};
function parseTotpPositiveInt(value: string | null, fallback: number, min: number, max: number): number {
if (!value) return fallback;
function parseTotpDigits(value: string | null): number {
if (!value) return DEFAULT_TOTP_CONFIG.digits;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < min || parsed > max) return fallback;
return parsed;
if (!Number.isInteger(parsed)) return DEFAULT_TOTP_CONFIG.digits;
return Math.max(0, Math.min(10, parsed));
}
function parseTotpPeriod(value: string | null): number {
if (!value) return DEFAULT_TOTP_CONFIG.period;
const parsed = Number(value);
if (!Number.isSafeInteger(parsed)) return DEFAULT_TOTP_CONFIG.period;
return Math.max(1, parsed);
}
function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm {
@@ -279,9 +295,190 @@ function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm {
return 'SHA-1';
}
function parseTotpConfig(raw: string): TotpConfig {
if (!raw) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
function base64ToBytesLoose(value: string): Uint8Array {
const normalized = value.trim().replace(/\s/g, '+').replace(/-/g, '+').replace(/_/g, '/');
if (!normalized) return new Uint8Array();
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
try {
const binary = atob(padded);
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
} catch {
return new Uint8Array();
}
}
function bytesToBase32(bytes: Uint8Array): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = 0;
let value = 0;
let out = '';
for (const byte of bytes) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
out += alphabet[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
out += alphabet[(value << (5 - bits)) & 31];
}
return out;
}
function readProtoVarint(bytes: Uint8Array, state: { offset: number }): number | null {
let result = 0;
let factor = 1;
for (let i = 0; i < 10 && state.offset < bytes.length; i += 1) {
const byte = bytes[state.offset++];
result += (byte & 0x7f) * factor;
if ((byte & 0x80) === 0) return Number.isSafeInteger(result) ? result : null;
factor *= 128;
}
return null;
}
function readProtoBytes(bytes: Uint8Array, state: { offset: number }): Uint8Array | null {
const length = readProtoVarint(bytes, state);
if (length == null || length < 0 || state.offset + length > bytes.length) return null;
const out = bytes.slice(state.offset, state.offset + length);
state.offset += length;
return out;
}
function skipProtoField(bytes: Uint8Array, state: { offset: number }, wireType: number): boolean {
if (wireType === 0) return readProtoVarint(bytes, state) != null;
if (wireType === 1 && state.offset + 8 <= bytes.length) {
state.offset += 8;
return true;
}
if (wireType === 2) return readProtoBytes(bytes, state) != null;
if (wireType === 5 && state.offset + 4 <= bytes.length) {
state.offset += 4;
return true;
}
return false;
}
function googleMigrationAlgorithm(value: number): TotpHashAlgorithm | null {
if (value === 0 || value === 1) return 'SHA-1';
if (value === 2) return 'SHA-256';
if (value === 3) return 'SHA-512';
return null;
}
function googleMigrationDigits(value: number): number {
if (value === 2) return 8;
return 6;
}
function parseGoogleMigrationOtpParameter(bytes: Uint8Array): GoogleAuthenticatorMigrationTotp | null {
const state = { offset: 0 };
let secretBytes: Uint8Array | null = null;
let name = '';
let issuer = '';
let algorithm: TotpHashAlgorithm | null = 'SHA-1';
let digits = 6;
let otpType = 0;
const decoder = new TextDecoder();
while (state.offset < bytes.length) {
const key = readProtoVarint(bytes, state);
if (key == null) return null;
const fieldNumber = Math.floor(key / 8);
const wireType = key % 8;
if (fieldNumber === 1 && wireType === 2) {
secretBytes = readProtoBytes(bytes, state);
} else if (fieldNumber === 2 && wireType === 2) {
const value = readProtoBytes(bytes, state);
name = value ? decoder.decode(value) : '';
} else if (fieldNumber === 3 && wireType === 2) {
const value = readProtoBytes(bytes, state);
issuer = value ? decoder.decode(value) : '';
} else if (fieldNumber === 4 && wireType === 0) {
const value = readProtoVarint(bytes, state);
algorithm = value == null ? null : googleMigrationAlgorithm(value);
} else if (fieldNumber === 5 && wireType === 0) {
const value = readProtoVarint(bytes, state);
digits = googleMigrationDigits(value ?? 0);
} else if (fieldNumber === 6 && wireType === 0) {
otpType = readProtoVarint(bytes, state) ?? 0;
} else if (!skipProtoField(bytes, state, wireType)) {
return null;
}
}
if (!secretBytes?.length || !algorithm || otpType === 1) return null;
return {
secret: bytesToBase32(secretBytes),
name,
issuer,
algorithm,
digits,
period: DEFAULT_TOTP_CONFIG.period,
};
}
function parseGoogleAuthenticatorMigration(raw: string): GoogleAuthenticatorMigrationTotp[] {
let data = '';
try {
data = new URL(raw).searchParams.get('data') || '';
} catch {
data = readOtpAuthParam(raw, 'data');
}
const bytes = base64ToBytesLoose(data);
if (!bytes.length) return [];
const state = { offset: 0 };
const out: GoogleAuthenticatorMigrationTotp[] = [];
while (state.offset < bytes.length) {
const key = readProtoVarint(bytes, state);
if (key == null) return [];
const fieldNumber = Math.floor(key / 8);
const wireType = key % 8;
if (fieldNumber === 1 && wireType === 2) {
const parameterBytes = readProtoBytes(bytes, state);
const parameter = parameterBytes ? parseGoogleMigrationOtpParameter(parameterBytes) : null;
if (parameter) out.push(parameter);
} else if (!skipProtoField(bytes, state, wireType)) {
return [];
}
}
return out;
}
function buildOtpAuthUri(account: GoogleAuthenticatorMigrationTotp): string {
const issuer = account.issuer.trim();
const name = account.name.trim();
const label = issuer && name && !name.toLowerCase().startsWith(`${issuer.toLowerCase()}:`)
? `${issuer}:${name}`
: name || issuer || 'TOTP';
const params = new URLSearchParams({
secret: account.secret,
algorithm: account.algorithm.replace('-', ''),
digits: String(account.digits),
period: String(account.period),
});
if (issuer) params.set('issuer', issuer);
return `otpauth://totp/${encodeURIComponent(label)}?${params.toString()}`;
}
export function normalizeTotpInput(raw: string): string {
const s = raw.trim();
if (!s) return '';
if (/^otpauth-migration:\/\//i.test(s)) {
const accounts = parseGoogleAuthenticatorMigration(s);
return accounts.length === 1 ? buildOtpAuthUri(accounts[0]) : '';
}
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s) && !/^otpauth:\/\//i.test(s) && !/^steam:\/\//i.test(s)) {
return '';
}
return s;
}
function parseTotpConfig(raw: string): TotpConfig {
const s = normalizeTotpInput(raw);
if (!s) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
if (/^steam:\/\//i.test(s)) {
return {
@@ -295,31 +492,20 @@ function parseTotpConfig(raw: string): TotpConfig {
if (/^otpauth:\/\//i.test(s)) {
try {
const u = new URL(s);
const otpType = u.hostname.toLowerCase();
if (otpType !== 'totp') {
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
}
const label = decodeURIComponent((u.pathname || '').replace(/^\/+/, '')).toLowerCase();
const issuer = (u.searchParams.get('issuer') || '').trim().toLowerCase();
const algorithm = (u.searchParams.get('algorithm') || '').trim().toLowerCase();
const steam = issuer === 'steam' || label.startsWith('steam:') || algorithm === 'steam';
return {
secret: normalizeTotpSecret(u.searchParams.get('secret') || ''),
steam,
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(u.searchParams.get('algorithm')),
digits: steam ? 5 : parseTotpPositiveInt(u.searchParams.get('digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
period: parseTotpPositiveInt(u.searchParams.get('period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
steam: false,
algorithm: parseTotpHashAlgorithm(u.searchParams.get('algorithm')),
digits: parseTotpDigits(u.searchParams.get('digits')),
period: parseTotpPeriod(u.searchParams.get('period')),
};
} catch {
const issuer = readOtpAuthParam(s, 'issuer').trim().toLowerCase();
const algorithm = readOtpAuthParam(s, 'algorithm').trim().toLowerCase();
const steam = issuer === 'steam' || algorithm === 'steam';
return {
secret: normalizeTotpSecret(readOtpAuthParam(s, 'secret')),
steam,
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(algorithm),
digits: steam ? 5 : parseTotpPositiveInt(readOtpAuthParam(s, 'digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
period: parseTotpPositiveInt(readOtpAuthParam(s, 'period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
steam: false,
algorithm: parseTotpHashAlgorithm(readOtpAuthParam(s, 'algorithm')),
digits: parseTotpDigits(readOtpAuthParam(s, 'digits')),
period: parseTotpPeriod(readOtpAuthParam(s, 'period')),
};
}
}
@@ -349,7 +535,13 @@ function base32ToBytes(input: string): Uint8Array {
return new Uint8Array(out);
}
export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now()): Promise<{ code: string; remain: number } | null> {
export interface TotpCodeResult {
code: string;
remain: number;
period: number;
}
export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now()): Promise<TotpCodeResult | null> {
const { secret, steam, algorithm, digits, period } = parseTotpConfig(rawSecret);
if (!secret) return null;
const keyBytes = base32ToBytes(secret);
@@ -378,5 +570,5 @@ export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now())
value = Math.floor(value / chars.length);
}
}
return { code, remain };
return { code, remain, period };
}
+1
View File
@@ -20,6 +20,7 @@ export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
return {
defaultKdfIterations: 600000,
registrationInviteRequired: true,
websiteIconsEnabled: true,
jwtWarning: null,
session: null,
phase: 'login',
+399 -1
View File
@@ -9,6 +9,7 @@ import type {
import type {
AdminInvite,
AdminUser,
AuditLogEntry,
AuthorizedDevice,
Cipher,
Folder,
@@ -383,6 +384,143 @@ export const DEMO_CIPHERS: Cipher[] = [
decFingerprint: 'SHA256:demoNodeWardenFingerprint',
},
},
// --- Duplicate detection demo pairs (exact, login-site, login-credentials, password) ---
{
id: 'cipher-dup-exact-a',
type: 1,
folderId: 'folder-work',
favorite: false,
name: 'Internal VPN',
decName: 'Internal VPN',
creationDate: '2026-04-10T08:00:00.000Z',
revisionDate: '2026-04-28T10:00:00.000Z',
login: {
username: 'vpn-user',
password: 'vpn-secret-2026', // gitguardian:ignore
decUsername: 'vpn-user',
decPassword: 'vpn-secret-2026', // gitguardian:ignore
uris: [{ uri: 'https://vpn.internal.example.com', decUri: 'https://vpn.internal.example.com', match: null }],
},
},
{
id: 'cipher-dup-exact-b',
type: 1,
folderId: 'folder-work',
favorite: false,
name: 'Internal VPN',
decName: 'Internal VPN',
creationDate: '2026-03-15T08:00:00.000Z',
revisionDate: '2026-04-30T10:00:00.000Z',
login: {
username: 'vpn-user',
password: 'vpn-secret-2026', // gitguardian:ignore
decUsername: 'vpn-user',
decPassword: 'vpn-secret-2026', // gitguardian:ignore
uris: [{ uri: 'https://vpn.internal.example.com', decUri: 'https://vpn.internal.example.com', match: null }],
},
},
{
id: 'cipher-dup-site-a',
type: 1,
folderId: 'folder-devops',
favorite: false,
name: 'AWS Console',
decName: 'AWS Console',
creationDate: '2026-03-01T08:00:00.000Z',
revisionDate: '2026-04-25T09:00:00.000Z',
login: {
username: 'aws-admin',
password: 'aws-secure-password', // gitguardian:ignore
decUsername: 'aws-admin',
decPassword: 'aws-secure-password', // gitguardian:ignore
uris: [{ uri: 'https://console.aws.amazon.com', decUri: 'https://console.aws.amazon.com', match: null }],
},
},
{
id: 'cipher-dup-site-b',
type: 1,
folderId: 'folder-devops',
favorite: false,
name: 'Amazon Web Services',
decName: 'Amazon Web Services',
creationDate: '2026-02-20T08:00:00.000Z',
revisionDate: '2026-04-20T09:00:00.000Z',
login: {
username: 'aws-admin',
password: 'aws-secure-password', // gitguardian:ignore
decUsername: 'aws-admin',
decPassword: 'aws-secure-password', // gitguardian:ignore
uris: [{ uri: 'https://console.aws.amazon.com', decUri: 'https://console.aws.amazon.com', match: null }],
},
},
{
id: 'cipher-dup-cred-a',
type: 1,
folderId: 'folder-personal',
favorite: false,
name: 'Personal Blog',
decName: 'Personal Blog',
creationDate: '2026-01-10T08:00:00.000Z',
revisionDate: '2026-04-15T10:00:00.000Z',
login: {
username: 'my-account@example.com',
password: 'shared-credential', // gitguardian:ignore
decUsername: 'my-account@example.com',
decPassword: 'shared-credential', // gitguardian:ignore
uris: [{ uri: 'https://blog.example.com', decUri: 'https://blog.example.com', match: null }],
},
},
{
id: 'cipher-dup-cred-b',
type: 1,
folderId: 'folder-personal',
favorite: false,
name: 'Forum Account',
decName: 'Forum Account',
creationDate: '2026-01-15T08:00:00.000Z',
revisionDate: '2026-04-18T10:00:00.000Z',
login: {
username: 'my-account@example.com',
password: 'shared-credential', // gitguardian:ignore
decUsername: 'my-account@example.com',
decPassword: 'shared-credential', // gitguardian:ignore
uris: [{ uri: 'https://forum.example.com', decUri: 'https://forum.example.com', match: null }],
},
},
{
id: 'cipher-dup-pw-a',
type: 1,
folderId: 'folder-personal',
favorite: false,
name: 'Old Forum',
decName: 'Old Forum',
creationDate: '2025-06-01T08:00:00.000Z',
revisionDate: '2026-03-01T10:00:00.000Z',
login: {
username: 'legacy-user',
password: 'reused-password-2020', // gitguardian:ignore
decUsername: 'legacy-user',
decPassword: 'reused-password-2020', // gitguardian:ignore
uris: [{ uri: 'https://old-forum.example.com', decUri: 'https://old-forum.example.com', match: null }],
},
},
{
id: 'cipher-dup-pw-b',
type: 1,
folderId: 'folder-personal',
favorite: false,
name: 'Legacy CMS',
decName: 'Legacy CMS',
creationDate: '2025-05-10T08:00:00.000Z',
revisionDate: '2026-02-15T10:00:00.000Z',
login: {
username: 'cms-admin',
password: 'reused-password-2020', // gitguardian:ignore
decUsername: 'cms-admin',
decPassword: 'reused-password-2020', // gitguardian:ignore
uris: [{ uri: 'https://cms.example.com', decUri: 'https://cms.example.com', match: null }],
},
},
{
id: 'cipher-archived',
type: 1,
@@ -575,6 +713,233 @@ export const DEMO_BACKUP_SETTINGS: AdminBackupSettings = {
],
};
export const DEMO_AUDIT_LOGS: AuditLogEntry[] = [
{
id: 'demo-log-auth-login',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'auth.login.success',
category: 'auth',
level: 'info',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ ip: '203.0.113.42', device: 'Chrome 125 on Windows', location: 'San Francisco, US' }),
createdAt: '2026-07-08T14:32:10.000Z',
object: 'auditLog',
},
{
id: 'demo-log-auth-failed',
actorUserId: null,
actorEmail: 'unknown@example.com',
action: 'auth.login.failed',
category: 'auth',
level: 'warn',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ ip: '198.51.100.7', reason: 'invalid_password', attemptCount: 3 }),
createdAt: '2026-07-08T13:15:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-auth-2fa',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'auth.totp.enabled',
category: 'auth',
level: 'security',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ ip: '203.0.113.42', trigger: 'user_initiated' }),
createdAt: '2026-07-07T09:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-auth-refresh-failed',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'auth.refresh.failed.token_expired',
category: 'auth',
level: 'error',
targetType: null,
targetId: 'demo-device-browser',
targetUserEmail: null,
metadata: JSON.stringify({ ip: '203.0.113.42', device: 'Chrome 125 on Windows' }),
createdAt: '2026-07-06T18:45:30.000Z',
object: 'auditLog',
},
{
id: 'demo-log-security-password',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'user.password.changed',
category: 'security',
level: 'security',
targetType: 'user',
targetId: DEMO_USER_ID,
targetUserEmail: DEMO_PROFILE.email,
metadata: JSON.stringify({ ip: '203.0.113.42', trigger: 'user_initiated' }),
createdAt: '2026-07-05T10:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-security-user-banned',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.user.banned',
category: 'security',
level: 'security',
targetType: 'user',
targetId: 'demo-user-003',
targetUserEmail: 'suspended@example.com',
metadata: JSON.stringify({ ip: '203.0.113.42', reason: 'violation_of_tos' }),
createdAt: '2026-07-04T16:20:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-security-user-register',
actorUserId: null,
actorEmail: 'newuser@example.com',
action: 'user.register.completed',
category: 'security',
level: 'info',
targetType: 'user',
targetId: 'demo-user-004',
targetUserEmail: 'newuser@example.com',
metadata: JSON.stringify({ ip: '192.0.2.55', invite: 'DEMO-INVITE-2026' }),
createdAt: '2026-07-03T08:30:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-device-trusted',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'device.trusted.added',
category: 'device',
level: 'info',
targetType: 'device',
targetId: 'demo-device-mobile',
targetUserEmail: null,
metadata: JSON.stringify({ deviceName: 'iPhone', os: 'iOS 18', ip: '203.0.113.42' }),
createdAt: '2026-07-02T12:15:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-device-removed',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'device.removed',
category: 'device',
level: 'warn',
targetType: 'device',
targetId: 'demo-device-old',
targetUserEmail: null,
metadata: JSON.stringify({ deviceName: 'Firefox on Linux', ip: '198.51.100.20', trigger: 'user_initiated' }),
createdAt: '2026-07-01T09:45:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-device-all-revoked',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'device.all_trust_revoked',
category: 'device',
level: 'security',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ ip: '203.0.113.42', trigger: 'password_change' }),
createdAt: '2026-07-01T09:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-data-backup',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.backup.run.completed',
category: 'data',
level: 'info',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ fileName: 'nodewarden_backup_20260701_030000.zip', size: '1.2 MB', destination: 'Demo WebDAV' }),
createdAt: '2026-07-01T03:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-data-restore',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.backup.restore.completed',
category: 'data',
level: 'warn',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ fileName: 'nodewarden_backup_20260628_030000.zip', checksum: 'verified' }),
createdAt: '2026-06-30T14:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-data-export',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.export.completed',
category: 'data',
level: 'info',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ format: 'encrypted_json', totalItems: 24 }),
createdAt: '2026-06-28T11:30:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-system-settings',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.settings.updated',
category: 'system',
level: 'info',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ changedKeys: ['signupsAllowed', 'kdfIterations'], ip: '203.0.113.42' }),
createdAt: '2026-06-25T08:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-system-invite',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.invite.created',
category: 'system',
level: 'info',
targetType: 'invite',
targetId: 'DEMO-INVITE-2026',
targetUserEmail: null,
metadata: JSON.stringify({ expiresIn: '168h', ip: '203.0.113.42' }),
createdAt: '2026-06-20T10:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-system-config',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.config.updated',
category: 'system',
level: 'warn',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ changedKeys: ['smtp.host', 'smtp.port'], ip: '203.0.113.42' }),
createdAt: '2026-06-18T15:30:00.000Z',
object: 'auditLog',
},
];
function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
@@ -790,6 +1155,7 @@ export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
return {
defaultKdfIterations: 600000,
registrationInviteRequired: true,
websiteIconsEnabled: true,
jwtWarning: null,
session: null,
phase: 'login',
@@ -1074,6 +1440,38 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
onGetRecoveryCode: readonlyString,
onGetApiKey: readonlyString,
onRotateApiKey: readonlyString,
onListAccountPasskeys: async () => [],
onCreateAccountPasskey: async () => {
await readonly();
return null;
},
onEnableAccountPasskeyDirectUnlock: readonly,
onDeleteAccountPasskey: readonly,
onLoadAuditLogs: async (filters) => {
const limit = Number(filters.limit || 50) || 50;
const offset = Number(filters.offset || 0) || 0;
let filtered = DEMO_AUDIT_LOGS.filter((log) => {
if (filters.category && filters.category !== 'all' && log.category !== filters.category) return false;
if (filters.level && filters.level !== 'all' && log.level !== filters.level) return false;
if (filters.q) {
const q = filters.q.toLowerCase();
if (!log.action.toLowerCase().includes(q) && !(log.actorEmail || '').toLowerCase().includes(q)) return false;
}
if (filters.from && new Date(log.createdAt).getTime() < new Date(filters.from).getTime()) return false;
if (filters.to && new Date(log.createdAt).getTime() > new Date(filters.to).getTime()) return false;
return true;
});
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const total = filtered.length;
const sliced = filtered.slice(offset, offset + limit);
return {
logs: sliced,
total,
limit,
offset: offset + sliced.length,
hasMore: offset + sliced.length < total,
};
},
onLockTimeoutChange: readonlyVoid,
onSessionTimeoutActionChange: readonlyVoid,
onRefreshAuthorizedDevices: async () => {
@@ -1200,7 +1598,7 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
onDownloadRemoteBackup: async (_masterPassword: string, _destinationId: string, _path: string, _onProgress?: (percent: number | null) => void) => {
notify('success', t('txt_demo_download_prepared'));
},
onInspectRemoteBackup: async (_destinationId: string, path: string) => ({
onInspectRemoteBackup: async (_masterPassword: string, _destinationId: string, path: string) => ({
object: 'backup-remote-integrity',
destinationId: _destinationId,
path,
File diff suppressed because it is too large Load Diff
+34 -4
View File
@@ -215,13 +215,13 @@ function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
const login = cipher.login;
out.login = login
? {
...cloneValue(login),
...(cloneWithoutDecodedFields(login) || {}),
username: login.username ?? null,
password: login.password ?? null,
totp: login.totp ?? null,
uris: Array.isArray(login.uris)
? login.uris.map((uri) => ({
...cloneValue(uri),
...(cloneWithoutDecodedFields(uri) || {}),
uri: uri?.uri ?? null,
uriChecksum: uri?.uriChecksum ?? null,
match: (uri as { match?: unknown })?.match ?? null,
@@ -280,6 +280,7 @@ function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
out.sshKey = cipher.sshKey
? {
...(cloneWithoutDecodedFields(cipher.sshKey) || {}),
privateKey: cipher.sshKey.privateKey ?? null,
publicKey: cipher.sshKey.publicKey ?? null,
keyFingerprint: cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
@@ -287,6 +288,9 @@ function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
fingerprint: cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
}
: null;
out.bankAccount = cloneWithoutDecodedFields(cipher.bankAccount) ?? null;
out.driversLicense = cloneWithoutDecodedFields(cipher.driversLicense) ?? null;
out.passport = cloneWithoutDecodedFields(cipher.passport) ?? null;
return out;
}
@@ -331,8 +335,8 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
out.login = null;
}
out.card = cipher.card ? await deepDecryptUnknown(cipher.card, keyParts.enc, keyParts.mac) : null;
out.identity = cipher.identity ? await deepDecryptUnknown(cipher.identity, keyParts.enc, keyParts.mac) : null;
out.card = cipher.card ? await deepDecryptUnknown(cloneWithoutDecodedFields(cipher.card), keyParts.enc, keyParts.mac) : null;
out.identity = cipher.identity ? await deepDecryptUnknown(cloneWithoutDecodedFields(cipher.identity), keyParts.enc, keyParts.mac) : null;
if (cipher.sshKey) {
const fingerprint = await decryptMaybe(
cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
@@ -340,6 +344,7 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
keyParts.mac
);
out.sshKey = {
...((await deepDecryptUnknown(cloneWithoutDecodedFields(cipher.sshKey), keyParts.enc, keyParts.mac)) as Record<string, unknown>),
privateKey: await decryptMaybe(cipher.sshKey.privateKey ?? null, keyParts.enc, keyParts.mac),
publicKey: await decryptMaybe(cipher.sshKey.publicKey ?? null, keyParts.enc, keyParts.mac),
keyFingerprint: fingerprint,
@@ -349,6 +354,15 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
} else {
out.sshKey = null;
}
out.bankAccount = cipher.bankAccount
? await deepDecryptUnknown(cloneWithoutDecodedFields(cipher.bankAccount), keyParts.enc, keyParts.mac)
: null;
out.driversLicense = cipher.driversLicense
? await deepDecryptUnknown(cloneWithoutDecodedFields(cipher.driversLicense), keyParts.enc, keyParts.mac)
: null;
out.passport = cipher.passport
? await deepDecryptUnknown(cloneWithoutDecodedFields(cipher.passport), keyParts.enc, keyParts.mac)
: null;
out.secureNote = cipher.secureNote
? {
type: normalizeNumber((cipher.secureNote as { type?: unknown }).type, 0),
@@ -431,6 +445,9 @@ function sourceTypeLabel(type: number): string {
if (type === 3) return 'card';
if (type === 4) return 'identity';
if (type === 5) return 'sshKey';
if (type === 6) return 'bankAccount';
if (type === 7) return 'driversLicense';
if (type === 8) return 'passport';
if (type === 2) return 'note';
return `type ${type}`;
}
@@ -449,6 +466,16 @@ function appendRecordFieldLines(lines: string[], prefix: string, value: unknown)
}
}
function cloneWithoutDecodedFields(value: unknown): Record<string, unknown> | null {
if (!isRecord(value)) return null;
const out: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
if (/^dec[A-Z]/.test(key)) continue;
out[key] = cloneValue(item);
}
return out;
}
const BITWARDEN_CSV_OBJECT_FIELDS: Record<string, readonly string[]> = {
card: ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code'],
identity: [
@@ -472,6 +499,9 @@ const BITWARDEN_CSV_OBJECT_FIELDS: Record<string, readonly string[]> = {
'country',
],
sshKey: ['privateKey', 'publicKey', 'keyFingerprint', 'fingerprint'],
bankAccount: ['bankName', 'nameOnAccount', 'accountType', 'accountNumber', 'routingNumber', 'branchNumber', 'pin', 'swiftCode', 'iban', 'bankContactPhone'],
driversLicense: ['firstName', 'middleName', 'lastName', 'dateOfBirth', 'licenseNumber', 'issuingCountry', 'issuingState', 'issueDate', 'expirationDate', 'issuingAuthority', 'licenseClass'],
passport: ['surname', 'givenName', 'dateOfBirth', 'sex', 'birthPlace', 'nationality', 'issuingCountry', 'passportNumber', 'passportType', 'nationalIdentificationNumber', 'issuingAuthority', 'issueDate', 'expirationDate'],
};
function appendKnownRecordFieldLines(lines: string[], prefix: string, value: unknown): void {
+21 -4
View File
@@ -10,10 +10,14 @@ export type Locale =
| 'zh-CN'
| 'zh-TW'
| 'ru'
| 'es';
| 'es'
| 'fi'
| 'de'
| 'fr'
| 'it'
| 'sv';
import enMessages from './i18n/locales/en';
const LOCALE_STORAGE_KEY = 'nodewarden.locale';
type MessageTable = Record<string, string>;
@@ -24,6 +28,11 @@ export const AVAILABLE_LOCALES: readonly { value: Locale; label: string }[] = [
{ value: 'zh-TW', label: '繁體中文' },
{ value: 'ru', label: 'Русский' },
{ value: 'es', label: 'Español' },
{ value: 'fi', label: 'Suomi' },
{ value: 'de', label: 'Deutsch' },
{ value: 'fr', label: 'Français' },
{ value: 'it', label: 'Italiano' },
{ value: 'sv', label: 'Svenska' },
];
let locale: Locale = resolveInitialLocale();
@@ -49,6 +58,11 @@ function resolveInitialLocale(): Locale {
if (normalized.startsWith('zh')) return 'zh-CN';
if (normalized.startsWith('ru')) return 'ru';
if (normalized.startsWith('es')) return 'es';
if (normalized.startsWith('fi')) return 'fi';
if (normalized.startsWith('de')) return 'de';
if (normalized.startsWith('fr')) return 'fr';
if (normalized.startsWith('it')) return 'it';
if (normalized.startsWith('sv')) return 'sv';
}
}
return 'en';
@@ -60,6 +74,11 @@ const localeLoaders: Record<Locale, () => Promise<{ default: MessageTable }>> =
'zh-TW': () => import('./i18n/locales/zh-TW'),
ru: () => import('./i18n/locales/ru'),
es: () => import('./i18n/locales/es'),
fi: () => import('./i18n/locales/fi'),
de: () => import('./i18n/locales/de'),
fr: () => import('./i18n/locales/fr'),
it: () => import('./i18n/locales/it'),
sv: () => import('./i18n/locales/sv'),
};
function localeToHtmlLang(value: Locale): string {
@@ -74,7 +93,6 @@ function syncDocumentLanguage(): void {
async function loadLocaleMessages(next: Locale): Promise<MessageTable> {
const cached = loadedMessages.get(next);
if (cached) return cached;
const mod = await localeLoaders[next]();
loadedMessages.set(next, mod.default);
return mod.default;
@@ -223,7 +241,6 @@ export function translateServerError(message: string | null | undefined, fallbac
'masterPasswordHash is required': 'txt_server_error_master_password_hash_required',
'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required',
}[normalized];
return key ? t(key) : normalized;
}
File diff suppressed because it is too large Load Diff
+110
View File
@@ -7,6 +7,30 @@ const en: Record<string, string> = {
"nav_my_vault": "My Vault",
"nav_vault_items": "Vault",
"nav_sends": "Sends",
"nav_generator": "Generator",
"txt_password_generator": "Password Generator",
"txt_password_generator_description": "Create a strong, unique password locally on this device.",
"txt_generator_type": "Generator type",
"txt_passphrase": "Passphrase",
"txt_generated_password": "Generated password",
"txt_password_strength": "Strength",
"txt_password_strength_weak": "Weak",
"txt_password_strength_fair": "Fair",
"txt_password_strength_good": "Good",
"txt_password_strength_strong": "Strong",
"txt_generator_security_note": "Generation happens locally. Your generated password is never sent to the server.",
"txt_generator_length": "Length",
"txt_generator_character_types": "Character types",
"txt_generator_uppercase": "Uppercase (A-Z)",
"txt_generator_lowercase": "Lowercase (a-z)",
"txt_generator_numbers": "Numbers (0-9)",
"txt_generator_special": "Special characters (!@#$%^&*)",
"txt_generator_minimum": "Minimum",
"txt_generator_avoid_ambiguous": "Avoid ambiguous characters",
"txt_generator_words": "Number of words",
"txt_generator_separator": "Word separator",
"txt_generator_capitalize": "Capitalize",
"txt_generator_include_number": "Include a number",
"nav_backup_strategy": "Cloud Backup",
"nav_import_export": "Import & Export",
"nav_group_data_backup": "Data & Backup",
@@ -688,6 +712,35 @@ const en: Record<string, string> = {
"txt_last_name": "Last Name",
"txt_last_seen": "Last Seen",
"txt_license_number": "License Number",
"txt_bank_account": "Bank Account",
"txt_bank_account_details": "Bank Account Details",
"txt_bank_name": "Bank Name",
"txt_name_on_account": "Name on Account",
"txt_account_type": "Account Type",
"txt_account_number": "Account Number",
"txt_routing_number": "Routing Number",
"txt_branch_number": "Branch Number",
"txt_pin": "PIN",
"txt_swift_code": "SWIFT Code",
"txt_iban": "IBAN",
"txt_bank_contact_phone": "Bank Contact Phone",
"txt_drivers_license": "Driver License",
"txt_drivers_license_details": "Driver License Details",
"txt_date_of_birth": "Date of Birth",
"txt_issuing_country": "Issuing Country",
"txt_issuing_state": "Issuing State",
"txt_issue_date": "Issue Date",
"txt_issuing_authority": "Issuing Authority",
"txt_license_class": "License Class",
"txt_passport": "Passport",
"txt_passport_details": "Passport Details",
"txt_surname": "Surname",
"txt_given_name": "Given Name",
"txt_sex": "Sex",
"txt_birth_place": "Place of Birth",
"txt_nationality": "Nationality",
"txt_passport_type": "Passport Type",
"txt_national_id_number": "National ID Number",
"txt_link_copied": "Link copied",
"txt_linked": "Linked",
"txt_linux_desktop": "Linux Desktop",
@@ -983,6 +1036,9 @@ const en: Record<string, string> = {
"txt_online": "Online",
"txt_offline": "Offline",
"txt_offline_vault_readonly": "Offline mode is read-only. Connect to NodeWarden before changing your vault.",
"txt_offline_mode_notice_title": "You are in offline mode. If this looks wrong, force refresh.",
"txt_offline_mode_notice_windows": "Windows:",
"txt_offline_mode_notice_macos": "macOS:",
"txt_submit": "Submit",
"txt_sync": "Sync",
"txt_sync_vault": "Sync Vault",
@@ -1014,6 +1070,8 @@ const en: Record<string, string> = {
"txt_totp_qr_scanned": "TOTP value added.",
"txt_totp_qr_not_found": "No QR code found in that image.",
"txt_totp_qr_scan_failed": "Failed to scan QR code.",
"txt_totp_qr_invalid_image_type": "Choose an image file.",
"txt_totp_qr_image_too_large": "Choose an image smaller than 8 MB.",
"txt_totp_qr_unsupported": "This browser does not support QR scanning. Try Chrome or Edge, or paste the TOTP link or secret manually.",
"txt_totp_qr_camera_unavailable": "Camera is unavailable. Check browser permission, or choose an image.",
"txt_totp_qr_choose_image": "Choose image",
@@ -1125,6 +1183,12 @@ const en: Record<string, string> = {
"txt_import_invalid_password_protected_file": "Invalid password-protected export file.",
"txt_import_decrypt_failed": "Failed to decrypt import file.",
"txt_import_empty_zip_archive": "Empty zip archive.",
"txt_import_zip_too_large": "ZIP archive is too large. Maximum size is {size} MiB.",
"txt_import_file_too_large": "Import file is too large. Maximum size is {size} MiB.",
"txt_import_zip_too_many_files": "ZIP archive contains too many files.",
"txt_import_zip_entry_too_large": "ZIP archive contains a file larger than {size} MiB.",
"txt_import_zip_expands_too_large": "ZIP archive expands beyond the current import limit of {size} MiB.",
"txt_import_zip_unsafe_file_name": "ZIP archive contains an unsafe file name.",
"txt_import_no_json_found_in_zip": "No importable JSON data found in zip archive.",
"txt_import_data_json_not_found": "data.json not found in zip archive.",
"txt_import_zip_password_required": "ZIP password is required.",
@@ -1219,11 +1283,18 @@ const en: Record<string, string> = {
"txt_log_action_account_api_key_create": "Create API key",
"txt_log_action_account_api_key_rotate": "Rotate API key",
"txt_log_action_account_keys_update": "Update account keys",
"txt_log_action_account_passkey_create": "Create login passkey",
"txt_log_action_account_passkey_delete": "Delete login passkey",
"txt_log_action_account_passkey_encryption_enable": "Enable passkey vault unlock",
"txt_log_action_account_profile_update": "Update account profile",
"txt_log_action_account_totp_disable": "Disable two-step login",
"txt_log_action_account_totp_enable": "Enable two-step login",
"txt_log_action_account_totp_recover": "Recover two-step login",
"txt_log_action_account_verify_devices_update": "Update device verification",
"txt_log_action_account_webauthn_2fa_delete": "Delete passkey two-step login key",
"txt_log_action_account_webauthn_2fa_enable": "Enable passkey two-step login",
"txt_log_action_account_yubikey_enable": "Update YubiKey OTP settings",
"txt_log_action_admin_audit_clear": "Clear audit logs",
"txt_log_action_admin_audit_settings_update": "Update log retention settings",
"txt_log_action_admin_backup_export": "Export backup",
"txt_log_action_admin_backup_import": "Import backup",
@@ -1246,6 +1317,8 @@ const en: Record<string, string> = {
"txt_log_action_auth_login_failed_bad_password": "Login failed: bad password",
"txt_log_action_auth_login_failed_user_inactive": "Login failed: inactive account",
"txt_log_action_auth_login_success": "Login succeeded",
"txt_log_action_auth_passkey_login_failed": "Passkey login failed",
"txt_log_action_auth_passkey_login_success": "Passkey login succeeded",
"txt_log_action_auth_refresh_failed": "Refresh login failed: {reason}",
"txt_log_action_cipher_delete_permanent": "Permanently delete vault item",
"txt_log_action_cipher_delete_permanent_bulk": "Permanently delete vault items",
@@ -1296,6 +1369,7 @@ const en: Record<string, string> = {
"txt_log_meta_method": "Request method",
"txt_log_meta_path": "Request path",
"txt_log_meta_provider": "Provider",
"txt_log_meta_prf_status": "PRF status",
"txt_log_meta_prune_error": "Cleanup error",
"txt_log_meta_pruned_file_count": "Cleaned files",
"txt_log_meta_raw": "Raw data",
@@ -1331,6 +1405,7 @@ const en: Record<string, string> = {
"txt_log_reason_user_inactive": "User inactive",
"txt_log_reason_user_missing": "User missing",
"txt_log_target_type_attachment": "Attachment",
"txt_log_target_type_account_passkey": "Login passkey",
"txt_log_target_type_audit_log": "Log",
"txt_log_target_type_backup": "Backup",
"txt_log_target_type_cipher": "Vault item",
@@ -1396,4 +1471,39 @@ const en: Record<string, string> = {
"txt_ip_address": "IP address"
};
Object.assign(en, {
"nav_password_security": "Password Security",
"txt_password_security": "Password Security Check",
"txt_password_security_privacy": "Passwords are checked locally in your browser. Only an anonymous hash prefix is sent to the breach database.",
"txt_check_password_security": "Start check",
"txt_checking_password_security": "Checking",
"txt_recheck_password_security": "Check again",
"txt_password_security_ready": "Your vault is ready for a security check.",
"txt_password_security_no_login": "There are no login passwords to check.",
"txt_password_security_manual": "The check only starts when you choose it. Results are kept until you refresh or your vault changes.",
"txt_password_security_no_login_help": "Add a login item with a password, then return here to check it.",
"txt_exposed_passwords": "Exposed",
"txt_reused_passwords": "Reused",
"txt_weak_passwords": "Weak",
"txt_passwords_checked": "Checked",
"txt_password_security_last_checked": "Last checked: {value}",
"txt_password_security_show_all": "Show all",
"txt_password_security_hide_all": "Hide all",
"txt_password_security_jump": "Go to item",
"txt_password_security_exposed_short": "Exposed {count} times",
"txt_password_security_weak_short": "Weak password",
"txt_password_security_reused_short": "Reused",
"txt_password_security_unavailable": "{count} password checks could not reach the breach database. They are not marked safe.",
"txt_password_security_not_checked": "Not checked",
"txt_password_exposed_count": "Found in {count} breaches",
"txt_password_reused_count": "Used {count} times",
"txt_weak_password": "Weak password",
"txt_no_password_risks": "No password risks found",
"txt_no_password_risks_in_filter": "No password risks in this category",
"txt_open_vault": "Open vault",
"txt_check_password_breach": "Check breach",
"txt_password_not_found_in_breaches": "Not found in the breach database",
"txt_password_security_check_failed": "The breach check could not be completed."
});
export default en;
+182 -122
View File
@@ -7,6 +7,7 @@ const es: Record<string, string> = {
"nav_my_vault": "Mi bóveda",
"nav_vault_items": "Bóveda",
"nav_sends": "Envíos",
"nav_generator": "Generador", "txt_password_generator": "Generador de contraseñas", "txt_password_generator_description": "Crea una contraseña única y segura localmente en este dispositivo.", "txt_generator_type": "Tipo de generador", "txt_passphrase": "Frase de contraseña", "txt_generated_password": "Contraseña generada", "txt_password_strength": "Seguridad", "txt_password_strength_weak": "Débil", "txt_password_strength_fair": "Regular", "txt_password_strength_good": "Buena", "txt_password_strength_strong": "Fuerte", "txt_generator_security_note": "La generación se realiza localmente. Tu contraseña nunca se envía al servidor.", "txt_generator_length": "Longitud", "txt_generator_character_types": "Tipos de caracteres", "txt_generator_uppercase": "Mayúsculas (A-Z)", "txt_generator_lowercase": "Minúsculas (a-z)", "txt_generator_numbers": "Números (0-9)", "txt_generator_special": "Caracteres especiales (!@#$%^&*)", "txt_generator_minimum": "Mínimo", "txt_generator_avoid_ambiguous": "Evitar caracteres ambiguos", "txt_generator_words": "Número de palabras", "txt_generator_separator": "Separador de palabras", "txt_generator_capitalize": "Usar mayúsculas", "txt_generator_include_number": "Incluir un número",
"nav_backup_strategy": "Copia de seguridad en la nube",
"nav_import_export": "Importar y exportar",
"nav_group_data_backup": "Datos y copias",
@@ -688,6 +689,35 @@ const es: Record<string, string> = {
"txt_last_name": "Apellido",
"txt_last_seen": "Visto por última vez",
"txt_license_number": "Número de licencia",
"txt_bank_account": "Cuenta bancaria",
"txt_bank_account_details": "Detalles de cuenta bancaria",
"txt_bank_name": "Nombre del banco",
"txt_name_on_account": "Nombre en la cuenta",
"txt_account_type": "Tipo de cuenta",
"txt_account_number": "Número de cuenta",
"txt_routing_number": "Número de ruta",
"txt_branch_number": "Número de sucursal",
"txt_pin": "PIN",
"txt_swift_code": "Código SWIFT",
"txt_iban": "IBAN",
"txt_bank_contact_phone": "Teléfono del banco",
"txt_drivers_license": "Licencia de conducir",
"txt_drivers_license_details": "Detalles de licencia de conducir",
"txt_date_of_birth": "Fecha de nacimiento",
"txt_issuing_country": "País emisor",
"txt_issuing_state": "Estado emisor",
"txt_issue_date": "Fecha de emisión",
"txt_issuing_authority": "Autoridad emisora",
"txt_license_class": "Clase de licencia",
"txt_passport": "Pasaporte",
"txt_passport_details": "Detalles del pasaporte",
"txt_surname": "Apellido",
"txt_given_name": "Nombre",
"txt_sex": "Sexo",
"txt_birth_place": "Lugar de nacimiento",
"txt_nationality": "Nacionalidad",
"txt_passport_type": "Tipo de pasaporte",
"txt_national_id_number": "Número de ID nacional",
"txt_link_copied": "Enlace copiado",
"txt_linked": "Vinculado",
"txt_linux_desktop": "Escritorio Linux",
@@ -983,6 +1013,9 @@ const es: Record<string, string> = {
"txt_online": "En línea",
"txt_offline": "Sin conexión",
"txt_offline_vault_readonly": "El modo sin conexión es de solo lectura. Conecta con NodeWarden antes de cambiar la bóveda.",
"txt_offline_mode_notice_title": "Estás en modo sin conexión. Si parece un error, fuerza la recarga.",
"txt_offline_mode_notice_windows": "Windows:",
"txt_offline_mode_notice_macos": "macOS:",
"txt_submit": "Enviar",
"txt_sync": "Sincronizar",
"txt_sync_vault": "Sincronizar bóveda",
@@ -1014,6 +1047,8 @@ const es: Record<string, string> = {
"txt_totp_qr_scanned": "Valor TOTP agregado.",
"txt_totp_qr_not_found": "No se encontró ningún código QR en esa imagen.",
"txt_totp_qr_scan_failed": "No se pudo escanear el código QR.",
"txt_totp_qr_invalid_image_type": "Elija un archivo de imagen.",
"txt_totp_qr_image_too_large": "Elija una imagen de menos de 8 MB.",
"txt_totp_qr_unsupported": "Este navegador no admite escaneo QR. Pruebe Chrome o Edge, o pegue manualmente el enlace o secreto TOTP.",
"txt_totp_qr_camera_unavailable": "La cámara no está disponible. Revise el permiso del navegador o elija una imagen.",
"txt_totp_qr_choose_image": "Elegir imagen",
@@ -1125,6 +1160,12 @@ const es: Record<string, string> = {
"txt_import_invalid_password_protected_file": "Archivo de exportación protegido con contraseña no válido.",
"txt_import_decrypt_failed": "Error al descifrar el archivo de importación.",
"txt_import_empty_zip_archive": "El archivo ZIP está vacío.",
"txt_import_zip_too_large": "El archivo ZIP es demasiado grande. El tamaño máximo es {size} MiB.",
"txt_import_file_too_large": "El archivo de importación es demasiado grande. El tamaño máximo es {size} MiB.",
"txt_import_zip_too_many_files": "El archivo ZIP contiene demasiados archivos.",
"txt_import_zip_entry_too_large": "El archivo ZIP contiene un archivo mayor que {size} MiB.",
"txt_import_zip_expands_too_large": "El archivo ZIP se descomprime por encima del límite actual de importación de {size} MiB.",
"txt_import_zip_unsafe_file_name": "El archivo ZIP contiene un nombre de archivo no seguro.",
"txt_import_no_json_found_in_zip": "No se encontraron datos JSON importables en el archivo zip.",
"txt_import_data_json_not_found": "No se encontró data.json en el archivo ZIP.",
"txt_import_zip_password_required": "La contraseña ZIP es obligatoria.",
@@ -1216,133 +1257,144 @@ const es: Record<string, string> = {
"txt_log_level_info": "Info",
"txt_log_level_security": "Seguridad",
"txt_log_level_warn": "Aviso",
"txt_log_action_account_api_key_create": "Create API key",
"txt_log_action_account_api_key_rotate": "Rotate API key",
"txt_log_action_account_keys_update": "Update account keys",
"txt_log_action_account_profile_update": "Update account profile",
"txt_log_action_account_totp_disable": "Disable two-step login",
"txt_log_action_account_totp_enable": "Enable two-step login",
"txt_log_action_account_totp_recover": "Recover two-step login",
"txt_log_action_account_verify_devices_update": "Update device verification",
"txt_log_action_admin_audit_settings_update": "Update log retention settings",
"txt_log_action_admin_backup_export": "Export backup",
"txt_log_action_admin_backup_import": "Import backup",
"txt_log_action_admin_backup_remote_delete": "Delete remote backup",
"txt_log_action_admin_backup_remote_manual": "Manual remote backup succeeded",
"txt_log_action_admin_backup_remote_manual_failed": "Manual remote backup failed",
"txt_log_action_admin_backup_remote_scheduled": "Scheduled remote backup succeeded",
"txt_log_action_admin_backup_remote_scheduled_failed": "Scheduled remote backup failed",
"txt_log_action_admin_backup_settings_repair": "Repair backup settings",
"txt_log_action_admin_backup_settings_update": "Update backup settings",
"txt_log_action_admin_invite_create": "Create invite",
"txt_log_action_admin_invite_delete": "Delete invite",
"txt_log_action_admin_invite_delete_all": "Clear invites",
"txt_log_action_admin_invite_delete_invalid": "Delete invalid invites",
"txt_log_action_admin_invite_revoke": "Revoke invite",
"txt_log_action_admin_user_delete": "Delete user",
"txt_log_action_admin_user_status": "Change user status",
"txt_log_action_attachment_delete": "Delete attachment",
"txt_log_action_auth_login_failed_bad_api_key": "Login failed: bad API key",
"txt_log_action_auth_login_failed_bad_password": "Login failed: bad password",
"txt_log_action_auth_login_failed_user_inactive": "Login failed: inactive account",
"txt_log_action_auth_login_success": "Login succeeded",
"txt_log_action_auth_refresh_failed": "Refresh login failed: {reason}",
"txt_log_action_cipher_delete_permanent": "Permanently delete vault item",
"txt_log_action_cipher_delete_permanent_bulk": "Permanently delete vault items",
"txt_log_action_cipher_delete_soft": "Move vault item to trash",
"txt_log_action_cipher_delete_soft_bulk": "Move vault items to trash",
"txt_log_action_device_deactivate": "Deactivate device",
"txt_log_action_device_delete": "Delete device",
"txt_log_action_device_delete_all": "Delete all devices",
"txt_log_action_device_name_update": "Update device name",
"txt_log_action_device_trust_permanent": "Trust device permanently",
"txt_log_action_device_trust_revoke": "Revoke device trust",
"txt_log_action_device_trust_revoke_batch": "Revoke device trust in bulk",
"txt_log_action_folder_delete": "Delete folder",
"txt_log_action_folder_delete_bulk": "Delete folders",
"txt_log_action_send_auth_remove": "Remove Send authentication",
"txt_log_action_send_delete": "Delete Send",
"txt_log_action_send_delete_bulk": "Delete Sends",
"txt_log_action_send_password_remove": "Remove Send password",
"txt_log_action_user_password_change": "Change master password",
"txt_log_action_user_register_first_admin": "Register first admin",
"txt_log_action_user_register_invite": "Register by invite",
"txt_log_meta_attachments": "Attachments",
"txt_log_action_account_api_key_create": "Crear clave de API",
"txt_log_action_account_api_key_rotate": "Rotar clave de API",
"txt_log_action_account_keys_update": "Actualizar claves de cuenta",
"txt_log_action_account_passkey_create": "Crear passkey de inicio de sesión",
"txt_log_action_account_passkey_delete": "Eliminar passkey de inicio de sesión",
"txt_log_action_account_passkey_encryption_enable": "Activar desbloqueo de la bóveda con passkey",
"txt_log_action_account_profile_update": "Actualizar perfil de cuenta",
"txt_log_action_account_totp_disable": "Desactivar verificación en dos pasos",
"txt_log_action_account_totp_enable": "Activar verificación en dos pasos",
"txt_log_action_account_totp_recover": "Recuperar verificación en dos pasos",
"txt_log_action_account_verify_devices_update": "Actualizar verificación de dispositivos",
"txt_log_action_account_webauthn_2fa_delete": "Eliminar clave de verificación en dos pasos con passkey",
"txt_log_action_account_webauthn_2fa_enable": "Activar verificación en dos pasos con passkey",
"txt_log_action_account_yubikey_enable": "Actualizar configuración de YubiKey OTP",
"txt_log_action_admin_audit_clear": "Borrar registros de auditoría",
"txt_log_action_admin_audit_settings_update": "Actualizar retención de registros",
"txt_log_action_admin_backup_export": "Exportar copia de seguridad",
"txt_log_action_admin_backup_import": "Importar copia de seguridad",
"txt_log_action_admin_backup_remote_delete": "Eliminar copia remota",
"txt_log_action_admin_backup_remote_manual": "Copia remota manual completada",
"txt_log_action_admin_backup_remote_manual_failed": "Error en copia remota manual",
"txt_log_action_admin_backup_remote_scheduled": "Copia remota programada completada",
"txt_log_action_admin_backup_remote_scheduled_failed": "Error en copia remota programada",
"txt_log_action_admin_backup_settings_repair": "Reparar configuración de copias",
"txt_log_action_admin_backup_settings_update": "Actualizar configuración de copias",
"txt_log_action_admin_invite_create": "Crear invitación",
"txt_log_action_admin_invite_delete": "Eliminar invitación",
"txt_log_action_admin_invite_delete_all": "Borrar invitaciones",
"txt_log_action_admin_invite_delete_invalid": "Eliminar invitaciones no válidas",
"txt_log_action_admin_invite_revoke": "Revocar invitación",
"txt_log_action_admin_user_delete": "Eliminar usuario",
"txt_log_action_admin_user_status": "Cambiar estado del usuario",
"txt_log_action_attachment_delete": "Eliminar adjunto",
"txt_log_action_auth_login_failed_bad_api_key": "Inicio de sesión fallido: clave de API incorrecta",
"txt_log_action_auth_login_failed_bad_password": "Inicio de sesión fallido: contraseña incorrecta",
"txt_log_action_auth_login_failed_user_inactive": "Inicio de sesión fallido: cuenta inactiva",
"txt_log_action_auth_login_success": "Inicio de sesión correcto",
"txt_log_action_auth_passkey_login_failed": "Error de inicio de sesión con passkey",
"txt_log_action_auth_passkey_login_success": "Inicio de sesión con passkey correcto",
"txt_log_action_auth_refresh_failed": "Error al renovar inicio de sesión: {reason}",
"txt_log_action_cipher_delete_permanent": "Eliminar elemento de bóveda permanentemente",
"txt_log_action_cipher_delete_permanent_bulk": "Eliminar elementos de bóveda permanentemente",
"txt_log_action_cipher_delete_soft": "Mover elemento de bóveda a la papelera",
"txt_log_action_cipher_delete_soft_bulk": "Mover elementos de bóveda a la papelera",
"txt_log_action_device_deactivate": "Desactivar dispositivo",
"txt_log_action_device_delete": "Eliminar dispositivo",
"txt_log_action_device_delete_all": "Eliminar todos los dispositivos",
"txt_log_action_device_name_update": "Actualizar nombre del dispositivo",
"txt_log_action_device_trust_permanent": "Confiar permanentemente en el dispositivo",
"txt_log_action_device_trust_revoke": "Revocar confianza del dispositivo",
"txt_log_action_device_trust_revoke_batch": "Revocar confianza de dispositivos en lote",
"txt_log_action_folder_delete": "Eliminar carpeta",
"txt_log_action_folder_delete_bulk": "Eliminar carpetas",
"txt_log_action_send_auth_remove": "Quitar autenticación de Send",
"txt_log_action_send_delete": "Eliminar Send",
"txt_log_action_send_delete_bulk": "Eliminar Sends",
"txt_log_action_send_password_remove": "Quitar contraseña de Send",
"txt_log_action_user_password_change": "Cambiar contraseña maestra",
"txt_log_action_user_register_first_admin": "Registrar primer administrador",
"txt_log_action_user_register_invite": "Registrarse por invitación",
"txt_log_meta_attachments": "Adjuntos",
"txt_log_meta_bytes": "Bytes",
"txt_log_meta_changed": "Changed fields",
"txt_log_meta_checksum_mismatch_accepted": "Accepted checksum mismatch",
"txt_log_meta_cipher_id": "Vault item ID",
"txt_log_meta_ciphers": "Vault items",
"txt_log_meta_compat": "Compatibility",
"txt_log_meta_compressed_bytes": "Compressed bytes",
"txt_log_meta_count": "Count",
"txt_log_meta_deleted": "Deleted count",
"txt_log_meta_destination_count": "Destination count",
"txt_log_meta_destination_id": "Destination ID",
"txt_log_meta_destination_name": "Destination name",
"txt_log_meta_destination_type": "Destination type",
"txt_log_meta_device_identifier": "Device ID",
"txt_log_meta_device_type": "Device type",
"txt_log_meta_changed": "Campos modificados",
"txt_log_meta_checksum_mismatch_accepted": "Desajuste de checksum aceptado",
"txt_log_meta_cipher_id": "ID del elemento de bóveda",
"txt_log_meta_ciphers": "Elementos de bóveda",
"txt_log_meta_compat": "Compatibilidad",
"txt_log_meta_compressed_bytes": "Bytes comprimidos",
"txt_log_meta_count": "Cantidad",
"txt_log_meta_deleted": "Cantidad eliminada",
"txt_log_meta_destination_count": "Cantidad de destinos",
"txt_log_meta_destination_id": "ID de destino",
"txt_log_meta_destination_name": "Nombre de destino",
"txt_log_meta_destination_type": "Tipo de destino",
"txt_log_meta_device_identifier": "ID del dispositivo",
"txt_log_meta_device_type": "Tipo de dispositivo",
"txt_log_meta_email": "Email",
"txt_log_meta_error": "Error",
"txt_log_meta_expires_in_hours": "Expires in hours",
"txt_log_meta_file_bytes": "File bytes",
"txt_log_meta_file_name": "File name",
"txt_log_meta_folder_id": "Folder ID",
"txt_log_meta_grant_type": "Login method",
"txt_log_meta_includes_attachments": "Includes attachments",
"txt_log_meta_ip": "IP address",
"txt_log_meta_max_entries": "Entry limit",
"txt_log_meta_method": "Request method",
"txt_log_meta_path": "Request path",
"txt_log_meta_provider": "Provider",
"txt_log_meta_prune_error": "Cleanup error",
"txt_log_meta_pruned_file_count": "Cleaned files",
"txt_log_meta_raw": "Raw data",
"txt_log_meta_reason": "Reason",
"txt_log_meta_remote_path": "Remote path",
"txt_log_meta_removed": "Removed count",
"txt_log_meta_removed_devices": "Removed devices",
"txt_log_meta_removed_sessions": "Removed sessions",
"txt_log_meta_removed_trusted": "Trust removals",
"txt_log_meta_replace_existing": "Replace existing data",
"txt_log_meta_requested": "Requested count",
"txt_log_meta_requested_count": "Requested count",
"txt_log_meta_retention_days": "Retention days",
"txt_log_meta_scheduled_destination_count": "Scheduled destinations",
"txt_log_meta_size": "Size",
"txt_log_meta_skipped_attachments": "Skipped attachments",
"txt_log_meta_skipped_reason": "Skip reason",
"txt_log_meta_status": "Status",
"txt_log_meta_target_email": "Target email",
"txt_log_meta_trigger": "Trigger",
"txt_log_meta_type": "Type",
"txt_log_meta_updated": "Updated count",
"txt_log_meta_upload_verification_attempts": "Upload verification attempts",
"txt_log_meta_user_agent": "Browser/client",
"txt_log_meta_users": "Users",
"txt_log_meta_verify_devices": "Verify devices",
"txt_log_meta_web_session": "Web session",
"txt_log_reason_bad_api_key": "Bad API key",
"txt_log_reason_bad_password": "Bad password",
"txt_log_reason_device_missing": "Device missing",
"txt_log_reason_device_session_mismatch": "Device session mismatch",
"txt_log_reason_token_not_found_or_expired": "Token missing or expired",
"txt_log_reason_user_inactive": "User inactive",
"txt_log_reason_user_missing": "User missing",
"txt_log_target_type_attachment": "Attachment",
"txt_log_target_type_audit_log": "Log",
"txt_log_target_type_backup": "Backup",
"txt_log_target_type_cipher": "Vault item",
"txt_log_target_type_device": "Device",
"txt_log_target_type_folder": "Folder",
"txt_log_target_type_invite": "Invite",
"txt_log_target_type_refresh_token": "Refresh token",
"txt_log_meta_expires_in_hours": "Caduca en horas",
"txt_log_meta_file_bytes": "Bytes del archivo",
"txt_log_meta_file_name": "Nombre del archivo",
"txt_log_meta_folder_id": "ID de carpeta",
"txt_log_meta_grant_type": "Método de inicio de sesión",
"txt_log_meta_includes_attachments": "Incluye adjuntos",
"txt_log_meta_ip": "Dirección IP",
"txt_log_meta_max_entries": "Límite de entradas",
"txt_log_meta_method": "Método de solicitud",
"txt_log_meta_path": "Ruta de solicitud",
"txt_log_meta_provider": "Proveedor",
"txt_log_meta_prf_status": "Estado de PRF",
"txt_log_meta_prune_error": "Error de limpieza",
"txt_log_meta_pruned_file_count": "Archivos limpiados",
"txt_log_meta_raw": "Datos sin procesar",
"txt_log_meta_reason": "Motivo",
"txt_log_meta_remote_path": "Ruta remota",
"txt_log_meta_removed": "Cantidad quitada",
"txt_log_meta_removed_devices": "Dispositivos quitados",
"txt_log_meta_removed_sessions": "Sesiones quitadas",
"txt_log_meta_removed_trusted": "Confianzas revocadas",
"txt_log_meta_replace_existing": "Reemplazar datos existentes",
"txt_log_meta_requested": "Cantidad solicitada",
"txt_log_meta_requested_count": "Cantidad solicitada",
"txt_log_meta_retention_days": "Días de retención",
"txt_log_meta_scheduled_destination_count": "Destinos programados",
"txt_log_meta_size": "Tamaño",
"txt_log_meta_skipped_attachments": "Adjuntos omitidos",
"txt_log_meta_skipped_reason": "Motivo de omisión",
"txt_log_meta_status": "Estado",
"txt_log_meta_target_email": "Correo del destino",
"txt_log_meta_trigger": "Disparador",
"txt_log_meta_type": "Tipo",
"txt_log_meta_updated": "Cantidad actualizada",
"txt_log_meta_upload_verification_attempts": "Intentos de verificación de subida",
"txt_log_meta_user_agent": "Navegador/cliente",
"txt_log_meta_users": "Usuarios",
"txt_log_meta_verify_devices": "Verificar dispositivos",
"txt_log_meta_web_session": "Sesión web",
"txt_log_reason_bad_api_key": "Clave de API incorrecta",
"txt_log_reason_bad_password": "Contraseña incorrecta",
"txt_log_reason_device_missing": "Dispositivo no encontrado",
"txt_log_reason_device_session_mismatch": "La sesión no coincide con el dispositivo",
"txt_log_reason_token_not_found_or_expired": "Token no encontrado o caducado",
"txt_log_reason_user_inactive": "Usuario inactivo",
"txt_log_reason_user_missing": "Usuario no encontrado",
"txt_log_target_type_attachment": "Adjunto",
"txt_log_target_type_account_passkey": "Passkey de inicio de sesión",
"txt_log_target_type_audit_log": "Registro",
"txt_log_target_type_backup": "Copia de seguridad",
"txt_log_target_type_cipher": "Elemento de bóveda",
"txt_log_target_type_device": "Dispositivo",
"txt_log_target_type_folder": "Carpeta",
"txt_log_target_type_invite": "Invitación",
"txt_log_target_type_refresh_token": "Token de renovación",
"txt_log_target_type_send": "Send",
"txt_log_target_type_user": "User",
"txt_log_target_type_user": "Usuario",
"txt_log_trigger_manual": "Manual",
"txt_log_trigger_remote": "Remote",
"txt_log_trigger_scheduled": "Scheduled",
"txt_log_trigger_remote": "Remoto",
"txt_log_trigger_scheduled": "Programado",
"txt_log_max_1000": "Hasta 1000 entradas",
"txt_log_max_5000": "Hasta 5000 entradas",
"txt_log_max_10000": "Hasta 10 000 entradas",
@@ -1396,4 +1448,12 @@ const es: Record<string, string> = {
"txt_auth_request_missing_public_key": "La solicitud de inicio de sesión con dispositivo no incluye una clave pública"
};
Object.assign(es, {
"nav_password_security": "Seguridad de contraseñas", "txt_password_security": "Comprobación de seguridad", "txt_password_security_privacy": "Las contraseñas se comprueban localmente. Solo se envía un prefijo de hash anónimo a la base de filtraciones al iniciar la comprobación.", "txt_check_password_security": "Iniciar comprobación", "txt_checking_password_security": "Comprobando", "txt_recheck_password_security": "Comprobar de nuevo", "txt_password_security_ready": "Tu bóveda está lista para una comprobación de seguridad.", "txt_password_security_no_login": "No hay contraseñas de inicio de sesión para comprobar.", "txt_password_security_manual": "La comprobación solo empieza cuando la eliges. Los resultados se conservan solo en esta página.", "txt_password_security_no_login_help": "Añade un inicio de sesión con contraseña y vuelve aquí para comprobarlo.", "txt_exposed_passwords": "Filtradas", "txt_reused_passwords": "Reutilizadas", "txt_weak_passwords": "Débiles", "txt_passwords_checked": "Comprobadas", "txt_password_security_unavailable": "{count} comprobaciones no pudieron acceder a la base de filtraciones. No se marcan como seguras.", "txt_password_security_not_checked": "Sin comprobar", "txt_password_exposed_count": "Encontrada en {count} filtraciones", "txt_password_reused_count": "Usada {count} veces", "txt_weak_password": "Contraseña débil", "txt_no_password_risks": "No se encontraron riesgos de contraseña", "txt_open_vault": "Abrir bóveda", "txt_check_password_breach": "Comprobar filtración", "txt_password_not_found_in_breaches": "No encontrada en la base de filtraciones", "txt_password_security_check_failed": "No se pudo completar la comprobación de filtraciones."
});
Object.assign(es, { "txt_password_security_last_checked": "Última comprobación: {value}" });
Object.assign(es, { "txt_no_password_risks_in_filter": "No hay riesgos de contraseña en esta categoría" });
Object.assign(es, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
export default es;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+184 -124
View File
@@ -8,6 +8,7 @@ const ru: Record<string, string> = {
"nav_my_vault": "Мое хранилище",
"nav_vault_items": "Хранилище",
"nav_sends": "Отправляет",
"nav_generator": "Генератор", "txt_password_generator": "Генератор паролей", "txt_password_generator_description": "Создайте надежный уникальный пароль локально на этом устройстве.", "txt_generator_type": "Тип генератора", "txt_passphrase": "Парольная фраза", "txt_generated_password": "Созданный пароль", "txt_password_strength": "Надежность", "txt_password_strength_weak": "Слабый", "txt_password_strength_fair": "Средний", "txt_password_strength_good": "Хороший", "txt_password_strength_strong": "Надежный", "txt_generator_security_note": "Генерация выполняется локально. Пароль никогда не отправляется на сервер.", "txt_generator_length": "Длина", "txt_generator_character_types": "Типы символов", "txt_generator_uppercase": "Заглавные буквы (A-Z)", "txt_generator_lowercase": "Строчные буквы (a-z)", "txt_generator_numbers": "Цифры (0-9)", "txt_generator_special": "Специальные символы (!@#$%^&*)", "txt_generator_minimum": "Минимум", "txt_generator_avoid_ambiguous": "Исключить похожие символы", "txt_generator_words": "Количество слов", "txt_generator_separator": "Разделитель слов", "txt_generator_capitalize": "С заглавной буквы", "txt_generator_include_number": "Добавить число",
"nav_backup_strategy": "Облачное резервное копирование",
"nav_import_export": "Импорт и экспорт",
"nav_group_data_backup": "Данные и резервные копии",
@@ -688,6 +689,35 @@ const ru: Record<string, string> = {
"txt_last_name": "Фамилия",
"txt_last_seen": "Последний визит",
"txt_license_number": "Номер лицензии",
"txt_bank_account": "Банковский счет",
"txt_bank_account_details": "Данные банковского счета",
"txt_bank_name": "Название банка",
"txt_name_on_account": "Имя владельца счета",
"txt_account_type": "Тип счета",
"txt_account_number": "Номер счета",
"txt_routing_number": "Маршрутный номер",
"txt_branch_number": "Номер отделения",
"txt_pin": "PIN",
"txt_swift_code": "SWIFT-код",
"txt_iban": "IBAN",
"txt_bank_contact_phone": "Телефон банка",
"txt_drivers_license": "Водительское удостоверение",
"txt_drivers_license_details": "Данные водительского удостоверения",
"txt_date_of_birth": "Дата рождения",
"txt_issuing_country": "Страна выдачи",
"txt_issuing_state": "Регион выдачи",
"txt_issue_date": "Дата выдачи",
"txt_issuing_authority": "Орган выдачи",
"txt_license_class": "Категория",
"txt_passport": "Паспорт",
"txt_passport_details": "Данные паспорта",
"txt_surname": "Фамилия",
"txt_given_name": "Имя",
"txt_sex": "Пол",
"txt_birth_place": "Место рождения",
"txt_nationality": "Гражданство",
"txt_passport_type": "Тип паспорта",
"txt_national_id_number": "Национальный ID",
"txt_link_copied": "Ссылка скопирована",
"txt_linked": "Связано",
"txt_linux_desktop": "Рабочий стол Linux",
@@ -983,6 +1013,9 @@ const ru: Record<string, string> = {
"txt_online": "Онлайн",
"txt_offline": "Офлайн",
"txt_offline_vault_readonly": "Автономный режим доступен только для чтения. Подключитесь к NodeWarden, чтобы изменить хранилище.",
"txt_offline_mode_notice_title": "Вы в автономном режиме. Если это ошибка, выполните принудительное обновление.",
"txt_offline_mode_notice_windows": "Windows:",
"txt_offline_mode_notice_macos": "macOS:",
"txt_submit": "Отправить",
"txt_sync": "Синхронизировать",
"txt_sync_vault": "Синхронизировать хранилище",
@@ -1014,6 +1047,8 @@ const ru: Record<string, string> = {
"txt_totp_qr_scanned": "Значение TOTP добавлено.",
"txt_totp_qr_not_found": "QR-код на этом изображении не найден.",
"txt_totp_qr_scan_failed": "Не удалось отсканировать QR-код.",
"txt_totp_qr_invalid_image_type": "Выберите файл изображения.",
"txt_totp_qr_image_too_large": "Выберите изображение меньше 8 МБ.",
"txt_totp_qr_unsupported": "Этот браузер не поддерживает сканирование QR. Попробуйте Chrome или Edge либо вставьте ссылку или секрет TOTP вручную.",
"txt_totp_qr_camera_unavailable": "Камера недоступна. Проверьте разрешение браузера или выберите изображение.",
"txt_totp_qr_choose_image": "Выбрать изображение",
@@ -1125,6 +1160,12 @@ const ru: Record<string, string> = {
"txt_import_invalid_password_protected_file": "Неверный файл экспорта, защищенный паролем.",
"txt_import_decrypt_failed": "Не удалось расшифровать файл импорта.",
"txt_import_empty_zip_archive": "Пустой zip-архив.",
"txt_import_zip_too_large": "ZIP-архив слишком большой. Максимальный размер: {size} MiB.",
"txt_import_file_too_large": "Файл импорта слишком большой. Максимальный размер: {size} MiB.",
"txt_import_zip_too_many_files": "ZIP-архив содержит слишком много файлов.",
"txt_import_zip_entry_too_large": "ZIP-архив содержит файл больше {size} MiB.",
"txt_import_zip_expands_too_large": "ZIP-архив распаковывается за текущий лимит импорта {size} MiB.",
"txt_import_zip_unsafe_file_name": "ZIP-архив содержит небезопасное имя файла.",
"txt_import_no_json_found_in_zip": "В zip-архиве не найдены импортируемые данные JSON.",
"txt_import_data_json_not_found": "data.json не найден в zip-архиве.",
"txt_import_zip_password_required": "Требуется пароль ZIP.",
@@ -1216,133 +1257,144 @@ const ru: Record<string, string> = {
"txt_log_level_info": "Инфо",
"txt_log_level_security": "Безопасность",
"txt_log_level_warn": "Предупреждение",
"txt_log_action_account_api_key_create": "Create API key",
"txt_log_action_account_api_key_rotate": "Rotate API key",
"txt_log_action_account_keys_update": "Update account keys",
"txt_log_action_account_profile_update": "Update account profile",
"txt_log_action_account_totp_disable": "Disable two-step login",
"txt_log_action_account_totp_enable": "Enable two-step login",
"txt_log_action_account_totp_recover": "Recover two-step login",
"txt_log_action_account_verify_devices_update": "Update device verification",
"txt_log_action_admin_audit_settings_update": "Update log retention settings",
"txt_log_action_admin_backup_export": "Export backup",
"txt_log_action_admin_backup_import": "Import backup",
"txt_log_action_admin_backup_remote_delete": "Delete remote backup",
"txt_log_action_admin_backup_remote_manual": "Manual remote backup succeeded",
"txt_log_action_admin_backup_remote_manual_failed": "Manual remote backup failed",
"txt_log_action_admin_backup_remote_scheduled": "Scheduled remote backup succeeded",
"txt_log_action_admin_backup_remote_scheduled_failed": "Scheduled remote backup failed",
"txt_log_action_admin_backup_settings_repair": "Repair backup settings",
"txt_log_action_admin_backup_settings_update": "Update backup settings",
"txt_log_action_admin_invite_create": "Create invite",
"txt_log_action_admin_invite_delete": "Delete invite",
"txt_log_action_admin_invite_delete_all": "Clear invites",
"txt_log_action_admin_invite_delete_invalid": "Delete invalid invites",
"txt_log_action_admin_invite_revoke": "Revoke invite",
"txt_log_action_admin_user_delete": "Delete user",
"txt_log_action_admin_user_status": "Change user status",
"txt_log_action_attachment_delete": "Delete attachment",
"txt_log_action_auth_login_failed_bad_api_key": "Login failed: bad API key",
"txt_log_action_auth_login_failed_bad_password": "Login failed: bad password",
"txt_log_action_auth_login_failed_user_inactive": "Login failed: inactive account",
"txt_log_action_auth_login_success": "Login succeeded",
"txt_log_action_auth_refresh_failed": "Refresh login failed: {reason}",
"txt_log_action_cipher_delete_permanent": "Permanently delete vault item",
"txt_log_action_cipher_delete_permanent_bulk": "Permanently delete vault items",
"txt_log_action_cipher_delete_soft": "Move vault item to trash",
"txt_log_action_cipher_delete_soft_bulk": "Move vault items to trash",
"txt_log_action_device_deactivate": "Deactivate device",
"txt_log_action_device_delete": "Delete device",
"txt_log_action_device_delete_all": "Delete all devices",
"txt_log_action_device_name_update": "Update device name",
"txt_log_action_device_trust_permanent": "Trust device permanently",
"txt_log_action_device_trust_revoke": "Revoke device trust",
"txt_log_action_device_trust_revoke_batch": "Revoke device trust in bulk",
"txt_log_action_folder_delete": "Delete folder",
"txt_log_action_folder_delete_bulk": "Delete folders",
"txt_log_action_send_auth_remove": "Remove Send authentication",
"txt_log_action_send_delete": "Delete Send",
"txt_log_action_send_delete_bulk": "Delete Sends",
"txt_log_action_send_password_remove": "Remove Send password",
"txt_log_action_user_password_change": "Change master password",
"txt_log_action_user_register_first_admin": "Register first admin",
"txt_log_action_user_register_invite": "Register by invite",
"txt_log_meta_attachments": "Attachments",
"txt_log_action_account_api_key_create": "Создание API-ключа",
"txt_log_action_account_api_key_rotate": "Ротация API-ключа",
"txt_log_action_account_keys_update": "Обновление ключей учетной записи",
"txt_log_action_account_passkey_create": "Создание ключа входа",
"txt_log_action_account_passkey_delete": "Удаление ключа входа",
"txt_log_action_account_passkey_encryption_enable": "Включение разблокировки хранилища ключом доступа",
"txt_log_action_account_profile_update": "Обновление профиля учетной записи",
"txt_log_action_account_totp_disable": "Отключение двухфакторной проверки",
"txt_log_action_account_totp_enable": "Включение двухфакторной проверки",
"txt_log_action_account_totp_recover": "Восстановление двухфакторной проверки",
"txt_log_action_account_verify_devices_update": "Обновление проверки устройств",
"txt_log_action_account_webauthn_2fa_delete": "Удаление ключа двухфакторной проверки",
"txt_log_action_account_webauthn_2fa_enable": "Включение двухфакторной проверки ключом доступа",
"txt_log_action_account_yubikey_enable": "Обновление настроек YubiKey OTP",
"txt_log_action_admin_audit_clear": "Очистка журнала аудита",
"txt_log_action_admin_audit_settings_update": "Обновление хранения журналов",
"txt_log_action_admin_backup_export": "Экспорт резервной копии",
"txt_log_action_admin_backup_import": "Импорт резервной копии",
"txt_log_action_admin_backup_remote_delete": "Удаление удаленной резервной копии",
"txt_log_action_admin_backup_remote_manual": "Ручное удаленное резервное копирование выполнено",
"txt_log_action_admin_backup_remote_manual_failed": "Ошибка ручного удаленного резервного копирования",
"txt_log_action_admin_backup_remote_scheduled": "Запланированное удаленное резервное копирование выполнено",
"txt_log_action_admin_backup_remote_scheduled_failed": "Ошибка запланированного удаленного резервного копирования",
"txt_log_action_admin_backup_settings_repair": "Восстановление настроек резервного копирования",
"txt_log_action_admin_backup_settings_update": "Обновление настроек резервного копирования",
"txt_log_action_admin_invite_create": "Создание приглашения",
"txt_log_action_admin_invite_delete": "Удаление приглашения",
"txt_log_action_admin_invite_delete_all": "Очистка приглашений",
"txt_log_action_admin_invite_delete_invalid": "Удаление недействительных приглашений",
"txt_log_action_admin_invite_revoke": "Отзыв приглашения",
"txt_log_action_admin_user_delete": "Удаление пользователя",
"txt_log_action_admin_user_status": "Изменение статуса пользователя",
"txt_log_action_attachment_delete": "Удаление вложения",
"txt_log_action_auth_login_failed_bad_api_key": "Ошибка входа: неверный API-ключ",
"txt_log_action_auth_login_failed_bad_password": "Ошибка входа: неверный пароль",
"txt_log_action_auth_login_failed_user_inactive": "Ошибка входа: учетная запись неактивна",
"txt_log_action_auth_login_success": "Вход выполнен",
"txt_log_action_auth_passkey_login_failed": "Ошибка входа по ключу доступа",
"txt_log_action_auth_passkey_login_success": "Вход по ключу доступа выполнен",
"txt_log_action_auth_refresh_failed": "Не удалось обновить вход: {reason}",
"txt_log_action_cipher_delete_permanent": "Окончательное удаление элемента хранилища",
"txt_log_action_cipher_delete_permanent_bulk": "Окончательное удаление элементов хранилища",
"txt_log_action_cipher_delete_soft": "Перемещение элемента хранилища в корзину",
"txt_log_action_cipher_delete_soft_bulk": "Перемещение элементов хранилища в корзину",
"txt_log_action_device_deactivate": "Деактивация устройства",
"txt_log_action_device_delete": "Удаление устройства",
"txt_log_action_device_delete_all": "Удаление всех устройств",
"txt_log_action_device_name_update": "Обновление имени устройства",
"txt_log_action_device_trust_permanent": "Постоянное доверие устройству",
"txt_log_action_device_trust_revoke": "Отзыв доверия устройству",
"txt_log_action_device_trust_revoke_batch": "Массовый отзыв доверия устройствам",
"txt_log_action_folder_delete": "Удаление папки",
"txt_log_action_folder_delete_bulk": "Удаление папок",
"txt_log_action_send_auth_remove": "Удаление проверки Send",
"txt_log_action_send_delete": "Удаление Send",
"txt_log_action_send_delete_bulk": "Удаление Send",
"txt_log_action_send_password_remove": "Удаление пароля Send",
"txt_log_action_user_password_change": "Изменение мастер-пароля",
"txt_log_action_user_register_first_admin": "Регистрация первого администратора",
"txt_log_action_user_register_invite": "Регистрация по приглашению",
"txt_log_meta_attachments": "Вложения",
"txt_log_meta_bytes": "Bytes",
"txt_log_meta_changed": "Changed fields",
"txt_log_meta_checksum_mismatch_accepted": "Accepted checksum mismatch",
"txt_log_meta_cipher_id": "Vault item ID",
"txt_log_meta_ciphers": "Vault items",
"txt_log_meta_compat": "Compatibility",
"txt_log_meta_compressed_bytes": "Compressed bytes",
"txt_log_meta_count": "Count",
"txt_log_meta_deleted": "Deleted count",
"txt_log_meta_destination_count": "Destination count",
"txt_log_meta_destination_id": "Destination ID",
"txt_log_meta_destination_name": "Destination name",
"txt_log_meta_destination_type": "Destination type",
"txt_log_meta_device_identifier": "Device ID",
"txt_log_meta_device_type": "Device type",
"txt_log_meta_changed": "Измененные поля",
"txt_log_meta_checksum_mismatch_accepted": "Принято несовпадение контрольной суммы",
"txt_log_meta_cipher_id": "ID элемента хранилища",
"txt_log_meta_ciphers": "Элементы хранилища",
"txt_log_meta_compat": "Совместимость",
"txt_log_meta_compressed_bytes": "Байт после сжатия",
"txt_log_meta_count": "Количество",
"txt_log_meta_deleted": "Удалено",
"txt_log_meta_destination_count": "Количество назначений",
"txt_log_meta_destination_id": "ID назначения",
"txt_log_meta_destination_name": "Имя назначения",
"txt_log_meta_destination_type": "Тип назначения",
"txt_log_meta_device_identifier": "ID устройства",
"txt_log_meta_device_type": "Тип устройства",
"txt_log_meta_email": "Email",
"txt_log_meta_error": "Error",
"txt_log_meta_expires_in_hours": "Expires in hours",
"txt_log_meta_file_bytes": "File bytes",
"txt_log_meta_file_name": "File name",
"txt_log_meta_folder_id": "Folder ID",
"txt_log_meta_grant_type": "Login method",
"txt_log_meta_includes_attachments": "Includes attachments",
"txt_log_meta_ip": "IP address",
"txt_log_meta_max_entries": "Entry limit",
"txt_log_meta_method": "Request method",
"txt_log_meta_path": "Request path",
"txt_log_meta_provider": "Provider",
"txt_log_meta_prune_error": "Cleanup error",
"txt_log_meta_pruned_file_count": "Cleaned files",
"txt_log_meta_raw": "Raw data",
"txt_log_meta_reason": "Reason",
"txt_log_meta_remote_path": "Remote path",
"txt_log_meta_removed": "Removed count",
"txt_log_meta_removed_devices": "Removed devices",
"txt_log_meta_removed_sessions": "Removed sessions",
"txt_log_meta_removed_trusted": "Trust removals",
"txt_log_meta_replace_existing": "Replace existing data",
"txt_log_meta_requested": "Requested count",
"txt_log_meta_requested_count": "Requested count",
"txt_log_meta_retention_days": "Retention days",
"txt_log_meta_scheduled_destination_count": "Scheduled destinations",
"txt_log_meta_size": "Size",
"txt_log_meta_skipped_attachments": "Skipped attachments",
"txt_log_meta_skipped_reason": "Skip reason",
"txt_log_meta_status": "Status",
"txt_log_meta_target_email": "Target email",
"txt_log_meta_trigger": "Trigger",
"txt_log_meta_type": "Type",
"txt_log_meta_updated": "Updated count",
"txt_log_meta_upload_verification_attempts": "Upload verification attempts",
"txt_log_meta_user_agent": "Browser/client",
"txt_log_meta_users": "Users",
"txt_log_meta_verify_devices": "Verify devices",
"txt_log_meta_web_session": "Web session",
"txt_log_reason_bad_api_key": "Bad API key",
"txt_log_reason_bad_password": "Bad password",
"txt_log_reason_device_missing": "Device missing",
"txt_log_reason_device_session_mismatch": "Device session mismatch",
"txt_log_reason_token_not_found_or_expired": "Token missing or expired",
"txt_log_reason_user_inactive": "User inactive",
"txt_log_reason_user_missing": "User missing",
"txt_log_target_type_attachment": "Attachment",
"txt_log_target_type_audit_log": "Log",
"txt_log_target_type_backup": "Backup",
"txt_log_target_type_cipher": "Vault item",
"txt_log_target_type_device": "Device",
"txt_log_target_type_folder": "Folder",
"txt_log_target_type_invite": "Invite",
"txt_log_target_type_refresh_token": "Refresh token",
"txt_log_meta_error": "Ошибка",
"txt_log_meta_expires_in_hours": "Истекает через часов",
"txt_log_meta_file_bytes": "Байт файла",
"txt_log_meta_file_name": "Имя файла",
"txt_log_meta_folder_id": "ID папки",
"txt_log_meta_grant_type": "Способ входа",
"txt_log_meta_includes_attachments": "Включает вложения",
"txt_log_meta_ip": "IP-адрес",
"txt_log_meta_max_entries": "Лимит записей",
"txt_log_meta_method": "Метод запроса",
"txt_log_meta_path": "Путь запроса",
"txt_log_meta_provider": "Поставщик",
"txt_log_meta_prf_status": "Статус PRF",
"txt_log_meta_prune_error": "Ошибка очистки",
"txt_log_meta_pruned_file_count": "Очищено файлов",
"txt_log_meta_raw": "Исходные данные",
"txt_log_meta_reason": "Причина",
"txt_log_meta_remote_path": "Удаленный путь",
"txt_log_meta_removed": "Удалено",
"txt_log_meta_removed_devices": "Удалено устройств",
"txt_log_meta_removed_sessions": "Удалено сессий",
"txt_log_meta_removed_trusted": "Отозвано доверий",
"txt_log_meta_replace_existing": "Заменить существующие данные",
"txt_log_meta_requested": "Запрошено",
"txt_log_meta_requested_count": "Запрошено",
"txt_log_meta_retention_days": "Дней хранения",
"txt_log_meta_scheduled_destination_count": "Запланированные назначения",
"txt_log_meta_size": "Размер",
"txt_log_meta_skipped_attachments": "Пропущенные вложения",
"txt_log_meta_skipped_reason": "Причина пропуска",
"txt_log_meta_status": "Статус",
"txt_log_meta_target_email": "Email цели",
"txt_log_meta_trigger": "Триггер",
"txt_log_meta_type": "Тип",
"txt_log_meta_updated": "Обновлено",
"txt_log_meta_upload_verification_attempts": "Попытки проверки загрузки",
"txt_log_meta_user_agent": "Браузер/клиент",
"txt_log_meta_users": "Пользователи",
"txt_log_meta_verify_devices": "Проверка устройств",
"txt_log_meta_web_session": "Веб-сессия",
"txt_log_reason_bad_api_key": "Неверный API-ключ",
"txt_log_reason_bad_password": "Неверный пароль",
"txt_log_reason_device_missing": "Устройство не найдено",
"txt_log_reason_device_session_mismatch": "Сессия не соответствует устройству",
"txt_log_reason_token_not_found_or_expired": "Токен отсутствует или истек",
"txt_log_reason_user_inactive": "Пользователь неактивен",
"txt_log_reason_user_missing": "Пользователь не найден",
"txt_log_target_type_attachment": "Вложение",
"txt_log_target_type_account_passkey": "Ключ входа",
"txt_log_target_type_audit_log": "Журнал",
"txt_log_target_type_backup": "Резервная копия",
"txt_log_target_type_cipher": "Элемент хранилища",
"txt_log_target_type_device": "Устройство",
"txt_log_target_type_folder": "Папка",
"txt_log_target_type_invite": "Приглашение",
"txt_log_target_type_refresh_token": "Токен обновления",
"txt_log_target_type_send": "Send",
"txt_log_target_type_user": "User",
"txt_log_trigger_manual": "Manual",
"txt_log_trigger_remote": "Remote",
"txt_log_trigger_scheduled": "Scheduled",
"txt_log_target_type_user": "Пользователь",
"txt_log_trigger_manual": "Вручную",
"txt_log_trigger_remote": "Удаленно",
"txt_log_trigger_scheduled": "По расписанию",
"txt_log_max_1000": "До 1 000 записей",
"txt_log_max_5000": "До 5 000 записей",
"txt_log_max_10000": "До 10 000 записей",
@@ -1396,4 +1448,12 @@ const ru: Record<string, string> = {
"txt_auth_request_missing_public_key": "В запросе входа с устройства отсутствует открытый ключ"
};
Object.assign(ru, {
"nav_password_security": "Безопасность паролей", "txt_password_security": "Проверка безопасности паролей", "txt_password_security_privacy": "Пароли проверяются локально. После запуска в базу утечек передаётся только анонимный префикс хеша.", "txt_check_password_security": "Начать проверку", "txt_checking_password_security": "Проверка", "txt_recheck_password_security": "Проверить снова", "txt_password_security_ready": "Ваше хранилище готово к проверке безопасности.", "txt_password_security_no_login": "Нет паролей для входа, доступных для проверки.", "txt_password_security_manual": "Проверка запускается только по вашему выбору. Результаты остаются только на этой странице.", "txt_password_security_no_login_help": "Добавьте запись входа с паролем и вернитесь сюда для проверки.", "txt_exposed_passwords": "Скомпрометированы", "txt_reused_passwords": "Повторно используются", "txt_weak_passwords": "Слабые", "txt_passwords_checked": "Проверено", "txt_password_security_unavailable": "{count} проверок не смогли обратиться к базе утечек. Они не помечены безопасными.", "txt_password_security_not_checked": "Не проверено", "txt_password_exposed_count": "Найдено в {count} утечках", "txt_password_reused_count": "Используется {count} раз", "txt_weak_password": "Слабый пароль", "txt_no_password_risks": "Рисков паролей не найдено", "txt_open_vault": "Открыть хранилище", "txt_check_password_breach": "Проверить утечку", "txt_password_not_found_in_breaches": "Не найден в базе утечек", "txt_password_security_check_failed": "Не удалось завершить проверку утечки."
});
Object.assign(ru, { "txt_password_security_last_checked": "Последняя проверка: {value}" });
Object.assign(ru, { "txt_no_password_risks_in_filter": "В этой категории нет рисков для паролей" });
Object.assign(ru, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
export default ru;
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More