Skip to main content

rustrails_model/
secure_password.rs

1use argon2::password_hash::{
2    SaltString,
3    rand_core::{OsRng, RngCore},
4};
5use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
6use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
7use bcrypt::{DEFAULT_COST, hash as bcrypt_hash, verify as bcrypt_verify};
8use hmac::{Hmac, Mac};
9use indexmap::IndexMap;
10use once_cell::sync::Lazy;
11use sha2::Sha256;
12use std::sync::{PoisonError, RwLock};
13
14const DEFAULT_PASSWORD_ALGORITHM: &str = "bcrypt";
15const MAX_PASSWORD_BYTES: usize = 72;
16const RECOVERY_TOKEN_NONCE_BYTES: usize = 32;
17const RECOVERY_TOKEN_SIGNATURE_BYTES: usize = 32;
18const RECOVERY_TOKEN_PREFIX: &str = "rrspwt_";
19
20type RecoveryTokenMac = Hmac<Sha256>;
21
22/// Errors returned by [`SecurePassword`] operations.
23#[derive(Debug, thiserror::Error, PartialEq, Eq)]
24pub enum SecurePasswordError {
25    /// The password was blank.
26    #[error("password is blank")]
27    Blank,
28    /// The password exceeded the supported bcrypt-compatible byte limit.
29    #[error("password is too long (maximum is {max} characters)")]
30    TooLong { max: usize },
31    /// The provided password confirmation did not match.
32    #[error("password confirmation doesn't match")]
33    ConfirmationMismatch,
34    /// Password hashing or digest parsing failed.
35    #[error("hashing error: {0}")]
36    HashError(String),
37    /// Authentication could not be performed because no digest exists.
38    #[error("authentication failed")]
39    AuthenticationFailed,
40}
41
42#[derive(Clone, Copy, Debug)]
43pub struct PasswordAlgorithm {
44    hash: fn(&str) -> Result<String, SecurePasswordError>,
45    verify: fn(&str, &str) -> Result<bool, SecurePasswordError>,
46    matches_digest: fn(&str) -> bool,
47}
48
49impl PasswordAlgorithm {
50    pub const fn new(
51        hash: fn(&str) -> Result<String, SecurePasswordError>,
52        verify: fn(&str, &str) -> Result<bool, SecurePasswordError>,
53        matches_digest: fn(&str) -> bool,
54    ) -> Self {
55        Self {
56            hash,
57            verify,
58            matches_digest,
59        }
60    }
61}
62
63static PASSWORD_ALGORITHM_REGISTRY: Lazy<RwLock<IndexMap<String, PasswordAlgorithm>>> =
64    Lazy::new(|| {
65        let mut algorithms = IndexMap::new();
66        algorithms.insert(
67            DEFAULT_PASSWORD_ALGORITHM.to_owned(),
68            PasswordAlgorithm::new(
69                hash_password_with_bcrypt,
70                verify_password_with_bcrypt,
71                is_bcrypt_digest,
72            ),
73        );
74        algorithms.insert(
75            "argon2".to_owned(),
76            PasswordAlgorithm::new(
77                hash_password_with_argon2,
78                verify_password_with_argon2,
79                is_argon2_digest,
80            ),
81        );
82        RwLock::new(algorithms)
83    });
84
85pub fn default_password_algorithm() -> &'static str {
86    DEFAULT_PASSWORD_ALGORITHM
87}
88
89pub fn register_password_algorithm(name: impl Into<String>, algorithm: PasswordAlgorithm) {
90    with_password_algorithm_registry_mut(|algorithms| {
91        algorithms.insert(name.into(), algorithm);
92    });
93}
94
95pub fn registered_password_algorithms() -> Vec<String> {
96    with_password_algorithm_registry(|algorithms| algorithms.keys().cloned().collect())
97}
98
99fn with_password_algorithm_registry<T>(
100    f: impl FnOnce(&IndexMap<String, PasswordAlgorithm>) -> T,
101) -> T {
102    let registry = PASSWORD_ALGORITHM_REGISTRY
103        .read()
104        .unwrap_or_else(PoisonError::into_inner);
105    f(&registry)
106}
107
108fn with_password_algorithm_registry_mut<T>(
109    f: impl FnOnce(&mut IndexMap<String, PasswordAlgorithm>) -> T,
110) -> T {
111    let mut registry = PASSWORD_ALGORITHM_REGISTRY
112        .write()
113        .unwrap_or_else(PoisonError::into_inner);
114    f(&mut registry)
115}
116
117fn resolve_password_algorithm(name: &str) -> Result<PasswordAlgorithm, SecurePasswordError> {
118    with_password_algorithm_registry(|algorithms| algorithms.get(name).copied()).ok_or_else(|| {
119        SecurePasswordError::HashError(format!("unknown password algorithm: {name}"))
120    })
121}
122
123fn resolve_password_algorithm_for_digest(
124    digest: &str,
125) -> Result<PasswordAlgorithm, SecurePasswordError> {
126    if digest.is_empty() {
127        return Err(SecurePasswordError::HashError(
128            "password digest is blank".to_owned(),
129        ));
130    }
131
132    with_password_algorithm_registry(|algorithms| {
133        algorithms
134            .values()
135            .copied()
136            .find(|algorithm| (algorithm.matches_digest)(digest))
137    })
138    .ok_or_else(|| SecurePasswordError::HashError("unknown password digest format".to_owned()))
139}
140
141fn validate_password(password: &str) -> Result<(), SecurePasswordError> {
142    if password.is_empty() {
143        return Err(SecurePasswordError::Blank);
144    }
145
146    if password.len() > MAX_PASSWORD_BYTES {
147        return Err(SecurePasswordError::TooLong {
148            max: MAX_PASSWORD_BYTES,
149        });
150    }
151
152    Ok(())
153}
154
155fn stored_password_digest(digest: Option<&str>) -> Result<&str, SecurePasswordError> {
156    match digest {
157        Some("") => Err(SecurePasswordError::HashError(
158            "password digest is blank".to_owned(),
159        )),
160        Some(digest) => Ok(digest),
161        None => Err(SecurePasswordError::AuthenticationFailed),
162    }
163}
164
165fn hash_password_with_argon2(password: &str) -> Result<String, SecurePasswordError> {
166    let salt = SaltString::generate(&mut OsRng);
167    let hash = Argon2::default()
168        .hash_password(password.as_bytes(), &salt)
169        .map_err(|error| SecurePasswordError::HashError(error.to_string()))?;
170
171    Ok(hash.to_string())
172}
173
174fn verify_password_with_argon2(password: &str, digest: &str) -> Result<bool, SecurePasswordError> {
175    let parsed = PasswordHash::new(digest)
176        .map_err(|error| SecurePasswordError::HashError(error.to_string()))?;
177
178    Ok(Argon2::default()
179        .verify_password(password.as_bytes(), &parsed)
180        .is_ok())
181}
182
183fn hash_password_with_bcrypt(password: &str) -> Result<String, SecurePasswordError> {
184    bcrypt_hash(password, DEFAULT_COST)
185        .map_err(|error| SecurePasswordError::HashError(error.to_string()))
186}
187
188fn verify_password_with_bcrypt(password: &str, digest: &str) -> Result<bool, SecurePasswordError> {
189    bcrypt_verify(password, digest)
190        .map_err(|error| SecurePasswordError::HashError(error.to_string()))
191}
192
193fn is_argon2_digest(digest: &str) -> bool {
194    digest.starts_with("$argon2")
195}
196
197fn is_bcrypt_digest(digest: &str) -> bool {
198    digest.starts_with("$2a$")
199        || digest.starts_with("$2b$")
200        || digest.starts_with("$2x$")
201        || digest.starts_with("$2y$")
202}
203
204fn encode_recovery_token(payload: &[u8]) -> String {
205    format!("{RECOVERY_TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(payload))
206}
207
208fn decode_recovery_token(token: &str) -> Option<Vec<u8>> {
209    let encoded = token.strip_prefix(RECOVERY_TOKEN_PREFIX)?;
210    URL_SAFE_NO_PAD.decode(encoded).ok()
211}
212
213fn recovery_token_signature(digest: &str, nonce: &[u8]) -> Result<Vec<u8>, SecurePasswordError> {
214    let mut mac = RecoveryTokenMac::new_from_slice(digest.as_bytes())
215        .map_err(|error| SecurePasswordError::HashError(error.to_string()))?;
216    mac.update(nonce);
217    Ok(mac.finalize().into_bytes().to_vec())
218}
219
220fn password_salt_from_digest(digest: &str) -> Option<String> {
221    if is_argon2_digest(digest) {
222        return PasswordHash::new(digest)
223            .ok()?
224            .salt
225            .map(|salt| salt.as_str().to_string());
226    }
227
228    if is_bcrypt_digest(digest) && digest.len() >= 29 {
229        return Some(digest[..29].to_string());
230    }
231
232    None
233}
234
235/// Trait for models that store a password digest.
236pub trait SecurePassword {
237    /// Returns the stored password digest, if one exists.
238    fn password_digest(&self) -> Option<&str>;
239
240    /// Replaces the stored password digest.
241    fn set_password_digest(&mut self, digest: String);
242
243    /// Hashes and stores a password using the default algorithm registry entry.
244    fn set_password(&mut self, password: &str) -> Result<(), SecurePasswordError> {
245        self.set_password_with_algorithm(password, DEFAULT_PASSWORD_ALGORITHM)
246    }
247
248    /// Hashes and stores a password when the optional confirmation matches.
249    fn set_password_confirmed(
250        &mut self,
251        password: &str,
252        confirmation: Option<&str>,
253    ) -> Result<(), SecurePasswordError> {
254        if let Some(confirmation) = confirmation
255            && password != confirmation
256        {
257            return Err(SecurePasswordError::ConfirmationMismatch);
258        }
259
260        self.set_password(password)
261    }
262
263    /// Updates an existing digest, treating a blank password as "no change".
264    fn update_password(&mut self, password: &str) -> Result<bool, SecurePasswordError> {
265        if password.is_empty() {
266            return Ok(false);
267        }
268
269        self.set_password(password)?;
270        Ok(true)
271    }
272
273    /// Updates an existing digest with optional confirmation, treating a blank password as "no change".
274    fn update_password_confirmed(
275        &mut self,
276        password: &str,
277        confirmation: Option<&str>,
278    ) -> Result<bool, SecurePasswordError> {
279        if password.is_empty() {
280            if matches!(confirmation, Some(confirmation) if !confirmation.is_empty()) {
281                return Err(SecurePasswordError::ConfirmationMismatch);
282            }
283            return Ok(false);
284        }
285
286        self.set_password_confirmed(password, confirmation)?;
287        Ok(true)
288    }
289
290    /// Hashes and stores a password using a registered algorithm.
291    fn set_password_with_algorithm(
292        &mut self,
293        password: &str,
294        algorithm: &str,
295    ) -> Result<(), SecurePasswordError> {
296        validate_password(password)?;
297
298        let algorithm = resolve_password_algorithm(algorithm)?;
299        let digest = (algorithm.hash)(password)?;
300        self.set_password_digest(digest);
301        Ok(())
302    }
303
304    /// Verifies a password against the stored digest.
305    fn authenticate(&self, password: &str) -> Result<bool, SecurePasswordError> {
306        if password.len() > MAX_PASSWORD_BYTES {
307            return Ok(false);
308        }
309
310        if matches!(self.password_digest(), Some("")) {
311            return Ok(false);
312        }
313
314        let digest = stored_password_digest(self.password_digest())?;
315        let algorithm = resolve_password_algorithm_for_digest(digest)?;
316        (algorithm.verify)(password, digest)
317    }
318
319    /// Verifies an optional challenge and returns false for missing, blank, or malformed inputs.
320    fn authenticate_password(&self, challenge: Option<&str>) -> bool {
321        let Some(challenge) = challenge else {
322            return false;
323        };
324
325        if challenge.is_empty() {
326            return false;
327        }
328
329        self.authenticate(challenge).unwrap_or(false)
330    }
331
332    /// Generates a self-contained recovery token bound to the current password digest.
333    fn generate_recovery_token(&self) -> Result<String, SecurePasswordError> {
334        let digest = stored_password_digest(self.password_digest())?;
335        let mut nonce = [0_u8; RECOVERY_TOKEN_NONCE_BYTES];
336        OsRng.fill_bytes(&mut nonce);
337        let signature = recovery_token_signature(digest, &nonce)?;
338
339        let mut payload =
340            Vec::with_capacity(RECOVERY_TOKEN_NONCE_BYTES + RECOVERY_TOKEN_SIGNATURE_BYTES);
341        payload.extend_from_slice(&nonce);
342        payload.extend_from_slice(&signature);
343
344        Ok(encode_recovery_token(&payload))
345    }
346
347    /// Verifies a recovery token against the current password digest.
348    fn verify_recovery_token(&self, token: &str) -> bool {
349        let Some(payload) = decode_recovery_token(token) else {
350            return false;
351        };
352
353        if payload.len() != RECOVERY_TOKEN_NONCE_BYTES + RECOVERY_TOKEN_SIGNATURE_BYTES {
354            return false;
355        }
356
357        let Ok(digest) = stored_password_digest(self.password_digest()) else {
358            return false;
359        };
360
361        let (nonce, signature) = payload.split_at(RECOVERY_TOKEN_NONCE_BYTES);
362        let Ok(expected_signature) = recovery_token_signature(digest, nonce) else {
363            return false;
364        };
365
366        signature == expected_signature.as_slice()
367    }
368
369    /// Returns `true` when a password digest is present.
370    fn has_password(&self) -> bool {
371        self.password_digest().is_some()
372    }
373
374    /// Extracts the salt encoded in the current password digest.
375    fn password_salt(&self) -> Option<String> {
376        let digest = self.password_digest()?;
377        if digest.is_empty() {
378            return None;
379        }
380        password_salt_from_digest(digest)
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::{
387        PasswordAlgorithm, SecurePassword, SecurePasswordError, default_password_algorithm,
388        register_password_algorithm, registered_password_algorithms,
389    };
390    use argon2::PasswordHasher;
391
392    #[derive(Debug, Default)]
393    struct User {
394        digest: Option<String>,
395    }
396
397    impl SecurePassword for User {
398        fn password_digest(&self) -> Option<&str> {
399            self.digest.as_deref()
400        }
401
402        fn set_password_digest(&mut self, digest: String) {
403            self.digest = Some(digest);
404        }
405    }
406
407    fn external_digest(password: &str) -> String {
408        let salt = argon2::password_hash::SaltString::generate(
409            &mut argon2::password_hash::rand_core::OsRng,
410        );
411
412        argon2::Argon2::default()
413            .hash_password(password.as_bytes(), &salt)
414            .expect("external hash should succeed")
415            .to_string()
416    }
417
418    fn reverse_algorithm_hash(password: &str) -> Result<String, SecurePasswordError> {
419        Ok(format!(
420            "reverse${}",
421            password.chars().rev().collect::<String>()
422        ))
423    }
424
425    fn reverse_algorithm_verify(password: &str, digest: &str) -> Result<bool, SecurePasswordError> {
426        Ok(digest == format!("reverse${}", password.chars().rev().collect::<String>()))
427    }
428
429    fn reverse_algorithm_matches(digest: &str) -> bool {
430        digest.starts_with("reverse$")
431    }
432
433    #[test]
434    fn set_password_hashes_and_stores_the_digest() {
435        let mut user = User::default();
436
437        user.set_password("password").expect("password should hash");
438
439        let digest = user.password_digest().expect("digest should exist");
440        assert!(!digest.is_empty());
441        assert_ne!(digest, "password");
442        assert!(digest.starts_with("$2"));
443    }
444
445    #[test]
446    fn authenticate_with_correct_password_returns_true() {
447        let mut user = User::default();
448        user.set_password("password").expect("password should hash");
449
450        let authenticated = user
451            .authenticate("password")
452            .expect("authentication should parse the digest");
453
454        assert!(authenticated);
455    }
456
457    #[test]
458    fn authenticate_with_wrong_password_returns_false() {
459        let mut user = User::default();
460        user.set_password("password").expect("password should hash");
461
462        let authenticated = user
463            .authenticate("not-the-password")
464            .expect("authentication should parse the digest");
465
466        assert!(!authenticated);
467    }
468
469    #[test]
470    fn blank_password_is_rejected() {
471        let mut user = User::default();
472
473        let error = user
474            .set_password("")
475            .expect_err("blank password should fail");
476
477        assert_eq!(error, SecurePasswordError::Blank);
478        assert!(!user.has_password());
479    }
480
481    #[test]
482    fn too_long_password_is_rejected() {
483        let mut user = User::default();
484
485        let error = user
486            .set_password(&"a".repeat(73))
487            .expect_err("73-byte password should fail");
488
489        assert_eq!(error, SecurePasswordError::TooLong { max: 72 });
490        assert!(!user.has_password());
491    }
492
493    #[test]
494    fn password_byte_length_limit_uses_bytes_not_scalar_values() {
495        let mut user = User::default();
496        let password = "あ".repeat(24) + "a";
497
498        let error = user
499            .set_password(&password)
500            .expect_err("73-byte unicode password should fail");
501
502        assert_eq!(error, SecurePasswordError::TooLong { max: 72 });
503    }
504
505    #[test]
506    fn password_round_trip_succeeds() {
507        let mut user = User::default();
508        user.set_password("correct horse battery staple")
509            .expect("password should hash");
510
511        assert!(
512            user.authenticate("correct horse battery staple")
513                .expect("authentication should succeed")
514        );
515        assert!(
516            !user
517                .authenticate("tr0ub4dor&3")
518                .expect("authentication should succeed")
519        );
520    }
521
522    #[test]
523    fn has_password_reflects_whether_a_digest_exists() {
524        let mut user = User::default();
525        assert!(!user.has_password());
526
527        user.set_password("password").expect("password should hash");
528
529        assert!(user.has_password());
530    }
531
532    #[test]
533    fn different_passwords_produce_different_digests() {
534        let mut first = User::default();
535        let mut second = User::default();
536        first
537            .set_password("password-one")
538            .expect("password should hash");
539        second
540            .set_password("password-two")
541            .expect("password should hash");
542
543        assert_ne!(first.password_digest(), second.password_digest());
544    }
545
546    #[test]
547    fn same_password_uses_unique_salts() {
548        let mut first = User::default();
549        let mut second = User::default();
550        first
551            .set_password("password")
552            .expect("password should hash");
553        second
554            .set_password("password")
555            .expect("password should hash");
556
557        assert_ne!(first.password_digest(), second.password_digest());
558    }
559
560    #[test]
561    fn authentication_without_a_digest_fails_gracefully() {
562        let user = User::default();
563
564        let error = user
565            .authenticate("password")
566            .expect_err("missing digest should fail");
567
568        assert_eq!(error, SecurePasswordError::AuthenticationFailed);
569    }
570
571    #[test]
572    fn malformed_digest_returns_a_hash_error() {
573        let user = User {
574            digest: Some("not-a-valid-phc-string".to_owned()),
575        };
576
577        let error = user
578            .authenticate("password")
579            .expect_err("malformed digest should fail");
580
581        assert!(matches!(error, SecurePasswordError::HashError(_)));
582    }
583
584    #[test]
585    fn spaces_only_password_is_accepted() {
586        let mut user = User::default();
587        let password = " ".repeat(72);
588
589        user.set_password(&password)
590            .expect("spaces-only password should hash");
591
592        assert!(
593            user.authenticate(&password)
594                .expect("authentication should succeed")
595        );
596    }
597
598    #[test]
599    fn password_digest_is_absent_by_default() {
600        let user = User::default();
601
602        assert_eq!(user.password_digest(), None);
603        assert!(!user.has_password());
604    }
605
606    #[test]
607    fn exact_maximum_length_password_is_accepted() {
608        let mut user = User::default();
609        let password = "a".repeat(72);
610
611        user.set_password(&password)
612            .expect("72-byte password should hash");
613
614        assert!(
615            user.authenticate(&password)
616                .expect("authentication should succeed")
617        );
618    }
619
620    #[test]
621    fn exact_maximum_byte_length_unicode_password_is_accepted() {
622        let mut user = User::default();
623        let password = "あ".repeat(24);
624
625        user.set_password(&password)
626            .expect("72-byte unicode password should hash");
627
628        assert!(
629            user.authenticate(&password)
630                .expect("authentication should succeed")
631        );
632    }
633
634    #[test]
635    fn authenticate_with_empty_password_returns_false_when_digest_exists() {
636        let mut user = User::default();
637        user.set_password("password").expect("password should hash");
638
639        assert!(
640            !user
641                .authenticate("")
642                .expect("authentication should succeed")
643        );
644    }
645
646    #[test]
647    fn authenticate_is_case_sensitive() {
648        let mut user = User::default();
649        user.set_password("Password").expect("password should hash");
650
651        assert!(
652            user.authenticate("Password")
653                .expect("authentication should succeed")
654        );
655        assert!(
656            !user
657                .authenticate("password")
658                .expect("authentication should succeed")
659        );
660    }
661
662    #[test]
663    fn setting_a_new_password_replaces_the_stored_digest() {
664        let mut user = User::default();
665        user.set_password("password").expect("password should hash");
666        let original_digest = user
667            .password_digest()
668            .expect("digest should exist")
669            .to_owned();
670
671        user.set_password("new password")
672            .expect("replacement password should hash");
673
674        assert_ne!(user.password_digest(), Some(original_digest.as_str()));
675    }
676
677    #[test]
678    fn old_password_no_longer_authenticates_after_reset() {
679        let mut user = User::default();
680        user.set_password("password").expect("password should hash");
681        user.set_password("new password")
682            .expect("replacement password should hash");
683
684        assert!(
685            user.authenticate("new password")
686                .expect("authentication should succeed")
687        );
688        assert!(
689            !user
690                .authenticate("password")
691                .expect("authentication should succeed")
692        );
693    }
694
695    #[test]
696    fn blank_password_error_preserves_existing_digest() {
697        let mut user = User::default();
698        user.set_password("password").expect("password should hash");
699        let original_digest = user
700            .password_digest()
701            .expect("digest should exist")
702            .to_owned();
703
704        let error = user
705            .set_password("")
706            .expect_err("blank password should fail");
707
708        assert_eq!(error, SecurePasswordError::Blank);
709        assert_eq!(user.password_digest(), Some(original_digest.as_str()));
710        assert!(
711            user.authenticate("password")
712                .expect("authentication should succeed")
713        );
714    }
715
716    #[test]
717    fn too_long_password_error_preserves_existing_digest() {
718        let mut user = User::default();
719        user.set_password("password").expect("password should hash");
720        let original_digest = user
721            .password_digest()
722            .expect("digest should exist")
723            .to_owned();
724
725        let error = user
726            .set_password(&"a".repeat(73))
727            .expect_err("73-byte password should fail");
728
729        assert_eq!(error, SecurePasswordError::TooLong { max: 72 });
730        assert_eq!(user.password_digest(), Some(original_digest.as_str()));
731        assert!(
732            user.authenticate("password")
733                .expect("authentication should succeed")
734        );
735    }
736
737    #[test]
738    fn manually_setting_a_digest_marks_password_as_present() {
739        let mut user = User::default();
740        user.set_password_digest("manual-digest".to_string());
741
742        assert!(user.has_password());
743        assert_eq!(user.password_digest(), Some("manual-digest"));
744    }
745
746    #[test]
747    fn externally_generated_digest_can_authenticate() {
748        let salt = argon2::password_hash::SaltString::generate(
749            &mut argon2::password_hash::rand_core::OsRng,
750        );
751        let digest = argon2::Argon2::default()
752            .hash_password("password".as_bytes(), &salt)
753            .expect("external hash should succeed")
754            .to_string();
755        let user = User {
756            digest: Some(digest),
757        };
758
759        assert!(
760            user.authenticate("password")
761                .expect("authentication should succeed")
762        );
763        assert!(
764            !user
765                .authenticate("wrong")
766                .expect("authentication should succeed")
767        );
768    }
769
770    #[test]
771    fn external_empty_password_digest_can_authenticate_an_empty_password() {
772        let user = User {
773            digest: Some(external_digest("")),
774        };
775
776        assert!(
777            user.authenticate("")
778                .expect("authentication should succeed")
779        );
780        assert!(
781            !user
782                .authenticate("not-empty")
783                .expect("authentication should succeed")
784        );
785    }
786
787    #[test]
788    fn authenticate_with_an_overlong_password_returns_false_instead_of_error() {
789        let mut user = User::default();
790        user.set_password("password").expect("password should hash");
791
792        let password = "a".repeat(200);
793
794        assert!(
795            !user
796                .authenticate(&password)
797                .expect("authentication should still parse the digest")
798        );
799    }
800
801    #[test]
802    fn password_with_an_embedded_nul_byte_hashes_and_authenticates() {
803        let mut user = User::default();
804        let password = "prefix\0suffix";
805
806        user.set_password(password)
807            .expect("password with embedded nul should hash");
808
809        assert!(
810            user.authenticate(password)
811                .expect("authentication should succeed")
812        );
813        assert!(
814            !user
815                .authenticate("prefixsuffix")
816                .expect("authentication should succeed")
817        );
818    }
819
820    #[test]
821    fn generated_digest_exposes_argon2_metadata_and_components() {
822        let mut user = User::default();
823        user.set_password_with_algorithm("password", "argon2")
824            .expect("password should hash");
825
826        let digest = user.password_digest().expect("digest should exist");
827        let parsed = argon2::PasswordHash::new(digest).expect("digest should parse");
828
829        assert!(digest.starts_with("$argon2id$"));
830        assert!(digest.contains("$v=19$"));
831        assert!(digest.contains("m="));
832        assert!(digest.contains("t="));
833        assert!(digest.contains("p="));
834        assert_eq!(parsed.algorithm.as_str(), "argon2id");
835        assert!(parsed.salt.is_some(), "digest should include a salt");
836        assert!(parsed.hash.is_some(), "digest should include a hash output");
837    }
838
839    #[test]
840    fn replacing_the_digest_switches_which_password_authenticates() {
841        let mut user = User::default();
842        user.set_password("password").expect("password should hash");
843        user.set_password_digest(external_digest("replacement"));
844
845        assert!(
846            user.authenticate("replacement")
847                .expect("authentication should succeed")
848        );
849        assert!(
850            !user
851                .authenticate("password")
852                .expect("authentication should succeed")
853        );
854    }
855
856    #[test]
857    fn blank_error_message_is_human_readable() {
858        assert_eq!(SecurePasswordError::Blank.to_string(), "password is blank");
859    }
860
861    #[test]
862    fn too_long_error_message_mentions_the_limit() {
863        assert_eq!(
864            SecurePasswordError::TooLong { max: 72 }.to_string(),
865            "password is too long (maximum is 72 characters)"
866        );
867    }
868
869    #[test]
870    fn hash_error_message_includes_the_hashing_prefix() {
871        assert_eq!(
872            SecurePasswordError::HashError("boom".to_string()).to_string(),
873            "hashing error: boom"
874        );
875    }
876
877    #[test]
878    fn set_password_with_argon2_stores_an_argon2_digest() {
879        let mut user = User::default();
880
881        user.set_password_with_algorithm("password", "argon2")
882            .expect("password should hash");
883
884        let digest = user.password_digest().expect("digest should exist");
885        let parsed = argon2::PasswordHash::new(digest).expect("digest should parse");
886
887        assert!(digest.starts_with("$argon2id$"));
888        assert!(digest.contains("$v=19$"));
889        assert!(digest.contains("m="));
890        assert!(digest.contains("t="));
891        assert!(digest.contains("p="));
892        assert_eq!(parsed.algorithm.as_str(), "argon2id");
893        assert!(parsed.salt.is_some(), "digest should include a salt");
894        assert!(parsed.hash.is_some(), "digest should include a hash output");
895    }
896
897    #[test]
898    fn registering_a_custom_algorithm_allows_hashing_and_authentication() {
899        register_password_algorithm(
900            "reverse",
901            PasswordAlgorithm::new(
902                reverse_algorithm_hash,
903                reverse_algorithm_verify,
904                reverse_algorithm_matches,
905            ),
906        );
907
908        let mut user = User::default();
909        user.set_password_with_algorithm("password", "reverse")
910            .expect("password should hash");
911
912        assert_eq!(user.password_digest(), Some("reverse$drowssap"));
913        assert!(
914            user.authenticate("password")
915                .expect("authentication should succeed")
916        );
917        assert!(
918            !user
919                .authenticate("wrong")
920                .expect("authentication should succeed")
921        );
922    }
923
924    #[test]
925    fn unknown_password_algorithm_returns_a_hash_error() {
926        let mut user = User::default();
927
928        let error = user
929            .set_password_with_algorithm("password", "missing")
930            .expect_err("unknown algorithm should fail");
931
932        assert!(matches!(error, SecurePasswordError::HashError(_)));
933        assert!(error.to_string().contains("missing"));
934    }
935
936    #[test]
937    fn registered_password_algorithms_include_bcrypt_and_argon2() {
938        let algorithms = registered_password_algorithms();
939
940        assert!(algorithms.iter().any(|name| name == "bcrypt"));
941        assert!(algorithms.iter().any(|name| name == "argon2"));
942        assert_eq!(default_password_algorithm(), "bcrypt");
943    }
944
945    #[test]
946    fn authenticate_password_handles_optional_challenges() {
947        let mut user = User::default();
948        user.set_password("secret").expect("password should hash");
949
950        assert!(user.authenticate_password(Some("secret")));
951        assert!(!user.authenticate_password(Some("wrong")));
952        assert!(!user.authenticate_password(Some("")));
953        assert!(!user.authenticate_password(None));
954    }
955
956    #[test]
957    fn authenticate_password_returns_false_for_missing_or_malformed_digests() {
958        let missing = User::default();
959        let malformed = User {
960            digest: Some("not-a-valid-digest".to_owned()),
961        };
962        let blank = User {
963            digest: Some(String::new()),
964        };
965
966        assert!(!missing.authenticate_password(Some("secret")));
967        assert!(!malformed.authenticate_password(Some("secret")));
968        assert!(!blank.authenticate_password(Some("secret")));
969    }
970
971    #[test]
972    fn recovery_tokens_round_trip_and_are_invalidated_by_password_changes() {
973        let mut user = User::default();
974        user.set_password("secret").expect("password should hash");
975
976        let token = user
977            .generate_recovery_token()
978            .expect("recovery token should generate");
979
980        assert!(user.verify_recovery_token(&token));
981        assert!(!user.verify_recovery_token("not-a-token"));
982
983        user.set_password("new-secret")
984            .expect("replacement password should hash");
985        assert!(!user.verify_recovery_token(&token));
986    }
987}
988
989#[cfg(test)]
990mod rails_port_tests {
991    use super::{
992        PasswordAlgorithm, SecurePassword, SecurePasswordError, default_password_algorithm,
993        register_password_algorithm, registered_password_algorithms,
994    };
995
996    #[derive(Debug, Default)]
997    struct RailsUser {
998        digest: Option<String>,
999    }
1000
1001    impl SecurePassword for RailsUser {
1002        fn password_digest(&self) -> Option<&str> {
1003            self.digest.as_deref()
1004        }
1005
1006        fn set_password_digest(&mut self, digest: String) {
1007            self.digest = Some(digest);
1008        }
1009    }
1010
1011    fn reverse_algorithm_hash(password: &str) -> Result<String, SecurePasswordError> {
1012        Ok(format!(
1013            "reverse${}",
1014            password.chars().rev().collect::<String>()
1015        ))
1016    }
1017
1018    fn reverse_algorithm_verify(password: &str, digest: &str) -> Result<bool, SecurePasswordError> {
1019        Ok(digest == format!("reverse${}", password.chars().rev().collect::<String>()))
1020    }
1021
1022    fn reverse_algorithm_matches(digest: &str) -> bool {
1023        digest.starts_with("reverse$")
1024    }
1025
1026    macro_rules! rails_ignored_test {
1027        ($name:ident, $reason:literal) => {
1028            #[test]
1029            #[ignore = $reason]
1030            fn $name() {}
1031        };
1032    }
1033
1034    #[test]
1035    fn rails_create_new_user_with_validation_and_blank_password() {
1036        let mut user = RailsUser::default();
1037
1038        assert_eq!(user.set_password(""), Err(SecurePasswordError::Blank));
1039        assert!(!user.has_password());
1040    }
1041
1042    #[test]
1043    fn rails_create_new_user_with_validation_and_password_length_greater_than_72_characters() {
1044        let mut user = RailsUser::default();
1045
1046        assert_eq!(
1047            user.set_password(&"a".repeat(73)),
1048            Err(SecurePasswordError::TooLong { max: 72 }),
1049        );
1050        assert!(!user.has_password());
1051    }
1052
1053    #[test]
1054    fn rails_create_new_user_with_validation_and_password_byte_size_greater_than_72_bytes() {
1055        let mut user = RailsUser::default();
1056        let password = "あ".repeat(24) + "a";
1057
1058        assert_eq!(
1059            user.set_password(&password),
1060            Err(SecurePasswordError::TooLong { max: 72 }),
1061        );
1062        assert!(!user.has_password());
1063    }
1064
1065    #[test]
1066    fn rails_authenticate() {
1067        let mut user = RailsUser::default();
1068        user.set_password("secret").expect("password should hash");
1069
1070        assert!(!user.authenticate("wrong").expect("digest should parse"));
1071        assert!(user.authenticate("secret").expect("digest should parse"));
1072    }
1073
1074    #[test]
1075    fn rails_argon2_digest_is_generated_and_authenticate_works() {
1076        let mut user = RailsUser::default();
1077        user.set_password_with_algorithm("secret", "argon2")
1078            .expect("password should hash");
1079
1080        assert!(
1081            user.password_digest()
1082                .expect("digest should exist")
1083                .starts_with("$argon2")
1084        );
1085        assert!(user.authenticate("secret").expect("digest should parse"));
1086        assert!(!user.authenticate("wrong").expect("digest should parse"));
1087    }
1088
1089    #[test]
1090    fn rails_authentication_fails_when_password_digest_is_missing() {
1091        let user = RailsUser::default();
1092
1093        assert_eq!(
1094            user.authenticate("secret"),
1095            Err(SecurePasswordError::AuthenticationFailed),
1096        );
1097    }
1098
1099    #[test]
1100    fn rails_invalid_password_digest_reports_a_hash_error() {
1101        let user = RailsUser {
1102            digest: Some("not-a-valid-digest".to_owned()),
1103        };
1104
1105        let error = user
1106            .authenticate("secret")
1107            .expect_err("invalid digest should fail parsing");
1108
1109        assert!(matches!(error, SecurePasswordError::HashError(_)));
1110    }
1111
1112    #[test]
1113    fn rails_setting_a_new_password_replaces_the_existing_digest() {
1114        let mut user = RailsUser::default();
1115        user.set_password("first")
1116            .expect("first password should hash");
1117        let first_digest = user
1118            .password_digest()
1119            .expect("digest should exist")
1120            .to_owned();
1121
1122        user.set_password("second")
1123            .expect("second password should hash");
1124        let second_digest = user
1125            .password_digest()
1126            .expect("digest should exist")
1127            .to_owned();
1128
1129        assert_ne!(first_digest, second_digest);
1130        assert!(user.authenticate("second").expect("digest should parse"));
1131        assert!(!user.authenticate("first").expect("digest should parse"));
1132    }
1133
1134    #[test]
1135    fn rails_updating_existing_user_with_correct_password_challenge() {
1136        let mut user = RailsUser::default();
1137        user.set_password("secret").expect("password should hash");
1138
1139        assert!(user.authenticate_password(Some("secret")));
1140    }
1141
1142    #[test]
1143    fn rails_updating_existing_user_with_nil_password_challenge() {
1144        let mut user = RailsUser::default();
1145        user.set_password("secret").expect("password should hash");
1146
1147        assert!(!user.authenticate_password(None));
1148    }
1149
1150    #[test]
1151    fn rails_updating_existing_user_with_blank_password_challenge() {
1152        let mut user = RailsUser::default();
1153        user.set_password("secret").expect("password should hash");
1154
1155        assert!(!user.authenticate_password(Some("")));
1156    }
1157
1158    #[test]
1159    fn rails_updating_existing_user_with_incorrect_password_challenge() {
1160        let mut user = RailsUser::default();
1161        user.set_password("secret").expect("password should hash");
1162
1163        assert!(!user.authenticate_password(Some("wrong")));
1164    }
1165
1166    #[test]
1167    fn rails_updating_user_without_dirty_tracking_with_correct_password_challenge() {
1168        let mut user = RailsUser::default();
1169        user.set_password("secret").expect("password should hash");
1170
1171        assert!(user.authenticate_password(Some("secret")));
1172    }
1173
1174    #[test]
1175    fn rails_password_reset_token() {
1176        let mut user = RailsUser::default();
1177        user.set_password("secret").expect("password should hash");
1178
1179        let token = user
1180            .generate_recovery_token()
1181            .expect("recovery token should generate");
1182
1183        assert!(user.verify_recovery_token(&token));
1184    }
1185
1186    #[test]
1187    fn rails_password_algorithm_defaults_to_bcrypt() {
1188        let mut user = RailsUser::default();
1189        user.set_password("secret").expect("password should hash");
1190
1191        assert_eq!(default_password_algorithm(), "bcrypt");
1192        assert!(
1193            user.password_digest()
1194                .expect("digest should exist")
1195                .starts_with("$2")
1196        );
1197    }
1198
1199    #[test]
1200    fn rails_custom_password_algorithm_is_supported() {
1201        register_password_algorithm(
1202            "reverse",
1203            PasswordAlgorithm::new(
1204                reverse_algorithm_hash,
1205                reverse_algorithm_verify,
1206                reverse_algorithm_matches,
1207            ),
1208        );
1209
1210        let mut user = RailsUser::default();
1211        user.set_password_with_algorithm("secret", "reverse")
1212            .expect("password should hash");
1213
1214        assert_eq!(user.password_digest(), Some("reverse$terces"));
1215        assert!(user.authenticate("secret").expect("digest should parse"));
1216    }
1217
1218    #[test]
1219    fn rails_algorithm_can_be_registered_and_used_via_symbol() {
1220        register_password_algorithm(
1221            "reverse",
1222            PasswordAlgorithm::new(
1223                reverse_algorithm_hash,
1224                reverse_algorithm_verify,
1225                reverse_algorithm_matches,
1226            ),
1227        );
1228
1229        let mut user = RailsUser::default();
1230        user.set_password_with_algorithm("secret", "reverse")
1231            .expect("password should hash");
1232
1233        assert!(user.authenticate("secret").expect("digest should parse"));
1234    }
1235
1236    #[test]
1237    fn rails_raises_error_for_unknown_algorithm_symbol() {
1238        let mut user = RailsUser::default();
1239
1240        let error = user
1241            .set_password_with_algorithm("secret", "missing")
1242            .expect_err("unknown algorithm should fail");
1243
1244        assert!(matches!(error, SecurePasswordError::HashError(_)));
1245    }
1246
1247    #[test]
1248    fn rails_algorithm_registry_can_be_inspected() {
1249        let algorithms = registered_password_algorithms();
1250
1251        assert!(algorithms.iter().any(|name| name == "bcrypt"));
1252        assert!(algorithms.iter().any(|name| name == "argon2"));
1253    }
1254
1255    #[test]
1256    fn rails_argon2_algorithm_is_registered() {
1257        let algorithms = registered_password_algorithms();
1258        let mut user = RailsUser::default();
1259        user.set_password_with_algorithm("secret", "argon2")
1260            .expect("password should hash");
1261
1262        assert!(algorithms.iter().any(|name| name == "argon2"));
1263        assert!(
1264            user.password_digest()
1265                .expect("digest should exist")
1266                .starts_with("$argon2")
1267        );
1268    }
1269
1270    #[test]
1271    fn rails_create_new_user_with_spaces_only_password_without_confirmation_context() {
1272        let mut user = RailsUser::default();
1273        let password = " ".repeat(72);
1274
1275        user.set_password(&password)
1276            .expect("spaces-only password should hash");
1277
1278        assert!(user.authenticate(&password).expect("digest should parse"));
1279    }
1280
1281    #[test]
1282    fn rails_password_reset_token_is_invalidated_by_password_change() {
1283        let mut user = RailsUser::default();
1284        user.set_password("secret").expect("password should hash");
1285
1286        let token = user
1287            .generate_recovery_token()
1288            .expect("recovery token should generate");
1289
1290        assert!(user.verify_recovery_token(&token));
1291
1292        user.set_password("new secret")
1293            .expect("replacement password should hash");
1294
1295        assert!(!user.verify_recovery_token(&token));
1296    }
1297
1298    #[test]
1299    fn rails_algorithm_registry_can_be_inspected_after_registering_custom_algorithm() {
1300        register_password_algorithm(
1301            "reverse",
1302            PasswordAlgorithm::new(
1303                reverse_algorithm_hash,
1304                reverse_algorithm_verify,
1305                reverse_algorithm_matches,
1306            ),
1307        );
1308
1309        let algorithms = registered_password_algorithms();
1310
1311        assert!(algorithms.iter().any(|name| name == "bcrypt"));
1312        assert!(algorithms.iter().any(|name| name == "argon2"));
1313        assert!(algorithms.iter().any(|name| name == "reverse"));
1314    }
1315
1316    rails_ignored_test!(
1317        rails_automatically_include_activemodel_validations_when_validations_are_enabled,
1318        "SecurePassword is a trait, not a Rails validation mixin"
1319    );
1320    rails_ignored_test!(
1321        rails_dont_include_activemodel_validations_when_validations_are_disabled,
1322        "SecurePassword is a trait, not a Rails validation mixin"
1323    );
1324    #[test]
1325    fn rails_create_new_user_with_valid_password_confirmation() {
1326        let mut user = RailsUser::default();
1327
1328        assert_eq!(
1329            user.set_password_confirmed("password", Some("password")),
1330            Ok(())
1331        );
1332        assert!(user.authenticate("password").expect("digest should parse"));
1333    }
1334
1335    #[test]
1336    fn rails_create_new_user_with_spaces_only_password() {
1337        let mut user = RailsUser::default();
1338        let password = " ".repeat(72);
1339
1340        assert_eq!(
1341            user.set_password_confirmed(password.as_str(), Some(password.as_str())),
1342            Ok(())
1343        );
1344        assert!(user.authenticate(&password).expect("digest should parse"));
1345    }
1346
1347    rails_ignored_test!(
1348        rails_create_new_user_with_nil_password,
1349        "set_password accepts &str and has no nil concept"
1350    );
1351
1352    #[test]
1353    fn rails_create_new_user_with_blank_password_confirmation() {
1354        let mut user = RailsUser::default();
1355
1356        assert_eq!(
1357            user.set_password_confirmed("password", Some("")),
1358            Err(SecurePasswordError::ConfirmationMismatch)
1359        );
1360        assert!(!user.has_password());
1361    }
1362
1363    #[test]
1364    fn rails_create_new_user_with_nil_password_confirmation() {
1365        let mut user = RailsUser::default();
1366
1367        assert_eq!(user.set_password_confirmed("password", None), Ok(()));
1368        assert!(user.authenticate("password").expect("digest should parse"));
1369    }
1370
1371    #[test]
1372    fn rails_create_new_user_with_incorrect_password_confirmation() {
1373        let mut user = RailsUser::default();
1374
1375        assert_eq!(
1376            user.set_password_confirmed("password", Some("something else")),
1377            Err(SecurePasswordError::ConfirmationMismatch)
1378        );
1379        assert!(!user.has_password());
1380    }
1381
1382    #[test]
1383    fn rails_create_new_user_with_spaces_only_password_and_incorrect_password_confirmation() {
1384        let mut user = RailsUser::default();
1385
1386        assert_eq!(
1387            user.set_password_confirmed(" ", Some("something else")),
1388            Err(SecurePasswordError::ConfirmationMismatch)
1389        );
1390        assert!(!user.has_password());
1391    }
1392
1393    rails_ignored_test!(
1394        rails_resetting_password_to_nil_clears_the_password_cache,
1395        "the SecurePassword trait has no cached plain-text password field"
1396    );
1397
1398    #[test]
1399    fn rails_update_existing_user_with_validation_and_no_change_in_password() {
1400        let mut user = RailsUser::default();
1401        user.set_password("secret").expect("password should hash");
1402        let digest = user
1403            .password_digest()
1404            .expect("digest should exist")
1405            .to_owned();
1406
1407        assert_eq!(user.update_password(""), Ok(false));
1408        assert_eq!(user.password_digest(), Some(digest.as_str()));
1409        assert!(user.authenticate("secret").expect("digest should parse"));
1410    }
1411
1412    #[test]
1413    fn rails_update_existing_user_with_valid_password_confirmation() {
1414        let mut user = RailsUser::default();
1415        user.set_password("old secret")
1416            .expect("password should hash");
1417
1418        assert_eq!(
1419            user.update_password_confirmed("password", Some("password")),
1420            Ok(true)
1421        );
1422        assert!(user.authenticate("password").expect("digest should parse"));
1423        assert!(
1424            !user
1425                .authenticate("old secret")
1426                .expect("digest should parse")
1427        );
1428    }
1429
1430    #[test]
1431    fn rails_updating_existing_user_with_blank_password() {
1432        let mut user = RailsUser::default();
1433        user.set_password("secret").expect("password should hash");
1434        let digest = user
1435            .password_digest()
1436            .expect("digest should exist")
1437            .to_owned();
1438
1439        assert_eq!(user.update_password(""), Ok(false));
1440        assert_eq!(user.password_digest(), Some(digest.as_str()));
1441    }
1442
1443    #[test]
1444    fn rails_updating_existing_user_with_spaces_only_password() {
1445        let mut user = RailsUser::default();
1446        user.set_password("secret").expect("password should hash");
1447        let password = " ".repeat(72);
1448
1449        assert_eq!(user.update_password(&password), Ok(true));
1450        assert!(user.authenticate(&password).expect("digest should parse"));
1451        assert!(!user.authenticate("secret").expect("digest should parse"));
1452    }
1453
1454    #[test]
1455    fn rails_updating_existing_user_with_blank_password_and_password_confirmation() {
1456        let mut user = RailsUser::default();
1457        user.set_password("secret").expect("password should hash");
1458        let digest = user
1459            .password_digest()
1460            .expect("digest should exist")
1461            .to_owned();
1462
1463        assert_eq!(user.update_password_confirmed("", Some("")), Ok(false));
1464        assert_eq!(user.password_digest(), Some(digest.as_str()));
1465        assert!(user.authenticate("secret").expect("digest should parse"));
1466    }
1467
1468    rails_ignored_test!(
1469        rails_updating_existing_user_with_nil_password,
1470        "set_password accepts &str and has no nil concept"
1471    );
1472
1473    #[test]
1474    fn rails_updating_existing_user_with_blank_password_confirmation() {
1475        let mut user = RailsUser::default();
1476        user.set_password("secret").expect("password should hash");
1477        let digest = user
1478            .password_digest()
1479            .expect("digest should exist")
1480            .to_owned();
1481
1482        assert_eq!(
1483            user.update_password_confirmed("password", Some("")),
1484            Err(SecurePasswordError::ConfirmationMismatch)
1485        );
1486        assert_eq!(user.password_digest(), Some(digest.as_str()));
1487    }
1488
1489    #[test]
1490    fn rails_updating_existing_user_with_nil_password_confirmation() {
1491        let mut user = RailsUser::default();
1492        user.set_password("secret").expect("password should hash");
1493
1494        assert_eq!(user.update_password_confirmed("password", None), Ok(true));
1495        assert!(user.authenticate("password").expect("digest should parse"));
1496    }
1497
1498    #[test]
1499    fn rails_updating_existing_user_with_incorrect_password_confirmation() {
1500        let mut user = RailsUser::default();
1501        user.set_password("secret").expect("password should hash");
1502        let digest = user
1503            .password_digest()
1504            .expect("digest should exist")
1505            .to_owned();
1506
1507        assert_eq!(
1508            user.update_password_confirmed("password", Some("something else")),
1509            Err(SecurePasswordError::ConfirmationMismatch)
1510        );
1511        assert_eq!(user.password_digest(), Some(digest.as_str()));
1512        assert!(user.authenticate("secret").expect("digest should parse"));
1513    }
1514
1515    #[test]
1516    fn rails_updating_existing_user_with_spaces_only_password_and_incorrect_password_confirmation()
1517    {
1518        let mut user = RailsUser::default();
1519        user.set_password("secret").expect("password should hash");
1520        let digest = user
1521            .password_digest()
1522            .expect("digest should exist")
1523            .to_owned();
1524
1525        assert_eq!(
1526            user.update_password_confirmed(" ", Some("something else")),
1527            Err(SecurePasswordError::ConfirmationMismatch)
1528        );
1529        assert_eq!(user.password_digest(), Some(digest.as_str()));
1530        assert!(user.authenticate("secret").expect("digest should parse"));
1531    }
1532
1533    rails_ignored_test!(
1534        rails_updating_existing_user_with_blank_password_digest,
1535        "ActiveModel validation against blank persisted digests is outside SecurePassword scope"
1536    );
1537    rails_ignored_test!(
1538        rails_updating_existing_user_with_nil_password_digest,
1539        "ActiveModel validation against nil persisted digests is outside SecurePassword scope"
1540    );
1541
1542    #[test]
1543    fn rails_setting_a_blank_password_should_not_change_an_existing_password() {
1544        let mut user = RailsUser::default();
1545        user.set_password("secret").expect("password should hash");
1546        let digest = user
1547            .password_digest()
1548            .expect("digest should exist")
1549            .to_owned();
1550
1551        assert_eq!(user.update_password(""), Ok(false));
1552        assert_eq!(user.password_digest(), Some(digest.as_str()));
1553        assert!(user.authenticate("secret").expect("digest should parse"));
1554    }
1555
1556    rails_ignored_test!(
1557        rails_setting_a_nil_password_should_clear_an_existing_password,
1558        "set_password accepts &str and has no nil concept"
1559    );
1560    rails_ignored_test!(
1561        rails_override_secure_password_attribute,
1562        "per-attribute generated accessors are a Rails metaprogramming feature"
1563    );
1564
1565    #[test]
1566    fn rails_authenticate_should_return_false_and_not_raise_when_password_digest_is_blank() {
1567        let user = RailsUser {
1568            digest: Some(String::new()),
1569        };
1570
1571        assert_eq!(user.authenticate(" "), Ok(false));
1572    }
1573
1574    #[test]
1575    fn rails_password_salt() {
1576        let mut user = RailsUser::default();
1577        user.set_password("secret").expect("password should hash");
1578
1579        let digest = user.password_digest().expect("digest should exist");
1580        assert_eq!(user.password_salt(), Some(digest[..29].to_string()));
1581    }
1582
1583    rails_ignored_test!(
1584        rails_password_salt_should_return_nil_when_password_is_nil,
1585        "the SecurePassword trait has no cached plain-text password field"
1586    );
1587
1588    #[test]
1589    fn rails_password_salt_should_return_nil_when_password_digest_is_nil() {
1590        let user = RailsUser::default();
1591        assert_eq!(user.password_salt(), None);
1592    }
1593
1594    rails_ignored_test!(
1595        rails_password_digest_cost_defaults_to_bcrypt_default_cost_when_min_cost_is_false,
1596        "SecurePassword does not expose configurable bcrypt cost or min-cost test knobs"
1597    );
1598    rails_ignored_test!(
1599        rails_password_digest_cost_honors_bcrypt_cost_attribute_when_min_cost_is_false,
1600        "SecurePassword does not expose configurable bcrypt cost or min-cost test knobs"
1601    );
1602    rails_ignored_test!(
1603        rails_password_digest_cost_can_be_set_to_bcrypt_min_cost_to_speed_up_tests,
1604        "SecurePassword does not expose configurable bcrypt cost or min-cost test knobs"
1605    );
1606    rails_ignored_test!(
1607        rails_password_reset_token_duration,
1608        "recovery tokens do not yet encode or enforce expiration windows"
1609    );
1610
1611    #[test]
1612    fn rails_argon2_password_salt_extraction() {
1613        let mut user = RailsUser::default();
1614        user.set_password_with_algorithm("secret", "argon2")
1615            .expect("password should hash");
1616
1617        let digest = user.password_digest().expect("digest should exist");
1618        let parsed = argon2::PasswordHash::new(digest).expect("argon2 digest should parse");
1619
1620        assert_eq!(
1621            user.password_salt(),
1622            parsed.salt.map(|salt| salt.as_str().to_string())
1623        );
1624        assert!(user.password_salt().is_some());
1625    }
1626
1627    rails_ignored_test!(
1628        rails_argon2_allows_long_passwords,
1629        "rustrails-model keeps the Rails-compatible 72-byte limit even with Argon2"
1630    );
1631
1632    rails_ignored_test!(
1633        rails_authenticate_recovery_password_generated_helper,
1634        "the SecurePassword trait manages a single digest and does not generate per-attribute authenticate_* helpers"
1635    );
1636    rails_ignored_test!(
1637        rails_password_reset_token_accessor_is_not_generated_for_unrelated_models,
1638        "SecurePassword exposes recovery tokens through trait methods, not Rails-style generated model accessors"
1639    );
1640    rails_ignored_test!(
1641        rails_password_reset_token_generated_attribute_accessor,
1642        "SecurePassword exposes generate_recovery_token instead of a Rails-style password_reset_token accessor"
1643    );
1644    rails_ignored_test!(
1645        rails_find_by_password_reset_token_class_helper,
1646        "SecurePassword is a trait and does not generate Rails model class finder helpers"
1647    );
1648    rails_ignored_test!(
1649        rails_find_by_password_reset_token_bang_class_helper,
1650        "SecurePassword is a trait and does not generate Rails model class finder helpers"
1651    );
1652}