diff --git a/Cargo.toml b/Cargo.toml index 0c4ac5cd..0fc4d317 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ udev = ">= 0.3, <0.5" url = "2.1" #valgrind_request = { git = "https://github.com/edef1c/libvalgrind_request", version = "1.1.0", optional = true } walkdir = "2" +webauthn-rs = "0.2.5" xdg = "2.2" zstd = { version = "0.4", features = [ "bindgen" ] } nom = "5.1" diff --git a/src/api2/access.rs b/src/api2/access.rs index 22d6ebd2..ebab42d1 100644 --- a/src/api2/access.rs +++ b/src/api2/access.rs @@ -4,33 +4,46 @@ use serde_json::{json, Value}; use std::collections::HashMap; use std::collections::HashSet; -use proxmox::api::{api, RpcEnvironment, Permission}; use proxmox::api::router::{Router, SubdirMap}; -use proxmox::{sortable, identity}; +use proxmox::api::{api, Permission, RpcEnvironment}; use proxmox::{http_err, list_subdirs_api_method}; +use proxmox::{identity, sortable}; -use crate::tools::ticket::{self, Empty, Ticket}; -use crate::auth_helpers::*; use crate::api2::types::*; +use crate::auth_helpers::*; +use crate::server::ticket::ApiTicket; +use crate::tools::ticket::{self, Empty, Ticket}; use crate::config::acl as acl_config; -use crate::config::acl::{PRIVILEGES, PRIV_SYS_AUDIT, PRIV_PERMISSIONS_MODIFY}; +use crate::config::acl::{PRIVILEGES, PRIV_PERMISSIONS_MODIFY, PRIV_SYS_AUDIT}; use crate::config::cached_user_info::CachedUserInfo; +use crate::config::tfa::TfaChallenge; -pub mod user; -pub mod domain; pub mod acl; +pub mod domain; pub mod role; +pub mod tfa; +pub mod user; + +enum AuthResult { + /// Successful authentication which does not require a new ticket. + Success, + + /// Successful authentication which requires a ticket to be created. + CreateTicket, + + /// A partial ticket which requires a 2nd factor will be created. + Partial(TfaChallenge), +} -/// returns Ok(true) if a ticket has to be created -/// and Ok(false) if not fn authenticate_user( userid: &Userid, password: &str, path: Option, privs: Option, port: Option, -) -> Result { + tfa_challenge: Option, +) -> Result { let user_info = CachedUserInfo::new()?; let auth_id = Authid::from(userid.clone()); @@ -38,12 +51,16 @@ fn authenticate_user( bail!("user account disabled or expired."); } + if let Some(tfa_challenge) = tfa_challenge { + return authenticate_2nd(userid, &tfa_challenge, password); + } + if password.starts_with("PBS:") { if let Ok(ticket_userid) = Ticket::::parse(password) .and_then(|ticket| ticket.verify(public_auth_key(), "PBS", None)) { if *userid == ticket_userid { - return Ok(true); + return Ok(AuthResult::CreateTicket); } bail!("ticket login failed - wrong userid"); } @@ -53,17 +70,17 @@ fn authenticate_user( } let path = path.ok_or_else(|| format_err!("missing path for termproxy ticket"))?; - let privilege_name = privs - .ok_or_else(|| format_err!("missing privilege name for termproxy ticket"))?; + let privilege_name = + privs.ok_or_else(|| format_err!("missing privilege name for termproxy ticket"))?; let port = port.ok_or_else(|| format_err!("missing port for termproxy ticket"))?; - if let Ok(Empty) = Ticket::parse(password) - .and_then(|ticket| ticket.verify( + if let Ok(Empty) = Ticket::parse(password).and_then(|ticket| { + ticket.verify( public_auth_key(), ticket::TERM_PREFIX, Some(&ticket::term_aad(userid, &path, port)), - )) - { + ) + }) { for (name, privilege) in PRIVILEGES { if *name == privilege_name { let mut path_vec = Vec::new(); @@ -73,7 +90,7 @@ fn authenticate_user( } } user_info.check_privs(&auth_id, &path_vec, *privilege, false)?; - return Ok(false); + return Ok(AuthResult::Success); } } @@ -81,8 +98,26 @@ fn authenticate_user( } } - let _ = crate::auth::authenticate_user(userid, password)?; - Ok(true) + let _: () = crate::auth::authenticate_user(userid, password)?; + + Ok(match crate::config::tfa::login_challenge(userid)? { + None => AuthResult::CreateTicket, + Some(challenge) => AuthResult::Partial(challenge), + }) +} + +fn authenticate_2nd( + userid: &Userid, + challenge_ticket: &str, + response: &str, +) -> Result { + let challenge: TfaChallenge = Ticket::::parse(&challenge_ticket)? + .verify_with_time_frame(public_auth_key(), "PBS", Some(userid.as_str()), -120..240)? + .require_partial()?; + + let _: () = crate::config::tfa::verify_challenge(userid, &challenge, response.parse()?)?; + + Ok(AuthResult::CreateTicket) } #[api( @@ -109,6 +144,11 @@ fn authenticate_user( description: "Port for verifying terminal tickets.", optional: true, }, + "tfa-challenge": { + type: String, + description: "The signed TFA challenge string the user wants to respond to.", + optional: true, + }, }, }, returns: { @@ -141,15 +181,18 @@ fn create_ticket( path: Option, privs: Option, port: Option, + tfa_challenge: Option, rpcenv: &mut dyn RpcEnvironment, ) -> Result { - match authenticate_user(&username, &password, path, privs, port) { - Ok(true) => { - let ticket = Ticket::new("PBS", &username)?.sign(private_auth_key(), None)?; - + match authenticate_user(&username, &password, path, privs, port, tfa_challenge) { + Ok(AuthResult::Success) => Ok(json!({ "username": username })), + Ok(AuthResult::CreateTicket) => { + let api_ticket = ApiTicket::full(username.clone()); + let ticket = Ticket::new("PBS", &api_ticket)?.sign(private_auth_key(), None)?; let token = assemble_csrf_prevention_token(csrf_secret(), &username); - crate::server::rest::auth_logger()?.log(format!("successful auth for user '{}'", username)); + crate::server::rest::auth_logger()? + .log(format!("successful auth for user '{}'", username)); Ok(json!({ "username": username, @@ -157,9 +200,15 @@ fn create_ticket( "CSRFPreventionToken": token, })) } - Ok(false) => Ok(json!({ - "username": username, - })), + Ok(AuthResult::Partial(challenge)) => { + let api_ticket = ApiTicket::partial(challenge); + let ticket = Ticket::new("PBS", &api_ticket)? + .sign(private_auth_key(), Some(username.as_str()))?; + Ok(json!({ + "username": username, + "ticket": ticket, + })) + } Err(err) => { let client_ip = match rpcenv.get_client_ip().map(|addr| addr.ip()) { Some(ip) => format!("{}", ip), @@ -219,12 +268,16 @@ fn change_password( let mut allowed = userid == *current_user; - if current_user == "root@pam" { allowed = true; } + if current_user == "root@pam" { + allowed = true; + } if !allowed { let user_info = CachedUserInfo::new()?; let privs = user_info.lookup_privs(¤t_auth, &[]); - if (privs & PRIV_PERMISSIONS_MODIFY) != 0 { allowed = true; } + if (privs & PRIV_PERMISSIONS_MODIFY) != 0 { + allowed = true; + } } if !allowed { @@ -281,12 +334,13 @@ pub fn list_permissions( auth_id } else if auth_id.is_token() && !current_auth_id.is_token() - && auth_id.user() == current_auth_id.user() { + && auth_id.user() == current_auth_id.user() + { auth_id } else { bail!("not allowed to list permissions of {}", auth_id); } - }, + } None => current_auth_id, } } else { @@ -296,11 +350,10 @@ pub fn list_permissions( } }; - fn populate_acl_paths( mut paths: HashSet, node: acl_config::AclTreeNode, - path: &str + path: &str, ) -> HashSet { for (sub_path, child_node) in node.children { let sub_path = format!("{}/{}", path, &sub_path); @@ -315,7 +368,7 @@ pub fn list_permissions( let mut paths = HashSet::new(); paths.insert(path); paths - }, + } None => { let mut paths = HashSet::new(); @@ -330,31 +383,35 @@ pub fn list_permissions( paths.insert("/system".to_string()); paths - }, + } }; - let map = paths - .into_iter() - .fold(HashMap::new(), |mut map: HashMap>, path: String| { + let map = paths.into_iter().fold( + HashMap::new(), + |mut map: HashMap>, path: String| { let split_path = acl_config::split_acl_path(path.as_str()); let (privs, propagated_privs) = user_info.lookup_privs_details(&auth_id, &split_path); match privs { 0 => map, // Don't leak ACL paths where we don't have any privileges _ => { - let priv_map = PRIVILEGES - .iter() - .fold(HashMap::new(), |mut priv_map, (name, value)| { - if value & privs != 0 { - priv_map.insert(name.to_string(), value & propagated_privs != 0); - } - priv_map - }); + let priv_map = + PRIVILEGES + .iter() + .fold(HashMap::new(), |mut priv_map, (name, value)| { + if value & privs != 0 { + priv_map + .insert(name.to_string(), value & propagated_privs != 0); + } + priv_map + }); map.insert(path, priv_map); map - }, - }}); + } + } + }, + ); Ok(map) } @@ -362,21 +419,16 @@ pub fn list_permissions( #[sortable] const SUBDIRS: SubdirMap = &sorted!([ ("acl", &acl::ROUTER), + ("password", &Router::new().put(&API_METHOD_CHANGE_PASSWORD)), ( - "password", &Router::new() - .put(&API_METHOD_CHANGE_PASSWORD) - ), - ( - "permissions", &Router::new() - .get(&API_METHOD_LIST_PERMISSIONS) - ), - ( - "ticket", &Router::new() - .post(&API_METHOD_CREATE_TICKET) + "permissions", + &Router::new().get(&API_METHOD_LIST_PERMISSIONS) ), + ("ticket", &Router::new().post(&API_METHOD_CREATE_TICKET)), ("domains", &domain::ROUTER), ("roles", &role::ROUTER), ("users", &user::ROUTER), + ("tfa", &tfa::ROUTER), ]); pub const ROUTER: Router = Router::new() diff --git a/src/api2/access/tfa.rs b/src/api2/access/tfa.rs new file mode 100644 index 00000000..bae15ccc --- /dev/null +++ b/src/api2/access/tfa.rs @@ -0,0 +1,575 @@ +use anyhow::{bail, format_err, Error}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use proxmox::api::{api, Permission, Router, RpcEnvironment}; +use proxmox::tools::tfa::totp::Totp; +use proxmox::{http_bail, http_err}; + +use crate::api2::types::{Authid, Userid, PASSWORD_SCHEMA}; +use crate::config::acl::{PRIV_PERMISSIONS_MODIFY, PRIV_SYS_AUDIT}; +use crate::config::cached_user_info::CachedUserInfo; +use crate::config::tfa::{TfaInfo, TfaUserData}; + +/// Perform first-factor (password) authentication only. Ignore password for the root user. +/// Otherwise check the current user's password. +/// +/// This means that user admins need to type in their own password while editing a user, and +/// regular users, which can only change their own TFA settings (checked at the API level), can +/// change their own settings using their own password. +fn tfa_update_auth( + rpcenv: &mut dyn RpcEnvironment, + userid: &Userid, + password: Option, +) -> Result<(), Error> { + let authid: Authid = rpcenv.get_auth_id().unwrap().parse()?; + + if authid.user() != Userid::root_userid() { + let password = password.ok_or_else(|| format_err!("missing password"))?; + let _: () = crate::auth::authenticate_user(authid.user(), &password)?; + } + + // After authentication, verify that the to-be-modified user actually exists: + if authid.user() != userid { + let (config, _digest) = crate::config::user::config()?; + + if config.sections.get(userid.as_str()).is_none() { + bail!("user '{}' does not exists.", userid); + } + } + + Ok(()) +} + +#[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, +} + +#[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() + + if data.has_recovery() { 1 } else { 0 }, + ); + if data.has_recovery() { + out.push(TypedTfaInfo { + ty: TfaType::Recovery, + info: TfaInfo::recovery(), + }) + } + for entry in data.totp { + out.push(TypedTfaInfo { + ty: TfaType::Totp, + info: entry.info, + }); + } + for entry in data.webauthn { + out.push(TypedTfaInfo { + ty: TfaType::Webauthn, + info: entry.info, + }); + } + for entry in data.u2f { + out.push(TypedTfaInfo { + ty: TfaType::U2f, + info: entry.info, + }); + } + out +} + +#[api( + protected: true, + input: { + properties: { userid: { type: Userid } }, + }, + access: { + permission: &Permission::Or(&[ + &Permission::Privilege(&["access", "users"], PRIV_PERMISSIONS_MODIFY, false), + &Permission::UserParam("userid"), + ]), + }, +)] +/// Add a TOTP secret to the user. +pub fn list_user_tfa(userid: Userid) -> Result, Error> { + let _lock = crate::config::tfa::read_lock()?; + + Ok(match crate::config::tfa::read()?.users.remove(&userid) { + Some(data) => to_data(data), + None => Vec::new(), + }) +} + +#[api( + protected: true, + input: { + properties: { + userid: { type: Userid }, + id: { description: "the tfa entry id" } + }, + }, + access: { + permission: &Permission::Or(&[ + &Permission::Privilege(&["access", "users"], PRIV_PERMISSIONS_MODIFY, false), + &Permission::UserParam("userid"), + ]), + }, +)] +/// Get a single TFA entry. +pub fn get_tfa(userid: Userid, id: String) -> Result { + let _lock = crate::config::tfa::read_lock()?; + + if let Some(user_data) = crate::config::tfa::read()?.users.remove(&userid) { + if id == "recovery" { + if user_data.has_recovery() { + return Ok(TypedTfaInfo { + ty: TfaType::Recovery, + info: TfaInfo::recovery(), + }); + } + } else { + for tfa in user_data.totp { + if tfa.info.id == id { + return Ok(TypedTfaInfo { + ty: TfaType::Totp, + info: tfa.info, + }); + } + } + + for tfa in user_data.webauthn { + if tfa.info.id == id { + return Ok(TypedTfaInfo { + ty: TfaType::Webauthn, + info: tfa.info, + }); + } + } + + for tfa in user_data.u2f { + if tfa.info.id == id { + return Ok(TypedTfaInfo { + ty: TfaType::U2f, + info: tfa.info, + }); + } + } + } + } + + http_bail!(NOT_FOUND, "no such tfa entry: {}/{}", userid, id); +} + +#[api( + protected: true, + input: { + properties: { + userid: { type: Userid }, + id: { + description: "the tfa entry id", + }, + password: { + schema: PASSWORD_SCHEMA, + optional: true, + }, + }, + }, + access: { + permission: &Permission::Or(&[ + &Permission::Privilege(&["access", "users"], PRIV_PERMISSIONS_MODIFY, false), + &Permission::UserParam("userid"), + ]), + }, +)] +/// Get a single TFA entry. +pub fn delete_tfa( + userid: Userid, + id: String, + password: Option, + rpcenv: &mut dyn RpcEnvironment, +) -> Result<(), Error> { + tfa_update_auth(rpcenv, &userid, password)?; + + let _lock = crate::config::tfa::write_lock()?; + + let mut data = crate::config::tfa::read()?; + + let user_data = data + .users + .get_mut(&userid) + .ok_or_else(|| http_err!(NOT_FOUND, "no such entry: {}/{}", userid, id))?; + + let found = if id == "recovery" { + let found = user_data.has_recovery(); + user_data.recovery = None; + found + } else if let Some(i) = user_data.totp.iter().position(|entry| entry.info.id == id) { + user_data.totp.remove(i); + true + } else if let Some(i) = user_data + .webauthn + .iter() + .position(|entry| entry.info.id == id) + { + user_data.webauthn.remove(i); + true + } else if let Some(i) = user_data.u2f.iter().position(|entry| entry.info.id == id) { + user_data.u2f.remove(i); + true + } else { + false + }; + + if !found { + http_bail!(NOT_FOUND, "no such tfa entry: {}/{}", userid, id); + } + + if user_data.is_empty() { + data.users.remove(&userid); + } + + crate::config::tfa::write(&data)?; + + Ok(()) +} + +#[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: Userid, + + /// TFA entries. + entries: Vec, +} + +#[api( + protected: true, + input: { + properties: {}, + }, + access: { + permission: &Permission::Anybody, + description: "Returns all or just the logged-in user, depending on privileges.", + }, +)] +/// List user TFA configuration. +pub fn list_tfa(rpcenv: &mut dyn RpcEnvironment) -> Result { + let authid: Authid = rpcenv.get_auth_id().unwrap().parse()?; + let user_info = CachedUserInfo::new()?; + + let top_level_privs = user_info.lookup_privs(&authid, &["access", "users"]); + let top_level_allowed = (top_level_privs & PRIV_SYS_AUDIT) != 0; + + let _lock = crate::config::tfa::read_lock()?; + let tfa_data = crate::config::tfa::read()?.users; + + let mut out = Vec::::new(); + if top_level_allowed { + for (user, data) in tfa_data { + out.push(TfaUser { + userid: user, + entries: to_data(data), + }); + } + } else { + if let Some(data) = { tfa_data }.remove(authid.user()) { + out.push(TfaUser { + userid: authid.into(), + entries: to_data(data), + }); + } + } + + Ok(serde_json::to_value(out)?) +} + +#[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)] +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() + } + } +} + +#[api( + protected: true, + input: { + properties: { + userid: { type: Userid }, + description: { + description: "A description to distinguish multiple entries from one another", + type: String, + max_length: 255, + optional: true, + }, + "type": { type: TfaType }, + totp: { + description: "A totp URI.", + optional: true, + }, + value: { + description: + "The current value for the provided totp URI, or a Webauthn/U2F challenge response", + optional: true, + }, + challenge: { + description: "When responding to a u2f challenge: the original challenge string", + optional: true, + }, + password: { + schema: PASSWORD_SCHEMA, + optional: true, + }, + }, + }, + returns: { type: TfaUpdateInfo }, + access: { + permission: &Permission::Or(&[ + &Permission::Privilege(&["access", "users"], PRIV_PERMISSIONS_MODIFY, false), + &Permission::UserParam("userid"), + ]), + }, +)] +/// Add a TFA entry to the user. +fn add_tfa_entry( + userid: Userid, + description: Option, + totp: Option, + value: Option, + challenge: Option, + password: Option, + mut params: Value, // FIXME: once api macro supports raw parameters names, use `r#type` + rpcenv: &mut dyn RpcEnvironment, +) -> Result { + tfa_update_auth(rpcenv, &userid, password)?; + + let tfa_type: TfaType = serde_json::from_value(params["type"].take())?; + + let need_description = + move || description.ok_or_else(|| format_err!("'description' is required for new entries")); + + match tfa_type { + TfaType::Totp => match (totp, value) { + (Some(totp), Some(value)) => { + if challenge.is_some() { + bail!("'challenge' parameter is invalid for 'totp' entries"); + } + let description = need_description()?; + + let totp: Totp = totp.parse()?; + if totp + .verify(&value, std::time::SystemTime::now(), -1..=1)? + .is_none() + { + bail!("failed to verify TOTP challenge"); + } + crate::config::tfa::add_totp(&userid, description, totp).map(TfaUpdateInfo::id) + } + _ => bail!("'totp' type requires both 'totp' and 'value' parameters"), + }, + TfaType::Webauthn => { + if totp.is_some() { + bail!("'totp' parameter is invalid for 'totp' entries"); + } + + match challenge { + None => crate::config::tfa::add_webauthn_registration(&userid, need_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)" + ) + })?; + crate::config::tfa::finish_webauthn_registration(&userid, &challenge, &value) + .map(TfaUpdateInfo::id) + } + } + } + TfaType::U2f => { + if totp.is_some() { + bail!("'totp' parameter is invalid for 'totp' entries"); + } + + match challenge { + None => crate::config::tfa::add_u2f_registration(&userid, need_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)") + })?; + crate::config::tfa::finish_u2f_registration(&userid, &challenge, &value) + .map(TfaUpdateInfo::id) + } + } + } + TfaType::Recovery => { + if totp.or(value).or(challenge).is_some() { + bail!("generating recovery tokens does not allow additional parameters"); + } + + let recovery = crate::config::tfa::add_recovery(&userid)?; + + Ok(TfaUpdateInfo { + id: Some("recovery".to_string()), + recovery, + ..Default::default() + }) + } + } +} + +#[api( + protected: true, + input: { + properties: { + userid: { type: Userid }, + id: { + description: "the tfa entry id", + }, + description: { + description: "A description to distinguish multiple entries from one another", + type: String, + max_length: 255, + optional: true, + }, + enable: { + description: "Whether this entry should currently be enabled or disabled", + optional: true, + }, + password: { + schema: PASSWORD_SCHEMA, + optional: true, + }, + }, + }, + access: { + permission: &Permission::Or(&[ + &Permission::Privilege(&["access", "users"], PRIV_PERMISSIONS_MODIFY, false), + &Permission::UserParam("userid"), + ]), + }, +)] +/// Update user's TFA entry description. +pub fn update_tfa_entry( + userid: Userid, + id: String, + description: Option, + enable: Option, + password: Option, + rpcenv: &mut dyn RpcEnvironment, +) -> Result<(), Error> { + tfa_update_auth(rpcenv, &userid, password)?; + + let _lock = crate::config::tfa::write_lock()?; + + let mut data = crate::config::tfa::read()?; + + let mut entry = data + .users + .get_mut(&userid) + .and_then(|user| user.find_entry_mut(&id)) + .ok_or_else(|| http_err!(NOT_FOUND, "no such entry: {}/{}", userid, id))?; + + if let Some(description) = description { + entry.description = description; + } + + if let Some(enable) = enable { + entry.enable = enable; + } + + crate::config::tfa::write(&data)?; + Ok(()) +} + +pub const ROUTER: Router = Router::new() + .get(&API_METHOD_LIST_TFA) + .match_all("userid", &USER_ROUTER); + +const USER_ROUTER: Router = Router::new() + .get(&API_METHOD_LIST_USER_TFA) + .post(&API_METHOD_ADD_TFA_ENTRY) + .match_all("id", &ITEM_ROUTER); + +const ITEM_ROUTER: Router = Router::new() + .get(&API_METHOD_GET_TFA) + .put(&API_METHOD_UPDATE_TFA_ENTRY) + .delete(&API_METHOD_DELETE_TFA); diff --git a/src/config/tfa.rs b/src/config/tfa.rs index da3a4f9a..67ef62c6 100644 --- a/src/config/tfa.rs +++ b/src/config/tfa.rs @@ -1,16 +1,28 @@ use std::collections::HashMap; use std::fs::File; +use std::io::{self, Read, Seek, SeekFrom}; +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; @@ -21,46 +33,128 @@ 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(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, +#[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() + } } /// 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())) + 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 { +/// +/// 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")) } +/// Heper 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) +} + +/// Heper 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 { - 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()), + 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), } } @@ -68,28 +162,61 @@ impl TfaConfig { /// Get a u2f registration challenge. fn u2f_registration_challenge( &mut self, - user: &Userid, + userid: &Userid, description: String, ) -> Result { - let u2f = self.need_u2f()?; + let u2f = check_u2f(&self.u2f)?; self.users - .entry(user.clone()) + .entry(userid.clone()) .or_default() - .u2f_registration_challenge(&u2f, description) + .u2f_registration_challenge(userid, &u2f, description) } /// Finish a u2f registration challenge. fn u2f_registration_finish( &mut self, - user: &Userid, + userid: &Userid, challenge: &str, response: &str, ) -> Result { - let u2f = self.need_u2f()?; + let u2f = check_u2f(&self.u2f)?; - match self.users.get_mut(user) { - Some(user) => user.u2f_registration_finish(&u2f, challenge, response), + 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"), } } @@ -102,19 +229,21 @@ impl TfaConfig { 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), + 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) } - TfaResponse::Recovery(value) => user.verify_recovery(&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), } } @@ -175,6 +304,10 @@ impl TfaEntry { } } +trait IsExpired { + fn is_expired(&self, at_epoch: i64) -> bool; +} + /// A u2f registration challenge. #[derive(Deserialize, Serialize)] #[serde(deny_unknown_fields)] @@ -197,12 +330,312 @@ impl U2fRegistrationChallenge { 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 { + use std::os::unix::fs::OpenOptionsExt; + + 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 File::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)] @@ -216,39 +649,27 @@ pub struct TfaUserData { #[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 = "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), - ))?, - ) + #[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.recovery.is_empty() + 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 @@ -260,6 +681,12 @@ impl TfaUserData { } } + 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); @@ -277,75 +704,107 @@ impl TfaUserData { /// instead. fn u2f_registration_challenge( &mut self, + userid: &Userid, u2f: &u2f::U2f, description: String, ) -> Result { let challenge = serde_json::to_string(&u2f.registration_challenge()?)?; - self.u2f_registrations.push(U2fRegistrationChallenge::new( - challenge.clone(), - description, - )); + let mut data = TfaUserChallengeData::open(userid)?; + data.inner + .u2f_registrations + .push(U2fRegistrationChallenge::new( + challenge.clone(), + description, + )); + data.save()?; 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, + userid: &Userid, u2f: &u2f::U2f, challenge: &str, response: &str, ) -> Result { - let expire_before = proxmox::tools::time::epoch_i64() - CHALLENGE_TIMEOUT; + let mut data = TfaUserChallengeData::open(userid)?; + let entry = data.u2f_registration_finish(u2f, challenge, response)?; + data.save()?; - 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) } + /// 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(&self, u2f: Option<&u2f::U2f>) -> Result, Error> { + 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_count(self.recovery.len()), + 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, @@ -357,26 +816,21 @@ impl TfaUserData { fn enabled_totp_entries(&self) -> impl Iterator { self.totp .iter() - .filter_map(|e| { - if e.info.enable { - Some(&e.entry) - } else { - None - } - }) + .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 - } - }) + .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. @@ -400,6 +854,33 @@ impl TfaUserData { })) } + /// 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(); @@ -425,9 +906,12 @@ impl TfaUserData { if let Some(entry) = self .enabled_u2f_entries() - .find(|e| e.key.key_handle == response.key_handle) + .find(|e| e.key.key_handle == response.key_handle()) { - if u2f.auth_verify_obj(&entry.public_key, &challenge.challenge, response)?.is_some() { + if u2f + .auth_verify_obj(&entry.public_key, &challenge.challenge, response)? + .is_some() + { return Ok(()); } } @@ -435,67 +919,196 @@ impl TfaUserData { 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()?; + + 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> { - match self.recovery.iter().position(|v| v == value) { - Some(idx) => { - self.recovery.remove(idx); - Ok(()) + if let Some(r) = &mut self.recovery { + if r.verify(value)? { + return Ok(()); } - None => bail!("recovery verification failed"), } + 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() { + if self.recovery.is_some() { 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])); - } + let (recovery, original) = Recovery::generate()?; - Ok(self.recovery.clone()) + self.recovery = Some(recovery); + + Ok(original) } } -/// 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)?) +/// 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, } -/// 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)); +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 json = serde_json::to_vec(data)?; - proxmox::tools::fs::replace_file(CONF_FILE, &json, options) + 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, + }) + } } -pub fn read_lock() -> Result { - proxmox::tools::fs::open_file_locked(LOCK_FILE, LOCK_TIMEOUT, 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), + ))?, + ) } -pub fn write_lock() -> Result { - proxmox::tools::fs::open_file_locked(LOCK_FILE, LOCK_TIMEOUT, true) +/// 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 = crate::config::tfa::write_lock(); + let _lock = write_lock(); let mut data = read()?; let entry = TfaEntry::new(description, value); let id = entry.info.id.clone(); @@ -510,10 +1123,14 @@ pub fn add_totp(userid: &Userid, description: String, value: Totp) -> Result Result, Error> { - let _lock = crate::config::tfa::write_lock(); + let _lock = write_lock(); let mut data = read()?; - let out = data.users.entry(userid.clone()).or_default().add_recovery()?; + let out = data + .users + .entry(userid.clone()) + .or_default() + .add_recovery()?; write(&data)?; Ok(out) } @@ -535,11 +1152,33 @@ pub fn finish_u2f_registration( ) -> Result { let _lock = crate::config::tfa::write_lock(); let mut data = read()?; - let challenge = data.u2f_registration_finish(userid, challenge, response)?; + 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, @@ -584,8 +1223,18 @@ impl Default for RecoveryState { } } +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 u2f devices are available, a u2f challenge will be included. +/// 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 { @@ -599,6 +1248,11 @@ pub struct TfaChallenge { /// 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. @@ -615,6 +1269,7 @@ pub struct U2fChallenge { pub enum TfaResponse { Totp(String), U2f(Value), + Webauthn(Value), Recovery(String), } @@ -626,6 +1281,8 @@ impl std::str::FromStr for TfaResponse { 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 { diff --git a/src/server.rs b/src/server.rs index 983a300d..7c159c23 100644 --- a/src/server.rs +++ b/src/server.rs @@ -87,3 +87,5 @@ pub use email_notifications::*; mod report; pub use report::*; + +pub mod ticket; diff --git a/src/server/rest.rs b/src/server/rest.rs index c1c4fd55..307f888e 100644 --- a/src/server/rest.rs +++ b/src/server/rest.rs @@ -600,8 +600,9 @@ fn check_auth( let ticket = user_auth_data.ticket.clone(); let ticket_lifetime = tools::ticket::TICKET_LIFETIME; - let userid: Userid = Ticket::parse(&ticket)? - .verify_with_time_frame(public_auth_key(), "PBS", None, -300..ticket_lifetime)?; + let userid: Userid = Ticket::::parse(&ticket)? + .verify_with_time_frame(public_auth_key(), "PBS", None, -300..ticket_lifetime)? + .require_full()?; let auth_id = Authid::from(userid.clone()); if !user_info.is_active_auth_id(&auth_id) { diff --git a/src/server/ticket.rs b/src/server/ticket.rs new file mode 100644 index 00000000..0142a03a --- /dev/null +++ b/src/server/ticket.rs @@ -0,0 +1,77 @@ +use std::fmt; + +use anyhow::{bail, Error}; +use serde::{Deserialize, Serialize}; + +use crate::api2::types::Userid; +use crate::config::tfa; + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PartialTicket { + #[serde(rename = "u")] + userid: Userid, + + #[serde(rename = "c")] + challenge: tfa::TfaChallenge, +} + +/// A new ticket struct used in rest.rs's `check_auth` - mostly for better errors than failing to +/// parse the userid ticket content. +pub enum ApiTicket { + Full(Userid), + Partial(tfa::TfaChallenge), +} + +impl ApiTicket { + /// Require the ticket to be a full ticket, otherwise error with a meaningful error message. + pub fn require_full(self) -> Result { + match self { + ApiTicket::Full(userid) => Ok(userid), + ApiTicket::Partial(_) => bail!("access denied - second login factor required"), + } + } + + /// Expect the ticket to contain a tfa challenge, otherwise error with a meaningful error + /// message. + pub fn require_partial(self) -> Result { + match self { + ApiTicket::Full(_) => bail!("invalid tfa challenge"), + ApiTicket::Partial(challenge) => Ok(challenge), + } + } + + /// Create a new full ticket. + pub fn full(userid: Userid) -> Self { + ApiTicket::Full(userid) + } + + /// Create a new partial ticket. + pub fn partial(challenge: tfa::TfaChallenge) -> Self { + ApiTicket::Partial(challenge) + } +} + +impl fmt::Display for ApiTicket { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ApiTicket::Full(userid) => fmt::Display::fmt(userid, f), + ApiTicket::Partial(partial) => { + let data = serde_json::to_string(partial).map_err(|_| fmt::Error)?; + write!(f, "!tfa!{}", data) + } + } + } +} + +impl std::str::FromStr for ApiTicket { + type Err = Error; + + fn from_str(s: &str) -> Result { + if s.starts_with("!tfa!") { + Ok(ApiTicket::Partial(serde_json::from_str(&s[5..])?)) + } else { + Ok(ApiTicket::Full(s.parse()?)) + } + } +}