Skip to main content

yb_core/errors/
catalog.rs

1// SPDX-FileCopyrightText: 2026 Frederic Ruget <fred@atlant.is> (GitHub: @douzebis)
2//
3// SPDX-License-Identifier: MIT
4
5//! The error catalog and the renderer (spec 0025 §2–§3).
6//!
7//! The catalog maps (operation, status word) to what / why / fix texts.  It
8//! never depends on the firmware version.
9
10use super::{CardError, CardOp, PcscCode, YbError};
11use std::error::Error as StdError;
12
13const ISSUES_URL: &str = "https://github.com/douzebis/yb/issues";
14
15/// Which operations a catalog entry applies to.
16#[derive(Clone, Copy)]
17enum OpPat {
18    Any,
19    Is(fn(&CardOp) -> bool),
20}
21
22impl OpPat {
23    fn matches(self, op: &CardOp) -> bool {
24        match self {
25            Self::Any => true,
26            Self::Is(pred) => pred(op),
27        }
28    }
29}
30
31/// One catalog entry.  `sw & mask == sw_value` selects the status words.
32/// Texts may use `{tries}` (low nibble of a `63Cx`), `{cred}` (PIN or
33/// PUK), `{algo}` (the management key algorithm yb used) and `{slot}`.
34struct Entry {
35    op: OpPat,
36    sw_value: u16,
37    mask: u16,
38    what: &'static str,
39    why: &'static str,
40    fix: &'static str,
41}
42
43const EXACT: u16 = 0xFFFF;
44
45/// The catalog, most specific entries first (spec 0025 §2).
46static CATALOG: &[Entry] = &[
47    Entry {
48        op: OpPat::Is(|op| *op == CardOp::VerifyPin),
49        sw_value: 0x63C0,
50        mask: 0xFFF0,
51        what: "wrong PIN",
52        why: "{tries}",
53        fix: "",
54    },
55    Entry {
56        op: OpPat::Is(|op| *op == CardOp::VerifyPin),
57        sw_value: 0x6983,
58        mask: EXACT,
59        what: "the PIN is blocked",
60        why: "too many wrong attempts",
61        fix: "unblock it with the PUK (`ykman piv access unblock-pin`); if the PUK is \
62              blocked too, only a PIV reset helps, and it erases everything",
63    },
64    Entry {
65        op: OpPat::Is(|op| *op == CardOp::ChangePin),
66        sw_value: 0x63C0,
67        mask: 0xFFF0,
68        what: "wrong current PIN",
69        why: "{tries}",
70        fix: "",
71    },
72    Entry {
73        op: OpPat::Is(|op| *op == CardOp::ChangePuk),
74        sw_value: 0x63C0,
75        mask: 0xFFF0,
76        what: "wrong current PUK",
77        why: "{tries}",
78        fix: "",
79    },
80    Entry {
81        op: OpPat::Is(|op| matches!(op, CardOp::ChangePin | CardOp::ChangePuk)),
82        sw_value: 0x6983,
83        mask: EXACT,
84        what: "the {cred} is blocked",
85        why: "too many wrong attempts",
86        fix: "",
87    },
88    Entry {
89        op: OpPat::Is(|op| matches!(op, CardOp::ChangePin | CardOp::ChangePuk)),
90        sw_value: 0x6985,
91        mask: EXACT,
92        what: "the new {cred} does not meet this YubiKey's complexity requirement",
93        why: "the YubiKey enforces a PIN complexity policy",
94        fix: "choose a less predictable value (no repeated or sequential digits, \
95              not a common PIN)",
96    },
97    Entry {
98        op: OpPat::Is(|op| *op == CardOp::MgmtAuth),
99        sw_value: 0x6A80,
100        mask: EXACT,
101        what: "cannot authenticate with the management key",
102        why: "the YubiKey rejected the {algo} algorithm yb used",
103        fix: "`yb fsck` shows the card's management key algorithm",
104    },
105    Entry {
106        op: OpPat::Is(|op| *op == CardOp::MgmtAuth),
107        sw_value: 0x6982,
108        mask: EXACT,
109        what: "wrong management key",
110        why: "the key from YB_MANAGEMENT_KEY or from PRINTED is not the YubiKey's \
111              management key",
112        fix: "check YB_MANAGEMENT_KEY; `yb fsck` shows whether the YubiKey keeps its \
113              management key",
114    },
115    Entry {
116        op: OpPat::Is(|op| *op == CardOp::ReadObject(crate::auxiliaries::OBJ_PRINTED)),
117        sw_value: 0x6A82,
118        mask: EXACT,
119        what: "the management key is not stored on the YubiKey",
120        why: "the YubiKey does not keep its management key, or was set up by another tool",
121        fix: "set YB_MANAGEMENT_KEY, or have yb keep a new one on the YubiKey with \
122              `yb rotate-management-key`",
123    },
124    Entry {
125        op: OpPat::Is(|op| matches!(op, CardOp::ReadCertificate(_))),
126        sw_value: 0x6A82,
127        mask: EXACT,
128        what: "no key in slot {slot}",
129        why: "this YubiKey has not been set up for yb",
130        fix: "`yb format --generate`",
131    },
132    Entry {
133        op: OpPat::Is(|op| matches!(op, CardOp::WriteObject(_))),
134        sw_value: 0x6A84,
135        mask: EXACT,
136        what: "the YubiKey's storage is full",
137        why: "the PIV area's ~51 KB are used up",
138        fix: "`yb fsck --nvm`, then remove blobs",
139    },
140    Entry {
141        op: OpPat::Is(|op| matches!(op, CardOp::WriteObject(_))),
142        sw_value: 0x6982,
143        mask: EXACT,
144        what: "the YubiKey refused the write",
145        why: "management key authentication did not happen or was lost",
146        fix: "this is likely a yb bug: please report it",
147    },
148    Entry {
149        op: OpPat::Is(|op| {
150            matches!(
151                op,
152                CardOp::GenerateKey(_) | CardOp::Sign(_) | CardOp::Ecdh(_)
153            )
154        }),
155        sw_value: 0x6982,
156        mask: EXACT,
157        what: "the YubiKey requires the PIN or a touch for this key",
158        why: "the PIN was not verified, or the key requires a touch",
159        fix: "run the command again, and touch the YubiKey if it blinks",
160    },
161    Entry {
162        op: OpPat::Is(|op| matches!(op, CardOp::Sign(_) | CardOp::Ecdh(_))),
163        sw_value: 0x6A80,
164        mask: EXACT,
165        what: "the key in slot {slot} cannot do this operation",
166        why: "it is not an EC P-256 key",
167        fix: "`yb format --generate` (erases the store)",
168    },
169    Entry {
170        op: OpPat::Any,
171        sw_value: 0x6D00,
172        mask: EXACT,
173        what: "this YubiKey does not support this operation",
174        why: "its firmware is too old for it",
175        fix: "",
176    },
177    Entry {
178        op: OpPat::Is(|op| *op == CardOp::SelectPiv),
179        sw_value: 0x6A82,
180        mask: EXACT,
181        what: "the PIV application is not available",
182        why: "PIV is disabled on this YubiKey, or the device is not a YubiKey",
183        fix: "`ykman config usb --enable PIV`",
184    },
185];
186
187/// The user-facing explanation of an error.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct Explanation {
190    pub what: String,
191    pub why: Option<String>,
192    pub fix: Option<String>,
193    /// Details line content (card errors only).
194    pub details: Option<String>,
195}
196
197/// What the renderer needs besides the error itself.  Only the fallback
198/// for unexpected errors uses it (spec 0025 §2).
199#[derive(Debug, Clone, Default)]
200pub struct RenderEnv {
201    pub firmware: Option<String>,
202    pub yb_version: Option<String>,
203}
204
205/// Explanation of a PC/SC failure, e.g. for `list-readers` hints.
206pub fn explain_pcsc(code: &PcscCode) -> Option<Explanation> {
207    let (what, fix) = match code {
208        PcscCode::NoService => (
209            "the smart card service is not running",
210            "start pcscd (`systemctl start pcscd.socket`); on NixOS, set \
211             `services.pcscd.enable = true`",
212        ),
213        PcscCode::NoReaders => (
214            "no YubiKey found",
215            "check that it is plugged in and that its CCID interface is enabled \
216             (`ykman config usb`); in a VM, check the USB passthrough (`lsusb` shows `1050:…`)",
217        ),
218        PcscCode::SharingViolation => (
219            "another program is using the YubiKey",
220            "usually gpg's scdaemon: `gpgconf --kill scdaemon`",
221        ),
222        PcscCode::RemovedCard | PcscCode::ResetCard => (
223            "the YubiKey was removed or reset during the operation",
224            "reconnect it; after a write command, run `yb fsck` to check the store",
225        ),
226        PcscCode::Other(_) => return None,
227    };
228    Some(Explanation {
229        what: what.to_owned(),
230        why: None,
231        fix: Some(fix.to_owned()),
232        details: None,
233    })
234}
235
236fn substitute(template: &str, op: &CardOp, sw: u16, err: &CardError) -> String {
237    let cred = if *op == CardOp::ChangePuk {
238        "PUK"
239    } else {
240        "PIN"
241    };
242    let tries = (sw & 0x0F) as u8;
243    let tries_text = match tries {
244        0 => format!("no attempts left: the {cred} is now blocked"),
245        1 => format!("1 attempt left; one more failure blocks the {cred}"),
246        n => format!("{n} attempts left before the {cred} is blocked"),
247    };
248    let algo = match err {
249        CardError::Status { ctx, .. } => ctx.algo.map(|a| a.to_string()),
250        _ => None,
251    }
252    .unwrap_or_else(|| "management key".to_owned());
253    let slot = match op {
254        CardOp::ReadCertificate(s) | CardOp::Sign(s) | CardOp::Ecdh(s) | CardOp::GenerateKey(s) => {
255            format!("0x{s:02x}")
256        }
257        _ => "the store slot".to_owned(),
258    };
259    template
260        .replace("{tries}", &tries_text)
261        .replace("{cred}", cred)
262        .replace("{algo}", &algo)
263        .replace("{slot}", &slot)
264}
265
266fn non_empty(s: String) -> Option<String> {
267    (!s.is_empty()).then_some(s)
268}
269
270/// Explain a card error, or `None` if the catalog does not know it.
271fn explain_card(err: &CardError) -> Option<Explanation> {
272    let details = Some(err.details());
273    let (op, sw) = match err {
274        CardError::Status { op, sw, .. } => (*op, *sw),
275        // yb itself found the management key wrong (response mismatch):
276        // same explanation as the card saying so.
277        CardError::Protocol {
278            op: CardOp::MgmtAuth,
279            ..
280        } => (CardOp::MgmtAuth, 0x6982),
281        CardError::Protocol { .. } => return None,
282        CardError::Pcsc { code, .. } => {
283            return explain_pcsc(code).map(|e| Explanation { details, ..e });
284        }
285    };
286    let entry = CATALOG
287        .iter()
288        .find(|e| e.op.matches(&op) && sw & e.mask == e.sw_value)?;
289    Some(Explanation {
290        what: substitute(entry.what, &op, sw, err),
291        why: non_empty(substitute(entry.why, &op, sw, err)),
292        fix: non_empty(substitute(entry.fix, &op, sw, err)),
293        details,
294    })
295}
296
297/// The fallback for card errors the catalog does not know: all the
298/// context available, never identifying data (spec 0025 §2).
299fn unexpected(err: &CardError, env: &RenderEnv) -> Explanation {
300    let op = match err {
301        CardError::Status { op, .. } | CardError::Protocol { op, .. } => op.describe(),
302        CardError::Pcsc { .. } => "the operation".to_owned(),
303    };
304    let mut details = err.details();
305    if let CardError::Status { ctx, .. } = err {
306        if let Some(algo) = ctx.algo {
307            details.push_str(&format!("; management key algorithm {algo}"));
308        }
309    }
310    if let Some(ref fw) = env.firmware {
311        details.push_str(&format!("; firmware {fw}"));
312    }
313    if let Some(ref v) = env.yb_version {
314        details.push_str(&format!("; yb {v}"));
315    }
316    Explanation {
317        what: format!("the YubiKey rejected {op}"),
318        why: Some(format!(
319            "this is unexpected — please report it at {ISSUES_URL} with the details below"
320        )),
321        fix: None,
322        details: Some(details),
323    }
324}
325
326fn sentence(s: &str) -> String {
327    let mut chars = s.chars();
328    let mut out: String = match chars.next() {
329        Some(c) => c.to_uppercase().chain(chars).collect(),
330        None => String::new(),
331    };
332    if !out.ends_with(['.', '!', '?']) {
333        out.push('.');
334    }
335    out
336}
337
338fn with_period(s: &str) -> String {
339    if s.ends_with(['.', '!', '?']) {
340        s.to_owned()
341    } else {
342        format!("{s}.")
343    }
344}
345
346/// Render an error chain for the user (spec 0025 §3).
347///
348/// If the chain contains a [`CardError`] or [`YbError`], its explanation is
349/// shown: as the headline when it stands alone, or as `Cause:` / `Try:`
350/// under the outer context messages.  Other errors render as `{e:#}`.
351pub fn render(err: &anyhow::Error, env: &RenderEnv) -> String {
352    let chain: Vec<&(dyn StdError + 'static)> = err.chain().collect();
353    let typed = chain.iter().enumerate().find_map(|(i, e)| {
354        if let Some(c) = e.downcast_ref::<CardError>() {
355            Some((i, explain_card(c).unwrap_or_else(|| unexpected(c, env))))
356        } else {
357            e.downcast_ref::<YbError>().map(|y| {
358                (
359                    i,
360                    Explanation {
361                        what: y.what.clone(),
362                        why: y.why.clone(),
363                        fix: y.fix.clone(),
364                        details: None,
365                    },
366                )
367            })
368        }
369    });
370    let Some((index, exp)) = typed else {
371        return format!("Error: {err:#}");
372    };
373
374    let mut lines = Vec::new();
375    if index == 0 {
376        lines.push(format!("Error: {}", with_period(&exp.what)));
377        if let Some(ref why) = exp.why {
378            lines.push(format!("  {}", sentence(why)));
379        }
380    } else {
381        let outer: Vec<String> = chain[..index].iter().map(|e| e.to_string()).collect();
382        lines.push(format!("Error: {}", outer.join(": ")));
383        let cause = match exp.why {
384            Some(ref why) => format!("{} ({why})", exp.what),
385            None => exp.what.clone(),
386        };
387        lines.push(format!("  Cause: {}", with_period(&cause)));
388    }
389    if let Some(ref fix) = exp.fix {
390        lines.push(format!("  Try: {}", with_period(fix)));
391    }
392    if let Some(ref details) = exp.details {
393        lines.push(format!("  (details: {details})"));
394    }
395    lines.join("\n")
396}
397
398// ---------------------------------------------------------------------------
399// Tests
400// ---------------------------------------------------------------------------
401
402#[cfg(test)]
403mod tests {
404    use super::super::{ErrCtx, PcscOp};
405    use super::*;
406    use crate::piv::MgmtAlgo;
407    use anyhow::Context as _;
408
409    fn env() -> RenderEnv {
410        RenderEnv {
411            firmware: Some("5.4.3".to_owned()),
412            yb_version: Some("0.4.2".to_owned()),
413        }
414    }
415
416    fn status(op: CardOp, sw: u16) -> anyhow::Error {
417        let [a, b] = sw.to_be_bytes();
418        anyhow::Error::new(CardError::status(op, a, b))
419    }
420
421    /// First line and details line of a standalone card error.
422    fn first_and_details(e: anyhow::Error) -> (String, String) {
423        let text = render(&e, &env());
424        let first = text.lines().next().unwrap().to_owned();
425        let details = text.lines().last().unwrap().to_owned();
426        (first, details)
427    }
428
429    #[test]
430    fn every_catalog_entry_renders() {
431        let cases: &[(CardOp, u16, &str)] = &[
432            (CardOp::VerifyPin, 0x63C2, "Error: wrong PIN."),
433            (CardOp::VerifyPin, 0x6983, "Error: the PIN is blocked."),
434            (
435                CardOp::MgmtAuth,
436                0x6A80,
437                "Error: cannot authenticate with the management key.",
438            ),
439            (CardOp::MgmtAuth, 0x6982, "Error: wrong management key."),
440            (
441                CardOp::ReadObject(crate::auxiliaries::OBJ_PRINTED),
442                0x6A82,
443                "Error: the management key is not stored on the YubiKey.",
444            ),
445            (
446                CardOp::ReadCertificate(0x82),
447                0x6A82,
448                "Error: no key in slot 0x82.",
449            ),
450            (
451                CardOp::WriteObject(0x5F0003),
452                0x6A84,
453                "Error: the YubiKey's storage is full.",
454            ),
455            (
456                CardOp::WriteObject(0x5F0003),
457                0x6982,
458                "Error: the YubiKey refused the write.",
459            ),
460            (
461                CardOp::Sign(0x82),
462                0x6982,
463                "Error: the YubiKey requires the PIN or a touch for this key.",
464            ),
465            (
466                CardOp::Ecdh(0x82),
467                0x6A80,
468                "Error: the key in slot 0x82 cannot do this operation.",
469            ),
470            (
471                CardOp::GenerateKey(0x82),
472                0x6D00,
473                "Error: this YubiKey does not support this operation.",
474            ),
475            (
476                CardOp::SelectPiv,
477                0x6A82,
478                "Error: the PIV application is not available.",
479            ),
480        ];
481        for &(op, sw, expected) in cases {
482            let (first, details) = first_and_details(status(op, sw));
483            assert_eq!(first, expected, "{op:?} {sw:04X}");
484            assert_eq!(
485                details,
486                format!("  (details: {} → SW {sw:04X})", op.describe()),
487                "{op:?} {sw:04X}"
488            );
489        }
490    }
491
492    #[test]
493    fn wrong_pin_counts_down() {
494        let text = render(&status(CardOp::VerifyPin, 0x63C1), &env());
495        assert!(
496            text.contains("1 attempt left; one more failure blocks the PIN"),
497            "{text}"
498        );
499        let text = render(&status(CardOp::VerifyPin, 0x63C2), &env());
500        assert!(text.contains("2 attempts left"), "{text}");
501    }
502
503    #[test]
504    fn response_mismatch_is_a_wrong_management_key() {
505        let e = anyhow::Error::new(CardError::protocol(
506            CardOp::MgmtAuth,
507            "card response mismatch",
508        ));
509        let text = render(&e, &env());
510        assert!(text.starts_with("Error: wrong management key."), "{text}");
511        assert!(text.ends_with("(details: management key authentication → card response mismatch)"));
512    }
513
514    #[test]
515    fn pcsc_errors_are_explained() {
516        for (code, what) in [
517            (PcscCode::NoService, "the smart card service is not running"),
518            (PcscCode::NoReaders, "no YubiKey found"),
519            (
520                PcscCode::SharingViolation,
521                "another program is using the YubiKey",
522            ),
523            (
524                PcscCode::RemovedCard,
525                "the YubiKey was removed or reset during the operation",
526            ),
527        ] {
528            let e = anyhow::Error::new(CardError::Pcsc {
529                op: PcscOp::Connect,
530                code: code.clone(),
531            });
532            let text = render(&e, &env());
533            assert!(text.starts_with(&format!("Error: {what}.")), "{text}");
534            assert!(text.contains("  Try: "), "{text}");
535            assert!(text.contains(&format!("(details: connecting to the YubiKey → {code})")));
536        }
537    }
538
539    #[test]
540    fn unexpected_errors_carry_all_context() {
541        let e = anyhow::Error::new(
542            CardError::status(CardOp::WriteObject(0x5F0001), 0x6F, 0x00).with_ctx(ErrCtx {
543                algo: Some(MgmtAlgo::Aes192),
544            }),
545        );
546        assert_eq!(
547            render(&e, &env()),
548            "Error: the YubiKey rejected write object 0x5F0001.\n  \
549             This is unexpected — please report it at https://github.com/douzebis/yb/issues \
550             with the details below.\n  \
551             (details: write object 0x5F0001 → SW 6F00; management key algorithm AES-192; \
552             firmware 5.4.3; yb 0.4.2)"
553        );
554    }
555
556    /// Spec 0025 §3, first example.
557    #[test]
558    fn snapshot_standalone_card_error() {
559        let e = anyhow::Error::new(CardError::status(CardOp::MgmtAuth, 0x6A, 0x80).with_ctx(
560            ErrCtx {
561                algo: Some(MgmtAlgo::Tdes),
562            },
563        ));
564        assert_eq!(
565            render(&e, &env()),
566            "Error: cannot authenticate with the management key.\n  \
567             The YubiKey rejected the 3DES algorithm yb used.\n  \
568             Try: `yb fsck` shows the card's management key algorithm.\n  \
569             (details: management key authentication → SW 6A80)"
570        );
571    }
572
573    /// Spec 0025 §3, second example: a card error under context.
574    #[test]
575    fn snapshot_card_error_under_context() {
576        let e = Err::<(), _>(CardError::status(CardOp::WriteObject(0x5F0003), 0x6A, 0x84))
577            .context("yb format stopped while erasing the store (completed: nothing)")
578            .unwrap_err();
579        assert_eq!(
580            render(&e, &env()),
581            "Error: yb format stopped while erasing the store (completed: nothing)\n  \
582             Cause: the YubiKey's storage is full (the PIV area's ~51 KB are used up).\n  \
583             Try: `yb fsck --nvm`, then remove blobs.\n  \
584             (details: write object 0x5F0003 → SW 6A84)"
585        );
586    }
587
588    #[test]
589    fn yb_errors_render_without_details() {
590        let e = anyhow::Error::new(
591            YbError::new("the management key is not in PRINTED and is not the factory default")
592                .fix("set YB_MANAGEMENT_KEY"),
593        );
594        assert_eq!(
595            render(&e, &env()),
596            "Error: the management key is not in PRINTED and is not the factory default.\n  \
597             Try: set YB_MANAGEMENT_KEY."
598        );
599    }
600
601    #[test]
602    fn untyped_errors_render_as_before() {
603        let e = anyhow::anyhow!("plain failure").context("outer");
604        assert_eq!(render(&e, &env()), "Error: outer: plain failure");
605    }
606}