From 00cf5f4e15a8bdc2f39a967acbc9f43460bf58aa Mon Sep 17 00:00:00 2001 From: Firstyear Date: Thu, 19 Jan 2023 17:14:38 +1000 Subject: [PATCH] 1121 SCIM import totp freeipa (#1328) --- iam_migrations/freeipa/src/main.rs | 396 ++++++++++++++---- kanidm_proto/src/scim_v1.rs | 44 ++ kanidm_proto/src/v1.rs | 10 +- kanidmd/lib/src/be/dbvalue.rs | 5 + kanidmd/lib/src/be/mod.rs | 4 +- kanidmd/lib/src/constants/uuids.rs | 1 + kanidmd/lib/src/credential/totp.rs | 128 +++++- kanidmd/lib/src/idm/credupdatesession.rs | 6 +- kanidmd/lib/src/idm/scim.rs | 154 +++++++ .../{password_import.rs => cred_import.rs} | 232 ++++++---- kanidmd/lib/src/plugins/mod.rs | 8 +- kanidmd/lib/src/schema.rs | 18 + kanidmd/lib/src/server/mod.rs | 5 +- kanidmd/lib/src/value.rs | 10 +- kanidmd/lib/src/valueset/mod.rs | 12 +- kanidmd/lib/src/valueset/totp.rs | 164 ++++++++ kanidmd/testkit/tests/proto_v1_test.rs | 2 +- 17 files changed, 1009 insertions(+), 190 deletions(-) rename kanidmd/lib/src/plugins/{password_import.rs => cred_import.rs} (57%) create mode 100644 kanidmd/lib/src/valueset/totp.rs diff --git a/iam_migrations/freeipa/src/main.rs b/iam_migrations/freeipa/src/main.rs index a1441de76..873f4c4b3 100644 --- a/iam_migrations/freeipa/src/main.rs +++ b/iam_migrations/freeipa/src/main.rs @@ -14,15 +14,16 @@ mod config; mod error; -#[cfg(test)] -mod tests; +// #[cfg(test)] +// mod tests; use crate::config::{Config, EntryConfig}; use crate::error::SyncError; +use base64urlsafedata::Base64UrlSafeData; use chrono::Utc; use clap::Parser; use cron::Schedule; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::fs::metadata; use std::fs::File; use std::io::Read; @@ -48,13 +49,14 @@ use uuid::Uuid; use kanidm_client::KanidmClientBuilder; use kanidm_proto::scim_v1::{ ScimEntry, ScimExternalMember, ScimSyncGroup, ScimSyncPerson, ScimSyncRequest, ScimSyncState, + ScimTotp, }; use kanidmd_lib::utils::file_permissions_readonly; use users::{get_current_gid, get_current_uid, get_effective_gid, get_effective_uid}; use ldap3_client::{ - proto, proto::LdapFilter, LdapClientBuilder, LdapSyncRepl, LdapSyncReplEntry, + proto, proto::LdapFilter, LdapClient, LdapClientBuilder, LdapSyncRepl, LdapSyncReplEntry, LdapSyncStateValue, }; @@ -331,6 +333,8 @@ async fn run_sync( ScimSyncState::Active { cookie } => Some(cookie.0.clone()), }; + let is_initialise = cookie.is_none(); + let filter = LdapFilter::Or(vec![ // LdapFilter::Equality("objectclass".to_string(), "domain".to_string()), LdapFilter::And(vec![ @@ -341,6 +345,7 @@ async fn run_sync( LdapFilter::And(vec![ LdapFilter::Equality("objectclass".to_string(), "groupofnames".to_string()), LdapFilter::Equality("objectclass".to_string(), "ipausergroup".to_string()), + // Ignore user private groups, kani generates these internally. LdapFilter::Not(Box::new(LdapFilter::Equality( "objectclass".to_string(), "mepmanagedentry".to_string(), @@ -356,6 +361,7 @@ async fn run_sync( "ipausers".to_string(), ))), ]), + // Fetch TOTP's so we know when/if they change. LdapFilter::And(vec![ LdapFilter::Equality("objectclass".to_string(), "ipatoken".to_string()), LdapFilter::Equality("objectclass".to_string(), "ipatokentotp".to_string()), @@ -381,17 +387,69 @@ async fn run_sync( } } - // pre-process the entries. - // - > fn so we can test. - let scim_sync_request = match process_ipa_sync_result( - scim_sync_status, - sync_result, - &sync_config.entry_map, - ) - .await - { - Ok(ssr) => ssr, - Err(()) => return Err(SyncError::Preprocess), + // Convert the ldap sync repl result to a scim equivalent + let scim_sync_request = match sync_result { + LdapSyncRepl::Success { + cookie, + refresh_deletes, + entries, + delete_uuids, + present_uuids, + } => { + if refresh_deletes { + error!("Unsure how to handle refreshDeletes=True"); + return Err(SyncError::Preprocess); + } + + if !present_uuids.is_empty() { + error!("Unsure how to handle presentUuids > 0"); + return Err(SyncError::Preprocess); + } + + let to_state = cookie + .map(|cookie| { + ScimSyncState::Active { cookie } + }) + .ok_or_else(|| { + error!("Invalid state, ldap sync repl did not provide a valid state cookie in response."); + + SyncError::Preprocess + + })?; + + // process the entries to scim. + let entries = match process_ipa_sync_result( + ipa_client, + entries, + &sync_config.entry_map, + is_initialise, + ) + .await + { + Ok(ssr) => ssr, + Err(()) => { + error!("Failed to process IPA entries to SCIM"); + return Err(SyncError::Preprocess); + } + }; + + ScimSyncRequest { + from_state: scim_sync_status, + to_state, + entries, + delete_uuids, + } + } + LdapSyncRepl::RefreshRequired => { + let to_state = ScimSyncState::Refresh; + + ScimSyncRequest { + from_state: scim_sync_status, + to_state, + entries: Vec::new(), + delete_uuids: Vec::new(), + } + } }; if opt.proto_dump { @@ -421,78 +479,186 @@ async fn run_sync( } async fn process_ipa_sync_result( - from_state: ScimSyncState, - sync_result: LdapSyncRepl, + _ipa_client: LdapClient, + ldap_entries: Vec, entry_config_map: &HashMap, -) -> Result { - match sync_result { - LdapSyncRepl::Success { - cookie, - refresh_deletes, - entries, - delete_uuids, - present_uuids, - } => { - if refresh_deletes { - error!("Unsure how to handle refreshDeletes=True"); - return Err(()); - } + is_initialise: bool, +) -> Result, ()> { + // Because of how TOTP works with freeipa it's a soft referral from + // the totp toward the user. This means if a TOTP is added or removed + // we see those as unique entries in the syncrepl but we are missing + // the user entry that actually needs the update since Kanidm makes TOTP + // part of the entry itself. + // + // This *also* means that when a user is updated that we also need to + // fetch their TOTP's that are related so we can assert them on the + // submission. + // + // Because of this, we have to do some client-side processing here to + // work out what "entries we are missing" and do a second search to + // fetch them. Sadly, this means that we need to do a second search + // and since ldap is NOT transactional there is a possibility of a + // desync between the sync-repl and the results of the second search. + // + // There are 5 possibilities - note one of TOTP or USER must be in syncrepl + // state else we wouldn't proceed. + // TOTP USER OUTCOME + // SyncRepl SyncRepl No ext detail needed, proceed + // SyncRepl Add/Mod Update user, won't change on next syncrepl + // SyncRepl Del Ignore this TOTP -> will be deleted on next syncrepl + // Add/Mod SyncRepl Add the new TOTP, won't change on next syncrepl + // Del SyncRepl Remove TOTP, won't change on next syncrepl + // + // The big challenge is to transform our data in a way that we can actually work + // with it here meaning we have to disassemble and "index" the content of our + // sync result. - if !present_uuids.is_empty() { - error!("Unsure how to handle presentUuids > 0"); - return Err(()); - } + // Hash entries by DN -> Split TOTP's to their own set. + // make a list of updated TOTP's and what DN's they require. + // make a list of updated Users and what TOTP's they require. - let to_state = cookie - .map(|cookie| { - ScimSyncState::Active { cookie } - }) - .ok_or_else(|| { - error!("Invalid state, ldap sync repl did not provide a valid state cookie in response."); - })?; + let mut entries = BTreeMap::default(); + let mut user_dns = Vec::default(); + let mut totp_entries: BTreeMap> = BTreeMap::default(); - // Future - make this par-map - let entries = entries - .into_iter() - .filter_map(|e| { - let e_config = entry_config_map - .get(&e.entry_uuid) - .cloned() - .unwrap_or_default(); - match ipa_to_scim_entry(e, &e_config) { - Ok(Some(e)) => Some(Ok(e)), - Ok(None) => None, - Err(()) => Some(Err(())), - } - }) - .collect::, _>>(); - - let entries = match entries { - Ok(e) => e, - Err(()) => { - error!("Failed to process IPA entries to SCIM"); - return Err(()); - } + for lentry in ldap_entries.into_iter() { + if lentry + .entry + .attrs + .get("objectclass") + .map(|oc| oc.contains("ipatokentotp")) + .unwrap_or_default() + { + // It's an otp. Lets see ... + let token_owner_dn = if let Some(todn) = lentry + .entry + .attrs + .get("ipatokenowner") + .and_then(|attr| if attr.len() != 1 { None } else { attr.first() }) + { + debug!("totp with owner {}", todn); + todn.clone() + } else { + warn!("totp with invalid ownership will be ignored"); + continue; }; - Ok(ScimSyncRequest { - from_state, - to_state, - entries, - delete_uuids, - }) - } - LdapSyncRepl::RefreshRequired => { - let to_state = ScimSyncState::Refresh; + if !totp_entries.contains_key(&token_owner_dn) { + totp_entries.insert(token_owner_dn.clone(), Vec::default()); + } - Ok(ScimSyncRequest { - from_state, - to_state, - entries: Vec::new(), - delete_uuids: Vec::new(), - }) + if let Some(v) = totp_entries.get_mut(&token_owner_dn) { + v.push(lentry) + } + } else { + let dn = lentry.entry.dn.clone(); + + if lentry + .entry + .attrs + .get("objectclass") + .map(|oc| oc.contains("person")) + .unwrap_or_default() + { + user_dns.push(dn.clone()); + } + + entries.insert(dn, lentry); } } + + // Now, we have to invert the totp set so that it's defined by entry dn instead. + debug!("te, {}, e {}", totp_entries.len(), entries.len()); + + // If this is an INIT we have the full state already - no extra search is needed. + + // On a refresh, we need to search and fix up to make sure TOTP/USER sets are + // consistent. + if !is_initialise { + // If the totp's related user is NOT in our sync repl, we need to fetch them. + let fetch_user: Vec<&str> = totp_entries + .keys() + .map(|k| k.as_str()) + .filter(|k| !entries.contains_key(*k)) + .collect(); + + // For every user in our fetch_user *and* entries set, we need to fetch their + // related TOTP's. + let fetch_totps_for: Vec<&str> = fetch_user + .iter() + .copied() + .chain(user_dns.iter().map(|s| s.as_str())) + .collect(); + + // Create filter (could hit a limit, may need to split this search). + + let totp_conditions: Vec<_> = fetch_totps_for + .iter() + .map(|dn| LdapFilter::Equality("ipatokenowner".to_string(), dn.to_string())) + .collect(); + + let user_conditions = fetch_user + .iter() + .filter_map(|dn| { + // We have to split the DN to it's RDN because lol. + dn.split_once(',') + .and_then(|(rdn, _)| rdn.split_once('=')) + .map(|(_, uid)| LdapFilter::Equality("uid".to_string(), uid.to_string())) + }) + .collect(); + + let filter = LdapFilter::Or(vec![ + LdapFilter::And(vec![ + LdapFilter::Equality("objectclass".to_string(), "ipatoken".to_string()), + LdapFilter::Equality("objectclass".to_string(), "ipatokentotp".to_string()), + LdapFilter::Or(totp_conditions), + ]), + LdapFilter::And(vec![ + LdapFilter::Equality("objectclass".to_string(), "person".to_string()), + LdapFilter::Equality("objectclass".to_string(), "ipantuserattrs".to_string()), + LdapFilter::Equality("objectclass".to_string(), "posixaccount".to_string()), + LdapFilter::Or(user_conditions), + ]), + ]); + + debug!(?filter); + + // Search + // Inject all new entries to our maps. At this point we discard the original content + // of the totp entries since we just fetched them all again anyway. + } + + // For each updated TOTP -> If it's related DN is not in Hash -> remove from map + totp_entries.retain(|k, _| { + let x = entries.contains_key(k); + if !x { + warn!("Removing totp with no valid owner {}", k); + } + x + }); + + let empty_slice = Vec::default(); + + // Future - make this par-map + let entries = entries + .into_iter() + .filter_map(|(dn, e)| { + let e_config = entry_config_map + .get(&e.entry_uuid) + .cloned() + .unwrap_or_default(); + + let totp = totp_entries.get(&dn).unwrap_or(&empty_slice); + + match ipa_to_scim_entry(e, &e_config, totp) { + Ok(Some(e)) => Some(Ok(e)), + Ok(None) => None, + Err(()) => Some(Err(())), + } + }) + .collect::, _>>(); + + entries } // TODO: Allow re-map of uuid -> uuid @@ -500,6 +666,7 @@ async fn process_ipa_sync_result( fn ipa_to_scim_entry( sync_entry: LdapSyncReplEntry, entry_config: &EntryConfig, + totp: &[LdapSyncReplEntry], ) -> Result, ()> { debug!("{:#?}", sync_entry); @@ -555,6 +722,10 @@ fn ipa_to_scim_entry( let password_import = entry .remove_ava_single("ipanthash") .map(|s| format!("ipaNTHash: {}", s)); + + // If there are TOTP's, convert them to something sensible. + let totp_import = totp.iter().filter_map(ipa_to_totp).collect(); + let login_shell = entry.remove_ava_single("loginshell"); let external_id = Some(entry.dn); @@ -566,6 +737,7 @@ fn ipa_to_scim_entry( display_name, gidnumber, password_import, + totp_import, login_shell, } .into(), @@ -631,6 +803,74 @@ fn ipa_to_scim_entry( } } +fn ipa_to_totp(sync_entry: &LdapSyncReplEntry) -> Option { + let external_id = sync_entry + .entry + .attrs + .get("ipatokenuniqueid") + .and_then(|v| v.first().cloned()) + .or_else(|| { + warn!("Invalid ipatokenuniqueid"); + None + })?; + + let secret = sync_entry + .entry + .attrs + .get("ipatokenotpkey") + .and_then(|v| v.first()) + .and_then(|s| { + // Decode, and then make it urlsafe. + Base64UrlSafeData::try_from(s.as_str()) + .ok() + .map(|b| b.to_string()) + }) + .or_else(|| { + warn!("Invalid ipatokenotpkey"); + None + })?; + + let algo = sync_entry + .entry + .attrs + .get("ipatokenotpalgorithm") + .and_then(|v| v.first().cloned()) + .or_else(|| { + warn!("Invalid ipatokenotpalgorithm"); + None + })?; + + let step = sync_entry + .entry + .attrs + .get("ipatokentotptimestep") + .and_then(|v| v.first()) + .and_then(|d| u32::from_str(d).ok()) + .or_else(|| { + warn!("Invalid ipatokentotptimestep"); + None + })?; + + let digits = sync_entry + .entry + .attrs + .get("ipatokenotpdigits") + .and_then(|v| v.first()) + .and_then(|d| u32::from_str(d).ok()) + .or_else(|| { + warn!("Invalid ipatokenotpdigits"); + None + })?; + + Some(ScimTotp { + external_id, + secret, + algo, + step, + digits, + }) +} + fn config_security_checks(cfg_path: &Path) -> bool { let cfg_path_str = cfg_path.to_string_lossy(); diff --git a/kanidm_proto/src/scim_v1.rs b/kanidm_proto/src/scim_v1.rs index 2a1c5659c..fa4a602c7 100644 --- a/kanidm_proto/src/scim_v1.rs +++ b/kanidm_proto/src/scim_v1.rs @@ -40,6 +40,47 @@ pub const SCIM_SCHEMA_SYNC_ACCOUNT: &str = "urn:ietf:params:scim:schemas:kanidm: pub const SCIM_SCHEMA_SYNC_POSIXACCOUNT: &str = "urn:ietf:params:scim:schemas:kanidm:1.0:posixaccount"; +#[derive(Serialize, Debug, Clone)] +pub struct ScimTotp { + /// maps to "label" in kanidm. + pub external_id: String, + pub secret: String, + pub algo: String, + pub step: u32, + pub digits: u32, +} + +// Need to allow this because clippy is broken and doesn't realise scimentry is out of crate +// so this can't be fulfilled +#[allow(clippy::from_over_into)] +impl Into for ScimTotp { + fn into(self) -> ScimComplexAttr { + let ScimTotp { + external_id, + secret, + algo, + step, + digits, + } = self; + let mut attrs = BTreeMap::default(); + + attrs.insert( + "external_id".to_string(), + ScimSimpleAttr::String(external_id), + ); + + attrs.insert("secret".to_string(), ScimSimpleAttr::String(secret)); + + attrs.insert("algo".to_string(), ScimSimpleAttr::String(algo)); + + attrs.insert("step".to_string(), ScimSimpleAttr::Number(step.into())); + + attrs.insert("digits".to_string(), ScimSimpleAttr::Number(digits.into())); + + ScimComplexAttr { attrs } + } +} + #[derive(Serialize, Debug, Clone)] #[serde(into = "ScimEntry")] pub struct ScimSyncPerson { @@ -49,6 +90,7 @@ pub struct ScimSyncPerson { pub display_name: String, pub gidnumber: Option, pub password_import: Option, + pub totp_import: Vec, pub login_shell: Option, } @@ -64,6 +106,7 @@ impl Into for ScimSyncPerson { display_name, gidnumber, password_import, + totp_import, login_shell, } = self; @@ -86,6 +129,7 @@ impl Into for ScimSyncPerson { set_string!(attrs, "displayname", display_name); set_option_u32!(attrs, "gidnumber", gidnumber); set_option_string!(attrs, "password_import", password_import); + set_multi_complex!(attrs, "totp_import", totp_import); set_option_string!(attrs, "loginshell", login_shell); ScimEntry { diff --git a/kanidm_proto/src/v1.rs b/kanidm_proto/src/v1.rs index a8ab19093..c7c7b9393 100644 --- a/kanidm_proto/src/v1.rs +++ b/kanidm_proto/src/v1.rs @@ -38,7 +38,7 @@ pub enum PluginError { AttrUnique(String), Base(String), ReferentialIntegrity(String), - PasswordImport(String), + CredImport(String), Oauth2Secrets, } @@ -1076,6 +1076,7 @@ pub struct TotpSecret { pub secret: Vec, pub algo: TotpAlgo, pub step: u64, + pub digits: u8, } impl TotpSecret { @@ -1087,10 +1088,11 @@ impl TotpSecret { let algo = self.algo.to_string(); let secret = self.get_secret(); let period = self.step; + let digits = self.digits; format!( - "otpauth://totp/{}?secret={}&issuer={}&algorithm={}&digits=6&period={}", - label, secret, issuer, algo, period + "otpauth://totp/{}?secret={}&issuer={}&algorithm={}&digits={}&period={}", + label, secret, issuer, algo, digits, period ) } @@ -1224,6 +1226,7 @@ mod tests { secret: vec![0xaa, 0xbb, 0xcc, 0xdd], step: 30, algo: TotpAlgo::Sha256, + digits: 6, }; let s = totp.to_uri(); assert!(s == "otpauth://totp/blackhats:william?secret=VK54ZXI&issuer=blackhats&algorithm=SHA256&digits=6&period=30"); @@ -1235,6 +1238,7 @@ mod tests { secret: vec![0xaa, 0xbb, 0xcc, 0xdd], step: 30, algo: TotpAlgo::Sha256, + digits: 6, }; let s = totp.to_uri(); println!("{}", s); diff --git a/kanidmd/lib/src/be/dbvalue.rs b/kanidmd/lib/src/be/dbvalue.rs index 59b7d93bc..04bc98164 100644 --- a/kanidmd/lib/src/be/dbvalue.rs +++ b/kanidmd/lib/src/be/dbvalue.rs @@ -73,6 +73,8 @@ pub struct DbTotpV1 { pub step: u64, #[serde(rename = "a")] pub algo: DbTotpAlgoV1, + #[serde(rename = "d", default)] + pub digits: Option, } impl std::fmt::Debug for DbTotpV1 { @@ -576,6 +578,8 @@ pub enum DbValueSetV2 { Oauth2Session(Vec), #[serde(rename = "UH")] UiHint(Vec), + #[serde(rename = "TO")] + TotpSecret(Vec<(String, DbTotpV1)>), } impl DbValueSetV2 { @@ -616,6 +620,7 @@ impl DbValueSetV2 { DbValueSetV2::JwsKeyEs256(set) => set.len(), DbValueSetV2::JwsKeyRs256(set) => set.len(), DbValueSetV2::UiHint(set) => set.len(), + DbValueSetV2::TotpSecret(set) => set.len(), } } diff --git a/kanidmd/lib/src/be/mod.rs b/kanidmd/lib/src/be/mod.rs index a85414b96..20181bfdf 100644 --- a/kanidmd/lib/src/be/mod.rs +++ b/kanidmd/lib/src/be/mod.rs @@ -61,8 +61,8 @@ impl Default for Limits { fn default() -> Self { Limits { unindexed_allow: false, - search_max_results: 128, - search_max_filter_test: 256, + search_max_results: 256, + search_max_filter_test: 512, filter_max_elements: 32, } } diff --git a/kanidmd/lib/src/constants/uuids.rs b/kanidmd/lib/src/constants/uuids.rs index b9f03efe4..ffbd8faed 100644 --- a/kanidmd/lib/src/constants/uuids.rs +++ b/kanidmd/lib/src/constants/uuids.rs @@ -222,6 +222,7 @@ pub const UUID_SCHEMA_ATTR_SYNC_ALLOWED: Uuid = uuid!("00000000-0000-0000-0000-f pub const UUID_SCHEMA_ATTR_EMAILPRIMARY: Uuid = uuid!("00000000-0000-0000-0000-ffff00000126"); pub const UUID_SCHEMA_ATTR_EMAILALTERNATIVE: Uuid = uuid!("00000000-0000-0000-0000-ffff00000127"); +pub const UUID_SCHEMA_ATTR_TOTP_IMPORT: Uuid = uuid!("00000000-0000-0000-0000-ffff00000128"); // System and domain infos // I'd like to strongly criticise william of the past for making poor choices about these allocations. diff --git a/kanidmd/lib/src/credential/totp.rs b/kanidmd/lib/src/credential/totp.rs index e42d2b50a..e81b5b8ee 100644 --- a/kanidmd/lib/src/credential/totp.rs +++ b/kanidmd/lib/src/credential/totp.rs @@ -20,6 +20,35 @@ pub enum TotpError { TimeError, } +#[repr(u32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TotpDigits { + Six = 1_000_000, + Eight = 100_000_000, +} + +impl TryFrom for TotpDigits { + type Error = (); + + fn try_from(value: u8) -> Result { + match value { + 6 => Ok(TotpDigits::Six), + 8 => Ok(TotpDigits::Six), + _ => Err(()), + } + } +} + +#[allow(clippy::from_over_into)] +impl Into for TotpDigits { + fn into(self) -> u8 { + match self { + TotpDigits::Six => 6, + TotpDigits::Eight => 8, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum TotpAlgo { Sha1, @@ -62,6 +91,7 @@ pub struct Totp { secret: Vec, pub(crate) step: u64, algo: TotpAlgo, + digits: TotpDigits, } impl TryFrom for Totp { @@ -73,17 +103,23 @@ impl TryFrom for Totp { DbTotpAlgoV1::S256 => TotpAlgo::Sha256, DbTotpAlgoV1::S512 => TotpAlgo::Sha512, }; + // Default. + let digits = TotpDigits::try_from(value.digits.unwrap_or(6))?; + Ok(Totp { secret: value.key, step: value.step, algo, + digits, }) } } -impl From for Totp { - fn from(value: ProtoTotp) -> Self { - Totp { +impl TryFrom for Totp { + type Error = (); + + fn try_from(value: ProtoTotp) -> Result { + Ok(Totp { secret: value.secret, algo: match value.algo { ProtoTotpAlgo::Sha1 => TotpAlgo::Sha1, @@ -91,13 +127,19 @@ impl From for Totp { ProtoTotpAlgo::Sha512 => TotpAlgo::Sha512, }, step: value.step, - } + digits: TotpDigits::try_from(value.digits)?, + }) } } impl Totp { - pub fn new(secret: Vec, step: u64, algo: TotpAlgo) -> Self { - Totp { secret, step, algo } + pub fn new(secret: Vec, step: u64, algo: TotpAlgo, digits: TotpDigits) -> Self { + Totp { + secret, + step, + algo, + digits, + } } // Create a new token with secure key and algo. @@ -105,7 +147,13 @@ impl Totp { let mut rng = rand::thread_rng(); let secret: Vec = (0..SECRET_SIZE_BYTES).map(|_| rng.gen()).collect(); let algo = TotpAlgo::Sha256; - Totp { secret, step, algo } + let digits = TotpDigits::Six; + Totp { + secret, + step, + algo, + digits, + } } pub(crate) fn to_dbtotpv1(&self) -> DbTotpV1 { @@ -118,6 +166,7 @@ impl Totp { TotpAlgo::Sha256 => DbTotpAlgoV1::S256, TotpAlgo::Sha512 => DbTotpAlgoV1::S512, }, + digits: Some(self.digits.into()), } } @@ -134,7 +183,12 @@ impl Totp { .map_err(|_| TotpError::HmacError)?; let otp = u32::from_be_bytes(bytes); - Ok((otp & 0x7fff_ffff) % 1_000_000) + // Treat as a u31, this masks the first bit. + // then modulo based on the number of digits requested. + // * For 6 digits modulo 1_000_000 + // * For 8 digits modulo 100_000_000 + // Based on this 9 is max digits. + Ok((otp & 0x7fff_ffff) % (self.digits as u32)) } pub fn do_totp_duration_from_epoch(&self, time: &Duration) -> Result { @@ -173,6 +227,7 @@ impl Totp { TotpAlgo::Sha256 => ProtoTotpAlgo::Sha256, TotpAlgo::Sha512 => ProtoTotpAlgo::Sha512, }, + digits: self.digits.into(), } } @@ -185,6 +240,7 @@ impl Totp { secret: self.secret, step: self.step, algo: TotpAlgo::Sha1, + digits: self.digits, } } } @@ -193,20 +249,27 @@ impl Totp { mod tests { use std::time::Duration; - use crate::credential::totp::{Totp, TotpAlgo, TotpError, TOTP_DEFAULT_STEP}; + use crate::credential::totp::{Totp, TotpAlgo, TotpDigits, TotpError, TOTP_DEFAULT_STEP}; #[test] fn hotp_basic() { - let otp_sha1 = Totp::new(vec![0], 30, TotpAlgo::Sha1); + let otp_sha1 = Totp::new(vec![0], 30, TotpAlgo::Sha1, TotpDigits::Six); assert!(otp_sha1.digest(0) == Ok(328482)); - let otp_sha256 = Totp::new(vec![0], 30, TotpAlgo::Sha256); + let otp_sha256 = Totp::new(vec![0], 30, TotpAlgo::Sha256, TotpDigits::Six); assert!(otp_sha256.digest(0) == Ok(356306)); - let otp_sha512 = Totp::new(vec![0], 30, TotpAlgo::Sha512); + let otp_sha512 = Totp::new(vec![0], 30, TotpAlgo::Sha512, TotpDigits::Six); assert!(otp_sha512.digest(0) == Ok(674061)); } - fn do_test(key: Vec, algo: TotpAlgo, secs: u64, step: u64, expect: Result) { - let otp = Totp::new(key.clone(), step, algo.clone()); + fn do_test( + key: Vec, + algo: TotpAlgo, + secs: u64, + step: u64, + digits: TotpDigits, + expect: Result, + ) { + let otp = Totp::new(key.clone(), step, algo.clone(), digits); let d = Duration::from_secs(secs); let r = otp.do_totp_duration_from_epoch(&d); debug!( @@ -223,13 +286,23 @@ mod tests { TotpAlgo::Sha1, 1585368920, TOTP_DEFAULT_STEP, + TotpDigits::Six, Ok(728926), ); + do_test( + vec![0x00, 0x00, 0x00, 0x00], + TotpAlgo::Sha1, + 1585368920, + TOTP_DEFAULT_STEP, + TotpDigits::Eight, + Ok(74728926), + ); do_test( vec![0x00, 0xaa, 0xbb, 0xcc], TotpAlgo::Sha1, 1585369498, TOTP_DEFAULT_STEP, + TotpDigits::Six, Ok(985074), ); } @@ -241,13 +314,23 @@ mod tests { TotpAlgo::Sha256, 1585369682, TOTP_DEFAULT_STEP, + TotpDigits::Six, Ok(795483), ); + do_test( + vec![0x00, 0x00, 0x00, 0x00], + TotpAlgo::Sha256, + 1585369682, + TOTP_DEFAULT_STEP, + TotpDigits::Eight, + Ok(11795483), + ); do_test( vec![0x00, 0xaa, 0xbb, 0xcc], TotpAlgo::Sha256, 1585369689, TOTP_DEFAULT_STEP, + TotpDigits::Six, Ok(728402), ); } @@ -259,13 +342,23 @@ mod tests { TotpAlgo::Sha512, 1585369775, TOTP_DEFAULT_STEP, + TotpDigits::Six, Ok(587735), ); + do_test( + vec![0x00, 0x00, 0x00, 0x00], + TotpAlgo::Sha512, + 1585369775, + TOTP_DEFAULT_STEP, + TotpDigits::Eight, + Ok(14587735), + ); do_test( vec![0x00, 0xaa, 0xbb, 0xcc], TotpAlgo::Sha512, 1585369780, TOTP_DEFAULT_STEP, + TotpDigits::Six, Ok(952181), ); } @@ -274,7 +367,12 @@ mod tests { fn totp_allow_one_previous() { let key = vec![0x00, 0xaa, 0xbb, 0xcc]; let secs = 1585369780; - let otp = Totp::new(key.clone(), TOTP_DEFAULT_STEP, TotpAlgo::Sha512); + let otp = Totp::new( + key.clone(), + TOTP_DEFAULT_STEP, + TotpAlgo::Sha512, + TotpDigits::Six, + ); let d = Duration::from_secs(secs); // Step assert!(otp.verify(952181, &d)); diff --git a/kanidmd/lib/src/idm/credupdatesession.rs b/kanidmd/lib/src/idm/credupdatesession.rs index 511963514..27ea4b29d 100644 --- a/kanidmd/lib/src/idm/credupdatesession.rs +++ b/kanidmd/lib/src/idm/credupdatesession.rs @@ -2114,7 +2114,7 @@ mod tests { // Check the status has the token. let totp_token: Totp = match c_status.mfaregstate { - MfaRegStateStatus::TotpCheck(secret) => Some(secret.into()), + MfaRegStateStatus::TotpCheck(secret) => Some(secret.try_into().unwrap()), _ => None, } @@ -2208,7 +2208,7 @@ mod tests { // Check the status has the token. let totp_token: Totp = match c_status.mfaregstate { - MfaRegStateStatus::TotpCheck(secret) => Some(secret.into()), + MfaRegStateStatus::TotpCheck(secret) => Some(secret.try_into().unwrap()), _ => None, } @@ -2283,7 +2283,7 @@ mod tests { .expect("Failed to update the primary cred password"); let totp_token: Totp = match c_status.mfaregstate { - MfaRegStateStatus::TotpCheck(secret) => Some(secret.into()), + MfaRegStateStatus::TotpCheck(secret) => Some(secret.try_into().unwrap()), _ => None, } .expect("Unable to retrieve totp token, invalid state."); diff --git a/kanidmd/lib/src/idm/scim.rs b/kanidmd/lib/src/idm/scim.rs index 87382134d..134a530b3 100644 --- a/kanidmd/lib/src/idm/scim.rs +++ b/kanidmd/lib/src/idm/scim.rs @@ -8,6 +8,7 @@ use kanidm_proto::v1::ApiTokenPurpose; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; +use crate::credential::totp::{Totp, TotpAlgo, TotpDigits}; use crate::idm::server::{IdmServerProxyReadTransaction, IdmServerProxyWriteTransaction}; use crate::prelude::*; use crate::value::Session; @@ -876,6 +877,159 @@ impl<'a> IdmServerProxyWriteTransaction<'a> { } Ok(vs) } + (SyntaxType::TotpSecret, true, ScimAttr::MultiComplex(values)) => { + // We have to break down each complex value into a totp. + let mut vs = Vec::with_capacity(values.len()); + for complex in values.iter() { + let external_id = complex + .attrs + .get("external_id") + .ok_or_else(|| { + error!("Invalid scim complex attr - missing required key external_id"); + OperationError::InvalidAttribute(format!( + "missing required key external_id - {}", + scim_attr_name + )) + }) + .and_then(|external_id| match external_id { + ScimSimpleAttr::String(value) => Ok(value.clone()), + _ => { + error!( + "Invalid external_id attribute - must be scim simple string" + ); + Err(OperationError::InvalidAttribute(format!( + "external_id must be scim simple string - {}", + scim_attr_name + ))) + } + })?; + + let secret = complex + .attrs + .get("secret") + .ok_or_else(|| { + error!("Invalid scim complex attr - missing required key secret"); + OperationError::InvalidAttribute(format!( + "missing required key secret - {}", + scim_attr_name + )) + }) + .and_then(|secret| match secret { + ScimSimpleAttr::String(value) => { + Base64UrlSafeData::try_from(value.as_str()) + .map(|b| b.into()) + .map_err(|_| { + error!("Invalid secret attribute - must be base64 string"); + OperationError::InvalidAttribute(format!( + "secret must be base64 string - {}", + scim_attr_name + )) + }) + } + _ => { + error!("Invalid secret attribute - must be scim simple string"); + Err(OperationError::InvalidAttribute(format!( + "secret must be scim simple string - {}", + scim_attr_name + ))) + } + })?; + + let algo = complex.attrs.get("algo") + .ok_or_else(|| { + error!("Invalid scim complex attr - missing required key algo"); + OperationError::InvalidAttribute(format!( + "missing required key algo - {}", + scim_attr_name + )) + }) + .and_then(|algo_str| { + match algo_str { + ScimSimpleAttr::String(value) => { + match value.as_str() { + "sha1" => Ok(TotpAlgo::Sha1), + "sha256" => Ok(TotpAlgo::Sha256), + "sha512" => Ok(TotpAlgo::Sha512), + _ => { + error!("Invalid algo attribute - must be one of sha1, sha256 or sha512"); + Err(OperationError::InvalidAttribute(format!( + "algo must be one of sha1, sha256 or sha512 - {}", + scim_attr_name + ))) + } + } + } + _ => { + error!("Invalid algo attribute - must be scim simple string"); + Err(OperationError::InvalidAttribute(format!( + "algo must be scim simple string - {}", + scim_attr_name + ))) + } + } + })?; + + let step = complex.attrs.get("step").ok_or_else(|| { + error!("Invalid scim complex attr - missing required key step"); + OperationError::InvalidAttribute(format!( + "missing required key step - {}", + scim_attr_name + )) + }).and_then(|step| { + match step { + ScimSimpleAttr::Number(value) => { + match value.as_u64() { + Some(s) if s >= 30 => Ok(s), + _ => + Err(OperationError::InvalidAttribute(format!( + "step must be a positive integer value equal to or greater than 30 - {}", + scim_attr_name + ))), + } + } + _ => { + error!("Invalid step attribute - must be scim simple number"); + Err(OperationError::InvalidAttribute(format!( + "step must be scim simple number - {}", + scim_attr_name + ))) + } + } + })?; + + let digits = complex + .attrs + .get("digits") + .ok_or_else(|| { + error!("Invalid scim complex attr - missing required key digits"); + OperationError::InvalidAttribute(format!( + "missing required key digits - {}", + scim_attr_name + )) + }) + .and_then(|digits| match digits { + ScimSimpleAttr::Number(value) => match value.as_u64() { + Some(6) => Ok(TotpDigits::Six), + Some(8) => Ok(TotpDigits::Eight), + _ => Err(OperationError::InvalidAttribute(format!( + "digits must be a positive integer value of 6 OR 8 - {}", + scim_attr_name + ))), + }, + _ => { + error!("Invalid digits attribute - must be scim simple number"); + Err(OperationError::InvalidAttribute(format!( + "digits must be scim simple number - {}", + scim_attr_name + ))) + } + })?; + + let totp = Totp::new(secret, step, algo, digits); + vs.push(Value::TotpSecret(external_id, totp)) + } + Ok(vs) + } (syn, mv, sa) => { error!(?syn, ?mv, ?sa, "Unsupported scim attribute conversion. This may be a syntax error in your import, or a missing feature in Kanidm."); Err(OperationError::InvalidAttribute(format!( diff --git a/kanidmd/lib/src/plugins/password_import.rs b/kanidmd/lib/src/plugins/cred_import.rs similarity index 57% rename from kanidmd/lib/src/plugins/password_import.rs rename to kanidmd/lib/src/plugins/cred_import.rs index 03b19411a..5eb32fd2d 100644 --- a/kanidmd/lib/src/plugins/password_import.rs +++ b/kanidmd/lib/src/plugins/cred_import.rs @@ -9,9 +9,9 @@ use crate::event::{CreateEvent, ModifyEvent}; use crate::plugins::Plugin; use crate::prelude::*; -pub struct PasswordImport {} +pub struct CredImport {} -impl Plugin for PasswordImport { +impl Plugin for CredImport { fn id() -> &'static str { "plugin_password_import" } @@ -26,44 +26,6 @@ impl Plugin for PasswordImport { cand: &mut Vec>, _ce: &CreateEvent, ) -> Result<(), OperationError> { - /* - cand.iter_mut() - .try_for_each(|e| { - // is there a password we are trying to import? - let vs = match e.pop_ava("password_import") { - Some(vs) => vs, - None => return Ok(()), - }; - // if there are multiple, fail. - if vs.len() > 1 { - return Err(OperationError::Plugin(PluginError::PasswordImport("multiple password_imports specified".to_string()))) - } - - let im_pw = vs.to_utf8_single() - .ok_or_else(|| OperationError::Plugin(PluginError::PasswordImport("password_import has incorrect value type".to_string())))?; - - // convert the import_password to a cred - let pw = Password::try_from(im_pw) - .map_err(|_| OperationError::Plugin(PluginError::PasswordImport("password_import was unable to convert hash format".to_string())))?; - - // does the entry have a primary cred? - match e.get_ava_single_credential("primary_credential") { - Some(_c) => { - Err( - OperationError::Plugin(PluginError::PasswordImport( - "password_import - impossible state, how did you get a credential into a create!?".to_string())) - ) - } - None => { - // just set it then! - let c = Credential::new_from_password(pw); - e.set_ava("primary_credential", - once(Value::new_credential("primary", c))); - Ok(()) - } - } - }) - */ Self::modify_inner(cand) } @@ -90,55 +52,74 @@ impl Plugin for PasswordImport { } } -impl PasswordImport { +impl CredImport { fn modify_inner(cand: &mut [Entry]) -> Result<(), OperationError> { cand.iter_mut().try_for_each(|e| { - // is there a password we are trying to import? - let vs = match e.pop_ava("password_import") { - Some(vs) => vs, - None => return Ok(()), + // PASSWORD IMPORT + if let Some(vs) = e.pop_ava("password_import") { + // if there are multiple, fail. + let im_pw = vs.to_utf8_single().ok_or_else(|| { + OperationError::Plugin(PluginError::CredImport( + "password_import has incorrect value type - should be a single utf8 string" + .to_string(), + )) + })?; + + // convert the import_password_string to a password + let pw = Password::try_from(im_pw).map_err(|_| { + OperationError::Plugin(PluginError::CredImport( + "password_import was unable to convert hash format".to_string(), + )) + })?; + + // does the entry have a primary cred? + match e.get_ava_single_credential("primary_credential") { + Some(c) => { + // This is the major diff to create, we can update in place! + let c = c.update_password(pw); + e.set_ava( + "primary_credential", + once(Value::new_credential("primary", c)), + ); + } + None => { + // just set it then! + let c = Credential::new_from_password(pw); + e.set_ava( + "primary_credential", + once(Value::new_credential("primary", c)), + ); + } + } }; - // if there are multiple, fail. - if vs.len() > 1 { - return Err(OperationError::Plugin(PluginError::PasswordImport( - "multiple password_imports specified".to_string(), - ))); - } - let im_pw = vs.to_utf8_single().ok_or_else(|| { - OperationError::Plugin(PluginError::PasswordImport( - "password_import has incorrect value type".to_string(), - )) - })?; + // TOTP IMPORT + if let Some(vs) = e.pop_ava("totp_import") { + // Get the map. + let totps = vs.as_totp_map().ok_or_else(|| { + OperationError::Plugin(PluginError::CredImport( + "totp_import has incorrect value type - should be a map of totp" + .to_string(), + )) + })?; - // convert the import_password to a cred - let pw = Password::try_from(im_pw).map_err(|_| { - OperationError::Plugin(PluginError::PasswordImport( - "password_import was unable to convert hash format".to_string(), - )) - })?; - - // does the entry have a primary cred? - match e.get_ava_single_credential("primary_credential") { - Some(c) => { - // This is the major diff to create, we can update in place! - let c = c.update_password(pw); + if let Some(c) = e.get_ava_single_credential("primary_credential") { + let c = totps.iter().fold(c.clone(), |acc, (label, totp)| { + acc.append_totp(label.clone(), totp.clone()) + }); e.set_ava( "primary_credential", once(Value::new_credential("primary", c)), ); - Ok(()) - } - None => { - // just set it then! - let c = Credential::new_from_password(pw); - e.set_ava( - "primary_credential", - once(Value::new_credential("primary", c)), - ); - Ok(()) + } else { + return Err(OperationError::Plugin(PluginError::CredImport( + "totp_import can not be used if primary_credential (password) is missing" + .to_string(), + ))); } } + + Ok(()) }) } } @@ -149,6 +130,7 @@ mod tests { use crate::credential::totp::{Totp, TOTP_DEFAULT_STEP}; use crate::credential::{Credential, CredentialType}; use crate::prelude::*; + use kanidm_proto::v1::PluginError; const IMPORT_HASH: &'static str = "pbkdf2_sha256$36000$xIEozuZVAoYm$uW1b35DUKyhvQAf1mBqMvoBDcqSD06juzyO/nmyV0+w="; @@ -285,7 +267,7 @@ mod tests { .expect("failed to get primary cred."); match &c.type_ { CredentialType::PasswordMfa(_pw, totp, webauthn, backup_code) => { - assert!(!totp.is_empty()); + assert!(totp.len() == 1); assert!(webauthn.is_empty()); assert!(backup_code.is_none()); } @@ -294,4 +276,96 @@ mod tests { } ); } + + #[test] + fn test_modify_cred_import_pw_and_multi_totp() { + let euuid = Uuid::new_v4(); + + let ea = entry_init!( + ("class", Value::new_class("account")), + ("class", Value::new_class("person")), + ("name", Value::new_iname("testperson")), + ("description", Value::Utf8("testperson".to_string())), + ("displayname", Value::Utf8("testperson".to_string())), + ("uuid", Value::Uuid(euuid)) + ); + + let preload = vec![ea]; + + let totp_a = Totp::generate_secure(TOTP_DEFAULT_STEP); + let totp_b = Totp::generate_secure(TOTP_DEFAULT_STEP); + + run_modify_test!( + Ok(()), + preload, + filter!(f_eq("name", PartialValue::new_iutf8("testperson"))), + ModifyList::new_list(vec![ + Modify::Present( + AttrString::from("password_import"), + Value::Utf8(IMPORT_HASH.to_string()) + ), + Modify::Present( + AttrString::from("totp_import"), + Value::TotpSecret("a".to_string(), totp_a.clone()) + ), + Modify::Present( + AttrString::from("totp_import"), + Value::TotpSecret("b".to_string(), totp_b.clone()) + ) + ]), + None, + |_| {}, + |qs: &mut QueryServerWriteTransaction| { + let e = qs.internal_search_uuid(euuid).expect("failed to get entry"); + let c = e + .get_ava_single_credential("primary_credential") + .expect("failed to get primary cred."); + match &c.type_ { + CredentialType::PasswordMfa(_pw, totp, webauthn, backup_code) => { + assert!(totp.len() == 2); + assert!(webauthn.is_empty()); + assert!(backup_code.is_none()); + + assert!(totp.get("a") == Some(&totp_a)); + assert!(totp.get("b") == Some(&totp_b)); + } + _ => assert!(false), + }; + } + ); + } + + #[test] + fn test_modify_cred_import_pw_missing_with_totp() { + let euuid = Uuid::new_v4(); + + let ea = entry_init!( + ("class", Value::new_class("account")), + ("class", Value::new_class("person")), + ("name", Value::new_iname("testperson")), + ("description", Value::Utf8("testperson".to_string())), + ("displayname", Value::Utf8("testperson".to_string())), + ("uuid", Value::Uuid(euuid)) + ); + + let preload = vec![ea]; + + let totp_a = Totp::generate_secure(TOTP_DEFAULT_STEP); + + run_modify_test!( + Err(OperationError::Plugin(PluginError::CredImport( + "totp_import can not be used if primary_credential (password) is missing" + .to_string() + ))), + preload, + filter!(f_eq("name", PartialValue::new_iutf8("testperson"))), + ModifyList::new_list(vec![Modify::Present( + AttrString::from("totp_import"), + Value::TotpSecret("a".to_string(), totp_a.clone()) + )]), + None, + |_| {}, + |_| {} + ); + } } diff --git a/kanidmd/lib/src/plugins/mod.rs b/kanidmd/lib/src/plugins/mod.rs index 72ff93072..4d8714905 100644 --- a/kanidmd/lib/src/plugins/mod.rs +++ b/kanidmd/lib/src/plugins/mod.rs @@ -13,12 +13,12 @@ use crate::prelude::*; mod attrunique; mod base; +mod cred_import; mod domain; pub(crate) mod dyngroup; mod gidnumber; mod jwskeygen; mod memberof; -mod password_import; mod protected; mod refint; mod session; @@ -151,7 +151,7 @@ impl Plugins { ce: &CreateEvent, ) -> Result<(), OperationError> { base::Base::pre_create_transform(qs, cand, ce) - .and_then(|_| password_import::PasswordImport::pre_create_transform(qs, cand, ce)) + .and_then(|_| cred_import::CredImport::pre_create_transform(qs, cand, ce)) .and_then(|_| jwskeygen::JwsKeygen::pre_create_transform(qs, cand, ce)) .and_then(|_| gidnumber::GidNumber::pre_create_transform(qs, cand, ce)) .and_then(|_| domain::Domain::pre_create_transform(qs, cand, ce)) @@ -187,7 +187,7 @@ impl Plugins { ) -> Result<(), OperationError> { protected::Protected::pre_modify(qs, cand, me) .and_then(|_| base::Base::pre_modify(qs, cand, me)) - .and_then(|_| password_import::PasswordImport::pre_modify(qs, cand, me)) + .and_then(|_| cred_import::CredImport::pre_modify(qs, cand, me)) .and_then(|_| jwskeygen::JwsKeygen::pre_modify(qs, cand, me)) .and_then(|_| gidnumber::GidNumber::pre_modify(qs, cand, me)) .and_then(|_| domain::Domain::pre_modify(qs, cand, me)) @@ -217,7 +217,7 @@ impl Plugins { ) -> Result<(), OperationError> { protected::Protected::pre_batch_modify(qs, cand, me) .and_then(|_| base::Base::pre_batch_modify(qs, cand, me)) - .and_then(|_| password_import::PasswordImport::pre_batch_modify(qs, cand, me)) + .and_then(|_| cred_import::CredImport::pre_batch_modify(qs, cand, me)) .and_then(|_| jwskeygen::JwsKeygen::pre_batch_modify(qs, cand, me)) .and_then(|_| gidnumber::GidNumber::pre_batch_modify(qs, cand, me)) .and_then(|_| domain::Domain::pre_batch_modify(qs, cand, me)) diff --git a/kanidmd/lib/src/schema.rs b/kanidmd/lib/src/schema.rs index f569019ce..ad4e7119f 100644 --- a/kanidmd/lib/src/schema.rs +++ b/kanidmd/lib/src/schema.rs @@ -203,6 +203,8 @@ impl SchemaAttribute { SyntaxType::JwsKeyEs256 => matches!(v, PartialValue::Iutf8(_)), SyntaxType::JwsKeyRs256 => matches!(v, PartialValue::Iutf8(_)), SyntaxType::UiHint => matches!(v, PartialValue::UiHint(_)), + // Comparing on the label. + SyntaxType::TotpSecret => matches!(v, PartialValue::Utf8(_)), }; if r { Ok(()) @@ -250,6 +252,7 @@ impl SchemaAttribute { SyntaxType::JwsKeyEs256 => matches!(v, Value::JwsKeyEs256(_)), SyntaxType::JwsKeyRs256 => matches!(v, Value::JwsKeyRs256(_)), SyntaxType::UiHint => matches!(v, Value::UiHint(_)), + SyntaxType::TotpSecret => matches!(v, Value::TotpSecret(_, _)), }; if r { Ok(()) @@ -1317,6 +1320,21 @@ impl<'a> SchemaWriteTransaction<'a> { }, ); + self.attributes.insert( + AttrString::from("totp_import"), + SchemaAttribute { + name: AttrString::from("totp_import"), + uuid: UUID_SCHEMA_ATTR_TOTP_IMPORT, + description: String::from("An imported totp secret from an external system."), + multivalue: true, + unique: false, + phantom: true, + sync_allowed: true, + index: vec![], + syntax: SyntaxType::TotpSecret, + }, + ); + // LDAP Masking Phantoms self.attributes.insert( AttrString::from("dn"), diff --git a/kanidmd/lib/src/server/mod.rs b/kanidmd/lib/src/server/mod.rs index ffe1786de..1622a5d54 100644 --- a/kanidmd/lib/src/server/mod.rs +++ b/kanidmd/lib/src/server/mod.rs @@ -499,6 +499,7 @@ pub trait QueryServerTransaction<'a> { SyntaxType::UiHint => UiHint::from_str(value) .map(Value::UiHint) .map_err(|()| OperationError::InvalidAttribute("Invalid uihint syntax".to_string())), + SyntaxType::TotpSecret => Err(OperationError::InvalidAttribute("TotpSecret Values can not be supplied through modification".to_string())), } } None => { @@ -520,7 +521,9 @@ pub trait QueryServerTransaction<'a> { match schema.get_attributes().get(attr) { Some(schema_a) => { match schema_a.syntax { - SyntaxType::Utf8String => Ok(PartialValue::new_utf8(value.to_string())), + SyntaxType::Utf8String | SyntaxType::TotpSecret => { + Ok(PartialValue::new_utf8(value.to_string())) + } SyntaxType::Utf8StringInsensitive | SyntaxType::JwsKeyEs256 | SyntaxType::JwsKeyRs256 => Ok(PartialValue::new_iutf8(value)), diff --git a/kanidmd/lib/src/value.rs b/kanidmd/lib/src/value.rs index c1c5e8f59..c3224fa0a 100644 --- a/kanidmd/lib/src/value.rs +++ b/kanidmd/lib/src/value.rs @@ -23,7 +23,7 @@ use uuid::Uuid; use webauthn_rs::prelude::{DeviceKey as DeviceKeyV4, Passkey as PasskeyV4}; use crate::be::dbentry::DbIdentSpn; -use crate::credential::Credential; +use crate::credential::{totp::Totp, Credential}; use crate::repl::cid::Cid; use crate::server::identity::{AccessScope, IdentityId}; @@ -198,6 +198,7 @@ pub enum SyntaxType { JwsKeyRs256 = 27, Oauth2Session = 28, UiHint = 29, + TotpSecret = 30, } impl TryFrom<&str> for SyntaxType { @@ -237,6 +238,7 @@ impl TryFrom<&str> for SyntaxType { "JWS_KEY_RS256" => Ok(SyntaxType::JwsKeyRs256), "OAUTH2SESSION" => Ok(SyntaxType::Oauth2Session), "UIHINT" => Ok(SyntaxType::UiHint), + "TOTPSECRET" => Ok(SyntaxType::TotpSecret), _ => Err(()), } } @@ -275,6 +277,7 @@ impl fmt::Display for SyntaxType { SyntaxType::JwsKeyRs256 => "JWS_KEY_RS256", SyntaxType::Oauth2Session => "OAUTH2SESSION", SyntaxType::UiHint => "UIHINT", + SyntaxType::TotpSecret => "TOTPSECRET", }) } } @@ -321,12 +324,11 @@ pub enum PartialValue { RestrictedString(String), IntentToken(String), UiHint(UiHint), - Passkey(Uuid), DeviceKey(Uuid), - TrustedDeviceEnrollment(Uuid), Session(Uuid), + // The label, if any. } impl From for PartialValue { @@ -773,6 +775,8 @@ pub enum Value { JwsKeyEs256(JwsSigner), JwsKeyRs256(JwsSigner), UiHint(UiHint), + + TotpSecret(String, Totp), } impl PartialEq for Value { diff --git a/kanidmd/lib/src/valueset/mod.rs b/kanidmd/lib/src/valueset/mod.rs index aaa88f88a..1735d4f2b 100644 --- a/kanidmd/lib/src/valueset/mod.rs +++ b/kanidmd/lib/src/valueset/mod.rs @@ -12,7 +12,7 @@ use webauthn_rs::prelude::DeviceKey as DeviceKeyV4; use webauthn_rs::prelude::Passkey as PasskeyV4; use crate::be::dbvalue::DbValueSetV2; -use crate::credential::Credential; +use crate::credential::{totp::Totp, Credential}; use crate::prelude::*; use crate::repl::cid::Cid; use crate::schema::SchemaAttribute; @@ -37,6 +37,7 @@ mod session; mod spn; mod ssh; mod syntax; +mod totp; mod uihint; mod uint32; mod url; @@ -62,6 +63,7 @@ pub use self::session::{ValueSetOauth2Session, ValueSetSession}; pub use self::spn::ValueSetSpn; pub use self::ssh::ValueSetSshKey; pub use self::syntax::ValueSetSyntax; +pub use self::totp::ValueSetTotpSecret; pub use self::uihint::ValueSetUiHint; pub use self::uint32::ValueSetUint32; pub use self::url::ValueSetUrl; @@ -283,6 +285,11 @@ pub trait ValueSetT: std::fmt::Debug + DynClone { None } + fn as_totp_map(&self) -> Option<&BTreeMap> { + debug_assert!(false); + None + } + fn as_emailaddress_set(&self) -> Option<(&String, &BTreeSet)> { debug_assert!(false); None @@ -563,6 +570,7 @@ pub fn from_result_value_iter( Value::PhoneNumber(_, _) | Value::Passkey(_, _, _) | Value::DeviceKey(_, _, _) + | Value::TotpSecret(_, _) | Value::TrustedDeviceEnrollment(_) | Value::Session(_, _) | Value::Oauth2Session(_, _) @@ -623,6 +631,7 @@ pub fn from_value_iter(mut iter: impl Iterator) -> Result ValueSetSession::new(u, m), Value::Oauth2Session(u, m) => ValueSetOauth2Session::new(u, m), Value::UiHint(u) => ValueSetUiHint::new(u), + Value::TotpSecret(l, t) => ValueSetTotpSecret::new(l, t), Value::PhoneNumber(_, _) | Value::TrustedDeviceEnrollment(_) => { debug_assert!(false); return Err(OperationError::InvalidValueState); @@ -670,6 +679,7 @@ pub fn from_db_valueset_v2(dbvs: DbValueSetV2) -> Result ValueSetJwsKeyEs256::from_dbvs2(&set), DbValueSetV2::JwsKeyRs256(set) => ValueSetJwsKeyEs256::from_dbvs2(&set), DbValueSetV2::UiHint(set) => ValueSetUiHint::from_dbvs2(set), + DbValueSetV2::TotpSecret(set) => ValueSetTotpSecret::from_dbvs2(set), DbValueSetV2::PhoneNumber(_, _) | DbValueSetV2::TrustedDeviceEnrollment(_) => { debug_assert!(false); Err(OperationError::InvalidValueState) diff --git a/kanidmd/lib/src/valueset/totp.rs b/kanidmd/lib/src/valueset/totp.rs new file mode 100644 index 000000000..4439c4ffc --- /dev/null +++ b/kanidmd/lib/src/valueset/totp.rs @@ -0,0 +1,164 @@ +use std::collections::btree_map::Entry as BTreeEntry; +use std::collections::BTreeMap; + +use crate::credential::totp::Totp; +use crate::prelude::*; + +use crate::be::dbvalue::DbTotpV1; +use crate::schema::SchemaAttribute; +use crate::valueset::{DbValueSetV2, ValueSet}; + +#[derive(Debug, Clone)] +pub struct ValueSetTotpSecret { + map: BTreeMap, +} + +impl ValueSetTotpSecret { + pub fn new(l: String, t: Totp) -> Box { + let mut map = BTreeMap::new(); + map.insert(l, t); + Box::new(ValueSetTotpSecret { map }) + } + + pub fn push(&mut self, l: String, t: Totp) -> bool { + self.map.insert(l, t).is_none() + } + + pub fn from_dbvs2(data: Vec<(String, DbTotpV1)>) -> Result { + let map = data + .into_iter() + .map(|(l, data)| { + Totp::try_from(data) + .map_err(|()| OperationError::InvalidValueState) + .map(|t| (l, t)) + }) + .collect::>()?; + Ok(Box::new(ValueSetTotpSecret { map })) + } + + // We need to allow this, because rust doesn't allow us to impl FromIterator on foreign + // types, and tuples are always foreign. + #[allow(clippy::should_implement_trait)] + pub fn from_iter(iter: T) -> Option> + where + T: IntoIterator, + { + let map = iter.into_iter().collect(); + Some(Box::new(ValueSetTotpSecret { map })) + } +} + +impl ValueSetT for ValueSetTotpSecret { + fn insert_checked(&mut self, value: Value) -> Result { + match value { + Value::TotpSecret(l, t) => { + if let BTreeEntry::Vacant(e) = self.map.entry(l) { + e.insert(t); + Ok(true) + } else { + Ok(false) + } + } + _ => Err(OperationError::InvalidValueState), + } + } + + fn clear(&mut self) { + self.map.clear(); + } + + fn remove(&mut self, pv: &PartialValue) -> bool { + match pv { + PartialValue::Utf8(l) => self.map.remove(l.as_str()).is_some(), + _ => false, + } + } + + fn contains(&self, pv: &PartialValue) -> bool { + match pv { + PartialValue::Utf8(l) => self.map.contains_key(l.as_str()), + _ => false, + } + } + + fn substring(&self, _pv: &PartialValue) -> bool { + false + } + + fn lessthan(&self, _pv: &PartialValue) -> bool { + false + } + + fn len(&self) -> usize { + self.map.len() + } + + fn generate_idx_eq_keys(&self) -> Vec { + self.map.keys().cloned().collect() + } + + fn syntax(&self) -> SyntaxType { + SyntaxType::TotpSecret + } + + fn validate(&self, _schema_attr: &SchemaAttribute) -> bool { + true + } + + fn to_proto_string_clone_iter(&self) -> Box + '_> { + Box::new(self.map.keys().cloned()) + } + + fn to_db_valueset_v2(&self) -> DbValueSetV2 { + DbValueSetV2::TotpSecret( + self.map + .iter() + .map(|(label, totp)| (label.clone(), totp.to_dbtotpv1())) + .collect(), + ) + } + + fn to_partialvalue_iter(&self) -> Box + '_> { + Box::new(self.map.keys().cloned().map(PartialValue::Utf8)) + } + + fn to_value_iter(&self) -> Box + '_> { + Box::new( + self.map + .iter() + .map(|(l, t)| Value::TotpSecret(l.clone(), t.clone())), + ) + } + + fn equal(&self, _other: &ValueSet) -> bool { + // Looks like we may not need this? + /* + if let Some(other) = other.as_credential_map() { + &self.map == other + } else { + // debug_assert!(false); + false + } + */ + debug_assert!(false); + false + } + + fn merge(&mut self, _other: &ValueSet) -> Result<(), OperationError> { + /* + if let Some(b) = other.as_credential_map() { + mergemaps!(self.map, b) + } else { + debug_assert!(false); + Err(OperationError::InvalidValueState) + } + */ + + debug_assert!(false); + Err(OperationError::InvalidValueState) + } + + fn as_totp_map(&self) -> Option<&BTreeMap> { + Some(&self.map) + } +} diff --git a/kanidmd/testkit/tests/proto_v1_test.rs b/kanidmd/testkit/tests/proto_v1_test.rs index c562fece2..73a06d336 100644 --- a/kanidmd/testkit/tests/proto_v1_test.rs +++ b/kanidmd/testkit/tests/proto_v1_test.rs @@ -1014,7 +1014,7 @@ async fn test_server_credential_update_session_totp_pw(rsclient: KanidmClient) { // Extract the totp from the status, and set it back let totp: Totp = match status.mfaregstate { - CURegState::TotpCheck(totp_secret) => totp_secret.into(), + CURegState::TotpCheck(totp_secret) => totp_secret.try_into().unwrap(), _ => unreachable!(), };