From: Wolfgang Bumiller <w.bumiller@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [PATCH proxmox-perl-rs 4/6] pve: add tfa api
Date: Tue, 9 Nov 2021 12:26:53 +0100 [thread overview]
Message-ID: <20211109112721.130935-5-w.bumiller@proxmox.com> (raw)
In-Reply-To: <20211109112721.130935-1-w.bumiller@proxmox.com>
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@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
next prev parent reply other threads:[~2021-11-09 11:27 UTC|newest]
Thread overview: 43+ messages / expand[flat|nested] mbox.gz Atom feed top
2021-11-09 11:26 [pve-devel] [PATCH multiple 0/9] PBS-like TFA support in PVE Wolfgang Bumiller
2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 1/6] import basic skeleton Wolfgang Bumiller
2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 2/6] import pve-rs Wolfgang Bumiller
2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 3/6] move apt to /perl-apt, use PERLMOD_PRODUCT env var Wolfgang Bumiller
2021-11-09 11:26 ` Wolfgang Bumiller [this message]
2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 5/6] build fix: pmg-rs is not here yet Wolfgang Bumiller
2021-11-09 11:26 ` [pve-devel] [PATCH proxmox-perl-rs 6/6] Add some dev tips to a README Wolfgang Bumiller
2021-11-09 11:26 ` [pve-devel] [PATCH access-control 01/10] use rust parser for TFA config Wolfgang Bumiller
2021-11-09 11:26 ` [pve-devel] [PATCH access-control 02/10] update read_user_tfa_type call Wolfgang Bumiller
2021-11-09 11:26 ` [pve-devel] [PATCH access-control 03/10] use PBS-like auth api call flow Wolfgang Bumiller
2021-11-09 11:26 ` [pve-devel] [PATCH access-control 04/10] handle yubico authentication in new path Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH access-control 05/10] move TFA api path into its own module Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH access-control 06/10] add pbs-style TFA API implementation Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH access-control 07/10] support registering yubico otp keys Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH access-control 08/10] update tfa cleanup when deleting users Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH access-control 09/10] pveum: update tfa delete command Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH access-control 10/10] set/remove 'x' for tfa keys in user.cfg in new api Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH cluster] add webauthn configuration to datacenter.cfg Wolfgang Bumiller
2021-11-10 10:12 ` [pve-devel] applied: " Thomas Lamprecht
2021-11-09 11:27 ` [pve-devel] [PATCH common] Ticket: uri-escape colons Wolfgang Bumiller
2021-11-09 12:26 ` [pve-devel] applied: " Thomas Lamprecht
2021-11-09 11:27 ` [pve-devel] [PATCH manager 1/7] www: use render_u2f_error from wtk Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH manager 2/7] www: use UserSelector " Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH manager 3/7] use u2f-api.js and qrcode.min.js " Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH manager 4/7] www: switch to new tfa login format Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH manager 5/7] www: use af-address-book-o for realms Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH manager 6/7] www: add TFA view to config Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH manager 7/7] www: redirect user TFA button to TFA view Wolfgang Bumiller
2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 1/7] add pmxUserSelector Wolfgang Bumiller
2021-11-10 8:29 ` [pve-devel] applied: " Dominik Csapak
2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 2/7] add Utils used for u2f and webauthn Wolfgang Bumiller
2021-11-10 8:30 ` [pve-devel] applied: " Dominik Csapak
2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 3/7] add u2f-api.js and qrcode.min.js Wolfgang Bumiller
2021-11-10 8:31 ` Dominik Csapak
2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 4/7] add Proxmox.window.TfaLoginWindow Wolfgang Bumiller
2021-11-10 8:30 ` [pve-devel] applied: " Dominik Csapak
2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 5/7] add totp, wa and recovery creation and tfa edit windows Wolfgang Bumiller
2021-11-10 8:30 ` [pve-devel] applied: " Dominik Csapak
2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 6/7] add Proxmox.panel.TfaView Wolfgang Bumiller
2021-11-10 8:30 ` [pve-devel] applied: " Dominik Csapak
2021-11-09 11:27 ` [pve-devel] [PATCH widget-toolkit 7/7] add yubico otp windows & login support Wolfgang Bumiller
2021-11-10 8:30 ` [pve-devel] applied: " Dominik Csapak
2021-11-11 15:52 ` [pve-devel] applied-series: [PATCH multiple 0/9] PBS-like TFA support in PVE Thomas Lamprecht
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20211109112721.130935-5-w.bumiller@proxmox.com \
--to=w.bumiller@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal