1use 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 #[arg(long, value_name = "HASH")]
83 commit: Option<String>,
84 #[arg(long, value_name = "PATH")]
86 trust_roots: Option<String>,
87 #[arg(long, value_name = "ALG")]
89 algorithm: Option<String>,
90 #[arg(long, value_enum, default_value = "default")]
93 format: VerifyAttestFormat,
94}
95
96struct SigRecord {
98 keyid: String,
99 algorithm: Option<String>,
100 verified: bool,
101 reason: Option<String>,
102}
103
104struct 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 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 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 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 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 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 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, ®istry) {
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 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
409use 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 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 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 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 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 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 assert!(keyid_matches_pubkey("sigstore:https://x", &pk));
604 assert!(!keyid_matches_pubkey("ed25519:dead", &pk));
606 }
607
608 #[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 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 #[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 #[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); 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}