kanidmd_lib/credential/
mod.rs

1use std::convert::TryFrom;
2
3use hashbrown::{HashMap as Map, 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
23// These are in order of "relative" strength.
24/*
25#[derive(Clone, Debug)]
26pub enum Policy {
27    PasswordOnly,
28    WebauthnOnly,
29    GeneratedPassword,
30    PasswordAndWebauthn,
31}
32*/
33
34pub 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)]
72/// This is how we store credentials in the server. An account can have many credentials, and
73/// a credential can have many factors. Only successful auth to a credential as a whole unit
74/// will succeed. For example:
75/// A: Credential { password: aaa }
76/// B: Credential { password: bbb, otp: ... }
77/// In this case, if we selected credential B, and then provided password "aaa" we would deny
78/// the auth as the password of B was incorrect. Additionally, while A only needs the "password",
79/// B requires both the password and otp to be valid.
80///
81/// In this way, each Credential provides its own password requirements and policy, and requires
82/// some metadata to support this such as it's source and strength etc.
83pub struct Credential {
84    // policy: Policy,
85    pub(crate) type_: CredentialType,
86    // Uuid of Credential, used by auth session to lock this specific credential
87    // if required.
88    pub(crate) uuid: Uuid,
89    // TODO #59: Add auth policy IE validUntil, lock state ...
90    // locked: bool
91    timestamp: OffsetDateTime,
92}
93
94#[derive(Clone, Debug, PartialEq)]
95/// The type of credential that is stored. Each of these represents a full set of 'what is required'
96/// to complete an authentication session. The reason to have these typed like this is so we can
97/// apply policy later to what classes or levels of credentials can be used. We use these types
98/// to also know what type of auth session handler to initiate.
99pub enum CredentialType {
100    // Anonymous,
101    Password(Password),
102    GeneratedPassword(Password),
103    PasswordMfa(
104        Password,
105        Map<String, Totp>,
106        Map<String, SecurityKey>,
107        Option<BackupCodes>,
108    ),
109    Webauthn(Map<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                    // Don't sort - we need these in order to match to what the user
125                    // sees so they can remove by index.
126                    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        // We need to retrieve the timestamp here since not all DbCreds have one.
145        // All V1 creds will fall back to a default
146        let timestamp = value.last_changed_timestamp();
147
148        // Work out what the policy is?
149        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                        Map::from([(l, t)])
215                    }
216                    None => Map::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                        Map::from([(l, t)])
323                    }
324                    None => Map::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<Map<_, _>, _>>()?;
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    /// Create a new credential that contains a CredentialType::Password
398    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    /// Create a new credential that contains a CredentialType::GeneratedPassword
412    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    /// Update the state of the Password on this credential, if a password is present. If possible
426    /// this will convert the credential to a PasswordMFA in some cases, or fail in others.
427    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            // Note, during update_password we normally rotate the uuid, here we
460            // set it back to our current value. This is because we are just
461            // updating the hash value, not actually changing the password itself.
462            let mut cred = self.update_password(pw, self.timestamp);
463            cred.uuid = self.uuid;
464
465            Ok(Some(cred))
466        } else {
467            // No updates needed, password has changed.
468            Ok(None)
469        }
470    }
471
472    /// Extend this credential with another alternate webauthn credential. This is especially
473    /// useful for `PasswordMfa` where you can have many webauthn credentials and a password
474    /// generally so that one is a backup.
475    #[cfg(test)] // This method isn't used outside of tests, Should we keep the cfg?
476    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 = Map::new();
484                wan.insert(label, cred);
485                CredentialType::PasswordMfa(pw.clone(), Map::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            // Ignore
497            CredentialType::Webauthn(map) => CredentialType::Webauthn(map.clone()),
498        };
499
500        // Check stuff
501        Ok(Credential {
502            type_,
503            // Rotate the credential id on any change to invalidate sessions.
504            uuid: Uuid::new_v4(),
505            // Update the timestamp to signify a changed credential
506            timestamp: self.timestamp,
507        })
508    }
509
510    /// Remove a webauthn token identified by `label` from this Credential.
511    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                        // Note: No need to keep backup code if it is no longer MFA
541                        CredentialType::Password(pw.clone())
542                    }
543                } else {
544                    CredentialType::PasswordMfa(pw.clone(), totp.clone(), nmap, backup_code.clone())
545                }
546            }
547        };
548
549        // Check stuff
550        Ok(Credential {
551            type_,
552            // Rotate the credential id on any change to invalidate sessions.
553            uuid: Uuid::new_v4(),
554            // Update the timestamp to signify a changed credential
555            timestamp,
556        })
557    }
558
559    #[allow(clippy::ptr_arg)]
560    /// After a successful authentication with Webauthn, we need to advance the credentials
561    /// counter value to prevent certain classes of replay attacks.
562    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                // Should not be possible!
569                // -- this does occur when we have mixed pw/passkey
570                // and we need to do an update, so we just mask this no Ok(None).
571                // return Err(OperationError::InvalidState);
572                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            // Rotate the credential id on any change to invalidate sessions.
593            uuid: Uuid::new_v4(),
594            timestamp: self.timestamp,
595        }))
596    }
597
598    pub(crate) fn has_securitykey(&self) -> bool {
599        match &self.type_ {
600            CredentialType::PasswordMfa(_, _, map, _) => !map.is_empty(),
601            _ => false,
602        }
603    }
604
605    /// Get a reference to the contained webuthn credentials, if any.
606    pub fn securitykey_ref(&self) -> Result<&Map<String, SecurityKey>, OperationError> {
607        match &self.type_ {
608            CredentialType::Webauthn(_)
609            | CredentialType::Password(_)
610            | CredentialType::GeneratedPassword(_) => Err(OperationError::InvalidAccountState(
611                "non-webauthn cred type?".to_string(),
612            )),
613            CredentialType::PasswordMfa(_, _, map, _) => Ok(map),
614        }
615    }
616
617    pub fn passkey_ref(&self) -> Result<&Map<String, Passkey>, OperationError> {
618        match &self.type_ {
619            CredentialType::PasswordMfa(_, _, _, _)
620            | CredentialType::Password(_)
621            | CredentialType::GeneratedPassword(_) => Err(OperationError::InvalidAccountState(
622                "non-webauthn cred type?".to_string(),
623            )),
624            CredentialType::Webauthn(map) => Ok(map),
625        }
626    }
627
628    /// Get a reference to the contained password, if any.
629    pub fn password_ref(&self) -> Result<&Password, OperationError> {
630        match &self.type_ {
631            CredentialType::Password(pw)
632            | CredentialType::GeneratedPassword(pw)
633            | CredentialType::PasswordMfa(pw, _, _, _) => Ok(pw),
634            CredentialType::Webauthn(_) => Err(OperationError::InvalidAccountState(
635                "non-password cred type?".to_string(),
636            )),
637        }
638    }
639
640    pub fn is_mfa(&self) -> bool {
641        match &self.type_ {
642            CredentialType::Password(_) | CredentialType::GeneratedPassword(_) => false,
643            CredentialType::PasswordMfa(..) | CredentialType::Webauthn(_) => true,
644        }
645    }
646
647    #[cfg(test)]
648    pub fn verify_password(&self, cleartext: &str) -> Result<bool, OperationError> {
649        self.password_ref().and_then(|pw| {
650            pw.verify(cleartext).map_err(|e| {
651                error!(crypto_err = ?e);
652                OperationError::CryptographyError
653            })
654        })
655    }
656
657    /// Extract this credential into it's Serialisable Database form, ready for persistence.
658    pub fn to_db_valuev1(&self) -> DbCred {
659        let uuid = self.uuid;
660        match &self.type_ {
661            CredentialType::Password(pw) => DbCred::V2Password {
662                password: pw.to_dbpasswordv1(),
663                uuid,
664                timestamp: self.timestamp,
665            },
666            CredentialType::GeneratedPassword(pw) => DbCred::V2GenPassword {
667                password: pw.to_dbpasswordv1(),
668                uuid,
669                timestamp: self.timestamp,
670            },
671            CredentialType::PasswordMfa(pw, totp, map, backup_code) => DbCred::V3PasswordMfa {
672                password: pw.to_dbpasswordv1(),
673                totp: totp
674                    .iter()
675                    .map(|(l, t)| (l.clone(), t.to_dbtotpv1()))
676                    .collect(),
677                backup_code: backup_code.as_ref().map(|b| b.to_dbbackupcodev1()),
678                webauthn: map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
679                uuid,
680                timestamp: self.timestamp,
681            },
682            CredentialType::Webauthn(map) => DbCred::TmpWn {
683                webauthn: map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
684                uuid,
685            },
686        }
687    }
688
689    pub(crate) fn update_password(&self, pw: Password, timestamp: OffsetDateTime) -> Self {
690        let type_ = match &self.type_ {
691            CredentialType::Password(_) | CredentialType::GeneratedPassword(_) => {
692                CredentialType::Password(pw)
693            }
694            CredentialType::PasswordMfa(_, totp, wan, backup_code) => {
695                CredentialType::PasswordMfa(pw, totp.clone(), wan.clone(), backup_code.clone())
696            }
697            // Ignore
698            CredentialType::Webauthn(wan) => CredentialType::Webauthn(wan.clone()),
699        };
700        Credential {
701            type_,
702            // Rotate the credential id on any change to invalidate sessions.
703            uuid: Uuid::new_v4(),
704            // Update the timestamp to signify a changed credential
705            timestamp,
706        }
707    }
708
709    // We don't make totp accessible from outside the crate for now.
710    pub(crate) fn append_totp(&self, label: String, totp: Totp, timestamp: OffsetDateTime) -> Self {
711        let type_ = match &self.type_ {
712            CredentialType::Password(pw) | CredentialType::GeneratedPassword(pw) => {
713                CredentialType::PasswordMfa(
714                    pw.clone(),
715                    Map::from([(label, totp)]),
716                    Map::new(),
717                    None,
718                )
719            }
720            CredentialType::PasswordMfa(pw, totps, wan, backup_code) => {
721                let mut totps = totps.clone();
722                let replaced = totps.insert(label, totp).is_none();
723                debug_assert!(replaced);
724
725                CredentialType::PasswordMfa(pw.clone(), totps, wan.clone(), backup_code.clone())
726            }
727            CredentialType::Webauthn(wan) => {
728                debug_assert!(false);
729                CredentialType::Webauthn(wan.clone())
730            }
731        };
732        Credential {
733            type_,
734            // Rotate the credential id on any change to invalidate sessions.
735            uuid: Uuid::new_v4(),
736            // Update the timestamp to signify a changed credential
737            timestamp,
738        }
739    }
740
741    pub(crate) fn remove_totp(&self, label: &str, timestamp: OffsetDateTime) -> Self {
742        let type_ = match &self.type_ {
743            CredentialType::PasswordMfa(pw, totp, wan, backup_code) => {
744                let mut totp = totp.clone();
745                let removed = totp.remove(label).is_some();
746                debug_assert!(removed);
747
748                if wan.is_empty() && totp.is_empty() {
749                    // Note: No need to keep backup code if it is no longer MFA
750                    CredentialType::Password(pw.clone())
751                } else {
752                    CredentialType::PasswordMfa(pw.clone(), totp, wan.clone(), backup_code.clone())
753                }
754            }
755            _ => self.type_.clone(),
756        };
757        Credential {
758            type_,
759            // Rotate the credential id on any change to invalidate sessions.
760            uuid: Uuid::new_v4(),
761            // Update the timestamp to signify a changed credential
762            timestamp,
763        }
764    }
765
766    pub(crate) fn has_totp_by_name(&self, label: &str) -> bool {
767        match &self.type_ {
768            CredentialType::PasswordMfa(_, totp, _, _) => totp.contains_key(label),
769            _ => false,
770        }
771    }
772
773    pub(crate) fn new_from_generatedpassword(pw: Password, timestamp: OffsetDateTime) -> Self {
774        Credential {
775            type_: CredentialType::GeneratedPassword(pw),
776            uuid: Uuid::new_v4(),
777            timestamp,
778        }
779    }
780
781    pub(crate) fn new_from_password(pw: Password, timestamp: OffsetDateTime) -> Self {
782        Credential {
783            type_: CredentialType::Password(pw),
784            uuid: Uuid::new_v4(),
785            timestamp,
786        }
787    }
788
789    pub(crate) fn softlock_policy(&self) -> CredSoftLockPolicy {
790        match &self.type_ {
791            CredentialType::Password(_pw) | CredentialType::GeneratedPassword(_pw) => {
792                CredSoftLockPolicy::Password
793            }
794            CredentialType::PasswordMfa(_pw, totp, wan, _) => {
795                // For backup code, use totp/wan policy (whatever is available)
796                if !totp.is_empty() {
797                    // What's the min step?
798                    let min_step = totp
799                        .iter()
800                        .map(|(_, t)| t.step)
801                        .min()
802                        .unwrap_or(TOTP_DEFAULT_STEP);
803                    CredSoftLockPolicy::Totp(min_step)
804                } else if !wan.is_empty() {
805                    CredSoftLockPolicy::Webauthn
806                } else {
807                    CredSoftLockPolicy::Password
808                }
809            }
810            CredentialType::Webauthn(_wan) => CredSoftLockPolicy::Webauthn,
811        }
812    }
813
814    pub(crate) fn update_backup_code(
815        &self,
816        backup_codes: BackupCodes,
817        timestamp: OffsetDateTime,
818    ) -> Result<Self, OperationError> {
819        match &self.type_ {
820            CredentialType::PasswordMfa(pw, totp, wan, _) => Ok(Credential {
821                type_: CredentialType::PasswordMfa(
822                    pw.clone(),
823                    totp.clone(),
824                    wan.clone(),
825                    Some(backup_codes),
826                ),
827                // Rotate the credential id on any change to invalidate sessions.
828                uuid: Uuid::new_v4(),
829                // Update the timestamp to signify a changed credential
830                timestamp,
831            }),
832            _ => Err(OperationError::InvalidAccountState(
833                "Non-MFA credential type".to_string(),
834            )),
835        }
836    }
837
838    pub(crate) fn invalidate_backup_code(
839        self,
840        code_to_remove: &str,
841    ) -> Result<Self, OperationError> {
842        match self.type_ {
843            CredentialType::PasswordMfa(pw, totp, wan, opt_backup_codes) => {
844                match opt_backup_codes {
845                    Some(mut backup_codes) => {
846                        backup_codes.remove(code_to_remove);
847                        Ok(Credential {
848                            type_: CredentialType::PasswordMfa(pw, totp, wan, Some(backup_codes)),
849                            // Don't rotate uuid here since this is a consumption of a backup
850                            // code.
851                            uuid: self.uuid,
852                            timestamp: self.timestamp,
853                        })
854                    }
855                    _ => Err(OperationError::InvalidAccountState(
856                        "backup code does not exist".to_string(),
857                    )),
858                }
859            }
860            _ => Err(OperationError::InvalidAccountState(
861                "Non-MFA credential type".to_string(),
862            )),
863        }
864    }
865
866    pub(crate) fn remove_backup_code(
867        &self,
868        timestamp: OffsetDateTime,
869    ) -> Result<Self, OperationError> {
870        match &self.type_ {
871            CredentialType::PasswordMfa(pw, totp, wan, _) => Ok(Credential {
872                type_: CredentialType::PasswordMfa(pw.clone(), totp.clone(), wan.clone(), None),
873                // Rotate the credential id on any change to invalidate sessions.
874                uuid: Uuid::new_v4(),
875                // Update the timestamp to signify a changed credential
876                timestamp,
877            }),
878            _ => Err(OperationError::InvalidAccountState(
879                "Non-MFA credential type".to_string(),
880            )),
881        }
882    }
883}
884
885impl CredentialType {
886    fn is_valid(&self) -> bool {
887        match self {
888            CredentialType::Password(_) | CredentialType::GeneratedPassword(_) => true,
889            CredentialType::PasswordMfa(_, m_totp, webauthn, _) => {
890                !m_totp.is_empty() || !webauthn.is_empty() // ignore backup code (it should only be a complement for totp/webauth)
891            }
892            CredentialType::Webauthn(webauthn) => !webauthn.is_empty(),
893        }
894    }
895}
896
897#[cfg(test)]
898mod tests {
899    use std::time::Duration;
900
901    use crate::credential::totp::{Totp, TOTP_DEFAULT_STEP};
902    use crate::credential::Credential;
903    use kanidm_lib_crypto::{CryptoPolicy, Password};
904    use time::OffsetDateTime;
905
906    #[test]
907    fn test_credential_timestamp_updated_on_totp_append() {
908        let pw = Password::new(&CryptoPolicy::minimum(), "test_password")
909            .expect("Failed to create password");
910        let original_cred = Credential::new_from_password(pw, OffsetDateTime::UNIX_EPOCH);
911        let original_timestamp = original_cred.timestamp;
912
913        let totp = Totp::generate_secure(TOTP_DEFAULT_STEP);
914        let updated_cred = original_cred.append_totp(
915            "test_totp".to_string(),
916            totp,
917            OffsetDateTime::UNIX_EPOCH + Duration::from_millis(10),
918        );
919
920        // Verify timestamp was updated
921        assert!(updated_cred.timestamp > original_timestamp);
922        assert_ne!(original_cred.uuid, updated_cred.uuid);
923    }
924
925    #[test]
926    fn test_credential_timestamp_updated_on_totp_remove() {
927        let pw = Password::new(&CryptoPolicy::minimum(), "test_password")
928            .expect("Failed to create password");
929        let cred = Credential::new_from_password(pw, OffsetDateTime::UNIX_EPOCH);
930
931        let totp = Totp::generate_secure(TOTP_DEFAULT_STEP);
932        let cred_with_totp = cred.append_totp(
933            "test_totp".to_string(),
934            totp,
935            OffsetDateTime::UNIX_EPOCH + Duration::from_millis(10),
936        );
937        let timestamp_after_append = cred_with_totp.timestamp;
938
939        let cred_removed = cred_with_totp.remove_totp(
940            "test_totp",
941            OffsetDateTime::UNIX_EPOCH + Duration::from_millis(20),
942        );
943
944        // Verify timestamp was updated
945        assert!(cred_removed.timestamp > timestamp_after_append);
946        assert_ne!(cred_with_totp.uuid, cred_removed.uuid);
947    }
948
949    #[test]
950    fn test_credential_timestamp_updated_on_password_change() {
951        let original_cred = Credential::new_password_only(
952            &CryptoPolicy::minimum(),
953            "original_password",
954            OffsetDateTime::UNIX_EPOCH,
955        )
956        .expect("Failed to create credential");
957        let original_timestamp = original_cred.timestamp;
958
959        let updated_cred = original_cred
960            .set_password(
961                &CryptoPolicy::minimum(),
962                "new_password",
963                OffsetDateTime::UNIX_EPOCH + Duration::from_millis(10),
964            )
965            .expect("Failed to update password");
966
967        // Verify timestamp was updated
968        assert!(updated_cred.timestamp > original_timestamp);
969        assert_ne!(original_cred.uuid, updated_cred.uuid);
970    }
971
972    #[test]
973    fn test_credential_timestamp_preserved_on_password_upgrade() {
974        let original_cred = Credential::new_password_only(
975            &CryptoPolicy::minimum(),
976            "test_password",
977            OffsetDateTime::UNIX_EPOCH,
978        )
979        .expect("Failed to create credential");
980        let original_timestamp = original_cred.timestamp;
981        let original_uuid = original_cred.uuid;
982
983        // Password upgrade should preserve UUID and timestamp since it's just
984        // updating the hash algorithm, not actually changing the password
985        let maybe_upgraded = original_cred
986            .upgrade_password(&CryptoPolicy::minimum(), "test_password")
987            .expect("Failed to upgrade password");
988
989        if let Some(upgraded_cred) = maybe_upgraded {
990            // UUID should be preserved during upgrade (unlike other operations)
991            assert_eq!(original_uuid, upgraded_cred.uuid);
992            // Timestamp should be updated to reflect the upgrade
993            assert!(upgraded_cred.timestamp >= original_timestamp);
994        }
995    }
996
997    #[test]
998    fn test_credential_timestamp_on_mfa_operations() {
999        use crate::credential::BackupCodes;
1000        use hashbrown::HashSet;
1001
1002        let pw = Password::new(&CryptoPolicy::minimum(), "test_password")
1003            .expect("Failed to create password");
1004        let cred = Credential::new_from_password(pw, OffsetDateTime::UNIX_EPOCH);
1005
1006        // Add TOTP to make it MFA
1007        let totp = Totp::generate_secure(TOTP_DEFAULT_STEP);
1008        let mfa_cred = cred.append_totp(
1009            "test_totp".to_string(),
1010            totp,
1011            OffsetDateTime::UNIX_EPOCH + Duration::from_millis(10),
1012        );
1013        let mfa_timestamp = mfa_cred.timestamp;
1014
1015        // Add backup codes
1016        let backup_codes =
1017            BackupCodes::new(HashSet::from(["code1".to_string(), "code2".to_string()]));
1018        let cred_with_backup = mfa_cred
1019            .update_backup_code(
1020                backup_codes,
1021                OffsetDateTime::UNIX_EPOCH + Duration::from_millis(20),
1022            )
1023            .expect("Failed to add backup codes");
1024
1025        // Verify timestamp was updated
1026        assert!(cred_with_backup.timestamp > mfa_timestamp);
1027        assert_ne!(mfa_cred.uuid, cred_with_backup.uuid);
1028
1029        // Remove backup codes
1030        let cred_removed_backup = cred_with_backup
1031            .remove_backup_code(OffsetDateTime::UNIX_EPOCH + Duration::from_millis(30))
1032            .expect("Failed to remove backup codes");
1033
1034        // Verify timestamp was updated again
1035        assert!(cred_removed_backup.timestamp > cred_with_backup.timestamp);
1036        assert_ne!(cred_with_backup.uuid, cred_removed_backup.uuid);
1037    }
1038}