pmg: add tfa module

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
This commit is contained in:
Wolfgang Bumiller 2021-11-26 14:55:17 +01:00 committed by Thomas Lamprecht
parent 67882d1a95
commit 83ac34503e
5 changed files with 618 additions and 5 deletions

View File

@ -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"] }

View File

@ -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

View File

@ -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

View File

@ -1,3 +1,4 @@
pub mod acme;
pub mod apt;
pub mod csr;
pub mod tfa;

603
pmg-rs/src/tfa.rs Normal file
View File

@ -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> : &Tfa as "PMG::RS::TFA");
/// A TFA Config instance.
pub struct Tfa {
inner: Mutex<TfaConfig>,
}
/// Prevent 'dclone'.
#[export(name = "STORABLE_freeze", raw_return)]
fn storable_freeze(#[try_from_ref] _this: &Tfa, _cloning: bool) -> Result<Value, Error> {
bail!("freezing TFA config not supported!");
}
/// Parse a TFA configuration.
#[export(raw_return)]
fn new(#[raw] class: Value, config: &[u8]) -> Result<Value, Error> {
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<serde_bytes::ByteBuf, Error> {
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<Value, Error> {
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<Vec<String>, 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<bool, Error> {
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<Value, perlmod::Error> {
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<String, Error> {
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<String, Error> {
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<bool, Error> {
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<Option<String>, 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<super::U2fConfig>) {
this.inner.lock().unwrap().u2f = config;
}
#[export]
fn set_webauthn_config(
#[try_from_ref] this: &Tfa,
config: Option<super::WebauthnConfig>,
) -> 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<String>, Option<super::WebauthnConfig>), 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<Url>,
) -> Result<Option<String>, 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<super::RecoveryState> {
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<Url>,
) -> Result<bool, Error> {
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<String, Error> {
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<Vec<methods::TypedTfaInfo>, 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::TypedTfaInfo> {
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<bool, Error> {
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<Vec<methods::TfaUser>, 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<String>,
totp: Option<String>,
value: Option<String>,
challenge: Option<String>,
ty: methods::TfaType,
origin: Option<Url>,
) -> Result<methods::TfaUpdateInfo, Error> {
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<String, Error> {
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<String>,
enable: Option<bool>,
) -> 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<P: AsRef<Path>>(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<Self, Error> {
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<Self, std::convert::Infallible> {
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<UserChallengeData, Error> {
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<Option<UserChallengeData>, 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<bool, Error> {
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(())
}
}