# Spec: Privacy Monitor (data-breach checking) Checks the user's email address against known data breaches. **Default provider: [XposedOrNot](https://xposedornot.com/api_doc)** — 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`): ```csharp 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`. ```csharp // 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 ```csharp public record BreachDto(string Name, string Title, string? Domain, int? Year, IReadOnlyList DataClasses, string? RiskLevel, string? Industry, string? LogoUrl, string? Description); public interface IBreachProvider { bool IsEnabled { get; } /// Breaches for an address; empty list if clean; throws only on hard errors. Task> CheckAsync(string emailAddress, CancellationToken ct = default); } ``` - **`XposedOrNotBreachProvider`** (default) — `GET /v1/breach-analytics?email={url-encoded}` on the `"xposedornot"` named client. Map `ExposedBreaches.breaches_details[]` (`breach`, `xposed_data` → `DataClasses`, `xposed_date`/`year`, `industry`, `risk` → `RiskLevel`, `logo`, `details` → `Description`) into `BreachDto`. Treat `Error:"Not found"` / `404` as clean (empty). `429` → respect backoff, return cached/empty. No key, no `user-agent` requirement. - `HibpBreachProvider` — `GET https://haveibeenpwned.com/api/v3/breachedaccount/{account}?truncateResponse=false`, header `hibp-api-key`, descriptive `user-agent`. `404` = clean, `401` = misconfig (log, disable), `429` = back off. Used only when `Provider="Hibp"` + key set. - `NullBreachProvider` — `IsEnabled => false`, returns empty. Registered when `Provider="None"` or the chosen provider isn't usable (mirrors `NullAiProvider`). DI selects the provider by `PrivacyOptions.Provider` (switch in `DependencyInjection`, same shape as the AI provider selection). ## 3. Service ```csharp public interface IPrivacyService { bool IsEnabled { get; } /// Check the signed-in user's own address. Cached per PrivacyOptions.CacheHours. Task CheckSelfAsync(Guid userId, CancellationToken ct = default); } public record PrivacyReportDto(string Address, bool Checked, int BreachCount, IReadOnlyList Breaches, DateTimeOffset? LastCheckedUtc); ``` - Resolve the user's address from `User`/identity. - Cache the last result + timestamp (new `User.LastBreachCheckUtc` + a small `BreachCheck`/JSON column, or a dedicated table if we later check multiple addresses). For v1, store on `User`: `LastBreachCheckUtc`, `BreachCountCached`. - Respect `CacheHours`: return cached unless stale or `force` requested. ## 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 when `GET /privacy/status` is 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=false` or `Provider="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.