stenoxide_core/crypto/kdf.rs
1//! Argon2id password-based key derivation.
2//!
3//! This is the only place where a user-supplied password enters the system. The
4//! password is stretched into a 32-byte [`MasterKey`] whose cost parameters are
5//! compiled in rather than configurable: a weakened parameter set is
6//! indistinguishable from a correct one at the API surface, so exposing it
7//! would turn a silent misconfiguration into a silent loss of security.
8//!
9//! The salt is not random. It is the perceptual hash of the container image, so
10//! the same password applied to the same image always yields the same master
11//! key — which is what lets extraction work without storing any key material
12//! alongside the payload.
13
14use std::fmt;
15
16use argon2::{Algorithm, Argon2, Params, Version};
17use zeroize::{Zeroize, ZeroizeOnDrop};
18
19use crate::image_io::phash::PHashSalt;
20
21/// Argon2id memory cost, in kibibytes (128 MiB).
22const M_COST: u32 = 131_072;
23
24/// Argon2id time cost, in passes over the memory block.
25const T_COST: u32 = 4;
26
27/// Argon2id degree of parallelism, in lanes.
28const PARALLELISM: u32 = 2;
29
30/// Length of the derived master key, in bytes.
31const MASTER_KEY_LEN: usize = 32;
32
33/// Every way password stretching can fail.
34#[derive(Debug)]
35pub enum KdfError {
36 /// The Argon2id implementation rejected the parameters or the inputs.
37 Argon2Error(String),
38 /// The password was empty. An empty password is never a mistake worth
39 /// honouring, so it is refused rather than stretched.
40 EmptyPassword,
41}
42
43impl fmt::Display for KdfError {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 match self {
46 KdfError::Argon2Error(message) => {
47 write!(f, "argon2id key derivation failed: {message}")
48 }
49 KdfError::EmptyPassword => write!(f, "the password must not be empty"),
50 }
51 }
52}
53
54impl std::error::Error for KdfError {}
55
56/// A 32-byte key derived from the password and the container's perceptual hash.
57///
58/// The buffer is wiped when the value is dropped. The type deliberately
59/// implements neither [`Clone`] nor [`Copy`] — a copy would be a second live
60/// image of the key that no owner is responsible for erasing — nor [`Debug`],
61/// which would put key bytes into logs and panic messages.
62#[derive(ZeroizeOnDrop)]
63pub struct MasterKey([u8; MASTER_KEY_LEN]);
64
65impl MasterKey {
66 /// Takes ownership of already derived key bytes.
67 ///
68 /// Restricted to the crate: outside code must obtain a master key through
69 /// [`KeyDeriver::derive`], the only path that applies the compiled-in cost
70 /// parameters.
71 pub(crate) fn new(bytes: [u8; MASTER_KEY_LEN]) -> Self {
72 Self(bytes)
73 }
74
75 /// Borrows the key bytes, for use as HKDF input keying material.
76 pub(crate) fn as_bytes(&self) -> &[u8] {
77 &self.0
78 }
79}
80
81/// Stretching of a password into a master key.
82///
83/// The trait exists so that tests can substitute a cheap deriver for the
84/// production one without the layers above knowing which is in use. It is
85/// `Send + Sync` because the pipeline may hold a deriver behind a shared
86/// reference while worker threads are running.
87pub trait KeyDeriver: Send + Sync {
88 /// Derives a master key from `password` and an arbitrary salt.
89 ///
90 /// The general form, and the only one an implementor has to write. Most of
91 /// this crate salts with the container's perceptual hash and reaches for
92 /// [`KeyDeriver::derive`] instead; the exception is the passphrase that
93 /// protects a private key file, where there is no container and the salt is
94 /// read from the file.
95 ///
96 /// # Errors
97 ///
98 /// Returns [`KdfError::EmptyPassword`] if `password` has no bytes, and
99 /// [`KdfError::Argon2Error`] if the underlying implementation fails.
100 fn derive_with_salt(&self, password: &[u8], salt: &[u8]) -> Result<MasterKey, KdfError>;
101
102 /// Derives a master key from `password`, salted with the container hash.
103 ///
104 /// # Errors
105 ///
106 /// As [`KeyDeriver::derive_with_salt`].
107 fn derive(&self, password: &[u8], salt: &PHashSalt) -> Result<MasterKey, KdfError> {
108 self.derive_with_salt(password, salt.as_bytes())
109 }
110}
111
112/// The production key deriver: Argon2id with compiled-in cost parameters.
113///
114/// The parameters are held as fields rather than read from the constants at use
115/// time only so that `Argon2Kdf::low_cost_for_tests` can exist; no public
116/// constructor accepts them.
117pub struct Argon2Kdf {
118 m_cost: u32,
119 t_cost: u32,
120 parallelism: u32,
121}
122
123impl Argon2Kdf {
124 /// Builds a deriver with the parameters this project considers secure:
125 /// 128 MiB of memory, 4 passes and 2 lanes.
126 pub fn default_secure() -> Self {
127 Self {
128 m_cost: M_COST,
129 t_cost: T_COST,
130 parallelism: PARALLELISM,
131 }
132 }
133
134 /// Builds a deliberately weak deriver so that tests do not spend 128 MiB
135 /// and hundreds of milliseconds per derivation.
136 ///
137 /// Compiled only under `cfg(test)` or the `test-utils` feature, so it
138 /// cannot leak into a release build. The feature exists because a
139 /// `cfg(test)` item is invisible to the integration tests in `tests/`,
140 /// which link the library as an external crate; the crate's dev-dependency
141 /// on itself is the only thing that ever enables it.
142 #[cfg(any(test, feature = "test-utils"))]
143 pub fn low_cost_for_tests() -> Self {
144 Self {
145 m_cost: 8,
146 t_cost: 1,
147 parallelism: 1,
148 }
149 }
150}
151
152impl KeyDeriver for Argon2Kdf {
153 fn derive_with_salt(&self, password: &[u8], salt: &[u8]) -> Result<MasterKey, KdfError> {
154 if password.is_empty() {
155 return Err(KdfError::EmptyPassword);
156 }
157
158 // Built here rather than in the constructor because `Params::new` is
159 // fallible and the constructor has no error channel; validating at the
160 // point of use keeps both of them panic-free.
161 let params = Params::new(self.m_cost, self.t_cost, self.parallelism, None)
162 .map_err(|err| KdfError::Argon2Error(err.to_string()))?;
163 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
164
165 let mut bytes = [0u8; MASTER_KEY_LEN];
166 let outcome = argon2
167 .hash_password_into(password, salt, &mut bytes)
168 .map_err(|err| KdfError::Argon2Error(err.to_string()));
169
170 // `bytes` is copied into the key rather than moved, so the local array
171 // stays a second image of the material and has to be wiped by hand on
172 // both paths.
173 let result = outcome.map(|()| MasterKey::new(bytes));
174 bytes.zeroize();
175 result
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 // The crate-wide bans on panicking helpers reach into `cfg(test)` code as
182 // well. A test that cannot panic cannot fail, so they are lifted here and
183 // only here.
184 #![allow(clippy::expect_used)]
185 #![allow(clippy::panic)]
186
187 use super::*;
188
189 /// A salt of the shape the perceptual hash produces.
190 fn salt(fill: u8) -> PHashSalt {
191 PHashSalt::new([fill; 32])
192 }
193
194 /// The same password and the same container always give the same key.
195 ///
196 /// The property extraction rests on: no key material travels with the
197 /// payload, so the receiver has to arrive at the same 32 bytes from the
198 /// password and the image alone.
199 #[test]
200 fn derivation_is_deterministic_in_both_its_inputs() {
201 let kdf = Argon2Kdf::low_cost_for_tests();
202
203 let first = kdf.derive(b"passphrase", &salt(1)).expect("must derive");
204 let again = kdf.derive(b"passphrase", &salt(1)).expect("must derive");
205 let other_password = kdf.derive(b"passphrasf", &salt(1)).expect("must derive");
206 let other_salt = kdf.derive(b"passphrase", &salt(2)).expect("must derive");
207
208 assert_eq!(first.as_bytes(), again.as_bytes());
209 assert_ne!(first.as_bytes(), other_password.as_bytes());
210 assert_ne!(first.as_bytes(), other_salt.as_bytes());
211 assert_eq!(first.as_bytes().len(), MASTER_KEY_LEN);
212 }
213
214 /// The container salt is the general salt, not a different code path.
215 ///
216 /// [`KeyDeriver::derive`] is a thin default over
217 /// [`KeyDeriver::derive_with_salt`], and this is what holds that down: a
218 /// hash used as a salt must stretch to exactly what the same bytes stretch
219 /// to when they arrive as a plain slice. If the two ever diverged, a
220 /// container written by one and read by the other would be unrecoverable.
221 #[test]
222 fn the_container_salt_is_the_general_salt() {
223 let kdf = Argon2Kdf::low_cost_for_tests();
224
225 let through_hash = kdf.derive(b"passphrase", &salt(3)).expect("must derive");
226 let through_slice = kdf
227 .derive_with_salt(b"passphrase", &[3u8; 32])
228 .expect("must derive");
229 let other_salt = kdf
230 .derive_with_salt(b"passphrase", &[4u8; 32])
231 .expect("must derive");
232
233 assert_eq!(through_hash.as_bytes(), through_slice.as_bytes());
234 assert_ne!(through_hash.as_bytes(), other_salt.as_bytes());
235 }
236
237 /// An empty password is refused rather than stretched.
238 #[test]
239 fn an_empty_password_is_refused() {
240 let error = Argon2Kdf::low_cost_for_tests()
241 .derive(&[], &salt(1))
242 .map(|_| ())
243 .expect_err("an empty password must never be honoured");
244
245 assert!(matches!(error, KdfError::EmptyPassword), "got: {error:?}");
246 }
247
248 /// Parameters the implementation rejects are reported rather than assumed
249 /// away.
250 ///
251 /// The production constructor cannot produce such a set — that is the point
252 /// of compiling the cost in — but building `Params` is fallible and the
253 /// failure has to have somewhere to go.
254 #[test]
255 fn parameters_argon2_refuses_are_reported() {
256 let broken = Argon2Kdf {
257 m_cost: 0,
258 t_cost: 0,
259 parallelism: 0,
260 };
261
262 let error = broken
263 .derive(b"passphrase", &salt(1))
264 .map(|_| ())
265 .expect_err("a memory cost of zero must be refused");
266
267 assert!(matches!(error, KdfError::Argon2Error(_)), "got: {error:?}");
268 }
269
270 /// The production parameters are the ones this project considers secure.
271 #[test]
272 fn the_default_deriver_carries_the_compiled_in_cost() {
273 let kdf = Argon2Kdf::default_secure();
274
275 assert_eq!(
276 (kdf.m_cost, kdf.t_cost, kdf.parallelism),
277 (M_COST, T_COST, PARALLELISM)
278 );
279 }
280
281 /// Both failures explain themselves.
282 #[test]
283 fn every_failure_explains_itself() {
284 assert!(KdfError::EmptyPassword.to_string().contains("empty"));
285 assert!(KdfError::Argon2Error("bad params".to_owned())
286 .to_string()
287 .contains("bad params"));
288 }
289}