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#[derive(Debug, thiserror::Error, PartialEq, Eq)]
24pub enum SecurePasswordError {
25 #[error("password is blank")]
27 Blank,
28 #[error("password is too long (maximum is {max} characters)")]
30 TooLong { max: usize },
31 #[error("password confirmation doesn't match")]
33 ConfirmationMismatch,
34 #[error("hashing error: {0}")]
36 HashError(String),
37 #[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(®istry)
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
235pub trait SecurePassword {
237 fn password_digest(&self) -> Option<&str>;
239
240 fn set_password_digest(&mut self, digest: String);
242
243 fn set_password(&mut self, password: &str) -> Result<(), SecurePasswordError> {
245 self.set_password_with_algorithm(password, DEFAULT_PASSWORD_ALGORITHM)
246 }
247
248 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 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 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 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 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 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 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 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 fn has_password(&self) -> bool {
371 self.password_digest().is_some()
372 }
373
374 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}