diff --git a/app/static/js/vault.js b/app/static/js/vault.js index 833ba30..4385dcc 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -9,6 +9,41 @@ const Vault = (() => { let _items = []; let _folders = []; let _currentView = "vault"; + + // ── Web-app inactivity / session timeout ────────────────────────────────── + // Mirrors the extension idle lock. Default 15 min; user can change in + // Account Settings. Zero means never. Stored in localStorage so it persists + // across page reloads without a server round-trip. + + const WEB_IDLE_KEY = 'web_idle_minutes'; + const WEB_IDLE_DEFAULT = 15; // minutes; 0 = never + let _webIdleTimer = null; + + function _getWebIdleMinutes() { + const v = parseInt(localStorage.getItem(WEB_IDLE_KEY), 10); + return isNaN(v) ? WEB_IDLE_DEFAULT : v; + } + + function _resetWebIdleTimer() { + if (_webIdleTimer) clearTimeout(_webIdleTimer); + const mins = _getWebIdleMinutes(); + if (mins === 0) return; + _webIdleTimer = setTimeout(() => { + // Only lock if the vault is currently unlocked. + if (!VaultSession.getKey()) return; + console.log('[PassKeeper] Web inactivity lock after', mins, 'min'); + VaultSession.clear(); + showUnlockOverlay(); + showToast('Vault locked due to inactivity.', 'info'); + }, mins * 60_000); + } + + function _startWebIdleTracking() { + const events = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll']; + const handler = () => _resetWebIdleTimer(); + events.forEach((e) => document.addEventListener(e, handler, { passive: true })); + _resetWebIdleTimer(); + } let _activeFilter = null; let _sortOrder = "name-asc"; @@ -197,7 +232,7 @@ const Vault = (() => { // ── View switching ──────────────────────────────────────────────────────── - function switchView(view) { + function switchView(view, { pushState = true } = {}) { _currentView = view; ["vault", "security", "sharing", "emergency", "import-export"].forEach((v) => { document @@ -215,6 +250,12 @@ const Vault = (() => { .forEach((el) => el.classList.remove("active")); } + // Push to browser history so the back button navigates between views. + if (pushState) { + const hash = view === "vault" ? "" : `#${view}`; + history.pushState({ view }, "", hash || window.location.pathname); + } + if (view === "security") renderSecurityDashboard(); if (view === "sharing") loadSharingView(); if (view === "emergency") loadEmergencyView(); @@ -643,6 +684,19 @@ const Vault = (() => { ); makeSection("Old Passwords", "🕐", old, "Weak or reused passwords not changed in over 180 days."); + // ── Missing 2FA warning ────────────────────────────────────────────────── + // Flag password items that have a URL but no TOTP URI saved. + // These accounts likely support 2FA but the user hasn't stored it. + const noTotp = pwItems.filter((i) => + i.plain?.url && !extractTotpSecret(i.plain?.totp_uri) + ); + makeSection( + 'No 2FA Saved', + '🔓', + noTotp, + 'These accounts may support two-factor authentication but have no TOTP code stored.', + ); + // ── HaveIBeenPwned breach check ────────────────────────────────────────── // Run after the synchronous sections are rendered so the UI is immediately // useful. The HIBP API is queried in parallel for all passwords. @@ -1798,6 +1852,9 @@ const Vault = (() => { async function openSettingsModal() { document.getElementById("settings-modal").classList.add("open"); + // Populate web-app session timeout select with saved value. + const idleSel = document.getElementById("web-idle-select"); + if (idleSel) idleSel.value = String(_getWebIdleMinutes()); // Reset change password fields ["cp-current", "cp-new", "cp-confirm"].forEach((id) => { const el = document.getElementById(id); @@ -2717,10 +2774,21 @@ const Vault = (() => { return 30 - (Math.floor(Date.now() / 1000) % 30); } + // Track the clipboard clear timer so multiple rapid copies don't stack. + let _clipboardClearTimer = null; + function copyToClipboard(text, msg) { navigator.clipboard .writeText(text) - .then(() => showToast(msg)) + .then(() => { + showToast(msg); + // Auto-clear clipboard after 30 seconds — industry-standard hygiene. + if (_clipboardClearTimer) clearTimeout(_clipboardClearTimer); + _clipboardClearTimer = setTimeout(() => { + navigator.clipboard.writeText('').catch(() => {}); + _clipboardClearTimer = null; + }, 30_000); + }) .catch(() => {}); } @@ -2979,6 +3047,16 @@ const Vault = (() => { document .getElementById("btn-close-settings") ?.addEventListener("click", () => closeModal("settings-modal")); + + // Web-app session timeout setting. + document.getElementById("web-idle-select")?.addEventListener("change", (e) => { + const mins = parseInt(e.target.value, 10); + localStorage.setItem(WEB_IDLE_KEY, mins); + _resetWebIdleTimer(); + const label = mins === 0 ? 'never' : mins + ' min'; + showToast('Auto-lock set to ' + label + '.'); + console.log('[PassKeeper] Web idle timeout set to', label); + }); document .getElementById("settings-modal") ?.addEventListener("click", (e) => { @@ -3161,10 +3239,28 @@ const Vault = (() => { document.getElementById("btn-add-item")?.click(); }); + // ── Browser history / back-button support ───────────────────────────────── + const _hashToView = (hash) => { + const v = (hash || '').replace('#', '').trim(); + const valid = ['vault', 'security', 'sharing', 'emergency', 'import-export']; + return valid.includes(v) ? v : 'vault'; + }; + const _initialView = _hashToView(window.location.hash); + history.replaceState({ view: _initialView }, '', window.location.href); + window.addEventListener('popstate', (e) => { + const view = e.state?.view || _hashToView(window.location.hash); + switchView(view, { pushState: false }); + }); + + // Start web-app inactivity tracking. + _startWebIdleTracking(); + if (!VaultSession.getKey()) { showUnlockOverlay(); } else { loadVault(); + // Restore view from hash after vault loads (avoids rendering before decrypt). + if (_initialView !== 'vault') switchView(_initialView, { pushState: false }); } } @@ -3268,4 +3364,4 @@ const Vault = (() => { } })(); -document.addEventListener("DOMContentLoaded", Vault.init); \ No newline at end of file +document.addEventListener("DOMContentLoaded", Vault.init); diff --git a/app/templates/vault/index.html b/app/templates/vault/index.html index 1432968..a052ea1 100644 --- a/app/templates/vault/index.html +++ b/app/templates/vault/index.html @@ -853,6 +853,24 @@ +
+

⏱️ Auto-Lock

+

+ Automatically lock the vault after a period of inactivity. +

+
+ + +
+
+

⚠️ Danger Zone

@@ -1005,4 +1023,4 @@ -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/extension/background.firefox.js b/extension/background.firefox.js new file mode 100644 index 0000000..fa4525e --- /dev/null +++ b/extension/background.firefox.js @@ -0,0 +1,193 @@ +/** + * extension/background.firefox.js — Firefox MV2 background page. + * + * Differences from Chrome MV3 background.js: + * - Uses `browser` namespace (WebExtensions API) via the polyfill shim + * - No chrome.storage.session — session data stored in memory (object) + * and backed by storage.local with a "__session__" prefix + * - No chrome.idle API in Firefox MV2 without permission; idle lock + * is handled via a timeout-based approach instead + * - chrome.action → browser.browserAction (MV2) + * + * All message types and storage keys are identical to background.js + * so popup.js and content.js work without modification. + */ + +// Firefox uses `browser` namespace; wrap with `chrome` alias if needed. +if (typeof chrome === 'undefined') { + // eslint-disable-next-line no-global-assign + chrome = browser; +} + +// ── In-memory session storage shim ─────────────────────────────────────────── +// Firefox MV2 does not have chrome.storage.session. We emulate it with an +// in-memory object. Data is lost when the background page is unloaded, which +// mimics the "cleared on browser close" guarantee of chrome.storage.session. + +const _session = {}; + +const sessionStorage = { + async get(keys) { + if (typeof keys === 'string') keys = [keys]; + const result = {}; + for (const k of keys) result[k] = _session[k]; + return result; + }, + async set(obj) { + Object.assign(_session, obj); + }, + async remove(keys) { + if (typeof keys === 'string') keys = [keys]; + for (const k of keys) delete _session[k]; + }, + async clear() { + for (const k of Object.keys(_session)) delete _session[k]; + }, +}; + +// Override chrome.storage.session with our shim so the rest of the code +// works without modification. +if (!chrome.storage.session) { + chrome.storage.session = sessionStorage; +} + +// ── Idle lock (timeout-based, no chrome.idle) ───────────────────────────────── + +const DEFAULT_IDLE_LOCK_SECONDS = 600; +const IDLE_TIMEOUT_KEY = 'idle_lock_seconds'; +let _idleTimer = null; + +async function resetIdleTimer() { + const stored = await chrome.storage.local.get(IDLE_TIMEOUT_KEY); + const seconds = stored[IDLE_TIMEOUT_KEY] ?? DEFAULT_IDLE_LOCK_SECONDS; + if (_idleTimer) clearTimeout(_idleTimer); + if (seconds === 0) return; // "Never" + _idleTimer = setTimeout(async () => { + console.log('[PassKeeper] Idle lock triggered after', seconds, 's'); + await chrome.storage.session.clear(); + await chrome.storage.local.remove('vault_items_cs'); + const tabs = await chrome.tabs.query({}); + tabs.forEach((tab) => { + if (tab.id) chrome.browserAction.setBadgeText({ text: '', tabId: tab.id }); + }); + }, seconds * 1000); +} + +resetIdleTimer(); + +// ── Badge helpers ───────────────────────────────────────────────────────────── + +async function updateBadgeForTab(tabId, url) { + try { + const { vault_items } = await chrome.storage.session.get('vault_items'); + if (!vault_items?.length) { + chrome.browserAction.setBadgeText({ text: '', tabId }); + return; + } + let hostname; + try { hostname = new URL(url).hostname.replace(/^www\./, ''); } catch { + chrome.browserAction.setBadgeText({ text: '', tabId }); return; + } + const matches = vault_items.filter((item) => { + if (item.item_type !== 'password' || !item.plain?.url) return false; + try { + const h = new URL(item.plain.url).hostname.replace(/^www\./, ''); + return h === hostname || h.endsWith(`.${hostname}`) || hostname.endsWith(`.${h}`); + } catch { return false; } + }); + if (matches.length > 0) { + chrome.browserAction.setBadgeText({ text: String(matches.length), tabId }); + chrome.browserAction.setBadgeBackgroundColor({ color: '#1a73e8', tabId }); + } else { + chrome.browserAction.setBadgeText({ text: '', tabId }); + } + } catch { /* tab may have closed */ } +} + +async function refreshAllBadges() { + const tabs = await chrome.tabs.query({}); + for (const tab of tabs) { + if (tab.url?.startsWith('http')) updateBadgeForTab(tab.id, tab.url); + } +} + +// ── Tab events ──────────────────────────────────────────────────────────────── + +chrome.tabs.onActivated.addListener(async ({ tabId }) => { + resetIdleTimer(); + try { + const tab = await chrome.tabs.get(tabId); + if (tab.url?.startsWith('http')) updateBadgeForTab(tabId, tab.url); + } catch {} +}); + +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status === 'complete' && tab.url?.startsWith('http')) { + updateBadgeForTab(tabId, tab.url); + } +}); + +// ── Message handler ─────────────────────────────────────────────────────────── + +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + resetIdleTimer(); + + if (msg.type === 'FORMS_DETECTED') { + sendResponse({ ok: true }); return false; + } + if (msg.type === 'SET_IDLE_TIMEOUT') { + resetIdleTimer(); sendResponse({ ok: true }); return false; + } + if (msg.type === 'OPEN_VAULT') { + chrome.tabs.create({ url: 'https://pwkeeper.ngodanguyen.tech/vault' }); + sendResponse({ ok: true }); return false; + } + if (msg.type === 'OPEN_GENERATOR') { + chrome.storage.session.set({ popup_nav: 'generator' }); + sendResponse({ ok: true }); return false; + } + if (msg.type === 'KEEPALIVE') { + sendResponse({ ok: true }); return false; + } + if (msg.type === 'VAULT_UPDATED') { + refreshAllBadges(); + const payload = { type: 'VAULT_UPDATED', vault_items: msg.vault_items || [] }; + chrome.tabs.query({}, (tabs) => { + tabs.forEach((tab) => { + if (tab.url?.startsWith('http://') || tab.url?.startsWith('https://')) { + chrome.tabs.sendMessage(tab.id, payload).catch(() => {}); + } + }); + }); + sendResponse({ ok: true }); return false; + } + if (msg.type === 'SAVE_CREDENTIALS') { + chrome.storage.local.set({ pending_save: msg.data }); + chrome.browserAction.setBadgeText({ text: '!' }); + chrome.browserAction.setBadgeBackgroundColor({ color: '#c0392b' }); + sendResponse({ ok: true }); return false; + } + if (msg.type === 'CLEAR_SAVE_BADGE') { + chrome.browserAction.setBadgeText({ text: '' }); + sendResponse({ ok: true }); return false; + } + if (msg.type === 'WEB_SESSION_SYNC') { + chrome.storage.session.set({ access_token: msg.access_token, enc_key_salt: msg.enc_key_salt || '' }); + chrome.storage.local.set({ refresh_token: msg.refresh_token, ...(msg.enc_key_salt ? { enc_key_salt: msg.enc_key_salt } : {}) }); + sendResponse({ ok: true }); return false; + } + if (msg.type === 'WEB_SESSION_CLEAR') { + chrome.storage.session.remove(['access_token', 'vault_key_jwk', 'vault_items', 'enc_key_salt']); + chrome.storage.local.remove('refresh_token'); + sendResponse({ ok: true }); return false; + } + if (msg.type === 'EXT_SESSION_SYNC') { + chrome.tabs.query({ url: 'https://pwkeeper.ngodanguyen.tech/*' }, (tabs) => { + tabs.forEach((tab) => { + chrome.tabs.sendMessage(tab.id, { type: 'INJECT_SESSION', access_token: msg.access_token, refresh_token: msg.refresh_token, enc_key_salt: msg.enc_key_salt }).catch(() => {}); + }); + }); + sendResponse({ ok: true }); return false; + } + return false; +}); diff --git a/extension/manifest.firefox.json b/extension/manifest.firefox.json new file mode 100644 index 0000000..02e8715 --- /dev/null +++ b/extension/manifest.firefox.json @@ -0,0 +1,53 @@ +{ + "manifest_version": 2, + "name": "PassKeeper", + "version": "1.0.0", + "description": "Autofill and manage your PassKeeper vault", + "permissions": [ + "storage", + "activeTab", + "tabs", + "idle", + "http://*/*", + "https://*/*" + ], + "browser_action": { + "default_popup": "popup/popup.html", + "default_title": "PassKeeper", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "background": { + "scripts": ["background.firefox.js"], + "persistent": false + }, + "content_scripts": [ + { + "matches": ["http://*/*", "https://*/*"], + "js": ["shared/browser-polyfill.js", "content/content.js"], + "run_at": "document_idle" + }, + { + "matches": ["https://pwkeeper.ngodanguyen.tech/*"], + "js": ["shared/browser-polyfill.js", "bridge/bridge.js"], + "run_at": "document_idle" + } + ], + "commands": { + "_execute_browser_action": { + "suggested_key": { + "default": "Ctrl+Shift+L", + "mac": "Command+Shift+L" + }, + "description": "Open PassKeeper" + } + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } +} diff --git a/extension/popup/popup.js b/extension/popup/popup.js index 726fe31..0686519 100644 --- a/extension/popup/popup.js +++ b/extension/popup/popup.js @@ -59,6 +59,17 @@ function escHtml(str) { .replace(/>/g, '>').replace(/"/g, '"'); } +// Auto-clear clipboard 30 s after a sensitive copy. +let _clipTimer = null; +function _copyWithAutoClear(text) { + navigator.clipboard.writeText(text).catch(() => {}); + if (_clipTimer) clearTimeout(_clipTimer); + _clipTimer = setTimeout(() => { + navigator.clipboard.writeText('').catch(() => {}); + _clipTimer = null; + }, 30_000); +} + // ── Avatar helpers ──────────────────────────────────────────────────────────── const AVATAR_COLORS = ['', 'color-red', 'color-green', 'color-purple', 'color-teal']; @@ -539,7 +550,7 @@ function renderList() { e.stopPropagation(); const item = _items.find(i => i.id === parseInt(btn.dataset.copyPass)); if (item?.plain?.password) { - navigator.clipboard.writeText(item.plain.password); + _copyWithAutoClear(item.plain.password); btn.title = 'Copied!'; setTimeout(() => { btn.title = 'Copy password'; }, 1500); } @@ -586,7 +597,7 @@ function renderList() { e.stopPropagation(); const item = _items.find(i => i.id === parseInt(btn.dataset.copyUser)); if (item?.plain?.username) { - navigator.clipboard.writeText(item.plain.username); + _copyWithAutoClear(item.plain.username); btn.title = 'Copied!'; setTimeout(() => { btn.title = 'Copy username'; }, 1500); } @@ -615,12 +626,12 @@ function renderList() { item.plain?.username ? { label: 'Copy username', icon: '', - action: () => { navigator.clipboard.writeText(item.plain.username); flyout.remove(); }, + action: () => { _copyWithAutoClear(item.plain.username); flyout.remove(); }, } : null, item.plain?.password ? { label: 'Copy password', icon: '', - action: () => { navigator.clipboard.writeText(item.plain.password); flyout.remove(); }, + action: () => { _copyWithAutoClear(item.plain.password); flyout.remove(); }, } : null, ].filter(Boolean); diff --git a/extension/shared/browser-polyfill.js b/extension/shared/browser-polyfill.js new file mode 100644 index 0000000..7613990 --- /dev/null +++ b/extension/shared/browser-polyfill.js @@ -0,0 +1,19 @@ +/** + * shared/browser-polyfill.js + * + * Minimal shim so content scripts and bridge.js can use `chrome.*` on Firefox. + * Firefox exposes the WebExtensions API as `browser.*` (promise-based). + * Chrome exposes it as `chrome.*` (callback-based, with Promise wrappers in MV3). + * + * This shim only aliases `browser` → `chrome` when `chrome` is not defined, + * which is sufficient for the subset of APIs used by PassKeeper content scripts + * (chrome.runtime, chrome.storage.session/local, chrome.storage.onChanged). + * + * For full cross-browser compatibility, replace with the official + * Mozilla WebExtension browser-polyfill: + * https://github.com/mozilla/webextension-polyfill + */ +if (typeof chrome === 'undefined' && typeof browser !== 'undefined') { + // eslint-disable-next-line no-global-assign + chrome = browser; +}