Skip to content

Commit 4acd558

Browse files
pexatarclaude
andauthored
feat(watchtower): cluster 4 — HIBP password breach detection (k-anonymity, opt-in) (#46)
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: Giuseppe Imperato <pexatar@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b55c8fa commit 4acd558

27 files changed

Lines changed: 1801 additions & 235 deletions

docs/privacy-policy.md

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# PassKey Privacy Policy
22

3-
**Last updated:** 2026-05-10
3+
**Last updated:** 2026-05-20
44

55
---
66

@@ -21,12 +21,54 @@ PassKey does not collect, store, or transmit any personal data to external serve
2121

2222
## Network Activity
2323

24-
PassKey makes **zero** network connections. The only local communication that occurs is:
24+
By default, PassKey makes **zero** network connections. The only local communication that occurs is:
2525

2626
- **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.
2727

2828
There is no analytics, no telemetry, no crash reporting, and no update checking.
2929

30+
The **only** circumstance in which PassKey contacts an external server is the optional, opt-in
31+
compromised-password check described in the next section. It is disabled by default and never
32+
runs unless you explicitly enable it.
33+
34+
---
35+
36+
## Compromised Password Check (optional, opt-in)
37+
38+
PassKey can tell you whether any of your stored passwords have appeared in a known data breach.
39+
This feature is **disabled by default** and is only ever activated when you explicitly turn on
40+
the **"Check for compromised passwords"** setting in *Settings → Security*.
41+
42+
### How it works — k-anonymity
43+
44+
When enabled, PassKey queries the [Have I Been Pwned](https://haveibeenpwned.com/) Pwned Passwords
45+
service using the **k-anonymity model**, which is designed so that **your password is never sent**:
46+
47+
1. PassKey computes the SHA-1 hash of a password locally, on your device.
48+
2. Only the **first 5 hexadecimal characters** of that hash are sent to `api.pwnedpasswords.com`.
49+
3. The service returns the list of all breached-hash suffixes that share those 5 characters.
50+
4. PassKey compares that list against the remaining hash characters **locally** — the server
51+
never learns which password, or even which full hash, you were checking.
52+
53+
This means the breach service cannot identify your passwords, your account, or you. No API key
54+
is required and no personal identifier is transmitted.
55+
56+
### What is and isn't sent
57+
58+
| Sent to the breach service | Never sent |
59+
|----------------------------|------------|
60+
| The first 5 characters of a password's SHA-1 hash | The password itself |
61+
| | The full SHA-1 hash |
62+
| | Usernames, URLs, or entry titles |
63+
| | Any device or account identifier |
64+
65+
### Your control
66+
67+
- The feature is **off until you turn it on**, and turning it off immediately stops all such requests.
68+
- Requests are made over HTTPS with a short timeout; if the service is unreachable, the check
69+
fails gracefully and no data is retried or queued.
70+
- No results are shared with anyone — breach findings are displayed only inside the app.
71+
3072
---
3173

3274
## Browser Extension
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
using System.Security.Cryptography;
2+
using System.Text;
3+
4+
namespace PassKey.Core.Services;
5+
6+
/// <summary>
7+
/// k-anonymity client for the Have I Been Pwned "Pwned Passwords" API.
8+
/// </summary>
9+
/// <remarks>
10+
/// <para>Wire protocol:</para>
11+
/// <list type="number">
12+
/// <item>Compute <c>SHA1(password)</c> → 40 hex chars (uppercase, no separators).</item>
13+
/// <item>Send the first <b>5</b> hex chars to <c>https://api.pwnedpasswords.com/range/{prefix}</c>.</item>
14+
/// <item>Server returns plain text — one line per hash with that prefix in the form
15+
/// <c>{suffix35}:{count}</c>.</item>
16+
/// <item>Search the response locally for the 35-char suffix of our hash. The matching
17+
/// line's count is the number of breaches; absence means 0.</item>
18+
/// </list>
19+
/// <para>The full password (and its full SHA-1) never leaves the device.</para>
20+
/// </remarks>
21+
public sealed class HibpService : IHibpService
22+
{
23+
private const string BaseAddress = "https://api.pwnedpasswords.com/range/";
24+
private const int PrefixLength = 5;
25+
26+
// Static singleton HttpClient — same pattern as UpdateService. HttpClient is
27+
// designed to be reused across calls (DNS / TCP pooling, thread-safe). Disposing
28+
// per-call would exhaust ephemeral ports under load.
29+
private static readonly HttpClient DefaultClient = new()
30+
{
31+
Timeout = TimeSpan.FromSeconds(5),
32+
};
33+
34+
private readonly HttpClient _http;
35+
36+
/// <summary>Production constructor — uses the shared singleton client.</summary>
37+
public HibpService() : this(DefaultClient)
38+
{
39+
}
40+
41+
/// <summary>Test seam — injects a <see cref="HttpClient"/> with a mock handler.</summary>
42+
public HibpService(HttpClient http)
43+
{
44+
_http = http ?? throw new ArgumentNullException(nameof(http));
45+
// Identify the client per HIBP courtesy guidance. This is the only header sent.
46+
if (!_http.DefaultRequestHeaders.UserAgent.Any())
47+
{
48+
_http.DefaultRequestHeaders.UserAgent.ParseAdd("PassKey/2.0 (+https://pass-key.it)");
49+
}
50+
}
51+
52+
/// <inheritdoc/>
53+
public async Task<int> CheckPasswordAsync(string password, CancellationToken cancellationToken = default)
54+
{
55+
if (string.IsNullOrEmpty(password)) return 0;
56+
57+
var hash = Sha1Hex(password);
58+
var prefix = hash[..PrefixLength];
59+
var suffix = hash[PrefixLength..];
60+
61+
var url = BaseAddress + prefix;
62+
using var response = await _http.GetAsync(url, cancellationToken).ConfigureAwait(false);
63+
response.EnsureSuccessStatusCode();
64+
65+
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
66+
return ParseBreachCount(body, suffix);
67+
}
68+
69+
/// <summary>Parses the line "<c>suffix:count</c>" matching the supplied suffix and returns the count.</summary>
70+
internal static int ParseBreachCount(string body, string suffix)
71+
{
72+
// Iterate lines without allocating arrays for each token. The HIBP response
73+
// averages ~500 lines (~30 KB) — small but worth avoiding the LINQ overhead.
74+
var span = body.AsSpan();
75+
while (true)
76+
{
77+
var newline = span.IndexOf('\n');
78+
ReadOnlySpan<char> line = newline < 0 ? span : span[..newline];
79+
if (line.Length > 0 && line[^1] == '\r') line = line[..^1];
80+
81+
var colon = line.IndexOf(':');
82+
if (colon == suffix.Length && line[..colon].Equals(suffix, StringComparison.OrdinalIgnoreCase))
83+
{
84+
if (int.TryParse(line[(colon + 1)..], out var count))
85+
return count;
86+
}
87+
88+
if (newline < 0) break;
89+
span = span[(newline + 1)..];
90+
}
91+
return 0;
92+
}
93+
94+
/// <summary>Uppercase, no-separator SHA-1 hex digest of <paramref name="text"/> (UTF-8 bytes).</summary>
95+
internal static string Sha1Hex(string text)
96+
{
97+
Span<byte> hash = stackalloc byte[20]; // SHA-1 is 160 bits
98+
SHA1.HashData(Encoding.UTF8.GetBytes(text), hash);
99+
return Convert.ToHexString(hash); // uppercase by contract
100+
}
101+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
namespace PassKey.Core.Services;
2+
3+
/// <summary>
4+
/// Queries the Have I Been Pwned "Pwned Passwords" API
5+
/// (<see href="https://haveibeenpwned.com/API/v3#PwnedPasswords"/>) using the
6+
/// <b>k-anonymity</b> protocol: only the first 5 hex characters of the SHA-1
7+
/// hash of the password ever leave the device, so HIBP can never identify the
8+
/// password being checked.
9+
/// </summary>
10+
/// <remarks>
11+
/// <para>The free <c>api.pwnedpasswords.com</c> endpoint backs every check —
12+
/// no API key, no auth, no rate-limit beyond a courtesy cap that the
13+
/// <see cref="WatchtowerScanService"/> respects when scanning many entries
14+
/// in a row.</para>
15+
/// <para>Privacy: this service issues network requests. PassKey is offline-first
16+
/// by default — the caller is responsible for honouring the user's opt-in
17+
/// preference (see <c>AppSettings.HibpEnabled</c>) and short-circuiting before
18+
/// reaching this service when the user has not consented.</para>
19+
/// </remarks>
20+
public interface IHibpService
21+
{
22+
/// <summary>
23+
/// Checks <paramref name="password"/> against the HIBP Pwned Passwords list.
24+
/// </summary>
25+
/// <param name="password">The cleartext password to check. Never leaves the device.</param>
26+
/// <param name="cancellationToken">Optional cancellation token (e.g. timeout from a scan).</param>
27+
/// <returns>
28+
/// The number of distinct breaches in which the password has been observed.
29+
/// <c>0</c> means "not seen in any known breach" (still possible the password
30+
/// is weak — HIBP doesn't grade strength, only known compromise).
31+
/// </returns>
32+
/// <exception cref="HttpRequestException">Thrown on network failure / non-2xx response.</exception>
33+
/// <exception cref="TaskCanceledException">Thrown on timeout / explicit cancellation.</exception>
34+
Task<int> CheckPasswordAsync(string password, CancellationToken cancellationToken = default);
35+
}

src/PassKey.Desktop/App.xaml.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ public App()
3232
services.AddSingleton<IPasswordGenerator, PasswordGenerator>();
3333
services.AddSingleton<IPasswordStrengthAnalyzer, PasswordStrengthAnalyzer>();
3434
services.AddSingleton<ITotpService, TotpService>();
35+
services.AddSingleton<IHibpService, HibpService>();
36+
services.AddSingleton<IWatchtowerScanService, WatchtowerScanService>();
3537

3638
// Desktop services
3739
services.AddSingleton<INavigationStack, NavigationStack>();

src/PassKey.Desktop/Services/ISettingsService.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ public interface ISettingsService
5353
/// <summary>Gets or sets the release tag the user chose to skip (e.g. <c>"v1.0.5"</c>). <c>null</c> means no version has been skipped.</summary>
5454
string? SkippedUpdateVersion { get; set; }
5555

56+
/// <summary>Gets or sets whether the user has opted in to Have I Been Pwned k-anonymity checks.
57+
/// Default is <c>false</c> (privacy-by-default — no network traffic until explicitly enabled).</summary>
58+
bool HibpEnabled { get; set; }
59+
60+
/// <summary>Gets or sets the UTC timestamp of the last successful Watchtower scan.
61+
/// Used to anchor the 24-hour cache that prevents re-scanning every navigation.</summary>
62+
DateTime? LastHibpScanUtc { get; set; }
63+
5664
/// <summary>Serialises all current settings to <c>settings.json</c> on disk.</summary>
5765
void Save();
5866

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using PassKey.Core.Models;
2+
3+
namespace PassKey.Desktop.Services;
4+
5+
/// <summary>
6+
/// Runs a full-vault audit combining the local strength analyser and the remote
7+
/// Have I Been Pwned k-anonymity check. The service is the orchestrator: throttling,
8+
/// caching, and progress reporting live here; <see cref="PassKey.Core.Services.IHibpService"/>
9+
/// stays a pure HTTP call.
10+
/// </summary>
11+
public interface IWatchtowerScanService
12+
{
13+
/// <summary>Indicates that <see cref="ScanAsync"/> is currently running.</summary>
14+
bool IsScanning { get; }
15+
16+
/// <summary>Cached result of the most recent successful scan (null until the first scan).</summary>
17+
WatchtowerResult? LastResult { get; }
18+
19+
/// <summary>
20+
/// Raised on the UI thread every time a single entry has been checked. The argument
21+
/// is in <c>[0, 1]</c>. Useful for progress bars during long scans.
22+
/// </summary>
23+
event Action<double>? Progress;
24+
25+
/// <summary>Raised on the UI thread when the scan finishes (success, cancel, or error).</summary>
26+
event Action<WatchtowerResult?>? Completed;
27+
28+
/// <summary>
29+
/// Runs (or returns the cached value from) the full audit.
30+
/// </summary>
31+
/// <param name="forceRefresh">If true, ignore the 24-hour cache and rescan now.</param>
32+
Task<WatchtowerResult?> ScanAsync(bool forceRefresh = false, CancellationToken cancellationToken = default);
33+
}
34+
35+
/// <summary>Per-entry findings produced by the scan.</summary>
36+
public sealed record WatchtowerIssue(
37+
Guid EntryId,
38+
string Title,
39+
string Username,
40+
int StrengthScore,
41+
string StrengthLabel,
42+
int BreachCount,
43+
bool IsDuplicate);
44+
45+
/// <summary>Aggregated result of a full Watchtower scan.</summary>
46+
public sealed record WatchtowerResult(
47+
int TotalPasswords,
48+
int CompromisedCount,
49+
int WeakCount,
50+
int DuplicateCount,
51+
int HealthScore,
52+
DateTime ScannedUtc,
53+
IReadOnlyList<WatchtowerIssue> Compromised,
54+
IReadOnlyList<WatchtowerIssue> Weak,
55+
IReadOnlyList<WatchtowerIssue> Duplicates);

src/PassKey.Desktop/Services/SettingsService.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,21 @@ public sealed class SettingsService : ISettingsService
7070
/// <summary>Gets or sets whether automatic update checks at startup are enabled. Default is true.</summary>
7171
public bool AutoUpdateCheckEnabled { get; set; } = true;
7272

73+
/// <summary>
74+
/// Gets or sets whether PassKey is allowed to query the Have I Been Pwned "Pwned Passwords"
75+
/// API to detect compromised passwords. Default is <see langword="false"/> — the user must
76+
/// opt in explicitly because this is the only feature that issues network requests
77+
/// (privacy-by-default for an otherwise offline-first vault). Only the first 5 hex chars
78+
/// of the SHA-1 hash are ever transmitted (k-anonymity).
79+
/// </summary>
80+
public bool HibpEnabled { get; set; }
81+
82+
/// <summary>
83+
/// Gets or sets the UTC timestamp of the last successful Watchtower full-vault scan.
84+
/// Used to decide when the 24-hour cache must be invalidated. Null means never scanned.
85+
/// </summary>
86+
public DateTime? LastHibpScanUtc { get; set; }
87+
7388
/// <summary>Gets or sets the UTC timestamp of the last successful update check. Null means never checked.</summary>
7489
public DateTime? LastUpdateCheckUtc { get; set; }
7590

@@ -130,6 +145,8 @@ public void Load()
130145
AutoUpdateCheckEnabled = loaded.AutoUpdateCheckEnabled;
131146
LastUpdateCheckUtc = loaded.LastUpdateCheckUtc;
132147
SkippedUpdateVersion = loaded.SkippedUpdateVersion;
148+
HibpEnabled = loaded.HibpEnabled;
149+
LastHibpScanUtc = loaded.LastHibpScanUtc;
133150
}
134151
catch
135152
{

0 commit comments

Comments
 (0)