umsh_journal_store/
seed.rs

1//! Power-loss-safe entropy-pool seed records.
2//!
3//! One 32-byte seed per record, written through the shared
4//! [`record`](crate::record) engine so a torn write can only ever cost
5//! the *newest* seed, never the previous committed one. Which two pages
6//! the journal owns is the firmware's memory-map decision.
7//!
8//! The seed is what `umsh_crypto::pool::EntropyPool` ratchets forward
9//! at every boot; the write cadence is once per boot-that-drew plus the
10//! occasional post-harvest refresh, so at 64 records per page the pair
11//! sees an erase every couple hundred boots — wear is a non-issue.
12
13use crate::record::{crc32, generation_is_newer};
14
15pub const SLOT_SIZE: usize = 64;
16pub const COMMIT_OFFSET: usize = SLOT_SIZE - 4;
17const CRC_OFFSET: usize = COMMIT_OFFSET - 4;
18const MAGIC: [u8; 4] = *b"URNG";
19const VERSION: u8 = 1;
20const SEED_OFFSET: usize = 12;
21
22/// One committed pool seed.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub struct SeedRecord {
25    pub generation: u32,
26    pub seed: [u8; 32],
27}
28
29impl SeedRecord {
30    pub fn encode(&self) -> [u8; SLOT_SIZE] {
31        let mut out = [0xff; SLOT_SIZE];
32        out[..4].copy_from_slice(&MAGIC);
33        out[4] = VERSION;
34        out[5..8].fill(0);
35        out[8..12].copy_from_slice(&self.generation.to_le_bytes());
36        out[SEED_OFFSET..SEED_OFFSET + 32].copy_from_slice(&self.seed);
37        let crc = crc32(&out[..CRC_OFFSET]);
38        out[CRC_OFFSET..COMMIT_OFFSET].copy_from_slice(&crc.to_le_bytes());
39        out
40    }
41
42    pub fn decode(bytes: &[u8; SLOT_SIZE]) -> Option<Self> {
43        if bytes[COMMIT_OFFSET..] != [0, 0, 0, 0]
44            || bytes[..4] != MAGIC
45            || bytes[4] != VERSION
46            || crc32(&bytes[..CRC_OFFSET])
47                != u32::from_le_bytes(bytes[CRC_OFFSET..COMMIT_OFFSET].try_into().ok()?)
48        {
49            return None;
50        }
51        let seed: [u8; 32] = bytes[SEED_OFFSET..SEED_OFFSET + 32].try_into().ok()?;
52        // An all-zero seed is far more plausibly erased-then-written
53        // garbage than a real 256-bit value; refuse it rather than boot
54        // a pool from it.
55        if seed == [0; 32] {
56            return None;
57        }
58        Some(Self {
59            generation: u32::from_le_bytes(bytes[8..12].try_into().ok()?),
60            seed,
61        })
62    }
63}
64
65/// Fold one slot's bytes into the newest-record scan.
66pub fn consider_record(
67    current: Option<(u32, SeedRecord)>,
68    address: u32,
69    bytes: &[u8; SLOT_SIZE],
70) -> Option<(u32, SeedRecord)> {
71    let Some(candidate) = SeedRecord::decode(bytes) else {
72        return current;
73    };
74    if current
75        .as_ref()
76        .is_none_or(|(_, record)| generation_is_newer(candidate.generation, record.generation))
77    {
78        Some((address, candidate))
79    } else {
80        current
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    fn sample() -> SeedRecord {
89        let mut seed = [0u8; 32];
90        for (index, byte) in seed.iter_mut().enumerate() {
91            *byte = index as u8 + 1;
92        }
93        SeedRecord {
94            generation: 7,
95            seed,
96        }
97    }
98
99    fn committed(record: &SeedRecord) -> [u8; SLOT_SIZE] {
100        let mut bytes = record.encode();
101        bytes[COMMIT_OFFSET..].fill(0);
102        bytes
103    }
104
105    #[test]
106    fn a_committed_record_round_trips() {
107        let record = sample();
108        assert_eq!(SeedRecord::decode(&committed(&record)), Some(record));
109    }
110
111    #[test]
112    fn an_uncommitted_record_is_ignored() {
113        // encode() leaves the commit word erased; only the engine's
114        // second write makes the record visible.
115        assert_eq!(SeedRecord::decode(&sample().encode()), None);
116    }
117
118    #[test]
119    fn a_corrupt_body_is_ignored() {
120        let mut bytes = committed(&sample());
121        bytes[SEED_OFFSET] ^= 0x40;
122        assert_eq!(SeedRecord::decode(&bytes), None);
123    }
124
125    #[test]
126    fn an_all_zero_seed_is_refused() {
127        let mut zeroed = sample();
128        zeroed.seed = [0; 32];
129        assert_eq!(SeedRecord::decode(&committed(&zeroed)), None);
130    }
131
132    #[test]
133    fn the_newest_generation_wins_across_slots() {
134        let old = committed(&sample());
135        let new = committed(&SeedRecord {
136            generation: 8,
137            ..sample()
138        });
139        let latest = consider_record(None, 0x1000, &old);
140        let latest = consider_record(latest, 0x1040, &new);
141        assert_eq!(
142            latest.map(|(address, r)| (address, r.generation)),
143            Some((0x1040, 8))
144        );
145        // Order independence.
146        let latest = consider_record(None, 0x1040, &new);
147        let latest = consider_record(latest, 0x1000, &old);
148        assert_eq!(latest.map(|(_, r)| r.generation), Some(8));
149    }
150
151    #[test]
152    fn a_torn_newer_record_leaves_the_older_one_current() {
153        let old = committed(&sample());
154        // Body fully written, commit word never landed.
155        let torn = SeedRecord {
156            generation: 8,
157            ..sample()
158        }
159        .encode();
160        let latest = consider_record(None, 0x1000, &old);
161        let latest = consider_record(latest, 0x1040, &torn);
162        assert_eq!(latest.map(|(_, r)| r.generation), Some(7));
163    }
164
165    #[test]
166    fn generation_wraparound_prefers_the_wrapped_record() {
167        let pre_wrap = committed(&SeedRecord {
168            generation: u32::MAX,
169            ..sample()
170        });
171        let wrapped = committed(&SeedRecord {
172            generation: 0,
173            ..sample()
174        });
175        let latest = consider_record(None, 0x1000, &pre_wrap);
176        let latest = consider_record(latest, 0x1040, &wrapped);
177        assert_eq!(latest.map(|(_, r)| r.generation), Some(0));
178    }
179}