Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2654dcde3 | |||
| cb662b7d70 | |||
| 1ac063909f | |||
| 35dc239c25 | |||
| c99a558b5e | |||
| 819734ce5c | |||
| 7b4733d4c4 | |||
| af56236dba | |||
| 3622c58680 | |||
| b5284e669a | |||
| 4da5525a1a | |||
| 16a7bcace9 | |||
| f59e81de3a | |||
| 227d43194d | |||
| 3341a9ef74 | |||
| d0c97ee573 | |||
| 5dab96f40e | |||
| dc12a73ab3 | |||
| 9c9c76d82e | |||
| a1d38b76c6 | |||
| 705a716a80 | |||
| 1a1b334f6c | |||
| 8d6835b665 | |||
| 189a7b9285 | |||
| 23a45913e0 | |||
| ace9f4f5ac | |||
| c0683016c3 | |||
| e9ace523e6 | |||
| 4390251c1e | |||
| aef0c2f688 | |||
| 594ca0c7ea | |||
| 26447cd9b4 | |||
| f5a2523f91 | |||
| bbf4094943 | |||
| 9f14bca99a | |||
| 8641df3cff | |||
| 8852127743 | |||
| 053ce887f9 | |||
| 2fbe29a0d9 | |||
| 15b87025ad | |||
| 0e823e80a6 | |||
| bb50617b16 | |||
| be3b68956b | |||
| 0f132f4f43 | |||
| 32c695c81f | |||
| 651eb69bd6 | |||
| 0cf8028087 | |||
| 3494471cad | |||
| 59566f88e3 | |||
| 172f6626c0 | |||
| 829008db7f | |||
| 363aec1652 | |||
| b8c4bcef0c | |||
| d0c8516021 | |||
| 1f4933c5d5 | |||
| 4a37d742eb | |||
| 6bbc7554c1 | |||
| d80821edeb | |||
| 6e95d7a235 | |||
| f9b084d09d | |||
| 4f82cf9d43 | |||
| bc0fd65b6b | |||
| 08114762bc | |||
| 1dfa96611a | |||
| 36715645c6 | |||
| 3873d347aa | |||
| 874d31e86b | |||
| cd7b5a361c | |||
| 9eddb91237 | |||
| b2e8d3e00b | |||
| a83e0d259e | |||
| b6f2882cdf | |||
| aaf5078c8a | |||
| 76d766d5d6 | |||
| cdbe87aac2 | |||
| d1a43f2e95 | |||
| 8d6bcc327d | |||
| d1e6ec8b8d | |||
| 3e56d05283 | |||
| 870149c771 | |||
| 9771df8777 | |||
| 0be3b91dd7 | |||
| 645a2f8e95 | |||
| f63b5d6cf4 | |||
| 081dc64093 | |||
| 3ec1ecf464 | |||
| b6d4113e21 | |||
| c53819e178 | |||
| fff2b149e9 | |||
| 50ee2e6b64 | |||
| 309cd98edc | |||
| 3fff6c0277 | |||
| ced0a183b3 | |||
| 4939df7fa2 | |||
| 6c3fbbe78c | |||
| 719024d0fd | |||
| ff7b44e501 | |||
| 4772c17e44 | |||
| b33ee64c58 | |||
| c825280707 | |||
| c445714fd5 | |||
| f2a857d3f3 | |||
| 435a21072c | |||
| 70a58aeb04 | |||
| 866ffb8390 | |||
| d2ce2aea24 | |||
| 5fc2436552 |
@@ -1,3 +1,5 @@
|
|||||||
# JWT Secret for signing tokens (required)
|
# JWT Secret for signing tokens (required)
|
||||||
|
# IMPORTANT: change this value before any real deployment.
|
||||||
# Generate one with: openssl rand -hex 32
|
# Generate one with: openssl rand -hex 32
|
||||||
JWT_SECRET=your-secret-key-herexxs22fd2ds
|
# (Example only, 64 hex chars = 32 bytes)
|
||||||
|
JWT_SECRET=Enter-your-JWT-key-here-at-least-32-characters
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: Sync upstream
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- run: |
|
||||||
|
git remote add upstream https://github.com/shuaiplus/nodewarden.git || true
|
||||||
|
git fetch upstream
|
||||||
|
|
||||||
|
# 强制让当前分支完全等于 upstream
|
||||||
|
git reset --hard upstream/main
|
||||||
|
|
||||||
|
# 强制推送
|
||||||
|
git push origin main --force
|
||||||
@@ -6,6 +6,8 @@ node_modules/
|
|||||||
.dev.vars
|
.dev.vars
|
||||||
wrangler.my.toml
|
wrangler.my.toml
|
||||||
RELEASE_NOTES.md
|
RELEASE_NOTES.md
|
||||||
|
tests/selfcheck.ts
|
||||||
|
problem.md
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
@@ -35,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/
|
||||||
|
After Width: | Height: | Size: 122 KiB |
@@ -1,108 +1,138 @@
|
|||||||
# NodeWarden
|
<p align="center">
|
||||||
中文文档:[`README_ZH.md`](./README_ZH.md)
|
<img src="./NodeWarden.png" alt="NodeWarden Logo" />
|
||||||
|
</p>
|
||||||
|
|
||||||
A **Bitwarden-compatible** server that runs on **Cloudflare Workers**, designed for personal use.
|
<p align="center">
|
||||||
|
运行在 Cloudflare Workers 的 Bitwarden 第三方服务端,兼容官方客户端
|
||||||
|
</p>
|
||||||
|
|
||||||
- Simple deploy (no VPS)
|
[](https://workers.cloudflare.com/)
|
||||||
- Focused feature set
|
[](./LICENSE)
|
||||||
- Low maintenance
|
[](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/NodeWarden)
|
||||||
|
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||||
|
[](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)
|
||||||
|
|
||||||
|
|
||||||
> Disclaimer
|
> **免责声明**
|
||||||
> - This project is **not affiliated** with Bitwarden.
|
> 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份您的密码库。
|
||||||
> - Use at your own risk. Keep regular backups of your vault.
|
> 本项目与 Bitwarden 官方无关,请勿向 Bitwarden 官方反馈问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
## 与 Bitwarden 官方服务端能力对比
|
||||||
|
|
||||||
|
| 能力项 | Bitwarden | NodeWarden | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Web Vault(登录/笔记/卡片/身份) | ✅ | ✅ | 网页端密码库管理页面 |
|
||||||
|
| 文件夹 / 收藏 | ✅ | ✅ | 常用管理能力可用 |
|
||||||
|
| 全量同步 `/api/sync` | ✅ | ✅ | 已做兼容与性能优化 |
|
||||||
|
| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 |
|
||||||
|
| 导入导出功能 | ✅ | ✅ | 完整实现,含 Bitwarden 密码库+附件 ZIP 导入 |
|
||||||
|
| 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` |
|
||||||
|
| passkey、TOTP字段 | ❌ | ✅ |官方需要会员,我们的不需要 |
|
||||||
|
| Send | ✅ | ✅ | 已支持文本 Send 与文件 Send |
|
||||||
|
| 多用户 | ✅ | ✅ | 完整的用户管理,邀请机制 |
|
||||||
|
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 |
|
||||||
|
| 登录 2FA(TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ 部分支持 | 仅支持用户级 TOTP |
|
||||||
|
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
|
||||||
|
| 紧急访问 | ✅ | ❌ | 没必要实现 |
|
||||||
|
| 管理后台 / 计费订阅 | ✅ | ❌ | 纯免费 |
|
||||||
|
| 推送通知完整链路 | ✅ | ❌ | 没必要实现 |
|
||||||
|
|
||||||
|
## 测试情况:
|
||||||
|
|
||||||
|
- ✅ Windows 客户端(v2026.1.0)
|
||||||
|
- ✅ 手机 App(v2026.1.0)
|
||||||
|
- ✅ 浏览器扩展(v2026.1.0)
|
||||||
|
- ✅ Linux 客户端(v2026.1.0)
|
||||||
|
- ⬜ macOS 客户端(未测试)
|
||||||
|
---
|
||||||
|
|
||||||
|
# 快速开始
|
||||||
|
|
||||||
|
### 一键部署
|
||||||
|
|
||||||
|
**部署步骤:**
|
||||||
|
|
||||||
|
1. 首先Fork本仓库,命名为**NodeWarden**
|
||||||
|
2. 点击下面的一键部署按钮,修改项目名称为**NodeWarden2**,修改**JWT_SECRET**成32为随机字符串
|
||||||
|
3. [](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
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## 本地开发
|
||||||
|
|
||||||
- ✅ **Free to use. No server to manage.**
|
这是一个 Cloudflare Workers 的 TypeScript 项目(Wrangler)。
|
||||||
- ✅ Full support for logins, notes, cards, and identities
|
|
||||||
- ✅ Folders and favorites
|
|
||||||
- ✅ Attachments (Cloudflare R2)
|
|
||||||
- ✅ Import / export
|
|
||||||
- ✅ Website icons
|
|
||||||
- ✅ End-to-end encryption (the server can’t see plaintext)
|
|
||||||
- ✅ Compatible with common Bitwarden official clients
|
|
||||||
|
|
||||||
## Tested clients / platforms
|
|
||||||
|
|
||||||
- ✅ Windows desktop client(v2026.1.0)
|
|
||||||
- ✅ Android app (v2026.1.0)
|
|
||||||
- ✅ Browser extension(v2026.1.0)
|
|
||||||
- ⬜ macOS desktop client (not tested)
|
|
||||||
- ⬜ Linux desktop client (not tested)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Quick start
|
|
||||||
|
|
||||||
### One-click deploy
|
|
||||||
|
|
||||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
|
|
||||||
|
|
||||||
**Deploy steps:**
|
|
||||||
|
|
||||||
1. Sign in with GitHub and authorize
|
|
||||||
2. Sign in to Cloudflare
|
|
||||||
3. **Important**: set `JWT_SECRET` to a strong random string (recommended: `openssl rand -hex 32`)
|
|
||||||
4. KV namespace and R2 bucket will be created automatically
|
|
||||||
5. Click **Deploy** and wait for it to finish
|
|
||||||
6. After deploy, open the Cloudflare-provided Workers URL (your service URL), and register on the web page
|
|
||||||
|
|
||||||
> ⚠️ **Reminder**: always use a strong random `JWT_SECRET`. Weak secrets may put your account at risk.
|
|
||||||
|
|
||||||
### Configure your client
|
|
||||||
|
|
||||||
In any Bitwarden client:
|
|
||||||
|
|
||||||
1. Open **Settings**
|
|
||||||
2. Choose **Self-hosted environment**
|
|
||||||
3. Set **Server URL** to your Worker URL (for example: `https://your-project.your-subdomain.workers.dev`)
|
|
||||||
4. Save, then go back to the login screen
|
|
||||||
|
|
||||||
## 🧑💻 Local development
|
|
||||||
|
|
||||||
This repo is a Cloudflare Workers TypeScript project (Wrangler).
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
---
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
**Q: 如何备份数据?**
|
||||||
|
A: 在客户端中选择「导出密码库」,保存 JSON 文件。
|
||||||
|
|
||||||
|
**Q: 导入导出支持哪些格式?**
|
||||||
|
A: 支持 Bitwarden `json/csv/密码库+附件 zip` 和 NodeWarden `密码库+附件 json`(均含加密模式),且导入下拉中看到的格式都可直接导入。
|
||||||
|
A: 另外支持直接导入 Bitwarden `密码库+附件 zip`,这条路径官方 Bitwarden Web 不支持。
|
||||||
|
|
||||||
|
**Q: 忘记主密码怎么办?**
|
||||||
|
A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。
|
||||||
|
|
||||||
|
**Q: 可以多人使用吗?**
|
||||||
|
A: 支持。第一个注册的用户自动成为管理员,管理员可在管理页面生成邀请码,其他用户凭邀请码注册。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tech stack
|
## 开源协议
|
||||||
|
|
||||||
- **Runtime**: Cloudflare Workers
|
|
||||||
- **Data storage**: Cloudflare KV
|
|
||||||
- **File storage**: Cloudflare R2
|
|
||||||
- **Language**: TypeScript
|
|
||||||
- **Crypto**: Client-side AES-256-CBC, JWT uses HS256
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
**Q: How do I back up my data?**
|
|
||||||
A: Use **Export vault** in your client and save the JSON file.
|
|
||||||
|
|
||||||
**Q: What if I forget the master password?**
|
|
||||||
A: It can’t be recovered (end-to-end encryption). Keep it safe.
|
|
||||||
|
|
||||||
**Q: Can multiple people use it?**
|
|
||||||
A: Not recommended. This project is designed for single-user usage.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
LGPL-3.0 License
|
LGPL-3.0 License
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Credits
|
## 致谢
|
||||||
|
|
||||||
- [Bitwarden](https://bitwarden.com/) - original design and clients
|
- [Bitwarden](https://bitwarden.com/) - 原始设计和客户端
|
||||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - server implementation reference
|
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务器实现参考
|
||||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - serverless platform
|
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
||||||
|
---
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
[](https://workers.cloudflare.com/)
|
||||||
|
[](./LICENSE)
|
||||||
|
[](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/NodeWarden)
|
||||||
|
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||||
|
[](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)
|
||||||
|
|
||||||
|
> **Disclaimer**
|
||||||
|
> This project is for learning and communication purposes 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Comparison Table (vs Official Bitwarden Server)
|
||||||
|
|
||||||
|
| Capability | Bitwarden | NodeWarden | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Web Vault (logins/notes/cards/identities) | ✅ | ✅ | Web-based vault management UI |
|
||||||
|
| Folders / Favorites | ✅ | ✅ | Common vault organization supported |
|
||||||
|
| Full sync `/api/sync` | ✅ | ✅ | Compatibility and performance optimized |
|
||||||
|
| Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 |
|
||||||
|
| mport / export | ✅ | ✅ | Fully implemented, including Bitwarden vault + attachments ZIP import. |
|
||||||
|
| Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` |
|
||||||
|
| passkey、TOTP fields | ❌ | ✅ | Official service requires premium; NodeWarden does not |
|
||||||
|
| Multi-user | ✅ | ✅ | Full user management with invitation mechanism |
|
||||||
|
| Send | ✅ | ✅ | Text Send and File Send are supported |
|
||||||
|
| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement |
|
||||||
|
| Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ Partial | User-level TOTP only |
|
||||||
|
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement |
|
||||||
|
| Emergency access | ✅ | ❌ | Not necessary to implement |
|
||||||
|
| Admin console / Billing & subscription | ✅ | ❌ | Free only |
|
||||||
|
| Full push notification pipeline | ✅ | ❌ | Not necessary to implement |
|
||||||
|
|
||||||
|
## Tested clients / platforms
|
||||||
|
|
||||||
|
- ✅ Windows desktop client (v2026.1.0)
|
||||||
|
- ✅ Mobile app (v2026.1.0)
|
||||||
|
- ✅ Browser extension (v2026.1.0)
|
||||||
|
- ✅ Linux desktop client (v2026.1.0)
|
||||||
|
- ⬜ macOS desktop client (not tested)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick start
|
||||||
|
|
||||||
|
### One-click deploy
|
||||||
|
|
||||||
|
**Deploy steps:**
|
||||||
|
|
||||||
|
1. Fork this repository and name it **NodeWarden**.
|
||||||
|
2. Click the deploy button below, rename the project to **NodeWarden2**, and set **JWT_SECRET** to a 32-character random string.
|
||||||
|
3. [](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
|
||||||
|
|
||||||
|
This repo is a Cloudflare Workers TypeScript project (Wrangler).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
---
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Q: How do I back up my data?**
|
||||||
|
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?**
|
||||||
|
A: It can’t be recovered (end-to-end encryption). Keep it safe.
|
||||||
|
|
||||||
|
**Q: Can multiple people use it?**
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
LGPL-3.0 License
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
- [Bitwarden](https://bitwarden.com/) - original design and clients
|
||||||
|
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - server implementation reference
|
||||||
|
- [Cloudflare Workers](https://workers.cloudflare.com/) - serverless platform
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
|
|
||||||
# NodeWarden
|
|
||||||
English:[`README.md`](./README.md)
|
|
||||||
|
|
||||||
一个运行在 **Cloudflare Workers** 上的 **Bitwarden 兼容**服务端实现,面向个人使用场景。
|
|
||||||
|
|
||||||
- 部署简单(不需要 VPS)
|
|
||||||
- 功能聚焦
|
|
||||||
- 维护成本低
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
> **免责声明**
|
|
||||||
> 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份您的密码库。
|
|
||||||
> 本项目与 Bitwarden 官方无关,请勿向 Bitwarden 官方反馈问题。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 特性
|
|
||||||
- ✅ **完全免费,不需要在服务器上部署,再次感谢大善人!**
|
|
||||||
- ✅ 完整的密码、笔记、卡片、身份信息管理
|
|
||||||
- ✅ 文件夹和收藏功能
|
|
||||||
- ✅ 文件附件支持(基于 R2 存储)
|
|
||||||
- ✅ 导入/导出功能
|
|
||||||
- ✅ 网站图标获取
|
|
||||||
- ✅ 端到端加密(服务器无法查看明文)
|
|
||||||
- ✅ 兼容常见的 Bitwarden 官方客户端
|
|
||||||
|
|
||||||
## 测试情况:
|
|
||||||
- ✅ Windows 客户端(v2026.1.0)
|
|
||||||
- ✅ Android App(v2026.1.0)
|
|
||||||
- ✅ 浏览器扩展(v2026.1.0)
|
|
||||||
- ⬜ macOS 客户端(未测试)
|
|
||||||
- ⬜ Linux 客户端(未测试)
|
|
||||||
---
|
|
||||||
|
|
||||||
# 快速开始
|
|
||||||
|
|
||||||
### 一键部署
|
|
||||||
|
|
||||||
点击下方按钮部署到 Cloudflare Workers:
|
|
||||||
|
|
||||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
|
|
||||||
|
|
||||||
**部署步骤:**
|
|
||||||
|
|
||||||
1. 使用 GitHub 登录并授权
|
|
||||||
2. 登录 Cloudflare 账户
|
|
||||||
3. **重要**:设置 `JWT_SECRET` 为强随机字符串(推荐使用 `openssl rand -hex 32` 生成)
|
|
||||||
4. KV 存储和 R2 存储桶将自动创建
|
|
||||||
5. 点击 Deploy 等待部署完成
|
|
||||||
6. 部署完成后,先打开 Cloudflare 给你的 Workers 链接(也就是你的服务地址),在网页上填写信息完成注册。
|
|
||||||
|
|
||||||
> ⚠️ **再次提醒**:请务必使用强随机的 `JWT_SECRET`,使用默认或弱密钥可能导致账户被入侵,**后果自负!**
|
|
||||||
|
|
||||||
### 配置客户端
|
|
||||||
|
|
||||||
部署完成后,在任意 Bitwarden 客户端中:
|
|
||||||
|
|
||||||
1. 打开设置(⚙️)
|
|
||||||
2. 选择「自托管环境」
|
|
||||||
3. 服务器 URL 填入:`https://你的项目名`
|
|
||||||
4. 保存并返回登录页面
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 本地开发
|
|
||||||
|
|
||||||
这是一个 Cloudflare Workers 的 TypeScript 项目(Wrangler)。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
- **运行环境**:Cloudflare Workers
|
|
||||||
- **数据存储**:Cloudflare KV
|
|
||||||
- **文件存储**:Cloudflare R2
|
|
||||||
- **开发语言**:TypeScript
|
|
||||||
- **加密算法**:客户端 AES-256-CBC,JWT 使用 HS256
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
**Q: 如何备份数据?**
|
|
||||||
A: 在客户端中选择「导出密码库」,保存 JSON 文件。
|
|
||||||
|
|
||||||
**Q: 忘记主密码怎么办?**
|
|
||||||
A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。
|
|
||||||
|
|
||||||
**Q: 可以多人使用吗?**
|
|
||||||
A: 不建议。本项目为单用户设计,多人使用请选择 Vaultwarden。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 开源协议
|
|
||||||
|
|
||||||
LGPL-3.0 License
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 致谢
|
|
||||||
|
|
||||||
- [Bitwarden](https://bitwarden.com/) - 原始设计和客户端
|
|
||||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务器实现参考
|
|
||||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
-- IMPORTANT:
|
||||||
|
-- Keep this file in sync with src/services/storage.ts (SCHEMA_STATEMENTS).
|
||||||
|
-- Any new table/column/index must be added to both places together.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
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,
|
||||||
|
kdf_iterations INTEGER NOT NULL,
|
||||||
|
kdf_memory INTEGER,
|
||||||
|
kdf_parallelism INTEGER,
|
||||||
|
security_stamp TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
totp_secret TEXT,
|
||||||
|
totp_recovery_code TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Per-user sync revision date
|
||||||
|
CREATE TABLE IF NOT EXISTS user_revisions (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
revision_date TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ciphers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
type INTEGER NOT NULL,
|
||||||
|
folder_id TEXT,
|
||||||
|
name TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
favorite INTEGER NOT NULL DEFAULT 0,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
reprompt INTEGER,
|
||||||
|
key TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
deleted_at TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS folders (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS attachments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
cipher_id TEXT NOT NULL,
|
||||||
|
file_name TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
size_name TEXT NOT NULL,
|
||||||
|
key TEXT,
|
||||||
|
FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
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 (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
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 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 (
|
||||||
|
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,
|
||||||
|
PRIMARY KEY (user_id, device_identifier),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
device_identifier TEXT NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
|
||||||
|
ON trusted_two_factor_device_tokens(user_id, device_identifier);
|
||||||
|
|
||||||
|
-- Rate limiting
|
||||||
|
CREATE TABLE IF NOT EXISTS login_attempts_ip (
|
||||||
|
ip TEXT PRIMARY KEY,
|
||||||
|
attempts INTEGER NOT NULL,
|
||||||
|
locked_until INTEGER,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS api_rate_limits (
|
||||||
|
identifier TEXT NOT NULL,
|
||||||
|
window_start INTEGER NOT NULL,
|
||||||
|
count INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (identifier, window_start)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (
|
||||||
|
jti TEXT PRIMARY KEY,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "0.1.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",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "wrangler dev -c wrangler.dev.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": [
|
||||||
@@ -21,20 +21,33 @@
|
|||||||
"cloudflare": {
|
"cloudflare": {
|
||||||
"bindings": {
|
"bindings": {
|
||||||
"JWT_SECRET": {
|
"JWT_SECRET": {
|
||||||
"description": "用于签名 JWT 的密钥。请输入一个随机的复杂字符串(建议 32 位以上)"
|
"description": "Use a strong random string (32+ characters recommended)"
|
||||||
},
|
},
|
||||||
"VAULT": {
|
"DB": {
|
||||||
"description": "用于存储密码库数据的 KV 存储"
|
"description": "D1 database for storing vault data"
|
||||||
},
|
},
|
||||||
"ATTACHMENTS": {
|
"ATTACHMENTS": {
|
||||||
"description": "用于存储文件附件的 R2 存储桶"
|
"description": "R2 bucket for storing file attachments"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
export const LIMITS = {
|
||||||
|
auth: {
|
||||||
|
// Access token lifetime in seconds.
|
||||||
|
// 访问令牌有效期(秒)。
|
||||||
|
accessTokenTtlSeconds: 7200,
|
||||||
|
// Refresh token lifetime in milliseconds.
|
||||||
|
// 刷新令牌有效期(毫秒)。
|
||||||
|
refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000,
|
||||||
|
// Grace window for previous refresh token after rotation (ms).
|
||||||
|
// 刷新令牌轮换后的旧令牌宽限窗口(毫秒)。
|
||||||
|
refreshTokenOverlapGraceMs: 60 * 1000,
|
||||||
|
// Refresh token random byte length.
|
||||||
|
// 刷新令牌随机字节长度。
|
||||||
|
refreshTokenRandomBytes: 32,
|
||||||
|
// Attachment download token lifetime in seconds.
|
||||||
|
// 附件下载令牌有效期(秒)。
|
||||||
|
fileDownloadTokenTtlSeconds: 300,
|
||||||
|
// Send access token lifetime in seconds.
|
||||||
|
// Send 访问令牌有效期(秒)。
|
||||||
|
sendAccessTokenTtlSeconds: 300,
|
||||||
|
// Minimum required JWT secret length.
|
||||||
|
// JWT 密钥最小长度要求。
|
||||||
|
jwtSecretMinLength: 32,
|
||||||
|
// Default PBKDF2 iterations for account creation/prelogin fallback.
|
||||||
|
// 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。
|
||||||
|
defaultKdfIterations: 600000,
|
||||||
|
},
|
||||||
|
rateLimit: {
|
||||||
|
// Max failed login attempts before temporary lock.
|
||||||
|
// 触发临时锁定前允许的最大登录失败次数。
|
||||||
|
loginMaxAttempts: 10,
|
||||||
|
// Login lock duration in minutes.
|
||||||
|
// 登录锁定时长(分钟)。
|
||||||
|
loginLockoutMinutes: 2,
|
||||||
|
// Authenticated API request budget per user per minute (all reads & writes combined).
|
||||||
|
// 认证 API 每用户每分钟请求配额(读写合计)。
|
||||||
|
apiRequestsPerMinute: 200,
|
||||||
|
// Public (unauthenticated) request budget per IP per minute.
|
||||||
|
// 公开(未认证)接口每 IP 每分钟请求配额。
|
||||||
|
publicRequestsPerMinute: 60,
|
||||||
|
// Fixed window size for API rate limiting in seconds.
|
||||||
|
// API 限流固定窗口大小(秒)。
|
||||||
|
apiWindowSeconds: 60,
|
||||||
|
// Probability to run low-frequency cleanup on request path.
|
||||||
|
// 在请求路径中触发低频清理的概率。
|
||||||
|
cleanupProbability: 0.05,
|
||||||
|
// Minimum interval between login-attempt cleanup runs.
|
||||||
|
// 登录尝试表清理的最小间隔。
|
||||||
|
loginIpCleanupIntervalMs: 10 * 60 * 1000,
|
||||||
|
// Retention window for login IP records.
|
||||||
|
// 登录 IP 记录保留时长。
|
||||||
|
loginIpRetentionMs: 30 * 24 * 60 * 60 * 1000,
|
||||||
|
},
|
||||||
|
cleanup: {
|
||||||
|
// Minimum interval between refresh-token cleanup runs.
|
||||||
|
// refresh_token 表清理最小间隔。
|
||||||
|
refreshTokenCleanupIntervalMs: 30 * 60 * 1000,
|
||||||
|
// Minimum interval between used attachment token cleanup runs.
|
||||||
|
// 已使用附件令牌表清理最小间隔。
|
||||||
|
attachmentTokenCleanupIntervalMs: 10 * 60 * 1000,
|
||||||
|
// Probability to trigger cleanup during requests.
|
||||||
|
// 请求过程中触发清理的概率。
|
||||||
|
cleanupProbability: 0.05,
|
||||||
|
},
|
||||||
|
attachment: {
|
||||||
|
// Max attachment upload size in bytes.
|
||||||
|
// 附件上传大小上限(字节)。
|
||||||
|
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: {
|
||||||
|
// Default page size when client does not specify pageSize.
|
||||||
|
// 客户端未传 pageSize 时的默认分页大小。
|
||||||
|
defaultPageSize: 100,
|
||||||
|
// Hard maximum page size accepted by server.
|
||||||
|
// 服务端允许的最大分页大小。
|
||||||
|
maxPageSize: 500,
|
||||||
|
},
|
||||||
|
cors: {
|
||||||
|
// Browser preflight cache max age in seconds.
|
||||||
|
// 浏览器预检请求缓存时长(秒)。
|
||||||
|
preflightMaxAgeSeconds: 86400,
|
||||||
|
},
|
||||||
|
cache: {
|
||||||
|
// Icon proxy cache TTL in seconds.
|
||||||
|
// 图标代理缓存时长(秒)。
|
||||||
|
iconTtlSeconds: 604800,
|
||||||
|
// In-memory /api/sync response cache TTL (milliseconds).
|
||||||
|
// /api/sync 内存缓存有效期(毫秒)。
|
||||||
|
syncResponseTtlMs: 30 * 1000,
|
||||||
|
// Max in-memory /api/sync cache entries per isolate.
|
||||||
|
// 每个 isolate 的 /api/sync 最大缓存条目数。
|
||||||
|
syncResponseMaxEntries: 64,
|
||||||
|
},
|
||||||
|
performance: {
|
||||||
|
// Max IDs per SQL batch when moving ciphers in bulk.
|
||||||
|
// 批量移动密码项时每批 SQL 的最大 ID 数量。
|
||||||
|
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: {
|
||||||
|
// Single source of truth for /config.version and /api/version.
|
||||||
|
// /config.version 与 /api/version 的统一版本号来源。
|
||||||
|
bitwardenServerVersion: '2026.1.0',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -1,29 +1,119 @@
|
|||||||
import { Env, User, ProfileResponse } 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 { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||||
|
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||||
|
|
||||||
// POST /api/accounts/register (only used from setup page, not client)
|
function looksLikeEncString(value: string): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
const firstDot = value.indexOf('.');
|
||||||
|
if (firstDot <= 0 || firstDot === value.length - 1) return false;
|
||||||
|
const payload = value.slice(firstDot + 1);
|
||||||
|
const parts = payload.split('|');
|
||||||
|
// Bitwarden encrypted payloads should have at least IV + ciphertext.
|
||||||
|
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 {
|
||||||
|
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 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.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
// Check if already registered
|
const unsafe = jwtSecretUnsafeReason(env);
|
||||||
const isRegistered = await storage.isRegistered();
|
if (unsafe) {
|
||||||
if (isRegistered) {
|
const message = unsafe === 'missing'
|
||||||
return errorResponse('Registration is closed', 403);
|
? 'JWT_SECRET is not set'
|
||||||
|
: unsafe === 'default'
|
||||||
|
? 'JWT_SECRET is using the default/sample value. Please change it.'
|
||||||
|
: 'JWT_SECRET must be at least 32 characters';
|
||||||
|
return errorResponse(message, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: {
|
let body: {
|
||||||
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;
|
||||||
@@ -36,110 +126,160 @@ 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);
|
||||||
}
|
}
|
||||||
|
if (!looksLikeEncString(key)) {
|
||||||
|
return errorResponse('key is not a valid encrypted string', 400);
|
||||||
|
}
|
||||||
|
if (!looksLikeEncString(privateKey)) {
|
||||||
|
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
// Create user
|
|
||||||
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 ?? 600000,
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
await storage.saveUser(user);
|
const userCount = await storage.getUserCount();
|
||||||
|
if (userCount === 0) {
|
||||||
|
user.role = 'admin';
|
||||||
|
const created = await storage.createFirstUser(user);
|
||||||
|
if (!created) {
|
||||||
|
return errorResponse('Registration is temporarily unavailable, retry once', 409);
|
||||||
|
}
|
||||||
await storage.setRegistered();
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
return jsonResponse({ success: true }, 200);
|
if (!inviteCode) {
|
||||||
|
return errorResponse('Invite code is required', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
const storage = new StorageService(env.VAULT);
|
void request;
|
||||||
|
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: false,
|
|
||||||
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.VAULT);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.VAULT);
|
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) {
|
||||||
@@ -147,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;
|
||||||
@@ -158,9 +299,24 @@ 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;
|
||||||
|
if (body.key && !looksLikeEncString(body.key)) {
|
||||||
|
return errorResponse('key is not a valid encrypted string', 400);
|
||||||
|
}
|
||||||
|
if (body.encryptedPrivateKey && !looksLikeEncString(body.encryptedPrivateKey)) {
|
||||||
|
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
|
||||||
|
}
|
||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
await storage.saveUser(user);
|
await storage.saveUser(user);
|
||||||
@@ -168,9 +324,259 @@ 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> {
|
||||||
const storage = new StorageService(env.VAULT);
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
const revisionDate = await storage.getRevisionDate(userId);
|
const revisionDate = await storage.getRevisionDate(userId);
|
||||||
|
|
||||||
// Return as milliseconds timestamp (Bitwarden format)
|
// Return as milliseconds timestamp (Bitwarden format)
|
||||||
@@ -180,7 +586,8 @@ export async function handleGetRevisionDate(request: Request, env: Env, userId:
|
|||||||
|
|
||||||
// POST /api/accounts/verify-password
|
// POST /api/accounts/verify-password
|
||||||
export async function handleVerifyPassword(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleVerifyPassword(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
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) {
|
||||||
@@ -198,7 +605,8 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s
|
|||||||
return errorResponse('masterPasswordHash is required', 400);
|
return errorResponse('masterPasswordHash is required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.masterPasswordHash !== user.masterPasswordHash) {
|
const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email);
|
||||||
|
if (!valid) {
|
||||||
return errorResponse('Invalid password', 400);
|
return errorResponse('Invalid password', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +1,10 @@
|
|||||||
import { Env, Attachment, Cipher } from '../types';
|
import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
|
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
|
||||||
|
import { cipherToResponse } from './ciphers';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
// Format file size to human readable
|
// Format file size to human readable
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
@@ -25,7 +27,7 @@ export async function handleCreateAttachment(
|
|||||||
userId: string,
|
userId: string,
|
||||||
cipherId: string
|
cipherId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
// Verify cipher exists and belongs to user
|
// Verify cipher exists and belongs to user
|
||||||
const cipher = await storage.getCipher(cipherId);
|
const cipher = await storage.getCipher(cipherId);
|
||||||
@@ -80,12 +82,12 @@ export async function handleCreateAttachment(
|
|||||||
attachmentId: attachmentId,
|
attachmentId: attachmentId,
|
||||||
url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`,
|
url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`,
|
||||||
fileUploadType: 0, // Direct upload
|
fileUploadType: 0, // Direct upload
|
||||||
cipherResponse: formatCipherResponse(updatedCipher!, attachments),
|
cipherResponse: cipherToResponse(updatedCipher!, attachments),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maximum file size: 100MB
|
// Maximum file size: 100MB
|
||||||
const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
const MAX_FILE_SIZE = LIMITS.attachment.maxFileSizeBytes;
|
||||||
|
|
||||||
// POST /api/ciphers/{cipherId}/attachment/{attachmentId}
|
// POST /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||||
// Upload attachment file content
|
// Upload attachment file content
|
||||||
@@ -96,7 +98,7 @@ export async function handleUploadAttachment(
|
|||||||
cipherId: string,
|
cipherId: string,
|
||||||
attachmentId: string
|
attachmentId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
// Verify cipher exists and belongs to user
|
// Verify cipher exists and belongs to user
|
||||||
const cipher = await storage.getCipher(cipherId);
|
const cipher = await storage.getCipher(cipherId);
|
||||||
@@ -169,7 +171,7 @@ export async function handleGetAttachment(
|
|||||||
cipherId: string,
|
cipherId: string,
|
||||||
attachmentId: string
|
attachmentId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
// Verify cipher exists and belongs to user
|
// Verify cipher exists and belongs to user
|
||||||
const cipher = await storage.getCipher(cipherId);
|
const cipher = await storage.getCipher(cipherId);
|
||||||
@@ -196,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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -209,6 +211,11 @@ export async function handlePublicDownloadAttachment(
|
|||||||
cipherId: string,
|
cipherId: string,
|
||||||
attachmentId: string
|
attachmentId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
|
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||||
|
return errorResponse('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const token = url.searchParams.get('token');
|
const token = url.searchParams.get('token');
|
||||||
|
|
||||||
@@ -227,7 +234,7 @@ export async function handlePublicDownloadAttachment(
|
|||||||
return errorResponse('Token mismatch', 401);
|
return errorResponse('Token mismatch', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
// Verify attachment exists
|
// Verify attachment exists
|
||||||
const attachment = await storage.getAttachment(attachmentId);
|
const attachment = await storage.getAttachment(attachmentId);
|
||||||
@@ -243,12 +250,16 @@ export async function handlePublicDownloadAttachment(
|
|||||||
return errorResponse('Attachment file not found', 404);
|
return errorResponse('Attachment file not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp);
|
||||||
|
if (!firstUse) {
|
||||||
|
return errorResponse('Invalid or expired token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(object.body, {
|
return new Response(object.body, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': 'application/octet-stream',
|
||||||
'Content-Length': String(object.size),
|
'Content-Length': String(object.size),
|
||||||
'Cache-Control': 'private, no-cache',
|
'Cache-Control': 'private, no-cache',
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -262,7 +273,7 @@ export async function handleDeleteAttachment(
|
|||||||
cipherId: string,
|
cipherId: string,
|
||||||
attachmentId: string
|
attachmentId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
// Verify cipher exists and belongs to user
|
// Verify cipher exists and belongs to user
|
||||||
const cipher = await storage.getCipher(cipherId);
|
const cipher = await storage.getCipher(cipherId);
|
||||||
@@ -294,61 +305,16 @@ export async function handleDeleteAttachment(
|
|||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
cipher: formatCipherResponse(updatedCipher!, attachments),
|
cipher: cipherToResponse(updatedCipher!, attachments),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Format cipher response with attachments
|
|
||||||
function formatCipherResponse(cipher: Cipher, attachments: Attachment[]): any {
|
|
||||||
return {
|
|
||||||
id: cipher.id,
|
|
||||||
organizationId: null,
|
|
||||||
folderId: cipher.folderId,
|
|
||||||
type: Number(cipher.type) || 1,
|
|
||||||
name: cipher.name,
|
|
||||||
notes: cipher.notes,
|
|
||||||
favorite: cipher.favorite,
|
|
||||||
login: cipher.login,
|
|
||||||
card: cipher.card,
|
|
||||||
identity: cipher.identity,
|
|
||||||
secureNote: cipher.secureNote,
|
|
||||||
sshKey: cipher.sshKey,
|
|
||||||
fields: cipher.fields,
|
|
||||||
passwordHistory: cipher.passwordHistory,
|
|
||||||
reprompt: cipher.reprompt,
|
|
||||||
organizationUseTotp: false,
|
|
||||||
creationDate: cipher.createdAt,
|
|
||||||
revisionDate: cipher.updatedAt,
|
|
||||||
deletedDate: cipher.deletedAt,
|
|
||||||
archivedDate: null,
|
|
||||||
edit: true,
|
|
||||||
viewPassword: true,
|
|
||||||
permissions: {
|
|
||||||
delete: true,
|
|
||||||
restore: true,
|
|
||||||
},
|
|
||||||
object: 'cipher',
|
|
||||||
collectionIds: [],
|
|
||||||
attachments: attachments.length > 0 ? attachments.map(a => ({
|
|
||||||
id: a.id,
|
|
||||||
fileName: a.fileName,
|
|
||||||
size: Number(a.size) || 0,
|
|
||||||
sizeName: a.sizeName,
|
|
||||||
key: a.key,
|
|
||||||
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`,
|
|
||||||
object: 'attachment',
|
|
||||||
})) : null,
|
|
||||||
key: cipher.key,
|
|
||||||
encryptedFor: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete all attachments for a cipher (used when deleting cipher)
|
// Delete all attachments for a cipher (used when deleting cipher)
|
||||||
export async function deleteAllAttachmentsForCipher(
|
export async function deleteAllAttachmentsForCipher(
|
||||||
env: Env,
|
env: Env,
|
||||||
cipherId: string
|
cipherId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
|
||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
|
|||||||
@@ -3,14 +3,73 @@ import { StorageService } from '../services/storage';
|
|||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { deleteAllAttachmentsForCipher } from './attachments';
|
import { deleteAllAttachmentsForCipher } from './attachments';
|
||||||
|
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
|
||||||
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!
|
||||||
@@ -18,28 +77,26 @@ function formatAttachments(attachments: Attachment[]): any[] | null {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert internal cipher to API response format
|
// Convert internal cipher to API response format.
|
||||||
function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse {
|
// Uses opaque passthrough: spreads ALL stored fields (including unknown/future ones),
|
||||||
|
// then overlays server-computed fields. This ensures new Bitwarden client fields
|
||||||
|
// survive a round-trip without code changes.
|
||||||
|
export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse {
|
||||||
|
// Strip internal-only fields that must not appear in the API response
|
||||||
|
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 {
|
||||||
id: cipher.id,
|
// Pass through ALL stored cipher fields (known + unknown)
|
||||||
organizationId: null,
|
...passthrough,
|
||||||
folderId: cipher.folderId,
|
// Server-computed / enforced fields (always override)
|
||||||
type: Number(cipher.type) || 1,
|
type: Number(cipher.type) || 1,
|
||||||
name: cipher.name,
|
organizationId: null,
|
||||||
notes: cipher.notes,
|
|
||||||
favorite: cipher.favorite,
|
|
||||||
login: cipher.login,
|
|
||||||
card: cipher.card,
|
|
||||||
identity: cipher.identity,
|
|
||||||
secureNote: cipher.secureNote,
|
|
||||||
sshKey: cipher.sshKey,
|
|
||||||
fields: cipher.fields,
|
|
||||||
passwordHistory: cipher.passwordHistory,
|
|
||||||
reprompt: cipher.reprompt,
|
|
||||||
organizationUseTotp: false,
|
organizationUseTotp: false,
|
||||||
creationDate: cipher.createdAt,
|
creationDate: createdAt,
|
||||||
revisionDate: cipher.updatedAt,
|
revisionDate: updatedAt,
|
||||||
deletedDate: cipher.deletedAt,
|
deletedDate: deletedAt,
|
||||||
archivedDate: null,
|
archivedDate: null,
|
||||||
edit: true,
|
edit: true,
|
||||||
viewPassword: true,
|
viewPassword: true,
|
||||||
@@ -50,41 +107,57 @@ function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): Ciphe
|
|||||||
object: 'cipher',
|
object: 'cipher',
|
||||||
collectionIds: [],
|
collectionIds: [],
|
||||||
attachments: formatAttachments(attachments),
|
attachments: formatAttachments(attachments),
|
||||||
key: cipher.key,
|
login: normalizedLogin,
|
||||||
|
sshKey: normalizedSshKey,
|
||||||
encryptedFor: null,
|
encryptedFor: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/ciphers
|
// GET /api/ciphers
|
||||||
export async function handleGetCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleGetCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const ciphers = await storage.getAllCiphers(userId);
|
|
||||||
|
|
||||||
// Filter out soft-deleted ciphers unless specifically requested
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const includeDeleted = url.searchParams.get('deleted') === 'true';
|
const includeDeleted = url.searchParams.get('deleted') === 'true';
|
||||||
|
const pagination = parsePagination(url);
|
||||||
|
|
||||||
const filteredCiphers = includeDeleted
|
let filteredCiphers: Cipher[];
|
||||||
|
let continuationToken: string | null = null;
|
||||||
|
if (pagination) {
|
||||||
|
const pageRows = await storage.getCiphersPage(
|
||||||
|
userId,
|
||||||
|
includeDeleted,
|
||||||
|
pagination.limit + 1,
|
||||||
|
pagination.offset
|
||||||
|
);
|
||||||
|
const hasNext = pageRows.length > pagination.limit;
|
||||||
|
filteredCiphers = hasNext ? pageRows.slice(0, pagination.limit) : pageRows;
|
||||||
|
continuationToken = hasNext ? encodeContinuationToken(pagination.offset + filteredCiphers.length) : null;
|
||||||
|
} else {
|
||||||
|
const ciphers = await storage.getAllCiphers(userId);
|
||||||
|
filteredCiphers = includeDeleted
|
||||||
? ciphers
|
? ciphers
|
||||||
: ciphers.filter(c => !c.deletedAt);
|
: ciphers.filter(c => !c.deletedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
|
||||||
|
|
||||||
// Get attachments for all ciphers
|
// Get attachments for all ciphers
|
||||||
const cipherResponses = [];
|
const cipherResponses = [];
|
||||||
for (const cipher of filteredCiphers) {
|
for (const cipher of filteredCiphers) {
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
data: cipherResponses,
|
data: cipherResponses,
|
||||||
object: 'list',
|
object: 'list',
|
||||||
continuationToken: null,
|
continuationToken: continuationToken,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/ciphers/:id
|
// GET /api/ciphers/:id
|
||||||
export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const cipher = await storage.getCipher(id);
|
const cipher = await storage.getCipher(id);
|
||||||
|
|
||||||
if (!cipher || cipher.userId !== userId) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
@@ -95,9 +168,15 @@ 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.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
let body: any;
|
let body: any;
|
||||||
try {
|
try {
|
||||||
@@ -111,27 +190,30 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
const cipherData = body.Cipher || body.cipher || body;
|
const cipherData = body.Cipher || body.cipher || body;
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
// Opaque passthrough: spread ALL client fields to preserve unknown/future ones,
|
||||||
|
// then override only server-controlled fields.
|
||||||
const cipher: Cipher = {
|
const cipher: Cipher = {
|
||||||
|
...cipherData,
|
||||||
|
// Server-controlled fields (always override client values)
|
||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
userId: userId,
|
userId: userId,
|
||||||
type: Number(cipherData.type) || 1,
|
type: Number(cipherData.type) || 1,
|
||||||
folderId: cipherData.folderId || null,
|
favorite: !!cipherData.favorite,
|
||||||
name: cipherData.name,
|
|
||||||
notes: cipherData.notes || null,
|
|
||||||
favorite: cipherData.favorite || false,
|
|
||||||
login: cipherData.login || null,
|
|
||||||
card: cipherData.card || null,
|
|
||||||
identity: cipherData.identity || null,
|
|
||||||
secureNote: cipherData.secureNote || null,
|
|
||||||
sshKey: cipherData.sshKey || null,
|
|
||||||
fields: cipherData.fields || null,
|
|
||||||
passwordHistory: cipherData.passwordHistory || null,
|
|
||||||
reprompt: cipherData.reprompt || 0,
|
reprompt: cipherData.reprompt || 0,
|
||||||
key: cipherData.key || null,
|
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
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);
|
||||||
@@ -141,7 +223,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
|
|
||||||
// PUT /api/ciphers/:id
|
// PUT /api/ciphers/:id
|
||||||
export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const existingCipher = await storage.getCipher(id);
|
const existingCipher = await storage.getCipher(id);
|
||||||
|
|
||||||
if (!existingCipher || existingCipher.userId !== userId) {
|
if (!existingCipher || existingCipher.userId !== userId) {
|
||||||
@@ -159,24 +241,40 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
// Android client sends PascalCase "Cipher" for organization ciphers
|
// Android client sends PascalCase "Cipher" for organization ciphers
|
||||||
const cipherData = body.Cipher || body.cipher || body;
|
const cipherData = body.Cipher || body.cipher || body;
|
||||||
|
|
||||||
|
// Opaque passthrough: merge existing stored data with ALL incoming client fields.
|
||||||
|
// Unknown/future fields from the client are preserved; server-controlled fields are protected.
|
||||||
const cipher: Cipher = {
|
const cipher: Cipher = {
|
||||||
...existingCipher,
|
...existingCipher, // start with all existing stored data (including unknowns)
|
||||||
|
...cipherData, // overlay all client data (including new/unknown fields)
|
||||||
|
// Server-controlled fields (never from client)
|
||||||
|
id: existingCipher.id,
|
||||||
|
userId: existingCipher.userId,
|
||||||
type: Number(cipherData.type) || existingCipher.type,
|
type: Number(cipherData.type) || existingCipher.type,
|
||||||
folderId: cipherData.folderId !== undefined ? cipherData.folderId : existingCipher.folderId,
|
|
||||||
name: cipherData.name ?? existingCipher.name,
|
|
||||||
notes: cipherData.notes !== undefined ? cipherData.notes : existingCipher.notes,
|
|
||||||
favorite: cipherData.favorite ?? existingCipher.favorite,
|
favorite: cipherData.favorite ?? existingCipher.favorite,
|
||||||
login: cipherData.login !== undefined ? cipherData.login : existingCipher.login,
|
|
||||||
card: cipherData.card !== undefined ? cipherData.card : existingCipher.card,
|
|
||||||
identity: cipherData.identity !== undefined ? cipherData.identity : existingCipher.identity,
|
|
||||||
secureNote: cipherData.secureNote !== undefined ? cipherData.secureNote : existingCipher.secureNote,
|
|
||||||
sshKey: cipherData.sshKey !== undefined ? cipherData.sshKey : existingCipher.sshKey,
|
|
||||||
fields: cipherData.fields !== undefined ? cipherData.fields : existingCipher.fields,
|
|
||||||
passwordHistory: cipherData.passwordHistory !== undefined ? cipherData.passwordHistory : existingCipher.passwordHistory,
|
|
||||||
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
||||||
key: cipherData.key !== undefined ? cipherData.key : existingCipher.key,
|
createdAt: existingCipher.createdAt,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
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);
|
||||||
@@ -186,7 +284,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
|
|
||||||
// DELETE /api/ciphers/:id
|
// DELETE /api/ciphers/:id
|
||||||
export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const cipher = await storage.getCipher(id);
|
const cipher = await storage.getCipher(id);
|
||||||
|
|
||||||
if (!cipher || cipher.userId !== userId) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
@@ -202,9 +300,32 @@ 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.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const cipher = await storage.getCipher(id);
|
const cipher = await storage.getCipher(id);
|
||||||
|
|
||||||
if (!cipher || cipher.userId !== userId) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
@@ -222,7 +343,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
|
|||||||
|
|
||||||
// PUT /api/ciphers/:id/restore
|
// PUT /api/ciphers/:id/restore
|
||||||
export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const cipher = await storage.getCipher(id);
|
const cipher = await storage.getCipher(id);
|
||||||
|
|
||||||
if (!cipher || cipher.userId !== userId) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
@@ -239,7 +360,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
|||||||
|
|
||||||
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
|
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
|
||||||
export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const cipher = await storage.getCipher(id);
|
const cipher = await storage.getCipher(id);
|
||||||
|
|
||||||
if (!cipher || cipher.userId !== userId) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
@@ -254,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) {
|
||||||
@@ -269,7 +394,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
|||||||
|
|
||||||
// POST/PUT /api/ciphers/move - Bulk move to folder
|
// POST/PUT /api/ciphers/move - Bulk move to folder
|
||||||
export async function handleBulkMoveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleBulkMoveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
let body: { ids?: string[]; folderId?: string | null };
|
let body: { ids?: string[]; folderId?: string | null };
|
||||||
try {
|
try {
|
||||||
@@ -282,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 });
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { Env } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
|
import { readKnownDeviceProbe } from '../utils/device';
|
||||||
|
|
||||||
|
// GET /api/devices/knowndevice
|
||||||
|
// Compatible with Bitwarden/Vaultwarden behavior:
|
||||||
|
// - X-Request-Email: base64url(email) without padding
|
||||||
|
// - X-Device-Identifier: client device identifier
|
||||||
|
export async function handleKnownDevice(request: Request, env: Env): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const { email, deviceIdentifier } = readKnownDeviceProbe(request);
|
||||||
|
|
||||||
|
if (!email || !deviceIdentifier) {
|
||||||
|
return jsonResponse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const known = await storage.isKnownDeviceByEmail(email, deviceIdentifier);
|
||||||
|
return jsonResponse(known);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/devices
|
||||||
|
export async function handleGetDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const devices = await storage.getDevicesByUserId(userId);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
data: devices.map(device => ({
|
||||||
|
id: device.deviceIdentifier,
|
||||||
|
name: device.name,
|
||||||
|
identifier: device.deviceIdentifier,
|
||||||
|
type: device.type,
|
||||||
|
creationDate: device.createdAt,
|
||||||
|
revisionDate: device.updatedAt,
|
||||||
|
object: 'device',
|
||||||
|
})),
|
||||||
|
object: 'list',
|
||||||
|
continuationToken: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@ import { Env, Folder, FolderResponse } from '../types';
|
|||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||||
|
|
||||||
// Convert internal folder to API response format
|
// Convert internal folder to API response format
|
||||||
function folderToResponse(folder: Folder): FolderResponse {
|
function folderToResponse(folder: Folder): FolderResponse {
|
||||||
@@ -15,19 +16,31 @@ function folderToResponse(folder: Folder): FolderResponse {
|
|||||||
|
|
||||||
// GET /api/folders
|
// GET /api/folders
|
||||||
export async function handleGetFolders(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleGetFolders(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const folders = await storage.getAllFolders(userId);
|
const url = new URL(request.url);
|
||||||
|
const pagination = parsePagination(url);
|
||||||
|
|
||||||
|
let folders: Folder[];
|
||||||
|
let continuationToken: string | null = null;
|
||||||
|
if (pagination) {
|
||||||
|
const pageRows = await storage.getFoldersPage(userId, pagination.limit + 1, pagination.offset);
|
||||||
|
const hasNext = pageRows.length > pagination.limit;
|
||||||
|
folders = hasNext ? pageRows.slice(0, pagination.limit) : pageRows;
|
||||||
|
continuationToken = hasNext ? encodeContinuationToken(pagination.offset + folders.length) : null;
|
||||||
|
} else {
|
||||||
|
folders = await storage.getAllFolders(userId);
|
||||||
|
}
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
data: folders.map(folderToResponse),
|
data: folders.map(folderToResponse),
|
||||||
object: 'list',
|
object: 'list',
|
||||||
continuationToken: null,
|
continuationToken: continuationToken,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/folders/:id
|
// GET /api/folders/:id
|
||||||
export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const folder = await storage.getFolder(id);
|
const folder = await storage.getFolder(id);
|
||||||
|
|
||||||
if (!folder || folder.userId !== userId) {
|
if (!folder || folder.userId !== userId) {
|
||||||
@@ -39,7 +52,7 @@ export async function handleGetFolder(request: Request, env: Env, userId: string
|
|||||||
|
|
||||||
// POST /api/folders
|
// POST /api/folders
|
||||||
export async function handleCreateFolder(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleCreateFolder(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
let body: { name?: string };
|
let body: { name?: string };
|
||||||
try {
|
try {
|
||||||
@@ -62,13 +75,14 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
|
|||||||
};
|
};
|
||||||
|
|
||||||
await storage.saveFolder(folder);
|
await storage.saveFolder(folder);
|
||||||
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
return jsonResponse(folderToResponse(folder), 200);
|
return jsonResponse(folderToResponse(folder), 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/folders/:id
|
// PUT /api/folders/:id
|
||||||
export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const folder = await storage.getFolder(id);
|
const folder = await storage.getFolder(id);
|
||||||
|
|
||||||
if (!folder || folder.userId !== userId) {
|
if (!folder || folder.userId !== userId) {
|
||||||
@@ -88,20 +102,23 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
|
|||||||
folder.updatedAt = new Date().toISOString();
|
folder.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
await storage.saveFolder(folder);
|
await storage.saveFolder(folder);
|
||||||
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
return jsonResponse(folderToResponse(folder));
|
return jsonResponse(folderToResponse(folder));
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/folders/:id
|
// DELETE /api/folders/:id
|
||||||
export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const folder = await storage.getFolder(id);
|
const folder = await storage.getFolder(id);
|
||||||
|
|
||||||
if (!folder || folder.userId !== userId) {
|
if (!folder || folder.userId !== userId) {
|
||||||
return errorResponse('Folder not found', 404);
|
return errorResponse('Folder not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await storage.clearFolderFromCiphers(userId, id);
|
||||||
await storage.deleteFolder(id, userId);
|
await storage.deleteFolder(id, userId);
|
||||||
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,130 @@
|
|||||||
import { Env, TokenResponse } from '../types';
|
import { Env, TokenResponse } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { AuthService } from '../services/auth';
|
import { AuthService } from '../services/auth';
|
||||||
import { RateLimitService } from '../services/ratelimit';
|
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||||
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||||
|
import { createRefreshToken } from '../utils/jwt';
|
||||||
|
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_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;
|
||||||
|
|
||||||
|
// Bitwarden clients rely on these fields to trigger the 2FA UI flow.
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
error: 'invalid_grant',
|
||||||
|
error_description: message,
|
||||||
|
TwoFactorProviders: providers,
|
||||||
|
TwoFactorProviders2: providers2,
|
||||||
|
// Required by current Android parser (nullable value is acceptable).
|
||||||
|
SsoEmail2faSessionToken: null,
|
||||||
|
// Keep payload shape close to upstream implementations.
|
||||||
|
MasterPasswordPolicy: {
|
||||||
|
Object: 'masterPasswordPolicy',
|
||||||
|
},
|
||||||
|
ErrorModel: {
|
||||||
|
Message: message,
|
||||||
|
Object: 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordFailedLoginAndBuildResponse(
|
||||||
|
rateLimit: RateLimitService,
|
||||||
|
loginIdentifier: string,
|
||||||
|
message: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const result = await rateLimit.recordFailedLogin(loginIdentifier);
|
||||||
|
if (result.locked) {
|
||||||
|
return identityErrorResponse(
|
||||||
|
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
|
||||||
|
'TooManyRequests',
|
||||||
|
429
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const auth = new AuthService(env);
|
const auth = new AuthService(env);
|
||||||
const rateLimit = new RateLimitService(env.VAULT);
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
|
|
||||||
let body: Record<string, string>;
|
let body: Record<string, string>;
|
||||||
const contentType = request.headers.get('content-type') || '';
|
const contentType = request.headers.get('content-type') || '';
|
||||||
|
try {
|
||||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
body = Object.fromEntries(formData.entries()) as Record<string, string>;
|
body = Object.fromEntries(formData.entries()) as Record<string, string>;
|
||||||
} else {
|
} else {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
return identityErrorResponse('Invalid request payload', 'invalid_request', 400);
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
const email = body.username?.toLowerCase();
|
const email = body.username?.toLowerCase();
|
||||||
const passwordHash = body.password;
|
const passwordHash = body.password;
|
||||||
|
const twoFactorToken = body.twoFactorToken;
|
||||||
|
const twoFactorProvider = body.twoFactorProvider;
|
||||||
|
const twoFactorRemember = body.twoFactorRemember;
|
||||||
|
const loginIdentifier = `${clientIdentifier}:${email}`;
|
||||||
|
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||||
|
|
||||||
if (!email || !passwordHash) {
|
if (!email || !passwordHash) {
|
||||||
return errorResponse('Email and password are required', 400);
|
// Bitwarden clients expect OAuth-style error fields.
|
||||||
|
return identityErrorResponse('Email and password are required', 'invalid_request', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await storage.getUser(email);
|
// Check login lockout before user lookup to reduce user-enumeration signal
|
||||||
if (!user) {
|
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
||||||
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if login is rate limited (only after confirming user exists)
|
|
||||||
const loginCheck = await rateLimit.checkLoginAttempt(email);
|
|
||||||
if (!loginCheck.allowed) {
|
if (!loginCheck.allowed) {
|
||||||
return identityErrorResponse(
|
return identityErrorResponse(
|
||||||
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
|
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
|
||||||
@@ -46,31 +133,109 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash);
|
const user = await storage.getUser(email);
|
||||||
|
if (!user) {
|
||||||
|
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||||
|
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, user.email);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
// Record failed login attempt
|
return recordFailedLoginAndBuildResponse(
|
||||||
const result = await rateLimit.recordFailedLogin(email);
|
rateLimit,
|
||||||
if (result.locked) {
|
loginIdentifier,
|
||||||
return identityErrorResponse(
|
'Username or password is incorrect. Try again'
|
||||||
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
|
|
||||||
'TooManyRequests',
|
|
||||||
429
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
|
|
||||||
|
// Optional 2FA: enabled only by per-user secret.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
let passedByRememberToken = false;
|
||||||
|
if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_REMEMBER)) {
|
||||||
|
if (deviceInfo.deviceIdentifier) {
|
||||||
|
const trustedUserId = await storage.getTrustedTwoFactorDeviceTokenUserId(
|
||||||
|
normalizedTwoFactorToken,
|
||||||
|
deviceInfo.deviceIdentifier
|
||||||
|
);
|
||||||
|
passedByRememberToken = trustedUserId === user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
|
||||||
|
if (!passedByRememberToken) {
|
||||||
|
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
|
||||||
|
}
|
||||||
|
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
|
||||||
|
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
|
||||||
|
if (!totpOk) {
|
||||||
|
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||||
|
}
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upstream behavior: do not issue a new remember token when auth itself used remember provider.
|
||||||
|
if (rememberRequested && !passedByRememberToken && deviceInfo.deviceIdentifier) {
|
||||||
|
trustedTwoFactorTokenToReturn = createRefreshToken();
|
||||||
|
await storage.saveTrustedTwoFactorDeviceToken(
|
||||||
|
trustedTwoFactorTokenToReturn,
|
||||||
|
user.id,
|
||||||
|
deviceInfo.deviceIdentifier,
|
||||||
|
Date.now() + TWO_FACTOR_REMEMBER_TTL_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(email);
|
await rateLimit.clearLoginAttempts(loginIdentifier);
|
||||||
|
|
||||||
const accessToken = await auth.generateAccessToken(user);
|
const accessToken = await auth.generateAccessToken(user);
|
||||||
const refreshToken = await auth.generateRefreshToken(user.id);
|
const refreshToken = await auth.generateRefreshToken(user.id);
|
||||||
|
|
||||||
const response: TokenResponse = {
|
const response: TokenResponse = {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
expires_in: 7200,
|
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
|
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||||
Key: user.key,
|
Key: user.key,
|
||||||
PrivateKey: user.privateKey,
|
PrivateKey: user.privateKey,
|
||||||
Kdf: user.kdfType,
|
Kdf: user.kdfType,
|
||||||
@@ -92,34 +257,83 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
Parallelism: user.kdfParallelism || null,
|
Parallelism: user.kdfParallelism || null,
|
||||||
},
|
},
|
||||||
MasterKeyEncryptedUserKey: user.key,
|
MasterKeyEncryptedUserKey: user.key,
|
||||||
|
MasterKeyWrappedUserKey: user.key,
|
||||||
Salt: email, // email is already lowercased above
|
Salt: email, // email is already lowercased above
|
||||||
|
Object: 'masterPasswordUnlock',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
return errorResponse('Refresh token is required', 400);
|
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await auth.refreshAccessToken(refreshToken);
|
const result = await auth.refreshAccessToken(refreshToken);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return errorResponse('Invalid refresh token', 401);
|
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);
|
||||||
|
|
||||||
const response: TokenResponse = {
|
const response: TokenResponse = {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
expires_in: 7200,
|
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
refresh_token: newRefreshToken,
|
refresh_token: newRefreshToken,
|
||||||
Key: user.key,
|
Key: user.key,
|
||||||
@@ -143,7 +357,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
Parallelism: user.kdfParallelism || null,
|
Parallelism: user.kdfParallelism || null,
|
||||||
},
|
},
|
||||||
MasterKeyEncryptedUserKey: user.key,
|
MasterKeyEncryptedUserKey: user.key,
|
||||||
|
MasterKeyWrappedUserKey: user.key,
|
||||||
Salt: user.email.toLowerCase(),
|
Salt: user.email.toLowerCase(),
|
||||||
|
Object: 'masterPasswordUnlock',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -151,12 +367,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
return jsonResponse(response);
|
return jsonResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorResponse('Unsupported grant type', 400);
|
return identityErrorResponse('Unsupported grant type', 'unsupported_grant_type', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /identity/accounts/prelogin
|
// POST /identity/accounts/prelogin
|
||||||
export async function handlePrelogin(request: Request, env: Env): Promise<Response> {
|
export async function handlePrelogin(request: Request, env: Env): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
let body: { email?: string };
|
let body: { email?: string };
|
||||||
try {
|
try {
|
||||||
@@ -174,9 +390,11 @@ 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 ?? 600000;
|
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,
|
||||||
@@ -185,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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,31 @@
|
|||||||
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 { 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;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
totp?: string | null;
|
totp?: string | null;
|
||||||
|
autofillOnPageLoad?: boolean | null;
|
||||||
|
fido2Credentials?: any[] | null;
|
||||||
|
uri?: string | null;
|
||||||
|
passwordRevisionDate?: string | null;
|
||||||
|
[key: string]: any;
|
||||||
} | null;
|
} | null;
|
||||||
card?: {
|
card?: {
|
||||||
cardholderName?: string | null;
|
cardholderName?: string | null;
|
||||||
@@ -56,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;
|
||||||
@@ -66,9 +77,22 @@ interface CiphersImportRequest {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindNull(v: any): any {
|
||||||
|
return v === undefined ? null : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
|
||||||
|
for (let i = 0; i < statements.length; i += chunkSize) {
|
||||||
|
const chunk = statements.slice(i, i + chunkSize);
|
||||||
|
await db.batch(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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.VAULT);
|
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 {
|
||||||
@@ -81,10 +105,16 @@ 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;
|
||||||
|
|
||||||
// Create folders and build index -> id mapping
|
// Create folders and build index -> id mapping
|
||||||
const folderIdMap = new Map<number, string>();
|
const folderIdMap = new Map<number, string>();
|
||||||
|
const folderRows: Folder[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < folders.length; i++) {
|
for (let i = 0; i < folders.length; i++) {
|
||||||
const folderId = generateUUID();
|
const folderId = generateUUID();
|
||||||
@@ -98,7 +128,19 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await storage.saveFolder(folder);
|
folderRows.push(folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folderRows.length > 0) {
|
||||||
|
const folderStatements = folderRows.map(folder =>
|
||||||
|
env.DB
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(id) DO UPDATE SET user_id=excluded.user_id, name=excluded.name, updated_at=excluded.updated_at'
|
||||||
|
)
|
||||||
|
.bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt)
|
||||||
|
);
|
||||||
|
await runBatchInChunks(env.DB, folderStatements, batchChunkSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build cipher index -> folder id mapping from relationships
|
// Build cipher index -> folder id mapping from relationships
|
||||||
@@ -111,81 +153,129 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create ciphers
|
// Create ciphers
|
||||||
|
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,
|
||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
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 ? {
|
||||||
username: c.login.username || null,
|
...c.login,
|
||||||
password: c.login.password || null,
|
username: c.login.username ?? 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: null,
|
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
|
||||||
fido2Credentials: null,
|
fido2Credentials: c.login.fido2Credentials ?? null,
|
||||||
uri: null,
|
uri: c.login.uri ?? null,
|
||||||
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: null,
|
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
|
||||||
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);
|
||||||
|
|
||||||
await storage.saveCipher(cipher);
|
cipherRows.push(cipher);
|
||||||
|
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cipherRows.length > 0) {
|
||||||
|
const cipherStatements = cipherRows.map(cipher => {
|
||||||
|
const data = JSON.stringify(cipher);
|
||||||
|
return env.DB
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' +
|
||||||
|
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||||
|
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at'
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
cipher.id,
|
||||||
|
cipher.userId,
|
||||||
|
Number(cipher.type) || 1,
|
||||||
|
bindNull(cipher.folderId),
|
||||||
|
bindNull(cipher.name),
|
||||||
|
bindNull(cipher.notes),
|
||||||
|
cipher.favorite ? 1 : 0,
|
||||||
|
data,
|
||||||
|
bindNull(cipher.reprompt ?? 0),
|
||||||
|
bindNull(cipher.key),
|
||||||
|
cipher.createdAt,
|
||||||
|
cipher.updatedAt,
|
||||||
|
bindNull(cipher.deletedAt)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await runBatchInChunks(env.DB, cipherStatements, batchChunkSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,795 +1,11 @@
|
|||||||
import { Env } from '../types';
|
import { Env } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, htmlResponse, errorResponse } from '../utils/response';
|
import { jsonResponse } from '../utils/response';
|
||||||
|
|
||||||
// Setup page HTML (single-file, no external assets)
|
|
||||||
const setupPageHTML = `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>NodeWarden</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
color-scheme: light;
|
|
||||||
--bg0: #0b0b0f;
|
|
||||||
--bg1: #0f1020;
|
|
||||||
--card: rgba(255, 255, 255, 0.08);
|
|
||||||
--card2: rgba(255, 255, 255, 0.06);
|
|
||||||
--border: rgba(255, 255, 255, 0.14);
|
|
||||||
--text: rgba(255, 255, 255, 0.92);
|
|
||||||
--muted: rgba(255, 255, 255, 0.62);
|
|
||||||
--muted2: rgba(255, 255, 255, 0.52);
|
|
||||||
--accent: #0a84ff;
|
|
||||||
--accent2: #64d2ff;
|
|
||||||
--danger: #ff453a;
|
|
||||||
--ok: #32d74b;
|
|
||||||
--shadow: 0 16px 60px rgba(0, 0, 0, 0.50);
|
|
||||||
--radius: 18px;
|
|
||||||
--radius2: 14px;
|
|
||||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
html, body { height: 100%; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
background:
|
|
||||||
radial-gradient(900px 600px at 15% 10%, rgba(100, 210, 255, 0.25), transparent 60%),
|
|
||||||
radial-gradient(900px 600px at 85% 20%, rgba(10, 132, 255, 0.22), transparent 60%),
|
|
||||||
radial-gradient(900px 600px at 50% 90%, rgba(50, 215, 75, 0.10), transparent 60%),
|
|
||||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
|
||||||
color: var(--text);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
.shell {
|
|
||||||
|
|
||||||
width: max(500px);
|
|
||||||
}
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.shell { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
.hero {
|
|
||||||
padding: 26px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.06));
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
-webkit-backdrop-filter: blur(16px);
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.hero::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: -2px;
|
|
||||||
background: radial-gradient(500px 240px at 20% 0%, rgba(100, 210, 255, 0.18), transparent 60%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.top {
|
|
||||||
display: flex;
|
|
||||||
gap: 14px;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.mark {
|
|
||||||
width: 46px;
|
|
||||||
height: 46px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: linear-gradient(135deg, rgba(10,132,255,0.85), rgba(100,210,255,0.55));
|
|
||||||
border: 1px solid rgba(255,255,255,0.20);
|
|
||||||
box-shadow: 0 10px 40px rgba(10, 132, 255, 0.30);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
color: rgba(255,255,255,0.96);
|
|
||||||
text-transform: uppercase;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.title h1 {
|
|
||||||
font-size: 22px;
|
|
||||||
margin: 0;
|
|
||||||
letter-spacing: -0.3px;
|
|
||||||
}
|
|
||||||
.title p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.panel {
|
|
||||||
padding: 22px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
-webkit-backdrop-filter: blur(16px);
|
|
||||||
}
|
|
||||||
.panel h2 {
|
|
||||||
font-size: 16px;
|
|
||||||
margin: 0 0 14px 0;
|
|
||||||
letter-spacing: -0.2px;
|
|
||||||
}
|
|
||||||
.message {
|
|
||||||
display: none;
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 12px 12px;
|
|
||||||
margin: 0 0 12px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.45;
|
|
||||||
border: 1px solid rgba(255,255,255,0.14);
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
}
|
|
||||||
.message.error {
|
|
||||||
display: block;
|
|
||||||
border-color: rgba(255, 69, 58, 0.40);
|
|
||||||
background: rgba(255, 69, 58, 0.10);
|
|
||||||
color: rgba(255, 255, 255, 0.92);
|
|
||||||
}
|
|
||||||
.message.success {
|
|
||||||
display: block;
|
|
||||||
border-color: rgba(50, 215, 75, 0.35);
|
|
||||||
background: rgba(50, 215, 75, 0.10);
|
|
||||||
color: rgba(255, 255, 255, 0.92);
|
|
||||||
}
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
@media (max-width: 540px) {
|
|
||||||
.grid { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
.field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 7px;
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
letter-spacing: 0.2px;
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
height: 42px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid rgba(255,255,255,0.18);
|
|
||||||
background: rgba(0,0,0,0.18);
|
|
||||||
color: rgba(255,255,255,0.92);
|
|
||||||
outline: none;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
|
||||||
}
|
|
||||||
input::placeholder { color: rgba(255,255,255,0.35); }
|
|
||||||
input:focus {
|
|
||||||
border-color: rgba(10, 132, 255, 0.55);
|
|
||||||
box-shadow: 0 0 0 6px rgba(10, 132, 255, 0.12);
|
|
||||||
}
|
|
||||||
.hint {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--muted2);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
.actions {
|
|
||||||
margin-top: 12px;
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.primary {
|
|
||||||
width: 100%;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 14px;
|
|
||||||
border: 1px solid rgba(255,255,255,0.18);
|
|
||||||
background: linear-gradient(135deg, rgba(10,132,255,0.95), rgba(100,210,255,0.60));
|
|
||||||
color: rgba(255,255,255,0.96);
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.2px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 120ms ease, filter 120ms ease;
|
|
||||||
}
|
|
||||||
.primary:hover { filter: brightness(1.03); }
|
|
||||||
.primary:active { transform: translateY(1px) scale(0.99); }
|
|
||||||
.primary:disabled {
|
|
||||||
opacity: 0.55;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
.sideCard {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.kv {
|
|
||||||
border-radius: var(--radius2);
|
|
||||||
border: 1px solid rgba(255,255,255,0.14);
|
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
padding: 14px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.kv h3 {
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: rgba(255,255,255,0.86);
|
|
||||||
}
|
|
||||||
.kv p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.55;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.server {
|
|
||||||
margin-top: 10px;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(0,0,0,0.25);
|
|
||||||
border: 1px solid rgba(255,255,255,0.12);
|
|
||||||
word-break: break-all;
|
|
||||||
color: rgba(255,255,255,0.90);
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
color: rgba(100, 210, 255, 0.92);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
.footer {
|
|
||||||
margin-top: 18px;
|
|
||||||
padding-top: 14px;
|
|
||||||
border-top: 1px solid rgba(255,255,255,0.10);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(255,255,255,0.55);
|
|
||||||
}
|
|
||||||
.muted { color: var(--muted); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="shell">
|
|
||||||
<aside class="panel">
|
|
||||||
<div class="top">
|
|
||||||
<div class="mark" aria-label="NodeWarden">NW</div>
|
|
||||||
<div class="title">
|
|
||||||
<h1 id="t_app">NodeWarden</h1>
|
|
||||||
<p id="t_tag">部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端(个人使用)。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height: 12px"></div>
|
|
||||||
<div class="muted" id="t_intro" style="font-size: 13px; line-height: 1.7;">
|
|
||||||
创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height: 14px"></div>
|
|
||||||
<h2 id="t_setup">初始化</h2>
|
|
||||||
|
|
||||||
<div id="message" class="message"></div>
|
|
||||||
|
|
||||||
<div id="setup-form">
|
|
||||||
<form id="form" onsubmit="handleSubmit(event)">
|
|
||||||
<div class="grid">
|
|
||||||
<div class="field">
|
|
||||||
<label for="name" id="t_name_label">Name</label>
|
|
||||||
<input type="text" id="name" name="name" required placeholder="Your name">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="email" id="t_email_label">Email</label>
|
|
||||||
<input type="email" id="email" name="email" required placeholder="you@example.com" autocomplete="email">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height: 10px"></div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="password" id="t_pw_label">Master password</label>
|
|
||||||
<input type="password" id="password" name="password" required minlength="12" placeholder="At least 12 characters" autocomplete="new-password">
|
|
||||||
<p class="hint" id="t_pw_hint">Choose a strong password you can remember. The server cannot recover it.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height: 10px"></div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="confirmPassword" id="t_pw2_label">Confirm password</label>
|
|
||||||
<input type="password" id="confirmPassword" name="confirmPassword" required placeholder="Confirm password" autocomplete="new-password">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button type="submit" id="submitBtn" class="primary">Create account</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="registered-view" class="sideCard" style="display: none;">
|
|
||||||
<div class="kv">
|
|
||||||
<h3 id="t_done_title">Setup complete</h3>
|
|
||||||
<p id="t_done_desc">Your server is ready. Configure your Bitwarden client with this server URL:</p>
|
|
||||||
<div class="server" id="serverUrl"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="kv">
|
|
||||||
<h3 id="t_important">Important</h3>
|
|
||||||
<p id="t_limitations">
|
|
||||||
This project is designed for a single user. You cannot add new users. Changing the master password is not supported.
|
|
||||||
If you forget it, you must redeploy and register again.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="kv">
|
|
||||||
<h3 id="t_hide_title">Hide setup page</h3>
|
|
||||||
<p id="t_hide_desc">After hiding, this setup page will return 404 for everyone. Your vault will keep working.</p>
|
|
||||||
<div class="actions">
|
|
||||||
<button type="button" id="hideBtn" class="primary" onclick="disableSetupPage()">Hide setup page</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<div>
|
|
||||||
<span class="muted" id="t_by">By</span>
|
|
||||||
<a href="https://shuai.plus" target="_blank" rel="noreferrer">shuaiplus</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a href="https://github.com/shuaiplus/nodewarden" target="_blank" rel="noreferrer">GitHub</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const AUTHOR = { name: 'shuaiplus', website: 'https://shuai.plus', github: 'https://github.com/shuaiplus/nodewarden' };
|
|
||||||
let isRegistered = false;
|
|
||||||
|
|
||||||
function isChinese() {
|
|
||||||
const lang = (navigator.language || '').toLowerCase();
|
|
||||||
return lang.startsWith('zh');
|
|
||||||
}
|
|
||||||
|
|
||||||
function t(key) {
|
|
||||||
const zh = {
|
|
||||||
app: 'NodeWarden',
|
|
||||||
tag: '部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端(个人使用)。',
|
|
||||||
intro: '创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。',
|
|
||||||
by: '作者',
|
|
||||||
setup: '初始化',
|
|
||||||
nameLabel: '昵称',
|
|
||||||
emailLabel: '邮箱',
|
|
||||||
pwLabel: '主密码',
|
|
||||||
pwHint: '请选择你能记住的强密码。服务器无法找回主密码。',
|
|
||||||
pw2Label: '确认主密码',
|
|
||||||
create: '创建账号',
|
|
||||||
creating: '正在创建…',
|
|
||||||
doneTitle: '初始化完成',
|
|
||||||
doneDesc: '服务已就绪。在 Bitwarden 客户端中填入以下服务器地址:',
|
|
||||||
important: '重要提示',
|
|
||||||
limitations: '本项目仅支持单用户:不能添加新用户;不支持修改主密码;如果忘记主密码,只能重新部署并重新注册。',
|
|
||||||
hideTitle: '隐藏初始化页',
|
|
||||||
hideDesc: '隐藏后,初始化页对任何人都会直接返回 404。你的密码库仍可正常使用。',
|
|
||||||
hideBtn: '隐藏初始化页',
|
|
||||||
hideWorking: '正在隐藏…',
|
|
||||||
hideDone: '已隐藏,此页面将返回 404。',
|
|
||||||
hideFailed: '隐藏失败',
|
|
||||||
hideConfirm: '确认隐藏初始化页?隐藏后页面将不可访问,但你的密码库不会受影响。',
|
|
||||||
errPwNotMatch: '两次输入的密码不一致',
|
|
||||||
errPwTooShort: '密码长度至少 12 位',
|
|
||||||
errGeneric: '发生错误:',
|
|
||||||
errRegisterFailed: '注册失败',
|
|
||||||
};
|
|
||||||
const en = {
|
|
||||||
app: 'NodeWarden',
|
|
||||||
tag: 'Minimal Bitwarden-compatible server on Cloudflare Workers (personal use).',
|
|
||||||
intro: 'Create your first account to finish setup. Then use any official Bitwarden client to sign in.',
|
|
||||||
by: 'By',
|
|
||||||
setup: 'Setup',
|
|
||||||
nameLabel: 'Name',
|
|
||||||
emailLabel: 'Email',
|
|
||||||
pwLabel: 'Master password',
|
|
||||||
pwHint: 'Choose a strong password you can remember. The server cannot recover it.',
|
|
||||||
pw2Label: 'Confirm password',
|
|
||||||
create: 'Create account',
|
|
||||||
creating: 'Creating…',
|
|
||||||
doneTitle: 'Setup complete',
|
|
||||||
doneDesc: 'Your server is ready. Configure your Bitwarden client with this server URL:',
|
|
||||||
important: 'Important',
|
|
||||||
limitations: 'Single user only: you cannot add new users. Changing the master password is not supported. If you forget it, redeploy and register again.',
|
|
||||||
hideTitle: 'Hide setup page',
|
|
||||||
hideDesc: 'After hiding, this setup page will return 404 for everyone. Your vault will keep working.',
|
|
||||||
hideBtn: 'Hide setup page',
|
|
||||||
hideWorking: 'Hiding…',
|
|
||||||
hideDone: 'Hidden. This page will now return 404.',
|
|
||||||
hideFailed: 'Failed to hide setup page',
|
|
||||||
hideConfirm: 'Hide the setup page? It will no longer be accessible, but your vault will keep working.',
|
|
||||||
errPwNotMatch: 'Passwords do not match',
|
|
||||||
errPwTooShort: 'Password must be at least 12 characters',
|
|
||||||
errGeneric: 'An error occurred: ',
|
|
||||||
errRegisterFailed: 'Registration failed',
|
|
||||||
};
|
|
||||||
return (isChinese() ? zh : en)[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyI18n() {
|
|
||||||
document.documentElement.lang = isChinese() ? 'zh-CN' : 'en';
|
|
||||||
|
|
||||||
document.getElementById('t_app').textContent = t('app');
|
|
||||||
document.getElementById('t_tag').textContent = t('tag');
|
|
||||||
document.getElementById('t_intro').textContent = t('intro');
|
|
||||||
document.getElementById('t_by').textContent = t('by');
|
|
||||||
document.getElementById('t_setup').textContent = t('setup');
|
|
||||||
|
|
||||||
document.getElementById('t_name_label').textContent = t('nameLabel');
|
|
||||||
document.getElementById('t_email_label').textContent = t('emailLabel');
|
|
||||||
document.getElementById('t_pw_label').textContent = t('pwLabel');
|
|
||||||
document.getElementById('t_pw_hint').textContent = t('pwHint');
|
|
||||||
document.getElementById('t_pw2_label').textContent = t('pw2Label');
|
|
||||||
document.getElementById('submitBtn').textContent = t('create');
|
|
||||||
|
|
||||||
document.getElementById('t_done_title').textContent = t('doneTitle');
|
|
||||||
document.getElementById('t_done_desc').textContent = t('doneDesc');
|
|
||||||
document.getElementById('t_important').textContent = t('important');
|
|
||||||
document.getElementById('t_limitations').textContent = t('limitations');
|
|
||||||
document.getElementById('t_hide_title').textContent = t('hideTitle');
|
|
||||||
document.getElementById('t_hide_desc').textContent = t('hideDesc');
|
|
||||||
document.getElementById('hideBtn').textContent = t('hideBtn');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already registered
|
|
||||||
async function checkStatus() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/setup/status');
|
|
||||||
const data = await res.json();
|
|
||||||
isRegistered = !!data.registered;
|
|
||||||
if (data.registered) {
|
|
||||||
showRegisteredView();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to check status:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showRegisteredView() {
|
|
||||||
isRegistered = true;
|
|
||||||
document.getElementById('setup-form').style.display = 'none';
|
|
||||||
document.getElementById('registered-view').style.display = 'block';
|
|
||||||
document.getElementById('serverUrl').textContent = window.location.origin;
|
|
||||||
showMessage(t('doneTitle'), 'success');
|
|
||||||
const form = document.getElementById('form');
|
|
||||||
if (form) {
|
|
||||||
const fields = form.querySelectorAll('input, button');
|
|
||||||
fields.forEach((el) => {
|
|
||||||
el.disabled = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function disableSetupPage() {
|
|
||||||
if (!isRegistered) return;
|
|
||||||
if (!confirm(t('hideConfirm'))) return;
|
|
||||||
|
|
||||||
const btn = document.getElementById('hideBtn');
|
|
||||||
if (btn) {
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = t('hideWorking');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/setup/disable', { method: 'POST' });
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok && data.success) {
|
|
||||||
showMessage(t('hideDone'), 'success');
|
|
||||||
setTimeout(() => window.location.reload(), 600);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showMessage(data.error || t('hideFailed'), 'error');
|
|
||||||
} catch (e) {
|
|
||||||
showMessage(t('hideFailed'), 'error');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (btn) {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = t('hideBtn');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showMessage(text, type) {
|
|
||||||
const msg = document.getElementById('message');
|
|
||||||
msg.textContent = text;
|
|
||||||
msg.className = 'message ' + type;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PBKDF2-SHA256 key derivation (compatible with Bitwarden)
|
|
||||||
// password can be string or Uint8Array
|
|
||||||
async function pbkdf2(password, salt, iterations, keyLen) {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
|
|
||||||
// Handle password as string or Uint8Array
|
|
||||||
const passwordBytes = (password instanceof Uint8Array)
|
|
||||||
? password
|
|
||||||
: encoder.encode(password);
|
|
||||||
|
|
||||||
// Handle salt as string or Uint8Array
|
|
||||||
const saltBytes = (salt instanceof Uint8Array)
|
|
||||||
? salt
|
|
||||||
: encoder.encode(salt);
|
|
||||||
|
|
||||||
const keyMaterial = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
passwordBytes,
|
|
||||||
'PBKDF2',
|
|
||||||
false,
|
|
||||||
['deriveBits']
|
|
||||||
);
|
|
||||||
|
|
||||||
const derivedBits = await crypto.subtle.deriveBits(
|
|
||||||
{
|
|
||||||
name: 'PBKDF2',
|
|
||||||
salt: saltBytes,
|
|
||||||
iterations: iterations,
|
|
||||||
hash: 'SHA-256'
|
|
||||||
},
|
|
||||||
keyMaterial,
|
|
||||||
keyLen * 8
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Uint8Array(derivedBits);
|
|
||||||
}
|
|
||||||
|
|
||||||
// HKDF expand
|
|
||||||
async function hkdfExpand(prk, info, length) {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const key = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
prk,
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['sign']
|
|
||||||
);
|
|
||||||
|
|
||||||
const infoBytes = encoder.encode(info);
|
|
||||||
const result = new Uint8Array(length);
|
|
||||||
let prev = new Uint8Array(0);
|
|
||||||
let offset = 0;
|
|
||||||
let counter = 1;
|
|
||||||
|
|
||||||
while (offset < length) {
|
|
||||||
const input = new Uint8Array(prev.length + infoBytes.length + 1);
|
|
||||||
input.set(prev);
|
|
||||||
input.set(infoBytes, prev.length);
|
|
||||||
input[input.length - 1] = counter;
|
|
||||||
|
|
||||||
const signature = await crypto.subtle.sign('HMAC', key, input);
|
|
||||||
prev = new Uint8Array(signature);
|
|
||||||
|
|
||||||
const toCopy = Math.min(prev.length, length - offset);
|
|
||||||
result.set(prev.slice(0, toCopy), offset);
|
|
||||||
offset += toCopy;
|
|
||||||
counter++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate symmetric key
|
|
||||||
function generateSymmetricKey() {
|
|
||||||
return crypto.getRandomValues(new Uint8Array(64));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt with AES-256-CBC
|
|
||||||
async function encryptAesCbc(data, key, iv) {
|
|
||||||
const cryptoKey = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
key,
|
|
||||||
{ name: 'AES-CBC' },
|
|
||||||
false,
|
|
||||||
['encrypt']
|
|
||||||
);
|
|
||||||
|
|
||||||
const encrypted = await crypto.subtle.encrypt(
|
|
||||||
{ name: 'AES-CBC', iv: iv },
|
|
||||||
cryptoKey,
|
|
||||||
data
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Uint8Array(encrypted);
|
|
||||||
}
|
|
||||||
|
|
||||||
// HMAC-SHA256
|
|
||||||
async function hmacSha256(key, data) {
|
|
||||||
const cryptoKey = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
key,
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['sign']
|
|
||||||
);
|
|
||||||
|
|
||||||
const signature = await crypto.subtle.sign('HMAC', cryptoKey, data);
|
|
||||||
return new Uint8Array(signature);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Base64 encode
|
|
||||||
function base64Encode(bytes) {
|
|
||||||
return btoa(String.fromCharCode.apply(null, bytes));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create encrypted string in Bitwarden format
|
|
||||||
async function encryptToBitwardenFormat(data, encKey, macKey) {
|
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
|
||||||
const encrypted = await encryptAesCbc(data, encKey, iv);
|
|
||||||
|
|
||||||
// Calculate MAC over IV + encrypted data
|
|
||||||
const macData = new Uint8Array(iv.length + encrypted.length);
|
|
||||||
macData.set(iv);
|
|
||||||
macData.set(encrypted, iv.length);
|
|
||||||
const mac = await hmacSha256(macKey, macData);
|
|
||||||
|
|
||||||
// Format: 2.{base64(iv)}|{base64(encrypted)}|{base64(mac)}
|
|
||||||
return '2.' + base64Encode(iv) + '|' + base64Encode(encrypted) + '|' + base64Encode(mac);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate RSA key pair
|
|
||||||
async function generateRsaKeyPair() {
|
|
||||||
const keyPair = await crypto.subtle.generateKey(
|
|
||||||
{
|
|
||||||
name: 'RSA-OAEP',
|
|
||||||
modulusLength: 2048,
|
|
||||||
publicExponent: new Uint8Array([1, 0, 1]),
|
|
||||||
hash: 'SHA-1'
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
['encrypt', 'decrypt']
|
|
||||||
);
|
|
||||||
|
|
||||||
// Export public key
|
|
||||||
const publicKeySpki = await crypto.subtle.exportKey('spki', keyPair.publicKey);
|
|
||||||
const publicKeyB64 = base64Encode(new Uint8Array(publicKeySpki));
|
|
||||||
|
|
||||||
// Export private key
|
|
||||||
const privateKeyPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
|
||||||
const privateKeyBytes = new Uint8Array(privateKeyPkcs8);
|
|
||||||
|
|
||||||
return {
|
|
||||||
publicKey: publicKeyB64,
|
|
||||||
privateKey: privateKeyBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (isRegistered) {
|
|
||||||
showMessage(t('doneTitle'), 'success');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = document.getElementById('name').value;
|
|
||||||
const email = document.getElementById('email').value.toLowerCase();
|
|
||||||
const password = document.getElementById('password').value;
|
|
||||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
showMessage(t('errPwNotMatch'), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.length < 12) {
|
|
||||||
showMessage(t('errPwTooShort'), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const btn = document.getElementById('submitBtn');
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = t('creating');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Generate master key using PBKDF2 (Bitwarden default: 600000 iterations)
|
|
||||||
const iterations = 600000;
|
|
||||||
const masterKey = await pbkdf2(password, email, iterations, 32);
|
|
||||||
|
|
||||||
// Generate master password hash (for authentication)
|
|
||||||
// Bitwarden: PBKDF2(masterKey as raw bytes, password, 1 iteration)
|
|
||||||
const masterPasswordHash = await pbkdf2(masterKey, password, 1, 32);
|
|
||||||
const masterPasswordHashB64 = base64Encode(masterPasswordHash);
|
|
||||||
|
|
||||||
// Stretch master key using HKDF
|
|
||||||
const stretchedKey = await hkdfExpand(masterKey, 'enc', 32);
|
|
||||||
const stretchedMacKey = await hkdfExpand(masterKey, 'mac', 32);
|
|
||||||
|
|
||||||
// Generate symmetric key (will be encrypted with stretched master key)
|
|
||||||
const symmetricKey = generateSymmetricKey();
|
|
||||||
|
|
||||||
// Encrypt symmetric key with stretched master key
|
|
||||||
const encryptedKey = await encryptToBitwardenFormat(symmetricKey, stretchedKey, stretchedMacKey);
|
|
||||||
|
|
||||||
// Generate RSA key pair
|
|
||||||
const rsaKeys = await generateRsaKeyPair();
|
|
||||||
|
|
||||||
// Encrypt private key with symmetric key
|
|
||||||
const encryptedPrivateKey = await encryptToBitwardenFormat(rsaKeys.privateKey, symmetricKey.slice(0, 32), symmetricKey.slice(32, 64));
|
|
||||||
|
|
||||||
// Register with server
|
|
||||||
const response = await fetch('/api/accounts/register', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: email,
|
|
||||||
name: name,
|
|
||||||
masterPasswordHash: masterPasswordHashB64,
|
|
||||||
key: encryptedKey,
|
|
||||||
kdf: 0,
|
|
||||||
kdfIterations: iterations,
|
|
||||||
keys: {
|
|
||||||
publicKey: rsaKeys.publicKey,
|
|
||||||
encryptedPrivateKey: encryptedPrivateKey
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
|
||||||
showRegisteredView();
|
|
||||||
} else {
|
|
||||||
showMessage(result.error || result.ErrorModel?.Message || t('errRegisterFailed'), 'error');
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = t('create');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Registration error:', error);
|
|
||||||
showMessage(t('errGeneric') + (error && error.message ? error.message : String(error)), 'error');
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = t('create');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check status on page load
|
|
||||||
applyI18n();
|
|
||||||
checkStatus();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
// GET / - Setup page
|
|
||||||
export async function handleSetupPage(request: Request, env: Env): Promise<Response> {
|
|
||||||
const storage = new StorageService(env.VAULT);
|
|
||||||
const disabled = await storage.isSetupDisabled();
|
|
||||||
if (disabled) {
|
|
||||||
return new Response(null, { status: 404 });
|
|
||||||
}
|
|
||||||
return htmlResponse(setupPageHTML);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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> {
|
||||||
const storage = new StorageService(env.VAULT);
|
void request;
|
||||||
const registered = await storage.isRegistered();
|
const storage = new StorageService(env.DB);
|
||||||
const disabled = await storage.isSetupDisabled();
|
const registered = (await storage.isRegistered()) || (await storage.getUserCount()) > 0;
|
||||||
return jsonResponse({ registered, disabled });
|
return jsonResponse({ registered });
|
||||||
}
|
|
||||||
|
|
||||||
// POST /setup/disable
|
|
||||||
export async function handleDisableSetup(request: Request, env: Env): Promise<Response> {
|
|
||||||
const storage = new StorageService(env.VAULT);
|
|
||||||
const registered = await storage.isRegistered();
|
|
||||||
if (!registered) {
|
|
||||||
return errorResponse('Registration required', 403);
|
|
||||||
}
|
|
||||||
await storage.setSetupDisabled();
|
|
||||||
return jsonResponse({ success: true });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,68 @@
|
|||||||
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse, Attachment } from '../types';
|
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { errorResponse } from '../utils/response';
|
||||||
|
import { cipherToResponse } from './ciphers';
|
||||||
|
import { sendToResponse } from './sends';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
// Format attachments for API response
|
interface SyncCacheEntry {
|
||||||
function formatAttachments(attachments: Attachment[]): any[] | null {
|
body: string;
|
||||||
if (attachments.length === 0) return null;
|
expiresAt: number;
|
||||||
return attachments.map(a => ({
|
}
|
||||||
id: a.id,
|
|
||||||
fileName: a.fileName,
|
const syncResponseCache = new Map<string, SyncCacheEntry>();
|
||||||
size: Number(a.size) || 0, // Android expects Int, not String
|
|
||||||
sizeName: a.sizeName,
|
function buildSyncCacheKey(userId: string, revisionDate: string, excludeDomains: boolean): string {
|
||||||
key: a.key,
|
return `${userId}:${revisionDate}:${excludeDomains ? '1' : '0'}`;
|
||||||
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url!
|
}
|
||||||
object: 'attachment',
|
|
||||||
}));
|
function readSyncCache(key: string): string | null {
|
||||||
|
const hit = syncResponseCache.get(key);
|
||||||
|
if (!hit) return null;
|
||||||
|
if (hit.expiresAt <= Date.now()) {
|
||||||
|
syncResponseCache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return hit.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSyncCache(key: string, body: string): void {
|
||||||
|
if (syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries) {
|
||||||
|
const oldestKey = syncResponseCache.keys().next().value as string | undefined;
|
||||||
|
if (oldestKey) syncResponseCache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
syncResponseCache.set(key, {
|
||||||
|
body,
|
||||||
|
expiresAt: Date.now() + LIMITS.cache.syncResponseTtlMs,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/sync
|
// GET /api/sync
|
||||||
export async function handleSync(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleSync(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
||||||
|
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||||
|
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return errorResponse('User not found', 404);
|
return errorResponse('User not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const revisionDate = await storage.getRevisionDate(userId);
|
||||||
|
const cacheKey = buildSyncCacheKey(userId, revisionDate, excludeDomains);
|
||||||
|
const cachedBody = readSyncCache(cacheKey);
|
||||||
|
if (cachedBody) {
|
||||||
|
return new Response(cachedBody, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
// Build profile response
|
// Build profile response
|
||||||
const profile: ProfileResponse = {
|
const profile: ProfileResponse = {
|
||||||
@@ -39,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: false,
|
twoFactorEnabled: !!user.totpSecret,
|
||||||
key: user.key,
|
key: user.key,
|
||||||
privateKey: user.privateKey,
|
privateKey: user.privateKey,
|
||||||
accountKeys: null,
|
accountKeys: null,
|
||||||
@@ -56,41 +92,9 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
// Build cipher responses with attachments
|
// Build cipher responses with attachments
|
||||||
const cipherResponses: CipherResponse[] = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of ciphers) {
|
for (const cipher of ciphers) {
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||||
cipherResponses.push({
|
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||||
id: cipher.id,
|
}
|
||||||
organizationId: null,
|
|
||||||
folderId: cipher.folderId,
|
|
||||||
type: Number(cipher.type) || 1,
|
|
||||||
name: cipher.name,
|
|
||||||
notes: cipher.notes,
|
|
||||||
favorite: cipher.favorite,
|
|
||||||
login: cipher.login,
|
|
||||||
card: cipher.card,
|
|
||||||
identity: cipher.identity,
|
|
||||||
secureNote: cipher.secureNote,
|
|
||||||
sshKey: cipher.sshKey,
|
|
||||||
fields: cipher.fields,
|
|
||||||
passwordHistory: cipher.passwordHistory,
|
|
||||||
reprompt: cipher.reprompt,
|
|
||||||
organizationUseTotp: false,
|
|
||||||
creationDate: cipher.createdAt,
|
|
||||||
revisionDate: cipher.updatedAt,
|
|
||||||
deletedDate: cipher.deletedAt,
|
|
||||||
archivedDate: null,
|
|
||||||
edit: true,
|
|
||||||
viewPassword: true,
|
|
||||||
permissions: {
|
|
||||||
delete: true,
|
|
||||||
restore: true,
|
|
||||||
},
|
|
||||||
object: 'cipher',
|
|
||||||
collectionIds: [],
|
|
||||||
attachments: formatAttachments(attachments),
|
|
||||||
key: cipher.key,
|
|
||||||
encryptedFor: null,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build folder responses
|
// Build folder responses
|
||||||
const folderResponses: FolderResponse[] = folders.map(folder => ({
|
const folderResponses: FolderResponse[] = folders.map(folder => ({
|
||||||
@@ -105,27 +109,54 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
folders: folderResponses,
|
folders: folderResponses,
|
||||||
collections: [],
|
collections: [],
|
||||||
ciphers: cipherResponses,
|
ciphers: cipherResponses,
|
||||||
domains: {
|
domains: excludeDomains
|
||||||
|
? null
|
||||||
|
: {
|
||||||
equivalentDomains: [],
|
equivalentDomains: [],
|
||||||
globalEquivalentDomains: [],
|
globalEquivalentDomains: [],
|
||||||
object: 'domains',
|
object: 'domains',
|
||||||
},
|
},
|
||||||
policies: [],
|
policies: [],
|
||||||
sends: [],
|
sends: sends.map(sendToResponse),
|
||||||
|
// PascalCase for desktop/browser clients
|
||||||
|
UserDecryptionOptions: {
|
||||||
|
HasMasterPassword: true,
|
||||||
|
Object: 'userDecryptionOptions',
|
||||||
|
MasterPasswordUnlock: {
|
||||||
|
Kdf: {
|
||||||
|
KdfType: user.kdfType,
|
||||||
|
Iterations: user.kdfIterations,
|
||||||
|
Memory: user.kdfMemory || null,
|
||||||
|
Parallelism: user.kdfParallelism || null,
|
||||||
|
},
|
||||||
|
MasterKeyEncryptedUserKey: user.key,
|
||||||
|
MasterKeyWrappedUserKey: user.key,
|
||||||
|
Salt: user.email.toLowerCase(),
|
||||||
|
Object: 'masterPasswordUnlock',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
||||||
userDecryption: {
|
userDecryption: {
|
||||||
masterPasswordUnlock: {
|
masterPasswordUnlock: {
|
||||||
salt: user.email,
|
|
||||||
kdf: {
|
kdf: {
|
||||||
kdfType: user.kdfType,
|
kdfType: user.kdfType,
|
||||||
iterations: user.kdfIterations,
|
iterations: user.kdfIterations,
|
||||||
memory: user.kdfMemory || null,
|
memory: user.kdfMemory || null,
|
||||||
parallelism: user.kdfParallelism || null,
|
parallelism: user.kdfParallelism || null,
|
||||||
},
|
},
|
||||||
|
masterKeyWrappedUserKey: user.key,
|
||||||
masterKeyEncryptedUserKey: user.key,
|
masterKeyEncryptedUserKey: user.key,
|
||||||
|
salt: user.email.toLowerCase(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
object: 'sync',
|
object: 'sync',
|
||||||
};
|
};
|
||||||
|
|
||||||
return jsonResponse(syncResponse);
|
const body = JSON.stringify(syncResponse);
|
||||||
|
writeSyncCache(cacheKey, body);
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,56 @@
|
|||||||
import { Env } from './types';
|
import { Env } from './types';
|
||||||
import { handleRequest } from './router';
|
import { handleRequest } from './router';
|
||||||
|
import { StorageService } from './services/storage';
|
||||||
|
import { applyCors, jsonResponse } from './utils/response';
|
||||||
|
|
||||||
|
let dbInitialized = false;
|
||||||
|
let dbInitError: string | null = null;
|
||||||
|
let dbInitPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
||||||
|
if (dbInitialized) return;
|
||||||
|
|
||||||
|
if (!dbInitPromise) {
|
||||||
|
dbInitPromise = (async () => {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
await storage.initializeDatabase();
|
||||||
|
dbInitialized = true;
|
||||||
|
dbInitError = null;
|
||||||
|
})()
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error('Failed to initialize database:', error);
|
||||||
|
dbInitError = error instanceof Error ? error.message : 'Unknown database initialization error';
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
dbInitPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbInitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||||
// Security check: JWT_SECRET must be set
|
void ctx;
|
||||||
if (!env.JWT_SECRET) {
|
await ensureDatabaseInitialized(env);
|
||||||
return new Response('Server configuration error: JWT_SECRET is not set', { status: 500 });
|
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(
|
||||||
|
{
|
||||||
|
error: 'Database not initialized',
|
||||||
|
error_description: 'Database initialization failed. Check server logs for details.',
|
||||||
|
ErrorModel: {
|
||||||
|
Message: 'Service temporarily unavailable',
|
||||||
|
Object: 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
500
|
||||||
|
);
|
||||||
|
return applyCors(request, resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Security check: warn if JWT_SECRET is too weak
|
const resp = await handleRequest(request, env);
|
||||||
if (env.JWT_SECRET.length < 32) {
|
return applyCors(request, resp);
|
||||||
console.warn('[SECURITY WARNING] JWT_SECRET should be at least 32 characters for adequate security');
|
|
||||||
}
|
|
||||||
|
|
||||||
return handleRequest(request, env);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
import { Env } 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';
|
||||||
|
|
||||||
// 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 {
|
||||||
@@ -16,6 +29,7 @@ import {
|
|||||||
handleCreateCipher,
|
handleCreateCipher,
|
||||||
handleUpdateCipher,
|
handleUpdateCipher,
|
||||||
handleDeleteCipher,
|
handleDeleteCipher,
|
||||||
|
handleDeleteCipherCompat,
|
||||||
handlePermanentDeleteCipher,
|
handlePermanentDeleteCipher,
|
||||||
handleRestoreCipher,
|
handleRestoreCipher,
|
||||||
handlePartialUpdateCipher,
|
handlePartialUpdateCipher,
|
||||||
@@ -31,11 +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,
|
||||||
|
handleGetAuthorizedDevices,
|
||||||
|
handleGetDevices,
|
||||||
|
handleRevokeAllTrustedDevices,
|
||||||
|
handleRevokeTrustedDevice,
|
||||||
|
handleDeleteDevice,
|
||||||
|
handleUpdateDeviceToken
|
||||||
|
} from './handlers/devices';
|
||||||
|
|
||||||
// Import handler
|
// Import handler
|
||||||
import { handleCiphersImport } from './handlers/import';
|
import { handleCiphersImport } from './handlers/import';
|
||||||
@@ -48,27 +90,117 @@ 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 {
|
||||||
|
const targetOrigin = new URL(request.url).origin;
|
||||||
|
const origin = request.headers.get('Origin');
|
||||||
|
if (origin) {
|
||||||
|
return origin === targetOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referer = request.headers.get('Referer');
|
||||||
|
if (referer) {
|
||||||
|
try {
|
||||||
|
return new URL(referer).origin === targetOrigin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require browser-origin evidence for setup/register write operations.
|
||||||
|
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 {
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNwFavicon(): Response {
|
||||||
|
return new Response(getNwIconSvg(), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'image/svg+xml; charset=utf-8',
|
||||||
|
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidIconHostname(hostname: string): boolean {
|
||||||
|
if (!hostname) return false;
|
||||||
|
if (hostname.length > 253) return false;
|
||||||
|
|
||||||
|
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
||||||
|
// Slightly relaxed domain validation:
|
||||||
|
// - keep strict label boundaries (no leading/trailing hyphen)
|
||||||
|
// - allow punycode TLD (e.g. xn--...)
|
||||||
|
const domainPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})$/;
|
||||||
|
const ipv4Pattern = /^(?:\d{1,3}\.){3}\d{1,3}$/;
|
||||||
|
|
||||||
|
if (domainPattern.test(normalized)) return true;
|
||||||
|
if (!ipv4Pattern.test(normalized)) return false;
|
||||||
|
|
||||||
|
const parts = normalized.split('.');
|
||||||
|
return parts.every(p => {
|
||||||
|
const n = Number(p);
|
||||||
|
return Number.isInteger(n) && n >= 0 && n <= 255;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Icons handler - proxy to Bitwarden's official icon service
|
// Icons handler - proxy to Bitwarden's official icon service
|
||||||
async function handleGetIcon(request: Request, env: Env, hostname: string): Promise<Response> {
|
async function handleGetIcon(request: Request, env: Env, hostname: string): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
void env;
|
||||||
|
const normalizedHostname = hostname.toLowerCase();
|
||||||
|
if (!isValidIconHostname(normalizedHostname)) {
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = caches.default;
|
||||||
|
const cacheKey = new Request(`https://nodewarden-icons.local/icons/${normalizedHostname}/icon.png`, { method: 'GET' });
|
||||||
|
const cached = await cache.match(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
// Use Bitwarden's official icon service
|
// Use Bitwarden's official icon service
|
||||||
const iconUrl = `https://icons.bitwarden.net/${hostname}/icon.png`;
|
const iconUrl = `https://icons.bitwarden.net/${normalizedHostname}/icon.png`;
|
||||||
const resp = await fetch(iconUrl, {
|
const resp = await fetch(iconUrl, {
|
||||||
headers: { 'User-Agent': 'NodeWarden/1.0' },
|
headers: { 'User-Agent': 'NodeWarden/1.0' },
|
||||||
redirect: 'follow',
|
redirect: 'follow',
|
||||||
|
cf: {
|
||||||
|
cacheEverything: true,
|
||||||
|
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const body = await resp.arrayBuffer();
|
const body = await resp.arrayBuffer();
|
||||||
return new Response(body, {
|
const iconResponse = new Response(body, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
||||||
'Cache-Control': 'public, max-age=604800', // 7 days
|
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`, // 7 days
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await cache.put(cacheKey, iconResponse.clone());
|
||||||
|
return iconResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
@@ -81,17 +213,43 @@ 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') {
|
||||||
return handleCors();
|
return handleCors(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route matching
|
// Route matching
|
||||||
try {
|
try {
|
||||||
// Setup page (root)
|
|
||||||
if (path === '/' && method === 'GET') {
|
// Reject oversized bodies before any path-specific parsing.
|
||||||
return handleSetupPage(request, env);
|
// File upload paths enforce their own limits and are exempt here.
|
||||||
|
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
|
||||||
@@ -99,14 +257,30 @@ 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') {
|
||||||
return handleDisableSetup(request, env);
|
const jwtUnsafeReason = jwtSecretUnsafeReason(env);
|
||||||
|
return jsonResponse({
|
||||||
|
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
|
||||||
|
jwtUnsafeReason,
|
||||||
|
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Favicon - return empty
|
// Browser/devtools probe endpoint
|
||||||
if (path === '/favicon.ico') {
|
if (path === '/.well-known/appspecific/com.chrome.devtools.json' && method === 'GET') {
|
||||||
return new Response(null, { status: 204 });
|
return new Response('{}', {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favicon
|
||||||
|
if ((path === '/favicon.ico' || path === '/favicon.svg') && method === 'GET') {
|
||||||
|
return handleNwFavicon();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Icon endpoint - proxy to Bitwarden's icon service (no auth required)
|
// Icon endpoint - proxy to Bitwarden's icon service (no auth required)
|
||||||
@@ -124,18 +298,58 @@ 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) - returns plain string "true" or "false"
|
// Known device check (no auth required)
|
||||||
if (path.startsWith('/api/devices/knowndevice')) {
|
if (path === '/api/devices/knowndevice' && method === 'GET') {
|
||||||
return new Response('true', {
|
const blocked = await enforcePublicRateLimit();
|
||||||
headers: {
|
if (blocked) return jsonResponse(false);
|
||||||
'Content-Type': 'text/plain',
|
return handleKnownDevice(request, env);
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identity endpoints (no auth required)
|
// Identity endpoints (no auth required)
|
||||||
@@ -143,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.
|
||||||
@@ -154,7 +376,19 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
if (isConfigRequest) {
|
if (isConfigRequest) {
|
||||||
const origin = url.origin;
|
const origin = url.origin;
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
version: '2025.12.0',
|
// ── Version Strategy (Plan E) ──────────────────────────────────────
|
||||||
|
// Bitwarden clients use this version for backwards-compatibility feature gating.
|
||||||
|
// Confirmed version-gated features (from client source code):
|
||||||
|
// - Individual cipher key encryption: >= 2024.2.0
|
||||||
|
// (clients/libs/common/src/vault/services/cipher.service.ts: CIPHER_KEY_ENC_MIN_SERVER_VER)
|
||||||
|
// (android/.../FeatureFlagManagerImpl.kt: CIPHER_KEY_ENC_MIN_SERVER_VERSION)
|
||||||
|
// - MasterPasswordUnlockData (mobile): >= 2025.8.0
|
||||||
|
// (documented in Vaultwarden source comments)
|
||||||
|
// There is NO global minimum version that blocks all client functionality.
|
||||||
|
// Keep this aligned with Vaultwarden's reported version to maintain compatibility.
|
||||||
|
// When Vaultwarden bumps their version, update this value accordingly.
|
||||||
|
// Vaultwarden source: src/api/core/mod.rs → fn config()
|
||||||
|
version: LIMITS.compatibility.bitwardenServerVersion,
|
||||||
gitHash: 'nodewarden',
|
gitHash: 'nodewarden',
|
||||||
server: null,
|
server: null,
|
||||||
environment: {
|
environment: {
|
||||||
@@ -164,8 +398,15 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
notifications: origin + '/notifications',
|
notifications: origin + '/notifications',
|
||||||
sso: '',
|
sso: '',
|
||||||
},
|
},
|
||||||
|
// Feature flags control client behavior. Clients use server-provided values;
|
||||||
|
// flags not listed here fall back to DefaultFeatureFlagValue (all false).
|
||||||
|
// Only enable flags for features we actually support.
|
||||||
|
// Reference: clients/libs/common/src/enums/feature-flag.enum.ts
|
||||||
featureStates: {
|
featureStates: {
|
||||||
'duo-redirect': true,
|
'duo-redirect': true,
|
||||||
|
'email-verification': true,
|
||||||
|
'pm-19051-send-email-verification': false,
|
||||||
|
'unauth-ui-refresh': true,
|
||||||
},
|
},
|
||||||
object: 'config',
|
object: 'config',
|
||||||
});
|
});
|
||||||
@@ -173,14 +414,25 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
|
|
||||||
// Version endpoint (some clients probe this to validate the server)
|
// Version endpoint (some clients probe this to validate the server)
|
||||||
if (path === '/api/version' && method === 'GET') {
|
if (path === '/api/version' && method === 'GET') {
|
||||||
return jsonResponse('2025.12.0');
|
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)) {
|
||||||
|
return errorResponse('Forbidden origin', 403);
|
||||||
|
}
|
||||||
return handleRegister(request, env);
|
return handleRegister(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If JWT_SECRET is not safely configured, block any other endpoints.
|
||||||
|
const secret = jwtSecretUnsafeReason(env);
|
||||||
|
if (secret) {
|
||||||
|
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
|
||||||
|
}
|
||||||
|
|
||||||
// All other API endpoints require authentication
|
// All other API endpoints require authentication
|
||||||
const auth = new AuthService(env);
|
const auth = new AuthService(env);
|
||||||
const authHeader = request.headers.get('Authorization');
|
const authHeader = request.headers.get('Authorization');
|
||||||
@@ -191,11 +443,21 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userId = payload.sub;
|
const userId = payload.sub;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
// API rate limiting for authenticated requests
|
const currentUser = await storage.getUserById(userId);
|
||||||
const rateLimit = new RateLimitService(env.VAULT);
|
if (!currentUser) {
|
||||||
const clientId = getClientIdentifier(request);
|
return errorResponse('Unauthorized', 401);
|
||||||
const rateLimitCheck = await rateLimit.checkApiRateLimit(userId + ':' + clientId);
|
}
|
||||||
|
if (currentUser.status !== 'active') {
|
||||||
|
return errorResponse('Account is disabled', 403);
|
||||||
|
}
|
||||||
|
// Unified rate limiting for all authenticated API requests.
|
||||||
|
{
|
||||||
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
|
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({
|
||||||
@@ -210,36 +472,44 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Increment rate limit counter
|
// Block account operations we do not support yet.
|
||||||
await rateLimit.incrementApiCount(userId + ':' + clientId);
|
|
||||||
|
|
||||||
// Block account operations that could change password or delete user
|
|
||||||
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('This operation is disabled', 403);
|
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);
|
||||||
@@ -282,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') {
|
||||||
@@ -372,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,9 +704,60 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Devices endpoint (stub) - for authenticated requests
|
// Devices endpoint
|
||||||
if (path === '/api/devices' && method === 'GET') {
|
if (path === '/api/devices' && method === 'GET') {
|
||||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
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
|
||||||
|
|||||||
@@ -2,18 +2,62 @@ 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;
|
||||||
|
|
||||||
constructor(private env: Env) {
|
constructor(private env: Env) {
|
||||||
this.storage = new StorageService(env.VAULT);
|
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).
|
||||||
// In Bitwarden, the client sends the password hash directly
|
// Result is prefixed with "$s$" to distinguish from legacy raw client hashes.
|
||||||
// We compare the hashes
|
async hashPasswordServer(clientHash: string, email: string): Promise<string> {
|
||||||
return inputHash === storedHash;
|
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;
|
||||||
|
for (let i = 0; i < encA.length; i++) {
|
||||||
|
diff |= encA[i] ^ encB[i];
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate access token
|
// Generate access token
|
||||||
@@ -66,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 };
|
||||||
|
|||||||
@@ -1,171 +1,197 @@
|
|||||||
import { Env } from '../types';
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
|
// Rate limiting service.
|
||||||
|
// - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence).
|
||||||
|
// - API budgets: Cloudflare Cache API (high volume, auto-expires, zero D1 writes).
|
||||||
|
|
||||||
// Rate limit configuration
|
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
// Login attempt limits
|
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
|
||||||
LOGIN_MAX_ATTEMPTS: 15, // Max failed login attempts
|
LOGIN_LOCKOUT_MINUTES: LIMITS.rateLimit.loginLockoutMinutes,
|
||||||
LOGIN_LOCKOUT_MINUTES: 5, // Lockout duration after max attempts
|
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
|
||||||
|
|
||||||
// API rate limits (per minute)
|
|
||||||
API_REQUESTS_PER_MINUTE: 300, // General API rate limit
|
|
||||||
API_WINDOW_SECONDS: 60, // Rate limit window
|
|
||||||
};
|
|
||||||
|
|
||||||
// KV key prefixes
|
|
||||||
const KEYS = {
|
|
||||||
LOGIN_ATTEMPTS: 'ratelimit:login:',
|
|
||||||
API_RATE: 'ratelimit:api:',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class RateLimitService {
|
export class RateLimitService {
|
||||||
constructor(private kv: KVNamespace) {}
|
private static loginIpTableReady = false;
|
||||||
|
private static lastLoginIpCleanupAt = 0;
|
||||||
|
|
||||||
/**
|
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability;
|
||||||
* Check and record login attempt
|
private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
|
||||||
* Returns { allowed: boolean, remainingAttempts: number, retryAfterSeconds?: number }
|
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
|
||||||
*/
|
|
||||||
async checkLoginAttempt(email: string): Promise<{
|
constructor(private db: D1Database) {}
|
||||||
|
|
||||||
|
private shouldRunCleanup(lastRunAt: number, intervalMs: number): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastRunAt < intervalMs) return false;
|
||||||
|
return Math.random() < RateLimitService.PERIODIC_CLEANUP_PROBABILITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async maybeCleanupLoginAttemptsIp(nowMs: number): Promise<void> {
|
||||||
|
if (!this.shouldRunCleanup(RateLimitService.lastLoginIpCleanupAt, RateLimitService.LOGIN_IP_CLEANUP_INTERVAL_MS)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cutoff = nowMs - RateLimitService.LOGIN_IP_RETENTION_MS;
|
||||||
|
await this.db
|
||||||
|
.prepare(
|
||||||
|
'DELETE FROM login_attempts_ip WHERE updated_at < ? AND (locked_until IS NULL OR locked_until < ?)'
|
||||||
|
)
|
||||||
|
.bind(cutoff, nowMs)
|
||||||
|
.run();
|
||||||
|
RateLimitService.lastLoginIpCleanupAt = nowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureLoginIpTable(): Promise<void> {
|
||||||
|
if (RateLimitService.loginIpTableReady) return;
|
||||||
|
|
||||||
|
await this.db
|
||||||
|
.prepare(
|
||||||
|
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
|
||||||
|
'ip TEXT PRIMARY KEY, ' +
|
||||||
|
'attempts INTEGER NOT NULL, ' +
|
||||||
|
'locked_until INTEGER, ' +
|
||||||
|
'updated_at INTEGER NOT NULL' +
|
||||||
|
')'
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
RateLimitService.loginIpTableReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkLoginAttempt(ip: string): Promise<{
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
remainingAttempts: number;
|
remainingAttempts: number;
|
||||||
retryAfterSeconds?: number;
|
retryAfterSeconds?: number;
|
||||||
}> {
|
}> {
|
||||||
const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`;
|
await this.ensureLoginIpTable();
|
||||||
const data = await this.kv.get(key);
|
|
||||||
|
|
||||||
if (!data) {
|
const key = ip.trim() || 'unknown';
|
||||||
|
const now = Date.now();
|
||||||
|
await this.maybeCleanupLoginAttemptsIp(now);
|
||||||
|
|
||||||
|
const row = await this.db
|
||||||
|
.prepare('SELECT attempts, locked_until FROM login_attempts_ip WHERE ip = ?')
|
||||||
|
.bind(key)
|
||||||
|
.first<{ attempts: number; locked_until: number | null }>();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
|
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
|
||||||
}
|
}
|
||||||
|
|
||||||
const record: { attempts: number; lockedUntil?: number } = JSON.parse(data);
|
if (row.locked_until && row.locked_until > now) {
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
// Check if currently locked out
|
|
||||||
if (record.lockedUntil && record.lockedUntil > now) {
|
|
||||||
const retryAfterSeconds = Math.ceil((record.lockedUntil - now) / 1000);
|
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
remainingAttempts: 0,
|
remainingAttempts: 0,
|
||||||
retryAfterSeconds,
|
retryAfterSeconds: Math.ceil((row.locked_until - now) / 1000),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// If lockout expired, reset
|
if (row.locked_until && row.locked_until <= now) {
|
||||||
if (record.lockedUntil && record.lockedUntil <= now) {
|
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
|
||||||
await this.kv.delete(key);
|
|
||||||
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
|
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
|
||||||
}
|
}
|
||||||
|
|
||||||
const remainingAttempts = CONFIG.LOGIN_MAX_ATTEMPTS - record.attempts;
|
const remainingAttempts = Math.max(0, CONFIG.LOGIN_MAX_ATTEMPTS - (row.attempts || 0));
|
||||||
return { allowed: true, remainingAttempts };
|
return { allowed: true, remainingAttempts };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async recordFailedLogin(ip: string): Promise<{ locked: boolean; retryAfterSeconds?: number }> {
|
||||||
* Record a failed login attempt
|
await this.ensureLoginIpTable();
|
||||||
*/
|
|
||||||
async recordFailedLogin(email: string): Promise<{
|
|
||||||
locked: boolean;
|
|
||||||
retryAfterSeconds?: number;
|
|
||||||
}> {
|
|
||||||
const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`;
|
|
||||||
const data = await this.kv.get(key);
|
|
||||||
|
|
||||||
let record: { attempts: number; lockedUntil?: number };
|
const key = ip.trim() || 'unknown';
|
||||||
|
const now = Date.now();
|
||||||
|
await this.maybeCleanupLoginAttemptsIp(now);
|
||||||
|
|
||||||
if (data) {
|
// D1 in Workers forbids raw BEGIN/COMMIT statements.
|
||||||
record = JSON.parse(data);
|
// Use a single atomic UPSERT to increment attempts.
|
||||||
record.attempts += 1;
|
// This is concurrency-safe because the row is keyed by IP.
|
||||||
} else {
|
await this.db
|
||||||
record = { attempts: 1 };
|
.prepare(
|
||||||
|
'INSERT INTO login_attempts_ip(ip, attempts, locked_until, updated_at) VALUES(?, 1, NULL, ?) ' +
|
||||||
|
'ON CONFLICT(ip) DO UPDATE SET attempts = attempts + 1, updated_at = excluded.updated_at'
|
||||||
|
)
|
||||||
|
.bind(key, now)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const row = await this.db
|
||||||
|
.prepare('SELECT attempts FROM login_attempts_ip WHERE ip = ?')
|
||||||
|
.bind(key)
|
||||||
|
.first<{ attempts: number }>();
|
||||||
|
|
||||||
|
const attempts = row?.attempts || 1;
|
||||||
|
if (attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) {
|
||||||
|
const lockedUntil = now + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000;
|
||||||
|
await this.db
|
||||||
|
.prepare('UPDATE login_attempts_ip SET locked_until = ?, updated_at = ? WHERE ip = ?')
|
||||||
|
.bind(lockedUntil, now, key)
|
||||||
|
.run();
|
||||||
|
return { locked: true, retryAfterSeconds: CONFIG.LOGIN_LOCKOUT_MINUTES * 60 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if should lock out
|
|
||||||
if (record.attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) {
|
|
||||||
record.lockedUntil = Date.now() + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000;
|
|
||||||
await this.kv.put(key, JSON.stringify(record), {
|
|
||||||
expirationTtl: CONFIG.LOGIN_LOCKOUT_MINUTES * 60 + 60, // Extra minute buffer
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
locked: true,
|
|
||||||
retryAfterSeconds: CONFIG.LOGIN_LOCKOUT_MINUTES * 60,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store with expiration (auto-reset after lockout period even without lockout)
|
|
||||||
await this.kv.put(key, JSON.stringify(record), {
|
|
||||||
expirationTtl: CONFIG.LOGIN_LOCKOUT_MINUTES * 60,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { locked: false };
|
return { locked: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async clearLoginAttempts(ip: string): Promise<void> {
|
||||||
* Clear login attempts on successful login
|
await this.ensureLoginIpTable();
|
||||||
*/
|
const key = ip.trim() || 'unknown';
|
||||||
async clearLoginAttempts(email: string): Promise<void> {
|
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
|
||||||
const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`;
|
|
||||||
await this.kv.delete(key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Cache API-backed fixed-window rate limiter.
|
||||||
* Check API rate limit for a user or IP
|
// Uses Cloudflare edge cache instead of D1 — zero database writes, auto-expires via TTL.
|
||||||
* Returns { allowed: boolean, remaining: number, retryAfterSeconds?: number }
|
// Per-colo isolation is acceptable (matches Cloudflare's own rate limiting behaviour).
|
||||||
*/
|
private async consumeFixedWindowBudget(
|
||||||
async checkApiRateLimit(identifier: string): Promise<{
|
identifier: string,
|
||||||
allowed: boolean;
|
maxRequests: number,
|
||||||
remaining: number;
|
windowSeconds: number
|
||||||
retryAfterSeconds?: number;
|
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||||
}> {
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const windowStart = nowSec - (nowSec % windowSeconds);
|
||||||
const windowStart = now - (now % CONFIG.API_WINDOW_SECONDS);
|
const windowEnd = windowStart + windowSeconds;
|
||||||
const key = `${KEYS.API_RATE}${identifier}:${windowStart}`;
|
const ttl = Math.max(1, windowEnd - nowSec);
|
||||||
|
|
||||||
const countStr = await this.kv.get(key);
|
const cache = await caches.open('rate-limit');
|
||||||
const count = countStr ? parseInt(countStr, 10) : 0;
|
const cacheKey = new Request(`https://rl/${identifier}/${windowStart}`);
|
||||||
|
|
||||||
if (count >= CONFIG.API_REQUESTS_PER_MINUTE) {
|
const cached = await cache.match(cacheKey);
|
||||||
const retryAfterSeconds = CONFIG.API_WINDOW_SECONDS - (now % CONFIG.API_WINDOW_SECONDS);
|
let count = 0;
|
||||||
return {
|
if (cached) {
|
||||||
allowed: false,
|
count = parseInt(await cached.text(), 10) || 0;
|
||||||
remaining: 0,
|
|
||||||
retryAfterSeconds,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
if (count >= maxRequests) {
|
||||||
allowed: true,
|
return { allowed: false, remaining: 0, retryAfterSeconds: ttl };
|
||||||
remaining: CONFIG.API_REQUESTS_PER_MINUTE - count,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
count++;
|
||||||
* Increment API request count
|
await cache.put(
|
||||||
*/
|
cacheKey,
|
||||||
async incrementApiCount(identifier: string): Promise<void> {
|
new Response(String(count), {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
headers: { 'Cache-Control': `public, max-age=${ttl}` },
|
||||||
const windowStart = now - (now % CONFIG.API_WINDOW_SECONDS);
|
})
|
||||||
const key = `${KEYS.API_RATE}${identifier}:${windowStart}`;
|
);
|
||||||
|
|
||||||
const countStr = await this.kv.get(key);
|
return { allowed: true, remaining: Math.max(0, maxRequests - count) };
|
||||||
const count = countStr ? parseInt(countStr, 10) : 0;
|
}
|
||||||
|
|
||||||
await this.kv.put(key, (count + 1).toString(), {
|
// General-purpose fixed-window budget.
|
||||||
expirationTtl: CONFIG.API_WINDOW_SECONDS + 10, // Slight buffer
|
// Callers supply an identifier (must be unique per rate-limit category) and the
|
||||||
});
|
// per-window maximum. This single method replaces all previous specialised
|
||||||
|
// budget helpers (write / sync / knownDevice / publicSend).
|
||||||
|
async consumeBudget(
|
||||||
|
identifier: string,
|
||||||
|
maxRequests: number
|
||||||
|
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||||
|
return this.consumeFixedWindowBudget(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get client identifier from request (IP or CF-Connecting-IP)
|
|
||||||
*/
|
|
||||||
export function getClientIdentifier(request: Request): string {
|
export function getClientIdentifier(request: Request): string {
|
||||||
// Cloudflare provides the real client IP
|
|
||||||
const cfIp = request.headers.get('CF-Connecting-IP');
|
const cfIp = request.headers.get('CF-Connecting-IP');
|
||||||
if (cfIp) return cfIp;
|
if (cfIp) return cfIp;
|
||||||
|
|
||||||
// Fallback for local development
|
|
||||||
const forwardedFor = request.headers.get('X-Forwarded-For');
|
const forwardedFor = request.headers.get('X-Forwarded-For');
|
||||||
if (forwardedFor) return forwardedFor.split(',')[0].trim();
|
if (forwardedFor) return forwardedFor.split(',')[0].trim();
|
||||||
|
|
||||||
// Last resort
|
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
// Environment bindings
|
// Environment bindings
|
||||||
export interface Env {
|
export interface Env {
|
||||||
VAULT: KVNamespace;
|
DB: D1Database;
|
||||||
ATTACHMENTS: R2Bucket;
|
ATTACHMENTS: R2Bucket;
|
||||||
JWT_SECRET: string;
|
JWT_SECRET: string;
|
||||||
|
TOTP_SECRET?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserRole = 'admin' | 'user';
|
||||||
|
export type UserStatus = 'active' | 'banned';
|
||||||
|
|
||||||
|
// Sample JWT secret used by `.dev.vars.example`.
|
||||||
|
// 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';
|
||||||
|
|
||||||
// Attachment model
|
// Attachment model
|
||||||
export interface Attachment {
|
export interface Attachment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,7 +27,7 @@ export interface Attachment {
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string | null;
|
||||||
masterPasswordHash: string;
|
masterPasswordHash: string;
|
||||||
key: string;
|
key: string;
|
||||||
privateKey: string | null;
|
privateKey: string | null;
|
||||||
@@ -29,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,
|
||||||
@@ -115,7 +147,7 @@ export interface Cipher {
|
|||||||
userId: string;
|
userId: string;
|
||||||
type: CipherType;
|
type: CipherType;
|
||||||
folderId: string | null;
|
folderId: string | null;
|
||||||
name: string;
|
name: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
favorite: boolean;
|
favorite: boolean;
|
||||||
login: CipherLogin | null;
|
login: CipherLogin | null;
|
||||||
@@ -130,6 +162,8 @@ export interface Cipher {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
|
/** Allow unknown fields from Bitwarden clients to be stored and passed through transparently. */
|
||||||
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Folder model
|
// Folder model
|
||||||
@@ -141,11 +175,82 @@ export interface Folder {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Device {
|
||||||
|
userId: string;
|
||||||
|
deviceIdentifier: string;
|
||||||
|
name: string;
|
||||||
|
type: number;
|
||||||
|
createdAt: 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
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string | null;
|
||||||
email_verified: boolean; // required by mobile client
|
email_verified: boolean; // required by mobile client
|
||||||
amr: string[]; // authentication methods reference - required by mobile client
|
amr: string[]; // authentication methods reference - required by mobile client
|
||||||
sstamp: string; // security stamp - invalidates token when user changes password
|
sstamp: string; // security stamp - invalidates token when user changes password
|
||||||
@@ -166,13 +271,16 @@ export interface MasterPasswordUnlockKdf {
|
|||||||
export interface MasterPasswordUnlock {
|
export interface MasterPasswordUnlock {
|
||||||
Kdf: MasterPasswordUnlockKdf;
|
Kdf: MasterPasswordUnlockKdf;
|
||||||
MasterKeyEncryptedUserKey: string;
|
MasterKeyEncryptedUserKey: string;
|
||||||
|
MasterKeyWrappedUserKey: string;
|
||||||
Salt: string;
|
Salt: string;
|
||||||
|
Object: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserDecryptionOptions {
|
export interface UserDecryptionOptions {
|
||||||
HasMasterPassword: boolean;
|
HasMasterPassword: boolean;
|
||||||
Object: string;
|
Object: string;
|
||||||
MasterPasswordUnlock?: MasterPasswordUnlock;
|
// Bitwarden Android 2026.1.x expects this to exist; missing it breaks unlock when the vault is empty.
|
||||||
|
MasterPasswordUnlock: MasterPasswordUnlock;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API Response types
|
// API Response types
|
||||||
@@ -181,6 +289,7 @@ export interface TokenResponse {
|
|||||||
expires_in: number;
|
expires_in: number;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
|
TwoFactorToken?: string;
|
||||||
Key: string;
|
Key: string;
|
||||||
PrivateKey: string | null;
|
PrivateKey: string | null;
|
||||||
Kdf: number;
|
Kdf: number;
|
||||||
@@ -196,7 +305,7 @@ export interface TokenResponse {
|
|||||||
|
|
||||||
export interface ProfileResponse {
|
export interface ProfileResponse {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
emailVerified: boolean;
|
emailVerified: boolean;
|
||||||
premium: boolean;
|
premium: boolean;
|
||||||
@@ -215,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +334,7 @@ export interface CipherResponse {
|
|||||||
organizationId: string | null;
|
organizationId: string | null;
|
||||||
folderId: string | null;
|
folderId: string | null;
|
||||||
type: number;
|
type: number;
|
||||||
name: string;
|
name: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
favorite: boolean;
|
favorite: boolean;
|
||||||
login: CipherLogin | null;
|
login: CipherLogin | null;
|
||||||
@@ -247,6 +358,8 @@ export interface CipherResponse {
|
|||||||
attachments: any[] | null;
|
attachments: any[] | null;
|
||||||
key: string | null;
|
key: string | null;
|
||||||
encryptedFor: string | null;
|
encryptedFor: string | null;
|
||||||
|
/** Allow unknown fields to pass through to clients transparently. */
|
||||||
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CipherPermissions {
|
export interface CipherPermissions {
|
||||||
@@ -268,7 +381,22 @@ export interface SyncResponse {
|
|||||||
ciphers: CipherResponse[];
|
ciphers: CipherResponse[];
|
||||||
domains: any;
|
domains: any;
|
||||||
policies: any[];
|
policies: any[];
|
||||||
sends: any[];
|
sends: SendResponse[];
|
||||||
userDecryption: any | null;
|
// PascalCase for desktop/browser clients
|
||||||
|
UserDecryptionOptions: UserDecryptionOptions | null;
|
||||||
|
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
||||||
|
userDecryption: {
|
||||||
|
masterPasswordUnlock: {
|
||||||
|
kdf: {
|
||||||
|
kdfType: number;
|
||||||
|
iterations: number;
|
||||||
|
memory: number | null;
|
||||||
|
parallelism: number | null;
|
||||||
|
};
|
||||||
|
masterKeyWrappedUserKey: string;
|
||||||
|
masterKeyEncryptedUserKey: string;
|
||||||
|
salt: string;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
object: string;
|
object: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
const DEFAULT_DEVICE_NAME = 'Unknown device';
|
||||||
|
const DEFAULT_DEVICE_TYPE = 14;
|
||||||
|
|
||||||
|
function decodeBase64UrlUtf8(value: string): string | null {
|
||||||
|
try {
|
||||||
|
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padding = normalized.length % 4;
|
||||||
|
const padded = padding === 0 ? normalized : normalized + '='.repeat(4 - padding);
|
||||||
|
const binary = atob(padded);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDeviceIdentifier(value: string | undefined | null): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
if (!normalized) return null;
|
||||||
|
return normalized.slice(0, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDeviceName(value: string | undefined | null): string {
|
||||||
|
const normalized = String(value || '').trim();
|
||||||
|
if (!normalized) return DEFAULT_DEVICE_NAME;
|
||||||
|
return normalized.slice(0, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDeviceType(value: string | number | undefined | null): number {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return Math.max(0, Math.floor(value));
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(String(value || ''), 10);
|
||||||
|
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
||||||
|
return DEFAULT_DEVICE_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthRequestDeviceInfo {
|
||||||
|
deviceIdentifier: string | null;
|
||||||
|
deviceName: string;
|
||||||
|
deviceType: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAuthRequestDeviceInfo(
|
||||||
|
body: Record<string, string | undefined>,
|
||||||
|
request: Request
|
||||||
|
): AuthRequestDeviceInfo {
|
||||||
|
const bodyIdentifier = body.deviceIdentifier || body.device_identifier;
|
||||||
|
const headerIdentifier = request.headers.get('X-Device-Identifier') || undefined;
|
||||||
|
const bodyName = body.deviceName || body.device_name;
|
||||||
|
const headerName = request.headers.get('X-Device-Name') || undefined;
|
||||||
|
const bodyType = body.deviceType || body.device_type;
|
||||||
|
const headerType = request.headers.get('Device-Type') || undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceIdentifier: normalizeDeviceIdentifier(bodyIdentifier || headerIdentifier),
|
||||||
|
deviceName: normalizeDeviceName(bodyName || headerName),
|
||||||
|
deviceType: parseDeviceType(bodyType || headerType),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readKnownDeviceProbe(request: Request): { email: string | null; deviceIdentifier: string | null } {
|
||||||
|
const encodedEmail = request.headers.get('X-Request-Email') || '';
|
||||||
|
const decodedEmail = decodeBase64UrlUtf8(encodedEmail);
|
||||||
|
const fallbackRawEmail = request.headers.get('X-Request-Email');
|
||||||
|
const email = (decodedEmail || fallbackRawEmail || '').trim().toLowerCase() || null;
|
||||||
|
const deviceIdentifier = normalizeDeviceIdentifier(request.headers.get('X-Device-Identifier'));
|
||||||
|
return { email, deviceIdentifier };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { JWTPayload } from '../types';
|
import { JWTPayload } from '../types';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
// Base64 URL encode
|
// Base64 URL encode
|
||||||
function base64UrlEncode(data: Uint8Array): string {
|
function base64UrlEncode(data: Uint8Array): string {
|
||||||
@@ -19,7 +20,7 @@ function base64UrlDecode(str: string): Uint8Array {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create JWT
|
// Create JWT
|
||||||
export async function createJWT(payload: Omit<JWTPayload, 'iat' | 'exp' | 'iss' | 'premium' | 'email_verified' | 'amr'>, secret: string, expiresIn: number = 7200): Promise<string> {
|
export async function createJWT(payload: Omit<JWTPayload, 'iat' | 'exp' | 'iss' | 'premium' | 'email_verified' | 'amr'>, secret: string, expiresIn: number = LIMITS.auth.accessTokenTtlSeconds): Promise<string> {
|
||||||
const header = { alg: 'HS256', typ: 'JWT' };
|
const header = { alg: 'HS256', typ: 'JWT' };
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
@@ -90,7 +91,7 @@ export async function verifyJWT(token: string, secret: string): Promise<JWTPaylo
|
|||||||
|
|
||||||
// Create refresh token (simple random string)
|
// Create refresh token (simple random string)
|
||||||
export function createRefreshToken(): string {
|
export function createRefreshToken(): string {
|
||||||
const bytes = new Uint8Array(32);
|
const bytes = new Uint8Array(LIMITS.auth.refreshTokenRandomBytes);
|
||||||
crypto.getRandomValues(bytes);
|
crypto.getRandomValues(bytes);
|
||||||
return base64UrlEncode(bytes);
|
return base64UrlEncode(bytes);
|
||||||
}
|
}
|
||||||
@@ -99,6 +100,7 @@ export function createRefreshToken(): string {
|
|||||||
export interface FileDownloadClaims {
|
export interface FileDownloadClaims {
|
||||||
cipherId: string;
|
cipherId: string;
|
||||||
attachmentId: string;
|
attachmentId: string;
|
||||||
|
jti: string;
|
||||||
exp: number;
|
exp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +116,8 @@ export async function createFileDownloadToken(
|
|||||||
const payload: FileDownloadClaims = {
|
const payload: FileDownloadClaims = {
|
||||||
cipherId,
|
cipherId,
|
||||||
attachmentId,
|
attachmentId,
|
||||||
exp: now + 300, // 5 minutes
|
jti: createRefreshToken(),
|
||||||
|
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds, // 5 minutes
|
||||||
};
|
};
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
@@ -174,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
|
const MAX_PAGE_SIZE = LIMITS.pagination.maxPageSize;
|
||||||
|
|
||||||
|
export interface PaginationRequest {
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePagination(url: URL): PaginationRequest | null {
|
||||||
|
const pageSizeRaw = url.searchParams.get('pageSize');
|
||||||
|
const continuationToken = url.searchParams.get('continuationToken');
|
||||||
|
if (!pageSizeRaw && !continuationToken) return null;
|
||||||
|
|
||||||
|
const pageSize = pageSizeRaw ? Number(pageSizeRaw) : LIMITS.pagination.defaultPageSize;
|
||||||
|
if (!Number.isInteger(pageSize) || pageSize <= 0) return null;
|
||||||
|
|
||||||
|
const limit = Math.min(pageSize, MAX_PAGE_SIZE);
|
||||||
|
const offset = decodeContinuationToken(continuationToken);
|
||||||
|
|
||||||
|
return { limit, offset };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeContinuationToken(offset: number): string {
|
||||||
|
return btoa(String(offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeContinuationToken(token: string | null): number {
|
||||||
|
if (!token) return 0;
|
||||||
|
try {
|
||||||
|
const decoded = atob(token);
|
||||||
|
const offset = Number(decoded);
|
||||||
|
if (!Number.isInteger(offset) || offset < 0) return 0;
|
||||||
|
return offset;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,10 +1,72 @@
|
|||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
|
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
|
||||||
|
const CORS_HEADERS = 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version, X-Request-Email, X-Device-Identifier, X-Device-Name';
|
||||||
|
|
||||||
|
function isTrustedClientOrigin(origin: string): boolean {
|
||||||
|
// Official browser extension / desktop-webview common origins.
|
||||||
|
if (origin.startsWith('chrome-extension://')) return true;
|
||||||
|
if (origin.startsWith('moz-extension://')) return true;
|
||||||
|
if (origin.startsWith('safari-web-extension://')) return true;
|
||||||
|
if (origin.startsWith('app://')) return true;
|
||||||
|
if (origin.startsWith('capacitor://')) return true;
|
||||||
|
if (origin.startsWith('ionic://')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllowedOrigin(request: Request): string | null {
|
||||||
|
const origin = request.headers.get('Origin');
|
||||||
|
if (!origin) return null;
|
||||||
|
|
||||||
|
const targetOrigin = new URL(request.url).origin;
|
||||||
|
if (origin === targetOrigin) return origin;
|
||||||
|
if (isTrustedClientOrigin(origin)) return origin;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCorsHeaders(request: Request): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Access-Control-Allow-Methods': CORS_METHODS,
|
||||||
|
'Access-Control-Allow-Headers': CORS_HEADERS,
|
||||||
|
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
||||||
|
};
|
||||||
|
|
||||||
|
const allowedOrigin = getAllowedOrigin(request);
|
||||||
|
if (allowedOrigin) {
|
||||||
|
headers['Access-Control-Allow-Origin'] = allowedOrigin;
|
||||||
|
headers['Vary'] = 'Origin';
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyCors(
|
||||||
|
request: Request,
|
||||||
|
response: Response
|
||||||
|
): Response {
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
const corsHeaders = buildCorsHeaders(request);
|
||||||
|
for (const [k, v] of Object.entries(corsHeaders)) {
|
||||||
|
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, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// JSON response helper
|
// JSON response helper
|
||||||
export function jsonResponse(data: any, status: number = 200, headers: Record<string, string> = {}): Response {
|
export function jsonResponse(data: any, status: number = 200, headers: Record<string, string> = {}): Response {
|
||||||
return new Response(JSON.stringify(data), {
|
return new Response(JSON.stringify(data), {
|
||||||
status,
|
status,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...getCorsHeaders(),
|
|
||||||
...headers,
|
...headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -40,21 +102,19 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// CORS headers
|
// Handle CORS preflight
|
||||||
export function getCorsHeaders(): Record<string, string> {
|
export function handleCors(request: Request): Response {
|
||||||
return {
|
const origin = request.headers.get('Origin');
|
||||||
'Access-Control-Allow-Origin': '*',
|
if (origin) {
|
||||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
const allowedOrigin = getAllowedOrigin(request);
|
||||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version',
|
if (!allowedOrigin) {
|
||||||
'Access-Control-Max-Age': '86400',
|
return new Response(null, { status: 403 });
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle CORS preflight
|
|
||||||
export function handleCors(): Response {
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 204,
|
status: 204,
|
||||||
headers: getCorsHeaders(),
|
headers: buildCorsHeaders(request),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +124,6 @@ export function htmlResponse(html: string, status: number = 200): Response {
|
|||||||
status,
|
status,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/html; charset=utf-8',
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
...getCorsHeaders(),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
const TOTP_STEP_SECONDS = 30;
|
||||||
|
const TOTP_DIGITS = 6;
|
||||||
|
const TOTP_WINDOW = 1; // allow previous/current/next step for small clock drift
|
||||||
|
|
||||||
|
function normalizeBase32(input: string): string {
|
||||||
|
return input.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function base32Decode(input: string): Uint8Array | null {
|
||||||
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
const normalized = normalizeBase32(input);
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
let bits = 0;
|
||||||
|
let value = 0;
|
||||||
|
const output: number[] = [];
|
||||||
|
|
||||||
|
for (const char of normalized) {
|
||||||
|
const idx = alphabet.indexOf(char);
|
||||||
|
if (idx === -1) return null;
|
||||||
|
value = (value << 5) | idx;
|
||||||
|
bits += 5;
|
||||||
|
if (bits >= 8) {
|
||||||
|
bits -= 8;
|
||||||
|
output.push((value >> bits) & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.length > 0 ? new Uint8Array(output) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hotp(secret: Uint8Array, counter: number): Promise<string> {
|
||||||
|
const counterBytes = new Uint8Array(8);
|
||||||
|
let c = counter;
|
||||||
|
for (let i = 7; i >= 0; i--) {
|
||||||
|
counterBytes[i] = c & 0xff;
|
||||||
|
c = Math.floor(c / 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
secret,
|
||||||
|
{ name: 'HMAC', hash: 'SHA-1' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
const signature = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBytes));
|
||||||
|
const offset = signature[signature.length - 1] & 0x0f;
|
||||||
|
const binary =
|
||||||
|
((signature[offset] & 0x7f) << 24) |
|
||||||
|
((signature[offset + 1] & 0xff) << 16) |
|
||||||
|
((signature[offset + 2] & 0xff) << 8) |
|
||||||
|
(signature[offset + 3] & 0xff);
|
||||||
|
|
||||||
|
const otp = binary % (10 ** TOTP_DIGITS);
|
||||||
|
return otp.toString().padStart(TOTP_DIGITS, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToken(token: string): string {
|
||||||
|
return token.replace(/\s+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> {
|
||||||
|
const token = normalizeToken(tokenRaw);
|
||||||
|
if (!/^\d{6}$/.test(token)) return false;
|
||||||
|
|
||||||
|
const secret = base32Decode(secretRaw);
|
||||||
|
if (!secret) return false;
|
||||||
|
|
||||||
|
const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS);
|
||||||
|
let matched = false;
|
||||||
|
for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) {
|
||||||
|
const expected = await hotp(secret, currentCounter + delta);
|
||||||
|
// 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];
|
||||||
|
}
|
||||||
|
if (diff === 0) matched = true;
|
||||||
|
}
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTotpEnabled(secretRaw: string | undefined | null): boolean {
|
||||||
|
return Boolean(secretRaw && normalizeBase32(secretRaw).length > 0);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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')!
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
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/*"] }
|
||||||
|
|
||||||
# KV Namespace for storing vault data
|
[build]
|
||||||
[[kv_namespaces]]
|
command = "npm run build"
|
||||||
binding = "VAULT"
|
|
||||||
id = "placeholder"
|
# D1 Database for storing vault data
|
||||||
|
[[d1_databases]]
|
||||||
|
binding = "DB"
|
||||||
|
database_name = "nodewarden-db"
|
||||||
|
|
||||||
# R2 Bucket for storing attachments
|
# R2 Bucket for storing attachments
|
||||||
[[r2_buckets]]
|
[[r2_buckets]]
|
||||||
|
|||||||