]> git.proxmox.com Git - proxmox-backup.git/blobdiff - src/config/tfa.rs
typo fixes all over the place
[proxmox-backup.git] / src / config / tfa.rs
index da3a4f9a392da925aa7d4bc66e4bc900f97f9609..6b65f6a555b354188178ce1fe65399fff475322a 100644 (file)
@@ -1,16 +1,30 @@
 use std::collections::HashMap;
 use std::fs::File;
+use std::io::{self, Read, Seek, SeekFrom};
+use std::os::unix::fs::OpenOptionsExt;
+use std::os::unix::io::AsRawFd;
+use std::path::PathBuf;
 use std::time::Duration;
 
 use anyhow::{bail, format_err, Error};
+use nix::sys::stat::Mode;
+use openssl::hash::MessageDigest;
+use openssl::pkey::PKey;
+use openssl::sign::Signer;
 use serde::{de::Deserializer, Deserialize, Serialize};
 use serde_json::Value;
+use webauthn_rs::{proto::UserVerificationPolicy, Webauthn};
+
+use webauthn_rs::proto::Credential as WebauthnCredential;
 
 use proxmox::api::api;
+use proxmox::api::schema::{Updatable, Updater};
 use proxmox::sys::error::SysError;
+use proxmox::tools::fs::CreateOptions;
 use proxmox::tools::tfa::totp::Totp;
 use proxmox::tools::tfa::u2f;
 use proxmox::tools::uuid::Uuid;
+use proxmox::tools::AsHex;
 
 use crate::api2::types::Userid;
 
@@ -21,46 +35,153 @@ const CONF_FILE: &str = configdir!("/tfa.json");
 const LOCK_FILE: &str = configdir!("/tfa.json.lock");
 const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
 
+const CHALLENGE_DATA_PATH: &str = rundir!("/tfa/challenges");
+
 /// U2F registration challenges time out after 2 minutes.
 const CHALLENGE_TIMEOUT: i64 = 2 * 60;
 
+pub fn read_lock() -> Result<File, Error> {
+    proxmox::tools::fs::open_file_locked(LOCK_FILE, LOCK_TIMEOUT, false)
+}
+
+pub fn write_lock() -> Result<File, Error> {
+    proxmox::tools::fs::open_file_locked(LOCK_FILE, LOCK_TIMEOUT, true)
+}
+
+/// Read the TFA entries.
+pub fn read() -> Result<TfaConfig, Error> {
+    let file = match File::open(CONF_FILE) {
+        Ok(file) => file,
+        Err(ref err) if err.not_found() => return Ok(TfaConfig::default()),
+        Err(err) => return Err(err.into()),
+    };
+
+    Ok(serde_json::from_reader(file)?)
+}
+
+/// Get the webauthn config with a digest.
+///
+/// This is meant only for configuration updates, which currently only means webauthn updates.
+/// Since this is meant to be done only once (since changes will lock out users), this should be
+/// used rarely, since the digest calculation is currently a bit more involved.
+pub fn webauthn_config() -> Result<Option<(WebauthnConfig, [u8; 32])>, Error>{
+    Ok(match read()?.webauthn {
+        Some(wa) => {
+            let digest = wa.digest()?;
+            Some((wa, digest))
+        }
+        None => None,
+    })
+}
+
+/// Requires the write lock to be held.
+pub fn write(data: &TfaConfig) -> Result<(), Error> {
+    let options = CreateOptions::new().perm(Mode::from_bits_truncate(0o0600));
+
+    let json = serde_json::to_vec(data)?;
+    proxmox::tools::fs::replace_file(CONF_FILE, &json, options)
+}
+
 #[derive(Deserialize, Serialize)]
 pub struct U2fConfig {
     appid: String,
 }
 
-#[derive(Default, Deserialize, Serialize)]
-pub struct TfaConfig {
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub u2f: Option<U2fConfig>,
-    #[serde(skip_serializing_if = "TfaUsers::is_empty", default)]
-    pub users: TfaUsers,
+#[api]
+#[derive(Clone, Deserialize, Serialize, Updater)]
+#[serde(deny_unknown_fields)]
+/// Server side webauthn server configuration.
+pub struct WebauthnConfig {
+    /// Relying party name. Any text identifier.
+    ///
+    /// Changing this *may* break existing credentials.
+    rp: String,
+
+    /// Site origin. Must be a `https://` URL (or `http://localhost`). Should contain the address
+    /// users type in their browsers to access the web interface.
+    ///
+    /// Changing this *may* break existing credentials.
+    origin: String,
+
+    /// Relying part ID. Must be the domain name without protocol, port or location.
+    ///
+    /// Changing this *will* break existing credentials.
+    id: String,
 }
 
-/// Heper to get a u2f instance from a u2f config, or `None` if there isn't one configured.
+impl WebauthnConfig {
+    pub fn digest(&self) -> Result<[u8; 32], Error> {
+        let digest_data = crate::tools::json::to_canonical_json(&serde_json::to_value(self)?)?;
+        Ok(openssl::sha::sha256(&digest_data))
+    }
+}
+
+/// For now we just implement this on the configuration this way.
+///
+/// Note that we may consider changing this so `get_origin` returns the `Host:` header provided by
+/// the connecting client.
+impl webauthn_rs::WebauthnConfig for WebauthnConfig {
+    fn get_relying_party_name(&self) -> String {
+        self.rp.clone()
+    }
+
+    fn get_origin(&self) -> &String {
+        &self.origin
+    }
+
+    fn get_relying_party_id(&self) -> String {
+        self.id.clone()
+    }
+}
+
+/// Helper to get a u2f instance from a u2f config, or `None` if there isn't one configured.
 fn get_u2f(u2f: &Option<U2fConfig>) -> Option<u2f::U2f> {
-    u2f.as_ref().map(|cfg| u2f::U2f::new(cfg.appid.clone(), cfg.appid.clone()))
+    u2f.as_ref()
+        .map(|cfg| u2f::U2f::new(cfg.appid.clone(), cfg.appid.clone()))
 }
 
-/// Heper to get a u2f instance from a u2f config.
-// deduplicate error message while working around self-borrow issue
-fn need_u2f(u2f: &Option<U2fConfig>) -> Result<u2f::U2f, Error> {
+/// Helper to get a u2f instance from a u2f config.
+///
+/// This is outside of `TfaConfig` to not borrow its `&self`.
+fn check_u2f(u2f: &Option<U2fConfig>) -> Result<u2f::U2f, Error> {
     get_u2f(u2f).ok_or_else(|| format_err!("no u2f configuration available"))
 }
 
-impl TfaConfig {
-    fn u2f(&self) -> Option<u2f::U2f> {
-        get_u2f(&self.u2f)
-    }
+/// Helper to get a `Webauthn` instance from a `WebauthnConfig`, or `None` if there isn't one
+/// configured.
+fn get_webauthn(waconfig: &Option<WebauthnConfig>) -> Option<Webauthn<WebauthnConfig>> {
+    waconfig.clone().map(Webauthn::new)
+}
 
-    fn need_u2f(&self) -> Result<u2f::U2f, Error> {
-        need_u2f(&self.u2f)
-    }
+/// Helper to get a u2f instance from a u2f config.
+///
+/// This is outside of `TfaConfig` to not borrow its `&self`.
+fn check_webauthn(waconfig: &Option<WebauthnConfig>) -> Result<Webauthn<WebauthnConfig>, Error> {
+    get_webauthn(waconfig).ok_or_else(|| format_err!("no webauthn configuration available"))
+}
+
+/// TFA Configuration for this instance.
+#[derive(Default, Deserialize, Serialize)]
+pub struct TfaConfig {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub u2f: Option<U2fConfig>,
 
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub webauthn: Option<WebauthnConfig>,
+
+    #[serde(skip_serializing_if = "TfaUsers::is_empty", default)]
+    pub users: TfaUsers,
+}
+
+impl TfaConfig {
     /// Get a two factor authentication challenge for a user, if the user has TFA set up.
-    pub fn login_challenge(&self, userid: &Userid) -> Result<Option<TfaChallenge>, Error> {
-        match self.users.get(userid) {
-            Some(udata) => udata.challenge(self.u2f().as_ref()),
+    pub fn login_challenge(&mut self, userid: &Userid) -> Result<Option<TfaChallenge>, Error> {
+        match self.users.get_mut(userid) {
+            Some(udata) => udata.challenge(
+                userid,
+                get_webauthn(&self.webauthn),
+                get_u2f(&self.u2f).as_ref(),
+            ),
             None => Ok(None),
         }
     }
@@ -68,28 +189,61 @@ impl TfaConfig {
     /// Get a u2f registration challenge.
     fn u2f_registration_challenge(
         &mut self,
-        user: &Userid,
+        userid: &Userid,
         description: String,
     ) -> Result<String, Error> {
-        let u2f = self.need_u2f()?;
+        let u2f = check_u2f(&self.u2f)?;
 
         self.users
-            .entry(user.clone())
+            .entry(userid.clone())
             .or_default()
-            .u2f_registration_challenge(&u2f, description)
+            .u2f_registration_challenge(userid, &u2f, description)
     }
 
     /// Finish a u2f registration challenge.
     fn u2f_registration_finish(
+        &mut self,
+        userid: &Userid,
+        challenge: &str,
+        response: &str,
+    ) -> Result<String, Error> {
+        let u2f = check_u2f(&self.u2f)?;
+
+        match self.users.get_mut(userid) {
+            Some(user) => user.u2f_registration_finish(userid, &u2f, challenge, response),
+            None => bail!("no such challenge"),
+        }
+    }
+
+    /// Get a webauthn registration challenge.
+    fn webauthn_registration_challenge(
         &mut self,
         user: &Userid,
+        description: String,
+    ) -> Result<String, Error> {
+        let webauthn = check_webauthn(&self.webauthn)?;
+
+        self.users
+            .entry(user.clone())
+            .or_default()
+            .webauthn_registration_challenge(webauthn, user, description)
+    }
+
+    /// Finish a webauthn registration challenge.
+    fn webauthn_registration_finish(
+        &mut self,
+        userid: &Userid,
         challenge: &str,
         response: &str,
     ) -> Result<String, Error> {
-        let u2f = self.need_u2f()?;
+        let webauthn = check_webauthn(&self.webauthn)?;
 
-        match self.users.get_mut(user) {
-            Some(user) => user.u2f_registration_finish(&u2f, challenge, response),
+        let response: webauthn_rs::proto::RegisterPublicKeyCredential =
+            serde_json::from_str(response)
+                .map_err(|err| format_err!("error parsing challenge response: {}", err))?;
+
+        match self.users.get_mut(userid) {
+            Some(user) => user.webauthn_registration_finish(webauthn, userid, challenge, response),
             None => bail!("no such challenge"),
         }
     }
@@ -102,22 +256,36 @@ impl TfaConfig {
         response: TfaResponse,
     ) -> Result<(), Error> {
         match self.users.get_mut(userid) {
-            Some(user) => {
-                match response {
-                    TfaResponse::Totp(value) => user.verify_totp(&value),
-                    TfaResponse::U2f(value) => match &challenge.u2f {
-                        Some(challenge) => {
-                            let u2f = need_u2f(&self.u2f)?;
-                            user.verify_u2f(u2f, &challenge.challenge, value)
-                        }
-                        None => bail!("no u2f factor available for user '{}'", userid),
+            Some(user) => match response {
+                TfaResponse::Totp(value) => user.verify_totp(&value),
+                TfaResponse::U2f(value) => match &challenge.u2f {
+                    Some(challenge) => {
+                        let u2f = check_u2f(&self.u2f)?;
+                        user.verify_u2f(u2f, &challenge.challenge, value)
                     }
-                    TfaResponse::Recovery(value) => user.verify_recovery(&value),
+                    None => bail!("no u2f factor available for user '{}'", userid),
+                },
+                TfaResponse::Webauthn(value) => {
+                    let webauthn = check_webauthn(&self.webauthn)?;
+                    user.verify_webauthn(userid, webauthn, value)
                 }
-            }
+                TfaResponse::Recovery(value) => user.verify_recovery(&value),
+            },
             None => bail!("no 2nd factor available for user '{}'", userid),
         }
     }
+
+    /// Remove non-existent users.
+    pub fn cleanup_users(&mut self, config: &proxmox::api::section_config::SectionConfigData) {
+        use crate::config::user::User;
+        self.users
+            .retain(|user, _| config.lookup::<User>("user", user.as_str()).is_ok());
+    }
+
+    /// Remove a user. Returns `true` if the user actually existed.
+    pub fn remove_user(&mut self, user: &Userid) -> bool {
+        self.users.remove(user).is_some()
+    }
 }
 
 #[api]
@@ -129,8 +297,12 @@ pub struct TfaInfo {
     pub id: String,
 
     /// User chosen description for this entry.
+    #[serde(skip_serializing_if = "String::is_empty")]
     pub description: String,
 
+    /// Creation time of this entry as unix epoch.
+    pub created: i64,
+
     /// Whether this TFA entry is currently enabled.
     #[serde(skip_serializing_if = "is_default_tfa_enable")]
     #[serde(default = "default_tfa_enable")]
@@ -139,11 +311,12 @@ pub struct TfaInfo {
 
 impl TfaInfo {
     /// For recovery keys we have a fixed entry.
-    pub(crate) fn recovery() -> Self {
+    pub(crate) fn recovery(created: i64) -> Self {
         Self {
             id: "recovery".to_string(),
-            description: "recovery keys".to_string(),
+            description: String::new(),
             enable: true,
+            created,
         }
     }
 }
@@ -169,12 +342,17 @@ impl<T> TfaEntry<T> {
                 id: Uuid::generate().to_string(),
                 enable: true,
                 description,
+                created: proxmox::tools::time::epoch_i64(),
             },
             entry,
         }
     }
 }
 
+trait IsExpired {
+    fn is_expired(&self, at_epoch: i64) -> bool;
+}
+
 /// A u2f registration challenge.
 #[derive(Deserialize, Serialize)]
 #[serde(deny_unknown_fields)]
@@ -197,12 +375,316 @@ impl U2fRegistrationChallenge {
             created: proxmox::tools::time::epoch_i64(),
         }
     }
+}
+
+impl IsExpired for U2fRegistrationChallenge {
+    fn is_expired(&self, at_epoch: i64) -> bool {
+        self.created < at_epoch
+    }
+}
+
+/// A webauthn registration challenge.
+#[derive(Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct WebauthnRegistrationChallenge {
+    /// Server side registration state data.
+    state: webauthn_rs::RegistrationState,
+
+    /// While this is basically the content of a `RegistrationState`, the webauthn-rs crate doesn't
+    /// make this public.
+    challenge: String,
+
+    /// The description chosen by the user for this registration.
+    description: String,
+
+    /// When the challenge was created as unix epoch. They are supposed to be short-lived.
+    created: i64,
+}
+
+impl WebauthnRegistrationChallenge {
+    pub fn new(
+        state: webauthn_rs::RegistrationState,
+        challenge: String,
+        description: String,
+    ) -> Self {
+        Self {
+            state,
+            challenge,
+            description,
+            created: proxmox::tools::time::epoch_i64(),
+        }
+    }
+}
+
+impl IsExpired for WebauthnRegistrationChallenge {
+    fn is_expired(&self, at_epoch: i64) -> bool {
+        self.created < at_epoch
+    }
+}
+
+/// A webauthn authentication challenge.
+#[derive(Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct WebauthnAuthChallenge {
+    /// Server side authentication state.
+    state: webauthn_rs::AuthenticationState,
+
+    /// While this is basically the content of a `AuthenticationState`, the webauthn-rs crate
+    /// doesn't make this public.
+    challenge: String,
+
+    /// When the challenge was created as unix epoch. They are supposed to be short-lived.
+    created: i64,
+}
+
+impl WebauthnAuthChallenge {
+    pub fn new(state: webauthn_rs::AuthenticationState, challenge: String) -> Self {
+        Self {
+            state,
+            challenge,
+            created: proxmox::tools::time::epoch_i64(),
+        }
+    }
+}
 
+impl IsExpired for WebauthnAuthChallenge {
     fn is_expired(&self, at_epoch: i64) -> bool {
         self.created < at_epoch
     }
 }
 
+/// Active TFA challenges per user, stored in `CHALLENGE_DATA_PATH`.
+#[derive(Default, Deserialize, Serialize)]
+pub struct TfaUserChallenges {
+    /// Active u2f registration challenges for a user.
+    ///
+    /// Expired values are automatically filtered out while parsing the tfa configuration file.
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    #[serde(deserialize_with = "filter_expired_challenge")]
+    u2f_registrations: Vec<U2fRegistrationChallenge>,
+
+    /// Active webauthn registration challenges for a user.
+    ///
+    /// Expired values are automatically filtered out while parsing the tfa configuration file.
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    #[serde(deserialize_with = "filter_expired_challenge")]
+    webauthn_registrations: Vec<WebauthnRegistrationChallenge>,
+
+    /// Active webauthn registration challenges for a user.
+    ///
+    /// Expired values are automatically filtered out while parsing the tfa configuration file.
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    #[serde(deserialize_with = "filter_expired_challenge")]
+    webauthn_auths: Vec<WebauthnAuthChallenge>,
+}
+
+/// Container of `TfaUserChallenges` with the corresponding file lock guard.
+///
+/// TODO: Implement a general file lock guarded struct container in the `proxmox` crate.
+pub struct TfaUserChallengeData {
+    inner: TfaUserChallenges,
+    path: PathBuf,
+    lock: File,
+}
+
+impl TfaUserChallengeData {
+    /// Build the path to the challenge data file for a user.
+    fn challenge_data_path(userid: &Userid) -> PathBuf {
+        PathBuf::from(format!("{}/{}", CHALLENGE_DATA_PATH, userid))
+    }
+
+    /// Load the user's current challenges with the intent to create a challenge (create the file
+    /// if it does not exist), and keep a lock on the file.
+    fn open(userid: &Userid) -> Result<Self, Error> {
+        crate::tools::create_run_dir()?;
+        let options = CreateOptions::new().perm(Mode::from_bits_truncate(0o0600));
+        proxmox::tools::fs::create_path(CHALLENGE_DATA_PATH, Some(options.clone()), Some(options))
+            .map_err(|err| {
+                format_err!(
+                    "failed to crate challenge data dir {:?}: {}",
+                    CHALLENGE_DATA_PATH,
+                    err
+                )
+            })?;
+
+        let path = Self::challenge_data_path(userid);
+
+        let mut file = std::fs::OpenOptions::new()
+            .create(true)
+            .read(true)
+            .write(true)
+            .truncate(false)
+            .mode(0o600)
+            .open(&path)
+            .map_err(|err| format_err!("failed to create challenge file {:?}: {}", path, err))?;
+
+        proxmox::tools::fs::lock_file(&mut file, true, None)?;
+
+        // the file may be empty, so read to a temporary buffer first:
+        let mut data = Vec::with_capacity(4096);
+
+        file.read_to_end(&mut data).map_err(|err| {
+            format_err!("failed to read challenge data for user {}: {}", userid, err)
+        })?;
+
+        let inner = if data.is_empty() {
+            Default::default()
+        } else {
+            serde_json::from_slice(&data).map_err(|err| {
+                format_err!(
+                    "failed to parse challenge data for user {}: {}",
+                    userid,
+                    err
+                )
+            })?
+        };
+
+        Ok(Self {
+            inner,
+            path,
+            lock: file,
+        })
+    }
+
+    /// `open` without creating the file if it doesn't exist, to finish WA authentications.
+    fn open_no_create(userid: &Userid) -> Result<Option<Self>, Error> {
+        let path = Self::challenge_data_path(userid);
+        let mut file = match std::fs::OpenOptions::new()
+            .read(true)
+            .write(true)
+            .truncate(false)
+            .mode(0o600)
+            .open(&path)
+        {
+            Ok(file) => file,
+            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
+            Err(err) => return Err(err.into()),
+        };
+
+        proxmox::tools::fs::lock_file(&mut file, true, None)?;
+
+        let inner = serde_json::from_reader(&mut file).map_err(|err| {
+            format_err!("failed to read challenge data for user {}: {}", userid, err)
+        })?;
+
+        Ok(Some(Self {
+            inner,
+            path,
+            lock: file,
+        }))
+    }
+
+    /// Rewind & truncate the file for an update.
+    fn rewind(&mut self) -> Result<(), Error> {
+        let pos = self.lock.seek(SeekFrom::Start(0))?;
+        if pos != 0 {
+            bail!(
+                "unexpected result trying to rewind file, position is {}",
+                pos
+            );
+        }
+
+        proxmox::c_try!(unsafe { libc::ftruncate(self.lock.as_raw_fd(), 0) });
+
+        Ok(())
+    }
+
+    /// Save the current data. Note that we do not replace the file here since we lock the file
+    /// itself, as it is in `/run`, and the typical error case for this particular situation
+    /// (machine loses power) simply prevents some login, but that'll probably fail anyway for
+    /// other reasons then...
+    ///
+    /// This currently consumes selfe as we never perform more than 1 insertion/removal, and this
+    /// way also unlocks early.
+    fn save(mut self) -> Result<(), Error> {
+        self.rewind()?;
+
+        serde_json::to_writer(&mut &self.lock, &self.inner).map_err(|err| {
+            format_err!("failed to update challenge file {:?}: {}", self.path, err)
+        })?;
+
+        Ok(())
+    }
+
+    /// Finish a u2f registration. The challenge should correspond to an output of
+    /// `u2f_registration_challenge` (which is a stringified `RegistrationChallenge`). The response
+    /// should come directly from the client.
+    fn u2f_registration_finish(
+        &mut self,
+        u2f: &u2f::U2f,
+        challenge: &str,
+        response: &str,
+    ) -> Result<TfaEntry<u2f::Registration>, Error> {
+        let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT;
+
+        let index = self
+            .inner
+            .u2f_registrations
+            .iter()
+            .position(|r| r.challenge == challenge)
+            .ok_or_else(|| format_err!("no such challenge"))?;
+
+        let reg = &self.inner.u2f_registrations[index];
+        if reg.is_expired(expire_before) {
+            bail!("no such challenge");
+        }
+
+        // the verify call only takes the actual challenge string, so we have to extract it
+        // (u2f::RegistrationChallenge did not always implement Deserialize...)
+        let chobj: Value = serde_json::from_str(challenge)
+            .map_err(|err| format_err!("error parsing original registration challenge: {}", err))?;
+        let challenge = chobj["challenge"]
+            .as_str()
+            .ok_or_else(|| format_err!("invalid registration challenge"))?;
+
+        let (mut reg, description) = match u2f.registration_verify(challenge, response)? {
+            None => bail!("verification failed"),
+            Some(reg) => {
+                let entry = self.inner.u2f_registrations.remove(index);
+                (reg, entry.description)
+            }
+        };
+
+        // we do not care about the attestation certificates, so don't store them
+        reg.certificate.clear();
+
+        Ok(TfaEntry::new(description, reg))
+    }
+
+    /// Finish a webauthn registration. The challenge should correspond to an output of
+    /// `webauthn_registration_challenge`. The response should come directly from the client.
+    fn webauthn_registration_finish(
+        &mut self,
+        webauthn: Webauthn<WebauthnConfig>,
+        challenge: &str,
+        response: webauthn_rs::proto::RegisterPublicKeyCredential,
+        existing_registrations: &[TfaEntry<WebauthnCredential>],
+    ) -> Result<TfaEntry<WebauthnCredential>, Error> {
+        let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT;
+
+        let index = self
+            .inner
+            .webauthn_registrations
+            .iter()
+            .position(|r| r.challenge == challenge)
+            .ok_or_else(|| format_err!("no such challenge"))?;
+
+        let reg = self.inner.webauthn_registrations.remove(index);
+        if reg.is_expired(expire_before) {
+            bail!("no such challenge");
+        }
+
+        let credential =
+            webauthn.register_credential(response, reg.state, |id| -> Result<bool, ()> {
+                Ok(existing_registrations
+                    .iter()
+                    .any(|cred| cred.entry.cred_id == *id))
+            })?;
+
+        Ok(TfaEntry::new(reg.description, credential))
+    }
+}
+
 /// TFA data for a user.
 #[derive(Default, Deserialize, Serialize)]
 #[serde(deny_unknown_fields)]
@@ -216,39 +698,31 @@ pub struct TfaUserData {
     #[serde(skip_serializing_if = "Vec::is_empty", default)]
     pub(crate) u2f: Vec<TfaEntry<u2f::Registration>>,
 
-    /// Recovery keys. (Unordered OTP values).
-    #[serde(skip_serializing_if = "Vec::is_empty", default)]
-    pub(crate) recovery: Vec<String>,
-
-    /// Active u2f registration challenges for a user.
-    ///
-    /// Expired values are automatically filtered out while parsing the tfa configuration file.
+    /// Registered webauthn tokens for a user.
     #[serde(skip_serializing_if = "Vec::is_empty", default)]
-    #[serde(deserialize_with = "filter_expired_registrations")]
-    u2f_registrations: Vec<U2fRegistrationChallenge>,
-}
+    pub(crate) webauthn: Vec<TfaEntry<WebauthnCredential>>,
 
-/// Serde helper using our `FilteredVecVisitor` to filter out expired entries directly at load
-/// time.
-fn filter_expired_registrations<'de, D>(
-    deserializer: D,
-) -> Result<Vec<U2fRegistrationChallenge>, D::Error>
-where
-    D: Deserializer<'de>,
-{
-    let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT;
-    Ok(
-        deserializer.deserialize_seq(crate::tools::serde_filter::FilteredVecVisitor::new(
-            "a u2f registration challenge entry",
-            move |reg: &U2fRegistrationChallenge| !reg.is_expired(expire_before),
-        ))?,
-    )
+    /// Recovery keys. (Unordered OTP values).
+    #[serde(skip_serializing_if = "Recovery::option_is_empty", default)]
+    pub(crate) recovery: Option<Recovery>,
 }
 
 impl TfaUserData {
+    /// Shortcut to get the recovery entry only if it is not empty!
+    pub fn recovery(&self) -> Option<&Recovery> {
+        if Recovery::option_is_empty(&self.recovery) {
+            None
+        } else {
+            self.recovery.as_ref()
+        }
+    }
+
     /// `true` if no second factors exist
     pub fn is_empty(&self) -> bool {
-        self.totp.is_empty() && self.u2f.is_empty() && self.recovery.is_empty()
+        self.totp.is_empty()
+            && self.u2f.is_empty()
+            && self.webauthn.is_empty()
+            && self.recovery().is_none()
     }
 
     /// Find an entry by id, except for the "recovery" entry which we're currently treating
@@ -260,6 +734,12 @@ impl TfaUserData {
             }
         }
 
+        for entry in &mut self.webauthn {
+            if entry.info.id == id {
+                return Some(&mut entry.info);
+            }
+        }
+
         for entry in &mut self.u2f {
             if entry.info.id == id {
                 return Some(&mut entry.info);
@@ -277,75 +757,119 @@ impl TfaUserData {
     /// instead.
     fn u2f_registration_challenge(
         &mut self,
+        userid: &Userid,
         u2f: &u2f::U2f,
         description: String,
     ) -> Result<String, Error> {
         let challenge = serde_json::to_string(&u2f.registration_challenge()?)?;
 
-        self.u2f_registrations.push(U2fRegistrationChallenge::new(
-            challenge.clone(),
-            description,
-        ));
+        let mut data = TfaUserChallengeData::open(userid)?;
+        data.inner
+            .u2f_registrations
+            .push(U2fRegistrationChallenge::new(
+                challenge.clone(),
+                description,
+            ));
+        data.save()?;
 
         Ok(challenge)
     }
 
-    /// Finish a u2f registration. The challenge should correspond to an output of
-    /// `u2f_registration_challenge` (which is a stringified `RegistrationChallenge`). The response
-    /// should come directly from the client.
     fn u2f_registration_finish(
         &mut self,
+        userid: &Userid,
         u2f: &u2f::U2f,
         challenge: &str,
         response: &str,
     ) -> Result<String, Error> {
-        let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT;
+        let mut data = TfaUserChallengeData::open(userid)?;
+        let entry = data.u2f_registration_finish(u2f, challenge, response)?;
+        data.save()?;
 
-        let index = self
-            .u2f_registrations
-            .iter()
-            .position(|r| r.challenge == challenge)
-            .ok_or_else(|| format_err!("no such challenge"))?;
+        let id = entry.info.id.clone();
+        self.u2f.push(entry);
+        Ok(id)
+    }
 
-        let reg = &self.u2f_registrations[index];
-        if reg.is_expired(expire_before) {
-            bail!("no such challenge");
-        }
+    /// Create a webauthn registration challenge.
+    ///
+    /// The description is required at this point already mostly to better be able to identify such
+    /// challenges in the tfa config file if necessary. The user otherwise has no access to this
+    /// information at this point, as the challenge is identified by its actual challenge data
+    /// instead.
+    fn webauthn_registration_challenge(
+        &mut self,
+        mut webauthn: Webauthn<WebauthnConfig>,
+        userid: &Userid,
+        description: String,
+    ) -> Result<String, Error> {
+        let cred_ids: Vec<_> = self
+            .enabled_webauthn_entries()
+            .map(|cred| cred.cred_id.clone())
+            .collect();
 
-        // the verify call only takes the actual challenge string, so we have to extract it
-        // (u2f::RegistrationChallenge did not always implement Deserialize...)
-        let chobj: Value = serde_json::from_str(challenge)
-            .map_err(|err| format_err!("error parsing original registration challenge: {}", err))?;
-        let challenge = chobj["challenge"]
-            .as_str()
-            .ok_or_else(|| format_err!("invalid registration challenge"))?;
+        let userid_str = userid.to_string();
+        let (challenge, state) = webauthn.generate_challenge_register_options(
+            userid_str.as_bytes().to_vec(),
+            userid_str.clone(),
+            userid_str.clone(),
+            Some(cred_ids),
+            Some(UserVerificationPolicy::Discouraged),
+        )?;
+
+        let challenge_string = challenge.public_key.challenge.to_string();
+        let challenge = serde_json::to_string(&challenge)?;
+
+        let mut data = TfaUserChallengeData::open(userid)?;
+        data.inner
+            .webauthn_registrations
+            .push(WebauthnRegistrationChallenge::new(
+                state,
+                challenge_string,
+                description,
+            ));
+        data.save()?;
 
-        let (mut reg, description) = match u2f.registration_verify(challenge, response)? {
-            None => bail!("verification failed"),
-            Some(reg) => {
-                let entry = self.u2f_registrations.remove(index);
-                (reg, entry.description)
-            }
-        };
+        Ok(challenge)
+    }
 
-        // we do not care about the attestation certificates, so don't store them
-        reg.certificate.clear();
+    /// Finish a webauthn registration. The challenge should correspond to an output of
+    /// `webauthn_registration_challenge`. The response should come directly from the client.
+    fn webauthn_registration_finish(
+        &mut self,
+        webauthn: Webauthn<WebauthnConfig>,
+        userid: &Userid,
+        challenge: &str,
+        response: webauthn_rs::proto::RegisterPublicKeyCredential,
+    ) -> Result<String, Error> {
+        let mut data = TfaUserChallengeData::open(userid)?;
+        let entry =
+            data.webauthn_registration_finish(webauthn, challenge, response, &self.webauthn)?;
+        data.save()?;
 
-        let entry = TfaEntry::new(description, reg);
         let id = entry.info.id.clone();
-        self.u2f.push(entry);
+        self.webauthn.push(entry);
         Ok(id)
     }
 
     /// Generate a generic TFA challenge. See the [`TfaChallenge`] description for details.
-    pub fn challenge(&self, u2f: Option<&u2f::U2f>) -> Result<Option<TfaChallenge>, Error> {
+    pub fn challenge(
+        &mut self,
+        userid: &Userid,
+        webauthn: Option<Webauthn<WebauthnConfig>>,
+        u2f: Option<&u2f::U2f>,
+    ) -> Result<Option<TfaChallenge>, Error> {
         if self.is_empty() {
             return Ok(None);
         }
 
         Ok(Some(TfaChallenge {
             totp: self.totp.iter().any(|e| e.info.enable),
-            recovery: RecoveryState::from_count(self.recovery.len()),
+            recovery: RecoveryState::from(&self.recovery),
+            webauthn: match webauthn {
+                Some(webauthn) => self.webauthn_challenge(userid, webauthn)?,
+                None => None,
+            },
             u2f: match u2f {
                 Some(u2f) => self.u2f_challenge(u2f)?,
                 None => None,
@@ -357,26 +881,21 @@ impl TfaUserData {
     fn enabled_totp_entries(&self) -> impl Iterator<Item = &Totp> {
         self.totp
             .iter()
-            .filter_map(|e| {
-                if e.info.enable {
-                    Some(&e.entry)
-                } else {
-                    None
-                }
-            })
+            .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None })
     }
 
     /// Helper to iterate over enabled u2f entries.
     fn enabled_u2f_entries(&self) -> impl Iterator<Item = &u2f::Registration> {
         self.u2f
             .iter()
-            .filter_map(|e| {
-                if e.info.enable {
-                    Some(&e.entry)
-                } else {
-                    None
-                }
-            })
+            .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None })
+    }
+
+    /// Helper to iterate over enabled u2f entries.
+    fn enabled_webauthn_entries(&self) -> impl Iterator<Item = &WebauthnCredential> {
+        self.webauthn
+            .iter()
+            .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None })
     }
 
     /// Generate an optional u2f challenge.
@@ -400,6 +919,34 @@ impl TfaUserData {
         }))
     }
 
+    /// Generate an optional webauthn challenge.
+    fn webauthn_challenge(
+        &mut self,
+        userid: &Userid,
+        mut webauthn: Webauthn<WebauthnConfig>,
+    ) -> Result<Option<webauthn_rs::proto::RequestChallengeResponse>, Error> {
+        if self.webauthn.is_empty() {
+            return Ok(None);
+        }
+
+        let creds: Vec<_> = self.enabled_webauthn_entries().map(Clone::clone).collect();
+
+        if creds.is_empty() {
+            return Ok(None);
+        }
+
+        let (challenge, state) = webauthn
+            .generate_challenge_authenticate(creds, Some(UserVerificationPolicy::Discouraged))?;
+        let challenge_string = challenge.public_key.challenge.to_string();
+        let mut data = TfaUserChallengeData::open(userid)?;
+        data.inner
+            .webauthn_auths
+            .push(WebauthnAuthChallenge::new(state, challenge_string));
+        data.save()?;
+
+        Ok(Some(challenge))
+    }
+
     /// Verify a totp challenge. The `value` should be the totp digits as plain text.
     fn verify_totp(&self, value: &str) -> Result<(), Error> {
         let now = std::time::SystemTime::now();
@@ -425,9 +972,12 @@ impl TfaUserData {
 
         if let Some(entry) = self
             .enabled_u2f_entries()
-            .find(|e| e.key.key_handle == response.key_handle)
+            .find(|e| e.key.key_handle == response.key_handle())
         {
-            if u2f.auth_verify_obj(&entry.public_key, &challenge.challenge, response)?.is_some() {
+            if u2f
+                .auth_verify_obj(&entry.public_key, &challenge.challenge, response)?
+                .is_some()
+            {
                 return Ok(());
             }
         }
@@ -435,67 +985,207 @@ impl TfaUserData {
         bail!("u2f verification failed");
     }
 
+    /// Verify a webauthn response.
+    fn verify_webauthn(
+        &mut self,
+        userid: &Userid,
+        mut webauthn: Webauthn<WebauthnConfig>,
+        mut response: Value,
+    ) -> Result<(), Error> {
+        let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT;
+
+        let challenge = match response
+            .as_object_mut()
+            .ok_or_else(|| format_err!("invalid response, must be a json object"))?
+            .remove("challenge")
+            .ok_or_else(|| format_err!("missing challenge data in response"))?
+        {
+            Value::String(s) => s,
+            _ => bail!("invalid challenge data in response"),
+        };
+
+        let response: webauthn_rs::proto::PublicKeyCredential = serde_json::from_value(response)
+            .map_err(|err| format_err!("invalid webauthn response: {}", err))?;
+
+        let mut data = match TfaUserChallengeData::open_no_create(userid)? {
+            Some(data) => data,
+            None => bail!("no such challenge"),
+        };
+
+        let index = data
+            .inner
+            .webauthn_auths
+            .iter()
+            .position(|r| r.challenge == challenge)
+            .ok_or_else(|| format_err!("no such challenge"))?;
+
+        let challenge = data.inner.webauthn_auths.remove(index);
+        if challenge.is_expired(expire_before) {
+            bail!("no such challenge");
+        }
+
+        // we don't allow re-trying the challenge, so make the removal persistent now:
+        data.save()
+            .map_err(|err| format_err!("failed to save challenge file: {}", err))?;
+
+        match webauthn.authenticate_credential(response, challenge.state)? {
+            Some((_cred, _counter)) => Ok(()),
+            None => bail!("webauthn authentication failed"),
+        }
+    }
+
     /// Verify a recovery key.
     ///
     /// NOTE: If successful, the key will automatically be removed from the list of available
     /// recovery keys, so the configuration needs to be saved afterwards!
     fn verify_recovery(&mut self, value: &str) -> Result<(), Error> {
-        match self.recovery.iter().position(|v| v == value) {
-            Some(idx) => {
-                self.recovery.remove(idx);
-                Ok(())
+        if let Some(r) = &mut self.recovery {
+            if r.verify(value)? {
+                return Ok(());
             }
-            None => bail!("recovery verification failed"),
         }
+        bail!("recovery verification failed");
     }
 
     /// Add a new set of recovery keys. There can only be 1 set of keys at a time.
     fn add_recovery(&mut self) -> Result<Vec<String>, Error> {
-        if !self.recovery.is_empty() {
+        if self.recovery.is_some() {
             bail!("user already has recovery keys");
         }
 
-        let mut key_data = [0u8; 40]; // 10 keys of 32 bits
-        proxmox::sys::linux::fill_with_random_data(&mut key_data)?;
-        for b in key_data.chunks(4) {
-            self.recovery.push(format!("{:02x}{:02x}{:02x}{:02x}", b[0], b[1], b[2], b[3]));
-        }
+        let (recovery, original) = Recovery::generate()?;
+
+        self.recovery = Some(recovery);
 
-        Ok(self.recovery.clone())
+        Ok(original)
     }
 }
 
-/// Read the TFA entries.
-pub fn read() -> Result<TfaConfig, Error> {
-    let file = match File::open(CONF_FILE) {
-        Ok(file) => file,
-        Err(ref err) if err.not_found() => return Ok(TfaConfig::default()),
-        Err(err) => return Err(err.into()),
-    };
+/// Recovery entries. We use HMAC-SHA256 with a random secret as a salted hash replacement.
+#[derive(Deserialize, Serialize)]
+pub struct Recovery {
+    /// "Salt" used for the key HMAC.
+    secret: String,
 
-    Ok(serde_json::from_reader(file)?)
+    /// Recovery key entries are HMACs of the original data. When used up they will become `None`
+    /// since the user is presented an enumerated list of codes, so we know the indices of used and
+    /// unused codes.
+    entries: Vec<Option<String>>,
+
+    /// Creation timestamp as a unix epoch.
+    pub created: i64,
 }
 
-/// Requires the write lock to be held.
-pub fn write(data: &TfaConfig) -> Result<(), Error> {
-    let options = proxmox::tools::fs::CreateOptions::new()
-        .perm(nix::sys::stat::Mode::from_bits_truncate(0o0600));
+impl Recovery {
+    /// Generate recovery keys and return the recovery entry along with the original string
+    /// entries.
+    fn generate() -> Result<(Self, Vec<String>), Error> {
+        let mut secret = [0u8; 8];
+        proxmox::sys::linux::fill_with_random_data(&mut secret)?;
 
-    let json = serde_json::to_vec(data)?;
-    proxmox::tools::fs::replace_file(CONF_FILE, &json, options)
+        let mut this = Self {
+            secret: AsHex(&secret).to_string(),
+            entries: Vec::with_capacity(10),
+            created: proxmox::tools::time::epoch_i64(),
+        };
+
+        let mut original = Vec::new();
+
+        let mut key_data = [0u8; 80]; // 10 keys of 12 bytes
+        proxmox::sys::linux::fill_with_random_data(&mut key_data)?;
+        for b in key_data.chunks(8) {
+            let entry = format!(
+                "{}-{}-{}-{}",
+                AsHex(&b[0..2]),
+                AsHex(&b[2..4]),
+                AsHex(&b[4..6]),
+                AsHex(&b[6..8]),
+            );
+
+            this.entries.push(Some(this.hash(entry.as_bytes())?));
+            original.push(entry);
+        }
+
+        Ok((this, original))
+    }
+
+    /// Perform HMAC-SHA256 on the data and return the result as a hex string.
+    fn hash(&self, data: &[u8]) -> Result<String, Error> {
+        let secret = PKey::hmac(self.secret.as_bytes())
+            .map_err(|err| format_err!("error instantiating hmac key: {}", err))?;
+
+        let mut signer = Signer::new(MessageDigest::sha256(), &secret)
+            .map_err(|err| format_err!("error instantiating hmac signer: {}", err))?;
+
+        let hmac = signer
+            .sign_oneshot_to_vec(data)
+            .map_err(|err| format_err!("error calculating hmac: {}", err))?;
+
+        Ok(AsHex(&hmac).to_string())
+    }
+
+    /// Iterator over available keys.
+    fn available(&self) -> impl Iterator<Item = &str> {
+        self.entries.iter().filter_map(Option::as_deref)
+    }
+
+    /// Count the available keys.
+    fn count_available(&self) -> usize {
+        self.available().count()
+    }
+
+    /// Convenience serde method to check if either the option is `None` or the content `is_empty`.
+    fn option_is_empty(this: &Option<Self>) -> bool {
+        this.as_ref()
+            .map_or(true, |this| this.count_available() == 0)
+    }
+
+    /// Verify a key and remove it. Returns whether the key was valid. Errors on openssl errors.
+    fn verify(&mut self, key: &str) -> Result<bool, Error> {
+        let hash = self.hash(key.as_bytes())?;
+        for entry in &mut self.entries {
+            if entry.as_ref() == Some(&hash) {
+                *entry = None;
+                return Ok(true);
+            }
+        }
+        Ok(false)
+    }
 }
 
-pub fn read_lock() -> Result<File, Error> {
-    proxmox::tools::fs::open_file_locked(LOCK_FILE, LOCK_TIMEOUT, false)
+/// Serde helper using our `FilteredVecVisitor` to filter out expired entries directly at load
+/// time.
+fn filter_expired_challenge<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
+where
+    D: Deserializer<'de>,
+    T: Deserialize<'de> + IsExpired,
+{
+    let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT;
+    Ok(
+        deserializer.deserialize_seq(crate::tools::serde_filter::FilteredVecVisitor::new(
+            "a challenge entry",
+            move |reg: &T| !reg.is_expired(expire_before),
+        ))?,
+    )
 }
 
-pub fn write_lock() -> Result<File, Error> {
-    proxmox::tools::fs::open_file_locked(LOCK_FILE, LOCK_TIMEOUT, true)
+/// Get an optional TFA challenge for a user.
+pub fn login_challenge(userid: &Userid) -> Result<Option<TfaChallenge>, Error> {
+    let _lock = write_lock()?;
+
+    let mut data = read()?;
+    Ok(match data.login_challenge(userid)? {
+        Some(challenge) => {
+            write(&data)?;
+            Some(challenge)
+        }
+        None => None,
+    })
 }
 
 /// Add a TOTP entry for a user. Returns the ID.
 pub fn add_totp(userid: &Userid, description: String, value: Totp) -> Result<String, Error> {
-    let _lock = crate::config::tfa::write_lock();
+    let _lock = write_lock();
     let mut data = read()?;
     let entry = TfaEntry::new(description, value);
     let id = entry.info.id.clone();
@@ -510,10 +1200,14 @@ pub fn add_totp(userid: &Userid, description: String, value: Totp) -> Result<Str
 
 /// Add recovery tokens for the user. Returns the token list.
 pub fn add_recovery(userid: &Userid) -> Result<Vec<String>, Error> {
-    let _lock = crate::config::tfa::write_lock();
+    let _lock = write_lock();
 
     let mut data = read()?;
-    let out = data.users.entry(userid.clone()).or_default().add_recovery()?;
+    let out = data
+        .users
+        .entry(userid.clone())
+        .or_default()
+        .add_recovery()?;
     write(&data)?;
     Ok(out)
 }
@@ -535,11 +1229,33 @@ pub fn finish_u2f_registration(
 ) -> Result<String, Error> {
     let _lock = crate::config::tfa::write_lock();
     let mut data = read()?;
-    let challenge = data.u2f_registration_finish(userid, challenge, response)?;
+    let id = data.u2f_registration_finish(userid, challenge, response)?;
+    write(&data)?;
+    Ok(id)
+}
+
+/// Add a webauthn registration challenge for a user.
+pub fn add_webauthn_registration(userid: &Userid, description: String) -> Result<String, Error> {
+    let _lock = crate::config::tfa::write_lock();
+    let mut data = read()?;
+    let challenge = data.webauthn_registration_challenge(userid, description)?;
     write(&data)?;
     Ok(challenge)
 }
 
+/// Finish a webauthn registration challenge for a user.
+pub fn finish_webauthn_registration(
+    userid: &Userid,
+    challenge: &str,
+    response: &str,
+) -> Result<String, Error> {
+    let _lock = crate::config::tfa::write_lock();
+    let mut data = read()?;
+    let id = data.webauthn_registration_finish(userid, challenge, response)?;
+    write(&data)?;
+    Ok(id)
+}
+
 /// Verify a TFA challenge.
 pub fn verify_challenge(
     userid: &Userid,
@@ -554,38 +1270,41 @@ pub fn verify_challenge(
 }
 
 /// Used to inform the user about the recovery code status.
-#[derive(Clone, Copy, Eq, PartialEq, Deserialize, Serialize)]
-#[serde(rename_all = "kebab-case")]
-pub enum RecoveryState {
-    Unavailable,
-    Low,
-    Available,
-}
+///
+/// This contains the available key indices.
+#[derive(Clone, Default, Eq, PartialEq, Deserialize, Serialize)]
+pub struct RecoveryState(Vec<usize>);
 
 impl RecoveryState {
-    fn from_count(count: usize) -> Self {
-        match count {
-            0 => RecoveryState::Unavailable,
-            1..=3 => RecoveryState::Low,
-            _ => RecoveryState::Available,
-        }
+    fn is_unavailable(&self) -> bool {
+        self.0.is_empty()
     }
+}
 
-    // serde needs `&self` but this is a tiny Copy type, so we mark this as inline
-    #[inline]
-    fn is_unavailable(&self) -> bool {
-        *self == RecoveryState::Unavailable
+impl From<&Option<Recovery>> for RecoveryState {
+    fn from(r: &Option<Recovery>) -> Self {
+        match r {
+            Some(r) => Self::from(r),
+            None => Self::default(),
+        }
     }
 }
 
-impl Default for RecoveryState {
-    fn default() -> Self {
-        RecoveryState::Unavailable
+impl From<&Recovery> for RecoveryState {
+    fn from(r: &Recovery) -> Self {
+        Self(
+            r.entries
+                .iter()
+                .enumerate()
+                .filter_map(|(idx, key)| if key.is_some() { Some(idx) } else { None })
+                .collect(),
+        )
     }
 }
 
 /// When sending a TFA challenge to the user, we include information about what kind of challenge
-/// the user may perform. If u2f devices are available, a u2f challenge will be included.
+/// the user may perform. If webauthn credentials are available, a webauthn challenge will be
+/// included.
 #[derive(Deserialize, Serialize)]
 #[serde(rename_all = "kebab-case")]
 pub struct TfaChallenge {
@@ -599,6 +1318,11 @@ pub struct TfaChallenge {
     /// If the user has any u2f tokens registered, this will contain the U2F challenge data.
     #[serde(skip_serializing_if = "Option::is_none")]
     u2f: Option<U2fChallenge>,
+
+    /// If the user has any webauthn credentials registered, this will contain the corresponding
+    /// challenge data.
+    #[serde(skip_serializing_if = "Option::is_none", skip_deserializing)]
+    webauthn: Option<webauthn_rs::proto::RequestChallengeResponse>,
 }
 
 /// Data used for u2f challenges.
@@ -615,6 +1339,7 @@ pub struct U2fChallenge {
 pub enum TfaResponse {
     Totp(String),
     U2f(Value),
+    Webauthn(Value),
     Recovery(String),
 }
 
@@ -622,12 +1347,14 @@ impl std::str::FromStr for TfaResponse {
     type Err = Error;
 
     fn from_str(s: &str) -> Result<Self, Error> {
-        Ok(if s.starts_with("totp:") {
-            TfaResponse::Totp(s[5..].to_string())
-        } else if s.starts_with("u2f:") {
-            TfaResponse::U2f(serde_json::from_str(&s[4..])?)
-        } else if s.starts_with("recovery:") {
-            TfaResponse::Recovery(s[9..].to_string())
+        Ok(if let Some(totp) = s.strip_prefix("totp:") {
+            TfaResponse::Totp(totp.to_string())
+        } else if let Some(u2f) = s.strip_prefix("u2f:") {
+            TfaResponse::U2f(serde_json::from_str(u2f)?)
+        } else if let Some(webauthn) = s.strip_prefix("webauthn:") {
+            TfaResponse::Webauthn(serde_json::from_str(webauthn)?)
+        } else if let Some(recovery) = s.strip_prefix("recovery:") {
+            TfaResponse::Recovery(recovery.to_string())
         } else {
             bail!("invalid tfa response");
         })