73 Commits

Author SHA1 Message Date
shuaiplus a2654dcde3 feat: enhance import/export feature description for completeness and clarity 2026-03-04 23:52:56 +08:00
shuaiplus cb662b7d70 feat: update import/export feature descriptions for clarity and completeness 2026-03-04 23:49:37 +08:00
shuaiplus 1ac063909f feat: improve import/export feature descriptions for clarity and consistency 2026-03-04 23:17:58 +08:00
shuaiplus 35dc239c25 feat: enhance import/export page with new layout and features 2026-03-04 23:07:03 +08:00
shuaiplus c99a558b5e feat: add support for SSH key fingerprint normalization and compatibility 2026-03-04 22:45:30 +08:00
shuaiplus 819734ce5c feat: add export and import functionality for Bitwarden and NodeWarden formats
- Implemented export formats for Bitwarden (JSON, encrypted JSON, ZIP) and NodeWarden (JSON).
- Added support for attachments in ciphers and introduced new types for handling attachments.
- Enhanced import formats to include Bitwarden ZIP and NodeWarden JSON.
- Updated internationalization strings for attachment-related features.
- Improved UI styles for attachment management and import summary display.
2026-03-04 01:03:49 +08:00
shuaiplus 7b4733d4c4 feat: implement folder management features including create, update, and delete actions 2026-03-03 21:03:16 +08:00
shuaiplus af56236dba Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-03-03 20:30:28 +08:00
Zheng Li 3622c58680 fix: add build command to wrangler.toml for CI/CD compatibility 2026-03-03 20:30:06 +08:00
shuaiplus b5284e669a feat: add FIDO2 credentials support to CipherLogin and VaultDraft types
- Introduced CipherLoginPasskey interface to represent FIDO2 credentials with a creation date.
- Updated CipherLogin interface to include an optional fido2Credentials property.
- Modified VaultDraft interface to add loginFido2Credentials property for handling FIDO2 credentials.
2026-03-03 02:18:26 +08:00
shuaiplus 4da5525a1a fix: update 2FA support descriptions and improve error handling in TOTP actions 2026-03-02 22:36:10 +08:00
shuaiplus 16a7bcace9 fix: resolve merge conflict in twoFactorRequiredResponse function 2026-03-02 22:12:46 +08:00
shuaiplus f59e81de3a Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-03-02 22:08:53 +08:00
shuaiplus 227d43194d fix: update two-factor provider constants for backward compatibility 2026-03-02 22:07:04 +08:00
copilot-swe-agent[bot] 3341a9ef74 fix: return numeric provider IDs in TwoFactorProviders for Android client compatibility
Co-authored-by: shuaiplus <100134295+shuaiplus@users.noreply.github.com>
2026-03-02 13:57:37 +08:00
shuaiplus d0c97ee573 fix: correct typo in README.md 2026-03-02 00:41:10 +08:00
shuaiplus 5dab96f40e feat: add Import & Export page and update Help page with new navigation 2026-03-02 00:10:44 +08:00
shuaiplus dc12a73ab3 fix: update deploy script to use consistent build command 2026-03-02 00:10:44 +08:00
shuaiplus 9c9c76d82e chore: ensure newline at end of .gitignore file 2026-03-02 00:10:44 +08:00
shuaiplus a1d38b76c6 chore: remove accidental tmp submodules 2026-03-02 00:10:44 +08:00
shuaiplus 705a716a80 chore: remove accidental tmp submodules 2026-03-02 00:10:44 +08:00
shuaiplus 1a1b334f6c feat: add build script for consistent project building 2026-03-02 00:10:44 +08:00
shuaiplus 8d6835b665 feat: remove deprecated Bitwarden subprojects from the repository 2026-03-02 00:10:44 +08:00
shuaiplus 189a7b9285 feat: update routing regex patterns for improved API path matching 2026-03-02 00:10:44 +08:00
shuaiplus 23a45913e0 feat: update favicon and logo images for improved branding 2026-03-02 00:10:44 +08:00
shuaiplus ace9f4f5ac feat: enhance security headers and update content security policy in response and HTML files 2026-03-02 00:10:44 +08:00
shuaiplus c0683016c3 feat: enhance deployment process and update dependencies
- Updated the deployment script to build the web application before deploying.
- Upgraded Wrangler dependency from 4.61.1 to 4.69.0.

feat: add import item limit and request body size limit

- Introduced a new limit for the maximum total items allowed in a single import (5000).
- Set a hard body size limit for JSON API endpoints (25 MB).

feat: validate KDF parameters during registration and password change

- Added validation for KDF parameters to ensure compliance with Bitwarden's minimum requirements.
- Enhanced error handling for invalid KDF parameters during user registration and password change.

feat: clean up R2 files on user deletion

- Implemented cleanup of R2 files associated with user attachments and sends before deleting user metadata.

feat: verify folder ownership when creating or updating ciphers

- Added checks to ensure that users cannot reference folders owned by other users when creating or updating ciphers.

fix: handle corrupted cipher data gracefully

- Improved error handling when retrieving ciphers from the database to avoid crashes due to corrupted data.

feat: increment send access count atomically

- Added a method to atomically increment the access count for sends and return whether the update was successful.

fix: enforce request body size limits

- Implemented checks to reject oversized request bodies for non-file upload paths.

fix: update error handling for database initialization

- Enhanced error logging for database initialization failures while providing a generic message to clients.

feat: enhance security with Content Security Policy

- Added a Content Security Policy to the web application to improve security against XSS attacks.

fix: remove plaintext TOTP secret from localStorage

- Updated the TOTP enabling process to remove the plaintext secret from localStorage after it is stored on the server.

fix: ensure only PBKDF2 hash is sent for public send access

- Modified the public send access payload to ensure only the PBKDF2 hash is sent, never the plaintext password.
2026-03-02 00:10:44 +08:00
shuaiplus e9ace523e6 feat: enhance password security with server-side hashing and constant-time comparisons 2026-03-02 00:10:44 +08:00
shuaiplus 4390251c1e feat: unify API rate limiting and enhance request budgets 2026-03-02 00:10:44 +08:00
shuaiplus aef0c2f688 docs: update capability descriptions in README files for clarity 2026-03-02 00:10:44 +08:00
shuaiplus 594ca0c7ea feat: add TOTP recovery code field to users table 2026-03-02 00:10:44 +08:00
shuaiplus 26447cd9b4 docs: update README files for clarity on deployment steps and features 2026-03-02 00:10:44 +08:00
shuaiplus f5a2523f91 feat: add JWT secret safety checks and warning page for insecure configurations 2026-03-02 00:10:44 +08:00
shuaiplus bbf4094943 fix: remove unnecessary zoom property from html in styles.css 2026-03-02 00:10:44 +08:00
shuaiplus 9f14bca99a feat(i18n): add internationalization support with English and Chinese translations 2026-03-02 00:10:44 +08:00
shuaiplus 8641df3cff feat: add recovery code functionality and device management 2026-03-02 00:10:44 +08:00
shuaiplus 8852127743 feat: update README files to reflect full user management and support for text and file sends 2026-03-02 00:10:44 +08:00
shuaiplus 053ce887f9 fix: update README to clarify NodeWarden as a third-party Bitwarden server 2026-03-02 00:10:44 +08:00
shuaiplus 2fbe29a0d9 feat: add NodeWarden logo to README files for improved branding 2026-03-02 00:10:44 +08:00
shuaiplus 15b87025ad feat: enhance send functionality with improved key handling and decryption, update UI components for better user experience 2026-03-02 00:10:44 +08:00
shuaiplus 0e823e80a6 feat: enhance SendsPage with notes display and update VaultPage for improved filtering and history tracking 2026-03-02 00:10:44 +08:00
shuaiplus bb50617b16 feat: add PublicSendPage and SendsPage components for managing sends 2026-03-02 00:10:44 +08:00
shuaiplus be3b68956b feat: add favicon and logo assets, update App component to use logo 2026-03-02 00:10:44 +08:00
shuaiplus 0f132f4f43 feat: add SSH key utilities and improve field decryption 2026-03-02 00:10:44 +08:00
shuaiplus 32c695c81f feat: enhance VaultPage and App layout with new UI components and styles 2026-03-02 00:10:44 +08:00
shuaiplus 651eb69bd6 feat: enhance authentication and settings UI 2026-03-02 00:10:44 +08:00
shuaiplus 0cf8028087 feat: add cryptographic utilities and types for secure data handling 2026-03-02 00:10:44 +08:00
shuaiplus 3494471cad feat: add toast notifications and dialog components for improved user interaction 2026-03-02 00:10:44 +08:00
shuaiplus 59566f88e3 feat: implement vault locking mechanism with auto-lock settings and unlock functionality 2026-03-02 00:10:44 +08:00
shuaiplus 172f6626c0 feat: add QR code generation support and rate limiting for known device probes 2026-03-02 00:10:44 +08:00
shuaiplus 829008db7f Add vault-utils.js with utility functions for field type parsing, selection counting, cipher type mapping, URI handling, and extracting first cipher URI 2026-03-02 00:10:44 +08:00
shuaiplus 363aec1652 Add runtime configuration loader and styles for web application 2026-03-02 00:10:44 +08:00
shuaiplus b8c4bcef0c Enhance styles for app layout and components 2026-03-02 00:10:44 +08:00
shuaiplus d0c8516021 Add global styles for web client interface 2026-03-02 00:10:44 +08:00
shuaiplus 1f4933c5d5 Implement code changes to enhance functionality and improve performance 2026-03-02 00:10:44 +08:00
shuaiplus 4a37d742eb feat: 更新网页客户端样式和布局,提升用户体验 2026-03-02 00:10:44 +08:00
shuaiplus 6bbc7554c1 Refactor code structure for improved readability and maintainability 2026-03-02 00:10:44 +08:00
shuaiplus d80821edeb feat: enhance registration and password management UI with additional state handling 2026-03-02 00:10:44 +08:00
shuaiplus 6e95d7a235 feat: implement admin user management and invite system 2026-03-02 00:10:44 +08:00
shuaiplus f9b084d09d feat: remove setup disabling functionality and related UI elements 2026-02-25 01:30:08 +08:00
shuaiplus 4f82cf9d43 feat: add overlap grace period for refresh tokens to handle concurrent requests 2026-02-25 00:22:31 +08:00
shuaiplus bc0fd65b6b feat: add compatibility for custom fields handling in cipher creation and update 2026-02-25 00:10:11 +08:00
shuaiplus 08114762bc feat: add compatibility for fido2Credentials counter and implement no-op device token update handler 2026-02-23 23:29:00 +08:00
shuaiplus 1dfa96611a feat: add CLI deployment instructions 2026-02-23 20:01:55 +08:00
shuaiplus 36715645c6 feat: add compatibility mode for deleting ciphers to support Bitwarden clients 2026-02-23 19:35:06 +08:00
shuaiplus 3873d347aa enhance README with badges and project links 2026-02-23 16:56:00 +08:00
shuaiplus 874d31e86b fix: ensure attachment size is formatted as string for compatibility with Bitwarden clients 2026-02-23 14:07:11 +08:00
shuaiplus cd7b5a361c feat: add TOTP code generation and display functionality with UI enhancements 2026-02-21 15:13:21 +08:00
shuaiplus 9eddb91237 feat: enhance two-factor authentication handling and improve error responses 2026-02-21 14:13:22 +08:00
shuaiplus b2e8d3e00b feat: enhance registration page with TOTP support and UI improvements 2026-02-20 20:28:08 +08:00
shuaiplus a83e0d259e fix: increase max login attempts and improve two-factor token error response 2026-02-20 18:53:10 +08:00
shuaiplus b6f2882cdf chore: update version to 1.1.0 and improve two-factor provider validation 2026-02-20 18:39:18 +08:00
shuaiplus aaf5078c8a feat: add token revocation endpoint and enhance ciphers import request structure 2026-02-20 18:16:07 +08:00
64 changed files with 19662 additions and 1993 deletions
+3
View File
@@ -7,6 +7,7 @@ node_modules/
wrangler.my.toml wrangler.my.toml
RELEASE_NOTES.md RELEASE_NOTES.md
tests/selfcheck.ts tests/selfcheck.ts
problem.md
# Build output # Build output
dist/ dist/
@@ -36,3 +37,5 @@ npm-debug.log*
# Package lock (optional - remove if you want to commit it) # Package lock (optional - remove if you want to commit it)
# package-lock.json # package-lock.json
tmp/
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

+63 -22
View File
@@ -1,7 +1,21 @@
# NodeWarden <p align="center">
<img src="./NodeWarden.png" alt="NodeWarden Logo" />
</p>
<p align="center">
运行在 Cloudflare Workers 的 Bitwarden 第三方服务端,兼容官方客户端
</p>
[![Powered by Cloudflare](https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white)](https://workers.cloudflare.com/)
[![License: LGPL-3.0](https://img.shields.io/badge/License-LGPL--3.0-2ea44f)](./LICENSE)
[![Deploy to Cloudflare Workers](https://img.shields.io/badge/Deploy%20to-Cloudflare%20Workers-F38020?logo=cloudflare&logoColor=white)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/NodeWarden)
[![Latest Release](https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag)](https://github.com/shuaiplus/NodeWarden/releases/latest)
[![Sync Upstream](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg)](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
[更新日志](./RELEASE_NOTES.md) • [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) • [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest)
English[`README_EN.md`](./README_EN.md) English[`README_EN.md`](./README_EN.md)
运行在 **Cloudflare Workers** 上的 **Bitwarden 第三方服务端**
> **免责声明** > **免责声明**
> 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份您的密码库。 > 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份您的密码库。
@@ -12,18 +26,18 @@ English[`README_EN.md`](./README_EN.md)
| 能力项 | Bitwarden | NodeWarden | 说明 | | 能力项 | Bitwarden | NodeWarden | 说明 |
|---|---|---|---| |---|---|---|---|
| 单用户保管库(登录/笔记/卡片/身份) | ✅ | ✅ | 基于Cloudflare D1 | | Web Vault(登录/笔记/卡片/身份) | ✅ | ✅ | 网页端密码库管理页面 |
| 文件夹 / 收藏 | ✅ | ✅ | 常用管理能力可用 | | 文件夹 / 收藏 | ✅ | ✅ | 常用管理能力可用 |
| 全量同步 `/api/sync` | ✅ | ✅ | 已做兼容与性能优化 | | 全量同步 `/api/sync` | ✅ | ✅ | 已做兼容与性能优化 |
| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 | | 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 |
| 导入功能 | ✅ | ✅ | 覆盖常见导入路径 | | 导入导出功能 | ✅ | ✅ | 完整实现,含 Bitwarden 密码库+附件 ZIP 导入 |
| 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` | | 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` |
| passkey、TOTP | ❌ | ✅ |官方需要会员,我们的不需要 | | passkey、TOTP字段 | ❌ | ✅ |官方需要会员,我们的不需要 |
| 多用户 | ✅ | | NodeWarden 定位单用户 | | Send | ✅ | | 已支持文本 Send 与文件 Send |
| 多用户 | ✅ | ✅ | 完整的用户管理,邀请机制 |
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 | | 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 |
| 登录 2FATOTP/WebAuthn/Duo/Email | ✅ | ⚠️ 部分支持 | 仅支持 TOTP(通过 `TOTP_SECRET` | | 登录 2FATOTP/WebAuthn/Duo/Email | ✅ | ⚠️ 部分支持 | 仅支持用户级 TOTP |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 | | SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
| Send | ✅ | ❌ | 基本没人用 |
| 紧急访问 | ✅ | ❌ | 没必要实现 | | 紧急访问 | ✅ | ❌ | 没必要实现 |
| 管理后台 / 计费订阅 | ✅ | ❌ | 纯免费 | | 管理后台 / 计费订阅 | ✅ | ❌ | 纯免费 |
| 推送通知完整链路 | ✅ | ❌ | 没必要实现 | | 推送通知完整链路 | ✅ | ❌ | 没必要实现 |
@@ -33,8 +47,8 @@ English[`README_EN.md`](./README_EN.md)
- ✅ Windows 客户端(v2026.1.0 - ✅ Windows 客户端(v2026.1.0
- ✅ 手机 Appv2026.1.0 - ✅ 手机 Appv2026.1.0
- ✅ 浏览器扩展(v2026.1.0 - ✅ 浏览器扩展(v2026.1.0
- ✅ Linux 客户端(v2026.1.0
- ⬜ macOS 客户端(未测试) - ⬜ macOS 客户端(未测试)
- ⬜ Linux 客户端(未测试)
--- ---
# 快速开始 # 快速开始
@@ -43,9 +57,41 @@ English[`README_EN.md`](./README_EN.md)
**部署步骤:** **部署步骤:**
1. 先在右上角fork此项目(若后续不需要更新,可不fork) 1. 首先Fork本仓库,命名为**NodeWarden**
2. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden) 2. 点击下面的一键部署按钮,修改项目名称为**NodeWarden2**,修改**JWT_SECRET**成32为随机字符串
3. 打开部署后生成的链接,并根据网页提示完成后续操作。 3. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
4. 部署完成后,同一页面打开workers设置,将**Git存储库**断开连接
5. 同一位置,**Git存储库**链接至第一步Fork的仓库
**同步上游(更新):**
- 手动:Github打开你Fork的私人仓库,看到顶部同步提示时,点击 “Sync fork”。
- 自动:进入你的 Fork 仓库 → Actions,点击 “I understand my workflows, go ahead and enable them”,每天凌晨三点自动同步至上游
### CLI 部署
```powershell
# 先把仓库拉到本地
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
# 安装依赖
npm install
# Cloudflare CLI 登录
npx wrangler login
# 创建云资源(D1 + R2
npx wrangler d1 create nodewarden-db
npx wrangler r2 bucket create nodewarden-attachments
# 部署
npm run deploy
# 需更新时重新拉取仓库,重新部署即可,无需创建云资源
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
npm run deploy
```
--- ---
@@ -57,26 +103,21 @@ English[`README_EN.md`](./README_EN.md)
npm install npm install
npm run dev npm run dev
``` ```
## 可选:登录 TOTP2FA
- 在 Workers 的 Variables and Secrets 里新增 Secret`TOTP_SECRET`Base32)。
- 配置了 `TOTP_SECRET` 就启用登录 TOTP;删除该变量即关闭。
- 客户端流程:密码 -> TOTP 验证码。
- 支持“记住此设备”30 天。
--- ---
## 常见问题 ## 常见问题
**Q: 如何备份数据?** **Q: 如何备份数据?**
A: 在客户端中选择「导出密码库」,保存 JSON 文件。 A: 在客户端中选择「导出密码库」,保存 JSON 文件。
**Q: 导入导出支持哪些格式?**
A: 支持 Bitwarden `json/csv/密码库+附件 zip` 和 NodeWarden `密码库+附件 json`(均含加密模式),且导入下拉中看到的格式都可直接导入。
A: 另外支持直接导入 Bitwarden `密码库+附件 zip`,这条路径官方 Bitwarden Web 不支持。
**Q: 忘记主密码怎么办?** **Q: 忘记主密码怎么办?**
A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。 A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。
**Q: 可以多人使用吗?** **Q: 可以多人使用吗?**
A: 不建议。本项目为单用户设计,多人使用请选择 Vaultwarden A: 支持。第一个注册的用户自动成为管理员,管理员可在管理页面生成邀请码,其他用户凭邀请码注册
--- ---
+68 -29
View File
@@ -1,12 +1,24 @@
# NodeWarden <p align="center">
<img src="./NodeWarden.png" alt="NodeWarden Logo" />
</p>
<p align="center">
A third-party Bitwarden server running on Cloudflare Workers, fully compatible with official clients.
</p>
[![Powered by Cloudflare](https://img.shields.io/badge/Powered%20by-Cloudflare-F38020?logo=cloudflare&logoColor=white)](https://workers.cloudflare.com/)
[![License: LGPL-3.0](https://img.shields.io/badge/License-LGPL--3.0-2ea44f)](./LICENSE)
[![Deploy to Cloudflare Workers](https://img.shields.io/badge/Deploy%20to-Cloudflare%20Workers-F38020?logo=cloudflare&logoColor=white)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/NodeWarden)
[![Latest Release](https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag)](https://github.com/shuaiplus/NodeWarden/releases/latest)
[![Sync Upstream](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg)](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
[Release Notes](./RELEASE_NOTES.md) • [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) • [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest)
中文文档:[`README.md`](./README.md) 中文文档:[`README.md`](./README.md)
A **Bitwarden-compatible** server that runs on **Cloudflare Workers**. > **Disclaimer**
> This project is for learning and communication purposes only. We are not responsible for any data loss; regular vault backups are strongly recommended.
> Disclaimer > This project is not affiliated with Bitwarden. Please do not report issues to the official Bitwarden team.
> - This project is for learning and communication only.
> - We are not responsible for any data loss. Regular vault backups are strongly recommended.
> - This project is not affiliated with Bitwarden. Please do not report issues to the official Bitwarden team.
--- ---
@@ -14,30 +26,29 @@ A **Bitwarden-compatible** server that runs on **Cloudflare Workers**.
| Capability | Bitwarden | NodeWarden | Notes | | Capability | Bitwarden | NodeWarden | Notes |
|---|---|---|---| |---|---|---|---|
| Single-user vault (logins/notes/cards/identities) | ✅ | ✅ | Core vault model supported | | Web Vault (logins/notes/cards/identities) | ✅ | ✅ | Web-based vault management UI |
| Folders / Favorites | ✅ | ✅ | Common vault organization supported | | Folders / Favorites | ✅ | ✅ | Common vault organization supported |
| Full sync `/api/sync` | ✅ | ✅ | Compatibility-focused implementation | | Full sync `/api/sync` | ✅ | ✅ | Compatibility and performance optimized |
| Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 | | Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 |
| Import flow (common clients) | ✅ | ✅ | Common import paths covered | | mport / export | ✅ | ✅ | Fully implemented, including Bitwarden vault + attachments ZIP import. |
| Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` | | Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` |
| passkey、TOTP | ❌ | ✅ | Official service requires premium; NodeWarden does not | | passkey、TOTP fields | ❌ | ✅ | Official service requires premium; NodeWarden does not |
| Multi-user | ✅ | | NodeWarden is single-user by design | | Multi-user | ✅ | | Full user management with invitation mechanism |
| Send | ✅ | ✅ | Text Send and File Send are supported |
| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement | | Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement |
| Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ Partial | TOTP-only via `TOTP_SECRET` | | Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ Partial | User-level TOTP only |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement | | SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement |
| Send | ✅ | ❌ | Not necessary to implement |
| Emergency access | ✅ | ❌ | Not necessary to implement | | Emergency access | ✅ | ❌ | Not necessary to implement |
| Admin console / Billing & subscription | ✅ | ❌ | Free only | | Admin console / Billing & subscription | ✅ | ❌ | Free only |
| Full push notification pipeline | ✅ | ❌ | Not necessary to implement | | Full push notification pipeline | ✅ | ❌ | Not necessary to implement |
## Tested clients / platforms ## Tested clients / platforms
- ✅ Windows desktop client (v2026.1.0) - ✅ Windows desktop client (v2026.1.0)
-Android app (v2026.1.0) -Mobile app (v2026.1.0)
- ✅ Browser extension (v2026.1.0) - ✅ Browser extension (v2026.1.0)
- ✅ Linux desktop client (v2026.1.0)
- ⬜ macOS desktop client (not tested) - ⬜ macOS desktop client (not tested)
- ⬜ Linux desktop client (not tested)
--- ---
@@ -47,11 +58,43 @@ A **Bitwarden-compatible** server that runs on **Cloudflare Workers**.
**Deploy steps:** **Deploy steps:**
1. Fork this project (you don't need to fork it if you don't need to update it later). 1. Fork this repository and name it **NodeWarden**.
2. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden) 2. Click the deploy button below, rename the project to **NodeWarden2**, and set **JWT_SECRET** to a 32-character random string.
3. Open the generated service URL and follow the on-page instructions. 3. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
4. After deployment, open the Workers settings on the same page and disconnect the **Git repository**.
5. From the same location, reconnect the **Git repository** to the fork you created in step 1.
**Sync upstream (update):**
- Manual: Open your forked repository on GitHub and click **Sync fork** when the sync prompt appears at the top.
- Automatic: Go to your fork → Actions, click "I understand my workflows, go ahead and enable them". The repository will auto-sync with upstream every day at 3 AM.
### CLI deploy
```powershell
# Clone repository
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
# Install dependencies
npm install
# Cloudflare CLI login
npx wrangler login
# Create cloud resources (D1 + R2)
npx wrangler d1 create nodewarden-db
npx wrangler r2 bucket create nodewarden-attachments
# Deploy
npm run deploy
# To update later: re-clone and re-deploy — no need to recreate cloud resources
git clone https://github.com/shuaiplus/NodeWarden.git
cd NodeWarden
npm run deploy
```
---
## Local development ## Local development
This repo is a Cloudflare Workers TypeScript project (Wrangler). This repo is a Cloudflare Workers TypeScript project (Wrangler).
@@ -60,14 +103,6 @@ This repo is a Cloudflare Workers TypeScript project (Wrangler).
npm install npm install
npm run dev npm run dev
``` ```
## Optional Login TOTP (2FA)
- Add Workers Secret `TOTP_SECRET` (Base32) to enable login TOTP.
- Remove `TOTP_SECRET` to disable login TOTP.
- Client flow: password -> TOTP code.
- "Remember this device" is supported for 30 days.
--- ---
## FAQ ## FAQ
@@ -75,11 +110,15 @@ npm run dev
**Q: How do I back up my data?** **Q: How do I back up my data?**
A: Use **Export vault** in your client and save the JSON file. A: Use **Export vault** in your client and save the JSON file.
**Q: Which import/export formats are supported?**
A: NodeWarden supports Bitwarden `json/csv/vault + attachments zip` and NodeWarden `vault + attachments json` in both plain and encrypted modes, and every format visible in the import selector is directly importable.
A: It also supports direct import of Bitwarden `vault + attachments zip`, which is not directly supported by official Bitwarden Web import.
**Q: What if I forget the master password?** **Q: What if I forget the master password?**
A: It cant be recovered (end-to-end encryption). Keep it safe. A: It cant be recovered (end-to-end encryption). Keep it safe.
**Q: Can multiple people use it?** **Q: Can multiple people use it?**
A: Not recommended. This project is designed for single-user usage. For multi-user usage, choose Vaultwarden. A: Yes. The first registered user becomes the admin. The admin can generate invite codes from the admin panel, and other users register with those codes.
--- ---
+57
View File
@@ -22,6 +22,10 @@ CREATE TABLE IF NOT EXISTS users (
kdf_memory INTEGER, kdf_memory INTEGER,
kdf_parallelism INTEGER, kdf_parallelism INTEGER,
security_stamp TEXT NOT NULL, security_stamp TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active',
totp_secret TEXT,
totp_recovery_code TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
@@ -73,6 +77,32 @@ CREATE TABLE IF NOT EXISTS attachments (
); );
CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id); CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id);
CREATE TABLE IF NOT EXISTS sends (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
type INTEGER NOT NULL,
name TEXT NOT NULL,
notes TEXT,
data TEXT NOT NULL,
key TEXT NOT NULL,
password_hash TEXT,
password_salt TEXT,
password_iterations INTEGER,
auth_type INTEGER NOT NULL DEFAULT 2,
emails TEXT,
max_access_count INTEGER,
access_count INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0,
hide_email INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
expiration_date TEXT,
deletion_date TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date);
CREATE TABLE IF NOT EXISTS refresh_tokens ( CREATE TABLE IF NOT EXISTS refresh_tokens (
token TEXT PRIMARY KEY, token TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
@@ -81,6 +111,33 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
); );
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE TABLE IF NOT EXISTS invites (
code TEXT PRIMARY KEY,
created_by TEXT NOT NULL,
used_by TEXT,
expires_at TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at);
CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at);
CREATE TABLE IF NOT EXISTS audit_logs (
id TEXT PRIMARY KEY,
actor_user_id TEXT,
action TEXT NOT NULL,
target_type TEXT,
target_id TEXT,
metadata TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at);
CREATE TABLE IF NOT EXISTS devices ( CREATE TABLE IF NOT EXISTS devices (
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
device_identifier TEXT NOT NULL, device_identifier TEXT NOT NULL,
+1838 -158
View File
File diff suppressed because it is too large Load Diff
+15 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.0.0", "version": "1.1.0",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers", "description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus", "author": "shuaiplus",
"license": "LGPL-3.0", "license": "LGPL-3.0",
@@ -8,7 +8,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "wrangler dev -c wrangler.toml", "dev": "wrangler dev -c wrangler.toml",
"deploymy": "wrangler deploy -c wrangler.my.toml", "build": "vite build --config webapp/vite.config.ts",
"deploy": "wrangler deploy" "deploy": "wrangler deploy"
}, },
"keywords": [ "keywords": [
@@ -33,9 +33,21 @@
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20260131.0", "@cloudflare/workers-types": "^4.20260131.0",
"@preact/preset-vite": "^2.10.3",
"@types/node": "^25.2.3", "@types/node": "^25.2.3",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"wrangler": "^4.61.1" "vite": "^7.3.1",
"wrangler": "^4.69.0"
},
"dependencies": {
"@noble/hashes": "^2.0.1",
"@tanstack/react-query": "^5.90.21",
"@zip.js/zip.js": "^2.8.22",
"fflate": "^0.8.2",
"lucide-preact": "^0.575.0",
"preact": "^10.28.4",
"qrcode-generator": "^2.0.4",
"wouter": "^3.9.0"
} }
} }
+29 -13
View File
@@ -6,12 +6,18 @@
// Refresh token lifetime in milliseconds. // Refresh token lifetime in milliseconds.
// 刷新令牌有效期(毫秒)。 // 刷新令牌有效期(毫秒)。
refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000, refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000,
// Grace window for previous refresh token after rotation (ms).
// 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。
refreshTokenOverlapGraceMs: 60 * 1000,
// Refresh token random byte length. // Refresh token random byte length.
// 刷新令牌随机字节长度。 // 刷新令牌随机字节长度。
refreshTokenRandomBytes: 32, refreshTokenRandomBytes: 32,
// Attachment download token lifetime in seconds. // Attachment download token lifetime in seconds.
// 附件下载令牌有效期(秒)。 // 附件下载令牌有效期(秒)。
fileDownloadTokenTtlSeconds: 300, fileDownloadTokenTtlSeconds: 300,
// Send access token lifetime in seconds.
// Send 访问令牌有效期(秒)。
sendAccessTokenTtlSeconds: 300,
// Minimum required JWT secret length. // Minimum required JWT secret length.
// JWT 密钥最小长度要求。 // JWT 密钥最小长度要求。
jwtSecretMinLength: 32, jwtSecretMinLength: 32,
@@ -22,16 +28,16 @@
rateLimit: { rateLimit: {
// Max failed login attempts before temporary lock. // Max failed login attempts before temporary lock.
// 触发临时锁定前允许的最大登录失败次数。 // 触发临时锁定前允许的最大登录失败次数。
loginMaxAttempts: 5, loginMaxAttempts: 10,
// Login lock duration in minutes. // Login lock duration in minutes.
// 登录锁定时长(分钟)。 // 登录锁定时长(分钟)。
loginLockoutMinutes: 2, loginLockoutMinutes: 2,
// Write API request budget per minute. // Authenticated API request budget per user per minute (all reads & writes combined).
// 写操作 API 每分钟请求配额。 // 认证 API 每用户每分钟请求配额(读写合计)
apiWriteRequestsPerMinute: 120, apiRequestsPerMinute: 200,
// /api/sync read request budget per minute. // Public (unauthenticated) request budget per IP per minute.
// /api/sync 读请求每分钟配额。 // 公开(未认证)接口每 IP 每分钟请求配额。
syncReadRequestsPerMinute: 1000, publicRequestsPerMinute: 60,
// Fixed window size for API rate limiting in seconds. // Fixed window size for API rate limiting in seconds.
// API 限流固定窗口大小(秒)。 // API 限流固定窗口大小(秒)。
apiWindowSeconds: 60, apiWindowSeconds: 60,
@@ -41,15 +47,9 @@
// Minimum interval between login-attempt cleanup runs. // Minimum interval between login-attempt cleanup runs.
// 登录尝试表清理的最小间隔。 // 登录尝试表清理的最小间隔。
loginIpCleanupIntervalMs: 10 * 60 * 1000, loginIpCleanupIntervalMs: 10 * 60 * 1000,
// Minimum interval between API-window cleanup runs.
// API 窗口计数清理的最小间隔。
apiWindowCleanupIntervalMs: 5 * 60 * 1000,
// Retention window for login IP records. // Retention window for login IP records.
// 登录 IP 记录保留时长。 // 登录 IP 记录保留时长。
loginIpRetentionMs: 30 * 24 * 60 * 60 * 1000, loginIpRetentionMs: 30 * 24 * 60 * 60 * 1000,
// Number of historical API windows to keep.
// 保留的历史 API 窗口数量。
apiWindowRetentionWindows: 120,
}, },
cleanup: { cleanup: {
// Minimum interval between refresh-token cleanup runs. // Minimum interval between refresh-token cleanup runs.
@@ -67,6 +67,14 @@
// 附件上传大小上限(字节)。 // 附件上传大小上限(字节)。
maxFileSizeBytes: 100 * 1024 * 1024, maxFileSizeBytes: 100 * 1024 * 1024,
}, },
send: {
// Max file size allowed for Send file uploads.
// Send 文件上传大小上限。
maxFileSizeBytes: 550_502_400,
// Max days allowed between now and deletion date.
// 允许的最远删除日期(距当前天数)。
maxDeletionDays: 31,
},
pagination: { pagination: {
// Default page size when client does not specify pageSize. // Default page size when client does not specify pageSize.
// 客户端未传 pageSize 时的默认分页大小。 // 客户端未传 pageSize 时的默认分页大小。
@@ -95,6 +103,14 @@
// Max IDs per SQL batch when moving ciphers in bulk. // Max IDs per SQL batch when moving ciphers in bulk.
// 批量移动密码项时每批 SQL 的最大 ID 数量。 // 批量移动密码项时每批 SQL 的最大 ID 数量。
bulkMoveChunkSize: 200, bulkMoveChunkSize: 200,
// Max total items (folders + ciphers) allowed in a single import.
// 单次导入允许的最大条目数(文件夹 + 密码项合计)。
importItemLimit: 5000,
},
request: {
// Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt.
// JSON 接口请求 body 大小上限(字节),文件上传接口除外。
maxBodyBytes: 25 * 1024 * 1024,
}, },
compatibility: { compatibility: {
// Single source of truth for /config.version and /api/version. // Single source of truth for /config.version and /api/version.
+421 -56
View File
@@ -1,10 +1,12 @@
import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types'; import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { isTotpEnabled } from '../utils/totp'; import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
function looksLikeEncString(value: string): boolean { function looksLikeEncString(value: string): boolean {
if (!value) return false; if (!value) return false;
@@ -16,6 +18,40 @@ function looksLikeEncString(value: string): boolean {
return parts.length >= 2; return parts.length >= 2;
} }
/**
* Validate KDF parameters according to Bitwarden minimum requirements.
* Returns an error message if invalid, or null if OK.
*/
function validateKdfParams(kdfType: number | undefined, kdfIterations: number | undefined, kdfMemory?: number | undefined, kdfParallelism?: number | undefined): string | null {
const type = kdfType ?? 0;
if (type === 0) {
// PBKDF2-SHA256: minimum 100 000 iterations
if (typeof kdfIterations === 'number' && kdfIterations < 100_000) {
return 'PBKDF2 iterations must be at least 100000';
}
} else if (type === 1) {
// Argon2id: iterations >= 2, memory >= 16 MiB, parallelism >= 1
if (typeof kdfIterations === 'number' && kdfIterations < 2) {
return 'Argon2id iterations must be at least 2';
}
if (typeof kdfMemory === 'number' && kdfMemory < 16) {
return 'Argon2id memory must be at least 16 MiB';
}
if (typeof kdfParallelism === 'number' && kdfParallelism < 1) {
return 'Argon2id parallelism must be at least 1';
}
}
return null;
}
function normalizeTotpSecret(input: string): string {
return input.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
}
function normalizeRecoveryCodeInput(input: string): string {
return String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
}
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null { function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing'; if (!secret) return 'missing';
@@ -24,11 +60,40 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' |
return null; return null;
} }
// POST /api/accounts/register (only used from setup page, not client) function toProfile(user: User, env: Env): ProfileResponse {
return {
id: user.id,
name: user.name,
email: user.email,
emailVerified: true,
premium: true,
premiumFromOrganization: false,
usesKeyConnector: false,
masterPasswordHint: null,
culture: 'en-US',
twoFactorEnabled: !!user.totpSecret,
key: user.key,
privateKey: user.privateKey,
accountKeys: null,
securityStamp: user.securityStamp || user.id,
organizations: [],
providers: [],
providerOrganizations: [],
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
role: user.role,
status: user.status,
object: 'profile',
};
}
// POST /api/accounts/register
// - First user becomes admin.
// - Any subsequent user must provide a valid inviteCode.
export async function handleRegister(request: Request, env: Env): Promise<Response> { export async function handleRegister(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
// Enforce safe JWT_SECRET before allowing first registration.
const unsafe = jwtSecretUnsafeReason(env); const unsafe = jwtSecretUnsafeReason(env);
if (unsafe) { if (unsafe) {
const message = unsafe === 'missing' const message = unsafe === 'missing'
@@ -43,12 +108,12 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
email?: string; email?: string;
name?: string; name?: string;
masterPasswordHash?: string; masterPasswordHash?: string;
masterPasswordHint?: string;
key?: string; key?: string;
kdf?: number; kdf?: number;
kdfIterations?: number; kdfIterations?: number;
kdfMemory?: number; kdfMemory?: number;
kdfParallelism?: number; kdfParallelism?: number;
inviteCode?: string;
keys?: { keys?: {
publicKey?: string; publicKey?: string;
encryptedPrivateKey?: string; encryptedPrivateKey?: string;
@@ -61,17 +126,20 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse('Invalid JSON', 400); return errorResponse('Invalid JSON', 400);
} }
const email = body.email?.toLowerCase(); const email = body.email?.toLowerCase().trim();
const name = body.name || email; const name = body.name?.trim() || email;
const masterPasswordHash = body.masterPasswordHash; const masterPasswordHash = body.masterPasswordHash;
const key = body.key; const key = body.key;
const privateKey = body.keys?.encryptedPrivateKey; const privateKey = body.keys?.encryptedPrivateKey;
const publicKey = body.keys?.publicKey; const publicKey = body.keys?.publicKey;
const inviteCode = (body.inviteCode || '').trim();
if (!email || !masterPasswordHash || !key) { if (!email || !masterPasswordHash || !key) {
return errorResponse('Email, masterPasswordHash, and key are required', 400); return errorResponse('Email, masterPasswordHash, and key are required', 400);
} }
if (!email.includes('@') || email.length < 3) {
return errorResponse('Invalid email address', 400);
}
if (!privateKey || !publicKey) { if (!privateKey || !publicKey) {
return errorResponse('Private key and public key are required', 400); return errorResponse('Private key and public key are required', 400);
} }
@@ -82,92 +150,128 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400); return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
} }
// Create user const kdfErr = validateKdfParams(body.kdf, body.kdfIterations, body.kdfMemory, body.kdfParallelism);
if (kdfErr) return errorResponse(kdfErr, 400);
const now = new Date().toISOString();
const auth = new AuthService(env);
const serverHash = await auth.hashPasswordServer(masterPasswordHash, email);
const user: User = { const user: User = {
id: generateUUID(), id: generateUUID(),
email: email, email,
name: name || email, name: name || email,
masterPasswordHash: masterPasswordHash, masterPasswordHash: serverHash,
key: key, key,
privateKey: privateKey, privateKey,
publicKey: publicKey, publicKey,
kdfType: body.kdf ?? 0, kdfType: body.kdf ?? 0,
kdfIterations: body.kdfIterations ?? LIMITS.auth.defaultKdfIterations, kdfIterations: body.kdfIterations ?? LIMITS.auth.defaultKdfIterations,
kdfMemory: body.kdfMemory, kdfMemory: body.kdfMemory,
kdfParallelism: body.kdfParallelism, kdfParallelism: body.kdfParallelism,
securityStamp: generateUUID(), securityStamp: generateUUID(),
createdAt: new Date().toISOString(), role: 'user',
updatedAt: new Date().toISOString(), status: 'active',
totpSecret: null,
totpRecoveryCode: null,
createdAt: now,
updatedAt: now,
}; };
const userCount = await storage.getUserCount();
if (userCount === 0) {
user.role = 'admin';
const created = await storage.createFirstUser(user); const created = await storage.createFirstUser(user);
if (!created) { if (!created) {
return errorResponse('Registration is closed', 403); return errorResponse('Registration is temporarily unavailable, retry once', 409);
}
await storage.setRegistered();
await storage.createAuditLog({
id: generateUUID(),
actorUserId: user.id,
action: 'user.register.first_admin',
targetType: 'user',
targetId: user.id,
metadata: JSON.stringify({ email: user.email }),
createdAt: now,
});
return jsonResponse({ success: true, role: user.role }, 200);
} }
await storage.setRegistered(); if (!inviteCode) {
return errorResponse('Invite code is required', 403);
}
return jsonResponse({ success: true }, 200); try {
await storage.createUser(user);
} catch (error) {
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
if (msg.includes('unique') || msg.includes('constraint')) {
return errorResponse('Email already registered', 409);
}
throw error;
}
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
if (!inviteMarked) {
await storage.deleteUserById(user.id);
return errorResponse('Invite code is invalid or expired', 403);
}
await storage.createAuditLog({
id: generateUUID(),
actorUserId: user.id,
action: 'user.register.invite',
targetType: 'user',
targetId: user.id,
metadata: JSON.stringify({ email: user.email, inviteCode }),
createdAt: now,
});
return jsonResponse({ success: true, role: user.role }, 200);
} }
// GET /api/accounts/profile // GET /api/accounts/profile
export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> { export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
if (!user) { return jsonResponse(toProfile(user, env));
return errorResponse('User not found', 404);
}
const profile: ProfileResponse = {
id: user.id,
name: user.name,
email: user.email,
emailVerified: true,
premium: true,
premiumFromOrganization: false,
usesKeyConnector: false,
masterPasswordHint: null,
culture: 'en-US',
twoFactorEnabled: isTotpEnabled(env.TOTP_SECRET),
key: user.key,
privateKey: user.privateKey,
accountKeys: null,
securityStamp: user.securityStamp || user.id,
organizations: [],
providers: [],
providerOrganizations: [],
forcePasswordReset: false,
avatarColor: null,
creationDate: user.createdAt,
object: 'profile',
};
return jsonResponse(profile);
} }
// PUT /api/accounts/profile // PUT /api/accounts/profile
export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise<Response> { export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
if (!user) { let body: { name?: string; email?: string };
return errorResponse('User not found', 404);
}
let body: { name?: string; masterPasswordHint?: string };
try { try {
body = await request.json(); body = await request.json();
} catch { } catch {
return errorResponse('Invalid JSON', 400); return errorResponse('Invalid JSON', 400);
} }
if (body.name) { if (typeof body.name === 'string') {
user.name = body.name; user.name = body.name.trim() || user.name;
}
if (typeof body.email === 'string') {
const normalized = body.email.trim().toLowerCase();
if (!normalized) return errorResponse('Email is required', 400);
user.email = normalized;
} }
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
try {
await storage.saveUser(user); await storage.saveUser(user);
} catch (error) {
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
if (msg.includes('unique') || msg.includes('constraint')) {
return errorResponse('Email already registered', 409);
}
throw error;
}
return handleGetProfile(request, env, userId); return handleGetProfile(request, env, userId);
} }
@@ -175,6 +279,7 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
// POST /api/accounts/keys // POST /api/accounts/keys
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> { export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) { if (!user) {
@@ -182,6 +287,7 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
} }
let body: { let body: {
masterPasswordHash?: string;
key?: string; key?: string;
encryptedPrivateKey?: string; encryptedPrivateKey?: string;
publicKey?: string; publicKey?: string;
@@ -193,6 +299,15 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
return errorResponse('Invalid JSON', 400); return errorResponse('Invalid JSON', 400);
} }
// Require password verification before allowing key replacement.
if (!body.masterPasswordHash) {
return errorResponse('masterPasswordHash is required', 400);
}
const passwordValid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email);
if (!passwordValid) {
return errorResponse('Invalid password', 400);
}
if (body.key) user.key = body.key; if (body.key) user.key = body.key;
if (body.encryptedPrivateKey) user.privateKey = body.encryptedPrivateKey; if (body.encryptedPrivateKey) user.privateKey = body.encryptedPrivateKey;
if (body.publicKey) user.publicKey = body.publicKey; if (body.publicKey) user.publicKey = body.publicKey;
@@ -209,8 +324,258 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
return handleGetProfile(request, env, userId); return handleGetProfile(request, env, userId);
} }
// POST/PUT /api/accounts/password
export async function handleChangePassword(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: {
masterPasswordHash?: string;
currentPasswordHash?: string;
newMasterPasswordHash?: string;
key?: string;
newKey?: string;
encryptedPrivateKey?: string;
newEncryptedPrivateKey?: string;
publicKey?: string;
newPublicKey?: string;
kdf?: number;
kdfIterations?: number;
kdfMemory?: number;
kdfParallelism?: number;
};
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const currentHash = body.currentPasswordHash || body.masterPasswordHash;
if (!currentHash) return errorResponse('Current password hash is required', 400);
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400);
if (!body.newMasterPasswordHash) {
return errorResponse('newMasterPasswordHash is required', 400);
}
const nextKey = body.newKey || body.key;
const nextPrivateKey = body.newEncryptedPrivateKey || body.encryptedPrivateKey;
const nextPublicKey = body.newPublicKey || body.publicKey;
if (nextKey && !looksLikeEncString(nextKey)) {
return errorResponse('new key is not a valid encrypted string', 400);
}
if (nextPrivateKey && !looksLikeEncString(nextPrivateKey)) {
return errorResponse('new encryptedPrivateKey is not a valid encrypted string', 400);
}
const kdfErr = validateKdfParams(body.kdf ?? user.kdfType, body.kdfIterations, body.kdfMemory, body.kdfParallelism);
if (kdfErr) return errorResponse(kdfErr, 400);
user.masterPasswordHash = await auth.hashPasswordServer(body.newMasterPasswordHash, user.email);
if (nextKey) user.key = nextKey;
if (nextPrivateKey) user.privateKey = nextPrivateKey;
if (nextPublicKey) user.publicKey = nextPublicKey;
if (typeof body.kdf === 'number') user.kdfType = body.kdf;
if (typeof body.kdfIterations === 'number') user.kdfIterations = body.kdfIterations;
if (typeof body.kdfMemory === 'number') user.kdfMemory = body.kdfMemory;
if (typeof body.kdfParallelism === 'number') user.kdfParallelism = body.kdfParallelism;
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
await storage.createAuditLog({
id: generateUUID(),
actorUserId: user.id,
action: 'user.password.change',
targetType: 'user',
targetId: user.id,
metadata: JSON.stringify({ email: user.email }),
createdAt: user.updatedAt,
});
return new Response(null, { status: 200 });
}
// GET /api/accounts/totp
export async function handleGetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
return jsonResponse({
enabled: !!user.totpSecret,
object: 'twoFactor',
});
}
// PUT /api/accounts/totp
// enable: { enabled: true, secret: "...", token: "123456" }
// disable: { enabled: false, masterPasswordHash: "..." }
export async function handleSetTotpStatus(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: { enabled?: boolean; secret?: string; token?: string; masterPasswordHash?: string };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
if (body.enabled === true) {
const normalizedSecret = normalizeTotpSecret(body.secret || '');
if (!isTotpEnabled(normalizedSecret)) {
return errorResponse('Invalid TOTP secret', 400);
}
if (!body.token) {
return errorResponse('TOTP token is required', 400);
}
const verified = await verifyTotpToken(normalizedSecret, body.token);
if (!verified) {
return errorResponse('Invalid TOTP token', 400);
}
user.totpSecret = normalizedSecret;
if (!user.totpRecoveryCode) {
user.totpRecoveryCode = createRecoveryCode();
}
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
return jsonResponse({ enabled: true, recoveryCode: user.totpRecoveryCode, object: 'twoFactor' });
}
if (body.enabled === false) {
if (!body.masterPasswordHash) {
return errorResponse('masterPasswordHash is required to disable TOTP', 400);
}
const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400);
user.totpSecret = null;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
return jsonResponse({ enabled: false, object: 'twoFactor' });
}
return errorResponse('enabled must be true or false', 400);
}
// POST /api/accounts/totp/recovery-code
export async function handleGetTotpRecoveryCode(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: Record<string, string | undefined>;
try {
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
body = Object.fromEntries(formData.entries()) as Record<string, string>;
} else {
body = await request.json();
}
} catch {
return errorResponse('Invalid JSON', 400);
}
const currentHash = String(body.masterPasswordHash || body.master_password_hash || body.password || '').trim();
if (!currentHash) return errorResponse('masterPasswordHash is required', 400);
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400);
if (!user.totpRecoveryCode) {
user.totpRecoveryCode = createRecoveryCode();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
}
return jsonResponse({
code: user.totpRecoveryCode,
object: 'twoFactorRecover',
});
}
// POST /identity/accounts/recover-2fa
// Disable TOTP by recovery code + password, then rotate recovery code.
export async function handleRecoverTwoFactor(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const rateLimit = new RateLimitService(env.DB);
let body: Record<string, string | undefined>;
try {
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
body = Object.fromEntries(formData.entries()) as Record<string, string>;
} else {
body = await request.json();
}
} catch {
return errorResponse('Invalid JSON', 400);
}
const email = String(body.email || body.username || '').trim().toLowerCase();
const masterPasswordHash = String(body.masterPasswordHash || body.password || '').trim();
const recoveryCode = normalizeRecoveryCodeInput(String(body.recoveryCode || body.twoFactorToken || body.recovery_code || ''));
const recoverLimitKey = `${getClientIdentifier(request)}:recover-2fa:${email || 'unknown'}`;
const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey);
if (!recoverAttemptCheck.allowed) {
return errorResponse(
`Too many failed recovery attempts. Try again in ${Math.ceil((recoverAttemptCheck.retryAfterSeconds || 60) / 60)} minutes.`,
429
);
}
if (!email || !masterPasswordHash || !recoveryCode) {
return errorResponse('Email, masterPasswordHash and recoveryCode are required', 400);
}
const user = await storage.getUser(email);
if (!user || user.status !== 'active') {
await rateLimit.recordFailedLogin(recoverLimitKey);
return errorResponse('Invalid credentials or recovery code', 400);
}
const validPassword = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
if (!validPassword) {
await rateLimit.recordFailedLogin(recoverLimitKey);
return errorResponse('Invalid credentials or recovery code', 400);
}
if (!recoveryCodeEquals(recoveryCode, user.totpRecoveryCode)) {
await rateLimit.recordFailedLogin(recoverLimitKey);
return errorResponse('Invalid credentials or recovery code', 400);
}
user.totpSecret = null;
user.totpRecoveryCode = createRecoveryCode();
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
await rateLimit.clearLoginAttempts(recoverLimitKey);
return jsonResponse({
success: true,
twoFactorEnabled: false,
newRecoveryCode: user.totpRecoveryCode,
object: 'twoFactorRecovery',
});
}
// GET /api/accounts/revision-date // GET /api/accounts/revision-date
export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise<Response> { export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const revisionDate = await storage.getRevisionDate(userId); const revisionDate = await storage.getRevisionDate(userId);
@@ -240,7 +605,7 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
return errorResponse('masterPasswordHash is required', 400); return errorResponse('masterPasswordHash is required', 400);
} }
const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash); const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email);
if (!valid) { if (!valid) {
return errorResponse('Invalid password', 400); return errorResponse('Invalid password', 400);
} }
+287
View File
@@ -0,0 +1,287 @@
import { Env, User, Invite } from '../types';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active';
}
function randomHex(bytes: number): string {
const data = crypto.getRandomValues(new Uint8Array(bytes));
return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join('');
}
function buildInviteLink(request: Request, code: string): string {
const url = new URL(request.url);
return `${url.origin}/?invite=${encodeURIComponent(code)}`;
}
async function writeAuditLog(
storage: StorageService,
actorUserId: string | null,
action: string,
targetType: string | null,
targetId: string | null,
metadata: Record<string, unknown> | null
): Promise<void> {
await storage.createAuditLog({
id: generateUUID(),
actorUserId,
action,
targetType,
targetId,
metadata: metadata ? JSON.stringify(metadata) : null,
createdAt: new Date().toISOString(),
});
}
function toInviteResponse(request: Request, invite: Invite): Record<string, unknown> {
return {
code: invite.code,
status: invite.status,
createdBy: invite.createdBy,
usedBy: invite.usedBy,
createdAt: invite.createdAt,
updatedAt: invite.updatedAt,
expiresAt: invite.expiresAt,
inviteLink: buildInviteLink(request, invite.code),
object: 'invite',
};
}
// GET /api/admin/users
export async function handleAdminListUsers(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
void request;
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
const users = await storage.getAllUsers();
return jsonResponse({
data: users.map(user => ({
id: user.id,
email: user.email,
name: user.name,
role: user.role,
status: user.status,
twoFactorEnabled: !!user.totpSecret,
creationDate: user.createdAt,
revisionDate: user.updatedAt,
object: 'user',
})),
object: 'list',
continuationToken: null,
});
}
// POST /api/admin/invites
export async function handleAdminCreateInvite(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
let body: { expiresInHours?: number } = {};
try {
body = await request.json();
} catch {
body = {};
}
const expiresInHours = Number.isFinite(body.expiresInHours)
? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours))))
: 24 * 7;
const now = new Date();
const expiresAt = new Date(now.getTime() + expiresInHours * 60 * 60 * 1000);
const invite: Invite = {
code: randomHex(20),
createdBy: actorUser.id,
usedBy: null,
expiresAt: expiresAt.toISOString(),
status: 'active',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
};
await storage.createInvite(invite);
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', invite.code, {
expiresInHours,
});
return jsonResponse(toInviteResponse(request, invite), 201);
}
// GET /api/admin/invites
export async function handleAdminListInvites(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
const url = new URL(request.url);
const includeInactive = url.searchParams.get('includeInactive') === 'true';
const invites = await storage.listInvites(includeInactive);
return jsonResponse({
data: invites.map(invite => toInviteResponse(request, invite)),
object: 'list',
continuationToken: null,
});
}
// DELETE /api/admin/invites/:code
export async function handleAdminRevokeInvite(
request: Request,
env: Env,
actorUser: User,
code: string
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
const revoked = await storage.revokeInvite(code);
if (!revoked) {
return errorResponse('Invite not found or already inactive', 404);
}
await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', code, null);
return new Response(null, { status: 204 });
}
// DELETE /api/admin/invites
export async function handleAdminDeleteAllInvites(
request: Request,
env: Env,
actorUser: User
): Promise<Response> {
void request;
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
const storage = new StorageService(env.DB);
const deleted = await storage.deleteAllInvites();
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, {
deleted,
});
return jsonResponse({ deleted }, 200);
}
// PUT /api/admin/users/:id/status
export async function handleAdminSetUserStatus(
request: Request,
env: Env,
actorUser: User,
targetUserId: string
): Promise<Response> {
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
let body: { status?: string };
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null;
if (!nextStatus) {
return errorResponse('status must be active or banned', 400);
}
if (targetUserId === actorUser.id && nextStatus !== 'active') {
return errorResponse('You cannot ban yourself', 400);
}
const storage = new StorageService(env.DB);
const target = await storage.getUserById(targetUserId);
if (!target) {
return errorResponse('User not found', 404);
}
target.status = nextStatus;
target.updatedAt = new Date().toISOString();
await storage.saveUser(target);
if (nextStatus === 'banned') {
await storage.deleteRefreshTokensByUserId(target.id);
}
await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, {
status: nextStatus,
});
return jsonResponse({
id: target.id,
email: target.email,
role: target.role,
status: target.status,
object: 'user',
});
}
// DELETE /api/admin/users/:id
export async function handleAdminDeleteUser(
request: Request,
env: Env,
actorUser: User,
targetUserId: string
): Promise<Response> {
void request;
if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
if (targetUserId === actorUser.id) {
return errorResponse('You cannot delete yourself', 400);
}
const storage = new StorageService(env.DB);
const target = await storage.getUserById(targetUserId);
if (!target) {
return errorResponse('User not found', 404);
}
// Clean up R2 files before DB cascade deletes the metadata rows.
// 1. Attachment files (keyed by cipherId/attachmentId)
const attachmentMap = await storage.getAttachmentsByUserId(target.id);
for (const [cipherId, attachments] of attachmentMap) {
for (const att of attachments) {
await env.ATTACHMENTS.delete(`${cipherId}/${att.id}`);
}
}
// 2. Send files (keyed by sends/sendId/fileId)
const sends = await storage.getAllSends(target.id);
for (const send of sends) {
if (send.type === 1) { // SendType.File
try {
const parsed = JSON.parse(send.data) as Record<string, unknown>;
const fileId = typeof parsed.id === 'string' ? parsed.id : null;
if (fileId) {
await env.ATTACHMENTS.delete(`sends/${send.id}/${fileId}`);
}
} catch { /* non-file send or bad data, skip */ }
}
}
await storage.deleteRefreshTokensByUserId(target.id);
await storage.deleteUserById(target.id);
await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, {
email: target.email,
});
return new Response(null, { status: 204 });
}
+1 -1
View File
@@ -198,7 +198,7 @@ export async function handleGetAttachment(
url: downloadUrl, url: downloadUrl,
fileName: attachment.fileName, fileName: attachment.fileName,
key: attachment.key, key: attachment.key,
size: Number(attachment.size) || 0, size: String(Number(attachment.size) || 0),
sizeName: attachment.sizeName, sizeName: attachment.sizeName,
}); });
} }
+130 -1
View File
@@ -5,13 +5,71 @@ import { generateUUID } from '../utils/uuid';
import { deleteAllAttachmentsForCipher } from './attachments'; import { deleteAllAttachmentsForCipher } from './attachments';
import { parsePagination, encodeContinuationToken } from '../utils/pagination'; import { parsePagination, encodeContinuationToken } from '../utils/pagination';
function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } {
if (!source || typeof source !== 'object') return { present: false, value: undefined };
for (const key of aliases) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
return { present: true, value: source[key] };
}
}
return { present: false, value: undefined };
}
// Android 2026.2.0 expects fido2Credentials[].counter to be a string.
export function normalizeCipherLoginForCompatibility(login: any): any {
if (!login || typeof login !== 'object') return login ?? null;
const fido2 = Array.isArray(login.fido2Credentials)
? login.fido2Credentials.map((cred: any) => {
if (!cred || typeof cred !== 'object') return cred;
const rawCounter = cred.counter;
const counter =
rawCounter === null || rawCounter === undefined
? '0'
: String(rawCounter);
return {
...cred,
counter,
};
})
: login.fido2Credentials;
return {
...login,
fido2Credentials: fido2,
};
}
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
// Keep legacy alias "fingerprint" in parallel for older web payloads.
export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
if (!sshKey || typeof sshKey !== 'object') return sshKey ?? null;
const candidate =
sshKey.keyFingerprint !== undefined && sshKey.keyFingerprint !== null
? sshKey.keyFingerprint
: sshKey.fingerprint;
const normalizedFingerprint =
candidate === undefined || candidate === null
? ''
: String(candidate);
return {
...sshKey,
keyFingerprint: normalizedFingerprint,
fingerprint: normalizedFingerprint,
};
}
// Format attachments for API response // Format attachments for API response
export function formatAttachments(attachments: Attachment[]): any[] | null { export function formatAttachments(attachments: Attachment[]): any[] | null {
if (attachments.length === 0) return null; if (attachments.length === 0) return null;
return attachments.map(a => ({ return attachments.map(a => ({
id: a.id, id: a.id,
fileName: a.fileName, fileName: a.fileName,
size: Number(a.size) || 0, // Android expects Int, not String // Bitwarden clients decode attachment size as string in cipher payloads.
size: String(Number(a.size) || 0),
sizeName: a.sizeName, sizeName: a.sizeName,
key: a.key, key: a.key,
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url! url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url!
@@ -26,6 +84,8 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse { export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse {
// Strip internal-only fields that must not appear in the API response // Strip internal-only fields that must not appear in the API response
const { userId, createdAt, updatedAt, deletedAt, ...passthrough } = cipher; const { userId, createdAt, updatedAt, deletedAt, ...passthrough } = cipher;
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null);
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
return { return {
// Pass through ALL stored cipher fields (known + unknown) // Pass through ALL stored cipher fields (known + unknown)
@@ -47,6 +107,8 @@ export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = [])
object: 'cipher', object: 'cipher',
collectionIds: [], collectionIds: [],
attachments: formatAttachments(attachments), attachments: formatAttachments(attachments),
login: normalizedLogin,
sshKey: normalizedSshKey,
encryptedFor: null, encryptedFor: null,
}; };
} }
@@ -106,6 +168,12 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
return jsonResponse(cipherToResponse(cipher, attachments)); return jsonResponse(cipherToResponse(cipher, attachments));
} }
async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> {
if (!folderId) return true;
const folder = await storage.getFolder(folderId);
return !!(folder && folder.userId === userId);
}
// POST /api/ciphers // POST /api/ciphers
export async function handleCreateCipher(request: Request, env: Env, userId: string): Promise<Response> { export async function handleCreateCipher(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
@@ -136,6 +204,16 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
updatedAt: now, updatedAt: now,
deletedAt: null, deletedAt: null,
}; };
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
// Prevent referencing a folder owned by another user.
if (cipher.folderId) {
const folderOk = await verifyFolderOwnership(storage, cipher.folderId, userId);
if (!folderOk) return errorResponse('Folder not found', 404);
}
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
await storage.updateRevisionDate(userId); await storage.updateRevisionDate(userId);
@@ -178,6 +256,25 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
deletedAt: existingCipher.deletedAt, deletedAt: existingCipher.deletedAt,
}; };
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
// Custom fields deletion compatibility:
// - Accept both camelCase "fields" and PascalCase "Fields".
// - For full update (PUT/POST on this endpoint), missing fields means cleared fields.
// This prevents stale custom fields from being resurrected by merge fallback.
const incomingFields = getAliasedProp(cipherData, ['fields', 'Fields']);
if (incomingFields.present) {
cipher.fields = incomingFields.value ?? null;
} else if (request.method === 'PUT' || request.method === 'POST') {
cipher.fields = null;
}
// Prevent referencing a folder owned by another user.
if (cipher.folderId) {
const folderOk = await verifyFolderOwnership(storage, cipher.folderId, userId);
if (!folderOk) return errorResponse('Folder not found', 404);
}
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
await storage.updateRevisionDate(userId); await storage.updateRevisionDate(userId);
@@ -203,6 +300,29 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
return jsonResponse(cipherToResponse(cipher)); return jsonResponse(cipherToResponse(cipher));
} }
// DELETE /api/ciphers/:id (compat mode)
// Bitwarden clients may call DELETE on a trashed item to purge it permanently.
// For compatibility:
// - If item is active -> soft delete.
// - If item is already soft-deleted -> hard delete.
export async function handleDeleteCipherCompat(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404);
}
if (cipher.deletedAt) {
await deleteAllAttachmentsForCipher(env, id);
await storage.deleteCipher(id, userId);
await storage.updateRevisionDate(userId);
return new Response(null, { status: 204 });
}
return handleDeleteCipher(request, env, userId, id);
}
// DELETE /api/ciphers/:id (permanent) // DELETE /api/ciphers/:id (permanent)
export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
@@ -255,6 +375,10 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
} }
if (body.folderId !== undefined) { if (body.folderId !== undefined) {
if (body.folderId) {
const folderOk = await verifyFolderOwnership(storage, body.folderId, userId);
if (!folderOk) return errorResponse('Folder not found', 404);
}
cipher.folderId = body.folderId; cipher.folderId = body.folderId;
} }
if (body.favorite !== undefined) { if (body.favorite !== undefined) {
@@ -283,6 +407,11 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId:
return errorResponse('ids array is required', 400); return errorResponse('ids array is required', 400);
} }
if (body.folderId) {
const folderOk = await verifyFolderOwnership(storage, body.folderId, userId);
if (!folderOk) return errorResponse('Folder not found', 404);
}
await storage.bulkMoveCiphers(body.ids, body.folderId || null, userId); await storage.bulkMoveCiphers(body.ids, body.folderId || null, userId);
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
+114 -1
View File
@@ -1,6 +1,6 @@
import { Env } from '../types'; import { Env } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { jsonResponse } from '../utils/response'; import { errorResponse, jsonResponse } from '../utils/response';
import { readKnownDeviceProbe } from '../utils/device'; import { readKnownDeviceProbe } from '../utils/device';
// GET /api/devices/knowndevice // GET /api/devices/knowndevice
@@ -40,3 +40,116 @@ export async function handleGetDevices(request: Request, env: Env, userId: strin
}); });
} }
// GET /api/devices/authorized
// Returns known devices together with active 2FA remember-token expiry.
export async function handleGetAuthorizedDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const [devices, trusted] = await Promise.all([
storage.getDevicesByUserId(userId),
storage.getTrustedDeviceTokenSummariesByUserId(userId),
]);
const trustedByIdentifier = new Map<string, { expiresAt: number; tokenCount: number }>();
for (const row of trusted) {
trustedByIdentifier.set(row.deviceIdentifier, { expiresAt: row.expiresAt, tokenCount: row.tokenCount });
}
const knownIdentifiers = new Set<string>();
const data = devices.map(device => {
knownIdentifiers.add(device.deviceIdentifier);
const trustedInfo = trustedByIdentifier.get(device.deviceIdentifier);
return {
id: device.deviceIdentifier,
name: device.name,
identifier: device.deviceIdentifier,
type: device.type,
creationDate: device.createdAt,
revisionDate: device.updatedAt,
trusted: !!trustedInfo,
trustedTokenCount: trustedInfo?.tokenCount || 0,
trustedUntil: trustedInfo?.expiresAt ? new Date(trustedInfo.expiresAt).toISOString() : null,
object: 'device',
};
});
for (const row of trusted) {
if (knownIdentifiers.has(row.deviceIdentifier)) continue;
data.push({
id: row.deviceIdentifier,
name: 'Unknown device',
identifier: row.deviceIdentifier,
type: 14,
creationDate: '',
revisionDate: '',
trusted: true,
trustedTokenCount: row.tokenCount,
trustedUntil: row.expiresAt ? new Date(row.expiresAt).toISOString() : null,
object: 'device',
});
}
return jsonResponse({
data,
object: 'list',
continuationToken: null,
});
}
// DELETE /api/devices/authorized
export async function handleRevokeAllTrustedDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const removed = await storage.deleteTrustedTwoFactorTokensByUserId(userId);
return jsonResponse({ success: true, removed });
}
// DELETE /api/devices/authorized/:deviceIdentifier
export async function handleRevokeTrustedDevice(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = String(deviceIdentifier || '').trim();
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const removed = await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
return jsonResponse({ success: true, removed });
}
// DELETE /api/devices/:deviceIdentifier
export async function handleDeleteDevice(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = String(deviceIdentifier || '').trim();
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized);
return jsonResponse({ success: deleted });
}
// PUT /api/devices/identifier/{deviceIdentifier}/token
// Bitwarden mobile reports push token updates to this endpoint.
// NodeWarden does not implement push notifications, so accept and no-op.
export async function handleUpdateDeviceToken(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
void env;
void userId;
void deviceIdentifier;
return new Response(null, { status: 200 });
}
+179 -36
View File
@@ -7,20 +7,44 @@ import { LIMITS } from '../config/limits';
import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
import { createRefreshToken } from '../utils/jwt'; import { createRefreshToken } from '../utils/jwt';
import { readAuthRequestDeviceInfo } from '../utils/device'; import { readAuthRequestDeviceInfo } from '../utils/device';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { issueSendAccessToken } from './sends';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
// Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code.
// Keep request parsing backward-compatible with historical provider values (8 / 100).
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY = 8;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
function resolveTotpSecret(userSecret: string | null): string | null {
if (userSecret && isTotpEnabled(userSecret)) {
return userSecret;
}
return null;
}
function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response {
const providers = includeRecoveryCode
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE]
: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
const providers2: Record<string, null> = {};
for (const provider of providers) providers2[provider] = null;
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
// Bitwarden clients rely on these fields to trigger the 2FA UI flow. // Bitwarden clients rely on these fields to trigger the 2FA UI flow.
return jsonResponse( return jsonResponse(
{ {
error: 'invalid_grant', error: 'invalid_grant',
error_description: message, error_description: message,
TwoFactorProviders: [0], TwoFactorProviders: providers,
TwoFactorProviders2: { TwoFactorProviders2: providers2,
'0': { // Required by current Android parser (nullable value is acceptable).
Priority: 1, SsoEmail2faSessionToken: null,
}, // Keep payload shape close to upstream implementations.
MasterPasswordPolicy: {
Object: 'masterPasswordPolicy',
}, },
ErrorModel: { ErrorModel: {
Message: message, Message: message,
@@ -47,6 +71,21 @@ async function recordFailedLoginAndBuildResponse(
return identityErrorResponse(message, 'invalid_grant', 400); return identityErrorResponse(message, 'invalid_grant', 400);
} }
async function recordFailedTwoFactorAndBuildResponse(
rateLimit: RateLimitService,
loginIdentifier: string
): Promise<Response> {
const failed = await rateLimit.recordFailedLogin(loginIdentifier);
if (failed.locked) {
return identityErrorResponse(
`Too many failed login attempts. Account locked for ${Math.ceil(failed.retryAfterSeconds! / 60)} minutes.`,
'TooManyRequests',
429
);
}
return identityErrorResponse('Two-step token is invalid. Try again.', 'invalid_grant', 400);
}
// POST /identity/connect/token // POST /identity/connect/token
export async function handleToken(request: Request, env: Env): Promise<Response> { export async function handleToken(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
@@ -67,6 +106,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
const grantType = body.grant_type; const grantType = body.grant_type;
const clientIdentifier = getClientIdentifier(request);
if (grantType === 'password') { if (grantType === 'password') {
// Login with password // Login with password
@@ -75,7 +115,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const twoFactorToken = body.twoFactorToken; const twoFactorToken = body.twoFactorToken;
const twoFactorProvider = body.twoFactorProvider; const twoFactorProvider = body.twoFactorProvider;
const twoFactorRemember = body.twoFactorRemember; const twoFactorRemember = body.twoFactorRemember;
const loginIdentifier = getClientIdentifier(request); const loginIdentifier = `${clientIdentifier}:${email}`;
const deviceInfo = readAuthRequestDeviceInfo(body, request); const deviceInfo = readAuthRequestDeviceInfo(body, request);
if (!email || !passwordHash) { if (!email || !passwordHash) {
@@ -98,8 +138,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
await rateLimit.recordFailedLogin(loginIdentifier); await rateLimit.recordFailedLogin(loginIdentifier);
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400); return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
} }
if (user.status !== 'active') {
await rateLimit.recordFailedLogin(loginIdentifier);
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
}
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash); const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
if (!valid) { if (!valid) {
return recordFailedLoginAndBuildResponse( return recordFailedLoginAndBuildResponse(
rateLimit, rateLimit,
@@ -108,45 +152,63 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
); );
} }
if (deviceInfo.deviceIdentifier) { // Optional 2FA: enabled only by per-user secret.
await storage.upsertDevice(user.id, deviceInfo.deviceIdentifier, deviceInfo.deviceName, deviceInfo.deviceType); let trustedTwoFactorTokenToReturn: string | undefined;
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
if (effectiveTotpSecret) {
const canUseRecoveryCode = !!user.totpRecoveryCode;
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
const hasProvider = normalizedTwoFactorProvider.length > 0;
const hasToken = normalizedTwoFactorToken.length > 0;
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
// respond with a 2FA challenge payload.
if (!hasProvider || !hasToken) {
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
} }
// Optional 2FA: enabled only when TOTP_SECRET is configured in Workers env.
let trustedTwoFactorTokenToReturn: string | undefined;
if (isTotpEnabled(env.TOTP_SECRET)) {
const rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
// Bitwarden may reuse twoFactorToken as a remembered-device token on subsequent logins.
let passedByRememberToken = false; let passedByRememberToken = false;
if (twoFactorToken && !/^\d{6}$/.test(twoFactorToken) && deviceInfo.deviceIdentifier) { if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_REMEMBER)) {
if (deviceInfo.deviceIdentifier) {
const trustedUserId = await storage.getTrustedTwoFactorDeviceTokenUserId( const trustedUserId = await storage.getTrustedTwoFactorDeviceTokenUserId(
twoFactorToken, normalizedTwoFactorToken,
deviceInfo.deviceIdentifier deviceInfo.deviceIdentifier
); );
passedByRememberToken = trustedUserId === user.id; passedByRememberToken = trustedUserId === user.id;
} }
if (!passedByRememberToken && !twoFactorToken) { // Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
return twoFactorRequiredResponse();
}
if (!passedByRememberToken) { if (!passedByRememberToken) {
const totpOk = await verifyTotpToken(env.TOTP_SECRET!, twoFactorToken); return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
}
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
if (!totpOk) { if (!totpOk) {
const failed = await rateLimit.recordFailedLogin(loginIdentifier); return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
if (failed.locked) {
return identityErrorResponse(
`Too many failed login attempts. Account locked for ${Math.ceil(failed.retryAfterSeconds! / 60)} minutes.`,
'TooManyRequests',
429
);
} }
return identityErrorResponse('Invalid two-factor token', 'invalid_grant', 400); } else if (
normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE ||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY) ||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST)
) {
if (!recoveryCodeEquals(normalizedTwoFactorToken, user.totpRecoveryCode)) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
} }
user.totpSecret = null;
user.totpRecoveryCode = createRecoveryCode();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
rememberRequested = false;
} else {
// Unsupported provider for this server profile behaves as an invalid 2FA attempt.
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
} }
if (rememberRequested && deviceInfo.deviceIdentifier) { // Upstream behavior: do not issue a new remember token when auth itself used remember provider.
if (rememberRequested && !passedByRememberToken && deviceInfo.deviceIdentifier) {
trustedTwoFactorTokenToReturn = createRefreshToken(); trustedTwoFactorTokenToReturn = createRefreshToken();
await storage.saveTrustedTwoFactorDeviceToken( await storage.saveTrustedTwoFactorDeviceToken(
trustedTwoFactorTokenToReturn, trustedTwoFactorTokenToReturn,
@@ -157,6 +219,11 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
} }
// Persist device only after successful password + (optional) 2FA verification.
if (deviceInfo.deviceIdentifier) {
await storage.upsertDevice(user.id, deviceInfo.deviceIdentifier, deviceInfo.deviceName, deviceInfo.deviceType);
}
// Successful login - clear failed attempts // Successful login - clear failed attempts
await rateLimit.clearLoginAttempts(loginIdentifier); await rateLimit.clearLoginAttempts(loginIdentifier);
@@ -199,6 +266,49 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return jsonResponse(response); return jsonResponse(response);
} else if (grantType === 'send_access') {
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
if (!sendAccessLimit.allowed) {
return identityErrorResponse(
`Rate limit exceeded. Try again in ${sendAccessLimit.retryAfterSeconds} seconds.`,
'TooManyRequests',
429
);
}
const sendId = String(body.send_id || body.sendId || '').trim();
if (!sendId) {
return jsonResponse(
{
error: 'invalid_request',
error_description: 'send_id is required',
send_access_error_type: 'invalid_send_id',
ErrorModel: {
Message: 'send_id is required',
Object: 'error',
},
},
400
);
}
const passwordHashB64 = String(
body.password_hash_b64 || body.passwordHashB64 || body.passwordHash || body.password_hash || ''
).trim() || null;
const password = String(body.password || '').trim() || null;
const result = await issueSendAccessToken(env, sendId, passwordHashB64, password);
if ('error' in result) {
return result.error;
}
return jsonResponse({
access_token: result.token,
expires_in: LIMITS.auth.sendAccessTokenTtlSeconds,
token_type: 'Bearer',
scope: 'api.send',
unofficialServer: true,
});
} else if (grantType === 'refresh_token') { } else if (grantType === 'refresh_token') {
// Refresh token // Refresh token
const refreshToken = body.refresh_token; const refreshToken = body.refresh_token;
@@ -211,8 +321,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Invalid refresh token', 'invalid_grant', 400); return identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
} }
// Revoke old refresh token (prevent reuse) // Keep a short overlap window for old refresh token to absorb
await storage.deleteRefreshToken(refreshToken); // concurrent refresh requests from multiple client contexts.
await storage.constrainRefreshTokenExpiry(
refreshToken,
Date.now() + LIMITS.auth.refreshTokenOverlapGraceMs
);
const { accessToken, user } = result; const { accessToken, user } = result;
const newRefreshToken = await auth.generateRefreshToken(user.id); const newRefreshToken = await auth.generateRefreshToken(user.id);
@@ -277,8 +391,10 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
// Return default KDF settings even if user doesn't exist (to prevent user enumeration) // Return default KDF settings even if user doesn't exist (to prevent user enumeration)
const kdfType = user?.kdfType ?? 0; const kdfType = user?.kdfType ?? 0;
const kdfIterations = user?.kdfIterations ?? LIMITS.auth.defaultKdfIterations; const kdfIterations = user?.kdfIterations ?? LIMITS.auth.defaultKdfIterations;
const kdfMemory = user?.kdfMemory; // Use ?? null so non-existent users return null (not undefined/omitted) for these fields,
const kdfParallelism = user?.kdfParallelism; // matching the response shape of real PBKDF2 users and reducing enumeration signal.
const kdfMemory = user?.kdfMemory ?? null;
const kdfParallelism = user?.kdfParallelism ?? null;
return jsonResponse({ return jsonResponse({
kdf: kdfType, kdf: kdfType,
@@ -287,3 +403,30 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
kdfParallelism: kdfParallelism, kdfParallelism: kdfParallelism,
}); });
} }
// POST /identity/connect/revocation
// Best-effort OAuth token revocation endpoint.
// RFC 7009 allows returning 200 even if token is unknown.
export async function handleRevocation(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
let body: Record<string, string>;
const contentType = request.headers.get('content-type') || '';
try {
if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
body = Object.fromEntries(formData.entries()) as Record<string, string>;
} else {
body = await request.json();
}
} catch {
return new Response(null, { status: 200 });
}
const token = String(body.token || '').trim();
if (token) {
await storage.deleteRefreshToken(token);
}
return new Response(null, { status: 200 });
}
+66 -39
View File
@@ -1,17 +1,21 @@
import { Env, Cipher, Folder, CipherType } from '../types'; import { Env, Cipher, Folder, CipherType } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { errorResponse } from '../utils/response'; import { errorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { normalizeCipherLoginForCompatibility, normalizeCipherSshKeyForCompatibility } from './ciphers';
// Bitwarden client import request format // Bitwarden client import request format
interface CiphersImportRequest { interface CiphersImportRequest {
ciphers: Array<{ ciphers: Array<{
id?: string | null;
type: number; type: number;
name: string; name?: string | null;
notes?: string | null; notes?: string | null;
favorite?: boolean; favorite?: boolean;
reprompt?: number; reprompt?: number;
sshKey?: any | null;
key?: string | null;
login?: { login?: {
uris?: Array<{ uri: string | null; match?: number | null }> | null; uris?: Array<{ uri: string | null; match?: number | null }> | null;
username?: string | null; username?: string | null;
@@ -62,6 +66,7 @@ interface CiphersImportRequest {
password: string; password: string;
lastUsedDate: string; lastUsedDate: string;
}> | null; }> | null;
[key: string]: any;
}>; }>;
folders: Array<{ folders: Array<{
name: string; name: string;
@@ -86,6 +91,8 @@ async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[
// POST /api/ciphers/import - Bitwarden client import endpoint // POST /api/ciphers/import - Bitwarden client import endpoint
export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise<Response> { export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const url = new URL(request.url);
const returnCipherMap = url.searchParams.get('returnCipherMap') === '1';
let importData: CiphersImportRequest; let importData: CiphersImportRequest;
try { try {
@@ -98,6 +105,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
const ciphers = importData.ciphers || []; const ciphers = importData.ciphers || [];
const folderRelationships = importData.folderRelationships || []; const folderRelationships = importData.folderRelationships || [];
if (folders.length + ciphers.length > LIMITS.performance.importItemLimit) {
return errorResponse(`Import exceeds maximum of ${LIMITS.performance.importItemLimit} items`, 400);
}
const now = new Date().toISOString(); const now = new Date().toISOString();
const batchChunkSize = LIMITS.performance.bulkMoveChunkSize; const batchChunkSize = LIMITS.performance.bulkMoveChunkSize;
@@ -143,9 +154,12 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
// Create ciphers // Create ciphers
const cipherRows: Cipher[] = []; const cipherRows: Cipher[] = [];
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
for (let i = 0; i < ciphers.length; i++) { for (let i = 0; i < ciphers.length; i++) {
const c = ciphers[i]; const c = ciphers[i];
const folderId = cipherFolderMap.get(i) || null; const folderId = cipherFolderMap.get(i) || null;
const sourceIdRaw = String(c?.id ?? '').trim();
const sourceId = sourceIdRaw || null;
const cipher: Cipher = { const cipher: Cipher = {
...c, ...c,
@@ -153,69 +167,75 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
userId: userId, userId: userId,
type: c.type as CipherType, type: c.type as CipherType,
folderId: folderId, folderId: folderId,
name: c.name || 'Untitled', name: c.name ?? 'Untitled',
notes: c.notes || null, notes: c.notes ?? null,
favorite: c.favorite || false, favorite: c.favorite ?? false,
login: c.login ? { login: c.login ? {
...c.login, ...c.login,
username: c.login.username || null, username: c.login.username ?? null,
password: c.login.password || null, password: c.login.password ?? null,
uris: c.login.uris?.map(u => ({ uris: c.login.uris?.map(u => ({
uri: u.uri || null, ...u,
uri: u.uri ?? null,
uriChecksum: null, uriChecksum: null,
match: u.match ?? null, match: u.match ?? null,
})) || null, })) || null,
totp: c.login.totp || null, totp: c.login.totp ?? null,
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null, autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
fido2Credentials: c.login.fido2Credentials ?? null, fido2Credentials: c.login.fido2Credentials ?? null,
uri: c.login.uri ?? null, uri: c.login.uri ?? null,
passwordRevisionDate: c.login.passwordRevisionDate ?? null, passwordRevisionDate: c.login.passwordRevisionDate ?? null,
} : null, } : null,
card: c.card ? { card: c.card ? {
cardholderName: c.card.cardholderName || null, ...c.card,
brand: c.card.brand || null, cardholderName: c.card.cardholderName ?? null,
number: c.card.number || null, brand: c.card.brand ?? null,
expMonth: c.card.expMonth || null, number: c.card.number ?? null,
expYear: c.card.expYear || null, expMonth: c.card.expMonth ?? null,
code: c.card.code || null, expYear: c.card.expYear ?? null,
code: c.card.code ?? null,
} : null, } : null,
identity: c.identity ? { identity: c.identity ? {
title: c.identity.title || null, ...c.identity,
firstName: c.identity.firstName || null, title: c.identity.title ?? null,
middleName: c.identity.middleName || null, firstName: c.identity.firstName ?? null,
lastName: c.identity.lastName || null, middleName: c.identity.middleName ?? null,
address1: c.identity.address1 || null, lastName: c.identity.lastName ?? null,
address2: c.identity.address2 || null, address1: c.identity.address1 ?? null,
address3: c.identity.address3 || null, address2: c.identity.address2 ?? null,
city: c.identity.city || null, address3: c.identity.address3 ?? null,
state: c.identity.state || null, city: c.identity.city ?? null,
postalCode: c.identity.postalCode || null, state: c.identity.state ?? null,
country: c.identity.country || null, postalCode: c.identity.postalCode ?? null,
company: c.identity.company || null, country: c.identity.country ?? null,
email: c.identity.email || null, company: c.identity.company ?? null,
phone: c.identity.phone || null, email: c.identity.email ?? null,
ssn: c.identity.ssn || null, phone: c.identity.phone ?? null,
username: c.identity.username || null, ssn: c.identity.ssn ?? null,
passportNumber: c.identity.passportNumber || null, username: c.identity.username ?? null,
licenseNumber: c.identity.licenseNumber || null, passportNumber: c.identity.passportNumber ?? null,
licenseNumber: c.identity.licenseNumber ?? null,
} : null, } : null,
secureNote: c.secureNote || null, secureNote: c.secureNote ?? null,
fields: c.fields?.map(f => ({ fields: c.fields?.map(f => ({
name: f.name || null, ...f,
value: f.value || null, name: f.name ?? null,
value: f.value ?? null,
type: f.type, type: f.type,
linkedId: f.linkedId ?? null, linkedId: f.linkedId ?? null,
})) || null, })) || null,
passwordHistory: c.passwordHistory || null, passwordHistory: c.passwordHistory ?? null,
reprompt: c.reprompt || 0, reprompt: c.reprompt ?? 0,
sshKey: (c as any).sshKey ?? null, sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
key: (c as any).key ?? null, key: (c as any).key ?? null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
deletedAt: null, deletedAt: null,
}; };
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
cipherRows.push(cipher); cipherRows.push(cipher);
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
} }
if (cipherRows.length > 0) { if (cipherRows.length > 0) {
@@ -250,5 +270,12 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
// Update revision date // Update revision date
await storage.updateRevisionDate(userId); await storage.updateRevisionDate(userId);
if (returnCipherMap) {
return jsonResponse({
object: 'import-result',
cipherMap: cipherMapRows,
});
}
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
} }
File diff suppressed because it is too large Load Diff
+5 -51
View File
@@ -1,57 +1,11 @@
import { Env, DEFAULT_DEV_SECRET } from '../types'; import { Env } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse, htmlResponse } from '../utils/response'; import { jsonResponse } from '../utils/response';
import { renderRegisterPageHTML } from '../setup/pageTemplate';
import { LIMITS } from '../config/limits';
type JwtSecretState = 'missing' | 'default' | 'too_short';
function getJwtSecretState(env: Env): JwtSecretState | null {
const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing';
// Block common "forgot to change" sample value (matches .dev.vars.example)
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null;
}
async function handleRegisterPage(request: Request, env: Env, jwtState: JwtSecretState | null): Promise<Response> {
const storage = new StorageService(env.DB);
const disabled = await storage.isSetupDisabled();
if (disabled) {
return new Response(null, { status: 404 });
}
return htmlResponse(renderRegisterPageHTML(jwtState));
}
// GET / - Setup page
export async function handleSetupPage(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const disabled = await storage.isSetupDisabled();
if (disabled) {
return new Response(null, { status: 404 });
}
// 引导页内会处理 JWT_SECRET 检测与分流(坏密钥停留在修复步骤)。
const jwtState = getJwtSecretState(env);
return handleRegisterPage(request, env, jwtState);
}
// GET /setup/status // GET /setup/status
export async function handleSetupStatus(request: Request, env: Env): Promise<Response> { export async function handleSetupStatus(request: Request, env: Env): Promise<Response> {
void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const registered = await storage.isRegistered(); const registered = (await storage.isRegistered()) || (await storage.getUserCount()) > 0;
const disabled = await storage.isSetupDisabled(); return jsonResponse({ registered });
return jsonResponse({ registered, disabled });
}
// POST /setup/disable
export async function handleDisableSetup(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const registered = await storage.isRegistered();
if (!registered) {
return errorResponse('Registration required', 403);
}
await storage.setSetupDisabled();
return jsonResponse({ success: true });
} }
+4 -3
View File
@@ -2,8 +2,8 @@ import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } fr
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { errorResponse } from '../utils/response'; import { errorResponse } from '../utils/response';
import { cipherToResponse } from './ciphers'; import { cipherToResponse } from './ciphers';
import { sendToResponse } from './sends';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { isTotpEnabled } from '../utils/totp';
interface SyncCacheEntry { interface SyncCacheEntry {
body: string; body: string;
@@ -61,6 +61,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
const ciphers = await storage.getAllCiphers(userId); const ciphers = await storage.getAllCiphers(userId);
const folders = await storage.getAllFolders(userId); const folders = await storage.getAllFolders(userId);
const sends = await storage.getAllSends(userId);
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId); const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
// Build profile response // Build profile response
@@ -74,7 +75,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
usesKeyConnector: false, usesKeyConnector: false,
masterPasswordHint: null, masterPasswordHint: null,
culture: 'en-US', culture: 'en-US',
twoFactorEnabled: isTotpEnabled(env.TOTP_SECRET), twoFactorEnabled: !!user.totpSecret,
key: user.key, key: user.key,
privateKey: user.privateKey, privateKey: user.privateKey,
accountKeys: null, accountKeys: null,
@@ -116,7 +117,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
object: 'domains', object: 'domains',
}, },
policies: [], policies: [],
sends: [], sends: sends.map(sendToResponse),
// PascalCase for desktop/browser clients // PascalCase for desktop/browser clients
UserDecryptionOptions: { UserDecryptionOptions: {
HasMasterPassword: true, HasMasterPassword: true,
+4 -21
View File
@@ -7,21 +7,6 @@ let dbInitialized = false;
let dbInitError: string | null = null; let dbInitError: string | null = null;
let dbInitPromise: Promise<void> | null = null; let dbInitPromise: Promise<void> | null = null;
function shouldSkipDatabaseInit(request: Request): boolean {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;
if (method === 'OPTIONS') return true;
if (method === 'GET' && (path === '/favicon.ico' || path === '/favicon.svg')) return true;
if (method === 'GET' && path === '/.well-known/appspecific/com.chrome.devtools.json') return true;
if (method === 'GET' && path.startsWith('/icons/')) return true;
if (path.startsWith('/notifications/')) return true;
if (method === 'GET' && (path === '/config' || path === '/api/config' || path === '/api/version')) return true;
return false;
}
async function ensureDatabaseInitialized(env: Env): Promise<void> { async function ensureDatabaseInitialized(env: Env): Promise<void> {
if (dbInitialized) return; if (dbInitialized) return;
@@ -47,17 +32,16 @@ async function ensureDatabaseInitialized(env: Env): Promise<void> {
export default { export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
void ctx; void ctx;
const requiresDatabase = !shouldSkipDatabaseInit(request);
if (requiresDatabase) {
await ensureDatabaseInitialized(env); await ensureDatabaseInitialized(env);
if (dbInitError) { if (dbInitError) {
// Log full error server-side, return generic message to client.
console.error('DB init error (not forwarded to client):', dbInitError);
const resp = jsonResponse( const resp = jsonResponse(
{ {
error: 'Database not initialized', error: 'Database not initialized',
error_description: dbInitError, error_description: 'Database initialization failed. Check server logs for details.',
ErrorModel: { ErrorModel: {
Message: dbInitError, Message: 'Service temporarily unavailable',
Object: 'error', Object: 'error',
}, },
}, },
@@ -65,7 +49,6 @@ export default {
); );
return applyCors(request, resp); return applyCors(request, resp);
} }
}
const resp = await handleRequest(request, env); const resp = await handleRequest(request, env);
return applyCors(request, resp); return applyCors(request, resp);
+269 -51
View File
@@ -1,14 +1,26 @@
import { Env, DEFAULT_DEV_SECRET } from './types'; import { Env, DEFAULT_DEV_SECRET } from './types';
import { AuthService } from './services/auth'; import { AuthService } from './services/auth';
import { StorageService } from './services/storage';
import { RateLimitService, getClientIdentifier } from './services/ratelimit'; import { RateLimitService, getClientIdentifier } from './services/ratelimit';
import { handleCors, errorResponse, jsonResponse } from './utils/response'; import { handleCors, errorResponse, jsonResponse } from './utils/response';
import { LIMITS } from './config/limits'; import { LIMITS } from './config/limits';
// Identity handlers // Identity handlers
import { handleToken, handlePrelogin } from './handlers/identity'; import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
// Account handlers // Account handlers
import { handleRegister, handleGetProfile, handleUpdateProfile, handleSetKeys, handleGetRevisionDate, handleVerifyPassword } from './handlers/accounts'; import {
handleRegister,
handleGetProfile,
handleSetKeys,
handleGetRevisionDate,
handleVerifyPassword,
handleChangePassword,
handleGetTotpStatus,
handleSetTotpStatus,
handleGetTotpRecoveryCode,
handleRecoverTwoFactor,
} from './handlers/accounts';
// Cipher handlers // Cipher handlers
import { import {
@@ -17,6 +29,7 @@ import {
handleCreateCipher, handleCreateCipher,
handleUpdateCipher, handleUpdateCipher,
handleDeleteCipher, handleDeleteCipher,
handleDeleteCipherCompat,
handlePermanentDeleteCipher, handlePermanentDeleteCipher,
handleRestoreCipher, handleRestoreCipher,
handlePartialUpdateCipher, handlePartialUpdateCipher,
@@ -32,12 +45,39 @@ import {
handleDeleteFolder handleDeleteFolder
} from './handlers/folders'; } from './handlers/folders';
// Send handlers
import {
handleGetSends,
handleGetSend,
handleCreateSend,
handleCreateFileSendV2,
handleGetSendFileUpload,
handleUploadSendFile,
handleUpdateSend,
handleDeleteSend,
handleRemoveSendPassword,
handleRemoveSendAuth,
handleAccessSend,
handleAccessSendFile,
handleAccessSendV2,
handleAccessSendFileV2,
handleDownloadSendFile,
} from './handlers/sends';
// Sync handler // Sync handler
import { handleSync } from './handlers/sync'; import { handleSync } from './handlers/sync';
// Setup handlers // Setup handlers
import { handleSetupPage, handleSetupStatus, handleDisableSetup } from './handlers/setup'; import { handleSetupStatus } from './handlers/setup';
import { handleKnownDevice, handleGetDevices } from './handlers/devices'; import {
handleKnownDevice,
handleGetAuthorizedDevices,
handleGetDevices,
handleRevokeAllTrustedDevices,
handleRevokeTrustedDevice,
handleDeleteDevice,
handleUpdateDeviceToken
} from './handlers/devices';
// Import handler // Import handler
import { handleCiphersImport } from './handlers/import'; import { handleCiphersImport } from './handlers/import';
@@ -50,6 +90,15 @@ import {
handleDeleteAttachment, handleDeleteAttachment,
handlePublicDownloadAttachment, handlePublicDownloadAttachment,
} from './handlers/attachments'; } from './handlers/attachments';
import {
handleAdminListUsers,
handleAdminCreateInvite,
handleAdminListInvites,
handleAdminDeleteAllInvites,
handleAdminRevokeInvite,
handleAdminSetUserStatus,
handleAdminDeleteUser,
} from './handlers/admin';
function isSameOriginWriteRequest(request: Request): boolean { function isSameOriginWriteRequest(request: Request): boolean {
const targetOrigin = new URL(request.url).origin; const targetOrigin = new URL(request.url).origin;
@@ -71,6 +120,14 @@ function isSameOriginWriteRequest(request: Request): boolean {
return false; return false;
} }
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing';
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null;
}
function getNwIconSvg(): string { function getNwIconSvg(): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`; return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`;
} }
@@ -156,6 +213,24 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
const url = new URL(request.url); const url = new URL(request.url);
const path = url.pathname; const path = url.pathname;
const method = request.method; const method = request.method;
const clientId = getClientIdentifier(request);
async function enforcePublicRateLimit(): Promise<Response | null> {
const rateLimit = new RateLimitService(env.DB);
const check = await rateLimit.consumeBudget(`${clientId}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
if (check.allowed) return null;
return new Response(JSON.stringify({
error: 'Too many requests',
error_description: `Rate limit exceeded. Try again in ${check.retryAfterSeconds} seconds.`,
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(check.retryAfterSeconds || 60),
'X-RateLimit-Remaining': '0',
},
});
}
// Handle CORS preflight // Handle CORS preflight
if (method === 'OPTIONS') { if (method === 'OPTIONS') {
@@ -165,9 +240,16 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Route matching // Route matching
try { try {
// Setup page (root) // Reject oversized bodies before any path-specific parsing.
if (path === '/' && method === 'GET') { // File upload paths enforce their own limits and are exempt here.
return handleSetupPage(request, env); const isFileUploadPath =
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path);
if (!isFileUploadPath) {
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
if (contentLength > LIMITS.request.maxBodyBytes) {
return errorResponse('Request body too large', 413);
}
} }
// Setup status // Setup status
@@ -175,12 +257,14 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return handleSetupStatus(request, env); return handleSetupStatus(request, env);
} }
// Disable setup page (one-way) // Web runtime config for static client bootstrap
if (path === '/setup/disable' && method === 'POST') { if (path === '/api/web/config' && method === 'GET') {
if (!isSameOriginWriteRequest(request)) { const jwtUnsafeReason = jwtSecretUnsafeReason(env);
return errorResponse('Forbidden origin', 403); return jsonResponse({
} defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
return handleDisableSetup(request, env); jwtUnsafeReason,
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
});
} }
// Browser/devtools probe endpoint // Browser/devtools probe endpoint
@@ -214,13 +298,57 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return handlePublicDownloadAttachment(request, env, cipherId, attachmentId); return handlePublicDownloadAttachment(request, env, cipherId, attachmentId);
} }
// Public Send access endpoints
const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i);
if (sendAccessMatch && method === 'POST') {
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
const accessId = sendAccessMatch[1];
return handleAccessSend(request, env, accessId);
}
const sendAccessV2Match = path === '/api/sends/access';
if (sendAccessV2Match && method === 'POST') {
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
return handleAccessSendV2(request, env);
}
const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([^/]+)\/?$/i);
if (sendAccessFileV2Match && method === 'POST') {
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
const fileId = sendAccessFileV2Match[1];
return handleAccessSendFileV2(request, env, fileId);
}
const sendAccessFileMatch = path.match(/^\/api\/sends\/([^/]+)\/access\/file\/([^/]+)\/?$/i);
if (sendAccessFileMatch && method === 'POST') {
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
const idOrAccessId = sendAccessFileMatch[1];
const fileId = sendAccessFileMatch[2];
return handleAccessSendFile(request, env, idOrAccessId, fileId);
}
const sendDownloadMatch = path.match(/^\/api\/sends\/([^/]+)\/([^/]+)\/?$/i);
if (sendDownloadMatch && method === 'GET') {
const sendId = sendDownloadMatch[1];
const fileId = sendDownloadMatch[2];
return handleDownloadSendFile(request, env, sendId, fileId);
}
// Notifications hub (stub - no auth required, return 200 for connection) // Notifications hub (stub - no auth required, return 200 for connection)
if (path.startsWith('/notifications/')) { if (path.startsWith('/notifications/')) {
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
return new Response(null, { status: 200 }); return new Response(null, { status: 200 });
} }
// Known device check (no auth required) // Known device check (no auth required)
if (path === '/api/devices/knowndevice' && method === 'GET') { if (path === '/api/devices/knowndevice' && method === 'GET') {
const blocked = await enforcePublicRateLimit();
if (blocked) return jsonResponse(false);
return handleKnownDevice(request, env); return handleKnownDevice(request, env);
} }
@@ -229,10 +357,18 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return handleToken(request, env); return handleToken(request, env);
} }
if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') {
return handleRevocation(request, env);
}
if (path === '/identity/accounts/prelogin' && method === 'POST') { if (path === '/identity/accounts/prelogin' && method === 'POST') {
return handlePrelogin(request, env); return handlePrelogin(request, env);
} }
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
return handleRecoverTwoFactor(request, env);
}
// Config endpoint (no auth required for basic config) // Config endpoint (no auth required for basic config)
// Bitwarden clients call GET "/config" (relative to the API base URL). // Bitwarden clients call GET "/config" (relative to the API base URL).
// They also tolerate different casing, but their response models use PascalCase. // They also tolerate different casing, but their response models use PascalCase.
@@ -269,6 +405,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
featureStates: { featureStates: {
'duo-redirect': true, 'duo-redirect': true,
'email-verification': true, 'email-verification': true,
'pm-19051-send-email-verification': false,
'unauth-ui-refresh': true, 'unauth-ui-refresh': true,
}, },
object: 'config', object: 'config',
@@ -280,7 +417,9 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return jsonResponse(LIMITS.compatibility.bitwardenServerVersion); // Always same value as /config.version return jsonResponse(LIMITS.compatibility.bitwardenServerVersion); // Always same value as /config.version
} }
// Registration endpoint (no auth required, but only works once) // Registration endpoint (no auth required):
// - first user can self-register and becomes admin
// - later registrations require inviteCode in request body
if (path === '/api/accounts/register' && method === 'POST') { if (path === '/api/accounts/register' && method === 'POST') {
if (!isSameOriginWriteRequest(request)) { if (!isSameOriginWriteRequest(request)) {
return errorResponse('Forbidden origin', 403); return errorResponse('Forbidden origin', 403);
@@ -289,8 +428,8 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
} }
// If JWT_SECRET is not safely configured, block any other endpoints. // If JWT_SECRET is not safely configured, block any other endpoints.
const secret = (env.JWT_SECRET || '').trim(); const secret = jwtSecretUnsafeReason(env);
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { if (secret) {
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500); return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
} }
@@ -304,33 +443,21 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
} }
const userId = payload.sub; const userId = payload.sub;
const clientId = getClientIdentifier(request); const storage = new StorageService(env.DB);
const currentUser = await storage.getUserById(userId);
// Dedicated read rate limiting for heavy sync endpoint. if (!currentUser) {
if (path === '/api/sync' && method === 'GET') { return errorResponse('Unauthorized', 401);
const rateLimit = new RateLimitService(env.DB);
const rateLimitCheck = await rateLimit.consumeSyncReadBudget(userId + ':' + clientId + ':sync');
if (!rateLimitCheck.allowed) {
return new Response(JSON.stringify({
error: 'Too many requests',
error_description: `Sync rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
'X-RateLimit-Remaining': '0',
},
});
} }
if (currentUser.status !== 'active') {
return errorResponse('Account is disabled', 403);
} }
// Unified rate limiting for all authenticated API requests.
// API rate limiting only for write operations (keep reads frictionless) {
const isWriteMethod = method === 'POST' || method === 'PUT' || method === 'DELETE' || method === 'PATCH';
if (isWriteMethod) {
const rateLimit = new RateLimitService(env.DB); const rateLimit = new RateLimitService(env.DB);
const rateLimitCheck = await rateLimit.consumeApiWriteBudget(userId + ':' + clientId + ':write'); const rateLimitCheck = await rateLimit.consumeBudget(
userId + ':api',
LIMITS.rateLimit.apiRequestsPerMinute
);
if (!rateLimitCheck.allowed) { if (!rateLimitCheck.allowed) {
return new Response(JSON.stringify({ return new Response(JSON.stringify({
@@ -347,32 +474,42 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
} }
} }
// Block account operations that could change password or delete user // Block account operations we do not support yet.
if (method === 'POST' || method === 'PUT' || method === 'DELETE') { if (method === 'POST' || method === 'PUT' || method === 'DELETE') {
const blockedAccountPaths = new Set([ const blockedAccountPaths = new Set([
'/api/accounts/password',
'/api/accounts/change-password',
'/api/accounts/set-password', '/api/accounts/set-password',
'/api/accounts/master-password',
'/api/accounts/delete', '/api/accounts/delete',
'/api/accounts/delete-account', '/api/accounts/delete-account',
'/api/accounts/delete-vault', '/api/accounts/delete-vault',
]); ]);
if (blockedAccountPaths.has(path)) { if (blockedAccountPaths.has(path)) {
return errorResponse('Not implemented in single-user mode', 501); return errorResponse('Not implemented', 501);
} }
} }
// Account endpoints // Account endpoints
if (path === '/api/accounts/profile') { if (path === '/api/accounts/profile') {
if (method === 'GET') return handleGetProfile(request, env, userId); if (method === 'GET') return handleGetProfile(request, env, userId);
if (method === 'PUT') return handleUpdateProfile(request, env, userId); return errorResponse('Method not allowed', 405);
}
if ((path === '/api/accounts/password' || path === '/api/accounts/change-password') && (method === 'POST' || method === 'PUT')) {
return handleChangePassword(request, env, userId);
} }
if (path === '/api/accounts/keys' && method === 'POST') { if (path === '/api/accounts/keys' && method === 'POST') {
return handleSetKeys(request, env, userId); return handleSetKeys(request, env, userId);
} }
if (path === '/api/accounts/totp') {
if (method === 'GET') return handleGetTotpStatus(request, env, userId);
if (method === 'PUT' || method === 'POST') return handleSetTotpStatus(request, env, userId);
}
if ((path === '/api/accounts/totp/recovery-code' || path === '/api/two-factor/get-recover') && method === 'POST') {
return handleGetTotpRecoveryCode(request, env, userId);
}
// Revision date endpoint // Revision date endpoint
if (path === '/api/accounts/revision-date' && method === 'GET') { if (path === '/api/accounts/revision-date' && method === 'GET') {
return handleGetRevisionDate(request, env, userId); return handleGetRevisionDate(request, env, userId);
@@ -415,7 +552,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
if (subPath === '' || subPath === '/') { if (subPath === '' || subPath === '/') {
if (method === 'GET') return handleGetCipher(request, env, userId, cipherId); if (method === 'GET') return handleGetCipher(request, env, userId, cipherId);
if (method === 'PUT' || method === 'POST') return handleUpdateCipher(request, env, userId, cipherId); if (method === 'PUT' || method === 'POST') return handleUpdateCipher(request, env, userId, cipherId);
if (method === 'DELETE') return handleDeleteCipher(request, env, userId, cipherId); if (method === 'DELETE') return handleDeleteCipherCompat(request, env, userId, cipherId);
} }
if (subPath === '/delete' && method === 'PUT') { if (subPath === '/delete' && method === 'PUT') {
@@ -505,10 +642,40 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
} }
} }
// Sends endpoint (stub - not implemented) // Send endpoints
if (path === '/api/sends' || path.startsWith('/api/sends/')) { if (path === '/api/sends') {
if (method === 'GET') { if (method === 'GET') return handleGetSends(request, env, userId);
return jsonResponse({ data: [], object: 'list', continuationToken: null }); if (method === 'POST') return handleCreateSend(request, env, userId);
}
if ((path === '/api/sends/file/v2' || path === '/api/sends/file') && method === 'POST') {
return handleCreateFileSendV2(request, env, userId);
}
const sendMatch = path.match(/^\/api\/sends\/([^/]+)(\/.*)?$/i);
if (sendMatch) {
const sendId = sendMatch[1];
const subPath = sendMatch[2] || '';
if (subPath === '' || subPath === '/') {
if (method === 'GET') return handleGetSend(request, env, userId, sendId);
if (method === 'PUT') return handleUpdateSend(request, env, userId, sendId);
if (method === 'DELETE') return handleDeleteSend(request, env, userId, sendId);
}
if (subPath === '/remove-password' && (method === 'PUT' || method === 'POST')) {
return handleRemoveSendPassword(request, env, userId, sendId);
}
if (subPath === '/remove-auth' && (method === 'PUT' || method === 'POST')) {
return handleRemoveSendAuth(request, env, userId, sendId);
}
const sendFileUploadMatch = subPath.match(/^\/file\/([^/]+)\/?$/i);
if (sendFileUploadMatch) {
const fileId = sendFileUploadMatch[1];
if (method === 'GET') return handleGetSendFileUpload(request, env, userId, sendId, fileId);
if (method === 'POST' || method === 'PUT') return handleUploadSendFile(request, env, userId, sendId, fileId);
} }
} }
@@ -542,6 +709,57 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return handleGetDevices(request, env, userId); return handleGetDevices(request, env, userId);
} }
if (path === '/api/devices/authorized') {
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
}
const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i);
if (authorizedDeviceMatch && method === 'DELETE') {
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
}
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
if (deleteDeviceMatch && method === 'DELETE') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
return handleDeleteDevice(request, env, userId, deviceIdentifier);
}
// Admin endpoints
if (path === '/api/admin/users' && method === 'GET') {
return handleAdminListUsers(request, env, currentUser);
}
if (path === '/api/admin/invites') {
if (method === 'GET') return handleAdminListInvites(request, env, currentUser);
if (method === 'POST') return handleAdminCreateInvite(request, env, currentUser);
if (method === 'DELETE') return handleAdminDeleteAllInvites(request, env, currentUser);
}
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
if (adminInviteMatch && method === 'DELETE') {
const inviteCode = decodeURIComponent(adminInviteMatch[1]);
return handleAdminRevokeInvite(request, env, currentUser, inviteCode);
}
const adminUserStatusMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)\/status$/i);
if (adminUserStatusMatch && (method === 'PUT' || method === 'POST')) {
return handleAdminSetUserStatus(request, env, currentUser, adminUserStatusMatch[1]);
}
const adminUserDeleteMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)$/i);
if (adminUserDeleteMatch && method === 'DELETE') {
return handleAdminDeleteUser(request, env, currentUser, adminUserDeleteMatch[1]);
}
// Device push token endpoint (no-op compatibility handler)
const deviceTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
if (deviceTokenMatch && (method === 'PUT' || method === 'POST')) {
const deviceIdentifier = decodeURIComponent(deviceTokenMatch[1]);
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
}
// Not found // Not found
return errorResponse('Not found', 404); return errorResponse('Not found', 404);
+49 -7
View File
@@ -2,6 +2,11 @@ import { Env, JWTPayload, User } from '../types';
import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt'; import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt';
import { StorageService } from './storage'; import { StorageService } from './storage';
// Server-side iterations for second-layer hashing.
// The client already does heavy PBKDF2 (600k iterations).
// This second layer only needs to be non-trivial, not expensive.
const SERVER_HASH_ITERATIONS = 100_000;
export class AuthService { export class AuthService {
private storage: StorageService; private storage: StorageService;
@@ -9,15 +14,48 @@ export class AuthService {
this.storage = new StorageService(env.DB); this.storage = new StorageService(env.DB);
} }
// Verify password hash (compare with stored hash) // Second-layer hash: PBKDF2-SHA256(clientHash, email-salt, iterations).
async verifyPassword(inputHash: string, storedHash: string): Promise<boolean> { // Ensures database contents alone cannot be used to authenticate (pass-the-hash defense).
const input = new TextEncoder().encode(inputHash); // Result is prefixed with "$s$" to distinguish from legacy raw client hashes.
const stored = new TextEncoder().encode(storedHash); async hashPasswordServer(clientHash: string, email: string): Promise<string> {
if (input.length !== stored.length) return false; const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(clientHash),
'PBKDF2',
false,
['deriveBits']
);
const salt = new TextEncoder().encode(email.toLowerCase().trim());
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: SERVER_HASH_ITERATIONS },
keyMaterial,
256
);
const bytes = new Uint8Array(bits);
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return '$s$' + btoa(binary);
}
// Verify password: hash the input the same way, then constant-time compare.
async verifyPassword(inputHash: string, storedHash: string, email?: string): Promise<boolean> {
// New server-hashed passwords are prefixed with "$s$".
// Legacy accounts (created before the upgrade) store raw client hashes without prefix.
if (email && storedHash.startsWith('$s$')) {
const serverHash = await this.hashPasswordServer(inputHash, email);
return this.constantTimeEquals(serverHash, storedHash);
}
// Legacy path: direct constant-time comparison of raw client hashes.
return this.constantTimeEquals(inputHash, storedHash);
}
private constantTimeEquals(a: string, b: string): boolean {
const encA = new TextEncoder().encode(a);
const encB = new TextEncoder().encode(b);
if (encA.length !== encB.length) return false;
let diff = 0; let diff = 0;
for (let i = 0; i < input.length; i++) { for (let i = 0; i < encA.length; i++) {
diff |= input[i] ^ stored[i]; diff |= encA[i] ^ encB[i];
} }
return diff === 0; return diff === 0;
} }
@@ -72,6 +110,10 @@ export class AuthService {
const user = await this.storage.getUserById(userId); const user = await this.storage.getUserById(userId);
if (!user) return null; if (!user) return null;
if (user.status !== 'active') {
await this.storage.deleteRefreshToken(refreshToken);
return null;
}
const accessToken = await this.generateAccessToken(user); const accessToken = await this.generateAccessToken(user);
return { accessToken, user }; return { accessToken, user };
+32 -70
View File
@@ -1,33 +1,22 @@
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
// D1-backed rate limiting. // Rate limiting service.
// Notes: // - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence).
// - Login attempts are tracked per client IP. // - API budgets: Cloudflare Cache API (high volume, auto-expires, zero D1 writes).
// - API rate is tracked per identifier per fixed window.
// Rate limit configuration
const CONFIG = { const CONFIG = {
// Friendly default: short cooldown instead of long lockouts.
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts, LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
LOGIN_LOCKOUT_MINUTES: LIMITS.rateLimit.loginLockoutMinutes, LOGIN_LOCKOUT_MINUTES: LIMITS.rateLimit.loginLockoutMinutes,
// Write operations only (POST/PUT/DELETE/PATCH) should use this budget.
API_WRITE_REQUESTS_PER_MINUTE: LIMITS.rateLimit.apiWriteRequestsPerMinute,
// Dedicated budget for GET /api/sync reads.
SYNC_READ_REQUESTS_PER_MINUTE: LIMITS.rateLimit.syncReadRequestsPerMinute,
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds, API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
}; };
export class RateLimitService { export class RateLimitService {
private static loginIpTableReady = false; private static loginIpTableReady = false;
private static lastLoginIpCleanupAt = 0; private static lastLoginIpCleanupAt = 0;
private static lastApiWindowCleanupAt = 0;
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability; private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability;
private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs; private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
private static readonly API_WINDOW_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.apiWindowCleanupIntervalMs;
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs; private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
private static readonly API_WINDOW_RETENTION_WINDOWS = LIMITS.rateLimit.apiWindowRetentionWindows;
constructor(private db: D1Database) {} constructor(private db: D1Database) {}
@@ -52,16 +41,6 @@ export class RateLimitService {
RateLimitService.lastLoginIpCleanupAt = nowMs; RateLimitService.lastLoginIpCleanupAt = nowMs;
} }
private async maybeCleanupApiWindows(windowStart: number, windowSeconds: number): Promise<void> {
if (!this.shouldRunCleanup(RateLimitService.lastApiWindowCleanupAt, RateLimitService.API_WINDOW_CLEANUP_INTERVAL_MS)) {
return;
}
const cutoff = windowStart - (windowSeconds * RateLimitService.API_WINDOW_RETENTION_WINDOWS);
await this.db.prepare('DELETE FROM api_rate_limits WHERE window_start < ?').bind(cutoff).run();
RateLimitService.lastApiWindowCleanupAt = Date.now();
}
private async ensureLoginIpTable(): Promise<void> { private async ensureLoginIpTable(): Promise<void> {
if (RateLimitService.loginIpTableReady) return; if (RateLimitService.loginIpTableReady) return;
@@ -158,8 +137,9 @@ export class RateLimitService {
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run(); await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
} }
// Atomically consume one budget unit for the current fixed window. // Cache API-backed fixed-window rate limiter.
// Uses SQLite UPSERT-with-WHERE so requests at/over limit do not increment. // Uses Cloudflare edge cache instead of D1 — zero database writes, auto-expires via TTL.
// Per-colo isolation is acceptable (matches Cloudflare's own rate limiting behaviour).
private async consumeFixedWindowBudget( private async consumeFixedWindowBudget(
identifier: string, identifier: string,
maxRequests: number, maxRequests: number,
@@ -168,59 +148,41 @@ export class RateLimitService {
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
const windowStart = nowSec - (nowSec % windowSeconds); const windowStart = nowSec - (nowSec % windowSeconds);
const windowEnd = windowStart + windowSeconds; const windowEnd = windowStart + windowSeconds;
await this.maybeCleanupApiWindows(windowStart, windowSeconds); const ttl = Math.max(1, windowEnd - nowSec);
const writeResult = await this.db const cache = await caches.open('rate-limit');
.prepare( const cacheKey = new Request(`https://rl/${identifier}/${windowStart}`);
'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' +
'ON CONFLICT(identifier, window_start) DO UPDATE SET count = count + 1 ' +
'WHERE api_rate_limits.count < ?'
)
.bind(identifier, windowStart, maxRequests)
.run();
// No changed row means conflict happened and WHERE prevented increment: const cached = await cache.match(cacheKey);
// current count is already at/above configured limit. let count = 0;
if ((writeResult.meta.changes ?? 0) === 0) { if (cached) {
return { count = parseInt(await cached.text(), 10) || 0;
allowed: false,
remaining: 0,
retryAfterSeconds: windowEnd - nowSec,
};
} }
const row = await this.db if (count >= maxRequests) {
.prepare('SELECT count FROM api_rate_limits WHERE identifier = ? AND window_start = ?') return { allowed: false, remaining: 0, retryAfterSeconds: ttl };
.bind(identifier, windowStart)
.first<{ count: number }>();
if (!row) {
return {
allowed: true,
remaining: 0,
};
} }
const remaining = Math.max(0, maxRequests - row.count); count++;
return { allowed: true, remaining }; await cache.put(
} cacheKey,
new Response(String(count), {
// Write budget for POST/PUT/DELETE/PATCH requests. headers: { 'Cache-Control': `public, max-age=${ttl}` },
async consumeApiWriteBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { })
return this.consumeFixedWindowBudget(
identifier,
CONFIG.API_WRITE_REQUESTS_PER_MINUTE,
CONFIG.API_WINDOW_SECONDS
); );
return { allowed: true, remaining: Math.max(0, maxRequests - count) };
} }
// Read budget for GET /api/sync. // General-purpose fixed-window budget.
async consumeSyncReadBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { // Callers supply an identifier (must be unique per rate-limit category) and the
return this.consumeFixedWindowBudget( // per-window maximum. This single method replaces all previous specialised
identifier, // budget helpers (write / sync / knownDevice / publicSend).
CONFIG.SYNC_READ_REQUESTS_PER_MINUTE, async consumeBudget(
CONFIG.API_WINDOW_SECONDS identifier: string,
); maxRequests: number
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeFixedWindowBudget(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS);
} }
} }
+388 -64
View File
@@ -1,8 +1,7 @@
import { User, Cipher, Folder, Attachment, Device } from '../types'; import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, SendAuthType, TrustedDeviceTokenSummary } from '../types';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const SCHEMA_HASH_CONFIG_KEY = 'schema_hash';
// IMPORTANT: // IMPORTANT:
// Keep this schema list in sync with migrations/0001_init.sql. // Keep this schema list in sync with migrations/0001_init.sql.
@@ -12,7 +11,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' + 'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' +
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' + 'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' + 'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
'security_stamp TEXT NOT NULL, 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\', totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'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 totp_secret TEXT',
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
'CREATE TABLE IF NOT EXISTS user_revisions (' + 'CREATE TABLE IF NOT EXISTS user_revisions (' +
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' + 'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
@@ -37,11 +40,35 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)', 'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)', 'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)',
'CREATE TABLE IF NOT EXISTS sends (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, name TEXT NOT NULL, notes TEXT, data TEXT NOT NULL, ' +
'key TEXT NOT NULL, password_hash TEXT, password_salt TEXT, password_iterations INTEGER, auth_type INTEGER NOT NULL DEFAULT 2, emails TEXT, ' +
'max_access_count INTEGER, access_count INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0, hide_email INTEGER, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, expiration_date TEXT, deletion_date TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)',
'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)',
'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2',
'ALTER TABLE sends ADD COLUMN emails TEXT',
'CREATE TABLE IF NOT EXISTS refresh_tokens (' + 'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, ' + 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)', 'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
'CREATE TABLE IF NOT EXISTS invites (' +
'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, ' +
'FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL)',
'CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at)',
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
'CREATE TABLE IF NOT EXISTS audit_logs (' +
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)',
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
'CREATE TABLE IF NOT EXISTS devices (' + 'CREATE TABLE IF NOT EXISTS devices (' +
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, ' + 'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
@@ -123,30 +150,17 @@ export class StorageService {
// --- Database initialization --- // --- Database initialization ---
// Strategy: // Strategy:
// - Run only once per isolate. // - Run only once per isolate.
// - Persist schema hash in DB config; if unchanged, skip all schema SQL. // - Execute idempotent schema SQL on first request in each isolate.
// - Keep statements idempotent so updates are safe. // - Keep statements idempotent so updates are safe.
async initializeDatabase(): Promise<void> { async initializeDatabase(): Promise<void> {
if (StorageService.schemaVerified) return; if (StorageService.schemaVerified) return;
await this.db.prepare('PRAGMA foreign_keys = ON').run(); await this.db.prepare('PRAGMA foreign_keys = ON').run();
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run(); await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
const schemaHash = await this.sha256Hex(SCHEMA_STATEMENTS.join('\n'));
const current = await this.db.prepare('SELECT value FROM config WHERE key = ?')
.bind(SCHEMA_HASH_CONFIG_KEY)
.first<{ value: string }>();
if (current?.value !== schemaHash) {
for (const stmt of SCHEMA_STATEMENTS) { for (const stmt of SCHEMA_STATEMENTS) {
await this.executeSchemaStatement(stmt); await this.executeSchemaStatement(stmt);
} }
await this.ensureAdminUserExists();
await this.db.prepare(
'INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
)
.bind(SCHEMA_HASH_CONFIG_KEY, schemaHash)
.run();
}
StorageService.schemaVerified = true; StorageService.schemaVerified = true;
} }
@@ -164,6 +178,21 @@ export class StorageService {
} }
} }
private async ensureAdminUserExists(): Promise<void> {
const admin = await this.db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").first<{ id: string }>();
if (admin?.id) return;
const firstUser = await this.db
.prepare('SELECT id FROM users ORDER BY created_at ASC LIMIT 1')
.first<{ id: string }>();
if (!firstUser?.id) return;
await this.db
.prepare("UPDATE users SET role = 'admin', updated_at = ? WHERE id = ?")
.bind(new Date().toISOString(), firstUser.id)
.run();
}
// --- Config / setup --- // --- Config / setup ---
async isRegistered(): Promise<boolean> { async isRegistered(): Promise<boolean> {
@@ -177,27 +206,9 @@ export class StorageService {
.run(); .run();
} }
async isSetupDisabled(): Promise<boolean> {
const row = await this.db.prepare('SELECT value FROM config WHERE key = ?').bind('setup_disabled').first<{ value: string }>();
return row?.value === 'true';
}
async setSetupDisabled(): Promise<void> {
await this.db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
.bind('setup_disabled', 'true')
.run();
}
// --- Users --- // --- Users ---
async getUser(email: string): Promise<User | null> { private mapUserRow(row: any): User {
const row = await this.db
.prepare(
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at FROM users WHERE email = ?'
)
.bind(email.toLowerCase())
.first<any>();
if (!row) return null;
return { return {
id: row.id, id: row.id,
email: row.email, email: row.email,
@@ -211,45 +222,59 @@ export class StorageService {
kdfMemory: row.kdf_memory ?? undefined, kdfMemory: row.kdf_memory ?? undefined,
kdfParallelism: row.kdf_parallelism ?? undefined, kdfParallelism: row.kdf_parallelism ?? undefined,
securityStamp: row.security_stamp, securityStamp: row.security_stamp,
role: row.role === 'admin' ? 'admin' : 'user',
status: row.status === 'banned' ? 'banned' : 'active',
totpSecret: row.totp_secret ?? null,
totpRecoveryCode: row.totp_recovery_code ?? null,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
}; };
} }
async getUser(email: string): Promise<User | null> {
const row = await this.db
.prepare(
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE email = ?'
)
.bind(email.toLowerCase())
.first<any>();
if (!row) return null;
return this.mapUserRow(row);
}
async getUserById(id: string): Promise<User | null> { async getUserById(id: string): Promise<User | null> {
const row = await this.db const row = await this.db
.prepare( .prepare(
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at FROM users WHERE id = ?' 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE id = ?'
) )
.bind(id) .bind(id)
.first<any>(); .first<any>();
if (!row) return null; if (!row) return null;
return { return this.mapUserRow(row);
id: row.id, }
email: row.email,
name: row.name, async getUserCount(): Promise<number> {
masterPasswordHash: row.master_password_hash, const row = await this.db.prepare('SELECT COUNT(*) AS count FROM users').first<{ count: number }>();
key: row.key, return Number(row?.count || 0);
privateKey: row.private_key, }
publicKey: row.public_key,
kdfType: row.kdf_type, async getAllUsers(): Promise<User[]> {
kdfIterations: row.kdf_iterations, const res = await this.db
kdfMemory: row.kdf_memory ?? undefined, .prepare(
kdfParallelism: row.kdf_parallelism ?? undefined, 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'
securityStamp: row.security_stamp, )
createdAt: row.created_at, .all<any>();
updatedAt: row.updated_at, return (res.results || []).map(row => this.mapUserRow(row));
};
} }
async saveUser(user: User): Promise<void> { async saveUser(user: User): Promise<void> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
const stmt = this.db.prepare( const stmt = this.db.prepare(
'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at) ' + 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + 'ON CONFLICT(id) DO UPDATE SET ' +
'email=excluded.email, name=excluded.name, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + 'email=excluded.email, name=excluded.name, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, updated_at=excluded.updated_at' 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at'
); );
await this.safeBind(stmt, await this.safeBind(stmt,
user.id, user.id,
@@ -264,16 +289,24 @@ export class StorageService {
user.kdfMemory, user.kdfMemory,
user.kdfParallelism, user.kdfParallelism,
user.securityStamp, user.securityStamp,
user.role,
user.status,
user.totpSecret,
user.totpRecoveryCode,
user.createdAt, user.createdAt,
user.updatedAt user.updatedAt
).run(); ).run();
} }
async createUser(user: User): Promise<void> {
await this.saveUser(user);
}
async createFirstUser(user: User): Promise<boolean> { async createFirstUser(user: User): Promise<boolean> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
const stmt = this.db.prepare( const stmt = this.db.prepare(
'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at) ' + 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' +
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
); );
const result = await this.safeBind(stmt, const result = await this.safeBind(stmt,
@@ -289,6 +322,10 @@ export class StorageService {
user.kdfMemory, user.kdfMemory,
user.kdfParallelism, user.kdfParallelism,
user.securityStamp, user.securityStamp,
user.role,
user.status,
user.totpSecret,
user.totpRecoveryCode,
user.createdAt, user.createdAt,
user.updatedAt user.updatedAt
).run(); ).run();
@@ -296,11 +333,105 @@ export class StorageService {
return (result.meta.changes ?? 0) > 0; return (result.meta.changes ?? 0) > 0;
} }
async deleteUserById(id: string): Promise<boolean> {
const result = await this.db.prepare('DELETE FROM users WHERE id = ?').bind(id).run();
return (result.meta.changes ?? 0) > 0;
}
async createInvite(invite: Invite): Promise<void> {
await this.db
.prepare(
'INSERT INTO invites(code, created_by, used_by, expires_at, status, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?)'
)
.bind(invite.code, invite.createdBy, invite.usedBy, invite.expiresAt, invite.status, invite.createdAt, invite.updatedAt)
.run();
}
async getInvite(code: string): Promise<Invite | null> {
const row = await this.db
.prepare('SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites WHERE code = ?')
.bind(code)
.first<any>();
if (!row) return null;
return {
code: row.code,
createdBy: row.created_by,
usedBy: row.used_by ?? null,
expiresAt: row.expires_at,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
async listInvites(includeInactive: boolean = false): Promise<Invite[]> {
const now = new Date().toISOString();
const predicate = includeInactive
? '1 = 1'
: "(status = 'active' AND expires_at > ?)";
const query =
'SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites ' +
`WHERE ${predicate} ORDER BY created_at DESC`;
const res = includeInactive
? await this.db.prepare(query).all<any>()
: await this.db.prepare(query).bind(now).all<any>();
return (res.results || []).map(row => ({
code: row.code,
createdBy: row.created_by,
usedBy: row.used_by ?? null,
expiresAt: row.expires_at,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
}
async markInviteUsed(code: string, userId: string): Promise<boolean> {
const now = new Date().toISOString();
const result = await this.db
.prepare(
"UPDATE invites SET status = 'used', used_by = ?, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?"
)
.bind(userId, now, code, now)
.run();
return (result.meta.changes ?? 0) > 0;
}
async revokeInvite(code: string): Promise<boolean> {
const now = new Date().toISOString();
const result = await this.db
.prepare("UPDATE invites SET status = 'revoked', updated_at = ? WHERE code = ? AND status = 'active'")
.bind(now, code)
.run();
return (result.meta.changes ?? 0) > 0;
}
async deleteAllInvites(): Promise<number> {
const result = await this.db.prepare('DELETE FROM invites').run();
return Number(result.meta.changes ?? 0);
}
async createAuditLog(log: AuditLog): Promise<void> {
await this.db
.prepare(
'INSERT INTO audit_logs(id, actor_user_id, action, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)'
)
.bind(log.id, log.actorUserId, log.action, log.targetType, log.targetId, log.metadata, log.createdAt)
.run();
}
// --- Ciphers --- // --- Ciphers ---
async getCipher(id: string): Promise<Cipher | null> { async getCipher(id: string): Promise<Cipher | null> {
const row = await this.db.prepare('SELECT data FROM ciphers WHERE id = ?').bind(id).first<{ data: string }>(); const row = await this.db.prepare('SELECT data FROM ciphers WHERE id = ?').bind(id).first<{ data: string }>();
return row?.data ? (JSON.parse(row.data) as Cipher) : null; if (!row?.data) return null;
try {
return JSON.parse(row.data) as Cipher;
} catch {
console.error('Corrupted cipher data, id:', id);
return null;
}
} }
async saveCipher(cipher: Cipher): Promise<void> { async saveCipher(cipher: Cipher): Promise<void> {
@@ -335,7 +466,9 @@ export class StorageService {
async getAllCiphers(userId: string): Promise<Cipher[]> { async getAllCiphers(userId: string): Promise<Cipher[]> {
const res = await this.db.prepare('SELECT data FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC').bind(userId).all<{ data: string }>(); const res = await this.db.prepare('SELECT data FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC').bind(userId).all<{ data: string }>();
return (res.results || []).map(r => JSON.parse(r.data) as Cipher); return (res.results || []).flatMap(r => {
try { return [JSON.parse(r.data) as Cipher]; } catch { return []; }
});
} }
async getCiphersPage(userId: string, includeDeleted: boolean, limit: number, offset: number): Promise<Cipher[]> { async getCiphersPage(userId: string, includeDeleted: boolean, limit: number, offset: number): Promise<Cipher[]> {
@@ -350,7 +483,9 @@ export class StorageService {
) )
.bind(userId, limit, offset) .bind(userId, limit, offset)
.all<{ data: string }>(); .all<{ data: string }>();
return (res.results || []).map(r => JSON.parse(r.data) as Cipher); return (res.results || []).flatMap(r => {
try { return [JSON.parse(r.data) as Cipher]; } catch { return []; }
});
} }
async getCiphersByIds(ids: string[], userId: string): Promise<Cipher[]> { async getCiphersByIds(ids: string[], userId: string): Promise<Cipher[]> {
@@ -359,7 +494,9 @@ export class StorageService {
const placeholders = ids.map(() => '?').join(','); const placeholders = ids.map(() => '?').join(',');
const stmt = this.db.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`); const stmt = this.db.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`);
const res = await stmt.bind(userId, ...ids).all<{ data: string }>(); const res = await stmt.bind(userId, ...ids).all<{ data: string }>();
return (res.results || []).map(r => JSON.parse(r.data) as Cipher); return (res.results || []).flatMap(r => {
try { return [JSON.parse(r.data) as Cipher]; } catch { return []; }
});
} }
async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<void> { async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<void> {
@@ -430,7 +567,12 @@ export class StorageService {
.all<{ data: string }>(); .all<{ data: string }>();
for (const row of (res.results || [])) { for (const row of (res.results || [])) {
const cipher = JSON.parse(row.data) as Cipher; let cipher: Cipher;
try {
cipher = JSON.parse(row.data) as Cipher;
} catch {
continue;
}
cipher.folderId = null; cipher.folderId = null;
cipher.updatedAt = now; cipher.updatedAt = now;
await this.saveCipher(cipher); await this.saveCipher(cipher);
@@ -658,6 +800,145 @@ export class StorageService {
await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run(); await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run();
} }
// --- Sends ---
private mapSendRow(row: any): Send {
return {
id: row.id,
userId: row.user_id,
type: row.type,
name: row.name,
notes: row.notes,
data: row.data,
key: row.key,
passwordHash: row.password_hash,
passwordSalt: row.password_salt,
passwordIterations: row.password_iterations,
authType: row.auth_type ?? SendAuthType.None,
emails: row.emails ?? null,
maxAccessCount: row.max_access_count,
accessCount: row.access_count,
disabled: !!row.disabled,
hideEmail: row.hide_email === null || row.hide_email === undefined ? null : !!row.hide_email,
createdAt: row.created_at,
updatedAt: row.updated_at,
expirationDate: row.expiration_date,
deletionDate: row.deletion_date,
};
}
async getSend(id: string): Promise<Send | null> {
const row = await this.db
.prepare(
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE id = ?'
)
.bind(id)
.first<any>();
if (!row) return null;
return this.mapSendRow(row);
}
async saveSend(send: Send): Promise<void> {
const stmt = this.db.prepare(
'INSERT INTO sends(id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' +
'user_id=excluded.user_id, type=excluded.type, name=excluded.name, notes=excluded.notes, data=excluded.data, key=excluded.key, ' +
'password_hash=excluded.password_hash, password_salt=excluded.password_salt, password_iterations=excluded.password_iterations, auth_type=excluded.auth_type, emails=excluded.emails, ' +
'max_access_count=excluded.max_access_count, access_count=excluded.access_count, disabled=excluded.disabled, hide_email=excluded.hide_email, ' +
'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date'
);
await this.safeBind(
stmt,
send.id,
send.userId,
Number(send.type) || 0,
send.name,
send.notes,
send.data,
send.key,
send.passwordHash,
send.passwordSalt,
send.passwordIterations,
send.authType,
send.emails,
send.maxAccessCount,
send.accessCount,
send.disabled ? 1 : 0,
send.hideEmail === null || send.hideEmail === undefined ? null : (send.hideEmail ? 1 : 0),
send.createdAt,
send.updatedAt,
send.expirationDate,
send.deletionDate
).run();
}
/**
* Atomically increment access_count and update updated_at.
* Returns true if the row was updated (send still available),
* false if max_access_count has already been reached.
*/
async incrementSendAccessCount(sendId: string): Promise<boolean> {
const now = new Date().toISOString();
const result = await this.db
.prepare(
'UPDATE sends SET access_count = access_count + 1, updated_at = ? ' +
'WHERE id = ? AND (max_access_count IS NULL OR access_count < max_access_count)'
)
.bind(now, sendId)
.run();
return (result.meta.changes ?? 0) > 0;
}
async deleteSend(id: string, userId: string): Promise<void> {
await this.db.prepare('DELETE FROM sends WHERE id = ? AND user_id = ?').bind(id, userId).run();
}
async getAllSends(userId: string): Promise<Send[]> {
const res = await this.db
.prepare(
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE user_id = ? ORDER BY updated_at DESC'
)
.bind(userId)
.all<any>();
return (res.results || []).map(row => this.mapSendRow(row));
}
async getSendsPage(userId: string, limit: number, offset: number): Promise<Send[]> {
const res = await this.db
.prepare(
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?'
)
.bind(userId, limit, offset)
.all<any>();
return (res.results || []).map(row => this.mapSendRow(row));
}
async deleteRefreshTokensByUserId(userId: string): Promise<void> {
await this.db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(userId).run();
}
// Keep a short overlap window for rotated refresh token to reduce
// multi-context refresh races (e.g. browser extension popup/background).
// Expiry is only tightened, never extended.
async constrainRefreshTokenExpiry(token: string, maxExpiresAtMs: number): Promise<void> {
const tokenKey = await this.refreshTokenKey(token);
await this.db.prepare(
'UPDATE refresh_tokens ' +
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
'WHERE token = ?'
).bind(maxExpiresAtMs, maxExpiresAtMs, tokenKey).run();
// Best-effort legacy plaintext support for older rows.
await this.db.prepare(
'UPDATE refresh_tokens ' +
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
'WHERE token = ?'
).bind(maxExpiresAtMs, maxExpiresAtMs, token).run();
}
private async trustedTwoFactorTokenKey(token: string): Promise<string> { private async trustedTwoFactorTokenKey(token: string): Promise<string> {
const digest = await this.sha256Hex(token); const digest = await this.sha256Hex(token);
return `sha256:${digest}`; return `sha256:${digest}`;
@@ -707,6 +988,49 @@ export class StorageService {
})); }));
} }
async deleteDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
const result = await this.db
.prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?')
.bind(userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise<TrustedDeviceTokenSummary[]> {
const now = Date.now();
await this.db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run();
const res = await this.db
.prepare(
'SELECT device_identifier, MAX(expires_at) AS expires_at, COUNT(*) AS token_count ' +
'FROM trusted_two_factor_device_tokens WHERE user_id = ? GROUP BY device_identifier ORDER BY expires_at DESC'
)
.bind(userId)
.all<any>();
return (res.results || []).map(row => ({
deviceIdentifier: row.device_identifier,
expiresAt: Number(row.expires_at || 0),
tokenCount: Number(row.token_count || 0),
}));
}
async deleteTrustedTwoFactorTokensByDevice(userId: string, deviceIdentifier: string): Promise<number> {
const result = await this.db
.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ? AND device_identifier = ?')
.bind(userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0);
}
async deleteTrustedTwoFactorTokensByUserId(userId: string): Promise<number> {
const result = await this.db
.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ?')
.bind(userId)
.run();
return Number(result.meta.changes ?? 0);
}
// --- Trusted 2FA remember tokens (device-bound) --- // --- Trusted 2FA remember tokens (device-bound) ---
async saveTrustedTwoFactorDeviceToken( async saveTrustedTwoFactorDeviceToken(
File diff suppressed because it is too large Load Diff
+92 -1
View File
@@ -6,6 +6,9 @@ export interface Env {
TOTP_SECRET?: string; TOTP_SECRET?: string;
} }
export type UserRole = 'admin' | 'user';
export type UserStatus = 'active' | 'banned';
// Sample JWT secret used by `.dev.vars.example`. // Sample JWT secret used by `.dev.vars.example`.
// If runtime JWT_SECRET equals this value, treat it as unsafe. // If runtime JWT_SECRET equals this value, treat it as unsafe.
export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters'; export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters';
@@ -34,10 +37,34 @@ export interface User {
kdfMemory?: number; kdfMemory?: number;
kdfParallelism?: number; kdfParallelism?: number;
securityStamp: string; securityStamp: string;
role: UserRole;
status: UserStatus;
totpSecret: string | null;
totpRecoveryCode: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
export interface Invite {
code: string;
createdBy: string;
usedBy: string | null;
expiresAt: string;
status: 'active' | 'used' | 'revoked' | 'expired';
createdAt: string;
updatedAt: string;
}
export interface AuditLog {
id: string;
actorUserId: string | null;
action: string;
targetType: string | null;
targetId: string | null;
metadata: string | null;
createdAt: string;
}
// Cipher types // Cipher types
export enum CipherType { export enum CipherType {
Login = 1, Login = 1,
@@ -157,6 +184,68 @@ export interface Device {
updatedAt: string; updatedAt: string;
} }
export interface TrustedDeviceTokenSummary {
deviceIdentifier: string;
expiresAt: number;
tokenCount: number;
}
export enum SendType {
Text = 0,
File = 1,
}
export enum SendAuthType {
Email = 0,
Password = 1,
None = 2,
}
export interface Send {
id: string;
userId: string;
type: SendType;
name: string;
notes: string | null;
data: string;
key: string;
passwordHash: string | null;
passwordSalt: string | null;
passwordIterations: number | null;
authType: SendAuthType;
emails: string | null;
maxAccessCount: number | null;
accessCount: number;
disabled: boolean;
hideEmail: boolean | null;
createdAt: string;
updatedAt: string;
expirationDate: string | null;
deletionDate: string;
}
export interface SendResponse {
id: string;
accessId: string;
type: number;
name: string;
notes: string | null;
text: any | null;
file: any | null;
key: string;
maxAccessCount: number | null;
accessCount: number;
password: string | null;
emails: string | null;
authType: SendAuthType;
disabled: boolean;
hideEmail: boolean | null;
revisionDate: string;
expirationDate: string | null;
deletionDate: string;
object: string;
}
// JWT Payload // JWT Payload
export interface JWTPayload { export interface JWTPayload {
sub: string; // user id sub: string; // user id
@@ -235,6 +324,8 @@ export interface ProfileResponse {
forcePasswordReset: boolean; forcePasswordReset: boolean;
avatarColor: string | null; avatarColor: string | null;
creationDate: string; creationDate: string;
role?: UserRole;
status?: UserStatus;
object: string; object: string;
} }
@@ -290,7 +381,7 @@ export interface SyncResponse {
ciphers: CipherResponse[]; ciphers: CipherResponse[];
domains: any; domains: any;
policies: any[]; policies: any[];
sends: any[]; sends: SendResponse[];
// PascalCase for desktop/browser clients // PascalCase for desktop/browser clients
UserDecryptionOptions: UserDecryptionOptions | null; UserDecryptionOptions: UserDecryptionOptions | null;
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption")) // camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
+137
View File
@@ -177,3 +177,140 @@ export async function verifyFileDownloadToken(
return null; return null;
} }
} }
export interface SendFileDownloadClaims {
sendId: string;
fileId: string;
exp: number;
}
export async function createSendFileDownloadToken(
sendId: string,
fileId: string,
secret: string
): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
const payload: SendFileDownloadClaims = {
sendId,
fileId,
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds,
};
const encoder = new TextEncoder();
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
const data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
return `${data}.${signatureB64}`;
}
export async function verifySendFileDownloadToken(
token: string,
secret: string
): Promise<SendFileDownloadClaims | null> {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64);
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
if (!valid) return null;
const payload: SendFileDownloadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
return payload;
} catch {
return null;
}
}
export interface SendAccessTokenClaims {
sub: string; // send id
typ: 'send_access';
iat: number;
exp: number;
}
export async function createSendAccessToken(sendId: string, secret: string): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
const payload: SendAccessTokenClaims = {
sub: sendId,
typ: 'send_access',
iat: now,
exp: now + LIMITS.auth.sendAccessTokenTtlSeconds,
};
const encoder = new TextEncoder();
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
const data = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
return `${data}.${signatureB64}`;
}
export async function verifySendAccessToken(token: string, secret: string): Promise<SendAccessTokenClaims | null> {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const data = `${headerB64}.${payloadB64}`;
const signature = base64UrlDecode(signatureB64);
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
if (!valid) return null;
const payload: SendAccessTokenClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
if (payload.typ !== 'send_access') return null;
if (!payload.sub) return null;
return payload;
} catch {
return null;
}
}
+35
View File
@@ -0,0 +1,35 @@
const RECOVERY_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
function normalizeRecoveryCode(raw: string): string {
return String(raw || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
}
function formatRecoveryCode(compact: string): string {
return compact.replace(/(.{4})/g, '$1 ').trim();
}
export function createRecoveryCode(): string {
const bytes = crypto.getRandomValues(new Uint8Array(20));
let compact = '';
for (const b of bytes) {
compact += RECOVERY_ALPHABET[b % RECOVERY_ALPHABET.length];
}
// 20 bytes -> 20 chars in this simple mapping. Expand to 32 chars for friendlier grouping.
while (compact.length < 32) {
const extra = crypto.getRandomValues(new Uint8Array(1))[0];
compact += RECOVERY_ALPHABET[extra % RECOVERY_ALPHABET.length];
}
return formatRecoveryCode(compact.slice(0, 32));
}
export function recoveryCodeEquals(input: string, storedCode: string | null | undefined): boolean {
if (!storedCode) return false;
const a = new TextEncoder().encode(normalizeRecoveryCode(input));
const b = new TextEncoder().encode(normalizeRecoveryCode(storedCode));
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a[i] ^ b[i];
}
return diff === 0;
}
+5 -1
View File
@@ -5,7 +5,6 @@ const CORS_HEADERS = 'Content-Type, Authorization, Accept, Device-Type, Bitwarde
function isTrustedClientOrigin(origin: string): boolean { function isTrustedClientOrigin(origin: string): boolean {
// Official browser extension / desktop-webview common origins. // Official browser extension / desktop-webview common origins.
if (origin === 'null') return true;
if (origin.startsWith('chrome-extension://')) return true; if (origin.startsWith('chrome-extension://')) return true;
if (origin.startsWith('moz-extension://')) return true; if (origin.startsWith('moz-extension://')) return true;
if (origin.startsWith('safari-web-extension://')) return true; if (origin.startsWith('safari-web-extension://')) return true;
@@ -50,6 +49,11 @@ export function applyCors(
for (const [k, v] of Object.entries(corsHeaders)) { for (const [k, v] of Object.entries(corsHeaders)) {
headers.set(k, v); headers.set(k, v);
} }
// Security headers applied to every response.
headers.set('X-Frame-Options', 'DENY');
headers.set('X-Content-Type-Options', 'nosniff');
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
headers.set('Content-Security-Policy', "frame-ancestors 'none'");
return new Response(response.body, { return new Response(response.body, {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
+10 -2
View File
@@ -69,11 +69,19 @@ export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs
if (!secret) return false; if (!secret) return false;
const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS); const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS);
let matched = false;
for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) { for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) {
const expected = await hotp(secret, currentCounter + delta); const expected = await hotp(secret, currentCounter + delta);
if (expected === token) return true; // Constant-time comparison: always check all windows, never short-circuit.
const a = new TextEncoder().encode(expected);
const b = new TextEncoder().encode(token);
let diff = a.length ^ b.length;
for (let i = 0; i < a.length && i < b.length; i++) {
diff |= a[i] ^ b[i];
} }
return false; if (diff === 0) matched = true;
}
return matched;
} }
export function isTotpEnabled(secretRaw: string | undefined | null): boolean { export function isTotpEnabled(secretRaw: string | undefined | null): boolean {
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://icons.bitwarden.net; connect-src 'self' https://cloudflareinsights.com; font-src 'self'; form-action 'self'; base-uri 'self';" />
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>NodeWarden</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+1951
View File
File diff suppressed because it is too large Load Diff
+166
View File
@@ -0,0 +1,166 @@
import { useState } from 'preact/hooks';
import { ChevronLeft, ChevronRight, Clipboard, Plus, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact';
import type { AdminInvite, AdminUser } from '@/lib/types';
import { t } from '@/lib/i18n';
interface AdminPageProps {
currentUserId: string;
users: AdminUser[];
invites: AdminInvite[];
onRefresh: () => void;
onCreateInvite: (hours: number) => Promise<void>;
onDeleteAllInvites: () => Promise<void>;
onToggleUserStatus: (userId: string, currentStatus: string) => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>;
onRevokeInvite: (code: string) => Promise<void>;
}
export default function AdminPage(props: AdminPageProps) {
const [inviteHours, setInviteHours] = useState(168);
const [page, setPage] = useState(1);
const pageSize = 20;
const formatExpiresAt = (x?: string) => (x ? new Date(x).toLocaleString() : t('txt_dash'));
const totalPages = Math.max(1, Math.ceil(props.invites.length / pageSize));
const safePage = Math.min(page, totalPages);
const pagedInvites = props.invites.slice((safePage - 1) * pageSize, safePage * pageSize);
const roleText = (role: string) => {
const normalized = String(role || '').toLowerCase();
if (normalized === 'admin') return t('txt_role_admin');
if (normalized === 'user') return t('txt_role_user');
return role || '-';
};
const statusText = (status: string) => {
const normalized = String(status || '').toLowerCase();
if (normalized === 'active') return t('txt_status_active');
if (normalized === 'banned') return t('txt_status_banned');
if (normalized === 'inactive') return t('txt_status_inactive');
return status || '-';
};
return (
<div className="stack">
<section className="card">
<h3>{t('txt_users')}</h3>
<table className="table">
<thead>
<tr>
<th>{t('txt_email')}</th>
<th>{t('txt_name')}</th>
<th>{t('txt_role')}</th>
<th>{t('txt_status')}</th>
<th>{t('txt_actions')}</th>
</tr>
</thead>
<tbody>
{props.users.map((user) => (
<tr key={user.id}>
<td>{user.email}</td>
<td>{user.name || t('txt_dash')}</td>
<td>{roleText(user.role)}</td>
<td>{statusText(user.status)}</td>
<td>
<div className="actions">
<button
type="button"
className="btn btn-secondary"
disabled={user.id === props.currentUserId}
onClick={() => void props.onToggleUserStatus(user.id, user.status)}
>
{user.status === 'active' ? <UserX size={14} className="btn-icon" /> : <UserCheck size={14} className="btn-icon" />}
{user.status === 'active' ? t('txt_ban') : t('txt_unban')}
</button>
{user.role !== 'admin' && (
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteUser(user.id)}>
<Trash2 size={14} className="btn-icon" />
{t('txt_delete')}
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</section>
<section className="card">
<div className="section-head">
<h3>{t('txt_invites')}</h3>
<button type="button" className="btn btn-secondary" onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync')}
</button>
</div>
<div className="invite-toolbar">
<div className="actions invite-create-group">
<label className="field invite-hours-field">
<span>{t('txt_invite_validity_hours')}</span>
<input
className="input small"
type="number"
value={inviteHours}
min={1}
max={720}
onInput={(e) => setInviteHours(Number((e.currentTarget as HTMLInputElement).value || 168))}
/>
</label>
<button type="button" className="btn btn-primary" onClick={() => void props.onCreateInvite(inviteHours)}>
<Plus size={14} className="btn-icon" />
{t('txt_create_timed_invite')}
</button>
</div>
<button type="button" className="btn btn-danger" onClick={() => void props.onDeleteAllInvites()}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_all')}
</button>
</div>
<table className="table">
<thead>
<tr>
<th>{t('txt_code')}</th>
<th>{t('txt_status')}</th>
<th>{t('txt_expires_at')}</th>
<th className="invite-actions-head">{t('txt_actions')}</th>
</tr>
</thead>
<tbody>
{pagedInvites.map((invite) => (
<tr key={invite.code}>
<td>{invite.code}</td>
<td>{statusText(invite.status)}</td>
<td>{formatExpiresAt(invite.expiresAt)}</td>
<td>
<div className="actions invite-row-actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => navigator.clipboard.writeText(invite.inviteLink || '')}
>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy_link')}
</button>
{invite.status === 'active' && (
<button type="button" className="btn btn-danger" onClick={() => void props.onRevokeInvite(invite.code)}>
<Trash2 size={14} className="btn-icon" /> {t('txt_revoke')}
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
<div className="actions">
<button type="button" className="btn btn-secondary small" disabled={safePage <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft size={14} className="btn-icon" />
{t('txt_prev')}
</button>
<span className="muted-inline">{safePage} / {totalPages}</span>
<button type="button" className="btn btn-secondary small" disabled={safePage >= totalPages} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}>
{t('txt_next')}
<ChevronRight size={14} className="btn-icon" />
</button>
</div>
</section>
</div>
);
}
+177
View File
@@ -0,0 +1,177 @@
import { useState } from 'preact/hooks';
import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n';
interface LoginValues {
email: string;
password: string;
}
interface RegisterValues {
name: string;
email: string;
password: string;
password2: string;
inviteCode: string;
}
interface AuthViewsProps {
mode: 'login' | 'register' | 'locked';
loginValues: LoginValues;
registerValues: RegisterValues;
unlockPassword: string;
emailForLock: string;
onChangeLogin: (next: LoginValues) => void;
onChangeRegister: (next: RegisterValues) => void;
onChangeUnlock: (password: string) => void;
onSubmitLogin: () => void;
onSubmitRegister: () => void;
onSubmitUnlock: () => void;
onGotoLogin: () => void;
onGotoRegister: () => void;
onLogout: () => void;
}
function PasswordField(props: {
label: string;
value: string;
onInput: (v: string) => void;
autoFocus?: boolean;
}) {
const [show, setShow] = useState(false);
return (
<label className="field">
<span>{props.label}</span>
<div className="password-wrap">
<input
className="input"
type={show ? 'text' : 'password'}
value={props.value}
onInput={(e) => props.onInput((e.currentTarget as HTMLInputElement).value)}
autoFocus={props.autoFocus}
/>
<button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}>
{show ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</label>
);
}
export default function AuthViews(props: AuthViewsProps) {
if (props.mode === 'locked') {
return (
<div className="auth-page">
<StandalonePageFrame title={t('txt_unlock_vault')}>
<p className="muted standalone-muted">{props.emailForLock}</p>
<PasswordField
label={t('txt_master_password')}
value={props.unlockPassword}
autoFocus
onInput={props.onChangeUnlock}
/>
<button type="button" className="btn btn-primary full" onClick={props.onSubmitUnlock}>
<Unlock size={16} className="btn-icon" />
{t('txt_unlock')}
</button>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onLogout}>
<LogOut size={16} className="btn-icon" />
{t('txt_log_out')}
</button>
</StandalonePageFrame>
</div>
);
}
if (props.mode === 'register') {
return (
<div className="auth-page">
<StandalonePageFrame title={t('txt_create_account')}>
<label className="field">
<span>{t('txt_name')}</span>
<input
className="input"
value={props.registerValues.name}
onInput={(e) =>
props.onChangeRegister({ ...props.registerValues, name: (e.currentTarget as HTMLInputElement).value })
}
/>
</label>
<label className="field">
<span>{t('txt_email')}</span>
<input
className="input"
type="email"
value={props.registerValues.email}
onInput={(e) =>
props.onChangeRegister({ ...props.registerValues, email: (e.currentTarget as HTMLInputElement).value })
}
/>
</label>
<PasswordField
label={t('txt_master_password')}
value={props.registerValues.password}
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password: v })}
/>
<PasswordField
label={t('txt_confirm_master_password')}
value={props.registerValues.password2}
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password2: v })}
/>
<label className="field">
<span>{t('txt_invite_code_optional')}</span>
<input
className="input"
value={props.registerValues.inviteCode}
onInput={(e) =>
props.onChangeRegister({ ...props.registerValues, inviteCode: (e.currentTarget as HTMLInputElement).value })
}
/>
</label>
<button type="button" className="btn btn-primary full" onClick={props.onSubmitRegister}>
<UserPlus size={16} className="btn-icon" />
{t('txt_create_account')}
</button>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoLogin}>
<ArrowLeft size={16} className="btn-icon" />
{t('txt_back_to_login')}
</button>
</StandalonePageFrame>
</div>
);
}
return (
<div className="auth-page">
<StandalonePageFrame title={t('txt_log_in')}>
<label className="field">
<span>{t('txt_email')}</span>
<input
className="input"
type="email"
value={props.loginValues.email}
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
/>
</label>
<PasswordField
label={t('txt_master_password')}
value={props.loginValues.password}
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
autoFocus
/>
<button type="button" className="btn btn-primary full" onClick={props.onSubmitLogin}>
<LogIn size={16} className="btn-icon" />
{t('txt_log_in')}
</button>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister}>
<UserPlus size={16} className="btn-icon" />
{t('txt_create_account')}
</button>
</StandalonePageFrame>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import type { ComponentChildren } from 'preact';
import { Check, X } from 'lucide-preact';
import { t } from '@/lib/i18n';
interface ConfirmDialogProps {
open: boolean;
title: string;
message: string;
showIcon?: boolean;
confirmText?: string;
cancelText?: string;
danger?: boolean;
onConfirm: () => void;
onCancel: () => void;
children?: ComponentChildren;
afterActions?: ComponentChildren;
}
export default function ConfirmDialog(props: ConfirmDialogProps) {
if (!props.open) return null;
return (
<div className="dialog-mask">
<div className="dialog-card">
<h3 className="dialog-title">{props.title}</h3>
<div className="dialog-message">{props.message}</div>
{props.children}
<button
type="button"
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
onClick={props.onConfirm}
>
<Check size={14} className="btn-icon" />
{props.confirmText || t('txt_yes')}
</button>
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}>
<X size={14} className="btn-icon" />
{props.cancelText || t('txt_no')}
</button>
{props.afterActions}
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { Cloud } from 'lucide-preact';
import { t } from '@/lib/i18n';
export default function HelpPage() {
return (
<div className="stack">
<section className="card">
<h3>{t('backup_strategy_title')}</h3>
<div className="empty" style={{ minHeight: 180 }}>
<div style={{ textAlign: 'center' }}>
<Cloud size={34} style={{ color: '#64748b', marginBottom: 8 }} />
<div>{t('backup_strategy_under_construction')}</div>
</div>
</div>
</section>
</div>
);
}
+895
View File
@@ -0,0 +1,895 @@
import { useState } from 'preact/hooks';
import { argon2idAsync } from '@noble/hashes/argon2.js';
import { strFromU8, unzipSync } from 'fflate';
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
import { Archive, ArrowLeftRight, Download, FileJson, FileUp } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog';
import type { CiphersImportPayload } from '@/lib/api';
import {
type EncryptedJsonMode,
EXPORT_FORMATS,
type ExportDownloadPayload,
type ExportFormatId,
type ExportRequest,
} from '@/lib/export-formats';
import {
getFileAcceptBySource,
IMPORT_SOURCES,
type BitwardenJsonInput,
type ImportSourceId,
normalizeBitwardenEncryptedAccountImport,
normalizeBitwardenImport,
parseImportPayloadBySource,
} from '@/lib/import-formats';
import { base64ToBytes, decryptStr, hkdfExpand, pbkdf2 } from '@/lib/crypto';
import { t } from '@/lib/i18n';
import type { Folder } from '@/lib/types';
configureZipJs({ useWebWorkers: false });
export interface ImportAttachmentFile {
sourceCipherId: string | null;
sourceCipherIndex: number | null;
fileName: string;
bytes: Uint8Array;
}
interface ImportPageProps {
onImport: (
payload: CiphersImportPayload,
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
attachments?: ImportAttachmentFile[]
) => Promise<ImportResultSummary>;
onImportEncryptedRaw: (
payload: CiphersImportPayload,
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
attachments?: ImportAttachmentFile[]
) => Promise<ImportResultSummary>;
accountKeys?: { encB64: string; macB64: string } | null;
onNotify: (type: 'success' | 'error', text: string) => void;
folders: Folder[];
onExport: (request: ExportRequest) => Promise<ExportDownloadPayload>;
}
export interface ImportResultSummary {
totalItems: number;
folderCount: number;
typeCounts: Array<{ label: string; count: number }>;
}
interface BitwardenPasswordProtectedInput extends BitwardenJsonInput {
encrypted: true;
passwordProtected: true;
salt?: string;
kdfIterations?: number;
kdfMemory?: number;
kdfParallelism?: number;
kdfType?: number;
data?: string;
}
const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [
'bitwarden_json',
'bitwarden_csv',
'bitwarden_zip',
'nodewarden_json',
'onepassword_1pux',
'onepassword_1pif',
'onepassword_mac_csv',
'onepassword_win_csv',
'protonpass_json',
'chrome',
'edge',
'brave',
'opera',
'vivaldi',
'firefox_csv',
'safari_csv',
'lastpass',
'dashlane_csv',
'dashlane_json',
'keepass_xml',
'keepassx_csv',
];
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function isPasswordProtectedExport(value: unknown): value is BitwardenPasswordProtectedInput {
return isRecord(value) && value.encrypted === true && value.passwordProtected === true;
}
async function derivePasswordProtectedFileKey(
parsed: BitwardenPasswordProtectedInput,
password: string
): Promise<{ enc: Uint8Array; mac: Uint8Array }> {
const salt = String(parsed.salt || '').trim();
const iterations = Number(parsed.kdfIterations || 0);
const kdfType = Number(parsed.kdfType);
if (!salt || !Number.isFinite(iterations) || iterations <= 0) {
throw new Error(t('txt_import_invalid_password_protected_file'));
}
let keyMaterial: Uint8Array;
if (kdfType === 0) {
keyMaterial = await pbkdf2(password, salt, iterations, 32);
} else if (kdfType === 1) {
const memoryMiB = Number(parsed.kdfMemory || 0);
const parallelism = Number(parsed.kdfParallelism || 0);
if (!Number.isFinite(memoryMiB) || memoryMiB <= 0 || !Number.isFinite(parallelism) || parallelism <= 0) {
throw new Error('Invalid Argon2id parameters in export file.');
}
const memoryKiB = Math.floor(memoryMiB * 1024);
const maxmem = memoryKiB * 1024 + 1024 * 1024;
keyMaterial = await argon2idAsync(new TextEncoder().encode(password), new TextEncoder().encode(salt), {
t: Math.floor(iterations),
m: memoryKiB,
p: Math.floor(parallelism),
dkLen: 32,
maxmem,
asyncTick: 10,
});
} else {
throw new Error(`Unsupported kdfType: ${kdfType}`);
}
const enc = await hkdfExpand(keyMaterial, 'enc', 32);
const mac = await hkdfExpand(keyMaterial, 'mac', 32);
return { enc, mac };
}
async function decryptPasswordProtectedExport(parsed: BitwardenPasswordProtectedInput, password: string): Promise<unknown> {
if (!parsed.encKeyValidation_DO_NOT_EDIT || !parsed.data) {
throw new Error(t('txt_import_invalid_password_protected_file'));
}
const pass = String(password || '').trim();
if (!pass) {
throw new Error(t('txt_import_file_password_required'));
}
const key = await derivePasswordProtectedFileKey(parsed, pass);
try {
await decryptStr(parsed.encKeyValidation_DO_NOT_EDIT, key.enc, key.mac);
} catch {
throw new Error('Invalid file password.');
}
const plainJson = await decryptStr(parsed.data, key.enc, key.mac);
try {
return JSON.parse(plainJson);
} catch {
throw new Error(t('txt_import_decrypt_failed'));
}
}
function isZipPayload(bytes: Uint8Array): boolean {
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
}
function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
const unzipped = unzipSync(bytes);
const fileNames = Object.keys(unzipped);
if (!fileNames.length) throw new Error(t('txt_import_empty_zip_archive'));
const preferred = source === 'onepassword_1pux' ? ['export.data', 'export.json'] : ['protonpass.json', 'export.json'];
for (const p of preferred) {
const hit = fileNames.find((n) => n.toLowerCase().endsWith(p.toLowerCase()));
if (hit) return strFromU8(unzipped[hit]);
}
const firstJson = fileNames.find((n) => n.toLowerCase().endsWith('.json') || n.toLowerCase().endsWith('.data'));
if (firstJson) return strFromU8(unzipped[firstJson]);
throw new Error(t('txt_import_no_json_found_in_zip'));
}
async function readImportText(file: File, source: ImportSourceId): Promise<string> {
if (source !== 'onepassword_1pux' && source !== 'protonpass_json') {
return file.text();
}
const bytes = new Uint8Array(await file.arrayBuffer());
if (isZipPayload(bytes)) return readZipText(bytes, source);
return new TextDecoder().decode(bytes);
}
interface PendingPasswordImportContext {
parsed: BitwardenPasswordProtectedInput;
source: 'bitwarden_json' | 'nodewarden_json' | 'bitwarden_zip';
attachments: ImportAttachmentFile[];
}
class ZipNeedsPasswordError extends Error {}
class ZipInvalidPasswordError extends Error {}
function looksLikeZipPasswordError(error: unknown): boolean {
const message = error instanceof Error ? String(error.message || '').toLowerCase() : '';
if (!message) return false;
return message.includes('password') || message.includes('encrypted');
}
async function readBitwardenZipPayload(
file: File,
passwordRaw: string
): Promise<{ jsonText: string; attachments: ImportAttachmentFile[] }> {
const password = String(passwordRaw || '').trim();
const reader = new ZipReader(new BlobReader(file), { useWebWorkers: false });
try {
const entries = await reader.getEntries();
if (!entries.length) throw new Error(t('txt_import_empty_zip_archive'));
let jsonText = '';
const attachments: ImportAttachmentFile[] = [];
const options = password ? { password } : undefined;
for (const entry of entries) {
if (entry.directory) continue;
const name = String(entry.filename || '').trim().replace(/\\/g, '/');
if (!name) continue;
const bytes = await entry.getData(new Uint8ArrayWriter(), options);
const lower = name.toLowerCase();
if (lower === 'data.json') {
jsonText = new TextDecoder().decode(bytes);
continue;
}
const attachmentMatch = name.match(/^attachments\/([^/]+)\/(.+)$/i);
if (!attachmentMatch) continue;
const sourceCipherId = String(attachmentMatch[1] || '').trim() || null;
const fileName = String(attachmentMatch[2] || '').trim() || 'attachment.bin';
attachments.push({
sourceCipherId,
sourceCipherIndex: null,
fileName,
bytes,
});
}
if (!jsonText) throw new Error(t('txt_import_data_json_not_found'));
return { jsonText, attachments };
} catch (error) {
if (looksLikeZipPasswordError(error)) {
if (!password) throw new ZipNeedsPasswordError(t('txt_import_zip_password_required'));
throw new ZipInvalidPasswordError(t('txt_import_invalid_zip_password'));
}
if (!password && error instanceof Error && /invalid|corrupt|unsupported/.test(error.message.toLowerCase())) {
throw error;
}
throw error;
} finally {
await reader.close();
}
}
function parseNodeWardenAttachmentArray(raw: unknown): ImportAttachmentFile[] {
if (!Array.isArray(raw)) return [];
const out: ImportAttachmentFile[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== 'object') continue;
const row = entry as Record<string, unknown>;
const fileName = String(row.fileName || '').trim() || 'attachment.bin';
const base64 = String(row.data || '').trim();
if (!base64) continue;
try {
const bytes = base64ToBytes(base64);
const sourceCipherId = String(row.cipherId || '').trim() || null;
const indexRaw = Number(row.cipherIndex);
out.push({
sourceCipherId,
sourceCipherIndex: Number.isFinite(indexRaw) ? indexRaw : null,
fileName,
bytes,
});
} catch {
// skip malformed attachment row
}
}
return out;
}
export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys, onNotify, folders, onExport }: ImportPageProps) {
const [source, setSource] = useState<ImportSourceId>('bitwarden_json');
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPasswordSubmitting, setIsPasswordSubmitting] = useState(false);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const [importPassword, setImportPassword] = useState('');
const [pendingPasswordImport, setPendingPasswordImport] = useState<PendingPasswordImportContext | null>(null);
const [zipPasswordDialogOpen, setZipPasswordDialogOpen] = useState(false);
const [zipImportPassword, setZipImportPassword] = useState('');
const [pendingZipFile, setPendingZipFile] = useState<File | null>(null);
const [isZipPasswordSubmitting, setIsZipPasswordSubmitting] = useState(false);
const [folderMode, setFolderMode] = useState<'original' | 'none' | 'target'>('original');
const [targetFolderId, setTargetFolderId] = useState('');
const [exportFormat, setExportFormat] = useState<ExportFormatId>('bitwarden_json');
const [encryptedJsonMode, setEncryptedJsonMode] = useState<EncryptedJsonMode>('account');
const [exportPassword, setExportPassword] = useState('');
const [zipPassword, setZipPassword] = useState('');
const [isExporting, setIsExporting] = useState(false);
const [exportAuthDialogOpen, setExportAuthDialogOpen] = useState(false);
const [exportAuthPassword, setExportAuthPassword] = useState('');
const [importSummary, setImportSummary] = useState<ImportResultSummary | null>(null);
const commonSourceSet = new Set<ImportSourceId>(COMMON_IMPORT_SOURCE_IDS);
const commonSources = IMPORT_SOURCES.filter((item) => commonSourceSet.has(item.id as ImportSourceId));
const otherSources = IMPORT_SOURCES.filter((item) => !commonSourceSet.has(item.id as ImportSourceId));
async function runBitwardenJsonImport(parsed: unknown, attachments: ImportAttachmentFile[] = []): Promise<ImportResultSummary> {
if (isRecord(parsed) && parsed.encrypted === true) {
const accountEncrypted = parsed as BitwardenJsonInput;
if (!accountKeys?.encB64 || !accountKeys?.macB64) {
throw new Error('Vault key unavailable. Please unlock vault and try again.');
}
const validation = String(accountEncrypted.encKeyValidation_DO_NOT_EDIT || '').trim();
if (!validation) throw new Error('Invalid encrypted export file.');
const accountEncKey = base64ToBytes(accountKeys.encB64);
const accountMacKey = base64ToBytes(accountKeys.macB64);
try {
await decryptStr(validation, accountEncKey, accountMacKey);
} catch {
throw new Error('This encrypted export belongs to another account.');
}
return onImportEncryptedRaw(
normalizeBitwardenEncryptedAccountImport(accountEncrypted),
{
folderMode,
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
},
attachments
);
}
return onImport(
normalizeBitwardenImport(parsed),
{
folderMode,
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
},
attachments
);
}
async function extractNodeWardenAttachments(parsed: unknown): Promise<ImportAttachmentFile[]> {
if (!isRecord(parsed)) return [];
const direct = parseNodeWardenAttachmentArray(parsed.nodewardenAttachments);
if (direct.length) return direct;
const encryptedPayload = String(parsed.nodewardenAttachmentsEnc || '').trim();
if (!encryptedPayload) return [];
if (!accountKeys?.encB64 || !accountKeys?.macB64) {
throw new Error('Vault key unavailable. Please unlock vault and try again.');
}
const accountEnc = base64ToBytes(accountKeys.encB64);
const accountMac = base64ToBytes(accountKeys.macB64);
const plain = await decryptStr(encryptedPayload, accountEnc, accountMac);
const unpacked = JSON.parse(plain) as Record<string, unknown>;
return parseNodeWardenAttachmentArray(unpacked.nodewardenAttachments);
}
async function runNodeWardenJsonImport(parsed: unknown, extraAttachments: ImportAttachmentFile[] = []): Promise<ImportResultSummary> {
const bundled = await extractNodeWardenAttachments(parsed);
return runBitwardenJsonImport(parsed, [...bundled, ...extraAttachments]);
}
async function processPasswordProtectedImport(ctx: PendingPasswordImportContext): Promise<ImportResultSummary> {
const parsed = await decryptPasswordProtectedExport(ctx.parsed, importPassword);
if (ctx.source === 'nodewarden_json') {
return runNodeWardenJsonImport(parsed, ctx.attachments);
}
return runBitwardenJsonImport(parsed, ctx.attachments);
}
async function handleSubmit() {
if (!file) {
onNotify('error', t('txt_please_select_a_file'));
return;
}
setIsSubmitting(true);
try {
if (source === 'bitwarden_zip') {
try {
const bundle = await readBitwardenZipPayload(file, '');
let parsed: unknown;
try {
parsed = JSON.parse(bundle.jsonText);
} catch {
throw new Error(t('txt_import_invalid_json_file'));
}
if (isPasswordProtectedExport(parsed)) {
setPendingPasswordImport({
parsed,
source: 'bitwarden_zip',
attachments: bundle.attachments,
});
setImportPassword('');
setPasswordDialogOpen(true);
return;
}
const summary = await runBitwardenJsonImport(parsed, bundle.attachments);
setImportSummary(summary);
setFile(null);
return;
} catch (error) {
if (error instanceof ZipNeedsPasswordError) {
setPendingZipFile(file);
setZipImportPassword('');
setZipPasswordDialogOpen(true);
return;
}
throw error;
}
}
const text = await readImportText(file, source);
if (source === 'bitwarden_json' || source === 'nodewarden_json') {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new Error(t('txt_import_invalid_json_file'));
}
if (isPasswordProtectedExport(parsed)) {
setPendingPasswordImport({
parsed,
source,
attachments: [],
});
setImportPassword('');
setPasswordDialogOpen(true);
return;
}
const summary =
source === 'nodewarden_json'
? await runNodeWardenJsonImport(parsed)
: await runBitwardenJsonImport(parsed);
setImportSummary(summary);
} else {
const summary = await onImport(
parseImportPayloadBySource(source, text),
{
folderMode,
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
},
[]
);
setImportSummary(summary);
}
setFile(null);
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_import_failed');
onNotify('error', message);
} finally {
setIsSubmitting(false);
}
}
async function handlePasswordImportConfirm() {
if (!pendingPasswordImport) return;
setIsPasswordSubmitting(true);
try {
const summary = await processPasswordProtectedImport(pendingPasswordImport);
setImportSummary(summary);
setFile(null);
setImportPassword('');
setPendingPasswordImport(null);
setPasswordDialogOpen(false);
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_import_failed');
onNotify('error', message);
} finally {
setIsPasswordSubmitting(false);
}
}
async function handleZipPasswordImportConfirm() {
if (!pendingZipFile) return;
setIsZipPasswordSubmitting(true);
try {
const bundle = await readBitwardenZipPayload(pendingZipFile, zipImportPassword);
let parsed: unknown;
try {
parsed = JSON.parse(bundle.jsonText);
} catch {
throw new Error(t('txt_import_invalid_json_file'));
}
if (isPasswordProtectedExport(parsed)) {
setPendingPasswordImport({
parsed,
source: 'bitwarden_zip',
attachments: bundle.attachments,
});
setImportPassword('');
setPasswordDialogOpen(true);
} else {
const summary = await runBitwardenJsonImport(parsed, bundle.attachments);
setImportSummary(summary);
setFile(null);
}
setZipPasswordDialogOpen(false);
setPendingZipFile(null);
setZipImportPassword('');
} catch (error) {
if (error instanceof ZipInvalidPasswordError) {
onNotify('error', t('txt_import_invalid_zip_password'));
return;
}
const message = error instanceof Error ? error.message : t('txt_import_failed');
onNotify('error', message);
} finally {
setIsZipPasswordSubmitting(false);
}
}
const exportNeedsMode =
exportFormat === 'bitwarden_encrypted_json' ||
exportFormat === 'bitwarden_encrypted_json_zip' ||
exportFormat === 'nodewarden_encrypted_json';
const exportNeedsFilePassword = exportNeedsMode && encryptedJsonMode === 'password';
const exportIsZip = exportFormat === 'bitwarden_json_zip' || exportFormat === 'bitwarden_encrypted_json_zip';
async function runExportWithMasterPassword(masterPassword: string) {
const filePassword = exportPassword.trim();
const zipPass = zipPassword.trim();
if (exportNeedsFilePassword && !filePassword) {
onNotify('error', t('txt_import_file_password_required'));
return;
}
setIsExporting(true);
try {
const payload = await onExport({
format: exportFormat,
encryptedJsonMode: exportNeedsMode ? encryptedJsonMode : undefined,
filePassword,
zipPassword: exportIsZip ? zipPass : '',
masterPassword,
});
const blobBytes = Uint8Array.from(payload.bytes);
const blob = new Blob([blobBytes], { type: payload.mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = payload.fileName;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
onNotify('success', t('txt_export_completed'));
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_export_failed');
onNotify('error', message);
} finally {
setIsExporting(false);
}
}
async function handleExportConfirmPassword() {
const masterPassword = String(exportAuthPassword || '').trim();
if (!masterPassword) {
onNotify('error', t('txt_master_password_is_required'));
return;
}
await runExportWithMasterPassword(masterPassword);
if (!isExporting) {
setExportAuthPassword('');
setExportAuthDialogOpen(false);
}
}
function handleExport() {
setExportAuthPassword('');
setExportAuthDialogOpen(true);
}
return (
<div className="import-export-page">
<section className="card import-export-hero">
<h3>{t('txt_import_export_title')}</h3>
<p className="import-export-hero-sub">{t('txt_import_export_feature_intro')}</p>
<div className="import-export-feature-grid">
<article className="import-export-feature-item">
<span className="import-export-feature-icon">
<Archive size={16} />
</span>
<div>
<strong>{t('txt_import_export_feature_bw_zip_title')}</strong>
<p>{t('txt_import_export_feature_bw_zip_desc')}</p>
</div>
</article>
<article className="import-export-feature-item">
<span className="import-export-feature-icon">
<FileJson size={16} />
</span>
<div>
<strong>{t('txt_import_export_feature_nodewarden_json_title')}</strong>
<p>{t('txt_import_export_feature_nodewarden_json_desc')}</p>
</div>
</article>
<article className="import-export-feature-item">
<span className="import-export-feature-icon">
<ArrowLeftRight size={16} />
</span>
<div>
<strong>{t('txt_import_export_feature_compat_title')}</strong>
<p>{t('txt_import_export_feature_compat_desc')}</p>
</div>
</article>
</div>
</section>
<div className="import-export-panels">
<section className="card import-export-panel">
<h3>{t('txt_import')}</h3>
<p className="muted" style={{ textAlign: 'left', marginBottom: 12 }}>
{t('txt_import_vault_data_hint')}
</p>
<div className="field-grid">
<label className="field field-span-2">
<span>{t('txt_format')}</span>
<select className="input" value={source} onChange={(e) => setSource((e.currentTarget as HTMLSelectElement).value as ImportSourceId)}>
{commonSources.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
{otherSources.length > 0 && (
<option disabled value="__separator__">
--------------------
</option>
)}
{otherSources.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
</label>
<label className="field field-span-2">
<span>{t('txt_source_file')}</span>
<input
className="input"
type="file"
accept={getFileAcceptBySource(source)}
onChange={(e) => {
const next = (e.currentTarget as HTMLInputElement).files?.[0] || null;
setFile(next);
}}
/>
</label>
<label className="field field-span-2">
<span>{t('txt_folder_handling')}</span>
<select
className="input"
value={folderMode}
onChange={(e) => setFolderMode((e.currentTarget as HTMLSelectElement).value as 'original' | 'none' | 'target')}
>
<option value="original">{t('txt_import_folder_mode_original')}</option>
<option value="none">{t('txt_import_folder_mode_none')}</option>
<option value="target">{t('txt_import_folder_mode_target')}</option>
</select>
</label>
{folderMode === 'target' && (
<label className="field field-span-2">
<span>{t('txt_target_folder')}</span>
<select className="input" value={targetFolderId} onChange={(e) => setTargetFolderId((e.currentTarget as HTMLSelectElement).value)}>
<option value="">{t('txt_select_folder_placeholder')}</option>
{folders
.slice()
.sort((a, b) => String(a.decName || a.name || '').localeCompare(String(b.decName || b.name || '')))
.map((folder) => (
<option key={folder.id} value={folder.id}>
{folder.decName || folder.name || folder.id}
</option>
))}
</select>
</label>
)}
</div>
<div className="actions">
<button
type="button"
className="btn btn-primary"
disabled={isSubmitting || (folderMode === 'target' && !targetFolderId)}
onClick={() => void handleSubmit()}
>
<FileUp size={15} /> {isSubmitting ? t('txt_loading') : t('txt_import')}
</button>
</div>
</section>
<section className="card import-export-panel">
<h3>{t('txt_export')}</h3>
<p className="muted" style={{ textAlign: 'left', marginBottom: 12 }}>
{t('txt_export_vault_data_hint')}
</p>
<div className="field-grid">
<label className="field field-span-2">
<span>{t('txt_format')}</span>
<select
className="input"
value={exportFormat}
onChange={(e) => {
const next = (e.currentTarget as HTMLSelectElement).value as ExportFormatId;
setExportFormat(next);
}}
>
{EXPORT_FORMATS.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
</label>
{exportNeedsMode && (
<label className="field field-span-2">
<span>{t('txt_encrypted_mode')}</span>
<select
className="input"
value={encryptedJsonMode}
onChange={(e) => setEncryptedJsonMode((e.currentTarget as HTMLSelectElement).value as EncryptedJsonMode)}
>
<option value="account">{t('txt_account_verification')}</option>
<option value="password">{t('txt_password_verification')}</option>
</select>
</label>
)}
{exportNeedsFilePassword && (
<label className="field field-span-2">
<span>{t('txt_file_password')}</span>
<input
className="input"
type="password"
value={exportPassword}
onInput={(e) => setExportPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
)}
{exportIsZip && (
<label className="field field-span-2">
<span>{t('txt_zip_password_optional')}</span>
<input
className="input"
type="password"
value={zipPassword}
onInput={(e) => setZipPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
)}
</div>
<div className="actions">
<button type="button" className="btn btn-primary" disabled={isExporting} onClick={() => void handleExport()}>
<Download size={15} className="btn-icon" />
{isExporting ? t('txt_loading') : t('txt_export')}
</button>
</div>
</section>
</div>
<ConfirmDialog
open={exportAuthDialogOpen}
title={t('txt_export')}
message={t('txt_enter_master_password_to_view_this_item')}
confirmText={isExporting ? t('txt_loading') : t('txt_verify')}
cancelText={t('txt_cancel')}
showIcon={false}
onConfirm={() => void handleExportConfirmPassword()}
onCancel={() => {
if (isExporting) return;
setExportAuthDialogOpen(false);
setExportAuthPassword('');
}}
>
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
value={exportAuthPassword}
onInput={(e) => setExportAuthPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
<ConfirmDialog
open={passwordDialogOpen}
title={t('txt_import_encrypted_file_title')}
message={t('txt_import_encrypted_file_message')}
confirmText={isPasswordSubmitting ? t('txt_loading') : t('txt_import')}
cancelText={t('txt_cancel')}
showIcon={false}
onConfirm={() => void handlePasswordImportConfirm()}
onCancel={() => {
if (isPasswordSubmitting) return;
setPasswordDialogOpen(false);
setImportPassword('');
setPendingPasswordImport(null);
}}
>
<label className="field">
<span>{t('txt_file_password')}</span>
<input
className="input"
type="password"
value={importPassword}
onInput={(e) => setImportPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
<ConfirmDialog
open={zipPasswordDialogOpen}
title={t('txt_import_encrypted_zip_title')}
message={t('txt_import_encrypted_zip_message')}
confirmText={isZipPasswordSubmitting ? t('txt_loading') : t('txt_import')}
cancelText={t('txt_cancel')}
showIcon={false}
onConfirm={() => void handleZipPasswordImportConfirm()}
onCancel={() => {
if (isZipPasswordSubmitting) return;
setZipPasswordDialogOpen(false);
setZipImportPassword('');
setPendingZipFile(null);
}}
>
<label className="field">
<span>{t('txt_zip_password')}</span>
<input
className="input"
type="password"
value={zipImportPassword}
onInput={(e) => setZipImportPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
{importSummary && (
<div className="dialog-mask">
<section className="dialog-card import-summary-dialog">
<button
type="button"
className="import-summary-close"
onClick={() => setImportSummary(null)}
aria-label={t('txt_close')}
>
X
</button>
<h3 className="dialog-title">{t('txt_import_success')}</h3>
<div className="dialog-message">{t('txt_import_success_number_of_items', { count: importSummary.totalItems })}</div>
<div className="import-summary-table-wrap">
<table className="import-summary-table">
<thead>
<tr>
<th>{t('txt_type')}</th>
<th>{t('txt_total')}</th>
</tr>
</thead>
<tbody>
{importSummary.typeCounts.map((row) => (
<tr key={row.label}>
<td>{row.label}</td>
<td>{row.count}</td>
</tr>
))}
<tr>
<td>{t('txt_folder')}</td>
<td>{importSummary.folderCount}</td>
</tr>
</tbody>
</table>
</div>
<button type="button" className="btn btn-primary dialog-btn" onClick={() => setImportSummary(null)}>
{t('txt_confirm')}
</button>
</section>
</div>
)}
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { useMemo, useState } from 'preact/hooks';
import { AlertTriangle, Copy, RefreshCw } from 'lucide-preact';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n';
interface JwtWarningPageProps {
reason: 'missing' | 'default' | 'too_short';
minLength: number;
}
export default function JwtWarningPage(props: JwtWarningPageProps) {
const [seed, setSeed] = useState(0);
const [copyHint, setCopyHint] = useState('');
const generatedSecret = useMemo(() => generateJwtSecret(32), [seed]);
const title =
props.reason === 'missing'
? t('txt_jwt_title_missing')
: props.reason === 'default'
? t('txt_jwt_title_default')
: t('txt_jwt_title_too_short');
const isMissing = props.reason === 'missing';
const fixTitle = isMissing ? t('txt_jwt_how_to_fix_add') : t('txt_jwt_how_to_fix_replace');
const fixStep1 = isMissing ? t('txt_jwt_add_step_1') : t('txt_jwt_replace_step_1', { min: props.minLength });
const fixStep2 = isMissing ? t('txt_jwt_add_step_2') : t('txt_jwt_replace_step_2');
const fixStep3 = isMissing ? t('txt_jwt_add_step_3') : t('txt_jwt_replace_step_3');
return (
<div className="auth-page">
<StandalonePageFrame title={title}>
<div className="jwt-warning-head">
<AlertTriangle size={20} />
<strong>{t('txt_jwt_warning_subtitle')}</strong>
</div>
<div className="jwt-warning-box">
<div className="jwt-warning-label">{fixTitle}</div>
<ol className="jwt-warning-list">
<li>{fixStep1}</li>
<li>{fixStep2}</li>
<li>{fixStep3}</li>
</ol>
<div className="jwt-generator">
<div className="jwt-warning-label">{t('txt_random_secret_generator')}</div>
<input className="input input-readonly" readOnly value={generatedSecret} />
<div className="jwt-generator-actions">
<button type="button" className="btn btn-primary" onClick={() => setSeed((v) => v + 1)}>
<RefreshCw size={15} className="btn-icon" />
{t('txt_regenerate')}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={async () => {
await navigator.clipboard.writeText(generatedSecret);
setCopyHint(t('txt_copied'));
window.setTimeout(() => setCopyHint(''), 1500);
}}
>
<Copy size={15} className="btn-icon" />
{t('txt_copy')}
</button>
{copyHint && <span className="jwt-copy-hint">{copyHint}</span>}
</div>
</div>
</div>
</StandalonePageFrame>
</div>
);
}
function generateJwtSecret(length: number): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
const bytes = crypto.getRandomValues(new Uint8Array(length));
let out = '';
for (let i = 0; i < length; i += 1) {
out += chars[bytes[i] % chars.length];
}
return out;
}
+144
View File
@@ -0,0 +1,144 @@
import { useEffect, useState } from 'preact/hooks';
import { Download, Eye, Lock } from 'lucide-preact';
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n';
interface PublicSendPageProps {
accessId: string;
keyPart: string | null;
}
export default function PublicSendPage(props: PublicSendPageProps) {
const [loading, setLoading] = useState(true);
const [password, setPassword] = useState('');
const [needPassword, setNeedPassword] = useState(false);
const [error, setError] = useState('');
const [sendData, setSendData] = useState<any>(null);
const [busy, setBusy] = useState(false);
async function loadSend(pass?: string): Promise<void> {
setBusy(true);
setError('');
try {
const data = await accessPublicSend(props.accessId, props.keyPart, pass);
if (!props.keyPart) {
setError(t('txt_this_link_is_missing_decryption_key'));
setSendData(null);
return;
}
const decrypted = await decryptPublicSend(data, props.keyPart);
setSendData(decrypted);
setNeedPassword(false);
} catch (e) {
const err = e as Error & { status?: number };
if (err.status === 401) {
setNeedPassword(true);
setError(t('txt_this_send_is_password_protected'));
} else {
setError(err.message || t('txt_failed_to_open_send'));
}
setSendData(null);
} finally {
setBusy(false);
setLoading(false);
}
}
async function downloadFile(): Promise<void> {
if (!sendData?.id || !sendData?.file?.id) return;
setBusy(true);
setError('');
try {
const url = await accessPublicSendFile(sendData.id, sendData.file.id, props.keyPart, password || undefined);
const resp = await fetch(url);
if (!resp.ok) throw new Error(t('txt_download_failed'));
const encryptedBytes = await resp.arrayBuffer();
let blob: Blob;
if (props.keyPart) {
try {
const decryptedBytes = await decryptPublicSendFileBytes(encryptedBytes, props.keyPart);
blob = new Blob([decryptedBytes as unknown as BlobPart], { type: 'application/octet-stream' });
} catch {
// Legacy compatibility: early web-created file sends uploaded plaintext bytes.
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
}
} else {
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
}
const obj = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = obj;
a.download = sendData.decFileName || sendData.file?.fileName || t('txt_send_file');
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(obj);
} catch (e) {
const err = e as Error;
setError(err.message || t('txt_download_failed'));
} finally {
setBusy(false);
}
}
useEffect(() => {
void loadSend();
}, [props.accessId, props.keyPart]);
return (
<div className="auth-page public-send-page">
<StandalonePageFrame title={t('txt_nodewarden_send')}>
{loading && <p className="muted">{t('txt_loading')}</p>}
{!loading && needPassword && (
<>
<label className="field">
<span>{t('txt_password')}</span>
<div className="password-wrap">
<input
className="input"
type="password"
value={password}
onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}
/>
</div>
</label>
<button type="button" className="btn btn-primary full" disabled={busy} onClick={() => void loadSend(password)}>
<Lock size={14} className="btn-icon" /> {t('txt_unlock_send')}
</button>
</>
)}
{!loading && sendData && (
<>
<h2 style={{ marginTop: '8px' }}>{sendData.decName || t('txt_no_name')}</h2>
{sendData.type === 0 ? (
<div className="card" style={{ marginTop: '10px' }}>
<div className="notes">{sendData.decText || ''}</div>
</div>
) : (
<div className="card" style={{ marginTop: '10px' }}>
<div className="kv-line">
<span>{t('txt_file')}</span>
<strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || t('txt_encrypted_file')}</strong>
</div>
<button type="button" className="btn btn-primary full" disabled={busy} onClick={() => void downloadFile()}>
<Download size={14} className="btn-icon" /> {t('txt_download')}
</button>
</div>
)}
{!!sendData.expirationDate && <p className="muted">{t('txt_expires_at_value', { value: sendData.expirationDate })}</p>}
</>
)}
{!loading && !sendData && !needPassword && !error && (
<p className="muted">
<Eye size={14} style={{ verticalAlign: 'text-bottom' }} /> {t('txt_send_unavailable')}
</p>
)}
{!!error && <p className="local-error">{error}</p>}
</StandalonePageFrame>
</div>
);
}
@@ -0,0 +1,68 @@
import { useState } from 'preact/hooks';
import { Eye, EyeOff, Send, X } from 'lucide-preact';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n';
interface RecoverTwoFactorPageProps {
values: { email: string; password: string; recoveryCode: string };
onChange: (next: { email: string; password: string; recoveryCode: string }) => void;
onSubmit: () => void;
onCancel: () => void;
}
export default function RecoverTwoFactorPage(props: RecoverTwoFactorPageProps) {
const [showPassword, setShowPassword] = useState(false);
return (
<div className="auth-page">
<StandalonePageFrame title={t('txt_recover_two_step_login')}>
<p className="muted standalone-muted">{t('txt_use_your_one_time_recovery_code_to_disable_two_step_verification')}</p>
<label className="field">
<span>{t('txt_email')}</span>
<input
className="input"
type="email"
value={props.values.email}
onInput={(e) => props.onChange({ ...props.values, email: (e.currentTarget as HTMLInputElement).value })}
/>
</label>
<label className="field">
<span>{t('txt_master_password')}</span>
<div className="password-wrap">
<input
className="input"
type={showPassword ? 'text' : 'password'}
value={props.values.password}
onInput={(e) => props.onChange({ ...props.values, password: (e.currentTarget as HTMLInputElement).value })}
/>
<button type="button" className="eye-btn" onClick={() => setShowPassword((v) => !v)}>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</label>
<label className="field">
<span>{t('txt_recovery_code')}</span>
<input
className="input"
value={props.values.recoveryCode}
onInput={(e) => props.onChange({ ...props.values, recoveryCode: (e.currentTarget as HTMLInputElement).value.toUpperCase() })}
/>
</label>
<div className="field-grid">
<button type="button" className="btn btn-primary" onClick={props.onSubmit}>
<Send size={14} className="btn-icon" />
{t('txt_submit')}
</button>
<button type="button" className="btn btn-secondary" onClick={props.onCancel}>
<X size={14} className="btn-icon" />
{t('txt_cancel')}
</button>
</div>
</StandalonePageFrame>
</div>
);
}
@@ -0,0 +1,130 @@
import { Clock3, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
import type { AuthorizedDevice } from '@/lib/types';
import { t } from '@/lib/i18n';
interface SecurityDevicesPageProps {
devices: AuthorizedDevice[];
loading: boolean;
onRefresh: () => void;
onRevokeTrust: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void;
onRevokeAll: () => void;
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return t('txt_dash');
const date = new Date(value);
if (Number.isNaN(date.getTime())) return t('txt_dash');
return date.toLocaleString();
}
function mapDeviceTypeName(type: number): string {
switch (type) {
case 0: return t('txt_android');
case 1: return t('txt_ios');
case 2: return t('txt_chrome_extension');
case 3: return t('txt_firefox_extension');
case 4: return t('txt_opera_extension');
case 5: return t('txt_edge_extension');
case 6: return t('txt_windows_desktop');
case 7: return t('txt_macos_desktop');
case 8: return t('txt_linux_desktop');
case 9: return t('txt_chrome_browser');
case 10: return t('txt_firefox_browser');
case 11: return t('txt_opera_browser');
case 12: return t('txt_edge_browser');
case 13: return t('txt_ie_browser');
case 14: return t('txt_web');
default: return t('txt_type_type', { type });
}
}
export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
return (
<div className="stack">
<section className="card">
<div className="section-head">
<div>
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
<div className="muted-inline" style={{ marginTop: 4 }}>
{t('txt_manage_authorized_devices_and_30_day_totp_trusted_sessions')}
</div>
</div>
<div className="actions">
<button type="button" className="btn btn-secondary small" onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')}
</button>
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
<ShieldOff size={14} className="btn-icon" />
{t('txt_revoke_all_trusted')}
</button>
</div>
</div>
</section>
<section className="card">
<h3 style={{ marginTop: 0 }}>{t('txt_authorized_devices')}</h3>
<table className="table">
<thead>
<tr>
<th>{t('txt_device')}</th>
<th>{t('txt_type')}</th>
<th>{t('txt_added')}</th>
<th>{t('txt_last_seen')}</th>
<th>{t('txt_trusted_until')}</th>
<th>{t('txt_actions')}</th>
</tr>
</thead>
<tbody>
{props.devices.map((device) => (
<tr key={device.identifier}>
<td>
<div>{device.name || t('txt_unknown_device')}</div>
<div className="muted-inline">{device.identifier}</div>
</td>
<td>{mapDeviceTypeName(device.type)}</td>
<td>{formatDateTime(device.creationDate)}</td>
<td>{formatDateTime(device.revisionDate)}</td>
<td>
{device.trusted ? (
<div className="trusted-cell">
<Clock3 size={13} />
<span>{formatDateTime(device.trustedUntil)}</span>
</div>
) : (
<span className="muted-inline">{t('txt_not_trusted')}</span>
)}
</td>
<td>
<div className="actions">
<button
type="button"
className="btn btn-secondary small"
disabled={!device.trusted}
onClick={() => props.onRevokeTrust(device)}
>
<ShieldOff size={14} className="btn-icon" />
{t('txt_revoke_trust')}
</button>
<button type="button" className="btn btn-danger small" onClick={() => props.onRemoveDevice(device)}>
<Trash2 size={14} className="btn-icon" />
{t('txt_remove_device_2')}
</button>
</div>
</td>
</tr>
))}
{!props.loading && props.devices.length === 0 && (
<tr>
<td colSpan={6}>
<div className="empty" style={{ minHeight: 80 }}>{t('txt_no_devices_found')}</div>
</td>
</tr>
)}
</tbody>
</table>
</section>
</div>
);
}
+433
View File
@@ -0,0 +1,433 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { CheckCheck, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
import type { Send, SendDraft } from '@/lib/types';
import { t } from '@/lib/i18n';
interface SendsPageProps {
sends: Send[];
loading: boolean;
onRefresh: () => Promise<void>;
onCreate: (draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
onUpdate: (send: Send, draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
onDelete: (send: Send) => Promise<void>;
onBulkDelete: (ids: string[]) => Promise<void>;
onNotify: (type: 'success' | 'error', text: string) => void;
}
type SendTypeFilter = 'all' | 'text' | 'file';
const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1';
function daysFromNow(iso: string | null | undefined, fallback: number): string {
if (!iso) return String(fallback);
const d = new Date(iso).getTime();
if (!Number.isFinite(d)) return String(fallback);
const diff = d - Date.now();
const days = Math.ceil(diff / (24 * 60 * 60 * 1000));
return String(Math.max(days, 0));
}
function buildDefaultDraft(): SendDraft {
return {
type: 'text',
name: '',
notes: '',
text: '',
file: null,
deletionDays: '7',
expirationDays: '0',
maxAccessCount: '',
password: '',
disabled: false,
};
}
function draftFromSend(send: Send): SendDraft {
return {
id: send.id,
type: Number(send.type) === 1 ? 'file' : 'text',
name: send.decName || '',
notes: send.decNotes || '',
text: send.decText || '',
file: null,
deletionDays: daysFromNow(send.deletionDate, 7),
expirationDays: daysFromNow(send.expirationDate, 0),
maxAccessCount: send.maxAccessCount !== null && send.maxAccessCount !== undefined ? String(send.maxAccessCount) : '',
password: '',
disabled: !!send.disabled,
};
}
export default function SendsPage(props: SendsPageProps) {
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<SendTypeFilter>('all');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [busy, setBusy] = useState(false);
const [draft, setDraft] = useState<SendDraft | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
try {
return localStorage.getItem(AUTO_COPY_KEY) === '1';
} catch {
return false;
}
});
useEffect(() => {
try {
localStorage.setItem(AUTO_COPY_KEY, autoCopyLink ? '1' : '0');
} catch {
// ignore storage errors
}
}, [autoCopyLink]);
const filteredSends = useMemo(() => {
const q = search.trim().toLowerCase();
return props.sends.filter((send) => {
if (typeFilter === 'text' && Number(send.type) !== 0) return false;
if (typeFilter === 'file' && Number(send.type) !== 1) return false;
if (!q) return true;
const name = (send.decName || '').toLowerCase();
const text = (send.decText || '').toLowerCase();
return name.includes(q) || text.includes(q);
});
}, [props.sends, search, typeFilter]);
useEffect(() => {
if (!filteredSends.length) {
setSelectedId(null);
return;
}
if (!selectedId || !filteredSends.some((x) => x.id === selectedId)) {
setSelectedId(filteredSends[0].id);
setIsEditing(false);
setIsCreating(false);
setDraft(null);
}
}, [filteredSends, selectedId]);
const selectedSend = useMemo(
() => props.sends.find((x) => x.id === selectedId) || null,
[props.sends, selectedId]
);
const selectedIds = useMemo(() => Object.keys(selectedMap).filter((id) => selectedMap[id]), [selectedMap]);
const selectedCount = selectedIds.length;
async function saveDraft(): Promise<void> {
if (!draft) return;
if (!draft.name.trim()) {
props.onNotify('error', t('txt_name_is_required'));
return;
}
if (draft.type === 'text' && !draft.text.trim()) {
props.onNotify('error', t('txt_text_is_required'));
return;
}
if (draft.type === 'file' && isCreating && !draft.file) {
props.onNotify('error', t('txt_please_select_a_file'));
return;
}
setBusy(true);
try {
if (isCreating) {
await props.onCreate(draft, autoCopyLink);
setSelectedId(null);
} else if (selectedSend) {
await props.onUpdate(selectedSend, draft, autoCopyLink);
}
setIsEditing(false);
setIsCreating(false);
setDraft(null);
setShowPassword(false);
} finally {
setBusy(false);
}
}
async function removeSend(send: Send): Promise<void> {
setBusy(true);
try {
await props.onDelete(send);
if (selectedId === send.id) setSelectedId(null);
setIsEditing(false);
setDraft(null);
} finally {
setBusy(false);
}
}
async function removeSelected(): Promise<void> {
if (!selectedCount) return;
setBusy(true);
try {
await props.onBulkDelete(selectedIds);
setSelectedMap({});
} finally {
setBusy(false);
}
}
function copyAccessUrl(send: Send): void {
const url = send.shareUrl || `${window.location.origin}/#/send/${send.accessId}`;
void navigator.clipboard.writeText(url);
props.onNotify('success', t('txt_link_copied'));
}
return (
<div className="vault-grid">
<aside className="sidebar">
<div className="sidebar-block">
<div className="sidebar-title">{t('txt_all_sends')}</div>
<button type="button" className={`tree-btn ${typeFilter === 'all' ? 'active' : ''}`} onClick={() => setTypeFilter('all')}>
<LayoutGrid size={14} className="tree-icon" />
<span className="tree-label">{t('txt_all_sends')}</span>
</button>
</div>
<div className="sidebar-block">
<div className="sidebar-title">{t('txt_type')}</div>
<button type="button" className={`tree-btn ${typeFilter === 'text' ? 'active' : ''}`} onClick={() => setTypeFilter('text')}>
<FileText size={14} className="tree-icon" />
<span className="tree-label">{t('txt_text')}</span>
</button>
<button type="button" className={`tree-btn ${typeFilter === 'file' ? 'active' : ''}`} onClick={() => setTypeFilter('file')}>
<File size={14} className="tree-icon" />
<span className="tree-label">{t('txt_file')}</span>
</button>
</div>
</aside>
<section className="list-col">
<div className="list-head">
<input
className="search-input"
placeholder={t('txt_search_sends')}
value={search}
onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}
/>
<button type="button" className="btn btn-secondary small" disabled={busy || props.loading} onClick={() => void props.onRefresh()}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_refresh')}
</button>
</div>
<div className="toolbar actions">
<button type="button" className="btn btn-danger small" disabled={!selectedCount || busy} onClick={() => void removeSelected()}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_selected')}
</button>
<button
type="button"
className="btn btn-secondary small"
disabled={!filteredSends.length}
onClick={() => {
const map: Record<string, boolean> = {};
for (const send of filteredSends) map[send.id] = true;
setSelectedMap(map);
}}
>
<CheckCheck size={14} className="btn-icon" />
{t('txt_select_all')}
</button>
{!!selectedCount && (
<button type="button" className="btn btn-secondary small" onClick={() => setSelectedMap({})}>
<X size={14} className="btn-icon" />
{t('txt_cancel')}
</button>
)}
<button
type="button"
className="btn btn-primary small"
disabled={busy}
onClick={() => {
setIsCreating(true);
setIsEditing(true);
setDraft(buildDefaultDraft());
setShowPassword(false);
}}
>
<Plus size={14} className="btn-icon" /> {t('txt_add')}
</button>
</div>
<div className="list-panel">
{filteredSends.map((send) => (
<div key={send.id} className={`list-item ${selectedId === send.id ? 'active' : ''}`}>
<input
type="checkbox"
className="row-check"
checked={!!selectedMap[send.id]}
onInput={(e) =>
setSelectedMap((prev) => ({
...prev,
[send.id]: (e.currentTarget as HTMLInputElement).checked,
}))
}
/>
<button
type="button"
className="row-main"
onClick={() => {
setSelectedId(send.id);
setIsEditing(false);
setIsCreating(false);
setDraft(null);
}}
>
<div className="list-icon-wrap">
<span className="list-icon-fallback">
<SendIcon />
</span>
</div>
<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">
{Number(send.type) === 1 ? t('txt_file') : t('txt_text')} - {t('txt_accessed_count_times', { count: send.accessCount || 0 })}
</span>
</div>
</button>
</div>
))}
{!filteredSends.length && <div className="empty">{t('txt_no_sends')}</div>}
</div>
</section>
<section className="detail-col">
{isEditing && draft && (
<div className="card">
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
<div className="field-grid">
<label className="field field-span-2">
<span>{t('txt_name')}</span>
<input className="input" value={draft.name} onInput={(e) => setDraft({ ...draft, name: (e.currentTarget as HTMLInputElement).value })} />
</label>
<label className="field field-span-2">
<span>{t('txt_type')}</span>
<div className="send-options">
<label>
<input
type="radio"
checked={draft.type === 'file'}
disabled={!isCreating}
onInput={() => setDraft({ ...draft, type: 'file' })}
/>
{t('txt_file')}
</label>
<label>
<input
type="radio"
checked={draft.type === 'text'}
disabled={!isCreating}
onInput={() => setDraft({ ...draft, type: 'text' })}
/>
{t('txt_text')}
</label>
</div>
</label>
{draft.type === 'file' ? (
<label className="field field-span-2">
<span>{t('txt_file')}</span>
<input className="input" type="file" onInput={(e) => setDraft({ ...draft, file: (e.currentTarget as HTMLInputElement).files?.[0] || null })} />
</label>
) : (
<label className="field field-span-2">
<span>{t('txt_text')}</span>
<textarea className="input textarea" rows={8} value={draft.text} onInput={(e) => setDraft({ ...draft, text: (e.currentTarget as HTMLTextAreaElement).value })} />
</label>
)}
<label className="field">
<span>{t('txt_deletion_days')}</span>
<input className="input" type="number" min="1" max="31" value={draft.deletionDays} onInput={(e) => setDraft({ ...draft, deletionDays: (e.currentTarget as HTMLInputElement).value })} />
</label>
<label className="field">
<span>{t('txt_expiration_days_0_never')}</span>
<input className="input" type="number" min="0" max="3650" value={draft.expirationDays} onInput={(e) => setDraft({ ...draft, expirationDays: (e.currentTarget as HTMLInputElement).value })} />
</label>
<label className="field">
<span>{t('txt_max_access_count')}</span>
<input className="input" value={draft.maxAccessCount} onInput={(e) => setDraft({ ...draft, maxAccessCount: (e.currentTarget as HTMLInputElement).value })} />
</label>
<label className="field">
<span>{t('txt_password')}</span>
<div className="password-wrap">
<input className="input" type={showPassword ? 'text' : 'password'} value={draft.password} onInput={(e) => setDraft({ ...draft, password: (e.currentTarget as HTMLInputElement).value })} />
<button type="button" className="password-toggle" onClick={() => setShowPassword((v) => !v)}>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</label>
<label className="field field-span-2">
<span>{t('txt_notes')}</span>
<textarea className="input textarea" rows={5} value={draft.notes} onInput={(e) => setDraft({ ...draft, notes: (e.currentTarget as HTMLTextAreaElement).value })} />
</label>
<label className="field field-span-2">
<span>{t('txt_options')}</span>
<div className="send-options">
<label><input type="checkbox" checked={draft.disabled} onInput={(e) => setDraft({ ...draft, disabled: (e.currentTarget as HTMLInputElement).checked })} /> {t('txt_disable_this_send')}</label>
<label><input type="checkbox" checked={autoCopyLink} onInput={(e) => setAutoCopyLink((e.currentTarget as HTMLInputElement).checked)} /> {t('txt_auto_copy_link_after_save')}</label>
</div>
</label>
</div>
<div className="detail-actions">
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>
<Save size={14} className="btn-icon" /> {t('txt_save')}
</button>
<button type="button" className="btn btn-secondary small" disabled={busy} onClick={() => { setIsEditing(false); setIsCreating(false); setDraft(null); setShowPassword(false); }}>
<X size={14} className="btn-icon" /> {t('txt_cancel')}
</button>
</div>
</div>
)}
{!isEditing && selectedSend && (
<>
<div className="card">
<h3 className="detail-title">{selectedSend.decName || t('txt_no_name')}</h3>
<div className="detail-sub">{Number(selectedSend.type) === 1 ? t('txt_file_send') : t('txt_text_send')}</div>
</div>
<div className="card">
<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>
<div className="card">
{Number(selectedSend.type) === 1 ? (
<>
<h4>{t('txt_file')}</h4>
<div className="kv-line"><span>{t('txt_file_name')}</span><strong>{selectedSend.file?.fileName || t('txt_encrypted_file_2')}</strong></div>
<div className="kv-line"><span>{t('txt_file_size')}</span><strong>{selectedSend.file?.sizeName || t('txt_dash')}</strong></div>
</>
) : (
<>
<h4>{t('txt_text')}</h4>
<div className="notes">{selectedSend.decText || ''}</div>
</>
)}
</div>
{!!(selectedSend.decNotes || '').trim() && (
<div className="card">
<h4>{t('txt_notes')}</h4>
<div className="notes">{selectedSend.decNotes || ''}</div>
</div>
)}
<div className="detail-actions">
<div className="actions">
<button type="button" className="btn btn-secondary small" onClick={() => copyAccessUrl(selectedSend)}>
<Copy size={14} className="btn-icon" /> {t('txt_copy_link')}
</button>
<button type="button" className="btn btn-secondary small" onClick={() => { setDraft(draftFromSend(selectedSend)); setIsCreating(false); setIsEditing(true); }}>
<Pencil size={14} className="btn-icon" /> {t('txt_edit')}
</button>
</div>
<button type="button" className="btn btn-danger small detail-delete-btn" disabled={busy} onClick={() => void removeSend(selectedSend)}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
</button>
</div>
</>
)}
</section>
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
import qrcode from 'qrcode-generator';
import type { Profile } from '@/lib/types';
import { t } from '@/lib/i18n';
interface SettingsPageProps {
profile: Profile;
totpEnabled: boolean;
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>;
onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onNotify?: (type: 'success' | 'error', text: string) => void;
}
function randomBase32Secret(length: number): string {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const random = crypto.getRandomValues(new Uint8Array(length));
let out = '';
for (const x of random) out += alphabet[x % alphabet.length];
return out;
}
function buildOtpUri(email: string, secret: string): string {
const issuer = 'NodeWarden';
return `otpauth://totp/${encodeURIComponent(`${issuer}:${email}`)}?secret=${encodeURIComponent(secret)}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
}
export default function SettingsPage(props: SettingsPageProps) {
const totpSecretStorageKey = `nodewarden.totp.secret.${props.profile.id}`;
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newPassword2, setNewPassword2] = useState('');
const [secret, setSecret] = useState(() => localStorage.getItem(totpSecretStorageKey) || randomBase32Secret(32));
const [token, setToken] = useState('');
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
const [recoveryCode, setRecoveryCode] = useState('');
useEffect(() => {
if (!props.totpEnabled) {
setTotpLocked(false);
return;
}
setTotpLocked(true);
}, [props.totpEnabled]);
const qrDataUrl = useMemo(() => {
const qr = qrcode(0, 'M');
qr.addData(buildOtpUri(props.profile.email, secret));
qr.make();
const svg = qr.createSvgTag({ scalable: true, margin: 0 });
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}, [props.profile.email, secret]);
async function enableTotp(): Promise<void> {
try {
await props.onEnableTotp(secret, token);
// Secret is now stored on the server; remove plaintext copy from localStorage.
localStorage.removeItem(totpSecretStorageKey);
setTotpLocked(true);
} catch {
// Keep inputs editable after a failed attempt.
}
}
async function loadRecoveryCode(): Promise<void> {
const code = await props.onGetRecoveryCode(recoveryMasterPassword);
setRecoveryCode(code);
props.onNotify?.('success', t('txt_recovery_code_loaded'));
}
return (
<div className="stack">
<section className="card">
<h3>{t('txt_change_master_password')}</h3>
<label className="field">
<span>{t('txt_current_password')}</span>
<input
className="input"
type="password"
value={currentPassword}
onInput={(e) => setCurrentPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
<div className="field-grid">
<label className="field">
<span>{t('txt_new_password')}</span>
<input className="input" type="password" value={newPassword} onInput={(e) => setNewPassword((e.currentTarget as HTMLInputElement).value)} />
</label>
<label className="field">
<span>{t('txt_confirm_password')}</span>
<input className="input" type="password" value={newPassword2} onInput={(e) => setNewPassword2((e.currentTarget as HTMLInputElement).value)} />
</label>
</div>
<button
type="button"
className="btn btn-danger"
onClick={() => void props.onChangePassword(currentPassword, newPassword, newPassword2)}
>
<KeyRound size={14} className="btn-icon" />
{t('txt_change_password')}
</button>
</section>
<section className="card">
<div className="settings-twofactor-grid">
<div className="settings-subcard">
<h3>{t('txt_totp')}</h3>
{totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>}
<div className="totp-grid">
<div className="totp-qr">
<img src={qrDataUrl} alt="TOTP QR" />
</div>
<div>
<div>
<label className="field">
<span>{t('txt_authenticator_key')}</span>
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
</label>
<label className="field">
<span>{t('txt_verification_code')}</span>
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
</label>
<div className="actions">
<button type="button" className="btn btn-primary" disabled={totpLocked} onClick={() => void enableTotp()}>
<ShieldCheck size={14} className="btn-icon" />
{totpLocked ? t('txt_enabled') : t('txt_enable_totp')}
</button>
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_regenerate')}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={totpLocked}
onClick={() => {
void navigator.clipboard.writeText(secret);
props.onNotify?.('success', t('txt_secret_copied'));
}}
>
<Clipboard size={14} className="btn-icon" />
{t('txt_copy_secret')}
</button>
</div>
</div>
</div>
</div>
<button type="button" className="btn btn-danger" disabled={!totpLocked} onClick={props.onOpenDisableTotp}>
<ShieldOff size={14} className="btn-icon" />
{t('txt_disable_totp')}
</button>
</div>
<div className="settings-subcard">
<h3>{t('txt_recovery_code')}</h3>
<p className="muted-inline" style={{ marginBottom: 8 }}>
{t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')}
</p>
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
value={recoveryMasterPassword}
onInput={(e) => setRecoveryMasterPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
<div className="actions">
<button type="button" className="btn btn-secondary" onClick={() => void loadRecoveryCode()}>
<ShieldCheck size={14} className="btn-icon" />
{t('txt_view_recovery_code')}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={!recoveryCode}
onClick={() => {
void navigator.clipboard.writeText(recoveryCode);
props.onNotify?.('success', t('txt_recovery_code_copied'));
}}
>
<Clipboard size={14} className="btn-icon" />
{t('txt_copy_code')}
</button>
</div>
{recoveryCode && (
<div className="card" style={{ marginTop: 10, marginBottom: 0 }}>
<div style={{ fontWeight: 800, letterSpacing: '0.08em' }}>{recoveryCode}</div>
</div>
)}
</div>
</div>
</section>
</div>
);
}
@@ -0,0 +1,30 @@
import type { ComponentChildren } from 'preact';
interface StandalonePageFrameProps {
title: string;
children: ComponentChildren;
}
export default function StandalonePageFrame(props: StandalonePageFrameProps) {
return (
<div className="standalone-shell">
<div className="standalone-brand standalone-brand-outside">
<img src="/logo-64.png" alt="NodeWarden logo" className="standalone-brand-logo" />
<div>
<div className="standalone-brand-title">NodeWarden</div>
</div>
</div>
<div className="auth-card">
<h1 className="standalone-title">{props.title}</h1>
{props.children}
</div>
<div className="standalone-footer">
<a href="https://github.com/shuaiplus/NodeWarden" target="_blank" rel="noreferrer">NodeWarden Repository</a>
<span> | </span>
<a href="https://github.com/shuaiplus" target="_blank" rel="noreferrer">Author: @shuaiplus</a>
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { ToastMessage } from '@/lib/types';
interface ToastHostProps {
toasts: ToastMessage[];
onClose: (id: string) => void;
}
export default function ToastHost({ toasts, onClose }: ToastHostProps) {
if (!toasts.length) return null;
return (
<ul className="toast-stack">
{toasts.map((toast) => (
<li key={toast.id} className={`toast-item ${toast.type}`}>
<div className="toast-text">{toast.text}</div>
<button type="button" className="toast-close" onClick={() => onClose(toast.id)}>
x
</button>
<div className="toast-progress" />
</li>
))}
</ul>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+217
View File
@@ -0,0 +1,217 @@
export function bytesToBase64(bytes: Uint8Array): string {
let s = '';
for (let i = 0; i < bytes.length; i += 1) s += String.fromCharCode(bytes[i]);
return btoa(s);
}
export function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
return out;
}
export function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
const out = new Uint8Array(a.length + b.length);
out.set(a, 0);
out.set(b, a.length);
return out;
}
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
return new Uint8Array(bytes).buffer;
}
export async function pbkdf2(
passwordOrBytes: string | Uint8Array,
saltOrBytes: string | Uint8Array,
iterations: number,
keyLen: number
): Promise<Uint8Array> {
const pwdBytes = typeof passwordOrBytes === 'string' ? new TextEncoder().encode(passwordOrBytes) : passwordOrBytes;
const saltBytes = typeof saltOrBytes === 'string' ? new TextEncoder().encode(saltOrBytes) : saltOrBytes;
const key = await crypto.subtle.importKey('raw', toBufferSource(pwdBytes), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', hash: 'SHA-256', salt: toBufferSource(saltBytes), iterations },
key,
keyLen * 8
);
return new Uint8Array(bits);
}
export async function hkdfExpand(prk: Uint8Array, info: string, length: number): Promise<Uint8Array> {
const infoBytes = new TextEncoder().encode(info || '');
const key = await crypto.subtle.importKey('raw', toBufferSource(prk), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const result = new Uint8Array(length);
let previous = new Uint8Array(0);
let offset = 0;
let counter = 1;
while (offset < length) {
const input = new Uint8Array(previous.length + infoBytes.length + 1);
input.set(previous, 0);
input.set(infoBytes, previous.length);
input[input.length - 1] = counter & 0xff;
previous = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(input)));
const copyLen = Math.min(previous.length, length - offset);
result.set(previous.slice(0, copyLen), offset);
offset += copyLen;
counter += 1;
}
return result;
}
export async function hkdf(
ikm: Uint8Array,
salt: string | Uint8Array,
info: string | Uint8Array,
outputByteSize: number
): Promise<Uint8Array> {
const saltBytes = typeof salt === 'string' ? new TextEncoder().encode(salt) : salt;
const infoBytes = typeof info === 'string' ? new TextEncoder().encode(info) : info;
const params: HkdfParams = {
name: 'HKDF',
salt: toBufferSource(saltBytes),
info: toBufferSource(infoBytes),
hash: 'SHA-256',
};
const key = await crypto.subtle.importKey('raw', toBufferSource(ikm), 'HKDF', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits(params, key, outputByteSize * 8);
return new Uint8Array(bits);
}
async function hmacSha256(keyBytes: Uint8Array, dataBytes: Uint8Array): Promise<Uint8Array> {
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(dataBytes)));
}
async function encryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
const cryptoKey = await crypto.subtle.importKey('raw', toBufferSource(key), { name: 'AES-CBC' }, false, ['encrypt']);
return new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
}
async function decryptAesCbc(data: Uint8Array, key: Uint8Array, iv: Uint8Array): Promise<Uint8Array> {
const cryptoKey = await crypto.subtle.importKey('raw', toBufferSource(key), { name: 'AES-CBC' }, false, ['decrypt']);
return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toBufferSource(iv) }, cryptoKey, toBufferSource(data)));
}
export async function encryptBwFileData(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<Uint8Array> {
const iv = crypto.getRandomValues(new Uint8Array(16));
const cipher = await encryptAesCbc(data, encKey, iv);
const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
const out = new Uint8Array(1 + iv.length + mac.length + cipher.length);
out[0] = 2; // EncryptionType.AesCbc256_HmacSha256_B64
out.set(iv, 1);
out.set(mac, 1 + iv.length);
out.set(cipher, 1 + iv.length + mac.length);
return out;
}
export async function decryptBwFileData(encrypted: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<Uint8Array> {
if (!encrypted || encrypted.length < 1 + 16 + 32 + 1) throw new Error('Invalid encrypted file data');
const encType = encrypted[0];
if (encType !== 2) throw new Error('Unsupported file encryption type');
const iv = encrypted.slice(1, 17);
const mac = encrypted.slice(17, 49);
const cipher = encrypted.slice(49);
const expected = await hmacSha256(macKey, concatBytes(iv, cipher));
if (bytesToBase64(expected) !== bytesToBase64(mac)) throw new Error('MAC mismatch');
return decryptAesCbc(cipher, encKey, iv);
}
export async function encryptBw(data: Uint8Array, encKey: Uint8Array, macKey: Uint8Array): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(16));
const cipher = await encryptAesCbc(data, encKey, iv);
const mac = await hmacSha256(macKey, concatBytes(iv, cipher));
return `2.${bytesToBase64(iv)}|${bytesToBase64(cipher)}|${bytesToBase64(mac)}`;
}
function parseCipherString(s: string): { type: number; iv: Uint8Array; ct: Uint8Array; mac: Uint8Array | null } {
if (!s || typeof s !== 'string') throw new Error('invalid encrypted string');
const p = s.indexOf('.');
if (p <= 0) throw new Error('invalid encrypted string');
const type = Number(s.slice(0, p));
const body = s.slice(p + 1);
const parts = body.split('|');
if (type === 2 && parts.length === 3) {
return { type: 2, iv: base64ToBytes(parts[0]), ct: base64ToBytes(parts[1]), mac: base64ToBytes(parts[2]) };
}
if ((type === 0 || type === 1 || type === 4) && parts.length >= 2) {
return { type, iv: base64ToBytes(parts[0]), ct: base64ToBytes(parts[1]), mac: null };
}
throw new Error('unsupported enc type');
}
export async function decryptBw(cipherString: string, encKey: Uint8Array, macKey?: Uint8Array): Promise<Uint8Array> {
const parsed = parseCipherString(cipherString);
if (parsed.type === 2 && macKey && parsed.mac) {
const expected = await hmacSha256(macKey, concatBytes(parsed.iv, parsed.ct));
if (bytesToBase64(expected) !== bytesToBase64(parsed.mac)) throw new Error('MAC mismatch');
}
return decryptAesCbc(parsed.ct, encKey, parsed.iv);
}
export async function decryptStr(cipherString: string | null | undefined, encKey: Uint8Array, macKey?: Uint8Array): Promise<string> {
if (!cipherString || typeof cipherString !== 'string') return '';
const plain = await decryptBw(cipherString, encKey, macKey);
return new TextDecoder().decode(plain);
}
export function extractTotpSecret(raw: string): string {
if (!raw) return '';
const s = raw.trim();
if (!s) return '';
if (/^otpauth:\/\//i.test(s)) {
try {
const u = new URL(s);
return (u.searchParams.get('secret') || '').toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
} catch {
return '';
}
}
return s.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
}
function base32ToBytes(input: string): Uint8Array {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const clean = input.toUpperCase().replace(/[^A-Z2-7]/g, '');
let bits = 0;
let value = 0;
const out: number[] = [];
for (let i = 0; i < clean.length; i += 1) {
const idx = alphabet.indexOf(clean.charAt(i));
if (idx < 0) continue;
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return new Uint8Array(out);
}
export async function calcTotpNow(rawSecret: string): Promise<{ code: string; remain: number } | null> {
const secret = extractTotpSecret(rawSecret);
if (!secret) return null;
const keyBytes = base32ToBytes(secret);
if (!keyBytes.length) return null;
const step = 30;
const epoch = Math.floor(Date.now() / 1000);
const counter = Math.floor(epoch / step);
const remain = step - (epoch % step);
const message = new Uint8Array(8);
let c = counter;
for (let i = 7; i >= 0; i -= 1) {
message[i] = c & 0xff;
c = Math.floor(c / 256);
}
const key = await crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
const hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, toBufferSource(message)));
const offset = hs[hs.length - 1] & 0x0f;
const bin = ((hs[offset] & 0x7f) << 24) | ((hs[offset + 1] & 0xff) << 16) | ((hs[offset + 2] & 0xff) << 8) | (hs[offset + 3] & 0xff);
const code = (bin % 1000000).toString().padStart(6, '0');
return { code, remain };
}
+711
View File
@@ -0,0 +1,711 @@
import { argon2idAsync } from '@noble/hashes/argon2.js';
import { strToU8, zipSync } from 'fflate';
import { Uint8ArrayReader, Uint8ArrayWriter, ZipReader, ZipWriter, configure as configureZipJs } from '@zip.js/zip.js';
import type { PreloginKdfConfig } from './api';
import { base64ToBytes, bytesToBase64, decryptBw, decryptStr, encryptBw, hkdfExpand, pbkdf2 } from './crypto';
import type { Cipher, Folder } from './types';
configureZipJs({ useWebWorkers: false });
export const EXPORT_FORMATS = [
{ id: 'bitwarden_json', label: 'Bitwarden (vault as json)' },
{ id: 'bitwarden_encrypted_json', label: 'Bitwarden (encrypted vault as json)' },
{ id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' },
{ id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' },
{ id: 'nodewarden_json', label: 'NodeWarden (vault + attachments as json)' },
{ id: 'nodewarden_encrypted_json', label: 'NodeWarden (encrypted vault + attachments as json)' },
] as const;
export type ExportFormatId = (typeof EXPORT_FORMATS)[number]['id'];
export type EncryptedJsonMode = 'account' | 'password';
export interface ExportRequest {
format: ExportFormatId;
encryptedJsonMode?: EncryptedJsonMode;
filePassword?: string;
zipPassword?: string;
masterPassword?: string;
}
export interface ExportDownloadPayload {
fileName: string;
mimeType: string;
bytes: Uint8Array;
}
export interface ZipAttachmentEntry {
cipherId: string;
fileName: string;
bytes: Uint8Array;
}
export interface NodeWardenAttachmentRecord {
cipherId: string;
cipherIndex: number | null;
fileName: string;
data: string;
}
interface BuildPlainJsonArgs {
folders: Folder[];
ciphers: Cipher[];
userEncB64: string;
userMacB64: string;
}
interface BuildEncryptedJsonArgs {
folders: Folder[];
ciphers: Cipher[];
userEncB64: string;
userMacB64: string;
}
interface PasswordProtectedArgs {
plaintextJson: string;
password: string;
kdf: PreloginKdfConfig;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function isCipherString(value: string): boolean {
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
}
function normalizeString(value: unknown): string | null {
if (value === null || value === undefined) return null;
return String(value);
}
function normalizeNumber(value: unknown, fallback = 0): number {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
return n;
}
function cloneValue<T>(value: T): T {
if (value === null || value === undefined) return value;
if (typeof structuredClone === 'function') {
try {
return structuredClone(value);
} catch {
// ignore and fallback
}
}
try {
return JSON.parse(JSON.stringify(value)) as T;
} catch {
return value;
}
}
function randomGuid(): string {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
function toAesBuffer(bytes: Uint8Array): ArrayBuffer {
return new Uint8Array(bytes).buffer;
}
async function getCipherKeyParts(cipher: Cipher, userEnc: Uint8Array, userMac: Uint8Array): Promise<{ enc: Uint8Array; mac: Uint8Array }> {
if (cipher.key && typeof cipher.key === 'string') {
try {
const raw = await decryptBw(cipher.key, userEnc, userMac);
if (raw.length >= 64) {
return { enc: raw.slice(0, 32), mac: raw.slice(32, 64) };
}
} catch {
// Fallback to user key.
}
}
return { enc: userEnc, mac: userMac };
}
async function decryptMaybe(value: unknown, enc: Uint8Array, mac: Uint8Array): Promise<string | null> {
if (value === null || value === undefined) return null;
if (typeof value !== 'string') return String(value);
const raw = value;
if (!raw) return '';
if (!isCipherString(raw)) return raw;
try {
return await decryptStr(raw, enc, mac);
} catch {
return raw;
}
}
async function deepDecryptUnknown(value: unknown, enc: Uint8Array, mac: Uint8Array): Promise<unknown> {
if (value === null || value === undefined) return value;
if (typeof value === 'string') return decryptMaybe(value, enc, mac);
if (Array.isArray(value)) {
return Promise.all(value.map((item) => deepDecryptUnknown(item, enc, mac)));
}
if (isRecord(value)) {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
out[k] = await deepDecryptUnknown(v, enc, mac);
}
return out;
}
return value;
}
function mapCipherCommonMetadata(cipher: Cipher): Record<string, unknown> {
const out: Record<string, unknown> = {
id: cipher.id,
type: normalizeNumber(cipher.type, 1),
reprompt: normalizeNumber(cipher.reprompt, 0),
favorite: !!cipher.favorite,
folderId: normalizeString(cipher.folderId),
creationDate: normalizeString(cipher.creationDate),
revisionDate: normalizeString(cipher.revisionDate),
collectionIds: null,
};
if ((out.creationDate as string | null) === null) delete out.creationDate;
if ((out.revisionDate as string | null) === null) delete out.revisionDate;
if ((out.folderId as string | null) === null) delete out.folderId;
return out;
}
function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
const out = mapCipherCommonMetadata(cipher);
out.name = cipher.name ?? null;
out.notes = cipher.notes ?? null;
out.key = cipher.key ?? null;
out.fields = Array.isArray(cipher.fields)
? cipher.fields.map((field) => ({
name: field?.name ?? null,
value: field?.value ?? null,
type: normalizeNumber(field?.type, 0),
linkedId: field?.linkedId ?? null,
}))
: [];
const login = cipher.login;
out.login = login
? {
username: login.username ?? null,
password: login.password ?? null,
totp: login.totp ?? null,
uris: Array.isArray(login.uris)
? login.uris.map((uri) => ({
uri: uri?.uri ?? null,
match: (uri as { match?: unknown })?.match ?? null,
}))
: [],
fido2Credentials: Array.isArray(login.fido2Credentials) ? cloneValue(login.fido2Credentials) : [],
}
: null;
out.card = cipher.card
? {
cardholderName: cipher.card.cardholderName ?? null,
brand: cipher.card.brand ?? null,
number: cipher.card.number ?? null,
expMonth: cipher.card.expMonth ?? null,
expYear: cipher.card.expYear ?? null,
code: cipher.card.code ?? null,
}
: null;
out.identity = cipher.identity
? {
title: cipher.identity.title ?? null,
firstName: cipher.identity.firstName ?? null,
middleName: cipher.identity.middleName ?? null,
lastName: cipher.identity.lastName ?? null,
username: cipher.identity.username ?? null,
company: cipher.identity.company ?? null,
ssn: cipher.identity.ssn ?? null,
passportNumber: cipher.identity.passportNumber ?? null,
licenseNumber: cipher.identity.licenseNumber ?? null,
email: cipher.identity.email ?? null,
phone: cipher.identity.phone ?? null,
address1: cipher.identity.address1 ?? null,
address2: cipher.identity.address2 ?? null,
address3: cipher.identity.address3 ?? null,
city: cipher.identity.city ?? null,
state: cipher.identity.state ?? null,
postalCode: cipher.identity.postalCode ?? null,
country: cipher.identity.country ?? null,
}
: null;
out.secureNote = cipher.secureNote
? {
type: normalizeNumber((cipher.secureNote as { type?: unknown }).type, 0),
}
: null;
out.passwordHistory = Array.isArray(cipher.passwordHistory)
? cipher.passwordHistory.map((entry) => ({
password: (entry as { password?: unknown }).password ?? null,
lastUsedDate: (entry as { lastUsedDate?: unknown }).lastUsedDate ?? null,
}))
: [];
out.sshKey = cipher.sshKey
? {
privateKey: cipher.sshKey.privateKey ?? null,
publicKey: cipher.sshKey.publicKey ?? null,
keyFingerprint: cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
// Keep legacy alias for compatibility with older importers.
fingerprint: cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
}
: null;
return out;
}
async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint8Array): Promise<Record<string, unknown>> {
const keyParts = await getCipherKeyParts(cipher, userEnc, userMac);
const out = mapCipherCommonMetadata(cipher);
out.name = await decryptMaybe(cipher.name ?? null, keyParts.enc, keyParts.mac);
out.notes = await decryptMaybe(cipher.notes ?? null, keyParts.enc, keyParts.mac);
out.fields = Array.isArray(cipher.fields)
? await Promise.all(
cipher.fields.map(async (field) => ({
name: await decryptMaybe(field?.name ?? null, keyParts.enc, keyParts.mac),
value: await decryptMaybe(field?.value ?? null, keyParts.enc, keyParts.mac),
type: normalizeNumber(field?.type, 0),
linkedId: field?.linkedId ?? null,
}))
)
: [];
if (cipher.login) {
out.login = {
username: await decryptMaybe(cipher.login.username ?? null, keyParts.enc, keyParts.mac),
password: await decryptMaybe(cipher.login.password ?? null, keyParts.enc, keyParts.mac),
totp: await decryptMaybe(cipher.login.totp ?? null, keyParts.enc, keyParts.mac),
uris: Array.isArray(cipher.login.uris)
? await Promise.all(
cipher.login.uris.map(async (uri) => ({
uri: await decryptMaybe(uri?.uri ?? null, keyParts.enc, keyParts.mac),
match: (uri as { match?: unknown })?.match ?? null,
}))
)
: [],
fido2Credentials: Array.isArray(cipher.login.fido2Credentials)
? await Promise.all(cipher.login.fido2Credentials.map((credential) => deepDecryptUnknown(credential, keyParts.enc, keyParts.mac)))
: [],
};
} else {
out.login = null;
}
out.card = cipher.card ? await deepDecryptUnknown(cipher.card, keyParts.enc, keyParts.mac) : null;
out.identity = cipher.identity ? await deepDecryptUnknown(cipher.identity, keyParts.enc, keyParts.mac) : null;
if (cipher.sshKey) {
const fingerprint = await decryptMaybe(
cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
keyParts.enc,
keyParts.mac
);
out.sshKey = {
privateKey: await decryptMaybe(cipher.sshKey.privateKey ?? null, keyParts.enc, keyParts.mac),
publicKey: await decryptMaybe(cipher.sshKey.publicKey ?? null, keyParts.enc, keyParts.mac),
keyFingerprint: fingerprint,
// Keep legacy alias for compatibility with older importers.
fingerprint,
};
} else {
out.sshKey = null;
}
out.secureNote = cipher.secureNote
? {
type: normalizeNumber((cipher.secureNote as { type?: unknown }).type, 0),
}
: null;
out.passwordHistory = Array.isArray(cipher.passwordHistory)
? await Promise.all(
cipher.passwordHistory.map(async (entry) => ({
password: await decryptMaybe((entry as { password?: unknown }).password ?? null, keyParts.enc, keyParts.mac),
lastUsedDate: normalizeString((entry as { lastUsedDate?: unknown }).lastUsedDate),
}))
)
: [];
return out;
}
async function decryptFolderName(folder: Folder, userEnc: Uint8Array, userMac: Uint8Array): Promise<string> {
const value = await decryptMaybe(folder.name ?? '', userEnc, userMac);
return value || '';
}
function trimNullKeys(value: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
if (v !== undefined) out[k] = v;
}
return out;
}
function filterExportableCiphers(ciphers: Cipher[]): Cipher[] {
return ciphers.filter((cipher) => !cipher.deletedDate && !(cipher as { organizationId?: unknown }).organizationId);
}
export async function buildPlainBitwardenJsonDocument(args: BuildPlainJsonArgs): Promise<Record<string, unknown>> {
const userEnc = base64ToBytes(args.userEncB64);
const userMac = base64ToBytes(args.userMacB64);
const folders = await Promise.all(
args.folders.map(async (folder) => ({
id: folder.id,
name: await decryptFolderName(folder, userEnc, userMac),
}))
);
const items = await Promise.all(filterExportableCiphers(args.ciphers).map((cipher) => mapCipherPlain(cipher, userEnc, userMac)));
return {
encrypted: false,
folders,
items: items.map((item) => trimNullKeys(item)),
};
}
export async function buildPlainBitwardenJsonString(args: BuildPlainJsonArgs): Promise<string> {
const doc = await buildPlainBitwardenJsonDocument(args);
return JSON.stringify(doc, null, 2);
}
export async function buildBitwardenCsvString(args: BuildPlainJsonArgs): Promise<string> {
const doc = await buildPlainBitwardenJsonDocument(args);
const folders = Array.isArray(doc.folders) ? (doc.folders as Array<Record<string, unknown>>) : [];
const items = Array.isArray(doc.items) ? (doc.items as Array<Record<string, unknown>>) : [];
const folderNameById = new Map<string, string>();
for (const folder of folders) {
const id = normalizeString(folder.id);
if (!id) continue;
folderNameById.set(id, normalizeString(folder.name) || '');
}
const header = [
'folder',
'favorite',
'type',
'name',
'notes',
'fields',
'reprompt',
'archivedDate',
'login_uri',
'login_username',
'login_password',
'login_totp',
];
const rows: string[][] = [header];
for (const item of items) {
const type = normalizeNumber(item.type, 1);
if (type !== 1 && type !== 2) continue;
const folderId = normalizeString(item.folderId);
const folderName = folderId ? folderNameById.get(folderId) || '' : '';
const fields = Array.isArray(item.fields)
? (item.fields as Array<Record<string, unknown>>)
.map((field) => {
const name = normalizeString(field.name) || '';
const value = normalizeString(field.value) || '';
if (!name && !value) return '';
return `${name}: ${value}`;
})
.filter((line) => !!line)
.join('\n')
: '';
const login = isRecord(item.login) ? (item.login as Record<string, unknown>) : null;
const loginUris = login && Array.isArray(login.uris)
? (login.uris as Array<Record<string, unknown>>)
.map((uri) => normalizeString(uri.uri) || '')
.filter((uri) => !!uri)
.join(',')
: '';
rows.push([
folderName,
item.favorite ? '1' : '',
type === 1 ? 'login' : 'note',
normalizeString(item.name) || '',
normalizeString(item.notes) || '',
fields,
String(normalizeNumber(item.reprompt, 0)),
normalizeString(item.archivedDate) || '',
loginUris,
normalizeString(login?.username) || '',
normalizeString(login?.password) || '',
normalizeString(login?.totp) || '',
]);
}
const escapeCsv = (value: string): string => {
if (/[",\n\r]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
};
return rows.map((row) => row.map((cell) => escapeCsv(String(cell || ''))).join(',')).join('\n');
}
export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
const userEnc = base64ToBytes(args.userEncB64);
const userMac = base64ToBytes(args.userMacB64);
const validation = await encryptBw(new TextEncoder().encode(randomGuid()), userEnc, userMac);
const folders = args.folders.map((folder) => ({
id: folder.id,
name: folder.name,
}));
const items = filterExportableCiphers(args.ciphers).map((cipher) => mapCipherEncrypted(cipher));
const doc = {
encrypted: true,
encKeyValidation_DO_NOT_EDIT: validation,
folders,
items,
};
return JSON.stringify(doc, null, 2);
}
async function derivePasswordProtectedKey(kdf: PreloginKdfConfig, password: string, saltB64: string): Promise<{ enc: Uint8Array; mac: Uint8Array }> {
const iterations = Math.max(1, normalizeNumber(kdf.kdfIterations, 600000));
const kdfType = normalizeNumber(kdf.kdfType, 0);
const saltTextBytes = new TextEncoder().encode(saltB64);
let keyMaterial: Uint8Array;
if (kdfType === 1) {
const memoryMiB = Math.max(16, normalizeNumber(kdf.kdfMemory, 64));
const parallelism = Math.max(1, normalizeNumber(kdf.kdfParallelism, 4));
const memoryKiB = Math.floor(memoryMiB * 1024);
const maxmem = memoryKiB * 1024 + 1024 * 1024;
keyMaterial = await argon2idAsync(new TextEncoder().encode(password), saltTextBytes, {
t: Math.floor(iterations),
m: memoryKiB,
p: Math.floor(parallelism),
dkLen: 32,
maxmem,
asyncTick: 10,
});
} else {
keyMaterial = await pbkdf2(password, saltTextBytes, iterations, 32);
}
const enc = await hkdfExpand(keyMaterial, 'enc', 32);
const mac = await hkdfExpand(keyMaterial, 'mac', 32);
return { enc, mac };
}
export async function buildPasswordProtectedBitwardenJsonString(args: PasswordProtectedArgs): Promise<string> {
const password = String(args.password || '').trim();
if (!password) throw new Error('File password is required');
const salt = crypto.getRandomValues(new Uint8Array(16));
const saltB64 = bytesToBase64(salt);
const key = await derivePasswordProtectedKey(args.kdf, password, saltB64);
const validation = await encryptBw(new TextEncoder().encode(randomGuid()), key.enc, key.mac);
const data = await encryptBw(new TextEncoder().encode(args.plaintextJson), key.enc, key.mac);
const kdfType = normalizeNumber(args.kdf.kdfType, 0);
const out: Record<string, unknown> = {
encrypted: true,
passwordProtected: true,
salt: saltB64,
kdfType,
kdfIterations: Math.max(1, normalizeNumber(args.kdf.kdfIterations, 600000)),
encKeyValidation_DO_NOT_EDIT: validation,
data,
};
if (kdfType === 1) {
out.kdfMemory = Math.max(16, normalizeNumber(args.kdf.kdfMemory, 64));
out.kdfParallelism = Math.max(1, normalizeNumber(args.kdf.kdfParallelism, 4));
}
return JSON.stringify(out, null, 2);
}
function sanitizeFileName(name: string): string {
const normalized = String(name || '').trim().replace(/[\\/]/g, '_').replace(/[\x00-\x1F\x7F]/g, '');
if (!normalized) return 'attachment.bin';
if (normalized.length > 240) {
const dot = normalized.lastIndexOf('.');
if (dot > 0 && dot > normalized.length - 16) {
const ext = normalized.slice(dot);
return `${normalized.slice(0, 240 - ext.length)}${ext}`;
}
return normalized.slice(0, 240);
}
return normalized;
}
function uniqueAttachmentFileName(cipherId: string, originalName: string, used: Set<string>): string {
const safe = sanitizeFileName(originalName);
const keyBase = `${cipherId}/${safe}`;
if (!used.has(keyBase)) {
used.add(keyBase);
return safe;
}
const dot = safe.lastIndexOf('.');
const base = dot > 0 ? safe.slice(0, dot) : safe;
const ext = dot > 0 ? safe.slice(dot) : '';
let idx = 1;
while (idx < 10000) {
const candidate = `${base} (${idx})${ext}`;
const key = `${cipherId}/${candidate}`;
if (!used.has(key)) {
used.add(key);
return candidate;
}
idx += 1;
}
return `${base}-${Date.now()}${ext}`;
}
export function buildBitwardenZipBytes(dataJson: string, attachments: ZipAttachmentEntry[]): Uint8Array {
const files: Record<string, Uint8Array> = {
'data.json': strToU8(dataJson),
};
const used = new Set<string>();
for (const attachment of attachments) {
const cipherId = String(attachment.cipherId || '').trim();
if (!cipherId) continue;
const fileName = uniqueAttachmentFileName(cipherId, attachment.fileName || 'attachment.bin', used);
files[`attachments/${cipherId}/${fileName}`] = attachment.bytes;
}
return zipSync(files, { level: 6 });
}
export async function encryptZipBytesWithPassword(
zipBytes: Uint8Array,
passwordRaw: string
): Promise<{ bytes: Uint8Array; encrypted: boolean }> {
const password = String(passwordRaw || '').trim();
if (!password) return { bytes: zipBytes, encrypted: false };
const zipReader = new ZipReader(new Uint8ArrayReader(zipBytes), { useWebWorkers: false });
const zipWriter = new ZipWriter(new Uint8ArrayWriter(), { useWebWorkers: false });
try {
const entries = await zipReader.getEntries();
for (const entry of entries) {
const filename = String(entry.filename || '').trim();
if (!filename) continue;
if (entry.directory) {
await zipWriter.add(filename, undefined, {
directory: true,
password,
encryptionStrength: 3,
});
continue;
}
const data = await entry.getData(new Uint8ArrayWriter());
await zipWriter.add(filename, new Uint8ArrayReader(data), {
password,
encryptionStrength: 3,
level: 6,
});
}
return {
bytes: await zipWriter.close(),
encrypted: true,
};
} finally {
await zipReader.close();
}
}
function nowStamp(now = new Date()): string {
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
return `${y}${m}${d}_${hh}${mm}${ss}`;
}
export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string {
const stamp = nowStamp();
if (
format === 'bitwarden_json' ||
format === 'bitwarden_encrypted_json' ||
format === 'nodewarden_json' ||
format === 'nodewarden_encrypted_json'
) {
if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`;
return `bitwarden_export_${stamp}.json`;
}
if (format === 'bitwarden_json_zip' || format === 'bitwarden_encrypted_json_zip') {
if (zipEncrypted) return `bitwarden_export_${stamp}.zip`;
return `bitwarden_export_${stamp}.zip`;
}
return `bitwarden_export_${stamp}.bin`;
}
export function buildNodeWardenAttachmentRecords(
attachments: ZipAttachmentEntry[],
cipherIndexById?: Map<string, number>
): NodeWardenAttachmentRecord[] {
const out: NodeWardenAttachmentRecord[] = [];
for (const attachment of attachments) {
const cipherId = String(attachment.cipherId || '').trim();
if (!cipherId) continue;
const fileName = sanitizeFileName(String(attachment.fileName || '').trim() || 'attachment.bin');
out.push({
cipherId,
cipherIndex: cipherIndexById?.get(cipherId) ?? null,
fileName,
data: bytesToBase64(attachment.bytes),
});
}
return out;
}
export function buildNodeWardenPlainJsonDocument(
bitwardenJsonDoc: Record<string, unknown>,
attachments: NodeWardenAttachmentRecord[]
): Record<string, unknown> {
return {
...bitwardenJsonDoc,
nodewardenFormat: 'nodewarden_json',
nodewardenVersion: 1,
nodewardenAttachments: attachments,
};
}
export async function attachNodeWardenEncryptedAttachmentPayload(
encryptedBitwardenJson: string,
attachments: NodeWardenAttachmentRecord[],
userEncB64: string,
userMacB64: string
): Promise<string> {
const parsed = JSON.parse(encryptedBitwardenJson) as Record<string, unknown>;
const userEnc = base64ToBytes(userEncB64);
const userMac = base64ToBytes(userMacB64);
const payload = JSON.stringify({
nodewardenFormat: 'nodewarden_json',
nodewardenVersion: 1,
nodewardenAttachments: attachments,
});
parsed.nodewardenFormat = 'nodewarden_json';
parsed.nodewardenVersion = 1;
parsed.nodewardenAttachmentsEnc = await encryptBw(new TextEncoder().encode(payload), userEnc, userMac);
return JSON.stringify(parsed, null, 2);
}
+877
View File
@@ -0,0 +1,877 @@
type Locale = 'en' | 'zh-CN';
const LOCALE_STORAGE_KEY = 'nodewarden.locale';
const messages: Record<Locale, Record<string, string>> = {
en: {
nav_account_settings: "Account Settings",
nav_admin_panel: "Admin Panel",
nav_device_management: "Device Management",
nav_my_vault: "My Vault",
nav_sends: "Sends",
nav_backup_strategy: "Backup Strategy",
nav_import_export: "Import & Export",
backup_strategy_title: "Backup Strategy",
backup_strategy_under_construction: "Under construction.",
import_export_title: "Import & Export",
import_export_under_construction: "Under construction.",
txt_access_count: "Access Count",
txt_accessed_count_times: "Accessed {count} times",
txt_actions: "Actions",
txt_add: "Add",
txt_add_field: "Add Field",
txt_add_website: "Add Website",
txt_added: "Added",
txt_additional_options: "Additional Options",
txt_address: "Address",
txt_address_1: "Address 1",
txt_address_2: "Address 2",
txt_address_3: "Address 3",
txt_all_device_authorizations_revoked: "All device authorizations revoked",
txt_all_invites_deleted: "All invites deleted",
txt_all_items: "All Items",
txt_all_sends: "All Sends",
txt_android: "Android",
txt_are_you_sure_you_want_to_delete_count_selected_items: "Are you sure you want to delete {count} selected items?",
txt_are_you_sure_you_want_to_delete_this_item: "Are you sure you want to delete this item?",
txt_are_you_sure_you_want_to_log_out: "Are you sure you want to log out?",
txt_authenticator_key: "Authenticator Key",
txt_authorized_devices: "Authorized Devices",
txt_auto_copy_link_after_save: "Auto copy link after save",
txt_autofill_options: "Autofill Options",
txt_back_to_login: "Back To Login",
txt_ban: "Ban",
txt_boolean: "Boolean",
txt_brand: "Brand",
txt_bulk_delete_failed: "Bulk delete failed",
txt_bulk_delete_sends_failed: "Bulk delete sends failed",
txt_bulk_move_failed: "Bulk move failed",
txt_cancel: "Cancel",
txt_card: "Card",
txt_card_details: "Card Details",
txt_cardholder_name: "Cardholder Name",
txt_change_master_password: "Change Master Password",
txt_change_password: "Change Password",
txt_change_password_failed: "Change password failed",
txt_checked: "Checked",
txt_choose_destination_folder: "Choose destination folder.",
txt_chrome_browser: "Chrome Browser",
txt_chrome_extension: "Chrome Extension",
txt_city_town: "City / Town",
txt_code: "Code",
txt_company: "Company",
txt_configure_custom_field_values: "Configure custom field values.",
txt_confirm: "Confirm",
txt_confirm_master_password: "Confirm Master Password",
txt_confirm_password: "Confirm Password",
txt_copy: "Copy",
txt_copy_code: "Copy Code",
txt_copy_link: "Copy Link",
txt_copy_secret: "Copy Secret",
txt_country: "Country",
txt_create: "Create",
txt_create_account: "Create Account",
txt_create_folder: "Create Folder",
txt_create_folder_failed: "Create folder failed",
txt_create_item_failed: "Create item failed",
txt_create_send_failed: "Create send failed",
txt_create_timed_invite: "Create Timed Invite",
txt_created_value: "Created: {value}",
txt_current_new_password_is_required: "Current/new password is required",
txt_current_password: "Current Password",
txt_custom_fields: "Custom Fields",
txt_decrypt_failed: "(Decrypt failed)",
txt_decrypt_failed_2: "Decrypt failed",
txt_delete: "Delete",
txt_delete_all: "Delete All",
txt_delete_all_invite_codes_active_inactive: "Delete all invite codes (active/inactive)?",
txt_delete_all_invites: "Delete all invites",
txt_delete_item: "Delete Item",
txt_delete_item_failed: "Delete item failed",
txt_delete_selected: "Delete Selected",
txt_delete_selected_items: "Delete Selected Items",
txt_delete_send_failed: "Delete send failed",
txt_delete_this_user_and_all_user_data: "Delete this user and all user data?",
txt_delete_user: "Delete user",
txt_deleted_selected_items: "Deleted selected items",
txt_deleted_selected_sends: "Deleted selected sends",
txt_deletion_date: "Deletion Date",
txt_deletion_days: "Deletion Days",
txt_device: "Device",
txt_device_authorization_revoked: "Device authorization revoked",
txt_device_management: "Device Management",
txt_device_removed: "Device removed",
txt_disable_this_send: "Disable this send",
txt_disable_totp: "Disable TOTP",
txt_disable_totp_failed: "Disable TOTP failed",
txt_download: "Download",
txt_download_failed: "Download failed",
txt_edge_browser: "Edge Browser",
txt_edge_extension: "Edge Extension",
txt_edit: "Edit",
txt_edit_send: "Edit Send",
txt_email: "Email",
txt_email_password_and_recovery_code_are_required: "Email, password and recovery code are required",
txt_enable_totp: "Enable TOTP",
txt_enable_totp_failed: "Enable TOTP failed",
txt_enabled: "Enabled",
txt_encrypted_file: "Encrypted File",
txt_encrypted_file_2: "Encrypted file",
txt_enter_a_folder_name: "Enter a folder name.",
txt_enter_master_password_to_disable_two_step_verification: "Enter master password to disable two-step verification.",
txt_enter_master_password_to_view_this_item: "Enter master password to view this item.",
txt_expiration_date: "Expiration Date",
txt_expiration_days_0_never: "Expiration Days (0 = never)",
txt_expires_at: "Expires At",
txt_expires_at_value: "Expires at: {value}",
txt_expiry: "Expiry",
txt_expiry_month: "Expiry Month",
txt_expiry_year: "Expiry Year",
txt_failed_to_open_send: "Failed to open send",
txt_favorite: "Favorite",
txt_favorites: "Favorites",
txt_field: "Field",
txt_field_label: "Field Label",
txt_field_label_is_required: "Field label is required.",
txt_field_type: "Field Type",
txt_field_value: "Field Value",
txt_file: "File",
txt_file_name: "File Name",
txt_file_send: "File Send",
txt_file_size: "File Size",
txt_fingerprint: "Fingerprint",
txt_firefox_browser: "Firefox Browser",
txt_firefox_extension: "Firefox Extension",
txt_first_name: "First Name",
txt_folder: "Folder",
txt_folder_created: "Folder created",
txt_folder_name: "Folder Name",
txt_folder_name_is_required: "Folder name is required",
txt_folders: "Folders",
txt_hidden: "Hidden",
txt_hide: "Hide",
txt_identity: "Identity",
txt_identity_details: "Identity Details",
txt_ie_browser: "IE Browser",
txt_invite_code_optional: "Invite Code (Optional)",
txt_invite_created: "Invite created",
txt_invite_revoked: "Invite revoked",
txt_invite_validity_hours: "Invite validity (hours)",
txt_invites: "Invites",
txt_ios: "iOS",
txt_item: "Item",
txt_item_created: "Item created",
txt_item_deleted: "Item deleted",
txt_item_history: "Item History",
txt_item_name_is_required: "Item name is required.",
txt_item_updated: "Item updated",
txt_last_edited_value: "Last edited: {value}",
txt_last_name: "Last Name",
txt_last_seen: "Last Seen",
txt_license_number: "License Number",
txt_link_copied: "Link copied",
txt_linked: "Linked",
txt_linux_desktop: "Linux Desktop",
txt_loading: "Loading...",
txt_loading_nodewarden: "Loading NodeWarden...",
txt_jwt_warning_title: "Server Security Warning",
txt_jwt_warning_subtitle: "JWT secret is not configured safely.",
txt_jwt_title_missing: "JWT_SECRET is missing",
txt_jwt_title_too_short: "JWT_SECRET is too short",
txt_jwt_title_default: "JWT_SECRET is using the default value",
txt_jwt_reason_missing: "JWT secret is missing.",
txt_jwt_reason_default: "JWT secret is still the default/sample value.",
txt_jwt_reason_too_short: "JWT secret is too short. Minimum length is {min}.",
txt_jwt_how_to_fix_add: "How to add JWT_SECRET",
txt_jwt_how_to_fix_replace: "How to replace JWT_SECRET",
txt_jwt_add_step_1: "Use the 32-character generator below and copy a new key.",
txt_jwt_add_step_2: "Cloudflare Dashboard -> Workers & Pages -> Your Service -> Settings -> Variables and Secrets, add JWT_SECRET.",
txt_jwt_add_step_3: "Save and wait for redeploy, then refresh this page.",
txt_jwt_replace_step_1: "Use the 32-character generator below and create a stronger key (minimum {min} characters).",
txt_jwt_replace_step_2: "Cloudflare Dashboard -> Workers & Pages -> Your Service -> Settings -> Variables and Secrets, replace JWT_SECRET.",
txt_jwt_replace_step_3: "Save and wait for redeploy, then refresh this page.",
txt_how_to_fix: "How to fix",
txt_jwt_fix_step_1: "Open your deployment environment variables.",
txt_jwt_fix_step_2: "If your current key is not random enough, use the 32-character generator below.",
txt_jwt_fix_step_3: "Cloudflare Dashboard -> Workers & Pages -> Your Service -> Settings -> Variables and Secrets, update JWT_SECRET.",
txt_jwt_fix_step_4: "Save and wait for redeploy, then refresh this page to verify.",
txt_random_secret_generator: "Random Secret Generator",
txt_copied: "Copied",
txt_log_in: "Log In",
txt_log_out: "Log Out",
txt_lock: "Lock",
txt_login: "Login",
txt_login_credentials: "Login Credentials",
txt_login_failed: "Login failed",
txt_login_success: "Login success",
txt_macos_desktop: "macOS Desktop",
txt_manage_authorized_devices_and_30_day_totp_trusted_sessions: "Manage authorized devices and 30-day TOTP trusted sessions.",
txt_master_password: "Master Password",
txt_master_password_changed_please_login_again: "Master password changed. Please login again.",
txt_master_password_is_required: "Master password is required",
txt_master_password_is_required_2: "Master password is required.",
txt_master_password_must_be_at_least_12_chars: "Master password must be at least 12 chars",
txt_master_password_reprompt: "Master password reprompt",
txt_master_password_reprompt_2: "Master Password Reprompt",
txt_max_access_count: "Max Access Count",
txt_middle_name: "Middle Name",
txt_move: "Move",
txt_move_selected_items: "Move Selected Items",
txt_moved_selected_items: "Moved selected items",
txt_name: "Name",
txt_name_is_required: "Name is required",
txt_new_password: "New Password",
txt_new_password_must_be_at_least_12_chars: "New password must be at least 12 chars",
txt_new_passwords_do_not_match: "New passwords do not match",
txt_new_send: "New Send",
txt_next: "Next",
txt_no: "No",
txt_no_devices_found: "No devices found.",
txt_no_folder: "No Folder",
txt_no_items: "No items",
txt_no_name: "(No Name)",
txt_no_sends: "No sends",
txt_nodewarden_send: "NodeWarden Send",
txt_not_trusted: "Not trusted",
txt_note: "Note",
txt_notes: "Notes",
txt_number: "Number",
txt_open: "Open",
txt_opera_browser: "Opera Browser",
txt_opera_extension: "Opera Extension",
txt_or: "or",
txt_options: "Options",
txt_passport_number: "Passport Number",
txt_password: "Password",
txt_password_is_already_verified: "Password is already verified.",
txt_passwords_do_not_match: "Passwords do not match",
txt_phone: "Phone",
txt_please_input_email_and_password: "Please input email and password",
txt_please_input_master_password: "Please input master password",
txt_please_input_totp_code: "Please input TOTP code",
txt_please_select_a_file: "Please select a file",
txt_postal_code: "Postal Code",
txt_prev: "Prev",
txt_private_key: "Private Key",
txt_profile: "Profile",
txt_profile_unavailable: "Profile unavailable",
txt_profile_updated: "Profile updated",
txt_public_key: "Public Key",
txt_recover_2fa_failed: "Recover 2FA failed",
txt_recover_two_step_login: "Recover Two-step Login",
txt_recovered_but_auto_login_failed_please_sign_in: "Recovered but auto-login failed, please sign in.",
txt_recovery_code: "Recovery Code",
txt_recovery_code_copied: "Recovery code copied",
txt_recovery_code_is_empty: "Recovery code is empty",
txt_recovery_code_loaded: "Recovery code loaded",
txt_refresh: "Refresh",
txt_refresh_in_seconds_s: "Refresh in {seconds}s",
txt_regenerate: "Regenerate",
txt_registration_succeeded_please_sign_in: "Registration succeeded. Please sign in.",
txt_remove: "Remove",
txt_remove_device: "Remove device",
txt_remove_device_2: "Remove Device",
txt_remove_device_name_and_clear_its_2fa_trust: "Remove device \"{name}\" and clear its 2FA trust?",
txt_reveal: "Reveal",
txt_revoke: "Revoke",
txt_revoke_30_day_totp_trust_for_name: "Revoke 30-day TOTP trust for \"{name}\"?",
txt_revoke_30_day_totp_trust_from_all_devices: "Revoke 30-day TOTP trust from all devices?",
txt_revoke_all_trusted: "Revoke All Trusted",
txt_revoke_all_trusted_devices: "Revoke all trusted devices",
txt_revoke_device_authorization: "Revoke device authorization",
txt_revoke_trust: "Revoke Trust",
txt_role: "Role",
txt_save: "Save",
txt_save_profile: "Save Profile",
txt_save_profile_failed: "Save profile failed",
txt_search_sends: "Search sends...",
txt_search_your_secure_vault: "Search your secure vault...",
txt_secret_and_code_are_required: "Secret and code are required",
txt_secret_copied: "Secret copied",
txt_secure_note: "Secure Note",
txt_security_code: "Security Code",
txt_security_code_cvv: "Security Code (CVV)",
txt_select_all: "Select All",
txt_select_an_item: "Select an item",
txt_send_created: "Send created",
txt_send_deleted: "Send deleted",
txt_send_details: "Send Details",
txt_send_file: "send-file",
txt_send_unavailable: "Send unavailable.",
txt_send_updated: "Send updated",
txt_sign_out: "Sign Out",
txt_ssh_key: "SSH Key",
txt_ssn: "SSN",
txt_state_province: "State / Province",
txt_status: "Status",
txt_submit: "Submit",
txt_sync: "Sync",
txt_sync_vault: "Sync Vault",
txt_dash: "-",
txt_text: "Text",
txt_text_2fa_recovered: "2FA recovered",
txt_text_2fa_recovered_new_recovery_code_code: "2FA recovered. New recovery code: {code}",
txt_text_3: "------",
txt_text_is_required: "Text is required",
txt_text_send: "Text Send",
txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically: "This is a one-time code. After it is used, a new code is generated automatically.",
txt_this_item_requires_master_password_every_time_before_viewing_details: "This item requires master password every time before viewing details.",
txt_this_link_is_missing_decryption_key: "This link is missing decryption key.",
txt_this_send_is_password_protected: "This send is password protected.",
txt_title: "Title",
txt_totp: "TOTP",
txt_totp_code: "TOTP Code",
txt_totp_disabled: "TOTP disabled",
txt_totp_enabled: "TOTP enabled",
txt_totp_is_enabled_for_this_account: "TOTP is enabled for this account.",
txt_totp_secret: "TOTP Secret",
txt_totp_verify_failed: "TOTP verify failed",
txt_passkey: "Passkey",
txt_passkey_created_at_value: "Created at {value}",
txt_attachments: "Attachments",
txt_upload_attachments: "Upload attachments",
txt_new_attachments: "New attachments",
txt_marked_for_removal_count: "{count} attachment(s) will be removed on save",
txt_trash: "Trash",
txt_trust_this_device_for_30_days: "Trust this device for 30 days",
txt_trusted_until: "Trusted Until",
txt_two_step_verification: "Two-step verification",
txt_type: "Type",
txt_type_type: "Type {type}",
txt_unban: "Unban",
txt_unchecked: "Unchecked",
txt_unknown_device: "Unknown device",
txt_unlock: "Unlock",
txt_unlock_details: "Unlock Details",
txt_unlock_failed: "Unlock failed",
txt_unlock_failed_master_password_is_incorrect: "Unlock failed. Master password is incorrect.",
txt_unlock_item: "Unlock Item",
txt_unlock_send: "Unlock Send",
txt_unlock_vault: "Unlock Vault",
txt_unlocked: "Unlocked",
txt_update_item_failed: "Update item failed",
txt_update_send_failed: "Update send failed",
txt_use_recovery_code: "Use Recovery Code",
txt_use_your_one_time_recovery_code_to_disable_two_step_verification: "Use your one-time recovery code to disable two-step verification.",
txt_user_deleted: "User deleted",
txt_user_status_updated: "User status updated",
txt_username: "Username",
txt_users: "Users",
txt_vault_synced: "Vault synced",
txt_verification_code: "Verification Code",
txt_verify: "Verify",
txt_view_recovery_code: "View Recovery Code",
txt_web: "Web",
txt_website: "Website",
txt_websites: "Websites",
txt_windows_desktop: "Windows Desktop",
txt_yes: "Yes",
},
'zh-CN': {},
};
const zhCNOverrides: Record<string, string> = {
nav_my_vault: '我的保险库',
nav_sends: 'Send',
nav_admin_panel: '管理面板',
nav_account_settings: '账户设置',
nav_device_management: '设备管理',
nav_backup_strategy: '备份策略',
nav_import_export: '导入导出',
backup_strategy_title: '备份策略',
backup_strategy_under_construction: '正在搭建中',
import_export_title: '导入导出',
import_export_under_construction: '正在搭建中',
txt_sign_out: '退出登录',
txt_log_in: '登录',
txt_log_out: '退出',
txt_create_account: '创建账户',
txt_back_to_login: '返回登录',
txt_unlock: '解锁',
txt_unlock_vault: '解锁保险库',
txt_master_password: '主密码',
txt_email: '邮箱',
txt_name: '名称',
txt_password: '密码',
txt_confirm_password: '确认密码',
txt_confirm_master_password: '确认主密码',
txt_submit: '提交',
txt_cancel: '取消',
txt_yes: '是',
txt_no: '否',
txt_loading: '加载中...',
txt_loading_nodewarden: '正在加载 NodeWarden...',
txt_search_sends: '搜索发送...',
txt_search_your_secure_vault: '搜索你的保险库...',
txt_refresh: '刷新',
txt_sync: '同步',
txt_sync_vault: '同步',
txt_add: '新增',
txt_edit: '编辑',
txt_delete: '删除',
txt_save: '保存',
txt_confirm: '确认',
txt_move: '移动',
txt_copy: '复制',
txt_copy_link: '复制链接',
txt_select_all: '全选',
txt_delete_selected: '删除所选',
txt_all_items: '所有项目',
txt_favorites: '收藏',
txt_trash: '回收站',
txt_folder: '文件夹',
txt_folders: '文件夹',
txt_no_folder: '无文件夹',
txt_no_items: '没有项目',
txt_no_sends: '没有发送',
txt_select_an_item: '请选择一个项目',
txt_login: '登录',
txt_card: '银行卡',
txt_identity: '身份',
txt_note: '笔记',
txt_secure_note: '安全笔记',
txt_ssh_key: 'SSH 密钥',
txt_login_credentials: '登录信息',
txt_card_details: '银行卡详情',
txt_identity_details: '身份详情',
txt_autofill_options: '自动填充选项',
txt_additional_options: '附加选项',
txt_custom_fields: '自定义字段',
txt_notes: '备注',
txt_item_history: '项目历史',
txt_last_edited_value: '最后编辑:{value}',
txt_created_value: '创建于:{value}',
txt_username: '用户名',
txt_website: '网站',
txt_websites: '网站',
txt_open: '打开',
txt_hide: '隐藏',
txt_reveal: '显示',
txt_favorite: '收藏',
txt_field: '字段',
txt_field_type: '字段类型',
txt_field_label: '字段标签',
txt_field_value: '字段值',
txt_add_field: '添加字段',
txt_remove: '移除',
txt_enabled: '已启用',
txt_checked: '已勾选',
txt_unchecked: '未勾选',
txt_profile: '资料',
txt_save_profile: '保存资料',
txt_change_master_password: '修改主密码',
txt_current_password: '当前密码',
txt_new_password: '新密码',
txt_change_password: '修改密码',
txt_totp: 'TOTP',
txt_enable_totp: '启用 TOTP',
txt_disable_totp: '停用 TOTP',
txt_totp_code: 'TOTP 验证码',
txt_totp_secret: 'TOTP 密钥',
txt_verification_code: '验证码',
txt_recovery_code: '恢复代码',
txt_view_recovery_code: '查看恢复代码',
txt_copy_code: '复制代码',
txt_device_management: '设备管理',
txt_authorized_devices: '已授权设备',
txt_device: '设备',
txt_last_seen: '最后在线',
txt_trusted_until: '信任至',
txt_revoke_trust: '撤销信任',
txt_remove_device_2: '移除设备',
txt_not_trusted: '未信任',
txt_unknown_device: '未知设备',
txt_users: '用户',
txt_invites: '邀请码',
txt_ban: '封禁',
txt_unban: '解封',
txt_create_timed_invite: '创建时效邀请码',
txt_invite_validity_hours: '邀请码有效期(小时)',
txt_delete_all: '全部删除',
txt_prev: '上一页',
txt_next: '下一页',
txt_send_details: '发送详情',
txt_new_send: '新建发送',
txt_edit_send: '编辑发送',
txt_file_send: '文件发送',
txt_text_send: '文本发送',
txt_file: '文件',
txt_text: '文本',
txt_file_name: '文件名',
txt_file_size: '文件大小',
txt_access_count: '访问次数',
txt_deletion_date: '删除日期',
txt_expiration_date: '过期日期',
txt_deletion_days: '删除天数',
txt_expiration_days_0_never: '过期天数(0 表示不过期)',
txt_max_access_count: '最大访问次数',
txt_options: '选项',
txt_disable_this_send: '禁用此发送',
txt_auto_copy_link_after_save: '保存后自动复制链接',
txt_unlock_send: '解锁发送',
txt_nodewarden_send: 'NodeWarden 发送',
txt_send_unavailable: '发送不可用。',
txt_download: '下载',
txt_expires_at: '过期时间',
txt_expires_at_value: '过期于:{value}',
txt_dash: '-',
txt_or: '或',
txt_no_name: '(无名称)',
txt_are_you_sure_you_want_to_log_out: '确认要退出登录吗?',
txt_delete_item: '删除项目',
txt_delete_selected_items: '删除所选项目',
txt_move_selected_items: '移动所选项目',
txt_create_folder: '创建文件夹',
txt_folder_name: '文件夹名称',
txt_unlock_item: '解锁项目',
txt_use_recovery_code: '使用恢复代码',
txt_two_step_verification: '两步验证',
txt_recover_two_step_login: '恢复两步登录',
txt_title: '称谓',
txt_first_name: '名',
txt_middle_name: '中间名',
txt_last_name: '姓',
txt_company: '公司',
txt_ssn: '社保号',
txt_passport_number: '护照号',
txt_license_number: '证件号',
txt_private_key: '私钥',
txt_public_key: '公钥',
txt_fingerprint: '指纹',
txt_master_password_reprompt: '主密码二次确认',
txt_master_password_reprompt_2: '主密码二次确认',
txt_configure_custom_field_values: '配置自定义字段值。',
txt_hidden: '隐藏',
txt_boolean: '布尔',
txt_regenerate: '重新生成',
txt_copy_secret: '复制密钥',
txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically: '这是一次性恢复代码,使用后将自动生成新的恢复代码。',
txt_manage_authorized_devices_and_30_day_totp_trusted_sessions: '管理已授权设备和 30 天 TOTP 受信会话。',
txt_role: '角色',
txt_status: '状态',
txt_actions: '操作',
txt_type: '类型',
txt_revoke_all_trusted: '撤销全部受信任设备',
txt_revoke_all_trusted_devices: '撤销所有受信任设备',
txt_revoke_30_day_totp_trust_from_all_devices: '确认撤销所有设备的 30 天 TOTP 信任吗?',
txt_revoke_30_day_totp_trust_for_name: '确认撤销“{name}”的 30 天 TOTP 信任吗?',
txt_remove_device_name_and_clear_its_2fa_trust: '确认移除设备“{name}”并清除其 2FA 信任吗?',
txt_role_admin: '管理员',
txt_role_user: '用户',
txt_status_active: '正常',
txt_status_banned: '已封禁',
txt_status_inactive: '未激活',
txt_accessed_count_times: '已访问 {count} 次',
txt_add_website: '添加网站',
txt_added: '已添加',
txt_address: '地址',
txt_address_1: '地址 1',
txt_address_2: '地址 2',
txt_address_3: '地址 3',
txt_all_device_authorizations_revoked: '已撤销所有设备授权',
txt_all_invites_deleted: '已删除所有邀请码',
txt_all_sends: '所有发送',
txt_android: '安卓',
txt_are_you_sure_you_want_to_delete_count_selected_items: '确认删除所选的 {count} 个项目?',
txt_are_you_sure_you_want_to_delete_this_item: '确认删除此项目?',
txt_authenticator_key: '验证器密钥',
txt_brand: '品牌',
txt_bulk_delete_failed: '批量删除失败',
txt_bulk_delete_sends_failed: '批量删除发送失败',
txt_bulk_move_failed: '批量移动失败',
txt_cardholder_name: '持卡人姓名',
txt_change_password_failed: '修改密码失败',
txt_choose_destination_folder: '选择目标文件夹。',
txt_chrome_browser: 'Chrome 浏览器',
txt_chrome_extension: 'Chrome 扩展',
txt_city_town: '城市 / 城镇',
txt_code: '代码',
txt_country: '国家',
txt_create: '创建',
txt_create_folder_failed: '创建文件夹失败',
txt_create_item_failed: '创建项目失败',
txt_create_send_failed: '创建发送失败',
txt_current_new_password_is_required: '需要输入当前密码和新密码',
txt_decrypt_failed: '(解密失败)',
txt_decrypt_failed_2: '解密失败',
txt_delete_all_invite_codes_active_inactive: '删除所有邀请码(有效/无效)?',
txt_delete_all_invites: '删除所有邀请码',
txt_delete_item_failed: '删除项目失败',
txt_delete_send_failed: '删除发送失败',
txt_delete_this_user_and_all_user_data: '删除此用户及其所有数据?',
txt_delete_user: '删除用户',
txt_deleted_selected_items: '已删除所选项目',
txt_deleted_selected_sends: '已删除所选发送',
txt_device_authorization_revoked: '已撤销设备授权',
txt_device_removed: '设备已移除',
txt_disable_totp_failed: '禁用 TOTP 失败',
txt_download_failed: '下载失败',
txt_edge_browser: 'Edge 浏览器',
txt_edge_extension: 'Edge 扩展',
txt_email_password_and_recovery_code_are_required: '需要输入邮箱、密码和恢复代码',
txt_enable_totp_failed: '启用 TOTP 失败',
txt_encrypted_file: '加密文件',
txt_encrypted_file_2: '加密文件',
txt_enter_a_folder_name: '请输入文件夹名称',
txt_enter_master_password_to_disable_two_step_verification: '输入主密码以禁用两步验证',
txt_enter_master_password_to_view_this_item: '输入主密码以查看此项目',
txt_expiry: '有效期',
txt_expiry_month: '有效期月',
txt_expiry_year: '有效期年',
txt_failed_to_open_send: '打开发送失败',
txt_field_label_is_required: '字段标签不能为空',
txt_firefox_browser: 'Firefox 浏览器',
txt_firefox_extension: 'Firefox 扩展',
txt_folder_created: '文件夹已创建',
txt_folder_name_is_required: '文件夹名称不能为空',
txt_ie_browser: 'IE 浏览器',
txt_invite_code_optional: '邀请码(可选)',
txt_invite_created: '邀请码已创建',
txt_invite_revoked: '邀请码已撤销',
txt_ios: 'iOS',
txt_item: '项目',
txt_item_created: '项目已创建',
txt_item_deleted: '项目已删除',
txt_item_name_is_required: '项目名称不能为空',
txt_item_updated: '项目已更新',
txt_link_copied: '链接已复制',
txt_linked: '已关联',
txt_linux_desktop: 'Linux 桌面端',
txt_login_failed: '登录失败',
txt_login_success: '登录成功',
txt_macos_desktop: 'macOS 桌面端',
txt_master_password_changed_please_login_again: '主密码已修改,请重新登录',
txt_master_password_is_required: '主密码不能为空',
txt_master_password_is_required_2: '请输入主密码',
txt_master_password_must_be_at_least_12_chars: '主密码至少需要 12 个字符',
txt_moved_selected_items: '已移动所选项目',
txt_name_is_required: '名称不能为空',
txt_new_password_must_be_at_least_12_chars: '新密码至少需要 12 个字符',
txt_new_passwords_do_not_match: '两次输入的新密码不一致',
txt_no_devices_found: '未找到设备',
txt_number: '数字',
txt_opera_browser: 'Opera 浏览器',
txt_opera_extension: 'Opera 扩展',
txt_password_is_already_verified: '密码已验证',
txt_passwords_do_not_match: '两次输入的密码不一致',
txt_phone: '电话',
txt_please_input_email_and_password: '请输入邮箱和密码',
txt_please_input_master_password: '请输入主密码',
txt_please_input_totp_code: '请输入 TOTP 验证码',
txt_please_select_a_file: '请选择文件',
txt_postal_code: '邮政编码',
txt_profile_unavailable: '资料不可用',
txt_profile_updated: '资料已更新',
txt_recover_2fa_failed: '恢复 2FA 失败',
txt_recovered_but_auto_login_failed_please_sign_in: '已恢复,但自动登录失败,请手动登录',
txt_recovery_code_copied: '恢复代码已复制',
txt_recovery_code_is_empty: '恢复代码为空',
txt_recovery_code_loaded: '恢复代码已加载',
txt_refresh_in_seconds_s: '{seconds} 秒后刷新',
txt_registration_succeeded_please_sign_in: '注册成功,请登录',
txt_remove_device: '移除设备',
txt_revoke: '撤销',
txt_revoke_device_authorization: '撤销设备授权',
txt_save_profile_failed: '保存资料失败',
txt_secret_and_code_are_required: '密钥和代码不能为空',
txt_secret_copied: '密钥已复制',
txt_security_code: '安全码',
txt_security_code_cvv: '安全码 (CVV)',
txt_send_created: '发送已创建',
txt_send_deleted: '发送已删除',
txt_send_file: '发送文件',
txt_send_updated: '发送已更新',
txt_state_province: '省 / 州',
txt_text_2fa_recovered: '2FA 已恢复',
txt_text_2fa_recovered_new_recovery_code_code: '2FA 已恢复,新的恢复代码:{code}',
txt_text_3: '------',
txt_text_is_required: '文本不能为空',
txt_this_item_requires_master_password_every_time_before_viewing_details: '每次查看详情前均需输入主密码',
txt_this_link_is_missing_decryption_key: '此链接缺少解密密钥',
txt_this_send_is_password_protected: '此发送受密码保护',
txt_totp_disabled: 'TOTP 已禁用',
txt_totp_enabled: 'TOTP 已启用',
txt_totp_is_enabled_for_this_account: '此账户已启用 TOTP。',
txt_totp_verify_failed: 'TOTP 验证失败',
txt_trust_this_device_for_30_days: '信任此设备 30 天',
txt_type_type: '类型 {type}',
txt_unlock_details: '解锁详情',
txt_unlock_failed: '解锁失败',
txt_unlock_failed_master_password_is_incorrect: '解锁失败,主密码不正确。',
txt_unlocked: '已解锁',
txt_update_item_failed: '更新项目失败',
txt_update_send_failed: '更新发送失败',
txt_use_your_one_time_recovery_code_to_disable_two_step_verification: '使用一次性恢复代码禁用两步验证。',
txt_user_deleted: '用户已删除',
txt_user_status_updated: '用户状态已更新',
txt_vault_synced: '保险库已同步',
txt_verify: '验证',
txt_web: '网页',
txt_windows_desktop: 'Windows 桌面端',
txt_jwt_warning_title: 'JWT_SECRET 配置警告',
txt_jwt_warning_subtitle: 'JWT 密钥当前不安全,请先修复后再继续。',
txt_jwt_title_missing: '未检测到 JWT_SECRET',
txt_jwt_title_too_short: 'JWT_SECRET 长度过短',
txt_jwt_title_default: 'JWT_SECRET使用默认值',
txt_jwt_reason_missing: '未检测到 JWT_SECRET。',
txt_jwt_reason_default: 'JWT_SECRET 仍在使用默认示例值。',
txt_jwt_reason_too_short: 'JWT_SECRET 长度过短,至少需要 {min} 位。',
txt_jwt_how_to_fix_add: '处理步骤(添加 JWT_SECRET',
txt_jwt_how_to_fix_replace: '处理步骤(更换 JWT_SECRET',
txt_jwt_add_step_1: '使用下方 32 位随机生成器,复制一个新密钥。',
txt_jwt_add_step_2: '到 Cloudflare 控制台 -> Workers 和 Pages -> 你的服务 -> 设置 -> 变量和机密,新增 JWT_SECRET。',
txt_jwt_add_step_3: '保存并等待重新部署完成,然后刷新本页确认。',
txt_jwt_replace_step_1: '使用下方 32 位随机生成器,生成更强的密钥(至少 {min} 位)。',
txt_jwt_replace_step_2: '到 Cloudflare 控制台 -> Workers 和 Pages -> 你的服务 -> 设置 -> 变量和机密,替换 JWT_SECRET。',
txt_jwt_replace_step_3: '保存并等待重新部署完成,然后刷新本页确认。',
txt_how_to_fix: '处理步骤(添加 / 更换)',
txt_jwt_fix_step_1: '你可以继续下一步,不影响使用。',
txt_jwt_fix_step_2: '如果当前密钥不是强随机值,建议使用下方 32 位生成器。',
txt_jwt_fix_step_3: '到 Cloudflare 控制台 -> Workers 和 Pages -> 你的服务 -> 设置 -> 变量和机密,更新 JWT_SECRET。',
txt_jwt_fix_step_4: '保存并等待重新部署完成,然后刷新本页确认。',
txt_random_secret_generator: '随机密钥生成器',
txt_copied: '已复制',
};
zhCNOverrides.txt_lock = '锁定';
zhCNOverrides.txt_passkey = 'Passkey';
zhCNOverrides.txt_passkey_created_at_value = '创建于 {value}';
zhCNOverrides.txt_attachments = '附件';
zhCNOverrides.txt_upload_attachments = '上传附件';
zhCNOverrides.txt_new_attachments = '待上传附件';
zhCNOverrides.txt_marked_for_removal_count = '保存后将删除 {count} 个附件';
messages.en.txt_import = 'Import';
messages.en.txt_export = 'Export';
messages.en.txt_format = 'Format';
messages.en.txt_source_file = 'Source file';
messages.en.txt_folder_handling = 'Folder handling';
messages.en.txt_import_folder_mode_original = 'Original path from import file';
messages.en.txt_import_folder_mode_none = 'No folder';
messages.en.txt_import_folder_mode_target = 'One selected folder';
messages.en.txt_target_folder = 'Target folder';
messages.en.txt_select_folder_placeholder = '-- Select folder --';
messages.en.txt_import_vault_data_hint = 'Import vault data into your current account.';
messages.en.txt_export_vault_data_hint = 'Export vault data from your current account.';
messages.en.txt_import_export_title = 'Import & Export';
messages.en.txt_import_export_feature_intro = 'Provides standardized vault migration across clients, including attachment-aware and encrypted workflows.';
messages.en.txt_import_export_feature_bw_zip_title = 'Bitwarden vault + attachments ZIP';
messages.en.txt_import_export_feature_bw_zip_desc = 'Supports both import and export for Bitwarden ZIP archives containing vault data and attachments.';
messages.en.txt_import_export_feature_nodewarden_json_title = 'NodeWarden vault + attachments JSON';
messages.en.txt_import_export_feature_nodewarden_json_desc = 'Supports NodeWarden JSON import/export with vault and attachment payloads in a single document. Exported vault data remains importable by Bitwarden clients.';
messages.en.txt_import_export_feature_compat_title = 'Cross-client compatibility';
messages.en.txt_import_export_feature_compat_desc = 'Supports Bitwarden JSON/CSV and mainstream migration formats with consistent field normalization and import mapping.';
messages.en.txt_encrypted_mode = 'Encrypted mode';
messages.en.txt_account_verification = 'Account verification';
messages.en.txt_password_verification = 'Password verification';
messages.en.txt_file_password = 'File password';
messages.en.txt_zip_password_optional = 'ZIP password (optional)';
messages.en.txt_zip_password = 'ZIP password';
messages.en.txt_close = 'Close';
messages.en.txt_total = 'Total';
messages.en.txt_import_success = 'Import successful';
messages.en.txt_import_success_number_of_items = 'Imported {count} item(s) in total.';
messages.en.txt_import_file_password_required = 'Please enter file password.';
messages.en.txt_import_invalid_zip_password = 'Invalid ZIP password.';
messages.en.txt_export_completed = 'Export completed';
messages.en.txt_export_failed = 'Export failed';
messages.en.txt_import_invalid_password_protected_file = 'Invalid password-protected export file.';
messages.en.txt_import_decrypt_failed = 'Failed to decrypt import file.';
messages.en.txt_import_empty_zip_archive = 'Empty zip archive.';
messages.en.txt_import_no_json_found_in_zip = 'No importable JSON data found in zip archive.';
messages.en.txt_import_data_json_not_found = 'data.json not found in zip archive.';
messages.en.txt_import_zip_password_required = 'ZIP password is required.';
messages.en.txt_import_invalid_json_file = 'Invalid JSON file';
messages.en.txt_import_failed = 'Import failed';
messages.en.txt_import_encrypted_file_title = 'Import encrypted file';
messages.en.txt_import_encrypted_file_message = 'This Bitwarden export is password-protected. Enter the export file password to continue.';
messages.en.txt_import_encrypted_zip_title = 'Import encrypted ZIP';
messages.en.txt_import_encrypted_zip_message = 'This ZIP archive is password-protected. Enter the ZIP password to continue.';
zhCNOverrides.txt_import = '导入';
zhCNOverrides.txt_export = '导出';
zhCNOverrides.txt_format = '格式';
zhCNOverrides.txt_source_file = '源文件';
zhCNOverrides.txt_folder_handling = '文件夹处理';
zhCNOverrides.txt_import_folder_mode_original = '保留导入文件中的原始路径';
zhCNOverrides.txt_import_folder_mode_none = '不使用文件夹';
zhCNOverrides.txt_import_folder_mode_target = '导入到指定文件夹';
zhCNOverrides.txt_target_folder = '目标文件夹';
zhCNOverrides.txt_select_folder_placeholder = '-- 选择文件夹 --';
zhCNOverrides.txt_import_vault_data_hint = '将数据导入到当前账号。';
zhCNOverrides.txt_export_vault_data_hint = '从当前账号导出数据。';
zhCNOverrides.txt_encrypted_mode = '加密方式';
zhCNOverrides.txt_account_verification = '账号验证';
zhCNOverrides.txt_password_verification = '密码验证';
zhCNOverrides.txt_file_password = '文件密码';
zhCNOverrides.txt_zip_password_optional = 'ZIP 密码(可选)';
zhCNOverrides.txt_zip_password = 'ZIP 密码';
zhCNOverrides.txt_close = '关闭';
zhCNOverrides.txt_total = '总计';
zhCNOverrides.txt_import_success = '数据导入成功';
zhCNOverrides.txt_import_success_number_of_items = '一共导入了 {count} 个项目。';
zhCNOverrides.txt_import_file_password_required = '请输入文件密码。';
zhCNOverrides.txt_import_invalid_zip_password = 'ZIP 密码错误。';
zhCNOverrides.txt_export_completed = '导出完成';
zhCNOverrides.txt_export_failed = '导出失败';
zhCNOverrides.txt_import_invalid_password_protected_file = '密码保护导出文件格式无效。';
zhCNOverrides.txt_import_decrypt_failed = '导入文件解密失败。';
zhCNOverrides.txt_import_empty_zip_archive = 'ZIP 压缩包为空。';
zhCNOverrides.txt_import_no_json_found_in_zip = 'ZIP 内未找到可导入的 JSON 数据。';
zhCNOverrides.txt_import_data_json_not_found = 'ZIP 内未找到 data.json。';
zhCNOverrides.txt_import_zip_password_required = '该 ZIP 需要密码。';
zhCNOverrides.txt_import_invalid_json_file = 'JSON 文件无效';
zhCNOverrides.txt_import_failed = '导入失败';
zhCNOverrides.txt_import_encrypted_file_title = '导入加密文件';
zhCNOverrides.txt_import_encrypted_file_message = '该 Bitwarden 导出文件已加密,请输入文件密码继续。';
zhCNOverrides.txt_import_encrypted_zip_title = '导入加密 ZIP';
zhCNOverrides.txt_import_encrypted_zip_message = '该 ZIP 压缩包已加密,请输入 ZIP 密码继续。';
zhCNOverrides.txt_import_export_title = '导入导出';
zhCNOverrides.txt_import_export_feature_intro = '提供标准化的数据迁移能力,覆盖附件与加密场景。';
zhCNOverrides.txt_import_export_feature_bw_zip_title = 'Bitwarden 密码库 + 附件 ZIP';
zhCNOverrides.txt_import_export_feature_bw_zip_desc = '支持导入与导出包含密码库和附件的 Bitwarden ZIP 压缩包。';
zhCNOverrides.txt_import_export_feature_nodewarden_json_title = 'NodeWarden 密码库 + 附件 JSON';
zhCNOverrides.txt_import_export_feature_nodewarden_json_desc = '支持 NodeWarden JSON 导入导出,单文件包含密码库与附件;导出的密码库数据可被 Bitwarden 客户端导入。';
zhCNOverrides.txt_import_export_feature_compat_title = '跨客户端兼容';
zhCNOverrides.txt_import_export_feature_compat_desc = '支持 Bitwarden JSON/CSV 与主流迁移格式,统一字段映射与导入行为。';
messages['zh-CN'] = { ...messages.en, ...zhCNOverrides };
function resolveInitialLocale(): Locale {
try {
const saved = localStorage.getItem(LOCALE_STORAGE_KEY);
if (saved === 'en' || saved === 'zh-CN') return saved;
} catch {
// ignore storage errors
}
if (typeof navigator !== 'undefined') {
const langs = Array.isArray(navigator.languages) ? navigator.languages : [navigator.language];
for (const lang of langs) {
if (String(lang || '').toLowerCase().startsWith('zh')) return 'zh-CN';
}
}
return 'en';
}
let locale: Locale = resolveInitialLocale();
export type I18nParams = Record<string, string | number | null | undefined>;
export function t(key: string, params?: I18nParams): string {
const template = messages[locale][key] ?? key;
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? ''));
}
export function getLocale(): Locale {
return locale;
}
export function setLocale(next: Locale): void {
locale = next;
try {
localStorage.setItem(LOCALE_STORAGE_KEY, next);
} catch {
// ignore storage errors
}
}
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function base64ToBytes(base64: string): Uint8Array | null {
const normalized = base64.replace(/\s+/g, '').replace(/-/g, '+').replace(/_/g, '/');
if (!normalized) return null;
const padLength = (4 - (normalized.length % 4)) % 4;
const padded = normalized + '='.repeat(padLength);
try {
const binary = atob(padded);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
return out;
} catch {
return null;
}
}
function concatBytes(...parts: Uint8Array[]): Uint8Array {
const total = parts.reduce((sum, part) => sum + part.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
function encodeSshString(value: Uint8Array): Uint8Array {
const out = new Uint8Array(4 + value.length);
const view = new DataView(out.buffer);
view.setUint32(0, value.length, false);
out.set(value, 4);
return out;
}
function extractSshBlobFromPublicKey(publicKey: string): Uint8Array | null {
const text = String(publicKey || '').trim();
if (!text) return null;
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
for (const line of lines) {
const match = line.match(/^([A-Za-z0-9-]+)\s+([A-Za-z0-9+/=_-]+)(?:\s+.*)?$/);
if (!match) continue;
const keyType = match[1].toLowerCase();
if (!keyType.startsWith('ssh-') && !keyType.startsWith('ecdsa-')) continue;
return base64ToBytes(match[2]);
}
return null;
}
export async function computeSshFingerprint(publicKey: string): Promise<string> {
const blob = extractSshBlobFromPublicKey(publicKey);
if (!blob) return '';
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', blob as unknown as BufferSource));
return `SHA256:${bytesToBase64(digest).replace(/=+$/g, '')}`;
}
function toPem(tag: string, bytes: Uint8Array): string {
const b64 = bytesToBase64(bytes);
const chunks: string[] = [];
for (let i = 0; i < b64.length; i += 64) chunks.push(b64.slice(i, i + 64));
return `-----BEGIN ${tag}-----\n${chunks.join('\n')}\n-----END ${tag}-----`;
}
function extractEd25519RawPublicKey(spki: Uint8Array): Uint8Array | null {
const prefix = new Uint8Array([0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00]);
const hasPrefix = spki.length >= prefix.length + 32 && prefix.every((value, idx) => spki[idx] === value);
if (hasPrefix) return spki.slice(prefix.length, prefix.length + 32);
if (spki.length >= 32) return spki.slice(spki.length - 32);
return null;
}
export async function generateDefaultSshKeyMaterial(): Promise<{ privateKey: string; publicKey: string; fingerprint: string }> {
const keyPair = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
const pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey));
const spki = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey));
const rawPublic = extractEd25519RawPublicKey(spki);
if (!rawPublic) throw new Error('Cannot export Ed25519 public key');
const encoder = new TextEncoder();
const sshBlob = concatBytes(encodeSshString(encoder.encode('ssh-ed25519')), encodeSshString(rawPublic));
const publicKey = `ssh-ed25519 ${bytesToBase64(sshBlob)}`;
const privateKey = toPem('PRIVATE KEY', pkcs8);
const fingerprint = await computeSshFingerprint(publicKey);
return { privateKey, publicKey, fingerprint };
}
+310
View File
@@ -0,0 +1,310 @@
export type AppPhase = 'loading' | 'register' | 'login' | 'locked' | 'app';
export interface SessionState {
accessToken: string;
refreshToken: string;
email: string;
symEncKey?: string;
symMacKey?: string;
}
export interface Profile {
id: string;
email: string;
name: string;
key: string;
role: 'admin' | 'user';
[k: string]: unknown;
}
export interface Folder {
id: string;
name: string;
decName?: string;
}
export interface CipherLoginUri {
uri?: string | null;
decUri?: string;
}
export interface CipherAttachment {
id?: string;
url?: string | null;
fileName?: string | null;
decFileName?: string;
key?: string | null;
size?: string | number | null;
sizeName?: string | null;
object?: string;
}
export interface CipherLoginPasskey {
creationDate?: string | null;
[key: string]: unknown;
}
export interface CipherLogin {
username?: string | null;
password?: string | null;
totp?: string | null;
uris?: CipherLoginUri[] | null;
fido2Credentials?: CipherLoginPasskey[] | null;
decUsername?: string;
decPassword?: string;
decTotp?: string;
}
export interface CipherCard {
cardholderName?: string | null;
number?: string | null;
brand?: string | null;
expMonth?: string | null;
expYear?: string | null;
code?: string | null;
decCardholderName?: string;
decNumber?: string;
decBrand?: string;
decExpMonth?: string;
decExpYear?: string;
decCode?: string;
}
export interface CipherIdentity {
title?: string | null;
firstName?: string | null;
middleName?: string | null;
lastName?: string | null;
username?: string | null;
company?: string | null;
ssn?: string | null;
passportNumber?: string | null;
licenseNumber?: string | null;
email?: string | null;
phone?: string | null;
address1?: string | null;
address2?: string | null;
address3?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
decTitle?: string;
decFirstName?: string;
decMiddleName?: string;
decLastName?: string;
decUsername?: string;
decCompany?: string;
decSsn?: string;
decPassportNumber?: string;
decLicenseNumber?: string;
decEmail?: string;
decPhone?: string;
decAddress1?: string;
decAddress2?: string;
decAddress3?: string;
decCity?: string;
decState?: string;
decPostalCode?: string;
decCountry?: string;
}
export interface CipherSshKey {
privateKey?: string | null;
publicKey?: string | null;
keyFingerprint?: string | null;
fingerprint?: string | null;
decPrivateKey?: string;
decPublicKey?: string;
decFingerprint?: string;
}
export interface CipherField {
type?: number | string | null;
name?: string | null;
value?: string | null;
linkedId?: number | null;
decName?: string;
decValue?: string;
}
export interface Cipher {
id: string;
type: number;
folderId?: string | null;
favorite?: boolean;
reprompt?: number;
name?: string | null;
notes?: string | null;
key?: string | null;
creationDate?: string;
revisionDate?: string;
deletedDate?: string | null;
attachments?: CipherAttachment[] | null;
login?: CipherLogin | null;
card?: CipherCard | null;
identity?: CipherIdentity | null;
sshKey?: CipherSshKey | null;
secureNote?: { type?: number | null } | null;
passwordHistory?: Array<{ password?: string | null; lastUsedDate?: string | null }> | null;
fields?: CipherField[] | null;
decName?: string;
decNotes?: string;
}
export interface SendTextData {
text?: string | null;
hidden?: boolean;
}
export interface Send {
id: string;
accessId: string;
type: number;
name?: string | null;
notes?: string | null;
text?: SendTextData | null;
key?: string | null;
maxAccessCount?: number | null;
accessCount?: number;
disabled?: boolean;
revisionDate?: string;
expirationDate?: string | null;
deletionDate?: string;
decName?: string;
decNotes?: string;
decText?: string;
decShareKey?: string;
shareUrl?: string;
file?: {
id?: string;
fileName?: string;
size?: string | number;
sizeName?: string;
} | null;
}
export interface SendDraft {
id?: string;
type: 'text' | 'file';
name: string;
notes: string;
text: string;
file: File | null;
deletionDays: string;
expirationDays: string;
maxAccessCount: string;
password: string;
disabled: boolean;
}
export type CustomFieldType = 0 | 1 | 2 | 3;
export interface VaultDraftField {
type: CustomFieldType;
label: string;
value: string;
}
export interface VaultDraft {
id?: string;
type: number;
favorite: boolean;
name: string;
folderId: string;
notes: string;
reprompt: boolean;
loginUsername: string;
loginPassword: string;
loginTotp: string;
loginUris: string[];
loginFido2Credentials: Array<Record<string, unknown>>;
cardholderName: string;
cardNumber: string;
cardBrand: string;
cardExpMonth: string;
cardExpYear: string;
cardCode: string;
identTitle: string;
identFirstName: string;
identMiddleName: string;
identLastName: string;
identUsername: string;
identCompany: string;
identSsn: string;
identPassportNumber: string;
identLicenseNumber: string;
identEmail: string;
identPhone: string;
identAddress1: string;
identAddress2: string;
identAddress3: string;
identCity: string;
identState: string;
identPostalCode: string;
identCountry: string;
sshPrivateKey: string;
sshPublicKey: string;
sshFingerprint: string;
customFields: VaultDraftField[];
}
export interface ListResponse<T> {
object: 'list';
data: T[];
}
export interface SetupStatusResponse {
registered: boolean;
}
export interface WebConfigResponse {
defaultKdfIterations?: number;
jwtUnsafeReason?: 'missing' | 'default' | 'too_short' | null;
jwtSecretMinLength?: number;
}
export interface TokenSuccess {
access_token: string;
refresh_token: string;
TwoFactorToken?: string;
}
export interface TokenError {
error?: string;
error_description?: string;
TwoFactorProviders?: unknown;
}
export interface ToastMessage {
id: string;
type: 'success' | 'error';
text: string;
}
export interface AdminUser {
id: string;
email: string;
name?: string;
role: string;
status: string;
}
export interface AdminInvite {
code: string;
inviteLink?: string;
status: string;
expiresAt?: string;
}
export interface AuthorizedDevice {
id: string;
name: string;
identifier: string;
type: number;
creationDate: string | null;
revisionDate: string | null;
trusted: boolean;
trustedTokenCount: number;
trustedUntil: string | null;
}
+20
View File
@@ -0,0 +1,20 @@
import { render } from 'preact';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './styles.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
});
render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
document.getElementById('root')!
);
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
declare module 'qrcode-generator' {
interface QrCode {
addData(data: string): void;
make(): void;
createSvgTag(options?: { scalable?: boolean; margin?: number }): string;
}
export default function qrcode(typeNumber: number, errorCorrectionLevel: 'L' | 'M' | 'Q' | 'H'): QrCode;
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"jsxImportSource": "preact",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"strict": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"types": ["vite/client"]
},
"include": ["src/**/*", "vite.config.ts"]
}
+43
View File
@@ -0,0 +1,43 @@
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import preact from '@preact/preset-vite';
import { defineConfig } from 'vite';
const rootDir = fileURLToPath(new URL('.', import.meta.url));
export default defineConfig({
root: rootDir,
plugins: [preact()],
resolve: {
alias: {
'@': path.resolve(rootDir, 'src'),
},
},
build: {
outDir: path.resolve(rootDir, '../dist'),
emptyOutDir: true,
sourcemap: false,
target: 'esnext',
rollupOptions: {
output: {
manualChunks: {
vendor: ['preact', 'preact/hooks', 'preact/jsx-runtime'],
query: ['@tanstack/react-query'],
icons: ['lucide-preact'],
},
},
},
},
server: {
port: 5173,
proxy: {
'/api': 'http://127.0.0.1:8787',
'/identity': 'http://127.0.0.1:8787',
'/setup': 'http://127.0.0.1:8787',
'/icons': 'http://127.0.0.1:8787',
'/config': 'http://127.0.0.1:8787',
'/notifications': 'http://127.0.0.1:8787',
'/.well-known': 'http://127.0.0.1:8787',
},
},
});
+4
View File
@@ -1,6 +1,10 @@
name = "nodewarden" name = "nodewarden"
main = "src/index.ts" main = "src/index.ts"
compatibility_date = "2024-01-01" compatibility_date = "2024-01-01"
assets = { directory = "./dist", not_found_handling = "single-page-application", run_worker_first = ["/api/*", "/identity/*", "/icons/*", "/setup/*", "/config", "/notifications/*", "/.well-known/*"] }
[build]
command = "npm run build"
# D1 Database for storing vault data # D1 Database for storing vault data
[[d1_databases]] [[d1_databases]]