Skip to main content

mkit_cli/commands/
verify_attest.rs

1//! `mkit verify-attest` — verify every attestation attached to a commit.
2//!
3//! ```text
4//! mkit verify-attest [--commit <hash>] [--trust-roots <path>]
5//!                    [--algorithm <filter>]
6//! ```
7//!
8//! Trust-roots file (TOML, simple flat schema):
9//!
10//! ```toml
11//! [[trust_root]]
12//! keyid = "ed25519:..."
13//! kind  = "ed25519"
14//! pubkey_hex = "..."
15//! ```
16//!
17//! * `kind` is one of `ed25519`, `secp256k1` (alias `secp256k1-sec1`),
18//!   `p256-sec1` (alias `p256`), or `bls12381-thr`. Anything else is
19//!   ignored.
20//! * `algorithm` is accepted as an alias for `kind` (per
21//!   `docs/specs/SPEC-RELEASE-THRESHOLD.md`); either field name works.
22//! * `pubkey_hex` is the raw public key bytes in lowercase hex. For
23//!   `bls12381-thr`, the bytes are the 96-byte G2 compressed
24//!   aggregated cohort public key (the `MinSig` variant).
25//!
26//! Exit code is 0 iff every listed attestation is bound to the requested
27//! commit and has `any_verified = true`, nonzero otherwise.
28//!
29//! `--format=json` emits one JSON object to stdout describing the
30//! outcome (in addition to the stderr prose report above, which stays
31//! unconditional):
32//!
33//! ```json
34//! {
35//!   "ok": <bool>,
36//!   "commit": "<64-hex>",
37//!   "error": "<string>|null",
38//!   "attestations": [
39//!     {
40//!       "id": "<64-hex>|null",
41//!       "error": "<string>|null",
42//!       "signatures": [
43//!         {"keyid": "...", "algorithm": "<string>|null", "verified": <bool>, "reason": "<string>|null"}
44//!       ]
45//!     }
46//!   ]
47//! }
48//! ```
49//!
50//! `error` at the attestation level covers read/decode/subject-mismatch
51//! failures (in which case `signatures` is empty); `error` at the
52//! top level is set whenever `ok` is `false`.
53
54use std::io::Write;
55use std::path::PathBuf;
56
57use clap::{Parser, ValueEnum};
58use mkit_attest::envelope;
59use mkit_attest::verify::{extract_primary_commit_hash, verify};
60use mkit_attest::{Algorithm, store};
61use mkit_core::hash::Hash;
62use mkit_core::layout::RepoLayout;
63use mkit_core::{hash as hash_mod, refs};
64
65use crate::clap_shim;
66use crate::exit;
67use crate::format::JsonObject;
68
69#[derive(Debug, Clone, Copy, ValueEnum)]
70enum VerifyAttestFormat {
71    Default,
72    Json,
73}
74
75#[derive(Debug, Parser)]
76#[command(
77    name = "mkit verify-attest",
78    about = "Verify every attestation attached to a commit."
79)]
80struct Args {
81    /// Commit hash to verify attestations for. Defaults to HEAD.
82    #[arg(long, value_name = "HASH")]
83    commit: Option<String>,
84    /// Path to a trust-roots TOML file.
85    #[arg(long, value_name = "PATH")]
86    trust_roots: Option<String>,
87    /// Filter signatures by algorithm.
88    #[arg(long, value_name = "ALG")]
89    algorithm: Option<String>,
90    /// Emit a machine-readable JSON result object to stdout alongside
91    /// the human report on stderr.
92    #[arg(long, value_enum, default_value = "default")]
93    format: VerifyAttestFormat,
94}
95
96/// One reported signature verdict, collected for the JSON envelope.
97struct SigRecord {
98    keyid: String,
99    algorithm: Option<String>,
100    verified: bool,
101    reason: Option<String>,
102}
103
104/// One reported attestation, collected for the JSON envelope. `error`
105/// covers read/decode/subject-mismatch failures (mutually exclusive
106/// with a populated `signatures`).
107struct AttRecord {
108    id: Option<Hash>,
109    error: Option<String>,
110    signatures: Vec<SigRecord>,
111}
112
113fn emit_json(commit: &Hash, ok: bool, error: Option<&str>, atts: &[AttRecord]) {
114    let mut items = Vec::with_capacity(atts.len());
115    for a in atts {
116        let mut obj = JsonObject::new();
117        obj.field_opt_hash("id", a.id.as_ref())
118            .field_opt_str("error", a.error.as_deref());
119        let mut sigs = Vec::with_capacity(a.signatures.len());
120        for s in &a.signatures {
121            let mut sobj = JsonObject::new();
122            sobj.field_str("keyid", &s.keyid)
123                .field_opt_str("algorithm", s.algorithm.as_deref())
124                .field_bool("verified", s.verified)
125                .field_opt_str("reason", s.reason.as_deref());
126            sigs.push(sobj.finish());
127        }
128        obj.field_raw("signatures", &format!("[{}]", sigs.join(",")));
129        items.push(obj.finish());
130    }
131    let mut top = JsonObject::new();
132    top.field_bool("ok", ok)
133        .field_hash("commit", commit)
134        .field_opt_str("error", error)
135        .field_raw("attestations", &format!("[{}]", items.join(",")));
136    let mut stdout = std::io::stdout().lock();
137    let _ = writeln!(stdout, "{}", top.finish());
138}
139
140#[must_use]
141#[allow(clippy::too_many_lines)]
142pub fn run(args: &[String]) -> u8 {
143    let parsed = match clap_shim::parse::<Args>("mkit verify-attest", args) {
144        Ok(o) => o,
145        Err(code) => return code,
146    };
147    let json = matches!(parsed.format, VerifyAttestFormat::Json);
148    let cwd = match std::env::current_dir() {
149        Ok(p) => p,
150        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
151    };
152    let layout = match super::resolve_layout(&cwd) {
153        Ok(layout) => layout,
154        Err(code) => return code,
155    };
156    if !layout.common_dir().is_dir() {
157        return emit_err("not a mkit repo", exit::GENERAL_ERROR);
158    }
159
160    // --- Resolve commit. --------------------------------------------
161    let commit_hash = match resolve_commit(&layout, parsed.commit.as_deref()) {
162        Ok(h) => h,
163        Err((msg, code)) => return emit_err(&msg, code),
164    };
165
166    // --- Build Registry. --------------------------------------------
167    //
168    // Trust-roots default to the **user-scoped** path
169    // `$XDG_CONFIG_HOME/mkit/trust-roots.toml`. A repo-local default
170    // would let a hostile clone ship its own trust-roots and have
171    // `mkit verify-attest` print "ok" against attacker keys; see
172    // `docs/THREAT-MODEL.md` §"Trust-roots scope". An explicit
173    // `--trust-roots <path>` always wins so CI flows can point at a
174    // pinned file.
175    let trust_path = parsed
176        .trust_roots
177        .as_deref()
178        .map_or_else(default_trust_roots_path, PathBuf::from);
179    if let Err(code) = warn_if_unsafe_trust_roots(
180        &trust_path,
181        layout.common_dir(),
182        parsed.trust_roots.is_some(),
183    ) {
184        return code;
185    }
186    note_if_missing(&trust_path);
187    // Below this point `commit_hash` is fixed, so error returns can
188    // populate a `--format=json` payload; shadow `emit_err` with a
189    // wrapper that also prints the JSON envelope when requested.
190    let err = |msg: &str, code: u8| -> u8 {
191        if json {
192            emit_json(&commit_hash, false, Some(msg), &[]);
193        }
194        emit_err(msg, code)
195    };
196
197    let registry = match load_trust_roots(&trust_path) {
198        Ok(r) => r,
199        Err((msg, code)) => return err(&msg, code),
200    };
201
202    // --- Algorithm filter. ------------------------------------------
203    let filter: Option<Algorithm> = match parsed.algorithm.as_deref() {
204        Some(s) => match s.parse::<Algorithm>() {
205            Ok(a) => Some(a),
206            Err(_) => {
207                return err(&format!("unknown algorithm filter '{s}'"), exit::USAGE);
208            }
209        },
210        None => None,
211    };
212
213    // --- Enumerate envelopes. ---------------------------------------
214    let envelopes = match store::list(&layout, &commit_hash) {
215        Ok(v) => v,
216        Err(e) => return err(&format!("list attestations: {e}"), exit::NOINPUT),
217    };
218    // All `verify-attest` report lines are human-readable prose; the
219    // verdict is conveyed via the exit code (OK / DATAERR /
220    // GENERAL_ERROR). Route the entire report to stderr — unconditional,
221    // regardless of `--format=json` — while stdout carries the JSON
222    // envelope (see `emit_json`).
223    let mut report = std::io::stderr().lock();
224    if envelopes.is_empty() {
225        let msg = format!(
226            "no attestations for commit {}",
227            hash_mod::to_hex(&commit_hash)
228        );
229        let _ = writeln!(report, "{msg}");
230        drop(report);
231        if json {
232            emit_json(&commit_hash, false, Some(&msg), &[]);
233        }
234        return exit::GENERAL_ERROR;
235    }
236
237    let _ = writeln!(
238        report,
239        "verifying {} attestation(s) for commit {}",
240        envelopes.len(),
241        hash_mod::to_hex(&commit_hash)
242    );
243
244    let mut all_ok = true;
245    let mut atts: Vec<AttRecord> = Vec::with_capacity(envelopes.len());
246    for path in &envelopes {
247        let bytes = match std::fs::read(path) {
248            Ok(b) => b,
249            Err(e) => {
250                let _ = writeln!(report, "  {}: read error: {e}", path.display());
251                all_ok = false;
252                atts.push(AttRecord {
253                    id: None,
254                    error: Some(format!("read error: {e}")),
255                    signatures: Vec::new(),
256                });
257                continue;
258            }
259        };
260        let att_id = mkit_attest::attestation_id(&bytes);
261        let env = match envelope::decode(&bytes) {
262            Ok(env) => env,
263            Err(e) => {
264                let _ = writeln!(
265                    report,
266                    "  {}: malformed envelope: {e}",
267                    hash_mod::to_hex(&att_id)
268                );
269                all_ok = false;
270                atts.push(AttRecord {
271                    id: Some(att_id),
272                    error: Some(format!("malformed envelope: {e}")),
273                    signatures: Vec::new(),
274                });
275                continue;
276            }
277        };
278        let subject_hash = match extract_primary_commit_hash(&env.payload) {
279            Ok(subject_hash) => subject_hash,
280            Err(e) => {
281                let _ = writeln!(
282                    report,
283                    "  {}: subject error: {e}",
284                    hash_mod::to_hex(&att_id)
285                );
286                all_ok = false;
287                atts.push(AttRecord {
288                    id: Some(att_id),
289                    error: Some(format!("subject error: {e}")),
290                    signatures: Vec::new(),
291                });
292                continue;
293            }
294        };
295        if subject_hash != commit_hash {
296            let _ = writeln!(
297                report,
298                "  {}: subject mismatch: statement names {}, requested {}",
299                hash_mod::to_hex(&att_id),
300                hash_mod::to_hex(&subject_hash),
301                hash_mod::to_hex(&commit_hash)
302            );
303            all_ok = false;
304            atts.push(AttRecord {
305                id: Some(att_id),
306                error: Some(format!(
307                    "subject mismatch: statement names {}, requested {}",
308                    hash_mod::to_hex(&subject_hash),
309                    hash_mod::to_hex(&commit_hash)
310                )),
311                signatures: Vec::new(),
312            });
313            continue;
314        }
315        let result = match verify(&env, &registry) {
316            Ok(r) => r,
317            Err(e) => {
318                let _ = writeln!(
319                    report,
320                    "  {}: malformed envelope: {e}",
321                    hash_mod::to_hex(&att_id)
322                );
323                all_ok = false;
324                atts.push(AttRecord {
325                    id: Some(att_id),
326                    error: Some(format!("malformed envelope: {e}")),
327                    signatures: Vec::new(),
328                });
329                continue;
330            }
331        };
332        let _ = writeln!(
333            report,
334            "  attestation {}: {} signature(s)",
335            hash_mod::to_hex(&att_id),
336            result.signatures.len()
337        );
338        let mut any_shown = false;
339        // The JSON record carries EVERY signature (unfiltered) so an
340        // agent parsing it never loses data to `--algorithm`; only the
341        // human stderr report is filtered.
342        let mut sig_records = Vec::with_capacity(result.signatures.len());
343        for sig in &result.signatures {
344            let alg = Algorithm::from_keyid(&sig.keyid);
345            let alg_str = alg.map_or_else(|| "unknown".to_owned(), |a| a.to_string());
346            sig_records.push(SigRecord {
347                keyid: sig.keyid.clone(),
348                algorithm: alg.map(|_| alg_str.clone()),
349                verified: sig.verified,
350                reason: (!sig.verified).then(|| format!("{:?}", sig.reason)),
351            });
352            if let (Some(filter_alg), Some(sig_alg)) = (filter, alg)
353                && filter_alg != sig_alg
354            {
355                continue;
356            }
357            any_shown = true;
358            let verdict = if sig.verified {
359                "verified".to_owned()
360            } else {
361                format!("FAILED ({:?})", sig.reason)
362            };
363            let _ = writeln!(
364                report,
365                "    [{alg_str}] {} — {verdict}",
366                short_keyid(&sig.keyid)
367            );
368        }
369        atts.push(AttRecord {
370            id: Some(att_id),
371            error: None,
372            signatures: sig_records,
373        });
374        if !any_shown && filter.is_some() {
375            let _ = writeln!(report, "    (no signatures matched --algorithm filter)");
376        }
377        if !result.any_verified {
378            all_ok = false;
379        }
380    }
381
382    drop(report);
383    if all_ok {
384        if json {
385            emit_json(&commit_hash, true, None, &atts);
386        }
387        {
388            let mut stderr = std::io::stderr().lock();
389            let _ = writeln!(stderr, "ok: all attestations verified");
390        }
391        exit::OK
392    } else {
393        if json {
394            emit_json(
395                &commit_hash,
396                false,
397                Some("at least one attestation failed verification"),
398                &atts,
399            );
400        }
401        {
402            let mut stderr = std::io::stderr().lock();
403            let _ = writeln!(stderr, "bad: at least one attestation failed verification");
404        }
405        exit::DATAERR
406    }
407}
408
409// The trust-roots file format (`[[trust_root]]` TOML blocks), the
410// in-repo path-fencing policy, and the keyid<->pubkey cross-check all
411// live in `commands/trust_roots.rs` now — shared with `mkit trust
412// add/list/remove` and `mkit verify --trusted` so the three never grow
413// separate trust-file formats (issue #693). Pull the names this module
414// still uses directly into scope; the `mod tests` block below resolves
415// them via `use super::*`.
416use super::trust_roots::{
417    default_trust_roots_path, load_registry as load_trust_roots, note_if_missing, short_keyid,
418    warn_if_unsafe_trust_roots,
419};
420
421fn resolve_commit(layout: &RepoLayout, flag: Option<&str>) -> Result<Hash, (String, u8)> {
422    if let Some(hex) = flag {
423        return hash_mod::from_hex(hex)
424            .map_err(|e| (format!("bad --commit hash: {e}"), exit::DATAERR));
425    }
426    match refs::resolve_head(layout) {
427        Ok(Some(h)) => Ok(h),
428        Ok(None) => Err(("HEAD has no commit yet".to_owned(), exit::GENERAL_ERROR)),
429        Err(e) => Err((format!("read HEAD: {e}"), exit::GENERAL_ERROR)),
430    }
431}
432
433use super::error as emit_err;
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use crate::commands::trust_roots::keyid_matches_pubkey;
439    use clap::Parser;
440    use std::fs;
441    use std::path::Path;
442
443    /// Test-only adapter: drive the clap-derive parser with just the
444    /// trailing args.
445    fn parse_args(args: &[String]) -> Result<Args, clap::Error> {
446        let mut full: Vec<String> = vec!["mkit verify-attest".into()];
447        full.extend_from_slice(args);
448        Args::try_parse_from(full)
449    }
450
451    #[test]
452    fn parse_args_defaults() {
453        let p = parse_args(&[]).unwrap();
454        assert!(p.commit.is_none());
455        assert!(p.trust_roots.is_none());
456        assert!(p.algorithm.is_none());
457    }
458
459    #[test]
460    fn parse_args_accepts_all_flags() {
461        let args = vec![
462            "--commit".into(),
463            "abc".into(),
464            "--trust-roots".into(),
465            "/tmp/tr.toml".into(),
466            "--algorithm".into(),
467            "p256".into(),
468        ];
469        let p = parse_args(&args).unwrap();
470        assert_eq!(p.commit.as_deref(), Some("abc"));
471        assert_eq!(p.trust_roots.as_deref(), Some("/tmp/tr.toml"));
472        assert_eq!(p.algorithm.as_deref(), Some("p256"));
473    }
474
475    #[test]
476    fn warn_if_unsafe_trust_roots_refuses_in_repo_path_without_explicit_flag() {
477        // A hostile clone shipping `<repo>/.mkit/attest-trust-roots.toml`
478        // must not be trusted implicitly — only an explicit
479        // `--trust-roots` flag can point at an in-repo path.
480        let mkit_dir = Path::new("/repo/.mkit");
481        let trust_path = mkit_dir.join("attest-trust-roots.toml");
482        let err = warn_if_unsafe_trust_roots(&trust_path, mkit_dir, false).unwrap_err();
483        assert_eq!(err, exit::CONFIG_ERROR);
484    }
485
486    #[test]
487    fn warn_if_unsafe_trust_roots_allows_in_repo_path_when_explicitly_passed() {
488        let mkit_dir = Path::new("/repo/.mkit");
489        let trust_path = mkit_dir.join("attest-trust-roots.toml");
490        warn_if_unsafe_trust_roots(&trust_path, mkit_dir, true)
491            .expect("an explicit --trust-roots flag must be honored even in-repo");
492    }
493
494    #[test]
495    fn warn_if_unsafe_trust_roots_allows_user_scoped_path_without_flag() {
496        let mkit_dir = Path::new("/repo/.mkit");
497        let trust_path = Path::new("/home/user/.config/mkit/trust-roots.toml");
498        warn_if_unsafe_trust_roots(trust_path, mkit_dir, false)
499            .expect("a path outside the repo is never the in-repo hazard this gate guards against");
500    }
501
502    #[test]
503    fn load_trust_roots_missing_file_returns_empty_registry() {
504        let td = tempfile::tempdir().unwrap();
505        let path = td.path().join("nope.toml");
506        let reg = load_trust_roots(&path).unwrap();
507        assert!(reg.lookup("anything").is_none());
508    }
509
510    #[test]
511    fn load_trust_roots_parses_ed25519_block() {
512        let td = tempfile::tempdir().unwrap();
513        let path = td.path().join("tr.toml");
514        // Canonical ed25519 keyid embeds the raw pubkey hex as its body.
515        let hex = "aa".repeat(32);
516        let keyid = format!("ed25519:{hex}");
517        fs::write(
518            &path,
519            format!(
520                "[[trust_root]]\nkeyid = \"{keyid}\"\nkind = \"ed25519\"\npubkey_hex = \"{hex}\"\n"
521            ),
522        )
523        .unwrap();
524        let reg = load_trust_roots(&path).unwrap();
525        assert!(reg.lookup(&keyid).is_some());
526    }
527
528    #[test]
529    fn load_trust_roots_tolerates_comments_and_blank_lines() {
530        let td = tempfile::tempdir().unwrap();
531        let path = td.path().join("tr.toml");
532        // `blake3:` keyid embeds blake3(pubkey); compute it so the
533        // keyid↔pubkey cross-check passes.
534        let pk = [0xbbu8; 32];
535        let hex = mkit_core::hash::to_hex_bytes(&pk);
536        let digest = mkit_core::hash::to_hex(&mkit_core::hash::hash(&pk));
537        let keyid = format!("blake3:{digest}");
538        fs::write(
539            &path,
540            format!(
541                "# leading comment\n\n\
542                 [[trust_root]]\n# mid-comment\n\
543                 keyid = \"{keyid}\"\n\
544                 kind = \"ed25519\"\n\
545                 pubkey_hex = \"{hex}\"\n\n\n"
546            ),
547        )
548        .unwrap();
549        let reg = load_trust_roots(&path).unwrap();
550        assert!(reg.lookup(&keyid).is_some());
551    }
552
553    #[test]
554    fn load_trust_roots_multiple_blocks() {
555        let td = tempfile::tempdir().unwrap();
556        let path = td.path().join("tr.toml");
557        let hex_a = "aa".repeat(32);
558        let hex_b = "cc".repeat(32);
559        let keyid_a = format!("ed25519:{hex_a}");
560        let keyid_b = format!("ed25519:{hex_b}");
561        fs::write(
562            &path,
563            format!(
564                "[[trust_root]]\nkeyid = \"{keyid_a}\"\nkind = \"ed25519\"\npubkey_hex = \"{hex_a}\"\n\
565                 [[trust_root]]\nkeyid = \"{keyid_b}\"\nkind = \"ed25519\"\npubkey_hex = \"{hex_b}\"\n"
566            ),
567        )
568        .unwrap();
569        let reg = load_trust_roots(&path).unwrap();
570        assert!(reg.lookup(&keyid_a).is_some());
571        assert!(reg.lookup(&keyid_b).is_some());
572    }
573
574    #[test]
575    fn load_trust_roots_drops_keyid_pubkey_mismatch() {
576        // #223: keyid embeds pubkey `aa..`, but pubkey_hex says `bb..`.
577        // The entry must be dropped, not silently trusted.
578        let td = tempfile::tempdir().unwrap();
579        let path = td.path().join("tr.toml");
580        let keyid_hex = "aa".repeat(32);
581        let wrong_pubkey = "bb".repeat(32);
582        let keyid = format!("ed25519:{keyid_hex}");
583        fs::write(
584            &path,
585            format!(
586                "[[trust_root]]\nkeyid = \"{keyid}\"\nkind = \"ed25519\"\npubkey_hex = \"{wrong_pubkey}\"\n"
587            ),
588        )
589        .unwrap();
590        let reg = load_trust_roots(&path).unwrap();
591        assert!(reg.lookup(&keyid).is_none());
592    }
593
594    #[test]
595    fn keyid_matches_pubkey_canonical_and_blake3() {
596        let pk = [0x11u8; 32];
597        let hex = mkit_core::hash::to_hex_bytes(&pk);
598        assert!(keyid_matches_pubkey(&format!("ed25519:{hex}"), &pk));
599        assert!(keyid_matches_pubkey(&format!("secp256k1:{hex}"), &pk));
600        let digest = mkit_core::hash::to_hex(&mkit_core::hash::hash(&pk));
601        assert!(keyid_matches_pubkey(&format!("blake3:{digest}"), &pk));
602        // Opaque / unknown prefixes are not cross-checked.
603        assert!(keyid_matches_pubkey("sigstore:https://x", &pk));
604        // Mismatched body is rejected.
605        assert!(!keyid_matches_pubkey("ed25519:dead", &pk));
606    }
607
608    /// `[[trust_root]]` blocks with `kind = "bls12381-thr"` (or the
609    /// `algorithm = "bls12381-thr"` alias) load into the registry as
610    /// `TrustRoot::Bls12381ThresholdPubKey` and verify-dispatch picks
611    /// them up. Pinned to the 96-byte `MinSig` G2 compressed length —
612    /// anything shorter is silently dropped (per the parser's
613    /// tolerate-and-skip policy).
614    #[cfg(feature = "bls-threshold")]
615    #[test]
616    fn load_trust_roots_parses_bls_threshold_block() {
617        let td = tempfile::tempdir().unwrap();
618        let path = td.path().join("tr.toml");
619        // 96 bytes of dummy hex — exact length matches MinSig G2
620        // compressed encoding.
621        let hex = "ab".repeat(96);
622        fs::write(
623            &path,
624            format!(
625                "[[trust_root]]\n\
626                 keyid = \"bls12381-thr:{hex}\"\n\
627                 kind = \"bls12381-thr\"\n\
628                 pubkey_hex = \"{hex}\"\n"
629            ),
630        )
631        .unwrap();
632        let reg = load_trust_roots(&path).unwrap();
633        let lookup = format!("bls12381-thr:{hex}");
634        assert!(reg.lookup(&lookup).is_some());
635    }
636
637    /// Spec wording in `docs/specs/SPEC-RELEASE-THRESHOLD.md` says
638    /// `algorithm = "bls12381-thr"`; the parser accepts that as an
639    /// alias for `kind` to keep both forms compatible.
640    #[cfg(feature = "bls-threshold")]
641    #[test]
642    fn load_trust_roots_accepts_algorithm_alias() {
643        let td = tempfile::tempdir().unwrap();
644        let path = td.path().join("tr.toml");
645        let hex = "cd".repeat(96);
646        fs::write(
647            &path,
648            format!(
649                "[[trust_root]]\n\
650                 keyid = \"bls12381-thr:{hex}\"\n\
651                 algorithm = \"bls12381-thr\"\n\
652                 pubkey_hex = \"{hex}\"\n"
653            ),
654        )
655        .unwrap();
656        let reg = load_trust_roots(&path).unwrap();
657        assert!(reg.lookup(&format!("bls12381-thr:{hex}")).is_some());
658    }
659
660    /// Wrong-length BLS public key (e.g. someone mistakenly pasted a
661    /// G1 sig or a truncated key) is silently dropped — the
662    /// `verify-attest` run will then surface the keyid as
663    /// `UnknownKeyid` rather than panic on a malformed registry.
664    #[cfg(feature = "bls-threshold")]
665    #[test]
666    fn load_trust_roots_skips_wrong_length_bls_pubkey() {
667        let td = tempfile::tempdir().unwrap();
668        let path = td.path().join("tr.toml");
669        let short = "ee".repeat(32); // 32 bytes, not 96
670        let keyid = "bls12381-thr:abc";
671        fs::write(
672            &path,
673            format!(
674                "[[trust_root]]\n\
675                 keyid = \"{keyid}\"\n\
676                 kind = \"bls12381-thr\"\n\
677                 pubkey_hex = \"{short}\"\n"
678            ),
679        )
680        .unwrap();
681        let reg = load_trust_roots(&path).unwrap();
682        assert!(reg.lookup(keyid).is_none());
683    }
684
685    #[test]
686    fn short_keyid_abbreviates_long_hex() {
687        let kid = "ed25519:".to_owned() + &"a".repeat(64);
688        let short = short_keyid(&kid);
689        assert!(short.starts_with("ed25519:"));
690        assert!(short.ends_with('…'));
691        assert!(short.len() < kid.len());
692    }
693
694    #[test]
695    fn short_keyid_keeps_short_ones_intact() {
696        assert_eq!(short_keyid("ed25519:abc"), "ed25519:abc");
697    }
698}