1use std::convert::TryFrom;
2
3use hashbrown::{HashMap, HashSet};
4use kanidm_proto::internal::{CredentialDetail, CredentialDetailType, OperationError};
5use time::OffsetDateTime;
6use uuid::Uuid;
7use webauthn_rs::prelude::{AuthenticationResult, Passkey, SecurityKey};
8use webauthn_rs_core::proto::{Credential as WebauthnCredential, CredentialV3};
9
10use crate::be::dbvalue::{DbBackupCodeV1, DbCred};
11
12pub mod apppwd;
13pub mod softlock;
14pub mod totp;
15
16use self::totp::TOTP_DEFAULT_STEP;
17
18use kanidm_lib_crypto::CryptoPolicy;
19
20use crate::credential::softlock::CredSoftLockPolicy;
21use crate::credential::totp::Totp;
22
23pub use kanidm_lib_crypto::Password;
35
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct BackupCodes {
38 code_set: HashSet<String>,
39}
40
41impl TryFrom<DbBackupCodeV1> for BackupCodes {
42 type Error = ();
43
44 fn try_from(value: DbBackupCodeV1) -> Result<Self, Self::Error> {
45 Ok(BackupCodes {
46 code_set: value.code_set,
47 })
48 }
49}
50
51impl BackupCodes {
52 pub fn new(code_set: HashSet<String>) -> Self {
53 BackupCodes { code_set }
54 }
55
56 pub fn verify(&self, code_chal: &str) -> bool {
57 self.code_set.contains(code_chal)
58 }
59
60 pub fn remove(&mut self, code_chal: &str) -> bool {
61 self.code_set.remove(code_chal)
62 }
63
64 pub fn to_dbbackupcodev1(&self) -> DbBackupCodeV1 {
65 DbBackupCodeV1 {
66 code_set: self.code_set.clone(),
67 }
68 }
69}
70
71#[derive(Clone, Debug, PartialEq)]
72pub struct Credential {
84 pub(crate) type_: CredentialType,
86 pub(crate) uuid: Uuid,
89 timestamp: OffsetDateTime,
92}
93
94#[derive(Clone, Debug, PartialEq)]
95pub enum CredentialType {
100 Password(Password),
102 GeneratedPassword(Password),
103 PasswordMfa(
104 Password,
105 HashMap<String, Totp>,
106 HashMap<String, SecurityKey>,
107 Option<BackupCodes>,
108 ),
109 Webauthn(HashMap<String, Passkey>),
110}
111
112impl From<&Credential> for CredentialDetail {
113 fn from(value: &Credential) -> Self {
114 CredentialDetail {
115 uuid: value.uuid,
116 type_: match &value.type_ {
117 CredentialType::Password(_) => CredentialDetailType::Password,
118 CredentialType::GeneratedPassword(_) => CredentialDetailType::GeneratedPassword,
119 CredentialType::Webauthn(wan) => {
120 let labels: Vec<_> = wan.keys().cloned().collect();
121 CredentialDetailType::Passkey(labels)
122 }
123 CredentialType::PasswordMfa(_, totp, wan, backup_code) => {
124 let wan_labels: Vec<_> = wan.keys().cloned().collect();
127 let totp_labels: Vec<_> = totp.keys().cloned().collect();
128
129 CredentialDetailType::PasswordMfa(
130 totp_labels,
131 wan_labels,
132 backup_code.as_ref().map(|c| c.code_set.len()).unwrap_or(0),
133 )
134 }
135 },
136 }
137 }
138}
139
140impl TryFrom<DbCred> for Credential {
141 type Error = ();
142
143 fn try_from(value: DbCred) -> Result<Self, Self::Error> {
144 let timestamp = value.last_changed_timestamp();
147
148 match value {
150 DbCred::V2Password {
151 password: db_password,
152 uuid,
153 ..
154 }
155 | DbCred::Pw {
156 password: Some(db_password),
157 webauthn: _,
158 totp: _,
159 backup_code: _,
160 claims: _,
161 uuid,
162 } => {
163 let v_password = Password::try_from(db_password)?;
164 let type_ = CredentialType::Password(v_password);
165 if type_.is_valid() {
166 Ok(Credential {
167 type_,
168 uuid,
169 timestamp,
170 })
171 } else {
172 Err(())
173 }
174 }
175 DbCred::V2GenPassword {
176 password: db_password,
177 uuid,
178 ..
179 }
180 | DbCred::GPw {
181 password: Some(db_password),
182 webauthn: _,
183 totp: _,
184 backup_code: _,
185 claims: _,
186 uuid,
187 } => {
188 let v_password = Password::try_from(db_password)?;
189 let type_ = CredentialType::GeneratedPassword(v_password);
190 if type_.is_valid() {
191 Ok(Credential {
192 type_,
193 uuid,
194 timestamp,
195 })
196 } else {
197 Err(())
198 }
199 }
200 DbCred::PwMfa {
201 password: Some(db_password),
202 webauthn: maybe_db_webauthn,
203 totp,
204 backup_code,
205 claims: _,
206 uuid,
207 } => {
208 let v_password = Password::try_from(db_password)?;
209
210 let v_totp = match totp {
211 Some(dbt) => {
212 let l = "totp".to_string();
213 let t = Totp::try_from(dbt)?;
214 HashMap::from([(l, t)])
215 }
216 None => HashMap::default(),
217 };
218
219 let v_webauthn = match maybe_db_webauthn {
220 Some(db_webauthn) => db_webauthn
221 .into_iter()
222 .map(|wc| {
223 (
224 wc.label,
225 SecurityKey::from(WebauthnCredential::from(CredentialV3 {
226 cred_id: wc.id,
227 cred: wc.cred,
228 counter: wc.counter,
229 verified: wc.verified,
230 registration_policy: wc.registration_policy,
231 })),
232 )
233 })
234 .collect(),
235 None => Default::default(),
236 };
237
238 let v_backup_code = match backup_code {
239 Some(dbb) => Some(BackupCodes::try_from(dbb)?),
240 None => None,
241 };
242
243 let type_ =
244 CredentialType::PasswordMfa(v_password, v_totp, v_webauthn, v_backup_code);
245
246 if type_.is_valid() {
247 Ok(Credential {
248 type_,
249 uuid,
250 timestamp,
251 })
252 } else {
253 Err(())
254 }
255 }
256 DbCred::Wn {
257 password: _,
258 webauthn: Some(db_webauthn),
259 totp: _,
260 backup_code: _,
261 claims: _,
262 uuid,
263 } => {
264 let v_webauthn = db_webauthn
265 .into_iter()
266 .map(|wc| {
267 (
268 wc.label,
269 Passkey::from(WebauthnCredential::from(CredentialV3 {
270 cred_id: wc.id,
271 cred: wc.cred,
272 counter: wc.counter,
273 verified: wc.verified,
274 registration_policy: wc.registration_policy,
275 })),
276 )
277 })
278 .collect();
279
280 let type_ = CredentialType::Webauthn(v_webauthn);
281
282 if type_.is_valid() {
283 Ok(Credential {
284 type_,
285 uuid,
286 timestamp,
287 })
288 } else {
289 Err(())
290 }
291 }
292 DbCred::TmpWn {
293 webauthn: db_webauthn,
294 uuid,
295 } => {
296 let v_webauthn = db_webauthn.into_iter().collect();
297 let type_ = CredentialType::Webauthn(v_webauthn);
298
299 if type_.is_valid() {
300 Ok(Credential {
301 type_,
302 uuid,
303 timestamp,
304 })
305 } else {
306 Err(())
307 }
308 }
309 DbCred::V2PasswordMfa {
310 password: db_password,
311 totp: maybe_db_totp,
312 backup_code,
313 webauthn: db_webauthn,
314 uuid,
315 } => {
316 let v_password = Password::try_from(db_password)?;
317
318 let v_totp = match maybe_db_totp {
319 Some(dbt) => {
320 let l = "totp".to_string();
321 let t = Totp::try_from(dbt)?;
322 HashMap::from([(l, t)])
323 }
324 None => HashMap::default(),
325 };
326
327 let v_backup_code = match backup_code {
328 Some(dbb) => Some(BackupCodes::try_from(dbb)?),
329 None => None,
330 };
331
332 let v_webauthn = db_webauthn.into_iter().collect();
333
334 let type_ =
335 CredentialType::PasswordMfa(v_password, v_totp, v_webauthn, v_backup_code);
336
337 if type_.is_valid() {
338 Ok(Credential {
339 type_,
340 uuid,
341 timestamp,
342 })
343 } else {
344 Err(())
345 }
346 }
347 DbCred::V3PasswordMfa {
348 password: db_password,
349 totp: db_totp,
350 backup_code,
351 webauthn: db_webauthn,
352 uuid,
353 ..
354 } => {
355 let v_password = Password::try_from(db_password)?;
356
357 let v_totp = db_totp
358 .into_iter()
359 .map(|(l, dbt)| Totp::try_from(dbt).map(|t| (l, t)))
360 .collect::<Result<HashMap<_, _>, _>>()?;
361
362 let v_backup_code = match backup_code {
363 Some(dbb) => Some(BackupCodes::try_from(dbb)?),
364 None => None,
365 };
366
367 let v_webauthn = db_webauthn.into_iter().collect();
368
369 let type_ =
370 CredentialType::PasswordMfa(v_password, v_totp, v_webauthn, v_backup_code);
371
372 if type_.is_valid() {
373 Ok(Credential {
374 type_,
375 uuid,
376 timestamp,
377 })
378 } else {
379 Err(())
380 }
381 }
382 credential => {
383 error!("Database content may be corrupt - invalid credential state");
384 debug!(%credential);
385 debug!(?credential);
386 Err(())
387 }
388 }
389 }
390}
391
392impl Credential {
393 pub fn timestamp(&self) -> OffsetDateTime {
394 self.timestamp
395 }
396
397 pub fn new_password_only(
399 policy: &CryptoPolicy,
400 cleartext: &str,
401 timestamp: OffsetDateTime,
402 ) -> Result<Self, OperationError> {
403 Password::new(policy, cleartext)
404 .map_err(|e| {
405 error!(crypto_err = ?e);
406 OperationError::CryptographyError
407 })
408 .map(|password| Self::new_from_password(password, timestamp))
409 }
410
411 pub fn new_generatedpassword_only(
413 policy: &CryptoPolicy,
414 cleartext: &str,
415 timestamp: OffsetDateTime,
416 ) -> Result<Self, OperationError> {
417 Password::new(policy, cleartext)
418 .map_err(|e| {
419 error!(crypto_err = ?e);
420 OperationError::CryptographyError
421 })
422 .map(|password| Self::new_from_generatedpassword(password, timestamp))
423 }
424
425 pub fn set_password(
428 &self,
429 policy: &CryptoPolicy,
430 cleartext: &str,
431 timestamp: OffsetDateTime,
432 ) -> Result<Self, OperationError> {
433 Password::new(policy, cleartext)
434 .map_err(|e| {
435 error!(crypto_err = ?e);
436 OperationError::CryptographyError
437 })
438 .map(|pw| self.update_password(pw, timestamp))
439 }
440
441 pub fn upgrade_password(
442 &self,
443 policy: &CryptoPolicy,
444 cleartext: &str,
445 ) -> Result<Option<Self>, OperationError> {
446 let valid = self.password_ref().and_then(|pw| {
447 pw.verify(cleartext).map_err(|e| {
448 error!(crypto_err = ?e);
449 OperationError::CryptographyError
450 })
451 })?;
452
453 if valid {
454 let pw = Password::new(policy, cleartext).map_err(|e| {
455 error!(crypto_err = ?e);
456 OperationError::CryptographyError
457 })?;
458
459 let mut cred = self.update_password(pw, self.timestamp);
463 cred.uuid = self.uuid;
464
465 Ok(Some(cred))
466 } else {
467 Ok(None)
469 }
470 }
471
472 #[cfg(test)] pub fn append_securitykey(
477 &self,
478 label: String,
479 cred: SecurityKey,
480 ) -> Result<Self, OperationError> {
481 let type_ = match &self.type_ {
482 CredentialType::Password(pw) | CredentialType::GeneratedPassword(pw) => {
483 let mut wan = HashMap::new();
484 wan.insert(label, cred);
485 CredentialType::PasswordMfa(pw.clone(), HashMap::default(), wan, None)
486 }
487 CredentialType::PasswordMfa(pw, totp, map, backup_code) => {
488 let mut nmap = map.clone();
489 if nmap.insert(label.clone(), cred).is_some() {
490 return Err(OperationError::InvalidAttribute(format!(
491 "Webauthn label '{label:?}' already exists"
492 )));
493 }
494 CredentialType::PasswordMfa(pw.clone(), totp.clone(), nmap, backup_code.clone())
495 }
496 CredentialType::Webauthn(map) => CredentialType::Webauthn(map.clone()),
498 };
499
500 Ok(Credential {
502 type_,
503 uuid: Uuid::new_v4(),
505 timestamp: self.timestamp,
507 })
508 }
509
510 pub fn remove_securitykey(
512 &self,
513 label: &str,
514 timestamp: OffsetDateTime,
515 ) -> Result<Self, OperationError> {
516 let type_ = match &self.type_ {
517 CredentialType::Password(_)
518 | CredentialType::GeneratedPassword(_)
519 | CredentialType::Webauthn(_) => {
520 return Err(OperationError::InvalidAttribute(
521 "SecurityKey is not present on this credential".to_string(),
522 ));
523 }
524 CredentialType::PasswordMfa(pw, totp, map, backup_code) => {
525 let mut nmap = map.clone();
526 if nmap.remove(label).is_none() {
527 return Err(OperationError::InvalidAttribute(format!(
528 "Removing Webauthn token with label '{label:?}': does not exist"
529 )));
530 }
531 if nmap.is_empty() {
532 if !totp.is_empty() {
533 CredentialType::PasswordMfa(
534 pw.clone(),
535 totp.clone(),
536 nmap,
537 backup_code.clone(),
538 )
539 } else {
540 CredentialType::Password(pw.clone())
542 }
543 } else {
544 CredentialType::PasswordMfa(pw.clone(), totp.clone(), nmap, backup_code.clone())
545 }
546 }
547 };
548
549 Ok(Credential {
551 type_,
552 uuid: Uuid::new_v4(),
554 timestamp,
556 })
557 }
558
559 #[allow(clippy::ptr_arg)]
560 pub fn update_webauthn_properties(
563 &self,
564 auth_result: &AuthenticationResult,
565 ) -> Result<Option<Self>, OperationError> {
566 let type_ = match &self.type_ {
567 CredentialType::Password(_pw) | CredentialType::GeneratedPassword(_pw) => {
568 return Ok(None);
573 }
574 CredentialType::Webauthn(map) => {
575 let mut nmap = map.clone();
576 nmap.values_mut().for_each(|pk| {
577 pk.update_credential(auth_result);
578 });
579 CredentialType::Webauthn(nmap)
580 }
581 CredentialType::PasswordMfa(pw, totp, map, backup_code) => {
582 let mut nmap = map.clone();
583 nmap.values_mut().for_each(|sk| {
584 sk.update_credential(auth_result);
585 });
586 CredentialType::PasswordMfa(pw.clone(), totp.clone(), nmap, backup_code.clone())
587 }
588 };
589
590 Ok(Some(Credential {
591 type_,
592 uuid: Uuid::new_v4(),
594 timestamp: self.timestamp,
595 }))
596 }
597
598 pub fn securitykey_ref(&self) -> Result<&HashMap<String, SecurityKey>, OperationError> {
600 match &self.type_ {
601 CredentialType::Webauthn(_)
602 | CredentialType::Password(_)
603 | CredentialType::GeneratedPassword(_) => Err(OperationError::InvalidAccountState(
604 "non-webauthn cred type?".to_string(),
605 )),
606 CredentialType::PasswordMfa(_, _, map, _) => Ok(map),
607 }
608 }
609
610 pub fn passkey_ref(&self) -> Result<&HashMap<String, Passkey>, OperationError> {
611 match &self.type_ {
612 CredentialType::PasswordMfa(_, _, _, _)
613 | CredentialType::Password(_)
614 | CredentialType::GeneratedPassword(_) => Err(OperationError::InvalidAccountState(
615 "non-webauthn cred type?".to_string(),
616 )),
617 CredentialType::Webauthn(map) => Ok(map),
618 }
619 }
620
621 pub fn password_ref(&self) -> Result<&Password, OperationError> {
623 match &self.type_ {
624 CredentialType::Password(pw)
625 | CredentialType::GeneratedPassword(pw)
626 | CredentialType::PasswordMfa(pw, _, _, _) => Ok(pw),
627 CredentialType::Webauthn(_) => Err(OperationError::InvalidAccountState(
628 "non-password cred type?".to_string(),
629 )),
630 }
631 }
632
633 pub fn is_mfa(&self) -> bool {
634 match &self.type_ {
635 CredentialType::Password(_) | CredentialType::GeneratedPassword(_) => false,
636 CredentialType::PasswordMfa(..) | CredentialType::Webauthn(_) => true,
637 }
638 }
639
640 #[cfg(test)]
641 pub fn verify_password(&self, cleartext: &str) -> Result<bool, OperationError> {
642 self.password_ref().and_then(|pw| {
643 pw.verify(cleartext).map_err(|e| {
644 error!(crypto_err = ?e);
645 OperationError::CryptographyError
646 })
647 })
648 }
649
650 pub fn to_db_valuev1(&self) -> DbCred {
652 let uuid = self.uuid;
653 match &self.type_ {
654 CredentialType::Password(pw) => DbCred::V2Password {
655 password: pw.to_dbpasswordv1(),
656 uuid,
657 timestamp: self.timestamp,
658 },
659 CredentialType::GeneratedPassword(pw) => DbCred::V2GenPassword {
660 password: pw.to_dbpasswordv1(),
661 uuid,
662 timestamp: self.timestamp,
663 },
664 CredentialType::PasswordMfa(pw, totp, map, backup_code) => DbCred::V3PasswordMfa {
665 password: pw.to_dbpasswordv1(),
666 totp: totp
667 .iter()
668 .map(|(l, t)| (l.clone(), t.to_dbtotpv1()))
669 .collect(),
670 backup_code: backup_code.as_ref().map(|b| b.to_dbbackupcodev1()),
671 webauthn: map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
672 uuid,
673 timestamp: self.timestamp,
674 },
675 CredentialType::Webauthn(map) => DbCred::TmpWn {
676 webauthn: map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
677 uuid,
678 },
679 }
680 }
681
682 pub(crate) fn update_password(&self, pw: Password, timestamp: OffsetDateTime) -> Self {
683 let type_ = match &self.type_ {
684 CredentialType::Password(_) | CredentialType::GeneratedPassword(_) => {
685 CredentialType::Password(pw)
686 }
687 CredentialType::PasswordMfa(_, totp, wan, backup_code) => {
688 CredentialType::PasswordMfa(pw, totp.clone(), wan.clone(), backup_code.clone())
689 }
690 CredentialType::Webauthn(wan) => CredentialType::Webauthn(wan.clone()),
692 };
693 Credential {
694 type_,
695 uuid: Uuid::new_v4(),
697 timestamp,
699 }
700 }
701
702 pub(crate) fn append_totp(&self, label: String, totp: Totp, timestamp: OffsetDateTime) -> Self {
704 let type_ = match &self.type_ {
705 CredentialType::Password(pw) | CredentialType::GeneratedPassword(pw) => {
706 CredentialType::PasswordMfa(
707 pw.clone(),
708 HashMap::from([(label, totp)]),
709 HashMap::new(),
710 None,
711 )
712 }
713 CredentialType::PasswordMfa(pw, totps, wan, backup_code) => {
714 let mut totps = totps.clone();
715 let replaced = totps.insert(label, totp).is_none();
716 debug_assert!(replaced);
717
718 CredentialType::PasswordMfa(pw.clone(), totps, wan.clone(), backup_code.clone())
719 }
720 CredentialType::Webauthn(wan) => {
721 debug_assert!(false);
722 CredentialType::Webauthn(wan.clone())
723 }
724 };
725 Credential {
726 type_,
727 uuid: Uuid::new_v4(),
729 timestamp,
731 }
732 }
733
734 pub(crate) fn remove_totp(&self, label: &str, timestamp: OffsetDateTime) -> Self {
735 let type_ = match &self.type_ {
736 CredentialType::PasswordMfa(pw, totp, wan, backup_code) => {
737 let mut totp = totp.clone();
738 let removed = totp.remove(label).is_some();
739 debug_assert!(removed);
740
741 if wan.is_empty() && totp.is_empty() {
742 CredentialType::Password(pw.clone())
744 } else {
745 CredentialType::PasswordMfa(pw.clone(), totp, wan.clone(), backup_code.clone())
746 }
747 }
748 _ => self.type_.clone(),
749 };
750 Credential {
751 type_,
752 uuid: Uuid::new_v4(),
754 timestamp,
756 }
757 }
758
759 pub(crate) fn has_totp_by_name(&self, label: &str) -> bool {
760 match &self.type_ {
761 CredentialType::PasswordMfa(_, totp, _, _) => totp.contains_key(label),
762 _ => false,
763 }
764 }
765
766 pub(crate) fn new_from_generatedpassword(pw: Password, timestamp: OffsetDateTime) -> Self {
767 Credential {
768 type_: CredentialType::GeneratedPassword(pw),
769 uuid: Uuid::new_v4(),
770 timestamp,
771 }
772 }
773
774 pub(crate) fn new_from_password(pw: Password, timestamp: OffsetDateTime) -> Self {
775 Credential {
776 type_: CredentialType::Password(pw),
777 uuid: Uuid::new_v4(),
778 timestamp,
779 }
780 }
781
782 pub(crate) fn softlock_policy(&self) -> CredSoftLockPolicy {
783 match &self.type_ {
784 CredentialType::Password(_pw) | CredentialType::GeneratedPassword(_pw) => {
785 CredSoftLockPolicy::Password
786 }
787 CredentialType::PasswordMfa(_pw, totp, wan, _) => {
788 if !totp.is_empty() {
790 let min_step = totp
792 .iter()
793 .map(|(_, t)| t.step)
794 .min()
795 .unwrap_or(TOTP_DEFAULT_STEP);
796 CredSoftLockPolicy::Totp(min_step)
797 } else if !wan.is_empty() {
798 CredSoftLockPolicy::Webauthn
799 } else {
800 CredSoftLockPolicy::Password
801 }
802 }
803 CredentialType::Webauthn(_wan) => CredSoftLockPolicy::Webauthn,
804 }
805 }
806
807 pub(crate) fn update_backup_code(
808 &self,
809 backup_codes: BackupCodes,
810 timestamp: OffsetDateTime,
811 ) -> Result<Self, OperationError> {
812 match &self.type_ {
813 CredentialType::PasswordMfa(pw, totp, wan, _) => Ok(Credential {
814 type_: CredentialType::PasswordMfa(
815 pw.clone(),
816 totp.clone(),
817 wan.clone(),
818 Some(backup_codes),
819 ),
820 uuid: Uuid::new_v4(),
822 timestamp,
824 }),
825 _ => Err(OperationError::InvalidAccountState(
826 "Non-MFA credential type".to_string(),
827 )),
828 }
829 }
830
831 pub(crate) fn invalidate_backup_code(
832 self,
833 code_to_remove: &str,
834 ) -> Result<Self, OperationError> {
835 match self.type_ {
836 CredentialType::PasswordMfa(pw, totp, wan, opt_backup_codes) => {
837 match opt_backup_codes {
838 Some(mut backup_codes) => {
839 backup_codes.remove(code_to_remove);
840 Ok(Credential {
841 type_: CredentialType::PasswordMfa(pw, totp, wan, Some(backup_codes)),
842 uuid: self.uuid,
845 timestamp: self.timestamp,
846 })
847 }
848 _ => Err(OperationError::InvalidAccountState(
849 "backup code does not exist".to_string(),
850 )),
851 }
852 }
853 _ => Err(OperationError::InvalidAccountState(
854 "Non-MFA credential type".to_string(),
855 )),
856 }
857 }
858
859 pub(crate) fn remove_backup_code(
860 &self,
861 timestamp: OffsetDateTime,
862 ) -> Result<Self, OperationError> {
863 match &self.type_ {
864 CredentialType::PasswordMfa(pw, totp, wan, _) => Ok(Credential {
865 type_: CredentialType::PasswordMfa(pw.clone(), totp.clone(), wan.clone(), None),
866 uuid: Uuid::new_v4(),
868 timestamp,
870 }),
871 _ => Err(OperationError::InvalidAccountState(
872 "Non-MFA credential type".to_string(),
873 )),
874 }
875 }
876}
877
878impl CredentialType {
879 fn is_valid(&self) -> bool {
880 match self {
881 CredentialType::Password(_) | CredentialType::GeneratedPassword(_) => true,
882 CredentialType::PasswordMfa(_, m_totp, webauthn, _) => {
883 !m_totp.is_empty() || !webauthn.is_empty() }
885 CredentialType::Webauthn(webauthn) => !webauthn.is_empty(),
886 }
887 }
888}
889
890#[cfg(test)]
891mod tests {
892 use std::time::Duration;
893
894 use crate::credential::totp::{Totp, TOTP_DEFAULT_STEP};
895 use crate::credential::Credential;
896 use kanidm_lib_crypto::{CryptoPolicy, Password};
897 use time::OffsetDateTime;
898
899 #[test]
900 fn test_credential_timestamp_updated_on_totp_append() {
901 let pw = Password::new(&CryptoPolicy::minimum(), "test_password")
902 .expect("Failed to create password");
903 let original_cred = Credential::new_from_password(pw, OffsetDateTime::UNIX_EPOCH);
904 let original_timestamp = original_cred.timestamp;
905
906 let totp = Totp::generate_secure(TOTP_DEFAULT_STEP);
907 let updated_cred = original_cred.append_totp(
908 "test_totp".to_string(),
909 totp,
910 OffsetDateTime::UNIX_EPOCH + Duration::from_millis(10),
911 );
912
913 assert!(updated_cred.timestamp > original_timestamp);
915 assert_ne!(original_cred.uuid, updated_cred.uuid);
916 }
917
918 #[test]
919 fn test_credential_timestamp_updated_on_totp_remove() {
920 let pw = Password::new(&CryptoPolicy::minimum(), "test_password")
921 .expect("Failed to create password");
922 let cred = Credential::new_from_password(pw, OffsetDateTime::UNIX_EPOCH);
923
924 let totp = Totp::generate_secure(TOTP_DEFAULT_STEP);
925 let cred_with_totp = cred.append_totp(
926 "test_totp".to_string(),
927 totp,
928 OffsetDateTime::UNIX_EPOCH + Duration::from_millis(10),
929 );
930 let timestamp_after_append = cred_with_totp.timestamp;
931
932 let cred_removed = cred_with_totp.remove_totp(
933 "test_totp",
934 OffsetDateTime::UNIX_EPOCH + Duration::from_millis(20),
935 );
936
937 assert!(cred_removed.timestamp > timestamp_after_append);
939 assert_ne!(cred_with_totp.uuid, cred_removed.uuid);
940 }
941
942 #[test]
943 fn test_credential_timestamp_updated_on_password_change() {
944 let original_cred = Credential::new_password_only(
945 &CryptoPolicy::minimum(),
946 "original_password",
947 OffsetDateTime::UNIX_EPOCH,
948 )
949 .expect("Failed to create credential");
950 let original_timestamp = original_cred.timestamp;
951
952 let updated_cred = original_cred
953 .set_password(
954 &CryptoPolicy::minimum(),
955 "new_password",
956 OffsetDateTime::UNIX_EPOCH + Duration::from_millis(10),
957 )
958 .expect("Failed to update password");
959
960 assert!(updated_cred.timestamp > original_timestamp);
962 assert_ne!(original_cred.uuid, updated_cred.uuid);
963 }
964
965 #[test]
966 fn test_credential_timestamp_preserved_on_password_upgrade() {
967 let original_cred = Credential::new_password_only(
968 &CryptoPolicy::minimum(),
969 "test_password",
970 OffsetDateTime::UNIX_EPOCH,
971 )
972 .expect("Failed to create credential");
973 let original_timestamp = original_cred.timestamp;
974 let original_uuid = original_cred.uuid;
975
976 let maybe_upgraded = original_cred
979 .upgrade_password(&CryptoPolicy::minimum(), "test_password")
980 .expect("Failed to upgrade password");
981
982 if let Some(upgraded_cred) = maybe_upgraded {
983 assert_eq!(original_uuid, upgraded_cred.uuid);
985 assert!(upgraded_cred.timestamp >= original_timestamp);
987 }
988 }
989
990 #[test]
991 fn test_credential_timestamp_on_mfa_operations() {
992 use crate::credential::BackupCodes;
993 use hashbrown::HashSet;
994
995 let pw = Password::new(&CryptoPolicy::minimum(), "test_password")
996 .expect("Failed to create password");
997 let cred = Credential::new_from_password(pw, OffsetDateTime::UNIX_EPOCH);
998
999 let totp = Totp::generate_secure(TOTP_DEFAULT_STEP);
1001 let mfa_cred = cred.append_totp(
1002 "test_totp".to_string(),
1003 totp,
1004 OffsetDateTime::UNIX_EPOCH + Duration::from_millis(10),
1005 );
1006 let mfa_timestamp = mfa_cred.timestamp;
1007
1008 let backup_codes =
1010 BackupCodes::new(HashSet::from(["code1".to_string(), "code2".to_string()]));
1011 let cred_with_backup = mfa_cred
1012 .update_backup_code(
1013 backup_codes,
1014 OffsetDateTime::UNIX_EPOCH + Duration::from_millis(20),
1015 )
1016 .expect("Failed to add backup codes");
1017
1018 assert!(cred_with_backup.timestamp > mfa_timestamp);
1020 assert_ne!(mfa_cred.uuid, cred_with_backup.uuid);
1021
1022 let cred_removed_backup = cred_with_backup
1024 .remove_backup_code(OffsetDateTime::UNIX_EPOCH + Duration::from_millis(30))
1025 .expect("Failed to remove backup codes");
1026
1027 assert!(cred_removed_backup.timestamp > cred_with_backup.timestamp);
1029 assert_ne!(cred_with_backup.uuid, cred_removed_backup.uuid);
1030 }
1031}