aster_forge_crypto/
hash.rs

1//! Password hashing, keyed authentication, and digest helpers.
2//!
3//! Passwords are hashed with an explicit Argon2id policy and a fresh salt for storage. Stored PHC
4//! strings are checked against finite verification limits before Argon2 allocates its work memory.
5//! SHA-256 helpers cover deterministic digest cases, while HMAC-SHA-256 covers keyed cache and
6//! lookup components that must not expose a fast, reusable digest of a low-entropy secret.
7
8use crate::{CryptoError, Result};
9use argon2::{
10    Algorithm, Argon2, Params, Version,
11    password_hash::{
12        Error as PasswordHashError, PasswordHasher, PasswordVerifier,
13        phc::{Output, PasswordHash},
14    },
15};
16use hmac::{Hmac, KeyInit, Mac};
17use sha2::{Digest, Sha256};
18use std::fmt::Write;
19
20const ARGON2_VERSION: u32 = 19;
21const PASSWORD_SALT_LENGTH: usize = 16;
22const RFC_9106_SECOND_MEMORY_KIB: u32 = 64 * 1024;
23const RFC_9106_SECOND_ITERATIONS: u32 = 3;
24const RFC_9106_SECOND_PARALLELISM: u32 = 4;
25const DEFAULT_PASSWORD_HASH_OUTPUT_LENGTH: usize = 32;
26
27/// Argon2id work factor used when creating new password hashes.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub struct PasswordHashWorkFactor {
30    memory_kib: u32,
31    iterations: u32,
32    parallelism: u32,
33    output_length: usize,
34}
35
36impl PasswordHashWorkFactor {
37    /// Returns the RFC 9106 second recommended Argon2id profile.
38    ///
39    /// This uses 64 MiB of memory, three iterations, four lanes, and a 32-byte output.
40    #[must_use]
41    pub const fn rfc_9106_second_recommended() -> Self {
42        Self {
43            memory_kib: RFC_9106_SECOND_MEMORY_KIB,
44            iterations: RFC_9106_SECOND_ITERATIONS,
45            parallelism: RFC_9106_SECOND_PARALLELISM,
46            output_length: DEFAULT_PASSWORD_HASH_OUTPUT_LENGTH,
47        }
48    }
49
50    /// Creates a custom Argon2id work factor.
51    ///
52    /// # Errors
53    ///
54    /// Returns an error when the output length or Argon2 cost parameters fall outside the ranges
55    /// accepted by the Argon2 implementation.
56    pub fn new(
57        memory_kib: u32,
58        iterations: u32,
59        parallelism: u32,
60        output_length: usize,
61    ) -> Result<Self> {
62        let work_factor = Self {
63            memory_kib,
64            iterations,
65            parallelism,
66            output_length,
67        };
68        if !(Output::MIN_LENGTH..=Output::MAX_LENGTH).contains(&output_length) {
69            return Err(CryptoError::password_hash_policy(format_args!(
70                "output length must be between {} and {} bytes",
71                Output::MIN_LENGTH,
72                Output::MAX_LENGTH
73            )));
74        }
75        work_factor.params()?;
76        Ok(work_factor)
77    }
78
79    /// Memory cost in KiB.
80    #[must_use]
81    pub const fn memory_kib(self) -> u32 {
82        self.memory_kib
83    }
84
85    /// Number of Argon2 passes.
86    #[must_use]
87    pub const fn iterations(self) -> u32 {
88        self.iterations
89    }
90
91    /// Number of Argon2 lanes.
92    #[must_use]
93    pub const fn parallelism(self) -> u32 {
94        self.parallelism
95    }
96
97    /// Password-hash output length in bytes.
98    #[must_use]
99    pub const fn output_length(self) -> usize {
100        self.output_length
101    }
102
103    fn params(self) -> Result<Params> {
104        Params::new(
105            self.memory_kib,
106            self.iterations,
107            self.parallelism,
108            Some(self.output_length),
109        )
110        .map_err(CryptoError::password_hash_policy)
111    }
112}
113
114impl Default for PasswordHashWorkFactor {
115    fn default() -> Self {
116        Self::rfc_9106_second_recommended()
117    }
118}
119
120/// Absolute resource limits accepted when verifying a stored Argon2id PHC string.
121///
122/// Values below the current work factor remain valid for compatibility and are reported through
123/// [`PasswordHashVerification::needs_rehash`]. Values above these limits are rejected before the
124/// Argon2 work-memory allocation begins.
125#[derive(Clone, Copy, Debug, PartialEq, Eq)]
126#[expect(
127    clippy::struct_field_names,
128    reason = "The max prefix distinguishes verification ceilings from password hashing work factors."
129)]
130pub struct PasswordHashVerificationLimits {
131    max_memory_kib: u32,
132    max_iterations: u32,
133    max_parallelism: u32,
134    max_output_length: usize,
135}
136
137impl PasswordHashVerificationLimits {
138    /// Creates custom absolute verification limits.
139    ///
140    /// # Errors
141    ///
142    /// Returns an error when any limit is below the Argon2 minimum or the output-length limit is
143    /// outside the range accepted by the password-hash encoding.
144    pub fn new(
145        max_memory_kib: u32,
146        max_iterations: u32,
147        max_parallelism: u32,
148        max_output_length: usize,
149    ) -> Result<Self> {
150        if max_memory_kib < Params::MIN_M_COST
151            || max_iterations < Params::MIN_T_COST
152            || max_parallelism < Params::MIN_P_COST
153        {
154            return Err(CryptoError::password_hash_policy(
155                "verification limits are below the Argon2 minimum",
156            ));
157        }
158        if !(Output::MIN_LENGTH..=Output::MAX_LENGTH).contains(&max_output_length) {
159            return Err(CryptoError::password_hash_policy(format_args!(
160                "maximum output length must be between {} and {} bytes",
161                Output::MIN_LENGTH,
162                Output::MAX_LENGTH
163            )));
164        }
165
166        Ok(Self {
167            max_memory_kib,
168            max_iterations,
169            max_parallelism,
170            max_output_length,
171        })
172    }
173
174    /// Maximum accepted memory cost in KiB.
175    #[must_use]
176    pub const fn max_memory_kib(self) -> u32 {
177        self.max_memory_kib
178    }
179
180    /// Maximum accepted number of Argon2 passes.
181    #[must_use]
182    pub const fn max_iterations(self) -> u32 {
183        self.max_iterations
184    }
185
186    /// Maximum accepted number of Argon2 lanes.
187    #[must_use]
188    pub const fn max_parallelism(self) -> u32 {
189        self.max_parallelism
190    }
191
192    /// Maximum accepted password-hash output length in bytes.
193    #[must_use]
194    pub const fn max_output_length(self) -> usize {
195        self.max_output_length
196    }
197}
198
199impl Default for PasswordHashVerificationLimits {
200    fn default() -> Self {
201        let work_factor = PasswordHashWorkFactor::rfc_9106_second_recommended();
202        Self {
203            max_memory_kib: work_factor.memory_kib,
204            max_iterations: work_factor.iterations,
205            max_parallelism: work_factor.parallelism,
206            max_output_length: work_factor.output_length,
207        }
208    }
209}
210
211/// Password hashing and verification policy.
212///
213/// The work factor controls newly created hashes. Verification limits are a separate absolute
214/// resource budget so products can accept bounded legacy or stronger hashes without allowing a
215/// stored PHC string to request arbitrary memory or CPU time.
216#[derive(Clone, Copy, Debug, PartialEq, Eq)]
217pub struct PasswordHashPolicy {
218    work_factor: PasswordHashWorkFactor,
219    verification_limits: PasswordHashVerificationLimits,
220}
221
222impl PasswordHashPolicy {
223    /// Creates a validated password-hash policy.
224    ///
225    /// # Errors
226    ///
227    /// Returns an error when the work factor is invalid or exceeds any configured verification
228    /// limit.
229    pub fn new(
230        work_factor: PasswordHashWorkFactor,
231        verification_limits: PasswordHashVerificationLimits,
232    ) -> Result<Self> {
233        work_factor.params()?;
234        ensure_policy_value_within_limit(
235            "m",
236            u64::from(work_factor.memory_kib),
237            u64::from(verification_limits.max_memory_kib),
238        )?;
239        ensure_policy_value_within_limit(
240            "t",
241            u64::from(work_factor.iterations),
242            u64::from(verification_limits.max_iterations),
243        )?;
244        ensure_policy_value_within_limit(
245            "p",
246            u64::from(work_factor.parallelism),
247            u64::from(verification_limits.max_parallelism),
248        )?;
249        ensure_policy_value_within_limit(
250            "output_length",
251            usize_to_u64(work_factor.output_length),
252            usize_to_u64(verification_limits.max_output_length),
253        )?;
254
255        Ok(Self {
256            work_factor,
257            verification_limits,
258        })
259    }
260
261    /// Work factor used for newly created hashes.
262    #[must_use]
263    pub const fn work_factor(self) -> PasswordHashWorkFactor {
264        self.work_factor
265    }
266
267    /// Absolute limits used before verifying stored hashes.
268    #[must_use]
269    pub const fn verification_limits(self) -> PasswordHashVerificationLimits {
270        self.verification_limits
271    }
272}
273
274impl Default for PasswordHashPolicy {
275    fn default() -> Self {
276        Self {
277            work_factor: PasswordHashWorkFactor::rfc_9106_second_recommended(),
278            verification_limits: PasswordHashVerificationLimits {
279                max_memory_kib: RFC_9106_SECOND_MEMORY_KIB,
280                max_iterations: RFC_9106_SECOND_ITERATIONS,
281                max_parallelism: RFC_9106_SECOND_PARALLELISM,
282                max_output_length: DEFAULT_PASSWORD_HASH_OUTPUT_LENGTH,
283            },
284        }
285    }
286}
287
288/// Detailed result of password verification.
289#[derive(Clone, Copy, Debug, PartialEq, Eq)]
290pub struct PasswordHashVerification {
291    /// Whether the password matched the stored hash.
292    pub is_valid: bool,
293    /// Whether a matching password should be hashed again with the current work factor.
294    pub needs_rehash: bool,
295}
296
297/// Hashes a password with the default RFC 9106 second recommended policy.
298///
299/// # Errors
300///
301/// Returns an error when the default Argon2 parameters cannot be constructed or hashing fails.
302pub fn hash_password(password: &str) -> Result<String> {
303    hash_password_with_policy(password, &PasswordHashPolicy::default())
304}
305
306/// Hashes a password with an explicit policy and a fresh random salt.
307///
308/// # Errors
309///
310/// Returns an error when the policy's Argon2 parameters are invalid or the password-hash operation
311/// fails.
312pub fn hash_password_with_policy(password: &str, policy: &PasswordHashPolicy) -> Result<String> {
313    password_hasher(policy.work_factor)?
314        .hash_password(password.as_bytes())
315        .map(|hash| hash.to_string())
316        .map_err(CryptoError::password_hash)
317}
318
319/// Verifies a password with the default policy.
320///
321/// Malformed, unsupported, or over-budget hashes return an error. Only a genuine password
322/// mismatch returns `Ok(false)`.
323///
324/// # Errors
325///
326/// Returns an error when the stored PHC string is malformed, uses unsupported parameters, exceeds
327/// the default verification budget, or the Argon2 verifier fails for a reason other than password
328/// mismatch.
329pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
330    verify_password_with_policy(password, hash, &PasswordHashPolicy::default())
331        .map(|verification| verification.is_valid)
332}
333
334/// Verifies a password with an explicit policy and reports whether a matching hash needs upgrade.
335///
336/// # Errors
337///
338/// Returns an error when the stored PHC string is malformed, unsupported, outside `policy`'s
339/// verification limits, or the Argon2 verifier fails for a reason other than password mismatch.
340pub fn verify_password_with_policy(
341    password: &str,
342    hash: &str,
343    policy: &PasswordHashPolicy,
344) -> Result<PasswordHashVerification> {
345    let parsed = PasswordHash::new(hash).map_err(CryptoError::password_hash)?;
346    let params = validate_stored_password_hash(&parsed, policy.verification_limits)?;
347    let needs_rehash = password_hash_needs_rehash(&parsed, &params, policy.work_factor)?;
348
349    match password_hasher(policy.work_factor)?.verify_password(password.as_bytes(), &parsed) {
350        Ok(()) => Ok(PasswordHashVerification {
351            is_valid: true,
352            needs_rehash,
353        }),
354        Err(PasswordHashError::PasswordInvalid) => Ok(PasswordHashVerification {
355            is_valid: false,
356            needs_rehash: false,
357        }),
358        Err(error) => Err(CryptoError::password_hash(error)),
359    }
360}
361
362fn password_hasher(work_factor: PasswordHashWorkFactor) -> Result<Argon2<'static>> {
363    Ok(Argon2::new(
364        Algorithm::Argon2id,
365        Version::V0x13,
366        work_factor.params()?,
367    ))
368}
369
370fn validate_stored_password_hash(
371    parsed: &PasswordHash,
372    limits: PasswordHashVerificationLimits,
373) -> Result<Params> {
374    if parsed.algorithm.as_str() != "argon2id" {
375        return Err(CryptoError::password_hash(format_args!(
376            "unsupported password hash algorithm {}",
377            parsed.algorithm
378        )));
379    }
380    if parsed.version != Some(ARGON2_VERSION) {
381        return Err(CryptoError::password_hash(
382            "unsupported or missing Argon2 version",
383        ));
384    }
385    if parsed.salt.is_none() {
386        return Err(CryptoError::password_hash(
387            "password hash is missing a salt",
388        ));
389    }
390    if parsed.hash.is_none() {
391        return Err(CryptoError::password_hash(
392            "password hash is missing an output",
393        ));
394    }
395
396    let params = Params::try_from(parsed).map_err(CryptoError::password_hash)?;
397    if !params.keyid().is_empty() || !params.data().is_empty() {
398        return Err(CryptoError::password_hash(
399            "Argon2 keyid and associated data are not supported",
400        ));
401    }
402
403    ensure_within_limit(
404        "m",
405        u64::from(params.m_cost()),
406        u64::from(limits.max_memory_kib),
407    )?;
408    ensure_within_limit(
409        "t",
410        u64::from(params.t_cost()),
411        u64::from(limits.max_iterations),
412    )?;
413    ensure_within_limit(
414        "p",
415        u64::from(params.p_cost()),
416        u64::from(limits.max_parallelism),
417    )?;
418    ensure_within_limit(
419        "output_length",
420        usize_to_u64(
421            params
422                .output_len()
423                .unwrap_or(DEFAULT_PASSWORD_HASH_OUTPUT_LENGTH),
424        ),
425        usize_to_u64(limits.max_output_length),
426    )?;
427
428    Ok(params)
429}
430
431fn password_hash_needs_rehash(
432    parsed: &PasswordHash,
433    params: &Params,
434    current: PasswordHashWorkFactor,
435) -> Result<bool> {
436    let salt = parsed
437        .salt
438        .ok_or_else(|| CryptoError::password_hash("password hash is missing a salt"))?;
439    let salt_length = salt.as_ref().len();
440    let output_length = params
441        .output_len()
442        .unwrap_or(DEFAULT_PASSWORD_HASH_OUTPUT_LENGTH);
443
444    Ok(params.m_cost() < current.memory_kib
445        || params.t_cost() < current.iterations
446        || params.p_cost() < current.parallelism
447        || output_length < current.output_length
448        || salt_length < PASSWORD_SALT_LENGTH)
449}
450
451fn ensure_within_limit(parameter: &'static str, actual: u64, maximum: u64) -> Result<()> {
452    if actual > maximum {
453        Err(CryptoError::PasswordHashVerificationLimit {
454            parameter,
455            actual,
456            maximum,
457        })
458    } else {
459        Ok(())
460    }
461}
462
463fn ensure_policy_value_within_limit(
464    parameter: &'static str,
465    actual: u64,
466    maximum: u64,
467) -> Result<()> {
468    if actual > maximum {
469        Err(CryptoError::password_hash_policy(format_args!(
470            "work factor {parameter}={actual} exceeds verification limit {maximum}"
471        )))
472    } else {
473        Ok(())
474    }
475}
476
477fn usize_to_u64(value: usize) -> u64 {
478    u64::try_from(value).unwrap_or(u64::MAX)
479}
480
481/// Computes HMAC-SHA-256 over `data` and returns lowercase hex.
482///
483/// Products remain responsible for providing a high-entropy, purpose-specific key and managing
484/// its lifecycle. This helper is suitable for keyed cache components and lookup digests; it does
485/// not replace Argon2id for human passwords.
486///
487/// # Errors
488///
489/// Returns an error when the HMAC implementation rejects the supplied key.
490pub fn hmac_sha256_hex(key: &[u8], data: &[u8]) -> Result<String> {
491    let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(key)
492        .map_err(CryptoError::message_authentication)?;
493    mac.update(data);
494    Ok(bytes_to_hex(&mac.finalize().into_bytes()))
495}
496
497/// Computes the SHA-256 digest of `data` and returns lowercase hex.
498#[must_use]
499pub fn sha256_hex(data: &[u8]) -> String {
500    let mut hasher = Sha256::new();
501    hasher.update(data);
502    bytes_to_hex(&hasher.finalize())
503}
504
505/// Encodes arbitrary bytes as lowercase hex.
506#[must_use]
507pub fn bytes_to_hex(bytes: &[u8]) -> String {
508    let mut hex = String::with_capacity(bytes.len() * 2);
509    for byte in bytes {
510        let _ = write!(&mut hex, "{byte:02x}");
511    }
512    hex
513}
514
515/// Encodes a SHA-256 digest as lowercase hex.
516#[must_use]
517pub fn sha256_digest_to_hex(digest: &[u8]) -> String {
518    bytes_to_hex(digest)
519}
520
521/// Creates a new incremental SHA-256 hasher.
522#[must_use]
523pub fn new_sha256() -> Sha256 {
524    Sha256::new()
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530    use sha2::Digest;
531
532    fn lightweight_policy() -> PasswordHashPolicy {
533        let work_factor = PasswordHashWorkFactor::new(8, 1, 1, 32).unwrap();
534        let limits = PasswordHashVerificationLimits::new(8, 1, 1, 32).unwrap();
535        PasswordHashPolicy::new(work_factor, limits).unwrap()
536    }
537
538    fn hash_with_work_factor(password: &str, work_factor: PasswordHashWorkFactor) -> String {
539        hash_with_work_factor_and_salt(password, work_factor, &[7_u8; PASSWORD_SALT_LENGTH])
540    }
541
542    fn hash_with_work_factor_and_salt(
543        password: &str,
544        work_factor: PasswordHashWorkFactor,
545        salt: &[u8],
546    ) -> String {
547        password_hasher(work_factor)
548            .unwrap()
549            .hash_password_with_salt(password.as_bytes(), salt)
550            .unwrap()
551            .to_string()
552    }
553
554    #[test]
555    fn default_policy_uses_rfc_9106_second_recommended_profile() {
556        let policy = PasswordHashPolicy::default();
557        let work_factor = policy.work_factor();
558        let limits = policy.verification_limits();
559
560        assert_eq!(work_factor.memory_kib(), 64 * 1024);
561        assert_eq!(work_factor.iterations(), 3);
562        assert_eq!(work_factor.parallelism(), 4);
563        assert_eq!(work_factor.output_length(), 32);
564        assert_eq!(limits.max_memory_kib(), work_factor.memory_kib());
565        assert_eq!(limits.max_iterations(), work_factor.iterations());
566        assert_eq!(limits.max_parallelism(), work_factor.parallelism());
567        assert_eq!(limits.max_output_length(), work_factor.output_length());
568    }
569
570    #[test]
571    fn default_hash_encodes_rfc_9106_second_recommended_profile() {
572        let hash = hash_password("default policy password").unwrap();
573
574        assert!(hash.starts_with("$argon2id$v=19$m=65536,t=3,p=4$"));
575        assert!(verify_password("default policy password", &hash).unwrap());
576    }
577
578    #[test]
579    fn sha256_hex_matches_known_vectors_and_binary_input() {
580        assert_eq!(
581            sha256_hex(b""),
582            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
583        );
584        assert_eq!(
585            sha256_hex(b"abc"),
586            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
587        );
588        assert_eq!(
589            sha256_hex(&[0x00, 0xff, 0x10]),
590            "2da45f2cd1f9c8e69a67abf7a6b26c282533d0a7686787a9533265418680d4d2"
591        );
592    }
593
594    #[test]
595    fn hmac_sha256_matches_rfc_4231_vector_and_separates_keys() {
596        let data = b"Hi There";
597        let first = hmac_sha256_hex(&[0x0b; 20], data).unwrap();
598        let second = hmac_sha256_hex(&[0x0c; 20], data).unwrap();
599
600        assert_eq!(
601            first,
602            "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
603        );
604        assert_ne!(first, second);
605        assert_ne!(first, hmac_sha256_hex(&[0x0b; 20], b"Hi There!").unwrap());
606        assert_eq!(
607            hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?").unwrap(),
608            "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
609        );
610        assert_eq!(
611            hmac_sha256_hex(b"", b"").unwrap(),
612            "b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad"
613        );
614    }
615
616    #[test]
617    fn bytes_to_hex_encodes_lowercase_and_preserves_leading_zeroes() {
618        assert_eq!(bytes_to_hex(&[]), "");
619        assert_eq!(bytes_to_hex(&[0x00, 0x0f, 0x10, 0xab, 0xff]), "000f10abff");
620    }
621
622    #[test]
623    fn sha256_digest_to_hex_matches_incremental_hasher_output() {
624        let mut hasher = new_sha256();
625        hasher.update(b"a");
626        hasher.update(b"bc");
627        assert_eq!(sha256_digest_to_hex(&hasher.finalize()), sha256_hex(b"abc"));
628    }
629
630    #[test]
631    fn password_hash_verifies_matching_password_and_rejects_wrong_password() {
632        let policy = lightweight_policy();
633        let hash = hash_password_with_policy("correct horse battery staple", &policy).unwrap();
634
635        assert!(
636            verify_password_with_policy("correct horse battery staple", &hash, &policy)
637                .unwrap()
638                .is_valid
639        );
640        assert!(
641            !verify_password_with_policy("wrong password", &hash, &policy)
642                .unwrap()
643                .is_valid
644        );
645        assert!(hash.starts_with("$argon2id$v=19$m=8,t=1,p=1$"));
646    }
647
648    #[test]
649    fn password_hash_uses_fresh_salt() {
650        let policy = lightweight_policy();
651        let first = hash_password_with_policy("same password", &policy).unwrap();
652        let second = hash_password_with_policy("same password", &policy).unwrap();
653
654        assert_ne!(first, second);
655        assert!(
656            verify_password_with_policy("same password", &first, &policy)
657                .unwrap()
658                .is_valid
659        );
660        assert!(
661            verify_password_with_policy("same password", &second, &policy)
662                .unwrap()
663                .is_valid
664        );
665    }
666
667    #[test]
668    fn malformed_and_unsupported_password_hashes_return_errors() {
669        assert!(matches!(
670            verify_password("password", "not-a-password-hash"),
671            Err(CryptoError::PasswordHash(_))
672        ));
673
674        let policy = lightweight_policy();
675        let hash = hash_password_with_policy("password", &policy).unwrap();
676        let unsupported = hash.replacen("$argon2id$", "$notargon$", 1);
677        assert!(matches!(
678            verify_password_with_policy("password", &unsupported, &policy),
679            Err(CryptoError::PasswordHash(_))
680        ));
681
682        let unsupported_version = hash.replacen("v=19", "v=16", 1);
683        assert!(matches!(
684            verify_password_with_policy("password", &unsupported_version, &policy),
685            Err(CryptoError::PasswordHash(_))
686        ));
687
688        let missing_version = hash.replacen("$v=19$", "$", 1);
689        assert!(matches!(
690            verify_password_with_policy("password", &missing_version, &policy),
691            Err(CryptoError::PasswordHash(_))
692        ));
693
694        assert!(matches!(
695            verify_password_with_policy("password", "$argon2id$v=19$m=8,t=1,p=1", &policy,),
696            Err(CryptoError::PasswordHash(_))
697        ));
698        assert!(matches!(
699            verify_password_with_policy(
700                "password",
701                "$argon2id$v=19$m=8,t=1,p=1$c2FsdHNhbHQ",
702                &policy,
703            ),
704            Err(CryptoError::PasswordHash(_))
705        ));
706
707        let unknown_parameter = hash.replacen("p=1", "p=1,x=1", 1);
708        assert!(matches!(
709            verify_password_with_policy("password", &unknown_parameter, &policy),
710            Err(CryptoError::PasswordHash(_))
711        ));
712    }
713
714    #[test]
715    fn verification_rejects_over_budget_work_factors_before_argon2_runs() {
716        let policy = lightweight_policy();
717        let hash = hash_password_with_policy("password", &policy).unwrap();
718        let over_memory = hash.replacen("m=8", "m=9", 1);
719        let over_iterations = hash.replacen("t=1", "t=2", 1);
720        let over_output = hash_with_work_factor(
721            "password",
722            PasswordHashWorkFactor::new(8, 1, 1, 33).unwrap(),
723        );
724        let parallel_work_factor = PasswordHashWorkFactor::new(16, 1, 1, 32).unwrap();
725        let parallel_limits = PasswordHashVerificationLimits::new(16, 1, 1, 32).unwrap();
726        let parallel_policy =
727            PasswordHashPolicy::new(parallel_work_factor, parallel_limits).unwrap();
728        let over_parallelism =
729            hash_with_work_factor("password", parallel_work_factor).replacen("p=1", "p=2", 1);
730
731        assert!(matches!(
732            verify_password_with_policy("password", &over_memory, &policy),
733            Err(CryptoError::PasswordHashVerificationLimit {
734                parameter: "m",
735                actual: 9,
736                maximum: 8,
737            })
738        ));
739        assert!(matches!(
740            verify_password_with_policy("password", &over_iterations, &policy),
741            Err(CryptoError::PasswordHashVerificationLimit {
742                parameter: "t",
743                actual: 2,
744                maximum: 1,
745            })
746        ));
747        assert!(matches!(
748            verify_password_with_policy("password", &over_parallelism, &parallel_policy),
749            Err(CryptoError::PasswordHashVerificationLimit {
750                parameter: "p",
751                actual: 2,
752                maximum: 1,
753            })
754        ));
755        assert!(matches!(
756            verify_password_with_policy("password", &over_output, &policy),
757            Err(CryptoError::PasswordHashVerificationLimit {
758                parameter: "output_length",
759                actual: 33,
760                maximum: 32,
761            })
762        ));
763
764        let extreme_memory = hash.replacen("m=8", "m=4294967295", 1);
765        assert!(matches!(
766            verify_password_with_policy("password", &extreme_memory, &policy),
767            Err(CryptoError::PasswordHashVerificationLimit {
768                parameter: "m",
769                actual: 4_294_967_295,
770                maximum: 8,
771            })
772        ));
773    }
774
775    #[test]
776    fn legacy_work_factor_verifies_and_requests_rehash() {
777        let legacy = PasswordHashWorkFactor::new(19 * 1024, 2, 1, 32).unwrap();
778        let hash = hash_with_work_factor("password", legacy);
779        let verification =
780            verify_password_with_policy("password", &hash, &PasswordHashPolicy::default()).unwrap();
781
782        assert!(verification.is_valid);
783        assert!(verification.needs_rehash);
784
785        let wrong =
786            verify_password_with_policy("wrong password", &hash, &PasswordHashPolicy::default())
787                .unwrap();
788        assert!(!wrong.is_valid);
789        assert!(!wrong.needs_rehash);
790    }
791
792    #[test]
793    fn current_work_factor_does_not_request_rehash() {
794        let policy = lightweight_policy();
795        let hash = hash_password_with_policy("password", &policy).unwrap();
796        let verification = verify_password_with_policy("password", &hash, &policy).unwrap();
797
798        assert!(verification.is_valid);
799        assert!(!verification.needs_rehash);
800    }
801
802    #[test]
803    fn short_legacy_salt_verifies_and_requests_rehash() {
804        let policy = lightweight_policy();
805        let hash = hash_with_work_factor_and_salt("password", policy.work_factor(), &[5_u8; 8]);
806        let verification = verify_password_with_policy("password", &hash, &policy).unwrap();
807
808        assert!(verification.is_valid);
809        assert!(verification.needs_rehash);
810    }
811
812    #[test]
813    fn custom_limits_accept_stronger_hash_without_rehashing_down() {
814        let current = PasswordHashWorkFactor::new(8, 1, 1, 32).unwrap();
815        let limits = PasswordHashVerificationLimits::new(16, 2, 2, 32).unwrap();
816        let policy = PasswordHashPolicy::new(current, limits).unwrap();
817        let stronger = PasswordHashWorkFactor::new(16, 2, 2, 32).unwrap();
818        let hash = hash_with_work_factor("password", stronger);
819        let verification = verify_password_with_policy("password", &hash, &policy).unwrap();
820
821        assert!(verification.is_valid);
822        assert!(!verification.needs_rehash);
823    }
824
825    #[test]
826    fn policy_rejects_work_factor_above_its_verification_limits() {
827        let work_factor = PasswordHashWorkFactor::new(64, 2, 1, 32).unwrap();
828        let limits = PasswordHashVerificationLimits::new(32, 2, 1, 32).unwrap();
829
830        assert!(matches!(
831            PasswordHashPolicy::new(work_factor, limits),
832            Err(CryptoError::PasswordHashPolicy(_))
833        ));
834
835        assert!(matches!(
836            PasswordHashWorkFactor::new(8, 1, 1, 4),
837            Err(CryptoError::PasswordHashPolicy(_))
838        ));
839    }
840
841    #[test]
842    fn work_factor_and_limit_constructors_reject_every_invalid_boundary() {
843        for result in [
844            PasswordHashWorkFactor::new(7, 1, 1, 32),
845            PasswordHashWorkFactor::new(15, 1, 2, 32),
846            PasswordHashWorkFactor::new(8, 0, 1, 32),
847            PasswordHashWorkFactor::new(8, 1, 0, 32),
848            PasswordHashWorkFactor::new(8, 1, 1, Output::MIN_LENGTH - 1),
849            PasswordHashWorkFactor::new(8, 1, 1, Output::MAX_LENGTH + 1),
850        ] {
851            assert!(matches!(result, Err(CryptoError::PasswordHashPolicy(_))));
852        }
853
854        for result in [
855            PasswordHashVerificationLimits::new(Params::MIN_M_COST - 1, 1, 1, 32),
856            PasswordHashVerificationLimits::new(8, Params::MIN_T_COST - 1, 1, 32),
857            PasswordHashVerificationLimits::new(8, 1, Params::MIN_P_COST - 1, 32),
858            PasswordHashVerificationLimits::new(8, 1, 1, Output::MIN_LENGTH - 1),
859            PasswordHashVerificationLimits::new(8, 1, 1, Output::MAX_LENGTH + 1),
860        ] {
861            assert!(matches!(result, Err(CryptoError::PasswordHashPolicy(_))));
862        }
863    }
864
865    #[test]
866    fn policy_rejects_each_work_factor_dimension_above_its_limit() {
867        let cases = [
868            (
869                PasswordHashWorkFactor::new(16, 1, 1, 32).unwrap(),
870                PasswordHashVerificationLimits::new(8, 1, 1, 32).unwrap(),
871            ),
872            (
873                PasswordHashWorkFactor::new(8, 2, 1, 32).unwrap(),
874                PasswordHashVerificationLimits::new(8, 1, 1, 32).unwrap(),
875            ),
876            (
877                PasswordHashWorkFactor::new(16, 1, 2, 32).unwrap(),
878                PasswordHashVerificationLimits::new(16, 1, 1, 32).unwrap(),
879            ),
880            (
881                PasswordHashWorkFactor::new(8, 1, 1, 33).unwrap(),
882                PasswordHashVerificationLimits::new(8, 1, 1, 32).unwrap(),
883            ),
884        ];
885
886        for (work_factor, limits) in cases {
887            assert!(matches!(
888                PasswordHashPolicy::new(work_factor, limits),
889                Err(CryptoError::PasswordHashPolicy(_))
890            ));
891        }
892    }
893}