Skip to main content

stenoxide_core/crypto/
expand.rs

1//! HKDF-SHA3-512 expansion of the master key into domain-separated subkeys.
2//!
3//! One master key is not enough: the pipeline needs an encryption key, a nonce
4//! and a seed for the embedding permutation, and reusing the same bytes for all
5//! three would tie failures in one component to the security of the others.
6//! HKDF derives them independently, each under its own `info` string, so that
7//! knowing one reveals nothing about the rest.
8//!
9//! # Why SHA3 here and BLAKE2b in Argon2id
10//!
11//! Argon2id hashes internally with BLAKE2b; this expansion hashes with
12//! SHA3-512 (Keccak). The two are unrelated designs — a sponge construction
13//! against an ARX-based Merkle–Damgård variant — so a cryptanalytic advance
14//! against one family does not weaken the other. The chain
15//! `password → Argon2id → HKDF-SHA3-512 → subkeys` therefore has no single
16//! primitive whose break compromises every stage.
17
18use std::fmt;
19
20use hkdf::SimpleHkdf;
21use sha3::Sha3_512;
22use zeroize::ZeroizeOnDrop;
23
24use crate::crypto::kdf::MasterKey;
25
26/// Domain separator for the XChaCha20-Poly1305 encryption key.
27const INFO_ENC_KEY: &[u8] = b"STENOXIDE-v1-enc-key";
28
29/// Domain separator for the XChaCha20-Poly1305 nonce.
30const INFO_NONCE: &[u8] = b"STENOXIDE-v1-nonce";
31
32/// Domain separator for the seed of the Syndrome-Trellis Codes permutation.
33const INFO_STC_SEED: &[u8] = b"STENOXIDE-v1-stc-seed";
34
35/// Domain separator for the master key an ML-KEM-1024 shared secret stands in
36/// for.
37///
38/// The three separators above keep the subkeys of one master key apart from
39/// each other. This one keeps two *sources* of a master key apart: a password
40/// stretched by Argon2id, and a secret established by key encapsulation. Both
41/// arrive at [`expand_master_key`] and both leave it as the same three subkeys,
42/// so without this step a KEM secret and an Argon2id output that happened to
43/// agree would produce the same encryption key and the same nonce for two
44/// unrelated messages. The odds are negligible and the separation is free, and
45/// a construction that relies on two 256-bit values never colliding when it
46/// could simply not rely on it is one nobody can audit in a sentence.
47///
48/// It is versioned like the others: the day this crate encapsulates to
49/// something other than ML-KEM-1024, that scheme gets its own separator rather
50/// than inheriting this one.
51#[cfg(feature = "pqc")]
52const INFO_KEM_MASTER_KEY: &[u8] = b"STENOXIDE-v1-mlkem1024-master-key";
53
54/// Length of an ML-KEM-1024 shared secret, in bytes.
55#[cfg(feature = "pqc")]
56const SHARED_SECRET_LEN: usize = 32;
57
58/// Every way key expansion can fail.
59#[derive(Debug)]
60pub enum ExpandError {
61    /// HKDF refused to produce output of the requested length.
62    HkdfError(String),
63}
64
65impl fmt::Display for ExpandError {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        match self {
68            ExpandError::HkdfError(message) => {
69                write!(f, "hkdf-sha3-512 key expansion failed: {message}")
70            }
71        }
72    }
73}
74
75impl std::error::Error for ExpandError {}
76
77/// The three independent subkeys the pipeline runs on.
78///
79/// Every field is wiped when the value is dropped. The fields are readable
80/// inside the crate and through the accessors below; there is no constructor
81/// other than [`expand_master_key`], so a caller cannot assemble a set of
82/// subkeys that were not derived from a real master key.
83#[derive(ZeroizeOnDrop)]
84pub struct DerivedKeys {
85    /// XChaCha20-Poly1305 key used to encrypt the compressed payload.
86    pub(crate) enc_key: [u8; 32],
87    /// XChaCha20 extended nonce. Derived rather than random: the container's
88    /// perceptual hash already makes the master key unique per image, so a
89    /// stored nonce would be redundant metadata for an attacker to key on.
90    pub(crate) nonce: [u8; 24],
91    /// Seed of the Fisher-Yates permutation used by the embedding layer.
92    pub(crate) stc_seed: [u8; 32],
93}
94
95impl DerivedKeys {
96    /// Borrows the payload encryption key.
97    pub fn enc_key(&self) -> &[u8; 32] {
98        &self.enc_key
99    }
100
101    /// Borrows the extended nonce.
102    pub fn nonce(&self) -> &[u8; 24] {
103        &self.nonce
104    }
105
106    /// Borrows the permutation seed.
107    pub fn stc_seed(&self) -> &[u8; 32] {
108        &self.stc_seed
109    }
110}
111
112/// Expands a master key into the encryption key, nonce and permutation seed.
113///
114/// The master key is taken by reference so that this function never owns key
115/// material it did not create. Callers are expected to `drop(master_key)`
116/// explicitly as soon as this returns, which wipes it at the earliest point the
117/// ownership chain allows.
118///
119/// No HKDF salt is supplied. The extract step exists to condense a
120/// non-uniform secret into a uniform one, and the Argon2id output already is
121/// uniform; the per-container uniqueness that a salt would add is provided
122/// upstream by the perceptual hash used as the Argon2id salt.
123///
124/// # Errors
125///
126/// Returns [`ExpandError::HkdfError`] if HKDF rejects an output length. With
127/// the fixed lengths used here that cannot happen in practice, but the error is
128/// propagated rather than swallowed.
129pub fn expand_master_key(mk: &MasterKey) -> Result<DerivedKeys, ExpandError> {
130    // `SimpleHkdf`, not `Hkdf`. The two compute the same HMAC of RFC 2104 and
131    // agree byte for byte — `expansion_matches_pinned_vectors` is what holds
132    // that claim down — but they reach it differently. `Hkdf` builds on
133    // `Hmac<D>`, which requires `D: EagerHash` so it can precompute the padded
134    // states through the digest block API; `SimpleHkdf` builds on `SimpleHmac`,
135    // which asks only for `Digest + BlockSizeUser`.
136    //
137    // That distinction is what keeps this crate on current dependencies. As of
138    // `sha3` 0.12 the SHA-3 family is implemented as a self-contained sponge
139    // and no longer exposes the block API at all, so `Hmac<Sha3_512>` — and
140    // with it `Hkdf<Sha3_512>` — does not compile. The simple form does, and
141    // gives up nothing but an optimisation that is invisible next to the
142    // Argon2id pass preceding it.
143    let hkdf = SimpleHkdf::<Sha3_512>::new(None, mk.as_bytes());
144
145    // Started zeroed and filled in place: if an expansion fails midway, the
146    // partially written struct is dropped and wiped by `ZeroizeOnDrop`.
147    let mut keys = DerivedKeys {
148        enc_key: [0u8; 32],
149        nonce: [0u8; 24],
150        stc_seed: [0u8; 32],
151    };
152
153    hkdf.expand(INFO_ENC_KEY, &mut keys.enc_key)
154        .map_err(|err| ExpandError::HkdfError(err.to_string()))?;
155    hkdf.expand(INFO_NONCE, &mut keys.nonce)
156        .map_err(|err| ExpandError::HkdfError(err.to_string()))?;
157    hkdf.expand(INFO_STC_SEED, &mut keys.stc_seed)
158        .map_err(|err| ExpandError::HkdfError(err.to_string()))?;
159
160    Ok(keys)
161}
162
163/// The secret ML-KEM-1024 establishes between a sender and a recipient.
164///
165/// The buffer is wiped when the value is dropped. Like [`MasterKey`] it
166/// implements neither [`Clone`], [`Copy`] nor [`Debug`], and there is no
167/// accessor: the only thing this crate ever does with a shared secret is hand
168/// it to [`expand_shared_secret`], so nothing else needs to be able to read it.
169///
170/// [`MasterKey`]: crate::crypto::kdf::MasterKey
171#[cfg(feature = "pqc")]
172#[derive(ZeroizeOnDrop)]
173pub struct SharedSecret([u8; SHARED_SECRET_LEN]);
174
175#[cfg(feature = "pqc")]
176impl SharedSecret {
177    /// Takes ownership of the bytes an encapsulation or a decapsulation
178    /// produced.
179    ///
180    /// Restricted to the crate: outside code has no way to inject a secret that
181    /// no key exchange established.
182    pub(crate) fn new(bytes: [u8; SHARED_SECRET_LEN]) -> Self {
183        Self(bytes)
184    }
185}
186
187/// Expands an encapsulated shared secret into the same three subkeys a password
188/// would have produced.
189///
190/// The secret takes the place of the password *and* of the perceptual hash: it
191/// is fresh for every message and independent of the container, which is what
192/// makes reuse of a container harmless in this mode rather than merely
193/// discouraged. It passes through [`INFO_KEM_MASTER_KEY`] first, so the two
194/// sources of a master key can never meet; see that constant for why.
195///
196/// No HKDF salt is supplied here either, and for a stronger reason than in
197/// [`expand_master_key`]: an ML-KEM shared secret is already the output of a
198/// hash function over fresh randomness, so it is uniform by construction and
199/// the extract step has nothing left to condense.
200///
201/// # Errors
202///
203/// Returns [`ExpandError::HkdfError`] if HKDF rejects an output length, which
204/// with the fixed lengths used here it cannot.
205#[cfg(feature = "pqc")]
206pub fn expand_shared_secret(secret: &SharedSecret) -> Result<DerivedKeys, ExpandError> {
207    let hkdf = SimpleHkdf::<Sha3_512>::new(None, &secret.0);
208
209    // Wiped when this returns, on both paths: it is a second live image of key
210    // material, and the `MasterKey` built from it below is a third that
211    // `ZeroizeOnDrop` takes care of.
212    let mut master = zeroize::Zeroizing::new([0u8; SHARED_SECRET_LEN]);
213    hkdf.expand(INFO_KEM_MASTER_KEY, master.as_mut_slice())
214        .map_err(|err| ExpandError::HkdfError(err.to_string()))?;
215
216    let master_key = MasterKey::new(*master);
217    drop(master);
218
219    let keys = expand_master_key(&master_key)?;
220    drop(master_key);
221
222    Ok(keys)
223}
224
225#[cfg(test)]
226mod tests {
227    // The crate-wide `deny(clippy::expect_used)` reaches into `cfg(test)` code
228    // as well. A test that cannot panic cannot fail, so the ban is lifted here
229    // and only here — every `expect` below is an assertion about a value the
230    // test itself constructed.
231    #![allow(clippy::expect_used)]
232
233    use super::*;
234
235    /// Known-answer test pinning the output of the whole expansion.
236    ///
237    /// These vectors are not taken from a standard — there is no published one
238    /// for this particular chain — but from this implementation itself, and that
239    /// is exactly what makes them useful. Every subkey the system derives is a
240    /// function of `MasterKey` and three info strings, and nothing about that
241    /// function is transmitted or stored: sender and receiver each recompute it.
242    /// A dependency upgrade that silently altered a single byte here would not
243    /// break a build or fail a round trip run entirely on the new version; it
244    /// would simply make every image produced by an older build unreadable, and
245    /// the first evidence would be a user with an unrecoverable payload.
246    ///
247    /// The values were captured under `sha3` 0.11 with `hkdf::Hkdf` and verified
248    /// unchanged after moving to `sha3` 0.12 with [`SimpleHkdf`], which is the
249    /// migration they were written for.
250    #[test]
251    fn expansion_matches_pinned_vectors() {
252        const ENC_KEY: [u8; 32] = [
253            0x9a, 0x09, 0x5f, 0x87, 0xbf, 0x45, 0x5d, 0x1c, 0x30, 0x61, 0x94, 0xd1, 0x58, 0xdb,
254            0x7c, 0xfa, 0x6b, 0x10, 0xd9, 0xe6, 0x29, 0xd9, 0xb1, 0x43, 0xcd, 0x3b, 0xb6, 0x76,
255            0x89, 0xd5, 0xb9, 0x36,
256        ];
257        const NONCE: [u8; 24] = [
258            0x34, 0x83, 0xe6, 0x2d, 0x0b, 0xae, 0x7f, 0xae, 0x8d, 0x13, 0x77, 0x3a, 0x98, 0x97,
259            0x89, 0x3b, 0x97, 0xcb, 0x56, 0x66, 0x0f, 0x49, 0xee, 0x3f,
260        ];
261        const STC_SEED: [u8; 32] = [
262            0x35, 0x52, 0xd3, 0x1e, 0x7e, 0x52, 0xdb, 0xa7, 0x77, 0xf8, 0x75, 0xd4, 0xa4, 0x86,
263            0xb2, 0xea, 0x5f, 0x38, 0x08, 0xaa, 0xa1, 0x4d, 0x0d, 0xeb, 0x21, 0x31, 0x4e, 0x62,
264            0x42, 0x90, 0x8e, 0x11,
265        ];
266
267        let keys = expand_master_key(&MasterKey::new([7u8; 32])).expect("expansion must succeed");
268
269        assert_eq!(keys.enc_key(), &ENC_KEY);
270        assert_eq!(keys.nonce(), &NONCE);
271        assert_eq!(keys.stc_seed(), &STC_SEED);
272    }
273
274    /// The three subkeys must be independent draws, not the same bytes reused.
275    ///
276    /// They differ only by their info string, so this is what would catch the
277    /// domain separation being dropped or two constants colliding.
278    #[test]
279    fn subkeys_are_domain_separated() {
280        let keys = expand_master_key(&MasterKey::new([1u8; 32])).expect("expansion must succeed");
281
282        assert_ne!(keys.enc_key().as_slice(), keys.stc_seed().as_slice());
283        assert_ne!(&keys.enc_key()[..24], keys.nonce().as_slice());
284    }
285
286    /// The two sources of a master key never meet, even on identical bytes.
287    ///
288    /// The one property [`INFO_KEM_MASTER_KEY`] exists for, asserted the only
289    /// way it can be: by feeding the same thirty-two bytes down both paths and
290    /// demanding that every subkey differ. A separator that was dropped, or
291    /// copied from one of the three above, would fail here and nowhere else —
292    /// the round trips would all still pass, because each mode is
293    /// self-consistent whatever the separator says.
294    #[cfg(feature = "pqc")]
295    #[test]
296    fn a_shared_secret_and_a_password_never_derive_the_same_keys() {
297        const BYTES: [u8; 32] = [0x5Eu8; 32];
298
299        let from_password = expand_master_key(&MasterKey::new(BYTES)).expect("expansion");
300        let from_kem = expand_shared_secret(&SharedSecret::new(BYTES)).expect("expansion");
301
302        assert_ne!(from_password.enc_key(), from_kem.enc_key());
303        assert_ne!(from_password.nonce(), from_kem.nonce());
304        assert_ne!(from_password.stc_seed(), from_kem.stc_seed());
305
306        // And the encapsulated path is itself deterministic and
307        // domain-separated internally, since it ends in the same expansion.
308        let again = expand_shared_secret(&SharedSecret::new(BYTES)).expect("expansion");
309        assert_eq!(from_kem.enc_key(), again.enc_key());
310        assert_ne!(from_kem.enc_key().as_slice(), from_kem.stc_seed().as_slice());
311    }
312}