import { isSearchEngineResultsPage } from '../lib/searchEngineHelpers.js'; import { DEFAULT_BLOCKED_SITES } from '../lib/defaultBlocklist.js'; import { tryPostQuietStatsReport, ELLY_HOST_TALLY_KEY, MAX_HOST_KEYS } from '../lib/ellyQuietStats.js'; import { isUrlInList as urlListMatches } from '../lib/ellyUrlMatch.js'; import { ACCESS_PIN_CONFIGURED_KEY, ACCESS_PIN_HASH_STORAGE_KEY, ACCESS_PIN_STORAGE_KEY, LEGACY_DEFAULT_PARENT_PIN, hashAccessPin, hashAccessPinLegacySha256, hashParentPin, isAcceptableNewAccessPin, isAcceptableNewParentPin, isAccessPinEffectivelyConfigured, isForbiddenAccessPin, isForbiddenParentPin, isLegacySha256PinHash, isPbkdf2PinHash, isValidAccessPin, isValidParentPin, normalizePinDigits, secureStringEqual, stripSecretsFromStorageSnapshot, importContainsPlaintextParentPin } from '../lib/ellyAccessPin.js'; // PIN Protection System let isPINVerified = false; const PIN_STORAGE_KEY = ACCESS_PIN_STORAGE_KEY; const PIN_HASH_KEY = ACCESS_PIN_HASH_STORAGE_KEY; const APP_SHORTNAME = 'Elly'; let pinIsSet = false; /** Access session idle timeout (15 minutes). */ const ACCESS_SESSION_IDLE_MS = 15 * 60 * 1000; /** @type {number} */ let lastAccessActivityMs = 0; /** * Short-lived Parent mutation auth — SW memory only (never trust storage.local). * Cleared plantable key `ellyBlocklistMutateUntil` on boot. Max 5 minutes. */ const BLOCKLIST_AUTH_KEY = 'ellyBlocklistMutateUntil'; const BLOCKLIST_AUTH_MS = 5 * 60 * 1000; /** @type {number} */ let blocklistMutateUntilMs = 0; /** Access PIN verify rate limit: 5 failures / 5 minutes. */ const ACCESS_PIN_FAIL_WINDOW_MS = 5 * 60 * 1000; const ACCESS_PIN_FAIL_MAX = 5; /** @type {number[]} */ let accessPinFailTimestamps = []; /** Prevent re-entry while reverting unauthorized blocklist shrinks. */ let blocklistRevertInFlight = false; /** Set true only around intentional Access PIN / configured-flag writes from this SW. */ let accessPinWriteAuthorized = false; /** Set true only around intentional Parent PIN hash writes from this SW. */ let parentPinWriteAuthorized = false; /** Set true only around intentional security toggle writes (extensionEnabled / strict / pinLock OFF). */ let securityToggleWriteAuthorized = false; /** One-shot: focus session restore may shrink blockedSites without Parent mutation auth. */ let focusRestoreAuthorized = false; /** Optional SW write-auth for schedules / limitedSites (import paths may use mutation auth instead). */ let schedulesWriteAuthorized = false; let limitedSitesWriteAuthorized = false; /** Last known good Access PIN hash / configured flag (for unauthorized-change revert). */ let lastKnownAccessPinHash = ''; let lastKnownAccessPinConfigured = false; /** Last known Parent override PIN hash (memory; for unauthorized-change revert). Never plaintext. */ let lastKnownOverridePinHash = ''; /** One-time seed: block extension-management surfaces after first Access/Parent PIN setup. */ const EXT_MGMT_BLOCKLIST_SEEDED_KEY = 'ellyExtMgmtBlocklistSeededV1'; /** Patterns added once to blockedSites (exact entries; matching uses includes / wildcard regex). */ const EXT_MGMT_BLOCK_PATTERNS = [ 'chrome://extensions', 'opera://extensions', 'addons.opera.com', '*.addons.opera.com' ]; /** * Authorized parent overrides live only in SW memory (never trust storage.local alone). * @type {Map} hostKey -> expiry ms */ const parentOverrideHosts = new Map(); /** * @type {Map} tabId string -> expiry ms */ const parentOverrideTabs = new Map(); /** Allowlisted sync keys for locked-down updateSettings. */ const UPDATE_SETTINGS_ALLOWLIST = new Set([ 'autoCloseTabs', 'showNotifications', 'allowSearchEngineResults', 'theme', 'pomodoroMinutes', 'breakMinutes', 'syncFrequency' ]); /** * Clears client-plantable override / mutation keys from local storage. * @returns {Promise} */ async function clearPlantableLocalAuthKeys() { await chrome.storage.local.remove(['overrideHosts', 'overrideTabs', BLOCKLIST_AUTH_KEY]); parentOverrideHosts.clear(); parentOverrideTabs.clear(); } /** * Writes Access PIN hash (+ removes plaintext) with write-auth flag set. * @param {string} hash * @returns {Promise} */ async function writeAccessPinHashAuthorized(hash) { accessPinWriteAuthorized = true; try { await chrome.storage.sync.set({ [PIN_HASH_KEY]: hash }); await chrome.storage.sync.remove([PIN_STORAGE_KEY]); lastKnownAccessPinHash = hash; } finally { accessPinWriteAuthorized = false; } } /** * Writes Access PIN configured local flag with write-auth flag set. * @param {boolean} value * @returns {Promise} */ async function writeAccessPinConfiguredAuthorized(value) { accessPinWriteAuthorized = true; try { await chrome.storage.local.set({ [ACCESS_PIN_CONFIGURED_KEY]: !!value }); lastKnownAccessPinConfigured = !!value; } finally { accessPinWriteAuthorized = false; } } /** * Migrates / refreshes Access PIN configured flag without auto-setting a factory PIN. * Migrates plaintext `nanny_pin` → PBKDF2 hash; upgrades legacy SHA-256 hashes on verify. * @returns {Promise} effectively configured */ async function refreshAccessPinConfiguredState() { const sync = await chrome.storage.sync.get([PIN_STORAGE_KEY, PIN_HASH_KEY]); const local = await chrome.storage.local.get([ACCESS_PIN_CONFIGURED_KEY]); let storedHash = typeof sync[PIN_HASH_KEY] === 'string' ? sync[PIN_HASH_KEY] : ''; const stored = normalizePinDigits(sync[PIN_STORAGE_KEY]); // Migrate acceptable plaintext → PBKDF2 hash once. if (!storedHash && isAcceptableNewAccessPin(stored)) { storedHash = await hashAccessPin(stored); await writeAccessPinHashAuthorized(storedHash); } else if (stored && (isForbiddenAccessPin(stored) || !isValidAccessPin(stored))) { accessPinWriteAuthorized = true; try { await chrome.storage.sync.remove([PIN_STORAGE_KEY]); } finally { accessPinWriteAuthorized = false; } } else if (stored && storedHash) { // Hash already present — drop leftover plaintext. accessPinWriteAuthorized = true; try { await chrome.storage.sync.remove([PIN_STORAGE_KEY]); } finally { accessPinWriteAuthorized = false; } } lastKnownAccessPinHash = storedHash || ''; if (!storedHash && (isForbiddenAccessPin(stored) || !isValidAccessPin(stored))) { await writeAccessPinConfiguredAuthorized(false); pinIsSet = false; return false; } let flag = !!local[ACCESS_PIN_CONFIGURED_KEY]; // Existing non-legacy PIN / hash from older installs: treat as configured. if (!flag && (storedHash || isAcceptableNewAccessPin(stored))) { await writeAccessPinConfiguredAuthorized(true); flag = true; } lastKnownAccessPinConfigured = flag; const configured = isAccessPinEffectivelyConfigured(stored, flag, storedHash); pinIsSet = configured; if (!configured) { isPINVerified = false; } return configured; } /** * Migrates plaintext Parent PIN → hash; clears invalid-length / forbidden PINs. * @returns {Promise} */ async function refreshLastKnownParentPin() { const data = await chrome.storage.sync.get(['settings']); const prev = { ...(data.settings || {}) }; const storedPlain = normalizePinDigits(prev.overridePIN); let hash = typeof prev.overridePinHash === 'string' ? prev.overridePinHash : ''; // Upgrade: short / forbidden legacy plaintext → clear and force re-setup. if (storedPlain && (!isValidParentPin(storedPlain) || isForbiddenParentPin(storedPlain))) { parentPinWriteAuthorized = true; try { const next = { ...prev }; delete next.overridePIN; delete next.overridePinHash; await chrome.storage.sync.set({ settings: next }); } finally { parentPinWriteAuthorized = false; } lastKnownOverridePinHash = ''; return; } // Migrate acceptable plaintext → hash. if (storedPlain && isValidParentPin(storedPlain) && !isForbiddenParentPin(storedPlain)) { if (!isPbkdf2PinHash(hash)) { hash = await hashParentPin(storedPlain); } parentPinWriteAuthorized = true; try { const next = { ...prev, overridePinHash: hash }; delete next.overridePIN; await chrome.storage.sync.set({ settings: next }); } finally { parentPinWriteAuthorized = false; } lastKnownOverridePinHash = hash; return; } // Hash present but invalid format / leftover plaintext empty — keep hash if PBKDF2. if (isPbkdf2PinHash(hash)) { if (storedPlain) { parentPinWriteAuthorized = true; try { const next = { ...prev }; delete next.overridePIN; await chrome.storage.sync.set({ settings: next }); } finally { parentPinWriteAuthorized = false; } } lastKnownOverridePinHash = hash; return; } // Unusable hash leftovers — clear. if (hash || prev.overridePIN != null) { parentPinWriteAuthorized = true; try { const next = { ...prev }; delete next.overridePIN; delete next.overridePinHash; await chrome.storage.sync.set({ settings: next }); } finally { parentPinWriteAuthorized = false; } } lastKnownOverridePinHash = ''; } /** * @returns {Promise} */ async function isParentPinConfigured() { await refreshLastKnownParentPin(); return isPbkdf2PinHash(lastKnownOverridePinHash); } /** * @returns {void} */ function touchAccessSession() { lastAccessActivityMs = Date.now(); } /** * Clears Access session if idle past timeout. * @returns {boolean} still verified */ function ensureAccessSessionFresh() { if (!isPINVerified) { return false; } if (!lastAccessActivityMs || (Date.now() - lastAccessActivityMs) > ACCESS_SESSION_IDLE_MS) { isPINVerified = false; lastAccessActivityMs = 0; return false; } return true; } /** * @returns {void} */ function lockAccessSession() { isPINVerified = false; lastAccessActivityMs = 0; } /** * True when message sender is an extension page (options/popup/pin/welcome/block). * @param {chrome.runtime.MessageSender | undefined} sender * @returns {boolean} */ function isExtensionPageSender(sender) { if (!sender || typeof sender.url !== 'string' || !sender.url) { return false; } const origin = chrome.runtime.getURL(''); return sender.url.startsWith(origin); } /** * Override from block.html (extension page / web_accessible) only — not arbitrary content scripts. * @param {chrome.runtime.MessageSender | undefined} sender * @returns {boolean} */ function isOverrideBlockSenderAllowed(sender) { if (!sender || typeof sender.url !== 'string') { return false; } const u = sender.url; const origin = chrome.runtime.getURL(''); if (u.startsWith(origin)) { return true; } const extId = chrome.runtime.id; if (extId && u.includes(extId) && u.includes('block.html')) { return true; } return false; } /** * Fresh-install first-claim: welcome / pin / options only. * @param {chrome.runtime.MessageSender | undefined} sender * @returns {boolean} */ function isFirstClaimSetupSender(sender) { if (!isExtensionPageSender(sender)) { return false; } const u = sender.url || ''; return u.includes('/welcome/') || u.includes('/pin/') || u.includes('/options/'); } /** * Reject privileged actions from non-extension senders. * @param {chrome.runtime.MessageSender | undefined} sender * @param {(r: object) => void} sendResponse * @returns {boolean} true if rejected */ function rejectIfNotExtensionPage(sender, sendResponse) { if (isExtensionPageSender(sender)) { return false; } sendResponse({ success: false, error: 'Privileged action requires an extension page' }); return true; } /** * Access PIN verify rate-limit bookkeeping. * @returns {{ limited: boolean, retryAfterMs?: number }} */ function getAccessPinRateLimitState() { const now = Date.now(); accessPinFailTimestamps = accessPinFailTimestamps.filter((t) => now - t < ACCESS_PIN_FAIL_WINDOW_MS); if (accessPinFailTimestamps.length >= ACCESS_PIN_FAIL_MAX) { const oldest = accessPinFailTimestamps[0]; return { limited: true, retryAfterMs: Math.max(0, ACCESS_PIN_FAIL_WINDOW_MS - (now - oldest)) }; } return { limited: false }; } /** * @returns {void} */ function recordAccessPinFailure() { accessPinFailTimestamps.push(Date.now()); } /** * @returns {void} */ function clearAccessPinFailures() { accessPinFailTimestamps = []; } /** * @returns {Promise} */ async function isAccessPinConfigured() { return refreshAccessPinConfiguredState(); } /** * Opens the Access PIN window (setup or unlock). * @param {'setup' | 'unlock'} mode * @returns {Promise} */ async function openAccessPinWindow(mode) { const q = mode === 'setup' ? '?mode=setup' : ''; await chrome.windows.create({ url: chrome.runtime.getURL(`pin/pin.html${q}`), type: 'popup', width: 560, height: 900, focused: true }); } /** * Opens the main toolbar popup as a dedicated window (reliable after PIN, no default_popup). * @returns {Promise} */ async function openMainPopupWindow() { await chrome.windows.create({ url: chrome.runtime.getURL('popup/popup.html'), type: 'popup', width: 560, height: 640, focused: true }); } /** * Toolbar icon click: setup → unlock → main popup. */ chrome.action.onClicked.addListener(() => { (async () => { try { const configured = await isAccessPinConfigured(); if (!configured) { await openAccessPinWindow('setup'); return; } ensureAccessSessionFresh(); if (!isPINVerified) { await openAccessPinWindow('unlock'); return; } touchAccessSession(); await openMainPopupWindow(); } catch (e) { console.error('Elly action.onClicked:', e); } })(); }); /** * Shared fresh-install defaults (no factory PINs). * @returns {object} */ function buildDefaultSyncPayload() { return { blockedSites: [...DEFAULT_BLOCKED_SITES], limitedSites: {}, schedules: [], settings: { strictMode: true, blocklistPinLock: true, autoCloseTabs: true, showNotifications: true, theme: 'light', allowSearchEngineResults: true }, statistics: {}, whitelist: [], focusSessions: [] }; } // Install / update: defaults, welcome tab, search-results preference — never auto-set Access PIN chrome.runtime.onInstalled.addListener((details) => { (async () => { try { await clearPlantableLocalAuthKeys(); isPINVerified = false; const data = await chrome.storage.sync.get(null); const local = await chrome.storage.local.get(['ellySearchDefaultApplied219', 'ellyExpandedBlocklistV1']); if (Object.keys(data).length === 0) { await chrome.storage.sync.set(buildDefaultSyncPayload()); await chrome.storage.local.set({ ellySearchDefaultApplied219: true, ellyExpandedBlocklistV1: true }); await writeAccessPinConfiguredAuthorized(false); } else { const merged = { ...(data.settings || {}) }; if (merged.allowSearchEngineResults === undefined) { merged.allowSearchEngineResults = true; } // Strip unusable legacy parent PIN from storage (migrate/clear on refresh) if (isForbiddenParentPin(merged.overridePIN) || merged.overridePIN === LEGACY_DEFAULT_PARENT_PIN) { delete merged.overridePIN; } if (merged.overridePIN && !isValidParentPin(merged.overridePIN)) { delete merged.overridePIN; delete merged.overridePinHash; } if (!local.ellySearchDefaultApplied219) { merged.allowSearchEngineResults = true; await chrome.storage.local.set({ ellySearchDefaultApplied219: true }); } await chrome.storage.sync.set({ settings: merged }); if (!local.ellyExpandedBlocklistV1) { const sync = await chrome.storage.sync.get(['blockedSites']); const existing = new Set(sync.blockedSites || []); const mergedSites = [...(sync.blockedSites || [])]; for (const entry of DEFAULT_BLOCKED_SITES) { if (!existing.has(entry)) { existing.add(entry); mergedSites.push(entry); } } await chrome.storage.sync.set({ blockedSites: mergedSites }); await chrome.storage.local.set({ ellyExpandedBlocklistV1: true }); } } await refreshAccessPinConfiguredState(); await refreshLastKnownParentPin(); await maybeSeedExtMgmtAfterPinReady(); if (details.reason === 'install') { chrome.tabs.create({ url: chrome.runtime.getURL('welcome/welcome.html') }); } initializeAlarms(); } catch (e) { console.error('Elly onInstalled:', e); initializeAlarms(); } })(); }); chrome.runtime.onStartup.addListener(() => { isPINVerified = false; blocklistMutateUntilMs = 0; (async () => { try { await clearPlantableLocalAuthKeys(); await refreshAccessPinConfiguredState(); await refreshLastKnownParentPin(); await maybeSeedExtMgmtAfterPinReady(); await loadPersistedSiteUsage(); } catch (e) { console.error('Elly onStartup:', e); } })(); }); // Track active timers and site usage let activeTimers = {}; /** @type {Record>} */ let siteUsage = {}; /** Max domains kept in persisted siteUsage per day. */ const SITE_USAGE_MAX_DOMAINS = 150; /** Persist at most once per this many ms from the usage ticker. */ const SITE_USAGE_PERSIST_INTERVAL_MS = 15000; let siteUsageLastPersistMs = 0; /** * @returns {string} chrome.storage.local key for today's usage */ function siteUsageStorageKey() { const d = new Date(); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `ellySiteUsage${y}-${m}-${day}`; } /** * Loads today's siteUsage from local storage (capped). * @returns {Promise} */ async function loadPersistedSiteUsage() { try { const key = siteUsageStorageKey(); const data = await chrome.storage.local.get([key]); const raw = data[key]; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { return; } /** @type {Record>} */ const loaded = {}; const keys = Object.keys(raw).slice(0, SITE_USAGE_MAX_DOMAINS); for (const domain of keys) { const days = raw[domain]; if (days && typeof days === 'object' && !Array.isArray(days)) { loaded[domain] = { ...days }; } } siteUsage = loaded; } catch (e) { console.error('Elly loadPersistedSiteUsage:', e); } } /** * Persists siteUsage lightly under the day key (capped). * @param {boolean} [force] * @returns {Promise} */ async function persistSiteUsage(force = false) { const now = Date.now(); if (!force && now - siteUsageLastPersistMs < SITE_USAGE_PERSIST_INTERVAL_MS) { return; } siteUsageLastPersistMs = now; try { const today = new Date().toDateString(); /** @type {Record>} */ const slim = {}; const entries = Object.entries(siteUsage); // Prefer domains with today's usage, then cap. entries.sort((a, b) => (b[1]?.[today] || 0) - (a[1]?.[today] || 0)); for (let i = 0; i < entries.length && Object.keys(slim).length < SITE_USAGE_MAX_DOMAINS; i++) { const [domain, days] = entries[i]; if (!days || typeof days !== 'object') { continue; } const dayVal = days[today]; if (typeof dayVal === 'number' && dayVal > 0) { slim[domain] = { [today]: dayVal }; } } await chrome.storage.local.set({ [siteUsageStorageKey()]: slim }); } catch (e) { console.error('Elly persistSiteUsage:', e); } } const QUIET_STATS_ALARM = 'ellyQuietStatsDaily'; /** Daily alarm for optional aggregate stats sync (JWT + server config). */ function ensureQuietStatsAlarm() { try { chrome.alarms.create(QUIET_STATS_ALARM, { periodInMinutes: 24 * 60 }); } catch { /* ignore */ } } // Initialize alarms for scheduled blocking function initializeAlarms() { ensureQuietStatsAlarm(); chrome.storage.sync.get(['schedules']).then((data) => { if (data.schedules) { data.schedules.forEach(schedule => { createScheduleAlarm(schedule); }); } }); } function createScheduleAlarm(schedule) { if (schedule.enabled) { chrome.alarms.create(`schedule_${schedule.id}_start`, { when: new Date(schedule.startTime).getTime(), periodInMinutes: 24 * 60 // Daily }); chrome.alarms.create(`schedule_${schedule.id}_end`, { when: new Date(schedule.endTime).getTime(), periodInMinutes: 24 * 60 // Daily }); } } // Handle alarms chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === QUIET_STATS_ALARM) { tryPostQuietStatsReport().catch(() => {}); return; } if (alarm.name.includes('schedule')) { handleScheduleAlarm(alarm); } else if (alarm.name.includes('limit_warning')) { showTimeLimitWarning(alarm.name.split('_')[2]); } else if (alarm.name.includes('limit_reached')) { handleTimeLimitReached(alarm.name.split('_')[2]); } else if (alarm.name.startsWith('override_host_')) { const enc = alarm.name.slice('override_host_'.length); let hostKey = ''; try { hostKey = decodeURIComponent(enc); } catch (e) { return; } if (!hostKey) { return; } (async () => { try { parentOverrideHosts.delete(hostKey); const tabs = await chrome.tabs.query({}); for (const tab of tabs) { if (!tab.id || !tab.url) continue; if (tab.url.startsWith('chrome://') || tab.url.startsWith('chrome-extension://') || tab.url.startsWith('opera://') || tab.url.startsWith('edge://')) { continue; } try { if (extractDomain(tab.url).toLowerCase() === hostKey) { await checkAndBlockSite(tab.url, tab.id); } } catch (e) { console.error('Elly override expiry re-check:', e); } } } catch (e) { console.error('Elly override_host alarm:', e); } })(); } else if (alarm.name.startsWith('override_tab_')) { const tidStr = alarm.name.slice('override_tab_'.length); const tid = parseInt(tidStr, 10); if (!Number.isFinite(tid)) { return; } (async () => { try { parentOverrideTabs.delete(String(tid)); const tab = await chrome.tabs.get(tid).catch(() => null); if (tab?.url && tab.id != null) { await checkAndBlockSite(tab.url, tab.id); } } catch (e) { console.error('Elly override_tab alarm:', e); } })(); } }); chrome.tabs.onRemoved.addListener((tabId) => { parentOverrideTabs.delete(String(tabId)); }); async function handleScheduleAlarm(alarm) { const [_, scheduleId, type] = alarm.name.split('_'); const data = await chrome.storage.sync.get(['schedules']); const schedule = data.schedules?.find(s => s.id === scheduleId); if (schedule) { const tabs = await chrome.tabs.query({}); tabs.forEach(tab => { if (isUrlBlockedBySchedule(tab.url, schedule)) { if (type === 'start') { blockTab(tab.id, tab.url); } else { unblockTab(tab.id); } } }); } } /** * Blocking model (there are no Declarative Net Request "rules"): * - Policies live in chrome.storage.sync: blockedSites, schedules, limitedSites, whitelist, settings. * - On navigation, checkAndBlockSite() evaluates those policies and may call blockTab(). * - Parent PIN grants in-memory overrideTabs / overrideHosts for 15 minutes (SW Map only). * - Direct blockTab() calls must still honor overrides (see shouldSkipBlockDueToParentOverride). */ // Check if site should be blocked chrome.webNavigation.onBeforeNavigate.addListener((details) => { if (details.url) { checkAndBlockSite(details.url, details.tabId); } }); chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { if (changeInfo.url && !shouldSkipInternalBrowserUrl(changeInfo.url)) { checkAndBlockSite(changeInfo.url, tabId); } }); /** * chrome://extensions and opera://extensions only — other chrome/opera/edge internals stay skipped. * @param {string} url * @returns {boolean} */ function isExtensionManagementUrl(url) { if (!url || typeof url !== 'string') { return false; } const u = url.toLowerCase(); return u.startsWith('chrome://extensions') || u.startsWith('opera://extensions'); } /** * Skip chrome:// / opera:// / edge:// except extension-management pages we intentionally gate. * @param {string} url * @returns {boolean} */ function shouldSkipInternalBrowserUrl(url) { if (!url || typeof url !== 'string') { return true; } if (url.startsWith('chrome://') || url.startsWith('opera://') || url.startsWith('edge://')) { return !isExtensionManagementUrl(url); } return false; } /** * Seed ext-mgmt blocklist once when a real PIN already exists (upgrade path) * or after first-time PIN setup handlers call seed directly. * @returns {Promise} */ async function maybeSeedExtMgmtAfterPinReady() { const accessReady = await refreshAccessPinConfiguredState(); await refreshLastKnownParentPin(); const parentReady = isPbkdf2PinHash(lastKnownOverridePinHash); if (accessReady || parentReady) { await seedExtensionManagementBlocklistIfNeeded(); } } /** * Once only: add extension-management patterns to blockedSites after first PIN setup. * Grants mutation auth so the storage guard does not revert the seed write. * @returns {Promise} */ async function seedExtensionManagementBlocklistIfNeeded() { try { const local = await chrome.storage.local.get([EXT_MGMT_BLOCKLIST_SEEDED_KEY]); if (local[EXT_MGMT_BLOCKLIST_SEEDED_KEY]) { return; } grantBlocklistMutationAuth(); const sync = await chrome.storage.sync.get(['blockedSites']); const sites = [...(sync.blockedSites || [])]; let changed = false; for (const pattern of EXT_MGMT_BLOCK_PATTERNS) { if (!sites.includes(pattern)) { sites.push(pattern); changed = true; } } if (changed) { await chrome.storage.sync.set({ blockedSites: sites }); } await chrome.storage.local.set({ [EXT_MGMT_BLOCKLIST_SEEDED_KEY]: true }); } catch (err) { console.error('Elly: extension-mgmt blocklist seed failed', err); } } async function checkAndBlockSite(url, tabId) { if (!url || shouldSkipInternalBrowserUrl(url)) { return; } if (isExtensionSchemeUrl(url)) { return; } if (typeof tabId !== 'number' || !Number.isFinite(tabId)) { return; } try { const tabExp = parentOverrideTabs.get(String(tabId)); if (typeof tabExp === 'number') { if (Date.now() < tabExp) { return; } parentOverrideTabs.delete(String(tabId)); } const { settings: extSettings } = await chrome.storage.sync.get(['settings']); if (extSettings?.extensionEnabled === false) { return; } const hostsObj = Object.fromEntries(parentOverrideHosts.entries()); const matchKey = findMatchingOverrideKey(url, hostsObj); const exp = matchKey != null ? parentOverrideHosts.get(matchKey) : undefined; if (typeof exp === 'number') { if (Date.now() < exp) { return; } parentOverrideHosts.delete(matchKey); } const data = await chrome.storage.sync.get(['blockedSites', 'limitedSites', 'schedules', 'whitelist', 'settings']); // Check whitelist first if (data.whitelist && isUrlInList(url, data.whitelist)) { return; } if (data.settings?.allowSearchEngineResults !== false && isSearchEngineResultsPage(url)) { return; } // Check blocked sites if (data.blockedSites && isUrlBlocked(url, data.blockedSites)) { await blockTab(tabId, url); return; } // Check schedules if (data.schedules && data.schedules.length > 0) { const now = new Date(); const currentDay = now.getDay(); const currentTime = now.getHours() * 60 + now.getMinutes(); for (const schedule of data.schedules) { if (!schedule.enabled) continue; const scheduleDays = schedule.days || [0, 1, 2, 3, 4, 5, 6]; if (!scheduleDays.includes(currentDay)) continue; const startMinutes = timeToMinutes(schedule.startTime); const endMinutes = timeToMinutes(schedule.endTime); if (currentTime >= startMinutes && currentTime <= endMinutes) { if (schedule.type === 'block' && !isUrlInList(url, schedule.allowedSites || [])) { await blockTab(tabId, url); return; } else if (schedule.type === 'allow' && isUrlInList(url, schedule.blockedSites || [])) { await blockTab(tabId, url); return; } } } } // Check time limits const limit = getLimitedSiteEntryForUrl(url, data.limitedSites); if (limit && typeof limit.limitMinutes === 'number') { const domain = extractDomain(url); const today = new Date().toDateString(); if (!siteUsage[domain]) siteUsage[domain] = {}; if (!siteUsage[domain][today]) siteUsage[domain][today] = 0; if (siteUsage[domain][today] >= limit.limitMinutes * 60) { await blockTab(tabId, url); return; } const remaining = (limit.limitMinutes * 60) - siteUsage[domain][today]; if (remaining <= 300 && remaining > 0) { chrome.alarms.create(`limit_warning_${domain}`, { delayInMinutes: 1 }); } } } catch (error) { console.error('Error checking site:', error); } } function isUrlBlocked(url, blockedList) { return urlListMatches(url, blockedList); } function isUrlInList(url, list) { return urlListMatches(url, list); } function extractDomain(url) { try { const urlObj = new URL(url); return urlObj.hostname.replace('www.', ''); } catch { return url; } } /** * Matches limitedSites keys whether stored as bare domain, full hostname, or www. * @param {string} url * @param {Record | undefined} limitedSites * @returns {{ limitMinutes: number } | null} */ /** * Finds the overrideHosts storage key that applies to this URL (bare domain keys apply to subdomains). * @param {string} url * @param {Record} hosts * @returns {string | null} */ function findMatchingOverrideKey(url, hosts) { if (!hosts || typeof hosts !== 'object') { return null; } try { const host = new URL(url).hostname.toLowerCase(); const bare = extractDomain(url).toLowerCase(); if (typeof hosts[bare] === 'number') { return bare; } if (typeof hosts[host] === 'number') { return host; } for (const k of Object.keys(hosts)) { if (typeof hosts[k] !== 'number') { continue; } const key = String(k).toLowerCase(); if (host === key || bare === key) { return k; } if (host.endsWith('.' + key)) { return k; } } } catch (e) { /* ignore */ } return null; } /** * Extension internal pages must never be evaluated against web blocklists (Opera may use opera-extension://). * @param {string} url * @returns {boolean} */ function isExtensionSchemeUrl(url) { if (!url || typeof url !== 'string') { return false; } const u = url.toLowerCase(); return ( u.startsWith('chrome-extension://') || u.startsWith('opera-extension://') || u.startsWith('moz-extension://') ); } /** * Same logic as the override checks inside checkAndBlockSite. Schedule alarms and timers call blockTab() * directly; without this, parent override is ignored and the tab keeps getting forced to the block page. * @param {number} tabId * @param {string} url * @returns {Promise} */ async function shouldSkipBlockDueToParentOverride(tabId, url) { if (typeof tabId !== 'number' || !Number.isFinite(tabId) || !url) { return false; } const tabExp = parentOverrideTabs.get(String(tabId)); if (typeof tabExp === 'number' && Date.now() < tabExp) { return true; } const hostsObj = Object.fromEntries(parentOverrideHosts.entries()); const matchKey = findMatchingOverrideKey(url, hostsObj); const exp = matchKey != null ? parentOverrideHosts.get(matchKey) : undefined; return typeof exp === 'number' && Date.now() < exp; } function getLimitedSiteEntryForUrl(url, limitedSites) { if (!limitedSites || typeof limitedSites !== 'object') { return null; } try { const domain = extractDomain(url); let host = ''; try { host = new URL(url).hostname.toLowerCase(); } catch (e) { host = ''; } const bare = host.replace(/^www\./i, ''); if (limitedSites[domain]) { return limitedSites[domain]; } if (host && limitedSites[host]) { return limitedSites[host]; } if (bare && limitedSites[bare]) { return limitedSites[bare]; } for (const key of Object.keys(limitedSites)) { const k = String(key).toLowerCase(); if (k === domain || k === host || k === bare) { return limitedSites[key]; } } } catch (e) { /* ignore */ } return null; } function timeToMinutes(timeStr) { const [hours, minutes] = timeStr.split(':').map(Number); return hours * 60 + minutes; } /** * True if the tab is already showing Elly's block page (any extension id). * @param {string} url * @returns {boolean} */ function tabUrlIsEllyBlockPage(url) { try { const u = new URL(url); const p = u.protocol.toLowerCase(); if (p !== 'chrome-extension:' && p !== 'opera-extension:' && p !== 'moz-extension:') { return false; } return u.pathname.toLowerCase().endsWith('block.html'); } catch { return false; } } async function blockTab(tabId, originalUrl) { try { if (await shouldSkipBlockDueToParentOverride(tabId, originalUrl)) { return; } const blockPage = new URL(chrome.runtime.getURL('content/block.html')); blockPage.searchParams.set('tabId', String(tabId)); const targetHref = blockPage.href; const tab = await chrome.tabs.get(tabId); if (tab?.url) { if (tabUrlIsEllyBlockPage(tab.url)) { const data = await chrome.storage.local.get(['originalUrls']); const originalUrls = { ...(data.originalUrls || {}) }; if (originalUrl) { originalUrls[tabId] = originalUrl; await chrome.storage.local.set({ originalUrls }); } return; } let sameDestination = false; try { sameDestination = new URL(tab.url).href === new URL(targetHref).href; } catch (e) { sameDestination = tab.url === targetHref; } if (sameDestination) { return; } } const data = await chrome.storage.local.get(['originalUrls']); const originalUrls = data.originalUrls || {}; originalUrls[tabId] = originalUrl; await chrome.storage.local.set({ originalUrls }); await chrome.tabs.update(tabId, { url: targetHref }); const blockHost = hostFromBlockedUrl(originalUrl); updateStatistics('blocks', 1, blockHost); } catch (error) { console.error('Error blocking tab:', error); } } /** * Hostname for per-site block tallies (chrome:// / invalid URLs yield empty). * @param {string | undefined} url * @returns {string} */ function hostFromBlockedUrl(url) { if (!url || typeof url !== 'string') { return ''; } try { const u = new URL(url); const p = u.protocol.toLowerCase(); if (p === 'chrome-extension:' || p === 'opera-extension:' || p === 'moz-extension:' || p === 'chrome:' || p === 'opera:' || p === 'edge:') { return ''; } return (u.hostname || '').toLowerCase(); } catch { return ''; } } async function unblockTab(tabId) { try { // Restore original URL from storage const data = await chrome.storage.local.get(['originalUrls']); if (data.originalUrls && data.originalUrls[tabId]) { await chrome.tabs.update(tabId, { url: data.originalUrls[tabId] }); delete data.originalUrls[tabId]; await chrome.storage.local.set(data); } } catch (error) { console.error('Error unblocking tab:', error); } } function showTimeLimitWarning(domain) { chrome.storage.sync.get(['settings']).then((data) => { if (data.settings?.showNotifications !== false) { chrome.notifications.create({ type: 'basic', iconUrl: 'icons/icon48.png', title: `${APP_SHORTNAME} - Time Limit Warning`, message: `You have less than 5 minutes remaining on ${domain}`, priority: 2 }); } }); } async function handleTimeLimitReached(domain) { const tabs = await chrome.tabs.query({}); tabs.forEach(tab => { if (tab.url && tab.url.includes(domain)) { blockTab(tab.id, tab.url); } }); const data = await chrome.storage.sync.get(['settings']); if (data.settings?.autoCloseTabs !== false) { setTimeout(() => { chrome.tabs.query({}).then((tabs) => { tabs.forEach(tab => { if (tab.url && tab.url.includes(domain)) { chrome.tabs.remove(tab.id); } }); }); }, 5000); // Close tabs after 5 seconds } } /** * @param {'blocks' | 'timeSaved'} type * @param {number} value * @param {string} [blockHost] Registrable domain for block tallies when type === 'blocks' */ function updateStatistics(type, value = 1, blockHost = '') { const today = new Date().toDateString(); chrome.storage.sync.get(['statistics', ELLY_HOST_TALLY_KEY]).then((data) => { const statistics = data.statistics || {}; if (!statistics[today]) { statistics[today] = { blocks: 0, timeSaved: 0 }; } if (type === 'blocks') { statistics[today].blocks += value; } else if (type === 'timeSaved') { statistics[today].timeSaved += value; } const patch = { statistics }; if (type === 'blocks' && blockHost) { const tally = { ...(data[ELLY_HOST_TALLY_KEY] || {}) }; tally[blockHost] = (tally[blockHost] || 0) + value; const keys = Object.keys(tally); if (keys.length > MAX_HOST_KEYS) { keys.sort((a, b) => (tally[b] || 0) - (tally[a] || 0)); const pruned = {}; for (let i = 0; i < MAX_HOST_KEYS; i++) { pruned[keys[i]] = tally[keys[i]]; } patch[ELLY_HOST_TALLY_KEY] = pruned; } else { patch[ELLY_HOST_TALLY_KEY] = tally; } } chrome.storage.sync.set(patch); }); } // Track time spent on sites setInterval(() => { chrome.tabs.query({ active: true, currentWindow: true }).then((tabs) => { if (tabs[0] && tabs[0].url) { if (tabUrlIsEllyBlockPage(tabs[0].url)) { return; } const domain = extractDomain(tabs[0].url); const today = new Date().toDateString(); if (!siteUsage[domain]) siteUsage[domain] = {}; if (!siteUsage[domain][today]) siteUsage[domain][today] = 0; siteUsage[domain][today]++; persistSiteUsage().catch(() => {}); chrome.storage.sync.get(['limitedSites']).then((data) => { const limit = getLimitedSiteEntryForUrl(tabs[0].url, data.limitedSites); if (limit && typeof limit.limitMinutes === 'number') { if (siteUsage[domain][today] >= limit.limitMinutes * 60) { blockTab(tabs[0].id, tabs[0].url); } } }); } }); }, 1000); // Update every second // Message listener chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { const action = request?.action; /** Privileged actions that require extension-page sender (not website content scripts). */ const PRIVILEGED_EXT_PAGE = new Set([ 'setNewPIN', 'resetPIN', 'setParentPin', 'resetParentPin', 'startFocusSession', 'addToBlocklist', 'removeFromBlocklist', 'authorizeBlocklistMutation', 'updateSettings', 'verifyAccessPin', 'verifyParentPinForBlocklist', 'updateSecuritySettings', 'setExtensionEnabled', 'importSettings', 'lockAccessSession' ]); if (PRIVILEGED_EXT_PAGE.has(action)) { if (rejectIfNotExtensionPage(sender, sendResponse)) { return false; } } if (action === 'overrideBlock') { if (!isOverrideBlockSenderAllowed(sender)) { sendResponse({ success: false, error: 'Override only allowed from the Elly block page' }); return false; } } switch (action) { case 'getSiteUsage': sendResponse({ usage: siteUsage }); break; case 'overrideBlock': handleOverrideBlock(request.pin, request.tabId, sendResponse); return true; case 'updateBlocklist': sendResponse({ success: true }); break; case 'authorizeBlocklistMutation': handleAuthorizeBlocklistMutation(request.pin, sendResponse); return true; case 'hasBlocklistMutationAuth': sendResponse({ active: hasBlocklistMutationAuth() }); break; case 'revokeBlocklistMutationAuth': revokeBlocklistMutationAuth(); sendResponse({ success: true }); break; case 'verifyParentPinForBlocklist': handleVerifyParentPinForBlocklist(request.pin, sendResponse); return true; case 'removeFromBlocklist': handleRemoveFromBlocklist(request.sites, request.pin, sendResponse); return true; case 'addToBlocklist': touchAccessSession(); addToBlocklist(request.url); sendResponse({ success: true }); break; case 'getSettings': chrome.storage.sync.get(null).then(async (data) => { const parentConfigured = await isParentPinConfigured(); const safe = stripSecretsFromStorageSnapshot(data); sendResponse({ ...safe, parentPinConfigured: parentConfigured }); }); return true; case 'updateSettings': { ensureAccessSessionFresh(); if (!isPINVerified) { sendResponse({ success: false, error: 'Access PIN verification required' }); break; } touchAccessSession(); const incoming = request.settings; if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) { sendResponse({ success: false, error: 'Invalid settings payload' }); break; } (async () => { try { const data = await chrome.storage.sync.get(['settings']); const prev = { ...(data.settings || {}) }; /** @type {Record} */ const patch = {}; for (const key of Object.keys(incoming)) { if (UPDATE_SETTINGS_ALLOWLIST.has(key)) { patch[key] = incoming[key]; } } if (Object.keys(patch).length === 0) { sendResponse({ success: false, error: 'No allowlisted settings keys' }); return; } await chrome.storage.sync.set({ settings: { ...prev, ...patch } }); sendResponse({ success: true }); } catch (err) { sendResponse({ success: false, error: err?.message || 'updateSettings failed' }); } })(); return true; } case 'updateSecuritySettings': handleUpdateSecuritySettings(request.settings, request.pin, sendResponse); return true; case 'setExtensionEnabled': handleSetExtensionEnabled(request.enabled, request.pin, sendResponse); return true; case 'importSettings': handleImportSettings(request.data, sendResponse); return true; case 'verifyAccessPin': handleVerifyAccessPin(request.pin, sendResponse); return true; case 'lockAccessSession': lockAccessSession(); sendResponse({ success: true }); break; case 'pinVerified': sendResponse({ success: false, error: 'Use verifyAccessPin with PIN digits' }); break; case 'checkPINStatus': ensureAccessSessionFresh(); sendResponse({ verified: isPINVerified }); break; case 'startFocusSession': ensureAccessSessionFresh(); if (!isPINVerified) { sendResponse({ success: false, error: 'Access PIN verification required' }); break; } touchAccessSession(); handleFocusSession(request.duration); sendResponse({ success: true }); break; case 'resetPIN': handleResetPIN(request.oldPin, request.newPin, sendResponse); return true; case 'setNewPIN': handleSetNewPIN(request.newPin, sender, sendResponse); return true; case 'setParentPin': case 'resetParentPin': handleSetParentPin(request.currentPin, request.newPin, sender, sendResponse); return true; case 'clearPIN': handleClearPIN(sendResponse); return true; case 'getAppStatus': (async () => { try { const configured = await isAccessPinConfigured(); ensureAccessSessionFresh(); const parentConfigured = await isParentPinConfigured(); sendResponse({ pinSet: configured, configured, verified: isPINVerified && configured, parentPinConfigured: parentConfigured, version: chrome.runtime.getManifest()?.version || '2.4.1' }); } catch (err) { sendResponse({ pinSet: false, configured: false, verified: false, parentPinConfigured: false, version: '2.4.1', error: err?.message }); } })(); return true; case 'heartbeat': sendResponse({ alive: true }); break; default: sendResponse({ error: 'Unknown action' }); } return false; }); // Helper functions for PIN management /** * Verifies the 8-digit Access PIN and marks the session verified on success. * Does NOT grant Parent blocklist mutation auth (Access session only). * @param {unknown} pin * @param {(r: object) => void} callback * @returns {Promise} */ async function handleVerifyAccessPin(pin, callback) { try { const rate = getAccessPinRateLimitState(); if (rate.limited) { const secs = Math.ceil((rate.retryAfterMs || 0) / 1000); callback({ success: false, error: `Too many failed attempts. Try again in ${secs}s.`, cooldown: true, retryAfterMs: rate.retryAfterMs }); return; } const entered = normalizePinDigits(pin); if (!isValidAccessPin(entered) || isForbiddenAccessPin(entered)) { recordAccessPinFailure(); callback({ success: false, error: 'Invalid Access PIN' }); return; } const configured = await isAccessPinConfigured(); if (!configured) { callback({ success: false, error: 'Access PIN is not configured', configured: false }); return; } if (!(await accessPinMatches(entered))) { isPINVerified = false; recordAccessPinFailure(); callback({ success: false, error: 'Incorrect Access PIN' }); return; } clearAccessPinFailures(); isPINVerified = true; pinIsSet = true; touchAccessSession(); callback({ success: true, verified: true, configured: true }); } catch (error) { console.error('Error verifying Access PIN:', error); callback({ success: false, error: error?.message || 'Verification failed' }); } } /** * Compares entered Access PIN against stored hash (migrates legacy formats on success). * @param {string} entered * @returns {Promise} */ async function accessPinMatches(entered) { const data = await chrome.storage.sync.get([PIN_HASH_KEY, PIN_STORAGE_KEY]); const storedHash = typeof data[PIN_HASH_KEY] === 'string' ? data[PIN_HASH_KEY] : ''; if (isPbkdf2PinHash(storedHash)) { const candidate = await hashAccessPin(entered); return secureStringEqual(storedHash, candidate); } if (isLegacySha256PinHash(storedHash)) { const legacy = await hashAccessPinLegacySha256(entered); if (secureStringEqual(storedHash.toLowerCase(), legacy.toLowerCase())) { const upgraded = await hashAccessPin(entered); await writeAccessPinHashAuthorized(upgraded); return true; } return false; } const plaintext = normalizePinDigits(data[PIN_STORAGE_KEY]); if (isAcceptableNewAccessPin(plaintext) && plaintext === entered) { const upgraded = await hashAccessPin(entered); await writeAccessPinHashAuthorized(upgraded); return true; } return false; } async function handleResetPIN(oldPin, newPin, callback) { try { const configured = await refreshAccessPinConfiguredState(); if (!configured) { callback({ success: false, error: 'Access PIN not configured — use setNewPIN first' }); return; } const oldNorm = normalizePinDigits(oldPin); const newNorm = normalizePinDigits(newPin); if (!(await accessPinMatches(oldNorm))) { callback({ success: false, error: 'Current PIN incorrect' }); return; } if (!isAcceptableNewAccessPin(newNorm)) { callback({ success: false, error: isForbiddenAccessPin(newNorm) ? 'That PIN is not allowed. Choose a different 8-digit PIN.' : 'New PIN must be 8 digits' }); return; } const newHash = await hashAccessPin(newNorm); await writeAccessPinHashAuthorized(newHash); await writeAccessPinConfiguredAuthorized(true); pinIsSet = true; isPINVerified = true; touchAccessSession(); callback({ success: true }); } catch (error) { console.error('Error resetting PIN:', error); callback({ success: false, error: error.message }); } } /** * @param {unknown} newPin * @param {chrome.runtime.MessageSender | undefined} sender * @param {(r: object) => void} callback */ async function handleSetNewPIN(newPin, sender, callback) { try { if (!isExtensionPageSender(sender)) { callback({ success: false, error: 'Access PIN setup only allowed from Elly pages' }); return; } // Content-script style: tab present without extension URL already rejected by allowlist. if (sender?.tab && !isExtensionPageSender(sender)) { callback({ success: false, error: 'Rejected' }); return; } const configured = await refreshAccessPinConfiguredState(); if (configured) { callback({ success: false, error: 'Access PIN already set — use resetPIN with your current PIN' }); return; } const newNorm = normalizePinDigits(newPin); if (!isAcceptableNewAccessPin(newNorm)) { callback({ success: false, error: isForbiddenAccessPin(newNorm) ? 'That PIN is not allowed. Choose a different 8-digit PIN.' : 'PIN must be 8 digits' }); return; } const newHash = await hashAccessPin(newNorm); await writeAccessPinHashAuthorized(newHash); await writeAccessPinConfiguredAuthorized(true); pinIsSet = true; isPINVerified = true; touchAccessSession(); await seedExtensionManagementBlocklistIfNeeded(); callback({ success: true }); } catch (error) { console.error('Error setting PIN:', error); callback({ success: false, error: error.message }); } } /** * Sets or resets Parent override PIN hash. Requires currentPin when a usable Parent PIN exists. * First-set when missing: requires Access unlocked unless Access also unconfigured (welcome flow). * @param {unknown} currentPin * @param {unknown} newPin * @param {chrome.runtime.MessageSender | undefined} sender * @param {(r: object) => void} callback * @returns {Promise} */ async function handleSetParentPin(currentPin, newPin, sender, callback) { try { if (!isExtensionPageSender(sender)) { callback({ success: false, error: 'Parent PIN setup only allowed from Elly pages' }); return; } const newNorm = normalizePinDigits(newPin); if (!isAcceptableNewParentPin(newNorm)) { callback({ success: false, error: isForbiddenParentPin(newNorm) ? 'That Parent PIN is not allowed. Choose a different PIN.' : 'Parent PIN must be 8–12 digits' }); return; } await refreshLastKnownParentPin(); const hasUsable = isPbkdf2PinHash(lastKnownOverridePinHash); const accessConfigured = await refreshAccessPinConfiguredState(); ensureAccessSessionFresh(); if (!hasUsable) { // First-set: Access unlocked, OR fresh install (Access also unconfigured) from welcome/pin/options. if (accessConfigured) { if (!isPINVerified) { callback({ success: false, error: 'Unlock with your Access PIN before setting Parent PIN' }); return; } } else if (!isFirstClaimSetupSender(sender)) { callback({ success: false, error: 'Parent PIN first-set only from welcome, PIN, or options pages' }); return; } } else { const cur = normalizePinDigits(currentPin); if (!cur || !(await parentPinMatches(cur))) { callback({ success: false, error: 'Current Parent PIN incorrect' }); return; } } const newHash = await hashParentPin(newNorm); parentPinWriteAuthorized = true; try { const data = await chrome.storage.sync.get(['settings']); const prev = { ...(data.settings || {}) }; const next = { ...prev, overridePinHash: newHash }; delete next.overridePIN; await chrome.storage.sync.set({ settings: next }); lastKnownOverridePinHash = newHash; } finally { parentPinWriteAuthorized = false; } // Changing Parent PIN grants short mutation auth (not override-only path). grantBlocklistMutationAuth(); touchAccessSession(); if (!hasUsable) { await seedExtensionManagementBlocklistIfNeeded(); } callback({ success: true }); } catch (error) { console.error('Error setting Parent PIN:', error); callback({ success: false, error: error?.message || 'setParentPin failed' }); } } async function handleClearPIN(callback) { callback({ success: false, error: 'Access PIN cannot be cleared. Use resetPIN to change it.' }); } async function handleFocusSession(duration) { const data = await chrome.storage.sync.get(['blockedSites', 'focusSessions']); const originalBlocklist = data.blockedSites || []; const focusBlocklist = [...originalBlocklist, '*.facebook.com', '*.twitter.com', '*.instagram.com', '*.youtube.com', '*.netflix.com', '*.reddit.com' ]; const sessions = data.focusSessions || []; sessions.push({ start: new Date().toISOString(), duration: parseInt(duration), active: true }); await chrome.storage.sync.set({ blockedSites: focusBlocklist, focusSessions: sessions, focusSessionActive: true }); setTimeout(async () => { const currentData = await chrome.storage.sync.get(['focusSessions']); const updatedSessions = currentData.focusSessions || []; const currentSession = updatedSessions.find(s => s.active); if (currentSession) { currentSession.active = false; currentSession.end = new Date().toISOString(); focusRestoreAuthorized = true; try { await chrome.storage.sync.set({ blockedSites: originalBlocklist, focusSessions: updatedSessions, focusSessionActive: false }); } finally { focusRestoreAuthorized = false; } chrome.notifications.create({ type: 'basic', iconUrl: 'icons/icon48.png', title: 'Focus Session Ended', message: `Your ${duration}-minute focus session has ended!`, priority: 2 }); } }, duration * 60000); } /** * Temporary site override only — does NOT grant blocklist mutation auth. * @param {unknown} pin * @param {unknown} tabId * @param {(r: object) => void} callback */ async function handleOverrideBlock(pin, tabId, callback) { try { const pinStr = normalizePinDigits(pin); const data = await chrome.storage.sync.get(['settings']); if (data.settings?.strictMode) { callback({ success: false, error: 'Strict Mode is on — temporary overrides are disabled. Remove the site from the Blocklist with your Parent PIN instead.' }); return; } if (isForbiddenParentPin(pinStr) || !(await parentPinMatches(pinStr))) { callback({ success: false, error: 'Incorrect parent PIN' }); return; } const tid = typeof tabId === 'number' ? tabId : parseInt(String(tabId), 10); if (!Number.isFinite(tid)) { callback({ success: false, error: 'Invalid tab' }); return; } const urlData = await chrome.storage.local.get(['originalUrls']); const originalUrls = urlData.originalUrls || {}; const originalUrl = originalUrls[tid] ?? originalUrls[String(tid)]; if (!originalUrl || typeof originalUrl !== 'string') { callback({ success: false, error: 'Blocked URL not found' }); return; } let hostKey = ''; try { hostKey = extractDomain(originalUrl).toLowerCase(); } catch (e) { callback({ success: false, error: 'Invalid URL' }); return; } if (!hostKey) { callback({ success: false, error: 'Invalid host' }); return; } const until = Date.now() + 15 * 60 * 1000; parentOverrideHosts.set(hostKey, until); parentOverrideTabs.set(String(tid), until); // Intentionally do NOT grantBlocklistMutationAuth — override ≠ mutation auth. const alarmName = `override_host_${encodeURIComponent(hostKey)}`; chrome.alarms.create(alarmName, { delayInMinutes: 15 }); chrome.alarms.create(`override_tab_${tid}`, { delayInMinutes: 15 }); callback({ success: true }); } catch (error) { console.error('Error handling override:', error); callback({ success: false, error: error.message }); } } /** * Grants short-lived Parent mutation auth in SW memory only (max 5 min). * @returns {void} */ function grantBlocklistMutationAuth() { blocklistMutateUntilMs = Date.now() + BLOCKLIST_AUTH_MS; } /** * Clears the short-lived mutation auth window (used by the header Lock button). * @returns {void} */ function revokeBlocklistMutationAuth() { blocklistMutateUntilMs = 0; } /** * @returns {boolean} */ function hasBlocklistMutationAuth() { return Number.isFinite(blocklistMutateUntilMs) && Date.now() < blocklistMutateUntilMs; } /** * @returns {Promise} */ async function isBlocklistPinLockActive() { const data = await chrome.storage.sync.get(['settings']); return !!(data.settings?.blocklistPinLock || data.settings?.strictMode); } /** * Parent PIN compare — hash only; requires isValidParentPin (8–12). * @param {unknown} pin * @returns {Promise} */ async function parentPinMatches(pin) { const entered = normalizePinDigits(pin); if (!isValidParentPin(entered) || isForbiddenParentPin(entered)) { return false; } await refreshLastKnownParentPin(); if (!isPbkdf2PinHash(lastKnownOverridePinHash)) { return false; } const candidate = await hashParentPin(entered); return secureStringEqual(lastKnownOverridePinHash, candidate); } /** * @param {unknown} pin * @param {(r: object) => void} callback */ async function handleAuthorizeBlocklistMutation(pin, callback) { try { ensureAccessSessionFresh(); if (!(await isBlocklistPinLockActive())) { // Lock OFF: still require Access session — never grant with zero auth. if (!isPINVerified) { callback({ success: false, error: 'Access PIN verification required' }); return; } grantBlocklistMutationAuth(); touchAccessSession(); callback({ success: true }); return; } if (hasBlocklistMutationAuth()) { grantBlocklistMutationAuth(); touchAccessSession(); callback({ success: true }); return; } if (!(await parentPinMatches(pin))) { callback({ success: false, error: 'Incorrect parent PIN' }); return; } grantBlocklistMutationAuth(); touchAccessSession(); callback({ success: true }); } catch (error) { callback({ success: false, error: error?.message || 'Auth failed' }); } } /** * @param {unknown} pin * @param {(r: object) => void} callback */ async function handleVerifyParentPinForBlocklist(pin, callback) { try { if (!(await parentPinMatches(pin))) { callback({ success: false, error: 'Incorrect parent PIN' }); return; } grantBlocklistMutationAuth(); touchAccessSession(); callback({ success: true }); } catch (error) { callback({ success: false, error: error?.message || 'Verification failed' }); } } /** * @param {unknown} sites * @param {unknown} pin * @param {(r: object) => void} callback */ async function handleRemoveFromBlocklist(sites, pin, callback) { try { const toRemove = Array.isArray(sites) ? sites.map((s) => String(s)).filter(Boolean) : (sites ? [String(sites)] : []); if (toRemove.length === 0) { callback({ success: false, error: 'No sites to remove' }); return; } ensureAccessSessionFresh(); if (await isBlocklistPinLockActive()) { const authed = hasBlocklistMutationAuth() || (await parentPinMatches(pin)); if (!authed) { callback({ success: false, error: 'Parent PIN required to remove blocked sites' }); return; } grantBlocklistMutationAuth(); } else { if (!isPINVerified) { callback({ success: false, error: 'Access PIN verification required' }); return; } grantBlocklistMutationAuth(); } touchAccessSession(); const data = await chrome.storage.sync.get(['blockedSites']); const blockedSites = (data.blockedSites || []).filter((s) => !toRemove.includes(s)); await chrome.storage.sync.set({ blockedSites }); callback({ success: true, blockedSites }); } catch (error) { callback({ success: false, error: error?.message || 'Remove failed' }); } } /** * Writes security toggles with dedicated write-auth (not mutation auth alone). * @param {Record | undefined} patch * @param {unknown} pin * @param {(r: object) => void} callback */ async function handleUpdateSecuritySettings(patch, pin, callback) { try { if (!patch || typeof patch !== 'object') { callback({ success: false, error: 'Invalid settings' }); return; } const turningOffSensitive = patch.extensionEnabled === false || patch.strictMode === false || patch.blocklistPinLock === false; if (turningOffSensitive) { if (!(await parentPinMatches(pin))) { callback({ success: false, error: 'Incorrect parent PIN' }); return; } } const data = await chrome.storage.sync.get(['settings']); const prev = { ...(data.settings || {}) }; /** @type {Record} */ const next = { ...prev }; if ('strictMode' in patch) { next.strictMode = !!patch.strictMode; } if ('blocklistPinLock' in patch) { next.blocklistPinLock = !!patch.blocklistPinLock; } if ('extensionEnabled' in patch) { next.extensionEnabled = patch.extensionEnabled !== false; } if (next.strictMode) { next.blocklistPinLock = true; } securityToggleWriteAuthorized = true; try { await chrome.storage.sync.set({ settings: next }); } finally { securityToggleWriteAuthorized = false; } touchAccessSession(); callback({ success: true, settings: stripSecretsFromStorageSnapshot({ settings: next }).settings }); } catch (error) { callback({ success: false, error: error?.message || 'updateSecuritySettings failed' }); } } /** * @param {unknown} enabled * @param {unknown} pin * @param {(r: object) => void} callback */ async function handleSetExtensionEnabled(enabled, pin, callback) { const on = enabled !== false; if (!on) { if (!(await parentPinMatches(pin))) { callback({ success: false, error: 'Incorrect parent PIN' }); return; } } await handleUpdateSecuritySettings({ extensionEnabled: on }, pin, callback); } /** * Validates and applies an import snapshot (strips secrets; refuses plaintext Parent PIN). * @param {unknown} raw * @param {(r: object) => void} callback */ async function handleImportSettings(raw, callback) { try { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { callback({ success: false, error: 'Invalid import data' }); return; } /** @type {Record} */ const data = /** @type {Record} */ (raw); if (importContainsPlaintextParentPin(data)) { callback({ success: false, error: 'Import refused: file contains plaintext Parent PIN' }); return; } const safe = stripSecretsFromStorageSnapshot(data); // Never import PIN hashes or JWT either. delete safe.nanny_pin; delete safe.nanny_pin_hash; if (safe.settings && typeof safe.settings === 'object') { const s = /** @type {Record} */ (safe.settings); delete s.overridePIN; delete s.overridePinHash; delete s.parent_pin_hash; } grantBlocklistMutationAuth(); schedulesWriteAuthorized = true; limitedSitesWriteAuthorized = true; try { await chrome.storage.sync.set(safe); } finally { schedulesWriteAuthorized = false; limitedSitesWriteAuthorized = false; } touchAccessSession(); callback({ success: true }); } catch (error) { callback({ success: false, error: error?.message || 'Import failed' }); } } /** * True when schedules changed without write/mutation auth (any policy edit, not only shrinks). * @param {unknown} oldArr * @param {unknown} newArr * @returns {boolean} */ function isSchedulesUnauthorizedChange(oldArr, newArr) { try { return JSON.stringify(oldArr ?? []) !== JSON.stringify(newArr ?? []); } catch { return true; } } /** * True when limitedSites changed without write/mutation auth (any policy edit, not only shrinks). * @param {unknown} oldObj * @param {unknown} newObj * @returns {boolean} */ function isLimitedSitesUnauthorizedChange(oldObj, newObj) { try { return JSON.stringify(oldObj ?? {}) !== JSON.stringify(newObj ?? {}); } catch { return true; } } /** * Defense-in-depth: revert unauthorized parental control weakenings. */ chrome.storage.onChanged.addListener((changes, areaName) => { (async () => { if (areaName === 'local') { const accessWriteOkLocal = accessPinWriteAuthorized; if (changes[BLOCKLIST_AUTH_KEY]) { await chrome.storage.local.remove([BLOCKLIST_AUTH_KEY]); } if (changes[ACCESS_PIN_CONFIGURED_KEY] && !accessWriteOkLocal) { const newVal = !!changes[ACCESS_PIN_CONFIGURED_KEY].newValue; if (newVal !== lastKnownAccessPinConfigured) { accessPinWriteAuthorized = true; try { await chrome.storage.local.set({ [ACCESS_PIN_CONFIGURED_KEY]: lastKnownAccessPinConfigured }); console.warn('Elly: reverted unauthorized Access PIN configured flag change'); } finally { accessPinWriteAuthorized = false; } } } return; } if (areaName !== 'sync') { return; } // Snapshot SW-only auth flags before any await (onChanged is async). const mutateAuthed = hasBlocklistMutationAuth(); const focusRestoreOk = focusRestoreAuthorized; const accessWriteOk = accessPinWriteAuthorized; const parentWriteOk = parentPinWriteAuthorized; const securityToggleOk = securityToggleWriteAuthorized; const schedulesWriteOk = schedulesWriteAuthorized; const limitedWriteOk = limitedSitesWriteAuthorized; if ((changes[PIN_HASH_KEY] || changes[PIN_STORAGE_KEY]) && !accessWriteOk) { accessPinWriteAuthorized = true; try { /** @type {Record} */ const restore = {}; if (lastKnownAccessPinHash) { restore[PIN_HASH_KEY] = lastKnownAccessPinHash; } if (Object.keys(restore).length > 0) { await chrome.storage.sync.set(restore); } await chrome.storage.sync.remove([PIN_STORAGE_KEY]); if (!lastKnownAccessPinHash) { await chrome.storage.sync.remove([PIN_HASH_KEY]); } console.warn('Elly: reverted unauthorized Access PIN storage change'); } finally { accessPinWriteAuthorized = false; } } if (changes.settings) { const oldSettings = changes.settings.oldValue || {}; const newSettings = changes.settings.newValue || {}; const oldHash = typeof oldSettings.overridePinHash === 'string' ? oldSettings.overridePinHash : ''; const newHash = typeof newSettings.overridePinHash === 'string' ? newSettings.overridePinHash : ''; const oldPlain = normalizePinDigits(oldSettings.overridePIN); const newPlain = normalizePinDigits(newSettings.overridePIN); const pinMaterialChanged = oldHash !== newHash || oldPlain !== newPlain || (!!newPlain); // Mutation auth alone must NOT allow Parent PIN changes. if (pinMaterialChanged && !parentWriteOk) { parentPinWriteAuthorized = true; try { const restored = { ...newSettings }; delete restored.overridePIN; if (lastKnownOverridePinHash) { restored.overridePinHash = lastKnownOverridePinHash; } else { delete restored.overridePinHash; } await chrome.storage.sync.set({ settings: restored }); console.warn('Elly: reverted unauthorized Parent PIN change'); } finally { parentPinWriteAuthorized = false; } } else if (pinMaterialChanged && parentWriteOk) { lastKnownOverridePinHash = isPbkdf2PinHash(newHash) ? newHash : lastKnownOverridePinHash; if (newPlain) { // Drop any plaintext that slipped in during authorized write. parentPinWriteAuthorized = true; try { const cleaned = { ...newSettings }; delete cleaned.overridePIN; await chrome.storage.sync.set({ settings: cleaned }); } finally { parentPinWriteAuthorized = false; } } } const extensionTurnedOff = oldSettings.extensionEnabled !== false && newSettings.extensionEnabled === false; if (extensionTurnedOff && !securityToggleOk) { await chrome.storage.sync.set({ settings: { ...newSettings, extensionEnabled: true } }); console.warn('Elly: reverted unauthorized extension disable'); } } const lockActive = await isBlocklistPinLockActive(); if (lockActive && changes.blockedSites && !blocklistRevertInFlight) { const oldSites = Array.isArray(changes.blockedSites.oldValue) ? changes.blockedSites.oldValue : []; const newSites = Array.isArray(changes.blockedSites.newValue) ? changes.blockedSites.newValue : []; const removed = oldSites.filter((s) => !newSites.includes(s)); if (removed.length > 0 && !mutateAuthed && !focusRestoreOk) { blocklistRevertInFlight = true; try { await chrome.storage.sync.set({ blockedSites: oldSites }); console.warn('Elly: reverted unauthorized blocklist removal'); } finally { blocklistRevertInFlight = false; } } } if (lockActive && changes.whitelist) { const oldList = Array.isArray(changes.whitelist.oldValue) ? changes.whitelist.oldValue : []; const newList = Array.isArray(changes.whitelist.newValue) ? changes.whitelist.newValue : []; const added = newList.filter((s) => !oldList.includes(s)); if (added.length > 0 && !mutateAuthed) { await chrome.storage.sync.set({ whitelist: oldList }); console.warn('Elly: reverted unauthorized whitelist add'); } } if (lockActive && changes.schedules) { if ( isSchedulesUnauthorizedChange(changes.schedules.oldValue, changes.schedules.newValue) && !mutateAuthed && !schedulesWriteOk ) { const oldList = Array.isArray(changes.schedules.oldValue) ? changes.schedules.oldValue : []; schedulesWriteAuthorized = true; try { await chrome.storage.sync.set({ schedules: oldList }); console.warn('Elly: reverted unauthorized schedules change'); } finally { schedulesWriteAuthorized = false; } } } if (lockActive && changes.limitedSites) { if ( isLimitedSitesUnauthorizedChange(changes.limitedSites.oldValue, changes.limitedSites.newValue) && !mutateAuthed && !limitedWriteOk ) { const oldObj = changes.limitedSites.oldValue && typeof changes.limitedSites.oldValue === 'object' ? changes.limitedSites.oldValue : {}; limitedSitesWriteAuthorized = true; try { await chrome.storage.sync.set({ limitedSites: oldObj }); console.warn('Elly: reverted unauthorized limitedSites change'); } finally { limitedSitesWriteAuthorized = false; } } } if (changes.settings) { const oldSettings = changes.settings.oldValue || {}; const newSettings = changes.settings.newValue || {}; const strictTurnedOff = !!oldSettings.strictMode && !newSettings.strictMode; const pinLockTurnedOff = (!!oldSettings.blocklistPinLock || !!oldSettings.strictMode) && !newSettings.blocklistPinLock && !newSettings.strictMode; // Mutation auth alone must NOT allow security toggles OFF. if ((strictTurnedOff || pinLockTurnedOff) && !securityToggleOk) { await chrome.storage.sync.set({ settings: { ...newSettings, strictMode: oldSettings.strictMode, blocklistPinLock: oldSettings.blocklistPinLock || oldSettings.strictMode } }); console.warn('Elly: reverted unauthorized security toggle change'); } } })().catch((err) => { console.error('Elly storage guard error:', err); }); }); function addToBlocklist(url) { chrome.storage.sync.get(['blockedSites']).then((data) => { const blockedSites = data.blockedSites || []; if (!blockedSites.includes(url)) { blockedSites.push(url); chrome.storage.sync.set({ blockedSites }); } }); } // Function to check if URL is blocked by schedule function isUrlBlockedBySchedule(url, schedule) { if (!url) return false; if (schedule.type === 'block') { // Block all except allowed sites return !isUrlInList(url, schedule.allowedSites || []); } else { // Allow all except blocked sites return isUrlInList(url, schedule.blockedSites || []); } } // Cold-start: wipe plantable override/auth keys and refresh Access PIN flag (async () => { try { await clearPlantableLocalAuthKeys(); blocklistMutateUntilMs = 0; await refreshAccessPinConfiguredState(); await refreshLastKnownParentPin(); await loadPersistedSiteUsage(); } catch (e) { console.error('Elly SW init:', e); } })();