From 3bbf663381973f3ac7fb71d88f6bad3d7f390a92 Mon Sep 17 00:00:00 2001 From: David Mulder Date: Mon, 9 Dec 2024 15:38:21 -0700 Subject: [PATCH] Implement Hello Pin changes via PAM This implements Pin changes via pam (a user will call the terminal `passwd` command). This will require more interaction than a typical call to `passwd`, since we need to authenitcate to Entra Id to register the new Pin. We could use either an existing Hello Key or the PRT to register the key, but not if we authenticated using a DAG (or SFA, for that matter). This extra authentication step forces the token to have an ngcmfa amr (if using the interactive flow). Signed-off-by: David Mulder --- Cargo.toml | 2 +- README.md | 2 +- src/common/src/idprovider/himmelblau.rs | 143 +++++++++++++++ src/common/src/idprovider/interface.rs | 17 ++ src/common/src/resolver.rs | 46 +++++ src/common/src/unix_proto.rs | 6 + src/daemon/Cargo.toml | 1 + src/daemon/src/daemon.rs | 34 ++++ src/pam/Cargo.toml | 2 + src/pam/src/pam/mod.rs | 230 +++++++++++++++++++++++- src/pam/src/pam/module.rs | 6 +- 11 files changed, 483 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 94dfa79..2c82b85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ tracing-subscriber = "^0.3.17" tracing = "^0.1.37" himmelblau_unix_common = { path = "src/common" } kanidm_unix_common = { path = "src/glue" } -libhimmelblau = { version = "0.4.2" } +libhimmelblau = { version = "0.4.4", features = ["interactive"] } clap = { version = "^4.5", features = ["derive", "env"] } clap_complete = "^4.4.1" reqwest = { version = "^0.12.2", features = ["json"] } diff --git a/README.md b/README.md index d2af51f..0f2fb37 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ sudo zypper ref && sudo zypper in himmelblau nss-himmelblau pam-himmelblau The following packages are required on openSUSE to build and test this package. - sudo zypper in make cargo git gcc sqlite3-devel libopenssl-3-devel pam-devel libcap-devel libtalloc-devel libtevent-devel libldb-devel libdhash-devel krb5-devel pcre2-devel libclang13 autoconf make automake gettext-tools clang dbus-1-devel utf8proc-devel + sudo zypper in make cargo git gcc sqlite3-devel libopenssl-3-devel pam-devel libcap-devel libtalloc-devel libtevent-devel libldb-devel libdhash-devel krb5-devel pcre2-devel libclang13 autoconf make automake gettext-tools clang dbus-1-devel utf8proc-devel gobject-introspection-devel cairo-devel gdk-pixbuf-devel libsoup-devel pango-devel atk-devel gtk3-devel webkit2gtk3-devel Or on Debian based systems: diff --git a/src/common/src/idprovider/himmelblau.rs b/src/common/src/idprovider/himmelblau.rs index b3acfd5..76110b1 100644 --- a/src/common/src/idprovider/himmelblau.rs +++ b/src/common/src/idprovider/himmelblau.rs @@ -264,6 +264,57 @@ impl IdProvider for HimmelblauMultiProvider { } } + async fn check_auth_token_set( + &self, + account_id: &str, + keystore: &mut D, + ) -> Result { + match split_username(account_id) { + Some((_sam, domain)) => { + let providers = self.providers.read().await; + match providers.get(domain) { + Some(provider) => provider.check_auth_token_set(account_id, keystore).await, + None => Err(IdpError::NotFound), + } + } + None => Err(IdpError::NotFound), + } + } + + async fn change_auth_token( + &self, + account_id: &str, + token: &UnixUserToken, + old_tok: Option<&str>, + new_tok: &str, + keystore: &mut D, + tpm: &mut tpm::BoxedDynTpm, + machine_key: &tpm::MachineKey, + ) -> Result { + match split_username(account_id) { + Some((_sam, domain)) => { + let providers = self.providers.read().await; + match providers.get(domain) { + Some(provider) => { + provider + .change_auth_token( + account_id, + token, + old_tok, + new_tok, + keystore, + tpm, + machine_key, + ) + .await + } + None => Err(IdpError::NotFound), + } + } + None => Err(IdpError::NotFound), + } + } + async fn unix_user_get( &self, id: &Id, @@ -575,6 +626,98 @@ impl IdProvider for HimmelblauProvider { }) } + async fn check_auth_token_set( + &self, + account_id: &str, + keystore: &mut D, + ) -> Result { + let hello_tag = self.fetch_hello_key_tag(account_id); + let hello_key: Option = + keystore.get_tagged_hsm_key(&hello_tag).unwrap_or(None); + Ok(hello_key.is_some()) + } + + async fn change_auth_token( + &self, + account_id: &str, + token: &UnixUserToken, + old_tok: Option<&str>, + new_tok: &str, + keystore: &mut D, + tpm: &mut tpm::BoxedDynTpm, + machine_key: &tpm::MachineKey, + ) -> Result { + let hello_tag = self.fetch_hello_key_tag(account_id); + + // Ensure the user is setting the token for the account it has authenticated to + if account_id.to_string().to_lowercase() + != token + .spn() + .map_err(|e| { + error!("Failed checking the spn on the user token: {:?}", e); + IdpError::BadRequest + })? + .to_lowercase() + { + error!("A hello key may only be set by the authenticated user!"); + return Err(IdpError::BadRequest); + } + + // Ensure the old pin is valid + if let Some(old_tok) = old_tok { + let hello_key: LoadableIdentityKey = keystore + .get_tagged_hsm_key(&hello_tag) + .map_err(|e| { + error!("Failed fetching hello key from keystore: {:?}", e); + IdpError::BadRequest + })? + .ok_or_else(|| { + error!("Authentication failed. Hello key missing."); + IdpError::BadRequest + })?; + + let pin = PinValue::new(old_tok).map_err(|e| { + error!("Failed setting pin value: {:?}", e); + IdpError::Tpm + })?; + tpm.identity_key_load(machine_key, Some(&pin), &hello_key) + .map_err(|e| { + error!("{:?}", e); + IdpError::BadRequest + })?; + } else { + // If no old pin was provided, make sure one isn't already set + let hello_key: Option = + keystore.get_tagged_hsm_key(&hello_tag).unwrap_or(None); + if hello_key.is_some() { + error!("Failed to set Hello pin. An existing key is already set!"); + return Ok(false); + } + } + + // Set the hello pin + let hello_key = match self + .client + .write() + .await + .provision_hello_for_business_key(token, tpm, machine_key, new_tok) + .await + { + Ok(hello_key) => hello_key, + Err(e) => { + error!("Failed to provision hello key: {:?}", e); + return Ok(false); + } + }; + keystore + .insert_tagged_hsm_key(&hello_tag, &hello_key) + .map_err(|e| { + error!("Failed to provision hello key: {:?}", e); + IdpError::Tpm + })?; + Ok(true) + } + async fn unix_user_get( &self, id: &Id, diff --git a/src/common/src/idprovider/interface.rs b/src/common/src/idprovider/interface.rs index 7cb56a7..ff291a0 100644 --- a/src/common/src/idprovider/interface.rs +++ b/src/common/src/idprovider/interface.rs @@ -196,6 +196,23 @@ pub trait IdProvider { _machine_key: &tpm::MachineKey, ) -> Result; + async fn check_auth_token_set( + &self, + _account_id: &str, + _keystore: &mut D, + ) -> Result; + + async fn change_auth_token( + &self, + _account_id: &str, + _token: &UnixUserToken, + _old_tok: Option<&str>, + _new_tok: &str, + _keystore: &mut D, + _tpm: &mut tpm::BoxedDynTpm, + _machine_key: &tpm::MachineKey, + ) -> Result; + async fn unix_user_online_auth_init( &self, _account_id: &str, diff --git a/src/common/src/resolver.rs b/src/common/src/resolver.rs index cb3309a..a805bcd 100644 --- a/src/common/src/resolver.rs +++ b/src/common/src/resolver.rs @@ -706,6 +706,52 @@ where } } + pub async fn check_auth_token_set(&self, account_id: &str) -> Result { + let mut dbtxn = self.db.write().await; + + let res = self + .client + .check_auth_token_set(account_id, &mut dbtxn) + .await; + + dbtxn.commit().map_err(|_| ())?; + + res.map_err(|e| { + debug!("check_auth_token_set error -> {:?}", e); + }) + } + + pub async fn change_auth_token( + &self, + account_id: &str, + token: &UnixUserToken, + old_tok: Option<&str>, + new_tok: &str, + ) -> Result { + let mut hsm_lock = self.hsm.lock().await; + let mut dbtxn = self.db.write().await; + + let res = self + .client + .change_auth_token( + account_id, + token, + old_tok, + new_tok, + &mut dbtxn, + hsm_lock.deref_mut(), + &self.machine_key, + ) + .await; + + drop(hsm_lock); + dbtxn.commit().map_err(|_| ())?; + + res.map_err(|e| { + debug!("change_auth_token error -> {:?}", e); + }) + } + pub async fn get_usertoken(&self, account_id: Id) -> Result, ()> { debug!("get_usertoken"); // get the item from the cache diff --git a/src/common/src/unix_proto.rs b/src/common/src/unix_proto.rs index 1146b8f..7d0ef0f 100644 --- a/src/common/src/unix_proto.rs +++ b/src/common/src/unix_proto.rs @@ -74,6 +74,8 @@ pub enum ClientRequest { PamAuthenticateStep(PamAuthRequest), PamAccountAllowed(String), PamAccountBeginSession(String), + PamAuthTokenIsSet(String), + PamChangeAuthToken(String, String, String, Option, String), InvalidateCache, ClearCache, Status, @@ -95,6 +97,10 @@ impl ClientRequest { format!("PamAccountAllowed({})", id) } ClientRequest::PamAccountBeginSession(_) => "PamAccountBeginSession".to_string(), + ClientRequest::PamAuthTokenIsSet(id) => format!("PamAuthTokenIsSet({})", id), + ClientRequest::PamChangeAuthToken(id, _, _, _, _) => { + format!("PamChangeAuthToken({}, ...)", id) + } ClientRequest::InvalidateCache => "InvalidateCache".to_string(), ClientRequest::ClearCache => "ClearCache".to_string(), ClientRequest::Status => "Status".to_string(), diff --git a/src/daemon/Cargo.toml b/src/daemon/Cargo.toml index 6671275..ccfc745 100644 --- a/src/daemon/Cargo.toml +++ b/src/daemon/Cargo.toml @@ -42,6 +42,7 @@ kanidm_lib_file_permissions.workspace = true identity_dbus_broker.workspace = true base64.workspace = true async-trait = "0.1.83" +libhimmelblau.workspace = true [package.metadata.deb] name = "himmelblau" diff --git a/src/daemon/src/daemon.rs b/src/daemon/src/daemon.rs index ee026a4..4435e97 100644 --- a/src/daemon/src/daemon.rs +++ b/src/daemon/src/daemon.rs @@ -33,6 +33,7 @@ use std::time::Duration; use bytes::{BufMut, BytesMut}; use clap::{Arg, ArgAction, Command}; use futures::{SinkExt, StreamExt}; +use himmelblau::{ClientInfo, IdToken, UserToken as UnixUserToken}; use himmelblau_unix_common::config::HimmelblauConfig; use himmelblau_unix_common::constants::DEFAULT_CONFIG_PATH; use himmelblau_unix_common::db::{Cache, CacheTxn, Db}; @@ -549,6 +550,39 @@ async fn handle_client( _ => ClientResponse::Error, } } + ClientRequest::PamAuthTokenIsSet(account_id) => { + debug!("sm_chauthtok check set"); + cachelayer + .check_auth_token_set(&account_id) + .await + .map(|is_set| ClientResponse::PamStatus(Some(is_set))) + .unwrap_or(ClientResponse::Error) + } + ClientRequest::PamChangeAuthToken( + account_id, + access_token, + refresh_token, + old_pin, + new_pin, + ) => { + debug!("sm_chauthtok req"); + let token = UnixUserToken { + token_type: "Bearer".to_string(), + scope: None, + expires_in: 0, + ext_expires_in: 0, + access_token: Some(access_token), + refresh_token, + id_token: IdToken::default(), + client_info: ClientInfo::default(), + prt: None, + }; + cachelayer + .change_auth_token(&account_id, &token, old_pin.as_deref(), &new_pin) + .await + .map(|_| ClientResponse::Ok) + .unwrap_or(ClientResponse::Error) + } ClientRequest::InvalidateCache => { debug!("invalidate cache"); cachelayer diff --git a/src/pam/Cargo.toml b/src/pam/Cargo.toml index be05c77..78a614a 100644 --- a/src/pam/Cargo.toml +++ b/src/pam/Cargo.toml @@ -21,6 +21,8 @@ kanidm_unix_common = { workspace = true } tracing-subscriber = { workspace = true } tracing = { workspace = true } himmelblau_unix_common.workspace = true +tokio.workspace = true +libhimmelblau.workspace = true [build-dependencies] pkg-config.workspace = true diff --git a/src/pam/src/pam/mod.rs b/src/pam/src/pam/mod.rs index c9e377c..7975330 100755 --- a/src/pam/src/pam/mod.rs +++ b/src/pam/src/pam/mod.rs @@ -60,13 +60,17 @@ use std::collections::BTreeSet; use std::convert::TryFrom; use std::ffi::CStr; -use himmelblau_unix_common::config::HimmelblauConfig; +use himmelblau::error::MsalError; +use himmelblau::PublicClientApplication; +use himmelblau_unix_common::config::{split_username, HimmelblauConfig}; +use himmelblau_unix_common::constants::BROKER_APP_ID; use kanidm_unix_common::client_sync::DaemonClientBlocking; use kanidm_unix_common::constants::DEFAULT_CONFIG_PATH; use kanidm_unix_common::unix_config::KanidmUnixdConfig; use kanidm_unix_common::unix_proto::{ ClientRequest, ClientResponse, PamAuthRequest, PamAuthResponse, }; +use std::thread::sleep; use crate::pam::constants::*; use crate::pam::conv::PamConv; @@ -82,6 +86,8 @@ use tracing_subscriber::prelude::*; use std::thread; use std::time::Duration; +use tokio::runtime::Runtime; + pub fn get_cfg() -> Result { HimmelblauConfig::new(Some(DEFAULT_CONFIG_PATH)).map_err(|_| PamResultCode::PAM_SERVICE_ERR) } @@ -524,7 +530,7 @@ impl PamHooks for PamKanidm { } // while true, continue calling PamAuthenticateStep until we get a decision. } - fn sm_chauthtok(_pamh: &PamHandle, args: Vec<&CStr>, _flags: PamFlag) -> PamResultCode { + fn sm_chauthtok(pamh: &PamHandle, args: Vec<&CStr>, _flags: PamFlag) -> PamResultCode { let opts = match Options::try_from(&args) { Ok(o) => o, Err(_) => return PamResultCode::PAM_SERVICE_ERR, @@ -534,7 +540,225 @@ impl PamHooks for PamKanidm { debug!(?args, ?opts, "sm_chauthtok"); - PamResultCode::PAM_IGNORE + let account_id = match pamh.get_user(None) { + Ok(aid) => aid, + Err(e) => { + error!(err = ?e, "get_user"); + return PamResultCode::PAM_SERVICE_ERR; + } + }; + + let cfg = match get_cfg() { + Ok(cfg) => cfg, + Err(e) => return e, + }; + let account_id = cfg.map_cn_name(&account_id); + + let mut daemon_client = match DaemonClientBlocking::new(cfg.get_socket_path().as_str()) { + Ok(dc) => dc, + Err(e) => { + error!(err = ?e, "Error DaemonClientBlocking::new()"); + return PamResultCode::PAM_SERVICE_ERR; + } + }; + + let (_, domain) = match split_username(&account_id) { + Some(resp) => resp, + None => { + error!("split_username"); + return PamResultCode::PAM_AUTH_ERR; + } + }; + let tenant_id = match cfg.get_tenant_id(domain) { + Some(tenant_id) => tenant_id, + None => "common".to_string(), + }; + let authority = format!("https://{}/{}", cfg.get_authority_host(domain), tenant_id); + let app = match PublicClientApplication::new(BROKER_APP_ID, Some(&authority)) { + Ok(app) => app, + Err(e) => { + error!(err = ?e, "PublicClientApplication"); + return PamResultCode::PAM_AUTH_ERR; + } + }; + + let conv = match pamh.get_item::() { + Ok(conv) => conv, + Err(err) => { + error!(?err, "pam_conv"); + return err; + } + }; + + let password = match conv.send(PAM_PROMPT_ECHO_OFF, "Password: ") { + Ok(password) => match password { + Some(cred) => cred, + None => { + debug!("no password"); + return PamResultCode::PAM_CRED_INSUFFICIENT; + } + }, + Err(err) => { + debug!("unable to get password"); + return err; + } + }; + + let rt = match Runtime::new() { + Ok(rt) => rt, + Err(e) => { + error!("{:?}", e); + return PamResultCode::PAM_AUTH_ERR; + } + }; + let token = if cfg.get_enable_experimental_mfa() { + let mut mfa_req = match rt.block_on(async { + app.initiate_acquire_token_by_mfa_flow(&account_id, &password, vec![], None, vec![]) + .await + }) { + Ok(mfa) => mfa, + Err(e) => { + error!("{:?}", e); + return PamResultCode::PAM_AUTH_ERR; + } + }; + + match mfa_req.mfa_method.as_str() { + "PhoneAppOTP" | "OneWaySMS" | "ConsolidatedTelephony" => { + let input = match conv.send(PAM_PROMPT_ECHO_OFF, &mfa_req.msg) { + Ok(password) => match password { + Some(cred) => cred, + None => { + debug!("no password"); + return PamResultCode::PAM_CRED_INSUFFICIENT; + } + }, + Err(err) => { + debug!("unable to get password"); + return err; + } + }; + match rt.block_on(async { + app.acquire_token_by_mfa_flow(&account_id, Some(&input), None, &mut mfa_req) + .await + }) { + Ok(token) => token, + Err(e) => { + error!("MFA FAIL: {:?}", e); + return PamResultCode::PAM_AUTH_ERR; + } + } + } + _ => { + match conv.send(PAM_TEXT_INFO, &mfa_req.msg) { + Ok(_) => {} + Err(err) => { + if opts.debug { + println!("Message prompt failed"); + } + return err; + } + } + let mut poll_attempt = 1; + let polling_interval = mfa_req.polling_interval.unwrap_or(5000); + loop { + match rt.block_on(async { + app.acquire_token_by_mfa_flow( + &account_id, + None, + Some(poll_attempt), + &mut mfa_req, + ) + .await + }) { + Ok(token) => break token, + Err(e) => match e { + MsalError::MFAPollContinue => { + poll_attempt += 1; + sleep(Duration::from_millis(polling_interval.into())); + continue; + } + e => { + error!("MFA FAIL: {:?}", e); + return PamResultCode::PAM_AUTH_ERR; + } + }, + } + } + } + } + } else { + match rt.block_on(async { app.acquire_token_interactive(&account_id, None).await }) { + Ok(token) => token, + Err(e) => { + error!(err = ?e, "acquire_token"); + return PamResultCode::PAM_AUTH_ERR; + } + } + }; + + // Don't request an old pin if it hasn't been set yet. + let req = ClientRequest::PamAuthTokenIsSet(account_id.clone()); + let req_old_pin = match daemon_client.call_and_wait(&req, cfg.get_unix_sock_timeout()) { + Ok(ClientResponse::PamStatus(Some(val))) => val, + other => { + debug!(err = ?other, "PamResultCode::PAM_AUTH_ERR"); + return PamResultCode::PAM_AUTH_ERR; + } + }; + + let old_pin = if req_old_pin { + match pamh.get_old_authtok() { + Ok(Some(v)) => Some(v), + Ok(None) => { + error!("get_old_authtok"); + return PamResultCode::PAM_AUTHTOK_ERR; + } + Err(e) => { + error!(err = ?e, "get_old_authtok"); + return e; + } + } + } else { + None + }; + + let new_pin = match pamh.get_authtok() { + Ok(Some(v)) => v, + Ok(None) => { + error!("get_authtok"); + return PamResultCode::PAM_AUTHTOK_ERR; + } + Err(e) => { + error!(err = ?e, "get_authtok"); + return e; + } + }; + + let req = ClientRequest::PamChangeAuthToken( + account_id, + match token.access_token.clone() { + Some(access_token) => access_token, + None => { + error!("Failed fetching access token for pin change"); + return PamResultCode::PAM_AUTH_ERR; + } + }, + token.refresh_token.clone(), + old_pin, + new_pin, + ); + + match daemon_client.call_and_wait(&req, cfg.get_unix_sock_timeout()) { + Ok(ClientResponse::Ok) => { + debug!("PamResultCode::PAM_SUCCESS"); + PamResultCode::PAM_SUCCESS + } + other => { + debug!(err = ?other, "PamResultCode::PAM_AUTH_ERR"); + PamResultCode::PAM_AUTH_ERR + } + } } fn sm_close_session(_pamh: &PamHandle, args: Vec<&CStr>, _flags: PamFlag) -> PamResultCode { diff --git a/src/pam/src/pam/module.rs b/src/pam/src/pam/module.rs index 8d7fb7e..ca6ce7a 100755 --- a/src/pam/src/pam/module.rs +++ b/src/pam/src/pam/module.rs @@ -31,7 +31,7 @@ use std::{mem, ptr}; use libc::c_char; use crate::pam::constants::{PamFlag, PamItemType, PamResultCode}; -use crate::pam::items::{PamAuthTok, PamRHost, PamService, PamTty}; +use crate::pam::items::{PamAuthTok, PamOldAuthTok, PamRHost, PamService, PamTty}; /// Opaque type, used as a pointer when making pam API calls. /// @@ -246,6 +246,10 @@ impl PamHandle { self.get_item_string::() } + pub fn get_old_authtok(&self) -> PamResult> { + self.get_item_string::() + } + pub fn get_tty(&self) -> PamResult> { self.get_item_string::() }