Skip to main content

yb_core/store/
mod.rs

1// SPDX-FileCopyrightText: 2025 - 2026 Frederic Ruget <fred@atlant.is> (GitHub: @douzebis)
2//
3// SPDX-License-Identifier: MIT
4
5//! Binary store: serialization / deserialization of PIV objects.
6
7pub mod constants;
8
9use anyhow::{bail, Context, Result};
10use constants::*;
11use std::collections::{HashMap, HashSet};
12
13use crate::orchestrator::BLOB_SIZE_C_BIT;
14use std::time::{SystemTime, UNIX_EPOCH};
15
16use crate::piv::PivBackend;
17
18// ---------------------------------------------------------------------------
19// Object
20// ---------------------------------------------------------------------------
21
22/// One PIV data object.  May be empty (age == 0) or occupied (age > 0).
23#[derive(Debug, Clone)]
24pub struct Object {
25    /// Index within the store (0 = object at OBJECT_ID_ZERO).
26    pub(crate) index: u8,
27    /// Object size in bytes (same for all objects in a store).
28    pub(crate) object_size: usize,
29
30    // -- fields present in every object --
31    pub(crate) yblob_magic: u32,
32    pub(crate) object_count: u8,
33    pub(crate) store_key_slot: u8,
34    /// Age counter (0 = empty).
35    pub(crate) age: u32,
36
37    // -- fields present when age != 0 --
38    pub(crate) chunk_pos: u8,
39    pub(crate) next_chunk: u8,
40
41    // -- fields present only in head chunks (chunk_pos == 0) --
42    /// Modification time as a Unix timestamp (seconds since epoch).
43    pub blob_mtime: u32,
44    /// Byte length of the (possibly encrypted) payload stored across all chunks.
45    pub blob_size: u32,
46    /// PIV slot used for encryption (0 = unencrypted).
47    pub blob_key_slot: u8,
48    /// Byte length of the plaintext before encryption.
49    pub blob_plain_size: u32,
50    /// Whether the stored payload is compressed (C-bit = bit 23 of blob_plain_size).
51    pub is_compressed: bool,
52    pub blob_name: String,
53
54    /// Raw chunk payload bytes (excluding all header fields).
55    pub(crate) payload: Vec<u8>,
56
57    /// Whether this object needs to be written back to the YubiKey.
58    pub(crate) dirty: bool,
59}
60
61impl Object {
62    /// Deserialize a PIV object from raw bytes.
63    pub fn from_bytes(index: u8, data: &[u8]) -> Result<Self> {
64        let object_size = data.len();
65        if !(OBJECT_MIN_SIZE..=OBJECT_MAX_SIZE).contains(&object_size) {
66            bail!(
67                "object {index}: invalid size {object_size} \
68                 (expected {OBJECT_MIN_SIZE}..={OBJECT_MAX_SIZE})"
69            );
70        }
71
72        let magic = read_u32_le(data, MAGIC_O)?;
73        if magic != YBLOB_MAGIC {
74            bail!("object {index}: bad magic 0x{magic:08x} (expected 0x{YBLOB_MAGIC:08x})");
75        }
76
77        let object_count = data[OBJECT_COUNT_O];
78        let store_key_slot = data[STORE_KEY_SLOT_O];
79        let age = read_u24_le(data, OBJECT_AGE_O)?;
80
81        if age == 0 {
82            // Empty slot — remaining fields are zero / don't-care.
83            return Ok(Self {
84                index,
85                object_size,
86                yblob_magic: magic,
87                object_count,
88                store_key_slot,
89                age: 0,
90                chunk_pos: 0,
91                next_chunk: 0,
92                blob_mtime: 0,
93                blob_size: 0,
94                blob_key_slot: 0,
95                blob_plain_size: 0,
96                is_compressed: false,
97                blob_name: String::new(),
98                payload: Vec::new(),
99                dirty: false,
100            });
101        }
102
103        let chunk_pos = data[CHUNK_POS_O];
104        let next_chunk = data[NEXT_CHUNK_O];
105
106        let (
107            blob_mtime,
108            blob_size,
109            blob_key_slot,
110            blob_plain_size,
111            is_compressed,
112            blob_name,
113            payload_start,
114        ) = if chunk_pos == 0 {
115            let mtime = read_u32_le(data, BLOB_MTIME_O)?;
116            let bsize = read_u24_le(data, BLOB_SIZE_O)?;
117            let bkslot = data[BLOB_KEY_SLOT_O];
118            let raw_plain = read_u24_le(data, BLOB_PLAIN_SIZE_O)?;
119            let compressed = raw_plain & BLOB_SIZE_C_BIT as u32 != 0;
120            let plain = raw_plain & !(BLOB_SIZE_C_BIT as u32);
121            let nlen = data[BLOB_NAME_LEN_O] as usize;
122            if BLOB_NAME_O + nlen > object_size {
123                bail!("object {index}: name length {nlen} overflows object");
124            }
125            let name = std::str::from_utf8(&data[BLOB_NAME_O..BLOB_NAME_O + nlen])
126                .with_context(|| format!("object {index}: blob name is not valid UTF-8"))?
127                .to_owned();
128            (
129                mtime,
130                bsize,
131                bkslot,
132                plain,
133                compressed,
134                name,
135                BLOB_NAME_O + nlen,
136            )
137        } else {
138            (0, 0, 0, 0, false, String::new(), CONTINUATION_PAYLOAD_O)
139        };
140
141        // Read all remaining bytes as payload — both head and continuation.
142        // Consumers that want only the blob data (fetch_blob) truncate to
143        // blob_size themselves.  fsck uses the full bytes to locate the yb2
144        // signature trailer via collect_blob_chain.
145        let raw_payload = &data[payload_start..];
146        let payload = raw_payload.to_vec();
147
148        Ok(Self {
149            index,
150            object_size,
151            yblob_magic: magic,
152            object_count,
153            store_key_slot,
154            age,
155            chunk_pos,
156            next_chunk,
157            blob_mtime,
158            blob_size,
159            blob_key_slot,
160            blob_plain_size,
161            is_compressed,
162            blob_name,
163            payload,
164            dirty: false,
165        })
166    }
167
168    /// Serialize this object to the minimum number of bytes required.
169    ///
170    /// - Empty slot (age == 0): 9-byte sentinel (common header only).
171    /// - Head chunk: `0x17 + name_len + payload_len` bytes.
172    /// - Continuation chunk: `0x0B + payload_len` bytes.
173    ///
174    /// The result is always in the range `OBJECT_MIN_SIZE`..=`OBJECT_MAX_SIZE`.
175    pub fn to_bytes(&self) -> Vec<u8> {
176        if self.age == 0 {
177            // Empty-slot sentinel: 9-byte common header only.
178            let mut buf = vec![0u8; OBJECT_MIN_SIZE];
179            write_u32_le(&mut buf, MAGIC_O, self.yblob_magic);
180            buf[OBJECT_COUNT_O] = self.object_count;
181            buf[STORE_KEY_SLOT_O] = self.store_key_slot;
182            // OBJECT_AGE stays 0.
183            return buf;
184        }
185
186        if self.chunk_pos == 0 {
187            // Head chunk: headers + name + payload.
188            let name_bytes = self.blob_name.as_bytes();
189            let total = (BLOB_NAME_O + name_bytes.len() + self.payload.len())
190                .clamp(OBJECT_MIN_SIZE, OBJECT_MAX_SIZE);
191            let mut buf = vec![0u8; total];
192            write_u32_le(&mut buf, MAGIC_O, self.yblob_magic);
193            buf[OBJECT_COUNT_O] = self.object_count;
194            buf[STORE_KEY_SLOT_O] = self.store_key_slot;
195            write_u24_le(&mut buf, OBJECT_AGE_O, self.age);
196            buf[CHUNK_POS_O] = self.chunk_pos;
197            buf[NEXT_CHUNK_O] = self.next_chunk;
198            write_u32_le(&mut buf, BLOB_MTIME_O, self.blob_mtime);
199            write_u24_le(&mut buf, BLOB_SIZE_O, self.blob_size);
200            buf[BLOB_KEY_SLOT_O] = self.blob_key_slot;
201            write_u24_le(
202                &mut buf,
203                BLOB_PLAIN_SIZE_O,
204                self.blob_plain_size | self.c_bit(),
205            );
206            buf[BLOB_NAME_LEN_O] = name_bytes.len() as u8;
207            buf[BLOB_NAME_O..BLOB_NAME_O + name_bytes.len()].copy_from_slice(name_bytes);
208            let payload_start = BLOB_NAME_O + name_bytes.len();
209            let payload_end = (payload_start + self.payload.len()).min(total);
210            buf[payload_start..payload_end]
211                .copy_from_slice(&self.payload[..payload_end - payload_start]);
212            buf
213        } else {
214            // Continuation chunk: headers + payload.
215            let total = (CONTINUATION_PAYLOAD_O + self.payload.len())
216                .clamp(OBJECT_MIN_SIZE, OBJECT_MAX_SIZE);
217            let mut buf = vec![0u8; total];
218            write_u32_le(&mut buf, MAGIC_O, self.yblob_magic);
219            buf[OBJECT_COUNT_O] = self.object_count;
220            buf[STORE_KEY_SLOT_O] = self.store_key_slot;
221            write_u24_le(&mut buf, OBJECT_AGE_O, self.age);
222            buf[CHUNK_POS_O] = self.chunk_pos;
223            buf[NEXT_CHUNK_O] = self.next_chunk;
224            let payload_end = (CONTINUATION_PAYLOAD_O + self.payload.len()).min(total);
225            buf[CONTINUATION_PAYLOAD_O..payload_end]
226                .copy_from_slice(&self.payload[..payload_end - CONTINUATION_PAYLOAD_O]);
227            buf
228        }
229    }
230
231    /// Mark this object as empty (age = 0) and dirty.
232    ///
233    /// Uses an explicit full struct literal so that adding a new field causes a
234    /// compile error here, forcing the author to decide whether it should be
235    /// preserved or zeroed on reset.
236    pub fn reset(&mut self) {
237        let index = self.index;
238        let object_count = self.object_count;
239        let store_key_slot = self.store_key_slot;
240        *self = Self {
241            index,
242            object_size: OBJECT_MIN_SIZE, // write compact 9-byte sentinel
243            yblob_magic: crate::store::constants::YBLOB_MAGIC,
244            object_count,
245            store_key_slot,
246            age: 0,
247            chunk_pos: 0,
248            next_chunk: 0,
249            blob_mtime: 0,
250            blob_size: 0,
251            blob_key_slot: 0,
252            blob_plain_size: 0,
253            is_compressed: false,
254            blob_name: String::new(),
255            payload: Vec::new(),
256            dirty: true,
257        };
258    }
259
260    /// Maximum payload capacity in a head chunk given a name of `name_len` bytes.
261    ///
262    /// Uses `OBJECT_MAX_SIZE` (the hard limit) regardless of the `object_size`
263    /// argument, which is ignored and retained only for call-site compatibility.
264    pub fn head_payload_capacity(_object_size: usize, name_len: usize) -> usize {
265        OBJECT_MAX_SIZE.saturating_sub(BLOB_NAME_O + name_len)
266    }
267
268    /// Maximum payload capacity in a continuation chunk.
269    ///
270    /// Uses `OBJECT_MAX_SIZE` (the hard limit).  The `object_size` argument is
271    /// ignored and retained only for call-site compatibility.
272    pub fn continuation_payload_capacity(_object_size: usize) -> usize {
273        OBJECT_MAX_SIZE.saturating_sub(CONTINUATION_PAYLOAD_O)
274    }
275
276    /// Returns `BLOB_SIZE_C_BIT` when the blob is compressed, 0 otherwise.
277    /// Used when serializing `blob_plain_size` to the wire format.
278    fn c_bit(&self) -> u32 {
279        BLOB_SIZE_C_BIT as u32 * self.is_compressed as u32
280    }
281
282    pub fn is_empty(&self) -> bool {
283        self.age == 0
284    }
285
286    pub fn is_head(&self) -> bool {
287        self.age != 0 && self.chunk_pos == 0
288    }
289
290    pub fn is_encrypted(&self) -> bool {
291        self.blob_key_slot != 0
292    }
293
294    // -- Accessors for internal fields --
295
296    /// Set the raw chunk payload.  Intended for test helpers that need to
297    /// construct synthetic objects via [`Store::make_object`].
298    pub fn set_payload(&mut self, payload: Vec<u8>) {
299        self.payload = payload;
300    }
301
302    pub fn index(&self) -> u8 {
303        self.index
304    }
305
306    pub fn age(&self) -> u32 {
307        self.age
308    }
309
310    pub fn chunk_pos(&self) -> u8 {
311        self.chunk_pos
312    }
313
314    pub fn next_chunk(&self) -> u8 {
315        self.next_chunk
316    }
317
318    /// Raw chunk payload bytes (read-only).
319    pub fn payload(&self) -> &[u8] {
320        &self.payload
321    }
322
323    /// Length of the raw chunk payload bytes.
324    pub fn payload_len(&self) -> usize {
325        self.payload.len()
326    }
327
328    pub fn object_size(&self) -> usize {
329        self.object_size
330    }
331}
332
333// ---------------------------------------------------------------------------
334// ObjectParams
335// ---------------------------------------------------------------------------
336
337/// Named parameters for [`Store::make_object`].
338pub struct ObjectParams {
339    pub index: u8,
340    pub age: u32,
341    pub chunk_pos: u8,
342    pub next_chunk: u8,
343}
344
345// ---------------------------------------------------------------------------
346// Store
347// ---------------------------------------------------------------------------
348
349/// The full set of PIV objects that make up one yb store.
350pub struct Store {
351    pub reader: String,
352    pub object_size: usize,
353    pub object_count: u8,
354    pub store_key_slot: u8,
355    pub objects: Vec<Object>,
356    /// Highest age seen across all objects; new objects get age = store_age + 1.
357    pub store_age: u32,
358}
359
360impl Store {
361    /// Read all objects from the device and construct a Store.
362    pub fn from_device(reader: &str, piv: &dyn PivBackend) -> Result<Self> {
363        let first_id = OBJECT_ID_ZERO;
364        let raw = piv
365            .read_object(reader, first_id)
366            .with_context(|| format!("reading object 0x{first_id:06x}"))?;
367
368        let first =
369            Object::from_bytes(0, &raw).with_context(|| "parsing object 0 (store header)")?;
370
371        let object_count = first.object_count;
372        let object_size = raw.len();
373        let store_key_slot = first.store_key_slot;
374
375        let mut objects = vec![first];
376        for i in 1..object_count {
377            let id = OBJECT_ID_ZERO + i as u32;
378            let raw = piv
379                .read_object(reader, id)
380                .with_context(|| format!("reading object 0x{id:06x}"))?;
381            let obj = Object::from_bytes(i, &raw).with_context(|| format!("parsing object {i}"))?;
382            objects.push(obj);
383        }
384
385        let store_age = objects.iter().map(|o| o.age).max().unwrap_or(0);
386
387        Ok(Self {
388            reader: reader.to_owned(),
389            object_size,
390            object_count,
391            store_key_slot,
392            objects,
393            store_age,
394        })
395    }
396
397    /// Write a fresh empty store to the device (yb format).
398    ///
399    /// Each slot is initialised with a compact 9-byte empty-slot sentinel
400    /// (spec 0010).  No `object_size` parameter — size is determined
401    /// dynamically at write time.
402    pub fn format(
403        reader: &str,
404        piv: &dyn PivBackend,
405        object_count: u8,
406        store_key_slot: u8,
407        management_key: &str,
408    ) -> Result<Self> {
409        // Build a temporary store (no objects yet) so we can use make_object.
410        let mut store = Self {
411            reader: reader.to_owned(),
412            object_size: OBJECT_MIN_SIZE,
413            object_count,
414            store_key_slot,
415            objects: Vec::with_capacity(object_count as usize),
416            store_age: 0,
417        };
418        for i in 0..object_count {
419            // age == 0 produces a 9-byte empty-slot sentinel.
420            store.objects.push(store.make_object(ObjectParams {
421                index: i,
422                age: 0,
423                chunk_pos: 0,
424                next_chunk: 0,
425            }));
426        }
427        // A failed format has its own messages (spec 0022): no partial note.
428        store.write_dirty(piv, management_key, false)?;
429        Ok(store)
430    }
431
432    /// Remove corrupt / orphaned / duplicate objects.
433    pub fn sanitize(&mut self) {
434        // Remove heads with invalid ages (age should be > 0 for occupied).
435        // Remove older duplicate-named blobs.
436        // Remove chunks whose head cannot be reached.
437
438        // Collect heads: name -> (index, age)
439        let mut seen: HashMap<String, (u8, u32)> = HashMap::new();
440        let mut to_reset: Vec<u8> = Vec::new();
441
442        for obj in self.objects.iter().filter(|o| o.is_head()) {
443            if let Some(&(prev_idx, prev_age)) = seen.get(&obj.blob_name) {
444                // Keep the newer one, reset the older.
445                if obj.age > prev_age {
446                    to_reset.push(prev_idx);
447                    seen.insert(obj.blob_name.clone(), (obj.index, obj.age));
448                } else {
449                    to_reset.push(obj.index);
450                }
451            } else {
452                seen.insert(obj.blob_name.clone(), (obj.index, obj.age));
453            }
454        }
455
456        // Collect all reachable chunk indices.
457        let mut reachable: HashSet<u8> = HashSet::new();
458        for (head_idx, _) in seen.values() {
459            let mut idx = *head_idx;
460            loop {
461                reachable.insert(idx);
462                let next = self.objects[idx as usize].next_chunk;
463                if next == idx {
464                    break;
465                }
466                idx = next;
467            }
468        }
469
470        // Mark unreachable non-empty objects for reset.
471        for obj in self.objects.iter().filter(|o| !o.is_empty()) {
472            if !reachable.contains(&obj.index) {
473                to_reset.push(obj.index);
474            }
475        }
476
477        for idx in to_reset {
478            self.objects[idx as usize].reset();
479        }
480    }
481
482    /// Write all dirty objects back to the device.  If a write fails after
483    /// others succeeded, the error says the store may be partially updated
484    /// (spec 0025 §3).
485    pub fn sync(&mut self, piv: &dyn PivBackend, management_key: &str) -> Result<()> {
486        self.write_dirty(piv, management_key, true)
487    }
488
489    fn write_dirty(
490        &mut self,
491        piv: &dyn PivBackend,
492        management_key: &str,
493        note_partial: bool,
494    ) -> Result<()> {
495        use indicatif::{ProgressBar, ProgressStyle};
496
497        let dirty: Vec<u8> = self
498            .objects
499            .iter()
500            .filter(|o| o.dirty)
501            .map(|o| o.index)
502            .collect();
503
504        let pb = ProgressBar::new(dirty.len() as u64);
505        pb.set_style(
506            ProgressStyle::with_template("Writing objects: [{bar:30}] {pos}/{len}")
507                .unwrap()
508                .progress_chars("=>-"),
509        );
510
511        for (written, idx) in dirty.iter().enumerate() {
512            let obj = &mut self.objects[*idx as usize];
513            let id = OBJECT_ID_ZERO + obj.index as u32;
514            let data = obj.to_bytes();
515            let result = piv.write_object(&self.reader, id, &data, management_key);
516            if note_partial && written > 0 {
517                result.context("the store may be partially updated; run `yb fsck`")?;
518            } else {
519                result?;
520            }
521            obj.dirty = false;
522            pb.inc(1);
523        }
524        pb.finish_and_clear();
525        Ok(())
526    }
527
528    /// Allocate the next free object index, or None if the store is full.
529    pub fn alloc_free(&self) -> Option<u8> {
530        self.objects.iter().find(|o| o.is_empty()).map(|o| o.index)
531    }
532
533    /// Number of free (empty) slots.
534    pub fn free_count(&self) -> usize {
535        self.objects.iter().filter(|o| o.is_empty()).count()
536    }
537
538    /// Find the head object for a blob by name.
539    pub fn find_head(&self, name: &str) -> Option<&Object> {
540        self.objects
541            .iter()
542            .find(|o| o.is_head() && o.blob_name == name)
543    }
544
545    /// Follow the chunk chain from a head, collecting all chunk indices in order.
546    ///
547    /// Includes a cycle guard: if a `next_chunk` pointer revisits an already-
548    /// seen index (corrupt store), the walk stops early so the function always
549    /// terminates.
550    pub fn chunk_chain(&self, head_index: u8) -> Vec<u8> {
551        let mut chain = vec![head_index];
552        let mut seen: HashSet<u8> = HashSet::from([head_index]);
553        let mut idx = head_index;
554        loop {
555            let next = self.objects[idx as usize].next_chunk;
556            if next == idx {
557                break;
558            }
559            if seen.contains(&next) {
560                // Corrupt store: cycle detected — stop walking.
561                break;
562            }
563            seen.insert(next);
564            chain.push(next);
565            idx = next;
566        }
567        chain
568    }
569
570    /// Current Unix timestamp (seconds), for use as blob_mtime.
571    pub fn now_unix() -> u32 {
572        SystemTime::now()
573            .duration_since(UNIX_EPOCH)
574            .map(|d| d.as_secs() as u32)
575            .unwrap_or(0)
576    }
577
578    /// Bump the store age and return the new value for a new chunk.
579    pub fn next_age(&mut self) -> u32 {
580        self.store_age += 1;
581        self.store_age
582    }
583
584    /// Construct a new occupied `Object` for this store, filling all common
585    /// header fields from the store's own metadata.  Blob-specific fields
586    /// (`blob_mtime`, `blob_size`, `blob_key_slot`, `blob_plain_size`,
587    /// `is_compressed`, `blob_name`, `payload`) are left at their zero values
588    /// for the caller to set.
589    pub fn make_object(&self, p: ObjectParams) -> Object {
590        Object {
591            index: p.index,
592            object_size: self.object_size,
593            yblob_magic: crate::store::constants::YBLOB_MAGIC,
594            object_count: self.object_count,
595            store_key_slot: self.store_key_slot,
596            age: p.age,
597            chunk_pos: p.chunk_pos,
598            next_chunk: p.next_chunk,
599            blob_mtime: 0,
600            blob_size: 0,
601            blob_key_slot: 0,
602            blob_plain_size: 0,
603            is_compressed: false,
604            blob_name: String::new(),
605            payload: Vec::new(),
606            dirty: true,
607        }
608    }
609}
610
611// ---------------------------------------------------------------------------
612// Little-endian helpers
613// ---------------------------------------------------------------------------
614
615pub(crate) fn read_u32_le(buf: &[u8], offset: usize) -> Result<u32> {
616    let end = offset + 4;
617    if end > buf.len() {
618        bail!(
619            "read_u32_le: offset {offset}+4 out of bounds (buf len {})",
620            buf.len()
621        );
622    }
623    Ok(u32::from_le_bytes(buf[offset..end].try_into().unwrap()))
624}
625
626pub(crate) fn read_u24_le(buf: &[u8], offset: usize) -> Result<u32> {
627    let end = offset + 3;
628    if end > buf.len() {
629        bail!(
630            "read_u24_le: offset {offset}+3 out of bounds (buf len {})",
631            buf.len()
632        );
633    }
634    let b = &buf[offset..end];
635    Ok(b[0] as u32 | ((b[1] as u32) << 8) | ((b[2] as u32) << 16))
636}
637
638pub(crate) fn write_u32_le(buf: &mut [u8], offset: usize, v: u32) {
639    buf[offset..offset + 4].copy_from_slice(&v.to_le_bytes());
640}
641
642pub(crate) fn write_u24_le(buf: &mut [u8], offset: usize, v: u32) {
643    buf[offset] = (v & 0xff) as u8;
644    buf[offset + 1] = ((v >> 8) & 0xff) as u8;
645    buf[offset + 2] = ((v >> 16) & 0xff) as u8;
646}
647
648// ---------------------------------------------------------------------------
649// Tests
650// ---------------------------------------------------------------------------
651
652#[cfg(test)]
653mod tests;