Compare commits

...
31 Commits
Author SHA1 Message Date
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
66 changed files with 3717 additions and 1127 deletions
+2
View File
@@ -0,0 +1,2 @@
custom:
- https://nodewarden.app/sponsor
+1 -1
View File
@@ -43,7 +43,7 @@ tmp/
.tmp/
.tmp-bitwarden-clients/
nodewarden.wiki/
nodewarden-wiki/
wiki/
AGENTS.md
settings.json
+63 -109
View File
@@ -3,95 +3,100 @@
</p>
<p align="center">
运行在 Cloudflare Workers 上的 Bitwarden 兼容服务端
Bitwarden-compatible server running on Cloudflare Workers
</p>
<p align="center">
<a href="https://workers.cloudflare.com/"><img src="https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white" alt="Powered by Cloudflare" /></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-LGPL--3.0-2ea44f" alt="License: LGPL-3.0" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/releases/latest"><img src="https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag" alt="Latest Release" /></a>
<a href="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml"><img src="https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg" alt="Sync Upstream" /></a>
</p>
<p align="center">
<a href="https://t.me/NodeWarden_News">Telegram 频道</a> |
<a href="https://t.me/NodeWarden_Official">Telegram 群组</a>
<a href="https://t.me/NodeWarden_News">Telegram Channel</a> |
<a href="https://t.me/NodeWarden_Official">Telegram Group</a>
</p>
<p align="center">
<a href="./README_EN.md">English</a> |
<a href="./CONTRIBUTING.md">贡献指南</a>
<a href="./README_ZH.md">中文</a> |
<a href="./CONTRIBUTING.md">Contributing</a> |
<a href="https://nodewarden.app">Official wiki</a>
</p>
> **免责声明**
> 本项目仅供学习与交流使用,请定期备份你的密码库。
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
> **Disclaimer**
> This project is for learning and discussion purposes only. Please back up your vault regularly.
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
---
## Bitwarden 官方服务端能力对比
## Feature comparison with the official Bitwarden server
| 能力 | Bitwarden | NodeWarden | 说明 |
| Feature | Bitwarden Free | NodeWarden | Notes |
|---|---|---|---|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
| **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** |
| **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** |
| **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** |
| 实时同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 |
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
| Send | ✅ | ✅ | 支持文本与文件 Send |
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / S3 定时备份(OneDrive/Google Drive等)** |
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
| TOTP / Steam TOTP | | ✅ | `steam://` 支持 |
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
| 登录 2FA | ✅ | ⚠️ 部分支持 | 支持TOTP和Passkey(作为第二因素) |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
| Web vault | ✅ | ✅ | **Original Web Vault UI** |
| TOTP | ❌ | ✅ | Includes `steam://` support |
| **PWA / offline** | ❌ | ✅ | **Installable, offline** |
| **Passkey login** | ✅ | ✅ | **passwordless auth** |
| API keys | ✅ | ✅ | CLI keys; create and rotate |
| Login 2FA | ✅ | ✅ | TOTP, YubiKey, Passkey |
| 2FA recovery codes | ✅ | ✅ | One-time 2FA disable codes |
| Real-time push sync | ✅ | ✅ | All device sync |
| Attachments / Send | ✅ | ✅ | Cloudflare R2 or KV |
| Import / export | ✅ | ✅ | Bitwarden JSON / CSV / **ZIP** |
| **Cloud backup center** | | ✅ | **Scheduled WebDAV / S3 incrementals** |
| Device management | ✅ | ✅ | **Remove devices; trust controls** |
| Login requests | ✅ | ✅ | **Cross-device login approval/unlock** |
| **Multi-user** | ✅ | ✅ | Invite-code registration |
| Domain rules | ✅ | ✅ | Equivalent domains, global exclusions |
| Fill-assist | ✅ | ✅ | `POST /fill-assist`|
| Organizations / collections / roles | ✅ | ❌ | Not implemented |
| SSO / SCIM / directory | ✅ | ❌ | Not implemented |
---
## 已测试客户端
## Tested clients
- ✅ Windows 桌面端
-手机 App
-浏览器扩展
- ✅ Linux 桌面端
- ⚠️ macOS 桌面端尚未完整验证
- ✅ Windows desktop
-Mobile app
-Browser extension
- ✅ Linux desktop
- ⚠️ macOS desktop not fully verified yet
---
## 可视化快速部署
## Visual quick deploy
1. Fork NodeWarden 仓库到自己的 GitHub 账号
2. 进入 [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
3. 选择 Continue with GitHub 并选择你的仓库
4. 构建命令填 `npm run build`,部署命令填 `npm run deploy`
- 如果你打算用 KV 模式,把部署命令改成 `npm run deploy:kv`
5. 等部署完成后,打开生成的 Workers 域名
1. Fork the NodeWarden repository to your GitHub account
2. Open [Cloudflare Workers & Pages](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create)
3. Choose **Continue with GitHub** and select your fork
4. Set **build command** to `npm run build` and **deploy command** to `npm run deploy`
- For KV mode, change the deploy command to `npm run deploy:kv`
5. After deployment finishes, open the generated Workers URL
- Workers 默认域名在部分网络环境不可直连。如需自定义域名,到 [Workers 设置](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings)里添加。
- The default Workers hostname may be unreachable on some networks. To use a custom domain, add it in [Workers settings](https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings).
- 页面提示缺少 `JWT_SECRET` 时,到 Workers 设置里添加 Secret。正式环境至少使用 32 个字符以上的随机字符串,不要使用临时值或示例值。
- If the site reports a missing `JWT_SECRET`, add it as a **Secret** in Workers settings. In production use a random string of at least 32 characters; do not use temporary or example values.
- 这套流程里,用户实际做的是把代码交给 Cloudflare 构建并部署。代码里的 `wrangler.toml` `wrangler.kv.toml` 决定绑定名,Worker 第一次处理请求时会自动初始化 D1 schema,不需要用户上传 SQL。
- In this flow you hand code to Cloudflare to build and deploy. `wrangler.toml` or `wrangler.kv.toml` in the repo defines binding names; the Worker initializes the D1 schema on first request—no manual SQL upload.
> [!TIP]
> 默认R2与可选KV的区别:
> | 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
> Default R2 vs optional KV:
> | Storage | Card required | Max single attachment / Send file | Free tier |
> |---|---|---|---|
> | R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
> | KV | 不需要 | 25 MiBCloudflare限制) | 1 GB |
> | R2 | Yes | 100 MB (soft limit, adjustable) | 10 GB |
> | KV | No | 25 MiB (Cloudflare limit) | 1 GB |
## 更新方法:
- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork``Update branch`
- 自动:进入你的 Fork 仓库 ➜ `Actions``Sync upstream``Enable workflow`,会在每天凌晨 3 点自动同步上游。
## How to update
- Manual: open your fork on GitHub; when the sync banner appears, click **Sync fork****Update branch**
## CLI 部署
## CLI deploy
```powershell
git clone https://github.com/shuaiplus/NodeWarden.git
@@ -100,82 +105,31 @@ cd NodeWarden
npm install
npx wrangler login
# 默认:R2 模式
# Default: R2 mode
npm run deploy
# 可选:KV 模式
# Optional: KV mode
npm run deploy:kv
# 本地开发
# Local development
npm run dev
npm run dev:kv
```
---
## 主要特性
### PWA 渐进式 Web 应用
-**可安装到桌面** - 像原生应用一样运行
-**离线使用** - Service Worker 缓存,离线也能查看密码
-**App 快捷方式** - 快速启动保险库、TOTP代码
-**后台解密** - Web Worker 处理解密,不阻塞UI
### Passkey 无密码登录
-**WebAuthn/FIDO2 支持** - 使用指纹、Face ID等登录
-**PRF 密钥解锁** - Passkey 可直接解锁保险库
-**官方客户端兼容** - Chromium系浏览器扩展可用Passkey登录
-**多设备同步** - 支持iCloud、Google Password Manager等
### 云端备份说明
- 远程备份支持 **WebDAV****S3**
- 支持 **OneDrive**(通过Koofr)、**Google Drive**(通过Koofr)、**Cloudflare R2**、**Backblaze B2** 等
- 勾选”包含附件”后:
- ZIP 内仍只包含 `db.json``manifest.json`
- 真实附件单独存放在 `attachments/`
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
- 远程还原时:
- 会从 `attachments/` 目录按需读取附件
- 缺失的附件会被安全跳过
- 被跳过的附件不会在恢复后的数据库中留下脏记录
---
## 导入 / 导出
当前支持的导入来源包括:
- Bitwarden JSON
- Bitwarden CSV
- Bitwarden 密码库 + 附件 ZIP
- NodeWarden JSON
- 网页导入器里可见的多种浏览器 / 密码管理器格式
当前支持的导出方式包括:
- Bitwarden JSON
- Bitwarden 加密 JSON
- 带附件的 ZIP 导出
- NodeWarden JSON 系列
- 备份中心中的实例级完整手动导出
---
## 开源协议
## License
LGPL-3.0 License
---
## 致谢
## Credits
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
- [Bitwarden](https://bitwarden.com/) - Original design and clients
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference
- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform
---
-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)
+137
View File
@@ -0,0 +1,137 @@
<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/) - 无服务器平台
---
## 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)
+1 -1
View File
@@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS users (
security_stamp TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active',
verify_devices INTEGER NOT NULL DEFAULT 1,
verify_devices INTEGER NOT NULL DEFAULT 0,
totp_secret TEXT,
totp_recovery_code TEXT,
api_key TEXT,
+672 -412
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "nodewarden",
"version": "1.7.2",
"version": "1.7.3",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus",
"license": "LGPL-3.0",
@@ -58,7 +58,7 @@
"tailwindcss": "^3.4.19",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"vite": "^7.3.1",
"vite": "^8.1.3",
"wrangler": "^4.105.0"
},
"dependencies": {
+1
View File
@@ -13,6 +13,7 @@ const localeFiles = [
['zh-TW', 'zh-TW.ts', 'zhTW', 'Traditional Chinese'],
['ru', 'ru.ts', 'ru', 'Russian'],
['es', 'es.ts', 'es', 'Spanish'],
['fi', 'fi.ts', 'fi', 'Finnish'],
];
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(),
role: 'user',
status: 'active',
verifyDevices: true,
verifyDevices: false, // new-device verification requires email delivery (not available)
totpSecret: null,
totpRecoveryCode: null,
yubikeyKey1: null,
@@ -553,51 +553,31 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
}
// PUT/POST /api/accounts/verify-devices
// New-device verification requires an email delivery channel which NodeWarden
// does not provide. This endpoint always rejects the request so clients receive
// clear feedback that the feature is unavailable rather than silently ignoring
// the user's preference.
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: {
secret?: string;
masterPasswordHash?: string;
verifyDevices?: boolean;
VerifyDevices?: boolean;
};
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const verifyDevices = typeof body.verifyDevices === 'boolean' ? body.verifyDevices : body.VerifyDevices;
if (typeof verifyDevices !== 'boolean') {
return errorResponse('verifyDevices must be true or false', 400);
}
const verified = await verifyUserSecret(auth, user, body.secret || body.masterPasswordHash);
if (!verified) {
return errorResponse('User verification failed.', 400);
}
user.verifyDevices = verifyDevices;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
// Log the attempt for audit purposes, but do not change state.
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.verify_devices.update',
action: 'account.verify_devices.update.rejected',
category: 'security',
level: 'security',
level: 'info',
targetType: 'user',
targetId: user.id,
metadata: {
verifyDevices: user.verifyDevices,
reason: 'new-device verification is not supported (no email delivery channel)',
...auditRequestMetadata(request),
},
});
return new Response(null, { status: 200 });
return errorResponse('New device verification is not available on this server. Enable TOTP or WebAuthn two-factor authentication instead.', 400);
}
// GET /api/accounts/keys
@@ -819,13 +799,16 @@ function yubiKeyResponse(user: User): Record<string, unknown> {
};
}
function deviceVerificationSettingsResponse(user: User): Record<string, unknown> {
const enabled = user.verifyDevices !== false;
// New-device verification requires an email delivery channel to send OTP
// challenges to unknown devices. NodeWarden does not integrate with an email
// provider, so this feature is intentionally unavailable. The settings
// response always reports disabled regardless of any legacy DB value.
function deviceVerificationSettingsResponse(_user: User): Record<string, unknown> {
return {
Enabled: enabled,
enabled,
VerifyDevices: enabled,
verifyDevices: enabled,
Enabled: false,
enabled: false,
VerifyDevices: false,
verifyDevices: false,
Object: 'deviceVerificationSettings',
object: 'deviceVerificationSettings',
};
@@ -915,9 +898,10 @@ export async function handleGetDeviceVerificationSettings(request: Request, env:
}
// PUT/POST /api/two-factor/device-verification-settings
// New-device verification is not supported (no email delivery channel).
// Reject any attempt to enable it; always return disabled state.
export async function handlePutDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
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;
if (typeof rawEnabled !== 'boolean') {
return errorResponse('enabled must be true or false', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'secret', 'Secret']);
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);
// Log the attempt for audit purposes — never change state.
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.verify_devices.update',
action: 'account.verify_devices.update.rejected',
category: 'security',
level: 'security',
level: 'info',
targetType: 'user',
targetId: user.id,
metadata: {
verifyDevices: user.verifyDevices,
requested: rawEnabled,
reason: 'new-device verification is not supported (no email delivery channel)',
source: 'two-factor.device-verification-settings',
...auditRequestMetadata(request),
},
});
if (rawEnabled === true) {
return errorResponse('New device verification is not available on this server. Enable TOTP or WebAuthn two-factor authentication instead.', 400);
}
// Setting to false is the only supported state — return it.
return jsonResponse(deviceVerificationSettingsResponse(user));
}
@@ -1161,16 +1142,8 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
return errorResponse('Two-factor provider is not supported by this server.', 400);
}
const key = normalizeTotpSecret(readBodyString(body, ['key', 'Key']));
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
let verified = false;
if (key && userVerificationToken) {
verified = await verifyTotpUserVerificationToken(env, user, key, userVerificationToken);
}
if (!verified) {
verified = await verifyUserSecret(auth, user, secret);
}
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
+5 -6
View File
@@ -439,17 +439,16 @@ export async function handlePublicDownloadAttachment(
}
const path = getAttachmentObjectKey(cipherId, attachmentId);
const object = await getBlobObject(env, path);
if (!object) {
return errorResponse('Attachment file not found', 404);
}
const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp);
if (!firstUse) {
return errorResponse('Invalid or expired token', 401);
}
const object = await getBlobObject(env, path);
if (!object) {
return errorResponse('Attachment file not found', 404);
}
return new Response(object.body, {
headers: {
'Content-Type': sanitizeDownloadContentType(object.contentType),
+3 -4
View File
@@ -96,8 +96,8 @@ function toAuthRequestResponse(request: Request, authRequest: AuthRequestRecord,
RequestCountryName: authRequest.requestCountryName,
key: authRequest.key,
Key: authRequest.key,
masterPasswordHash: authRequest.masterPasswordHash,
MasterPasswordHash: authRequest.masterPasswordHash,
masterPasswordHash: null,
MasterPasswordHash: null,
creationDate: authRequest.creationDate,
CreationDate: authRequest.creationDate,
responseDate: authRequest.responseDate,
@@ -349,7 +349,6 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
const approved = Boolean(readBodyValue(body, ['requestApproved', 'RequestApproved']));
const key = normalizeText(readBodyValue(body, ['key', 'Key']), 20000);
const masterPasswordHash = normalizeText(readBodyValue(body, ['masterPasswordHash', 'MasterPasswordHash']), 20000) || null;
const responseDeviceIdentifier =
normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128) ||
readActingDeviceIdentifier(request) ||
@@ -366,7 +365,7 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
approved,
responseDeviceIdentifier,
key,
masterPasswordHash,
masterPasswordHash: null,
});
if (!updated) return errorResponse('Auth request has already been answered.', 409);
const updatedRequest = await storage.getAuthRequestByIdForUser(id, userId);
+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> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
try {
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
} catch {
return errorResponse('Remote backup integrity payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB);
try {
const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url);
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const destination = requireBackupDestination(settings, body.destinationId || null);
const remoteFile = await downloadRemoteBackupFile(destination, path);
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
return jsonResponse({
@@ -1239,7 +1248,28 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
try {
const url = new URL(request.url);
const blobName = ensureBackupBlobName(url.searchParams.get('blobName') || '');
let input: { blobName?: unknown; masterPasswordHash?: unknown } = {};
if (request.method === 'POST') {
try {
input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>();
} catch {
return errorResponse('Backup attachment download payload is invalid', 400);
}
} else {
input = {
blobName: url.searchParams.get('blobName') || '',
masterPasswordHash: url.searchParams.get('masterPasswordHash') || '',
};
}
const verificationError = await requireBackupUserVerification(
actorUser,
String(input.masterPasswordHash || ''),
env
);
if (verificationError) return verificationError;
const blobName = ensureBackupBlobName(String(input.blobName || ''));
const object = await getBlobObject(env, blobName);
if (!object) {
return errorResponse('Backup attachment blob not found', 404);
+5 -5
View File
@@ -300,17 +300,17 @@ export async function handleDownloadSendFile(
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
if (!firstUse) {
return errorResponse('Invalid or expired token', 401);
}
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
if (!object) {
return errorResponse('Send file not found', 404);
}
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
if (!firstUse) {
return errorResponse('Invalid or expired token', 401);
}
return new Response(object.body, {
headers: {
'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 {
if (typeof raw !== 'string' || !raw.trim()) return null;
const date = new Date(raw);
let value = raw.trim();
if (!/[zZ]$/.test(value) && !/[+\-]\d{2}:?\d{2}$/.test(value)) {
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)) {
value += 'Z';
} else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(value)) {
value = value.replace(' ', 'T') + 'Z';
}
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date;
}
+6 -3
View File
@@ -24,8 +24,11 @@ function isWorkerHandledPath(path: string): boolean {
path.startsWith('/api/') ||
path.startsWith('/identity/') ||
path.startsWith('/icons/') ||
path.startsWith('/fill-assist/') ||
path.startsWith('/notifications/') ||
path.startsWith('/.well-known/') ||
path === '/v1/assetlinks:check' ||
path === '/web-bootstrap' ||
path === '/config' ||
path === '/api/config' ||
path === '/api/version'
@@ -89,7 +92,7 @@ export default {
const normalizedRequest = normalizeRequestUrl(request);
const assetResponse = await maybeServeAsset(normalizedRequest, env);
if (assetResponse) {
return applyCors(normalizedRequest, assetResponse);
return applyCors(normalizedRequest, assetResponse, env);
}
await ensureDatabaseInitialized(env);
@@ -107,11 +110,11 @@ export default {
},
500
);
return applyCors(normalizedRequest, resp);
return applyCors(normalizedRequest, resp, env);
}
const resp = await handleRequest(normalizedRequest, env);
return applyCors(normalizedRequest, resp);
return applyCors(normalizedRequest, resp, env);
},
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
+2 -2
View File
@@ -26,7 +26,7 @@ export async function handleAdminBackupRoute(
return handleAdminExportBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/blob' && method === 'GET') {
if (path === '/api/admin/backup/blob' && (method === 'GET' || method === 'POST')) {
return handleDownloadAdminBackupAttachment(request, env, actorUser);
}
@@ -54,7 +54,7 @@ export async function handleAdminBackupRoute(
return handleDownloadAdminRemoteBackup(request, env, actorUser);
}
if (path === '/api/admin/backup/remote/integrity' && method === 'GET') {
if (path === '/api/admin/backup/remote/integrity' && method === 'POST') {
return handleInspectAdminRemoteBackup(request, env, actorUser);
}
+15 -2
View File
@@ -35,6 +35,7 @@ import { isSafeWebsiteIconContentType } from './utils/content-type';
import { jsonResponse, unsupportedResponse } from './utils/response';
import { StorageService } from './services/storage';
import type { Env } from './types';
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
type JwtUnsafeReason = 'missing' | 'too_short' | null;
@@ -44,6 +45,12 @@ export interface WebBootstrapResponse {
jwtUnsafeReason: JwtUnsafeReason;
jwtSecretMinLength: number;
registrationInviteRequired: boolean;
webAuthnAllowedOrigins: string[];
websiteIconsEnabled: boolean;
}
function isWebsiteIconProxyEnabled(env: Env): boolean {
return true;
}
function isSameOriginWriteRequest(request: Request): boolean {
@@ -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);
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
@@ -322,6 +333,8 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
jwtUnsafeReason,
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
registrationInviteRequired: userCount > 0,
webAuthnAllowedOrigins: getConfiguredWebAuthnAllowedOrigins(env),
websiteIconsEnabled: isWebsiteIconProxyEnabled(env),
};
}
@@ -372,7 +385,7 @@ export async function handlePublicRoute(
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
if (blocked) return blocked;
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
return handleWebsiteIcon(iconMatch[1], fallbackMode);
return handleWebsiteIcon(env, iconMatch[1], fallbackMode);
}
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
+1 -1
View File
@@ -148,7 +148,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
}
if (method === 'OPTIONS') {
return handleCors(request);
return handleCors(request, env);
}
try {
+3 -2
View File
@@ -442,9 +442,10 @@ export function validateBackupPayloadContents(
for (const row of accountPasskeyRows) {
const id = String(row.id || '').trim();
const userId = String(row.user_id || '').trim();
const purpose = row.purpose == null ? 'login' : String(row.purpose || '').trim();
const credentialId = String(row.credential_id || '').trim();
const publicKey = String(row.public_key || '').trim();
if (!id || !userIds.has(userId) || !credentialId || !publicKey) {
if (!id || !userIds.has(userId) || !credentialId || !publicKey || (purpose !== 'login' && purpose !== 'twoFactor')) {
throw new Error('Backup archive contains an invalid account passkey row');
}
if (accountPasskeyIds.has(id)) throw new Error(`Backup archive contains duplicate account passkey id: ${id}`);
@@ -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, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
]);
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
+112 -4
View File
@@ -68,6 +68,114 @@ function normalizePath(value: unknown): string {
return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
}
function normalizeHostnameForPolicy(hostname: string): string {
return hostname.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
}
function parseIpv4Address(hostname: string): number[] | null {
const parts = hostname.split('.');
if (parts.length !== 4) return null;
const octets = parts.map((part) => {
if (!/^\d{1,3}$/.test(part)) return -1;
const value = Number(part);
return Number.isInteger(value) && value >= 0 && value <= 255 ? value : -1;
});
return octets.every((value) => value >= 0) ? octets : null;
}
function isBlockedIpv4Address(octets: number[]): boolean {
const [a, b, c] = octets;
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && (b === 0 || b === 168)) ||
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
(a === 203 && b === 0 && c === 113) ||
a >= 224
);
}
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 {
try {
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 (!endpoint) throw new Error('S3 endpoint is required');
if (!/^https?:\/\//i.test(endpoint)) throw new Error('S3 endpoint must start with http:// or https://');
normalizeBackupEndpointUrl(endpoint, 'S3 endpoint');
}
if (!allowIncomplete || bucket) {
if (!bucket) throw new Error('S3 bucket is required');
@@ -136,7 +244,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
}
return {
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
endpoint: endpoint ? normalizeBackupEndpointUrl(endpoint, 'S3 endpoint') : '',
bucket,
addressingStyle,
region,
@@ -155,7 +263,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
if (!allowIncomplete || baseUrl) {
if (!baseUrl) throw new Error('WebDAV server URL is required');
if (!/^https?:\/\//i.test(baseUrl)) throw new Error('WebDAV server URL must start with http:// or https://');
normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL');
}
if (!allowIncomplete || username) {
if (!username) throw new Error('WebDAV username is required');
@@ -165,7 +273,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
}
return {
baseUrl: baseUrl ? baseUrl.replace(/\/+$/, '') : '',
baseUrl: baseUrl ? normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL') : '',
username,
password,
remotePath,
+10 -3
View File
@@ -254,6 +254,10 @@ function cloneRows(rows: SqlRow[]): SqlRow[] {
return rows.map((row) => ({ ...row }));
}
function normalizeAccountPasskeyPurpose(value: unknown): 'login' | 'twoFactor' {
return value == null ? 'login' : String(value).trim() === 'twoFactor' ? 'twoFactor' : 'login';
}
function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] {
let replaced = false;
const nextRows = rows.map((row) => {
@@ -297,13 +301,16 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
users: cloneRows(payload.users || []).map((row) => ({
...row,
verify_devices: row.verify_devices ?? 1,
verify_devices: row.verify_devices ?? 0,
yubikey_nfc: row.yubikey_nfc ?? 0,
})),
domain_settings: cloneRows(payload.domain_settings || []),
user_revisions: cloneRows(payload.user_revisions || []),
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
webauthn_credentials: cloneRows(payload.webauthn_credentials || []),
webauthn_credentials: cloneRows(payload.webauthn_credentials || []).map((row) => ({
...row,
purpose: normalizeAccountPasskeyPurpose(row.purpose),
})),
folders: cloneRows(payload.folders || []),
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
...row,
@@ -668,7 +675,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
buildInsertStatements(
db,
tableName('webauthn_credentials'),
['id', 'user_id', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'],
['id', 'user_id', 'purpose', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'],
payload.webauthn_credentials || []
)
);
+6 -5
View File
@@ -3,6 +3,7 @@ import {
BackupDestinationType,
S3BackupDestination,
WebDavBackupDestination,
normalizeBackupEndpointUrl,
} from './backup-config';
export interface BackupUploadResult {
@@ -215,7 +216,7 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
if (destination.type === 'webdav') {
const config = destination.destination as WebDavBackupDestination;
if (!String(config.baseUrl || '').trim()) throw new Error('WebDAV server URL is required');
if (!/^https?:\/\//i.test(String(config.baseUrl || '').trim())) throw new Error('WebDAV server URL must start with http:// or https://');
normalizeBackupEndpointUrl(String(config.baseUrl || '').trim(), 'WebDAV server URL');
if (!String(config.username || '').trim()) throw new Error('WebDAV username is required');
if (!String(config.password || '')) throw new Error('WebDAV password is required');
return;
@@ -223,7 +224,7 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
if (destination.type === 's3') {
const config = destination.destination as S3BackupDestination;
if (!String(config.endpoint || '').trim()) throw new Error('S3 endpoint is required');
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('S3 endpoint must start with http:// or https://');
normalizeBackupEndpointUrl(String(config.endpoint || '').trim(), 'S3 endpoint');
if (!String(config.bucket || '').trim()) throw new Error('S3 bucket is required');
if (!String(config.accessKeyId || '').trim()) throw new Error('S3 access key is required');
if (!String(config.secretAccessKey || '')) throw new Error('S3 secret key is required');
@@ -252,7 +253,7 @@ async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, aut
Authorization: authHeader,
},
});
if ([200, 201, 204, 301, 302, 405].includes(response.status)) continue;
if ([200, 201, 204, 405].includes(response.status)) continue;
throw new Error(`WebDAV directory creation failed: ${response.status}`);
}
}
@@ -275,7 +276,7 @@ async function ensureWebDavDirectoryCached(
Authorization: authHeader,
},
});
if ([200, 201, 204, 301, 302, 405].includes(response.status)) {
if ([200, 201, 204, 405].includes(response.status)) {
ensuredDirectories.add(current);
continue;
}
@@ -518,7 +519,7 @@ async function signedS3Request(
config.region || 'auto'
);
return fetch(url.toString(), {
return fetch(url, {
method,
headers: {
Authorization: authorization,
+2 -2
View File
@@ -14,11 +14,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 0, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT',
+1 -1
View File
@@ -23,7 +23,7 @@ function mapUserRow(row: any): User {
securityStamp: row.security_stamp,
role: row.role === 'admin' ? 'admin' : 'user',
status: row.status === 'banned' ? 'banned' : 'active',
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
verifyDevices: row.verify_devices == null ? false : !!row.verify_devices,
totpSecret: row.totp_secret ?? null,
totpRecoveryCode: row.totp_recovery_code ?? null,
yubikeyKey1: row.yubikey_key1 ?? null,
+2 -15
View File
@@ -12,6 +12,7 @@ import type {
WebAuthnPrfDecryptionOption,
} from '../types';
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
import { getConfiguredWebAuthnAllowedOrigins } from './origins';
const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1';
const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000;
@@ -159,22 +160,8 @@ export function getAccountPasskeyRpConfig(request: Request, env: Env): { rpId: s
const configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim();
const rpId = configuredRpId || url.hostname;
const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME;
const configuredOrigins = String(env.WEBAUTHN_ALLOWED_ORIGINS || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
const configuredOrigins = getConfiguredWebAuthnAllowedOrigins(env);
const origins = new Set<string>([url.origin, ...configuredOrigins]);
const requestOrigin = request.headers.get('Origin');
if (
requestOrigin
&& (
requestOrigin.startsWith('chrome-extension://')
|| requestOrigin.startsWith('moz-extension://')
|| requestOrigin.startsWith('safari-web-extension://')
)
) {
origins.add(requestOrigin);
}
return { rpId, rpName, origins: Array.from(origins) };
}
+50
View File
@@ -0,0 +1,50 @@
import type { Env } from '../types';
// Keep this list aligned with Bitwarden server's default FIDO2 origins.
// These are the stable store IDs for the official Chromium-based extensions.
export const OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS = [
'chrome-extension://nngceckbapebfimnlniiiahkandclblb',
'chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh',
'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn',
] as const;
export function normalizeOrigin(value: unknown): string | null {
const raw = String(value || '').trim();
if (!raw) return null;
try {
const url = new URL(raw);
if (!url.protocol || !url.host) return null;
return `${url.protocol}//${url.host}`;
} catch {
return null;
}
}
export function isBrowserExtensionOrigin(origin: unknown): boolean {
const normalized = normalizeOrigin(origin);
return !!normalized && (
normalized.startsWith('chrome-extension://')
|| normalized.startsWith('moz-extension://')
|| normalized.startsWith('safari-web-extension://')
);
}
export function getConfiguredWebAuthnAllowedOrigins(
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
): string[] {
const seen = new Set<string>(OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS);
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
const origin = normalizeOrigin(item);
if (origin) seen.add(origin);
}
return Array.from(seen);
}
export function isConfiguredWebAuthnAllowedOrigin(
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>,
origin: unknown
): boolean {
const normalized = normalizeOrigin(origin);
return !!normalized && getConfiguredWebAuthnAllowedOrigins(env).includes(normalized);
}
+3 -1
View File
@@ -30,7 +30,9 @@ export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
verifyDevices: user.verifyDevices !== false,
// New-device verification is not supported without an email delivery channel.
// Always report disabled so clients do not present a false security posture.
verifyDevices: false,
role: user.role,
status: user.status,
object: 'profile',
+18 -18
View File
@@ -1,4 +1,10 @@
import { LIMITS } from '../config/limits';
import type { Env } from '../types';
import {
isBrowserExtensionOrigin,
isConfiguredWebAuthnAllowedOrigin,
normalizeOrigin,
} from './origins';
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
const DEFAULT_CORS_HEADERS = [
@@ -18,14 +24,6 @@ const DEFAULT_CORS_HEADERS = [
'X-NodeWarden-Web-Session',
];
function isExtensionOrigin(origin: string): boolean {
return (
origin.startsWith('chrome-extension://')
|| origin.startsWith('moz-extension://')
|| origin.startsWith('safari-web-extension://')
);
}
function isWildcardCorsPath(path: string): boolean {
return (
path.startsWith('/icons/')
@@ -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 origin = request.headers.get('Origin');
if (!origin) {
const originHeader = request.headers.get('Origin');
if (!originHeader) {
return isWildcardCorsPath(url.pathname)
? { allowOrigin: '*', allowCredentials: false }
: { allowOrigin: null, allowCredentials: false };
}
const origin = normalizeOrigin(originHeader);
if (origin === url.origin) {
return { allowOrigin: origin, allowCredentials: true };
}
if (isExtensionOrigin(origin)) {
if (isBrowserExtensionOrigin(origin) && isConfiguredWebAuthnAllowedOrigin(env, origin)) {
return { allowOrigin: origin, allowCredentials: true };
}
if (isWildcardCorsPath(url.pathname)) {
@@ -58,7 +57,7 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
return { allowOrigin: null, allowCredentials: false };
}
function buildCorsHeaders(request: Request): Record<string, string> {
function buildCorsHeaders(request: Request, env: Env): Record<string, string> {
const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '')
.split(',')
.map((value) => value.trim())
@@ -72,7 +71,7 @@ function buildCorsHeaders(request: Request): Record<string, string> {
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
};
const corsPolicy = getCorsPolicy(request);
const corsPolicy = getCorsPolicy(request, env);
if (corsPolicy.allowOrigin) {
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
if (corsPolicy.allowCredentials) {
@@ -86,7 +85,8 @@ function buildCorsHeaders(request: Request): Record<string, string> {
export function applyCors(
request: Request,
response: Response
response: Response,
env: Env
): Response {
// WebSocket upgrade responses must be returned untouched.
const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
@@ -95,7 +95,7 @@ export function applyCors(
}
const headers = new Headers(response.headers);
const corsHeaders = buildCorsHeaders(request);
const corsHeaders = buildCorsHeaders(request, env);
for (const [k, v] of Object.entries(corsHeaders)) {
headers.set(k, v);
}
@@ -159,10 +159,10 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
}
// Handle CORS preflight
export function handleCors(request: Request): Response {
export function handleCors(request: Request, env: Env): Response {
return new Response(null, {
status: 204,
headers: buildCorsHeaders(request),
headers: buildCorsHeaders(request, env),
});
}
+49 -17
View File
@@ -170,6 +170,7 @@
(function () {
var params = new URLSearchParams(window.location.search);
var sentSuccess = false;
var allowedParentOriginsPromise = null;
var text = pickText(params.get("locale") || navigator.language || "en");
document.documentElement.lang = params.get("locale") || navigator.language || "en";
@@ -227,24 +228,55 @@
return out;
}
function trustedParentOrigin() {
var parent = decodeRepeated(params.get("parent"));
if (!parent) return "";
function normalizeOrigin(value) {
if (!value) return "";
try {
var parentUrl = new URL(parent);
if (
parentUrl.protocol === "chrome-extension:" ||
parentUrl.protocol === "moz-extension:" ||
parentUrl.protocol === "safari-web-extension:"
) {
return parentUrl.protocol + "//" + parentUrl.host;
}
if (parentUrl.origin === window.location.origin) {
return parentUrl.origin;
}
var url = new URL(value);
if (!url.protocol || !url.host) return "";
return url.protocol + "//" + url.host;
} catch (_error) {
return "";
}
}
function isExtensionOrigin(origin) {
return (
origin.indexOf("chrome-extension://") === 0 ||
origin.indexOf("moz-extension://") === 0 ||
origin.indexOf("safari-web-extension://") === 0
);
}
function allowedParentOrigins() {
if (allowedParentOriginsPromise) return allowedParentOriginsPromise;
allowedParentOriginsPromise = fetch("/api/web-bootstrap", {
headers: { Accept: "application/json" },
credentials: "omit",
}).then(function (response) {
if (!response.ok) return [];
return response.json();
}).then(function (body) {
var origins = Array.isArray(body && body.webAuthnAllowedOrigins)
? body.webAuthnAllowedOrigins
: [];
return origins.map(normalizeOrigin).filter(Boolean);
}).catch(function () {
return [];
});
return allowedParentOriginsPromise;
}
function trustedParentOrigin(allowedOrigins) {
var parent = decodeRepeated(params.get("parent"));
if (!parent) return "";
var parentOrigin = normalizeOrigin(parent);
if (!parentOrigin) return "";
if (parentOrigin === window.location.origin) {
return parentOrigin;
}
if (isExtensionOrigin(parentOrigin) && allowedOrigins.indexOf(parentOrigin) >= 0) {
return parentOrigin;
}
return "";
}
@@ -258,8 +290,8 @@
return copy;
}
function postResult(message) {
var parentOrigin = trustedParentOrigin();
async function postResult(message) {
var parentOrigin = trustedParentOrigin(await allowedParentOrigins());
if (parentOrigin) {
if (window.opener && !window.opener.closed) {
window.opener.postMessage(message, parentOrigin);
@@ -371,7 +403,7 @@
if (!(credential instanceof PublicKeyCredential)) {
throw new Error("No security key was selected.");
}
postResult({
await postResult({
command: "webAuthnResult",
data: credentialToDataString(credential),
remember: rememberEl.checked,
+4 -2
View File
@@ -1167,7 +1167,6 @@ export default function App() {
const key = await encryptSessionUserKeyForAuthRequest(session, authRequest);
await respondToAuthRequest(authedFetch, authRequest.id, {
key,
masterPasswordHash: null,
deviceIdentifier: getCurrentDeviceIdentifier(),
requestApproved: true,
});
@@ -2121,7 +2120,10 @@ export default function App() {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
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) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
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>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
onInspectRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
+35 -2
View File
@@ -1,8 +1,9 @@
import { useState } from 'preact/hooks';
import { ArrowLeft, Eye, EyeOff, KeyRound, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
import { useEffect, useState } from 'preact/hooks';
import { AlertTriangle, ArrowLeft, Eye, EyeOff, KeyRound, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n';
import { getCurrentNetworkStatus, subscribeNetworkStatus, type NetworkStatus } from '@/lib/network-status';
interface LoginValues {
email: string;
@@ -81,6 +82,36 @@ function PasswordField(props: {
);
}
function OfflineModeNotice() {
const [status, setStatus] = useState<NetworkStatus>(getCurrentNetworkStatus);
useEffect(() => subscribeNetworkStatus(setStatus), []);
if (status !== 'offline') return null;
return (
<div className="offline-mode-notice" role="alert" aria-live="assertive">
<div>
<strong>{t('txt_offline_mode_notice_title')}</strong>
<div className="offline-shortcut-list">
<div className="offline-shortcut-row">
<span className="offline-shortcut-label">{t('txt_offline_mode_notice_windows')}</span>
<span className="offline-shortcut-value">
<span className="offline-shortcut-chord"><kbd>Ctrl</kbd><span>+</span><kbd>F5</kbd></span>
</span>
</div>
<div className="offline-shortcut-row">
<span className="offline-shortcut-label">{t('txt_offline_mode_notice_macos')}</span>
<span className="offline-shortcut-value">
<span className="offline-shortcut-chord"><kbd>Command</kbd><span>+</span><kbd>Shift</kbd><span>+</span><kbd>R</kbd></span>
</span>
</div>
</div>
</div>
</div>
);
}
export default function AuthViews(props: AuthViewsProps) {
const loginBusy = props.pendingAction === 'login';
const passkeyBusy = props.pendingAction === 'passkey';
@@ -99,6 +130,7 @@ export default function AuthViews(props: AuthViewsProps) {
props.onSubmitUnlock();
}}
>
<OfflineModeNotice />
<p className="muted standalone-muted">{props.emailForLock}</p>
<input type="text" value={props.emailForLock} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
<PasswordField
@@ -245,6 +277,7 @@ export default function AuthViews(props: AuthViewsProps) {
props.onSubmitLogin();
}}
>
<OfflineModeNotice />
{passkeyPasswordPending ? (
<>
<p className="muted standalone-muted">{props.pendingPasskeyPasswordEmail}</p>
+21 -17
View File
@@ -42,7 +42,7 @@ interface BackupCenterPageProps {
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
onInspectRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
@@ -492,8 +492,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
return verifyBackupFileIntegrity(bytes, file.name || '');
}
async function inspectRemoteBackupFile(destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
const payload = await props.onInspectRemoteBackup(destinationId, path);
async function inspectRemoteBackupFile(masterPassword: string, destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
const payload = await props.onInspectRemoteBackup(masterPassword, destinationId, path);
return {
source: 'remote',
path,
@@ -800,19 +800,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
if (!savedSelectedDestination) return;
setLocalError('');
resetPendingIntegrityWarning();
try {
const integrity = await inspectRemoteBackupFile(savedSelectedDestination.id, path);
if (!integrity.result.matches) {
setPendingRestoreIntegrity(integrity);
setConfirmIntegrityWarningOpen(true);
return;
}
await runRemoteRestore(path, false, false, integrity.result);
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed');
setLocalError(message);
props.onNotify('error', message);
}
await runRemoteRestore(path, false);
}
async function runRemoteRestore(
@@ -846,7 +834,23 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setRestoringRemotePath(path);
setLocalError('');
try {
const integrity = knownIntegrity ? { result: knownIntegrity } : await inspectRemoteBackupFile(savedSelectedDestination.id, path);
const integrity = knownIntegrity
? { result: knownIntegrity }
: await inspectRemoteBackupFile(masterPassword, savedSelectedDestination.id, path);
if (!allowChecksumMismatch && !integrity.result.matches) {
setPendingRestoreIntegrity(
'source' in integrity
? integrity
: {
source: 'remote',
path,
fileName: path.split('/').pop() || path,
result: integrity.result,
}
);
setConfirmIntegrityWarningOpen(true);
return true;
}
startRestoreProgress('backup-restore', path.split('/').pop() || path, {
source: 'remote',
delayMs: replaceExisting ? 480 : 1400,
+3 -1
View File
@@ -87,6 +87,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
const cardRef = useRef<HTMLFormElement | null>(null);
const maskPointerStartedRef = useRef(false);
const restoreFocusRef = useRef<HTMLElement | null>(null);
const lastTitleRef = useRef<ComponentChildren>(props.title);
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
const titleId = `${dialogId}-title`;
const messageId = `${dialogId}-message`;
@@ -95,6 +96,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
useEffect(() => {
if (props.open) {
lastTitleRef.current = props.title;
setPresent(true);
setClosing(false);
return;
@@ -228,7 +230,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
<X size={18} />
</button>
)}
<h3 id={titleId} className="dialog-title">{props.title}</h3>
<h3 id={titleId} className="dialog-title">{props.open ? props.title : lastTitleRef.current}</h3>
{hasMessage && <div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>}
{props.children}
{!props.hideConfirm && (
+8 -1
View File
@@ -53,6 +53,13 @@ function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? value as Record<string, unknown> : null;
}
function formatSendDate(value: string | null | undefined): string {
if (!value) return '';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '';
return parsed.toLocaleString();
}
function optionalString(value: unknown): string | null {
return typeof value === 'string' ? value : null;
}
@@ -283,7 +290,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
</button>
</div>
)}
{!!sendData.expirationDate && <p className="muted">{t('txt_expires_at_value', { value: sendData.expirationDate })}</p>}
{!!sendData.expirationDate && <p className="muted">{t('txt_expires_at_value', { value: formatSendDate(sendData.expirationDate) })}</p>}
</>
)}
+24 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Lock, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import LoadingState from '@/components/LoadingState';
import type { Send, SendDraft } from '@/lib/types';
@@ -32,6 +32,13 @@ function daysFromNow(iso: string | null | undefined, fallback: number): string {
return String(Math.max(days, 0));
}
function formatSendDate(value: string | null | undefined): string {
if (!value) return t('txt_dash');
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return t('txt_dash');
return parsed.toLocaleString();
}
function buildDefaultDraft(): SendDraft {
return {
type: 'text',
@@ -43,6 +50,7 @@ function buildDefaultDraft(): SendDraft {
expirationDays: '0',
maxAccessCount: '',
password: '',
hasPassword: false,
disabled: false,
};
}
@@ -59,6 +67,7 @@ function draftFromSend(send: Send): SendDraft {
expirationDays: daysFromNow(send.expirationDate, 0),
maxAccessCount: send.maxAccessCount !== null && send.maxAccessCount !== undefined ? String(send.maxAccessCount) : '',
password: '',
hasPassword: !!send.password,
disabled: !!send.disabled,
};
}
@@ -380,6 +389,7 @@ export default function SendsPage(props: SendsPageProps) {
<div className="list-text">
<span className="list-title" title={send.decName || t('txt_no_name')}>{send.decName || t('txt_no_name')}</span>
<span className="list-sub">
{!!send.password && <><Lock size={12} className="inline-icon" /> </>}
{Number(send.type) === 1 ? t('txt_file') : t('txt_text')} - {t('txt_accessed_count_times', { count: send.accessCount || 0 })}
</span>
</div>
@@ -471,12 +481,23 @@ export default function SendsPage(props: SendsPageProps) {
</label>
<label className="field">
<span>{t('txt_password')}</span>
{draft.hasPassword ? (
<div className="password-wrap">
<input className="input" type="password" value="••••••••" disabled />
{!isCreating && (
<button type="button" className="password-toggle text-red-600 hover:text-red-700" onClick={() => setDraft({ ...draft, hasPassword: false, password: '' })} title={t('txt_remove')}>
<Trash2 size={16} />
</button>
)}
</div>
) : (
<div className="password-wrap">
<input className="input" type={showPassword ? 'text' : 'password'} value={draft.password} onInput={(e) => setDraft({ ...draft, password: (e.currentTarget as HTMLInputElement).value })} />
<button type="button" className="password-toggle" onClick={() => setShowPassword((v) => !v)}>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
)}
</label>
<label className="field field-span-2">
<span>{t('txt_notes')}</span>
@@ -523,8 +544,8 @@ export default function SendsPage(props: SendsPageProps) {
<div className="card stagger-item stagger-delay-2">
<h4>{t('txt_send_details')}</h4>
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{selectedSend.expirationDate || t('txt_dash')}</strong></div>
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{formatSendDate(selectedSend.deletionDate)}</strong></div>
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{formatSendDate(selectedSend.expirationDate)}</strong></div>
</div>
<div className="card">
+2 -2
View File
@@ -130,7 +130,7 @@ export default function SettingsPage(props: SettingsPageProps) {
const [accountPasskeys, setAccountPasskeys] = useState<AccountPasskeyCredential[]>([]);
const [accountPasskeysLoading, setAccountPasskeysLoading] = useState(false);
const [accountPasskeyName, setAccountPasskeyName] = useState(t('txt_account_passkey'));
const [accountPasskeyDirectUnlock, setAccountPasskeyDirectUnlock] = useState(false);
const [accountPasskeyDirectUnlock, setAccountPasskeyDirectUnlock] = useState(true);
const [accountPasskeyPromptId, setAccountPasskeyPromptId] = useState<string | null>(null);
const [createPasskeyDialogOpen, setCreatePasskeyDialogOpen] = useState(false);
const [createPasskeyMasterPassword, setCreatePasskeyMasterPassword] = useState('');
@@ -509,7 +509,7 @@ export default function SettingsPage(props: SettingsPageProps) {
setCreatePasskeyDialogOpen(false);
setCreatePasskeyMasterPassword('');
setAccountPasskeyName(t('txt_account_passkey'));
setAccountPasskeyDirectUnlock(false);
setAccountPasskeyDirectUnlock(true);
}
async function submitCreatePasskeyDialog(): Promise<void> {
+18 -26
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Clipboard, Globe } from 'lucide-preact';
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
import { calcTotpNow } from '@/lib/crypto';
import { calcTotpNow, type TotpCodeResult } from '@/lib/crypto';
import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types';
import LoadingState from '@/components/LoadingState';
@@ -14,17 +14,9 @@ interface TotpCodesPageProps {
onNotify: (type: 'success' | 'error', text: string) => void;
}
const TOTP_PERIOD_SECONDS = 30;
const TOTP_RING_RADIUS = 14;
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
const TOTP_REFRESH_BATCH_SIZE = 16;
function getTotpTimeState(): { windowId: number; remain: number } {
const epoch = Math.floor(Date.now() / 1000);
return {
windowId: Math.floor(epoch / TOTP_PERIOD_SECONDS),
remain: TOTP_PERIOD_SECONDS - (epoch % TOTP_PERIOD_SECONDS),
};
}
function TotpListIcon({ cipher }: { cipher: Cipher }) {
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
@@ -32,13 +24,15 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
interface TotpRowProps {
cipher: Cipher;
live: { code: string; remain: number } | null;
live: TotpCodeResult | null;
onCopy: (value: string) => void;
}
function TotpRow(props: TotpRowProps) {
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
const username = props.cipher.login?.decUsername || '';
const period = Math.max(1, props.live?.period || 30);
const progress = props.live ? Math.max(0, Math.min(period, props.live.remain)) / period : 0;
return (
<div className="totp-code-row">
@@ -69,8 +63,7 @@ function TotpRow(props: TotpRowProps) {
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
strokeDashoffset: String(
TOTP_RING_CIRCUMFERENCE -
TOTP_RING_CIRCUMFERENCE *
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
TOTP_RING_CIRCUMFERENCE * progress
),
}}
/>
@@ -86,8 +79,7 @@ function TotpRow(props: TotpRowProps) {
}
export default function TotpCodesPage(props: TotpCodesPageProps) {
const [totpCodes, setTotpCodes] = useState<Record<string, string | null>>({});
const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain);
const [totpCodes, setTotpCodes] = useState<Record<string, TotpCodeResult | null>>({});
const [columnCount, setColumnCount] = useState(1);
const listRef = useRef<HTMLDivElement | null>(null);
@@ -120,11 +112,10 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
let stopped = false;
let activeRun = 0;
let timer = 0;
let currentWindowId = -1;
const refreshCodes = async () => {
const runId = ++activeRun;
const nextCodes: Record<string, string | null> = {};
const nextCodes: Record<string, TotpCodeResult | null> = {};
for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) {
if (stopped || runId !== activeRun) return;
const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE);
@@ -132,7 +123,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
batch.map(async (cipher) => {
try {
const next = await calcTotpNow(cipher.login?.decTotp || '');
return [cipher.id, next?.code || null] as const;
return [cipher.id, next] as const;
} catch {
return [cipher.id, null] as const;
}
@@ -146,15 +137,20 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
if (stopped || runId !== activeRun) return;
setTotpCodes((prev) => {
let changed = false;
const next: Record<string, string | null> = { ...prev };
const next: Record<string, TotpCodeResult | null> = { ...prev };
for (const id of Object.keys(next)) {
if (id in nextCodes) continue;
delete next[id];
changed = true;
}
for (const [id, code] of Object.entries(nextCodes)) {
if (next[id] === code) continue;
next[id] = code;
for (const [id, live] of Object.entries(nextCodes)) {
const prevLive = next[id];
if (
prevLive?.code === live?.code &&
prevLive?.remain === live?.remain &&
prevLive?.period === live?.period
) continue;
next[id] = live;
changed = true;
}
return changed ? next : prev;
@@ -162,10 +158,6 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
};
const tick = () => {
const next = getTotpTimeState();
setRemainingSeconds((prev) => (prev === next.remain ? prev : next.remain));
if (next.windowId === currentWindowId) return;
currentWindowId = next.windowId;
void refreshCodes();
};
@@ -215,7 +207,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
<TotpRow
key={cipher.id}
cipher={cipher}
live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null}
live={totpCodes[cipher.id] || null}
onCopy={(value) => void copyToClipboard(value)}
/>
))}
+46 -2
View File
@@ -31,7 +31,7 @@ import {
type SidebarFilter,
type VaultSortMode,
} from '@/components/vault/vault-page-helpers';
import { calcTotpNow } from '@/lib/crypto';
import { calcTotpNow, type TotpCodeResult } from '@/lib/crypto';
import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh';
import { ChevronLeft } from 'lucide-preact';
import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
@@ -109,7 +109,7 @@ export default function VaultPage(props: VaultPageProps) {
const [renameFolderName, setRenameFolderName] = useState('');
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
const [totpLive, setTotpLive] = useState<TotpCodeResult | null>(null);
const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({});
const [attachmentQueue, setAttachmentQueue] = useState<File[]>([]);
const [removedAttachmentIds, setRemovedAttachmentIds] = useState<Record<string, boolean>>({});
@@ -419,7 +419,36 @@ export default function VaultPage(props: VaultPageProps) {
return !!meta?.searchText.includes(searchQuery);
});
// Pre-compute group min name for duplicates group ordering
const groupMinName = new Map<string, string>();
if (sidebarFilter.kind === 'duplicates' && duplicateSignatureInfo) {
for (const cipher of next) {
const gk = (duplicateSignatureInfo.byId.get(cipher.id) || [])
.filter(s => (duplicateSignatureInfo.counts.get(s) || 0) >= 2)
.sort()[0] || '';
if (!gk) continue;
const name = cipherMetaById.get(cipher.id)?.name || '';
const cur = groupMinName.get(gk);
if (!cur || nameCollator.compare(name, cur) < 0) groupMinName.set(gk, name);
}
}
next.sort((a, b) => {
// Duplicates view: group by color, sort A-Z within each group
if (sidebarFilter.kind === 'duplicates' && duplicateSignatureInfo) {
const gk = (id: string) => (duplicateSignatureInfo.byId.get(id) || [])
.filter(s => (duplicateSignatureInfo.counts.get(s) || 0) >= 2)
.sort()[0] || '';
const gA = gk(a.id), gB = gk(b.id);
if (gA !== gB) return !gA ? 1 : !gB ? -1 : nameCollator.compare(
groupMinName.get(gA) || '', groupMinName.get(gB) || ''
) || (gA < gB ? -1 : 1);
return nameCollator.compare(
cipherMetaById.get(a.id)?.name || '',
cipherMetaById.get(b.id)?.name || ''
) || String(a.id || '').localeCompare(String(b.id || ''));
}
const metaA = cipherMetaById.get(a.id);
const metaB = cipherMetaById.get(b.id);
if (sortMode === 'edited') {
@@ -1049,6 +1078,20 @@ const folderName = useCallback((id: string | null | undefined): string => {
}
setSelectedMap(map);
}, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
const handleSelectUniqueFromDuplicates = useCallback(() => {
const map: Record<string, boolean> = {};
const seen = new Set<number>();
for (const cipher of filteredCiphers) {
const groupIndex = duplicateGroupIndexById.get(cipher.id);
if (groupIndex === undefined) continue;
if (seen.has(groupIndex)) {
map[cipher.id] = true;
} else {
seen.add(groupIndex);
}
}
setSelectedMap(map);
}, [filteredCiphers, duplicateGroupIndexById]);
const handleSelectAll = useCallback(() => {
const map: Record<string, boolean> = {};
for (const cipher of filteredCiphers) map[cipher.id] = true;
@@ -1163,6 +1206,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
onSyncVault={handleSyncVault}
onOpenBulkDelete={handleOpenBulkDelete}
onSelectDuplicates={handleSelectDuplicates}
onSelectUniqueFromDuplicates={handleSelectUniqueFromDuplicates}
onSelectAll={handleSelectAll}
onToggleCreateMenu={handleToggleCreateMenu}
onStartCreate={startCreate}
@@ -2,11 +2,11 @@ import { createPortal } from 'preact/compat';
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
import { useDialogLifecycle } from '@/components/ConfirmDialog';
import type { TotpCodeResult } from '@/lib/crypto';
import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n';
import {
CardBrandIcon,
TOTP_PERIOD_SECONDS,
TOTP_RING_CIRCUMFERENCE,
VaultListIcon,
copyToClipboard,
@@ -25,7 +25,7 @@ interface VaultDetailViewProps {
selectedCipher: Cipher;
repromptApprovedCipherId: string | null;
showPassword: boolean;
totpLive: { code: string; remain: number } | null;
totpLive: TotpCodeResult | null;
passkeyCreatedAt: string | null;
hiddenFieldVisibleMap: Record<number, boolean>;
folderName: (id: string | null | undefined) => string;
@@ -42,6 +42,11 @@ interface VaultDetailViewProps {
onUnarchive: (cipher: Cipher) => void | Promise<void>;
}
function totpProgress(live: TotpCodeResult | null): number {
const period = Math.max(1, live?.period || 30);
return live ? Math.max(0, Math.min(period, live.remain)) / period : 0;
}
function PasswordHistoryDialog(props: {
open: boolean;
entries: Array<{ password: string; lastUsedDate: string | null }>;
@@ -191,8 +196,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
strokeDashoffset: String(
TOTP_RING_CIRCUMFERENCE -
TOTP_RING_CIRCUMFERENCE *
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.totpLive?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
TOTP_RING_CIRCUMFERENCE * totpProgress(props.totpLive)
),
}}
/>
+4 -3
View File
@@ -4,6 +4,7 @@ import { ArrowDown, ArrowUp, CheckCheck, Download, Paperclip, Plus, QrCode, Refr
import jsQR from 'jsqr';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useDialogLifecycle } from '@/components/ConfirmDialog';
import { normalizeTotpInput } from '@/lib/crypto';
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
import { t } from '@/lib/i18n';
import { cardBrand } from '@/lib/import-format-shared';
@@ -161,9 +162,9 @@ export default function VaultEditor(props: VaultEditorProps) {
};
const applyTotpQrValue = (value: string) => {
const trimmed = value.trim();
if (!trimmed) return false;
props.onUpdateDraft({ loginTotp: trimmed });
const normalized = normalizeTotpInput(value);
if (!normalized) return false;
props.onUpdateDraft({ loginTotp: normalized });
setTotpQrStatus(t('txt_totp_qr_scanned'));
setTotpQrOpen(false);
return true;
+16 -6
View File
@@ -81,6 +81,7 @@ interface VaultListPanelProps {
onSyncVault: () => void;
onOpenBulkDelete: () => void;
onSelectDuplicates: () => void;
onSelectUniqueFromDuplicates: () => void;
onSelectAll: () => void;
onToggleCreateMenu: () => void;
onStartCreate: (type: number) => void;
@@ -319,13 +320,17 @@ export default function VaultListPanel(props: VaultListPanelProps) {
</>
) : (
<>
<div className="search-input-wrap">
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
<div className="duplicate-mode-head-menu">
<div className="duplicate-mode-head-menu mobile-duplicate-toolbar">
<div className="mobile-duplicate-mode-select-wrap">
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
</div>
<button type="button" className="btn btn-secondary small" onClick={props.onSelectUniqueFromDuplicates}>
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
</button>
</div>
) : (
<>
<div className="search-input-wrap">
<input
className="search-input"
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
@@ -350,9 +355,8 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<X size={14} />
</button>
)}
</>
)}
</div>
)}
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
<div className="duplicate-mode-head-menu">
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
@@ -387,7 +391,13 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
</button>
{!props.isMobileLayout && props.sidebarFilter !== undefined && createMenu}
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout ? (
<button type="button" className="btn btn-secondary small" onClick={props.onSelectUniqueFromDuplicates}>
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
</button>
) : (
!props.isMobileLayout && props.sidebarFilter !== undefined && createMenu
)}
</>
)}
</div>
+8 -5
View File
@@ -10,6 +10,7 @@ import {
} from '@/lib/website-icon-cache';
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status';
import { areWebsiteIconsEnabled } from '@/lib/website-icon-settings';
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
@@ -22,7 +23,8 @@ interface WebsiteIconProps {
export default function WebsiteIcon(props: WebsiteIconProps) {
const host = useMemo(() => hostFromUri(firstCipherUri(props.cipher)), [props.cipher]);
const src = host ? websiteIconUrl(host) : '';
const iconsEnabled = areWebsiteIconsEnabled();
const src = iconsEnabled && host ? websiteIconUrl(host) : '';
const nodeRef = useRef<HTMLSpanElement | null>(null);
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
@@ -33,7 +35,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
useEffect(() => subscribeNetworkStatus(setNetworkStatus), []);
useEffect(() => {
if (!host) {
if (!host || !iconsEnabled) {
setShouldLoad(true);
setStatus('idle');
setImageUrl('');
@@ -47,7 +49,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
setStatus(next);
setImageUrl(getWebsiteIconImageUrl(host));
});
}, [host]);
}, [host, iconsEnabled]);
useEffect(() => {
if (!host || shouldLoad || status === 'loaded' || status === 'error') return;
@@ -81,10 +83,11 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
useEffect(() => {
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
if (demoIconUrl) return;
if (!iconsEnabled) return;
if (networkStatus !== 'online') return;
if (!host || !src || !shouldLoad || status !== 'idle') return;
beginWebsiteIconLoad(host, src);
}, [demoIconUrl, host, networkStatus, src, shouldLoad, status]);
}, [demoIconUrl, host, iconsEnabled, networkStatus, src, shouldLoad, status]);
if (demoIconUrl) {
return (
@@ -100,7 +103,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
);
}
if (!host || status === 'error') {
if (!host || !iconsEnabled || status === 'error') {
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
}
@@ -207,8 +207,7 @@ export function getWebsiteMatchOptions(): Array<{ value: number | null; label: s
];
}
export const TOTP_PERIOD_SECONDS = 30;
export const TOTP_RING_RADIUS = 14;
const TOTP_RING_RADIUS = 14;
export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
export function CreateTypeIcon({ type }: { type: number }) {
+2 -2
View File
@@ -82,8 +82,8 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
},
async inspectRemoteBackup(destinationId: string, path: string) {
return inspectRemoteBackupIntegrity(authedFetch, destinationId, path);
async inspectRemoteBackup(masterPasswordHash: string, destinationId: string, path: string) {
return inspectRemoteBackupIntegrity(authedFetch, masterPasswordHash, destinationId, path);
},
async deleteRemoteBackup(masterPasswordHash: string, destinationId: string, path: string) {
+27 -113
View File
@@ -16,6 +16,7 @@ export interface PendingAccountPasskeyCredential {
deviceResponse: PublicKeyCredential;
request: Record<string, unknown>;
supportsPrf: boolean;
prfKey?: Uint8Array;
}
export interface AccountPasskeyPrfKeySet {
@@ -82,20 +83,9 @@ async function getLoginWithPrfSalt(): Promise<Uint8Array> {
return new Uint8Array(hash);
}
function credentialIdToBase64Url(id: BufferSource): string | null {
try {
const bytes = id instanceof ArrayBuffer
? new Uint8Array(id)
: new Uint8Array(id.buffer, id.byteOffset, id.byteLength);
return bytesToBase64Url(bytes);
} catch {
return null;
}
}
type PrfEvalInput = { first: Uint8Array };
function buildLegacyPrfExtension(salt: Uint8Array): Record<string, unknown> {
function buildPrfExtension(salt: Uint8Array): Record<string, unknown> {
const evalInput: PrfEvalInput = { first: salt };
return {
prf: {
@@ -104,34 +94,23 @@ function buildLegacyPrfExtension(salt: Uint8Array): Record<string, unknown> {
};
}
function buildCredentialPrfExtension(
salt: Uint8Array,
credentialIds: Array<string | null | undefined>
): Record<string, unknown> {
const evalInput = { first: salt };
const evalByCredential = credentialIds
.filter((id): id is string => !!id)
.reduce<Record<string, PrfEvalInput>>((out, id) => {
out[id] = evalInput;
return out;
}, {});
if (!Object.keys(evalByCredential).length) return buildLegacyPrfExtension(salt);
return {
prf: {
evalByCredential,
},
};
}
function withPrfExtension(
options: PublicKeyCredentialCreationOptions,
salt: Uint8Array
): PublicKeyCredentialCreationOptions;
function withPrfExtension(
options: PublicKeyCredentialRequestOptions,
extension: Record<string, unknown>
): PublicKeyCredentialRequestOptions {
salt: Uint8Array
): PublicKeyCredentialRequestOptions;
function withPrfExtension(
options: PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions,
salt: Uint8Array
): PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions {
return {
...options,
extensions: {
...((options as any).extensions || {}),
...extension,
...buildPrfExtension(salt),
} as any,
};
}
@@ -154,71 +133,18 @@ function readPrfFirstResult(credential: PublicKeyCredential): ArrayBuffer | unde
return result instanceof ArrayBuffer ? result : undefined;
}
function hasPrfExtensionResult(credential: PublicKeyCredential): boolean {
return Object.prototype.hasOwnProperty.call(credential.getClientExtensionResults() as any, 'prf');
}
function shouldRetryWithLegacyPrf(error: unknown): boolean {
const name = error instanceof DOMException || error instanceof Error ? error.name : '';
return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError';
}
function shouldRetryCreateWithoutPrf(error: unknown): boolean {
const name = error instanceof DOMException || error instanceof Error ? error.name : '';
const message = error instanceof DOMException || error instanceof Error ? error.message : '';
return (
name === 'NotSupportedError' ||
name === 'SyntaxError' ||
name === 'TypeError' ||
(name === 'UnknownError' && /transient/i.test(message))
);
}
async function canRequestPrfExtension(): Promise<boolean> {
if (/\bFirefox\//i.test(navigator.userAgent)) return false;
return true;
}
async function getPublicKeyCredentialWithPrf(
options: PublicKeyCredentialRequestOptions,
salt: Uint8Array,
credentialIds: string[] = []
salt: Uint8Array
): Promise<PublicKeyCredential> {
const attempts = credentialIds.length
? [
buildCredentialPrfExtension(salt, credentialIds),
buildLegacyPrfExtension(salt),
]
: [buildLegacyPrfExtension(salt)];
let lastCredential: PublicKeyCredential | null = null;
for (let index = 0; index < attempts.length; index += 1) {
try {
const credential = await navigator.credentials.get({
publicKey: withPrfExtension(options, attempts[index]),
publicKey: withPrfExtension(options, salt),
});
if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_selected'));
}
lastCredential = credential;
if (readPrfFirstResult(credential) || hasPrfExtensionResult(credential) || index === attempts.length - 1) {
return credential;
}
} catch (error) {
if (index === attempts.length - 1 || !shouldRetryWithLegacyPrf(error)) {
if (lastCredential) return lastCredential;
throw error;
}
}
}
if (lastCredential) return lastCredential;
throw new Error(t('txt_no_passkey_selected'));
}
function prfCredentialIdsFromAllowCredentials(options: PublicKeyCredentialRequestOptions): string[] {
return (options.allowCredentials || [])
.map((credential) => credentialIdToBase64Url(credential.id))
.filter((id): id is string => !!id);
}
async function prfOutputToKey(prfOutput: ArrayBuffer): Promise<Uint8Array> {
const prf = new Uint8Array(prfOutput);
@@ -282,8 +208,7 @@ export async function assertAccountPasskey(
const nativeOptions = cloneRequestOptions(response.options);
const credential = await getPublicKeyCredentialWithPrf(
nativeOptions,
await getLoginWithPrfSalt(),
prfCredentialIdsFromAllowCredentials(nativeOptions)
await getLoginWithPrfSalt()
);
const prfResult = readPrfFirstResult(credential);
return {
@@ -309,34 +234,22 @@ export async function createAccountPasskeyCredential(
}
return credential;
};
let credential: PublicKeyCredential;
if (requestPrf && await canRequestPrfExtension()) {
const prfOptions: PublicKeyCredentialCreationOptions = {
...noPrfOptions,
extensions: {
...((noPrfOptions as any).extensions || {}),
prf: {},
} as any,
};
try {
credential = await createWithOptions(prfOptions);
} catch (error) {
if (!shouldRetryCreateWithoutPrf(error)) throw error;
credential = await createWithOptions(noPrfOptions);
}
} else {
credential = await createWithOptions(noPrfOptions);
}
const prfSalt = requestPrf ? await getLoginWithPrfSalt() : null;
const credential = await createWithOptions(
prfSalt ? withPrfExtension(noPrfOptions, prfSalt) : noPrfOptions
);
if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_created'));
}
const supportsPrf = !!(credential.getClientExtensionResults() as any).prf?.enabled;
const prfResult = readPrfFirstResult(credential);
const supportsPrf = !!prfResult || (credential.getClientExtensionResults() as any).prf?.enabled === true;
return {
token: response.token,
createOptions: nativeOptions,
deviceResponse: credential,
request: attestationRequest(credential),
supportsPrf,
prfKey: prfResult ? await prfOutputToKey(prfResult) : undefined,
};
}
@@ -373,8 +286,10 @@ export async function buildAccountPasskeyPrfKeySet(
pending: PendingAccountPasskeyCredential,
userKey: { symEncKey: string; symMacKey: string }
): Promise<AccountPasskeyPrfKeySet> {
if (pending.prfKey) {
return buildAccountPasskeyPrfKeySetFromPrfKey(pending.prfKey, userKey);
}
const rawId = new Uint8Array(pending.deviceResponse.rawId);
const credentialId = bytesToBase64Url(rawId);
const assertionOptions: PublicKeyCredentialRequestOptions = {
challenge: pending.createOptions?.challenge!,
rpId: pending.createOptions?.rp?.id,
@@ -384,8 +299,7 @@ export async function buildAccountPasskeyPrfKeySet(
};
const assertion = await getPublicKeyCredentialWithPrf(
assertionOptions,
await getLoginWithPrfSalt(),
[credentialId]
await getLoginWithPrfSalt()
);
const prfResult = readPrfFirstResult(assertion);
if (!prfResult) {
-1
View File
@@ -52,7 +52,6 @@ export async function respondToAuthRequest(
requestId: string,
payload: {
key?: string | null;
masterPasswordHash?: string | null;
deviceIdentifier: string;
requestApproved: boolean;
}
+14 -9
View File
@@ -196,11 +196,14 @@ export async function exportAdminBackup(
export async function downloadAdminBackupAttachmentBlob(
authedFetch: AuthedFetch,
blobName: string
blobName: string,
masterPasswordHash: string
): Promise<Uint8Array> {
const params = new URLSearchParams();
params.set('blobName', blobName);
const resp = await authedFetch(`/api/admin/backup/blob?${params.toString()}`, { method: 'GET' });
const resp = await authedFetch('/api/admin/backup/blob', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ blobName, masterPasswordHash }),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
return new Uint8Array(await resp.arrayBuffer());
}
@@ -246,7 +249,7 @@ export async function buildCompleteAdminBackupExport(
stageDetail: 'txt_backup_export_progress_fetch_attachments_detail',
});
for (const attachment of manifest.attachmentBlobs || []) {
const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName);
const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName, masterPasswordHash);
zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes;
}
@@ -417,13 +420,15 @@ export async function deleteRemoteBackup(
export async function inspectRemoteBackupIntegrity(
authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId: string,
path: string
): Promise<RemoteBackupIntegrityResponse> {
const params = new URLSearchParams();
params.set('destinationId', destinationId);
params.set('path', path);
const resp = await authedFetch(`/api/admin/backup/remote/integrity?${params.toString()}`, { method: 'GET' });
const resp = await authedFetch('/api/admin/backup/remote/integrity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destinationId, path, masterPasswordHash }),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
const body = await parseJson<RemoteBackupIntegrityResponse>(resp);
if (!body?.integrity || !body?.fileName) throw new Error(t('txt_backup_remote_invalid_response'));
+15 -2
View File
@@ -27,6 +27,7 @@ import {
unlockOfflineVaultWithMasterKey,
} from '@/lib/offline-auth';
import { probeNodeWardenService } from '@/lib/network-status';
import { setWebsiteIconsEnabled } from '@/lib/website-icon-settings';
import type { AccountPasskeyPrfOption, AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
export interface PendingTotp {
@@ -51,6 +52,7 @@ export type JwtUnsafeReason = 'missing' | 'too_short';
export interface BootstrapAppResult {
defaultKdfIterations: number;
registrationInviteRequired?: boolean;
websiteIconsEnabled: boolean;
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
session: SessionState | null;
profile: Profile | null;
@@ -61,6 +63,7 @@ export interface BootstrapAppResult {
export interface InitialAppBootstrapState {
defaultKdfIterations: number;
registrationInviteRequired?: boolean;
websiteIconsEnabled: boolean;
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
session: SessionState | null;
phase: AppPhase;
@@ -229,10 +232,11 @@ function readWindowBootstrap(): WebBootstrapResponse {
return raw && typeof raw === 'object' ? raw : {};
}
function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialAppBootstrapState, 'defaultKdfIterations' | 'registrationInviteRequired' | 'jwtWarning'> {
function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialAppBootstrapState, 'defaultKdfIterations' | 'registrationInviteRequired' | 'websiteIconsEnabled' | 'jwtWarning'> {
const defaultKdfIterations = Number(boot.defaultKdfIterations || 600000);
const registrationInviteRequired =
typeof boot.registrationInviteRequired === 'boolean' ? boot.registrationInviteRequired : undefined;
const websiteIconsEnabled = boot.websiteIconsEnabled !== false;
const jwtUnsafeReason = boot.jwtUnsafeReason || null;
const jwtWarning = jwtUnsafeReason
? {
@@ -244,6 +248,7 @@ function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialApp
return {
defaultKdfIterations,
registrationInviteRequired,
websiteIconsEnabled,
jwtWarning,
};
}
@@ -304,7 +309,8 @@ function resolveUnauthenticatedPhase(registrationInviteRequired: boolean | undef
}
export function readInitialAppBootstrapState(): InitialAppBootstrapState {
const { defaultKdfIterations, registrationInviteRequired, jwtWarning } = normalizeBootstrapResponse(readWindowBootstrap());
const { defaultKdfIterations, registrationInviteRequired, websiteIconsEnabled, jwtWarning } = normalizeBootstrapResponse(readWindowBootstrap());
setWebsiteIconsEnabled(websiteIconsEnabled);
const session = loadSession();
const hasInviteCode = !!readInviteCodeFromUrl();
const unauthenticatedPhase = hasInviteCode ? 'register' : 'login';
@@ -312,6 +318,7 @@ export function readInitialAppBootstrapState(): InitialAppBootstrapState {
return {
defaultKdfIterations,
registrationInviteRequired,
websiteIconsEnabled,
jwtWarning,
session,
phase: jwtWarning ? 'login' : session ? 'locked' : resolveUnauthenticatedPhase(registrationInviteRequired, unauthenticatedPhase),
@@ -323,12 +330,15 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
const normalizedBoot = normalizeBootstrapResponse(remoteBoot);
const defaultKdfIterations = normalizedBoot.defaultKdfIterations || initial.defaultKdfIterations;
const registrationInviteRequired = normalizedBoot.registrationInviteRequired ?? initial.registrationInviteRequired;
const websiteIconsEnabled = normalizedBoot.websiteIconsEnabled !== false;
setWebsiteIconsEnabled(websiteIconsEnabled);
const jwtWarning = normalizedBoot.jwtWarning ?? initial.jwtWarning;
if (jwtWarning) {
return {
defaultKdfIterations,
registrationInviteRequired,
websiteIconsEnabled,
jwtWarning,
session: null,
profile: null,
@@ -341,6 +351,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
return {
defaultKdfIterations,
registrationInviteRequired,
websiteIconsEnabled,
jwtWarning: null,
session: null,
profile: null,
@@ -353,6 +364,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
return {
defaultKdfIterations,
registrationInviteRequired,
websiteIconsEnabled,
jwtWarning: null,
session: loaded,
profile: cachedProfile,
@@ -364,6 +376,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
return {
defaultKdfIterations,
registrationInviteRequired,
websiteIconsEnabled,
jwtWarning: null,
session: loaded,
profile: null,
+219 -27
View File
@@ -259,17 +259,33 @@ interface TotpConfig {
period: number;
}
interface GoogleAuthenticatorMigrationTotp {
secret: string;
name: string;
issuer: string;
algorithm: TotpHashAlgorithm;
digits: number;
period: number;
}
const DEFAULT_TOTP_CONFIG: Omit<TotpConfig, 'secret' | 'steam'> = {
algorithm: 'SHA-1',
digits: 6,
period: 30,
};
function parseTotpPositiveInt(value: string | null, fallback: number, min: number, max: number): number {
if (!value) return fallback;
function parseTotpDigits(value: string | null): number {
if (!value) return DEFAULT_TOTP_CONFIG.digits;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < min || parsed > max) return fallback;
return parsed;
if (!Number.isInteger(parsed)) return DEFAULT_TOTP_CONFIG.digits;
return Math.max(0, Math.min(10, parsed));
}
function parseTotpPeriod(value: string | null): number {
if (!value) return DEFAULT_TOTP_CONFIG.period;
const parsed = Number(value);
if (!Number.isSafeInteger(parsed)) return DEFAULT_TOTP_CONFIG.period;
return Math.max(1, parsed);
}
function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm {
@@ -279,9 +295,190 @@ function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm {
return 'SHA-1';
}
function parseTotpConfig(raw: string): TotpConfig {
if (!raw) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
function base64ToBytesLoose(value: string): Uint8Array {
const normalized = value.trim().replace(/\s/g, '+').replace(/-/g, '+').replace(/_/g, '/');
if (!normalized) return new Uint8Array();
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
try {
const binary = atob(padded);
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
} catch {
return new Uint8Array();
}
}
function bytesToBase32(bytes: Uint8Array): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = 0;
let value = 0;
let out = '';
for (const byte of bytes) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
out += alphabet[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
out += alphabet[(value << (5 - bits)) & 31];
}
return out;
}
function readProtoVarint(bytes: Uint8Array, state: { offset: number }): number | null {
let result = 0;
let factor = 1;
for (let i = 0; i < 10 && state.offset < bytes.length; i += 1) {
const byte = bytes[state.offset++];
result += (byte & 0x7f) * factor;
if ((byte & 0x80) === 0) return Number.isSafeInteger(result) ? result : null;
factor *= 128;
}
return null;
}
function readProtoBytes(bytes: Uint8Array, state: { offset: number }): Uint8Array | null {
const length = readProtoVarint(bytes, state);
if (length == null || length < 0 || state.offset + length > bytes.length) return null;
const out = bytes.slice(state.offset, state.offset + length);
state.offset += length;
return out;
}
function skipProtoField(bytes: Uint8Array, state: { offset: number }, wireType: number): boolean {
if (wireType === 0) return readProtoVarint(bytes, state) != null;
if (wireType === 1 && state.offset + 8 <= bytes.length) {
state.offset += 8;
return true;
}
if (wireType === 2) return readProtoBytes(bytes, state) != null;
if (wireType === 5 && state.offset + 4 <= bytes.length) {
state.offset += 4;
return true;
}
return false;
}
function googleMigrationAlgorithm(value: number): TotpHashAlgorithm | null {
if (value === 0 || value === 1) return 'SHA-1';
if (value === 2) return 'SHA-256';
if (value === 3) return 'SHA-512';
return null;
}
function googleMigrationDigits(value: number): number {
if (value === 2) return 8;
return 6;
}
function parseGoogleMigrationOtpParameter(bytes: Uint8Array): GoogleAuthenticatorMigrationTotp | null {
const state = { offset: 0 };
let secretBytes: Uint8Array | null = null;
let name = '';
let issuer = '';
let algorithm: TotpHashAlgorithm | null = 'SHA-1';
let digits = 6;
let otpType = 0;
const decoder = new TextDecoder();
while (state.offset < bytes.length) {
const key = readProtoVarint(bytes, state);
if (key == null) return null;
const fieldNumber = Math.floor(key / 8);
const wireType = key % 8;
if (fieldNumber === 1 && wireType === 2) {
secretBytes = readProtoBytes(bytes, state);
} else if (fieldNumber === 2 && wireType === 2) {
const value = readProtoBytes(bytes, state);
name = value ? decoder.decode(value) : '';
} else if (fieldNumber === 3 && wireType === 2) {
const value = readProtoBytes(bytes, state);
issuer = value ? decoder.decode(value) : '';
} else if (fieldNumber === 4 && wireType === 0) {
const value = readProtoVarint(bytes, state);
algorithm = value == null ? null : googleMigrationAlgorithm(value);
} else if (fieldNumber === 5 && wireType === 0) {
const value = readProtoVarint(bytes, state);
digits = googleMigrationDigits(value ?? 0);
} else if (fieldNumber === 6 && wireType === 0) {
otpType = readProtoVarint(bytes, state) ?? 0;
} else if (!skipProtoField(bytes, state, wireType)) {
return null;
}
}
if (!secretBytes?.length || !algorithm || otpType === 1) return null;
return {
secret: bytesToBase32(secretBytes),
name,
issuer,
algorithm,
digits,
period: DEFAULT_TOTP_CONFIG.period,
};
}
function parseGoogleAuthenticatorMigration(raw: string): GoogleAuthenticatorMigrationTotp[] {
let data = '';
try {
data = new URL(raw).searchParams.get('data') || '';
} catch {
data = readOtpAuthParam(raw, 'data');
}
const bytes = base64ToBytesLoose(data);
if (!bytes.length) return [];
const state = { offset: 0 };
const out: GoogleAuthenticatorMigrationTotp[] = [];
while (state.offset < bytes.length) {
const key = readProtoVarint(bytes, state);
if (key == null) return [];
const fieldNumber = Math.floor(key / 8);
const wireType = key % 8;
if (fieldNumber === 1 && wireType === 2) {
const parameterBytes = readProtoBytes(bytes, state);
const parameter = parameterBytes ? parseGoogleMigrationOtpParameter(parameterBytes) : null;
if (parameter) out.push(parameter);
} else if (!skipProtoField(bytes, state, wireType)) {
return [];
}
}
return out;
}
function buildOtpAuthUri(account: GoogleAuthenticatorMigrationTotp): string {
const issuer = account.issuer.trim();
const name = account.name.trim();
const label = issuer && name && !name.toLowerCase().startsWith(`${issuer.toLowerCase()}:`)
? `${issuer}:${name}`
: name || issuer || 'TOTP';
const params = new URLSearchParams({
secret: account.secret,
algorithm: account.algorithm.replace('-', ''),
digits: String(account.digits),
period: String(account.period),
});
if (issuer) params.set('issuer', issuer);
return `otpauth://totp/${encodeURIComponent(label)}?${params.toString()}`;
}
export function normalizeTotpInput(raw: string): string {
const s = raw.trim();
if (!s) return '';
if (/^otpauth-migration:\/\//i.test(s)) {
const accounts = parseGoogleAuthenticatorMigration(s);
return accounts.length === 1 ? buildOtpAuthUri(accounts[0]) : '';
}
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s) && !/^otpauth:\/\//i.test(s) && !/^steam:\/\//i.test(s)) {
return '';
}
return s;
}
function parseTotpConfig(raw: string): TotpConfig {
const s = normalizeTotpInput(raw);
if (!s) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
if (/^steam:\/\//i.test(s)) {
return {
@@ -295,31 +492,20 @@ function parseTotpConfig(raw: string): TotpConfig {
if (/^otpauth:\/\//i.test(s)) {
try {
const u = new URL(s);
const otpType = u.hostname.toLowerCase();
if (otpType !== 'totp') {
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
}
const label = decodeURIComponent((u.pathname || '').replace(/^\/+/, '')).toLowerCase();
const issuer = (u.searchParams.get('issuer') || '').trim().toLowerCase();
const algorithm = (u.searchParams.get('algorithm') || '').trim().toLowerCase();
const steam = issuer === 'steam' || label.startsWith('steam:') || algorithm === 'steam';
return {
secret: normalizeTotpSecret(u.searchParams.get('secret') || ''),
steam,
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(u.searchParams.get('algorithm')),
digits: steam ? 5 : parseTotpPositiveInt(u.searchParams.get('digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
period: parseTotpPositiveInt(u.searchParams.get('period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
steam: false,
algorithm: parseTotpHashAlgorithm(u.searchParams.get('algorithm')),
digits: parseTotpDigits(u.searchParams.get('digits')),
period: parseTotpPeriod(u.searchParams.get('period')),
};
} catch {
const issuer = readOtpAuthParam(s, 'issuer').trim().toLowerCase();
const algorithm = readOtpAuthParam(s, 'algorithm').trim().toLowerCase();
const steam = issuer === 'steam' || algorithm === 'steam';
return {
secret: normalizeTotpSecret(readOtpAuthParam(s, 'secret')),
steam,
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(algorithm),
digits: steam ? 5 : parseTotpPositiveInt(readOtpAuthParam(s, 'digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
period: parseTotpPositiveInt(readOtpAuthParam(s, 'period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
steam: false,
algorithm: parseTotpHashAlgorithm(readOtpAuthParam(s, 'algorithm')),
digits: parseTotpDigits(readOtpAuthParam(s, 'digits')),
period: parseTotpPeriod(readOtpAuthParam(s, 'period')),
};
}
}
@@ -349,7 +535,13 @@ function base32ToBytes(input: string): Uint8Array {
return new Uint8Array(out);
}
export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now()): Promise<{ code: string; remain: number } | null> {
export interface TotpCodeResult {
code: string;
remain: number;
period: number;
}
export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now()): Promise<TotpCodeResult | null> {
const { secret, steam, algorithm, digits, period } = parseTotpConfig(rawSecret);
if (!secret) return null;
const keyBytes = base32ToBytes(secret);
@@ -378,5 +570,5 @@ export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now())
value = Math.floor(value / chars.length);
}
}
return { code, remain };
return { code, remain, period };
}
+1
View File
@@ -20,6 +20,7 @@ export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
return {
defaultKdfIterations: 600000,
registrationInviteRequired: true,
websiteIconsEnabled: true,
jwtWarning: null,
session: null,
phase: 'login',
+399 -1
View File
@@ -9,6 +9,7 @@ import type {
import type {
AdminInvite,
AdminUser,
AuditLogEntry,
AuthorizedDevice,
Cipher,
Folder,
@@ -383,6 +384,143 @@ export const DEMO_CIPHERS: Cipher[] = [
decFingerprint: 'SHA256:demoNodeWardenFingerprint',
},
},
// --- Duplicate detection demo pairs (exact, login-site, login-credentials, password) ---
{
id: 'cipher-dup-exact-a',
type: 1,
folderId: 'folder-work',
favorite: false,
name: 'Internal VPN',
decName: 'Internal VPN',
creationDate: '2026-04-10T08:00:00.000Z',
revisionDate: '2026-04-28T10:00:00.000Z',
login: {
username: 'vpn-user',
password: 'vpn-secret-2026', // gitguardian:ignore
decUsername: 'vpn-user',
decPassword: 'vpn-secret-2026', // gitguardian:ignore
uris: [{ uri: 'https://vpn.internal.example.com', decUri: 'https://vpn.internal.example.com', match: null }],
},
},
{
id: 'cipher-dup-exact-b',
type: 1,
folderId: 'folder-work',
favorite: false,
name: 'Internal VPN',
decName: 'Internal VPN',
creationDate: '2026-03-15T08:00:00.000Z',
revisionDate: '2026-04-30T10:00:00.000Z',
login: {
username: 'vpn-user',
password: 'vpn-secret-2026', // gitguardian:ignore
decUsername: 'vpn-user',
decPassword: 'vpn-secret-2026', // gitguardian:ignore
uris: [{ uri: 'https://vpn.internal.example.com', decUri: 'https://vpn.internal.example.com', match: null }],
},
},
{
id: 'cipher-dup-site-a',
type: 1,
folderId: 'folder-devops',
favorite: false,
name: 'AWS Console',
decName: 'AWS Console',
creationDate: '2026-03-01T08:00:00.000Z',
revisionDate: '2026-04-25T09:00:00.000Z',
login: {
username: 'aws-admin',
password: 'aws-secure-password', // gitguardian:ignore
decUsername: 'aws-admin',
decPassword: 'aws-secure-password', // gitguardian:ignore
uris: [{ uri: 'https://console.aws.amazon.com', decUri: 'https://console.aws.amazon.com', match: null }],
},
},
{
id: 'cipher-dup-site-b',
type: 1,
folderId: 'folder-devops',
favorite: false,
name: 'Amazon Web Services',
decName: 'Amazon Web Services',
creationDate: '2026-02-20T08:00:00.000Z',
revisionDate: '2026-04-20T09:00:00.000Z',
login: {
username: 'aws-admin',
password: 'aws-secure-password', // gitguardian:ignore
decUsername: 'aws-admin',
decPassword: 'aws-secure-password', // gitguardian:ignore
uris: [{ uri: 'https://console.aws.amazon.com', decUri: 'https://console.aws.amazon.com', match: null }],
},
},
{
id: 'cipher-dup-cred-a',
type: 1,
folderId: 'folder-personal',
favorite: false,
name: 'Personal Blog',
decName: 'Personal Blog',
creationDate: '2026-01-10T08:00:00.000Z',
revisionDate: '2026-04-15T10:00:00.000Z',
login: {
username: 'my-account@example.com',
password: 'shared-credential', // gitguardian:ignore
decUsername: 'my-account@example.com',
decPassword: 'shared-credential', // gitguardian:ignore
uris: [{ uri: 'https://blog.example.com', decUri: 'https://blog.example.com', match: null }],
},
},
{
id: 'cipher-dup-cred-b',
type: 1,
folderId: 'folder-personal',
favorite: false,
name: 'Forum Account',
decName: 'Forum Account',
creationDate: '2026-01-15T08:00:00.000Z',
revisionDate: '2026-04-18T10:00:00.000Z',
login: {
username: 'my-account@example.com',
password: 'shared-credential', // gitguardian:ignore
decUsername: 'my-account@example.com',
decPassword: 'shared-credential', // gitguardian:ignore
uris: [{ uri: 'https://forum.example.com', decUri: 'https://forum.example.com', match: null }],
},
},
{
id: 'cipher-dup-pw-a',
type: 1,
folderId: 'folder-personal',
favorite: false,
name: 'Old Forum',
decName: 'Old Forum',
creationDate: '2025-06-01T08:00:00.000Z',
revisionDate: '2026-03-01T10:00:00.000Z',
login: {
username: 'legacy-user',
password: 'reused-password-2020', // gitguardian:ignore
decUsername: 'legacy-user',
decPassword: 'reused-password-2020', // gitguardian:ignore
uris: [{ uri: 'https://old-forum.example.com', decUri: 'https://old-forum.example.com', match: null }],
},
},
{
id: 'cipher-dup-pw-b',
type: 1,
folderId: 'folder-personal',
favorite: false,
name: 'Legacy CMS',
decName: 'Legacy CMS',
creationDate: '2025-05-10T08:00:00.000Z',
revisionDate: '2026-02-15T10:00:00.000Z',
login: {
username: 'cms-admin',
password: 'reused-password-2020', // gitguardian:ignore
decUsername: 'cms-admin',
decPassword: 'reused-password-2020', // gitguardian:ignore
uris: [{ uri: 'https://cms.example.com', decUri: 'https://cms.example.com', match: null }],
},
},
{
id: 'cipher-archived',
type: 1,
@@ -575,6 +713,233 @@ export const DEMO_BACKUP_SETTINGS: AdminBackupSettings = {
],
};
export const DEMO_AUDIT_LOGS: AuditLogEntry[] = [
{
id: 'demo-log-auth-login',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'auth.login.success',
category: 'auth',
level: 'info',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ ip: '203.0.113.42', device: 'Chrome 125 on Windows', location: 'San Francisco, US' }),
createdAt: '2026-07-08T14:32:10.000Z',
object: 'auditLog',
},
{
id: 'demo-log-auth-failed',
actorUserId: null,
actorEmail: 'unknown@example.com',
action: 'auth.login.failed',
category: 'auth',
level: 'warn',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ ip: '198.51.100.7', reason: 'invalid_password', attemptCount: 3 }),
createdAt: '2026-07-08T13:15:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-auth-2fa',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'auth.totp.enabled',
category: 'auth',
level: 'security',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ ip: '203.0.113.42', trigger: 'user_initiated' }),
createdAt: '2026-07-07T09:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-auth-refresh-failed',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'auth.refresh.failed.token_expired',
category: 'auth',
level: 'error',
targetType: null,
targetId: 'demo-device-browser',
targetUserEmail: null,
metadata: JSON.stringify({ ip: '203.0.113.42', device: 'Chrome 125 on Windows' }),
createdAt: '2026-07-06T18:45:30.000Z',
object: 'auditLog',
},
{
id: 'demo-log-security-password',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'user.password.changed',
category: 'security',
level: 'security',
targetType: 'user',
targetId: DEMO_USER_ID,
targetUserEmail: DEMO_PROFILE.email,
metadata: JSON.stringify({ ip: '203.0.113.42', trigger: 'user_initiated' }),
createdAt: '2026-07-05T10:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-security-user-banned',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.user.banned',
category: 'security',
level: 'security',
targetType: 'user',
targetId: 'demo-user-003',
targetUserEmail: 'suspended@example.com',
metadata: JSON.stringify({ ip: '203.0.113.42', reason: 'violation_of_tos' }),
createdAt: '2026-07-04T16:20:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-security-user-register',
actorUserId: null,
actorEmail: 'newuser@example.com',
action: 'user.register.completed',
category: 'security',
level: 'info',
targetType: 'user',
targetId: 'demo-user-004',
targetUserEmail: 'newuser@example.com',
metadata: JSON.stringify({ ip: '192.0.2.55', invite: 'DEMO-INVITE-2026' }),
createdAt: '2026-07-03T08:30:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-device-trusted',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'device.trusted.added',
category: 'device',
level: 'info',
targetType: 'device',
targetId: 'demo-device-mobile',
targetUserEmail: null,
metadata: JSON.stringify({ deviceName: 'iPhone', os: 'iOS 18', ip: '203.0.113.42' }),
createdAt: '2026-07-02T12:15:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-device-removed',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'device.removed',
category: 'device',
level: 'warn',
targetType: 'device',
targetId: 'demo-device-old',
targetUserEmail: null,
metadata: JSON.stringify({ deviceName: 'Firefox on Linux', ip: '198.51.100.20', trigger: 'user_initiated' }),
createdAt: '2026-07-01T09:45:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-device-all-revoked',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'device.all_trust_revoked',
category: 'device',
level: 'security',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ ip: '203.0.113.42', trigger: 'password_change' }),
createdAt: '2026-07-01T09:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-data-backup',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.backup.run.completed',
category: 'data',
level: 'info',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ fileName: 'nodewarden_backup_20260701_030000.zip', size: '1.2 MB', destination: 'Demo WebDAV' }),
createdAt: '2026-07-01T03:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-data-restore',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.backup.restore.completed',
category: 'data',
level: 'warn',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ fileName: 'nodewarden_backup_20260628_030000.zip', checksum: 'verified' }),
createdAt: '2026-06-30T14:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-data-export',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.export.completed',
category: 'data',
level: 'info',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ format: 'encrypted_json', totalItems: 24 }),
createdAt: '2026-06-28T11:30:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-system-settings',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.settings.updated',
category: 'system',
level: 'info',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ changedKeys: ['signupsAllowed', 'kdfIterations'], ip: '203.0.113.42' }),
createdAt: '2026-06-25T08:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-system-invite',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.invite.created',
category: 'system',
level: 'info',
targetType: 'invite',
targetId: 'DEMO-INVITE-2026',
targetUserEmail: null,
metadata: JSON.stringify({ expiresIn: '168h', ip: '203.0.113.42' }),
createdAt: '2026-06-20T10:00:00.000Z',
object: 'auditLog',
},
{
id: 'demo-log-system-config',
actorUserId: DEMO_USER_ID,
actorEmail: DEMO_PROFILE.email,
action: 'admin.config.updated',
category: 'system',
level: 'warn',
targetType: null,
targetId: null,
targetUserEmail: null,
metadata: JSON.stringify({ changedKeys: ['smtp.host', 'smtp.port'], ip: '203.0.113.42' }),
createdAt: '2026-06-18T15:30:00.000Z',
object: 'auditLog',
},
];
function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
@@ -790,6 +1155,7 @@ export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
return {
defaultKdfIterations: 600000,
registrationInviteRequired: true,
websiteIconsEnabled: true,
jwtWarning: null,
session: null,
phase: 'login',
@@ -1074,6 +1440,38 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
onGetRecoveryCode: readonlyString,
onGetApiKey: readonlyString,
onRotateApiKey: readonlyString,
onListAccountPasskeys: async () => [],
onCreateAccountPasskey: async () => {
await readonly();
return null;
},
onEnableAccountPasskeyDirectUnlock: readonly,
onDeleteAccountPasskey: readonly,
onLoadAuditLogs: async (filters) => {
const limit = Number(filters.limit || 50) || 50;
const offset = Number(filters.offset || 0) || 0;
let filtered = DEMO_AUDIT_LOGS.filter((log) => {
if (filters.category && filters.category !== 'all' && log.category !== filters.category) return false;
if (filters.level && filters.level !== 'all' && log.level !== filters.level) return false;
if (filters.q) {
const q = filters.q.toLowerCase();
if (!log.action.toLowerCase().includes(q) && !(log.actorEmail || '').toLowerCase().includes(q)) return false;
}
if (filters.from && new Date(log.createdAt).getTime() < new Date(filters.from).getTime()) return false;
if (filters.to && new Date(log.createdAt).getTime() > new Date(filters.to).getTime()) return false;
return true;
});
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const total = filtered.length;
const sliced = filtered.slice(offset, offset + limit);
return {
logs: sliced,
total,
limit,
offset: offset + sliced.length,
hasMore: offset + sliced.length < total,
};
},
onLockTimeoutChange: readonlyVoid,
onSessionTimeoutActionChange: readonlyVoid,
onRefreshAuthorizedDevices: async () => {
@@ -1200,7 +1598,7 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
onDownloadRemoteBackup: async (_masterPassword: string, _destinationId: string, _path: string, _onProgress?: (percent: number | null) => void) => {
notify('success', t('txt_demo_download_prepared'));
},
onInspectRemoteBackup: async (_destinationId: string, path: string) => ({
onInspectRemoteBackup: async (_masterPassword: string, _destinationId: string, path: string) => ({
object: 'backup-remote-integrity',
destinationId: _destinationId,
path,
+5 -4
View File
@@ -10,10 +10,10 @@ export type Locale =
| 'zh-CN'
| 'zh-TW'
| 'ru'
| 'es';
| 'es'
| 'fi';
import enMessages from './i18n/locales/en';
const LOCALE_STORAGE_KEY = 'nodewarden.locale';
type MessageTable = Record<string, string>;
@@ -24,6 +24,7 @@ export const AVAILABLE_LOCALES: readonly { value: Locale; label: string }[] = [
{ value: 'zh-TW', label: '繁體中文' },
{ value: 'ru', label: 'Русский' },
{ value: 'es', label: 'Español' },
{ value: 'fi', label: 'Suomi' },
];
let locale: Locale = resolveInitialLocale();
@@ -49,6 +50,7 @@ function resolveInitialLocale(): Locale {
if (normalized.startsWith('zh')) return 'zh-CN';
if (normalized.startsWith('ru')) return 'ru';
if (normalized.startsWith('es')) return 'es';
if (normalized.startsWith('fi')) return 'fi';
}
}
return 'en';
@@ -60,6 +62,7 @@ const localeLoaders: Record<Locale, () => Promise<{ default: MessageTable }>> =
'zh-TW': () => import('./i18n/locales/zh-TW'),
ru: () => import('./i18n/locales/ru'),
es: () => import('./i18n/locales/es'),
fi: () => import('./i18n/locales/fi'),
};
function localeToHtmlLang(value: Locale): string {
@@ -74,7 +77,6 @@ function syncDocumentLanguage(): void {
async function loadLocaleMessages(next: Locale): Promise<MessageTable> {
const cached = loadedMessages.get(next);
if (cached) return cached;
const mod = await localeLoaders[next]();
loadedMessages.set(next, mod.default);
return mod.default;
@@ -223,7 +225,6 @@ export function translateServerError(message: string | null | undefined, fallbac
'masterPasswordHash is required': 'txt_server_error_master_password_hash_required',
'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required',
}[normalized];
return key ? t(key) : normalized;
}
+3
View File
@@ -1012,6 +1012,9 @@ const en: Record<string, string> = {
"txt_online": "Online",
"txt_offline": "Offline",
"txt_offline_vault_readonly": "Offline mode is read-only. Connect to NodeWarden before changing your vault.",
"txt_offline_mode_notice_title": "You are in offline mode. If this looks wrong, force refresh.",
"txt_offline_mode_notice_windows": "Windows:",
"txt_offline_mode_notice_macos": "macOS:",
"txt_submit": "Submit",
"txt_sync": "Sync",
"txt_sync_vault": "Sync Vault",
+3
View File
@@ -1012,6 +1012,9 @@ const es: Record<string, string> = {
"txt_online": "En línea",
"txt_offline": "Sin conexión",
"txt_offline_vault_readonly": "El modo sin conexión es de solo lectura. Conecta con NodeWarden antes de cambiar la bóveda.",
"txt_offline_mode_notice_title": "Estás en modo sin conexión. Si parece un error, fuerza la recarga.",
"txt_offline_mode_notice_windows": "Windows:",
"txt_offline_mode_notice_macos": "macOS:",
"txt_submit": "Enviar",
"txt_sync": "Sincronizar",
"txt_sync_vault": "Sincronizar bóveda",
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_offline": "Офлайн",
"txt_offline_vault_readonly": "Автономный режим доступен только для чтения. Подключитесь к NodeWarden, чтобы изменить хранилище.",
"txt_offline_mode_notice_title": "Вы в автономном режиме. Если это ошибка, выполните принудительное обновление.",
"txt_offline_mode_notice_windows": "Windows:",
"txt_offline_mode_notice_macos": "macOS:",
"txt_submit": "Отправить",
"txt_sync": "Синхронизировать",
"txt_sync_vault": "Синхронизировать хранилище",
+3
View File
@@ -1012,6 +1012,9 @@ const zhCN: Record<string, string> = {
"txt_online": "在线",
"txt_offline": "离线",
"txt_offline_vault_readonly": "当前为离线模式,只能查看密码库。连接到 NodeWarden 后才能修改。",
"txt_offline_mode_notice_title": "你正处于离线模式,若误判请强制刷新",
"txt_offline_mode_notice_windows": "Windows",
"txt_offline_mode_notice_macos": "macOS",
"txt_submit": "提交",
"txt_sync": "同步",
"txt_sync_vault": "同步",
+3
View File
@@ -1012,6 +1012,9 @@ const zhTW: Record<string, string> = {
"txt_online": "在線",
"txt_offline": "離線",
"txt_offline_vault_readonly": "目前為離線模式,只能查看密碼庫。連線到 NodeWarden 後才能修改。",
"txt_offline_mode_notice_title": "你正處於離線模式,若誤判請強制重新整理",
"txt_offline_mode_notice_windows": "Windows",
"txt_offline_mode_notice_macos": "macOS",
"txt_submit": "提交",
"txt_sync": "同步",
"txt_sync_vault": "同步",
+5
View File
@@ -280,6 +280,8 @@ export interface Send {
key?: string | null;
maxAccessCount?: number | null;
accessCount?: number;
password?: string | null;
authType?: number | null;
disabled?: boolean;
revisionDate?: string;
expirationDate?: string | null;
@@ -308,6 +310,7 @@ export interface SendDraft {
expirationDays: string;
maxAccessCount: string;
password: string;
hasPassword?: boolean;
disabled: boolean;
}
@@ -411,6 +414,8 @@ export interface WebBootstrapResponse {
jwtUnsafeReason?: 'missing' | 'too_short' | null;
jwtSecretMinLength?: number;
registrationInviteRequired?: boolean;
webAuthnAllowedOrigins?: string[];
websiteIconsEnabled?: boolean;
}
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%);
}
.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 {
@apply text-left;
}
+14
View File
@@ -427,6 +427,20 @@
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 {
@apply justify-end overflow-visible pb-0.5;
flex-wrap: unset;
+5
View File
@@ -1269,3 +1269,8 @@ select.input.duplicate-mode-toolbar-select {
@apply text-sm;
color: var(--danger);
}
.inline-icon {
display: inline;
vertical-align: middle;
}