1use std::fmt;
31use std::io::Read;
32use std::time::Duration;
33
34use hmac::{Hmac, Mac};
35use sha2::Sha256;
36
37type HmacSha256 = Hmac<Sha256>;
38
39const HKDF_SALT: &[u8] = b"envvault/entropy/v1";
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum Source {
46 Os,
49 File { path: std::path::PathBuf },
54}
55
56impl Source {
57 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 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 pub fn is_external(&self) -> bool {
83 !matches!(self, Source::Os)
84 }
85
86 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 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#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum Availability {
121 Ready,
122 Missing(String),
123}
124
125fn 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 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
159fn repetition_count_ok(bytes: &[u8]) -> bool {
167 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
184fn adaptive_proportion_ok(bytes: &[u8]) -> bool {
189 const WINDOW: usize = 512;
190 const CUTOFF: usize = 410; if bytes.len() < WINDOW {
192 return true; }
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
209pub 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 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 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
254pub fn fill(source: &Source, purpose: &str, buf: &mut [u8]) -> Result<(), String> {
260 use rand::RngCore;
261
262 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 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
289pub 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 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 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 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 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; }
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 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; }
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 assert_ne!(long[0..32], long[32..64]);
438 }
439}