Skip to main content

dynomite/entropy/
util.rs

1//! Pre-shared key and IV loading for the entropy reconciliation
2//! channel.
3//!
4//! The reconciliation channel uses AES-128-CBC with a 16-byte key
5//! and 16-byte IV held in two on-disk files at the conf-configured
6//! `recon_key.pem` and `recon_iv.pem` paths. Despite the `.pem`
7//! suffix, the bundled fixtures are plain ASCII files containing the
8//! key material followed by a trailing newline.
9//!
10//! The loader honours the contents of the file. To absorb
11//! the off-by-one in the bundled fixture (the file is
12//! `01234567890123456` -- seventeen characters, not sixteen) it
13//! takes the first [`ENTROPY_KEY_LEN`] / [`ENTROPY_IV_LEN`] bytes
14//! once trailing whitespace has been trimmed, provided the file
15//! contains at least that many bytes. This is recorded as a
16//! deviation in `docs/parity.md`.
17//!
18//! The loader accepts both shapes:
19//!
20//! * a raw secret followed by optional trailing whitespace
21//!   (matches the bundled fixtures), and
22//! * a `BEGIN/END`-armored PEM block whose decoded body is at
23//!   least 16 bytes long.
24//!
25//! Anything else is rejected.
26
27use std::fs;
28use std::path::Path;
29
30use crate::crypto::base64::base64_decode;
31use crate::entropy::EntropyError;
32
33/// Length in bytes of the AES-128 key consumed by the entropy
34/// channel.
35pub const ENTROPY_KEY_LEN: usize = 16;
36
37/// Length in bytes of the AES-128-CBC initialisation vector
38/// consumed by the entropy channel.
39pub const ENTROPY_IV_LEN: usize = 16;
40
41/// 16-byte AES-128 key for the entropy reconciliation channel.
42///
43/// # Examples
44///
45/// ```
46/// use dynomite::entropy::util::{EntropyKey, ENTROPY_KEY_LEN};
47/// let key = EntropyKey::from_bytes([0x10; ENTROPY_KEY_LEN]);
48/// assert_eq!(key.as_bytes().len(), ENTROPY_KEY_LEN);
49/// ```
50#[derive(Clone, Copy, Eq, PartialEq)]
51pub struct EntropyKey([u8; ENTROPY_KEY_LEN]);
52
53impl EntropyKey {
54    /// Wrap a fixed-size array.
55    #[must_use]
56    pub fn from_bytes(bytes: [u8; ENTROPY_KEY_LEN]) -> Self {
57        Self(bytes)
58    }
59
60    /// Borrow the raw key material.
61    #[must_use]
62    pub fn as_bytes(&self) -> &[u8; ENTROPY_KEY_LEN] {
63        &self.0
64    }
65}
66
67impl std::fmt::Debug for EntropyKey {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        f.debug_struct("EntropyKey")
70            .field("len", &ENTROPY_KEY_LEN)
71            .finish()
72    }
73}
74
75/// 16-byte AES-128-CBC IV for the entropy reconciliation channel.
76///
77/// # Examples
78///
79/// ```
80/// use dynomite::entropy::util::{EntropyIv, ENTROPY_IV_LEN};
81/// let iv = EntropyIv::from_bytes([0x42; ENTROPY_IV_LEN]);
82/// assert_eq!(iv.as_bytes().len(), ENTROPY_IV_LEN);
83/// ```
84#[derive(Clone, Copy, Eq, PartialEq)]
85pub struct EntropyIv([u8; ENTROPY_IV_LEN]);
86
87impl EntropyIv {
88    /// Wrap a fixed-size array.
89    #[must_use]
90    pub fn from_bytes(bytes: [u8; ENTROPY_IV_LEN]) -> Self {
91        Self(bytes)
92    }
93
94    /// Borrow the raw IV material.
95    #[must_use]
96    pub fn as_bytes(&self) -> &[u8; ENTROPY_IV_LEN] {
97        &self.0
98    }
99}
100
101impl std::fmt::Debug for EntropyIv {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        f.debug_struct("EntropyIv")
104            .field("len", &ENTROPY_IV_LEN)
105            .finish()
106    }
107}
108
109/// Pre-shared key + IV pair held by the entropy worker.
110///
111/// # Examples
112///
113/// ```
114/// use dynomite::entropy::util::{EntropyKey, EntropyIv, EntropyMaterial};
115/// let mat = EntropyMaterial::new(
116///     EntropyKey::from_bytes([0x10; 16]),
117///     EntropyIv::from_bytes([0x42; 16]),
118/// );
119/// assert_eq!(mat.key().as_bytes()[0], 0x10);
120/// assert_eq!(mat.iv().as_bytes()[0], 0x42);
121/// ```
122#[derive(Clone, Debug, Eq, PartialEq)]
123pub struct EntropyMaterial {
124    key: EntropyKey,
125    iv: EntropyIv,
126}
127
128impl EntropyMaterial {
129    /// Bundle a key and IV into a single material handle.
130    #[must_use]
131    pub fn new(key: EntropyKey, iv: EntropyIv) -> Self {
132        Self { key, iv }
133    }
134
135    /// Borrow the AES key.
136    #[must_use]
137    pub fn key(&self) -> &EntropyKey {
138        &self.key
139    }
140
141    /// Borrow the AES IV.
142    #[must_use]
143    pub fn iv(&self) -> &EntropyIv {
144        &self.iv
145    }
146}
147
148/// Strip the `-----BEGIN/END-----` armor and decode the base64
149/// body when present. Returns the input verbatim (less trailing
150/// whitespace) if no armor markers are found.
151fn parse_secret_bytes(text: &str) -> Result<Vec<u8>, EntropyError> {
152    if text.contains("-----BEGIN") {
153        return decode_pem_block(text);
154    }
155    let trimmed = text.trim_end_matches(['\r', '\n', ' ', '\t']);
156    Ok(trimmed.as_bytes().to_vec())
157}
158
159/// Minimal PEM block decoder: locates the first `-----BEGIN ...-----`
160/// line, gathers everything up to the matching `-----END ...-----`
161/// line, base64-decodes the body. Header/key-value lines inside the
162/// block are not supported (the entropy loader does not produce
163/// them).
164fn decode_pem_block(text: &str) -> Result<Vec<u8>, EntropyError> {
165    let mut lines = text.lines();
166    while let Some(line) = lines.next() {
167        if line.trim_start().starts_with("-----BEGIN") {
168            let mut body = String::new();
169            let mut saw_end = false;
170            for inner in lines.by_ref() {
171                let trimmed = inner.trim();
172                if trimmed.starts_with("-----END") {
173                    saw_end = true;
174                    break;
175                }
176                body.push_str(trimmed);
177            }
178            if !saw_end {
179                return Err(EntropyError::KeyMaterial(
180                    "PEM block missing END marker".to_string(),
181                ));
182            }
183            return base64_decode(&body)
184                .map_err(|e| EntropyError::KeyMaterial(format!("PEM base64 decode: {e}")));
185        }
186    }
187    Err(EntropyError::KeyMaterial(
188        "PEM block missing BEGIN marker".to_string(),
189    ))
190}
191
192/// Read the AES key from `path`. The file must contain exactly
193/// 16 bytes of key material (raw or PEM-armored).
194///
195/// # Errors
196/// Returns [`EntropyError::Io`] if the file cannot be read and
197/// [`EntropyError::KeyMaterial`] if the contents do not yield
198/// exactly [`ENTROPY_KEY_LEN`] bytes.
199///
200/// # Examples
201///
202/// ```no_run
203/// use std::path::Path;
204/// use dynomite::entropy::util::load_key_file;
205/// let key = load_key_file(Path::new("/etc/dynomite/recon_key.pem")).unwrap();
206/// assert_eq!(key.as_bytes().len(), 16);
207/// ```
208pub fn load_key_file(path: &Path) -> Result<EntropyKey, EntropyError> {
209    let raw = fs::read_to_string(path).map_err(|e| io_err(path, "read key file", &e))?;
210    let bytes = parse_secret_bytes(&raw)?;
211    if bytes.len() < ENTROPY_KEY_LEN {
212        return Err(EntropyError::KeyMaterial(format!(
213            "expected at least {ENTROPY_KEY_LEN} bytes of key material in {}, got {}",
214            path.display(),
215            bytes.len()
216        )));
217    }
218    let mut out = [0u8; ENTROPY_KEY_LEN];
219    out.copy_from_slice(&bytes[..ENTROPY_KEY_LEN]);
220    Ok(EntropyKey(out))
221}
222
223/// Read the AES IV from `path`. The file must contain exactly
224/// 16 bytes of IV material (raw or PEM-armored).
225///
226/// # Errors
227/// Returns [`EntropyError::Io`] if the file cannot be read and
228/// [`EntropyError::KeyMaterial`] if the contents do not yield
229/// exactly [`ENTROPY_IV_LEN`] bytes.
230///
231/// # Examples
232///
233/// ```no_run
234/// use std::path::Path;
235/// use dynomite::entropy::util::load_iv_file;
236/// let iv = load_iv_file(Path::new("/etc/dynomite/recon_iv.pem")).unwrap();
237/// assert_eq!(iv.as_bytes().len(), 16);
238/// ```
239pub fn load_iv_file(path: &Path) -> Result<EntropyIv, EntropyError> {
240    let raw = fs::read_to_string(path).map_err(|e| io_err(path, "read iv file", &e))?;
241    let bytes = parse_secret_bytes(&raw)?;
242    if bytes.len() < ENTROPY_IV_LEN {
243        return Err(EntropyError::KeyMaterial(format!(
244            "expected at least {ENTROPY_IV_LEN} bytes of IV material in {}, got {}",
245            path.display(),
246            bytes.len()
247        )));
248    }
249    let mut out = [0u8; ENTROPY_IV_LEN];
250    out.copy_from_slice(&bytes[..ENTROPY_IV_LEN]);
251    Ok(EntropyIv(out))
252}
253
254/// Convenience wrapper that loads both files and bundles them.
255///
256/// # Errors
257/// Forwarded from [`load_key_file`] / [`load_iv_file`]. Both files
258/// are read; if both fail only the first error is returned.
259///
260/// # Examples
261///
262/// ```no_run
263/// use std::path::PathBuf;
264/// use dynomite::entropy::util::load_material;
265/// let mat = load_material(
266///     &PathBuf::from("/etc/dynomite/recon_key.pem"),
267///     &PathBuf::from("/etc/dynomite/recon_iv.pem"),
268/// ).unwrap();
269/// assert_eq!(mat.key().as_bytes().len(), 16);
270/// ```
271pub fn load_material(key_file: &Path, iv_file: &Path) -> Result<EntropyMaterial, EntropyError> {
272    let key = load_key_file(key_file)?;
273    let iv = load_iv_file(iv_file)?;
274    Ok(EntropyMaterial::new(key, iv))
275}
276
277fn io_err(path: &Path, what: &str, e: &std::io::Error) -> EntropyError {
278    EntropyError::Io(std::io::Error::new(
279        e.kind(),
280        format!("{what} {}: {e}", path.display()),
281    ))
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use std::io::Write;
288    use tempfile::NamedTempFile;
289
290    fn write_temp(contents: &[u8]) -> NamedTempFile {
291        let mut f = NamedTempFile::new().unwrap();
292        f.write_all(contents).unwrap();
293        f.flush().unwrap();
294        f
295    }
296
297    #[test]
298    fn loads_raw_16_byte_key() {
299        let f = write_temp(b"0123456789012345\n");
300        let key = load_key_file(f.path()).unwrap();
301        assert_eq!(key.as_bytes(), b"0123456789012345");
302    }
303
304    #[test]
305    fn loads_raw_16_byte_iv() {
306        let f = write_temp(b"0123456789012345\n");
307        let iv = load_iv_file(f.path()).unwrap();
308        assert_eq!(iv.as_bytes(), b"0123456789012345");
309    }
310
311    #[test]
312    fn rejects_short_key() {
313        let f = write_temp(b"short\n");
314        let err = load_key_file(f.path()).unwrap_err();
315        assert!(matches!(err, EntropyError::KeyMaterial(_)));
316    }
317
318    #[test]
319    fn rejects_short_iv() {
320        let f = write_temp(b"short\n");
321        let err = load_iv_file(f.path()).unwrap_err();
322        assert!(matches!(err, EntropyError::KeyMaterial(_)));
323    }
324
325    #[test]
326    fn truncates_oversized_key_to_16_bytes() {
327        let f = write_temp(b"01234567890123456\n");
328        let key = load_key_file(f.path()).unwrap();
329        assert_eq!(key.as_bytes(), b"0123456789012345");
330    }
331
332    #[test]
333    fn truncates_oversized_iv_to_16_bytes() {
334        let f = write_temp(b"01234567890123456\n");
335        let iv = load_iv_file(f.path()).unwrap();
336        assert_eq!(iv.as_bytes(), b"0123456789012345");
337    }
338
339    #[test]
340    fn loads_pem_armored_16_bytes() {
341        // 16 bytes of 0x42 base64-armored.
342        let body: [u8; 16] = [0x42; 16];
343        let armored = format!(
344            "-----BEGIN ENTROPY KEY-----\n{}\n-----END ENTROPY KEY-----\n",
345            crate::crypto::base64::base64_encode(&body)
346        );
347        let f = write_temp(armored.as_bytes());
348        let key = load_key_file(f.path()).unwrap();
349        assert_eq!(key.as_bytes(), &body);
350    }
351
352    #[test]
353    fn missing_file_is_io_error() {
354        let path = Path::new("/nonexistent/dynomite/no-such-key");
355        let err = load_key_file(path).unwrap_err();
356        assert!(matches!(err, EntropyError::Io(_)));
357    }
358
359    #[test]
360    fn loads_bundled_recon_fixtures() {
361        // Bundled recon fixtures live with the crate's test data.
362        let crate_root = Path::new(env!("CARGO_MANIFEST_DIR"));
363        let key_path = crate_root.join("tests/fixtures/recon/recon_key.pem");
364        let iv_path = crate_root.join("tests/fixtures/recon/recon_iv.pem");
365        let key = load_key_file(&key_path).unwrap();
366        let iv = load_iv_file(&iv_path).unwrap();
367        // The bundled fixtures contain a 17-byte ASCII string
368        // ("01234567890123456"); the loader takes the first 16
369        // bytes, yielding the 16-byte key the cipher runs against.
370        assert_eq!(key.as_bytes(), b"0123456789012345");
371        assert_eq!(iv.as_bytes(), b"0123456789012345");
372    }
373}