Skip to main content

yb_core/
report.rs

1// SPDX-FileCopyrightText: 2026 Frederic Ruget <fred@atlant.is> (GitHub: @douzebis)
2//
3// SPDX-License-Identifier: MIT
4
5//! Read-only report on the state of a YubiKey (spec 0023 §1), shared by
6//! `yb fsck` and the guided `yb format`.
7//!
8//! Building a report sends read-only APDUs only and needs no PIN.  The
9//! key/certificate match check, which needs the PIN, is added by the
10//! caller.
11
12use crate::auxiliaries::ProtectionMode;
13use crate::context::{Context, SlotKeyCheck};
14use crate::piv::mgmt::{
15    get_metadata_apdu, key_algorithm_name, parse_credential_metadata, parse_mgmt_metadata,
16    parse_slot_metadata, CredentialMetadata, KeyOrigin, MgmtMetadata, SlotMetadata, TouchPolicy,
17    ALGO_ECCP256, GET_METADATA_MGMT, GET_METADATA_PIN, GET_METADATA_PUK,
18};
19use crate::piv::PivBackend;
20use crate::store::{constants::OBJECT_ID_ZERO, Store};
21
22/// How serious a report item is.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
24pub enum Severity {
25    Ok,
26    Warning,
27    Error,
28}
29
30/// The certificate of the store slot.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum SlotCertificate {
33    None,
34    /// An EC P-256 certificate, with its subject.
35    P256 {
36        subject: String,
37    },
38    /// A certificate for another key type (e.g. RSA).
39    NotP256 {
40        subject: String,
41    },
42    /// Data that does not parse as a certificate.
43    Unparseable,
44}
45
46/// The store slot: its key (as GET METADATA reports it) and certificate.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct SlotReport {
49    pub slot: u8,
50    /// `None` when the slot is empty, or the firmware cannot tell.
51    pub key: Option<SlotMetadata>,
52    pub certificate: SlotCertificate,
53}
54
55impl SlotReport {
56    /// The slot holds something yb would replace: a key or a certificate.
57    pub fn is_occupied(&self) -> bool {
58        self.key.is_some() || self.certificate != SlotCertificate::None
59    }
60
61    /// The certificate exists but does not hold an EC P-256 key.
62    pub fn certificate_unusable(&self) -> bool {
63        matches!(
64            self.certificate,
65            SlotCertificate::NotP256 { .. } | SlotCertificate::Unparseable
66        )
67    }
68}
69
70/// Whether a store exists (spec 0023 §1).
71pub enum StorePresence {
72    None,
73    Present(Store),
74    /// Object 0 exists but the store cannot be parsed.
75    Unreadable(String),
76}
77
78impl StorePresence {
79    /// Read the store, telling "no store" from "unreadable store".
80    pub fn probe(reader: &str, piv: &dyn PivBackend) -> Self {
81        match Store::from_device(reader, piv) {
82            Ok(store) => Self::Present(store),
83            Err(e) => match piv.object_size(reader, OBJECT_ID_ZERO) {
84                Ok(Some(_)) => Self::Unreadable(format!("{e:#}")),
85                _ => Self::None,
86            },
87        }
88    }
89}
90
91/// One line of the YubiKey section.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct Item {
94    pub label: String,
95    pub text: String,
96    pub severity: Severity,
97    /// Advice shown under the line by `yb fsck` (spec 0027 §8).
98    pub hint: Vec<String>,
99}
100
101/// The state of a YubiKey, from read-only APDUs.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct CardReport {
104    pub serial: u32,
105    pub firmware: String,
106    /// `None` on firmware without GET METADATA (< 5.3).
107    pub pin: Option<CredentialMetadata>,
108    pub puk: Option<CredentialMetadata>,
109    pub management_key: Option<MgmtMetadata>,
110    pub protection: ProtectionMode,
111    pub slot: SlotReport,
112    /// The key/certificate match check, when the caller ran it.
113    pub key_check: Option<SlotKeyCheck>,
114}
115
116impl CardReport {
117    /// Build the report for the YubiKey of `ctx`, with `slot` as the store
118    /// slot.  Read-only; no PIN.
119    pub fn build(ctx: &Context, slot: u8) -> Self {
120        let (reader, piv) = (ctx.reader.as_str(), ctx.piv.as_ref());
121        let metadata = |apdu: &[u8]| piv.send_apdu(reader, apdu).ok();
122        Self {
123            serial: ctx.serial,
124            firmware: ctx.firmware.clone(),
125            pin: metadata(&GET_METADATA_PIN).and_then(|d| parse_credential_metadata(&d).ok()),
126            puk: metadata(&GET_METADATA_PUK).and_then(|d| parse_credential_metadata(&d).ok()),
127            management_key: metadata(&GET_METADATA_MGMT).and_then(|d| parse_mgmt_metadata(&d).ok()),
128            protection: ctx.protection,
129            slot: SlotReport {
130                slot,
131                key: metadata(&get_metadata_apdu(slot)).and_then(|d| parse_slot_metadata(&d).ok()),
132                certificate: read_slot_certificate(reader, piv, slot),
133            },
134            key_check: None,
135        }
136    }
137
138    /// The firmware reports PIN, PUK and management key metadata (5.3+).
139    pub fn has_metadata(&self) -> bool {
140        self.pin.is_some()
141    }
142
143    pub fn pin_blocked(&self) -> bool {
144        self.pin.is_some_and(|m| m.retries_left == 0)
145    }
146
147    pub fn puk_blocked(&self) -> bool {
148        self.puk.is_some_and(|m| m.retries_left == 0)
149    }
150
151    /// The report lines, in display order.  `key_check_hint` is shown on
152    /// the Key/certificate line when the check did not run; `None` omits
153    /// the line.
154    pub fn items(&self, key_check_hint: Option<&str>) -> Vec<Item> {
155        let mut items = vec![
156            credential_item("PIN", self.pin),
157            credential_item("PUK", self.puk),
158            self.management_key_item(),
159            self.slot_item(),
160        ];
161        let check = match self.key_check {
162            Some(SlotKeyCheck::Match) => Some(("match".to_owned(), Severity::Ok)),
163            Some(SlotKeyCheck::Mismatch) => Some((
164                "the key does not match its certificate".to_owned(),
165                Severity::Error,
166            )),
167            Some(SlotKeyCheck::NoCertificate) => Some(("no certificate".to_owned(), Severity::Ok)),
168            None => key_check_hint.map(|hint| (format!("not checked ({hint})"), Severity::Ok)),
169        };
170        if let Some((text, severity)) = check {
171            items.push(item("Key/certificate", text, severity));
172        }
173        items
174    }
175
176    /// The most serious severity among the items.
177    pub fn severity(&self) -> Severity {
178        self.items(None)
179            .iter()
180            .map(|i| i.severity)
181            .max()
182            .unwrap_or(Severity::Ok)
183    }
184
185    /// The YubiKey section (spec 0023 §2), one line per item, followed by
186    /// the item's hint lines when `hints` is set.
187    pub fn render(&self, key_check_hint: Option<&str>, hints: bool) -> String {
188        let mut out = format!("YubiKey {} — firmware {}\n", self.serial, self.firmware);
189        for i in self.items(key_check_hint) {
190            let text = match i.severity {
191                Severity::Ok => i.text,
192                Severity::Warning => format!("warning: {}", i.text),
193                Severity::Error => format!("ERROR: {}", i.text),
194            };
195            out.push_str(&format!("  {:<16} {text}\n", i.label));
196            if hints {
197                for line in &i.hint {
198                    out.push_str(&format!("  {:<16} {line}\n", ""));
199                }
200            }
201        }
202        out
203    }
204
205    fn management_key_item(&self) -> Item {
206        const LABEL: &str = "Management key";
207        let algo = match self.management_key {
208            Some(md) => md.algo.to_string(),
209            None => "unknown algorithm (firmware < 5.3)".to_owned(),
210        };
211        let (protection, mut severity) = match self.protection {
212            ProtectionMode::Standard => ("kept on the YubiKey, unlocked by the PIN", Severity::Ok),
213            ProtectionMode::LegacyOrPukBlocked => (
214                "ADMIN DATA flag 0x01 (key kept on the YubiKey by yb ≤ 0.4.x, or blocked PUK)",
215                Severity::Warning,
216            ),
217            // The factory key needs no storing: "factory default" says it all.
218            ProtectionMode::None if self.management_key.is_some_and(|md| md.is_default) => {
219                ("", Severity::Ok)
220            }
221            ProtectionMode::None => ("not stored on the YubiKey", Severity::Ok),
222            ProtectionMode::Derived => {
223                return item(
224                    LABEL,
225                    format!("{algo}, PIN-derived (deprecated; yb does not support it)"),
226                    Severity::Error,
227                )
228            }
229            ProtectionMode::Invalid => {
230                return item(
231                    LABEL,
232                    format!("{algo}, ADMIN DATA (0x5FFF00) cannot be parsed"),
233                    Severity::Error,
234                )
235            }
236        };
237        let mut parts = vec![algo];
238        if !protection.is_empty() {
239            parts.push(protection.to_owned());
240        }
241        if let Some(md) = self.management_key {
242            if md.is_default {
243                parts.push("factory default".to_owned());
244                severity = severity.max(Severity::Warning);
245            }
246            match md.touch {
247                TouchPolicy::Always => {
248                    parts.push("touch always required".to_owned());
249                    severity = severity.max(Severity::Warning);
250                }
251                TouchPolicy::Cached => parts.push("touch required (cached)".to_owned()),
252                TouchPolicy::Never => {}
253            }
254        }
255        let mut item = item(LABEL, parts.join(", "), severity);
256        // A key yb must be given each time: say it could keep it (spec
257        // 0027 §8).  The factory key needs no keeping.
258        if self.protection == ProtectionMode::None
259            && self.management_key.is_some_and(|md| !md.is_default)
260        {
261            item.hint = vec![
262                "yb store and yb remove need it each time (YB_MANAGEMENT_KEY).".to_owned(),
263                "To have yb keep it on the YubiKey, unlocked by your PIN:".to_owned(),
264                "yb rotate-management-key".to_owned(),
265            ];
266        }
267        item
268    }
269
270    fn slot_item(&self) -> Item {
271        let label = format!("Slot 0x{:02x}", self.slot.slot);
272        let mut parts = Vec::new();
273        if let Some(key) = self.slot.key {
274            parts.push(key_algorithm_name(key.algorithm));
275            match key.origin {
276                Some(KeyOrigin::Generated) => parts.push("generated on card".to_owned()),
277                Some(KeyOrigin::Imported) => parts.push("imported".to_owned()),
278                None => {}
279            }
280        }
281        let mut severity = Severity::Ok;
282        match &self.slot.certificate {
283            SlotCertificate::None if self.slot.key.is_some() => {
284                parts.push("no certificate".to_owned())
285            }
286            SlotCertificate::None => parts.push("empty".to_owned()),
287            SlotCertificate::P256 { subject } => parts.push(format!("certificate {subject}")),
288            SlotCertificate::NotP256 { subject } => {
289                parts.push(format!(
290                    "certificate {subject} does not hold an EC P-256 key"
291                ));
292                severity = Severity::Error;
293            }
294            SlotCertificate::Unparseable => {
295                parts.push("the certificate cannot be parsed".to_owned());
296                severity = Severity::Error;
297            }
298        }
299        if self.slot.key.is_some_and(|k| k.algorithm != ALGO_ECCP256) {
300            severity = Severity::Error;
301        }
302        item(&label, parts.join(", "), severity)
303    }
304}
305
306fn item(label: &str, text: String, severity: Severity) -> Item {
307    Item {
308        label: label.to_owned(),
309        text,
310        severity,
311        hint: Vec::new(),
312    }
313}
314
315fn credential_item(label: &str, md: Option<CredentialMetadata>) -> Item {
316    let Some(md) = md else {
317        return item(label, "unknown (firmware < 5.3)".to_owned(), Severity::Ok);
318    };
319    let tries = format!("{}/{} tries left", md.retries_left, md.retries_total);
320    if md.retries_left == 0 {
321        // A blocked PUK only stops PIN resets: not an error.
322        let severity = if label == "PIN" {
323            Severity::Error
324        } else {
325            Severity::Warning
326        };
327        return item(label, format!("blocked ({tries})"), severity);
328    }
329    if md.is_default {
330        return item(
331            label,
332            format!("factory default ({tries})"),
333            Severity::Warning,
334        );
335    }
336    item(label, format!("ok ({tries})"), Severity::Ok)
337}
338
339fn read_slot_certificate(reader: &str, piv: &dyn PivBackend, slot: u8) -> SlotCertificate {
340    use der::Decode;
341    use x509_cert::Certificate;
342
343    let Ok(der) = piv.read_certificate(reader, slot) else {
344        return SlotCertificate::None;
345    };
346    let Ok(cert) = Certificate::from_der(&der) else {
347        return SlotCertificate::Unparseable;
348    };
349    let subject = cert.tbs_certificate.subject.to_string();
350    if crate::context::parse_ec_public_key_from_cert_der(&der).is_ok() {
351        SlotCertificate::P256 { subject }
352    } else {
353        SlotCertificate::NotP256 { subject }
354    }
355}
356
357// ---------------------------------------------------------------------------
358// Tests
359// ---------------------------------------------------------------------------
360
361#[cfg(all(test, feature = "virtual-piv"))]
362mod tests {
363    use super::*;
364    use crate::piv::VirtualPiv;
365    use std::sync::Arc;
366
367    const MGMT: &str = crate::auxiliaries::DEFAULT_MANAGEMENT_KEY;
368
369    #[test]
370    fn factory_card() {
371        let piv: Arc<dyn PivBackend> = Arc::new(VirtualPiv::new());
372        let ctx = Context::with_backend(piv, None, false).unwrap();
373        let report = CardReport::build(&ctx, 0x82);
374        assert!(report.has_metadata());
375        let text = report.render(Some("use --check-key"), true);
376        assert_eq!(
377            text,
378            "YubiKey 99999999 — firmware 5.4.3\n\
379             \x20 PIN              warning: factory default (3/3 tries left)\n\
380             \x20 PUK              warning: factory default (3/3 tries left)\n\
381             \x20 Management key   warning: 3DES, factory default\n\
382             \x20 Slot 0x82        empty\n\
383             \x20 Key/certificate  not checked (use --check-key)\n"
384        );
385        assert_eq!(report.severity(), Severity::Warning);
386        assert!(!report.slot.is_occupied());
387    }
388
389    #[test]
390    fn generated_key_with_certificate() {
391        let piv: Arc<dyn PivBackend> = Arc::new(VirtualPiv::new());
392        let reader = piv.list_devices().unwrap()[0].reader.clone();
393        piv.generate_certificate(&reader, 0x82, "/CN=YBLOB ECCP256", MGMT, None)
394            .unwrap();
395        let ctx = Context::with_backend(piv, None, false).unwrap();
396        let mut report = CardReport::build(&ctx, 0x82);
397        report.key_check = Some(SlotKeyCheck::Mismatch);
398        let slot = &report.items(None)[3];
399        assert_eq!(
400            slot.text,
401            "EC P-256, generated on card, certificate CN=YBLOB ECCP256"
402        );
403        assert_eq!(report.severity(), Severity::Error);
404    }
405
406    #[test]
407    fn store_presence() {
408        let piv = VirtualPiv::new();
409        let reader = piv.reader_name();
410        assert!(matches!(
411            StorePresence::probe(&reader, &piv),
412            StorePresence::None
413        ));
414        piv.write_object(&reader, OBJECT_ID_ZERO, b"garbage", MGMT)
415            .unwrap();
416        assert!(matches!(
417            StorePresence::probe(&reader, &piv),
418            StorePresence::Unreadable(_)
419        ));
420        Store::format(&reader, &piv, 4, 0x82, MGMT).unwrap();
421        assert!(matches!(
422            StorePresence::probe(&reader, &piv),
423            StorePresence::Present(_)
424        ));
425    }
426}