Skip to main content

yb_core/
nvm.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//! NVM free-space measurement.
7//!
8//! This module contains two functions with very different risk profiles:
9//!
10//! - [`scan_nvm`] — **read-only**: reads every occupied PIV object once and
11//!   tallies store / other / free bytes.  Safe to call at any time.
12//!
13//! - [`measure_free_nvm`] — **destructive probe**: fills every empty PIV slot
14//!   to its maximum capacity via binary search, sums the totals, then restores
15//!   all probe slots to empty.  The device is left in exactly the state it was
16//!   found in, but the probe writes are real and count against YubiKey NVM
17//!   wear.  Never call this function in a tight loop or in production hot paths.
18
19use crate::piv::session::PcscSession;
20use crate::piv::PivBackend;
21use crate::store::constants::OBJECT_ID_ZERO;
22use anyhow::Result;
23
24/// Maximum number of 3-byte PIV object IDs in the range 0x5F0000..0x5F00FF.
25const MAX_SLOTS: usize = 256;
26
27/// Upper bound for binary-search probing — above the empirically measured
28/// maximum (3063) to be safe across firmware versions.
29const PROBE_MAX: usize = 4095;
30
31/// Measure total free NVM on the device without altering its state.
32///
33/// Only slots that GET DATA reports as empty (SW 6A82 → size 0) are touched.
34/// Each empty slot is filled to its maximum capacity via binary search, the
35/// total is summed, and all probe slots are then freed (0-byte PUT DATA).
36///
37/// Returns the total number of bytes available for new allocations.
38///
39/// If `verbose` is true, prints per-slot progress to stderr.  Stops as soon
40/// as the first probe yields 0 bytes (NVM already full).
41pub fn measure_free_nvm(reader: &str, mgmt_key: &str, verbose: bool) -> Result<usize> {
42    let mut session = PcscSession::open(reader)?;
43
44    // Identify empty slots.
45    let mut empty_slots = Vec::new();
46    for i in 0..MAX_SLOTS {
47        let id = OBJECT_ID_ZERO + i as u32;
48        if session.try_get_data_size(id)?.unwrap_or(0) == 0 {
49            empty_slots.push(i);
50        }
51    }
52    if verbose {
53        eprintln!(
54            "  measure_free_nvm: {} empty slots out of {MAX_SLOTS}.",
55            empty_slots.len()
56        );
57    }
58
59    // Fill each empty slot to maximum via binary search.
60    let mut total = 0usize;
61    let mut filled: Vec<usize> = Vec::new();
62    for &slot in &empty_slots {
63        let id = OBJECT_ID_ZERO + slot as u32;
64        let size = dichotomy_fill(&mut session, mgmt_key, id)?;
65        if verbose {
66            eprintln!("    slot {slot:3}: {size} bytes");
67        }
68        if size == 0 {
69            if verbose {
70                eprintln!("    NVM full — stopping early.");
71            }
72            break;
73        }
74        total += size;
75        filled.push(slot);
76    }
77
78    // Restore all probe slots to empty.
79    for &slot in &filled {
80        let id = OBJECT_ID_ZERO + slot as u32;
81        session.authenticate_management_key(mgmt_key)?;
82        session.try_put_data(id, &[])?;
83    }
84
85    if verbose {
86        eprintln!("  measure_free_nvm: total free = {total} bytes.");
87    }
88    Ok(total)
89}
90
91/// Fill slot `id` to the largest payload that fits using binary search.
92///
93/// The slot must be empty on entry.  On return the slot holds `committed`
94/// bytes (the largest size that succeeded).  A failed PUT DATA clears the
95/// slot on the YubiKey, so after each failure we re-commit the last known
96/// good size before narrowing the search.
97fn dichotomy_fill(session: &mut PcscSession, mgmt_key: &str, id: u32) -> Result<usize> {
98    let mut lo = 1usize;
99    let mut hi = PROBE_MAX;
100    let mut committed = 0usize;
101
102    while lo <= hi {
103        let mid = (lo + hi) / 2;
104        session.authenticate_management_key(mgmt_key)?;
105        if session.try_put_data(id, &vec![0xEEu8; mid])? {
106            committed = mid;
107            lo = mid + 1;
108        } else {
109            // Failed write cleared the slot — re-commit last known good size.
110            if committed > 0 {
111                session.authenticate_management_key(mgmt_key)?;
112                session.try_put_data(id, &vec![0xEEu8; committed])?;
113            }
114            hi = mid - 1;
115        }
116    }
117    Ok(committed)
118}
119
120// ---------------------------------------------------------------------------
121// NVM usage scan
122// ---------------------------------------------------------------------------
123
124/// Total NVM budget of the YubiKey 5 PIV application (bytes).
125/// Used only as the denominator for the "free" estimate.
126const YUBIKEY_NVM_BYTES: usize = 51_200;
127
128/// NVM usage broken down into three buckets.
129pub struct NvmUsage {
130    /// Bytes consumed by yb store objects (the `0x5F_00xx` range).
131    pub store_bytes: usize,
132    /// Bytes consumed by other PIV objects (certificates, printed info, etc.).
133    pub other_bytes: usize,
134    /// Estimated free bytes (total budget minus store and other).
135    pub free_bytes: usize,
136}
137
138/// Well-known PIV data object IDs outside the `0x5F_00xx` yb store range.
139/// Sources: NIST SP 800-73-4 Table 3, Yubico extensions.
140const KNOWN_PIV_OBJECTS: &[u32] = &[
141    0x5F_C102, // Card Capability Container
142    0x5F_C100, // Card Holder Unique Identifier (CHUID)
143    0x5F_C101, // Card Authentication certificate
144    0x5F_C105, // PIV Authentication certificate (slot 9A)
145    0x5F_C10A, // Digital Signature certificate (slot 9C)
146    0x5F_C10B, // Key Management certificate (slot 9D)
147    0x5F_C10C, // Secure Messaging certificate
148    0x5F_C103, // Cardholder Fingerprints
149    0x5F_C106, // Security Object
150    0x5F_C108, // Cardholder Facial Image
151    0x5F_C109, // Printed Information (PIN-protected mgmt key)
152    0x5F_C10D, // Retired Key Management 1 certificate (slot 82)
153    0x5F_C10E, // Retired Key Management 2 certificate (slot 83)
154    0x5F_C10F, // Retired Key Management 3 certificate (slot 84)
155    0x5F_C110, // Retired Key Management 4 certificate (slot 85)
156    0x5F_C111, // Retired Key Management 5 certificate (slot 86)
157    0x5F_C112, // Retired Key Management 6 certificate (slot 87)
158    0x5F_C113, // Retired Key Management 7 certificate (slot 88)
159    0x5F_C114, // Retired Key Management 8 certificate (slot 89)
160    0x5F_C115, // Retired Key Management 9 certificate (slot 8A)
161    0x5F_C116, // Retired Key Management 10 certificate (slot 8B)
162    0x5F_C117, // Retired Key Management 11 certificate (slot 8C)
163    0x5F_C118, // Retired Key Management 12 certificate (slot 8D)
164    0x5F_C119, // Retired Key Management 13 certificate (slot 8E)
165    0x5F_C11A, // Retired Key Management 14 certificate (slot 8F)
166    0x5F_C11B, // Retired Key Management 15 certificate (slot 90)
167    0x5F_C11C, // Retired Key Management 16 certificate (slot 91)
168    0x5F_C11D, // Retired Key Management 17 certificate (slot 92)
169    0x5F_C11E, // Retired Key Management 18 certificate (slot 93)
170    0x5F_C11F, // Retired Key Management 19 certificate (slot 94)
171    0x5F_C120, // Retired Key Management 20 certificate (slot 95)
172    0x5F_C121, // Attestation certificate (Yubico extension)
173];
174
175/// Scan all known PIV object IDs and return NVM usage broken down by bucket.
176///
177/// Probes all 256 slots in `0x5F_0000..0x5F_00FF` (yb store range) plus the
178/// fixed set of well-known PIV object IDs.  No credentials required — only
179/// GET DATA (read-only) APDUs are issued.
180///
181/// `store_object_ids` identifies which `0x5F_00xx` slots are reachable yb
182/// store objects (counted as store bytes).  Any other occupied slot in the
183/// store range — i.e. orphaned chunks — is silently skipped so its bytes
184/// fall into the free estimate rather than polluting the "other" bucket.
185/// Occupied slots outside the store range go into the "other" bucket.
186pub fn scan_nvm(
187    reader: &str,
188    piv: &dyn PivBackend,
189    store_object_ids: &std::collections::HashSet<u32>,
190) -> Result<NvmUsage> {
191    let mut store_bytes = 0usize;
192    let mut other_bytes = 0usize;
193
194    // Scan the full yb store range: 0x5F_0000..0x5F_00FF.
195    for i in 0u32..256 {
196        let id = OBJECT_ID_ZERO + i;
197        if let Some(size) = piv.object_size(reader, id)? {
198            if store_object_ids.contains(&id) {
199                store_bytes += size;
200            }
201            // Orphaned store slots (occupied but not reachable) are skipped:
202            // they are treated as free space, not as "other".
203        }
204    }
205
206    // Scan well-known PIV objects outside the yb store range.
207    for &id in KNOWN_PIV_OBJECTS {
208        if let Some(size) = piv.object_size(reader, id)? {
209            other_bytes += size;
210        }
211    }
212
213    let used = store_bytes + other_bytes;
214    let free_bytes = YUBIKEY_NVM_BYTES.saturating_sub(used);
215
216    Ok(NvmUsage {
217        store_bytes,
218        other_bytes,
219        free_bytes,
220    })
221}