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