fix: enhance cipher handling to support unknown fields and improve database binding

This commit is contained in:
shuaiplus
2026-02-17 22:20:01 +08:00
parent 50ee2e6b64
commit fff2b149e9
4 changed files with 131 additions and 122 deletions
+28 -43
View File
@@ -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 { 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 { return {
id: cipher.id, // Pass through ALL stored cipher fields (known + unknown)
organizationId: null, ...passthrough,
folderId: cipher.folderId, // Server-computed / enforced fields (always override)
type: Number(cipher.type) || 1, type: Number(cipher.type) || 1,
name: cipher.name, organizationId: null,
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, organizationUseTotp: false,
creationDate: cipher.createdAt, creationDate: createdAt,
revisionDate: cipher.updatedAt, revisionDate: updatedAt,
deletedDate: cipher.deletedAt, deletedDate: deletedAt,
archivedDate: null, archivedDate: null,
edit: true, edit: true,
viewPassword: true, viewPassword: true,
@@ -50,7 +46,6 @@ export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = [])
object: 'cipher', object: 'cipher',
collectionIds: [], collectionIds: [],
attachments: formatAttachments(attachments), attachments: formatAttachments(attachments),
key: cipher.key,
encryptedFor: null, encryptedFor: null,
}; };
} }
@@ -113,23 +108,16 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
const cipherData = body.Cipher || body.cipher || body; const cipherData = body.Cipher || body.cipher || body;
const now = new Date().toISOString(); 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 = { const cipher: Cipher = {
...cipherData,
// Server-controlled fields (always override client values)
id: generateUUID(), id: generateUUID(),
userId: userId, userId: userId,
type: Number(cipherData.type) || 1, type: Number(cipherData.type) || 1,
folderId: cipherData.folderId || null, favorite: !!cipherData.favorite,
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,
reprompt: cipherData.reprompt || 0, reprompt: cipherData.reprompt || 0,
key: cipherData.key || null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
deletedAt: null, deletedAt: null,
@@ -161,23 +149,20 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// Android client sends PascalCase "Cipher" for organization ciphers // Android client sends PascalCase "Cipher" for organization ciphers
const cipherData = body.Cipher || body.cipher || body; 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 = { 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, 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, 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, reprompt: cipherData.reprompt ?? existingCipher.reprompt,
key: cipherData.key !== undefined ? cipherData.key : existingCipher.key, createdAt: existingCipher.createdAt,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
deletedAt: existingCipher.deletedAt,
}; };
await storage.saveCipher(cipher); await storage.saveCipher(cipher);
+19 -1
View File
@@ -180,6 +180,18 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
if (isConfigRequest) { if (isConfigRequest) {
const origin = url.origin; const origin = url.origin;
return jsonResponse({ return jsonResponse({
// ── Version Strategy (Plan E) ──────────────────────────────────────
// Bitwarden clients use this version for backwards-compatibility feature gating.
// Confirmed version-gated features (from client source code):
// - Individual cipher key encryption: >= 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', version: '2025.12.0',
gitHash: 'nodewarden', gitHash: 'nodewarden',
server: null, server: null,
@@ -190,8 +202,14 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
notifications: origin + '/notifications', notifications: origin + '/notifications',
sso: '', sso: '',
}, },
// Feature flags control client behavior. Clients use server-provided values;
// flags not listed here fall back to DefaultFeatureFlagValue (all false).
// Only enable flags for features we actually support.
// Reference: clients/libs/common/src/enums/feature-flag.enum.ts
featureStates: { featureStates: {
'duo-redirect': true, 'duo-redirect': true,
'email-verification': true,
'unauth-ui-refresh': true,
}, },
object: 'config', object: 'config',
}); });
@@ -199,7 +217,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Version endpoint (some clients probe this to validate the server) // Version endpoint (some clients probe this to validate the server)
if (path === '/api/version' && method === 'GET') { if (path === '/api/version' && method === 'GET') {
return jsonResponse('2025.12.0'); return jsonResponse('2025.12.0'); // Keep in sync with config.version above
} }
// Registration endpoint (no auth required, but only works once) // Registration endpoint (no auth required, but only works once)
+80 -78
View File
@@ -9,6 +9,16 @@ import { User, Cipher, Folder, Attachment } from '../types';
export class StorageService { export class StorageService {
constructor(private db: D1Database) {} constructor(private db: D1Database) {}
/**
* D1 .bind() throws on `undefined` values. This helper converts every
* `undefined` in the argument list to `null` so we never hit that runtime
* error — especially important after the opaque-passthrough change where
* client-supplied JSON may omit fields we later reference as columns.
*/
private safeBind(stmt: D1PreparedStatement, ...values: any[]): D1PreparedStatement {
return stmt.bind(...values.map(v => v === undefined ? null : v));
}
private async sha256Hex(input: string): Promise<string> { private async sha256Hex(input: string): Promise<string> {
const bytes = new TextEncoder().encode(input); const bytes = new TextEncoder().encode(input);
const digest = await crypto.subtle.digest('SHA-256', bytes); 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<void> { async saveUser(user: User): Promise<void> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
await this.db const stmt = this.db.prepare(
.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) ' +
'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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(id) DO UPDATE SET ' +
'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, ' +
'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'
'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,
.bind( user.id,
user.id, email,
email, user.name,
user.name, user.masterPasswordHash,
user.masterPasswordHash, user.key,
user.key, user.privateKey,
user.privateKey, user.publicKey,
user.publicKey, user.kdfType,
user.kdfType, user.kdfIterations,
user.kdfIterations, user.kdfMemory,
user.kdfMemory ?? null, user.kdfParallelism,
user.kdfParallelism ?? null, user.securityStamp,
user.securityStamp, user.createdAt,
user.createdAt, user.updatedAt
user.updatedAt ).run();
)
.run();
} }
async createFirstUser(user: User): Promise<boolean> { async createFirstUser(user: User): Promise<boolean> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
const result = await this.db const stmt = this.db.prepare(
.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) ' +
'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 ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' );
) const result = await this.safeBind(stmt,
.bind( user.id,
user.id, email,
email, user.name,
user.name, user.masterPasswordHash,
user.masterPasswordHash, user.key,
user.key, user.privateKey,
user.privateKey, user.publicKey,
user.publicKey, user.kdfType,
user.kdfType, user.kdfIterations,
user.kdfIterations, user.kdfMemory,
user.kdfMemory ?? null, user.kdfParallelism,
user.kdfParallelism ?? null, user.securityStamp,
user.securityStamp, user.createdAt,
user.createdAt, user.updatedAt
user.updatedAt ).run();
)
.run();
return (result.meta.changes ?? 0) > 0; 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<void> { async saveCipher(cipher: Cipher): Promise<void> {
const data = JSON.stringify(cipher); const data = JSON.stringify(cipher);
await this.db const stmt = this.db.prepare(
.prepare( 'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' +
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(id) DO UPDATE SET ' +
'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'
'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,
.bind( cipher.id,
cipher.id, cipher.userId,
cipher.userId, Number(cipher.type) || 1,
Number(cipher.type) || 1, cipher.folderId,
cipher.folderId, cipher.name,
cipher.name, cipher.notes,
cipher.notes, cipher.favorite ? 1 : 0,
cipher.favorite ? 1 : 0, data,
data, cipher.reprompt ?? 0,
cipher.reprompt ?? 0, cipher.key,
cipher.key, cipher.createdAt,
cipher.createdAt, cipher.updatedAt,
cipher.updatedAt, cipher.deletedAt
cipher.deletedAt ).run();
)
.run();
} }
async deleteCipher(id: string, userId: string): Promise<void> { async deleteCipher(id: string, userId: string): Promise<void> {
@@ -424,13 +428,11 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start);
} }
async saveAttachment(attachment: Attachment): Promise<void> { async saveAttachment(attachment: Attachment): Promise<void> {
await this.db const stmt = this.db.prepare(
.prepare( 'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' +
'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'
'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();
.bind(attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key)
.run();
} }
async deleteAttachment(id: string): Promise<void> { async deleteAttachment(id: string): Promise<void> {
+4
View File
@@ -134,6 +134,8 @@ export interface Cipher {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
deletedAt: string | null; deletedAt: string | null;
/** Allow unknown fields from Bitwarden clients to be stored and passed through transparently. */
[key: string]: any;
} }
// Folder model // Folder model
@@ -254,6 +256,8 @@ export interface CipherResponse {
attachments: any[] | null; attachments: any[] | null;
key: string | null; key: string | null;
encryptedFor: string | null; encryptedFor: string | null;
/** Allow unknown fields to pass through to clients transparently. */
[key: string]: any;
} }
export interface CipherPermissions { export interface CipherPermissions {