fix: enable cipher key encryption feature for 2026.4.x clients and streamline key handling

This commit is contained in:
shuaiplus
2026-05-31 01:03:32 +08:00
parent 192071e4a7
commit fd9707c396
6 changed files with 76 additions and 24 deletions
+5 -4
View File
@@ -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;
+8 -8
View File
@@ -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;
+4 -7
View File
@@ -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.
+53
View File
@@ -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 = {
+2 -1
View File
@@ -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(
+4 -4
View File
@@ -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<DecryptVaultCoreResult> {