From 03f7fbf6018cb430c906f58d8928aa070f652a5c Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sat, 23 May 2026 12:36:22 +0800 Subject: [PATCH] fix: repair mixed cipher key encryption handling --- .gitignore | 1 + src/config/limits.ts | 6 +- src/router-public.ts | 2 +- webapp/src/App.tsx | 11 +- webapp/src/lib/api/vault.ts | 288 ++++++++++++++++++++++++++++++- webapp/src/lib/decrypt-cipher.ts | 105 ++++++----- webapp/src/lib/vault-decrypt.ts | 106 ++++++++---- 7 files changed, 432 insertions(+), 87 deletions(-) diff --git a/.gitignore b/.gitignore index 50b5aa9..33f67bb 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ AGENTS.md settings.json .claude/ NodeWarden-compat/ +.codex-upstream/ diff --git a/src/config/limits.ts b/src/config/limits.ts index 1307769..bdfb31a 100644 --- a/src/config/limits.ts +++ b/src/config/limits.ts @@ -148,6 +148,10 @@ compatibility: { // Single source of truth for /config.version and /api/version. // /config.version 与 /api/version 的统一版本号来源。 - bitwardenServerVersion: '2026.1.0', + bitwardenServerVersion: '2026.4.1', + // Advertise official per-cipher item-key encryption support only after + // NodeWarden can guarantee key/field consistency across all write paths. + // 在所有写入路径都能保证 cipher.key 与字段密文一致之前,不向官方客户端声明支持逐项密钥加密。 + cipherKeyEncryptionFeatureEnabled: false, }, } as const; diff --git a/src/router-public.ts b/src/router-public.ts index 4ef902a..c6d626d 100644 --- a/src/router-public.ts +++ b/src/router-public.ts @@ -115,7 +115,7 @@ function buildConfigResponse(origin: string) { _icon_service_url: buildIconServiceTemplate(origin), _icon_service_csp: buildIconServiceCsp(origin), featureStates: { - 'cipher-key-encryption': true, + 'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled, 'duo-redirect': true, 'email-verification': true, 'pm-19051-send-email-verification': false, diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 841d4e0..fbc4273 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -25,7 +25,7 @@ import { import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin'; import { getDomainRules, saveDomainRules } from '@/lib/api/domains'; import { getSends } from '@/lib/api/send'; -import { repairCipherUriChecksums } from '@/lib/api/vault'; +import { repairCipherKeyMismatches, repairCipherUriChecksums } from '@/lib/api/vault'; import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync'; import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; import { @@ -1086,9 +1086,12 @@ export default function App() { const repairKey = `${session.accessToken}:${encryptedCiphers.map((cipher) => `${cipher.id}:${cipher.revisionDate || ''}`).join(',')}`; if (uriChecksumRepairAttemptRef.current !== repairKey) { uriChecksumRepairAttemptRef.current = repairKey; - void repairCipherUriChecksums(authedFetch, session, result.ciphers) - .then((count) => { - if (count > 0) void refetchVaultCoreData(); + void Promise.all([ + repairCipherKeyMismatches(authedFetch, session, result.ciphers), + repairCipherUriChecksums(authedFetch, session, result.ciphers), + ]) + .then(([keyMismatchCount, uriChecksumCount]) => { + if (keyMismatchCount + uriChecksumCount > 0) void refetchVaultCoreData(); }) .catch(() => { // Best-effort compatibility repair must not interrupt normal vault loading. diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index 15ff64e..f4bb24e 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -496,8 +496,11 @@ async function encryptPasswordHistory( const out: CipherPasswordHistoryEntry[] = []; for (const entry of entries) { const rawPassword = String(entry?.password || ''); + const hasDecryptedPassword = typeof entry?.decPassword === 'string'; const plainPassword = entry?.decPassword ?? rawPassword; - const encryptedPassword = looksLikeCipherString(rawPassword) + const encryptedPassword = hasDecryptedPassword + ? await encryptTextValue(plainPassword, enc, mac) + : looksLikeCipherString(rawPassword) ? rawPassword : await encryptTextValue(plainPassword, enc, mac); if (!encryptedPassword) continue; @@ -510,6 +513,133 @@ async function encryptPasswordHistory( return out.length ? out : null; } +function plainCipherValue(decrypted: unknown, raw: unknown = ''): string { + if (typeof decrypted === 'string' && !looksLikeCipherString(decrypted)) return decrypted; + const value = String(raw ?? ''); + return looksLikeCipherString(value) ? '' : value; +} + +function draftFromDecryptedCipher(cipher: Cipher): VaultDraft { + const type = Number(cipher.type || 1) || 1; + const draft: VaultDraft = { + type, + name: plainCipherValue(cipher.decName, cipher.name).trim() || 'Untitled', + notes: plainCipherValue(cipher.decNotes, cipher.notes), + favorite: !!cipher.favorite, + reprompt: Number(cipher.reprompt || 0) === 1, + folderId: cipher.folderId || '', + loginUsername: '', + loginPassword: '', + loginTotp: '', + loginUris: [{ uri: '', match: null, originalUri: '', extra: {} }], + loginFido2Credentials: [], + cardholderName: '', + cardNumber: '', + cardBrand: '', + cardExpMonth: '', + cardExpYear: '', + cardCode: '', + identTitle: '', + identFirstName: '', + identMiddleName: '', + identLastName: '', + identUsername: '', + identCompany: '', + identSsn: '', + identPassportNumber: '', + identLicenseNumber: '', + identEmail: '', + identPhone: '', + identAddress1: '', + identAddress2: '', + identAddress3: '', + identCity: '', + identState: '', + identPostalCode: '', + identCountry: '', + sshPrivateKey: '', + sshPublicKey: '', + sshFingerprint: '', + customFields: [], + }; + + draft.customFields = (cipher.fields || []) + .map((field) => ({ + type: parseFieldType(field.type ?? 0), + label: plainCipherValue(field.decName, field.name).trim(), + value: plainCipherValue(field.decValue, field.value), + })) + .filter((field) => field.label); + + if (type === 1 && cipher.login) { + draft.loginUsername = plainCipherValue(cipher.login.decUsername, cipher.login.username); + draft.loginPassword = plainCipherValue(cipher.login.decPassword, cipher.login.password); + draft.loginTotp = plainCipherValue(cipher.login.decTotp, cipher.login.totp); + draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials) + ? cipher.login.fido2Credentials.filter((item): item is Record => !!item && typeof item === 'object') + : []; + const seenUris = new Set(); + const uris = (cipher.login.uris || []) + .map((entry) => { + const uri = plainCipherValue(entry.decUri, entry.uri).trim(); + const extra = { ...(entry as Record) }; + delete extra.uri; + delete extra.uriChecksum; + delete extra.match; + delete extra.decUri; + return { + uri, + match: typeof entry.match === 'number' && Number.isFinite(entry.match) ? entry.match : null, + originalUri: '', + extra, + }; + }) + .filter((entry) => { + if (!entry.uri) return false; + const key = entry.uri.toLowerCase(); + if (seenUris.has(key)) return false; + seenUris.add(key); + return true; + }); + draft.loginUris = uris.length ? uris : draft.loginUris; + } else if (type === 3 && cipher.card) { + draft.cardholderName = plainCipherValue(cipher.card.decCardholderName, cipher.card.cardholderName); + draft.cardNumber = plainCipherValue(cipher.card.decNumber, cipher.card.number); + draft.cardBrand = plainCipherValue(cipher.card.decBrand, cipher.card.brand); + draft.cardExpMonth = plainCipherValue(cipher.card.decExpMonth, cipher.card.expMonth); + draft.cardExpYear = plainCipherValue(cipher.card.decExpYear, cipher.card.expYear); + draft.cardCode = plainCipherValue(cipher.card.decCode, cipher.card.code); + } else if (type === 4 && cipher.identity) { + draft.identTitle = plainCipherValue(cipher.identity.decTitle, cipher.identity.title); + draft.identFirstName = plainCipherValue(cipher.identity.decFirstName, cipher.identity.firstName); + draft.identMiddleName = plainCipherValue(cipher.identity.decMiddleName, cipher.identity.middleName); + draft.identLastName = plainCipherValue(cipher.identity.decLastName, cipher.identity.lastName); + draft.identUsername = plainCipherValue(cipher.identity.decUsername, cipher.identity.username); + draft.identCompany = plainCipherValue(cipher.identity.decCompany, cipher.identity.company); + draft.identSsn = plainCipherValue(cipher.identity.decSsn, cipher.identity.ssn); + draft.identPassportNumber = plainCipherValue(cipher.identity.decPassportNumber, cipher.identity.passportNumber); + draft.identLicenseNumber = plainCipherValue(cipher.identity.decLicenseNumber, cipher.identity.licenseNumber); + draft.identEmail = plainCipherValue(cipher.identity.decEmail, cipher.identity.email); + draft.identPhone = plainCipherValue(cipher.identity.decPhone, cipher.identity.phone); + draft.identAddress1 = plainCipherValue(cipher.identity.decAddress1, cipher.identity.address1); + draft.identAddress2 = plainCipherValue(cipher.identity.decAddress2, cipher.identity.address2); + draft.identAddress3 = plainCipherValue(cipher.identity.decAddress3, cipher.identity.address3); + draft.identCity = plainCipherValue(cipher.identity.decCity, cipher.identity.city); + draft.identState = plainCipherValue(cipher.identity.decState, cipher.identity.state); + draft.identPostalCode = plainCipherValue(cipher.identity.decPostalCode, cipher.identity.postalCode); + draft.identCountry = plainCipherValue(cipher.identity.decCountry, cipher.identity.country); + } else if (type === 5 && cipher.sshKey) { + draft.sshPrivateKey = plainCipherValue(cipher.sshKey.decPrivateKey, cipher.sshKey.privateKey); + draft.sshPublicKey = plainCipherValue(cipher.sshKey.decPublicKey, cipher.sshKey.publicKey); + draft.sshFingerprint = plainCipherValue( + cipher.sshKey.decFingerprint, + cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint + ); + } + + return draft; +} + async function buildUpdatedPasswordHistory( cipher: Cipher | null, draft: VaultDraft, @@ -798,6 +928,162 @@ export async function repairCipherUriChecksums( return repaired; } +function getCipherKeyMismatchProbes(cipher: Cipher): string[] { + const candidates = [ + cipher.name, + cipher.notes, + cipher.login?.username, + cipher.login?.password, + cipher.login?.totp, + ...(cipher.login?.uris || []).map((uri) => uri.uri), + cipher.card?.cardholderName, + cipher.card?.number, + cipher.identity?.title, + cipher.identity?.firstName, + cipher.sshKey?.privateKey, + ...(cipher.fields || []).flatMap((field) => [field.name, field.value]), + ]; + const probes: string[] = []; + const seen = new Set(); + for (const value of candidates) { + const probe = String(value || '').trim(); + if (!looksLikeCipherString(probe) || seen.has(probe)) continue; + seen.add(probe); + probes.push(probe); + } + return probes; +} + +function isResolvedEncryptedField(raw: unknown, decrypted: unknown): boolean { + const encrypted = String(raw || '').trim(); + if (!looksLikeCipherString(encrypted)) return true; + const plain = typeof decrypted === 'string' ? decrypted.trim() : ''; + return !!plain && !looksLikeCipherString(plain); +} + +function hasUnresolvedEncryptedFields(cipher: Cipher): boolean { + const fido2EncryptedFields = (cipher.login?.fido2Credentials || []).flatMap((credential) => [ + credential?.credentialId, + credential?.keyType, + credential?.keyAlgorithm, + credential?.keyCurve, + credential?.keyValue, + credential?.rpId, + credential?.rpName, + credential?.userHandle, + credential?.userName, + credential?.userDisplayName, + credential?.counter, + credential?.discoverable, + ]); + + const checks: Array<[unknown, unknown]> = [ + [cipher.name, cipher.decName], + [cipher.notes, cipher.decNotes], + [cipher.login?.username, cipher.login?.decUsername], + [cipher.login?.password, cipher.login?.decPassword], + [cipher.login?.totp, cipher.login?.decTotp], + ...(cipher.login?.uris || []).map((uri) => [uri.uri, uri.decUri] as [unknown, unknown]), + [cipher.card?.cardholderName, cipher.card?.decCardholderName], + [cipher.card?.number, cipher.card?.decNumber], + [cipher.card?.brand, cipher.card?.decBrand], + [cipher.card?.expMonth, cipher.card?.decExpMonth], + [cipher.card?.expYear, cipher.card?.decExpYear], + [cipher.card?.code, cipher.card?.decCode], + [cipher.identity?.title, cipher.identity?.decTitle], + [cipher.identity?.firstName, cipher.identity?.decFirstName], + [cipher.identity?.middleName, cipher.identity?.decMiddleName], + [cipher.identity?.lastName, cipher.identity?.decLastName], + [cipher.identity?.username, cipher.identity?.decUsername], + [cipher.identity?.company, cipher.identity?.decCompany], + [cipher.identity?.ssn, cipher.identity?.decSsn], + [cipher.identity?.passportNumber, cipher.identity?.decPassportNumber], + [cipher.identity?.licenseNumber, cipher.identity?.decLicenseNumber], + [cipher.identity?.email, cipher.identity?.decEmail], + [cipher.identity?.phone, cipher.identity?.decPhone], + [cipher.identity?.address1, cipher.identity?.decAddress1], + [cipher.identity?.address2, cipher.identity?.decAddress2], + [cipher.identity?.address3, cipher.identity?.decAddress3], + [cipher.identity?.city, cipher.identity?.decCity], + [cipher.identity?.state, cipher.identity?.decState], + [cipher.identity?.postalCode, cipher.identity?.decPostalCode], + [cipher.identity?.country, cipher.identity?.decCountry], + [cipher.sshKey?.privateKey, cipher.sshKey?.decPrivateKey], + [cipher.sshKey?.publicKey, cipher.sshKey?.decPublicKey], + [cipher.sshKey?.keyFingerprint || cipher.sshKey?.fingerprint, cipher.sshKey?.decFingerprint], + ...(cipher.fields || []).flatMap((field) => [ + [field.name, field.decName] as [unknown, unknown], + [field.value, field.decValue] as [unknown, unknown], + ]), + ...(cipher.passwordHistory || []).map((entry) => [entry.password, entry.decPassword] as [unknown, unknown]), + ...fido2EncryptedFields.map((value) => [value, undefined] as [unknown, unknown]), + ]; + + return checks.some(([raw, decrypted]) => !isResolvedEncryptedField(raw, decrypted)); +} + +async function hasItemKeyFieldMismatch( + cipher: Cipher, + userEnc: Uint8Array, + userMac: Uint8Array +): Promise { + if (!looksLikeCipherString(cipher.key)) return false; + const probes = getCipherKeyMismatchProbes(cipher); + if (probes.length === 0) return false; + + let itemKey: Uint8Array; + try { + itemKey = await decryptBw(String(cipher.key).trim(), userEnc, userMac); + } catch { + return false; + } + if (itemKey.length < 64) return false; + + const itemEnc = itemKey.slice(0, 32); + const itemMac = itemKey.slice(32, 64); + for (const probe of probes) { + try { + await decryptStr(probe, itemEnc, itemMac); + continue; + } catch { + // Try the legacy user-key field path below. + } + + try { + await decryptStr(probe, userEnc, userMac); + return true; + } catch { + // Keep scanning in case another field reveals a repairable mismatch. + } + } + + return false; +} + +export async function repairCipherKeyMismatches( + authedFetch: AuthedFetch, + session: SessionState, + ciphers: Cipher[] +): Promise { + if (!session.symEncKey || !session.symMacKey || !Array.isArray(ciphers) || ciphers.length === 0) { + return 0; + } + + const userEnc = base64ToBytes(session.symEncKey); + const userMac = base64ToBytes(session.symMacKey); + let repaired = 0; + + for (const cipher of ciphers) { + if (!cipher?.id || !looksLikeCipherString(cipher.key)) continue; + if (!(await hasItemKeyFieldMismatch(cipher, userEnc, userMac))) continue; + if (hasUnresolvedEncryptedFields(cipher)) continue; + await updateCipher(authedFetch, session, cipher, draftFromDecryptedCipher(cipher)); + repaired += 1; + } + + return repaired; +} + async function buildCipherPayload( session: SessionState, draft: VaultDraft, diff --git a/webapp/src/lib/decrypt-cipher.ts b/webapp/src/lib/decrypt-cipher.ts index 0ffc1a1..525e0c9 100644 --- a/webapp/src/lib/decrypt-cipher.ts +++ b/webapp/src/lib/decrypt-cipher.ts @@ -1,13 +1,28 @@ import { decryptStr, decryptBw } from './crypto'; import type { Cipher } from './types'; -async function decryptField( +async function decryptCipherField( value: string | null | undefined, - enc: Uint8Array, - mac: Uint8Array, + itemEnc: Uint8Array, + itemMac: Uint8Array, + userEnc: Uint8Array, + userMac: Uint8Array, + canFallbackToUserKey: boolean, ): Promise { if (!value || typeof value !== 'string') return ''; - try { return await decryptStr(value, enc, mac); } catch { return value; } + try { + return await decryptStr(value, itemEnc, itemMac); + } catch { + // Try the legacy user-key path for mixed key/field ciphers. + } + if (canFallbackToUserKey) { + try { + return await decryptStr(value, userEnc, userMac); + } catch { + // Preserve the old raw fallback for fields that are genuinely unreadable. + } + } + return value; } export async function decryptSingleCipher( @@ -17,29 +32,35 @@ export async function decryptSingleCipher( ): Promise { let itemEnc = userEnc; let itemMac = userMac; + let usesItemKey = false; if (encrypted.key) { try { const itemKey = await decryptBw(encrypted.key, userEnc, userMac); - itemEnc = itemKey.slice(0, 32); - itemMac = itemKey.slice(32, 64); + if (itemKey.length >= 64) { + itemEnc = itemKey.slice(0, 32); + itemMac = itemKey.slice(32, 64); + usesItemKey = true; + } } catch { /* keep user key */ } } + const canFallbackToUserKey = usesItemKey; + const decrypted: Cipher = { ...encrypted, - decName: await decryptField(encrypted.name, itemEnc, itemMac), - decNotes: await decryptField(encrypted.notes, itemEnc, itemMac), + decName: await decryptCipherField(encrypted.name, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decNotes: await decryptCipherField(encrypted.notes, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), }; if (encrypted.login) { decrypted.login = { ...encrypted.login, - decUsername: await decryptField(encrypted.login.username, itemEnc, itemMac), - decPassword: await decryptField(encrypted.login.password, itemEnc, itemMac), - decTotp: await decryptField(encrypted.login.totp, itemEnc, itemMac), + decUsername: await decryptCipherField(encrypted.login.username, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decPassword: await decryptCipherField(encrypted.login.password, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decTotp: await decryptCipherField(encrypted.login.totp, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), uris: await Promise.all((encrypted.login.uris || []).map(async (u) => ({ ...u, - decUri: await decryptField(u.uri, itemEnc, itemMac), + decUri: await decryptCipherField(u.uri, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), }))), }; } @@ -48,7 +69,7 @@ export async function decryptSingleCipher( decrypted.passwordHistory = await Promise.all( encrypted.passwordHistory.map(async (entry) => ({ ...entry, - decPassword: await decryptField(entry?.password, itemEnc, itemMac), + decPassword: await decryptCipherField(entry?.password, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), })) ); } @@ -56,36 +77,36 @@ export async function decryptSingleCipher( if (encrypted.card) { decrypted.card = { ...encrypted.card, - decCardholderName: await decryptField(encrypted.card.cardholderName, itemEnc, itemMac), - decNumber: await decryptField(encrypted.card.number, itemEnc, itemMac), - decBrand: await decryptField(encrypted.card.brand, itemEnc, itemMac), - decExpMonth: await decryptField(encrypted.card.expMonth, itemEnc, itemMac), - decExpYear: await decryptField(encrypted.card.expYear, itemEnc, itemMac), - decCode: await decryptField(encrypted.card.code, itemEnc, itemMac), + decCardholderName: await decryptCipherField(encrypted.card.cardholderName, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decNumber: await decryptCipherField(encrypted.card.number, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decBrand: await decryptCipherField(encrypted.card.brand, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decExpMonth: await decryptCipherField(encrypted.card.expMonth, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decExpYear: await decryptCipherField(encrypted.card.expYear, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decCode: await decryptCipherField(encrypted.card.code, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), }; } if (encrypted.identity) { decrypted.identity = { ...encrypted.identity, - decTitle: await decryptField(encrypted.identity.title, itemEnc, itemMac), - decFirstName: await decryptField(encrypted.identity.firstName, itemEnc, itemMac), - decMiddleName: await decryptField(encrypted.identity.middleName, itemEnc, itemMac), - decLastName: await decryptField(encrypted.identity.lastName, itemEnc, itemMac), - decUsername: await decryptField(encrypted.identity.username, itemEnc, itemMac), - decCompany: await decryptField(encrypted.identity.company, itemEnc, itemMac), - decSsn: await decryptField(encrypted.identity.ssn, itemEnc, itemMac), - decPassportNumber: await decryptField(encrypted.identity.passportNumber, itemEnc, itemMac), - decLicenseNumber: await decryptField(encrypted.identity.licenseNumber, itemEnc, itemMac), - decEmail: await decryptField(encrypted.identity.email, itemEnc, itemMac), - decPhone: await decryptField(encrypted.identity.phone, itemEnc, itemMac), - decAddress1: await decryptField(encrypted.identity.address1, itemEnc, itemMac), - decAddress2: await decryptField(encrypted.identity.address2, itemEnc, itemMac), - decAddress3: await decryptField(encrypted.identity.address3, itemEnc, itemMac), - decCity: await decryptField(encrypted.identity.city, itemEnc, itemMac), - decState: await decryptField(encrypted.identity.state, itemEnc, itemMac), - decPostalCode: await decryptField(encrypted.identity.postalCode, itemEnc, itemMac), - decCountry: await decryptField(encrypted.identity.country, itemEnc, itemMac), + decTitle: await decryptCipherField(encrypted.identity.title, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decFirstName: await decryptCipherField(encrypted.identity.firstName, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decMiddleName: await decryptCipherField(encrypted.identity.middleName, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decLastName: await decryptCipherField(encrypted.identity.lastName, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decUsername: await decryptCipherField(encrypted.identity.username, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decCompany: await decryptCipherField(encrypted.identity.company, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decSsn: await decryptCipherField(encrypted.identity.ssn, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decPassportNumber: await decryptCipherField(encrypted.identity.passportNumber, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decLicenseNumber: await decryptCipherField(encrypted.identity.licenseNumber, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decEmail: await decryptCipherField(encrypted.identity.email, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decPhone: await decryptCipherField(encrypted.identity.phone, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decAddress1: await decryptCipherField(encrypted.identity.address1, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decAddress2: await decryptCipherField(encrypted.identity.address2, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decAddress3: await decryptCipherField(encrypted.identity.address3, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decCity: await decryptCipherField(encrypted.identity.city, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decState: await decryptCipherField(encrypted.identity.state, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decPostalCode: await decryptCipherField(encrypted.identity.postalCode, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decCountry: await decryptCipherField(encrypted.identity.country, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), }; } @@ -93,11 +114,11 @@ export async function decryptSingleCipher( const fingerprint = encrypted.sshKey.keyFingerprint || encrypted.sshKey.fingerprint || ''; decrypted.sshKey = { ...encrypted.sshKey, - decPrivateKey: await decryptField(encrypted.sshKey.privateKey, itemEnc, itemMac), - decPublicKey: await decryptField(encrypted.sshKey.publicKey, itemEnc, itemMac), + decPrivateKey: await decryptCipherField(encrypted.sshKey.privateKey, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decPublicKey: await decryptCipherField(encrypted.sshKey.publicKey, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), keyFingerprint: fingerprint || null, fingerprint: fingerprint || null, - decFingerprint: await decryptField(fingerprint, itemEnc, itemMac), + decFingerprint: await decryptCipherField(fingerprint, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), }; } @@ -105,8 +126,8 @@ export async function decryptSingleCipher( decrypted.fields = await Promise.all( encrypted.fields.map(async (field) => ({ ...field, - decName: await decryptField(field.name, itemEnc, itemMac), - decValue: await decryptField(field.value, itemEnc, itemMac), + decName: await decryptCipherField(field.name, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decValue: await decryptCipherField(field.value, itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), })) ); } diff --git a/webapp/src/lib/vault-decrypt.ts b/webapp/src/lib/vault-decrypt.ts index 4fd09a4..edae72f 100644 --- a/webapp/src/lib/vault-decrypt.ts +++ b/webapp/src/lib/vault-decrypt.ts @@ -42,6 +42,30 @@ async function decryptField( } } +async function decryptCipherField( + value: string | null | undefined, + itemEnc: Uint8Array, + itemMac: Uint8Array, + userEnc: Uint8Array, + userMac: Uint8Array, + canFallbackToUserKey: boolean +): Promise { + if (!value || typeof value !== 'string') return ''; + try { + return await decryptStr(value, itemEnc, itemMac); + } catch { + // Try the legacy user-key path for mixed key/field ciphers. + } + if (canFallbackToUserKey) { + try { + return await decryptStr(value, userEnc, userMac); + } catch { + // Preserve the old raw fallback for fields that are genuinely unreadable. + } + } + return value; +} + async function decryptFieldWithSource( value: string | null | undefined, itemEnc: Uint8Array, @@ -82,32 +106,38 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise { let itemEnc = userEnc; let itemMac = userMac; + let usesItemKey = false; if (cipher.key) { try { const itemKey = await decryptBw(cipher.key, userEnc, userMac); - itemEnc = itemKey.slice(0, 32); - itemMac = itemKey.slice(32, 64); + if (itemKey.length >= 64) { + itemEnc = itemKey.slice(0, 32); + itemMac = itemKey.slice(32, 64); + usesItemKey = true; + } } catch { // Keep user key fallback. } } + const itemUsesUserKey = sameBytes(itemEnc, userEnc) && sameBytes(itemMac, userMac); + const canFallbackToUserKey = usesItemKey; const nextCipher: Cipher = { ...cipher, - decName: await decryptField(cipher.name || '', itemEnc, itemMac), - decNotes: await decryptField(cipher.notes || '', itemEnc, itemMac), + decName: await decryptCipherField(cipher.name || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decNotes: await decryptCipherField(cipher.notes || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), }; if (cipher.login) { nextCipher.login = { ...cipher.login, - decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac), - decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac), - decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac), + decUsername: await decryptCipherField(cipher.login.username || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decPassword: await decryptCipherField(cipher.login.password || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decTotp: await decryptCipherField(cipher.login.totp || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), uris: await Promise.all( (cipher.login.uris || []).map(async (uri) => ({ ...uri, - decUri: await decryptField(uri.uri || '', itemEnc, itemMac), + decUri: await decryptCipherField(uri.uri || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), })) ), }; @@ -117,7 +147,7 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise ({ ...entry, - decPassword: await decryptField(entry?.password || '', itemEnc, itemMac), + decPassword: await decryptCipherField(entry?.password || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), })) ); } @@ -125,36 +155,36 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise ({ ...field, - decName: await decryptField(field.name || '', itemEnc, itemMac), - decValue: await decryptField(field.value || '', itemEnc, itemMac), + decName: await decryptCipherField(field.name || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), + decValue: await decryptCipherField(field.value || '', itemEnc, itemMac, userEnc, userMac, canFallbackToUserKey), })) ); }