05/02/2026 updated code for security
This commit is contained in:
+151
-68
@@ -44,8 +44,9 @@ const Recover = (() => {
|
||||
// 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 _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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -61,36 +62,39 @@ const Recover = (() => {
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
let bin = '';
|
||||
bytes.forEach(b => (bin += String.fromCharCode(b)));
|
||||
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;
|
||||
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();
|
||||
return input.replace(/[-\s]/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
function showError(id, message) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) { el.textContent = message; el.classList.remove('hidden'); }
|
||||
if (el) {
|
||||
el.textContent = message;
|
||||
el.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function hideError(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.classList.add('hidden');
|
||||
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);
|
||||
? btn.dataset.loadingText || "Please wait…"
|
||||
: btn.dataset.originalText || btn.textContent;
|
||||
}
|
||||
|
||||
// ── Crypto ─────────────────────────────────────────────────────────────────
|
||||
@@ -101,23 +105,23 @@ const Recover = (() => {
|
||||
*/
|
||||
async function deriveRecoveryKey(recoveryCode) {
|
||||
const baseKey = await subtle.importKey(
|
||||
'raw',
|
||||
"raw",
|
||||
strToBytes(recoveryCode),
|
||||
'PBKDF2',
|
||||
"PBKDF2",
|
||||
false,
|
||||
['deriveKey']
|
||||
["deriveKey"],
|
||||
);
|
||||
return subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: strToBytes('passkeeper-recovery'),
|
||||
name: "PBKDF2",
|
||||
salt: strToBytes("passkeeper-recovery"),
|
||||
iterations: 200_000,
|
||||
hash: 'SHA-256',
|
||||
hash: "SHA-256",
|
||||
},
|
||||
baseKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,9 +132,9 @@ const Recover = (() => {
|
||||
async function encryptEncKeySalt(recoveryKey, encKeySalt) {
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
{ name: "AES-GCM", iv },
|
||||
recoveryKey,
|
||||
strToBytes(encKeySalt)
|
||||
strToBytes(encKeySalt),
|
||||
);
|
||||
return {
|
||||
recovery_enc_salt: bytesToBase64(new Uint8Array(ciphertext)),
|
||||
@@ -144,35 +148,68 @@ const Recover = (() => {
|
||||
*/
|
||||
async function decryptEncKeySalt(recoveryKey, recoveryEncSalt, recoveryIv) {
|
||||
const plaintext = await subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: base64ToBytes(recoveryIv) },
|
||||
{ name: "AES-GCM", iv: base64ToBytes(recoveryIv) },
|
||||
recoveryKey,
|
||||
base64ToBytes(recoveryEncSalt)
|
||||
base64ToBytes(recoveryEncSalt),
|
||||
);
|
||||
return new TextDecoder().decode(plaintext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the HMAC-SHA256 recovery proof.
|
||||
* proof = HMAC-SHA256(key=enc_key_salt_bytes, msg=nonce_bytes)
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
async function computeRecoveryProof(encKeySalt, nonce) {
|
||||
const keyMaterial = await subtle.importKey(
|
||||
"raw",
|
||||
strToBytes(encKeySalt),
|
||||
{ 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');
|
||||
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());
|
||||
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.');
|
||||
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)}`);
|
||||
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.');
|
||||
showError(
|
||||
"recover-error-1",
|
||||
"No recovery code found for this account.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
@@ -184,27 +221,37 @@ const Recover = (() => {
|
||||
decryptedEncKeySalt = await decryptEncKeySalt(
|
||||
recoveryKey,
|
||||
data.recovery_enc_salt,
|
||||
data.recovery_iv
|
||||
data.recovery_iv,
|
||||
);
|
||||
} catch {
|
||||
showError('recover-error-1', 'Invalid recovery code. Please check and try again.');
|
||||
showError(
|
||||
"recover-error-1",
|
||||
"Invalid recovery code. Please check and try again.",
|
||||
);
|
||||
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);
|
||||
|
||||
// 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();
|
||||
|
||||
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.');
|
||||
showError(
|
||||
"recover-error-1",
|
||||
"An unexpected error occurred. Please try again.",
|
||||
);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(btn, false);
|
||||
@@ -215,28 +262,36 @@ const Recover = (() => {
|
||||
|
||||
async function handleStep2(e) {
|
||||
e.preventDefault();
|
||||
hideError('recover-error-2');
|
||||
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;
|
||||
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.');
|
||||
showError("recover-error-2", "Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 12) {
|
||||
showError('recover-error-2', 'Password must be at least 12 characters.');
|
||||
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);
|
||||
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.
|
||||
@@ -264,9 +319,12 @@ const Recover = (() => {
|
||||
// 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': _oldEncKeySalt },
|
||||
});
|
||||
const itemsRes = await fetch(
|
||||
`/api/auth/recovery/items?email=${encodeURIComponent(_email)}`,
|
||||
{
|
||||
headers: { "X-Recovery-Proof": _recoveryProof },
|
||||
},
|
||||
);
|
||||
|
||||
let reEncryptedItems = [];
|
||||
if (itemsRes.ok) {
|
||||
@@ -274,8 +332,15 @@ const Recover = (() => {
|
||||
// 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);
|
||||
const plain = await Crypto.decryptItem(
|
||||
_oldVaultKey,
|
||||
item.enc_data,
|
||||
item.iv,
|
||||
);
|
||||
const { enc_data, iv } = await Crypto.encryptItem(
|
||||
newVaultKey,
|
||||
plain,
|
||||
);
|
||||
reEncryptedItems.push({ id: item.id, enc_data, iv });
|
||||
} catch {
|
||||
// Item decryption failed — skip (shouldn't happen if recovery code is correct)
|
||||
@@ -285,37 +350,45 @@ const Recover = (() => {
|
||||
}
|
||||
|
||||
// Submit recovery
|
||||
const recoverRes = await fetch('/api/auth/recover', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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: _oldEncKeySalt,
|
||||
recovery_proof: _recoveryProof,
|
||||
items: reEncryptedItems,
|
||||
}),
|
||||
});
|
||||
|
||||
const recoverData = await recoverRes.json();
|
||||
if (!recoverRes.ok) {
|
||||
showError('recover-error-2', recoverData.error || 'Recovery failed. Please try again.');
|
||||
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);
|
||||
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);
|
||||
const finalVaultKey = await Crypto.deriveVaultKey(
|
||||
newPassword,
|
||||
recoverData.enc_key_salt,
|
||||
);
|
||||
VaultSession.setKey(finalVaultKey);
|
||||
|
||||
window.location.href = '/vault?recovered=1';
|
||||
|
||||
window.location.href = "/vault?recovered=1";
|
||||
} catch (err) {
|
||||
showError('recover-error-2', 'An unexpected error occurred. Please try again.');
|
||||
showError(
|
||||
"recover-error-2",
|
||||
"An unexpected error occurred. Please try again.",
|
||||
);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(btn, false);
|
||||
@@ -325,14 +398,18 @@ const Recover = (() => {
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function init() {
|
||||
document.getElementById('recover-form-step1')?.addEventListener('submit', handleStep1);
|
||||
document.getElementById('recover-form-step2')?.addEventListener('submit', handleStep2);
|
||||
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');
|
||||
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';
|
||||
toggleBtn.addEventListener("click", () => {
|
||||
passInput.type = passInput.type === "password" ? "text" : "password";
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -345,10 +422,16 @@ const Recover = (() => {
|
||||
const VaultSession = (() => {
|
||||
let _key = null;
|
||||
return {
|
||||
setKey(k) { _key = k; },
|
||||
getKey() { return _key; },
|
||||
clear() { _key = null; },
|
||||
setKey(k) {
|
||||
_key = k;
|
||||
},
|
||||
getKey() {
|
||||
return _key;
|
||||
},
|
||||
clear() {
|
||||
_key = null;
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
document.addEventListener('DOMContentLoaded', Recover.init);
|
||||
document.addEventListener("DOMContentLoaded", Recover.init);
|
||||
|
||||
Reference in New Issue
Block a user