Skip to main content

dryoc/classic/
crypto_pwhash.rs

1//! # Password hashing
2//!
3//! Implements libsodium's `crypto_pwhash_*` functions. This implementation
4//! currently only supports Argon2i and Argon2id algorithms, and does not
5//! support scrypt.
6//!
7//! To use the string-based functions, the `base64` crate feature must be
8//! enabled.
9//!
10//! For details, refer to [libsodium docs](https://libsodium.gitbook.io/doc/password_hashing/default_phf).
11//!
12//! ## Classic API example, key derivation
13//!
14//! ```
15//! use base64::{Engine as _, engine::general_purpose};
16//! use dryoc::classic::crypto_pwhash::*;
17//! use dryoc::rng::copy_randombytes;
18//! use dryoc::constants::{CRYPTO_SECRETBOX_KEYBYTES, CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
19//!     CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE, CRYPTO_PWHASH_SALTBYTES};
20//!
21//! let mut key = [0u8; CRYPTO_SECRETBOX_KEYBYTES];
22//!
23//! // Randomly generate a salt
24//! let mut salt = [0u8; CRYPTO_PWHASH_SALTBYTES];
25//! copy_randombytes(&mut salt);
26//!
27//! // Create a really good password
28//! let password = b"It is by riding a bicycle that you learn the contours of a country best, since you have to sweat up the hills and coast down them.";
29//!
30//! crypto_pwhash(
31//!     &mut key,
32//!     password,
33//!     &salt,
34//!     CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
35//!     CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE,
36//!     PasswordHashAlgorithm::Argon2id13,
37//! )
38//! .expect("pwhash failed");
39//!
40//! // now `key` can be used as a secret key
41//! println!("key = {}", general_purpose::STANDARD_NO_PAD.encode(&key));
42//! ```
43
44#[cfg(feature = "serde")]
45use serde::{Deserialize, Serialize};
46#[cfg(feature = "base64")]
47use subtle::ConstantTimeEq;
48use zeroize::Zeroize;
49
50#[cfg(feature = "base64")]
51use crate::argon2::ARGON2_VERSION_NUMBER;
52use crate::argon2::{self, argon2_hash};
53use crate::constants::*;
54use crate::error::Error;
55
56pub(crate) const STR_HASHBYTES: usize = 32;
57
58#[cfg_attr(
59    feature = "serde",
60    derive(Zeroize, Clone, Debug, Serialize, Deserialize)
61)]
62#[cfg_attr(not(feature = "serde"), derive(Zeroize, Clone, Debug))]
63/// Password hash algorithm implementations.
64pub enum PasswordHashAlgorithm {
65    /// Argon2i version 0x13 (v19)
66    Argon2i13  = 1,
67    /// Argon2id version 0x13 (v19)
68    Argon2id13 = 2,
69}
70
71impl From<u32> for PasswordHashAlgorithm {
72    fn from(num: u32) -> Self {
73        // a bit clunky but it gets the job done
74        match num {
75            num if num == PasswordHashAlgorithm::Argon2i13 as u32 => {
76                PasswordHashAlgorithm::Argon2i13
77            }
78            num if num == PasswordHashAlgorithm::Argon2id13 as u32 => {
79                PasswordHashAlgorithm::Argon2id13
80            }
81            _ => panic!("invalid password hash algorithm type: {}", num),
82        }
83    }
84}
85
86impl From<PasswordHashAlgorithm> for argon2::Argon2Type {
87    fn from(algo: PasswordHashAlgorithm) -> Self {
88        match algo {
89            PasswordHashAlgorithm::Argon2i13 => argon2::Argon2Type::Argon2i,
90            PasswordHashAlgorithm::Argon2id13 => argon2::Argon2Type::Argon2id,
91        }
92    }
93}
94
95/// Hashes `password` with `salt`, placing the resulting hash into `output`.
96///
97/// * `opslimit` specifies the number of iterations to use in the underlying
98///   algorithm
99/// * `memlimit` specifies the maximum amount of memory to use, in bytes
100///
101/// Generally speaking, you want to set `opslimit` and `memlimit` sufficiently
102/// large such that it's hard for someone to brute-force a password.
103///
104/// For your convenience, the following constants are defined which can be used
105/// with `opslimit` and `memlimit`:
106/// * [`CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE`] and
107///   [`CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE`] for interactive operations
108/// * [`CRYPTO_PWHASH_OPSLIMIT_MODERATE`] and
109///   [`CRYPTO_PWHASH_MEMLIMIT_MODERATE`] for typical operations, such as
110///   server-side password hashing
111/// * [`CRYPTO_PWHASH_OPSLIMIT_SENSITIVE`] and
112///   [`CRYPTO_PWHASH_MEMLIMIT_SENSITIVE`] for sensitive operations
113///
114/// Compatible with libsodium's `crypto_pwhash`.
115pub fn crypto_pwhash(
116    output: &mut [u8],
117    password: &[u8],
118    salt: &[u8],
119    opslimit: u64,
120    memlimit: usize,
121    algorithm: PasswordHashAlgorithm,
122) -> Result<(), Error> {
123    validate!(
124        CRYPTO_PWHASH_OPSLIMIT_MIN,
125        CRYPTO_PWHASH_OPSLIMIT_MAX,
126        opslimit,
127        "opslimit"
128    );
129    validate!(
130        CRYPTO_PWHASH_MEMLIMIT_MIN,
131        CRYPTO_PWHASH_MEMLIMIT_MAX,
132        memlimit,
133        "memlimit"
134    );
135
136    let (t_cost, m_cost) = convert_costs(opslimit, memlimit);
137
138    argon2_hash(
139        t_cost,
140        m_cost,
141        1,
142        password,
143        salt,
144        None,
145        None,
146        output,
147        algorithm.into(),
148    )
149}
150
151#[cfg(any(feature = "base64", all(doc, not(doctest))))]
152#[cfg_attr(all(feature = "nightly", doc), doc(cfg(feature = "base64")))]
153pub(crate) fn pwhash_to_string(t_cost: u32, m_cost: u32, salt: &[u8], hash: &[u8]) -> String {
154    use base64::Engine as _;
155    use base64::engine::general_purpose;
156
157    format!(
158        "$argon2id$v={}$m={},t={},p=1${}${}",
159        argon2::ARGON2_VERSION_NUMBER,
160        m_cost,
161        t_cost,
162        general_purpose::STANDARD_NO_PAD.encode(salt),
163        general_purpose::STANDARD_NO_PAD.encode(hash),
164    )
165}
166
167pub(crate) fn convert_costs(opslimit: u64, memlimit: usize) -> (u32, u32) {
168    (opslimit as u32, (memlimit / 1024) as u32)
169}
170
171/// Hash a password string with a random salt.
172///
173/// This function provides a wrapper for [`crypto_pwhash`] that returns a string
174/// encoding of a hashed password with a random salt, suitable for use with
175/// password hash storage (i.e., in a database). Can be used to verify a
176/// password using [`crypto_pwhash_str_verify`].
177///
178/// Compatible with libsodium's `crypto_pwhash_str`.
179#[cfg(any(feature = "base64", all(doc, not(doctest))))]
180#[cfg_attr(all(feature = "nightly", doc), doc(cfg(feature = "base64")))]
181pub fn crypto_pwhash_str(password: &[u8], opslimit: u64, memlimit: usize) -> Result<String, Error> {
182    validate!(
183        CRYPTO_PWHASH_OPSLIMIT_MIN,
184        CRYPTO_PWHASH_OPSLIMIT_MAX,
185        opslimit,
186        "opslimit"
187    );
188    validate!(
189        CRYPTO_PWHASH_MEMLIMIT_MIN,
190        CRYPTO_PWHASH_MEMLIMIT_MAX,
191        memlimit,
192        "memlimit"
193    );
194
195    let mut salt = [0u8; CRYPTO_PWHASH_SALTBYTES];
196    let mut hash = [0u8; STR_HASHBYTES];
197    crate::rng::copy_randombytes(&mut salt);
198
199    let (t_cost, m_cost) = convert_costs(opslimit, memlimit);
200
201    argon2_hash(
202        t_cost,
203        m_cost,
204        1,
205        password,
206        &salt,
207        None,
208        None,
209        &mut hash,
210        argon2::Argon2Type::Argon2id,
211    )?;
212
213    let pw = pwhash_to_string(t_cost, m_cost, &salt, &hash);
214
215    Ok(pw)
216}
217
218#[cfg(feature = "base64")]
219#[derive(Default)]
220pub(crate) struct Pwhash {
221    pub(crate) pwhash: Option<Vec<u8>>,
222    pub(crate) salt: Option<Vec<u8>>,
223    pub(crate) type_: Option<PasswordHashAlgorithm>,
224    pub(crate) t_cost: Option<u32>,
225    pub(crate) m_cost: Option<u32>,
226    pub(crate) parallelism: Option<u32>,
227    pub(crate) version: Option<u32>,
228}
229
230#[cfg(feature = "base64")]
231impl Pwhash {
232    pub(crate) fn parse_encoded_pwhash(hashed_password: &str) -> Result<Self, Error> {
233        use base64::Engine;
234        let mut pwhash = Pwhash::default();
235        let base64_engine = base64::engine::general_purpose::GeneralPurpose::new(
236            &base64::alphabet::STANDARD,
237            base64::engine::general_purpose::NO_PAD,
238        );
239
240        for s in hashed_password.split('$') {
241            if s.is_empty() {
242                // skip
243            } else if s.starts_with("argon2") {
244                match s {
245                    "argon2i" => pwhash.type_ = Some(PasswordHashAlgorithm::Argon2i13),
246                    "argon2id" => pwhash.type_ = Some(PasswordHashAlgorithm::Argon2id13),
247                    _ => return Err(dryoc_error!(format!("invalid type: {}", s))),
248                }
249            } else if let Some(stripped) = s.strip_prefix("v=") {
250                pwhash.version = Some(
251                    stripped
252                        .parse::<u32>()
253                        .map_err(|_| dryoc_error!("unable to decode password hash version"))?,
254                );
255            } else if s.contains("m=") && s.contains("t=") && s.contains("p=") {
256                for p in s.split(',') {
257                    if let Some(m_cost) = p.strip_prefix("m=") {
258                        pwhash.m_cost = Some(m_cost.parse::<u32>().map_err(|_| {
259                            dryoc_error!("unable to decode password hash parameter m_cost")
260                        })?);
261                    } else if let Some(t_cost) = p.strip_prefix("t=") {
262                        pwhash.t_cost = Some(t_cost.parse::<u32>().map_err(|_| {
263                            dryoc_error!("unable to decode password hash parameter t_cost")
264                        })?);
265                    } else if let Some(parallelism) = p.strip_prefix("p=") {
266                        pwhash.parallelism = Some(parallelism.parse::<u32>().map_err(|_| {
267                            dryoc_error!("unable to decode password hash parameter t_cost")
268                        })?);
269                    }
270                }
271            } else if pwhash.salt.is_none() {
272                pwhash.salt = base64_engine.decode(s).ok();
273            } else if pwhash.pwhash.is_none() {
274                pwhash.pwhash = base64_engine.decode(s).ok();
275            }
276        }
277
278        // Check if version is supported
279        if pwhash.version.is_none() || pwhash.version.unwrap() != ARGON2_VERSION_NUMBER {
280            Err(dryoc_error!("unsupported password hash"))
281        // Verify correct value for parallism
282        } else if pwhash.parallelism.is_none() || pwhash.parallelism.unwrap() != 1 {
283            Err(dryoc_error!("parallelism missing or invalid"))
284        // Check for missing fields
285        } else if pwhash.pwhash.is_none() || pwhash.pwhash.as_ref().unwrap().is_empty() {
286            Err(dryoc_error!("password hash missing"))
287        } else if pwhash.salt.is_none() || pwhash.salt.as_ref().unwrap().is_empty() {
288            Err(dryoc_error!("password salt missing"))
289        } else if pwhash.type_.is_none() {
290            Err(dryoc_error!("algorithm type missing"))
291        } else if pwhash.m_cost.is_none() {
292            Err(dryoc_error!("m_cost missing"))
293        } else if pwhash.t_cost.is_none() {
294            Err(dryoc_error!("t_cost missing"))
295        } else {
296            Ok(pwhash)
297        }
298    }
299}
300
301/// Verifies that `hashed_password` is valid for `password`, assuming the hashed
302/// password was encoded using `crypto_pwhash_str`.
303///
304/// Compatible with libsodium's `crypto_pwhash_str_verify`.
305#[cfg(any(feature = "base64", all(doc, not(doctest))))]
306#[cfg_attr(all(feature = "nightly", doc), doc(cfg(feature = "base64")))]
307pub fn crypto_pwhash_str_verify(hashed_password: &str, password: &[u8]) -> Result<(), Error> {
308    let mut hash = [0u8; STR_HASHBYTES];
309
310    let pwhash = Pwhash::parse_encoded_pwhash(hashed_password)?;
311
312    argon2_hash(
313        pwhash.t_cost.unwrap(),
314        pwhash.m_cost.unwrap(),
315        pwhash.parallelism.unwrap(),
316        password,
317        pwhash.salt.unwrap().as_ref(),
318        None,
319        None,
320        &mut hash,
321        pwhash.type_.unwrap().into(),
322    )?;
323
324    if hash.ct_eq(pwhash.pwhash.unwrap().as_ref()).unwrap_u8() == 1 {
325        Ok(())
326    } else {
327        Err(dryoc_error!("password hashes do not match"))
328    }
329}
330
331/// Checks if the parameters for `hashed_password` match those passed to the
332/// function. Returns `false` if the parameters match, and `true` if the
333/// parameters are mismatched (requiring a rehash).
334///
335/// Compatible with libsodium's `crypto_pwhash_str_needs_rehash`.
336#[cfg(any(feature = "base64", all(doc, not(doctest))))]
337#[cfg_attr(all(feature = "nightly", doc), doc(cfg(feature = "base64")))]
338pub fn crypto_pwhash_str_needs_rehash(
339    hashed_password: &str,
340    opslimit: u64,
341    memlimit: usize,
342) -> Result<bool, Error> {
343    let pwhash = Pwhash::parse_encoded_pwhash(hashed_password)?;
344
345    let (t_cost, m_cost) = convert_costs(opslimit, memlimit);
346
347    if t_cost != pwhash.t_cost.unwrap() || m_cost != pwhash.m_cost.unwrap() {
348        Ok(true)
349    } else {
350        Ok(false)
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn test_crypto_pwhash() {
360        use sodiumoxide::crypto::pwhash;
361
362        use crate::rng::copy_randombytes;
363
364        let mut hash = [0u8; 32];
365        let mut so_hash = [0u8; 32];
366        let mut salt = [0u8; CRYPTO_PWHASH_SALTBYTES];
367
368        copy_randombytes(&mut salt);
369
370        let password = b"donkey kong";
371
372        crypto_pwhash(
373            &mut hash,
374            password,
375            &salt,
376            CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
377            CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE,
378            PasswordHashAlgorithm::Argon2id13,
379        )
380        .expect("pwhash failed");
381
382        let _ = pwhash::argon2id13::derive_key(
383            &mut so_hash,
384            password,
385            &pwhash::argon2id13::Salt::from_slice(&salt).expect("salt failed"),
386            pwhash::argon2id13::OPSLIMIT_INTERACTIVE,
387            pwhash::argon2id13::MEMLIMIT_INTERACTIVE,
388        )
389        .expect("so pwhash failed");
390
391        assert_eq!(hash, so_hash);
392    }
393
394    #[cfg(feature = "base64")]
395    #[test]
396    fn test_crypto_pwhash_str() {
397        use sodiumoxide::crypto::pwhash;
398
399        let password = b"donkey kong";
400
401        let pwhash = crypto_pwhash_str(
402            password,
403            CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
404            CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE,
405        )
406        .expect("pwhash failed");
407        let pwhash2 = crypto_pwhash_str(
408            password,
409            CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
410            CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE,
411        )
412        .expect("pwhash failed");
413
414        let parsed = Pwhash::parse_encoded_pwhash(&pwhash).expect("couldn't parse pwhash");
415        let parsed2 = Pwhash::parse_encoded_pwhash(&pwhash2).expect("couldn't parse pwhash");
416
417        assert_ne!(
418            parsed.salt.as_ref().expect("missing salt"),
419            &vec![0u8; CRYPTO_PWHASH_SALTBYTES]
420        );
421        assert_ne!(parsed.salt, parsed2.salt);
422
423        let mut pwhash_bytes = [0u8; CRYPTO_PWHASH_STRBYTES];
424        pwhash_bytes[..pwhash.len()].copy_from_slice(pwhash.as_bytes());
425
426        assert!(pwhash::argon2id13::pwhash_verify(
427            &pwhash::argon2id13::HashedPassword::from_slice(&pwhash_bytes)
428                .expect("hashed password failed"),
429            password,
430        ));
431    }
432
433    #[cfg(feature = "base64")]
434    #[test]
435    fn test_crypto_pwhash_str_verify() {
436        use sodiumoxide::crypto::pwhash;
437
438        let password = b"donkey kong";
439
440        let pwhash = pwhash::argon2id13::pwhash(
441            password,
442            pwhash::argon2id13::OPSLIMIT_INTERACTIVE,
443            pwhash::argon2id13::MEMLIMIT_INTERACTIVE,
444        )
445        .expect("so pwhash failed");
446
447        let pw_str = std::str::from_utf8(&pwhash.0)
448            .expect("from ut8 failed")
449            .trim_end_matches('\x00');
450
451        crypto_pwhash_str_verify(pw_str, password).expect("verify failed");
452        crypto_pwhash_str_verify(pw_str, b"invalid password")
453            .expect_err("verify should have failed");
454
455        // should be false
456        assert!(
457            !crypto_pwhash_str_needs_rehash(
458                pw_str,
459                CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
460                CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
461            )
462            .expect("verify rehash failed")
463        );
464
465        // should be true
466        assert!(
467            crypto_pwhash_str_needs_rehash(
468                pw_str,
469                CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE + 1,
470                CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
471            )
472            .expect("verify rehash failed")
473        );
474    }
475}