Skip to main content

vault_core/
entropy.rs

1//! Entropy sources for secret generation.
2//!
3//! The point of this module is a rule that is easy to state and easy to get
4//! wrong: **hardware entropy is mixed in, never consumed raw.**
5//!
6//! A physical source can be absent, unplugged mid-read, wedged returning
7//! constant bytes, counterfeit, or deliberately backdoored. If its output were
8//! used directly, anyone who controlled the device would control every key
9//! generated on that machine — strictly worse than the OS RNG the feature was
10//! meant to improve on. Mixing means the result is at least as good as
11//! `getrandom` no matter what the device does:
12//!
13//! ```text
14//! output = HKDF-SHA256(ikm = os_bytes ‖ device_bytes, salt = domain, info = purpose)
15//! ```
16//!
17//! Three consequences, all deliberate:
18//!
19//! * **The default never changes.** [`Source::Os`] is what every generator uses
20//!   unless a caller asks for something else.
21//! * **Absence fails closed.** A selected device that cannot be read is an
22//!   error, not a quiet fallback — otherwise the user believes they used the
23//!   token and did not, which is the one outcome worse than not having the
24//!   feature.
25//! * **Generation only.** The vault salt, the Argon2 salt and backup IVs stay on
26//!   the OS RNG. A salt that depends on a device turns a lost device into a lost
27//!   vault, and this module exists to reduce risk, not to add a new way to lose
28//!   everything.
29
30use std::fmt;
31use std::io::Read;
32use std::time::Duration;
33
34use hmac::{Hmac, Mac};
35use sha2::Sha256;
36
37type HmacSha256 = Hmac<Sha256>;
38
39/// Domain separator, so bytes produced here can never collide with bytes some
40/// other part of the program derives from the same inputs.
41const HKDF_SALT: &[u8] = b"envvault/entropy/v1";
42
43/// Where the extra entropy comes from.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum Source {
46    /// The operating system CSPRNG (`getrandom`). The default, and the floor
47    /// that mixing guarantees every other variant stays above.
48    Os,
49    /// A character device or file — `/dev/random`, an rng-tools device, a
50    /// hardware RNG exposed as a node. Read with a timeout, because
51    /// `/dev/random` can block and a generator pane that hangs forever is a bug
52    /// report, not a security feature.
53    File { path: std::path::PathBuf },
54}
55
56impl Source {
57    /// Parse the `--entropy-source` argument.
58    pub fn parse(spec: &str) -> Result<Self, String> {
59        let spec = spec.trim();
60        match spec {
61            "os" | "" => Ok(Source::Os),
62            other => match other.split_once(':') {
63                Some(("file", p)) if !p.is_empty() => Ok(Source::File {
64                    path: std::path::PathBuf::from(p),
65                }),
66                Some(("file", _)) => Err("file: needs a path, e.g. file:/dev/random".into()),
67                // Named rather than ignored. "pkcs11 is not built in" is a
68                // different problem from "pkcs11 is not a thing", and a user who
69                // typed it deserves to know which.
70                Some(("pkcs11", _)) | Some(("tpm", _)) => Err(format!(
71                    "Entropy source '{other}' is not available in this build. \
72                     Available: os, file:PATH"
73                )),
74                _ => Err(format!(
75                    "Unknown entropy source '{other}'. Available: os, file:PATH"
76                )),
77            },
78        }
79    }
80
81    /// True when this source contributes bytes from outside the OS CSPRNG.
82    pub fn is_external(&self) -> bool {
83        !matches!(self, Source::Os)
84    }
85
86    /// Human label, also what the CLI reports in its output metadata so a caller
87    /// can tell how a secret was produced.
88    pub fn label(&self) -> String {
89        match self {
90            Source::Os => "os".into(),
91            Source::File { path } => format!("file:{}", path.display()),
92        }
93    }
94
95    /// Whether this machine can actually use the source right now.
96    pub fn availability(&self) -> Availability {
97        match self {
98            Source::Os => Availability::Ready,
99            Source::File { path } => {
100                if !path.exists() {
101                    Availability::Missing(format!("{} does not exist", path.display()))
102                } else if std::fs::File::open(path).is_err() {
103                    Availability::Missing(format!("{} is not readable", path.display()))
104                } else {
105                    Availability::Ready
106                }
107            }
108        }
109    }
110}
111
112impl fmt::Display for Source {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        write!(f, "{}", self.label())
115    }
116}
117
118/// Result of probing a source without using it.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum Availability {
121    Ready,
122    Missing(String),
123}
124
125/// Read `buf.len()` bytes from the source itself, before mixing.
126fn read_external(source: &Source, buf: &mut [u8], timeout: Duration) -> Result<(), String> {
127    match source {
128        Source::Os => unreachable!("Os is never read as an external source"),
129        Source::File { path } => {
130            // A blocking source must fail loudly rather than hang. The thread is
131            // detached deliberately: it may be parked in a blocking read on
132            // /dev/random that no timeout can interrupt, and leaking one thread
133            // beats wedging the caller.
134            let (tx, rx) = std::sync::mpsc::channel();
135            let p = path.clone();
136            let n = buf.len();
137            std::thread::spawn(move || {
138                let mut out = vec![0u8; n];
139                let r = std::fs::File::open(&p).and_then(|mut f| f.read_exact(&mut out));
140                let _ = tx.send(r.map(|_| out).map_err(|e| e.to_string()));
141            });
142            match rx.recv_timeout(timeout) {
143                Ok(Ok(bytes)) => {
144                    buf.copy_from_slice(&bytes);
145                    Ok(())
146                }
147                Ok(Err(e)) => Err(format!("Cannot read {}: {e}", path.display())),
148                Err(_) => Err(format!(
149                    "{} produced no bytes within {:?}. A blocking source such as \
150                     /dev/random may be waiting for the entropy pool to refill.",
151                    path.display(),
152                    timeout
153                )),
154            }
155        }
156    }
157}
158
159/// NIST SP 800-90B repetition-count test.
160///
161/// Catches the realistic hardware failure: a device that has stopped and is
162/// returning the same byte forever. Cheap, and necessary *because* of the
163/// mixing — HKDF would turn a constant input into perfectly random-looking
164/// output, so without this check a dead device is indistinguishable from a
165/// working one and the user keeps believing they have hardware entropy.
166fn repetition_count_ok(bytes: &[u8]) -> bool {
167    // Cutoff for 8-bit samples at the usual α = 2^-30 with a conservative
168    // 1 bit/byte min-entropy assumption.
169    const CUTOFF: usize = 32;
170    let mut run = 1usize;
171    for w in bytes.windows(2) {
172        if w[0] == w[1] {
173            run += 1;
174            if run >= CUTOFF {
175                return false;
176            }
177        } else {
178            run = 1;
179        }
180    }
181    true
182}
183
184/// NIST SP 800-90B adaptive-proportion test, single window.
185///
186/// Catches a source that is not stuck but is badly biased — one value dominating
187/// the window.
188fn adaptive_proportion_ok(bytes: &[u8]) -> bool {
189    const WINDOW: usize = 512;
190    const CUTOFF: usize = 410; // ~80% of the window
191    if bytes.len() < WINDOW {
192        return true; // too little data to judge; the repetition test still applies
193    }
194    for chunk in bytes.chunks(WINDOW) {
195        if chunk.len() < WINDOW {
196            break;
197        }
198        let mut counts = [0usize; 256];
199        for &b in chunk {
200            counts[b as usize] += 1;
201            if counts[b as usize] >= CUTOFF {
202                return false;
203            }
204        }
205    }
206    true
207}
208
209/// Run both health tests, naming which one failed.
210pub fn health_check(bytes: &[u8]) -> Result<(), String> {
211    if !repetition_count_ok(bytes) {
212        return Err(
213            "Entropy source failed the repetition-count test — it is returning the same \
214             byte repeatedly, which is what a stopped or disconnected device looks like."
215                .into(),
216        );
217    }
218    if !adaptive_proportion_ok(bytes) {
219        return Err(
220            "Entropy source failed the adaptive-proportion test — its output is heavily \
221             biased toward one value."
222                .into(),
223        );
224    }
225    Ok(())
226}
227
228fn hkdf_sha256(ikm: &[u8], info: &[u8], out: &mut [u8]) -> Result<(), String> {
229    // Extract
230    let mut mac = HmacSha256::new_from_slice(HKDF_SALT).map_err(|e| e.to_string())?;
231    mac.update(ikm);
232    let prk = mac.finalize().into_bytes();
233
234    // Expand
235    let mut t: Vec<u8> = Vec::new();
236    let mut counter: u8 = 1;
237    let mut written = 0;
238    while written < out.len() {
239        let mut mac = HmacSha256::new_from_slice(&prk).map_err(|e| e.to_string())?;
240        mac.update(&t);
241        mac.update(info);
242        mac.update(&[counter]);
243        t = mac.finalize().into_bytes().to_vec();
244        let take = (out.len() - written).min(t.len());
245        out[written..written + take].copy_from_slice(&t[..take]);
246        written += take;
247        counter = counter
248            .checked_add(1)
249            .ok_or_else(|| "HKDF output length exceeds one round".to_string())?;
250    }
251    Ok(())
252}
253
254/// Fill `buf` with random bytes from `source`, mixed with OS entropy.
255///
256/// `purpose` is HKDF `info` — a label such as `"secret"` or `"ssh-ed25519"`, so
257/// two generators asking for bytes in the same instant cannot receive the same
258/// stream.
259pub fn fill(source: &Source, purpose: &str, buf: &mut [u8]) -> Result<(), String> {
260    use rand::RngCore;
261
262    // OS bytes always, and always first. Even in the external case they are the
263    // floor under the result.
264    let mut os_bytes = vec![0u8; buf.len().max(32)];
265    rand::rngs::OsRng.fill_bytes(&mut os_bytes);
266
267    if !source.is_external() {
268        buf.copy_from_slice(&os_bytes[..buf.len()]);
269        return Ok(());
270    }
271
272    if let Availability::Missing(why) = source.availability() {
273        return Err(format!("Entropy source {source} is unavailable: {why}"));
274    }
275
276    // Read enough to health-test meaningfully even for a short request. A 16-byte
277    // secret is not enough data to judge a device on.
278    let probe_len = buf.len().max(1024);
279    let mut dev_bytes = vec![0u8; probe_len];
280    read_external(source, &mut dev_bytes, Duration::from_secs(10))?;
281    health_check(&dev_bytes)?;
282
283    let mut ikm = Vec::with_capacity(os_bytes.len() + dev_bytes.len());
284    ikm.extend_from_slice(&os_bytes);
285    ikm.extend_from_slice(&dev_bytes);
286    hkdf_sha256(&ikm, purpose.as_bytes(), buf)
287}
288
289/// A `RngCore` view over a [`Source`], for crates that generate keys themselves.
290///
291/// `ssh-key` and the certificate path take an RNG rather than bytes, so the
292/// mixing has to reach them through this rather than being applied afterwards.
293pub struct EntropyRng {
294    source: Source,
295    purpose: &'static str,
296}
297
298impl EntropyRng {
299    pub fn new(source: Source, purpose: &'static str) -> Self {
300        Self { source, purpose }
301    }
302}
303
304impl rand::RngCore for EntropyRng {
305    fn next_u32(&mut self) -> u32 {
306        let mut b = [0u8; 4];
307        self.fill_bytes(&mut b);
308        u32::from_le_bytes(b)
309    }
310    fn next_u64(&mut self) -> u64 {
311        let mut b = [0u8; 8];
312        self.fill_bytes(&mut b);
313        u64::from_le_bytes(b)
314    }
315    fn fill_bytes(&mut self, dest: &mut [u8]) {
316        // `RngCore::fill_bytes` cannot fail, and a key generator that silently
317        // produced weak bytes on a device error would be indefensible. Panicking
318        // is wrong too — so the fallible path is `try_fill_bytes`, and this one
319        // falls back to the OS RNG only for `Source::Os`, where there is nothing
320        // to fall back *from*.
321        if let Err(e) = self.try_fill_bytes(dest) {
322            panic!("entropy source failed during key generation: {e}");
323        }
324    }
325    fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand::Error> {
326        fill(&self.source, self.purpose, dest)
327            .map_err(|e| rand::Error::new(std::io::Error::other(e)))
328    }
329}
330
331impl rand::CryptoRng for EntropyRng {}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn os_is_the_default_and_parses_from_nothing() {
339        assert_eq!(Source::parse("").unwrap(), Source::Os);
340        assert_eq!(Source::parse("os").unwrap(), Source::Os);
341        assert!(!Source::Os.is_external());
342    }
343
344    #[test]
345    fn unbuilt_backends_say_so_rather_than_being_unknown() {
346        // "not available in this build" and "not a thing" are different problems.
347        let err = Source::parse("pkcs11:0").unwrap_err();
348        assert!(err.contains("not available in this build"), "{err}");
349        let err = Source::parse("banana").unwrap_err();
350        assert!(err.contains("Unknown entropy source"), "{err}");
351    }
352
353    #[test]
354    fn a_missing_device_fails_closed_and_produces_nothing() {
355        // The whole feature turns on this test. A user who selected a device and
356        // got OS bytes anyway believes something false about their key.
357        let src = Source::File {
358            path: "/nonexistent/entropy/device".into(),
359        };
360        let mut buf = [0u8; 32];
361        let err = fill(&src, "test", &mut buf).unwrap_err();
362        assert!(err.contains("unavailable"), "{err}");
363        assert_eq!(buf, [0u8; 32], "no bytes may be written on failure");
364    }
365
366    #[test]
367    fn a_stuck_device_is_caught_before_mixing_hides_it() {
368        // HKDF turns constant input into random-looking output, so without the
369        // health check a dead device is indistinguishable from a working one.
370        assert!(health_check(&[0x41; 4096]).is_err());
371        assert!(health_check(&[7u8; 64]).is_err());
372    }
373
374    #[test]
375    fn a_biased_but_unstuck_device_is_caught_too() {
376        let mut bytes = vec![0xAAu8; 512];
377        for (i, b) in bytes.iter_mut().enumerate() {
378            if i % 7 == 0 {
379                *b = i as u8; // breaks runs, keeps the bias
380            }
381        }
382        assert!(health_check(&bytes).is_err());
383    }
384
385    #[test]
386    fn real_random_bytes_pass_the_health_tests() {
387        use rand::RngCore;
388        let mut bytes = vec![0u8; 4096];
389        rand::rngs::OsRng.fill_bytes(&mut bytes);
390        assert!(health_check(&bytes).is_ok());
391    }
392
393    #[test]
394    fn mixing_changes_when_either_input_changes() {
395        // Both directions. If only one is tested, a mix that ignores the device
396        // still passes — and that is precisely the bug worth catching.
397        let mut a = [0u8; 32];
398        let mut b = [0u8; 32];
399        hkdf_sha256(b"os-part-1device-part", b"secret", &mut a).unwrap();
400        hkdf_sha256(b"os-part-2device-part", b"secret", &mut b).unwrap();
401        assert_ne!(a, b, "changing the OS half must change the output");
402
403        hkdf_sha256(b"os-part-1device-part", b"secret", &mut a).unwrap();
404        hkdf_sha256(b"os-part-1device-XXXX", b"secret", &mut b).unwrap();
405        assert_ne!(a, b, "changing the device half must change the output");
406    }
407
408    #[test]
409    fn purpose_separates_streams() {
410        let mut a = [0u8; 32];
411        let mut b = [0u8; 32];
412        hkdf_sha256(b"identical-input", b"secret", &mut a).unwrap();
413        hkdf_sha256(b"identical-input", b"ssh-ed25519", &mut b).unwrap();
414        assert_ne!(a, b);
415    }
416
417    #[test]
418    fn a_file_source_produces_usable_bytes() {
419        let src = Source::File {
420            path: "/dev/urandom".into(),
421        };
422        if src.availability() != Availability::Ready {
423            return; // not every CI box has it; the failure path is tested above
424        }
425        let mut buf = [0u8; 32];
426        fill(&src, "test", &mut buf).expect("read /dev/urandom");
427        assert_ne!(buf, [0u8; 32]);
428    }
429
430    #[test]
431    fn hkdf_fills_more_than_one_block() {
432        let mut long = [0u8; 100];
433        hkdf_sha256(b"input", b"info", &mut long).unwrap();
434        assert!(long.iter().any(|&b| b != 0));
435        // Second 32-byte block must differ from the first, or the counter is
436        // not being fed into the expand step.
437        assert_ne!(long[0..32], long[32..64]);
438    }
439}