use std::collections::BTreeSet;
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};
use url::Url;
use crate::config::{Config, LocalCaSubjectConfig};
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;
const MIN_CONTEXT_WORD_LEN: usize = 4;
const UNIVERSAL_CONTEXT_WORDS: [&str; 2] = ["acme", "proxy"];
const COMMON_PASSWORDS: &str = include_str!("corpus/common-passwords.txt");
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PasswordContext {
words: Vec<String>,
}
impl PasswordContext {
#[must_use]
pub fn empty() -> Self {
Self::default()
}
#[must_use]
pub fn from_config(config: &Config, username: &str) -> Self {
let mut words = BTreeSet::new();
for word in UNIVERSAL_CONTEXT_WORDS {
words.insert(word.to_string());
}
push_tokens(&mut words, username);
push_host(&mut words, &config.server.base_url);
push_host(&mut words, &config.admin.base_url);
push_subject(&mut words, &config.signer.local_ca.subject);
for profile in config.resolve_profiles().unwrap_or_default() {
push_tokens(&mut words, &profile.name);
push_subject(&mut words, &profile.sections.signer.local_ca.subject);
}
Self {
words: words.into_iter().collect(),
}
}
fn first_match(&self, folded: &str) -> Option<&str> {
self.words
.iter()
.find(|word| folded.contains(word.as_str()))
.map(String::as_str)
}
#[cfg(test)]
fn words(&self) -> &[String] {
&self.words
}
}
fn push_tokens(words: &mut BTreeSet<String>, value: &str) {
for token in value.split(|c: char| !c.is_alphanumeric()) {
if token.chars().count() >= MIN_CONTEXT_WORD_LEN {
words.insert(token.to_lowercase());
}
}
}
fn push_host(words: &mut BTreeSet<String>, base_url: &str) {
if let Some(host) = Url::parse(base_url)
.ok()
.and_then(|url| url.host_str().map(str::to_string))
{
push_tokens(words, &host);
}
}
fn push_subject(words: &mut BTreeSet<String>, subject: &LocalCaSubjectConfig) {
for value in [
&subject.common_name,
&subject.organization,
&subject.organizational_unit,
&subject.state,
&subject.locality,
]
.into_iter()
.flatten()
{
push_tokens(words, value);
}
}
fn is_common(folded: &str) -> bool {
COMMON_PASSWORDS.lines().any(|entry| entry == folded)
}
#[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, context: &PasswordContext) -> 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()
));
}
let folded = password.to_lowercase();
if let Some(word) = context.first_match(&folded) {
return Err(format!(
"password must not contain `{word}`, which names this deployment"
));
}
if is_common(&folded) {
return Err("password appears in a list of commonly used passwords".to_string());
}
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"));
}
const SHORTEST_ACCEPTABLE: &str = "Zq7-Kx2-Mp9v";
#[test]
fn the_policy_enforces_length_at_both_ends() {
let none = PasswordContext::empty();
assert_eq!(SHORTEST_ACCEPTABLE.chars().count(), MIN_PASSWORD_LEN);
assert!(check_password_policy(SHORTEST_ACCEPTABLE, &none).is_ok());
assert!(check_password_policy(&"a".repeat(MAX_PASSWORD_LEN), &none).is_ok());
let too_short =
check_password_policy(&"x".repeat(MIN_PASSWORD_LEN - 1), &none).unwrap_err();
assert!(too_short.contains("at least 12"), "got: {too_short}");
let too_long = check_password_policy(&"a".repeat(MAX_PASSWORD_LEN + 1), &none).unwrap_err();
assert!(too_long.contains("at most 1024"), "got: {too_long}");
}
#[test]
fn the_policy_still_has_no_composition_rules() {
let none = PasswordContext::empty();
assert!(check_password_policy(&"a".repeat(24), &none).is_ok());
assert!(check_password_policy("Aa1!Aa1!", &none).is_err());
}
#[test]
fn the_policy_counts_characters_not_bytes() {
let none = PasswordContext::empty();
let passphrase = "日本語日本語日本語日本語";
assert_eq!(passphrase.chars().count(), 12);
assert!(passphrase.len() > MIN_PASSWORD_LEN);
assert!(check_password_policy(passphrase, &none).is_ok());
assert!(check_password_policy("日本語日本語日本語日本", &none).is_err());
}
#[test]
fn the_corpus_holds_its_shape() {
let mut previous = "";
let mut entries = 0usize;
for entry in COMMON_PASSWORDS.lines() {
assert!(
entry.is_ascii(),
"non-ASCII entry `{entry}`: the >= 12 filter counts bytes, which \
equals characters only for ASCII"
);
assert_eq!(
entry,
entry.to_lowercase(),
"entry `{entry}` is not folded, so the folded lookup can never match it"
);
assert!(
entry.chars().count() >= MIN_PASSWORD_LEN,
"entry `{entry}` is shorter than the length rule already refuses, \
so it is bytes spent on an unreachable comparison"
);
assert!(
previous < entry,
"`{previous}` then `{entry}`: the corpus must be `LC_ALL=C sort -u`ed"
);
previous = entry;
entries += 1;
}
assert!(
entries > 10_000,
"only {entries} entries: the file looks truncated"
);
assert!(
COMMON_PASSWORDS.len() < 200 * 1024,
"corpus is {} bytes, past the 200 KiB budget the rank cut was derived from",
COMMON_PASSWORDS.len()
);
}
#[test]
fn the_test_fixture_passwords_are_not_in_the_corpus() {
for fixture in [
"a-long-enough-password",
"correct horse battery",
SHORTEST_ACCEPTABLE,
] {
assert!(
!is_common(&fixture.to_lowercase()),
"`{fixture}` is now a corpus entry, and every test that uses it is \
about to fail somewhere else"
);
}
}
#[test]
fn a_common_password_is_refused_however_it_is_capitalized() {
let none = PasswordContext::empty();
for spelling in ["passwordpassword", "PasswordPassword", "PASSWORDPASSWORD"] {
let error = check_password_policy(spelling, &none).unwrap_err();
assert!(error.contains("commonly used"), "got: {error}");
assert!(
!error.contains(spelling),
"the message must never echo the password: {error}"
);
}
}
#[test]
fn the_corpus_rule_does_not_match_a_substring() {
assert!(is_common("passwordpassword"));
assert!(!is_common("a-long-enough-password"));
assert!(!is_common("xx-passwordpassword-xx"));
}
fn load(body: &str) -> Config {
let _lock = crate::config::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = crate::testutil::TempDir::new("password-context");
std::fs::write(dir.join("config.toml"), body).unwrap();
unsafe {
std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
}
let config = Config::load().expect("the configuration must load");
unsafe {
std::env::remove_var("ACME_PROXY_CONFIG");
}
config
}
#[test]
fn the_context_list_is_derived_from_the_deployment() {
let mut config = Config::default();
config.server.base_url = "https://ca.example.com:3000".to_string();
config.admin.base_url = "https://panel.internal.test".to_string();
config.signer.local_ca.subject.common_name = Some("Example Corp Issuing CA".to_string());
config.signer.local_ca.subject.organizational_unit = Some("Platform".to_string());
config.signer.local_ca.subject.state = Some("Noord-Holland".to_string());
config.signer.local_ca.subject.locality = Some("Amsterdam".to_string());
config.signer.local_ca.subject.country = Some("NL".to_string());
let context = PasswordContext::from_config(&config, "operator");
let words = context.words();
for expected in [
"acme",
"proxy",
"operator",
"example",
"panel",
"internal",
"test",
"issuing",
"platform",
"noord",
"holland",
"amsterdam",
] {
assert!(
words.iter().any(|word| word == expected),
"expected `{expected}` among {words:?}"
);
}
for absent in ["http", "https"] {
assert!(
!words.iter().any(|word| word == absent),
"`{absent}` came from a URL scheme: {words:?}"
);
}
for absent in ["com", "ca", "nl"] {
assert!(
!words.iter().any(|word| word == absent),
"`{absent}` is under the floor and must bar nothing: {words:?}"
);
}
let mut expected = words.to_vec();
expected.sort();
expected.dedup();
assert_eq!(
words,
expected.as_slice(),
"words must be sorted and unique"
);
}
#[test]
fn a_context_word_is_refused_as_a_substring_and_named_in_the_message() {
let mut config = Config::default();
config.server.base_url = "https://ca.example.com".to_string();
let context = PasswordContext::from_config(&config, "operator");
let error = check_password_policy("acmeproxy2026!!", &context).unwrap_err();
assert!(
error.contains("acme"),
"the message must name the word: {error}"
);
assert!(error.contains("names this deployment"), "got: {error}");
assert!(
!error.contains("acmeproxy2026!!"),
"the message must name the word, never the password: {error}"
);
assert!(check_password_policy("XXXX-ExAmPlE-XXXX", &context).is_err());
assert!(check_password_policy("a-long-enough-password", &context).is_ok());
}
#[test]
fn an_empty_context_bars_nothing_and_keeps_the_other_rules() {
let none = PasswordContext::empty();
assert!(none.words().is_empty());
assert!(check_password_policy("acmeproxy2026!!", &none).is_ok());
assert!(check_password_policy("passwordpassword", &none).is_err());
assert!(check_password_policy("short", &none).is_err());
}
#[test]
fn each_rule_ends_the_check() {
let mut config = Config::default();
config.server.base_url = "https://ca.example.com".to_string();
let context = PasswordContext::from_config(&config, "operator");
let error = check_password_policy("acme", &context).unwrap_err();
assert!(error.contains("at least 12"), "got: {error}");
let barring_word = PasswordContext {
words: vec!["word".to_string()],
};
assert!(is_common("passwordpassword"));
let error = check_password_policy("passwordpassword", &barring_word).unwrap_err();
assert!(error.contains("names this deployment"), "got: {error}");
}
#[test]
fn profiles_contribute_their_names_and_their_own_ca_subjects() {
let config = load(
r#"
[profiles.staging]
[profiles.staging.signer.local_ca.subject]
common_name = "Contoso Staging Root"
"#,
);
let context = PasswordContext::from_config(&config, "op");
let words = context.words();
for expected in ["staging", "contoso", "root"] {
assert!(
words.iter().any(|word| word == expected),
"expected `{expected}` among {words:?}"
);
}
assert!(!words.iter().any(|word| word == "op"));
}
#[test]
fn a_configuration_with_no_resolvable_profiles_still_yields_words() {
let config = Config::default();
assert!(
config.resolve_profiles().is_err(),
"a default configuration resolves no profiles -- if that ever changes, \
this test stops proving the fallback works"
);
let context = PasswordContext::from_config(&config, "operator");
let words = context.words();
for expected in ["acme", "proxy", "operator", "localhost"] {
assert!(
words.iter().any(|word| word == expected),
"expected `{expected}` among {words:?}"
);
}
}
#[test]
fn an_unparseable_base_url_contributes_nothing() {
let mut config = Config::default();
config.server.base_url = "not a url".to_string();
config.admin.base_url = String::new();
let context = PasswordContext::from_config(&config, "operator");
assert_eq!(context.words(), ["acme", "operator", "proxy"]);
}
#[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));
}
}