microvms_app/entropy.rs
1// SPDX-License-Identifier: Apache-2.0
2//! The entropy port: where idempotency nonces, agent tokens and identity seeds come from.
3//!
4//! # Why a port for randomness
5//!
6//! Three values a launch mints are random by requirement, and each has a different reason:
7//!
8//! * the `clientToken` nonce must never repeat across attempts, because a replayed token
9//! wedges an image in `CREATING` for fifteen hours (TRAP-1, [`crate::control::token`]);
10//! * the agent token is a bearer credential, so it must be unguessable;
11//! * the identity seeds are x25519 secrets.
12//!
13//! Drawing them through [`Entropy`] rather than calling the OS pool inline keeps the use cases
14//! free of that I/O, and lets a test pin exactly which draw became which value.
15//!
16//! # No weak default
17//!
18//! Every production control plane gets `OsEntropy` (in `microvms-edges`) unless a caller swaps it, including one built over a
19//! caller's transport. A plane with a deterministic source would mint repeating client tokens,
20//! which is the exact failure TRAP-1 exists to prevent, so the only way to get one is to ask
21//! for it by name.
22
23use std::fmt;
24
25use protocol::identity::SEED_BYTES;
26
27use crate::error::{Error, ErrorKind};
28use crate::identity::LaunchIdentity;
29
30/// A source of random bytes.
31pub trait Entropy: Send + Sync + fmt::Debug {
32 /// Fills `buf` with fresh random bytes.
33 ///
34 /// Fails only when the source is unavailable. A caller refuses the operation then, never
35 /// falls back to a clock: a clock-derived nonce risks the collision the nonce exists to
36 /// prevent, and a clock-derived credential is guessable.
37 fn fill(&self, buf: &mut [u8]) -> Result<(), Error>;
38
39 /// A fresh identity seed.
40 fn seed(&self) -> Result<[u8; SEED_BYTES], Error> {
41 let mut seed = [0_u8; SEED_BYTES];
42 self.fill(&mut seed)?;
43 Ok(seed)
44 }
45}
46
47/// A launch identity from two fresh seeds drawn from `entropy`.
48///
49/// An all-zero draw isn't reachable from a working pool. It's still refused, because the
50/// protocol crate refuses a zero seed (a shared identity proves nothing), and a launch must
51/// never hand out material the daemon would reject at bootstrap. The refusal is the source's
52/// fault, not the caller's, so it isn't an invalid argument.
53pub fn launch_identity(entropy: &dyn Entropy) -> Result<LaunchIdentity, Error> {
54 let vm_seed = entropy.seed()?;
55 let host_seed = entropy.seed()?;
56 LaunchIdentity::from_seeds(vm_seed, host_seed)
57 .map_err(|error| Error::new(ErrorKind::Unexpected, error.to_string()))
58}
59
60#[cfg(any(test, feature = "test-support"))]
61#[cfg_attr(test, mutants::skip)] // a test double; see .cargo/mutants.toml
62pub(crate) mod testing {
63 //! The deterministic source the tests share.
64
65 use std::sync::atomic::{AtomicU64, Ordering};
66
67 use super::*;
68
69 /// Deterministic bytes, distinct per call, so a test can say which draw became which value.
70 ///
71 /// Call `n` (from 1) fills byte `i` with byte `i % 8` of `n`, little-endian, XORed with
72 /// `i`. The first eight bytes encode `n` one-to-one, so two calls never produce the same
73 /// fill, and no fill longer than eight bytes is all zero, so a seed drawn from it passes the
74 /// protocol's check.
75 #[derive(Debug, Default)]
76 pub struct SequenceEntropy {
77 calls: AtomicU64,
78 unavailable: bool,
79 }
80
81 impl SequenceEntropy {
82 pub fn new() -> Self {
83 Self::default()
84 }
85
86 /// A source whose every draw fails, the way an unavailable OS pool does.
87 pub fn unavailable() -> Self {
88 Self {
89 unavailable: true,
90 ..Self::default()
91 }
92 }
93
94 /// The bytes call `n` (from 1) fills a buffer of `len` with.
95 pub fn draw(n: u64, len: usize) -> Vec<u8> {
96 let counter = n.to_le_bytes();
97 (0..len)
98 .map(|index| counter[index % counter.len()] ^ (index as u8))
99 .collect()
100 }
101
102 /// How many fills were asked for, failed ones included.
103 pub fn calls(&self) -> u64 {
104 self.calls.load(Ordering::SeqCst)
105 }
106 }
107
108 impl Entropy for SequenceEntropy {
109 fn fill(&self, buf: &mut [u8]) -> Result<(), Error> {
110 let n = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
111 if self.unavailable {
112 return Err(Error::new(
113 ErrorKind::Unexpected,
114 "the scripted random pool is unavailable",
115 ));
116 }
117 buf.copy_from_slice(&Self::draw(n, buf.len()));
118 Ok(())
119 }
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use std::collections::HashSet;
126
127 use super::testing::SequenceEntropy;
128 use super::*;
129
130 /// The scripted source is distinct per call and never all zero, which is what lets a
131 /// test's seeds pass the protocol's check.
132 #[test]
133 fn the_sequence_source_is_distinct_per_call_and_never_zero() {
134 let entropy = SequenceEntropy::new();
135 let seeds: HashSet<[u8; SEED_BYTES]> = (0..300)
136 .map(|_| entropy.seed().expect("scripted"))
137 .collect();
138 assert_eq!(seeds.len(), 300);
139 assert!(seeds.iter().all(|seed| seed.iter().any(|byte| *byte != 0)));
140 }
141
142 /// Both seeds of a launch identity come from the source, in order: VM seed, then host.
143 ///
144 /// **Falsification**: draw the seeds from `OsEntropy` inside `launch_identity` and the VM
145 /// seed stops being the first scripted draw.
146 #[test]
147 fn a_launch_identity_is_the_source_s_next_two_seeds() {
148 use base64::Engine as _;
149 let entropy = SequenceEntropy::new();
150 let identity = launch_identity(&entropy).expect("valid seeds");
151 let expected =
152 base64::engine::general_purpose::STANDARD.encode(SequenceEntropy::draw(1, SEED_BYTES));
153 assert_eq!(identity.seed_field(), expected);
154 assert_eq!(entropy.calls(), 2);
155 }
156
157 /// An unavailable source refuses the identity; it doesn't panic.
158 #[test]
159 fn an_unavailable_source_refuses_the_identity() {
160 let err = launch_identity(&SequenceEntropy::unavailable()).expect_err("no pool");
161 assert_eq!(err.kind(), ErrorKind::Unexpected);
162 }
163}