From 84417400ed6b14be73d1b03bc1f37b89acc3b80c Mon Sep 17 00:00:00 2001 From: Wolfgang Bumiller Date: Tue, 16 Nov 2021 12:42:55 +0100 Subject: [PATCH] move proxmox_tfa_api module to proxmox-tfa as api feature Signed-off-by: Wolfgang Bumiller --- pve-rs/Cargo.toml | 9 +- pve-rs/src/{tfa/mod.rs => tfa.rs} | 56 +- pve-rs/src/tfa/proxmox_tfa_api/api.rs | 491 --------- pve-rs/src/tfa/proxmox_tfa_api/mod.rs | 991 ------------------ 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, 33 insertions(+), 1985 deletions(-) rename pve-rs/src/{tfa/mod.rs => tfa.rs} (94%) delete mode 100644 pve-rs/src/tfa/proxmox_tfa_api/api.rs delete mode 100644 pve-rs/src/tfa/proxmox_tfa_api/mod.rs delete mode 100644 pve-rs/src/tfa/proxmox_tfa_api/recovery.rs delete mode 100644 pve-rs/src/tfa/proxmox_tfa_api/serde_tools.rs delete mode 100644 pve-rs/src/tfa/proxmox_tfa_api/u2f.rs delete mode 100644 pve-rs/src/tfa/proxmox_tfa_api/webauthn.rs diff --git a/pve-rs/Cargo.toml b/pve-rs/Cargo.toml index 947da45..e96b45a 100644 --- a/pve-rs/Cargo.toml +++ b/pve-rs/Cargo.toml @@ -30,11 +30,4 @@ perlmod = { version = "0.8.1", features = [ "exporter" ] } proxmox-apt = "0.8" proxmox-openid = "0.8" - -#proxmox-tfa-api = { path = "../proxmox-tfa-api", version = "0.1" } - -# Dependencies purely in proxmox-tfa-api: -webauthn-rs = "0.2.5" -proxmox-time = "1" -proxmox-uuid = "1" -proxmox-tfa = { version = "1.2", features = ["u2f"] } +proxmox-tfa = { version = "1.3", features = ["api"] } diff --git a/pve-rs/src/tfa/mod.rs b/pve-rs/src/tfa.rs similarity index 94% rename from pve-rs/src/tfa/mod.rs rename to pve-rs/src/tfa.rs index d91278d..7bbee38 100644 --- a/pve-rs/src/tfa/mod.rs +++ b/pve-rs/src/tfa.rs @@ -20,8 +20,7 @@ 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::{ +pub(self) use proxmox_tfa::api::{ RecoveryState, TfaChallenge, TfaConfig, TfaResponse, TfaUserData, U2fConfig, WebauthnConfig, }; @@ -34,8 +33,8 @@ mod export { use serde_bytes::ByteBuf; use perlmod::Value; + use proxmox_tfa::api::methods; - use super::proxmox_tfa_api::api; use super::{TfaConfig, UserAccess}; perlmod::declare_magic!(Box : &Tfa as "PVE::RS::TFA"); @@ -300,8 +299,8 @@ mod export { fn api_list_user_tfa( #[try_from_ref] this: &Tfa, userid: &str, - ) -> Result, Error> { - api::list_user_tfa(&this.inner.lock().unwrap(), userid) + ) -> Result, Error> { + methods::list_user_tfa(&this.inner.lock().unwrap(), userid) } #[export] @@ -309,8 +308,8 @@ mod export { #[try_from_ref] this: &Tfa, userid: &str, id: &str, - ) -> Result, Error> { - api::get_tfa_entry(&this.inner.lock().unwrap(), userid, id) + ) -> Option { + methods::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* @@ -318,9 +317,9 @@ mod export { #[export] fn api_delete_tfa(#[try_from_ref] this: &Tfa, userid: &str, id: String) -> Result { let mut this = this.inner.lock().unwrap(); - match api::delete_tfa(&mut this, userid, id) { + match methods::delete_tfa(&mut this, userid, &id) { Ok(has_entries_left) => Ok(has_entries_left), - Err(api::EntryNotFound) => bail!("no such entry"), + Err(methods::EntryNotFound) => bail!("no such entry"), } } @@ -329,8 +328,8 @@ mod export { #[try_from_ref] this: &Tfa, authid: &str, top_level_allowed: bool, - ) -> Result, Error> { - api::list_tfa(&this.inner.lock().unwrap(), authid, top_level_allowed) + ) -> Result, Error> { + methods::list_tfa(&this.inner.lock().unwrap(), authid, top_level_allowed) } #[export] @@ -342,10 +341,10 @@ mod export { totp: Option, value: Option, challenge: Option, - ty: api::TfaType, - ) -> Result { + ty: methods::TfaType, + ) -> Result { let this: &Tfa = (&raw_this).try_into()?; - api::add_tfa_entry( + methods::add_tfa_entry( &mut this.inner.lock().unwrap(), UserAccess::new(&raw_this)?, userid, @@ -396,7 +395,7 @@ mod export { description: Option, enable: Option, ) -> Result<(), Error> { - match api::update_tfa_entry( + match methods::update_tfa_entry( &mut this.inner.lock().unwrap(), userid, id, @@ -404,7 +403,7 @@ mod export { enable, ) { Ok(()) => Ok(()), - Err(api::EntryNotFound) => bail!("no such entry"), + Err(methods::EntryNotFound) => bail!("no such entry"), } } } @@ -479,7 +478,7 @@ fn parse_old_config(data: &[u8]) -> Result { fn decode_old_entry(ty: &[u8], data: &[u8], user: &str) -> Result { let mut user_data = TfaUserData::default(); - let info = proxmox_tfa_api::TfaInfo { + let info = proxmox_tfa::api::TfaInfo { id: "v1-entry".to_string(), description: "".to_string(), created: 0, @@ -494,18 +493,18 @@ fn decode_old_entry(ty: &[u8], data: &[u8], user: &str) -> Result user_data.totp.extend( decode_old_oath_entry(value, user)? .into_iter() - .map(move |entry| proxmox_tfa_api::TfaEntry::from_parts(info.clone(), entry)), + .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)), + .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), @@ -852,7 +851,7 @@ fn challenge_data_path(userid: &str, debug: bool) -> PathBuf { } } -impl proxmox_tfa_api::OpenUserChallengeData for UserAccess { +impl proxmox_tfa::api::OpenUserChallengeData for UserAccess { type Data = UserChallengeData; fn open(&self, userid: &str) -> Result { @@ -930,6 +929,15 @@ impl proxmox_tfa_api::OpenUserChallengeData for UserAccess { lock: file, })) } + + fn remove(&self, userid: &str) -> Result { + let path = challenge_data_path(userid, self.is_debug()); + match std::fs::remove_file(&path) { + Ok(()) => Ok(true), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(err.into()), + } + } } /// Container of `TfaUserChallenges` with the corresponding file lock guard. @@ -937,13 +945,13 @@ impl proxmox_tfa_api::OpenUserChallengeData for UserAccess { /// Basically provides the TFA API to the REST server by persisting, updating and verifying active /// challenges. pub struct UserChallengeData { - inner: proxmox_tfa_api::TfaUserChallenges, + 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 { +impl proxmox_tfa::api::UserChallengeAccess for UserChallengeData { + fn get_mut(&mut self) -> &mut proxmox_tfa::api::TfaUserChallenges { &mut self.inner } diff --git a/pve-rs/src/tfa/proxmox_tfa_api/api.rs b/pve-rs/src/tfa/proxmox_tfa_api/api.rs deleted file mode 100644 index 031aaf3..0000000 --- a/pve-rs/src/tfa/proxmox_tfa_api/api.rs +++ /dev/null @@ -1,491 +0,0 @@ -//! 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 { - 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 { - 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, 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, 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 { - 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, -} - -/// 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, Error> { - let tfa_data = &config.users; - - let mut out = Vec::::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, - - /// 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, - - /// 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, -} - -impl TfaUpdateInfo { - fn id(id: String) -> Self { - Self { - id: Some(id), - ..Default::default() - } - } -} - -fn need_description(description: Option) -> Result { - 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( - config: &mut TfaConfig, - access: A, - userid: &str, - description: Option, - totp: Option, - value: Option, - challenge: Option, - r#type: TfaType, -) -> Result { - 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, - value: Option, -) -> Result { - 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"); - } - Ok(TfaUpdateInfo::id(config.add_totp( - userid, - description, - totp, - ))) -} - -fn add_yubico( - config: &mut TfaConfig, - userid: &str, - description: String, - value: Option, -) -> Result { - let key = value.ok_or_else(|| format_err!("missing 'value' parameter for 'yubico' entry"))?; - Ok(TfaUpdateInfo::id(config.add_yubico( - userid, - description, - key, - ))) -} - -fn add_u2f( - config: &mut TfaConfig, - access: A, - userid: &str, - description: Option, - challenge: Option, - value: Option, -) -> Result { - 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( - config: &mut TfaConfig, - access: A, - userid: &str, - description: Option, - challenge: Option, - value: Option, -) -> Result { - 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, - enable: Option, -) -> 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 deleted file mode 100644 index 0a6dfd0..0000000 --- a/pve-rs/src/tfa/proxmox_tfa_api/mod.rs +++ /dev/null @@ -1,991 +0,0 @@ -//! 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; - fn open_no_create(&self, userid: &str) -> Result, Error>; -} - -pub trait UserChallengeAccess: Sized { - //fn open(userid: &str) -> Result; - //fn open_no_create(userid: &str) -> Result, 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, - - #[serde(skip_serializing_if = "Option::is_none")] - pub webauthn: Option, - - #[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) -> Option { - 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) -> Result { - 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) -> Option> { - 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) -> Result, 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( - &mut self, - access: A, - userid: &str, - description: String, - ) -> Result { - 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( - &mut self, - access: A, - userid: &str, - challenge: &str, - response: &str, - ) -> Result { - 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( - &mut self, - access: A, - user: &str, - description: String, - ) -> Result { - 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( - &mut self, - access: A, - userid: &str, - challenge: &str, - response: &str, - ) -> Result { - 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) -> String { - 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) -> String { - 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, 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( - &mut self, - access: A, - userid: &str, - ) -> Result, 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( - &mut self, - access: A, - userid: &str, - challenge: &TfaChallenge, - response: TfaResponse, - ) -> Result { - 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; - -/// 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>, - - /// Registered u2f tokens for a user. - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub u2f: Vec>, - - /// Registered webauthn tokens for a user. - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub webauthn: Vec>, - - /// Recovery keys. (Unordered OTP values). - #[serde(skip_serializing_if = "Recovery::option_is_empty", default)] - pub recovery: Option, - - /// 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>, -} - -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( - &mut self, - access: A, - userid: &str, - u2f: &u2f::U2f, - description: String, - ) -> Result { - 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( - &mut self, - access: A, - userid: &str, - u2f: &u2f::U2f, - challenge: &str, - response: &str, - ) -> Result { - 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( - &mut self, - access: A, - mut webauthn: Webauthn, - userid: &str, - description: String, - ) -> Result { - 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( - &mut self, - access: A, - webauthn: Webauthn, - userid: &str, - challenge: &str, - response: webauthn_rs::proto::RegisterPublicKeyCredential, - ) -> Result { - 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, 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 { - 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 { - 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 { - 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 { - 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( - &mut self, - access: A, - userid: &str, - webauthn: Option>, - u2f: Option<&u2f::U2f>, - ) -> Result, 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( - &mut self, - access: A, - userid: &str, - mut webauthn: Webauthn, - ) -> Result, 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( - &self, - access: A, - userid: &str, - u2f: &u2f::U2f, - ) -> Result, Error> { - if self.u2f.is_empty() { - return Ok(None); - } - - let keys: Vec = 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( - &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( - &mut self, - access: A, - userid: &str, - mut webauthn: Webauthn, - 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 { - #[serde(flatten)] - pub info: TfaInfo, - - /// The actual entry. - pub entry: T, -} - -impl TfaEntry { - /// 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, - - /// 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, - - /// 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 { - 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, - - /// 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, - - /// 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, - - /// 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, -} - -/// Serde helper using our `FilteredVecVisitor` to filter out expired entries directly at load -/// time. -fn filter_expired_challenge<'de, D, T>(deserializer: D) -> Result, 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, 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, - challenge: &str, - response: webauthn_rs::proto::RegisterPublicKeyCredential, - existing_registrations: &[TfaEntry], - ) -> Result, 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 { - 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 deleted file mode 100644 index 9af2873..0000000 --- a/pve-rs/src/tfa/proxmox_tfa_api/recovery.rs +++ /dev/null @@ -1,153 +0,0 @@ -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>, - - /// 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), 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 { - 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 { - 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) -> 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 { - 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); - -impl RecoveryState { - pub fn is_available(&self) -> bool { - !self.is_unavailable() - } - - pub fn is_unavailable(&self) -> bool { - self.0.is_empty() - } -} - -impl From<&Option> for RecoveryState { - fn from(r: &Option) -> 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 deleted file mode 100644 index 1f307a2..0000000 --- a/pve-rs/src/tfa/proxmox_tfa_api/serde_tools.rs +++ /dev/null @@ -1,111 +0,0 @@ -//! 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 -where - Init: FnOnce(Option) -> Out, - F: Fn(&mut Out, T) -> (), -{ - init: Option, - closure: F, - expecting: &'static str, - _ty: PhantomData, -} - -impl FoldSeqVisitor -where - Init: FnOnce(Option) -> 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 -where - Init: FnOnce(Option) -> 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(mut self, mut seq: A) -> Result - 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::()? { - (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, -/// } -/// -/// fn stringify_u64<'de, D>(deserializer: D) -> Result, 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 -where - Init: FnOnce(Option) -> 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 deleted file mode 100644 index 7b75eb3..0000000 --- a/pve-rs/src/tfa/proxmox_tfa_api/u2f.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! 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, -} - -/// 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, -} - -/// 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 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 deleted file mode 100644 index 8d98ed4..0000000 --- a/pve-rs/src/tfa/proxmox_tfa_api/webauthn.rs +++ /dev/null @@ -1,118 +0,0 @@ -//! 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 - } -}