Compare commits

..
33 Commits
Author SHA1 Message Date
shuaiplus b472121f43 feat: add contributors section and star history chart to README files 2026-07-11 17:25:53 +08:00
DomainmasteriandGitHub 9caa064488 Add German (de), French (fr), Italian (it), and Swedish (sv) initial translations (#303) 2026-07-11 17:06:41 +08:00
shuaiplus aae614a079 feat: add offline mode notice and related styles to enhance user experience during offline access 2026-07-10 22:24:06 +08:00
shuaiplus 0e46cd371f Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-07-10 14:24:15 +08:00
shuaiplus db31792cef feat: add fill-assist, assetlinks check, and web-bootstrap to worker-handled paths 2026-07-10 14:23:45 +08:00
shuaiplus 8c65cb2e80 feat: update FIDO2 origins and enable direct unlock for account passkeys 2026-07-10 14:22:24 +08:00
shuaiplus 14dff8ee6a feat: disable new-device verification and update related logic across services 2026-07-10 13:00:32 +08:00
dependabot[bot]andShuai 8d399f431b chore(deps-dev): bump vite from 7.3.5 to 8.1.3
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.5 to 8.1.3.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.1.3/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 23:53:06 +08:00
shuaiplus bb3f866220 feat: add audit log demo data and passkey/API stub overrides 2026-07-09 20:51:12 +08:00
shuaiplusandShuai 39d9df78ea feat: add functionality to select unique items from duplicates in Vault components 2026-07-09 20:21:45 +08:00
rootphantomerandShuai a1b12fc447 feat: add duplicate detection demo data to dev:demo mode 2026-07-09 20:21:45 +08:00
rootphantomerandShuai 099217062a fix: group duplicates by color and sort A-Z within groups 2026-07-09 20:21:45 +08:00
DomainmasteriandGitHub dd90d7b8b8 Add Finnish (fi) translation (#299)
* Add Finnish (fi) locale file for nodewarden web vault translation
2026-07-09 19:00:33 +08:00
rootphantomerandShuai 525b773cf4 fix: preserve dialog title during closing animation to prevent flash 2026-07-09 18:53:01 +08:00
shuaiplus 04cb475935 feat: enhance date formatting in PublicSendPage and SendsPage components 2026-07-08 19:31:11 +08:00
ShuaiandGitHub e063f45cd9 Update FUNDING.yml 2026-07-08 11:59:17 +08:00
ShuaiandGitHub e10920d142 Create FUNDING.yml 2026-07-07 19:55:15 +08:00
shuaiplus b07edb0850 fix: correct directory name in .gitignore and remove nodewarden-wiki subproject 2026-07-07 19:48:40 +08:00
shuaiplus 58a86ae8fd docs: update feature comparison table for clarity and consistency 2026-07-07 18:36:35 +08:00
shuaiplus b986af86dc docs: add Fill-assist row to feature comparison table
Align English README with README_ZH for POST /fill-assist client support.
2026-07-07 14:18:19 +08:00
shuaiplus 8e33f92b33 docs: make English the default README, move Chinese to README_ZH
- README.md is now the English version (GitHub default)
- Add README_ZH.md for Chinese documentation
- Remove redundant README_EN.md
2026-07-07 13:53:11 +08:00
shuaiplus a0f832e8a5 chore: release v1.7.3 2026-07-07 01:43:14 +08:00
shuaiplus 8a5b210a1d Improve Bitwarden-compatible TOTP handling 2026-07-07 01:27:55 +08:00
shuaiplus ebc8e8e340 Replace remove button with trash icon positioned inside password field 2026-07-07 00:35:16 +08:00
shuaiplus a870142b7b Show password indicator and masked password field for encrypted sends
- Add password and authType fields to Send type
- Show lock icon in send list for password-protected sends
- Display masked dots in password field when editing a send that has a password set
- Add Remove button to clear existing password
2026-07-07 00:31:56 +08:00
shuaiplus a366acbac0 Revert redirect guard in backup uploader 2026-07-06 23:54:17 +08:00
shuaiplus 57c5ef9da6 Remove WEBSITE_ICONS_ENABLED and always enable website icons 2026-07-06 22:56:36 +08:00
shuaiplus f532d3ace3 Preserve WebAuthn credential purpose in backups 2026-07-06 22:25:02 +08:00
shuaiplus cc4a830be8 Harden backup and download token flows 2026-07-06 19:06:24 +08:00
shuaiplus c6438747e3 Harden 2FA disable and website icon privacy 2026-07-06 18:45:54 +08:00
shuaiplus 5c8f01be59 Harden backup blob and remote endpoint handling 2026-07-06 18:17:55 +08:00
shuaiplus 7ac6ae50bb Harden auth requests and backup endpoints 2026-07-06 18:04:23 +08:00
shuaiplus ace00e8e74 Harden WebAuthn extension origins 2026-07-06 17:43:57 +08:00
70 changed files with 9562 additions and 1128 deletions
+2
View File
@@ -0,0 +1,2 @@
custom:
- https://nodewarden.app/sponsor
+1 -1
View File
@@ -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
+76 -110
View File
@@ -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 MiBCloudflare限制) | 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
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left) <a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
</picture>
</a>
-172
View File
@@ -1,172 +0,0 @@
<p align="center">
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
</p>
<p align="center">
Bitwarden-compatible server running on Cloudflare Workers
</p>
<p align="center">
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
</p>
<p align="center">
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
</p>
<p align="center">
<a href="./README.md">中文说明</a> |
<a href="./CONTRIBUTING.md">Contributing</a>
</p>
> **Disclaimer**
>
> This project is for learning and discussion purposes only. Please back up your vault regularly.
>
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
---
## Feature Comparison with the Official Bitwarden Server
| Capability | Bitwarden | NodeWarden | Notes |
|---|---|---|---|
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
| **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** |
| **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** |
| **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** |
| Real-time sync | ✅ | ✅ | Web, browser extension, desktop, and mobile clients stay in sync in real time |
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
| Send | ✅ | ✅ | Supports both text and file Sends |
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
| **Cloud Backup Center** | ❌ | ✅ | **WebDAV / S3 scheduled backup (OneDrive/Google Drive etc.)** |
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
| Multi-user | ✅ | ✅ | Invite-based registration |
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
| Login 2FA | ✅ | ⚠️ Partial | TOTP and Passkey (as second factor) |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
---
## Tested Clients
- ✅ Windows desktop client
- ✅ Mobile app
- ✅ Browser extension
- ✅ Linux desktop client
- ⚠️ macOS desktop client has not been fully verified yet
---
## Web Deploy
1. Fork this repository. If this project helps you, consider giving it a Star.
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> continue.
3. R2 is used by default. If R2 is not enabled on your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
4. Deploy and open the generated URL.
| Storage | Card required | Single attachment / Send file limit | Free tier |
|---|---|---|---|
| R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
> [!TIP]
> How to keep your fork updated:
> - Manual: open your fork on GitHub, click `Sync fork`, then `Update branch`
> - Automatic: go to your fork -> `Actions` -> `Sync upstream` -> `Enable workflow`; it will sync upstream automatically every day at 3 AM
## CLI Deploy
```powershell
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
npm install
npx wrangler login
# Default: R2 mode
npm run deploy
# Optional: KV mode
npm run deploy:kv
# Local development
npm run dev
npm run dev:kv
```
---
## Key Features
### PWA Progressive Web App
-**Install to desktop** - Runs like a native app
-**Offline usage** - Service Worker caching, view passwords offline
-**App shortcuts** - Quick launch vault, TOTP codes
-**Background decryption** - Web Worker handles decryption without blocking UI
### Passkey Passwordless Login
-**WebAuthn/FIDO2 support** - Login with fingerprint, Face ID, etc.
-**PRF key unlock** - Passkey can unlock vault directly
-**Official client compatibility** - Chromium browser extension supports Passkey login
-**Multi-device sync** - Supports iCloud, Google Password Manager, etc.
### Cloud Backup Notes
- Remote backup supports **WebDAV** and **S3**
- Supports **OneDrive** (via Koofr), **Google Drive** (via Koofr), **Cloudflare R2**, **Backblaze B2**, etc.
- When `Include attachments` is enabled:
- the ZIP still contains only `db.json` and `manifest.json`
- actual attachment files are stored separately under `attachments/`
- later backups reuse existing attachments by stable blob name instead of re-uploading everything every time
- During remote restore:
- required attachment files are loaded from `attachments/` on demand
- missing attachments are skipped safely
- skipped attachments do not leave broken rows in the restored database
---
## Import / Export
Current supported import sources include:
- Bitwarden JSON
- Bitwarden CSV
- Bitwarden vault + attachments ZIP
- NodeWarden JSON
- Multiple browser / password-manager formats available in the web import selector
Current supported export formats include:
- Bitwarden JSON
- Bitwarden encrypted JSON
- ZIP export with attachments
- NodeWarden JSON variants
- Full manual instance export from the backup center
---
## License
LGPL-3.0 License
---
## Credits
- [Bitwarden](https://bitwarden.com/) - Original design and clients
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
+149
View File
@@ -0,0 +1,149 @@
<p align="center">
<img src="./NodeWarden.svg" alt="NodeWarden Logo" />
</p>
<p align="center">
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
</p>
<p align="center">
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
</p>
<p align="center">
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
</p>
<p align="center">
<a href="./README.md">English</a> |
<a href="./CONTRIBUTING.md">贡献指南</a> |
<a href="https://nodewarden.app">官方wiki</a>
</p>
> **免责声明**
> 本项目仅供学习与交流使用,请定期备份你的密码库。
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
---
## 与 Bitwarden 官方服务端能力对比
| 能力 | Bitwarden免费版 | NodeWarden | 说明 |
|---|---|---|---|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
| TOTP | ❌ | ✅ | 包括 `steam://` 支持 |
| **PWA / 离线使用** | ❌ | ✅ | **可安装、离线使用、App快捷方式** |
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
| API 密钥 | ✅ | ✅ | 供bitwarden cli使用,支持获取和轮换 |
| 登录 2FA | ✅ | ✅ | 支持 TOTP、YubiKey、Passkey |
| 2FA 恢复码 | ✅ | ✅ | 一次性恢复码用于禁用 2FA |
| 实时推送同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
| 附件 / Send| ✅ | ✅ | Cloudflare R2 或 KV |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时增量备份** |
| 设备管理 | ✅ | ✅ | **删除设备、撤销信任、永久信任** |
| 登录请求 | ✅ | ✅ | **多端免密登录审批、跨设备解锁请求** |
| **多用户使用** | ✅ | ✅ | 支持邀请码注册 |
| 域名规则 | ✅ | ✅ | 自定义等效域名、全局域名排除 |
| Fill-assist | ✅ | ✅ | `POST /fill-assist` 辅助客户端自动填充;不能绕过保险库解锁 |
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
---
## 已测试客户端
- ✅ Windows 桌面端
- ✅ 手机 App
- ✅ 浏览器扩展
- ✅ Linux 桌面端
- ⚠️ macOS 桌面端尚未完整验证
---
## 可视化快速部署
1. Fork NodeWarden 仓库到自己的 GitHub 账号
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
3. 选择 Continue with GitHub 并选择你的仓库
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
5. 等部署完成后,打开生成的 Workers 域名
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml``wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
> [!TIP]
> 默认R2与可选KV的区别:
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
> |---|---|---|---|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
> | KV | 不需要 | 25 MiBCloudflare限制) | 1 GB |
## 更新方法:
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork``Update branch`
## CLI 部署
```powershell
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
npm install
npx wrangler login
# 默认:R2 模式
npm run deploy
# 可选:KV 模式
npm run deploy:kv
# 本地开发
npm run dev
npm run dev:kv
```
---
## 开源协议
LGPL-3.0 License
---
## 致谢
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
---
## 贡献者
<a href="https://github.com/shuaiplus/nodewarden/graphs/contributors">
<img src="https://contrib.rocks/image?repo=shuaiplus/nodewarden" alt="NodeWarden contributors" />
</a>
## Star History
<a href="https://www.star-history.com/?repos=shuaiplus%2FNodeWarden&type=timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&theme=dark&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left&sealed_token=ck0AMqR8EFMjJ6tMbnGDHT5QwMpO85IUuN7i8e82zRRNPtjoLsAAFwVzxmSZwaid97wLUwy56EEiVE9M-OY0cf16bQKBrU9GaauFoOFXGq-vMqcOyk0tIc4b3o1ZGfDw9IH8o6NUxC125TJkjKSLn9fxhFUUeNr1f1El0UcAUcjsMPl_LX80qQrlvQqp" />
</picture>
</a>
+1 -1
View File
@@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS users (
security_stamp TEXT NOT NULL, 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,
+672 -412
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.7.2", "version": "1.7.3",
"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": {
+5
View File
@@ -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) {
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.7.2'; export const APP_VERSION = '1.7.3';
+32 -59
View File
@@ -352,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,
@@ -553,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
@@ -819,13 +799,16 @@ function yubiKeyResponse(user: User): Record<string, unknown> {
}; };
} }
function deviceVerificationSettingsResponse(user: User): Record<string, unknown> { // New-device verification requires an email delivery channel to send OTP
const enabled = user.verifyDevices !== false; // 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 { return {
Enabled: enabled, Enabled: false,
enabled, enabled: false,
VerifyDevices: enabled, VerifyDevices: false,
verifyDevices: enabled, verifyDevices: false,
Object: 'deviceVerificationSettings', Object: 'deviceVerificationSettings',
object: 'deviceVerificationSettings', object: 'deviceVerificationSettings',
}; };
@@ -915,9 +898,10 @@ export async function handleGetDeviceVerificationSettings(request: Request, env:
} }
// PUT/POST /api/two-factor/device-verification-settings // 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> { export async function handlePutDeviceVerificationSettings(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 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);
@@ -929,31 +913,28 @@ export async function handlePutDeviceVerificationSettings(request: Request, env:
} }
const rawEnabled = body.enabled ?? body.Enabled ?? body.verifyDevices ?? body.VerifyDevices; const rawEnabled = body.enabled ?? body.Enabled ?? body.verifyDevices ?? body.VerifyDevices;
if (typeof rawEnabled !== 'boolean') {
return errorResponse('enabled must be true or false', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'secret', 'Secret']); // Log the attempt for audit purposes — never change state.
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
user.verifyDevices = rawEnabled;
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, requested: rawEnabled,
reason: 'new-device verification is not supported (no email delivery channel)',
source: 'two-factor.device-verification-settings', source: 'two-factor.device-verification-settings',
...auditRequestMetadata(request), ...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)); return jsonResponse(deviceVerificationSettingsResponse(user));
} }
@@ -1161,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) {
+5 -6
View File
@@ -439,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),
+3 -4
View File
@@ -96,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,
@@ -349,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) ||
@@ -366,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);
+34 -4
View File
@@ -1062,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({
@@ -1239,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);
+5 -5
View File
@@ -300,17 +300,17 @@ export async function handleDownloadSendFile(
return errorResponse(SEND_INACCESSIBLE_MSG, 404); return errorResponse(SEND_INACCESSIBLE_MSG, 404);
} }
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
if (!firstUse) {
return errorResponse('Invalid or expired token', 401);
}
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId)); const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
if (!object) { if (!object) {
return errorResponse('Send file not found', 404); return errorResponse('Send file not found', 404);
} }
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId; const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
if (!firstUse) {
return errorResponse('Invalid or expired token', 401);
}
return new Response(object.body, { return new Response(object.body, {
headers: { headers: {
'Content-Type': sanitizeDownloadContentType(object.contentType), 'Content-Type': sanitizeDownloadContentType(object.contentType),
+9 -1
View File
@@ -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;
} }
+6 -3
View File
@@ -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> {
+2 -2
View File
@@ -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);
} }
+15 -2
View File
@@ -35,6 +35,7 @@ import { isSafeWebsiteIconContentType } from './utils/content-type';
import { jsonResponse, unsupportedResponse } 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;
@@ -44,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 {
@@ -255,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();
@@ -322,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),
}; };
} }
@@ -372,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);
+1 -1
View File
@@ -148,7 +148,7 @@ 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 {
+3 -2
View File
@@ -442,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}`);
@@ -493,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);
+112 -4
View File
@@ -68,6 +68,114 @@ 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
);
}
function isBlockedIpv6Address(hostname: string): boolean {
if (!hostname.includes(':')) return false;
const normalized = hostname.toLowerCase();
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
if (mappedIpv4) {
const octets = parseIpv4Address(mappedIpv4[1]);
return !octets || isBlockedIpv4Address(octets);
}
const firstHextetText = normalized.split(':').find((part) => part.length > 0) || '0';
const firstHextet = Number.parseInt(firstHextetText, 16);
if (!Number.isFinite(firstHextet)) return true;
return (
firstHextet === 0 ||
(firstHextet & 0xfe00) === 0xfc00 ||
(firstHextet & 0xffc0) === 0xfe80 ||
(firstHextet & 0xff00) === 0xff00 ||
normalized.startsWith('2001:db8:')
);
}
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());
@@ -123,7 +231,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');
@@ -136,7 +244,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,
@@ -155,7 +263,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');
@@ -165,7 +273,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,
+10 -3
View File
@@ -254,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) => {
@@ -297,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,
@@ -668,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 || []
) )
); );
+6 -5
View File
@@ -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,
+2 -2
View File
@@ -14,11 +14,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' + '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',
+1 -1
View File
@@ -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,
+2 -15
View File
@@ -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;
@@ -159,22 +160,8 @@ 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) };
} }
+50
View File
@@ -0,0 +1,50 @@
import type { Env } from '../types';
// Keep this list aligned with Bitwarden server's default FIDO2 origins.
// These are the stable store IDs for the official Chromium-based extensions.
export const OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS = [
'chrome-extension://nngceckbapebfimnlniiiahkandclblb',
'chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh',
'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn',
] as const;
export function normalizeOrigin(value: unknown): string | null {
const raw = String(value || '').trim();
if (!raw) return null;
try {
const url = new URL(raw);
if (!url.protocol || !url.host) return null;
return `${url.protocol}//${url.host}`;
} catch {
return null;
}
}
export function isBrowserExtensionOrigin(origin: unknown): boolean {
const normalized = normalizeOrigin(origin);
return !!normalized && (
normalized.startsWith('chrome-extension://')
|| normalized.startsWith('moz-extension://')
|| normalized.startsWith('safari-web-extension://')
);
}
export function getConfiguredWebAuthnAllowedOrigins(
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
): string[] {
const seen = new Set<string>(OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS);
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
const origin = normalizeOrigin(item);
if (origin) seen.add(origin);
}
return Array.from(seen);
}
export function isConfiguredWebAuthnAllowedOrigin(
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>,
origin: unknown
): boolean {
const normalized = normalizeOrigin(origin);
return !!normalized && getConfiguredWebAuthnAllowedOrigins(env).includes(normalized);
}
+3 -1
View File
@@ -30,7 +30,9 @@ export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
forcePasswordReset: false, 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',
+18 -18
View File
@@ -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,14 +24,6 @@ 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/')
@@ -38,18 +36,19 @@ function isWildcardCorsPath(path: string): boolean {
); );
} }
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)) {
@@ -58,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())
@@ -72,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) {
@@ -86,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;
@@ -95,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);
} }
@@ -159,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),
}); });
} }
+49 -17
View File
@@ -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,24 +228,55 @@
return out; return out;
} }
function trustedParentOrigin() { function normalizeOrigin(value) {
var parent = decodeRepeated(params.get("parent")); if (!value) return "";
if (!parent) return "";
try { try {
var parentUrl = new URL(parent); var url = new URL(value);
if ( if (!url.protocol || !url.host) return "";
parentUrl.protocol === "chrome-extension:" || return url.protocol + "//" + url.host;
parentUrl.protocol === "moz-extension:" ||
parentUrl.protocol === "safari-web-extension:"
) {
return parentUrl.protocol + "//" + parentUrl.host;
}
if (parentUrl.origin === window.location.origin) {
return parentUrl.origin;
}
} catch (_error) { } catch (_error) {
return ""; 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 ""; return "";
} }
@@ -258,8 +290,8 @@
return copy; return copy;
} }
function postResult(message) { async function postResult(message) {
var parentOrigin = trustedParentOrigin(); var parentOrigin = trustedParentOrigin(await allowedParentOrigins());
if (parentOrigin) { if (parentOrigin) {
if (window.opener && !window.opener.closed) { if (window.opener && !window.opener.closed) {
window.opener.postMessage(message, parentOrigin); window.opener.postMessage(message, parentOrigin);
@@ -371,7 +403,7 @@
if (!(credential instanceof PublicKeyCredential)) { if (!(credential instanceof PublicKeyCredential)) {
throw new Error("No security key was selected."); throw new Error("No security key was selected.");
} }
postResult({ await postResult({
command: "webAuthnResult", command: "webAuthnResult",
data: credentialToDataString(credential), data: credentialToDataString(credential),
remember: rememberEl.checked, remember: rememberEl.checked,
+4 -2
View File
@@ -1167,7 +1167,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,
}); });
@@ -2121,7 +2120,10 @@ 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) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.inspectRemoteBackup(hash, destinationId, path);
},
onDeleteRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => { onDeleteRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword); const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.deleteRemoteBackup(hash, destinationId, path); return backupActions.deleteRemoteBackup(hash, destinationId, path);
+1 -1
View File
@@ -168,7 +168,7 @@ 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: (masterPassword: string, 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>;
+35 -2
View File
@@ -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>
+21 -17
View File
@@ -42,7 +42,7 @@ 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: (masterPassword: string, 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>;
@@ -492,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,
@@ -800,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(
@@ -846,7 +834,23 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
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,
+3 -1
View File
@@ -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 && (
+8 -1
View File
@@ -53,6 +53,13 @@ function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? value as Record<string, unknown> : null; 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>}
</> </>
)} )}
+30 -9
View File
@@ -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>
<div className="password-wrap"> {draft.hasPassword ? (
<input className="input" type={showPassword ? 'text' : 'password'} value={draft.password} onInput={(e) => setDraft({ ...draft, password: (e.currentTarget as HTMLInputElement).value })} /> <div className="password-wrap">
<button type="button" className="password-toggle" onClick={() => setShowPassword((v) => !v)}> <input className="input" type="password" value="••••••••" disabled />
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />} {!isCreating && (
</button> <button type="button" className="password-toggle text-red-600 hover:text-red-700" onClick={() => setDraft({ ...draft, hasPassword: false, password: '' })} title={t('txt_remove')}>
</div> <Trash2 size={16} />
</button>
)}
</div>
) : (
<div className="password-wrap">
<input className="input" type={showPassword ? 'text' : 'password'} value={draft.password} onInput={(e) => setDraft({ ...draft, password: (e.currentTarget as HTMLInputElement).value })} />
<button type="button" className="password-toggle" onClick={() => setShowPassword((v) => !v)}>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
)}
</label> </label>
<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">
+2 -2
View File
@@ -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> {
+18 -26
View File
@@ -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)}
/> />
))} ))}
+46 -2
View File
@@ -31,7 +31,7 @@ import {
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';
@@ -109,7 +109,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>>({});
@@ -419,7 +419,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') {
@@ -1049,6 +1078,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;
@@ -1163,6 +1206,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}
@@ -2,11 +2,11 @@ import { createPortal } from 'preact/compat';
import { useEffect, useMemo, useState } from 'preact/hooks'; import { useEffect, useMemo, useState } from 'preact/hooks';
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact'; import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
import { useDialogLifecycle } from '@/components/ConfirmDialog'; import { useDialogLifecycle } from '@/components/ConfirmDialog';
import type { TotpCodeResult } from '@/lib/crypto';
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,
@@ -25,7 +25,7 @@ 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 +42,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 }>;
@@ -191,8 +196,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)
), ),
}} }}
/> />
+4 -3
View File
@@ -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';
@@ -161,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;
+43 -33
View File
@@ -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,40 +320,43 @@ 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 mobile-duplicate-toolbar">
<div className="duplicate-mode-head-menu"> <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')}
<input </button>
className="search-input" </div>
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })} ) : (
value={props.searchInput} <div className="search-input-wrap">
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)} <input
onCompositionStart={props.onSearchCompositionStart} className="search-input"
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)} placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
onKeyDown={(e) => { value={props.searchInput}
if (e.key !== 'Escape' || !props.searchInput) return; onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
e.preventDefault(); onCompositionStart={props.onSearchCompositionStart}
props.onClearSearch(); onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
}} onKeyDown={(e) => {
/> if (e.key !== 'Escape' || !props.searchInput) return;
{!!props.searchInput && ( e.preventDefault();
<button props.onClearSearch();
type="button" }}
className="search-clear-btn" />
aria-label={t('txt_clear_search')} {!!props.searchInput && (
title={t('txt_clear_search_esc')} <button
onClick={props.onClearSearch} type="button"
> className="search-clear-btn"
<X size={14} /> aria-label={t('txt_clear_search')}
</button> title={t('txt_clear_search_esc')}
)} onClick={props.onClearSearch}
</> >
)} <X size={14} />
</div> </button>
)}
</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>
+8 -5
View File
@@ -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>;
} }
@@ -207,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 }) {
+2 -2
View File
@@ -82,8 +82,8 @@ 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(masterPasswordHash: string, destinationId: string, path: string) { async deleteRemoteBackup(masterPasswordHash: string, destinationId: string, path: string) {
+32 -118
View File
@@ -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 const credential = await navigator.credentials.get({
? [ publicKey: withPrfExtension(options, salt),
buildCredentialPrfExtension(salt, credentialIds), });
buildLegacyPrfExtension(salt), if (!(credential instanceof PublicKeyCredential)) {
] throw new Error(t('txt_no_passkey_selected'));
: [buildLegacyPrfExtension(salt)];
let lastCredential: PublicKeyCredential | null = null;
for (let index = 0; index < attempts.length; index += 1) {
try {
const credential = await navigator.credentials.get({
publicKey: withPrfExtension(options, attempts[index]),
});
if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_selected'));
}
lastCredential = credential;
if (readPrfFirstResult(credential) || hasPrfExtensionResult(credential) || index === attempts.length - 1) {
return credential;
}
} catch (error) {
if (index === attempts.length - 1 || !shouldRetryWithLegacyPrf(error)) {
if (lastCredential) return lastCredential;
throw error;
}
}
} }
if (lastCredential) return lastCredential; return credential;
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) {
-1
View File
@@ -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;
} }
+14 -9
View File
@@ -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;
} }
@@ -417,13 +420,15 @@ export async function deleteRemoteBackup(
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'));
+15 -2
View File
@@ -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
View File
@@ -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 };
} }
+1
View File
@@ -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
View File
@@ -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,
+21 -4
View File
@@ -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
+3
View File
@@ -1012,6 +1012,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",
+3
View File
@@ -1012,6 +1012,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",
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
+3
View File
@@ -1012,6 +1012,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": "Синхронизировать хранилище",
File diff suppressed because it is too large Load Diff
+3
View File
@@ -1012,6 +1012,9 @@ const zhCN: 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": "同步",
+3
View File
@@ -1012,6 +1012,9 @@ const zhTW: 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": "同步",
+5
View File
@@ -280,6 +280,8 @@ export interface Send {
key?: string | null; key?: string | null;
maxAccessCount?: number | null; maxAccessCount?: number | null;
accessCount?: number; accessCount?: number;
password?: string | null;
authType?: number | null;
disabled?: boolean; disabled?: boolean;
revisionDate?: string; revisionDate?: string;
expirationDate?: string | null; expirationDate?: string | null;
@@ -308,6 +310,7 @@ export interface SendDraft {
expirationDays: string; expirationDays: string;
maxAccessCount: string; maxAccessCount: string;
password: string; password: string;
hasPassword?: boolean;
disabled: boolean; disabled: boolean;
} }
@@ -411,6 +414,8 @@ export interface WebBootstrapResponse {
jwtUnsafeReason?: 'missing' | 'too_short' | null; jwtUnsafeReason?: 'missing' | 'too_short' | null;
jwtSecretMinLength?: number; jwtSecretMinLength?: number;
registrationInviteRequired?: boolean; registrationInviteRequired?: boolean;
webAuthnAllowedOrigins?: string[];
websiteIconsEnabled?: boolean;
} }
export interface YubiKeyOtpSettings { export interface YubiKeyOtpSettings {
+9
View File
@@ -0,0 +1,9 @@
let websiteIconsEnabled = true;
export function setWebsiteIconsEnabled(enabled: boolean): void {
websiteIconsEnabled = enabled;
}
export function areWebsiteIconsEnabled(): boolean {
return websiteIconsEnabled;
}
+52
View File
@@ -492,6 +492,58 @@
transform: translateY(-50%); transform: translateY(-50%);
} }
.offline-mode-notice {
@apply mb-4 flex items-start gap-3 rounded-xl border px-3.5 py-3 text-left text-sm leading-relaxed;
background: color-mix(in srgb, var(--warning) 10%, var(--panel));
border-color: color-mix(in srgb, var(--warning) 42%, var(--line));
color: var(--text);
}
.offline-mode-notice svg {
@apply mt-0.5 shrink-0;
color: var(--warning);
}
.offline-mode-notice strong {
@apply block text-center text-[13px] font-extrabold;
color: color-mix(in srgb, var(--warning) 82%, var(--text));
}
.offline-mode-notice > div {
@apply min-w-0 flex-1;
}
.offline-shortcut-list {
@apply mt-2 grid gap-1.5;
grid-template-columns: max-content minmax(0, 1fr);
}
.offline-shortcut-row {
display: contents;
}
.offline-shortcut-label {
@apply text-xs font-extrabold;
color: color-mix(in srgb, var(--warning) 76%, var(--text));
}
.offline-shortcut-value {
@apply flex min-w-0 flex-wrap items-center gap-1.5;
color: var(--muted-strong);
}
.offline-shortcut-chord {
@apply inline-flex items-center gap-1 whitespace-nowrap;
}
.offline-shortcut-chord kbd {
@apply inline-flex min-h-6 items-center rounded-md border px-1.5 font-mono text-[12px] font-bold leading-none;
background: color-mix(in srgb, var(--panel) 88%, var(--warning));
border-color: color-mix(in srgb, var(--warning) 35%, var(--line));
box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--warning) 26%, transparent);
color: var(--text);
}
.standalone-muted { .standalone-muted {
@apply text-left; @apply text-left;
} }
+14
View File
@@ -427,6 +427,20 @@
min-width: max(100%, 190px); min-width: max(100%, 190px);
} }
.list-head .mobile-duplicate-toolbar {
@apply flex min-w-0 items-center gap-1.5;
flex: none;
}
.list-head .mobile-duplicate-mode-select-wrap {
max-width: 130px;
flex-shrink: 0;
}
.list-head .mobile-duplicate-mode-select-wrap .mobile-vault-filter-trigger {
@apply w-full;
}
.toolbar.actions { .toolbar.actions {
@apply justify-end overflow-visible pb-0.5; @apply justify-end overflow-visible pb-0.5;
flex-wrap: unset; flex-wrap: unset;
+5
View File
@@ -1269,3 +1269,8 @@ select.input.duplicate-mode-toolbar-select {
@apply text-sm; @apply text-sm;
color: var(--danger); color: var(--danger);
} }
.inline-icon {
display: inline;
vertical-align: middle;
}