diff --git a/Cargo.lock b/Cargo.lock index 30b6ed7b2..7f594558c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2335,6 +2335,7 @@ dependencies = [ "futures", "futures-util", "hashbrown", + "hex", "idlset", "kanidm_proto", "lazy_static", diff --git a/Cargo.toml b/Cargo.toml index 2bdb2884a..86524c772 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,7 @@ futures-util = "^0.3.21" gloo = "^0.8.0" gloo-net = "0.2.4" hashbrown = { version = "0.12.3", features = ["serde", "inline-more", "ahash"] } +hex = "^0.4.3" http-types = "^2.12.0" idlset = "^0.2.4" # idlset = { path = "../idlset" } diff --git a/iam_migrations/freeipa/notes.txt b/iam_migrations/freeipa/notes.txt new file mode 100644 index 000000000..91c66177e --- /dev/null +++ b/iam_migrations/freeipa/notes.txt @@ -0,0 +1,25 @@ + + +OTP tokens are stored in *related* entries: + +Not sure how you would determine this in an import? + +token key is base64 standard no pad + +entryuuid: 94dbeb81-45f8-11ed-a50d-919b4b1a5ec0 +syncstate: Add +dn: ipatokenuniqueid=16b6d328-cead-4b71-af5e-0a7b8622b204,cn=otp,dc=dev,dc=blackhats,dc=net,dc=au +ipatokenOTPalgorithm: sha1 +ipatokenOTPdigits: 6 +ipatokenOTPkey: iWM6XXyQt9GZV/yiPHYW/w4qjpaIJ8IlF6DlFndHe+C/zu4 +ipatokenOwner: uid=testuser,cn=users,cn=accounts,dc=dev,dc=blackhats,dc=net,dc=au +ipatokenTOTPclockOffset: 0 +ipatokenTOTPtimeStep: 30 +ipatokenUniqueID: 16b6d328-cead-4b71-af5e-0a7b8622b204 +objectClass: ipatoken +objectClass: ipatokentotp +objectClass: top + + + + diff --git a/kanidmd/lib/Cargo.toml b/kanidmd/lib/Cargo.toml index 6225dc7d1..ec12533ae 100644 --- a/kanidmd/lib/Cargo.toml +++ b/kanidmd/lib/Cargo.toml @@ -32,6 +32,7 @@ filetime.workspace = true futures.workspace = true futures-util.workspace = true hashbrown.workspace = true +hex.workspace = true idlset.workspace = true kanidm_proto.workspace = true lazy_static.workspace = true diff --git a/kanidmd/lib/src/be/dbvalue.rs b/kanidmd/lib/src/be/dbvalue.rs index c2c681750..cfa92a0ec 100644 --- a/kanidmd/lib/src/be/dbvalue.rs +++ b/kanidmd/lib/src/be/dbvalue.rs @@ -21,16 +21,23 @@ pub struct DbCidV1 { } #[derive(Serialize, Deserialize)] +#[allow(non_camel_case_types)] pub enum DbPasswordV1 { PBKDF2(usize, Vec, Vec), + PBKDF2_SHA1(usize, Vec, Vec), + PBKDF2_SHA512(usize, Vec, Vec), SSHA512(Vec, Vec), + NT_MD4(Vec), } impl fmt::Debug for DbPasswordV1 { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { DbPasswordV1::PBKDF2(_, _, _) => write!(f, "PBKDF2"), + DbPasswordV1::PBKDF2_SHA1(_, _, _) => write!(f, "PBKDF2_SHA1"), + DbPasswordV1::PBKDF2_SHA512(_, _, _) => write!(f, "PBKDF2_SHA512"), DbPasswordV1::SSHA512(_, _) => write!(f, "SSHA512"), + DbPasswordV1::NT_MD4(_) => write!(f, "NT_MD4"), } } } diff --git a/kanidmd/lib/src/credential/mod.rs b/kanidmd/lib/src/credential/mod.rs index b84d45425..4b93d6ab3 100644 --- a/kanidmd/lib/src/credential/mod.rs +++ b/kanidmd/lib/src/credential/mod.rs @@ -3,7 +3,8 @@ use std::time::{Duration, Instant}; use hashbrown::{HashMap as Map, HashSet}; use kanidm_proto::v1::{BackupCodesView, CredentialDetail, CredentialDetailType, OperationError}; -use openssl::hash::MessageDigest; +use openssl::hash::{self, MessageDigest}; +use openssl::nid::Nid; use openssl::pkcs5::pbkdf2_hmac; use openssl::sha::Sha512; use rand::prelude::*; @@ -24,9 +25,16 @@ use crate::credential::totp::Totp; // NIST 800-63.b salt should be 112 bits -> 14 8u8. // I choose tinfoil hat though ... const PBKDF2_SALT_LEN: usize = 24; + +const PBKDF2_MIN_NIST_SALT_LEN: usize = 14; + +// Min number of rounds for a pbkdf2 +pub const PBKDF2_MIN_NIST_COST: usize = 10000; + // 64 * u8 -> 512 bits of out. const PBKDF2_KEY_LEN: usize = 64; -const PBKDF2_IMPORT_MIN_LEN: usize = 32; +const PBKDF2_MIN_NIST_KEY_LEN: usize = 32; +const PBKDF2_SHA1_MIN_KEY_LEN: usize = 19; const DS_SSHA512_SALT_LEN: usize = 8; const DS_SSHA512_HASH_LEN: usize = 64; @@ -46,11 +54,20 @@ pub enum Policy { // I don't really feel like adding in so many restrictions, so I'll use // pbkdf2 in openssl because it doesn't have the same limits. #[derive(Clone, Debug, PartialEq)] +#[allow(non_camel_case_types)] enum Kdf { // cost, salt, hash PBKDF2(usize, Vec, Vec), + + // Imported types, will upgrade to the above. + // cost, salt, hash + PBKDF2_SHA1(usize, Vec, Vec), + // cost, salt, hash + PBKDF2_SHA512(usize, Vec, Vec), // salt hash SSHA512(Vec, Vec), + // hash + NT_MD4(Vec), } #[derive(Clone, Debug, PartialEq)] @@ -66,13 +83,45 @@ impl TryFrom for Password { DbPasswordV1::PBKDF2(c, s, h) => Ok(Password { material: Kdf::PBKDF2(c, s, h), }), + DbPasswordV1::PBKDF2_SHA1(c, s, h) => Ok(Password { + material: Kdf::PBKDF2_SHA1(c, s, h), + }), + DbPasswordV1::PBKDF2_SHA512(c, s, h) => Ok(Password { + material: Kdf::PBKDF2_SHA512(c, s, h), + }), DbPasswordV1::SSHA512(s, h) => Ok(Password { material: Kdf::SSHA512(s, h), }), + DbPasswordV1::NT_MD4(h) => Ok(Password { + material: Kdf::NT_MD4(h), + }), } } } +// OpenLDAP based their PBKDF2 implementation on passlib from python, that uses a +// non-standard base64 altchar set and padding that is not supported by +// anything else in the world. To manage this, we only ever encode to base64 with +// no pad but we have to remap ab64 to b64. This function allows b64 standard with +// padding to pass, and remaps ab64 to b64 standard with padding. +macro_rules! ab64_to_b64 { + ($ab64:expr) => {{ + let mut s = $ab64.replace(".", "+"); + match s.len() & 3 { + 0 => { + // Do nothing + } + 1 => { + // One is invalid, do nothing, we'll error in base64 + } + 2 => s.push_str("=="), + 3 => s.push_str("="), + _ => unreachable!(), + } + s + }}; +} + impl TryFrom<&str> for Password { type Error = (); @@ -93,7 +142,7 @@ impl TryFrom<&str> for Password { let c = cost.parse::().map_err(|_| ())?; let s: Vec<_> = salt.as_bytes().to_vec(); let h = base64::decode(hash).map_err(|_| ())?; - if h.len() < PBKDF2_IMPORT_MIN_LEN { + if h.len() < PBKDF2_MIN_NIST_KEY_LEN { return Err(()); } return Ok(Password { @@ -104,6 +153,34 @@ impl TryFrom<&str> for Password { } } + if value.starts_with("ipaNTHash: ") { + let nt_md4 = match value.split_once(" ") { + Some((_, v)) => v, + None => { + unreachable!(); + } + }; + + let h = base64::decode_config(nt_md4, base64::STANDARD_NO_PAD).map_err(|_| ())?; + return Ok(Password { + material: Kdf::NT_MD4(h), + }); + } + + if value.starts_with("sambaNTPassword: ") { + let nt_md4 = match value.split_once(" ") { + Some((_, v)) => v, + None => { + unreachable!(); + } + }; + + let h = hex::decode(nt_md4).map_err(|_| ())?; + return Ok(Password { + material: Kdf::NT_MD4(h), + }); + } + // Test 389ds formats if let Some(ds_ssha512) = value.strip_prefix("{SSHA512}") { let sh = base64::decode(ds_ssha512).map_err(|_| ())?; @@ -116,6 +193,78 @@ impl TryFrom<&str> for Password { }); } + // Test for OpenLDAP formats + if value.starts_with("{PBKDF2}") + || value.starts_with("{PBKDF2-SHA1}") + || value.starts_with("{PBKDF2-SHA256}") + || value.starts_with("{PBKDF2-SHA512}") + { + let ol_pbkdf2 = match value.split_once("}") { + Some((_, v)) => v, + None => { + unreachable!(); + } + }; + + let ol_pbkdf: Vec<&str> = ol_pbkdf2.split('$').collect(); + if ol_pbkdf.len() == 3 { + let cost = ol_pbkdf[0]; + let salt = ol_pbkdf[1]; + let hash = ol_pbkdf[2]; + + let c = cost.parse::().map_err(|_| ())?; + + let s = ab64_to_b64!(salt); + let s = + base64::decode_config(&s, base64::STANDARD.decode_allow_trailing_bits(true)) + .map_err(|e| { + error!(?e, "Invalid base64 in oldap pbkdf2-sha1"); + })?; + + let h = ab64_to_b64!(hash); + let h = + base64::decode_config(&h, base64::STANDARD.decode_allow_trailing_bits(true)) + .map_err(|e| { + error!(?e, "Invalid base64 in oldap pbkdf2-sha1"); + })?; + + // This is just sha1 in a trenchcoat. + if value.strip_prefix("{PBKDF2}").is_some() + || value.strip_prefix("{PBKDF2-SHA1}").is_some() + { + if h.len() < PBKDF2_SHA1_MIN_KEY_LEN { + return Err(()); + } + return Ok(Password { + material: Kdf::PBKDF2_SHA1(c, s, h), + }); + } + + if value.strip_prefix("{PBKDF2-SHA256}").is_some() { + if h.len() < PBKDF2_MIN_NIST_KEY_LEN { + return Err(()); + } + return Ok(Password { + material: Kdf::PBKDF2(c, s, h), + }); + } + + if value.strip_prefix("{PBKDF2-SHA512}").is_some() { + if h.len() < PBKDF2_MIN_NIST_KEY_LEN { + return Err(()); + } + return Ok(Password { + material: Kdf::PBKDF2_SHA512(c, s, h), + }); + } + + // Should be no way to get here! + unreachable!(); + } else { + warn!("oldap pbkdf2 found but invalid number of elements?"); + } + } + // Nothing matched to this point. Err(()) } @@ -173,7 +322,7 @@ impl Password { // We have to get the number of bits to derive from our stored hash // as some imported hash types may have variable lengths let key_len = key.len(); - debug_assert!(key_len >= PBKDF2_IMPORT_MIN_LEN); + debug_assert!(key_len >= PBKDF2_MIN_NIST_KEY_LEN); let mut chal_key: Vec = (0..key_len).map(|_| 0).collect(); pbkdf2_hmac( cleartext.as_bytes(), @@ -188,6 +337,40 @@ impl Password { &chal_key == key }) } + Kdf::PBKDF2_SHA1(cost, salt, key) => { + let key_len = key.len(); + debug_assert!(key_len >= PBKDF2_SHA1_MIN_KEY_LEN); + let mut chal_key: Vec = (0..key_len).map(|_| 0).collect(); + pbkdf2_hmac( + cleartext.as_bytes(), + salt.as_slice(), + *cost, + MessageDigest::sha1(), + chal_key.as_mut_slice(), + ) + .map_err(|_| OperationError::CryptographyError) + .map(|()| { + // Actually compare the outputs. + &chal_key == key + }) + } + Kdf::PBKDF2_SHA512(cost, salt, key) => { + let key_len = key.len(); + debug_assert!(key_len >= PBKDF2_MIN_NIST_KEY_LEN); + let mut chal_key: Vec = (0..key_len).map(|_| 0).collect(); + pbkdf2_hmac( + cleartext.as_bytes(), + salt.as_slice(), + *cost, + MessageDigest::sha512(), + chal_key.as_mut_slice(), + ) + .map_err(|_| OperationError::CryptographyError) + .map(|()| { + // Actually compare the outputs. + &chal_key == key + }) + } Kdf::SSHA512(salt, key) => { let mut hasher = Sha512::new(); hasher.update(cleartext.as_bytes()); @@ -195,6 +378,23 @@ impl Password { let r = hasher.finish(); Ok(key == &(r.to_vec())) } + Kdf::NT_MD4(key) => { + // We need to get the cleartext to utf16le for reasons. + let clear_utf16le: Vec = cleartext + .encode_utf16() + .map(|c| c.to_le_bytes()) + .flat_map(|i| i.into_iter()) + .collect(); + + let dgst = MessageDigest::from_nid(Nid::MD4).ok_or_else(|| { + error!("Unable to access MD4 - fips mode enabled?"); + OperationError::CryptographyError + })?; + + hash::hash(dgst, &clear_utf16le) + .map_err(|_| OperationError::CryptographyError) + .map(|chal_key| chal_key.as_ref() == key) + } } } @@ -203,12 +403,26 @@ impl Password { Kdf::PBKDF2(cost, salt, hash) => { DbPasswordV1::PBKDF2(*cost, salt.clone(), hash.clone()) } + Kdf::PBKDF2_SHA1(cost, salt, hash) => { + DbPasswordV1::PBKDF2_SHA1(*cost, salt.clone(), hash.clone()) + } + Kdf::PBKDF2_SHA512(cost, salt, hash) => { + DbPasswordV1::PBKDF2_SHA512(*cost, salt.clone(), hash.clone()) + } Kdf::SSHA512(salt, hash) => DbPasswordV1::SSHA512(salt.clone(), hash.clone()), + Kdf::NT_MD4(hash) => DbPasswordV1::NT_MD4(hash.clone()), } } pub fn requires_upgrade(&self) -> bool { - !matches!(&self.material, Kdf::PBKDF2(_, _, _)) + match &self.material { + Kdf::PBKDF2_SHA512(cost, salt, hash) | Kdf::PBKDF2(cost, salt, hash) => { + *cost < PBKDF2_MIN_NIST_COST + || salt.len() < PBKDF2_MIN_NIST_SALT_LEN + || hash.len() < PBKDF2_MIN_NIST_KEY_LEN + } + Kdf::PBKDF2_SHA1(_, _, _) | Kdf::SSHA512(_, _) | Kdf::NT_MD4(_) => true, + } } } @@ -930,4 +1144,75 @@ mod tests { assert!(r.requires_upgrade()); assert!(r.verify(password).unwrap_or(false)); } + + // Can be generated with: + // slappasswd -s password -o module-load=/usr/lib64/openldap/pw-argon2.so -h {ARGON2} + + #[test] + fn test_password_from_openldap_pkbdf2() { + let im_pw = "{PBKDF2}10000$IlfapjA351LuDSwYC0IQ8Q$saHqQTuYnjJN/tmAndT.8mJt.6w"; + let password = "password"; + let r = Password::try_from(im_pw).expect("Failed to parse"); + assert!(r.requires_upgrade()); + assert!(r.verify(password).unwrap_or(false)); + } + + #[test] + fn test_password_from_openldap_pkbdf2_sha1() { + let im_pw = "{PBKDF2-SHA1}10000$ZBEH6B07rgQpJSikyvMU2w$TAA03a5IYkz1QlPsbJKvUsTqNV"; + let password = "password"; + let r = Password::try_from(im_pw).expect("Failed to parse"); + assert!(r.requires_upgrade()); + assert!(r.verify(password).unwrap_or(false)); + } + + #[test] + fn test_password_from_openldap_pkbdf2_sha256() { + let im_pw = "{PBKDF2-SHA256}10000$henZGfPWw79Cs8ORDeVNrQ$1dTJy73v6n3bnTmTZFghxHXHLsAzKaAy8SksDfZBPIw"; + let password = "password"; + let r = Password::try_from(im_pw).expect("Failed to parse"); + assert!(!r.requires_upgrade()); + assert!(r.verify(password).unwrap_or(false)); + } + + #[test] + fn test_password_from_openldap_pkbdf2_sha512() { + let im_pw = "{PBKDF2-SHA512}10000$Je1Uw19Bfv5lArzZ6V3EPw$g4T/1sqBUYWl9o93MVnyQ/8zKGSkPbKaXXsT8WmysXQJhWy8MRP2JFudSL.N9RklQYgDPxPjnfum/F2f/TrppA"; + let password = "password"; + let r = Password::try_from(im_pw).expect("Failed to parse"); + assert!(!r.requires_upgrade()); + assert!(r.verify(password).unwrap_or(false)); + } + + /* + // Not supported in openssl, may need an external crate. + #[test] + fn test_password_from_openldap_argon2() { + let im_pw = "{ARGON2}$argon2id$v=19$m=65536,t=2,p=1$IyTQMsvzB2JHDiWx8fq7Ew$VhYOA7AL0kbRXI5g2kOyyp8St1epkNj7WZyUY4pAIQQ" + let password = "password"; + let r = Password::try_from(im_pw).expect("Failed to parse"); + assert!(r.requires_upgrade()); + assert!(r.verify(password).unwrap_or(false)); + } + */ + + #[test] + fn test_password_from_ipa_nt_hash() { + // Base64 no pad + let im_pw = "ipaNTHash: iEb36u6PsRetBr3YMLdYbA"; + let password = "password"; + let r = Password::try_from(im_pw).expect("Failed to parse"); + assert!(r.requires_upgrade()); + assert!(r.verify(password).unwrap_or(false)); + } + + #[test] + fn test_password_from_samba_nt_hash() { + // Base64 no pad + let im_pw = "sambaNTPassword: 8846F7EAEE8FB117AD06BDD830B7586C"; + let password = "password"; + let r = Password::try_from(im_pw).expect("Failed to parse"); + assert!(r.requires_upgrade()); + assert!(r.verify(password).unwrap_or(false)); + } } diff --git a/kanidmd/lib/src/credential/policy.rs b/kanidmd/lib/src/credential/policy.rs index c0d34f00e..b4b13eff8 100644 --- a/kanidmd/lib/src/credential/policy.rs +++ b/kanidmd/lib/src/credential/policy.rs @@ -1,8 +1,6 @@ use std::time::Duration; -use super::Password; - -const PBKDF2_MIN_NIST_COST: u64 = 10000; +use super::{Password, PBKDF2_MIN_NIST_COST}; #[derive(Debug)] pub struct CryptoPolicy { @@ -20,7 +18,7 @@ impl CryptoPolicy { pub fn time_target(t: Duration) -> Self { let r = match Password::bench_pbkdf2((PBKDF2_MIN_NIST_COST * 10) as usize) { Some(bt) => { - let ubt = bt.as_nanos() as u64; + let ubt = bt.as_nanos() as usize; // Get the cost per thousand rounds let per_thou = (PBKDF2_MIN_NIST_COST * 10) / 1000; @@ -28,7 +26,7 @@ impl CryptoPolicy { // eprintln!("{} / {}", ubt, per_thou); // Now we need the attacker work in nanos - let attack_time = t.as_nanos() as u64; + let attack_time = t.as_nanos() as usize; let r = (attack_time / t_per_thou) * 1000; // eprintln!("({} / {} ) * 1000", attack_time, t_per_thou);