Email breach endpoints need no API key (2 req/s, cached), so Privacy Monitor ships enabled by default. HIBP kept as a swappable key-based alternative; provider chosen via Privacy:Provider config. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
6.4 KiB
Spec: Privacy Monitor (data-breach checking)
Checks the user's email address against known data breaches. Default provider: XposedOrNot — free, no API key required for email endpoints. HIBP is kept as a swappable alternative behind a key. Because it's free and keyless, Privacy Monitor ships enabled by default (it only ever checks the signed-in user's own address).
1. Provider details (XposedOrNot)
No auth for email endpoints. Rate limit: 2 req/sec per IP (we cache, so this is a non-issue). Two endpoints:
| Purpose | Request | Returns |
|---|---|---|
| Quick check | GET https://api.xposedornot.com/v1/check-email/{email} |
{ "breaches": [["Name1","Name2",…]], "email", "status":"success" }; or { "Error":"Not found", "email":null } when clean |
| Rich analytics (what we use) | GET https://api.xposedornot.com/v1/breach-analytics?email={email} |
BreachesSummary, ExposedBreaches (entity name, industry, risk level, exposed data types, year, record count), BreachMetrics, ExposedPastes |
We call breach-analytics to populate the rich UI. A 404/Error:"Not found"
means "no breaches" → return empty, not an error. We call the REST API directly via a
named HttpClient (consistent with the existing ollama/openai/unsubscribe
clients); the official XposedOrNot-DotNet SDK exists but we avoid the extra dependency
- audit surface.
2. Configuration
PrivacyOptions (new, Configuration/Options.cs):
public class PrivacyOptions
{
public const string SectionName = "Privacy";
public bool Enabled { get; set; } = true; // free + keyless → on by default
public string Provider { get; set; } = "XposedOrNot"; // "XposedOrNot" | "Hibp" | "None"
public string? HibpApiKey { get; set; } // only needed if Provider="Hibp"
public int CacheHours { get; set; } = 24; // don't re-check more than daily
}
appsettings.json gains a Privacy section: Enabled: true, Provider: "XposedOrNot", empty HibpApiKey.
// XposedOrNot needs no key; Hibp does.
IsEnabled => Enabled && Provider switch {
"XposedOrNot" => true,
"Hibp" => !string.IsNullOrWhiteSpace(HibpApiKey),
_ => false
};
Privacy note: this sends the user's own email address to a third-party service. That's the feature's purpose and it's the signed-in user's own address, but it stays a single config flag away from off, and we never check anyone else's address.
3. Provider abstraction
public record BreachDto(string Name, string Title, string? Domain, int? Year,
IReadOnlyList<string> DataClasses, string? RiskLevel,
string? Industry, string? LogoUrl, string? Description);
public interface IBreachProvider
{
bool IsEnabled { get; }
/// <summary>Breaches for an address; empty list if clean; throws only on hard errors.</summary>
Task<IReadOnlyList<BreachDto>> CheckAsync(string emailAddress, CancellationToken ct = default);
}
XposedOrNotBreachProvider(default) —GET /v1/breach-analytics?email={url-encoded}on the"xposedornot"named client. MapExposedBreaches.breaches_details[](breach,xposed_data→DataClasses,xposed_date/year,industry,risk→RiskLevel,logo,details→Description) intoBreachDto. TreatError:"Not found"/404as clean (empty).429→ respect backoff, return cached/empty. No key, nouser-agentrequirement.HibpBreachProvider—GET https://haveibeenpwned.com/api/v3/breachedaccount/{account}?truncateResponse=false, headerhibp-api-key, descriptiveuser-agent.404= clean,401= misconfig (log, disable),429= back off. Used only whenProvider="Hibp"+ key set.NullBreachProvider—IsEnabled => false, returns empty. Registered whenProvider="None"or the chosen provider isn't usable (mirrorsNullAiProvider).
DI selects the provider by PrivacyOptions.Provider (switch in DependencyInjection,
same shape as the AI provider selection).
3. Service
public interface IPrivacyService
{
bool IsEnabled { get; }
/// <summary>Check the signed-in user's own address. Cached per PrivacyOptions.CacheHours.</summary>
Task<PrivacyReportDto> CheckSelfAsync(Guid userId, CancellationToken ct = default);
}
public record PrivacyReportDto(string Address, bool Checked, int BreachCount,
IReadOnlyList<BreachDto> Breaches, DateTimeOffset? LastCheckedUtc);
- Resolve the user's address from
User/identity. - Cache the last result + timestamp (new
User.LastBreachCheckUtc+ a smallBreachCheck/JSON column, or a dedicated table if we later check multiple addresses). For v1, store onUser:LastBreachCheckUtc,BreachCountCached. - Respect
CacheHours: return cached unless stale orforcerequested.
4. API (PrivacyController : ApiControllerBase)
| Method | Route | Purpose |
|---|---|---|
| GET | /privacy/status |
{ enabled } so the UI can hide the feature when off |
| GET | /privacy/self |
cached report for the signed-in user |
| POST | /privacy/self/refresh |
force a fresh check (rate-limit aware) |
All scoped to UserId. We only check the authenticated user's own address in v1 —
never arbitrary addresses (avoids turning the app into a breach-lookup tool for others).
5. Frontend
- Privacy page (
/app/privacy), hidden from nav whenGET /privacy/statusis disabled. - Shows: address checked, breach count, and a card per breach (title, date, what leaked, verified badge), plus a "Re-check" button and "what this means / next steps" guidance.
- Dashboard widget (optional): a small "Privacy" tile with breach count + link, also hidden when disabled.
6. Security & safety
- Only the authenticated user's own address is ever checked (no lookup of others).
- Any API key (HIBP path) read from config/secrets, never logged, never sent to the client.
- Feature can be fully disabled via
Privacy:Enabled=falseorProvider="None"(endpoints return{ enabled:false }, nav hidden). - Rate-limit/backoff respected (XposedOrNot 2 req/s); failures degrade to cached/empty, never crash.
- Provider responses cached (
CacheHours) to minimize external calls and avoid leaking usage patterns. - Email is URL-encoded into the request path/query; no other PII is sent.