Files
PassKeeper/extension/background.js
T
nngo 0295fac3fa
CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled
06/07 Optimize app
2026-06-07 16:16:55 -04:00

330 lines
12 KiB
JavaScript

/**
* extension/background.js — Manifest V3 service worker.
*
* Responsibilities:
* - Update the action badge (number of matching vault items) for the active tab.
* - Bridge SAVE_CREDENTIALS messages from content script → chrome.storage.local.
* - Re-update badges when the vault cache changes.
* - Lock the vault automatically after IDLE_LOCK_SECONDS of system inactivity.
*/
// ── Idle lock ─────────────────────────────────────────────────────────────────
// Default: never lock (session clears naturally on browser close via chrome.storage.session).
const DEFAULT_IDLE_LOCK_SECONDS = 0;
const IDLE_TIMEOUT_KEY = "idle_lock_seconds";
/** Apply the idle detection interval, reading the user's saved preference. */
async function applyIdleInterval() {
const stored = await chrome.storage.local.get(IDLE_TIMEOUT_KEY);
const seconds = stored[IDLE_TIMEOUT_KEY] ?? DEFAULT_IDLE_LOCK_SECONDS;
if (seconds === 0) {
// "Never" — unregister by setting to Chrome's maximum (the API requires a value).
chrome.idle.setDetectionInterval(3600);
} else {
chrome.idle.setDetectionInterval(Math.max(15, seconds));
}
console.log(
"[PassKeeper] Idle lock interval:",
seconds === 0 ? "never" : seconds + "s",
);
}
// Apply on every SW startup (SW can be killed and restarted at any time).
applyIdleInterval();
// Allow content scripts to read chrome.storage.session.
// By default, MV3 session storage is restricted to TRUSTED_CONTEXTS only
// (background service worker + popup). Without this call, every
// chrome.storage.session.get() in content.js silently returns undefined,
// so vault_items_cs is never read and the autofill dropdown always shows
// "No saved passwords for this site." even when credentials exist.
chrome.storage.session.setAccessLevel(
{ accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" },
() => {
if (chrome.runtime.lastError) {
console.warn(
"[PassKeeper] setAccessLevel failed:",
chrome.runtime.lastError.message,
);
} else {
console.log(
"[PassKeeper] session storage access level set to TRUSTED_AND_UNTRUSTED_CONTEXTS",
);
}
},
);
chrome.idle.onStateChanged.addListener(async (newState) => {
if (newState === "idle" || newState === "locked") {
// Check if the user set "Never" before locking.
const stored = await chrome.storage.local.get(IDLE_TIMEOUT_KEY);
if ((stored[IDLE_TIMEOUT_KEY] ?? DEFAULT_IDLE_LOCK_SECONDS) === 0) return;
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 });
});
}
});
// ── 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");
if (!vault_items?.length) {
chrome.action.setBadgeText({ text: "", tabId });
return;
}
let hostname;
try {
hostname = new URL(url).hostname.replace(/^www\./, "");
} catch {
chrome.action.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.action.setBadgeText({ text: String(matches.length), tabId });
chrome.action.setBadgeBackgroundColor({ color: "#1a73e8", tabId });
} else {
// 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 */
}
}
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 }) => {
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) => {
// Content script detected a login form on the page — no action needed in the
// background, but we must respond to close the message port cleanly.
if (msg.type === "FORMS_DETECTED") {
sendResponse({ ok: true });
return false;
}
// Popup updated the idle lock timeout → re-apply immediately.
if (msg.type === "SET_IDLE_TIMEOUT") {
applyIdleInterval();
sendResponse({ ok: true });
return false;
}
// Content script requests opening the vault tab (from suggestion dropdown).
if (msg.type === "OPEN_VAULT") {
chrome.tabs.create({ url: "https://pwkeeper.ngodanguyen.tech/vault" });
sendResponse({ ok: true });
return false;
}
// Content script requests navigating the popup to the generator view.
if (msg.type === "OPEN_GENERATOR") {
chrome.storage.session.set({ popup_nav: "generator" });
chrome.action.openPopup().catch(() => {
// openPopup() requires user gesture in some Chrome versions — fallback is a no-op.
});
sendResponse({ ok: true });
return false;
}
// No-op ping from popup — keeps the service worker alive while the browser
// is open so chrome.storage.session is not wiped between popup openings.
if (msg.type === "KEEPALIVE") {
sendResponse({ ok: true });
return false;
}
// Popup signals vault cache was refreshed → re-check badges AND forward
// the decrypted items directly to all content scripts (avoids storage read).
if (msg.type === "VAULT_UPDATED") {
refreshAllBadges();
const payload = {
type: "VAULT_UPDATED",
vault_items: msg.vault_items || [],
};
chrome.tabs.query({}, function (tabs) {
tabs.forEach(function (tab) {
if (
tab.url &&
(tab.url.startsWith("http://") || tab.url.startsWith("https://"))
) {
chrome.tabs.sendMessage(tab.id, payload).catch(function () {});
}
});
});
sendResponse({ ok: true });
return false;
}
// Content script detected credentials on form submit → store for save-prompt.
if (msg.type === "SAVE_CREDENTIALS") {
// Store in local storage so the prompt survives service worker restarts
// and is guaranteed to be present when the user next opens the popup.
chrome.storage.local.set({ pending_save: msg.data });
// Show a global "!" badge on the toolbar icon so the user knows to open
// the popup even if they never do so otherwise.
chrome.action.setBadgeText({ text: "!" });
chrome.action.setBadgeBackgroundColor({ color: "#c0392b" });
console.log("[PassKeeper] pending_save badge set for", msg.data?.siteName);
sendResponse({ ok: true });
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;
}
// Bridge: web app logged in → store tokens in extension storage.
if (msg.type === "WEB_SESSION_SYNC") {
chrome.storage.session.set({
access_token: msg.access_token,
enc_key_salt: msg.enc_key_salt || "",
});
// Also persist enc_key_salt locally so the unlock-only view survives browser restarts
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;
}
// Bridge: web app logged out → clear extension session.
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;
}
// Popup logged in via extension → inject session into open vault tabs.
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;
});