mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-21 05:10:41 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ec1ecf464 | |||
| b6d4113e21 | |||
| c53819e178 | |||
| fff2b149e9 | |||
| 50ee2e6b64 | |||
| 309cd98edc | |||
| 3fff6c0277 | |||
| ced0a183b3 | |||
| 4939df7fa2 | |||
| 6c3fbbe78c | |||
| 719024d0fd | |||
| ff7b44e501 | |||
| 4772c17e44 | |||
| b33ee64c58 | |||
| c825280707 | |||
| c445714fd5 | |||
| f2a857d3f3 | |||
| 435a21072c | |||
| 70a58aeb04 | |||
| 866ffb8390 | |||
| d2ce2aea24 | |||
| 5fc2436552 |
+3
-1
@@ -1,3 +1,5 @@
|
|||||||
# JWT Secret for signing tokens (required)
|
# JWT Secret for signing tokens (required)
|
||||||
|
# IMPORTANT: change this value before any real deployment.
|
||||||
# Generate one with: openssl rand -hex 32
|
# Generate one with: openssl rand -hex 32
|
||||||
JWT_SECRET=your-secret-key-herexxs22fd2ds
|
# (Example only, 64 hex chars = 32 bytes)
|
||||||
|
JWT_SECRET=Enter-your-JWT-key-here-at-least-32-characters
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: Sync upstream
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- run: |
|
||||||
|
git remote add upstream https://github.com/shuaiplus/nodewarden.git || true
|
||||||
|
git fetch upstream
|
||||||
|
|
||||||
|
# 强制让当前分支完全等于 upstream
|
||||||
|
git reset --hard upstream/main
|
||||||
|
|
||||||
|
# 强制推送
|
||||||
|
git push origin main --force
|
||||||
@@ -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**, designed for personal use.
|
运行在 **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 定位单用户 |
|
||||||
|
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 |
|
||||||
|
| 完整 2FA(TOTP/WebAuthn/Duo/Email) | ✅ | ❌ | 没必要实现 |
|
||||||
|
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
|
||||||
|
| Send | ✅ | ❌ | 基本没人用 |
|
||||||
|
| 紧急访问 | ✅ | ❌ | 没必要实现 |
|
||||||
|
| 管理后台 / 计费订阅 | ✅ | ❌ | 纯免费 |
|
||||||
|
| 推送通知完整链路 | ✅ | ❌ | 没必要实现 |
|
||||||
|
|
||||||
|
## 测试情况:
|
||||||
|
|
||||||
|
- ✅ Windows 客户端(v2026.1.0)
|
||||||
|
- ✅ 手机 App(v2026.1.0)
|
||||||
|
- ✅ 浏览器扩展(v2026.1.0)
|
||||||
|
- ⬜ macOS 客户端(未测试)
|
||||||
|
- ⬜ Linux 客户端(未测试)
|
||||||
|
---
|
||||||
|
|
||||||
|
# 快速开始
|
||||||
|
|
||||||
|
### 一键部署
|
||||||
|
|
||||||
|
**部署步骤:**
|
||||||
|
|
||||||
|
1. 先在右上角fork此项目(若后续不需要更新,可不fork)
|
||||||
|
2. [](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 can’t see plaintext)
|
|
||||||
- ✅ Compatible with common Bitwarden official clients
|
|
||||||
|
|
||||||
## Tested clients / platforms
|
|
||||||
|
|
||||||
- ✅ Windows desktop client(v2026.1.0)
|
|
||||||
- ✅ Android app (v2026.1.0)
|
|
||||||
- ✅ Browser extension(v2026.1.0)
|
|
||||||
- ⬜ macOS desktop client (not tested)
|
|
||||||
- ⬜ Linux desktop client (not tested)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Quick start
|
|
||||||
|
|
||||||
### One-click deploy
|
|
||||||
|
|
||||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
|
|
||||||
|
|
||||||
**Deploy steps:**
|
|
||||||
|
|
||||||
1. Sign in with GitHub and authorize
|
|
||||||
2. Sign in to Cloudflare
|
|
||||||
3. **Important**: set `JWT_SECRET` to a strong random string (recommended: `openssl rand -hex 32`)
|
|
||||||
4. KV namespace and R2 bucket will be created automatically
|
|
||||||
5. Click **Deploy** and wait for it to finish
|
|
||||||
6. After deploy, open the Cloudflare-provided Workers URL (your service URL), and register on the web page
|
|
||||||
|
|
||||||
> ⚠️ **Reminder**: always use a strong random `JWT_SECRET`. Weak secrets may put your account at risk.
|
|
||||||
|
|
||||||
### Configure your client
|
|
||||||
|
|
||||||
In any Bitwarden client:
|
|
||||||
|
|
||||||
1. Open **Settings**
|
|
||||||
2. Choose **Self-hosted environment**
|
|
||||||
3. Set **Server URL** to your Worker URL (for example: `https://your-project.your-subdomain.workers.dev`)
|
|
||||||
4. Save, then go back to the login screen
|
|
||||||
|
|
||||||
## 🧑💻 Local development
|
|
||||||
|
|
||||||
This repo is a Cloudflare Workers TypeScript project (Wrangler).
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
@@ -72,37 +59,28 @@ npm run dev
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tech stack
|
## 常见问题
|
||||||
|
|
||||||
- **Runtime**: Cloudflare Workers
|
**Q: 如何备份数据?**
|
||||||
- **Data storage**: Cloudflare KV
|
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 can’t be recovered (end-to-end encryption). Keep it safe.
|
|
||||||
|
|
||||||
**Q: Can multiple people use it?**
|
|
||||||
A: Not recommended. This project is designed for single-user usage.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
LGPL-3.0 License
|
LGPL-3.0 License
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Credits
|
## 致谢
|
||||||
|
|
||||||
|
- [Bitwarden](https://bitwarden.com/) - 原始设计和客户端
|
||||||
|
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务器实现参考
|
||||||
|
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
||||||
|
|
||||||
- [Bitwarden](https://bitwarden.com/) - original design and clients
|
|
||||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - server implementation reference
|
|
||||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - serverless platform
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# 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. [](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 can’t be recovered (end-to-end encryption). Keep it safe.
|
||||||
|
|
||||||
|
**Q: Can multiple people use it?**
|
||||||
|
A: Not recommended. This project is designed for single-user usage. 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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-114
@@ -1,114 +0,0 @@
|
|||||||
|
|
||||||
# NodeWarden
|
|
||||||
English:[`README.md`](./README.md)
|
|
||||||
|
|
||||||
一个运行在 **Cloudflare Workers** 上的 **Bitwarden 兼容**服务端实现,面向个人使用场景。
|
|
||||||
|
|
||||||
- 部署简单(不需要 VPS)
|
|
||||||
- 功能聚焦
|
|
||||||
- 维护成本低
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
> **免责声明**
|
|
||||||
> 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份您的密码库。
|
|
||||||
> 本项目与 Bitwarden 官方无关,请勿向 Bitwarden 官方反馈问题。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 特性
|
|
||||||
- ✅ **完全免费,不需要在服务器上部署,再次感谢大善人!**
|
|
||||||
- ✅ 完整的密码、笔记、卡片、身份信息管理
|
|
||||||
- ✅ 文件夹和收藏功能
|
|
||||||
- ✅ 文件附件支持(基于 R2 存储)
|
|
||||||
- ✅ 导入/导出功能
|
|
||||||
- ✅ 网站图标获取
|
|
||||||
- ✅ 端到端加密(服务器无法查看明文)
|
|
||||||
- ✅ 兼容常见的 Bitwarden 官方客户端
|
|
||||||
|
|
||||||
## 测试情况:
|
|
||||||
- ✅ Windows 客户端(v2026.1.0)
|
|
||||||
- ✅ Android App(v2026.1.0)
|
|
||||||
- ✅ 浏览器扩展(v2026.1.0)
|
|
||||||
- ⬜ macOS 客户端(未测试)
|
|
||||||
- ⬜ Linux 客户端(未测试)
|
|
||||||
---
|
|
||||||
|
|
||||||
# 快速开始
|
|
||||||
|
|
||||||
### 一键部署
|
|
||||||
|
|
||||||
点击下方按钮部署到 Cloudflare Workers:
|
|
||||||
|
|
||||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
|
|
||||||
|
|
||||||
**部署步骤:**
|
|
||||||
|
|
||||||
1. 使用 GitHub 登录并授权
|
|
||||||
2. 登录 Cloudflare 账户
|
|
||||||
3. **重要**:设置 `JWT_SECRET` 为强随机字符串(推荐使用 `openssl rand -hex 32` 生成)
|
|
||||||
4. KV 存储和 R2 存储桶将自动创建
|
|
||||||
5. 点击 Deploy 等待部署完成
|
|
||||||
6. 部署完成后,先打开 Cloudflare 给你的 Workers 链接(也就是你的服务地址),在网页上填写信息完成注册。
|
|
||||||
|
|
||||||
> ⚠️ **再次提醒**:请务必使用强随机的 `JWT_SECRET`,使用默认或弱密钥可能导致账户被入侵,**后果自负!**
|
|
||||||
|
|
||||||
### 配置客户端
|
|
||||||
|
|
||||||
部署完成后,在任意 Bitwarden 客户端中:
|
|
||||||
|
|
||||||
1. 打开设置(⚙️)
|
|
||||||
2. 选择「自托管环境」
|
|
||||||
3. 服务器 URL 填入:`https://你的项目名`
|
|
||||||
4. 保存并返回登录页面
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 本地开发
|
|
||||||
|
|
||||||
这是一个 Cloudflare Workers 的 TypeScript 项目(Wrangler)。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
- **运行环境**:Cloudflare Workers
|
|
||||||
- **数据存储**:Cloudflare KV
|
|
||||||
- **文件存储**:Cloudflare R2
|
|
||||||
- **开发语言**:TypeScript
|
|
||||||
- **加密算法**:客户端 AES-256-CBC,JWT 使用 HS256
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
**Q: 如何备份数据?**
|
|
||||||
A: 在客户端中选择「导出密码库」,保存 JSON 文件。
|
|
||||||
|
|
||||||
**Q: 忘记主密码怎么办?**
|
|
||||||
A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。
|
|
||||||
|
|
||||||
**Q: 可以多人使用吗?**
|
|
||||||
A: 不建议。本项目为单用户设计,多人使用请选择 Vaultwarden。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 开源协议
|
|
||||||
|
|
||||||
LGPL-3.0 License
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 致谢
|
|
||||||
|
|
||||||
- [Bitwarden](https://bitwarden.com/) - 原始设计和客户端
|
|
||||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务器实现参考
|
|
||||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Per-user sync revision date
|
||||||
|
CREATE TABLE IF NOT EXISTS user_revisions (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
revision_date TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ciphers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
type INTEGER NOT NULL,
|
||||||
|
folder_id TEXT,
|
||||||
|
name TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
favorite INTEGER NOT NULL DEFAULT 0,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
reprompt INTEGER,
|
||||||
|
key TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
deleted_at TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS folders (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS attachments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
cipher_id TEXT NOT NULL,
|
||||||
|
file_name TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
size_name TEXT NOT NULL,
|
||||||
|
key TEXT,
|
||||||
|
FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS 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);
|
||||||
|
|
||||||
|
-- Rate limiting
|
||||||
|
CREATE TABLE IF NOT EXISTS login_attempts_ip (
|
||||||
|
ip TEXT PRIMARY KEY,
|
||||||
|
attempts INTEGER NOT NULL,
|
||||||
|
locked_until INTEGER,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS api_rate_limits (
|
||||||
|
identifier TEXT NOT NULL,
|
||||||
|
window_start INTEGER NOT NULL,
|
||||||
|
count INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (identifier, window_start)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (
|
||||||
|
jti TEXT PRIMARY KEY,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
Generated
+65
-3
@@ -1,15 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.0.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.0.0",
|
"version": "0.2.0",
|
||||||
"license": "ISC",
|
"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",
|
||||||
|
|||||||
+10
-8
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||||
"author": "shuaiplus",
|
"author": "shuaiplus",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "wrangler dev -c wrangler.dev.toml",
|
"dev": "wrangler dev -c wrangler.toml",
|
||||||
"deploymy": "wrangler deploy -c wrangler.my.toml",
|
"deploymy": "wrangler deploy -c wrangler.my.toml",
|
||||||
"deploy": "wrangler deploy "
|
"deploy": "wrangler deploy",
|
||||||
|
"selfcheck": "npx tsx tests/selfcheck.ts"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"bitwarden",
|
"bitwarden",
|
||||||
@@ -21,20 +22,21 @@
|
|||||||
"cloudflare": {
|
"cloudflare": {
|
||||||
"bindings": {
|
"bindings": {
|
||||||
"JWT_SECRET": {
|
"JWT_SECRET": {
|
||||||
"description": "用于签名 JWT 的密钥。请输入一个随机的复杂字符串(建议 32 位以上)"
|
"description": "Secret used to sign JWTs. Use a strong random string (32+ characters recommended)"
|
||||||
},
|
},
|
||||||
"VAULT": {
|
"DB": {
|
||||||
"description": "用于存储密码库数据的 KV 存储"
|
"description": "D1 database for storing vault data"
|
||||||
},
|
},
|
||||||
"ATTACHMENTS": {
|
"ATTACHMENTS": {
|
||||||
"description": "用于存储文件附件的 R2 存储桶"
|
"description": "R2 bucket for storing file attachments"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20260131.0",
|
"@cloudflare/workers-types": "^4.20260131.0",
|
||||||
|
"@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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: '2025.12.0',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
+56
-14
@@ -1,17 +1,41 @@
|
|||||||
import { Env, User, ProfileResponse } from '../types';
|
import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { AuthService } from '../services/auth';
|
import { AuthService } from '../services/auth';
|
||||||
import { 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 {
|
||||||
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
|
if (!secret) return 'missing';
|
||||||
|
if (secret === DEFAULT_DEV_SECRET) return 'default';
|
||||||
|
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/accounts/register (only used from setup page, not client)
|
// POST /api/accounts/register (only used from setup page, not client)
|
||||||
export async function handleRegister(request: Request, env: Env): Promise<Response> {
|
export async function handleRegister(request: Request, env: Env): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
// Check if already registered
|
// Enforce safe JWT_SECRET before allowing first registration.
|
||||||
const isRegistered = await storage.isRegistered();
|
const unsafe = jwtSecretUnsafeReason(env);
|
||||||
if (isRegistered) {
|
if (unsafe) {
|
||||||
return errorResponse('Registration is closed', 403);
|
const message = unsafe === 'missing'
|
||||||
|
? 'JWT_SECRET is not set'
|
||||||
|
: unsafe === 'default'
|
||||||
|
? 'JWT_SECRET is using the default/sample value. Please change it.'
|
||||||
|
: 'JWT_SECRET must be at least 32 characters';
|
||||||
|
return errorResponse(message, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: {
|
let body: {
|
||||||
@@ -50,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 = {
|
||||||
@@ -61,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(),
|
||||||
@@ -69,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);
|
||||||
@@ -77,7 +111,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
|
|
||||||
// GET /api/accounts/profile
|
// GET /api/accounts/profile
|
||||||
export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -113,7 +147,7 @@ export async function handleGetProfile(request: Request, env: Env, userId: strin
|
|||||||
|
|
||||||
// PUT /api/accounts/profile
|
// PUT /api/accounts/profile
|
||||||
export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -139,7 +173,7 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
|
|||||||
|
|
||||||
// POST /api/accounts/keys
|
// POST /api/accounts/keys
|
||||||
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -161,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);
|
||||||
@@ -170,7 +210,7 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
|
|||||||
|
|
||||||
// GET /api/accounts/revision-date
|
// GET /api/accounts/revision-date
|
||||||
export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const revisionDate = await storage.getRevisionDate(userId);
|
const revisionDate = await storage.getRevisionDate(userId);
|
||||||
|
|
||||||
// Return as milliseconds timestamp (Bitwarden format)
|
// Return as milliseconds timestamp (Bitwarden format)
|
||||||
@@ -180,7 +220,8 @@ export async function handleGetRevisionDate(request: Request, env: Env, userId:
|
|||||||
|
|
||||||
// POST /api/accounts/verify-password
|
// POST /api/accounts/verify-password
|
||||||
export async function handleVerifyPassword(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleVerifyPassword(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
const auth = new AuthService(env);
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -198,7 +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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-56
@@ -1,8 +1,10 @@
|
|||||||
import { Env, Attachment, Cipher } from '../types';
|
import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
|
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
|
||||||
|
import { cipherToResponse } from './ciphers';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
// Format file size to human readable
|
// Format file size to human readable
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
@@ -25,7 +27,7 @@ export async function handleCreateAttachment(
|
|||||||
userId: string,
|
userId: string,
|
||||||
cipherId: string
|
cipherId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
// Verify cipher exists and belongs to user
|
// Verify cipher exists and belongs to user
|
||||||
const cipher = await storage.getCipher(cipherId);
|
const cipher = await storage.getCipher(cipherId);
|
||||||
@@ -80,12 +82,12 @@ export async function handleCreateAttachment(
|
|||||||
attachmentId: attachmentId,
|
attachmentId: attachmentId,
|
||||||
url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`,
|
url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`,
|
||||||
fileUploadType: 0, // Direct upload
|
fileUploadType: 0, // Direct upload
|
||||||
cipherResponse: formatCipherResponse(updatedCipher!, attachments),
|
cipherResponse: cipherToResponse(updatedCipher!, attachments),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maximum file size: 100MB
|
// Maximum file size: 100MB
|
||||||
const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
const MAX_FILE_SIZE = LIMITS.attachment.maxFileSizeBytes;
|
||||||
|
|
||||||
// POST /api/ciphers/{cipherId}/attachment/{attachmentId}
|
// POST /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||||
// Upload attachment file content
|
// Upload attachment file content
|
||||||
@@ -96,7 +98,7 @@ export async function handleUploadAttachment(
|
|||||||
cipherId: string,
|
cipherId: string,
|
||||||
attachmentId: string
|
attachmentId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
// Verify cipher exists and belongs to user
|
// Verify cipher exists and belongs to user
|
||||||
const cipher = await storage.getCipher(cipherId);
|
const cipher = await storage.getCipher(cipherId);
|
||||||
@@ -169,7 +171,7 @@ export async function handleGetAttachment(
|
|||||||
cipherId: string,
|
cipherId: string,
|
||||||
attachmentId: string
|
attachmentId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
// Verify cipher exists and belongs to user
|
// Verify cipher exists and belongs to user
|
||||||
const cipher = await storage.getCipher(cipherId);
|
const cipher = await storage.getCipher(cipherId);
|
||||||
@@ -209,6 +211,11 @@ export async function handlePublicDownloadAttachment(
|
|||||||
cipherId: string,
|
cipherId: string,
|
||||||
attachmentId: string
|
attachmentId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
|
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||||
|
return errorResponse('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const token = url.searchParams.get('token');
|
const token = url.searchParams.get('token');
|
||||||
|
|
||||||
@@ -227,7 +234,7 @@ export async function handlePublicDownloadAttachment(
|
|||||||
return errorResponse('Token mismatch', 401);
|
return errorResponse('Token mismatch', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
// Verify attachment exists
|
// Verify attachment exists
|
||||||
const attachment = await storage.getAttachment(attachmentId);
|
const attachment = await storage.getAttachment(attachmentId);
|
||||||
@@ -243,12 +250,16 @@ export async function handlePublicDownloadAttachment(
|
|||||||
return errorResponse('Attachment file not found', 404);
|
return errorResponse('Attachment file not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp);
|
||||||
|
if (!firstUse) {
|
||||||
|
return errorResponse('Invalid or expired token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(object.body, {
|
return new Response(object.body, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': 'application/octet-stream',
|
||||||
'Content-Length': String(object.size),
|
'Content-Length': String(object.size),
|
||||||
'Cache-Control': 'private, no-cache',
|
'Cache-Control': 'private, no-cache',
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -262,7 +273,7 @@ export async function handleDeleteAttachment(
|
|||||||
cipherId: string,
|
cipherId: string,
|
||||||
attachmentId: string
|
attachmentId: string
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
// Verify cipher exists and belongs to user
|
// Verify cipher exists and belongs to user
|
||||||
const cipher = await storage.getCipher(cipherId);
|
const cipher = await storage.getCipher(cipherId);
|
||||||
@@ -294,61 +305,16 @@ export async function handleDeleteAttachment(
|
|||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
cipher: formatCipherResponse(updatedCipher!, attachments),
|
cipher: cipherToResponse(updatedCipher!, attachments),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Format cipher response with attachments
|
|
||||||
function formatCipherResponse(cipher: Cipher, attachments: Attachment[]): any {
|
|
||||||
return {
|
|
||||||
id: cipher.id,
|
|
||||||
organizationId: null,
|
|
||||||
folderId: cipher.folderId,
|
|
||||||
type: Number(cipher.type) || 1,
|
|
||||||
name: cipher.name,
|
|
||||||
notes: cipher.notes,
|
|
||||||
favorite: cipher.favorite,
|
|
||||||
login: cipher.login,
|
|
||||||
card: cipher.card,
|
|
||||||
identity: cipher.identity,
|
|
||||||
secureNote: cipher.secureNote,
|
|
||||||
sshKey: cipher.sshKey,
|
|
||||||
fields: cipher.fields,
|
|
||||||
passwordHistory: cipher.passwordHistory,
|
|
||||||
reprompt: cipher.reprompt,
|
|
||||||
organizationUseTotp: false,
|
|
||||||
creationDate: cipher.createdAt,
|
|
||||||
revisionDate: cipher.updatedAt,
|
|
||||||
deletedDate: cipher.deletedAt,
|
|
||||||
archivedDate: null,
|
|
||||||
edit: true,
|
|
||||||
viewPassword: true,
|
|
||||||
permissions: {
|
|
||||||
delete: true,
|
|
||||||
restore: true,
|
|
||||||
},
|
|
||||||
object: 'cipher',
|
|
||||||
collectionIds: [],
|
|
||||||
attachments: attachments.length > 0 ? attachments.map(a => ({
|
|
||||||
id: a.id,
|
|
||||||
fileName: a.fileName,
|
|
||||||
size: Number(a.size) || 0,
|
|
||||||
sizeName: a.sizeName,
|
|
||||||
key: a.key,
|
|
||||||
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`,
|
|
||||||
object: 'attachment',
|
|
||||||
})) : null,
|
|
||||||
key: cipher.key,
|
|
||||||
encryptedFor: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete all attachments for a cipher (used when deleting cipher)
|
// Delete all attachments for a cipher (used when deleting cipher)
|
||||||
export async function deleteAllAttachmentsForCipher(
|
export async function deleteAllAttachmentsForCipher(
|
||||||
env: Env,
|
env: Env,
|
||||||
cipherId: string
|
cipherId: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
|
||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
|
|||||||
+61
-60
@@ -3,9 +3,10 @@ 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
|
||||||
function formatAttachments(attachments: Attachment[]): any[] | null {
|
export function formatAttachments(attachments: Attachment[]): any[] | null {
|
||||||
if (attachments.length === 0) return null;
|
if (attachments.length === 0) return null;
|
||||||
return attachments.map(a => ({
|
return attachments.map(a => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
@@ -18,28 +19,24 @@ function formatAttachments(attachments: Attachment[]): any[] | null {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert internal cipher to API response format
|
// Convert internal cipher to API response format.
|
||||||
function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse {
|
// Uses opaque passthrough: spreads ALL stored fields (including unknown/future ones),
|
||||||
|
// then overlays server-computed fields. This ensures new Bitwarden client fields
|
||||||
|
// survive a round-trip without code changes.
|
||||||
|
export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse {
|
||||||
|
// Strip internal-only fields that must not appear in the API response
|
||||||
|
const { userId, createdAt, updatedAt, deletedAt, ...passthrough } = cipher;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: cipher.id,
|
// Pass through ALL stored cipher fields (known + unknown)
|
||||||
organizationId: null,
|
...passthrough,
|
||||||
folderId: cipher.folderId,
|
// Server-computed / enforced fields (always override)
|
||||||
type: Number(cipher.type) || 1,
|
type: Number(cipher.type) || 1,
|
||||||
name: cipher.name,
|
organizationId: null,
|
||||||
notes: cipher.notes,
|
|
||||||
favorite: cipher.favorite,
|
|
||||||
login: cipher.login,
|
|
||||||
card: cipher.card,
|
|
||||||
identity: cipher.identity,
|
|
||||||
secureNote: cipher.secureNote,
|
|
||||||
sshKey: cipher.sshKey,
|
|
||||||
fields: cipher.fields,
|
|
||||||
passwordHistory: cipher.passwordHistory,
|
|
||||||
reprompt: cipher.reprompt,
|
|
||||||
organizationUseTotp: false,
|
organizationUseTotp: false,
|
||||||
creationDate: cipher.createdAt,
|
creationDate: createdAt,
|
||||||
revisionDate: cipher.updatedAt,
|
revisionDate: updatedAt,
|
||||||
deletedDate: cipher.deletedAt,
|
deletedDate: deletedAt,
|
||||||
archivedDate: null,
|
archivedDate: null,
|
||||||
edit: true,
|
edit: true,
|
||||||
viewPassword: true,
|
viewPassword: true,
|
||||||
@@ -50,41 +47,55 @@ function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): Ciphe
|
|||||||
object: 'cipher',
|
object: 'cipher',
|
||||||
collectionIds: [],
|
collectionIds: [],
|
||||||
attachments: formatAttachments(attachments),
|
attachments: formatAttachments(attachments),
|
||||||
key: cipher.key,
|
|
||||||
encryptedFor: null,
|
encryptedFor: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/ciphers
|
// GET /api/ciphers
|
||||||
export async function handleGetCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleGetCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const ciphers = await storage.getAllCiphers(userId);
|
|
||||||
|
|
||||||
// Filter out soft-deleted ciphers unless specifically requested
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const includeDeleted = url.searchParams.get('deleted') === 'true';
|
const includeDeleted = url.searchParams.get('deleted') === 'true';
|
||||||
|
const pagination = parsePagination(url);
|
||||||
|
|
||||||
const filteredCiphers = includeDeleted
|
let filteredCiphers: Cipher[];
|
||||||
|
let continuationToken: string | null = null;
|
||||||
|
if (pagination) {
|
||||||
|
const pageRows = await storage.getCiphersPage(
|
||||||
|
userId,
|
||||||
|
includeDeleted,
|
||||||
|
pagination.limit + 1,
|
||||||
|
pagination.offset
|
||||||
|
);
|
||||||
|
const hasNext = pageRows.length > pagination.limit;
|
||||||
|
filteredCiphers = hasNext ? pageRows.slice(0, pagination.limit) : pageRows;
|
||||||
|
continuationToken = hasNext ? encodeContinuationToken(pagination.offset + filteredCiphers.length) : null;
|
||||||
|
} else {
|
||||||
|
const ciphers = await storage.getAllCiphers(userId);
|
||||||
|
filteredCiphers = includeDeleted
|
||||||
? ciphers
|
? ciphers
|
||||||
: ciphers.filter(c => !c.deletedAt);
|
: ciphers.filter(c => !c.deletedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(filteredCiphers.map(c => c.id));
|
||||||
|
|
||||||
// Get attachments for all ciphers
|
// Get attachments for all ciphers
|
||||||
const cipherResponses = [];
|
const cipherResponses = [];
|
||||||
for (const cipher of filteredCiphers) {
|
for (const cipher of filteredCiphers) {
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
data: cipherResponses,
|
data: cipherResponses,
|
||||||
object: 'list',
|
object: 'list',
|
||||||
continuationToken: null,
|
continuationToken: continuationToken,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/ciphers/:id
|
// GET /api/ciphers/:id
|
||||||
export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const cipher = await storage.getCipher(id);
|
const cipher = await storage.getCipher(id);
|
||||||
|
|
||||||
if (!cipher || cipher.userId !== userId) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
@@ -97,7 +108,7 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
|
|||||||
|
|
||||||
// POST /api/ciphers
|
// POST /api/ciphers
|
||||||
export async function handleCreateCipher(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleCreateCipher(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
let body: any;
|
let body: any;
|
||||||
try {
|
try {
|
||||||
@@ -111,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,
|
|
||||||
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,
|
||||||
@@ -141,7 +145,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
|
|
||||||
// PUT /api/ciphers/:id
|
// PUT /api/ciphers/:id
|
||||||
export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const existingCipher = await storage.getCipher(id);
|
const existingCipher = await storage.getCipher(id);
|
||||||
|
|
||||||
if (!existingCipher || existingCipher.userId !== userId) {
|
if (!existingCipher || existingCipher.userId !== userId) {
|
||||||
@@ -159,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);
|
||||||
@@ -186,7 +187,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
|
|
||||||
// DELETE /api/ciphers/:id
|
// DELETE /api/ciphers/:id
|
||||||
export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const cipher = await storage.getCipher(id);
|
const cipher = await storage.getCipher(id);
|
||||||
|
|
||||||
if (!cipher || cipher.userId !== userId) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
@@ -204,7 +205,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
|||||||
|
|
||||||
// DELETE /api/ciphers/:id (permanent)
|
// DELETE /api/ciphers/:id (permanent)
|
||||||
export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const cipher = await storage.getCipher(id);
|
const cipher = await storage.getCipher(id);
|
||||||
|
|
||||||
if (!cipher || cipher.userId !== userId) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
@@ -222,7 +223,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
|
|||||||
|
|
||||||
// PUT /api/ciphers/:id/restore
|
// PUT /api/ciphers/:id/restore
|
||||||
export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const cipher = await storage.getCipher(id);
|
const cipher = await storage.getCipher(id);
|
||||||
|
|
||||||
if (!cipher || cipher.userId !== userId) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
@@ -239,7 +240,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
|||||||
|
|
||||||
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
|
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
|
||||||
export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const cipher = await storage.getCipher(id);
|
const cipher = await storage.getCipher(id);
|
||||||
|
|
||||||
if (!cipher || cipher.userId !== userId) {
|
if (!cipher || cipher.userId !== userId) {
|
||||||
@@ -269,7 +270,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
|||||||
|
|
||||||
// POST/PUT /api/ciphers/move - Bulk move to folder
|
// POST/PUT /api/ciphers/move - Bulk move to folder
|
||||||
export async function handleBulkMoveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleBulkMoveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
let body: { ids?: string[]; folderId?: string | null };
|
let body: { ids?: string[]; folderId?: string | null };
|
||||||
try {
|
try {
|
||||||
|
|||||||
+24
-7
@@ -2,6 +2,7 @@ import { Env, Folder, FolderResponse } from '../types';
|
|||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||||
|
|
||||||
// Convert internal folder to API response format
|
// Convert internal folder to API response format
|
||||||
function folderToResponse(folder: Folder): FolderResponse {
|
function folderToResponse(folder: Folder): FolderResponse {
|
||||||
@@ -15,19 +16,31 @@ function folderToResponse(folder: Folder): FolderResponse {
|
|||||||
|
|
||||||
// GET /api/folders
|
// GET /api/folders
|
||||||
export async function handleGetFolders(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleGetFolders(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const folders = await storage.getAllFolders(userId);
|
const url = new URL(request.url);
|
||||||
|
const pagination = parsePagination(url);
|
||||||
|
|
||||||
|
let folders: Folder[];
|
||||||
|
let continuationToken: string | null = null;
|
||||||
|
if (pagination) {
|
||||||
|
const pageRows = await storage.getFoldersPage(userId, pagination.limit + 1, pagination.offset);
|
||||||
|
const hasNext = pageRows.length > pagination.limit;
|
||||||
|
folders = hasNext ? pageRows.slice(0, pagination.limit) : pageRows;
|
||||||
|
continuationToken = hasNext ? encodeContinuationToken(pagination.offset + folders.length) : null;
|
||||||
|
} else {
|
||||||
|
folders = await storage.getAllFolders(userId);
|
||||||
|
}
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
data: folders.map(folderToResponse),
|
data: folders.map(folderToResponse),
|
||||||
object: 'list',
|
object: 'list',
|
||||||
continuationToken: null,
|
continuationToken: continuationToken,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/folders/:id
|
// GET /api/folders/:id
|
||||||
export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const folder = await storage.getFolder(id);
|
const folder = await storage.getFolder(id);
|
||||||
|
|
||||||
if (!folder || folder.userId !== userId) {
|
if (!folder || folder.userId !== userId) {
|
||||||
@@ -39,7 +52,7 @@ export async function handleGetFolder(request: Request, env: Env, userId: string
|
|||||||
|
|
||||||
// POST /api/folders
|
// POST /api/folders
|
||||||
export async function handleCreateFolder(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleCreateFolder(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
let body: { name?: string };
|
let body: { name?: string };
|
||||||
try {
|
try {
|
||||||
@@ -62,13 +75,14 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
|
|||||||
};
|
};
|
||||||
|
|
||||||
await storage.saveFolder(folder);
|
await storage.saveFolder(folder);
|
||||||
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
return jsonResponse(folderToResponse(folder), 200);
|
return jsonResponse(folderToResponse(folder), 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/folders/:id
|
// PUT /api/folders/:id
|
||||||
export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const folder = await storage.getFolder(id);
|
const folder = await storage.getFolder(id);
|
||||||
|
|
||||||
if (!folder || folder.userId !== userId) {
|
if (!folder || folder.userId !== userId) {
|
||||||
@@ -88,20 +102,23 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
|
|||||||
folder.updatedAt = new Date().toISOString();
|
folder.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
await storage.saveFolder(folder);
|
await storage.saveFolder(folder);
|
||||||
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
return jsonResponse(folderToResponse(folder));
|
return jsonResponse(folderToResponse(folder));
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/folders/:id
|
// DELETE /api/folders/:id
|
||||||
export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const folder = await storage.getFolder(id);
|
const folder = await storage.getFolder(id);
|
||||||
|
|
||||||
if (!folder || folder.userId !== userId) {
|
if (!folder || folder.userId !== userId) {
|
||||||
return errorResponse('Folder not found', 404);
|
return errorResponse('Folder not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await storage.clearFolderFromCiphers(userId, id);
|
||||||
await storage.deleteFolder(id, userId);
|
await storage.deleteFolder(id, userId);
|
||||||
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-21
@@ -1,24 +1,28 @@
|
|||||||
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> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const auth = new AuthService(env);
|
const auth = new AuthService(env);
|
||||||
const rateLimit = new RateLimitService(env.VAULT);
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
|
|
||||||
let body: Record<string, string>;
|
let body: Record<string, string>;
|
||||||
const contentType = request.headers.get('content-type') || '';
|
const contentType = request.headers.get('content-type') || '';
|
||||||
|
try {
|
||||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
body = Object.fromEntries(formData.entries()) as Record<string, string>;
|
body = Object.fromEntries(formData.entries()) as Record<string, string>;
|
||||||
} else {
|
} else {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
return identityErrorResponse('Invalid request payload', 'invalid_request', 400);
|
||||||
|
}
|
||||||
|
|
||||||
const grantType = body.grant_type;
|
const grantType = body.grant_type;
|
||||||
|
|
||||||
@@ -26,18 +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) {
|
||||||
return errorResponse('Email and password are required', 400);
|
// Bitwarden clients expect OAuth-style error fields.
|
||||||
|
return identityErrorResponse('Email and password are required', 'invalid_request', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await storage.getUser(email);
|
// Check login lockout before user lookup to reduce user-enumeration signal
|
||||||
if (!user) {
|
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
|
||||||
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if login is rate limited (only after confirming user exists)
|
|
||||||
const loginCheck = await rateLimit.checkLoginAttempt(email);
|
|
||||||
if (!loginCheck.allowed) {
|
if (!loginCheck.allowed) {
|
||||||
return identityErrorResponse(
|
return identityErrorResponse(
|
||||||
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
|
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
|
||||||
@@ -46,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.`,
|
||||||
@@ -61,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,
|
||||||
@@ -92,7 +99,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
Parallelism: user.kdfParallelism || null,
|
Parallelism: user.kdfParallelism || null,
|
||||||
},
|
},
|
||||||
MasterKeyEncryptedUserKey: user.key,
|
MasterKeyEncryptedUserKey: user.key,
|
||||||
|
MasterKeyWrappedUserKey: user.key,
|
||||||
Salt: email, // email is already lowercased above
|
Salt: email, // email is already lowercased above
|
||||||
|
Object: 'masterPasswordUnlock',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -103,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)
|
||||||
@@ -119,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,
|
||||||
@@ -143,7 +152,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
Parallelism: user.kdfParallelism || null,
|
Parallelism: user.kdfParallelism || null,
|
||||||
},
|
},
|
||||||
MasterKeyEncryptedUserKey: user.key,
|
MasterKeyEncryptedUserKey: user.key,
|
||||||
|
MasterKeyWrappedUserKey: user.key,
|
||||||
Salt: user.email.toLowerCase(),
|
Salt: user.email.toLowerCase(),
|
||||||
|
Object: 'masterPasswordUnlock',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -151,12 +162,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
return jsonResponse(response);
|
return jsonResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorResponse('Unsupported grant type', 400);
|
return identityErrorResponse('Unsupported grant type', 'unsupported_grant_type', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /identity/accounts/prelogin
|
// POST /identity/accounts/prelogin
|
||||||
export async function handlePrelogin(request: Request, env: Env): Promise<Response> {
|
export async function handlePrelogin(request: Request, env: Env): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
let body: { email?: string };
|
let body: { email?: string };
|
||||||
try {
|
try {
|
||||||
@@ -174,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;
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ interface CiphersImportRequest {
|
|||||||
|
|
||||||
// POST /api/ciphers/import - Bitwarden client import endpoint
|
// POST /api/ciphers/import - Bitwarden client import endpoint
|
||||||
export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
let importData: CiphersImportRequest;
|
let importData: CiphersImportRequest;
|
||||||
try {
|
try {
|
||||||
|
|||||||
+34
-772
@@ -1,783 +1,45 @@
|
|||||||
import { Env } 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 { renderRegisterPageHTML } from '../setup/pageTemplate';
|
||||||
// Setup page HTML (single-file, no external assets)
|
import { LIMITS } from '../config/limits';
|
||||||
const setupPageHTML = `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
type JwtSecretState = 'missing' | 'default' | 'too_short';
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
function getJwtSecretState(env: Env): JwtSecretState | null {
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
<title>NodeWarden</title>
|
if (!secret) return 'missing';
|
||||||
<style>
|
// Block common "forgot to change" sample value (matches .dev.vars.example)
|
||||||
:root {
|
if (secret === DEFAULT_DEV_SECRET) return 'default';
|
||||||
color-scheme: light;
|
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
|
||||||
--bg0: #0b0b0f;
|
return null;
|
||||||
--bg1: #0f1020;
|
}
|
||||||
--card: rgba(255, 255, 255, 0.08);
|
|
||||||
--card2: rgba(255, 255, 255, 0.06);
|
async function handleRegisterPage(request: Request, env: Env, jwtState: JwtSecretState | null): Promise<Response> {
|
||||||
--border: rgba(255, 255, 255, 0.14);
|
const storage = new StorageService(env.DB);
|
||||||
--text: rgba(255, 255, 255, 0.92);
|
|
||||||
--muted: rgba(255, 255, 255, 0.62);
|
|
||||||
--muted2: rgba(255, 255, 255, 0.52);
|
|
||||||
--accent: #0a84ff;
|
|
||||||
--accent2: #64d2ff;
|
|
||||||
--danger: #ff453a;
|
|
||||||
--ok: #32d74b;
|
|
||||||
--shadow: 0 16px 60px rgba(0, 0, 0, 0.50);
|
|
||||||
--radius: 18px;
|
|
||||||
--radius2: 14px;
|
|
||||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
html, body { height: 100%; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
background:
|
|
||||||
radial-gradient(900px 600px at 15% 10%, rgba(100, 210, 255, 0.25), transparent 60%),
|
|
||||||
radial-gradient(900px 600px at 85% 20%, rgba(10, 132, 255, 0.22), transparent 60%),
|
|
||||||
radial-gradient(900px 600px at 50% 90%, rgba(50, 215, 75, 0.10), transparent 60%),
|
|
||||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
|
||||||
color: var(--text);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
.shell {
|
|
||||||
|
|
||||||
width: max(500px);
|
|
||||||
}
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.shell { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
.hero {
|
|
||||||
padding: 26px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.06));
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
-webkit-backdrop-filter: blur(16px);
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.hero::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: -2px;
|
|
||||||
background: radial-gradient(500px 240px at 20% 0%, rgba(100, 210, 255, 0.18), transparent 60%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.top {
|
|
||||||
display: flex;
|
|
||||||
gap: 14px;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.mark {
|
|
||||||
width: 46px;
|
|
||||||
height: 46px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: linear-gradient(135deg, rgba(10,132,255,0.85), rgba(100,210,255,0.55));
|
|
||||||
border: 1px solid rgba(255,255,255,0.20);
|
|
||||||
box-shadow: 0 10px 40px rgba(10, 132, 255, 0.30);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
color: rgba(255,255,255,0.96);
|
|
||||||
text-transform: uppercase;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.title h1 {
|
|
||||||
font-size: 22px;
|
|
||||||
margin: 0;
|
|
||||||
letter-spacing: -0.3px;
|
|
||||||
}
|
|
||||||
.title p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.panel {
|
|
||||||
padding: 22px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
-webkit-backdrop-filter: blur(16px);
|
|
||||||
}
|
|
||||||
.panel h2 {
|
|
||||||
font-size: 16px;
|
|
||||||
margin: 0 0 14px 0;
|
|
||||||
letter-spacing: -0.2px;
|
|
||||||
}
|
|
||||||
.message {
|
|
||||||
display: none;
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 12px 12px;
|
|
||||||
margin: 0 0 12px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.45;
|
|
||||||
border: 1px solid rgba(255,255,255,0.14);
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
}
|
|
||||||
.message.error {
|
|
||||||
display: block;
|
|
||||||
border-color: rgba(255, 69, 58, 0.40);
|
|
||||||
background: rgba(255, 69, 58, 0.10);
|
|
||||||
color: rgba(255, 255, 255, 0.92);
|
|
||||||
}
|
|
||||||
.message.success {
|
|
||||||
display: block;
|
|
||||||
border-color: rgba(50, 215, 75, 0.35);
|
|
||||||
background: rgba(50, 215, 75, 0.10);
|
|
||||||
color: rgba(255, 255, 255, 0.92);
|
|
||||||
}
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
@media (max-width: 540px) {
|
|
||||||
.grid { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
.field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 7px;
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
letter-spacing: 0.2px;
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
height: 42px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid rgba(255,255,255,0.18);
|
|
||||||
background: rgba(0,0,0,0.18);
|
|
||||||
color: rgba(255,255,255,0.92);
|
|
||||||
outline: none;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
|
||||||
}
|
|
||||||
input::placeholder { color: rgba(255,255,255,0.35); }
|
|
||||||
input:focus {
|
|
||||||
border-color: rgba(10, 132, 255, 0.55);
|
|
||||||
box-shadow: 0 0 0 6px rgba(10, 132, 255, 0.12);
|
|
||||||
}
|
|
||||||
.hint {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--muted2);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
.actions {
|
|
||||||
margin-top: 12px;
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.primary {
|
|
||||||
width: 100%;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 14px;
|
|
||||||
border: 1px solid rgba(255,255,255,0.18);
|
|
||||||
background: linear-gradient(135deg, rgba(10,132,255,0.95), rgba(100,210,255,0.60));
|
|
||||||
color: rgba(255,255,255,0.96);
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.2px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 120ms ease, filter 120ms ease;
|
|
||||||
}
|
|
||||||
.primary:hover { filter: brightness(1.03); }
|
|
||||||
.primary:active { transform: translateY(1px) scale(0.99); }
|
|
||||||
.primary:disabled {
|
|
||||||
opacity: 0.55;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
.sideCard {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.kv {
|
|
||||||
border-radius: var(--radius2);
|
|
||||||
border: 1px solid rgba(255,255,255,0.14);
|
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
padding: 14px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.kv h3 {
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: rgba(255,255,255,0.86);
|
|
||||||
}
|
|
||||||
.kv p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.55;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.server {
|
|
||||||
margin-top: 10px;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(0,0,0,0.25);
|
|
||||||
border: 1px solid rgba(255,255,255,0.12);
|
|
||||||
word-break: break-all;
|
|
||||||
color: rgba(255,255,255,0.90);
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
color: rgba(100, 210, 255, 0.92);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
.footer {
|
|
||||||
margin-top: 18px;
|
|
||||||
padding-top: 14px;
|
|
||||||
border-top: 1px solid rgba(255,255,255,0.10);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(255,255,255,0.55);
|
|
||||||
}
|
|
||||||
.muted { color: var(--muted); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="shell">
|
|
||||||
<aside class="panel">
|
|
||||||
<div class="top">
|
|
||||||
<div class="mark" aria-label="NodeWarden">NW</div>
|
|
||||||
<div class="title">
|
|
||||||
<h1 id="t_app">NodeWarden</h1>
|
|
||||||
<p id="t_tag">部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端(个人使用)。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height: 12px"></div>
|
|
||||||
<div class="muted" id="t_intro" style="font-size: 13px; line-height: 1.7;">
|
|
||||||
创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height: 14px"></div>
|
|
||||||
<h2 id="t_setup">初始化</h2>
|
|
||||||
|
|
||||||
<div id="message" class="message"></div>
|
|
||||||
|
|
||||||
<div id="setup-form">
|
|
||||||
<form id="form" onsubmit="handleSubmit(event)">
|
|
||||||
<div class="grid">
|
|
||||||
<div class="field">
|
|
||||||
<label for="name" id="t_name_label">Name</label>
|
|
||||||
<input type="text" id="name" name="name" required placeholder="Your name">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="email" id="t_email_label">Email</label>
|
|
||||||
<input type="email" id="email" name="email" required placeholder="you@example.com" autocomplete="email">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height: 10px"></div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="password" id="t_pw_label">Master password</label>
|
|
||||||
<input type="password" id="password" name="password" required minlength="12" placeholder="At least 12 characters" autocomplete="new-password">
|
|
||||||
<p class="hint" id="t_pw_hint">Choose a strong password you can remember. The server cannot recover it.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height: 10px"></div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="confirmPassword" id="t_pw2_label">Confirm password</label>
|
|
||||||
<input type="password" id="confirmPassword" name="confirmPassword" required placeholder="Confirm password" autocomplete="new-password">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button type="submit" id="submitBtn" class="primary">Create account</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="registered-view" class="sideCard" style="display: none;">
|
|
||||||
<div class="kv">
|
|
||||||
<h3 id="t_done_title">Setup complete</h3>
|
|
||||||
<p id="t_done_desc">Your server is ready. Configure your Bitwarden client with this server URL:</p>
|
|
||||||
<div class="server" id="serverUrl"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="kv">
|
|
||||||
<h3 id="t_important">Important</h3>
|
|
||||||
<p id="t_limitations">
|
|
||||||
This project is designed for a single user. You cannot add new users. Changing the master password is not supported.
|
|
||||||
If you forget it, you must redeploy and register again.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="kv">
|
|
||||||
<h3 id="t_hide_title">Hide setup page</h3>
|
|
||||||
<p id="t_hide_desc">After hiding, this setup page will return 404 for everyone. Your vault will keep working.</p>
|
|
||||||
<div class="actions">
|
|
||||||
<button type="button" id="hideBtn" class="primary" onclick="disableSetupPage()">Hide setup page</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<div>
|
|
||||||
<span class="muted" id="t_by">By</span>
|
|
||||||
<a href="https://shuai.plus" target="_blank" rel="noreferrer">shuaiplus</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a href="https://github.com/shuaiplus/nodewarden" target="_blank" rel="noreferrer">GitHub</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const AUTHOR = { name: 'shuaiplus', website: 'https://shuai.plus', github: 'https://github.com/shuaiplus/nodewarden' };
|
|
||||||
let isRegistered = false;
|
|
||||||
|
|
||||||
function isChinese() {
|
|
||||||
const lang = (navigator.language || '').toLowerCase();
|
|
||||||
return lang.startsWith('zh');
|
|
||||||
}
|
|
||||||
|
|
||||||
function t(key) {
|
|
||||||
const zh = {
|
|
||||||
app: 'NodeWarden',
|
|
||||||
tag: '部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端(个人使用)。',
|
|
||||||
intro: '创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。',
|
|
||||||
by: '作者',
|
|
||||||
setup: '初始化',
|
|
||||||
nameLabel: '昵称',
|
|
||||||
emailLabel: '邮箱',
|
|
||||||
pwLabel: '主密码',
|
|
||||||
pwHint: '请选择你能记住的强密码。服务器无法找回主密码。',
|
|
||||||
pw2Label: '确认主密码',
|
|
||||||
create: '创建账号',
|
|
||||||
creating: '正在创建…',
|
|
||||||
doneTitle: '初始化完成',
|
|
||||||
doneDesc: '服务已就绪。在 Bitwarden 客户端中填入以下服务器地址:',
|
|
||||||
important: '重要提示',
|
|
||||||
limitations: '本项目仅支持单用户:不能添加新用户;不支持修改主密码;如果忘记主密码,只能重新部署并重新注册。',
|
|
||||||
hideTitle: '隐藏初始化页',
|
|
||||||
hideDesc: '隐藏后,初始化页对任何人都会直接返回 404。你的密码库仍可正常使用。',
|
|
||||||
hideBtn: '隐藏初始化页',
|
|
||||||
hideWorking: '正在隐藏…',
|
|
||||||
hideDone: '已隐藏,此页面将返回 404。',
|
|
||||||
hideFailed: '隐藏失败',
|
|
||||||
hideConfirm: '确认隐藏初始化页?隐藏后页面将不可访问,但你的密码库不会受影响。',
|
|
||||||
errPwNotMatch: '两次输入的密码不一致',
|
|
||||||
errPwTooShort: '密码长度至少 12 位',
|
|
||||||
errGeneric: '发生错误:',
|
|
||||||
errRegisterFailed: '注册失败',
|
|
||||||
};
|
|
||||||
const en = {
|
|
||||||
app: 'NodeWarden',
|
|
||||||
tag: 'Minimal Bitwarden-compatible server on Cloudflare Workers (personal use).',
|
|
||||||
intro: 'Create your first account to finish setup. Then use any official Bitwarden client to sign in.',
|
|
||||||
by: 'By',
|
|
||||||
setup: 'Setup',
|
|
||||||
nameLabel: 'Name',
|
|
||||||
emailLabel: 'Email',
|
|
||||||
pwLabel: 'Master password',
|
|
||||||
pwHint: 'Choose a strong password you can remember. The server cannot recover it.',
|
|
||||||
pw2Label: 'Confirm password',
|
|
||||||
create: 'Create account',
|
|
||||||
creating: 'Creating…',
|
|
||||||
doneTitle: 'Setup complete',
|
|
||||||
doneDesc: 'Your server is ready. Configure your Bitwarden client with this server URL:',
|
|
||||||
important: 'Important',
|
|
||||||
limitations: 'Single user only: you cannot add new users. Changing the master password is not supported. If you forget it, redeploy and register again.',
|
|
||||||
hideTitle: 'Hide setup page',
|
|
||||||
hideDesc: 'After hiding, this setup page will return 404 for everyone. Your vault will keep working.',
|
|
||||||
hideBtn: 'Hide setup page',
|
|
||||||
hideWorking: 'Hiding…',
|
|
||||||
hideDone: 'Hidden. This page will now return 404.',
|
|
||||||
hideFailed: 'Failed to hide setup page',
|
|
||||||
hideConfirm: 'Hide the setup page? It will no longer be accessible, but your vault will keep working.',
|
|
||||||
errPwNotMatch: 'Passwords do not match',
|
|
||||||
errPwTooShort: 'Password must be at least 12 characters',
|
|
||||||
errGeneric: 'An error occurred: ',
|
|
||||||
errRegisterFailed: 'Registration failed',
|
|
||||||
};
|
|
||||||
return (isChinese() ? zh : en)[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyI18n() {
|
|
||||||
document.documentElement.lang = isChinese() ? 'zh-CN' : 'en';
|
|
||||||
|
|
||||||
document.getElementById('t_app').textContent = t('app');
|
|
||||||
document.getElementById('t_tag').textContent = t('tag');
|
|
||||||
document.getElementById('t_intro').textContent = t('intro');
|
|
||||||
document.getElementById('t_by').textContent = t('by');
|
|
||||||
document.getElementById('t_setup').textContent = t('setup');
|
|
||||||
|
|
||||||
document.getElementById('t_name_label').textContent = t('nameLabel');
|
|
||||||
document.getElementById('t_email_label').textContent = t('emailLabel');
|
|
||||||
document.getElementById('t_pw_label').textContent = t('pwLabel');
|
|
||||||
document.getElementById('t_pw_hint').textContent = t('pwHint');
|
|
||||||
document.getElementById('t_pw2_label').textContent = t('pw2Label');
|
|
||||||
document.getElementById('submitBtn').textContent = t('create');
|
|
||||||
|
|
||||||
document.getElementById('t_done_title').textContent = t('doneTitle');
|
|
||||||
document.getElementById('t_done_desc').textContent = t('doneDesc');
|
|
||||||
document.getElementById('t_important').textContent = t('important');
|
|
||||||
document.getElementById('t_limitations').textContent = t('limitations');
|
|
||||||
document.getElementById('t_hide_title').textContent = t('hideTitle');
|
|
||||||
document.getElementById('t_hide_desc').textContent = t('hideDesc');
|
|
||||||
document.getElementById('hideBtn').textContent = t('hideBtn');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already registered
|
|
||||||
async function checkStatus() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/setup/status');
|
|
||||||
const data = await res.json();
|
|
||||||
isRegistered = !!data.registered;
|
|
||||||
if (data.registered) {
|
|
||||||
showRegisteredView();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to check status:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showRegisteredView() {
|
|
||||||
isRegistered = true;
|
|
||||||
document.getElementById('setup-form').style.display = 'none';
|
|
||||||
document.getElementById('registered-view').style.display = 'block';
|
|
||||||
document.getElementById('serverUrl').textContent = window.location.origin;
|
|
||||||
showMessage(t('doneTitle'), 'success');
|
|
||||||
const form = document.getElementById('form');
|
|
||||||
if (form) {
|
|
||||||
const fields = form.querySelectorAll('input, button');
|
|
||||||
fields.forEach((el) => {
|
|
||||||
el.disabled = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function disableSetupPage() {
|
|
||||||
if (!isRegistered) return;
|
|
||||||
if (!confirm(t('hideConfirm'))) return;
|
|
||||||
|
|
||||||
const btn = document.getElementById('hideBtn');
|
|
||||||
if (btn) {
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = t('hideWorking');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/setup/disable', { method: 'POST' });
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok && data.success) {
|
|
||||||
showMessage(t('hideDone'), 'success');
|
|
||||||
setTimeout(() => window.location.reload(), 600);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showMessage(data.error || t('hideFailed'), 'error');
|
|
||||||
} catch (e) {
|
|
||||||
showMessage(t('hideFailed'), 'error');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (btn) {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = t('hideBtn');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showMessage(text, type) {
|
|
||||||
const msg = document.getElementById('message');
|
|
||||||
msg.textContent = text;
|
|
||||||
msg.className = 'message ' + type;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PBKDF2-SHA256 key derivation (compatible with Bitwarden)
|
|
||||||
// password can be string or Uint8Array
|
|
||||||
async function pbkdf2(password, salt, iterations, keyLen) {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
|
|
||||||
// Handle password as string or Uint8Array
|
|
||||||
const passwordBytes = (password instanceof Uint8Array)
|
|
||||||
? password
|
|
||||||
: encoder.encode(password);
|
|
||||||
|
|
||||||
// Handle salt as string or Uint8Array
|
|
||||||
const saltBytes = (salt instanceof Uint8Array)
|
|
||||||
? salt
|
|
||||||
: encoder.encode(salt);
|
|
||||||
|
|
||||||
const keyMaterial = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
passwordBytes,
|
|
||||||
'PBKDF2',
|
|
||||||
false,
|
|
||||||
['deriveBits']
|
|
||||||
);
|
|
||||||
|
|
||||||
const derivedBits = await crypto.subtle.deriveBits(
|
|
||||||
{
|
|
||||||
name: 'PBKDF2',
|
|
||||||
salt: saltBytes,
|
|
||||||
iterations: iterations,
|
|
||||||
hash: 'SHA-256'
|
|
||||||
},
|
|
||||||
keyMaterial,
|
|
||||||
keyLen * 8
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Uint8Array(derivedBits);
|
|
||||||
}
|
|
||||||
|
|
||||||
// HKDF expand
|
|
||||||
async function hkdfExpand(prk, info, length) {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const key = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
prk,
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['sign']
|
|
||||||
);
|
|
||||||
|
|
||||||
const infoBytes = encoder.encode(info);
|
|
||||||
const result = new Uint8Array(length);
|
|
||||||
let prev = new Uint8Array(0);
|
|
||||||
let offset = 0;
|
|
||||||
let counter = 1;
|
|
||||||
|
|
||||||
while (offset < length) {
|
|
||||||
const input = new Uint8Array(prev.length + infoBytes.length + 1);
|
|
||||||
input.set(prev);
|
|
||||||
input.set(infoBytes, prev.length);
|
|
||||||
input[input.length - 1] = counter;
|
|
||||||
|
|
||||||
const signature = await crypto.subtle.sign('HMAC', key, input);
|
|
||||||
prev = new Uint8Array(signature);
|
|
||||||
|
|
||||||
const toCopy = Math.min(prev.length, length - offset);
|
|
||||||
result.set(prev.slice(0, toCopy), offset);
|
|
||||||
offset += toCopy;
|
|
||||||
counter++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate symmetric key
|
|
||||||
function generateSymmetricKey() {
|
|
||||||
return crypto.getRandomValues(new Uint8Array(64));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt with AES-256-CBC
|
|
||||||
async function encryptAesCbc(data, key, iv) {
|
|
||||||
const cryptoKey = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
key,
|
|
||||||
{ name: 'AES-CBC' },
|
|
||||||
false,
|
|
||||||
['encrypt']
|
|
||||||
);
|
|
||||||
|
|
||||||
const encrypted = await crypto.subtle.encrypt(
|
|
||||||
{ name: 'AES-CBC', iv: iv },
|
|
||||||
cryptoKey,
|
|
||||||
data
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Uint8Array(encrypted);
|
|
||||||
}
|
|
||||||
|
|
||||||
// HMAC-SHA256
|
|
||||||
async function hmacSha256(key, data) {
|
|
||||||
const cryptoKey = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
key,
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['sign']
|
|
||||||
);
|
|
||||||
|
|
||||||
const signature = await crypto.subtle.sign('HMAC', cryptoKey, data);
|
|
||||||
return new Uint8Array(signature);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Base64 encode
|
|
||||||
function base64Encode(bytes) {
|
|
||||||
return btoa(String.fromCharCode.apply(null, bytes));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create encrypted string in Bitwarden format
|
|
||||||
async function encryptToBitwardenFormat(data, encKey, macKey) {
|
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
|
||||||
const encrypted = await encryptAesCbc(data, encKey, iv);
|
|
||||||
|
|
||||||
// Calculate MAC over IV + encrypted data
|
|
||||||
const macData = new Uint8Array(iv.length + encrypted.length);
|
|
||||||
macData.set(iv);
|
|
||||||
macData.set(encrypted, iv.length);
|
|
||||||
const mac = await hmacSha256(macKey, macData);
|
|
||||||
|
|
||||||
// Format: 2.{base64(iv)}|{base64(encrypted)}|{base64(mac)}
|
|
||||||
return '2.' + base64Encode(iv) + '|' + base64Encode(encrypted) + '|' + base64Encode(mac);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate RSA key pair
|
|
||||||
async function generateRsaKeyPair() {
|
|
||||||
const keyPair = await crypto.subtle.generateKey(
|
|
||||||
{
|
|
||||||
name: 'RSA-OAEP',
|
|
||||||
modulusLength: 2048,
|
|
||||||
publicExponent: new Uint8Array([1, 0, 1]),
|
|
||||||
hash: 'SHA-1'
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
['encrypt', 'decrypt']
|
|
||||||
);
|
|
||||||
|
|
||||||
// Export public key
|
|
||||||
const publicKeySpki = await crypto.subtle.exportKey('spki', keyPair.publicKey);
|
|
||||||
const publicKeyB64 = base64Encode(new Uint8Array(publicKeySpki));
|
|
||||||
|
|
||||||
// Export private key
|
|
||||||
const privateKeyPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
|
||||||
const privateKeyBytes = new Uint8Array(privateKeyPkcs8);
|
|
||||||
|
|
||||||
return {
|
|
||||||
publicKey: publicKeyB64,
|
|
||||||
privateKey: privateKeyBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (isRegistered) {
|
|
||||||
showMessage(t('doneTitle'), 'success');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = document.getElementById('name').value;
|
|
||||||
const email = document.getElementById('email').value.toLowerCase();
|
|
||||||
const password = document.getElementById('password').value;
|
|
||||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
showMessage(t('errPwNotMatch'), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.length < 12) {
|
|
||||||
showMessage(t('errPwTooShort'), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const btn = document.getElementById('submitBtn');
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = t('creating');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Generate master key using PBKDF2 (Bitwarden default: 600000 iterations)
|
|
||||||
const iterations = 600000;
|
|
||||||
const masterKey = await pbkdf2(password, email, iterations, 32);
|
|
||||||
|
|
||||||
// Generate master password hash (for authentication)
|
|
||||||
// Bitwarden: PBKDF2(masterKey as raw bytes, password, 1 iteration)
|
|
||||||
const masterPasswordHash = await pbkdf2(masterKey, password, 1, 32);
|
|
||||||
const masterPasswordHashB64 = base64Encode(masterPasswordHash);
|
|
||||||
|
|
||||||
// Stretch master key using HKDF
|
|
||||||
const stretchedKey = await hkdfExpand(masterKey, 'enc', 32);
|
|
||||||
const stretchedMacKey = await hkdfExpand(masterKey, 'mac', 32);
|
|
||||||
|
|
||||||
// Generate symmetric key (will be encrypted with stretched master key)
|
|
||||||
const symmetricKey = generateSymmetricKey();
|
|
||||||
|
|
||||||
// Encrypt symmetric key with stretched master key
|
|
||||||
const encryptedKey = await encryptToBitwardenFormat(symmetricKey, stretchedKey, stretchedMacKey);
|
|
||||||
|
|
||||||
// Generate RSA key pair
|
|
||||||
const rsaKeys = await generateRsaKeyPair();
|
|
||||||
|
|
||||||
// Encrypt private key with symmetric key
|
|
||||||
const encryptedPrivateKey = await encryptToBitwardenFormat(rsaKeys.privateKey, symmetricKey.slice(0, 32), symmetricKey.slice(32, 64));
|
|
||||||
|
|
||||||
// Register with server
|
|
||||||
const response = await fetch('/api/accounts/register', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: email,
|
|
||||||
name: name,
|
|
||||||
masterPasswordHash: masterPasswordHashB64,
|
|
||||||
key: encryptedKey,
|
|
||||||
kdf: 0,
|
|
||||||
kdfIterations: iterations,
|
|
||||||
keys: {
|
|
||||||
publicKey: rsaKeys.publicKey,
|
|
||||||
encryptedPrivateKey: encryptedPrivateKey
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
|
||||||
showRegisteredView();
|
|
||||||
} else {
|
|
||||||
showMessage(result.error || result.ErrorModel?.Message || t('errRegisterFailed'), 'error');
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = t('create');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Registration error:', error);
|
|
||||||
showMessage(t('errGeneric') + (error && error.message ? error.message : String(error)), 'error');
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = t('create');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check status on page load
|
|
||||||
applyI18n();
|
|
||||||
checkStatus();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
// GET / - Setup page
|
|
||||||
export async function handleSetupPage(request: Request, env: Env): Promise<Response> {
|
|
||||||
const storage = new StorageService(env.VAULT);
|
|
||||||
const disabled = await storage.isSetupDisabled();
|
const disabled = await storage.isSetupDisabled();
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
return new Response(null, { status: 404 });
|
return new Response(null, { status: 404 });
|
||||||
}
|
}
|
||||||
return htmlResponse(setupPageHTML);
|
return htmlResponse(renderRegisterPageHTML(jwtState));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET / - Setup page
|
||||||
|
export async function handleSetupPage(request: Request, env: Env): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const disabled = await storage.isSetupDisabled();
|
||||||
|
if (disabled) {
|
||||||
|
return new Response(null, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 引导页内会处理 JWT_SECRET 检测与分流(坏密钥停留在修复步骤)。
|
||||||
|
const jwtState = getJwtSecretState(env);
|
||||||
|
return handleRegisterPage(request, env, jwtState);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /setup/status
|
// GET /setup/status
|
||||||
export async function handleSetupStatus(request: Request, env: Env): Promise<Response> {
|
export async function handleSetupStatus(request: Request, env: Env): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const registered = await storage.isRegistered();
|
const registered = await storage.isRegistered();
|
||||||
const disabled = await storage.isSetupDisabled();
|
const disabled = await storage.isSetupDisabled();
|
||||||
return jsonResponse({ registered, disabled });
|
return jsonResponse({ registered, disabled });
|
||||||
@@ -785,7 +47,7 @@ export async function handleSetupStatus(request: Request, env: Env): Promise<Res
|
|||||||
|
|
||||||
// POST /setup/disable
|
// POST /setup/disable
|
||||||
export async function handleDisableSetup(request: Request, env: Env): Promise<Response> {
|
export async function handleDisableSetup(request: Request, env: Env): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
const registered = await storage.isRegistered();
|
const registered = await storage.isRegistered();
|
||||||
if (!registered) {
|
if (!registered) {
|
||||||
return errorResponse('Registration required', 403);
|
return errorResponse('Registration required', 403);
|
||||||
|
|||||||
+82
-53
@@ -1,32 +1,66 @@
|
|||||||
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse, Attachment } from '../types';
|
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { errorResponse } from '../utils/response';
|
||||||
|
import { cipherToResponse } from './ciphers';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
// Format attachments for API response
|
interface SyncCacheEntry {
|
||||||
function formatAttachments(attachments: Attachment[]): any[] | null {
|
body: string;
|
||||||
if (attachments.length === 0) return null;
|
expiresAt: number;
|
||||||
return attachments.map(a => ({
|
}
|
||||||
id: a.id,
|
|
||||||
fileName: a.fileName,
|
const syncResponseCache = new Map<string, SyncCacheEntry>();
|
||||||
size: Number(a.size) || 0, // Android expects Int, not String
|
|
||||||
sizeName: a.sizeName,
|
function buildSyncCacheKey(userId: string, revisionDate: string, excludeDomains: boolean): string {
|
||||||
key: a.key,
|
return `${userId}:${revisionDate}:${excludeDomains ? '1' : '0'}`;
|
||||||
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url!
|
}
|
||||||
object: 'attachment',
|
|
||||||
}));
|
function readSyncCache(key: string): string | null {
|
||||||
|
const hit = syncResponseCache.get(key);
|
||||||
|
if (!hit) return null;
|
||||||
|
if (hit.expiresAt <= Date.now()) {
|
||||||
|
syncResponseCache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return hit.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSyncCache(key: string, body: string): void {
|
||||||
|
if (syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries) {
|
||||||
|
const oldestKey = syncResponseCache.keys().next().value as string | undefined;
|
||||||
|
if (oldestKey) syncResponseCache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
syncResponseCache.set(key, {
|
||||||
|
body,
|
||||||
|
expiresAt: Date.now() + LIMITS.cache.syncResponseTtlMs,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/sync
|
// GET /api/sync
|
||||||
export async function handleSync(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleSync(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.DB);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
||||||
|
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||||
|
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return errorResponse('User not found', 404);
|
return errorResponse('User not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const revisionDate = await storage.getRevisionDate(userId);
|
||||||
|
const cacheKey = buildSyncCacheKey(userId, revisionDate, excludeDomains);
|
||||||
|
const cachedBody = readSyncCache(cacheKey);
|
||||||
|
if (cachedBody) {
|
||||||
|
return new Response(cachedBody, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const ciphers = await storage.getAllCiphers(userId);
|
const ciphers = await storage.getAllCiphers(userId);
|
||||||
const folders = await storage.getAllFolders(userId);
|
const folders = await storage.getAllFolders(userId);
|
||||||
|
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map(c => c.id));
|
||||||
|
|
||||||
// Build profile response
|
// Build profile response
|
||||||
const profile: ProfileResponse = {
|
const profile: ProfileResponse = {
|
||||||
@@ -56,41 +90,9 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
// Build cipher responses with attachments
|
// Build cipher responses with attachments
|
||||||
const cipherResponses: CipherResponse[] = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of ciphers) {
|
for (const cipher of ciphers) {
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||||
cipherResponses.push({
|
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||||
id: cipher.id,
|
}
|
||||||
organizationId: null,
|
|
||||||
folderId: cipher.folderId,
|
|
||||||
type: Number(cipher.type) || 1,
|
|
||||||
name: cipher.name,
|
|
||||||
notes: cipher.notes,
|
|
||||||
favorite: cipher.favorite,
|
|
||||||
login: cipher.login,
|
|
||||||
card: cipher.card,
|
|
||||||
identity: cipher.identity,
|
|
||||||
secureNote: cipher.secureNote,
|
|
||||||
sshKey: cipher.sshKey,
|
|
||||||
fields: cipher.fields,
|
|
||||||
passwordHistory: cipher.passwordHistory,
|
|
||||||
reprompt: cipher.reprompt,
|
|
||||||
organizationUseTotp: false,
|
|
||||||
creationDate: cipher.createdAt,
|
|
||||||
revisionDate: cipher.updatedAt,
|
|
||||||
deletedDate: cipher.deletedAt,
|
|
||||||
archivedDate: null,
|
|
||||||
edit: true,
|
|
||||||
viewPassword: true,
|
|
||||||
permissions: {
|
|
||||||
delete: true,
|
|
||||||
restore: true,
|
|
||||||
},
|
|
||||||
object: 'cipher',
|
|
||||||
collectionIds: [],
|
|
||||||
attachments: formatAttachments(attachments),
|
|
||||||
key: cipher.key,
|
|
||||||
encryptedFor: null,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build folder responses
|
// Build folder responses
|
||||||
const folderResponses: FolderResponse[] = folders.map(folder => ({
|
const folderResponses: FolderResponse[] = folders.map(folder => ({
|
||||||
@@ -105,27 +107,54 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
folders: folderResponses,
|
folders: folderResponses,
|
||||||
collections: [],
|
collections: [],
|
||||||
ciphers: cipherResponses,
|
ciphers: cipherResponses,
|
||||||
domains: {
|
domains: excludeDomains
|
||||||
|
? null
|
||||||
|
: {
|
||||||
equivalentDomains: [],
|
equivalentDomains: [],
|
||||||
globalEquivalentDomains: [],
|
globalEquivalentDomains: [],
|
||||||
object: 'domains',
|
object: 'domains',
|
||||||
},
|
},
|
||||||
policies: [],
|
policies: [],
|
||||||
sends: [],
|
sends: [],
|
||||||
|
// PascalCase for desktop/browser clients
|
||||||
|
UserDecryptionOptions: {
|
||||||
|
HasMasterPassword: true,
|
||||||
|
Object: 'userDecryptionOptions',
|
||||||
|
MasterPasswordUnlock: {
|
||||||
|
Kdf: {
|
||||||
|
KdfType: user.kdfType,
|
||||||
|
Iterations: user.kdfIterations,
|
||||||
|
Memory: user.kdfMemory || null,
|
||||||
|
Parallelism: user.kdfParallelism || null,
|
||||||
|
},
|
||||||
|
MasterKeyEncryptedUserKey: user.key,
|
||||||
|
MasterKeyWrappedUserKey: user.key,
|
||||||
|
Salt: user.email.toLowerCase(),
|
||||||
|
Object: 'masterPasswordUnlock',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
||||||
userDecryption: {
|
userDecryption: {
|
||||||
masterPasswordUnlock: {
|
masterPasswordUnlock: {
|
||||||
salt: user.email,
|
|
||||||
kdf: {
|
kdf: {
|
||||||
kdfType: user.kdfType,
|
kdfType: user.kdfType,
|
||||||
iterations: user.kdfIterations,
|
iterations: user.kdfIterations,
|
||||||
memory: user.kdfMemory || null,
|
memory: user.kdfMemory || null,
|
||||||
parallelism: user.kdfParallelism || null,
|
parallelism: user.kdfParallelism || null,
|
||||||
},
|
},
|
||||||
|
masterKeyWrappedUserKey: user.key,
|
||||||
masterKeyEncryptedUserKey: user.key,
|
masterKeyEncryptedUserKey: user.key,
|
||||||
|
salt: user.email.toLowerCase(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
object: 'sync',
|
object: 'sync',
|
||||||
};
|
};
|
||||||
|
|
||||||
return jsonResponse(syncResponse);
|
const body = JSON.stringify(syncResponse);
|
||||||
|
writeSyncCache(cacheKey, body);
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-7
@@ -1,18 +1,44 @@
|
|||||||
import { Env } from './types';
|
import { Env } from './types';
|
||||||
import { handleRequest } from './router';
|
import { handleRequest } from './router';
|
||||||
|
import { StorageService } from './services/storage';
|
||||||
|
import { applyCors, jsonResponse } from './utils/response';
|
||||||
|
|
||||||
|
// Per-isolate flags. Each Worker isolate may have its own copy of these flags.
|
||||||
|
// initializeDatabase() only validates schema presence, so retries are cheap.
|
||||||
|
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> {
|
||||||
// Security check: JWT_SECRET must be set
|
// Auto-initialize database on first request
|
||||||
if (!env.JWT_SECRET) {
|
if (!dbInitialized) {
|
||||||
return new Response('Server configuration error: JWT_SECRET is not set', { status: 500 });
|
try {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
await storage.initializeDatabase();
|
||||||
|
dbInitialized = true;
|
||||||
|
dbInitError = null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize database:', error);
|
||||||
|
dbInitError = error instanceof Error ? error.message : 'Unknown database initialization error';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Security check: warn if JWT_SECRET is too weak
|
if (dbInitError) {
|
||||||
if (env.JWT_SECRET.length < 32) {
|
const resp = jsonResponse(
|
||||||
console.warn('[SECURITY WARNING] JWT_SECRET should be at least 32 characters for adequate security');
|
{
|
||||||
|
error: 'Database not initialized',
|
||||||
|
error_description: dbInitError,
|
||||||
|
ErrorModel: {
|
||||||
|
Message: dbInitError,
|
||||||
|
Object: 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
500
|
||||||
|
);
|
||||||
|
return applyCors(request, resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleRequest(request, env);
|
const resp = await handleRequest(request, env);
|
||||||
|
return applyCors(request, resp);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+155
-19
@@ -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,11 +158,12 @@ 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
|
||||||
try {
|
try {
|
||||||
|
|
||||||
// Setup page (root)
|
// Setup page (root)
|
||||||
if (path === '/' && method === 'GET') {
|
if (path === '/' && method === 'GET') {
|
||||||
return handleSetupPage(request, env);
|
return handleSetupPage(request, env);
|
||||||
@@ -101,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)
|
||||||
@@ -154,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: {
|
||||||
@@ -164,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',
|
||||||
});
|
});
|
||||||
@@ -173,14 +280,23 @@ 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.
|
||||||
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// All other API endpoints require authentication
|
// All other API endpoints require authentication
|
||||||
const auth = new AuthService(env);
|
const auth = new AuthService(env);
|
||||||
const authHeader = request.headers.get('Authorization');
|
const authHeader = request.headers.get('Authorization');
|
||||||
@@ -191,11 +307,33 @@ 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.VAULT);
|
|
||||||
const clientId = getClientIdentifier(request);
|
const clientId = getClientIdentifier(request);
|
||||||
const rateLimitCheck = await rateLimit.checkApiRateLimit(userId + ':' + clientId);
|
|
||||||
|
// Dedicated read rate limiting for heavy sync endpoint.
|
||||||
|
if (path === '/api/sync' && method === 'GET') {
|
||||||
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
|
const rateLimitCheck = await rateLimit.consumeSyncReadBudget(userId + ':' + clientId + ':sync');
|
||||||
|
|
||||||
|
if (!rateLimitCheck.allowed) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Too many requests',
|
||||||
|
error_description: `Sync rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
|
||||||
|
}), {
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
|
||||||
|
'X-RateLimit-Remaining': '0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API rate limiting only for write operations (keep reads frictionless)
|
||||||
|
const isWriteMethod = method === 'POST' || method === 'PUT' || method === 'DELETE' || method === 'PATCH';
|
||||||
|
if (isWriteMethod) {
|
||||||
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
|
const rateLimitCheck = await rateLimit.consumeApiWriteBudget(userId + ':' + clientId + ':write');
|
||||||
|
|
||||||
if (!rateLimitCheck.allowed) {
|
if (!rateLimitCheck.allowed) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
@@ -210,9 +348,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Increment rate limit counter
|
|
||||||
await rateLimit.incrementApiCount(userId + ':' + clientId);
|
|
||||||
|
|
||||||
// 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') {
|
||||||
@@ -226,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-4
@@ -6,14 +6,20 @@ export class AuthService {
|
|||||||
private storage: StorageService;
|
private storage: StorageService;
|
||||||
|
|
||||||
constructor(private env: Env) {
|
constructor(private env: Env) {
|
||||||
this.storage = new StorageService(env.VAULT);
|
this.storage = new StorageService(env.DB);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify password hash (compare with stored hash)
|
// 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
|
||||||
|
|||||||
+171
-107
@@ -1,171 +1,235 @@
|
|||||||
import { Env } from '../types';
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
|
// D1-backed rate limiting.
|
||||||
|
// Notes:
|
||||||
|
// - Login attempts are tracked per client IP.
|
||||||
|
// - API rate is tracked per identifier per fixed window.
|
||||||
|
|
||||||
// Rate limit configuration
|
// Rate limit configuration
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
// Login attempt limits
|
// Friendly default: short cooldown instead of long lockouts.
|
||||||
LOGIN_MAX_ATTEMPTS: 15, // Max failed login attempts
|
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
|
||||||
LOGIN_LOCKOUT_MINUTES: 5, // Lockout duration after max attempts
|
LOGIN_LOCKOUT_MINUTES: LIMITS.rateLimit.loginLockoutMinutes,
|
||||||
|
|
||||||
// API rate limits (per minute)
|
// Write operations only (POST/PUT/DELETE/PATCH) should use this budget.
|
||||||
API_REQUESTS_PER_MINUTE: 300, // General API rate limit
|
API_WRITE_REQUESTS_PER_MINUTE: LIMITS.rateLimit.apiWriteRequestsPerMinute,
|
||||||
API_WINDOW_SECONDS: 60, // Rate limit window
|
// Dedicated budget for GET /api/sync reads.
|
||||||
};
|
SYNC_READ_REQUESTS_PER_MINUTE: LIMITS.rateLimit.syncReadRequestsPerMinute,
|
||||||
|
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
|
||||||
// KV key prefixes
|
|
||||||
const KEYS = {
|
|
||||||
LOGIN_ATTEMPTS: 'ratelimit:login:',
|
|
||||||
API_RATE: 'ratelimit:api:',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class RateLimitService {
|
export class RateLimitService {
|
||||||
constructor(private kv: KVNamespace) {}
|
private static loginIpTableReady = false;
|
||||||
|
private static lastLoginIpCleanupAt = 0;
|
||||||
|
private static lastApiWindowCleanupAt = 0;
|
||||||
|
|
||||||
/**
|
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability;
|
||||||
* Check and record login attempt
|
private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
|
||||||
* Returns { allowed: boolean, remainingAttempts: number, retryAfterSeconds?: number }
|
private static readonly API_WINDOW_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.apiWindowCleanupIntervalMs;
|
||||||
*/
|
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
|
||||||
async checkLoginAttempt(email: string): Promise<{
|
private static readonly API_WINDOW_RETENTION_WINDOWS = LIMITS.rateLimit.apiWindowRetentionWindows;
|
||||||
|
|
||||||
|
constructor(private db: D1Database) {}
|
||||||
|
|
||||||
|
private shouldRunCleanup(lastRunAt: number, intervalMs: number): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastRunAt < intervalMs) return false;
|
||||||
|
return Math.random() < RateLimitService.PERIODIC_CLEANUP_PROBABILITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async maybeCleanupLoginAttemptsIp(nowMs: number): Promise<void> {
|
||||||
|
if (!this.shouldRunCleanup(RateLimitService.lastLoginIpCleanupAt, RateLimitService.LOGIN_IP_CLEANUP_INTERVAL_MS)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cutoff = nowMs - RateLimitService.LOGIN_IP_RETENTION_MS;
|
||||||
|
await this.db
|
||||||
|
.prepare(
|
||||||
|
'DELETE FROM login_attempts_ip WHERE updated_at < ? AND (locked_until IS NULL OR locked_until < ?)'
|
||||||
|
)
|
||||||
|
.bind(cutoff, nowMs)
|
||||||
|
.run();
|
||||||
|
RateLimitService.lastLoginIpCleanupAt = nowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async 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 = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`;
|
await this.ensureLoginIpTable();
|
||||||
const data = await this.kv.get(key);
|
|
||||||
|
|
||||||
if (!data) {
|
const key = ip.trim() || 'unknown';
|
||||||
|
const now = Date.now();
|
||||||
|
await this.maybeCleanupLoginAttemptsIp(now);
|
||||||
|
|
||||||
|
const row = await this.db
|
||||||
|
.prepare('SELECT attempts, locked_until FROM login_attempts_ip WHERE ip = ?')
|
||||||
|
.bind(key)
|
||||||
|
.first<{ attempts: number; locked_until: number | null }>();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
|
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
|
||||||
}
|
}
|
||||||
|
|
||||||
const record: { attempts: number; lockedUntil?: number } = JSON.parse(data);
|
if (row.locked_until && row.locked_until > now) {
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
// Check if currently locked out
|
|
||||||
if (record.lockedUntil && record.lockedUntil > now) {
|
|
||||||
const retryAfterSeconds = Math.ceil((record.lockedUntil - now) / 1000);
|
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
remainingAttempts: 0,
|
remainingAttempts: 0,
|
||||||
retryAfterSeconds,
|
retryAfterSeconds: Math.ceil((row.locked_until - now) / 1000),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// If lockout expired, reset
|
if (row.locked_until && row.locked_until <= now) {
|
||||||
if (record.lockedUntil && record.lockedUntil <= now) {
|
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
|
||||||
await this.kv.delete(key);
|
|
||||||
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
|
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
|
||||||
}
|
}
|
||||||
|
|
||||||
const remainingAttempts = CONFIG.LOGIN_MAX_ATTEMPTS - record.attempts;
|
const remainingAttempts = Math.max(0, CONFIG.LOGIN_MAX_ATTEMPTS - (row.attempts || 0));
|
||||||
return { allowed: true, remainingAttempts };
|
return { allowed: true, remainingAttempts };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async recordFailedLogin(ip: string): Promise<{ locked: boolean; retryAfterSeconds?: number }> {
|
||||||
* Record a failed login attempt
|
await this.ensureLoginIpTable();
|
||||||
*/
|
|
||||||
async recordFailedLogin(email: string): Promise<{
|
|
||||||
locked: boolean;
|
|
||||||
retryAfterSeconds?: number;
|
|
||||||
}> {
|
|
||||||
const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`;
|
|
||||||
const data = await this.kv.get(key);
|
|
||||||
|
|
||||||
let record: { attempts: number; lockedUntil?: number };
|
const key = ip.trim() || 'unknown';
|
||||||
|
const now = Date.now();
|
||||||
|
await this.maybeCleanupLoginAttemptsIp(now);
|
||||||
|
|
||||||
if (data) {
|
// D1 in Workers forbids raw BEGIN/COMMIT statements.
|
||||||
record = JSON.parse(data);
|
// Use a single atomic UPSERT to increment attempts.
|
||||||
record.attempts += 1;
|
// This is concurrency-safe because the row is keyed by IP.
|
||||||
} else {
|
await this.db
|
||||||
record = { attempts: 1 };
|
.prepare(
|
||||||
|
'INSERT INTO login_attempts_ip(ip, attempts, locked_until, updated_at) VALUES(?, 1, NULL, ?) ' +
|
||||||
|
'ON CONFLICT(ip) DO UPDATE SET attempts = attempts + 1, updated_at = excluded.updated_at'
|
||||||
|
)
|
||||||
|
.bind(key, now)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const row = await this.db
|
||||||
|
.prepare('SELECT attempts FROM login_attempts_ip WHERE ip = ?')
|
||||||
|
.bind(key)
|
||||||
|
.first<{ attempts: number }>();
|
||||||
|
|
||||||
|
const attempts = row?.attempts || 1;
|
||||||
|
if (attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) {
|
||||||
|
const lockedUntil = now + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000;
|
||||||
|
await this.db
|
||||||
|
.prepare('UPDATE login_attempts_ip SET locked_until = ?, updated_at = ? WHERE ip = ?')
|
||||||
|
.bind(lockedUntil, now, key)
|
||||||
|
.run();
|
||||||
|
return { locked: true, retryAfterSeconds: CONFIG.LOGIN_LOCKOUT_MINUTES * 60 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if should lock out
|
|
||||||
if (record.attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) {
|
|
||||||
record.lockedUntil = Date.now() + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000;
|
|
||||||
await this.kv.put(key, JSON.stringify(record), {
|
|
||||||
expirationTtl: CONFIG.LOGIN_LOCKOUT_MINUTES * 60 + 60, // Extra minute buffer
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
locked: true,
|
|
||||||
retryAfterSeconds: CONFIG.LOGIN_LOCKOUT_MINUTES * 60,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store with expiration (auto-reset after lockout period even without lockout)
|
|
||||||
await this.kv.put(key, JSON.stringify(record), {
|
|
||||||
expirationTtl: CONFIG.LOGIN_LOCKOUT_MINUTES * 60,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { locked: false };
|
return { locked: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async clearLoginAttempts(ip: string): Promise<void> {
|
||||||
* Clear login attempts on successful login
|
await this.ensureLoginIpTable();
|
||||||
*/
|
const key = ip.trim() || 'unknown';
|
||||||
async clearLoginAttempts(email: string): Promise<void> {
|
await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run();
|
||||||
const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`;
|
|
||||||
await this.kv.delete(key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Atomically consume one budget unit for the current fixed window.
|
||||||
* Check API rate limit for a user or IP
|
// Uses SQLite UPSERT-with-WHERE so requests at/over limit do not increment.
|
||||||
* Returns { allowed: boolean, remaining: number, retryAfterSeconds?: number }
|
private async consumeFixedWindowBudget(
|
||||||
*/
|
identifier: string,
|
||||||
async checkApiRateLimit(identifier: string): Promise<{
|
maxRequests: number,
|
||||||
allowed: boolean;
|
windowSeconds: number
|
||||||
remaining: number;
|
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||||
retryAfterSeconds?: number;
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
}> {
|
const windowStart = nowSec - (nowSec % windowSeconds);
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const windowEnd = windowStart + windowSeconds;
|
||||||
const windowStart = now - (now % CONFIG.API_WINDOW_SECONDS);
|
await this.maybeCleanupApiWindows(windowStart, windowSeconds);
|
||||||
const key = `${KEYS.API_RATE}${identifier}:${windowStart}`;
|
|
||||||
|
|
||||||
const countStr = await this.kv.get(key);
|
const writeResult = await this.db
|
||||||
const count = countStr ? parseInt(countStr, 10) : 0;
|
.prepare(
|
||||||
|
'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' +
|
||||||
|
'ON CONFLICT(identifier, window_start) DO UPDATE SET count = count + 1 ' +
|
||||||
|
'WHERE api_rate_limits.count < ?'
|
||||||
|
)
|
||||||
|
.bind(identifier, windowStart, maxRequests)
|
||||||
|
.run();
|
||||||
|
|
||||||
if (count >= CONFIG.API_REQUESTS_PER_MINUTE) {
|
// No changed row means conflict happened and WHERE prevented increment:
|
||||||
const retryAfterSeconds = CONFIG.API_WINDOW_SECONDS - (now % CONFIG.API_WINDOW_SECONDS);
|
// current count is already at/above configured limit.
|
||||||
|
if ((writeResult.meta.changes ?? 0) === 0) {
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
remaining: 0,
|
remaining: 0,
|
||||||
retryAfterSeconds,
|
retryAfterSeconds: windowEnd - nowSec,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const row = await this.db
|
||||||
|
.prepare('SELECT count FROM api_rate_limits WHERE identifier = ? AND window_start = ?')
|
||||||
|
.bind(identifier, windowStart)
|
||||||
|
.first<{ count: number }>();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
return {
|
return {
|
||||||
allowed: true,
|
allowed: true,
|
||||||
remaining: CONFIG.API_REQUESTS_PER_MINUTE - count,
|
remaining: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const remaining = Math.max(0, maxRequests - row.count);
|
||||||
* Increment API request count
|
return { allowed: true, remaining };
|
||||||
*/
|
}
|
||||||
async incrementApiCount(identifier: string): Promise<void> {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const windowStart = now - (now % CONFIG.API_WINDOW_SECONDS);
|
|
||||||
const key = `${KEYS.API_RATE}${identifier}:${windowStart}`;
|
|
||||||
|
|
||||||
const countStr = await this.kv.get(key);
|
// Write budget for POST/PUT/DELETE/PATCH requests.
|
||||||
const count = countStr ? parseInt(countStr, 10) : 0;
|
async consumeApiWriteBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||||
|
return this.consumeFixedWindowBudget(
|
||||||
|
identifier,
|
||||||
|
CONFIG.API_WRITE_REQUESTS_PER_MINUTE,
|
||||||
|
CONFIG.API_WINDOW_SECONDS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.kv.put(key, (count + 1).toString(), {
|
// Read budget for GET /api/sync.
|
||||||
expirationTtl: CONFIG.API_WINDOW_SECONDS + 10, // Slight buffer
|
async consumeSyncReadBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||||
});
|
return this.consumeFixedWindowBudget(
|
||||||
|
identifier,
|
||||||
|
CONFIG.SYNC_READ_REQUESTS_PER_MINUTE,
|
||||||
|
CONFIG.API_WINDOW_SECONDS
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get client identifier from request (IP or CF-Connecting-IP)
|
|
||||||
*/
|
|
||||||
export function getClientIdentifier(request: Request): string {
|
export function getClientIdentifier(request: Request): string {
|
||||||
// Cloudflare provides the real client IP
|
|
||||||
const cfIp = request.headers.get('CF-Connecting-IP');
|
const cfIp = request.headers.get('CF-Connecting-IP');
|
||||||
if (cfIp) return cfIp;
|
if (cfIp) return cfIp;
|
||||||
|
|
||||||
// Fallback for local development
|
|
||||||
const forwardedFor = request.headers.get('X-Forwarded-For');
|
const forwardedFor = request.headers.get('X-Forwarded-For');
|
||||||
if (forwardedFor) return forwardedFor.split(',')[0].trim();
|
if (forwardedFor) return forwardedFor.split(',')[0].trim();
|
||||||
|
|
||||||
// Last resort
|
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|||||||
+567
-176
@@ -1,256 +1,647 @@
|
|||||||
import { Env, User, Cipher, Folder, Attachment } from '../types';
|
import { User, Cipher, Folder, Attachment } from '../types';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
const KEYS = {
|
// D1-backed storage.
|
||||||
CONFIG_REGISTERED: 'config:registered',
|
// Contract:
|
||||||
CONFIG_SETUP_DISABLED: 'config:setup_disabled',
|
// - All methods are scoped by userId where applicable.
|
||||||
USER_PREFIX: 'user:',
|
// - Uses SQL constraints (PK/unique/FK) to avoid KV-style index race conditions.
|
||||||
CIPHER_PREFIX: 'cipher:',
|
// - Revision date is maintained per user for Bitwarden sync.
|
||||||
FOLDER_PREFIX: 'folder:',
|
|
||||||
ATTACHMENT_PREFIX: 'attachment:',
|
|
||||||
CIPHERS_INDEX: 'index:ciphers',
|
|
||||||
FOLDERS_INDEX: 'index:folders',
|
|
||||||
ATTACHMENTS_INDEX: 'index:attachments',
|
|
||||||
REFRESH_TOKEN_PREFIX: 'refresh:',
|
|
||||||
REVISION_DATE_PREFIX: 'revision:',
|
|
||||||
};
|
|
||||||
|
|
||||||
export class StorageService {
|
export class StorageService {
|
||||||
constructor(private kv: KVNamespace) {}
|
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) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ---
|
||||||
|
// One-click deploy requires zero manual migration steps.
|
||||||
|
// This method idempotently creates required schema objects on first request.
|
||||||
|
async initializeDatabase(): Promise<void> {
|
||||||
|
if (StorageService.schemaVerified) return;
|
||||||
|
|
||||||
|
const schemaStatements = [
|
||||||
|
'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 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
StorageService.schemaVerified = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Config / setup ---
|
||||||
|
|
||||||
// Registration status
|
|
||||||
async isRegistered(): Promise<boolean> {
|
async isRegistered(): Promise<boolean> {
|
||||||
const value = await this.kv.get(KEYS.CONFIG_REGISTERED);
|
const row = await this.db.prepare('SELECT value FROM config WHERE key = ?').bind('registered').first<{ value: string }>();
|
||||||
return value === 'true';
|
return row?.value === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
async setRegistered(): Promise<void> {
|
async setRegistered(): Promise<void> {
|
||||||
await this.kv.put(KEYS.CONFIG_REGISTERED, 'true');
|
await this.db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||||
|
.bind('registered', 'true')
|
||||||
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup page visibility
|
|
||||||
async isSetupDisabled(): Promise<boolean> {
|
async isSetupDisabled(): Promise<boolean> {
|
||||||
const value = await this.kv.get(KEYS.CONFIG_SETUP_DISABLED);
|
const row = await this.db.prepare('SELECT value FROM config WHERE key = ?').bind('setup_disabled').first<{ value: string }>();
|
||||||
return value === 'true';
|
return row?.value === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
async setSetupDisabled(): Promise<void> {
|
async setSetupDisabled(): Promise<void> {
|
||||||
await this.kv.put(KEYS.CONFIG_SETUP_DISABLED, 'true');
|
await this.db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||||
|
.bind('setup_disabled', 'true')
|
||||||
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
// User operations
|
// --- Users ---
|
||||||
|
|
||||||
async getUser(email: string): Promise<User | null> {
|
async getUser(email: string): Promise<User | null> {
|
||||||
const data = await this.kv.get(`${KEYS.USER_PREFIX}${email.toLowerCase()}`);
|
const row = await this.db
|
||||||
return data ? JSON.parse(data) : null;
|
.prepare(
|
||||||
|
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at FROM users WHERE email = ?'
|
||||||
|
)
|
||||||
|
.bind(email.toLowerCase())
|
||||||
|
.first<any>();
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
name: row.name,
|
||||||
|
masterPasswordHash: row.master_password_hash,
|
||||||
|
key: row.key,
|
||||||
|
privateKey: row.private_key,
|
||||||
|
publicKey: row.public_key,
|
||||||
|
kdfType: row.kdf_type,
|
||||||
|
kdfIterations: row.kdf_iterations,
|
||||||
|
kdfMemory: row.kdf_memory ?? undefined,
|
||||||
|
kdfParallelism: row.kdf_parallelism ?? undefined,
|
||||||
|
securityStamp: row.security_stamp,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserById(id: string): Promise<User | null> {
|
async getUserById(id: string): Promise<User | null> {
|
||||||
// Get user email from id mapping
|
const row = await this.db
|
||||||
const email = await this.kv.get(`userid:${id}`);
|
.prepare(
|
||||||
if (!email) return null;
|
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at FROM users WHERE id = ?'
|
||||||
return this.getUser(email);
|
)
|
||||||
|
.bind(id)
|
||||||
|
.first<any>();
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
name: row.name,
|
||||||
|
masterPasswordHash: row.master_password_hash,
|
||||||
|
key: row.key,
|
||||||
|
privateKey: row.private_key,
|
||||||
|
publicKey: row.public_key,
|
||||||
|
kdfType: row.kdf_type,
|
||||||
|
kdfIterations: row.kdf_iterations,
|
||||||
|
kdfMemory: row.kdf_memory ?? undefined,
|
||||||
|
kdfParallelism: row.kdf_parallelism ?? undefined,
|
||||||
|
securityStamp: row.security_stamp,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveUser(user: User): Promise<void> {
|
async saveUser(user: User): Promise<void> {
|
||||||
await this.kv.put(`${KEYS.USER_PREFIX}${user.email.toLowerCase()}`, JSON.stringify(user));
|
const email = user.email.toLowerCase();
|
||||||
await this.kv.put(`userid:${user.id}`, 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) ' +
|
||||||
|
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
|
'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, ' +
|
||||||
|
'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,
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cipher operations
|
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 ---
|
||||||
|
|
||||||
async getCipher(id: string): Promise<Cipher | null> {
|
async getCipher(id: string): Promise<Cipher | null> {
|
||||||
const data = await this.kv.get(`${KEYS.CIPHER_PREFIX}${id}`);
|
const row = await this.db.prepare('SELECT data FROM ciphers WHERE id = ?').bind(id).first<{ data: string }>();
|
||||||
return data ? JSON.parse(data) : null;
|
return row?.data ? (JSON.parse(row.data) as Cipher) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveCipher(cipher: Cipher): Promise<void> {
|
async saveCipher(cipher: Cipher): Promise<void> {
|
||||||
await this.kv.put(`${KEYS.CIPHER_PREFIX}${cipher.id}`, JSON.stringify(cipher));
|
const data = JSON.stringify(cipher);
|
||||||
|
const stmt = this.db.prepare(
|
||||||
// Update index
|
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' +
|
||||||
const index = await this.getCipherIds(cipher.userId);
|
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
if (!index.includes(cipher.id)) {
|
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||||
index.push(cipher.id);
|
'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.kv.put(`${KEYS.CIPHERS_INDEX}:${cipher.userId}`, JSON.stringify(index));
|
);
|
||||||
}
|
await this.safeBind(stmt,
|
||||||
|
cipher.id,
|
||||||
|
cipher.userId,
|
||||||
|
Number(cipher.type) || 1,
|
||||||
|
cipher.folderId,
|
||||||
|
cipher.name,
|
||||||
|
cipher.notes,
|
||||||
|
cipher.favorite ? 1 : 0,
|
||||||
|
data,
|
||||||
|
cipher.reprompt ?? 0,
|
||||||
|
cipher.key,
|
||||||
|
cipher.createdAt,
|
||||||
|
cipher.updatedAt,
|
||||||
|
cipher.deletedAt
|
||||||
|
).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteCipher(id: string, userId: string): Promise<void> {
|
async deleteCipher(id: string, userId: string): Promise<void> {
|
||||||
await this.kv.delete(`${KEYS.CIPHER_PREFIX}${id}`);
|
// hard delete
|
||||||
|
await this.db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||||
// Update index
|
|
||||||
const index = await this.getCipherIds(userId);
|
|
||||||
const newIndex = index.filter(cid => cid !== id);
|
|
||||||
await this.kv.put(`${KEYS.CIPHERS_INDEX}:${userId}`, JSON.stringify(newIndex));
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCipherIds(userId: string): Promise<string[]> {
|
|
||||||
const data = await this.kv.get(`${KEYS.CIPHERS_INDEX}:${userId}`);
|
|
||||||
return data ? JSON.parse(data) : [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllCiphers(userId: string): Promise<Cipher[]> {
|
async getAllCiphers(userId: string): Promise<Cipher[]> {
|
||||||
const ids = await this.getCipherIds(userId);
|
const res = await this.db.prepare('SELECT data FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC').bind(userId).all<{ data: string }>();
|
||||||
const ciphers: Cipher[] = [];
|
return (res.results || []).map(r => JSON.parse(r.data) as Cipher);
|
||||||
|
|
||||||
for (const id of ids) {
|
|
||||||
const cipher = await this.getCipher(id);
|
|
||||||
if (cipher) ciphers.push(cipher);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ciphers;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Folder operations
|
|
||||||
async getFolder(id: string): Promise<Folder | null> {
|
|
||||||
const data = await this.kv.get(`${KEYS.FOLDER_PREFIX}${id}`);
|
|
||||||
return data ? JSON.parse(data) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveFolder(folder: Folder): Promise<void> {
|
|
||||||
await this.kv.put(`${KEYS.FOLDER_PREFIX}${folder.id}`, JSON.stringify(folder));
|
|
||||||
|
|
||||||
// Update index
|
|
||||||
const index = await this.getFolderIds(folder.userId);
|
|
||||||
if (!index.includes(folder.id)) {
|
|
||||||
index.push(folder.id);
|
|
||||||
await this.kv.put(`${KEYS.FOLDERS_INDEX}:${folder.userId}`, JSON.stringify(index));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteFolder(id: string, userId: string): Promise<void> {
|
|
||||||
await this.kv.delete(`${KEYS.FOLDER_PREFIX}${id}`);
|
|
||||||
|
|
||||||
// Update index
|
|
||||||
const index = await this.getFolderIds(userId);
|
|
||||||
const newIndex = index.filter(fid => fid !== id);
|
|
||||||
await this.kv.put(`${KEYS.FOLDERS_INDEX}:${userId}`, JSON.stringify(newIndex));
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFolderIds(userId: string): Promise<string[]> {
|
|
||||||
const data = await this.kv.get(`${KEYS.FOLDERS_INDEX}:${userId}`);
|
|
||||||
return data ? JSON.parse(data) : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAllFolders(userId: string): Promise<Folder[]> {
|
|
||||||
const ids = await this.getFolderIds(userId);
|
|
||||||
const folders: Folder[] = [];
|
|
||||||
|
|
||||||
for (const id of ids) {
|
|
||||||
const folder = await this.getFolder(id);
|
|
||||||
if (folder) folders.push(folder);
|
|
||||||
}
|
|
||||||
|
|
||||||
return folders;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh token operations
|
|
||||||
async saveRefreshToken(token: string, userId: string): Promise<void> {
|
|
||||||
// Store refresh token with 30 day expiry
|
|
||||||
await this.kv.put(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`, userId, {
|
|
||||||
expirationTtl: 30 * 24 * 60 * 60,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRefreshTokenUserId(token: string): Promise<string | null> {
|
|
||||||
return await this.kv.get(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteRefreshToken(token: string): Promise<void> {
|
|
||||||
await this.kv.delete(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revision date operations (for incremental sync)
|
|
||||||
async getRevisionDate(userId: string): Promise<string> {
|
|
||||||
const date = await this.kv.get(`${KEYS.REVISION_DATE_PREFIX}${userId}`);
|
|
||||||
return date || new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateRevisionDate(userId: string): Promise<string> {
|
|
||||||
const date = new Date().toISOString();
|
|
||||||
await this.kv.put(`${KEYS.REVISION_DATE_PREFIX}${userId}`, date);
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bulk cipher operations
|
|
||||||
async getCiphersByIds(ids: string[], userId: string): Promise<Cipher[]> {
|
async getCiphersByIds(ids: string[], userId: string): Promise<Cipher[]> {
|
||||||
const ciphers: Cipher[] = [];
|
if (ids.length === 0) return [];
|
||||||
for (const id of ids) {
|
// D1 doesn't support binding arrays directly; build placeholders.
|
||||||
const cipher = await this.getCipher(id);
|
const placeholders = ids.map(() => '?').join(',');
|
||||||
if (cipher && cipher.userId === userId) {
|
const stmt = this.db.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`);
|
||||||
ciphers.push(cipher);
|
const res = await stmt.bind(userId, ...ids).all<{ data: string }>();
|
||||||
}
|
return (res.results || []).map(r => JSON.parse(r.data) as Cipher);
|
||||||
}
|
|
||||||
return ciphers;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
for (const id of ids) {
|
const uniqueIds = Array.from(new Set(ids));
|
||||||
const cipher = await this.getCipher(id);
|
const patch = JSON.stringify({
|
||||||
if (cipher && cipher.userId === userId) {
|
folderId,
|
||||||
cipher.folderId = folderId;
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
const chunkSize = LIMITS.performance.bulkMoveChunkSize;
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
|
||||||
|
await this.db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE ciphers
|
||||||
|
SET folder_id = ?, updated_at = ?, data = json_patch(data, ?)
|
||||||
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
|
)
|
||||||
|
.bind(folderId, now, patch, userId, ...chunk)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Folders ---
|
||||||
|
|
||||||
|
async getFolder(id: string): Promise<Folder | null> {
|
||||||
|
const row = await this.db
|
||||||
|
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ?')
|
||||||
|
.bind(id)
|
||||||
|
.first<any>();
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
name: row.name,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveFolder(folder: Folder): Promise<void> {
|
||||||
|
await this.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)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFolder(id: string, userId: string): Promise<void> {
|
||||||
|
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;
|
cipher.updatedAt = now;
|
||||||
await this.saveCipher(cipher);
|
await this.saveCipher(cipher);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.updateRevisionDate(userId);
|
|
||||||
|
async getAllFolders(userId: string): 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')
|
||||||
|
.bind(userId)
|
||||||
|
.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,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attachment operations
|
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 ---
|
||||||
|
|
||||||
async getAttachment(id: string): Promise<Attachment | null> {
|
async getAttachment(id: string): Promise<Attachment | null> {
|
||||||
const data = await this.kv.get(`${KEYS.ATTACHMENT_PREFIX}${id}`);
|
const row = await this.db
|
||||||
return data ? JSON.parse(data) : null;
|
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE id = ?')
|
||||||
|
.bind(id)
|
||||||
|
.first<any>();
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
cipherId: row.cipher_id,
|
||||||
|
fileName: row.file_name,
|
||||||
|
size: row.size,
|
||||||
|
sizeName: row.size_name,
|
||||||
|
key: row.key,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveAttachment(attachment: Attachment): Promise<void> {
|
async saveAttachment(attachment: Attachment): Promise<void> {
|
||||||
await this.kv.put(`${KEYS.ATTACHMENT_PREFIX}${attachment.id}`, JSON.stringify(attachment));
|
const stmt = this.db.prepare(
|
||||||
|
'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'
|
||||||
|
);
|
||||||
|
await this.safeBind(stmt, 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> {
|
||||||
await this.kv.delete(`${KEYS.ATTACHMENT_PREFIX}${id}`);
|
await this.db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
|
||||||
}
|
|
||||||
|
|
||||||
async getAttachmentIdsByCipher(cipherId: string): Promise<string[]> {
|
|
||||||
const data = await this.kv.get(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`);
|
|
||||||
return data ? JSON.parse(data) : [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAttachmentsByCipher(cipherId: string): Promise<Attachment[]> {
|
async getAttachmentsByCipher(cipherId: string): Promise<Attachment[]> {
|
||||||
const ids = await this.getAttachmentIdsByCipher(cipherId);
|
const res = await this.db
|
||||||
const attachments: Attachment[] = [];
|
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?')
|
||||||
for (const id of ids) {
|
.bind(cipherId)
|
||||||
const attachment = await this.getAttachment(id);
|
.all<any>();
|
||||||
if (attachment) attachments.push(attachment);
|
return (res.results || []).map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
cipherId: r.cipher_id,
|
||||||
|
fileName: r.file_name,
|
||||||
|
size: r.size,
|
||||||
|
sizeName: r.size_name,
|
||||||
|
key: r.key,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
return attachments;
|
|
||||||
|
async getAttachmentsByCipherIds(cipherIds: string[]): Promise<Map<string, Attachment[]>> {
|
||||||
|
const grouped = new Map<string, Attachment[]>();
|
||||||
|
if (cipherIds.length === 0) return grouped;
|
||||||
|
|
||||||
|
const placeholders = cipherIds.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(...cipherIds)
|
||||||
|
.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> {
|
||||||
const ids = await this.getAttachmentIdsByCipher(cipherId);
|
// Kept for API compatibility; no-op because attachments table already links cipher_id.
|
||||||
if (!ids.includes(attachmentId)) {
|
// We still validate that the attachment exists and belongs to cipher.
|
||||||
ids.push(attachmentId);
|
await this.db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
|
||||||
await this.kv.put(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`, JSON.stringify(ids));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
|
async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
|
||||||
const ids = await this.getAttachmentIdsByCipher(cipherId);
|
// No-op: schema uses NOT NULL cipher_id.
|
||||||
const newIds = ids.filter(id => id !== attachmentId);
|
// Callers always delete attachment row afterwards, so this method is kept for compatibility only.
|
||||||
await this.kv.put(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`, JSON.stringify(newIds));
|
void cipherId;
|
||||||
|
void attachmentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
|
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
|
||||||
const ids = await this.getAttachmentIdsByCipher(cipherId);
|
await this.db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
|
||||||
for (const id of ids) {
|
|
||||||
await this.deleteAttachment(id);
|
|
||||||
}
|
|
||||||
await this.kv.delete(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateCipherRevisionDate(cipherId: string): Promise<void> {
|
async updateCipherRevisionDate(cipherId: string): Promise<void> {
|
||||||
const cipher = await this.getCipher(cipherId);
|
const cipher = await this.getCipher(cipherId);
|
||||||
if (cipher) {
|
if (!cipher) return;
|
||||||
cipher.updatedAt = new Date().toISOString();
|
cipher.updatedAt = new Date().toISOString();
|
||||||
await this.saveCipher(cipher);
|
await this.saveCipher(cipher);
|
||||||
await this.updateRevisionDate(cipher.userId);
|
await this.updateRevisionDate(cipher.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Refresh tokens ---
|
||||||
|
|
||||||
|
async saveRefreshToken(token: string, userId: string, expiresAtMs?: number): Promise<void> {
|
||||||
|
const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs);
|
||||||
|
await this.maybeCleanupExpiredRefreshTokens(Date.now());
|
||||||
|
const tokenKey = await this.refreshTokenKey(token);
|
||||||
|
await this.db.prepare(
|
||||||
|
'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'
|
||||||
|
)
|
||||||
|
.bind(tokenKey, userId, expiresAt)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRefreshTokenUserId(token: string): Promise<string | null> {
|
||||||
|
const now = Date.now();
|
||||||
|
await this.maybeCleanupExpiredRefreshTokens(now);
|
||||||
|
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 }>();
|
||||||
|
|
||||||
|
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.expires_at && row.expires_at < now) {
|
||||||
|
await this.deleteRefreshToken(token);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return row.user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(tokenKey).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Revision dates ---
|
||||||
|
|
||||||
|
async getRevisionDate(userId: string): Promise<string> {
|
||||||
|
const row = await this.db.prepare('SELECT revision_date FROM user_revisions WHERE user_id = ?')
|
||||||
|
.bind(userId)
|
||||||
|
.first<{ revision_date: string }>();
|
||||||
|
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> {
|
||||||
|
const date = new Date().toISOString();
|
||||||
|
await this.db.prepare(
|
||||||
|
'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' +
|
||||||
|
'ON CONFLICT(user_id) DO UPDATE SET revision_date = excluded.revision_date'
|
||||||
|
)
|
||||||
|
.bind(userId, date)
|
||||||
|
.run();
|
||||||
|
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
+34
-8
@@ -1,10 +1,14 @@
|
|||||||
// Environment bindings
|
// Environment bindings
|
||||||
export interface Env {
|
export interface Env {
|
||||||
VAULT: KVNamespace;
|
DB: D1Database;
|
||||||
ATTACHMENTS: R2Bucket;
|
ATTACHMENTS: R2Bucket;
|
||||||
JWT_SECRET: string;
|
JWT_SECRET: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sample JWT secret used by `.dev.vars.example`.
|
||||||
|
// If runtime JWT_SECRET equals this value, treat it as unsafe.
|
||||||
|
export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters';
|
||||||
|
|
||||||
// Attachment model
|
// Attachment model
|
||||||
export interface Attachment {
|
export interface Attachment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,7 +23,7 @@ export interface Attachment {
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string | null;
|
||||||
masterPasswordHash: string;
|
masterPasswordHash: string;
|
||||||
key: string;
|
key: string;
|
||||||
privateKey: string | null;
|
privateKey: string | null;
|
||||||
@@ -115,7 +119,7 @@ export interface Cipher {
|
|||||||
userId: string;
|
userId: string;
|
||||||
type: CipherType;
|
type: CipherType;
|
||||||
folderId: string | null;
|
folderId: string | null;
|
||||||
name: string;
|
name: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
favorite: boolean;
|
favorite: boolean;
|
||||||
login: CipherLogin | null;
|
login: CipherLogin | null;
|
||||||
@@ -130,6 +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
|
||||||
@@ -145,7 +151,7 @@ export interface Folder {
|
|||||||
export interface JWTPayload {
|
export interface JWTPayload {
|
||||||
sub: string; // user id
|
sub: string; // user id
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string | null;
|
||||||
email_verified: boolean; // required by mobile client
|
email_verified: boolean; // required by mobile client
|
||||||
amr: string[]; // authentication methods reference - required by mobile client
|
amr: string[]; // authentication methods reference - required by mobile client
|
||||||
sstamp: string; // security stamp - invalidates token when user changes password
|
sstamp: string; // security stamp - invalidates token when user changes password
|
||||||
@@ -166,13 +172,16 @@ export interface MasterPasswordUnlockKdf {
|
|||||||
export interface MasterPasswordUnlock {
|
export interface MasterPasswordUnlock {
|
||||||
Kdf: MasterPasswordUnlockKdf;
|
Kdf: MasterPasswordUnlockKdf;
|
||||||
MasterKeyEncryptedUserKey: string;
|
MasterKeyEncryptedUserKey: string;
|
||||||
|
MasterKeyWrappedUserKey: string;
|
||||||
Salt: string;
|
Salt: string;
|
||||||
|
Object: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserDecryptionOptions {
|
export interface UserDecryptionOptions {
|
||||||
HasMasterPassword: boolean;
|
HasMasterPassword: boolean;
|
||||||
Object: string;
|
Object: string;
|
||||||
MasterPasswordUnlock?: MasterPasswordUnlock;
|
// Bitwarden Android 2026.1.x expects this to exist; missing it breaks unlock when the vault is empty.
|
||||||
|
MasterPasswordUnlock: MasterPasswordUnlock;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API Response types
|
// API Response types
|
||||||
@@ -196,7 +205,7 @@ export interface TokenResponse {
|
|||||||
|
|
||||||
export interface ProfileResponse {
|
export interface ProfileResponse {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
emailVerified: boolean;
|
emailVerified: boolean;
|
||||||
premium: boolean;
|
premium: boolean;
|
||||||
@@ -223,7 +232,7 @@ export interface CipherResponse {
|
|||||||
organizationId: string | null;
|
organizationId: string | null;
|
||||||
folderId: string | null;
|
folderId: string | null;
|
||||||
type: number;
|
type: number;
|
||||||
name: string;
|
name: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
favorite: boolean;
|
favorite: boolean;
|
||||||
login: CipherLogin | null;
|
login: CipherLogin | null;
|
||||||
@@ -247,6 +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 {
|
||||||
@@ -269,6 +280,21 @@ export interface SyncResponse {
|
|||||||
domains: any;
|
domains: any;
|
||||||
policies: any[];
|
policies: any[];
|
||||||
sends: any[];
|
sends: any[];
|
||||||
userDecryption: any | null;
|
// PascalCase for desktop/browser clients
|
||||||
|
UserDecryptionOptions: UserDecryptionOptions | null;
|
||||||
|
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
||||||
|
userDecryption: {
|
||||||
|
masterPasswordUnlock: {
|
||||||
|
kdf: {
|
||||||
|
kdfType: number;
|
||||||
|
iterations: number;
|
||||||
|
memory: number | null;
|
||||||
|
parallelism: number | null;
|
||||||
|
};
|
||||||
|
masterKeyWrappedUserKey: string;
|
||||||
|
masterKeyEncryptedUserKey: string;
|
||||||
|
salt: string;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
object: string;
|
object: string;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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(),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -2,10 +2,10 @@ name = "nodewarden"
|
|||||||
main = "src/index.ts"
|
main = "src/index.ts"
|
||||||
compatibility_date = "2024-01-01"
|
compatibility_date = "2024-01-01"
|
||||||
|
|
||||||
# KV Namespace for storing vault data
|
# D1 Database for storing vault data
|
||||||
[[kv_namespaces]]
|
[[d1_databases]]
|
||||||
binding = "VAULT"
|
binding = "DB"
|
||||||
id = "placeholder"
|
database_name = "nodewarden-db"
|
||||||
|
|
||||||
# R2 Bucket for storing attachments
|
# R2 Bucket for storing attachments
|
||||||
[[r2_buckets]]
|
[[r2_buckets]]
|
||||||
|
|||||||
Reference in New Issue
Block a user