fix: repair mixed cipher key encryption handling

This commit is contained in:
shuaiplus
2026-05-23 12:36:22 +08:00
parent a63336764f
commit 03f7fbf601
7 changed files with 432 additions and 87 deletions
+287 -1
View File
@@ -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<string, unknown> => !!item && typeof item === 'object')
: [];
const seenUris = new Set<string>();
const uris = (cipher.login.uris || [])
.map((entry) => {
const uri = plainCipherValue(entry.decUri, entry.uri).trim();
const extra = { ...(entry as Record<string, unknown>) };
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<string>();
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<boolean> {
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<number> {
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,