feat(security): HIBP password breach check + CSP tightening
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 <script> tags inline, only the external js/app.js. Removed 'unsafe-inline' from script-src — real XSS defense. Kept 'unsafe-inline' on style-src for now because index.html contains inline style="" attributes and app.js calls element.style.cssText extensively. Refactoring to CSS classes is a separate cleanup. Style injection alone cannot execute code, so the residual risk is bounded to visual manipulation in a single-user loopback app. Added api.pwnedpasswords.com to connect-src as the only allowed external origin (required by the HIBP feature above). Default still 'self' — everything else stays loopback. Before: script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; After: script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://api.pwnedpasswords.com;
This commit is contained in:
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user