umsh_crypto/pool.rs
1//! Persisted entropy pool: a flash-seeded, hash-ratcheted CSPRNG.
2//!
3//! The pool exists for platforms whose hardware entropy source is not
4//! always available — on the ESP32 the TRNG is only trustworthy while
5//! the RF subsystem is up, which used to chain the RNG's lifetime to
6//! the BLE controller's. A seed stored in flash breaks that chain: the
7//! pool is cryptographically strong from the first instruction of boot,
8//! and the hardware source becomes something it *harvests* when
9//! available rather than something it dies without.
10//!
11//! ## The seed-file protocol (Fortuna's, with a lazy write)
12//!
13//! 1. Read the stored seed `S` and build the pool with
14//! [`EntropyPool::from_seed`]. The working key is `HKDF(S, salt)`
15//! with per-boot salt (chip id, reset reason) — flash never holds
16//! the working key, and a flash image taken later reveals nothing
17//! about this session's outputs.
18//! 2. Before the first draw, write [`next_seed`](EntropyPool::next_seed)
19//! to flash and, once the write is confirmed, call
20//! [`seed_committed`](EntropyPool::seed_committed).
21//! 3. Draw. [`draw`](EntropyPool::draw) refuses until step 2 has
22//! happened — that ordering is the whole crash-safety story. A boot
23//! that dies before the commit replays a working key that never
24//! emitted a byte, which is harmless; a boot that dies after it
25//! ratchets forward next time. No boot counter is needed.
26//!
27//! The write is deliberately *lazy*: nothing touches flash until
28//! something actually wants randomness, so a reboot loop that dies
29//! before its first draw costs zero flash cycles.
30//!
31//! ## Mixing
32//!
33//! [`mix`](EntropyPool::mix) folds harvested entropy into the working
34//! key whenever a hardware source happens to be live. Mixing is what
35//! heals a compromised or cloned seed file, so callers should persist a
36//! fresh [`next_seed`](EntropyPool::next_seed) afterwards — but mixing
37//! never *invalidates* the commit, because replay safety comes from the
38//! boot-time ratchet, not from the stored seed tracking the live key.
39//!
40//! ## Construction
41//!
42//! Everything is HKDF-SHA256 over the platform's [`Sha256Provider`];
43//! there is no stream cipher because every consumer wants a small seed
44//! or nonce, not a keystream. Each draw ratchets the working key
45//! one-way, so compromising the pool later reveals nothing already
46//! emitted. Domain separation comes from distinct `info` strings plus
47//! the caller's label as HKDF salt.
48
49use zeroize::Zeroize;
50
51use crate::{Sha256Provider, hkdf_sha256};
52
53const INFO_BOOT: &[u8] = b"umsh-entropy-pool-v1 boot";
54const INFO_NEXT: &[u8] = b"umsh-entropy-pool-v1 next";
55const INFO_OUT: &[u8] = b"umsh-entropy-pool-v1 out";
56const INFO_RATCHET: &[u8] = b"umsh-entropy-pool-v1 ratchet";
57const INFO_MIX: &[u8] = b"umsh-entropy-pool-v1 mix";
58
59/// A draw was attempted before the next seed was committed to storage.
60///
61/// Serving output before the commit is the one ordering that can replay
62/// a stream after a crash, so it is a refusal rather than a footgun.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct DrawBeforeCommit;
65
66/// The pool: a 32-byte working key that only ever moves forward.
67pub struct EntropyPool<S> {
68 sha: S,
69 key: [u8; 32],
70 committed: bool,
71 dirty: bool,
72}
73
74impl<S> Drop for EntropyPool<S> {
75 fn drop(&mut self) {
76 self.key.zeroize();
77 }
78}
79
80impl<S: Sha256Provider> EntropyPool<S> {
81 /// Build the pool from the stored seed.
82 ///
83 /// `salt` is the per-boot uniqueness: chip id and reset reason are
84 /// free (no flash write) and make a restored flash image derive a
85 /// different stream on different hardware.
86 pub fn from_seed(sha: S, seed: &[u8; 32], salt: &[u8]) -> Self {
87 let mut key = [0u8; 32];
88 hkdf_sha256(&sha, seed, salt, INFO_BOOT, &mut key);
89 Self {
90 sha,
91 key,
92 committed: false,
93 dirty: false,
94 }
95 }
96
97 /// The seed the *next* boot should load. Derived one-way from the
98 /// working key, so a flash image reveals nothing about this
99 /// session, and independent of every draw label, so committing it
100 /// first leaks nothing about outputs.
101 pub fn next_seed(&self) -> [u8; 32] {
102 let mut seed = [0u8; 32];
103 hkdf_sha256(&self.sha, &self.key, &[], INFO_NEXT, &mut seed);
104 seed
105 }
106
107 /// The caller has confirmed [`next_seed`](Self::next_seed) is in
108 /// storage; draws are now permitted.
109 pub fn seed_committed(&mut self) {
110 self.committed = true;
111 }
112
113 /// Whether draws are currently permitted.
114 pub fn is_committed(&self) -> bool {
115 self.committed
116 }
117
118 /// Fill `out` with output bound to `label`, then ratchet the
119 /// working key so this output can never be re-derived from later
120 /// pool state.
121 pub fn draw(&mut self, label: &[u8], out: &mut [u8]) -> Result<(), DrawBeforeCommit> {
122 if !self.committed {
123 return Err(DrawBeforeCommit);
124 }
125 hkdf_sha256(&self.sha, &self.key, label, INFO_OUT, out);
126 self.ratchet();
127 Ok(())
128 }
129
130 /// Fold harvested entropy into the working key.
131 ///
132 /// Hash mixing means adversary-known input cannot reduce the pool's
133 /// entropy, only fail to add any — so anything cheap is fair game.
134 /// Marks the pool dirty: the stored seed no longer reflects the
135 /// best key we have, and the caller should persist a fresh
136 /// [`next_seed`](Self::next_seed) when convenient.
137 pub fn mix(&mut self, entropy: &[u8]) {
138 let mut next = [0u8; 32];
139 hkdf_sha256(&self.sha, entropy, &self.key, INFO_MIX, &mut next);
140 self.key.zeroize();
141 self.key = next;
142 self.dirty = true;
143 }
144
145 /// Whether a [`mix`](Self::mix) has made the stored seed stale.
146 pub fn is_dirty(&self) -> bool {
147 self.dirty
148 }
149
150 /// The caller has persisted a post-[`mix`](Self::mix) seed.
151 pub fn seed_refreshed(&mut self) {
152 self.dirty = false;
153 }
154
155 fn ratchet(&mut self) {
156 let mut next = [0u8; 32];
157 hkdf_sha256(&self.sha, &self.key, &[], INFO_RATCHET, &mut next);
158 self.key.zeroize();
159 self.key = next;
160 }
161}
162
163#[cfg(all(test, feature = "software-crypto"))]
164mod tests {
165 use super::*;
166 use crate::software::SoftwareSha256;
167
168 fn pool(seed: &[u8; 32], salt: &[u8]) -> EntropyPool<SoftwareSha256> {
169 EntropyPool::from_seed(SoftwareSha256, seed, salt)
170 }
171
172 #[test]
173 fn a_draw_is_refused_until_the_seed_is_committed() {
174 let mut p = pool(&[7; 32], b"salt");
175 let mut out = [0u8; 32];
176 assert_eq!(p.draw(b"x", &mut out), Err(DrawBeforeCommit));
177 p.seed_committed();
178 assert_eq!(p.draw(b"x", &mut out), Ok(()));
179 assert_ne!(out, [0u8; 32]);
180 }
181
182 #[test]
183 fn the_same_seed_and_salt_replay_the_same_stream() {
184 // The crash case: reboot before commit, load the same seed.
185 // Determinism is what makes never-output replay harmless to
186 // reason about.
187 let mut a = pool(&[1; 32], b"chip");
188 let mut b = pool(&[1; 32], b"chip");
189 a.seed_committed();
190 b.seed_committed();
191 let (mut oa, mut ob) = ([0u8; 32], [0u8; 32]);
192 a.draw(b"node", &mut oa).unwrap();
193 b.draw(b"node", &mut ob).unwrap();
194 assert_eq!(oa, ob);
195 }
196
197 #[test]
198 fn salt_separates_streams() {
199 let mut a = pool(&[1; 32], b"reset:power-on");
200 let mut b = pool(&[1; 32], b"reset:watchdog");
201 a.seed_committed();
202 b.seed_committed();
203 let (mut oa, mut ob) = ([0u8; 32], [0u8; 32]);
204 a.draw(b"node", &mut oa).unwrap();
205 b.draw(b"node", &mut ob).unwrap();
206 assert_ne!(oa, ob);
207 }
208
209 #[test]
210 fn labels_separate_outputs_within_one_boot() {
211 let mut p = pool(&[2; 32], b"salt");
212 p.seed_committed();
213 let (mut irk, mut node) = ([0u8; 16], [0u8; 16]);
214 // Re-derive the first label's output from a twin pool so the
215 // ratchet between draws is not what separates them.
216 p.draw(b"irk", &mut irk).unwrap();
217 let mut twin = pool(&[2; 32], b"salt");
218 twin.seed_committed();
219 twin.draw(b"node", &mut node).unwrap();
220 assert_ne!(irk, node);
221 }
222
223 #[test]
224 fn each_draw_ratchets_the_key() {
225 let mut p = pool(&[3; 32], b"salt");
226 p.seed_committed();
227 let (mut first, mut second) = ([0u8; 32], [0u8; 32]);
228 p.draw(b"same", &mut first).unwrap();
229 p.draw(b"same", &mut second).unwrap();
230 assert_ne!(first, second);
231 }
232
233 #[test]
234 fn the_next_seed_is_stable_across_draws_of_this_boot() {
235 // The commit happens before the draws; the value written must
236 // be the value the next boot loads regardless of what this
237 // session went on to do.
238 let p = pool(&[4; 32], b"salt");
239 let persisted = p.next_seed();
240 let mut p2 = pool(&[4; 32], b"salt");
241 p2.seed_committed();
242 let mut sink = [0u8; 32];
243 p2.draw(b"a", &mut sink).unwrap();
244 // p2's live key has ratcheted, but the seed written at commit
245 // time is what counts — recompute from a fresh twin.
246 assert_eq!(pool(&[4; 32], b"salt").next_seed(), persisted);
247 }
248
249 #[test]
250 fn the_next_seed_differs_from_every_output() {
251 let mut p = pool(&[5; 32], b"salt");
252 let seed = p.next_seed();
253 p.seed_committed();
254 let mut out = [0u8; 32];
255 p.draw(b"", &mut out).unwrap();
256 assert_ne!(seed, out);
257 }
258
259 #[test]
260 fn mixing_changes_the_next_seed_and_marks_dirty() {
261 let mut p = pool(&[6; 32], b"salt");
262 p.seed_committed();
263 let before = p.next_seed();
264 assert!(!p.is_dirty());
265 p.mix(b"trng bytes");
266 assert!(p.is_dirty());
267 assert_ne!(p.next_seed(), before);
268 p.seed_refreshed();
269 assert!(!p.is_dirty());
270 }
271
272 #[test]
273 fn mixing_does_not_revoke_the_commit() {
274 // Replay safety comes from the boot ratchet, not from the
275 // stored seed tracking the live key; a draw right after a mix
276 // is legal.
277 let mut p = pool(&[8; 32], b"salt");
278 p.seed_committed();
279 p.mix(b"harvest");
280 let mut out = [0u8; 32];
281 assert_eq!(p.draw(b"x", &mut out), Ok(()));
282 }
283
284 #[test]
285 fn mixed_streams_diverge_from_unmixed() {
286 let mut a = pool(&[9; 32], b"salt");
287 let mut b = pool(&[9; 32], b"salt");
288 a.seed_committed();
289 b.seed_committed();
290 b.mix(b"entropy");
291 let (mut oa, mut ob) = ([0u8; 32], [0u8; 32]);
292 a.draw(b"x", &mut oa).unwrap();
293 b.draw(b"x", &mut ob).unwrap();
294 assert_ne!(oa, ob);
295 }
296}