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.
|
// Single source of truth for /config.version and /api/version.
|
||||||
// /config.version 与 /api/version 的统一版本号来源。
|
// /config.version 与 /api/version 的统一版本号来源。
|
||||||
bitwardenServerVersion: '2026.4.1',
|
bitwardenServerVersion: '2026.4.1',
|
||||||
// Advertise official per-cipher item-key encryption support only after
|
// Official 2026.4.x clients need this flag to receive and use cipher.key.
|
||||||
// NodeWarden can guarantee key/field consistency across all write paths.
|
// Hiding existing item keys makes item-key encrypted vault data unreadable.
|
||||||
// 在所有写入路径都能保证 cipher.key 与字段密文一致之前,不向官方客户端声明支持逐项密钥加密。
|
// 官方 2026.4.x 客户端需要该开关来接收并使用 cipher.key。
|
||||||
cipherKeyEncryptionFeatureEnabled: false,
|
// 隐藏已有逐项密钥会导致逐项密钥加密的密码库数据无法解密。
|
||||||
|
cipherKeyEncryptionFeatureEnabled: true,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
Attachment,
|
Attachment,
|
||||||
PasswordHistory,
|
PasswordHistory,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { LIMITS } from '../config/limits';
|
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
@@ -131,12 +130,10 @@ function optionalEncString(value: unknown): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldAcceptCipherKey(value: unknown): boolean {
|
function shouldAcceptCipherKey(value: unknown): boolean {
|
||||||
if (LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled) return true;
|
return value == null || value === '' || isValidEncString(value);
|
||||||
return optionalEncString(value) === null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCipherKeyForStorage(value: unknown): string | null {
|
function normalizeCipherKeyForStorage(value: unknown): string | null {
|
||||||
if (!LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled) return null;
|
|
||||||
return optionalEncString(value);
|
return optionalEncString(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,9 +559,7 @@ export function cipherToResponse(
|
|||||||
): CipherResponse {
|
): CipherResponse {
|
||||||
// Strip internal-only fields that must not appear in the API response
|
// Strip internal-only fields that must not appear in the API response
|
||||||
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
|
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
|
||||||
const responseCipherKey = LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled
|
const responseCipherKey = optionalEncString(cipher.key);
|
||||||
? optionalEncString(cipher.key)
|
|
||||||
: null;
|
|
||||||
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, !!responseCipherKey);
|
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, !!responseCipherKey);
|
||||||
const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']);
|
const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']);
|
||||||
const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [
|
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) {
|
if (incomingFolderId.present) {
|
||||||
cipher.folderId = normalizeOptionalId(incomingFolderId.value);
|
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.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.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;
|
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 { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin';
|
||||||
import { getDomainRules, saveDomainRules } from '@/lib/api/domains';
|
import { getDomainRules, saveDomainRules } from '@/lib/api/domains';
|
||||||
import { getSends } from '@/lib/api/send';
|
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 { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
|
||||||
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
||||||
import {
|
import {
|
||||||
@@ -1086,12 +1086,9 @@ export default function App() {
|
|||||||
const repairKey = `${session.accessToken}:${encryptedCiphers.map((cipher) => `${cipher.id}:${cipher.revisionDate || ''}`).join(',')}`;
|
const repairKey = `${session.accessToken}:${encryptedCiphers.map((cipher) => `${cipher.id}:${cipher.revisionDate || ''}`).join(',')}`;
|
||||||
if (uriChecksumRepairAttemptRef.current !== repairKey) {
|
if (uriChecksumRepairAttemptRef.current !== repairKey) {
|
||||||
uriChecksumRepairAttemptRef.current = repairKey;
|
uriChecksumRepairAttemptRef.current = repairKey;
|
||||||
void Promise.all([
|
void repairCipherUriChecksums(authedFetch, session, result.ciphers)
|
||||||
repairCipherKeyMismatches(authedFetch, session, result.ciphers),
|
.then((uriChecksumCount) => {
|
||||||
repairCipherUriChecksums(authedFetch, session, result.ciphers),
|
if (uriChecksumCount > 0) void refetchVaultCoreData();
|
||||||
])
|
|
||||||
.then(([keyMismatchCount, uriChecksumCount]) => {
|
|
||||||
if (keyMismatchCount + uriChecksumCount > 0) void refetchVaultCoreData();
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Best-effort compatibility repair must not interrupt normal vault loading.
|
// Best-effort compatibility repair must not interrupt normal vault loading.
|
||||||
|
|||||||
@@ -224,6 +224,56 @@ function optimisticCipherFromDraft(draft: VaultDraft, current?: Cipher | null):
|
|||||||
return next;
|
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) {
|
export default function useVaultSendActions(options: UseVaultSendActionsOptions) {
|
||||||
const {
|
const {
|
||||||
authedFetch,
|
authedFetch,
|
||||||
@@ -421,6 +471,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
|
|
||||||
async updateVaultItem(cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) {
|
async updateVaultItem(cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
if (hasUnresolvedCipherData(cipher)) {
|
||||||
|
throw new Error(t('txt_decrypt_failed_2'));
|
||||||
|
}
|
||||||
const addFiles = Array.isArray(options?.addFiles) ? options.addFiles : [];
|
const addFiles = Array.isArray(options?.addFiles) ? options.addFiles : [];
|
||||||
const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : [];
|
const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : [];
|
||||||
const previousCipher: Cipher = {
|
const previousCipher: Cipher = {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { decryptStr, decryptBw } from './crypto';
|
import { decryptStr, decryptBw } from './crypto';
|
||||||
|
import { looksLikeCipherString } from './app-support';
|
||||||
import type { Cipher } from './types';
|
import type { Cipher } from './types';
|
||||||
|
|
||||||
async function decryptCipherField(
|
async function decryptCipherField(
|
||||||
@@ -22,7 +23,7 @@ async function decryptCipherField(
|
|||||||
// Preserve the old raw fallback for fields that are genuinely unreadable.
|
// Preserve the old raw fallback for fields that are genuinely unreadable.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return value;
|
return looksLikeCipherString(value) ? '' : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decryptSingleCipher(
|
export async function decryptSingleCipher(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { base64ToBytes, decryptBw, decryptStr } from './crypto';
|
import { base64ToBytes, decryptBw, decryptStr } from './crypto';
|
||||||
import { deriveSendKeyParts } from './app-support';
|
import { deriveSendKeyParts, looksLikeCipherString } from './app-support';
|
||||||
import type { Cipher, Folder, Send } from './types';
|
import type { Cipher, Folder, Send } from './types';
|
||||||
|
|
||||||
export interface DecryptVaultCoreArgs {
|
export interface DecryptVaultCoreArgs {
|
||||||
@@ -38,7 +38,7 @@ async function decryptField(
|
|||||||
try {
|
try {
|
||||||
return await decryptStr(value, enc, mac);
|
return await decryptStr(value, enc, mac);
|
||||||
} catch {
|
} 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.
|
// Preserve the old raw fallback for fields that are genuinely unreadable.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return value;
|
return looksLikeCipherString(value) ? '' : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decryptFieldWithSource(
|
async function decryptFieldWithSource(
|
||||||
@@ -88,7 +88,7 @@ async function decryptFieldWithSource(
|
|||||||
// Keep plain fallback.
|
// Keep plain fallback.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { text: raw, source: 'plain' };
|
return { text: looksLikeCipherString(raw) ? '' : raw, source: 'plain' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<DecryptVaultCoreResult> {
|
export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<DecryptVaultCoreResult> {
|
||||||
|
|||||||
Reference in New Issue
Block a user