mirror of
https://git.proxmox.com/git/proxmox-perl-rs
synced 2025-07-25 07:01:53 +00:00
pve: add tfa api
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>
This commit is contained in:
parent
9bc3ab23cb
commit
2cc9163892
@ -4,3 +4,4 @@
|
||||
pub mod apt;
|
||||
|
||||
pub mod openid;
|
||||
pub mod tfa;
|
||||
|
965
pve-rs/src/tfa/mod.rs
Normal file
965
pve-rs/src/tfa/mod.rs
Normal file
@ -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(())
|
||||
}
|
||||
}
|
487
pve-rs/src/tfa/proxmox_tfa_api/api.rs
Normal file
487
pve-rs/src/tfa/proxmox_tfa_api/api.rs
Normal file
@ -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(())
|
||||
}
|
1003
pve-rs/src/tfa/proxmox_tfa_api/mod.rs
Normal file
1003
pve-rs/src/tfa/proxmox_tfa_api/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
153
pve-rs/src/tfa/proxmox_tfa_api/recovery.rs
Normal file
153
pve-rs/src/tfa/proxmox_tfa_api/recovery.rs
Normal file
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
111
pve-rs/src/tfa/proxmox_tfa_api/serde_tools.rs
Normal file
111
pve-rs/src/tfa/proxmox_tfa_api/serde_tools.rs
Normal file
@ -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)
|
||||
}
|
89
pve-rs/src/tfa/proxmox_tfa_api/u2f.rs
Normal file
89
pve-rs/src/tfa/proxmox_tfa_api/u2f.rs
Normal file
@ -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
|
||||
}
|
||||
}
|
118
pve-rs/src/tfa/proxmox_tfa_api/webauthn.rs
Normal file
118
pve-rs/src/tfa/proxmox_tfa_api/webauthn.rs
Normal file
@ -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
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user