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 F3EEF81F3C for ; Fri, 26 Nov 2021 14:55:56 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id F19D119471 for ; Fri, 26 Nov 2021 14:55:56 +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 0B66119227 for ; Fri, 26 Nov 2021 14:55:44 +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 DB65044C64 for ; Fri, 26 Nov 2021 14:55:43 +0100 (CET) From: Wolfgang Bumiller To: pmg-devel@lists.proxmox.com Date: Fri, 26 Nov 2021 14:55:17 +0100 Message-Id: <20211126135524.117846-14-w.bumiller@proxmox.com> X-Mailer: git-send-email 2.30.2 In-Reply-To: <20211126135524.117846-1-w.bumiller@proxmox.com> References: <20211126135524.117846-1-w.bumiller@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.277 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 0.1 Meta: its spam POISEN_SPAM_PILL_2 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. [tfa.pm, acme.pm, csr.pm, proxmox.com, lib.rs, tfa.rs, repositories.pm] Subject: [pmg-devel] [PATCH perl-rs 6/7] pmg: add tfa module X-BeenThere: pmg-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Mail Gateway development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Fri, 26 Nov 2021 13:55:57 -0000 Signed-off-by: Wolfgang Bumiller --- pmg-rs/Cargo.toml | 5 +- pmg-rs/Makefile | 5 +- pmg-rs/debian/control | 9 +- pmg-rs/src/lib.rs | 1 + pmg-rs/src/tfa.rs | 603 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 618 insertions(+), 5 deletions(-) create mode 100644 pmg-rs/src/tfa.rs diff --git a/pmg-rs/Cargo.toml b/pmg-rs/Cargo.toml index 659456c..1d66bb5 100644 --- a/pmg-rs/Cargo.toml +++ b/pmg-rs/Cargo.toml @@ -21,13 +21,16 @@ crate-type = [ "cdylib" ] [dependencies] anyhow = "1.0" hex = "0.4" +libc = "0.2" +nix = "0.19" openssl = "0.10.32" serde = "1.0" serde_bytes = "0.11.3" serde_json = "1.0" +url = "2" perlmod = { version = "0.9", features = [ "exporter" ] } proxmox-acme-rs = { version = "0.3.1", features = ["client"] } - proxmox-apt = "0.8.0" +proxmox-tfa = { version = "2", features = ["api"] } diff --git a/pmg-rs/Makefile b/pmg-rs/Makefile index a290544..69b2798 100644 --- a/pmg-rs/Makefile +++ b/pmg-rs/Makefile @@ -18,9 +18,10 @@ PM_DIRS := \ PMG/RS/APT PM_FILES := \ - PMG/RS/Acme.pm \ PMG/RS/APT/Repositories.pm \ - PMG/RS/CSR.pm + PMG/RS/Acme.pm \ + PMG/RS/CSR.pm \ + PMG/RS/TFA.pm ifeq ($(BUILD_MODE), release) CARGO_BUILD_ARGS += --release diff --git a/pmg-rs/debian/control b/pmg-rs/debian/control index be632ba..8c22fae 100644 --- a/pmg-rs/debian/control +++ b/pmg-rs/debian/control @@ -6,15 +6,20 @@ Build-Depends: debhelper (>= 12), librust-anyhow-1+default-dev, librust-hex-0.4+default-dev, + librust-libc-0.2+default-dev, + librust-nix-0.19+default-dev, librust-openssl-0.10+default-dev (>= 0.10.32-~~), - librust-perlmod-0.8+default-dev, - librust-perlmod-0.8+exporter-dev, + librust-perlmod-0.8+default-dev (>= 0.8.1-~~), + librust-perlmod-0.8+exporter-dev (>= 0.8.1-~~), librust-proxmox-acme-rs-0.3+client-dev (>= 0.3.1-~~), librust-proxmox-acme-rs-0.3+default-dev (>= 0.3.1-~~), librust-proxmox-apt-0.8+default-dev, + librust-proxmox-tfa-2+api-dev, + librust-proxmox-tfa-2+default-dev, librust-serde-1+default-dev, librust-serde-bytes-0.11+default-dev (>= 0.11.3-~~), librust-serde-json-1+default-dev, + librust-url-2+default-dev, Standards-Version: 4.3.0 Homepage: https://www.proxmox.com diff --git a/pmg-rs/src/lib.rs b/pmg-rs/src/lib.rs index 47e61b5..e3c9593 100644 --- a/pmg-rs/src/lib.rs +++ b/pmg-rs/src/lib.rs @@ -1,3 +1,4 @@ pub mod acme; pub mod apt; pub mod csr; +pub mod tfa; diff --git a/pmg-rs/src/tfa.rs b/pmg-rs/src/tfa.rs new file mode 100644 index 0000000..404ddb2 --- /dev/null +++ b/pmg-rs/src/tfa.rs @@ -0,0 +1,603 @@ +//! This implements the `tfa.cfg` parser & TFA API calls for PMG. +//! +//! The exported `PMG::RS::TFA` perl package provides access to rust's `TfaConfig`. +//! Contrary to the PVE implementation, this does not need to provide any backward compatible +//! entries. +//! +//! NOTE: In PMG the tfa config is behind `PVE::INotify`'s `ccache`, so PMG sets it to `noclone` in +//! order to avoid losing the rust magic-ref. + +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; + +pub(self) use proxmox_tfa::api::{ + RecoveryState, TfaChallenge, TfaConfig, TfaResponse, U2fConfig, WebauthnConfig, +}; + +#[perlmod::package(name = "PMG::RS::TFA")] +mod export { + use std::convert::TryInto; + use std::sync::Mutex; + + use anyhow::{bail, format_err, Error}; + use serde_bytes::ByteBuf; + use url::Url; + + use perlmod::Value; + use proxmox_tfa::api::methods; + + use super::{TfaConfig, UserAccess}; + + perlmod::declare_magic!(Box : &Tfa as "PMG::RS::TFA"); + + /// A TFA Config instance. + pub struct Tfa { + inner: Mutex, + } + + /// Prevent 'dclone'. + #[export(name = "STORABLE_freeze", raw_return)] + fn storable_freeze(#[try_from_ref] _this: &Tfa, _cloning: bool) -> Result { + bail!("freezing TFA config not supported!"); + } + + /// 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(|err| format_err!("failed to parse TFA file: {}", err))?; + + // PMG does not support U2F. + inner.u2f = None; + 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 inner = this.inner.lock().unwrap(); + Ok(ByteBuf::from(serde_json::to_vec(&*inner)?)) + } + + /// Debug helper: serialize the TFA user data into a perl value. + #[export] + fn to_perl(#[try_from_ref] this: &Tfa) -> Result { + let inner = this.inner.lock().unwrap(); + Ok(perlmod::to_value(&*inner)?) + } + + /// Get a list of all the user names in this config. + /// PMG 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, + ) -> Result<(), Error> { + this.inner.lock().unwrap().webauthn = config.map(TryInto::try_into).transpose()?; + Ok(()) + } + + #[export] + fn get_webauthn_config( + #[try_from_ref] this: &Tfa, + ) -> Result<(Option, Option), Error> { + Ok(match this.inner.lock().unwrap().webauthn.clone() { + Some(config) => (Some(hex::encode(&config.digest())), Some(config.into())), + None => (None, None), + }) + } + + #[export] + fn has_webauthn_origin(#[try_from_ref] this: &Tfa) -> bool { + match &this.inner.lock().unwrap().webauthn { + Some(wa) => wa.origin.is_some(), + None => false, + } + } + + /// 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, + origin: Option, + ) -> 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, + origin.as_ref(), + )? { + 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, + origin: Option, + ) -> 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, + origin.as_ref(), + ) + .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> { + methods::list_user_tfa(&this.inner.lock().unwrap(), userid) + } + + #[export] + fn api_get_tfa_entry( + #[try_from_ref] this: &Tfa, + userid: &str, + id: &str, + ) -> Option { + methods::get_tfa_entry(&this.inner.lock().unwrap(), userid, id) + } + + /// Returns `true` if the user still has other TFA entries left, `false` if the user has *no* + /// 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 methods::delete_tfa(&mut this, userid, &id) { + Ok(has_entries_left) => Ok(has_entries_left), + Err(methods::EntryNotFound) => bail!("no such entry"), + } + } + + #[export] + fn api_list_tfa( + #[try_from_ref] this: &Tfa, + authid: &str, + top_level_allowed: bool, + ) -> Result, Error> { + methods::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: methods::TfaType, + origin: Option, + ) -> Result { + let this: &Tfa = (&raw_this).try_into()?; + methods::add_tfa_entry( + &mut this.inner.lock().unwrap(), + UserAccess::new(&raw_this)?, + userid, + description, + totp, + value, + challenge, + ty, + origin.as_ref(), + ) + } + + /// Add a totp entry without validating it, used for user.cfg keys. + /// Returns the ID. + #[export] + fn add_totp_entry( + #[try_from_ref] this: &Tfa, + userid: &str, + description: String, + totp: String, + ) -> Result { + Ok(this + .inner + .lock() + .unwrap() + .add_totp(userid, description, totp.parse()?)) + } + + /// Add a yubico entry without validating it, used for user.cfg keys. + /// Returns the ID. + #[export] + fn add_yubico_entry( + #[try_from_ref] this: &Tfa, + userid: &str, + description: String, + yubico: String, + ) -> String { + this.inner + .lock() + .unwrap() + .add_yubico(userid, description, yubico) + } + + #[export] + fn api_update_tfa_entry( + #[try_from_ref] this: &Tfa, + userid: &str, + id: &str, + description: Option, + enable: Option, + ) -> Result<(), Error> { + match methods::update_tfa_entry( + &mut this.inner.lock().unwrap(), + userid, + id, + description, + enable, + ) { + Ok(()) => Ok(()), + Err(methods::EntryNotFound) => bail!("no such entry"), + } + } +} + +/// 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/pmg-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/pmg-private", 0o700)?; + mkdir("/run/pmg-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 { + match serde_json::from_slice(&data) { + Ok(inner) => inner, + Err(err) => { + eprintln!( + "failed to parse challenge data for user {}: {}", + userid, err + ); + Default::default() + } + } + }; + + 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, + })) + } + + fn remove(&self, userid: &str) -> Result { + let path = challenge_data_path(userid, self.is_debug()); + match std::fs::remove_file(&path) { + Ok(()) => Ok(true), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(err.into()), + } + } +} + +/// Container of `TfaUserChallenges` with the corresponding file lock guard. +/// +/// 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(()) + } +} -- 2.30.2