From ec9d3b889d88251ca8e169362578457bcf967d78 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sat, 7 Feb 2026 03:48:08 +0800 Subject: [PATCH] enhance cipher and identity handling with new fields and rate limit adjustments --- .github/workflows/README.md | 36 ----------------- .github/workflows/sync-upstream.yml | 60 ----------------------------- .gitignore | 1 + README.md | 6 +-- README_ZH.md | 6 +-- package.json | 5 ++- src/handlers/accounts.ts | 1 + src/handlers/attachments.ts | 12 +++++- src/handlers/ciphers.ts | 22 ++++++++--- src/handlers/identity.ts | 31 +++++++++------ src/handlers/import.ts | 4 ++ src/handlers/sync.ts | 21 +++++++++- src/services/ratelimit.ts | 6 +-- src/types/index.ts | 23 +++++++++-- 14 files changed, 102 insertions(+), 132 deletions(-) delete mode 100644 .github/workflows/README.md delete mode 100644 .github/workflows/sync-upstream.yml diff --git a/.github/workflows/README.md b/.github/workflows/README.md deleted file mode 100644 index aeede2f..0000000 --- a/.github/workflows/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# 自动同步上游更新 - -这个 GitHub Actions 工作流会自动将上游仓库(shuaiplus/nodewarden)的更新同步到你的 fork。 - -## 功能特性 - -- ✅ 每天自动检查并同步上游更新 -- ✅ 支持手动触发同步 -- ✅ 自动处理简单的合并 -- ⚠️ 遇到冲突时会提醒你手动处理 - -## 如何启用 - -1. 在你 fork 的仓库中,进入 **Actions** 标签页 -2. 点击 **I understand my workflows, go ahead and enable them** -3. 完成!工作流会自动运行 - -## 手动触发 - -1. 进入 **Actions** 标签页 -2. 选择 **Sync Fork with Upstream** -3. 点击 **Run workflow** → **Run workflow** - -## 注意事项 - -- 如果你修改了代码,可能会产生合并冲突 -- 遇到冲突时,工作流会失败,需要你手动解决 -- 建议不要修改核心代码,只修改配置文件 - -## 禁用自动同步 - -如果你不想自动同步: - -1. 进入 **Actions** 标签页 -2. 选择 **Sync Fork with Upstream** -3. 点击右上角的 **···** → **Disable workflow** diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml deleted file mode 100644 index 2c11779..0000000 --- a/.github/workflows/sync-upstream.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Sync Fork with Upstream - -on: - schedule: - # Run every day at 2:00 AM UTC - - cron: '0 2 * * *' - workflow_dispatch: - # Allow manual trigger - -jobs: - sync: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - fetch-depth: 0 - - - name: Configure Git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Sync with upstream - run: | - # Add upstream repository - git remote add upstream https://github.com/shuaiplus/nodewarden.git || true - - # Fetch upstream changes - git fetch upstream - - # Check if there are updates - BEHIND=$(git rev-list --count HEAD..upstream/main) - - if [ "$BEHIND" -gt 0 ]; then - echo "Found $BEHIND commits behind upstream. Syncing..." - - # Try to merge upstream/main into current branch - if git merge upstream/main --no-edit; then - echo "Merge successful!" - git push origin main - else - echo "Merge conflict detected. Please resolve manually." - echo "::warning::Failed to auto-sync due to merge conflicts. Manual intervention required." - exit 1 - fi - else - echo "Already up to date with upstream." - fi - - - name: Create summary - if: always() - run: | - echo "## Sync Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "- **Upstream**: shuaiplus/nodewarden" >> $GITHUB_STEP_SUMMARY - echo "- **Branch**: main" >> $GITHUB_STEP_SUMMARY - echo "- **Status**: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 2345986..7924ee7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ # Wrangler .wrangler/ .dev.vars +wrangler.my.toml RELEASE_NOTES.md # Build output diff --git a/README.md b/README.md index f06fad0..699be63 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,9 @@ A **Bitwarden-compatible** server that runs on **Cloudflare Workers**, designed ## Tested clients / platforms -- ✅ Windows desktop client -- ✅ Mobile app (Android / iOS) -- ✅ Browser extension +- ✅ 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) diff --git a/README_ZH.md b/README_ZH.md index 894bf47..b5bed9a 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -27,9 +27,9 @@ English:[`README.md`](./README.md) - ✅ 兼容常见的 Bitwarden 官方客户端 ## 测试情况: -- ✅ Windows 客户端 -- ✅ 手机 App(Android / iOS) -- ✅ 浏览器扩展 +- ✅ Windows 客户端(v2026.1.0) +- ✅ Android App(v2026.1.0) +- ✅ 浏览器扩展(v2026.1.0) - ⬜ macOS 客户端(未测试) - ⬜ Linux 客户端(未测试) --- diff --git a/package.json b/package.json index ee9195d..e3e9c02 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,9 @@ "main": "src/index.ts", "type": "module", "scripts": { - "dev": "wrangler dev", - "deploy": "wrangler deploy" + "dev": "wrangler dev -c wrangler.dev.toml", + "deploymy": "wrangler deploy -c wrangler.my.toml", + "deploy": "wrangler deploy " }, "keywords": [ "bitwarden", diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index c448d4f..0958b25 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -97,6 +97,7 @@ export async function handleGetProfile(request: Request, env: Env, userId: strin twoFactorEnabled: false, key: user.key, privateKey: user.privateKey, + accountKeys: null, securityStamp: user.securityStamp || user.id, organizations: [], providers: [], diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index 980a95e..f13942e 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -304,7 +304,7 @@ function formatCipherResponse(cipher: Cipher, attachments: Attachment[]): any { id: cipher.id, organizationId: null, folderId: cipher.folderId, - type: cipher.type, + type: Number(cipher.type) || 1, name: cipher.name, notes: cipher.notes, favorite: cipher.favorite, @@ -312,6 +312,7 @@ function formatCipherResponse(cipher: Cipher, attachments: Attachment[]): any { card: cipher.card, identity: cipher.identity, secureNote: cipher.secureNote, + sshKey: cipher.sshKey, fields: cipher.fields, passwordHistory: cipher.passwordHistory, reprompt: cipher.reprompt, @@ -319,9 +320,13 @@ function formatCipherResponse(cipher: Cipher, attachments: Attachment[]): any { creationDate: cipher.createdAt, revisionDate: cipher.updatedAt, deletedDate: cipher.deletedAt, + archivedDate: null, edit: true, viewPassword: true, - permissions: null, + permissions: { + delete: true, + restore: true, + }, object: 'cipher', collectionIds: [], attachments: attachments.length > 0 ? attachments.map(a => ({ @@ -330,8 +335,11 @@ function formatCipherResponse(cipher: Cipher, attachments: Attachment[]): any { size: String(a.size), sizeName: a.sizeName, key: a.key, + url: null, object: 'attachment', })) : null, + key: cipher.key, + encryptedFor: null, }; } diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index b0edbd3..aaf3c92 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -13,6 +13,7 @@ function formatAttachments(attachments: Attachment[]): any[] | null { size: String(a.size), sizeName: a.sizeName, key: a.key, + url: null, object: 'attachment', })); } @@ -23,7 +24,7 @@ function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): Ciphe id: cipher.id, organizationId: null, folderId: cipher.folderId, - type: cipher.type, + type: Number(cipher.type) || 1, name: cipher.name, notes: cipher.notes, favorite: cipher.favorite, @@ -31,6 +32,7 @@ function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): Ciphe card: cipher.card, identity: cipher.identity, secureNote: cipher.secureNote, + sshKey: cipher.sshKey, fields: cipher.fields, passwordHistory: cipher.passwordHistory, reprompt: cipher.reprompt, @@ -38,16 +40,18 @@ function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): Ciphe creationDate: cipher.createdAt, revisionDate: cipher.updatedAt, deletedDate: cipher.deletedAt, + archivedDate: null, edit: true, viewPassword: true, permissions: { delete: true, restore: true, - edit: true, }, object: 'cipher', collectionIds: [], attachments: formatAttachments(attachments), + key: cipher.key, + encryptedFor: null, }; } @@ -103,13 +107,14 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str } // Handle nested cipher object (from some clients) - const cipherData = body.cipher || body; + // Android client sends PascalCase "Cipher" for organization ciphers + const cipherData = body.Cipher || body.cipher || body; const now = new Date().toISOString(); const cipher: Cipher = { id: generateUUID(), userId: userId, - type: cipherData.type, + type: Number(cipherData.type) || 1, folderId: cipherData.folderId || null, name: cipherData.name, notes: cipherData.notes || null, @@ -118,9 +123,11 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str card: cipherData.card || null, identity: cipherData.identity || null, secureNote: cipherData.secureNote || null, + sshKey: cipherData.sshKey || null, fields: cipherData.fields || null, passwordHistory: cipherData.passwordHistory || null, reprompt: cipherData.reprompt || 0, + key: cipherData.key || null, createdAt: now, updatedAt: now, deletedAt: null, @@ -149,11 +156,12 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str } // Handle nested cipher object - const cipherData = body.cipher || body; + // Android client sends PascalCase "Cipher" for organization ciphers + const cipherData = body.Cipher || body.cipher || body; const cipher: Cipher = { ...existingCipher, - type: cipherData.type ?? existingCipher.type, + type: Number(cipherData.type) || existingCipher.type, folderId: cipherData.folderId !== undefined ? cipherData.folderId : existingCipher.folderId, name: cipherData.name ?? existingCipher.name, notes: cipherData.notes !== undefined ? cipherData.notes : existingCipher.notes, @@ -162,9 +170,11 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str card: cipherData.card !== undefined ? cipherData.card : existingCipher.card, identity: cipherData.identity !== undefined ? cipherData.identity : existingCipher.identity, secureNote: cipherData.secureNote !== undefined ? cipherData.secureNote : existingCipher.secureNote, + sshKey: cipherData.sshKey !== undefined ? cipherData.sshKey : existingCipher.sshKey, fields: cipherData.fields !== undefined ? cipherData.fields : existingCipher.fields, passwordHistory: cipherData.passwordHistory !== undefined ? cipherData.passwordHistory : existingCipher.passwordHistory, reprompt: cipherData.reprompt ?? existingCipher.reprompt, + key: cipherData.key !== undefined ? cipherData.key : existingCipher.key, updatedAt: new Date().toISOString(), }; diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index aad5648..3ea16a0 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -31,22 +31,21 @@ export async function handleToken(request: Request, env: Env): Promise return errorResponse('Email and password are required', 400); } - // Check if login is rate limited - const loginCheck = await rateLimit.checkLoginAttempt(email); - if (!loginCheck.allowed) { - return errorResponse( - `Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`, - 429 - ); - } - const user = await storage.getUser(email); if (!user) { - // Record failed attempt even for non-existent user (prevent enumeration) - await rateLimit.recordFailedLogin(email); return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400); } + // Check if login is rate limited (only after confirming user exists) + const loginCheck = await rateLimit.checkLoginAttempt(email); + if (!loginCheck.allowed) { + return identityErrorResponse( + `Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`, + 'TooManyRequests', + 429 + ); + } + const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash); if (!valid) { // Record failed login attempt @@ -136,6 +135,16 @@ export async function handleToken(request: Request, env: Env): Promise UserDecryptionOptions: { HasMasterPassword: true, Object: 'userDecryptionOptions', + MasterPasswordUnlock: { + Kdf: { + KdfType: user.kdfType, + Iterations: user.kdfIterations, + Memory: user.kdfMemory || null, + Parallelism: user.kdfParallelism || null, + }, + MasterKeyEncryptedUserKey: user.key, + Salt: user.email.toLowerCase(), + }, }, }; diff --git a/src/handlers/import.ts b/src/handlers/import.ts index f790517..f0f224b 100644 --- a/src/handlers/import.ts +++ b/src/handlers/import.ts @@ -134,6 +134,8 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st totp: c.login.totp || null, autofillOnPageLoad: null, fido2Credentials: null, + uri: null, + passwordRevisionDate: null, } : null, card: c.card ? { cardholderName: c.card.cardholderName || null, @@ -172,6 +174,8 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st })) || null, passwordHistory: c.passwordHistory || null, reprompt: c.reprompt || 0, + sshKey: null, + key: null, createdAt: now, updatedAt: now, deletedAt: null, diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index e0a7e24..825c7ea 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -11,6 +11,7 @@ function formatAttachments(attachments: Attachment[]): any[] | null { size: String(a.size), sizeName: a.sizeName, key: a.key, + url: null, object: 'attachment', })); } @@ -41,6 +42,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr twoFactorEnabled: false, key: user.key, privateKey: user.privateKey, + accountKeys: null, securityStamp: user.securityStamp || user.id, organizations: [], providers: [], @@ -59,7 +61,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr id: cipher.id, organizationId: null, folderId: cipher.folderId, - type: cipher.type, + type: Number(cipher.type) || 1, name: cipher.name, notes: cipher.notes, favorite: cipher.favorite, @@ -67,6 +69,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr card: cipher.card, identity: cipher.identity, secureNote: cipher.secureNote, + sshKey: cipher.sshKey, fields: cipher.fields, passwordHistory: cipher.passwordHistory, reprompt: cipher.reprompt, @@ -74,16 +77,18 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr creationDate: cipher.createdAt, revisionDate: cipher.updatedAt, deletedDate: cipher.deletedAt, + archivedDate: null, edit: true, viewPassword: true, permissions: { delete: true, restore: true, - edit: true, }, object: 'cipher', collectionIds: [], attachments: formatAttachments(attachments), + key: cipher.key, + encryptedFor: null, }); }; @@ -107,6 +112,18 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr }, policies: [], sends: [], + userDecryption: { + masterPasswordUnlock: { + salt: user.email, + kdf: { + kdfType: user.kdfType, + iterations: user.kdfIterations, + memory: user.kdfMemory || null, + parallelism: user.kdfParallelism || null, + }, + masterKeyEncryptedUserKey: user.key, + }, + }, object: 'sync', }; diff --git a/src/services/ratelimit.ts b/src/services/ratelimit.ts index 54d769c..7d12252 100644 --- a/src/services/ratelimit.ts +++ b/src/services/ratelimit.ts @@ -3,11 +3,11 @@ import { Env } from '../types'; // Rate limit configuration const CONFIG = { // Login attempt limits - LOGIN_MAX_ATTEMPTS: 5, // Max failed login attempts - LOGIN_LOCKOUT_MINUTES: 15, // Lockout duration after max attempts + 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: 60, // General API rate limit + API_REQUESTS_PER_MINUTE: 300, // General API rate limit API_WINDOW_SECONDS: 60, // Rate limit window }; diff --git a/src/types/index.ts b/src/types/index.ts index 9f7747c..5dad461 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -54,6 +54,8 @@ export interface CipherLogin { totp: string | null; autofillOnPageLoad: boolean | null; fido2Credentials: any[] | null; + uri: string | null; + passwordRevisionDate: string | null; } export interface CipherCard { @@ -65,6 +67,12 @@ export interface CipherCard { code: string | null; } +export interface CipherSshKey { + publicKey: string; + privateKey: string; + keyFingerprint: string; +} + export interface CipherIdentity { title: string | null; firstName: string | null; @@ -114,9 +122,11 @@ export interface Cipher { card: CipherCard | null; identity: CipherIdentity | null; secureNote: CipherSecureNote | null; + sshKey: CipherSshKey | null; fields: CipherField[] | null; passwordHistory: PasswordHistory[] | null; reprompt: number; + key: string | null; createdAt: string; updatedAt: string; deletedAt: string | null; @@ -190,20 +200,21 @@ export interface ProfileResponse { email: string; emailVerified: boolean; premium: boolean; - premiumFromOrganization: boolean; // required by mobile client - usesKeyConnector: boolean; // required by mobile client + premiumFromOrganization: boolean; + usesKeyConnector: boolean; masterPasswordHint: string | null; culture: string; twoFactorEnabled: boolean; key: string; privateKey: string | null; + accountKeys: any | null; securityStamp: string; organizations: any[]; providers: any[]; providerOrganizations: any[]; forcePasswordReset: boolean; avatarColor: string | null; - creationDate: string; // required by mobile client + creationDate: string; object: string; } @@ -219,6 +230,7 @@ export interface CipherResponse { card: CipherCard | null; identity: CipherIdentity | null; secureNote: CipherSecureNote | null; + sshKey: CipherSshKey | null; fields: CipherField[] | null; passwordHistory: PasswordHistory[] | null; reprompt: number; @@ -226,18 +238,20 @@ export interface CipherResponse { creationDate: string; revisionDate: string; deletedDate: string | null; + archivedDate: string | null; edit: boolean; viewPassword: boolean; permissions: CipherPermissions | null; object: string; collectionIds: string[]; attachments: any[] | null; + key: string | null; + encryptedFor: string | null; } export interface CipherPermissions { delete: boolean; restore: boolean; - edit: boolean; } export interface FolderResponse { @@ -255,5 +269,6 @@ export interface SyncResponse { domains: any; policies: any[]; sends: any[]; + userDecryption: any | null; object: string; }