9 Commits

23 changed files with 1737 additions and 1232 deletions
+3 -1
View File
@@ -1,3 +1,5 @@
# JWT Secret for signing tokens (required)
# IMPORTANT: change this value before any real deployment.
# Generate one with: openssl rand -hex 32
JWT_SECRET=your-secret-key-herexxs22fd2ds
# (Example only, 64 hex chars = 32 bytes)
JWT_SECRET=Enter-your-JWT-key-here-at-least-32-characters
+6 -6
View File
@@ -1,7 +1,7 @@
# NodeWarden
中文文档:[`README_ZH.md`](./README_ZH.md)
A **Bitwarden-compatible** server that runs on **Cloudflare Workers**, designed for personal use.
A **Bitwarden-compatible** server that runs on **Cloudflare Workers**.
- Simple deploy (no VPS)
- Focused feature set
@@ -27,9 +27,9 @@ A **Bitwarden-compatible** server that runs on **Cloudflare Workers**, designed
## Tested clients / platforms
- ✅ Windows desktop clientv2026.1.0
- ✅ Android app v2026.1.0
- ✅ Browser extensionv2026.1.0
- ✅ Windows desktop client (v2026.1.0)
- ✅ Android app (v2026.1.0)
- ✅ Browser extension (v2026.1.0)
- ⬜ macOS desktop client (not tested)
- ⬜ Linux desktop client (not tested)
@@ -46,7 +46,7 @@ A **Bitwarden-compatible** server that runs on **Cloudflare Workers**, designed
1. Sign in with GitHub and authorize
2. Sign in to Cloudflare
3. **Important**: set `JWT_SECRET` to a strong random string (recommended: `openssl rand -hex 32`)
4. KV namespace and R2 bucket will be created automatically
4. D1 database and R2 bucket will be created automatically
5. Click **Deploy** and wait for it to finish
6. After deploy, open the Cloudflare-provided Workers URL (your service URL), and register on the web page
@@ -75,7 +75,7 @@ npm run dev
## Tech stack
- **Runtime**: Cloudflare Workers
- **Data storage**: Cloudflare KV
- **Data storage**: Cloudflare D1 (SQLite)
- **File storage**: Cloudflare R2
- **Language**: TypeScript
- **Crypto**: Client-side AES-256-CBC, JWT uses HS256
+3 -3
View File
@@ -2,7 +2,7 @@
# NodeWarden
English[`README.md`](./README.md)
一个运行在 **Cloudflare Workers** 上的 **Bitwarden 兼容**服务端实现,面向个人使用场景
一个运行在 **Cloudflare Workers** 上的 **Bitwarden 兼容**服务端实现。
- 部署简单(不需要 VPS
- 功能聚焦
@@ -47,7 +47,7 @@ English[`README.md`](./README.md)
1. 使用 GitHub 登录并授权
2. 登录 Cloudflare 账户
3. **重要**:设置 `JWT_SECRET` 为强随机字符串(推荐使用 `openssl rand -hex 32` 生成)
4. KV 存储和 R2 存储桶将自动创建
4. D1 数据库和 R2 存储桶将自动创建
5. 点击 Deploy 等待部署完成
6. 部署完成后,先打开 Cloudflare 给你的 Workers 链接(也就是你的服务地址),在网页上填写信息完成注册。
@@ -81,7 +81,7 @@ npm run dev
## 技术栈
- **运行环境**Cloudflare Workers
- **数据存储**Cloudflare KV
- **数据存储**Cloudflare D1SQLite
- **文件存储**Cloudflare R2
- **开发语言**TypeScript
- **加密算法**:客户端 AES-256-CBCJWT 使用 HS256
+94
View File
@@ -0,0 +1,94 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT,
master_password_hash TEXT NOT NULL,
key TEXT NOT NULL,
private_key TEXT,
public_key TEXT,
kdf_type INTEGER NOT NULL,
kdf_iterations INTEGER NOT NULL,
kdf_memory INTEGER,
kdf_parallelism INTEGER,
security_stamp TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Per-user sync revision date
CREATE TABLE IF NOT EXISTS user_revisions (
user_id TEXT PRIMARY KEY,
revision_date TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS ciphers (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
type INTEGER NOT NULL,
folder_id TEXT,
name TEXT,
notes TEXT,
favorite INTEGER NOT NULL DEFAULT 0,
data TEXT NOT NULL,
reprompt INTEGER,
key TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at);
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
cipher_id TEXT NOT NULL,
file_name TEXT NOT NULL,
size INTEGER NOT NULL,
size_name TEXT NOT NULL,
key TEXT,
FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id);
CREATE TABLE IF NOT EXISTS refresh_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
-- Rate limiting
CREATE TABLE IF NOT EXISTS login_attempts (
email TEXT PRIMARY KEY,
attempts INTEGER NOT NULL,
locked_until INTEGER,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS api_rate_limits (
identifier TEXT NOT NULL,
window_start INTEGER NOT NULL,
count INTEGER NOT NULL,
PRIMARY KEY (identifier, window_start)
);
CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
+3 -3
View File
@@ -1,13 +1,13 @@
{
"name": "nodewarden",
"version": "1.0.0",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nodewarden",
"version": "1.0.0",
"license": "ISC",
"version": "0.1.0",
"license": "LGPL-3.0",
"devDependencies": {
"@cloudflare/workers-types": "^4.20260131.0",
"typescript": "^5.9.3",
+6 -6
View File
@@ -1,13 +1,13 @@
{
"name": "nodewarden",
"version": "0.1.0",
"version": "0.2.0",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus",
"license": "LGPL-3.0",
"main": "src/index.ts",
"type": "module",
"scripts": {
"dev": "wrangler dev -c wrangler.dev.toml",
"dev": "wrangler dev -c wrangler.toml",
"deploymy": "wrangler deploy -c wrangler.my.toml",
"deploy": "wrangler deploy "
},
@@ -21,13 +21,13 @@
"cloudflare": {
"bindings": {
"JWT_SECRET": {
"description": "用于签名 JWT 的密钥。请输入一个随机的复杂字符串(建议 32 位以上)"
"description": "Secret used to sign JWTs. Use a strong random string (32+ characters recommended)"
},
"VAULT": {
"description": "用于存储密码库数据的 KV 存储"
"DB": {
"description": "D1 database for storing vault data"
},
"ATTACHMENTS": {
"description": "用于存储文件附件的 R2 存储桶"
"description": "R2 bucket for storing file attachments"
}
}
},
+26 -7
View File
@@ -1,12 +1,31 @@
import { Env, User, ProfileResponse } from '../types';
import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types';
import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing';
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < 32) return 'too_short';
return null;
}
// 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);
const storage = new StorageService(env.DB);
// Enforce safe JWT_SECRET before allowing first registration.
const unsafe = jwtSecretUnsafeReason(env);
if (unsafe) {
const message = unsafe === 'missing'
? 'JWT_SECRET is not set'
: unsafe === 'default'
? 'JWT_SECRET is using the default/sample value. Please change it.'
: 'JWT_SECRET must be at least 32 characters';
return errorResponse(message, 400);
}
// Check if already registered
const isRegistered = await storage.isRegistered();
@@ -77,7 +96,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
// GET /api/accounts/profile
export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) {
@@ -113,7 +132,7 @@ export async function handleGetProfile(request: Request, env: Env, userId: strin
// PUT /api/accounts/profile
export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) {
@@ -139,7 +158,7 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
// POST /api/accounts/keys
export async function handleSetKeys(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) {
@@ -170,7 +189,7 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
// GET /api/accounts/revision-date
export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
const revisionDate = await storage.getRevisionDate(userId);
// Return as milliseconds timestamp (Bitwarden format)
@@ -180,7 +199,7 @@ export async function handleGetRevisionDate(request: Request, env: Env, userId:
// POST /api/accounts/verify-password
export async function handleVerifyPassword(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) {
+11 -54
View File
@@ -1,8 +1,9 @@
import { Env, Attachment, Cipher } from '../types';
import { Env, Attachment } from '../types';
import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
import { cipherToResponse } from './ciphers';
// Format file size to human readable
function formatSize(bytes: number): string {
@@ -25,7 +26,7 @@ export async function handleCreateAttachment(
userId: string,
cipherId: string
): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId);
@@ -80,7 +81,7 @@ export async function handleCreateAttachment(
attachmentId: attachmentId,
url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`,
fileUploadType: 0, // Direct upload
cipherResponse: formatCipherResponse(updatedCipher!, attachments),
cipherResponse: cipherToResponse(updatedCipher!, attachments),
});
}
@@ -96,7 +97,7 @@ export async function handleUploadAttachment(
cipherId: string,
attachmentId: string
): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId);
@@ -169,7 +170,7 @@ export async function handleGetAttachment(
cipherId: string,
attachmentId: string
): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId);
@@ -227,7 +228,8 @@ export async function handlePublicDownloadAttachment(
return errorResponse('Token mismatch', 401);
}
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
// Verify attachment exists
const attachment = await storage.getAttachment(attachmentId);
@@ -262,7 +264,7 @@ export async function handleDeleteAttachment(
cipherId: string,
attachmentId: string
): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId);
@@ -294,61 +296,16 @@ export async function handleDeleteAttachment(
const attachments = await storage.getAttachmentsByCipher(cipherId);
return jsonResponse({
cipher: formatCipherResponse(updatedCipher!, attachments),
cipher: cipherToResponse(updatedCipher!, attachments),
});
}
// Helper: Format cipher response with attachments
function formatCipherResponse(cipher: Cipher, attachments: Attachment[]): any {
return {
id: cipher.id,
organizationId: null,
folderId: cipher.folderId,
type: Number(cipher.type) || 1,
name: cipher.name,
notes: cipher.notes,
favorite: cipher.favorite,
login: cipher.login,
card: cipher.card,
identity: cipher.identity,
secureNote: cipher.secureNote,
sshKey: cipher.sshKey,
fields: cipher.fields,
passwordHistory: cipher.passwordHistory,
reprompt: cipher.reprompt,
organizationUseTotp: false,
creationDate: cipher.createdAt,
revisionDate: cipher.updatedAt,
deletedDate: cipher.deletedAt,
archivedDate: null,
edit: true,
viewPassword: true,
permissions: {
delete: true,
restore: true,
},
object: 'cipher',
collectionIds: [],
attachments: attachments.length > 0 ? attachments.map(a => ({
id: a.id,
fileName: a.fileName,
size: Number(a.size) || 0,
sizeName: a.sizeName,
key: a.key,
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`,
object: 'attachment',
})) : null,
key: cipher.key,
encryptedFor: null,
};
}
// Delete all attachments for a cipher (used when deleting cipher)
export async function deleteAllAttachmentsForCipher(
env: Env,
cipherId: string
): Promise<void> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
const attachments = await storage.getAttachmentsByCipher(cipherId);
for (const attachment of attachments) {
+12 -12
View File
@@ -5,7 +5,7 @@ import { generateUUID } from '../utils/uuid';
import { deleteAllAttachmentsForCipher } from './attachments';
// Format attachments for API response
function formatAttachments(attachments: Attachment[]): any[] | null {
export function formatAttachments(attachments: Attachment[]): any[] | null {
if (attachments.length === 0) return null;
return attachments.map(a => ({
id: a.id,
@@ -19,7 +19,7 @@ function formatAttachments(attachments: Attachment[]): any[] | null {
}
// Convert internal cipher to API response format
function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse {
export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse {
return {
id: cipher.id,
organizationId: null,
@@ -57,7 +57,7 @@ function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): Ciphe
// GET /api/ciphers
export async function handleGetCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
const ciphers = await storage.getAllCiphers(userId);
// Filter out soft-deleted ciphers unless specifically requested
@@ -84,7 +84,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
// 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 storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) {
@@ -97,7 +97,7 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
// POST /api/ciphers
export async function handleCreateCipher(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
let body: any;
try {
@@ -116,7 +116,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
userId: userId,
type: Number(cipherData.type) || 1,
folderId: cipherData.folderId || null,
name: cipherData.name,
name: cipherData.name || null,
notes: cipherData.notes || null,
favorite: cipherData.favorite || false,
login: cipherData.login || null,
@@ -141,7 +141,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
// 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 storage = new StorageService(env.DB);
const existingCipher = await storage.getCipher(id);
if (!existingCipher || existingCipher.userId !== userId) {
@@ -186,7 +186,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// 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 storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) {
@@ -204,7 +204,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
// 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 storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) {
@@ -222,7 +222,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
// 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 storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) {
@@ -239,7 +239,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
// 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 storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id);
if (!cipher || cipher.userId !== userId) {
@@ -269,7 +269,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
// 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);
const storage = new StorageService(env.DB);
let body: { ids?: string[]; folderId?: string | null };
try {
+8 -5
View File
@@ -15,7 +15,7 @@ function folderToResponse(folder: Folder): FolderResponse {
// GET /api/folders
export async function handleGetFolders(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
const folders = await storage.getAllFolders(userId);
return jsonResponse({
@@ -27,7 +27,7 @@ export async function handleGetFolders(request: Request, env: Env, userId: strin
// 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 storage = new StorageService(env.DB);
const folder = await storage.getFolder(id);
if (!folder || folder.userId !== userId) {
@@ -39,7 +39,7 @@ export async function handleGetFolder(request: Request, env: Env, userId: string
// POST /api/folders
export async function handleCreateFolder(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
let body: { name?: string };
try {
@@ -62,13 +62,14 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
};
await storage.saveFolder(folder);
await storage.updateRevisionDate(userId);
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 storage = new StorageService(env.DB);
const folder = await storage.getFolder(id);
if (!folder || folder.userId !== userId) {
@@ -88,13 +89,14 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
folder.updatedAt = new Date().toISOString();
await storage.saveFolder(folder);
await storage.updateRevisionDate(userId);
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 storage = new StorageService(env.DB);
const folder = await storage.getFolder(id);
if (!folder || folder.userId !== userId) {
@@ -102,6 +104,7 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
}
await storage.deleteFolder(id, userId);
await storage.updateRevisionDate(userId);
return new Response(null, { status: 204 });
}
+9 -4
View File
@@ -6,9 +6,9 @@ import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/res
// POST /identity/connect/token
export async function handleToken(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const rateLimit = new RateLimitService(env.VAULT);
const rateLimit = new RateLimitService(env.DB);
let body: Record<string, string>;
const contentType = request.headers.get('content-type') || '';
@@ -28,7 +28,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const passwordHash = body.password;
if (!email || !passwordHash) {
return errorResponse('Email and password are required', 400);
// Bitwarden clients expect OAuth-style error fields.
return identityErrorResponse('Email and password are required', 'invalid_request', 400);
}
const user = await storage.getUser(email);
@@ -92,7 +93,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
Parallelism: user.kdfParallelism || null,
},
MasterKeyEncryptedUserKey: user.key,
MasterKeyWrappedUserKey: user.key,
Salt: email, // email is already lowercased above
Object: 'masterPasswordUnlock',
},
},
};
@@ -143,7 +146,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
Parallelism: user.kdfParallelism || null,
},
MasterKeyEncryptedUserKey: user.key,
MasterKeyWrappedUserKey: user.key,
Salt: user.email.toLowerCase(),
Object: 'masterPasswordUnlock',
},
},
};
@@ -156,7 +161,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// POST /identity/accounts/prelogin
export async function handlePrelogin(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
let body: { email?: string };
try {
+1 -1
View File
@@ -68,7 +68,7 @@ interface CiphersImportRequest {
// 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);
const storage = new StorageService(env.DB);
let importData: CiphersImportRequest;
try {
+23 -767
View File
@@ -1,783 +1,39 @@
import { Env } from '../types';
import { Env, DEFAULT_DEV_SECRET } from '../types';
import { StorageService } from '../services/storage';
import { jsonResponse, htmlResponse, errorResponse } from '../utils/response';
import { renderJwtSecretWarningPage, JwtSecretState } from './setupPages';
import { handleRegisterPage } from './setupRegisterPage';
// Setup page HTML (single-file, no external assets)
const setupPageHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NodeWarden</title>
<style>
:root {
color-scheme: light;
--bg0: #0b0b0f;
--bg1: #0f1020;
--card: rgba(255, 255, 255, 0.08);
--card2: rgba(255, 255, 255, 0.06);
--border: rgba(255, 255, 255, 0.14);
--text: rgba(255, 255, 255, 0.92);
--muted: rgba(255, 255, 255, 0.62);
--muted2: rgba(255, 255, 255, 0.52);
--accent: #0a84ff;
--accent2: #64d2ff;
--danger: #ff453a;
--ok: #32d74b;
--shadow: 0 16px 60px rgba(0, 0, 0, 0.50);
--radius: 18px;
--radius2: 14px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background:
radial-gradient(900px 600px at 15% 10%, rgba(100, 210, 255, 0.25), transparent 60%),
radial-gradient(900px 600px at 85% 20%, rgba(10, 132, 255, 0.22), transparent 60%),
radial-gradient(900px 600px at 50% 90%, rgba(50, 215, 75, 0.10), transparent 60%),
linear-gradient(180deg, var(--bg0), var(--bg1));
color: var(--text);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.shell {
width: max(500px);
}
@media (max-width: 860px) {
.shell { grid-template-columns: 1fr; }
}
.hero {
padding: 26px;
border: 1px solid var(--border);
background: linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.06));
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
overflow: hidden;
position: relative;
}
.hero::before {
content: "";
position: absolute;
inset: -2px;
background: radial-gradient(500px 240px at 20% 0%, rgba(100, 210, 255, 0.18), transparent 60%);
pointer-events: none;
}
.top {
display: flex;
gap: 14px;
align-items: center;
margin-bottom: 14px;
}
.mark {
width: 46px;
height: 46px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(10,132,255,0.85), rgba(100,210,255,0.55));
border: 1px solid rgba(255,255,255,0.20);
box-shadow: 0 10px 40px rgba(10, 132, 255, 0.30);
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
letter-spacing: 1px;
color: rgba(255,255,255,0.96);
text-transform: uppercase;
user-select: none;
}
.title {
display: flex;
flex-direction: column;
gap: 4px;
}
.title h1 {
font-size: 22px;
margin: 0;
letter-spacing: -0.3px;
}
.title p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.panel {
padding: 22px;
border: 1px solid var(--border);
background: rgba(255,255,255,0.06);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.panel h2 {
font-size: 16px;
margin: 0 0 14px 0;
letter-spacing: -0.2px;
}
.message {
display: none;
border-radius: 14px;
padding: 12px 12px;
margin: 0 0 12px 0;
font-size: 13px;
line-height: 1.45;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.06);
}
.message.error {
display: block;
border-color: rgba(255, 69, 58, 0.40);
background: rgba(255, 69, 58, 0.10);
color: rgba(255, 255, 255, 0.92);
}
.message.success {
display: block;
border-color: rgba(50, 215, 75, 0.35);
background: rgba(50, 215, 75, 0.10);
color: rgba(255, 255, 255, 0.92);
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 540px) {
.grid { grid-template-columns: 1fr; }
}
.field {
display: flex;
flex-direction: column;
gap: 7px;
}
label {
font-size: 12px;
color: var(--muted);
letter-spacing: 0.2px;
}
input {
height: 42px;
padding: 0 12px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(0,0,0,0.18);
color: rgba(255,255,255,0.92);
outline: none;
font-size: 14px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
input::placeholder { color: rgba(255,255,255,0.35); }
input:focus {
border-color: rgba(10, 132, 255, 0.55);
box-shadow: 0 0 0 6px rgba(10, 132, 255, 0.12);
}
.hint {
margin: 0;
color: var(--muted2);
font-size: 12px;
line-height: 1.55;
}
.actions {
margin-top: 12px;
display: flex;
gap: 10px;
align-items: center;
}
.primary {
width: 100%;
height: 44px;
border-radius: 14px;
border: 1px solid rgba(255,255,255,0.18);
background: linear-gradient(135deg, rgba(10,132,255,0.95), rgba(100,210,255,0.60));
color: rgba(255,255,255,0.96);
font-weight: 700;
letter-spacing: 0.2px;
cursor: pointer;
transition: transform 120ms ease, filter 120ms ease;
}
.primary:hover { filter: brightness(1.03); }
.primary:active { transform: translateY(1px) scale(0.99); }
.primary:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
.sideCard {
display: flex;
flex-direction: column;
gap: 12px;
}
.kv {
border-radius: var(--radius2);
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.05);
padding: 14px;
margin-bottom: 10px;
}
.kv h3 {
margin: 0 0 8px 0;
font-size: 13px;
color: rgba(255,255,255,0.86);
}
.kv p {
margin: 0;
font-size: 12px;
line-height: 1.55;
color: var(--muted);
}
.server {
margin-top: 10px;
font-family: var(--mono);
font-size: 12px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(0,0,0,0.25);
border: 1px solid rgba(255,255,255,0.12);
word-break: break-all;
color: rgba(255,255,255,0.90);
}
a {
color: rgba(100, 210, 255, 0.92);
text-decoration: none;
}
a:hover { text-decoration: underline; }
.footer {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid rgba(255,255,255,0.10);
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
color: rgba(255,255,255,0.55);
}
.muted { color: var(--muted); }
</style>
</head>
<body>
<div class="shell">
<aside class="panel">
<div class="top">
<div class="mark" aria-label="NodeWarden">NW</div>
<div class="title">
<h1 id="t_app">NodeWarden</h1>
<p id="t_tag">部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端(个人使用)。</p>
</div>
</div>
<div style="height: 12px"></div>
<div class="muted" id="t_intro" style="font-size: 13px; line-height: 1.7;">
创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。
</div>
<div style="height: 14px"></div>
<h2 id="t_setup">初始化</h2>
<div id="message" class="message"></div>
<div id="setup-form">
<form id="form" onsubmit="handleSubmit(event)">
<div class="grid">
<div class="field">
<label for="name" id="t_name_label">Name</label>
<input type="text" id="name" name="name" required placeholder="Your name">
</div>
<div class="field">
<label for="email" id="t_email_label">Email</label>
<input type="email" id="email" name="email" required placeholder="you@example.com" autocomplete="email">
</div>
</div>
<div style="height: 10px"></div>
<div class="field">
<label for="password" id="t_pw_label">Master password</label>
<input type="password" id="password" name="password" required minlength="12" placeholder="At least 12 characters" autocomplete="new-password">
<p class="hint" id="t_pw_hint">Choose a strong password you can remember. The server cannot recover it.</p>
</div>
<div style="height: 10px"></div>
<div class="field">
<label for="confirmPassword" id="t_pw2_label">Confirm password</label>
<input type="password" id="confirmPassword" name="confirmPassword" required placeholder="Confirm password" autocomplete="new-password">
</div>
<div class="actions">
<button type="submit" id="submitBtn" class="primary">Create account</button>
</div>
</form>
</div>
<div id="registered-view" class="sideCard" style="display: none;">
<div class="kv">
<h3 id="t_done_title">Setup complete</h3>
<p id="t_done_desc">Your server is ready. Configure your Bitwarden client with this server URL:</p>
<div class="server" id="serverUrl"></div>
</div>
<div class="kv">
<h3 id="t_important">Important</h3>
<p id="t_limitations">
This project is designed for a single user. You cannot add new users. Changing the master password is not supported.
If you forget it, you must redeploy and register again.
</p>
</div>
<div class="kv">
<h3 id="t_hide_title">Hide setup page</h3>
<p id="t_hide_desc">After hiding, this setup page will return 404 for everyone. Your vault will keep working.</p>
<div class="actions">
<button type="button" id="hideBtn" class="primary" onclick="disableSetupPage()">Hide setup page</button>
</div>
</div>
</div>
<div class="footer">
<div>
<span class="muted" id="t_by">By</span>
<a href="https://shuai.plus" target="_blank" rel="noreferrer">shuaiplus</a>
</div>
<div>
<a href="https://github.com/shuaiplus/nodewarden" target="_blank" rel="noreferrer">GitHub</a>
</div>
</div>
</aside>
</div>
<script>
const AUTHOR = { name: 'shuaiplus', website: 'https://shuai.plus', github: 'https://github.com/shuaiplus/nodewarden' };
let isRegistered = false;
function isChinese() {
const lang = (navigator.language || '').toLowerCase();
return lang.startsWith('zh');
}
function t(key) {
const zh = {
app: 'NodeWarden',
tag: '部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端(个人使用)。',
intro: '创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。',
by: '作者',
setup: '初始化',
nameLabel: '昵称',
emailLabel: '邮箱',
pwLabel: '主密码',
pwHint: '请选择你能记住的强密码。服务器无法找回主密码。',
pw2Label: '确认主密码',
create: '创建账号',
creating: '正在创建…',
doneTitle: '初始化完成',
doneDesc: '服务已就绪。在 Bitwarden 客户端中填入以下服务器地址:',
important: '重要提示',
limitations: '本项目仅支持单用户:不能添加新用户;不支持修改主密码;如果忘记主密码,只能重新部署并重新注册。',
hideTitle: '隐藏初始化页',
hideDesc: '隐藏后,初始化页对任何人都会直接返回 404。你的密码库仍可正常使用。',
hideBtn: '隐藏初始化页',
hideWorking: '正在隐藏…',
hideDone: '已隐藏,此页面将返回 404。',
hideFailed: '隐藏失败',
hideConfirm: '确认隐藏初始化页?隐藏后页面将不可访问,但你的密码库不会受影响。',
errPwNotMatch: '两次输入的密码不一致',
errPwTooShort: '密码长度至少 12 位',
errGeneric: '发生错误:',
errRegisterFailed: '注册失败',
};
const en = {
app: 'NodeWarden',
tag: 'Minimal Bitwarden-compatible server on Cloudflare Workers (personal use).',
intro: 'Create your first account to finish setup. Then use any official Bitwarden client to sign in.',
by: 'By',
setup: 'Setup',
nameLabel: 'Name',
emailLabel: 'Email',
pwLabel: 'Master password',
pwHint: 'Choose a strong password you can remember. The server cannot recover it.',
pw2Label: 'Confirm password',
create: 'Create account',
creating: 'Creating…',
doneTitle: 'Setup complete',
doneDesc: 'Your server is ready. Configure your Bitwarden client with this server URL:',
important: 'Important',
limitations: 'Single user only: you cannot add new users. Changing the master password is not supported. If you forget it, redeploy and register again.',
hideTitle: 'Hide setup page',
hideDesc: 'After hiding, this setup page will return 404 for everyone. Your vault will keep working.',
hideBtn: 'Hide setup page',
hideWorking: 'Hiding…',
hideDone: 'Hidden. This page will now return 404.',
hideFailed: 'Failed to hide setup page',
hideConfirm: 'Hide the setup page? It will no longer be accessible, but your vault will keep working.',
errPwNotMatch: 'Passwords do not match',
errPwTooShort: 'Password must be at least 12 characters',
errGeneric: 'An error occurred: ',
errRegisterFailed: 'Registration failed',
};
return (isChinese() ? zh : en)[key];
}
function applyI18n() {
document.documentElement.lang = isChinese() ? 'zh-CN' : 'en';
document.getElementById('t_app').textContent = t('app');
document.getElementById('t_tag').textContent = t('tag');
document.getElementById('t_intro').textContent = t('intro');
document.getElementById('t_by').textContent = t('by');
document.getElementById('t_setup').textContent = t('setup');
document.getElementById('t_name_label').textContent = t('nameLabel');
document.getElementById('t_email_label').textContent = t('emailLabel');
document.getElementById('t_pw_label').textContent = t('pwLabel');
document.getElementById('t_pw_hint').textContent = t('pwHint');
document.getElementById('t_pw2_label').textContent = t('pw2Label');
document.getElementById('submitBtn').textContent = t('create');
document.getElementById('t_done_title').textContent = t('doneTitle');
document.getElementById('t_done_desc').textContent = t('doneDesc');
document.getElementById('t_important').textContent = t('important');
document.getElementById('t_limitations').textContent = t('limitations');
document.getElementById('t_hide_title').textContent = t('hideTitle');
document.getElementById('t_hide_desc').textContent = t('hideDesc');
document.getElementById('hideBtn').textContent = t('hideBtn');
}
// Check if already registered
async function checkStatus() {
try {
const res = await fetch('/setup/status');
const data = await res.json();
isRegistered = !!data.registered;
if (data.registered) {
showRegisteredView();
}
} catch (e) {
console.error('Failed to check status:', e);
}
}
function showRegisteredView() {
isRegistered = true;
document.getElementById('setup-form').style.display = 'none';
document.getElementById('registered-view').style.display = 'block';
document.getElementById('serverUrl').textContent = window.location.origin;
showMessage(t('doneTitle'), 'success');
const form = document.getElementById('form');
if (form) {
const fields = form.querySelectorAll('input, button');
fields.forEach((el) => {
el.disabled = true;
});
}
}
async function disableSetupPage() {
if (!isRegistered) return;
if (!confirm(t('hideConfirm'))) return;
const btn = document.getElementById('hideBtn');
if (btn) {
btn.disabled = true;
btn.textContent = t('hideWorking');
}
try {
const res = await fetch('/setup/disable', { method: 'POST' });
const data = await res.json();
if (res.ok && data.success) {
showMessage(t('hideDone'), 'success');
setTimeout(() => window.location.reload(), 600);
return;
}
showMessage(data.error || t('hideFailed'), 'error');
} catch (e) {
showMessage(t('hideFailed'), 'error');
}
if (btn) {
btn.disabled = false;
btn.textContent = t('hideBtn');
}
}
function showMessage(text, type) {
const msg = document.getElementById('message');
msg.textContent = text;
msg.className = 'message ' + type;
}
// PBKDF2-SHA256 key derivation (compatible with Bitwarden)
// password can be string or Uint8Array
async function pbkdf2(password, salt, iterations, keyLen) {
const encoder = new TextEncoder();
// Handle password as string or Uint8Array
const passwordBytes = (password instanceof Uint8Array)
? password
: encoder.encode(password);
// Handle salt as string or Uint8Array
const saltBytes = (salt instanceof Uint8Array)
? salt
: encoder.encode(salt);
const keyMaterial = await crypto.subtle.importKey(
'raw',
passwordBytes,
'PBKDF2',
false,
['deriveBits']
);
const derivedBits = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: saltBytes,
iterations: iterations,
hash: 'SHA-256'
},
keyMaterial,
keyLen * 8
);
return new Uint8Array(derivedBits);
}
// HKDF expand
async function hkdfExpand(prk, info, length) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
prk,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const infoBytes = encoder.encode(info);
const result = new Uint8Array(length);
let prev = new Uint8Array(0);
let offset = 0;
let counter = 1;
while (offset < length) {
const input = new Uint8Array(prev.length + infoBytes.length + 1);
input.set(prev);
input.set(infoBytes, prev.length);
input[input.length - 1] = counter;
const signature = await crypto.subtle.sign('HMAC', key, input);
prev = new Uint8Array(signature);
const toCopy = Math.min(prev.length, length - offset);
result.set(prev.slice(0, toCopy), offset);
offset += toCopy;
counter++;
}
return result;
}
// Generate symmetric key
function generateSymmetricKey() {
return crypto.getRandomValues(new Uint8Array(64));
}
// Encrypt with AES-256-CBC
async function encryptAesCbc(data, key, iv) {
const cryptoKey = await crypto.subtle.importKey(
'raw',
key,
{ name: 'AES-CBC' },
false,
['encrypt']
);
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-CBC', iv: iv },
cryptoKey,
data
);
return new Uint8Array(encrypted);
}
// HMAC-SHA256
async function hmacSha256(key, data) {
const cryptoKey = await crypto.subtle.importKey(
'raw',
key,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', cryptoKey, data);
return new Uint8Array(signature);
}
// Base64 encode
function base64Encode(bytes) {
return btoa(String.fromCharCode.apply(null, bytes));
}
// Create encrypted string in Bitwarden format
async function encryptToBitwardenFormat(data, encKey, macKey) {
const iv = crypto.getRandomValues(new Uint8Array(16));
const encrypted = await encryptAesCbc(data, encKey, iv);
// Calculate MAC over IV + encrypted data
const macData = new Uint8Array(iv.length + encrypted.length);
macData.set(iv);
macData.set(encrypted, iv.length);
const mac = await hmacSha256(macKey, macData);
// Format: 2.{base64(iv)}|{base64(encrypted)}|{base64(mac)}
return '2.' + base64Encode(iv) + '|' + base64Encode(encrypted) + '|' + base64Encode(mac);
}
// Generate RSA key pair
async function generateRsaKeyPair() {
const keyPair = await crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-1'
},
true,
['encrypt', 'decrypt']
);
// Export public key
const publicKeySpki = await crypto.subtle.exportKey('spki', keyPair.publicKey);
const publicKeyB64 = base64Encode(new Uint8Array(publicKeySpki));
// Export private key
const privateKeyPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
const privateKeyBytes = new Uint8Array(privateKeyPkcs8);
return {
publicKey: publicKeyB64,
privateKey: privateKeyBytes
};
}
async function handleSubmit(event) {
event.preventDefault();
if (isRegistered) {
showMessage(t('doneTitle'), 'success');
return;
}
const name = document.getElementById('name').value;
const email = document.getElementById('email').value.toLowerCase();
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirmPassword').value;
if (password !== confirmPassword) {
showMessage(t('errPwNotMatch'), 'error');
return;
}
if (password.length < 12) {
showMessage(t('errPwTooShort'), 'error');
return;
}
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = t('creating');
try {
// Generate master key using PBKDF2 (Bitwarden default: 600000 iterations)
const iterations = 600000;
const masterKey = await pbkdf2(password, email, iterations, 32);
// Generate master password hash (for authentication)
// Bitwarden: PBKDF2(masterKey as raw bytes, password, 1 iteration)
const masterPasswordHash = await pbkdf2(masterKey, password, 1, 32);
const masterPasswordHashB64 = base64Encode(masterPasswordHash);
// Stretch master key using HKDF
const stretchedKey = await hkdfExpand(masterKey, 'enc', 32);
const stretchedMacKey = await hkdfExpand(masterKey, 'mac', 32);
// Generate symmetric key (will be encrypted with stretched master key)
const symmetricKey = generateSymmetricKey();
// Encrypt symmetric key with stretched master key
const encryptedKey = await encryptToBitwardenFormat(symmetricKey, stretchedKey, stretchedMacKey);
// Generate RSA key pair
const rsaKeys = await generateRsaKeyPair();
// Encrypt private key with symmetric key
const encryptedPrivateKey = await encryptToBitwardenFormat(rsaKeys.privateKey, symmetricKey.slice(0, 32), symmetricKey.slice(32, 64));
// Register with server
const response = await fetch('/api/accounts/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email,
name: name,
masterPasswordHash: masterPasswordHashB64,
key: encryptedKey,
kdf: 0,
kdfIterations: iterations,
keys: {
publicKey: rsaKeys.publicKey,
encryptedPrivateKey: encryptedPrivateKey
}
})
});
const result = await response.json();
if (response.ok && result.success) {
showRegisteredView();
} else {
showMessage(result.error || result.ErrorModel?.Message || t('errRegisterFailed'), 'error');
btn.disabled = false;
btn.textContent = t('create');
}
} catch (error) {
console.error('Registration error:', error);
showMessage(t('errGeneric') + (error && error.message ? error.message : String(error)), 'error');
btn.disabled = false;
btn.textContent = t('create');
}
}
// Check status on page load
applyI18n();
checkStatus();
</script>
</body>
</html>`;
function getJwtSecretState(env: Env): JwtSecretState | null {
const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing';
// Block common "forgot to change" sample value (matches .dev.vars.example)
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < 32) return 'too_short';
return null;
}
// GET / - Setup page
export async function handleSetupPage(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
const disabled = await storage.isSetupDisabled();
if (disabled) {
return new Response(null, { status: 404 });
}
return htmlResponse(setupPageHTML);
// Guard: require a strong JWT_SECRET before allowing setup/registration.
const jwtState = getJwtSecretState(env);
if (jwtState) {
return htmlResponse(renderJwtSecretWarningPage(request, jwtState), 200);
}
// Serve the registration/setup UI (split into a dedicated module).
return handleRegisterPage(request, env);
}
// GET /setup/status
export async function handleSetupStatus(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
const registered = await storage.isRegistered();
const disabled = await storage.isSetupDisabled();
return jsonResponse({ registered, disabled });
@@ -785,7 +41,7 @@ export async function handleSetupStatus(request: Request, env: Env): Promise<Res
// POST /setup/disable
export async function handleDisableSetup(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
const registered = await storage.isRegistered();
if (!registered) {
return errorResponse('Registration required', 403);
+290
View File
@@ -0,0 +1,290 @@
import { Env } from '../types';
// NOTE: Kept as a single file with inline HTML/CSS to avoid external assets.
// This file splits the old monolithic setup page into reusable page generators.
type Lang = 'zh' | 'en';
function isChineseFromRequest(request: Request): boolean {
const acceptLang = (request.headers.get('accept-language') || '').toLowerCase();
return acceptLang.includes('zh');
}
function t(lang: Lang, key: string): string {
const zh: Record<string, string> = {
app: 'NodeWarden',
tag: '部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端。',
// Config warning page
cfgTitle: '需要配置 JWT_SECRET',
cfgDescMissing: '当前服务没有配置 JWT_SECRET(用于签名登录令牌)。为了安全起见,必须先配置后才能注册/使用。',
cfgDescDefault: '检测到你正在使用示例/默认 JWT_SECRET。为了安全起见,请先修改为随机强密钥后再注册/使用。',
cfgDescTooShort: '检测到 JWT_SECRET 长度不足 32 个字符。为了安全起见,请使用至少 32 位的随机字符串。',
cfgStepsTitle: '如何在 Cloudflare 修改 JWT_SECRET',
cfgSteps: '打开 Cloudflare 控制台 → Workers 和 Pages → 选择 nodewarden → 设置 → 变量和机密 → 添加变量。\n类型:密钥\n名称:JWT_SECRET\n值:粘贴你生成的随机密钥\n保存后,等待重新部署生效。',
cfgGenTitle: '随机密钥生成器',
cfgGenHint: '建议长度:至少 32 字符(推荐 64+)。点击刷新生成新的随机值。',
cfgCopy: '复制',
cfgRefresh: '刷新',
// Shared
by: '作者',
github: 'GitHub',
};
const en: Record<string, string> = {
app: 'NodeWarden',
tag: 'Minimal Bitwarden-compatible server on Cloudflare Workers.',
// Config warning page
cfgTitle: 'JWT_SECRET is required',
cfgDescMissing: 'This server has no JWT_SECRET configured (used to sign login tokens). For safety, you must configure it before registration/usage.',
cfgDescDefault: 'You are using the sample/default JWT_SECRET. For safety, please change it to a strong random secret before registration/usage.',
cfgDescTooShort: 'JWT_SECRET is shorter than 32 characters. For safety, use a random string with at least 32 characters.',
cfgStepsTitle: 'How to set JWT_SECRET in Cloudflare',
cfgSteps: 'Open Cloudflare Dashboard → Workers & Pages → select nodewarden → Settings → Variables and Secrets → Add variable.\nType: Secret\nName: JWT_SECRET\nValue: paste a random secret\nSave, and wait for redeploy to take effect.',
cfgGenTitle: 'Random secret generator',
cfgGenHint: 'Recommended length: 32+ characters (64+ preferred). Click refresh to generate a new one.',
cfgCopy: 'Copy',
cfgRefresh: 'Refresh',
// Shared
by: 'By',
github: 'GitHub',
};
return (lang === 'zh' ? zh : en)[key] ?? key;
}
function baseStyles(): string {
// Keep consistent with existing setup page look & feel.
return `
:root {
color-scheme: light;
--bg0: #0b0b0f;
--bg1: #0f1020;
--card: rgba(255, 255, 255, 0.08);
--card2: rgba(255, 255, 255, 0.06);
--border: rgba(255, 255, 255, 0.14);
--text: rgba(255, 255, 255, 0.92);
--muted: rgba(255, 255, 255, 0.62);
--muted2: rgba(255, 255, 255, 0.52);
--accent: #0a84ff;
--accent2: #64d2ff;
--danger: #ff453a;
--ok: #32d74b;
--shadow: 0 16px 60px rgba(0, 0, 0, 0.50);
--radius: 18px;
--radius2: 14px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background:
radial-gradient(900px 600px at 15% 10%, rgba(100, 210, 255, 0.25), transparent 60%),
radial-gradient(900px 600px at 85% 20%, rgba(10, 132, 255, 0.22), transparent 60%),
radial-gradient(900px 600px at 50% 90%, rgba(50, 215, 75, 0.10), transparent 60%),
linear-gradient(180deg, var(--bg0), var(--bg1));
color: var(--text);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.shell { width: max(500px); }
.panel {
padding: 22px;
border: 1px solid var(--border);
background: rgba(255,255,255,0.06);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.top {
display: flex;
gap: 14px;
align-items: center;
margin-bottom: 14px;
}
.mark {
width: 46px;
height: 46px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(10,132,255,0.85), rgba(100,210,255,0.55));
border: 1px solid rgba(255,255,255,0.20);
box-shadow: 0 10px 40px rgba(10, 132, 255, 0.30);
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
letter-spacing: 1px;
color: rgba(255,255,255,0.96);
text-transform: uppercase;
user-select: none;
}
.title { display: flex; flex-direction: column; gap: 4px; }
.title h1 { font-size: 22px; margin: 0; letter-spacing: -0.3px; }
.title p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
h2 { font-size: 16px; margin: 14px 0 10px 0; letter-spacing: -0.2px; }
.lead { font-size: 13px; line-height: 1.7; color: rgba(255,255,255,0.86); }
.kv {
border-radius: var(--radius2);
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.05);
padding: 14px;
margin-top: 12px;
}
.kv h3 { margin: 0 0 8px 0; font-size: 13px; color: rgba(255,255,255,0.86); }
.kv p { margin: 0; font-size: 12px; line-height: 1.55; color: var(--muted); white-space: pre-line; }
.server {
margin-top: 10px;
font-family: var(--mono);
font-size: 12px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(0,0,0,0.25);
border: 1px solid rgba(255,255,255,0.12);
word-break: break-all;
color: rgba(255,255,255,0.90);
}
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.btn {
height: 38px;
padding: 0 12px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(0,0,0,0.18);
color: rgba(255,255,255,0.92);
font-weight: 700;
cursor: pointer;
}
.btn.primary {
background: linear-gradient(135deg, rgba(10,132,255,0.95), rgba(100,210,255,0.60));
}
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
a { color: rgba(100, 210, 255, 0.92); text-decoration: none; }
a:hover { text-decoration: underline; }
.footer {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid rgba(255,255,255,0.10);
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
color: rgba(255,255,255,0.55);
}
`;
}
export type JwtSecretState = 'missing' | 'default' | 'too_short';
export function renderJwtSecretWarningPage(request: Request, state: JwtSecretState): string {
const lang: Lang = isChineseFromRequest(request) ? 'zh' : 'en';
const descKey = state === 'missing' ? 'cfgDescMissing' : state === 'default' ? 'cfgDescDefault' : 'cfgDescTooShort';
return `<!DOCTYPE html>
<html lang="${lang === 'zh' ? 'zh-CN' : 'en'}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NodeWarden</title>
<style>${baseStyles()}</style>
</head>
<body>
<div class="shell">
<aside class="panel">
<div class="top">
<div class="mark" aria-label="NodeWarden">NW</div>
<div class="title">
<h1>${t(lang, 'app')}</h1>
<p>${t(lang, 'tag')}</p>
</div>
</div>
<h2>${t(lang, 'cfgTitle')}</h2>
<div class="lead">${t(lang, descKey)}</div>
<div class="kv">
<h3>${t(lang, 'cfgStepsTitle')}</h3>
<p>${t(lang, 'cfgSteps')
.replace(/^类型:密钥/m, '<b>类型:密钥</b>')
.replace(/^名称:JWT_SECRET/m, '<b>名称:JWT_SECRET</b>')
.replace(/^Type: Secret/m, '<b>Type: Secret</b>')
.replace(/^Name: JWT_SECRET/m, '<b>Name: JWT_SECRET</b>')
}</p>
</div>
<div class="kv">
<h3>${t(lang, 'cfgGenTitle')}</h3>
<p>${t(lang, 'cfgGenHint')}</p>
<div class="server" id="secret"></div>
<div style="height: 10px"></div>
<div class="row">
<button class="btn primary" type="button" onclick="refreshSecret()">${t(lang, 'cfgRefresh')}</button>
<button class="btn" type="button" onclick="copySecret()">${t(lang, 'cfgCopy')}</button>
</div>
</div>
<div class="footer">
<div>
<span>${t(lang, 'by')} </span>
<a href="https://shuai.plus" target="_blank" rel="noreferrer">shuaiplus</a>
</div>
<div>
<a href="https://github.com/shuaiplus/nodewarden" target="_blank" rel="noreferrer">${t(lang, 'github')}</a>
</div>
</div>
</aside>
</div>
<script>
// Generate a URL-safe random secret (default length: 64)
function genSecret(len) {
len = len || 50;
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
const bytes = new Uint8Array(len);
crypto.getRandomValues(bytes);
let out = '';
for (let i = 0; i < bytes.length; i++) {
out += chars[bytes[i] % chars.length];
}
return out;
}
function refreshSecret() {
const s = genSecret(50);
document.getElementById('secret').textContent = s;
}
async function copySecret() {
const s = document.getElementById('secret').textContent || '';
try {
await navigator.clipboard.writeText(s);
} catch {
const ta = document.createElement('textarea');
ta.value = s;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
}
refreshSecret();
</script>
</body>
</html>`;
}
+668
View File
@@ -0,0 +1,668 @@
import { Env } from '../types';
import { StorageService } from '../services/storage';
import { htmlResponse } from '../utils/response';
// Registration/setup page HTML (single-file, no external assets)
// Split out from the old monolithic `setup.ts` as requested.
const registerPageHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NodeWarden</title>
<style>
:root {
color-scheme: light;
--bg0: #0b0b0f;
--bg1: #0f1020;
--card: rgba(255, 255, 255, 0.08);
--card2: rgba(255, 255, 255, 0.06);
--border: rgba(255, 255, 255, 0.14);
--text: rgba(255, 255, 255, 0.92);
--muted: rgba(255, 255, 255, 0.62);
--muted2: rgba(255, 255, 255, 0.52);
--accent: #0a84ff;
--accent2: #64d2ff;
--danger: #ff453a;
--ok: #32d74b;
--shadow: 0 16px 60px rgba(0, 0, 0, 0.50);
--radius: 18px;
--radius2: 14px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background:
radial-gradient(900px 600px at 15% 10%, rgba(100, 210, 255, 0.25), transparent 60%),
radial-gradient(900px 600px at 85% 20%, rgba(10, 132, 255, 0.22), transparent 60%),
radial-gradient(900px 600px at 50% 90%, rgba(50, 215, 75, 0.10), transparent 60%),
linear-gradient(180deg, var(--bg0), var(--bg1));
color: var(--text);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.shell { width: max(500px); }
.panel {
padding: 22px;
border: 1px solid var(--border);
background: rgba(255,255,255,0.06);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.top {
display: flex;
gap: 14px;
align-items: center;
margin-bottom: 14px;
}
.mark {
width: 46px;
height: 46px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(10,132,255,0.85), rgba(100,210,255,0.55));
border: 1px solid rgba(255,255,255,0.20);
box-shadow: 0 10px 40px rgba(10, 132, 255, 0.30);
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
letter-spacing: 1px;
color: rgba(255,255,255,0.96);
text-transform: uppercase;
user-select: none;
}
.title { display: flex; flex-direction: column; gap: 4px; }
.title h1 { font-size: 22px; margin: 0; letter-spacing: -0.3px; }
.title p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
h2 { font-size: 16px; margin: 14px 0 10px 0; letter-spacing: -0.2px; }
.message {
display: none;
border-radius: 14px;
padding: 12px 12px;
margin: 0 0 12px 0;
font-size: 13px;
line-height: 1.45;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.06);
}
.message.error {
display: block;
border-color: rgba(255, 69, 58, 0.40);
background: rgba(255, 69, 58, 0.10);
color: rgba(255, 255, 255, 0.92);
}
.message.success {
display: block;
border-color: rgba(50, 215, 75, 0.35);
background: rgba(50, 215, 75, 0.10);
color: rgba(255, 255, 255, 0.92);
}
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@media (max-width: 540px) { .grid { grid-template-columns: 1fr; } }
.field { display: flex; flex-direction: column; gap: 7px; }
label { font-size: 12px; color: var(--muted); letter-spacing: 0.2px; }
input {
height: 42px;
padding: 0 12px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.18);
background: rgba(0,0,0,0.18);
color: rgba(255,255,255,0.92);
outline: none;
font-size: 14px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
input::placeholder { color: rgba(255,255,255,0.35); }
input:focus {
border-color: rgba(10, 132, 255, 0.55);
box-shadow: 0 0 0 6px rgba(10, 132, 255, 0.12);
}
.hint { margin: 0; color: var(--muted2); font-size: 12px; line-height: 1.55; }
.actions { margin-top: 12px; display: flex; gap: 10px; align-items: center; }
.primary {
width: 100%;
height: 44px;
border-radius: 14px;
border: 1px solid rgba(255,255,255,0.18);
background: linear-gradient(135deg, rgba(10,132,255,0.95), rgba(100,210,255,0.60));
color: rgba(255,255,255,0.96);
font-weight: 700;
letter-spacing: 0.2px;
cursor: pointer;
transition: transform 120ms ease, filter 120ms ease;
}
.primary:hover { filter: brightness(1.03); }
.primary:active { transform: translateY(1px) scale(0.99); }
.primary:disabled { opacity: 0.55; cursor: not-allowed; transform: none; }
.sideCard { display: flex; flex-direction: column; gap: 12px; }
.kv {
border-radius: var(--radius2);
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.05);
padding: 14px;
margin-bottom: 10px;
}
.kv h3 { margin: 0 0 8px 0; font-size: 13px; color: rgba(255,255,255,0.86); }
.kv p { margin: 0; font-size: 12px; line-height: 1.55; color: var(--muted); }
.server {
margin-top: 10px;
font-family: var(--mono);
font-size: 12px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(0,0,0,0.25);
border: 1px solid rgba(255,255,255,0.12);
word-break: break-all;
color: rgba(255,255,255,0.90);
}
a { color: rgba(100, 210, 255, 0.92); text-decoration: none; }
a:hover { text-decoration: underline; }
.footer {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid rgba(255,255,255,0.10);
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
font-size: 12px;
color: rgba(255,255,255,0.55);
}
.muted { color: var(--muted); }
</style>
</head>
<body>
<div class="shell">
<aside class="panel">
<div class="top">
<div class="mark" aria-label="NodeWarden">NW</div>
<div class="title">
<h1 id="t_app">NodeWarden</h1>
<p id="t_tag">Minimal Bitwarden-compatible server on Cloudflare Workers.</p>
</div>
</div>
<div class="muted" id="t_intro" style="font-size: 13px; line-height: 1.7;">
Create your first account to finish setup. Then use any official Bitwarden client to sign in.
</div>
<div style="height: 14px"></div>
<h2 id="t_setup">Setup</h2>
<div id="message" class="message"></div>
<div id="setup-form">
<form id="form" onsubmit="handleSubmit(event)">
<div class="grid">
<div class="field">
<label for="name" id="t_name_label">Name</label>
<input type="text" id="name" name="name" required placeholder="Your name">
</div>
<div class="field">
<label for="email" id="t_email_label">Email</label>
<input type="email" id="email" name="email" required placeholder="you@example.com" autocomplete="email">
</div>
</div>
<div style="height: 10px"></div>
<div class="field">
<label for="password" id="t_pw_label">Master password</label>
<input type="password" id="password" name="password" required minlength="12" placeholder="At least 12 characters" autocomplete="new-password">
<p class="hint" id="t_pw_hint">Choose a strong password you can remember. The server cannot recover it.</p>
</div>
<div style="height: 10px"></div>
<div class="field">
<label for="confirmPassword" id="t_pw2_label">Confirm password</label>
<input type="password" id="confirmPassword" name="confirmPassword" required placeholder="Confirm password" autocomplete="new-password">
</div>
<div class="actions">
<button type="submit" id="submitBtn" class="primary">Create account</button>
</div>
</form>
</div>
<div id="registered-view" class="sideCard" style="display: none;">
<div class="kv">
<h3 id="t_done_title">Setup complete</h3>
<p id="t_done_desc">Your server is ready. Configure your Bitwarden client with this server URL:</p>
<div class="server" id="serverUrl"></div>
</div>
<div class="kv">
<h3 id="t_important">Important</h3>
<p id="t_limitations">
This project is designed for a single user. You cannot add new users. Changing the master password is not supported.
If you forget it, you must redeploy and register again.
</p>
</div>
<div class="kv">
<h3 id="t_hide_title">Hide setup page</h3>
<p id="t_hide_desc">After hiding, this setup page will return 404 for everyone. Your vault will keep working.</p>
<div class="actions">
<button type="button" id="hideBtn" class="primary" onclick="disableSetupPage()">Hide setup page</button>
</div>
</div>
</div>
<div class="footer">
<div>
<span class="muted" id="t_by">By</span>
<a href="https://shuai.plus" target="_blank" rel="noreferrer">shuaiplus</a>
</div>
<div>
<a href="https://github.com/shuaiplus/nodewarden" target="_blank" rel="noreferrer">GitHub</a>
</div>
</div>
</aside>
</div>
<script>
let isRegistered = false;
function isChinese() {
const lang = (navigator.language || '').toLowerCase();
return lang.startsWith('zh');
}
function t(key) {
const zh = {
app: 'NodeWarden',
tag: '部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端。',
intro: '创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。',
by: '作者',
setup: '初始化',
nameLabel: '昵称',
emailLabel: '邮箱',
pwLabel: '主密码',
pwHint: '请选择你能记住的强密码。服务器无法找回主密码。',
pw2Label: '确认主密码',
create: '创建账号',
creating: '正在创建…',
doneTitle: '初始化完成',
doneDesc: '服务已就绪。在 Bitwarden 客户端中填入以下服务器地址:',
important: '重要提示',
limitations: '本项目仅支持单用户:不能添加新用户;不支持修改主密码;如果忘记主密码,只能重新部署并重新注册。',
hideTitle: '隐藏初始化页',
hideDesc: '隐藏后,初始化页对任何人都会直接返回 404。你的密码库仍可正常使用。',
hideBtn: '隐藏初始化页',
hideWorking: '正在隐藏…',
hideDone: '已隐藏,此页面将返回 404。',
hideFailed: '隐藏失败',
hideConfirm: '确认隐藏初始化页?隐藏后页面将不可访问,但你的密码库不会受影响。',
errPwNotMatch: '两次输入的密码不一致',
errPwTooShort: '密码长度至少 12 位',
errGeneric: '发生错误:',
errRegisterFailed: '注册失败',
};
const en = {
app: 'NodeWarden',
tag: 'Minimal Bitwarden-compatible server on Cloudflare Workers.',
intro: 'Create your first account to finish setup. Then use any official Bitwarden client to sign in.',
by: 'By',
setup: 'Setup',
nameLabel: 'Name',
emailLabel: 'Email',
pwLabel: 'Master password',
pwHint: 'Choose a strong password you can remember. The server cannot recover it.',
pw2Label: 'Confirm password',
create: 'Create account',
creating: 'Creating…',
doneTitle: 'Setup complete',
doneDesc: 'Your server is ready. Configure your Bitwarden client with this server URL:',
important: 'Important',
limitations: 'Single user only: you cannot add new users. Changing the master password is not supported. If you forget it, redeploy and register again.',
hideTitle: 'Hide setup page',
hideDesc: 'After hiding, this setup page will return 404 for everyone. Your vault will keep working.',
hideBtn: 'Hide setup page',
hideWorking: 'Hiding…',
hideDone: 'Hidden. This page will now return 404.',
hideFailed: 'Failed to hide setup page',
hideConfirm: 'Hide the setup page? It will no longer be accessible, but your vault will keep working.',
errPwNotMatch: 'Passwords do not match',
errPwTooShort: 'Password must be at least 12 characters',
errGeneric: 'An error occurred: ',
errRegisterFailed: 'Registration failed',
};
return (isChinese() ? zh : en)[key];
}
function applyI18n() {
document.documentElement.lang = isChinese() ? 'zh-CN' : 'en';
document.getElementById('t_app').textContent = t('app');
document.getElementById('t_tag').textContent = t('tag');
document.getElementById('t_intro').textContent = t('intro');
document.getElementById('t_by').textContent = t('by');
document.getElementById('t_setup').textContent = t('setup');
document.getElementById('t_name_label').textContent = t('nameLabel');
document.getElementById('t_email_label').textContent = t('emailLabel');
document.getElementById('t_pw_label').textContent = t('pwLabel');
document.getElementById('t_pw_hint').textContent = t('pwHint');
document.getElementById('t_pw2_label').textContent = t('pw2Label');
document.getElementById('submitBtn').textContent = t('create');
document.getElementById('t_done_title').textContent = t('doneTitle');
document.getElementById('t_done_desc').textContent = t('doneDesc');
document.getElementById('t_important').textContent = t('important');
document.getElementById('t_limitations').textContent = t('limitations');
document.getElementById('t_hide_title').textContent = t('hideTitle');
document.getElementById('t_hide_desc').textContent = t('hideDesc');
document.getElementById('hideBtn').textContent = t('hideBtn');
}
async function checkStatus() {
try {
const res = await fetch('/setup/status');
const data = await res.json();
isRegistered = !!data.registered;
if (data.registered) {
showRegisteredView();
}
} catch (e) {
console.error('Failed to check status:', e);
}
}
function showRegisteredView() {
isRegistered = true;
document.getElementById('setup-form').style.display = 'none';
document.getElementById('registered-view').style.display = 'block';
document.getElementById('serverUrl').textContent = window.location.origin;
showMessage(t('doneTitle'), 'success');
const form = document.getElementById('form');
if (form) {
const fields = form.querySelectorAll('input, button');
fields.forEach((el) => {
el.disabled = true;
});
}
}
async function disableSetupPage() {
if (!isRegistered) return;
if (!confirm(t('hideConfirm'))) return;
const btn = document.getElementById('hideBtn');
if (btn) {
btn.disabled = true;
btn.textContent = t('hideWorking');
}
try {
const res = await fetch('/setup/disable', { method: 'POST' });
const data = await res.json();
if (res.ok && data.success) {
showMessage(t('hideDone'), 'success');
setTimeout(() => window.location.reload(), 600);
return;
}
showMessage(data.error || t('hideFailed'), 'error');
} catch (e) {
showMessage(t('hideFailed'), 'error');
}
if (btn) {
btn.disabled = false;
btn.textContent = t('hideBtn');
}
}
function showMessage(text, type) {
const msg = document.getElementById('message');
msg.textContent = text;
msg.className = 'message ' + type;
}
async function pbkdf2(password, salt, iterations, keyLen) {
const encoder = new TextEncoder();
const passwordBytes = (password instanceof Uint8Array)
? password
: encoder.encode(password);
const saltBytes = (salt instanceof Uint8Array)
? salt
: encoder.encode(salt);
const keyMaterial = await crypto.subtle.importKey(
'raw',
passwordBytes,
'PBKDF2',
false,
['deriveBits']
);
const derivedBits = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
salt: saltBytes,
iterations: iterations,
hash: 'SHA-256'
},
keyMaterial,
keyLen * 8
);
return new Uint8Array(derivedBits);
}
async function hkdfExpand(prk, info, length) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
prk,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const infoBytes = encoder.encode(info);
const result = new Uint8Array(length);
let prev = new Uint8Array(0);
let offset = 0;
let counter = 1;
while (offset < length) {
const input = new Uint8Array(prev.length + infoBytes.length + 1);
input.set(prev);
input.set(infoBytes, prev.length);
input[input.length - 1] = counter;
const signature = await crypto.subtle.sign('HMAC', key, input);
prev = new Uint8Array(signature);
const toCopy = Math.min(prev.length, length - offset);
result.set(prev.slice(0, toCopy), offset);
offset += toCopy;
counter++;
}
return result;
}
function generateSymmetricKey() {
return crypto.getRandomValues(new Uint8Array(64));
}
async function encryptAesCbc(data, key, iv) {
const cryptoKey = await crypto.subtle.importKey(
'raw',
key,
{ name: 'AES-CBC' },
false,
['encrypt']
);
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-CBC', iv: iv },
cryptoKey,
data
);
return new Uint8Array(encrypted);
}
async function hmacSha256(key, data) {
const cryptoKey = await crypto.subtle.importKey(
'raw',
key,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', cryptoKey, data);
return new Uint8Array(signature);
}
function base64Encode(bytes) {
return btoa(String.fromCharCode.apply(null, bytes));
}
async function encryptToBitwardenFormat(data, encKey, macKey) {
const iv = crypto.getRandomValues(new Uint8Array(16));
const encrypted = await encryptAesCbc(data, encKey, iv);
const macData = new Uint8Array(iv.length + encrypted.length);
macData.set(iv);
macData.set(encrypted, iv.length);
const mac = await hmacSha256(macKey, macData);
return '2.' + base64Encode(iv) + '|' + base64Encode(encrypted) + '|' + base64Encode(mac);
}
async function generateRsaKeyPair() {
const keyPair = await crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-1'
},
true,
['encrypt', 'decrypt']
);
const publicKeySpki = await crypto.subtle.exportKey('spki', keyPair.publicKey);
const publicKeyB64 = base64Encode(new Uint8Array(publicKeySpki));
const privateKeyPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
const privateKeyBytes = new Uint8Array(privateKeyPkcs8);
return {
publicKey: publicKeyB64,
privateKey: privateKeyBytes
};
}
async function handleSubmit(event) {
event.preventDefault();
if (isRegistered) {
showMessage(t('doneTitle'), 'success');
return;
}
const name = document.getElementById('name').value;
const email = document.getElementById('email').value.toLowerCase();
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirmPassword').value;
if (password !== confirmPassword) {
showMessage(t('errPwNotMatch'), 'error');
return;
}
if (password.length < 12) {
showMessage(t('errPwTooShort'), 'error');
return;
}
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = t('creating');
try {
const iterations = 600000;
const masterKey = await pbkdf2(password, email, iterations, 32);
const masterPasswordHash = await pbkdf2(masterKey, password, 1, 32);
const masterPasswordHashB64 = base64Encode(masterPasswordHash);
const stretchedKey = await hkdfExpand(masterKey, 'enc', 32);
const stretchedMacKey = await hkdfExpand(masterKey, 'mac', 32);
const symmetricKey = generateSymmetricKey();
const encryptedKey = await encryptToBitwardenFormat(symmetricKey, stretchedKey, stretchedMacKey);
const rsaKeys = await generateRsaKeyPair();
const encryptedPrivateKey = await encryptToBitwardenFormat(rsaKeys.privateKey, symmetricKey.slice(0, 32), symmetricKey.slice(32, 64));
const response = await fetch('/api/accounts/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email,
name: name,
masterPasswordHash: masterPasswordHashB64,
key: encryptedKey,
kdf: 0,
kdfIterations: iterations,
keys: {
publicKey: rsaKeys.publicKey,
encryptedPrivateKey: encryptedPrivateKey
}
})
});
const result = await response.json();
if (response.ok && result.success) {
showRegisteredView();
} else {
showMessage(result.error || result.ErrorModel?.Message || t('errRegisterFailed'), 'error');
btn.disabled = false;
btn.textContent = t('create');
}
} catch (error) {
console.error('Registration error:', error);
showMessage(t('errGeneric') + (error && error.message ? error.message : String(error)), 'error');
btn.disabled = false;
btn.textContent = t('create');
}
}
applyI18n();
checkStatus();
</script>
</body>
</html>`;
export async function handleRegisterPage(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const disabled = await storage.isSetupDisabled();
if (disabled) {
return new Response(null, { status: 404 });
}
return htmlResponse(registerPageHTML);
}
+25 -51
View File
@@ -1,24 +1,11 @@
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse, Attachment } from '../types';
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } 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: Number(a.size) || 0, // Android expects Int, not String
sizeName: a.sizeName,
key: a.key,
url: `/api/ciphers/${a.cipherId}/attachment/${a.id}`, // Android requires non-null url!
object: 'attachment',
}));
}
import { cipherToResponse } from './ciphers';
// GET /api/sync
export async function handleSync(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.VAULT);
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) {
@@ -57,40 +44,8 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
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: Number(cipher.type) || 1,
name: cipher.name,
notes: cipher.notes,
favorite: cipher.favorite,
login: cipher.login,
card: cipher.card,
identity: cipher.identity,
secureNote: cipher.secureNote,
sshKey: cipher.sshKey,
fields: cipher.fields,
passwordHistory: cipher.passwordHistory,
reprompt: cipher.reprompt,
organizationUseTotp: false,
creationDate: cipher.createdAt,
revisionDate: cipher.updatedAt,
deletedDate: cipher.deletedAt,
archivedDate: null,
edit: true,
viewPassword: true,
permissions: {
delete: true,
restore: true,
},
object: 'cipher',
collectionIds: [],
attachments: formatAttachments(attachments),
key: cipher.key,
encryptedFor: null,
});
};
cipherResponses.push(cipherToResponse(cipher, attachments));
}
// Build folder responses
const folderResponses: FolderResponse[] = folders.map(folder => ({
@@ -112,16 +67,35 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
},
policies: [],
sends: [],
// PascalCase for desktop/browser clients
UserDecryptionOptions: {
HasMasterPassword: true,
Object: 'userDecryptionOptions',
MasterPasswordUnlock: {
Kdf: {
KdfType: user.kdfType,
Iterations: user.kdfIterations,
Memory: user.kdfMemory || null,
Parallelism: user.kdfParallelism || null,
},
MasterKeyEncryptedUserKey: user.key,
MasterKeyWrappedUserKey: user.key,
Salt: user.email,
Object: 'masterPasswordUnlock',
},
},
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
userDecryption: {
masterPasswordUnlock: {
salt: user.email,
kdf: {
kdfType: user.kdfType,
iterations: user.kdfIterations,
memory: user.kdfMemory || null,
parallelism: user.kdfParallelism || null,
},
masterKeyWrappedUserKey: user.key,
masterKeyEncryptedUserKey: user.key,
salt: user.email,
},
},
object: 'sync',
+17 -9
View File
@@ -1,18 +1,26 @@
import { Env } from './types';
import { handleRequest } from './router';
import { StorageService } from './services/storage';
// Per-isolate flag. Each Worker isolate may have its own copy of this flag,
// but initializeDatabase() is idempotent (uses CREATE TABLE IF NOT EXISTS),
// so redundant calls are harmless and fast (single SELECT check).
let dbInitialized = false;
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 });
// Auto-initialize database on first request
if (!dbInitialized) {
try {
const storage = new StorageService(env.DB);
await storage.initializeDatabase();
dbInitialized = true;
} catch (error) {
console.error('Failed to initialize database:', error);
// Continue anyway - the error will surface when actual DB operations are attempted
}
}
// 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);
},
};
+8 -1
View File
@@ -89,6 +89,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Route matching
try {
// Setup page (root)
if (path === '/' && method === 'GET') {
return handleSetupPage(request, env);
@@ -181,6 +182,12 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return handleRegister(request, env);
}
// If JWT_SECRET is not safely configured, block any other endpoints.
const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < 32) {
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
}
// All other API endpoints require authentication
const auth = new AuthService(env);
const authHeader = request.headers.get('Authorization');
@@ -193,7 +200,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
const userId = payload.sub;
// API rate limiting for authenticated requests
const rateLimit = new RateLimitService(env.VAULT);
const rateLimit = new RateLimitService(env.DB);
const clientId = getClientIdentifier(request);
const rateLimitCheck = await rateLimit.checkApiRateLimit(userId + ':' + clientId);
+1 -1
View File
@@ -6,7 +6,7 @@ export class AuthService {
private storage: StorageService;
constructor(private env: Env) {
this.storage = new StorageService(env.VAULT);
this.storage = new StorageService(env.DB);
}
// Verify password hash (compare with stored hash)
+71 -105
View File
@@ -1,133 +1,107 @@
import { Env } from '../types';
// D1-backed rate limiting.
// Notes:
// - Login attempts are tracked per email.
// - API rate is tracked per identifier per fixed window.
// Rate limit configuration
const CONFIG = {
// Login attempt limits
LOGIN_MAX_ATTEMPTS: 15, // Max failed login attempts
LOGIN_LOCKOUT_MINUTES: 5, // Lockout duration after max attempts
// API rate limits (per minute)
API_REQUESTS_PER_MINUTE: 300, // General API rate limit
API_WINDOW_SECONDS: 60, // Rate limit window
};
LOGIN_MAX_ATTEMPTS: 15,
LOGIN_LOCKOUT_MINUTES: 5,
// KV key prefixes
const KEYS = {
LOGIN_ATTEMPTS: 'ratelimit:login:',
API_RATE: 'ratelimit:api:',
API_REQUESTS_PER_MINUTE: 300,
API_WINDOW_SECONDS: 60,
};
export class RateLimitService {
constructor(private kv: KVNamespace) {}
constructor(private db: D1Database) {}
/**
* 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) {
const key = email.toLowerCase();
const now = Date.now();
const row = await this.db
.prepare('SELECT attempts, locked_until FROM login_attempts WHERE email = ?')
.bind(key)
.first<{ attempts: number; locked_until: number | null }>();
if (!row) {
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);
if (row.locked_until && row.locked_until > now) {
return {
allowed: false,
remainingAttempts: 0,
retryAfterSeconds,
retryAfterSeconds: Math.ceil((row.locked_until - now) / 1000),
};
}
// If lockout expired, reset
if (record.lockedUntil && record.lockedUntil <= now) {
await this.kv.delete(key);
if (row.locked_until && row.locked_until <= now) {
await this.db.prepare('DELETE FROM login_attempts WHERE email = ?').bind(key).run();
return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS };
}
const remainingAttempts = CONFIG.LOGIN_MAX_ATTEMPTS - record.attempts;
const remainingAttempts = Math.max(0, CONFIG.LOGIN_MAX_ATTEMPTS - (row.attempts || 0));
return { allowed: true, remainingAttempts };
}
/**
* 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 };
}
async recordFailedLogin(email: string): Promise<{ locked: boolean; retryAfterSeconds?: number }> {
const key = email.toLowerCase();
const now = Date.now();
// 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,
};
}
// D1 in Workers forbids raw BEGIN/COMMIT statements.
// Use a single atomic UPSERT to increment attempts.
// This is concurrency-safe because the row is keyed by email.
await this.db
.prepare(
'INSERT INTO login_attempts(email, attempts, locked_until, updated_at) VALUES(?, 1, NULL, ?) ' +
'ON CONFLICT(email) DO UPDATE SET attempts = attempts + 1, updated_at = excluded.updated_at'
)
.bind(key, now)
.run();
// 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,
});
const row = await this.db
.prepare('SELECT attempts FROM login_attempts WHERE email = ?')
.bind(key)
.first<{ attempts: number }>();
const attempts = row?.attempts || 1;
if (attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) {
const lockedUntil = now + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000;
await this.db
.prepare('UPDATE login_attempts SET locked_until = ?, updated_at = ? WHERE email = ?')
.bind(lockedUntil, now, key)
.run();
return { locked: true, retryAfterSeconds: 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);
await this.db.prepare('DELETE FROM login_attempts WHERE email = ?').bind(email.toLowerCase()).run();
}
/**
* 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}`;
async checkApiRateLimit(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
const nowSec = Math.floor(Date.now() / 1000);
const windowStart = nowSec - (nowSec % CONFIG.API_WINDOW_SECONDS);
const windowEnd = windowStart + CONFIG.API_WINDOW_SECONDS;
const countStr = await this.kv.get(key);
const count = countStr ? parseInt(countStr, 10) : 0;
const row = await this.db
.prepare('SELECT count FROM api_rate_limits WHERE identifier = ? AND window_start = ?')
.bind(identifier, windowStart)
.first<{ count: number }>();
const count = row?.count || 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,
retryAfterSeconds: windowEnd - nowSec,
};
}
@@ -137,35 +111,27 @@ export class RateLimitService {
};
}
/**
* 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 nowSec = Math.floor(Date.now() / 1000);
const windowStart = nowSec - (nowSec % CONFIG.API_WINDOW_SECONDS);
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
});
// Atomic increment via UPSERT.
await this.db
.prepare(
'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' +
'ON CONFLICT(identifier, window_start) DO UPDATE SET count = count + 1'
)
.bind(identifier, windowStart)
.run();
}
}
/**
* 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';
}
+416 -183
View File
@@ -1,256 +1,489 @@
import { Env, User, Cipher, Folder, Attachment } from '../types';
import { User, Cipher, Folder, Attachment } from '../types';
const KEYS = {
CONFIG_REGISTERED: 'config:registered',
CONFIG_SETUP_DISABLED: 'config:setup_disabled',
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:',
};
// D1-backed storage.
// Contract:
// - All methods are scoped by userId where applicable.
// - Uses SQL constraints (PK/unique/FK) to avoid KV-style index race conditions.
// - Revision date is maintained per user for Bitwarden sync.
export class StorageService {
constructor(private kv: KVNamespace) {}
constructor(private db: D1Database) {}
// --- Database initialization ---
// Idempotent auto-init for environments where D1 migrations have not been applied
// (e.g. one-click deploy). Mirrors the schema in migrations/0001_init.sql —
// keep both in sync when changing the schema.
async initializeDatabase(): Promise<void> {
// Check if database is already initialized by looking for the config table
try {
const result = await this.db
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='config'")
.first<{ name: string }>();
if (result?.name === 'config') {
// Database already initialized
return;
}
} catch (e) {
// If error occurs, assume database needs initialization
console.log('Initializing database...');
}
// Execute initialization SQL
const initSQL = `
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT,
master_password_hash TEXT NOT NULL,
key TEXT NOT NULL,
private_key TEXT,
public_key TEXT,
kdf_type INTEGER NOT NULL,
kdf_iterations INTEGER NOT NULL,
kdf_memory INTEGER,
kdf_parallelism INTEGER,
security_stamp TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_revisions (
user_id TEXT PRIMARY KEY,
revision_date TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS ciphers (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
type INTEGER NOT NULL,
folder_id TEXT,
name TEXT,
notes TEXT,
favorite INTEGER NOT NULL DEFAULT 0,
data TEXT NOT NULL,
reprompt INTEGER,
key TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at);
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at);
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
cipher_id TEXT NOT NULL,
file_name TEXT NOT NULL,
size INTEGER NOT NULL,
size_name TEXT NOT NULL,
key TEXT,
FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id);
CREATE TABLE IF NOT EXISTS refresh_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE TABLE IF NOT EXISTS login_attempts (
email TEXT PRIMARY KEY,
attempts INTEGER NOT NULL,
locked_until INTEGER,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS api_rate_limits (
identifier TEXT NOT NULL,
window_start INTEGER NOT NULL,
count INTEGER NOT NULL,
PRIMARY KEY (identifier, window_start)
);
CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
`.trim();
// Split by semicolon and execute each statement
const statements = initSQL.split(';').filter(s => s.trim().length > 0);
for (const stmt of statements) {
if (stmt.trim()) {
await this.db.prepare(stmt).run();
}
}
console.log('Database initialized successfully');
}
// --- Config / setup ---
// Registration status
async isRegistered(): Promise<boolean> {
const value = await this.kv.get(KEYS.CONFIG_REGISTERED);
return value === 'true';
const row = await this.db.prepare('SELECT value FROM config WHERE key = ?').bind('registered').first<{ value: string }>();
return row?.value === 'true';
}
async setRegistered(): Promise<void> {
await this.kv.put(KEYS.CONFIG_REGISTERED, 'true');
await this.db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
.bind('registered', 'true')
.run();
}
// Setup page visibility
async isSetupDisabled(): Promise<boolean> {
const value = await this.kv.get(KEYS.CONFIG_SETUP_DISABLED);
return value === 'true';
const row = await this.db.prepare('SELECT value FROM config WHERE key = ?').bind('setup_disabled').first<{ value: string }>();
return row?.value === 'true';
}
async setSetupDisabled(): Promise<void> {
await this.kv.put(KEYS.CONFIG_SETUP_DISABLED, 'true');
await this.db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
.bind('setup_disabled', 'true')
.run();
}
// User operations
// --- Users ---
async getUser(email: string): Promise<User | null> {
const data = await this.kv.get(`${KEYS.USER_PREFIX}${email.toLowerCase()}`);
return data ? JSON.parse(data) : null;
const row = await this.db
.prepare(
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at FROM users WHERE email = ?'
)
.bind(email.toLowerCase())
.first<any>();
if (!row) return null;
return {
id: row.id,
email: row.email,
name: row.name,
masterPasswordHash: row.master_password_hash,
key: row.key,
privateKey: row.private_key,
publicKey: row.public_key,
kdfType: row.kdf_type,
kdfIterations: row.kdf_iterations,
kdfMemory: row.kdf_memory ?? undefined,
kdfParallelism: row.kdf_parallelism ?? undefined,
securityStamp: row.security_stamp,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
async getUserById(id: string): Promise<User | null> {
// Get user email from id mapping
const email = await this.kv.get(`userid:${id}`);
if (!email) return null;
return this.getUser(email);
const row = await this.db
.prepare(
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at FROM users WHERE id = ?'
)
.bind(id)
.first<any>();
if (!row) return null;
return {
id: row.id,
email: row.email,
name: row.name,
masterPasswordHash: row.master_password_hash,
key: row.key,
privateKey: row.private_key,
publicKey: row.public_key,
kdfType: row.kdf_type,
kdfIterations: row.kdf_iterations,
kdfMemory: row.kdf_memory ?? undefined,
kdfParallelism: row.kdf_parallelism ?? undefined,
securityStamp: row.security_stamp,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
async saveUser(user: User): Promise<void> {
await this.kv.put(`${KEYS.USER_PREFIX}${user.email.toLowerCase()}`, JSON.stringify(user));
await this.kv.put(`userid:${user.id}`, user.email.toLowerCase());
const email = user.email.toLowerCase();
await this.db
.prepare(
'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' +
'email=excluded.email, name=excluded.name, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, updated_at=excluded.updated_at'
)
.bind(
user.id,
email,
user.name,
user.masterPasswordHash,
user.key,
user.privateKey,
user.publicKey,
user.kdfType,
user.kdfIterations,
user.kdfMemory ?? null,
user.kdfParallelism ?? null,
user.securityStamp,
user.createdAt,
user.updatedAt
)
.run();
}
// Cipher operations
// --- Ciphers ---
async getCipher(id: string): Promise<Cipher | null> {
const data = await this.kv.get(`${KEYS.CIPHER_PREFIX}${id}`);
return data ? JSON.parse(data) : null;
const row = await this.db.prepare('SELECT data FROM ciphers WHERE id = ?').bind(id).first<{ data: string }>();
return row?.data ? (JSON.parse(row.data) as Cipher) : null;
}
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));
}
const data = JSON.stringify(cipher);
await this.db
.prepare(
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' +
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at'
)
.bind(
cipher.id,
cipher.userId,
Number(cipher.type) || 1,
cipher.folderId,
cipher.name,
cipher.notes,
cipher.favorite ? 1 : 0,
data,
cipher.reprompt ?? 0,
cipher.key,
cipher.createdAt,
cipher.updatedAt,
cipher.deletedAt
)
.run();
}
async deleteCipher(id: string, userId: string): Promise<void> {
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) : [];
// hard delete
await this.db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run();
}
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;
const res = await this.db.prepare('SELECT data FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC').bind(userId).all<{ data: string }>();
return (res.results || []).map(r => JSON.parse(r.data) as Cipher);
}
// 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;
if (ids.length === 0) return [];
// D1 doesn't support binding arrays directly; build placeholders.
const placeholders = ids.map(() => '?').join(',');
const stmt = this.db.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`);
const res = await stmt.bind(userId, ...ids).all<{ data: string }>();
return (res.results || []).map(r => JSON.parse(r.data) as Cipher);
}
async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<void> {
if (ids.length === 0) return;
const now = new Date().toISOString();
// D1 forbids raw BEGIN/COMMIT statements in this runtime.
// For this endpoint, we accept per-row updates and then bump revision once.
// Concurrency: each cipher write is an UPSERT on its PK, no shared index.
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);
}
const row = await this.db
.prepare('SELECT data FROM ciphers WHERE id = ? AND user_id = ?')
.bind(id, userId)
.first<{ data: string }>();
if (!row?.data) continue;
const cipher = JSON.parse(row.data) as Cipher;
cipher.folderId = folderId;
cipher.updatedAt = now;
await this.saveCipher(cipher);
}
await this.updateRevisionDate(userId);
}
// Attachment operations
// --- Folders ---
async getFolder(id: string): Promise<Folder | null> {
const row = await this.db
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ?')
.bind(id)
.first<any>();
if (!row) return null;
return {
id: row.id,
userId: row.user_id,
name: row.name,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
async saveFolder(folder: Folder): Promise<void> {
await this.db
.prepare(
'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET user_id=excluded.user_id, name=excluded.name, updated_at=excluded.updated_at'
)
.bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt)
.run();
}
async deleteFolder(id: string, userId: string): Promise<void> {
await this.db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run();
}
async getAllFolders(userId: string): Promise<Folder[]> {
const res = await this.db
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC')
.bind(userId)
.all<any>();
return (res.results || []).map(r => ({
id: r.id,
userId: r.user_id,
name: r.name,
createdAt: r.created_at,
updatedAt: r.updated_at,
}));
}
// --- Attachments ---
async getAttachment(id: string): Promise<Attachment | null> {
const data = await this.kv.get(`${KEYS.ATTACHMENT_PREFIX}${id}`);
return data ? JSON.parse(data) : null;
const row = await this.db
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE id = ?')
.bind(id)
.first<any>();
if (!row) return null;
return {
id: row.id,
cipherId: row.cipher_id,
fileName: row.file_name,
size: row.size,
sizeName: row.size_name,
key: row.key,
};
}
async saveAttachment(attachment: Attachment): Promise<void> {
await this.kv.put(`${KEYS.ATTACHMENT_PREFIX}${attachment.id}`, JSON.stringify(attachment));
await this.db
.prepare(
'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key'
)
.bind(attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key)
.run();
}
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) : [];
await this.db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
}
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;
const res = await this.db
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?')
.bind(cipherId)
.all<any>();
return (res.results || []).map(r => ({
id: r.id,
cipherId: r.cipher_id,
fileName: r.file_name,
size: r.size,
sizeName: r.size_name,
key: r.key,
}));
}
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));
}
// Kept for API compatibility; no-op because attachments table already links cipher_id.
// We still validate that the attachment exists and belongs to cipher.
await this.db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
}
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));
// No-op: schema uses NOT NULL cipher_id.
// Callers always delete attachment row afterwards, so this method is kept for compatibility only.
void cipherId;
void attachmentId;
}
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}`);
await this.db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
}
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);
if (!cipher) return;
cipher.updatedAt = new Date().toISOString();
await this.saveCipher(cipher);
await this.updateRevisionDate(cipher.userId);
}
// --- Refresh tokens ---
async saveRefreshToken(token: string, userId: string, expiresAtMs?: number): Promise<void> {
const expiresAt = expiresAtMs ?? (Date.now() + 30 * 24 * 60 * 60 * 1000);
await this.db.prepare(
'INSERT INTO refresh_tokens(token, user_id, expires_at) VALUES(?, ?, ?) ' +
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at'
)
.bind(token, userId, expiresAt)
.run();
}
async getRefreshTokenUserId(token: string): Promise<string | null> {
const now = Date.now();
const row = await this.db.prepare('SELECT user_id, expires_at FROM refresh_tokens WHERE token = ?')
.bind(token)
.first<{ user_id: string; expires_at: number }>();
if (!row) return null;
if (row.expires_at && row.expires_at < now) {
await this.deleteRefreshToken(token);
return null;
}
return row.user_id;
}
async deleteRefreshToken(token: string): Promise<void> {
await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
}
// --- Revision dates ---
async getRevisionDate(userId: string): Promise<string> {
const row = await this.db.prepare('SELECT revision_date FROM user_revisions WHERE user_id = ?')
.bind(userId)
.first<{ revision_date: string }>();
return row?.revision_date || new Date().toISOString();
}
async updateRevisionDate(userId: string): Promise<string> {
const date = new Date().toISOString();
await this.db.prepare(
'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' +
'ON CONFLICT(user_id) DO UPDATE SET revision_date = excluded.revision_date'
)
.bind(userId, date)
.run();
return date;
}
}
+30 -8
View File
@@ -1,10 +1,14 @@
// Environment bindings
export interface Env {
VAULT: KVNamespace;
DB: D1Database;
ATTACHMENTS: R2Bucket;
JWT_SECRET: string;
}
// Sample JWT secret used by `.dev.vars.example`.
// If runtime JWT_SECRET equals this value, treat it as unsafe.
export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters';
// Attachment model
export interface Attachment {
id: string;
@@ -19,7 +23,7 @@ export interface Attachment {
export interface User {
id: string;
email: string;
name: string;
name: string | null;
masterPasswordHash: string;
key: string;
privateKey: string | null;
@@ -115,7 +119,7 @@ export interface Cipher {
userId: string;
type: CipherType;
folderId: string | null;
name: string;
name: string | null;
notes: string | null;
favorite: boolean;
login: CipherLogin | null;
@@ -145,7 +149,7 @@ export interface Folder {
export interface JWTPayload {
sub: string; // user id
email: string;
name: string;
name: string | null;
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
@@ -166,13 +170,16 @@ export interface MasterPasswordUnlockKdf {
export interface MasterPasswordUnlock {
Kdf: MasterPasswordUnlockKdf;
MasterKeyEncryptedUserKey: string;
MasterKeyWrappedUserKey: string;
Salt: string;
Object: string;
}
export interface UserDecryptionOptions {
HasMasterPassword: boolean;
Object: string;
MasterPasswordUnlock?: MasterPasswordUnlock;
// Bitwarden Android 2026.1.x expects this to exist; missing it breaks unlock when the vault is empty.
MasterPasswordUnlock: MasterPasswordUnlock;
}
// API Response types
@@ -196,7 +203,7 @@ export interface TokenResponse {
export interface ProfileResponse {
id: string;
name: string;
name: string | null;
email: string;
emailVerified: boolean;
premium: boolean;
@@ -223,7 +230,7 @@ export interface CipherResponse {
organizationId: string | null;
folderId: string | null;
type: number;
name: string;
name: string | null;
notes: string | null;
favorite: boolean;
login: CipherLogin | null;
@@ -269,6 +276,21 @@ export interface SyncResponse {
domains: any;
policies: any[];
sends: any[];
userDecryption: any | null;
// PascalCase for desktop/browser clients
UserDecryptionOptions: UserDecryptionOptions | null;
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
userDecryption: {
masterPasswordUnlock: {
kdf: {
kdfType: number;
iterations: number;
memory: number | null;
parallelism: number | null;
};
masterKeyWrappedUserKey: string;
masterKeyEncryptedUserKey: string;
salt: string;
} | null;
} | null;
object: string;
}
+6 -5
View File
@@ -1,11 +1,12 @@
name = "nodewarden"
name = "nodewarden-test"
main = "src/index.ts"
compatibility_date = "2024-01-01"
# KV Namespace for storing vault data
[[kv_namespaces]]
binding = "VAULT"
id = "placeholder"
# D1 Database for storing vault data
[[d1_databases]]
binding = "DB"
database_name = "nodewarden-db"
database_id = "placeholde"
# R2 Bucket for storing attachments
[[r2_buckets]]