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}