mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
Basic success
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
# JWT Secret for signing tokens (required)
|
||||||
|
# Generate one with: openssl rand -hex 32
|
||||||
|
JWT_SECRET=your-secret-key-herexxs22fd2ds
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 自动同步上游更新
|
||||||
|
|
||||||
|
这个 GitHub Actions 工作流会自动将上游仓库(shuaiplus/nodewarden)的更新同步到你的 fork。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- ✅ 每天自动检查并同步上游更新
|
||||||
|
- ✅ 支持手动触发同步
|
||||||
|
- ✅ 自动处理简单的合并
|
||||||
|
- ⚠️ 遇到冲突时会提醒你手动处理
|
||||||
|
|
||||||
|
## 如何启用
|
||||||
|
|
||||||
|
1. 在你 fork 的仓库中,进入 **Actions** 标签页
|
||||||
|
2. 点击 **I understand my workflows, go ahead and enable them**
|
||||||
|
3. 完成!工作流会自动运行
|
||||||
|
|
||||||
|
## 手动触发
|
||||||
|
|
||||||
|
1. 进入 **Actions** 标签页
|
||||||
|
2. 选择 **Sync Fork with Upstream**
|
||||||
|
3. 点击 **Run workflow** → **Run workflow**
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 如果你修改了代码,可能会产生合并冲突
|
||||||
|
- 遇到冲突时,工作流会失败,需要你手动解决
|
||||||
|
- 建议不要修改核心代码,只修改配置文件
|
||||||
|
|
||||||
|
## 禁用自动同步
|
||||||
|
|
||||||
|
如果你不想自动同步:
|
||||||
|
|
||||||
|
1. 进入 **Actions** 标签页
|
||||||
|
2. 选择 **Sync Fork with Upstream**
|
||||||
|
3. 点击右上角的 **···** → **Disable workflow**
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
name: Sync Fork with Upstream
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Run every day at 2:00 AM UTC
|
||||||
|
- cron: '0 2 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
# Allow manual trigger
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Configure Git
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Sync with upstream
|
||||||
|
run: |
|
||||||
|
# Add upstream repository
|
||||||
|
git remote add upstream https://github.com/shuaiplus/nodewarden.git || true
|
||||||
|
|
||||||
|
# Fetch upstream changes
|
||||||
|
git fetch upstream
|
||||||
|
|
||||||
|
# Check if there are updates
|
||||||
|
BEHIND=$(git rev-list --count HEAD..upstream/main)
|
||||||
|
|
||||||
|
if [ "$BEHIND" -gt 0 ]; then
|
||||||
|
echo "Found $BEHIND commits behind upstream. Syncing..."
|
||||||
|
|
||||||
|
# Try to merge upstream/main into current branch
|
||||||
|
if git merge upstream/main --no-edit; then
|
||||||
|
echo "Merge successful!"
|
||||||
|
git push origin main
|
||||||
|
else
|
||||||
|
echo "Merge conflict detected. Please resolve manually."
|
||||||
|
echo "::warning::Failed to auto-sync due to merge conflicts. Manual intervention required."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Already up to date with upstream."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "## Sync Summary" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Upstream**: shuaiplus/nodewarden" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Branch**: main" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Status**: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Wrangler
|
||||||
|
.wrangler/
|
||||||
|
.dev.vars
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Package lock (optional - remove if you want to commit it)
|
||||||
|
# package-lock.json
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# NodeWarden
|
||||||
|
一个基于 Cloudflare Workers 的 Bitwarden 兼容服务器实现,专为个人用户设计。
|
||||||
|
|
||||||
|
[English](./README_EN.md) | 中文
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **免责声明**
|
||||||
|
> 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份您的密码库。
|
||||||
|
> 本项目与 Bitwarden 官方无关,请勿向 Bitwarden 官方反馈问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
- ✅ 完全免费,不需要在服务器上部署,再次感谢大善人!
|
||||||
|
- ✅ 完整的密码、笔记、卡片、身份信息管理
|
||||||
|
- ✅ 文件夹和收藏功能
|
||||||
|
- ✅ 文件附件支持(基于 R2 存储)
|
||||||
|
- ✅ 导入/导出功能
|
||||||
|
- ✅ 网站图标获取
|
||||||
|
- ✅ 登录限速保护(5 次失败后锁定 15 分钟)
|
||||||
|
- ✅ API 访问频率限制(60 次/分钟)
|
||||||
|
- ✅ 端到端加密(服务器无法查看明文)
|
||||||
|
- ✅ 兼容所有 Bitwarden 官方客户端
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 一键部署
|
||||||
|
|
||||||
|
点击下方按钮部署到 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 等待部署完成
|
||||||
|
|
||||||
|
> ⚠️ **再次提醒**:请务必使用强随机的 `JWT_SECRET`,使用默认或弱密钥可能导致账户被入侵,**后果自负!**
|
||||||
|
|
||||||
|
### 配置客户端
|
||||||
|
|
||||||
|
部署完成后,在任意 Bitwarden 客户端中:
|
||||||
|
|
||||||
|
1. 打开设置(⚙️)
|
||||||
|
2. 选择「自托管环境」
|
||||||
|
3. 服务器 URL 填入:`https://你的项目名`
|
||||||
|
4. 保存并返回登录页面
|
||||||
|
|
||||||
|
**首次注册**:直接访问 Workers 地址,在网页上完成账户注册。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 手动部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆项目
|
||||||
|
git clone https://github.com/shuaiplus/nodewarden.git
|
||||||
|
cd nodewarden
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 登录 Cloudflare
|
||||||
|
npx wrangler login
|
||||||
|
|
||||||
|
# 创建 KV 存储
|
||||||
|
npx wrangler kv namespace create VAULT
|
||||||
|
# 将输出的 id 填入 wrangler.toml 的 [[kv_namespaces]]
|
||||||
|
|
||||||
|
# 创建 R2 存储桶(用于文件附件)
|
||||||
|
npx wrangler r2 bucket create nodewarden-attachments
|
||||||
|
|
||||||
|
# 设置 JWT 密钥(请使用强随机字符串)
|
||||||
|
npx wrangler secret put JWT_SECRET
|
||||||
|
# 建议使用:openssl rand -hex 32
|
||||||
|
|
||||||
|
# 部署
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NodeWarden vs Vaultwarden
|
||||||
|
|
||||||
|
NodeWarden 专注于**个人用户**的核心功能,保持代码简洁。以下是与 Vaultwarden 的功能对比:
|
||||||
|
|
||||||
|
| 功能 | NodeWarden | Vaultwarden | 说明 |
|
||||||
|
|------|:----------:|:-----------:|------|
|
||||||
|
| 密码/笔记/卡片/身份 | ✅ | ✅ | 完整支持 |
|
||||||
|
| 文件夹 & 收藏 | ✅ | ✅ | 完整支持 |
|
||||||
|
| 文件附件 | ✅ | ✅ | 使用 R2 存储,100MB 限制 |
|
||||||
|
| 导入/导出 | ✅ | ✅ | 完整支持 |
|
||||||
|
| 网站图标 | ✅ | ✅ | 代理获取 |
|
||||||
|
| 登录限速 | ✅ | ✅ | 防暴力破解 |
|
||||||
|
| 单用户模式 | ✅ | ✅ | 个人使用 |
|
||||||
|
| Bitwarden Send | ❌ | ✅ | 安全分享功能 |
|
||||||
|
| 两步验证 (2FA) | ❌ | ✅ | TOTP/WebAuthn 等 |
|
||||||
|
| 紧急访问 | ❌ | ✅ | 紧急联系人访问 |
|
||||||
|
| 组织/团队 | ❌ | ✅ | 多用户协作 |
|
||||||
|
| 实时同步 (WebSocket) | ❌ | ✅ | 多设备即时推送 |
|
||||||
|
| 邮件通知 | ❌ | ✅ | 需要 SMTP |
|
||||||
|
| 修改主密码 | ❌ | ✅ | 重新加密数据 |
|
||||||
|
| Admin 管理页 | ❌ | ✅ | 后台管理 |
|
||||||
|
|
||||||
|
> **💡 选择建议**
|
||||||
|
> 如果你只需要个人密码管理,NodeWarden 足够使用且部署更简单。
|
||||||
|
> 如果需要团队功能或高级特性,建议使用 [Vaultwarden](https://github.com/dani-garcia/vaultwarden)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 更新指南
|
||||||
|
|
||||||
|
如果你通过一键部署按钮安装,代码会被 fork 到你的 GitHub 账户。要获取最新更新:
|
||||||
|
|
||||||
|
### 方法 1:手动同步(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在你的 fork 仓库中
|
||||||
|
git remote add upstream https://github.com/shuaiplus/nodewarden.git
|
||||||
|
git fetch upstream
|
||||||
|
git merge upstream/main
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法 2:GitHub Actions 自动同步
|
||||||
|
|
||||||
|
项目已内置自动同步配置,在你的 fork 仓库中:
|
||||||
|
|
||||||
|
1. 进入 **Actions** 标签页
|
||||||
|
2. 如果看到提示"Workflows aren't being run on this forked repository",点击 **I understand my workflows, go ahead and enable them**
|
||||||
|
3. 自动同步将每天运行一次(UTC 时间凌晨 2 点)
|
||||||
|
4. 也可以点击 **Sync Fork with Upstream** → **Run workflow** 手动触发
|
||||||
|
|
||||||
|
> **⚠️ 注意**:如果你修改了代码,自动同步可能会产生冲突,需要手动解决。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 限制(本人认为完全没必要的功能)
|
||||||
|
|
||||||
|
- 不支持两步验证
|
||||||
|
- 不支持组织/团队功能
|
||||||
|
- 不支持修改主密码
|
||||||
|
- 文件附件大小限制 100MB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **运行环境**:Cloudflare Workers
|
||||||
|
- **数据存储**:Cloudflare KV
|
||||||
|
- **文件存储**:Cloudflare R2
|
||||||
|
- **开发语言**:TypeScript
|
||||||
|
- **加密算法**:客户端 AES-256-CBC,JWT 使用 HS256
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
**Q: 如何备份数据?**
|
||||||
|
A: 在客户端中选择「导出密码库」,保存 JSON 文件。
|
||||||
|
|
||||||
|
**Q: 忘记主密码怎么办?**
|
||||||
|
A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。
|
||||||
|
|
||||||
|
**Q: 可以多人使用吗?**
|
||||||
|
A: 不建议。本项目为单用户设计,多人使用请选择 Vaultwarden。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 开源协议
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
- [Bitwarden](https://bitwarden.com/) - 原始设计和客户端
|
||||||
|
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务器实现参考
|
||||||
|
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
||||||
+200
@@ -0,0 +1,200 @@
|
|||||||
|
# NodeWarden
|
||||||
|
|
||||||
|
An alternative implementation of the Bitwarden server API running on Cloudflare Workers, designed for personal use.
|
||||||
|
|
||||||
|
English | [中文](./README.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Important Notice
|
||||||
|
|
||||||
|
|
||||||
|
> **Disclaimer**
|
||||||
|
> This project is for educational purposes only. We are not responsible for any data loss. Regular backups are strongly recommended.
|
||||||
|
> This project is not associated with Bitwarden. Do not report issues to Bitwarden's official support channels.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ Full password, note, card, and identity management
|
||||||
|
- ✅ Folders and favorites
|
||||||
|
- ✅ File attachments (R2 storage, 100MB limit)
|
||||||
|
- ✅ Import/Export functionality
|
||||||
|
- ✅ Website icons
|
||||||
|
- ✅ Login rate limiting (lockout after 5 failed attempts for 15 minutes)
|
||||||
|
- ✅ API rate limiting (60 requests/minute)
|
||||||
|
- ✅ End-to-end encryption (server cannot access plaintext)
|
||||||
|
- ✅ Compatible with all official Bitwarden clients
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### One-Click Deploy
|
||||||
|
|
||||||
|
Click the button below to deploy to Cloudflare Workers:
|
||||||
|
|
||||||
|
[](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
|
||||||
|
|
||||||
|
**Deployment Steps:**
|
||||||
|
|
||||||
|
1. Sign in with GitHub and authorize
|
||||||
|
2. Log in to your Cloudflare account
|
||||||
|
3. **Important**: Set `JWT_SECRET` to a strong random string (use `openssl rand -hex 32`)
|
||||||
|
4. KV storage and R2 bucket will be auto-provisioned
|
||||||
|
5. Click Deploy and wait for completion
|
||||||
|
|
||||||
|
> ⚠️ **Reminder**: Always use a strong random `JWT_SECRET`. Never use example values or simple strings!
|
||||||
|
|
||||||
|
### Client Setup
|
||||||
|
|
||||||
|
After deployment, open any Bitwarden client:
|
||||||
|
|
||||||
|
1. Click Settings (⚙️)
|
||||||
|
2. Select "Self-hosted environment"
|
||||||
|
3. Enter Server URL: `https://your-project.workers.dev`
|
||||||
|
4. Save and return to login page
|
||||||
|
|
||||||
|
**First-time registration**: Visit your Workers URL directly to register an account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone
|
||||||
|
git clone https://github.com/shuaiplus/nodewarden.git
|
||||||
|
cd nodewarden
|
||||||
|
|
||||||
|
# Install
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Login to Cloudflare
|
||||||
|
npx wrangler login
|
||||||
|
|
||||||
|
# Create KV storage
|
||||||
|
npx wrangler kv namespace create VAULT
|
||||||
|
# Copy the id to wrangler.toml [[kv_namespaces]]
|
||||||
|
|
||||||
|
# Create R2 bucket (for file attachments)
|
||||||
|
npx wrangler r2 bucket create nodewarden-attachments
|
||||||
|
|
||||||
|
# Set JWT secret (use a strong random string)
|
||||||
|
npx wrangler secret put JWT_SECRET
|
||||||
|
# Recommended: openssl rand -hex 32
|
||||||
|
|
||||||
|
# Deploy
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NodeWarden vs Vaultwarden
|
||||||
|
|
||||||
|
NodeWarden focuses on **personal users** with core features, keeping the codebase minimal. Here's a comparison with Vaultwarden:
|
||||||
|
|
||||||
|
| Feature | NodeWarden | Vaultwarden | Notes |
|
||||||
|
|---------|:----------:|:-----------:|-------|
|
||||||
|
| Passwords/Notes/Cards/Identity | ✅ | ✅ | Full support |
|
||||||
|
| Folders & Favorites | ✅ | ✅ | Full support |
|
||||||
|
| File Attachments | ✅ | ✅ | R2 storage, 100MB limit |
|
||||||
|
| Import/Export | ✅ | ✅ | Full support |
|
||||||
|
| Website Icons | ✅ | ✅ | Proxy fetch |
|
||||||
|
| Login Rate Limiting | ✅ | ✅ | Brute-force protection |
|
||||||
|
| Single User Mode | ✅ | ✅ | Personal use |
|
||||||
|
| Bitwarden Send | ❌ | ✅ | Secure sharing |
|
||||||
|
| Two-Factor Auth (2FA) | ❌ | ✅ | TOTP/WebAuthn etc |
|
||||||
|
| Emergency Access | ❌ | ✅ | Emergency contacts |
|
||||||
|
| Organizations/Teams | ❌ | ✅ | Multi-user collaboration |
|
||||||
|
| Real-time Sync (WebSocket) | ❌ | ✅ | Instant multi-device push |
|
||||||
|
| Email Notifications | ❌ | ✅ | Requires SMTP |
|
||||||
|
| Change Master Password | ❌ | ✅ | Re-encrypt vault |
|
||||||
|
| Admin Panel | ❌ | ✅ | Backend management |
|
||||||
|
|
||||||
|
> **💡 Recommendation**
|
||||||
|
> If you only need personal password management, NodeWarden is sufficient and easier to deploy.
|
||||||
|
> For team features or advanced capabilities, consider [Vaultwarden](https://github.com/dani-garcia/vaultwarden).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Update Guide
|
||||||
|
|
||||||
|
If you deployed via the one-click button, the code is forked to your GitHub account. To get the latest updates:
|
||||||
|
|
||||||
|
### Method 1: Manual Sync (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In your forked repository
|
||||||
|
git remote add upstream https://github.com/shuaiplus/nodewarden.git
|
||||||
|
git fetch upstream
|
||||||
|
git merge upstream/main
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 2: GitHub Actions Auto-Sync
|
||||||
|
|
||||||
|
The project includes built-in auto-sync configuration. In your forked repository:
|
||||||
|
|
||||||
|
1. Go to the **Actions** tab
|
||||||
|
2. If you see "Workflows aren't being run on this forked repository", click **I understand my workflows, go ahead and enable them**
|
||||||
|
3. Auto-sync will run daily at 2:00 AM UTC
|
||||||
|
4. You can also manually trigger by clicking **Sync Fork with Upstream** → **Run workflow**
|
||||||
|
|
||||||
|
> **⚠️ Note**: If you've modified the code, auto-sync may cause merge conflicts that require manual resolution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Single user only (personal use)
|
||||||
|
- No two-factor authentication
|
||||||
|
- No organization/team support
|
||||||
|
- Cannot change master password
|
||||||
|
- File attachment size limit: 100MB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime**: Cloudflare Workers
|
||||||
|
- **Data Storage**: Cloudflare KV
|
||||||
|
- **File Storage**: Cloudflare R2
|
||||||
|
- **Language**: TypeScript
|
||||||
|
- **Encryption**: Client-side AES-256-CBC, JWT with HS256
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Recommendations
|
||||||
|
|
||||||
|
1. **Strong JWT_SECRET**: Generate with `openssl rand -hex 32`
|
||||||
|
2. **Regular Backups**: Export your vault and store securely
|
||||||
|
3. **HTTPS Access**: Cloudflare Workers provides HTTPS by default
|
||||||
|
4. **Access Control**: Use Cloudflare WAF rules or IP whitelist
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Q: How to backup data?**
|
||||||
|
A: In the client, select "Export Vault" and save the JSON file.
|
||||||
|
|
||||||
|
**Q: Forgot master password?**
|
||||||
|
A: Cannot be recovered due to end-to-end encryption. Keep your master password safe.
|
||||||
|
|
||||||
|
**Q: Can multiple people use it?**
|
||||||
|
A: Not recommended. This project is designed for single user. Use Vaultwarden for multi-user scenarios.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
- [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
|
||||||
Generated
+1531
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "nodewarden",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||||
|
"author": "shuaiplus",
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"bitwarden",
|
||||||
|
"vaultwarden",
|
||||||
|
"cloudflare",
|
||||||
|
"workers",
|
||||||
|
"password-manager"
|
||||||
|
],
|
||||||
|
"cloudflare": {
|
||||||
|
"bindings": {
|
||||||
|
"JWT_SECRET": {
|
||||||
|
"description": "用于签名 JWT 的密钥。请输入一个随机的复杂字符串(建议 32 位以上)"
|
||||||
|
},
|
||||||
|
"VAULT": {
|
||||||
|
"description": "用于存储密码库数据的 KV 存储"
|
||||||
|
},
|
||||||
|
"ATTACHMENTS": {
|
||||||
|
"description": "用于存储文件附件的 R2 存储桶"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20260131.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"wrangler": "^4.61.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { Env, User, ProfileResponse } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { AuthService } from '../services/auth';
|
||||||
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
|
||||||
|
// POST /api/accounts/register (only used from setup page, not client)
|
||||||
|
export async function handleRegister(request: Request, env: Env): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
|
||||||
|
// Check if already registered
|
||||||
|
const isRegistered = await storage.isRegistered();
|
||||||
|
if (isRegistered) {
|
||||||
|
return errorResponse('Registration is closed', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: {
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
masterPasswordHash?: string;
|
||||||
|
masterPasswordHint?: string;
|
||||||
|
key?: string;
|
||||||
|
kdf?: number;
|
||||||
|
kdfIterations?: number;
|
||||||
|
kdfMemory?: number;
|
||||||
|
kdfParallelism?: number;
|
||||||
|
keys?: {
|
||||||
|
publicKey?: string;
|
||||||
|
encryptedPrivateKey?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = body.email?.toLowerCase();
|
||||||
|
const name = body.name || email;
|
||||||
|
const masterPasswordHash = body.masterPasswordHash;
|
||||||
|
const key = body.key;
|
||||||
|
const privateKey = body.keys?.encryptedPrivateKey;
|
||||||
|
const publicKey = body.keys?.publicKey;
|
||||||
|
|
||||||
|
if (!email || !masterPasswordHash || !key) {
|
||||||
|
return errorResponse('Email, masterPasswordHash, and key are required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!privateKey || !publicKey) {
|
||||||
|
return errorResponse('Private key and public key are required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const user: User = {
|
||||||
|
id: generateUUID(),
|
||||||
|
email: email,
|
||||||
|
name: name || email,
|
||||||
|
masterPasswordHash: masterPasswordHash,
|
||||||
|
key: key,
|
||||||
|
privateKey: privateKey,
|
||||||
|
publicKey: publicKey,
|
||||||
|
kdfType: body.kdf ?? 0,
|
||||||
|
kdfIterations: body.kdfIterations ?? 600000,
|
||||||
|
kdfMemory: body.kdfMemory,
|
||||||
|
kdfParallelism: body.kdfParallelism,
|
||||||
|
securityStamp: generateUUID(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await storage.saveUser(user);
|
||||||
|
await storage.setRegistered();
|
||||||
|
|
||||||
|
return jsonResponse({ success: true }, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/accounts/profile
|
||||||
|
export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return errorResponse('User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile: ProfileResponse = {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
emailVerified: true,
|
||||||
|
premium: true,
|
||||||
|
premiumFromOrganization: false,
|
||||||
|
usesKeyConnector: false,
|
||||||
|
masterPasswordHint: null,
|
||||||
|
culture: 'en-US',
|
||||||
|
twoFactorEnabled: false,
|
||||||
|
key: user.key,
|
||||||
|
privateKey: user.privateKey,
|
||||||
|
securityStamp: user.securityStamp || user.id,
|
||||||
|
organizations: [],
|
||||||
|
providers: [],
|
||||||
|
providerOrganizations: [],
|
||||||
|
forcePasswordReset: false,
|
||||||
|
avatarColor: null,
|
||||||
|
creationDate: user.createdAt,
|
||||||
|
object: 'profile',
|
||||||
|
};
|
||||||
|
|
||||||
|
return jsonResponse(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/accounts/profile
|
||||||
|
export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return errorResponse('User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { name?: string; masterPasswordHint?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.name) {
|
||||||
|
user.name = body.name;
|
||||||
|
}
|
||||||
|
user.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
await storage.saveUser(user);
|
||||||
|
|
||||||
|
return handleGetProfile(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/accounts/keys
|
||||||
|
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return errorResponse('User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: {
|
||||||
|
key?: string;
|
||||||
|
encryptedPrivateKey?: string;
|
||||||
|
publicKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.key) user.key = body.key;
|
||||||
|
if (body.encryptedPrivateKey) user.privateKey = body.encryptedPrivateKey;
|
||||||
|
if (body.publicKey) user.publicKey = body.publicKey;
|
||||||
|
user.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
await storage.saveUser(user);
|
||||||
|
|
||||||
|
return handleGetProfile(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/accounts/revision-date
|
||||||
|
export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
const revisionDate = await storage.getRevisionDate(userId);
|
||||||
|
|
||||||
|
// Return as milliseconds timestamp (Bitwarden format)
|
||||||
|
const timestamp = new Date(revisionDate).getTime();
|
||||||
|
return jsonResponse(timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/accounts/verify-password
|
||||||
|
export async function handleVerifyPassword(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return errorResponse('User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { masterPasswordHash?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.masterPasswordHash) {
|
||||||
|
return errorResponse('masterPasswordHash is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.masterPasswordHash !== user.masterPasswordHash) {
|
||||||
|
return errorResponse('Invalid password', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import { Env, Attachment, Cipher } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
|
||||||
|
|
||||||
|
// Format file size to human readable
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} Bytes`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get R2 object path for attachment
|
||||||
|
function getAttachmentPath(cipherId: string, attachmentId: string): string {
|
||||||
|
return `${cipherId}/${attachmentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/ciphers/{cipherId}/attachment/v2
|
||||||
|
// Creates attachment metadata and returns upload URL
|
||||||
|
export async function handleCreateAttachment(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
cipherId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
|
||||||
|
// Verify cipher exists and belongs to user
|
||||||
|
const cipher = await storage.getCipher(cipherId);
|
||||||
|
if (!cipher || cipher.userId !== userId) {
|
||||||
|
return errorResponse('Cipher not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: {
|
||||||
|
fileName?: string;
|
||||||
|
key?: string;
|
||||||
|
fileSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.fileName || !body.key) {
|
||||||
|
return errorResponse('fileName and key are required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileSize = body.fileSize || 0;
|
||||||
|
const attachmentId = generateUUID();
|
||||||
|
|
||||||
|
// Create attachment metadata
|
||||||
|
const attachment: Attachment = {
|
||||||
|
id: attachmentId,
|
||||||
|
cipherId: cipherId,
|
||||||
|
fileName: body.fileName,
|
||||||
|
size: fileSize,
|
||||||
|
sizeName: formatSize(fileSize),
|
||||||
|
key: body.key,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save attachment metadata
|
||||||
|
await storage.saveAttachment(attachment);
|
||||||
|
|
||||||
|
// Add attachment to cipher
|
||||||
|
await storage.addAttachmentToCipher(cipherId, attachmentId);
|
||||||
|
|
||||||
|
// Update cipher revision date
|
||||||
|
await storage.updateCipherRevisionDate(cipherId);
|
||||||
|
|
||||||
|
// Get updated cipher for response
|
||||||
|
const updatedCipher = await storage.getCipher(cipherId);
|
||||||
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'attachment-fileUpload',
|
||||||
|
attachmentId: attachmentId,
|
||||||
|
url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`,
|
||||||
|
fileUploadType: 0, // Direct upload
|
||||||
|
cipherResponse: formatCipherResponse(updatedCipher!, attachments),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maximum file size: 100MB
|
||||||
|
const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
||||||
|
|
||||||
|
// POST /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||||
|
// Upload attachment file content
|
||||||
|
export async function handleUploadAttachment(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
cipherId: string,
|
||||||
|
attachmentId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
|
||||||
|
// Verify cipher exists and belongs to user
|
||||||
|
const cipher = await storage.getCipher(cipherId);
|
||||||
|
if (!cipher || cipher.userId !== userId) {
|
||||||
|
return errorResponse('Cipher not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify attachment exists
|
||||||
|
const attachment = await storage.getAttachment(attachmentId);
|
||||||
|
if (!attachment || attachment.cipherId !== cipherId) {
|
||||||
|
return errorResponse('Attachment not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check content-length header for size limit
|
||||||
|
const contentLength = request.headers.get('content-length');
|
||||||
|
if (contentLength && parseInt(contentLength) > MAX_FILE_SIZE) {
|
||||||
|
return errorResponse('File too large. Maximum size is 100MB', 413);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the file from multipart form data
|
||||||
|
const contentType = request.headers.get('content-type') || '';
|
||||||
|
if (!contentType.includes('multipart/form-data')) {
|
||||||
|
return errorResponse('Content-Type must be multipart/form-data', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('data') as File | null;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return errorResponse('No file uploaded', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check actual file size
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return errorResponse('File too large. Maximum size is 100MB', 413);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store file in R2
|
||||||
|
const path = getAttachmentPath(cipherId, attachmentId);
|
||||||
|
await env.ATTACHMENTS.put(path, file.stream(), {
|
||||||
|
httpMetadata: {
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
},
|
||||||
|
customMetadata: {
|
||||||
|
cipherId: cipherId,
|
||||||
|
attachmentId: attachmentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update attachment size if different
|
||||||
|
const actualSize = file.size;
|
||||||
|
if (actualSize !== attachment.size) {
|
||||||
|
attachment.size = actualSize;
|
||||||
|
attachment.sizeName = formatSize(actualSize);
|
||||||
|
await storage.saveAttachment(attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cipher revision date
|
||||||
|
await storage.updateCipherRevisionDate(cipherId);
|
||||||
|
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||||
|
// Get attachment download info
|
||||||
|
export async function handleGetAttachment(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
cipherId: string,
|
||||||
|
attachmentId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
|
||||||
|
// Verify cipher exists and belongs to user
|
||||||
|
const cipher = await storage.getCipher(cipherId);
|
||||||
|
if (!cipher || cipher.userId !== userId) {
|
||||||
|
return errorResponse('Cipher not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify attachment exists
|
||||||
|
const attachment = await storage.getAttachment(attachmentId);
|
||||||
|
if (!attachment || attachment.cipherId !== cipherId) {
|
||||||
|
return errorResponse('Attachment not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate short-lived download token
|
||||||
|
const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET);
|
||||||
|
|
||||||
|
// Generate download URL with token
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const downloadUrl = `${url.origin}/api/attachments/${cipherId}/${attachmentId}?token=${token}`;
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'attachment',
|
||||||
|
id: attachment.id,
|
||||||
|
url: downloadUrl,
|
||||||
|
fileName: attachment.fileName,
|
||||||
|
key: attachment.key,
|
||||||
|
size: String(attachment.size),
|
||||||
|
sizeName: attachment.sizeName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/attachments/{cipherId}/{attachmentId}?token=xxx
|
||||||
|
// Public download endpoint (uses token for auth instead of header)
|
||||||
|
export async function handlePublicDownloadAttachment(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
cipherId: string,
|
||||||
|
attachmentId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const token = url.searchParams.get('token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return errorResponse('Token required', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token
|
||||||
|
const claims = await verifyFileDownloadToken(token, env.JWT_SECRET);
|
||||||
|
if (!claims) {
|
||||||
|
return errorResponse('Invalid or expired token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token matches request
|
||||||
|
if (claims.cipherId !== cipherId || claims.attachmentId !== attachmentId) {
|
||||||
|
return errorResponse('Token mismatch', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
|
||||||
|
// Verify attachment exists
|
||||||
|
const attachment = await storage.getAttachment(attachmentId);
|
||||||
|
if (!attachment || attachment.cipherId !== cipherId) {
|
||||||
|
return errorResponse('Attachment not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file from R2
|
||||||
|
const path = getAttachmentPath(cipherId, attachmentId);
|
||||||
|
const object = await env.ATTACHMENTS.get(path);
|
||||||
|
|
||||||
|
if (!object) {
|
||||||
|
return errorResponse('Attachment file not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(object.body, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Length': String(object.size),
|
||||||
|
'Cache-Control': 'private, no-cache',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||||
|
// Delete attachment
|
||||||
|
export async function handleDeleteAttachment(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
cipherId: string,
|
||||||
|
attachmentId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
|
||||||
|
// Verify cipher exists and belongs to user
|
||||||
|
const cipher = await storage.getCipher(cipherId);
|
||||||
|
if (!cipher || cipher.userId !== userId) {
|
||||||
|
return errorResponse('Cipher not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify attachment exists
|
||||||
|
const attachment = await storage.getAttachment(attachmentId);
|
||||||
|
if (!attachment || attachment.cipherId !== cipherId) {
|
||||||
|
return errorResponse('Attachment not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file from R2
|
||||||
|
const path = getAttachmentPath(cipherId, attachmentId);
|
||||||
|
await env.ATTACHMENTS.delete(path);
|
||||||
|
|
||||||
|
// Delete attachment metadata
|
||||||
|
await storage.deleteAttachment(attachmentId);
|
||||||
|
|
||||||
|
// Remove attachment from cipher
|
||||||
|
await storage.removeAttachmentFromCipher(cipherId, attachmentId);
|
||||||
|
|
||||||
|
// Update cipher revision date
|
||||||
|
await storage.updateCipherRevisionDate(cipherId);
|
||||||
|
|
||||||
|
// Get updated cipher for response
|
||||||
|
const updatedCipher = await storage.getCipher(cipherId);
|
||||||
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
cipher: formatCipherResponse(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: cipher.type,
|
||||||
|
name: cipher.name,
|
||||||
|
notes: cipher.notes,
|
||||||
|
favorite: cipher.favorite,
|
||||||
|
login: cipher.login,
|
||||||
|
card: cipher.card,
|
||||||
|
identity: cipher.identity,
|
||||||
|
secureNote: cipher.secureNote,
|
||||||
|
fields: cipher.fields,
|
||||||
|
passwordHistory: cipher.passwordHistory,
|
||||||
|
reprompt: cipher.reprompt,
|
||||||
|
organizationUseTotp: false,
|
||||||
|
creationDate: cipher.createdAt,
|
||||||
|
revisionDate: cipher.updatedAt,
|
||||||
|
deletedDate: cipher.deletedAt,
|
||||||
|
edit: true,
|
||||||
|
viewPassword: true,
|
||||||
|
permissions: null,
|
||||||
|
object: 'cipher',
|
||||||
|
collectionIds: [],
|
||||||
|
attachments: attachments.length > 0 ? attachments.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
fileName: a.fileName,
|
||||||
|
size: String(a.size),
|
||||||
|
sizeName: a.sizeName,
|
||||||
|
key: a.key,
|
||||||
|
object: 'attachment',
|
||||||
|
})) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all attachments for a cipher (used when deleting cipher)
|
||||||
|
export async function deleteAllAttachmentsForCipher(
|
||||||
|
env: Env,
|
||||||
|
cipherId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
const path = getAttachmentPath(cipherId, attachment.id);
|
||||||
|
await env.ATTACHMENTS.delete(path);
|
||||||
|
await storage.deleteAttachment(attachment.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { Env, Cipher, CipherResponse, Attachment } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { deleteAllAttachmentsForCipher } from './attachments';
|
||||||
|
|
||||||
|
// Format attachments for API response
|
||||||
|
function formatAttachments(attachments: Attachment[]): any[] | null {
|
||||||
|
if (attachments.length === 0) return null;
|
||||||
|
return attachments.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
fileName: a.fileName,
|
||||||
|
size: String(a.size),
|
||||||
|
sizeName: a.sizeName,
|
||||||
|
key: a.key,
|
||||||
|
object: 'attachment',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert internal cipher to API response format
|
||||||
|
function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse {
|
||||||
|
return {
|
||||||
|
id: cipher.id,
|
||||||
|
organizationId: null,
|
||||||
|
folderId: cipher.folderId,
|
||||||
|
type: cipher.type,
|
||||||
|
name: cipher.name,
|
||||||
|
notes: cipher.notes,
|
||||||
|
favorite: cipher.favorite,
|
||||||
|
login: cipher.login,
|
||||||
|
card: cipher.card,
|
||||||
|
identity: cipher.identity,
|
||||||
|
secureNote: cipher.secureNote,
|
||||||
|
fields: cipher.fields,
|
||||||
|
passwordHistory: cipher.passwordHistory,
|
||||||
|
reprompt: cipher.reprompt,
|
||||||
|
organizationUseTotp: false,
|
||||||
|
creationDate: cipher.createdAt,
|
||||||
|
revisionDate: cipher.updatedAt,
|
||||||
|
deletedDate: cipher.deletedAt,
|
||||||
|
edit: true,
|
||||||
|
viewPassword: true,
|
||||||
|
permissions: {
|
||||||
|
delete: true,
|
||||||
|
restore: true,
|
||||||
|
edit: true,
|
||||||
|
},
|
||||||
|
object: 'cipher',
|
||||||
|
collectionIds: [],
|
||||||
|
attachments: formatAttachments(attachments),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/ciphers
|
||||||
|
export async function handleGetCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
const ciphers = await storage.getAllCiphers(userId);
|
||||||
|
|
||||||
|
// Filter out soft-deleted ciphers unless specifically requested
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const includeDeleted = url.searchParams.get('deleted') === 'true';
|
||||||
|
|
||||||
|
const filteredCiphers = includeDeleted
|
||||||
|
? ciphers
|
||||||
|
: ciphers.filter(c => !c.deletedAt);
|
||||||
|
|
||||||
|
// Get attachments for all ciphers
|
||||||
|
const cipherResponses = [];
|
||||||
|
for (const cipher of filteredCiphers) {
|
||||||
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
|
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
data: cipherResponses,
|
||||||
|
object: 'list',
|
||||||
|
continuationToken: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/ciphers/:id
|
||||||
|
export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
const cipher = await storage.getCipher(id);
|
||||||
|
|
||||||
|
if (!cipher || cipher.userId !== userId) {
|
||||||
|
return errorResponse('Cipher not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
|
return jsonResponse(cipherToResponse(cipher, attachments));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/ciphers
|
||||||
|
export async function handleCreateCipher(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
|
||||||
|
let body: any;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle nested cipher object (from some clients)
|
||||||
|
const cipherData = body.cipher || body;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const cipher: Cipher = {
|
||||||
|
id: generateUUID(),
|
||||||
|
userId: userId,
|
||||||
|
type: cipherData.type,
|
||||||
|
folderId: cipherData.folderId || null,
|
||||||
|
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,
|
||||||
|
fields: cipherData.fields || null,
|
||||||
|
passwordHistory: cipherData.passwordHistory || null,
|
||||||
|
reprompt: cipherData.reprompt || 0,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
deletedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await storage.saveCipher(cipher);
|
||||||
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
|
return jsonResponse(cipherToResponse(cipher), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/ciphers/:id
|
||||||
|
export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
const existingCipher = await storage.getCipher(id);
|
||||||
|
|
||||||
|
if (!existingCipher || existingCipher.userId !== userId) {
|
||||||
|
return errorResponse('Cipher not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: any;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle nested cipher object
|
||||||
|
const cipherData = body.cipher || body;
|
||||||
|
|
||||||
|
const cipher: Cipher = {
|
||||||
|
...existingCipher,
|
||||||
|
type: 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,
|
||||||
|
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,
|
||||||
|
fields: cipherData.fields !== undefined ? cipherData.fields : existingCipher.fields,
|
||||||
|
passwordHistory: cipherData.passwordHistory !== undefined ? cipherData.passwordHistory : existingCipher.passwordHistory,
|
||||||
|
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await storage.saveCipher(cipher);
|
||||||
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
|
return jsonResponse(cipherToResponse(cipher));
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/ciphers/:id
|
||||||
|
export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
const cipher = await storage.getCipher(id);
|
||||||
|
|
||||||
|
if (!cipher || cipher.userId !== userId) {
|
||||||
|
return errorResponse('Cipher not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
cipher.deletedAt = new Date().toISOString();
|
||||||
|
cipher.updatedAt = cipher.deletedAt;
|
||||||
|
await storage.saveCipher(cipher);
|
||||||
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
|
return jsonResponse(cipherToResponse(cipher));
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/ciphers/:id (permanent)
|
||||||
|
export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
const cipher = await storage.getCipher(id);
|
||||||
|
|
||||||
|
if (!cipher || cipher.userId !== userId) {
|
||||||
|
return errorResponse('Cipher not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all attachments first
|
||||||
|
await deleteAllAttachmentsForCipher(env, id);
|
||||||
|
|
||||||
|
await storage.deleteCipher(id, userId);
|
||||||
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/ciphers/:id/restore
|
||||||
|
export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
const cipher = await storage.getCipher(id);
|
||||||
|
|
||||||
|
if (!cipher || cipher.userId !== userId) {
|
||||||
|
return errorResponse('Cipher not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
cipher.deletedAt = null;
|
||||||
|
cipher.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveCipher(cipher);
|
||||||
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
|
return jsonResponse(cipherToResponse(cipher));
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
|
||||||
|
export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
const cipher = await storage.getCipher(id);
|
||||||
|
|
||||||
|
if (!cipher || cipher.userId !== userId) {
|
||||||
|
return errorResponse('Cipher not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { folderId?: string | null; favorite?: boolean };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.folderId !== undefined) {
|
||||||
|
cipher.folderId = body.folderId;
|
||||||
|
}
|
||||||
|
if (body.favorite !== undefined) {
|
||||||
|
cipher.favorite = body.favorite;
|
||||||
|
}
|
||||||
|
cipher.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
await storage.saveCipher(cipher);
|
||||||
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
|
return jsonResponse(cipherToResponse(cipher));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST/PUT /api/ciphers/move - Bulk move to folder
|
||||||
|
export async function handleBulkMoveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
|
||||||
|
let body: { ids?: string[]; folderId?: string | null };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.ids || !Array.isArray(body.ids)) {
|
||||||
|
return errorResponse('ids array is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.bulkMoveCiphers(body.ids, body.folderId || null, userId);
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Env, Folder, FolderResponse } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
|
||||||
|
// Convert internal folder to API response format
|
||||||
|
function folderToResponse(folder: Folder): FolderResponse {
|
||||||
|
return {
|
||||||
|
id: folder.id,
|
||||||
|
name: folder.name,
|
||||||
|
revisionDate: folder.updatedAt,
|
||||||
|
object: 'folder',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/folders
|
||||||
|
export async function handleGetFolders(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
const folders = await storage.getAllFolders(userId);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
data: folders.map(folderToResponse),
|
||||||
|
object: 'list',
|
||||||
|
continuationToken: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/folders/:id
|
||||||
|
export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
const folder = await storage.getFolder(id);
|
||||||
|
|
||||||
|
if (!folder || folder.userId !== userId) {
|
||||||
|
return errorResponse('Folder not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(folderToResponse(folder));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/folders
|
||||||
|
export async function handleCreateFolder(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
|
||||||
|
let body: { name?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.name) {
|
||||||
|
return errorResponse('Name is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const folder: Folder = {
|
||||||
|
id: generateUUID(),
|
||||||
|
userId: userId,
|
||||||
|
name: body.name,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await storage.saveFolder(folder);
|
||||||
|
|
||||||
|
return jsonResponse(folderToResponse(folder), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/folders/:id
|
||||||
|
export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
const folder = await storage.getFolder(id);
|
||||||
|
|
||||||
|
if (!folder || folder.userId !== userId) {
|
||||||
|
return errorResponse('Folder not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { name?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.name) {
|
||||||
|
folder.name = body.name;
|
||||||
|
}
|
||||||
|
folder.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
await storage.saveFolder(folder);
|
||||||
|
|
||||||
|
return jsonResponse(folderToResponse(folder));
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/folders/:id
|
||||||
|
export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
const folder = await storage.getFolder(id);
|
||||||
|
|
||||||
|
if (!folder || folder.userId !== userId) {
|
||||||
|
return errorResponse('Folder not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.deleteFolder(id, userId);
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { Env, TokenResponse } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { AuthService } from '../services/auth';
|
||||||
|
import { RateLimitService } from '../services/ratelimit';
|
||||||
|
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
|
||||||
|
|
||||||
|
// POST /identity/connect/token
|
||||||
|
export async function handleToken(request: Request, env: Env): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
const rateLimit = new RateLimitService(env.VAULT);
|
||||||
|
|
||||||
|
let body: Record<string, string>;
|
||||||
|
const contentType = request.headers.get('content-type') || '';
|
||||||
|
|
||||||
|
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
body = Object.fromEntries(formData.entries()) as Record<string, string>;
|
||||||
|
} else {
|
||||||
|
body = await request.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const grantType = body.grant_type;
|
||||||
|
|
||||||
|
if (grantType === 'password') {
|
||||||
|
// Login with password
|
||||||
|
const email = body.username?.toLowerCase();
|
||||||
|
const passwordHash = body.password;
|
||||||
|
|
||||||
|
if (!email || !passwordHash) {
|
||||||
|
return errorResponse('Email and password are required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if login is rate limited
|
||||||
|
const loginCheck = await rateLimit.checkLoginAttempt(email);
|
||||||
|
if (!loginCheck.allowed) {
|
||||||
|
return errorResponse(
|
||||||
|
`Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`,
|
||||||
|
429
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await storage.getUser(email);
|
||||||
|
if (!user) {
|
||||||
|
// Record failed attempt even for non-existent user (prevent enumeration)
|
||||||
|
await rateLimit.recordFailedLogin(email);
|
||||||
|
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash);
|
||||||
|
if (!valid) {
|
||||||
|
// Record failed login attempt
|
||||||
|
const result = await rateLimit.recordFailedLogin(email);
|
||||||
|
if (result.locked) {
|
||||||
|
return identityErrorResponse(
|
||||||
|
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
|
||||||
|
'TooManyRequests',
|
||||||
|
429
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successful login - clear failed attempts
|
||||||
|
await rateLimit.clearLoginAttempts(email);
|
||||||
|
|
||||||
|
const accessToken = await auth.generateAccessToken(user);
|
||||||
|
const refreshToken = await auth.generateRefreshToken(user.id);
|
||||||
|
|
||||||
|
const response: TokenResponse = {
|
||||||
|
access_token: accessToken,
|
||||||
|
expires_in: 7200,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
Key: user.key,
|
||||||
|
PrivateKey: user.privateKey,
|
||||||
|
Kdf: user.kdfType,
|
||||||
|
KdfIterations: user.kdfIterations,
|
||||||
|
KdfMemory: user.kdfMemory,
|
||||||
|
KdfParallelism: user.kdfParallelism,
|
||||||
|
ForcePasswordReset: false,
|
||||||
|
ResetMasterPassword: false,
|
||||||
|
scope: 'api offline_access',
|
||||||
|
unofficialServer: true,
|
||||||
|
UserDecryptionOptions: {
|
||||||
|
HasMasterPassword: true,
|
||||||
|
Object: 'userDecryptionOptions',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return jsonResponse(response);
|
||||||
|
|
||||||
|
} else if (grantType === 'refresh_token') {
|
||||||
|
// Refresh token
|
||||||
|
const refreshToken = body.refresh_token;
|
||||||
|
if (!refreshToken) {
|
||||||
|
return errorResponse('Refresh token is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await auth.refreshAccessToken(refreshToken);
|
||||||
|
if (!result) {
|
||||||
|
return errorResponse('Invalid refresh token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke old refresh token (prevent reuse)
|
||||||
|
await storage.deleteRefreshToken(refreshToken);
|
||||||
|
|
||||||
|
const { accessToken, user } = result;
|
||||||
|
const newRefreshToken = await auth.generateRefreshToken(user.id);
|
||||||
|
|
||||||
|
const response: TokenResponse = {
|
||||||
|
access_token: accessToken,
|
||||||
|
expires_in: 7200,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
refresh_token: newRefreshToken,
|
||||||
|
Key: user.key,
|
||||||
|
PrivateKey: user.privateKey,
|
||||||
|
Kdf: user.kdfType,
|
||||||
|
KdfIterations: user.kdfIterations,
|
||||||
|
KdfMemory: user.kdfMemory,
|
||||||
|
KdfParallelism: user.kdfParallelism,
|
||||||
|
ForcePasswordReset: false,
|
||||||
|
ResetMasterPassword: false,
|
||||||
|
scope: 'api offline_access',
|
||||||
|
unofficialServer: true,
|
||||||
|
UserDecryptionOptions: {
|
||||||
|
HasMasterPassword: true,
|
||||||
|
Object: 'userDecryptionOptions',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return jsonResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorResponse('Unsupported grant type', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /identity/accounts/prelogin
|
||||||
|
export async function handlePrelogin(request: Request, env: Env): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
|
||||||
|
let body: { email?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = body.email?.toLowerCase();
|
||||||
|
if (!email) {
|
||||||
|
return errorResponse('Email is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await storage.getUser(email);
|
||||||
|
|
||||||
|
// Return default KDF settings even if user doesn't exist (to prevent user enumeration)
|
||||||
|
const kdfType = user?.kdfType ?? 0;
|
||||||
|
const kdfIterations = user?.kdfIterations ?? 600000;
|
||||||
|
const kdfMemory = user?.kdfMemory;
|
||||||
|
const kdfParallelism = user?.kdfParallelism;
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
kdf: kdfType,
|
||||||
|
kdfIterations: kdfIterations,
|
||||||
|
kdfMemory: kdfMemory,
|
||||||
|
kdfParallelism: kdfParallelism,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { Env, Cipher, Folder, CipherType } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { errorResponse } from '../utils/response';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
|
||||||
|
// Bitwarden client import request format
|
||||||
|
interface CiphersImportRequest {
|
||||||
|
ciphers: Array<{
|
||||||
|
type: number;
|
||||||
|
name: string;
|
||||||
|
notes?: string | null;
|
||||||
|
favorite?: boolean;
|
||||||
|
reprompt?: number;
|
||||||
|
login?: {
|
||||||
|
uris?: Array<{ uri: string | null; match?: number | null }> | null;
|
||||||
|
username?: string | null;
|
||||||
|
password?: string | null;
|
||||||
|
totp?: string | null;
|
||||||
|
} | null;
|
||||||
|
card?: {
|
||||||
|
cardholderName?: string | null;
|
||||||
|
brand?: string | null;
|
||||||
|
number?: string | null;
|
||||||
|
expMonth?: string | null;
|
||||||
|
expYear?: string | null;
|
||||||
|
code?: string | null;
|
||||||
|
} | null;
|
||||||
|
identity?: {
|
||||||
|
title?: string | null;
|
||||||
|
firstName?: string | null;
|
||||||
|
middleName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
address1?: string | null;
|
||||||
|
address2?: string | null;
|
||||||
|
address3?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
postalCode?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
company?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
ssn?: string | null;
|
||||||
|
username?: string | null;
|
||||||
|
passportNumber?: string | null;
|
||||||
|
licenseNumber?: string | null;
|
||||||
|
} | null;
|
||||||
|
secureNote?: { type: number } | null;
|
||||||
|
fields?: Array<{
|
||||||
|
name?: string | null;
|
||||||
|
value?: string | null;
|
||||||
|
type: number;
|
||||||
|
linkedId?: number | null;
|
||||||
|
}> | null;
|
||||||
|
passwordHistory?: Array<{
|
||||||
|
password: string;
|
||||||
|
lastUsedDate: string;
|
||||||
|
}> | null;
|
||||||
|
}>;
|
||||||
|
folders: Array<{
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
folderRelationships: Array<{
|
||||||
|
key: number; // cipher index
|
||||||
|
value: number; // folder index
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/ciphers/import - Bitwarden client import endpoint
|
||||||
|
export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
|
||||||
|
let importData: CiphersImportRequest;
|
||||||
|
try {
|
||||||
|
importData = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const folders = importData.folders || [];
|
||||||
|
const ciphers = importData.ciphers || [];
|
||||||
|
const folderRelationships = importData.folderRelationships || [];
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Create folders and build index -> id mapping
|
||||||
|
const folderIdMap = new Map<number, string>();
|
||||||
|
|
||||||
|
for (let i = 0; i < folders.length; i++) {
|
||||||
|
const folderId = generateUUID();
|
||||||
|
folderIdMap.set(i, folderId);
|
||||||
|
|
||||||
|
const folder: Folder = {
|
||||||
|
id: folderId,
|
||||||
|
userId: userId,
|
||||||
|
name: folders[i].name,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await storage.saveFolder(folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build cipher index -> folder id mapping from relationships
|
||||||
|
const cipherFolderMap = new Map<number, string>();
|
||||||
|
for (const rel of folderRelationships) {
|
||||||
|
const folderId = folderIdMap.get(rel.value);
|
||||||
|
if (folderId) {
|
||||||
|
cipherFolderMap.set(rel.key, folderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ciphers
|
||||||
|
for (let i = 0; i < ciphers.length; i++) {
|
||||||
|
const c = ciphers[i];
|
||||||
|
const folderId = cipherFolderMap.get(i) || null;
|
||||||
|
|
||||||
|
const cipher: Cipher = {
|
||||||
|
id: generateUUID(),
|
||||||
|
userId: userId,
|
||||||
|
type: c.type as CipherType,
|
||||||
|
folderId: folderId,
|
||||||
|
name: c.name || 'Untitled',
|
||||||
|
notes: c.notes || null,
|
||||||
|
favorite: c.favorite || false,
|
||||||
|
login: c.login ? {
|
||||||
|
username: c.login.username || null,
|
||||||
|
password: c.login.password || null,
|
||||||
|
uris: c.login.uris?.map(u => ({
|
||||||
|
uri: u.uri || null,
|
||||||
|
uriChecksum: null,
|
||||||
|
match: u.match ?? null,
|
||||||
|
})) || null,
|
||||||
|
totp: c.login.totp || null,
|
||||||
|
autofillOnPageLoad: null,
|
||||||
|
fido2Credentials: null,
|
||||||
|
} : null,
|
||||||
|
card: c.card ? {
|
||||||
|
cardholderName: c.card.cardholderName || null,
|
||||||
|
brand: c.card.brand || null,
|
||||||
|
number: c.card.number || null,
|
||||||
|
expMonth: c.card.expMonth || null,
|
||||||
|
expYear: c.card.expYear || null,
|
||||||
|
code: c.card.code || null,
|
||||||
|
} : null,
|
||||||
|
identity: c.identity ? {
|
||||||
|
title: c.identity.title || null,
|
||||||
|
firstName: c.identity.firstName || null,
|
||||||
|
middleName: c.identity.middleName || null,
|
||||||
|
lastName: c.identity.lastName || null,
|
||||||
|
address1: c.identity.address1 || null,
|
||||||
|
address2: c.identity.address2 || null,
|
||||||
|
address3: c.identity.address3 || null,
|
||||||
|
city: c.identity.city || null,
|
||||||
|
state: c.identity.state || null,
|
||||||
|
postalCode: c.identity.postalCode || null,
|
||||||
|
country: c.identity.country || null,
|
||||||
|
company: c.identity.company || null,
|
||||||
|
email: c.identity.email || null,
|
||||||
|
phone: c.identity.phone || null,
|
||||||
|
ssn: c.identity.ssn || null,
|
||||||
|
username: c.identity.username || null,
|
||||||
|
passportNumber: c.identity.passportNumber || null,
|
||||||
|
licenseNumber: c.identity.licenseNumber || null,
|
||||||
|
} : null,
|
||||||
|
secureNote: c.secureNote || null,
|
||||||
|
fields: c.fields?.map(f => ({
|
||||||
|
name: f.name || null,
|
||||||
|
value: f.value || null,
|
||||||
|
type: f.type,
|
||||||
|
linkedId: f.linkedId ?? null,
|
||||||
|
})) || null,
|
||||||
|
passwordHistory: c.passwordHistory || null,
|
||||||
|
reprompt: c.reprompt || 0,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
deletedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await storage.saveCipher(cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update revision date
|
||||||
|
await storage.updateRevisionDate(userId);
|
||||||
|
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,709 @@
|
|||||||
|
import { Env } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { jsonResponse, htmlResponse, errorResponse } from '../utils/response';
|
||||||
|
|
||||||
|
// Setup page HTML (single-file, no external assets)
|
||||||
|
const setupPageHTML = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>NodeWarden</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg0: #0b0b0f;
|
||||||
|
--bg1: #0f1020;
|
||||||
|
--card: rgba(255, 255, 255, 0.08);
|
||||||
|
--card2: rgba(255, 255, 255, 0.06);
|
||||||
|
--border: rgba(255, 255, 255, 0.14);
|
||||||
|
--text: rgba(255, 255, 255, 0.92);
|
||||||
|
--muted: rgba(255, 255, 255, 0.62);
|
||||||
|
--muted2: rgba(255, 255, 255, 0.52);
|
||||||
|
--accent: #0a84ff;
|
||||||
|
--accent2: #64d2ff;
|
||||||
|
--danger: #ff453a;
|
||||||
|
--ok: #32d74b;
|
||||||
|
--shadow: 0 16px 60px rgba(0, 0, 0, 0.50);
|
||||||
|
--radius: 18px;
|
||||||
|
--radius2: 14px;
|
||||||
|
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(900px 600px at 15% 10%, rgba(100, 210, 255, 0.25), transparent 60%),
|
||||||
|
radial-gradient(900px 600px at 85% 20%, rgba(10, 132, 255, 0.22), transparent 60%),
|
||||||
|
radial-gradient(900px 600px at 50% 90%, rgba(50, 215, 75, 0.10), transparent 60%),
|
||||||
|
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.shell {
|
||||||
|
|
||||||
|
width: max(500px);
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.shell { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
padding: 26px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.06));
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.hero::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -2px;
|
||||||
|
background: radial-gradient(500px 240px at 20% 0%, rgba(100, 210, 255, 0.18), transparent 60%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.top {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.mark {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(135deg, rgba(10,132,255,0.85), rgba(100,210,255,0.55));
|
||||||
|
border: 1px solid rgba(255,255,255,0.20);
|
||||||
|
box-shadow: 0 10px 40px rgba(10, 132, 255, 0.30);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: rgba(255,255,255,0.96);
|
||||||
|
text-transform: uppercase;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.title h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
.title p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
padding: 22px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
.panel h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0 0 14px 0;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
display: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 12px;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
border: 1px solid rgba(255,255,255,0.14);
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.message.error {
|
||||||
|
display: block;
|
||||||
|
border-color: rgba(255, 69, 58, 0.40);
|
||||||
|
background: rgba(255, 69, 58, 0.10);
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
.message.success {
|
||||||
|
display: block;
|
||||||
|
border-color: rgba(50, 215, 75, 0.35);
|
||||||
|
background: rgba(50, 215, 75, 0.10);
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
@media (max-width: 540px) {
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.18);
|
||||||
|
background: rgba(0,0,0,0.18);
|
||||||
|
color: rgba(255,255,255,0.92);
|
||||||
|
outline: none;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
input::placeholder { color: rgba(255,255,255,0.35); }
|
||||||
|
input:focus {
|
||||||
|
border-color: rgba(10, 132, 255, 0.55);
|
||||||
|
box-shadow: 0 0 0 6px rgba(10, 132, 255, 0.12);
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted2);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.primary {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.18);
|
||||||
|
background: linear-gradient(135deg, rgba(10,132,255,0.95), rgba(100,210,255,0.60));
|
||||||
|
color: rgba(255,255,255,0.96);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 120ms ease, filter 120ms ease;
|
||||||
|
}
|
||||||
|
.primary:hover { filter: brightness(1.03); }
|
||||||
|
.primary:active { transform: translateY(1px) scale(0.99); }
|
||||||
|
.primary:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.sideCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.kv {
|
||||||
|
border-radius: var(--radius2);
|
||||||
|
border: 1px solid rgba(255,255,255,0.14);
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
padding: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.kv h3 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255,255,255,0.86);
|
||||||
|
}
|
||||||
|
.kv p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.server {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(0,0,0,0.25);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
word-break: break-all;
|
||||||
|
color: rgba(255,255,255,0.90);
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: rgba(100, 210, 255, 0.92);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
.footer {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.10);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255,255,255,0.55);
|
||||||
|
}
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<aside class="panel">
|
||||||
|
<div class="top">
|
||||||
|
<div class="mark" aria-label="NodeWarden">NW</div>
|
||||||
|
<div class="title">
|
||||||
|
<h1 id="t_app">NodeWarden</h1>
|
||||||
|
<p id="t_tag">部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端(个人使用)。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="height: 12px"></div>
|
||||||
|
<div class="muted" id="t_intro" style="font-size: 13px; line-height: 1.7;">
|
||||||
|
创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="height: 14px"></div>
|
||||||
|
<h2 id="t_setup">初始化</h2>
|
||||||
|
|
||||||
|
<div id="message" class="message"></div>
|
||||||
|
|
||||||
|
<div id="setup-form">
|
||||||
|
<form id="form" onsubmit="handleSubmit(event)">
|
||||||
|
<div class="grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="name" id="t_name_label">Name</label>
|
||||||
|
<input type="text" id="name" name="name" required placeholder="Your name">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="email" id="t_email_label">Email</label>
|
||||||
|
<input type="email" id="email" name="email" required placeholder="you@example.com" autocomplete="email">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="height: 10px"></div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password" id="t_pw_label">Master password</label>
|
||||||
|
<input type="password" id="password" name="password" required minlength="12" placeholder="At least 12 characters" autocomplete="new-password">
|
||||||
|
<p class="hint" id="t_pw_hint">Choose a strong password you can remember. The server cannot recover it.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="height: 10px"></div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="confirmPassword" id="t_pw2_label">Confirm password</label>
|
||||||
|
<input type="password" id="confirmPassword" name="confirmPassword" required placeholder="Confirm password" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" id="submitBtn" class="primary">Create account</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="registered-view" class="sideCard" style="display: none;">
|
||||||
|
<div class="kv">
|
||||||
|
<h3 id="t_done_title">Setup complete</h3>
|
||||||
|
<p id="t_done_desc">Your server is ready. Configure your Bitwarden client with this server URL:</p>
|
||||||
|
<div class="server" id="serverUrl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<h3 id="t_important">Important</h3>
|
||||||
|
<p id="t_limitations">
|
||||||
|
This project is designed for a single user. You cannot add new users. Changing the master password is not supported.
|
||||||
|
If you forget it, you must redeploy and register again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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' };
|
||||||
|
|
||||||
|
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: '本项目仅支持单用户:不能添加新用户;不支持修改主密码;如果忘记主密码,只能重新部署并重新注册。',
|
||||||
|
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.',
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already registered
|
||||||
|
async function checkStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/setup/status');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.registered) {
|
||||||
|
showRegisteredView();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to check status:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRegisteredView() {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
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> {
|
||||||
|
return htmlResponse(setupPageHTML);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /setup/status
|
||||||
|
export async function handleSetupStatus(request: Request, env: Env): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
const registered = await storage.isRegistered();
|
||||||
|
return jsonResponse({ registered });
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse, Attachment } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
|
|
||||||
|
// Format attachments for API response
|
||||||
|
function formatAttachments(attachments: Attachment[]): any[] | null {
|
||||||
|
if (attachments.length === 0) return null;
|
||||||
|
return attachments.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
fileName: a.fileName,
|
||||||
|
size: String(a.size),
|
||||||
|
sizeName: a.sizeName,
|
||||||
|
key: a.key,
|
||||||
|
object: 'attachment',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/sync
|
||||||
|
export async function handleSync(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) {
|
||||||
|
return errorResponse('User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ciphers = await storage.getAllCiphers(userId);
|
||||||
|
const folders = await storage.getAllFolders(userId);
|
||||||
|
|
||||||
|
// Build profile response
|
||||||
|
const profile: ProfileResponse = {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
emailVerified: true,
|
||||||
|
premium: true,
|
||||||
|
premiumFromOrganization: false,
|
||||||
|
usesKeyConnector: false,
|
||||||
|
masterPasswordHint: null,
|
||||||
|
culture: 'en-US',
|
||||||
|
twoFactorEnabled: false,
|
||||||
|
key: user.key,
|
||||||
|
privateKey: user.privateKey,
|
||||||
|
securityStamp: user.securityStamp || user.id,
|
||||||
|
organizations: [],
|
||||||
|
providers: [],
|
||||||
|
providerOrganizations: [],
|
||||||
|
forcePasswordReset: false,
|
||||||
|
avatarColor: null,
|
||||||
|
creationDate: user.createdAt,
|
||||||
|
object: 'profile',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build cipher responses with attachments
|
||||||
|
const cipherResponses: CipherResponse[] = [];
|
||||||
|
for (const cipher of ciphers) {
|
||||||
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
|
cipherResponses.push({
|
||||||
|
id: cipher.id,
|
||||||
|
organizationId: null,
|
||||||
|
folderId: cipher.folderId,
|
||||||
|
type: cipher.type,
|
||||||
|
name: cipher.name,
|
||||||
|
notes: cipher.notes,
|
||||||
|
favorite: cipher.favorite,
|
||||||
|
login: cipher.login,
|
||||||
|
card: cipher.card,
|
||||||
|
identity: cipher.identity,
|
||||||
|
secureNote: cipher.secureNote,
|
||||||
|
fields: cipher.fields,
|
||||||
|
passwordHistory: cipher.passwordHistory,
|
||||||
|
reprompt: cipher.reprompt,
|
||||||
|
organizationUseTotp: false,
|
||||||
|
creationDate: cipher.createdAt,
|
||||||
|
revisionDate: cipher.updatedAt,
|
||||||
|
deletedDate: cipher.deletedAt,
|
||||||
|
edit: true,
|
||||||
|
viewPassword: true,
|
||||||
|
permissions: {
|
||||||
|
delete: true,
|
||||||
|
restore: true,
|
||||||
|
edit: true,
|
||||||
|
},
|
||||||
|
object: 'cipher',
|
||||||
|
collectionIds: [],
|
||||||
|
attachments: formatAttachments(attachments),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build folder responses
|
||||||
|
const folderResponses: FolderResponse[] = folders.map(folder => ({
|
||||||
|
id: folder.id,
|
||||||
|
name: folder.name,
|
||||||
|
revisionDate: folder.updatedAt,
|
||||||
|
object: 'folder',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const syncResponse: SyncResponse = {
|
||||||
|
profile: profile,
|
||||||
|
folders: folderResponses,
|
||||||
|
collections: [],
|
||||||
|
ciphers: cipherResponses,
|
||||||
|
domains: {
|
||||||
|
equivalentDomains: [],
|
||||||
|
globalEquivalentDomains: [],
|
||||||
|
object: 'domains',
|
||||||
|
},
|
||||||
|
policies: [],
|
||||||
|
sends: [],
|
||||||
|
object: 'sync',
|
||||||
|
};
|
||||||
|
|
||||||
|
return jsonResponse(syncResponse);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Env } from './types';
|
||||||
|
import { handleRequest } from './router';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||||
|
// Security check: JWT_SECRET must be set
|
||||||
|
if (!env.JWT_SECRET) {
|
||||||
|
return new Response('Server configuration error: JWT_SECRET is not set', { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security check: warn if JWT_SECRET is too weak
|
||||||
|
if (env.JWT_SECRET.length < 32) {
|
||||||
|
console.warn('[SECURITY WARNING] JWT_SECRET should be at least 32 characters for adequate security');
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleRequest(request, env);
|
||||||
|
},
|
||||||
|
};
|
||||||
+398
@@ -0,0 +1,398 @@
|
|||||||
|
import { Env } from './types';
|
||||||
|
import { AuthService } from './services/auth';
|
||||||
|
import { RateLimitService, getClientIdentifier } from './services/ratelimit';
|
||||||
|
import { handleCors, errorResponse, jsonResponse } from './utils/response';
|
||||||
|
|
||||||
|
// Identity handlers
|
||||||
|
import { handleToken, handlePrelogin } from './handlers/identity';
|
||||||
|
|
||||||
|
// Account handlers
|
||||||
|
import { handleRegister, handleGetProfile, handleUpdateProfile, handleSetKeys, handleGetRevisionDate, handleVerifyPassword } from './handlers/accounts';
|
||||||
|
|
||||||
|
// Cipher handlers
|
||||||
|
import {
|
||||||
|
handleGetCiphers,
|
||||||
|
handleGetCipher,
|
||||||
|
handleCreateCipher,
|
||||||
|
handleUpdateCipher,
|
||||||
|
handleDeleteCipher,
|
||||||
|
handlePermanentDeleteCipher,
|
||||||
|
handleRestoreCipher,
|
||||||
|
handlePartialUpdateCipher,
|
||||||
|
handleBulkMoveCiphers,
|
||||||
|
} from './handlers/ciphers';
|
||||||
|
|
||||||
|
// Folder handlers
|
||||||
|
import {
|
||||||
|
handleGetFolders,
|
||||||
|
handleGetFolder,
|
||||||
|
handleCreateFolder,
|
||||||
|
handleUpdateFolder,
|
||||||
|
handleDeleteFolder
|
||||||
|
} from './handlers/folders';
|
||||||
|
|
||||||
|
// Sync handler
|
||||||
|
import { handleSync } from './handlers/sync';
|
||||||
|
|
||||||
|
// Setup handlers
|
||||||
|
import { handleSetupPage, handleSetupStatus } from './handlers/setup';
|
||||||
|
|
||||||
|
// Import handler
|
||||||
|
import { handleCiphersImport } from './handlers/import';
|
||||||
|
|
||||||
|
// Attachment handlers
|
||||||
|
import {
|
||||||
|
handleCreateAttachment,
|
||||||
|
handleUploadAttachment,
|
||||||
|
handleGetAttachment,
|
||||||
|
handleDeleteAttachment,
|
||||||
|
handlePublicDownloadAttachment,
|
||||||
|
} from './handlers/attachments';
|
||||||
|
|
||||||
|
// Icons handler - proxy to Bitwarden's official icon service
|
||||||
|
async function handleGetIcon(request: Request, env: Env, hostname: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
// Use Bitwarden's official icon service
|
||||||
|
const iconUrl = `https://icons.bitwarden.net/${hostname}/icon.png`;
|
||||||
|
const resp = await fetch(iconUrl, {
|
||||||
|
headers: { 'User-Agent': 'NodeWarden/1.0' },
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
const body = await resp.arrayBuffer();
|
||||||
|
return new Response(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
||||||
|
'Cache-Control': 'public, max-age=604800', // 7 days
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
} catch {
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleRequest(request: Request, env: Env): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const path = url.pathname;
|
||||||
|
const method = request.method;
|
||||||
|
|
||||||
|
// Handle CORS preflight
|
||||||
|
if (method === 'OPTIONS') {
|
||||||
|
return handleCors();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route matching
|
||||||
|
try {
|
||||||
|
// Setup page (root)
|
||||||
|
if (path === '/' && method === 'GET') {
|
||||||
|
return handleSetupPage(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup status
|
||||||
|
if (path === '/setup/status' && method === 'GET') {
|
||||||
|
return handleSetupStatus(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favicon - return empty
|
||||||
|
if (path === '/favicon.ico') {
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon endpoint - proxy to Bitwarden's icon service (no auth required)
|
||||||
|
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||||
|
if (iconMatch) {
|
||||||
|
const hostname = iconMatch[1];
|
||||||
|
return handleGetIcon(request, env, hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public attachment download (no auth header, uses token in query string)
|
||||||
|
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
||||||
|
if (publicAttachmentMatch && method === 'GET') {
|
||||||
|
const cipherId = publicAttachmentMatch[1];
|
||||||
|
const attachmentId = publicAttachmentMatch[2];
|
||||||
|
return handlePublicDownloadAttachment(request, env, cipherId, attachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifications hub (stub - no auth required, return 200 for connection)
|
||||||
|
if (path.startsWith('/notifications/')) {
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known device check (no auth required) - returns plain string "true" or "false"
|
||||||
|
if (path.startsWith('/api/devices/knowndevice')) {
|
||||||
|
return new Response('true', {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity endpoints (no auth required)
|
||||||
|
if (path === '/identity/connect/token' && method === 'POST') {
|
||||||
|
return handleToken(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/identity/accounts/prelogin' && method === 'POST') {
|
||||||
|
return handlePrelogin(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config endpoint (no auth required for basic config)
|
||||||
|
// Bitwarden clients call GET "/config" (relative to the API base URL).
|
||||||
|
// They also tolerate different casing, but their response models use PascalCase.
|
||||||
|
const isConfigRequest = (path === '/config' || path === '/api/config') && method === 'GET';
|
||||||
|
if (isConfigRequest) {
|
||||||
|
const origin = url.origin;
|
||||||
|
return jsonResponse({
|
||||||
|
version: '2025.12.0',
|
||||||
|
gitHash: 'nodewarden',
|
||||||
|
server: null,
|
||||||
|
environment: {
|
||||||
|
vault: origin,
|
||||||
|
api: origin + '/api',
|
||||||
|
identity: origin + '/identity',
|
||||||
|
notifications: origin + '/notifications',
|
||||||
|
sso: '',
|
||||||
|
},
|
||||||
|
featureStates: {
|
||||||
|
'duo-redirect': true,
|
||||||
|
},
|
||||||
|
object: 'config',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version endpoint (some clients probe this to validate the server)
|
||||||
|
if (path === '/api/version' && method === 'GET') {
|
||||||
|
return jsonResponse('2025.12.0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registration endpoint (no auth required, but only works once)
|
||||||
|
if (path === '/api/accounts/register' && method === 'POST') {
|
||||||
|
return handleRegister(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other API endpoints require authentication
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
const authHeader = request.headers.get('Authorization');
|
||||||
|
const payload = await auth.verifyAccessToken(authHeader);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return errorResponse('Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = payload.sub;
|
||||||
|
|
||||||
|
// API rate limiting for authenticated requests
|
||||||
|
const rateLimit = new RateLimitService(env.VAULT);
|
||||||
|
const clientId = getClientIdentifier(request);
|
||||||
|
const rateLimitCheck = await rateLimit.checkApiRateLimit(userId + ':' + clientId);
|
||||||
|
|
||||||
|
if (!rateLimitCheck.allowed) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Too many requests',
|
||||||
|
error_description: `Rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
|
||||||
|
}), {
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
|
||||||
|
'X-RateLimit-Remaining': '0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment rate limit counter
|
||||||
|
await rateLimit.incrementApiCount(userId + ':' + clientId);
|
||||||
|
|
||||||
|
// Account endpoints
|
||||||
|
if (path === '/api/accounts/profile') {
|
||||||
|
if (method === 'GET') return handleGetProfile(request, env, userId);
|
||||||
|
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/accounts/keys' && method === 'POST') {
|
||||||
|
return handleSetKeys(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revision date endpoint
|
||||||
|
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
||||||
|
return handleGetRevisionDate(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password endpoint
|
||||||
|
if (path === '/api/accounts/verify-password' && method === 'POST') {
|
||||||
|
return handleVerifyPassword(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync endpoint
|
||||||
|
if (path === '/api/sync' && method === 'GET') {
|
||||||
|
return handleSync(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cipher endpoints
|
||||||
|
if (path === '/api/ciphers' || path === '/api/ciphers/create') {
|
||||||
|
if (method === 'GET') return handleGetCiphers(request, env, userId);
|
||||||
|
if (method === 'POST') return handleCreateCipher(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ciphers import endpoint (Bitwarden client format)
|
||||||
|
if (path === '/api/ciphers/import' && method === 'POST') {
|
||||||
|
return handleCiphersImport(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk cipher operations (only move is allowed)
|
||||||
|
if (path === '/api/ciphers/move') {
|
||||||
|
if (method === 'POST' || method === 'PUT') {
|
||||||
|
return handleBulkMoveCiphers(request, env, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match /api/ciphers/:id patterns
|
||||||
|
const cipherMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)(\/.*)?$/i);
|
||||||
|
if (cipherMatch) {
|
||||||
|
const cipherId = cipherMatch[1];
|
||||||
|
const subPath = cipherMatch[2] || '';
|
||||||
|
|
||||||
|
if (subPath === '' || subPath === '/') {
|
||||||
|
if (method === 'GET') return handleGetCipher(request, env, userId, cipherId);
|
||||||
|
if (method === 'PUT' || method === 'POST') return handleUpdateCipher(request, env, userId, cipherId);
|
||||||
|
if (method === 'DELETE') return handleDeleteCipher(request, env, userId, cipherId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subPath === '/delete' && method === 'PUT') {
|
||||||
|
return handleDeleteCipher(request, env, userId, cipherId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subPath === '/delete' && method === 'DELETE') {
|
||||||
|
return handlePermanentDeleteCipher(request, env, userId, cipherId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subPath === '/restore' && method === 'PUT') {
|
||||||
|
return handleRestoreCipher(request, env, userId, cipherId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return handlePartialUpdateCipher(request, env, userId, cipherId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Share endpoint - just return the cipher (single user mode)
|
||||||
|
if (subPath === '/share' && method === 'POST') {
|
||||||
|
return handleGetCipher(request, env, userId, cipherId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subPath === '/details' && method === 'GET') {
|
||||||
|
return handleGetCipher(request, env, userId, cipherId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachment endpoints
|
||||||
|
// POST /api/ciphers/{id}/attachment/v2 - Create attachment metadata
|
||||||
|
if (subPath === '/attachment/v2' && method === 'POST') {
|
||||||
|
return handleCreateAttachment(request, env, userId, cipherId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy attachment endpoint - also goes to v2 flow
|
||||||
|
if (subPath === '/attachment' && method === 'POST') {
|
||||||
|
return handleCreateAttachment(request, env, userId, cipherId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match /api/ciphers/{id}/attachment/{attachmentId}
|
||||||
|
const attachmentMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)$/i);
|
||||||
|
if (attachmentMatch) {
|
||||||
|
const attachmentId = attachmentMatch[1];
|
||||||
|
if (method === 'POST') return handleUploadAttachment(request, env, userId, cipherId, attachmentId);
|
||||||
|
if (method === 'GET') return handleGetAttachment(request, env, userId, cipherId, attachmentId);
|
||||||
|
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE via POST (legacy)
|
||||||
|
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
|
||||||
|
if (attachmentDeleteMatch && method === 'POST') {
|
||||||
|
const attachmentId = attachmentDeleteMatch[1];
|
||||||
|
return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folder endpoints
|
||||||
|
if (path === '/api/folders') {
|
||||||
|
if (method === 'GET') return handleGetFolders(request, env, userId);
|
||||||
|
if (method === 'POST') return handleCreateFolder(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match /api/folders/:id patterns
|
||||||
|
const folderMatch = path.match(/^\/api\/folders\/([a-f0-9-]+)$/i);
|
||||||
|
if (folderMatch) {
|
||||||
|
const folderId = folderMatch[1];
|
||||||
|
if (method === 'GET') return handleGetFolder(request, env, userId, folderId);
|
||||||
|
if (method === 'PUT') return handleUpdateFolder(request, env, userId, folderId);
|
||||||
|
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth requests endpoint (stub - we don't support passwordless login)
|
||||||
|
if (path.startsWith('/api/auth-requests')) {
|
||||||
|
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collections endpoint (stub - no organization support)
|
||||||
|
if (path === '/api/collections' || path.startsWith('/api/collections/')) {
|
||||||
|
if (method === 'GET') {
|
||||||
|
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organizations endpoint (stub - no organization support)
|
||||||
|
if (path === '/api/organizations' || path.startsWith('/api/organizations/')) {
|
||||||
|
if (method === 'GET') {
|
||||||
|
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sends endpoint (stub - not implemented)
|
||||||
|
if (path === '/api/sends' || path.startsWith('/api/sends/')) {
|
||||||
|
if (method === 'GET') {
|
||||||
|
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Policies endpoint (stub - not implemented)
|
||||||
|
if (path === '/api/policies' || path.startsWith('/api/policies/')) {
|
||||||
|
if (method === 'GET') {
|
||||||
|
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings domains endpoint (stub)
|
||||||
|
if (path === '/api/settings/domains') {
|
||||||
|
if (method === 'GET') {
|
||||||
|
return jsonResponse({
|
||||||
|
equivalentDomains: [],
|
||||||
|
globalEquivalentDomains: [],
|
||||||
|
object: 'domains',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (method === 'PUT' || method === 'POST') {
|
||||||
|
return jsonResponse({
|
||||||
|
equivalentDomains: [],
|
||||||
|
globalEquivalentDomains: [],
|
||||||
|
object: 'domains',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devices endpoint (stub) - for authenticated requests
|
||||||
|
if (path === '/api/devices' && method === 'GET') {
|
||||||
|
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found
|
||||||
|
return errorResponse('Not found', 404);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Request error:', error);
|
||||||
|
return errorResponse('Internal server error', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Env, JWTPayload, User } from '../types';
|
||||||
|
import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt';
|
||||||
|
import { StorageService } from './storage';
|
||||||
|
|
||||||
|
export class AuthService {
|
||||||
|
private storage: StorageService;
|
||||||
|
|
||||||
|
constructor(private env: Env) {
|
||||||
|
this.storage = new StorageService(env.VAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password hash (compare with stored hash)
|
||||||
|
async verifyPassword(inputHash: string, storedHash: string): Promise<boolean> {
|
||||||
|
// In Bitwarden, the client sends the password hash directly
|
||||||
|
// We compare the hashes
|
||||||
|
return inputHash === storedHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate access token
|
||||||
|
async generateAccessToken(user: User): Promise<string> {
|
||||||
|
return createJWT(
|
||||||
|
{
|
||||||
|
sub: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
sstamp: user.securityStamp,
|
||||||
|
},
|
||||||
|
this.env.JWT_SECRET
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate refresh token
|
||||||
|
async generateRefreshToken(userId: string): Promise<string> {
|
||||||
|
const token = createRefreshToken();
|
||||||
|
await this.storage.saveRefreshToken(token, userId);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access token from Authorization header
|
||||||
|
async verifyAccessToken(authHeader: string | null): Promise<JWTPayload | null> {
|
||||||
|
if (!authHeader) return null;
|
||||||
|
|
||||||
|
const parts = authHeader.split(' ');
|
||||||
|
if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await verifyJWT(parts[1], this.env.JWT_SECRET);
|
||||||
|
if (!payload) return null;
|
||||||
|
|
||||||
|
// Verify security stamp - ensures token is invalidated after password change
|
||||||
|
const user = await this.storage.getUserById(payload.sub);
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
if (payload.sstamp !== user.securityStamp) {
|
||||||
|
return null; // Token was issued before password change
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh access token
|
||||||
|
async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; user: User } | null> {
|
||||||
|
const userId = await this.storage.getRefreshTokenUserId(refreshToken);
|
||||||
|
if (!userId) return null;
|
||||||
|
|
||||||
|
const user = await this.storage.getUserById(userId);
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const accessToken = await this.generateAccessToken(user);
|
||||||
|
return { accessToken, user };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { Env } from '../types';
|
||||||
|
|
||||||
|
// Rate limit configuration
|
||||||
|
const CONFIG = {
|
||||||
|
// Login attempt limits
|
||||||
|
LOGIN_MAX_ATTEMPTS: 5, // Max failed login attempts
|
||||||
|
LOGIN_LOCKOUT_MINUTES: 15, // Lockout duration after max attempts
|
||||||
|
|
||||||
|
// API rate limits (per minute)
|
||||||
|
API_REQUESTS_PER_MINUTE: 60, // General API rate limit
|
||||||
|
API_WINDOW_SECONDS: 60, // Rate limit window
|
||||||
|
};
|
||||||
|
|
||||||
|
// KV key prefixes
|
||||||
|
const KEYS = {
|
||||||
|
LOGIN_ATTEMPTS: 'ratelimit:login:',
|
||||||
|
API_RATE: 'ratelimit:api:',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class RateLimitService {
|
||||||
|
constructor(private kv: KVNamespace) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check and record login attempt
|
||||||
|
* Returns { allowed: boolean, remainingAttempts: number, retryAfterSeconds?: number }
|
||||||
|
*/
|
||||||
|
async checkLoginAttempt(email: string): Promise<{
|
||||||
|
allowed: boolean;
|
||||||
|
remainingAttempts: number;
|
||||||
|
retryAfterSeconds?: number;
|
||||||
|
}> {
|
||||||
|
const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`;
|
||||||
|
const data = await this.kv.get(key);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
|
||||||
|
}
|
||||||
|
|
||||||
|
const record: { attempts: number; lockedUntil?: number } = JSON.parse(data);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Check if currently locked out
|
||||||
|
if (record.lockedUntil && record.lockedUntil > now) {
|
||||||
|
const retryAfterSeconds = Math.ceil((record.lockedUntil - now) / 1000);
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
remainingAttempts: 0,
|
||||||
|
retryAfterSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If lockout expired, reset
|
||||||
|
if (record.lockedUntil && record.lockedUntil <= now) {
|
||||||
|
await this.kv.delete(key);
|
||||||
|
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingAttempts = CONFIG.LOGIN_MAX_ATTEMPTS - record.attempts;
|
||||||
|
return { allowed: true, remainingAttempts };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a failed login attempt
|
||||||
|
*/
|
||||||
|
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 };
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
record = JSON.parse(data);
|
||||||
|
record.attempts += 1;
|
||||||
|
} else {
|
||||||
|
record = { attempts: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear login attempts on successful login
|
||||||
|
*/
|
||||||
|
async clearLoginAttempts(email: string): Promise<void> {
|
||||||
|
const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`;
|
||||||
|
await this.kv.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check API rate limit for a user or IP
|
||||||
|
* Returns { allowed: boolean, remaining: number, retryAfterSeconds?: number }
|
||||||
|
*/
|
||||||
|
async checkApiRateLimit(identifier: string): Promise<{
|
||||||
|
allowed: boolean;
|
||||||
|
remaining: number;
|
||||||
|
retryAfterSeconds?: number;
|
||||||
|
}> {
|
||||||
|
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);
|
||||||
|
const count = countStr ? parseInt(countStr, 10) : 0;
|
||||||
|
|
||||||
|
if (count >= CONFIG.API_REQUESTS_PER_MINUTE) {
|
||||||
|
const retryAfterSeconds = CONFIG.API_WINDOW_SECONDS - (now % CONFIG.API_WINDOW_SECONDS);
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
remaining: 0,
|
||||||
|
retryAfterSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: CONFIG.API_REQUESTS_PER_MINUTE - count,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment API request count
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
const count = countStr ? parseInt(countStr, 10) : 0;
|
||||||
|
|
||||||
|
await this.kv.put(key, (count + 1).toString(), {
|
||||||
|
expirationTtl: CONFIG.API_WINDOW_SECONDS + 10, // Slight buffer
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client identifier from request (IP or CF-Connecting-IP)
|
||||||
|
*/
|
||||||
|
export function getClientIdentifier(request: Request): string {
|
||||||
|
// Cloudflare provides the real client IP
|
||||||
|
const cfIp = request.headers.get('CF-Connecting-IP');
|
||||||
|
if (cfIp) return cfIp;
|
||||||
|
|
||||||
|
// Fallback for local development
|
||||||
|
const forwardedFor = request.headers.get('X-Forwarded-For');
|
||||||
|
if (forwardedFor) return forwardedFor.split(',')[0].trim();
|
||||||
|
|
||||||
|
// Last resort
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { Env, User, Cipher, Folder, Attachment } from '../types';
|
||||||
|
|
||||||
|
const KEYS = {
|
||||||
|
CONFIG_REGISTERED: 'config:registered',
|
||||||
|
USER_PREFIX: 'user:',
|
||||||
|
CIPHER_PREFIX: 'cipher:',
|
||||||
|
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 {
|
||||||
|
constructor(private kv: KVNamespace) {}
|
||||||
|
|
||||||
|
// Registration status
|
||||||
|
async isRegistered(): Promise<boolean> {
|
||||||
|
const value = await this.kv.get(KEYS.CONFIG_REGISTERED);
|
||||||
|
return value === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
async setRegistered(): Promise<void> {
|
||||||
|
await this.kv.put(KEYS.CONFIG_REGISTERED, 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// User operations
|
||||||
|
async getUser(email: string): Promise<User | null> {
|
||||||
|
const data = await this.kv.get(`${KEYS.USER_PREFIX}${email.toLowerCase()}`);
|
||||||
|
return data ? JSON.parse(data) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserById(id: string): Promise<User | null> {
|
||||||
|
// Get user email from id mapping
|
||||||
|
const email = await this.kv.get(`userid:${id}`);
|
||||||
|
if (!email) return null;
|
||||||
|
return this.getUser(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveUser(user: User): Promise<void> {
|
||||||
|
await this.kv.put(`${KEYS.USER_PREFIX}${user.email.toLowerCase()}`, JSON.stringify(user));
|
||||||
|
await this.kv.put(`userid:${user.id}`, user.email.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cipher operations
|
||||||
|
async getCipher(id: string): Promise<Cipher | null> {
|
||||||
|
const data = await this.kv.get(`${KEYS.CIPHER_PREFIX}${id}`);
|
||||||
|
return data ? JSON.parse(data) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveCipher(cipher: Cipher): Promise<void> {
|
||||||
|
await this.kv.put(`${KEYS.CIPHER_PREFIX}${cipher.id}`, JSON.stringify(cipher));
|
||||||
|
|
||||||
|
// Update index
|
||||||
|
const index = await this.getCipherIds(cipher.userId);
|
||||||
|
if (!index.includes(cipher.id)) {
|
||||||
|
index.push(cipher.id);
|
||||||
|
await this.kv.put(`${KEYS.CIPHERS_INDEX}:${cipher.userId}`, JSON.stringify(index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCipher(id: string, userId: string): Promise<void> {
|
||||||
|
await this.kv.delete(`${KEYS.CIPHER_PREFIX}${id}`);
|
||||||
|
|
||||||
|
// 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[]> {
|
||||||
|
const ids = await this.getCipherIds(userId);
|
||||||
|
const ciphers: Cipher[] = [];
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
const cipher = await this.getCipher(id);
|
||||||
|
if (cipher) ciphers.push(cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ciphers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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[]> {
|
||||||
|
const ciphers: Cipher[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const cipher = await this.getCipher(id);
|
||||||
|
if (cipher && cipher.userId === userId) {
|
||||||
|
ciphers.push(cipher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ciphers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
for (const id of ids) {
|
||||||
|
const cipher = await this.getCipher(id);
|
||||||
|
if (cipher && cipher.userId === userId) {
|
||||||
|
cipher.folderId = folderId;
|
||||||
|
cipher.updatedAt = now;
|
||||||
|
await this.saveCipher(cipher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachment operations
|
||||||
|
async getAttachment(id: string): Promise<Attachment | null> {
|
||||||
|
const data = await this.kv.get(`${KEYS.ATTACHMENT_PREFIX}${id}`);
|
||||||
|
return data ? JSON.parse(data) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveAttachment(attachment: Attachment): Promise<void> {
|
||||||
|
await this.kv.put(`${KEYS.ATTACHMENT_PREFIX}${attachment.id}`, JSON.stringify(attachment));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAttachment(id: string): Promise<void> {
|
||||||
|
await this.kv.delete(`${KEYS.ATTACHMENT_PREFIX}${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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[]> {
|
||||||
|
const ids = await this.getAttachmentIdsByCipher(cipherId);
|
||||||
|
const attachments: Attachment[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
const attachment = await this.getAttachment(id);
|
||||||
|
if (attachment) attachments.push(attachment);
|
||||||
|
}
|
||||||
|
return attachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addAttachmentToCipher(cipherId: string, attachmentId: string): Promise<void> {
|
||||||
|
const ids = await this.getAttachmentIdsByCipher(cipherId);
|
||||||
|
if (!ids.includes(attachmentId)) {
|
||||||
|
ids.push(attachmentId);
|
||||||
|
await this.kv.put(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`, JSON.stringify(ids));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
|
||||||
|
const ids = await this.getAttachmentIdsByCipher(cipherId);
|
||||||
|
const newIds = ids.filter(id => id !== attachmentId);
|
||||||
|
await this.kv.put(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`, JSON.stringify(newIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
|
||||||
|
const ids = await this.getAttachmentIdsByCipher(cipherId);
|
||||||
|
for (const id of ids) {
|
||||||
|
await this.deleteAttachment(id);
|
||||||
|
}
|
||||||
|
await this.kv.delete(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCipherRevisionDate(cipherId: string): Promise<void> {
|
||||||
|
const cipher = await this.getCipher(cipherId);
|
||||||
|
if (cipher) {
|
||||||
|
cipher.updatedAt = new Date().toISOString();
|
||||||
|
await this.saveCipher(cipher);
|
||||||
|
await this.updateRevisionDate(cipher.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
// Environment bindings
|
||||||
|
export interface Env {
|
||||||
|
VAULT: KVNamespace;
|
||||||
|
ATTACHMENTS: R2Bucket;
|
||||||
|
JWT_SECRET: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attachment model
|
||||||
|
export interface Attachment {
|
||||||
|
id: string;
|
||||||
|
cipherId: string;
|
||||||
|
fileName: string; // encrypted
|
||||||
|
size: number;
|
||||||
|
sizeName: string;
|
||||||
|
key: string | null; // encrypted attachment key
|
||||||
|
}
|
||||||
|
|
||||||
|
// User model
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
masterPasswordHash: string;
|
||||||
|
key: string;
|
||||||
|
privateKey: string | null;
|
||||||
|
publicKey: string | null;
|
||||||
|
kdfType: number;
|
||||||
|
kdfIterations: number;
|
||||||
|
kdfMemory?: number;
|
||||||
|
kdfParallelism?: number;
|
||||||
|
securityStamp: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cipher types
|
||||||
|
export enum CipherType {
|
||||||
|
Login = 1,
|
||||||
|
SecureNote = 2,
|
||||||
|
Card = 3,
|
||||||
|
Identity = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CipherLoginUri {
|
||||||
|
uri: string | null;
|
||||||
|
uriChecksum: string | null;
|
||||||
|
match: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CipherLogin {
|
||||||
|
username: string | null;
|
||||||
|
password: string | null;
|
||||||
|
uris: CipherLoginUri[] | null;
|
||||||
|
totp: string | null;
|
||||||
|
autofillOnPageLoad: boolean | null;
|
||||||
|
fido2Credentials: any[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CipherCard {
|
||||||
|
cardholderName: string | null;
|
||||||
|
brand: string | null;
|
||||||
|
number: string | null;
|
||||||
|
expMonth: string | null;
|
||||||
|
expYear: string | null;
|
||||||
|
code: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CipherIdentity {
|
||||||
|
title: string | null;
|
||||||
|
firstName: string | null;
|
||||||
|
middleName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
address1: string | null;
|
||||||
|
address2: string | null;
|
||||||
|
address3: string | null;
|
||||||
|
city: string | null;
|
||||||
|
state: string | null;
|
||||||
|
postalCode: string | null;
|
||||||
|
country: string | null;
|
||||||
|
company: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
ssn: string | null;
|
||||||
|
username: string | null;
|
||||||
|
passportNumber: string | null;
|
||||||
|
licenseNumber: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CipherSecureNote {
|
||||||
|
type: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CipherField {
|
||||||
|
name: string | null;
|
||||||
|
value: string | null;
|
||||||
|
type: number;
|
||||||
|
linkedId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordHistory {
|
||||||
|
password: string;
|
||||||
|
lastUsedDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cipher {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
type: CipherType;
|
||||||
|
folderId: string | null;
|
||||||
|
name: string;
|
||||||
|
notes: string | null;
|
||||||
|
favorite: boolean;
|
||||||
|
login: CipherLogin | null;
|
||||||
|
card: CipherCard | null;
|
||||||
|
identity: CipherIdentity | null;
|
||||||
|
secureNote: CipherSecureNote | null;
|
||||||
|
fields: CipherField[] | null;
|
||||||
|
passwordHistory: PasswordHistory[] | null;
|
||||||
|
reprompt: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folder model
|
||||||
|
export interface Folder {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT Payload
|
||||||
|
export interface JWTPayload {
|
||||||
|
sub: string; // user id
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
email_verified: boolean; // required by mobile client
|
||||||
|
amr: string[]; // authentication methods reference - required by mobile client
|
||||||
|
sstamp: string; // security stamp - invalidates token when user changes password
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
iss: string;
|
||||||
|
premium: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Response types
|
||||||
|
export interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
token_type: string;
|
||||||
|
refresh_token: string;
|
||||||
|
Key: string;
|
||||||
|
PrivateKey: string | null;
|
||||||
|
Kdf: number;
|
||||||
|
KdfIterations: number;
|
||||||
|
KdfMemory?: number;
|
||||||
|
KdfParallelism?: number;
|
||||||
|
ForcePasswordReset: boolean;
|
||||||
|
ResetMasterPassword: boolean;
|
||||||
|
scope: string;
|
||||||
|
unofficialServer: boolean;
|
||||||
|
UserDecryptionOptions: {
|
||||||
|
HasMasterPassword: boolean;
|
||||||
|
Object: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
emailVerified: boolean;
|
||||||
|
premium: boolean;
|
||||||
|
premiumFromOrganization: boolean; // required by mobile client
|
||||||
|
usesKeyConnector: boolean; // required by mobile client
|
||||||
|
masterPasswordHint: string | null;
|
||||||
|
culture: string;
|
||||||
|
twoFactorEnabled: boolean;
|
||||||
|
key: string;
|
||||||
|
privateKey: string | null;
|
||||||
|
securityStamp: string;
|
||||||
|
organizations: any[];
|
||||||
|
providers: any[];
|
||||||
|
providerOrganizations: any[];
|
||||||
|
forcePasswordReset: boolean;
|
||||||
|
avatarColor: string | null;
|
||||||
|
creationDate: string; // required by mobile client
|
||||||
|
object: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CipherResponse {
|
||||||
|
id: string;
|
||||||
|
organizationId: string | null;
|
||||||
|
folderId: string | null;
|
||||||
|
type: number;
|
||||||
|
name: string;
|
||||||
|
notes: string | null;
|
||||||
|
favorite: boolean;
|
||||||
|
login: CipherLogin | null;
|
||||||
|
card: CipherCard | null;
|
||||||
|
identity: CipherIdentity | null;
|
||||||
|
secureNote: CipherSecureNote | null;
|
||||||
|
fields: CipherField[] | null;
|
||||||
|
passwordHistory: PasswordHistory[] | null;
|
||||||
|
reprompt: number;
|
||||||
|
organizationUseTotp: boolean;
|
||||||
|
creationDate: string;
|
||||||
|
revisionDate: string;
|
||||||
|
deletedDate: string | null;
|
||||||
|
edit: boolean;
|
||||||
|
viewPassword: boolean;
|
||||||
|
permissions: CipherPermissions | null;
|
||||||
|
object: string;
|
||||||
|
collectionIds: string[];
|
||||||
|
attachments: any[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CipherPermissions {
|
||||||
|
delete: boolean;
|
||||||
|
restore: boolean;
|
||||||
|
edit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FolderResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
revisionDate: string;
|
||||||
|
object: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncResponse {
|
||||||
|
profile: ProfileResponse;
|
||||||
|
folders: FolderResponse[];
|
||||||
|
collections: any[];
|
||||||
|
ciphers: CipherResponse[];
|
||||||
|
domains: any;
|
||||||
|
policies: any[];
|
||||||
|
sends: any[];
|
||||||
|
object: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { JWTPayload } from '../types';
|
||||||
|
|
||||||
|
// Base64 URL encode
|
||||||
|
function base64UrlEncode(data: Uint8Array): string {
|
||||||
|
const base64 = btoa(String.fromCharCode(...data));
|
||||||
|
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base64 URL decode
|
||||||
|
function base64UrlDecode(str: string): Uint8Array {
|
||||||
|
str = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
while (str.length % 4) str += '=';
|
||||||
|
const binary = atob(str);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create JWT
|
||||||
|
export async function createJWT(payload: Omit<JWTPayload, 'iat' | 'exp' | 'iss' | 'premium' | 'email_verified' | 'amr'>, secret: string, expiresIn: number = 7200): Promise<string> {
|
||||||
|
const header = { alg: 'HS256', typ: 'JWT' };
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const fullPayload: JWTPayload = {
|
||||||
|
...payload,
|
||||||
|
email_verified: true, // required by mobile client
|
||||||
|
amr: ['Application'], // authentication methods reference - required by mobile client
|
||||||
|
iat: now,
|
||||||
|
exp: now + expiresIn,
|
||||||
|
iss: 'nodewarden',
|
||||||
|
premium: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
|
||||||
|
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(fullPayload)));
|
||||||
|
|
||||||
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||||
|
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||||
|
|
||||||
|
return `${data}.${signatureB64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JWT
|
||||||
|
export async function verifyJWT(token: string, secret: string): Promise<JWTPayload | null> {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
|
||||||
|
const [headerB64, payloadB64, signatureB64] = parts;
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['verify']
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
const signature = base64UrlDecode(signatureB64);
|
||||||
|
|
||||||
|
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
|
||||||
|
if (!valid) return null;
|
||||||
|
|
||||||
|
const payload: JWTPayload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (payload.exp < now) return null;
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create refresh token (simple random string)
|
||||||
|
export function createRefreshToken(): string {
|
||||||
|
const bytes = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return base64UrlEncode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// File download token payload
|
||||||
|
export interface FileDownloadClaims {
|
||||||
|
cipherId: string;
|
||||||
|
attachmentId: string;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create file download token (short-lived, 5 minutes)
|
||||||
|
export async function createFileDownloadToken(
|
||||||
|
cipherId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
secret: string
|
||||||
|
): Promise<string> {
|
||||||
|
const header = { alg: 'HS256', typ: 'JWT' };
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const payload: FileDownloadClaims = {
|
||||||
|
cipherId,
|
||||||
|
attachmentId,
|
||||||
|
exp: now + 300, // 5 minutes
|
||||||
|
};
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
|
||||||
|
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
||||||
|
|
||||||
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||||
|
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||||
|
|
||||||
|
return `${data}.${signatureB64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file download token
|
||||||
|
export async function verifyFileDownloadToken(
|
||||||
|
token: string,
|
||||||
|
secret: string
|
||||||
|
): Promise<FileDownloadClaims | null> {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
|
||||||
|
const [headerB64, payloadB64, signatureB64] = parts;
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['verify']
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
const signature = base64UrlDecode(signatureB64);
|
||||||
|
|
||||||
|
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
|
||||||
|
if (!valid) return null;
|
||||||
|
|
||||||
|
const payload: FileDownloadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (payload.exp < now) return null;
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// JSON response helper
|
||||||
|
export function jsonResponse(data: any, status: number = 200, headers: Record<string, string> = {}): Response {
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...getCorsHeaders(),
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error response helper
|
||||||
|
export function errorResponse(message: string, status: number = 400): Response {
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
error: message,
|
||||||
|
error_description: message,
|
||||||
|
ErrorModel: {
|
||||||
|
Message: message,
|
||||||
|
Object: 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identity endpoint error response (for /identity/connect/token)
|
||||||
|
export function identityErrorResponse(message: string, error: string = 'invalid_grant', status: number = 400): Response {
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
error: error,
|
||||||
|
error_description: message,
|
||||||
|
ErrorModel: {
|
||||||
|
Message: message,
|
||||||
|
Object: 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
export function handleCors(): Response {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: getCorsHeaders(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML response helper
|
||||||
|
export function htmlResponse(html: string, status: number = 200): Response {
|
||||||
|
return new Response(html, {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
|
...getCorsHeaders(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Generate UUID v4
|
||||||
|
export function generateUUID(): string {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["@cloudflare/workers-types"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
name = "nodewarden"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2024-01-01"
|
||||||
|
|
||||||
|
# KV Namespace for storing vault data
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "VAULT"
|
||||||
|
id = "placeholder"
|
||||||
|
|
||||||
|
# R2 Bucket for storing attachments
|
||||||
|
[[r2_buckets]]
|
||||||
|
binding = "ATTACHMENTS"
|
||||||
|
bucket_name = "nodewarden-attachments"
|
||||||
Reference in New Issue
Block a user