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}