/** * Shared Access PIN / Parent PIN helpers for Elly parental controls. * Used by background (module) and options (module). * * At-rest format (preferred): `pbkdf2$310000$` using per-install salt * `ellyPinSaltV1` in chrome.storage.local (32 random bytes as hex). * Legacy Access: plain SHA-256 hex of pepper + digits (migrated on successful verify). * Legacy Parent: plaintext `settings.overridePIN` (migrated to `settings.overridePinHash`). */ /** Legacy factory Access PIN — never accept for unlock or as a new PIN. */ export const LEGACY_DEFAULT_ACCESS_PIN = '12345678'; /** Legacy factory Parent / override PIN — never accept as a usable override. */ export const LEGACY_DEFAULT_PARENT_PIN = '1234'; /** chrome.storage.local flag: user has completed Access PIN setup. */ export const ACCESS_PIN_CONFIGURED_KEY = 'ellyAccessPinConfigured'; /** Sync storage key for legacy plaintext 8-digit Access PIN (migrated away). */ export const ACCESS_PIN_STORAGE_KEY = 'nanny_pin'; /** Sync storage key for hashed Access PIN (preferred). */ export const ACCESS_PIN_HASH_STORAGE_KEY = 'nanny_pin_hash'; /** chrome.storage.local per-install salt (32 bytes hex). */ export const PIN_SALT_LOCAL_KEY = 'ellyPinSaltV1'; /** PBKDF2 iteration count for Access + Parent PIN hashes. */ export const PBKDF2_ITERATIONS = 310000; /** * Fixed app pepper for legacy Access PIN hashing (SHA-256). * Changing this breaks existing legacy hashes — keep for one-time migration verify. */ export const ACCESS_PIN_PEPPER = 'Elly.AccessPin.v1'; /** Optional material prefix for Parent PIN PBKDF2 password (domain separation). */ export const PARENT_PIN_PEPPER = 'Elly.ParentPin.v1'; /** * @param {unknown} pin * @returns {string} */ export function normalizePinDigits(pin) { return String(pin ?? '').trim(); } /** * True when pin is exactly 8 numeric digits. * @param {unknown} pin * @returns {boolean} */ export function isValidAccessPin(pin) { const p = normalizePinDigits(pin); return /^\d{8}$/.test(p); } /** * Forbidden Access PINs (legacy factory default and any future bans). * @param {unknown} pin * @returns {boolean} */ export function isForbiddenAccessPin(pin) { const p = normalizePinDigits(pin); return p === LEGACY_DEFAULT_ACCESS_PIN; } /** * True when the pin may be stored / accepted as a new Access PIN. * @param {unknown} pin * @returns {boolean} */ export function isAcceptableNewAccessPin(pin) { return isValidAccessPin(pin) && !isForbiddenAccessPin(pin); } /** * Parent / override PIN: 8–12 digits, not the legacy default. * @param {unknown} pin * @returns {boolean} */ export function isValidParentPin(pin) { const p = normalizePinDigits(pin); return /^\d{8,12}$/.test(p); } /** * Forbidden Parent PINs (legacy factory default). * @param {unknown} pin * @returns {boolean} */ export function isForbiddenParentPin(pin) { const p = normalizePinDigits(pin); return p === LEGACY_DEFAULT_PARENT_PIN; } /** * True when the pin may be stored as a new Parent PIN. * @param {unknown} pin * @returns {boolean} */ export function isAcceptableNewParentPin(pin) { return isValidParentPin(pin) && !isForbiddenParentPin(pin); } /** * @param {Uint8Array} bytes * @returns {string} */ function bytesToHex(bytes) { return Array.from(bytes) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } /** * @param {string} hex * @returns {Uint8Array} */ function hexToBytes(hex) { const clean = String(hex || '').toLowerCase(); if (!/^[0-9a-f]+$/.test(clean) || clean.length % 2 !== 0) { throw new Error('Invalid hex salt'); } const out = new Uint8Array(clean.length / 2); for (let i = 0; i < out.length; i++) { out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); } return out; } /** * True when hash is the versioned PBKDF2 format. * @param {unknown} hash * @returns {boolean} */ export function isPbkdf2PinHash(hash) { return typeof hash === 'string' && /^pbkdf2\$\d+\$[0-9a-f]{32,}$/i.test(hash); } /** * True when hash looks like a legacy SHA-256 hex digest (64 hex chars). * @param {unknown} hash * @returns {boolean} */ export function isLegacySha256PinHash(hash) { return typeof hash === 'string' && /^[0-9a-f]{64}$/i.test(hash); } /** * Ensures a per-install 32-byte salt exists in chrome.storage.local. * @returns {Promise} hex salt */ export async function getOrCreatePinSalt() { const local = await chrome.storage.local.get([PIN_SALT_LOCAL_KEY]); const existing = local[PIN_SALT_LOCAL_KEY]; if (typeof existing === 'string' && /^[0-9a-f]{64}$/i.test(existing)) { return existing.toLowerCase(); } const bytes = crypto.getRandomValues(new Uint8Array(32)); const hex = bytesToHex(bytes); await chrome.storage.local.set({ [PIN_SALT_LOCAL_KEY]: hex }); return hex; } /** * PBKDF2-SHA256 hex digest wrapped as `pbkdf2$iterations$hex`. * @param {string} passwordMaterial * @param {string} saltHex * @param {number} [iterations] * @returns {Promise} */ export async function pbkdf2PinHash(passwordMaterial, saltHex, iterations = PBKDF2_ITERATIONS) { const enc = new TextEncoder(); const keyMaterial = await crypto.subtle.importKey( 'raw', enc.encode(passwordMaterial), 'PBKDF2', false, ['deriveBits'] ); const bits = await crypto.subtle.deriveBits( { name: 'PBKDF2', hash: 'SHA-256', salt: hexToBytes(saltHex), iterations }, keyMaterial, 256 ); return `pbkdf2$${iterations}$${bytesToHex(new Uint8Array(bits))}`; } /** * Legacy SHA-256 hex digest of pepper + normalized PIN digits. * @param {unknown} pin * @returns {Promise} */ export async function hashAccessPinLegacySha256(pin) { const normalized = normalizePinDigits(pin); const payload = `${ACCESS_PIN_PEPPER}${normalized}`; const bytes = new TextEncoder().encode(payload); const digest = await crypto.subtle.digest('SHA-256', bytes); return bytesToHex(new Uint8Array(digest)); } /** * Hash Access PIN for storage (PBKDF2 with per-install salt). * @param {unknown} pin * @returns {Promise} */ export async function hashAccessPin(pin) { const normalized = normalizePinDigits(pin); const salt = await getOrCreatePinSalt(); return pbkdf2PinHash(`${ACCESS_PIN_PEPPER}${normalized}`, salt); } /** * Hash Parent PIN for storage (PBKDF2 with shared per-install salt). * @param {unknown} pin * @returns {Promise} */ export async function hashParentPin(pin) { const normalized = normalizePinDigits(pin); const salt = await getOrCreatePinSalt(); return pbkdf2PinHash(`${PARENT_PIN_PEPPER}${normalized}`, salt); } /** * Constant-time-ish string compare for equal-length digests. * @param {string} a * @param {string} b * @returns {boolean} */ export function secureStringEqual(a, b) { const x = String(a || ''); const y = String(b || ''); if (x.length !== y.length) { return false; } let diff = 0; for (let i = 0; i < x.length; i++) { diff |= x.charCodeAt(i) ^ y.charCodeAt(i); } return diff === 0; } /** * Whether Access PIN should be treated as configured (usable). * Prefer hash presence; plaintext path is migration-only. * @param {unknown} storedPin * @param {boolean} configuredFlag * @param {unknown} [storedHash] * @returns {boolean} */ export function isAccessPinEffectivelyConfigured(storedPin, configuredFlag, storedHash) { if (!configuredFlag) { return false; } if (typeof storedHash === 'string' && (isPbkdf2PinHash(storedHash) || isLegacySha256PinHash(storedHash))) { return true; } const p = normalizePinDigits(storedPin); if (!p || isForbiddenAccessPin(p) || !isValidAccessPin(p)) { return false; } return true; } /** * Strip secret PIN fields from a settings object (shallow copy). * Never expose plaintext or hashes to UI via getSettings. * @param {Record | null | undefined} settings * @returns {Record} */ export function stripPinFieldsFromSettings(settings) { const out = { ...(settings || {}) }; delete out.overridePIN; delete out.overridePinHash; delete out.parent_pin_hash; delete out.nanny_pin; delete out.nanny_pin_hash; return out; } /** * Strip top-level and nested PIN secrets / JWT from a sync storage snapshot. * Export must never include plaintext or hash PIN material. * @param {Record | null | undefined} data * @returns {Record} */ export function stripSecretsFromStorageSnapshot(data) { const out = { ...(data || {}) }; delete out.nanny_pin; delete out.nanny_pin_hash; delete out.parent_pin_hash; delete out[ACCESS_PIN_STORAGE_KEY]; delete out[ACCESS_PIN_HASH_STORAGE_KEY]; delete out.jwt; delete out.ellyVrilsoftJwt; if (out.settings && typeof out.settings === 'object') { out.settings = stripPinFieldsFromSettings(/** @type {Record} */ (out.settings)); } return out; } /** * True when import payload contains forbidden plaintext Parent PIN. * @param {Record | null | undefined} data * @returns {boolean} */ export function importContainsPlaintextParentPin(data) { if (!data || typeof data !== 'object') { return false; } const settings = data.settings; if (settings && typeof settings === 'object') { const pin = /** @type {Record} */ (settings).overridePIN; if (pin != null && String(pin).length > 0) { return true; } } return false; }