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);
+ }
+}