From a45897c33d934a136b95ff0a813858de8b8d4c2c Mon Sep 17 00:00:00 2001 From: Zaki <18zaki18@gmail.com> Date: Sat, 23 May 2026 05:05:50 +0100 Subject: [PATCH] feat(security): HIBP password breach check + CSP tightening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HIBP integration ================ Opt-in (default OFF) password breach check via the Have I Been Pwned range API. The full master / entry password never leaves the machine — only the first 5 characters of its SHA-1 hash. HIBP returns ~500 candidate suffixes; the client matches its own suffix locally. UI: - New "Check passwords against breach database (HIBP)" toggle in Settings → Security with an explainer hint about k-anonymity. - On enable: background batch scan of all entries, results cached in state.hibpResults keyed by entry id. Concurrency capped at 6 to avoid hammering HIBP / hitting browser connection limits. - Entry cards show a red "Pwned" chip + breach count in the tooltip when count > 0. New i-alert icon added to the SVG sprite. - Auto-scan triggered after every enterApp() when the toggle is on. Functions added to app.js: - sha1Hex(text) — crypto.subtle wrapper - hibpCheckPassword(plaintext) — single-password check, returns count - hibpCheckAllEntries() — batched scan over state.entries The "Add-Padding: true" header is sent on every range request to defeat the response-size side-channel (HIBP adds 800-1000 random extra entries so an observer counting bytes can't narrow the prefix queried). CSP tightening ============== Audited the served HTML: zero + + + + + + + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/js/app.js b/js/app.js index 94b576d..cc615d9 100644 --- a/js/app.js +++ b/js/app.js @@ -82,6 +82,9 @@ const state = { compactActions: localStorage.getItem('compactActions') === '1', // default false viewMode: localStorage.getItem('viewMode') || 'cards', // 'cards' | 'list' checked: new Set(), // entry IDs checked for batch operations + hibpEnabled: localStorage.getItem('hibpEnabled') === '1', // default OFF + // entry.id → count from HIBP (0 = clean, >0 = pwned, undefined = unchecked) + hibpResults: new Map(), }; // ============================================================ @@ -174,6 +177,90 @@ async function api(path, opts) { return body; } +// ============================================================ +// HIBP — Have I Been Pwned breach check (k-anonymity) +// ============================================================ +// +// HIBP's range API exposes pwned password counts without ever seeing the +// password (or even its full hash): +// 1. Client computes SHA-1 of the password. +// 2. Client sends ONLY the first 5 hex chars to api.pwnedpasswords.com/range/XXXXX +// 3. Server returns up to ~500 suffixes (35 chars each) with counts. +// 4. Client searches the response for its own suffix locally. +// +// This means the network observer (and HIBP itself) sees only the 5-char +// prefix — which matches ~3,000 of the ~half-billion known pwned passwords. +// Information leakage is bounded by design. +// +// Toggle is OFF by default. When enabled, all entries are checked once +// after vault load, then individual entries are re-checked when the user +// edits the password. Results cached in state.hibpResults keyed by entry id. + +async function sha1Hex(text) { + const buf = new TextEncoder().encode(text); + const hashBuf = await crypto.subtle.digest('SHA-1', buf); + const bytes = new Uint8Array(hashBuf); + let hex = ''; + for (const b of bytes) hex += b.toString(16).padStart(2, '0'); + return hex.toUpperCase(); +} + +// Returns the breach count (0 if not found, >0 if pwned). Throws on +// network failure — caller decides whether to silently skip or alert. +async function hibpCheckPassword(plaintext) { + if (!plaintext) return 0; + const hash = await sha1Hex(plaintext); + const prefix = hash.substring(0, 5); + const suffix = hash.substring(5); + + const resp = await fetch('https://api.pwnedpasswords.com/range/' + prefix, { + // Padding mitigates side-channel attacks where an observer counts + // bytes in the response to narrow down the prefix queried. + headers: { 'Add-Padding': 'true' }, + }); + if (!resp.ok) throw new Error('HIBP HTTP ' + resp.status); + const body = await resp.text(); + // Body lines: "SUFFIX:COUNT\r\n" — search for our suffix. + for (const line of body.split('\n')) { + const colonAt = line.indexOf(':'); + if (colonAt <= 0) continue; + if (line.substring(0, colonAt).trim() === suffix) { + return parseInt(line.substring(colonAt + 1).trim(), 10) || 0; + } + } + return 0; +} + +// Batch-check every entry currently in state.entries. Awaits all in +// parallel but with a small concurrency cap so we don't hammer HIBP +// or trip browser connection limits. Mutates state.hibpResults and +// re-renders to show the new badges. +async function hibpCheckAllEntries() { + if (!state.hibpEnabled || !state.entries.length) return; + const CONCURRENCY = 6; + const queue = state.entries.slice(); + const workers = []; + + for (let w = 0; w < CONCURRENCY; w++) { + workers.push((async () => { + while (queue.length) { + const entry = queue.shift(); + try { + const pwd = await decryptPwd(entry.encrypted_password, entry.iv); + if (pwd === '[ERROR]') continue; + const count = await hibpCheckPassword(pwd); + state.hibpResults.set(entry.id, count); + } catch (e) { + // Network or decrypt failure: skip silently. Will retry + // next time the user opens the vault. + } + } + })()); + } + await Promise.all(workers); + render(); +} + // ============================================================ // KDF MIGRATION (PBKDF2 100k → 600k re-encryption) // ============================================================ @@ -942,6 +1029,19 @@ function renderCard(e) { chip.appendChild(el('span', null, t)); meta.appendChild(chip); }); + // HIBP pwned badge — only shown when the user enabled HIBP and the + // background scan completed with count > 0 for this entry. + const pwnedCount = state.hibpResults.get(e.id); + if (state.hibpEnabled && pwnedCount && pwnedCount > 0) { + const chip = el('span', { + class: 'entry-chip is-pwned', + title: 'This password appeared in ' + pwnedCount.toLocaleString() + + ' known data breaches. Consider changing it.', + }); + chip.appendChild(icon('i-alert')); + chip.appendChild(el('span', null, 'Pwned')); + meta.appendChild(chip); + } card.appendChild(meta); return card; @@ -2086,6 +2186,7 @@ function openSettings() { $('#settingAskDelete').checked = state.askBeforeDelete; $('#settingCompact').checked = state.compactActions; $('#settingMaskUser').checked = state.maskUsernames; + $('#settingHIBP').checked = state.hibpEnabled; $('#settingUser').textContent = state.username; $('#settingsPanel').classList.add('is-open'); } @@ -2159,6 +2260,9 @@ async function enterApp() { await loadEntries(); render(); resetAutoLock(); + // Fire-and-forget HIBP scan if the user opted in. Runs in background, + // re-renders when done to show badges. + if (state.hibpEnabled) hibpCheckAllEntries(); } // ============================================================ @@ -2351,6 +2455,18 @@ async function init() { localStorage.setItem('maskUsernames', state.maskUsernames ? '1' : '0'); render(); }); + $('#settingHIBP').addEventListener('change', e => { + state.hibpEnabled = e.target.checked; + localStorage.setItem('hibpEnabled', state.hibpEnabled ? '1' : '0'); + if (state.hibpEnabled) { + toast('Checking passwords against breach database…'); + hibpCheckAllEntries(); + } else { + state.hibpResults.clear(); + render(); + toast('Breach check disabled'); + } + }); $('#openClipboardSettings').addEventListener('click', () => { toast('Open Windows Settings → System → Clipboard → turn off "Clipboard history"', 'warning'); });