umsh_journal_store/
ble.rs

1//! Power-loss-safe BLE security snapshot encoding.
2//!
3//! Fixed-size snapshots (pairing PIN, local IRK, bonds) written through
4//! the shared [`record`](crate::record) engine. Which two pages the
5//! journal owns is the firmware's memory-map decision.
6
7use crate::record::{crc32, generation_is_newer};
8
9pub const MAX_BONDS: usize = 4;
10pub const SLOT_SIZE: usize = 256;
11pub const COMMIT_OFFSET: usize = SLOT_SIZE - 4;
12const CRC_OFFSET: usize = COMMIT_OFFSET - 4;
13const MAGIC: [u8; 4] = *b"UBLS";
14// Version 1 may contain a bond captured at the first protected GATT edge,
15// before SMP identity-key distribution completed. Do not restore those
16// incomplete records.
17const VERSION: u8 = 3;
18const BOND_SIZE: usize = 44;
19const LOCAL_IRK_OFFSET: usize = 16;
20const BONDS_OFFSET: usize = 32;
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub struct StoredBond {
24    pub address_kind: u8,
25    pub address: [u8; 6],
26    pub irk: Option<[u8; 16]>,
27    pub ltk: [u8; 16],
28    pub security_level: u8,
29    pub is_bonded: bool,
30}
31
32/// Outcome of [`upsert_bond`].
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum BondUpsert {
35    /// The bond's content and position were already current.
36    Unchanged,
37    /// An existing bond's content and/or MRU position changed.
38    Updated,
39    /// A new bond was appended, evicting the LRU entry first if the list
40    /// was already at `MAX_BONDS`.
41    Inserted { evicted: Option<StoredBond> },
42}
43
44/// Insert or refresh `bond` in `bonds`, which is kept ordered
45/// least-recently-used-first / most-recently-used-last (index 0 is the
46/// eviction candidate). A bond matching by `address_kind` + `address` is
47/// refreshed in place and moved to the MRU end; a new bond is appended at
48/// the MRU end, evicting the current LRU entry first if the list is full.
49pub fn upsert_bond(
50    bonds: &mut heapless::Vec<StoredBond, MAX_BONDS>,
51    bond: StoredBond,
52) -> BondUpsert {
53    if let Some(index) = bonds.iter().position(|existing| {
54        existing.address_kind == bond.address_kind && existing.address == bond.address
55    }) {
56        if bonds[index] == bond && index == bonds.len() - 1 {
57            return BondUpsert::Unchanged;
58        }
59        bonds.remove(index);
60        let _ = bonds.push(bond);
61        return BondUpsert::Updated;
62    }
63    let evicted = if bonds.len() == MAX_BONDS {
64        Some(bonds.remove(0))
65    } else {
66        None
67    };
68    let _ = bonds.push(bond);
69    BondUpsert::Inserted { evicted }
70}
71
72/// Move the bond matching `address_kind` + `address` to the MRU end, if
73/// present and not already there. Returns `true` when the order changed
74/// (and the caller should persist), `false` when the bond was already MRU
75/// or is not stored at all.
76pub fn touch_bond(
77    bonds: &mut heapless::Vec<StoredBond, MAX_BONDS>,
78    address_kind: u8,
79    address: [u8; 6],
80) -> bool {
81    let Some(index) = bonds
82        .iter()
83        .position(|existing| existing.address_kind == address_kind && existing.address == address)
84    else {
85        return false;
86    };
87    if index == bonds.len() - 1 {
88        return false;
89    }
90    let bond = bonds.remove(index);
91    let _ = bonds.push(bond);
92    true
93}
94
95#[derive(Clone, Debug, PartialEq, Eq)]
96pub struct Snapshot {
97    pub generation: u32,
98    pub pin: Option<u32>,
99    pub local_irk: Option<[u8; 16]>,
100    pub bonds: heapless::Vec<StoredBond, MAX_BONDS>,
101}
102
103impl Snapshot {
104    pub const fn empty() -> Self {
105        Self {
106            generation: 0,
107            pin: None,
108            local_irk: None,
109            bonds: heapless::Vec::new(),
110        }
111    }
112
113    pub fn encode(&self) -> [u8; SLOT_SIZE] {
114        let mut out = [0xff; SLOT_SIZE];
115        out[..4].copy_from_slice(&MAGIC);
116        out[4] = VERSION;
117        out[5] = self.bonds.len() as u8;
118        out[6] = u8::from(self.pin.is_some());
119        out[7] = u8::from(self.local_irk.is_some());
120        out[8..12].copy_from_slice(&self.generation.to_le_bytes());
121        out[12..16].copy_from_slice(&self.pin.unwrap_or(u32::MAX).to_le_bytes());
122        out[LOCAL_IRK_OFFSET..BONDS_OFFSET].copy_from_slice(&self.local_irk.unwrap_or([0; 16]));
123        for (index, bond) in self.bonds.iter().enumerate() {
124            let start = BONDS_OFFSET + index * BOND_SIZE;
125            out[start] = bond.address_kind;
126            out[start + 1..start + 7].copy_from_slice(&bond.address);
127            out[start + 7] = u8::from(bond.irk.is_some());
128            out[start + 8..start + 24].copy_from_slice(&bond.irk.unwrap_or([0; 16]));
129            out[start + 24..start + 40].copy_from_slice(&bond.ltk);
130            out[start + 40] = bond.security_level;
131            out[start + 41] = u8::from(bond.is_bonded);
132        }
133        let crc = crc32(&out[..CRC_OFFSET]);
134        out[CRC_OFFSET..COMMIT_OFFSET].copy_from_slice(&crc.to_le_bytes());
135        out
136    }
137
138    pub fn decode(bytes: &[u8; SLOT_SIZE]) -> Option<Self> {
139        if bytes[COMMIT_OFFSET..] != [0, 0, 0, 0]
140            || bytes[..4] != MAGIC
141            || bytes[4] != VERSION
142            || usize::from(bytes[5]) > MAX_BONDS
143            || crc32(&bytes[..CRC_OFFSET])
144                != u32::from_le_bytes(bytes[CRC_OFFSET..COMMIT_OFFSET].try_into().ok()?)
145        {
146            return None;
147        }
148        let pin = match (bytes[6], u32::from_le_bytes(bytes[12..16].try_into().ok()?)) {
149            (0, _) => None,
150            (1, value @ 0..=999_999) => Some(value),
151            _ => return None,
152        };
153        let local_irk = match bytes[7] {
154            0 => None,
155            1 => {
156                let value: [u8; 16] = bytes[LOCAL_IRK_OFFSET..BONDS_OFFSET].try_into().ok()?;
157                if value == [0; 16] {
158                    return None;
159                }
160                Some(value)
161            }
162            _ => return None,
163        };
164        let mut bonds = heapless::Vec::new();
165        for index in 0..usize::from(bytes[5]) {
166            let start = BONDS_OFFSET + index * BOND_SIZE;
167            let irk = match bytes[start + 7] {
168                0 => None,
169                1 => Some(bytes[start + 8..start + 24].try_into().ok()?),
170                _ => return None,
171            };
172            let security_level = bytes[start + 40];
173            if security_level > 2 || bytes[start + 41] > 1 {
174                return None;
175            }
176            bonds
177                .push(StoredBond {
178                    address_kind: bytes[start],
179                    address: bytes[start + 1..start + 7].try_into().ok()?,
180                    irk,
181                    ltk: bytes[start + 24..start + 40].try_into().ok()?,
182                    security_level,
183                    is_bonded: bytes[start + 41] == 1,
184                })
185                .ok()?;
186        }
187        Some(Self {
188            generation: u32::from_le_bytes(bytes[8..12].try_into().ok()?),
189            pin,
190            local_irk,
191            bonds,
192        })
193    }
194}
195
196/// Select the newest valid committed snapshot from `(flash_address, record)`
197/// pairs. Invalid, corrupt, and incompletely committed records are ignored.
198#[cfg(test)]
199pub fn latest_snapshot<'a>(
200    records: impl IntoIterator<Item = (u32, &'a [u8; SLOT_SIZE])>,
201) -> Option<(u32, Snapshot)> {
202    let mut latest: Option<(u32, Snapshot)> = None;
203    for (address, bytes) in records {
204        latest = consider_snapshot(latest, address, bytes);
205    }
206    latest
207}
208
209/// Consider one journal slot while mounting without retaining its flash buffer.
210pub fn consider_snapshot(
211    current: Option<(u32, Snapshot)>,
212    address: u32,
213    bytes: &[u8; SLOT_SIZE],
214) -> Option<(u32, Snapshot)> {
215    let Some(candidate) = Snapshot::decode(bytes) else {
216        return current;
217    };
218    if current
219        .as_ref()
220        .is_none_or(|(_, snapshot)| generation_is_newer(candidate.generation, snapshot.generation))
221    {
222        Some((address, candidate))
223    } else {
224        current
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::record::{
232        CommitError, PAGE_SIZE, PageEraser, RecordWriter, erase_journal_page,
233        write_committed_record,
234    };
235    use core::future::Future;
236    use core::task::{Context, Poll, Waker};
237
238    /// Test-only journal addresses (production addresses are the
239    /// firmware's memory-map decision).
240    const PAGE0: u32 = 0x000E_4000;
241    const PAGE1: u32 = PAGE0 + PAGE_SIZE;
242
243    fn block_on<F: Future>(future: F) -> F::Output {
244        let mut future = core::pin::pin!(future);
245        let mut context = Context::from_waker(Waker::noop());
246        loop {
247            match future.as_mut().poll(&mut context) {
248                Poll::Ready(output) => return output,
249                Poll::Pending => std::thread::yield_now(),
250            }
251        }
252    }
253
254    #[derive(Default)]
255    struct MockWriter {
256        fail_call: Option<usize>,
257        calls: std::vec::Vec<(u32, std::vec::Vec<u8>)>,
258        erase_failure: bool,
259        erases: std::vec::Vec<(u32, u32)>,
260    }
261
262    impl RecordWriter for MockWriter {
263        type Error = usize;
264
265        async fn write_record(&mut self, address: u32, bytes: &[u8]) -> Result<(), Self::Error> {
266            let call = self.calls.len();
267            self.calls.push((address, bytes.to_vec()));
268            if self.fail_call == Some(call) {
269                Err(call)
270            } else {
271                Ok(())
272            }
273        }
274    }
275
276    impl PageEraser for MockWriter {
277        type Error = ();
278
279        async fn erase_page(&mut self, start: u32, end: u32) -> Result<(), Self::Error> {
280            self.erases.push((start, end));
281            if self.erase_failure { Err(()) } else { Ok(()) }
282        }
283    }
284
285    fn sample() -> Snapshot {
286        let mut snapshot = Snapshot {
287            generation: 42,
288            pin: Some(123_456),
289            local_irk: Some([9; 16]),
290            bonds: heapless::Vec::new(),
291        };
292        snapshot
293            .bonds
294            .push(StoredBond {
295                address_kind: 1,
296                address: [1, 2, 3, 4, 5, 6],
297                irk: Some([7; 16]),
298                ltk: [8; 16],
299                security_level: 2,
300                is_bonded: true,
301            })
302            .unwrap();
303        snapshot
304    }
305
306    #[test]
307    fn committed_snapshot_round_trips() {
308        let snapshot = sample();
309        let mut encoded = snapshot.encode();
310        encoded[COMMIT_OFFSET..].fill(0);
311        assert_eq!(Snapshot::decode(&encoded), Some(snapshot));
312    }
313
314    #[test]
315    fn uncommitted_or_corrupt_snapshot_is_ignored() {
316        let snapshot = sample();
317        let encoded = snapshot.encode();
318        assert_eq!(Snapshot::decode(&encoded), None);
319        let mut corrupt = encoded;
320        corrupt[COMMIT_OFFSET..].fill(0);
321        corrupt[24] ^= 1;
322        assert_eq!(Snapshot::decode(&corrupt), None);
323    }
324
325    #[test]
326    fn journal_selects_newest_valid_record_across_wraparound() {
327        let mut old = sample();
328        old.generation = u32::MAX;
329        let mut old_bytes = old.encode();
330        old_bytes[COMMIT_OFFSET..].fill(0);
331
332        let mut new = sample();
333        new.generation = 0;
334        new.pin = Some(654_321);
335        let mut new_bytes = new.encode();
336        new_bytes[COMMIT_OFFSET..].fill(0);
337
338        assert_eq!(
339            latest_snapshot([(PAGE0, &old_bytes), (PAGE1, &new_bytes)]),
340            Some((PAGE1, new))
341        );
342    }
343
344    #[test]
345    fn interrupted_new_record_never_replaces_committed_old_record() {
346        let mut old = sample();
347        old.generation = 7;
348        let mut old_bytes = old.encode();
349        old_bytes[COMMIT_OFFSET..].fill(0);
350
351        let mut new = sample();
352        new.generation = 8;
353        new.pin = Some(654_321);
354        let encoded_new = new.encode();
355
356        // Simulate power loss after every possible byte of the body write and
357        // after each byte of the final commit-word write. Until all four
358        // commit bytes are present, mount must recover the old snapshot.
359        for body_bytes in 0..=COMMIT_OFFSET {
360            let mut interrupted = [0xff; SLOT_SIZE];
361            interrupted[..body_bytes].copy_from_slice(&encoded_new[..body_bytes]);
362            assert_eq!(
363                latest_snapshot([(PAGE0, &old_bytes), (PAGE1, &interrupted)]),
364                Some((PAGE0, old.clone())),
365                "body power cut after {body_bytes} bytes"
366            );
367        }
368        for commit_bytes in 0..4 {
369            let mut interrupted = encoded_new;
370            interrupted[COMMIT_OFFSET..COMMIT_OFFSET + commit_bytes].fill(0);
371            assert_eq!(
372                latest_snapshot([(PAGE0, &old_bytes), (PAGE1, &interrupted)]),
373                Some((PAGE0, old.clone())),
374                "commit power cut after {commit_bytes} bytes"
375            );
376        }
377
378        let mut committed = encoded_new;
379        committed[COMMIT_OFFSET..].fill(0);
380        assert_eq!(
381            latest_snapshot([(PAGE0, &old_bytes), (PAGE1, &committed)]),
382            Some((PAGE1, new))
383        );
384    }
385
386    #[test]
387    fn record_writer_faults_distinguish_body_and_commit_failures() {
388        let bytes = sample().encode();
389
390        let mut body_failure = MockWriter {
391            fail_call: Some(0),
392            ..Default::default()
393        };
394        assert_eq!(
395            block_on(write_committed_record(&mut body_failure, PAGE0, &bytes)),
396            Err(CommitError::Body(0))
397        );
398        assert_eq!(body_failure.calls.len(), 1);
399
400        let mut commit_failure = MockWriter {
401            fail_call: Some(1),
402            ..Default::default()
403        };
404        assert_eq!(
405            block_on(write_committed_record(&mut commit_failure, PAGE0, &bytes)),
406            Err(CommitError::Commit(1))
407        );
408        assert_eq!(commit_failure.calls.len(), 2);
409        assert_eq!(commit_failure.calls[0].0, PAGE0);
410        assert_eq!(commit_failure.calls[0].1, bytes[..COMMIT_OFFSET]);
411        assert_eq!(
412            commit_failure.calls[1],
413            (PAGE0 + COMMIT_OFFSET as u32, std::vec![0; 4])
414        );
415    }
416
417    #[test]
418    fn successful_record_write_commits_marker_last() {
419        let bytes = sample().encode();
420        let mut writer = MockWriter::default();
421        assert_eq!(
422            block_on(write_committed_record(&mut writer, PAGE1, &bytes)),
423            Ok(())
424        );
425        assert_eq!(writer.calls.len(), 2);
426        assert_eq!(writer.calls[0].0, PAGE1);
427        assert_eq!(writer.calls[0].1, bytes[..COMMIT_OFFSET]);
428        assert_eq!(
429            writer.calls[1],
430            (PAGE1 + COMMIT_OFFSET as u32, std::vec![0; 4])
431        );
432    }
433
434    #[test]
435    fn journal_page_erase_propagates_failure_and_uses_exact_bounds() {
436        let mut failing = MockWriter {
437            erase_failure: true,
438            ..Default::default()
439        };
440        assert_eq!(block_on(erase_journal_page(&mut failing, PAGE1)), Err(()));
441        assert_eq!(failing.erases, std::vec![(PAGE1, PAGE1 + PAGE_SIZE)]);
442
443        let mut successful = MockWriter::default();
444        assert_eq!(block_on(erase_journal_page(&mut successful, PAGE0)), Ok(()));
445        assert_eq!(successful.erases, std::vec![(PAGE0, PAGE0 + PAGE_SIZE)]);
446    }
447
448    fn bond(id: u8) -> StoredBond {
449        StoredBond {
450            address_kind: 0,
451            address: [id, 0, 0, 0, 0, 0],
452            irk: None,
453            ltk: [id; 16],
454            security_level: 2,
455            is_bonded: true,
456        }
457    }
458
459    fn addresses(bonds: &heapless::Vec<StoredBond, MAX_BONDS>) -> std::vec::Vec<u8> {
460        bonds.iter().map(|b| b.address[0]).collect()
461    }
462
463    #[test]
464    fn upsert_appends_new_bonds_at_mru_end_until_full() {
465        let mut bonds = heapless::Vec::new();
466        for id in 1..=4 {
467            assert_eq!(
468                upsert_bond(&mut bonds, bond(id)),
469                BondUpsert::Inserted { evicted: None }
470            );
471        }
472        assert_eq!(addresses(&bonds), std::vec![1, 2, 3, 4]);
473    }
474
475    #[test]
476    fn upsert_evicts_lru_entry_when_full() {
477        let mut bonds = heapless::Vec::new();
478        for id in 1..=4 {
479            upsert_bond(&mut bonds, bond(id));
480        }
481        // 1 is least-recently-used; inserting 5 should evict it.
482        assert_eq!(
483            upsert_bond(&mut bonds, bond(5)),
484            BondUpsert::Inserted {
485                evicted: Some(bond(1))
486            }
487        );
488        assert_eq!(addresses(&bonds), std::vec![2, 3, 4, 5]);
489    }
490
491    #[test]
492    fn upsert_of_existing_bond_moves_it_to_mru_end() {
493        let mut bonds = heapless::Vec::new();
494        for id in 1..=4 {
495            upsert_bond(&mut bonds, bond(id));
496        }
497        // Re-pairing bond 2 (content unchanged) should move it to the end,
498        // so the *new* LRU candidate becomes 1, not 2.
499        assert_eq!(upsert_bond(&mut bonds, bond(2)), BondUpsert::Updated);
500        assert_eq!(addresses(&bonds), std::vec![1, 3, 4, 2]);
501    }
502
503    #[test]
504    fn upsert_of_bond_already_at_mru_end_with_same_content_is_unchanged() {
505        let mut bonds = heapless::Vec::new();
506        for id in 1..=4 {
507            upsert_bond(&mut bonds, bond(id));
508        }
509        assert_eq!(upsert_bond(&mut bonds, bond(4)), BondUpsert::Unchanged);
510        assert_eq!(addresses(&bonds), std::vec![1, 2, 3, 4]);
511    }
512
513    #[test]
514    fn touch_moves_known_bond_to_mru_end_and_reports_change() {
515        let mut bonds = heapless::Vec::new();
516        for id in 1..=4 {
517            upsert_bond(&mut bonds, bond(id));
518        }
519        assert!(touch_bond(&mut bonds, 0, [1, 0, 0, 0, 0, 0]));
520        assert_eq!(addresses(&bonds), std::vec![2, 3, 4, 1]);
521    }
522
523    #[test]
524    fn touch_of_bond_already_at_mru_end_is_a_no_op() {
525        let mut bonds = heapless::Vec::new();
526        for id in 1..=4 {
527            upsert_bond(&mut bonds, bond(id));
528        }
529        assert!(!touch_bond(&mut bonds, 0, [4, 0, 0, 0, 0, 0]));
530        assert_eq!(addresses(&bonds), std::vec![1, 2, 3, 4]);
531    }
532
533    #[test]
534    fn touch_of_unknown_bond_is_a_no_op() {
535        let mut bonds = heapless::Vec::new();
536        upsert_bond(&mut bonds, bond(1));
537        assert!(!touch_bond(&mut bonds, 0, [9, 0, 0, 0, 0, 0]));
538        assert_eq!(addresses(&bonds), std::vec![1]);
539    }
540}