1use std::io::Write;
48
49use clap::Parser;
50use mkit_attest::{Algorithm, Envelope, PAYLOAD_TYPE_IN_TOTO, Sig, Signer, statement, store};
51use mkit_core::hash::Hash;
52use mkit_core::layout::RepoLayout;
53use mkit_core::{hash as hash_mod, refs};
54
55use crate::clap_shim;
56use crate::commands::attest_factory::{self, FactoryError};
57use crate::config::Config;
58use crate::exit;
59
60const DEFAULT_PREDICATE_TYPE: &str =
68 "https://github.com/officialunofficial/mkit/spec/predicate/empty/v1";
69
70const MAX_PREDICATE_BYTES: u64 = 1024 * 1024;
75
76#[derive(Debug, Parser)]
77#[command(
78 name = "mkit attest",
79 about = "Produce a signed DSSE attestation for a commit."
80)]
81#[allow(clippy::struct_field_names)]
82struct Args {
83 #[arg(long, value_name = "HASH")]
85 commit: Option<String>,
86 #[arg(long, value_name = "ALG")]
88 algorithm: Option<String>,
89 #[arg(long, value_name = "KIND")]
91 signer: Option<String>,
92 #[arg(long = "predicate-type", value_name = "URI")]
94 predicate_type: Option<String>,
95 #[arg(long = "predicate-file", value_name = "PATH")]
97 predicate_file: Option<String>,
98 #[arg(long = "additional-signer", value_name = "SPEC")]
102 additional_signers: Vec<String>,
103 #[arg(
112 long = "external-signer-arg",
113 value_name = "ARG",
114 allow_hyphen_values = true
115 )]
116 external_signer_args_vec: Vec<String>,
117}
118
119impl Args {
120 fn external_signer_args(&self) -> Option<Vec<String>> {
124 if self.external_signer_args_vec.is_empty() {
125 None
126 } else {
127 Some(self.external_signer_args_vec.clone())
128 }
129 }
130}
131
132#[derive(Debug, PartialEq, Eq)]
137struct SignerSpec {
138 algorithm: Algorithm,
139 signer_kind: String,
140 path: Option<String>,
141 args: Option<Vec<String>>,
146}
147
148fn parse_signer_spec(s: &str) -> Result<SignerSpec, String> {
149 let mut algorithm: Option<Algorithm> = None;
150 let mut signer_kind: Option<String> = None;
151 let mut path: Option<String> = None;
152 let mut args: Option<Vec<String>> = None;
153 for part in s.split(',') {
154 let part = part.trim();
155 if part.is_empty() {
156 continue;
157 }
158 let Some((k, v)) = part.split_once('=') else {
159 return Err(format!(
160 "--additional-signer spec part '{part}' is not key=value"
161 ));
162 };
163 match k.trim() {
164 "algorithm" => {
165 let v = v.trim();
166 let alg = attest_factory::parse_algorithm(v).map_err(|_| {
167 format!("--additional-signer: unknown algorithm '{v}' — expected one of: ed25519, secp256k1, p256")
168 })?;
169 algorithm = Some(alg);
170 }
171 "signer" => {
172 let v = v.trim();
173 if !matches!(v, "repo-key" | "external") {
174 return Err(format!(
175 "--additional-signer: unknown signer '{v}' — expected one of: repo-key, external"
176 ));
177 }
178 signer_kind = Some(v.to_owned());
179 }
180 "path" => {
181 path = Some(v.trim().to_owned());
182 }
183 "args" => {
184 args = Some(crate::config::parse_pipe_list(v.trim()));
191 }
192 other => {
193 return Err(format!("--additional-signer: unknown spec key '{other}'"));
194 }
195 }
196 }
197 let algorithm =
198 algorithm.ok_or_else(|| "--additional-signer: missing algorithm=...".to_owned())?;
199 let signer_kind =
200 signer_kind.ok_or_else(|| "--additional-signer: missing signer=...".to_owned())?;
201 Ok(SignerSpec {
202 algorithm,
203 signer_kind,
204 path,
205 args,
206 })
207}
208
209#[must_use]
210#[allow(clippy::too_many_lines)]
211pub fn run(args: &[String]) -> u8 {
212 let parsed = match clap_shim::parse::<Args>("mkit attest", args) {
213 Ok(o) => o,
214 Err(code) => return code,
215 };
216
217 let cwd = match std::env::current_dir() {
218 Ok(p) => p,
219 Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
220 };
221 let layout = match super::resolve_layout(&cwd) {
222 Ok(layout) => layout,
223 Err(code) => return code,
224 };
225 if !layout.common_dir().is_dir() {
226 return emit_err("not a mkit repo", exit::GENERAL_ERROR);
227 }
228
229 let mut cfg = match crate::config::read_or_default(&layout) {
230 Ok(c) => c,
231 Err(e) => return emit_err(&format!("config: {e}"), exit::CONFIG_ERROR),
232 };
233
234 if let Some(argv) = parsed.external_signer_args() {
239 cfg.attest.external_signer_args = argv;
240 }
241
242 let commit_hash = match resolve_commit(&layout, parsed.commit.as_deref()) {
244 Ok(h) => h,
245 Err((msg, code)) => return emit_err(&msg, code),
246 };
247
248 let commit_bytes = {
257 let obj_store = match mkit_core::store::ObjectStore::open(&layout) {
258 Ok(s) => s,
259 Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
260 };
261 match super::read_object_bytes(&obj_store, &commit_hash) {
262 Ok(b) => b,
263 Err((msg, code)) => return emit_err(&msg, code),
264 }
265 };
266
267 let alg_str = parsed
269 .algorithm
270 .clone()
271 .unwrap_or_else(|| cfg.attest.default_algorithm_or_fallback().to_owned());
272 let algorithm = match attest_factory::parse_algorithm(&alg_str) {
273 Ok(a) => a,
274 Err(FactoryError::UnknownAlgorithm(s)) => {
275 return emit_err(
276 &format!("unknown algorithm '{s}' — expected one of: ed25519, secp256k1, p256"),
277 exit::USAGE,
278 );
279 }
280 Err(e) => return emit_err(&format!("{e}"), exit::USAGE),
281 };
282 let signer_kind = parsed
283 .signer
284 .clone()
285 .unwrap_or_else(|| cfg.attest.signer_or_fallback().to_owned());
286
287 let primary_signer = match attest_factory::build_signer(&layout, algorithm, &signer_kind, &cfg)
288 {
289 Ok(s) => s,
290 Err(e) => return emit_err(&format!("{e}"), factory_error_code(&e)),
291 };
292
293 let mut additional_specs: Vec<SignerSpec> = Vec::with_capacity(parsed.additional_signers.len());
297 for spec_str in &parsed.additional_signers {
298 match parse_signer_spec(spec_str) {
299 Ok(s) => additional_specs.push(s),
300 Err(e) => return emit_err(&e, exit::USAGE),
301 }
302 }
303
304 let mut signers: Vec<Box<dyn Signer>> = Vec::with_capacity(1 + additional_specs.len());
305 signers.push(primary_signer);
306 for spec in &additional_specs {
307 let signer = match build_additional_signer(&layout, spec, &cfg) {
308 Ok(s) => s,
309 Err(e) => return emit_err(&format!("{e}"), factory_error_code(&e)),
310 };
311 signers.push(signer);
312 }
313
314 let predicate_bytes: Vec<u8> = match parsed.predicate_file.as_deref() {
316 Some(p) => match read_predicate_file(p) {
317 Ok(b) => b,
318 Err((msg, code)) => return emit_err(&msg, code),
319 },
320 None => b"{}".to_vec(),
321 };
322 let predicate_type = parsed
323 .predicate_type
324 .unwrap_or_else(|| DEFAULT_PREDICATE_TYPE.to_owned());
325
326 let stmt_bytes = match statement::for_commit(
328 &commit_hash,
329 &commit_bytes,
330 &predicate_type,
331 &predicate_bytes,
332 ) {
333 Ok(s) => s.into_bytes(),
334 Err(
335 mkit_attest::Error::PredicateMustBeJsonObject
336 | mkit_attest::Error::PredicateNotJsonObject
337 | mkit_attest::Error::PredicateNotUtf8,
338 ) => {
339 return emit_err(
340 "--predicate-file must contain a JCS-canonical JSON object",
341 exit::DATAERR,
342 );
343 }
344 Err(e) => return emit_err(&format!("statement: {e}"), exit::DATAERR),
345 };
346
347 let pae = mkit_attest::pae_of(PAYLOAD_TYPE_IN_TOTO, &stmt_bytes);
349 let mut signatures: Vec<Sig> = Vec::with_capacity(signers.len());
350 for (idx, signer) in signers.iter_mut().enumerate() {
351 let sig_bytes = match signer.sign(&pae) {
352 Ok(b) => b,
353 Err(e) => {
354 return emit_err(
355 &format!("sign (signer #{}): {e}", idx + 1),
356 exit::GENERAL_ERROR,
357 );
358 }
359 };
360 let keyid = match signer.keyid() {
361 Ok(k) => k,
362 Err(e) => {
363 return emit_err(
364 &format!("keyid (signer #{}): {e}", idx + 1),
365 exit::GENERAL_ERROR,
366 );
367 }
368 };
369 signatures.push(Sig {
370 keyid,
371 sig: sig_bytes,
372 });
373 }
374
375 let envelope = Envelope {
376 payload_type: PAYLOAD_TYPE_IN_TOTO.to_owned(),
377 payload: stmt_bytes,
378 signatures,
379 };
380 let encoded = match envelope.encode() {
381 Ok(s) => s,
382 Err(e) => return emit_err(&format!("encode envelope: {e}"), exit::DATAERR),
383 };
384
385 let _lock = match super::acquire_worktree_lock(&layout) {
394 Ok(l) => l,
395 Err(code) => return code,
396 };
397 let obj_store = match mkit_core::store::ObjectStore::open(&layout) {
403 Ok(s) => s,
404 Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
405 };
406 if !obj_store.contains(&commit_hash) {
407 return emit_err(
408 &format!(
409 "attested commit {} no longer exists (pruned concurrently?); aborting",
410 hash_mod::to_hex(&commit_hash)
411 ),
412 exit::CANTCREAT,
413 );
414 }
415 let (att_id, path) = match store::save(&layout, &commit_hash, encoded.as_bytes()) {
416 Ok(p) => p,
417 Err(e) => return emit_err(&format!("store: {e}"), exit::CANTCREAT),
418 };
419 let mut stderr = std::io::stderr().lock();
420 let _ = writeln!(
421 stderr,
422 "attested {} → {} ({} signature(s))",
423 hash_mod::to_hex(&att_id),
424 path.display(),
425 envelope.signatures.len()
426 );
427 exit::OK
428}
429
430fn build_additional_signer(
436 layout: &RepoLayout,
437 spec: &SignerSpec,
438 base: &Config,
439) -> Result<Box<dyn Signer>, FactoryError> {
440 match spec.signer_kind.as_str() {
441 "repo-key" => {
442 let mut cfg = base.clone();
446 if let Some(p) = spec.path.as_deref() {
447 if let Err(e) = crate::config::validate_key_path(p) {
453 return Err(FactoryError::InvalidKeyFile {
454 path: p.to_owned(),
455 reason: e.to_string(),
456 });
457 }
458 match spec.algorithm {
459 Algorithm::Ed25519 => p.clone_into(&mut cfg.signing_key),
460 Algorithm::Secp256k1 => p.clone_into(&mut cfg.attest.secp256k1_key_path),
461 Algorithm::P256 => p.clone_into(&mut cfg.attest.p256_key_path),
462 #[cfg(feature = "bls-threshold")]
463 Algorithm::Bls12381Threshold => {
464 return Err(FactoryError::UnknownAlgorithm(
465 "bls12381-thr key path is not yet configurable".to_owned(),
466 ));
467 }
468 }
469 }
470 attest_factory::build_signer(layout, spec.algorithm, "repo-key", &cfg)
471 }
472 "external" => {
473 let mut cfg = base.clone();
474 if let Some(p) = spec.path.as_deref() {
475 p.clone_into(&mut cfg.attest.external_signer_path);
476 }
477 if let Some(argv) = spec.args.as_ref() {
482 cfg.attest.external_signer_args.clone_from(argv);
483 }
484 attest_factory::build_signer(layout, spec.algorithm, "external", &cfg)
485 }
486 other => Err(FactoryError::UnknownSignerKind(other.to_owned())),
487 }
488}
489
490pub(crate) fn factory_error_code(e: &FactoryError) -> u8 {
491 match e {
492 FactoryError::UnknownSignerKind(_) | FactoryError::UnknownAlgorithm(_) => exit::USAGE,
493 FactoryError::MissingKeyFile { .. } | FactoryError::MissingKeystoreKey { .. } => {
494 exit::NOINPUT
495 }
496 _ => exit::CONFIG_ERROR,
497 }
498}
499
500fn read_predicate_file(path: &str) -> Result<Vec<u8>, (String, u8)> {
505 use std::io::Read;
506 let meta = std::fs::metadata(path)
507 .map_err(|e| (format!("predicate file '{path}': {e}"), exit::NOINPUT))?;
508 if meta.len() > MAX_PREDICATE_BYTES {
509 return Err((
510 format!("predicate file '{path}' exceeds {MAX_PREDICATE_BYTES}-byte cap"),
511 exit::DATAERR,
512 ));
513 }
514 let file = std::fs::File::open(path)
515 .map_err(|e| (format!("predicate file '{path}': {e}"), exit::NOINPUT))?;
516 let mut data = Vec::new();
517 file.take(MAX_PREDICATE_BYTES + 1)
518 .read_to_end(&mut data)
519 .map_err(|e| (format!("predicate file '{path}': {e}"), exit::NOINPUT))?;
520 if data.len() as u64 > MAX_PREDICATE_BYTES {
521 return Err((
522 format!("predicate file '{path}' exceeds {MAX_PREDICATE_BYTES}-byte cap"),
523 exit::DATAERR,
524 ));
525 }
526 Ok(data)
527}
528
529fn resolve_commit(layout: &RepoLayout, flag: Option<&str>) -> Result<Hash, (String, u8)> {
531 if let Some(hex) = flag {
532 return hash_mod::from_hex(hex)
533 .map_err(|e| (format!("bad --commit hash: {e}"), exit::DATAERR));
534 }
535 match refs::resolve_head(layout) {
536 Ok(Some(h)) => Ok(h),
537 Ok(None) => Err(("HEAD has no commit yet".to_owned(), exit::GENERAL_ERROR)),
538 Err(e) => Err((format!("read HEAD: {e}"), exit::GENERAL_ERROR)),
539 }
540}
541
542use super::error as emit_err;
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547 use clap::Parser;
548
549 fn parse_args(args: &[String]) -> Result<Args, clap::Error> {
552 let mut full: Vec<String> = vec!["mkit attest".into()];
553 full.extend_from_slice(args);
554 Args::try_parse_from(full)
555 }
556
557 #[test]
558 fn parse_args_accepts_all_flags() {
559 let args = vec![
560 "--commit".into(),
561 "abc".into(),
562 "--algorithm".into(),
563 "p256".into(),
564 "--signer".into(),
565 "external".into(),
566 "--predicate-type".into(),
567 "https://example.com/p".into(),
568 "--predicate-file".into(),
569 "/tmp/x.json".into(),
570 ];
571 let p = parse_args(&args).unwrap();
572 assert_eq!(p.commit.as_deref(), Some("abc"));
573 assert_eq!(p.algorithm.as_deref(), Some("p256"));
574 assert_eq!(p.signer.as_deref(), Some("external"));
575 assert_eq!(p.predicate_type.as_deref(), Some("https://example.com/p"));
576 assert_eq!(p.predicate_file.as_deref(), Some("/tmp/x.json"));
577 assert!(p.additional_signers.is_empty());
578 }
579
580 #[test]
581 fn parse_args_collects_repeatable_external_signer_args() {
582 let args = vec![
583 "--external-signer-arg".into(),
584 "sign".into(),
585 "--external-signer-arg".into(),
586 "--tag".into(),
587 "--external-signer-arg".into(),
588 "demo".into(),
589 ];
590 let p = parse_args(&args).unwrap();
591 let expected = vec!["sign".to_owned(), "--tag".to_owned(), "demo".to_owned()];
592 assert_eq!(p.external_signer_args(), Some(expected));
593 }
594
595 #[test]
596 fn parse_args_external_signer_arg_none_when_absent() {
597 let p = parse_args(&[]).unwrap();
601 assert!(p.external_signer_args().is_none());
602 }
603
604 #[test]
605 fn parse_args_collects_multiple_additional_signers() {
606 let args = vec![
607 "--additional-signer".into(),
608 "algorithm=ed25519,signer=repo-key".into(),
609 "--additional-signer".into(),
610 "algorithm=p256,signer=external,path=/x".into(),
611 ];
612 let p = parse_args(&args).unwrap();
613 assert_eq!(p.additional_signers.len(), 2);
614 assert_eq!(p.additional_signers[0], "algorithm=ed25519,signer=repo-key");
615 }
616
617 #[test]
618 fn parse_args_rejects_unknown() {
619 let args = vec!["--bogus".into(), "x".into()];
620 assert!(parse_args(&args).is_err());
621 }
622
623 #[test]
624 fn parse_signer_spec_ok() {
625 let s = parse_signer_spec("algorithm=secp256k1,signer=repo-key,path=k.key").unwrap();
626 assert_eq!(s.algorithm, Algorithm::Secp256k1);
627 assert_eq!(s.signer_kind, "repo-key");
628 assert_eq!(s.path.as_deref(), Some("k.key"));
629 }
630
631 #[test]
632 fn parse_signer_spec_with_args() {
633 let s = parse_signer_spec(
634 "algorithm=p256,signer=external,path=/usr/bin/signer,args=sign|--tag|demo",
635 )
636 .unwrap();
637 assert_eq!(s.algorithm, Algorithm::P256);
638 assert_eq!(s.signer_kind, "external");
639 assert_eq!(s.path.as_deref(), Some("/usr/bin/signer"));
640 assert_eq!(
641 s.args.as_deref(),
642 Some(["sign".to_owned(), "--tag".to_owned(), "demo".to_owned()].as_slice())
643 );
644 }
645
646 #[test]
647 fn parse_signer_spec_args_empty_means_zero_argv() {
648 let s = parse_signer_spec("algorithm=ed25519,signer=external,args=").unwrap();
652 assert_eq!(s.args.as_deref(), Some([].as_slice()));
653 }
654
655 #[test]
656 fn parse_signer_spec_without_path() {
657 let s = parse_signer_spec("algorithm=p256,signer=external").unwrap();
658 assert_eq!(s.algorithm, Algorithm::P256);
659 assert_eq!(s.signer_kind, "external");
660 assert!(s.path.is_none());
661 }
662
663 #[test]
664 fn parse_signer_spec_missing_algorithm() {
665 let e = parse_signer_spec("signer=repo-key").unwrap_err();
666 assert!(e.contains("algorithm"), "{e}");
667 }
668
669 #[test]
670 fn parse_signer_spec_missing_signer() {
671 let e = parse_signer_spec("algorithm=ed25519").unwrap_err();
672 assert!(e.contains("signer"), "{e}");
673 }
674
675 #[test]
676 fn parse_signer_spec_unknown_algorithm() {
677 let e = parse_signer_spec("algorithm=rsa,signer=repo-key").unwrap_err();
678 assert!(e.contains("rsa"), "{e}");
679 }
680
681 #[test]
682 fn parse_signer_spec_unknown_signer_kind() {
683 let e = parse_signer_spec("algorithm=ed25519,signer=sigstore").unwrap_err();
684 assert!(e.contains("sigstore"), "{e}");
685 }
686
687 #[test]
688 fn parse_signer_spec_not_key_value() {
689 let e = parse_signer_spec("algorithm=ed25519 signer=repo-key").unwrap_err();
694 assert!(e.contains("algorithm") || e.contains("key=value"), "{e}");
695 }
696
697 #[test]
698 fn parse_args_all_defaults_when_empty() {
699 let p = parse_args(&[]).unwrap();
700 assert!(p.commit.is_none());
701 assert!(p.algorithm.is_none());
702 assert!(p.signer.is_none());
703 assert!(p.predicate_type.is_none());
704 assert!(p.predicate_file.is_none());
705 assert!(p.additional_signers.is_empty());
706 }
707}