From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id D7B357D723 for ; Tue, 9 Nov 2021 12:27:53 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 7F473B092 for ; Tue, 9 Nov 2021 12:27:47 +0100 (CET) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS id C6DC2B067 for ; Tue, 9 Nov 2021 12:27:31 +0100 (CET) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 975BF4257F for ; Tue, 9 Nov 2021 12:27:31 +0100 (CET) From: Wolfgang Bumiller To: pve-devel@lists.proxmox.com Date: Tue, 9 Nov 2021 12:26:53 +0100 Message-Id: <20211109112721.130935-5-w.bumiller@proxmox.com> X-Mailer: git-send-email 2.30.2 In-Reply-To: <20211109112721.130935-1-w.bumiller@proxmox.com> References: <20211109112721.130935-1-w.bumiller@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.296 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_2 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_4 0.1 random spam to be learned in bayes SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [mod.rs, u2f.rs, recovery.rs, webauthn.rs, lib.rs, tfa.rs, api.rs] X-Mailman-Approved-At: Tue, 09 Nov 2021 14:12:20 +0100 Subject: [pve-devel] [PATCH proxmox-perl-rs 4/6] pve: add tfa api X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Tue, 09 Nov 2021 11:27:54 -0000 This consists of two parts: 1) A proxmox_tfa_api module which temporarily lives here but will become its own crate. Most of this is a copy from ' src/config/tfa.rs with some compatibility changes: * The #[api] macro is guarded by a feature flag, since we cannot use it for PVE. * The Userid type is replaced by &str since we don't have Userid in PVE either. * The file locking/reading is removed, this will stay in the corresponding product code, and the main entry point is now the TfaConfig object. * Access to the runtime active challenges in /run is provided via a trait implementation since PVE and PBS will use different paths for this. Essentially anything pbs-specific was removed and the code split into a few submodules (one per tfa type basically). 2) The tfa module in pve-rs, which contains: * The parser for the OLD /etc/pve/priv/tfa.cfg * The parser for the NEW /etc/pve/priv/tfa.cfg * These create a blessed PVE::RS::TFA instance which: - Wraps access to the TfaConfig rust object. - Has methods all the TFA API call implementations These are copied from PBS' src/api2/access/tfa.rs, and pbs specific code removed. Signed-off-by: Wolfgang Bumiller --- pve-rs/src/lib.rs | 1 + pve-rs/src/tfa/mod.rs | 965 ++++++++++++++++ pve-rs/src/tfa/proxmox_tfa_api/api.rs | 487 ++++++++ pve-rs/src/tfa/proxmox_tfa_api/mod.rs | 1003 +++++++++++++++++ pve-rs/src/tfa/proxmox_tfa_api/recovery.rs | 153 +++ pve-rs/src/tfa/proxmox_tfa_api/serde_tools.rs | 111 ++ pve-rs/src/tfa/proxmox_tfa_api/u2f.rs | 89 ++ pve-rs/src/tfa/proxmox_tfa_api/webauthn.rs | 118 ++ 8 files changed, 2927 insertions(+) create mode 100644 pve-rs/src/tfa/mod.rs create mode 100644 pve-rs/src/tfa/proxmox_tfa_api/api.rs create mode 100644 pve-rs/src/tfa/proxmox_tfa_api/mod.rs create mode 100644 pve-rs/src/tfa/proxmox_tfa_api/recovery.rs create mode 100644 pve-rs/src/tfa/proxmox_tfa_api/serde_tools.rs create mode 100644 pve-rs/src/tfa/proxmox_tfa_api/u2f.rs create mode 100644 pve-rs/src/tfa/proxmox_tfa_api/webauthn.rs diff --git a/pve-rs/src/lib.rs b/pve-rs/src/lib.rs index 15793ef..594a96f 100644 --- a/pve-rs/src/lib.rs +++ b/pve-rs/src/lib.rs @@ -4,3 +4,4 @@ pub mod apt; pub mod openid; +pub mod tfa; diff --git a/pve-rs/src/tfa/mod.rs b/pve-rs/src/tfa/mod.rs new file mode 100644 index 0000000..56e63f2 --- /dev/null +++ b/pve-rs/src/tfa/mod.rs @@ -0,0 +1,965 @@ +//! This implements the `tfa.cfg` parser & TFA API calls for PVE. +//! +//! The exported `PVE::RS::TFA` perl package provides access to rust's `TfaConfig` as well as +//! transparently providing the old style TFA config so that as long as users only have a single +//! TFA entry, the old authentication API still works. +//! +//! NOTE: In PVE the tfa config is behind `PVE::Cluster`'s `ccache` and therefore must be clonable +//! via `Storable::dclone`, so we implement the storable hooks `STORABLE_freeze` and +//! `STORABLE_attach`. Note that we only allow *cloning*, not freeze/thaw. + +use std::convert::TryFrom; +use std::fs::File; +use std::io::{self, Read}; +use std::os::unix::fs::OpenOptionsExt; +use std::os::unix::io::{AsRawFd, RawFd}; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, format_err, Error}; +use nix::errno::Errno; +use nix::sys::stat::Mode; +use serde_json::Value as JsonValue; + +mod proxmox_tfa_api; +pub(self) use proxmox_tfa_api::{ + RecoveryState, TfaChallenge, TfaConfig, TfaResponse, TfaUserData, U2fConfig, WebauthnConfig, +}; + +#[perlmod::package(name = "PVE::RS::TFA")] +mod export { + use std::convert::TryInto; + use std::sync::Mutex; + + use anyhow::{bail, format_err, Error}; + use serde_bytes::ByteBuf; + + use perlmod::Value; + + use super::proxmox_tfa_api::api; + use super::{TfaConfig, UserAccess}; + + perlmod::declare_magic!(Box : &Tfa as "PVE::RS::TFA"); + + /// A TFA Config instance. + pub struct Tfa { + inner: Mutex, + } + + /// Support `dclone` so this can be put into the `ccache` of `PVE::Cluster`. + #[export(name = "STORABLE_freeze", raw_return)] + fn storable_freeze(#[try_from_ref] this: &Tfa, cloning: bool) -> Result { + if !cloning { + bail!("freezing TFA config not supported!"); + } + + // An alternative would be to literally just *serialize* the data, then we wouldn't even + // need to restrict it to `cloning=true`, but since `clone=true` means we're immediately + // attaching anyway, this should be safe enough... + + let mut cloned = Box::new(Tfa { + inner: Mutex::new(this.inner.lock().unwrap().clone()), + }); + let value = Value::new_pointer::(&mut *cloned); + let _perl = Box::leak(cloned); + Ok(value) + } + + /// Instead of `thaw` we implement `attach` for `dclone`. + #[export(name = "STORABLE_attach", raw_return)] + fn storable_attach( + #[raw] class: Value, + cloning: bool, + #[raw] serialized: Value, + ) -> Result { + if !cloning { + bail!("STORABLE_attach called with cloning=false"); + } + let data = unsafe { Box::from_raw(serialized.pv_raw::()?) }; + + let mut hash = perlmod::Hash::new(); + super::generate_legacy_config(&mut hash, &data.inner.lock().unwrap()); + let hash = Value::Hash(hash); + let obj = Value::new_ref(&hash); + obj.bless_sv(&class)?; + hash.add_magic(MAGIC.with_value(data)); + Ok(obj) + + // Once we drop support for legacy authentication we can just do this: + // Ok(perlmod::instantiate_magic!(&class, MAGIC => data)) + } + + /// Parse a TFA configuration. + #[export(raw_return)] + fn new(#[raw] class: Value, config: &[u8]) -> Result { + let mut inner: TfaConfig = serde_json::from_slice(config) + .map_err(Error::from) + .or_else(|_err| super::parse_old_config(config)) + .map_err(|_err| { + format_err!("failed to parse TFA file, neither old style nor valid json") + })?; + + // In PVE, the U2F and Webauthn configurations come from `datacenter.cfg`. In case this + // config was copied from PBS, let's clear it out: + inner.u2f = None; + inner.webauthn = None; + + let mut hash = perlmod::Hash::new(); + super::generate_legacy_config(&mut hash, &inner); + let hash = Value::Hash(hash); + let obj = Value::new_ref(&hash); + obj.bless_sv(&class)?; + hash.add_magic(MAGIC.with_value(Box::new(Tfa { + inner: Mutex::new(inner), + }))); + Ok(obj) + + // Once we drop support for legacy authentication we can just do this: + // Ok(perlmod::instantiate_magic!( + // &class, MAGIC => Box::new(Tfa { inner: Mutex::new(inner) }) + // )) + } + + /// Write the configuration out into a JSON string. + #[export] + fn write(#[try_from_ref] this: &Tfa) -> Result { + let mut inner = this.inner.lock().unwrap(); + let u2f = inner.u2f.take(); + let webauthn = inner.webauthn.take(); + let output = serde_json::to_vec(&*inner); // must not use `?` here + inner.u2f = u2f; + inner.webauthn = webauthn; + Ok(ByteBuf::from(output?)) + } + + /// Debug helper: serialize the TFA user data into a perl value. + #[export] + fn to_perl(#[try_from_ref] this: &Tfa) -> Result { + let mut inner = this.inner.lock().unwrap(); + let u2f = inner.u2f.take(); + let webauthn = inner.webauthn.take(); + let output = Ok(perlmod::to_value(&*inner)?); + inner.u2f = u2f; + inner.webauthn = webauthn; + output + } + + /// Get a list of all the user names in this config. + /// PVE uses this to verify users and purge the invalid ones. + #[export] + fn users(#[try_from_ref] this: &Tfa) -> Result, Error> { + Ok(this.inner.lock().unwrap().users.keys().cloned().collect()) + } + + /// Remove a user from the TFA configuration. + #[export] + fn remove_user(#[try_from_ref] this: &Tfa, userid: &str) -> Result { + Ok(this.inner.lock().unwrap().users.remove(userid).is_some()) + } + + /// Get the TFA data for a specific user. + #[export(raw_return)] + fn get_user(#[try_from_ref] this: &Tfa, userid: &str) -> Result { + perlmod::to_value(&this.inner.lock().unwrap().users.get(userid)) + } + + /// Add a u2f registration. This modifies the config (adds the user to it), so it needs be + /// written out. + #[export] + fn add_u2f_registration( + #[raw] raw_this: Value, + //#[try_from_ref] this: &Tfa, + userid: &str, + description: String, + ) -> Result { + let this: &Tfa = (&raw_this).try_into()?; + let mut inner = this.inner.lock().unwrap(); + inner.u2f_registration_challenge(UserAccess::new(&raw_this)?, userid, description) + } + + /// Finish a u2f registration. This updates temporary data in `/run` and therefore the config + /// needs to be written out! + #[export] + fn finish_u2f_registration( + #[raw] raw_this: Value, + //#[try_from_ref] this: &Tfa, + userid: &str, + challenge: &str, + response: &str, + ) -> Result { + let this: &Tfa = (&raw_this).try_into()?; + let mut inner = this.inner.lock().unwrap(); + inner.u2f_registration_finish(UserAccess::new(&raw_this)?, userid, challenge, response) + } + + /// Check if a user has any TFA entries of a given type. + #[export] + fn has_type(#[try_from_ref] this: &Tfa, userid: &str, typename: &str) -> Result { + Ok(match this.inner.lock().unwrap().users.get(userid) { + Some(user) => match typename { + "totp" | "oath" => !user.totp.is_empty(), + "u2f" => !user.u2f.is_empty(), + "webauthn" => !user.webauthn.is_empty(), + "yubico" => !user.yubico.is_empty(), + "recovery" => match &user.recovery { + Some(r) => r.count_available() > 0, + None => false, + }, + _ => bail!("unrecognized TFA type {:?}", typename), + }, + None => false, + }) + } + + /// Generates a space separated list of yubico keys of this account. + #[export] + fn get_yubico_keys(#[try_from_ref] this: &Tfa, userid: &str) -> Result, Error> { + Ok(this.inner.lock().unwrap().users.get(userid).map(|user| { + user.enabled_yubico_entries() + .fold(String::new(), |mut s, k| { + if !s.is_empty() { + s.push(' '); + } + s.push_str(k); + s + }) + })) + } + + #[export] + fn set_u2f_config(#[try_from_ref] this: &Tfa, config: Option) { + this.inner.lock().unwrap().u2f = config; + } + + #[export] + fn set_webauthn_config(#[try_from_ref] this: &Tfa, config: Option) { + this.inner.lock().unwrap().webauthn = config; + } + + /// Create an authentication challenge. + /// + /// Returns the challenge as a json string. + /// Returns `undef` if no second factor is configured. + #[export] + fn authentication_challenge( + #[raw] raw_this: Value, + //#[try_from_ref] this: &Tfa, + userid: &str, + ) -> Result, Error> { + let this: &Tfa = (&raw_this).try_into()?; + let mut inner = this.inner.lock().unwrap(); + match inner.authentication_challenge(UserAccess::new(&raw_this)?, userid)? { + Some(challenge) => Ok(Some(serde_json::to_string(&challenge)?)), + None => Ok(None), + } + } + + /// Get the recovery state (suitable for a challenge object). + #[export] + fn recovery_state(#[try_from_ref] this: &Tfa, userid: &str) -> Option { + this.inner + .lock() + .unwrap() + .users + .get(userid) + .and_then(|user| { + let state = user.recovery_state(); + state.is_available().then(move || state) + }) + } + + /// Takes the TFA challenge string (which is a json object) and verifies ther esponse against + /// it. + /// + /// NOTE: This returns a boolean whether the config data needs to be *saved* after this call + /// (to use up recovery keys!). + #[export] + fn authentication_verify( + #[raw] raw_this: Value, + //#[try_from_ref] this: &Tfa, + userid: &str, + challenge: &str, //super::TfaChallenge, + response: &str, + ) -> Result { + let this: &Tfa = (&raw_this).try_into()?; + let challenge: super::TfaChallenge = serde_json::from_str(challenge)?; + let response: super::TfaResponse = response.parse()?; + let mut inner = this.inner.lock().unwrap(); + inner + .verify(UserAccess::new(&raw_this)?, userid, &challenge, response) + .map(|save| save.needs_saving()) + } + + /// DEBUG HELPER: Get the current TOTP value for a given TOTP URI. + #[export] + fn get_current_totp_value(otp_uri: &str) -> Result { + let totp: proxmox_tfa::totp::Totp = otp_uri.parse()?; + Ok(totp.time(std::time::SystemTime::now())?.to_string()) + } + + #[export] + fn api_list_user_tfa( + #[try_from_ref] this: &Tfa, + userid: &str, + ) -> Result, Error> { + api::list_user_tfa(&this.inner.lock().unwrap(), userid) + } + + #[export] + fn api_get_tfa_entry( + #[try_from_ref] this: &Tfa, + userid: &str, + id: &str, + ) -> Result, Error> { + api::get_tfa_entry(&this.inner.lock().unwrap(), userid, id) + } + + /// Returns `true` if the user still has other TFA entries left, `false` if the user has *no* + /// more tfa entries. + #[export] + fn api_delete_tfa(#[try_from_ref] this: &Tfa, userid: &str, id: String) -> Result { + let mut this = this.inner.lock().unwrap(); + match api::delete_tfa(&mut this, userid, id) { + Ok(has_entries_left) => Ok(has_entries_left), + Err(api::EntryNotFound) => bail!("no such entry"), + } + } + + #[export] + fn api_list_tfa( + #[try_from_ref] this: &Tfa, + authid: &str, + top_level_allowed: bool, + ) -> Result, Error> { + api::list_tfa(&this.inner.lock().unwrap(), authid, top_level_allowed) + } + + #[export] + fn api_add_tfa_entry( + #[raw] raw_this: Value, + //#[try_from_ref] this: &Tfa, + userid: &str, + description: Option, + totp: Option, + value: Option, + challenge: Option, + ty: api::TfaType, + ) -> Result { + let this: &Tfa = (&raw_this).try_into()?; + api::add_tfa_entry( + &mut this.inner.lock().unwrap(), + UserAccess::new(&raw_this)?, + userid, + description, + totp, + value, + challenge, + ty, + ) + } + + #[export] + fn api_update_tfa_entry( + #[try_from_ref] this: &Tfa, + userid: &str, + id: &str, + description: Option, + enable: Option, + ) -> Result<(), Error> { + match api::update_tfa_entry( + &mut this.inner.lock().unwrap(), + userid, + id, + description, + enable, + ) { + Ok(()) => Ok(()), + Err(api::EntryNotFound) => bail!("no such entry"), + } + } +} + +/// Version 1 format of `/etc/pve/priv/tfa.cfg` +/// =========================================== +/// +/// The TFA configuration in priv/tfa.cfg format contains one line per user of the form: +/// +/// USER:TYPE:DATA +/// +/// DATA is a base64 encoded json object and its format depends on the type. +/// +/// TYPEs +/// ----- +/// - oath +/// +/// This is a TOTP entry. In PVE, 1 such entry can contain multiple secrets, provided they use +/// the same configuration. +/// +/// DATA: { +/// "keys" => "string of space separated TOTP secrets", +/// "config" => { "step", "digits" }, +/// } +/// +/// - yubico +/// +/// Authentication using the Yubico API. +/// +/// DATA: { +/// "keys" => "string list of yubico keys", +/// } +/// +/// - u2f +/// +/// Legacy U2F entry for the U2F browser API. +/// +/// DATA: { +/// "keyHandle" => "u2f key handle", +/// "publicKey" => "u2f public key", +/// } +/// +fn parse_old_config(data: &[u8]) -> Result { + let mut config = TfaConfig::default(); + + for line in data.split(|&b| b == b'\n') { + let line = trim_ascii_whitespace(line); + if line.is_empty() || line.starts_with(b"#") { + continue; + } + + let mut parts = line.splitn(3, |&b| b == b':'); + let ((user, ty), data) = parts + .next() + .zip(parts.next()) + .zip(parts.next()) + .ok_or_else(|| format_err!("bad line in tfa config"))?; + + let user = std::str::from_utf8(user) + .map_err(|_err| format_err!("bad non-utf8 username in tfa config"))?; + + let data = base64::decode(data) + .map_err(|err| format_err!("failed to decode data in tfa config entry - {}", err))?; + + let entry = decode_old_entry(ty, &data, user)?; + config.users.insert(user.to_owned(), entry); + } + + Ok(config) +} + +fn decode_old_entry(ty: &[u8], data: &[u8], user: &str) -> Result { + let mut user_data = TfaUserData::default(); + + let info = proxmox_tfa_api::TfaInfo { + id: "v1-entry".to_string(), + description: "".to_string(), + created: 0, + enable: true, + }; + + let value: JsonValue = serde_json::from_slice(data) + .map_err(|err| format_err!("failed to parse json data in tfa entry - {}", err))?; + + match ty { + b"u2f" => user_data.u2f.push(proxmox_tfa_api::TfaEntry::from_parts( + info, + decode_old_u2f_entry(value)?, + )), + b"oath" => user_data.totp.extend( + decode_old_oath_entry(value, user)? + .into_iter() + .map(move |entry| proxmox_tfa_api::TfaEntry::from_parts(info.clone(), entry)), + ), + b"yubico" => user_data.yubico.extend( + decode_old_yubico_entry(value)? + .into_iter() + .map(move |entry| proxmox_tfa_api::TfaEntry::from_parts(info.clone(), entry)), + ), + other => match std::str::from_utf8(other) { + Ok(s) => bail!("unknown tfa.cfg entry type: {:?}", s), + Err(_) => bail!("unknown tfa.cfg entry type"), + }, + }; + + Ok(user_data) +} + +fn decode_old_u2f_entry(data: JsonValue) -> Result { + let mut obj = match data { + JsonValue::Object(obj) => obj, + _ => bail!("bad json type for u2f registration"), + }; + + let reg = proxmox_tfa::u2f::Registration { + key: proxmox_tfa::u2f::RegisteredKey { + key_handle: base64::decode_config( + take_json_string(&mut obj, "keyHandle", "u2f")?, + base64::URL_SAFE_NO_PAD, + ) + .map_err(|_| format_err!("handle in u2f entry"))?, + // PVE did not store this, but we only had U2F_V2 anyway... + version: "U2F_V2".to_string(), + }, + public_key: base64::decode(take_json_string(&mut obj, "publicKey", "u2f")?) + .map_err(|_| format_err!("bad public key in u2f entry"))?, + certificate: Vec::new(), + }; + + if !obj.is_empty() { + bail!("invalid extra data in u2f entry"); + } + + Ok(reg) +} + +fn decode_old_oath_entry( + data: JsonValue, + user: &str, +) -> Result, Error> { + let mut obj = match data { + JsonValue::Object(obj) => obj, + _ => bail!("bad json type for oath registration"), + }; + + let mut config = match obj.remove("config") { + Some(JsonValue::Object(obj)) => obj, + Some(_) => bail!("bad 'config' entry in oath tfa entry"), + None => bail!("missing 'config' entry in oath tfa entry"), + }; + + let mut totp = proxmox_tfa::totp::Totp::builder().account_name(user.to_owned()); + if let Some(step) = config.remove("step") { + totp = totp.period( + usize_from_perl(step).ok_or_else(|| format_err!("bad 'step' value in oath config"))?, + ); + } + + if let Some(digits) = config.remove("digits") { + totp = totp.digits( + usize_from_perl(digits) + .and_then(|v| u8::try_from(v).ok()) + .ok_or_else(|| format_err!("bad 'digits' value in oath config"))?, + ); + } + + if !config.is_empty() { + bail!("unhandled totp config keys in oath entry"); + } + + let mut out = Vec::new(); + + let keys = take_json_string(&mut obj, "keys", "oath")?; + for key in keys.split(|c| c == ',' || c == ';' || c == ' ') { + let key = trim_ascii_whitespace(key.as_bytes()); + if key.is_empty() { + continue; + } + + // key started out as a `String` and we only trimmed ASCII white space: + let key = unsafe { std::str::from_utf8_unchecked(key) }; + + // See PVE::OTP::oath_verify_otp + let key = if key.starts_with("v2-0x") { + hex::decode(&key[5..]).map_err(|_| format_err!("bad v2 hex key in oath entry"))? + } else if key.starts_with("v2-") { + base32::decode(base32::Alphabet::RFC4648 { padding: true }, &key[3..]) + .ok_or_else(|| format_err!("bad v2 base32 key in oath entry"))? + } else if key.len() == 16 { + base32::decode(base32::Alphabet::RFC4648 { padding: true }, key) + .ok_or_else(|| format_err!("bad v1 base32 key in oath entry"))? + } else if key.len() == 40 { + hex::decode(key).map_err(|_| format_err!("bad v1 hex key in oath entry"))? + } else { + bail!("unrecognized key format, must be hex or base32 encoded"); + }; + + out.push(totp.clone().secret(key).build()); + } + + Ok(out) +} + +fn decode_old_yubico_entry(data: JsonValue) -> Result, Error> { + let mut obj = match data { + JsonValue::Object(obj) => obj, + _ => bail!("bad json type for yubico registration"), + }; + + let mut out = Vec::new(); + + let keys = take_json_string(&mut obj, "keys", "yubico")?; + for key in keys.split(|c| c == ',' || c == ';' || c == ' ') { + let key = trim_ascii_whitespace(key.as_bytes()); + if key.is_empty() { + continue; + } + + // key started out as a `String` and we only trimmed ASCII white space: + out.push(unsafe { std::str::from_utf8_unchecked(key) }.to_owned()); + } + + Ok(out) +} + +fn take_json_string( + data: &mut serde_json::Map, + what: &'static str, + in_what: &'static str, +) -> Result { + match data.remove(what) { + None => bail!("missing '{}' value in {} entry", what, in_what), + Some(JsonValue::String(s)) => Ok(s), + _ => bail!("bad '{}' value", what), + } +} + +fn usize_from_perl(value: JsonValue) -> Option { + // we come from perl, numbers are strings! + match value { + JsonValue::Number(n) => n.as_u64().and_then(|n| usize::try_from(n).ok()), + JsonValue::String(s) => s.parse().ok(), + _ => None, + } +} + +fn trim_ascii_whitespace_start(data: &[u8]) -> &[u8] { + match data.iter().position(|&c| !c.is_ascii_whitespace()) { + Some(from) => &data[from..], + None => &data[..], + } +} + +fn trim_ascii_whitespace_end(data: &[u8]) -> &[u8] { + match data.iter().rposition(|&c| !c.is_ascii_whitespace()) { + Some(to) => &data[..to], + None => data, + } +} + +fn trim_ascii_whitespace(data: &[u8]) -> &[u8] { + trim_ascii_whitespace_start(trim_ascii_whitespace_end(data)) +} + +fn create_legacy_data(data: &TfaUserData) -> bool { + if !data.webauthn.is_empty() || data.recovery.is_some() || data.u2f.len() > 1 { + // incompatible + return false; + } + + if data.u2f.is_empty() && data.totp.is_empty() && data.yubico.is_empty() { + // no tfa configured + return false; + } + + if let Some(totp) = data.totp.get(0) { + let algorithm = totp.entry.algorithm(); + let digits = totp.entry.digits(); + let period = totp.entry.period(); + if period.subsec_nanos() != 0 { + return false; + } + + for totp in data.totp.iter().skip(1) { + if totp.entry.algorithm() != algorithm + || totp.entry.digits() != digits + || totp.entry.period() != period + { + return false; + } + } + } + return true; +} + +fn b64u_np_encode>(data: T) -> String { + base64::encode_config(data.as_ref(), base64::URL_SAFE_NO_PAD) +} + +// fn b64u_np_decode>(data: T) -> Result, base64::DecodeError> { +// base64::decode_config(data.as_ref(), base64::URL_SAFE_NO_PAD) +// } + +fn generate_legacy_config(out: &mut perlmod::Hash, config: &TfaConfig) { + use perlmod::{Hash, Value}; + + let users = Hash::new(); + + for (user, data) in &config.users { + if !create_legacy_data(data) { + continue; + } + + if let Some(u2f) = data.u2f.get(0) { + let data = Hash::new(); + data.insert( + "publicKey", + Value::new_string(&base64::encode(&u2f.entry.public_key)), + ); + data.insert( + "keyHandle", + Value::new_string(&b64u_np_encode(&u2f.entry.key.key_handle)), + ); + let data = Value::new_ref(&data); + + let entry = Hash::new(); + entry.insert("type", Value::new_string("u2f")); + entry.insert("data", data); + users.insert(user, Value::new_ref(&entry)); + continue; + } + + if let Some(totp) = data.totp.get(0) { + let totp = &totp.entry; + let config = Hash::new(); + config.insert("digits", Value::new_int(isize::from(totp.digits()))); + config.insert("step", Value::new_int(totp.period().as_secs() as isize)); + + let mut keys = format!("v2-0x{}", hex::encode(totp.secret())); + for totp in data.totp.iter().skip(1) { + keys.push_str(" v2-0x"); + keys.push_str(&hex::encode(totp.entry.secret())); + } + + let data = Hash::new(); + data.insert("config", Value::new_ref(&config)); + data.insert("keys", Value::new_string(&keys)); + + let entry = Hash::new(); + entry.insert("type", Value::new_string("oath")); + entry.insert("data", Value::new_ref(&data)); + users.insert(user, Value::new_ref(&entry)); + continue; + } + + if let Some(entry) = data.yubico.get(0) { + let mut keys = entry.entry.clone(); + + for entry in data.yubico.iter().skip(1) { + keys.push(' '); + keys.push_str(&entry.entry); + } + + let data = Hash::new(); + data.insert("keys", Value::new_string(&keys)); + + let entry = Hash::new(); + entry.insert("type", Value::new_string("yubico")); + entry.insert("data", Value::new_ref(&data)); + users.insert(user, Value::new_ref(&entry)); + continue; + } + } + + out.insert("users", Value::new_ref(&users)); +} + +/// Attach the path to errors from [`nix::mkir()`]. +pub(crate) fn mkdir>(path: P, mode: libc::mode_t) -> Result<(), Error> { + let path = path.as_ref(); + match nix::unistd::mkdir(path, unsafe { Mode::from_bits_unchecked(mode) }) { + Ok(()) => Ok(()), + Err(nix::Error::Sys(Errno::EEXIST)) => Ok(()), + Err(err) => bail!("failed to create directory {:?}: {}", path, err), + } +} + +#[cfg(debug_assertions)] +#[derive(Clone)] +#[repr(transparent)] +pub struct UserAccess(perlmod::Value); + +#[cfg(debug_assertions)] +impl UserAccess { + #[inline] + fn new(value: &perlmod::Value) -> Result { + value + .dereference() + .ok_or_else(|| format_err!("bad TFA config object")) + .map(Self) + } + + #[inline] + fn is_debug(&self) -> bool { + self.0 + .as_hash() + .and_then(|v| v.get("-debug")) + .map(|v| v.iv() != 0) + .unwrap_or(false) + } +} + +#[cfg(not(debug_assertions))] +#[derive(Clone, Copy)] +#[repr(transparent)] +pub struct UserAccess; + +#[cfg(not(debug_assertions))] +impl UserAccess { + #[inline] + const fn new(_value: &perlmod::Value) -> Result { + Ok(Self) + } + + #[inline] + const fn is_debug(&self) -> bool { + false + } +} + +/// Build the path to the challenge data file for a user. +fn challenge_data_path(userid: &str, debug: bool) -> PathBuf { + if debug { + PathBuf::from(format!("./local-tfa-challenges/{}", userid)) + } else { + PathBuf::from(format!("/run/pve-private/tfa-challenges/{}", userid)) + } +} + +impl proxmox_tfa_api::OpenUserChallengeData for UserAccess { + type Data = UserChallengeData; + + fn open(&self, userid: &str) -> Result { + if self.is_debug() { + mkdir("./local-tfa-challenges", 0o700)?; + } else { + mkdir("/run/pve-private", 0o700)?; + mkdir("/run/pve-private/tfa-challenges", 0o700)?; + } + + let path = challenge_data_path(userid, self.is_debug()); + + let mut file = std::fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .mode(0o600) + .open(&path) + .map_err(|err| format_err!("failed to create challenge file {:?}: {}", &path, err))?; + + UserChallengeData::lock_file(file.as_raw_fd())?; + + // the file may be empty, so read to a temporary buffer first: + let mut data = Vec::with_capacity(4096); + + file.read_to_end(&mut data).map_err(|err| { + format_err!("failed to read challenge data for user {}: {}", userid, err) + })?; + + let inner = if data.is_empty() { + Default::default() + } else { + serde_json::from_slice(&data).map_err(|err| { + format_err!( + "failed to parse challenge data for user {}: {}", + userid, + err + ) + })? + }; + + Ok(UserChallengeData { + inner, + path, + lock: file, + }) + } + + /// `open` without creating the file if it doesn't exist, to finish WA authentications. + fn open_no_create(&self, userid: &str) -> Result, Error> { + let path = challenge_data_path(userid, self.is_debug()); + + let mut file = match std::fs::OpenOptions::new() + .read(true) + .write(true) + .truncate(false) + .mode(0o600) + .open(&path) + { + Ok(file) => file, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err.into()), + }; + + UserChallengeData::lock_file(file.as_raw_fd())?; + + let inner = serde_json::from_reader(&mut file).map_err(|err| { + format_err!("failed to read challenge data for user {}: {}", userid, err) + })?; + + Ok(Some(UserChallengeData { + inner, + path, + lock: file, + })) + } +} + +/// Container of `TfaUserChallenges` with the corresponding file lock guard. +/// +/// Basically provides the TFA API to the REST server by persisting, updating and verifying active +/// challenges. +pub struct UserChallengeData { + inner: proxmox_tfa_api::TfaUserChallenges, + path: PathBuf, + lock: File, +} + +impl proxmox_tfa_api::UserChallengeAccess for UserChallengeData { + fn get_mut(&mut self) -> &mut proxmox_tfa_api::TfaUserChallenges { + &mut self.inner + } + + fn save(self) -> Result<(), Error> { + UserChallengeData::save(self) + } +} + +impl UserChallengeData { + fn lock_file(fd: RawFd) -> Result<(), Error> { + let rc = unsafe { libc::flock(fd, libc::LOCK_EX) }; + + if rc != 0 { + let err = io::Error::last_os_error(); + bail!("failed to lock tfa user challenge data: {}", err); + } + + Ok(()) + } + + /// Rewind & truncate the file for an update. + fn rewind(&mut self) -> Result<(), Error> { + use std::io::{Seek, SeekFrom}; + + let pos = self.lock.seek(SeekFrom::Start(0))?; + if pos != 0 { + bail!( + "unexpected result trying to rewind file, position is {}", + pos + ); + } + + let rc = unsafe { libc::ftruncate(self.lock.as_raw_fd(), 0) }; + if rc != 0 { + let err = io::Error::last_os_error(); + bail!("failed to truncate challenge data: {}", err); + } + + Ok(()) + } + + /// Save the current data. Note that we do not replace the file here since we lock the file + /// itself, as it is in `/run`, and the typical error case for this particular situation + /// (machine loses power) simply prevents some login, but that'll probably fail anyway for + /// other reasons then... + /// + /// This currently consumes selfe as we never perform more than 1 insertion/removal, and this + /// way also unlocks early. + fn save(mut self) -> Result<(), Error> { + self.rewind()?; + + serde_json::to_writer(&mut &self.lock, &self.inner).map_err(|err| { + format_err!("failed to update challenge file {:?}: {}", self.path, err) + })?; + + Ok(()) + } +} diff --git a/pve-rs/src/tfa/proxmox_tfa_api/api.rs b/pve-rs/src/tfa/proxmox_tfa_api/api.rs new file mode 100644 index 0000000..6be5205 --- /dev/null +++ b/pve-rs/src/tfa/proxmox_tfa_api/api.rs @@ -0,0 +1,487 @@ +//! API interaction module. +//! +//! This defines the methods & types used in the authentication and TFA configuration API between +//! PBS, PVE, PMG. + +use anyhow::{bail, format_err, Error}; +use serde::{Deserialize, Serialize}; + +use proxmox_tfa::totp::Totp; + +#[cfg(feature = "api-types")] +use proxmox_schema::api; + +use super::{OpenUserChallengeData, TfaConfig, TfaInfo, TfaUserData}; + +#[cfg_attr(feature = "api-types", api)] +/// A TFA entry type. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum TfaType { + /// A TOTP entry type. + Totp, + /// A U2F token entry. + U2f, + /// A Webauthn token entry. + Webauthn, + /// Recovery tokens. + Recovery, + /// Yubico authentication entry. + Yubico, +} + +#[cfg_attr(feature = "api-types", api( + properties: { + type: { type: TfaType }, + info: { type: TfaInfo }, + }, +))] +/// A TFA entry for a user. +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TypedTfaInfo { + #[serde(rename = "type")] + pub ty: TfaType, + + #[serde(flatten)] + pub info: TfaInfo, +} + +fn to_data(data: &TfaUserData) -> Vec { + let mut out = Vec::with_capacity( + data.totp.len() + + data.u2f.len() + + data.webauthn.len() + + data.yubico.len() + + if data.recovery().is_some() { 1 } else { 0 }, + ); + if let Some(recovery) = data.recovery() { + out.push(TypedTfaInfo { + ty: TfaType::Recovery, + info: TfaInfo::recovery(recovery.created), + }) + } + for entry in &data.totp { + out.push(TypedTfaInfo { + ty: TfaType::Totp, + info: entry.info.clone(), + }); + } + for entry in &data.webauthn { + out.push(TypedTfaInfo { + ty: TfaType::Webauthn, + info: entry.info.clone(), + }); + } + for entry in &data.u2f { + out.push(TypedTfaInfo { + ty: TfaType::U2f, + info: entry.info.clone(), + }); + } + for entry in &data.yubico { + out.push(TypedTfaInfo { + ty: TfaType::Yubico, + info: entry.info.clone(), + }); + } + out +} + +/// Iterate through tuples of `(type, index, id)`. +fn tfa_id_iter(data: &TfaUserData) -> impl Iterator { + data.totp + .iter() + .enumerate() + .map(|(i, entry)| (TfaType::Totp, i, entry.info.id.as_str())) + .chain( + data.webauthn + .iter() + .enumerate() + .map(|(i, entry)| (TfaType::Webauthn, i, entry.info.id.as_str())), + ) + .chain( + data.u2f + .iter() + .enumerate() + .map(|(i, entry)| (TfaType::U2f, i, entry.info.id.as_str())), + ) + .chain( + data.yubico + .iter() + .enumerate() + .map(|(i, entry)| (TfaType::Yubico, i, entry.info.id.as_str())), + ) + .chain( + data.recovery + .iter() + .map(|_| (TfaType::Recovery, 0, "recovery")), + ) +} + +/// API call implementation for `GET /access/tfa/{userid}` +/// +/// Permissions for accessing `userid` must have been verified by the caller. +pub fn list_user_tfa(config: &TfaConfig, userid: &str) -> Result, Error> { + Ok(match config.users.get(userid) { + Some(data) => to_data(data), + None => Vec::new(), + }) +} + +/// API call implementation for `GET /access/tfa/{userid}/{ID}`. +/// +/// Permissions for accessing `userid` must have been verified by the caller. +/// +/// In case this returns `None` a `NOT_FOUND` http error should be returned. +pub fn get_tfa_entry( + config: &TfaConfig, + userid: &str, + id: &str, +) -> Result, Error> { + let user_data = match config.users.get(userid) { + Some(u) => u, + None => return Ok(None), + }; + + Ok(Some( + match { + // scope to prevent the temporary iter from borrowing across the whole match + let entry = tfa_id_iter(&user_data).find(|(_ty, _index, entry_id)| id == *entry_id); + entry.map(|(ty, index, _)| (ty, index)) + } { + Some((TfaType::Recovery, _)) => match user_data.recovery() { + Some(recovery) => TypedTfaInfo { + ty: TfaType::Recovery, + info: TfaInfo::recovery(recovery.created), + }, + None => return Ok(None), + }, + Some((TfaType::Totp, index)) => { + TypedTfaInfo { + ty: TfaType::Totp, + // `into_iter().nth()` to *move* out of it + info: user_data.totp.iter().nth(index).unwrap().info.clone(), + } + } + Some((TfaType::Webauthn, index)) => TypedTfaInfo { + ty: TfaType::Webauthn, + info: user_data.webauthn.iter().nth(index).unwrap().info.clone(), + }, + Some((TfaType::U2f, index)) => TypedTfaInfo { + ty: TfaType::U2f, + info: user_data.u2f.iter().nth(index).unwrap().info.clone(), + }, + Some((TfaType::Yubico, index)) => TypedTfaInfo { + ty: TfaType::Yubico, + info: user_data.yubico.iter().nth(index).unwrap().info.clone(), + }, + None => return Ok(None), + }, + )) +} + +pub struct EntryNotFound; + +/// API call implementation for `DELETE /access/tfa/{userid}/{ID}`. +/// +/// The caller must have already verified the user's password. +/// +/// The TFA config must be WRITE locked. +/// +/// The caller must *save* the config afterwards! +/// +/// Errors only if the entry was not found. +/// +/// Returns `true` if the user still has other TFA entries left, `false` if the user has *no* more +/// tfa entries. +pub fn delete_tfa(config: &mut TfaConfig, userid: &str, id: String) -> Result { + let user_data = config.users.get_mut(userid).ok_or(EntryNotFound)?; + + match { + // scope to prevent the temporary iter from borrowing across the whole match + let entry = tfa_id_iter(&user_data).find(|(_, _, entry_id)| id == *entry_id); + entry.map(|(ty, index, _)| (ty, index)) + } { + Some((TfaType::Recovery, _)) => user_data.recovery = None, + Some((TfaType::Totp, index)) => drop(user_data.totp.remove(index)), + Some((TfaType::Webauthn, index)) => drop(user_data.webauthn.remove(index)), + Some((TfaType::U2f, index)) => drop(user_data.u2f.remove(index)), + Some((TfaType::Yubico, index)) => drop(user_data.yubico.remove(index)), + None => return Err(EntryNotFound), + } + + if user_data.is_empty() { + config.users.remove(userid); + Ok(false) + } else { + Ok(true) + } +} + +#[cfg_attr(feature = "api-types", api( + properties: { + "userid": { type: Userid }, + "entries": { + type: Array, + items: { type: TypedTfaInfo }, + }, + }, +))] +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +/// Over the API we only provide the descriptions for TFA data. +pub struct TfaUser { + /// The user this entry belongs to. + userid: String, + + /// TFA entries. + entries: Vec, +} + +/// API call implementation for `GET /access/tfa`. +/// +/// Caller needs to have performed the required privilege checks already. +pub fn list_tfa( + config: &TfaConfig, + authid: &str, + top_level_allowed: bool, +) -> Result, Error> { + let tfa_data = &config.users; + + let mut out = Vec::::new(); + if top_level_allowed { + for (user, data) in tfa_data { + out.push(TfaUser { + userid: user.clone(), + entries: to_data(data), + }); + } + } else if let Some(data) = { tfa_data }.get(authid) { + out.push(TfaUser { + userid: authid.into(), + entries: to_data(data), + }); + } + + Ok(out) +} + +#[cfg_attr(feature = "api-types", api( + properties: { + recovery: { + description: "A list of recovery codes as integers.", + type: Array, + items: { + type: Integer, + description: "A one-time usable recovery code entry.", + }, + }, + }, +))] +/// The result returned when adding TFA entries to a user. +#[derive(Default, Serialize)] +pub struct TfaUpdateInfo { + /// The id if a newly added TFA entry. + id: Option, + + /// When adding u2f entries, this contains a challenge the user must respond to in order to + /// finish the registration. + #[serde(skip_serializing_if = "Option::is_none")] + challenge: Option, + + /// When adding recovery codes, this contains the list of codes to be displayed to the user + /// this one time. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + recovery: Vec, +} + +impl TfaUpdateInfo { + fn id(id: String) -> Self { + Self { + id: Some(id), + ..Default::default() + } + } +} + +fn need_description(description: Option) -> Result { + description.ok_or_else(|| format_err!("'description' is required for new entries")) +} + +/// API call implementation for `POST /access/tfa/{userid}`. +/// +/// Permissions for accessing `userid` must have been verified by the caller. +/// +/// The caller must have already verified the user's password! +pub fn add_tfa_entry( + config: &mut TfaConfig, + access: A, + userid: &str, + description: Option, + totp: Option, + value: Option, + challenge: Option, + r#type: TfaType, +) -> Result { + match r#type { + TfaType::Totp => { + if challenge.is_some() { + bail!("'challenge' parameter is invalid for 'totp' entries"); + } + + add_totp(config, userid, need_description(description)?, totp, value) + } + TfaType::Webauthn => { + if totp.is_some() { + bail!("'totp' parameter is invalid for 'webauthn' entries"); + } + + add_webauthn(config, access, userid, description, challenge, value) + } + TfaType::U2f => { + if totp.is_some() { + bail!("'totp' parameter is invalid for 'u2f' entries"); + } + + add_u2f(config, access, userid, description, challenge, value) + } + TfaType::Recovery => { + if totp.or(value).or(challenge).is_some() { + bail!("generating recovery tokens does not allow additional parameters"); + } + + let recovery = config.add_recovery(&userid)?; + + Ok(TfaUpdateInfo { + id: Some("recovery".to_string()), + recovery, + ..Default::default() + }) + } + TfaType::Yubico => { + if totp.or(challenge).is_some() { + bail!("'totp' and 'challenge' parameters are invalid for 'yubico' entries"); + } + + add_yubico(config, userid, need_description(description)?, value) + } + } +} + +fn add_totp( + config: &mut TfaConfig, + userid: &str, + description: String, + totp: Option, + value: Option, +) -> Result { + let (totp, value) = match (totp, value) { + (Some(totp), Some(value)) => (totp, value), + _ => bail!("'totp' type requires both 'totp' and 'value' parameters"), + }; + + let totp: Totp = totp.parse()?; + if totp + .verify(&value, std::time::SystemTime::now(), -1..=1)? + .is_none() + { + bail!("failed to verify TOTP challenge"); + } + config + .add_totp(userid, description, totp) + .map(TfaUpdateInfo::id) +} + +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"))?; + config + .add_yubico(userid, description, key) + .map(TfaUpdateInfo::id) +} + +fn add_u2f( + config: &mut TfaConfig, + access: A, + userid: &str, + description: Option, + challenge: Option, + value: Option, +) -> Result { + match challenge { + None => config + .u2f_registration_challenge(access, userid, need_description(description)?) + .map(|c| TfaUpdateInfo { + challenge: Some(c), + ..Default::default() + }), + Some(challenge) => { + let value = value.ok_or_else(|| { + format_err!("missing 'value' parameter (u2f challenge response missing)") + })?; + config + .u2f_registration_finish(access, userid, &challenge, &value) + .map(TfaUpdateInfo::id) + } + } +} + +fn add_webauthn( + config: &mut TfaConfig, + access: A, + userid: &str, + description: Option, + challenge: Option, + value: Option, +) -> Result { + match challenge { + None => config + .webauthn_registration_challenge(access, &userid, need_description(description)?) + .map(|c| TfaUpdateInfo { + challenge: Some(c), + ..Default::default() + }), + Some(challenge) => { + let value = value.ok_or_else(|| { + format_err!("missing 'value' parameter (webauthn challenge response missing)") + })?; + config + .webauthn_registration_finish(access, &userid, &challenge, &value) + .map(TfaUpdateInfo::id) + } + } +} + +/// API call implementation for `PUT /access/tfa/{userid}/{id}`. +/// +/// The caller must have already verified the user's password. +/// +/// Errors only if the entry was not found. +pub fn update_tfa_entry( + config: &mut TfaConfig, + userid: &str, + id: &str, + description: Option, + enable: Option, +) -> Result<(), EntryNotFound> { + let mut entry = config + .users + .get_mut(userid) + .and_then(|user| user.find_entry_mut(id)) + .ok_or(EntryNotFound)?; + + if let Some(description) = description { + entry.description = description; + } + + if let Some(enable) = enable { + entry.enable = enable; + } + + Ok(()) +} diff --git a/pve-rs/src/tfa/proxmox_tfa_api/mod.rs b/pve-rs/src/tfa/proxmox_tfa_api/mod.rs new file mode 100644 index 0000000..bd5ab27 --- /dev/null +++ b/pve-rs/src/tfa/proxmox_tfa_api/mod.rs @@ -0,0 +1,1003 @@ +//! TFA configuration and user data. +//! +//! This is the same as used in PBS but without the `#[api]` type. +//! +//! We may want to move this into a shared crate making the `#[api]` macro feature-gated! + +use std::collections::HashMap; + +use anyhow::{bail, format_err, Error}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use webauthn_rs::proto::Credential as WebauthnCredential; +use webauthn_rs::{proto::UserVerificationPolicy, Webauthn}; + +use proxmox_tfa::totp::Totp; +use proxmox_uuid::Uuid; + +#[cfg(feature = "api-types")] +use proxmox_schema::api; + +mod serde_tools; + +mod recovery; +mod u2f; +mod webauthn; + +pub mod api; + +pub use recovery::RecoveryState; +pub use u2f::U2fConfig; +pub use webauthn::WebauthnConfig; + +use recovery::Recovery; +use u2f::{U2fChallenge, U2fChallengeEntry, U2fRegistrationChallenge}; +use webauthn::{WebauthnAuthChallenge, WebauthnRegistrationChallenge}; + +trait IsExpired { + fn is_expired(&self, at_epoch: i64) -> bool; +} + +pub trait OpenUserChallengeData: Clone { + type Data: UserChallengeAccess; + + fn open(&self, userid: &str) -> Result; + fn open_no_create(&self, userid: &str) -> Result, Error>; +} + +pub trait UserChallengeAccess: Sized { + //fn open(userid: &str) -> Result; + //fn open_no_create(userid: &str) -> Result, Error>; + fn get_mut(&mut self) -> &mut TfaUserChallenges; + fn save(self) -> Result<(), Error>; +} + +const CHALLENGE_TIMEOUT_SECS: i64 = 2 * 60; + +/// TFA Configuration for this instance. +#[derive(Clone, Default, Deserialize, Serialize)] +pub struct TfaConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub u2f: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub webauthn: Option, + + #[serde(skip_serializing_if = "TfaUsers::is_empty", default)] + pub users: TfaUsers, +} + +/// Helper to get a u2f instance from a u2f config, or `None` if there isn't one configured. +fn get_u2f(u2f: &Option) -> Option { + u2f.as_ref() + .map(|cfg| u2f::U2f::new(cfg.appid.clone(), cfg.appid.clone())) +} + +/// Helper to get a u2f instance from a u2f config. +/// +/// This is outside of `TfaConfig` to not borrow its `&self`. +fn check_u2f(u2f: &Option) -> Result { + get_u2f(u2f).ok_or_else(|| format_err!("no u2f configuration available")) +} + +/// Helper to get a `Webauthn` instance from a `WebauthnConfig`, or `None` if there isn't one +/// configured. +fn get_webauthn(waconfig: &Option) -> Option> { + waconfig.clone().map(Webauthn::new) +} + +/// Helper to get a u2f instance from a u2f config. +/// +/// This is outside of `TfaConfig` to not borrow its `&self`. +fn check_webauthn(waconfig: &Option) -> Result, Error> { + get_webauthn(waconfig).ok_or_else(|| format_err!("no webauthn configuration available")) +} + +impl TfaConfig { + // Get a u2f registration challenge. + pub fn u2f_registration_challenge( + &mut self, + access: A, + userid: &str, + description: String, + ) -> Result { + let u2f = check_u2f(&self.u2f)?; + + self.users + .entry(userid.to_owned()) + .or_default() + .u2f_registration_challenge(access, userid, &u2f, description) + } + + /// Finish a u2f registration challenge. + pub fn u2f_registration_finish( + &mut self, + access: A, + userid: &str, + challenge: &str, + response: &str, + ) -> Result { + let u2f = check_u2f(&self.u2f)?; + + match self.users.get_mut(userid) { + Some(user) => user.u2f_registration_finish(access, userid, &u2f, challenge, response), + None => bail!("no such challenge"), + } + } + + /// Get a webauthn registration challenge. + fn webauthn_registration_challenge( + &mut self, + access: A, + user: &str, + description: String, + ) -> Result { + let webauthn = check_webauthn(&self.webauthn)?; + + self.users + .entry(user.to_owned()) + .or_default() + .webauthn_registration_challenge(access, webauthn, user, description) + } + + /// Finish a webauthn registration challenge. + fn webauthn_registration_finish( + &mut self, + access: A, + userid: &str, + challenge: &str, + response: &str, + ) -> Result { + let webauthn = check_webauthn(&self.webauthn)?; + + let response: webauthn_rs::proto::RegisterPublicKeyCredential = + serde_json::from_str(response) + .map_err(|err| format_err!("error parsing challenge response: {}", err))?; + + match self.users.get_mut(userid) { + Some(user) => { + user.webauthn_registration_finish(access, webauthn, userid, challenge, response) + } + None => bail!("no such challenge"), + } + } + + /// Add a TOTP entry for a user. + /// + /// Unlike U2F/WA, this does not require a challenge/response. The user can choose their secret + /// themselves. + pub fn add_totp( + &mut self, + userid: &str, + description: String, + value: Totp, + ) -> Result { + Ok(self + .users + .entry(userid.to_owned()) + .or_default() + .add_totp(description, value)) + } + + /// Add a Yubico key to a user. + /// + /// Unlike U2F/WA, this does not require a challenge/response. The user can choose their secret + /// themselves. + pub fn add_yubico( + &mut self, + userid: &str, + description: String, + key: String, + ) -> Result { + Ok(self + .users + .entry(userid.to_owned()) + .or_default() + .add_yubico(description, key)) + } + + /// Add a new set of recovery keys. There can only be 1 set of keys at a time. + fn add_recovery(&mut self, userid: &str) -> Result, Error> { + self.users + .entry(userid.to_owned()) + .or_default() + .add_recovery() + } + + /// Get a two factor authentication challenge for a user, if the user has TFA set up. + pub fn authentication_challenge( + &mut self, + access: A, + userid: &str, + ) -> Result, Error> { + match self.users.get_mut(userid) { + Some(udata) => udata.challenge( + access, + userid, + get_webauthn(&self.webauthn), + get_u2f(&self.u2f).as_ref(), + ), + None => Ok(None), + } + } + + /// Verify a TFA challenge. + pub fn verify( + &mut self, + access: A, + userid: &str, + challenge: &TfaChallenge, + response: TfaResponse, + ) -> Result { + match self.users.get_mut(userid) { + Some(user) => match response { + TfaResponse::Totp(value) => user.verify_totp(&value), + TfaResponse::U2f(value) => match &challenge.u2f { + Some(challenge) => { + let u2f = check_u2f(&self.u2f)?; + user.verify_u2f(access.clone(), userid, u2f, &challenge.challenge, value) + } + None => bail!("no u2f factor available for user '{}'", userid), + }, + TfaResponse::Webauthn(value) => { + let webauthn = check_webauthn(&self.webauthn)?; + user.verify_webauthn(access.clone(), userid, webauthn, value) + } + TfaResponse::Recovery(value) => { + user.verify_recovery(&value)?; + return Ok(NeedsSaving::Yes); + } + }, + None => bail!("no 2nd factor available for user '{}'", userid), + }?; + + Ok(NeedsSaving::No) + } +} + +#[must_use = "must save the config in order to ensure one-time use of recovery keys"] +#[derive(Clone, Copy)] +pub enum NeedsSaving { + No, + Yes, +} + +impl NeedsSaving { + /// Convenience method so we don't need to import the type name. + pub fn needs_saving(self) -> bool { + matches!(self, NeedsSaving::Yes) + } +} + +/// Mapping of userid to TFA entry. +pub type TfaUsers = HashMap; + +/// TFA data for a user. +#[derive(Clone, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +#[serde(rename_all = "kebab-case")] +#[serde(bound(deserialize = "", serialize = ""))] +pub struct TfaUserData { + /// Totp keys for a user. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub totp: Vec>, + + /// Registered u2f tokens for a user. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub u2f: Vec>, + + /// Registered webauthn tokens for a user. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub webauthn: Vec>, + + /// Recovery keys. (Unordered OTP values). + #[serde(skip_serializing_if = "Recovery::option_is_empty", default)] + pub recovery: Option, + + /// Yubico keys for a user. NOTE: This is not directly supported currently, we just need this + /// available for PVE, where the yubico API server configuration is part if the realm. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub yubico: Vec>, +} + +impl TfaUserData { + /// Shortcut to get the recovery entry only if it is not empty! + pub fn recovery(&self) -> Option<&Recovery> { + if Recovery::option_is_empty(&self.recovery) { + None + } else { + self.recovery.as_ref() + } + } + + /// `true` if no second factors exist + pub fn is_empty(&self) -> bool { + self.totp.is_empty() + && self.u2f.is_empty() + && self.webauthn.is_empty() + && self.yubico.is_empty() + && self.recovery().is_none() + } + + /// Find an entry by id, except for the "recovery" entry which we're currently treating + /// specially. + pub fn find_entry_mut<'a>(&'a mut self, id: &str) -> Option<&'a mut TfaInfo> { + for entry in &mut self.totp { + if entry.info.id == id { + return Some(&mut entry.info); + } + } + + for entry in &mut self.webauthn { + if entry.info.id == id { + return Some(&mut entry.info); + } + } + + for entry in &mut self.u2f { + if entry.info.id == id { + return Some(&mut entry.info); + } + } + + for entry in &mut self.yubico { + if entry.info.id == id { + return Some(&mut entry.info); + } + } + + None + } + + /// Create a u2f registration challenge. + /// + /// The description is required at this point already mostly to better be able to identify such + /// challenges in the tfa config file if necessary. The user otherwise has no access to this + /// information at this point, as the challenge is identified by its actual challenge data + /// instead. + fn u2f_registration_challenge( + &mut self, + access: A, + userid: &str, + u2f: &u2f::U2f, + description: String, + ) -> Result { + let challenge = serde_json::to_string(&u2f.registration_challenge()?)?; + + let mut data = access.open(userid)?; + data.get_mut() + .u2f_registrations + .push(U2fRegistrationChallenge::new( + challenge.clone(), + description, + )); + data.save()?; + + Ok(challenge) + } + + fn u2f_registration_finish( + &mut self, + access: A, + userid: &str, + u2f: &u2f::U2f, + challenge: &str, + response: &str, + ) -> Result { + let mut data = access.open(userid)?; + let entry = data + .get_mut() + .u2f_registration_finish(u2f, challenge, response)?; + data.save()?; + + let id = entry.info.id.clone(); + self.u2f.push(entry); + Ok(id) + } + + /// Create a webauthn registration challenge. + /// + /// The description is required at this point already mostly to better be able to identify such + /// challenges in the tfa config file if necessary. The user otherwise has no access to this + /// information at this point, as the challenge is identified by its actual challenge data + /// instead. + fn webauthn_registration_challenge( + &mut self, + access: A, + mut webauthn: Webauthn, + userid: &str, + description: String, + ) -> Result { + let cred_ids: Vec<_> = self + .enabled_webauthn_entries() + .map(|cred| cred.cred_id.clone()) + .collect(); + + let (challenge, state) = webauthn.generate_challenge_register_options( + userid.as_bytes().to_vec(), + userid.to_owned(), + userid.to_owned(), + Some(cred_ids), + Some(UserVerificationPolicy::Discouraged), + )?; + + let challenge_string = challenge.public_key.challenge.to_string(); + let challenge = serde_json::to_string(&challenge)?; + + let mut data = access.open(userid)?; + data.get_mut() + .webauthn_registrations + .push(WebauthnRegistrationChallenge::new( + state, + challenge_string, + description, + )); + data.save()?; + + Ok(challenge) + } + + /// Finish a webauthn registration. The challenge should correspond to an output of + /// `webauthn_registration_challenge`. The response should come directly from the client. + fn webauthn_registration_finish( + &mut self, + access: A, + webauthn: Webauthn, + userid: &str, + challenge: &str, + response: webauthn_rs::proto::RegisterPublicKeyCredential, + ) -> Result { + let mut data = access.open(userid)?; + let entry = data.get_mut().webauthn_registration_finish( + webauthn, + challenge, + response, + &self.webauthn, + )?; + data.save()?; + + let id = entry.info.id.clone(); + self.webauthn.push(entry); + Ok(id) + } + + fn add_totp(&mut self, description: String, totp: Totp) -> String { + let entry = TfaEntry::new(description, totp); + let id = entry.info.id.clone(); + self.totp.push(entry); + id + } + + fn add_yubico(&mut self, description: String, key: String) -> String { + let entry = TfaEntry::new(description, key); + let id = entry.info.id.clone(); + self.yubico.push(entry); + id + } + + /// Add a new set of recovery keys. There can only be 1 set of keys at a time. + fn add_recovery(&mut self) -> Result, Error> { + if self.recovery.is_some() { + bail!("user already has recovery keys"); + } + + let (recovery, original) = Recovery::generate()?; + + self.recovery = Some(recovery); + + Ok(original) + } + + /// Helper to iterate over enabled totp entries. + fn enabled_totp_entries(&self) -> impl Iterator { + self.totp + .iter() + .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None }) + } + + /// Helper to iterate over enabled u2f entries. + fn enabled_u2f_entries(&self) -> impl Iterator { + self.u2f + .iter() + .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None }) + } + + /// Helper to iterate over enabled u2f entries. + fn enabled_webauthn_entries(&self) -> impl Iterator { + self.webauthn + .iter() + .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None }) + } + + /// Helper to iterate over enabled yubico entries. + pub fn enabled_yubico_entries(&self) -> impl Iterator { + self.yubico.iter().filter_map(|e| { + if e.info.enable { + Some(e.entry.as_str()) + } else { + None + } + }) + } + + /// Verify a totp challenge. The `value` should be the totp digits as plain text. + fn verify_totp(&self, value: &str) -> Result<(), Error> { + let now = std::time::SystemTime::now(); + + for entry in self.enabled_totp_entries() { + if entry.verify(value, now, -1..=1)?.is_some() { + return Ok(()); + } + } + + bail!("totp verification failed"); + } + + /// Generate a generic TFA challenge. See the [`TfaChallenge`] description for details. + pub fn challenge( + &mut self, + access: A, + userid: &str, + webauthn: Option>, + u2f: Option<&u2f::U2f>, + ) -> Result, Error> { + if self.is_empty() { + return Ok(None); + } + + Ok(Some(TfaChallenge { + totp: self.totp.iter().any(|e| e.info.enable), + recovery: RecoveryState::from(&self.recovery), + webauthn: match webauthn { + Some(webauthn) => self.webauthn_challenge(access.clone(), userid, webauthn)?, + None => None, + }, + u2f: match u2f { + Some(u2f) => self.u2f_challenge(access.clone(), userid, u2f)?, + None => None, + }, + yubico: self.yubico.iter().any(|e| e.info.enable), + })) + } + + /// Get the recovery state. + pub fn recovery_state(&self) -> RecoveryState { + RecoveryState::from(&self.recovery) + } + + /// Generate an optional webauthn challenge. + fn webauthn_challenge( + &mut self, + access: A, + userid: &str, + mut webauthn: Webauthn, + ) -> Result, Error> { + if self.webauthn.is_empty() { + return Ok(None); + } + + let creds: Vec<_> = self.enabled_webauthn_entries().map(Clone::clone).collect(); + + if creds.is_empty() { + return Ok(None); + } + + let (challenge, state) = webauthn + .generate_challenge_authenticate(creds, Some(UserVerificationPolicy::Discouraged))?; + let challenge_string = challenge.public_key.challenge.to_string(); + let mut data = access.open(userid)?; + data.get_mut() + .webauthn_auths + .push(WebauthnAuthChallenge::new(state, challenge_string)); + data.save()?; + + Ok(Some(challenge)) + } + + /// Generate an optional u2f challenge. + fn u2f_challenge( + &self, + access: A, + userid: &str, + u2f: &u2f::U2f, + ) -> Result, Error> { + if self.u2f.is_empty() { + return Ok(None); + } + + let keys: Vec = self + .enabled_u2f_entries() + .map(|registration| registration.key.clone()) + .collect(); + + if keys.is_empty() { + return Ok(None); + } + + let challenge = U2fChallenge { + challenge: u2f.auth_challenge()?, + keys, + }; + + let mut data = access.open(userid)?; + data.get_mut() + .u2f_auths + .push(U2fChallengeEntry::new(&challenge)); + data.save()?; + + Ok(Some(challenge)) + } + + /// Verify a u2f response. + fn verify_u2f( + &self, + access: A, + userid: &str, + u2f: u2f::U2f, + challenge: &proxmox_tfa::u2f::AuthChallenge, + response: Value, + ) -> Result<(), Error> { + let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS; + + let response: proxmox_tfa::u2f::AuthResponse = serde_json::from_value(response) + .map_err(|err| format_err!("invalid u2f response: {}", err))?; + + if let Some(entry) = self + .enabled_u2f_entries() + .find(|e| e.key.key_handle == response.key_handle()) + { + if u2f + .auth_verify_obj(&entry.public_key, &challenge.challenge, response)? + .is_some() + { + let mut data = match access.open_no_create(userid)? { + Some(data) => data, + None => bail!("no such challenge"), + }; + let index = data + .get_mut() + .u2f_auths + .iter() + .position(|r| r == challenge) + .ok_or_else(|| format_err!("no such challenge"))?; + let entry = data.get_mut().u2f_auths.remove(index); + if entry.is_expired(expire_before) { + bail!("no such challenge"); + } + data.save() + .map_err(|err| format_err!("failed to save challenge file: {}", err))?; + + return Ok(()); + } + } + + bail!("u2f verification failed"); + } + + /// Verify a webauthn response. + fn verify_webauthn( + &mut self, + access: A, + userid: &str, + mut webauthn: Webauthn, + mut response: Value, + ) -> Result<(), Error> { + let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS; + + let challenge = match response + .as_object_mut() + .ok_or_else(|| format_err!("invalid response, must be a json object"))? + .remove("challenge") + .ok_or_else(|| format_err!("missing challenge data in response"))? + { + Value::String(s) => s, + _ => bail!("invalid challenge data in response"), + }; + + let response: webauthn_rs::proto::PublicKeyCredential = serde_json::from_value(response) + .map_err(|err| format_err!("invalid webauthn response: {}", err))?; + + let mut data = match access.open_no_create(userid)? { + Some(data) => data, + None => bail!("no such challenge"), + }; + + let index = data + .get_mut() + .webauthn_auths + .iter() + .position(|r| r.challenge == challenge) + .ok_or_else(|| format_err!("no such challenge"))?; + + let challenge = data.get_mut().webauthn_auths.remove(index); + if challenge.is_expired(expire_before) { + bail!("no such challenge"); + } + + // we don't allow re-trying the challenge, so make the removal persistent now: + data.save() + .map_err(|err| format_err!("failed to save challenge file: {}", err))?; + + match webauthn.authenticate_credential(response, challenge.state)? { + Some((_cred, _counter)) => Ok(()), + None => bail!("webauthn authentication failed"), + } + } + + /// Verify a recovery key. + /// + /// NOTE: If successful, the key will automatically be removed from the list of available + /// recovery keys, so the configuration needs to be saved afterwards! + fn verify_recovery(&mut self, value: &str) -> Result<(), Error> { + if let Some(r) = &mut self.recovery { + if r.verify(value)? { + return Ok(()); + } + } + bail!("recovery verification failed"); + } +} + +/// A TFA entry for a user. +/// +/// This simply connects a raw registration to a non optional descriptive text chosen by the user. +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TfaEntry { + #[serde(flatten)] + pub info: TfaInfo, + + /// The actual entry. + pub entry: T, +} + +impl TfaEntry { + /// Create an entry with a description. The id will be autogenerated. + fn new(description: String, entry: T) -> Self { + Self { + info: TfaInfo { + id: Uuid::generate().to_string(), + enable: true, + description, + created: proxmox_time::epoch_i64(), + }, + entry, + } + } + + /// Create a raw entry from a `TfaInfo` and the corresponding entry data. + pub fn from_parts(info: TfaInfo, entry: T) -> Self { + Self { info, entry } + } +} + +#[cfg_attr(feature = "api-types", api)] +/// Over the API we only provide this part when querying a user's second factor list. +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TfaInfo { + /// The id used to reference this entry. + pub id: String, + + /// User chosen description for this entry. + #[serde(skip_serializing_if = "String::is_empty")] + pub description: String, + + /// Creation time of this entry as unix epoch. + pub created: i64, + + /// Whether this TFA entry is currently enabled. + #[serde(skip_serializing_if = "is_default_tfa_enable")] + #[serde(default = "default_tfa_enable")] + pub enable: bool, +} + +impl TfaInfo { + /// For recovery keys we have a fixed entry. + pub fn recovery(created: i64) -> Self { + Self { + id: "recovery".to_string(), + description: String::new(), + enable: true, + created, + } + } +} + +const fn default_tfa_enable() -> bool { + true +} + +const fn is_default_tfa_enable(v: &bool) -> bool { + *v +} + +/// When sending a TFA challenge to the user, we include information about what kind of challenge +/// the user may perform. If webauthn credentials are available, a webauthn challenge will be +/// included. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct TfaChallenge { + /// True if the user has TOTP devices. + #[serde(skip_serializing_if = "bool_is_false", default)] + totp: bool, + + /// Whether there are recovery keys available. + #[serde(skip_serializing_if = "RecoveryState::is_unavailable", default)] + recovery: RecoveryState, + + /// If the user has any u2f tokens registered, this will contain the U2F challenge data. + #[serde(skip_serializing_if = "Option::is_none")] + u2f: Option, + + /// If the user has any webauthn credentials registered, this will contain the corresponding + /// challenge data. + #[serde(skip_serializing_if = "Option::is_none", skip_deserializing)] + webauthn: Option, + + /// True if the user has yubico keys configured. + #[serde(skip_serializing_if = "bool_is_false", default)] + yubico: bool, +} + +fn bool_is_false(v: &bool) -> bool { + !v +} + +/// A user's response to a TFA challenge. +pub enum TfaResponse { + Totp(String), + U2f(Value), + Webauthn(Value), + Recovery(String), +} + +/// This is part of the REST API: +impl std::str::FromStr for TfaResponse { + type Err = Error; + + fn from_str(s: &str) -> Result { + Ok(if let Some(totp) = s.strip_prefix("totp:") { + TfaResponse::Totp(totp.to_string()) + } else if let Some(u2f) = s.strip_prefix("u2f:") { + TfaResponse::U2f(serde_json::from_str(u2f)?) + } else if let Some(webauthn) = s.strip_prefix("webauthn:") { + TfaResponse::Webauthn(serde_json::from_str(webauthn)?) + } else if let Some(recovery) = s.strip_prefix("recovery:") { + TfaResponse::Recovery(recovery.to_string()) + } else { + bail!("invalid tfa response"); + }) + } +} + +/// Active TFA challenges per user, stored in a restricted temporary file on the machine handling +/// the current user's authentication. +#[derive(Default, Deserialize, Serialize)] +pub struct TfaUserChallenges { + /// Active u2f registration challenges for a user. + /// + /// Expired values are automatically filtered out while parsing the tfa configuration file. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + #[serde(deserialize_with = "filter_expired_challenge")] + u2f_registrations: Vec, + + /// Active u2f authentication challenges for a user. + /// + /// Expired values are automatically filtered out while parsing the tfa configuration file. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + #[serde(deserialize_with = "filter_expired_challenge")] + u2f_auths: Vec, + + /// Active webauthn registration challenges for a user. + /// + /// Expired values are automatically filtered out while parsing the tfa configuration file. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + #[serde(deserialize_with = "filter_expired_challenge")] + webauthn_registrations: Vec, + + /// Active webauthn authentication challenges for a user. + /// + /// Expired values are automatically filtered out while parsing the tfa configuration file. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + #[serde(deserialize_with = "filter_expired_challenge")] + webauthn_auths: Vec, +} + +/// Serde helper using our `FilteredVecVisitor` to filter out expired entries directly at load +/// time. +fn filter_expired_challenge<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de> + IsExpired, +{ + let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS; + deserializer.deserialize_seq(serde_tools::fold( + "a challenge entry", + |cap| cap.map(Vec::with_capacity).unwrap_or_else(Vec::new), + move |out, reg: T| { + if !reg.is_expired(expire_before) { + out.push(reg); + } + }, + )) +} + +impl TfaUserChallenges { + /// Finish a u2f registration. The challenge should correspond to an output of + /// `u2f_registration_challenge` (which is a stringified `RegistrationChallenge`). The response + /// should come directly from the client. + fn u2f_registration_finish( + &mut self, + u2f: &u2f::U2f, + challenge: &str, + response: &str, + ) -> Result, Error> { + let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS; + + let index = self + .u2f_registrations + .iter() + .position(|r| r.challenge == challenge) + .ok_or_else(|| format_err!("no such challenge"))?; + + let reg = &self.u2f_registrations[index]; + if reg.is_expired(expire_before) { + bail!("no such challenge"); + } + + // the verify call only takes the actual challenge string, so we have to extract it + // (u2f::RegistrationChallenge did not always implement Deserialize...) + let chobj: Value = serde_json::from_str(challenge) + .map_err(|err| format_err!("error parsing original registration challenge: {}", err))?; + let challenge = chobj["challenge"] + .as_str() + .ok_or_else(|| format_err!("invalid registration challenge"))?; + + let (mut reg, description) = match u2f.registration_verify(challenge, response)? { + None => bail!("verification failed"), + Some(reg) => { + let entry = self.u2f_registrations.remove(index); + (reg, entry.description) + } + }; + + // we do not care about the attestation certificates, so don't store them + reg.certificate.clear(); + + Ok(TfaEntry::new(description, reg)) + } + + /// Finish a webauthn registration. The challenge should correspond to an output of + /// `webauthn_registration_challenge`. The response should come directly from the client. + fn webauthn_registration_finish( + &mut self, + webauthn: Webauthn, + challenge: &str, + response: webauthn_rs::proto::RegisterPublicKeyCredential, + existing_registrations: &[TfaEntry], + ) -> Result, Error> { + let expire_before = proxmox_time::epoch_i64() - CHALLENGE_TIMEOUT_SECS; + + let index = self + .webauthn_registrations + .iter() + .position(|r| r.challenge == challenge) + .ok_or_else(|| format_err!("no such challenge"))?; + + let reg = self.webauthn_registrations.remove(index); + if reg.is_expired(expire_before) { + bail!("no such challenge"); + } + + let credential = + webauthn.register_credential(response, reg.state, |id| -> Result { + Ok(existing_registrations + .iter() + .any(|cred| cred.entry.cred_id == *id)) + })?; + + Ok(TfaEntry::new(reg.description, credential)) + } +} diff --git a/pve-rs/src/tfa/proxmox_tfa_api/recovery.rs b/pve-rs/src/tfa/proxmox_tfa_api/recovery.rs new file mode 100644 index 0000000..9af2873 --- /dev/null +++ b/pve-rs/src/tfa/proxmox_tfa_api/recovery.rs @@ -0,0 +1,153 @@ +use std::io; + +use anyhow::{format_err, Error}; +use openssl::hash::MessageDigest; +use openssl::pkey::PKey; +use openssl::sign::Signer; +use serde::{Deserialize, Serialize}; + +fn getrandom(mut buffer: &mut [u8]) -> Result<(), io::Error> { + while !buffer.is_empty() { + let res = unsafe { + libc::getrandom( + buffer.as_mut_ptr() as *mut libc::c_void, + buffer.len() as libc::size_t, + 0 as libc::c_uint, + ) + }; + + if res < 0 { + return Err(io::Error::last_os_error()); + } + + buffer = &mut buffer[(res as usize)..]; + } + + Ok(()) +} + +/// Recovery entries. We use HMAC-SHA256 with a random secret as a salted hash replacement. +#[derive(Clone, Deserialize, Serialize)] +pub struct Recovery { + /// "Salt" used for the key HMAC. + secret: String, + + /// Recovery key entries are HMACs of the original data. When used up they will become `None` + /// since the user is presented an enumerated list of codes, so we know the indices of used and + /// unused codes. + entries: Vec>, + + /// Creation timestamp as a unix epoch. + pub created: i64, +} + +impl Recovery { + /// Generate recovery keys and return the recovery entry along with the original string + /// entries. + pub(super) fn generate() -> Result<(Self, Vec), Error> { + let mut secret = [0u8; 8]; + getrandom(&mut secret)?; + + let mut this = Self { + secret: hex::encode(&secret).to_string(), + entries: Vec::with_capacity(10), + created: proxmox_time::epoch_i64(), + }; + + let mut original = Vec::new(); + + let mut key_data = [0u8; 80]; // 10 keys of 12 bytes + getrandom(&mut key_data)?; + for b in key_data.chunks(8) { + // unwrap: encoding hex bytes to fixed sized arrays + let entry = format!( + "{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}", + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], + ); + this.entries.push(Some(this.hash(entry.as_bytes())?)); + original.push(entry); + } + + Ok((this, original)) + } + + /// Perform HMAC-SHA256 on the data and return the result as a hex string. + fn hash(&self, data: &[u8]) -> Result { + let secret = PKey::hmac(self.secret.as_bytes()) + .map_err(|err| format_err!("error instantiating hmac key: {}", err))?; + + let mut signer = Signer::new(MessageDigest::sha256(), &secret) + .map_err(|err| format_err!("error instantiating hmac signer: {}", err))?; + + let hmac = signer + .sign_oneshot_to_vec(data) + .map_err(|err| format_err!("error calculating hmac: {}", err))?; + + Ok(hex::encode(&hmac)) + } + + /// Iterator over available keys. + fn available(&self) -> impl Iterator { + self.entries.iter().filter_map(Option::as_deref) + } + + /// Count the available keys. + pub fn count_available(&self) -> usize { + self.available().count() + } + + /// Convenience serde method to check if either the option is `None` or the content `is_empty`. + pub(super) fn option_is_empty(this: &Option) -> bool { + this.as_ref() + .map_or(true, |this| this.count_available() == 0) + } + + /// Verify a key and remove it. Returns whether the key was valid. Errors on openssl errors. + pub(super) fn verify(&mut self, key: &str) -> Result { + let hash = self.hash(key.as_bytes())?; + for entry in &mut self.entries { + if entry.as_ref() == Some(&hash) { + *entry = None; + return Ok(true); + } + } + Ok(false) + } +} + +/// Used to inform the user about the recovery code status. +/// +/// This contains the available key indices. +#[derive(Clone, Default, Eq, PartialEq, Deserialize, Serialize)] +pub struct RecoveryState(Vec); + +impl RecoveryState { + pub fn is_available(&self) -> bool { + !self.is_unavailable() + } + + pub fn is_unavailable(&self) -> bool { + self.0.is_empty() + } +} + +impl From<&Option> for RecoveryState { + fn from(r: &Option) -> Self { + match r { + Some(r) => Self::from(r), + None => Self::default(), + } + } +} + +impl From<&Recovery> for RecoveryState { + fn from(r: &Recovery) -> Self { + Self( + r.entries + .iter() + .enumerate() + .filter_map(|(idx, key)| if key.is_some() { Some(idx) } else { None }) + .collect(), + ) + } +} diff --git a/pve-rs/src/tfa/proxmox_tfa_api/serde_tools.rs b/pve-rs/src/tfa/proxmox_tfa_api/serde_tools.rs new file mode 100644 index 0000000..1f307a2 --- /dev/null +++ b/pve-rs/src/tfa/proxmox_tfa_api/serde_tools.rs @@ -0,0 +1,111 @@ +//! Submodule for generic serde helpers. +//! +//! FIXME: This should appear in `proxmox-serde`. + +use std::fmt; +use std::marker::PhantomData; + +use serde::Deserialize; + +/// Helper to abstract away serde details, see [`fold`](fold()). +pub struct FoldSeqVisitor +where + Init: FnOnce(Option) -> Out, + F: Fn(&mut Out, T) -> (), +{ + init: Option, + closure: F, + expecting: &'static str, + _ty: PhantomData, +} + +impl FoldSeqVisitor +where + Init: FnOnce(Option) -> Out, + F: Fn(&mut Out, T) -> (), +{ + pub fn new(expecting: &'static str, init: Init, closure: F) -> Self { + Self { + init: Some(init), + closure, + expecting, + _ty: PhantomData, + } + } +} + +impl<'de, T, Out, F, Init> serde::de::Visitor<'de> for FoldSeqVisitor +where + Init: FnOnce(Option) -> Out, + F: Fn(&mut Out, T) -> (), + T: Deserialize<'de>, +{ + type Value = Out; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str(self.expecting) + } + + fn visit_seq(mut self, mut seq: A) -> Result + where + A: serde::de::SeqAccess<'de>, + { + // unwrap: this is the only place taking out init and we're consuming `self` + let mut output = (self.init.take().unwrap())(seq.size_hint()); + + while let Some(entry) = seq.next_element::()? { + (self.closure)(&mut output, entry); + } + + Ok(output) + } +} + +/// Create a serde sequence visitor with simple callbacks. +/// +/// This helps building things such as filters for arrays without having to worry about the serde +/// implementation details. +/// +/// Example: +/// ``` +/// # use serde::Deserialize; +/// +/// #[derive(Deserialize)] +/// struct Test { +/// #[serde(deserialize_with = "stringify_u64")] +/// foo: Vec, +/// } +/// +/// fn stringify_u64<'de, D>(deserializer: D) -> Result, D::Error> +/// where +/// D: serde::Deserializer<'de>, +/// { +/// deserializer.deserialize_seq(proxmox_serde::fold( +/// "a sequence of integers", +/// |cap| cap.map(Vec::with_capacity).unwrap_or_else(Vec::new), +/// |out, num: u64| { +/// if num != 4 { +/// out.push(num.to_string()); +/// } +/// }, +/// )) +/// } +/// +/// let test: Test = +/// serde_json::from_str(r#"{"foo":[2, 4, 6]}"#).expect("failed to deserialize test"); +/// assert_eq!(test.foo.len(), 2); +/// assert_eq!(test.foo[0], "2"); +/// assert_eq!(test.foo[1], "6"); +/// ``` +pub fn fold<'de, T, Out, Init, Fold>( + expected: &'static str, + init: Init, + fold: Fold, +) -> FoldSeqVisitor +where + Init: FnOnce(Option) -> Out, + Fold: Fn(&mut Out, T) -> (), + T: Deserialize<'de>, +{ + FoldSeqVisitor::new(expected, init, fold) +} diff --git a/pve-rs/src/tfa/proxmox_tfa_api/u2f.rs b/pve-rs/src/tfa/proxmox_tfa_api/u2f.rs new file mode 100644 index 0000000..7b75eb3 --- /dev/null +++ b/pve-rs/src/tfa/proxmox_tfa_api/u2f.rs @@ -0,0 +1,89 @@ +//! u2f configuration and challenge data + +use serde::{Deserialize, Serialize}; + +use proxmox_tfa::u2f; + +pub use proxmox_tfa::u2f::{Registration, U2f}; + +/// The U2F authentication configuration. +#[derive(Clone, Deserialize, Serialize)] +pub struct U2fConfig { + pub appid: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub origin: Option, +} + +/// A u2f registration challenge. +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct U2fRegistrationChallenge { + /// JSON formatted challenge string. + pub challenge: String, + + /// The description chosen by the user for this registration. + pub description: String, + + /// When the challenge was created as unix epoch. They are supposed to be short-lived. + created: i64, +} + +impl super::IsExpired for U2fRegistrationChallenge { + fn is_expired(&self, at_epoch: i64) -> bool { + self.created < at_epoch + } +} + +impl U2fRegistrationChallenge { + pub fn new(challenge: String, description: String) -> Self { + Self { + challenge, + description, + created: proxmox_time::epoch_i64(), + } + } +} + +/// Data used for u2f authentication challenges. +/// +/// This is sent to the client at login time. +#[derive(Deserialize, Serialize)] +pub struct U2fChallenge { + /// AppID and challenge data. + pub(super) challenge: u2f::AuthChallenge, + + /// Available tokens/keys. + pub(super) keys: Vec, +} + +/// The challenge data we need on the server side to verify the challenge: +/// * It can only be used once. +/// * It can expire. +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct U2fChallengeEntry { + challenge: u2f::AuthChallenge, + created: i64, +} + +impl U2fChallengeEntry { + pub fn new(challenge: &U2fChallenge) -> Self { + Self { + challenge: challenge.challenge.clone(), + created: proxmox_time::epoch_i64(), + } + } +} + +impl super::IsExpired for U2fChallengeEntry { + fn is_expired(&self, at_epoch: i64) -> bool { + self.created < at_epoch + } +} + +impl PartialEq for U2fChallengeEntry { + fn eq(&self, other: &u2f::AuthChallenge) -> bool { + self.challenge.challenge == other.challenge && self.challenge.app_id == other.app_id + } +} diff --git a/pve-rs/src/tfa/proxmox_tfa_api/webauthn.rs b/pve-rs/src/tfa/proxmox_tfa_api/webauthn.rs new file mode 100644 index 0000000..8d98ed4 --- /dev/null +++ b/pve-rs/src/tfa/proxmox_tfa_api/webauthn.rs @@ -0,0 +1,118 @@ +//! Webauthn configuration and challenge data. + +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "api-types")] +use proxmox_schema::api; + +use super::IsExpired; + +#[cfg_attr(feature = "api-types", api)] +/// Server side webauthn server configuration. +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct WebauthnConfig { + /// Relying party name. Any text identifier. + /// + /// Changing this *may* break existing credentials. + pub rp: String, + + /// Site origin. Must be a `https://` URL (or `http://localhost`). Should contain the address + /// users type in their browsers to access the web interface. + /// + /// Changing this *may* break existing credentials. + pub origin: String, + + /// Relying part ID. Must be the domain name without protocol, port or location. + /// + /// Changing this *will* break existing credentials. + pub id: String, +} + +/// For now we just implement this on the configuration this way. +/// +/// Note that we may consider changing this so `get_origin` returns the `Host:` header provided by +/// the connecting client. +impl webauthn_rs::WebauthnConfig for WebauthnConfig { + fn get_relying_party_name(&self) -> String { + self.rp.clone() + } + + fn get_origin(&self) -> &String { + &self.origin + } + + fn get_relying_party_id(&self) -> String { + self.id.clone() + } +} + +/// A webauthn registration challenge. +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct WebauthnRegistrationChallenge { + /// Server side registration state data. + pub(super) state: webauthn_rs::RegistrationState, + + /// While this is basically the content of a `RegistrationState`, the webauthn-rs crate doesn't + /// make this public. + pub(super) challenge: String, + + /// The description chosen by the user for this registration. + pub(super) description: String, + + /// When the challenge was created as unix epoch. They are supposed to be short-lived. + created: i64, +} + +impl WebauthnRegistrationChallenge { + pub fn new( + state: webauthn_rs::RegistrationState, + challenge: String, + description: String, + ) -> Self { + Self { + state, + challenge, + description, + created: proxmox_time::epoch_i64(), + } + } +} + +impl IsExpired for WebauthnRegistrationChallenge { + fn is_expired(&self, at_epoch: i64) -> bool { + self.created < at_epoch + } +} + +/// A webauthn authentication challenge. +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct WebauthnAuthChallenge { + /// Server side authentication state. + pub(super) state: webauthn_rs::AuthenticationState, + + /// While this is basically the content of a `AuthenticationState`, the webauthn-rs crate + /// doesn't make this public. + pub(super) challenge: String, + + /// When the challenge was created as unix epoch. They are supposed to be short-lived. + created: i64, +} + +impl WebauthnAuthChallenge { + pub fn new(state: webauthn_rs::AuthenticationState, challenge: String) -> Self { + Self { + state, + challenge, + created: proxmox_time::epoch_i64(), + } + } +} + +impl IsExpired for WebauthnAuthChallenge { + fn is_expired(&self, at_epoch: i64) -> bool { + self.created < at_epoch + } +} -- 2.30.2