Add design document for HIBP password breach checking
This commit is contained in:
parent
2fab08f5f8
commit
43fe8cd326
1 changed files with 406 additions and 0 deletions
406
docs/plans/2026-01-31-hibp-password-breach-checking-design.md
Normal file
406
docs/plans/2026-01-31-hibp-password-breach-checking-design.md
Normal file
|
|
@ -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 %>
|
||||
<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/
|
||||
Loading…
Add table
Reference in a new issue