Skip to main content

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}