[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