diff --git a/docs/plans/2026-01-31-hibp-password-breach-checking-design.md b/docs/plans/2026-01-31-hibp-password-breach-checking-design.md new file mode 100644 index 00000000..965fbedd --- /dev/null +++ b/docs/plans/2026-01-31-hibp-password-breach-checking-design.md @@ -0,0 +1,406 @@ +# Have I Been Pwned Password Breach Checking + +**Date:** 2026-01-31 +**Status:** Approved + +## Overview + +Integrate Have I Been Pwned (HIBP) API to check user passwords against known data breaches. Provides real-time warnings when users enter compromised passwords during registration, password reset, and password changes. + +## Requirements + +- Check passwords against HIBP Pwned Passwords API +- Trigger check when user leaves password field (blur event) +- Show warning if password found in breaches, but allow user to proceed +- Apply to all password entry points: registration, reset, settings +- Backend-only implementation (no frontend API calls) +- Privacy-preserving k-anonymity approach +- Fail open on API errors (never block users due to API issues) + +## Architecture + +### Have I Been Pwned k-Anonymity API + +The HIBP Passwords API uses k-anonymity to protect user privacy: + +1. Hash the password with SHA-1 +2. Send only the first 5 characters of the hash to the API +3. API returns all hash suffixes that match that prefix (with breach counts) +4. Check locally if the full hash appears in the results + +**Example flow:** +``` +Password: "password123" +SHA-1 hash: "482C811DA5D5B4BC6D497FFA98491E38" +Send to API: "482C8" (first 5 chars) +Receive: List of hash suffixes + breach counts +Check if "11DA5D5B4BC6D497FFA98491E38" is in results +Result: Found with count = 123,456 +``` + +**API Endpoint:** `https://api.pwnedpasswords.com/range/{hash-prefix}` + +**Response format:** +``` +11DA5D5B4BC6D497FFA98491E38:123456 +21AB3C4D5E6F7890ABCDEF123456:789 +... +``` + +### LiveView Integration + +**On blur event flow:** + +1. User types password in field and tabs away (blur event) +2. LiveView sends `phx-blur="check_password_breach"` event to server +3. Server extracts password from params +4. Server calls `HIBP.check_password/1` +5. HIBP module hashes password, calls API, parses response +6. Server updates socket assigns with `password_breach_count` +7. LiveView re-renders, warning banner appears if count > 0 +8. User can still submit form (warning only, not blocking) + +**Where it applies:** + +- User registration (convert controller to LiveView) +- Password reset (convert controller to LiveView) +- User settings password change (already LiveView) + +## Technical Implementation + +### New Module: `lib/towerops/accounts/hibp.ex` + +```elixir +defmodule Towerops.Accounts.HIBP do + @moduledoc """ + Have I Been Pwned password breach checking using k-anonymity API. + + Uses the HIBP Pwned Passwords API v3 to check if a password appears + in known data breaches. Implements k-anonymity by only sending the + first 5 characters of the SHA-1 hash to the API. + + See: https://haveibeenpwned.com/API/v3#PwnedPasswords + """ + + require Logger + + @api_url "https://api.pwnedpasswords.com/range/" + @timeout 5_000 # 5 second timeout + + @doc """ + Checks if a password appears in known data breaches. + + Returns: + - `{:ok, 0}` - Password not found in breaches + - `{:ok, count}` - Password found in `count` breaches + - `{:ok, :unknown}` - API error, status unknown (fail open) + + ## Examples + + iex> check_password("correct-horse-battery-staple") + {:ok, 0} + + iex> check_password("password123") + {:ok, 123456} + """ + def check_password(password) when is_binary(password) and byte_size(password) > 0 do + hash = hash_password(password) + prefix = String.slice(hash, 0, 5) + suffix = String.slice(hash, 5..-1) + + case fetch_breaches(prefix) do + {:ok, results} -> check_suffix(results, suffix) + {:error, reason} -> + Logger.warning("HIBP API error: #{inspect(reason)}") + {:ok, :unknown} # Fail open on API errors + end + end + + def check_password(_), do: {:ok, :unknown} + + # Hash password to SHA-1 (HIBP uses SHA-1, not for security but for API compatibility) + defp hash_password(password) do + :crypto.hash(:sha, password) + |> Base.encode16() + |> String.upcase() + end + + # Fetch breaches from HIBP API + defp fetch_breaches(prefix) do + url = @api_url <> prefix + + case Req.get(url, receive_timeout: @timeout) do + {:ok, %Req.Response{status: 200, body: body}} -> + {:ok, body} + + {:ok, %Req.Response{status: 429}} -> + {:error, :rate_limited} + + {:ok, %Req.Response{status: status}} -> + {:error, {:http_error, status}} + + {:error, reason} -> + {:error, reason} + end + end + + # Parse API response and check if suffix exists + defp check_suffix(results, suffix) do + results + |> String.split("\r\n", trim: true) + |> Enum.find_value({:ok, 0}, fn line -> + case String.split(line, ":") do + [hash_suffix, count] when hash_suffix == suffix -> + {:ok, String.to_integer(count)} + _ -> + nil + end + end) + end +end +``` + +### Schema Changes: `lib/towerops/accounts/user.ex` + +Add virtual field to store breach count: + +```elixir +schema "users" do + # ... existing fields ... + field :password_breach_count, :integer, virtual: true +end +``` + +### Changeset Integration + +The breach check will be called in LiveView event handlers, not in the changeset itself. The changeset remains unchanged - breach status is stored in socket assigns, not in the changeset. + +**Rationale:** Keeping the check outside the changeset allows us to: +- Control when the API is called (only on blur, not on every keystroke) +- Handle async API calls without blocking changeset validation +- Show warnings without failing validation + +### LiveView Conversion + +**New LiveView: `lib/towerops_web/live/user_registration_live.ex`** + +Convert existing controller to LiveView: + +```elixir +defmodule ToweropsWeb.UserRegistrationLive do + use ToweropsWeb, :live_view + + alias Towerops.Accounts + alias Towerops.Accounts.HIBP + + def mount(params, _session, socket) do + invitation_token = params["invitation_token"] || params["token"] + invitation = invitation_token && Organizations.get_invitation_by_token(invitation_token) + + changeset = Accounts.change_user_registration(%User{}) + + {:ok, + socket + |> assign(:form, to_form(changeset)) + |> assign(:invitation, invitation) + |> assign(:invitation_token, invitation_token) + |> assign(:password_breach_count, nil)} + end + + def handle_event("check_password_breach", %{"user" => %{"password" => password}}, socket) do + case HIBP.check_password(password) do + {:ok, count} when is_integer(count) -> + {:noreply, assign(socket, :password_breach_count, count)} + {:ok, :unknown} -> + {:noreply, assign(socket, :password_breach_count, nil)} + end + end + + def handle_event("validate", %{"user" => user_params}, socket) do + changeset = + %User{} + |> Accounts.change_user_registration(user_params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :form, to_form(changeset))} + end + + def handle_event("save", %{"user" => user_params}, socket) do + # Registration logic (with/without invitation) + end +end +``` + +**New LiveView: `lib/towerops_web/live/user_reset_password_live.ex`** + +Similar conversion for password reset controller. + +**Update existing: `lib/towerops_web/live/user_settings_live.ex`** + +Add breach checking to existing password change functionality. + +### Template Changes + +Add `phx-blur` event to password input and warning banner: + +```heex +<.input + field={@form[:password]} + type="password" + label="Password" + phx-blur="check_password_breach" + required +/> + +<%= if @password_breach_count && @password_breach_count > 0 do %> +
+
+
+ <.icon name="hero-exclamation-triangle" class="h-5 w-5 text-yellow-400" /> +
+
+

+ Password found in data breaches +

+
+

+ This password has appeared <%= @password_breach_count %> + <%= if @password_breach_count == 1, do: "time", else: "times" %> + in known data breaches. We strongly recommend choosing a different, + more unique password to protect your account. +

+
+
+
+
+<% end %> +``` + +### Router Changes + +Update routes in `lib/towerops_web/router.ex`: + +```elixir +# Old (controllers) +get "/users/register", UserRegistrationController, :new +post "/users/register", UserRegistrationController, :create +get "/users/reset_password/:token", UserResetPasswordController, :edit +put "/users/reset_password/:token", UserResetPasswordController, :update + +# New (LiveView) +live "/users/register", UserRegistrationLive, :new +live "/users/reset_password/:token", UserResetPasswordLive, :edit +``` + +User settings is already LiveView, no route changes needed. + +## Error Handling + +**Fail Open Philosophy:** API failures should never block users from registering or changing passwords. + +**Error scenarios:** + +- **API timeout (> 5 seconds)** → Return `{:ok, :unknown}`, no warning shown +- **Network error** → Return `{:ok, :unknown}`, no warning shown +- **Rate limiting (429)** → Return `{:ok, :unknown}`, no warning shown +- **Malformed API response** → Return `{:ok, :unknown}`, no warning shown +- **HIBP service down (500/503)** → Return `{:ok, :unknown}`, no warning shown + +**Logging:** All API errors logged at `:warning` level for monitoring, but never exposed to user. + +## Edge Cases + +| Scenario | Behavior | +|----------|----------| +| Empty password on blur | Don't call API, wait for form validation | +| Very long password | SHA-1 handles any length, no special handling | +| Unicode/special characters | SHA-1 works on binary, handles all UTF-8 | +| API returns malformed data | Log error, fail open, allow password | +| Rapid blur events | Each triggers separate API call (acceptable) | +| User changes password after warning | Next blur clears old warning, checks new password | +| Password field cleared after blur | Warning remains until next blur or form submit | + +## Privacy & Security + +**Privacy Considerations:** + +- Password **never** sent to HIBP in plaintext +- Only first 5 characters of SHA-1 hash sent (k-anonymity) +- No logging of actual passwords or full hashes in application logs +- API calls don't include user identification (no cookies, no IP tracking) + +**Security Notes:** + +- SHA-1 used for HIBP compatibility (not for password storage) +- Passwords still hashed with Argon2 for database storage +- Breach check is advisory only, doesn't prevent weak passwords meeting length requirements +- Consider adding additional password strength requirements in future + +## Testing Strategy + +### Unit Tests: `test/towerops/accounts/hibp_test.exs` + +```elixir +defmodule Towerops.Accounts.HIBPTest do + use ExUnit.Case, async: true + + describe "check_password/1" do + test "returns breach count for compromised password" + test "returns 0 for clean password" + test "handles API timeout gracefully" + test "handles network errors gracefully" + test "handles rate limiting gracefully" + test "handles malformed API response" + test "only sends 5-character hash prefix" + test "handles unicode passwords" + end +end +``` + +Use Mox to mock Req HTTP calls. + +### LiveView Tests + +```elixir +test "shows warning when password is breached" +test "hides warning when password is clean" +test "clears warning when password changed" +test "form submits even with breached password" +test "API failure doesn't block registration" +test "blur event triggers breach check" +``` + +### Integration Tests + +- Test complete registration flow with breached password +- Test password reset with breached password +- Test password change in settings with breached password +- Verify all warnings appear correctly +- Verify form submission works in all cases + +## Implementation Tasks + +1. Create `HIBP` module with k-anonymity implementation +2. Add virtual field to `User` schema +3. Convert `UserRegistrationController` to LiveView +4. Convert `UserResetPasswordController` to LiveView +5. Update `UserSettingsLive` with breach checking +6. Update templates with blur events and warning banners +7. Update routes +8. Write comprehensive tests +9. Manual testing with known breached passwords + +## Future Enhancements + +- Add password strength meter (zxcvbn library) +- Require minimum password entropy score +- Track breach check metrics (how often users hit warnings) +- Cache API responses temporarily (5-minute TTL) +- Add "Show password" toggle for better UX + +## References + +- HIBP API Documentation: https://haveibeenpwned.com/API/v3#PwnedPasswords +- k-Anonymity Explanation: https://www.troyhunt.com/ive-just-launched-pwned-passwords-version-2/ +- Phoenix LiveView: https://hexdocs.pm/phoenix_live_view/ +- Req HTTP Client: https://hexdocs.pm/req/