21 Commits

Author SHA1 Message Date
shuaiplus d1e6ec8b8d fix: update version to 1.0.0 in package.json and package-lock.json 2026-02-19 22:14:44 +08:00
shuaiplus 3e56d05283 chore: remove temporary subproject references for cleanup 2026-02-19 21:39:12 +08:00
shuaiplus 870149c771 style: enhance register page styling with grid background and button effects 2026-02-19 21:13:59 +08:00
shuaiplus 9771df8777 fix: update bitwarden server version to 2026.1.0 2026-02-19 19:58:33 +08:00
shuaiplus 0be3b91dd7 Refactor code structure for improved readability and maintainability 2026-02-19 18:57:23 +08:00
shuaiplus 645a2f8e95 docs: add Star History section to README files 2026-02-19 16:08:08 +08:00
shuaiplus f63b5d6cf4 feat(storage): add method to retrieve attachments by user ID for improved data handling 2026-02-19 02:27:56 +08:00
shuaiplus 081dc64093 fix(storage): optimize attachment retrieval by batching cipher IDs to improve performance 2026-02-19 01:42:55 +08:00
shuaiplus 3ec1ecf464 docs: update feature comparison table in README files for clarity and consistency 2026-02-18 21:29:51 +08:00
shuaiplus b6d4113e21 feat(pagination): add pagination utility functions for handling page size and continuation tokens
- Introduced `PaginationRequest` interface to define pagination parameters.
- Implemented `parsePagination` function to extract and validate pagination parameters from a URL.
- Added `encodeContinuationToken` and `decodeContinuationToken` functions for managing continuation tokens.
- Ensured that pagination respects maximum page size limits defined in configuration.
2026-02-18 20:59:46 +08:00
shuaiplus c53819e178 fix: enhance attachment handling and folder deletion logic; improve error responses and rate limiting 2026-02-18 03:06:50 +08:00
shuaiplus fff2b149e9 fix: enhance cipher handling to support unknown fields and improve database binding 2026-02-17 22:20:01 +08:00
shuaiplus 50ee2e6b64 fix: adjust layout and improve JWT_SECRET instructions on registration page 2026-02-15 03:10:59 +08:00
shuaiplus 309cd98edc fix: update README to clarify deployment steps and features 2026-02-15 02:56:31 +08:00
shuaiplus 3fff6c0277 Refactor code structure for improved readability and maintainability 2026-02-15 02:45:57 +08:00
shuaiplus ced0a183b3 fix: remove placeholder database_id from D1 database configuration 2026-02-15 02:25:26 +08:00
shuaiplus 4939df7fa2 fix: correct link to English README in Chinese version 2026-02-15 02:23:01 +08:00
shuaiplus 6c3fbbe78c Refactor code structure for improved readability and maintainability 2026-02-15 02:21:55 +08:00
shuaiplus 719024d0fd feat: enhance rate limiting by tracking login attempts per client IP and refining API rate limits for write operations 2026-02-14 20:56:34 +08:00
shuaiplus ff7b44e501 feat: update setup pages and router to enhance UI and favicon handling 2026-02-14 01:03:40 +08:00
shuaiplus 4772c17e44 feat: enhance user registration and authentication flow, improve attachment handling, and strengthen security measures 2026-02-14 00:34:08 +08:00
30 changed files with 2749 additions and 1570 deletions
+28
View File
@@ -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
+1
View File
@@ -6,6 +6,7 @@ node_modules/
.dev.vars .dev.vars
wrangler.my.toml wrangler.my.toml
RELEASE_NOTES.md RELEASE_NOTES.md
tests/selfcheck.ts
# Build output # Build output
dist/ dist/
+62 -81
View File
@@ -1,69 +1,56 @@
# NodeWarden # NodeWarden
中文文档[`README_ZH.md`](./README_ZH.md) English[`README_EN.md`](./README_EN.md)
A **Bitwarden-compatible** server that runs on **Cloudflare Workers**. 运行在 **Cloudflare Workers** 上的 **Bitwarden 第三方服务端**
- Simple deploy (no VPS) > **免责声明**
- Focused feature set > 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份您的密码库。
- Low maintenance > 本项目与 Bitwarden 官方无关,请勿向 Bitwarden 官方反馈问题。
---
## 与 Bitwarden 官方服务端能力对比
> Disclaimer | 能力项 | Bitwarden | NodeWarden | 说明 |
> - This project is **not affiliated** with Bitwarden. |---|---|---|---|
> - Use at your own risk. Keep regular backups of your vault. | 单用户保管库(登录/笔记/卡片/身份) | ✅ | ✅ | 基于Cloudflare D1 |
| 文件夹 / 收藏 | ✅ | ✅ | 常用管理能力可用 |
| 全量同步 `/api/sync` | ✅ | ✅ | 已做兼容与性能优化 |
| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 |
| 导入功能 | ✅ | ✅ | 覆盖常见导入路径 |
| 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` |
| 多用户 | ✅ | ❌ | NodeWarden 定位单用户 |
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 |
| 完整 2FATOTP/WebAuthn/Duo/Email | ✅ | ❌ | 没必要实现 |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
| Send | ✅ | ❌ | 基本没人用 |
| 紧急访问 | ✅ | ❌ | 没必要实现 |
| 管理后台 / 计费订阅 | ✅ | ❌ | 纯免费 |
| 推送通知完整链路 | ✅ | ❌ | 没必要实现 |
## 测试情况:
- ✅ Windows 客户端(v2026.1.0
- ✅ 手机 Appv2026.1.0
- ✅ 浏览器扩展(v2026.1.0
- ⬜ macOS 客户端(未测试)
- ⬜ Linux 客户端(未测试)
---
# 快速开始
### 一键部署
**部署步骤:**
1. 先在右上角fork此项目(若后续不需要更新,可不fork)
2. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
3. 打开部署后生成的链接,并根据网页提示完成后续操作。
--- ---
## 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 cant 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
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](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. D1 database 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
@@ -72,37 +59,31 @@ npm run dev
--- ---
## Tech stack ## 常见问题
- **Runtime**: Cloudflare Workers **Q: 如何备份数据?**
- **Data storage**: Cloudflare D1 (SQLite) A: 在客户端中选择「导出密码库」,保存 JSON 文件。
- **File storage**: Cloudflare R2
- **Language**: TypeScript **Q: 忘记主密码怎么办?**
- **Crypto**: Client-side AES-256-CBC, JWT uses HS256 A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。
**Q: 可以多人使用吗?**
A: 不建议。本项目为单用户设计,多人使用请选择 Vaultwarden。
--- ---
## 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 cant 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
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
+95
View File
@@ -0,0 +1,95 @@
# NodeWarden
中文文档:[`README.md`](./README.md)
A **Bitwarden-compatible** server that runs on **Cloudflare Workers**.
> Disclaimer
> - This project is for learning and communication only.
> - We are not responsible for any data loss. Regular vault backups are strongly recommended.
> - This project is not affiliated with Bitwarden. Please do not report issues to the official Bitwarden team.
---
## Feature Comparison Table (vs Official Bitwarden Server)
| Capability | Bitwarden | NodeWarden | Notes |
|---|---|---|---|
| Single-user vault (logins/notes/cards/identities) | ✅ | ✅ | Core vault model supported |
| Folders / Favorites | ✅ | ✅ | Common vault organization supported |
| Full sync `/api/sync` | ✅ | ✅ | Compatibility-focused implementation |
| Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 |
| Import flow (common clients) | ✅ | ✅ | Common import paths covered |
| Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` |
| Multi-user | ✅ | ❌ | NodeWarden is single-user by design |
| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement |
| Full 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ❌ | Not necessary to implement |
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement |
| Send | ✅ | ❌ | 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)
- ✅ 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
**Deploy steps:**
1. Fork this project (you don't need to fork it if you don't need to update it later).
2. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
3. Open the generated service URL and follow the on-page instructions.
## 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: What if I forget the master password?**
A: It cant 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. For multi-user usage, choose Vaultwarden.
---
## License
LGPL-3.0 License
---
## Credits
- [Bitwarden](https://bitwarden.com/) - original design and clients
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - server implementation reference
- [Cloudflare Workers](https://workers.cloudflare.com/) - serverless platform
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
-114
View File
@@ -1,114 +0,0 @@
# NodeWarden
English[`README.md`](./README.md)
一个运行在 **Cloudflare Workers** 上的 **Bitwarden 兼容**服务端实现。
- 部署简单(不需要 VPS
- 功能聚焦
- 维护成本低
> **免责声明**
> 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份您的密码库。
> 本项目与 Bitwarden 官方无关,请勿向 Bitwarden 官方反馈问题。
---
## 特性
-**完全免费,不需要在服务器上部署,再次感谢大善人!**
- ✅ 完整的密码、笔记、卡片、身份信息管理
- ✅ 文件夹和收藏功能
- ✅ 文件附件支持(基于 R2 存储)
- ✅ 导入/导出功能
- ✅ 网站图标获取
- ✅ 端到端加密(服务器无法查看明文)
- ✅ 兼容常见的 Bitwarden 官方客户端
## 测试情况:
- ✅ Windows 客户端(v2026.1.0
- ✅ Android Appv2026.1.0
- ✅ 浏览器扩展(v2026.1.0
- ⬜ macOS 客户端(未测试)
- ⬜ Linux 客户端(未测试)
---
# 快速开始
### 一键部署
点击下方按钮部署到 Cloudflare Workers
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
**部署步骤:**
1. 使用 GitHub 登录并授权
2. 登录 Cloudflare 账户
3. **重要**:设置 `JWT_SECRET` 为强随机字符串(推荐使用 `openssl rand -hex 32` 生成)
4. D1 数据库和 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 D1SQLite
- **文件存储**Cloudflare R2
- **开发语言**TypeScript
- **加密算法**:客户端 AES-256-CBCJWT 使用 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/) - 无服务器平台
+7 -2
View File
@@ -78,8 +78,8 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
-- Rate limiting -- Rate limiting
CREATE TABLE IF NOT EXISTS login_attempts ( CREATE TABLE IF NOT EXISTS login_attempts_ip (
email TEXT PRIMARY KEY, ip TEXT PRIMARY KEY,
attempts INTEGER NOT NULL, attempts INTEGER NOT NULL,
locked_until INTEGER, locked_until INTEGER,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
@@ -92,3 +92,8 @@ CREATE TABLE IF NOT EXISTS api_rate_limits (
PRIMARY KEY (identifier, window_start) PRIMARY KEY (identifier, window_start)
); );
CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(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
);
+64 -2
View File
@@ -1,15 +1,17 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "0.1.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nodewarden", "name": "nodewarden",
"version": "0.1.0", "version": "1.0.0",
"license": "LGPL-3.0", "license": "LGPL-3.0",
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20260131.0", "@cloudflare/workers-types": "^4.20260131.0",
"@types/node": "^25.2.3",
"tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"wrangler": "^4.61.1" "wrangler": "^4.61.1"
} }
@@ -1166,6 +1168,16 @@
"dev": true, "dev": true,
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
"node_modules/@types/node": {
"version": "25.2.3",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-25.2.3.tgz",
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/blake3-wasm": { "node_modules/blake3-wasm": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmmirror.com/blake3-wasm/-/blake3-wasm-2.1.5.tgz", "resolved": "https://registry.npmmirror.com/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
@@ -1264,6 +1276,19 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmmirror.com/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/kleur": { "node_modules/kleur": {
"version": "4.1.5", "version": "4.1.5",
"resolved": "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz", "resolved": "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz",
@@ -1309,6 +1334,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.3", "version": "7.7.3",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz",
@@ -1388,6 +1423,26 @@
"license": "0BSD", "license": "0BSD",
"optional": true "optional": true
}, },
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
@@ -1412,6 +1467,13 @@
"node": ">=20.18.1" "node": ">=20.18.1"
} }
}, },
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
"node_modules/unenv": { "node_modules/unenv": {
"version": "2.0.0-rc.24", "version": "2.0.0-rc.24",
"resolved": "https://registry.npmmirror.com/unenv/-/unenv-2.0.0-rc.24.tgz", "resolved": "https://registry.npmmirror.com/unenv/-/unenv-2.0.0-rc.24.tgz",
+4 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "0.2.0", "version": "1.0.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",
@@ -9,7 +9,7 @@
"scripts": { "scripts": {
"dev": "wrangler dev -c wrangler.toml", "dev": "wrangler dev -c wrangler.toml",
"deploymy": "wrangler deploy -c wrangler.my.toml", "deploymy": "wrangler deploy -c wrangler.my.toml",
"deploy": "wrangler deploy " "deploy": "wrangler deploy"
}, },
"keywords": [ "keywords": [
"bitwarden", "bitwarden",
@@ -33,8 +33,9 @@
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20260131.0", "@cloudflare/workers-types": "^4.20260131.0",
"@types/node": "^25.2.3",
"tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"wrangler": "^4.61.1" "wrangler": "^4.61.1"
} }
} }
+104
View File
@@ -0,0 +1,104 @@
export const LIMITS = {
auth: {
// Access token lifetime in seconds.
// 访问令牌有效期(秒)。
accessTokenTtlSeconds: 7200,
// Refresh token lifetime in milliseconds.
// 刷新令牌有效期(毫秒)。
refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000,
// Refresh token random byte length.
// 刷新令牌随机字节长度。
refreshTokenRandomBytes: 32,
// Attachment download token lifetime in seconds.
// 附件下载令牌有效期(秒)。
fileDownloadTokenTtlSeconds: 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: 5,
// Login lock duration in minutes.
// 登录锁定时长(分钟)。
loginLockoutMinutes: 2,
// Write API request budget per minute.
// 写操作 API 每分钟请求配额。
apiWriteRequestsPerMinute: 120,
// /api/sync read request budget per minute.
// /api/sync 读请求每分钟配额。
syncReadRequestsPerMinute: 1000,
// 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,
// Minimum interval between API-window cleanup runs.
// API 窗口计数清理的最小间隔。
apiWindowCleanupIntervalMs: 5 * 60 * 1000,
// Retention window for login IP records.
// 登录 IP 记录保留时长。
loginIpRetentionMs: 30 * 24 * 60 * 60 * 1000,
// Number of historical API windows to keep.
// 保留的历史 API 窗口数量。
apiWindowRetentionWindows: 120,
},
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,
},
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,
},
compatibility: {
// Single source of truth for /config.version and /api/version.
// /config.version 与 /api/version 的统一版本号来源。
bitwardenServerVersion: '2026.1.0',
},
} as const;
+33 -10
View File
@@ -3,12 +3,23 @@ import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
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';
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;
}
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null { function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing'; if (!secret) return 'missing';
if (secret === DEFAULT_DEV_SECRET) return 'default'; if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < 32) return 'too_short'; if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null; return null;
} }
@@ -27,12 +38,6 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse(message, 400); return errorResponse(message, 400);
} }
// Check if already registered
const isRegistered = await storage.isRegistered();
if (isRegistered) {
return errorResponse('Registration is closed', 403);
}
let body: { let body: {
email?: string; email?: string;
name?: string; name?: string;
@@ -69,6 +74,12 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
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);
}
// Create user // Create user
const user: User = { const user: User = {
@@ -80,7 +91,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
privateKey: privateKey, privateKey: privateKey,
publicKey: 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(),
@@ -88,7 +99,11 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
await storage.saveUser(user); const created = await storage.createFirstUser(user);
if (!created) {
return errorResponse('Registration is closed', 403);
}
await storage.setRegistered(); await storage.setRegistered();
return jsonResponse({ success: true }, 200); return jsonResponse({ success: true }, 200);
@@ -180,6 +195,12 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
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);
@@ -200,6 +221,7 @@ 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.DB); const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) { if (!user) {
@@ -217,7 +239,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);
if (!valid) {
return errorResponse('Invalid password', 400); return errorResponse('Invalid password', 400);
} }
+13 -4
View File
@@ -1,9 +1,10 @@
import { Env, Attachment } 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 { 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 {
@@ -86,7 +87,7 @@ export async function handleCreateAttachment(
} }
// 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
@@ -210,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');
@@ -229,7 +235,6 @@ export async function handlePublicDownloadAttachment(
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
// Verify attachment exists // Verify attachment exists
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachment(attachmentId);
@@ -245,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': '*',
}, },
}); });
} }
+53 -52
View File
@@ -3,6 +3,7 @@ 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';
// Format attachments for API response // Format attachments for API response
export function formatAttachments(attachments: Attachment[]): any[] | null { export function formatAttachments(attachments: Attachment[]): any[] | null {
@@ -18,28 +19,24 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
})); }));
} }
// Convert internal cipher to API response format // Convert internal cipher to API response format.
// 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 { 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;
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,7 +47,6 @@ export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = [])
object: 'cipher', object: 'cipher',
collectionIds: [], collectionIds: [],
attachments: formatAttachments(attachments), attachments: formatAttachments(attachments),
key: cipher.key,
encryptedFor: null, encryptedFor: null,
}; };
} }
@@ -58,27 +54,42 @@ export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = [])
// 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.DB); 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
? ciphers let filteredCiphers: Cipher[];
: ciphers.filter(c => !c.deletedAt); 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.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,
}); });
} }
@@ -111,23 +122,16 @@ 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 || null,
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,
@@ -159,23 +163,20 @@ 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,
}; };
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
+16 -2
View File
@@ -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 {
@@ -16,12 +17,24 @@ 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.DB); 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,
}); });
} }
@@ -103,6 +116,7 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
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); await storage.updateRevisionDate(userId);
+28 -22
View File
@@ -1,8 +1,9 @@
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';
// 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> {
@@ -12,12 +13,15 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
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;
@@ -26,19 +30,15 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// 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 loginIdentifier = getClientIdentifier(request);
if (!email || !passwordHash) { if (!email || !passwordHash) {
// Bitwarden clients expect OAuth-style error fields. // Bitwarden clients expect OAuth-style error fields.
return identityErrorResponse('Email and password are required', 'invalid_request', 400); return identityErrorResponse('Email and password are required', 'invalid_request', 400);
} }
const 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.`,
@@ -47,10 +47,16 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
); );
} }
const user = await storage.getUser(email);
if (!user) {
await rateLimit.recordFailedLogin(loginIdentifier);
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
}
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash); const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash);
if (!valid) { if (!valid) {
// Record failed login attempt // Record failed login attempt
const result = await rateLimit.recordFailedLogin(email); const result = await rateLimit.recordFailedLogin(loginIdentifier);
if (result.locked) { if (result.locked) {
return identityErrorResponse( return identityErrorResponse(
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`, `Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
@@ -62,14 +68,14 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
// 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,
Key: user.key, Key: user.key,
@@ -106,12 +112,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// 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) // Revoke old refresh token (prevent reuse)
@@ -122,7 +128,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
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,
@@ -156,7 +162,7 @@ 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
@@ -179,7 +185,7 @@ 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; const kdfMemory = user?.kdfMemory;
const kdfParallelism = user?.kdfParallelism; const kdfParallelism = user?.kdfParallelism;
+58 -2
View File
@@ -2,6 +2,7 @@ import { Env, Cipher, Folder, CipherType } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { errorResponse } from '../utils/response'; import { errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits';
// Bitwarden client import request format // Bitwarden client import request format
interface CiphersImportRequest { interface CiphersImportRequest {
@@ -66,6 +67,17 @@ 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.DB); const storage = new StorageService(env.DB);
@@ -82,9 +94,11 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
const folderRelationships = importData.folderRelationships || []; const folderRelationships = importData.folderRelationships || [];
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 +112,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,6 +137,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
} }
// Create ciphers // Create ciphers
const cipherRows: Cipher[] = [];
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;
@@ -181,7 +208,36 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
deletedAt: null, deletedAt: null,
}; };
await storage.saveCipher(cipher); cipherRows.push(cipher);
}
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
+17 -11
View File
@@ -1,18 +1,29 @@
import { Env, DEFAULT_DEV_SECRET } from '../types'; import { Env, DEFAULT_DEV_SECRET } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { jsonResponse, htmlResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse, htmlResponse } from '../utils/response';
import { renderJwtSecretWarningPage, JwtSecretState } from './setupPages'; import { renderRegisterPageHTML } from '../setup/pageTemplate';
import { handleRegisterPage } from './setupRegisterPage'; import { LIMITS } from '../config/limits';
type JwtSecretState = 'missing' | 'default' | 'too_short';
function getJwtSecretState(env: Env): JwtSecretState | null { function getJwtSecretState(env: Env): JwtSecretState | null {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing'; if (!secret) return 'missing';
// Block common "forgot to change" sample value (matches .dev.vars.example) // Block common "forgot to change" sample value (matches .dev.vars.example)
if (secret === DEFAULT_DEV_SECRET) return 'default'; if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < 32) return 'too_short'; if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null; return null;
} }
async function handleRegisterPage(request: Request, env: Env, jwtState: JwtSecretState | null): Promise<Response> {
const storage = new StorageService(env.DB);
const disabled = await storage.isSetupDisabled();
if (disabled) {
return new Response(null, { status: 404 });
}
return htmlResponse(renderRegisterPageHTML(jwtState));
}
// GET / - Setup page // GET / - Setup page
export async function handleSetupPage(request: Request, env: Env): Promise<Response> { export async function handleSetupPage(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
@@ -21,14 +32,9 @@ export async function handleSetupPage(request: Request, env: Env): Promise<Respo
return new Response(null, { status: 404 }); return new Response(null, { status: 404 });
} }
// Guard: require a strong JWT_SECRET before allowing setup/registration. // 引导页内会处理 JWT_SECRET 检测与分流(坏密钥停留在修复步骤)。
const jwtState = getJwtSecretState(env); const jwtState = getJwtSecretState(env);
if (jwtState) { return handleRegisterPage(request, env, jwtState);
return htmlResponse(renderJwtSecretWarningPage(request, jwtState), 200);
}
// Serve the registration/setup UI (split into a dedicated module).
return handleRegisterPage(request, env);
} }
// GET /setup/status // GET /setup/status
-290
View File
@@ -1,290 +0,0 @@
import { Env } from '../types';
// NOTE: Kept as a single file with inline HTML/CSS to avoid external assets.
// This file splits the old monolithic setup page into reusable page generators.
type Lang = 'zh' | 'en';
function isChineseFromRequest(request: Request): boolean {
const acceptLang = (request.headers.get('accept-language') || '').toLowerCase();
return acceptLang.includes('zh');
}
function t(lang: Lang, key: string): string {
const zh: Record<string, string> = {
app: 'NodeWarden',
tag: '部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端。',
// Config warning page
cfgTitle: '需要配置 JWT_SECRET',
cfgDescMissing: '当前服务没有配置 JWT_SECRET(用于签名登录令牌)。为了安全起见,必须先配置后才能注册/使用。',
cfgDescDefault: '检测到你正在使用示例/默认 JWT_SECRET。为了安全起见,请先修改为随机强密钥后再注册/使用。',
cfgDescTooShort: '检测到 JWT_SECRET 长度不足 32 个字符。为了安全起见,请使用至少 32 位的随机字符串。',
cfgStepsTitle: '如何在 Cloudflare 修改 JWT_SECRET',
cfgSteps: '打开 Cloudflare 控制台 → Workers 和 Pages → 选择 nodewarden → 设置 → 变量和机密 → 添加变量。\n类型:密钥\n名称:JWT_SECRET\n值:粘贴你生成的随机密钥\n保存后,等待重新部署生效。',
cfgGenTitle: '随机密钥生成器',
cfgGenHint: '建议长度:至少 32 字符(推荐 64+)。点击刷新生成新的随机值。',
cfgCopy: '复制',
cfgRefresh: '刷新',
// Shared
by: '作者',
github: 'GitHub',
};
const en: Record<string, string> = {
app: 'NodeWarden',
tag: 'Minimal Bitwarden-compatible server on Cloudflare Workers.',
// Config warning page
cfgTitle: 'JWT_SECRET is required',
cfgDescMissing: 'This server has no JWT_SECRET configured (used to sign login tokens). For safety, you must configure it before registration/usage.',
cfgDescDefault: 'You are using the sample/default JWT_SECRET. For safety, please change it to a strong random secret before registration/usage.',
cfgDescTooShort: 'JWT_SECRET is shorter than 32 characters. For safety, use a random string with at least 32 characters.',
cfgStepsTitle: 'How to set JWT_SECRET in Cloudflare',
cfgSteps: 'Open Cloudflare Dashboard → Workers & Pages → select nodewarden → Settings → Variables and Secrets → Add variable.\nType: Secret\nName: JWT_SECRET\nValue: paste a random secret\nSave, and wait for redeploy to take effect.',
cfgGenTitle: 'Random secret generator',
cfgGenHint: 'Recommended length: 32+ characters (64+ preferred). Click refresh to generate a new one.',
cfgCopy: 'Copy',
cfgRefresh: 'Refresh',
// Shared
by: 'By',
github: 'GitHub',
};
return (lang === 'zh' ? zh : en)[key] ?? key;
}
function baseStyles(): string {
// Keep consistent with existing setup page look & feel.
return `
: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); }
.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);
}
.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; }
h2 { font-size: 16px; margin: 14px 0 10px 0; letter-spacing: -0.2px; }
.lead { font-size: 13px; line-height: 1.7; color: rgba(255,255,255,0.86); }
.kv {
border-radius: var(--radius2);
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.05);
padding: 14px;
margin-top: 12px;
}
.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); white-space: pre-line; }
.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);
}
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.btn {
height: 38px;
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);
font-weight: 700;
cursor: pointer;
}
.btn.primary {
background: linear-gradient(135deg, rgba(10,132,255,0.95), rgba(100,210,255,0.60));
}
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
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);
}
`;
}
export type JwtSecretState = 'missing' | 'default' | 'too_short';
export function renderJwtSecretWarningPage(request: Request, state: JwtSecretState): string {
const lang: Lang = isChineseFromRequest(request) ? 'zh' : 'en';
const descKey = state === 'missing' ? 'cfgDescMissing' : state === 'default' ? 'cfgDescDefault' : 'cfgDescTooShort';
return `<!DOCTYPE html>
<html lang="${lang === 'zh' ? 'zh-CN' : 'en'}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NodeWarden</title>
<style>${baseStyles()}</style>
</head>
<body>
<div class="shell">
<aside class="panel">
<div class="top">
<div class="mark" aria-label="NodeWarden">NW</div>
<div class="title">
<h1>${t(lang, 'app')}</h1>
<p>${t(lang, 'tag')}</p>
</div>
</div>
<h2>${t(lang, 'cfgTitle')}</h2>
<div class="lead">${t(lang, descKey)}</div>
<div class="kv">
<h3>${t(lang, 'cfgStepsTitle')}</h3>
<p>${t(lang, 'cfgSteps')
.replace(/^类型:密钥/m, '<b>类型:密钥</b>')
.replace(/^名称:JWT_SECRET/m, '<b>名称:JWT_SECRET</b>')
.replace(/^Type: Secret/m, '<b>Type: Secret</b>')
.replace(/^Name: JWT_SECRET/m, '<b>Name: JWT_SECRET</b>')
}</p>
</div>
<div class="kv">
<h3>${t(lang, 'cfgGenTitle')}</h3>
<p>${t(lang, 'cfgGenHint')}</p>
<div class="server" id="secret"></div>
<div style="height: 10px"></div>
<div class="row">
<button class="btn primary" type="button" onclick="refreshSecret()">${t(lang, 'cfgRefresh')}</button>
<button class="btn" type="button" onclick="copySecret()">${t(lang, 'cfgCopy')}</button>
</div>
</div>
<div class="footer">
<div>
<span>${t(lang, '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">${t(lang, 'github')}</a>
</div>
</div>
</aside>
</div>
<script>
// Generate a URL-safe random secret (default length: 64)
function genSecret(len) {
len = len || 50;
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
const bytes = new Uint8Array(len);
crypto.getRandomValues(bytes);
let out = '';
for (let i = 0; i < bytes.length; i++) {
out += chars[bytes[i] % chars.length];
}
return out;
}
function refreshSecret() {
const s = genSecret(50);
document.getElementById('secret').textContent = s;
}
async function copySecret() {
const s = document.getElementById('secret').textContent || '';
try {
await navigator.clipboard.writeText(s);
} catch {
const ta = document.createElement('textarea');
ta.value = s;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
}
refreshSecret();
</script>
</body>
</html>`;
}
-668
View File
@@ -1,668 +0,0 @@
import { Env } from '../types';
import { StorageService } from '../services/storage';
import { htmlResponse } from '../utils/response';
// Registration/setup page HTML (single-file, no external assets)
// Split out from the old monolithic `setup.ts` as requested.
const registerPageHTML = `<!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); }
.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);
}
.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; }
h2 { font-size: 16px; margin: 14px 0 10px 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">Minimal Bitwarden-compatible server on Cloudflare Workers.</p>
</div>
</div>
<div class="muted" id="t_intro" style="font-size: 13px; line-height: 1.7;">
Create your first account to finish setup. Then use any official Bitwarden client to sign in.
</div>
<div style="height: 14px"></div>
<h2 id="t_setup">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>
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.',
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');
}
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;
}
async function pbkdf2(password, salt, iterations, keyLen) {
const encoder = new TextEncoder();
const passwordBytes = (password instanceof Uint8Array)
? password
: encoder.encode(password);
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);
}
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;
}
function generateSymmetricKey() {
return crypto.getRandomValues(new Uint8Array(64));
}
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);
}
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);
}
function base64Encode(bytes) {
return btoa(String.fromCharCode.apply(null, bytes));
}
async function encryptToBitwardenFormat(data, encKey, macKey) {
const iv = crypto.getRandomValues(new Uint8Array(16));
const encrypted = await encryptAesCbc(data, encKey, iv);
const macData = new Uint8Array(iv.length + encrypted.length);
macData.set(iv);
macData.set(encrypted, iv.length);
const mac = await hmacSha256(macKey, macData);
return '2.' + base64Encode(iv) + '|' + base64Encode(encrypted) + '|' + base64Encode(mac);
}
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']
);
const publicKeySpki = await crypto.subtle.exportKey('spki', keyPair.publicKey);
const publicKeyB64 = base64Encode(new Uint8Array(publicKeySpki));
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 {
const iterations = 600000;
const masterKey = await pbkdf2(password, email, iterations, 32);
const masterPasswordHash = await pbkdf2(masterKey, password, 1, 32);
const masterPasswordHashB64 = base64Encode(masterPasswordHash);
const stretchedKey = await hkdfExpand(masterKey, 'enc', 32);
const stretchedMacKey = await hkdfExpand(masterKey, 'mac', 32);
const symmetricKey = generateSymmetricKey();
const encryptedKey = await encryptToBitwardenFormat(symmetricKey, stretchedKey, stretchedMacKey);
const rsaKeys = await generateRsaKeyPair();
const encryptedPrivateKey = await encryptToBitwardenFormat(rsaKeys.privateKey, symmetricKey.slice(0, 32), symmetricKey.slice(32, 64));
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');
}
}
applyI18n();
checkStatus();
</script>
</body>
</html>`;
export async function handleRegisterPage(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const disabled = await storage.isSetupDisabled();
if (disabled) {
return new Response(null, { status: 404 });
}
return htmlResponse(registerPageHTML);
}
+65 -10
View File
@@ -1,19 +1,66 @@
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } 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 { cipherToResponse } from './ciphers';
import { LIMITS } from '../config/limits';
interface SyncCacheEntry {
body: string;
expiresAt: number;
}
const syncResponseCache = new Map<string, SyncCacheEntry>();
function buildSyncCacheKey(userId: string, revisionDate: string, excludeDomains: boolean): string {
return `${userId}:${revisionDate}:${excludeDomains ? '1' : '0'}`;
}
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.DB); 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 attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
// Build profile response // Build profile response
const profile: ProfileResponse = { const profile: ProfileResponse = {
@@ -43,7 +90,7 @@ 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(cipherToResponse(cipher, attachments)); cipherResponses.push(cipherToResponse(cipher, attachments));
} }
@@ -60,11 +107,13 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
folders: folderResponses, folders: folderResponses,
collections: [], collections: [],
ciphers: cipherResponses, ciphers: cipherResponses,
domains: { domains: excludeDomains
equivalentDomains: [], ? null
globalEquivalentDomains: [], : {
object: 'domains', equivalentDomains: [],
}, globalEquivalentDomains: [],
object: 'domains',
},
policies: [], policies: [],
sends: [], sends: [],
// PascalCase for desktop/browser clients // PascalCase for desktop/browser clients
@@ -80,7 +129,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
}, },
MasterKeyEncryptedUserKey: user.key, MasterKeyEncryptedUserKey: user.key,
MasterKeyWrappedUserKey: user.key, MasterKeyWrappedUserKey: user.key,
Salt: user.email, Salt: user.email.toLowerCase(),
Object: 'masterPasswordUnlock', Object: 'masterPasswordUnlock',
}, },
}, },
@@ -95,11 +144,17 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
}, },
masterKeyWrappedUserKey: user.key, masterKeyWrappedUserKey: user.key,
masterKeyEncryptedUserKey: user.key, masterKeyEncryptedUserKey: user.key,
salt: user.email, 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' },
});
} }
+23 -5
View File
@@ -1,11 +1,12 @@
import { Env } from './types'; import { Env } from './types';
import { handleRequest } from './router'; import { handleRequest } from './router';
import { StorageService } from './services/storage'; import { StorageService } from './services/storage';
import { applyCors, jsonResponse } from './utils/response';
// Per-isolate flag. Each Worker isolate may have its own copy of this flag, // Per-isolate flags. Each Worker isolate may have its own copy of these flags.
// but initializeDatabase() is idempotent (uses CREATE TABLE IF NOT EXISTS), // initializeDatabase() only validates schema presence, so retries are cheap.
// so redundant calls are harmless and fast (single SELECT check).
let dbInitialized = false; let dbInitialized = false;
let dbInitError: string | null = null;
export default { export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
@@ -15,12 +16,29 @@ export default {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
await storage.initializeDatabase(); await storage.initializeDatabase();
dbInitialized = true; dbInitialized = true;
dbInitError = null;
} catch (error) { } catch (error) {
console.error('Failed to initialize database:', error); console.error('Failed to initialize database:', error);
// Continue anyway - the error will surface when actual DB operations are attempted dbInitError = error instanceof Error ? error.message : 'Unknown database initialization error';
} }
} }
return handleRequest(request, env); if (dbInitError) {
const resp = jsonResponse(
{
error: 'Database not initialized',
error_description: dbInitError,
ErrorModel: {
Message: dbInitError,
Object: 'error',
},
},
500
);
return applyCors(request, resp);
}
const resp = await handleRequest(request, env);
return applyCors(request, resp);
}, },
}; };
+161 -32
View File
@@ -1,7 +1,8 @@
import { Env } from './types'; import { Env, DEFAULT_DEV_SECRET } from './types';
import { AuthService } from './services/auth'; import { AuthService } from './services/auth';
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 } from './handlers/identity';
@@ -49,26 +50,99 @@ import {
handlePublicDownloadAttachment, handlePublicDownloadAttachment,
} from './handlers/attachments'; } from './handlers/attachments';
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 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 });
@@ -84,7 +158,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Handle CORS preflight // Handle CORS preflight
if (method === 'OPTIONS') { if (method === 'OPTIONS') {
return handleCors(); return handleCors(request);
} }
// Route matching // Route matching
@@ -102,12 +176,26 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Disable setup page (one-way) // Disable setup page (one-way)
if (path === '/setup/disable' && method === 'POST') { if (path === '/setup/disable' && method === 'POST') {
if (!isSameOriginWriteRequest(request)) {
return errorResponse('Forbidden origin', 403);
}
return handleDisableSetup(request, env); return handleDisableSetup(request, env);
} }
// 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)
@@ -155,7 +243,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: {
@@ -165,8 +265,14 @@ 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,
'unauth-ui-refresh': true,
}, },
object: 'config', object: 'config',
}); });
@@ -174,17 +280,20 @@ 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, but only works once)
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. // If JWT_SECRET is not safely configured, block any other endpoints.
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < 32) { if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500); return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
} }
@@ -198,28 +307,48 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
} }
const userId = payload.sub; const userId = payload.sub;
// API rate limiting for authenticated requests
const rateLimit = new RateLimitService(env.DB);
const clientId = getClientIdentifier(request); const clientId = getClientIdentifier(request);
const rateLimitCheck = await rateLimit.checkApiRateLimit(userId + ':' + clientId);
// Dedicated read rate limiting for heavy sync endpoint.
if (!rateLimitCheck.allowed) { if (path === '/api/sync' && method === 'GET') {
return new Response(JSON.stringify({ const rateLimit = new RateLimitService(env.DB);
error: 'Too many requests', const rateLimitCheck = await rateLimit.consumeSyncReadBudget(userId + ':' + clientId + ':sync');
error_description: `Rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
}), { if (!rateLimitCheck.allowed) {
status: 429, return new Response(JSON.stringify({
headers: { error: 'Too many requests',
'Content-Type': 'application/json', error_description: `Sync rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(), }), {
'X-RateLimit-Remaining': '0', status: 429,
}, headers: {
}); 'Content-Type': 'application/json',
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
'X-RateLimit-Remaining': '0',
},
});
}
} }
// Increment rate limit counter // API rate limiting only for write operations (keep reads frictionless)
await rateLimit.incrementApiCount(userId + ':' + clientId); const isWriteMethod = method === 'POST' || method === 'PUT' || method === 'DELETE' || method === 'PATCH';
if (isWriteMethod) {
const rateLimit = new RateLimitService(env.DB);
const rateLimitCheck = await rateLimit.consumeApiWriteBudget(userId + ':' + clientId + ':write');
if (!rateLimitCheck.allowed) {
return new Response(JSON.stringify({
error: 'Too many requests',
error_description: `Rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
}), {
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
'X-RateLimit-Remaining': '0',
},
});
}
}
// Block account operations that could change password or delete user // Block account operations that could change password or delete user
if (method === 'POST' || method === 'PUT' || method === 'DELETE') { if (method === 'POST' || method === 'PUT' || method === 'DELETE') {
@@ -233,7 +362,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
'/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 in single-user mode', 501);
} }
} }
+9 -3
View File
@@ -11,9 +11,15 @@ export class AuthService {
// Verify password hash (compare with stored hash) // Verify password hash (compare with stored hash)
async verifyPassword(inputHash: string, storedHash: string): Promise<boolean> { async verifyPassword(inputHash: string, storedHash: string): Promise<boolean> {
// In Bitwarden, the client sends the password hash directly const input = new TextEncoder().encode(inputHash);
// We compare the hashes const stored = new TextEncoder().encode(storedHash);
return inputHash === storedHash; if (input.length !== stored.length) return false;
let diff = 0;
for (let i = 0; i < input.length; i++) {
diff |= input[i] ^ stored[i];
}
return diff === 0;
} }
// Generate access token // Generate access token
+140 -42
View File
@@ -1,30 +1,97 @@
import { LIMITS } from '../config/limits';
// D1-backed rate limiting. // D1-backed rate limiting.
// Notes: // Notes:
// - Login attempts are tracked per email. // - Login attempts are tracked per client IP.
// - API rate is tracked per identifier per fixed window. // - API rate is tracked per identifier per fixed window.
// Rate limit configuration // Rate limit configuration
const CONFIG = { const CONFIG = {
LOGIN_MAX_ATTEMPTS: 15, // Friendly default: short cooldown instead of long lockouts.
LOGIN_LOCKOUT_MINUTES: 5, LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
LOGIN_LOCKOUT_MINUTES: LIMITS.rateLimit.loginLockoutMinutes,
API_REQUESTS_PER_MINUTE: 300, // Write operations only (POST/PUT/DELETE/PATCH) should use this budget.
API_WINDOW_SECONDS: 60, API_WRITE_REQUESTS_PER_MINUTE: LIMITS.rateLimit.apiWriteRequestsPerMinute,
// Dedicated budget for GET /api/sync reads.
SYNC_READ_REQUESTS_PER_MINUTE: LIMITS.rateLimit.syncReadRequestsPerMinute,
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
}; };
export class RateLimitService { export class RateLimitService {
private static loginIpTableReady = false;
private static lastLoginIpCleanupAt = 0;
private static lastApiWindowCleanupAt = 0;
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability;
private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
private static readonly API_WINDOW_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.apiWindowCleanupIntervalMs;
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
private static readonly API_WINDOW_RETENTION_WINDOWS = LIMITS.rateLimit.apiWindowRetentionWindows;
constructor(private db: D1Database) {} constructor(private db: D1Database) {}
async checkLoginAttempt(email: string): Promise<{ 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 maybeCleanupApiWindows(windowStart: number, windowSeconds: number): Promise<void> {
if (!this.shouldRunCleanup(RateLimitService.lastApiWindowCleanupAt, RateLimitService.API_WINDOW_CLEANUP_INTERVAL_MS)) {
return;
}
const cutoff = windowStart - (windowSeconds * RateLimitService.API_WINDOW_RETENTION_WINDOWS);
await this.db.prepare('DELETE FROM api_rate_limits WHERE window_start < ?').bind(cutoff).run();
RateLimitService.lastApiWindowCleanupAt = Date.now();
}
private async ensureLoginIpTable(): Promise<void> {
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 = email.toLowerCase(); await this.ensureLoginIpTable();
const key = ip.trim() || 'unknown';
const now = Date.now(); const now = Date.now();
await this.maybeCleanupLoginAttemptsIp(now);
const row = await this.db const row = await this.db
.prepare('SELECT attempts, locked_until FROM login_attempts WHERE email = ?') .prepare('SELECT attempts, locked_until FROM login_attempts_ip WHERE ip = ?')
.bind(key) .bind(key)
.first<{ attempts: number; locked_until: number | null }>(); .first<{ attempts: number; locked_until: number | null }>();
@@ -41,7 +108,7 @@ export class RateLimitService {
} }
if (row.locked_until && row.locked_until <= now) { if (row.locked_until && row.locked_until <= now) {
await this.db.prepare('DELETE FROM login_attempts WHERE email = ?').bind(key).run(); await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS }; return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
} }
@@ -49,23 +116,26 @@ export class RateLimitService {
return { allowed: true, remainingAttempts }; return { allowed: true, remainingAttempts };
} }
async recordFailedLogin(email: string): Promise<{ locked: boolean; retryAfterSeconds?: number }> { async recordFailedLogin(ip: string): Promise<{ locked: boolean; retryAfterSeconds?: number }> {
const key = email.toLowerCase(); await this.ensureLoginIpTable();
const key = ip.trim() || 'unknown';
const now = Date.now(); const now = Date.now();
await this.maybeCleanupLoginAttemptsIp(now);
// D1 in Workers forbids raw BEGIN/COMMIT statements. // D1 in Workers forbids raw BEGIN/COMMIT statements.
// Use a single atomic UPSERT to increment attempts. // Use a single atomic UPSERT to increment attempts.
// This is concurrency-safe because the row is keyed by email. // This is concurrency-safe because the row is keyed by IP.
await this.db await this.db
.prepare( .prepare(
'INSERT INTO login_attempts(email, attempts, locked_until, updated_at) VALUES(?, 1, NULL, ?) ' + 'INSERT INTO login_attempts_ip(ip, attempts, locked_until, updated_at) VALUES(?, 1, NULL, ?) ' +
'ON CONFLICT(email) DO UPDATE SET attempts = attempts + 1, updated_at = excluded.updated_at' 'ON CONFLICT(ip) DO UPDATE SET attempts = attempts + 1, updated_at = excluded.updated_at'
) )
.bind(key, now) .bind(key, now)
.run(); .run();
const row = await this.db const row = await this.db
.prepare('SELECT attempts FROM login_attempts WHERE email = ?') .prepare('SELECT attempts FROM login_attempts_ip WHERE ip = ?')
.bind(key) .bind(key)
.first<{ attempts: number }>(); .first<{ attempts: number }>();
@@ -73,7 +143,7 @@ export class RateLimitService {
if (attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) { if (attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) {
const lockedUntil = now + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000; const lockedUntil = now + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000;
await this.db await this.db
.prepare('UPDATE login_attempts SET locked_until = ?, updated_at = ? WHERE email = ?') .prepare('UPDATE login_attempts_ip SET locked_until = ?, updated_at = ? WHERE ip = ?')
.bind(lockedUntil, now, key) .bind(lockedUntil, now, key)
.run(); .run();
return { locked: true, retryAfterSeconds: CONFIG.LOGIN_LOCKOUT_MINUTES * 60 }; return { locked: true, retryAfterSeconds: CONFIG.LOGIN_LOCKOUT_MINUTES * 60 };
@@ -82,22 +152,36 @@ export class RateLimitService {
return { locked: false }; return { locked: false };
} }
async clearLoginAttempts(email: string): Promise<void> { async clearLoginAttempts(ip: string): Promise<void> {
await this.db.prepare('DELETE FROM login_attempts WHERE email = ?').bind(email.toLowerCase()).run(); await this.ensureLoginIpTable();
const key = ip.trim() || 'unknown';
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
} }
async checkApiRateLimit(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { // Atomically consume one budget unit for the current fixed window.
// Uses SQLite UPSERT-with-WHERE so requests at/over limit do not increment.
private async consumeFixedWindowBudget(
identifier: string,
maxRequests: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
const windowStart = nowSec - (nowSec % CONFIG.API_WINDOW_SECONDS); const windowStart = nowSec - (nowSec % windowSeconds);
const windowEnd = windowStart + CONFIG.API_WINDOW_SECONDS; const windowEnd = windowStart + windowSeconds;
await this.maybeCleanupApiWindows(windowStart, windowSeconds);
const row = await this.db const writeResult = await this.db
.prepare('SELECT count FROM api_rate_limits WHERE identifier = ? AND window_start = ?') .prepare(
.bind(identifier, windowStart) 'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' +
.first<{ count: number }>(); 'ON CONFLICT(identifier, window_start) DO UPDATE SET count = count + 1 ' +
'WHERE api_rate_limits.count < ?'
)
.bind(identifier, windowStart, maxRequests)
.run();
const count = row?.count || 0; // No changed row means conflict happened and WHERE prevented increment:
if (count >= CONFIG.API_REQUESTS_PER_MINUTE) { // current count is already at/above configured limit.
if ((writeResult.meta.changes ?? 0) === 0) {
return { return {
allowed: false, allowed: false,
remaining: 0, remaining: 0,
@@ -105,24 +189,38 @@ export class RateLimitService {
}; };
} }
return { const row = await this.db
allowed: true, .prepare('SELECT count FROM api_rate_limits WHERE identifier = ? AND window_start = ?')
remaining: CONFIG.API_REQUESTS_PER_MINUTE - count, .bind(identifier, windowStart)
}; .first<{ count: number }>();
if (!row) {
return {
allowed: true,
remaining: 0,
};
}
const remaining = Math.max(0, maxRequests - row.count);
return { allowed: true, remaining };
} }
async incrementApiCount(identifier: string): Promise<void> { // Write budget for POST/PUT/DELETE/PATCH requests.
const nowSec = Math.floor(Date.now() / 1000); async consumeApiWriteBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
const windowStart = nowSec - (nowSec % CONFIG.API_WINDOW_SECONDS); return this.consumeFixedWindowBudget(
identifier,
CONFIG.API_WRITE_REQUESTS_PER_MINUTE,
CONFIG.API_WINDOW_SECONDS
);
}
// Atomic increment via UPSERT. // Read budget for GET /api/sync.
await this.db async consumeSyncReadBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
.prepare( return this.consumeFixedWindowBudget(
'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' + identifier,
'ON CONFLICT(identifier, window_start) DO UPDATE SET count = count + 1' CONFIG.SYNC_READ_REQUESTS_PER_MINUTE,
) CONFIG.API_WINDOW_SECONDS
.bind(identifier, windowStart) );
.run();
} }
} }
+392 -196
View File
@@ -1,4 +1,5 @@
import { User, Cipher, Folder, Attachment } from '../types'; import { User, Cipher, Folder, Attachment } from '../types';
import { LIMITS } from '../config/limits';
// D1-backed storage. // D1-backed storage.
// Contract: // Contract:
@@ -7,135 +8,115 @@ import { User, Cipher, Folder, Attachment } from '../types';
// - Revision date is maintained per user for Bitwarden sync. // - Revision date is maintained per user for Bitwarden sync.
export class StorageService { export class StorageService {
private static attachmentTokenTableReady = false;
private static schemaVerified = false;
private static lastRefreshTokenCleanupAt = 0;
private static lastAttachmentTokenCleanupAt = 0;
private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs;
private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs;
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability;
constructor(private db: D1Database) {} constructor(private db: D1Database) {}
/**
* D1 .bind() throws on `undefined` values. This helper converts every
* `undefined` in the argument list to `null` so we never hit that runtime
* error - especially important after the opaque-passthrough change where
* client-supplied JSON may omit fields we later reference as columns.
*/
private safeBind(stmt: D1PreparedStatement, ...values: any[]): D1PreparedStatement {
return stmt.bind(...values.map(v => v === undefined ? null : v));
}
private async sha256Hex(input: string): Promise<string> {
const bytes = new TextEncoder().encode(input);
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('');
}
private async refreshTokenKey(token: string): Promise<string> {
const digest = await this.sha256Hex(token);
return `sha256:${digest}`;
}
private shouldRunPeriodicCleanup(lastRunAt: number, intervalMs: number): boolean {
const now = Date.now();
if (now - lastRunAt < intervalMs) return false;
return Math.random() < StorageService.PERIODIC_CLEANUP_PROBABILITY;
}
private async maybeCleanupExpiredRefreshTokens(nowMs: number): Promise<void> {
if (!this.shouldRunPeriodicCleanup(StorageService.lastRefreshTokenCleanupAt, StorageService.REFRESH_TOKEN_CLEANUP_INTERVAL_MS)) {
return;
}
await this.db.prepare('DELETE FROM refresh_tokens WHERE expires_at < ?').bind(nowMs).run();
StorageService.lastRefreshTokenCleanupAt = nowMs;
}
// --- Database initialization --- // --- Database initialization ---
// Idempotent auto-init for environments where D1 migrations have not been applied // One-click deploy requires zero manual migration steps.
// (e.g. one-click deploy). Mirrors the schema in migrations/0001_init.sql — // This method idempotently creates required schema objects on first request.
// keep both in sync when changing the schema.
async initializeDatabase(): Promise<void> { async initializeDatabase(): Promise<void> {
// Check if database is already initialized by looking for the config table if (StorageService.schemaVerified) return;
try {
const result = await this.db const schemaStatements = [
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='config'") 'PRAGMA foreign_keys = ON',
.first<{ name: string }>();
'CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)',
if (result?.name === 'config') {
// Database already initialized 'CREATE TABLE IF NOT EXISTS users (' +
return; '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, ' +
} catch (e) { 'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
// If error occurs, assume database needs initialization 'security_stamp TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
console.log('Initializing database...');
'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 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 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 login_attempts_ip (' +
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)',
];
for (const stmt of schemaStatements) {
await this.db.prepare(stmt).run();
} }
// Execute initialization SQL StorageService.schemaVerified = true;
const initSQL = `
PRAGMA foreign_keys = ON;
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,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
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 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 login_attempts (
email 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);
`.trim();
// Split by semicolon and execute each statement
const statements = initSQL.split(';').filter(s => s.trim().length > 0);
for (const stmt of statements) {
if (stmt.trim()) {
await this.db.prepare(stmt).run();
}
}
console.log('Database initialized successfully');
} }
// --- Config / setup --- // --- Config / setup ---
@@ -218,31 +199,56 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
async saveUser(user: User): Promise<void> { async saveUser(user: User): Promise<void> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
await this.db const stmt = this.db.prepare(
.prepare( 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at) ' +
'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(id) DO UPDATE SET ' +
'ON CONFLICT(id) DO UPDATE SET ' + 'email=excluded.email, name=excluded.name, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
'email=excluded.email, name=excluded.name, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, updated_at=excluded.updated_at'
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, updated_at=excluded.updated_at' );
) await this.safeBind(stmt,
.bind( user.id,
user.id, email,
email, user.name,
user.name, user.masterPasswordHash,
user.masterPasswordHash, user.key,
user.key, user.privateKey,
user.privateKey, user.publicKey,
user.publicKey, user.kdfType,
user.kdfType, user.kdfIterations,
user.kdfIterations, user.kdfMemory,
user.kdfMemory ?? null, user.kdfParallelism,
user.kdfParallelism ?? null, user.securityStamp,
user.securityStamp, user.createdAt,
user.createdAt, user.updatedAt
user.updatedAt ).run();
) }
.run();
async createFirstUser(user: User): Promise<boolean> {
const email = user.email.toLowerCase();
const stmt = this.db.prepare(
'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at) ' +
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
);
const result = await this.safeBind(stmt,
user.id,
email,
user.name,
user.masterPasswordHash,
user.key,
user.privateKey,
user.publicKey,
user.kdfType,
user.kdfIterations,
user.kdfMemory,
user.kdfParallelism,
user.securityStamp,
user.createdAt,
user.updatedAt
).run();
return (result.meta.changes ?? 0) > 0;
} }
// --- Ciphers --- // --- Ciphers ---
@@ -254,29 +260,27 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
async saveCipher(cipher: Cipher): Promise<void> { async saveCipher(cipher: Cipher): Promise<void> {
const data = JSON.stringify(cipher); const data = JSON.stringify(cipher);
await this.db const stmt = this.db.prepare(
.prepare( 'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' +
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(id) DO UPDATE SET ' +
'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'
'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' );
) await this.safeBind(stmt,
.bind( cipher.id,
cipher.id, cipher.userId,
cipher.userId, Number(cipher.type) || 1,
Number(cipher.type) || 1, cipher.folderId,
cipher.folderId, cipher.name,
cipher.name, cipher.notes,
cipher.notes, cipher.favorite ? 1 : 0,
cipher.favorite ? 1 : 0, data,
data, cipher.reprompt ?? 0,
cipher.reprompt ?? 0, cipher.key,
cipher.key, cipher.createdAt,
cipher.createdAt, cipher.updatedAt,
cipher.updatedAt, cipher.deletedAt
cipher.deletedAt ).run();
)
.run();
} }
async deleteCipher(id: string, userId: string): Promise<void> { async deleteCipher(id: string, userId: string): Promise<void> {
@@ -289,6 +293,21 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
return (res.results || []).map(r => JSON.parse(r.data) as Cipher); return (res.results || []).map(r => JSON.parse(r.data) as Cipher);
} }
async getCiphersPage(userId: string, includeDeleted: boolean, limit: number, offset: number): Promise<Cipher[]> {
const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL';
const res = await this.db
.prepare(
`SELECT data FROM ciphers
WHERE user_id = ?
${whereDeleted}
ORDER BY updated_at DESC
LIMIT ? OFFSET ?`
)
.bind(userId, limit, offset)
.all<{ data: string }>();
return (res.results || []).map(r => JSON.parse(r.data) as Cipher);
}
async getCiphersByIds(ids: string[], userId: string): Promise<Cipher[]> { async getCiphersByIds(ids: string[], userId: string): Promise<Cipher[]> {
if (ids.length === 0) return []; if (ids.length === 0) return [];
// D1 doesn't support binding arrays directly; build placeholders. // D1 doesn't support binding arrays directly; build placeholders.
@@ -301,20 +320,25 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<void> { async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<void> {
if (ids.length === 0) return; if (ids.length === 0) return;
const now = new Date().toISOString(); const now = new Date().toISOString();
const uniqueIds = Array.from(new Set(ids));
const patch = JSON.stringify({
folderId,
updatedAt: now,
});
const chunkSize = LIMITS.performance.bulkMoveChunkSize;
// D1 forbids raw BEGIN/COMMIT statements in this runtime. for (let i = 0; i < uniqueIds.length; i += chunkSize) {
// For this endpoint, we accept per-row updates and then bump revision once. const chunk = uniqueIds.slice(i, i + chunkSize);
// Concurrency: each cipher write is an UPSERT on its PK, no shared index. const placeholders = chunk.map(() => '?').join(',');
for (const id of ids) {
const row = await this.db await this.db
.prepare('SELECT data FROM ciphers WHERE id = ? AND user_id = ?') .prepare(
.bind(id, userId) `UPDATE ciphers
.first<{ data: string }>(); SET folder_id = ?, updated_at = ?, data = json_patch(data, ?)
if (!row?.data) continue; WHERE user_id = ? AND id IN (${placeholders})`
const cipher = JSON.parse(row.data) as Cipher; )
cipher.folderId = folderId; .bind(folderId, now, patch, userId, ...chunk)
cipher.updatedAt = now; .run();
await this.saveCipher(cipher);
} }
await this.updateRevisionDate(userId); await this.updateRevisionDate(userId);
@@ -351,6 +375,23 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
await this.db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run(); await this.db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run();
} }
// Clear folder references from all ciphers owned by the user.
// Without this, deleting a folder leaves stale folderId values in cipher JSON.
async clearFolderFromCiphers(userId: string, folderId: string): Promise<void> {
const now = new Date().toISOString();
const res = await this.db
.prepare('SELECT data FROM ciphers WHERE user_id = ? AND folder_id = ?')
.bind(userId, folderId)
.all<{ data: string }>();
for (const row of (res.results || [])) {
const cipher = JSON.parse(row.data) as Cipher;
cipher.folderId = null;
cipher.updatedAt = now;
await this.saveCipher(cipher);
}
}
async getAllFolders(userId: string): Promise<Folder[]> { async getAllFolders(userId: string): Promise<Folder[]> {
const res = await this.db const res = await this.db
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC') .prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC')
@@ -365,6 +406,22 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
})); }));
} }
async getFoldersPage(userId: string, limit: number, offset: number): Promise<Folder[]> {
const res = await this.db
.prepare(
'SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?'
)
.bind(userId, limit, offset)
.all<any>();
return (res.results || []).map(r => ({
id: r.id,
userId: r.user_id,
name: r.name,
createdAt: r.created_at,
updatedAt: r.updated_at,
}));
}
// --- Attachments --- // --- Attachments ---
async getAttachment(id: string): Promise<Attachment | null> { async getAttachment(id: string): Promise<Attachment | null> {
@@ -384,13 +441,11 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
} }
async saveAttachment(attachment: Attachment): Promise<void> { async saveAttachment(attachment: Attachment): Promise<void> {
await this.db const stmt = this.db.prepare(
.prepare( 'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' +
'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key'
'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key' );
) await this.safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run();
.bind(attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key)
.run();
} }
async deleteAttachment(id: string): Promise<void> { async deleteAttachment(id: string): Promise<void> {
@@ -412,6 +467,74 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
})); }));
} }
async getAttachmentsByCipherIds(cipherIds: string[]): Promise<Map<string, Attachment[]>> {
const grouped = new Map<string, Attachment[]>();
if (cipherIds.length === 0) return grouped;
const uniqueCipherIds = [...new Set(cipherIds)];
const chunkSize = LIMITS.performance.bulkMoveChunkSize;
for (let i = 0; i < uniqueCipherIds.length; i += chunkSize) {
const chunk = uniqueCipherIds.slice(i, i + chunkSize);
const placeholders = chunk.map(() => '?').join(',');
const res = await this.db
.prepare(`SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id IN (${placeholders})`)
.bind(...chunk)
.all<any>();
for (const row of (res.results || [])) {
const item: Attachment = {
id: row.id,
cipherId: row.cipher_id,
fileName: row.file_name,
size: row.size,
sizeName: row.size_name,
key: row.key,
};
const list = grouped.get(item.cipherId);
if (list) {
list.push(item);
} else {
grouped.set(item.cipherId, [item]);
}
}
}
return grouped;
}
async getAttachmentsByUserId(userId: string): Promise<Map<string, Attachment[]>> {
const grouped = new Map<string, Attachment[]>();
const res = await this.db
.prepare(
`SELECT a.id, a.cipher_id, a.file_name, a.size, a.size_name, a.key
FROM attachments a
INNER JOIN ciphers c ON c.id = a.cipher_id
WHERE c.user_id = ?`
)
.bind(userId)
.all<any>();
for (const row of (res.results || [])) {
const item: Attachment = {
id: row.id,
cipherId: row.cipher_id,
fileName: row.file_name,
size: row.size,
sizeName: row.size_name,
key: row.key,
};
const list = grouped.get(item.cipherId);
if (list) {
list.push(item);
} else {
grouped.set(item.cipherId, [item]);
}
}
return grouped;
}
async addAttachmentToCipher(cipherId: string, attachmentId: string): Promise<void> { async addAttachmentToCipher(cipherId: string, attachmentId: string): Promise<void> {
// Kept for API compatibility; no-op because attachments table already links cipher_id. // Kept for API compatibility; no-op because attachments table already links cipher_id.
// We still validate that the attachment exists and belongs to cipher. // We still validate that the attachment exists and belongs to cipher.
@@ -440,21 +563,42 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
// --- Refresh tokens --- // --- Refresh tokens ---
async saveRefreshToken(token: string, userId: string, expiresAtMs?: number): Promise<void> { async saveRefreshToken(token: string, userId: string, expiresAtMs?: number): Promise<void> {
const expiresAt = expiresAtMs ?? (Date.now() + 30 * 24 * 60 * 60 * 1000); const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs);
await this.maybeCleanupExpiredRefreshTokens(Date.now());
const tokenKey = await this.refreshTokenKey(token);
await this.db.prepare( await this.db.prepare(
'INSERT INTO refresh_tokens(token, user_id, expires_at) VALUES(?, ?, ?) ' + 'INSERT INTO refresh_tokens(token, user_id, expires_at) VALUES(?, ?, ?) ' +
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at' 'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at'
) )
.bind(token, userId, expiresAt) .bind(tokenKey, userId, expiresAt)
.run(); .run();
} }
async getRefreshTokenUserId(token: string): Promise<string | null> { async getRefreshTokenUserId(token: string): Promise<string | null> {
const now = Date.now(); const now = Date.now();
const row = await this.db.prepare('SELECT user_id, expires_at FROM refresh_tokens WHERE token = ?') await this.maybeCleanupExpiredRefreshTokens(now);
.bind(token) const tokenKey = await this.refreshTokenKey(token);
let row = await this.db.prepare('SELECT user_id, expires_at FROM refresh_tokens WHERE token = ?')
.bind(tokenKey)
.first<{ user_id: string; expires_at: number }>(); .first<{ user_id: string; expires_at: number }>();
if (!row) {
const legacyRow = await this.db.prepare('SELECT user_id, expires_at FROM refresh_tokens WHERE token = ?')
.bind(token)
.first<{ user_id: string; expires_at: number }>();
if (legacyRow) {
if (legacyRow.expires_at && legacyRow.expires_at < now) {
await this.deleteRefreshToken(token);
return null;
}
await this.saveRefreshToken(token, legacyRow.user_id, legacyRow.expires_at);
await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
return legacyRow.user_id;
}
}
if (!row) return null; if (!row) return null;
if (row.expires_at && row.expires_at < now) { if (row.expires_at && row.expires_at < now) {
await this.deleteRefreshToken(token); await this.deleteRefreshToken(token);
@@ -464,7 +608,9 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
} }
async deleteRefreshToken(token: string): Promise<void> { async deleteRefreshToken(token: string): Promise<void> {
const tokenKey = await this.refreshTokenKey(token);
await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run(); await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run();
} }
// --- Revision dates --- // --- Revision dates ---
@@ -473,7 +619,17 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
const row = await this.db.prepare('SELECT revision_date FROM user_revisions WHERE user_id = ?') const row = await this.db.prepare('SELECT revision_date FROM user_revisions WHERE user_id = ?')
.bind(userId) .bind(userId)
.first<{ revision_date: string }>(); .first<{ revision_date: string }>();
return row?.revision_date || new Date().toISOString(); if (row?.revision_date) return row.revision_date;
const date = new Date().toISOString();
await this.db
.prepare(
'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' +
'ON CONFLICT(user_id) DO NOTHING'
)
.bind(userId, date)
.run();
return date;
} }
async updateRevisionDate(userId: string): Promise<string> { async updateRevisionDate(userId: string): Promise<string> {
@@ -486,4 +642,44 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
.run(); .run();
return date; return date;
} }
// --- One-time attachment download tokens ---
private async ensureUsedAttachmentDownloadTokenTable(): Promise<void> {
if (StorageService.attachmentTokenTableReady) return;
await this.db.prepare(
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
'jti TEXT PRIMARY KEY, ' +
'expires_at INTEGER NOT NULL' +
')'
).run();
StorageService.attachmentTokenTableReady = true;
}
// Marks an attachment download token JTI as consumed.
// Returns true only on first use. Reuse returns false.
async consumeAttachmentDownloadToken(jti: string, expUnixSeconds: number): Promise<boolean> {
await this.ensureUsedAttachmentDownloadTokenTable();
const nowMs = Date.now();
if (
this.shouldRunPeriodicCleanup(
StorageService.lastAttachmentTokenCleanupAt,
StorageService.ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS
)
) {
await this.db.prepare('DELETE FROM used_attachment_download_tokens WHERE expires_at < ?').bind(nowMs).run();
StorageService.lastAttachmentTokenCleanupAt = nowMs;
}
const expiresAtMs = expUnixSeconds * 1000;
const result = await this.db.prepare(
'INSERT INTO used_attachment_download_tokens(jti, expires_at) VALUES(?, ?) ' +
'ON CONFLICT(jti) DO NOTHING'
).bind(jti, expiresAtMs).run();
return (result.meta.changes ?? 0) > 0;
}
} }
File diff suppressed because it is too large Load Diff
+4
View File
@@ -134,6 +134,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
@@ -254,6 +256,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 {
+6 -3
View File
@@ -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();
+38
View File
@@ -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;
}
}
+69 -14
View File
@@ -1,10 +1,68 @@
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';
function isTrustedClientOrigin(origin: string): boolean {
// Official browser extension / desktop-webview common origins.
if (origin === 'null') return true;
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);
}
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 +98,19 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
); );
} }
// CORS headers
export function getCorsHeaders(): Record<string, string> {
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version',
'Access-Control-Max-Age': '86400',
};
}
// Handle CORS preflight // Handle CORS preflight
export function handleCors(): Response { export function handleCors(request: Request): Response {
const origin = request.headers.get('Origin');
if (origin) {
const allowedOrigin = getAllowedOrigin(request);
if (!allowedOrigin) {
return new Response(null, { status: 403 });
}
}
return new Response(null, { return new Response(null, {
status: 204, status: 204,
headers: getCorsHeaders(), headers: buildCorsHeaders(request),
}); });
} }
@@ -64,7 +120,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(),
}, },
}); });
} }
+1 -2
View File
@@ -1,4 +1,4 @@
name = "nodewarden-test" name = "nodewarden"
main = "src/index.ts" main = "src/index.ts"
compatibility_date = "2024-01-01" compatibility_date = "2024-01-01"
@@ -6,7 +6,6 @@ compatibility_date = "2024-01-01"
[[d1_databases]] [[d1_databases]]
binding = "DB" binding = "DB"
database_name = "nodewarden-db" database_name = "nodewarden-db"
database_id = "placeholde"
# R2 Bucket for storing attachments # R2 Bucket for storing attachments
[[r2_buckets]] [[r2_buckets]]