Skip to main content

kanidmd_lib/credential/
mod.rs

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
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        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                    // 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                        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    /// 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 = 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            // 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    /// Get a reference to the contained webuthn credentials, if any.
599    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    /// Get a reference to the contained password, if any.
622    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    /// Extract this credential into it's Serialisable Database form, ready for persistence.
651    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            // Ignore
691            CredentialType::Webauthn(wan) => CredentialType::Webauthn(wan.clone()),
692        };
693        Credential {
694            type_,
695            // Rotate the credential id on any change to invalidate sessions.
696            uuid: Uuid::new_v4(),
697            // Update the timestamp to signify a changed credential
698            timestamp,
699        }
700    }
701
702    // We don't make totp accessible from outside the crate for now.
703    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            // Rotate the credential id on any change to invalidate sessions.
728            uuid: Uuid::new_v4(),
729            // Update the timestamp to signify a changed credential
730            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                    // Note: No need to keep backup code if it is no longer MFA
743                    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            // Rotate the credential id on any change to invalidate sessions.
753            uuid: Uuid::new_v4(),
754            // Update the timestamp to signify a changed credential
755            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                // For backup code, use totp/wan policy (whatever is available)
789                if !totp.is_empty() {
790                    // What's the min step?
791                    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                // Rotate the credential id on any change to invalidate sessions.
821                uuid: Uuid::new_v4(),
822                // Update the timestamp to signify a changed credential
823                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                            // Don't rotate uuid here since this is a consumption of a backup
843                            // code.
844                            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                // Rotate the credential id on any change to invalidate sessions.
867                uuid: Uuid::new_v4(),
868                // Update the timestamp to signify a changed credential
869                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() // ignore backup code (it should only be a complement for totp/webauth)
884            }
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        // Verify timestamp was updated
914        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        // Verify timestamp was updated
938        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        // Verify timestamp was updated
961        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        // Password upgrade should preserve UUID and timestamp since it's just
977        // updating the hash algorithm, not actually changing the password
978        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            // UUID should be preserved during upgrade (unlike other operations)
984            assert_eq!(original_uuid, upgraded_cred.uuid);
985            // Timestamp should be updated to reflect the upgrade
986            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        // Add TOTP to make it MFA
1000        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        // Add backup codes
1009        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        // Verify timestamp was updated
1019        assert!(cred_with_backup.timestamp > mfa_timestamp);
1020        assert_ne!(mfa_cred.uuid, cred_with_backup.uuid);
1021
1022        // Remove backup codes
1023        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        // Verify timestamp was updated again
1028        assert!(cred_removed_backup.timestamp > cred_with_backup.timestamp);
1029        assert_ne!(cred_with_backup.uuid, cred_removed_backup.uuid);
1030    }
1031}