diff --git a/proxmox-tfa/Cargo.toml b/proxmox-tfa/Cargo.toml index eb6c87a9..7a3fe941 100644 --- a/proxmox-tfa/Cargo.toml +++ b/proxmox-tfa/Cargo.toml @@ -20,6 +20,13 @@ serde_plain = "1.0" serde_json = { version = "1.0", optional = true } libc = { version = "0.2", optional = true } +proxmox-schema = { path = "../proxmox-schema", features = [ "api-macro" ], optional = true } +proxmox-time = { path = "../proxmox-time", optional = true } +proxmox-uuid = { path = "../proxmox-uuid", optional = true } +webauthn-rs = { version = "0.2.5", optional = true } + [features] default = [] u2f = [ "libc", "serde_json", "serde/derive" ] +api = [ "u2f", "webauthn-rs", "proxmox-uuid", "proxmox-time" ] +api-types = [ "proxmox-schema" ] diff --git a/proxmox-tfa/src/api/methods.rs b/proxmox-tfa/src/api/methods.rs new file mode 100644 index 00000000..56971f12 --- /dev/null +++ b/proxmox-tfa/src/api/methods.rs @@ -0,0 +1,485 @@ +//! 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}; + +#[cfg(feature = "api-types")] +use proxmox_schema::api; + +use super::{OpenUserChallengeData, TfaConfig, TfaInfo, TfaUserData}; +use crate::totp::Totp; + +#[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) -> Option { + let user_data = match config.users.get(userid) { + Some(u) => u, + None => return None, + }; + + 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 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 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: &str) -> 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: { + "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/proxmox-tfa/src/api/mod.rs b/proxmox-tfa/src/api/mod.rs new file mode 100644 index 00000000..fcdf14b9 --- /dev/null +++ b/proxmox-tfa/src/api/mod.rs @@ -0,0 +1,1020 @@ +//! 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 crate::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 methods; + +pub use recovery::RecoveryState; +pub use u2f::U2fConfig; +pub use webauthn::WebauthnConfig; + +#[cfg(feature = "api-types")] +pub use webauthn::WebauthnConfigUpdater; + +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>; + + /// Should return `true` if something was removed, `false` if no data existed for the user. + fn remove(&self, userid: &str) -> Result; +} + +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. + pub 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. + pub 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. + pub 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) + } + + pub fn remove_user( + &mut self, + access: A, + userid: &str, + ) -> Result { + let mut save = access.remove(userid)?; + if self.users.remove(userid).is_some() { + save = true; + } + Ok(save.into()) + } +} + +#[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) + } +} + +impl From for NeedsSaving { + fn from(v: bool) -> Self { + if v { + NeedsSaving::Yes + } else { + NeedsSaving::No + } + } +} + +/// 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: &crate::u2f::AuthChallenge, + response: Value, + ) -> Result<(), Error> { + let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS; + + let response: crate::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/proxmox-tfa/src/api/recovery.rs b/proxmox-tfa/src/api/recovery.rs new file mode 100644 index 00000000..9af28731 --- /dev/null +++ b/proxmox-tfa/src/api/recovery.rs @@ -0,0 +1,153 @@ +use std::io; + +use anyhow::{format_err, Error}; +use openssl::hash::MessageDigest; +use openssl::pkey::PKey; +use openssl::sign::Signer; +use serde::{Deserialize, Serialize}; + +fn getrandom(mut buffer: &mut [u8]) -> Result<(), io::Error> { + while !buffer.is_empty() { + let res = unsafe { + libc::getrandom( + buffer.as_mut_ptr() as *mut libc::c_void, + buffer.len() as libc::size_t, + 0 as libc::c_uint, + ) + }; + + if res < 0 { + return Err(io::Error::last_os_error()); + } + + buffer = &mut buffer[(res as usize)..]; + } + + Ok(()) +} + +/// Recovery entries. We use HMAC-SHA256 with a random secret as a salted hash replacement. +#[derive(Clone, Deserialize, Serialize)] +pub struct Recovery { + /// "Salt" used for the key HMAC. + secret: String, + + /// Recovery key entries are HMACs of the original data. When used up they will become `None` + /// since the user is presented an enumerated list of codes, so we know the indices of used and + /// unused codes. + entries: Vec>, + + /// 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/proxmox-tfa/src/api/serde_tools.rs b/proxmox-tfa/src/api/serde_tools.rs new file mode 100644 index 00000000..1f307a29 --- /dev/null +++ b/proxmox-tfa/src/api/serde_tools.rs @@ -0,0 +1,111 @@ +//! Submodule for generic serde helpers. +//! +//! FIXME: This should appear in `proxmox-serde`. + +use std::fmt; +use std::marker::PhantomData; + +use serde::Deserialize; + +/// Helper to abstract away serde details, see [`fold`](fold()). +pub struct FoldSeqVisitor +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/proxmox-tfa/src/api/u2f.rs b/proxmox-tfa/src/api/u2f.rs new file mode 100644 index 00000000..e62f7c9a --- /dev/null +++ b/proxmox-tfa/src/api/u2f.rs @@ -0,0 +1,89 @@ +//! u2f configuration and challenge data + +use serde::{Deserialize, Serialize}; + +use crate::u2f; + +pub use crate::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/proxmox-tfa/src/api/webauthn.rs b/proxmox-tfa/src/api/webauthn.rs new file mode 100644 index 00000000..7f217bfa --- /dev/null +++ b/proxmox-tfa/src/api/webauthn.rs @@ -0,0 +1,119 @@ +//! Webauthn configuration and challenge data. + +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "api-types")] +use proxmox_schema::{api, Updater}; + +use super::IsExpired; + +#[cfg_attr(feature = "api-types", api)] +#[cfg_attr(feature = "api-types", derive(Updater))] +/// 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 + } +} diff --git a/proxmox-tfa/src/lib.rs b/proxmox-tfa/src/lib.rs index cebcf0dd..69309661 100644 --- a/proxmox-tfa/src/lib.rs +++ b/proxmox-tfa/src/lib.rs @@ -2,3 +2,6 @@ pub mod u2f; pub mod totp; + +#[cfg(feature = "api")] +pub mod api;