yb 0.7.1

Secure blob storage on a YubiKey
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
// SPDX-FileCopyrightText: 2025, 2026 Frederic Ruget <fred@atlant.is> (GitHub: @douzebis)
//
// SPDX-License-Identifier: MIT

use anyhow::Result;
use clap::Args;
use std::collections::HashSet;
use std::io::Write;
use yb_core::{
    errors::YbError,
    orchestrator::uses_legacy_encryption,
    parse_ec_public_key_from_cert_der,
    report::{CardReport, Fido2Report, ReportStore, Severity, StorePresence},
    scan_store_memory,
    store::{
        constants::{DEFAULT_KEY_SLOT, OBJECT_ID_ZERO},
        Object, Store, MAX_AGE,
    },
    Context,
};

use crate::cli::util::{check_blob_signature, quote_name, SigVerdict};

#[derive(Args, Debug)]
pub struct FsckArgs {
    /// Print full per-object dump in addition to the summary.
    #[arg(short = 'v', long = "verbose")]
    pub verbose: bool,

    /// Scan all PIV objects to report the YubiKey's memory (used by the
    /// store, by other data, free).
    /// Issues ~290 read-only APDUs; may take a few seconds on real hardware.
    #[arg(long = "nvm")]
    pub nvm: bool,

    /// Ask for the PIN and check that the key in the store slot matches
    /// its certificate.
    #[arg(long = "check-key")]
    pub check_key: bool,

    /// Post-quantum stores: also check, with the FIDO2 PIN, the store's
    /// credential and the management key (one touch), then every blob's
    /// seal (one touch per blob).  Without it, fsck asks for nothing.
    #[arg(long = "seals")]
    pub seals: bool,
}

// ---------------------------------------------------------------------------
// run
// ---------------------------------------------------------------------------

pub fn run(ctx: &Context, args: &FsckArgs) -> Result<()> {
    let healthy = check(ctx, args, &mut std::io::stdout().lock())?;
    if !healthy {
        std::process::exit(1);
    }
    Ok(())
}

/// Write the fsck report to `out`: the YubiKey section (spec 0023 ยง2),
/// then the store.  Returns `false` when the report has an error (exit
/// status 1).  Writes nothing to the card.
pub fn check(ctx: &Context, args: &FsckArgs, out: &mut dyn Write) -> Result<bool> {
    let presence = StorePresence::probe(&ctx.reader, ctx.piv.as_ref());
    let slot = match presence {
        StorePresence::Present(ref store) => store.store_key_slot,
        _ => DEFAULT_KEY_SLOT,
    };
    let post_quantum =
        matches!(&presence, StorePresence::Present(store) if store.kind.is_post_quantum());

    let mut report = CardReport::build(ctx, slot);
    // Each application's role (spec 0045); a post-quantum store has no
    // store slot (spec 0028).
    report.store = match &presence {
        StorePresence::Present(_) if post_quantum => ReportStore::PostQuantum,
        StorePresence::Present(_) => ReportStore::Legacy,
        _ => ReportStore::None,
    };
    if let StorePresence::Present(store) = &presence {
        if let Some(descriptor) = store.descriptor() {
            report.fido2 = Some(Fido2Report {
                state: yb_core::report::fido2_state(ctx),
                credential_id_len: descriptor.credential_id.len(),
                checked_below: args.seals,
                unencrypted_blobs: store
                    .objects
                    .iter()
                    .any(|o| o.is_head() && !o.is_encrypted()),
            });
        }
    }
    // A certificate without a P-256 key is already reported as an error.
    if args.check_key && !post_quantum && !report.slot.certificate_unusable() {
        if ctx.require_pin()?.is_none() {
            return Err(YbError::new("a PIN is needed for --check-key")
                .fix("set YB_PIN, use --pin-stdin, or run yb in a terminal")
                .into());
        }
        report.key_check = Some(ctx.check_slot_key(slot)?);
    }
    let hint = (!post_quantum).then_some("use --check-key");
    writeln!(out, "{}", report.render(hint, true))?;
    let card_ok = report.severity() < Severity::Error;

    let store_ok = match presence {
        StorePresence::None => {
            writeln!(out, "Store: none โ€” run `yb format` to create one")?;
            if args.nvm {
                write_nvm(ctx, out)?;
            }
            true
        }
        StorePresence::Unreadable(reason) => {
            writeln!(out, "Store: unreadable ({reason})")?;
            false
        }
        StorePresence::Present(store) if post_quantum => {
            check_post_quantum_store(ctx, args, &store, out)?
        }
        StorePresence::Present(store) => check_store(ctx, args, &store, out)?,
    };
    Ok(card_ok && store_ok)
}

/// The store part of the report, unchanged since before spec 0023.
fn check_store(ctx: &Context, args: &FsckArgs, store: &Store, out: &mut dyn Write) -> Result<bool> {
    // Fetch public key from the store's key slot certificate โ€” no PIN needed.
    let verifying_key = ctx
        .piv
        .read_certificate(&ctx.reader, store.store_key_slot)
        .ok()
        .and_then(|cert_der| parse_ec_public_key_from_cert_der(&cert_der).ok())
        .map(|pk| {
            use p256::ecdsa::VerifyingKey;
            VerifyingKey::from(&pk)
        });

    let heads: Vec<&Object> = store.objects.iter().filter(|o| o.is_head()).collect();
    let stored = heads.len();

    // Per-blob signature verdicts.
    let mut sig_verified = 0usize;
    let mut sig_unsigned = 0usize;
    let mut sig_unverifiable = 0usize;
    let mut sig_newer = 0usize;
    let mut sig_corrupted = 0usize;
    let mut legacy = 0usize;

    let mut blob_verdicts: Vec<(&Object, SigVerdict)> = Vec::new();
    for head in &heads {
        let verdict = check_blob_signature(head, store, verifying_key.as_ref());
        match verdict {
            // Post-quantum stores (spec 0028) get their own report in
            // `yb fsck`; the legacy check never gives a checksum verdict.
            SigVerdict::Verified | SigVerdict::ChecksumOk => sig_verified += 1,
            SigVerdict::Unsigned => sig_unsigned += 1,
            SigVerdict::Unverifiable => sig_unverifiable += 1,
            SigVerdict::NewerVersion => sig_newer += 1,
            SigVerdict::Corrupted => sig_corrupted += 1,
        }
        blob_verdicts.push((head, verdict));
    }

    // Store header โ€” count only reachable (non-orphaned) objects as used.
    let reachable: HashSet<u8> = heads
        .iter()
        .flat_map(|h| store.chunk_chain(h.index()))
        .collect();
    let free_count = store
        .objects
        .iter()
        .filter(|o| o.is_empty() || !reachable.contains(&o.index()))
        .count();
    let store_bytes_used: usize = store
        .objects
        .iter()
        .filter(|o| reachable.contains(&o.index()))
        .map(|o| o.object_size())
        .sum();

    writeln!(
        out,
        "Store: legacy, {} objects, age {}",
        store.object_count, store.store_age
    )?;
    writeln!(
        out,
        "  {:<16} {} stored, {} objects free (~{} bytes used by the store)",
        "Blobs", stored, free_count, store_bytes_used
    )?;

    // Ages are 24-bit; a store must be reformatted before they run out
    // (spec 0034 G6).
    if store.store_age > MAX_AGE / 10 * 9 {
        writeln!(
            out,
            "warning: the store is at age {} of {MAX_AGE}; stores fail when ages run \
             out: fetch the blobs, reformat, store them again",
            store.store_age
        )?;
    }

    // Objects a write that ran out of card memory deleted (spec 0038).
    for &idx in &store.missing {
        writeln!(
            out,
            "warning: object 0x{:06X} is absent from the card (a write ran out of card \
             memory); the next write recreates it",
            OBJECT_ID_ZERO + u32::from(idx)
        )?;
    }

    // Objects recording another object count than object 0 do not belong
    // to this store; yb ignores them (spec 0034 G7).
    let foreign: Vec<&Object> = store
        .objects
        .iter()
        .filter(|o| o.object_count() != store.object_count)
        .collect();
    for obj in &foreign {
        writeln!(
            out,
            "error: object {} records a store of {} objects, not {}: not part of this \
             store, ignored",
            obj.index(),
            obj.object_count(),
            store.object_count
        )?;
    }

    // Per-blob table.
    if stored > 0 {
        writeln!(out)?;
        for (head, verdict) in &blob_verdicts {
            // LEGACY: the encryption before spec 0006 (spec 0034 G4).
            let marker = if *verdict != SigVerdict::Corrupted && uses_legacy_encryption(head) {
                legacy += 1;
                "  LEGACY"
            } else {
                ""
            };
            writeln!(
                out,
                "  {:<30} {verdict}{marker}",
                quote_name(&head.blob_name)
            )?;
        }
        writeln!(out)?;
        writeln!(
            out,
            "Integrity: {} verified, {} unsigned, {} corrupted",
            sig_verified, sig_unsigned, sig_corrupted
        )?;
        if sig_unverifiable > 0 {
            writeln!(
                out,
                "  {sig_unverifiable} signed blob(s) not checked: no certificate in the store's key slot"
            )?;
        }
        if sig_newer > 0 {
            writeln!(
                out,
                "  {sig_newer} blob(s) not checked: signed by a newer version of yb (update yb \
                 to check them)"
            )?;
        }
        if legacy > 0 {
            writeln!(
                out,
                "  {legacy} blob(s) LEGACY: encrypted as by older yb versions (AES-CBC, \
                 unauthenticated); store them again to protect them with AES-GCM"
            )?;
        }
    }

    // NVM breakdown โ€” only when --nvm is requested.
    if args.nvm {
        write_nvm(ctx, out)?;
    }

    // Structural anomalies โ€” verbose only.
    let has_anomalies = args.verbose && {
        let warnings = detect_anomalies(store);
        for w in &warnings {
            writeln!(out, "WARNING: {w}")?;
        }

        writeln!(out)?;
        for obj in &store.objects {
            writeln!(out, "Object {}:", obj.index())?;
            writeln!(out, "  age:        {}", obj.age())?;
            if obj.age() == 0 {
                writeln!(out, "  (empty)")?;
            } else {
                writeln!(out, "  chunk_pos:  {}", obj.chunk_pos())?;
                writeln!(out, "  next_chunk: {}", obj.next_chunk())?;
                if obj.chunk_pos() == 0 {
                    writeln!(out, "  blob_name:      {}", obj.blob_name)?;
                    writeln!(out, "  blob_size:      {}", obj.blob_size)?;
                    writeln!(out, "  blob_plain_sz:  {}", obj.blob_plain_size)?;
                    writeln!(out, "  blob_key_slot:  0x{:02x}", obj.blob_key_slot)?;
                    writeln!(out, "  blob_mtime:     {}", obj.blob_mtime)?;
                    writeln!(
                        out,
                        "  encrypted:      {}",
                        if obj.is_encrypted() { "yes" } else { "no" }
                    )?;
                }
                writeln!(out, "  payload_len: {}", obj.payload_len())?;
            }
            writeln!(out)?;
        }
        !warnings.is_empty()
    };

    Ok(sig_corrupted == 0 && foreign.is_empty() && !has_anomalies)
}

/// The store part of the report for a post-quantum store (spec 0028): the
/// descriptor, each blob's checksum (no PIN), then the credential and the
/// management key (FIDO2 PIN, one touch), and with `--seals` each blob's
/// seal (one touch per blob).  Writes nothing to the card.
#[cfg(not(windows))]
fn check_post_quantum_store(
    ctx: &Context,
    args: &FsckArgs,
    store: &Store,
    out: &mut dyn Write,
) -> Result<bool> {
    use yb_core::orchestrator::post_quantum::quick_check;
    use yb_core::orchestrator::SigVerdict;

    let descriptor = store.descriptor().expect("a post-quantum store");
    let heads: Vec<&Object> = store.objects.iter().filter(|o| o.is_head()).collect();
    let reachable: HashSet<u8> = heads
        .iter()
        .flat_map(|h| store.chunk_chain(h.index()))
        .collect();
    let free_count = store
        .objects
        .iter()
        .filter(|o| o.is_empty() || !reachable.contains(&o.index()))
        .count();
    writeln!(
        out,
        "Store: post-quantum, {} objects, age {}",
        store.object_count, store.store_age
    )?;
    let not_checked = if args.seals { "" } else { ", not checked" };
    writeln!(
        out,
        "  {:<16} a copy, wrapped by FIDO2 ({}){not_checked}",
        "Management key",
        descriptor.mgmt_algo.name()
    )?;
    writeln!(
        out,
        "  {:<16} {} stored, {} objects free",
        "Blobs",
        heads.len(),
        free_count
    )?;
    for &idx in &store.missing {
        writeln!(
            out,
            "warning: object 0x{:06X} is absent from the card (a write ran out of card \
             memory); the next write recreates it",
            store.object_id(idx)
        )?;
    }

    let mut healthy = true;
    if !heads.is_empty() {
        writeln!(out)?;
        let mut corrupted = 0;
        for head in &heads {
            let verdict = quick_check(head, store);
            if verdict == SigVerdict::Corrupted {
                corrupted += 1;
            }
            writeln!(out, "  {:<30} {verdict}", quote_name(&head.blob_name))?;
        }
        writeln!(out)?;
        let ok = heads.len() - corrupted;
        let checksums = if ok == 1 { "checksum" } else { "checksums" };
        writeln!(out, "Integrity: {ok} {checksums} OK, {corrupted} corrupted")?;
        healthy &= corrupted == 0;
    }

    if !args.seals {
        writeln!(
            out,
            "Not checked: credential, management key, seals โ€” check with `yb fsck --seals` \
             (needs the FIDO2 PIN)"
        )?;
    } else {
        // Prompts go to standard error while the checks run; the section
        // is printed whole once they are done (spec 0045 S3).
        let mut lines = Vec::new();
        healthy &= check_with_fido2(ctx, store, descriptor, &heads, &mut lines);
        writeln!(out, "Checked with the FIDO2 PIN:")?;
        for (label, text) in lines {
            writeln!(out, "  {label:<16} {text}")?;
        }
    }

    if args.nvm {
        write_nvm(ctx, out)?;
    }
    Ok(healthy)
}

/// What `--seals` will ask for: one touch for the credential and the
/// management key, then one per blob, as each blob is sealed with a key of
/// its own (one FIDO2 assertion each).
#[cfg(not(windows))]
fn seals_notice(blobs: usize) -> String {
    match blobs {
        0 => "Checking the seals: 1 touch, for the credential and the management key.".to_owned(),
        n => format!(
            "Checking the seals: {} touches, one for the credential and the management key, then \
             one per blob (each is sealed with its own key).",
            n + 1
        ),
    }
}

/// The checks that need the FIDO2 PIN (`fsck --seals`, spec 0045 S3): the
/// credential and the management key (one touch), then each blob's seal
/// (one touch each).  Fills `lines` with (label, result); returns whether
/// all passed.
#[cfg(not(windows))]
fn check_with_fido2(
    ctx: &Context,
    store: &Store,
    descriptor: &yb_core::store::Descriptor,
    heads: &[&Object],
    lines: &mut Vec<(String, String)>,
) -> bool {
    use yb_core::fido2::Fido2Error;
    use yb_core::orchestrator::fetch_blob_with;
    use yb_core::orchestrator::post_quantum::IntegrityError;
    let error = |e: &anyhow::Error| format!("ERROR: {e:#}");
    // How many touches, and why, before the FIDO2 PIN is asked; then each
    // touch numbered (spec 0051 S4).
    let total = 1 + heads.len();
    if !ctx.quiet {
        eprintln!("{}", seals_notice(heads.len()));
    }
    let session = match ctx.post_quantum_session() {
        Ok(session) => session,
        Err(e) => {
            lines.push(("FIDO2".to_owned(), error(&e)));
            return false;
        }
    };
    // Each touch says what it checks.
    let touches = std::cell::Cell::new(0);
    let purpose = std::cell::RefCell::new(String::from("the credential and the management key"));
    let numbered = || {
        touches.set(touches.get() + 1);
        if !ctx.quiet {
            eprintln!(
                "Touch your YubiKey ({}/{total}) to check {}โ€ฆ",
                touches.get(),
                purpose.borrow()
            );
        }
    };
    let mut protection = session.protection(ctx, descriptor);
    protection.on_touch = &numbered;
    match protection.management_key() {
        Ok(_) => {
            lines.push(("Credential".to_owned(), "answers".to_owned()));
            lines.push(("Management key".to_owned(), "matches the card".to_owned()));
        }
        Err(e) => {
            let mismatch = e
                .downcast_ref::<IntegrityError>()
                .is_some_and(|i| *i == IntegrityError::ManagementKeyMismatch);
            let no_credential = e
                .downcast_ref::<Fido2Error>()
                .is_some_and(|f| *f == Fido2Error::NoCredential);
            if mismatch {
                lines.push(("Credential".to_owned(), "answers".to_owned()));
                lines.push(("Management key".to_owned(), error(&e)));
            } else if no_credential {
                lines.push(("Credential".to_owned(), error(&e)));
            } else {
                lines.push(("FIDO2".to_owned(), error(&e)));
            }
            return false;
        }
    }
    let mut healthy = true;
    for head in heads {
        let name = quote_name(&head.blob_name);
        *purpose.borrow_mut() = format!("the seal of {name}");
        match fetch_blob_with(store, &protection, &head.blob_name) {
            Ok(_) => lines.push((name, "seal OK".to_owned())),
            Err(e) => {
                lines.push((name, error(&e)));
                healthy = false;
            }
        }
    }
    healthy
}

#[cfg(windows)]
fn check_post_quantum_store(
    _: &Context,
    _: &FsckArgs,
    _: &Store,
    out: &mut dyn Write,
) -> Result<bool> {
    writeln!(out, "Store: post-quantum (not supported on Windows)")?;
    Ok(false)
}

/// The `--nvm` line: the store and the scan read under one card lock, at
/// the end, after any touch (spec 0048: nothing waits for the user under
/// it).
fn write_nvm(ctx: &Context, out: &mut dyn Write) -> Result<()> {
    match scan_store_memory(&ctx.reader, ctx.piv.as_ref()) {
        Ok(usage) => writeln!(
            out,
            "\nYubiKey memory: ~{} bytes used by the store, ~{} bytes by other data, ~{} bytes \
             free (estimated)",
            usage.store_bytes, usage.other_bytes, usage.free_bytes
        )?,
        Err(e) => eprintln!("yb: warning: the YubiKey memory scan failed: {e}"),
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Structural anomaly detection
// ---------------------------------------------------------------------------

pub fn detect_anomalies(store: &Store) -> Vec<String> {
    use std::collections::{HashMap, HashSet};

    let mut warnings = Vec::new();

    // Find duplicate blob names (two head chunks with the same name).
    let mut name_map: HashMap<&str, Vec<u8>> = HashMap::new();
    for obj in store.objects.iter().filter(|o| o.is_head()) {
        name_map
            .entry(obj.blob_name.as_str())
            .or_default()
            .push(obj.index());
    }
    for (name, indices) in &name_map {
        if indices.len() > 1 {
            warnings.push(format!(
                "duplicate blob name '{}' in objects {:?}",
                name, indices
            ));
        }
    }

    // Collect reachable indices by following all head chains.
    let mut reachable: HashSet<u8> = HashSet::new();
    for obj in store.objects.iter().filter(|o| o.is_head()) {
        let chain = store.chunk_chain(obj.index());
        for idx in chain {
            reachable.insert(idx);
        }
    }

    // Any occupied non-head object not in a reachable chain is an orphan.
    for obj in store.objects.iter().filter(|o| !o.is_empty()) {
        if !reachable.contains(&obj.index()) {
            warnings.push(format!(
                "object {} is an orphaned continuation chunk (no reachable head)",
                obj.index()
            ));
        }
    }

    warnings
}