| 1 |
|
defmodule WalletAuth.Credentials.CredentialPolicy do |
| 2 |
|
@moduledoc """ |
| 3 |
|
Password hashing and verification policy. |
| 4 |
|
|
| 5 |
|
Per ADR 0006: credentials must be stored as hashes, never plaintext. |
| 6 |
|
|
| 7 |
|
Implementation: |
| 8 |
|
- Uses PBKDF2-SHA256 via `:crypto.pbkdf2_hmac/5` (available in OTP). |
| 9 |
|
- Salt: 16 bytes of CSPRNG, stored alongside hash. |
| 10 |
|
- Format: `"pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>"`. |
| 11 |
|
|
| 12 |
|
Production note: for higher-security deployments replace with Argon2 or bcrypt |
| 13 |
|
by implementing the pluggable hasher below. The hash/3 and verify/2 functions |
| 14 |
|
accept an optional `hasher_module` for that purpose. |
| 15 |
|
|
| 16 |
|
Timing-safe comparison is used for all verification to prevent timing attacks. |
| 17 |
|
""" |
| 18 |
|
|
| 19 |
|
@iterations 100_000 |
| 20 |
|
@hash_bytes 32 |
| 21 |
|
@salt_bytes 16 |
| 22 |
|
@algorithm :sha256 |
| 23 |
|
|
| 24 |
|
@type hash_string :: String.t() |
| 25 |
|
|
| 26 |
|
@doc """ |
| 27 |
|
Hashes a password string. Returns an opaque hash string suitable for storage. |
| 28 |
|
""" |
| 29 |
|
@spec hash(password :: String.t()) :: hash_string() |
| 30 |
|
def hash(password) when is_binary(password) do |
| 31 |
2 |
salt = :crypto.strong_rand_bytes(@salt_bytes) |
| 32 |
2 |
hash_bytes = derive(password, salt) |
| 33 |
|
|
| 34 |
2 |
"pbkdf2_sha256$#{@iterations}$#{Base.encode16(salt, case: :lower)}$#{Base.encode16(hash_bytes, case: :lower)}" |
| 35 |
|
end |
| 36 |
|
|
| 37 |
|
@doc """ |
| 38 |
|
Verifies a plaintext password against a stored hash string. |
| 39 |
|
|
| 40 |
|
Returns `true` or `false`. Always runs to completion (no early exit) to |
| 41 |
|
prevent timing oracle attacks. |
| 42 |
|
""" |
| 43 |
|
@spec verify(password :: String.t(), stored_hash :: hash_string()) :: boolean() |
| 44 |
|
def verify(password, stored_hash) when is_binary(password) and is_binary(stored_hash) do |
| 45 |
2 |
with ["pbkdf2_sha256", iter_str, salt_hex, hash_hex] <- String.split(stored_hash, "$"), |
| 46 |
2 |
{iterations, ""} <- Integer.parse(iter_str), |
| 47 |
2 |
{:ok, salt} <- Base.decode16(salt_hex, case: :lower), |
| 48 |
2 |
{:ok, expected_hash} <- Base.decode16(hash_hex, case: :lower) do |
| 49 |
2 |
derived = :crypto.pbkdf2_hmac(@algorithm, password, salt, iterations, @hash_bytes) |
| 50 |
2 |
Plug.Crypto.secure_compare(derived, expected_hash) |
| 51 |
|
else |
| 52 |
|
_ -> false |
| 53 |
|
end |
| 54 |
|
end |
| 55 |
|
|
| 56 |
:-( |
def verify(_, _), do: false |
| 57 |
|
|
| 58 |
|
defp derive(password, salt) do |
| 59 |
2 |
:crypto.pbkdf2_hmac(@algorithm, password, salt, @iterations, @hash_bytes) |
| 60 |
|
end |
| 61 |
|
end |