Skip to main content

modern_crypto_toolkit/
password.rs

1//! Password hashing, verification, and key derivation with safe defaults.
2
3use std::str::FromStr;
4
5use argon2::{Algorithm as Argon2Algorithm, Argon2, Params as Argon2Params, Version};
6use base64::{engine::general_purpose::STANDARD, engine::general_purpose::STANDARD_NO_PAD, Engine as _};
7use pbkdf2::pbkdf2_hmac;
8use rand::rngs::OsRng;
9use rand::RngCore;
10use scrypt::{scrypt, Params as ScryptParams};
11use sha2::Sha256;
12use subtle::ConstantTimeEq;
13
14use crate::error::{Error, Result};
15
16/// Options for password hashing and key derivation.
17#[derive(Clone, Debug)]
18pub struct HashOptions {
19    /// PBKDF2 iteration count.
20    pub iterations: u32,
21    /// Argon2 time cost.
22    pub time_cost: u32,
23    /// Argon2 memory cost in KiB.
24    pub memory_cost: u32,
25    /// Argon2 parallelism.
26    pub parallelism: u32,
27    /// Derived key length in bytes.
28    pub output_length: usize,
29    /// scrypt logâ‚‚(N).
30    pub log_n: u8,
31    /// scrypt block size parameter.
32    pub r: u32,
33    /// scrypt parallelization parameter.
34    pub p: u32,
35    /// bcrypt cost factor.
36    pub cost: u32,
37}
38
39impl Default for HashOptions {
40    fn default() -> Self {
41        Self {
42            iterations: 100_000,
43            time_cost: 3,
44            memory_cost: 64 * 1024,
45            parallelism: 4,
46            output_length: 32,
47            log_n: 14,
48            r: 8,
49            p: 1,
50            cost: 12,
51        }
52    }
53}
54
55impl HashOptions {
56    /// Validates that the cost parameters are internally consistent and
57    /// above safe minimums. Called automatically by [`hash_with`] and
58    /// [`derive`]; also usable standalone to check a hand-built struct.
59    pub fn validate(&self) -> Result<()> {
60        if self.iterations == 0 {
61            return Err(Error::InvalidParameter(
62                "iterations must be at least 1".to_string(),
63            ));
64        }
65        if self.time_cost == 0 {
66            return Err(Error::InvalidParameter(
67                "argon2 time_cost must be at least 1".to_string(),
68            ));
69        }
70        if self.parallelism == 0 {
71            return Err(Error::InvalidParameter(
72                "argon2 parallelism must be at least 1".to_string(),
73            ));
74        }
75        // Argon2 requires memory >= 8 * parallelism (RFC 9106).
76        if u64::from(self.memory_cost) < 8 * u64::from(self.parallelism) {
77            return Err(Error::InvalidParameter(
78                "argon2 memory_cost is too low for the requested parallelism".to_string(),
79            ));
80        }
81        // scrypt requires 1 <= log_n < 64 and n = 2^log_n < 2^(128*r/8).
82        if self.log_n == 0 || self.log_n >= 64 {
83            return Err(Error::InvalidParameter(
84                "scrypt log_n must be between 1 and 63".to_string(),
85            ));
86        }
87        if self.r == 0 {
88            return Err(Error::InvalidParameter(
89                "scrypt r must be at least 1".to_string(),
90            ));
91        }
92        if self.p == 0 {
93            return Err(Error::InvalidParameter(
94                "scrypt p must be at least 1".to_string(),
95            ));
96        }
97        if self.output_length == 0 {
98            return Err(Error::InvalidParameter(
99                "output_length must be at least 1".to_string(),
100            ));
101        }
102        // bcrypt accepts cost factors in [4, 31].
103        if !(4..=31).contains(&self.cost) {
104            return Err(Error::InvalidParameter(
105                "bcrypt cost must be between 4 and 31".to_string(),
106            ));
107        }
108        Ok(())
109    }
110}
111
112fn opts_or_default(options: Option<&HashOptions>) -> HashOptions {
113    let mut defaults = HashOptions::default();
114    if let Some(o) = options {
115        if o.iterations != 0 {
116            defaults.iterations = o.iterations;
117        }
118        if o.time_cost != 0 {
119            defaults.time_cost = o.time_cost;
120        }
121        if o.memory_cost != 0 {
122            defaults.memory_cost = o.memory_cost;
123        }
124        if o.parallelism != 0 {
125            defaults.parallelism = o.parallelism;
126        }
127        if o.output_length != 0 {
128            defaults.output_length = o.output_length;
129        }
130        if o.log_n != 0 {
131            defaults.log_n = o.log_n;
132        }
133        if o.r != 0 {
134            defaults.r = o.r;
135        }
136        if o.p != 0 {
137            defaults.p = o.p;
138        }
139        if o.cost != 0 {
140            defaults.cost = o.cost;
141        }
142    }
143    defaults
144}
145
146fn normalize_algorithm(name: &str) -> String {
147    name.to_lowercase().replace('_', "-")
148}
149
150/// Hash a password with the default algorithm (Argon2id).
151pub fn hash(password: &str) -> Result<String> {
152    hash_with(password, "argon2id", None)
153}
154
155/// Hash a password with a chosen algorithm and optional cost parameters.
156pub fn hash_with(password: &str, algorithm: &str, options: Option<&HashOptions>) -> Result<String> {
157    let algorithm = normalize_algorithm(algorithm);
158    let opts = opts_or_default(options);
159    opts.validate()?;
160    match algorithm.as_str() {
161        "argon2id" => hash_argon2id(password, &opts),
162        "scrypt" => hash_scrypt(password, &opts),
163        "pbkdf2-sha256" => hash_pbkdf2(password, &opts),
164        "bcrypt" => hash_bcrypt(password, &opts),
165        _ => Err(Error::UnsupportedAlgorithm(algorithm)),
166    }
167}
168
169fn random_salt(len: usize) -> Vec<u8> {
170    let mut salt = vec![0u8; len];
171    OsRng.fill_bytes(&mut salt);
172    salt
173}
174
175fn phc_b64(data: &[u8]) -> String {
176    STANDARD_NO_PAD.encode(data)
177}
178
179fn phc_b64_decode(text: &str) -> Result<Vec<u8>> {
180    STANDARD_NO_PAD
181        .decode(text)
182        .map_err(|e| Error::Base64(e.to_string()))
183}
184
185fn ab64_encode(data: &[u8]) -> String {
186    STANDARD
187        .encode(data)
188        .replace('+', ".")
189        .trim_end_matches('=')
190        .to_string()
191}
192
193fn ab64_decode(text: &str) -> Result<Vec<u8>> {
194    let mut s = text.replace('.', "+");
195    let pad = (4 - (s.len() % 4)) % 4;
196    s.push_str(&"=".repeat(pad));
197    STANDARD
198        .decode(s)
199        .map_err(|e| Error::Base64(e.to_string()))
200}
201
202fn hash_argon2id(password: &str, opts: &HashOptions) -> Result<String> {
203    let salt = random_salt(16);
204    let params = Argon2Params::new(
205        opts.memory_cost,
206        opts.time_cost,
207        opts.parallelism,
208        Some(opts.output_length),
209    )
210    .map_err(|e| Error::InvalidParameter(e.to_string()))?;
211    let argon2 = Argon2::new(Argon2Algorithm::Argon2id, Version::V0x13, params);
212    let mut out = vec![0u8; opts.output_length];
213    argon2
214        .hash_password_into(password.as_bytes(), &salt, &mut out)
215        .map_err(|e| Error::Internal(e.to_string()))?;
216    Ok(format!(
217        "$argon2id$v=19$m={},t={},p={}${}${}",
218        opts.memory_cost,
219        opts.time_cost,
220        opts.parallelism,
221        phc_b64(&salt),
222        phc_b64(&out)
223    ))
224}
225
226fn hash_scrypt(password: &str, opts: &HashOptions) -> Result<String> {
227    let salt = random_salt(32);
228    let params = ScryptParams::new(opts.log_n, opts.r, opts.p, opts.output_length)
229        .map_err(|e| Error::InvalidParameter(e.to_string()))?;
230    let mut out = vec![0u8; opts.output_length];
231    scrypt(password.as_bytes(), &salt, &params, &mut out)
232        .map_err(|e| Error::Internal(e.to_string()))?;
233    Ok(format!(
234        "$scrypt$ln={},r={},p={}${}${}",
235        opts.log_n,
236        opts.r,
237        opts.p,
238        phc_b64(&salt),
239        phc_b64(&out)
240    ))
241}
242
243fn hash_pbkdf2(password: &str, opts: &HashOptions) -> Result<String> {
244    let salt = random_salt(32);
245    let mut out = vec![0u8; opts.output_length];
246    pbkdf2_hmac::<Sha256>(password.as_bytes(), &salt, opts.iterations, &mut out);
247    Ok(format!(
248        "$pbkdf2-sha256${}${}${}",
249        opts.iterations,
250        ab64_encode(&salt),
251        ab64_encode(&out)
252    ))
253}
254
255fn hash_bcrypt(password: &str, opts: &HashOptions) -> Result<String> {
256    let cost = if opts.cost == 0 { 12 } else { opts.cost };
257    bcrypt::hash(password, cost).map_err(|e| Error::Internal(e.to_string()))
258}
259
260/// Verify a password against a stored hash.
261pub fn verify(password: &str, hashed: &str) -> Result<bool> {
262    if hashed.starts_with("$argon2id$") {
263        verify_argon2id(password, hashed)
264    } else if hashed.starts_with("$scrypt$") {
265        verify_scrypt(password, hashed)
266    } else if hashed.starts_with("$pbkdf2-sha256$") {
267        verify_pbkdf2(password, hashed)
268    } else if hashed.starts_with("$2a$") || hashed.starts_with("$2b$") || hashed.starts_with("$2y$") {
269        bcrypt::verify(password, hashed).map_err(|e| Error::Internal(e.to_string()))
270    } else {
271        Err(Error::InvalidHashFormat(hashed.to_string()))
272    }
273}
274
275fn verify_argon2id(password: &str, hashed: &str) -> Result<bool> {
276    let parts: Vec<&str> = hashed.split('$').collect();
277    if parts.len() != 6 {
278        return Err(Error::InvalidHashFormat(hashed.to_string()));
279    }
280    let params = parse_params(parts[3])?;
281    let memory = u32::from_str(params.get("m").ok_or_else(|| Error::InvalidHashFormat("m".to_string()))?)
282        .map_err(|_| Error::InvalidHashFormat("m".to_string()))?;
283    let time = u32::from_str(params.get("t").ok_or_else(|| Error::InvalidHashFormat("t".to_string()))?)
284        .map_err(|_| Error::InvalidHashFormat("t".to_string()))?;
285    let parallelism = u32::from_str(params.get("p").ok_or_else(|| Error::InvalidHashFormat("p".to_string()))?)
286        .map_err(|_| Error::InvalidHashFormat("p".to_string()))?;
287    let salt = phc_b64_decode(parts[4])?;
288    let stored = phc_b64_decode(parts[5])?;
289    if salt.is_empty() || stored.is_empty() {
290        return Err(Error::InvalidHashFormat(hashed.to_string()));
291    }
292
293    let params = Argon2Params::new(memory, time, parallelism, Some(stored.len()))
294        .map_err(|e| Error::InvalidParameter(e.to_string()))?;
295    let argon2 = Argon2::new(Argon2Algorithm::Argon2id, Version::V0x13, params);
296    let mut out = vec![0u8; stored.len()];
297    argon2
298        .hash_password_into(password.as_bytes(), &salt, &mut out)
299        .map_err(|e| Error::Internal(e.to_string()))?;
300    Ok(out.as_slice().ct_eq(stored.as_slice()).into())
301}
302
303fn verify_scrypt(password: &str, hashed: &str) -> Result<bool> {
304    let parts: Vec<&str> = hashed.split('$').collect();
305    if parts.len() != 5 {
306        return Err(Error::InvalidHashFormat(hashed.to_string()));
307    }
308    let params = parse_params(parts[2])?;
309    let log_n = u8::from_str(params.get("ln").ok_or_else(|| Error::InvalidHashFormat("ln".to_string()))?)
310        .map_err(|_| Error::InvalidHashFormat("ln".to_string()))?;
311    let r = u32::from_str(params.get("r").ok_or_else(|| Error::InvalidHashFormat("r".to_string()))?)
312        .map_err(|_| Error::InvalidHashFormat("r".to_string()))?;
313    let p = u32::from_str(params.get("p").ok_or_else(|| Error::InvalidHashFormat("p".to_string()))?)
314        .map_err(|_| Error::InvalidHashFormat("p".to_string()))?;
315    let salt = phc_b64_decode(parts[3])?;
316    let stored = phc_b64_decode(parts[4])?;
317    if salt.is_empty() || stored.is_empty() {
318        return Err(Error::InvalidHashFormat(hashed.to_string()));
319    }
320
321    let params = ScryptParams::new(log_n, r, p, stored.len())
322        .map_err(|e| Error::InvalidParameter(e.to_string()))?;
323    let mut out = vec![0u8; stored.len()];
324    scrypt(password.as_bytes(), &salt, &params, &mut out)
325        .map_err(|e| Error::Internal(e.to_string()))?;
326    Ok(out.as_slice().ct_eq(stored.as_slice()).into())
327}
328
329fn verify_pbkdf2(password: &str, hashed: &str) -> Result<bool> {
330    let parts: Vec<&str> = hashed.split('$').collect();
331    if parts.len() != 5 {
332        return Err(Error::InvalidHashFormat(hashed.to_string()));
333    }
334    let iterations = u32::from_str(parts[2])
335        .map_err(|_| Error::InvalidHashFormat("iterations".to_string()))?;
336    let salt = ab64_decode(parts[3])?;
337    let stored = ab64_decode(parts[4])?;
338    if salt.is_empty() || stored.is_empty() {
339        return Err(Error::InvalidHashFormat(hashed.to_string()));
340    }
341
342    let mut out = vec![0u8; stored.len()];
343    pbkdf2_hmac::<Sha256>(password.as_bytes(), &salt, iterations, &mut out);
344    Ok(out.as_slice().ct_eq(stored.as_slice()).into())
345}
346
347fn parse_params(s: &str) -> Result<std::collections::HashMap<String, String>> {
348    let mut map = std::collections::HashMap::new();
349    for pair in s.split(',') {
350        let mut kv = pair.splitn(2, '=');
351        let k = kv.next().ok_or_else(|| Error::InvalidHashFormat(s.to_string()))?;
352        let v = kv.next().ok_or_else(|| Error::InvalidHashFormat(s.to_string()))?;
353        map.insert(k.to_string(), v.to_string());
354    }
355    Ok(map)
356}
357
358/// Derive a key from a passphrase and salt.
359pub fn derive(
360    passphrase: &str,
361    salt: &[u8],
362    length: usize,
363    algorithm: Option<&str>,
364    options: Option<&HashOptions>,
365) -> Result<Vec<u8>> {
366    if salt.is_empty() {
367        return Err(Error::InvalidParameter("salt must not be empty".to_string()));
368    }
369    if length == 0 {
370        return Err(Error::InvalidParameter("length must be positive".to_string()));
371    }
372
373    let algorithm = normalize_algorithm(algorithm.unwrap_or("pbkdf2_sha256"));
374    let opts = opts_or_default(options);
375    opts.validate()?;
376
377    match algorithm.as_str() {
378        "pbkdf2-sha256" => {
379            let mut out = vec![0u8; length];
380            pbkdf2_hmac::<Sha256>(passphrase.as_bytes(), salt, opts.iterations, &mut out);
381            Ok(out)
382        }
383        "scrypt" => {
384            let params = ScryptParams::new(opts.log_n, opts.r, opts.p, length)
385                .map_err(|e| Error::InvalidParameter(e.to_string()))?;
386            let mut out = vec![0u8; length];
387            scrypt(passphrase.as_bytes(), salt, &params, &mut out)
388                .map_err(|e| Error::Internal(e.to_string()))?;
389            Ok(out)
390        }
391        "argon2id" => {
392            let params = Argon2Params::new(
393                opts.memory_cost,
394                opts.time_cost,
395                opts.parallelism,
396                Some(length),
397            )
398            .map_err(|e| Error::InvalidParameter(e.to_string()))?;
399            let argon2 = Argon2::new(Argon2Algorithm::Argon2id, Version::V0x13, params);
400            let mut out = vec![0u8; length];
401            argon2
402                .hash_password_into(passphrase.as_bytes(), salt, &mut out)
403                .map_err(|e| Error::Internal(e.to_string()))?;
404            Ok(out)
405        }
406        _ => Err(Error::UnsupportedAlgorithm(algorithm)),
407    }
408}