# 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 %>
  <div class="mt-2 rounded-md bg-yellow-50 p-4 dark:bg-yellow-900/20">
    <div class="flex">
      <div class="flex-shrink-0">
        <.icon name="hero-exclamation-triangle" class="h-5 w-5 text-yellow-400" />
      </div>
      <div class="ml-3">
        <h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
          Password found in data breaches
        </h3>
        <div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
          <p>
            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.
          </p>
        </div>
      </div>
    </div>
  </div>
<% 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/
