05/02/2026 updated code for security

This commit is contained in:
2026-05-02 15:30:33 -04:00
parent 9af7435cab
commit c7b1806ec8
6 changed files with 594 additions and 124 deletions
+151 -68
View File
@@ -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);