@jpgilldev / steid

3.6 KBRaw
1use argon2::{
2 Argon2,
3 password_hash::{
4 PasswordHash as EncodedHash, PasswordHasher as _, PasswordVerifier, SaltString,
5 rand_core::OsRng,
6 },
7};
8
9use crate::{
10 application::port::{PasswordError, PasswordHasher},
11 domain::PasswordHash,
12};
13
14/// Argon2id password hashing with the crate's default parameters.
15#[derive(Debug, Default, Clone)]
16pub struct Argon2Hasher;
17
18impl Argon2Hasher {
19 pub fn new() -> Self {
20 Self
21 }
22}
23
24impl PasswordHasher for Argon2Hasher {
25 fn hash(&self, plaintext: &str) -> Result<PasswordHash, PasswordError> {
26 let salt = SaltString::generate(&mut OsRng);
27
28 Argon2::default()
29 .hash_password(plaintext.as_bytes(), &salt)
30 .map(|hash| PasswordHash::from_trusted(hash.to_string()))
31 .map_err(|error| PasswordError::new(error.to_string()))
32 }
33
34 fn verify(&self, plaintext: &str, hash: &PasswordHash) -> Result<bool, PasswordError> {
35 let parsed = EncodedHash::new(hash.as_str())
36 .map_err(|error| PasswordError::new(format!("stored hash is unreadable: {error}")))?;
37
38 match Argon2::default().verify_password(plaintext.as_bytes(), &parsed) {
39 Ok(()) => Ok(true),
40 Err(argon2::password_hash::Error::Password) => Ok(false),
41 Err(error) => Err(PasswordError::new(error.to_string())),
42 }
43 }
44}
45
46/// A fast, insecure hasher for tests.
47///
48/// Argon2 is deliberately slow; using it across a use case suite costs seconds per
49/// test. This keeps those tests fast. Never construct it outside `#[cfg(test)]` paths.
50#[derive(Debug, Default, Clone)]
51pub struct StubHasher;
52
53impl StubHasher {
54 pub fn new() -> Self {
55 Self
56 }
57}
58
59impl PasswordHasher for StubHasher {
60 fn hash(&self, plaintext: &str) -> Result<PasswordHash, PasswordError> {
61 Ok(PasswordHash::from_trusted(format!("stub:{plaintext}")))
62 }
63
64 fn verify(&self, plaintext: &str, hash: &PasswordHash) -> Result<bool, PasswordError> {
65 let stored = hash
66 .as_str()
67 .strip_prefix("stub:")
68 .ok_or_else(|| PasswordError::new("not a stub hash"))?;
69
70 Ok(stored == plaintext)
71 }
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77
78 #[test]
79 fn argon2_accepts_the_correct_password() {
80 let hasher = Argon2Hasher::new();
81 let hash = hasher.hash("correct horse battery staple").expect("hash");
82
83 assert!(
84 hasher
85 .verify("correct horse battery staple", &hash)
86 .expect("verify")
87 );
88 }
89
90 #[test]
91 fn argon2_rejects_a_wrong_password_without_erroring() {
92 let hasher = Argon2Hasher::new();
93 let hash = hasher.hash("correct horse battery staple").expect("hash");
94
95 assert!(!hasher.verify("wrong password", &hash).expect("verify"));
96 }
97
98 #[test]
99 fn argon2_salts_each_hash_separately() {
100 let hasher = Argon2Hasher::new();
101
102 let first = hasher.hash("same password").expect("hash");
103 let second = hasher.hash("same password").expect("hash");
104
105 assert_ne!(first.as_str(), second.as_str());
106 }
107
108 #[test]
109 fn an_unreadable_stored_hash_is_an_error_not_a_failed_login() {
110 let hasher = Argon2Hasher::new();
111 let corrupt = PasswordHash::from_trusted("not-a-real-hash");
112
113 assert!(hasher.verify("anything", &corrupt).is_err());
114 }
115
116 #[test]
117 fn the_stub_round_trips() {
118 let hasher = StubHasher::new();
119 let hash = hasher.hash("password").expect("hash");
120
121 assert!(hasher.verify("password", &hash).expect("verify"));
122 assert!(!hasher.verify("other", &hash).expect("verify"));
123 }
124}