Skip to main content

dstu_core/
crypto_pwhash.rs

1//! `crypto_pwhash` equivalent (`docs/dstu-crypto-project.md` "Mapping onto the libsodium API",
2//! `docs/TASKS.md` T-71, `docs/DECISIONS.md` D-03/D-49/D-50) - plain Argon2id, the one deliberately non-DSTU
3//! component (no Ukrainian standard covers password hashing). Wraps the `argon2` crate
4//! (`RustCrypto/password-hashes`, vetted in D-49) with libsodium's own `crypto_pwhash_str`/
5//! `crypto_pwhash_str_verify` shape: a self-describing PHC string that embeds algorithm, version,
6//! salt, and parameters, so `verify_password` needs nothing but the password and that string back.
7//!
8//! Every parameter choice here is cited to libsodium's own `crypto_pwhash_argon2id` C source
9//! (`docs/DECISIONS.md` D-50), not invented: only Argon2id (no algorithm knob), a fixed 1-lane
10//! parallelism (`pwhash_argon2id.c`'s own `argon2id_hash_encoded(..., (uint32_t) 1U, ...)` call,
11//! not a knob either), a 16-byte salt (`crypto_pwhash_argon2id_SALTBYTES`), a 32-byte hash
12//! (`STR_HASHBYTES`), and three named strength presets mirroring
13//! `OPSLIMIT`/`MEMLIMIT_{INTERACTIVE,MODERATE,SENSITIVE}` exactly - no raw `m_cost`/`t_cost` knob
14//! exposed, per D-47's "libsodium API shape, no misconfigurable knobs" criterion.
15//!
16//! # Example
17//!
18//! For hashing passwords before storing them (never a general-purpose hash - deliberately slow and
19//! memory-hard so guessing many candidate passwords against a stolen hash is expensive).
20//! [`Strength::Interactive`] is used below so this example runs quickly; a real login system would
21//! usually want [`Strength::Moderate`] or [`Strength::Sensitive`] instead (both take real seconds
22//! and hundreds of MiB, deliberately, so not run here).
23//!
24//! ```rust
25//! use dstu_core::crypto_pwhash::{hash_password, verify_password, Strength};
26//!
27//! let stored_hash = hash_password(b"correct horse battery staple", Strength::Interactive)
28//!     .expect("OS CSPRNG should not fail");
29//!
30//! assert!(verify_password(b"correct horse battery staple", &stored_hash));
31//! assert!(!verify_password(b"wrong guess", &stored_hash));
32//! ```
33
34use crate::randombytes::{randombytes_buf, RandomError};
35use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
36use argon2::{Algorithm, Argon2, Params, Version};
37use core::fmt;
38
39/// Argon2id cost preset - mirrors libsodium's `crypto_pwhash_argon2id` `OPSLIMIT`/`MEMLIMIT`
40/// named constants (`crypto_pwhash_argon2id.h`) exactly, not an independently chosen value.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum Strength {
43    /// `OPSLIMIT_INTERACTIVE` / `MEMLIMIT_INTERACTIVE` (t=2, 64 MiB) - online, latency-sensitive.
44    Interactive,
45    /// `OPSLIMIT_MODERATE` / `MEMLIMIT_MODERATE` (t=3, 256 MiB).
46    Moderate,
47    /// `OPSLIMIT_SENSITIVE` / `MEMLIMIT_SENSITIVE` (t=4, 1024 MiB) - highly sensitive, offline-ok.
48    Sensitive,
49}
50
51impl Strength {
52    /// `(m_cost in KiB, t_cost)` - libsodium's own `OPSLIMIT`/`MEMLIMIT_*` constants
53    /// (`crypto_pwhash_argon2id.h`), `MEMLIMIT` converted from bytes to KiB. `p_cost` is fixed at
54    /// 1 lane below, not part of this preset - libsodium hardcodes it the same way.
55    const fn m_and_t_cost(self) -> (u32, u32) {
56        match self {
57            Strength::Interactive => (65536, 2),   // 64 MiB
58            Strength::Moderate => (262_144, 3),    // 256 MiB
59            Strength::Sensitive => (1_048_576, 4), // 1024 MiB
60        }
61    }
62
63    /// Builds the actual `argon2::Params` for this preset. A `const fn`: an invalid preset would
64    /// be a compile-time error, not a runtime one - these three presets are the only values this
65    /// type can hold, so validity is closed over at compile time rather than checked per call.
66    const fn params(self) -> Params {
67        let (m_cost, t_cost) = self.m_and_t_cost();
68        match Params::new(m_cost, t_cost, 1, None) {
69            Ok(p) => p,
70            Err(_) => panic!("Strength's own hardcoded (m_cost, t_cost, 1) is always valid"),
71        }
72    }
73}
74
75/// `crypto_pwhash_str`/`_str_verify` can fail for reasons unrelated to a wrong password: this
76/// covers those.
77#[derive(Debug)]
78pub enum PwHashError {
79    /// The OS CSPRNG failed while generating a salt (see [`crate::randombytes`]).
80    Random(RandomError),
81    /// Argon2/PHC-string encoding failed. Not expected to occur in practice for the fixed-length
82    /// salt this module always generates - included because the underlying crate's API is
83    /// fallible, not because a known failure mode exists here.
84    Hash(argon2::password_hash::Error),
85}
86
87impl fmt::Display for PwHashError {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self {
90            PwHashError::Random(e) => write!(f, "{e}"),
91            PwHashError::Hash(e) => write!(f, "Argon2 hashing failed: {e}"),
92        }
93    }
94}
95
96impl core::error::Error for PwHashError {}
97
98impl From<RandomError> for PwHashError {
99    fn from(e: RandomError) -> Self {
100        PwHashError::Random(e)
101    }
102}
103
104impl From<argon2::password_hash::Error> for PwHashError {
105    fn from(e: argon2::password_hash::Error) -> Self {
106        PwHashError::Hash(e)
107    }
108}
109
110/// Hashes `password` into a self-describing PHC string (`$argon2id$v=19$m=...,t=...,p=1$<salt>$
111/// <hash>`) - libsodium's `crypto_pwhash_str` equivalent. A fresh 16-byte salt
112/// (`crypto_pwhash_argon2id_SALTBYTES`) is drawn per call via
113/// [`crate::randombytes::randombytes_buf`], never `password_hash`'s own `rand_core`-based
114/// `SaltString::generate`, so this module pulls in no `CryptoRng` dependency of its own
115/// (`docs/DECISIONS.md` D-48/D-50).
116///
117/// # Errors
118///
119/// Returns [`PwHashError::Random`] if the OS CSPRNG fails. [`PwHashError::Hash`] is not expected
120/// for the fixed 16-byte salt generated here.
121pub fn hash_password(password: &[u8], strength: Strength) -> Result<String, PwHashError> {
122    let mut salt_bytes = [0u8; 16];
123    randombytes_buf(&mut salt_bytes)?;
124    let salt = SaltString::encode_b64(&salt_bytes)?;
125    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, strength.params());
126    let hash = argon2.hash_password(password, &salt)?;
127    Ok(hash.to_string())
128}
129
130/// Verifies `password` against a PHC string produced by [`hash_password`] - libsodium's
131/// `crypto_pwhash_str_verify` equivalent. Returns `false` for both a wrong password and a
132/// malformed/unparseable hash string, mirroring libsodium's own single pass/fail return (nothing
133/// for a caller to mishandle by branching differently on the two failure cases).
134#[must_use]
135pub fn verify_password(password: &[u8], hash: &str) -> bool {
136    let Ok(parsed) = PasswordHash::new(hash) else {
137        return false;
138    };
139    Argon2::default().verify_password(password, &parsed).is_ok()
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use argon2::AssociatedData;
146
147    /// Confirms the `argon2` dependency itself is spec-correct before trusting it through this
148    /// module's wrapper - RFC 9106 (IETF, primary source) Appendix A's Argon2id test vector,
149    /// cited verbatim. Deliberately bypasses `hash_password`/PHC-string encoding: the vector's own
150    /// `p=4` doesn't match this module's fixed `p=1` (libsodium's own choice, see the module doc),
151    /// so a raw `Argon2` context is built directly against the vector's exact parameters instead.
152    #[test]
153    fn argon2_dependency_matches_rfc9106_argon2id_vector() -> Result<(), argon2::Error> {
154        let password = [0x01u8; 32];
155        let salt = [0x02u8; 16];
156        let secret = [0x03u8; 8];
157        let associated_data = AssociatedData::new(&[0x04u8; 12])?;
158
159        let mut builder = argon2::ParamsBuilder::new();
160        builder
161            .m_cost(32)
162            .t_cost(3)
163            .p_cost(4)
164            .data(associated_data)
165            .output_len(32);
166        let params = builder.build()?;
167
168        let argon2 = Argon2::new_with_secret(&secret, Algorithm::Argon2id, Version::V0x13, params)?;
169        let mut out = [0u8; 32];
170        argon2.hash_password_into(&password, &salt, &mut out)?;
171
172        assert_eq!(
173            out,
174            [
175                0x0d, 0x64, 0x0d, 0xf5, 0x8d, 0x78, 0x76, 0x6c, 0x08, 0xc0, 0x37, 0xa3, 0x4a, 0x8b,
176                0x53, 0xc9, 0xd0, 0x1e, 0xf0, 0x45, 0x2d, 0x75, 0xb6, 0x5e, 0xb5, 0x25, 0x20, 0xe9,
177                0x6b, 0x01, 0xe6, 0x59,
178            ]
179        );
180        Ok(())
181    }
182
183    /// `Sensitive`'s own `(m_cost, t_cost, p_cost)` checked directly against a real `Params`,
184    /// instead of through a real `hash_password` call - a real 1024 MiB/t=4 hash took ~85s in an
185    /// unoptimized debug build (too expensive for every CI push), and `Interactive`/`Moderate`
186    /// already prove (`tests/crypto_pwhash.rs`) that `Strength` flows into the PHC string through
187    /// this exact code path - only the constants differ for `Sensitive`, checked here for free.
188    #[test]
189    fn sensitive_preset_has_libsodiums_sensitive_params() {
190        let params = Strength::Sensitive.params();
191        assert_eq!(params.m_cost(), 1_048_576);
192        assert_eq!(params.t_cost(), 4);
193        assert_eq!(params.p_cost(), 1);
194    }
195}