use argon2::{Algorithm, Argon2, Params, Version};
use solo_core::{Error, Result};
use zeroize::Zeroizing;
pub const ARGON2_M_COST_KIB: u32 = 64 * 1024;
pub const ARGON2_T_COST: u32 = 3;
pub const ARGON2_P_COST: u32 = 4;
pub const KEY_LEN: usize = 32;
pub const SALT_LEN: usize = 16;
pub struct KeyMaterial {
raw: Zeroizing<[u8; KEY_LEN]>,
}
impl KeyMaterial {
pub fn derive(passphrase: &str, salt: &[u8; SALT_LEN]) -> Result<Self> {
let params = Params::new(
ARGON2_M_COST_KIB,
ARGON2_T_COST,
ARGON2_P_COST,
Some(KEY_LEN),
)
.map_err(|e| Error::storage(format!("argon2 params: {e}")))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut out: Zeroizing<[u8; KEY_LEN]> = Zeroizing::new([0u8; KEY_LEN]);
argon2
.hash_password_into(passphrase.as_bytes(), salt, out.as_mut())
.map_err(|e| Error::storage(format!("argon2 hash: {e}")))?;
Ok(Self { raw: out })
}
pub fn fresh_salt() -> Result<[u8; SALT_LEN]> {
let mut salt = [0u8; SALT_LEN];
getrandom::getrandom(&mut salt)
.map_err(|e| Error::storage(format!("getrandom: {e}")))?;
Ok(salt)
}
pub fn as_hex(&self) -> Zeroizing<String> {
Zeroizing::new(hex::encode(self.raw.as_ref()))
}
#[cfg(test)]
fn eq_for_test(&self, other: &Self) -> bool {
self.raw.as_ref() == other.raw.as_ref()
}
#[cfg(any(test, feature = "test-support"))]
pub fn from_bytes_for_tests(bytes: [u8; KEY_LEN]) -> Self {
Self {
raw: Zeroizing::new(bytes),
}
}
}
impl Clone for KeyMaterial {
fn clone(&self) -> Self {
Self {
raw: Zeroizing::new(*self.raw),
}
}
}
impl std::fmt::Debug for KeyMaterial {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("KeyMaterial { raw: <redacted> }")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn derive_fast(passphrase: &str, salt: &[u8; SALT_LEN]) -> Result<KeyMaterial> {
let params = Params::new(8, 1, 1, Some(KEY_LEN))
.map_err(|e| Error::storage(format!("params: {e}")))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut out: Zeroizing<[u8; KEY_LEN]> = Zeroizing::new([0u8; KEY_LEN]);
argon2
.hash_password_into(passphrase.as_bytes(), salt, out.as_mut())
.map_err(|e| Error::storage(format!("hash: {e}")))?;
Ok(KeyMaterial { raw: out })
}
#[test]
fn derive_is_deterministic_with_same_salt() {
let salt = [0u8; SALT_LEN];
let a = derive_fast("hunter2", &salt).unwrap();
let b = derive_fast("hunter2", &salt).unwrap();
assert!(a.eq_for_test(&b));
assert_eq!(&*a.as_hex(), &*b.as_hex());
}
#[test]
fn derive_differs_with_different_salt() {
let s1 = [0u8; SALT_LEN];
let mut s2 = [0u8; SALT_LEN];
s2[0] = 1;
let a = derive_fast("hunter2", &s1).unwrap();
let b = derive_fast("hunter2", &s2).unwrap();
assert!(!a.eq_for_test(&b));
}
#[test]
fn derive_differs_with_different_passphrase() {
let salt = [0u8; SALT_LEN];
let a = derive_fast("hunter2", &salt).unwrap();
let b = derive_fast("hunter3", &salt).unwrap();
assert!(!a.eq_for_test(&b));
}
#[test]
fn fresh_salt_is_random() {
let s1 = KeyMaterial::fresh_salt().unwrap();
let s2 = KeyMaterial::fresh_salt().unwrap();
assert_ne!(s1, s2);
}
#[test]
fn as_hex_has_correct_length_and_charset() {
let salt = [0u8; SALT_LEN];
let k = derive_fast("hunter2", &salt).unwrap();
let h = k.as_hex();
assert_eq!(h.len(), KEY_LEN * 2);
assert!(h.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
}
#[test]
fn debug_redacts_key_material() {
let salt = [0u8; SALT_LEN];
let k = derive_fast("hunter2", &salt).unwrap();
let dbg = format!("{k:?}");
assert!(dbg.contains("redacted"));
assert!(!dbg.contains(&k.as_hex()[..8]));
}
}