Skip to main content

offline_protocol_leaf/
adapters.rs

1//! Adapters that carry mls-rs's storage traits onto [`LeafStore`].
2//!
3//! mls-rs asks for two storage providers, and neither shape belongs in a
4//! device integrator's lap: they carry mls-rs's own types, they are versioned
5//! with a dependency [ADR 0021](https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/docs/adr/0021-a-leaf-node-speaks-mls.md)
6//! calls monitored rather than settled, and getting the write ordering wrong
7//! is a confidentiality bug rather than a lost message. So firmware implements
8//! one blob store and these adapters do the rest.
9
10use alloc::{format, string::String, vec::Vec};
11use mls_rs_core::{
12    error::IntoAnyError,
13    group::{EpochRecord, GroupState, GroupStateStorage},
14    key_package::{KeyPackageData, KeyPackageStorage},
15};
16use mls_rs_core::{
17    identity::BasicCredential,
18    mls_rs_codec::{MlsDecode, MlsEncode},
19};
20use zeroize::Zeroizing;
21
22use crate::shared::Arc;
23
24use crate::store::{LeafStore, StoreError, KEY_TYPE_GROUP_EPOCH, KEY_TYPE_GROUP_STATE};
25use crate::store::{KEY_TYPE_KEY_PACKAGE, KEY_TYPE_PEER};
26
27impl IntoAnyError for StoreError {}
28
29/// How many prior-epoch records a group keeps.
30///
31/// mls-rs delegates this to the storage provider rather than applying it
32/// itself: its own in-memory provider trims to three on every write, and a
33/// provider that never trims keeps every epoch a group has ever left. That is
34/// two failures rather than one. The records accumulate on a part whose flash
35/// is measured in hundreds of kilobytes, and each one holds that epoch's
36/// secrets, so retaining them all turns "how far out of order a message may
37/// arrive" into "how far back a stolen device decrypts". Three is the window
38/// a phone-driven commit cadence needs on a lossy radio, and it is the bound
39/// on both.
40///
41/// This is leaf-side storage policy, not a number the two ends must match:
42/// the phone's provider keeps its own history and neither reads the other's.
43/// It is therefore declared here rather than in `offline-protocol-sealed`,
44/// which is for values a peer would disagree with us about.
45pub(crate) const PRIOR_EPOCH_RETENTION: u64 = 3;
46
47/// Renders bytes as lowercase hex, so an arbitrary group id can be a key id.
48///
49/// Group ids are chosen by whoever created the group and are not required to
50/// be printable. Hex is the shortest thing that cannot collide with the `:`
51/// this module uses as a separator.
52pub(crate) fn hex(bytes: &[u8]) -> String {
53    let mut out = String::with_capacity(bytes.len() * 2);
54    for b in bytes {
55        // `write!` would need `core::fmt::Write` in scope and can fail; a
56        // table lookup cannot.
57        const DIGITS: &[u8; 16] = b"0123456789abcdef";
58        out.push(DIGITS[(b >> 4) as usize] as char);
59        out.push(DIGITS[(b & 0x0f) as usize] as char);
60    }
61    out
62}
63
64/// Where a group's current state is kept.
65fn state_key(group_id: &[u8]) -> String {
66    hex(group_id)
67}
68
69/// Where one prior epoch is kept.
70fn epoch_key(group_id: &[u8], epoch_id: u64) -> String {
71    format!("{}:{}", hex(group_id), epoch_id)
72}
73
74/// Where the highest stored epoch id is kept.
75fn max_epoch_key(group_id: &[u8]) -> String {
76    format!("{}:max", hex(group_id))
77}
78
79/// How many bytes of a state entry carry the sequencing marker: a presence
80/// flag and a big-endian epoch id.
81const STATE_MARKER_LEN: usize = 9;
82
83/// Puts the sequencing marker in front of the state mls-rs handed us.
84///
85/// # Why the marker rides inside the state entry
86///
87/// mls-rs sequences every epoch insert against
88/// [`GroupStateStorage::max_epoch_id`]: an inserted record's id must be
89/// exactly one above what that returns, or the operation is refused. Nothing
90/// in this crate caches either value, so both are read from flash on every
91/// operation, and the two therefore have to move together or not at all.
92///
93/// Held as separate entries they cannot. This seam is atomic per entry rather
94/// than across a set, so a cut or a failed write between a marker record and
95/// the state leaves the marker one ahead of the state it describes, and from
96/// there **every later commit is refused, permanently**: the retry inserts the
97/// epoch id the marker has already counted. Reversing the order does not fix
98/// it, it moves the same wedge into the other window. What that costs is not a
99/// lost frame: the device stops opening anything its peer sends until the
100/// peer's own recovery drives a full reset, which on a door lock is an owner
101/// standing outside it.
102///
103/// One entry closes the window. The marker a validation reads is a slice of
104/// the same bytes as the state it validates against, so there is no moment in
105/// which the two disagree.
106fn encode_state_entry(marker: Option<u64>, state: &[u8]) -> Vec<u8> {
107    let mut out = Vec::with_capacity(STATE_MARKER_LEN + state.len());
108    match marker {
109        Some(epoch) => {
110            out.push(1);
111            out.extend_from_slice(&epoch.to_be_bytes());
112        }
113        // A group that has left no epoch behind yet. mls-rs skips the
114        // sequencing check entirely for `None`, which is what a group that was
115        // just joined needs.
116        None => out.extend_from_slice(&[0u8; STATE_MARKER_LEN]),
117    }
118    out.extend_from_slice(state);
119    out
120}
121
122/// Splits a state entry into its marker and the state mls-rs stored.
123fn decode_state_entry(raw: &[u8]) -> Result<(Option<u64>, &[u8]), StoreError> {
124    if raw.len() < STATE_MARKER_LEN {
125        return Err(StoreError::Corrupt(format!(
126            "group state entry is {} bytes, too short to carry its epoch marker",
127            raw.len()
128        )));
129    }
130    let (header, state) = raw.split_at(STATE_MARKER_LEN);
131    let marker = match header[0] {
132        0 => None,
133        1 => {
134            let mut epoch = [0u8; 8];
135            epoch.copy_from_slice(&header[1..STATE_MARKER_LEN]);
136            Some(u64::from_be_bytes(epoch))
137        }
138        other => {
139            return Err(StoreError::Corrupt(format!(
140                "group state entry carries epoch marker flag {other}, expected 0 or 1"
141            )))
142        }
143    };
144    Ok((marker, state))
145}
146
147/// The sequencing marker in a group's state entry, if it can be read.
148///
149/// For the erasure sweep in [`LeafDevice::unpair`](crate::LeafDevice::unpair),
150/// which needs an anchor rather than a guarantee: every failure here is the
151/// same answer, "this record cannot bound anything", and the caller looks
152/// elsewhere.
153pub(crate) fn state_marker(store: &Arc<dyn LeafStore>, group_key: &str) -> Option<u64> {
154    let raw = store.load(KEY_TYPE_GROUP_STATE, group_key).ok()??;
155    decode_state_entry(&raw).ok().and_then(|(marker, _)| marker)
156}
157
158/// Carries [`GroupStateStorage`] onto the device's blob store.
159#[derive(Clone)]
160pub(crate) struct GroupStateAdapter {
161    store: Arc<dyn LeafStore>,
162}
163
164impl GroupStateAdapter {
165    pub(crate) fn new(store: Arc<dyn LeafStore>) -> Self {
166        Self { store }
167    }
168}
169
170impl GroupStateStorage for GroupStateAdapter {
171    type Error = StoreError;
172
173    fn state(&self, group_id: &[u8]) -> Result<Option<Zeroizing<Vec<u8>>>, Self::Error> {
174        let Some(raw) = self
175            .store
176            .load(KEY_TYPE_GROUP_STATE, &state_key(group_id))?
177        else {
178            return Ok(None);
179        };
180        let raw = Zeroizing::new(raw);
181        let (_, state) = decode_state_entry(&raw)?;
182        Ok(Some(Zeroizing::new(state.to_vec())))
183    }
184
185    fn epoch(
186        &self,
187        group_id: &[u8],
188        epoch_id: u64,
189    ) -> Result<Option<Zeroizing<Vec<u8>>>, Self::Error> {
190        Ok(self
191            .store
192            .load(KEY_TYPE_GROUP_EPOCH, &epoch_key(group_id, epoch_id))?
193            .map(Zeroizing::new))
194    }
195
196    /// Writes the epoch records first and the group state last.
197    ///
198    /// mls-rs asks for one atomic transaction, and this seam offers atomicity
199    /// per entry rather than across a set, so the ordering has to carry what
200    /// the transaction would have. Prior-epoch records are additive: they let
201    /// a message that arrives late still decrypt. Writing them before the
202    /// state means a power cut mid-write leaves the **old** state alongside
203    /// records it does not yet reference, which costs nothing. The reverse
204    /// order would leave a new state whose prior epochs were never written, so
205    /// the device would come back having lost exactly the out-of-order
206    /// tolerance a lossy radio needs.
207    ///
208    /// The state and the marker that sequences it go down as **one entry**,
209    /// because ordering cannot make those two safe in either direction. See
210    /// [`encode_state_entry`] for what a torn write between them costs.
211    ///
212    /// The caller does not emit anything until this returns `Ok`, so a failure
213    /// here is a frame that was never sent rather than state that fell behind
214    /// one that was.
215    ///
216    /// The high-water record and the expired-record deletes both happen
217    /// **after** the state write, and neither propagates a failure. Both
218    /// follow from the same rule: once the state write has returned, the write
219    /// the caller is waiting on is durable, so reporting a failure in
220    /// housekeeping would suppress a frame whose state is already on flash.
221    /// A record that survives a failed delete is swept by
222    /// [`LeafDevice::unpair`](crate::LeafDevice::unpair).
223    fn write(
224        &mut self,
225        state: GroupState,
226        epoch_inserts: Vec<EpochRecord>,
227        epoch_updates: Vec<EpochRecord>,
228    ) -> Result<(), Self::Error> {
229        let previous = self.max_epoch_id(&state.id)?;
230        let mut marker = previous;
231
232        for record in epoch_inserts.iter().chain(epoch_updates.iter()) {
233            self.store.store(
234                KEY_TYPE_GROUP_EPOCH,
235                &epoch_key(&state.id, record.id),
236                &record.data,
237            )?;
238            marker = Some(marker.map_or(record.id, |held: u64| held.max(record.id)));
239        }
240
241        self.store.store(
242            KEY_TYPE_GROUP_STATE,
243            &state_key(&state.id),
244            &encode_state_entry(marker, &state.data),
245        )?;
246
247        // The erasure high-water mark, which is a different job from the
248        // marker above and is why it is a different record rather than the
249        // same one read twice.
250        //
251        // The marker inside the state entry answers "what may be inserted
252        // next", and it goes away with the state it lives in. This record
253        // answers "how far up did this group ever write", which
254        // [`LeafDevice::unpair`](crate::LeafDevice::unpair) needs in order to
255        // bound a sweep over records it cannot enumerate, and it has to be
256        // readable in the one case the other is not: a state entry the part
257        // hands back as something else. It is deliberately never lowered by
258        // trimming, so it stays above every record that could still be there.
259        let advanced_to = if marker == previous { None } else { marker };
260        if let Some(high) = advanced_to {
261            let _ = self.store.store(
262                KEY_TYPE_GROUP_EPOCH,
263                &max_epoch_key(&state.id),
264                &high.to_be_bytes(),
265            );
266        }
267
268        // One delete per record that entered, which is all the window can
269        // lose: mls-rs requires each inserted epoch id to be exactly one above
270        // the highest stored, so the window advances by the number of inserts
271        // and never skips. Updates rewrite records already inside it.
272        for record in epoch_inserts.iter() {
273            if let Some(expired) = record.id.checked_sub(PRIOR_EPOCH_RETENTION) {
274                let _ = self
275                    .store
276                    .delete(KEY_TYPE_GROUP_EPOCH, &epoch_key(&state.id, expired));
277            }
278        }
279
280        Ok(())
281    }
282
283    /// The highest epoch id this group has left behind.
284    ///
285    /// Read from the same entry as the state it belongs to, because mls-rs
286    /// sequences every epoch insert against this value and a marker that got
287    /// ahead of its state wedges the group permanently. See
288    /// [`encode_state_entry`].
289    ///
290    /// A group with no state has no marker, and that `None` is what makes
291    /// mls-rs skip the sequencing check for a group that was just joined.
292    fn max_epoch_id(&self, group_id: &[u8]) -> Result<Option<u64>, Self::Error> {
293        let Some(raw) = self
294            .store
295            .load(KEY_TYPE_GROUP_STATE, &state_key(group_id))?
296        else {
297            return Ok(None);
298        };
299        let raw = Zeroizing::new(raw);
300        let (marker, _) = decode_state_entry(&raw)?;
301        Ok(marker)
302    }
303}
304
305/// How many minted-but-unspent key packages a device keeps.
306///
307/// mls-rs deletes an entry when a join consumes it, so the only entries that
308/// accumulate are packages nobody ever spent: a pairing that was abandoned, or
309/// one a stranger provoked. [`LeafDevice::unpair`](crate::LeafDevice::unpair)
310/// reclaims the one a peer record still names, which needs an owner who knows
311/// the peer is there; a stranger provoking mints leaves records nobody is
312/// watching. Without a bound each of those is private key material written to
313/// flash and never reclaimed, and provoking a mint costs an attacker one
314/// signed frame.
315///
316/// Evicting the oldest is the trade this makes, and it is not free: a peer
317/// holding an evicted package can no longer complete a join with it, so a
318/// flood turns into a pairing failure rather than a full flash. Four is enough
319/// for a household pairing its phones in sequence, and small enough that the
320/// residue is bounded.
321const MAX_UNSPENT_KEY_PACKAGES: usize = 4;
322
323/// Where the list of unspent key package ids is kept.
324///
325/// Held under the same key type as the packages themselves. It cannot collide
326/// with one: every other id there is [`hex`] output, and `_` is not a hex
327/// digit.
328const KEY_PACKAGE_INDEX: &str = "__index__";
329
330/// Carries [`KeyPackageStorage`] onto the device's blob store.
331///
332/// The values here hold the init and leaf-node private keys of a key package
333/// the device minted and has not yet spent. mls-rs deletes an entry when the
334/// package is consumed by a join, which is why an init key is single use and
335/// why a static pairing artifact must never carry one. What it does not do is
336/// bound the ones never consumed, so this adapter does: see
337/// [`MAX_UNSPENT_KEY_PACKAGES`].
338#[derive(Clone)]
339pub(crate) struct KeyPackageAdapter {
340    store: Arc<dyn LeafStore>,
341}
342
343impl KeyPackageAdapter {
344    pub(crate) fn new(store: Arc<dyn LeafStore>) -> Self {
345        Self { store }
346    }
347
348    /// The ids of unspent packages, oldest first.
349    ///
350    /// A corrupt index is treated as an empty one rather than an error. It is
351    /// housekeeping state, and refusing to mint a key package because a list
352    /// of previous ones does not parse would turn a recoverable annoyance into
353    /// a device that cannot pair.
354    fn index(&self) -> Result<Vec<String>, StoreError> {
355        let Some(raw) = self.store.load(KEY_TYPE_KEY_PACKAGE, KEY_PACKAGE_INDEX)? else {
356            return Ok(Vec::new());
357        };
358        Ok(serde_json::from_slice(&raw).unwrap_or_default())
359    }
360
361    fn save_index(&self, index: &[String]) -> Result<(), StoreError> {
362        let encoded = serde_json::to_vec(index)
363            .map_err(|e| StoreError::Store(format!("cannot encode key package index: {e}")))?;
364        self.store
365            .store(KEY_TYPE_KEY_PACKAGE, KEY_PACKAGE_INDEX, &encoded)
366    }
367
368    /// Erases the package filed under `reference` and stops the index naming
369    /// it.
370    ///
371    /// Two callers, and the second is why this is reachable by reference
372    /// rather than only by the id mls-rs holds. mls-rs erases a package a join
373    /// consumed. [`LeafDevice::unpair`](crate::LeafDevice::unpair) erases one
374    /// minted for a peer the owner has removed, and nothing else ever will:
375    /// an init key is single use, so a package nobody spends is never
376    /// consumed, and the ring only evicts it once four later mints have
377    /// happened.
378    ///
379    /// The erase is what propagates and dropping the name is best effort. By
380    /// then the material is gone, which is what was asked for, and a stale
381    /// index entry costs one slot and is evicted in its turn. Reporting it
382    /// would turn a deletion that did happen into one the caller believes did
383    /// not: mls-rs calls this after persisting the group state that consumed
384    /// the package, so the caller would withhold a frame whose state is
385    /// already on flash.
386    pub(crate) fn erase(&self, reference: &str) -> Result<(), StoreError> {
387        self.store.delete(KEY_TYPE_KEY_PACKAGE, reference)?;
388
389        if let Ok(mut index) = self.index() {
390            if let Some(at) = index.iter().position(|held| held == reference) {
391                index.remove(at);
392                let _ = self.save_index(&index);
393            }
394        }
395        Ok(())
396    }
397}
398
399impl KeyPackageStorage for KeyPackageAdapter {
400    type Error = StoreError;
401
402    /// Erases the package a join consumed, so it does not hold a slot against
403    /// the ones still outstanding. See [`KeyPackageAdapter::erase`].
404    fn delete(&mut self, id: &[u8]) -> Result<(), Self::Error> {
405        self.erase(&hex(id))
406    }
407
408    fn insert(&mut self, id: Vec<u8>, pkg: KeyPackageData) -> Result<(), Self::Error> {
409        let encoded = pkg
410            .mls_encode_to_vec()
411            .map_err(|e| StoreError::Store(format!("cannot encode key package data: {e:?}")))?;
412        let key = hex(&id);
413
414        let mut index = self.index()?;
415        if !index.iter().any(|held| held == &key) {
416            index.push(key.clone());
417        }
418        let evicted: Vec<String> = if index.len() > MAX_UNSPENT_KEY_PACKAGES {
419            index
420                .drain(..index.len() - MAX_UNSPENT_KEY_PACKAGES)
421                .collect()
422        } else {
423            Vec::new()
424        };
425
426        // Two writes, ordered so the same thing survives either failure: an
427        // index entry naming a package that is not there. That costs one slot
428        // and is evicted in its turn. The opposite residue is private key
429        // material the index has stopped naming, and **nothing reclaims that**:
430        // [`LeafDevice::unpair`](crate::LeafDevice::unpair) erases the package
431        // a peer record still names and sweeps epoch records, neither of which
432        // reaches one already evicted, this key type has no sweep of its own,
433        // and an eviction the index has forgotten is never attempted again.
434        //
435        // So the evicted packages are erased before the index stops naming
436        // them, and the index is written before the package it names. A delete
437        // that fails takes the whole mint down with it, which is a key package
438        // this device does not hand out rather than one it cannot account for.
439        for stale in evicted {
440            self.store.delete(KEY_TYPE_KEY_PACKAGE, &stale)?;
441        }
442        self.save_index(&index)?;
443
444        self.store.store(KEY_TYPE_KEY_PACKAGE, &key, &encoded)
445    }
446
447    fn get(&self, id: &[u8]) -> Result<Option<KeyPackageData>, Self::Error> {
448        let Some(raw) = self.store.load(KEY_TYPE_KEY_PACKAGE, &hex(id))? else {
449            return Ok(None);
450        };
451        let decoded = KeyPackageData::mls_decode(&mut &raw[..])
452            .map_err(|e| StoreError::Corrupt(format!("key package data does not decode: {e:?}")))?;
453        Ok(Some(decoded))
454    }
455}
456
457/// What a peer told us it can parse, and what we therefore may emit to it.
458///
459/// Persisted because these are end-to-end capabilities: they describe what the
460/// recipient parses after any number of relay hops, so a device that forgot
461/// them across a power cycle would silently downgrade every established peer
462/// until the next key package exchange.
463#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
464pub(crate) struct PeerRecord {
465    /// Envelope forms the peer parses. `[1]` means the compact envelope.
466    #[serde(default)]
467    pub(crate) env_versions: Vec<u8>,
468    /// Frame encodings the peer decodes on the next hop.
469    #[serde(default)]
470    pub(crate) wire_versions: Vec<u8>,
471    /// Whether this device has already given this peer a key package.
472    ///
473    /// Persisted rather than held in RAM because an init key is single use:
474    /// a device that forgot across a power cycle would mint a second package
475    /// on the next exchange and leave the peer holding two, of which only one
476    /// is ever spent. Cleared when a peer resets the session, which is the
477    /// one moment a fresh package is required rather than wasteful.
478    #[serde(default)]
479    pub(crate) key_package_sent: bool,
480
481    /// The reference of the key package this device last minted for this peer.
482    ///
483    /// A key package is a **bearer token**: it travels in a frame that is
484    /// signed but not encrypted, so a copy taken off the air is as spendable
485    /// as the original, and a Welcome built around one passes every other gate
486    /// honestly. Recording which package went to which peer is what makes the
487    /// difference checkable, and
488    /// [`LeafDevice::handle`](crate::LeafDevice::handle) checks it before the
489    /// join rather than after, so a refused Welcome leaves the package
490    /// **unspent** and the peer it was minted for can still use it.
491    ///
492    /// One reference rather than a history: this device mints one package per
493    /// peer and re-mints only on a reset, so the newest is the one a peer was
494    /// told to use. A peer holding an older package that a re-mint replaced is
495    /// refused and re-pairs, which is the same path every other stale-pairing
496    /// failure takes.
497    #[serde(default)]
498    pub(crate) key_package_ref: Option<String>,
499
500    /// The signed timestamp of the most recent session reset acted on from
501    /// this peer, or `0` if none.
502    ///
503    /// A reset tears down a live session, so a frame carrying one is worth
504    /// capturing and sending again, and a reset is honoured only when it is
505    /// **strictly newer** than this.
506    ///
507    /// This replaced a ring of the last few frame ids, which was what a device
508    /// could do while nothing in the signed payload stated freshness: it
509    /// denied a repeat of a *remembered* frame and left a capture older than
510    /// the ring spendable once. A timestamp inside the signature makes the
511    /// stronger statement affordable — every older reset is refused, including
512    /// ones this device has no memory of — and it costs one integer per peer
513    /// instead of a list that grows with traffic, which on a device with a few
514    /// hundred kilobytes of flash is the difference that decides it.
515    ///
516    /// Sound only because the stamp is inside the signature. On a payload that
517    /// left it outside, an attacker would rewrite it, park this at `i64::MAX`
518    /// and deny the peer every future reset, which is worse than the replay.
519    /// A leaf verifies no such payload; see
520    /// [`frames`](crate::frames) for why it has no legacy peer to accommodate.
521    #[serde(default)]
522    pub(crate) last_reset_ms: i64,
523
524    /// The ids of the last few frames from this peer that were acknowledged.
525    ///
526    /// An acknowledgement is the frame most likely to be the one that goes
527    /// missing: it is the last in the exchange and nothing answers it, so
528    /// nothing retries it. When one is lost the peer retransmits what it
529    /// already delivered, the ratchet refuses the replay, and without this the
530    /// device stays silent and the full retry ladder runs anyway, which is the
531    /// case issue 402 exists to end rather than to narrow.
532    ///
533    /// Answering from memory closes it: a frame whose id is here was received
534    /// once, so saying so again is true, costs one small frame, and stops the
535    /// remaining nine.
536    ///
537    /// # Why a list here, where a reset needed only an integer
538    ///
539    /// [`Self::last_reset_ms`] replaced exactly this shape, and the difference
540    /// is where the number being compared comes from. A reset's timestamp is
541    /// inside the signature, so a high-water mark over it is a statement an
542    /// attacker cannot move. A sealed frame carries no signature at all, and
543    /// its id and timestamp sit outside the AEAD, so a watermark over either
544    /// could be parked in the future by anyone within radio range and would
545    /// deny the peer every future acknowledgement.
546    ///
547    /// A list has no such failure. Nothing reaches it that this device did not
548    /// itself acknowledge, and its worst case is re-answering a frame it
549    /// already answered, which is one frame rather than a durably broken pair.
550    #[serde(default)]
551    pub(crate) acknowledged: Vec<String>,
552}
553
554/// How many acknowledged frame ids a device remembers per peer.
555///
556/// Four, because the peer's own ladder is what sets the number: it waits ten
557/// seconds for an answer before retransmitting, so the ids at risk are the ones
558/// still in flight in that window, and a phone does not have dozens of frames
559/// outstanding to a lock. It is a bound on flash, not a bound on correctness:
560/// forgetting an id costs the retry ladder that was running before this
561/// existed.
562pub(crate) const REMEMBERED_ACKS: usize = 4;
563
564impl PeerRecord {
565    /// Whether a frame from this peer has already been acknowledged, so the
566    /// answer can be repeated without opening it a second time.
567    pub(crate) fn was_acknowledged(&self, id: &str) -> bool {
568        self.acknowledged.iter().any(|seen| seen == id)
569    }
570
571    /// Records a frame as acknowledged, evicting the oldest when full.
572    pub(crate) fn remember_acknowledged(&mut self, id: &str) {
573        if self.was_acknowledged(id) {
574            return;
575        }
576        if self.acknowledged.len() >= REMEMBERED_ACKS {
577            self.acknowledged.remove(0);
578        }
579        self.acknowledged.push(String::from(id));
580    }
581
582    /// Records a reset stamped `signed_ms` as acted on.
583    pub(crate) fn remember_reset(&mut self, signed_ms: i64) {
584        self.last_reset_ms = signed_ms;
585    }
586
587    /// Whether a reset stamped `signed_ms` is one this device has not acted on.
588    ///
589    /// Strictly newer, because the stamp is what identifies the frame here:
590    /// admitting an equal one admits the frame itself a second time. Two
591    /// genuine resets inside one millisecond would need the phone's rekey
592    /// ladder to run far faster than its own floor allows, and if it ever
593    /// happened the pair still converges, because a refused reset shows up as
594    /// the next frame that will not open and drives another.
595    pub(crate) fn reset_is_unspent(&self, signed_ms: i64) -> bool {
596        signed_ms > self.last_reset_ms
597    }
598
599    pub(crate) fn load(store: &Arc<dyn LeafStore>, peer: &str) -> Result<Option<Self>, StoreError> {
600        let Some(raw) = store.load(KEY_TYPE_PEER, peer)? else {
601            return Ok(None);
602        };
603        serde_json::from_slice(&raw)
604            .map(Some)
605            .map_err(|e| StoreError::Corrupt(format!("peer record does not decode: {e}")))
606    }
607
608    pub(crate) fn save(&self, store: &Arc<dyn LeafStore>, peer: &str) -> Result<(), StoreError> {
609        let encoded = serde_json::to_vec(self)
610            .map_err(|e| StoreError::Store(format!("cannot encode peer record: {e}")))?;
611        store.store(KEY_TYPE_PEER, peer, &encoded)
612    }
613}
614
615/// Reads the address out of a basic credential.
616///
617/// The credential's whole content is the peer's address in its canonical text
618/// form. Anything else is a credential this protocol did not mint.
619pub(crate) fn credential_address(credential: &BasicCredential) -> Result<&str, crate::LeafError> {
620    core::str::from_utf8(&credential.identifier).map_err(|_| {
621        crate::LeafError::IdentityBinding(String::from(
622            "credential identifier is not valid UTF-8, so it names no address",
623        ))
624    })
625}