From 08414d7cf212a0161d3e66e6e9698759dca693f8 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sat, 18 Apr 2026 03:44:17 +0800 Subject: [PATCH] feat: add support for new cipher properties and enhance import functionality --- src/handlers/ciphers.ts | 115 ++++++++++++++++++++++++++++------------ src/handlers/import.ts | 103 ++++++++++++++++++++--------------- src/router-public.ts | 1 + 3 files changed, 143 insertions(+), 76 deletions(-) diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index 83f7bfc..9e7f148 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -1,4 +1,15 @@ -import { Env, Cipher, CipherResponse, Attachment } from '../types'; +import { + Env, + Cipher, + CipherCard, + CipherIdentity, + CipherLogin, + CipherResponse, + CipherSecureNote, + CipherSshKey, + Attachment, + PasswordHistory, +} from '../types'; import { StorageService } from '../services/storage'; import { notifyUserVaultSync } from '../durable/notifications-hub'; import { jsonResponse, errorResponse } from '../utils/response'; @@ -13,26 +24,6 @@ function normalizeOptionalId(value: unknown): string | null { return normalized ? normalized : null; } -function mergeCipherNestedObject( - existingValue: T | null | undefined, - incomingValue: unknown -): T | null { - if (incomingValue === undefined) { - return (existingValue ?? null) as T | null; - } - if (incomingValue === null || typeof incomingValue !== 'object' || Array.isArray(incomingValue)) { - return incomingValue as T | null; - } - const existingObject = - existingValue && typeof existingValue === 'object' && !Array.isArray(existingValue) - ? (existingValue as Record) - : {}; - return { - ...existingObject, - ...(incomingValue as Record), - } as T; -} - async function notifyVaultSyncForRequest( request: Request, env: Env, @@ -52,6 +43,10 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val return { present: false, value: undefined }; } +function readCipherProp(source: any, aliases: string[]): { present: boolean; value: T | undefined } { + return getAliasedProp(source, aliases) as { present: boolean; value: T | undefined }; +} + function normalizeCipherTimestamp(value: unknown): string | null { if (value == null || value === '') return null; const parsed = new Date(String(value)); @@ -64,6 +59,19 @@ function readCipherArchivedAt(source: any, fallback: string | null = null): stri return archived.present ? normalizeCipherTimestamp(archived.value) : fallback; } +function readCipherRevisionDate(source: any): string | null { + const revision = getAliasedProp(source, ['lastKnownRevisionDate', 'LastKnownRevisionDate']); + return revision.present ? normalizeCipherTimestamp(revision.value) : null; +} + +function isStaleCipherUpdate(existingUpdatedAt: string, clientRevisionDate: string | null): boolean { + if (!clientRevisionDate) return false; + const existingTs = Date.parse(existingUpdatedAt); + const clientTs = Date.parse(clientRevisionDate); + if (Number.isNaN(existingTs) || Number.isNaN(clientTs)) return false; + return existingTs - clientTs > 1000; +} + function syncCipherComputedAliases(cipher: Cipher): Cipher { cipher.archivedDate = cipher.archivedAt ?? null; cipher.deletedDate = cipher.deletedAt ?? null; @@ -151,8 +159,8 @@ export function cipherToResponse( // Server-computed / enforced fields (always override) folderId: normalizeOptionalId(cipher.folderId), type: Number(cipher.type) || 1, - organizationId: null, - organizationUseTotp: false, + organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null), + organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false), creationDate: createdAt, revisionDate: updatedAt, deletedDate: deletedAt, @@ -163,12 +171,12 @@ export function cipherToResponse( delete: true, restore: true, }, - object: 'cipher', - collectionIds: [], + object: 'cipherDetails', + collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [], attachments: formatAttachments(attachments), login: normalizedLogin, sshKey: normalizedSshKey, - encryptedFor: null, + encryptedFor: (passthrough as any).encryptedFor ?? null, }; } @@ -251,6 +259,14 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str // Handle nested cipher object (from some clients) // Android client sends PascalCase "Cipher" for organization ciphers const cipherData = body.Cipher || body.cipher || body; + const createFolderId = readCipherProp(cipherData, ['folderId', 'FolderId']); + const createKey = readCipherProp(cipherData, ['key', 'Key']); + const createLogin = readCipherProp(cipherData, ['login', 'Login']); + const createCard = readCipherProp(cipherData, ['card', 'Card']); + const createIdentity = readCipherProp(cipherData, ['identity', 'Identity']); + const createSecureNote = readCipherProp(cipherData, ['secureNote', 'SecureNote']); + const createSshKey = readCipherProp(cipherData, ['sshKey', 'SshKey']); + const createPasswordHistory = readCipherProp(cipherData, ['passwordHistory', 'PasswordHistory']); const now = new Date().toISOString(); // Opaque passthrough: spread ALL client fields to preserve unknown/future ones, @@ -268,6 +284,14 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str archivedAt: readCipherArchivedAt(cipherData, null), deletedAt: null, }; + cipher.folderId = createFolderId.present ? normalizeOptionalId(createFolderId.value) : normalizeOptionalId(cipher.folderId); + cipher.key = createKey.present ? (createKey.value ?? null) : (cipher.key ?? null); + cipher.login = createLogin.present ? (createLogin.value ?? null) : (cipher.login ?? null); + cipher.card = createCard.present ? (createCard.value ?? null) : (cipher.card ?? null); + cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null); + cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null); + cipher.sshKey = createSshKey.present ? (createSshKey.value ?? null) : (cipher.sshKey ?? null); + cipher.passwordHistory = createPasswordHistory.present ? (createPasswordHistory.value ?? null) : (cipher.passwordHistory ?? null); const createFields = getAliasedProp(cipherData, ['fields', 'Fields']); cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null); normalizeCipherForStorage(cipher); @@ -307,6 +331,21 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str // Handle nested cipher object // Android client sends PascalCase "Cipher" for organization ciphers const cipherData = body.Cipher || body.cipher || body; + const incomingFolderId = readCipherProp(cipherData, ['folderId', 'FolderId']); + const incomingKey = readCipherProp(cipherData, ['key', 'Key']); + const incomingLogin = readCipherProp(cipherData, ['login', 'Login']); + const incomingCard = readCipherProp(cipherData, ['card', 'Card']); + const incomingIdentity = readCipherProp(cipherData, ['identity', 'Identity']); + const incomingSecureNote = readCipherProp(cipherData, ['secureNote', 'SecureNote']); + const incomingSshKey = readCipherProp(cipherData, ['sshKey', 'SshKey']); + const incomingPasswordHistory = readCipherProp(cipherData, ['passwordHistory', 'PasswordHistory']); + const incomingRevisionDate = readCipherRevisionDate(cipherData); + + if (isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) { + return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400); + } + + const nextType = Number(cipherData.type) || existingCipher.type; // Opaque passthrough: merge existing stored data with ALL incoming client fields. // Unknown/future fields from the client are preserved; server-controlled fields are protected. @@ -316,7 +355,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str // Server-controlled fields (never from client) id: existingCipher.id, userId: existingCipher.userId, - type: Number(cipherData.type) || existingCipher.type, + type: nextType, favorite: cipherData.favorite ?? existingCipher.favorite, reprompt: cipherData.reprompt ?? existingCipher.reprompt, createdAt: existingCipher.createdAt, @@ -324,11 +363,20 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null), deletedAt: existingCipher.deletedAt, }; - cipher.login = mergeCipherNestedObject(existingCipher.login, cipherData.login); - cipher.card = mergeCipherNestedObject(existingCipher.card, cipherData.card); - cipher.identity = mergeCipherNestedObject(existingCipher.identity, cipherData.identity); - cipher.secureNote = mergeCipherNestedObject(existingCipher.secureNote, cipherData.secureNote); - cipher.sshKey = mergeCipherNestedObject(existingCipher.sshKey, cipherData.sshKey); + if (incomingFolderId.present) { + cipher.folderId = normalizeOptionalId(incomingFolderId.value); + } + if (incomingKey.present) { + cipher.key = incomingKey.value ?? null; + } + cipher.login = nextType === 1 ? (incomingLogin.present ? (incomingLogin.value ?? null) : (existingCipher.login ?? null)) : null; + cipher.secureNote = nextType === 2 ? (incomingSecureNote.present ? (incomingSecureNote.value ?? null) : (existingCipher.secureNote ?? null)) : null; + cipher.card = nextType === 3 ? (incomingCard.present ? (incomingCard.value ?? null) : (existingCipher.card ?? null)) : null; + cipher.identity = nextType === 4 ? (incomingIdentity.present ? (incomingIdentity.value ?? null) : (existingCipher.identity ?? null)) : null; + cipher.sshKey = nextType === 5 ? (incomingSshKey.present ? (incomingSshKey.value ?? null) : (existingCipher.sshKey ?? null)) : null; + if (incomingPasswordHistory.present) { + cipher.passwordHistory = incomingPasswordHistory.value ?? null; + } // Custom fields deletion compatibility: // - Accept both camelCase "fields" and PascalCase "Fields". @@ -351,9 +399,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); await notifyVaultSyncForRequest(request, env, userId, revisionDate); + const attachments = await storage.getAttachmentsByCipher(cipher.id); return jsonResponse( - cipherToResponse(cipher, []) + cipherToResponse(cipher, attachments) ); } diff --git a/src/handlers/import.ts b/src/handlers/import.ts index 51bee18..bfaa1a9 100644 --- a/src/handlers/import.ts +++ b/src/handlers/import.ts @@ -82,6 +82,16 @@ function bindNull(v: any): any { return v === undefined ? null : v; } +function readAliasedImportProp(source: any, aliases: string[]): T | undefined { + if (!source || typeof source !== 'object') return undefined; + for (const key of aliases) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + return source[key] as T; + } + } + return undefined; +} + async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise { for (let i = 0; i < statements.length; i += chunkSize) { const chunk = statements.slice(i, i + chunkSize); @@ -158,9 +168,16 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = []; for (let i = 0; i < ciphers.length; i++) { const c = ciphers[i]; - const folderId = cipherFolderMap.get(i) || c.folderId || null; + const folderId = cipherFolderMap.get(i) || readAliasedImportProp(c, ['folderId', 'FolderId']) || null; const sourceIdRaw = String(c?.id ?? '').trim(); const sourceId = sourceIdRaw || null; + const login = readAliasedImportProp(c, ['login', 'Login']); + const card = readAliasedImportProp(c, ['card', 'Card']); + const identity = readAliasedImportProp(c, ['identity', 'Identity']); + const secureNote = readAliasedImportProp(c, ['secureNote', 'SecureNote']); + const fields = readAliasedImportProp(c, ['fields', 'Fields']); + const passwordHistory = readAliasedImportProp(c, ['passwordHistory', 'PasswordHistory']); + const key = readAliasedImportProp(c, ['key', 'Key']); const cipher: Cipher = { ...c, @@ -171,64 +188,64 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st name: c.name ?? 'Untitled', notes: c.notes ?? null, favorite: c.favorite ?? false, - login: c.login ? { - ...c.login, - username: c.login.username ?? null, - password: c.login.password ?? null, - uris: c.login.uris?.map(u => ({ + login: login ? { + ...login, + username: login.username ?? null, + password: login.password ?? null, + uris: login.uris?.map((u: any) => ({ ...u, uri: u.uri ?? null, uriChecksum: null, match: u.match ?? null, })) || null, - totp: c.login.totp ?? null, - autofillOnPageLoad: c.login.autofillOnPageLoad ?? null, - fido2Credentials: Array.isArray(c.login.fido2Credentials) ? c.login.fido2Credentials : null, - uri: c.login.uri ?? null, - passwordRevisionDate: c.login.passwordRevisionDate ?? null, + totp: login.totp ?? null, + autofillOnPageLoad: login.autofillOnPageLoad ?? null, + fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null, + uri: login.uri ?? null, + passwordRevisionDate: login.passwordRevisionDate ?? null, } : null, - card: c.card ? { - ...c.card, - cardholderName: c.card.cardholderName ?? null, - brand: c.card.brand ?? null, - number: c.card.number ?? null, - expMonth: c.card.expMonth ?? null, - expYear: c.card.expYear ?? null, - code: c.card.code ?? null, + card: card ? { + ...card, + cardholderName: card.cardholderName ?? null, + brand: card.brand ?? null, + number: card.number ?? null, + expMonth: card.expMonth ?? null, + expYear: card.expYear ?? null, + code: card.code ?? null, } : null, - identity: c.identity ? { - ...c.identity, - title: c.identity.title ?? null, - firstName: c.identity.firstName ?? null, - middleName: c.identity.middleName ?? null, - lastName: c.identity.lastName ?? null, - address1: c.identity.address1 ?? null, - address2: c.identity.address2 ?? null, - address3: c.identity.address3 ?? null, - city: c.identity.city ?? null, - state: c.identity.state ?? null, - postalCode: c.identity.postalCode ?? null, - country: c.identity.country ?? null, - company: c.identity.company ?? null, - email: c.identity.email ?? null, - phone: c.identity.phone ?? null, - ssn: c.identity.ssn ?? null, - username: c.identity.username ?? null, - passportNumber: c.identity.passportNumber ?? null, - licenseNumber: c.identity.licenseNumber ?? null, + identity: identity ? { + ...identity, + title: identity.title ?? null, + firstName: identity.firstName ?? null, + middleName: identity.middleName ?? null, + lastName: identity.lastName ?? null, + address1: identity.address1 ?? null, + address2: identity.address2 ?? null, + address3: identity.address3 ?? null, + city: identity.city ?? null, + state: identity.state ?? null, + postalCode: identity.postalCode ?? null, + country: identity.country ?? null, + company: identity.company ?? null, + email: identity.email ?? null, + phone: identity.phone ?? null, + ssn: identity.ssn ?? null, + username: identity.username ?? null, + passportNumber: identity.passportNumber ?? null, + licenseNumber: identity.licenseNumber ?? null, } : null, - secureNote: c.secureNote ?? null, - fields: c.fields?.map(f => ({ + secureNote: secureNote ?? null, + fields: fields?.map((f: any) => ({ ...f, name: f.name ?? null, value: f.value ?? null, type: f.type, linkedId: f.linkedId ?? null, })) || null, - passwordHistory: c.passwordHistory ?? null, + passwordHistory: passwordHistory ?? null, reprompt: c.reprompt ?? 0, sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null), - key: (c as any).key ?? null, + key: key ?? null, createdAt: now, updatedAt: now, archivedAt: null, diff --git a/src/router-public.ts b/src/router-public.ts index c762055..855f2be 100644 --- a/src/router-public.ts +++ b/src/router-public.ts @@ -104,6 +104,7 @@ function buildConfigResponse(origin: string) { _icon_service_url: buildIconServiceTemplate(origin), _icon_service_csp: buildIconServiceCsp(origin), featureStates: { + 'cipher-key-encryption': true, 'duo-redirect': true, 'email-verification': true, 'pm-19051-send-email-verification': false,