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/// Every way key expansion can fail.
36#[derive(Debug)]
37pub enum ExpandError {
38    /// HKDF refused to produce output of the requested length.
39    HkdfError(String),
40}
41
42impl fmt::Display for ExpandError {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            ExpandError::HkdfError(message) => {
46                write!(f, "hkdf-sha3-512 key expansion failed: {message}")
47            }
48        }
49    }
50}
51
52impl std::error::Error for ExpandError {}
53
54/// The three independent subkeys the pipeline runs on.
55///
56/// Every field is wiped when the value is dropped. The fields are readable
57/// inside the crate and through the accessors below; there is no constructor
58/// other than [`expand_master_key`], so a caller cannot assemble a set of
59/// subkeys that were not derived from a real master key.
60#[derive(ZeroizeOnDrop)]
61pub struct DerivedKeys {
62    /// XChaCha20-Poly1305 key used to encrypt the compressed payload.
63    pub(crate) enc_key: [u8; 32],
64    /// XChaCha20 extended nonce. Derived rather than random: the container's
65    /// perceptual hash already makes the master key unique per image, so a
66    /// stored nonce would be redundant metadata for an attacker to key on.
67    pub(crate) nonce: [u8; 24],
68    /// Seed of the Fisher-Yates permutation used by the embedding layer.
69    pub(crate) stc_seed: [u8; 32],
70}
71
72impl DerivedKeys {
73    /// Borrows the payload encryption key.
74    pub fn enc_key(&self) -> &[u8; 32] {
75        &self.enc_key
76    }
77
78    /// Borrows the extended nonce.
79    pub fn nonce(&self) -> &[u8; 24] {
80        &self.nonce
81    }
82
83    /// Borrows the permutation seed.
84    pub fn stc_seed(&self) -> &[u8; 32] {
85        &self.stc_seed
86    }
87}
88
89/// Expands a master key into the encryption key, nonce and permutation seed.
90///
91/// The master key is taken by reference so that this function never owns key
92/// material it did not create. Callers are expected to `drop(master_key)`
93/// explicitly as soon as this returns, which wipes it at the earliest point the
94/// ownership chain allows.
95///
96/// No HKDF salt is supplied. The extract step exists to condense a
97/// non-uniform secret into a uniform one, and the Argon2id output already is
98/// uniform; the per-container uniqueness that a salt would add is provided
99/// upstream by the perceptual hash used as the Argon2id salt.
100///
101/// # Errors
102///
103/// Returns [`ExpandError::HkdfError`] if HKDF rejects an output length. With
104/// the fixed lengths used here that cannot happen in practice, but the error is
105/// propagated rather than swallowed.
106pub fn expand_master_key(mk: &MasterKey) -> Result<DerivedKeys, ExpandError> {
107    // `SimpleHkdf`, not `Hkdf`. The two compute the same HMAC of RFC 2104 and
108    // agree byte for byte — `expansion_matches_pinned_vectors` is what holds
109    // that claim down — but they reach it differently. `Hkdf` builds on
110    // `Hmac<D>`, which requires `D: EagerHash` so it can precompute the padded
111    // states through the digest block API; `SimpleHkdf` builds on `SimpleHmac`,
112    // which asks only for `Digest + BlockSizeUser`.
113    //
114    // That distinction is what keeps this crate on current dependencies. As of
115    // `sha3` 0.12 the SHA-3 family is implemented as a self-contained sponge
116    // and no longer exposes the block API at all, so `Hmac<Sha3_512>` — and
117    // with it `Hkdf<Sha3_512>` — does not compile. The simple form does, and
118    // gives up nothing but an optimisation that is invisible next to the
119    // Argon2id pass preceding it.
120    let hkdf = SimpleHkdf::<Sha3_512>::new(None, mk.as_bytes());
121
122    // Started zeroed and filled in place: if an expansion fails midway, the
123    // partially written struct is dropped and wiped by `ZeroizeOnDrop`.
124    let mut keys = DerivedKeys {
125        enc_key: [0u8; 32],
126        nonce: [0u8; 24],
127        stc_seed: [0u8; 32],
128    };
129
130    hkdf.expand(INFO_ENC_KEY, &mut keys.enc_key)
131        .map_err(|err| ExpandError::HkdfError(err.to_string()))?;
132    hkdf.expand(INFO_NONCE, &mut keys.nonce)
133        .map_err(|err| ExpandError::HkdfError(err.to_string()))?;
134    hkdf.expand(INFO_STC_SEED, &mut keys.stc_seed)
135        .map_err(|err| ExpandError::HkdfError(err.to_string()))?;
136
137    Ok(keys)
138}
139
140#[cfg(test)]
141mod tests {
142    // The crate-wide `deny(clippy::expect_used)` reaches into `cfg(test)` code
143    // as well. A test that cannot panic cannot fail, so the ban is lifted here
144    // and only here — every `expect` below is an assertion about a value the
145    // test itself constructed.
146    #![allow(clippy::expect_used)]
147
148    use super::*;
149
150    /// Known-answer test pinning the output of the whole expansion.
151    ///
152    /// These vectors are not taken from a standard — there is no published one
153    /// for this particular chain — but from this implementation itself, and that
154    /// is exactly what makes them useful. Every subkey the system derives is a
155    /// function of `MasterKey` and three info strings, and nothing about that
156    /// function is transmitted or stored: sender and receiver each recompute it.
157    /// A dependency upgrade that silently altered a single byte here would not
158    /// break a build or fail a round trip run entirely on the new version; it
159    /// would simply make every image produced by an older build unreadable, and
160    /// the first evidence would be a user with an unrecoverable payload.
161    ///
162    /// The values were captured under `sha3` 0.11 with `hkdf::Hkdf` and verified
163    /// unchanged after moving to `sha3` 0.12 with [`SimpleHkdf`], which is the
164    /// migration they were written for.
165    #[test]
166    fn expansion_matches_pinned_vectors() {
167        const ENC_KEY: [u8; 32] = [
168            0x9a, 0x09, 0x5f, 0x87, 0xbf, 0x45, 0x5d, 0x1c, 0x30, 0x61, 0x94, 0xd1, 0x58, 0xdb,
169            0x7c, 0xfa, 0x6b, 0x10, 0xd9, 0xe6, 0x29, 0xd9, 0xb1, 0x43, 0xcd, 0x3b, 0xb6, 0x76,
170            0x89, 0xd5, 0xb9, 0x36,
171        ];
172        const NONCE: [u8; 24] = [
173            0x34, 0x83, 0xe6, 0x2d, 0x0b, 0xae, 0x7f, 0xae, 0x8d, 0x13, 0x77, 0x3a, 0x98, 0x97,
174            0x89, 0x3b, 0x97, 0xcb, 0x56, 0x66, 0x0f, 0x49, 0xee, 0x3f,
175        ];
176        const STC_SEED: [u8; 32] = [
177            0x35, 0x52, 0xd3, 0x1e, 0x7e, 0x52, 0xdb, 0xa7, 0x77, 0xf8, 0x75, 0xd4, 0xa4, 0x86,
178            0xb2, 0xea, 0x5f, 0x38, 0x08, 0xaa, 0xa1, 0x4d, 0x0d, 0xeb, 0x21, 0x31, 0x4e, 0x62,
179            0x42, 0x90, 0x8e, 0x11,
180        ];
181
182        let keys = expand_master_key(&MasterKey::new([7u8; 32])).expect("expansion must succeed");
183
184        assert_eq!(keys.enc_key(), &ENC_KEY);
185        assert_eq!(keys.nonce(), &NONCE);
186        assert_eq!(keys.stc_seed(), &STC_SEED);
187    }
188
189    /// The three subkeys must be independent draws, not the same bytes reused.
190    ///
191    /// They differ only by their info string, so this is what would catch the
192    /// domain separation being dropped or two constants colliding.
193    #[test]
194    fn subkeys_are_domain_separated() {
195        let keys = expand_master_key(&MasterKey::new([1u8; 32])).expect("expansion must succeed");
196
197        assert_ne!(keys.enc_key().as_slice(), keys.stc_seed().as_slice());
198        assert_ne!(&keys.enc_key()[..24], keys.nonce().as_slice());
199    }
200}