1121 SCIM import totp freeipa (#1328)

This commit is contained in:
Firstyear 2023-01-19 17:14:38 +10:00 committed by GitHub
parent 84fc7d0bac
commit 00cf5f4e15
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1009 additions and 190 deletions

View file

@ -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<LdapSyncReplEntry>,
entry_config_map: &HashMap<Uuid, EntryConfig>,
) -> Result<ScimSyncRequest, ()> {
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<Vec<ScimEntry>, ()> {
// 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<String, Vec<_>> = 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::<Result<Vec<_>, _>>();
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::<Result<Vec<_>, _>>();
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<Option<ScimEntry>, ()> {
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<ScimTotp> {
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();

View file

@ -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<ScimComplexAttr> 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<u32>,
pub password_import: Option<String>,
pub totp_import: Vec<ScimTotp>,
pub login_shell: Option<String>,
}
@ -64,6 +106,7 @@ impl Into<ScimEntry> for ScimSyncPerson {
display_name,
gidnumber,
password_import,
totp_import,
login_shell,
} = self;
@ -86,6 +129,7 @@ impl Into<ScimEntry> 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 {

View file

@ -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<u8>,
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);

View file

@ -73,6 +73,8 @@ pub struct DbTotpV1 {
pub step: u64,
#[serde(rename = "a")]
pub algo: DbTotpAlgoV1,
#[serde(rename = "d", default)]
pub digits: Option<u8>,
}
impl std::fmt::Debug for DbTotpV1 {
@ -576,6 +578,8 @@ pub enum DbValueSetV2 {
Oauth2Session(Vec<DbValueOauth2Session>),
#[serde(rename = "UH")]
UiHint(Vec<u16>),
#[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(),
}
}

View file

@ -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,
}
}

View file

@ -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.

View file

@ -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<u8> for TotpDigits {
type Error = ();
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
6 => Ok(TotpDigits::Six),
8 => Ok(TotpDigits::Six),
_ => Err(()),
}
}
}
#[allow(clippy::from_over_into)]
impl Into<u8> 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<u8>,
pub(crate) step: u64,
algo: TotpAlgo,
digits: TotpDigits,
}
impl TryFrom<DbTotpV1> for Totp {
@ -73,17 +103,23 @@ impl TryFrom<DbTotpV1> 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<ProtoTotp> for Totp {
fn from(value: ProtoTotp) -> Self {
Totp {
impl TryFrom<ProtoTotp> for Totp {
type Error = ();
fn try_from(value: ProtoTotp) -> Result<Self, Self::Error> {
Ok(Totp {
secret: value.secret,
algo: match value.algo {
ProtoTotpAlgo::Sha1 => TotpAlgo::Sha1,
@ -91,13 +127,19 @@ impl From<ProtoTotp> for Totp {
ProtoTotpAlgo::Sha512 => TotpAlgo::Sha512,
},
step: value.step,
}
digits: TotpDigits::try_from(value.digits)?,
})
}
}
impl Totp {
pub fn new(secret: Vec<u8>, step: u64, algo: TotpAlgo) -> Self {
Totp { secret, step, algo }
pub fn new(secret: Vec<u8>, 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<u8> = (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<u32, TotpError> {
@ -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<u8>, algo: TotpAlgo, secs: u64, step: u64, expect: Result<u32, TotpError>) {
let otp = Totp::new(key.clone(), step, algo.clone());
fn do_test(
key: Vec<u8>,
algo: TotpAlgo,
secs: u64,
step: u64,
digits: TotpDigits,
expect: Result<u32, TotpError>,
) {
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));

View file

@ -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.");

View file

@ -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!(

View file

@ -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<Entry<EntryInvalid, EntryNew>>,
_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<T: Clone>(cand: &mut [Entry<EntryInvalid, T>]) -> 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,
|_| {},
|_| {}
);
}
}

View file

@ -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))

View file

@ -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"),

View file

@ -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)),

View file

@ -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<SyntaxType> for PartialValue {
@ -773,6 +775,8 @@ pub enum Value {
JwsKeyEs256(JwsSigner),
JwsKeyRs256(JwsSigner),
UiHint(UiHint),
TotpSecret(String, Totp),
}
impl PartialEq for Value {

View file

@ -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<String, Totp>> {
debug_assert!(false);
None
}
fn as_emailaddress_set(&self) -> Option<(&String, &BTreeSet<String>)> {
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<Item = Value>) -> Result<ValueSet
Value::Session(u, m) => 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<ValueSet, OperationErro
DbValueSetV2::JwsKeyEs256(set) => 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)

View file

@ -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<String, Totp>,
}
impl ValueSetTotpSecret {
pub fn new(l: String, t: Totp) -> Box<Self> {
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<ValueSet, OperationError> {
let map = data
.into_iter()
.map(|(l, data)| {
Totp::try_from(data)
.map_err(|()| OperationError::InvalidValueState)
.map(|t| (l, t))
})
.collect::<Result<_, _>>()?;
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<T>(iter: T) -> Option<Box<Self>>
where
T: IntoIterator<Item = (String, Totp)>,
{
let map = iter.into_iter().collect();
Some(Box::new(ValueSetTotpSecret { map }))
}
}
impl ValueSetT for ValueSetTotpSecret {
fn insert_checked(&mut self, value: Value) -> Result<bool, OperationError> {
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<String> {
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<dyn Iterator<Item = String> + '_> {
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<dyn Iterator<Item = PartialValue> + '_> {
Box::new(self.map.keys().cloned().map(PartialValue::Utf8))
}
fn to_value_iter(&self) -> Box<dyn Iterator<Item = Value> + '_> {
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<String, Totp>> {
Some(&self.map)
}
}

View file

@ -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!(),
};