use argon2::password_hash::{
SaltString,
rand_core::{OsRng, RngCore},
};
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use bcrypt::{DEFAULT_COST, hash as bcrypt_hash, verify as bcrypt_verify};
use hmac::{Hmac, Mac};
use indexmap::IndexMap;
use once_cell::sync::Lazy;
use sha2::Sha256;
use std::sync::{PoisonError, RwLock};
const DEFAULT_PASSWORD_ALGORITHM: &str = "bcrypt";
const MAX_PASSWORD_BYTES: usize = 72;
const RECOVERY_TOKEN_NONCE_BYTES: usize = 32;
const RECOVERY_TOKEN_SIGNATURE_BYTES: usize = 32;
const RECOVERY_TOKEN_PREFIX: &str = "rrspwt_";
type RecoveryTokenMac = Hmac<Sha256>;
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum SecurePasswordError {
#[error("password is blank")]
Blank,
#[error("password is too long (maximum is {max} characters)")]
TooLong { max: usize },
#[error("password confirmation doesn't match")]
ConfirmationMismatch,
#[error("hashing error: {0}")]
HashError(String),
#[error("authentication failed")]
AuthenticationFailed,
}
#[derive(Clone, Copy, Debug)]
pub struct PasswordAlgorithm {
hash: fn(&str) -> Result<String, SecurePasswordError>,
verify: fn(&str, &str) -> Result<bool, SecurePasswordError>,
matches_digest: fn(&str) -> bool,
}
impl PasswordAlgorithm {
pub const fn new(
hash: fn(&str) -> Result<String, SecurePasswordError>,
verify: fn(&str, &str) -> Result<bool, SecurePasswordError>,
matches_digest: fn(&str) -> bool,
) -> Self {
Self {
hash,
verify,
matches_digest,
}
}
}
static PASSWORD_ALGORITHM_REGISTRY: Lazy<RwLock<IndexMap<String, PasswordAlgorithm>>> =
Lazy::new(|| {
let mut algorithms = IndexMap::new();
algorithms.insert(
DEFAULT_PASSWORD_ALGORITHM.to_owned(),
PasswordAlgorithm::new(
hash_password_with_bcrypt,
verify_password_with_bcrypt,
is_bcrypt_digest,
),
);
algorithms.insert(
"argon2".to_owned(),
PasswordAlgorithm::new(
hash_password_with_argon2,
verify_password_with_argon2,
is_argon2_digest,
),
);
RwLock::new(algorithms)
});
pub fn default_password_algorithm() -> &'static str {
DEFAULT_PASSWORD_ALGORITHM
}
pub fn register_password_algorithm(name: impl Into<String>, algorithm: PasswordAlgorithm) {
with_password_algorithm_registry_mut(|algorithms| {
algorithms.insert(name.into(), algorithm);
});
}
pub fn registered_password_algorithms() -> Vec<String> {
with_password_algorithm_registry(|algorithms| algorithms.keys().cloned().collect())
}
fn with_password_algorithm_registry<T>(
f: impl FnOnce(&IndexMap<String, PasswordAlgorithm>) -> T,
) -> T {
let registry = PASSWORD_ALGORITHM_REGISTRY
.read()
.unwrap_or_else(PoisonError::into_inner);
f(®istry)
}
fn with_password_algorithm_registry_mut<T>(
f: impl FnOnce(&mut IndexMap<String, PasswordAlgorithm>) -> T,
) -> T {
let mut registry = PASSWORD_ALGORITHM_REGISTRY
.write()
.unwrap_or_else(PoisonError::into_inner);
f(&mut registry)
}
fn resolve_password_algorithm(name: &str) -> Result<PasswordAlgorithm, SecurePasswordError> {
with_password_algorithm_registry(|algorithms| algorithms.get(name).copied()).ok_or_else(|| {
SecurePasswordError::HashError(format!("unknown password algorithm: {name}"))
})
}
fn resolve_password_algorithm_for_digest(
digest: &str,
) -> Result<PasswordAlgorithm, SecurePasswordError> {
if digest.is_empty() {
return Err(SecurePasswordError::HashError(
"password digest is blank".to_owned(),
));
}
with_password_algorithm_registry(|algorithms| {
algorithms
.values()
.copied()
.find(|algorithm| (algorithm.matches_digest)(digest))
})
.ok_or_else(|| SecurePasswordError::HashError("unknown password digest format".to_owned()))
}
fn validate_password(password: &str) -> Result<(), SecurePasswordError> {
if password.is_empty() {
return Err(SecurePasswordError::Blank);
}
if password.len() > MAX_PASSWORD_BYTES {
return Err(SecurePasswordError::TooLong {
max: MAX_PASSWORD_BYTES,
});
}
Ok(())
}
fn stored_password_digest(digest: Option<&str>) -> Result<&str, SecurePasswordError> {
match digest {
Some("") => Err(SecurePasswordError::HashError(
"password digest is blank".to_owned(),
)),
Some(digest) => Ok(digest),
None => Err(SecurePasswordError::AuthenticationFailed),
}
}
fn hash_password_with_argon2(password: &str) -> Result<String, SecurePasswordError> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|error| SecurePasswordError::HashError(error.to_string()))?;
Ok(hash.to_string())
}
fn verify_password_with_argon2(password: &str, digest: &str) -> Result<bool, SecurePasswordError> {
let parsed = PasswordHash::new(digest)
.map_err(|error| SecurePasswordError::HashError(error.to_string()))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok())
}
fn hash_password_with_bcrypt(password: &str) -> Result<String, SecurePasswordError> {
bcrypt_hash(password, DEFAULT_COST)
.map_err(|error| SecurePasswordError::HashError(error.to_string()))
}
fn verify_password_with_bcrypt(password: &str, digest: &str) -> Result<bool, SecurePasswordError> {
bcrypt_verify(password, digest)
.map_err(|error| SecurePasswordError::HashError(error.to_string()))
}
fn is_argon2_digest(digest: &str) -> bool {
digest.starts_with("$argon2")
}
fn is_bcrypt_digest(digest: &str) -> bool {
digest.starts_with("$2a$")
|| digest.starts_with("$2b$")
|| digest.starts_with("$2x$")
|| digest.starts_with("$2y$")
}
fn encode_recovery_token(payload: &[u8]) -> String {
format!("{RECOVERY_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(payload))
}
fn decode_recovery_token(token: &str) -> Option<Vec<u8>> {
let encoded = token.strip_prefix(RECOVERY_TOKEN_PREFIX)?;
URL_SAFE_NO_PAD.decode(encoded).ok()
}
fn recovery_token_signature(digest: &str, nonce: &[u8]) -> Result<Vec<u8>, SecurePasswordError> {
let mut mac = RecoveryTokenMac::new_from_slice(digest.as_bytes())
.map_err(|error| SecurePasswordError::HashError(error.to_string()))?;
mac.update(nonce);
Ok(mac.finalize().into_bytes().to_vec())
}
fn password_salt_from_digest(digest: &str) -> Option<String> {
if is_argon2_digest(digest) {
return PasswordHash::new(digest)
.ok()?
.salt
.map(|salt| salt.as_str().to_string());
}
if is_bcrypt_digest(digest) && digest.len() >= 29 {
return Some(digest[..29].to_string());
}
None
}
pub trait SecurePassword {
fn password_digest(&self) -> Option<&str>;
fn set_password_digest(&mut self, digest: String);
fn set_password(&mut self, password: &str) -> Result<(), SecurePasswordError> {
self.set_password_with_algorithm(password, DEFAULT_PASSWORD_ALGORITHM)
}
fn set_password_confirmed(
&mut self,
password: &str,
confirmation: Option<&str>,
) -> Result<(), SecurePasswordError> {
if let Some(confirmation) = confirmation
&& password != confirmation
{
return Err(SecurePasswordError::ConfirmationMismatch);
}
self.set_password(password)
}
fn update_password(&mut self, password: &str) -> Result<bool, SecurePasswordError> {
if password.is_empty() {
return Ok(false);
}
self.set_password(password)?;
Ok(true)
}
fn update_password_confirmed(
&mut self,
password: &str,
confirmation: Option<&str>,
) -> Result<bool, SecurePasswordError> {
if password.is_empty() {
if matches!(confirmation, Some(confirmation) if !confirmation.is_empty()) {
return Err(SecurePasswordError::ConfirmationMismatch);
}
return Ok(false);
}
self.set_password_confirmed(password, confirmation)?;
Ok(true)
}
fn set_password_with_algorithm(
&mut self,
password: &str,
algorithm: &str,
) -> Result<(), SecurePasswordError> {
validate_password(password)?;
let algorithm = resolve_password_algorithm(algorithm)?;
let digest = (algorithm.hash)(password)?;
self.set_password_digest(digest);
Ok(())
}
fn authenticate(&self, password: &str) -> Result<bool, SecurePasswordError> {
if password.len() > MAX_PASSWORD_BYTES {
return Ok(false);
}
if matches!(self.password_digest(), Some("")) {
return Ok(false);
}
let digest = stored_password_digest(self.password_digest())?;
let algorithm = resolve_password_algorithm_for_digest(digest)?;
(algorithm.verify)(password, digest)
}
fn authenticate_password(&self, challenge: Option<&str>) -> bool {
let Some(challenge) = challenge else {
return false;
};
if challenge.is_empty() {
return false;
}
self.authenticate(challenge).unwrap_or(false)
}
fn generate_recovery_token(&self) -> Result<String, SecurePasswordError> {
let digest = stored_password_digest(self.password_digest())?;
let mut nonce = [0_u8; RECOVERY_TOKEN_NONCE_BYTES];
OsRng.fill_bytes(&mut nonce);
let signature = recovery_token_signature(digest, &nonce)?;
let mut payload =
Vec::with_capacity(RECOVERY_TOKEN_NONCE_BYTES + RECOVERY_TOKEN_SIGNATURE_BYTES);
payload.extend_from_slice(&nonce);
payload.extend_from_slice(&signature);
Ok(encode_recovery_token(&payload))
}
fn verify_recovery_token(&self, token: &str) -> bool {
let Some(payload) = decode_recovery_token(token) else {
return false;
};
if payload.len() != RECOVERY_TOKEN_NONCE_BYTES + RECOVERY_TOKEN_SIGNATURE_BYTES {
return false;
}
let Ok(digest) = stored_password_digest(self.password_digest()) else {
return false;
};
let (nonce, signature) = payload.split_at(RECOVERY_TOKEN_NONCE_BYTES);
let Ok(expected_signature) = recovery_token_signature(digest, nonce) else {
return false;
};
signature == expected_signature.as_slice()
}
fn has_password(&self) -> bool {
self.password_digest().is_some()
}
fn password_salt(&self) -> Option<String> {
let digest = self.password_digest()?;
if digest.is_empty() {
return None;
}
password_salt_from_digest(digest)
}
}
#[cfg(test)]
mod tests {
use super::{
PasswordAlgorithm, SecurePassword, SecurePasswordError, default_password_algorithm,
register_password_algorithm, registered_password_algorithms,
};
use argon2::PasswordHasher;
#[derive(Debug, Default)]
struct User {
digest: Option<String>,
}
impl SecurePassword for User {
fn password_digest(&self) -> Option<&str> {
self.digest.as_deref()
}
fn set_password_digest(&mut self, digest: String) {
self.digest = Some(digest);
}
}
fn external_digest(password: &str) -> String {
let salt = argon2::password_hash::SaltString::generate(
&mut argon2::password_hash::rand_core::OsRng,
);
argon2::Argon2::default()
.hash_password(password.as_bytes(), &salt)
.expect("external hash should succeed")
.to_string()
}
fn reverse_algorithm_hash(password: &str) -> Result<String, SecurePasswordError> {
Ok(format!(
"reverse${}",
password.chars().rev().collect::<String>()
))
}
fn reverse_algorithm_verify(password: &str, digest: &str) -> Result<bool, SecurePasswordError> {
Ok(digest == format!("reverse${}", password.chars().rev().collect::<String>()))
}
fn reverse_algorithm_matches(digest: &str) -> bool {
digest.starts_with("reverse$")
}
#[test]
fn set_password_hashes_and_stores_the_digest() {
let mut user = User::default();
user.set_password("password").expect("password should hash");
let digest = user.password_digest().expect("digest should exist");
assert!(!digest.is_empty());
assert_ne!(digest, "password");
assert!(digest.starts_with("$2"));
}
#[test]
fn authenticate_with_correct_password_returns_true() {
let mut user = User::default();
user.set_password("password").expect("password should hash");
let authenticated = user
.authenticate("password")
.expect("authentication should parse the digest");
assert!(authenticated);
}
#[test]
fn authenticate_with_wrong_password_returns_false() {
let mut user = User::default();
user.set_password("password").expect("password should hash");
let authenticated = user
.authenticate("not-the-password")
.expect("authentication should parse the digest");
assert!(!authenticated);
}
#[test]
fn blank_password_is_rejected() {
let mut user = User::default();
let error = user
.set_password("")
.expect_err("blank password should fail");
assert_eq!(error, SecurePasswordError::Blank);
assert!(!user.has_password());
}
#[test]
fn too_long_password_is_rejected() {
let mut user = User::default();
let error = user
.set_password(&"a".repeat(73))
.expect_err("73-byte password should fail");
assert_eq!(error, SecurePasswordError::TooLong { max: 72 });
assert!(!user.has_password());
}
#[test]
fn password_byte_length_limit_uses_bytes_not_scalar_values() {
let mut user = User::default();
let password = "あ".repeat(24) + "a";
let error = user
.set_password(&password)
.expect_err("73-byte unicode password should fail");
assert_eq!(error, SecurePasswordError::TooLong { max: 72 });
}
#[test]
fn password_round_trip_succeeds() {
let mut user = User::default();
user.set_password("correct horse battery staple")
.expect("password should hash");
assert!(
user.authenticate("correct horse battery staple")
.expect("authentication should succeed")
);
assert!(
!user
.authenticate("tr0ub4dor&3")
.expect("authentication should succeed")
);
}
#[test]
fn has_password_reflects_whether_a_digest_exists() {
let mut user = User::default();
assert!(!user.has_password());
user.set_password("password").expect("password should hash");
assert!(user.has_password());
}
#[test]
fn different_passwords_produce_different_digests() {
let mut first = User::default();
let mut second = User::default();
first
.set_password("password-one")
.expect("password should hash");
second
.set_password("password-two")
.expect("password should hash");
assert_ne!(first.password_digest(), second.password_digest());
}
#[test]
fn same_password_uses_unique_salts() {
let mut first = User::default();
let mut second = User::default();
first
.set_password("password")
.expect("password should hash");
second
.set_password("password")
.expect("password should hash");
assert_ne!(first.password_digest(), second.password_digest());
}
#[test]
fn authentication_without_a_digest_fails_gracefully() {
let user = User::default();
let error = user
.authenticate("password")
.expect_err("missing digest should fail");
assert_eq!(error, SecurePasswordError::AuthenticationFailed);
}
#[test]
fn malformed_digest_returns_a_hash_error() {
let user = User {
digest: Some("not-a-valid-phc-string".to_owned()),
};
let error = user
.authenticate("password")
.expect_err("malformed digest should fail");
assert!(matches!(error, SecurePasswordError::HashError(_)));
}
#[test]
fn spaces_only_password_is_accepted() {
let mut user = User::default();
let password = " ".repeat(72);
user.set_password(&password)
.expect("spaces-only password should hash");
assert!(
user.authenticate(&password)
.expect("authentication should succeed")
);
}
#[test]
fn password_digest_is_absent_by_default() {
let user = User::default();
assert_eq!(user.password_digest(), None);
assert!(!user.has_password());
}
#[test]
fn exact_maximum_length_password_is_accepted() {
let mut user = User::default();
let password = "a".repeat(72);
user.set_password(&password)
.expect("72-byte password should hash");
assert!(
user.authenticate(&password)
.expect("authentication should succeed")
);
}
#[test]
fn exact_maximum_byte_length_unicode_password_is_accepted() {
let mut user = User::default();
let password = "あ".repeat(24);
user.set_password(&password)
.expect("72-byte unicode password should hash");
assert!(
user.authenticate(&password)
.expect("authentication should succeed")
);
}
#[test]
fn authenticate_with_empty_password_returns_false_when_digest_exists() {
let mut user = User::default();
user.set_password("password").expect("password should hash");
assert!(
!user
.authenticate("")
.expect("authentication should succeed")
);
}
#[test]
fn authenticate_is_case_sensitive() {
let mut user = User::default();
user.set_password("Password").expect("password should hash");
assert!(
user.authenticate("Password")
.expect("authentication should succeed")
);
assert!(
!user
.authenticate("password")
.expect("authentication should succeed")
);
}
#[test]
fn setting_a_new_password_replaces_the_stored_digest() {
let mut user = User::default();
user.set_password("password").expect("password should hash");
let original_digest = user
.password_digest()
.expect("digest should exist")
.to_owned();
user.set_password("new password")
.expect("replacement password should hash");
assert_ne!(user.password_digest(), Some(original_digest.as_str()));
}
#[test]
fn old_password_no_longer_authenticates_after_reset() {
let mut user = User::default();
user.set_password("password").expect("password should hash");
user.set_password("new password")
.expect("replacement password should hash");
assert!(
user.authenticate("new password")
.expect("authentication should succeed")
);
assert!(
!user
.authenticate("password")
.expect("authentication should succeed")
);
}
#[test]
fn blank_password_error_preserves_existing_digest() {
let mut user = User::default();
user.set_password("password").expect("password should hash");
let original_digest = user
.password_digest()
.expect("digest should exist")
.to_owned();
let error = user
.set_password("")
.expect_err("blank password should fail");
assert_eq!(error, SecurePasswordError::Blank);
assert_eq!(user.password_digest(), Some(original_digest.as_str()));
assert!(
user.authenticate("password")
.expect("authentication should succeed")
);
}
#[test]
fn too_long_password_error_preserves_existing_digest() {
let mut user = User::default();
user.set_password("password").expect("password should hash");
let original_digest = user
.password_digest()
.expect("digest should exist")
.to_owned();
let error = user
.set_password(&"a".repeat(73))
.expect_err("73-byte password should fail");
assert_eq!(error, SecurePasswordError::TooLong { max: 72 });
assert_eq!(user.password_digest(), Some(original_digest.as_str()));
assert!(
user.authenticate("password")
.expect("authentication should succeed")
);
}
#[test]
fn manually_setting_a_digest_marks_password_as_present() {
let mut user = User::default();
user.set_password_digest("manual-digest".to_string());
assert!(user.has_password());
assert_eq!(user.password_digest(), Some("manual-digest"));
}
#[test]
fn externally_generated_digest_can_authenticate() {
let salt = argon2::password_hash::SaltString::generate(
&mut argon2::password_hash::rand_core::OsRng,
);
let digest = argon2::Argon2::default()
.hash_password("password".as_bytes(), &salt)
.expect("external hash should succeed")
.to_string();
let user = User {
digest: Some(digest),
};
assert!(
user.authenticate("password")
.expect("authentication should succeed")
);
assert!(
!user
.authenticate("wrong")
.expect("authentication should succeed")
);
}
#[test]
fn external_empty_password_digest_can_authenticate_an_empty_password() {
let user = User {
digest: Some(external_digest("")),
};
assert!(
user.authenticate("")
.expect("authentication should succeed")
);
assert!(
!user
.authenticate("not-empty")
.expect("authentication should succeed")
);
}
#[test]
fn authenticate_with_an_overlong_password_returns_false_instead_of_error() {
let mut user = User::default();
user.set_password("password").expect("password should hash");
let password = "a".repeat(200);
assert!(
!user
.authenticate(&password)
.expect("authentication should still parse the digest")
);
}
#[test]
fn password_with_an_embedded_nul_byte_hashes_and_authenticates() {
let mut user = User::default();
let password = "prefix\0suffix";
user.set_password(password)
.expect("password with embedded nul should hash");
assert!(
user.authenticate(password)
.expect("authentication should succeed")
);
assert!(
!user
.authenticate("prefixsuffix")
.expect("authentication should succeed")
);
}
#[test]
fn generated_digest_exposes_argon2_metadata_and_components() {
let mut user = User::default();
user.set_password_with_algorithm("password", "argon2")
.expect("password should hash");
let digest = user.password_digest().expect("digest should exist");
let parsed = argon2::PasswordHash::new(digest).expect("digest should parse");
assert!(digest.starts_with("$argon2id$"));
assert!(digest.contains("$v=19$"));
assert!(digest.contains("m="));
assert!(digest.contains("t="));
assert!(digest.contains("p="));
assert_eq!(parsed.algorithm.as_str(), "argon2id");
assert!(parsed.salt.is_some(), "digest should include a salt");
assert!(parsed.hash.is_some(), "digest should include a hash output");
}
#[test]
fn replacing_the_digest_switches_which_password_authenticates() {
let mut user = User::default();
user.set_password("password").expect("password should hash");
user.set_password_digest(external_digest("replacement"));
assert!(
user.authenticate("replacement")
.expect("authentication should succeed")
);
assert!(
!user
.authenticate("password")
.expect("authentication should succeed")
);
}
#[test]
fn blank_error_message_is_human_readable() {
assert_eq!(SecurePasswordError::Blank.to_string(), "password is blank");
}
#[test]
fn too_long_error_message_mentions_the_limit() {
assert_eq!(
SecurePasswordError::TooLong { max: 72 }.to_string(),
"password is too long (maximum is 72 characters)"
);
}
#[test]
fn hash_error_message_includes_the_hashing_prefix() {
assert_eq!(
SecurePasswordError::HashError("boom".to_string()).to_string(),
"hashing error: boom"
);
}
#[test]
fn set_password_with_argon2_stores_an_argon2_digest() {
let mut user = User::default();
user.set_password_with_algorithm("password", "argon2")
.expect("password should hash");
let digest = user.password_digest().expect("digest should exist");
let parsed = argon2::PasswordHash::new(digest).expect("digest should parse");
assert!(digest.starts_with("$argon2id$"));
assert!(digest.contains("$v=19$"));
assert!(digest.contains("m="));
assert!(digest.contains("t="));
assert!(digest.contains("p="));
assert_eq!(parsed.algorithm.as_str(), "argon2id");
assert!(parsed.salt.is_some(), "digest should include a salt");
assert!(parsed.hash.is_some(), "digest should include a hash output");
}
#[test]
fn registering_a_custom_algorithm_allows_hashing_and_authentication() {
register_password_algorithm(
"reverse",
PasswordAlgorithm::new(
reverse_algorithm_hash,
reverse_algorithm_verify,
reverse_algorithm_matches,
),
);
let mut user = User::default();
user.set_password_with_algorithm("password", "reverse")
.expect("password should hash");
assert_eq!(user.password_digest(), Some("reverse$drowssap"));
assert!(
user.authenticate("password")
.expect("authentication should succeed")
);
assert!(
!user
.authenticate("wrong")
.expect("authentication should succeed")
);
}
#[test]
fn unknown_password_algorithm_returns_a_hash_error() {
let mut user = User::default();
let error = user
.set_password_with_algorithm("password", "missing")
.expect_err("unknown algorithm should fail");
assert!(matches!(error, SecurePasswordError::HashError(_)));
assert!(error.to_string().contains("missing"));
}
#[test]
fn registered_password_algorithms_include_bcrypt_and_argon2() {
let algorithms = registered_password_algorithms();
assert!(algorithms.iter().any(|name| name == "bcrypt"));
assert!(algorithms.iter().any(|name| name == "argon2"));
assert_eq!(default_password_algorithm(), "bcrypt");
}
#[test]
fn authenticate_password_handles_optional_challenges() {
let mut user = User::default();
user.set_password("secret").expect("password should hash");
assert!(user.authenticate_password(Some("secret")));
assert!(!user.authenticate_password(Some("wrong")));
assert!(!user.authenticate_password(Some("")));
assert!(!user.authenticate_password(None));
}
#[test]
fn authenticate_password_returns_false_for_missing_or_malformed_digests() {
let missing = User::default();
let malformed = User {
digest: Some("not-a-valid-digest".to_owned()),
};
let blank = User {
digest: Some(String::new()),
};
assert!(!missing.authenticate_password(Some("secret")));
assert!(!malformed.authenticate_password(Some("secret")));
assert!(!blank.authenticate_password(Some("secret")));
}
#[test]
fn recovery_tokens_round_trip_and_are_invalidated_by_password_changes() {
let mut user = User::default();
user.set_password("secret").expect("password should hash");
let token = user
.generate_recovery_token()
.expect("recovery token should generate");
assert!(user.verify_recovery_token(&token));
assert!(!user.verify_recovery_token("not-a-token"));
user.set_password("new-secret")
.expect("replacement password should hash");
assert!(!user.verify_recovery_token(&token));
}
}
#[cfg(test)]
mod rails_port_tests {
use super::{
PasswordAlgorithm, SecurePassword, SecurePasswordError, default_password_algorithm,
register_password_algorithm, registered_password_algorithms,
};
#[derive(Debug, Default)]
struct RailsUser {
digest: Option<String>,
}
impl SecurePassword for RailsUser {
fn password_digest(&self) -> Option<&str> {
self.digest.as_deref()
}
fn set_password_digest(&mut self, digest: String) {
self.digest = Some(digest);
}
}
fn reverse_algorithm_hash(password: &str) -> Result<String, SecurePasswordError> {
Ok(format!(
"reverse${}",
password.chars().rev().collect::<String>()
))
}
fn reverse_algorithm_verify(password: &str, digest: &str) -> Result<bool, SecurePasswordError> {
Ok(digest == format!("reverse${}", password.chars().rev().collect::<String>()))
}
fn reverse_algorithm_matches(digest: &str) -> bool {
digest.starts_with("reverse$")
}
macro_rules! rails_ignored_test {
($name:ident, $reason:literal) => {
#[test]
#[ignore = $reason]
fn $name() {}
};
}
#[test]
fn rails_create_new_user_with_validation_and_blank_password() {
let mut user = RailsUser::default();
assert_eq!(user.set_password(""), Err(SecurePasswordError::Blank));
assert!(!user.has_password());
}
#[test]
fn rails_create_new_user_with_validation_and_password_length_greater_than_72_characters() {
let mut user = RailsUser::default();
assert_eq!(
user.set_password(&"a".repeat(73)),
Err(SecurePasswordError::TooLong { max: 72 }),
);
assert!(!user.has_password());
}
#[test]
fn rails_create_new_user_with_validation_and_password_byte_size_greater_than_72_bytes() {
let mut user = RailsUser::default();
let password = "あ".repeat(24) + "a";
assert_eq!(
user.set_password(&password),
Err(SecurePasswordError::TooLong { max: 72 }),
);
assert!(!user.has_password());
}
#[test]
fn rails_authenticate() {
let mut user = RailsUser::default();
user.set_password("secret").expect("password should hash");
assert!(!user.authenticate("wrong").expect("digest should parse"));
assert!(user.authenticate("secret").expect("digest should parse"));
}
#[test]
fn rails_argon2_digest_is_generated_and_authenticate_works() {
let mut user = RailsUser::default();
user.set_password_with_algorithm("secret", "argon2")
.expect("password should hash");
assert!(
user.password_digest()
.expect("digest should exist")
.starts_with("$argon2")
);
assert!(user.authenticate("secret").expect("digest should parse"));
assert!(!user.authenticate("wrong").expect("digest should parse"));
}
#[test]
fn rails_authentication_fails_when_password_digest_is_missing() {
let user = RailsUser::default();
assert_eq!(
user.authenticate("secret"),
Err(SecurePasswordError::AuthenticationFailed),
);
}
#[test]
fn rails_invalid_password_digest_reports_a_hash_error() {
let user = RailsUser {
digest: Some("not-a-valid-digest".to_owned()),
};
let error = user
.authenticate("secret")
.expect_err("invalid digest should fail parsing");
assert!(matches!(error, SecurePasswordError::HashError(_)));
}
#[test]
fn rails_setting_a_new_password_replaces_the_existing_digest() {
let mut user = RailsUser::default();
user.set_password("first")
.expect("first password should hash");
let first_digest = user
.password_digest()
.expect("digest should exist")
.to_owned();
user.set_password("second")
.expect("second password should hash");
let second_digest = user
.password_digest()
.expect("digest should exist")
.to_owned();
assert_ne!(first_digest, second_digest);
assert!(user.authenticate("second").expect("digest should parse"));
assert!(!user.authenticate("first").expect("digest should parse"));
}
#[test]
fn rails_updating_existing_user_with_correct_password_challenge() {
let mut user = RailsUser::default();
user.set_password("secret").expect("password should hash");
assert!(user.authenticate_password(Some("secret")));
}
#[test]
fn rails_updating_existing_user_with_nil_password_challenge() {
let mut user = RailsUser::default();
user.set_password("secret").expect("password should hash");
assert!(!user.authenticate_password(None));
}
#[test]
fn rails_updating_existing_user_with_blank_password_challenge() {
let mut user = RailsUser::default();
user.set_password("secret").expect("password should hash");
assert!(!user.authenticate_password(Some("")));
}
#[test]
fn rails_updating_existing_user_with_incorrect_password_challenge() {
let mut user = RailsUser::default();
user.set_password("secret").expect("password should hash");
assert!(!user.authenticate_password(Some("wrong")));
}
#[test]
fn rails_updating_user_without_dirty_tracking_with_correct_password_challenge() {
let mut user = RailsUser::default();
user.set_password("secret").expect("password should hash");
assert!(user.authenticate_password(Some("secret")));
}
#[test]
fn rails_password_reset_token() {
let mut user = RailsUser::default();
user.set_password("secret").expect("password should hash");
let token = user
.generate_recovery_token()
.expect("recovery token should generate");
assert!(user.verify_recovery_token(&token));
}
#[test]
fn rails_password_algorithm_defaults_to_bcrypt() {
let mut user = RailsUser::default();
user.set_password("secret").expect("password should hash");
assert_eq!(default_password_algorithm(), "bcrypt");
assert!(
user.password_digest()
.expect("digest should exist")
.starts_with("$2")
);
}
#[test]
fn rails_custom_password_algorithm_is_supported() {
register_password_algorithm(
"reverse",
PasswordAlgorithm::new(
reverse_algorithm_hash,
reverse_algorithm_verify,
reverse_algorithm_matches,
),
);
let mut user = RailsUser::default();
user.set_password_with_algorithm("secret", "reverse")
.expect("password should hash");
assert_eq!(user.password_digest(), Some("reverse$terces"));
assert!(user.authenticate("secret").expect("digest should parse"));
}
#[test]
fn rails_algorithm_can_be_registered_and_used_via_symbol() {
register_password_algorithm(
"reverse",
PasswordAlgorithm::new(
reverse_algorithm_hash,
reverse_algorithm_verify,
reverse_algorithm_matches,
),
);
let mut user = RailsUser::default();
user.set_password_with_algorithm("secret", "reverse")
.expect("password should hash");
assert!(user.authenticate("secret").expect("digest should parse"));
}
#[test]
fn rails_raises_error_for_unknown_algorithm_symbol() {
let mut user = RailsUser::default();
let error = user
.set_password_with_algorithm("secret", "missing")
.expect_err("unknown algorithm should fail");
assert!(matches!(error, SecurePasswordError::HashError(_)));
}
#[test]
fn rails_algorithm_registry_can_be_inspected() {
let algorithms = registered_password_algorithms();
assert!(algorithms.iter().any(|name| name == "bcrypt"));
assert!(algorithms.iter().any(|name| name == "argon2"));
}
#[test]
fn rails_argon2_algorithm_is_registered() {
let algorithms = registered_password_algorithms();
let mut user = RailsUser::default();
user.set_password_with_algorithm("secret", "argon2")
.expect("password should hash");
assert!(algorithms.iter().any(|name| name == "argon2"));
assert!(
user.password_digest()
.expect("digest should exist")
.starts_with("$argon2")
);
}
#[test]
fn rails_create_new_user_with_spaces_only_password_without_confirmation_context() {
let mut user = RailsUser::default();
let password = " ".repeat(72);
user.set_password(&password)
.expect("spaces-only password should hash");
assert!(user.authenticate(&password).expect("digest should parse"));
}
#[test]
fn rails_password_reset_token_is_invalidated_by_password_change() {
let mut user = RailsUser::default();
user.set_password("secret").expect("password should hash");
let token = user
.generate_recovery_token()
.expect("recovery token should generate");
assert!(user.verify_recovery_token(&token));
user.set_password("new secret")
.expect("replacement password should hash");
assert!(!user.verify_recovery_token(&token));
}
#[test]
fn rails_algorithm_registry_can_be_inspected_after_registering_custom_algorithm() {
register_password_algorithm(
"reverse",
PasswordAlgorithm::new(
reverse_algorithm_hash,
reverse_algorithm_verify,
reverse_algorithm_matches,
),
);
let algorithms = registered_password_algorithms();
assert!(algorithms.iter().any(|name| name == "bcrypt"));
assert!(algorithms.iter().any(|name| name == "argon2"));
assert!(algorithms.iter().any(|name| name == "reverse"));
}
rails_ignored_test!(
rails_automatically_include_activemodel_validations_when_validations_are_enabled,
"SecurePassword is a trait, not a Rails validation mixin"
);
rails_ignored_test!(
rails_dont_include_activemodel_validations_when_validations_are_disabled,
"SecurePassword is a trait, not a Rails validation mixin"
);
#[test]
fn rails_create_new_user_with_valid_password_confirmation() {
let mut user = RailsUser::default();
assert_eq!(
user.set_password_confirmed("password", Some("password")),
Ok(())
);
assert!(user.authenticate("password").expect("digest should parse"));
}
#[test]
fn rails_create_new_user_with_spaces_only_password() {
let mut user = RailsUser::default();
let password = " ".repeat(72);
assert_eq!(
user.set_password_confirmed(password.as_str(), Some(password.as_str())),
Ok(())
);
assert!(user.authenticate(&password).expect("digest should parse"));
}
rails_ignored_test!(
rails_create_new_user_with_nil_password,
"set_password accepts &str and has no nil concept"
);
#[test]
fn rails_create_new_user_with_blank_password_confirmation() {
let mut user = RailsUser::default();
assert_eq!(
user.set_password_confirmed("password", Some("")),
Err(SecurePasswordError::ConfirmationMismatch)
);
assert!(!user.has_password());
}
#[test]
fn rails_create_new_user_with_nil_password_confirmation() {
let mut user = RailsUser::default();
assert_eq!(user.set_password_confirmed("password", None), Ok(()));
assert!(user.authenticate("password").expect("digest should parse"));
}
#[test]
fn rails_create_new_user_with_incorrect_password_confirmation() {
let mut user = RailsUser::default();
assert_eq!(
user.set_password_confirmed("password", Some("something else")),
Err(SecurePasswordError::ConfirmationMismatch)
);
assert!(!user.has_password());
}
#[test]
fn rails_create_new_user_with_spaces_only_password_and_incorrect_password_confirmation() {
let mut user = RailsUser::default();
assert_eq!(
user.set_password_confirmed(" ", Some("something else")),
Err(SecurePasswordError::ConfirmationMismatch)
);
assert!(!user.has_password());
}
rails_ignored_test!(
rails_resetting_password_to_nil_clears_the_password_cache,
"the SecurePassword trait has no cached plain-text password field"
);
#[test]
fn rails_update_existing_user_with_validation_and_no_change_in_password() {
let mut user = RailsUser::default();
user.set_password("secret").expect("password should hash");
let digest = user
.password_digest()
.expect("digest should exist")
.to_owned();
assert_eq!(user.update_password(""), Ok(false));
assert_eq!(user.password_digest(), Some(digest.as_str()));
assert!(user.authenticate("secret").expect("digest should parse"));
}
#[test]
fn rails_update_existing_user_with_valid_password_confirmation() {
let mut user = RailsUser::default();
user.set_password("old secret")
.expect("password should hash");
assert_eq!(
user.update_password_confirmed("password", Some("password")),
Ok(true)
);
assert!(user.authenticate("password").expect("digest should parse"));
assert!(
!user
.authenticate("old secret")
.expect("digest should parse")
);
}
#[test]
fn rails_updating_existing_user_with_blank_password() {
let mut user = RailsUser::default();
user.set_password("secret").expect("password should hash");
let digest = user
.password_digest()
.expect("digest should exist")
.to_owned();
assert_eq!(user.update_password(""), Ok(false));
assert_eq!(user.password_digest(), Some(digest.as_str()));
}
#[test]
fn rails_updating_existing_user_with_spaces_only_password() {
let mut user = RailsUser::default();
user.set_password("secret").expect("password should hash");
let password = " ".repeat(72);
assert_eq!(user.update_password(&password), Ok(true));
assert!(user.authenticate(&password).expect("digest should parse"));
assert!(!user.authenticate("secret").expect("digest should parse"));
}
#[test]
fn rails_updating_existing_user_with_blank_password_and_password_confirmation() {
let mut user = RailsUser::default();
user.set_password("secret").expect("password should hash");
let digest = user
.password_digest()
.expect("digest should exist")
.to_owned();
assert_eq!(user.update_password_confirmed("", Some("")), Ok(false));
assert_eq!(user.password_digest(), Some(digest.as_str()));
assert!(user.authenticate("secret").expect("digest should parse"));
}
rails_ignored_test!(
rails_updating_existing_user_with_nil_password,
"set_password accepts &str and has no nil concept"
);
#[test]
fn rails_updating_existing_user_with_blank_password_confirmation() {
let mut user = RailsUser::default();
user.set_password("secret").expect("password should hash");
let digest = user
.password_digest()
.expect("digest should exist")
.to_owned();
assert_eq!(
user.update_password_confirmed("password", Some("")),
Err(SecurePasswordError::ConfirmationMismatch)
);
assert_eq!(user.password_digest(), Some(digest.as_str()));
}
#[test]
fn rails_updating_existing_user_with_nil_password_confirmation() {
let mut user = RailsUser::default();
user.set_password("secret").expect("password should hash");
assert_eq!(user.update_password_confirmed("password", None), Ok(true));
assert!(user.authenticate("password").expect("digest should parse"));
}
#[test]
fn rails_updating_existing_user_with_incorrect_password_confirmation() {
let mut user = RailsUser::default();
user.set_password("secret").expect("password should hash");
let digest = user
.password_digest()
.expect("digest should exist")
.to_owned();
assert_eq!(
user.update_password_confirmed("password", Some("something else")),
Err(SecurePasswordError::ConfirmationMismatch)
);
assert_eq!(user.password_digest(), Some(digest.as_str()));
assert!(user.authenticate("secret").expect("digest should parse"));
}
#[test]
fn rails_updating_existing_user_with_spaces_only_password_and_incorrect_password_confirmation()
{
let mut user = RailsUser::default();
user.set_password("secret").expect("password should hash");
let digest = user
.password_digest()
.expect("digest should exist")
.to_owned();
assert_eq!(
user.update_password_confirmed(" ", Some("something else")),
Err(SecurePasswordError::ConfirmationMismatch)
);
assert_eq!(user.password_digest(), Some(digest.as_str()));
assert!(user.authenticate("secret").expect("digest should parse"));
}
rails_ignored_test!(
rails_updating_existing_user_with_blank_password_digest,
"ActiveModel validation against blank persisted digests is outside SecurePassword scope"
);
rails_ignored_test!(
rails_updating_existing_user_with_nil_password_digest,
"ActiveModel validation against nil persisted digests is outside SecurePassword scope"
);
#[test]
fn rails_setting_a_blank_password_should_not_change_an_existing_password() {
let mut user = RailsUser::default();
user.set_password("secret").expect("password should hash");
let digest = user
.password_digest()
.expect("digest should exist")
.to_owned();
assert_eq!(user.update_password(""), Ok(false));
assert_eq!(user.password_digest(), Some(digest.as_str()));
assert!(user.authenticate("secret").expect("digest should parse"));
}
rails_ignored_test!(
rails_setting_a_nil_password_should_clear_an_existing_password,
"set_password accepts &str and has no nil concept"
);
rails_ignored_test!(
rails_override_secure_password_attribute,
"per-attribute generated accessors are a Rails metaprogramming feature"
);
#[test]
fn rails_authenticate_should_return_false_and_not_raise_when_password_digest_is_blank() {
let user = RailsUser {
digest: Some(String::new()),
};
assert_eq!(user.authenticate(" "), Ok(false));
}
#[test]
fn rails_password_salt() {
let mut user = RailsUser::default();
user.set_password("secret").expect("password should hash");
let digest = user.password_digest().expect("digest should exist");
assert_eq!(user.password_salt(), Some(digest[..29].to_string()));
}
rails_ignored_test!(
rails_password_salt_should_return_nil_when_password_is_nil,
"the SecurePassword trait has no cached plain-text password field"
);
#[test]
fn rails_password_salt_should_return_nil_when_password_digest_is_nil() {
let user = RailsUser::default();
assert_eq!(user.password_salt(), None);
}
rails_ignored_test!(
rails_password_digest_cost_defaults_to_bcrypt_default_cost_when_min_cost_is_false,
"SecurePassword does not expose configurable bcrypt cost or min-cost test knobs"
);
rails_ignored_test!(
rails_password_digest_cost_honors_bcrypt_cost_attribute_when_min_cost_is_false,
"SecurePassword does not expose configurable bcrypt cost or min-cost test knobs"
);
rails_ignored_test!(
rails_password_digest_cost_can_be_set_to_bcrypt_min_cost_to_speed_up_tests,
"SecurePassword does not expose configurable bcrypt cost or min-cost test knobs"
);
rails_ignored_test!(
rails_password_reset_token_duration,
"recovery tokens do not yet encode or enforce expiration windows"
);
#[test]
fn rails_argon2_password_salt_extraction() {
let mut user = RailsUser::default();
user.set_password_with_algorithm("secret", "argon2")
.expect("password should hash");
let digest = user.password_digest().expect("digest should exist");
let parsed = argon2::PasswordHash::new(digest).expect("argon2 digest should parse");
assert_eq!(
user.password_salt(),
parsed.salt.map(|salt| salt.as_str().to_string())
);
assert!(user.password_salt().is_some());
}
rails_ignored_test!(
rails_argon2_allows_long_passwords,
"rustrails-model keeps the Rails-compatible 72-byte limit even with Argon2"
);
rails_ignored_test!(
rails_authenticate_recovery_password_generated_helper,
"the SecurePassword trait manages a single digest and does not generate per-attribute authenticate_* helpers"
);
rails_ignored_test!(
rails_password_reset_token_accessor_is_not_generated_for_unrelated_models,
"SecurePassword exposes recovery tokens through trait methods, not Rails-style generated model accessors"
);
rails_ignored_test!(
rails_password_reset_token_generated_attribute_accessor,
"SecurePassword exposes generate_recovery_token instead of a Rails-style password_reset_token accessor"
);
rails_ignored_test!(
rails_find_by_password_reset_token_class_helper,
"SecurePassword is a trait and does not generate Rails model class finder helpers"
);
rails_ignored_test!(
rails_find_by_password_reset_token_bang_class_helper,
"SecurePassword is a trait and does not generate Rails model class finder helpers"
);
}