diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index fec3e46..de4a44a 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -13,6 +13,26 @@ 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, @@ -302,6 +322,11 @@ 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); // Custom fields deletion compatibility: // - Accept both camelCase "fields" and PascalCase "Fields". diff --git a/webapp/src/components/vault/vault-page-helpers.tsx b/webapp/src/components/vault/vault-page-helpers.tsx index cdc67a3..8632522 100644 --- a/webapp/src/components/vault/vault-page-helpers.tsx +++ b/webapp/src/components/vault/vault-page-helpers.tsx @@ -165,7 +165,7 @@ export function websiteIconUrl(host: string): string { } export function createEmptyLoginUri(): VaultDraftLoginUri { - return { uri: '', match: null }; + return { uri: '', match: null, originalUri: '', extra: {} }; } export function websiteMatchLabel(value: number | null | undefined): string { @@ -313,6 +313,10 @@ export function draftFromCipher(cipher: Cipher): VaultDraft { draft.loginUris = (cipher.login.uris || []).map((x) => ({ uri: x.decUri || x.uri || '', match: x.match ?? null, + originalUri: x.decUri || x.uri || '', + extra: Object.fromEntries( + Object.entries(x as Record).filter(([key]) => !['uri', 'match', 'decUri'].includes(key)) + ), })); draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials) ? cipher.login.fido2Credentials.map((credential) => ({ ...credential })) diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index 56b80b3..4b6549e 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -372,12 +372,20 @@ async function encryptUris( uris: VaultDraft['loginUris'], enc: Uint8Array, mac: Uint8Array -): Promise> { - const out: Array<{ uri: string | null; match: number | null }> = []; +): Promise>> { + const out: Array> = []; for (const entry of uris || []) { const trimmed = String(entry?.uri || '').trim(); if (!trimmed) continue; + const preservedExtra = + entry?.extra && typeof entry.extra === 'object' + ? { ...entry.extra } + : {}; + if (String(entry?.originalUri || '').trim() !== trimmed) { + delete preservedExtra.uriChecksum; + } out.push({ + ...preservedExtra, uri: await encryptTextValue(trimmed, enc, mac), match: typeof entry?.match === 'number' && Number.isFinite(entry.match) ? entry.match : null, }); @@ -495,7 +503,12 @@ async function buildCipherPayload( cipher?.login && Array.isArray((cipher.login as any).fido2Credentials) ? (cipher.login as any).fido2Credentials : draft.loginFido2Credentials; + const existingLogin = + cipher?.login && typeof cipher.login === 'object' + ? { ...(cipher.login as Record) } + : {}; payload.login = { + ...existingLogin, username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac), password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac), totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac), diff --git a/webapp/src/lib/app-support.ts b/webapp/src/lib/app-support.ts index 4404edc..246138b 100644 --- a/webapp/src/lib/app-support.ts +++ b/webapp/src/lib/app-support.ts @@ -170,10 +170,14 @@ export function importCipherToDraft(cipher: Record, folderId: s return { uri, match: typeof matchRaw === 'number' && Number.isFinite(matchRaw) ? matchRaw : null, + originalUri: uri, + extra: Object.fromEntries( + Object.entries(row).filter(([key]) => !['uri', 'match'].includes(key)) + ), }; }) .filter((u) => !!u.uri); - draft.loginUris = uris.length ? uris : [{ uri: '', match: null }]; + draft.loginUris = uris.length ? uris : [{ uri: '', match: null, originalUri: '', extra: {} }]; draft.loginFido2Credentials = Array.isArray(login.fido2Credentials) ? login.fido2Credentials.filter((item): item is Record => !!item && typeof item === 'object') : []; diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index f602fca..b660fba 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -29,13 +29,18 @@ export interface Folder { export interface CipherLoginUri { uri?: string | null; + uriChecksum?: string | null; match?: number | null; + response?: unknown | null; decUri?: string; + [key: string]: unknown; } export interface VaultDraftLoginUri { uri: string; match: number | null; + originalUri?: string; + extra?: Record; } export interface CipherAttachment { @@ -60,9 +65,14 @@ export interface CipherLogin { totp?: string | null; uris?: CipherLoginUri[] | null; fido2Credentials?: CipherLoginPasskey[] | null; + autofillOnPageLoad?: boolean | null; + uri?: string | null; + passwordRevisionDate?: string | null; + response?: unknown | null; decUsername?: string; decPassword?: string; decTotp?: string; + [key: string]: unknown; } export interface CipherCard {