/** * auth.js — Register and Login flows (including TOTP MFA step) * * Master password never leaves the browser. Only the PBKDF2-derived authHash * is sent to the server for authentication. */ const Auth = (() => { // ── Helpers ────────────────────────────────────────────────────────────── function csrfToken() { const meta = document.querySelector('meta[name="csrf-token"]'); return meta ? meta.content : ""; } function showError(formEl, message) { let el = formEl.querySelector(".form-error"); if (!el) { el = document.createElement("p"); el.className = "form-error"; formEl.prepend(el); } el.textContent = message; el.classList.remove("hidden"); } function setLoading(btn, loading) { btn.disabled = loading; btn.textContent = loading ? btn.dataset.loadingText || "Please wait…" : btn.dataset.originalText; } // ── Register ───────────────────────────────────────────────────────────── async function handleRegister(e) { e.preventDefault(); const form = e.target; const btn = form.querySelector('[type="submit"]'); btn.dataset.originalText = btn.textContent; const email = form.email.value.trim().toLowerCase(); const password = form.password.value; const confirm = form.confirm_password.value; if (password !== confirm) { showError(form, "Passwords do not match."); return; } if (password.length < 12) { showError(form, "Master password must be at least 12 characters."); return; } setLoading(btn, true); try { const authHash = await Crypto.deriveAuthHash(password, email); const enc_key_salt = Crypto.generateSalt(16); const res = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRFToken": csrfToken(), }, body: JSON.stringify({ email, auth_hash: authHash, enc_key_salt }), }); const data = await res.json(); if (!res.ok) { showError(form, data.error || "Registration failed."); return; } window.location.href = "/login?registered=1"; } catch (err) { showError(form, "An unexpected error occurred. Please try again."); console.error(err); } finally { setLoading(btn, false); } } // ── Login ───────────────────────────────────────────────────────────────── // Temporarily held between Step 1 and Step 2 let _pendingMfaToken = null; let _pendingEncKeySalt = null; let _pendingPassword = null; async function handleLogin(e) { e.preventDefault(); const form = e.target; const btn = form.querySelector('[type="submit"]'); btn.dataset.originalText = btn.textContent; const email = form.email.value.trim().toLowerCase(); const password = form.password.value; setLoading(btn, true); try { const authHash = await Crypto.deriveAuthHash(password, email); const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRFToken": csrfToken(), }, body: JSON.stringify({ email, auth_hash: authHash }), }); const data = await res.json(); if (!res.ok) { showError(form, data.error || "Invalid email or password."); return; } if (data.mfa_required) { // Step 2: collect TOTP code _pendingMfaToken = data.mfa_token; _pendingEncKeySalt = data.enc_key_salt; _pendingPassword = password; sessionStorage.setItem("enc_key_salt", data.enc_key_salt); showMfaStep(); return; } await completeLogin(password, data); } catch (err) { showError(form, "An unexpected error occurred. Please try again."); console.error(err); } finally { setLoading(btn, false); } } async function handleMfaVerify(e) { e.preventDefault(); const form = e.target; const btn = form.querySelector('[type="submit"]'); btn.dataset.originalText = btn.textContent; const errEl = document.getElementById("mfa-error"); errEl.classList.add("hidden"); const usingBackup = !document .getElementById("mfa-backup-section") .classList.contains("hidden"); const body = { mfa_token: _pendingMfaToken }; if (usingBackup) { const code = document .getElementById("mfa-backup-code") .value.trim() .toLowerCase() .replace(/[\s-]/g, ""); if (!code) { errEl.textContent = "Enter your backup code."; errEl.classList.remove("hidden"); return; } body.backup_code = code; } else { const totp_code = document.getElementById("mfa-code").value.trim(); if (!totp_code) return; body.totp_code = totp_code; } setLoading(btn, true); try { const res = await fetch("/api/auth/mfa/verify", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRFToken": csrfToken(), }, body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok) { errEl.textContent = data.error || "Invalid code. Try again."; errEl.classList.remove("hidden"); return; } await completeLogin(_pendingPassword, { access_token: data.access_token, refresh_token: data.refresh_token, enc_key_salt: _pendingEncKeySalt, }); } catch (err) { errEl.textContent = "An unexpected error occurred. Please try again."; errEl.classList.remove("hidden"); console.error(err); } finally { setLoading(btn, false); } } async function completeLogin(password, data) { sessionStorage.setItem("access_token", data.access_token); localStorage.setItem("refresh_token", data.refresh_token); sessionStorage.setItem("enc_key_salt", data.enc_key_salt); // Notify the browser extension (if installed) so it can share the session window.dispatchEvent( new CustomEvent("passkeeper:session", { detail: { access_token: data.access_token, refresh_token: data.refresh_token, enc_key_salt: data.enc_key_salt, }, }), ); const vaultKey = await Crypto.deriveVaultKey(password, data.enc_key_salt); VaultSession.setKey(vaultKey); window.location.href = "/vault"; } function showMfaStep() { document.getElementById("login-step-1").classList.add("hidden"); document.getElementById("login-step-2").classList.remove("hidden"); document.getElementById("mfa-code").focus(); } function hideMfaStep() { document.getElementById("login-step-2").classList.add("hidden"); document.getElementById("login-step-1").classList.remove("hidden"); document.getElementById("mfa-code").value = ""; document.getElementById("mfa-error").classList.add("hidden"); _pendingMfaToken = null; _pendingEncKeySalt = null; _pendingPassword = null; } // ── Init ────────────────────────────────────────────────────────────────── function initPasswordToggle(btnId, inputId) { const btn = document.getElementById(btnId); const input = document.getElementById(inputId); if (btn && input) { btn.addEventListener("click", () => { input.type = input.type === "password" ? "text" : "password"; }); } } function initStrengthMeter() { const input = document.getElementById("password"); const bar = document.getElementById("strength-bar"); if (!input || !bar) return; input.addEventListener("input", function () { const v = this.value; let score = 0; if (v.length >= 12) score++; if (v.length >= 16) score++; if (/[A-Z]/.test(v)) score++; if (/[0-9]/.test(v)) score++; if (/[^A-Za-z0-9]/.test(v)) score++; const labels = ["", "Very weak", "Weak", "Fair", "Strong", "Very strong"]; const classes = [ "", "strength-1", "strength-2", "strength-3", "strength-4", "strength-5", ]; bar.textContent = v ? labels[score] : ""; bar.className = "password-strength " + (v ? classes[score] : ""); }); } function init() { const registerForm = document.getElementById("register-form"); if (registerForm) registerForm.addEventListener("submit", handleRegister); const loginForm = document.getElementById("login-form"); if (loginForm) loginForm.addEventListener("submit", handleLogin); const mfaForm = document.getElementById("mfa-form"); if (mfaForm) mfaForm.addEventListener("submit", handleMfaVerify); document .getElementById("btn-back-to-password") ?.addEventListener("click", hideMfaStep); document .getElementById("btn-use-backup-code") ?.addEventListener("click", () => { document.getElementById("mfa-totp-section").classList.add("hidden"); document .getElementById("mfa-backup-section") .classList.remove("hidden"); document.getElementById("btn-use-backup-code").classList.add("hidden"); document.getElementById("btn-use-totp-code").classList.remove("hidden"); document.getElementById("mfa-backup-code").focus(); }); document .getElementById("btn-use-totp-code") ?.addEventListener("click", () => { document.getElementById("mfa-backup-section").classList.add("hidden"); document.getElementById("mfa-totp-section").classList.remove("hidden"); document.getElementById("btn-use-totp-code").classList.add("hidden"); document .getElementById("btn-use-backup-code") .classList.remove("hidden"); document.getElementById("mfa-code").focus(); }); initPasswordToggle("toggle-login-pass", "password"); initPasswordToggle("toggle-reg-pass", "password"); initStrengthMeter(); if (window.location.search.includes("registered=1")) { const notice = document.getElementById("register-notice"); if (notice) notice.classList.remove("hidden"); } // Passkey login button (only present on login.html) const passkeyBtn = document.getElementById("btn-passkey-login"); if (passkeyBtn) { passkeyBtn.addEventListener("click", async () => { const errEl = document.getElementById("passkey-error"); errEl.classList.add("hidden"); passkeyBtn.disabled = true; passkeyBtn.textContent = "Waiting for passkey…"; const email = (document.getElementById("email")?.value || "").trim().toLowerCase(); const result = await PasskeyAuth.loginWithPasskey(email); passkeyBtn.disabled = false; passkeyBtn.textContent = "🔑 Sign in with Passkey"; if (result.error) { errEl.textContent = result.error; errEl.classList.remove("hidden"); return; } // Tokens received — store them, then prompt for master password to unlock vault. sessionStorage.setItem("access_token", result.data.access_token); localStorage.setItem("refresh_token", result.data.refresh_token); sessionStorage.setItem("enc_key_salt", result.data.enc_key_salt); // Derive vault key from master password. // Re-use the same master-password input field; if empty, show unlock overlay. const pw = document.getElementById("password")?.value; if (pw) { const vaultKey = await Crypto.deriveVaultKey(pw, result.data.enc_key_salt); VaultSession.setKey(vaultKey); } // Navigate to vault — if vault key wasn't derived, unlock overlay will show. window.location.href = "/vault"; }); } } return { init }; })(); document.addEventListener("DOMContentLoaded", Auth.init); // ── VaultSession — holds the vault key for the lifetime of the browser tab ── const VaultSession = (() => { let _vaultKey = null; function setKey(key) { _vaultKey = key; } function getKey() { return _vaultKey; } function clear() { _vaultKey = null; } return { setKey, getKey, clear }; })(); // ── PasskeyAuth — WebAuthn / Passkey login and registration management ──────── const PasskeyAuth = (() => { // ── Helpers ───────────────────────────────────────────────────────────────── /** Convert ArrayBuffer → base64url string (no padding). */ function _bufToB64url(buf) { const bytes = new Uint8Array(buf); let bin = ''; bytes.forEach((b) => (bin += String.fromCharCode(b))); return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } /** Convert base64url string → Uint8Array. */ function _b64urlToBuf(str) { str = str.replace(/-/g, '+').replace(/_/g, '/'); while (str.length % 4) str += '='; const bin = atob(str); const arr = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); return arr; } /** * Convert a PublicKeyCredentialCreationOptions or RequestOptions object * returned by the server (JSON) into the format expected by navigator.credentials. * The browser API requires ArrayBuffers for challenge and user.id; the server * sends base64url strings. */ function _prepareCreationOptions(opts) { opts.challenge = _b64urlToBuf(opts.challenge); if (opts.user?.id) opts.user.id = _b64urlToBuf(opts.user.id); if (opts.excludeCredentials) { opts.excludeCredentials = opts.excludeCredentials.map((c) => ({ ...c, id: _b64urlToBuf(c.id), })); } return opts; } function _prepareRequestOptions(opts) { opts.challenge = _b64urlToBuf(opts.challenge); if (opts.allowCredentials) { opts.allowCredentials = opts.allowCredentials.map((c) => ({ ...c, id: _b64urlToBuf(c.id), })); } return opts; } /** * Serialise a PublicKeyCredential returned by navigator.credentials.create() * or navigator.credentials.get() into a plain JSON-serialisable object that * the server can accept. */ function _credentialToJson(cred) { const resp = cred.response; const obj = { id: cred.id, rawId: _bufToB64url(cred.rawId), type: cred.type, response: {}, }; if (resp.clientDataJSON !== undefined) obj.response.clientDataJSON = _bufToB64url(resp.clientDataJSON); if (resp.attestationObject !== undefined) obj.response.attestationObject = _bufToB64url(resp.attestationObject); if (resp.authenticatorData !== undefined) obj.response.authenticatorData = _bufToB64url(resp.authenticatorData); if (resp.signature !== undefined) obj.response.signature = _bufToB64url(resp.signature); if (resp.userHandle !== undefined && resp.userHandle !== null) obj.response.userHandle = _bufToB64url(resp.userHandle); // Include transport hints if available (registration only). if (typeof resp.getTransports === 'function') { obj.response.transports = resp.getTransports(); } if (cred.authenticatorAttachment) { obj.authenticatorAttachment = cred.authenticatorAttachment; } if (cred.clientExtensionResults) { obj.clientExtensionResults = cred.getClientExtensionResults?.() ?? {}; } return obj; } // ── Login flow ─────────────────────────────────────────────────────────────── /** * Initiate a passkey login. * 1. Call /api/webauthn/authenticate/begin (optionally with email hint). * 2. Invoke navigator.credentials.get() — browser shows passkey picker. * 3. Send the assertion to /api/webauthn/authenticate/complete. * 4. On success: store tokens and derive vault key from master password. * The master password is still required to unlock the vault (ZK preserved). */ async function loginWithPasskey(email) { if (!window.PublicKeyCredential) { return { error: 'Passkeys are not supported in this browser.' }; } // Step 1: get options from server. const beginRes = await fetch('/api/webauthn/authenticate/begin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email || '' }), }); if (!beginRes.ok) { const d = await beginRes.json().catch(() => ({})); return { error: d.error || 'Could not start passkey authentication.' }; } const options = await beginRes.json(); // Step 2: browser passkey picker. let assertion; try { assertion = await navigator.credentials.get({ publicKey: _prepareRequestOptions(options), }); } catch (e) { if (e.name === 'NotAllowedError') return { error: 'Passkey cancelled.' }; return { error: e.message || 'Passkey authentication failed.' }; } // Step 3: send assertion to server. const completeRes = await fetch('/api/webauthn/authenticate/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(_credentialToJson(assertion)), }); const completeData = await completeRes.json().catch(() => ({})); if (!completeRes.ok) { return { error: completeData.error || 'Passkey authentication failed.' }; } return { ok: true, data: completeData }; } // ── Registration flow (settings page) ──────────────────────────────────────── /** * Register a new passkey for the currently logged-in user. * Requires an active access_token in sessionStorage (set by vault.js on login). * @param {string} name User-friendly name for the passkey (e.g. "iPhone 15"). */ async function registerPasskey(name) { if (!window.PublicKeyCredential) { return { error: 'Passkeys are not supported in this browser.' }; } const token = sessionStorage.getItem('access_token'); if (!token) return { error: 'Not authenticated.' }; // Step 1: get creation options from server. const beginRes = await fetch('/api/webauthn/register/begin', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, }); if (!beginRes.ok) { const d = await beginRes.json().catch(() => ({})); return { error: d.error || 'Could not start passkey registration.' }; } const options = await beginRes.json(); // Step 2: create credential. let credential; try { credential = await navigator.credentials.create({ publicKey: _prepareCreationOptions(options), }); } catch (e) { if (e.name === 'NotAllowedError') return { error: 'Passkey registration cancelled.' }; return { error: e.message || 'Passkey creation failed.' }; } // Step 3: send attestation to server. const payload = _credentialToJson(credential); payload.name = name || 'Passkey'; const completeRes = await fetch('/api/webauthn/register/complete', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify(payload), }); const completeData = await completeRes.json().catch(() => ({})); if (!completeRes.ok) { return { error: completeData.error || 'Passkey registration failed.' }; } return { ok: true, credential: completeData.credential }; } return { loginWithPasskey, registerPasskey }; })();