Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 | import { PassmanCrypto } from "../PassmanCrypto";
import { PAYLOAD_FIELDS } from "./types";
export function getPreferredCandidate(candidates) {
return candidates.find((candidate) => candidate.preferred) ?? candidates[0];
}
export function orderPreferredFirst(candidates, preferredKeyId) {
const preferred = candidates.filter((candidate) => candidate.id === preferredKeyId);
const rest = candidates.filter((candidate) => candidate.id !== preferredKeyId);
return [...preferred, ...rest];
}
export function requireCandidate(candidates, id, description) {
const candidate = id !== undefined ? candidates.find((c) => c.id === id) : undefined;
if (!candidate) {
throw new Error(`No ${description} key candidate found (id: ${id}).`);
}
return candidate;
}
export function getPresentPayloadFields(encrypted) {
return PAYLOAD_FIELDS.filter((field) => {
const value = encrypted[field];
if (value === undefined || value === null) {
return false;
}
// unencrypted `"null"` sentinel is handled separately; do not probe it as ciphertext
return !(field === 'compromised' && value === 'null');
});
}
/**
* Past bug: `compromised` was stored as the unencrypted sentinel string `"null"` (same shape as a missing `shared_key`) instead of ciphertext.
* Decrypt-side callers treat this as `false`; repair persists encrypted `false`.
*/
export function isUnencryptedCompromisedANullString(encrypted) {
return encrypted.compromised === 'null';
}
function applyUnencryptedNullCompromisedDefault(encrypted, decryptedData) {
if (isUnencryptedCompromisedANullString(encrypted)) {
decryptedData.compromised = false;
}
}
export function rawDecryptJsonField(encrypted, field, key) {
if (field === 'compromised' && isUnencryptedCompromisedANullString(encrypted)) {
return false;
}
const ciphertext = encrypted[field];
if (ciphertext === undefined || ciphertext === null) {
return null;
}
if (typeof ciphertext !== 'string') {
throw new Error('Ciphertext is not a string');
}
return JSON.parse(PassmanCrypto.decryptString(ciphertext, key));
}
export function emptyPayloadResult(label) {
const recoveredPreview = {};
if (label !== undefined) {
recoveredPreview.label = label;
}
return { fieldKeyIds: {}, recoveredPreview, brokenFields: [], coercedCompromised: false };
}
function finishPayloadDecrypt(encrypted, result) {
if (isUnencryptedCompromisedANullString(encrypted)) {
result.recoveredPreview.compromised = false;
result.coercedCompromised = true;
}
return result;
}
/**
* Tries each present payload field against the given candidates in order.
* Mixed keys are allowed: a field keeps the first candidate that decrypts it.
* Fields that decrypt with none are listed in `brokenFields`.
*/
export function decryptPayloadPerCandidate(encrypted, candidates, label) {
const result = emptyPayloadResult(label);
for (const field of getPresentPayloadFields(encrypted)) {
let decrypted = false;
for (const candidate of candidates) {
try {
// @ts-ignore transitively gated by PAYLOAD_FIELDS / ENCRYPTED_FIELDS
result.recoveredPreview[field] = rawDecryptJsonField(encrypted, field, candidate.password);
result.fieldKeyIds[field] = candidate.id;
decrypted = true;
break;
}
catch (e) {
// try next candidate
}
}
if (!decrypted) {
result.brokenFields.push(field);
}
}
return finishPayloadDecrypt(encrypted, result);
}
/**
* Decrypts present payload fields with a single payload key (shared_key plaintext or ACL key).
*/
export function decryptPayloadWithKey(encrypted, payloadKey, payloadKeyId, label) {
const result = emptyPayloadResult(label);
for (const field of getPresentPayloadFields(encrypted)) {
try {
// @ts-ignore gated by PAYLOAD_FIELDS / ENCRYPTED_FIELDS
result.recoveredPreview[field] = rawDecryptJsonField(encrypted, field, payloadKey);
result.fieldKeyIds[field] = payloadKeyId;
}
catch (e) {
result.brokenFields.push(field);
}
}
return finishPayloadDecrypt(encrypted, result);
}
/**
* Decrypts each present payload field with `payloadKey` first (the key a normal client would use),
* then with vault candidates for anything that key does not open.
* `payloadKeyId` is stored on fields the first key opened (for example `__shared_key__`).
*/
export function decryptPayloadWithKeyThenCandidates(encrypted, payloadKey, payloadKeyId, candidates, label) {
const result = emptyPayloadResult(label);
for (const field of getPresentPayloadFields(encrypted)) {
let decrypted = false;
if (payloadKey !== undefined && payloadKey !== '') {
try {
// @ts-ignore gated by PAYLOAD_FIELDS / ENCRYPTED_FIELDS
result.recoveredPreview[field] = rawDecryptJsonField(encrypted, field, payloadKey);
result.fieldKeyIds[field] = payloadKeyId;
decrypted = true;
}
catch (e) {
// field is not under the client-preferred key; try vault candidates below
}
}
if (!decrypted) {
for (const candidate of candidates) {
try {
// @ts-ignore transitively gated by PAYLOAD_FIELDS / ENCRYPTED_FIELDS
result.recoveredPreview[field] = rawDecryptJsonField(encrypted, field, candidate.password);
result.fieldKeyIds[field] = candidate.id;
decrypted = true;
break;
}
catch (e) {
// try next candidate
}
}
}
if (!decrypted) {
result.brokenFields.push(field);
}
}
return finishPayloadDecrypt(encrypted, result);
}
/**
* Decrypts recoverable payload fields for repair.
*
* Uses {@link CredentialRecoveryHitInterface.fieldKeyIds} when present (partial / mixed-key);
* otherwise decrypts every payload field with `hit.workingKeyId` and throws on the first failure.
* `extraKeys` maps synthetic ids such as `__shared_key__` to the plaintext those fields were encrypted with.
*/
export function decryptRecoverablePayload(encrypted, hit, candidates, extraKeys) {
const decryptedData = {};
const fieldIds = hit.fieldKeyIds;
if (fieldIds !== undefined && Object.keys(fieldIds).length > 0) {
for (const field of Object.keys(fieldIds)) {
const keyId = fieldIds[field];
const key = extraKeys?.[keyId] ?? requireCandidate(candidates, keyId, 'field').password;
// @ts-ignore gated by PAYLOAD_FIELDS
decryptedData[field] = rawDecryptJsonField(encrypted, field, key);
}
applyUnencryptedNullCompromisedDefault(encrypted, decryptedData);
return decryptedData;
}
const fromCandidate = requireCandidate(candidates, hit.workingKeyId, 'working');
for (const field of PAYLOAD_FIELDS) {
// @ts-ignore gated by PAYLOAD_FIELDS
decryptedData[field] = rawDecryptJsonField(encrypted, field, fromCandidate.password);
}
applyUnencryptedNullCompromisedDefault(encrypted, decryptedData);
return decryptedData;
}
|