Skip to content
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ iex> ExPwned.breached?("abc@example.com")
true
```

#### Check if a password is breached, and how many times
```elixir
# True/False Check
iex> ExPwned.password_breached?("password123")
true

# Returns # of times password was seen in a breach. Zero if none.
iex> ExPwned.password_breach_count("password123")
5032
```

#### Check the breaches for an account

```elixir
Expand Down
31 changes: 31 additions & 0 deletions lib/ex_pwned.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ defmodule ExPwned do
ExPwned is a client library for Elixir to interact with [haveibeenpwned.com](https://haveibeenpwned.com/API/v2).
"""
alias ExPwned.Breaches
alias ExPwned.Passwords

@doc """
A convenience to check if an account has been breached or not.
Expand All @@ -20,4 +21,34 @@ defmodule ExPwned do
{:ok, %{msg: "no breach was found for given input"}, _} -> false
end
end

@doc """
Returns true if this password has been seen in a data breach on Have I Been Pwned

## Example

iex> ExPwned.password_breached?("123456")
true
iex> ExPwned.password_breached?("correcthorsebatterystaplexkcdrules")
false
"""
def password_breached?(password) do
Passwords.breached?(password)
end

@doc """
Returns the number of times a password has been seen in a data breach. It will
return zero if the password has not yet been found in a breach.

## Example

iex> ExPwned.password_breach_count("123456")
20760336
iex> ExPwned.password_breach_count("correcthorsebatterystaplexkcdrules")
0
"""
def password_breach_count(password) do
Passwords.password_breach_count(password)
end

end
64 changes: 64 additions & 0 deletions lib/ex_pwned/passwords.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
defmodule ExPwned.Passwords do
@moduledoc """
Module to interact with HIBP API to retrive breached passwords data.
"""

@doc """
Returns true|false, or {:error, "reason for error, probably HTTP/Network related"}
"""
def breached?(password) do
case password_breach_count(password) do
0 -> false
x when x > 0 -> true
{:error, error} -> {:error, error}
unexpected -> unexpected
end
end

@doc """
Returns an integer representing the number of times this password has been
seen in a breach. Can be zero, representing no breaches.
"""
def password_breach_count(password) do
{partial_hash, hash_suffix} =
password
|> hash_sha1()
|> String.split_at(5)

call_api(partial_hash, hash_suffix)
end

def call_api(partial_hash, hash_suffix) do
case HTTPoison.get("https://api.pwnedpasswords.com/range/" <> partial_hash) do
{:ok, %HTTPoison.Response{body: body, status_code: 200}} ->
handle_success(body, hash_suffix)
{:ok, %HTTPoison.Response{body: body, status_code: 429}} ->
{:error, body}
other ->
{:error, other}
end
end

def handle_success(body, hash_suffix) do
[_suffix, count] =
body
|> parse_body()
|> Enum.find(["", "0"], &matches_suffix(&1, hash_suffix)) # default: ["", "0"]

count |> String.to_integer()
end

def parse_body(body) do
body
|> String.split(~r/\r\n/, trim: true)
|> Enum.map(&String.split(&1, ":"))
end

defp matches_suffix([line_suffix, _count], search_suffix) do
line_suffix == search_suffix
end

defp hash_sha1(word) do
:crypto.hash(:sha, word) |> Base.encode16
end
end
35 changes: 35 additions & 0 deletions test/ex_pwned/passwords_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
defmodule ExPwned.PasswordsTest do
use ExUnit.Case
alias ExPwned.Passwords

test "Check known breached password" do
assert Passwords.breached?("123456")
end

test "handle_success/2 with breached passwords" do
body = "00387259BECFC8B3CB0D27EBDDC2AC93758:1\r\n00BA633D4B050924FA8228526CE0F561B38:3"
assert 1 === Passwords.handle_success(body, "00387259BECFC8B3CB0D27EBDDC2AC93758")
assert 3 === Passwords.handle_success(body, "00BA633D4B050924FA8228526CE0F561B38")
end

test "handle_success/2 with hash not found" do
body = "00387259BECFC8B3CB0D27EBDDC2AC93758:1\r\n00BA633D4B050924FA8228526CE0F561B38:3"
assert 0 === Passwords.handle_success(body, "NOTFOUND")
end

test "handle_success/2 with only one response" do
body = "00387259BECFC8B3CB0D27EBDDC2AC93758:1"
assert 1 === Passwords.handle_success(body, "00387259BECFC8B3CB0D27EBDDC2AC93758")
end

test "parse_body/1" do
body = "00387259BECFC8B3CB0D27EBDDC2AC93758:1\r\n00BA633D4B050924FA8228526CE0F561B38:3"
hash_suffix = "259BECFC8B3CB0D27EBDDC2AC93758"

result = Passwords.parse_body(body)
assert is_list(result)
assert ["00387259BECFC8B3CB0D27EBDDC2AC93758", "1"] in result
assert ["00BA633D4B050924FA8228526CE0F561B38", "3"] in result
end

end
10 changes: 7 additions & 3 deletions test/ex_pwned_test.exs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
defmodule ExPwnedTest do
use ExUnit.Case
doctest ExPwned

test "the truth" do
assert 1 + 1 == 2
test "password_breached?/1" do
assert true == ExPwned.password_breached?("123456")
end

test "password_breach_count/1" do
result = ExPwned.password_breach_count("123456")
assert result > 0
end
end