mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
fix: enhance cipher handling to support unknown fields and improve database binding
This commit is contained in:
+28
-43
@@ -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
@@ -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
@@ -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> {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user