diff --git a/Cargo.lock b/Cargo.lock index 8e830954e..98fff8cf6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -132,6 +132,17 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" +[[package]] +name = "argon2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95c2fcf79ad1932ac6269a738109997a83c227c09b75842ae564dc8ede6a861c" +dependencies = [ + "base64ct", + "blake2", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.7" @@ -506,6 +517,12 @@ version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "604178f6c5c21f02dc555784810edfb88d34ac2c73b2eae109655649ee73ce3d" +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + [[package]] name = "base64urlsafedata" version = "0.1.3" @@ -576,6 +593,15 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbe3c979c178231552ecba20214a8272df4e09f232a87aef4320cf06539aded" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "blake3" version = "0.3.8" @@ -1331,6 +1357,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common", + "subtle", ] [[package]] @@ -2434,6 +2461,7 @@ dependencies = [ name = "kanidm_lib_crypto" version = "0.1.0" dependencies = [ + "argon2", "base64 0.21.2", "base64urlsafedata", "hex", @@ -3360,6 +3388,17 @@ dependencies = [ "windows-targets 0.48.0", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "0.1.18" diff --git a/Cargo.toml b/Cargo.toml index 8905be110..86270008a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ homepage = "https://github.com/kanidm/kanidm/" repository = "https://github.com/kanidm/kanidm/" [workspace.dependencies] +argon2 = { version = "0.5.0", features = ["alloc"] } async-recursion = "1.0.4" async-trait = "^0.1.68" base32 = "^0.4.0" diff --git a/libs/crypto/Cargo.toml b/libs/crypto/Cargo.toml index af2ca7907..112ea38ed 100644 --- a/libs/crypto/Cargo.toml +++ b/libs/crypto/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +argon2.workspace = true base64.workspace = true base64urlsafedata.workspace = true hex.workspace = true diff --git a/libs/crypto/src/lib.rs b/libs/crypto/src/lib.rs index 1ca99d5c1..6443647f4 100644 --- a/libs/crypto/src/lib.rs +++ b/libs/crypto/src/lib.rs @@ -1,6 +1,19 @@ +#![deny(warnings)] +#![warn(unused_extern_crates)] +#![deny(clippy::todo)] +#![deny(clippy::unimplemented)] +#![deny(clippy::unwrap_used)] +#![deny(clippy::expect_used)] +#![deny(clippy::panic)] +#![deny(clippy::await_holding_lock)] +#![deny(clippy::needless_pass_by_value)] +#![deny(clippy::trivially_copy_pass_by_ref)] +#![allow(clippy::unreachable)] + +use argon2::{Algorithm, Argon2, Params, PasswordHash, Version}; use base64::engine::GeneralPurpose; use base64::{alphabet, Engine}; -use tracing::{debug, error, warn}; +use tracing::{debug, error, info, trace, warn}; use base64::engine::general_purpose; use base64urlsafedata::Base64UrlSafeData; @@ -16,7 +29,6 @@ use openssl::pkcs5::pbkdf2_hmac; use openssl::sha::Sha512; // 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; @@ -32,9 +44,21 @@ const PBKDF2_SHA1_MIN_KEY_LEN: usize = 19; const DS_SSHA512_SALT_LEN: usize = 8; const DS_SSHA512_HASH_LEN: usize = 64; +const ARGON2_SALT_LEN: usize = 24; +const ARGON2_KEY_LEN: usize = 32; +const ARGON2_MAX_RAM_KIB: u32 = 32 * 1024; + #[derive(Serialize, Deserialize)] #[allow(non_camel_case_types)] pub enum DbPasswordV1 { + ARGON2ID { + m: u32, + t: u32, + p: u32, + v: u32, + s: Base64UrlSafeData, + k: Base64UrlSafeData, + }, PBKDF2(usize, Vec, Vec), PBKDF2_SHA1(usize, Vec, Vec), PBKDF2_SHA512(usize, Vec, Vec), @@ -45,6 +69,14 @@ pub enum DbPasswordV1 { #[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] #[allow(non_camel_case_types)] pub enum ReplPasswordV1 { + ARGON2ID { + m_cost: u32, + t_cost: u32, + p_cost: u32, + version: u32, + salt: Base64UrlSafeData, + key: Base64UrlSafeData, + }, PBKDF2 { cost: usize, salt: Base64UrlSafeData, @@ -72,6 +104,7 @@ pub enum ReplPasswordV1 { impl fmt::Debug for DbPasswordV1 { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { + DbPasswordV1::ARGON2ID { .. } => write!(f, "ARGON2ID"), DbPasswordV1::PBKDF2(_, _, _) => write!(f, "PBKDF2"), DbPasswordV1::PBKDF2_SHA1(_, _, _) => write!(f, "PBKDF2_SHA1"), DbPasswordV1::PBKDF2_SHA512(_, _, _) => write!(f, "PBKDF2_SHA512"), @@ -84,31 +117,37 @@ impl fmt::Debug for DbPasswordV1 { #[derive(Debug)] pub struct CryptoPolicy { pub(crate) pbkdf2_cost: usize, + // https://docs.rs/argon2/0.5.0/argon2/struct.Params.html + // defaults to 19mb memory, 2 iterations and 1 thread, with a 32byte output. + pub(crate) argon2id_params: Params, } impl CryptoPolicy { pub fn minimum() -> Self { CryptoPolicy { pbkdf2_cost: PBKDF2_MIN_NIST_COST, + argon2id_params: Params::default(), } } - pub fn time_target(t: Duration) -> Self { - let r = match Password::bench_pbkdf2(PBKDF2_MIN_NIST_COST * 10) { + pub fn time_target(target_time: Duration) -> Self { + const PBKDF2_BENCH_FACTOR: usize = 10; + + let pbkdf2_cost = match Password::bench_pbkdf2(PBKDF2_MIN_NIST_COST * PBKDF2_BENCH_FACTOR) { Some(bt) => { let ubt = bt.as_nanos() as usize; // Get the cost per thousand rounds - let per_thou = (PBKDF2_MIN_NIST_COST * 10) / 1000; + let per_thou = (PBKDF2_MIN_NIST_COST * PBKDF2_BENCH_FACTOR) / 1000; let t_per_thou = ubt / per_thou; - // eprintln!("{} / {}", ubt, per_thou); + trace!("{}µs / 1000 rounds", t_per_thou); // Now we need the attacker work in nanos - let attack_time = t.as_nanos() as usize; - let r = (attack_time / t_per_thou) * 1000; + let target = target_time.as_nanos() as usize; + let r = (target / t_per_thou) * 1000; - // eprintln!("({} / {} ) * 1000", attack_time, t_per_thou); - // eprintln!("Maybe rounds -> {}", r); + trace!("{}µs target time", target); + trace!("Maybe rounds -> {}", r); if r < PBKDF2_MIN_NIST_COST { PBKDF2_MIN_NIST_COST @@ -119,7 +158,78 @@ impl CryptoPolicy { None => PBKDF2_MIN_NIST_COST, }; - CryptoPolicy { pbkdf2_cost: r } + // Argon2id has multiple paramaters. These all are about *exchanges* that you can + // request in how the computation is performed. + // + // rfc9106 explains that there are two algorithms stacked here. Argon2i has defences + // against side-channel timing. Argon2d provides defences for time-memory tradeoffs. + // + // We can see how this impacts timings from sources like: + // https://www.twelve21.io/how-to-choose-the-right-parameters-for-argon2/ + // + // M = 256 MB, T = 2, d = 8, Time = 0.732 s + // M = 128 MB, T = 6, d = 8, Time = 0.99 s + // M = 64 MB, T = 12, d = 8, Time = 0.968 s + // M = 32 MB, T = 24, d = 8, Time = 0.896 s + // M = 16 MB, T = 49, d = 8, Time = 0.973 s + // M = 8 MB, T = 96, d = 8, Time = 0.991 s + // M = 4 MB, T = 190, d = 8, Time = 0.977 s + // M = 2 MB, T = 271, d = 8, Time = 0.973 s + // M = 1 MB, T = 639, d = 8, Time = 0.991 s + // + // As we can see, the time taken stays constant, but as ram decreases the amount of + // CPU work required goes up. In our case, our primary threat is from GPU hashcat + // cracking. GPU's tend to have many fast cores but very little amounts of fast ram + // for those cores. So we want to have as much ram as *possible* up to a limit, and + // then we want to increase iterations. + // + // This way a GPU has to expend further GPU time to compensate for the less ram. + // + // We also need to balance this against the fact we are a database, and we do have + // caches. We also don't want to over-use RAM, especially because in the worst case + // every thread will be operationg in argon2id at the same time. That means + // thread x ram will be used. If we had 8 threads at 64mb of ram, that would require + // 512mb of ram alone just for hashing. This becomes worse as core counts scale, with + // 24 core xeons easily reaching 1.5GB in these cases. + // + // To try to balance this we cap max ram at 32MB for now. + + let mut t = Duration::ZERO; + // Default amount of ram we sacrifice per thread is 8MB + let mut m_cost = 8 * 1024; + // Default t/p from argon2 library. + let t_cost = 2; + let p_cost = 1; + + // Raise memory usage until an acceptable ram amount is reached. + while t < target_time && m_cost < ARGON2_MAX_RAM_KIB { + m_cost += 1024; + let params = if let Ok(p) = Params::new(m_cost, t_cost, p_cost, None) { + p + } else { + // Unable to proceed. + break; + }; + + if let Some(ubt) = Password::bench_argon2id(params) { + t = ubt; + trace!("{}µs for m_cost {}", t.as_nanos(), m_cost); + } else { + error!("Unable to perform bench of argon2id, stopping benchmark"); + t = Duration::MAX; + } + } + + let argon2id_params = Params::new(m_cost, t_cost, p_cost, None) + // fallback + .unwrap_or_default(); + + let p = CryptoPolicy { + pbkdf2_cost, + argon2id_params, + }; + info!(pbkdf2_cost = %p.pbkdf2_cost, argon2id_m = %p.argon2id_params.m_cost(), argon2id_p = %p.argon2id_params.p_cost(), argon2id_t = %p.argon2id_params.t_cost(), ); + p } } @@ -129,6 +239,15 @@ impl CryptoPolicy { #[derive(Clone, Debug, PartialEq)] #[allow(non_camel_case_types)] enum Kdf { + // + ARGON2ID { + m_cost: u32, + t_cost: u32, + p_cost: u32, + version: u32, + salt: Vec, + key: Vec, + }, // cost, salt, hash PBKDF2(usize, Vec, Vec), @@ -153,6 +272,16 @@ impl TryFrom for Password { fn try_from(value: DbPasswordV1) -> Result { match value { + DbPasswordV1::ARGON2ID { m, t, p, v, s, k } => Ok(Password { + material: Kdf::ARGON2ID { + m_cost: m, + t_cost: t, + p_cost: p, + version: v, + salt: s.into(), + key: k.into(), + }, + }), DbPasswordV1::PBKDF2(c, s, h) => Ok(Password { material: Kdf::PBKDF2(c, s, h), }), @@ -177,6 +306,23 @@ impl TryFrom<&ReplPasswordV1> for Password { fn try_from(value: &ReplPasswordV1) -> Result { match value { + ReplPasswordV1::ARGON2ID { + m_cost, + t_cost, + p_cost, + version, + salt, + key, + } => Ok(Password { + material: Kdf::ARGON2ID { + m_cost: *m_cost, + t_cost: *t_cost, + p_cost: *p_cost, + version: *version, + salt: salt.0.clone(), + key: key.0.clone(), + }, + }), ReplPasswordV1::PBKDF2 { cost, salt, hash } => Ok(Password { material: Kdf::PBKDF2(*cost, salt.0.clone(), hash.0.clone()), }), @@ -367,6 +513,72 @@ impl TryFrom<&str> for Password { } } + if let Some(argon2_phc) = value.strip_prefix("{ARGON2}") { + match PasswordHash::try_from(argon2_phc) { + Ok(PasswordHash { + algorithm, + version, + params, + salt, + hash, + }) => { + if algorithm.as_str() != "argon2id" { + error!(alg = %algorithm.as_str(), "Only argon2id is supported"); + return Err(()); + } + + let version = version.unwrap_or(19); + let version: Version = version.try_into().map_err(|_| { + error!("Failed to convert {} to valid argon2id version", version); + })?; + + let m_cost = params.get_decimal("m").ok_or_else(|| { + error!("Failed to access m_cost parameter"); + })?; + + let t_cost = params.get_decimal("t").ok_or_else(|| { + error!("Failed to access t_cost parameter"); + })?; + + let p_cost = params.get_decimal("p").ok_or_else(|| { + error!("Failed to access p_cost parameter"); + })?; + + let salt = salt + .and_then(|s| { + let mut salt_arr = [0u8; 64]; + s.decode_b64(&mut salt_arr) + .ok() + .map(|salt_bytes| salt_bytes.to_owned()) + }) + .ok_or_else(|| { + error!("Failed to access salt"); + })?; + + error!(?salt); + + let key = hash.map(|h| h.as_bytes().into()).ok_or_else(|| { + error!("Failed to access key"); + })?; + + return Ok(Password { + material: Kdf::ARGON2ID { + m_cost, + t_cost, + p_cost, + version: version as u32, + salt, + key, + }, + }); + } + Err(e) => { + error!(?e, "Invalid argon2 phc string"); + return Err(()); + } + } + } + // Nothing matched to this point. Err(()) } @@ -394,7 +606,26 @@ impl Password { end.checked_duration_since(start) } - fn new_pbkdf2(pbkdf2_cost: usize, cleartext: &str) -> Result { + fn bench_argon2id(params: Params) -> Option { + let mut rng = rand::thread_rng(); + let salt: Vec = (0..PBKDF2_SALT_LEN).map(|_| rng.gen()).collect(); + let input: Vec = (0..PBKDF2_SALT_LEN).map(|_| rng.gen()).collect(); + // This is 512 bits of output + let mut key: Vec = (0..PBKDF2_KEY_LEN).map(|_| 0).collect(); + + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + + let start = Instant::now(); + argon + .hash_password_into(input.as_slice(), salt.as_slice(), key.as_mut_slice()) + .ok()?; + let end = Instant::now(); + + end.checked_duration_since(start) + } + + pub fn new_pbkdf2(policy: &CryptoPolicy, cleartext: &str) -> Result { + let pbkdf2_cost = policy.pbkdf2_cost; let mut rng = rand::thread_rng(); let salt: Vec = (0..PBKDF2_SALT_LEN).map(|_| rng.gen()).collect(); // This is 512 bits of output @@ -412,14 +643,78 @@ impl Password { Kdf::PBKDF2(pbkdf2_cost, salt, key) }) .map_err(|_| OperationError::CryptographyError) + .map(|material| Password { material }) } + pub fn new_argon2id(policy: &CryptoPolicy, cleartext: &str) -> Result { + let version = Version::V0x13; + + let argon = Argon2::new(Algorithm::Argon2id, version, policy.argon2id_params.clone()); + + let mut rng = rand::thread_rng(); + let salt: Vec = (0..ARGON2_SALT_LEN).map(|_| rng.gen()).collect(); + let mut key: Vec = (0..ARGON2_KEY_LEN).map(|_| 0).collect(); + + argon + .hash_password_into(cleartext.as_bytes(), salt.as_slice(), key.as_mut_slice()) + .map(|()| Kdf::ARGON2ID { + m_cost: policy.argon2id_params.m_cost(), + t_cost: policy.argon2id_params.t_cost(), + p_cost: policy.argon2id_params.p_cost(), + version: version as u32, + salt, + key, + }) + .map_err(|_| OperationError::CryptographyError) + .map(|material| Password { material }) + } + + #[inline] pub fn new(policy: &CryptoPolicy, cleartext: &str) -> Result { - Self::new_pbkdf2(policy.pbkdf2_cost, cleartext).map(|material| Password { material }) + Self::new_pbkdf2(policy, cleartext) } pub fn verify(&self, cleartext: &str) -> Result { match &self.material { + Kdf::ARGON2ID { + m_cost, + t_cost, + p_cost, + version, + salt, + key, + } => { + let version: Version = (*version).try_into().map_err(|_| { + error!("Failed to convert {} to valid argon2id version", version); + OperationError::CryptographyError + })?; + + let key_len = key.len(); + + let params = + Params::new(*m_cost, *t_cost, *p_cost, Some(key_len)).map_err(|e| { + error!(err = ?e, "invalid argon2id parameters"); + OperationError::CryptographyError + })?; + + let argon = Argon2::new(Algorithm::Argon2id, version, params); + let mut check_key: Vec = (0..key_len).map(|_| 0).collect(); + + argon + .hash_password_into( + cleartext.as_bytes(), + salt.as_slice(), + check_key.as_mut_slice(), + ) + .map_err(|e| { + error!(err = ?e, "unable to perform argon2id hash"); + OperationError::CryptographyError + }) + .map(|()| { + // Actually compare the outputs. + &check_key == key + }) + } Kdf::PBKDF2(cost, salt, key) => { // We have to get the number of bits to derive from our stored hash // as some imported hash types may have variable lengths @@ -508,6 +803,21 @@ impl Password { pub fn to_dbpasswordv1(&self) -> DbPasswordV1 { match &self.material { + Kdf::ARGON2ID { + m_cost, + t_cost, + p_cost, + version, + salt, + key, + } => DbPasswordV1::ARGON2ID { + m: *m_cost, + t: *t_cost, + p: *p_cost, + v: *version, + s: salt.clone().into(), + k: key.clone().into(), + }, Kdf::PBKDF2(cost, salt, hash) => { DbPasswordV1::PBKDF2(*cost, salt.clone(), hash.clone()) } @@ -524,6 +834,21 @@ impl Password { pub fn to_repl_v1(&self) -> ReplPasswordV1 { match &self.material { + Kdf::ARGON2ID { + m_cost, + t_cost, + p_cost, + version, + salt, + key, + } => ReplPasswordV1::ARGON2ID { + m_cost: *m_cost, + t_cost: *t_cost, + p_cost: *p_cost, + version: *version, + salt: salt.clone().into(), + key: key.clone().into(), + }, Kdf::PBKDF2(cost, salt, hash) => ReplPasswordV1::PBKDF2 { cost: *cost, salt: salt.clone().into(), @@ -551,6 +876,7 @@ impl Password { pub fn requires_upgrade(&self) -> bool { match &self.material { + Kdf::ARGON2ID { .. } => false, Kdf::PBKDF2_SHA512(cost, salt, hash) | Kdf::PBKDF2(cost, salt, hash) => { *cost < PBKDF2_MIN_NIST_COST || salt.len() < PBKDF2_MIN_NIST_SALT_LEN @@ -578,6 +904,24 @@ mod tests { assert!(!c.verify("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap()); } + #[test] + fn test_password_pbkdf2() { + let p = CryptoPolicy::minimum(); + let c = Password::new_pbkdf2(&p, "password").unwrap(); + assert!(c.verify("password").unwrap()); + assert!(!c.verify("password1").unwrap()); + assert!(!c.verify("Password1").unwrap()); + } + + #[test] + fn test_password_argon2id() { + let p = CryptoPolicy::minimum(); + let c = Password::new_argon2id(&p, "password").unwrap(); + assert!(c.verify("password").unwrap()); + assert!(!c.verify("password1").unwrap()); + assert!(!c.verify("Password1").unwrap()); + } + #[test] fn test_password_from_invalid() { assert!(Password::try_from("password").is_err()) @@ -640,17 +984,16 @@ mod tests { 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" + sketching::test_init(); + 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.requires_upgrade()); assert!(r.verify(password).unwrap_or(false)); } - */ /* * wbrown - 20221104 - I tried to programmatically enable the legacy provider, but diff --git a/server/lib/src/idm/server.rs b/server/lib/src/idm/server.rs index 68be27875..ebe55516d 100644 --- a/server/lib/src/idm/server.rs +++ b/server/lib/src/idm/server.rs @@ -153,13 +153,8 @@ impl IdmServer { origin: &str, ) -> Result<(IdmServer, IdmServerDelayed, IdmServerAudit), OperationError> { // This is calculated back from: - // 500 auths / thread -> 0.002 sec per op - // we can then spend up to ~0.001s hashing - // that means an attacker could possibly have - // 1000 attempts/sec on a compromised pw. - // overtime, we could increase this as auth parallelism - // improves. - let crypto_policy = CryptoPolicy::time_target(Duration::from_millis(1)); + // 100 password auths / thread -> 0.010 sec per op + let crypto_policy = CryptoPolicy::time_target(Duration::from_millis(10)); let (async_tx, async_rx) = unbounded(); let (audit_tx, audit_rx) = unbounded(); diff --git a/unix_integration/src/cache.rs b/unix_integration/src/cache.rs index e51868bf6..265b3e599 100644 --- a/unix_integration/src/cache.rs +++ b/unix_integration/src/cache.rs @@ -105,10 +105,7 @@ impl CacheLayer { home_alias, uid_attr_map, gid_attr_map, - allow_id_overrides: allow_id_overrides - .into_iter() - .map(|name| Id::Name(name)) - .collect(), + allow_id_overrides: allow_id_overrides.into_iter().map(Id::Name).collect(), nxset: Mutex::new(HashSet::new()), nxcache: Mutex::new(LruCache::new(NXCACHE_SIZE)), })