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<string, number>} hostKey -> expiry ms
*/
const parentOverrideHosts = new Map();
/**
* @type {Map<string, number>} 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<void>}
*/
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<void>}
*/
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<void>}
*/
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<boolean>} 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<void>}
*/
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<boolean>}
*/
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<boolean>}
*/
async function isAccessPinConfigured() {
return refreshAccessPinConfiguredState();
}
/**
* Opens the Access PIN window (setup or unlock).
* @param {'setup' | 'unlock'} mode
* @returns {Promise<void>}
*/
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<void>}
*/
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<string, Record<string, number>>} */
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<void>}
*/
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<string, Record<string, number>>} */
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<void>}
*/
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<string, Record<string, number>>} */
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<void>}
*/
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<void>}
*/
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<string, { limitMinutes: number }> | 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<string, number>} 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<boolean>}
*/
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<string, unknown>} */
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<void>}
*/
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<boolean>}
*/
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<void>}
*/
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<boolean>}
*/
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<boolean>}
*/
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<string, unknown> | 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<string, unknown>} */
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<string, unknown>} */
const data = /** @type {Record<string, unknown>} */ (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<string, unknown>} */ (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<string, unknown>} */
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);
}
})();