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
550 lines
20 KiB
JavaScript
550 lines
20 KiB
JavaScript
/**
|
|
* 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);
|