diff --git a/app/static/js/vault.js b/app/static/js/vault.js index 0005fdc..0c1f3a9 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -51,6 +51,45 @@ const Vault = (() => { document.addEventListener(e, handler, { passive: true }), ); _resetWebIdleTimer(); + + // Lock when the tab is hidden for longer than the idle timeout. + // This catches screen-lock, minimize, and long tab switches without + // requiring the user to wait for the inactivity timer to fire after + // returning to the tab. + let _hiddenAt = null; + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") { + _hiddenAt = Date.now(); + // Pause the inactivity timer while hidden — user activity is impossible. + if (_webIdleTimer) { + clearTimeout(_webIdleTimer); + _webIdleTimer = null; + } + } else { + // Tab became visible again. + const mins = _getWebIdleMinutes(); + if (mins > 0 && _hiddenAt !== null) { + const hiddenMs = Date.now() - _hiddenAt; + if (hiddenMs >= mins * 60_000 && VaultSession.getKey()) { + console.log( + "[PassKeeper] Locking — tab was hidden for", + Math.round(hiddenMs / 1000), + "s (idle timeout:", + mins, + "min)", + ); + VaultSession.clear(); + showUnlockOverlay(); + showToast("Vault locked due to inactivity.", "info"); + _hiddenAt = null; + return; + } + } + _hiddenAt = null; + // Resume the inactivity timer now that the user is back. + _resetWebIdleTimer(); + } + }); } let _activeFilter = null; let _sortOrder = "name-asc"; @@ -863,16 +902,18 @@ const Vault = (() => { const cutoff = Date.now() - 180 * 86400000; // "Old" only penalises passwords that are ALSO weak or reused. - // A strong, unique password that hasn't changed in 200 days is fine — - // penalising it discourages good password hygiene. + // Uses plain.password_changed_at when available (set on create/edit), + // falling back to created_at for items saved before this feature. const weakOrReusedIds = new Set([ ...weak.map((i) => i.id), ...reused.map((i) => i.id), ]); - const old = pwItems.filter( - (i) => - new Date(i.created_at).getTime() < cutoff && weakOrReusedIds.has(i.id), - ); + const old = pwItems.filter((i) => { + const ageRef = i.plain?.password_changed_at + ? new Date(i.plain.password_changed_at).getTime() + : new Date(i.created_at).getTime(); + return ageRef < cutoff && weakOrReusedIds.has(i.id); + }); const total = pwItems.length; const score = Math.max( @@ -3495,10 +3536,34 @@ const Vault = (() => { mode === "add" ? getVal("field-type") : form.dataset.itemType || "password"; - const { enc_data, iv } = await Crypto.encryptItem( - vaultKey, - buildPlainData(itemType), - ); + + const plainData = buildPlainData(itemType); + + // Track when the password was last changed — stored inside the encrypted + // blob so the server never sees it. Used by the security dashboard to + // accurately flag old passwords vs. old items that had their password changed. + if (itemType === "password") { + if (mode === "add") { + // New item — set password_changed_at to now. + plainData.password_changed_at = new Date().toISOString(); + } else { + // Edit — only update if the password field actually changed. + const existingItem = _items.find((i) => i.id === parseInt(itemId)); + const existingPassword = existingItem?.plain?.password ?? null; + const existingChangedAt = existingItem?.plain?.password_changed_at ?? null; + if (plainData.password !== existingPassword) { + // Password changed — record now. + plainData.password_changed_at = new Date().toISOString(); + } else if (existingChangedAt) { + // Password unchanged — preserve the existing timestamp. + plainData.password_changed_at = existingChangedAt; + } + // If no existing timestamp and password unchanged, leave it absent + // — the security dashboard will fall back to created_at. + } + } + + const { enc_data, iv } = await Crypto.encryptItem(vaultKey, plainData); // Encrypt the item name client-side so it is never stored in plaintext. const { enc_name, iv_name } = await Crypto.encryptName(vaultKey, name); const folderVal = getVal("field-folder"); @@ -4347,4 +4412,4 @@ const Vault = (() => { } })(); -document.addEventListener("DOMContentLoaded", Vault.init); +document.addEventListener("DOMContentLoaded", Vault.init); \ No newline at end of file diff --git a/extension/popup/popup.js b/extension/popup/popup.js index 3fefaa4..23a2c48 100644 --- a/extension/popup/popup.js +++ b/extension/popup/popup.js @@ -1273,6 +1273,7 @@ async function saveCredential(data) { username: data.username || "", password: data.password || "", notes: "", + password_changed_at: new Date().toISOString(), }; const name = $("save-name").value.trim() || data.siteName || "Untitled"; // Read selected folder — empty string means no folder (null). @@ -1577,7 +1578,7 @@ async function addItemToVault() { btn.textContent = "Saving…"; try { - const plain = { url, username, password, notes }; + const plain = { url, username, password, notes, password_changed_at: new Date().toISOString() }; const { enc_data, iv } = await ExtCrypto.encryptItem(_vaultKey, plain); const { enc_name, iv_name } = await ExtCrypto.encryptName(_vaultKey, name); const res = await apiFetch("/api/vault", {