From fd9707c396b5ef3967dee95a6176bbbe57a4fa2b Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 31 May 2026 01:03:32 +0800 Subject: [PATCH] fix: enable cipher key encryption feature for 2026.4.x clients and streamline key handling --- src/config/limits.ts | 9 +++-- src/handlers/ciphers.ts | 16 ++++---- webapp/src/App.tsx | 11 ++--- webapp/src/hooks/useVaultSendActions.ts | 53 +++++++++++++++++++++++++ webapp/src/lib/decrypt-cipher.ts | 3 +- webapp/src/lib/vault-decrypt.ts | 8 ++-- 6 files changed, 76 insertions(+), 24 deletions(-) diff --git a/src/config/limits.ts b/src/config/limits.ts index bdfb31a..2e40bcc 100644 --- a/src/config/limits.ts +++ b/src/config/limits.ts @@ -149,9 +149,10 @@ // Single source of truth for /config.version and /api/version. // /config.version 与 /api/version 的统一版本号来源。 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, + // Official 2026.4.x clients need this flag to receive and use cipher.key. + // Hiding existing item keys makes item-key encrypted vault data unreadable. + // 官方 2026.4.x 客户端需要该开关来接收并使用 cipher.key。 + // 隐藏已有逐项密钥会导致逐项密钥加密的密码库数据无法解密。 + cipherKeyEncryptionFeatureEnabled: true, }, } as const; diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index ad3a0bb..abf7e3b 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -10,7 +10,6 @@ import { Attachment, PasswordHistory, } from '../types'; -import { LIMITS } from '../config/limits'; import { StorageService } from '../services/storage'; import { notifyUserVaultSync } from '../durable/notifications-hub'; import { jsonResponse, errorResponse } from '../utils/response'; @@ -131,12 +130,10 @@ function optionalEncString(value: unknown): string | null { } function shouldAcceptCipherKey(value: unknown): boolean { - if (LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled) return true; - return optionalEncString(value) === null; + return value == null || value === '' || isValidEncString(value); } function normalizeCipherKeyForStorage(value: unknown): string | null { - if (!LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled) return null; return optionalEncString(value); } @@ -562,9 +559,7 @@ export function cipherToResponse( ): CipherResponse { // Strip internal-only fields that must not appear in the API response const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher; - const responseCipherKey = LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled - ? optionalEncString(cipher.key) - : null; + const responseCipherKey = optionalEncString(cipher.key); const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, !!responseCipherKey); const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']); const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [ @@ -827,7 +822,12 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str if (incomingFolderId.present) { cipher.folderId = normalizeOptionalId(incomingFolderId.value); } - cipher.key = normalizeCipherKeyForStorage(incomingKey.present ? incomingKey.value : existingCipher.key); + if (incomingKey.present) { + const normalizedIncomingKey = normalizeCipherKeyForStorage(incomingKey.value); + cipher.key = normalizedIncomingKey || normalizeCipherKeyForStorage(existingCipher.key); + } else { + cipher.key = normalizeCipherKeyForStorage(existingCipher.key); + } 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; diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index fbc4273..bb55e96 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 { repairCipherKeyMismatches, repairCipherUriChecksums } from '@/lib/api/vault'; +import { repairCipherUriChecksums } from '@/lib/api/vault'; import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync'; import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; import { @@ -1086,12 +1086,9 @@ export default function App() { const repairKey = `${session.accessToken}:${encryptedCiphers.map((cipher) => `${cipher.id}:${cipher.revisionDate || ''}`).join(',')}`; if (uriChecksumRepairAttemptRef.current !== repairKey) { uriChecksumRepairAttemptRef.current = repairKey; - void Promise.all([ - repairCipherKeyMismatches(authedFetch, session, result.ciphers), - repairCipherUriChecksums(authedFetch, session, result.ciphers), - ]) - .then(([keyMismatchCount, uriChecksumCount]) => { - if (keyMismatchCount + uriChecksumCount > 0) void refetchVaultCoreData(); + void repairCipherUriChecksums(authedFetch, session, result.ciphers) + .then((uriChecksumCount) => { + if (uriChecksumCount > 0) void refetchVaultCoreData(); }) .catch(() => { // Best-effort compatibility repair must not interrupt normal vault loading. diff --git a/webapp/src/hooks/useVaultSendActions.ts b/webapp/src/hooks/useVaultSendActions.ts index 1032741..2b8f21f 100644 --- a/webapp/src/hooks/useVaultSendActions.ts +++ b/webapp/src/hooks/useVaultSendActions.ts @@ -224,6 +224,56 @@ function optimisticCipherFromDraft(draft: VaultDraft, current?: Cipher | null): return next; } +function isEncryptedFieldUnresolved(raw: unknown, decrypted: unknown): boolean { + const encrypted = String(raw || '').trim(); + if (!looksLikeCipherString(encrypted)) return false; + const plain = String(decrypted || '').trim(); + return !plain || looksLikeCipherString(plain); +} + +function hasUnresolvedCipherData(cipher: Cipher): boolean { + 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], + ]), + ]; + return checks.some(([raw, decrypted]) => isEncryptedFieldUnresolved(raw, decrypted)); +} + export default function useVaultSendActions(options: UseVaultSendActionsOptions) { const { authedFetch, @@ -421,6 +471,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) async updateVaultItem(cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) { if (!session) return; + if (hasUnresolvedCipherData(cipher)) { + throw new Error(t('txt_decrypt_failed_2')); + } const addFiles = Array.isArray(options?.addFiles) ? options.addFiles : []; const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : []; const previousCipher: Cipher = { diff --git a/webapp/src/lib/decrypt-cipher.ts b/webapp/src/lib/decrypt-cipher.ts index 525e0c9..edc4307 100644 --- a/webapp/src/lib/decrypt-cipher.ts +++ b/webapp/src/lib/decrypt-cipher.ts @@ -1,4 +1,5 @@ import { decryptStr, decryptBw } from './crypto'; +import { looksLikeCipherString } from './app-support'; import type { Cipher } from './types'; async function decryptCipherField( @@ -22,7 +23,7 @@ async function decryptCipherField( // Preserve the old raw fallback for fields that are genuinely unreadable. } } - return value; + return looksLikeCipherString(value) ? '' : value; } export async function decryptSingleCipher( diff --git a/webapp/src/lib/vault-decrypt.ts b/webapp/src/lib/vault-decrypt.ts index edae72f..05ebe67 100644 --- a/webapp/src/lib/vault-decrypt.ts +++ b/webapp/src/lib/vault-decrypt.ts @@ -1,5 +1,5 @@ import { base64ToBytes, decryptBw, decryptStr } from './crypto'; -import { deriveSendKeyParts } from './app-support'; +import { deriveSendKeyParts, looksLikeCipherString } from './app-support'; import type { Cipher, Folder, Send } from './types'; export interface DecryptVaultCoreArgs { @@ -38,7 +38,7 @@ async function decryptField( try { return await decryptStr(value, enc, mac); } catch { - return value; + return looksLikeCipherString(value) ? '' : value; } } @@ -63,7 +63,7 @@ async function decryptCipherField( // Preserve the old raw fallback for fields that are genuinely unreadable. } } - return value; + return looksLikeCipherString(value) ? '' : value; } async function decryptFieldWithSource( @@ -88,7 +88,7 @@ async function decryptFieldWithSource( // Keep plain fallback. } } - return { text: raw, source: 'plain' }; + return { text: looksLikeCipherString(raw) ? '' : raw, source: 'plain' }; } export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise {