diff --git a/extension/background.firefox.js b/extension/background.firefox.js index fa4525e..1412d30 100644 --- a/extension/background.firefox.js +++ b/extension/background.firefox.js @@ -66,6 +66,7 @@ async function resetIdleTimer() { console.log('[PassKeeper] Idle lock triggered after', seconds, 's'); await chrome.storage.session.clear(); await chrome.storage.local.remove('vault_items_cs'); + await chrome.storage.local.remove(HEALTH_STATUS_KEY); const tabs = await chrome.tabs.query({}); tabs.forEach((tab) => { if (tab.id) chrome.browserAction.setBadgeText({ text: '', tabId: tab.id }); @@ -77,6 +78,33 @@ resetIdleTimer(); // ── Badge helpers ───────────────────────────────────────────────────────────── +const HEALTH_STATUS_KEY = 'health_status'; + +async function applyHealthBadge() { + try { + const stored = await chrome.storage.local.get(HEALTH_STATUS_KEY); + const health = stored[HEALTH_STATUS_KEY] || { breached: 0, weak: 0, reused: 0 }; + const hasIssues = (health.breached || 0) + (health.weak || 0) + (health.reused || 0) > 0; + const tabs = await chrome.tabs.query({}); + for (const tab of tabs) { + if (!tab.url?.startsWith('http')) continue; + const current = await new Promise((res) => + chrome.browserAction.getBadgeText({ tabId: tab.id }, res) + ).catch(() => ''); + if (current && current !== '' && current !== '⚠') continue; + if (hasIssues) { + const color = (health.breached || 0) > 0 ? '#c0392b' : '#d97706'; + chrome.browserAction.setBadgeText({ text: '⚠', tabId: tab.id }); + chrome.browserAction.setBadgeBackgroundColor({ color, tabId: tab.id }); + } else { + chrome.browserAction.setBadgeText({ text: '', tabId: tab.id }); + } + } + } catch (err) { + console.warn('[PassKeeper] applyHealthBadge error:', err); + } +} + async function updateBadgeForTab(tabId, url) { try { const { vault_items } = await chrome.storage.session.get('vault_items'); @@ -99,7 +127,16 @@ async function updateBadgeForTab(tabId, url) { chrome.browserAction.setBadgeText({ text: String(matches.length), tabId }); chrome.browserAction.setBadgeBackgroundColor({ color: '#1a73e8', tabId }); } else { - chrome.browserAction.setBadgeText({ text: '', tabId }); + const stored = await chrome.storage.local.get(HEALTH_STATUS_KEY); + const health = stored[HEALTH_STATUS_KEY] || {}; + const hasIssues = (health.breached || 0) + (health.weak || 0) + (health.reused || 0) > 0; + if (hasIssues) { + const color = (health.breached || 0) > 0 ? '#c0392b' : '#d97706'; + chrome.browserAction.setBadgeText({ text: '⚠', tabId }); + chrome.browserAction.setBadgeBackgroundColor({ color, tabId }); + } else { + chrome.browserAction.setBadgeText({ text: '', tabId }); + } } } catch { /* tab may have closed */ } } @@ -167,8 +204,15 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { chrome.browserAction.setBadgeBackgroundColor({ color: '#c0392b' }); sendResponse({ ok: true }); return false; } + if (msg.type === 'HEALTH_UPDATE') { + const { breached = 0, weak = 0, reused = 0 } = msg; + chrome.storage.local.set({ [HEALTH_STATUS_KEY]: { breached, weak, reused } }); + applyHealthBadge(); + sendResponse({ ok: true }); return false; + } if (msg.type === 'CLEAR_SAVE_BADGE') { chrome.browserAction.setBadgeText({ text: '' }); + applyHealthBadge(); sendResponse({ ok: true }); return false; } if (msg.type === 'WEB_SESSION_SYNC') { @@ -190,4 +234,4 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { sendResponse({ ok: true }); return false; } return false; -}); +}); \ No newline at end of file diff --git a/extension/background.js b/extension/background.js index ae5e1da..bbccbcb 100644 --- a/extension/background.js +++ b/extension/background.js @@ -64,6 +64,8 @@ chrome.idle.onStateChanged.addListener(async (newState) => { console.log("[PassKeeper] System", newState, "— locking vault."); // session.clear() also removes vault_items_cs (moved from local to session storage). await chrome.storage.session.clear(); + // Clear health status so stale warnings don't persist after lock. + await chrome.storage.local.remove(HEALTH_STATUS_KEY); const tabs = await chrome.tabs.query({}); tabs.forEach((tab) => { if (tab.id) chrome.action.setBadgeText({ text: "", tabId: tab.id }); @@ -73,6 +75,40 @@ chrome.idle.onStateChanged.addListener(async (newState) => { // ── Badge helpers ──────────────────────────────────────────────────────────── +const HEALTH_STATUS_KEY = 'health_status'; + +/** + * Read the stored health status and apply a global warning badge on tabs that + * currently have no match-count badge. Called after vault updates, after + * clearing the pending-save badge, and when the health status changes. + * Priority: pending-save "!" > per-tab match count (blue) > health warning (amber). + */ +async function applyHealthBadge() { + try { + const stored = await chrome.storage.local.get(HEALTH_STATUS_KEY); + const health = stored[HEALTH_STATUS_KEY] || { breached: 0, weak: 0, reused: 0 }; + const hasIssues = (health.breached || 0) + (health.weak || 0) + (health.reused || 0) > 0; + + const tabs = await chrome.tabs.query({}); + for (const tab of tabs) { + if (!tab.url?.startsWith('http')) continue; + // Don't overwrite the per-tab match-count badge. + const current = await chrome.action.getBadgeText({ tabId: tab.id }).catch(() => ''); + if (current && current !== '' && current !== '⚠') continue; + if (hasIssues) { + const label = health.breached > 0 ? '⚠' : '⚠'; + const color = health.breached > 0 ? '#c0392b' : '#d97706'; + chrome.action.setBadgeText({ text: label, tabId: tab.id }); + chrome.action.setBadgeBackgroundColor({ color, tabId: tab.id }); + } else { + chrome.action.setBadgeText({ text: '', tabId: tab.id }); + } + } + } catch (err) { + console.warn('[PassKeeper] applyHealthBadge error:', err); + } +} + async function updateBadgeForTab(tabId, url) { try { const { vault_items } = await chrome.storage.session.get("vault_items"); @@ -107,7 +143,17 @@ async function updateBadgeForTab(tabId, url) { chrome.action.setBadgeText({ text: String(matches.length), tabId }); chrome.action.setBadgeBackgroundColor({ color: "#1a73e8", tabId }); } else { - chrome.action.setBadgeText({ text: "", tabId }); + // No match for this tab — show health warning badge if applicable. + const stored = await chrome.storage.local.get(HEALTH_STATUS_KEY); + const health = stored[HEALTH_STATUS_KEY] || {}; + const hasIssues = (health.breached || 0) + (health.weak || 0) + (health.reused || 0) > 0; + if (hasIssues) { + const color = (health.breached || 0) > 0 ? "#c0392b" : "#d97706"; + chrome.action.setBadgeText({ text: "⚠", tabId }); + chrome.action.setBadgeBackgroundColor({ color, tabId }); + } else { + chrome.action.setBadgeText({ text: "", tabId }); + } } } catch { /* tab may have closed */ @@ -213,9 +259,20 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { return false; } + // Popup reports vault health check results → store and update badge. + if (msg.type === "HEALTH_UPDATE") { + const { breached = 0, weak = 0, reused = 0 } = msg; + chrome.storage.local.set({ [HEALTH_STATUS_KEY]: { breached, weak, reused } }); + applyHealthBadge(); + sendResponse({ ok: true }); + return false; + } + // Clear the pending-save badge once the popup signals it has handled the prompt. + // Re-apply the health warning badge afterwards if applicable. if (msg.type === "CLEAR_SAVE_BADGE") { chrome.action.setBadgeText({ text: "" }); + applyHealthBadge(); sendResponse({ ok: true }); return false; } @@ -270,4 +327,4 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { } return false; -}); +}); \ No newline at end of file diff --git a/extension/popup/popup.js b/extension/popup/popup.js index 88d3483..3fefaa4 100644 --- a/extension/popup/popup.js +++ b/extension/popup/popup.js @@ -617,6 +617,11 @@ async function fetchAndDecryptVault() { vault_items: itemsForContentScript, }) .catch(() => {}); + + // Run health checks in the background — sync metrics first, then HIBP. + // Results are sent to the background SW via HEALTH_UPDATE so the toolbar + // badge reflects the vault health even when the popup is closed. + _runPopupHealthCheck(); } catch (err) { console.error("fetchAndDecryptVault:", err); } finally { @@ -624,6 +629,107 @@ async function fetchAndDecryptVault() { } } +// ── Vault health check ──────────────────────────────────────────────────────── + +/** + * Compute weak/reused counts synchronously, then run HIBP in parallel. + * Sends HEALTH_UPDATE to the background service worker so the toolbar + * badge is updated even after the popup closes. + * Called once per fetchAndDecryptVault() cycle. + */ +async function _runPopupHealthCheck() { + try { + const pwItems = _items.filter( + (i) => i.item_type === "password" && i.plain?.password, + ); + if (!pwItems.length) { + chrome.runtime + .sendMessage({ type: "HEALTH_UPDATE", breached: 0, weak: 0, reused: 0 }) + .catch(() => {}); + return; + } + + // Synchronous metrics. + const weak = pwItems.filter((i) => { + const p = i.plain.password; + if (p.length < 10) return true; + return ( + [/[A-Z]/, /[a-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((r) => r.test(p)) + .length < 2 + ); + }); + + const passCounts = {}; + pwItems.forEach((i) => { + const p = i.plain.password; + (passCounts[p] = passCounts[p] || []).push(i); + }); + const reused = Object.values(passCounts) + .filter((a) => a.length > 1) + .flat(); + + // Send sync results immediately so background badge updates quickly. + chrome.runtime + .sendMessage({ + type: "HEALTH_UPDATE", + breached: 0, + weak: weak.length, + reused: reused.length, + }) + .catch(() => {}); + + // HIBP — k-anonymity, parallel. + const hibpResults = await Promise.all( + pwItems.map(async (item) => ({ + item, + count: await _popupCheckHibp(item.plain.password), + })), + ); + const breachedCount = hibpResults.filter((r) => r.count > 0).length; + + // Send final results with breach count. + chrome.runtime + .sendMessage({ + type: "HEALTH_UPDATE", + breached: breachedCount, + weak: weak.length, + reused: reused.length, + }) + .catch(() => {}); + } catch (err) { + console.error("[PassKeeper] Popup health check failed:", err); + } +} + +/** + * HIBP k-anonymity check — identical to the web app's checkHibp(). + * Only the first 5 hex chars of SHA-1(password) are sent; the full + * hash never leaves the browser. + */ +async function _popupCheckHibp(password) { + try { + const enc = new TextEncoder().encode(password); + const hashBuf = await crypto.subtle.digest("SHA-1", enc); + const hashHex = Array.from(new Uint8Array(hashBuf)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + .toUpperCase(); + const prefix = hashHex.slice(0, 5); + const suffix = hashHex.slice(5); + const res = await fetch( + `https://api.pwnedpasswords.com/range/${prefix}`, + { headers: { "Add-Padding": "true" } }, + ); + if (!res.ok) return 0; + const text = await res.text(); + const line = text.split("\n").find((l) => l.startsWith(suffix)); + if (!line) return 0; + return parseInt(line.split(":")[1], 10) || 0; + } catch { + return 0; + } +} + // ── TOTP engine (RFC 6238) — pure Web Crypto, no library ───────────────────── function _extractTotpSecret(uri) { @@ -1655,4 +1761,4 @@ async function init() { initTabs(); } -document.addEventListener("DOMContentLoaded", init); +document.addEventListener("DOMContentLoaded", init); \ No newline at end of file