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