Skip to content

Commit b21513c

Browse files
Grantimus9techgaun
authored andcommitted
Adds Support For PwnedPasswords API (#3)
* first draft of passwords module implementing the password range (with k-anonymity) HIBP endpoint * tests and docs written for passwords moduel * added more documentation * added readme documentation of newest top-level API methods * typo in documentation fix. * added top-level function tests
1 parent d035001 commit b21513c

File tree

5 files changed

+148
-3
lines changed

5 files changed

+148
-3
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,17 @@ iex> ExPwned.breached?("abc@example.com")
4444
true
4545
```
4646

47+
#### Check if a password is breached, and how many times
48+
```elixir
49+
# True/False Check
50+
iex> ExPwned.password_breached?("password123")
51+
true
52+
53+
# Returns # of times password was seen in a breach. Zero if none.
54+
iex> ExPwned.password_breach_count("password123")
55+
5032
56+
```
57+
4758
#### Check the breaches for an account
4859

4960
```elixir

lib/ex_pwned.ex

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ defmodule ExPwned do
33
ExPwned is a client library for Elixir to interact with [haveibeenpwned.com](https://haveibeenpwned.com/API/v2).
44
"""
55
alias ExPwned.Breaches
6+
alias ExPwned.Passwords
67

78
@doc """
89
A convenience to check if an account has been breached or not.
@@ -20,4 +21,34 @@ defmodule ExPwned do
2021
{:ok, %{msg: "no breach was found for given input"}, _} -> false
2122
end
2223
end
24+
25+
@doc """
26+
Returns true if this password has been seen in a data breach on Have I Been Pwned
27+
28+
## Example
29+
30+
iex> ExPwned.password_breached?("123456")
31+
true
32+
iex> ExPwned.password_breached?("correcthorsebatterystaplexkcdrules")
33+
false
34+
"""
35+
def password_breached?(password) do
36+
Passwords.breached?(password)
37+
end
38+
39+
@doc """
40+
Returns the number of times a password has been seen in a data breach. It will
41+
return zero if the password has not yet been found in a breach.
42+
43+
## Example
44+
45+
iex> ExPwned.password_breach_count("123456")
46+
20760336
47+
iex> ExPwned.password_breach_count("correcthorsebatterystaplexkcdrules")
48+
0
49+
"""
50+
def password_breach_count(password) do
51+
Passwords.password_breach_count(password)
52+
end
53+
2354
end

lib/ex_pwned/passwords.ex

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
defmodule ExPwned.Passwords do
2+
@moduledoc """
3+
Module to interact with HIBP API to retrive breached passwords data.
4+
"""
5+
6+
@doc """
7+
Returns true|false, or {:error, "reason for error, probably HTTP/Network related"}
8+
"""
9+
def breached?(password) do
10+
case password_breach_count(password) do
11+
0 -> false
12+
x when x > 0 -> true
13+
{:error, error} -> {:error, error}
14+
unexpected -> unexpected
15+
end
16+
end
17+
18+
@doc """
19+
Returns an integer representing the number of times this password has been
20+
seen in a breach. Can be zero, representing no breaches.
21+
"""
22+
def password_breach_count(password) do
23+
{partial_hash, hash_suffix} =
24+
password
25+
|> hash_sha1()
26+
|> String.split_at(5)
27+
28+
call_api(partial_hash, hash_suffix)
29+
end
30+
31+
def call_api(partial_hash, hash_suffix) do
32+
case HTTPoison.get("https://api.pwnedpasswords.com/range/" <> partial_hash) do
33+
{:ok, %HTTPoison.Response{body: body, status_code: 200}} ->
34+
handle_success(body, hash_suffix)
35+
{:ok, %HTTPoison.Response{body: body, status_code: 429}} ->
36+
{:error, body}
37+
other ->
38+
{:error, other}
39+
end
40+
end
41+
42+
def handle_success(body, hash_suffix) do
43+
[_suffix, count] =
44+
body
45+
|> parse_body()
46+
|> Enum.find(["", "0"], &matches_suffix(&1, hash_suffix)) # default: ["", "0"]
47+
48+
count |> String.to_integer()
49+
end
50+
51+
def parse_body(body) do
52+
body
53+
|> String.split(~r/\r\n/, trim: true)
54+
|> Enum.map(&String.split(&1, ":"))
55+
end
56+
57+
defp matches_suffix([line_suffix, _count], search_suffix) do
58+
line_suffix == search_suffix
59+
end
60+
61+
defp hash_sha1(word) do
62+
:crypto.hash(:sha, word) |> Base.encode16
63+
end
64+
end

test/ex_pwned/passwords_test.exs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
defmodule ExPwned.PasswordsTest do
2+
use ExUnit.Case
3+
alias ExPwned.Passwords
4+
5+
test "Check known breached password" do
6+
assert Passwords.breached?("123456")
7+
end
8+
9+
test "handle_success/2 with breached passwords" do
10+
body = "00387259BECFC8B3CB0D27EBDDC2AC93758:1\r\n00BA633D4B050924FA8228526CE0F561B38:3"
11+
assert 1 === Passwords.handle_success(body, "00387259BECFC8B3CB0D27EBDDC2AC93758")
12+
assert 3 === Passwords.handle_success(body, "00BA633D4B050924FA8228526CE0F561B38")
13+
end
14+
15+
test "handle_success/2 with hash not found" do
16+
body = "00387259BECFC8B3CB0D27EBDDC2AC93758:1\r\n00BA633D4B050924FA8228526CE0F561B38:3"
17+
assert 0 === Passwords.handle_success(body, "NOTFOUND")
18+
end
19+
20+
test "handle_success/2 with only one response" do
21+
body = "00387259BECFC8B3CB0D27EBDDC2AC93758:1"
22+
assert 1 === Passwords.handle_success(body, "00387259BECFC8B3CB0D27EBDDC2AC93758")
23+
end
24+
25+
test "parse_body/1" do
26+
body = "00387259BECFC8B3CB0D27EBDDC2AC93758:1\r\n00BA633D4B050924FA8228526CE0F561B38:3"
27+
hash_suffix = "259BECFC8B3CB0D27EBDDC2AC93758"
28+
29+
result = Passwords.parse_body(body)
30+
assert is_list(result)
31+
assert ["00387259BECFC8B3CB0D27EBDDC2AC93758", "1"] in result
32+
assert ["00BA633D4B050924FA8228526CE0F561B38", "3"] in result
33+
end
34+
35+
end

test/ex_pwned_test.exs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
defmodule ExPwnedTest do
22
use ExUnit.Case
3-
doctest ExPwned
43

5-
test "the truth" do
6-
assert 1 + 1 == 2
4+
test "password_breached?/1" do
5+
assert true == ExPwned.password_breached?("123456")
6+
end
7+
8+
test "password_breach_count/1" do
9+
result = ExPwned.password_breach_count("123456")
10+
assert result > 0
711
end
812
end

0 commit comments

Comments
 (0)