mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
fix: enable cipher key encryption feature for 2026.4.x clients and streamline key handling
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user