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
+90 -8
View File
@@ -3554,12 +3554,17 @@ const Vault = (() => {
btn.textContent = `Re-encrypting ${items.length} item(s)…`;
const reEncrypted = [];
const failedIds = [];
for (const item of items) {
const plain = await Crypto.decryptItem(
vaultKey,
item.enc_data,
item.iv,
);
let plain;
try {
plain = await Crypto.decryptItem(vaultKey, item.enc_data, item.iv);
} catch {
// Cannot re-encrypt what we cannot read. Collect and abort below —
// rotating the key regardless would orphan this item permanently.
failedIds.push(item.id);
continue;
}
const { enc_data, iv } = await Crypto.encryptItem(newVaultKey, plain);
// Re-encrypt the name if it was previously encrypted.
let encNamePayload = {};
@@ -3580,6 +3585,19 @@ const Vault = (() => {
reEncrypted.push({ id: item.id, enc_data, iv, ...encNamePayload });
}
// Refuse to rotate unless every item was re-encrypted. Unlike recovery
// there is no lockout risk in stopping here — the current password keeps
// working — so this fails closed with no partial-completion escape hatch.
if (failedIds.length || reEncrypted.length !== items.length) {
cpError.textContent =
`Password not changed: ${failedIds.length || items.length - reEncrypted.length} of ` +
`${items.length} item(s) could not be re-encrypted. Continuing would ` +
`permanently lock them. Reload the vault and try again — if this ` +
`persists, export your vault before retrying.`;
cpError.classList.remove("hidden");
return;
}
// Re-encrypt sharing private key with new vault key so sharing stays functional.
// The private key is stored as AES-GCM ciphertext on the server; rotating the
// vault key without re-encrypting it would leave it permanently unreadable.
@@ -3644,7 +3662,10 @@ const Vault = (() => {
showToast("Password changed. Please log in again.");
setTimeout(() => redirectToLogin(), 1500);
} catch (err) {
cpError.textContent = "An error occurred: " + err.message;
// The server runs the same completeness check and answers 409 if the
// payload was short; show its message rather than burying it.
cpError.textContent =
err.status === 409 ? err.message : "An error occurred: " + err.message;
cpError.classList.remove("hidden");
console.error(err);
} finally {
@@ -3663,7 +3684,16 @@ const Vault = (() => {
const statusEl = document.getElementById("recovery-status-text");
const actionsEl = document.getElementById("recovery-actions");
if (data.recovery_configured) {
if (data.recovery_configured && data.recovery_is_legacy) {
// Pre-verifier recovery code: its challenge proof is still keyed on
// enc_key_salt, which the server discloses at login. Regenerating
// rebinds the proof to a value derived from the recovery code alone.
statusEl.textContent =
"⚠ Your recovery code uses an outdated verification method. " +
"Generate a new one to secure it — your current code keeps working until you do.";
actionsEl.innerHTML =
'<button class="btn-primary" id="btn-regen-recovery">Generate new recovery code</button>';
} else if (data.recovery_configured) {
statusEl.textContent =
"✅ A recovery code is configured for your account.";
actionsEl.innerHTML =
@@ -3753,10 +3783,23 @@ const Vault = (() => {
const recovery_enc_salt = bytesToBase64(ciphertext);
const recovery_iv = bytesToBase64(iv);
// Derive the recovery verifier — the HMAC key the server uses for the
// recovery challenge-response. It comes from the recovery code alone and
// is used for nothing else, so it never doubles as key material.
// Must stay byte-identical to deriveRecoveryVerifier() in recover.js.
const recovery_verifier = await _deriveRecoveryVerifier(
recoveryCode,
userEmail,
);
// Store on server
const res = await apiFetch("/api/auth/recovery/setup", {
method: "POST",
body: JSON.stringify({ recovery_enc_salt, recovery_iv }),
body: JSON.stringify({
recovery_enc_salt,
recovery_iv,
recovery_verifier,
}),
});
if (!res) return;
@@ -4532,6 +4575,45 @@ const Vault = (() => {
.catch(() => {});
}
/**
* Derive the recovery verifier: 256 bits of PBKDF2 over the recovery code,
* salted with a domain-separated label plus the user's email, returned as 64
* lowercase hex characters.
*
* The server stores this and keys the recovery challenge HMAC with it. It is
* deliberately independent of enc_key_salt — enc_key_salt is the vault-key
* PBKDF2 salt and is disclosed to the client at login, so using it as the
* proof key let anyone with the master password forge a proof and pull the
* whole vault from /recovery/items without a second factor.
*
* recover.js has a byte-identical copy. Changing the label or iteration count
* in one place without the other invalidates every existing recovery code.
*/
async function _deriveRecoveryVerifier(recoveryCode, email) {
const baseKey = await window.crypto.subtle.importKey(
"raw",
new TextEncoder().encode(recoveryCode),
"PBKDF2",
false,
["deriveBits"],
);
const bits = await window.crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt: new TextEncoder().encode(
"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("");
}
function escHtml(str) {
return String(str)
.replace(/&/g, "&amp;")