Aug 26 - Enhance security
CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled

This commit is contained in:
2026-08-26 10:37:00 -04:00
parent 0295fac3fa
commit 0304095e53
9 changed files with 502 additions and 112 deletions
+98 -12
View File
@@ -159,17 +159,54 @@ const Recover = (() => {
}
/**
* Compute the HMAC-SHA256 recovery proof.
* proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce_bytes)
* Derive the recovery verifier: 256 bits of PBKDF2 over the recovery code,
* salted with a domain-separated label plus the user's email, as 64 hex chars.
*
* This proves to the server that the client correctly decrypted the recovery
* blob (and therefore holds the right recovery code) without transmitting
* enc_key_salt in plaintext.
* This is the HMAC key for the recovery challenge under the current scheme.
* It depends on the recovery code alone and is used for nothing else, so it
* cannot be derived from enc_key_salt (which the server hands to any client
* that clears the password factor).
*
* Must stay byte-identical to _deriveRecoveryVerifier() in vault.js.
*/
async function computeRecoveryProof(encKeySalt, nonce) {
async function deriveRecoveryVerifier(recoveryCode, email) {
const baseKey = await subtle.importKey(
"raw",
strToBytes(recoveryCode),
"PBKDF2",
false,
["deriveBits"],
);
const bits = await subtle.deriveBits(
{
name: "PBKDF2",
salt: strToBytes(
"passkeeper-recovery-verifier:" + email.toLowerCase(),
),
iterations: 200_000,
hash: "SHA-256",
},
baseKey,
256,
);
return Array.from(new Uint8Array(bits))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/**
* Compute the HMAC-SHA256 recovery proof: HMAC(key=proofKey, msg=nonce).
*
* proofKey is the recovery verifier for codes generated under the current
* scheme, or — for codes predating it — the enc_key_salt decrypted out of the
* recovery blob. The server tells us which via `proof_scheme` on
* /recovery/data. Either way the proof demonstrates possession of the
* recovery code without transmitting anything reusable.
*/
async function computeRecoveryProof(proofKey, nonce) {
const keyMaterial = await subtle.importKey(
"raw",
strToBytes(encKeySalt),
strToBytes(proofKey),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
@@ -245,9 +282,15 @@ const Recover = (() => {
return;
}
// Compute HMAC-SHA256 proof: proves we correctly decrypted the blob
// without sending enc_key_salt in plaintext.
const proof = await computeRecoveryProof(decryptedEncKeySalt, data.nonce);
// Key the proof on the recovery verifier when the account has one.
// Legacy accounts (recovery code created before recovery_verifier existed)
// still key it on the decrypted enc_key_salt; regenerating the code from
// Settings migrates them.
const proofKey =
data.proof_scheme === "verifier"
? await deriveRecoveryVerifier(rawCode, email)
: decryptedEncKeySalt;
const proof = await computeRecoveryProof(proofKey, data.nonce);
// Derive the old vault key using the recovery code as master password proxy
_oldVaultKey = await Crypto.deriveVaultKey(rawCode, decryptedEncKeySalt);
@@ -340,9 +383,20 @@ const Recover = (() => {
},
);
if (!itemsRes.ok) {
showError(
"recover-error-2",
"Could not load your vault items. Please restart the recovery process.",
);
return;
}
let reEncryptedItems = [];
if (itemsRes.ok) {
let totalItems = 0;
const failedItemIds = [];
{
const itemsData = await itemsRes.json();
totalItems = itemsData.items.length;
// Re-encrypt each item: old vault key → new vault key
for (const item of itemsData.items) {
try {
@@ -369,12 +423,43 @@ const Recover = (() => {
}
reEncryptedItems.push({ id: item.id, enc_data, iv, ...encNamePayload });
} catch {
// Item decryption failed — skip (shouldn't happen if recovery code is correct)
// Item decryption failed — record it. The server refuses to rotate
// the key unless every item is covered, so we must either resolve
// this or have the user explicitly accept losing these items.
failedItemIds.push(item.id);
console.warn(`Could not re-encrypt item ${item.id}`);
}
}
}
// Completing recovery rotates enc_key_salt, which permanently orphans any
// item still encrypted under the old key. Unlike the change-password flow
// we cannot simply refuse — the user is locked out of their account and
// has no other way in — so we surface the exact cost and let them decide.
let allowPartial = false;
if (reEncryptedItems.length !== totalItems) {
const lost = totalItems - reEncryptedItems.length;
const proceed = confirm(
`${lost} of your ${totalItems} vault item(s) could not be decrypted ` +
`with this recovery code and cannot be carried over.
` +
`Continuing will recover your account and the other ` +
`${reEncryptedItems.length} item(s), but those ${lost} item(s) will ` +
`be permanently unreadable.
Continue with recovery?`,
);
if (!proceed) {
showError(
"recover-error-2",
"Recovery cancelled. Your account is unchanged.",
);
return;
}
allowPartial = true;
}
// Submit recovery
const recoverRes = await fetch("/api/auth/recover", {
method: "POST",
@@ -385,6 +470,7 @@ const Recover = (() => {
new_enc_key_salt: newEncKeySalt,
recovery_proof: _recoveryProof,
items: reEncryptedItems,
allow_partial: allowPartial,
}),
});