diff --git a/CLAUDE.md b/CLAUDE.md index a96ebdd..c852d56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,7 @@ js/app.totp.js (TOTP RFC 6238 + TOTP/custom-field crypto — extrait §3.1) js/app.favicon.js (favicon fetch/cache + faviconHost — extrait §3.1) js/app.import.js (export container + import CSV/JSON — extrait §3.1) js/app.backup.js (auto-backup planifié — extrait §3.1) +js/app.health.js (vault health dashboard — extrait §3.1) js/app.js (le reste : state, Bridge, api, UI…) js/app.sync.js (WebDAV + merge — extrait §3.1, APRÈS app.js car effet de bord top-level `Bridge.onWebdavResult = …`) @@ -107,6 +108,7 @@ seule `api()` est stubbée). | Favicons (fetch/cache, `faviconHost`) — extrait §3.1 | `js/app.favicon.js` | | Import/export frontend (CSV/JSON parse, export container) — extrait §3.1 | `js/app.import.js` | | Auto-backup frontend (planifié, chiffré) — extrait §3.1 | `js/app.backup.js` | +| Vault health dashboard — extrait §3.1 | `js/app.health.js` | | Sync frontend (WebDAV, snapshot, merge) — extrait §3.1 | `js/app.sync.js` | | Argon2id vendé (bundle `@noble/hashes`, IIFE) | `js/argon2.js` | | HTML racine | `index.html` | diff --git a/CODE_AUDIT.md b/CODE_AUDIT.md index fa30fac..f7d81ca 100644 --- a/CODE_AUDIT.md +++ b/CODE_AUDIT.md @@ -242,10 +242,14 @@ réécriture des call-sites, risque quasi nul vs conversion en modules ES). **+7 tests `faviconHost`** (extraction hostname → décide quel domaine part vers DDG). A révélé un commentaire faux (IPs « valides » alors qu'elles sont rejetées par la règle TLD `[a-z]{2,}`) — corrigé. -- `app.js` : 11 936 → **9 790 lignes** (6 modules sortis). +- ✅ `js/app.health.js` extrait (vault health dashboard) — byte-for-byte + identique, chargé AVANT app.js (`auditCache`/`auditFilter` viennent avec, + résolus cross-fichier). +- `app.js` : 11 936 → **9 545 lignes** (7 modules sortis). - Suite de tests : 42 → **62 tests**. -- Reste à extraire (grosses sections cohésives) : slideover, settings, - quicksearch, autofill, vault-health… +- Reste : slideover, settings, autofill… Note : **quicksearch n'est PAS + contigu** (entrelacé avec cheatsheet + history-modal 886-1063) → extraction + propre pas triviale, reportée. - ✅ `node --check` en pré-étape de `BuildAssets.ps1` : **déjà fait** (cf. §3.2). ### 3.2 🟡 Aucun test automatisé — **partiellement adressé (2026-07-04)** diff --git a/delphi-backend/assets/BuildAssets.ps1 b/delphi-backend/assets/BuildAssets.ps1 index 6011684..4fa36a7 100644 --- a/delphi-backend/assets/BuildAssets.ps1 +++ b/delphi-backend/assets/BuildAssets.ps1 @@ -57,6 +57,7 @@ $patterns = @( 'js\app.favicon.js', 'js\app.import.js', 'js\app.backup.js', + 'js\app.health.js', 'js\app.js', 'js\app.sync.js', 'css\style.css' diff --git a/index.html b/index.html index 89f8262..73bdab1 100644 --- a/index.html +++ b/index.html @@ -1197,6 +1197,7 @@ + diff --git a/js/app.health.js b/js/app.health.js new file mode 100644 index 0000000..8aa55e1 --- /dev/null +++ b/js/app.health.js @@ -0,0 +1,261 @@ +// ============================================================ +// app.health.js — VAULT HEALTH dashboard (extracted from app.js, §3.1) +// ============================================================ +// +// Weak/reused/old/pwned scoring + the health view render. Pure declarations +// (state + consts), no top-level side effects → loads BEFORE app.js. Uses +// computeStrength, decryptPwd, state, api, render via shared global scope at +// call time. NOTE: auditCache/auditFilter live here (they sit in this var +// block) but drive the separate Audit-log viewer in app.js — resolved +// cross-file via shared scope, same as other cross-module refs. +// +// ============================================================ +// VAULT HEALTH dashboard +// ============================================================ +// +// One-shot computation per session — decrypting every entry is the +// expensive part, so we cache the result and clear it on lock / entry +// edit / view re-entry (Tools → Vault health). + +let healthCache = null; +// Audit log: array of {id, action, ip, created_at}. Refreshed on demand +// from the server. Cleared on lockVault. +let auditCache = null; +let auditFilter = ''; +// Per-category expand state. Survives full re-renders of the dashboard +// (renderGrid runs from openSlideOver → would otherwise reset every +// "Show all" toggle back to collapsed). +const healthExpanded = { weak: false, reused: false, old: false, pwned: false }; +const HEALTH_WEAK_THRESHOLD = 50; // computeStrength score < 50 → weak +const HEALTH_OLD_DAYS = 365; // entries not updated in > 1 year + +function entryAgeDays(e) { + const ts = e.updated_at || e.created_at; + if (!ts) return 0; + // ISO 'yyyy-mm-dd hh:nn:ss' → assume UTC-ish, close enough for ranking. + const d = new Date(ts.replace(' ', 'T')); + if (isNaN(d)) return 0; + return Math.floor((Date.now() - d.getTime()) / 86400000); +} + +async function computeHealthCache() { + const weak = [], old = [], pwned = []; + const byPwd = new Map(); // plaintext → [entries] + // Notes have no password to weigh — their encrypted_password is just + // the free-text body. Skipping them avoids polluting the "weak / reused" + // categories with note content. + for (const e of state.entries) { + if ((e.kind || 'login') !== 'login') continue; + const ageD = entryAgeDays(e); + if (ageD > HEALTH_OLD_DAYS) old.push({ entry: e, ageDays: ageD }); + + const pwn = state.hibpResults.get(e.id); + if (typeof pwn === 'number' && pwn > 0) + pwned.push({ entry: e, count: pwn }); + + // Decrypt for strength + reuse detection. '[ERROR]' bubbles up + // from decryptPwd for corrupted ciphertext — skip those silently. + const plain = await decryptPwd(e.encrypted_password, e.iv); + if (plain === '[ERROR]') continue; + const score = computeStrength(plain); + if (score < HEALTH_WEAK_THRESHOLD) weak.push({ entry: e, score }); + if (!byPwd.has(plain)) byPwd.set(plain, []); + byPwd.get(plain).push(e); + } + // Reuse: groups of ≥2 entries sharing the same plaintext password. + const reused = []; + for (const [, entries] of byPwd) { + if (entries.length >= 2) reused.push(entries); + } + + // Score: start at 100, subtract per issue (capped at 0). Weights + // chosen so a single pwned password dominates over a single old one. + let score = 100; + score -= Math.min(40, weak.length * 5); + score -= Math.min(30, reused.length * 10); + score -= Math.min(20, old.length * 2); + score -= Math.min(50, pwned.length * 15); + if (score < 0) score = 0; + + return { weak, reused, old, pwned, score }; +} + +function healthScoreBand(score) { + if (score >= 80) return { label: 'Good', cls: 'is-ok' }; + if (score >= 50) return { label: 'Fair', cls: 'is-fair' }; + if (score >= 25) return { label: 'At risk', cls: 'is-warn' }; + return { label: 'Critical', cls: 'is-danger' }; +} + +// Open the entry slideover, unmask the password, focus it, and pulse the +// generator button. The user keeps full context (which entry they're +// fixing) and decides whether to type a new password, click the dice, or +// dismiss. Auto-opening the generator modal hid the entry context and +// forced an extra Save click — worse UX than this lighter nudge. +async function openEntryForFix(entryId) { + await openSlideOver(entryId); + const pwd = document.getElementById('soPassword'); + if (pwd) { + pwd.type = 'text'; // unmask so the user sees what they're replacing + pwd.focus(); + pwd.select(); + } + const genBtn = document.querySelector('.so-pw-row button[title="Generate"]'); + if (genBtn) { + genBtn.classList.add('is-pulse'); + setTimeout(() => genBtn.classList.remove('is-pulse'), 2000); + } +} + +async function renderHealthDashboard(grid) { + // Recompute on demand. The "Recompute" button below also triggers it. + if (!healthCache) { + grid.appendChild(el('div', { class: 'health-loading' }, + 'Analysing ' + state.entries.length + ' entries…')); + healthCache = await computeHealthCache(); + grid.innerHTML = ''; + } + const h = healthCache; + const band = healthScoreBand(h.score); + + // Header: big score + recompute action + const header = el('div', { class: 'health-header' }); + const scoreEl = el('div', { class: 'health-score ' + band.cls }); + scoreEl.appendChild(el('div', { class: 'health-score-num' }, String(h.score))); + scoreEl.appendChild(el('div', { class: 'health-score-lbl' }, band.label)); + header.appendChild(scoreEl); + const intro = el('div', { class: 'health-intro' }); + intro.appendChild(el('h3', null, 'How healthy is your vault?')); + intro.appendChild(el('p', null, + 'A summary of weak, reused, old and breached passwords. ' + + 'Click any item to open it and rotate the password.')); + const recompute = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' }); + recompute.appendChild(icon('i-rotate-ccw')); + recompute.appendChild(document.createTextNode(' Recompute')); + recompute.addEventListener('click', () => { + healthCache = null; + render(); + }); + intro.appendChild(recompute); + header.appendChild(intro); + grid.appendChild(header); + + // Four category cards + grid.appendChild(renderHealthSection({ + key: 'weak', + title: 'Weak passwords', + hint: 'Strength score below ' + HEALTH_WEAK_THRESHOLD + + '/100 (short / few character classes).', + items: h.weak, + empty: 'All passwords pass the strength check. 👍', + formatItem: it => entryDisplayName(it.entry) + ' — ' + it.score + '/100', + })); + + grid.appendChild(renderHealthSection({ + key: 'reused', + title: 'Reused passwords', + hint: 'Same password used on multiple entries — a single breach affects them all.', + items: h.reused, + empty: 'Every password is unique. 👍', + formatItem: group => group.map(e => entryDisplayName(e)).join(' · ') + + ' (' + group.length + ' entries)', + // Click on a reused group: open the first entry. Could be smarter. + idOfItem: group => group[0].id, + })); + + grid.appendChild(renderHealthSection({ + key: 'old', + title: 'Old passwords', + hint: 'Not updated for more than ' + Math.round(HEALTH_OLD_DAYS / 30) + + ' months. Consider rotating periodically for high-value accounts.', + items: h.old, + empty: 'No stale passwords.', + formatItem: it => entryDisplayName(it.entry) + ' — ' + + Math.floor(it.ageDays / 30) + ' months old', + })); + + grid.appendChild(renderHealthSection({ + key: 'pwned', + title: 'Breached passwords (HIBP)', + hint: state.hibpEnabled + ? 'Found in the Have I Been Pwned database. Change them now.' + : 'Enable “Check passwords against breach database” in Settings to populate this list.', + items: h.pwned, + empty: state.hibpEnabled + ? 'No password matches a known breach. 👍' + : '— breach check is OFF —', + formatItem: it => entryDisplayName(it.entry) + + ' — seen ' + it.count.toLocaleString() + 'x', + })); +} + +// Build one collapsible category card. opts: +// title, hint, items[], empty, +// formatItem(item) → text for the row, +// idOfItem(item) → entry id used by the Fix click. Default: item.entry.id +function renderHealthSection(opts) { + const card = el('section', { class: 'health-card' }); + const head = el('header', { class: 'health-card-head' }); + head.appendChild(el('h4', null, opts.title)); + const badge = el('span', { class: 'health-badge' }, String(opts.items.length)); + if (opts.items.length === 0) badge.classList.add('is-empty'); + head.appendChild(badge); + card.appendChild(head); + card.appendChild(el('p', { class: 'health-hint' }, opts.hint)); + + if (opts.items.length === 0) { + card.appendChild(el('p', { class: 'health-empty' }, opts.empty)); + return card; + } + + const list = el('ul', { class: 'health-list' }); + const getId = opts.idOfItem || (it => it.entry.id); + const INITIAL_LIMIT = 20; + // Read persisted expand state so a renderGrid() triggered by + // openSlideOver (after clicking "Fix") doesn't snap the list back + // to the collapsed view. + let expanded = !!(opts.key && healthExpanded[opts.key]); + let shown = expanded ? opts.items.length : + Math.min(INITIAL_LIMIT, opts.items.length); + + function renderRows() { + list.innerHTML = ''; + opts.items.slice(0, shown).forEach(it => { + const li = el('li', { class: 'health-item' }); + li.appendChild(el('span', { class: 'health-item-label' }, opts.formatItem(it))); + const fix = el('button', { class: 'btn btn-ghost btn-xs', type: 'button' }, + 'Fix'); + fix.addEventListener('click', ev => { + // Stop bubbling — the document-level "click outside slideover" + // handler would otherwise close the slideover we're about to + // open within the same click event. + ev.stopPropagation(); + openEntryForFix(getId(it)); + }); + li.appendChild(fix); + list.appendChild(li); + }); + } + + renderRows(); + card.appendChild(list); + + if (opts.items.length > INITIAL_LIMIT) { + const more = el('button', { + class: 'btn btn-ghost btn-xs health-more-btn', + type: 'button', + }, expanded ? 'Show less' : ('Show all ' + opts.items.length)); + more.addEventListener('click', ev => { + ev.stopPropagation(); + expanded = !expanded; + shown = expanded ? opts.items.length : INITIAL_LIMIT; + more.textContent = expanded ? 'Show less' + : 'Show all ' + opts.items.length; + if (opts.key) healthExpanded[opts.key] = expanded; + renderRows(); + }); + card.appendChild(more); + } + return card; +} + diff --git a/js/app.js b/js/app.js index d368ff0..3200bf4 100644 --- a/js/app.js +++ b/js/app.js @@ -2740,254 +2740,9 @@ function renderAuthenticatorGrid(grid, entries) { } // ============================================================ -// VAULT HEALTH dashboard +// VAULT HEALTH — extracted to js/app.health.js (§3.1), loaded as a +// separate