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}