From fff2b149e9a4e3d5ea2545d805b82a6b0ae8d002 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Tue, 17 Feb 2026 22:20:01 +0800 Subject: [PATCH] fix: enhance cipher handling to support unknown fields and improve database binding --- src/handlers/ciphers.ts | 71 +++++++----------- src/router.ts | 20 ++++- src/services/storage.ts | 158 ++++++++++++++++++++-------------------- src/types/index.ts | 4 + 4 files changed, 131 insertions(+), 122 deletions(-) diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index 79fce4f..d38f50e 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -18,28 +18,24 @@ export function formatAttachments(attachments: Attachment[]): any[] | null { })); } -// Convert internal cipher to API response format +// Convert internal cipher to API response format. +// Uses opaque passthrough: spreads ALL stored fields (including unknown/future ones), +// then overlays server-computed fields. This ensures new Bitwarden client fields +// survive a round-trip without code changes. export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse { + // Strip internal-only fields that must not appear in the API response + const { userId, createdAt, updatedAt, deletedAt, ...passthrough } = cipher; + return { - id: cipher.id, - organizationId: null, - folderId: cipher.folderId, + // Pass through ALL stored cipher fields (known + unknown) + ...passthrough, + // Server-computed / enforced fields (always override) 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, + organizationId: null, organizationUseTotp: false, - creationDate: cipher.createdAt, - revisionDate: cipher.updatedAt, - deletedDate: cipher.deletedAt, + creationDate: createdAt, + revisionDate: updatedAt, + deletedDate: deletedAt, archivedDate: null, edit: true, viewPassword: true, @@ -50,7 +46,6 @@ export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []) object: 'cipher', collectionIds: [], attachments: formatAttachments(attachments), - key: cipher.key, encryptedFor: null, }; } @@ -113,23 +108,16 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str const cipherData = body.Cipher || body.cipher || body; const now = new Date().toISOString(); + // Opaque passthrough: spread ALL client fields to preserve unknown/future ones, + // then override only server-controlled fields. const cipher: Cipher = { + ...cipherData, + // Server-controlled fields (always override client values) id: generateUUID(), userId: userId, type: Number(cipherData.type) || 1, - folderId: cipherData.folderId || null, - name: cipherData.name || null, - notes: cipherData.notes || null, - favorite: cipherData.favorite || false, - login: cipherData.login || null, - card: cipherData.card || null, - identity: cipherData.identity || null, - secureNote: cipherData.secureNote || null, - sshKey: cipherData.sshKey || null, - fields: cipherData.fields || null, - passwordHistory: cipherData.passwordHistory || null, + favorite: !!cipherData.favorite, reprompt: cipherData.reprompt || 0, - key: cipherData.key || null, createdAt: now, updatedAt: now, deletedAt: null, @@ -161,23 +149,20 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str // Android client sends PascalCase "Cipher" for organization ciphers const cipherData = body.Cipher || body.cipher || body; + // Opaque passthrough: merge existing stored data with ALL incoming client fields. + // Unknown/future fields from the client are preserved; server-controlled fields are protected. const cipher: Cipher = { - ...existingCipher, + ...existingCipher, // start with all existing stored data (including unknowns) + ...cipherData, // overlay all client data (including new/unknown fields) + // Server-controlled fields (never from client) + id: existingCipher.id, + userId: existingCipher.userId, 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, favorite: cipherData.favorite ?? existingCipher.favorite, - login: cipherData.login !== undefined ? cipherData.login : existingCipher.login, - card: cipherData.card !== undefined ? cipherData.card : existingCipher.card, - identity: cipherData.identity !== undefined ? cipherData.identity : existingCipher.identity, - secureNote: cipherData.secureNote !== undefined ? cipherData.secureNote : existingCipher.secureNote, - 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, + createdAt: existingCipher.createdAt, updatedAt: new Date().toISOString(), + deletedAt: existingCipher.deletedAt, }; await storage.saveCipher(cipher); diff --git a/src/router.ts b/src/router.ts index b3fe3b9..659aa02 100644 --- a/src/router.ts +++ b/src/router.ts @@ -180,6 +180,18 @@ export async function handleRequest(request: Request, env: Env): Promise= 2024.2.0 + // (clients/libs/common/src/vault/services/cipher.service.ts: CIPHER_KEY_ENC_MIN_SERVER_VER) + // (android/.../FeatureFlagManagerImpl.kt: CIPHER_KEY_ENC_MIN_SERVER_VERSION) + // - MasterPasswordUnlockData (mobile): >= 2025.8.0 + // (documented in Vaultwarden source comments) + // There is NO global minimum version that blocks all client functionality. + // Keep this aligned with Vaultwarden's reported version to maintain compatibility. + // When Vaultwarden bumps their version, update this value accordingly. + // Vaultwarden source: src/api/core/mod.rs → fn config() version: '2025.12.0', gitHash: 'nodewarden', server: null, @@ -190,8 +202,14 @@ export async function handleRequest(request: Request, env: Env): Promise v === undefined ? null : v)); + } + private async sha256Hex(input: string): Promise { const bytes = new TextEncoder().encode(input); const digest = await crypto.subtle.digest('SHA-256', bytes); @@ -229,58 +239,54 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); async saveUser(user: User): Promise { 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(); + const stmt = 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' + ); + await this.safeBind(stmt, + user.id, + email, + user.name, + user.masterPasswordHash, + user.key, + user.privateKey, + user.publicKey, + user.kdfType, + user.kdfIterations, + user.kdfMemory, + user.kdfParallelism, + user.securityStamp, + user.createdAt, + user.updatedAt + ).run(); } async createFirstUser(user: User): Promise { const email = user.email.toLowerCase(); - const result = 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) ' + - 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + - 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' - ) - .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(); + const stmt = 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) ' + + 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + + 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' + ); + const result = await this.safeBind(stmt, + user.id, + email, + user.name, + user.masterPasswordHash, + user.key, + user.privateKey, + user.publicKey, + user.kdfType, + user.kdfIterations, + user.kdfMemory, + user.kdfParallelism, + user.securityStamp, + user.createdAt, + user.updatedAt + ).run(); return (result.meta.changes ?? 0) > 0; } @@ -294,29 +300,27 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); async saveCipher(cipher: Cipher): Promise { 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(); + const stmt = 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' + ); + await this.safeBind(stmt, + 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 { @@ -424,13 +428,11 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); } async saveAttachment(attachment: Attachment): Promise { - 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(); + const stmt = 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' + ); + await this.safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run(); } async deleteAttachment(id: string): Promise { diff --git a/src/types/index.ts b/src/types/index.ts index 9a7ee4b..1e5026d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -134,6 +134,8 @@ export interface Cipher { createdAt: string; updatedAt: string; deletedAt: string | null; + /** Allow unknown fields from Bitwarden clients to be stored and passed through transparently. */ + [key: string]: any; } // Folder model @@ -254,6 +256,8 @@ export interface CipherResponse { attachments: any[] | null; key: string | null; encryptedFor: string | null; + /** Allow unknown fields to pass through to clients transparently. */ + [key: string]: any; } export interface CipherPermissions {