Skip to main content

yb_core/store/
mod.rs

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