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 %> +
+ 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. +
+