mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 14:50:11 +00:00
Compare commits
63
Commits
94b5f3e975
...
v1.7.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b093c01fd7 | ||
|
|
fa611dc843 | ||
|
|
3c581d1fb1 | ||
|
|
fb376797d2 | ||
|
|
99b50275a6 | ||
|
|
dfc98008cb | ||
|
|
b472121f43 | ||
|
|
9caa064488 | ||
|
|
aae614a079 | ||
|
|
0e46cd371f | ||
|
|
db31792cef | ||
|
|
8c65cb2e80 | ||
|
|
14dff8ee6a | ||
|
|
8d399f431b | ||
|
|
bb3f866220 | ||
|
|
39d9df78ea | ||
|
|
a1b12fc447 | ||
|
|
099217062a | ||
|
|
dd90d7b8b8 | ||
|
|
525b773cf4 | ||
|
|
04cb475935 | ||
|
|
e063f45cd9 | ||
|
|
e10920d142 | ||
|
|
b07edb0850 | ||
|
|
58a86ae8fd | ||
|
|
b986af86dc | ||
|
|
8e33f92b33 | ||
|
|
a0f832e8a5 | ||
|
|
8a5b210a1d | ||
|
|
ebc8e8e340 | ||
|
|
a870142b7b | ||
|
|
a366acbac0 | ||
|
|
57c5ef9da6 | ||
|
|
f532d3ace3 | ||
|
|
cc4a830be8 | ||
|
|
c6438747e3 | ||
|
|
5c8f01be59 | ||
|
|
7ac6ae50bb | ||
|
|
ace00e8e74 | ||
|
|
51428461a8 | ||
|
|
23c53bd1af | ||
|
|
ae168bea31 | ||
|
|
00e0ec0892 | ||
|
|
2df43ccdb0 | ||
|
|
fd46dffc34 | ||
|
|
56b301f2d1 | ||
|
|
f0e523376c | ||
|
|
8b2f98b847 | ||
|
|
cde4555add | ||
|
|
1bad32fd90 | ||
|
|
e376a840c2 | ||
|
|
9de0d3bd87 | ||
|
|
109593da90 | ||
|
|
01ff627ac6 | ||
|
|
d028b194e7 | ||
|
|
c53d71fc28 | ||
|
|
6e722205b1 | ||
|
|
cf14704d99 | ||
|
|
0cef6a04e9 | ||
|
|
8c481a1564 | ||
|
|
d9a36fefe6 | ||
|
|
12af18e3a3 | ||
|
|
d8cc88d9c0 |
@@ -0,0 +1,2 @@
|
|||||||
|
custom:
|
||||||
|
- https://nodewarden.app/sponsor
|
||||||
+1
-1
@@ -43,7 +43,7 @@ tmp/
|
|||||||
.tmp/
|
.tmp/
|
||||||
.tmp-bitwarden-clients/
|
.tmp-bitwarden-clients/
|
||||||
|
|
||||||
nodewarden.wiki/
|
nodewarden-wiki/
|
||||||
wiki/
|
wiki/
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
settings.json
|
settings.json
|
||||||
|
|||||||
@@ -3,95 +3,100 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
|
Bitwarden-compatible server running on Cloudflare Workers
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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="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="./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/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>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
|
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
|
||||||
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
|
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="./README_EN.md">English</a> |
|
<a href="./README_ZH.md">中文</a> |
|
||||||
<a href="./CONTRIBUTING.md">贡献指南</a>
|
<a href="./CONTRIBUTING.md">Contributing</a> |
|
||||||
|
<a href="https://nodewarden.app">Official wiki</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> **免责声明**
|
> **Disclaimer**
|
||||||
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
> This project is for learning and discussion purposes only. Please back up your vault regularly.
|
||||||
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
|
> 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界面** |
|
| Web vault | ✅ | ✅ | **Original Web Vault UI** |
|
||||||
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** |
|
| TOTP | ❌ | ✅ | Includes `steam://` support |
|
||||||
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** |
|
| **PWA / offline** | ❌ | ✅ | **Installable, offline** |
|
||||||
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
|
| **Passkey login** | ✅ | ✅ | **passwordless auth** |
|
||||||
| 实时同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
|
| API keys | ✅ | ✅ | CLI keys; create and rotate |
|
||||||
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
| Login 2FA | ✅ | ✅ | TOTP, YubiKey, Passkey |
|
||||||
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
| 2FA recovery codes | ✅ | ✅ | One-time 2FA disable codes |
|
||||||
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
| Real-time push sync | ✅ | ✅ | All device sync |
|
||||||
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份(OneDrive/Google Drive等)** |
|
| Attachments / Send | ✅ | ✅ | Cloudflare R2 or KV |
|
||||||
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
|
| Import / export | ✅ | ✅ | Bitwarden JSON / CSV / **ZIP** |
|
||||||
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
|
| **Cloud backup center** | ❌ | ✅ | **Scheduled WebDAV / S3 incrementals** |
|
||||||
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
|
| Device management | ✅ | ✅ | **Remove devices; trust controls** |
|
||||||
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
| Login requests | ✅ | ✅ | **Cross-device login approval/unlock** |
|
||||||
| 登录 2FA | ✅ | ⚠️ 部分支持 | 支持TOTP和Passkey(作为第二因素) |
|
| **Multi-user** | ✅ | ✅ | Invite-code registration |
|
||||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
|
| Domain rules | ✅ | ✅ | Equivalent domains, global exclusions |
|
||||||
|
| Fill-assist | ✅ | ✅ | `POST /fill-assist`|
|
||||||
|
| Organizations / collections / roles | ✅ | ❌ | Not implemented |
|
||||||
|
| SSO / SCIM / directory | ✅ | ❌ | Not implemented |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 已测试客户端
|
## Tested clients
|
||||||
|
|
||||||
- ✅ Windows 桌面端
|
- ✅ Windows desktop
|
||||||
- ✅ 手机 App
|
- ✅ Mobile app
|
||||||
- ✅ 浏览器扩展
|
- ✅ Browser extension
|
||||||
- ✅ Linux 桌面端
|
- ✅ Linux desktop
|
||||||
- ⚠️ macOS 桌面端尚未完整验证
|
- ⚠️ macOS desktop not fully verified yet
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 可视化快速部署
|
## Visual quick deploy
|
||||||
|
|
||||||
1. Fork NodeWarden 仓库到自己的 GitHub 账号
|
1. Fork the NodeWarden repository to your GitHub account
|
||||||
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
2. Open [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
|
||||||
3. 选择 Continue with GitHub 并选择你的仓库
|
3. Choose **Continue with GitHub** and select your fork
|
||||||
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
|
4. Set **build command** to `npm run build` and **deploy command** to `npm run deploy`
|
||||||
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
|
- For KV mode, change the deploy command to `npm run deploy:kv`
|
||||||
5. 等部署完成后,打开生成的 Workers 域名
|
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]
|
> [!TIP]
|
||||||
> 默认R2与可选KV的区别:
|
> Default R2 vs optional KV:
|
||||||
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
> | Storage | Card required | Max single attachment / Send file | Free tier |
|
||||||
> |---|---|---|---|
|
> |---|---|---|---|
|
||||||
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
> | R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
|
||||||
> | KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
> | KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
||||||
|
|
||||||
|
|
||||||
## 更新方法:
|
## How to update
|
||||||
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
|
||||||
- 自动:进入你的 Fork 仓库 ➜ `Actions` ➜ `Sync upstream` ➜ `Enable workflow`,会在每天凌晨 3 点自动同步上游。
|
- Manual: open your fork on GitHub; when the sync banner appears, click **Sync fork** → **Update branch**
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## CLI 部署
|
|
||||||
|
## CLI deploy
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||||
@@ -100,85 +105,46 @@ cd NodeWarden
|
|||||||
npm install
|
npm install
|
||||||
npx wrangler login
|
npx wrangler login
|
||||||
|
|
||||||
# 默认:R2 模式
|
# Default: R2 mode
|
||||||
npm run deploy
|
npm run deploy
|
||||||
|
|
||||||
# 可选:KV 模式
|
# Optional: KV mode
|
||||||
npm run deploy:kv
|
npm run deploy:kv
|
||||||
|
|
||||||
# 本地开发
|
# Local development
|
||||||
npm run dev
|
npm run dev
|
||||||
npm run dev:kv
|
npm run dev:kv
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 主要特性
|
|
||||||
|
|
||||||
### PWA 渐进式 Web 应用
|
## License
|
||||||
|
|
||||||
- ✅ **可安装到桌面** - 像原生应用一样运行
|
|
||||||
- ✅ **离线使用** - 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 系列
|
|
||||||
- 备份中心中的实例级完整手动导出
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
## 开源协议
|
|
||||||
|
|
||||||
LGPL-3.0 License
|
LGPL-3.0 License
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 致谢
|
## Credits
|
||||||
|
|
||||||
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
|
- [Bitwarden](https://bitwarden.com/) - Original design and clients
|
||||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
|
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
|
||||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
- [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
|
||||||
|
|
||||||
[](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
@@ -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
|
|
||||||
|
|
||||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
|
||||||
+149
@@ -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 MiB(Cloudflare限制) | 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>
|
||||||
@@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
security_stamp TEXT NOT NULL,
|
security_stamp TEXT NOT NULL,
|
||||||
role TEXT NOT NULL DEFAULT 'user',
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
verify_devices INTEGER NOT NULL DEFAULT 1,
|
verify_devices INTEGER NOT NULL DEFAULT 0,
|
||||||
totp_secret TEXT,
|
totp_secret TEXT,
|
||||||
totp_recovery_code TEXT,
|
totp_recovery_code TEXT,
|
||||||
api_key TEXT,
|
api_key TEXT,
|
||||||
|
|||||||
Generated
+672
-412
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.7.2",
|
"version": "1.7.4",
|
||||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||||
"author": "shuaiplus",
|
"author": "shuaiplus",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"tsx": "^4.22.4",
|
"tsx": "^4.22.4",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^8.1.3",
|
||||||
"wrangler": "^4.105.0"
|
"wrangler": "^4.105.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ const localeFiles = [
|
|||||||
['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'],
|
['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'],
|
||||||
['ru', 'ru.ts', 'ru', 'Russian'],
|
['ru', 'ru.ts', 'ru', 'Russian'],
|
||||||
['es', 'es.ts', 'es', 'Spanish'],
|
['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) {
|
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 @@
|
|||||||
export const APP_VERSION = '1.7.2';
|
export const APP_VERSION = '1.7.4';
|
||||||
|
|||||||
@@ -62,6 +62,9 @@
|
|||||||
// Refresh-token grant budget per IP per minute.
|
// Refresh-token grant budget per IP per minute.
|
||||||
// refresh_token 授权每 IP 每分钟请求配额。
|
// refresh_token 授权每 IP 每分钟请求配额。
|
||||||
refreshTokenRequestsPerMinute: 30,
|
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.
|
// Fixed window size for API rate limiting in seconds.
|
||||||
// API 限流固定窗口大小(秒)。
|
// API 限流固定窗口大小(秒)。
|
||||||
apiWindowSeconds: 60,
|
apiWindowSeconds: 60,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
executeConfiguredBackup,
|
executeConfiguredBackup,
|
||||||
importAndAuditRemoteBackupFile,
|
importAndAuditRemoteBackupFile,
|
||||||
} from '../handlers/backup';
|
} from '../handlers/backup';
|
||||||
import { verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
|
import { isSafeBackupAttachmentBlobName, verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
|
||||||
import { zipSync } from 'fflate';
|
import { zipSync } from 'fflate';
|
||||||
|
|
||||||
const BACKUP_JOB_STATE_KEY = 'backup.job.state.v1';
|
const BACKUP_JOB_STATE_KEY = 'backup.job.state.v1';
|
||||||
@@ -372,7 +372,7 @@ export class BackupTransferRunner {
|
|||||||
return badRequest('Remote attachment download payload is invalid');
|
return badRequest('Remote attachment download payload is invalid');
|
||||||
}
|
}
|
||||||
const blobName = String(body?.blobName || '').trim();
|
const blobName = String(body?.blobName || '').trim();
|
||||||
if (!body?.destination || !blobName) {
|
if (!body?.destination || !isSafeBackupAttachmentBlobName(blobName)) {
|
||||||
return badRequest('Remote attachment download payload is invalid');
|
return badRequest('Remote attachment download payload is invalid');
|
||||||
}
|
}
|
||||||
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
|
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
|
||||||
@@ -398,7 +398,7 @@ export class BackupTransferRunner {
|
|||||||
const blobNames = Array.from(new Set(
|
const blobNames = Array.from(new Set(
|
||||||
(Array.isArray(body?.blobNames) ? body.blobNames : [])
|
(Array.isArray(body?.blobNames) ? body.blobNames : [])
|
||||||
.map((blobName) => String(blobName || '').trim())
|
.map((blobName) => String(blobName || '').trim())
|
||||||
.filter(Boolean)
|
.filter(isSafeBackupAttachmentBlobName)
|
||||||
));
|
));
|
||||||
if (!body?.destination || !blobNames.length || blobNames.length > 40) {
|
if (!body?.destination || !blobNames.length || blobNames.length > 40) {
|
||||||
return badRequest('Remote attachment batch download payload is invalid');
|
return badRequest('Remote attachment batch download payload is invalid');
|
||||||
@@ -446,7 +446,7 @@ export class BackupTransferRunner {
|
|||||||
|
|
||||||
for (const attachment of body.attachments) {
|
for (const attachment of body.attachments) {
|
||||||
const blobName = String(attachment?.blobName || '').trim();
|
const blobName = String(attachment?.blobName || '').trim();
|
||||||
if (!blobName) {
|
if (!isSafeBackupAttachmentBlobName(blobName)) {
|
||||||
return badRequest('Attachment chunk payload is invalid');
|
return badRequest('Attachment chunk payload is invalid');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+88
-43
@@ -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 {
|
function validateKdfParams(kdfType: number | undefined, kdfIterations: number | undefined, kdfMemory?: number | undefined, kdfParallelism?: number | undefined): string | null {
|
||||||
const type = kdfType ?? 0;
|
const type = kdfType ?? 0;
|
||||||
|
if (type !== 0 && type !== 1) {
|
||||||
|
return 'KDF type must be PBKDF2-SHA256 or Argon2id';
|
||||||
|
}
|
||||||
if (type === 0) {
|
if (type === 0) {
|
||||||
// PBKDF2-SHA256: minimum 100 000 iterations
|
// PBKDF2-SHA256: minimum 100 000 iterations
|
||||||
if (typeof kdfIterations === 'number' && kdfIterations < 100_000) {
|
if (typeof kdfIterations === 'number' && kdfIterations < 100_000) {
|
||||||
@@ -349,7 +352,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
securityStamp: generateUUID(),
|
securityStamp: generateUUID(),
|
||||||
role: 'user',
|
role: 'user',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
verifyDevices: true,
|
verifyDevices: false, // new-device verification requires email delivery (not available)
|
||||||
totpSecret: null,
|
totpSecret: null,
|
||||||
totpRecoveryCode: null,
|
totpRecoveryCode: null,
|
||||||
yubikeyKey1: null,
|
yubikeyKey1: null,
|
||||||
@@ -448,7 +451,7 @@ export async function handleGetPasswordHint(request: Request, env: Env): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rateLimit = new RateLimitService(env.DB);
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
const minuteBudget = await rateLimit.consumeBudgetWithWindow(
|
const minuteBudget = await rateLimit.consumeStrictBudgetWithWindow(
|
||||||
`${clientIdentifier}:password-hint`,
|
`${clientIdentifier}:password-hint`,
|
||||||
LIMITS.rateLimit.passwordHintRequestsPerMinute,
|
LIMITS.rateLimit.passwordHintRequestsPerMinute,
|
||||||
60
|
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`,
|
`${clientIdentifier}:password-hint-hour`,
|
||||||
LIMITS.rateLimit.passwordHintRequestsPerHour,
|
LIMITS.rateLimit.passwordHintRequestsPerHour,
|
||||||
60 * 60
|
60 * 60
|
||||||
@@ -550,51 +553,31 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PUT/POST /api/accounts/verify-devices
|
// 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> {
|
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const auth = new AuthService(env);
|
const auth = new AuthService(env);
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) return errorResponse('User not found', 404);
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
let body: {
|
// Log the attempt for audit purposes, but do not change state.
|
||||||
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);
|
|
||||||
await writeAuditEvent(storage, {
|
await writeAuditEvent(storage, {
|
||||||
actorUserId: user.id,
|
actorUserId: user.id,
|
||||||
action: 'account.verify_devices.update',
|
action: 'account.verify_devices.update.rejected',
|
||||||
category: 'security',
|
category: 'security',
|
||||||
level: 'security',
|
level: 'info',
|
||||||
targetType: 'user',
|
targetType: 'user',
|
||||||
targetId: user.id,
|
targetId: user.id,
|
||||||
metadata: {
|
metadata: {
|
||||||
verifyDevices: user.verifyDevices,
|
reason: 'new-device verification is not supported (no email delivery channel)',
|
||||||
...auditRequestMetadata(request),
|
...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
|
// 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 nextKdfParallelism = body.kdfParallelism ?? readNestedNumber(body, ['unlockData', 'kdf', 'parallelism']);
|
||||||
const kdfErr = validateKdfParams(nextKdf, nextKdfIterations, nextKdfMemory, nextKdfParallelism);
|
const kdfErr = validateKdfParams(nextKdf, nextKdfIterations, nextKdfMemory, nextKdfParallelism);
|
||||||
if (kdfErr) return errorResponse(kdfErr, 400);
|
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);
|
user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email);
|
||||||
if (nextKey) user.key = nextKey;
|
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 nextKdfIterations === 'number') user.kdfIterations = nextKdfIterations;
|
||||||
if (typeof nextKdfMemory === 'number') user.kdfMemory = nextKdfMemory;
|
if (typeof nextKdfMemory === 'number') user.kdfMemory = nextKdfMemory;
|
||||||
if (typeof nextKdfParallelism === 'number') user.kdfParallelism = nextKdfParallelism;
|
if (typeof nextKdfParallelism === 'number') user.kdfParallelism = nextKdfParallelism;
|
||||||
if (typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null) {
|
if (shouldUpdateHint) {
|
||||||
user.masterPasswordHint = body.masterPasswordHint;
|
user.masterPasswordHint = nextMasterPasswordHint ?? null;
|
||||||
}
|
}
|
||||||
user.securityStamp = generateUUID();
|
user.securityStamp = generateUUID();
|
||||||
user.updatedAt = new Date().toISOString();
|
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>> {
|
async function yubiKeySettingsResponse(storage: StorageService, env: Env, user: User): Promise<Record<string, unknown>> {
|
||||||
const credentials = await getStoredYubicoCredentials(storage, env);
|
const credentials = await getStoredYubicoCredentials(storage, env);
|
||||||
return {
|
return {
|
||||||
@@ -885,6 +888,56 @@ export async function handleGetTwoFactorYubiKey(request: Request, env: Env, user
|
|||||||
return jsonResponse(await yubiKeySettingsResponse(storage, 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
|
// PUT/POST /api/two-factor/authenticator
|
||||||
export async function handlePutTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handlePutTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
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);
|
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']);
|
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
||||||
let verified = false;
|
const verified = await verifyUserSecret(auth, user, secret);
|
||||||
if (key && userVerificationToken) {
|
|
||||||
verified = await verifyTotpUserVerificationToken(env, user, key, userVerificationToken);
|
|
||||||
}
|
|
||||||
if (!verified) {
|
|
||||||
verified = await verifyUserSecret(auth, user, secret);
|
|
||||||
}
|
|
||||||
if (!verified) return errorResponse('User verification failed.', 400);
|
if (!verified) return errorResponse('User verification failed.', 400);
|
||||||
|
|
||||||
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
||||||
|
|||||||
+58
-18
@@ -9,6 +9,34 @@ function isAdmin(user: User): boolean {
|
|||||||
return user.role === 'admin' && user.status === 'active';
|
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 {
|
function randomHex(bytes: number): string {
|
||||||
const data = crypto.getRandomValues(new Uint8Array(bytes));
|
const data = crypto.getRandomValues(new Uint8Array(bytes));
|
||||||
return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join('');
|
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 storage = new StorageService(env.DB);
|
||||||
const users = await storage.getAllUsers();
|
const users = await storage.getAllUsers();
|
||||||
return jsonResponse({
|
const data = await Promise.all(users.map(async user => {
|
||||||
data: users.map(user => ({
|
const hasTwoFactorPasskey = await storage.countAccountPasskeyCredentialsByUserId(user.id, 'twoFactor') > 0;
|
||||||
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
status: user.status,
|
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,
|
creationDate: user.createdAt,
|
||||||
revisionDate: user.updatedAt,
|
revisionDate: user.updatedAt,
|
||||||
object: 'user',
|
object: 'user',
|
||||||
})),
|
};
|
||||||
|
}));
|
||||||
|
return jsonResponse({
|
||||||
|
data,
|
||||||
object: 'list',
|
object: 'list',
|
||||||
continuationToken: null,
|
continuationToken: null,
|
||||||
});
|
});
|
||||||
@@ -183,6 +215,9 @@ export async function handleAdminClearAuditLogs(
|
|||||||
}
|
}
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const deleted = await storage.clearAuditLogs();
|
const deleted = await storage.clearAuditLogs();
|
||||||
|
await writeAuditLog(storage, actorUser.id, 'admin.audit.clear', 'auditLog', null, {
|
||||||
|
deleted,
|
||||||
|
}, request);
|
||||||
return jsonResponse({ object: 'auditLogClear', deleted });
|
return jsonResponse({ object: 'auditLogClear', deleted });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,14 +232,11 @@ export async function handleAdminCreateInvite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
let body: { expiresInHours?: number } = {};
|
const body = await readJsonBody(request);
|
||||||
try {
|
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||||
body = await request.json();
|
if (passwordError) return passwordError;
|
||||||
} catch {
|
|
||||||
body = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
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))))
|
? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours))))
|
||||||
: 24 * 7;
|
: 24 * 7;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -259,6 +291,10 @@ export async function handleAdminDeleteInvite(
|
|||||||
return errorResponse('Forbidden', 403);
|
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 storage = new StorageService(env.DB);
|
||||||
const deleted = await storage.deleteInvite(code);
|
const deleted = await storage.deleteInvite(code);
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
@@ -281,6 +317,10 @@ export async function handleAdminDeleteAllInvites(
|
|||||||
return errorResponse('Forbidden', 403);
|
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 storage = new StorageService(env.DB);
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.searchParams.get('scope') === 'invalid') {
|
if (url.searchParams.get('scope') === 'invalid') {
|
||||||
@@ -311,12 +351,9 @@ export async function handleAdminSetUserStatus(
|
|||||||
return errorResponse('Forbidden', 403);
|
return errorResponse('Forbidden', 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: { status?: string };
|
const body = await readJsonBody(request);
|
||||||
try {
|
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
|
||||||
body = await request.json();
|
if (passwordError) return passwordError;
|
||||||
} catch {
|
|
||||||
return errorResponse('Invalid JSON', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null;
|
const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null;
|
||||||
if (!nextStatus) {
|
if (!nextStatus) {
|
||||||
@@ -359,7 +396,6 @@ export async function handleAdminDeleteUser(
|
|||||||
actorUser: User,
|
actorUser: User,
|
||||||
targetUserId: string
|
targetUserId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
void request;
|
|
||||||
if (!isAdmin(actorUser)) {
|
if (!isAdmin(actorUser)) {
|
||||||
return errorResponse('Forbidden', 403);
|
return errorResponse('Forbidden', 403);
|
||||||
}
|
}
|
||||||
@@ -367,6 +403,10 @@ export async function handleAdminDeleteUser(
|
|||||||
return errorResponse('You cannot delete yourself', 400);
|
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 storage = new StorageService(env.DB);
|
||||||
const target = await storage.getUserById(targetUserId);
|
const target = await storage.getUserById(targetUserId);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
|
|||||||
@@ -124,6 +124,10 @@ async function processAttachmentUpload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
||||||
|
if (await getBlobObject(env, path)) {
|
||||||
|
return errorResponse('Attachment file has already been uploaded', 409);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await putBlobObject(env, path, upload.body, {
|
await putBlobObject(env, path, upload.body, {
|
||||||
size: upload.size,
|
size: upload.size,
|
||||||
@@ -435,17 +439,16 @@ export async function handlePublicDownloadAttachment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const path = getAttachmentObjectKey(cipherId, attachmentId);
|
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);
|
const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp);
|
||||||
if (!firstUse) {
|
if (!firstUse) {
|
||||||
return errorResponse('Invalid or expired token', 401);
|
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, {
|
return new Response(object.body, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { readAuthRequestDeviceInfo, readActingDeviceIdentifier } from '../utils/
|
|||||||
import { errorResponse, jsonResponse } from '../utils/response';
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
|
||||||
import { notifyAuthRequestResponse, notifyUserAuthRequest } from '../durable/notifications-hub';
|
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_AUTHENTICATE_AND_UNLOCK = 0;
|
||||||
const AUTH_REQUEST_TYPE_UNLOCK = 1;
|
const AUTH_REQUEST_TYPE_UNLOCK = 1;
|
||||||
@@ -94,8 +96,8 @@ function toAuthRequestResponse(request: Request, authRequest: AuthRequestRecord,
|
|||||||
RequestCountryName: authRequest.requestCountryName,
|
RequestCountryName: authRequest.requestCountryName,
|
||||||
key: authRequest.key,
|
key: authRequest.key,
|
||||||
Key: authRequest.key,
|
Key: authRequest.key,
|
||||||
masterPasswordHash: authRequest.masterPasswordHash,
|
masterPasswordHash: null,
|
||||||
MasterPasswordHash: authRequest.masterPasswordHash,
|
MasterPasswordHash: null,
|
||||||
creationDate: authRequest.creationDate,
|
creationDate: authRequest.creationDate,
|
||||||
CreationDate: authRequest.creationDate,
|
CreationDate: authRequest.creationDate,
|
||||||
responseDate: authRequest.responseDate,
|
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 {
|
function readBodyValue(body: Record<string, any>, names: string[]): unknown {
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
if (body[name] !== undefined) return body[name];
|
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) {
|
if (!email || !publicKey || !accessCode || !deviceInfo.deviceIdentifier) {
|
||||||
return errorResponse('Email, public key, device identifier, and access code are required.', 400);
|
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) {
|
if (!isSupportedAuthRequestType(type) || type === AUTH_REQUEST_TYPE_ADMIN_APPROVAL) {
|
||||||
return errorResponse('Invalid auth request type.', 400);
|
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));
|
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> {
|
export async function handleGetAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
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 approved = Boolean(readBodyValue(body, ['requestApproved', 'RequestApproved']));
|
||||||
const key = normalizeText(readBodyValue(body, ['key', 'Key']), 20000);
|
const key = normalizeText(readBodyValue(body, ['key', 'Key']), 20000);
|
||||||
const masterPasswordHash = normalizeText(readBodyValue(body, ['masterPasswordHash', 'MasterPasswordHash']), 20000) || null;
|
|
||||||
const responseDeviceIdentifier =
|
const responseDeviceIdentifier =
|
||||||
normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128) ||
|
normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128) ||
|
||||||
readActingDeviceIdentifier(request) ||
|
readActingDeviceIdentifier(request) ||
|
||||||
@@ -272,7 +365,7 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
|
|||||||
approved,
|
approved,
|
||||||
responseDeviceIdentifier,
|
responseDeviceIdentifier,
|
||||||
key,
|
key,
|
||||||
masterPasswordHash,
|
masterPasswordHash: null,
|
||||||
});
|
});
|
||||||
if (!updated) return errorResponse('Auth request has already been answered.', 409);
|
if (!updated) return errorResponse('Auth request has already been answered.', 409);
|
||||||
const updatedRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
const updatedRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
||||||
|
|||||||
+90
-16
@@ -2,8 +2,10 @@ import type { Env, User } from '../types';
|
|||||||
import { errorResponse, jsonResponse } from '../utils/response';
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
import {
|
import {
|
||||||
type BackupArchiveBundle,
|
type BackupArchiveBundle,
|
||||||
|
MAX_BACKUP_ARCHIVE_BYTES,
|
||||||
buildBackupArchive,
|
buildBackupArchive,
|
||||||
inspectBackupArchiveFileNameChecksum,
|
inspectBackupArchiveFileNameChecksum,
|
||||||
|
isSafeBackupAttachmentBlobName,
|
||||||
parseBackupArchive,
|
parseBackupArchive,
|
||||||
verifyBackupArchiveFileNameChecksum,
|
verifyBackupArchiveFileNameChecksum,
|
||||||
} from '../services/backup-archive';
|
} from '../services/backup-archive';
|
||||||
@@ -18,6 +20,7 @@ import {
|
|||||||
loadBackupSettings,
|
loadBackupSettings,
|
||||||
normalizeBackupSettingsInput,
|
normalizeBackupSettingsInput,
|
||||||
normalizeImportedBackupSettings,
|
normalizeImportedBackupSettings,
|
||||||
|
redactBackupSettingsSecrets,
|
||||||
repairBackupSettings,
|
repairBackupSettings,
|
||||||
requireBackupDestination,
|
requireBackupDestination,
|
||||||
saveBackupSettings,
|
saveBackupSettings,
|
||||||
@@ -45,6 +48,7 @@ import { AuthService } from '../services/auth';
|
|||||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||||
import { getBlobObject } from '../services/blob-store';
|
import { getBlobObject } from '../services/blob-store';
|
||||||
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
|
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
|
||||||
|
import { getMultipartRequestMaxBytes } from '../utils/direct-upload';
|
||||||
import { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token';
|
import { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token';
|
||||||
import { unzipSync } from 'fflate';
|
import { unzipSync } from 'fflate';
|
||||||
|
|
||||||
@@ -52,6 +56,14 @@ function isAdmin(user: User): boolean {
|
|||||||
return user.role === 'admin' && user.status === 'active';
|
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> {
|
async function requireBackupUserVerification(actorUser: User, masterPasswordHash: string, env: Env): Promise<Response | null> {
|
||||||
const normalized = String(masterPasswordHash || '').trim();
|
const normalized = String(masterPasswordHash || '').trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -129,11 +141,18 @@ function ensureBackupBlobName(value: string): string {
|
|||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
throw new Error('Backup attachment blob is required');
|
throw new Error('Backup attachment blob is required');
|
||||||
}
|
}
|
||||||
const parts = normalized.split('/').filter(Boolean);
|
if (!isSafeBackupAttachmentBlobName(normalized)) {
|
||||||
if (!parts.length || parts.some((part) => part === '.' || part === '..')) {
|
|
||||||
throw new Error('Backup attachment blob is invalid');
|
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';
|
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;
|
if (parsed.files[inlinePath]) continue;
|
||||||
const ref = refs.get(`${cipherId}/${attachmentId}`);
|
const ref = refs.get(`${cipherId}/${attachmentId}`);
|
||||||
const blobName = String(ref?.blobName || '').trim();
|
const blobName = String(ref?.blobName || '').trim();
|
||||||
|
if (!isSafeBackupAttachmentBlobName(blobName)) continue;
|
||||||
if (blobName && !seen.has(blobName)) {
|
if (blobName && !seen.has(blobName)) {
|
||||||
seen.add(blobName);
|
seen.add(blobName);
|
||||||
names.push(blobName);
|
names.push(blobName);
|
||||||
@@ -666,6 +686,7 @@ function collectExternalRemoteAttachmentBlobNames(archiveBytes: Uint8Array): str
|
|||||||
function toImportStatusCode(message: string): number {
|
function toImportStatusCode(message: string): number {
|
||||||
const lower = message.toLowerCase();
|
const lower = message.toLowerCase();
|
||||||
if (lower.includes('checksum')) return 400;
|
if (lower.includes('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('invalid backup') || lower.includes('invalid json')) return 400;
|
||||||
if (lower.includes('fresh instance')) return 409;
|
if (lower.includes('fresh instance')) return 409;
|
||||||
if (lower.includes('not configured') || lower.includes('kv')) return 409;
|
if (lower.includes('not configured') || lower.includes('kv')) return 409;
|
||||||
@@ -849,7 +870,7 @@ export async function handleGetAdminBackupSettings(request: Request, env: Env, a
|
|||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
try {
|
try {
|
||||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
return jsonResponse(settings);
|
return jsonResponse(redactBackupSettingsSecrets(settings));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error instanceof Error ? error.message : 'Backup settings could not be loaded', 409);
|
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,
|
destinationCount: next.destinations.length,
|
||||||
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
||||||
}, request);
|
}, request);
|
||||||
return jsonResponse(next);
|
return jsonResponse(redactBackupSettingsSecrets(next));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleGetAdminBackupSettingsRepairState(request: Request, env: Env, actorUser: User): Promise<Response> {
|
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,
|
destinationCount: next.destinations.length,
|
||||||
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
||||||
}, request);
|
}, request);
|
||||||
return jsonResponse(next);
|
return jsonResponse(redactBackupSettingsSecrets(next));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
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,
|
provider: outcome.result.provider,
|
||||||
remotePath: outcome.result.remotePath,
|
remotePath: outcome.result.remotePath,
|
||||||
},
|
},
|
||||||
settings: outcome.settings,
|
settings: redactBackupSettingsSecrets(outcome.settings),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500);
|
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,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': remoteFile.contentType || 'application/zip',
|
'Content-Type': remoteFile.contentType || 'application/zip',
|
||||||
'Content-Disposition': `attachment; filename="${remoteFile.fileName}"`,
|
'Content-Disposition': contentDispositionBackup(remoteFile.fileName),
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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> {
|
export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
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);
|
const storage = new StorageService(env.DB);
|
||||||
try {
|
try {
|
||||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
const url = new URL(request.url);
|
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||||
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
const destination = requireBackupDestination(settings, body.destinationId || null);
|
||||||
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
|
||||||
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||||
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
||||||
return jsonResponse({
|
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> {
|
export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
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);
|
const storage = new StorageService(env.DB);
|
||||||
try {
|
try {
|
||||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
const url = new URL(request.url);
|
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||||
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
const destination = requireBackupDestination(settings, body.destinationId || null);
|
||||||
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
|
||||||
await deleteRemoteBackupFile(destination, path);
|
await deleteRemoteBackupFile(destination, path);
|
||||||
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
|
||||||
...getBackupDestinationSummary(destination),
|
...getBackupDestinationSummary(destination),
|
||||||
@@ -1196,8 +1236,9 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
|
|||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/zip',
|
'Content-Type': 'application/zip',
|
||||||
'Content-Disposition': `attachment; filename="${archive.fileName}"`,
|
'Content-Disposition': contentDispositionBackup(archive.fileName),
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1207,7 +1248,28 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
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);
|
const object = await getBlobObject(env, blobName);
|
||||||
if (!object) {
|
if (!object) {
|
||||||
return errorResponse('Backup attachment blob not found', 404);
|
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> {
|
export async function handleAdminImportBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
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;
|
let formData: FormData;
|
||||||
try {
|
try {
|
||||||
formData = await request.formData();
|
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)) {
|
if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) {
|
||||||
return errorResponse('Backup file is required', 400);
|
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);
|
const verificationError = await requireBackupUserVerification(actorUser, String(formData.get('masterPasswordHash') || ''), env);
|
||||||
if (verificationError) return verificationError;
|
if (verificationError) return verificationError;
|
||||||
|
|||||||
+90
-2
@@ -7,6 +7,9 @@ import {
|
|||||||
CipherResponse,
|
CipherResponse,
|
||||||
CipherSecureNote,
|
CipherSecureNote,
|
||||||
CipherSshKey,
|
CipherSshKey,
|
||||||
|
CipherBankAccount,
|
||||||
|
CipherDriversLicense,
|
||||||
|
CipherPassport,
|
||||||
Attachment,
|
Attachment,
|
||||||
PasswordHistory,
|
PasswordHistory,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
@@ -254,6 +257,49 @@ function sanitizeEncryptedObject<T extends Record<string, any>>(
|
|||||||
return next as T;
|
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 {
|
function normalizeCipherForStorage(cipher: Cipher): Cipher {
|
||||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||||
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
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.';
|
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.
|
// Validate password history — each password must be an encrypted string.
|
||||||
if (Array.isArray(cipher.passwordHistory)) {
|
if (Array.isArray(cipher.passwordHistory)) {
|
||||||
for (const entry of cipher.passwordHistory) {
|
for (const entry of cipher.passwordHistory) {
|
||||||
@@ -752,7 +812,20 @@ export function cipherToResponse(
|
|||||||
'licenseNumber',
|
'licenseNumber',
|
||||||
]);
|
]);
|
||||||
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
||||||
const normalizedSecureNote = Number(cipher.type) === 2
|
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 }
|
? normalizeCipherSecureNoteForCompatibility((passthrough as any).secureNote ?? null) ?? { type: 0 }
|
||||||
: null;
|
: null;
|
||||||
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
|
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
|
||||||
@@ -763,7 +836,7 @@ export function cipherToResponse(
|
|||||||
...passthrough,
|
...passthrough,
|
||||||
// Server-computed / enforced fields (always override)
|
// Server-computed / enforced fields (always override)
|
||||||
folderId: normalizeResponseFolderId(cipher.folderId, options.validFolderIds),
|
folderId: normalizeResponseFolderId(cipher.folderId, options.validFolderIds),
|
||||||
type: Number(cipher.type) || 1,
|
type: responseType,
|
||||||
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
|
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
|
||||||
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
|
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
|
||||||
creationDate: createdAt,
|
creationDate: createdAt,
|
||||||
@@ -785,6 +858,9 @@ export function cipherToResponse(
|
|||||||
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
|
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
|
||||||
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
|
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
|
||||||
sshKey: normalizedSshKey,
|
sshKey: normalizedSshKey,
|
||||||
|
bankAccount: responseType === 6 ? normalizedBankAccount : null,
|
||||||
|
driversLicense: responseType === 7 ? normalizedDriversLicense : null,
|
||||||
|
passport: responseType === 8 ? normalizedPassport : null,
|
||||||
key: responseCipherKey,
|
key: responseCipherKey,
|
||||||
data: typeof (passthrough as any).data === 'string' ? (passthrough as any).data : null,
|
data: typeof (passthrough as any).data === 'string' ? (passthrough as any).data : null,
|
||||||
encryptedFor: (passthrough as any).encryptedFor ?? 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 createIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
|
||||||
const createSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
|
const createSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
|
||||||
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
|
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']);
|
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||||
|
|
||||||
if (createKey.present && !shouldAcceptCipherKey(createKey.value)) {
|
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.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
|
||||||
cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null);
|
cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null);
|
||||||
cipher.sshKey = createSshKey.present ? (createSshKey.value ?? null) : (cipher.sshKey ?? 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);
|
cipher.passwordHistory = createPasswordHistory.present ? (createPasswordHistory.value ?? null) : (cipher.passwordHistory ?? null);
|
||||||
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
||||||
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
||||||
@@ -960,6 +1042,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
const incomingIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
|
const incomingIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
|
||||||
const incomingSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
|
const incomingSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
|
||||||
const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
|
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 incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||||
const incomingRevisionDate = readCipherRevisionDate(cipherData);
|
const incomingRevisionDate = readCipherRevisionDate(cipherData);
|
||||||
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(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.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.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.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) {
|
if (incomingPasswordHistory.present) {
|
||||||
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
|
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
+96
-2
@@ -6,7 +6,7 @@ import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'
|
|||||||
import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay';
|
import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { errorResponse, jsonResponse } from '../utils/response';
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
import { readKnownDeviceProbe } from '../utils/device';
|
import { readAuthRequestDeviceInfo, readKnownDeviceProbe } from '../utils/device';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
|
||||||
const PERMANENT_TRUST_EXPIRES_AT_MS = Date.UTC(2099, 11, 31, 23, 59, 59);
|
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);
|
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
|
// GET /api/devices/knowndevice
|
||||||
// Compatible with Bitwarden/Vaultwarden behavior:
|
// Compatible with Bitwarden/Vaultwarden behavior:
|
||||||
// - X-Request-Email: base64url(email) without padding
|
// - X-Request-Email: base64url(email) without padding
|
||||||
@@ -385,11 +464,26 @@ export async function handleUpdateDeviceName(
|
|||||||
|
|
||||||
// DELETE /api/devices
|
// DELETE /api/devices
|
||||||
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
void request;
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) return errorResponse('User not found', 404);
|
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([
|
const [removedTrusted, removedSessions, removedDevices] = await Promise.all([
|
||||||
storage.deleteTrustedTwoFactorTokensByUserId(userId),
|
storage.deleteTrustedTwoFactorTokensByUserId(userId),
|
||||||
storage.deleteRefreshTokensByUserId(userId),
|
storage.deleteRefreshTokensByUserId(userId),
|
||||||
|
|||||||
@@ -1,19 +1,46 @@
|
|||||||
const EMPTY_FORMS_FILENAME = 'forms.v1.json';
|
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({
|
const EMPTY_FORMS_BODY = JSON.stringify({
|
||||||
schemaVersion: '1.0.0',
|
schemaVersion: '1.0.0',
|
||||||
hosts: {},
|
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({
|
const EMPTY_MANIFEST_BODY = JSON.stringify({
|
||||||
|
buildId: 'nodewarden-empty-fill-assist-v1',
|
||||||
|
timestamp: '2026-07-06T00:00:00.000Z',
|
||||||
|
gitSha: 'nodewarden',
|
||||||
maps: {
|
maps: {
|
||||||
forms: {
|
forms: {
|
||||||
v1: {
|
v1: {
|
||||||
filename: EMPTY_FORMS_FILENAME,
|
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 {
|
function fillAssistJsonResponse(body: string): Response {
|
||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
status: 200,
|
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 {
|
export function handleFillAssistManifest(): Response {
|
||||||
return fillAssistJsonResponse(EMPTY_MANIFEST_BODY);
|
return fillAssistJsonResponse(EMPTY_MANIFEST_BODY);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleFillAssistForms(filename: string): Response {
|
export function handleFillAssistForms(filename: string): Response {
|
||||||
if (String(filename || '').trim() !== EMPTY_FORMS_FILENAME) {
|
const normalized = normalizeFilename(filename);
|
||||||
return new Response('Not found', { status: 404 });
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,16 @@ function readBodyValue(body: Record<string, string>, names: string[]): string |
|
|||||||
return undefined;
|
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> {
|
async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise<YubicoApiCredentials | null> {
|
||||||
const fromEnv = yubicoCredentialsFromEnv(env);
|
const fromEnv = yubicoCredentialsFromEnv(env);
|
||||||
if (fromEnv) return fromEnv;
|
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(
|
function buildPreloginResponse(
|
||||||
email: string,
|
email: string,
|
||||||
kdfType: number,
|
kdfType: number,
|
||||||
@@ -319,13 +353,13 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
const twoFactorToken = readBodyValue(body, ['twoFactorToken', 'TwoFactorToken']);
|
const twoFactorToken = readBodyValue(body, ['twoFactorToken', 'TwoFactorToken']);
|
||||||
const twoFactorProvider = readBodyValue(body, ['twoFactorProvider', 'TwoFactorProvider']);
|
const twoFactorProvider = readBodyValue(body, ['twoFactorProvider', 'TwoFactorProvider']);
|
||||||
const twoFactorRemember = readBodyValue(body, ['twoFactorRemember', 'TwoFactorRemember']);
|
const twoFactorRemember = readBodyValue(body, ['twoFactorRemember', 'TwoFactorRemember']);
|
||||||
const loginIdentifier = clientIdentifier;
|
|
||||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||||
|
|
||||||
if (!email || !passwordHash) {
|
if (!email || !passwordHash) {
|
||||||
// Bitwarden clients expect OAuth-style error fields.
|
// Bitwarden clients expect OAuth-style error fields.
|
||||||
return identityErrorResponse('Email and password are required', 'invalid_request', 400);
|
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
|
// Check login lockout before user lookup to reduce user-enumeration signal
|
||||||
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
||||||
@@ -584,7 +618,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
: baseResponse;
|
: baseResponse;
|
||||||
|
|
||||||
} else if (grantType === 'webauthn') {
|
} 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);
|
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
||||||
if (!loginCheck.allowed) {
|
if (!loginCheck.allowed) {
|
||||||
return identityErrorResponse(
|
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;
|
let deviceResponse: unknown = body.deviceResponse;
|
||||||
if (typeof deviceResponse === 'string') {
|
if (typeof deviceResponse === 'string') {
|
||||||
try {
|
try {
|
||||||
@@ -712,11 +746,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
const scope = body.scope;
|
const scope = body.scope;
|
||||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||||
|
|
||||||
const loginIdentifier = clientIdentifier;
|
|
||||||
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
|
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
|
||||||
if (!parmValid) {
|
if (!parmValid) {
|
||||||
return identityErrorResponse('Parameter error', 'invalid_request', 400);
|
return identityErrorResponse('Parameter error', 'invalid_request', 400);
|
||||||
}
|
}
|
||||||
|
const uid = clientId.slice(5);
|
||||||
|
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, uid);
|
||||||
|
|
||||||
// Check login lockout before user lookup to reduce user-enumeration signal
|
// Check login lockout before user lookup to reduce user-enumeration signal
|
||||||
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
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);
|
const user = await storage.getUserById(uid);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||||
@@ -871,7 +905,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
passwordHashB64,
|
passwordHashB64,
|
||||||
password,
|
password,
|
||||||
rateLimit,
|
rateLimit,
|
||||||
`${clientIdentifier}:send-password`
|
clientIdentifier
|
||||||
);
|
);
|
||||||
if ('error' in result) {
|
if ('error' in result) {
|
||||||
return result.error;
|
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.
|
// RFC 7009 allows returning 200 even if token is unknown.
|
||||||
export async function handleRevocation(request: Request, env: Env): Promise<Response> {
|
export async function handleRevocation(request: Request, env: Env): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
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>;
|
let body: Record<string, string>;
|
||||||
const contentType = request.headers.get('content-type') || '';
|
const contentType = request.headers.get('content-type') || '';
|
||||||
|
|||||||
+27
-7
@@ -17,6 +17,9 @@ interface CiphersImportRequest {
|
|||||||
favorite?: boolean;
|
favorite?: boolean;
|
||||||
reprompt?: number;
|
reprompt?: number;
|
||||||
sshKey?: any | null;
|
sshKey?: any | null;
|
||||||
|
bankAccount?: any | null;
|
||||||
|
driversLicense?: any | null;
|
||||||
|
passport?: any | null;
|
||||||
key?: string | null;
|
key?: string | null;
|
||||||
login?: {
|
login?: {
|
||||||
uris?: Array<{ uri: string | null; uriChecksum?: string | null; match?: number | null }> | null;
|
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;
|
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> {
|
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
|
||||||
for (let i = 0; i < statements.length; i += chunkSize) {
|
for (let i = 0; i < statements.length; i += chunkSize) {
|
||||||
const chunk = statements.slice(i, 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);
|
return errorResponse('Invalid JSON', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const folders = importData.folders || [];
|
const folders = Array.isArray(importData.folders) ? importData.folders : [];
|
||||||
const ciphers = importData.ciphers || [];
|
const ciphers = Array.isArray(importData.ciphers) ? importData.ciphers : [];
|
||||||
const folderRelationships = importData.folderRelationships || [];
|
const folderRelationships = Array.isArray(importData.folderRelationships) ? importData.folderRelationships : [];
|
||||||
|
|
||||||
if (folders.length + ciphers.length > LIMITS.performance.importItemLimit) {
|
if (folders.length + ciphers.length > LIMITS.performance.importItemLimit) {
|
||||||
return errorResponse(`Import exceeds maximum of ${LIMITS.performance.importItemLimit} items`, 400);
|
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[] = [];
|
const folderRows: Folder[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < folders.length; i++) {
|
for (let i = 0; i < folders.length; i++) {
|
||||||
|
const importedFolder = folders[i] && typeof folders[i] === 'object' ? folders[i] : null;
|
||||||
const folderId = generateUUID();
|
const folderId = generateUUID();
|
||||||
folderIdMap.set(i, folderId);
|
folderIdMap.set(i, folderId);
|
||||||
|
|
||||||
const folder: Folder = {
|
const folder: Folder = {
|
||||||
id: folderId,
|
id: folderId,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
name: folders[i].name,
|
name: typeof importedFolder?.name === 'string' && importedFolder.name ? importedFolder.name : 'Folder',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: 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
|
// Build cipher index -> folder id mapping from relationships
|
||||||
const cipherFolderMap = new Map<number, string>();
|
const cipherFolderMap = new Map<number, string>();
|
||||||
for (const rel of folderRelationships) {
|
for (const rel of folderRelationships) {
|
||||||
|
if (!rel || typeof rel !== 'object') continue;
|
||||||
const folderId = folderIdMap.get(rel.value);
|
const folderId = folderIdMap.get(rel.value);
|
||||||
if (folderId) {
|
if (folderId) {
|
||||||
cipherFolderMap.set(rel.key, folderId);
|
cipherFolderMap.set(rel.key, folderId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const existingFolderIds = new Set((await storage.getAllFolders(userId)).map((folder) => folder.id));
|
||||||
|
|
||||||
// Create ciphers
|
// Create ciphers
|
||||||
const cipherRows: Cipher[] = [];
|
const cipherRows: Cipher[] = [];
|
||||||
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
|
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
|
||||||
for (let i = 0; i < ciphers.length; i++) {
|
for (let i = 0; i < ciphers.length; i++) {
|
||||||
const c = ciphers[i];
|
const c = ciphers[i] && typeof ciphers[i] === 'object' ? ciphers[i] : {} as CiphersImportRequest['ciphers'][number];
|
||||||
const folderId = cipherFolderMap.get(i) || readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']) || null;
|
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 sourceIdRaw = String(c?.id ?? '').trim();
|
||||||
const sourceId = sourceIdRaw || null;
|
const sourceId = sourceIdRaw || null;
|
||||||
const login = readAliasedImportProp<any | null>(c, ['login', 'Login']);
|
const login = readAliasedImportProp<any | null>(c, ['login', 'Login']);
|
||||||
const card = readAliasedImportProp<any | null>(c, ['card', 'Card']);
|
const card = readAliasedImportProp<any | null>(c, ['card', 'Card']);
|
||||||
const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']);
|
const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']);
|
||||||
const secureNote = readAliasedImportProp<any | null>(c, ['secureNote', 'SecureNote']);
|
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 fields = readAliasedImportProp<any[] | null>(c, ['fields', 'Fields']);
|
||||||
const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']);
|
const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']);
|
||||||
const key = readAliasedImportProp<string | null>(c, ['key', 'Key']);
|
const key = readAliasedImportProp<string | null>(c, ['key', 'Key']);
|
||||||
@@ -244,7 +261,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
})) || null,
|
})) || null,
|
||||||
passwordHistory: passwordHistory ?? null,
|
passwordHistory: passwordHistory ?? null,
|
||||||
reprompt: c.reprompt ?? 0,
|
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,
|
key: key ?? null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { AuthService } from '../services/auth';
|
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 type { Env, JWTPayload } from '../types';
|
||||||
import { errorResponse, jsonResponse } from '../utils/response';
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
@@ -65,6 +67,12 @@ export async function handleAnonymousNotificationsHub(request: Request, env: Env
|
|||||||
return errorResponse('Expected websocket', 426);
|
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 id = env.NOTIFICATIONS_HUB.idFromName(authRequestId);
|
||||||
const stub = env.NOTIFICATIONS_HUB.get(id);
|
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||||
const forwardedUrl = new URL(request.url);
|
const forwardedUrl = new URL(request.url);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { LIMITS } from '../config/limits';
|
|||||||
import {
|
import {
|
||||||
getBlobStorageMaxBytes,
|
getBlobStorageMaxBytes,
|
||||||
getSendFileObjectKey,
|
getSendFileObjectKey,
|
||||||
|
getBlobObject,
|
||||||
putBlobObject,
|
putBlobObject,
|
||||||
deleteBlobObject,
|
deleteBlobObject,
|
||||||
} from '../services/blob-store';
|
} from '../services/blob-store';
|
||||||
@@ -34,6 +35,8 @@ import {
|
|||||||
} from './sends-shared';
|
} from './sends-shared';
|
||||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
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(
|
async function writeSendAudit(
|
||||||
storage: StorageService,
|
storage: StorageService,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -82,8 +85,13 @@ async function processSendFileUpload(
|
|||||||
return upload;
|
return upload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const path = getSendFileObjectKey(send.id, fileId);
|
||||||
|
if (await getBlobObject(env, path)) {
|
||||||
|
return errorResponse('Send file has already been uploaded', 409);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await putBlobObject(env, getSendFileObjectKey(send.id, fileId), upload.body, {
|
await putBlobObject(env, path, upload.body, {
|
||||||
size: upload.size,
|
size: upload.size,
|
||||||
contentType: upload.contentType,
|
contentType: upload.contentType,
|
||||||
customMetadata: {
|
customMetadata: {
|
||||||
@@ -210,11 +218,17 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
|
|||||||
if (authTypeRaw.present && requestedAuthType === null) {
|
if (authTypeRaw.present && requestedAuthType === null) {
|
||||||
return errorResponse('Invalid authType', 400);
|
return errorResponse('Invalid authType', 400);
|
||||||
}
|
}
|
||||||
|
if (requestedAuthType === SendAuthType.Email) {
|
||||||
|
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
||||||
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
|
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
|
||||||
return errorResponse('Invalid emails', 400);
|
return errorResponse('Invalid emails', 400);
|
||||||
}
|
}
|
||||||
|
if (normalizedEmails) {
|
||||||
|
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const send: Send = {
|
const send: Send = {
|
||||||
@@ -334,11 +348,17 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
|
|||||||
if (authTypeRaw.present && requestedAuthType === null) {
|
if (authTypeRaw.present && requestedAuthType === null) {
|
||||||
return errorResponse('Invalid authType', 400);
|
return errorResponse('Invalid authType', 400);
|
||||||
}
|
}
|
||||||
|
if (requestedAuthType === SendAuthType.Email) {
|
||||||
|
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
||||||
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
|
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
|
||||||
return errorResponse('Invalid emails', 400);
|
return errorResponse('Invalid emails', 400);
|
||||||
}
|
}
|
||||||
|
if (normalizedEmails) {
|
||||||
|
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const send: Send = {
|
const send: Send = {
|
||||||
@@ -592,10 +612,11 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
|
|||||||
if (parsedAuthType === null) {
|
if (parsedAuthType === null) {
|
||||||
return errorResponse('Invalid authType', 400);
|
return errorResponse('Invalid authType', 400);
|
||||||
}
|
}
|
||||||
send.authType = parsedAuthType;
|
if (parsedAuthType === SendAuthType.Email) {
|
||||||
if (parsedAuthType !== SendAuthType.Email) {
|
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||||
send.emails = null;
|
|
||||||
}
|
}
|
||||||
|
send.authType = parsedAuthType;
|
||||||
|
send.emails = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
|
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) {
|
if (emailsRaw.value !== null && normalizedEmails === null) {
|
||||||
return errorResponse('Invalid emails', 400);
|
return errorResponse('Invalid emails', 400);
|
||||||
}
|
}
|
||||||
|
if (normalizedEmails) {
|
||||||
|
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
|
||||||
|
}
|
||||||
send.emails = normalizedEmails;
|
send.emails = normalizedEmails;
|
||||||
if (send.emails) {
|
if (send.emails) {
|
||||||
send.authType = SendAuthType.Email;
|
send.authType = SendAuthType.Email;
|
||||||
} else if (send.authType === SendAuthType.Email) {
|
} else if (Number(send.authType) === SendAuthType.Email) {
|
||||||
send.authType = SendAuthType.None;
|
send.authType = SendAuthType.None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str
|
|||||||
if (!clientIdentifier) {
|
if (!clientIdentifier) {
|
||||||
return errorResponse('Client IP is required', 403);
|
return errorResponse('Client IP is required', 403);
|
||||||
}
|
}
|
||||||
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
|
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier, send.id);
|
||||||
sendPasswordRateLimit = new RateLimitService(env.DB);
|
sendPasswordRateLimit = new RateLimitService(env.DB);
|
||||||
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||||
if (!sendPasswordCheck.allowed) {
|
if (!sendPasswordCheck.allowed) {
|
||||||
@@ -142,7 +142,7 @@ export async function handleAccessSendFile(
|
|||||||
if (!clientIdentifier) {
|
if (!clientIdentifier) {
|
||||||
return errorResponse('Client IP is required', 403);
|
return errorResponse('Client IP is required', 403);
|
||||||
}
|
}
|
||||||
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
|
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier, send.id);
|
||||||
sendPasswordRateLimit = new RateLimitService(env.DB);
|
sendPasswordRateLimit = new RateLimitService(env.DB);
|
||||||
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||||
if (!sendPasswordCheck.allowed) {
|
if (!sendPasswordCheck.allowed) {
|
||||||
@@ -290,19 +290,27 @@ export async function handleDownloadSendFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
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 send = await storage.getSend(sendId);
|
||||||
const data = send ? parseStoredSendData(send) : {};
|
if (!send || !isSendAvailable(send) || send.type !== SendType.File) {
|
||||||
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
|
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);
|
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
|
||||||
if (!firstUse) {
|
if (!firstUse) {
|
||||||
return errorResponse('Invalid or expired token', 401);
|
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, {
|
return new Response(object.body, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
||||||
@@ -320,7 +328,7 @@ export async function issueSendAccessToken(
|
|||||||
passwordHashB64?: string | null,
|
passwordHashB64?: string | null,
|
||||||
password?: string | null,
|
password?: string | null,
|
||||||
rateLimit?: RateLimitService,
|
rateLimit?: RateLimitService,
|
||||||
sendPasswordLimitIpKey?: string
|
clientIdentifier?: string
|
||||||
): Promise<{ token: string } | { error: Response }> {
|
): Promise<{ token: string } | { error: Response }> {
|
||||||
const jwt = getSafeJwtSecret(env);
|
const jwt = getSafeJwtSecret(env);
|
||||||
if (!jwt.ok) {
|
if (!jwt.ok) {
|
||||||
@@ -360,11 +368,14 @@ export async function issueSendAccessToken(
|
|||||||
Object: 'error',
|
Object: 'error',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
400
|
501
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sendPasswordLimitIpKey =
|
||||||
|
rateLimit && clientIdentifier ? sendPasswordLimitKey(clientIdentifier, send.id) : null;
|
||||||
|
|
||||||
if (send.passwordHash) {
|
if (send.passwordHash) {
|
||||||
if (rateLimit && sendPasswordLimitIpKey) {
|
if (rateLimit && sendPasswordLimitIpKey) {
|
||||||
const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||||
|
|||||||
@@ -155,7 +155,15 @@ export function formatSize(bytes: number): string {
|
|||||||
|
|
||||||
export function parseDate(raw: unknown): Date | null {
|
export function parseDate(raw: unknown): Date | null {
|
||||||
if (typeof raw !== 'string' || !raw.trim()) return 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;
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
return date;
|
return date;
|
||||||
}
|
}
|
||||||
@@ -434,8 +442,8 @@ export type PublicSendAccessValidationResult =
|
|||||||
| { ok: true }
|
| { ok: true }
|
||||||
| { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' };
|
| { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' };
|
||||||
|
|
||||||
export function sendPasswordLimitKey(clientIdentifier: string): string {
|
export function sendPasswordLimitKey(clientIdentifier: string, sendId: string): string {
|
||||||
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}`;
|
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}:${String(sendId || '').trim() || 'unknown-send'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendPasswordLockMessage(retryAfterSeconds: number): string {
|
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> {
|
export async function validatePublicSendAccess(send: Send, body: unknown): Promise<PublicSendAccessValidationResult> {
|
||||||
if (hasEmailAuth(send)) {
|
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 };
|
if (!send.passwordHash) return { ok: true };
|
||||||
|
|||||||
+6
-3
@@ -24,8 +24,11 @@ function isWorkerHandledPath(path: string): boolean {
|
|||||||
path.startsWith('/api/') ||
|
path.startsWith('/api/') ||
|
||||||
path.startsWith('/identity/') ||
|
path.startsWith('/identity/') ||
|
||||||
path.startsWith('/icons/') ||
|
path.startsWith('/icons/') ||
|
||||||
|
path.startsWith('/fill-assist/') ||
|
||||||
path.startsWith('/notifications/') ||
|
path.startsWith('/notifications/') ||
|
||||||
path.startsWith('/.well-known/') ||
|
path.startsWith('/.well-known/') ||
|
||||||
|
path === '/v1/assetlinks:check' ||
|
||||||
|
path === '/web-bootstrap' ||
|
||||||
path === '/config' ||
|
path === '/config' ||
|
||||||
path === '/api/config' ||
|
path === '/api/config' ||
|
||||||
path === '/api/version'
|
path === '/api/version'
|
||||||
@@ -89,7 +92,7 @@ export default {
|
|||||||
const normalizedRequest = normalizeRequestUrl(request);
|
const normalizedRequest = normalizeRequestUrl(request);
|
||||||
const assetResponse = await maybeServeAsset(normalizedRequest, env);
|
const assetResponse = await maybeServeAsset(normalizedRequest, env);
|
||||||
if (assetResponse) {
|
if (assetResponse) {
|
||||||
return applyCors(normalizedRequest, assetResponse);
|
return applyCors(normalizedRequest, assetResponse, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureDatabaseInitialized(env);
|
await ensureDatabaseInitialized(env);
|
||||||
@@ -107,11 +110,11 @@ export default {
|
|||||||
},
|
},
|
||||||
500
|
500
|
||||||
);
|
);
|
||||||
return applyCors(normalizedRequest, resp);
|
return applyCors(normalizedRequest, resp, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await handleRequest(normalizedRequest, 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> {
|
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function handleAdminBackupRoute(
|
|||||||
return handleAdminExportBackup(request, env, actorUser);
|
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);
|
return handleDownloadAdminBackupAttachment(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ export async function handleAdminBackupRoute(
|
|||||||
return handleDownloadAdminRemoteBackup(request, env, actorUser);
|
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);
|
return handleInspectAdminRemoteBackup(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,23 @@ import {
|
|||||||
handleAdminClearAuditLogs,
|
handleAdminClearAuditLogs,
|
||||||
} from './handlers/admin';
|
} from './handlers/admin';
|
||||||
import { handleAdminBackupRoute } from './router-admin-backup';
|
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(
|
export async function handleAdminRoute(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -21,6 +38,13 @@ export async function handleAdminRoute(
|
|||||||
path: string,
|
path: string,
|
||||||
method: string
|
method: string
|
||||||
): Promise<Response | null> {
|
): Promise<Response | null> {
|
||||||
|
if (!isKnownAdminPath(path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isActiveAdmin(actorUser)) {
|
||||||
|
return errorResponse('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/api/admin/users' && method === 'GET') {
|
if (path === '/api/admin/users' && method === 'GET') {
|
||||||
return handleAdminListUsers(request, env, actorUser);
|
return handleAdminListUsers(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Env, User } from './types';
|
import type { Env, User } from './types';
|
||||||
import { errorResponse, jsonResponse } from './utils/response';
|
import { errorResponse, jsonResponse, unsupportedResponse } from './utils/response';
|
||||||
import {
|
import {
|
||||||
handleGetProfile,
|
handleGetProfile,
|
||||||
handleUpdateProfile,
|
handleUpdateProfile,
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
handlePutTwoFactorYubiKey,
|
handlePutTwoFactorYubiKey,
|
||||||
handlePutTwoFactorYubiKeyConfig,
|
handlePutTwoFactorYubiKeyConfig,
|
||||||
handleBootstrapTwoFactorYubiKeyConfig,
|
handleBootstrapTwoFactorYubiKeyConfig,
|
||||||
|
handleGetDeviceVerificationSettings,
|
||||||
|
handlePutDeviceVerificationSettings,
|
||||||
handleDisableTwoFactorProvider,
|
handleDisableTwoFactorProvider,
|
||||||
handleGetApiKey,
|
handleGetApiKey,
|
||||||
handleRotateApiKey,
|
handleRotateApiKey,
|
||||||
@@ -88,6 +90,7 @@ import {
|
|||||||
handleUpdateAccountPasskeyEncryption,
|
handleUpdateAccountPasskeyEncryption,
|
||||||
} from './handlers/account-passkeys';
|
} from './handlers/account-passkeys';
|
||||||
import {
|
import {
|
||||||
|
handleCreateAdminAuthRequest,
|
||||||
handleGetAuthRequest,
|
handleGetAuthRequest,
|
||||||
handleListAuthRequests,
|
handleListAuthRequests,
|
||||||
handleListPendingAuthRequests,
|
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 (path === '/api/accounts/profile') {
|
||||||
if (method === 'GET') return handleGetProfile(request, env, userId);
|
if (method === 'GET') return handleGetProfile(request, env, userId);
|
||||||
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
|
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
|
||||||
@@ -153,6 +190,15 @@ export async function handleAuthenticatedRoute(
|
|||||||
return handleGetTwoFactorYubiKey(request, env, userId);
|
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') {
|
if (path === '/api/two-factor/get-webauthn' && method === 'POST') {
|
||||||
return handleGetTwoFactorWebAuthn(request, env, userId, currentUser);
|
return handleGetTwoFactorWebAuthn(request, env, userId, currentUser);
|
||||||
}
|
}
|
||||||
@@ -334,17 +380,22 @@ export async function handleAuthenticatedRoute(
|
|||||||
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
|
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);
|
if (method === 'GET') return handleListAuthRequests(request, env, userId);
|
||||||
return errorResponse('Method not allowed', 405);
|
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);
|
if (method === 'GET') return handleListPendingAuthRequests(request, env, userId);
|
||||||
return errorResponse('Method not allowed', 405);
|
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 (authRequestMatch) {
|
||||||
if (method === 'GET') return handleGetAuthRequest(request, env, userId, authRequestMatch[1]);
|
if (method === 'GET') return handleGetAuthRequest(request, env, userId, authRequestMatch[1]);
|
||||||
if (method === 'PUT') return handleUpdateAuthRequest(request, env, userId, authRequestMatch[1]);
|
if (method === 'PUT') return handleUpdateAuthRequest(request, env, userId, authRequestMatch[1]);
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
handleUpdateDeviceToken,
|
handleUpdateDeviceToken,
|
||||||
handleUpdateDeviceWebPushAuth,
|
handleUpdateDeviceWebPushAuth,
|
||||||
handleClearDeviceToken,
|
handleClearDeviceToken,
|
||||||
|
handleRegisterDevice,
|
||||||
|
handleReportLostTrust,
|
||||||
} from './handlers/devices';
|
} from './handlers/devices';
|
||||||
|
|
||||||
function devicesPath(pattern: string): RegExp {
|
function devicesPath(pattern: string): RegExp {
|
||||||
@@ -33,10 +35,15 @@ export async function handleAuthenticatedDeviceRoute(
|
|||||||
): Promise<Response | null> {
|
): Promise<Response | null> {
|
||||||
if (path === '/api/devices' || path === '/devices') {
|
if (path === '/api/devices' || path === '/devices') {
|
||||||
if (method === 'GET') return handleGetDevices(request, env, userId);
|
if (method === 'GET') return handleGetDevices(request, env, userId);
|
||||||
|
if (method === 'POST') return handleRegisterDevice(request, env, userId);
|
||||||
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
|
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
|
||||||
return null;
|
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 (path === '/api/devices/authorized' || path === '/devices/authorized') {
|
||||||
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
|
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
|
||||||
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
|
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
|
||||||
|
|||||||
+57
-7
@@ -7,7 +7,11 @@ import {
|
|||||||
handleDownloadSendFile,
|
handleDownloadSendFile,
|
||||||
} from './handlers/sends';
|
} from './handlers/sends';
|
||||||
import { handleKnownDevice } from './handlers/devices';
|
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 { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
||||||
import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys';
|
import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys';
|
||||||
import {
|
import {
|
||||||
@@ -28,9 +32,10 @@ import {
|
|||||||
} from './handlers/notifications';
|
} from './handlers/notifications';
|
||||||
import { handlePublicUploadSendFile } from './handlers/sends';
|
import { handlePublicUploadSendFile } from './handlers/sends';
|
||||||
import { isSafeWebsiteIconContentType } from './utils/content-type';
|
import { isSafeWebsiteIconContentType } from './utils/content-type';
|
||||||
import { jsonResponse } from './utils/response';
|
import { jsonResponse, unsupportedResponse } from './utils/response';
|
||||||
import { StorageService } from './services/storage';
|
import { StorageService } from './services/storage';
|
||||||
import type { Env } from './types';
|
import type { Env } from './types';
|
||||||
|
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
|
||||||
|
|
||||||
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
||||||
type JwtUnsafeReason = 'missing' | 'too_short' | null;
|
type JwtUnsafeReason = 'missing' | 'too_short' | null;
|
||||||
@@ -40,6 +45,12 @@ export interface WebBootstrapResponse {
|
|||||||
jwtUnsafeReason: JwtUnsafeReason;
|
jwtUnsafeReason: JwtUnsafeReason;
|
||||||
jwtSecretMinLength: number;
|
jwtSecretMinLength: number;
|
||||||
registrationInviteRequired: boolean;
|
registrationInviteRequired: boolean;
|
||||||
|
webAuthnAllowedOrigins: string[];
|
||||||
|
websiteIconsEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebsiteIconProxyEnabled(env: Env): boolean {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSameOriginWriteRequest(request: Request): boolean {
|
function isSameOriginWriteRequest(request: Request): boolean {
|
||||||
@@ -97,7 +108,7 @@ function buildIconServiceCsp(origin: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildConfigResponse(origin: string) {
|
function buildConfigResponse(origin: string) {
|
||||||
const fillAssistBase = `${origin}/fill-assist`;
|
const fillAssistBase = `${origin}/fill-assist/`;
|
||||||
return {
|
return {
|
||||||
version: LIMITS.compatibility.bitwardenServerVersion,
|
version: LIMITS.compatibility.bitwardenServerVersion,
|
||||||
gitHash: 'nodewarden',
|
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);
|
const normalizedHost = normalizeIconHost(host);
|
||||||
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
|
|
||||||
@@ -318,6 +333,8 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
|
|||||||
jwtUnsafeReason,
|
jwtUnsafeReason,
|
||||||
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
||||||
registrationInviteRequired: userCount > 0,
|
registrationInviteRequired: userCount > 0,
|
||||||
|
webAuthnAllowedOrigins: getConfiguredWebAuthnAllowedOrigins(env),
|
||||||
|
websiteIconsEnabled: isWebsiteIconProxyEnabled(env),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,6 +367,12 @@ export async function handlePublicRoute(
|
|||||||
return handleFillAssistManifest();
|
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);
|
const fillAssistFormsMatch = path.match(/^\/fill-assist\/([^/]+)$/i);
|
||||||
if (fillAssistFormsMatch && method === 'GET') {
|
if (fillAssistFormsMatch && method === 'GET') {
|
||||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
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);
|
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
||||||
return handleWebsiteIcon(iconMatch[1], fallbackMode);
|
return handleWebsiteIcon(env, iconMatch[1], fallbackMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
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]);
|
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);
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
return handleCreateAuthRequest(request, env);
|
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') {
|
if (authRequestResponseMatch && method === 'GET') {
|
||||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
if (blocked) return blocked;
|
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') {
|
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);
|
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') {
|
if (path === '/api/accounts/password-hint' && method === 'POST') {
|
||||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
@@ -514,6 +562,8 @@ export async function handlePublicRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/notifications/anonymous-hub' && method === 'GET') {
|
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 handleAnonymousNotificationsHub(request, env);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+74
-11
@@ -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 === '/.well-known/appspecific/com.chrome.devtools.json') return true;
|
||||||
if (method === 'GET' && path === '/fill-assist/manifest.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' && /^\/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;
|
if (method === 'GET' && /^\/icons\/[^/]+\/icon\.png$/i.test(path)) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -36,6 +37,70 @@ function isImportBypassRequest(request: Request, path: string, method: string):
|
|||||||
return false;
|
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> {
|
export async function handleRequest(request: Request, env: Env): Promise<Response> {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const path = url.pathname;
|
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 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;
|
if (check.allowed) return null;
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
@@ -80,20 +148,15 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'OPTIONS') {
|
if (method === 'OPTIONS') {
|
||||||
return handleCors(request);
|
return handleCors(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isLargeUploadPath =
|
const bodyLimitResult = await enforceRequestBodyLimit(request, path, method);
|
||||||
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
|
if (bodyLimitResult instanceof Response) {
|
||||||
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) ||
|
return bodyLimitResult;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
request = bodyLimitResult;
|
||||||
|
|
||||||
const secretIssue = jwtSecretUnsafeReason(env);
|
const secretIssue = jwtSecretUnsafeReason(env);
|
||||||
if (secretIssue && !canServeWithUnsafeJwtSecret(path, method)) {
|
if (secretIssue && !canServeWithUnsafeJwtSecret(path, method)) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zipSync, unzipSync } from 'fflate';
|
import { zipSync, unzipSync, type UnzipFileInfo } from 'fflate';
|
||||||
import type { Env } from '../types';
|
import type { Env } from '../types';
|
||||||
import { APP_VERSION } from '../../shared/app-version';
|
import { APP_VERSION } from '../../shared/app-version';
|
||||||
import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config';
|
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.
|
// Prefer store-only ZIP entries over heavier compression to keep exports reliable.
|
||||||
const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
|
const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
|
||||||
const BACKUP_JSON_INDENT = 2;
|
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_ARCHIVE_ENTRY_COUNT = 10_000;
|
||||||
const MAX_BACKUP_EXTRACTED_BYTES = 64 * 1024 * 1024;
|
const MAX_BACKUP_EXTRACTED_BYTES = 64 * 1024 * 1024;
|
||||||
const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024;
|
const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024;
|
||||||
|
const MAX_BACKUP_PATH_SEGMENT_LENGTH = 128;
|
||||||
|
|
||||||
export interface BackupManifest {
|
export interface BackupManifest {
|
||||||
formatVersion: 1;
|
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[] {
|
function getRequiredZipEntries(db: BackupPayload['db']): string[] {
|
||||||
const entries: string[] = [];
|
const entries: string[] = [];
|
||||||
for (const row of db.attachments) {
|
for (const row of db.attachments) {
|
||||||
@@ -223,8 +279,11 @@ export function parseBackupArchive(
|
|||||||
validateArchiveSize(bytes);
|
validateArchiveSize(bytes);
|
||||||
let zipped: Record<string, Uint8Array>;
|
let zipped: Record<string, Uint8Array>;
|
||||||
try {
|
try {
|
||||||
zipped = unzipSync(bytes);
|
zipped = unzipSync(bytes, { filter: createBackupUnzipFilter() });
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.startsWith('Backup archive ')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
throw new Error('Invalid backup archive');
|
throw new Error('Invalid backup archive');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +294,7 @@ export function parseBackupArchive(
|
|||||||
|
|
||||||
let totalExtractedBytes = 0;
|
let totalExtractedBytes = 0;
|
||||||
for (const entry of entryNames) {
|
for (const entry of entryNames) {
|
||||||
|
validateBackupEntryName(entry);
|
||||||
const entryBytes = zipped[entry];
|
const entryBytes = zipped[entry];
|
||||||
totalExtractedBytes += entryBytes.byteLength;
|
totalExtractedBytes += entryBytes.byteLength;
|
||||||
if (entry === 'db.json' && entryBytes.byteLength > MAX_BACKUP_DB_JSON_BYTES) {
|
if (entry === 'db.json' && entryBytes.byteLength > MAX_BACKUP_DB_JSON_BYTES) {
|
||||||
@@ -368,7 +428,7 @@ export function validateBackupPayloadContents(
|
|||||||
for (const row of attachmentRows) {
|
for (const row of attachmentRows) {
|
||||||
const id = String(row.id || '').trim();
|
const id = String(row.id || '').trim();
|
||||||
const cipherId = String(row.cipher_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');
|
throw new Error('Backup archive contains an invalid attachment row');
|
||||||
}
|
}
|
||||||
const attachmentPath = `attachments/${cipherId}/${id}.bin`;
|
const attachmentPath = `attachments/${cipherId}/${id}.bin`;
|
||||||
@@ -382,9 +442,10 @@ export function validateBackupPayloadContents(
|
|||||||
for (const row of accountPasskeyRows) {
|
for (const row of accountPasskeyRows) {
|
||||||
const id = String(row.id || '').trim();
|
const id = String(row.id || '').trim();
|
||||||
const userId = String(row.user_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 credentialId = String(row.credential_id || '').trim();
|
||||||
const publicKey = String(row.public_key || '').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');
|
throw new Error('Backup archive contains an invalid account passkey row');
|
||||||
}
|
}
|
||||||
if (accountPasskeyIds.has(id)) throw new Error(`Backup archive contains duplicate account passkey id: ${id}`);
|
if (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, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT 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()),
|
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
|
||||||
]);
|
]);
|
||||||
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
|
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
|
||||||
const BACKUP_RUNTIME_CONFIG_KEY = 'backup.runtime.v1';
|
const BACKUP_RUNTIME_CONFIG_KEY = 'backup.runtime.v1';
|
||||||
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
|
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
|
||||||
|
export const REDACTED_BACKUP_SECRET = '********';
|
||||||
const MAX_BACKUP_DESTINATIONS = 24;
|
const MAX_BACKUP_DESTINATIONS = 24;
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -67,6 +68,163 @@ function normalizePath(value: unknown): string {
|
|||||||
return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
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 {
|
function assertValidTimeZone(timezone: string): string {
|
||||||
try {
|
try {
|
||||||
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
|
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 (!allowIncomplete || endpoint) {
|
||||||
if (!endpoint) throw new Error('S3 endpoint is required');
|
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 (!allowIncomplete || bucket) {
|
||||||
if (!bucket) throw new Error('S3 bucket is required');
|
if (!bucket) throw new Error('S3 bucket is required');
|
||||||
@@ -135,7 +293,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
|
endpoint: endpoint ? normalizeBackupEndpointUrl(endpoint, 'S3 endpoint') : '',
|
||||||
bucket,
|
bucket,
|
||||||
addressingStyle,
|
addressingStyle,
|
||||||
region,
|
region,
|
||||||
@@ -154,7 +312,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
|
|||||||
|
|
||||||
if (!allowIncomplete || baseUrl) {
|
if (!allowIncomplete || baseUrl) {
|
||||||
if (!baseUrl) throw new Error('WebDAV server URL is required');
|
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 (!allowIncomplete || username) {
|
||||||
if (!username) throw new Error('WebDAV username is required');
|
if (!username) throw new Error('WebDAV username is required');
|
||||||
@@ -164,7 +322,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseUrl: baseUrl ? baseUrl.replace(/\/+$/, '') : '',
|
baseUrl: baseUrl ? normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL') : '',
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
remotePath,
|
remotePath,
|
||||||
@@ -180,6 +338,32 @@ function normalizeDestination(
|
|||||||
return normalizeWebDavDestination(destination, allowIncomplete);
|
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 {
|
function normalizeRuntime(value: unknown): BackupRuntimeState {
|
||||||
const source = isPlainObject(value) ? value : {};
|
const source = isPlainObject(value) ? value : {};
|
||||||
const asIso = (input: unknown): string | null => {
|
const asIso = (input: unknown): string | null => {
|
||||||
@@ -250,7 +434,11 @@ function normalizeDestinationRecord(
|
|||||||
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -432,6 +620,31 @@ export function serializeBackupSettings(settings: BackupSettings): string {
|
|||||||
return JSON.stringify(stripRuntimeFromSettings(settings));
|
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> {
|
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
|
||||||
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||||
const mergeRuntime = async (settings: BackupSettings): Promise<BackupSettings> => (
|
const mergeRuntime = async (settings: BackupSettings): Promise<BackupSettings> => (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from
|
|||||||
import {
|
import {
|
||||||
type BackupManifestAttachmentBlob,
|
type BackupManifestAttachmentBlob,
|
||||||
type BackupPayload,
|
type BackupPayload,
|
||||||
|
isSafeBackupAttachmentBlobName,
|
||||||
parseBackupArchive,
|
parseBackupArchive,
|
||||||
validateBackupPayloadContents,
|
validateBackupPayloadContents,
|
||||||
} from './backup-archive';
|
} from './backup-archive';
|
||||||
@@ -253,6 +254,10 @@ function cloneRows(rows: SqlRow[]): SqlRow[] {
|
|||||||
return rows.map((row) => ({ ...row }));
|
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[] {
|
function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] {
|
||||||
let replaced = false;
|
let replaced = false;
|
||||||
const nextRows = rows.map((row) => {
|
const nextRows = rows.map((row) => {
|
||||||
@@ -296,13 +301,16 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
|
|||||||
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
|
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
|
||||||
users: cloneRows(payload.users || []).map((row) => ({
|
users: cloneRows(payload.users || []).map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
verify_devices: row.verify_devices ?? 1,
|
verify_devices: row.verify_devices ?? 0,
|
||||||
yubikey_nfc: row.yubikey_nfc ?? 0,
|
yubikey_nfc: row.yubikey_nfc ?? 0,
|
||||||
})),
|
})),
|
||||||
domain_settings: cloneRows(payload.domain_settings || []),
|
domain_settings: cloneRows(payload.domain_settings || []),
|
||||||
user_revisions: cloneRows(payload.user_revisions || []),
|
user_revisions: cloneRows(payload.user_revisions || []),
|
||||||
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
|
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 || []),
|
folders: cloneRows(payload.folders || []),
|
||||||
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
@@ -462,9 +470,20 @@ async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildAttachmentBlobLookup(manifest: BackupPayload['manifest']): Map<string, BackupManifestAttachmentBlob> {
|
function buildAttachmentBlobLookup(manifest: BackupPayload['manifest']): Map<string, BackupManifestAttachmentBlob> {
|
||||||
return new Map(
|
const lookup = new Map<string, BackupManifestAttachmentBlob>();
|
||||||
(manifest.attachmentBlobs || []).map((item) => [`${item.cipherId}/${item.attachmentId}`, item])
|
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(
|
async function prepareRemoteAttachmentPayload(
|
||||||
@@ -656,7 +675,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
|||||||
buildInsertStatements(
|
buildInsertStatements(
|
||||||
db,
|
db,
|
||||||
tableName('webauthn_credentials'),
|
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 || []
|
payload.webauthn_credentials || []
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
BackupDestinationType,
|
BackupDestinationType,
|
||||||
S3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
|
normalizeBackupEndpointUrl,
|
||||||
} from './backup-config';
|
} from './backup-config';
|
||||||
|
|
||||||
export interface BackupUploadResult {
|
export interface BackupUploadResult {
|
||||||
@@ -215,7 +216,7 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
|
|||||||
if (destination.type === 'webdav') {
|
if (destination.type === 'webdav') {
|
||||||
const config = destination.destination as WebDavBackupDestination;
|
const config = destination.destination as WebDavBackupDestination;
|
||||||
if (!String(config.baseUrl || '').trim()) throw new Error('WebDAV server URL is required');
|
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.username || '').trim()) throw new Error('WebDAV username is required');
|
||||||
if (!String(config.password || '')) throw new Error('WebDAV password is required');
|
if (!String(config.password || '')) throw new Error('WebDAV password is required');
|
||||||
return;
|
return;
|
||||||
@@ -223,7 +224,7 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
|
|||||||
if (destination.type === 's3') {
|
if (destination.type === 's3') {
|
||||||
const config = destination.destination as S3BackupDestination;
|
const config = destination.destination as S3BackupDestination;
|
||||||
if (!String(config.endpoint || '').trim()) throw new Error('S3 endpoint is required');
|
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.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.accessKeyId || '').trim()) throw new Error('S3 access key is required');
|
||||||
if (!String(config.secretAccessKey || '')) throw new Error('S3 secret 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,
|
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}`);
|
throw new Error(`WebDAV directory creation failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,7 +276,7 @@ async function ensureWebDavDirectoryCached(
|
|||||||
Authorization: authHeader,
|
Authorization: authHeader,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if ([200, 201, 204, 301, 302, 405].includes(response.status)) {
|
if ([200, 201, 204, 405].includes(response.status)) {
|
||||||
ensuredDirectories.add(current);
|
ensuredDirectories.add(current);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -518,7 +519,7 @@ async function signedS3Request(
|
|||||||
config.region || 'auto'
|
config.region || 'auto'
|
||||||
);
|
);
|
||||||
|
|
||||||
return fetch(url.toString(), {
|
return fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: authorization,
|
Authorization: authorization,
|
||||||
|
|||||||
@@ -62,27 +62,10 @@ export async function ensurePushInstallationCredentials(db: D1Database): Promise
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
accept: 'application/json',
|
accept: 'application/json',
|
||||||
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
||||||
'cache-control': 'no-cache',
|
|
||||||
'content-type': 'application/json',
|
'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({
|
body: JSON.stringify({
|
||||||
formName: 'request_host',
|
|
||||||
url: '/host/',
|
|
||||||
locale: 'zh-CN',
|
|
||||||
email: randomInstallationEmail(),
|
email: randomInstallationEmail(),
|
||||||
region: 'us',
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
'Failed to request Bitwarden push installation:'
|
'Failed to request Bitwarden push installation:'
|
||||||
@@ -94,9 +77,9 @@ export async function ensurePushInstallationCredentials(db: D1Database): Promise
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (await response.json().catch(() => null)) as { id?: string; key?: string; enabled?: boolean } | null;
|
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 || '').trim();
|
const id = String(body?.id || body?.Id || '').trim();
|
||||||
const key = String(body?.key || '').trim();
|
const key = String(body?.key || body?.Key || '').trim();
|
||||||
if (!id || !key) {
|
if (!id || !key) {
|
||||||
console.error('Bitwarden push installation response did not include id/key');
|
console.error('Bitwarden push installation response did not include id/key');
|
||||||
return null;
|
return null;
|
||||||
@@ -234,7 +217,7 @@ export async function registerMobilePushDevice(
|
|||||||
export async function unregisterMobilePushDevice(env: Env, pushUuid: string | null | undefined): Promise<boolean> {
|
export async function unregisterMobilePushDevice(env: Env, pushUuid: string | null | undefined): Promise<boolean> {
|
||||||
const normalized = String(pushUuid || '').trim();
|
const normalized = String(pushUuid || '').trim();
|
||||||
if (!normalized) return false;
|
if (!normalized) return false;
|
||||||
return postToPushRelay(env, `/push/delete/${encodeURIComponent(normalized)}`);
|
return postToPushRelay(env, '/push/delete', { id: normalized });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function notifyMobilePush(
|
export async function notifyMobilePush(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { LIMITS } from '../config/limits';
|
|||||||
// Rate limiting service.
|
// Rate limiting service.
|
||||||
// - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence).
|
// - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence).
|
||||||
// - API budgets: Cloudflare Cache API (high volume, auto-expires, zero D1 writes).
|
// - 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 = {
|
const CONFIG = {
|
||||||
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
|
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
|
||||||
@@ -12,11 +13,14 @@ const CONFIG = {
|
|||||||
|
|
||||||
export class RateLimitService {
|
export class RateLimitService {
|
||||||
private static loginIpTableReady = false;
|
private static loginIpTableReady = false;
|
||||||
|
private static strictBudgetTableReady = false;
|
||||||
private static lastLoginIpCleanupAt = 0;
|
private static lastLoginIpCleanupAt = 0;
|
||||||
|
private static lastStrictBudgetCleanupAt = 0;
|
||||||
|
|
||||||
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability;
|
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_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
|
||||||
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
|
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) {}
|
constructor(private db: D1Database) {}
|
||||||
|
|
||||||
@@ -58,6 +62,35 @@ export class RateLimitService {
|
|||||||
RateLimitService.loginIpTableReady = true;
|
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<{
|
async checkLoginAttempt(ip: string): Promise<{
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
remainingAttempts: number;
|
remainingAttempts: number;
|
||||||
@@ -174,6 +207,59 @@ export class RateLimitService {
|
|||||||
return { allowed: true, remaining: Math.max(0, maxRequests - count) };
|
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.
|
// General-purpose fixed-window budget.
|
||||||
// Callers supply an identifier (must be unique per rate-limit category) and the
|
// Callers supply an identifier (must be unique per rate-limit category) and the
|
||||||
// per-window maximum. This single method replaces all previous specialised
|
// per-window maximum. This single method replaces all previous specialised
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ export async function updateAccountPasskeyEncryption(
|
|||||||
const result = await db
|
const result = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'UPDATE webauthn_credentials SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, supports_prf = 1, updated_at = ? ' +
|
'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)
|
.bind(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey, updatedAt, userId, credentialId)
|
||||||
.run();
|
.run();
|
||||||
|
|||||||
@@ -97,6 +97,20 @@ export async function touchDeviceLastSeen(
|
|||||||
return Number(result.meta.changes ?? 0) > 0;
|
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(
|
export async function updateDeviceKeys(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -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, ' +
|
'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, ' +
|
'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, ' +
|
'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 master_password_hint TEXT',
|
||||||
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
'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 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_secret TEXT',
|
||||||
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||||
'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT',
|
'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT',
|
||||||
|
|||||||
@@ -93,9 +93,13 @@ export async function incrementSendAccessCount(db: D1Database, sendId: string):
|
|||||||
const result = await db
|
const result = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'UPDATE sends SET access_count = access_count + 1, updated_at = ? ' +
|
'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();
|
.run();
|
||||||
return (result.meta.changes ?? 0) > 0;
|
return (result.meta.changes ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function mapUserRow(row: any): User {
|
|||||||
securityStamp: row.security_stamp,
|
securityStamp: row.security_stamp,
|
||||||
role: row.role === 'admin' ? 'admin' : 'user',
|
role: row.role === 'admin' ? 'admin' : 'user',
|
||||||
status: row.status === 'banned' ? 'banned' : 'active',
|
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,
|
totpSecret: row.totp_secret ?? null,
|
||||||
totpRecoveryCode: row.totp_recovery_code ?? null,
|
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||||
yubikeyKey1: row.yubikey_key1 ?? null,
|
yubikeyKey1: row.yubikey_key1 ?? null,
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ import {
|
|||||||
isKnownDevice as getKnownStoredDevice,
|
isKnownDevice as getKnownStoredDevice,
|
||||||
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
|
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
|
||||||
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
|
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
|
||||||
|
rotateDeviceSessionStamp as rotateStoredDeviceSessionStamp,
|
||||||
touchDeviceLastSeen as touchStoredDeviceLastSeen,
|
touchDeviceLastSeen as touchStoredDeviceLastSeen,
|
||||||
upsertDevice as saveStoredDevice,
|
upsertDevice as saveStoredDevice,
|
||||||
updateDeviceName as updateStoredDeviceName,
|
updateDeviceName as updateStoredDeviceName,
|
||||||
@@ -761,6 +762,10 @@ export class StorageService {
|
|||||||
return findStoredDevice(this.db, userId, deviceIdentifier);
|
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(
|
async updateDeviceKeys(
|
||||||
userId: string,
|
userId: string,
|
||||||
deviceIdentifier: string,
|
deviceIdentifier: string,
|
||||||
|
|||||||
@@ -124,6 +124,10 @@ export enum CipherType {
|
|||||||
SecureNote = 2,
|
SecureNote = 2,
|
||||||
Card = 3,
|
Card = 3,
|
||||||
Identity = 4,
|
Identity = 4,
|
||||||
|
SSHKey = 5,
|
||||||
|
BankAccount = 6,
|
||||||
|
DriversLicense = 7,
|
||||||
|
Passport = 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CipherLoginUri {
|
export interface CipherLoginUri {
|
||||||
@@ -158,6 +162,52 @@ export interface CipherSshKey {
|
|||||||
keyFingerprint: string;
|
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 {
|
export interface CipherIdentity {
|
||||||
title: string | null;
|
title: string | null;
|
||||||
firstName: string | null;
|
firstName: string | null;
|
||||||
@@ -208,6 +258,9 @@ export interface Cipher {
|
|||||||
identity: CipherIdentity | null;
|
identity: CipherIdentity | null;
|
||||||
secureNote: CipherSecureNote | null;
|
secureNote: CipherSecureNote | null;
|
||||||
sshKey: CipherSshKey | null;
|
sshKey: CipherSshKey | null;
|
||||||
|
bankAccount?: CipherBankAccount | null;
|
||||||
|
driversLicense?: CipherDriversLicense | null;
|
||||||
|
passport?: CipherPassport | null;
|
||||||
fields: CipherField[] | null;
|
fields: CipherField[] | null;
|
||||||
passwordHistory: PasswordHistory[] | null;
|
passwordHistory: PasswordHistory[] | null;
|
||||||
reprompt: number;
|
reprompt: number;
|
||||||
@@ -547,6 +600,9 @@ export interface CipherResponse {
|
|||||||
identity: CipherIdentity | null;
|
identity: CipherIdentity | null;
|
||||||
secureNote: CipherSecureNote | null;
|
secureNote: CipherSecureNote | null;
|
||||||
sshKey: CipherSshKey | null;
|
sshKey: CipherSshKey | null;
|
||||||
|
bankAccount: CipherBankAccount | null;
|
||||||
|
driversLicense: CipherDriversLicense | null;
|
||||||
|
passport: CipherPassport | null;
|
||||||
fields: CipherField[] | null;
|
fields: CipherField[] | null;
|
||||||
passwordHistory: PasswordHistory[] | null;
|
passwordHistory: PasswordHistory[] | null;
|
||||||
reprompt: number;
|
reprompt: number;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
WebAuthnPrfDecryptionOption,
|
WebAuthnPrfDecryptionOption,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
|
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
|
||||||
|
import { getConfiguredWebAuthnAllowedOrigins } from './origins';
|
||||||
|
|
||||||
const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1';
|
const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1';
|
||||||
const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000;
|
const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000;
|
||||||
@@ -32,6 +33,44 @@ function textBytes(value: string): Uint8Array {
|
|||||||
return new TextEncoder().encode(value);
|
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> {
|
async function importHmacKey(secret: string): Promise<CryptoKey> {
|
||||||
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
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 configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim();
|
||||||
const rpId = configuredRpId || url.hostname;
|
const rpId = configuredRpId || url.hostname;
|
||||||
const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME;
|
const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME;
|
||||||
const configuredOrigins = String(env.WEBAUTHN_ALLOWED_ORIGINS || '')
|
const configuredOrigins = getConfiguredWebAuthnAllowedOrigins(env);
|
||||||
.split(',')
|
|
||||||
.map((origin) => origin.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const origins = new Set<string>([url.origin, ...configuredOrigins]);
|
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) };
|
return { rpId, rpName, origins: Array.from(origins) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function userIdToWebAuthnUserId(userId: string): Uint8Array {
|
export function userIdToWebAuthnUserId(userId: string): Uint8Array {
|
||||||
return textBytes(userId);
|
return uuidToDotNetGuidBytes(userId) || textBytes(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function userHandleToUserId(userHandle: string | undefined): string | null {
|
export function userHandleToUserId(userHandle: string | undefined): string | null {
|
||||||
if (!userHandle) return null;
|
if (!userHandle) return null;
|
||||||
try {
|
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;
|
return decoded.trim() || null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -209,17 +237,17 @@ export function normalizeRegistrationResponse(raw: unknown): RegistrationRespons
|
|||||||
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
|
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
|
||||||
if (!input.id || !input.rawId || !clientDataJSON || !response.attestationObject) return null;
|
if (!input.id || !input.rawId || !clientDataJSON || !response.attestationObject) return null;
|
||||||
return {
|
return {
|
||||||
id: String(input.id),
|
id: normalizeWebAuthnBase64(input.id),
|
||||||
rawId: String(input.rawId),
|
rawId: normalizeWebAuthnBase64(input.rawId),
|
||||||
type: 'public-key',
|
type: 'public-key',
|
||||||
authenticatorAttachment: input.authenticatorAttachment,
|
authenticatorAttachment: input.authenticatorAttachment,
|
||||||
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
|
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
|
||||||
response: {
|
response: {
|
||||||
attestationObject: String(response.attestationObject),
|
attestationObject: normalizeWebAuthnBase64(response.attestationObject),
|
||||||
clientDataJSON: String(clientDataJSON),
|
clientDataJSON: normalizeWebAuthnBase64(clientDataJSON),
|
||||||
authenticatorData: response.authenticatorData ? String(response.authenticatorData) : undefined,
|
authenticatorData: response.authenticatorData ? normalizeWebAuthnBase64(response.authenticatorData) : undefined,
|
||||||
transports: Array.isArray(response.transports) ? response.transports.map(String) as AuthenticatorTransportFuture[] : 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,
|
publicKeyAlgorithm: typeof response.publicKeyAlgorithm === 'number' ? response.publicKeyAlgorithm : undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -232,16 +260,16 @@ export function normalizeAuthenticationResponse(raw: unknown): AuthenticationRes
|
|||||||
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
|
const clientDataJSON = response.clientDataJSON || response.clientDataJson;
|
||||||
if (!input.id || !input.rawId || !clientDataJSON || !response.authenticatorData || !response.signature) return null;
|
if (!input.id || !input.rawId || !clientDataJSON || !response.authenticatorData || !response.signature) return null;
|
||||||
return {
|
return {
|
||||||
id: String(input.id),
|
id: normalizeWebAuthnBase64(input.id),
|
||||||
rawId: String(input.rawId),
|
rawId: normalizeWebAuthnBase64(input.rawId),
|
||||||
type: 'public-key',
|
type: 'public-key',
|
||||||
authenticatorAttachment: input.authenticatorAttachment,
|
authenticatorAttachment: input.authenticatorAttachment,
|
||||||
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
|
clientExtensionResults: input.clientExtensionResults || input.extensions || {},
|
||||||
response: {
|
response: {
|
||||||
authenticatorData: String(response.authenticatorData),
|
authenticatorData: normalizeWebAuthnBase64(response.authenticatorData),
|
||||||
clientDataJSON: String(clientDataJSON),
|
clientDataJSON: normalizeWebAuthnBase64(clientDataJSON),
|
||||||
signature: String(response.signature),
|
signature: normalizeWebAuthnBase64(response.signature),
|
||||||
userHandle: response.userHandle ? String(response.userHandle) : undefined,
|
userHandle: response.userHandle ? normalizeWebAuthnBase64(response.userHandle) : undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ interface ParseDirectUploadOptions {
|
|||||||
fileNameMismatchMessage?: string;
|
fileNameMismatchMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MULTIPART_FORMDATA_OVERHEAD_BYTES = 256 * 1024;
|
||||||
|
|
||||||
export function buildDirectUploadUrl(request: Request, path: string, token: string): string {
|
export function buildDirectUploadUrl(request: Request, path: string, token: string): string {
|
||||||
const version = '2023-11-03';
|
const version = '2023-11-03';
|
||||||
const expiresAt = '2099-12-31T23:59:59Z';
|
const expiresAt = '2099-12-31T23:59:59Z';
|
||||||
@@ -34,6 +36,10 @@ export function getSafeJwtSecret(env: Env): string | null {
|
|||||||
return secret;
|
return secret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMultipartRequestMaxBytes(maxFileSize: number): number {
|
||||||
|
return maxFileSize + MULTIPART_FORMDATA_OVERHEAD_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
function parseContentLength(request: Request): number | null {
|
function parseContentLength(request: Request): number | null {
|
||||||
const raw = request.headers.get('content-length');
|
const raw = request.headers.get('content-length');
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
@@ -59,6 +65,10 @@ export async function parseDirectUploadPayload(
|
|||||||
const contentType = request.headers.get('content-type') || '';
|
const contentType = request.headers.get('content-type') || '';
|
||||||
|
|
||||||
if (contentType.includes('multipart/form-data')) {
|
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 formData = await request.formData();
|
||||||
const file = formData.get('data') as File | null;
|
const file = formData.get('data') as File | null;
|
||||||
if (!file) {
|
if (!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);
|
||||||
|
}
|
||||||
@@ -30,7 +30,9 @@ export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
|
|||||||
forcePasswordReset: false,
|
forcePasswordReset: false,
|
||||||
avatarColor: null,
|
avatarColor: null,
|
||||||
creationDate: user.createdAt,
|
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,
|
role: user.role,
|
||||||
status: user.status,
|
status: user.status,
|
||||||
object: 'profile',
|
object: 'profile',
|
||||||
|
|||||||
+25
-18
@@ -1,4 +1,10 @@
|
|||||||
import { LIMITS } from '../config/limits';
|
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 CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
|
||||||
const DEFAULT_CORS_HEADERS = [
|
const DEFAULT_CORS_HEADERS = [
|
||||||
@@ -18,35 +24,31 @@ const DEFAULT_CORS_HEADERS = [
|
|||||||
'X-NodeWarden-Web-Session',
|
'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 {
|
function isWildcardCorsPath(path: string): boolean {
|
||||||
return (
|
return (
|
||||||
path.startsWith('/icons/')
|
path.startsWith('/icons/')
|
||||||
|
|| path.startsWith('/fill-assist/')
|
||||||
|
|| path === '/v1/assetlinks:check'
|
||||||
|
|| path === '/api/v1/assetlinks:check'
|
||||||
|| path === '/config'
|
|| path === '/config'
|
||||||
|| path === '/api/config'
|
|| path === '/api/config'
|
||||||
|| path === '/api/version'
|
|| 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 url = new URL(request.url);
|
||||||
const origin = request.headers.get('Origin');
|
const originHeader = request.headers.get('Origin');
|
||||||
if (!origin) {
|
if (!originHeader) {
|
||||||
return isWildcardCorsPath(url.pathname)
|
return isWildcardCorsPath(url.pathname)
|
||||||
? { allowOrigin: '*', allowCredentials: false }
|
? { allowOrigin: '*', allowCredentials: false }
|
||||||
: { allowOrigin: null, allowCredentials: false };
|
: { allowOrigin: null, allowCredentials: false };
|
||||||
}
|
}
|
||||||
|
const origin = normalizeOrigin(originHeader);
|
||||||
if (origin === url.origin) {
|
if (origin === url.origin) {
|
||||||
return { allowOrigin: origin, allowCredentials: true };
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
}
|
}
|
||||||
if (isExtensionOrigin(origin)) {
|
if (isBrowserExtensionOrigin(origin) && isConfiguredWebAuthnAllowedOrigin(env, origin)) {
|
||||||
return { allowOrigin: origin, allowCredentials: true };
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
}
|
}
|
||||||
if (isWildcardCorsPath(url.pathname)) {
|
if (isWildcardCorsPath(url.pathname)) {
|
||||||
@@ -55,7 +57,7 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
|
|||||||
return { allowOrigin: null, allowCredentials: false };
|
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') || '')
|
const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((value) => value.trim())
|
.map((value) => value.trim())
|
||||||
@@ -69,7 +71,7 @@ function buildCorsHeaders(request: Request): Record<string, string> {
|
|||||||
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
||||||
};
|
};
|
||||||
|
|
||||||
const corsPolicy = getCorsPolicy(request);
|
const corsPolicy = getCorsPolicy(request, env);
|
||||||
if (corsPolicy.allowOrigin) {
|
if (corsPolicy.allowOrigin) {
|
||||||
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
|
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
|
||||||
if (corsPolicy.allowCredentials) {
|
if (corsPolicy.allowCredentials) {
|
||||||
@@ -83,7 +85,8 @@ function buildCorsHeaders(request: Request): Record<string, string> {
|
|||||||
|
|
||||||
export function applyCors(
|
export function applyCors(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response
|
response: Response,
|
||||||
|
env: Env
|
||||||
): Response {
|
): Response {
|
||||||
// WebSocket upgrade responses must be returned untouched.
|
// WebSocket upgrade responses must be returned untouched.
|
||||||
const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
|
const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
|
||||||
@@ -92,7 +95,7 @@ export function applyCors(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headers = new Headers(response.headers);
|
const headers = new Headers(response.headers);
|
||||||
const corsHeaders = buildCorsHeaders(request);
|
const corsHeaders = buildCorsHeaders(request, env);
|
||||||
for (const [k, v] of Object.entries(corsHeaders)) {
|
for (const [k, v] of Object.entries(corsHeaders)) {
|
||||||
headers.set(k, v);
|
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)
|
// Identity endpoint error response (for /identity/connect/token)
|
||||||
export function identityErrorResponse(message: string, error: string = 'invalid_grant', status: number = 400): Response {
|
export function identityErrorResponse(message: string, error: string = 'invalid_grant', status: number = 400): Response {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
@@ -152,10 +159,10 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle CORS preflight
|
// Handle CORS preflight
|
||||||
export function handleCors(request: Request): Response {
|
export function handleCors(request: Request, env: Env): Response {
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 204,
|
status: 204,
|
||||||
headers: buildCorsHeaders(request),
|
headers: buildCorsHeaders(request, env),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
script-src 'self' 'unsafe-inline';
|
script-src 'self' 'unsafe-inline';
|
||||||
style-src 'self' 'unsafe-inline';
|
style-src 'self' 'unsafe-inline';
|
||||||
img-src 'self' data:;
|
img-src 'self' data:;
|
||||||
connect-src 'self';
|
connect-src 'self' https://api.pwnedpasswords.com;
|
||||||
font-src 'self';
|
font-src 'self';
|
||||||
form-action 'self';
|
form-action 'self';
|
||||||
base-uri 'self';
|
base-uri 'self';
|
||||||
|
|||||||
@@ -170,6 +170,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var params = new URLSearchParams(window.location.search);
|
var params = new URLSearchParams(window.location.search);
|
||||||
var sentSuccess = false;
|
var sentSuccess = false;
|
||||||
|
var allowedParentOriginsPromise = null;
|
||||||
|
|
||||||
var text = pickText(params.get("locale") || navigator.language || "en");
|
var text = pickText(params.get("locale") || navigator.language || "en");
|
||||||
document.documentElement.lang = params.get("locale") || navigator.language || "en";
|
document.documentElement.lang = params.get("locale") || navigator.language || "en";
|
||||||
@@ -227,6 +228,81 @@
|
|||||||
return out;
|
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) {
|
function showMessage(kind, message) {
|
||||||
msgEl.textContent = String(message || "");
|
msgEl.textContent = String(message || "");
|
||||||
msgEl.className = "msg show " + kind;
|
msgEl.className = "msg show " + kind;
|
||||||
@@ -279,13 +355,13 @@
|
|||||||
|
|
||||||
function normalizeOptions(options) {
|
function normalizeOptions(options) {
|
||||||
if (!options || typeof options !== "object") throw new Error("Cannot parse data.");
|
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);
|
copy.challenge = bytesFromBase64Url(copy.challenge);
|
||||||
if (Array.isArray(copy.allowCredentials)) {
|
if (Array.isArray(copy.allowCredentials)) {
|
||||||
copy.allowCredentials = copy.allowCredentials.map(function (credential) {
|
copy.allowCredentials = copy.allowCredentials.map(function (credential) {
|
||||||
return Object.assign({}, credential, {
|
var next = safeShallowCopy(credential);
|
||||||
id: bytesFromBase64Url(credential.id),
|
next.id = bytesFromBase64Url(credential && credential.id);
|
||||||
});
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return copy;
|
return copy;
|
||||||
@@ -327,11 +403,11 @@
|
|||||||
if (!(credential instanceof PublicKeyCredential)) {
|
if (!(credential instanceof PublicKeyCredential)) {
|
||||||
throw new Error("No security key was selected.");
|
throw new Error("No security key was selected.");
|
||||||
}
|
}
|
||||||
window.postMessage({
|
await postResult({
|
||||||
command: "webAuthnResult",
|
command: "webAuthnResult",
|
||||||
data: credentialToDataString(credential),
|
data: credentialToDataString(credential),
|
||||||
remember: rememberEl.checked,
|
remember: rememberEl.checked,
|
||||||
}, "*");
|
});
|
||||||
sentSuccess = true;
|
sentSuccess = true;
|
||||||
showMessage("success", text.success);
|
showMessage("success", text.success);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+31
-10
@@ -68,6 +68,7 @@ import { t } from '@/lib/i18n';
|
|||||||
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
||||||
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
||||||
import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
|
import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
|
||||||
|
import { clearPasswordSecurityCache } from '@/lib/password-security-cache';
|
||||||
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
|
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
|
||||||
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
|
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
|
||||||
import {
|
import {
|
||||||
@@ -111,6 +112,8 @@ const APP_ROUTE_PATHS = [
|
|||||||
'/',
|
'/',
|
||||||
'/vault',
|
'/vault',
|
||||||
'/vault/totp',
|
'/vault/totp',
|
||||||
|
'/security/password-health',
|
||||||
|
'/generator',
|
||||||
'/sends',
|
'/sends',
|
||||||
'/admin',
|
'/admin',
|
||||||
'/logs',
|
'/logs',
|
||||||
@@ -228,6 +231,7 @@ export default function App() {
|
|||||||
hint: null,
|
hint: null,
|
||||||
});
|
});
|
||||||
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
|
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
|
||||||
|
const [hashPathRaw, setHashPathRaw] = useState(() => (typeof window !== 'undefined' ? window.location.hash || '' : ''));
|
||||||
const [unlockPassword, setUnlockPassword] = useState('');
|
const [unlockPassword, setUnlockPassword] = useState('');
|
||||||
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
||||||
const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null);
|
const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null);
|
||||||
@@ -295,15 +299,16 @@ export default function App() {
|
|||||||
}, [pushToast]);
|
}, [pushToast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncInviteFromUrl = () => {
|
const syncUrlState = () => {
|
||||||
setInviteCodeFromUrl(readInviteCodeFromUrl());
|
setInviteCodeFromUrl(readInviteCodeFromUrl());
|
||||||
|
setHashPathRaw(window.location.hash || '');
|
||||||
};
|
};
|
||||||
syncInviteFromUrl();
|
syncUrlState();
|
||||||
window.addEventListener('hashchange', syncInviteFromUrl);
|
window.addEventListener('hashchange', syncUrlState);
|
||||||
window.addEventListener('popstate', syncInviteFromUrl);
|
window.addEventListener('popstate', syncUrlState);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('hashchange', syncInviteFromUrl);
|
window.removeEventListener('hashchange', syncUrlState);
|
||||||
window.removeEventListener('popstate', syncInviteFromUrl);
|
window.removeEventListener('popstate', syncUrlState);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -383,6 +388,10 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [phase, profile, session]);
|
}, [phase, profile, session]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'app') clearPasswordSecurityCache();
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes));
|
window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes));
|
||||||
@@ -869,6 +878,7 @@ export default function App() {
|
|||||||
setDecryptedFolders([]);
|
setDecryptedFolders([]);
|
||||||
setDecryptedCiphers([]);
|
setDecryptedCiphers([]);
|
||||||
setDecryptedSends([]);
|
setDecryptedSends([]);
|
||||||
|
clearPasswordSecurityCache();
|
||||||
setUnlockPassword('');
|
setUnlockPassword('');
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
setPendingTotpMode(null);
|
setPendingTotpMode(null);
|
||||||
@@ -890,6 +900,7 @@ export default function App() {
|
|||||||
setSession(null);
|
setSession(null);
|
||||||
clearProfileSnapshot();
|
clearProfileSnapshot();
|
||||||
clearOfflineUnlockRecord();
|
clearOfflineUnlockRecord();
|
||||||
|
clearPasswordSecurityCache();
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
setUnlockPreparing(false);
|
setUnlockPreparing(false);
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
@@ -1165,7 +1176,6 @@ export default function App() {
|
|||||||
const key = await encryptSessionUserKeyForAuthRequest(session, authRequest);
|
const key = await encryptSessionUserKeyForAuthRequest(session, authRequest);
|
||||||
await respondToAuthRequest(authedFetch, authRequest.id, {
|
await respondToAuthRequest(authedFetch, authRequest.id, {
|
||||||
key,
|
key,
|
||||||
masterPasswordHash: null,
|
|
||||||
deviceIdentifier: getCurrentDeviceIdentifier(),
|
deviceIdentifier: getCurrentDeviceIdentifier(),
|
||||||
requestApproved: true,
|
requestApproved: true,
|
||||||
});
|
});
|
||||||
@@ -1846,6 +1856,8 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
const adminActions = useAdminActions({
|
const adminActions = useAdminActions({
|
||||||
authedFetch,
|
authedFetch,
|
||||||
|
email: String(profile?.email || session?.email || ''),
|
||||||
|
defaultKdfIterations,
|
||||||
onNotify: pushToast,
|
onNotify: pushToast,
|
||||||
onSetConfirm: setConfirm,
|
onSetConfirm: setConfirm,
|
||||||
refetchUsers: usersQuery.refetch,
|
refetchUsers: usersQuery.refetch,
|
||||||
@@ -1862,7 +1874,6 @@ export default function App() {
|
|||||||
await pendingAuthRequestsQuery.refetch();
|
await pendingAuthRequestsQuery.refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
|
|
||||||
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
|
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
|
||||||
const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0];
|
const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0];
|
||||||
const trimmedHashPath = hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, '');
|
const trimmedHashPath = hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||||
@@ -1901,13 +1912,17 @@ export default function App() {
|
|||||||
const mobilePrimaryRoute =
|
const mobilePrimaryRoute =
|
||||||
location === '/sends'
|
location === '/sends'
|
||||||
? '/sends'
|
? '/sends'
|
||||||
|
: location === '/generator'
|
||||||
|
? '/generator'
|
||||||
: location === '/vault/totp'
|
: location === '/vault/totp'
|
||||||
? '/vault/totp'
|
? '/vault/totp'
|
||||||
: location === '/vault'
|
: location === '/vault'
|
||||||
? '/vault'
|
? '/vault'
|
||||||
: '/settings';
|
: '/settings';
|
||||||
const currentPageTitle = (() => {
|
const currentPageTitle = (() => {
|
||||||
|
if (location === '/security/password-health') return t('txt_password_security');
|
||||||
if (location === '/vault/totp') return t('txt_verification_code');
|
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 === '/sends') return t('nav_sends');
|
||||||
if (location === '/admin') return t('nav_admin_panel');
|
if (location === '/admin') return t('nav_admin_panel');
|
||||||
if (location === '/logs') return t('nav_log_center');
|
if (location === '/logs') return t('nav_log_center');
|
||||||
@@ -2120,8 +2135,14 @@ export default function App() {
|
|||||||
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress);
|
return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress);
|
||||||
},
|
},
|
||||||
onInspectRemoteBackup: backupActions.inspectRemoteBackup,
|
onInspectRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => {
|
||||||
onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
|
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) => {
|
onRestoreRemoteBackup: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
|
||||||
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
return backupActions.restoreRemoteBackup(hash, destinationId, path, replaceExisting);
|
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 type { ComponentChildren } from 'preact';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { Link } from 'wouter';
|
import { Link } from 'wouter';
|
||||||
@@ -55,11 +55,12 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
const isDomainRulesRoute = props.location === '/settings/domain-rules';
|
const isDomainRulesRoute = props.location === '/settings/domain-rules';
|
||||||
const isLogRoute = props.location === '/logs';
|
const isLogRoute = props.location === '/logs';
|
||||||
const isAdmin = isAdminProfile(props.profile);
|
const isAdmin = isAdminProfile(props.profile);
|
||||||
const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
|
const vaultActive = props.location === '/vault' || props.location === '/vault/totp' || props.location === '/security/password-health';
|
||||||
const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules';
|
|
||||||
const dataActive = props.location === '/backup' || props.isImportRoute;
|
|
||||||
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
|
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 [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode);
|
||||||
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
|
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
|
||||||
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
|
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -174,14 +175,15 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
<>
|
<>
|
||||||
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
|
{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('/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('/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', flatSettingsActive, <SettingsIcon size={16} />, t('txt_settings'))}
|
||||||
{renderSideLink('/settings/domain-rules', props.location === '/settings/domain-rules', <Globe2 size={16} />, t('nav_domain_rules'))}
|
{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'))}
|
{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'))}
|
{renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))}
|
||||||
{isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
|
{isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
|
||||||
{isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))}
|
{isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))}
|
||||||
{renderSideLink(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', props.location === '/vault', t('nav_vault_items'))}
|
||||||
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
|
{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'))}
|
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
|
||||||
{renderNavGroup(
|
{renderNavGroup(
|
||||||
'settings',
|
'settings',
|
||||||
@@ -206,6 +210,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
<>
|
<>
|
||||||
{renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))}
|
{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('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))}
|
||||||
|
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{renderNavGroup(
|
{renderNavGroup(
|
||||||
@@ -226,7 +231,6 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
<>
|
<>
|
||||||
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
|
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
|
||||||
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
|
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
|
||||||
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -327,6 +331,10 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
|||||||
<Clock3 size={18} />
|
<Clock3 size={18} />
|
||||||
<span>{t('txt_verification_code')}</span>
|
<span>{t('txt_verification_code')}</span>
|
||||||
</Link>
|
</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' : ''}`}>
|
<Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}>
|
||||||
<SendIcon size={18} />
|
<SendIcon size={18} />
|
||||||
<span>{t('nav_sends')}</span>
|
<span>{t('nav_sends')}</span>
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export interface AppConfirmState {
|
|||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
hideCancel?: boolean;
|
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;
|
onCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +65,7 @@ function twoFactorProviderLabel(providerType: number): string {
|
|||||||
|
|
||||||
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||||
const [methodChooserOpen, setMethodChooserOpen] = useState(false);
|
const [methodChooserOpen, setMethodChooserOpen] = useState(false);
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const availableProviders = useMemo(
|
const availableProviders = useMemo(
|
||||||
() => uniqueSupportedProviders(props.pendingTotpAvailableProviders),
|
() => uniqueSupportedProviders(props.pendingTotpAvailableProviders),
|
||||||
[props.pendingTotpAvailableProviders]
|
[props.pendingTotpAvailableProviders]
|
||||||
@@ -70,11 +73,16 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
|||||||
const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType);
|
const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType);
|
||||||
const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY;
|
const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY;
|
||||||
const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN;
|
const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN;
|
||||||
|
const requireMasterPassword = !!props.confirm?.requireMasterPassword;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMethodChooserOpen(false);
|
setMethodChooserOpen(false);
|
||||||
}, [props.pendingTotpOpen, props.pendingTotpProviderType]);
|
}, [props.pendingTotpOpen, props.pendingTotpProviderType]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setConfirmPassword('');
|
||||||
|
}, [props.confirm?.title, props.confirm?.message, requireMasterPassword]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -86,9 +94,30 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
|||||||
confirmText={props.confirm?.confirmText}
|
confirmText={props.confirm?.confirmText}
|
||||||
cancelText={props.confirm?.cancelText}
|
cancelText={props.confirm?.cancelText}
|
||||||
hideCancel={props.confirm?.hideCancel}
|
hideCancel={props.confirm?.hideCancel}
|
||||||
onConfirm={() => props.confirm?.onConfirm()}
|
confirmDisabled={requireMasterPassword && !confirmPassword.trim()}
|
||||||
onCancel={props.confirm?.onCancel || props.onCancelConfirm}
|
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
|
<ConfirmDialog
|
||||||
open={props.pendingTotpOpen}
|
open={props.pendingTotpOpen}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { lazy, Suspense } from 'preact/compat';
|
import { lazy, Suspense } from 'preact/compat';
|
||||||
import { useEffect } from 'preact/hooks';
|
import { useEffect } from 'preact/hooks';
|
||||||
import { Link, Route, Switch } from 'wouter';
|
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 type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
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 VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||||
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
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 TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
||||||
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
||||||
const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage'));
|
const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage'));
|
||||||
@@ -168,8 +170,8 @@ export interface AppMainRoutesProps {
|
|||||||
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||||
onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
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 } }>;
|
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: (destinationId: string, path: string) => Promise<void>;
|
onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
|
||||||
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onRestoreRemoteBackupAllowingChecksumMismatch: (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 (
|
return (
|
||||||
<Switch>
|
<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">
|
<Route path="/sends">
|
||||||
<Suspense fallback={<RouteContentFallback />}>
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
<SendsPage
|
<SendsPage
|
||||||
@@ -320,12 +332,18 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
</Route>
|
</Route>
|
||||||
<Route path="/settings">
|
<Route path="/settings">
|
||||||
{props.profile ? (
|
{props.profile ? (
|
||||||
<section className="card mobile-settings-card">
|
<section className="card mobile-settings-card settings-home-card">
|
||||||
|
<div className="settings-home-section">
|
||||||
|
<h3>{t('txt_settings')}</h3>
|
||||||
<div className="mobile-settings-links">
|
<div className="mobile-settings-links">
|
||||||
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
|
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
|
||||||
<SettingsIcon size={18} />
|
<SettingsIcon size={18} />
|
||||||
<span>{t('nav_account_settings')}</span>
|
<span>{t('nav_account_settings')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/security/password-health" className="mobile-settings-link">
|
||||||
|
<ShieldCheck size={18} />
|
||||||
|
<span>{t('nav_password_security')}</span>
|
||||||
|
</Link>
|
||||||
<Link href="/settings/security/device-management" className="mobile-settings-link">
|
<Link href="/settings/security/device-management" className="mobile-settings-link">
|
||||||
<Shield size={18} />
|
<Shield size={18} />
|
||||||
<span>{t('nav_device_management')}</span>
|
<span>{t('nav_device_management')}</span>
|
||||||
@@ -334,22 +352,15 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
<Globe2 size={18} />
|
<Globe2 size={18} />
|
||||||
<span>{t('nav_domain_rules')}</span>
|
<span>{t('nav_domain_rules')}</span>
|
||||||
</Link>
|
</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">
|
<Link href={props.importRoute} className="mobile-settings-link">
|
||||||
<ArrowUpDown size={18} />
|
<ArrowUpDown size={18} />
|
||||||
<span>{t('nav_import_export')}</span>
|
<span>{t('nav_import_export')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
{isAdmin && (
|
|
||||||
<Link href="/admin" className="mobile-settings-link">
|
|
||||||
<ShieldUser size={18} />
|
|
||||||
<span>{t('nav_admin_panel')}</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
|
||||||
<Link href="/logs" className="mobile-settings-link">
|
|
||||||
<FileClock size={18} />
|
|
||||||
<span>{t('nav_log_center')}</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Link href="/backup" className="mobile-settings-link">
|
<Link href="/backup" className="mobile-settings-link">
|
||||||
<Cloud size={18} />
|
<Cloud size={18} />
|
||||||
@@ -357,6 +368,23 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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}>
|
<button type="button" className="btn btn-secondary mobile-settings-logout" onClick={props.onLogout}>
|
||||||
<LogOut size={14} className="btn-icon" />
|
<LogOut size={14} className="btn-icon" />
|
||||||
{t('txt_sign_out')}
|
{t('txt_sign_out')}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
import { ArrowLeft, Eye, EyeOff, KeyRound, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
import { AlertTriangle, ArrowLeft, Eye, EyeOff, KeyRound, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
||||||
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
|
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
|
||||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
import { getCurrentNetworkStatus, subscribeNetworkStatus, type NetworkStatus } from '@/lib/network-status';
|
||||||
|
|
||||||
interface LoginValues {
|
interface LoginValues {
|
||||||
email: string;
|
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) {
|
export default function AuthViews(props: AuthViewsProps) {
|
||||||
const loginBusy = props.pendingAction === 'login';
|
const loginBusy = props.pendingAction === 'login';
|
||||||
const passkeyBusy = props.pendingAction === 'passkey';
|
const passkeyBusy = props.pendingAction === 'passkey';
|
||||||
@@ -99,6 +130,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
props.onSubmitUnlock();
|
props.onSubmitUnlock();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<OfflineModeNotice />
|
||||||
<p className="muted standalone-muted">{props.emailForLock}</p>
|
<p className="muted standalone-muted">{props.emailForLock}</p>
|
||||||
<input type="text" value={props.emailForLock} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
|
<input type="text" value={props.emailForLock} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
|
||||||
<PasswordField
|
<PasswordField
|
||||||
@@ -245,6 +277,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
props.onSubmitLogin();
|
props.onSubmitLogin();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<OfflineModeNotice />
|
||||||
{passkeyPasswordPending ? (
|
{passkeyPasswordPending ? (
|
||||||
<>
|
<>
|
||||||
<p className="muted standalone-muted">{props.pendingPasskeyPasswordEmail}</p>
|
<p className="muted standalone-muted">{props.pendingPasskeyPasswordEmail}</p>
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ interface BackupCenterPageProps {
|
|||||||
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||||
onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
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 }>;
|
onInspectRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
|
||||||
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
|
||||||
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onRestoreRemoteBackupAllowingChecksumMismatch: (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;
|
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||||
@@ -60,6 +60,7 @@ type PendingBackupVerification =
|
|||||||
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
|
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
|
||||||
| { action: 'runRemoteBackup' }
|
| { action: 'runRemoteBackup' }
|
||||||
| { action: 'downloadRemote'; path: string }
|
| { action: 'downloadRemote'; path: string }
|
||||||
|
| { action: 'deleteRemote'; destinationId: string; path: string }
|
||||||
| { action: 'restoreRemote'; path: string; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult };
|
| { action: 'restoreRemote'; path: string; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult };
|
||||||
|
|
||||||
interface BackupProgressPhase {
|
interface BackupProgressPhase {
|
||||||
@@ -204,6 +205,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
|
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
|
||||||
const [pendingBackupVerification, setPendingBackupVerification] = useState<PendingBackupVerification | null>(null);
|
const [pendingBackupVerification, setPendingBackupVerification] = useState<PendingBackupVerification | null>(null);
|
||||||
const [backupPasswordValue, setBackupPasswordValue] = useState('');
|
const [backupPasswordValue, setBackupPasswordValue] = useState('');
|
||||||
|
const [backupPasswordError, setBackupPasswordError] = useState('');
|
||||||
const [backupPasswordSubmitting, setBackupPasswordSubmitting] = useState(false);
|
const [backupPasswordSubmitting, setBackupPasswordSubmitting] = useState(false);
|
||||||
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
|
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
|
||||||
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
|
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
|
||||||
@@ -247,10 +249,28 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
? t('txt_backup_run_manual')
|
? t('txt_backup_run_manual')
|
||||||
: pendingBackupVerification?.action === 'downloadRemote'
|
: pendingBackupVerification?.action === 'downloadRemote'
|
||||||
? t('txt_backup_remote_download')
|
? t('txt_backup_remote_download')
|
||||||
|
: pendingBackupVerification?.action === 'deleteRemote'
|
||||||
|
? t('txt_delete')
|
||||||
: pendingBackupVerification?.action === 'restoreRemote'
|
: pendingBackupVerification?.action === 'restoreRemote'
|
||||||
? t('txt_backup_import')
|
? t('txt_backup_import')
|
||||||
: 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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoadingSettings(true);
|
setLoadingSettings(true);
|
||||||
@@ -472,8 +492,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
return verifyBackupFileIntegrity(bytes, file.name || '');
|
return verifyBackupFileIntegrity(bytes, file.name || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function inspectRemoteBackupFile(destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
|
async function inspectRemoteBackupFile(masterPassword: string, destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
|
||||||
const payload = await props.onInspectRemoteBackup(destinationId, path);
|
const payload = await props.onInspectRemoteBackup(masterPassword, destinationId, path);
|
||||||
return {
|
return {
|
||||||
source: 'remote',
|
source: 'remote',
|
||||||
path,
|
path,
|
||||||
@@ -502,12 +522,11 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete),
|
destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete),
|
||||||
};
|
};
|
||||||
|
|
||||||
setPendingBackupVerification({ action: 'deleteDestination', destinationId: destinationIdToDelete, settings: nextSettings });
|
openBackupPasswordPrompt({ action: 'deleteDestination', destinationId: destinationIdToDelete, settings: nextSettings });
|
||||||
setBackupPasswordValue('');
|
|
||||||
setConfirmDeleteDestinationOpen(false);
|
setConfirmDeleteDestinationOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeDeleteDestination(masterPassword: string, destinationIdToDelete: string, payload: AdminBackupSettings) {
|
async function executeDeleteDestination(masterPassword: string, destinationIdToDelete: string, payload: AdminBackupSettings): Promise<boolean> {
|
||||||
setSavingSettings(true);
|
setSavingSettings(true);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
@@ -527,10 +546,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
setSelectedDestinationId(nextSelected);
|
setSelectedDestinationId(nextSelected);
|
||||||
setConfirmDeleteDestinationOpen(false);
|
setConfirmDeleteDestinationOpen(false);
|
||||||
props.onNotify('success', t('txt_backup_destination_deleted'));
|
props.onNotify('success', t('txt_backup_destination_deleted'));
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed');
|
showActionError(error, t('txt_backup_settings_save_failed'));
|
||||||
setLocalError(message);
|
return false;
|
||||||
props.onNotify('error', message);
|
|
||||||
} finally {
|
} finally {
|
||||||
setSavingSettings(false);
|
setSavingSettings(false);
|
||||||
}
|
}
|
||||||
@@ -538,22 +557,21 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
|
|
||||||
async function handleExport() {
|
async function handleExport() {
|
||||||
if (exporting) return;
|
if (exporting) return;
|
||||||
setPendingBackupVerification({ action: 'export' });
|
openBackupPasswordPrompt({ action: 'export' });
|
||||||
setBackupPasswordValue('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeExport(masterPassword: string) {
|
async function executeExport(masterPassword: string): Promise<boolean> {
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
|
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
|
||||||
await props.onExport(masterPassword, exportIncludeAttachments);
|
await props.onExport(masterPassword, exportIncludeAttachments);
|
||||||
props.onNotify('success', t('txt_backup_export_success'));
|
props.onNotify('success', t('txt_backup_export_success'));
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
|
showActionError(error, t('txt_backup_export_failed'));
|
||||||
setLocalError(message);
|
|
||||||
props.onNotify('error', message);
|
|
||||||
window.setTimeout(() => clearRestoreProgress(), 1200);
|
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
@@ -571,13 +589,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
props.onNotify('error', message);
|
props.onNotify('error', message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPendingBackupVerification({
|
openBackupPasswordPrompt({
|
||||||
action: 'import',
|
action: 'import',
|
||||||
replaceExisting,
|
replaceExisting,
|
||||||
allowChecksumMismatch,
|
allowChecksumMismatch,
|
||||||
knownIntegrity,
|
knownIntegrity,
|
||||||
});
|
});
|
||||||
setBackupPasswordValue('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeLocalRestore(
|
async function executeLocalRestore(
|
||||||
@@ -585,13 +602,14 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
replaceExisting: boolean,
|
replaceExisting: boolean,
|
||||||
allowChecksumMismatch: boolean = false,
|
allowChecksumMismatch: boolean = false,
|
||||||
knownIntegrity?: BackupFileIntegrityCheckResult
|
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||||
) {
|
): Promise<boolean> {
|
||||||
if (importing) return;
|
if (importing) return false;
|
||||||
if (!selectedFile) {
|
if (!selectedFile) {
|
||||||
const message = t('txt_backup_file_required');
|
const message = t('txt_backup_file_required');
|
||||||
setLocalError(message);
|
setLocalError(message);
|
||||||
|
setBackupPasswordError(message);
|
||||||
props.onNotify('error', message);
|
props.onNotify('error', message);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
setConfirmLocalRestoreOpen(false);
|
setConfirmLocalRestoreOpen(false);
|
||||||
@@ -614,17 +632,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
setConfirmLocalRestoreOpen(false);
|
setConfirmLocalRestoreOpen(false);
|
||||||
setConfirmReplaceOpen(false);
|
setConfirmReplaceOpen(false);
|
||||||
resetPendingIntegrityWarning();
|
resetPendingIntegrityWarning();
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!replaceExisting && isReplaceRequiredError(error)) {
|
if (!replaceExisting && isReplaceRequiredError(error)) {
|
||||||
clearRestoreProgress();
|
clearRestoreProgress();
|
||||||
setConfirmLocalRestoreOpen(false);
|
setConfirmLocalRestoreOpen(false);
|
||||||
setConfirmReplaceOpen(true);
|
setConfirmReplaceOpen(true);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_restore_failed');
|
showActionError(error, t('txt_backup_restore_failed'));
|
||||||
setLocalError(message);
|
|
||||||
props.onNotify('error', message);
|
|
||||||
window.setTimeout(() => clearRestoreProgress(), 1200);
|
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
}
|
}
|
||||||
@@ -632,11 +650,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
|
|
||||||
async function handleSaveSettings() {
|
async function handleSaveSettings() {
|
||||||
if (savingSettings) return;
|
if (savingSettings) return;
|
||||||
setPendingBackupVerification({ action: 'saveSettings' });
|
openBackupPasswordPrompt({ action: 'saveSettings' });
|
||||||
setBackupPasswordValue('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeSaveSettings(masterPassword: string) {
|
async function executeSaveSettings(masterPassword: string): Promise<boolean> {
|
||||||
const payload = buildSettingsPayloadForSelectedDestination();
|
const payload = buildSettingsPayloadForSelectedDestination();
|
||||||
const destinationIdToInvalidate = selectedDestinationId;
|
const destinationIdToInvalidate = selectedDestinationId;
|
||||||
setSavingSettings(true);
|
setSavingSettings(true);
|
||||||
@@ -656,10 +673,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
}
|
}
|
||||||
setSelectedDestinationId(nextSelected);
|
setSelectedDestinationId(nextSelected);
|
||||||
props.onNotify('success', t('txt_backup_settings_saved'));
|
props.onNotify('success', t('txt_backup_settings_saved'));
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed');
|
showActionError(error, t('txt_backup_settings_save_failed'));
|
||||||
setLocalError(message);
|
return false;
|
||||||
props.onNotify('error', message);
|
|
||||||
} finally {
|
} finally {
|
||||||
setSavingSettings(false);
|
setSavingSettings(false);
|
||||||
}
|
}
|
||||||
@@ -678,12 +695,11 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
|
|
||||||
async function handleRunRemoteBackup() {
|
async function handleRunRemoteBackup() {
|
||||||
if (!selectedDestination || runningRemoteBackup) return;
|
if (!selectedDestination || runningRemoteBackup) return;
|
||||||
setPendingBackupVerification({ action: 'runRemoteBackup' });
|
openBackupPasswordPrompt({ action: 'runRemoteBackup' });
|
||||||
setBackupPasswordValue('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeRunRemoteBackup(masterPassword: string) {
|
async function executeRunRemoteBackup(masterPassword: string): Promise<boolean> {
|
||||||
if (!selectedDestination) return;
|
if (!selectedDestination) return false;
|
||||||
setRunningRemoteBackup(true);
|
setRunningRemoteBackup(true);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
@@ -697,32 +713,31 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
setSelectedDestinationId(selectedDestination.id);
|
setSelectedDestinationId(selectedDestination.id);
|
||||||
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
|
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
|
||||||
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName }));
|
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName }));
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
|
showActionError(error, t('txt_backup_remote_run_failed'));
|
||||||
setLocalError(message);
|
|
||||||
props.onNotify('error', message);
|
|
||||||
window.setTimeout(() => clearRestoreProgress(), 1200);
|
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setRunningRemoteBackup(false);
|
setRunningRemoteBackup(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDownloadRemote(path: string) {
|
async function handleDownloadRemote(path: string) {
|
||||||
setPendingBackupVerification({ action: 'downloadRemote', path });
|
openBackupPasswordPrompt({ action: 'downloadRemote', path });
|
||||||
setBackupPasswordValue('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeDownloadRemote(masterPassword: string, path: string) {
|
async function executeDownloadRemote(masterPassword: string, path: string): Promise<boolean> {
|
||||||
if (!savedSelectedDestination) return;
|
if (!savedSelectedDestination) return false;
|
||||||
setDownloadingRemotePath(path);
|
setDownloadingRemotePath(path);
|
||||||
setDownloadingRemotePercent(null);
|
setDownloadingRemotePercent(null);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
await props.onDownloadRemoteBackup(masterPassword, savedSelectedDestination.id, path, setDownloadingRemotePercent);
|
await props.onDownloadRemoteBackup(masterPassword, savedSelectedDestination.id, path, setDownloadingRemotePercent);
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed');
|
showActionError(error, t('txt_backup_remote_download_failed'));
|
||||||
setLocalError(message);
|
return false;
|
||||||
props.onNotify('error', message);
|
|
||||||
} finally {
|
} finally {
|
||||||
setDownloadingRemotePath('');
|
setDownloadingRemotePath('');
|
||||||
setDownloadingRemotePercent(null);
|
setDownloadingRemotePercent(null);
|
||||||
@@ -732,18 +747,24 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
async function handleDeleteRemote(path: string) {
|
async function handleDeleteRemote(path: string) {
|
||||||
if (deletingRemotePath) return;
|
if (deletingRemotePath) return;
|
||||||
if (!savedSelectedDestination) 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);
|
setDeletingRemotePath(path);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
await props.onDeleteRemoteBackup(savedSelectedDestination.id, path);
|
await props.onDeleteRemoteBackup(masterPassword, destinationId, path);
|
||||||
setConfirmRemoteDeleteOpen(false);
|
setConfirmRemoteDeleteOpen(false);
|
||||||
setPendingRemoteDeletePath('');
|
setPendingRemoteDeletePath('');
|
||||||
await loadRemoteBrowser(savedSelectedDestination.id, currentRemoteBrowserPath, { force: true });
|
await loadRemoteBrowser(destinationId, remoteBrowserPathByDestination[destinationId] || '', { force: true });
|
||||||
props.onNotify('success', t('txt_backup_remote_delete_success'));
|
props.onNotify('success', t('txt_backup_remote_delete_success'));
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_delete_failed');
|
showActionError(error, t('txt_backup_remote_delete_failed'));
|
||||||
setLocalError(message);
|
return false;
|
||||||
props.onNotify('error', message);
|
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingRemotePath('');
|
setDeletingRemotePath('');
|
||||||
}
|
}
|
||||||
@@ -779,19 +800,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
if (!savedSelectedDestination) return;
|
if (!savedSelectedDestination) return;
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
resetPendingIntegrityWarning();
|
resetPendingIntegrityWarning();
|
||||||
try {
|
await runRemoteRestore(path, false);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runRemoteRestore(
|
async function runRemoteRestore(
|
||||||
@@ -802,14 +811,13 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
) {
|
) {
|
||||||
if (restoringRemotePath) return;
|
if (restoringRemotePath) return;
|
||||||
if (!savedSelectedDestination) return;
|
if (!savedSelectedDestination) return;
|
||||||
setPendingBackupVerification({
|
openBackupPasswordPrompt({
|
||||||
action: 'restoreRemote',
|
action: 'restoreRemote',
|
||||||
path,
|
path,
|
||||||
replaceExisting,
|
replaceExisting,
|
||||||
allowChecksumMismatch,
|
allowChecksumMismatch,
|
||||||
knownIntegrity,
|
knownIntegrity,
|
||||||
});
|
});
|
||||||
setBackupPasswordValue('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeRemoteRestore(
|
async function executeRemoteRestore(
|
||||||
@@ -818,15 +826,31 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
replaceExisting: boolean,
|
replaceExisting: boolean,
|
||||||
allowChecksumMismatch: boolean = false,
|
allowChecksumMismatch: boolean = false,
|
||||||
knownIntegrity?: BackupFileIntegrityCheckResult
|
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||||
) {
|
): Promise<boolean> {
|
||||||
if (restoringRemotePath) return;
|
if (restoringRemotePath) return false;
|
||||||
if (!savedSelectedDestination) return;
|
if (!savedSelectedDestination) return false;
|
||||||
setConfirmRemoteReplaceOpen(false);
|
setConfirmRemoteReplaceOpen(false);
|
||||||
setConfirmIntegrityWarningOpen(false);
|
setConfirmIntegrityWarningOpen(false);
|
||||||
setRestoringRemotePath(path);
|
setRestoringRemotePath(path);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
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, {
|
startRestoreProgress('backup-restore', path.split('/').pop() || path, {
|
||||||
source: 'remote',
|
source: 'remote',
|
||||||
delayMs: replaceExisting ? 480 : 1400,
|
delayMs: replaceExisting ? 480 : 1400,
|
||||||
@@ -840,17 +864,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
const skippedMessage = buildSkippedImportMessage(result);
|
const skippedMessage = buildSkippedImportMessage(result);
|
||||||
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
||||||
resetPendingIntegrityWarning();
|
resetPendingIntegrityWarning();
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!replaceExisting && isReplaceRequiredError(error)) {
|
if (!replaceExisting && isReplaceRequiredError(error)) {
|
||||||
setPendingRemoteRestorePath(path);
|
setPendingRemoteRestorePath(path);
|
||||||
setConfirmRemoteReplaceOpen(true);
|
setConfirmRemoteReplaceOpen(true);
|
||||||
clearRestoreProgress();
|
clearRestoreProgress();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed');
|
showActionError(error, t('txt_backup_remote_restore_failed'));
|
||||||
setLocalError(message);
|
|
||||||
props.onNotify('error', message);
|
|
||||||
window.setTimeout(() => clearRestoreProgress(), 1200);
|
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setRestoringRemotePath('');
|
setRestoringRemotePath('');
|
||||||
}
|
}
|
||||||
@@ -861,31 +885,38 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
const masterPassword = backupPasswordValue;
|
const masterPassword = backupPasswordValue;
|
||||||
if (!request || backupPasswordSubmitting) return;
|
if (!request || backupPasswordSubmitting) return;
|
||||||
if (!masterPassword.trim()) {
|
if (!masterPassword.trim()) {
|
||||||
props.onNotify('error', t('txt_master_password_is_required'));
|
setBackupPasswordError(t('txt_master_password_is_required'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBackupPasswordSubmitting(true);
|
setBackupPasswordSubmitting(true);
|
||||||
setPendingBackupVerification(null);
|
setBackupPasswordError('');
|
||||||
setBackupPasswordValue('');
|
let succeeded = false;
|
||||||
try {
|
try {
|
||||||
if (request.action === 'export') {
|
if (request.action === 'export') {
|
||||||
await executeExport(masterPassword);
|
succeeded = await executeExport(masterPassword);
|
||||||
} else if (request.action === 'saveSettings') {
|
} else if (request.action === 'saveSettings') {
|
||||||
await executeSaveSettings(masterPassword);
|
succeeded = await executeSaveSettings(masterPassword);
|
||||||
} else if (request.action === 'deleteDestination') {
|
} 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') {
|
} 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') {
|
} else if (request.action === 'runRemoteBackup') {
|
||||||
await executeRunRemoteBackup(masterPassword);
|
succeeded = await executeRunRemoteBackup(masterPassword);
|
||||||
} else if (request.action === 'downloadRemote') {
|
} 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') {
|
} 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 {
|
} finally {
|
||||||
setBackupPasswordSubmitting(false);
|
setBackupPasswordSubmitting(false);
|
||||||
}
|
}
|
||||||
|
if (succeeded) {
|
||||||
|
setPendingBackupVerification(null);
|
||||||
|
setBackupPasswordValue('');
|
||||||
|
setBackupPasswordError('');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1031,17 +1062,27 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
if (backupPasswordSubmitting) return;
|
if (backupPasswordSubmitting) return;
|
||||||
setPendingBackupVerification(null);
|
setPendingBackupVerification(null);
|
||||||
setBackupPasswordValue('');
|
setBackupPasswordValue('');
|
||||||
|
setBackupPasswordError('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_master_password')}</span>
|
<span>{t('txt_master_password')}</span>
|
||||||
<input
|
<input
|
||||||
|
id="backup-master-password"
|
||||||
className="input"
|
className="input"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
value={backupPasswordValue}
|
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>
|
</label>
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
const cardRef = useRef<HTMLFormElement | null>(null);
|
const cardRef = useRef<HTMLFormElement | null>(null);
|
||||||
const maskPointerStartedRef = useRef(false);
|
const maskPointerStartedRef = useRef(false);
|
||||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||||
|
const lastTitleRef = useRef<ComponentChildren>(props.title);
|
||||||
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
||||||
const titleId = `${dialogId}-title`;
|
const titleId = `${dialogId}-title`;
|
||||||
const messageId = `${dialogId}-message`;
|
const messageId = `${dialogId}-message`;
|
||||||
@@ -95,6 +96,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.open) {
|
if (props.open) {
|
||||||
|
lastTitleRef.current = props.title;
|
||||||
setPresent(true);
|
setPresent(true);
|
||||||
setClosing(false);
|
setClosing(false);
|
||||||
return;
|
return;
|
||||||
@@ -228,7 +230,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</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>}
|
{hasMessage && <div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>}
|
||||||
{props.children}
|
{props.children}
|
||||||
{!props.hideConfirm && (
|
{!props.hideConfirm && (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
||||||
import { createPortal } from 'preact/compat';
|
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 { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
|
||||||
import { Download, FileUp } from 'lucide-preact';
|
import { Download, FileUp } from 'lucide-preact';
|
||||||
import ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog';
|
import ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||||
@@ -96,6 +96,12 @@ const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [
|
|||||||
'keepassx_csv',
|
'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> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === 'object';
|
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;
|
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 {
|
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);
|
const fileNames = Object.keys(unzipped);
|
||||||
if (!fileNames.length) throw new Error(t('txt_import_empty_zip_archive'));
|
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> {
|
async function readImportText(file: File, source: ImportSourceId): Promise<string> {
|
||||||
if (source !== 'onepassword_1pux' && source !== 'protonpass_json') {
|
if (source !== 'onepassword_1pux' && source !== 'protonpass_json') {
|
||||||
|
assertImportTextFileSize(file.size);
|
||||||
return file.text();
|
return file.text();
|
||||||
}
|
}
|
||||||
|
assertImportZipSize(file.size);
|
||||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
if (isZipPayload(bytes)) return readZipText(bytes, source);
|
if (isZipPayload(bytes)) return readZipText(bytes, source);
|
||||||
|
assertImportTextFileSize(bytes.byteLength);
|
||||||
return new TextDecoder().decode(bytes);
|
return new TextDecoder().decode(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,34 +297,77 @@ function looksLikeZipPasswordError(error: unknown): boolean {
|
|||||||
return message.includes('password') || message.includes('encrypted');
|
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(
|
async function readBitwardenZipPayload(
|
||||||
file: File,
|
file: File,
|
||||||
passwordRaw: string
|
passwordRaw: string
|
||||||
): Promise<{ jsonText: string; attachments: ImportAttachmentFile[] }> {
|
): Promise<{ jsonText: string; attachments: ImportAttachmentFile[] }> {
|
||||||
const password = String(passwordRaw || '').trim();
|
const password = String(passwordRaw || '').trim();
|
||||||
|
assertImportZipSize(file.size);
|
||||||
const reader = new ZipReader(new BlobReader(file), { useWebWorkers: false });
|
const reader = new ZipReader(new BlobReader(file), { useWebWorkers: false });
|
||||||
try {
|
try {
|
||||||
const entries = await reader.getEntries();
|
const entries = await reader.getEntries();
|
||||||
if (!entries.length) throw new Error(t('txt_import_empty_zip_archive'));
|
if (!entries.length) throw new Error(t('txt_import_empty_zip_archive'));
|
||||||
|
validateBitwardenZipEntries(entries);
|
||||||
|
|
||||||
let jsonText = '';
|
let jsonText = '';
|
||||||
|
let totalAttachmentBytes = 0;
|
||||||
const attachments: ImportAttachmentFile[] = [];
|
const attachments: ImportAttachmentFile[] = [];
|
||||||
const options = password ? { password } : undefined;
|
const options = password ? { password } : undefined;
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.directory) continue;
|
if (entry.directory) continue;
|
||||||
const name = String(entry.filename || '').trim().replace(/\\/g, '/');
|
const name = zipEntryName(entry.filename);
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
|
assertSafeZipEntryName(name);
|
||||||
|
|
||||||
const bytes = await entry.getData(new Uint8ArrayWriter(), options);
|
const bytes = await entry.getData(new Uint8ArrayWriter(), options);
|
||||||
const lower = name.toLowerCase();
|
const lower = name.toLowerCase();
|
||||||
if (lower === 'data.json') {
|
if (lower === 'data.json') {
|
||||||
|
assertImportEntrySize(bytes.byteLength, MAX_IMPORT_TEXT_ENTRY_BYTES);
|
||||||
jsonText = new TextDecoder().decode(bytes);
|
jsonText = new TextDecoder().decode(bytes);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachmentMatch = name.match(/^attachments\/([^/]+)\/(.+)$/i);
|
const attachmentMatch = bitwardenZipAttachmentMatch(name);
|
||||||
if (!attachmentMatch) continue;
|
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 sourceCipherId = String(attachmentMatch[1] || '').trim() || null;
|
||||||
const fileName = String(attachmentMatch[2] || '').trim() || 'attachment.bin';
|
const fileName = String(attachmentMatch[2] || '').trim() || 'attachment.bin';
|
||||||
attachments.push({
|
attachments.push({
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ function formatReason(reason: string): string {
|
|||||||
return translatedOrHumanized(keyFor('txt_log_reason_', reason), reason);
|
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 {
|
function formatTime(value: string): string {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
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);
|
return translatedOrHumanized(keyFor('txt_log_trigger_', value), value);
|
||||||
}
|
}
|
||||||
if (key === 'type' && typeof value === 'string') {
|
if (key === 'type' && typeof value === 'string') {
|
||||||
return translatedOrHumanized(keyFor('txt_log_target_type_', value), value);
|
return formatTargetType(value);
|
||||||
}
|
}
|
||||||
return formatMetaValue(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) {
|
function iconForCategory(category: AuditLogCategory) {
|
||||||
if (category === 'auth') return <ShieldAlert size={16} />;
|
if (category === 'auth') return <ShieldAlert size={16} />;
|
||||||
if (category === 'security') return <UserRound 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_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_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_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>
|
||||||
<div className="log-detail-json">
|
<div className="log-detail-json">
|
||||||
<h4>{t('txt_metadata')}</h4>
|
<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>;
|
||||||
|
}
|
||||||
@@ -53,6 +53,13 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|||||||
return value && typeof value === 'object' ? value as 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 {
|
function optionalString(value: unknown): string | null {
|
||||||
return typeof value === 'string' ? value : null;
|
return typeof value === 'string' ? value : null;
|
||||||
}
|
}
|
||||||
@@ -283,7 +290,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
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 { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { Send, SendDraft } from '@/lib/types';
|
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));
|
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 {
|
function buildDefaultDraft(): SendDraft {
|
||||||
return {
|
return {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -43,6 +50,7 @@ function buildDefaultDraft(): SendDraft {
|
|||||||
expirationDays: '0',
|
expirationDays: '0',
|
||||||
maxAccessCount: '',
|
maxAccessCount: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
hasPassword: false,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -59,6 +67,7 @@ function draftFromSend(send: Send): SendDraft {
|
|||||||
expirationDays: daysFromNow(send.expirationDate, 0),
|
expirationDays: daysFromNow(send.expirationDate, 0),
|
||||||
maxAccessCount: send.maxAccessCount !== null && send.maxAccessCount !== undefined ? String(send.maxAccessCount) : '',
|
maxAccessCount: send.maxAccessCount !== null && send.maxAccessCount !== undefined ? String(send.maxAccessCount) : '',
|
||||||
password: '',
|
password: '',
|
||||||
|
hasPassword: !!send.password,
|
||||||
disabled: !!send.disabled,
|
disabled: !!send.disabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -380,6 +389,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
<div className="list-text">
|
<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-title" title={send.decName || t('txt_no_name')}>{send.decName || t('txt_no_name')}</span>
|
||||||
<span className="list-sub">
|
<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 })}
|
{Number(send.type) === 1 ? t('txt_file') : t('txt_text')} - {t('txt_accessed_count_times', { count: send.accessCount || 0 })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -471,12 +481,23 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_password')}</span>
|
<span>{t('txt_password')}</span>
|
||||||
|
{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">
|
<div className="password-wrap">
|
||||||
<input className="input" type={showPassword ? 'text' : 'password'} value={draft.password} onInput={(e) => setDraft({ ...draft, password: (e.currentTarget as HTMLInputElement).value })} />
|
<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)}>
|
<button type="button" className="password-toggle" onClick={() => setShowPassword((v) => !v)}>
|
||||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label className="field field-span-2">
|
<label className="field field-span-2">
|
||||||
<span>{t('txt_notes')}</span>
|
<span>{t('txt_notes')}</span>
|
||||||
@@ -523,8 +544,8 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
<div className="card stagger-item stagger-delay-2">
|
<div className="card stagger-item stagger-delay-2">
|
||||||
<h4>{t('txt_send_details')}</h4>
|
<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_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_deletion_date')}</span><strong>{formatSendDate(selectedSend.deletionDate)}</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_expiration_date')}</span><strong>{formatSendDate(selectedSend.expirationDate)}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
const [accountPasskeys, setAccountPasskeys] = useState<AccountPasskeyCredential[]>([]);
|
const [accountPasskeys, setAccountPasskeys] = useState<AccountPasskeyCredential[]>([]);
|
||||||
const [accountPasskeysLoading, setAccountPasskeysLoading] = useState(false);
|
const [accountPasskeysLoading, setAccountPasskeysLoading] = useState(false);
|
||||||
const [accountPasskeyName, setAccountPasskeyName] = useState(t('txt_account_passkey'));
|
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 [accountPasskeyPromptId, setAccountPasskeyPromptId] = useState<string | null>(null);
|
||||||
const [createPasskeyDialogOpen, setCreatePasskeyDialogOpen] = useState(false);
|
const [createPasskeyDialogOpen, setCreatePasskeyDialogOpen] = useState(false);
|
||||||
const [createPasskeyMasterPassword, setCreatePasskeyMasterPassword] = useState('');
|
const [createPasskeyMasterPassword, setCreatePasskeyMasterPassword] = useState('');
|
||||||
@@ -509,7 +509,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
setCreatePasskeyDialogOpen(false);
|
setCreatePasskeyDialogOpen(false);
|
||||||
setCreatePasskeyMasterPassword('');
|
setCreatePasskeyMasterPassword('');
|
||||||
setAccountPasskeyName(t('txt_account_passkey'));
|
setAccountPasskeyName(t('txt_account_passkey'));
|
||||||
setAccountPasskeyDirectUnlock(false);
|
setAccountPasskeyDirectUnlock(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitCreatePasskeyDialog(): Promise<void> {
|
async function submitCreatePasskeyDialog(): Promise<void> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { Clipboard, Globe } from 'lucide-preact';
|
import { Clipboard, Globe } from 'lucide-preact';
|
||||||
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
|
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 { t } from '@/lib/i18n';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
@@ -14,17 +14,9 @@ interface TotpCodesPageProps {
|
|||||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOTP_PERIOD_SECONDS = 30;
|
|
||||||
const TOTP_RING_RADIUS = 14;
|
const TOTP_RING_RADIUS = 14;
|
||||||
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||||
const TOTP_REFRESH_BATCH_SIZE = 16;
|
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 }) {
|
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
||||||
@@ -32,13 +24,15 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
|||||||
|
|
||||||
interface TotpRowProps {
|
interface TotpRowProps {
|
||||||
cipher: Cipher;
|
cipher: Cipher;
|
||||||
live: { code: string; remain: number } | null;
|
live: TotpCodeResult | null;
|
||||||
onCopy: (value: string) => void;
|
onCopy: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TotpRow(props: TotpRowProps) {
|
function TotpRow(props: TotpRowProps) {
|
||||||
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
|
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
|
||||||
const username = props.cipher.login?.decUsername || '';
|
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 (
|
return (
|
||||||
<div className="totp-code-row">
|
<div className="totp-code-row">
|
||||||
@@ -69,8 +63,7 @@ function TotpRow(props: TotpRowProps) {
|
|||||||
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
||||||
strokeDashoffset: String(
|
strokeDashoffset: String(
|
||||||
TOTP_RING_CIRCUMFERENCE -
|
TOTP_RING_CIRCUMFERENCE -
|
||||||
TOTP_RING_CIRCUMFERENCE *
|
TOTP_RING_CIRCUMFERENCE * progress
|
||||||
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -86,8 +79,7 @@ function TotpRow(props: TotpRowProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TotpCodesPage(props: TotpCodesPageProps) {
|
export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||||
const [totpCodes, setTotpCodes] = useState<Record<string, string | null>>({});
|
const [totpCodes, setTotpCodes] = useState<Record<string, TotpCodeResult | null>>({});
|
||||||
const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain);
|
|
||||||
const [columnCount, setColumnCount] = useState(1);
|
const [columnCount, setColumnCount] = useState(1);
|
||||||
const listRef = useRef<HTMLDivElement | null>(null);
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -120,11 +112,10 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
let stopped = false;
|
let stopped = false;
|
||||||
let activeRun = 0;
|
let activeRun = 0;
|
||||||
let timer = 0;
|
let timer = 0;
|
||||||
let currentWindowId = -1;
|
|
||||||
|
|
||||||
const refreshCodes = async () => {
|
const refreshCodes = async () => {
|
||||||
const runId = ++activeRun;
|
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) {
|
for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) {
|
||||||
if (stopped || runId !== activeRun) return;
|
if (stopped || runId !== activeRun) return;
|
||||||
const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE);
|
const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE);
|
||||||
@@ -132,7 +123,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
batch.map(async (cipher) => {
|
batch.map(async (cipher) => {
|
||||||
try {
|
try {
|
||||||
const next = await calcTotpNow(cipher.login?.decTotp || '');
|
const next = await calcTotpNow(cipher.login?.decTotp || '');
|
||||||
return [cipher.id, next?.code || null] as const;
|
return [cipher.id, next] as const;
|
||||||
} catch {
|
} catch {
|
||||||
return [cipher.id, null] as const;
|
return [cipher.id, null] as const;
|
||||||
}
|
}
|
||||||
@@ -146,15 +137,20 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
if (stopped || runId !== activeRun) return;
|
if (stopped || runId !== activeRun) return;
|
||||||
setTotpCodes((prev) => {
|
setTotpCodes((prev) => {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const next: Record<string, string | null> = { ...prev };
|
const next: Record<string, TotpCodeResult | null> = { ...prev };
|
||||||
for (const id of Object.keys(next)) {
|
for (const id of Object.keys(next)) {
|
||||||
if (id in nextCodes) continue;
|
if (id in nextCodes) continue;
|
||||||
delete next[id];
|
delete next[id];
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
for (const [id, code] of Object.entries(nextCodes)) {
|
for (const [id, live] of Object.entries(nextCodes)) {
|
||||||
if (next[id] === code) continue;
|
const prevLive = next[id];
|
||||||
next[id] = code;
|
if (
|
||||||
|
prevLive?.code === live?.code &&
|
||||||
|
prevLive?.remain === live?.remain &&
|
||||||
|
prevLive?.period === live?.period
|
||||||
|
) continue;
|
||||||
|
next[id] = live;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
return changed ? next : prev;
|
return changed ? next : prev;
|
||||||
@@ -162,10 +158,6 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const next = getTotpTimeState();
|
|
||||||
setRemainingSeconds((prev) => (prev === next.remain ? prev : next.remain));
|
|
||||||
if (next.windowId === currentWindowId) return;
|
|
||||||
currentWindowId = next.windowId;
|
|
||||||
void refreshCodes();
|
void refreshCodes();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -215,7 +207,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
<TotpRow
|
<TotpRow
|
||||||
key={cipher.id}
|
key={cipher.id}
|
||||||
cipher={cipher}
|
cipher={cipher}
|
||||||
live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null}
|
live={totpCodes[cipher.id] || null}
|
||||||
onCopy={(value) => void copyToClipboard(value)}
|
onCopy={(value) => void copyToClipboard(value)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -12,23 +12,26 @@ import {
|
|||||||
cardListSubtitle,
|
cardListSubtitle,
|
||||||
FOLDER_SORT_STORAGE_KEY,
|
FOLDER_SORT_STORAGE_KEY,
|
||||||
VAULT_SORT_STORAGE_KEY,
|
VAULT_SORT_STORAGE_KEY,
|
||||||
|
bankAccountListSubtitle,
|
||||||
cipherTypeKey,
|
cipherTypeKey,
|
||||||
cipherTypeLabel,
|
cipherTypeLabel,
|
||||||
createEmptyDraft,
|
createEmptyDraft,
|
||||||
creationTimeValue,
|
creationTimeValue,
|
||||||
draftFromCipher,
|
draftFromCipher,
|
||||||
|
driversLicenseListSubtitle,
|
||||||
buildCipherDuplicateSignatures,
|
buildCipherDuplicateSignatures,
|
||||||
firstCipherUri,
|
firstCipherUri,
|
||||||
firstPasskeyCreationTime,
|
firstPasskeyCreationTime,
|
||||||
isCipherVisibleInArchive,
|
isCipherVisibleInArchive,
|
||||||
isCipherVisibleInNormalVault,
|
isCipherVisibleInNormalVault,
|
||||||
isCipherVisibleInTrash,
|
isCipherVisibleInTrash,
|
||||||
|
passportListSubtitle,
|
||||||
sortTimeValue,
|
sortTimeValue,
|
||||||
type DuplicateDetectionMode,
|
type DuplicateDetectionMode,
|
||||||
type SidebarFilter,
|
type SidebarFilter,
|
||||||
type VaultSortMode,
|
type VaultSortMode,
|
||||||
} from '@/components/vault/vault-page-helpers';
|
} 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 { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh';
|
||||||
import { ChevronLeft } from 'lucide-preact';
|
import { ChevronLeft } from 'lucide-preact';
|
||||||
import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
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 [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
||||||
const [selectedCipherId, setSelectedCipherId] = useState('');
|
const [selectedCipherId, setSelectedCipherId] = useState('');
|
||||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||||
|
const pendingFocusCipherIdRef = useRef<string | null>(null);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [createMenuOpen, setCreateMenuOpen] = useState(false);
|
const [createMenuOpen, setCreateMenuOpen] = useState(false);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
@@ -106,7 +110,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const [renameFolderName, setRenameFolderName] = useState('');
|
const [renameFolderName, setRenameFolderName] = useState('');
|
||||||
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
|
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
|
||||||
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
|
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 [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({});
|
||||||
const [attachmentQueue, setAttachmentQueue] = useState<File[]>([]);
|
const [attachmentQueue, setAttachmentQueue] = useState<File[]>([]);
|
||||||
const [removedAttachmentIds, setRemovedAttachmentIds] = useState<Record<string, boolean>>({});
|
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 name = String(cipher.decName || cipher.name || '');
|
||||||
const username = String(cipher.login?.decUsername || '');
|
const username = String(cipher.login?.decUsername || '');
|
||||||
const uri = firstCipherUri(cipher);
|
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();
|
const cipherId = String(cipher.id || '').trim();
|
||||||
meta.set(cipher.id, {
|
meta.set(cipher.id, {
|
||||||
name,
|
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,
|
firstUri: uri,
|
||||||
typeKey: cipherTypeKey(Number(cipher.type || 1)),
|
typeKey: cipherTypeKey(Number(cipher.type || 1)),
|
||||||
sortTime: sortTimeValue(cipher),
|
sortTime: sortTimeValue(cipher),
|
||||||
@@ -405,7 +420,36 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
return !!meta?.searchText.includes(searchQuery);
|
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) => {
|
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 metaA = cipherMetaById.get(a.id);
|
||||||
const metaB = cipherMetaById.get(b.id);
|
const metaB = cipherMetaById.get(b.id);
|
||||||
if (sortMode === 'edited') {
|
if (sortMode === 'edited') {
|
||||||
@@ -454,8 +498,59 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
|
if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
|
||||||
}, [sidebarFilter.kind, duplicateMode]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (isCreating) return;
|
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 (!filteredCiphers.length) {
|
||||||
if (selectedCipherId) setSelectedCipherId('');
|
if (selectedCipherId) setSelectedCipherId('');
|
||||||
return;
|
return;
|
||||||
@@ -463,7 +558,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) {
|
if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) {
|
||||||
setSelectedCipherId(filteredCiphers[0].id);
|
setSelectedCipherId(filteredCiphers[0].id);
|
||||||
}
|
}
|
||||||
}, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating]);
|
}, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating, isMobileLayout]);
|
||||||
|
|
||||||
const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]);
|
const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]);
|
||||||
const virtualRange = useMemo(() => {
|
const virtualRange = useMemo(() => {
|
||||||
@@ -542,6 +637,9 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
if (Number(cipher.type || 1) === 3) {
|
if (Number(cipher.type || 1) === 3) {
|
||||||
return cardListSubtitle(cipher);
|
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));
|
return cipherTypeLabel(Number(cipher.type || 1));
|
||||||
}, [cipherMetaById]);
|
}, [cipherMetaById]);
|
||||||
|
|
||||||
@@ -1032,6 +1130,20 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
}
|
}
|
||||||
setSelectedMap(map);
|
setSelectedMap(map);
|
||||||
}, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
|
}, [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 handleSelectAll = useCallback(() => {
|
||||||
const map: Record<string, boolean> = {};
|
const map: Record<string, boolean> = {};
|
||||||
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
||||||
@@ -1146,6 +1258,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
onSyncVault={handleSyncVault}
|
onSyncVault={handleSyncVault}
|
||||||
onOpenBulkDelete={handleOpenBulkDelete}
|
onOpenBulkDelete={handleOpenBulkDelete}
|
||||||
onSelectDuplicates={handleSelectDuplicates}
|
onSelectDuplicates={handleSelectDuplicates}
|
||||||
|
onSelectUniqueFromDuplicates={handleSelectUniqueFromDuplicates}
|
||||||
onSelectAll={handleSelectAll}
|
onSelectAll={handleSelectAll}
|
||||||
onToggleCreateMenu={handleToggleCreateMenu}
|
onToggleCreateMenu={handleToggleCreateMenu}
|
||||||
onStartCreate={startCreate}
|
onStartCreate={startCreate}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { createPortal } from 'preact/compat';
|
import { createPortal } from 'preact/compat';
|
||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
|
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 { 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 type { Cipher } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
CardBrandIcon,
|
CardBrandIcon,
|
||||||
TOTP_PERIOD_SECONDS,
|
|
||||||
TOTP_RING_CIRCUMFERENCE,
|
TOTP_RING_CIRCUMFERENCE,
|
||||||
VaultListIcon,
|
VaultListIcon,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
@@ -21,11 +22,15 @@ import {
|
|||||||
toBooleanFieldValue,
|
toBooleanFieldValue,
|
||||||
} from '@/components/vault/vault-page-helpers';
|
} 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 {
|
interface VaultDetailViewProps {
|
||||||
selectedCipher: Cipher;
|
selectedCipher: Cipher;
|
||||||
repromptApprovedCipherId: string | null;
|
repromptApprovedCipherId: string | null;
|
||||||
showPassword: boolean;
|
showPassword: boolean;
|
||||||
totpLive: { code: string; remain: number } | null;
|
totpLive: TotpCodeResult | null;
|
||||||
passkeyCreatedAt: string | null;
|
passkeyCreatedAt: string | null;
|
||||||
hiddenFieldVisibleMap: Record<number, boolean>;
|
hiddenFieldVisibleMap: Record<number, boolean>;
|
||||||
folderName: (id: string | null | undefined) => string;
|
folderName: (id: string | null | undefined) => string;
|
||||||
@@ -42,6 +47,11 @@ interface VaultDetailViewProps {
|
|||||||
onUnarchive: (cipher: Cipher) => void | Promise<void>;
|
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: {
|
function PasswordHistoryDialog(props: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
entries: Array<{ password: string; lastUsedDate: string | null }>;
|
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 selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
|
||||||
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
|
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
|
||||||
const [passwordHistoryOpen, setPasswordHistoryOpen] = 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 isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
|
||||||
const isDeleted = isCipherDeleted(props.selectedCipher);
|
const isDeleted = isCipherDeleted(props.selectedCipher);
|
||||||
const passwordHistoryEntries = useMemo(
|
const passwordHistoryEntries = useMemo(
|
||||||
@@ -98,9 +111,39 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
[props.selectedCipher.passwordHistory]
|
[props.selectedCipher.passwordHistory]
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
breachControllerRef.current?.abort();
|
||||||
|
breachControllerRef.current = null;
|
||||||
setShowSshPrivateKey(false);
|
setShowSshPrivateKey(false);
|
||||||
setPasswordHistoryOpen(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 formatDownloadLabel = (attachmentId: string) => {
|
||||||
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
|
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
|
||||||
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
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 || '')}>
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.login?.decPassword || '')}>
|
||||||
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||||
</button>
|
</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>
|
||||||
</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 && (
|
{!!props.selectedCipher.login.decTotp && (
|
||||||
<div className="kv-row">
|
<div className="kv-row">
|
||||||
<span className="kv-label">{t('txt_totp')}</span>
|
<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}`,
|
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
||||||
strokeDashoffset: String(
|
strokeDashoffset: String(
|
||||||
TOTP_RING_CIRCUMFERENCE -
|
TOTP_RING_CIRCUMFERENCE -
|
||||||
TOTP_RING_CIRCUMFERENCE *
|
TOTP_RING_CIRCUMFERENCE * totpProgress(props.totpLive)
|
||||||
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.totpLive?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -327,6 +379,55 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
</div>
|
</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() && (
|
{!!(props.selectedCipher.decNotes || '').trim() && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h4>{t('txt_notes')}</h4>
|
<h4>{t('txt_notes')}</h4>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ArrowDown, ArrowUp, CheckCheck, Download, Paperclip, Plus, QrCode, Refr
|
|||||||
import jsQR from 'jsqr';
|
import jsQR from 'jsqr';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||||
|
import { normalizeTotpInput } from '@/lib/crypto';
|
||||||
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { cardBrand } from '@/lib/import-format-shared';
|
import { cardBrand } from '@/lib/import-format-shared';
|
||||||
@@ -67,6 +68,8 @@ interface WebsiteRowProps {
|
|||||||
onRemove: (index: number) => void;
|
onRemove: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TOTP_QR_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
|
||||||
|
|
||||||
function WebsiteRow(props: WebsiteRowProps) {
|
function WebsiteRow(props: WebsiteRowProps) {
|
||||||
const websiteMatchOptions = getWebsiteMatchOptions();
|
const websiteMatchOptions = getWebsiteMatchOptions();
|
||||||
|
|
||||||
@@ -159,9 +162,9 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const applyTotpQrValue = (value: string) => {
|
const applyTotpQrValue = (value: string) => {
|
||||||
const trimmed = value.trim();
|
const normalized = normalizeTotpInput(value);
|
||||||
if (!trimmed) return false;
|
if (!normalized) return false;
|
||||||
props.onUpdateDraft({ loginTotp: trimmed });
|
props.onUpdateDraft({ loginTotp: normalized });
|
||||||
setTotpQrStatus(t('txt_totp_qr_scanned'));
|
setTotpQrStatus(t('txt_totp_qr_scanned'));
|
||||||
setTotpQrOpen(false);
|
setTotpQrOpen(false);
|
||||||
return true;
|
return true;
|
||||||
@@ -208,6 +211,14 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
|
|
||||||
const handleTotpQrFile = async (file: File | null) => {
|
const handleTotpQrFile = async (file: File | null) => {
|
||||||
if (!file) return;
|
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);
|
setTotpQrBusy(true);
|
||||||
setTotpQrStatus(t('txt_totp_qr_scanning'));
|
setTotpQrStatus(t('txt_totp_qr_scanning'));
|
||||||
let bitmap: ImageBitmap | null = null;
|
let bitmap: ImageBitmap | null = null;
|
||||||
@@ -579,6 +590,64 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
</div>
|
</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="card">
|
||||||
<div className="section-head attachment-head">
|
<div className="section-head attachment-head">
|
||||||
<h4>{t('txt_attachments')}</h4>
|
<h4>{t('txt_attachments')}</h4>
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ interface VaultListPanelProps {
|
|||||||
onSyncVault: () => void;
|
onSyncVault: () => void;
|
||||||
onOpenBulkDelete: () => void;
|
onOpenBulkDelete: () => void;
|
||||||
onSelectDuplicates: () => void;
|
onSelectDuplicates: () => void;
|
||||||
|
onSelectUniqueFromDuplicates: () => void;
|
||||||
onSelectAll: () => void;
|
onSelectAll: () => void;
|
||||||
onToggleCreateMenu: () => void;
|
onToggleCreateMenu: () => void;
|
||||||
onStartCreate: (type: number) => void;
|
onStartCreate: (type: number) => void;
|
||||||
@@ -319,13 +320,17 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="search-input-wrap">
|
|
||||||
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
|
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
|
||||||
<div className="duplicate-mode-head-menu">
|
<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)}
|
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
||||||
</div>
|
</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
|
<input
|
||||||
className="search-input"
|
className="search-input"
|
||||||
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
|
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
|
||||||
@@ -350,9 +355,8 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
|
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
|
||||||
<div className="duplicate-mode-head-menu">
|
<div className="duplicate-mode-head-menu">
|
||||||
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
{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}>
|
<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')}
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { RefObject } from 'preact';
|
|||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
|
BookUser,
|
||||||
Check,
|
Check,
|
||||||
Copy,
|
Copy,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
@@ -10,15 +11,19 @@ import {
|
|||||||
FolderPlus,
|
FolderPlus,
|
||||||
FolderX,
|
FolderX,
|
||||||
Globe,
|
Globe,
|
||||||
|
IdCard,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
Landmark,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Pencil,
|
Pencil,
|
||||||
ShieldUser,
|
ShieldUser,
|
||||||
|
ShieldCheck,
|
||||||
Star,
|
Star,
|
||||||
StickyNote,
|
StickyNote,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-preact';
|
} from 'lucide-preact';
|
||||||
|
import { Link } from 'wouter';
|
||||||
import type { Folder } from '@/lib/types';
|
import type { Folder } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { getFolderSortOptions, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers';
|
import { getFolderSortOptions, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers';
|
||||||
@@ -92,6 +97,9 @@ export default function VaultSidebar(props: VaultSidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="sidebar-block">
|
<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' })}>
|
<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>
|
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">{t('txt_all_items')}</span>
|
||||||
</button>
|
</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' })}>
|
<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>
|
<CreditCard size={14} className="tree-icon" /> <span className="tree-label">{t('txt_card')}</span>
|
||||||
</button>
|
</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' })}>
|
<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>
|
<ShieldUser size={14} className="tree-icon" /> <span className="tree-label">{t('txt_identity')}</span>
|
||||||
</button>
|
</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' })}>
|
<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>
|
<StickyNote size={14} className="tree-icon" /> <span className="tree-label">{t('txt_note')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@/lib/website-icon-cache';
|
} from '@/lib/website-icon-cache';
|
||||||
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
|
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
|
||||||
import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status';
|
import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status';
|
||||||
|
import { areWebsiteIconsEnabled } from '@/lib/website-icon-settings';
|
||||||
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
||||||
|
|
||||||
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
||||||
@@ -22,7 +23,8 @@ interface WebsiteIconProps {
|
|||||||
|
|
||||||
export default function WebsiteIcon(props: WebsiteIconProps) {
|
export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||||
const host = useMemo(() => hostFromUri(firstCipherUri(props.cipher)), [props.cipher]);
|
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 nodeRef = useRef<HTMLSpanElement | null>(null);
|
||||||
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
|
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
|
||||||
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
|
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
|
||||||
@@ -33,7 +35,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
useEffect(() => subscribeNetworkStatus(setNetworkStatus), []);
|
useEffect(() => subscribeNetworkStatus(setNetworkStatus), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!host) {
|
if (!host || !iconsEnabled) {
|
||||||
setShouldLoad(true);
|
setShouldLoad(true);
|
||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
setImageUrl('');
|
setImageUrl('');
|
||||||
@@ -47,7 +49,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
setStatus(next);
|
setStatus(next);
|
||||||
setImageUrl(getWebsiteIconImageUrl(host));
|
setImageUrl(getWebsiteIconImageUrl(host));
|
||||||
});
|
});
|
||||||
}, [host]);
|
}, [host, iconsEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!host || shouldLoad || status === 'loaded' || status === 'error') return;
|
if (!host || shouldLoad || status === 'loaded' || status === 'error') return;
|
||||||
@@ -81,10 +83,11 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
|
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
|
||||||
if (demoIconUrl) return;
|
if (demoIconUrl) return;
|
||||||
|
if (!iconsEnabled) return;
|
||||||
if (networkStatus !== 'online') return;
|
if (networkStatus !== 'online') return;
|
||||||
if (!host || !src || !shouldLoad || status !== 'idle') return;
|
if (!host || !src || !shouldLoad || status !== 'idle') return;
|
||||||
beginWebsiteIconLoad(host, src);
|
beginWebsiteIconLoad(host, src);
|
||||||
}, [demoIconUrl, host, networkStatus, src, shouldLoad, status]);
|
}, [demoIconUrl, host, iconsEnabled, networkStatus, src, shouldLoad, status]);
|
||||||
|
|
||||||
if (demoIconUrl) {
|
if (demoIconUrl) {
|
||||||
return (
|
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>;
|
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useMemo } from 'preact/hooks';
|
import { useMemo } from 'preact/hooks';
|
||||||
import {
|
import {
|
||||||
|
BookUser,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FileKey2,
|
FileKey2,
|
||||||
Globe,
|
Globe,
|
||||||
|
IdCard,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
Landmark,
|
||||||
ShieldUser,
|
ShieldUser,
|
||||||
StickyNote,
|
StickyNote,
|
||||||
} from 'lucide-preact';
|
} from 'lucide-preact';
|
||||||
@@ -14,7 +17,7 @@ import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils
|
|||||||
import { normalizeEquivalentDomain } from '@shared/domain-normalize';
|
import { normalizeEquivalentDomain } from '@shared/domain-normalize';
|
||||||
import WebsiteIcon from './WebsiteIcon';
|
import WebsiteIcon from './WebsiteIcon';
|
||||||
|
|
||||||
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh' | 'bank' | 'license' | 'passport';
|
||||||
export type VaultSortMode = 'edited' | 'created' | 'name';
|
export type VaultSortMode = 'edited' | 'created' | 'name';
|
||||||
export type DuplicateDetectionMode = 'exact' | 'login-site' | 'login-credentials' | 'password';
|
export type DuplicateDetectionMode = 'exact' | 'login-site' | 'login-credentials' | 'password';
|
||||||
export type SidebarFilter =
|
export type SidebarFilter =
|
||||||
@@ -98,6 +101,32 @@ export function cardListSubtitle(cipher: Cipher): string {
|
|||||||
return cipherTypeLabel(3);
|
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 }) {
|
export function CardBrandIcon({ brand }: { brand?: string | null }) {
|
||||||
const display = displayCardBrand(brand);
|
const display = displayCardBrand(brand);
|
||||||
const key = display.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'generic';
|
const key = display.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'generic';
|
||||||
@@ -118,7 +147,10 @@ export function getCreateTypeOptions(): TypeOption[] {
|
|||||||
return [
|
return [
|
||||||
{ type: 1, label: t('txt_login') },
|
{ type: 1, label: t('txt_login') },
|
||||||
{ type: 3, label: t('txt_card') },
|
{ type: 3, label: t('txt_card') },
|
||||||
|
{ type: 6, label: t('txt_bank_account') },
|
||||||
{ type: 4, label: t('txt_identity') },
|
{ 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: 2, label: t('txt_note') },
|
||||||
{ type: 5, label: t('txt_ssh_key') },
|
{ 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;
|
const TOTP_RING_RADIUS = 14;
|
||||||
export const TOTP_RING_RADIUS = 14;
|
|
||||||
export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||||
|
|
||||||
export function CreateTypeIcon({ type }: { type: number }) {
|
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 === 4) return <ShieldUser size={15} />;
|
||||||
if (type === 2) return <StickyNote size={15} />;
|
if (type === 2) return <StickyNote size={15} />;
|
||||||
if (type === 5) return <KeyRound 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} />;
|
return <FileKey2 size={15} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +227,11 @@ export function cipherTypeKey(type: number): TypeFilter {
|
|||||||
if (type === 3) return 'card';
|
if (type === 3) return 'card';
|
||||||
if (type === 4) return 'identity';
|
if (type === 4) return 'identity';
|
||||||
if (type === 2) return 'note';
|
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 {
|
function cipherDeletedValue(cipher: Cipher): boolean {
|
||||||
@@ -230,6 +268,9 @@ export function cipherTypeLabel(type: number): string {
|
|||||||
if (type === 4) return t('txt_identity');
|
if (type === 4) return t('txt_identity');
|
||||||
if (type === 2) return t('txt_secure_note');
|
if (type === 2) return t('txt_secure_note');
|
||||||
if (type === 5) return t('txt_ssh_key');
|
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');
|
return t('txt_item');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +280,9 @@ export function TypeIcon({ type }: { type: number }) {
|
|||||||
if (type === 4) return <ShieldUser size={18} />;
|
if (type === 4) return <ShieldUser size={18} />;
|
||||||
if (type === 2) return <StickyNote size={18} />;
|
if (type === 2) return <StickyNote size={18} />;
|
||||||
if (type === 5) return <KeyRound 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} />;
|
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),
|
fingerprint: valueOrFallback(cipher.sshKey.decFingerprint ?? cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint),
|
||||||
}
|
}
|
||||||
: null,
|
: 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,
|
secureNoteType: cipher.secureNote?.type ?? null,
|
||||||
fields: (cipher.fields || []).map((field) => ({
|
fields: (cipher.fields || []).map((field) => ({
|
||||||
type: field.type ?? null,
|
type: field.type ?? null,
|
||||||
@@ -427,6 +517,40 @@ export function createEmptyDraft(type: number): VaultDraft {
|
|||||||
sshPrivateKey: '',
|
sshPrivateKey: '',
|
||||||
sshPublicKey: '',
|
sshPublicKey: '',
|
||||||
sshFingerprint: '',
|
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: [],
|
customFields: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -490,6 +614,46 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
|
|||||||
draft.sshPublicKey = cipher.sshKey.decPublicKey || '';
|
draft.sshPublicKey = cipher.sshKey.decPublicKey || '';
|
||||||
draft.sshFingerprint = cipher.sshKey.decFingerprint || '';
|
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) => ({
|
draft.customFields = (cipher.fields || []).map((field) => ({
|
||||||
type: parseFieldType(field.type),
|
type: parseFieldType(field.type),
|
||||||
label: field.decName || '',
|
label: field.decName || '',
|
||||||
|
|||||||
@@ -561,13 +561,18 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
openRemoveAllDevices() {
|
openRemoveAllDevices() {
|
||||||
onSetConfirm({
|
onSetConfirm({
|
||||||
title: t('txt_remove_all_devices'),
|
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,
|
danger: true,
|
||||||
onConfirm: () => {
|
requireMasterPassword: true,
|
||||||
|
onConfirm: (masterPassword) => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
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'));
|
onNotify('success', t('txt_all_devices_removed'));
|
||||||
onLogoutNow();
|
onLogoutNow();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from 'preact/hooks';
|
import { useMemo } from 'preact/hooks';
|
||||||
import { createInvite, deleteAllInvites, deleteInvalidInvites, deleteInvite, deleteUser, setUserStatus } from '@/lib/api/admin';
|
import { createInvite, deleteAllInvites, deleteInvalidInvites, deleteInvite, deleteUser, setUserStatus } from '@/lib/api/admin';
|
||||||
|
import { deriveLoginHash } from '@/lib/api/auth';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
|
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
|
||||||
import type { AuthedFetch } from '@/lib/api/shared';
|
import type { AuthedFetch } from '@/lib/api/shared';
|
||||||
@@ -8,6 +9,8 @@ type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
|
|||||||
|
|
||||||
interface UseAdminActionsOptions {
|
interface UseAdminActionsOptions {
|
||||||
authedFetch: AuthedFetch;
|
authedFetch: AuthedFetch;
|
||||||
|
email: string;
|
||||||
|
defaultKdfIterations: number;
|
||||||
onNotify: Notify;
|
onNotify: Notify;
|
||||||
onSetConfirm: (next: AppConfirmState | null) => void;
|
onSetConfirm: (next: AppConfirmState | null) => void;
|
||||||
refetchUsers: () => Promise<unknown>;
|
refetchUsers: () => Promise<unknown>;
|
||||||
@@ -15,7 +18,24 @@ interface UseAdminActionsOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function useAdminActions(options: 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(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -26,35 +46,61 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async createInvite(hours: number) {
|
async createInvite(hours: number) {
|
||||||
|
onSetConfirm({
|
||||||
|
title: t('txt_create_timed_invite'),
|
||||||
|
message: t('txt_enter_master_password_to_continue'),
|
||||||
|
requireMasterPassword: true,
|
||||||
|
onConfirm: (masterPassword) => {
|
||||||
|
onSetConfirm(null);
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await createInvite(authedFetch, hours);
|
const hash = await withMasterPasswordHash(masterPassword);
|
||||||
|
await createInvite(authedFetch, hours, hash);
|
||||||
await refetchInvites();
|
await refetchInvites();
|
||||||
onNotify('success', t('txt_invite_created'));
|
onNotify('success', t('txt_invite_created'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed'));
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async toggleUserStatus(userId: string, status: 'active' | 'banned') {
|
async toggleUserStatus(userId: string, status: 'active' | 'banned') {
|
||||||
|
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 {
|
try {
|
||||||
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
|
const hash = await withMasterPasswordHash(masterPassword);
|
||||||
|
await setUserStatus(authedFetch, userId, nextStatus, hash);
|
||||||
await refetchUsers();
|
await refetchUsers();
|
||||||
onNotify('success', t('txt_user_status_updated'));
|
onNotify('success', t('txt_user_status_updated'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed'));
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteInvite(code: string) {
|
async deleteInvite(code: string) {
|
||||||
onSetConfirm({
|
onSetConfirm({
|
||||||
title: t('txt_delete_invite'),
|
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,
|
danger: true,
|
||||||
onConfirm: () => {
|
requireMasterPassword: true,
|
||||||
|
onConfirm: (masterPassword) => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await deleteInvite(authedFetch, code);
|
const hash = await withMasterPasswordHash(masterPassword);
|
||||||
|
await deleteInvite(authedFetch, code, hash);
|
||||||
await refetchInvites();
|
await refetchInvites();
|
||||||
onNotify('success', t('txt_invite_deleted'));
|
onNotify('success', t('txt_invite_deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -68,13 +114,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
|||||||
async deleteInvalidInvites() {
|
async deleteInvalidInvites() {
|
||||||
onSetConfirm({
|
onSetConfirm({
|
||||||
title: t('txt_delete_invalid_invites'),
|
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,
|
danger: true,
|
||||||
onConfirm: () => {
|
requireMasterPassword: true,
|
||||||
|
onConfirm: (masterPassword) => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await deleteInvalidInvites(authedFetch);
|
const hash = await withMasterPasswordHash(masterPassword);
|
||||||
|
await deleteInvalidInvites(authedFetch, hash);
|
||||||
await refetchInvites();
|
await refetchInvites();
|
||||||
onNotify('success', t('txt_invalid_invites_deleted'));
|
onNotify('success', t('txt_invalid_invites_deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -88,13 +136,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
|||||||
async deleteAllInvites() {
|
async deleteAllInvites() {
|
||||||
onSetConfirm({
|
onSetConfirm({
|
||||||
title: t('txt_delete_all_invites'),
|
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,
|
danger: true,
|
||||||
onConfirm: () => {
|
requireMasterPassword: true,
|
||||||
|
onConfirm: (masterPassword) => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await deleteAllInvites(authedFetch);
|
const hash = await withMasterPasswordHash(masterPassword);
|
||||||
|
await deleteAllInvites(authedFetch, hash);
|
||||||
await refetchInvites();
|
await refetchInvites();
|
||||||
onNotify('success', t('txt_all_invites_deleted'));
|
onNotify('success', t('txt_all_invites_deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -108,13 +158,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
|||||||
async deleteUser(userId: string) {
|
async deleteUser(userId: string) {
|
||||||
onSetConfirm({
|
onSetConfirm({
|
||||||
title: t('txt_delete_user'),
|
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,
|
danger: true,
|
||||||
onConfirm: () => {
|
requireMasterPassword: true,
|
||||||
|
onConfirm: (masterPassword) => {
|
||||||
onSetConfirm(null);
|
onSetConfirm(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await deleteUser(authedFetch, userId);
|
const hash = await withMasterPasswordHash(masterPassword);
|
||||||
|
await deleteUser(authedFetch, userId, hash);
|
||||||
await refetchUsers();
|
await refetchUsers();
|
||||||
onNotify('success', t('txt_user_deleted'));
|
onNotify('success', t('txt_user_deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -125,6 +177,6 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[authedFetch, onNotify, onSetConfirm, refetchInvites, refetchUsers]
|
[authedFetch, defaultKdfIterations, email, onNotify, onSetConfirm, refetchInvites, refetchUsers]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,12 +82,12 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
|||||||
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
||||||
},
|
},
|
||||||
|
|
||||||
async inspectRemoteBackup(destinationId: string, path: string) {
|
async inspectRemoteBackup(masterPasswordHash: string, destinationId: string, path: string) {
|
||||||
return inspectRemoteBackupIntegrity(authedFetch, destinationId, path);
|
return inspectRemoteBackupIntegrity(authedFetch, masterPasswordHash, destinationId, path);
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteRemoteBackup(destinationId: string, path: string) {
|
async deleteRemoteBackup(masterPasswordHash: string, destinationId: string, path: string) {
|
||||||
await deleteRemoteBackup(authedFetch, destinationId, path);
|
await deleteRemoteBackup(authedFetch, masterPasswordHash, destinationId, path);
|
||||||
},
|
},
|
||||||
|
|
||||||
async restoreRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) {
|
async restoreRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface PendingAccountPasskeyCredential {
|
|||||||
deviceResponse: PublicKeyCredential;
|
deviceResponse: PublicKeyCredential;
|
||||||
request: Record<string, unknown>;
|
request: Record<string, unknown>;
|
||||||
supportsPrf: boolean;
|
supportsPrf: boolean;
|
||||||
|
prfKey?: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountPasskeyPrfKeySet {
|
export interface AccountPasskeyPrfKeySet {
|
||||||
@@ -82,20 +83,9 @@ async function getLoginWithPrfSalt(): Promise<Uint8Array> {
|
|||||||
return new Uint8Array(hash);
|
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 };
|
type PrfEvalInput = { first: Uint8Array };
|
||||||
|
|
||||||
function buildLegacyPrfExtension(salt: Uint8Array): Record<string, unknown> {
|
function buildPrfExtension(salt: Uint8Array): Record<string, unknown> {
|
||||||
const evalInput: PrfEvalInput = { first: salt };
|
const evalInput: PrfEvalInput = { first: salt };
|
||||||
return {
|
return {
|
||||||
prf: {
|
prf: {
|
||||||
@@ -104,34 +94,23 @@ function buildLegacyPrfExtension(salt: Uint8Array): Record<string, unknown> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCredentialPrfExtension(
|
function withPrfExtension(
|
||||||
salt: Uint8Array,
|
options: PublicKeyCredentialCreationOptions,
|
||||||
credentialIds: Array<string | null | undefined>
|
salt: Uint8Array
|
||||||
): Record<string, unknown> {
|
): PublicKeyCredentialCreationOptions;
|
||||||
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(
|
function withPrfExtension(
|
||||||
options: PublicKeyCredentialRequestOptions,
|
options: PublicKeyCredentialRequestOptions,
|
||||||
extension: Record<string, unknown>
|
salt: Uint8Array
|
||||||
): PublicKeyCredentialRequestOptions {
|
): PublicKeyCredentialRequestOptions;
|
||||||
|
function withPrfExtension(
|
||||||
|
options: PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions,
|
||||||
|
salt: Uint8Array
|
||||||
|
): PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions {
|
||||||
return {
|
return {
|
||||||
...options,
|
...options,
|
||||||
extensions: {
|
extensions: {
|
||||||
...((options as any).extensions || {}),
|
...((options as any).extensions || {}),
|
||||||
...extension,
|
...buildPrfExtension(salt),
|
||||||
} as any,
|
} as any,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -154,70 +133,17 @@ function readPrfFirstResult(credential: PublicKeyCredential): ArrayBuffer | unde
|
|||||||
return result instanceof ArrayBuffer ? result : undefined;
|
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(
|
async function getPublicKeyCredentialWithPrf(
|
||||||
options: PublicKeyCredentialRequestOptions,
|
options: PublicKeyCredentialRequestOptions,
|
||||||
salt: Uint8Array,
|
salt: Uint8Array
|
||||||
credentialIds: string[] = []
|
|
||||||
): Promise<PublicKeyCredential> {
|
): 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({
|
const credential = await navigator.credentials.get({
|
||||||
publicKey: withPrfExtension(options, attempts[index]),
|
publicKey: withPrfExtension(options, salt),
|
||||||
});
|
});
|
||||||
if (!(credential instanceof PublicKeyCredential)) {
|
if (!(credential instanceof PublicKeyCredential)) {
|
||||||
throw new Error(t('txt_no_passkey_selected'));
|
throw new Error(t('txt_no_passkey_selected'));
|
||||||
}
|
}
|
||||||
lastCredential = credential;
|
|
||||||
if (readPrfFirstResult(credential) || hasPrfExtensionResult(credential) || index === attempts.length - 1) {
|
|
||||||
return credential;
|
return credential;
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (index === attempts.length - 1 || !shouldRetryWithLegacyPrf(error)) {
|
|
||||||
if (lastCredential) return lastCredential;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (lastCredential) return lastCredential;
|
|
||||||
throw new Error(t('txt_no_passkey_selected'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function prfCredentialIdsFromAllowCredentials(options: PublicKeyCredentialRequestOptions): string[] {
|
|
||||||
return (options.allowCredentials || [])
|
|
||||||
.map((credential) => credentialIdToBase64Url(credential.id))
|
|
||||||
.filter((id): id is string => !!id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function prfOutputToKey(prfOutput: ArrayBuffer): Promise<Uint8Array> {
|
async function prfOutputToKey(prfOutput: ArrayBuffer): Promise<Uint8Array> {
|
||||||
@@ -282,8 +208,7 @@ export async function assertAccountPasskey(
|
|||||||
const nativeOptions = cloneRequestOptions(response.options);
|
const nativeOptions = cloneRequestOptions(response.options);
|
||||||
const credential = await getPublicKeyCredentialWithPrf(
|
const credential = await getPublicKeyCredentialWithPrf(
|
||||||
nativeOptions,
|
nativeOptions,
|
||||||
await getLoginWithPrfSalt(),
|
await getLoginWithPrfSalt()
|
||||||
prfCredentialIdsFromAllowCredentials(nativeOptions)
|
|
||||||
);
|
);
|
||||||
const prfResult = readPrfFirstResult(credential);
|
const prfResult = readPrfFirstResult(credential);
|
||||||
return {
|
return {
|
||||||
@@ -309,34 +234,22 @@ export async function createAccountPasskeyCredential(
|
|||||||
}
|
}
|
||||||
return credential;
|
return credential;
|
||||||
};
|
};
|
||||||
let credential: PublicKeyCredential;
|
const prfSalt = requestPrf ? await getLoginWithPrfSalt() : null;
|
||||||
if (requestPrf && await canRequestPrfExtension()) {
|
const credential = await createWithOptions(
|
||||||
const prfOptions: PublicKeyCredentialCreationOptions = {
|
prfSalt ? withPrfExtension(noPrfOptions, prfSalt) : noPrfOptions
|
||||||
...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);
|
|
||||||
}
|
|
||||||
if (!(credential instanceof PublicKeyCredential)) {
|
if (!(credential instanceof PublicKeyCredential)) {
|
||||||
throw new Error(t('txt_no_passkey_created'));
|
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 {
|
return {
|
||||||
token: response.token,
|
token: response.token,
|
||||||
createOptions: nativeOptions,
|
createOptions: nativeOptions,
|
||||||
deviceResponse: credential,
|
deviceResponse: credential,
|
||||||
request: attestationRequest(credential),
|
request: attestationRequest(credential),
|
||||||
supportsPrf,
|
supportsPrf,
|
||||||
|
prfKey: prfResult ? await prfOutputToKey(prfResult) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,8 +286,10 @@ export async function buildAccountPasskeyPrfKeySet(
|
|||||||
pending: PendingAccountPasskeyCredential,
|
pending: PendingAccountPasskeyCredential,
|
||||||
userKey: { symEncKey: string; symMacKey: string }
|
userKey: { symEncKey: string; symMacKey: string }
|
||||||
): Promise<AccountPasskeyPrfKeySet> {
|
): Promise<AccountPasskeyPrfKeySet> {
|
||||||
|
if (pending.prfKey) {
|
||||||
|
return buildAccountPasskeyPrfKeySetFromPrfKey(pending.prfKey, userKey);
|
||||||
|
}
|
||||||
const rawId = new Uint8Array(pending.deviceResponse.rawId);
|
const rawId = new Uint8Array(pending.deviceResponse.rawId);
|
||||||
const credentialId = bytesToBase64Url(rawId);
|
|
||||||
const assertionOptions: PublicKeyCredentialRequestOptions = {
|
const assertionOptions: PublicKeyCredentialRequestOptions = {
|
||||||
challenge: pending.createOptions?.challenge!,
|
challenge: pending.createOptions?.challenge!,
|
||||||
rpId: pending.createOptions?.rp?.id,
|
rpId: pending.createOptions?.rp?.id,
|
||||||
@@ -384,8 +299,7 @@ export async function buildAccountPasskeyPrfKeySet(
|
|||||||
};
|
};
|
||||||
const assertion = await getPublicKeyCredentialWithPrf(
|
const assertion = await getPublicKeyCredentialWithPrf(
|
||||||
assertionOptions,
|
assertionOptions,
|
||||||
await getLoginWithPrfSalt(),
|
await getLoginWithPrfSalt()
|
||||||
[credentialId]
|
|
||||||
);
|
);
|
||||||
const prfResult = readPrfFirstResult(assertion);
|
const prfResult = readPrfFirstResult(assertion);
|
||||||
if (!prfResult) {
|
if (!prfResult) {
|
||||||
|
|||||||
+29
-12
@@ -15,45 +15,62 @@ export async function listAdminInvites(authedFetch: AuthedFetch): Promise<AdminI
|
|||||||
return body?.data || [];
|
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', {
|
const resp = await authedFetch('/api/admin/invites', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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');
|
if (!resp.ok) throw new Error('Create invite failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteInvite(authedFetch: AuthedFetch, code: string): Promise<void> {
|
export async function deleteInvite(authedFetch: AuthedFetch, code: string, masterPasswordHash: string): Promise<void> {
|
||||||
const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, { method: 'DELETE' });
|
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');
|
if (!resp.ok) throw new Error('Delete invite failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteInvalidInvites(authedFetch: AuthedFetch): Promise<void> {
|
export async function deleteInvalidInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
|
||||||
const resp = await authedFetch('/api/admin/invites?scope=invalid', { method: 'DELETE' });
|
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');
|
if (!resp.ok) throw new Error('Delete invalid invites failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAllInvites(authedFetch: AuthedFetch): Promise<void> {
|
export async function deleteAllInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
|
||||||
const resp = await authedFetch('/api/admin/invites', { method: 'DELETE' });
|
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');
|
if (!resp.ok) throw new Error('Delete all invites failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setUserStatus(
|
export async function setUserStatus(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
userId: string,
|
userId: string,
|
||||||
status: 'active' | 'banned'
|
status: 'active' | 'banned',
|
||||||
|
masterPasswordHash: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, {
|
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ status }),
|
body: JSON.stringify({ status, masterPasswordHash }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error('Update user status failed');
|
if (!resp.ok) throw new Error('Update user status failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteUser(authedFetch: AuthedFetch, userId: string): Promise<void> {
|
export async function deleteUser(authedFetch: AuthedFetch, userId: string, masterPasswordHash: string): Promise<void> {
|
||||||
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' });
|
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');
|
if (!resp.ok) throw new Error('Delete user failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ export async function respondToAuthRequest(
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
payload: {
|
payload: {
|
||||||
key?: string | null;
|
key?: string | null;
|
||||||
masterPasswordHash?: string | null;
|
|
||||||
deviceIdentifier: string;
|
deviceIdentifier: string;
|
||||||
requestApproved: boolean;
|
requestApproved: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-10
@@ -89,11 +89,29 @@ function clearRememberTwoFactorToken(): void {
|
|||||||
localStorage.removeItem(TOTP_REMEMBER_TOKEN_KEY);
|
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 {
|
export function loadSession(): SessionState | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(SESSION_KEY);
|
const raw = localStorage.getItem(SESSION_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const parsed = JSON.parse(raw) as Partial<SessionState> & Partial<PersistedSessionState>;
|
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) {
|
if (parsed.authMode === 'web-cookie' && parsed.email) {
|
||||||
return {
|
return {
|
||||||
email: parsed.email,
|
email: parsed.email,
|
||||||
@@ -106,13 +124,7 @@ export function loadSession(): SessionState | null {
|
|||||||
authMode: 'token',
|
authMode: 'token',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null;
|
return null;
|
||||||
return {
|
|
||||||
accessToken: parsed.accessToken,
|
|
||||||
refreshToken: parsed.refreshToken,
|
|
||||||
email: parsed.email,
|
|
||||||
authMode: 'token',
|
|
||||||
};
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -280,7 +292,7 @@ export async function loginWithPassword(
|
|||||||
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
saveRememberTwoFactorToken((json as TokenSuccess).TwoFactorToken);
|
saveRememberTwoFactorToken((json as TokenSuccess).TwoFactorToken);
|
||||||
} else if (rememberedToken) {
|
} else if (rememberedToken && hasTwoFactorChallenge(json)) {
|
||||||
clearRememberTwoFactorToken();
|
clearRememberTwoFactorToken();
|
||||||
}
|
}
|
||||||
if (!resp.ok) return json;
|
if (!resp.ok) return json;
|
||||||
@@ -390,6 +402,7 @@ export async function revokeCurrentSession(session: SessionState | null): Promis
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
...(session?.accessToken ? { Authorization: `Bearer ${session.accessToken}` } : {}),
|
||||||
...(session?.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
|
...(session?.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
|
||||||
},
|
},
|
||||||
body: body.toString(),
|
body: body.toString(),
|
||||||
@@ -1127,8 +1140,12 @@ export async function updateAuthorizedDeviceName(
|
|||||||
if (!resp.ok) throw new Error(t('txt_update_device_note_failed'));
|
if (!resp.ok) throw new Error(t('txt_update_device_note_failed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> {
|
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
|
||||||
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
|
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'));
|
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -196,11 +196,14 @@ export async function exportAdminBackup(
|
|||||||
|
|
||||||
export async function downloadAdminBackupAttachmentBlob(
|
export async function downloadAdminBackupAttachmentBlob(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
blobName: string
|
blobName: string,
|
||||||
|
masterPasswordHash: string
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const params = new URLSearchParams();
|
const resp = await authedFetch('/api/admin/backup/blob', {
|
||||||
params.set('blobName', blobName);
|
method: 'POST',
|
||||||
const resp = await authedFetch(`/api/admin/backup/blob?${params.toString()}`, { method: 'GET' });
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ blobName, masterPasswordHash }),
|
||||||
|
});
|
||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
|
||||||
return new Uint8Array(await resp.arrayBuffer());
|
return new Uint8Array(await resp.arrayBuffer());
|
||||||
}
|
}
|
||||||
@@ -246,7 +249,7 @@ export async function buildCompleteAdminBackupExport(
|
|||||||
stageDetail: 'txt_backup_export_progress_fetch_attachments_detail',
|
stageDetail: 'txt_backup_export_progress_fetch_attachments_detail',
|
||||||
});
|
});
|
||||||
for (const attachment of manifest.attachmentBlobs || []) {
|
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;
|
zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,25 +406,29 @@ export async function verifyBackupFileIntegrity(bytes: Uint8Array, fileName: str
|
|||||||
|
|
||||||
export async function deleteRemoteBackup(
|
export async function deleteRemoteBackup(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
|
masterPasswordHash: string,
|
||||||
destinationId: string,
|
destinationId: string,
|
||||||
path: string
|
path: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const params = new URLSearchParams();
|
const resp = await authedFetch('/api/admin/backup/remote/file', {
|
||||||
params.set('destinationId', destinationId);
|
method: 'DELETE',
|
||||||
params.set('path', path);
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const resp = await authedFetch(`/api/admin/backup/remote/file?${params.toString()}`, { method: 'DELETE' });
|
body: JSON.stringify({ destinationId, path, masterPasswordHash }),
|
||||||
|
});
|
||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_delete_failed')));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_delete_failed')));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function inspectRemoteBackupIntegrity(
|
export async function inspectRemoteBackupIntegrity(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
|
masterPasswordHash: string,
|
||||||
destinationId: string,
|
destinationId: string,
|
||||||
path: string
|
path: string
|
||||||
): Promise<RemoteBackupIntegrityResponse> {
|
): Promise<RemoteBackupIntegrityResponse> {
|
||||||
const params = new URLSearchParams();
|
const resp = await authedFetch('/api/admin/backup/remote/integrity', {
|
||||||
params.set('destinationId', destinationId);
|
method: 'POST',
|
||||||
params.set('path', path);
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const resp = await authedFetch(`/api/admin/backup/remote/integrity?${params.toString()}`, { method: 'GET' });
|
body: JSON.stringify({ destinationId, path, masterPasswordHash }),
|
||||||
|
});
|
||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
|
||||||
const body = await parseJson<RemoteBackupIntegrityResponse>(resp);
|
const body = await parseJson<RemoteBackupIntegrityResponse>(resp);
|
||||||
if (!body?.integrity || !body?.fileName) throw new Error(t('txt_backup_remote_invalid_response'));
|
if (!body?.integrity || !body?.fileName) throw new Error(t('txt_backup_remote_invalid_response'));
|
||||||
|
|||||||
@@ -513,6 +513,30 @@ async function encryptTextValue(value: string, enc: Uint8Array, mac: Uint8Array)
|
|||||||
return encryptBw(new TextEncoder().encode(s), enc, mac);
|
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(
|
async function encryptPasswordHistory(
|
||||||
entries: CipherPasswordHistoryEntry[] | null | undefined,
|
entries: CipherPasswordHistoryEntry[] | null | undefined,
|
||||||
enc: Uint8Array,
|
enc: Uint8Array,
|
||||||
@@ -587,6 +611,40 @@ function draftFromDecryptedCipher(cipher: Cipher): VaultDraft {
|
|||||||
sshPrivateKey: '',
|
sshPrivateKey: '',
|
||||||
sshPublicKey: '',
|
sshPublicKey: '',
|
||||||
sshFingerprint: '',
|
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: [],
|
customFields: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -662,6 +720,43 @@ function draftFromDecryptedCipher(cipher: Cipher): VaultDraft {
|
|||||||
cipher.sshKey.decFingerprint,
|
cipher.sshKey.decFingerprint,
|
||||||
cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint
|
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;
|
return draft;
|
||||||
@@ -983,6 +1078,10 @@ function getCipherKeyMismatchProbes(cipher: Cipher): string[] {
|
|||||||
cipher.identity?.title,
|
cipher.identity?.title,
|
||||||
cipher.identity?.firstName,
|
cipher.identity?.firstName,
|
||||||
cipher.sshKey?.privateKey,
|
cipher.sshKey?.privateKey,
|
||||||
|
cipher.bankAccount?.bankName,
|
||||||
|
cipher.bankAccount?.accountNumber,
|
||||||
|
cipher.driversLicense?.licenseNumber,
|
||||||
|
cipher.passport?.passportNumber,
|
||||||
...(cipher.fields || []).flatMap((field) => [field.name, field.value]),
|
...(cipher.fields || []).flatMap((field) => [field.name, field.value]),
|
||||||
];
|
];
|
||||||
const probes: string[] = [];
|
const probes: string[] = [];
|
||||||
@@ -1053,6 +1152,40 @@ function hasUnresolvedEncryptedFields(cipher: Cipher): boolean {
|
|||||||
[cipher.sshKey?.privateKey, cipher.sshKey?.decPrivateKey],
|
[cipher.sshKey?.privateKey, cipher.sshKey?.decPrivateKey],
|
||||||
[cipher.sshKey?.publicKey, cipher.sshKey?.decPublicKey],
|
[cipher.sshKey?.publicKey, cipher.sshKey?.decPublicKey],
|
||||||
[cipher.sshKey?.keyFingerprint || cipher.sshKey?.fingerprint, cipher.sshKey?.decFingerprint],
|
[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) => [
|
...(cipher.fields || []).flatMap((field) => [
|
||||||
[field.name, field.decName] as [unknown, unknown],
|
[field.name, field.decName] as [unknown, unknown],
|
||||||
[field.value, field.decValue] as [unknown, unknown],
|
[field.value, field.decValue] as [unknown, unknown],
|
||||||
@@ -1157,6 +1290,9 @@ async function buildCipherPayload(
|
|||||||
identity: null,
|
identity: null,
|
||||||
secureNote: null,
|
secureNote: null,
|
||||||
sshKey: null,
|
sshKey: null,
|
||||||
|
bankAccount: null,
|
||||||
|
driversLicense: null,
|
||||||
|
passport: null,
|
||||||
fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
|
fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
|
||||||
passwordHistory: await encryptPasswordHistory(cipher?.passwordHistory, keys.enc, keys.mac),
|
passwordHistory: await encryptPasswordHistory(cipher?.passwordHistory, keys.enc, keys.mac),
|
||||||
};
|
};
|
||||||
@@ -1222,11 +1358,73 @@ async function buildCipherPayload(
|
|||||||
} else if (type === 5) {
|
} else if (type === 5) {
|
||||||
const encryptedFingerprint = await encryptTextValue(draft.sshFingerprint, keys.enc, keys.mac);
|
const encryptedFingerprint = await encryptTextValue(draft.sshFingerprint, keys.enc, keys.mac);
|
||||||
payload.sshKey = {
|
payload.sshKey = {
|
||||||
|
...stripDecodedObjectFields(cipher?.sshKey),
|
||||||
privateKey: await encryptTextValue(draft.sshPrivateKey, keys.enc, keys.mac),
|
privateKey: await encryptTextValue(draft.sshPrivateKey, keys.enc, keys.mac),
|
||||||
publicKey: await encryptTextValue(draft.sshPublicKey, keys.enc, keys.mac),
|
publicKey: await encryptTextValue(draft.sshPublicKey, keys.enc, keys.mac),
|
||||||
keyFingerprint: encryptedFingerprint,
|
keyFingerprint: encryptedFingerprint,
|
||||||
fingerprint: 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) {
|
} else if (type === 2) {
|
||||||
payload.secureNote = { type: 0 };
|
payload.secureNote = { type: 0 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
unlockOfflineVaultWithMasterKey,
|
unlockOfflineVaultWithMasterKey,
|
||||||
} from '@/lib/offline-auth';
|
} from '@/lib/offline-auth';
|
||||||
import { probeNodeWardenService } from '@/lib/network-status';
|
import { probeNodeWardenService } from '@/lib/network-status';
|
||||||
|
import { setWebsiteIconsEnabled } from '@/lib/website-icon-settings';
|
||||||
import type { AccountPasskeyPrfOption, AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
import type { AccountPasskeyPrfOption, AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
||||||
|
|
||||||
export interface PendingTotp {
|
export interface PendingTotp {
|
||||||
@@ -51,6 +52,7 @@ export type JwtUnsafeReason = 'missing' | 'too_short';
|
|||||||
export interface BootstrapAppResult {
|
export interface BootstrapAppResult {
|
||||||
defaultKdfIterations: number;
|
defaultKdfIterations: number;
|
||||||
registrationInviteRequired?: boolean;
|
registrationInviteRequired?: boolean;
|
||||||
|
websiteIconsEnabled: boolean;
|
||||||
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
||||||
session: SessionState | null;
|
session: SessionState | null;
|
||||||
profile: Profile | null;
|
profile: Profile | null;
|
||||||
@@ -61,6 +63,7 @@ export interface BootstrapAppResult {
|
|||||||
export interface InitialAppBootstrapState {
|
export interface InitialAppBootstrapState {
|
||||||
defaultKdfIterations: number;
|
defaultKdfIterations: number;
|
||||||
registrationInviteRequired?: boolean;
|
registrationInviteRequired?: boolean;
|
||||||
|
websiteIconsEnabled: boolean;
|
||||||
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
||||||
session: SessionState | null;
|
session: SessionState | null;
|
||||||
phase: AppPhase;
|
phase: AppPhase;
|
||||||
@@ -229,10 +232,11 @@ function readWindowBootstrap(): WebBootstrapResponse {
|
|||||||
return raw && typeof raw === 'object' ? raw : {};
|
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 defaultKdfIterations = Number(boot.defaultKdfIterations || 600000);
|
||||||
const registrationInviteRequired =
|
const registrationInviteRequired =
|
||||||
typeof boot.registrationInviteRequired === 'boolean' ? boot.registrationInviteRequired : undefined;
|
typeof boot.registrationInviteRequired === 'boolean' ? boot.registrationInviteRequired : undefined;
|
||||||
|
const websiteIconsEnabled = boot.websiteIconsEnabled !== false;
|
||||||
const jwtUnsafeReason = boot.jwtUnsafeReason || null;
|
const jwtUnsafeReason = boot.jwtUnsafeReason || null;
|
||||||
const jwtWarning = jwtUnsafeReason
|
const jwtWarning = jwtUnsafeReason
|
||||||
? {
|
? {
|
||||||
@@ -244,6 +248,7 @@ function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialApp
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning,
|
jwtWarning,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -304,7 +309,8 @@ function resolveUnauthenticatedPhase(registrationInviteRequired: boolean | undef
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function readInitialAppBootstrapState(): InitialAppBootstrapState {
|
export function readInitialAppBootstrapState(): InitialAppBootstrapState {
|
||||||
const { defaultKdfIterations, registrationInviteRequired, jwtWarning } = normalizeBootstrapResponse(readWindowBootstrap());
|
const { defaultKdfIterations, registrationInviteRequired, websiteIconsEnabled, jwtWarning } = normalizeBootstrapResponse(readWindowBootstrap());
|
||||||
|
setWebsiteIconsEnabled(websiteIconsEnabled);
|
||||||
const session = loadSession();
|
const session = loadSession();
|
||||||
const hasInviteCode = !!readInviteCodeFromUrl();
|
const hasInviteCode = !!readInviteCodeFromUrl();
|
||||||
const unauthenticatedPhase = hasInviteCode ? 'register' : 'login';
|
const unauthenticatedPhase = hasInviteCode ? 'register' : 'login';
|
||||||
@@ -312,6 +318,7 @@ export function readInitialAppBootstrapState(): InitialAppBootstrapState {
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning,
|
jwtWarning,
|
||||||
session,
|
session,
|
||||||
phase: jwtWarning ? 'login' : session ? 'locked' : resolveUnauthenticatedPhase(registrationInviteRequired, unauthenticatedPhase),
|
phase: jwtWarning ? 'login' : session ? 'locked' : resolveUnauthenticatedPhase(registrationInviteRequired, unauthenticatedPhase),
|
||||||
@@ -323,12 +330,15 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
const normalizedBoot = normalizeBootstrapResponse(remoteBoot);
|
const normalizedBoot = normalizeBootstrapResponse(remoteBoot);
|
||||||
const defaultKdfIterations = normalizedBoot.defaultKdfIterations || initial.defaultKdfIterations;
|
const defaultKdfIterations = normalizedBoot.defaultKdfIterations || initial.defaultKdfIterations;
|
||||||
const registrationInviteRequired = normalizedBoot.registrationInviteRequired ?? initial.registrationInviteRequired;
|
const registrationInviteRequired = normalizedBoot.registrationInviteRequired ?? initial.registrationInviteRequired;
|
||||||
|
const websiteIconsEnabled = normalizedBoot.websiteIconsEnabled !== false;
|
||||||
|
setWebsiteIconsEnabled(websiteIconsEnabled);
|
||||||
const jwtWarning = normalizedBoot.jwtWarning ?? initial.jwtWarning;
|
const jwtWarning = normalizedBoot.jwtWarning ?? initial.jwtWarning;
|
||||||
|
|
||||||
if (jwtWarning) {
|
if (jwtWarning) {
|
||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning,
|
jwtWarning,
|
||||||
session: null,
|
session: null,
|
||||||
profile: null,
|
profile: null,
|
||||||
@@ -341,6 +351,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: null,
|
session: null,
|
||||||
profile: null,
|
profile: null,
|
||||||
@@ -353,6 +364,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: loaded,
|
session: loaded,
|
||||||
profile: cachedProfile,
|
profile: cachedProfile,
|
||||||
@@ -364,6 +376,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: loaded,
|
session: loaded,
|
||||||
profile: null,
|
profile: null,
|
||||||
|
|||||||
+219
-27
@@ -259,17 +259,33 @@ interface TotpConfig {
|
|||||||
period: number;
|
period: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GoogleAuthenticatorMigrationTotp {
|
||||||
|
secret: string;
|
||||||
|
name: string;
|
||||||
|
issuer: string;
|
||||||
|
algorithm: TotpHashAlgorithm;
|
||||||
|
digits: number;
|
||||||
|
period: number;
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_TOTP_CONFIG: Omit<TotpConfig, 'secret' | 'steam'> = {
|
const DEFAULT_TOTP_CONFIG: Omit<TotpConfig, 'secret' | 'steam'> = {
|
||||||
algorithm: 'SHA-1',
|
algorithm: 'SHA-1',
|
||||||
digits: 6,
|
digits: 6,
|
||||||
period: 30,
|
period: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseTotpPositiveInt(value: string | null, fallback: number, min: number, max: number): number {
|
function parseTotpDigits(value: string | null): number {
|
||||||
if (!value) return fallback;
|
if (!value) return DEFAULT_TOTP_CONFIG.digits;
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isInteger(parsed) || parsed < min || parsed > max) return fallback;
|
if (!Number.isInteger(parsed)) return DEFAULT_TOTP_CONFIG.digits;
|
||||||
return parsed;
|
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 {
|
function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm {
|
||||||
@@ -279,9 +295,190 @@ function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm {
|
|||||||
return 'SHA-1';
|
return 'SHA-1';
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTotpConfig(raw: string): TotpConfig {
|
function base64ToBytesLoose(value: string): Uint8Array {
|
||||||
if (!raw) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
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();
|
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 (!s) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
||||||
if (/^steam:\/\//i.test(s)) {
|
if (/^steam:\/\//i.test(s)) {
|
||||||
return {
|
return {
|
||||||
@@ -295,31 +492,20 @@ function parseTotpConfig(raw: string): TotpConfig {
|
|||||||
if (/^otpauth:\/\//i.test(s)) {
|
if (/^otpauth:\/\//i.test(s)) {
|
||||||
try {
|
try {
|
||||||
const u = new URL(s);
|
const u = new URL(s);
|
||||||
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 {
|
return {
|
||||||
secret: normalizeTotpSecret(u.searchParams.get('secret') || ''),
|
secret: normalizeTotpSecret(u.searchParams.get('secret') || ''),
|
||||||
steam,
|
steam: false,
|
||||||
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(u.searchParams.get('algorithm')),
|
algorithm: parseTotpHashAlgorithm(u.searchParams.get('algorithm')),
|
||||||
digits: steam ? 5 : parseTotpPositiveInt(u.searchParams.get('digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
|
digits: parseTotpDigits(u.searchParams.get('digits')),
|
||||||
period: parseTotpPositiveInt(u.searchParams.get('period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
|
period: parseTotpPeriod(u.searchParams.get('period')),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
const issuer = readOtpAuthParam(s, 'issuer').trim().toLowerCase();
|
|
||||||
const algorithm = readOtpAuthParam(s, 'algorithm').trim().toLowerCase();
|
|
||||||
const steam = issuer === 'steam' || algorithm === 'steam';
|
|
||||||
return {
|
return {
|
||||||
secret: normalizeTotpSecret(readOtpAuthParam(s, 'secret')),
|
secret: normalizeTotpSecret(readOtpAuthParam(s, 'secret')),
|
||||||
steam,
|
steam: false,
|
||||||
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(algorithm),
|
algorithm: parseTotpHashAlgorithm(readOtpAuthParam(s, 'algorithm')),
|
||||||
digits: steam ? 5 : parseTotpPositiveInt(readOtpAuthParam(s, 'digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
|
digits: parseTotpDigits(readOtpAuthParam(s, 'digits')),
|
||||||
period: parseTotpPositiveInt(readOtpAuthParam(s, 'period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
|
period: parseTotpPeriod(readOtpAuthParam(s, 'period')),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,7 +535,13 @@ function base32ToBytes(input: string): Uint8Array {
|
|||||||
return new Uint8Array(out);
|
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);
|
const { secret, steam, algorithm, digits, period } = parseTotpConfig(rawSecret);
|
||||||
if (!secret) return null;
|
if (!secret) return null;
|
||||||
const keyBytes = base32ToBytes(secret);
|
const keyBytes = base32ToBytes(secret);
|
||||||
@@ -378,5 +570,5 @@ export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now())
|
|||||||
value = Math.floor(value / chars.length);
|
value = Math.floor(value / chars.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { code, remain };
|
return { code, remain, period };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations: 600000,
|
defaultKdfIterations: 600000,
|
||||||
registrationInviteRequired: true,
|
registrationInviteRequired: true,
|
||||||
|
websiteIconsEnabled: true,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: null,
|
session: null,
|
||||||
phase: 'login',
|
phase: 'login',
|
||||||
|
|||||||
+399
-1
@@ -9,6 +9,7 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
AdminInvite,
|
AdminInvite,
|
||||||
AdminUser,
|
AdminUser,
|
||||||
|
AuditLogEntry,
|
||||||
AuthorizedDevice,
|
AuthorizedDevice,
|
||||||
Cipher,
|
Cipher,
|
||||||
Folder,
|
Folder,
|
||||||
@@ -383,6 +384,143 @@ export const DEMO_CIPHERS: Cipher[] = [
|
|||||||
decFingerprint: 'SHA256:demoNodeWardenFingerprint',
|
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',
|
id: 'cipher-archived',
|
||||||
type: 1,
|
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 {
|
function cloneJson<T>(value: T): T {
|
||||||
return JSON.parse(JSON.stringify(value)) as T;
|
return JSON.parse(JSON.stringify(value)) as T;
|
||||||
}
|
}
|
||||||
@@ -790,6 +1155,7 @@ export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations: 600000,
|
defaultKdfIterations: 600000,
|
||||||
registrationInviteRequired: true,
|
registrationInviteRequired: true,
|
||||||
|
websiteIconsEnabled: true,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: null,
|
session: null,
|
||||||
phase: 'login',
|
phase: 'login',
|
||||||
@@ -1074,6 +1440,38 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
|||||||
onGetRecoveryCode: readonlyString,
|
onGetRecoveryCode: readonlyString,
|
||||||
onGetApiKey: readonlyString,
|
onGetApiKey: readonlyString,
|
||||||
onRotateApiKey: 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,
|
onLockTimeoutChange: readonlyVoid,
|
||||||
onSessionTimeoutActionChange: readonlyVoid,
|
onSessionTimeoutActionChange: readonlyVoid,
|
||||||
onRefreshAuthorizedDevices: async () => {
|
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) => {
|
onDownloadRemoteBackup: async (_masterPassword: string, _destinationId: string, _path: string, _onProgress?: (percent: number | null) => void) => {
|
||||||
notify('success', t('txt_demo_download_prepared'));
|
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',
|
object: 'backup-remote-integrity',
|
||||||
destinationId: _destinationId,
|
destinationId: _destinationId,
|
||||||
path,
|
path,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -215,13 +215,13 @@ function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
|
|||||||
const login = cipher.login;
|
const login = cipher.login;
|
||||||
out.login = login
|
out.login = login
|
||||||
? {
|
? {
|
||||||
...cloneValue(login),
|
...(cloneWithoutDecodedFields(login) || {}),
|
||||||
username: login.username ?? null,
|
username: login.username ?? null,
|
||||||
password: login.password ?? null,
|
password: login.password ?? null,
|
||||||
totp: login.totp ?? null,
|
totp: login.totp ?? null,
|
||||||
uris: Array.isArray(login.uris)
|
uris: Array.isArray(login.uris)
|
||||||
? login.uris.map((uri) => ({
|
? login.uris.map((uri) => ({
|
||||||
...cloneValue(uri),
|
...(cloneWithoutDecodedFields(uri) || {}),
|
||||||
uri: uri?.uri ?? null,
|
uri: uri?.uri ?? null,
|
||||||
uriChecksum: uri?.uriChecksum ?? null,
|
uriChecksum: uri?.uriChecksum ?? null,
|
||||||
match: (uri as { match?: unknown })?.match ?? null,
|
match: (uri as { match?: unknown })?.match ?? null,
|
||||||
@@ -280,6 +280,7 @@ function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
|
|||||||
|
|
||||||
out.sshKey = cipher.sshKey
|
out.sshKey = cipher.sshKey
|
||||||
? {
|
? {
|
||||||
|
...(cloneWithoutDecodedFields(cipher.sshKey) || {}),
|
||||||
privateKey: cipher.sshKey.privateKey ?? null,
|
privateKey: cipher.sshKey.privateKey ?? null,
|
||||||
publicKey: cipher.sshKey.publicKey ?? null,
|
publicKey: cipher.sshKey.publicKey ?? null,
|
||||||
keyFingerprint: cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? 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,
|
fingerprint: cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
out.bankAccount = cloneWithoutDecodedFields(cipher.bankAccount) ?? null;
|
||||||
|
out.driversLicense = cloneWithoutDecodedFields(cipher.driversLicense) ?? null;
|
||||||
|
out.passport = cloneWithoutDecodedFields(cipher.passport) ?? null;
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -331,8 +335,8 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
|
|||||||
out.login = null;
|
out.login = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
out.card = cipher.card ? await deepDecryptUnknown(cipher.card, 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(cipher.identity, keyParts.enc, keyParts.mac) : null;
|
out.identity = cipher.identity ? await deepDecryptUnknown(cloneWithoutDecodedFields(cipher.identity), keyParts.enc, keyParts.mac) : null;
|
||||||
if (cipher.sshKey) {
|
if (cipher.sshKey) {
|
||||||
const fingerprint = await decryptMaybe(
|
const fingerprint = await decryptMaybe(
|
||||||
cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
|
cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
|
||||||
@@ -340,6 +344,7 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
|
|||||||
keyParts.mac
|
keyParts.mac
|
||||||
);
|
);
|
||||||
out.sshKey = {
|
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),
|
privateKey: await decryptMaybe(cipher.sshKey.privateKey ?? null, keyParts.enc, keyParts.mac),
|
||||||
publicKey: await decryptMaybe(cipher.sshKey.publicKey ?? null, keyParts.enc, keyParts.mac),
|
publicKey: await decryptMaybe(cipher.sshKey.publicKey ?? null, keyParts.enc, keyParts.mac),
|
||||||
keyFingerprint: fingerprint,
|
keyFingerprint: fingerprint,
|
||||||
@@ -349,6 +354,15 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
|
|||||||
} else {
|
} else {
|
||||||
out.sshKey = null;
|
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
|
out.secureNote = cipher.secureNote
|
||||||
? {
|
? {
|
||||||
type: normalizeNumber((cipher.secureNote as { type?: unknown }).type, 0),
|
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 === 3) return 'card';
|
||||||
if (type === 4) return 'identity';
|
if (type === 4) return 'identity';
|
||||||
if (type === 5) return 'sshKey';
|
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';
|
if (type === 2) return 'note';
|
||||||
return `type ${type}`;
|
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[]> = {
|
const BITWARDEN_CSV_OBJECT_FIELDS: Record<string, readonly string[]> = {
|
||||||
card: ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code'],
|
card: ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code'],
|
||||||
identity: [
|
identity: [
|
||||||
@@ -472,6 +499,9 @@ const BITWARDEN_CSV_OBJECT_FIELDS: Record<string, readonly string[]> = {
|
|||||||
'country',
|
'country',
|
||||||
],
|
],
|
||||||
sshKey: ['privateKey', 'publicKey', 'keyFingerprint', 'fingerprint'],
|
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 {
|
function appendKnownRecordFieldLines(lines: string[], prefix: string, value: unknown): void {
|
||||||
|
|||||||
+21
-4
@@ -10,10 +10,14 @@ export type Locale =
|
|||||||
| 'zh-CN'
|
| 'zh-CN'
|
||||||
| 'zh-TW'
|
| 'zh-TW'
|
||||||
| 'ru'
|
| 'ru'
|
||||||
| 'es';
|
| 'es'
|
||||||
|
| 'fi'
|
||||||
|
| 'de'
|
||||||
|
| 'fr'
|
||||||
|
| 'it'
|
||||||
|
| 'sv';
|
||||||
|
|
||||||
import enMessages from './i18n/locales/en';
|
import enMessages from './i18n/locales/en';
|
||||||
|
|
||||||
const LOCALE_STORAGE_KEY = 'nodewarden.locale';
|
const LOCALE_STORAGE_KEY = 'nodewarden.locale';
|
||||||
|
|
||||||
type MessageTable = Record<string, string>;
|
type MessageTable = Record<string, string>;
|
||||||
@@ -24,6 +28,11 @@ export const AVAILABLE_LOCALES: readonly { value: Locale; label: string }[] = [
|
|||||||
{ value: 'zh-TW', label: '繁體中文' },
|
{ value: 'zh-TW', label: '繁體中文' },
|
||||||
{ value: 'ru', label: 'Русский' },
|
{ value: 'ru', label: 'Русский' },
|
||||||
{ value: 'es', label: 'Español' },
|
{ 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();
|
let locale: Locale = resolveInitialLocale();
|
||||||
@@ -49,6 +58,11 @@ function resolveInitialLocale(): Locale {
|
|||||||
if (normalized.startsWith('zh')) return 'zh-CN';
|
if (normalized.startsWith('zh')) return 'zh-CN';
|
||||||
if (normalized.startsWith('ru')) return 'ru';
|
if (normalized.startsWith('ru')) return 'ru';
|
||||||
if (normalized.startsWith('es')) return 'es';
|
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';
|
return 'en';
|
||||||
@@ -60,6 +74,11 @@ const localeLoaders: Record<Locale, () => Promise<{ default: MessageTable }>> =
|
|||||||
'zh-TW': () => import('./i18n/locales/zh-TW'),
|
'zh-TW': () => import('./i18n/locales/zh-TW'),
|
||||||
ru: () => import('./i18n/locales/ru'),
|
ru: () => import('./i18n/locales/ru'),
|
||||||
es: () => import('./i18n/locales/es'),
|
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 {
|
function localeToHtmlLang(value: Locale): string {
|
||||||
@@ -74,7 +93,6 @@ function syncDocumentLanguage(): void {
|
|||||||
async function loadLocaleMessages(next: Locale): Promise<MessageTable> {
|
async function loadLocaleMessages(next: Locale): Promise<MessageTable> {
|
||||||
const cached = loadedMessages.get(next);
|
const cached = loadedMessages.get(next);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const mod = await localeLoaders[next]();
|
const mod = await localeLoaders[next]();
|
||||||
loadedMessages.set(next, mod.default);
|
loadedMessages.set(next, mod.default);
|
||||||
return 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 is required': 'txt_server_error_master_password_hash_required',
|
||||||
'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required',
|
'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required',
|
||||||
}[normalized];
|
}[normalized];
|
||||||
|
|
||||||
return key ? t(key) : normalized;
|
return key ? t(key) : normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,30 @@ const en: Record<string, string> = {
|
|||||||
"nav_my_vault": "My Vault",
|
"nav_my_vault": "My Vault",
|
||||||
"nav_vault_items": "Vault",
|
"nav_vault_items": "Vault",
|
||||||
"nav_sends": "Sends",
|
"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_backup_strategy": "Cloud Backup",
|
||||||
"nav_import_export": "Import & Export",
|
"nav_import_export": "Import & Export",
|
||||||
"nav_group_data_backup": "Data & Backup",
|
"nav_group_data_backup": "Data & Backup",
|
||||||
@@ -688,6 +712,35 @@ const en: Record<string, string> = {
|
|||||||
"txt_last_name": "Last Name",
|
"txt_last_name": "Last Name",
|
||||||
"txt_last_seen": "Last Seen",
|
"txt_last_seen": "Last Seen",
|
||||||
"txt_license_number": "License Number",
|
"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_link_copied": "Link copied",
|
||||||
"txt_linked": "Linked",
|
"txt_linked": "Linked",
|
||||||
"txt_linux_desktop": "Linux Desktop",
|
"txt_linux_desktop": "Linux Desktop",
|
||||||
@@ -983,6 +1036,9 @@ const en: Record<string, string> = {
|
|||||||
"txt_online": "Online",
|
"txt_online": "Online",
|
||||||
"txt_offline": "Offline",
|
"txt_offline": "Offline",
|
||||||
"txt_offline_vault_readonly": "Offline mode is read-only. Connect to NodeWarden before changing your vault.",
|
"txt_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_submit": "Submit",
|
||||||
"txt_sync": "Sync",
|
"txt_sync": "Sync",
|
||||||
"txt_sync_vault": "Sync Vault",
|
"txt_sync_vault": "Sync Vault",
|
||||||
@@ -1014,6 +1070,8 @@ const en: Record<string, string> = {
|
|||||||
"txt_totp_qr_scanned": "TOTP value added.",
|
"txt_totp_qr_scanned": "TOTP value added.",
|
||||||
"txt_totp_qr_not_found": "No QR code found in that image.",
|
"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_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_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_camera_unavailable": "Camera is unavailable. Check browser permission, or choose an image.",
|
||||||
"txt_totp_qr_choose_image": "Choose 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_invalid_password_protected_file": "Invalid password-protected export file.",
|
||||||
"txt_import_decrypt_failed": "Failed to decrypt import file.",
|
"txt_import_decrypt_failed": "Failed to decrypt import file.",
|
||||||
"txt_import_empty_zip_archive": "Empty zip archive.",
|
"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_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_data_json_not_found": "data.json not found in zip archive.",
|
||||||
"txt_import_zip_password_required": "ZIP password is required.",
|
"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_create": "Create API key",
|
||||||
"txt_log_action_account_api_key_rotate": "Rotate 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_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_profile_update": "Update account profile",
|
||||||
"txt_log_action_account_totp_disable": "Disable two-step login",
|
"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_enable": "Enable two-step login",
|
||||||
"txt_log_action_account_totp_recover": "Recover 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_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_audit_settings_update": "Update log retention settings",
|
||||||
"txt_log_action_admin_backup_export": "Export backup",
|
"txt_log_action_admin_backup_export": "Export backup",
|
||||||
"txt_log_action_admin_backup_import": "Import 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_bad_password": "Login failed: bad password",
|
||||||
"txt_log_action_auth_login_failed_user_inactive": "Login failed: inactive account",
|
"txt_log_action_auth_login_failed_user_inactive": "Login failed: inactive account",
|
||||||
"txt_log_action_auth_login_success": "Login succeeded",
|
"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_auth_refresh_failed": "Refresh login failed: {reason}",
|
||||||
"txt_log_action_cipher_delete_permanent": "Permanently delete vault item",
|
"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_permanent_bulk": "Permanently delete vault items",
|
||||||
@@ -1296,6 +1369,7 @@ const en: Record<string, string> = {
|
|||||||
"txt_log_meta_method": "Request method",
|
"txt_log_meta_method": "Request method",
|
||||||
"txt_log_meta_path": "Request path",
|
"txt_log_meta_path": "Request path",
|
||||||
"txt_log_meta_provider": "Provider",
|
"txt_log_meta_provider": "Provider",
|
||||||
|
"txt_log_meta_prf_status": "PRF status",
|
||||||
"txt_log_meta_prune_error": "Cleanup error",
|
"txt_log_meta_prune_error": "Cleanup error",
|
||||||
"txt_log_meta_pruned_file_count": "Cleaned files",
|
"txt_log_meta_pruned_file_count": "Cleaned files",
|
||||||
"txt_log_meta_raw": "Raw data",
|
"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_inactive": "User inactive",
|
||||||
"txt_log_reason_user_missing": "User missing",
|
"txt_log_reason_user_missing": "User missing",
|
||||||
"txt_log_target_type_attachment": "Attachment",
|
"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_audit_log": "Log",
|
||||||
"txt_log_target_type_backup": "Backup",
|
"txt_log_target_type_backup": "Backup",
|
||||||
"txt_log_target_type_cipher": "Vault item",
|
"txt_log_target_type_cipher": "Vault item",
|
||||||
@@ -1396,4 +1471,39 @@ const en: Record<string, string> = {
|
|||||||
"txt_ip_address": "IP address"
|
"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;
|
export default en;
|
||||||
|
|||||||
+182
-122
@@ -7,6 +7,7 @@ const es: Record<string, string> = {
|
|||||||
"nav_my_vault": "Mi bóveda",
|
"nav_my_vault": "Mi bóveda",
|
||||||
"nav_vault_items": "Bóveda",
|
"nav_vault_items": "Bóveda",
|
||||||
"nav_sends": "Envíos",
|
"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_backup_strategy": "Copia de seguridad en la nube",
|
||||||
"nav_import_export": "Importar y exportar",
|
"nav_import_export": "Importar y exportar",
|
||||||
"nav_group_data_backup": "Datos y copias",
|
"nav_group_data_backup": "Datos y copias",
|
||||||
@@ -688,6 +689,35 @@ const es: Record<string, string> = {
|
|||||||
"txt_last_name": "Apellido",
|
"txt_last_name": "Apellido",
|
||||||
"txt_last_seen": "Visto por última vez",
|
"txt_last_seen": "Visto por última vez",
|
||||||
"txt_license_number": "Número de licencia",
|
"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_link_copied": "Enlace copiado",
|
||||||
"txt_linked": "Vinculado",
|
"txt_linked": "Vinculado",
|
||||||
"txt_linux_desktop": "Escritorio Linux",
|
"txt_linux_desktop": "Escritorio Linux",
|
||||||
@@ -983,6 +1013,9 @@ const es: Record<string, string> = {
|
|||||||
"txt_online": "En línea",
|
"txt_online": "En línea",
|
||||||
"txt_offline": "Sin conexión",
|
"txt_offline": "Sin conexión",
|
||||||
"txt_offline_vault_readonly": "El modo sin conexión es de solo lectura. Conecta con NodeWarden antes de cambiar la bóveda.",
|
"txt_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_submit": "Enviar",
|
||||||
"txt_sync": "Sincronizar",
|
"txt_sync": "Sincronizar",
|
||||||
"txt_sync_vault": "Sincronizar bóveda",
|
"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_scanned": "Valor TOTP agregado.",
|
||||||
"txt_totp_qr_not_found": "No se encontró ningún código QR en esa imagen.",
|
"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_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_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_camera_unavailable": "La cámara no está disponible. Revise el permiso del navegador o elija una imagen.",
|
||||||
"txt_totp_qr_choose_image": "Elegir 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_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_decrypt_failed": "Error al descifrar el archivo de importación.",
|
||||||
"txt_import_empty_zip_archive": "El archivo ZIP está vacío.",
|
"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_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_data_json_not_found": "No se encontró data.json en el archivo ZIP.",
|
||||||
"txt_import_zip_password_required": "La contraseña ZIP es obligatoria.",
|
"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_info": "Info",
|
||||||
"txt_log_level_security": "Seguridad",
|
"txt_log_level_security": "Seguridad",
|
||||||
"txt_log_level_warn": "Aviso",
|
"txt_log_level_warn": "Aviso",
|
||||||
"txt_log_action_account_api_key_create": "Create API key",
|
"txt_log_action_account_api_key_create": "Crear clave de API",
|
||||||
"txt_log_action_account_api_key_rotate": "Rotate API key",
|
"txt_log_action_account_api_key_rotate": "Rotar clave de API",
|
||||||
"txt_log_action_account_keys_update": "Update account keys",
|
"txt_log_action_account_keys_update": "Actualizar claves de cuenta",
|
||||||
"txt_log_action_account_profile_update": "Update account profile",
|
"txt_log_action_account_passkey_create": "Crear passkey de inicio de sesión",
|
||||||
"txt_log_action_account_totp_disable": "Disable two-step login",
|
"txt_log_action_account_passkey_delete": "Eliminar passkey de inicio de sesión",
|
||||||
"txt_log_action_account_totp_enable": "Enable two-step login",
|
"txt_log_action_account_passkey_encryption_enable": "Activar desbloqueo de la bóveda con passkey",
|
||||||
"txt_log_action_account_totp_recover": "Recover two-step login",
|
"txt_log_action_account_profile_update": "Actualizar perfil de cuenta",
|
||||||
"txt_log_action_account_verify_devices_update": "Update device verification",
|
"txt_log_action_account_totp_disable": "Desactivar verificación en dos pasos",
|
||||||
"txt_log_action_admin_audit_settings_update": "Update log retention settings",
|
"txt_log_action_account_totp_enable": "Activar verificación en dos pasos",
|
||||||
"txt_log_action_admin_backup_export": "Export backup",
|
"txt_log_action_account_totp_recover": "Recuperar verificación en dos pasos",
|
||||||
"txt_log_action_admin_backup_import": "Import backup",
|
"txt_log_action_account_verify_devices_update": "Actualizar verificación de dispositivos",
|
||||||
"txt_log_action_admin_backup_remote_delete": "Delete remote backup",
|
"txt_log_action_account_webauthn_2fa_delete": "Eliminar clave de verificación en dos pasos con passkey",
|
||||||
"txt_log_action_admin_backup_remote_manual": "Manual remote backup succeeded",
|
"txt_log_action_account_webauthn_2fa_enable": "Activar verificación en dos pasos con passkey",
|
||||||
"txt_log_action_admin_backup_remote_manual_failed": "Manual remote backup failed",
|
"txt_log_action_account_yubikey_enable": "Actualizar configuración de YubiKey OTP",
|
||||||
"txt_log_action_admin_backup_remote_scheduled": "Scheduled remote backup succeeded",
|
"txt_log_action_admin_audit_clear": "Borrar registros de auditoría",
|
||||||
"txt_log_action_admin_backup_remote_scheduled_failed": "Scheduled remote backup failed",
|
"txt_log_action_admin_audit_settings_update": "Actualizar retención de registros",
|
||||||
"txt_log_action_admin_backup_settings_repair": "Repair backup settings",
|
"txt_log_action_admin_backup_export": "Exportar copia de seguridad",
|
||||||
"txt_log_action_admin_backup_settings_update": "Update backup settings",
|
"txt_log_action_admin_backup_import": "Importar copia de seguridad",
|
||||||
"txt_log_action_admin_invite_create": "Create invite",
|
"txt_log_action_admin_backup_remote_delete": "Eliminar copia remota",
|
||||||
"txt_log_action_admin_invite_delete": "Delete invite",
|
"txt_log_action_admin_backup_remote_manual": "Copia remota manual completada",
|
||||||
"txt_log_action_admin_invite_delete_all": "Clear invites",
|
"txt_log_action_admin_backup_remote_manual_failed": "Error en copia remota manual",
|
||||||
"txt_log_action_admin_invite_delete_invalid": "Delete invalid invites",
|
"txt_log_action_admin_backup_remote_scheduled": "Copia remota programada completada",
|
||||||
"txt_log_action_admin_invite_revoke": "Revoke invite",
|
"txt_log_action_admin_backup_remote_scheduled_failed": "Error en copia remota programada",
|
||||||
"txt_log_action_admin_user_delete": "Delete user",
|
"txt_log_action_admin_backup_settings_repair": "Reparar configuración de copias",
|
||||||
"txt_log_action_admin_user_status": "Change user status",
|
"txt_log_action_admin_backup_settings_update": "Actualizar configuración de copias",
|
||||||
"txt_log_action_attachment_delete": "Delete attachment",
|
"txt_log_action_admin_invite_create": "Crear invitación",
|
||||||
"txt_log_action_auth_login_failed_bad_api_key": "Login failed: bad API key",
|
"txt_log_action_admin_invite_delete": "Eliminar invitación",
|
||||||
"txt_log_action_auth_login_failed_bad_password": "Login failed: bad password",
|
"txt_log_action_admin_invite_delete_all": "Borrar invitaciones",
|
||||||
"txt_log_action_auth_login_failed_user_inactive": "Login failed: inactive account",
|
"txt_log_action_admin_invite_delete_invalid": "Eliminar invitaciones no válidas",
|
||||||
"txt_log_action_auth_login_success": "Login succeeded",
|
"txt_log_action_admin_invite_revoke": "Revocar invitación",
|
||||||
"txt_log_action_auth_refresh_failed": "Refresh login failed: {reason}",
|
"txt_log_action_admin_user_delete": "Eliminar usuario",
|
||||||
"txt_log_action_cipher_delete_permanent": "Permanently delete vault item",
|
"txt_log_action_admin_user_status": "Cambiar estado del usuario",
|
||||||
"txt_log_action_cipher_delete_permanent_bulk": "Permanently delete vault items",
|
"txt_log_action_attachment_delete": "Eliminar adjunto",
|
||||||
"txt_log_action_cipher_delete_soft": "Move vault item to trash",
|
"txt_log_action_auth_login_failed_bad_api_key": "Inicio de sesión fallido: clave de API incorrecta",
|
||||||
"txt_log_action_cipher_delete_soft_bulk": "Move vault items to trash",
|
"txt_log_action_auth_login_failed_bad_password": "Inicio de sesión fallido: contraseña incorrecta",
|
||||||
"txt_log_action_device_deactivate": "Deactivate device",
|
"txt_log_action_auth_login_failed_user_inactive": "Inicio de sesión fallido: cuenta inactiva",
|
||||||
"txt_log_action_device_delete": "Delete device",
|
"txt_log_action_auth_login_success": "Inicio de sesión correcto",
|
||||||
"txt_log_action_device_delete_all": "Delete all devices",
|
"txt_log_action_auth_passkey_login_failed": "Error de inicio de sesión con passkey",
|
||||||
"txt_log_action_device_name_update": "Update device name",
|
"txt_log_action_auth_passkey_login_success": "Inicio de sesión con passkey correcto",
|
||||||
"txt_log_action_device_trust_permanent": "Trust device permanently",
|
"txt_log_action_auth_refresh_failed": "Error al renovar inicio de sesión: {reason}",
|
||||||
"txt_log_action_device_trust_revoke": "Revoke device trust",
|
"txt_log_action_cipher_delete_permanent": "Eliminar elemento de bóveda permanentemente",
|
||||||
"txt_log_action_device_trust_revoke_batch": "Revoke device trust in bulk",
|
"txt_log_action_cipher_delete_permanent_bulk": "Eliminar elementos de bóveda permanentemente",
|
||||||
"txt_log_action_folder_delete": "Delete folder",
|
"txt_log_action_cipher_delete_soft": "Mover elemento de bóveda a la papelera",
|
||||||
"txt_log_action_folder_delete_bulk": "Delete folders",
|
"txt_log_action_cipher_delete_soft_bulk": "Mover elementos de bóveda a la papelera",
|
||||||
"txt_log_action_send_auth_remove": "Remove Send authentication",
|
"txt_log_action_device_deactivate": "Desactivar dispositivo",
|
||||||
"txt_log_action_send_delete": "Delete Send",
|
"txt_log_action_device_delete": "Eliminar dispositivo",
|
||||||
"txt_log_action_send_delete_bulk": "Delete Sends",
|
"txt_log_action_device_delete_all": "Eliminar todos los dispositivos",
|
||||||
"txt_log_action_send_password_remove": "Remove Send password",
|
"txt_log_action_device_name_update": "Actualizar nombre del dispositivo",
|
||||||
"txt_log_action_user_password_change": "Change master password",
|
"txt_log_action_device_trust_permanent": "Confiar permanentemente en el dispositivo",
|
||||||
"txt_log_action_user_register_first_admin": "Register first admin",
|
"txt_log_action_device_trust_revoke": "Revocar confianza del dispositivo",
|
||||||
"txt_log_action_user_register_invite": "Register by invite",
|
"txt_log_action_device_trust_revoke_batch": "Revocar confianza de dispositivos en lote",
|
||||||
"txt_log_meta_attachments": "Attachments",
|
"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_bytes": "Bytes",
|
||||||
"txt_log_meta_changed": "Changed fields",
|
"txt_log_meta_changed": "Campos modificados",
|
||||||
"txt_log_meta_checksum_mismatch_accepted": "Accepted checksum mismatch",
|
"txt_log_meta_checksum_mismatch_accepted": "Desajuste de checksum aceptado",
|
||||||
"txt_log_meta_cipher_id": "Vault item ID",
|
"txt_log_meta_cipher_id": "ID del elemento de bóveda",
|
||||||
"txt_log_meta_ciphers": "Vault items",
|
"txt_log_meta_ciphers": "Elementos de bóveda",
|
||||||
"txt_log_meta_compat": "Compatibility",
|
"txt_log_meta_compat": "Compatibilidad",
|
||||||
"txt_log_meta_compressed_bytes": "Compressed bytes",
|
"txt_log_meta_compressed_bytes": "Bytes comprimidos",
|
||||||
"txt_log_meta_count": "Count",
|
"txt_log_meta_count": "Cantidad",
|
||||||
"txt_log_meta_deleted": "Deleted count",
|
"txt_log_meta_deleted": "Cantidad eliminada",
|
||||||
"txt_log_meta_destination_count": "Destination count",
|
"txt_log_meta_destination_count": "Cantidad de destinos",
|
||||||
"txt_log_meta_destination_id": "Destination ID",
|
"txt_log_meta_destination_id": "ID de destino",
|
||||||
"txt_log_meta_destination_name": "Destination name",
|
"txt_log_meta_destination_name": "Nombre de destino",
|
||||||
"txt_log_meta_destination_type": "Destination type",
|
"txt_log_meta_destination_type": "Tipo de destino",
|
||||||
"txt_log_meta_device_identifier": "Device ID",
|
"txt_log_meta_device_identifier": "ID del dispositivo",
|
||||||
"txt_log_meta_device_type": "Device type",
|
"txt_log_meta_device_type": "Tipo de dispositivo",
|
||||||
"txt_log_meta_email": "Email",
|
"txt_log_meta_email": "Email",
|
||||||
"txt_log_meta_error": "Error",
|
"txt_log_meta_error": "Error",
|
||||||
"txt_log_meta_expires_in_hours": "Expires in hours",
|
"txt_log_meta_expires_in_hours": "Caduca en horas",
|
||||||
"txt_log_meta_file_bytes": "File bytes",
|
"txt_log_meta_file_bytes": "Bytes del archivo",
|
||||||
"txt_log_meta_file_name": "File name",
|
"txt_log_meta_file_name": "Nombre del archivo",
|
||||||
"txt_log_meta_folder_id": "Folder ID",
|
"txt_log_meta_folder_id": "ID de carpeta",
|
||||||
"txt_log_meta_grant_type": "Login method",
|
"txt_log_meta_grant_type": "Método de inicio de sesión",
|
||||||
"txt_log_meta_includes_attachments": "Includes attachments",
|
"txt_log_meta_includes_attachments": "Incluye adjuntos",
|
||||||
"txt_log_meta_ip": "IP address",
|
"txt_log_meta_ip": "Dirección IP",
|
||||||
"txt_log_meta_max_entries": "Entry limit",
|
"txt_log_meta_max_entries": "Límite de entradas",
|
||||||
"txt_log_meta_method": "Request method",
|
"txt_log_meta_method": "Método de solicitud",
|
||||||
"txt_log_meta_path": "Request path",
|
"txt_log_meta_path": "Ruta de solicitud",
|
||||||
"txt_log_meta_provider": "Provider",
|
"txt_log_meta_provider": "Proveedor",
|
||||||
"txt_log_meta_prune_error": "Cleanup error",
|
"txt_log_meta_prf_status": "Estado de PRF",
|
||||||
"txt_log_meta_pruned_file_count": "Cleaned files",
|
"txt_log_meta_prune_error": "Error de limpieza",
|
||||||
"txt_log_meta_raw": "Raw data",
|
"txt_log_meta_pruned_file_count": "Archivos limpiados",
|
||||||
"txt_log_meta_reason": "Reason",
|
"txt_log_meta_raw": "Datos sin procesar",
|
||||||
"txt_log_meta_remote_path": "Remote path",
|
"txt_log_meta_reason": "Motivo",
|
||||||
"txt_log_meta_removed": "Removed count",
|
"txt_log_meta_remote_path": "Ruta remota",
|
||||||
"txt_log_meta_removed_devices": "Removed devices",
|
"txt_log_meta_removed": "Cantidad quitada",
|
||||||
"txt_log_meta_removed_sessions": "Removed sessions",
|
"txt_log_meta_removed_devices": "Dispositivos quitados",
|
||||||
"txt_log_meta_removed_trusted": "Trust removals",
|
"txt_log_meta_removed_sessions": "Sesiones quitadas",
|
||||||
"txt_log_meta_replace_existing": "Replace existing data",
|
"txt_log_meta_removed_trusted": "Confianzas revocadas",
|
||||||
"txt_log_meta_requested": "Requested count",
|
"txt_log_meta_replace_existing": "Reemplazar datos existentes",
|
||||||
"txt_log_meta_requested_count": "Requested count",
|
"txt_log_meta_requested": "Cantidad solicitada",
|
||||||
"txt_log_meta_retention_days": "Retention days",
|
"txt_log_meta_requested_count": "Cantidad solicitada",
|
||||||
"txt_log_meta_scheduled_destination_count": "Scheduled destinations",
|
"txt_log_meta_retention_days": "Días de retención",
|
||||||
"txt_log_meta_size": "Size",
|
"txt_log_meta_scheduled_destination_count": "Destinos programados",
|
||||||
"txt_log_meta_skipped_attachments": "Skipped attachments",
|
"txt_log_meta_size": "Tamaño",
|
||||||
"txt_log_meta_skipped_reason": "Skip reason",
|
"txt_log_meta_skipped_attachments": "Adjuntos omitidos",
|
||||||
"txt_log_meta_status": "Status",
|
"txt_log_meta_skipped_reason": "Motivo de omisión",
|
||||||
"txt_log_meta_target_email": "Target email",
|
"txt_log_meta_status": "Estado",
|
||||||
"txt_log_meta_trigger": "Trigger",
|
"txt_log_meta_target_email": "Correo del destino",
|
||||||
"txt_log_meta_type": "Type",
|
"txt_log_meta_trigger": "Disparador",
|
||||||
"txt_log_meta_updated": "Updated count",
|
"txt_log_meta_type": "Tipo",
|
||||||
"txt_log_meta_upload_verification_attempts": "Upload verification attempts",
|
"txt_log_meta_updated": "Cantidad actualizada",
|
||||||
"txt_log_meta_user_agent": "Browser/client",
|
"txt_log_meta_upload_verification_attempts": "Intentos de verificación de subida",
|
||||||
"txt_log_meta_users": "Users",
|
"txt_log_meta_user_agent": "Navegador/cliente",
|
||||||
"txt_log_meta_verify_devices": "Verify devices",
|
"txt_log_meta_users": "Usuarios",
|
||||||
"txt_log_meta_web_session": "Web session",
|
"txt_log_meta_verify_devices": "Verificar dispositivos",
|
||||||
"txt_log_reason_bad_api_key": "Bad API key",
|
"txt_log_meta_web_session": "Sesión web",
|
||||||
"txt_log_reason_bad_password": "Bad password",
|
"txt_log_reason_bad_api_key": "Clave de API incorrecta",
|
||||||
"txt_log_reason_device_missing": "Device missing",
|
"txt_log_reason_bad_password": "Contraseña incorrecta",
|
||||||
"txt_log_reason_device_session_mismatch": "Device session mismatch",
|
"txt_log_reason_device_missing": "Dispositivo no encontrado",
|
||||||
"txt_log_reason_token_not_found_or_expired": "Token missing or expired",
|
"txt_log_reason_device_session_mismatch": "La sesión no coincide con el dispositivo",
|
||||||
"txt_log_reason_user_inactive": "User inactive",
|
"txt_log_reason_token_not_found_or_expired": "Token no encontrado o caducado",
|
||||||
"txt_log_reason_user_missing": "User missing",
|
"txt_log_reason_user_inactive": "Usuario inactivo",
|
||||||
"txt_log_target_type_attachment": "Attachment",
|
"txt_log_reason_user_missing": "Usuario no encontrado",
|
||||||
"txt_log_target_type_audit_log": "Log",
|
"txt_log_target_type_attachment": "Adjunto",
|
||||||
"txt_log_target_type_backup": "Backup",
|
"txt_log_target_type_account_passkey": "Passkey de inicio de sesión",
|
||||||
"txt_log_target_type_cipher": "Vault item",
|
"txt_log_target_type_audit_log": "Registro",
|
||||||
"txt_log_target_type_device": "Device",
|
"txt_log_target_type_backup": "Copia de seguridad",
|
||||||
"txt_log_target_type_folder": "Folder",
|
"txt_log_target_type_cipher": "Elemento de bóveda",
|
||||||
"txt_log_target_type_invite": "Invite",
|
"txt_log_target_type_device": "Dispositivo",
|
||||||
"txt_log_target_type_refresh_token": "Refresh token",
|
"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_send": "Send",
|
||||||
"txt_log_target_type_user": "User",
|
"txt_log_target_type_user": "Usuario",
|
||||||
"txt_log_trigger_manual": "Manual",
|
"txt_log_trigger_manual": "Manual",
|
||||||
"txt_log_trigger_remote": "Remote",
|
"txt_log_trigger_remote": "Remoto",
|
||||||
"txt_log_trigger_scheduled": "Scheduled",
|
"txt_log_trigger_scheduled": "Programado",
|
||||||
"txt_log_max_1000": "Hasta 1000 entradas",
|
"txt_log_max_1000": "Hasta 1000 entradas",
|
||||||
"txt_log_max_5000": "Hasta 5000 entradas",
|
"txt_log_max_5000": "Hasta 5000 entradas",
|
||||||
"txt_log_max_10000": "Hasta 10 000 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"
|
"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;
|
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
@@ -8,6 +8,7 @@ const ru: Record<string, string> = {
|
|||||||
"nav_my_vault": "Мое хранилище",
|
"nav_my_vault": "Мое хранилище",
|
||||||
"nav_vault_items": "Хранилище",
|
"nav_vault_items": "Хранилище",
|
||||||
"nav_sends": "Отправляет",
|
"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_backup_strategy": "Облачное резервное копирование",
|
||||||
"nav_import_export": "Импорт и экспорт",
|
"nav_import_export": "Импорт и экспорт",
|
||||||
"nav_group_data_backup": "Данные и резервные копии",
|
"nav_group_data_backup": "Данные и резервные копии",
|
||||||
@@ -688,6 +689,35 @@ const ru: Record<string, string> = {
|
|||||||
"txt_last_name": "Фамилия",
|
"txt_last_name": "Фамилия",
|
||||||
"txt_last_seen": "Последний визит",
|
"txt_last_seen": "Последний визит",
|
||||||
"txt_license_number": "Номер лицензии",
|
"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_link_copied": "Ссылка скопирована",
|
||||||
"txt_linked": "Связано",
|
"txt_linked": "Связано",
|
||||||
"txt_linux_desktop": "Рабочий стол Linux",
|
"txt_linux_desktop": "Рабочий стол Linux",
|
||||||
@@ -983,6 +1013,9 @@ const ru: Record<string, string> = {
|
|||||||
"txt_online": "Онлайн",
|
"txt_online": "Онлайн",
|
||||||
"txt_offline": "Офлайн",
|
"txt_offline": "Офлайн",
|
||||||
"txt_offline_vault_readonly": "Автономный режим доступен только для чтения. Подключитесь к NodeWarden, чтобы изменить хранилище.",
|
"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_submit": "Отправить",
|
||||||
"txt_sync": "Синхронизировать",
|
"txt_sync": "Синхронизировать",
|
||||||
"txt_sync_vault": "Синхронизировать хранилище",
|
"txt_sync_vault": "Синхронизировать хранилище",
|
||||||
@@ -1014,6 +1047,8 @@ const ru: Record<string, string> = {
|
|||||||
"txt_totp_qr_scanned": "Значение TOTP добавлено.",
|
"txt_totp_qr_scanned": "Значение TOTP добавлено.",
|
||||||
"txt_totp_qr_not_found": "QR-код на этом изображении не найден.",
|
"txt_totp_qr_not_found": "QR-код на этом изображении не найден.",
|
||||||
"txt_totp_qr_scan_failed": "Не удалось отсканировать 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_unsupported": "Этот браузер не поддерживает сканирование QR. Попробуйте Chrome или Edge либо вставьте ссылку или секрет TOTP вручную.",
|
||||||
"txt_totp_qr_camera_unavailable": "Камера недоступна. Проверьте разрешение браузера или выберите изображение.",
|
"txt_totp_qr_camera_unavailable": "Камера недоступна. Проверьте разрешение браузера или выберите изображение.",
|
||||||
"txt_totp_qr_choose_image": "Выбрать изображение",
|
"txt_totp_qr_choose_image": "Выбрать изображение",
|
||||||
@@ -1125,6 +1160,12 @@ const ru: Record<string, string> = {
|
|||||||
"txt_import_invalid_password_protected_file": "Неверный файл экспорта, защищенный паролем.",
|
"txt_import_invalid_password_protected_file": "Неверный файл экспорта, защищенный паролем.",
|
||||||
"txt_import_decrypt_failed": "Не удалось расшифровать файл импорта.",
|
"txt_import_decrypt_failed": "Не удалось расшифровать файл импорта.",
|
||||||
"txt_import_empty_zip_archive": "Пустой zip-архив.",
|
"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_no_json_found_in_zip": "В zip-архиве не найдены импортируемые данные JSON.",
|
||||||
"txt_import_data_json_not_found": "data.json не найден в zip-архиве.",
|
"txt_import_data_json_not_found": "data.json не найден в zip-архиве.",
|
||||||
"txt_import_zip_password_required": "Требуется пароль ZIP.",
|
"txt_import_zip_password_required": "Требуется пароль ZIP.",
|
||||||
@@ -1216,133 +1257,144 @@ const ru: Record<string, string> = {
|
|||||||
"txt_log_level_info": "Инфо",
|
"txt_log_level_info": "Инфо",
|
||||||
"txt_log_level_security": "Безопасность",
|
"txt_log_level_security": "Безопасность",
|
||||||
"txt_log_level_warn": "Предупреждение",
|
"txt_log_level_warn": "Предупреждение",
|
||||||
"txt_log_action_account_api_key_create": "Create API key",
|
"txt_log_action_account_api_key_create": "Создание API-ключа",
|
||||||
"txt_log_action_account_api_key_rotate": "Rotate API key",
|
"txt_log_action_account_api_key_rotate": "Ротация API-ключа",
|
||||||
"txt_log_action_account_keys_update": "Update account keys",
|
"txt_log_action_account_keys_update": "Обновление ключей учетной записи",
|
||||||
"txt_log_action_account_profile_update": "Update account profile",
|
"txt_log_action_account_passkey_create": "Создание ключа входа",
|
||||||
"txt_log_action_account_totp_disable": "Disable two-step login",
|
"txt_log_action_account_passkey_delete": "Удаление ключа входа",
|
||||||
"txt_log_action_account_totp_enable": "Enable two-step login",
|
"txt_log_action_account_passkey_encryption_enable": "Включение разблокировки хранилища ключом доступа",
|
||||||
"txt_log_action_account_totp_recover": "Recover two-step login",
|
"txt_log_action_account_profile_update": "Обновление профиля учетной записи",
|
||||||
"txt_log_action_account_verify_devices_update": "Update device verification",
|
"txt_log_action_account_totp_disable": "Отключение двухфакторной проверки",
|
||||||
"txt_log_action_admin_audit_settings_update": "Update log retention settings",
|
"txt_log_action_account_totp_enable": "Включение двухфакторной проверки",
|
||||||
"txt_log_action_admin_backup_export": "Export backup",
|
"txt_log_action_account_totp_recover": "Восстановление двухфакторной проверки",
|
||||||
"txt_log_action_admin_backup_import": "Import backup",
|
"txt_log_action_account_verify_devices_update": "Обновление проверки устройств",
|
||||||
"txt_log_action_admin_backup_remote_delete": "Delete remote backup",
|
"txt_log_action_account_webauthn_2fa_delete": "Удаление ключа двухфакторной проверки",
|
||||||
"txt_log_action_admin_backup_remote_manual": "Manual remote backup succeeded",
|
"txt_log_action_account_webauthn_2fa_enable": "Включение двухфакторной проверки ключом доступа",
|
||||||
"txt_log_action_admin_backup_remote_manual_failed": "Manual remote backup failed",
|
"txt_log_action_account_yubikey_enable": "Обновление настроек YubiKey OTP",
|
||||||
"txt_log_action_admin_backup_remote_scheduled": "Scheduled remote backup succeeded",
|
"txt_log_action_admin_audit_clear": "Очистка журнала аудита",
|
||||||
"txt_log_action_admin_backup_remote_scheduled_failed": "Scheduled remote backup failed",
|
"txt_log_action_admin_audit_settings_update": "Обновление хранения журналов",
|
||||||
"txt_log_action_admin_backup_settings_repair": "Repair backup settings",
|
"txt_log_action_admin_backup_export": "Экспорт резервной копии",
|
||||||
"txt_log_action_admin_backup_settings_update": "Update backup settings",
|
"txt_log_action_admin_backup_import": "Импорт резервной копии",
|
||||||
"txt_log_action_admin_invite_create": "Create invite",
|
"txt_log_action_admin_backup_remote_delete": "Удаление удаленной резервной копии",
|
||||||
"txt_log_action_admin_invite_delete": "Delete invite",
|
"txt_log_action_admin_backup_remote_manual": "Ручное удаленное резервное копирование выполнено",
|
||||||
"txt_log_action_admin_invite_delete_all": "Clear invites",
|
"txt_log_action_admin_backup_remote_manual_failed": "Ошибка ручного удаленного резервного копирования",
|
||||||
"txt_log_action_admin_invite_delete_invalid": "Delete invalid invites",
|
"txt_log_action_admin_backup_remote_scheduled": "Запланированное удаленное резервное копирование выполнено",
|
||||||
"txt_log_action_admin_invite_revoke": "Revoke invite",
|
"txt_log_action_admin_backup_remote_scheduled_failed": "Ошибка запланированного удаленного резервного копирования",
|
||||||
"txt_log_action_admin_user_delete": "Delete user",
|
"txt_log_action_admin_backup_settings_repair": "Восстановление настроек резервного копирования",
|
||||||
"txt_log_action_admin_user_status": "Change user status",
|
"txt_log_action_admin_backup_settings_update": "Обновление настроек резервного копирования",
|
||||||
"txt_log_action_attachment_delete": "Delete attachment",
|
"txt_log_action_admin_invite_create": "Создание приглашения",
|
||||||
"txt_log_action_auth_login_failed_bad_api_key": "Login failed: bad API key",
|
"txt_log_action_admin_invite_delete": "Удаление приглашения",
|
||||||
"txt_log_action_auth_login_failed_bad_password": "Login failed: bad password",
|
"txt_log_action_admin_invite_delete_all": "Очистка приглашений",
|
||||||
"txt_log_action_auth_login_failed_user_inactive": "Login failed: inactive account",
|
"txt_log_action_admin_invite_delete_invalid": "Удаление недействительных приглашений",
|
||||||
"txt_log_action_auth_login_success": "Login succeeded",
|
"txt_log_action_admin_invite_revoke": "Отзыв приглашения",
|
||||||
"txt_log_action_auth_refresh_failed": "Refresh login failed: {reason}",
|
"txt_log_action_admin_user_delete": "Удаление пользователя",
|
||||||
"txt_log_action_cipher_delete_permanent": "Permanently delete vault item",
|
"txt_log_action_admin_user_status": "Изменение статуса пользователя",
|
||||||
"txt_log_action_cipher_delete_permanent_bulk": "Permanently delete vault items",
|
"txt_log_action_attachment_delete": "Удаление вложения",
|
||||||
"txt_log_action_cipher_delete_soft": "Move vault item to trash",
|
"txt_log_action_auth_login_failed_bad_api_key": "Ошибка входа: неверный API-ключ",
|
||||||
"txt_log_action_cipher_delete_soft_bulk": "Move vault items to trash",
|
"txt_log_action_auth_login_failed_bad_password": "Ошибка входа: неверный пароль",
|
||||||
"txt_log_action_device_deactivate": "Deactivate device",
|
"txt_log_action_auth_login_failed_user_inactive": "Ошибка входа: учетная запись неактивна",
|
||||||
"txt_log_action_device_delete": "Delete device",
|
"txt_log_action_auth_login_success": "Вход выполнен",
|
||||||
"txt_log_action_device_delete_all": "Delete all devices",
|
"txt_log_action_auth_passkey_login_failed": "Ошибка входа по ключу доступа",
|
||||||
"txt_log_action_device_name_update": "Update device name",
|
"txt_log_action_auth_passkey_login_success": "Вход по ключу доступа выполнен",
|
||||||
"txt_log_action_device_trust_permanent": "Trust device permanently",
|
"txt_log_action_auth_refresh_failed": "Не удалось обновить вход: {reason}",
|
||||||
"txt_log_action_device_trust_revoke": "Revoke device trust",
|
"txt_log_action_cipher_delete_permanent": "Окончательное удаление элемента хранилища",
|
||||||
"txt_log_action_device_trust_revoke_batch": "Revoke device trust in bulk",
|
"txt_log_action_cipher_delete_permanent_bulk": "Окончательное удаление элементов хранилища",
|
||||||
"txt_log_action_folder_delete": "Delete folder",
|
"txt_log_action_cipher_delete_soft": "Перемещение элемента хранилища в корзину",
|
||||||
"txt_log_action_folder_delete_bulk": "Delete folders",
|
"txt_log_action_cipher_delete_soft_bulk": "Перемещение элементов хранилища в корзину",
|
||||||
"txt_log_action_send_auth_remove": "Remove Send authentication",
|
"txt_log_action_device_deactivate": "Деактивация устройства",
|
||||||
"txt_log_action_send_delete": "Delete Send",
|
"txt_log_action_device_delete": "Удаление устройства",
|
||||||
"txt_log_action_send_delete_bulk": "Delete Sends",
|
"txt_log_action_device_delete_all": "Удаление всех устройств",
|
||||||
"txt_log_action_send_password_remove": "Remove Send password",
|
"txt_log_action_device_name_update": "Обновление имени устройства",
|
||||||
"txt_log_action_user_password_change": "Change master password",
|
"txt_log_action_device_trust_permanent": "Постоянное доверие устройству",
|
||||||
"txt_log_action_user_register_first_admin": "Register first admin",
|
"txt_log_action_device_trust_revoke": "Отзыв доверия устройству",
|
||||||
"txt_log_action_user_register_invite": "Register by invite",
|
"txt_log_action_device_trust_revoke_batch": "Массовый отзыв доверия устройствам",
|
||||||
"txt_log_meta_attachments": "Attachments",
|
"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_bytes": "Bytes",
|
||||||
"txt_log_meta_changed": "Changed fields",
|
"txt_log_meta_changed": "Измененные поля",
|
||||||
"txt_log_meta_checksum_mismatch_accepted": "Accepted checksum mismatch",
|
"txt_log_meta_checksum_mismatch_accepted": "Принято несовпадение контрольной суммы",
|
||||||
"txt_log_meta_cipher_id": "Vault item ID",
|
"txt_log_meta_cipher_id": "ID элемента хранилища",
|
||||||
"txt_log_meta_ciphers": "Vault items",
|
"txt_log_meta_ciphers": "Элементы хранилища",
|
||||||
"txt_log_meta_compat": "Compatibility",
|
"txt_log_meta_compat": "Совместимость",
|
||||||
"txt_log_meta_compressed_bytes": "Compressed bytes",
|
"txt_log_meta_compressed_bytes": "Байт после сжатия",
|
||||||
"txt_log_meta_count": "Count",
|
"txt_log_meta_count": "Количество",
|
||||||
"txt_log_meta_deleted": "Deleted count",
|
"txt_log_meta_deleted": "Удалено",
|
||||||
"txt_log_meta_destination_count": "Destination count",
|
"txt_log_meta_destination_count": "Количество назначений",
|
||||||
"txt_log_meta_destination_id": "Destination ID",
|
"txt_log_meta_destination_id": "ID назначения",
|
||||||
"txt_log_meta_destination_name": "Destination name",
|
"txt_log_meta_destination_name": "Имя назначения",
|
||||||
"txt_log_meta_destination_type": "Destination type",
|
"txt_log_meta_destination_type": "Тип назначения",
|
||||||
"txt_log_meta_device_identifier": "Device ID",
|
"txt_log_meta_device_identifier": "ID устройства",
|
||||||
"txt_log_meta_device_type": "Device type",
|
"txt_log_meta_device_type": "Тип устройства",
|
||||||
"txt_log_meta_email": "Email",
|
"txt_log_meta_email": "Email",
|
||||||
"txt_log_meta_error": "Error",
|
"txt_log_meta_error": "Ошибка",
|
||||||
"txt_log_meta_expires_in_hours": "Expires in hours",
|
"txt_log_meta_expires_in_hours": "Истекает через часов",
|
||||||
"txt_log_meta_file_bytes": "File bytes",
|
"txt_log_meta_file_bytes": "Байт файла",
|
||||||
"txt_log_meta_file_name": "File name",
|
"txt_log_meta_file_name": "Имя файла",
|
||||||
"txt_log_meta_folder_id": "Folder ID",
|
"txt_log_meta_folder_id": "ID папки",
|
||||||
"txt_log_meta_grant_type": "Login method",
|
"txt_log_meta_grant_type": "Способ входа",
|
||||||
"txt_log_meta_includes_attachments": "Includes attachments",
|
"txt_log_meta_includes_attachments": "Включает вложения",
|
||||||
"txt_log_meta_ip": "IP address",
|
"txt_log_meta_ip": "IP-адрес",
|
||||||
"txt_log_meta_max_entries": "Entry limit",
|
"txt_log_meta_max_entries": "Лимит записей",
|
||||||
"txt_log_meta_method": "Request method",
|
"txt_log_meta_method": "Метод запроса",
|
||||||
"txt_log_meta_path": "Request path",
|
"txt_log_meta_path": "Путь запроса",
|
||||||
"txt_log_meta_provider": "Provider",
|
"txt_log_meta_provider": "Поставщик",
|
||||||
"txt_log_meta_prune_error": "Cleanup error",
|
"txt_log_meta_prf_status": "Статус PRF",
|
||||||
"txt_log_meta_pruned_file_count": "Cleaned files",
|
"txt_log_meta_prune_error": "Ошибка очистки",
|
||||||
"txt_log_meta_raw": "Raw data",
|
"txt_log_meta_pruned_file_count": "Очищено файлов",
|
||||||
"txt_log_meta_reason": "Reason",
|
"txt_log_meta_raw": "Исходные данные",
|
||||||
"txt_log_meta_remote_path": "Remote path",
|
"txt_log_meta_reason": "Причина",
|
||||||
"txt_log_meta_removed": "Removed count",
|
"txt_log_meta_remote_path": "Удаленный путь",
|
||||||
"txt_log_meta_removed_devices": "Removed devices",
|
"txt_log_meta_removed": "Удалено",
|
||||||
"txt_log_meta_removed_sessions": "Removed sessions",
|
"txt_log_meta_removed_devices": "Удалено устройств",
|
||||||
"txt_log_meta_removed_trusted": "Trust removals",
|
"txt_log_meta_removed_sessions": "Удалено сессий",
|
||||||
"txt_log_meta_replace_existing": "Replace existing data",
|
"txt_log_meta_removed_trusted": "Отозвано доверий",
|
||||||
"txt_log_meta_requested": "Requested count",
|
"txt_log_meta_replace_existing": "Заменить существующие данные",
|
||||||
"txt_log_meta_requested_count": "Requested count",
|
"txt_log_meta_requested": "Запрошено",
|
||||||
"txt_log_meta_retention_days": "Retention days",
|
"txt_log_meta_requested_count": "Запрошено",
|
||||||
"txt_log_meta_scheduled_destination_count": "Scheduled destinations",
|
"txt_log_meta_retention_days": "Дней хранения",
|
||||||
"txt_log_meta_size": "Size",
|
"txt_log_meta_scheduled_destination_count": "Запланированные назначения",
|
||||||
"txt_log_meta_skipped_attachments": "Skipped attachments",
|
"txt_log_meta_size": "Размер",
|
||||||
"txt_log_meta_skipped_reason": "Skip reason",
|
"txt_log_meta_skipped_attachments": "Пропущенные вложения",
|
||||||
"txt_log_meta_status": "Status",
|
"txt_log_meta_skipped_reason": "Причина пропуска",
|
||||||
"txt_log_meta_target_email": "Target email",
|
"txt_log_meta_status": "Статус",
|
||||||
"txt_log_meta_trigger": "Trigger",
|
"txt_log_meta_target_email": "Email цели",
|
||||||
"txt_log_meta_type": "Type",
|
"txt_log_meta_trigger": "Триггер",
|
||||||
"txt_log_meta_updated": "Updated count",
|
"txt_log_meta_type": "Тип",
|
||||||
"txt_log_meta_upload_verification_attempts": "Upload verification attempts",
|
"txt_log_meta_updated": "Обновлено",
|
||||||
"txt_log_meta_user_agent": "Browser/client",
|
"txt_log_meta_upload_verification_attempts": "Попытки проверки загрузки",
|
||||||
"txt_log_meta_users": "Users",
|
"txt_log_meta_user_agent": "Браузер/клиент",
|
||||||
"txt_log_meta_verify_devices": "Verify devices",
|
"txt_log_meta_users": "Пользователи",
|
||||||
"txt_log_meta_web_session": "Web session",
|
"txt_log_meta_verify_devices": "Проверка устройств",
|
||||||
"txt_log_reason_bad_api_key": "Bad API key",
|
"txt_log_meta_web_session": "Веб-сессия",
|
||||||
"txt_log_reason_bad_password": "Bad password",
|
"txt_log_reason_bad_api_key": "Неверный API-ключ",
|
||||||
"txt_log_reason_device_missing": "Device missing",
|
"txt_log_reason_bad_password": "Неверный пароль",
|
||||||
"txt_log_reason_device_session_mismatch": "Device session mismatch",
|
"txt_log_reason_device_missing": "Устройство не найдено",
|
||||||
"txt_log_reason_token_not_found_or_expired": "Token missing or expired",
|
"txt_log_reason_device_session_mismatch": "Сессия не соответствует устройству",
|
||||||
"txt_log_reason_user_inactive": "User inactive",
|
"txt_log_reason_token_not_found_or_expired": "Токен отсутствует или истек",
|
||||||
"txt_log_reason_user_missing": "User missing",
|
"txt_log_reason_user_inactive": "Пользователь неактивен",
|
||||||
"txt_log_target_type_attachment": "Attachment",
|
"txt_log_reason_user_missing": "Пользователь не найден",
|
||||||
"txt_log_target_type_audit_log": "Log",
|
"txt_log_target_type_attachment": "Вложение",
|
||||||
"txt_log_target_type_backup": "Backup",
|
"txt_log_target_type_account_passkey": "Ключ входа",
|
||||||
"txt_log_target_type_cipher": "Vault item",
|
"txt_log_target_type_audit_log": "Журнал",
|
||||||
"txt_log_target_type_device": "Device",
|
"txt_log_target_type_backup": "Резервная копия",
|
||||||
"txt_log_target_type_folder": "Folder",
|
"txt_log_target_type_cipher": "Элемент хранилища",
|
||||||
"txt_log_target_type_invite": "Invite",
|
"txt_log_target_type_device": "Устройство",
|
||||||
"txt_log_target_type_refresh_token": "Refresh token",
|
"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_send": "Send",
|
||||||
"txt_log_target_type_user": "User",
|
"txt_log_target_type_user": "Пользователь",
|
||||||
"txt_log_trigger_manual": "Manual",
|
"txt_log_trigger_manual": "Вручную",
|
||||||
"txt_log_trigger_remote": "Remote",
|
"txt_log_trigger_remote": "Удаленно",
|
||||||
"txt_log_trigger_scheduled": "Scheduled",
|
"txt_log_trigger_scheduled": "По расписанию",
|
||||||
"txt_log_max_1000": "До 1 000 записей",
|
"txt_log_max_1000": "До 1 000 записей",
|
||||||
"txt_log_max_5000": "До 5 000 записей",
|
"txt_log_max_5000": "До 5 000 записей",
|
||||||
"txt_log_max_10000": "До 10 000 записей",
|
"txt_log_max_10000": "До 10 000 записей",
|
||||||
@@ -1396,4 +1448,12 @@ const ru: Record<string, string> = {
|
|||||||
"txt_auth_request_missing_public_key": "В запросе входа с устройства отсутствует открытый ключ"
|
"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;
|
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
Reference in New Issue
Block a user