From dc1fdd62676753aee23967b3c632556b9091cea8 Mon Sep 17 00:00:00 2001 From: Wolfgang Bumiller Date: Mon, 16 Nov 2020 14:36:14 +0100 Subject: [PATCH] config: add tfa configuration Signed-off-by: Wolfgang Bumiller --- src/config.rs | 1 + src/config/tfa.rs | 643 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 644 insertions(+) create mode 100644 src/config/tfa.rs diff --git a/src/config.rs b/src/config.rs index aa767811..75a9faa6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,6 +21,7 @@ pub mod datastore; pub mod network; pub mod remote; pub mod sync; +pub mod tfa; pub mod token_shadow; pub mod user; pub mod verify; diff --git a/src/config/tfa.rs b/src/config/tfa.rs new file mode 100644 index 00000000..da3a4f9a --- /dev/null +++ b/src/config/tfa.rs @@ -0,0 +1,643 @@ +use std::collections::HashMap; +use std::fs::File; +use std::time::Duration; + +use anyhow::{bail, format_err, Error}; +use serde::{de::Deserializer, Deserialize, Serialize}; +use serde_json::Value; + +use proxmox::api::api; +use proxmox::sys::error::SysError; +use proxmox::tools::tfa::totp::Totp; +use proxmox::tools::tfa::u2f; +use proxmox::tools::uuid::Uuid; + +use crate::api2::types::Userid; + +/// Mapping of userid to TFA entry. +pub type TfaUsers = HashMap; + +const CONF_FILE: &str = configdir!("/tfa.json"); +const LOCK_FILE: &str = configdir!("/tfa.json.lock"); +const LOCK_TIMEOUT: Duration = Duration::from_secs(5); + +/// U2F registration challenges time out after 2 minutes. +const CHALLENGE_TIMEOUT: i64 = 2 * 60; + +#[derive(Deserialize, Serialize)] +pub struct U2fConfig { + appid: String, +} + +#[derive(Default, Deserialize, Serialize)] +pub struct TfaConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub u2f: Option, + #[serde(skip_serializing_if = "TfaUsers::is_empty", default)] + pub users: TfaUsers, +} + +/// Heper 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())) +} + +/// Heper to get a u2f instance from a u2f config. +// deduplicate error message while working around self-borrow issue +fn need_u2f(u2f: &Option) -> Result { + get_u2f(u2f).ok_or_else(|| format_err!("no u2f configuration available")) +} + +impl TfaConfig { + fn u2f(&self) -> Option { + get_u2f(&self.u2f) + } + + fn need_u2f(&self) -> Result { + need_u2f(&self.u2f) + } + + /// Get a two factor authentication challenge for a user, if the user has TFA set up. + pub fn login_challenge(&self, userid: &Userid) -> Result, Error> { + match self.users.get(userid) { + Some(udata) => udata.challenge(self.u2f().as_ref()), + None => Ok(None), + } + } + + /// Get a u2f registration challenge. + fn u2f_registration_challenge( + &mut self, + user: &Userid, + description: String, + ) -> Result { + let u2f = self.need_u2f()?; + + self.users + .entry(user.clone()) + .or_default() + .u2f_registration_challenge(&u2f, description) + } + + /// Finish a u2f registration challenge. + fn u2f_registration_finish( + &mut self, + user: &Userid, + challenge: &str, + response: &str, + ) -> Result { + let u2f = self.need_u2f()?; + + match self.users.get_mut(user) { + Some(user) => user.u2f_registration_finish(&u2f, challenge, response), + None => bail!("no such challenge"), + } + } + + /// Verify a TFA response. + fn verify( + &mut self, + userid: &Userid, + challenge: &TfaChallenge, + response: TfaResponse, + ) -> Result<(), Error> { + match self.users.get_mut(userid) { + Some(user) => { + match response { + TfaResponse::Totp(value) => user.verify_totp(&value), + TfaResponse::U2f(value) => match &challenge.u2f { + Some(challenge) => { + let u2f = need_u2f(&self.u2f)?; + user.verify_u2f(u2f, &challenge.challenge, value) + } + None => bail!("no u2f factor available for user '{}'", userid), + } + TfaResponse::Recovery(value) => user.verify_recovery(&value), + } + } + None => bail!("no 2nd factor available for user '{}'", userid), + } + } +} + +#[api] +/// Over the API we only provide this part when querying a user's second factor list. +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TfaInfo { + /// The id used to reference this entry. + pub id: String, + + /// User chosen description for this entry. + pub description: String, + + /// Whether this TFA entry is currently enabled. + #[serde(skip_serializing_if = "is_default_tfa_enable")] + #[serde(default = "default_tfa_enable")] + pub enable: bool, +} + +impl TfaInfo { + /// For recovery keys we have a fixed entry. + pub(crate) fn recovery() -> Self { + Self { + id: "recovery".to_string(), + description: "recovery keys".to_string(), + enable: true, + } + } +} + +/// A TFA entry for a user. +/// +/// This simply connects a raw registration to a non optional descriptive text chosen by the user. +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TfaEntry { + #[serde(flatten)] + pub info: TfaInfo, + + /// The actual entry. + entry: T, +} + +impl TfaEntry { + /// Create an entry with a description. The id will be autogenerated. + fn new(description: String, entry: T) -> Self { + Self { + info: TfaInfo { + id: Uuid::generate().to_string(), + enable: true, + description, + }, + entry, + } + } +} + +/// A u2f registration challenge. +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct U2fRegistrationChallenge { + /// JSON formatted challenge string. + challenge: String, + + /// The description chosen by the user for this registration. + description: String, + + /// When the challenge was created as unix epoch. They are supposed to be short-lived. + created: i64, +} + +impl U2fRegistrationChallenge { + pub fn new(challenge: String, description: String) -> Self { + Self { + challenge, + description, + created: proxmox::tools::time::epoch_i64(), + } + } + + fn is_expired(&self, at_epoch: i64) -> bool { + self.created < at_epoch + } +} + +/// TFA data for a user. +#[derive(Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +#[serde(rename_all = "kebab-case")] +pub struct TfaUserData { + /// Totp keys for a user. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) totp: Vec>, + + /// Registered u2f tokens for a user. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) u2f: Vec>, + + /// Recovery keys. (Unordered OTP values). + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) recovery: Vec, + + /// 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_registrations")] + u2f_registrations: Vec, +} + +/// Serde helper using our `FilteredVecVisitor` to filter out expired entries directly at load +/// time. +fn filter_expired_registrations<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT; + Ok( + deserializer.deserialize_seq(crate::tools::serde_filter::FilteredVecVisitor::new( + "a u2f registration challenge entry", + move |reg: &U2fRegistrationChallenge| !reg.is_expired(expire_before), + ))?, + ) +} + +impl TfaUserData { + /// `true` if no second factors exist + pub fn is_empty(&self) -> bool { + self.totp.is_empty() && self.u2f.is_empty() && self.recovery.is_empty() + } + + /// 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.u2f { + if entry.info.id == id { + return Some(&mut entry.info); + } + } + + None + } + + /// Create a u2f registration challenge. + /// + /// The description is required at this point already mostly to better be able to identify such + /// challenges in the tfa config file if necessary. The user otherwise has no access to this + /// information at this point, as the challenge is identified by its actual challenge data + /// instead. + fn u2f_registration_challenge( + &mut self, + u2f: &u2f::U2f, + description: String, + ) -> Result { + let challenge = serde_json::to_string(&u2f.registration_challenge()?)?; + + self.u2f_registrations.push(U2fRegistrationChallenge::new( + challenge.clone(), + description, + )); + + Ok(challenge) + } + + /// 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 { + let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT; + + 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(); + + let entry = TfaEntry::new(description, reg); + let id = entry.info.id.clone(); + self.u2f.push(entry); + Ok(id) + } + + /// Generate a generic TFA challenge. See the [`TfaChallenge`] description for details. + pub fn challenge(&self, 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_count(self.recovery.len()), + u2f: match u2f { + Some(u2f) => self.u2f_challenge(u2f)?, + None => None, + }, + })) + } + + /// Helper to iterate over enabled totp entries. + fn enabled_totp_entries(&self) -> impl Iterator { + self.totp + .iter() + .filter_map(|e| { + if e.info.enable { + Some(&e.entry) + } else { + None + } + }) + } + + /// Helper to iterate over enabled u2f entries. + fn enabled_u2f_entries(&self) -> impl Iterator { + self.u2f + .iter() + .filter_map(|e| { + if e.info.enable { + Some(&e.entry) + } else { + None + } + }) + } + + /// Generate an optional u2f challenge. + fn u2f_challenge(&self, u2f: &u2f::U2f) -> Result, Error> { + if self.u2f.is_empty() { + return Ok(None); + } + + let keys: Vec = self + .enabled_u2f_entries() + .map(|registration| registration.key.clone()) + .collect(); + + if keys.is_empty() { + return Ok(None); + } + + Ok(Some(U2fChallenge { + challenge: u2f.auth_challenge()?, + keys, + })) + } + + /// Verify a totp challenge. The `value` should be the totp digits as plain text. + fn verify_totp(&self, value: &str) -> Result<(), Error> { + let now = std::time::SystemTime::now(); + + for entry in self.enabled_totp_entries() { + if entry.verify(value, now, -1..=1)?.is_some() { + return Ok(()); + } + } + + bail!("totp verification failed"); + } + + /// Verify a u2f response. + fn verify_u2f( + &self, + u2f: u2f::U2f, + challenge: &u2f::AuthChallenge, + response: Value, + ) -> Result<(), Error> { + let response: u2f::AuthResponse = serde_json::from_value(response) + .map_err(|err| format_err!("invalid u2f response: {}", err))?; + + if let Some(entry) = self + .enabled_u2f_entries() + .find(|e| e.key.key_handle == response.key_handle) + { + if u2f.auth_verify_obj(&entry.public_key, &challenge.challenge, response)?.is_some() { + return Ok(()); + } + } + + bail!("u2f verification failed"); + } + + /// Verify a 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> { + match self.recovery.iter().position(|v| v == value) { + Some(idx) => { + self.recovery.remove(idx); + Ok(()) + } + None => bail!("recovery verification failed"), + } + } + + /// Add a new set of recovery keys. There can only be 1 set of keys at a time. + fn add_recovery(&mut self) -> Result, Error> { + if !self.recovery.is_empty() { + bail!("user already has recovery keys"); + } + + let mut key_data = [0u8; 40]; // 10 keys of 32 bits + proxmox::sys::linux::fill_with_random_data(&mut key_data)?; + for b in key_data.chunks(4) { + self.recovery.push(format!("{:02x}{:02x}{:02x}{:02x}", b[0], b[1], b[2], b[3])); + } + + Ok(self.recovery.clone()) + } +} + +/// Read the TFA entries. +pub fn read() -> Result { + let file = match File::open(CONF_FILE) { + Ok(file) => file, + Err(ref err) if err.not_found() => return Ok(TfaConfig::default()), + Err(err) => return Err(err.into()), + }; + + Ok(serde_json::from_reader(file)?) +} + +/// Requires the write lock to be held. +pub fn write(data: &TfaConfig) -> Result<(), Error> { + let options = proxmox::tools::fs::CreateOptions::new() + .perm(nix::sys::stat::Mode::from_bits_truncate(0o0600)); + + let json = serde_json::to_vec(data)?; + proxmox::tools::fs::replace_file(CONF_FILE, &json, options) +} + +pub fn read_lock() -> Result { + proxmox::tools::fs::open_file_locked(LOCK_FILE, LOCK_TIMEOUT, false) +} + +pub fn write_lock() -> Result { + proxmox::tools::fs::open_file_locked(LOCK_FILE, LOCK_TIMEOUT, true) +} + +/// Add a TOTP entry for a user. Returns the ID. +pub fn add_totp(userid: &Userid, description: String, value: Totp) -> Result { + let _lock = crate::config::tfa::write_lock(); + let mut data = read()?; + let entry = TfaEntry::new(description, value); + let id = entry.info.id.clone(); + data.users + .entry(userid.clone()) + .or_default() + .totp + .push(entry); + write(&data)?; + Ok(id) +} + +/// Add recovery tokens for the user. Returns the token list. +pub fn add_recovery(userid: &Userid) -> Result, Error> { + let _lock = crate::config::tfa::write_lock(); + + let mut data = read()?; + let out = data.users.entry(userid.clone()).or_default().add_recovery()?; + write(&data)?; + Ok(out) +} + +/// Add a u2f registration challenge for a user. +pub fn add_u2f_registration(userid: &Userid, description: String) -> Result { + let _lock = crate::config::tfa::write_lock(); + let mut data = read()?; + let challenge = data.u2f_registration_challenge(userid, description)?; + write(&data)?; + Ok(challenge) +} + +/// Finish a u2f registration challenge for a user. +pub fn finish_u2f_registration( + userid: &Userid, + challenge: &str, + response: &str, +) -> Result { + let _lock = crate::config::tfa::write_lock(); + let mut data = read()?; + let challenge = data.u2f_registration_finish(userid, challenge, response)?; + write(&data)?; + Ok(challenge) +} + +/// Verify a TFA challenge. +pub fn verify_challenge( + userid: &Userid, + challenge: &TfaChallenge, + response: TfaResponse, +) -> Result<(), Error> { + let _lock = crate::config::tfa::write_lock(); + let mut data = read()?; + data.verify(userid, challenge, response)?; + write(&data)?; + Ok(()) +} + +/// Used to inform the user about the recovery code status. +#[derive(Clone, Copy, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum RecoveryState { + Unavailable, + Low, + Available, +} + +impl RecoveryState { + fn from_count(count: usize) -> Self { + match count { + 0 => RecoveryState::Unavailable, + 1..=3 => RecoveryState::Low, + _ => RecoveryState::Available, + } + } + + // serde needs `&self` but this is a tiny Copy type, so we mark this as inline + #[inline] + fn is_unavailable(&self) -> bool { + *self == RecoveryState::Unavailable + } +} + +impl Default for RecoveryState { + fn default() -> Self { + RecoveryState::Unavailable + } +} + +/// When sending a TFA challenge to the user, we include information about what kind of challenge +/// the user may perform. If u2f devices are available, a u2f challenge will be included. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct TfaChallenge { + /// True if the user has TOTP devices. + totp: bool, + + /// Whether there are recovery keys available. + #[serde(skip_serializing_if = "RecoveryState::is_unavailable", default)] + recovery: RecoveryState, + + /// If the user has any u2f tokens registered, this will contain the U2F challenge data. + #[serde(skip_serializing_if = "Option::is_none")] + u2f: Option, +} + +/// Data used for u2f challenges. +#[derive(Deserialize, Serialize)] +pub struct U2fChallenge { + /// AppID and challenge data. + challenge: u2f::AuthChallenge, + + /// Available tokens/keys. + keys: Vec, +} + +/// A user's response to a TFA challenge. +pub enum TfaResponse { + Totp(String), + U2f(Value), + Recovery(String), +} + +impl std::str::FromStr for TfaResponse { + type Err = Error; + + fn from_str(s: &str) -> Result { + Ok(if s.starts_with("totp:") { + TfaResponse::Totp(s[5..].to_string()) + } else if s.starts_with("u2f:") { + TfaResponse::U2f(serde_json::from_str(&s[4..])?) + } else if s.starts_with("recovery:") { + TfaResponse::Recovery(s[9..].to_string()) + } else { + bail!("invalid tfa response"); + }) + } +} + +const fn default_tfa_enable() -> bool { + true +} + +const fn is_default_tfa_enable(v: &bool) -> bool { + *v +} -- 2.39.2