solo_storage/
key_material.rs1use argon2::{Algorithm, Argon2, Params, Version};
16use solo_core::{Error, Result};
17use zeroize::Zeroizing;
18
19pub const ARGON2_M_COST_KIB: u32 = 64 * 1024;
21pub const ARGON2_T_COST: u32 = 3;
23pub const ARGON2_P_COST: u32 = 4;
25pub const KEY_LEN: usize = 32;
27pub const SALT_LEN: usize = 16;
30
31pub struct KeyMaterial {
37 raw: Zeroizing<[u8; KEY_LEN]>,
38}
39
40impl KeyMaterial {
41 pub fn derive(passphrase: &str, salt: &[u8; SALT_LEN]) -> Result<Self> {
46 let params = Params::new(
47 ARGON2_M_COST_KIB,
48 ARGON2_T_COST,
49 ARGON2_P_COST,
50 Some(KEY_LEN),
51 )
52 .map_err(|e| Error::storage(format!("argon2 params: {e}")))?;
53 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
54
55 let mut out: Zeroizing<[u8; KEY_LEN]> = Zeroizing::new([0u8; KEY_LEN]);
56 argon2
57 .hash_password_into(passphrase.as_bytes(), salt, out.as_mut())
58 .map_err(|e| Error::storage(format!("argon2 hash: {e}")))?;
59 Ok(Self { raw: out })
60 }
61
62 pub fn fresh_salt() -> Result<[u8; SALT_LEN]> {
65 let mut salt = [0u8; SALT_LEN];
66 getrandom::getrandom(&mut salt)
67 .map_err(|e| Error::storage(format!("getrandom: {e}")))?;
68 Ok(salt)
69 }
70
71 pub fn as_hex(&self) -> Zeroizing<String> {
84 Zeroizing::new(hex::encode(self.raw.as_ref()))
85 }
86
87 #[cfg(test)]
90 fn eq_for_test(&self, other: &Self) -> bool {
91 self.raw.as_ref() == other.raw.as_ref()
92 }
93
94 #[cfg(any(test, feature = "test-support"))]
97 pub fn from_bytes_for_tests(bytes: [u8; KEY_LEN]) -> Self {
98 Self {
99 raw: Zeroizing::new(bytes),
100 }
101 }
102}
103
104impl Clone for KeyMaterial {
105 fn clone(&self) -> Self {
106 Self {
107 raw: Zeroizing::new(*self.raw),
108 }
109 }
110}
111
112impl std::fmt::Debug for KeyMaterial {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 f.write_str("KeyMaterial { raw: <redacted> }")
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 fn derive_fast(passphrase: &str, salt: &[u8; SALT_LEN]) -> Result<KeyMaterial> {
125 let params = Params::new(8, 1, 1, Some(KEY_LEN))
126 .map_err(|e| Error::storage(format!("params: {e}")))?;
127 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
128 let mut out: Zeroizing<[u8; KEY_LEN]> = Zeroizing::new([0u8; KEY_LEN]);
129 argon2
130 .hash_password_into(passphrase.as_bytes(), salt, out.as_mut())
131 .map_err(|e| Error::storage(format!("hash: {e}")))?;
132 Ok(KeyMaterial { raw: out })
133 }
134
135 #[test]
136 fn derive_is_deterministic_with_same_salt() {
137 let salt = [0u8; SALT_LEN];
138 let a = derive_fast("hunter2", &salt).unwrap();
139 let b = derive_fast("hunter2", &salt).unwrap();
140 assert!(a.eq_for_test(&b));
141 assert_eq!(&*a.as_hex(), &*b.as_hex());
142 }
143
144 #[test]
145 fn derive_differs_with_different_salt() {
146 let s1 = [0u8; SALT_LEN];
147 let mut s2 = [0u8; SALT_LEN];
148 s2[0] = 1;
149 let a = derive_fast("hunter2", &s1).unwrap();
150 let b = derive_fast("hunter2", &s2).unwrap();
151 assert!(!a.eq_for_test(&b));
152 }
153
154 #[test]
155 fn derive_differs_with_different_passphrase() {
156 let salt = [0u8; SALT_LEN];
157 let a = derive_fast("hunter2", &salt).unwrap();
158 let b = derive_fast("hunter3", &salt).unwrap();
159 assert!(!a.eq_for_test(&b));
160 }
161
162 #[test]
163 fn fresh_salt_is_random() {
164 let s1 = KeyMaterial::fresh_salt().unwrap();
165 let s2 = KeyMaterial::fresh_salt().unwrap();
166 assert_ne!(s1, s2);
169 }
170
171 #[test]
172 fn as_hex_has_correct_length_and_charset() {
173 let salt = [0u8; SALT_LEN];
174 let k = derive_fast("hunter2", &salt).unwrap();
175 let h = k.as_hex();
176 assert_eq!(h.len(), KEY_LEN * 2);
177 assert!(h.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
178 }
179
180 #[test]
181 fn debug_redacts_key_material() {
182 let salt = [0u8; SALT_LEN];
183 let k = derive_fast("hunter2", &salt).unwrap();
184 let dbg = format!("{k:?}");
185 assert!(dbg.contains("redacted"));
186 assert!(!dbg.contains(&k.as_hex()[..8]));
187 }
188}