diff --git a/README.md b/README.md index c586c93..152fc75 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,9 @@ A **Bitwarden-compatible** server that runs on **Cloudflare Workers**. ## Tested clients / platforms -- ✅ Windows desktop client(v2026.1.0) -- ✅ Android app (v2026.1.0) -- ✅ Browser extension(v2026.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) @@ -39,14 +39,14 @@ A **Bitwarden-compatible** server that runs on **Cloudflare Workers**. ### One-click deploy -[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden/tree/d1-test) +[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden) **Deploy steps:** 1. Sign in with GitHub and authorize 2. Sign in to Cloudflare 3. **Important**: set `JWT_SECRET` to a strong random string (recommended: `openssl rand -hex 32`) -4. 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 diff --git a/README_ZH.md b/README_ZH.md index f342ff2..2a668f0 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -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 D1(SQLite) - **文件存储**:Cloudflare R2 - **开发语言**:TypeScript - **加密算法**:客户端 AES-256-CBC,JWT 使用 HS256 diff --git a/package.json b/package.json index f96acb0..752e2b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", @@ -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" } } }, diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index 294da67..52233d9 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -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 { @@ -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), }); } @@ -295,55 +296,10 @@ 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, diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index 2f04b00..6fecde2 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -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, diff --git a/src/handlers/folders.ts b/src/handlers/folders.ts index e02a24a..67ae181 100644 --- a/src/handlers/folders.ts +++ b/src/handlers/folders.ts @@ -62,6 +62,7 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str }; await storage.saveFolder(folder); + await storage.updateRevisionDate(userId); return jsonResponse(folderToResponse(folder), 200); } @@ -88,6 +89,7 @@ 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)); } @@ -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 }); } diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index a6bf595..d9afeed 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -28,8 +28,8 @@ export async function handleToken(request: Request, env: Env): Promise const passwordHash = body.password; if (!email || !passwordHash) { - // Bitwarden clients expect OAuth-style error fields. - return identityErrorResponse('Email and password are required', 'invalid_request', 400); + // Bitwarden clients expect OAuth-style error fields. + return identityErrorResponse('Email and password are required', 'invalid_request', 400); } const user = await storage.getUser(email); @@ -93,7 +93,9 @@ export async function handleToken(request: Request, env: Env): Promise Parallelism: user.kdfParallelism || null, }, MasterKeyEncryptedUserKey: user.key, + MasterKeyWrappedUserKey: user.key, Salt: email, // email is already lowercased above + Object: 'masterPasswordUnlock', }, }, }; @@ -144,7 +146,9 @@ export async function handleToken(request: Request, env: Env): Promise Parallelism: user.kdfParallelism || null, }, MasterKeyEncryptedUserKey: user.key, + MasterKeyWrappedUserKey: user.key, Salt: user.email.toLowerCase(), + Object: 'masterPasswordUnlock', }, }, }; diff --git a/src/handlers/setupRegisterPage.ts b/src/handlers/setupRegisterPage.ts index 5c985da..13a35fd 100644 --- a/src/handlers/setupRegisterPage.ts +++ b/src/handlers/setupRegisterPage.ts @@ -192,16 +192,16 @@ const registerPageHTML = `
NW

NodeWarden

-

部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端。

+

Minimal Bitwarden-compatible server on Cloudflare Workers.

- 创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。 + Create your first account to finish setup. Then use any official Bitwarden client to sign in.
-

初始化

+

Setup

diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index d6a8f8f..e154049 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -1,20 +1,7 @@ -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 { @@ -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,6 +67,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr }, policies: [], sends: [], + // PascalCase for desktop/browser clients UserDecryptionOptions: { HasMasterPassword: true, Object: 'userDecryptionOptions', @@ -123,7 +79,23 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr 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: { + 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', diff --git a/src/index.ts b/src/index.ts index 01e417e..322a1c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,9 @@ import { Env } from './types'; import { handleRequest } from './router'; import { StorageService } from './services/storage'; -// Global flag to track if database has been initialized in this worker instance +// 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 { diff --git a/src/router.ts b/src/router.ts index 59d20e4..a67411c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -200,7 +200,7 @@ export async function handleRequest(request: Request, env: Env): Promise { // Check if database is already initialized by looking for the config table @@ -245,8 +248,8 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); // --- Ciphers --- async getCipher(id: string): Promise { - 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; + 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 { @@ -282,7 +285,7 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); } async getAllCiphers(userId: string): Promise { - const res = await this.db.prepare('SELECT data FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC').bind(userId).all<{ data: string }>(); + 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); } @@ -416,10 +419,10 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); } async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise { - // 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; + // 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 { diff --git a/src/types/index.ts b/src/types/index.ts index 688241d..9a7ee4b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -149,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 @@ -170,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 @@ -273,6 +276,21 @@ export interface SyncResponse { domains: any; policies: any[]; sends: any[]; + // 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; } diff --git a/src/utils/jwtSecretConfig.ts b/src/utils/jwtSecretConfig.ts deleted file mode 100644 index 8fb3f85..0000000 --- a/src/utils/jwtSecretConfig.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Shared config related to JWT secret bootstrapping / safety checks. -// Keep this in one place so handlers don't duplicate the sample value. - -// IMPORTANT: -// This is a *sample* secret value used in `.dev.vars.example`. -// If the runtime JWT_SECRET equals this value, we treat it as unsafe. -export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters'; diff --git a/wrangler.toml b/wrangler.toml index fd95748..a40be04 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -2,7 +2,6 @@ name = "nodewarden-test" main = "src/index.ts" compatibility_date = "2024-01-01" -# KV Namespace for storing vault data # D1 Database for storing vault data [[d1_databases]] binding = "DB"