Files
Password-Manager/js/app.js
T
Zaki d13e5bc89f feat(zero-knowledge): client computes verifier, master pw never leaves the browser
THE GAP THIS CLOSES
===================
Before this commit, every auth endpoint accepted the master password
in plaintext. The server ran PBKDF2 + SHA-256 server-side to verify.
That means:
 - master pw traveled over HTTP (loopback, but still observable by
   any process that can intercept localhost)
 - master pw sat in the server's process memory (a local string
   variable) for the ~500 ms PBKDF2 took to run
 - a memory dump of PMServer.exe during /login would expose it

This commit moves the PBKDF2 step to the CLIENT and sends only the
hex result (the "verifier") to the server. The master pw never
leaves the browser — server is now zero-knowledge in the everyday
sense (the stored hash and the hash-format remain the same; SRP-
style proper zero-knowledge would be another refactor).

NEW ENDPOINT
============
POST /login/challenge  body { username }
                       -> { salt, kdfIterations, hashAlgo }

First leg of login: client posts username, server returns the params
needed for the client to compute PBKDF2 locally. Per-IP rate-limited.
Returns 404 for unknown user — client masks this as a generic
"Invalid credentials" toast to preserve user-existence opacity
(consistent with the existing /login timing leak).

UPDATED ENDPOINTS
=================
All auth endpoints now accept EITHER plaintext masterPassword OR a
precomputed verifier. New helpers in PM.Handler.Auth:

  function IsValidVerifier(s): 64 hex chars sanity check
  function VerifierToStoredHash(v, algo): SHA-256 wrap (CURRENT) or
                                          identity (LEGACY)
  function CheckVerifier(v, stored, algo): constant-time compare

Endpoint matrix:
  /register        :: salt, kdfIterations, verifier (all client-gen)
                      OR masterPassword (legacy)
  /login           :: verifier OR masterPassword
  /reauth          :: verifier OR masterPassword
  /migrate-kdf     :: oldVerifier + newVerifier OR masterPassword
                      (oldVerifier = under current iters, newVerifier
                      = under target iters)
  /change-master-pw:: currentVerifier + newVerifier + newSalt
                      OR currentMasterPassword + newMasterPassword
  /recovery-key/setup :: verifier OR masterPassword
                         (via VerifyMasterPassword helper updated to
                         accept either input)

When a verifier is present, the server simply applies the SHA-256
wrap (for HASH_ALGO_CURRENT) or compares directly (LEGACY) — no
PBKDF2 work, no plaintext pw in memory.

CLIENT
======
New helpers in app.js:

  bytesToHex(arr) : matches the server's PBKDF2_SHA256_Hex output
                    format (lowercase hex, no separators)
  deriveKeyAndVerifier(pwd, saltHex, iters)
                  : single PBKDF2 → returns BOTH the AES-GCM CryptoKey
                    AND the hex verifier. No double-PBKDF2 cost.
  computeVerifier(pwd, salt, iters)
                  : verifier-only variant for places that don't need
                    the CryptoKey (reauth, recovery setup, ...).

state.kdfIterations is now tracked + persisted to sessionStorage so
verifier computation works without a fresh /login/challenge round
trip on every reauth / change-pw / recovery setup.

Flows updated:
  doLogin           : POST /login/challenge → derive locally → POST
                      /login with verifier. CryptoKey reused from
                      the same PBKDF2 run.
  doRegister        : client-side randomHexSalt + derive → POST with
                      {salt, kdfIterations, verifier}.
  doUnlock          : verifier from cached salt+iters → POST /reauth.
  runKdfMigration   : compute oldVerifier + newVerifier from same
                      salt at different iters → POST.
  doChangeMasterPassword:
                    : currentVerifier (old salt+iters) + newVerifier
                      (fresh salt, target iters) + newSalt. New key
                      ready in memory by the time we POST.
  doGenerateRecoveryKey:
                    : verifier → /recovery-key/setup.
  enableQuickUnlock,
  doExport          : both /reauth callers switched to verifier.

Persistence
===========
The DPAPI quick-unlock blob (when enabled) now includes kdfIterations
so cold-start restores can correctly re-derive verifiers if reauth
is needed later. Recovery redeem similarly stashes kdfIterations
from the server response.

Backward compat
===============
Server endpoints still accept the legacy masterPassword path so
older client builds keep working through the next deploy. Future
cleanup: drop the plaintext branches once everyone has rolled
forward.

What this does NOT achieve
==========================
This is not SRP / OPAQUE. The stored value on the server IS the
final hash, and a stolen vault.db gives the attacker something they
can directly verify candidate guesses against (offline brute force).
Closing that requires asymmetric proofs (client and server holding
different things), which is a much larger refactor. The realistic
win here is "master pw never transits the network or sits in server
memory" — that's a meaningful reduction in attack surface, not a
cryptographic miracle.
2026-05-23 12:03:41 +01:00

3987 lines
154 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
Vault — UI V2 app.js
Clean state + render layer. Crypto helpers preserved verbatim
from legacy. Backend (PHP api.php / Delphi loopback) is detected
from URL path.
============================================================ */
// ---- Backend detection -------------------------------------
const API = (location.pathname.indexOf('/password-manager/') === 0)
? '/password-manager/api.php'
: '';
// ---- Delphi native bridge ----------------------------------
// Active only when running inside the Delphi-hosted WebView2 (API === '').
// Falls back to navigator.clipboard for the standalone PHP frontend.
const Bridge = (() => {
const active = (API === '');
// Navigate to a cmd:// URL — intercepted synchronously by
// TTMSFNCWebBrowser OnBeforeNavigate before any actual navigation occurs.
function cmd(path) {
window.location.href = path;
}
return {
active,
// Copy text to clipboard, excluded from Win+V history.
// clearAfterMs: Delphi auto-clears after this many ms (0 = never).
// Returns true when the bridge handled the copy, false as fallback signal.
copySecure(text, clearAfterMs = 30000) {
if (!active) return false;
cmd('cmd://clipboard/copy?text=' + encodeURIComponent(text) +
'&clear=' + clearAfterMs);
return true;
},
// Called by Delphi (ExecuteJavaScript) on WTS_SESSION_LOCK.
// Exposed as window.Bridge.onSystemLock so the Delphi side can call it,
// but the actual lock is triggered directly via lockVault() in Delphi.
onSystemLock() {
if (typeof lockVault === 'function') lockVault();
},
// Called by Delphi (ExecuteJavaScript) when the user restores the
// window from the tray icon. Useful for resetting auto-lock state
// and giving a subtle visual cue.
onTrayRestore() {
// If the user has been away long enough that the auto-lock
// should fire, lockVault was already called by either WTS lock
// or the local idle timer — so we only reset here when still
// unlocked.
if (state.cryptoKey && !state.locked) {
if (typeof resetAutoLock === 'function') resetAutoLock();
if (typeof toast === 'function') toast('Welcome back');
}
},
};
})();
// Expose Bridge on window so Delphi's ExecuteJavaScript can reach it.
window.Bridge = Bridge;
// ---- Global state ------------------------------------------
const state = {
token: sessionStorage.getItem('authToken') || '',
csrf: sessionStorage.getItem('csrfToken') || '',
salt: sessionStorage.getItem('salt') || '',
username: sessionStorage.getItem('username') || '',
// KDF iteration count of the currently-logged-in user. Cached so reauth
// and on-the-fly verifier computations don't need a /login/challenge
// round trip every time. Refreshed from every auth response.
kdfIterations: parseInt(sessionStorage.getItem('kdfIterations') || '0') || 0,
cryptoKey: null,
entries: [],
trashed: [],
folders: ['All'],
view: 'all', // 'all' | 'favorites' | 'folder:<name>' | 'tag:<name>' | 'trash'
search: '',
selectedId: null,
theme: localStorage.getItem('theme') || 'dark',
locked: false, // true after user clicks Lock (token still valid server-side)
autoLock: parseInt(localStorage.getItem('autoLockMin') || '5'),
askBeforeDelete: localStorage.getItem('askBeforeDelete') !== '0', // default true
maskUsernames: localStorage.getItem('maskUsernames') === '1', // default false
compactActions: localStorage.getItem('compactActions') === '1', // default false
viewMode: localStorage.getItem('viewMode') || 'cards', // 'cards' | 'list'
checked: new Set(), // entry IDs checked for batch operations
hibpEnabled: localStorage.getItem('hibpEnabled') === '1', // default OFF
// entry.id → count from HIBP (0 = clean, >0 = pwned, undefined = unchecked)
hibpResults: new Map(),
quickUnlockEnabled: localStorage.getItem('quickUnlockEnabled') === '1',
recoveryConfigured: false, // refreshed by refreshRecoveryStatus on Settings open
};
// ============================================================
// CRYPTO (preserved from legacy app.js — DO NOT TOUCH)
// ============================================================
async function deriveKey(pwd, saltHex, iterations) {
// Iterations parameter is the per-user value returned by the server in
// the /login response (legacy users = 100000, modern = 600000). Falling
// back to 100000 keeps backwards compatibility with old code paths that
// didn't pass the value, but every new caller should pass it explicitly.
iterations = iterations || 100000;
const enc = new TextEncoder();
const km = await crypto.subtle.importKey('raw', enc.encode(pwd), 'PBKDF2', false, ['deriveKey']);
// saltHex is the same string that PHP/Delphi passed to PBKDF2 — use its bytes.
const sb = enc.encode(saltHex);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: sb, iterations: iterations, hash: 'SHA-256' },
km,
{ name: 'AES-GCM', length: 256 },
true, ['encrypt', 'decrypt']
);
}
// ---- Zero-knowledge auth helpers --------------------------------
//
// Single PBKDF2 → both outputs at once:
// - cryptoKey: the AES-GCM key used to encrypt entries (= raw PBKDF2 bytes)
// - verifier: the same 32 bytes in hex form, sent to the server in place
// of the plaintext master password. Server then SHA-256-wraps
// it (HASH_ALGO_CURRENT) or compares directly (LEGACY) without
// ever seeing the plaintext.
//
// Doing it together avoids running PBKDF2 twice. computeVerifier() is for
// places that only need the hex (re-auth, current-pw verification on change,
// etc.) and skips the AES-GCM importKey work.
function bytesToHex(arr) {
if (arr instanceof ArrayBuffer) arr = new Uint8Array(arr);
let hex = '';
for (let i = 0; i < arr.length; i++)
hex += arr[i].toString(16).padStart(2, '0');
return hex;
}
async function deriveKeyAndVerifier(pwd, saltHex, iterations) {
iterations = iterations || 100000;
const enc = new TextEncoder();
const km = await crypto.subtle.importKey(
'raw', enc.encode(pwd), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt: enc.encode(saltHex),
iterations: iterations, hash: 'SHA-256' },
km, 256); // 256 bits = 32 bytes — matches PBKDF2_SHA256_Hex output
const keyBytes = new Uint8Array(bits);
const cryptoKey = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
return { cryptoKey, verifier: bytesToHex(keyBytes) };
}
async function computeVerifier(pwd, saltHex, iterations) {
const r = await deriveKeyAndVerifier(pwd, saltHex, iterations);
return r.verifier;
}
async function encryptPwd(plain) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, state.cryptoKey,
new TextEncoder().encode(plain)
);
return {
encrypted: btoa(String.fromCharCode(...new Uint8Array(enc))),
iv: btoa(String.fromCharCode(...iv)),
};
}
async function decryptPwd(encB64, ivB64) {
try {
const enc = Uint8Array.from(atob(encB64), c => c.charCodeAt(0));
const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0));
const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, state.cryptoKey, enc);
return new TextDecoder().decode(dec);
} catch (e) {
return '[ERROR]';
}
}
async function persistCryptoKey() {
const raw = await crypto.subtle.exportKey('raw', state.cryptoKey);
sessionStorage.setItem('cryptoKey', btoa(String.fromCharCode(...new Uint8Array(raw))));
}
async function restoreCryptoKey() {
const saved = sessionStorage.getItem('cryptoKey');
if (!saved) return false;
try {
const raw = Uint8Array.from(atob(saved), c => c.charCodeAt(0));
state.cryptoKey = await crypto.subtle.importKey(
'raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']
);
return true;
} catch (e) {
return false;
}
}
// ============================================================
// HTTP HELPERS
// ============================================================
function authHeaders(extra) {
const h = Object.assign({ 'Authorization': 'Bearer ' + state.token }, extra || {});
if (state.csrf) h['X-CSRF-Token'] = state.csrf;
return h;
}
async function api(path, opts) {
opts = opts || {};
const r = await fetch(API + path, opts);
let body = null;
try { body = await r.json(); } catch (e) { body = {}; }
if (!r.ok) {
// Preserve status + body on the Error so callers can distinguish
// 429-with-retry_after (account lockout) from a generic auth error.
const err = new Error(body.error || ('HTTP ' + r.status));
err.status = r.status;
err.body = body || {};
throw err;
}
return body;
}
// ============================================================
// TOTP (RFC 6238) — 6-digit time-based codes
// ============================================================
//
// Implementation is pure crypto.subtle (HMAC-SHA1) + a small base32
// decoder. No external library. The secret is stored encrypted with the
// vault's AES-GCM key (same flow as passwords), so the server never sees
// the plaintext base32 secret.
// Decode an RFC 4648 base32 string (Google Authenticator format) to bytes.
// Tolerates lowercase, spaces, and padding. Throws on invalid characters.
function base32Decode(s) {
const ALPH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const clean = String(s).toUpperCase().replace(/[\s=]/g, '');
let bits = 0, buffer = 0;
const out = [];
for (const ch of clean) {
const v = ALPH.indexOf(ch);
if (v < 0) throw new Error('Invalid base32 character: ' + ch);
buffer = (buffer << 5) | v;
bits += 5;
if (bits >= 8) {
bits -= 8;
out.push((buffer >> bits) & 0xFF);
}
}
return new Uint8Array(out);
}
// Generate a TOTP code per RFC 6238. Returns the 6-digit code as a string
// (zero-padded) along with how many seconds remain in the current 30s window.
// Throws if the secret can't be decoded.
async function generateTOTP(secretBase32, period, digits) {
period = period || 30;
digits = digits || 6;
const keyBytes = base32Decode(secretBase32);
// Counter = floor(unix_time / period), encoded as 8-byte big-endian.
const nowSec = Math.floor(Date.now() / 1000);
let counter = Math.floor(nowSec / period);
const counterBytes = new Uint8Array(8);
for (let i = 7; i >= 0; i--) {
counterBytes[i] = counter & 0xFF;
counter = Math.floor(counter / 256);
}
const cryptoKey = await crypto.subtle.importKey(
'raw', keyBytes,
{ name: 'HMAC', hash: 'SHA-1' },
false, ['sign']
);
const sigBuf = await crypto.subtle.sign('HMAC', cryptoKey, counterBytes);
const sig = new Uint8Array(sigBuf);
// Dynamic truncation: low nibble of last byte = offset into HMAC output.
const offset = sig[sig.length - 1] & 0x0F;
const truncated =
((sig[offset] & 0x7F) << 24) |
((sig[offset + 1] & 0xFF) << 16) |
((sig[offset + 2] & 0xFF) << 8) |
( sig[offset + 3] & 0xFF);
const mod = Math.pow(10, digits);
const code = String(truncated % mod).padStart(digits, '0');
return {
code: code,
period: period,
secondsLeft: period - (nowSec % period),
};
}
// Parse a Google Authenticator-style otpauth:// URI and extract the secret.
// Example: otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example
// Returns the secret alone (we don't yet honor issuer/algorithm/digits/period
// overrides — assume SHA-1 / 6 digits / 30s, which covers ~all real services).
function parseOtpAuthUri(raw) {
raw = String(raw || '').trim();
if (!raw.toLowerCase().startsWith('otpauth://')) return null;
try {
const u = new URL(raw);
const sec = u.searchParams.get('secret');
return sec ? sec.trim() : null;
} catch (e) { return null; }
}
// Encrypt a TOTP secret with the vault key. Returns { encrypted, iv } in
// the same base64 shape as encryptPwd, ready to send to the server.
async function encryptTotpSecret(secretBase32) {
return await encryptPwd(secretBase32); // same crypto, just different field
}
async function decryptTotpSecret(encB64, ivB64) {
return await decryptPwd(encB64, ivB64);
}
// ============================================================
// HIBP — Have I Been Pwned breach check (k-anonymity)
// ============================================================
//
// HIBP's range API exposes pwned password counts without ever seeing the
// password (or even its full hash):
// 1. Client computes SHA-1 of the password.
// 2. Client sends ONLY the first 5 hex chars to api.pwnedpasswords.com/range/XXXXX
// 3. Server returns up to ~500 suffixes (35 chars each) with counts.
// 4. Client searches the response for its own suffix locally.
//
// This means the network observer (and HIBP itself) sees only the 5-char
// prefix — which matches ~3,000 of the ~half-billion known pwned passwords.
// Information leakage is bounded by design.
//
// Toggle is OFF by default. When enabled, all entries are checked once
// after vault load, then individual entries are re-checked when the user
// edits the password. Results cached in state.hibpResults keyed by entry id.
async function sha1Hex(text) {
const buf = new TextEncoder().encode(text);
const hashBuf = await crypto.subtle.digest('SHA-1', buf);
const bytes = new Uint8Array(hashBuf);
let hex = '';
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
return hex.toUpperCase();
}
// Returns the breach count (0 if not found, >0 if pwned). Throws on
// network failure — caller decides whether to silently skip or alert.
async function hibpCheckPassword(plaintext) {
if (!plaintext) return 0;
const hash = await sha1Hex(plaintext);
const prefix = hash.substring(0, 5);
const suffix = hash.substring(5);
const resp = await fetch('https://api.pwnedpasswords.com/range/' + prefix, {
// Padding mitigates side-channel attacks where an observer counts
// bytes in the response to narrow down the prefix queried.
headers: { 'Add-Padding': 'true' },
});
if (!resp.ok) throw new Error('HIBP HTTP ' + resp.status);
const body = await resp.text();
// Body lines: "SUFFIX:COUNT\r\n" — search for our suffix.
for (const line of body.split('\n')) {
const colonAt = line.indexOf(':');
if (colonAt <= 0) continue;
if (line.substring(0, colonAt).trim() === suffix) {
return parseInt(line.substring(colonAt + 1).trim(), 10) || 0;
}
}
return 0;
}
// Batch-check every entry currently in state.entries. Awaits all in
// parallel but with a small concurrency cap so we don't hammer HIBP
// or trip browser connection limits. Mutates state.hibpResults and
// re-renders to show the new badges.
async function hibpCheckAllEntries() {
if (!state.hibpEnabled || !state.entries.length) return;
const CONCURRENCY = 6;
const queue = state.entries.slice();
const workers = [];
for (let w = 0; w < CONCURRENCY; w++) {
workers.push((async () => {
while (queue.length) {
const entry = queue.shift();
try {
const pwd = await decryptPwd(entry.encrypted_password, entry.iv);
if (pwd === '[ERROR]') continue;
const count = await hibpCheckPassword(pwd);
state.hibpResults.set(entry.id, count);
} catch (e) {
// Network or decrypt failure: skip silently. Will retry
// next time the user opens the vault.
}
}
})());
}
await Promise.all(workers);
render();
}
// ============================================================
// KDF MIGRATION (PBKDF2 100k → 600k re-encryption)
// ============================================================
//
// When the server signals kdfMigration in /login or /reauth, we transparently
// re-encrypt every entry with a stronger key (600k PBKDF2 iterations) and
// commit the new ciphertext + the new server-side hash in one atomic
// /migrate-kdf request. If anything fails, the user stays on the legacy
// config and the migration retries at next login. The entries currently
// loaded in state.entries are encrypted with the OLD key (state.cryptoKey).
//
// Threading: runs in the background after enterApp completes. Locking the
// vault during migration is safe — we just lose the in-flight transition
// and the server's atomic rollback means nothing persisted.
let kdfMigrationInProgress = false;
async function runKdfMigration(masterPwd, fromIters, toIters) {
if (kdfMigrationInProgress) return; // dedupe concurrent calls
if (!state.entries || !state.cryptoKey) return;
kdfMigrationInProgress = true;
try {
// Two distinct migration scenarios:
// A. fromIters !== toIters: KDF iteration count is changing, so
// the AES key is changing. We re-encrypt every entry with the
// new key + fresh IVs, swap state.cryptoKey at the end.
// B. fromIters === toIters: same KDF, only the server-side hash
// format is being upgraded (legacy "pbkdf2" raw → "pbkdf2-sha256"
// wrapped). No entry re-encryption needed — just trigger the
// endpoint so the server rewrites the user row.
const kdfChange = fromIters !== toIters;
let newKey, newCiphertexts;
if (kdfChange) {
newKey = await deriveKey(masterPwd, state.salt, toIters);
// Re-encrypt every entry. Each entry gets a fresh random IV
// under the new key — never reuse the old IV with the new key
// (would be pointless but also a small information leak via IV
// reuse patterns).
newCiphertexts = [];
for (const entry of state.entries) {
const plain = await decryptPwd(entry.encrypted_password, entry.iv);
if (plain === '[ERROR]') {
// One decrypt failure aborts the whole migration —
// better to stay on the legacy config than commit
// partial state.
throw new Error('Could not decrypt entry id=' + entry.id);
}
const tmpKey = state.cryptoKey;
try {
state.cryptoKey = newKey;
const re = await encryptPwd(plain);
newCiphertexts.push({
id: entry.id,
encrypted_password: re.encrypted,
iv: re.iv,
});
} finally {
state.cryptoKey = tmpKey; // restore for any concurrent read
}
}
} else {
// Hash-format-only upgrade — server still wants an entries
// array (it's an idempotent transactional update), just empty.
newCiphertexts = [];
}
// Zero-knowledge: compute BOTH verifiers locally. oldVerifier proves
// the user knows the master pw under the current (legacy) iters;
// newVerifier is what the server will SHA-256-wrap to be the new
// stored hash after migration. Master pw never leaves the browser.
const oldVerifier = await computeVerifier(masterPwd, state.salt, fromIters);
const newVerifier = await computeVerifier(masterPwd, state.salt, toIters);
await api('/migrate-kdf', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
oldVerifier: oldVerifier,
newVerifier: newVerifier,
entries: newCiphertexts,
}),
});
if (kdfChange) {
// Swap to the new AES key + update cached ciphertexts.
state.cryptoKey = newKey;
state.kdfIterations = toIters;
sessionStorage.setItem('kdfIterations', String(toIters));
await persistCryptoKey();
for (let i = 0; i < state.entries.length; i++) {
const nc = newCiphertexts[i];
state.entries[i].encrypted_password = nc.encrypted_password;
state.entries[i].iv = nc.iv;
}
toast('Vault security upgraded (' + fromIters.toLocaleString() +
' → ' + toIters.toLocaleString() + ' KDF iterations)');
} else {
// Format-only upgrade is silent — the user didn't perceive a
// weakness change, and nothing visible in the UI changed.
// (A subtle "Auth format upgraded" toast felt noisy.)
}
} catch (err) {
// Silent retry on next login — the migration is idempotent and
// safe to abandon (server rolled back).
console.warn('KDF migration aborted:', err);
} finally {
kdfMigrationInProgress = false;
}
}
// ============================================================
// ACCOUNT LOCKOUT UI
// ============================================================
let lockoutTimer = null;
// Called when the backend responds with 429 + retry_after on /login or
// /reauth. Disables the auth form and displays a live countdown in
// #authHint. When the countdown reaches 0, the form is re-enabled.
function showLockoutCountdown(seconds) {
if (lockoutTimer) { clearInterval(lockoutTimer); lockoutTimer = null; }
const hint = $('#authHint');
const btn = $('#loginBtn');
const fmt = (s) => {
if (s >= 3600) return Math.ceil(s / 3600) + ' h';
if (s >= 60) return Math.ceil(s / 60) + ' min';
return s + ' s';
};
const tick = () => {
if (seconds <= 0) {
clearInterval(lockoutTimer); lockoutTimer = null;
if (hint) hint.textContent = 'You can try again now.';
if (btn) btn.disabled = false;
return;
}
if (hint) hint.textContent = 'Account locked — try again in ' + fmt(seconds);
seconds--;
};
if (btn) btn.disabled = true;
tick(); // show first frame immediately
lockoutTimer = setInterval(tick, 1000);
}
// ============================================================
// TOAST
// ============================================================
function toast(msg, type) {
type = type || 'success';
const container = $('#toastContainer');
const t = document.createElement('div');
t.className = 'toast is-' + type;
t.textContent = msg;
container.appendChild(t);
setTimeout(() => t.remove(), 2800);
}
// ============================================================
// DOM HELPERS
// ============================================================
function $(sel, root) { return (root || document).querySelector(sel); }
function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); }
function el(tag, props, ...kids) {
const e = document.createElement(tag);
if (props) for (const k in props) {
if (k === 'class') e.className = props[k];
else if (k === 'on') for (const ev in props.on) e.addEventListener(ev, props.on[ev]);
else if (k === 'html') e.innerHTML = props[k];
else if (k in e) e[k] = props[k];
else e.setAttribute(k, props[k]);
}
for (const k of kids) {
if (k == null) continue;
e.appendChild(typeof k === 'string' ? document.createTextNode(k) : k);
}
return e;
}
function icon(id) {
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
const u = document.createElementNS('http://www.w3.org/2000/svg', 'use');
u.setAttribute('href', '#' + id);
s.appendChild(u);
return s;
}
// ============================================================
// AUTH
// ============================================================
async function doLogin(e) {
e && e.preventDefault();
const u = $('#loginUsername').value.trim();
const p = $('#loginPassword').value;
if (!u || !p) return;
// If we are in locked mode (token still valid), try fast unlock first.
if (state.locked && state.token && state.salt && u === state.username) {
$('#loginBtn').disabled = true;
const ok = await doUnlock(p);
$('#loginBtn').disabled = false;
if (ok) return;
// unlock failed — fall through to a full login
}
$('#loginBtn').disabled = true;
try {
// Zero-knowledge: ask the server for the user's salt + iter count,
// compute the verifier locally, send only the verifier. Master pw
// never leaves the browser.
const ch = await api('/login/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u }),
});
const derived = await deriveKeyAndVerifier(p, ch.salt, ch.kdfIterations);
const r = await api('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, verifier: derived.verifier }),
});
state.token = r.token;
state.csrf = r.csrfToken;
state.salt = r.salt;
state.username = u;
state.kdfIterations = r.kdfIterations || ch.kdfIterations;
sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('username', state.username);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
// cryptoKey is already derived — no second PBKDF2 pass.
state.cryptoKey = derived.cryptoKey;
await persistCryptoKey();
toast('Welcome back, ' + u);
await enterApp();
// Trigger KDF migration AFTER entries are loaded into state.
if (r.kdfMigration && r.kdfMigration.target) {
runKdfMigration(p, r.kdfIterations, r.kdfMigration.target);
}
} catch (err) {
// 429 with retry_after = account lockout. Show countdown in the
// auth hint instead of a generic error toast, and keep the login
// button disabled until the lockout expires.
if (err.status === 429 && err.body && err.body.retry_after) {
showLockoutCountdown(err.body.retry_after);
return; // do NOT re-enable the button in finally
}
// Mask "Unknown user" from /login/challenge as a generic credentials
// failure — keeps user-existence enumeration consistent with the
// existing /login behavior.
if (err.status === 404) {
toast('Invalid credentials', 'error');
} else {
toast(err.message, 'error');
}
} finally {
// Only re-enable when not in lockout (showLockoutCountdown manages
// the button itself for the lockout case).
if (!lockoutTimer) $('#loginBtn').disabled = false;
}
}
async function doRegister(e) {
e && e.preventDefault();
const u = $('#regUsername').value.trim();
const p = $('#regPassword').value;
if (u.length < 3 || p.length < 8) return toast('Min 3 / 8 chars', 'error');
$('#registerBtn').disabled = true;
try {
// Zero-knowledge register: client generates salt + iters, computes
// the verifier locally, sends only the verifier. Master pw never
// leaves the browser.
const newSalt = randomHexSalt();
const newIters = 600000;
const derived = await deriveKeyAndVerifier(p, newSalt, newIters);
const r = await api('/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: u,
salt: newSalt,
kdfIterations: newIters,
verifier: derived.verifier,
}),
});
state.token = r.token;
state.csrf = r.csrfToken;
state.salt = r.salt || newSalt;
state.username = u;
state.kdfIterations = r.kdfIterations || newIters;
sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('username', state.username);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
state.cryptoKey = derived.cryptoKey;
await persistCryptoKey();
toast('Vault created');
await enterApp();
} catch (err) {
toast(err.message, 'error');
} finally {
$('#registerBtn').disabled = false;
}
}
async function doLogout() {
try { await api('/logout', { method: 'POST', headers: authHeaders() }); } catch (e) {}
// Explicit logout → wipe the DPAPI quick-unlock blob too. Logout is the
// user saying "I'm done", quite different from a passive lock.
if (Bridge.active && localStorage.getItem('quickUnlockEnabled') === '1') {
window.location.href = 'cmd://quickunlock/clear';
localStorage.removeItem('quickUnlockEnabled');
state.quickUnlockEnabled = false;
}
sessionStorage.clear();
state.token = ''; state.csrf = ''; state.salt = ''; state.username = '';
state.kdfIterations = 0;
state.cryptoKey = null; state.entries = []; state.trashed = []; state.folders = ['All'];
state.locked = false;
showAuth();
$('#loginUsername').value = '';
$('#loginPassword').value = '';
$('#loginUsername').readOnly = false;
$('#authHint').textContent = '';
}
// Lock: do NOT hit /logout — keep server session alive, just drop the in-memory
// crypto key. On unlock, /reauth validates the master password and we re-derive.
function lockVault() {
sessionStorage.removeItem('cryptoKey');
state.cryptoKey = null;
state.entries = [];
state.trashed = [];
state.locked = true;
showAuth();
// Two UI variants for the auth screen:
// - We know the username (user was logged in before lock) →
// pre-fill it as readonly so the user only types the master pw.
// - We don't know the username (lock fired before any login — e.g.
// tray "Lock vault" clicked on a fresh session, or Win+L right
// after launch) → show a normal fresh login (editable username).
if (state.username) {
$('#loginUsername').value = state.username;
$('#loginUsername').readOnly = true;
$('#authHint').textContent = 'Vault locked — enter master password to unlock';
$('#loginPassword').focus();
} else {
$('#loginUsername').value = '';
$('#loginUsername').readOnly = false;
$('#authHint').textContent = '';
$('#loginUsername').focus();
}
$('#loginPassword').value = '';
}
// Unlock flow: validate master pw via /reauth (which uses current session),
// then re-derive the crypto key locally without rotating session/csrf.
async function doUnlock(p) {
try {
// Compute the verifier locally with the salt+iters cached at login.
// Server compares verifier → never sees the plaintext master pw.
const iters = state.kdfIterations || 100000;
const derived = await deriveKeyAndVerifier(p, state.salt, iters);
const r = await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ verifier: derived.verifier }),
});
// Refresh cached iter count in case the server has migrated us.
state.kdfIterations = r.kdfIterations || iters;
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
state.cryptoKey = derived.cryptoKey;
await persistCryptoKey();
state.locked = false;
$('#loginUsername').readOnly = false;
$('#authHint').textContent = '';
toast('Unlocked');
await enterApp();
if (r.kdfMigration && r.kdfMigration.target) {
runKdfMigration(p, r.kdfIterations, r.kdfMigration.target);
}
return true;
} catch (err) {
// Account lockout (too many wrong master pw attempts): show
// countdown in the auth hint, keep the form disabled.
if (err.status === 429 && err.body && err.body.retry_after) {
showLockoutCountdown(err.body.retry_after);
return false;
}
if (err.message === 'Invalid password') {
toast('Wrong master password', 'error');
} else {
// session expired — fall back to full login
sessionStorage.clear();
state.token = ''; state.csrf = ''; state.salt = '';
state.locked = false;
$('#loginUsername').readOnly = false;
$('#authHint').textContent = 'Session expired, please sign in again';
toast('Session expired', 'warning');
}
return false;
}
}
// ============================================================
// DATA LOADING
// ============================================================
async function loadFolders() {
try {
const r = await api('/folders', { headers: authHeaders() });
// 'All' is always implicit first
state.folders = ['All'].concat(r.filter(n => n !== 'All'));
} catch (e) { /* ignore */ }
}
async function loadEntries() {
try {
const r = await api('/entries', { headers: authHeaders() });
state.entries = Array.isArray(r) ? r : [];
} catch (e) {
if (e.message === 'Invalid session' || e.message === 'Session expired') {
return doLogout();
}
toast(e.message, 'error');
}
}
async function loadTrash() {
try {
const r = await api('/entries?deleted=1', { headers: authHeaders() });
state.trashed = Array.isArray(r) ? r : [];
} catch (e) { state.trashed = []; }
}
// ============================================================
// FILTERS / DERIVED
// ============================================================
function filteredEntries() {
// Trash view shows its own list (loaded separately)
let list;
if (state.view === 'trash') {
list = state.trashed;
} else {
list = state.entries;
if (state.view === 'favorites') list = list.filter(e => e.favorite);
else if (state.view.startsWith('folder:')) {
const f = state.view.slice(7);
if (f !== 'All') list = list.filter(e => e.folder === f);
} else if (state.view.startsWith('tag:')) {
const t = state.view.slice(4);
list = list.filter(e => parseTags(e.tags).includes(t));
}
}
if (state.search) {
const q = state.search.toLowerCase();
list = list.filter(e =>
(e.site || '').toLowerCase().includes(q) ||
(e.username || '').toLowerCase().includes(q) ||
(e.tags || '').toLowerCase().includes(q)
);
}
return list;
}
function parseTags(s) {
if (!s) return [];
return s.split(',').map(t => t.trim()).filter(Boolean);
}
function allTags() {
const set = new Set();
state.entries.forEach(e => parseTags(e.tags).forEach(t => set.add(t)));
return Array.from(set).sort();
}
function viewTitle() {
if (state.view === 'all') return 'All items';
if (state.view === 'favorites') return 'Favorites';
if (state.view === 'trash') return 'Trash';
if (state.view.startsWith('folder:')) return state.view.slice(7);
if (state.view.startsWith('tag:')) return '# ' + state.view.slice(4);
return 'Items';
}
// ============================================================
// RENDER
// ============================================================
function render() {
renderSidebar();
renderGrid();
}
function renderSidebar() {
// counts
$('#countAll').textContent = state.entries.length;
$('#countFav').textContent = state.entries.filter(e => e.favorite).length;
$('#countTrash').textContent = state.trashed.length || '';
// active state for top-level items
$$('#appShell .nav-item[data-view]').forEach(n => {
n.classList.toggle('is-active', n.dataset.view === state.view);
});
// folders
const fList = $('#foldersList');
fList.innerHTML = '';
state.folders.forEach(name => {
const count = state.entries.filter(e => e.folder === name).length;
const key = 'folder:' + name;
const item = el('button', {
class: 'nav-item' + (state.view === key ? ' is-active' : ''),
'data-folder': name,
on: { click: () => setView(key) },
});
item.appendChild(icon('i-folder'));
item.appendChild(el('span', null, name));
item.appendChild(el('span', { class: 'nav-count' }, String(count)));
// drag and drop target
item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('drag-over'); });
item.addEventListener('dragleave', () => item.classList.remove('drag-over'));
item.addEventListener('drop', async e => {
e.preventDefault();
item.classList.remove('drag-over');
const id = e.dataTransfer.getData('text/plain');
if (id) await moveEntryToFolder(parseInt(id), name);
});
fList.appendChild(item);
});
// tags
const tList = $('#tagsList');
tList.innerHTML = '';
const tags = allTags();
if (tags.length === 0) {
tList.appendChild(el('div', { class: 'sidebar-section-header', style: 'padding:6px 10px;color:var(--text-faint);font-size:11px;text-transform:none;letter-spacing:0' }, 'No tags yet'));
} else {
tags.forEach(t => {
const key = 'tag:' + t;
const count = state.entries.filter(e => parseTags(e.tags).includes(t)).length;
const item = el('button', {
class: 'nav-item' + (state.view === key ? ' is-active' : ''),
on: { click: () => setView(key) },
});
item.appendChild(icon('i-tag'));
item.appendChild(el('span', null, t));
item.appendChild(el('span', { class: 'nav-count' }, String(count)));
// Drop target: drag a card here to add this tag to that entry
item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('drag-over'); });
item.addEventListener('dragleave', () => item.classList.remove('drag-over'));
item.addEventListener('drop', async e => {
e.preventDefault();
item.classList.remove('drag-over');
const id = parseInt(e.dataTransfer.getData('text/plain'));
if (id) await addTagToEntry(id, t);
});
tList.appendChild(item);
});
}
}
async function addTagToEntry(id, tag) {
const e = state.entries.find(x => x.id === id);
if (!e) return;
const tags = parseTags(e.tags);
if (tags.includes(tag)) {
toast('Already tagged with "' + tag + '"', 'warning');
return;
}
tags.push(tag);
try {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
site: e.site, username: e.username,
encrypted_password: e.encrypted_password, iv: e.iv,
folder: e.folder, tags: tags.join(','),
}),
});
e.tags = tags.join(',');
render();
toast('Tagged "' + tag + '"');
} catch (err) { toast(err.message, 'error'); }
}
function renderGrid() {
$('#contentTitle').textContent = viewTitle();
const list = filteredEntries();
$('#contentMeta').textContent = list.length + (list.length === 1 ? ' item' : ' items');
// Empty trash action button next to title (only in trash view)
const oldBtn = $('#emptyTrashBtn');
if (oldBtn) oldBtn.remove();
if (state.view === 'trash' && state.trashed.length > 0) {
const btn = el('button', {
class: 'btn btn-ghost btn-sm', id: 'emptyTrashBtn',
style: 'margin-left:auto',
on: { click: emptyTrash },
}, withIcon('i-trash', 'Empty trash'));
$('.content-header').appendChild(btn);
}
// Batch action bar (shown when selection is non-empty)
renderBatchBar();
const grid = $('#entryGrid');
grid.className = 'entry-grid' + (state.viewMode === 'list' ? ' is-list' : '');
grid.innerHTML = '';
if (list.length === 0) {
showEmptyState();
return;
}
$('#emptyState').classList.add('is-hidden');
list.forEach(e => grid.appendChild(renderCard(e)));
}
function showEmptyState() {
const illustration = $('#emptyIllustration use');
const title = $('#emptyTitle');
const msg = $('#emptyMessage');
if (state.search) {
illustration.setAttribute('href', '#i-empty-search');
title.textContent = 'No matches';
msg.innerHTML = 'Try a different search term, or click <b>+ New</b> to add a new entry.';
} else if (state.view === 'trash') {
illustration.setAttribute('href', '#i-empty-trash');
title.textContent = 'Trash is empty';
msg.textContent = 'Deleted entries land here. They can be restored at any time.';
} else if (state.view === 'favorites') {
illustration.setAttribute('href', '#i-empty-vault');
title.textContent = 'No favorites yet';
msg.innerHTML = 'Click the <b>★</b> on any entry to add it to favorites.';
} else if (state.view.startsWith('folder:')) {
illustration.setAttribute('href', '#i-empty-vault');
title.textContent = 'Folder is empty';
msg.innerHTML = 'Move entries here by drag &amp; drop, or by setting their folder.';
} else if (state.view.startsWith('tag:')) {
illustration.setAttribute('href', '#i-empty-vault');
title.textContent = 'No entries with this tag';
msg.textContent = 'Drop a card on the tag to add this tag to that entry.';
} else {
illustration.setAttribute('href', '#i-empty-vault');
title.textContent = 'Your vault is empty';
msg.innerHTML = 'Click <b>+ New</b> to add your first password. They\'re encrypted before they leave your machine.';
}
$('#emptyState').classList.remove('is-hidden');
}
// Skeleton loaders shown during the initial fetch right after login/unlock
function showSkeletons(n) {
const grid = $('#entryGrid');
grid.innerHTML = '';
$('#emptyState').classList.add('is-hidden');
for (let i = 0; i < n; i++) {
const card = el('div', { class: 'skeleton-card' });
const row = el('div', { class: 'skeleton-row' });
row.appendChild(el('div', { class: 'skeleton-circle' }));
const col = el('div', { style: 'flex:1' });
col.appendChild(el('div', { class: 'skeleton-line w-60' }));
col.appendChild(el('div', { class: 'skeleton-line w-40', style: 'margin-bottom:0' }));
row.appendChild(col);
card.appendChild(row);
card.appendChild(el('div', { class: 'skeleton-line w-80' }));
card.appendChild(el('div', { class: 'skeleton-line w-40', style: 'margin-bottom:0' }));
grid.appendChild(card);
}
}
function initials(s) {
return (s || '?').replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || '?';
}
// Compact-action kebab menu shown on each card when state.compactActions is on.
function buildKebabMenu(entry) {
const wrap = el('div', { class: 'entry-kebab-wrap' });
const btn = el('button', {
class: 'entry-kebab',
title: 'More actions',
on: { click: ev => {
ev.stopPropagation();
// Close any other open menu, then toggle this one
$$('.entry-kebab-menu.is-open').forEach(m => {
if (m !== menu) m.classList.remove('is-open');
});
menu.classList.toggle('is-open');
} },
});
btn.appendChild(icon('i-more'));
wrap.appendChild(btn);
const menu = el('div', { class: 'entry-kebab-menu' });
const items = [
{ lbl: entry.favorite ? 'Unfavorite' : 'Favorite', ic: 'i-star', fn: () => toggleFavorite(entry.id) },
{ lbl: 'Copy password', ic: 'i-copy', fn: () => copyPassword(entry) },
{ lbl: 'Copy username', ic: 'i-user', fn: () => copyUsername(entry) },
{ lbl: 'Edit', ic: 'i-edit', fn: () => openSlideOver(entry.id) },
{ lbl: 'Move to trash', ic: 'i-trash', fn: () => deleteEntry(entry.id), danger: true },
];
items.forEach(it => {
const mi = el('button', {
class: 'kebab-item' + (it.danger ? ' is-danger' : ''),
on: { click: ev => {
ev.stopPropagation();
menu.classList.remove('is-open');
it.fn();
} },
});
mi.appendChild(icon(it.ic));
mi.appendChild(el('span', null, it.lbl));
menu.appendChild(mi);
});
wrap.appendChild(menu);
return wrap;
}
function renderCard(e) {
const inTrash = state.view === 'trash';
const checked = state.checked.has(e.id);
const card = el('article', {
class: 'entry-card'
+ (state.selectedId === e.id ? ' is-selected' : '')
+ (checked ? ' is-checked' : ''),
'data-id': e.id,
draggable: inTrash ? 'false' : 'true',
on: { click: ev => handleCardClick(ev, e, inTrash) },
});
if (!inTrash) {
card.addEventListener('dragstart', ev => {
ev.dataTransfer.setData('text/plain', String(e.id));
ev.dataTransfer.effectAllowed = 'move';
});
}
// head: avatar acts as a multi-select checkbox (click on avatar -> toggle)
const head = el('div', { class: 'entry-head' });
const avatar = el('div', {
class: 'entry-avatar is-checkable',
title: 'Click to select',
on: { click: ev => { ev.stopPropagation(); toggleChecked(e.id); } },
}, checked ? '✓' : initials(e.site));
head.appendChild(avatar);
const title = el('div', { class: 'entry-title' });
title.appendChild(el('b', null, e.site));
// Username row with inline copy button (visible on card hover)
const userRow = el('small', { class: 'entry-user-row' });
userRow.appendChild(el('span', null, displayUsername(e.username)));
if (e.username) {
const copyUser = el('button', {
class: 'entry-copy-user',
title: 'Copy username',
on: { click: ev => { ev.stopPropagation(); copyUsername(e); } },
});
copyUser.appendChild(icon('i-copy'));
userRow.appendChild(copyUser);
}
title.appendChild(userRow);
head.appendChild(title);
if (inTrash) {
// In trash: show restore + permanent delete buttons
const restore = el('button', {
class: 'icon-btn icon-btn-sm', title: 'Restore',
on: { click: ev => { ev.stopPropagation(); restoreEntry(e.id); } },
});
restore.appendChild(icon('i-rotate-ccw'));
const purge = el('button', {
class: 'icon-btn icon-btn-sm', title: 'Delete forever',
style: 'color:var(--danger)',
on: { click: ev => { ev.stopPropagation(); permanentDelete(e.id); } },
});
purge.appendChild(icon('i-trash'));
head.appendChild(restore);
head.appendChild(purge);
} else if (state.compactActions) {
// Compact mode: single kebab menu replaces fav + del
head.appendChild(buildKebabMenu(e));
} else {
const fav = el('button', {
class: 'entry-fav' + (e.favorite ? ' is-on' : ''),
title: 'Favorite',
on: { click: ev => { ev.stopPropagation(); toggleFavorite(e.id); } },
});
fav.appendChild(icon('i-star'));
head.appendChild(fav);
// Quick-delete: small X visible on card hover. Always available
// without opening the slide-over.
const del = el('button', {
class: 'entry-del',
title: 'Move to trash',
on: { click: ev => { ev.stopPropagation(); deleteEntry(e.id); } },
});
del.appendChild(icon('i-x'));
head.appendChild(del);
}
card.appendChild(head);
// password row (placeholder dots, click reveals via slide-over)
const pwRow = el('div', { class: 'entry-pw-row' });
pwRow.appendChild(el('span', { class: 'entry-pw', id: 'pw-' + e.id }, '••••••••'));
const copyBtn = el('button', {
class: 'icon-btn icon-btn-sm',
title: 'Copy password',
on: { click: ev => { ev.stopPropagation(); copyPassword(e); } },
});
copyBtn.appendChild(icon('i-copy'));
pwRow.appendChild(copyBtn);
card.appendChild(pwRow);
// meta chips: folder + first 2 tags
const meta = el('div', { class: 'entry-meta' });
if (e.folder) {
const chip = el('span', { class: 'entry-chip is-folder' });
chip.appendChild(icon('i-folder'));
chip.appendChild(el('span', null, e.folder));
meta.appendChild(chip);
}
parseTags(e.tags).slice(0, 3).forEach(t => {
const chip = el('span', { class: 'entry-chip' });
chip.appendChild(icon('i-tag'));
chip.appendChild(el('span', null, t));
meta.appendChild(chip);
});
// HIBP pwned badge — only shown when the user enabled HIBP and the
// background scan completed with count > 0 for this entry.
const pwnedCount = state.hibpResults.get(e.id);
if (state.hibpEnabled && pwnedCount && pwnedCount > 0) {
const chip = el('span', {
class: 'entry-chip is-pwned',
title: 'This password appeared in ' + pwnedCount.toLocaleString() +
' known data breaches. Consider changing it.',
});
chip.appendChild(icon('i-alert'));
chip.appendChild(el('span', null, 'Pwned'));
meta.appendChild(chip);
}
// 2FA indicator — entry has a TOTP secret configured. Server returns
// null for both fields when none; truthy = configured (the actual
// secret stays encrypted until the user opens the slide-over).
if (e.totp_secret && e.totp_iv) {
const chip = el('span', {
class: 'entry-chip is-2fa',
title: 'Two-factor authentication (TOTP) configured',
});
chip.appendChild(icon('i-lock'));
chip.appendChild(el('span', null, '2FA'));
meta.appendChild(chip);
}
card.appendChild(meta);
return card;
}
// ============================================================
// MARQUEE (rubber-band) SELECTION
// ============================================================
// Click-drag on empty space in the entry grid draws a rectangle.
// Cards whose bounding box intersects the rectangle become selected.
// Shift/Ctrl held = add to existing selection (otherwise replace).
let marqueeEl = null;
let marqueeStart = null;
let marqueeAdditive = false;
let marqueeInitialSet = null;
function startMarquee(ev) {
// Only fire on left mouse button, and only when starting on grid background
if (ev.button !== 0) return;
if (ev.target.closest('.entry-card')) return; // ignore drags from cards
if (ev.target.closest('.batch-bar')) return;
if (!ev.target.closest('#entryGrid')) return;
marqueeAdditive = ev.shiftKey || ev.ctrlKey || ev.metaKey;
marqueeInitialSet = new Set(state.checked);
if (!marqueeAdditive) state.checked.clear();
marqueeStart = { x: ev.clientX, y: ev.clientY };
marqueeEl = el('div', { class: 'marquee' });
Object.assign(marqueeEl.style, {
left: marqueeStart.x + 'px',
top: marqueeStart.y + 'px',
width: '0px', height: '0px',
});
document.body.appendChild(marqueeEl);
ev.preventDefault();
document.addEventListener('mousemove', updateMarquee);
document.addEventListener('mouseup', endMarquee);
}
function updateMarquee(ev) {
if (!marqueeEl) return;
const x1 = Math.min(marqueeStart.x, ev.clientX);
const y1 = Math.min(marqueeStart.y, ev.clientY);
const x2 = Math.max(marqueeStart.x, ev.clientX);
const y2 = Math.max(marqueeStart.y, ev.clientY);
Object.assign(marqueeEl.style, {
left: x1 + 'px', top: y1 + 'px',
width: (x2 - x1) + 'px', height: (y2 - y1) + 'px',
});
// Re-check intersections
const marqueeRect = { left: x1, top: y1, right: x2, bottom: y2 };
state.checked = new Set(marqueeAdditive ? marqueeInitialSet : []);
$$('#entryGrid .entry-card').forEach(card => {
const r = card.getBoundingClientRect();
const intersects = !(r.right < marqueeRect.left || r.left > marqueeRect.right ||
r.bottom < marqueeRect.top || r.top > marqueeRect.bottom);
if (intersects) {
const id = parseInt(card.dataset.id);
state.checked.add(id);
card.classList.add('is-checked');
} else if (!marqueeInitialSet.has(parseInt(card.dataset.id))) {
card.classList.remove('is-checked');
}
});
}
function endMarquee() {
document.removeEventListener('mousemove', updateMarquee);
document.removeEventListener('mouseup', endMarquee);
if (marqueeEl) marqueeEl.remove();
marqueeEl = null;
marqueeStart = null;
marqueeInitialSet = null;
// Re-render so the batch bar appears with the new count + avatar states
renderGrid();
}
// ============================================================
// MULTI-SELECTION + BATCH ACTIONS
// ============================================================
let selectionAnchor = null; // last single-clicked card, used for shift+click range
function toggleChecked(id) {
if (state.checked.has(id)) state.checked.delete(id);
else state.checked.add(id);
renderGrid();
}
function handleCardClick(ev, entry, inTrash) {
// Ctrl/Cmd+Click: toggle this card in selection
if (ev.ctrlKey || ev.metaKey) {
toggleChecked(entry.id);
selectionAnchor = entry.id;
return;
}
// Shift+Click: select range from anchor to this card
if (ev.shiftKey && selectionAnchor !== null) {
const list = filteredEntries();
const a = list.findIndex(x => x.id === selectionAnchor);
const b = list.findIndex(x => x.id === entry.id);
if (a >= 0 && b >= 0) {
const lo = Math.min(a, b), hi = Math.max(a, b);
for (let i = lo; i <= hi; i++) state.checked.add(list[i].id);
renderGrid();
return;
}
}
// If any cards are already checked, a plain click toggles (sticky multi-select)
if (state.checked.size > 0) {
toggleChecked(entry.id);
selectionAnchor = entry.id;
return;
}
// Default: open slide-over (or trash actions)
selectionAnchor = entry.id;
if (inTrash) openTrashActions(entry.id);
else openSlideOver(entry.id);
}
function clearChecked() {
state.checked.clear();
renderGrid();
}
async function batchMoveToFolder(folder) {
const ids = Array.from(state.checked);
if (!ids.length) return;
for (const id of ids) {
const e = state.entries.find(x => x.id === id);
if (!e || e.folder === folder) continue;
try {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
site: e.site, username: e.username,
encrypted_password: e.encrypted_password, iv: e.iv,
folder, tags: e.tags || '',
}),
});
e.folder = folder;
} catch (err) { /* ignore individual failures */ }
}
toast(ids.length + ' moved to ' + folder);
clearChecked();
}
async function batchAddTag(tag) {
tag = (tag || '').trim();
if (!tag) return;
const ids = Array.from(state.checked);
if (!ids.length) return;
for (const id of ids) {
const e = state.entries.find(x => x.id === id);
if (!e) continue;
const tags = parseTags(e.tags);
if (tags.includes(tag)) continue;
tags.push(tag);
try {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
site: e.site, username: e.username,
encrypted_password: e.encrypted_password, iv: e.iv,
folder: e.folder, tags: tags.join(','),
}),
});
e.tags = tags.join(',');
} catch (err) {}
}
toast('Tagged ' + ids.length + ' as "' + tag + '"');
clearChecked();
}
async function batchDelete() {
const ids = Array.from(state.checked);
if (!ids.length) return;
const ok = await confirmDialog({
title: 'Move to trash',
message: '<b>' + ids.length + '</b> entries will be moved to trash.',
okText: 'Move to trash',
danger: true,
});
if (!ok) return;
for (const id of ids) {
try {
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
} catch (err) {}
}
toast(ids.length + ' moved to trash');
await loadEntries();
await loadTrash();
clearChecked();
}
async function batchRestore() {
const ids = Array.from(state.checked);
if (!ids.length) return;
for (const id of ids) {
try {
await api('/entries/' + id + '/restore', { method: 'POST', headers: authHeaders() });
} catch (err) {}
}
toast(ids.length + ' restored');
await loadEntries();
await loadTrash();
clearChecked();
}
async function batchPermDelete() {
const ids = Array.from(state.checked);
if (!ids.length) return;
const ok = await confirmDialog({
title: 'Delete forever',
message: '<b>' + ids.length + '</b> entries will be <b>permanently deleted</b>. This cannot be undone.',
okText: 'Delete forever',
danger: true,
});
if (!ok) return;
for (const id of ids) {
try {
await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() });
} catch (err) {}
}
toast(ids.length + ' deleted permanently');
await loadTrash();
clearChecked();
}
function renderBatchBar() {
const existing = $('#batchBar');
if (existing) existing.remove();
if (state.checked.size === 0) return;
const inTrash = state.view === 'trash';
const bar = el('div', { class: 'batch-bar', id: 'batchBar' });
bar.appendChild(el('span', { class: 'batch-bar-count' }, state.checked.size + ' selected'));
if (inTrash) {
// Trash view: Restore | Delete forever
bar.appendChild(el('button', {
class: 'btn btn-ghost btn-sm',
on: { click: batchRestore },
}, withIcon('i-rotate-ccw', 'Restore')));
bar.appendChild(el('button', {
class: 'btn btn-ghost btn-sm',
style: 'color:var(--danger)',
on: { click: batchPermDelete },
}, withIcon('i-trash', 'Delete forever')));
} else {
// Normal view: Move to folder | Add tag | Delete (soft)
const moveSel = el('select');
moveSel.appendChild(el('option', { value: '' }, 'Move to folder…'));
state.folders.forEach(f => moveSel.appendChild(el('option', { value: f }, f)));
moveSel.addEventListener('change', () => {
if (moveSel.value) batchMoveToFolder(moveSel.value);
});
bar.appendChild(moveSel);
bar.appendChild(el('button', {
class: 'btn btn-ghost btn-sm',
on: { click: async () => {
const t = await promptDialog({
title: 'Add tag',
message: 'Add a tag to <b>' + state.checked.size + '</b> selected entries',
placeholder: 'tag name',
okText: 'Add',
});
if (t) batchAddTag(t);
} },
}, withIcon('i-tag', 'Add tag')));
bar.appendChild(el('button', {
class: 'btn btn-ghost btn-sm',
style: 'color:var(--danger)',
on: { click: batchDelete },
}, withIcon('i-trash', 'Delete')));
}
bar.appendChild(el('div', { class: 'grow' }));
bar.appendChild(el('button', {
class: 'btn btn-ghost btn-sm',
on: { click: clearChecked },
}, withIcon('i-x', 'Clear')));
const content = $('.content');
content.insertBefore(bar, $('#entryGrid'));
}
// ============================================================
// SLIDE-OVER
// ============================================================
// Edit-in-place state for the slide-over
let soState = null;
async function openSlideOver(id) {
const e = state.entries.find(x => x.id === id);
if (!e) return;
state.selectedId = id;
$('#slideoverTitle').textContent = e.site;
const body = $('#slideoverBody');
body.innerHTML = '';
const plain = await decryptPwd(e.encrypted_password, e.iv);
// Decrypt TOTP secret if present. Empty string when no TOTP configured
// OR when decryption fails (orphan ciphertext, key mismatch, etc.) — the
// UI treats both cases as "no 2FA", so the user can re-paste a secret to
// recover.
let plainTotp = '';
if (e.totp_secret && e.totp_iv) {
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
if (plainTotp === '[ERROR]') plainTotp = '';
}
// Track original values so we can detect "dirty"
soState = {
id: e.id,
original: {
site: e.site, username: e.username || '', password: plain,
folder: e.folder || 'All', tags: parseTags(e.tags).join(','),
totp: plainTotp,
},
tags: parseTags(e.tags),
originalEncrypted: e.encrypted_password,
originalIV: e.iv,
originalTotpEncrypted: e.totp_secret,
originalTotpIV: e.totp_iv,
};
body.appendChild(soEditableField('Site', 'soSite', e.site));
body.appendChild(soEditableField('Username', 'soUsername', e.username || ''));
body.appendChild(soPasswordField(plain));
body.appendChild(soTotpField(plainTotp));
body.appendChild(soFolderField(e.folder || 'All'));
body.appendChild(soTagsField());
// Action row — Save button is hidden until dirty. No Delete here:
// the quick-X on each card handles deletion (avoids duplication).
const actions = el('div', { class: 'slideover-actions' });
const saveBtn = el('button', {
class: 'btn btn-primary',
id: 'soSaveBtn',
style: 'display:none',
on: { click: soSave },
}, withIcon('i-check', 'Save'));
actions.appendChild(saveBtn);
body.appendChild(actions);
// Wire change detection
['#soSite', '#soUsername', '#soPassword', '#soFolder'].forEach(sel => {
const el = $(sel); if (el) el.addEventListener('input', soDirtyCheck);
if (el) el.addEventListener('change', soDirtyCheck);
});
$('#slideover').classList.add('is-open');
renderGrid();
}
function soEditableField(label, id, value) {
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, label));
const input = el('input', { type: 'text', id, value, class: 'so-input' });
wrap.appendChild(input);
return wrap;
}
function soPasswordField(plain) {
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Password'));
const row = el('div', { class: 'so-pw-row' });
const input = el('input', {
type: 'password', id: 'soPassword', value: plain,
class: 'so-input', style: 'flex:1;font-family:JetBrains Mono,monospace',
});
const toggle = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Show/hide' });
toggle.appendChild(icon('i-eye'));
toggle.addEventListener('click', () => {
input.type = input.type === 'password' ? 'text' : 'password';
});
const copy = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Copy' });
copy.appendChild(icon('i-copy'));
copy.addEventListener('click', () => {
if (Bridge.copySecure(input.value, 30000)) {
toast('Copied · clears in 30s');
} else {
navigator.clipboard.writeText(input.value).then(() => {
toast('Copied · clears in 30s');
setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000);
});
}
});
const gen = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Generate' });
gen.appendChild(icon('i-dice'));
gen.addEventListener('click', () => {
openGen('slideover');
});
row.appendChild(input);
row.appendChild(toggle);
row.appendChild(copy);
row.appendChild(gen);
wrap.appendChild(row);
return wrap;
}
// ---- TOTP field in slide-over (input + live code + countdown) ----
let totpTickTimer = null;
function startTotpTick() {
if (totpTickTimer) return;
// Refresh once per second so the countdown bar moves smoothly and the
// code auto-rolls when the 30s window expires.
totpTickTimer = setInterval(updateTotpDisplay, 1000);
updateTotpDisplay();
}
function stopTotpTick() {
if (totpTickTimer) { clearInterval(totpTickTimer); totpTickTimer = null; }
}
async function updateTotpDisplay() {
const input = $('#soTotpSecret');
const codeEl = $('#soTotpCode');
const barEl = $('#soTotpProgress');
if (!input || !codeEl) { stopTotpTick(); return; }
const secret = (input.value || '').trim();
if (!secret) {
codeEl.textContent = '';
codeEl.classList.remove('is-invalid');
if (barEl) barEl.style.width = '0%';
return;
}
try {
const t = await generateTOTP(secret);
// Format as "123 456" — the standard spacing for authenticator apps
codeEl.textContent = t.code.slice(0, 3) + ' ' + t.code.slice(3);
codeEl.classList.remove('is-invalid');
if (barEl) {
const pct = (t.secondsLeft / t.period) * 100;
barEl.style.width = pct + '%';
// Switch to red when < 5s left so the user notices the imminent roll
barEl.style.background = t.secondsLeft < 5 ? '#dc2626' : 'var(--accent)';
}
} catch (err) {
codeEl.textContent = 'invalid secret';
codeEl.classList.add('is-invalid');
if (barEl) barEl.style.width = '0%';
}
}
function soTotpField(plainSecret) {
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Two-factor (TOTP)'));
const row = el('div', { class: 'so-pw-row' });
const input = el('input', {
type: 'password', id: 'soTotpSecret',
value: plainSecret || '',
class: 'so-input',
placeholder: 'Paste base32 secret or otpauth:// URI',
style: 'flex:1;font-family:JetBrains Mono,monospace',
autocomplete: 'off', spellcheck: 'false',
});
// If the user pastes a full otpauth:// URI, auto-extract the secret param
// so the displayed value is the clean base32 only. Triggers via 'input'
// (covers both paste events and manual typing).
input.addEventListener('input', () => {
const v = input.value.trim();
const fromUri = parseOtpAuthUri(v);
if (fromUri) input.value = fromUri;
updateTotpDisplay();
soDirtyCheck();
});
const toggle = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Show/hide secret' });
toggle.appendChild(icon('i-eye'));
toggle.addEventListener('click', () => {
input.type = input.type === 'password' ? 'text' : 'password';
});
const clear = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Remove TOTP' });
clear.appendChild(icon('i-x'));
clear.addEventListener('click', () => {
input.value = '';
updateTotpDisplay();
soDirtyCheck();
});
row.appendChild(input);
row.appendChild(toggle);
row.appendChild(clear);
wrap.appendChild(row);
// Live code panel — shows the current 6-digit code with a copy button
// and a progress bar that drains over the 30s window.
const panel = el('div', { class: 'totp-panel' });
const codeEl = el('div', { class: 'totp-code', id: 'soTotpCode' });
panel.appendChild(codeEl);
const copyBtn = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Copy code' });
copyBtn.appendChild(icon('i-copy'));
copyBtn.addEventListener('click', async () => {
const secret = (input.value || '').trim();
if (!secret) return;
try {
const t = await generateTOTP(secret);
if (Bridge.copySecure(t.code, 30000)) {
toast('Code copied · clears in 30s');
} else {
navigator.clipboard.writeText(t.code).then(() => {
toast('Code copied · clears in 30s');
setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000);
});
}
} catch (e) {
toast('Invalid TOTP secret', 'error');
}
});
panel.appendChild(copyBtn);
wrap.appendChild(panel);
const barWrap = el('div', { class: 'totp-bar-wrap' });
const bar = el('div', { class: 'totp-bar', id: 'soTotpProgress' });
barWrap.appendChild(bar);
wrap.appendChild(barWrap);
startTotpTick();
return wrap;
}
function soFolderField(current) {
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Folder'));
const sel = el('select', { id: 'soFolder', class: 'so-input' });
state.folders.forEach(f => {
const opt = el('option', { value: f }, f);
if (f === current) opt.selected = true;
sel.appendChild(opt);
});
wrap.appendChild(sel);
return wrap;
}
function soTagsField() {
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Tags'));
const cont = el('div', { class: 'chip-input', id: 'soTagsContainer' });
const input = el('input', {
type: 'text', id: 'soTagsField',
placeholder: 'add a tag…', autocomplete: 'off',
});
cont.appendChild(input);
wrap.appendChild(cont);
// Render existing chips
renderSoChips();
input.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const v = input.value.trim().replace(/,/g, '');
if (v && !soState.tags.includes(v)) {
soState.tags.push(v);
renderSoChips();
soDirtyCheck();
}
input.value = '';
} else if (e.key === 'Backspace' && !input.value && soState.tags.length) {
soState.tags.pop();
renderSoChips();
soDirtyCheck();
}
});
return wrap;
}
function renderSoChips() {
const cont = $('#soTagsContainer');
if (!cont) return;
const input = $('#soTagsField');
$$('.chip', cont).forEach(c => c.remove());
soState.tags.forEach((t, i) => {
const chip = el('span', { class: 'chip' });
chip.appendChild(el('span', null, t));
const x = el('button', {
type: 'button',
on: { click: () => { soState.tags.splice(i, 1); renderSoChips(); soDirtyCheck(); } },
});
x.appendChild(icon('i-x'));
chip.appendChild(x);
cont.insertBefore(chip, input);
});
}
function soDirtyCheck() {
if (!soState) return;
const cur = {
site: ($('#soSite') || {}).value || '',
username: ($('#soUsername') || {}).value || '',
password: ($('#soPassword') || {}).value || '',
folder: ($('#soFolder') || {}).value || '',
totp: ($('#soTotpSecret') || {}).value || '',
tags: soState.tags.join(','),
};
const dirty =
cur.site !== soState.original.site ||
cur.username !== soState.original.username ||
cur.password !== soState.original.password ||
cur.folder !== soState.original.folder ||
cur.totp !== soState.original.totp ||
cur.tags !== soState.original.tags;
const btn = $('#soSaveBtn');
if (btn) btn.style.display = dirty ? '' : 'none';
}
async function soSave() {
if (!soState) return;
const site = $('#soSite').value.trim();
const user = $('#soUsername').value.trim();
const pwd = $('#soPassword').value;
const fold = $('#soFolder').value;
const totp = (($('#soTotpSecret') || {}).value || '').trim();
if (!site || !pwd) return toast('Site and password required', 'error');
// Only re-encrypt if password changed; otherwise reuse stored ciphertext
let enc;
if (pwd === soState.original.password) {
enc = { encrypted: soState.originalEncrypted, iv: soState.originalIV };
} else {
enc = await encryptPwd(pwd);
}
// Same idea for TOTP: re-encrypt only if changed, send empty strings when
// cleared so the server stores NULL.
let totpEnc = '';
let totpIv = '';
if (totp !== '') {
if (totp === soState.original.totp && soState.originalTotpEncrypted) {
totpEnc = soState.originalTotpEncrypted;
totpIv = soState.originalTotpIV;
} else {
// Validate the secret can be decoded BEFORE saving — saving a
// garbled base32 wouldn't break anything but would surprise the
// user when the code panel shows "invalid secret" next time.
try { base32Decode(totp); }
catch (e) { return toast('Invalid TOTP secret (must be base32)', 'error'); }
const tEnc = await encryptTotpSecret(totp);
totpEnc = tEnc.encrypted;
totpIv = tEnc.iv;
}
}
try {
await api('/entries/' + soState.id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
site, username: user,
encrypted_password: enc.encrypted, iv: enc.iv,
totp_secret: totpEnc, totp_iv: totpIv,
folder: fold, tags: soState.tags.join(','),
}),
});
toast('Saved');
await loadEntries();
// Re-open with updated data
const updated = state.entries.find(x => x.id === soState.id);
if (updated) openSlideOver(updated.id);
else closeSlideOver();
render();
} catch (err) { toast(err.message, 'error'); }
}
function withIcon(name, label) {
const frag = document.createDocumentFragment();
frag.appendChild(icon(name));
frag.appendChild(document.createTextNode(label));
return frag;
}
function field(label, value) {
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, label));
wrap.appendChild(el('div', { class: 'slideover-field-value' }, value));
return wrap;
}
function passwordField(plain) {
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Password'));
let revealed = false;
const valueRow = el('div', { class: 'slideover-field-value' });
const span = el('span', { style: 'flex:1;font-family:JetBrains Mono,monospace;user-select:none' }, '••••••••');
const toggle = el('button', { class: 'icon-btn icon-btn-sm', title: 'Show/hide' });
toggle.appendChild(icon('i-eye'));
toggle.addEventListener('click', () => {
revealed = !revealed;
span.textContent = revealed ? plain : '••••••••';
span.style.userSelect = revealed ? 'text' : 'none';
});
const copy = el('button', { class: 'icon-btn icon-btn-sm', title: 'Copy' });
copy.appendChild(icon('i-copy'));
copy.addEventListener('click', () => {
if (Bridge.copySecure(plain, 0)) {
toast('Copied');
} else {
navigator.clipboard.writeText(plain).then(() => toast('Copied'));
}
});
valueRow.appendChild(span);
valueRow.appendChild(toggle);
valueRow.appendChild(copy);
wrap.appendChild(valueRow);
return wrap;
}
function closeSlideOver() {
stopTotpTick();
$('#slideover').classList.remove('is-open');
state.selectedId = null;
renderGrid();
}
// ============================================================
// TAG CHIP INPUT
// ============================================================
// Local mutable list of tags currently in the entry modal. Synced to the
// hidden #entryTags field on every change so saveEntry can read it.
let editingTags = [];
let chipSuggestEl = null;
let chipSuggestActive = -1;
function syncTagsHidden() {
$('#entryTags').value = editingTags.join(',');
}
function renderChips() {
const container = $('#entryTagsInput');
// Wipe existing chips but keep the input element
$$('.chip', container).forEach(c => c.remove());
const input = $('#entryTagsField');
editingTags.forEach((t, i) => {
const chip = el('span', { class: 'chip' });
chip.appendChild(el('span', null, t));
const x = el('button', {
type: 'button',
on: { click: () => { editingTags.splice(i, 1); renderChips(); syncTagsHidden(); } },
});
x.appendChild(icon('i-x'));
chip.appendChild(x);
container.insertBefore(chip, input);
});
syncTagsHidden();
}
function setEditingTags(arr) {
editingTags = (arr || []).filter(Boolean).map(t => t.trim()).filter(Boolean);
renderChips();
}
function addTag(raw) {
const t = (raw || '').trim().replace(/,/g, '');
if (!t) return;
if (editingTags.includes(t)) return;
editingTags.push(t);
renderChips();
}
function closeChipSuggest() {
if (chipSuggestEl) { chipSuggestEl.remove(); chipSuggestEl = null; }
chipSuggestActive = -1;
}
function openChipSuggest() {
closeChipSuggest();
const field = $('#entryTagsField');
const q = field.value.trim().toLowerCase();
const existing = allTags();
const candidates = existing
.filter(t => !editingTags.includes(t))
.filter(t => !q || t.toLowerCase().includes(q))
.slice(0, 8);
if (!q && candidates.length === 0) return;
chipSuggestEl = el('div', { class: 'chip-suggestions' });
if (candidates.length === 0) {
chipSuggestEl.appendChild(el('div', { class: 'chip-suggestion-empty' }, 'Press Enter to create "' + q + '"'));
} else {
candidates.forEach((t, i) => {
const item = el('div', {
class: 'chip-suggestion' + (i === 0 ? ' is-active' : ''),
on: { mousedown: ev => { ev.preventDefault(); addTag(t); field.value = ''; closeChipSuggest(); } },
}, t);
chipSuggestEl.appendChild(item);
});
chipSuggestActive = 0;
}
// Position under the chip input
const rect = $('#entryTagsInput').getBoundingClientRect();
chipSuggestEl.style.position = 'fixed';
chipSuggestEl.style.top = (rect.bottom + 4) + 'px';
chipSuggestEl.style.left = rect.left + 'px';
chipSuggestEl.style.width = Math.max(160, rect.width / 2) + 'px';
document.body.appendChild(chipSuggestEl);
}
function moveChipSuggest(dir) {
if (!chipSuggestEl) return;
const items = $$('.chip-suggestion', chipSuggestEl);
if (items.length === 0) return;
items.forEach(it => it.classList.remove('is-active'));
chipSuggestActive = (chipSuggestActive + dir + items.length) % items.length;
items[chipSuggestActive].classList.add('is-active');
}
function selectActiveSuggestion() {
if (!chipSuggestEl || chipSuggestActive < 0) return false;
const items = $$('.chip-suggestion', chipSuggestEl);
if (items[chipSuggestActive]) {
addTag(items[chipSuggestActive].textContent);
$('#entryTagsField').value = '';
closeChipSuggest();
return true;
}
return false;
}
// ============================================================
// ENTRY MODAL (new / edit)
// ============================================================
async function openEntryModal(entry) {
populateFolderSelect();
if (entry) {
$('#entryModalTitle').textContent = 'Edit entry';
$('#entryId').value = entry.id;
$('#entrySite').value = entry.site;
$('#entryUsername').value = entry.username || '';
$('#entryFolder').value = entry.folder || 'All';
setEditingTags(parseTags(entry.tags));
$('#entryPassword').value = await decryptPwd(entry.encrypted_password, entry.iv);
if ($('#entryPassword').value === '[ERROR]') $('#entryPassword').value = '';
} else {
$('#entryModalTitle').textContent = 'New entry';
$('#entryId').value = '';
$('#entryForm').reset();
$('#entryFolder').value = state.view.startsWith('folder:') ? state.view.slice(7) : 'All';
setEditingTags([]);
}
$('#entryTagsField').value = '';
closeChipSuggest();
updateEntryStrength();
$('#entryModal').classList.remove('is-hidden');
$('#entrySite').focus();
}
function closeEntryModal() {
$('#entryModal').classList.add('is-hidden');
}
function populateFolderSelect() {
const sel = $('#entryFolder');
sel.innerHTML = '';
state.folders.forEach(f => sel.appendChild(el('option', { value: f }, f)));
}
async function saveEntry(e) {
e && e.preventDefault();
// Flush any pending text in the chip input as a final tag
const pending = $('#entryTagsField').value.trim();
if (pending) { addTag(pending); $('#entryTagsField').value = ''; }
const id = $('#entryId').value;
const site = $('#entrySite').value.trim();
const user = $('#entryUsername').value.trim();
const pwd = $('#entryPassword').value;
const fold = $('#entryFolder').value;
const tags = editingTags.join(',');
if (!site || !pwd) return toast('Site and password required', 'error');
const enc = await encryptPwd(pwd);
const body = JSON.stringify({
site, username: user, encrypted_password: enc.encrypted, iv: enc.iv,
folder: fold, tags,
});
try {
if (id) {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body,
});
toast('Updated');
} else {
await api('/entries', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body,
});
toast('Saved');
}
closeEntryModal();
await loadEntries();
render();
} catch (err) {
toast(err.message, 'error');
}
}
async function restoreEntry(id) {
try {
await api('/entries/' + id + '/restore', { method: 'POST', headers: authHeaders() });
toast('Restored');
await loadEntries();
await loadTrash();
render();
} catch (err) { toast(err.message, 'error'); }
}
async function permanentDelete(id) {
const ok = await confirmDialog({
title: 'Delete forever',
message: 'This entry will be <b>permanently deleted</b>. This cannot be undone.',
okText: 'Delete forever',
danger: true,
});
if (!ok) return;
try {
await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() });
toast('Deleted permanently');
await loadTrash();
render();
} catch (err) { toast(err.message, 'error'); }
}
async function emptyTrash() {
if (!state.trashed.length) return;
const ok = await confirmDialog({
title: 'Empty trash',
message: '<b>' + state.trashed.length + '</b> entries will be deleted forever. This cannot be undone.',
okText: 'Empty trash',
danger: true,
});
if (!ok) return;
try {
await api('/entries/trash/empty', { method: 'DELETE', headers: authHeaders() });
toast('Trash emptied');
await loadTrash();
render();
} catch (err) { toast(err.message, 'error'); }
}
function openTrashActions(id) {
// For trash entries, we don't open the slide-over — actions are inline on the card.
// But user can click outside the buttons to no-op. Could open a read-only view later.
}
async function deleteEntry(id) {
const e = state.entries.find(x => x.id === id);
if (state.askBeforeDelete) {
const ok = await confirmDialog({
title: 'Move to trash',
message: 'Send <b>' + (e ? e.site : 'this entry') + '</b> to trash? You can restore it later.',
okText: 'Move to trash',
danger: true,
});
if (!ok) return;
}
try {
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
toast('Moved to trash');
closeSlideOver();
await loadEntries();
render();
} catch (err) { toast(err.message, 'error'); }
}
async function toggleFavorite(id) {
try {
await api('/entries/' + id + '/favorite', { method: 'POST', headers: authHeaders() });
const entry = state.entries.find(e => e.id === id);
if (entry) entry.favorite = entry.favorite ? 0 : 1;
render();
} catch (e) {}
}
async function moveEntryToFolder(id, folder) {
const e = state.entries.find(x => x.id === id);
if (!e || e.folder === folder) return;
try {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
site: e.site, username: e.username,
encrypted_password: e.encrypted_password, iv: e.iv,
folder, tags: e.tags || '',
}),
});
e.folder = folder;
render();
toast('Moved to ' + folder);
} catch (err) { toast(err.message, 'error'); }
}
async function copyPassword(entry) {
const p = await decryptPwd(entry.encrypted_password, entry.iv);
if (p === '[ERROR]') return toast('Cannot decrypt', 'error');
if (Bridge.copySecure(p, 30000)) {
toast('Password copied · clears in 30s');
} else {
navigator.clipboard.writeText(p).then(() => toast('Password copied · clears in 30s'));
setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000);
}
}
function copyUsername(entry) {
const u = entry.username || '';
if (!u) return toast('No username to copy', 'warning');
if (Bridge.copySecure(u, 0)) {
toast('Username copied');
} else {
navigator.clipboard.writeText(u).then(() => toast('Username copied'));
}
}
// Display helper: when `maskUsernames` setting is on, show only the first 2
// chars followed by '***'. Used in cards/list (but slide-over always reveals).
function displayUsername(u) {
if (!u) return '—';
if (!state.maskUsernames) return u;
if (u.length <= 2) return u + '***';
return u.slice(0, 2) + '***';
}
// ============================================================
// FOLDERS CRUD
// ============================================================
async function addFolder() {
const name = await promptDialog({
title: 'New folder',
message: 'Folder name',
placeholder: 'e.g. Work',
okText: 'Create',
});
if (!name || !name.trim()) return;
try {
await api('/folders', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ name: name.trim() }),
});
await loadFolders();
render();
toast('Folder created');
} catch (e) { toast(e.message, 'error'); }
}
// ============================================================
// PASSWORD GENERATOR
// ============================================================
let genCurrent = '';
function genPassword() {
const len = parseInt($('#genLen').value);
$('#genLenLabel').textContent = len;
let chars = '';
if ($('#genUpper').checked) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
if ($('#genLower').checked) chars += 'abcdefghijklmnopqrstuvwxyz';
if ($('#genNum').checked) chars += '0123456789';
if ($('#genSym').checked) chars += '!@#$%^&*()_+-=[]{}|;:,.<>?';
if (!chars) { $('#genPreview').textContent = 'Select at least one'; return; }
let p = '';
const max = 256 - (256 % chars.length);
const buf = new Uint8Array(1);
for (let i = 0; i < len; i++) {
do { crypto.getRandomValues(buf); } while (buf[0] >= max);
p += chars.charAt(buf[0] % chars.length);
}
genCurrent = p;
$('#genPreview').textContent = p;
}
// genTarget: 'entry' (insert into entry form) or 'standalone' (just copy/dismiss)
let genTarget = 'entry';
function openGen(target) {
genTarget = target || 'entry';
// Show "Use" when targeting an editable field (entry modal or slide-over)
$('#genUse').style.display = (genTarget === 'standalone') ? 'none' : '';
$('#genModal').classList.remove('is-hidden');
genPassword();
}
function closeGen() { $('#genModal').classList.add('is-hidden'); }
// ============================================================
// PASSWORD STRENGTH
// ============================================================
function computeStrength(p) {
let s = 0;
if (p.length >= 8) s += 25;
if (p.length >= 12) s += 15;
if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s += 20;
if (/\d/.test(p)) s += 15;
if (/[^A-Za-z0-9]/.test(p)) s += 25;
return Math.min(100, s);
}
function updateRegStrength() {
const p = $('#regPassword').value;
$('#regStrengthBar').style.setProperty('--strength', computeStrength(p) + '%');
}
function updateEntryStrength() {
const p = $('#entryPassword').value;
$('#entryStrengthBar').style.setProperty('--strength', computeStrength(p) + '%');
}
// ============================================================
// COMMAND PALETTE
// ============================================================
function openPalette() {
$('#cmdPalette').classList.remove('is-hidden');
$('#cmdInput').value = '';
$('#cmdInput').focus();
renderPaletteResults('');
}
function closePalette() { $('#cmdPalette').classList.add('is-hidden'); }
function paletteCommands() {
return [
{ id: 'new', label: 'New entry', icon: 'i-plus', run: () => { closePalette(); openEntryModal(); } },
{ id: 'lock', label: 'Lock vault', icon: 'i-lock', run: () => { closePalette(); lockVault(); } },
{ id: 'logout', label: 'Sign out', icon: 'i-log-out', run: () => { closePalette(); doLogout(); } },
{ id: 'theme', label: 'Toggle theme', icon: 'i-sun', run: () => { closePalette(); toggleTheme(); } },
{ id: 'all', label: 'Show all items', icon: 'i-globe', run: () => { closePalette(); setView('all'); } },
{ id: 'fav', label: 'Show favorites', icon: 'i-star', run: () => { closePalette(); setView('favorites'); } },
{ id: 'trash', label: 'Show trash', icon: 'i-trash', run: () => { closePalette(); setView('trash'); } },
];
}
function renderPaletteResults(q) {
const cmds = paletteCommands();
const entries = state.entries.map(e => ({
id: 'entry-' + e.id, label: e.site, sub: e.username || '',
icon: 'i-globe', run: () => { closePalette(); openSlideOver(e.id); },
}));
const all = cmds.concat(entries);
q = (q || '').toLowerCase();
const filtered = q ? all.filter(c => c.label.toLowerCase().includes(q) || (c.sub||'').toLowerCase().includes(q)) : all;
const out = $('#cmdResults');
out.innerHTML = '';
filtered.slice(0, 12).forEach((c, i) => {
const it = el('div', {
class: 'cmd-item' + (i === 0 ? ' is-active' : ''),
on: { click: c.run },
});
it.appendChild(icon(c.icon));
it.appendChild(el('span', null, c.label));
if (c.sub) it.appendChild(el('span', { style: 'color:var(--text-faint);font-size:11px;margin-left:auto' }, c.sub));
out.appendChild(it);
});
}
// ============================================================
// IN-APP CONFIRM / PROMPT (no native alerts)
// ============================================================
let confirmResolver = null;
function confirmDialog(opts) {
// opts: { title, message, okText, cancelText, danger }
opts = opts || {};
$('#confirmTitle').textContent = opts.title || 'Confirm';
$('#confirmMessage').innerHTML = opts.message || 'Are you sure?';
$('#confirmOkBtn').lastChild.nodeValue = ' ' + (opts.okText || 'Confirm');
$('#confirmCancelBtn').textContent = opts.cancelText || 'Cancel';
$('#confirmOkBtn').classList.toggle('is-danger', !!opts.danger);
$('#confirmInputField').classList.add('is-hidden');
$('#confirmModal').classList.remove('is-hidden');
setTimeout(() => $('#confirmOkBtn').focus(), 50);
return new Promise(res => { confirmResolver = res; });
}
function promptDialog(opts) {
// opts: { title, message, okText, placeholder, value, password }
opts = opts || {};
$('#confirmTitle').textContent = opts.title || 'Enter value';
$('#confirmMessage').innerHTML = opts.message || '';
$('#confirmOkBtn').lastChild.nodeValue = ' ' + (opts.okText || 'OK');
$('#confirmCancelBtn').textContent = 'Cancel';
$('#confirmOkBtn').classList.remove('is-danger');
$('#confirmInputField').classList.remove('is-hidden');
$('#confirmInput').value = opts.value || '';
$('#confirmInput').placeholder = opts.placeholder || '';
// Allow password-style masking (used by encrypted import/export).
$('#confirmInput').type = opts.password ? 'password' : 'text';
$('#confirmModal').classList.remove('is-hidden');
setTimeout(() => $('#confirmInput').focus(), 50);
return new Promise(res => { confirmResolver = res; });
}
function closeConfirm(value) {
$('#confirmModal').classList.add('is-hidden');
if (confirmResolver) {
const cb = confirmResolver;
confirmResolver = null;
cb(value);
}
}
// ============================================================
// RE-AUTH MODAL + EXPORT
// ============================================================
let reauthResolve = null;
function askReauth(message) {
return new Promise(resolve => {
reauthResolve = resolve;
$('#reauthMessage').textContent = message || 'This action requires your master password.';
$('#reauthPassword').value = '';
$('#reauthModal').classList.remove('is-hidden');
setTimeout(() => $('#reauthPassword').focus(), 50);
});
}
function closeReauth(ok) {
$('#reauthModal').classList.add('is-hidden');
if (reauthResolve) {
const pwd = ok ? $('#reauthPassword').value : null;
const cb = reauthResolve;
reauthResolve = null;
cb(pwd);
}
}
// ============================================================
// QUICK UNLOCK — remember on this device (DPAPI-backed)
// ============================================================
//
// User-controlled convenience feature. When opted in, the current vault
// state (raw AES key + salt + username + session token) is bundled into
// a JSON blob and handed to the Delphi side, which DPAPI-encrypts it
// (CRYPTPROTECT_CURRENT_USER) and stashes the blob on disk. Subsequent
// app starts can recover the entire session without re-entering the
// master password.
//
// Honest trade-off: the encrypted blob is readable by ANY process
// running as the same Windows user. The protection is no stronger than
// the Windows account itself. Users with Windows Hello / fingerprint /
// PIN configured at the OS level get biometric gating transitively
// (via login). Without that, this is "remember on trusted device".
//
// Only available when running inside the Delphi host (Bridge.active);
// the PHP standalone has no DPAPI equivalent.
// Resolver for the Delphi → JS callback. Delphi side fires
// Bridge.onQuickUnlockResult(b64|null) after processing cmd://quickunlock/get.
let quickUnlockResolver = null;
// Same for the status query.
let quickUnlockStatusResolver = null;
function bridgeRequestQuickUnlock() {
if (!Bridge.active) return Promise.resolve(null);
return new Promise(resolve => {
quickUnlockResolver = resolve;
// Safety: if Delphi never responds, time out after 3 s so the auth
// screen doesn't hang. Falls back to master-pw login.
setTimeout(() => {
if (quickUnlockResolver === resolve) {
quickUnlockResolver = null;
resolve(null);
}
}, 3000);
window.location.href = 'cmd://quickunlock/get';
});
}
function bridgeQuickUnlockStatus() {
if (!Bridge.active) return Promise.resolve(false);
return new Promise(resolve => {
quickUnlockStatusResolver = resolve;
setTimeout(() => {
if (quickUnlockStatusResolver === resolve) {
quickUnlockStatusResolver = null;
resolve(false);
}
}, 2000);
window.location.href = 'cmd://quickunlock/status';
});
}
// Patch Bridge.onQuickUnlockResult / Status to feed the resolvers above.
Bridge.onQuickUnlockResult = function(b64) {
if (quickUnlockResolver) {
const cb = quickUnlockResolver;
quickUnlockResolver = null;
cb(b64);
}
};
Bridge.onQuickUnlockStatus = function(configured) {
if (quickUnlockStatusResolver) {
const cb = quickUnlockStatusResolver;
quickUnlockStatusResolver = null;
cb(!!configured);
}
};
async function enableQuickUnlock() {
if (!Bridge.active) return toast('Quick unlock requires the Delphi app', 'warning');
if (!state.cryptoKey) return toast('Unlock the vault first', 'warning');
const masterPwd = await askReauth(
'Confirm your master password to enable Quick unlock on this device.');
if (!masterPwd) return;
try {
const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000);
await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ verifier: verifier }),
});
} catch (err) {
return toast('Wrong master password', 'error');
}
// Export the raw key + bundle session pieces needed for a cold-start
// restore (no master pw available). Send as base64-encoded UTF-8 JSON.
const raw = await crypto.subtle.exportKey('raw', state.cryptoKey);
const blob = JSON.stringify({
v: 1,
username: state.username,
salt: state.salt,
kdfIterations: state.kdfIterations,
token: state.token,
csrf: state.csrf,
key: bytesToBase64(raw),
});
const b64 = bytesToBase64(new TextEncoder().encode(blob));
window.location.href = 'cmd://quickunlock/store?data=' + encodeURIComponent(b64);
localStorage.setItem('quickUnlockEnabled', '1');
state.quickUnlockEnabled = true;
if ($('#quickUnlockStatus')) updateQuickUnlockUI();
toast('Quick unlock enabled');
}
async function disableQuickUnlock() {
window.location.href = 'cmd://quickunlock/clear';
localStorage.removeItem('quickUnlockEnabled');
state.quickUnlockEnabled = false;
if ($('#quickUnlockStatus')) updateQuickUnlockUI();
toast('Quick unlock disabled');
}
function updateQuickUnlockUI() {
const lbl = $('#quickUnlockStatus');
const btn = $('#quickUnlockToggleBtn');
if (!lbl || !btn) return;
if (state.quickUnlockEnabled) {
lbl.textContent = 'Enabled on this device.';
btn.textContent = 'Disable';
btn.classList.add('is-danger');
} else {
lbl.textContent = 'Disabled.';
btn.textContent = 'Enable on this device';
btn.classList.remove('is-danger');
}
}
// Try to unlock via DPAPI. Returns true if the vault is now unlocked,
// false otherwise (caller falls back to master-pw login).
async function tryQuickUnlock() {
if (!Bridge.active) return false;
if (localStorage.getItem('quickUnlockEnabled') !== '1') return false;
const b64 = await bridgeRequestQuickUnlock();
if (!b64) return false;
let parsed;
try {
const jsonStr = new TextDecoder().decode(base64ToBytes(b64));
parsed = JSON.parse(jsonStr);
} catch (e) {
return false;
}
if (!parsed || !parsed.key || !parsed.salt || !parsed.username) return false;
// Restore session state from the blob.
state.username = parsed.username;
state.salt = parsed.salt;
state.kdfIterations = parsed.kdfIterations || 600000;
state.token = parsed.token || sessionStorage.getItem('authToken') || '';
state.csrf = parsed.csrf || sessionStorage.getItem('csrfToken') || '';
sessionStorage.setItem('username', state.username);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
if (state.token) sessionStorage.setItem('authToken', state.token);
if (state.csrf) sessionStorage.setItem('csrfToken', state.csrf);
try {
state.cryptoKey = await crypto.subtle.importKey(
'raw', base64ToBytes(parsed.key),
{ name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
await persistCryptoKey();
} catch (e) {
return false;
}
state.locked = false;
return true;
}
// ============================================================
// RECOVERY KEY — one-shot emergency access
// ============================================================
//
// Generated at the user's request from Settings. The plaintext code is
// shown exactly once; the server stores only SHA-256(code) for lookup
// + an AES-GCM wrap of the current vault key under a KEK derived from
// PBKDF2(code, kdfSalt, 600k).
//
// Recovery flow (master pw forgotten):
// 1. Auth screen → "Use recovery code" → enter username + code
// 2. Server hashes code, looks up user, verifies match, DELETES the
// recovery row (single-use), returns wrapped key + KEK salt +
// a fresh session.
// 3. Client derives the KEK, unwraps the AES key.
// 4. Client immediately forces a master-password change so the
// account isn't left with the recovery code's KEK as the only
// escape hatch.
// Random recovery code: 16 chars in 4 groups of 4. ~96 bits entropy
// from a 36-char alphabet (no ambiguous chars: no 0/O/I/l/1) so the
// printed form is misreading-resistant.
function generateRecoveryCode() {
const A = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 32 chars
const bytes = crypto.getRandomValues(new Uint8Array(16));
let s = '';
for (let i = 0; i < 16; i++) {
if (i > 0 && i % 4 === 0) s += '-';
s += A[bytes[i] % A.length];
}
return s;
}
async function sha256HexLocal(input) {
const buf = new TextEncoder().encode(input);
const hashBuf = await crypto.subtle.digest('SHA-256', buf);
const bytes = new Uint8Array(hashBuf);
let hex = '';
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
return hex;
}
// Derive a KEK from the recovery code + per-row salt, then wrap the
// supplied AES key bytes under it. Returns base64 ciphertext + IV.
async function wrapAesKeyForRecovery(aesKeyBytes, recoveryCode, kdfSaltHex) {
const saltBytes = new TextEncoder().encode(kdfSaltHex); // match deriveKey's quirk
const km = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(recoveryCode),
'PBKDF2', false, ['deriveKey']);
const kek = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: saltBytes, iterations: 600000, hash: 'SHA-256' },
km,
{ name: 'AES-GCM', length: 256 },
false, ['encrypt', 'decrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, kek, aesKeyBytes);
return { wrappedKey: bytesToBase64(ct), wrappedIv: bytesToBase64(iv) };
}
async function unwrapAesKeyFromRecovery(wrappedKeyB64, wrappedIvB64, recoveryCode, kdfSaltHex) {
const saltBytes = new TextEncoder().encode(kdfSaltHex);
const km = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(recoveryCode),
'PBKDF2', false, ['deriveKey']);
const kek = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: saltBytes, iterations: 600000, hash: 'SHA-256' },
km,
{ name: 'AES-GCM', length: 256 },
false, ['encrypt', 'decrypt']);
const iv = base64ToBytes(wrappedIvB64);
const ct = base64ToBytes(wrappedKeyB64);
return await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, kek, ct); // raw bytes
}
// Generate a new recovery key for the logged-in user. Shows the plaintext
// code in a modal that the user must explicitly acknowledge before closing.
async function doGenerateRecoveryKey() {
if (!state.cryptoKey) { return toast('Vault locked', 'warning'); }
const masterPwd = await askReauth(
'Confirm your master password to generate a recovery key.');
if (!masterPwd) return;
// Generate the code + a fresh per-row salt for the KEK PBKDF2. Salt is
// per-recovery so regenerating doesn't reuse the same KDF parameters.
const code = generateRecoveryCode();
const codeHash = await sha256HexLocal(code);
const kdfSalt = randomHexSalt();
// Export the current AES key as raw bytes so we can wrap it under
// the recovery KEK. The export only works because deriveKey was
// called with `extractable=true` — already the case in our code.
const rawKey = await crypto.subtle.exportKey('raw', state.cryptoKey);
const { wrappedKey, wrappedIv } = await wrapAesKeyForRecovery(
rawKey, code, kdfSalt);
try {
// Send a verifier instead of the master pw — server proves the
// user still knows the master pw without ever seeing the plaintext.
const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000);
await api('/recovery-key/setup', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
verifier: verifier,
codeHash: codeHash,
kdfSalt: kdfSalt,
wrappedKey: wrappedKey,
wrappedIv: wrappedIv,
}),
});
} catch (err) {
return toast('Setup failed: ' + (err.message || ''), 'error');
}
// Refresh the Settings button label
state.recoveryConfigured = true;
if ($('#recoveryStatus')) updateRecoveryStatusLabel();
// Show the code ONCE. Use the confirm modal so the user has to
// explicitly click "I saved it" before the value vanishes.
await confirmDialog({
title: 'Your recovery code',
message: '<p>Save this code somewhere safe (password manager, ' +
'safe deposit box, printed copy). It will <b>not</b> be ' +
'shown again.</p>' +
'<p style="font-family:JetBrains Mono,monospace;font-size:20px;' +
'letter-spacing:2px;text-align:center;padding:14px;' +
'background:var(--bg-elev);border-radius:6px;user-select:all">' +
code + '</p>' +
'<p style="color:var(--text-dim);font-size:12px">' +
'Using it later will let you recover access if you forget ' +
'your master password. The code is single-use.</p>',
okText: 'I saved it',
});
toast('Recovery code generated');
}
async function doRemoveRecoveryKey() {
const ok = await confirmDialog({
title: 'Remove recovery key',
message: 'You will lose your ability to recover this account if you ' +
'forget the master password. Continue?',
okText: 'Remove',
danger: true,
});
if (!ok) return;
try {
await api('/recovery-key', {
method: 'DELETE',
headers: authHeaders(),
});
state.recoveryConfigured = false;
if ($('#recoveryStatus')) updateRecoveryStatusLabel();
toast('Recovery key removed');
} catch (err) { toast(err.message, 'error'); }
}
function updateRecoveryStatusLabel() {
const lbl = $('#recoveryStatus');
const setupBtn = $('#recoverySetupBtn');
const removeBtn = $('#recoveryRemoveBtn');
if (!lbl) return;
if (state.recoveryConfigured) {
lbl.textContent = 'Recovery key is configured.';
if (setupBtn) setupBtn.textContent = 'Regenerate code';
if (removeBtn) removeBtn.style.display = '';
} else {
lbl.textContent = 'No recovery key set.';
if (setupBtn) setupBtn.textContent = 'Generate recovery code';
if (removeBtn) removeBtn.style.display = 'none';
}
}
async function refreshRecoveryStatus() {
try {
const r = await api('/recovery-key/status', { headers: authHeaders() });
state.recoveryConfigured = !!r.configured;
updateRecoveryStatusLabel();
} catch (e) { /* ignore */ }
}
// Recovery redeem flow — called from the auth screen when the user clicks
// "Use a recovery code". Prompts for username + code, redeems, unwraps the
// vault key, immediately forces a master password change.
async function doRecoveryRedeem() {
const u = await promptDialog({
title: 'Recover access',
message: 'Enter your username — we\'ll ask for the recovery code next.',
placeholder: 'Username',
okText: 'Continue',
});
if (!u) return;
const code = await promptDialog({
title: 'Enter recovery code',
message: 'Recovery codes look like XXXX-XXXX-XXXX-XXXX. ' +
'They\'re single-use — using one will remove it from your account.',
placeholder: 'XXXX-XXXX-XXXX-XXXX',
okText: 'Recover',
password: true,
});
if (!code) return;
let r;
try {
r = await api('/recovery-key/redeem', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u.trim(), code: code.trim() }),
});
} catch (err) {
if (err.status === 429 && err.body && err.body.retry_after) {
return showLockoutCountdown(err.body.retry_after);
}
return toast('Recovery failed: ' + (err.message || 'invalid code'), 'error');
}
// Unwrap the vault key with the code the user just typed.
let rawKey;
try {
rawKey = await unwrapAesKeyFromRecovery(
r.wrappedKey, r.wrappedIv, code.trim(), r.kdfSalt);
} catch (e) {
return toast('Could not decrypt vault — wrong code?', 'error');
}
// Reconstitute state from the new session.
state.token = r.token;
state.csrf = r.csrfToken;
state.salt = r.salt;
state.username = u.trim();
state.kdfIterations = r.kdfIterations || 600000;
sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('username', state.username);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
// Import the raw key bytes as a fresh AES-GCM CryptoKey (extractable
// so master-pw change can later re-export and re-wrap as needed).
state.cryptoKey = await crypto.subtle.importKey(
'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
await persistCryptoKey();
toast('Access recovered — please set a new master password');
await enterApp();
// Force a master pw change immediately. The recovery code is consumed
// (server deleted the row); the account is currently orphaned from
// a "we know who you are" perspective. Setting a new master pw both
// restores normal login AND lets the user generate a fresh recovery
// code afterwards.
setTimeout(openChangeMasterModal, 300);
}
// ============================================================
// CHANGE MASTER PASSWORD
// ============================================================
//
// Two-step flow:
// 1. Modal collects current pw + new pw (×2) + validates locally.
// 2. Client re-encrypts every entry under the new key (derived from
// new pw + freshly-generated salt) and sends the whole payload to
// /change-master-password. Server verifies current pw, then commits
// user row + all entry ciphertexts in one transaction.
// On success: state.salt + state.cryptoKey are swapped, the user stays
// logged in (current session preserved), all OTHER sessions invalidated.
// 64-char hex salt — matches the format the server expects and the shape
// produced by Delphi RandomHex(32). Crypto-secure RNG.
function randomHexSalt() {
const bytes = crypto.getRandomValues(new Uint8Array(32));
let hex = '';
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
return hex;
}
function openChangeMasterModal() {
$('#cmCurrentPwd').value = '';
$('#cmNewPwd').value = '';
$('#cmConfirmPwd').value = '';
const errEl = $('#cmError');
if (errEl) { errEl.textContent = ''; errEl.style.display = 'none'; }
$('#changeMasterModal').classList.remove('is-hidden');
setTimeout(() => $('#cmCurrentPwd').focus(), 50);
}
function closeChangeMasterModal() {
$('#changeMasterModal').classList.add('is-hidden');
}
function showCmError(msg) {
const el = $('#cmError');
if (!el) return;
el.textContent = msg;
el.style.display = '';
}
async function doChangeMasterPassword() {
const curPwd = $('#cmCurrentPwd').value;
const newPwd = $('#cmNewPwd').value;
const confPwd = $('#cmConfirmPwd').value;
// Local validation. Server enforces these too, but failing fast saves
// a round trip + leaves the modal open so the user can fix and retry.
if (!curPwd || !newPwd || !confPwd) return showCmError('All fields are required');
if (newPwd.length < 8) return showCmError('New password must be at least 8 characters');
if (newPwd !== confPwd) return showCmError('New password and confirmation do not match');
if (newPwd === curPwd) return showCmError('New password must differ from the current one');
// Disable the confirm button so a double-click doesn't fire two
// re-encryption passes in parallel.
const btn = $('#cmConfirmBtn');
if (btn) btn.disabled = true;
try {
// Step 1: generate the new salt and derive the new AES key + verifier.
// Also compute the verifier for the CURRENT pw so the server can
// authenticate the change without ever seeing the plaintext.
const newSalt = randomHexSalt();
const newDerived = await deriveKeyAndVerifier(newPwd, newSalt, 600000);
const newKey = newDerived.cryptoKey;
const currentVerifier = await computeVerifier(
curPwd, state.salt, state.kdfIterations || 100000);
// Step 2: re-encrypt every entry's password AND every entry's TOTP
// secret (if present) under the new key. The current state.cryptoKey
// still decrypts the existing ciphertext.
const encrypted = [];
for (const e of state.entries) {
const plain = await decryptPwd(e.encrypted_password, e.iv);
if (plain === '[ERROR]') {
throw new Error('Could not decrypt entry id=' + e.id);
}
// Swap the key around encryptPwd so it picks up the new one.
const oldKey = state.cryptoKey;
state.cryptoKey = newKey;
try {
const re = await encryptPwd(plain);
let totpEnc = '', totpIv = '';
if (e.totp_secret && e.totp_iv) {
state.cryptoKey = oldKey;
const plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
state.cryptoKey = newKey;
if (plainTotp !== '[ERROR]') {
const t = await encryptPwd(plainTotp);
totpEnc = t.encrypted;
totpIv = t.iv;
}
}
encrypted.push({
id: e.id,
encrypted_password: re.encrypted,
iv: re.iv,
totp_secret: totpEnc,
totp_iv: totpIv,
});
} finally {
state.cryptoKey = oldKey; // restore until server confirms
}
}
// Step 3: send the atomic request. Server verifies the current pw,
// updates the user row, swaps every entry's ciphertext, returns
// the new salt + iter count.
const r = await api('/change-master-password', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
currentVerifier: currentVerifier,
newVerifier: newDerived.verifier,
newSalt: newSalt,
entries: encrypted,
}),
});
// Step 4: server committed → switch the in-memory key & salt, refresh
// the cached ciphertexts, persist for F5 survival.
state.salt = r.salt || newSalt;
state.kdfIterations = r.kdfIterations || 600000;
state.cryptoKey = newKey;
await persistCryptoKey();
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
for (let i = 0; i < state.entries.length; i++) {
const nc = encrypted[i];
state.entries[i].encrypted_password = nc.encrypted_password;
state.entries[i].iv = nc.iv;
state.entries[i].totp_secret = nc.totp_secret || null;
state.entries[i].totp_iv = nc.totp_iv || null;
}
// The DPAPI quick-unlock blob (if any) still holds the OLD AES key
// bundle, which would unlock to entries encrypted with the new key
// → unreadable. Clear it; user can re-enable from Settings.
if (Bridge.active && localStorage.getItem('quickUnlockEnabled') === '1') {
window.location.href = 'cmd://quickunlock/clear';
localStorage.removeItem('quickUnlockEnabled');
state.quickUnlockEnabled = false;
}
closeChangeMasterModal();
toast('Master password changed · other sessions signed out');
} catch (err) {
if (err.status === 401) {
showCmError('Current password is incorrect');
} else if (err.status === 429 && err.body && err.body.retry_after) {
showCmError('Account locked, try again in ' +
Math.ceil(err.body.retry_after / 60) + ' min');
} else {
showCmError('Failed: ' + (err.message || 'unknown error'));
}
} finally {
if (btn) btn.disabled = false;
}
}
// ============================================================
// ENCRYPTED EXPORT CONTAINER
// ============================================================
//
// File format (JSON):
// {
// "format": "pm-encrypted-export-v1",
// "kdf": "pbkdf2-sha256",
// "kdf_iterations": 600000,
// "kdf_salt": "<base64 random 32 bytes>",
// "iv": "<base64 random 12 bytes>",
// "ciphertext":"<base64 AES-GCM ciphertext of JSON.stringify(payload)>",
// "created_at": "<ISO timestamp>"
// }
// payload = same shape produced by the plaintext exporter (entries array).
//
// The encryption password is INDEPENDENT of the master password — the
// user picks it at export time and provides it again at import time.
// Decoupling means a master-password change doesn't brick old backups,
// and the backup can be shared without revealing the master pw.
function bytesToBase64(arr) {
if (arr instanceof ArrayBuffer) arr = new Uint8Array(arr);
let s = '';
for (let i = 0; i < arr.length; i++) s += String.fromCharCode(arr[i]);
return btoa(s);
}
function base64ToBytes(b64) {
return Uint8Array.from(atob(b64), c => c.charCodeAt(0));
}
// Derive an AES-GCM key from a user-chosen export password + random salt.
// Uses the same 600k iteration PBKDF2 as the rest of the app.
async function deriveExportKey(password, saltBytes, iterations) {
const km = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(password),
'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: saltBytes, iterations: iterations, hash: 'SHA-256' },
km,
{ name: 'AES-GCM', length: 256 },
false, ['encrypt', 'decrypt']);
}
async function encryptExportPayload(payloadObj, exportPwd) {
const plaintext = new TextEncoder().encode(JSON.stringify(payloadObj));
const salt = crypto.getRandomValues(new Uint8Array(32));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveExportKey(exportPwd, salt, 600000);
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext);
return {
format: 'pm-encrypted-export-v1',
kdf: 'pbkdf2-sha256',
kdf_iterations: 600000,
kdf_salt: bytesToBase64(salt),
iv: bytesToBase64(iv),
ciphertext: bytesToBase64(ct),
created_at: new Date().toISOString(),
};
}
async function decryptExportContainer(container, exportPwd) {
const salt = base64ToBytes(container.kdf_salt);
const iv = base64ToBytes(container.iv);
const ct = base64ToBytes(container.ciphertext);
const key = await deriveExportKey(exportPwd, salt, container.kdf_iterations || 600000);
const plainBuf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
return JSON.parse(new TextDecoder().decode(plainBuf));
}
// ============================================================
// IMPORT — JSON (native round-trip) + CSV (universal)
// ============================================================
//
// Two supported input formats:
// 1. Native JSON: the same shape produced by doExport() above
// { version, exported_at, username, entries: [
// { site, username, password, folder, tags, favorite, ... }
// ]}
// 2. CSV: with a header row. Column names are mapped heuristically so
// exports from Bitwarden / KeePass / Chrome / 1Password generally
// "just work" without manual column mapping.
//
// Each parsed entry is encrypted client-side with the vault key (same
// flow as a single-entry add), then sent to /entries/bulk-import as one
// transactional batch.
// Minimal RFC 4180-ish CSV parser. Handles quoted fields, escaped quotes
// (""), commas inside quotes, and CRLF line endings. Returns an array of
// arrays (rows × columns). No streaming — fine for the ~MB-scale imports
// a password manager realistically deals with.
function parseCSV(text) {
const rows = [];
let row = [], field = '', inQuotes = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inQuotes) {
if (c === '"') {
if (text[i + 1] === '"') { field += '"'; i++; } // escaped ""
else inQuotes = false;
} else field += c;
} else {
if (c === '"') inQuotes = true;
else if (c === ',') { row.push(field); field = ''; }
else if (c === '\n' || c === '\r') {
if (c === '\r' && text[i + 1] === '\n') i++; // CRLF
row.push(field); field = '';
if (row.length > 1 || (row.length === 1 && row[0] !== '')) rows.push(row);
row = [];
} else field += c;
}
}
// Flush trailing field/row (file without final newline)
if (field !== '' || row.length > 0) { row.push(field); rows.push(row); }
return rows;
}
// Header heuristics: pick the first matching column name (case-insensitive,
// underscore/space-tolerant). Returns null if no candidate header matches.
function findColumn(headers, candidates) {
const norm = s => String(s || '').toLowerCase().replace(/[\s_-]+/g, '');
const cand = candidates.map(norm);
for (let i = 0; i < headers.length; i++) {
if (cand.indexOf(norm(headers[i])) >= 0) return i;
}
return null;
}
// Parse a CSV text into an array of plaintext entries
// ({ site, username, password, folder, tags, totp_secret }). Returns
// { entries, skipped, columns } so the preview can show what was matched.
function parseEntriesFromCSV(text) {
const rows = parseCSV(text);
if (rows.length < 2) {
throw new Error('CSV needs a header row and at least one data row');
}
const headers = rows[0];
// Candidate names per format observed in real exports:
// Bitwarden CSV : folder, name, login_uri, login_username, login_password, login_totp, notes
// KeePass CSV : Title, URL, Username, Password, Group, Notes
// Chrome/Edge : name, url, username, password
// 1Password CSV : Title, URL, Username, Password, Notes
// findColumn normalizes (lowercase, strip _ and -) so 'login_uri' and
// 'loginuri' both match the same candidate.
const colSite = findColumn(headers, ['name', 'title', 'url', 'site', 'website', 'login_uri', 'login_url', 'web_site']);
const colUser = findColumn(headers, ['login_username', 'username', 'user', 'login', 'email', 'user_name']);
const colPwd = findColumn(headers, ['login_password', 'password', 'pass', 'pwd']);
const colFolder = findColumn(headers, ['folder', 'group', 'category', 'path', 'collection']);
const colTags = findColumn(headers, ['tags', 'labels']);
const colNotes = findColumn(headers, ['notes', 'note', 'comment', 'comments']);
const colTotp = findColumn(headers, ['login_totp', 'totp', 'totp_secret', 'otp', 'otpauth', 'authenticator', 'two_factor', 'twofa']);
if (colSite === null && colUser === null)
throw new Error('No recognizable site/url or username column in CSV header');
if (colPwd === null)
throw new Error('No recognizable password column in CSV header');
const entries = [];
let skipped = 0;
for (let i = 1; i < rows.length; i++) {
const r = rows[i];
const site = (colSite !== null ? r[colSite] : '').trim() ||
(colUser !== null ? r[colUser] : '').trim();
const pwd = (colPwd !== null ? r[colPwd] : '');
if (!site || !pwd) { skipped++; continue; }
// Tags: combine the tags column and any free-form notes into a
// comma-separated string. Notes often contain useful metadata we
// don't want to drop on the floor.
let tagsArr = [];
if (colTags !== null) {
String(r[colTags] || '').split(/[,;]/).forEach(t => {
t = t.trim();
if (t) tagsArr.push(t);
});
}
if (colNotes !== null) {
const n = String(r[colNotes] || '').trim();
if (n && n.length < 80) tagsArr.push(n); // long notes become noise as tags
}
// TOTP: support raw base32 OR full otpauth:// URI in the cell.
let totp = '';
if (colTotp !== null) {
const raw = String(r[colTotp] || '').trim();
totp = parseOtpAuthUri(raw) || raw;
}
entries.push({
site: site,
username: (colUser !== null ? r[colUser] : '').trim(),
password: pwd,
folder: (colFolder !== null ? r[colFolder] : '').trim() || 'All',
tags: tagsArr.join(','),
totp_secret: totp,
});
}
return { entries, skipped, columns: {
site: colSite, username: colUser, password: colPwd,
folder: colFolder, tags: colTags, notes: colNotes, totp: colTotp,
} };
}
// Parse a native JSON export. Forgiving: accepts both our own format and
// a flat array of entry objects.
function parseEntriesFromJSON(text) {
let data;
try { data = JSON.parse(text); }
catch (e) { throw new Error('Invalid JSON: ' + e.message); }
const raw = Array.isArray(data) ? data : (data.entries || []);
if (!Array.isArray(raw) || raw.length === 0)
throw new Error('No entries in JSON file');
const entries = [];
let skipped = 0;
for (const e of raw) {
if (!e || typeof e !== 'object') { skipped++; continue; }
const site = String(e.site || e.url || e.name || '').trim();
const pwd = String(e.password || '');
if (!site || !pwd) { skipped++; continue; }
const tagsVal = e.tags;
const tagsStr = Array.isArray(tagsVal) ? tagsVal.join(',')
: String(tagsVal || '');
entries.push({
site: site,
username: String(e.username || e.user || e.login || '').trim(),
password: pwd,
folder: String(e.folder || e.group || 'All').trim() || 'All',
tags: tagsStr,
totp_secret: String(e.totp || e.totp_secret || e.otpauth || '').trim(),
});
}
return { entries, skipped, columns: null }; // JSON: no column report
}
// Encrypt one parsed entry (plaintext password + optional TOTP) into the
// shape the bulk-import endpoint expects. Reuses encryptPwd which already
// generates a fresh IV per call.
async function encryptImportEntry(plain) {
const pw = await encryptPwd(plain.password);
let totpEnc = '', totpIv = '';
if (plain.totp_secret) {
try {
base32Decode(plain.totp_secret); // validate before encrypting
const t = await encryptPwd(plain.totp_secret);
totpEnc = t.encrypted;
totpIv = t.iv;
} catch (e) {
// Bad TOTP secret in source file — keep the entry but drop the
// 2FA silently. The user can fix it later via the slide-over.
}
}
return {
site: plain.site,
username: plain.username || '',
encrypted_password: pw.encrypted,
iv: pw.iv,
folder: plain.folder || 'All',
tags: plain.tags || '',
totp_secret: totpEnc,
totp_iv: totpIv,
};
}
// Open a hidden file picker, route the result through the right parser,
// show a preview confirmation, then bulk-encrypt + POST.
async function doImport() {
const fileInput = el('input', {
type: 'file',
accept: '.json,.csv,application/json,text/csv',
style: 'display:none',
});
document.body.appendChild(fileInput);
fileInput.addEventListener('change', async () => {
const file = fileInput.files && fileInput.files[0];
fileInput.remove();
if (!file) return;
let text;
try { text = await file.text(); }
catch (e) { return toast('Cannot read file: ' + e.message, 'error'); }
const isJSON = /\.json$/i.test(file.name) || text.trim().startsWith('{') || text.trim().startsWith('[');
// If the JSON is an encrypted-export container, prompt for the
// backup password and decrypt before handing the plaintext payload
// to the regular JSON parser.
if (isJSON) {
let raw;
try { raw = JSON.parse(text); } catch (e) { raw = null; }
if (raw && raw.format === 'pm-encrypted-export-v1') {
const pw = await promptDialog({
title: 'Encrypted backup',
message: 'This backup is encrypted. Enter the password ' +
'you set when you exported it.',
placeholder: 'Backup encryption password',
okText: 'Decrypt',
password: true,
});
if (!pw) return;
try {
const payload = await decryptExportContainer(raw, pw);
// Hand the decrypted payload back to parseEntriesFromJSON
// via JSON.stringify — keeps the parser code path single.
text = JSON.stringify(payload);
} catch (e) {
return toast('Decryption failed — wrong password or corrupted file', 'error');
}
}
}
let parsed;
try {
parsed = isJSON ? parseEntriesFromJSON(text) : parseEntriesFromCSV(text);
} catch (e) {
return toast('Parse error: ' + e.message, 'error');
}
if (parsed.entries.length === 0) {
return toast('No valid entries found in file', 'warning');
}
// Build the preview message (innerHTML target → escape user data)
const esc = s => String(s).replace(/[&<>"]/g,
c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const parts = [];
parts.push('<b>' + parsed.entries.length + '</b> entries detected in <code>' +
esc(file.name) + '</code>');
if (parsed.skipped > 0)
parts.push('<span style="color:var(--text-dim)">' + parsed.skipped +
' rows skipped (missing site or password)</span>');
const sample = parsed.entries.slice(0, 3).map(e =>
'• ' + esc(e.site || '?') +
(e.username ? ' <span style="color:var(--text-dim)">(' + esc(e.username) + ')</span>' : '')
).join('<br>');
parts.push('<div style="margin-top:8px;font-size:12px">' + sample +
(parsed.entries.length > 3 ? '<br>…' : '') + '</div>');
parts.push('<div style="margin-top:10px">Import now? This adds the entries to your existing vault.</div>');
const confirmed = await confirmDialog({
title: 'Import vault',
message: parts.join('<br>'),
okText: 'Import',
});
if (!confirmed) return;
// Encrypt all entries client-side, then POST as one transaction.
toast('Encrypting ' + parsed.entries.length + ' entries…');
const encrypted = [];
for (const e of parsed.entries) {
encrypted.push(await encryptImportEntry(e));
}
try {
const r = await api('/entries/bulk-import', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ entries: encrypted }),
});
toast('Imported ' + r.imported + ' entries');
await loadEntries();
render();
if (state.hibpEnabled) hibpCheckAllEntries(); // scan the new entries too
} catch (err) {
toast('Import failed: ' + err.message, 'error');
}
});
fileInput.click();
}
async function doExport() {
// Step 1: reauth — verifies the human in front of the screen is the
// vault owner before we hand them every plaintext password. Defense
// against a stranger reaching the open laptop and exfiltrating data.
const masterPwd = await askReauth(
'Enter your master password to start an encrypted export.');
if (!masterPwd) return;
try {
const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000);
await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ verifier: verifier }),
});
} catch (err) {
// 429 (account lockout) is possible here too — propagate as a clear
// message rather than the generic "wrong master password" toast.
if (err.status === 429 && err.body && err.body.retry_after) {
return toast('Account locked. Try again in ' +
Math.ceil(err.body.retry_after / 60) + ' min', 'warning');
}
return toast('Wrong master password', 'error');
}
// Step 2: ask for an INDEPENDENT export password. Decoupled from the
// master pw so a master-pw change later doesn't invalidate the backup,
// and so the backup can be shared without revealing the master pw.
const exportPwd = await promptDialog({
title: 'Encrypted export',
message: 'Choose a password to encrypt the backup file.<br>' +
'<small style="color:var(--text-dim)">' +
'You will need this password to restore the file. ' +
'It is independent of your master password.</small>',
placeholder: 'Backup encryption password',
okText: 'Export',
password: true,
});
if (!exportPwd) return;
if (exportPwd.length < 6) {
return toast('Use at least 6 characters', 'warning');
}
// Step 3: assemble the plaintext payload (same shape as the legacy
// plaintext exporter — round-trips with the existing JSON importer
// after decryption).
const payload = {
version: 1,
exported_at: new Date().toISOString(),
username: state.username,
entries: [],
};
for (const e of state.entries) {
const plain = await decryptPwd(e.encrypted_password, e.iv);
let plainTotp = '';
if (e.totp_secret && e.totp_iv) {
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
if (plainTotp === '[ERROR]') plainTotp = '';
}
payload.entries.push({
site: e.site,
username: e.username,
password: plain,
folder: e.folder,
tags: parseTags(e.tags),
favorite: !!e.favorite,
totp_secret: plainTotp,
created_at: e.created_at,
updated_at: e.updated_at,
});
}
// Step 4: encrypt + download
const container = await encryptExportPayload(payload, exportPwd);
const blob = new Blob([JSON.stringify(container, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = el('a', {
href: url,
download: 'vault-export-' + new Date().toISOString().slice(0, 10) + '.json',
});
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
toast(payload.entries.length + ' entries exported (encrypted)');
}
// ============================================================
// VIEWS / NAV
// ============================================================
async function setView(v) {
state.view = v;
if (v === 'trash') {
await loadTrash();
}
render();
}
function toggleTheme() {
setTheme(state.theme === 'dark' ? 'light' : 'dark');
}
function setTheme(t) {
state.theme = t;
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem('theme', t);
const sel = $('#settingTheme');
if (sel) sel.value = t;
}
function openSettings() {
$('#settingTheme').value = state.theme;
$('#settingAutoLock').value = String(state.autoLock);
$('#settingAskDelete').checked = state.askBeforeDelete;
$('#settingCompact').checked = state.compactActions;
$('#settingMaskUser').checked = state.maskUsernames;
$('#settingHIBP').checked = state.hibpEnabled;
$('#settingUser').textContent = state.username;
// Async: query server for recovery key state and update the label
refreshRecoveryStatus();
// Sync the quick-unlock UI from the actual DPAPI file (in case the
// user cleared the file outside the app or the file is gone for some
// other reason like a Windows profile reset).
if (Bridge.active) {
bridgeQuickUnlockStatus().then(actual => {
if (!actual && state.quickUnlockEnabled) {
// Drift: localStorage said enabled but Delphi has no blob.
localStorage.removeItem('quickUnlockEnabled');
state.quickUnlockEnabled = false;
}
updateQuickUnlockUI();
});
} else {
updateQuickUnlockUI();
}
$('#settingsPanel').classList.add('is-open');
}
function closeSettings() {
$('#settingsPanel').classList.remove('is-open');
}
// ---- Auto-lock with 30s warning countdown -------------------
const WARNING_SECONDS = 30;
let autoLockTimer = null;
let warningTimer = null;
let countdownInterval = null;
function hideIdleWarning() {
$('#idleWarning').classList.add('is-hidden');
if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
}
function showIdleWarning() {
$('#idleCountdown').textContent = WARNING_SECONDS;
$('#idleWarning').classList.remove('is-hidden');
let s = WARNING_SECONDS;
countdownInterval = setInterval(() => {
s -= 1;
$('#idleCountdown').textContent = Math.max(0, s);
if (s <= 0) { clearInterval(countdownInterval); countdownInterval = null; }
}, 1000);
}
function resetAutoLock() {
if (autoLockTimer) clearTimeout(autoLockTimer);
if (warningTimer) clearTimeout(warningTimer);
hideIdleWarning();
if (!state.autoLock || !state.token || !state.cryptoKey) return;
const totalMs = state.autoLock * 60 * 1000;
const warningAt = Math.max(0, totalMs - WARNING_SECONDS * 1000);
warningTimer = setTimeout(showIdleWarning, warningAt);
autoLockTimer = setTimeout(() => {
hideIdleWarning();
toast('Auto-locked due to inactivity', 'warning');
lockVault();
}, totalMs);
}
// Reset idle on user interaction — but ignore events that fire while the
// warning popup is visible (otherwise the popup would never auto-dismiss).
['mousemove', 'keydown', 'click', 'touchstart'].forEach(ev =>
document.addEventListener(ev, e => {
// Allow clicks on the "Stay unlocked" button to also reset
if ($('#idleWarning').classList.contains('is-hidden')) {
resetAutoLock();
}
}, { passive: true })
);
function showAuth() {
$('#authScreen').classList.remove('is-hidden');
$('#appShell').classList.add('is-hidden');
if (autoLockTimer) { clearTimeout(autoLockTimer); autoLockTimer = null; }
}
async function enterApp() {
$('#authScreen').classList.add('is-hidden');
$('#appShell').classList.remove('is-hidden');
$('#userName').textContent = state.username;
// Show skeleton cards immediately while the initial fetch runs
showSkeletons(6);
await loadFolders();
await loadEntries();
render();
resetAutoLock();
// Fire-and-forget HIBP scan if the user opted in. Runs in background,
// re-renders when done to show badges.
if (state.hibpEnabled) hibpCheckAllEntries();
}
// ============================================================
// INIT
// ============================================================
async function init() {
document.documentElement.setAttribute('data-theme', state.theme);
// Auth tabs
$$('.auth-tab').forEach(t => {
t.addEventListener('click', () => {
$$('.auth-tab').forEach(x => x.classList.remove('is-active'));
t.classList.add('is-active');
const tab = t.dataset.tab;
$('#loginForm').classList.toggle('is-hidden', tab !== 'login');
$('#registerForm').classList.toggle('is-hidden', tab !== 'register');
});
});
// Forms
$('#loginForm').addEventListener('submit', doLogin);
$('#registerForm').addEventListener('submit', doRegister);
$('#regPassword').addEventListener('input', updateRegStrength);
$('#entryPassword').addEventListener('input', updateEntryStrength);
// Top-bar
function applyViewMode() {
$$('.view-btn').forEach(b => b.classList.toggle('is-active', b.dataset.view === state.viewMode));
renderGrid();
}
applyViewMode();
$$('.view-btn').forEach(b => b.addEventListener('click', () => {
state.viewMode = b.dataset.view;
localStorage.setItem('viewMode', state.viewMode);
applyViewMode();
}));
$('#themeBtn').addEventListener('click', toggleTheme);
$('#newEntryBtn').addEventListener('click', () => openEntryModal());
$('#userChip').addEventListener('click', () => $('#userDropdown').classList.toggle('is-hidden'));
$('#lockBtn').addEventListener('click', lockVault);
$('#logoutBtn').addEventListener('click', doLogout);
document.addEventListener('click', e => {
if (!e.target.closest('.user-menu')) $('#userDropdown').classList.add('is-hidden');
// Close any open kebab menu when clicking outside it
if (!e.target.closest('.entry-kebab-wrap')) {
$$('.entry-kebab-menu.is-open').forEach(m => m.classList.remove('is-open'));
}
});
// Sidebar nav
$$('#appShell .nav-item[data-view]').forEach(n => {
n.addEventListener('click', () => setView(n.dataset.view));
});
$('#addFolderBtn').addEventListener('click', addFolder);
// Drag-to-trash: dropping an entry onto the Trash nav item soft-deletes it
const trashItem = $('#appShell .nav-item[data-view="trash"]');
if (trashItem) {
trashItem.addEventListener('dragover', ev => { ev.preventDefault(); trashItem.classList.add('drag-over'); });
trashItem.addEventListener('dragleave', () => trashItem.classList.remove('drag-over'));
trashItem.addEventListener('drop', async ev => {
ev.preventDefault();
trashItem.classList.remove('drag-over');
const id = parseInt(ev.dataTransfer.getData('text/plain'));
if (!id) return;
try {
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
toast('Moved to trash');
await loadEntries();
await loadTrash();
render();
} catch (err) { toast(err.message, 'error'); }
});
}
// Search
$('#searchInput').addEventListener('input', e => {
state.search = e.target.value;
renderGrid();
});
// Marquee rubber-band selection on the entry grid
$('#entryGrid').addEventListener('mousedown', startMarquee);
// Slide-over
$('#slideoverClose').addEventListener('click', closeSlideOver);
// Click outside the slide-over closes it. Clicks on cards re-open it for
// another entry (so we don't close in that case; the card's own handler
// will switch state.selectedId).
document.addEventListener('click', e => {
if (!$('#slideover').classList.contains('is-open')) return;
if (e.target.closest('.slideover')) return;
if (e.target.closest('.entry-card')) return;
if (e.target.closest('.modal')) return;
if (e.target.closest('.cmd-palette')) return;
if (e.target.closest('.idle-warning')) return;
closeSlideOver();
});
// Entry modal
$('#entryForm').addEventListener('submit', saveEntry);
$('#entrySaveBtn').addEventListener('click', saveEntry);
$$('#entryModal [data-close]').forEach(b => b.addEventListener('click', closeEntryModal));
$('#entryPwToggle').addEventListener('click', () => {
const input = $('#entryPassword');
input.type = input.type === 'password' ? 'text' : 'password';
});
$('#entryPwGen').addEventListener('click', openGen);
// Chip input (tags)
$('#entryTagsInput').addEventListener('click', () => $('#entryTagsField').focus());
$('#entryTagsField').addEventListener('keydown', e => {
const field = e.target;
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
if (!selectActiveSuggestion()) {
addTag(field.value);
field.value = '';
closeChipSuggest();
}
} else if (e.key === 'Backspace' && !field.value && editingTags.length) {
editingTags.pop();
renderChips();
} else if (e.key === 'ArrowDown') { e.preventDefault(); moveChipSuggest(+1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); moveChipSuggest(-1); }
else if (e.key === 'Escape') { closeChipSuggest(); }
});
$('#entryTagsField').addEventListener('input', openChipSuggest);
$('#entryTagsField').addEventListener('focus', openChipSuggest);
$('#entryTagsField').addEventListener('blur', () => setTimeout(closeChipSuggest, 150));
// Generator
$('#genLen').addEventListener('input', genPassword);
$$('#genModal input[type=checkbox]').forEach(c => c.addEventListener('change', genPassword));
$('#genRegen').addEventListener('click', genPassword);
$('#genCopy').addEventListener('click', () => {
if (!genCurrent) return;
if (Bridge.copySecure(genCurrent, 30000)) {
toast('Copied · clears in 30s');
} else {
navigator.clipboard.writeText(genCurrent).then(() => {
toast('Copied · clears in 30s');
setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000);
});
}
});
$('#genUse').addEventListener('click', () => {
if (genTarget === 'slideover') {
const soPw = $('#soPassword');
if (soPw) { soPw.value = genCurrent; soDirtyCheck(); }
} else {
$('#entryPassword').value = genCurrent;
updateEntryStrength();
}
closeGen();
});
$$('#genModal [data-close]').forEach(b => b.addEventListener('click', closeGen));
// Sidebar Generator tool
$('#sidebarGenBtn').addEventListener('click', () => openGen('standalone'));
$('#sidebarExportBtn').addEventListener('click', doExport);
$('#sidebarImportBtn').addEventListener('click', doImport);
// Idle warning "Stay unlocked"
$('#idleStayBtn').addEventListener('click', resetAutoLock);
// Settings slide-over
$('#settingsBtn').addEventListener('click', openSettings);
$('#settingsClose').addEventListener('click', closeSettings);
$('#settingTheme').addEventListener('change', e => setTheme(e.target.value));
$('#settingAutoLock').addEventListener('change', e => {
state.autoLock = parseInt(e.target.value);
localStorage.setItem('autoLockMin', String(state.autoLock));
resetAutoLock();
toast(state.autoLock ? ('Auto-lock: ' + state.autoLock + ' min') : 'Auto-lock disabled');
});
$('#settingAskDelete').addEventListener('change', e => {
state.askBeforeDelete = e.target.checked;
localStorage.setItem('askBeforeDelete', state.askBeforeDelete ? '1' : '0');
toast(state.askBeforeDelete ? 'Will ask before deleting' : 'Will delete without asking');
});
$('#settingCompact').addEventListener('change', e => {
state.compactActions = e.target.checked;
localStorage.setItem('compactActions', state.compactActions ? '1' : '0');
render();
});
$('#settingMaskUser').addEventListener('change', e => {
state.maskUsernames = e.target.checked;
localStorage.setItem('maskUsernames', state.maskUsernames ? '1' : '0');
render();
});
$('#settingHIBP').addEventListener('change', e => {
state.hibpEnabled = e.target.checked;
localStorage.setItem('hibpEnabled', state.hibpEnabled ? '1' : '0');
if (state.hibpEnabled) {
toast('Checking passwords against breach database…');
hibpCheckAllEntries();
} else {
state.hibpResults.clear();
render();
toast('Breach check disabled');
}
});
$('#openClipboardSettings').addEventListener('click', () => {
toast('Open Windows Settings → System → Clipboard → turn off "Clipboard history"', 'warning');
});
$('#exportBtn').addEventListener('click', doExport);
$('#importBtn').addEventListener('click', doImport);
$('#changeMasterBtn').addEventListener('click', openChangeMasterModal);
$('#changeMasterForm').addEventListener('submit', e => {
e.preventDefault();
doChangeMasterPassword();
});
$$('#changeMasterModal [data-close]').forEach(b =>
b.addEventListener('click', closeChangeMasterModal));
// Recovery key
$('#recoverySetupBtn').addEventListener('click', doGenerateRecoveryKey);
$('#recoveryRemoveBtn').addEventListener('click', doRemoveRecoveryKey);
$('#recoveryBtn').addEventListener('click', doRecoveryRedeem);
// Quick unlock (DPAPI)
$('#quickUnlockToggleBtn').addEventListener('click', () => {
if (state.quickUnlockEnabled) disableQuickUnlock();
else enableQuickUnlock();
});
// Re-auth modal
$('#reauthForm').addEventListener('submit', e => { e.preventDefault(); closeReauth(true); });
$$('#reauthModal [data-close]').forEach(b => b.addEventListener('click', () => closeReauth(false)));
// Custom confirm / prompt modal
$('#confirmForm').addEventListener('submit', e => {
e.preventDefault();
// If input field visible -> resolve with its value, else -> true
const hasInput = !$('#confirmInputField').classList.contains('is-hidden');
closeConfirm(hasInput ? $('#confirmInput').value : true);
});
$$('#confirmModal [data-confirm-cancel]').forEach(b =>
b.addEventListener('click', () => closeConfirm(false))
);
// Command palette
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
openPalette();
} else if (e.key === 'Escape') {
// Close in priority order: confirm first (most modal-y) then others
if (!$('#confirmModal').classList.contains('is-hidden')) {
closeConfirm(false);
return;
}
if (!$('#changeMasterModal').classList.contains('is-hidden')) {
closeChangeMasterModal();
return;
}
closePalette();
closeSlideOver();
closeEntryModal();
closeGen();
}
});
$('#cmdInput').addEventListener('input', e => renderPaletteResults(e.target.value));
$$('#cmdPalette [data-close]').forEach(b => b.addEventListener('click', closePalette));
// Restore session if any
if (state.token && state.salt) {
const ok = await restoreCryptoKey();
if (ok) {
await enterApp();
} else {
// sessionStorage still has token+salt but the crypto key was
// cleared (locked / tab closed). Try the DPAPI quick-unlock
// path before falling back to the master-pw prompt.
if (await tryQuickUnlock()) {
await enterApp();
} else {
showAuth();
$('#loginUsername').value = state.username;
}
}
} else {
// Cold start: no sessionStorage at all. Quick unlock can still
// restore everything (token + salt + key + username) from the
// DPAPI blob.
if (await tryQuickUnlock()) {
await enterApp();
} else {
showAuth();
}
}
}
document.addEventListener('DOMContentLoaded', init);