steid

@jamesgill /

feat: password hashing behind a port

PasswordHasher port with an Argon2id adapter and a stub for tests. 5 tests.

The port exists mainly so tests can substitute the stub. Argon2 is
deliberately slow, and a use case suite that hashes for real costs seconds
per test -- which is how a fast test suite stops being run.

verify() returns Ok(false) for a wrong password but Err for a hash it cannot
parse. A corrupt stored hash is a different problem from a failed login and
collapsing the two hides real faults behind "invalid credentials".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JamesPatrickGill authored 1 month agoparentfc5b566Browse files26d982868a1bfa15761c4dafba956bb7317dd0c7

6 files changed+207 −0

Cargo.lock+39 −0View file
@@ -76,6 +76,18 @@ version = "1.1.0"
7676 source = "registry+https://github.com/rust-lang/crates.io-index"
7777 checksum = "fb5dfbc6d8d2675589ccbe4d0fd61df2419075625f8c1a62325e718e2b0049f9"
7878
79+[[package]]
80+name = "argon2"
81+version = "0.5.3"
82+source = "registry+https://github.com/rust-lang/crates.io-index"
83+checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
84+dependencies = [
85+ "base64ct",
86+ "blake2",
87+ "cpufeatures 0.2.17",
88+ "password-hash",
89+]
90+
7991 [[package]]
8092 name = "async-compression"
8193 version = "0.4.43"
@@ -121,6 +133,12 @@ version = "0.23.0"
121133 source = "registry+https://github.com/rust-lang/crates.io-index"
122134 checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9"
123135
136+[[package]]
137+name = "base64ct"
138+version = "1.8.3"
139+source = "registry+https://github.com/rust-lang/crates.io-index"
140+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
141+
124142 [[package]]
125143 name = "bitflags"
126144 version = "2.13.1"
@@ -130,6 +148,15 @@ dependencies = [
130148 "serde_core",
131149 ]
132150
151+[[package]]
152+name = "blake2"
153+version = "0.10.6"
154+source = "registry+https://github.com/rust-lang/crates.io-index"
155+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
156+dependencies = [
157+ "digest 0.10.7",
158+]
159+
133160 [[package]]
134161 name = "block-buffer"
135162 version = "0.10.4"
@@ -1206,6 +1233,17 @@ dependencies = [
12061233 "windows-link",
12071234 ]
12081235
1236+[[package]]
1237+name = "password-hash"
1238+version = "0.5.0"
1239+source = "registry+https://github.com/rust-lang/crates.io-index"
1240+checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
1241+dependencies = [
1242+ "base64ct",
1243+ "rand_core 0.6.4",
1244+ "subtle",
1245+]
1246+
12091247 [[package]]
12101248 name = "percent-encoding"
12111249 version = "2.3.2"
@@ -1780,6 +1818,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
17801818 name = "steid"
17811819 version = "0.1.0"
17821820 dependencies = [
1821+ "argon2",
17831822 "dotenvy",
17841823 "envy",
17851824 "serde",
Cargo.toml+1 −0View file
@@ -4,6 +4,7 @@ version = "0.1.0"
44 edition = "2024"
55
66 [dependencies]
7+argon2 = "0.5.3"
78 dotenvy = "0.15.7"
89 envy = "0.4.2"
910 serde = { version = "1.0.229", features = ["derive"] }
src/application/mod.rs+1 −0View file
@@ -1,3 +1,4 @@
11 pub mod config;
2+pub mod port;
23
34 pub use config::AppConfig;
src/application/port.rs+41 −0View file
@@ -0,0 +1,41 @@
1+//! Service ports the application layer needs.
2+//!
3+//! Repository ports live in `domain::repository`; these are the non-persistence
4+//! collaborators. Adapters live in `infrastructure`.
5+
6+use crate::domain::PasswordHash;
7+
8+/// Hashes and verifies passwords.
9+///
10+/// A port rather than a direct Argon2 call so tests can substitute a fast stub —
11+/// Argon2 is deliberately slow, and a use case suite that hashes for real takes
12+/// seconds per test.
13+pub trait PasswordHasher: Send + Sync {
14+ /// Hashes a plaintext password.
15+ fn hash(&self, plaintext: &str) -> Result<PasswordHash, PasswordError>;
16+
17+ /// Checks a plaintext password against a stored hash.
18+ ///
19+ /// Returns `Ok(false)` for a wrong password and `Err` only when the hash itself
20+ /// cannot be parsed — a corrupt stored hash is a different problem from a failed
21+ /// login, and collapsing them hides real faults.
22+ fn verify(&self, plaintext: &str, hash: &PasswordHash) -> Result<bool, PasswordError>;
23+}
24+
25+/// A password could not be hashed or a stored hash could not be parsed.
26+#[derive(Debug, Clone, PartialEq, Eq)]
27+pub struct PasswordError(String);
28+
29+impl PasswordError {
30+ pub fn new(message: impl Into<String>) -> Self {
31+ Self(message.into())
32+ }
33+}
34+
35+impl std::fmt::Display for PasswordError {
36+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37+ write!(f, "password hashing failed: {}", self.0)
38+ }
39+}
40+
41+impl std::error::Error for PasswordError {}
src/infrastructure/mod.rs+1 −0View file
@@ -1,3 +1,4 @@
11 pub mod database;
2+pub mod password;
23 pub mod repository;
34 pub mod web;
src/infrastructure/password.rs+124 −0View file
@@ -0,0 +1,124 @@
1+use argon2::{
2+ Argon2,
3+ password_hash::{
4+ PasswordHash as EncodedHash, PasswordHasher as _, PasswordVerifier, SaltString,
5+ rand_core::OsRng,
6+ },
7+};
8+
9+use 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)]
16+pub struct Argon2Hasher;
17+
18+impl Argon2Hasher {
19+ pub fn new() -> Self {
20+ Self
21+ }
22+}
23+
24+impl 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)]
51+pub struct StubHasher;
52+
53+impl StubHasher {
54+ pub fn new() -> Self {
55+ Self
56+ }
57+}
58+
59+impl 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)]
75+mod 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+}