enhance cipher and identity handling with new fields and rate limit adjustments

This commit is contained in:
shuaiplus
2026-02-07 03:48:08 +08:00
parent 91800f41c5
commit ec9d3b889d
14 changed files with 102 additions and 132 deletions
-36
View File
@@ -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**
-60
View File
@@ -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
+1
View File
@@ -4,6 +4,7 @@ node_modules/
# Wrangler # Wrangler
.wrangler/ .wrangler/
.dev.vars .dev.vars
wrangler.my.toml
RELEASE_NOTES.md RELEASE_NOTES.md
# Build output # Build output
+3 -3
View File
@@ -27,9 +27,9 @@ A **Bitwarden-compatible** server that runs on **Cloudflare Workers**, designed
## Tested clients / platforms ## Tested clients / platforms
- ✅ Windows desktop client - ✅ Windows desktop clientv2026.1.0
-Mobile app (Android / iOS) -Android app v2026.1.0
- ✅ Browser extension - ✅ Browser extensionv2026.1.0
- ⬜ macOS desktop client (not tested) - ⬜ macOS desktop client (not tested)
- ⬜ Linux desktop client (not tested) - ⬜ Linux desktop client (not tested)
+3 -3
View File
@@ -27,9 +27,9 @@ English[`README.md`](./README.md)
- ✅ 兼容常见的 Bitwarden 官方客户端 - ✅ 兼容常见的 Bitwarden 官方客户端
## 测试情况: ## 测试情况:
- ✅ Windows 客户端 - ✅ Windows 客户端v2026.1.0
-手机 AppAndroid / iOS - ✅ Android Appv2026.1.0
- ✅ 浏览器扩展 - ✅ 浏览器扩展v2026.1.0
- ⬜ macOS 客户端(未测试) - ⬜ macOS 客户端(未测试)
- ⬜ Linux 客户端(未测试) - ⬜ Linux 客户端(未测试)
--- ---
+3 -2
View File
@@ -7,8 +7,9 @@
"main": "src/index.ts", "main": "src/index.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "wrangler dev", "dev": "wrangler dev -c wrangler.dev.toml",
"deploy": "wrangler deploy" "deploymy": "wrangler deploy -c wrangler.my.toml",
"deploy": "wrangler deploy "
}, },
"keywords": [ "keywords": [
"bitwarden", "bitwarden",
+1
View File
@@ -97,6 +97,7 @@ export async function handleGetProfile(request: Request, env: Env, userId: strin
twoFactorEnabled: false, twoFactorEnabled: false,
key: user.key, key: user.key,
privateKey: user.privateKey, privateKey: user.privateKey,
accountKeys: null,
securityStamp: user.securityStamp || user.id, securityStamp: user.securityStamp || user.id,
organizations: [], organizations: [],
providers: [], providers: [],
+10 -2
View File
@@ -304,7 +304,7 @@ function formatCipherResponse(cipher: Cipher, attachments: Attachment[]): any {
id: cipher.id, id: cipher.id,
organizationId: null, organizationId: null,
folderId: cipher.folderId, folderId: cipher.folderId,
type: cipher.type, type: Number(cipher.type) || 1,
name: cipher.name, name: cipher.name,
notes: cipher.notes, notes: cipher.notes,
favorite: cipher.favorite, favorite: cipher.favorite,
@@ -312,6 +312,7 @@ function formatCipherResponse(cipher: Cipher, attachments: Attachment[]): any {
card: cipher.card, card: cipher.card,
identity: cipher.identity, identity: cipher.identity,
secureNote: cipher.secureNote, secureNote: cipher.secureNote,
sshKey: cipher.sshKey,
fields: cipher.fields, fields: cipher.fields,
passwordHistory: cipher.passwordHistory, passwordHistory: cipher.passwordHistory,
reprompt: cipher.reprompt, reprompt: cipher.reprompt,
@@ -319,9 +320,13 @@ function formatCipherResponse(cipher: Cipher, attachments: Attachment[]): any {
creationDate: cipher.createdAt, creationDate: cipher.createdAt,
revisionDate: cipher.updatedAt, revisionDate: cipher.updatedAt,
deletedDate: cipher.deletedAt, deletedDate: cipher.deletedAt,
archivedDate: null,
edit: true, edit: true,
viewPassword: true, viewPassword: true,
permissions: null, permissions: {
delete: true,
restore: true,
},
object: 'cipher', object: 'cipher',
collectionIds: [], collectionIds: [],
attachments: attachments.length > 0 ? attachments.map(a => ({ attachments: attachments.length > 0 ? attachments.map(a => ({
@@ -330,8 +335,11 @@ function formatCipherResponse(cipher: Cipher, attachments: Attachment[]): any {
size: String(a.size), size: String(a.size),
sizeName: a.sizeName, sizeName: a.sizeName,
key: a.key, key: a.key,
url: null,
object: 'attachment', object: 'attachment',
})) : null, })) : null,
key: cipher.key,
encryptedFor: null,
}; };
} }
+16 -6
View File
@@ -13,6 +13,7 @@ function formatAttachments(attachments: Attachment[]): any[] | null {
size: String(a.size), size: String(a.size),
sizeName: a.sizeName, sizeName: a.sizeName,
key: a.key, key: a.key,
url: null,
object: 'attachment', object: 'attachment',
})); }));
} }
@@ -23,7 +24,7 @@ function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): Ciphe
id: cipher.id, id: cipher.id,
organizationId: null, organizationId: null,
folderId: cipher.folderId, folderId: cipher.folderId,
type: cipher.type, type: Number(cipher.type) || 1,
name: cipher.name, name: cipher.name,
notes: cipher.notes, notes: cipher.notes,
favorite: cipher.favorite, favorite: cipher.favorite,
@@ -31,6 +32,7 @@ function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): Ciphe
card: cipher.card, card: cipher.card,
identity: cipher.identity, identity: cipher.identity,
secureNote: cipher.secureNote, secureNote: cipher.secureNote,
sshKey: cipher.sshKey,
fields: cipher.fields, fields: cipher.fields,
passwordHistory: cipher.passwordHistory, passwordHistory: cipher.passwordHistory,
reprompt: cipher.reprompt, reprompt: cipher.reprompt,
@@ -38,16 +40,18 @@ function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): Ciphe
creationDate: cipher.createdAt, creationDate: cipher.createdAt,
revisionDate: cipher.updatedAt, revisionDate: cipher.updatedAt,
deletedDate: cipher.deletedAt, deletedDate: cipher.deletedAt,
archivedDate: null,
edit: true, edit: true,
viewPassword: true, viewPassword: true,
permissions: { permissions: {
delete: true, delete: true,
restore: true, restore: true,
edit: true,
}, },
object: 'cipher', object: 'cipher',
collectionIds: [], collectionIds: [],
attachments: formatAttachments(attachments), 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) // 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 now = new Date().toISOString();
const cipher: Cipher = { const cipher: Cipher = {
id: generateUUID(), id: generateUUID(),
userId: userId, userId: userId,
type: cipherData.type, type: Number(cipherData.type) || 1,
folderId: cipherData.folderId || null, folderId: cipherData.folderId || null,
name: cipherData.name, name: cipherData.name,
notes: cipherData.notes || null, notes: cipherData.notes || null,
@@ -118,9 +123,11 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
card: cipherData.card || null, card: cipherData.card || null,
identity: cipherData.identity || null, identity: cipherData.identity || null,
secureNote: cipherData.secureNote || null, secureNote: cipherData.secureNote || null,
sshKey: cipherData.sshKey || null,
fields: cipherData.fields || null, fields: cipherData.fields || null,
passwordHistory: cipherData.passwordHistory || null, passwordHistory: cipherData.passwordHistory || null,
reprompt: cipherData.reprompt || 0, reprompt: cipherData.reprompt || 0,
key: cipherData.key || null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
deletedAt: null, deletedAt: null,
@@ -149,11 +156,12 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
} }
// Handle nested cipher object // 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 = { const cipher: Cipher = {
...existingCipher, ...existingCipher,
type: cipherData.type ?? existingCipher.type, type: Number(cipherData.type) || existingCipher.type,
folderId: cipherData.folderId !== undefined ? cipherData.folderId : existingCipher.folderId, folderId: cipherData.folderId !== undefined ? cipherData.folderId : existingCipher.folderId,
name: cipherData.name ?? existingCipher.name, name: cipherData.name ?? existingCipher.name,
notes: cipherData.notes !== undefined ? cipherData.notes : existingCipher.notes, 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, card: cipherData.card !== undefined ? cipherData.card : existingCipher.card,
identity: cipherData.identity !== undefined ? cipherData.identity : existingCipher.identity, identity: cipherData.identity !== undefined ? cipherData.identity : existingCipher.identity,
secureNote: cipherData.secureNote !== undefined ? cipherData.secureNote : existingCipher.secureNote, secureNote: cipherData.secureNote !== undefined ? cipherData.secureNote : existingCipher.secureNote,
sshKey: cipherData.sshKey !== undefined ? cipherData.sshKey : existingCipher.sshKey,
fields: cipherData.fields !== undefined ? cipherData.fields : existingCipher.fields, fields: cipherData.fields !== undefined ? cipherData.fields : existingCipher.fields,
passwordHistory: cipherData.passwordHistory !== undefined ? cipherData.passwordHistory : existingCipher.passwordHistory, passwordHistory: cipherData.passwordHistory !== undefined ? cipherData.passwordHistory : existingCipher.passwordHistory,
reprompt: cipherData.reprompt ?? existingCipher.reprompt, reprompt: cipherData.reprompt ?? existingCipher.reprompt,
key: cipherData.key !== undefined ? cipherData.key : existingCipher.key,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
+20 -11
View File
@@ -31,22 +31,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return errorResponse('Email and password are required', 400); 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); const user = await storage.getUser(email);
if (!user) { 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); 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); const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash);
if (!valid) { if (!valid) {
// Record failed login attempt // Record failed login attempt
@@ -136,6 +135,16 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
UserDecryptionOptions: { UserDecryptionOptions: {
HasMasterPassword: true, HasMasterPassword: true,
Object: 'userDecryptionOptions', 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(),
},
}, },
}; };
+4
View File
@@ -134,6 +134,8 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
totp: c.login.totp || null, totp: c.login.totp || null,
autofillOnPageLoad: null, autofillOnPageLoad: null,
fido2Credentials: null, fido2Credentials: null,
uri: null,
passwordRevisionDate: null,
} : null, } : null,
card: c.card ? { card: c.card ? {
cardholderName: c.card.cardholderName || null, cardholderName: c.card.cardholderName || null,
@@ -172,6 +174,8 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
})) || null, })) || null,
passwordHistory: c.passwordHistory || null, passwordHistory: c.passwordHistory || null,
reprompt: c.reprompt || 0, reprompt: c.reprompt || 0,
sshKey: null,
key: null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
deletedAt: null, deletedAt: null,
+19 -2
View File
@@ -11,6 +11,7 @@ function formatAttachments(attachments: Attachment[]): any[] | null {
size: String(a.size), size: String(a.size),
sizeName: a.sizeName, sizeName: a.sizeName,
key: a.key, key: a.key,
url: null,
object: 'attachment', object: 'attachment',
})); }));
} }
@@ -41,6 +42,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
twoFactorEnabled: false, twoFactorEnabled: false,
key: user.key, key: user.key,
privateKey: user.privateKey, privateKey: user.privateKey,
accountKeys: null,
securityStamp: user.securityStamp || user.id, securityStamp: user.securityStamp || user.id,
organizations: [], organizations: [],
providers: [], providers: [],
@@ -59,7 +61,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
id: cipher.id, id: cipher.id,
organizationId: null, organizationId: null,
folderId: cipher.folderId, folderId: cipher.folderId,
type: cipher.type, type: Number(cipher.type) || 1,
name: cipher.name, name: cipher.name,
notes: cipher.notes, notes: cipher.notes,
favorite: cipher.favorite, favorite: cipher.favorite,
@@ -67,6 +69,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
card: cipher.card, card: cipher.card,
identity: cipher.identity, identity: cipher.identity,
secureNote: cipher.secureNote, secureNote: cipher.secureNote,
sshKey: cipher.sshKey,
fields: cipher.fields, fields: cipher.fields,
passwordHistory: cipher.passwordHistory, passwordHistory: cipher.passwordHistory,
reprompt: cipher.reprompt, reprompt: cipher.reprompt,
@@ -74,16 +77,18 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
creationDate: cipher.createdAt, creationDate: cipher.createdAt,
revisionDate: cipher.updatedAt, revisionDate: cipher.updatedAt,
deletedDate: cipher.deletedAt, deletedDate: cipher.deletedAt,
archivedDate: null,
edit: true, edit: true,
viewPassword: true, viewPassword: true,
permissions: { permissions: {
delete: true, delete: true,
restore: true, restore: true,
edit: true,
}, },
object: 'cipher', object: 'cipher',
collectionIds: [], collectionIds: [],
attachments: formatAttachments(attachments), attachments: formatAttachments(attachments),
key: cipher.key,
encryptedFor: null,
}); });
}; };
@@ -107,6 +112,18 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
}, },
policies: [], policies: [],
sends: [], 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', object: 'sync',
}; };
+3 -3
View File
@@ -3,11 +3,11 @@ import { Env } from '../types';
// Rate limit configuration // Rate limit configuration
const CONFIG = { const CONFIG = {
// Login attempt limits // Login attempt limits
LOGIN_MAX_ATTEMPTS: 5, // Max failed login attempts LOGIN_MAX_ATTEMPTS: 15, // Max failed login attempts
LOGIN_LOCKOUT_MINUTES: 15, // Lockout duration after max attempts LOGIN_LOCKOUT_MINUTES: 5, // Lockout duration after max attempts
// API rate limits (per minute) // 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 API_WINDOW_SECONDS: 60, // Rate limit window
}; };
+19 -4
View File
@@ -54,6 +54,8 @@ export interface CipherLogin {
totp: string | null; totp: string | null;
autofillOnPageLoad: boolean | null; autofillOnPageLoad: boolean | null;
fido2Credentials: any[] | null; fido2Credentials: any[] | null;
uri: string | null;
passwordRevisionDate: string | null;
} }
export interface CipherCard { export interface CipherCard {
@@ -65,6 +67,12 @@ export interface CipherCard {
code: string | null; code: string | null;
} }
export interface CipherSshKey {
publicKey: string;
privateKey: string;
keyFingerprint: string;
}
export interface CipherIdentity { export interface CipherIdentity {
title: string | null; title: string | null;
firstName: string | null; firstName: string | null;
@@ -114,9 +122,11 @@ export interface Cipher {
card: CipherCard | null; card: CipherCard | null;
identity: CipherIdentity | null; identity: CipherIdentity | null;
secureNote: CipherSecureNote | null; secureNote: CipherSecureNote | null;
sshKey: CipherSshKey | null;
fields: CipherField[] | null; fields: CipherField[] | null;
passwordHistory: PasswordHistory[] | null; passwordHistory: PasswordHistory[] | null;
reprompt: number; reprompt: number;
key: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string | null; deletedAt: string | null;
@@ -190,20 +200,21 @@ export interface ProfileResponse {
email: string; email: string;
emailVerified: boolean; emailVerified: boolean;
premium: boolean; premium: boolean;
premiumFromOrganization: boolean; // required by mobile client premiumFromOrganization: boolean;
usesKeyConnector: boolean; // required by mobile client usesKeyConnector: boolean;
masterPasswordHint: string | null; masterPasswordHint: string | null;
culture: string; culture: string;
twoFactorEnabled: boolean; twoFactorEnabled: boolean;
key: string; key: string;
privateKey: string | null; privateKey: string | null;
accountKeys: any | null;
securityStamp: string; securityStamp: string;
organizations: any[]; organizations: any[];
providers: any[]; providers: any[];
providerOrganizations: any[]; providerOrganizations: any[];
forcePasswordReset: boolean; forcePasswordReset: boolean;
avatarColor: string | null; avatarColor: string | null;
creationDate: string; // required by mobile client creationDate: string;
object: string; object: string;
} }
@@ -219,6 +230,7 @@ export interface CipherResponse {
card: CipherCard | null; card: CipherCard | null;
identity: CipherIdentity | null; identity: CipherIdentity | null;
secureNote: CipherSecureNote | null; secureNote: CipherSecureNote | null;
sshKey: CipherSshKey | null;
fields: CipherField[] | null; fields: CipherField[] | null;
passwordHistory: PasswordHistory[] | null; passwordHistory: PasswordHistory[] | null;
reprompt: number; reprompt: number;
@@ -226,18 +238,20 @@ export interface CipherResponse {
creationDate: string; creationDate: string;
revisionDate: string; revisionDate: string;
deletedDate: string | null; deletedDate: string | null;
archivedDate: string | null;
edit: boolean; edit: boolean;
viewPassword: boolean; viewPassword: boolean;
permissions: CipherPermissions | null; permissions: CipherPermissions | null;
object: string; object: string;
collectionIds: string[]; collectionIds: string[];
attachments: any[] | null; attachments: any[] | null;
key: string | null;
encryptedFor: string | null;
} }
export interface CipherPermissions { export interface CipherPermissions {
delete: boolean; delete: boolean;
restore: boolean; restore: boolean;
edit: boolean;
} }
export interface FolderResponse { export interface FolderResponse {
@@ -255,5 +269,6 @@ export interface SyncResponse {
domains: any; domains: any;
policies: any[]; policies: any[];
sends: any[]; sends: any[];
userDecryption: any | null;
object: string; object: string;
} }