From 9407810fe15d3cd141a65a7edfe42d7855edd781 Mon Sep 17 00:00:00 2001 From: Wolfgang Bumiller Date: Tue, 16 Nov 2021 15:03:26 +0100 Subject: [PATCH] switch tfa api to use proxmox-tfa::api Signed-off-by: Wolfgang Bumiller --- Cargo.toml | 5 +- src/api2/access/tfa.rs | 383 ++-------- src/api2/access/user.rs | 3 +- src/api2/config/access/tfa.rs | 5 +- src/config/tfa.rs | 1314 ++++----------------------------- 5 files changed, 191 insertions(+), 1519 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5de190b3..8382ec96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,7 +86,6 @@ udev = "0.4" url = "2.1" #valgrind_request = { git = "https://github.com/edef1c/libvalgrind_request", version = "1.1.0", optional = true } walkdir = "2" -webauthn-rs = "0.2.5" xdg = "2.2" nom = "5.1" crossbeam-channel = "0.5" @@ -104,7 +103,7 @@ proxmox-lang = "1" proxmox-router = { version = "1.1", features = [ "cli" ] } proxmox-schema = { version = "1", features = [ "api-macro" ] } proxmox-section-config = "1" -proxmox-tfa = { version = "1", features = [ "u2f" ] } +proxmox-tfa = { version = "1.3", features = [ "api", "api-types" ] } proxmox-time = "1" proxmox-uuid = "1" proxmox-shared-memory = "0.1.1" @@ -130,6 +129,8 @@ pbs-tape = { path = "pbs-tape" } [patch.crates-io] #proxmox = { path = "../proxmox/proxmox" } #proxmox-http = { path = "../proxmox/proxmox-http" } +#proxmox-tfa = { path = "../proxmox/proxmox-tfa" } +#proxmox-schema = { path = "../proxmox/proxmox-schema" } #pxar = { path = "../pxar" } [features] diff --git a/src/api2/access/tfa.rs b/src/api2/access/tfa.rs index 096226e8..76258320 100644 --- a/src/api2/access/tfa.rs +++ b/src/api2/access/tfa.rs @@ -1,16 +1,17 @@ //! Two Factor Authentication -use anyhow::{bail, format_err, Error}; -use serde::{Deserialize, Serialize}; +use anyhow::Error; -use proxmox_router::{http_bail, http_err, Router, RpcEnvironment, Permission}; +use proxmox_router::{http_bail, http_err, Permission, Router, RpcEnvironment}; use proxmox_schema::api; -use proxmox_tfa::totp::Totp; - -use pbs_api_types::{Authid, Userid, User, PASSWORD_SCHEMA, PRIV_PERMISSIONS_MODIFY, PRIV_SYS_AUDIT}; +use proxmox_tfa::api::methods; +use pbs_api_types::{ + Authid, User, Userid, PASSWORD_SCHEMA, PRIV_PERMISSIONS_MODIFY, PRIV_SYS_AUDIT, +}; use pbs_config::CachedUserInfo; -use crate::config::tfa::{TfaInfo, TfaUserData}; + +use crate::config::tfa::UserAccess; /// Perform first-factor (password) authentication only. Ignore password for the root user. /// Otherwise check the current user's password. @@ -36,10 +37,7 @@ fn tfa_update_auth( if must_exist && authid.user() != userid { let (config, _digest) = pbs_config::user::config()?; - if config - .lookup::("user", userid.as_str()) - .is_err() - { + if config.lookup::("user", userid.as_str()).is_err() { http_bail!(UNAUTHORIZED, "user '{}' does not exists.", userid); } } @@ -47,97 +45,6 @@ fn tfa_update_auth( Ok(()) } -#[api] -/// A TFA entry type. -#[derive(Deserialize, Serialize)] -#[serde(rename_all = "lowercase")] -enum TfaType { - /// A TOTP entry type. - Totp, - /// A U2F token entry. - U2f, - /// A Webauthn token entry. - Webauthn, - /// Recovery tokens. - Recovery, -} - -#[api( - properties: { - type: { type: TfaType }, - info: { type: TfaInfo }, - }, -)] -/// A TFA entry for a user. -#[derive(Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -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() - + 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, - }); - } - for entry in data.webauthn { - out.push(TypedTfaInfo { - ty: TfaType::Webauthn, - info: entry.info, - }); - } - for entry in data.u2f { - out.push(TypedTfaInfo { - ty: TfaType::U2f, - info: entry.info, - }); - } - 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.recovery - .iter() - .map(|_| (TfaType::Recovery, 0, "recovery")), - ) -} - #[api( protected: true, input: { @@ -151,13 +58,10 @@ fn tfa_id_iter(data: &TfaUserData) -> impl Iterator Result, Error> { +fn list_user_tfa(userid: Userid) -> Result, Error> { let _lock = crate::config::tfa::read_lock()?; - Ok(match crate::config::tfa::read()?.users.remove(&userid) { - Some(data) => to_data(data), - None => Vec::new(), - }) + methods::list_user_tfa(&crate::config::tfa::read()?, userid.as_str()) } #[api( @@ -176,47 +80,13 @@ fn list_user_tfa(userid: Userid) -> Result, Error> { }, )] /// Get a single TFA entry. -fn get_tfa_entry(userid: Userid, id: String) -> Result { +fn get_tfa_entry(userid: Userid, id: String) -> Result { let _lock = crate::config::tfa::read_lock()?; - if let Some(user_data) = crate::config::tfa::read()?.users.remove(&userid) { - 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, _)) => { - if let Some(recovery) = user_data.recovery() { - return Ok(TypedTfaInfo { - ty: TfaType::Recovery, - info: TfaInfo::recovery(recovery.created), - }); - } - } - Some((TfaType::Totp, index)) => { - return Ok(TypedTfaInfo { - ty: TfaType::Totp, - // `into_iter().nth()` to *move* out of it - info: user_data.totp.into_iter().nth(index).unwrap().info, - }); - } - Some((TfaType::Webauthn, index)) => { - return Ok(TypedTfaInfo { - ty: TfaType::Webauthn, - info: user_data.webauthn.into_iter().nth(index).unwrap().info, - }); - } - Some((TfaType::U2f, index)) => { - return Ok(TypedTfaInfo { - ty: TfaType::U2f, - info: user_data.u2f.into_iter().nth(index).unwrap().info, - }); - } - None => (), - } + match methods::get_tfa_entry(&crate::config::tfa::read()?, userid.as_str(), &id) { + Some(entry) => Ok(entry), + None => http_bail!(NOT_FOUND, "no such tfa entry: {}/{}", userid, id), } - - http_bail!(NOT_FOUND, "no such tfa entry: {}/{}", userid, id); } #[api( @@ -253,25 +123,11 @@ fn delete_tfa( let mut data = crate::config::tfa::read()?; - let user_data = data - .users - .get_mut(&userid) - .ok_or_else(|| http_err!(NOT_FOUND, "no such entry: {}/{}", userid, id))?; - - 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)), - None => http_bail!(NOT_FOUND, "no such tfa entry: {}/{}", userid, id), - } - - if user_data.is_empty() { - data.users.remove(&userid); + match methods::delete_tfa(&mut data, userid.as_str(), &id) { + Ok(_) => (), + Err(methods::EntryNotFound) => { + http_bail!(NOT_FOUND, "no such tfa entry: {}/{}", userid, id) + } } crate::config::tfa::write(&data)?; @@ -279,26 +135,6 @@ fn delete_tfa( Ok(()) } -#[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. -struct TfaUser { - /// The user this entry belongs to. - userid: Userid, - - /// TFA entries. - entries: Vec, -} - #[api( protected: true, input: { @@ -311,11 +147,11 @@ struct TfaUser { returns: { description: "The list tuples of user and TFA entries.", type: Array, - items: { type: TfaUser } + items: { type: methods::TfaUser } }, )] /// List user TFA configuration. -fn list_tfa(rpcenv: &mut dyn RpcEnvironment) -> Result, Error> { +fn list_tfa(rpcenv: &mut dyn RpcEnvironment) -> Result, Error> { let authid: Authid = rpcenv.get_auth_id().unwrap().parse()?; let user_info = CachedUserInfo::new()?; @@ -323,62 +159,8 @@ fn list_tfa(rpcenv: &mut dyn RpcEnvironment) -> Result, Error> { let top_level_allowed = (top_level_privs & PRIV_SYS_AUDIT) != 0; let _lock = crate::config::tfa::read_lock()?; - let tfa_data = crate::config::tfa::read()?.users; - - let mut out = Vec::::new(); - if top_level_allowed { - for (user, data) in tfa_data { - out.push(TfaUser { - userid: user, - entries: to_data(data), - }); - } - } else if let Some(data) = { tfa_data }.remove(authid.user()) { - out.push(TfaUser { - userid: authid.into(), - entries: to_data(data), - }); - } - - Ok(out) -} - -#[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)] -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() - } - } + let tfa_data = crate::config::tfa::read()?; + methods::list_tfa(&tfa_data, authid.user().as_str(), top_level_allowed) } #[api( @@ -392,7 +174,7 @@ impl TfaUpdateInfo { max_length: 255, optional: true, }, - "type": { type: TfaType }, + "type": { type: methods::TfaType }, totp: { description: "A totp URI.", optional: true, @@ -412,7 +194,7 @@ impl TfaUpdateInfo { }, }, }, - returns: { type: TfaUpdateInfo }, + returns: { type: methods::TfaUpdateInfo }, access: { permission: &Permission::Or(&[ &Permission::Privilege(&["access", "users"], PRIV_PERMISSIONS_MODIFY, false), @@ -429,90 +211,26 @@ fn add_tfa_entry( value: Option, challenge: Option, password: Option, - r#type: TfaType, + r#type: methods::TfaType, rpcenv: &mut dyn RpcEnvironment, -) -> Result { +) -> Result { tfa_update_auth(rpcenv, &userid, password, true)?; - let need_description = - move || description.ok_or_else(|| format_err!("'description' is required for new entries")); + let _lock = crate::config::tfa::write_lock()?; - match r#type { - TfaType::Totp => match (totp, value) { - (Some(totp), Some(value)) => { - if challenge.is_some() { - bail!("'challenge' parameter is invalid for 'totp' entries"); - } - let description = need_description()?; - - let totp: Totp = totp.parse()?; - if totp - .verify(&value, std::time::SystemTime::now(), -1..=1)? - .is_none() - { - bail!("failed to verify TOTP challenge"); - } - crate::config::tfa::add_totp(&userid, description, totp).map(TfaUpdateInfo::id) - } - _ => bail!("'totp' type requires both 'totp' and 'value' parameters"), - }, - TfaType::Webauthn => { - if totp.is_some() { - bail!("'totp' parameter is invalid for 'totp' entries"); - } - - match challenge { - None => crate::config::tfa::add_webauthn_registration(&userid, need_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)" - ) - })?; - crate::config::tfa::finish_webauthn_registration(&userid, &challenge, &value) - .map(TfaUpdateInfo::id) - } - } - } - TfaType::U2f => { - if totp.is_some() { - bail!("'totp' parameter is invalid for 'totp' entries"); - } - - match challenge { - None => crate::config::tfa::add_u2f_registration(&userid, need_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)") - })?; - crate::config::tfa::finish_u2f_registration(&userid, &challenge, &value) - .map(TfaUpdateInfo::id) - } - } - } - TfaType::Recovery => { - if totp.or(value).or(challenge).is_some() { - bail!("generating recovery tokens does not allow additional parameters"); - } - - let recovery = crate::config::tfa::add_recovery(&userid)?; - - Ok(TfaUpdateInfo { - id: Some("recovery".to_string()), - recovery, - ..Default::default() - }) - } - } + let mut data = crate::config::tfa::read()?; + let out = methods::add_tfa_entry( + &mut data, + UserAccess, + userid.as_str(), + description, + totp, + value, + challenge, + r#type, + )?; + crate::config::tfa::write(&data)?; + Ok(out) } #[api( @@ -560,21 +278,10 @@ fn update_tfa_entry( let _lock = crate::config::tfa::write_lock()?; let mut data = crate::config::tfa::read()?; - - let mut entry = data - .users - .get_mut(&userid) - .and_then(|user| user.find_entry_mut(&id)) - .ok_or_else(|| http_err!(NOT_FOUND, "no such entry: {}/{}", userid, id))?; - - if let Some(description) = description { - entry.description = description; + match methods::update_tfa_entry(&mut data, userid.as_str(), &id, description, enable) { + Ok(()) => (), + Err(methods::EntryNotFound) => http_bail!(NOT_FOUND, "no such entry: {}/{}", userid, id), } - - if let Some(enable) = enable { - entry.enable = enable; - } - crate::config::tfa::write(&data)?; Ok(()) } diff --git a/src/api2/access/user.rs b/src/api2/access/user.rs index 7901ab96..37fce461 100644 --- a/src/api2/access/user.rs +++ b/src/api2/access/user.rs @@ -374,7 +374,8 @@ pub fn delete_user(userid: Userid, digest: Option) -> Result<(), Error> } match crate::config::tfa::read().and_then(|mut cfg| { - let _: bool = cfg.remove_user(&userid); + let _: proxmox_tfa::api::NeedsSaving = + cfg.remove_user(crate::config::tfa::UserAccess, userid.as_str())?; crate::config::tfa::write(&cfg) }) { Ok(()) => (), diff --git a/src/api2/config/access/tfa.rs b/src/api2/config/access/tfa.rs index ec15446e..2fad7e2f 100644 --- a/src/api2/config/access/tfa.rs +++ b/src/api2/config/access/tfa.rs @@ -73,7 +73,10 @@ pub fn update_webauthn_config( if let Some(wa) = &mut tfa.webauthn { if let Some(ref digest) = digest { let digest = proxmox::tools::hex_to_digest(digest)?; - crate::tools::detect_modified_configuration_file(&digest, &wa.digest()?)?; + crate::tools::detect_modified_configuration_file( + &digest, + &crate::config::tfa::webauthn_config_digest(&wa)?, + )?; } if let Some(ref rp) = webauthn.rp { wa.rp = rp.clone(); } if let Some(ref origin) = webauthn.rp { wa.origin = origin.clone(); } diff --git a/src/config/tfa.rs b/src/config/tfa.rs index dae0f31c..c6476426 100644 --- a/src/config/tfa.rs +++ b/src/config/tfa.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::fs::File; use std::io::{self, Read, Seek, SeekFrom}; use std::os::unix::fs::OpenOptionsExt; @@ -7,36 +6,25 @@ use std::path::PathBuf; use anyhow::{bail, format_err, Error}; use nix::sys::stat::Mode; -use openssl::hash::MessageDigest; -use openssl::pkey::PKey; -use openssl::sign::Signer; -use serde::{de::Deserializer, Deserialize, Serialize}; -use serde_json::Value; -use webauthn_rs::{proto::UserVerificationPolicy, Webauthn}; - -use webauthn_rs::proto::Credential as WebauthnCredential; use proxmox::sys::error::SysError; use proxmox::tools::fs::CreateOptions; -use proxmox_schema::{api, Updater}; -use proxmox_tfa::{totp::Totp, u2f}; -use proxmox_uuid::Uuid; +use proxmox_tfa::totp::Totp; +pub use proxmox_tfa::api::{ + TfaChallenge, TfaConfig, TfaEntry, TfaInfo, TfaResponse, TfaUserData, U2fConfig, + WebauthnConfig, WebauthnConfigUpdater, +}; + +use pbs_api_types::{User, Userid}; use pbs_buildcfg::configdir; use pbs_config::{open_backup_lockfile, BackupLockGuard}; -use pbs_api_types::{Userid, User}; - -/// Mapping of userid to TFA entry. -pub type TfaUsers = HashMap; const CONF_FILE: &str = configdir!("/tfa.json"); const LOCK_FILE: &str = configdir!("/tfa.json.lock"); const CHALLENGE_DATA_PATH: &str = pbs_buildcfg::rundir!("/tfa/challenges"); -/// U2F registration challenges time out after 2 minutes. -const CHALLENGE_TIMEOUT: i64 = 2 * 60; - pub fn read_lock() -> Result { open_backup_lockfile(LOCK_FILE, None, false) } @@ -56,15 +44,20 @@ pub fn read() -> Result { Ok(serde_json::from_reader(file)?) } +pub(crate) fn webauthn_config_digest(config: &WebauthnConfig) -> Result<[u8; 32], Error> { + let digest_data = pbs_tools::json::to_canonical_json(&serde_json::to_value(config)?)?; + Ok(openssl::sha::sha256(&digest_data)) +} + /// Get the webauthn config with a digest. /// /// This is meant only for configuration updates, which currently only means webauthn updates. /// Since this is meant to be done only once (since changes will lock out users), this should be /// used rarely, since the digest calculation is currently a bit more involved. -pub fn webauthn_config() -> Result, Error>{ +pub fn webauthn_config() -> Result, Error> { Ok(match read()?.webauthn { Some(wa) => { - let digest = wa.digest()?; + let digest = webauthn_config_digest(&wa)?; Some((wa, digest)) } None => None, @@ -79,497 +72,26 @@ pub fn write(data: &TfaConfig) -> Result<(), Error> { proxmox::tools::fs::replace_file(CONF_FILE, &json, options, true) } -#[derive(Deserialize, Serialize)] -pub struct U2fConfig { - appid: String, -} - -#[api] -#[derive(Clone, Deserialize, Serialize, Updater)] -#[serde(deny_unknown_fields)] -/// Server side webauthn server configuration. -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, -} - -impl WebauthnConfig { - pub fn digest(&self) -> Result<[u8; 32], Error> { - let digest_data = pbs_tools::json::to_canonical_json(&serde_json::to_value(self)?)?; - Ok(openssl::sha::sha256(&digest_data)) - } -} - -/// 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() - } -} - -/// 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")) -} - -/// TFA Configuration for this instance. -#[derive(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, -} - -impl TfaConfig { - /// Get a two factor authentication challenge for a user, if the user has TFA set up. - pub fn login_challenge(&mut self, userid: &Userid) -> Result, Error> { - match self.users.get_mut(userid) { - Some(udata) => udata.challenge( - userid, - get_webauthn(&self.webauthn), - get_u2f(&self.u2f).as_ref(), - ), - None => Ok(None), - } - } - - /// Get a u2f registration challenge. - fn u2f_registration_challenge( - &mut self, - userid: &Userid, - description: String, - ) -> Result { - let u2f = check_u2f(&self.u2f)?; - - self.users - .entry(userid.clone()) - .or_default() - .u2f_registration_challenge(userid, &u2f, description) - } - - /// Finish a u2f registration challenge. - fn u2f_registration_finish( - &mut self, - userid: &Userid, - challenge: &str, - response: &str, - ) -> Result { - let u2f = check_u2f(&self.u2f)?; - - match self.users.get_mut(userid) { - Some(user) => user.u2f_registration_finish(userid, &u2f, challenge, response), - None => bail!("no such challenge"), - } - } - - /// Get a webauthn registration challenge. - fn webauthn_registration_challenge( - &mut self, - user: &Userid, - description: String, - ) -> Result { - let webauthn = check_webauthn(&self.webauthn)?; - - self.users - .entry(user.clone()) - .or_default() - .webauthn_registration_challenge(webauthn, user, description) - } - - /// Finish a webauthn registration challenge. - fn webauthn_registration_finish( - &mut self, - userid: &Userid, - 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(webauthn, userid, challenge, response), - None => bail!("no such challenge"), - } - } - - /// Verify a TFA response. - fn verify( - &mut self, - userid: &Userid, - challenge: &TfaChallenge, - response: TfaResponse, - ) -> Result<(), Error> { - match self.users.get_mut(userid) { - Some(user) => match response { - TfaResponse::Totp(value) => user.verify_totp(&value), - TfaResponse::U2f(value) => match &challenge.u2f { - Some(challenge) => { - let u2f = check_u2f(&self.u2f)?; - user.verify_u2f(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(userid, webauthn, value) - } - TfaResponse::Recovery(value) => user.verify_recovery(&value), - }, - None => bail!("no 2nd factor available for user '{}'", userid), - } - } - - /// Remove non-existent users. - pub fn cleanup_users(&mut self, config: &proxmox_section_config::SectionConfigData) { - self.users - .retain(|user, _| config.lookup::("user", user.as_str()).is_ok()); - } - - /// Remove a user. Returns `true` if the user actually existed. - pub fn remove_user(&mut self, user: &Userid) -> bool { - self.users.remove(user).is_some() - } -} - -#[api] -/// Over the API we only provide this part when querying a user's second factor list. -#[derive(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(crate) fn recovery(created: i64) -> Self { - Self { - id: "recovery".to_string(), - description: String::new(), - enable: true, - created, - } - } -} - -/// A TFA entry for a user. -/// -/// This simply connects a raw registration to a non optional descriptive text chosen by the user. -#[derive(Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct TfaEntry { - #[serde(flatten)] - pub info: TfaInfo, - - /// The actual entry. - 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, - } - } -} - -trait IsExpired { - fn is_expired(&self, at_epoch: i64) -> bool; -} - -/// A u2f registration challenge. -#[derive(Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct U2fRegistrationChallenge { - /// JSON formatted challenge string. - challenge: String, - - /// The description chosen by the user for this registration. - description: String, - - /// When the challenge was created as unix epoch. They are supposed to be short-lived. - created: i64, -} - -impl U2fRegistrationChallenge { - pub fn new(challenge: String, description: String) -> Self { - Self { - challenge, - description, - created: proxmox_time::epoch_i64(), - } - } -} - -impl IsExpired for U2fRegistrationChallenge { - fn is_expired(&self, at_epoch: i64) -> bool { - self.created < at_epoch - } -} - -/// A webauthn registration challenge. -#[derive(Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct WebauthnRegistrationChallenge { - /// Server side registration state data. - state: webauthn_rs::RegistrationState, - - /// While this is basically the content of a `RegistrationState`, the webauthn-rs crate doesn't - /// make this public. - challenge: String, - - /// The description chosen by the user for this registration. - 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. - state: webauthn_rs::AuthenticationState, - - /// While this is basically the content of a `AuthenticationState`, the webauthn-rs crate - /// doesn't make this public. - 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 - } -} - -/// Active TFA challenges per user, stored in `CHALLENGE_DATA_PATH`. -#[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 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 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_auths: Vec, +/// Cleanup non-existent users from the tfa config. +pub fn cleanup_users(data: &mut TfaConfig, config: &proxmox_section_config::SectionConfigData) { + data.users + .retain(|user, _| config.lookup::("user", user.as_str()).is_ok()); } /// Container of `TfaUserChallenges` with the corresponding file lock guard. /// /// TODO: Implement a general file lock guarded struct container in the `proxmox` crate. pub struct TfaUserChallengeData { - inner: TfaUserChallenges, + inner: proxmox_tfa::api::TfaUserChallenges, path: PathBuf, lock: File, } +fn challenge_data_path_str(userid: &str) -> PathBuf { + PathBuf::from(format!("{}/{}", CHALLENGE_DATA_PATH, userid)) +} + impl TfaUserChallengeData { - /// Build the path to the challenge data file for a user. - fn challenge_data_path(userid: &Userid) -> PathBuf { - PathBuf::from(format!("{}/{}", CHALLENGE_DATA_PATH, userid)) - } - - /// Load the user's current challenges with the intent to create a challenge (create the file - /// if it does not exist), and keep a lock on the file. - fn open(userid: &Userid) -> Result { - crate::server::create_run_dir()?; - let options = CreateOptions::new().perm(Mode::from_bits_truncate(0o0600)); - proxmox::tools::fs::create_path(CHALLENGE_DATA_PATH, Some(options.clone()), Some(options)) - .map_err(|err| { - format_err!( - "failed to crate challenge data dir {:?}: {}", - CHALLENGE_DATA_PATH, - err - ) - })?; - - let path = Self::challenge_data_path(userid); - - let mut file = std::fs::OpenOptions::new() - .create(true) - .read(true) - .write(true) - .truncate(false) - .mode(0o600) - .open(&path) - .map_err(|err| format_err!("failed to create challenge file {:?}: {}", path, err))?; - - proxmox::tools::fs::lock_file(&mut file, true, None)?; - - // the file may be empty, so read to a temporary buffer first: - let mut data = Vec::with_capacity(4096); - - file.read_to_end(&mut data).map_err(|err| { - format_err!("failed to read challenge data for user {}: {}", userid, err) - })?; - - let inner = if data.is_empty() { - Default::default() - } else { - serde_json::from_slice(&data).map_err(|err| { - format_err!( - "failed to parse challenge data for user {}: {}", - userid, - err - ) - })? - }; - - Ok(Self { - inner, - path, - lock: file, - }) - } - - /// `open` without creating the file if it doesn't exist, to finish WA authentications. - fn open_no_create(userid: &Userid) -> Result, Error> { - let path = Self::challenge_data_path(userid); - let mut file = match std::fs::OpenOptions::new() - .read(true) - .write(true) - .truncate(false) - .mode(0o600) - .open(&path) - { - Ok(file) => file, - Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), - Err(err) => return Err(err.into()), - }; - - proxmox::tools::fs::lock_file(&mut file, true, None)?; - - let inner = serde_json::from_reader(&mut file).map_err(|err| { - format_err!("failed to read challenge data for user {}: {}", userid, err) - })?; - - Ok(Some(Self { - inner, - path, - lock: file, - })) - } - /// Rewind & truncate the file for an update. fn rewind(&mut self) -> Result<(), Error> { let pos = self.lock.seek(SeekFrom::Start(0))?; @@ -601,592 +123,19 @@ impl TfaUserChallengeData { Ok(()) } - - /// 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; - - let index = self - .inner - .u2f_registrations - .iter() - .position(|r| r.challenge == challenge) - .ok_or_else(|| format_err!("no such challenge"))?; - - let reg = &self.inner.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.inner.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; - - let index = self - .inner - .webauthn_registrations - .iter() - .position(|r| r.challenge == challenge) - .ok_or_else(|| format_err!("no such challenge"))?; - - let reg = self.inner.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)) - } -} - -/// TFA data for a user. -#[derive(Default, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -#[serde(rename_all = "kebab-case")] -pub struct TfaUserData { - /// Totp keys for a user. - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub(crate) totp: Vec>, - - /// Registered u2f tokens for a user. - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub(crate) u2f: Vec>, - - /// Registered webauthn tokens for a user. - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub(crate) webauthn: Vec>, - - /// Recovery keys. (Unordered OTP values). - #[serde(skip_serializing_if = "Recovery::option_is_empty", default)] - pub(crate) recovery: Option, -} - -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.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); - } - } - - 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, - userid: &Userid, - u2f: &u2f::U2f, - description: String, - ) -> Result { - let challenge = serde_json::to_string(&u2f.registration_challenge()?)?; - - let mut data = TfaUserChallengeData::open(userid)?; - data.inner - .u2f_registrations - .push(U2fRegistrationChallenge::new( - challenge.clone(), - description, - )); - data.save()?; - - Ok(challenge) - } - - fn u2f_registration_finish( - &mut self, - userid: &Userid, - u2f: &u2f::U2f, - challenge: &str, - response: &str, - ) -> Result { - let mut data = TfaUserChallengeData::open(userid)?; - let entry = data.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, - mut webauthn: Webauthn, - userid: &Userid, - description: String, - ) -> Result { - let cred_ids: Vec<_> = self - .enabled_webauthn_entries() - .map(|cred| cred.cred_id.clone()) - .collect(); - - let userid_str = userid.to_string(); - let (challenge, state) = webauthn.generate_challenge_register_options( - userid_str.as_bytes().to_vec(), - userid_str.clone(), - userid_str.clone(), - 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 = TfaUserChallengeData::open(userid)?; - data.inner - .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, - webauthn: Webauthn, - userid: &Userid, - challenge: &str, - response: webauthn_rs::proto::RegisterPublicKeyCredential, - ) -> Result { - let mut data = TfaUserChallengeData::open(userid)?; - let entry = - data.webauthn_registration_finish(webauthn, challenge, response, &self.webauthn)?; - data.save()?; - - let id = entry.info.id.clone(); - self.webauthn.push(entry); - Ok(id) - } - - /// Generate a generic TFA challenge. See the [`TfaChallenge`] description for details. - pub fn challenge( - &mut self, - userid: &Userid, - 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(userid, webauthn)?, - None => None, - }, - u2f: match u2f { - Some(u2f) => self.u2f_challenge(u2f)?, - None => None, - }, - })) - } - - /// 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 }) - } - - /// Generate an optional u2f challenge. - fn u2f_challenge(&self, 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); - } - - Ok(Some(U2fChallenge { - challenge: u2f.auth_challenge()?, - keys, - })) - } - - /// Generate an optional webauthn challenge. - fn webauthn_challenge( - &mut self, - userid: &Userid, - 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 = TfaUserChallengeData::open(userid)?; - data.inner - .webauthn_auths - .push(WebauthnAuthChallenge::new(state, challenge_string)); - data.save()?; - - Ok(Some(challenge)) - } - - /// 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"); - } - - /// Verify a u2f response. - fn verify_u2f( - &self, - u2f: u2f::U2f, - challenge: &u2f::AuthChallenge, - response: Value, - ) -> Result<(), Error> { - let response: 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() - { - return Ok(()); - } - } - - bail!("u2f verification failed"); - } - - /// Verify a webauthn response. - fn verify_webauthn( - &mut self, - userid: &Userid, - mut webauthn: Webauthn, - mut response: Value, - ) -> Result<(), Error> { - let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT; - - 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 TfaUserChallengeData::open_no_create(userid)? { - Some(data) => data, - None => bail!("no such challenge"), - }; - - let index = data - .inner - .webauthn_auths - .iter() - .position(|r| r.challenge == challenge) - .ok_or_else(|| format_err!("no such challenge"))?; - - let challenge = data.inner.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"); - } - - /// 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) - } -} - -/// Recovery entries. We use HMAC-SHA256 with a random secret as a salted hash replacement. -#[derive(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. - fn generate() -> Result<(Self, Vec), Error> { - let mut secret = [0u8; 8]; - proxmox::sys::linux::fill_with_random_data(&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 - proxmox::sys::linux::fill_with_random_data(&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. - fn count_available(&self) -> usize { - self.available().count() - } - - /// Convenience serde method to check if either the option is `None` or the content `is_empty`. - 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. - 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) - } -} - -/// 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: Deserializer<'de>, - T: Deserialize<'de> + IsExpired, -{ - let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT; - Ok( - deserializer.deserialize_seq(crate::tools::serde_filter::FilteredVecVisitor::new( - "a challenge entry", - move |reg: &T| !reg.is_expired(expire_before), - ))?, - ) } /// Get an optional TFA challenge for a user. pub fn login_challenge(userid: &Userid) -> Result, Error> { let _lock = write_lock()?; - - let mut data = read()?; - Ok(match data.login_challenge(userid)? { - Some(challenge) => { - write(&data)?; - Some(challenge) - } - None => None, - }) + read()?.authentication_challenge(UserAccess, userid.as_str()) } /// Add a TOTP entry for a user. Returns the ID. pub fn add_totp(userid: &Userid, description: String, value: Totp) -> Result { let _lock = write_lock(); let mut data = read()?; - let entry = TfaEntry::new(description, value); - let id = entry.info.id.clone(); - data.users - .entry(userid.clone()) - .or_default() - .totp - .push(entry); + let id = data.add_totp(userid.as_str(), description, value); write(&data)?; Ok(id) } @@ -1196,11 +145,7 @@ pub fn add_recovery(userid: &Userid) -> Result, Error> { let _lock = write_lock(); let mut data = read()?; - let out = data - .users - .entry(userid.clone()) - .or_default() - .add_recovery()?; + let out = data.add_recovery(userid.as_str())?; write(&data)?; Ok(out) } @@ -1209,7 +154,7 @@ pub fn add_recovery(userid: &Userid) -> Result, Error> { pub fn add_u2f_registration(userid: &Userid, description: String) -> Result { let _lock = crate::config::tfa::write_lock(); let mut data = read()?; - let challenge = data.u2f_registration_challenge(userid, description)?; + let challenge = data.u2f_registration_challenge(UserAccess, userid.as_str(), description)?; write(&data)?; Ok(challenge) } @@ -1222,7 +167,7 @@ pub fn finish_u2f_registration( ) -> Result { let _lock = crate::config::tfa::write_lock(); let mut data = read()?; - let id = data.u2f_registration_finish(userid, challenge, response)?; + let id = data.u2f_registration_finish(UserAccess, userid.as_str(), challenge, response)?; write(&data)?; Ok(id) } @@ -1231,7 +176,8 @@ pub fn finish_u2f_registration( pub fn add_webauthn_registration(userid: &Userid, description: String) -> Result { let _lock = crate::config::tfa::write_lock(); let mut data = read()?; - let challenge = data.webauthn_registration_challenge(userid, description)?; + let challenge = + data.webauthn_registration_challenge(UserAccess, userid.as_str(), description)?; write(&data)?; Ok(challenge) } @@ -1244,7 +190,7 @@ pub fn finish_webauthn_registration( ) -> Result { let _lock = crate::config::tfa::write_lock(); let mut data = read()?; - let id = data.webauthn_registration_finish(userid, challenge, response)?; + let id = data.webauthn_registration_finish(UserAccess, userid.as_str(), challenge, response)?; write(&data)?; Ok(id) } @@ -1257,107 +203,121 @@ pub fn verify_challenge( ) -> Result<(), Error> { let _lock = crate::config::tfa::write_lock(); let mut data = read()?; - data.verify(userid, challenge, response)?; - write(&data)?; + if data + .verify(UserAccess, userid.as_str(), challenge, response)? + .needs_saving() + { + write(&data)?; + } Ok(()) } -/// 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); +#[derive(Clone, Copy)] +#[repr(transparent)] +pub struct UserAccess; -impl RecoveryState { - fn is_unavailable(&self) -> bool { - self.0.is_empty() +/// Build th +impl proxmox_tfa::api::OpenUserChallengeData for UserAccess { + type Data = TfaUserChallengeData; + + /// Load the user's current challenges with the intent to create a challenge (create the file + /// if it does not exist), and keep a lock on the file. + fn open(&self, userid: &str) -> Result { + crate::server::create_run_dir()?; + let options = CreateOptions::new().perm(Mode::from_bits_truncate(0o0600)); + proxmox::tools::fs::create_path(CHALLENGE_DATA_PATH, Some(options.clone()), Some(options)) + .map_err(|err| { + format_err!( + "failed to crate challenge data dir {:?}: {}", + CHALLENGE_DATA_PATH, + err + ) + })?; + + let path = challenge_data_path_str(userid); + + let mut file = std::fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .mode(0o600) + .open(&path) + .map_err(|err| format_err!("failed to create challenge file {:?}: {}", path, err))?; + + proxmox::tools::fs::lock_file(&mut file, true, None)?; + + // the file may be empty, so read to a temporary buffer first: + let mut data = Vec::with_capacity(4096); + + file.read_to_end(&mut data).map_err(|err| { + format_err!("failed to read challenge data for user {}: {}", userid, err) + })?; + + let inner = if data.is_empty() { + Default::default() + } else { + serde_json::from_slice(&data).map_err(|err| { + format_err!( + "failed to parse challenge data for user {}: {}", + userid, + err + ) + })? + }; + + Ok(TfaUserChallengeData { + inner, + path, + lock: file, + }) } -} -impl From<&Option> for RecoveryState { - fn from(r: &Option) -> Self { - match r { - Some(r) => Self::from(r), - None => Self::default(), + /// `open` without creating the file if it doesn't exist, to finish WA authentications. + fn open_no_create(&self, userid: &str) -> Result, Error> { + let path = challenge_data_path_str(userid); + let mut file = match std::fs::OpenOptions::new() + .read(true) + .write(true) + .truncate(false) + .mode(0o600) + .open(&path) + { + Ok(file) => file, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err.into()), + }; + + proxmox::tools::fs::lock_file(&mut file, true, None)?; + + let inner = serde_json::from_reader(&mut file).map_err(|err| { + format_err!("failed to read challenge data for user {}: {}", userid, err) + })?; + + Ok(Some(TfaUserChallengeData { + inner, + path, + lock: file, + })) + } + + /// `remove` user data if it exists. + fn remove(&self, userid: &str) -> Result { + let path = challenge_data_path_str(userid); + match std::fs::remove_file(&path) { + Ok(()) => Ok(true), + Err(err) if err.not_found() => Ok(false), + Err(err) => Err(err.into()), } } } -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(), - ) +impl proxmox_tfa::api::UserChallengeAccess for TfaUserChallengeData { + fn get_mut(&mut self) -> &mut proxmox_tfa::api::TfaUserChallenges { + &mut self.inner + } + + fn save(self) -> Result<(), Error> { + TfaUserChallengeData::save(self) } } - -/// 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. - 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, -} - -/// Data used for u2f challenges. -#[derive(Deserialize, Serialize)] -pub struct U2fChallenge { - /// AppID and challenge data. - challenge: u2f::AuthChallenge, - - /// Available tokens/keys. - keys: Vec, -} - -/// A user's response to a TFA challenge. -pub enum TfaResponse { - Totp(String), - U2f(Value), - Webauthn(Value), - Recovery(String), -} - -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"); - }) - } -} - -const fn default_tfa_enable() -> bool { - true -} - -const fn is_default_tfa_enable(v: &bool) -> bool { - *v -}