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:
2026-05-23 05:05:50 +01:00
parent e0e452306e
commit a45897c33d
4 changed files with 1817 additions and 512 deletions
+116
View File
@@ -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');
});