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`, salted with the container hash.
89    ///
90    /// # Errors
91    ///
92    /// Returns [`KdfError::EmptyPassword`] if `password` has no bytes, and
93    /// [`KdfError::Argon2Error`] if the underlying implementation fails.
94    fn derive(&self, password: &[u8], salt: &PHashSalt) -> Result<MasterKey, KdfError>;
95}
96
97/// The production key deriver: Argon2id with compiled-in cost parameters.
98///
99/// The parameters are held as fields rather than read from the constants at use
100/// time only so that `Argon2Kdf::low_cost_for_tests` can exist; no public
101/// constructor accepts them.
102pub struct Argon2Kdf {
103    m_cost: u32,
104    t_cost: u32,
105    parallelism: u32,
106}
107
108impl Argon2Kdf {
109    /// Builds a deriver with the parameters this project considers secure:
110    /// 128 MiB of memory, 4 passes and 2 lanes.
111    pub fn default_secure() -> Self {
112        Self {
113            m_cost: M_COST,
114            t_cost: T_COST,
115            parallelism: PARALLELISM,
116        }
117    }
118
119    /// Builds a deliberately weak deriver so that tests do not spend 128 MiB
120    /// and hundreds of milliseconds per derivation.
121    ///
122    /// Compiled only under `cfg(test)` or the `test-utils` feature, so it
123    /// cannot leak into a release build. The feature exists because a
124    /// `cfg(test)` item is invisible to the integration tests in `tests/`,
125    /// which link the library as an external crate; the crate's dev-dependency
126    /// on itself is the only thing that ever enables it.
127    #[cfg(any(test, feature = "test-utils"))]
128    pub fn low_cost_for_tests() -> Self {
129        Self {
130            m_cost: 8,
131            t_cost: 1,
132            parallelism: 1,
133        }
134    }
135}
136
137impl KeyDeriver for Argon2Kdf {
138    fn derive(&self, password: &[u8], salt: &PHashSalt) -> Result<MasterKey, KdfError> {
139        if password.is_empty() {
140            return Err(KdfError::EmptyPassword);
141        }
142
143        // Built here rather than in the constructor because `Params::new` is
144        // fallible and the constructor has no error channel; validating at the
145        // point of use keeps both of them panic-free.
146        let params = Params::new(self.m_cost, self.t_cost, self.parallelism, None)
147            .map_err(|err| KdfError::Argon2Error(err.to_string()))?;
148        let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
149
150        let mut bytes = [0u8; MASTER_KEY_LEN];
151        let outcome = argon2
152            .hash_password_into(password, salt.as_bytes(), &mut bytes)
153            .map_err(|err| KdfError::Argon2Error(err.to_string()));
154
155        // `bytes` is copied into the key rather than moved, so the local array
156        // stays a second image of the material and has to be wiped by hand on
157        // both paths.
158        let result = outcome.map(|()| MasterKey::new(bytes));
159        bytes.zeroize();
160        result
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    // The crate-wide bans on panicking helpers reach into `cfg(test)` code as
167    // well. A test that cannot panic cannot fail, so they are lifted here and
168    // only here.
169    #![allow(clippy::expect_used)]
170    #![allow(clippy::panic)]
171
172    use super::*;
173
174    /// A salt of the shape the perceptual hash produces.
175    fn salt(fill: u8) -> PHashSalt {
176        PHashSalt::new([fill; 32])
177    }
178
179    /// The same password and the same container always give the same key.
180    ///
181    /// The property extraction rests on: no key material travels with the
182    /// payload, so the receiver has to arrive at the same 32 bytes from the
183    /// password and the image alone.
184    #[test]
185    fn derivation_is_deterministic_in_both_its_inputs() {
186        let kdf = Argon2Kdf::low_cost_for_tests();
187
188        let first = kdf.derive(b"passphrase", &salt(1)).expect("must derive");
189        let again = kdf.derive(b"passphrase", &salt(1)).expect("must derive");
190        let other_password = kdf.derive(b"passphrasf", &salt(1)).expect("must derive");
191        let other_salt = kdf.derive(b"passphrase", &salt(2)).expect("must derive");
192
193        assert_eq!(first.as_bytes(), again.as_bytes());
194        assert_ne!(first.as_bytes(), other_password.as_bytes());
195        assert_ne!(first.as_bytes(), other_salt.as_bytes());
196        assert_eq!(first.as_bytes().len(), MASTER_KEY_LEN);
197    }
198
199    /// An empty password is refused rather than stretched.
200    #[test]
201    fn an_empty_password_is_refused() {
202        let error = Argon2Kdf::low_cost_for_tests()
203            .derive(&[], &salt(1))
204            .map(|_| ())
205            .expect_err("an empty password must never be honoured");
206
207        assert!(matches!(error, KdfError::EmptyPassword), "got: {error:?}");
208    }
209
210    /// Parameters the implementation rejects are reported rather than assumed
211    /// away.
212    ///
213    /// The production constructor cannot produce such a set — that is the point
214    /// of compiling the cost in — but building `Params` is fallible and the
215    /// failure has to have somewhere to go.
216    #[test]
217    fn parameters_argon2_refuses_are_reported() {
218        let broken = Argon2Kdf {
219            m_cost: 0,
220            t_cost: 0,
221            parallelism: 0,
222        };
223
224        let error = broken
225            .derive(b"passphrase", &salt(1))
226            .map(|_| ())
227            .expect_err("a memory cost of zero must be refused");
228
229        assert!(matches!(error, KdfError::Argon2Error(_)), "got: {error:?}");
230    }
231
232    /// The production parameters are the ones this project considers secure.
233    #[test]
234    fn the_default_deriver_carries_the_compiled_in_cost() {
235        let kdf = Argon2Kdf::default_secure();
236
237        assert_eq!(
238            (kdf.m_cost, kdf.t_cost, kdf.parallelism),
239            (M_COST, T_COST, PARALLELISM)
240        );
241    }
242
243    /// Both failures explain themselves.
244    #[test]
245    fn every_failure_explains_itself() {
246        assert!(KdfError::EmptyPassword.to_string().contains("empty"));
247        assert!(KdfError::Argon2Error("bad params".to_owned())
248            .to_string()
249            .contains("bad params"));
250    }
251}