v3 · 37 files

ProjectElly/lib/ellyAccessPin.js 9.6 KB Raw
/**
 * 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;
}