Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions docs/privacy-policy.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# PassKey Privacy Policy

**Last updated:** 2026-05-10
**Last updated:** 2026-05-20

---

Expand All @@ -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
Expand Down
101 changes: 101 additions & 0 deletions src/PassKey.Core/Services/HibpService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using System.Security.Cryptography;
using System.Text;

namespace PassKey.Core.Services;

/// <summary>
/// k-anonymity client for the Have I Been Pwned "Pwned Passwords" API.
/// </summary>
/// <remarks>
/// <para>Wire protocol:</para>
/// <list type="number">
/// <item>Compute <c>SHA1(password)</c> → 40 hex chars (uppercase, no separators).</item>
/// <item>Send the first <b>5</b> hex chars to <c>https://api.pwnedpasswords.com/range/{prefix}</c>.</item>
/// <item>Server returns plain text — one line per hash with that prefix in the form
/// <c>{suffix35}:{count}</c>.</item>
/// <item>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.</item>
/// </list>
/// <para>The full password (and its full SHA-1) never leaves the device.</para>
/// </remarks>
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;

/// <summary>Production constructor — uses the shared singleton client.</summary>
public HibpService() : this(DefaultClient)
{
}

/// <summary>Test seam — injects a <see cref="HttpClient"/> with a mock handler.</summary>
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)");
}
}

/// <inheritdoc/>
public async Task<int> 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);
}

/// <summary>Parses the line "<c>suffix:count</c>" matching the supplied suffix and returns the count.</summary>
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<char> 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;
}

/// <summary>Uppercase, no-separator SHA-1 hex digest of <paramref name="text"/> (UTF-8 bytes).</summary>
internal static string Sha1Hex(string text)
{
Span<byte> hash = stackalloc byte[20]; // SHA-1 is 160 bits
SHA1.HashData(Encoding.UTF8.GetBytes(text), hash);
return Convert.ToHexString(hash); // uppercase by contract
}
}
35 changes: 35 additions & 0 deletions src/PassKey.Core/Services/IHibpService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
namespace PassKey.Core.Services;

/// <summary>
/// Queries the Have I Been Pwned "Pwned Passwords" API
/// (<see href="https://haveibeenpwned.com/API/v3#PwnedPasswords"/>) using the
/// <b>k-anonymity</b> 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.
/// </summary>
/// <remarks>
/// <para>The free <c>api.pwnedpasswords.com</c> endpoint backs every check —
/// no API key, no auth, no rate-limit beyond a courtesy cap that the
/// <see cref="WatchtowerScanService"/> respects when scanning many entries
/// in a row.</para>
/// <para>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 <c>AppSettings.HibpEnabled</c>) and short-circuiting before
/// reaching this service when the user has not consented.</para>
/// </remarks>
public interface IHibpService
{
/// <summary>
/// Checks <paramref name="password"/> against the HIBP Pwned Passwords list.
/// </summary>
/// <param name="password">The cleartext password to check. Never leaves the device.</param>
/// <param name="cancellationToken">Optional cancellation token (e.g. timeout from a scan).</param>
/// <returns>
/// The number of distinct breaches in which the password has been observed.
/// <c>0</c> means "not seen in any known breach" (still possible the password
/// is weak — HIBP doesn't grade strength, only known compromise).
/// </returns>
/// <exception cref="HttpRequestException">Thrown on network failure / non-2xx response.</exception>
/// <exception cref="TaskCanceledException">Thrown on timeout / explicit cancellation.</exception>
Task<int> CheckPasswordAsync(string password, CancellationToken cancellationToken = default);
}
2 changes: 2 additions & 0 deletions src/PassKey.Desktop/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ public App()
services.AddSingleton<IPasswordGenerator, PasswordGenerator>();
services.AddSingleton<IPasswordStrengthAnalyzer, PasswordStrengthAnalyzer>();
services.AddSingleton<ITotpService, TotpService>();
services.AddSingleton<IHibpService, HibpService>();
services.AddSingleton<IWatchtowerScanService, WatchtowerScanService>();

// Desktop services
services.AddSingleton<INavigationStack, NavigationStack>();
Expand Down
8 changes: 8 additions & 0 deletions src/PassKey.Desktop/Services/ISettingsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ public interface ISettingsService
/// <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>
string? SkippedUpdateVersion { get; set; }

/// <summary>Gets or sets whether the user has opted in to Have I Been Pwned k-anonymity checks.
/// Default is <c>false</c> (privacy-by-default — no network traffic until explicitly enabled).</summary>
bool HibpEnabled { get; set; }

/// <summary>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.</summary>
DateTime? LastHibpScanUtc { get; set; }

/// <summary>Serialises all current settings to <c>settings.json</c> on disk.</summary>
void Save();

Expand Down
55 changes: 55 additions & 0 deletions src/PassKey.Desktop/Services/IWatchtowerScanService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using PassKey.Core.Models;

namespace PassKey.Desktop.Services;

/// <summary>
/// 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; <see cref="PassKey.Core.Services.IHibpService"/>
/// stays a pure HTTP call.
/// </summary>
public interface IWatchtowerScanService
{
/// <summary>Indicates that <see cref="ScanAsync"/> is currently running.</summary>
bool IsScanning { get; }

/// <summary>Cached result of the most recent successful scan (null until the first scan).</summary>
WatchtowerResult? LastResult { get; }

/// <summary>
/// Raised on the UI thread every time a single entry has been checked. The argument
/// is in <c>[0, 1]</c>. Useful for progress bars during long scans.
/// </summary>
event Action<double>? Progress;

/// <summary>Raised on the UI thread when the scan finishes (success, cancel, or error).</summary>
event Action<WatchtowerResult?>? Completed;

/// <summary>
/// Runs (or returns the cached value from) the full audit.
/// </summary>
/// <param name="forceRefresh">If true, ignore the 24-hour cache and rescan now.</param>
Task<WatchtowerResult?> ScanAsync(bool forceRefresh = false, CancellationToken cancellationToken = default);
}

/// <summary>Per-entry findings produced by the scan.</summary>
public sealed record WatchtowerIssue(
Guid EntryId,
string Title,
string Username,
int StrengthScore,
string StrengthLabel,
int BreachCount,
bool IsDuplicate);

/// <summary>Aggregated result of a full Watchtower scan.</summary>
public sealed record WatchtowerResult(
int TotalPasswords,
int CompromisedCount,
int WeakCount,
int DuplicateCount,
int HealthScore,
DateTime ScannedUtc,
IReadOnlyList<WatchtowerIssue> Compromised,
IReadOnlyList<WatchtowerIssue> Weak,
IReadOnlyList<WatchtowerIssue> Duplicates);
17 changes: 17 additions & 0 deletions src/PassKey.Desktop/Services/SettingsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@ public sealed class SettingsService : ISettingsService
/// <summary>Gets or sets whether automatic update checks at startup are enabled. Default is true.</summary>
public bool AutoUpdateCheckEnabled { get; set; } = true;

/// <summary>
/// Gets or sets whether PassKey is allowed to query the Have I Been Pwned "Pwned Passwords"
/// API to detect compromised passwords. Default is <see langword="false"/> — 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).
/// </summary>
public bool HibpEnabled { get; set; }

/// <summary>
/// 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.
/// </summary>
public DateTime? LastHibpScanUtc { get; set; }

/// <summary>Gets or sets the UTC timestamp of the last successful update check. Null means never checked.</summary>
public DateTime? LastUpdateCheckUtc { get; set; }

Expand Down Expand Up @@ -130,6 +145,8 @@ public void Load()
AutoUpdateCheckEnabled = loaded.AutoUpdateCheckEnabled;
LastUpdateCheckUtc = loaded.LastUpdateCheckUtc;
SkippedUpdateVersion = loaded.SkippedUpdateVersion;
HibpEnabled = loaded.HibpEnabled;
LastHibpScanUtc = loaded.LastHibpScanUtc;
}
catch
{
Expand Down
Loading
Loading