/** * recover.js — Account recovery flow * * Step 1: User provides email + recovery code. * - Fetch recovery data (recovery_enc_salt, recovery_iv, nonce) from server. * NOTE: enc_key_salt is NOT returned here — the client must derive it by * decrypting the recovery blob with the recovery code. * - Derive recovery key from the recovery code (PBKDF2). * - Decrypt recovery_enc_salt using the recovery key → decrypted enc_key_salt. * - If decryption succeeds, store decrypted enc_key_salt in module state → show step 2. * * Step 2: User provides new master password. * - Derive new vault key from new password + new random enc_key_salt. * - Fetch all vault items (still encrypted with old vault key). * - Decrypt each item with old vault key (derived from old enc_key_salt + new password * would fail — instead we re-derive old vault key from old enc_key_salt + new password * which won't work either). Correct path: * OLD vault key = PBKDF2(old_master_password, old_enc_key_salt) * But we don't have the old master password. The recovery path therefore * cannot re-encrypt vault items with a new key unless it can derive the OLD vault key. * * Recovery key design: * recovery_key = PBKDF2(recovery_code, "passkeeper-recovery", 200_000 iter) * encrypted blob = AES-GCM(recovery_key, enc_key_salt) * * After decrypting enc_key_salt, the user sets a new master password: * new_auth_hash = PBKDF2(new_password, email, 100_000) * new_enc_key_salt = random 16 bytes * new_vault_key = PBKDF2(new_password, new_enc_key_salt, 600_000) * * Vault items are re-encrypted using old_vault_key → new_vault_key: * old_vault_key = PBKDF2(recovery_code, old_enc_key_salt, 600_000) * — This is the KEY INSIGHT: the recovery code acts as a stand-in master password * ONLY for the purpose of re-deriving the old vault key, since the recovery_key * already proved the recovery code is correct by successfully decrypting enc_key_salt. * * The server verifies the recovery code is correct via `recovery_proof`: * recovery_proof = old_enc_key_salt (plaintext) * If the client decrypted recovery_enc_salt correctly, it will have the true enc_key_salt. * The server compares recovery_proof === user.enc_key_salt. */ const Recover = (() => { const subtle = window.crypto.subtle; // Module state between step 1 and step 2 let _email = null; let _recoveryCode = null; let _oldEncKeySalt = null; // decrypted from recovery blob let _oldVaultKey = null; // derived for re-encrypting vault items let _recoveryProof = null; // HMAC-SHA256(enc_key_salt_bytes, nonce) — sent as proof // ── Helpers ──────────────────────────────────────────────────────────────── function strToBytes(str) { return new TextEncoder().encode(str); } function base64ToBytes(b64) { const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes; } function bytesToBase64(bytes) { let bin = ""; bytes.forEach((b) => (bin += String.fromCharCode(b))); return btoa(bin); } function formatRecoveryCode(raw) { // Display as groups of 4 for readability return raw.match(/.{1,4}/g)?.join("-") ?? raw; } function cleanRecoveryCode(input) { // Strip hyphens/spaces so users can paste formatted or raw codes return input.replace(/[-\s]/g, "").toLowerCase(); } function showError(id, message) { const el = document.getElementById(id); if (el) { el.textContent = message; el.classList.remove("hidden"); } } function hideError(id) { const el = document.getElementById(id); if (el) el.classList.add("hidden"); } function setLoading(btn, loading) { btn.disabled = loading; btn.textContent = loading ? btn.dataset.loadingText || "Please wait…" : btn.dataset.originalText || btn.textContent; } // ── Crypto ───────────────────────────────────────────────────────────────── /** * Derive an AES-256-GCM key from the recovery code using PBKDF2. * `salt` should be the user's email (per-user uniqueness). * Falls back to the legacy fixed salt for backward compatibility. */ async function deriveRecoveryKey(recoveryCode, salt = "passkeeper-recovery") { const baseKey = await subtle.importKey( "raw", strToBytes(recoveryCode), "PBKDF2", false, ["deriveKey"], ); return subtle.deriveKey( { name: "PBKDF2", salt: strToBytes(salt), iterations: 200_000, hash: "SHA-256", }, baseKey, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], ); } /** * Encrypt enc_key_salt (string) with the recovery key. * Returns { recovery_enc_salt: base64, recovery_iv: base64 } */ async function encryptEncKeySalt(recoveryKey, encKeySalt) { const iv = window.crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await subtle.encrypt( { name: "AES-GCM", iv }, recoveryKey, strToBytes(encKeySalt), ); return { recovery_enc_salt: bytesToBase64(new Uint8Array(ciphertext)), recovery_iv: bytesToBase64(iv), }; } /** * Decrypt recovery_enc_salt to recover the original enc_key_salt string. * Throws DOMException if the recovery code is wrong (GCM auth tag mismatch). */ async function decryptEncKeySalt(recoveryKey, recoveryEncSalt, recoveryIv) { const plaintext = await subtle.decrypt( { name: "AES-GCM", iv: base64ToBytes(recoveryIv) }, recoveryKey, base64ToBytes(recoveryEncSalt), ); return new TextDecoder().decode(plaintext); } /** * 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 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 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(proofKey), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const signature = await subtle.sign("HMAC", keyMaterial, strToBytes(nonce)); // Convert to hex string to match Python's hmac.hexdigest() return Array.from(new Uint8Array(signature)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } // ── Step 1: Verify recovery code ─────────────────────────────────────────── async function handleStep1(e) { e.preventDefault(); hideError("recover-error-1"); const btn = e.target.querySelector('[type="submit"]'); btn.dataset.originalText = btn.textContent; setLoading(btn, true); try { const email = document .getElementById("recover-email") .value.trim() .toLowerCase(); const rawCode = cleanRecoveryCode( document.getElementById("recover-code").value.trim(), ); if (!email || !rawCode) { showError("recover-error-1", "Email and recovery code are required."); return; } // Fetch recovery blobs from server const res = await fetch( `/api/auth/recovery/data?email=${encodeURIComponent(email)}`, ); if (!res.ok) { showError( "recover-error-1", "No recovery code found for this account.", ); return; } const data = await res.json(); // Attempt to decrypt enc_key_salt using the recovery code. // Try email-as-salt first (new format), fall back to the legacy fixed salt // so that recovery codes generated before this fix still work. let decryptedEncKeySalt; try { const keyWithEmail = await deriveRecoveryKey(rawCode, email); try { decryptedEncKeySalt = await decryptEncKeySalt( keyWithEmail, data.recovery_enc_salt, data.recovery_iv, ); } catch { const keyLegacy = await deriveRecoveryKey(rawCode); decryptedEncKeySalt = await decryptEncKeySalt( keyLegacy, data.recovery_enc_salt, data.recovery_iv, ); } } catch { showError( "recover-error-1", "Invalid recovery code. Please check and try again.", ); return; } // 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); _email = email; _recoveryCode = rawCode; _oldEncKeySalt = decryptedEncKeySalt; _recoveryProof = proof; // Show step 2 document.getElementById("recover-step-1").classList.add("hidden"); document.getElementById("recover-step-2").classList.remove("hidden"); document.getElementById("recover-new-pass").focus(); } catch (err) { showError( "recover-error-1", "An unexpected error occurred. Please try again.", ); console.error(err); } finally { setLoading(btn, false); } } // ── Step 2: Set new password + re-encrypt vault ──────────────────────────── async function handleStep2(e) { e.preventDefault(); hideError("recover-error-2"); const btn = e.target.querySelector('[type="submit"]'); btn.dataset.originalText = btn.textContent; setLoading(btn, true); try { const newPassword = document.getElementById("recover-new-pass").value; const confirmPassword = document.getElementById( "recover-confirm-pass", ).value; if (newPassword !== confirmPassword) { showError("recover-error-2", "Passwords do not match."); return; } if (newPassword.length < 12) { showError( "recover-error-2", "Password must be at least 12 characters.", ); return; } // Derive new credentials const newAuthHash = await Crypto.deriveAuthHash(newPassword, _email); const newEncKeySalt = Crypto.generateSalt(16); const newVaultKey = await Crypto.deriveVaultKey( newPassword, newEncKeySalt, ); // Fetch all vault items (encrypted with old vault key) // We use a minimal unauthenticated fetch here — items are still ciphertext on the wire. // We need a temporary token. Since we haven't authenticated yet, we use recovery_proof // to get a token from the /recover endpoint directly. // For the item fetch step, we issue the recovery call with empty items first to get tokens, // then re-encrypt. However, to keep this atomic, we fetch items via a pre-recovery token. // Simpler correct approach: fetch items as part of the /recover payload. // We'll get a session token only after /recover succeeds. So we must send items inline. // Fetch vault items using a preliminary unauthenticated endpoint is not ideal. // Instead: call /recover with items=[] to get a session token, fetch items, re-encrypt, // then call /api/auth/change-password to update. But that's two round trips and not atomic. // Correct atomic approach: use the recovery endpoint directly with all re-encrypted items. // To fetch items without auth, we need to log in with the old vault key... which we can't. // Solution: the /recover endpoint issues tokens. We fetch items BEFORE calling /recover // using no auth (items are ciphertext anyway, safe to expose to the authenticated session), // OR we make /recover accept an optional items array and handle both cases. // Implemented here: call /recover with items included. // But we need items to re-encrypt first. To get items, we must be authenticated. // We solve this by having the server issue a temporary session from /recover/data endpoint, // or more practically: fetch items unauthenticated with just the email to get encrypted blobs. // Since items are ciphertext and we verify recovery code server-side, this is acceptable. // Fetch items unauthenticated via a recovery-scoped endpoint const itemsRes = await fetch( `/api/auth/recovery/items?email=${encodeURIComponent(_email)}`, { headers: { "X-Recovery-Proof": _recoveryProof }, }, ); if (!itemsRes.ok) { showError( "recover-error-2", "Could not load your vault items. Please restart the recovery process.", ); return; } let reEncryptedItems = []; 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 { const plain = await Crypto.decryptItem( _oldVaultKey, item.enc_data, item.iv, ); const { enc_data, iv } = await Crypto.encryptItem(newVaultKey, plain); let encNamePayload = {}; if (item.enc_name && item.iv_name) { const plainName = await Crypto.decryptName( _oldVaultKey, item.enc_name, item.iv_name, ); if (plainName) { const { enc_name, iv_name } = await Crypto.encryptName( newVaultKey, plainName, ); encNamePayload = { enc_name, iv_name }; } } reEncryptedItems.push({ id: item.id, enc_data, iv, ...encNamePayload }); } catch { // 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", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: _email, new_auth_hash: newAuthHash, new_enc_key_salt: newEncKeySalt, recovery_proof: _recoveryProof, items: reEncryptedItems, allow_partial: allowPartial, }), }); const recoverData = await recoverRes.json(); if (!recoverRes.ok) { showError( "recover-error-2", recoverData.error || "Recovery failed. Please try again.", ); return; } // Store session and redirect sessionStorage.setItem("access_token", recoverData.access_token); localStorage.setItem("refresh_token", recoverData.refresh_token); sessionStorage.setItem("enc_key_salt", recoverData.enc_key_salt); // Set vault key in VaultSession so unlock overlay is skipped const finalVaultKey = await Crypto.deriveVaultKey( newPassword, recoverData.enc_key_salt, ); VaultSession.setKey(finalVaultKey); window.location.href = "/vault?recovered=1"; } catch (err) { showError( "recover-error-2", "An unexpected error occurred. Please try again.", ); console.error(err); } finally { setLoading(btn, false); } } // ── Init ─────────────────────────────────────────────────────────────────── function init() { document .getElementById("recover-form-step1") ?.addEventListener("submit", handleStep1); document .getElementById("recover-form-step2") ?.addEventListener("submit", handleStep2); const toggleBtn = document.getElementById("toggle-recover-pass"); const passInput = document.getElementById("recover-new-pass"); if (toggleBtn && passInput) { toggleBtn.addEventListener("click", () => { passInput.type = passInput.type === "password" ? "text" : "password"; }); } } // Public surface needed by recover.html (no VaultSession on recover page) return { init }; })(); // Minimal VaultSession stub (not used during recovery but imported by crypto.js chain) const VaultSession = (() => { let _key = null; return { setKey(k) { _key = k; }, getKey() { return _key; }, clear() { _key = null; }, }; })(); document.addEventListener("DOMContentLoaded", Recover.init);