health.ts

5.1 KB · raw

1/**2 * health.ts — Pro advanced vault health analysis.3 *4 * Pure analysis on top of `vault.getEntries()`. No side effects, no storage5 * writes, no network. Reuse detection hashes passwords in memory and discards6 * the hash; nothing about a password ever lands on disk through this module.7 *8 * The basic health surface (breach flagging, weak password detection) lives9 * in `popup/views/Health.tsx` and is free. The advanced report adds:10 *   - password age tracking with 90/180-day thresholds11 *   - cross-domain reuse detection12 *   - exposure scoring (0–100, financial/email weighted 2× in vault score)13 *   - prioritised remediation queue14 */1516import type {17  AdvancedHealthEntryReport,18  AdvancedHealthReport,19  VaultEntry,20} from "../shared/types";21import { extractDomain } from "../shared/domain";2223const SENSITIVE_DOMAIN_PATTERNS =24  /bank|pay|finance|invest|mail|gmail|outlook|yahoo|icloud|instagram|twitter|facebook|linkedin/i;2526const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000;27const ONE_EIGHTY_DAYS_MS = 180 * 24 * 60 * 60 * 1000;2829const SCORE_BREACHED = 60;30const SCORE_REUSED = 25;31const SCORE_AGED = 15;32const SCORE_WEAK = 20;33const SCORE_NO_TOTP_SENSITIVE = 10;3435async function sha256Hex(plain: string): Promise<string> {36  const buf = await crypto.subtle.digest(37    "SHA-256",38    new TextEncoder().encode(plain)39  );40  return Array.from(new Uint8Array(buf))41    .map((b) => b.toString(16).padStart(2, "0"))42    .join("");43}4445function isSensitiveDomain(domain: string): boolean {46  return SENSITIVE_DOMAIN_PATTERNS.test(domain);47}4849function ageThresholdMs(domain: string): number {50  return isSensitiveDomain(domain) ? NINETY_DAYS_MS : ONE_EIGHTY_DAYS_MS;51}5253/**54 * Lightweight zxcvbn-free heuristic for "weak password". Real zxcvbn is too55 * heavy for the service worker; the basic Health view computes it in the popup56 * already, but the background scoring needs a fast approximation. Anything57 * shorter than 10 chars or lacking variety counts as weak (zxcvbn < 2 region).58 */59function isWeakHeuristic(password: string): boolean {60  if (password.length < 10) return true;61  let classes = 0;62  if (/[a-z]/.test(password)) classes += 1;63  if (/[A-Z]/.test(password)) classes += 1;64  if (/[0-9]/.test(password)) classes += 1;65  if (/[^A-Za-z0-9]/.test(password)) classes += 1;66  return classes < 3;67}6869export async function buildAdvancedHealthReport(70  entries: VaultEntry[]71): Promise<AdvancedHealthReport> {72  const now = Date.now();73  const live = entries.filter(74    (e) => !e.deletedAt && (e.type === undefined || e.type === "login")75  );7677  const hashGroups = new Map<string, string[]>();78  for (const entry of live) {79    if (!entry.password) continue;80    const hash = await sha256Hex(entry.password);81    const ids = hashGroups.get(hash) ?? [];82    ids.push(entry.id);83    hashGroups.set(hash, ids);84  }85  const reusedIds = new Set<string>();86  let reusedGroupCount = 0;87  for (const ids of hashGroups.values()) {88    if (ids.length > 1) {89      reusedGroupCount += 1;90      for (const id of ids) reusedIds.add(id);91    }92  }9394  const reports: AdvancedHealthEntryReport[] = [];95  let agedCount = 0;96  let weightedSum = 0;97  let weightSum = 0;9899  for (const entry of live) {100    const domain = extractDomain(entry.url ?? "") ?? "";101    const sensitive = domain ? isSensitiveDomain(domain) : false;102    const passwordChanged =103      entry.passwordChangedAt ?? entry.createdAt ?? now;104    const ageMs = now - passwordChanged;105    const aged = entry.password106      ? ageMs > ageThresholdMs(domain)107      : false;108    if (aged) agedCount += 1;109110    const breached = (entry.breach ?? 0) > 0;111    const reused = reusedIds.has(entry.id);112    const weak = entry.password ? isWeakHeuristic(entry.password) : false;113    const missingTotp = sensitive && !entry.totp;114115    let score = 0;116    if (breached) score += SCORE_BREACHED;117    if (reused) score += SCORE_REUSED;118    if (aged) score += SCORE_AGED;119    if (weak) score += SCORE_WEAK;120    if (missingTotp) score += SCORE_NO_TOTP_SENSITIVE;121    if (score > 100) score = 100;122123    const reasons: string[] = [];124    if (breached) reasons.push(`Breached (${entry.breach} exposures)`);125    if (reused) reasons.push("Reused");126    if (aged) {127      const days = Math.floor(ageMs / 86_400_000);128      reasons.push(`${days} days old`);129    }130    if (weak) reasons.push("Weak");131    if (missingTotp) reasons.push("No 2FA on sensitive site");132133    const weight = sensitive ? 2 : 1;134    weightedSum += score * weight;135    weightSum += weight;136137    if (score > 0) {138      reports.push({139        id: entry.id,140        title: entry.title,141        domain,142        url: entry.url,143        score,144        ageDays: Math.floor(ageMs / 86_400_000),145        reasons,146        factors: {147          breached,148          breachCount: entry.breach ?? 0,149          reused,150          aged,151          weak,152          missingTotpOnSensitive: missingTotp,153        },154      });155    }156  }157158  reports.sort((a, b) => b.score - a.score);159160  const vaultScore = weightSum > 0 ? Math.round(weightedSum / weightSum) : 0;161162  return {163    vaultScore,164    generatedAt: now,165    entryCount: live.length,166    reusedGroups: reusedGroupCount,167    agedCount,168    remediation: reports,169  };170}