From 93c19e8f9c94541079e249fb8cda11a81c97fc68 Mon Sep 17 00:00:00 2001 From: Giuseppe Imperato Date: Wed, 20 May 2026 13:29:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(watchtower):=20cluster=204=20=E2=80=94=20H?= =?UTF-8?q?IBP=20password=20breach=20detection=20(k-anonymity,=20opt-in)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Have I Been Pwned compromised-password detection using the k-anonymity model: only the first 5 SHA-1 hex chars are sent, never the password. - IHibpService / HibpService: k-anonymity client, static HttpClient, 5s timeout - IWatchtowerScanService / WatchtowerScanService: orchestrator with 24h cache, 5 req/s throttle, UI-dispatched Progress/Completed events - Opt-in setting "Check for compromised passwords" in Settings → Security, persisted via SettingsService (HibpEnabled, LastHibpScanUtc) - Localized HIBP consent dialog (4 keys x 6 languages) - Refactor: merged standalone Watchtower into the Verifica page — "Password" and "Vault" tabs replace the separate Audit Vault tab and Watchtower nav entry; WatchtowerView/ViewModel removed - Dashboard: clickable "Salute del vault" card above the stat cards with Compromromise/Weak/Reused mini-counters, navigates to Verifica/Vault - Nav badge: red dot (CriticalDotInfoBadgeStyle) when compromised passwords exist - Privacy policy: new "Compromised Password Check" section documenting k-anonymity and the opt-in nature of the feature - 14 HibpService unit tests (k-anonymity, RFC 3174 vectors, network errors, cancellation) — full suite 213 green Co-Authored-By: Claude Opus 4.7 --- docs/privacy-policy.md | 46 +++- src/PassKey.Core/Services/HibpService.cs | 101 ++++++++ src/PassKey.Core/Services/IHibpService.cs | 35 +++ src/PassKey.Desktop/App.xaml.cs | 2 + .../Services/ISettingsService.cs | 8 + .../Services/IWatchtowerScanService.cs | 55 +++++ .../Services/SettingsService.cs | 17 ++ .../Services/WatchtowerScanService.cs | 184 +++++++++++++++ .../Strings/de-DE/Resources.resw | 89 ++++++- .../Strings/en-GB/Resources.resw | 89 ++++++- .../Strings/es-ES/Resources.resw | 89 ++++++- .../Strings/fr-FR/Resources.resw | 89 ++++++- .../Strings/it-IT/Resources.resw | 89 ++++++- .../Strings/pt-PT/Resources.resw | 89 ++++++- .../ViewModels/DashboardViewModel.cs | 76 +++++- .../ViewModels/PasswordVerifierViewModel.cs | 221 ++++++++++-------- .../ViewModels/SettingsViewModel.cs | 14 ++ .../ViewModels/ShellViewModel.cs | 3 +- src/PassKey.Desktop/Views/DashboardView.xaml | 140 +++++++++-- .../Views/DashboardView.xaml.cs | 33 ++- .../Views/PasswordVerifierView.xaml | 73 +++++- .../Views/PasswordVerifierView.xaml.cs | 182 +++++++++------ src/PassKey.Desktop/Views/SettingsView.xaml | 19 ++ .../Views/SettingsView.xaml.cs | 32 +++ src/PassKey.Desktop/Views/ShellView.xaml | 14 +- src/PassKey.Desktop/Views/ShellView.xaml.cs | 40 +++- src/PassKey.Tests/HibpServiceTests.cs | 207 ++++++++++++++++ 27 files changed, 1801 insertions(+), 235 deletions(-) create mode 100644 src/PassKey.Core/Services/HibpService.cs create mode 100644 src/PassKey.Core/Services/IHibpService.cs create mode 100644 src/PassKey.Desktop/Services/IWatchtowerScanService.cs create mode 100644 src/PassKey.Desktop/Services/WatchtowerScanService.cs create mode 100644 src/PassKey.Tests/HibpServiceTests.cs diff --git a/docs/privacy-policy.md b/docs/privacy-policy.md index be04119..90c88af 100644 --- a/docs/privacy-policy.md +++ b/docs/privacy-policy.md @@ -1,6 +1,6 @@ # PassKey Privacy Policy -**Last updated:** 2026-05-10 +**Last updated:** 2026-05-20 --- @@ -21,12 +21,54 @@ PassKey does not collect, store, or transmit any personal data to external serve ## Network Activity -PassKey makes **zero** network connections. The only local communication that occurs is: +By default, PassKey makes **zero** network connections. The only local communication that occurs is: - **Browser extension to PassKey Desktop app** — via the Native Messaging protocol over a local Named Pipe. This communication never leaves your computer and is encrypted with ephemeral ECDH P-256 + AES-256-GCM session keys. There is no analytics, no telemetry, no crash reporting, and no update checking. +The **only** circumstance in which PassKey contacts an external server is the optional, opt-in +compromised-password check described in the next section. It is disabled by default and never +runs unless you explicitly enable it. + +--- + +## Compromised Password Check (optional, opt-in) + +PassKey can tell you whether any of your stored passwords have appeared in a known data breach. +This feature is **disabled by default** and is only ever activated when you explicitly turn on +the **"Check for compromised passwords"** setting in *Settings → Security*. + +### How it works — k-anonymity + +When enabled, PassKey queries the [Have I Been Pwned](https://haveibeenpwned.com/) Pwned Passwords +service using the **k-anonymity model**, which is designed so that **your password is never sent**: + +1. PassKey computes the SHA-1 hash of a password locally, on your device. +2. Only the **first 5 hexadecimal characters** of that hash are sent to `api.pwnedpasswords.com`. +3. The service returns the list of all breached-hash suffixes that share those 5 characters. +4. PassKey compares that list against the remaining hash characters **locally** — the server + never learns which password, or even which full hash, you were checking. + +This means the breach service cannot identify your passwords, your account, or you. No API key +is required and no personal identifier is transmitted. + +### What is and isn't sent + +| Sent to the breach service | Never sent | +|----------------------------|------------| +| The first 5 characters of a password's SHA-1 hash | The password itself | +| | The full SHA-1 hash | +| | Usernames, URLs, or entry titles | +| | Any device or account identifier | + +### Your control + +- The feature is **off until you turn it on**, and turning it off immediately stops all such requests. +- Requests are made over HTTPS with a short timeout; if the service is unreachable, the check + fails gracefully and no data is retried or queued. +- No results are shared with anyone — breach findings are displayed only inside the app. + --- ## Browser Extension diff --git a/src/PassKey.Core/Services/HibpService.cs b/src/PassKey.Core/Services/HibpService.cs new file mode 100644 index 0000000..d9b2598 --- /dev/null +++ b/src/PassKey.Core/Services/HibpService.cs @@ -0,0 +1,101 @@ +using System.Security.Cryptography; +using System.Text; + +namespace PassKey.Core.Services; + +/// +/// k-anonymity client for the Have I Been Pwned "Pwned Passwords" API. +/// +/// +/// Wire protocol: +/// +/// Compute SHA1(password) → 40 hex chars (uppercase, no separators). +/// Send the first 5 hex chars to https://api.pwnedpasswords.com/range/{prefix}. +/// Server returns plain text — one line per hash with that prefix in the form +/// {suffix35}:{count}. +/// Search the response locally for the 35-char suffix of our hash. The matching +/// line's count is the number of breaches; absence means 0. +/// +/// The full password (and its full SHA-1) never leaves the device. +/// +public sealed class HibpService : IHibpService +{ + private const string BaseAddress = "https://api.pwnedpasswords.com/range/"; + private const int PrefixLength = 5; + + // Static singleton HttpClient — same pattern as UpdateService. HttpClient is + // designed to be reused across calls (DNS / TCP pooling, thread-safe). Disposing + // per-call would exhaust ephemeral ports under load. + private static readonly HttpClient DefaultClient = new() + { + Timeout = TimeSpan.FromSeconds(5), + }; + + private readonly HttpClient _http; + + /// Production constructor — uses the shared singleton client. + public HibpService() : this(DefaultClient) + { + } + + /// Test seam — injects a with a mock handler. + public HibpService(HttpClient http) + { + _http = http ?? throw new ArgumentNullException(nameof(http)); + // Identify the client per HIBP courtesy guidance. This is the only header sent. + if (!_http.DefaultRequestHeaders.UserAgent.Any()) + { + _http.DefaultRequestHeaders.UserAgent.ParseAdd("PassKey/2.0 (+https://pass-key.it)"); + } + } + + /// + public async Task CheckPasswordAsync(string password, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(password)) return 0; + + var hash = Sha1Hex(password); + var prefix = hash[..PrefixLength]; + var suffix = hash[PrefixLength..]; + + var url = BaseAddress + prefix; + using var response = await _http.GetAsync(url, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return ParseBreachCount(body, suffix); + } + + /// Parses the line "suffix:count" matching the supplied suffix and returns the count. + internal static int ParseBreachCount(string body, string suffix) + { + // Iterate lines without allocating arrays for each token. The HIBP response + // averages ~500 lines (~30 KB) — small but worth avoiding the LINQ overhead. + var span = body.AsSpan(); + while (true) + { + var newline = span.IndexOf('\n'); + ReadOnlySpan line = newline < 0 ? span : span[..newline]; + if (line.Length > 0 && line[^1] == '\r') line = line[..^1]; + + var colon = line.IndexOf(':'); + if (colon == suffix.Length && line[..colon].Equals(suffix, StringComparison.OrdinalIgnoreCase)) + { + if (int.TryParse(line[(colon + 1)..], out var count)) + return count; + } + + if (newline < 0) break; + span = span[(newline + 1)..]; + } + return 0; + } + + /// Uppercase, no-separator SHA-1 hex digest of (UTF-8 bytes). + internal static string Sha1Hex(string text) + { + Span hash = stackalloc byte[20]; // SHA-1 is 160 bits + SHA1.HashData(Encoding.UTF8.GetBytes(text), hash); + return Convert.ToHexString(hash); // uppercase by contract + } +} diff --git a/src/PassKey.Core/Services/IHibpService.cs b/src/PassKey.Core/Services/IHibpService.cs new file mode 100644 index 0000000..78eb72e --- /dev/null +++ b/src/PassKey.Core/Services/IHibpService.cs @@ -0,0 +1,35 @@ +namespace PassKey.Core.Services; + +/// +/// Queries the Have I Been Pwned "Pwned Passwords" API +/// () using the +/// k-anonymity protocol: only the first 5 hex characters of the SHA-1 +/// hash of the password ever leave the device, so HIBP can never identify the +/// password being checked. +/// +/// +/// The free api.pwnedpasswords.com endpoint backs every check — +/// no API key, no auth, no rate-limit beyond a courtesy cap that the +/// respects when scanning many entries +/// in a row. +/// Privacy: this service issues network requests. PassKey is offline-first +/// by default — the caller is responsible for honouring the user's opt-in +/// preference (see AppSettings.HibpEnabled) and short-circuiting before +/// reaching this service when the user has not consented. +/// +public interface IHibpService +{ + /// + /// Checks against the HIBP Pwned Passwords list. + /// + /// The cleartext password to check. Never leaves the device. + /// Optional cancellation token (e.g. timeout from a scan). + /// + /// The number of distinct breaches in which the password has been observed. + /// 0 means "not seen in any known breach" (still possible the password + /// is weak — HIBP doesn't grade strength, only known compromise). + /// + /// Thrown on network failure / non-2xx response. + /// Thrown on timeout / explicit cancellation. + Task CheckPasswordAsync(string password, CancellationToken cancellationToken = default); +} diff --git a/src/PassKey.Desktop/App.xaml.cs b/src/PassKey.Desktop/App.xaml.cs index a785a2f..5f95218 100644 --- a/src/PassKey.Desktop/App.xaml.cs +++ b/src/PassKey.Desktop/App.xaml.cs @@ -32,6 +32,8 @@ public App() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // Desktop services services.AddSingleton(); diff --git a/src/PassKey.Desktop/Services/ISettingsService.cs b/src/PassKey.Desktop/Services/ISettingsService.cs index 0f3f2ba..e7f2262 100644 --- a/src/PassKey.Desktop/Services/ISettingsService.cs +++ b/src/PassKey.Desktop/Services/ISettingsService.cs @@ -53,6 +53,14 @@ public interface ISettingsService /// Gets or sets the release tag the user chose to skip (e.g. "v1.0.5"). null means no version has been skipped. string? SkippedUpdateVersion { get; set; } + /// Gets or sets whether the user has opted in to Have I Been Pwned k-anonymity checks. + /// Default is false (privacy-by-default — no network traffic until explicitly enabled). + bool HibpEnabled { get; set; } + + /// Gets or sets the UTC timestamp of the last successful Watchtower scan. + /// Used to anchor the 24-hour cache that prevents re-scanning every navigation. + DateTime? LastHibpScanUtc { get; set; } + /// Serialises all current settings to settings.json on disk. void Save(); diff --git a/src/PassKey.Desktop/Services/IWatchtowerScanService.cs b/src/PassKey.Desktop/Services/IWatchtowerScanService.cs new file mode 100644 index 0000000..17b8b91 --- /dev/null +++ b/src/PassKey.Desktop/Services/IWatchtowerScanService.cs @@ -0,0 +1,55 @@ +using PassKey.Core.Models; + +namespace PassKey.Desktop.Services; + +/// +/// Runs a full-vault audit combining the local strength analyser and the remote +/// Have I Been Pwned k-anonymity check. The service is the orchestrator: throttling, +/// caching, and progress reporting live here; +/// stays a pure HTTP call. +/// +public interface IWatchtowerScanService +{ + /// Indicates that is currently running. + bool IsScanning { get; } + + /// Cached result of the most recent successful scan (null until the first scan). + WatchtowerResult? LastResult { get; } + + /// + /// Raised on the UI thread every time a single entry has been checked. The argument + /// is in [0, 1]. Useful for progress bars during long scans. + /// + event Action? Progress; + + /// Raised on the UI thread when the scan finishes (success, cancel, or error). + event Action? Completed; + + /// + /// Runs (or returns the cached value from) the full audit. + /// + /// If true, ignore the 24-hour cache and rescan now. + Task ScanAsync(bool forceRefresh = false, CancellationToken cancellationToken = default); +} + +/// Per-entry findings produced by the scan. +public sealed record WatchtowerIssue( + Guid EntryId, + string Title, + string Username, + int StrengthScore, + string StrengthLabel, + int BreachCount, + bool IsDuplicate); + +/// Aggregated result of a full Watchtower scan. +public sealed record WatchtowerResult( + int TotalPasswords, + int CompromisedCount, + int WeakCount, + int DuplicateCount, + int HealthScore, + DateTime ScannedUtc, + IReadOnlyList Compromised, + IReadOnlyList Weak, + IReadOnlyList Duplicates); diff --git a/src/PassKey.Desktop/Services/SettingsService.cs b/src/PassKey.Desktop/Services/SettingsService.cs index c6cc527..f538836 100644 --- a/src/PassKey.Desktop/Services/SettingsService.cs +++ b/src/PassKey.Desktop/Services/SettingsService.cs @@ -70,6 +70,21 @@ public sealed class SettingsService : ISettingsService /// Gets or sets whether automatic update checks at startup are enabled. Default is true. public bool AutoUpdateCheckEnabled { get; set; } = true; + /// + /// Gets or sets whether PassKey is allowed to query the Have I Been Pwned "Pwned Passwords" + /// API to detect compromised passwords. Default is — the user must + /// opt in explicitly because this is the only feature that issues network requests + /// (privacy-by-default for an otherwise offline-first vault). Only the first 5 hex chars + /// of the SHA-1 hash are ever transmitted (k-anonymity). + /// + public bool HibpEnabled { get; set; } + + /// + /// Gets or sets the UTC timestamp of the last successful Watchtower full-vault scan. + /// Used to decide when the 24-hour cache must be invalidated. Null means never scanned. + /// + public DateTime? LastHibpScanUtc { get; set; } + /// Gets or sets the UTC timestamp of the last successful update check. Null means never checked. public DateTime? LastUpdateCheckUtc { get; set; } @@ -130,6 +145,8 @@ public void Load() AutoUpdateCheckEnabled = loaded.AutoUpdateCheckEnabled; LastUpdateCheckUtc = loaded.LastUpdateCheckUtc; SkippedUpdateVersion = loaded.SkippedUpdateVersion; + HibpEnabled = loaded.HibpEnabled; + LastHibpScanUtc = loaded.LastHibpScanUtc; } catch { diff --git a/src/PassKey.Desktop/Services/WatchtowerScanService.cs b/src/PassKey.Desktop/Services/WatchtowerScanService.cs new file mode 100644 index 0000000..91654b9 --- /dev/null +++ b/src/PassKey.Desktop/Services/WatchtowerScanService.cs @@ -0,0 +1,184 @@ +using Microsoft.UI.Dispatching; +using PassKey.Core.Models; +using PassKey.Core.Services; + +namespace PassKey.Desktop.Services; + +/// +/// Orchestrates the Watchtower audit: walks the vault sequentially, computes local +/// strength scores + duplicate groups, optionally queries HIBP (k-anonymity) for each +/// password the user has opted in to check, and aggregates everything into a single +/// kept in . +/// +/// +/// Throttling. HIBP's free tier has no hard rate limit but the courtesy +/// guidance is "no more than ~10 req/s". We stay conservative at 5 req/s (one call +/// every 200 ms). +/// Caching. The result is cached for 24 hours; calls within that window +/// return the cached value unless the caller passes forceRefresh: true. The +/// field persists the cache anchor +/// across process restarts. +/// Privacy gate. If is false +/// the scan still runs (so the user sees weak / duplicate stats) but skips every +/// HIBP call — no network traffic at all. +/// +public sealed class WatchtowerScanService : IWatchtowerScanService +{ + private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(24); + private static readonly TimeSpan HibpThrottle = TimeSpan.FromMilliseconds(200); // 5 req/s + + private readonly IVaultStateService _vaultState; + private readonly IPasswordStrengthAnalyzer _strengthAnalyzer; + private readonly IHibpService _hibp; + private readonly ISettingsService _settings; + private readonly DispatcherQueue? _uiDispatcher; + + public bool IsScanning { get; private set; } + public WatchtowerResult? LastResult { get; private set; } + + public event Action? Progress; + public event Action? Completed; + + public WatchtowerScanService( + IVaultStateService vaultState, + IPasswordStrengthAnalyzer strengthAnalyzer, + IHibpService hibp, + ISettingsService settings) + { + _vaultState = vaultState; + _strengthAnalyzer = strengthAnalyzer; + _hibp = hibp; + _settings = settings; + _uiDispatcher = DispatcherQueue.GetForCurrentThread(); + } + + public async Task ScanAsync(bool forceRefresh = false, CancellationToken cancellationToken = default) + { + var vault = _vaultState.CurrentVault; + if (vault is null) return null; + + if (!forceRefresh && LastResult is { } cached && DateTime.UtcNow - cached.ScannedUtc < CacheTtl) + return cached; + + if (IsScanning) return LastResult; // a concurrent scan is already producing the result + + IsScanning = true; + try + { + var result = await Task.Run(() => RunScanAsync(vault, cancellationToken), cancellationToken) + .ConfigureAwait(false); + LastResult = result; + _settings.LastHibpScanUtc = result.ScannedUtc; + _settings.Save(); + RaiseCompleted(result); + return result; + } + catch (OperationCanceledException) + { + RaiseCompleted(null); + throw; + } + finally + { + IsScanning = false; + } + } + + private async Task RunScanAsync(Vault vault, CancellationToken ct) + { + var passwords = vault.Passwords; + var total = passwords.Count; + var hibpEnabled = _settings.HibpEnabled; + + // Build duplicate detection map first (cheap, fully local). + var groups = new Dictionary>(StringComparer.Ordinal); + foreach (var p in passwords) + { + if (string.IsNullOrEmpty(p.Password)) continue; + if (!groups.TryGetValue(p.Password, out var list)) + { + list = []; + groups[p.Password] = list; + } + list.Add(p.Id); + } + var duplicateIds = groups.Where(kv => kv.Value.Count > 1) + .SelectMany(kv => kv.Value) + .ToHashSet(); + + var compromised = new List(); + var weak = new List(); + var duplicates = new List(); + int totalScore = 0, weakCount = 0; + + for (int i = 0; i < total; i++) + { + ct.ThrowIfCancellationRequested(); + var p = passwords[i]; + + var strength = _strengthAnalyzer.Analyze(p.Password.AsSpan()); + totalScore += strength.Score; + bool isWeak = strength.Score < 40; + if (isWeak) weakCount++; + bool isDup = duplicateIds.Contains(p.Id); + + int breachCount = 0; + if (hibpEnabled && !string.IsNullOrEmpty(p.Password)) + { + try + { + breachCount = await _hibp.CheckPasswordAsync(p.Password, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + // A single HIBP failure must not abort the whole scan — the rest of the + // audit (local strength + duplicates) still produces valid findings. + System.Diagnostics.Debug.WriteLine( + $"[Watchtower] HIBP check failed for entry {p.Id}: {ex.GetType().Name}: {ex.Message}"); + } + // Courtesy throttle between successive HIBP calls — only when we actually called. + if (i < total - 1) await Task.Delay(HibpThrottle, ct).ConfigureAwait(false); + } + + var issue = new WatchtowerIssue( + EntryId: p.Id, + Title: p.Title, + Username: p.Username, + StrengthScore: strength.Score, + StrengthLabel: strength.Label, + BreachCount: breachCount, + IsDuplicate: isDup); + + if (breachCount > 0) compromised.Add(issue); + if (isWeak) weak.Add(issue); + if (isDup) duplicates.Add(issue); + + RaiseProgress((double)(i + 1) / Math.Max(1, total)); + } + + var avg = total > 0 ? totalScore / total : 0; + return new WatchtowerResult( + TotalPasswords: total, + CompromisedCount: compromised.Count, + WeakCount: weakCount, + DuplicateCount: duplicates.Count, + HealthScore: avg, + ScannedUtc: DateTime.UtcNow, + Compromised: compromised, + Weak: weak, + Duplicates: duplicates); + } + + private void RaiseProgress(double pct) + { + if (_uiDispatcher is null) { Progress?.Invoke(pct); return; } + _uiDispatcher.TryEnqueue(() => Progress?.Invoke(pct)); + } + + private void RaiseCompleted(WatchtowerResult? result) + { + if (_uiDispatcher is null) { Completed?.Invoke(result); return; } + _uiDispatcher.TryEnqueue(() => Completed?.Invoke(result)); + } +} diff --git a/src/PassKey.Desktop/Strings/de-DE/Resources.resw b/src/PassKey.Desktop/Strings/de-DE/Resources.resw index 026d39f..8918eb7 100644 --- a/src/PassKey.Desktop/Strings/de-DE/Resources.resw +++ b/src/PassKey.Desktop/Strings/de-DE/Resources.resw @@ -1275,13 +1275,13 @@ Passwort-Generator - Passwort-Prüfer + Pruefung - Prüfen + Passwort - Tresor-Audit + Tresor Anforderungen @@ -1414,4 +1414,87 @@ QR scannen + + Watchtower + + + Passwoerter gegen Have I Been Pwned pruefen. Nur die ersten 5 Zeichen des SHA-1-Hashes verlassen das Geraet (k-Anonymitaet). + + + Kompromittierte Passwoerter pruefen + + + Kompromittierte Passwoerter + + + Wiederverwendete Passwoerter + + + Aktivieren Sie die Option unter Einstellungen -> Sicherheit, um Ihre Passwoerter mit Have I Been Pwned abzugleichen. Nur die ersten 5 Zeichen des SHA-1-Hashes verlassen das Geraet (k-Anonymitaet). + + + Pruefung kompromittierter Passwoerter deaktiviert + + + Scan starten + + + Kompromittiert + + + Duplikate + + + Gesamt + + + Schwach + + + Tresor-Audit: schwache, wiederverwendete und in oeffentlichen Datenlecks kompromittierte Passwoerter. + + + Watchtower + + + Schwache Passwoerter + + + Aktivieren Sie die Option unter Einstellungen -> Sicherheit, um Ihre Passwoerter mit Have I Been Pwned abzugleichen. Nur die ersten 5 Zeichen des SHA-1-Hashes verlassen das Geraet (k-Anonymitaet). + + + Kompromittiert + + + Kompromittierte Passwoerter + + + Pruefung kompromittierter Passwoerter deaktiviert + + + Abbrechen + + + PassKey prueft Ihre Passwoerter gegen die oeffentliche Datenbank von Have I Been Pwned. Nur die ersten 5 Zeichen des SHA-1-Hashes jedes Passworts verlassen Ihr Geraet (k-Anonymitaet) - das Klartext-Passwort bleibt immer lokal. + +Moechten Sie diese Pruefung aktivieren? + + + Aktivieren + + + Pruefung kompromittierter Passwoerter aktivieren? + + + Kompromittiert + + + {0} kompromittierte Passwoerter + + + Wiederverwendet + + + Schwach + \ No newline at end of file diff --git a/src/PassKey.Desktop/Strings/en-GB/Resources.resw b/src/PassKey.Desktop/Strings/en-GB/Resources.resw index 04319e5..ec5885b 100644 --- a/src/PassKey.Desktop/Strings/en-GB/Resources.resw +++ b/src/PassKey.Desktop/Strings/en-GB/Resources.resw @@ -1265,13 +1265,13 @@ Password Generator - Password Verifier + Check - Verify + Password - Vault Audit + Vault Requirements @@ -1404,4 +1404,87 @@ Scan QR + + Watchtower + + + Verify your passwords against Have I Been Pwned. Only the first 5 characters of the SHA-1 hash ever leave the device (k-anonymity). + + + Check for compromised passwords + + + Compromised passwords + + + Reused passwords + + + To check your passwords against Have I Been Pwned enable the option in Settings -> Security. Only the first 5 characters of the SHA-1 hash ever leave the device (k-anonymity). + + + Compromised-password check is disabled + + + Start scan + + + Compromised + + + Duplicates + + + Total + + + Weak + + + Vault audit: weak, reused and compromised passwords detected in public data breaches. + + + Watchtower + + + Weak passwords + + + To check your passwords against Have I Been Pwned enable the option in Settings -> Security. Only the first 5 characters of the SHA-1 hash ever leave the device (k-anonymity). + + + Compromised + + + Compromised passwords + + + Compromised-password check is disabled + + + Cancel + + + PassKey will check your passwords against the public Have I Been Pwned database. Only the first 5 characters of the SHA-1 hash of each password will ever leave your device (k-anonymity) - the cleartext password always stays local. + +Do you want to enable this check? + + + Enable + + + Enable compromised-password check? + + + Compromised + + + {0} compromised passwords + + + Reused + + + Weak + \ No newline at end of file diff --git a/src/PassKey.Desktop/Strings/es-ES/Resources.resw b/src/PassKey.Desktop/Strings/es-ES/Resources.resw index 24aafca..cc04c68 100644 --- a/src/PassKey.Desktop/Strings/es-ES/Resources.resw +++ b/src/PassKey.Desktop/Strings/es-ES/Resources.resw @@ -1277,13 +1277,13 @@ Generador de contraseñas - Verificador de contraseñas + Verificacion - Verificar + Contrasena - Auditoría de la bóveda + Vault Requisitos @@ -1417,4 +1417,87 @@ Escanear QR + + Watchtower + + + Verificar las contrasenas contra Have I Been Pwned. Solo los primeros 5 caracteres del hash SHA-1 salen del dispositivo (k-anonymity). + + + Comprobar contrasenas comprometidas + + + Contrasenas comprometidas + + + Contrasenas reutilizadas + + + Para comprobar tus contrasenas contra Have I Been Pwned activa la opcion en Ajustes -> Seguridad. Solo los primeros 5 caracteres del hash SHA-1 salen del dispositivo (k-anonymity). + + + Comprobacion de contrasenas comprometidas desactivada + + + Iniciar escaneo + + + Comprometidas + + + Duplicadas + + + Total + + + Debiles + + + Auditoria del vault: contrasenas debiles, reutilizadas y comprometidas en filtraciones publicas. + + + Watchtower + + + Contrasenas debiles + + + Para comprobar tus contrasenas contra Have I Been Pwned activa la opcion en Ajustes -> Seguridad. Solo los primeros 5 caracteres del hash SHA-1 salen del dispositivo (k-anonymity). + + + Comprometidas + + + Contrasenas comprometidas + + + Comprobacion de contrasenas comprometidas desactivada + + + Cancelar + + + PassKey verificara tus contrasenas contra la base de datos publica de Have I Been Pwned. Solo los primeros 5 caracteres del hash SHA-1 de cada contrasena saldran del dispositivo (k-anonymity) - la contrasena en texto claro siempre se queda local. + +Deseas activar esta verificacion? + + + Activar + + + Activar la comprobacion de contrasenas comprometidas? + + + Comprometidas + + + {0} contrasenas comprometidas + + + Reutilizadas + + + Debiles + \ No newline at end of file diff --git a/src/PassKey.Desktop/Strings/fr-FR/Resources.resw b/src/PassKey.Desktop/Strings/fr-FR/Resources.resw index d7ccdd2..9a69f06 100644 --- a/src/PassKey.Desktop/Strings/fr-FR/Resources.resw +++ b/src/PassKey.Desktop/Strings/fr-FR/Resources.resw @@ -1277,13 +1277,13 @@ Générateur de mots de passe - Vérificateur de mots de passe + Verification - Vérifier + Mot de passe - Audit du coffre + Coffre Exigences @@ -1416,4 +1416,87 @@ Scanner QR + + Watchtower + + + Verifier les mots de passe contre Have I Been Pwned. Seuls les 5 premiers caracteres du hash SHA-1 quittent l'appareil (k-anonymity). + + + Verifier les mots de passe compromis + + + Mots de passe compromis + + + Mots de passe reutilises + + + Pour verifier vos mots de passe contre Have I Been Pwned activez l'option dans Parametres -> Securite. Seuls les 5 premiers caracteres du hash SHA-1 quittent l'appareil (k-anonymity). + + + Verification des mots de passe compromis desactivee + + + Lancer le scan + + + Compromis + + + Doublons + + + Total + + + Faibles + + + Audit du coffre : mots de passe faibles, reutilises et compromis dans des fuites publiques. + + + Watchtower + + + Mots de passe faibles + + + Pour verifier vos mots de passe contre Have I Been Pwned activez l'option dans Parametres -> Securite. Seuls les 5 premiers caracteres du hash SHA-1 quittent l'appareil (k-anonymity). + + + Compromis + + + Mots de passe compromis + + + Verification des mots de passe compromis desactivee + + + Annuler + + + PassKey verifiera vos mots de passe contre la base de donnees publique Have I Been Pwned. Seuls les 5 premiers caracteres du hash SHA-1 de chaque mot de passe quitteront votre appareil (k-anonymity) - le mot de passe en clair reste toujours local. + +Voulez-vous activer cette verification ? + + + Activer + + + Activer la verification des mots de passe compromis ? + + + Compromis + + + {0} mots de passe compromis + + + Reutilises + + + Faibles + \ No newline at end of file diff --git a/src/PassKey.Desktop/Strings/it-IT/Resources.resw b/src/PassKey.Desktop/Strings/it-IT/Resources.resw index 3ad27be..949f4d1 100644 --- a/src/PassKey.Desktop/Strings/it-IT/Resources.resw +++ b/src/PassKey.Desktop/Strings/it-IT/Resources.resw @@ -1277,13 +1277,13 @@ Generatore Password - Verifica Password + Verifica - Verifica + Password - Audit Vault + Vault Requisiti @@ -1417,4 +1417,87 @@ Scansiona QR + + Watchtower + + + Verifica le password contro Have I Been Pwned. Solo i primi 5 caratteri dell'hash SHA-1 lasciano il dispositivo (k-anonymity). + + + Controlla password compromesse + + + Password compromesse + + + Password duplicate + + + Per controllare le tue password contro Have I Been Pwned attiva l'opzione in Impostazioni -> Sicurezza. PassKey invia solo i primi 5 caratteri dell'hash SHA-1 (k-anonymity). + + + Controllo password compromesse disattivato + + + Avvia scansione + + + Compromesse + + + Duplicate + + + Totali + + + Deboli + + + Audit del vault: password deboli, riutilizzate e compromesse in data breach pubblici. + + + Watchtower + + + Password deboli + + + Per controllare le tue password contro Have I Been Pwned attiva l'opzione in Impostazioni -> Sicurezza. PassKey invia solo i primi 5 caratteri dell'hash SHA-1 (k-anonymity). + + + Compromesse + + + Password compromesse + + + Controllo password compromesse disattivato + + + Annulla + + + PassKey controllera' le tue password contro il database pubblico di Have I Been Pwned. Solo i primi 5 caratteri dell'hash SHA-1 di ogni password lasceranno il dispositivo (k-anonymity) - la password in chiaro resta sempre locale. + +Vuoi attivare questa verifica? + + + Attiva + + + Attivare il controllo password compromesse? + + + Compromesse + + + {0} password compromesse + + + Riutilizzate + + + Deboli + \ No newline at end of file diff --git a/src/PassKey.Desktop/Strings/pt-PT/Resources.resw b/src/PassKey.Desktop/Strings/pt-PT/Resources.resw index a14703f..19f9770 100644 --- a/src/PassKey.Desktop/Strings/pt-PT/Resources.resw +++ b/src/PassKey.Desktop/Strings/pt-PT/Resources.resw @@ -1275,13 +1275,13 @@ Gerador de palavras-passe - Verificador de palavras-passe + Verificacao - Verificar + Palavra-passe - Auditoria do cofre + Cofre Requisitos @@ -1414,4 +1414,87 @@ Digitalizar QR + + Watchtower + + + Verificar palavras-passe contra Have I Been Pwned. Apenas os primeiros 5 caracteres do hash SHA-1 saem do dispositivo (k-anonymity). + + + Verificar palavras-passe comprometidas + + + Palavras-passe comprometidas + + + Palavras-passe reutilizadas + + + Para verificar as suas palavras-passe contra Have I Been Pwned ative a opcao em Definicoes -> Seguranca. Apenas os primeiros 5 caracteres do hash SHA-1 saem do dispositivo (k-anonymity). + + + Verificacao de palavras-passe comprometidas desativada + + + Iniciar verificacao + + + Comprometidas + + + Duplicadas + + + Total + + + Fracas + + + Auditoria do cofre: palavras-passe fracas, reutilizadas e comprometidas em fugas publicas. + + + Watchtower + + + Palavras-passe fracas + + + Para verificar as suas palavras-passe contra Have I Been Pwned ative a opcao em Definicoes -> Seguranca. Apenas os primeiros 5 caracteres do hash SHA-1 saem do dispositivo (k-anonymity). + + + Comprometidas + + + Palavras-passe comprometidas + + + Verificacao de palavras-passe comprometidas desativada + + + Cancelar + + + O PassKey verificara as suas palavras-passe contra a base de dados publica do Have I Been Pwned. Apenas os primeiros 5 caracteres do hash SHA-1 de cada palavra-passe sairao do dispositivo (k-anonymity) - a palavra-passe em texto claro fica sempre local. + +Deseja ativar esta verificacao? + + + Ativar + + + Ativar a verificacao de palavras-passe comprometidas? + + + Comprometidas + + + {0} palavras-passe comprometidas + + + Reutilizadas + + + Fracas + \ No newline at end of file diff --git a/src/PassKey.Desktop/ViewModels/DashboardViewModel.cs b/src/PassKey.Desktop/ViewModels/DashboardViewModel.cs index aecde50..c7d9bba 100644 --- a/src/PassKey.Desktop/ViewModels/DashboardViewModel.cs +++ b/src/PassKey.Desktop/ViewModels/DashboardViewModel.cs @@ -11,7 +11,7 @@ namespace PassKey.Desktop.ViewModels; /// /// Dashboard ViewModel: greeting, 4 stat cards, 15 recent activity items, search. /// -public partial class DashboardViewModel : ObservableObject +public partial class DashboardViewModel : ObservableObject, IDisposable { private readonly IVaultStateService _vaultState; private readonly IVaultRepository _repository; @@ -125,6 +125,18 @@ public partial class DashboardViewModel : ObservableObject [ObservableProperty] public partial int WeakPasswordCount { get; set; } + /// Count of passwords reused across multiple entries (each duplicate's id contributes). + [ObservableProperty] + public partial int DuplicatePasswordCount { get; set; } + + /// + /// Count of passwords confirmed compromised in the Have I Been Pwned database by the + /// most recent Watchtower scan. Stays at 0 when HIBP is opted out or no scan has + /// been performed yet — the dashboard never issues network requests on its own. + /// + [ObservableProperty] + public partial int CompromisedPasswordCount { get; set; } + // ── Expiring credit cards ───────────────────────────────────────────────── /// Number of credit cards expiring within the next 60 days. @@ -173,18 +185,63 @@ public partial class DashboardViewModel : ObservableObject /// public event Action? NavigateToItemRequested; + /// + /// Fired when the user clicks the "Salute del vault" health card, expecting to land on + /// the Verifica → Vault audit page. The shell owns the navigation index, so we surface + /// the intent as an event and let it route. + /// + public event Action? NavigateToVerifierRequested; + public DashboardViewModel( IVaultStateService vaultState, IVaultRepository repository, IPasswordStrengthAnalyzer strengthAnalyzer, - IClipboardService clipboard) + IClipboardService clipboard, + IWatchtowerScanService scan) { _vaultState = vaultState; _repository = repository; _strengthAnalyzer = strengthAnalyzer; _clipboard = clipboard; + _scan = scan; + + // Mirror compromised-password totals from the shared Watchtower scan service so the + // Dashboard health card stays in sync with the Verifica/Vault tab. We never trigger + // a scan from here — the dashboard reflects whatever the verifier most recently + // produced (live updates when the user runs a fresh scan from the Verifier). + _scan.Completed += OnScanCompleted; + if (_scan.LastResult is { } cached) CompromisedPasswordCount = cached.CompromisedCount; + } + + private readonly IWatchtowerScanService _scan; + private bool _disposed; + + private void OnScanCompleted(WatchtowerResult? result) + { + if (result is null) return; + CompromisedPasswordCount = result.CompromisedCount; + // Refresh the duplicate / weak counts from the result too — the scan service uses + // identical logic to our local UpdatePasswordHealth but iterates the vault on a + // background thread, so it is the authoritative source post-scan. + DuplicatePasswordCount = result.DuplicateCount; + WeakPasswordCount = result.WeakCount; + VaultHealthScore = result.HealthScore; + } + + /// + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _scan.Completed -= OnScanCompleted; } + /// + /// Surfaces the user's intent to navigate from the Dashboard health card to the Verifier + /// (Vault audit tab). The View calls this in response to PointerPressed on the card. + /// + public void RequestNavigationToVerifier() => NavigateToVerifierRequested?.Invoke(); + /// /// Set localized greeting resources from View code-behind (ResourceLoader). /// @@ -321,19 +378,34 @@ private void UpdatePasswordHealth() { VaultHealthScore = 0; WeakPasswordCount = 0; + DuplicatePasswordCount = 0; + // CompromisedPasswordCount is fed by the Watchtower scan service, leave it as-is + // until the next scan finishes — the dashboard does not perform network checks. return; } int totalScore = 0, weak = 0; + // Build a count map for duplicate detection. Memory-cheap for typical vault sizes; + // we use ordinal comparison because passwords are byte-identical when reused. + var freq = new Dictionary(StringComparer.Ordinal); foreach (var pw in vault.Passwords) { var result = _strengthAnalyzer.Analyze(pw.Password.AsSpan()); totalScore += result.Score; if (result.Score < 40) weak++; + + if (!string.IsNullOrEmpty(pw.Password)) + { + freq.TryGetValue(pw.Password, out var n); + freq[pw.Password] = n + 1; + } } VaultHealthScore = totalScore / vault.Passwords.Count; WeakPasswordCount = weak; + // Every entry that appears in a duplicated group contributes to the count, so the + // figure on the dashboard matches the "Password riutilizzate" header in the Verifier. + DuplicatePasswordCount = freq.Values.Where(c => c > 1).Sum(); } private void UpdateExpiringCards() diff --git a/src/PassKey.Desktop/ViewModels/PasswordVerifierViewModel.cs b/src/PassKey.Desktop/ViewModels/PasswordVerifierViewModel.cs index 41e5f2c..5e8bb40 100644 --- a/src/PassKey.Desktop/ViewModels/PasswordVerifierViewModel.cs +++ b/src/PassKey.Desktop/ViewModels/PasswordVerifierViewModel.cs @@ -8,14 +8,25 @@ namespace PassKey.Desktop.ViewModels; /// -/// ViewModel for the Password Verifier page (Phase 10). -/// Tab 1: Manual password strength check. -/// Tab 2: Vault-wide audit (weak + duplicate passwords). +/// ViewModel for the "Verifica" page. Hosts two tabs: +/// +/// Password — manual single-password strength check (paste/type a password +/// and see the strength meter / suggestions / breach status update live). +/// Vault — vault-wide audit combining local strength + duplicate detection +/// with optional HIBP k-anonymity checks (when the user has opted in). Owned by +/// for the scan orchestration; this VM is +/// the presentation layer. +/// +/// In PassKey 2.0 the standalone "Watchtower" page was folded into Tab 2 to remove the +/// redundancy between "Audit Vault" (local only) and "Watchtower" (HIBP only). /// -public partial class PasswordVerifierViewModel : ObservableObject +public partial class PasswordVerifierViewModel : ObservableObject, IDisposable { private readonly IPasswordStrengthAnalyzer _analyzer; private readonly IVaultStateService _vaultState; + private readonly IWatchtowerScanService _scan; + private readonly ISettingsService _settings; + private bool _disposed; // ===== Tab 1: Manual Verify ===== @@ -36,6 +47,9 @@ public partial class PasswordVerifierViewModel : ObservableObject [ObservableProperty] public partial int TotalPasswords { get; set; } + [ObservableProperty] + public partial int CompromisedCount { get; set; } + [ObservableProperty] public partial int WeakCount { get; set; } @@ -45,18 +59,33 @@ public partial class PasswordVerifierViewModel : ObservableObject [ObservableProperty] public partial bool IsAuditLoading { get; set; } + [ObservableProperty] + public partial double AuditProgress { get; set; } + [ObservableProperty] public partial bool HasAuditResults { get; set; } - public ObservableCollection WeakPasswords { get; } = []; - public ObservableCollection DuplicateGroups { get; } = []; + [ObservableProperty] + public partial bool HibpEnabled { get; set; } + + public ObservableCollection CompromisedPasswords { get; } = []; + public ObservableCollection WeakPasswords { get; } = []; + public ObservableCollection DuplicateEntries { get; } = []; public PasswordVerifierViewModel( IPasswordStrengthAnalyzer analyzer, - IVaultStateService vaultState) + IVaultStateService vaultState, + IWatchtowerScanService scan, + ISettingsService settings) { _analyzer = analyzer; _vaultState = vaultState; + _scan = scan; + _settings = settings; + HibpEnabled = settings.HibpEnabled; + + _scan.Progress += OnScanProgress; + _scan.Completed += OnScanCompleted; } /// @@ -77,7 +106,8 @@ public void AnalyzePassword(string password) } /// - /// Runs a full audit of all passwords in the vault. + /// Runs a full audit of the vault: local strength + duplicate detection always; HIBP + /// k-anonymity checks only when the user has explicitly opted in (privacy-by-default). /// [RelayCommand] private async Task RunAuditAsync() @@ -85,100 +115,99 @@ private async Task RunAuditAsync() var vault = _vaultState.CurrentVault; if (vault is null) return; - IsAuditLoading = true; - WeakPasswords.Clear(); - DuplicateGroups.Clear(); - - // Heavy computation on background thread - var (weak, dupes, totalCount, weakCount, dupeCount, avgScore, scoreLabel) = - await Task.Run(() => - { - var passwords = vault.Passwords; - var auditItems = new List(passwords.Count); - var passwordGroups = new Dictionary>(); - - foreach (var entry in passwords) - { - var result = _analyzer.Analyze(entry.Password.AsSpan()); - var item = new AuditItem - { - Id = entry.Id, - Title = entry.Title, - Username = entry.Username, - Score = result.Score, - Label = result.Label - }; - - auditItems.Add(item); - - if (!string.IsNullOrEmpty(entry.Password)) - { - if (!passwordGroups.TryGetValue(entry.Password, out var group)) - { - group = []; - passwordGroups[entry.Password] = group; - } - group.Add(item); - } - } - - var w = auditItems.Where(a => a.Score < 60).OrderBy(a => a.Score).ToList(); - var d = passwordGroups - .Where(kv => kv.Value.Count > 1) - .Select(kv => new DuplicateGroup { Count = kv.Value.Count, Entries = kv.Value }) - .ToList(); - - var total = passwords.Count; - var wCount = w.Count; - var dCount = d.Sum(g => g.Entries.Count); - var avg = total > 0 ? (int)auditItems.Average(a => a.Score) : 0; - var label = avg switch - { - < 20 => "VeryWeak", - < 40 => "Weak", - < 60 => "Medium", - < 80 => "Strong", - _ => "VeryStrong" - }; - - return (w, d, total, wCount, dCount, avg, label); - }); - - // Back on UI thread — update observable properties - TotalPasswords = totalCount; - WeakCount = weakCount; - DuplicateCount = dupeCount; - VaultScore = avgScore; - VaultScoreLabel = scoreLabel; - - foreach (var w in weak) - WeakPasswords.Add(w); - foreach (var d in dupes) - DuplicateGroups.Add(d); - - HasAuditResults = true; - IsAuditLoading = false; + try + { + IsAuditLoading = true; + AuditProgress = 0; + + // Reset *both* the collections and the count properties to a known-blank state + // so the UI does not briefly show stale numbers from a previous scan while the + // new one is in flight. + CompromisedPasswords.Clear(); + WeakPasswords.Clear(); + DuplicateEntries.Clear(); + TotalPasswords = 0; + CompromisedCount = 0; + WeakCount = 0; + DuplicateCount = 0; + VaultScore = 0; + VaultScoreLabel = string.Empty; + HasAuditResults = false; + + await _scan.ScanAsync(forceRefresh: true); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[Verifier] Audit failed: {ex.GetType().Name}: {ex.Message}"); + } } public void Initialize() { - // Auto-run audit if vault has passwords - if (_vaultState.CurrentVault?.Passwords.Count > 0) + // Refresh the read-only opt-in mirror on every page entry (the user may have toggled + // it in Settings since the last visit). + HibpEnabled = _settings.HibpEnabled; + + var currentCount = _vaultState.CurrentVault?.Passwords.Count ?? 0; + + // If we have a cached scan whose total still matches the current vault size, show + // it immediately so the UI never blanks. We then ALWAYS kick off a fresh scan in the + // background — the user explicitly asked for an auto-refresh on every navigation + // to Verifica/Vault. Cached entries get superseded smoothly when the scan completes. + if (_scan.LastResult is { } cached && cached.TotalPasswords == currentCount) + { + ApplyResult(cached); + } + + if (currentCount > 0) RunAuditCommand.Execute(null); } -} -public sealed class AuditItem -{ - public Guid Id { get; init; } - public string Title { get; init; } = string.Empty; - public string Username { get; init; } = string.Empty; - public int Score { get; init; } - public string Label { get; init; } = string.Empty; -} + // ─── Scan service event handlers ────────────────────────────────────────── -public sealed class DuplicateGroup -{ - public int Count { get; init; } - public List Entries { get; init; } = []; + private void OnScanProgress(double pct) => AuditProgress = pct; + + private void OnScanCompleted(WatchtowerResult? result) + { + IsAuditLoading = false; + if (result is not null) ApplyResult(result); + } + + private void ApplyResult(WatchtowerResult result) + { + // IMPORTANT — populate the ObservableCollections *before* mutating the count + // properties. The View rebuilds each expander when its corresponding *Count + // property changes; if the collection were still empty at that moment the + // expander header would say "(N)" but the body would be blank. + CompromisedPasswords.Clear(); + foreach (var i in result.Compromised) CompromisedPasswords.Add(i); + WeakPasswords.Clear(); + foreach (var i in result.Weak) WeakPasswords.Add(i); + DuplicateEntries.Clear(); + foreach (var i in result.Duplicates) DuplicateEntries.Add(i); + + TotalPasswords = result.TotalPasswords; + CompromisedCount = result.CompromisedCount; + WeakCount = result.WeakCount; + DuplicateCount = result.DuplicateCount; + VaultScore = result.HealthScore; + VaultScoreLabel = result.HealthScore switch + { + < 20 => "VeryWeak", + < 40 => "Weak", + < 60 => "Medium", + < 80 => "Strong", + _ => "VeryStrong", + }; + + HasAuditResults = true; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _scan.Progress -= OnScanProgress; + _scan.Completed -= OnScanCompleted; + } } diff --git a/src/PassKey.Desktop/ViewModels/SettingsViewModel.cs b/src/PassKey.Desktop/ViewModels/SettingsViewModel.cs index 0739d33..492ef09 100644 --- a/src/PassKey.Desktop/ViewModels/SettingsViewModel.cs +++ b/src/PassKey.Desktop/ViewModels/SettingsViewModel.cs @@ -55,6 +55,10 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] public partial bool AutoUpdateCheckEnabled { get; set; } + // HIBP / Watchtower opt-in + [ObservableProperty] + public partial bool HibpEnabled { get; set; } + [ObservableProperty] public partial bool IsCheckingUpdate { get; set; } @@ -135,9 +139,19 @@ public void Initialize() // Update check settings AutoUpdateCheckEnabled = _settings.AutoUpdateCheckEnabled; + // HIBP / Watchtower opt-in + HibpEnabled = _settings.HibpEnabled; + _initializing = false; } + partial void OnHibpEnabledChanged(bool value) + { + if (_initializing) return; + _settings.HibpEnabled = value; + _settings.Save(); + } + partial void OnAutoUpdateCheckEnabledChanged(bool value) { if (_initializing) return; diff --git a/src/PassKey.Desktop/ViewModels/ShellViewModel.cs b/src/PassKey.Desktop/ViewModels/ShellViewModel.cs index ea950e4..35c3439 100644 --- a/src/PassKey.Desktop/ViewModels/ShellViewModel.cs +++ b/src/PassKey.Desktop/ViewModels/ShellViewModel.cs @@ -119,7 +119,8 @@ public void Dispose() /// /// Navigates to a page by its sidebar index. - /// Valid: 0=Dashboard, 1=Passwords, 2=Cards, 3=Identities, 4=Notes, 5=Generator, 6=Verifier. + /// Valid: 0=Dashboard, 1=Passwords, 2=Cards, 3=Identities, 4=Notes, 5=Generator, + /// 6=Verifier (manual password check + vault audit + HIBP). /// public void NavigateTo(int index) { diff --git a/src/PassKey.Desktop/Views/DashboardView.xaml b/src/PassKey.Desktop/Views/DashboardView.xaml index f40e568..af81d9e 100644 --- a/src/PassKey.Desktop/Views/DashboardView.xaml +++ b/src/PassKey.Desktop/Views/DashboardView.xaml @@ -69,6 +69,122 @@ Style="{StaticResource SubtitleTextBlockStyle}" Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -272,30 +388,6 @@ - - - - - - - - - - - - - - - - diff --git a/src/PassKey.Desktop/Views/DashboardView.xaml.cs b/src/PassKey.Desktop/Views/DashboardView.xaml.cs index 1249a7b..b04ebb6 100644 --- a/src/PassKey.Desktop/Views/DashboardView.xaml.cs +++ b/src/PassKey.Desktop/Views/DashboardView.xaml.cs @@ -83,6 +83,8 @@ private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.Pr case nameof(DashboardViewModel.IsVaultEmpty): case nameof(DashboardViewModel.VaultHealthScore): case nameof(DashboardViewModel.WeakPasswordCount): + case nameof(DashboardViewModel.CompromisedPasswordCount): + case nameof(DashboardViewModel.DuplicatePasswordCount): case nameof(DashboardViewModel.HasExpiringCards): UpdateUI(); break; @@ -126,9 +128,19 @@ private void UpdateUI() HealthScoreText.Text = $"{_viewModel.VaultHealthScore}%"; HealthRing.Foreground = GetHealthBrush(_viewModel.VaultHealthScore); HealthTitle.Text = _resourceLoader.GetString("DashHealthTitle"); - HealthSubtitle.Text = _viewModel.WeakPasswordCount > 0 - ? string.Format(_resourceLoader.GetString("DashHealthWeak"), _viewModel.WeakPasswordCount) + // Subtitle prefers the worst-news headline: compromised > weak > all good. + HealthSubtitle.Text = + _viewModel.CompromisedPasswordCount > 0 + ? string.Format(_resourceLoader.GetString("DashHealthCompromisedSummary"), _viewModel.CompromisedPasswordCount) + : _viewModel.WeakPasswordCount > 0 + ? string.Format(_resourceLoader.GetString("DashHealthWeak"), _viewModel.WeakPasswordCount) : _resourceLoader.GetString("DashHealthGood"); + + // 3 mini counters — leading-zero formatting ("01", "07") keeps the right column + // visually aligned and matches the compact stacked layout from the user mockup. + HealthCompromisedText.Text = _viewModel.CompromisedPasswordCount.ToString("D2"); + HealthWeakText.Text = _viewModel.WeakPasswordCount.ToString("D2"); + HealthDuplicatesText.Text = _viewModel.DuplicatePasswordCount.ToString("D2"); } else { @@ -233,6 +245,23 @@ private void StatCard_PointerExited(object sender, PointerRoutedEventArgs e) ProtectedCursor = Microsoft.UI.Input.InputSystemCursor.Create(Microsoft.UI.Input.InputSystemCursorShape.Arrow); } + // --- Health badge (clickable card → Verifica/Vault) --- + + private void HealthBadge_PointerPressed(object sender, PointerRoutedEventArgs e) + { + _viewModel?.RequestNavigationToVerifier(); + } + + private void HealthBadge_PointerEntered(object sender, PointerRoutedEventArgs e) + { + ProtectedCursor = Microsoft.UI.Input.InputSystemCursor.Create(Microsoft.UI.Input.InputSystemCursorShape.Hand); + } + + private void HealthBadge_PointerExited(object sender, PointerRoutedEventArgs e) + { + ProtectedCursor = Microsoft.UI.Input.InputSystemCursor.Create(Microsoft.UI.Input.InputSystemCursorShape.Arrow); + } + // --- Recent Items Hover Actions (Step C) --- private void RecentRow_PointerEntered(object sender, PointerRoutedEventArgs e) diff --git a/src/PassKey.Desktop/Views/PasswordVerifierView.xaml b/src/PassKey.Desktop/Views/PasswordVerifierView.xaml index 3e19109..483bfde 100644 --- a/src/PassKey.Desktop/Views/PasswordVerifierView.xaml +++ b/src/PassKey.Desktop/Views/PasswordVerifierView.xaml @@ -5,10 +5,10 @@ xmlns:controls="using:PassKey.Desktop.Controls"> - + - @@ -16,8 +16,8 @@ - - + + @@ -170,10 +170,19 @@ - - + + + + + + @@ -228,7 +238,7 @@ - + + + + + + + + + + + + + + + Foreground="{ThemeResource StatModifiedBrush}" /> - @@ -279,14 +314,30 @@ - - + + + + + + + + + + + + diff --git a/src/PassKey.Desktop/Views/PasswordVerifierView.xaml.cs b/src/PassKey.Desktop/Views/PasswordVerifierView.xaml.cs index b36bc5d..31da00e 100644 --- a/src/PassKey.Desktop/Views/PasswordVerifierView.xaml.cs +++ b/src/PassKey.Desktop/Views/PasswordVerifierView.xaml.cs @@ -4,6 +4,7 @@ using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Shapes; using PassKey.Core.Models; +using PassKey.Desktop.Services; using PassKey.Desktop.ViewModels; namespace PassKey.Desktop.Views; @@ -21,6 +22,12 @@ public PasswordVerifierView() _strengthSegments = [StrengthSeg0, StrengthSeg1, StrengthSeg2, StrengthSeg3, StrengthSeg4]; } + /// + /// Selects the second Pivot item ("Vault"). Called by the Shell when the user clicks + /// the Dashboard health card so the audit is the first thing they see. + /// + public void SelectVaultTab() => TabPivot.SelectedIndex = 1; + public void SetViewModel(PasswordVerifierViewModel vm) { _viewModel = vm; @@ -49,24 +56,121 @@ private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.Pr case nameof(PasswordVerifierViewModel.TotalPasswords): TotalCountText.Text = _viewModel!.TotalPasswords.ToString(); break; + case nameof(PasswordVerifierViewModel.CompromisedCount): + CompromisedCountText.Text = _viewModel!.CompromisedCount.ToString(); + CompromisedExpanderHeaderText.Text = $"Password compromesse ({_viewModel.CompromisedCount})"; + RebuildIssueList(CompromisedPasswordsList, _viewModel.CompromisedPasswords); + break; case nameof(PasswordVerifierViewModel.WeakCount): WeakCountText.Text = _viewModel!.WeakCount.ToString(); - UpdateWeakList(); + WeakExpanderHeaderText.Text = $"Password deboli ({_viewModel.WeakCount})"; + RebuildIssueList(WeakPasswordsList, _viewModel.WeakPasswords); break; case nameof(PasswordVerifierViewModel.DuplicateCount): DuplicateCountText.Text = _viewModel!.DuplicateCount.ToString(); - UpdateDuplicateList(); + DuplicateExpanderHeaderText.Text = $"Password riutilizzate ({_viewModel.DuplicateCount})"; + RebuildIssueList(DuplicateGroupsList, _viewModel.DuplicateEntries); break; case nameof(PasswordVerifierViewModel.IsAuditLoading): AuditLoadingRing.IsActive = _viewModel!.IsAuditLoading; + AuditLoadingRing.Visibility = _viewModel.IsAuditLoading ? Visibility.Visible : Visibility.Collapsed; break; case nameof(PasswordVerifierViewModel.HasAuditResults): var hasPasswords = _viewModel!.TotalPasswords > 0; AuditEmptyText.Visibility = !hasPasswords ? Visibility.Visible : Visibility.Collapsed; break; + case nameof(PasswordVerifierViewModel.HibpEnabled): + HibpDisabledBanner.IsOpen = !_viewModel!.HibpEnabled; + break; } } + /// + /// Rebuilds an expander's content stack from a flat list of . + /// Used for the Compromised / Weak / Duplicates lists inside the Vault tab. + /// + private void RebuildIssueList(StackPanel host, IEnumerable items) + { + host.Children.Clear(); + foreach (var item in items) + { + host.Children.Add(BuildIssueRow(item)); + } + } + + private static Grid BuildIssueRow(WatchtowerIssue item) + { + var row = new Grid { Padding = new Thickness(8, 6, 8, 6), ColumnSpacing = 10 }; + row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + // Severity dot (red for breached, orange for weak, otherwise muted) + var dot = new Ellipse + { + Width = 10, + Height = 10, + Fill = SeverityBrush(item), + VerticalAlignment = VerticalAlignment.Center, + }; + Grid.SetColumn(dot, 0); + row.Children.Add(dot); + + // Title + sub-info (username, breach count, "riutilizzata" flag) + var info = new StackPanel { Spacing = 2 }; + info.Children.Add(new TextBlock + { + Text = string.IsNullOrEmpty(item.Title) ? "(senza titolo)" : item.Title, + Style = (Style)Application.Current.Resources["BodyStrongTextBlockStyle"], + }); + var details = new System.Text.StringBuilder(); + if (!string.IsNullOrEmpty(item.Username)) details.Append(item.Username); + if (item.BreachCount > 0) + { + if (details.Length > 0) details.Append(" — "); + details.Append($"{item.BreachCount:N0} breach"); + } + if (item.IsDuplicate) + { + if (details.Length > 0) details.Append(" — "); + details.Append("riutilizzata"); + } + if (details.Length > 0) + { + info.Children.Add(new TextBlock + { + Text = details.ToString(), + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + }); + } + Grid.SetColumn(info, 1); + row.Children.Add(info); + + // Strength score (right side) + var score = new TextBlock + { + Text = item.StrengthScore.ToString(), + FontFamily = new FontFamily("Consolas, Courier New"), + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + VerticalAlignment = VerticalAlignment.Center, + Foreground = GetStrengthBrush(item.StrengthScore), + }; + Grid.SetColumn(score, 2); + row.Children.Add(score); + + return row; + } + + private static Brush SeverityBrush(WatchtowerIssue item) + { + if (item.BreachCount > 0) + return (Brush)Application.Current.Resources["StatRemovedBrush"]; + if (item.StrengthScore < 40) + return (Brush)Application.Current.Resources["StatModifiedBrush"]; + return (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"]; + } + private void VerifyPasswordInput_PasswordChanged(object sender, string password) { _viewModel?.AnalyzePassword(password); @@ -213,80 +317,6 @@ private void UpdateVaultScoreUI() VaultScoreLabelText.Text = GetLocalizedLabel(_viewModel.VaultScoreLabel); } - private void UpdateWeakList() - { - WeakPasswordsList.Children.Clear(); - if (_viewModel is null) return; - - foreach (var item in _viewModel.WeakPasswords) - { - var row = new Grid { Padding = new Thickness(8, 6, 8, 6) }; - row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - - // Strength dot (colored by score) - var dot = new Ellipse - { - Width = 10, - Height = 10, - Fill = GetStrengthBrush(item.Score), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 12, 0) - }; - Grid.SetColumn(dot, 0); - row.Children.Add(dot); - - // Info - var info = new StackPanel { Spacing = 2 }; - info.Children.Add(new TextBlock - { - Text = item.Title, - Style = (Style)Application.Current.Resources["BodyStrongTextBlockStyle"] - }); - info.Children.Add(new TextBlock - { - Text = $"{item.Username} — Punteggio: {item.Score}", - Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], - Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"] - }); - Grid.SetColumn(info, 1); - row.Children.Add(info); - - WeakPasswordsList.Children.Add(row); - } - - WeakExpanderHeaderText.Text = $"Password deboli ({_viewModel.WeakCount})"; - } - - private void UpdateDuplicateList() - { - DuplicateGroupsList.Children.Clear(); - if (_viewModel is null) return; - - foreach (var group in _viewModel.DuplicateGroups) - { - var groupPanel = new StackPanel { Spacing = 4 }; - groupPanel.Children.Add(new TextBlock - { - Text = $"Gruppo ({group.Count} password identiche)", - Style = (Style)Application.Current.Resources["BodyStrongTextBlockStyle"] - }); - - foreach (var entry in group.Entries) - { - groupPanel.Children.Add(new TextBlock - { - Text = $" \u2022 {entry.Title} ({entry.Username})", - Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], - Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"] - }); - } - - DuplicateGroupsList.Children.Add(groupPanel); - } - - DuplicateExpanderHeaderText.Text = $"Password riutilizzate ({_viewModel.DuplicateCount})"; - } private static string GetLocalizedLabel(string label) => label switch { diff --git a/src/PassKey.Desktop/Views/SettingsView.xaml b/src/PassKey.Desktop/Views/SettingsView.xaml index 760256a..3d3383c 100644 --- a/src/PassKey.Desktop/Views/SettingsView.xaml +++ b/src/PassKey.Desktop/Views/SettingsView.xaml @@ -171,6 +171,25 @@ TabIndex="60" /> + + + + + + + + + diff --git a/src/PassKey.Desktop/Views/SettingsView.xaml.cs b/src/PassKey.Desktop/Views/SettingsView.xaml.cs index 014b759..1ff538c 100644 --- a/src/PassKey.Desktop/Views/SettingsView.xaml.cs +++ b/src/PassKey.Desktop/Views/SettingsView.xaml.cs @@ -41,6 +41,7 @@ public void SetViewModel(SettingsViewModel vm) StartWithWindowsToggle.IsOn = vm.StartWithWindows; StartMinimizedCheck.IsChecked = vm.StartMinimized; StartMinimizedCheck.IsEnabled = vm.StartWithWindows; + HibpToggle.IsOn = vm.HibpEnabled; VersionText.Text = vm.AppVersion; AutoUpdateToggle.IsOn = vm.AutoUpdateCheckEnabled; UpdateStatusTextBlock.Text = FormatLastCheckTime(vm.LastUpdateCheckUtc); @@ -263,6 +264,37 @@ private void AutoUpdateToggle_Toggled(object sender, RoutedEventArgs e) _viewModel.AutoUpdateCheckEnabled = AutoUpdateToggle.IsOn; } + private async void HibpToggle_Toggled(object sender, RoutedEventArgs e) + { + if (_updatingFromVm || _viewModel is null) return; + + // Enabling HIBP is the only PassKey setting that opens a network connection, + // so we ask the user for an explicit, informed consent the moment the toggle + // flips ON. Disabling is silent — turning OFF should never need confirmation. + if (HibpToggle.IsOn) + { + // Strings come from the shared resource bundle so the dialog is consistent + // across the six supported UI cultures (see Strings/{lang}/Resources.resw — + // keys: HibpConsentTitle / HibpConsentMessage / HibpConsentPrimary / HibpConsentClose). + var confirmed = await _dialogQueue.ConfirmAsync( + title: _resourceLoader.GetString("HibpConsentTitle"), + content: _resourceLoader.GetString("HibpConsentMessage"), + primaryButtonText: _resourceLoader.GetString("HibpConsentPrimary"), + closeButtonText: _resourceLoader.GetString("HibpConsentClose"), + defaultButton: Microsoft.UI.Xaml.Controls.ContentDialogButton.Primary); + + if (!confirmed) + { + _updatingFromVm = true; + HibpToggle.IsOn = false; + _updatingFromVm = false; + return; + } + } + + _viewModel.HibpEnabled = HibpToggle.IsOn; + } + private async void CheckUpdateButton_Click(object sender, RoutedEventArgs e) { if (_viewModel is null) return; diff --git a/src/PassKey.Desktop/Views/ShellView.xaml b/src/PassKey.Desktop/Views/ShellView.xaml index 1f94f9e..548c393 100644 --- a/src/PassKey.Desktop/Views/ShellView.xaml +++ b/src/PassKey.Desktop/Views/ShellView.xaml @@ -58,12 +58,22 @@ - + + + + + + diff --git a/src/PassKey.Desktop/Views/ShellView.xaml.cs b/src/PassKey.Desktop/Views/ShellView.xaml.cs index 65ba131..f95c75b 100644 --- a/src/PassKey.Desktop/Views/ShellView.xaml.cs +++ b/src/PassKey.Desktop/Views/ShellView.xaml.cs @@ -77,17 +77,55 @@ private DashboardView SetVm(DashboardView v, DashboardViewModel vm) // Unsubscribe first to avoid duplicate handlers when Dashboard is revisited vm.NavigateToItemRequested -= OnDashboardNavigateToItem; vm.NavigateToItemRequested += OnDashboardNavigateToItem; + vm.NavigateToVerifierRequested -= OnDashboardNavigateToVerifier; + vm.NavigateToVerifierRequested += OnDashboardNavigateToVerifier; v.SetViewModel(vm); return v; } + + private void OnDashboardNavigateToVerifier() + { + // Index 6 is the "Verifica" entry per ShellViewModel.NavigateTo. NavigateTo triggers + // UpdatePageContent which constructs a fresh PasswordVerifierView; once that view is + // hosted we explicitly select the second Pivot item ("Vault") because clicking the + // health card on the Dashboard is a "show me the audit" gesture, not a "verify a + // single password" one. + _viewModel?.NavigateTo(6); + NavView.SelectedItem = NavItemVerifier; + + if (ShellContent.Content is PasswordVerifierView verifier) + verifier.SelectVaultTab(); + } private static PasswordsListView SetVm(PasswordsListView v, PasswordsListViewModel vm) { v.SetViewModel(vm); return v; } private static CreditCardsListView SetVm(CreditCardsListView v, CreditCardsListViewModel vm) { v.SetViewModel(vm); return v; } private static IdentitiesListView SetVm(IdentitiesListView v, IdentitiesListViewModel vm) { v.SetViewModel(vm); return v; } private static SecureNotesListView SetVm(SecureNotesListView v, SecureNotesListViewModel vm) { v.SetViewModel(vm); return v; } private static GeneratorView SetVm(GeneratorView v, GeneratorViewModel vm) { v.SetViewModel(vm); return v; } - private static PasswordVerifierView SetVm(PasswordVerifierView v, PasswordVerifierViewModel vm) { v.SetViewModel(vm); return v; } + private PasswordVerifierView SetVm(PasswordVerifierView v, PasswordVerifierViewModel vm) + { + // Sync the compromise badge on the Verifica nav entry whenever the scan reports + // breached passwords. Single source of truth lives in the verifier VM since the + // standalone Watchtower entry has been folded into this page in PassKey 2.0. + vm.PropertyChanged -= OnVerifierVmPropertyChanged; + vm.PropertyChanged += OnVerifierVmPropertyChanged; + UpdateVerifierBadge(vm.CompromisedCount); + v.SetViewModel(vm); + return v; + } private static HelpView SetVm(HelpView v, HelpViewModel vm) { v.SetViewModel(vm); return v; } + private void OnVerifierVmPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (sender is PasswordVerifierViewModel vm && e.PropertyName == nameof(PasswordVerifierViewModel.CompromisedCount)) + UpdateVerifierBadge(vm.CompromisedCount); + } + + private void UpdateVerifierBadge(int compromised) + { + if (FindName("VerifierBadge") is Microsoft.UI.Xaml.Controls.InfoBadge badge) + badge.Visibility = compromised > 0 ? Microsoft.UI.Xaml.Visibility.Visible : Microsoft.UI.Xaml.Visibility.Collapsed; + } + private SettingsView SetVm(SettingsView v, SettingsViewModel vm) { v.NavigateToHelpRequested += OnSettingsNavigateToHelp; diff --git a/src/PassKey.Tests/HibpServiceTests.cs b/src/PassKey.Tests/HibpServiceTests.cs new file mode 100644 index 0000000..31b8b57 --- /dev/null +++ b/src/PassKey.Tests/HibpServiceTests.cs @@ -0,0 +1,207 @@ +using System.Net; +using PassKey.Core.Services; + +namespace PassKey.Tests; + +public class HibpServiceTests +{ + // ─── ParseBreachCount (pure-function unit tests) ───────────────────────── + + [Fact] + public void ParseBreachCount_KnownSuffix_ReturnsCount() + { + // Format: SUFFIX:COUNT — body uses CRLF as HIBP's real responses do. + var body = + "1E4C9B93F3F0682250B6CF8331B7EE68FD8:3730471\r\n" + + "FC1E80FB44A9C9C90DFF7C2B72D8FCC4ED1:7\r\n" + + "ABCDEF0123456789ABCDEF0123456789ABC:42"; + + var n = HibpService.ParseBreachCount(body, "FC1E80FB44A9C9C90DFF7C2B72D8FCC4ED1"); + + Assert.Equal(7, n); + } + + [Fact] + public void ParseBreachCount_UnknownSuffix_ReturnsZero() + { + var body = "AAA0000000000000000000000000000000A:5"; + Assert.Equal(0, HibpService.ParseBreachCount(body, "BBB0000000000000000000000000000000B")); + } + + [Fact] + public void ParseBreachCount_IsCaseInsensitive() + { + // The HIBP API returns uppercase hex. Make sure we tolerate a lowercase + // local hash by chance (Convert.ToHexString returns uppercase, so this + // is belt + braces). + var body = "1E4C9B93F3F0682250B6CF8331B7EE68FD8:11\r\n"; + Assert.Equal(11, HibpService.ParseBreachCount(body, "1e4c9b93f3f0682250b6cf8331b7ee68fd8")); + } + + [Fact] + public void ParseBreachCount_BodyWithLfOnly_StillParses() + { + // Defensive — accept either CRLF or LF line endings. + var body = "AAA0:1\nBBB0:2\nCCC0:3"; + Assert.Equal(2, HibpService.ParseBreachCount(body, "BBB0")); + } + + [Fact] + public void ParseBreachCount_NoTrailingNewline_StillParses() + { + var body = "ABCD:99"; + Assert.Equal(99, HibpService.ParseBreachCount(body, "ABCD")); + } + + [Fact] + public void Sha1Hex_KnownVector_MatchesRfc3174() + { + // RFC 3174 test vector: SHA1("abc") == A9993E364706816ABA3E25717850C26C9CD0D89D + Assert.Equal("A9993E364706816ABA3E25717850C26C9CD0D89D", HibpService.Sha1Hex("abc")); + } + + [Fact] + public void Sha1Hex_EmptyString_MatchesRfc3174() + { + // RFC 3174: SHA1("") == DA39A3EE5E6B4B0D3255BFEF95601890AFD80709 + Assert.Equal("DA39A3EE5E6B4B0D3255BFEF95601890AFD80709", HibpService.Sha1Hex(string.Empty)); + } + + // ─── Network behaviour (HttpClient mocked with a fake handler) ─────────── + + [Fact] + public async Task CheckPasswordAsync_KAnonymity_SendsOnlyFiveCharPrefix() + { + // Prove that the URL request only carries 5 hex chars of the hash — the + // foundational privacy invariant of this whole feature. + string? observedUrl = null; + var handler = StubHandler.Sync((req, _) => + { + observedUrl = req.RequestUri?.AbsoluteUri; + // Return a body that matches "secret" → suffix not present (i.e. count 0). + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("0000000000000000000000000000000000A:1\r\n"), + }; + }); + var service = new HibpService(new HttpClient(handler)); + + await service.CheckPasswordAsync("hello"); + + Assert.NotNull(observedUrl); + Assert.StartsWith("https://api.pwnedpasswords.com/range/", observedUrl); + var prefix = observedUrl!["https://api.pwnedpasswords.com/range/".Length..]; + Assert.Equal(5, prefix.Length); + // Real SHA1("hello")[0..5] is "AAF4C" — assert that, to confirm we don't + // accidentally send the WHOLE hash. + Assert.Equal("AAF4C", prefix); + } + + [Fact] + public async Task CheckPasswordAsync_KnownBreachedPassword_ReturnsBreachCount() + { + // SHA1("password") = 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8 + // Prefix "5BAA6", suffix "1E4C9B93F3F0682250B6CF8331B7EE68FD8" + var handler = StubHandler.Sync((_, _) => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("1E4C9B93F3F0682250B6CF8331B7EE68FD8:9659365\r\n"), + }); + var service = new HibpService(new HttpClient(handler)); + + var count = await service.CheckPasswordAsync("password"); + + Assert.Equal(9659365, count); + } + + [Fact] + public async Task CheckPasswordAsync_NotBreached_ReturnsZero() + { + // Return a single non-matching line. + var handler = StubHandler.Sync((_, _) => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF:1\r\n"), + }); + var service = new HibpService(new HttpClient(handler)); + + var count = await service.CheckPasswordAsync("a-very-unlikely-passphrase-q9p4r2x7z"); + + Assert.Equal(0, count); + } + + [Fact] + public async Task CheckPasswordAsync_NetworkError_PropagatesException() + { + var handler = StubHandler.Sync((_, _) => throw new HttpRequestException("simulated DNS failure")); + var service = new HibpService(new HttpClient(handler)); + + await Assert.ThrowsAsync(() => + service.CheckPasswordAsync("anything")); + } + + [Fact] + public async Task CheckPasswordAsync_NonSuccessStatus_Throws() + { + var handler = StubHandler.Sync((_, _) => + new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)); + var service = new HibpService(new HttpClient(handler)); + + await Assert.ThrowsAsync(() => + service.CheckPasswordAsync("anything")); + } + + [Fact] + public async Task CheckPasswordAsync_EmptyPassword_ReturnsZeroWithoutHttpCall() + { + var called = false; + var handler = StubHandler.Sync((_, _) => + { + called = true; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(string.Empty), + }; + }); + var service = new HibpService(new HttpClient(handler)); + + var count = await service.CheckPasswordAsync(string.Empty); + + Assert.Equal(0, count); + Assert.False(called, "Empty password must short-circuit before issuing any HTTP request."); + } + + [Fact] + public async Task CheckPasswordAsync_Cancellation_PropagatesTaskCanceled() + { + var handler = new StubHandler(async (_, ct) => + { + await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + var service = new HibpService(new HttpClient(handler)); + + using var cts = new CancellationTokenSource(); + cts.CancelAfter(20); + + await Assert.ThrowsAnyAsync(() => + service.CheckPasswordAsync("anything", cts.Token)); + } + + // ─── Helper: minimal HttpMessageHandler stub ───────────────────────────── + + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func> _handler; + + public static StubHandler Sync(Func handler) + => new((req, ct) => Task.FromResult(handler(req, ct))); + + public StubHandler(Func> handler) + { + _handler = handler; + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + => _handler(request, cancellationToken); + } +}