diff --git a/Cargo.toml b/Cargo.toml index 3cee51cd..b1d936e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "proxmox-api-macro", "proxmox-async", + "proxmox-auth-api", "proxmox-borrow", "proxmox-compression", "proxmox-http", @@ -56,6 +57,8 @@ native-tls = "0.2" nix = "0.26.1" once_cell = "1.3.1" openssl = "0.10" +pam = "0.7" +pam-sys = "0.5" percent-encoding = "2.1" pin-utils = "0.1.0" proc-macro2 = "1.0" @@ -82,10 +85,12 @@ proxmox-compression = { version = "0.1.1", path = "proxmox-compression" } proxmox-http = { version = "0.8.0", path = "proxmox-http" } proxmox-io = { version = "1.0.0", path = "proxmox-io" } proxmox-lang = { version = "1.1", path = "proxmox-lang" } +proxmox-rest-server = { version = "0.3.0", path = "proxmox-rest-server" } proxmox-router = { version = "1.3.1", path = "proxmox-router" } proxmox-schema = { version = "1.3.6", path = "proxmox-schema" } proxmox-serde = { version = "0.1.1", path = "proxmox-serde", features = [ "serde_json" ] } proxmox-sortable-macro = { version = "0.1.2", path = "proxmox-sortable-macro" } proxmox-sys = { version = "0.4.2", path = "proxmox-sys" } +proxmox-tfa = { version = "2.1.0", path = "proxmox-tfa" } proxmox-time = { version = "1.1.4", path = "proxmox-time" } proxmox-uuid = { version = "1.0.1", path = "proxmox-uuid" } diff --git a/proxmox-auth-api/Cargo.toml b/proxmox-auth-api/Cargo.toml new file mode 100644 index 00000000..773ec3cf --- /dev/null +++ b/proxmox-auth-api/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "proxmox-auth-api" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +exclude.workspace = true +description = "Tickets, API and Realm handling" + +[dependencies] +anyhow.workspace = true + +base64 = { workspace = true, optional = true } +lazy_static = { workspace = true, optional = true } +libc = { workspace = true, optional = true } +log = { workspace = true, optional = true } +http = { workspace = true, optional = true } +openssl = { workspace = true, optional = true } +pam = { workspace = true, optional = true } +pam-sys = { workspace = true, optional = true } +percent-encoding = { workspace = true, optional = true } +regex = { workspace = true, optional = true } +serde = { workspace = true, optional = true, features = [ "derive" ] } +serde_json = { workspace = true, optional = true } +serde_plain = { workspace = true, optional = true } + +proxmox-rest-server = { workspace = true, optional = true } +proxmox-router = { workspace = true, optional = true } +proxmox-schema = { workspace = true, optional = true, features = [ "api-macro", "api-types" ] } +proxmox-tfa = { workspace = true, optional = true, features = [ "api" ] } + +[features] +default = [] + +ticket = [ "dep:base64", "dep:percent-encoding", "dep:openssl" ] +api-types = [ "dep:lazy_static", "dep:regex", "dep:serde", "dep:serde_plain", "dep:proxmox-schema" ] +api = [ + "api-types", + "ticket", + + "dep:http", + "dep:serde_json", + + "dep:proxmox-rest-server", + "dep:proxmox-router", + "dep:proxmox-tfa", +] +pam-authenticator = [ "api", "dep:libc", "dep:log", "dep:pam", "dep:pam-sys" ] diff --git a/proxmox-auth-api/examples/passwd.rs b/proxmox-auth-api/examples/passwd.rs new file mode 100644 index 00000000..2fd68bd5 --- /dev/null +++ b/proxmox-auth-api/examples/passwd.rs @@ -0,0 +1,96 @@ +//! Test the `Pam` authenticator's 'store_password' implementation. + +use std::future::Future; +use std::io::Write; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use anyhow::{bail, format_err, Error}; + +use proxmox_auth_api::api::Authenticator; +use proxmox_auth_api::types::Username; + +static LOG: PrintLog = PrintLog; + +fn main() -> Result<(), Error> { + poll_result_once(run()) +} + +async fn run() -> Result<(), Error> { + log::set_logger(&LOG).unwrap(); + log::set_max_level(log::LevelFilter::Debug); + + let mut args = std::env::args().skip(1); + let (username, changepass): (Username, bool) = match args.next() { + None => bail!("missing username or --check parameter"), + Some(ck) if ck == "--check" => ( + args.next() + .ok_or_else(|| format_err!("expected username as paramter"))? + .try_into()?, + false, + ), + Some(username) => (username.try_into()?, true), + }; + + let mut stdout = std::io::stdout(); + stdout.write_all(b"New password: ")?; + stdout.flush()?; + + let mut input = std::io::stdin().lines(); + let password = input + .next() + .ok_or_else(|| format_err!("failed to read new password"))??; + + let realm = proxmox_auth_api::Pam::new("test"); + if changepass { + realm.store_password(&username, &password)?; + } else { + realm.authenticate_user(&username, &password).await?; + } + + Ok(()) +} + +struct PrintLog; + +impl log::Log for PrintLog { + fn enabled(&self, _metadata: &log::Metadata<'_>) -> bool { + true + } + + fn flush(&self) { + let _ = std::io::stdout().flush(); + } + + fn log(&self, record: &log::Record<'_>) { + let _ = writeln!(std::io::stdout(), "{}", record.args()); + } +} + +pub fn poll_result_once(mut fut: T) -> Result +where + T: Future>, +{ + let waker = std::task::RawWaker::new(std::ptr::null(), &WAKER_VTABLE); + let waker = unsafe { std::task::Waker::from_raw(waker) }; + let mut cx = Context::from_waker(&waker); + unsafe { + match Pin::new_unchecked(&mut fut).poll(&mut cx) { + Poll::Pending => bail!("got Poll::Pending synchronous context"), + Poll::Ready(r) => r, + } + } +} + +const WAKER_VTABLE: std::task::RawWakerVTable = + std::task::RawWakerVTable::new(forbid_clone, forbid_wake, forbid_wake, ignore_drop); + +unsafe fn forbid_clone(_: *const ()) -> std::task::RawWaker { + panic!("tried to clone waker for synchronous task"); +} + +unsafe fn forbid_wake(_: *const ()) { + panic!("tried to wake synchronous task"); +} + +unsafe fn ignore_drop(_: *const ()) {} diff --git a/proxmox-auth-api/src/api/access.rs b/proxmox-auth-api/src/api/access.rs new file mode 100644 index 00000000..f79332ab --- /dev/null +++ b/proxmox-auth-api/src/api/access.rs @@ -0,0 +1,298 @@ +//! Provides the "/access/ticket" API call. + +use anyhow::{bail, format_err, Error}; +use serde_json::{json, Value}; + +use proxmox_router::{http_err, Permission, RpcEnvironment}; +use proxmox_schema::{api, api_types::PASSWORD_SCHEMA}; +use proxmox_tfa::api::TfaChallenge; + +use super::auth_context; +use super::ApiTicket; +use crate::ticket::Ticket; +use crate::types::{Authid, Userid}; + +#[allow(clippy::large_enum_variant)] +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(Box), +} + +#[api( + input: { + properties: { + username: { + type: Userid, + }, + password: { + schema: PASSWORD_SCHEMA, + }, + path: { + type: String, + description: "Path for verifying terminal tickets.", + optional: true, + }, + privs: { + type: String, + description: "Privilege for verifying terminal tickets.", + optional: true, + }, + port: { + type: Integer, + 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: { + properties: { + username: { + type: String, + description: "User name.", + }, + ticket: { + type: String, + description: "Auth ticket.", + }, + CSRFPreventionToken: { + type: String, + description: + "Cross Site Request Forgery Prevention Token. \ + For partial tickets this is the string \"invalid\".", + }, + }, + }, + protected: true, + access: { + permission: &Permission::World, + }, +)] +/// Create or verify authentication ticket. +/// +/// Returns: An authentication ticket with additional infos. +pub async fn create_ticket( + username: Userid, + password: String, + path: Option, + privs: Option, + port: Option, + tfa_challenge: Option, + rpcenv: &mut dyn RpcEnvironment, +) -> Result { + use proxmox_rest_server::RestEnvironment; + + let env: &RestEnvironment = rpcenv + .as_any() + .downcast_ref::() + .ok_or_else(|| format_err!("detected wrong RpcEnvironment type"))?; + + match authenticate_user(&username, &password, path, privs, port, tfa_challenge).await { + Ok(AuthResult::Success) => Ok(json!({ "username": username })), + Ok(AuthResult::CreateTicket) => { + let auth_context = auth_context()?; + let api_ticket = ApiTicket::Full(username.clone()); + let ticket = Ticket::new(auth_context.auth_prefix(), &api_ticket)? + .sign(auth_context.keyring(), None)?; + let token = assemble_csrf_prevention_token(auth_context.csrf_secret(), &username); + + env.log_auth(username.as_str()); + + Ok(json!({ + "username": username, + "ticket": ticket, + "CSRFPreventionToken": token, + })) + } + Ok(AuthResult::Partial(challenge)) => { + let auth_context = auth_context()?; + let api_ticket = ApiTicket::Partial(challenge); + let ticket = Ticket::new(auth_context.auth_prefix(), &api_ticket)? + .sign(auth_context.keyring(), Some(username.as_str()))?; + Ok(json!({ + "username": username, + "ticket": ticket, + "CSRFPreventionToken": "invalid", + })) + } + Err(err) => { + env.log_failed_auth(Some(username.to_string()), &err.to_string()); + Err(http_err!(UNAUTHORIZED, "permission check failed.")) + } + } +} + +async fn authenticate_user( + userid: &Userid, + password: &str, + path: Option, + privs: Option, + port: Option, + tfa_challenge: Option, +) -> Result { + let auth_context = auth_context()?; + let prefix = auth_context.auth_prefix(); + + let auth_id = Authid::from(userid.clone()); + if !auth_context.auth_id_is_active(&auth_id)? { + bail!("user account disabled or expired."); + } + + if let Some(tfa_challenge) = tfa_challenge { + return authenticate_2nd(userid, &tfa_challenge, password); + } + + if password.starts_with(prefix) && password.as_bytes().get(prefix.len()).copied() == Some(b':') + { + if let Ok(ticket_userid) = Ticket::::parse(password) + .and_then(|ticket| ticket.verify(auth_context.keyring(), prefix, None)) + { + if *userid == ticket_userid { + return Ok(AuthResult::CreateTicket); + } + bail!("ticket login failed - wrong userid"); + } + } else if let Some(((path, privs), port)) = path.zip(privs).zip(port) { + match auth_context.check_path_ticket(userid, password, path, privs, port)? { + None => (), // no path based tickets supported, just fall through. + Some(true) => return Ok(AuthResult::Success), + Some(false) => bail!("No such privilege"), + } + } + + #[allow(clippy::let_unit_value)] + { + let _: () = auth_context + .lookup_realm(userid.realm()) + .ok_or_else(|| format_err!("unknown realm {:?}", userid.realm().as_str()))? + .authenticate_user(userid.name(), password) + .await?; + } + + Ok(match login_challenge(userid)? { + None => AuthResult::CreateTicket, + Some(challenge) => AuthResult::Partial(Box::new(challenge)), + }) +} + +fn authenticate_2nd( + userid: &Userid, + challenge_ticket: &str, + response: &str, +) -> Result { + let auth_context = auth_context()?; + let challenge: Box = Ticket::::parse(challenge_ticket)? + .verify_with_time_frame( + auth_context.keyring(), + auth_context.auth_prefix(), + Some(userid.as_str()), + -60..600, + )? + .require_partial()?; + + #[allow(clippy::let_unit_value)] + { + let mut tfa_config_lock = auth_context.tfa_config_write_lock()?; + let (locked_config, tfa_config) = tfa_config_lock.config_mut(); + if tfa_config + .verify( + locked_config, + userid.as_str(), + &challenge, + response.parse()?, + None, + )? + .needs_saving() + { + tfa_config_lock.save_config()?; + } + } + + Ok(AuthResult::CreateTicket) +} + +fn login_challenge(userid: &Userid) -> Result, Error> { + let auth_context = auth_context()?; + let mut tfa_config_lock = auth_context.tfa_config_write_lock()?; + let (locked_config, tfa_config) = tfa_config_lock.config_mut(); + tfa_config.authentication_challenge(locked_config, userid.as_str(), None) +} + +fn assemble_csrf_prevention_token(secret: &[u8], userid: &Userid) -> String { + let epoch = crate::time::epoch_i64(); + + let digest = compute_csrf_secret_digest(epoch, secret, userid); + + format!("{:08X}:{}", epoch, digest) +} + +fn compute_csrf_secret_digest(timestamp: i64, secret: &[u8], userid: &Userid) -> String { + let mut hasher = openssl::sha::Sha256::new(); + let data = format!("{:08X}:{}:", timestamp, userid); + hasher.update(data.as_bytes()); + hasher.update(secret); + + base64::encode_config(hasher.finish(), base64::STANDARD_NO_PAD) +} + +pub(crate) fn verify_csrf_prevention_token( + secret: &[u8], + userid: &Userid, + token: &str, + min_age: i64, + max_age: i64, +) -> Result { + verify_csrf_prevention_token_do(secret, userid, token, min_age, max_age) + .map_err(|err| format_err!("invalid csrf token - {}", err)) +} + +fn verify_csrf_prevention_token_do( + secret: &[u8], + userid: &Userid, + token: &str, + min_age: i64, + max_age: i64, +) -> Result { + use std::collections::VecDeque; + + let mut parts: VecDeque<&str> = token.split(':').collect(); + + if parts.len() != 2 { + bail!("format error - wrong number of parts."); + } + + let timestamp = parts.pop_front().unwrap(); + let sig = parts.pop_front().unwrap(); + + let ttime = i64::from_str_radix(timestamp, 16) + .map_err(|err| format_err!("timestamp format error - {}", err))?; + + let digest = compute_csrf_secret_digest(ttime, secret, userid); + + if digest != sig { + bail!("invalid signature."); + } + + let now = crate::time::epoch_i64(); + + let age = now - ttime; + if age < min_age { + bail!("timestamp newer than expected."); + } + + if age > max_age { + bail!("timestamp too old."); + } + + Ok(age) +} diff --git a/proxmox-auth-api/src/api/mod.rs b/proxmox-auth-api/src/api/mod.rs new file mode 100644 index 00000000..fbcf69c7 --- /dev/null +++ b/proxmox-auth-api/src/api/mod.rs @@ -0,0 +1,220 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Mutex; + +use anyhow::{format_err, Error}; +use percent_encoding::percent_decode_str; + +use proxmox_rest_server::{extract_cookie, AuthError}; +use proxmox_tfa::api::{OpenUserChallengeData, TfaConfig}; + +use crate::auth_key::Keyring; +use crate::types::{Authid, RealmRef, Userid, UsernameRef}; + +mod access; +mod ticket; + +use crate::ticket::Ticket; +use access::verify_csrf_prevention_token; + +pub use access::{create_ticket, API_METHOD_CREATE_TICKET}; +pub use ticket::{ApiTicket, PartialTicket}; + +/// Authentication realms are used to manage users: authenticate, change password or remove. +pub trait Authenticator { + /// Authenticate a user given a password. + fn authenticate_user<'a>( + &'a self, + username: &'a UsernameRef, + password: &'a str, + ) -> Pin> + Send + 'a>>; + + /// Change a user's password. + fn store_password(&self, username: &UsernameRef, password: &str) -> Result<(), Error>; + + /// Remove a user. + fn remove_password(&self, username: &UsernameRef) -> Result<(), Error>; +} + +/// This provides access to the available realms and authentication keys. +pub trait AuthContext: Send + Sync { + /// Lookup a realm by name. + fn lookup_realm(&self, realm: &RealmRef) -> Option>; + + /// Get the current authentication keyring. + fn keyring(&self) -> &Keyring; + + /// The auth prefix without the separating colon. Eg. `"PBS"`. + fn auth_prefix(&self) -> &'static str; + + /// API token prefix (without the `'='`). + fn auth_token_prefix(&self) -> &'static str; + + /// Auth cookie name. + fn auth_cookie_name(&self) -> &'static str; + + /// Access the TFA config with an exclusive lock. + fn tfa_config_write_lock(&self) -> Result, Error>; + + /// Check if a userid is enabled and return a [`UserInformation`] handle. + fn auth_id_is_active(&self, auth_id: &Authid) -> Result; + + /// CSRF prevention token secret data. + fn csrf_secret(&self) -> &[u8]; + + /// Verify a token secret. + fn verify_token_secret(&self, token_id: &Authid, token_secret: &str) -> Result<(), Error>; + + /// Check path based tickets. (Used for terminal tickets). + fn check_path_ticket( + &self, + userid: &Userid, + password: &str, + path: String, + privs: String, + port: u16, + ) -> Result, Error> { + let _ = (userid, password, path, privs, port); + Ok(None) + } +} + +/// When verifying TFA challenges we need to be able to update the TFA config without interference +/// from other threads. Similarly, to authenticate with recovery keys, we need to be able to +/// atomically mark them as used. +pub trait LockedTfaConfig { + /// Get mutable access to the [`TfaConfig`] and retain immutable access to `self`. + fn config_mut(&mut self) -> (&dyn OpenUserChallengeData, &mut TfaConfig); + + // Save the modified [`TfaConfig`]. + // + // The config will have been modified by accessing the + // [`config_mut`](LockedTfaConfig::config_mut()) method. + fn save_config(&mut self) -> Result<(), Error>; +} + +static AUTH_CONTEXT: Mutex> = Mutex::new(None); + +/// Configure access to authentication realms and keys. +pub fn set_auth_context(auth_context: &'static dyn AuthContext) { + *AUTH_CONTEXT.lock().unwrap() = Some(auth_context); +} + +fn auth_context() -> Result<&'static dyn AuthContext, Error> { + AUTH_CONTEXT + .lock() + .unwrap() + .clone() + .ok_or_else(|| format_err!("no realm access configured")) +} + +struct UserAuthData { + ticket: String, + csrf_token: Option, +} + +enum AuthData { + User(UserAuthData), + ApiToken(String), +} + +pub fn http_check_auth( + headers: &http::HeaderMap, + method: &http::Method, +) -> Result { + let auth_context = auth_context()?; + + let auth_data = extract_auth_data(auth_context, headers); + match auth_data { + Some(AuthData::User(user_auth_data)) => { + let ticket = user_auth_data.ticket.clone(); + let ticket_lifetime = crate::TICKET_LIFETIME; + + let userid: Userid = Ticket::::parse(&ticket)? + .verify_with_time_frame( + auth_context.keyring(), + auth_context.auth_prefix(), + None, + -300..ticket_lifetime, + )? + .require_full()?; + + let auth_id = Authid::from(userid.clone()); + if !auth_context.auth_id_is_active(&auth_id)? { + return Err(format_err!("user account disabled or expired.").into()); + } + + if method != http::Method::GET { + if let Some(csrf_token) = &user_auth_data.csrf_token { + verify_csrf_prevention_token( + auth_context.csrf_secret(), + &userid, + csrf_token, + -300, + ticket_lifetime, + )?; + } else { + return Err(format_err!("missing CSRF prevention token").into()); + } + } + + Ok(auth_id.to_string()) + } + Some(AuthData::ApiToken(api_token)) => { + let mut parts = api_token.splitn(2, ':'); + let tokenid = parts + .next() + .ok_or_else(|| format_err!("failed to split API token header"))?; + let tokenid: Authid = tokenid.parse()?; + + if !auth_context.auth_id_is_active(&tokenid)? { + return Err(format_err!("user account or token disabled or expired.").into()); + } + + let tokensecret = parts + .next() + .ok_or_else(|| format_err!("failed to split API token header"))?; + let tokensecret = percent_decode_str(tokensecret) + .decode_utf8() + .map_err(|_| format_err!("failed to decode API token header"))?; + + auth_context.verify_token_secret(&tokenid, &tokensecret)?; + + Ok(tokenid.to_string()) + } + None => Err(AuthError::NoData), + } +} + +fn extract_auth_data( + auth_context: &dyn AuthContext, + headers: &http::HeaderMap, +) -> Option { + if let Some(raw_cookie) = headers.get(http::header::COOKIE) { + if let Ok(cookie) = raw_cookie.to_str() { + if let Some(ticket) = extract_cookie(cookie, auth_context.auth_cookie_name()) { + let csrf_token = match headers.get("CSRFPreventionToken").map(|v| v.to_str()) { + Some(Ok(v)) => Some(v.to_owned()), + _ => None, + }; + return Some(AuthData::User(UserAuthData { ticket, csrf_token })); + } + } + } + + let token_prefix = auth_context.auth_token_prefix(); + match headers.get(http::header::AUTHORIZATION).map(|v| v.to_str()) { + Some(Ok(v)) => { + if !v.starts_with(token_prefix) { + return None; + } + match v.as_bytes().get(token_prefix.len()).copied() { + Some(b' ') | Some(b'=') => { + Some(AuthData::ApiToken(v[(token_prefix.len() + 1)..].to_owned())) + } + _ => None, + } + } + _ => None, + } +} diff --git a/proxmox-auth-api/src/api/ticket.rs b/proxmox-auth-api/src/api/ticket.rs new file mode 100644 index 00000000..f2d1580b --- /dev/null +++ b/proxmox-auth-api/src/api/ticket.rs @@ -0,0 +1,70 @@ +//! API side ticket utility. + +use std::fmt; + +use anyhow::{bail, Error}; +use serde::{Deserialize, Serialize}; + +use proxmox_tfa::api::TfaChallenge; + +use crate::types::Userid; + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PartialTicket { + #[serde(rename = "u")] + pub userid: Userid, + + #[serde(rename = "c")] + pub challenge: TfaChallenge, +} + +/// A new ticket struct used in `check_auth` - mostly for better errors than failing to parse the +/// userid ticket content. +pub enum ApiTicket { + Full(Userid), + Partial(Box), +} + +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, Error> { + match self { + ApiTicket::Full(_) => bail!("invalid tfa challenge"), + ApiTicket::Partial(challenge) => Ok(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 let Some(tfa_ticket) = s.strip_prefix("!tfa!") { + Ok(ApiTicket::Partial(serde_json::from_str(tfa_ticket)?)) + } else { + Ok(ApiTicket::Full(s.parse()?)) + } + } +} diff --git a/proxmox-auth-api/src/auth_key.rs b/proxmox-auth-api/src/auth_key.rs new file mode 100644 index 00000000..cec73606 --- /dev/null +++ b/proxmox-auth-api/src/auth_key.rs @@ -0,0 +1,218 @@ +//! Auth key handling. + +use anyhow::{bail, format_err, Error}; +use openssl::ec::{EcGroup, EcKey}; +use openssl::hash::MessageDigest; +use openssl::nid::Nid; +use openssl::pkey::{HasPublic, PKey, PKeyRef, Private, Public}; +use openssl::rsa::Rsa; +use openssl::sign::{Signer, Verifier}; + +/// A private auth key used for API ticket signing and verification. +pub struct PrivateKey { + pub(crate) key: PKey, +} + +/// A private auth key used for API ticket verification. +pub struct PublicKey { + pub(crate) key: PKey, +} + +impl PrivateKey { + /// Generate a new RSA auth key. + pub fn generate_rsa() -> Result { + let rsa = + Rsa::generate(4096).map_err(|err| format_err!("failed to generate rsa key - {err}"))?; + Ok(Self { + key: PKey::from_rsa(rsa) + .map_err(|err| format_err!("failed to get PKey for rsa key - {err}"))?, + }) + } + + /// Generate a new EC auth key. + pub fn generate_ec() -> Result { + let nid = Nid::X9_62_PRIME256V1; + let group = EcGroup::from_curve_name(nid) + .map_err(|err| format_err!("failed to get P-256 group - {err}"))?; + let ec = EcKey::generate(&group) + .map_err(|err| format_err!("failed to generate EC key for testing - {err}"))?; + Ok(Self { + key: PKey::from_ec_key(ec) + .map_err(|err| format_err!("failed to get PKey for EC key - {err}"))?, + }) + } + + pub fn from_pem(data: &[u8]) -> Result { + let key = PKey::private_key_from_pem(data) + .map_err(|err| format_err!("failed to decode private key from PEM - {err}"))?; + Ok(Self { key }) + } + + /// Get the PEM formatted private key *unencrypted*. + pub fn private_key_to_pem(&self) -> Result, Error> { + // No PKCS#8 for legacy reasons: + if let Ok(rsa) = self.key.rsa() { + return rsa + .private_key_to_pem() + .map_err(|err| format_err!("failed to encode rsa private key as PEM - {err}")); + } + + if let Ok(ec) = self.key.ec_key() { + return ec + .private_key_to_pem() + .map_err(|err| format_err!("failed to encode ec private key as PEM - {err}")); + } + + bail!("unexpected key data") + } + + /// Get the PEM formatted public key. + pub fn public_key_to_pem(&self) -> Result, Error> { + // No PKCS#8 for legacy reasons: + if let Ok(rsa) = self.key.rsa() { + return rsa + .public_key_to_pem() + .map_err(|err| format_err!("failed to encode rsa public key as PEM - {err}")); + } + + if let Ok(ec) = self.key.ec_key() { + return ec + .public_key_to_pem() + .map_err(|err| format_err!("failed to encode ec public key as PEM - {err}")); + } + + bail!("unexpected key data") + } + + /// Get the public key. + pub fn public_key(&self) -> Result { + PublicKey::from_pem(&self.public_key_to_pem()?) + } +} + +impl From> for PrivateKey { + fn from(key: PKey) -> Self { + Self { key } + } +} + +impl PublicKey { + pub fn from_pem(data: &[u8]) -> Result { + let key = PKey::public_key_from_pem(data) + .map_err(|err| format_err!("failed to decode public key from PEM - {err}"))?; + Ok(Self { key }) + } + + /// Get the PEM formatted public key. + pub fn public_key_to_pem(&self) -> Result, Error> { + // No PKCS#8 for legacy reasons: + if let Ok(rsa) = self.key.rsa() { + return rsa + .public_key_to_pem() + .map_err(|err| format_err!("failed to encode rsa public key as PEM - {err}")); + } + + if let Ok(ec) = self.key.ec_key() { + return ec + .public_key_to_pem() + .map_err(|err| format_err!("failed to encode ec public key as PEM - {err}")); + } + + bail!("unexpected key data") + } +} + +impl From> for PublicKey { + fn from(key: PKey) -> Self { + Self { key } + } +} + +/// A key ring for authentication. +/// +/// This holds one active signing key for new tickets, and optionally multiple public keys for +/// verifying them in order to support key rollover. +pub struct Keyring { + signing_key: Option, + public_keys: Vec, +} + +impl Keyring { + pub fn generate_new_rsa() -> Result { + PrivateKey::generate_rsa().map(Self::with_private_key) + } + + pub fn generate_new_ec() -> Result { + PrivateKey::generate_ec().map(Self::with_private_key) + } + + pub fn new() -> Self { + Self { + signing_key: None, + public_keys: Vec::new(), + } + } + + pub fn with_public_key(key: PublicKey) -> Self { + Self { + signing_key: None, + public_keys: vec![key], + } + } + + pub fn with_private_key(key: PrivateKey) -> Self { + Self { + signing_key: Some(key), + public_keys: Vec::new(), + } + } + + pub fn add_public_key(&mut self, key: PublicKey) { + self.public_keys.push(key); + } + + pub fn verify( + &self, + digest: MessageDigest, + signature: &[u8], + data: &[u8], + ) -> Result { + fn verify_with( + key: &PKeyRef

, + digest: MessageDigest, + signature: &[u8], + data: &[u8], + ) -> Result { + Verifier::new(digest, key) + .map_err(|err| format_err!("failed to create openssl verifier - {err}"))? + .verify_oneshot(signature, data) + .map_err(|err| format_err!("openssl error verifying data - {err}")) + } + + if let Some(key) = &self.signing_key { + if verify_with(&key.key, digest, signature, data)? { + return Ok(true); + } + } + + for key in &self.public_keys { + if verify_with(&key.key, digest, signature, data)? { + return Ok(true); + } + } + + Ok(false) + } + + pub(crate) fn signer(&self, digest: MessageDigest) -> Result { + Signer::new( + digest, + &self + .signing_key + .as_ref() + .ok_or_else(|| format_err!("no private key available for signing"))? + .key, + ) + .map_err(|err| format_err!("failed to create openssl signer - {err}")) + } +} diff --git a/proxmox-auth-api/src/lib.rs b/proxmox-auth-api/src/lib.rs new file mode 100644 index 00000000..d371b963 --- /dev/null +++ b/proxmox-auth-api/src/lib.rs @@ -0,0 +1,36 @@ +//! Authentication API crate. +//! +//! This contains the API types for `Userid`/`Realm`/`Authid` etc., the PAM authenticator and the +//! authentication API calls. +//! +//! Each can be enabled via a feature: +//! +//! The `pam-authenticator` feature enables the `Pam` type. + +pub const TICKET_LIFETIME: i64 = 3600 * 2; // 2 hours + +#[cfg(feature = "ticket")] +mod time; + +#[cfg(feature = "api")] +pub mod api; + +#[cfg(feature = "api")] +pub use api::set_auth_context; + +#[cfg(any(feature = "api", feature = "ticket"))] +mod auth_key; + +#[cfg(any(feature = "api", feature = "ticket"))] +pub use auth_key::{Keyring, PrivateKey, PublicKey}; + +#[cfg(feature = "ticket")] +pub mod ticket; + +#[cfg(feature = "api-types")] +pub mod types; + +#[cfg(feature = "pam-authenticator")] +mod pam_authenticator; +#[cfg(feature = "pam-authenticator")] +pub use pam_authenticator::Pam; diff --git a/proxmox-auth-api/src/pam_authenticator.rs b/proxmox-auth-api/src/pam_authenticator.rs new file mode 100644 index 00000000..6e2ce1d2 --- /dev/null +++ b/proxmox-auth-api/src/pam_authenticator.rs @@ -0,0 +1,193 @@ +use std::ffi::{c_int, c_void, CStr}; +use std::future::Future; +use std::pin::Pin; + +use anyhow::{bail, Error}; +use pam_sys::types::{PamHandle, PamMessage, PamMessageStyle, PamResponse, PamReturnCode}; + +use crate::types::UsernameRef; + +#[allow(clippy::upper_case_acronyms)] +pub struct Pam { + service: &'static str, +} + +impl Pam { + pub const fn new(service: &'static str) -> Self { + Self { service } + } +} + +impl crate::api::Authenticator for Pam { + fn authenticate_user<'a>( + &'a self, + username: &'a UsernameRef, + password: &'a str, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let mut auth = pam::Authenticator::with_password(self.service).unwrap(); + auth.get_handler() + .set_credentials(username.as_str(), password); + auth.authenticate()?; + Ok(()) + }) + } + + fn store_password(&self, username: &UsernameRef, password: &str) -> Result<(), Error> { + let mut password_conv = PasswordConv { + login: username.as_str(), + password, + }; + + let conv = pam_sys::types::PamConversation { + conv: Some(conv_fn), + data_ptr: &mut password_conv as *mut _ as *mut c_void, + }; + + let mut handle = std::ptr::null_mut(); + let err = + pam_sys::wrapped::start(self.service, Some(username.as_str()), &conv, &mut handle); + if err != PamReturnCode::SUCCESS { + bail!("error opening pam - {err}"); + } + let mut handle = PamGuard { + handle: unsafe { &mut *handle }, + result: PamReturnCode::SUCCESS, + }; + + /* + * we assume we're root and don't need to authenticate + handle.result = + pam_sys::wrapped::authenticate(handle.handle, pam_sys::types::PamFlag::NONE); + if handle.result != PamReturnCode::SUCCESS { + bail!("authentication error - {err}"); + } + + handle.result = pam_sys::wrapped::acct_mgmt(handle.handle, pam_sys::types::PamFlag::NONE); + if handle.result != PamReturnCode::SUCCESS { + bail!("account error - {}", handle.result); + } + */ + + handle.result = pam_sys::wrapped::chauthtok(handle.handle, pam_sys::types::PamFlag::NONE); + if handle.result != PamReturnCode::SUCCESS { + bail!("error changing auth token - {}", handle.result); + } + + Ok(()) + } + + // do not remove password for pam users + fn remove_password(&self, _username: &UsernameRef) -> Result<(), Error> { + Ok(()) + } +} + +extern "C" fn conv_fn( + num_messages: c_int, + messages: *mut *mut PamMessage, + responses_out: *mut *mut PamResponse, + data_ptr: *mut c_void, +) -> c_int { + let messages: &[&PamMessage] = unsafe { + std::slice::from_raw_parts( + messages as *const *const PamMessage as *const &PamMessage, + num_messages as usize, + ) + }; + + let mut responses = Vec::new(); + responses.resize( + messages.len(), + PamResponse { + resp: std::ptr::null_mut(), + resp_retcode: 0, + }, + ); + let mut responses = responses.into_boxed_slice(); + + let data_ptr = unsafe { &*(data_ptr as *const PasswordConv<'_>) }; + + match data_ptr.converse(messages, &mut responses) { + Ok(()) => { + unsafe { + std::ptr::write(responses_out, &mut Box::leak(responses)[0]); + } + PamReturnCode::SUCCESS as c_int + } + Err(err) => { + log::error!("error conversing with pam - {err}"); + PamReturnCode::ABORT as c_int + } + } +} + +struct PamGuard<'a> { + handle: &'a mut PamHandle, + result: PamReturnCode, +} + +impl Drop for PamGuard<'_> { + fn drop(&mut self) { + pam_sys::wrapped::end(&mut self.handle, self.result); + } +} + +struct PasswordConv<'a> { + login: &'a str, + password: &'a str, +} + +impl PasswordConv<'_> { + fn converse( + &self, + messages: &[&PamMessage], + responses: &mut [PamResponse], + ) -> Result<(), Error> { + for i in 0..messages.len() { + self.msg(messages[i], &mut responses[i])?; + } + Ok(()) + } + + fn msg( + &self, + msg: &pam_sys::types::PamMessage, + response: &mut PamResponse, + ) -> Result<(), Error> { + let resp = match PamMessageStyle::from(msg.msg_style) { + PamMessageStyle::PROMPT_ECHO_ON => { + //let msg = unsafe { CStr::from_ptr(msg.msg) }; + //log::info!("pam prompt: {msg:?}"); + self.login + } + PamMessageStyle::PROMPT_ECHO_OFF => { + //let msg = unsafe { CStr::from_ptr(msg.msg) }; + //log::info!("pam password prompt: {msg:?}"); + self.password + } + PamMessageStyle::ERROR_MSG => { + let msg = unsafe { CStr::from_ptr(msg.msg) }; + log::error!("pam error: {msg:?}"); + return Ok(()); + } + PamMessageStyle::TEXT_INFO => { + let msg = unsafe { CStr::from_ptr(msg.msg) }; + log::info!("pam message: {msg:?}"); + return Ok(()); + } + }; + + // Since CString::into_raw is technically not `free()`-safe... + let resp = resp.as_bytes(); + let c_resp = unsafe { libc::malloc(resp.len() + 1) as *mut u8 }; + if c_resp.is_null() { + bail!("failed to allocate response"); + } + let c_resp = unsafe { std::slice::from_raw_parts_mut(c_resp, resp.len() + 1) }; + c_resp[c_resp.len() - 1] = 0; + c_resp[..resp.len()].copy_from_slice(resp); + response.resp = c_resp.as_mut_ptr() as *mut libc::c_char; + Ok(()) + } +} diff --git a/proxmox-auth-api/src/ticket.rs b/proxmox-auth-api/src/ticket.rs new file mode 100644 index 00000000..17379125 --- /dev/null +++ b/proxmox-auth-api/src/ticket.rs @@ -0,0 +1,334 @@ +//! Generate and verify Authentication tickets + +use std::borrow::Cow; +use std::marker::PhantomData; + +use anyhow::{bail, format_err, Error}; +use openssl::hash::MessageDigest; +use percent_encoding::{percent_decode_str, percent_encode, AsciiSet}; + +use crate::auth_key::Keyring; + +use crate::TICKET_LIFETIME; + +/// Stringified ticket data must not contain colons... +const TICKET_ASCIISET: &AsciiSet = &percent_encoding::CONTROLS.add(b':'); + +/// An empty type implementing [`ToString`] and [`FromStr`](std::str::FromStr), used for tickets +/// with no data. +pub struct Empty; + +impl ToString for Empty { + fn to_string(&self) -> String { + String::new() + } +} + +impl std::str::FromStr for Empty { + type Err = Error; + + fn from_str(s: &str) -> Result { + if !s.is_empty() { + bail!("unexpected ticket data, should be empty"); + } + Ok(Empty) + } +} + +/// An API ticket consists of a ticket type (prefix), type-dependent data, optional additional +/// authenticaztion data, a timestamp and a signature. We store these values in the form +/// `::::`. +/// +/// The signature is made over the string consisting of prefix, data, timestamp and aad joined +/// together by colons. If there is no additional authentication data it will be skipped together +/// with the colon separating it from the timestamp. +pub struct Ticket +where + T: ToString + std::str::FromStr, +{ + prefix: Cow<'static, str>, + data: String, + time: i64, + signature: Option>, + _type_marker: PhantomData T>, +} + +impl Ticket +where + T: ToString + std::str::FromStr, + ::Err: std::fmt::Debug, +{ + /// Prepare a new ticket for signing. + pub fn new(prefix: &'static str, data: &T) -> Result { + Ok(Self { + prefix: Cow::Borrowed(prefix), + data: data.to_string(), + time: crate::time::epoch_i64(), + signature: None, + _type_marker: PhantomData, + }) + } + + /// Get the ticket prefix. + pub fn prefix(&self) -> &str { + &self.prefix + } + + /// Get the ticket's time stamp in seconds since the unix epoch. + pub fn time(&self) -> i64 { + self.time + } + + /// Get the raw string data contained in the ticket. The `verify` method will call `parse()` + /// this in the end, so using this method directly is discouraged as it does not verify the + /// signature. + pub fn raw_data(&self) -> &str { + &self.data + } + + /// Serialize the ticket into a string. + fn ticket_data(&self) -> String { + format!( + "{}:{}:{:08X}", + percent_encode(self.prefix.as_bytes(), TICKET_ASCIISET), + percent_encode(self.data.as_bytes(), TICKET_ASCIISET), + self.time, + ) + } + + /// Serialize the verification data. + fn verification_data(&self, aad: Option<&str>) -> Vec { + let mut data = self.ticket_data().into_bytes(); + if let Some(aad) = aad { + data.push(b':'); + data.extend(aad.as_bytes()); + } + data + } + + /// Change the ticket's time, used mostly for testing. + #[cfg(test)] + fn change_time(&mut self, time: i64) -> &mut Self { + self.time = time; + self + } + + /// Sign the ticket. + pub fn sign(&mut self, keyring: &Keyring, aad: Option<&str>) -> Result { + let mut output = self.ticket_data(); + let mut signer = keyring.signer(MessageDigest::sha256())?; + + signer + .update(output.as_bytes()) + .map_err(Error::from) + .and_then(|()| { + if let Some(aad) = aad { + signer + .update(b":") + .and_then(|()| signer.update(aad.as_bytes())) + .map_err(Error::from) + } else { + Ok::<_, Error>(()) + } + }) + .map_err(|err| format_err!("error signing ticket: {}", err))?; + + let signature = signer + .sign_to_vec() + .map_err(|err| format_err!("error finishing ticket signature: {}", err))?; + + use std::fmt::Write; + write!( + &mut output, + "::{}", + base64::encode_config(&signature, base64::STANDARD_NO_PAD), + )?; + + self.signature = Some(signature); + + Ok(output) + } + + /// `verify` with an additional time frame parameter, not usually required since we always use + /// the same time frame. + pub fn verify_with_time_frame( + &self, + keyring: &Keyring, + prefix: &str, + aad: Option<&str>, + time_frame: std::ops::Range, + ) -> Result { + if self.prefix != prefix { + bail!("ticket with invalid prefix"); + } + + let signature = match self.signature.as_deref() { + Some(sig) => sig, + None => bail!("invalid ticket without signature"), + }; + + let age = crate::time::epoch_i64() - self.time; + if age < time_frame.start { + bail!("invalid ticket - timestamp newer than expected"); + } + if age > time_frame.end { + bail!("invalid ticket - expired"); + } + + let is_valid = keyring.verify( + MessageDigest::sha256(), + &signature, + &self.verification_data(aad), + )?; + + if !is_valid { + bail!("ticket with invalid signature"); + } + + self.data + .parse() + .map_err(|err| format_err!("failed to parse contained ticket data: {:?}", err)) + } + + /// Verify the ticket with the provided key pair. The additional authentication data needs to + /// match the one used when generating the ticket, and the ticket's age must fall into the time + /// frame. + pub fn verify(&self, keyring: &Keyring, prefix: &str, aad: Option<&str>) -> Result { + self.verify_with_time_frame(keyring, prefix, aad, -300..TICKET_LIFETIME) + } + + /// Parse a ticket string. + pub fn parse(ticket: &str) -> Result { + let mut parts = ticket.splitn(4, ':'); + + let prefix = percent_decode_str( + parts + .next() + .ok_or_else(|| format_err!("ticket without prefix"))?, + ) + .decode_utf8() + .map_err(|err| format_err!("invalid ticket, error decoding prefix: {}", err))?; + + let data = percent_decode_str( + parts + .next() + .ok_or_else(|| format_err!("ticket without data"))?, + ) + .decode_utf8() + .map_err(|err| format_err!("invalid ticket, error decoding data: {}", err))?; + + let time = i64::from_str_radix( + parts + .next() + .ok_or_else(|| format_err!("ticket without timestamp"))?, + 16, + ) + .map_err(|err| format_err!("ticket with bad timestamp: {}", err))?; + + let remainder = parts + .next() + .ok_or_else(|| format_err!("ticket without signature"))?; + // ::