use std::num::NonZeroU32;
use std::sync::LazyLock;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL_SAFE_NO_PAD;
use ring::pbkdf2;
use ring::rand::{SecureRandom, SystemRandom};
const ALGORITHM: &str = "pbkdf2-sha256";
const ITERATIONS: u32 = 600_000;
const SALT_LEN: usize = 16;
const HASH_LEN: usize = 32;
pub const MIN_PASSWORD_LEN: usize = 12;
pub const MAX_PASSWORD_LEN: usize = 1024;
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
pub enum PasswordError {
#[error("stored password hash is not in the expected format")]
Malformed,
#[error("unknown password hash algorithm `{0}`")]
UnknownAlgorithm(String),
#[error("stored password hash has an invalid iteration count")]
BadIterations,
#[error("stored password hash has an invalid salt or digest")]
BadEncoding,
}
pub fn check_password_policy(password: &str) -> Result<(), String> {
let length = password.chars().count();
if length < MIN_PASSWORD_LEN {
return Err(format!(
"password must be at least {MIN_PASSWORD_LEN} characters (got {length})"
));
}
if password.len() > MAX_PASSWORD_LEN {
return Err(format!(
"password must be at most {MAX_PASSWORD_LEN} bytes (got {})",
password.len()
));
}
Ok(())
}
#[must_use]
pub fn hash_password(password: &str) -> String {
hash_with_iterations(password, ITERATIONS)
}
#[must_use]
pub fn hash_generated_secret(secret: &str) -> String {
hash_with_iterations(secret, RECOVERY_ITERATIONS)
}
pub const RECOVERY_ITERATIONS: u32 = 10_000;
fn hash_with_iterations(password: &str, iterations: u32) -> String {
let mut salt = [0u8; SALT_LEN];
SystemRandom::new()
.fill(&mut salt)
.expect("system RNG unavailable");
encode(&salt, &derive(password, &salt, iterations), iterations)
}
pub fn verify_password(stored: &str, password: &str) -> Result<bool, PasswordError> {
let (iterations, salt, expected) = decode(stored)?;
Ok(pbkdf2::verify(
pbkdf2::PBKDF2_HMAC_SHA256,
iterations,
&salt,
password.as_bytes(),
&expected,
)
.is_ok())
}
#[must_use]
pub fn needs_rehash(stored: &str) -> bool {
match decode(stored) {
Ok((iterations, _, _)) => iterations.get() < ITERATIONS,
Err(_) => true,
}
}
static DUMMY_HASH: LazyLock<String> =
LazyLock::new(|| encode(&[0u8; SALT_LEN], &[0u8; HASH_LEN], ITERATIONS));
#[must_use]
pub fn dummy_hash() -> &'static str {
&DUMMY_HASH
}
fn derive(password: &str, salt: &[u8], iterations: u32) -> [u8; HASH_LEN] {
let mut out = [0u8; HASH_LEN];
pbkdf2::derive(
pbkdf2::PBKDF2_HMAC_SHA256,
nonzero(iterations),
salt,
password.as_bytes(),
&mut out,
);
out
}
fn nonzero(iterations: u32) -> NonZeroU32 {
NonZeroU32::new(iterations).unwrap_or(NonZeroU32::MIN)
}
fn encode(salt: &[u8], hash: &[u8], iterations: u32) -> String {
format!(
"{ALGORITHM}${iterations}${}${}",
BASE64_URL_SAFE_NO_PAD.encode(salt),
BASE64_URL_SAFE_NO_PAD.encode(hash),
)
}
fn decode(stored: &str) -> Result<(NonZeroU32, Vec<u8>, Vec<u8>), PasswordError> {
let mut fields = stored.split('$');
let (Some(algorithm), Some(iterations), Some(salt), Some(hash), None) = (
fields.next(),
fields.next(),
fields.next(),
fields.next(),
fields.next(),
) else {
return Err(PasswordError::Malformed);
};
if algorithm != ALGORITHM {
return Err(PasswordError::UnknownAlgorithm(algorithm.to_string()));
}
let iterations = iterations
.parse::<u32>()
.ok()
.and_then(NonZeroU32::new)
.ok_or(PasswordError::BadIterations)?;
let salt = BASE64_URL_SAFE_NO_PAD
.decode(salt)
.map_err(|_| PasswordError::BadEncoding)?;
let hash = BASE64_URL_SAFE_NO_PAD
.decode(hash)
.map_err(|_| PasswordError::BadEncoding)?;
if salt.len() != SALT_LEN || hash.len() != HASH_LEN {
return Err(PasswordError::BadEncoding);
}
Ok((iterations, salt, hash))
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_ITERATIONS: u32 = 1_000;
fn cheap_hash(password: &str) -> String {
hash_with_iterations(password, TEST_ITERATIONS)
}
fn stored_at(iterations: u32) -> String {
encode(&[7u8; SALT_LEN], &[0u8; HASH_LEN], iterations)
}
#[test]
fn hash_then_verify_round_trips() {
let stored = cheap_hash("correct horse battery");
assert_eq!(verify_password(&stored, "correct horse battery"), Ok(true));
}
#[test]
fn a_wrong_password_is_false_and_not_an_error() {
let stored = cheap_hash("correct horse battery");
assert_eq!(verify_password(&stored, "wrong"), Ok(false));
assert_eq!(verify_password(&stored, ""), Ok(false));
}
#[test]
fn two_hashes_of_one_password_differ_by_salt() {
let first = cheap_hash("a-long-enough-password");
let second = cheap_hash("a-long-enough-password");
assert_ne!(first, second, "each hash must carry its own random salt");
assert_ne!(
first.split('$').nth(2).unwrap(),
second.split('$').nth(2).unwrap()
);
assert_eq!(verify_password(&first, "a-long-enough-password"), Ok(true));
assert_eq!(verify_password(&second, "a-long-enough-password"), Ok(true));
}
#[test]
fn the_encoded_form_is_self_describing() {
let stored = hash_password("a-long-enough-password");
let fields: Vec<&str> = stored.split('$').collect();
assert_eq!(fields.len(), 4);
assert_eq!(fields[0], "pbkdf2-sha256");
assert_eq!(fields[1], ITERATIONS.to_string());
assert_eq!(
BASE64_URL_SAFE_NO_PAD.decode(fields[2]).unwrap().len(),
SALT_LEN
);
assert_eq!(
BASE64_URL_SAFE_NO_PAD.decode(fields[3]).unwrap().len(),
HASH_LEN
);
assert!(!stored.contains('='));
assert!(!stored.contains('+'));
}
#[test]
fn every_decode_failure_is_its_own_variant() {
let good = cheap_hash("pw");
let salt = good.split('$').nth(2).unwrap().to_string();
let hash = good.split('$').nth(3).unwrap().to_string();
let cases: Vec<(&str, String, PasswordError)> = vec![
("empty", String::new(), PasswordError::Malformed),
(
"too few fields",
format!("pbkdf2-sha256$1000${salt}"),
PasswordError::Malformed,
),
(
"too many fields",
format!("pbkdf2-sha256$1000${salt}${hash}$extra"),
PasswordError::Malformed,
),
(
"unknown algorithm",
format!("argon2id$1000${salt}${hash}"),
PasswordError::UnknownAlgorithm("argon2id".to_string()),
),
(
"non-numeric iterations",
format!("pbkdf2-sha256$many${salt}${hash}"),
PasswordError::BadIterations,
),
(
"zero iterations",
format!("pbkdf2-sha256$0${salt}${hash}"),
PasswordError::BadIterations,
),
(
"salt is not base64url",
format!("pbkdf2-sha256$1000$not base64${hash}"),
PasswordError::BadEncoding,
),
(
"digest is not base64url",
format!("pbkdf2-sha256$1000${salt}$not base64"),
PasswordError::BadEncoding,
),
(
"short salt",
format!(
"pbkdf2-sha256$1000${}${hash}",
BASE64_URL_SAFE_NO_PAD.encode([1u8; 4])
),
PasswordError::BadEncoding,
),
(
"truncated digest",
format!(
"pbkdf2-sha256$1000${salt}${}",
BASE64_URL_SAFE_NO_PAD.encode([1u8; 8])
),
PasswordError::BadEncoding,
),
];
for (name, stored, expected) in cases {
assert_eq!(
verify_password(&stored, "pw"),
Err(expected),
"case `{name}` decoded differently than expected"
);
}
}
#[test]
fn every_error_renders() {
let rendered: Vec<String> = [
PasswordError::Malformed,
PasswordError::UnknownAlgorithm("scrypt".to_string()),
PasswordError::BadIterations,
PasswordError::BadEncoding,
]
.iter()
.map(ToString::to_string)
.collect();
assert!(rendered.iter().all(|line| !line.is_empty()));
assert!(rendered[1].contains("scrypt"));
}
#[test]
fn a_generated_secret_hashes_cheaper_and_still_verifies() {
let stored = hash_generated_secret("K7QF23BXTM");
assert_eq!(stored.split('$').nth(1), Some("10000"));
assert_eq!(verify_password(&stored, "K7QF23BXTM"), Ok(true));
assert_eq!(verify_password(&stored, "K7QF23BXTN"), Ok(false));
assert!(needs_rehash(&stored));
}
#[test]
fn needs_rehash_tracks_the_current_parameters() {
assert!(!needs_rehash(&stored_at(ITERATIONS)));
assert!(needs_rehash(&stored_at(ITERATIONS - 1)));
assert!(needs_rehash(&stored_at(TEST_ITERATIONS)));
assert!(!needs_rehash(&stored_at(ITERATIONS + 1)));
assert!(needs_rehash("nonsense"));
assert!(needs_rehash(""));
assert!(needs_rehash("argon2id$1$c2FsdA$aGFzaA"));
}
#[test]
fn the_policy_enforces_length_and_nothing_else() {
assert!(check_password_policy("a-long-enough-password").is_ok());
assert!(check_password_policy(&"x".repeat(MIN_PASSWORD_LEN)).is_ok());
assert!(check_password_policy(&"x".repeat(MAX_PASSWORD_LEN)).is_ok());
let too_short = check_password_policy(&"x".repeat(MIN_PASSWORD_LEN - 1)).unwrap_err();
assert!(too_short.contains("at least 12"), "got: {too_short}");
let too_long = check_password_policy(&"x".repeat(MAX_PASSWORD_LEN + 1)).unwrap_err();
assert!(too_long.contains("at most 1024"), "got: {too_long}");
assert!(check_password_policy("aaaaaaaaaaaaaaaa").is_ok());
assert!(check_password_policy("Aa1!Aa1!").is_err());
}
#[test]
fn the_policy_counts_characters_not_bytes() {
let passphrase = "日本語日本語日本語日本語";
assert_eq!(passphrase.chars().count(), 12);
assert!(passphrase.len() > MIN_PASSWORD_LEN);
assert!(check_password_policy(passphrase).is_ok());
assert!(check_password_policy("日本語日本語日本語日本").is_err());
}
#[test]
fn the_dummy_hash_is_precomputed_and_matches_no_password() {
assert_eq!(
dummy_hash(),
dummy_hash(),
"a dummy computed per call costs the unknown-username branch an \
extra derivation, which is the enumeration oracle it exists to close"
);
let (iterations, _, _) = decode(dummy_hash()).expect("the dummy is well-formed");
assert_eq!(iterations.get(), ITERATIONS);
assert!(!needs_rehash(dummy_hash()));
assert_eq!(verify_password(dummy_hash(), "hunter2"), Ok(false));
}
}