Skip to main content

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
370/// Validates a stored verifier without running Argon2 or allocating its work memory.
371///
372/// # Errors
373/// Returns an error for malformed, unsupported or over-budget password verifiers.
374pub fn validate_password_hash(hash: &str, limits: PasswordHashVerificationLimits) -> Result<()> {
375    let parsed =
376        PasswordHash::new(hash).map_err(|_| CryptoError::password_hash("invalid PHC encoding"))?;
377    validate_stored_password_hash(&parsed, limits).map(|_| ())
378}
379
380fn validate_stored_password_hash(
381    parsed: &PasswordHash,
382    limits: PasswordHashVerificationLimits,
383) -> Result<Params> {
384    if parsed.algorithm.as_str() != "argon2id" {
385        return Err(CryptoError::password_hash(format_args!(
386            "unsupported password hash algorithm {}",
387            parsed.algorithm
388        )));
389    }
390    if parsed.version != Some(ARGON2_VERSION) {
391        return Err(CryptoError::password_hash(
392            "unsupported or missing Argon2 version",
393        ));
394    }
395    if parsed.salt.is_none() {
396        return Err(CryptoError::password_hash(
397            "password hash is missing a salt",
398        ));
399    }
400    if parsed.hash.is_none() {
401        return Err(CryptoError::password_hash(
402            "password hash is missing an output",
403        ));
404    }
405
406    let params = Params::try_from(parsed).map_err(CryptoError::password_hash)?;
407    if !params.keyid().is_empty() || !params.data().is_empty() {
408        return Err(CryptoError::password_hash(
409            "Argon2 keyid and associated data are not supported",
410        ));
411    }
412
413    ensure_within_limit(
414        "m",
415        u64::from(params.m_cost()),
416        u64::from(limits.max_memory_kib),
417    )?;
418    ensure_within_limit(
419        "t",
420        u64::from(params.t_cost()),
421        u64::from(limits.max_iterations),
422    )?;
423    ensure_within_limit(
424        "p",
425        u64::from(params.p_cost()),
426        u64::from(limits.max_parallelism),
427    )?;
428    ensure_within_limit(
429        "output_length",
430        usize_to_u64(
431            params
432                .output_len()
433                .unwrap_or(DEFAULT_PASSWORD_HASH_OUTPUT_LENGTH),
434        ),
435        usize_to_u64(limits.max_output_length),
436    )?;
437
438    Ok(params)
439}
440
441fn password_hash_needs_rehash(
442    parsed: &PasswordHash,
443    params: &Params,
444    current: PasswordHashWorkFactor,
445) -> Result<bool> {
446    let salt = parsed
447        .salt
448        .ok_or_else(|| CryptoError::password_hash("password hash is missing a salt"))?;
449    let salt_length = salt.as_ref().len();
450    let output_length = params
451        .output_len()
452        .unwrap_or(DEFAULT_PASSWORD_HASH_OUTPUT_LENGTH);
453
454    Ok(params.m_cost() < current.memory_kib
455        || params.t_cost() < current.iterations
456        || params.p_cost() < current.parallelism
457        || output_length < current.output_length
458        || salt_length < PASSWORD_SALT_LENGTH)
459}
460
461fn ensure_within_limit(parameter: &'static str, actual: u64, maximum: u64) -> Result<()> {
462    if actual > maximum {
463        Err(CryptoError::PasswordHashVerificationLimit {
464            parameter,
465            actual,
466            maximum,
467        })
468    } else {
469        Ok(())
470    }
471}
472
473fn ensure_policy_value_within_limit(
474    parameter: &'static str,
475    actual: u64,
476    maximum: u64,
477) -> Result<()> {
478    if actual > maximum {
479        Err(CryptoError::password_hash_policy(format_args!(
480            "work factor {parameter}={actual} exceeds verification limit {maximum}"
481        )))
482    } else {
483        Ok(())
484    }
485}
486
487fn usize_to_u64(value: usize) -> u64 {
488    u64::try_from(value).unwrap_or(u64::MAX)
489}
490
491/// Computes HMAC-SHA-256 over `data` and returns lowercase hex.
492///
493/// Products remain responsible for providing a high-entropy, purpose-specific key and managing
494/// its lifecycle. This helper is suitable for keyed cache components and lookup digests; it does
495/// not replace Argon2id for human passwords.
496///
497/// # Errors
498///
499/// Returns an error when the HMAC implementation rejects the supplied key.
500pub fn hmac_sha256_hex(key: &[u8], data: &[u8]) -> Result<String> {
501    let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(key)
502        .map_err(CryptoError::message_authentication)?;
503    mac.update(data);
504    Ok(bytes_to_hex(&mac.finalize().into_bytes()))
505}
506
507/// Computes the SHA-256 digest of `data` and returns lowercase hex.
508#[must_use]
509pub fn sha256_hex(data: &[u8]) -> String {
510    let mut hasher = Sha256::new();
511    hasher.update(data);
512    bytes_to_hex(&hasher.finalize())
513}
514
515/// Encodes arbitrary bytes as lowercase hex.
516#[must_use]
517pub fn bytes_to_hex(bytes: &[u8]) -> String {
518    let mut hex = String::with_capacity(bytes.len() * 2);
519    for byte in bytes {
520        let _ = write!(&mut hex, "{byte:02x}");
521    }
522    hex
523}
524
525/// Encodes a SHA-256 digest as lowercase hex.
526#[must_use]
527pub fn sha256_digest_to_hex(digest: &[u8]) -> String {
528    bytes_to_hex(digest)
529}
530
531/// Creates a new incremental SHA-256 hasher.
532#[must_use]
533pub fn new_sha256() -> Sha256 {
534    Sha256::new()
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use sha2::Digest;
541
542    fn lightweight_policy() -> PasswordHashPolicy {
543        let work_factor = PasswordHashWorkFactor::new(8, 1, 1, 32).unwrap();
544        let limits = PasswordHashVerificationLimits::new(8, 1, 1, 32).unwrap();
545        PasswordHashPolicy::new(work_factor, limits).unwrap()
546    }
547
548    fn hash_with_work_factor(password: &str, work_factor: PasswordHashWorkFactor) -> String {
549        hash_with_work_factor_and_salt(password, work_factor, &[7_u8; PASSWORD_SALT_LENGTH])
550    }
551
552    fn hash_with_work_factor_and_salt(
553        password: &str,
554        work_factor: PasswordHashWorkFactor,
555        salt: &[u8],
556    ) -> String {
557        password_hasher(work_factor)
558            .unwrap()
559            .hash_password_with_salt(password.as_bytes(), salt)
560            .unwrap()
561            .to_string()
562    }
563
564    #[test]
565    fn default_policy_uses_rfc_9106_second_recommended_profile() {
566        let policy = PasswordHashPolicy::default();
567        let work_factor = policy.work_factor();
568        let limits = policy.verification_limits();
569
570        assert_eq!(work_factor.memory_kib(), 64 * 1024);
571        assert_eq!(work_factor.iterations(), 3);
572        assert_eq!(work_factor.parallelism(), 4);
573        assert_eq!(work_factor.output_length(), 32);
574        assert_eq!(limits.max_memory_kib(), work_factor.memory_kib());
575        assert_eq!(limits.max_iterations(), work_factor.iterations());
576        assert_eq!(limits.max_parallelism(), work_factor.parallelism());
577        assert_eq!(limits.max_output_length(), work_factor.output_length());
578    }
579
580    #[test]
581    fn default_hash_encodes_rfc_9106_second_recommended_profile() {
582        let hash = hash_password("default policy password").unwrap();
583
584        assert!(hash.starts_with("$argon2id$v=19$m=65536,t=3,p=4$"));
585        assert!(verify_password("default policy password", &hash).unwrap());
586    }
587
588    #[test]
589    fn sha256_hex_matches_known_vectors_and_binary_input() {
590        assert_eq!(
591            sha256_hex(b""),
592            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
593        );
594        assert_eq!(
595            sha256_hex(b"abc"),
596            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
597        );
598        assert_eq!(
599            sha256_hex(&[0x00, 0xff, 0x10]),
600            "2da45f2cd1f9c8e69a67abf7a6b26c282533d0a7686787a9533265418680d4d2"
601        );
602    }
603
604    #[test]
605    fn hmac_sha256_matches_rfc_4231_vector_and_separates_keys() {
606        let data = b"Hi There";
607        let first = hmac_sha256_hex(&[0x0b; 20], data).unwrap();
608        let second = hmac_sha256_hex(&[0x0c; 20], data).unwrap();
609
610        assert_eq!(
611            first,
612            "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
613        );
614        assert_ne!(first, second);
615        assert_ne!(first, hmac_sha256_hex(&[0x0b; 20], b"Hi There!").unwrap());
616        assert_eq!(
617            hmac_sha256_hex(b"Jefe", b"what do ya want for nothing?").unwrap(),
618            "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
619        );
620        assert_eq!(
621            hmac_sha256_hex(b"", b"").unwrap(),
622            "b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad"
623        );
624    }
625
626    #[test]
627    fn bytes_to_hex_encodes_lowercase_and_preserves_leading_zeroes() {
628        assert_eq!(bytes_to_hex(&[]), "");
629        assert_eq!(bytes_to_hex(&[0x00, 0x0f, 0x10, 0xab, 0xff]), "000f10abff");
630    }
631
632    #[test]
633    fn sha256_digest_to_hex_matches_incremental_hasher_output() {
634        let mut hasher = new_sha256();
635        hasher.update(b"a");
636        hasher.update(b"bc");
637        assert_eq!(sha256_digest_to_hex(&hasher.finalize()), sha256_hex(b"abc"));
638    }
639
640    #[test]
641    fn password_hash_verifies_matching_password_and_rejects_wrong_password() {
642        let policy = lightweight_policy();
643        let hash = hash_password_with_policy("correct horse battery staple", &policy).unwrap();
644
645        assert!(
646            verify_password_with_policy("correct horse battery staple", &hash, &policy)
647                .unwrap()
648                .is_valid
649        );
650        assert!(
651            !verify_password_with_policy("wrong password", &hash, &policy)
652                .unwrap()
653                .is_valid
654        );
655        assert!(hash.starts_with("$argon2id$v=19$m=8,t=1,p=1$"));
656    }
657
658    #[test]
659    fn password_hash_uses_fresh_salt() {
660        let policy = lightweight_policy();
661        let first = hash_password_with_policy("same password", &policy).unwrap();
662        let second = hash_password_with_policy("same password", &policy).unwrap();
663
664        assert_ne!(first, second);
665        assert!(
666            verify_password_with_policy("same password", &first, &policy)
667                .unwrap()
668                .is_valid
669        );
670        assert!(
671            verify_password_with_policy("same password", &second, &policy)
672                .unwrap()
673                .is_valid
674        );
675    }
676
677    #[test]
678    fn malformed_and_unsupported_password_hashes_return_errors() {
679        assert!(matches!(
680            verify_password("password", "not-a-password-hash"),
681            Err(CryptoError::PasswordHash(_))
682        ));
683
684        let policy = lightweight_policy();
685        let hash = hash_password_with_policy("password", &policy).unwrap();
686        let unsupported = hash.replacen("$argon2id$", "$notargon$", 1);
687        assert!(matches!(
688            verify_password_with_policy("password", &unsupported, &policy),
689            Err(CryptoError::PasswordHash(_))
690        ));
691
692        let unsupported_version = hash.replacen("v=19", "v=16", 1);
693        assert!(matches!(
694            verify_password_with_policy("password", &unsupported_version, &policy),
695            Err(CryptoError::PasswordHash(_))
696        ));
697
698        let missing_version = hash.replacen("$v=19$", "$", 1);
699        assert!(matches!(
700            verify_password_with_policy("password", &missing_version, &policy),
701            Err(CryptoError::PasswordHash(_))
702        ));
703
704        assert!(matches!(
705            verify_password_with_policy("password", "$argon2id$v=19$m=8,t=1,p=1", &policy,),
706            Err(CryptoError::PasswordHash(_))
707        ));
708        assert!(matches!(
709            verify_password_with_policy(
710                "password",
711                "$argon2id$v=19$m=8,t=1,p=1$c2FsdHNhbHQ",
712                &policy,
713            ),
714            Err(CryptoError::PasswordHash(_))
715        ));
716
717        let unknown_parameter = hash.replacen("p=1", "p=1,x=1", 1);
718        assert!(matches!(
719            verify_password_with_policy("password", &unknown_parameter, &policy),
720            Err(CryptoError::PasswordHash(_))
721        ));
722    }
723
724    #[test]
725    fn verification_rejects_over_budget_work_factors_before_argon2_runs() {
726        let policy = lightweight_policy();
727        let hash = hash_password_with_policy("password", &policy).unwrap();
728        let over_memory = hash.replacen("m=8", "m=9", 1);
729        let over_iterations = hash.replacen("t=1", "t=2", 1);
730        let over_output = hash_with_work_factor(
731            "password",
732            PasswordHashWorkFactor::new(8, 1, 1, 33).unwrap(),
733        );
734        let parallel_work_factor = PasswordHashWorkFactor::new(16, 1, 1, 32).unwrap();
735        let parallel_limits = PasswordHashVerificationLimits::new(16, 1, 1, 32).unwrap();
736        let parallel_policy =
737            PasswordHashPolicy::new(parallel_work_factor, parallel_limits).unwrap();
738        let over_parallelism =
739            hash_with_work_factor("password", parallel_work_factor).replacen("p=1", "p=2", 1);
740
741        assert!(matches!(
742            verify_password_with_policy("password", &over_memory, &policy),
743            Err(CryptoError::PasswordHashVerificationLimit {
744                parameter: "m",
745                actual: 9,
746                maximum: 8,
747            })
748        ));
749        assert!(matches!(
750            verify_password_with_policy("password", &over_iterations, &policy),
751            Err(CryptoError::PasswordHashVerificationLimit {
752                parameter: "t",
753                actual: 2,
754                maximum: 1,
755            })
756        ));
757        assert!(matches!(
758            verify_password_with_policy("password", &over_parallelism, &parallel_policy),
759            Err(CryptoError::PasswordHashVerificationLimit {
760                parameter: "p",
761                actual: 2,
762                maximum: 1,
763            })
764        ));
765        assert!(matches!(
766            verify_password_with_policy("password", &over_output, &policy),
767            Err(CryptoError::PasswordHashVerificationLimit {
768                parameter: "output_length",
769                actual: 33,
770                maximum: 32,
771            })
772        ));
773
774        let extreme_memory = hash.replacen("m=8", "m=4294967295", 1);
775        assert!(matches!(
776            verify_password_with_policy("password", &extreme_memory, &policy),
777            Err(CryptoError::PasswordHashVerificationLimit {
778                parameter: "m",
779                actual: 4_294_967_295,
780                maximum: 8,
781            })
782        ));
783    }
784
785    #[test]
786    fn legacy_work_factor_verifies_and_requests_rehash() {
787        let legacy = PasswordHashWorkFactor::new(19 * 1024, 2, 1, 32).unwrap();
788        let hash = hash_with_work_factor("password", legacy);
789        let verification =
790            verify_password_with_policy("password", &hash, &PasswordHashPolicy::default()).unwrap();
791
792        assert!(verification.is_valid);
793        assert!(verification.needs_rehash);
794
795        let wrong =
796            verify_password_with_policy("wrong password", &hash, &PasswordHashPolicy::default())
797                .unwrap();
798        assert!(!wrong.is_valid);
799        assert!(!wrong.needs_rehash);
800    }
801
802    #[test]
803    fn current_work_factor_does_not_request_rehash() {
804        let policy = lightweight_policy();
805        let hash = hash_password_with_policy("password", &policy).unwrap();
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 short_legacy_salt_verifies_and_requests_rehash() {
814        let policy = lightweight_policy();
815        let hash = hash_with_work_factor_and_salt("password", policy.work_factor(), &[5_u8; 8]);
816        let verification = verify_password_with_policy("password", &hash, &policy).unwrap();
817
818        assert!(verification.is_valid);
819        assert!(verification.needs_rehash);
820    }
821
822    #[test]
823    fn custom_limits_accept_stronger_hash_without_rehashing_down() {
824        let current = PasswordHashWorkFactor::new(8, 1, 1, 32).unwrap();
825        let limits = PasswordHashVerificationLimits::new(16, 2, 2, 32).unwrap();
826        let policy = PasswordHashPolicy::new(current, limits).unwrap();
827        let stronger = PasswordHashWorkFactor::new(16, 2, 2, 32).unwrap();
828        let hash = hash_with_work_factor("password", stronger);
829        let verification = verify_password_with_policy("password", &hash, &policy).unwrap();
830
831        assert!(verification.is_valid);
832        assert!(!verification.needs_rehash);
833    }
834
835    #[test]
836    fn policy_rejects_work_factor_above_its_verification_limits() {
837        let work_factor = PasswordHashWorkFactor::new(64, 2, 1, 32).unwrap();
838        let limits = PasswordHashVerificationLimits::new(32, 2, 1, 32).unwrap();
839
840        assert!(matches!(
841            PasswordHashPolicy::new(work_factor, limits),
842            Err(CryptoError::PasswordHashPolicy(_))
843        ));
844
845        assert!(matches!(
846            PasswordHashWorkFactor::new(8, 1, 1, 4),
847            Err(CryptoError::PasswordHashPolicy(_))
848        ));
849    }
850
851    #[test]
852    fn work_factor_and_limit_constructors_reject_every_invalid_boundary() {
853        for result in [
854            PasswordHashWorkFactor::new(7, 1, 1, 32),
855            PasswordHashWorkFactor::new(15, 1, 2, 32),
856            PasswordHashWorkFactor::new(8, 0, 1, 32),
857            PasswordHashWorkFactor::new(8, 1, 0, 32),
858            PasswordHashWorkFactor::new(8, 1, 1, Output::MIN_LENGTH - 1),
859            PasswordHashWorkFactor::new(8, 1, 1, Output::MAX_LENGTH + 1),
860        ] {
861            assert!(matches!(result, Err(CryptoError::PasswordHashPolicy(_))));
862        }
863
864        for result in [
865            PasswordHashVerificationLimits::new(Params::MIN_M_COST - 1, 1, 1, 32),
866            PasswordHashVerificationLimits::new(8, Params::MIN_T_COST - 1, 1, 32),
867            PasswordHashVerificationLimits::new(8, 1, Params::MIN_P_COST - 1, 32),
868            PasswordHashVerificationLimits::new(8, 1, 1, Output::MIN_LENGTH - 1),
869            PasswordHashVerificationLimits::new(8, 1, 1, Output::MAX_LENGTH + 1),
870        ] {
871            assert!(matches!(result, Err(CryptoError::PasswordHashPolicy(_))));
872        }
873    }
874
875    #[test]
876    fn policy_rejects_each_work_factor_dimension_above_its_limit() {
877        let cases = [
878            (
879                PasswordHashWorkFactor::new(16, 1, 1, 32).unwrap(),
880                PasswordHashVerificationLimits::new(8, 1, 1, 32).unwrap(),
881            ),
882            (
883                PasswordHashWorkFactor::new(8, 2, 1, 32).unwrap(),
884                PasswordHashVerificationLimits::new(8, 1, 1, 32).unwrap(),
885            ),
886            (
887                PasswordHashWorkFactor::new(16, 1, 2, 32).unwrap(),
888                PasswordHashVerificationLimits::new(16, 1, 1, 32).unwrap(),
889            ),
890            (
891                PasswordHashWorkFactor::new(8, 1, 1, 33).unwrap(),
892                PasswordHashVerificationLimits::new(8, 1, 1, 32).unwrap(),
893            ),
894        ];
895
896        for (work_factor, limits) in cases {
897            assert!(matches!(
898                PasswordHashPolicy::new(work_factor, limits),
899                Err(CryptoError::PasswordHashPolicy(_))
900            ));
901        }
902    }
903}