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