/** * TypeLess - Auto Form Filler * crypto-utils.js — Profile data encryption using Web Crypto API (AES-256-GCM) * * DESIGN NOTES * ──────────── * • Algorithm: AES-256-GCM — authenticated encryption, detects tampering. * • Key derivation: PBKDF2-SHA-256 (310 000 iterations, per OWASP 2024). * • Key material: derived from a per-device secret stored in chrome.storage.local * (never leaves the device; never synced). * • Storage format (base64-encoded JSON): * { v: 1, iv: "", ct: "", salt: "" } * * THREAT MODEL * ──────────── * Protected against: * ✓ Another extension reading chrome.storage.local (data is opaque ciphertext) * ✓ Storage export/backup leaking plaintext passwords * ✓ Malicious web page reading injected storage values * * NOT protected against: * ✗ Malicious extension with "storage" permission (reads raw ciphertext + key) * ✗ Compromised OS / physical access (key lives in browser profile directory) * * This is appropriate for a browser extension — the key protects the data at * the storage layer without requiring the user to remember a master password. */ 'use strict'; const CryptoUtils = (() => { const ENC_VERSION = 1; const KEY_STORAGE_KEY = 'tl_device_key_v1'; // raw 256-bit key material (base64) const PBKDF2_ITERATIONS = 310_000; const SALT_LEN = 16; // bytes const IV_LEN = 12; // bytes (96-bit GCM IV) // ── Helpers ────────────────────────────────────────────────────────────── const _b64encode = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))); const _b64decode = (str) => { const bin = atob(str); const buf = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); return buf; }; // ── Key management ─────────────────────────────────────────────────────── /** * Retrieve (or generate and store) the per-device key material. * Returns a base64-encoded 256-bit random value. */ async function _getOrCreateKeyMaterial() { const result = await chrome.storage.local.get(KEY_STORAGE_KEY); if (result[KEY_STORAGE_KEY]) return result[KEY_STORAGE_KEY]; // First run: generate a new random key material const raw = crypto.getRandomValues(new Uint8Array(32)); const b64 = _b64encode(raw); await chrome.storage.local.set({ [KEY_STORAGE_KEY]: b64 }); return b64; } /** * Derive a CryptoKey from the stored key material + a per-record salt. * Using PBKDF2 even though the input is already random adds forward-secrecy * (each record has its own salt → different derived key). */ async function _deriveKey(keyMaterialB64, salt) { const rawKeyMaterial = _b64decode(keyMaterialB64); const importedKey = await crypto.subtle.importKey( 'raw', rawKeyMaterial, { name: 'PBKDF2' }, false, ['deriveKey'] ); return crypto.subtle.deriveKey( { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, importedKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] ); } // ── Public API ─────────────────────────────────────────────────────────── return { /** * Encrypt a plain JavaScript value (serialised to JSON). * * @param {*} data Any JSON-serialisable value * @returns {Promise} Opaque base64 ciphertext blob */ async encrypt(data) { const keyMaterial = await _getOrCreateKeyMaterial(); const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN)); const iv = crypto.getRandomValues(new Uint8Array(IV_LEN)); const key = await _deriveKey(keyMaterial, salt); const plaintext = new TextEncoder().encode(JSON.stringify(data)); const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, plaintext ); const envelope = JSON.stringify({ v: ENC_VERSION, salt: _b64encode(salt), iv: _b64encode(iv), ct: _b64encode(ciphertext) }); return btoa(envelope); }, /** * Decrypt a blob produced by `encrypt()`. * * @param {string} blob Base64 ciphertext blob * @returns {Promise<*>} Original value */ async decrypt(blob) { const envelope = JSON.parse(atob(blob)); if (envelope.v !== ENC_VERSION) { throw new Error(`[CryptoUtils] Unknown envelope version: ${envelope.v}`); } const keyMaterial = await _getOrCreateKeyMaterial(); const salt = _b64decode(envelope.salt); const iv = _b64decode(envelope.iv); const ct = _b64decode(envelope.ct); const key = await _deriveKey(keyMaterial, salt); const plaintext = await crypto.subtle.decrypt( { name: 'AES-GCM', iv }, key, ct ); return JSON.parse(new TextDecoder().decode(plaintext)); }, /** * Returns true if the given string looks like an encrypted blob. * Used by StorageManager to decide whether to decrypt on read. */ isEncrypted(value) { if (typeof value !== 'string') return false; try { const parsed = JSON.parse(atob(value)); return parsed && parsed.v === ENC_VERSION && parsed.ct; } catch (_) { return false; } }, /** * Rotate the device key: re-encrypt all profiles with a new key. * Call this if the user explicitly requests a security reset. * Returns the number of profiles re-encrypted. */ async rotateKey() { // Fetch and decrypt with old key const result = await chrome.storage.local.get('profiles'); const profiles = result.profiles || []; const decrypted = []; for (const p of profiles) { try { decrypted.push(this.isEncrypted(p) ? await this.decrypt(p) : p); } catch (e) { console.error('[CryptoUtils] rotateKey: failed to decrypt profile', e); decrypted.push(p); // keep as-is on failure } } // Generate new key material const newRaw = crypto.getRandomValues(new Uint8Array(32)); await chrome.storage.local.set({ [KEY_STORAGE_KEY]: _b64encode(newRaw) }); // Re-encrypt with new key const reEncrypted = []; for (const p of decrypted) { reEncrypted.push(await this.encrypt(p)); } await chrome.storage.local.set({ profiles: reEncrypted }); return reEncrypted.length; } }; })(); if (typeof window !== 'undefined') window.CryptoUtils = CryptoUtils; if (typeof globalThis !== 'undefined') globalThis.CryptoUtils = CryptoUtils;