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 salt = [0u8; CRYPTO_PWHASH_SALTBYTES];
196    let mut hash = [0u8; STR_HASHBYTES];
197
198    let (t_cost, m_cost) = convert_costs(opslimit, memlimit);
199
200    argon2_hash(
201        t_cost,
202        m_cost,
203        1,
204        password,
205        &salt,
206        None,
207        None,
208        &mut hash,
209        argon2::Argon2Type::Argon2id,
210    )?;
211
212    let pw = pwhash_to_string(t_cost, m_cost, &salt, &hash);
213
214    Ok(pw)
215}
216
217#[cfg(feature = "base64")]
218#[derive(Default)]
219pub(crate) struct Pwhash {
220    pub(crate) pwhash: Option<Vec<u8>>,
221    pub(crate) salt: Option<Vec<u8>>,
222    pub(crate) type_: Option<PasswordHashAlgorithm>,
223    pub(crate) t_cost: Option<u32>,
224    pub(crate) m_cost: Option<u32>,
225    pub(crate) parallelism: Option<u32>,
226    pub(crate) version: Option<u32>,
227}
228
229#[cfg(feature = "base64")]
230impl Pwhash {
231    pub(crate) fn parse_encoded_pwhash(hashed_password: &str) -> Result<Self, Error> {
232        use base64::Engine;
233        let mut pwhash = Pwhash::default();
234        let base64_engine = base64::engine::general_purpose::GeneralPurpose::new(
235            &base64::alphabet::STANDARD,
236            base64::engine::general_purpose::NO_PAD,
237        );
238
239        for s in hashed_password.split('$') {
240            if s.is_empty() {
241                // skip
242            } else if s.starts_with("argon2") {
243                match s {
244                    "argon2i" => pwhash.type_ = Some(PasswordHashAlgorithm::Argon2i13),
245                    "argon2id" => pwhash.type_ = Some(PasswordHashAlgorithm::Argon2id13),
246                    _ => return Err(dryoc_error!(format!("invalid type: {}", s))),
247                }
248            } else if let Some(stripped) = s.strip_prefix("v=") {
249                pwhash.version = Some(
250                    stripped
251                        .parse::<u32>()
252                        .map_err(|_| dryoc_error!("unable to decode password hash version"))?,
253                );
254            } else if s.contains("m=") && s.contains("t=") && s.contains("p=") {
255                for p in s.split(',') {
256                    if let Some(m_cost) = p.strip_prefix("m=") {
257                        pwhash.m_cost = Some(m_cost.parse::<u32>().map_err(|_| {
258                            dryoc_error!("unable to decode password hash parameter m_cost")
259                        })?);
260                    } else if let Some(t_cost) = p.strip_prefix("t=") {
261                        pwhash.t_cost = Some(t_cost.parse::<u32>().map_err(|_| {
262                            dryoc_error!("unable to decode password hash parameter t_cost")
263                        })?);
264                    } else if let Some(parallelism) = p.strip_prefix("p=") {
265                        pwhash.parallelism = Some(parallelism.parse::<u32>().map_err(|_| {
266                            dryoc_error!("unable to decode password hash parameter t_cost")
267                        })?);
268                    }
269                }
270            } else if pwhash.salt.is_none() {
271                pwhash.salt = base64_engine.decode(s).ok();
272            } else if pwhash.pwhash.is_none() {
273                pwhash.pwhash = base64_engine.decode(s).ok();
274            }
275        }
276
277        // Check if version is supported
278        if pwhash.version.is_none() || pwhash.version.unwrap() != ARGON2_VERSION_NUMBER {
279            Err(dryoc_error!("unsupported password hash"))
280        // Verify correct value for parallism
281        } else if pwhash.parallelism.is_none() || pwhash.parallelism.unwrap() != 1 {
282            Err(dryoc_error!("parallelism missing or invalid"))
283        // Check for missing fields
284        } else if pwhash.pwhash.is_none() || pwhash.pwhash.as_ref().unwrap().is_empty() {
285            Err(dryoc_error!("password hash missing"))
286        } else if pwhash.salt.is_none() || pwhash.salt.as_ref().unwrap().is_empty() {
287            Err(dryoc_error!("password salt missing"))
288        } else if pwhash.type_.is_none() {
289            Err(dryoc_error!("algorithm type missing"))
290        } else if pwhash.m_cost.is_none() {
291            Err(dryoc_error!("m_cost missing"))
292        } else if pwhash.t_cost.is_none() {
293            Err(dryoc_error!("t_cost missing"))
294        } else {
295            Ok(pwhash)
296        }
297    }
298}
299
300/// Verifies that `hashed_password` is valid for `password`, assuming the hashed
301/// password was encoded using `crypto_pwhash_str`.
302///
303/// Compatible with libsodium's `crypto_pwhash_str_verify`.
304#[cfg(any(feature = "base64", all(doc, not(doctest))))]
305#[cfg_attr(all(feature = "nightly", doc), doc(cfg(feature = "base64")))]
306pub fn crypto_pwhash_str_verify(hashed_password: &str, password: &[u8]) -> Result<(), Error> {
307    let mut hash = [0u8; STR_HASHBYTES];
308
309    let pwhash = Pwhash::parse_encoded_pwhash(hashed_password)?;
310
311    argon2_hash(
312        pwhash.t_cost.unwrap(),
313        pwhash.m_cost.unwrap(),
314        pwhash.parallelism.unwrap(),
315        password,
316        pwhash.salt.unwrap().as_ref(),
317        None,
318        None,
319        &mut hash,
320        pwhash.type_.unwrap().into(),
321    )?;
322
323    if hash.ct_eq(pwhash.pwhash.unwrap().as_ref()).unwrap_u8() == 1 {
324        Ok(())
325    } else {
326        Err(dryoc_error!("password hashes do not match"))
327    }
328}
329
330/// Checks if the parameters for `hashed_password` match those passed to the
331/// function. Returns `false` if the parameters match, and `true` if the
332/// parameters are mismatched (requiring a rehash).
333///
334/// Compatible with libsodium's `crypto_pwhash_str_needs_rehash`.
335#[cfg(any(feature = "base64", all(doc, not(doctest))))]
336#[cfg_attr(all(feature = "nightly", doc), doc(cfg(feature = "base64")))]
337pub fn crypto_pwhash_str_needs_rehash(
338    hashed_password: &str,
339    opslimit: u64,
340    memlimit: usize,
341) -> Result<bool, Error> {
342    let pwhash = Pwhash::parse_encoded_pwhash(hashed_password)?;
343
344    let (t_cost, m_cost) = convert_costs(opslimit, memlimit);
345
346    if t_cost != pwhash.t_cost.unwrap() || m_cost != pwhash.m_cost.unwrap() {
347        Ok(true)
348    } else {
349        Ok(false)
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn test_crypto_pwhash() {
359        use sodiumoxide::crypto::pwhash;
360
361        use crate::rng::copy_randombytes;
362
363        let mut hash = [0u8; 32];
364        let mut so_hash = [0u8; 32];
365        let mut salt = [0u8; CRYPTO_PWHASH_SALTBYTES];
366
367        copy_randombytes(&mut salt);
368
369        let password = b"donkey kong";
370
371        crypto_pwhash(
372            &mut hash,
373            password,
374            &salt,
375            CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
376            CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE,
377            PasswordHashAlgorithm::Argon2id13,
378        )
379        .expect("pwhash failed");
380
381        let _ = pwhash::argon2id13::derive_key(
382            &mut so_hash,
383            password,
384            &pwhash::argon2id13::Salt::from_slice(&salt).expect("salt failed"),
385            pwhash::argon2id13::OPSLIMIT_INTERACTIVE,
386            pwhash::argon2id13::MEMLIMIT_INTERACTIVE,
387        )
388        .expect("so pwhash failed");
389
390        assert_eq!(hash, so_hash);
391    }
392
393    #[cfg(feature = "base64")]
394    #[test]
395    fn test_crypto_pwhash_str() {
396        use sodiumoxide::crypto::pwhash;
397
398        let password = b"donkey kong";
399
400        let pwhash = crypto_pwhash_str(
401            password,
402            CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
403            CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE,
404        )
405        .expect("pwhash failed");
406
407        let mut pwhash_bytes = [0u8; CRYPTO_PWHASH_STRBYTES];
408        pwhash_bytes[..pwhash.len()].copy_from_slice(pwhash.as_bytes());
409
410        assert!(pwhash::argon2id13::pwhash_verify(
411            &pwhash::argon2id13::HashedPassword::from_slice(&pwhash_bytes)
412                .expect("hashed password failed"),
413            password,
414        ));
415    }
416
417    #[cfg(feature = "base64")]
418    #[test]
419    fn test_crypto_pwhash_str_verify() {
420        use sodiumoxide::crypto::pwhash;
421
422        let password = b"donkey kong";
423
424        let pwhash = pwhash::argon2id13::pwhash(
425            password,
426            pwhash::argon2id13::OPSLIMIT_INTERACTIVE,
427            pwhash::argon2id13::MEMLIMIT_INTERACTIVE,
428        )
429        .expect("so pwhash failed");
430
431        let pw_str = std::str::from_utf8(&pwhash.0)
432            .expect("from ut8 failed")
433            .trim_end_matches('\x00');
434
435        crypto_pwhash_str_verify(pw_str, password).expect("verify failed");
436        crypto_pwhash_str_verify(pw_str, b"invalid password")
437            .expect_err("verify should have failed");
438
439        // should be false
440        assert!(
441            !crypto_pwhash_str_needs_rehash(
442                pw_str,
443                CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
444                CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
445            )
446            .expect("verify rehash failed")
447        );
448
449        // should be true
450        assert!(
451            crypto_pwhash_str_needs_rehash(
452                pw_str,
453                CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE + 1,
454                CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
455            )
456            .expect("verify rehash failed")
457        );
458    }
459}