[pve-devel] [PATCH proxmox-perl-rs 4/6] pve: add tfa api

Wolfgang Bumiller w.bumiller at proxmox.com
Tue Nov 9 12:26:53 CET 2021


This consists of two parts:

1) A proxmox_tfa_api module which temporarily lives here but
   will become its own crate.

   Most of this is a copy from ' src/config/tfa.rs with some
   compatibility changes:
   * The #[api] macro is guarded by a feature flag, since we
     cannot use it for PVE.
   * The Userid type is replaced by &str since we don't have
     Userid in PVE either.
   * The file locking/reading is removed, this will stay in
     the corresponding product code, and the main entry
     point is now the TfaConfig object.
   * Access to the runtime active challenges in /run is
     provided via a trait implementation since PVE and PBS
     will use different paths for this.
   Essentially anything pbs-specific was removed and the
   code split into a few submodules (one per tfa type
   basically).

2) The tfa module in pve-rs, which contains:
   * The parser for the OLD /etc/pve/priv/tfa.cfg
   * The parser for the NEW /etc/pve/priv/tfa.cfg
   * These create a blessed PVE::RS::TFA instance which:
     - Wraps access to the TfaConfig rust object.
     - Has methods all the TFA API call implementations
       These are copied from PBS' src/api2/access/tfa.rs,
       and pbs specific code removed.

Signed-off-by: Wolfgang Bumiller <w.bumiller at proxmox.com>
---
 pve-rs/src/lib.rs                             |    1 +
 pve-rs/src/tfa/mod.rs                         |  965 ++++++++++++++++
 pve-rs/src/tfa/proxmox_tfa_api/api.rs         |  487 ++++++++
 pve-rs/src/tfa/proxmox_tfa_api/mod.rs         | 1003 +++++++++++++++++
 pve-rs/src/tfa/proxmox_tfa_api/recovery.rs    |  153 +++
 pve-rs/src/tfa/proxmox_tfa_api/serde_tools.rs |  111 ++
 pve-rs/src/tfa/proxmox_tfa_api/u2f.rs         |   89 ++
 pve-rs/src/tfa/proxmox_tfa_api/webauthn.rs    |  118 ++
 8 files changed, 2927 insertions(+)
 create mode 100644 pve-rs/src/tfa/mod.rs
 create mode 100644 pve-rs/src/tfa/proxmox_tfa_api/api.rs
 create mode 100644 pve-rs/src/tfa/proxmox_tfa_api/mod.rs
 create mode 100644 pve-rs/src/tfa/proxmox_tfa_api/recovery.rs
 create mode 100644 pve-rs/src/tfa/proxmox_tfa_api/serde_tools.rs
 create mode 100644 pve-rs/src/tfa/proxmox_tfa_api/u2f.rs
 create mode 100644 pve-rs/src/tfa/proxmox_tfa_api/webauthn.rs

diff --git a/pve-rs/src/lib.rs b/pve-rs/src/lib.rs
index 15793ef..594a96f 100644
--- a/pve-rs/src/lib.rs
+++ b/pve-rs/src/lib.rs
@@ -4,3 +4,4 @@
 pub mod apt;
 
 pub mod openid;
+pub mod tfa;
diff --git a/pve-rs/src/tfa/mod.rs b/pve-rs/src/tfa/mod.rs
new file mode 100644
index 0000000..56e63f2
--- /dev/null
+++ b/pve-rs/src/tfa/mod.rs
@@ -0,0 +1,965 @@
+//! This implements the `tfa.cfg` parser & TFA API calls for PVE.
+//!
+//! The exported `PVE::RS::TFA` perl package provides access to rust's `TfaConfig` as well as
+//! transparently providing the old style TFA config so that as long as users only have a single
+//! TFA entry, the old authentication API still works.
+//!
+//! NOTE: In PVE the tfa config is behind `PVE::Cluster`'s `ccache` and therefore must be clonable
+//! via `Storable::dclone`, so we implement the storable hooks `STORABLE_freeze` and
+//! `STORABLE_attach`. Note that we only allow *cloning*, not freeze/thaw.
+
+use std::convert::TryFrom;
+use std::fs::File;
+use std::io::{self, Read};
+use std::os::unix::fs::OpenOptionsExt;
+use std::os::unix::io::{AsRawFd, RawFd};
+use std::path::{Path, PathBuf};
+
+use anyhow::{bail, format_err, Error};
+use nix::errno::Errno;
+use nix::sys::stat::Mode;
+use serde_json::Value as JsonValue;
+
+mod proxmox_tfa_api;
+pub(self) use proxmox_tfa_api::{
+    RecoveryState, TfaChallenge, TfaConfig, TfaResponse, TfaUserData, U2fConfig, WebauthnConfig,
+};
+
+#[perlmod::package(name = "PVE::RS::TFA")]
+mod export {
+    use std::convert::TryInto;
+    use std::sync::Mutex;
+
+    use anyhow::{bail, format_err, Error};
+    use serde_bytes::ByteBuf;
+
+    use perlmod::Value;
+
+    use super::proxmox_tfa_api::api;
+    use super::{TfaConfig, UserAccess};
+
+    perlmod::declare_magic!(Box<Tfa> : &Tfa as "PVE::RS::TFA");
+
+    /// A TFA Config instance.
+    pub struct Tfa {
+        inner: Mutex<TfaConfig>,
+    }
+
+    /// Support `dclone` so this can be put into the `ccache` of `PVE::Cluster`.
+    #[export(name = "STORABLE_freeze", raw_return)]
+    fn storable_freeze(#[try_from_ref] this: &Tfa, cloning: bool) -> Result<Value, Error> {
+        if !cloning {
+            bail!("freezing TFA config not supported!");
+        }
+
+        // An alternative would be to literally just *serialize* the data, then we wouldn't even
+        // need to restrict it to `cloning=true`, but since `clone=true` means we're immediately
+        // attaching anyway, this should be safe enough...
+
+        let mut cloned = Box::new(Tfa {
+            inner: Mutex::new(this.inner.lock().unwrap().clone()),
+        });
+        let value = Value::new_pointer::<Tfa>(&mut *cloned);
+        let _perl = Box::leak(cloned);
+        Ok(value)
+    }
+
+    /// Instead of `thaw` we implement `attach` for `dclone`.
+    #[export(name = "STORABLE_attach", raw_return)]
+    fn storable_attach(
+        #[raw] class: Value,
+        cloning: bool,
+        #[raw] serialized: Value,
+    ) -> Result<Value, Error> {
+        if !cloning {
+            bail!("STORABLE_attach called with cloning=false");
+        }
+        let data = unsafe { Box::from_raw(serialized.pv_raw::<Tfa>()?) };
+
+        let mut hash = perlmod::Hash::new();
+        super::generate_legacy_config(&mut hash, &data.inner.lock().unwrap());
+        let hash = Value::Hash(hash);
+        let obj = Value::new_ref(&hash);
+        obj.bless_sv(&class)?;
+        hash.add_magic(MAGIC.with_value(data));
+        Ok(obj)
+
+        // Once we drop support for legacy authentication we can just do this:
+        // Ok(perlmod::instantiate_magic!(&class, MAGIC => data))
+    }
+
+    /// Parse a TFA configuration.
+    #[export(raw_return)]
+    fn new(#[raw] class: Value, config: &[u8]) -> Result<Value, Error> {
+        let mut inner: TfaConfig = serde_json::from_slice(config)
+            .map_err(Error::from)
+            .or_else(|_err| super::parse_old_config(config))
+            .map_err(|_err| {
+                format_err!("failed to parse TFA file, neither old style nor valid json")
+            })?;
+
+        // In PVE, the U2F and Webauthn configurations come from `datacenter.cfg`. In case this
+        // config was copied from PBS, let's clear it out:
+        inner.u2f = None;
+        inner.webauthn = None;
+
+        let mut hash = perlmod::Hash::new();
+        super::generate_legacy_config(&mut hash, &inner);
+        let hash = Value::Hash(hash);
+        let obj = Value::new_ref(&hash);
+        obj.bless_sv(&class)?;
+        hash.add_magic(MAGIC.with_value(Box::new(Tfa {
+            inner: Mutex::new(inner),
+        })));
+        Ok(obj)
+
+        // Once we drop support for legacy authentication we can just do this:
+        // Ok(perlmod::instantiate_magic!(
+        //     &class, MAGIC => Box::new(Tfa { inner: Mutex::new(inner) })
+        // ))
+    }
+
+    /// Write the configuration out into a JSON string.
+    #[export]
+    fn write(#[try_from_ref] this: &Tfa) -> Result<serde_bytes::ByteBuf, Error> {
+        let mut inner = this.inner.lock().unwrap();
+        let u2f = inner.u2f.take();
+        let webauthn = inner.webauthn.take();
+        let output = serde_json::to_vec(&*inner); // must not use `?` here
+        inner.u2f = u2f;
+        inner.webauthn = webauthn;
+        Ok(ByteBuf::from(output?))
+    }
+
+    /// Debug helper: serialize the TFA user data into a perl value.
+    #[export]
+    fn to_perl(#[try_from_ref] this: &Tfa) -> Result<Value, Error> {
+        let mut inner = this.inner.lock().unwrap();
+        let u2f = inner.u2f.take();
+        let webauthn = inner.webauthn.take();
+        let output = Ok(perlmod::to_value(&*inner)?);
+        inner.u2f = u2f;
+        inner.webauthn = webauthn;
+        output
+    }
+
+    /// Get a list of all the user names in this config.
+    /// PVE uses this to verify users and purge the invalid ones.
+    #[export]
+    fn users(#[try_from_ref] this: &Tfa) -> Result<Vec<String>, Error> {
+        Ok(this.inner.lock().unwrap().users.keys().cloned().collect())
+    }
+
+    /// Remove a user from the TFA configuration.
+    #[export]
+    fn remove_user(#[try_from_ref] this: &Tfa, userid: &str) -> Result<bool, Error> {
+        Ok(this.inner.lock().unwrap().users.remove(userid).is_some())
+    }
+
+    /// Get the TFA data for a specific user.
+    #[export(raw_return)]
+    fn get_user(#[try_from_ref] this: &Tfa, userid: &str) -> Result<Value, perlmod::Error> {
+        perlmod::to_value(&this.inner.lock().unwrap().users.get(userid))
+    }
+
+    /// Add a u2f registration. This modifies the config (adds the user to it), so it needs be
+    /// written out.
+    #[export]
+    fn add_u2f_registration(
+        #[raw] raw_this: Value,
+        //#[try_from_ref] this: &Tfa,
+        userid: &str,
+        description: String,
+    ) -> Result<String, Error> {
+        let this: &Tfa = (&raw_this).try_into()?;
+        let mut inner = this.inner.lock().unwrap();
+        inner.u2f_registration_challenge(UserAccess::new(&raw_this)?, userid, description)
+    }
+
+    /// Finish a u2f registration. This updates temporary data in `/run` and therefore the config
+    /// needs to be written out!
+    #[export]
+    fn finish_u2f_registration(
+        #[raw] raw_this: Value,
+        //#[try_from_ref] this: &Tfa,
+        userid: &str,
+        challenge: &str,
+        response: &str,
+    ) -> Result<String, Error> {
+        let this: &Tfa = (&raw_this).try_into()?;
+        let mut inner = this.inner.lock().unwrap();
+        inner.u2f_registration_finish(UserAccess::new(&raw_this)?, userid, challenge, response)
+    }
+
+    /// Check if a user has any TFA entries of a given type.
+    #[export]
+    fn has_type(#[try_from_ref] this: &Tfa, userid: &str, typename: &str) -> Result<bool, Error> {
+        Ok(match this.inner.lock().unwrap().users.get(userid) {
+            Some(user) => match typename {
+                "totp" | "oath" => !user.totp.is_empty(),
+                "u2f" => !user.u2f.is_empty(),
+                "webauthn" => !user.webauthn.is_empty(),
+                "yubico" => !user.yubico.is_empty(),
+                "recovery" => match &user.recovery {
+                    Some(r) => r.count_available() > 0,
+                    None => false,
+                },
+                _ => bail!("unrecognized TFA type {:?}", typename),
+            },
+            None => false,
+        })
+    }
+
+    /// Generates a space separated list of yubico keys of this account.
+    #[export]
+    fn get_yubico_keys(#[try_from_ref] this: &Tfa, userid: &str) -> Result<Option<String>, Error> {
+        Ok(this.inner.lock().unwrap().users.get(userid).map(|user| {
+            user.enabled_yubico_entries()
+                .fold(String::new(), |mut s, k| {
+                    if !s.is_empty() {
+                        s.push(' ');
+                    }
+                    s.push_str(k);
+                    s
+                })
+        }))
+    }
+
+    #[export]
+    fn set_u2f_config(#[try_from_ref] this: &Tfa, config: Option<super::U2fConfig>) {
+        this.inner.lock().unwrap().u2f = config;
+    }
+
+    #[export]
+    fn set_webauthn_config(#[try_from_ref] this: &Tfa, config: Option<super::WebauthnConfig>) {
+        this.inner.lock().unwrap().webauthn = config;
+    }
+
+    /// Create an authentication challenge.
+    ///
+    /// Returns the challenge as a json string.
+    /// Returns `undef` if no second factor is configured.
+    #[export]
+    fn authentication_challenge(
+        #[raw] raw_this: Value,
+        //#[try_from_ref] this: &Tfa,
+        userid: &str,
+    ) -> Result<Option<String>, Error> {
+        let this: &Tfa = (&raw_this).try_into()?;
+        let mut inner = this.inner.lock().unwrap();
+        match inner.authentication_challenge(UserAccess::new(&raw_this)?, userid)? {
+            Some(challenge) => Ok(Some(serde_json::to_string(&challenge)?)),
+            None => Ok(None),
+        }
+    }
+
+    /// Get the recovery state (suitable for a challenge object).
+    #[export]
+    fn recovery_state(#[try_from_ref] this: &Tfa, userid: &str) -> Option<super::RecoveryState> {
+        this.inner
+            .lock()
+            .unwrap()
+            .users
+            .get(userid)
+            .and_then(|user| {
+                let state = user.recovery_state();
+                state.is_available().then(move || state)
+            })
+    }
+
+    /// Takes the TFA challenge string (which is a json object) and verifies ther esponse against
+    /// it.
+    ///
+    /// NOTE: This returns a boolean whether the config data needs to be *saved* after this call
+    /// (to use up recovery keys!).
+    #[export]
+    fn authentication_verify(
+        #[raw] raw_this: Value,
+        //#[try_from_ref] this: &Tfa,
+        userid: &str,
+        challenge: &str, //super::TfaChallenge,
+        response: &str,
+    ) -> Result<bool, Error> {
+        let this: &Tfa = (&raw_this).try_into()?;
+        let challenge: super::TfaChallenge = serde_json::from_str(challenge)?;
+        let response: super::TfaResponse = response.parse()?;
+        let mut inner = this.inner.lock().unwrap();
+        inner
+            .verify(UserAccess::new(&raw_this)?, userid, &challenge, response)
+            .map(|save| save.needs_saving())
+    }
+
+    /// DEBUG HELPER: Get the current TOTP value for a given TOTP URI.
+    #[export]
+    fn get_current_totp_value(otp_uri: &str) -> Result<String, Error> {
+        let totp: proxmox_tfa::totp::Totp = otp_uri.parse()?;
+        Ok(totp.time(std::time::SystemTime::now())?.to_string())
+    }
+
+    #[export]
+    fn api_list_user_tfa(
+        #[try_from_ref] this: &Tfa,
+        userid: &str,
+    ) -> Result<Vec<api::TypedTfaInfo>, Error> {
+        api::list_user_tfa(&this.inner.lock().unwrap(), userid)
+    }
+
+    #[export]
+    fn api_get_tfa_entry(
+        #[try_from_ref] this: &Tfa,
+        userid: &str,
+        id: &str,
+    ) -> Result<Option<api::TypedTfaInfo>, Error> {
+        api::get_tfa_entry(&this.inner.lock().unwrap(), userid, id)
+    }
+
+    /// Returns `true` if the user still has other TFA entries left, `false` if the user has *no*
+    /// more tfa entries.
+    #[export]
+    fn api_delete_tfa(#[try_from_ref] this: &Tfa, userid: &str, id: String) -> Result<bool, Error> {
+        let mut this = this.inner.lock().unwrap();
+        match api::delete_tfa(&mut this, userid, id) {
+            Ok(has_entries_left) => Ok(has_entries_left),
+            Err(api::EntryNotFound) => bail!("no such entry"),
+        }
+    }
+
+    #[export]
+    fn api_list_tfa(
+        #[try_from_ref] this: &Tfa,
+        authid: &str,
+        top_level_allowed: bool,
+    ) -> Result<Vec<api::TfaUser>, Error> {
+        api::list_tfa(&this.inner.lock().unwrap(), authid, top_level_allowed)
+    }
+
+    #[export]
+    fn api_add_tfa_entry(
+        #[raw] raw_this: Value,
+        //#[try_from_ref] this: &Tfa,
+        userid: &str,
+        description: Option<String>,
+        totp: Option<String>,
+        value: Option<String>,
+        challenge: Option<String>,
+        ty: api::TfaType,
+    ) -> Result<api::TfaUpdateInfo, Error> {
+        let this: &Tfa = (&raw_this).try_into()?;
+        api::add_tfa_entry(
+            &mut this.inner.lock().unwrap(),
+            UserAccess::new(&raw_this)?,
+            userid,
+            description,
+            totp,
+            value,
+            challenge,
+            ty,
+        )
+    }
+
+    #[export]
+    fn api_update_tfa_entry(
+        #[try_from_ref] this: &Tfa,
+        userid: &str,
+        id: &str,
+        description: Option<String>,
+        enable: Option<bool>,
+    ) -> Result<(), Error> {
+        match api::update_tfa_entry(
+            &mut this.inner.lock().unwrap(),
+            userid,
+            id,
+            description,
+            enable,
+        ) {
+            Ok(()) => Ok(()),
+            Err(api::EntryNotFound) => bail!("no such entry"),
+        }
+    }
+}
+
+/// Version 1 format of `/etc/pve/priv/tfa.cfg`
+/// ===========================================
+///
+/// The TFA configuration in priv/tfa.cfg format contains one line per user of the form:
+///
+///     USER:TYPE:DATA
+///
+/// DATA is a base64 encoded json object and its format depends on the type.
+///
+/// TYPEs
+/// -----
+///   - oath
+///
+///     This is a TOTP entry. In PVE, 1 such entry can contain multiple secrets, provided they use
+///     the same configuration.
+///
+///     DATA: {
+///       "keys" => "string of space separated TOTP secrets",
+///       "config" => { "step", "digits" },
+///     }
+///
+///   - yubico
+///
+///     Authentication using the Yubico API.
+///
+///     DATA: {
+///       "keys" => "string list of yubico keys",
+///     }
+///
+///   - u2f
+///
+///     Legacy U2F entry for the U2F browser API.
+///
+///     DATA: {
+///       "keyHandle" => "u2f key handle",
+///       "publicKey" => "u2f public key",
+///     }
+///
+fn parse_old_config(data: &[u8]) -> Result<TfaConfig, Error> {
+    let mut config = TfaConfig::default();
+
+    for line in data.split(|&b| b == b'\n') {
+        let line = trim_ascii_whitespace(line);
+        if line.is_empty() || line.starts_with(b"#") {
+            continue;
+        }
+
+        let mut parts = line.splitn(3, |&b| b == b':');
+        let ((user, ty), data) = parts
+            .next()
+            .zip(parts.next())
+            .zip(parts.next())
+            .ok_or_else(|| format_err!("bad line in tfa config"))?;
+
+        let user = std::str::from_utf8(user)
+            .map_err(|_err| format_err!("bad non-utf8 username in tfa config"))?;
+
+        let data = base64::decode(data)
+            .map_err(|err| format_err!("failed to decode data in tfa config entry - {}", err))?;
+
+        let entry = decode_old_entry(ty, &data, user)?;
+        config.users.insert(user.to_owned(), entry);
+    }
+
+    Ok(config)
+}
+
+fn decode_old_entry(ty: &[u8], data: &[u8], user: &str) -> Result<TfaUserData, Error> {
+    let mut user_data = TfaUserData::default();
+
+    let info = proxmox_tfa_api::TfaInfo {
+        id: "v1-entry".to_string(),
+        description: "<old version 1 entry>".to_string(),
+        created: 0,
+        enable: true,
+    };
+
+    let value: JsonValue = serde_json::from_slice(data)
+        .map_err(|err| format_err!("failed to parse json data in tfa entry - {}", err))?;
+
+    match ty {
+        b"u2f" => user_data.u2f.push(proxmox_tfa_api::TfaEntry::from_parts(
+            info,
+            decode_old_u2f_entry(value)?,
+        )),
+        b"oath" => user_data.totp.extend(
+            decode_old_oath_entry(value, user)?
+                .into_iter()
+                .map(move |entry| proxmox_tfa_api::TfaEntry::from_parts(info.clone(), entry)),
+        ),
+        b"yubico" => user_data.yubico.extend(
+            decode_old_yubico_entry(value)?
+                .into_iter()
+                .map(move |entry| proxmox_tfa_api::TfaEntry::from_parts(info.clone(), entry)),
+        ),
+        other => match std::str::from_utf8(other) {
+            Ok(s) => bail!("unknown tfa.cfg entry type: {:?}", s),
+            Err(_) => bail!("unknown tfa.cfg entry type"),
+        },
+    };
+
+    Ok(user_data)
+}
+
+fn decode_old_u2f_entry(data: JsonValue) -> Result<proxmox_tfa::u2f::Registration, Error> {
+    let mut obj = match data {
+        JsonValue::Object(obj) => obj,
+        _ => bail!("bad json type for u2f registration"),
+    };
+
+    let reg = proxmox_tfa::u2f::Registration {
+        key: proxmox_tfa::u2f::RegisteredKey {
+            key_handle: base64::decode_config(
+                take_json_string(&mut obj, "keyHandle", "u2f")?,
+                base64::URL_SAFE_NO_PAD,
+            )
+            .map_err(|_| format_err!("handle in u2f entry"))?,
+            // PVE did not store this, but we only had U2F_V2 anyway...
+            version: "U2F_V2".to_string(),
+        },
+        public_key: base64::decode(take_json_string(&mut obj, "publicKey", "u2f")?)
+            .map_err(|_| format_err!("bad public key in u2f entry"))?,
+        certificate: Vec::new(),
+    };
+
+    if !obj.is_empty() {
+        bail!("invalid extra data in u2f entry");
+    }
+
+    Ok(reg)
+}
+
+fn decode_old_oath_entry(
+    data: JsonValue,
+    user: &str,
+) -> Result<Vec<proxmox_tfa::totp::Totp>, Error> {
+    let mut obj = match data {
+        JsonValue::Object(obj) => obj,
+        _ => bail!("bad json type for oath registration"),
+    };
+
+    let mut config = match obj.remove("config") {
+        Some(JsonValue::Object(obj)) => obj,
+        Some(_) => bail!("bad 'config' entry in oath tfa entry"),
+        None => bail!("missing 'config' entry in oath tfa entry"),
+    };
+
+    let mut totp = proxmox_tfa::totp::Totp::builder().account_name(user.to_owned());
+    if let Some(step) = config.remove("step") {
+        totp = totp.period(
+            usize_from_perl(step).ok_or_else(|| format_err!("bad 'step' value in oath config"))?,
+        );
+    }
+
+    if let Some(digits) = config.remove("digits") {
+        totp = totp.digits(
+            usize_from_perl(digits)
+                .and_then(|v| u8::try_from(v).ok())
+                .ok_or_else(|| format_err!("bad 'digits' value in oath config"))?,
+        );
+    }
+
+    if !config.is_empty() {
+        bail!("unhandled totp config keys in oath entry");
+    }
+
+    let mut out = Vec::new();
+
+    let keys = take_json_string(&mut obj, "keys", "oath")?;
+    for key in keys.split(|c| c == ',' || c == ';' || c == ' ') {
+        let key = trim_ascii_whitespace(key.as_bytes());
+        if key.is_empty() {
+            continue;
+        }
+
+        // key started out as a `String` and we only trimmed ASCII white space:
+        let key = unsafe { std::str::from_utf8_unchecked(key) };
+
+        // See PVE::OTP::oath_verify_otp
+        let key = if key.starts_with("v2-0x") {
+            hex::decode(&key[5..]).map_err(|_| format_err!("bad v2 hex key in oath entry"))?
+        } else if key.starts_with("v2-") {
+            base32::decode(base32::Alphabet::RFC4648 { padding: true }, &key[3..])
+                .ok_or_else(|| format_err!("bad v2 base32 key in oath entry"))?
+        } else if key.len() == 16 {
+            base32::decode(base32::Alphabet::RFC4648 { padding: true }, key)
+                .ok_or_else(|| format_err!("bad v1 base32 key in oath entry"))?
+        } else if key.len() == 40 {
+            hex::decode(key).map_err(|_| format_err!("bad v1 hex key in oath entry"))?
+        } else {
+            bail!("unrecognized key format, must be hex or base32 encoded");
+        };
+
+        out.push(totp.clone().secret(key).build());
+    }
+
+    Ok(out)
+}
+
+fn decode_old_yubico_entry(data: JsonValue) -> Result<Vec<String>, Error> {
+    let mut obj = match data {
+        JsonValue::Object(obj) => obj,
+        _ => bail!("bad json type for yubico registration"),
+    };
+
+    let mut out = Vec::new();
+
+    let keys = take_json_string(&mut obj, "keys", "yubico")?;
+    for key in keys.split(|c| c == ',' || c == ';' || c == ' ') {
+        let key = trim_ascii_whitespace(key.as_bytes());
+        if key.is_empty() {
+            continue;
+        }
+
+        // key started out as a `String` and we only trimmed ASCII white space:
+        out.push(unsafe { std::str::from_utf8_unchecked(key) }.to_owned());
+    }
+
+    Ok(out)
+}
+
+fn take_json_string(
+    data: &mut serde_json::Map<String, JsonValue>,
+    what: &'static str,
+    in_what: &'static str,
+) -> Result<String, Error> {
+    match data.remove(what) {
+        None => bail!("missing '{}' value in {} entry", what, in_what),
+        Some(JsonValue::String(s)) => Ok(s),
+        _ => bail!("bad '{}' value", what),
+    }
+}
+
+fn usize_from_perl(value: JsonValue) -> Option<usize> {
+    // we come from perl, numbers are strings!
+    match value {
+        JsonValue::Number(n) => n.as_u64().and_then(|n| usize::try_from(n).ok()),
+        JsonValue::String(s) => s.parse().ok(),
+        _ => None,
+    }
+}
+
+fn trim_ascii_whitespace_start(data: &[u8]) -> &[u8] {
+    match data.iter().position(|&c| !c.is_ascii_whitespace()) {
+        Some(from) => &data[from..],
+        None => &data[..],
+    }
+}
+
+fn trim_ascii_whitespace_end(data: &[u8]) -> &[u8] {
+    match data.iter().rposition(|&c| !c.is_ascii_whitespace()) {
+        Some(to) => &data[..to],
+        None => data,
+    }
+}
+
+fn trim_ascii_whitespace(data: &[u8]) -> &[u8] {
+    trim_ascii_whitespace_start(trim_ascii_whitespace_end(data))
+}
+
+fn create_legacy_data(data: &TfaUserData) -> bool {
+    if !data.webauthn.is_empty() || data.recovery.is_some() || data.u2f.len() > 1 {
+        // incompatible
+        return false;
+    }
+
+    if data.u2f.is_empty() && data.totp.is_empty() && data.yubico.is_empty() {
+        // no tfa configured
+        return false;
+    }
+
+    if let Some(totp) = data.totp.get(0) {
+        let algorithm = totp.entry.algorithm();
+        let digits = totp.entry.digits();
+        let period = totp.entry.period();
+        if period.subsec_nanos() != 0 {
+            return false;
+        }
+
+        for totp in data.totp.iter().skip(1) {
+            if totp.entry.algorithm() != algorithm
+                || totp.entry.digits() != digits
+                || totp.entry.period() != period
+            {
+                return false;
+            }
+        }
+    }
+    return true;
+}
+
+fn b64u_np_encode<T: AsRef<[u8]>>(data: T) -> String {
+    base64::encode_config(data.as_ref(), base64::URL_SAFE_NO_PAD)
+}
+
+// fn b64u_np_decode<T: AsRef<[u8]>>(data: T) -> Result<Vec<u8>, base64::DecodeError> {
+//     base64::decode_config(data.as_ref(), base64::URL_SAFE_NO_PAD)
+// }
+
+fn generate_legacy_config(out: &mut perlmod::Hash, config: &TfaConfig) {
+    use perlmod::{Hash, Value};
+
+    let users = Hash::new();
+
+    for (user, data) in &config.users {
+        if !create_legacy_data(data) {
+            continue;
+        }
+
+        if let Some(u2f) = data.u2f.get(0) {
+            let data = Hash::new();
+            data.insert(
+                "publicKey",
+                Value::new_string(&base64::encode(&u2f.entry.public_key)),
+            );
+            data.insert(
+                "keyHandle",
+                Value::new_string(&b64u_np_encode(&u2f.entry.key.key_handle)),
+            );
+            let data = Value::new_ref(&data);
+
+            let entry = Hash::new();
+            entry.insert("type", Value::new_string("u2f"));
+            entry.insert("data", data);
+            users.insert(user, Value::new_ref(&entry));
+            continue;
+        }
+
+        if let Some(totp) = data.totp.get(0) {
+            let totp = &totp.entry;
+            let config = Hash::new();
+            config.insert("digits", Value::new_int(isize::from(totp.digits())));
+            config.insert("step", Value::new_int(totp.period().as_secs() as isize));
+
+            let mut keys = format!("v2-0x{}", hex::encode(totp.secret()));
+            for totp in data.totp.iter().skip(1) {
+                keys.push_str(" v2-0x");
+                keys.push_str(&hex::encode(totp.entry.secret()));
+            }
+
+            let data = Hash::new();
+            data.insert("config", Value::new_ref(&config));
+            data.insert("keys", Value::new_string(&keys));
+
+            let entry = Hash::new();
+            entry.insert("type", Value::new_string("oath"));
+            entry.insert("data", Value::new_ref(&data));
+            users.insert(user, Value::new_ref(&entry));
+            continue;
+        }
+
+        if let Some(entry) = data.yubico.get(0) {
+            let mut keys = entry.entry.clone();
+
+            for entry in data.yubico.iter().skip(1) {
+                keys.push(' ');
+                keys.push_str(&entry.entry);
+            }
+
+            let data = Hash::new();
+            data.insert("keys", Value::new_string(&keys));
+
+            let entry = Hash::new();
+            entry.insert("type", Value::new_string("yubico"));
+            entry.insert("data", Value::new_ref(&data));
+            users.insert(user, Value::new_ref(&entry));
+            continue;
+        }
+    }
+
+    out.insert("users", Value::new_ref(&users));
+}
+
+/// Attach the path to errors from [`nix::mkir()`].
+pub(crate) fn mkdir<P: AsRef<Path>>(path: P, mode: libc::mode_t) -> Result<(), Error> {
+    let path = path.as_ref();
+    match nix::unistd::mkdir(path, unsafe { Mode::from_bits_unchecked(mode) }) {
+        Ok(()) => Ok(()),
+        Err(nix::Error::Sys(Errno::EEXIST)) => Ok(()),
+        Err(err) => bail!("failed to create directory {:?}: {}", path, err),
+    }
+}
+
+#[cfg(debug_assertions)]
+#[derive(Clone)]
+#[repr(transparent)]
+pub struct UserAccess(perlmod::Value);
+
+#[cfg(debug_assertions)]
+impl UserAccess {
+    #[inline]
+    fn new(value: &perlmod::Value) -> Result<Self, Error> {
+        value
+            .dereference()
+            .ok_or_else(|| format_err!("bad TFA config object"))
+            .map(Self)
+    }
+
+    #[inline]
+    fn is_debug(&self) -> bool {
+        self.0
+            .as_hash()
+            .and_then(|v| v.get("-debug"))
+            .map(|v| v.iv() != 0)
+            .unwrap_or(false)
+    }
+}
+
+#[cfg(not(debug_assertions))]
+#[derive(Clone, Copy)]
+#[repr(transparent)]
+pub struct UserAccess;
+
+#[cfg(not(debug_assertions))]
+impl UserAccess {
+    #[inline]
+    const fn new(_value: &perlmod::Value) -> Result<Self, std::convert::Infallible> {
+        Ok(Self)
+    }
+
+    #[inline]
+    const fn is_debug(&self) -> bool {
+        false
+    }
+}
+
+/// Build the path to the challenge data file for a user.
+fn challenge_data_path(userid: &str, debug: bool) -> PathBuf {
+    if debug {
+        PathBuf::from(format!("./local-tfa-challenges/{}", userid))
+    } else {
+        PathBuf::from(format!("/run/pve-private/tfa-challenges/{}", userid))
+    }
+}
+
+impl proxmox_tfa_api::OpenUserChallengeData for UserAccess {
+    type Data = UserChallengeData;
+
+    fn open(&self, userid: &str) -> Result<UserChallengeData, Error> {
+        if self.is_debug() {
+            mkdir("./local-tfa-challenges", 0o700)?;
+        } else {
+            mkdir("/run/pve-private", 0o700)?;
+            mkdir("/run/pve-private/tfa-challenges", 0o700)?;
+        }
+
+        let path = challenge_data_path(userid, self.is_debug());
+
+        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))?;
+
+        UserChallengeData::lock_file(file.as_raw_fd())?;
+
+        // 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(UserChallengeData {
+            inner,
+            path,
+            lock: file,
+        })
+    }
+
+    /// `open` without creating the file if it doesn't exist, to finish WA authentications.
+    fn open_no_create(&self, userid: &str) -> Result<Option<UserChallengeData>, Error> {
+        let path = challenge_data_path(userid, self.is_debug());
+
+        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()),
+        };
+
+        UserChallengeData::lock_file(file.as_raw_fd())?;
+
+        let inner = serde_json::from_reader(&mut file).map_err(|err| {
+            format_err!("failed to read challenge data for user {}: {}", userid, err)
+        })?;
+
+        Ok(Some(UserChallengeData {
+            inner,
+            path,
+            lock: file,
+        }))
+    }
+}
+
+/// Container of `TfaUserChallenges` with the corresponding file lock guard.
+///
+/// Basically provides the TFA API to the REST server by persisting, updating and verifying active
+/// challenges.
+pub struct UserChallengeData {
+    inner: proxmox_tfa_api::TfaUserChallenges,
+    path: PathBuf,
+    lock: File,
+}
+
+impl proxmox_tfa_api::UserChallengeAccess for UserChallengeData {
+    fn get_mut(&mut self) -> &mut proxmox_tfa_api::TfaUserChallenges {
+        &mut self.inner
+    }
+
+    fn save(self) -> Result<(), Error> {
+        UserChallengeData::save(self)
+    }
+}
+
+impl UserChallengeData {
+    fn lock_file(fd: RawFd) -> Result<(), Error> {
+        let rc = unsafe { libc::flock(fd, libc::LOCK_EX) };
+
+        if rc != 0 {
+            let err = io::Error::last_os_error();
+            bail!("failed to lock tfa user challenge data: {}", err);
+        }
+
+        Ok(())
+    }
+
+    /// Rewind & truncate the file for an update.
+    fn rewind(&mut self) -> Result<(), Error> {
+        use std::io::{Seek, SeekFrom};
+
+        let pos = self.lock.seek(SeekFrom::Start(0))?;
+        if pos != 0 {
+            bail!(
+                "unexpected result trying to rewind file, position is {}",
+                pos
+            );
+        }
+
+        let rc = unsafe { libc::ftruncate(self.lock.as_raw_fd(), 0) };
+        if rc != 0 {
+            let err = io::Error::last_os_error();
+            bail!("failed to truncate challenge data: {}", err);
+        }
+
+        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(())
+    }
+}
diff --git a/pve-rs/src/tfa/proxmox_tfa_api/api.rs b/pve-rs/src/tfa/proxmox_tfa_api/api.rs
new file mode 100644
index 0000000..6be5205
--- /dev/null
+++ b/pve-rs/src/tfa/proxmox_tfa_api/api.rs
@@ -0,0 +1,487 @@
+//! API interaction module.
+//!
+//! This defines the methods & types used in the authentication and TFA configuration API between
+//! PBS, PVE, PMG.
+
+use anyhow::{bail, format_err, Error};
+use serde::{Deserialize, Serialize};
+
+use proxmox_tfa::totp::Totp;
+
+#[cfg(feature = "api-types")]
+use proxmox_schema::api;
+
+use super::{OpenUserChallengeData, TfaConfig, TfaInfo, TfaUserData};
+
+#[cfg_attr(feature = "api-types", api)]
+/// A TFA entry type.
+#[derive(Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum TfaType {
+    /// A TOTP entry type.
+    Totp,
+    /// A U2F token entry.
+    U2f,
+    /// A Webauthn token entry.
+    Webauthn,
+    /// Recovery tokens.
+    Recovery,
+    /// Yubico authentication entry.
+    Yubico,
+}
+
+#[cfg_attr(feature = "api-types", api(
+    properties: {
+        type: { type: TfaType },
+        info: { type: TfaInfo },
+    },
+))]
+/// A TFA entry for a user.
+#[derive(Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct TypedTfaInfo {
+    #[serde(rename = "type")]
+    pub ty: TfaType,
+
+    #[serde(flatten)]
+    pub info: TfaInfo,
+}
+
+fn to_data(data: &TfaUserData) -> Vec<TypedTfaInfo> {
+    let mut out = Vec::with_capacity(
+        data.totp.len()
+            + data.u2f.len()
+            + data.webauthn.len()
+            + data.yubico.len()
+            + if data.recovery().is_some() { 1 } else { 0 },
+    );
+    if let Some(recovery) = data.recovery() {
+        out.push(TypedTfaInfo {
+            ty: TfaType::Recovery,
+            info: TfaInfo::recovery(recovery.created),
+        })
+    }
+    for entry in &data.totp {
+        out.push(TypedTfaInfo {
+            ty: TfaType::Totp,
+            info: entry.info.clone(),
+        });
+    }
+    for entry in &data.webauthn {
+        out.push(TypedTfaInfo {
+            ty: TfaType::Webauthn,
+            info: entry.info.clone(),
+        });
+    }
+    for entry in &data.u2f {
+        out.push(TypedTfaInfo {
+            ty: TfaType::U2f,
+            info: entry.info.clone(),
+        });
+    }
+    for entry in &data.yubico {
+        out.push(TypedTfaInfo {
+            ty: TfaType::Yubico,
+            info: entry.info.clone(),
+        });
+    }
+    out
+}
+
+/// Iterate through tuples of `(type, index, id)`.
+fn tfa_id_iter(data: &TfaUserData) -> impl Iterator<Item = (TfaType, usize, &str)> {
+    data.totp
+        .iter()
+        .enumerate()
+        .map(|(i, entry)| (TfaType::Totp, i, entry.info.id.as_str()))
+        .chain(
+            data.webauthn
+                .iter()
+                .enumerate()
+                .map(|(i, entry)| (TfaType::Webauthn, i, entry.info.id.as_str())),
+        )
+        .chain(
+            data.u2f
+                .iter()
+                .enumerate()
+                .map(|(i, entry)| (TfaType::U2f, i, entry.info.id.as_str())),
+        )
+        .chain(
+            data.yubico
+                .iter()
+                .enumerate()
+                .map(|(i, entry)| (TfaType::Yubico, i, entry.info.id.as_str())),
+        )
+        .chain(
+            data.recovery
+                .iter()
+                .map(|_| (TfaType::Recovery, 0, "recovery")),
+        )
+}
+
+/// API call implementation for `GET /access/tfa/{userid}`
+///
+/// Permissions for accessing `userid` must have been verified by the caller.
+pub fn list_user_tfa(config: &TfaConfig, userid: &str) -> Result<Vec<TypedTfaInfo>, Error> {
+    Ok(match config.users.get(userid) {
+        Some(data) => to_data(data),
+        None => Vec::new(),
+    })
+}
+
+/// API call implementation for `GET /access/tfa/{userid}/{ID}`.
+///
+/// Permissions for accessing `userid` must have been verified by the caller.
+///
+/// In case this returns `None` a `NOT_FOUND` http error should be returned.
+pub fn get_tfa_entry(
+    config: &TfaConfig,
+    userid: &str,
+    id: &str,
+) -> Result<Option<TypedTfaInfo>, Error> {
+    let user_data = match config.users.get(userid) {
+        Some(u) => u,
+        None => return Ok(None),
+    };
+
+    Ok(Some(
+        match {
+            // scope to prevent the temporary iter from borrowing across the whole match
+            let entry = tfa_id_iter(&user_data).find(|(_ty, _index, entry_id)| id == *entry_id);
+            entry.map(|(ty, index, _)| (ty, index))
+        } {
+            Some((TfaType::Recovery, _)) => match user_data.recovery() {
+                Some(recovery) => TypedTfaInfo {
+                    ty: TfaType::Recovery,
+                    info: TfaInfo::recovery(recovery.created),
+                },
+                None => return Ok(None),
+            },
+            Some((TfaType::Totp, index)) => {
+                TypedTfaInfo {
+                    ty: TfaType::Totp,
+                    // `into_iter().nth()` to *move* out of it
+                    info: user_data.totp.iter().nth(index).unwrap().info.clone(),
+                }
+            }
+            Some((TfaType::Webauthn, index)) => TypedTfaInfo {
+                ty: TfaType::Webauthn,
+                info: user_data.webauthn.iter().nth(index).unwrap().info.clone(),
+            },
+            Some((TfaType::U2f, index)) => TypedTfaInfo {
+                ty: TfaType::U2f,
+                info: user_data.u2f.iter().nth(index).unwrap().info.clone(),
+            },
+            Some((TfaType::Yubico, index)) => TypedTfaInfo {
+                ty: TfaType::Yubico,
+                info: user_data.yubico.iter().nth(index).unwrap().info.clone(),
+            },
+            None => return Ok(None),
+        },
+    ))
+}
+
+pub struct EntryNotFound;
+
+/// API call implementation for `DELETE /access/tfa/{userid}/{ID}`.
+///
+/// The caller must have already verified the user's password.
+///
+/// The TFA config must be WRITE locked.
+///
+/// The caller must *save* the config afterwards!
+///
+/// Errors only if the entry was not found.
+///
+/// Returns `true` if the user still has other TFA entries left, `false` if the user has *no* more
+/// tfa entries.
+pub fn delete_tfa(config: &mut TfaConfig, userid: &str, id: String) -> Result<bool, EntryNotFound> {
+    let user_data = config.users.get_mut(userid).ok_or(EntryNotFound)?;
+
+    match {
+        // scope to prevent the temporary iter from borrowing across the whole match
+        let entry = tfa_id_iter(&user_data).find(|(_, _, entry_id)| id == *entry_id);
+        entry.map(|(ty, index, _)| (ty, index))
+    } {
+        Some((TfaType::Recovery, _)) => user_data.recovery = None,
+        Some((TfaType::Totp, index)) => drop(user_data.totp.remove(index)),
+        Some((TfaType::Webauthn, index)) => drop(user_data.webauthn.remove(index)),
+        Some((TfaType::U2f, index)) => drop(user_data.u2f.remove(index)),
+        Some((TfaType::Yubico, index)) => drop(user_data.yubico.remove(index)),
+        None => return Err(EntryNotFound),
+    }
+
+    if user_data.is_empty() {
+        config.users.remove(userid);
+        Ok(false)
+    } else {
+        Ok(true)
+    }
+}
+
+#[cfg_attr(feature = "api-types", api(
+    properties: {
+        "userid": { type: Userid },
+        "entries": {
+            type: Array,
+            items: { type: TypedTfaInfo },
+        },
+    },
+))]
+#[derive(Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+/// Over the API we only provide the descriptions for TFA data.
+pub struct TfaUser {
+    /// The user this entry belongs to.
+    userid: String,
+
+    /// TFA entries.
+    entries: Vec<TypedTfaInfo>,
+}
+
+/// API call implementation for `GET /access/tfa`.
+///
+/// Caller needs to have performed the required privilege checks already.
+pub fn list_tfa(
+    config: &TfaConfig,
+    authid: &str,
+    top_level_allowed: bool,
+) -> Result<Vec<TfaUser>, Error> {
+    let tfa_data = &config.users;
+
+    let mut out = Vec::<TfaUser>::new();
+    if top_level_allowed {
+        for (user, data) in tfa_data {
+            out.push(TfaUser {
+                userid: user.clone(),
+                entries: to_data(data),
+            });
+        }
+    } else if let Some(data) = { tfa_data }.get(authid) {
+        out.push(TfaUser {
+            userid: authid.into(),
+            entries: to_data(data),
+        });
+    }
+
+    Ok(out)
+}
+
+#[cfg_attr(feature = "api-types", api(
+    properties: {
+        recovery: {
+            description: "A list of recovery codes as integers.",
+            type: Array,
+            items: {
+                type: Integer,
+                description: "A one-time usable recovery code entry.",
+            },
+        },
+    },
+))]
+/// The result returned when adding TFA entries to a user.
+#[derive(Default, Serialize)]
+pub struct TfaUpdateInfo {
+    /// The id if a newly added TFA entry.
+    id: Option<String>,
+
+    /// When adding u2f entries, this contains a challenge the user must respond to in order to
+    /// finish the registration.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    challenge: Option<String>,
+
+    /// When adding recovery codes, this contains the list of codes to be displayed to the user
+    /// this one time.
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    recovery: Vec<String>,
+}
+
+impl TfaUpdateInfo {
+    fn id(id: String) -> Self {
+        Self {
+            id: Some(id),
+            ..Default::default()
+        }
+    }
+}
+
+fn need_description(description: Option<String>) -> Result<String, Error> {
+    description.ok_or_else(|| format_err!("'description' is required for new entries"))
+}
+
+/// API call implementation for `POST /access/tfa/{userid}`.
+///
+/// Permissions for accessing `userid` must have been verified by the caller.
+///
+/// The caller must have already verified the user's password!
+pub fn add_tfa_entry<A: OpenUserChallengeData>(
+    config: &mut TfaConfig,
+    access: A,
+    userid: &str,
+    description: Option<String>,
+    totp: Option<String>,
+    value: Option<String>,
+    challenge: Option<String>,
+    r#type: TfaType,
+) -> Result<TfaUpdateInfo, Error> {
+    match r#type {
+        TfaType::Totp => {
+            if challenge.is_some() {
+                bail!("'challenge' parameter is invalid for 'totp' entries");
+            }
+
+            add_totp(config, userid, need_description(description)?, totp, value)
+        }
+        TfaType::Webauthn => {
+            if totp.is_some() {
+                bail!("'totp' parameter is invalid for 'webauthn' entries");
+            }
+
+            add_webauthn(config, access, userid, description, challenge, value)
+        }
+        TfaType::U2f => {
+            if totp.is_some() {
+                bail!("'totp' parameter is invalid for 'u2f' entries");
+            }
+
+            add_u2f(config, access, userid, description, challenge, value)
+        }
+        TfaType::Recovery => {
+            if totp.or(value).or(challenge).is_some() {
+                bail!("generating recovery tokens does not allow additional parameters");
+            }
+
+            let recovery = config.add_recovery(&userid)?;
+
+            Ok(TfaUpdateInfo {
+                id: Some("recovery".to_string()),
+                recovery,
+                ..Default::default()
+            })
+        }
+        TfaType::Yubico => {
+            if totp.or(challenge).is_some() {
+                bail!("'totp' and 'challenge' parameters are invalid for 'yubico' entries");
+            }
+
+            add_yubico(config, userid, need_description(description)?, value)
+        }
+    }
+}
+
+fn add_totp(
+    config: &mut TfaConfig,
+    userid: &str,
+    description: String,
+    totp: Option<String>,
+    value: Option<String>,
+) -> Result<TfaUpdateInfo, Error> {
+    let (totp, value) = match (totp, value) {
+        (Some(totp), Some(value)) => (totp, value),
+        _ => bail!("'totp' type requires both 'totp' and 'value' parameters"),
+    };
+
+    let totp: Totp = totp.parse()?;
+    if totp
+        .verify(&value, std::time::SystemTime::now(), -1..=1)?
+        .is_none()
+    {
+        bail!("failed to verify TOTP challenge");
+    }
+    config
+        .add_totp(userid, description, totp)
+        .map(TfaUpdateInfo::id)
+}
+
+fn add_yubico(
+    config: &mut TfaConfig,
+    userid: &str,
+    description: String,
+    value: Option<String>,
+) -> Result<TfaUpdateInfo, Error> {
+    let key = value.ok_or_else(|| format_err!("missing 'value' parameter for 'yubico' entry"))?;
+    config
+        .add_yubico(userid, description, key)
+        .map(TfaUpdateInfo::id)
+}
+
+fn add_u2f<A: OpenUserChallengeData>(
+    config: &mut TfaConfig,
+    access: A,
+    userid: &str,
+    description: Option<String>,
+    challenge: Option<String>,
+    value: Option<String>,
+) -> Result<TfaUpdateInfo, Error> {
+    match challenge {
+        None => config
+            .u2f_registration_challenge(access, userid, need_description(description)?)
+            .map(|c| TfaUpdateInfo {
+                challenge: Some(c),
+                ..Default::default()
+            }),
+        Some(challenge) => {
+            let value = value.ok_or_else(|| {
+                format_err!("missing 'value' parameter (u2f challenge response missing)")
+            })?;
+            config
+                .u2f_registration_finish(access, userid, &challenge, &value)
+                .map(TfaUpdateInfo::id)
+        }
+    }
+}
+
+fn add_webauthn<A: OpenUserChallengeData>(
+    config: &mut TfaConfig,
+    access: A,
+    userid: &str,
+    description: Option<String>,
+    challenge: Option<String>,
+    value: Option<String>,
+) -> Result<TfaUpdateInfo, Error> {
+    match challenge {
+        None => config
+            .webauthn_registration_challenge(access, &userid, need_description(description)?)
+            .map(|c| TfaUpdateInfo {
+                challenge: Some(c),
+                ..Default::default()
+            }),
+        Some(challenge) => {
+            let value = value.ok_or_else(|| {
+                format_err!("missing 'value' parameter (webauthn challenge response missing)")
+            })?;
+            config
+                .webauthn_registration_finish(access, &userid, &challenge, &value)
+                .map(TfaUpdateInfo::id)
+        }
+    }
+}
+
+/// API call implementation for `PUT /access/tfa/{userid}/{id}`.
+///
+/// The caller must have already verified the user's password.
+///
+/// Errors only if the entry was not found.
+pub fn update_tfa_entry(
+    config: &mut TfaConfig,
+    userid: &str,
+    id: &str,
+    description: Option<String>,
+    enable: Option<bool>,
+) -> Result<(), EntryNotFound> {
+    let mut entry = config
+        .users
+        .get_mut(userid)
+        .and_then(|user| user.find_entry_mut(id))
+        .ok_or(EntryNotFound)?;
+
+    if let Some(description) = description {
+        entry.description = description;
+    }
+
+    if let Some(enable) = enable {
+        entry.enable = enable;
+    }
+
+    Ok(())
+}
diff --git a/pve-rs/src/tfa/proxmox_tfa_api/mod.rs b/pve-rs/src/tfa/proxmox_tfa_api/mod.rs
new file mode 100644
index 0000000..bd5ab27
--- /dev/null
+++ b/pve-rs/src/tfa/proxmox_tfa_api/mod.rs
@@ -0,0 +1,1003 @@
+//! TFA configuration and user data.
+//!
+//! This is the same as used in PBS but without the `#[api]` type.
+//!
+//! We may want to move this into a shared crate making the `#[api]` macro feature-gated!
+
+use std::collections::HashMap;
+
+use anyhow::{bail, format_err, Error};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use webauthn_rs::proto::Credential as WebauthnCredential;
+use webauthn_rs::{proto::UserVerificationPolicy, Webauthn};
+
+use proxmox_tfa::totp::Totp;
+use proxmox_uuid::Uuid;
+
+#[cfg(feature = "api-types")]
+use proxmox_schema::api;
+
+mod serde_tools;
+
+mod recovery;
+mod u2f;
+mod webauthn;
+
+pub mod api;
+
+pub use recovery::RecoveryState;
+pub use u2f::U2fConfig;
+pub use webauthn::WebauthnConfig;
+
+use recovery::Recovery;
+use u2f::{U2fChallenge, U2fChallengeEntry, U2fRegistrationChallenge};
+use webauthn::{WebauthnAuthChallenge, WebauthnRegistrationChallenge};
+
+trait IsExpired {
+    fn is_expired(&self, at_epoch: i64) -> bool;
+}
+
+pub trait OpenUserChallengeData: Clone {
+    type Data: UserChallengeAccess;
+
+    fn open(&self, userid: &str) -> Result<Self::Data, Error>;
+    fn open_no_create(&self, userid: &str) -> Result<Option<Self::Data>, Error>;
+}
+
+pub trait UserChallengeAccess: Sized {
+    //fn open(userid: &str) -> Result<Self, Error>;
+    //fn open_no_create(userid: &str) -> Result<Option<Self>, Error>;
+    fn get_mut(&mut self) -> &mut TfaUserChallenges;
+    fn save(self) -> Result<(), Error>;
+}
+
+const CHALLENGE_TIMEOUT_SECS: i64 = 2 * 60;
+
+/// TFA Configuration for this instance.
+#[derive(Clone, 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,
+}
+
+/// 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()))
+}
+
+/// 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"))
+}
+
+/// 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)
+}
+
+/// 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"))
+}
+
+impl TfaConfig {
+    // Get a u2f registration challenge.
+    pub fn u2f_registration_challenge<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        userid: &str,
+        description: String,
+    ) -> Result<String, Error> {
+        let u2f = check_u2f(&self.u2f)?;
+
+        self.users
+            .entry(userid.to_owned())
+            .or_default()
+            .u2f_registration_challenge(access, userid, &u2f, description)
+    }
+
+    /// Finish a u2f registration challenge.
+    pub fn u2f_registration_finish<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        userid: &str,
+        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(access, userid, &u2f, challenge, response),
+            None => bail!("no such challenge"),
+        }
+    }
+
+    /// Get a webauthn registration challenge.
+    fn webauthn_registration_challenge<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        user: &str,
+        description: String,
+    ) -> Result<String, Error> {
+        let webauthn = check_webauthn(&self.webauthn)?;
+
+        self.users
+            .entry(user.to_owned())
+            .or_default()
+            .webauthn_registration_challenge(access, webauthn, user, description)
+    }
+
+    /// Finish a webauthn registration challenge.
+    fn webauthn_registration_finish<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        userid: &str,
+        challenge: &str,
+        response: &str,
+    ) -> Result<String, Error> {
+        let webauthn = check_webauthn(&self.webauthn)?;
+
+        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(access, webauthn, userid, challenge, response)
+            }
+            None => bail!("no such challenge"),
+        }
+    }
+
+    /// Add a TOTP entry for a user.
+    ///
+    /// Unlike U2F/WA, this does not require a challenge/response. The user can choose their secret
+    /// themselves.
+    pub fn add_totp(
+        &mut self,
+        userid: &str,
+        description: String,
+        value: Totp,
+    ) -> Result<String, Error> {
+        Ok(self
+            .users
+            .entry(userid.to_owned())
+            .or_default()
+            .add_totp(description, value))
+    }
+
+    /// Add a Yubico key to a user.
+    ///
+    /// Unlike U2F/WA, this does not require a challenge/response. The user can choose their secret
+    /// themselves.
+    pub fn add_yubico(
+        &mut self,
+        userid: &str,
+        description: String,
+        key: String,
+    ) -> Result<String, Error> {
+        Ok(self
+            .users
+            .entry(userid.to_owned())
+            .or_default()
+            .add_yubico(description, key))
+    }
+
+    /// Add a new set of recovery keys. There can only be 1 set of keys at a time.
+    fn add_recovery(&mut self, userid: &str) -> Result<Vec<String>, Error> {
+        self.users
+            .entry(userid.to_owned())
+            .or_default()
+            .add_recovery()
+    }
+
+    /// Get a two factor authentication challenge for a user, if the user has TFA set up.
+    pub fn authentication_challenge<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        userid: &str,
+    ) -> Result<Option<TfaChallenge>, Error> {
+        match self.users.get_mut(userid) {
+            Some(udata) => udata.challenge(
+                access,
+                userid,
+                get_webauthn(&self.webauthn),
+                get_u2f(&self.u2f).as_ref(),
+            ),
+            None => Ok(None),
+        }
+    }
+
+    /// Verify a TFA challenge.
+    pub fn verify<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        userid: &str,
+        challenge: &TfaChallenge,
+        response: TfaResponse,
+    ) -> Result<NeedsSaving, 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 = check_u2f(&self.u2f)?;
+                        user.verify_u2f(access.clone(), userid, u2f, &challenge.challenge, value)
+                    }
+                    None => bail!("no u2f factor available for user '{}'", userid),
+                },
+                TfaResponse::Webauthn(value) => {
+                    let webauthn = check_webauthn(&self.webauthn)?;
+                    user.verify_webauthn(access.clone(), userid, webauthn, value)
+                }
+                TfaResponse::Recovery(value) => {
+                    user.verify_recovery(&value)?;
+                    return Ok(NeedsSaving::Yes);
+                }
+            },
+            None => bail!("no 2nd factor available for user '{}'", userid),
+        }?;
+
+        Ok(NeedsSaving::No)
+    }
+}
+
+#[must_use = "must save the config in order to ensure one-time use of recovery keys"]
+#[derive(Clone, Copy)]
+pub enum NeedsSaving {
+    No,
+    Yes,
+}
+
+impl NeedsSaving {
+    /// Convenience method so we don't need to import the type name.
+    pub fn needs_saving(self) -> bool {
+        matches!(self, NeedsSaving::Yes)
+    }
+}
+
+/// Mapping of userid to TFA entry.
+pub type TfaUsers = HashMap<String, TfaUserData>;
+
+/// TFA data for a user.
+#[derive(Clone, Default, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+#[serde(rename_all = "kebab-case")]
+#[serde(bound(deserialize = "", serialize = ""))]
+pub struct TfaUserData {
+    /// Totp keys for a user.
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    pub totp: Vec<TfaEntry<Totp>>,
+
+    /// Registered u2f tokens for a user.
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    pub u2f: Vec<TfaEntry<u2f::Registration>>,
+
+    /// Registered webauthn tokens for a user.
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    pub webauthn: Vec<TfaEntry<WebauthnCredential>>,
+
+    /// Recovery keys. (Unordered OTP values).
+    #[serde(skip_serializing_if = "Recovery::option_is_empty", default)]
+    pub recovery: Option<Recovery>,
+
+    /// Yubico keys for a user. NOTE: This is not directly supported currently, we just need this
+    /// available for PVE, where the yubico API server configuration is part if the realm.
+    #[serde(skip_serializing_if = "Vec::is_empty", default)]
+    pub yubico: Vec<TfaEntry<String>>,
+}
+
+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.webauthn.is_empty()
+            && self.yubico.is_empty()
+            && self.recovery().is_none()
+    }
+
+    /// Find an entry by id, except for the "recovery" entry which we're currently treating
+    /// specially.
+    pub fn find_entry_mut<'a>(&'a mut self, id: &str) -> Option<&'a mut TfaInfo> {
+        for entry in &mut self.totp {
+            if entry.info.id == id {
+                return Some(&mut entry.info);
+            }
+        }
+
+        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);
+            }
+        }
+
+        for entry in &mut self.yubico {
+            if entry.info.id == id {
+                return Some(&mut entry.info);
+            }
+        }
+
+        None
+    }
+
+    /// Create a u2f 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 u2f_registration_challenge<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        userid: &str,
+        u2f: &u2f::U2f,
+        description: String,
+    ) -> Result<String, Error> {
+        let challenge = serde_json::to_string(&u2f.registration_challenge()?)?;
+
+        let mut data = access.open(userid)?;
+        data.get_mut()
+            .u2f_registrations
+            .push(U2fRegistrationChallenge::new(
+                challenge.clone(),
+                description,
+            ));
+        data.save()?;
+
+        Ok(challenge)
+    }
+
+    fn u2f_registration_finish<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        userid: &str,
+        u2f: &u2f::U2f,
+        challenge: &str,
+        response: &str,
+    ) -> Result<String, Error> {
+        let mut data = access.open(userid)?;
+        let entry = data
+            .get_mut()
+            .u2f_registration_finish(u2f, challenge, response)?;
+        data.save()?;
+
+        let id = entry.info.id.clone();
+        self.u2f.push(entry);
+        Ok(id)
+    }
+
+    /// 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<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        mut webauthn: Webauthn<WebauthnConfig>,
+        userid: &str,
+        description: String,
+    ) -> Result<String, Error> {
+        let cred_ids: Vec<_> = self
+            .enabled_webauthn_entries()
+            .map(|cred| cred.cred_id.clone())
+            .collect();
+
+        let (challenge, state) = webauthn.generate_challenge_register_options(
+            userid.as_bytes().to_vec(),
+            userid.to_owned(),
+            userid.to_owned(),
+            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 = access.open(userid)?;
+        data.get_mut()
+            .webauthn_registrations
+            .push(WebauthnRegistrationChallenge::new(
+                state,
+                challenge_string,
+                description,
+            ));
+        data.save()?;
+
+        Ok(challenge)
+    }
+
+    /// 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<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        webauthn: Webauthn<WebauthnConfig>,
+        userid: &str,
+        challenge: &str,
+        response: webauthn_rs::proto::RegisterPublicKeyCredential,
+    ) -> Result<String, Error> {
+        let mut data = access.open(userid)?;
+        let entry = data.get_mut().webauthn_registration_finish(
+            webauthn,
+            challenge,
+            response,
+            &self.webauthn,
+        )?;
+        data.save()?;
+
+        let id = entry.info.id.clone();
+        self.webauthn.push(entry);
+        Ok(id)
+    }
+
+    fn add_totp(&mut self, description: String, totp: Totp) -> String {
+        let entry = TfaEntry::new(description, totp);
+        let id = entry.info.id.clone();
+        self.totp.push(entry);
+        id
+    }
+
+    fn add_yubico(&mut self, description: String, key: String) -> String {
+        let entry = TfaEntry::new(description, key);
+        let id = entry.info.id.clone();
+        self.yubico.push(entry);
+        id
+    }
+
+    /// 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_some() {
+            bail!("user already has recovery keys");
+        }
+
+        let (recovery, original) = Recovery::generate()?;
+
+        self.recovery = Some(recovery);
+
+        Ok(original)
+    }
+
+    /// Helper to iterate over enabled totp entries.
+    fn enabled_totp_entries(&self) -> impl Iterator<Item = &Totp> {
+        self.totp
+            .iter()
+            .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 })
+    }
+
+    /// 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 })
+    }
+
+    /// Helper to iterate over enabled yubico entries.
+    pub fn enabled_yubico_entries(&self) -> impl Iterator<Item = &str> {
+        self.yubico.iter().filter_map(|e| {
+            if e.info.enable {
+                Some(e.entry.as_str())
+            } else {
+                None
+            }
+        })
+    }
+
+    /// 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();
+
+        for entry in self.enabled_totp_entries() {
+            if entry.verify(value, now, -1..=1)?.is_some() {
+                return Ok(());
+            }
+        }
+
+        bail!("totp verification failed");
+    }
+
+    /// Generate a generic TFA challenge. See the [`TfaChallenge`] description for details.
+    pub fn challenge<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        userid: &str,
+        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(&self.recovery),
+            webauthn: match webauthn {
+                Some(webauthn) => self.webauthn_challenge(access.clone(), userid, webauthn)?,
+                None => None,
+            },
+            u2f: match u2f {
+                Some(u2f) => self.u2f_challenge(access.clone(), userid, u2f)?,
+                None => None,
+            },
+            yubico: self.yubico.iter().any(|e| e.info.enable),
+        }))
+    }
+
+    /// Get the recovery state.
+    pub fn recovery_state(&self) -> RecoveryState {
+        RecoveryState::from(&self.recovery)
+    }
+
+    /// Generate an optional webauthn challenge.
+    fn webauthn_challenge<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        userid: &str,
+        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 = access.open(userid)?;
+        data.get_mut()
+            .webauthn_auths
+            .push(WebauthnAuthChallenge::new(state, challenge_string));
+        data.save()?;
+
+        Ok(Some(challenge))
+    }
+
+    /// Generate an optional u2f challenge.
+    fn u2f_challenge<A: OpenUserChallengeData>(
+        &self,
+        access: A,
+        userid: &str,
+        u2f: &u2f::U2f,
+    ) -> Result<Option<U2fChallenge>, Error> {
+        if self.u2f.is_empty() {
+            return Ok(None);
+        }
+
+        let keys: Vec<proxmox_tfa::u2f::RegisteredKey> = self
+            .enabled_u2f_entries()
+            .map(|registration| registration.key.clone())
+            .collect();
+
+        if keys.is_empty() {
+            return Ok(None);
+        }
+
+        let challenge = U2fChallenge {
+            challenge: u2f.auth_challenge()?,
+            keys,
+        };
+
+        let mut data = access.open(userid)?;
+        data.get_mut()
+            .u2f_auths
+            .push(U2fChallengeEntry::new(&challenge));
+        data.save()?;
+
+        Ok(Some(challenge))
+    }
+
+    /// Verify a u2f response.
+    fn verify_u2f<A: OpenUserChallengeData>(
+        &self,
+        access: A,
+        userid: &str,
+        u2f: u2f::U2f,
+        challenge: &proxmox_tfa::u2f::AuthChallenge,
+        response: Value,
+    ) -> Result<(), Error> {
+        let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
+
+        let response: proxmox_tfa::u2f::AuthResponse = serde_json::from_value(response)
+            .map_err(|err| format_err!("invalid u2f response: {}", err))?;
+
+        if let Some(entry) = self
+            .enabled_u2f_entries()
+            .find(|e| e.key.key_handle == response.key_handle())
+        {
+            if u2f
+                .auth_verify_obj(&entry.public_key, &challenge.challenge, response)?
+                .is_some()
+            {
+                let mut data = match access.open_no_create(userid)? {
+                    Some(data) => data,
+                    None => bail!("no such challenge"),
+                };
+                let index = data
+                    .get_mut()
+                    .u2f_auths
+                    .iter()
+                    .position(|r| r == challenge)
+                    .ok_or_else(|| format_err!("no such challenge"))?;
+                let entry = data.get_mut().u2f_auths.remove(index);
+                if entry.is_expired(expire_before) {
+                    bail!("no such challenge");
+                }
+                data.save()
+                    .map_err(|err| format_err!("failed to save challenge file: {}", err))?;
+
+                return Ok(());
+            }
+        }
+
+        bail!("u2f verification failed");
+    }
+
+    /// Verify a webauthn response.
+    fn verify_webauthn<A: OpenUserChallengeData>(
+        &mut self,
+        access: A,
+        userid: &str,
+        mut webauthn: Webauthn<WebauthnConfig>,
+        mut response: Value,
+    ) -> Result<(), Error> {
+        let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
+
+        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 access.open_no_create(userid)? {
+            Some(data) => data,
+            None => bail!("no such challenge"),
+        };
+
+        let index = data
+            .get_mut()
+            .webauthn_auths
+            .iter()
+            .position(|r| r.challenge == challenge)
+            .ok_or_else(|| format_err!("no such challenge"))?;
+
+        let challenge = data.get_mut().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> {
+        if let Some(r) = &mut self.recovery {
+            if r.verify(value)? {
+                return Ok(());
+            }
+        }
+        bail!("recovery verification failed");
+    }
+}
+
+/// A TFA entry for a user.
+///
+/// This simply connects a raw registration to a non optional descriptive text chosen by the user.
+#[derive(Clone, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct TfaEntry<T> {
+    #[serde(flatten)]
+    pub info: TfaInfo,
+
+    /// The actual entry.
+    pub entry: T,
+}
+
+impl<T> TfaEntry<T> {
+    /// Create an entry with a description. The id will be autogenerated.
+    fn new(description: String, entry: T) -> Self {
+        Self {
+            info: TfaInfo {
+                id: Uuid::generate().to_string(),
+                enable: true,
+                description,
+                created: proxmox_time::epoch_i64(),
+            },
+            entry,
+        }
+    }
+
+    /// Create a raw entry from a `TfaInfo` and the corresponding entry data.
+    pub fn from_parts(info: TfaInfo, entry: T) -> Self {
+        Self { info, entry }
+    }
+}
+
+#[cfg_attr(feature = "api-types", api)]
+/// Over the API we only provide this part when querying a user's second factor list.
+#[derive(Clone, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct TfaInfo {
+    /// The id used to reference this entry.
+    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")]
+    pub enable: bool,
+}
+
+impl TfaInfo {
+    /// For recovery keys we have a fixed entry.
+    pub fn recovery(created: i64) -> Self {
+        Self {
+            id: "recovery".to_string(),
+            description: String::new(),
+            enable: true,
+            created,
+        }
+    }
+}
+
+const fn default_tfa_enable() -> bool {
+    true
+}
+
+const fn is_default_tfa_enable(v: &bool) -> bool {
+    *v
+}
+
+/// When sending a TFA challenge to the user, we include information about what kind of challenge
+/// 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 {
+    /// True if the user has TOTP devices.
+    #[serde(skip_serializing_if = "bool_is_false", default)]
+    totp: bool,
+
+    /// Whether there are recovery keys available.
+    #[serde(skip_serializing_if = "RecoveryState::is_unavailable", default)]
+    recovery: RecoveryState,
+
+    /// 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>,
+
+    /// True if the user has yubico keys configured.
+    #[serde(skip_serializing_if = "bool_is_false", default)]
+    yubico: bool,
+}
+
+fn bool_is_false(v: &bool) -> bool {
+    !v
+}
+
+/// A user's response to a TFA challenge.
+pub enum TfaResponse {
+    Totp(String),
+    U2f(Value),
+    Webauthn(Value),
+    Recovery(String),
+}
+
+/// This is part of the REST API:
+impl std::str::FromStr for TfaResponse {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Error> {
+        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");
+        })
+    }
+}
+
+/// Active TFA challenges per user, stored in a restricted temporary file on the machine handling
+/// the current user's authentication.
+#[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 u2f authentication 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_auths: Vec<U2fChallengeEntry>,
+
+    /// 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 authentication 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>,
+}
+
+/// 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: serde::Deserializer<'de>,
+    T: Deserialize<'de> + IsExpired,
+{
+    let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
+    deserializer.deserialize_seq(serde_tools::fold(
+        "a challenge entry",
+        |cap| cap.map(Vec::with_capacity).unwrap_or_else(Vec::new),
+        move |out, reg: T| {
+            if !reg.is_expired(expire_before) {
+                out.push(reg);
+            }
+        },
+    ))
+}
+
+impl TfaUserChallenges {
+    /// 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_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
+
+        let index = self
+            .u2f_registrations
+            .iter()
+            .position(|r| r.challenge == challenge)
+            .ok_or_else(|| format_err!("no such challenge"))?;
+
+        let reg = &self.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.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_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS;
+
+        let index = self
+            .webauthn_registrations
+            .iter()
+            .position(|r| r.challenge == challenge)
+            .ok_or_else(|| format_err!("no such challenge"))?;
+
+        let reg = self.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))
+    }
+}
diff --git a/pve-rs/src/tfa/proxmox_tfa_api/recovery.rs b/pve-rs/src/tfa/proxmox_tfa_api/recovery.rs
new file mode 100644
index 0000000..9af2873
--- /dev/null
+++ b/pve-rs/src/tfa/proxmox_tfa_api/recovery.rs
@@ -0,0 +1,153 @@
+use std::io;
+
+use anyhow::{format_err, Error};
+use openssl::hash::MessageDigest;
+use openssl::pkey::PKey;
+use openssl::sign::Signer;
+use serde::{Deserialize, Serialize};
+
+fn getrandom(mut buffer: &mut [u8]) -> Result<(), io::Error> {
+    while !buffer.is_empty() {
+        let res = unsafe {
+            libc::getrandom(
+                buffer.as_mut_ptr() as *mut libc::c_void,
+                buffer.len() as libc::size_t,
+                0 as libc::c_uint,
+            )
+        };
+
+        if res < 0 {
+            return Err(io::Error::last_os_error());
+        }
+
+        buffer = &mut buffer[(res as usize)..];
+    }
+
+    Ok(())
+}
+
+/// Recovery entries. We use HMAC-SHA256 with a random secret as a salted hash replacement.
+#[derive(Clone, Deserialize, Serialize)]
+pub struct Recovery {
+    /// "Salt" used for the key HMAC.
+    secret: String,
+
+    /// 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,
+}
+
+impl Recovery {
+    /// Generate recovery keys and return the recovery entry along with the original string
+    /// entries.
+    pub(super) fn generate() -> Result<(Self, Vec<String>), Error> {
+        let mut secret = [0u8; 8];
+        getrandom(&mut secret)?;
+
+        let mut this = Self {
+            secret: hex::encode(&secret).to_string(),
+            entries: Vec::with_capacity(10),
+            created: proxmox_time::epoch_i64(),
+        };
+
+        let mut original = Vec::new();
+
+        let mut key_data = [0u8; 80]; // 10 keys of 12 bytes
+        getrandom(&mut key_data)?;
+        for b in key_data.chunks(8) {
+            // unwrap: encoding hex bytes to fixed sized arrays
+            let entry = format!(
+                "{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}",
+                b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
+            );
+            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(hex::encode(&hmac))
+    }
+
+    /// Iterator over available keys.
+    fn available(&self) -> impl Iterator<Item = &str> {
+        self.entries.iter().filter_map(Option::as_deref)
+    }
+
+    /// Count the available keys.
+    pub fn count_available(&self) -> usize {
+        self.available().count()
+    }
+
+    /// Convenience serde method to check if either the option is `None` or the content `is_empty`.
+    pub(super) 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.
+    pub(super) 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)
+    }
+}
+
+/// Used to inform the user about the recovery code status.
+///
+/// This contains the available key indices.
+#[derive(Clone, Default, Eq, PartialEq, Deserialize, Serialize)]
+pub struct RecoveryState(Vec<usize>);
+
+impl RecoveryState {
+    pub fn is_available(&self) -> bool {
+        !self.is_unavailable()
+    }
+
+    pub fn is_unavailable(&self) -> bool {
+        self.0.is_empty()
+    }
+}
+
+impl From<&Option<Recovery>> for RecoveryState {
+    fn from(r: &Option<Recovery>) -> Self {
+        match r {
+            Some(r) => Self::from(r),
+            None => Self::default(),
+        }
+    }
+}
+
+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(),
+        )
+    }
+}
diff --git a/pve-rs/src/tfa/proxmox_tfa_api/serde_tools.rs b/pve-rs/src/tfa/proxmox_tfa_api/serde_tools.rs
new file mode 100644
index 0000000..1f307a2
--- /dev/null
+++ b/pve-rs/src/tfa/proxmox_tfa_api/serde_tools.rs
@@ -0,0 +1,111 @@
+//! Submodule for generic serde helpers.
+//!
+//! FIXME: This should appear in `proxmox-serde`.
+
+use std::fmt;
+use std::marker::PhantomData;
+
+use serde::Deserialize;
+
+/// Helper to abstract away serde details, see [`fold`](fold()).
+pub struct FoldSeqVisitor<T, Out, F, Init>
+where
+    Init: FnOnce(Option<usize>) -> Out,
+    F: Fn(&mut Out, T) -> (),
+{
+    init: Option<Init>,
+    closure: F,
+    expecting: &'static str,
+    _ty: PhantomData<T>,
+}
+
+impl<T, Out, F, Init> FoldSeqVisitor<T, Out, F, Init>
+where
+    Init: FnOnce(Option<usize>) -> Out,
+    F: Fn(&mut Out, T) -> (),
+{
+    pub fn new(expecting: &'static str, init: Init, closure: F) -> Self {
+        Self {
+            init: Some(init),
+            closure,
+            expecting,
+            _ty: PhantomData,
+        }
+    }
+}
+
+impl<'de, T, Out, F, Init> serde::de::Visitor<'de> for FoldSeqVisitor<T, Out, F, Init>
+where
+    Init: FnOnce(Option<usize>) -> Out,
+    F: Fn(&mut Out, T) -> (),
+    T: Deserialize<'de>,
+{
+    type Value = Out;
+
+    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+        formatter.write_str(self.expecting)
+    }
+
+    fn visit_seq<A>(mut self, mut seq: A) -> Result<Self::Value, A::Error>
+    where
+        A: serde::de::SeqAccess<'de>,
+    {
+        // unwrap: this is the only place taking out init and we're consuming `self`
+        let mut output = (self.init.take().unwrap())(seq.size_hint());
+
+        while let Some(entry) = seq.next_element::<T>()? {
+            (self.closure)(&mut output, entry);
+        }
+
+        Ok(output)
+    }
+}
+
+/// Create a serde sequence visitor with simple callbacks.
+///
+/// This helps building things such as filters for arrays without having to worry about the serde
+/// implementation details.
+///
+/// Example:
+/// ```
+/// # use serde::Deserialize;
+///
+/// #[derive(Deserialize)]
+/// struct Test {
+///     #[serde(deserialize_with = "stringify_u64")]
+///     foo: Vec<String>,
+/// }
+///
+/// fn stringify_u64<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
+/// where
+///     D: serde::Deserializer<'de>,
+/// {
+///     deserializer.deserialize_seq(proxmox_serde::fold(
+///         "a sequence of integers",
+///         |cap| cap.map(Vec::with_capacity).unwrap_or_else(Vec::new),
+///         |out, num: u64| {
+///             if num != 4 {
+///                 out.push(num.to_string());
+///             }
+///         },
+///     ))
+/// }
+///
+/// let test: Test =
+///     serde_json::from_str(r#"{"foo":[2, 4, 6]}"#).expect("failed to deserialize test");
+/// assert_eq!(test.foo.len(), 2);
+/// assert_eq!(test.foo[0], "2");
+/// assert_eq!(test.foo[1], "6");
+/// ```
+pub fn fold<'de, T, Out, Init, Fold>(
+    expected: &'static str,
+    init: Init,
+    fold: Fold,
+) -> FoldSeqVisitor<T, Out, Fold, Init>
+where
+    Init: FnOnce(Option<usize>) -> Out,
+    Fold: Fn(&mut Out, T) -> (),
+    T: Deserialize<'de>,
+{
+    FoldSeqVisitor::new(expected, init, fold)
+}
diff --git a/pve-rs/src/tfa/proxmox_tfa_api/u2f.rs b/pve-rs/src/tfa/proxmox_tfa_api/u2f.rs
new file mode 100644
index 0000000..7b75eb3
--- /dev/null
+++ b/pve-rs/src/tfa/proxmox_tfa_api/u2f.rs
@@ -0,0 +1,89 @@
+//! u2f configuration and challenge data
+
+use serde::{Deserialize, Serialize};
+
+use proxmox_tfa::u2f;
+
+pub use proxmox_tfa::u2f::{Registration, U2f};
+
+/// The U2F authentication configuration.
+#[derive(Clone, Deserialize, Serialize)]
+pub struct U2fConfig {
+    pub appid: String,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub origin: Option<String>,
+}
+
+/// A u2f registration challenge.
+#[derive(Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct U2fRegistrationChallenge {
+    /// JSON formatted challenge string.
+    pub challenge: String,
+
+    /// The description chosen by the user for this registration.
+    pub description: String,
+
+    /// When the challenge was created as unix epoch. They are supposed to be short-lived.
+    created: i64,
+}
+
+impl super::IsExpired for U2fRegistrationChallenge {
+    fn is_expired(&self, at_epoch: i64) -> bool {
+        self.created < at_epoch
+    }
+}
+
+impl U2fRegistrationChallenge {
+    pub fn new(challenge: String, description: String) -> Self {
+        Self {
+            challenge,
+            description,
+            created: proxmox_time::epoch_i64(),
+        }
+    }
+}
+
+/// Data used for u2f authentication challenges.
+///
+/// This is sent to the client at login time.
+#[derive(Deserialize, Serialize)]
+pub struct U2fChallenge {
+    /// AppID and challenge data.
+    pub(super) challenge: u2f::AuthChallenge,
+
+    /// Available tokens/keys.
+    pub(super) keys: Vec<u2f::RegisteredKey>,
+}
+
+/// The challenge data we need on the server side to verify the challenge:
+/// * It can only be used once.
+/// * It can expire.
+#[derive(Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct U2fChallengeEntry {
+    challenge: u2f::AuthChallenge,
+    created: i64,
+}
+
+impl U2fChallengeEntry {
+    pub fn new(challenge: &U2fChallenge) -> Self {
+        Self {
+            challenge: challenge.challenge.clone(),
+            created: proxmox_time::epoch_i64(),
+        }
+    }
+}
+
+impl super::IsExpired for U2fChallengeEntry {
+    fn is_expired(&self, at_epoch: i64) -> bool {
+        self.created < at_epoch
+    }
+}
+
+impl PartialEq<u2f::AuthChallenge> for U2fChallengeEntry {
+    fn eq(&self, other: &u2f::AuthChallenge) -> bool {
+        self.challenge.challenge == other.challenge && self.challenge.app_id == other.app_id
+    }
+}
diff --git a/pve-rs/src/tfa/proxmox_tfa_api/webauthn.rs b/pve-rs/src/tfa/proxmox_tfa_api/webauthn.rs
new file mode 100644
index 0000000..8d98ed4
--- /dev/null
+++ b/pve-rs/src/tfa/proxmox_tfa_api/webauthn.rs
@@ -0,0 +1,118 @@
+//! Webauthn configuration and challenge data.
+
+use serde::{Deserialize, Serialize};
+
+#[cfg(feature = "api-types")]
+use proxmox_schema::api;
+
+use super::IsExpired;
+
+#[cfg_attr(feature = "api-types", api)]
+/// Server side webauthn server configuration.
+#[derive(Clone, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct WebauthnConfig {
+    /// Relying party name. Any text identifier.
+    ///
+    /// Changing this *may* break existing credentials.
+    pub 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.
+    pub origin: String,
+
+    /// Relying part ID. Must be the domain name without protocol, port or location.
+    ///
+    /// Changing this *will* break existing credentials.
+    pub id: String,
+}
+
+/// 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()
+    }
+}
+
+/// A webauthn registration challenge.
+#[derive(Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct WebauthnRegistrationChallenge {
+    /// Server side registration state data.
+    pub(super) state: webauthn_rs::RegistrationState,
+
+    /// While this is basically the content of a `RegistrationState`, the webauthn-rs crate doesn't
+    /// make this public.
+    pub(super) challenge: String,
+
+    /// The description chosen by the user for this registration.
+    pub(super) 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_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.
+    pub(super) state: webauthn_rs::AuthenticationState,
+
+    /// While this is basically the content of a `AuthenticationState`, the webauthn-rs crate
+    /// doesn't make this public.
+    pub(super) 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_time::epoch_i64(),
+        }
+    }
+}
+
+impl IsExpired for WebauthnAuthChallenge {
+    fn is_expired(&self, at_epoch: i64) -> bool {
+        self.created < at_epoch
+    }
+}
-- 
2.30.2





More information about the pve-devel mailing list