Add support for argon2id (#1736)

This commit is contained in:
Firstyear 2023-06-16 13:26:05 +10:00 committed by GitHub
parent e61c9bdd0d
commit c65be8174a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 404 additions and 28 deletions

39
Cargo.lock generated
View file

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

View file

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

View file

@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
argon2.workspace = true
base64.workspace = true
base64urlsafedata.workspace = true
hex.workspace = true

View file

@ -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<u8>, Vec<u8>),
PBKDF2_SHA1(usize, Vec<u8>, Vec<u8>),
PBKDF2_SHA512(usize, Vec<u8>, Vec<u8>),
@ -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<u8>,
key: Vec<u8>,
},
// cost, salt, hash
PBKDF2(usize, Vec<u8>, Vec<u8>),
@ -153,6 +272,16 @@ impl TryFrom<DbPasswordV1> for Password {
fn try_from(value: DbPasswordV1) -> Result<Self, Self::Error> {
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<Self, Self::Error> {
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<Kdf, OperationError> {
fn bench_argon2id(params: Params) -> Option<Duration> {
let mut rng = rand::thread_rng();
let salt: Vec<u8> = (0..PBKDF2_SALT_LEN).map(|_| rng.gen()).collect();
let input: Vec<u8> = (0..PBKDF2_SALT_LEN).map(|_| rng.gen()).collect();
// This is 512 bits of output
let mut key: Vec<u8> = (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<Self, OperationError> {
let pbkdf2_cost = policy.pbkdf2_cost;
let mut rng = rand::thread_rng();
let salt: Vec<u8> = (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<Self, OperationError> {
let version = Version::V0x13;
let argon = Argon2::new(Algorithm::Argon2id, version, policy.argon2id_params.clone());
let mut rng = rand::thread_rng();
let salt: Vec<u8> = (0..ARGON2_SALT_LEN).map(|_| rng.gen()).collect();
let mut key: Vec<u8> = (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, OperationError> {
Self::new_pbkdf2(policy.pbkdf2_cost, cleartext).map(|material| Password { material })
Self::new_pbkdf2(policy, cleartext)
}
pub fn verify(&self, cleartext: &str) -> Result<bool, OperationError> {
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<u8> = (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

View file

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

View file

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