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}