/**
* Shared Access PIN / Parent PIN helpers for Elly parental controls.
* Used by background (module) and options (module).
*
* At-rest format (preferred): `pbkdf2$310000$<hex>` 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<string>} 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<string>}
*/
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<string>}
*/
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<string>}
*/
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<string>}
*/
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<string, unknown> | null | undefined} settings
* @returns {Record<string, unknown>}
*/
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<string, unknown> | null | undefined} data
* @returns {Record<string, unknown>}
*/
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<string, unknown>} */ (out.settings));
}
return out;
}
/**
* True when import payload contains forbidden plaintext Parent PIN.
* @param {Record<string, unknown> | 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<string, unknown>} */ (settings).overridePIN;
if (pin != null && String(pin).length > 0) {
return true;
}
}
return false;
}