/** * health.ts — Pro advanced vault health analysis. * * Pure analysis on top of `vault.getEntries()`. No side effects, no storage * writes, no network. Reuse detection hashes passwords in memory and discards * the hash; nothing about a password ever lands on disk through this module. * * The basic health surface (breach flagging, weak password detection) lives * in `popup/views/Health.tsx` and is free. The advanced report adds: * - password age tracking with 90/180-day thresholds * - cross-domain reuse detection * - exposure scoring (0–100, financial/email weighted 2× in vault score) * - prioritised remediation queue */ import type { AdvancedHealthEntryReport, AdvancedHealthReport, VaultEntry, } from "../shared/types"; import { extractDomain } from "../shared/domain"; const SENSITIVE_DOMAIN_PATTERNS = /bank|pay|finance|invest|mail|gmail|outlook|yahoo|icloud|instagram|twitter|facebook|linkedin/i; const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000; const ONE_EIGHTY_DAYS_MS = 180 * 24 * 60 * 60 * 1000; const SCORE_BREACHED = 60; const SCORE_REUSED = 25; const SCORE_AGED = 15; const SCORE_WEAK = 20; const SCORE_NO_TOTP_SENSITIVE = 10; async function sha256Hex(plain: string): Promise { const buf = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(plain) ); return Array.from(new Uint8Array(buf)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } function isSensitiveDomain(domain: string): boolean { return SENSITIVE_DOMAIN_PATTERNS.test(domain); } function ageThresholdMs(domain: string): number { return isSensitiveDomain(domain) ? NINETY_DAYS_MS : ONE_EIGHTY_DAYS_MS; } /** * Lightweight zxcvbn-free heuristic for "weak password". Real zxcvbn is too * heavy for the service worker; the basic Health view computes it in the popup * already, but the background scoring needs a fast approximation. Anything * shorter than 10 chars or lacking variety counts as weak (zxcvbn < 2 region). */ function isWeakHeuristic(password: string): boolean { if (password.length < 10) return true; let classes = 0; if (/[a-z]/.test(password)) classes += 1; if (/[A-Z]/.test(password)) classes += 1; if (/[0-9]/.test(password)) classes += 1; if (/[^A-Za-z0-9]/.test(password)) classes += 1; return classes < 3; } export async function buildAdvancedHealthReport( entries: VaultEntry[] ): Promise { const now = Date.now(); const live = entries.filter( (e) => !e.deletedAt && (e.type === undefined || e.type === "login") ); const hashGroups = new Map(); for (const entry of live) { if (!entry.password) continue; const hash = await sha256Hex(entry.password); const ids = hashGroups.get(hash) ?? []; ids.push(entry.id); hashGroups.set(hash, ids); } const reusedIds = new Set(); let reusedGroupCount = 0; for (const ids of hashGroups.values()) { if (ids.length > 1) { reusedGroupCount += 1; for (const id of ids) reusedIds.add(id); } } const reports: AdvancedHealthEntryReport[] = []; let agedCount = 0; let weightedSum = 0; let weightSum = 0; for (const entry of live) { const domain = extractDomain(entry.url ?? "") ?? ""; const sensitive = domain ? isSensitiveDomain(domain) : false; const passwordChanged = entry.passwordChangedAt ?? entry.createdAt ?? now; const ageMs = now - passwordChanged; const aged = entry.password ? ageMs > ageThresholdMs(domain) : false; if (aged) agedCount += 1; const breached = (entry.breach ?? 0) > 0; const reused = reusedIds.has(entry.id); const weak = entry.password ? isWeakHeuristic(entry.password) : false; const missingTotp = sensitive && !entry.totp; let score = 0; if (breached) score += SCORE_BREACHED; if (reused) score += SCORE_REUSED; if (aged) score += SCORE_AGED; if (weak) score += SCORE_WEAK; if (missingTotp) score += SCORE_NO_TOTP_SENSITIVE; if (score > 100) score = 100; const reasons: string[] = []; if (breached) reasons.push(`Breached (${entry.breach} exposures)`); if (reused) reasons.push("Reused"); if (aged) { const days = Math.floor(ageMs / 86_400_000); reasons.push(`${days} days old`); } if (weak) reasons.push("Weak"); if (missingTotp) reasons.push("No 2FA on sensitive site"); const weight = sensitive ? 2 : 1; weightedSum += score * weight; weightSum += weight; if (score > 0) { reports.push({ id: entry.id, title: entry.title, domain, url: entry.url, score, ageDays: Math.floor(ageMs / 86_400_000), reasons, factors: { breached, breachCount: entry.breach ?? 0, reused, aged, weak, missingTotpOnSensitive: missingTotp, }, }); } } reports.sort((a, b) => b.score - a.score); const vaultScore = weightSum > 0 ? Math.round(weightedSum / weightSum) : 0; return { vaultScore, generatedAt: now, entryCount: live.length, reusedGroups: reusedGroupCount, agedCount, remediation: reports, }; }