/** * 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 = 0; 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'); 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 }); }); }, seconds * 1000); } 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'); 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 { 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 */ } } 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 === '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') { 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; });