use std::collections::HashMap; use std::fs::File; use std::io::{self, Read, Seek, SeekFrom}; use std::os::unix::fs::OpenOptionsExt; use std::os::unix::io::AsRawFd; use std::path::PathBuf; use std::time::Duration; use anyhow::{bail, format_err, Error}; use nix::sys::stat::Mode; use openssl::hash::MessageDigest; use openssl::pkey::PKey; use openssl::sign::Signer; use serde::{de::Deserializer, Deserialize, Serialize}; use serde_json::Value; use webauthn_rs::Webauthn; use webauthn_rs::proto::Credential as WebauthnCredential; use proxmox::api::api; use proxmox::sys::error::SysError; use proxmox::tools::fs::CreateOptions; use proxmox::tools::tfa::totp::Totp; use proxmox::tools::tfa::u2f; use proxmox::tools::uuid::Uuid; use proxmox::tools::AsHex; 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); const CHALLENGE_DATA_PATH: &str = rundir!("/tfa/challenges"); /// U2F registration challenges time out after 2 minutes. const CHALLENGE_TIMEOUT: i64 = 2 * 60; 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) } /// 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 = CreateOptions::new().perm(Mode::from_bits_truncate(0o0600)); let json = serde_json::to_vec(data)?; proxmox::tools::fs::replace_file(CONF_FILE, &json, options) } #[derive(Deserialize, Serialize)] pub struct U2fConfig { appid: String, } #[derive(Clone, Deserialize, Serialize)] pub struct WebauthnConfig { /// Relying party name. Any text identifier. /// /// Changing this *may* break existing credentials. 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. origin: String, /// Relying part ID. Must be the domain name without protocol, port or location. /// /// Changing this *will* break existing credentials. 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() } } /// Helper to get a u2f instance from a u2f config, or `None` if there isn't one configured. fn get_u2f(u2f: &Option) -> Option { u2f.as_ref() .map(|cfg| u2f::U2f::new(cfg.appid.clone(), cfg.appid.clone())) } /// Helper to get a u2f instance from a u2f config. /// /// This is outside of `TfaConfig` to not borrow its `&self`. fn check_u2f(u2f: &Option) -> Result { get_u2f(u2f).ok_or_else(|| format_err!("no u2f configuration available")) } /// Helper to get a `Webauthn` instance from a `WebauthnConfig`, or `None` if there isn't one /// configured. fn get_webauthn(waconfig: &Option) -> Option> { waconfig.clone().map(Webauthn::new) } /// Helper to get a u2f instance from a u2f config. /// /// This is outside of `TfaConfig` to not borrow its `&self`. fn check_webauthn(waconfig: &Option) -> Result, Error> { get_webauthn(waconfig).ok_or_else(|| format_err!("no webauthn configuration available")) } /// TFA Configuration for this instance. #[derive(Default, Deserialize, Serialize)] pub struct TfaConfig { #[serde(skip_serializing_if = "Option::is_none")] pub u2f: Option, #[serde(skip_serializing_if = "Option::is_none")] pub webauthn: Option, #[serde(skip_serializing_if = "TfaUsers::is_empty", default)] pub users: TfaUsers, } impl TfaConfig { /// Get a two factor authentication challenge for a user, if the user has TFA set up. pub fn login_challenge(&mut self, userid: &Userid) -> Result, Error> { match self.users.get_mut(userid) { Some(udata) => udata.challenge( userid, get_webauthn(&self.webauthn), get_u2f(&self.u2f).as_ref(), ), None => Ok(None), } } /// Get a u2f registration challenge. fn u2f_registration_challenge( &mut self, userid: &Userid, description: String, ) -> Result { let u2f = check_u2f(&self.u2f)?; self.users .entry(userid.clone()) .or_default() .u2f_registration_challenge(userid, &u2f, description) } /// Finish a u2f registration challenge. fn u2f_registration_finish( &mut self, userid: &Userid, challenge: &str, response: &str, ) -> Result { let u2f = check_u2f(&self.u2f)?; match self.users.get_mut(userid) { Some(user) => user.u2f_registration_finish(userid, &u2f, challenge, response), None => bail!("no such challenge"), } } /// Get a webauthn registration challenge. fn webauthn_registration_challenge( &mut self, user: &Userid, description: String, ) -> Result { let webauthn = check_webauthn(&self.webauthn)?; self.users .entry(user.clone()) .or_default() .webauthn_registration_challenge(webauthn, user, description) } /// Finish a webauthn registration challenge. fn webauthn_registration_finish( &mut self, userid: &Userid, challenge: &str, response: &str, ) -> Result { let webauthn = check_webauthn(&self.webauthn)?; let response: webauthn_rs::proto::RegisterPublicKeyCredential = serde_json::from_str(response) .map_err(|err| format_err!("error parsing challenge response: {}", err))?; match self.users.get_mut(userid) { Some(user) => user.webauthn_registration_finish(webauthn, userid, challenge, response), None => bail!("no such challenge"), } } /// Verify a TFA response. fn verify( &mut self, userid: &Userid, challenge: &TfaChallenge, response: TfaResponse, ) -> Result<(), Error> { match self.users.get_mut(userid) { Some(user) => match response { TfaResponse::Totp(value) => user.verify_totp(&value), TfaResponse::U2f(value) => match &challenge.u2f { Some(challenge) => { let u2f = check_u2f(&self.u2f)?; user.verify_u2f(u2f, &challenge.challenge, value) } None => bail!("no u2f factor available for user '{}'", userid), }, TfaResponse::Webauthn(value) => { let webauthn = check_webauthn(&self.webauthn)?; user.verify_webauthn(userid, webauthn, value) } TfaResponse::Recovery(value) => user.verify_recovery(&value), }, None => bail!("no 2nd factor available for user '{}'", userid), } } /// Remove non-existent users. pub fn cleanup_users(&mut self, config: &proxmox::api::section_config::SectionConfigData) { use crate::config::user::User; self.users .retain(|user, _| config.lookup::("user", user.as_str()).is_ok()); } /// Remove a user. Returns `true` if the user actually existed. pub fn remove_user(&mut self, user: &Userid) -> bool { self.users.remove(user).is_some() } } #[api] /// Over the API we only provide this part when querying a user's second factor list. #[derive(Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct TfaInfo { /// The id used to reference this entry. pub id: String, /// User chosen description for this entry. 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, } } } trait IsExpired { fn is_expired(&self, at_epoch: i64) -> bool; } /// A u2f registration challenge. #[derive(Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct U2fRegistrationChallenge { /// JSON formatted challenge string. challenge: String, /// The description chosen by the user for this registration. description: String, /// When the challenge was created as unix epoch. They are supposed to be short-lived. created: i64, } impl U2fRegistrationChallenge { pub fn new(challenge: String, description: String) -> Self { Self { challenge, description, created: proxmox::tools::time::epoch_i64(), } } } impl IsExpired for U2fRegistrationChallenge { fn is_expired(&self, at_epoch: i64) -> bool { self.created < at_epoch } } /// A webauthn registration challenge. #[derive(Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct WebauthnRegistrationChallenge { /// Server side registration state data. state: webauthn_rs::RegistrationState, /// While this is basically the content of a `RegistrationState`, the webauthn-rs crate doesn't /// make this public. challenge: String, /// The description chosen by the user for this registration. description: String, /// When the challenge was created as unix epoch. They are supposed to be short-lived. created: i64, } impl WebauthnRegistrationChallenge { pub fn new( state: webauthn_rs::RegistrationState, challenge: String, description: String, ) -> Self { Self { state, challenge, description, created: proxmox::tools::time::epoch_i64(), } } } impl IsExpired for WebauthnRegistrationChallenge { fn is_expired(&self, at_epoch: i64) -> bool { self.created < at_epoch } } /// A webauthn authentication challenge. #[derive(Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct WebauthnAuthChallenge { /// Server side authentication state. state: webauthn_rs::AuthenticationState, /// While this is basically the content of a `AuthenticationState`, the webauthn-rs crate /// doesn't make this public. challenge: String, /// When the challenge was created as unix epoch. They are supposed to be short-lived. created: i64, } impl WebauthnAuthChallenge { pub fn new(state: webauthn_rs::AuthenticationState, challenge: String) -> Self { Self { state, challenge, created: proxmox::tools::time::epoch_i64(), } } } impl IsExpired for WebauthnAuthChallenge { fn is_expired(&self, at_epoch: i64) -> bool { self.created < at_epoch } } /// Active TFA challenges per user, stored in `CHALLENGE_DATA_PATH`. #[derive(Default, Deserialize, Serialize)] pub struct TfaUserChallenges { /// Active u2f registration challenges for a user. /// /// Expired values are automatically filtered out while parsing the tfa configuration file. #[serde(skip_serializing_if = "Vec::is_empty", default)] #[serde(deserialize_with = "filter_expired_challenge")] u2f_registrations: Vec, /// Active webauthn registration challenges for a user. /// /// Expired values are automatically filtered out while parsing the tfa configuration file. #[serde(skip_serializing_if = "Vec::is_empty", default)] #[serde(deserialize_with = "filter_expired_challenge")] webauthn_registrations: Vec, /// Active webauthn registration challenges for a user. /// /// Expired values are automatically filtered out while parsing the tfa configuration file. #[serde(skip_serializing_if = "Vec::is_empty", default)] #[serde(deserialize_with = "filter_expired_challenge")] webauthn_auths: Vec, } /// Container of `TfaUserChallenges` with the corresponding file lock guard. /// /// TODO: Implement a general file lock guarded struct container in the `proxmox` crate. pub struct TfaUserChallengeData { inner: TfaUserChallenges, path: PathBuf, lock: File, } impl TfaUserChallengeData { /// Build the path to the challenge data file for a user. fn challenge_data_path(userid: &Userid) -> PathBuf { PathBuf::from(format!("{}/{}", CHALLENGE_DATA_PATH, userid)) } /// Load the user's current challenges with the intent to create a challenge (create the file /// if it does not exist), and keep a lock on the file. fn open(userid: &Userid) -> Result { crate::tools::create_run_dir()?; let options = CreateOptions::new().perm(Mode::from_bits_truncate(0o0600)); proxmox::tools::fs::create_path(CHALLENGE_DATA_PATH, Some(options.clone()), Some(options)) .map_err(|err| { format_err!( "failed to crate challenge data dir {:?}: {}", CHALLENGE_DATA_PATH, err ) })?; let path = Self::challenge_data_path(userid); let mut file = std::fs::OpenOptions::new() .create(true) .read(true) .write(true) .truncate(false) .mode(0o600) .open(&path) .map_err(|err| format_err!("failed to create challenge file {:?}: {}", path, err))?; proxmox::tools::fs::lock_file(&mut file, true, None)?; // the file may be empty, so read to a temporary buffer first: let mut data = Vec::with_capacity(4096); file.read_to_end(&mut data).map_err(|err| { format_err!("failed to read challenge data for user {}: {}", userid, err) })?; let inner = if data.is_empty() { Default::default() } else { serde_json::from_slice(&data).map_err(|err| { format_err!( "failed to parse challenge data for user {}: {}", userid, err ) })? }; Ok(Self { inner, path, lock: file, }) } /// `open` without creating the file if it doesn't exist, to finish WA authentications. fn open_no_create(userid: &Userid) -> Result, Error> { let path = Self::challenge_data_path(userid); let mut file = match std::fs::OpenOptions::new() .read(true) .write(true) .truncate(false) .mode(0o600) .open(&path) { Ok(file) => file, Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), Err(err) => return Err(err.into()), }; proxmox::tools::fs::lock_file(&mut file, true, None)?; let inner = serde_json::from_reader(&mut file).map_err(|err| { format_err!("failed to read challenge data for user {}: {}", userid, err) })?; Ok(Some(Self { inner, path, lock: file, })) } /// Rewind & truncate the file for an update. fn rewind(&mut self) -> Result<(), Error> { let pos = self.lock.seek(SeekFrom::Start(0))?; if pos != 0 { bail!( "unexpected result trying to rewind file, position is {}", pos ); } proxmox::c_try!(unsafe { libc::ftruncate(self.lock.as_raw_fd(), 0) }); 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 typicall 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(()) } /// 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::tools::time::epoch_i64() - CHALLENGE_TIMEOUT; let index = self .inner .u2f_registrations .iter() .position(|r| r.challenge == challenge) .ok_or_else(|| format_err!("no such challenge"))?; let reg = &self.inner.u2f_registrations[index]; if reg.is_expired(expire_before) { bail!("no such challenge"); } // the verify call only takes the actual challenge string, so we have to extract it // (u2f::RegistrationChallenge did not always implement Deserialize...) let chobj: Value = serde_json::from_str(challenge) .map_err(|err| format_err!("error parsing original registration challenge: {}", err))?; let challenge = chobj["challenge"] .as_str() .ok_or_else(|| format_err!("invalid registration challenge"))?; let (mut reg, description) = match u2f.registration_verify(challenge, response)? { None => bail!("verification failed"), Some(reg) => { let entry = self.inner.u2f_registrations.remove(index); (reg, entry.description) } }; // we do not care about the attestation certificates, so don't store them reg.certificate.clear(); Ok(TfaEntry::new(description, reg)) } /// Finish a webauthn registration. The challenge should correspond to an output of /// `webauthn_registration_challenge`. The response should come directly from the client. fn webauthn_registration_finish( &mut self, webauthn: Webauthn, challenge: &str, response: webauthn_rs::proto::RegisterPublicKeyCredential, existing_registrations: &[TfaEntry], ) -> Result, Error> { let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT; let index = self .inner .webauthn_registrations .iter() .position(|r| r.challenge == challenge) .ok_or_else(|| format_err!("no such challenge"))?; let reg = self.inner.webauthn_registrations.remove(index); if reg.is_expired(expire_before) { bail!("no such challenge"); } let credential = webauthn.register_credential(response, reg.state, |id| -> Result { Ok(existing_registrations .iter() .any(|cred| cred.entry.cred_id == *id)) })?; Ok(TfaEntry::new(reg.description, credential)) } } /// TFA data for a user. #[derive(Default, Deserialize, Serialize)] #[serde(deny_unknown_fields)] #[serde(rename_all = "kebab-case")] pub struct TfaUserData { /// Totp keys for a user. #[serde(skip_serializing_if = "Vec::is_empty", default)] pub(crate) totp: Vec>, /// Registered u2f tokens for a user. #[serde(skip_serializing_if = "Vec::is_empty", default)] pub(crate) u2f: Vec>, /// Registered webauthn tokens for a user. #[serde(skip_serializing_if = "Vec::is_empty", default)] pub(crate) webauthn: Vec>, /// Recovery keys. (Unordered OTP values). #[serde(skip_serializing_if = "Recovery::option_is_empty", default)] pub(crate) recovery: Option, } impl TfaUserData { /// Shortcut for the option type. pub fn has_recovery(&self) -> bool { !Recovery::option_is_empty(&self.recovery) } /// `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.has_recovery() } /// Find an entry by id, except for the "recovery" entry which we're currently treating /// specially. pub fn find_entry_mut<'a>(&'a mut self, id: &str) -> Option<&'a mut TfaInfo> { for entry in &mut self.totp { if entry.info.id == id { return Some(&mut entry.info); } } for entry in &mut self.webauthn { if entry.info.id == id { return Some(&mut entry.info); } } for entry in &mut self.u2f { if entry.info.id == id { return Some(&mut entry.info); } } None } /// Create a u2f registration challenge. /// /// The description is required at this point already mostly to better be able to identify such /// challenges in the tfa config file if necessary. The user otherwise has no access to this /// information at this point, as the challenge is identified by its actual challenge data /// instead. fn u2f_registration_challenge( &mut self, userid: &Userid, u2f: &u2f::U2f, description: String, ) -> Result { let challenge = serde_json::to_string(&u2f.registration_challenge()?)?; let mut data = TfaUserChallengeData::open(userid)?; data.inner .u2f_registrations .push(U2fRegistrationChallenge::new( challenge.clone(), description, )); data.save()?; Ok(challenge) } fn u2f_registration_finish( &mut self, userid: &Userid, u2f: &u2f::U2f, challenge: &str, response: &str, ) -> Result { let mut data = TfaUserChallengeData::open(userid)?; let entry = data.u2f_registration_finish(u2f, challenge, response)?; data.save()?; let id = entry.info.id.clone(); self.u2f.push(entry); Ok(id) } /// Create a webauthn registration challenge. /// /// The description is required at this point already mostly to better be able to identify such /// challenges in the tfa config file if necessary. The user otherwise has no access to this /// information at this point, as the challenge is identified by its actual challenge data /// instead. fn webauthn_registration_challenge( &mut self, mut webauthn: Webauthn, userid: &Userid, description: String, ) -> Result { let userid_str = userid.to_string(); let (challenge, state) = webauthn.generate_challenge_register(&userid_str, None)?; let challenge_string = challenge.public_key.challenge.to_string(); let challenge = serde_json::to_string(&challenge)?; let mut data = TfaUserChallengeData::open(userid)?; data.inner .webauthn_registrations .push(WebauthnRegistrationChallenge::new( state, challenge_string, description, )); data.save()?; Ok(challenge) } /// Finish a webauthn registration. The challenge should correspond to an output of /// `webauthn_registration_challenge`. The response should come directly from the client. fn webauthn_registration_finish( &mut self, webauthn: Webauthn, userid: &Userid, challenge: &str, response: webauthn_rs::proto::RegisterPublicKeyCredential, ) -> Result { let mut data = TfaUserChallengeData::open(userid)?; let entry = data.webauthn_registration_finish(webauthn, challenge, response, &self.webauthn)?; data.save()?; let id = entry.info.id.clone(); self.webauthn.push(entry); Ok(id) } /// Generate a generic TFA challenge. See the [`TfaChallenge`] description for details. pub fn challenge( &mut self, userid: &Userid, webauthn: Option>, u2f: Option<&u2f::U2f>, ) -> Result, Error> { if self.is_empty() { return Ok(None); } Ok(Some(TfaChallenge { totp: self.totp.iter().any(|e| e.info.enable), recovery: RecoveryState::from(&self.recovery), webauthn: match webauthn { Some(webauthn) => self.webauthn_challenge(userid, webauthn)?, None => None, }, u2f: match u2f { Some(u2f) => self.u2f_challenge(u2f)?, None => None, }, })) } /// Helper to iterate over enabled totp entries. fn enabled_totp_entries(&self) -> impl Iterator { self.totp .iter() .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None }) } /// Helper to iterate over enabled u2f entries. fn enabled_u2f_entries(&self) -> impl Iterator { self.u2f .iter() .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None }) } /// Helper to iterate over enabled u2f entries. fn enabled_webauthn_entries(&self) -> impl Iterator { self.webauthn .iter() .filter_map(|e| if e.info.enable { Some(&e.entry) } else { None }) } /// Generate an optional u2f challenge. fn u2f_challenge(&self, u2f: &u2f::U2f) -> Result, Error> { if self.u2f.is_empty() { return Ok(None); } let keys: Vec = self .enabled_u2f_entries() .map(|registration| registration.key.clone()) .collect(); if keys.is_empty() { return Ok(None); } Ok(Some(U2fChallenge { challenge: u2f.auth_challenge()?, keys, })) } /// Generate an optional webauthn challenge. fn webauthn_challenge( &mut self, userid: &Userid, mut webauthn: Webauthn, ) -> Result, Error> { if self.webauthn.is_empty() { return Ok(None); } let creds: Vec<_> = self.enabled_webauthn_entries().map(Clone::clone).collect(); if creds.is_empty() { return Ok(None); } let (challenge, state) = webauthn.generate_challenge_authenticate(creds, None)?; let challenge_string = challenge.public_key.challenge.to_string(); let mut data = TfaUserChallengeData::open(userid)?; data.inner .webauthn_auths .push(WebauthnAuthChallenge::new(state, challenge_string)); data.save()?; Ok(Some(challenge)) } /// Verify a totp challenge. The `value` should be the totp digits as plain text. fn verify_totp(&self, value: &str) -> Result<(), Error> { let now = std::time::SystemTime::now(); for entry in self.enabled_totp_entries() { if entry.verify(value, now, -1..=1)?.is_some() { return Ok(()); } } bail!("totp verification failed"); } /// Verify a u2f response. fn verify_u2f( &self, u2f: u2f::U2f, challenge: &u2f::AuthChallenge, response: Value, ) -> Result<(), Error> { let response: u2f::AuthResponse = serde_json::from_value(response) .map_err(|err| format_err!("invalid u2f response: {}", err))?; if let Some(entry) = self .enabled_u2f_entries() .find(|e| e.key.key_handle == response.key_handle()) { if u2f .auth_verify_obj(&entry.public_key, &challenge.challenge, response)? .is_some() { return Ok(()); } } bail!("u2f verification failed"); } /// Verify a webauthn response. fn verify_webauthn( &mut self, userid: &Userid, mut webauthn: Webauthn, mut response: Value, ) -> Result<(), Error> { let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT; let challenge = match response .as_object_mut() .ok_or_else(|| format_err!("invalid response, must be a json object"))? .remove("challenge") .ok_or_else(|| format_err!("missing challenge data in response"))? { Value::String(s) => s, _ => bail!("invalid challenge data in response"), }; let response: webauthn_rs::proto::PublicKeyCredential = serde_json::from_value(response) .map_err(|err| format_err!("invalid webauthn response: {}", err))?; let mut data = match TfaUserChallengeData::open_no_create(userid)? { Some(data) => data, None => bail!("no such challenge"), }; let index = data .inner .webauthn_auths .iter() .position(|r| r.challenge == challenge) .ok_or_else(|| format_err!("no such challenge"))?; let challenge = data.inner.webauthn_auths.remove(index); if challenge.is_expired(expire_before) { bail!("no such challenge"); } // we don't allow re-trying the challenge, so make the removal persistent now: data.save() .map_err(|err| format_err!("failed to save challenge file: {}", err))?; match webauthn.authenticate_credential(response, challenge.state)? { Some((_cred, _counter)) => Ok(()), None => bail!("webauthn authentication failed"), } } /// Verify a recovery key. /// /// NOTE: If successful, the key will automatically be removed from the list of available /// recovery keys, so the configuration needs to be saved afterwards! fn verify_recovery(&mut self, value: &str) -> Result<(), Error> { if let Some(r) = &mut self.recovery { if r.verify(value)? { return Ok(()); } } bail!("recovery verification failed"); } /// Add a new set of recovery keys. There can only be 1 set of keys at a time. fn add_recovery(&mut self) -> Result, Error> { if self.recovery.is_some() { bail!("user already has recovery keys"); } let (recovery, original) = Recovery::generate()?; self.recovery = Some(recovery); Ok(original) } } /// Recovery entries. We use HMAC-SHA256 with a random secret as a salted hash replacement. #[derive(Deserialize, Serialize)] pub struct Recovery { secret: String, entries: Vec, } impl Recovery { /// Generate recovery keys and return the recovery entry along with the original string /// entries. fn generate() -> Result<(Self, Vec), Error> { let mut secret = [0u8; 8]; proxmox::sys::linux::fill_with_random_data(&mut secret)?; let mut this = Self { secret: AsHex(&secret).to_string(), entries: Vec::with_capacity(10), }; let mut original = Vec::new(); let mut key_data = [0u8; 80]; // 10 keys of 12 bytes proxmox::sys::linux::fill_with_random_data(&mut key_data)?; for b in key_data.chunks(8) { let entry = format!( "{}-{}-{}-{}", AsHex(&b[0..2]), AsHex(&b[2..4]), AsHex(&b[4..6]), AsHex(&b[6..8]), ); this.entries.push(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(AsHex(&hmac).to_string()) } /// Shortcut to get the count. fn len(&self) -> usize { self.entries.len() } /// Check if this entry is empty. fn is_empty(&self) -> bool { self.entries.is_empty() } /// Convenience serde method to check if either the option is `None` or the content `is_empty`. fn option_is_empty(this: &Option) -> bool { this.as_ref().map_or(true, Self::is_empty) } /// Verify a key and remove it. Returns whether the key was valid. Errors on openssl errors. fn verify(&mut self, key: &str) -> Result { let hash = self.hash(key.as_bytes())?; Ok(match self.entries.iter().position(|entry| *entry == hash) { Some(index) => { self.entries.remove(index); true } None => false, }) } } /// Serde helper using our `FilteredVecVisitor` to filter out expired entries directly at load /// time. fn filter_expired_challenge<'de, D, T>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, T: Deserialize<'de> + IsExpired, { let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT; Ok( deserializer.deserialize_seq(crate::tools::serde_filter::FilteredVecVisitor::new( "a challenge entry", move |reg: &T| !reg.is_expired(expire_before), ))?, ) } /// Get an optional TFA challenge for a user. pub fn login_challenge(userid: &Userid) -> Result, Error> { let _lock = write_lock()?; let mut data = read()?; Ok(match data.login_challenge(userid)? { Some(challenge) => { write(&data)?; Some(challenge) } None => None, }) } /// Add a TOTP entry for a user. Returns the ID. pub fn add_totp(userid: &Userid, description: String, value: Totp) -> Result { let _lock = write_lock(); let mut data = read()?; let entry = TfaEntry::new(description, value); let id = entry.info.id.clone(); data.users .entry(userid.clone()) .or_default() .totp .push(entry); write(&data)?; Ok(id) } /// Add recovery tokens for the user. Returns the token list. pub fn add_recovery(userid: &Userid) -> Result, Error> { let _lock = write_lock(); let mut data = read()?; let out = data .users .entry(userid.clone()) .or_default() .add_recovery()?; 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 id = data.u2f_registration_finish(userid, challenge, response)?; write(&data)?; Ok(id) } /// Add a webauthn registration challenge for a user. pub fn add_webauthn_registration(userid: &Userid, description: String) -> Result { let _lock = crate::config::tfa::write_lock(); let mut data = read()?; let challenge = data.webauthn_registration_challenge(userid, description)?; write(&data)?; Ok(challenge) } /// Finish a webauthn registration challenge for a user. pub fn finish_webauthn_registration( userid: &Userid, challenge: &str, response: &str, ) -> Result { let _lock = crate::config::tfa::write_lock(); let mut data = read()?; let id = data.webauthn_registration_finish(userid, challenge, response)?; write(&data)?; Ok(id) } /// 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 } } impl From<&Option> for RecoveryState { fn from(r: &Option) -> Self { match r { Some(r) => Self::from_count(r.len()), None => RecoveryState::Unavailable, } } } /// When sending a TFA challenge to the user, we include information about what kind of challenge /// the user may perform. If webauthn credentials are available, a webauthn challenge will be /// included. #[derive(Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub struct TfaChallenge { /// True if the user has TOTP devices. totp: bool, /// Whether there are recovery keys available. #[serde(skip_serializing_if = "RecoveryState::is_unavailable", default)] recovery: RecoveryState, /// If the user has any u2f tokens registered, this will contain the U2F challenge data. #[serde(skip_serializing_if = "Option::is_none")] u2f: Option, /// If the user has any webauthn credentials registered, this will contain the corresponding /// challenge data. #[serde(skip_serializing_if = "Option::is_none", skip_deserializing)] webauthn: Option, } /// Data used for u2f challenges. #[derive(Deserialize, Serialize)] pub struct U2fChallenge { /// AppID and challenge data. challenge: u2f::AuthChallenge, /// Available tokens/keys. keys: Vec, } /// A user's response to a TFA challenge. pub enum TfaResponse { Totp(String), U2f(Value), Webauthn(Value), Recovery(String), } impl std::str::FromStr for TfaResponse { type Err = Error; fn from_str(s: &str) -> Result { Ok(if 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("webauthn:") { TfaResponse::Webauthn(serde_json::from_str(&s[9..])?) } 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 }