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        // Spec 0038: the previous content is intact; the next write frees
139        // what the interrupted one used.
140        fix: "remove blobs (`yb rm`); an interrupted store or removal leaves the blobs as \
141              they were, and the next yb command frees the space it used",
142    },
143    Entry {
144        op: OpPat::Is(|op| matches!(op, CardOp::WriteObject(_))),
145        sw_value: 0x6982,
146        mask: EXACT,
147        what: "the YubiKey refused the write",
148        why: "management key authentication did not happen or was lost",
149        fix: "this is likely a yb bug: please report it",
150    },
151    Entry {
152        op: OpPat::Is(|op| {
153            matches!(
154                op,
155                CardOp::GenerateKey(_) | CardOp::Sign(_) | CardOp::Ecdh(_)
156            )
157        }),
158        sw_value: 0x6982,
159        mask: EXACT,
160        what: "the YubiKey requires the PIN or a touch for this key",
161        why: "the PIN was not verified, or the key requires a touch",
162        fix: "run the command again, and touch the YubiKey if it blinks",
163    },
164    Entry {
165        op: OpPat::Is(|op| matches!(op, CardOp::Sign(_) | CardOp::Ecdh(_))),
166        sw_value: 0x6A80,
167        mask: EXACT,
168        what: "the key in slot {slot} cannot do this operation",
169        why: "it is not an EC P-256 key",
170        fix: "`yb format --generate` (erases the store)",
171    },
172    Entry {
173        op: OpPat::Any,
174        sw_value: 0x6D00,
175        mask: EXACT,
176        what: "this YubiKey does not support this operation",
177        why: "its firmware is too old for it",
178        fix: "",
179    },
180    Entry {
181        op: OpPat::Is(|op| *op == CardOp::SelectPiv),
182        sw_value: 0x6A82,
183        mask: EXACT,
184        what: "the PIV application is not available",
185        why: "PIV is disabled on this YubiKey, or the device is not a YubiKey",
186        fix: "`ykman config usb --enable PIV`",
187    },
188];
189
190/// The user-facing explanation of an error.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct Explanation {
193    pub what: String,
194    pub why: Option<String>,
195    pub fix: Option<String>,
196    /// Details line content (card errors only).
197    pub details: Option<String>,
198}
199
200/// What the renderer needs besides the error itself.  Only the fallback
201/// for unexpected errors uses it (spec 0025 §2).
202#[derive(Debug, Clone, Default)]
203pub struct RenderEnv {
204    pub firmware: Option<String>,
205    pub yb_version: Option<String>,
206}
207
208/// Explanation of a PC/SC failure, e.g. for `list-readers` hints.
209pub fn explain_pcsc(code: &PcscCode) -> Option<Explanation> {
210    let (what, fix) = match code {
211        PcscCode::NoService => (
212            "the smart card service is not running",
213            "start pcscd (`systemctl start pcscd.socket`); on NixOS, set \
214             `services.pcscd.enable = true`",
215        ),
216        PcscCode::NoReaders => (
217            "no YubiKey found",
218            "check that it is plugged in and that its CCID interface is enabled \
219             (`ykman config usb`); in a VM, check the USB passthrough (`lsusb` shows `1050:…`)",
220        ),
221        PcscCode::SharingViolation => (
222            "another program is using the YubiKey",
223            "usually gpg's scdaemon: `gpgconf --kill scdaemon`",
224        ),
225        PcscCode::RemovedCard | PcscCode::ResetCard => (
226            "the YubiKey was removed or reset during the operation",
227            "reconnect it; after a write command, run `yb fsck` to check the store",
228        ),
229        PcscCode::Other(_) => return None,
230    };
231    Some(Explanation {
232        what: what.to_owned(),
233        why: None,
234        fix: Some(fix.to_owned()),
235        details: None,
236    })
237}
238
239fn substitute(template: &str, op: &CardOp, sw: u16, err: &CardError) -> String {
240    let cred = if *op == CardOp::ChangePuk {
241        "PUK"
242    } else {
243        "PIN"
244    };
245    let tries = (sw & 0x0F) as u8;
246    let tries_text = match tries {
247        0 => format!("no attempts left: the {cred} is now blocked"),
248        1 => format!("1 attempt left; one more failure blocks the {cred}"),
249        n => format!("{n} attempts left before the {cred} is blocked"),
250    };
251    let algo = match err {
252        CardError::Status { ctx, .. } => ctx.algo.map(|a| a.to_string()),
253        _ => None,
254    }
255    .unwrap_or_else(|| "management key".to_owned());
256    let slot = match op {
257        CardOp::ReadCertificate(s) | CardOp::Sign(s) | CardOp::Ecdh(s) | CardOp::GenerateKey(s) => {
258            format!("0x{s:02x}")
259        }
260        _ => "the store slot".to_owned(),
261    };
262    template
263        .replace("{tries}", &tries_text)
264        .replace("{cred}", cred)
265        .replace("{algo}", &algo)
266        .replace("{slot}", &slot)
267}
268
269fn non_empty(s: String) -> Option<String> {
270    (!s.is_empty()).then_some(s)
271}
272
273/// Explain a card error, or `None` if the catalog does not know it.
274fn explain_card(err: &CardError) -> Option<Explanation> {
275    let details = Some(err.details());
276    let (op, sw) = match err {
277        CardError::Status { op, sw, .. } => (*op, *sw),
278        // yb itself found the management key wrong (response mismatch):
279        // same explanation as the card saying so.
280        CardError::Protocol {
281            op: CardOp::MgmtAuth,
282            ..
283        } => (CardOp::MgmtAuth, 0x6982),
284        CardError::Protocol { .. } => return None,
285        CardError::Pcsc { code, .. } => {
286            return explain_pcsc(code).map(|e| Explanation { details, ..e });
287        }
288    };
289    let entry = CATALOG
290        .iter()
291        .find(|e| e.op.matches(&op) && sw & e.mask == e.sw_value)?;
292    Some(Explanation {
293        what: substitute(entry.what, &op, sw, err),
294        why: non_empty(substitute(entry.why, &op, sw, err)),
295        fix: non_empty(substitute(entry.fix, &op, sw, err)),
296        details,
297    })
298}
299
300/// The fallback for card errors the catalog does not know: all the
301/// context available, never identifying data (spec 0025 §2).
302fn unexpected(err: &CardError, env: &RenderEnv) -> Explanation {
303    let op = match err {
304        CardError::Status { op, .. } | CardError::Protocol { op, .. } => op.describe(),
305        CardError::Pcsc { .. } => "the operation".to_owned(),
306    };
307    let mut details = err.details();
308    if let CardError::Status { ctx, .. } = err {
309        if let Some(algo) = ctx.algo {
310            details.push_str(&format!("; management key algorithm {algo}"));
311        }
312    }
313    if let Some(ref fw) = env.firmware {
314        details.push_str(&format!("; firmware {fw}"));
315    }
316    if let Some(ref v) = env.yb_version {
317        details.push_str(&format!("; yb {v}"));
318    }
319    Explanation {
320        what: format!("the YubiKey rejected {op}"),
321        why: Some(format!(
322            "this is unexpected — please report it at {ISSUES_URL} with the details below"
323        )),
324        fix: None,
325        details: Some(details),
326    }
327}
328
329fn sentence(s: &str) -> String {
330    let mut chars = s.chars();
331    let mut out: String = match chars.next() {
332        Some(c) => c.to_uppercase().chain(chars).collect(),
333        None => String::new(),
334    };
335    if !out.ends_with(['.', '!', '?']) {
336        out.push('.');
337    }
338    out
339}
340
341fn with_period(s: &str) -> String {
342    if s.ends_with(['.', '!', '?']) {
343        s.to_owned()
344    } else {
345        format!("{s}.")
346    }
347}
348
349/// Render an error chain for the user (spec 0025 §3).
350///
351/// If the chain contains a [`CardError`] or [`YbError`], its explanation is
352/// shown: as the headline when it stands alone, or as `Cause:` / `Try:`
353/// under the outer context messages.  Other errors render as `{e:#}`.
354pub fn render(err: &anyhow::Error, env: &RenderEnv) -> String {
355    let chain: Vec<&(dyn StdError + 'static)> = err.chain().collect();
356    let typed = chain.iter().enumerate().find_map(|(i, e)| {
357        if let Some(c) = e.downcast_ref::<CardError>() {
358            Some((i, explain_card(c).unwrap_or_else(|| unexpected(c, env))))
359        } else {
360            e.downcast_ref::<YbError>().map(|y| {
361                (
362                    i,
363                    Explanation {
364                        what: y.what.clone(),
365                        why: y.why.clone(),
366                        fix: y.fix.clone(),
367                        details: None,
368                    },
369                )
370            })
371        }
372    });
373    let Some((index, exp)) = typed else {
374        return format!("Error: {err:#}");
375    };
376
377    let mut lines = Vec::new();
378    if index == 0 {
379        lines.push(format!("Error: {}", with_period(&exp.what)));
380        if let Some(ref why) = exp.why {
381            lines.push(format!("  {}", sentence(why)));
382        }
383    } else {
384        let outer: Vec<String> = chain[..index].iter().map(|e| e.to_string()).collect();
385        lines.push(format!("Error: {}", outer.join(": ")));
386        let cause = match exp.why {
387            Some(ref why) => format!("{} ({why})", exp.what),
388            None => exp.what.clone(),
389        };
390        lines.push(format!("  Cause: {}", with_period(&cause)));
391    }
392    if let Some(ref fix) = exp.fix {
393        lines.push(format!("  Try: {}", with_period(fix)));
394    }
395    if let Some(ref details) = exp.details {
396        lines.push(format!("  (details: {details})"));
397    }
398    lines.join("\n")
399}
400
401// ---------------------------------------------------------------------------
402// Tests
403// ---------------------------------------------------------------------------
404
405#[cfg(test)]
406mod tests {
407    use super::super::{ErrCtx, PcscOp};
408    use super::*;
409    use crate::piv::MgmtAlgo;
410    use anyhow::Context as _;
411
412    fn env() -> RenderEnv {
413        RenderEnv {
414            firmware: Some("5.4.3".to_owned()),
415            yb_version: Some("0.4.2".to_owned()),
416        }
417    }
418
419    fn status(op: CardOp, sw: u16) -> anyhow::Error {
420        let [a, b] = sw.to_be_bytes();
421        anyhow::Error::new(CardError::status(op, a, b))
422    }
423
424    /// First line and details line of a standalone card error.
425    fn first_and_details(e: anyhow::Error) -> (String, String) {
426        let text = render(&e, &env());
427        let first = text.lines().next().unwrap().to_owned();
428        let details = text.lines().last().unwrap().to_owned();
429        (first, details)
430    }
431
432    #[test]
433    fn every_catalog_entry_renders() {
434        let cases: &[(CardOp, u16, &str)] = &[
435            (CardOp::VerifyPin, 0x63C2, "Error: wrong PIN."),
436            (CardOp::VerifyPin, 0x6983, "Error: the PIN is blocked."),
437            (
438                CardOp::MgmtAuth,
439                0x6A80,
440                "Error: cannot authenticate with the management key.",
441            ),
442            (CardOp::MgmtAuth, 0x6982, "Error: wrong management key."),
443            (
444                CardOp::ReadObject(crate::auxiliaries::OBJ_PRINTED),
445                0x6A82,
446                "Error: the management key is not stored on the YubiKey.",
447            ),
448            (
449                CardOp::ReadCertificate(0x82),
450                0x6A82,
451                "Error: no key in slot 0x82.",
452            ),
453            (
454                CardOp::WriteObject(0x5F0003),
455                0x6A84,
456                "Error: the YubiKey's storage is full.",
457            ),
458            (
459                CardOp::WriteObject(0x5F0003),
460                0x6982,
461                "Error: the YubiKey refused the write.",
462            ),
463            (
464                CardOp::Sign(0x82),
465                0x6982,
466                "Error: the YubiKey requires the PIN or a touch for this key.",
467            ),
468            (
469                CardOp::Ecdh(0x82),
470                0x6A80,
471                "Error: the key in slot 0x82 cannot do this operation.",
472            ),
473            (
474                CardOp::GenerateKey(0x82),
475                0x6D00,
476                "Error: this YubiKey does not support this operation.",
477            ),
478            (
479                CardOp::SelectPiv,
480                0x6A82,
481                "Error: the PIV application is not available.",
482            ),
483        ];
484        for &(op, sw, expected) in cases {
485            let (first, details) = first_and_details(status(op, sw));
486            assert_eq!(first, expected, "{op:?} {sw:04X}");
487            assert_eq!(
488                details,
489                format!("  (details: {} → SW {sw:04X})", op.describe()),
490                "{op:?} {sw:04X}"
491            );
492        }
493    }
494
495    #[test]
496    fn wrong_pin_counts_down() {
497        let text = render(&status(CardOp::VerifyPin, 0x63C1), &env());
498        assert!(
499            text.contains("1 attempt left; one more failure blocks the PIN"),
500            "{text}"
501        );
502        let text = render(&status(CardOp::VerifyPin, 0x63C2), &env());
503        assert!(text.contains("2 attempts left"), "{text}");
504    }
505
506    #[test]
507    fn response_mismatch_is_a_wrong_management_key() {
508        let e = anyhow::Error::new(CardError::protocol(
509            CardOp::MgmtAuth,
510            "card response mismatch",
511        ));
512        let text = render(&e, &env());
513        assert!(text.starts_with("Error: wrong management key."), "{text}");
514        assert!(text.ends_with("(details: management key authentication → card response mismatch)"));
515    }
516
517    #[test]
518    fn pcsc_errors_are_explained() {
519        for (code, what) in [
520            (PcscCode::NoService, "the smart card service is not running"),
521            (PcscCode::NoReaders, "no YubiKey found"),
522            (
523                PcscCode::SharingViolation,
524                "another program is using the YubiKey",
525            ),
526            (
527                PcscCode::RemovedCard,
528                "the YubiKey was removed or reset during the operation",
529            ),
530        ] {
531            let e = anyhow::Error::new(CardError::Pcsc {
532                op: PcscOp::Connect,
533                code: code.clone(),
534            });
535            let text = render(&e, &env());
536            assert!(text.starts_with(&format!("Error: {what}.")), "{text}");
537            assert!(text.contains("  Try: "), "{text}");
538            assert!(text.contains(&format!("(details: connecting to the YubiKey → {code})")));
539        }
540    }
541
542    #[test]
543    fn unexpected_errors_carry_all_context() {
544        let e = anyhow::Error::new(
545            CardError::status(CardOp::WriteObject(0x5F0001), 0x6F, 0x00).with_ctx(ErrCtx {
546                algo: Some(MgmtAlgo::Aes192),
547            }),
548        );
549        assert_eq!(
550            render(&e, &env()),
551            "Error: the YubiKey rejected write object 0x5F0001.\n  \
552             This is unexpected — please report it at https://github.com/douzebis/yb/issues \
553             with the details below.\n  \
554             (details: write object 0x5F0001 → SW 6F00; management key algorithm AES-192; \
555             firmware 5.4.3; yb 0.4.2)"
556        );
557    }
558
559    /// Spec 0025 §3, first example.
560    #[test]
561    fn snapshot_standalone_card_error() {
562        let e = anyhow::Error::new(CardError::status(CardOp::MgmtAuth, 0x6A, 0x80).with_ctx(
563            ErrCtx {
564                algo: Some(MgmtAlgo::Tdes),
565            },
566        ));
567        assert_eq!(
568            render(&e, &env()),
569            "Error: cannot authenticate with the management key.\n  \
570             The YubiKey rejected the 3DES algorithm yb used.\n  \
571             Try: `yb fsck` shows the card's management key algorithm.\n  \
572             (details: management key authentication → SW 6A80)"
573        );
574    }
575
576    /// Spec 0025 §3, second example: a card error under context.
577    #[test]
578    fn snapshot_card_error_under_context() {
579        let e = Err::<(), _>(CardError::status(CardOp::WriteObject(0x5F0003), 0x6A, 0x84))
580            .context("yb format stopped while erasing the store (completed: nothing)")
581            .unwrap_err();
582        assert_eq!(
583            render(&e, &env()),
584            "Error: yb format stopped while erasing the store (completed: nothing)\n  \
585             Cause: the YubiKey's storage is full (the PIV area's ~51 KB are used up).\n  \
586             Try: remove blobs (`yb rm`); an interrupted store or removal leaves the blobs as \
587             they were, and the next yb command frees the space it used.\n  \
588             (details: write object 0x5F0003 → SW 6A84)"
589        );
590    }
591
592    #[test]
593    fn yb_errors_render_without_details() {
594        let e = anyhow::Error::new(
595            YbError::new("the management key is not in PRINTED and is not the factory default")
596                .fix("set YB_MANAGEMENT_KEY"),
597        );
598        assert_eq!(
599            render(&e, &env()),
600            "Error: the management key is not in PRINTED and is not the factory default.\n  \
601             Try: set YB_MANAGEMENT_KEY."
602        );
603    }
604
605    #[test]
606    fn untyped_errors_render_as_before() {
607        let e = anyhow::anyhow!("plain failure").context("outer");
608        assert_eq!(render(&e, &env()), "Error: outer: plain failure");
609    }
610}