use std::io::Write;
use clap::Parser;
use mkit_attest::{Algorithm, Envelope, PAYLOAD_TYPE_IN_TOTO, Sig, Signer, statement, store};
use mkit_core::hash::Hash;
use mkit_core::layout::RepoLayout;
use mkit_core::{hash as hash_mod, refs};
use crate::clap_shim;
use crate::commands::attest_factory::{self, FactoryError};
use crate::config::Config;
use crate::exit;
const DEFAULT_PREDICATE_TYPE: &str =
"https://github.com/officialunofficial/mkit/spec/predicate/empty/v1";
const MAX_PREDICATE_BYTES: u64 = 1024 * 1024;
#[derive(Debug, Parser)]
#[command(
name = "mkit attest",
about = "Produce a signed DSSE attestation for a commit."
)]
#[allow(clippy::struct_field_names)]
struct Args {
#[arg(long, value_name = "HASH")]
commit: Option<String>,
#[arg(long, value_name = "ALG")]
algorithm: Option<String>,
#[arg(long, value_name = "KIND")]
signer: Option<String>,
#[arg(long = "predicate-type", value_name = "URI")]
predicate_type: Option<String>,
#[arg(long = "predicate-file", value_name = "PATH")]
predicate_file: Option<String>,
#[arg(long = "additional-signer", value_name = "SPEC")]
additional_signers: Vec<String>,
#[arg(
long = "external-signer-arg",
value_name = "ARG",
allow_hyphen_values = true
)]
external_signer_args_vec: Vec<String>,
}
impl Args {
fn external_signer_args(&self) -> Option<Vec<String>> {
if self.external_signer_args_vec.is_empty() {
None
} else {
Some(self.external_signer_args_vec.clone())
}
}
}
#[derive(Debug, PartialEq, Eq)]
struct SignerSpec {
algorithm: Algorithm,
signer_kind: String,
path: Option<String>,
args: Option<Vec<String>>,
}
fn parse_signer_spec(s: &str) -> Result<SignerSpec, String> {
let mut algorithm: Option<Algorithm> = None;
let mut signer_kind: Option<String> = None;
let mut path: Option<String> = None;
let mut args: Option<Vec<String>> = None;
for part in s.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
let Some((k, v)) = part.split_once('=') else {
return Err(format!(
"--additional-signer spec part '{part}' is not key=value"
));
};
match k.trim() {
"algorithm" => {
let v = v.trim();
let alg = attest_factory::parse_algorithm(v).map_err(|_| {
format!("--additional-signer: unknown algorithm '{v}' — expected one of: ed25519, secp256k1, p256")
})?;
algorithm = Some(alg);
}
"signer" => {
let v = v.trim();
if !matches!(v, "repo-key" | "external") {
return Err(format!(
"--additional-signer: unknown signer '{v}' — expected one of: repo-key, external"
));
}
signer_kind = Some(v.to_owned());
}
"path" => {
path = Some(v.trim().to_owned());
}
"args" => {
args = Some(crate::config::parse_pipe_list(v.trim()));
}
other => {
return Err(format!("--additional-signer: unknown spec key '{other}'"));
}
}
}
let algorithm =
algorithm.ok_or_else(|| "--additional-signer: missing algorithm=...".to_owned())?;
let signer_kind =
signer_kind.ok_or_else(|| "--additional-signer: missing signer=...".to_owned())?;
Ok(SignerSpec {
algorithm,
signer_kind,
path,
args,
})
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn run(args: &[String]) -> u8 {
let parsed = match clap_shim::parse::<Args>("mkit attest", args) {
Ok(o) => o,
Err(code) => return code,
};
let cwd = match std::env::current_dir() {
Ok(p) => p,
Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
};
let layout = match super::resolve_layout(&cwd) {
Ok(layout) => layout,
Err(code) => return code,
};
if !layout.common_dir().is_dir() {
return emit_err("not a mkit repo", exit::GENERAL_ERROR);
}
let mut cfg = match crate::config::read_or_default(&layout) {
Ok(c) => c,
Err(e) => return emit_err(&format!("config: {e}"), exit::CONFIG_ERROR),
};
if let Some(argv) = parsed.external_signer_args() {
cfg.attest.external_signer_args = argv;
}
let commit_hash = match resolve_commit(&layout, parsed.commit.as_deref()) {
Ok(h) => h,
Err((msg, code)) => return emit_err(&msg, code),
};
let commit_bytes = {
let obj_store = match mkit_core::store::ObjectStore::open(&layout) {
Ok(s) => s,
Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
};
match super::read_object_bytes(&obj_store, &commit_hash) {
Ok(b) => b,
Err((msg, code)) => return emit_err(&msg, code),
}
};
let alg_str = parsed
.algorithm
.clone()
.unwrap_or_else(|| cfg.attest.default_algorithm_or_fallback().to_owned());
let algorithm = match attest_factory::parse_algorithm(&alg_str) {
Ok(a) => a,
Err(FactoryError::UnknownAlgorithm(s)) => {
return emit_err(
&format!("unknown algorithm '{s}' — expected one of: ed25519, secp256k1, p256"),
exit::USAGE,
);
}
Err(e) => return emit_err(&format!("{e}"), exit::USAGE),
};
let signer_kind = parsed
.signer
.clone()
.unwrap_or_else(|| cfg.attest.signer_or_fallback().to_owned());
let primary_signer = match attest_factory::build_signer(&layout, algorithm, &signer_kind, &cfg)
{
Ok(s) => s,
Err(e) => return emit_err(&format!("{e}"), factory_error_code(&e)),
};
let mut additional_specs: Vec<SignerSpec> = Vec::with_capacity(parsed.additional_signers.len());
for spec_str in &parsed.additional_signers {
match parse_signer_spec(spec_str) {
Ok(s) => additional_specs.push(s),
Err(e) => return emit_err(&e, exit::USAGE),
}
}
let mut signers: Vec<Box<dyn Signer>> = Vec::with_capacity(1 + additional_specs.len());
signers.push(primary_signer);
for spec in &additional_specs {
let signer = match build_additional_signer(&layout, spec, &cfg) {
Ok(s) => s,
Err(e) => return emit_err(&format!("{e}"), factory_error_code(&e)),
};
signers.push(signer);
}
let predicate_bytes: Vec<u8> = match parsed.predicate_file.as_deref() {
Some(p) => match read_predicate_file(p) {
Ok(b) => b,
Err((msg, code)) => return emit_err(&msg, code),
},
None => b"{}".to_vec(),
};
let predicate_type = parsed
.predicate_type
.unwrap_or_else(|| DEFAULT_PREDICATE_TYPE.to_owned());
let stmt_bytes = match statement::for_commit(
&commit_hash,
&commit_bytes,
&predicate_type,
&predicate_bytes,
) {
Ok(s) => s.into_bytes(),
Err(
mkit_attest::Error::PredicateMustBeJsonObject
| mkit_attest::Error::PredicateNotJsonObject
| mkit_attest::Error::PredicateNotUtf8,
) => {
return emit_err(
"--predicate-file must contain a JCS-canonical JSON object",
exit::DATAERR,
);
}
Err(e) => return emit_err(&format!("statement: {e}"), exit::DATAERR),
};
let pae = mkit_attest::pae_of(PAYLOAD_TYPE_IN_TOTO, &stmt_bytes);
let mut signatures: Vec<Sig> = Vec::with_capacity(signers.len());
for (idx, signer) in signers.iter_mut().enumerate() {
let sig_bytes = match signer.sign(&pae) {
Ok(b) => b,
Err(e) => {
return emit_err(
&format!("sign (signer #{}): {e}", idx + 1),
exit::GENERAL_ERROR,
);
}
};
let keyid = match signer.keyid() {
Ok(k) => k,
Err(e) => {
return emit_err(
&format!("keyid (signer #{}): {e}", idx + 1),
exit::GENERAL_ERROR,
);
}
};
signatures.push(Sig {
keyid,
sig: sig_bytes,
});
}
let envelope = Envelope {
payload_type: PAYLOAD_TYPE_IN_TOTO.to_owned(),
payload: stmt_bytes,
signatures,
};
let encoded = match envelope.encode() {
Ok(s) => s,
Err(e) => return emit_err(&format!("encode envelope: {e}"), exit::DATAERR),
};
let _lock = match super::acquire_worktree_lock(&layout) {
Ok(l) => l,
Err(code) => return code,
};
let obj_store = match mkit_core::store::ObjectStore::open(&layout) {
Ok(s) => s,
Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
};
if !obj_store.contains(&commit_hash) {
return emit_err(
&format!(
"attested commit {} no longer exists (pruned concurrently?); aborting",
hash_mod::to_hex(&commit_hash)
),
exit::CANTCREAT,
);
}
let (att_id, path) = match store::save(&layout, &commit_hash, encoded.as_bytes()) {
Ok(p) => p,
Err(e) => return emit_err(&format!("store: {e}"), exit::CANTCREAT),
};
let mut stderr = std::io::stderr().lock();
let _ = writeln!(
stderr,
"attested {} → {} ({} signature(s))",
hash_mod::to_hex(&att_id),
path.display(),
envelope.signatures.len()
);
exit::OK
}
fn build_additional_signer(
layout: &RepoLayout,
spec: &SignerSpec,
base: &Config,
) -> Result<Box<dyn Signer>, FactoryError> {
match spec.signer_kind.as_str() {
"repo-key" => {
let mut cfg = base.clone();
if let Some(p) = spec.path.as_deref() {
if let Err(e) = crate::config::validate_key_path(p) {
return Err(FactoryError::InvalidKeyFile {
path: p.to_owned(),
reason: e.to_string(),
});
}
match spec.algorithm {
Algorithm::Ed25519 => p.clone_into(&mut cfg.signing_key),
Algorithm::Secp256k1 => p.clone_into(&mut cfg.attest.secp256k1_key_path),
Algorithm::P256 => p.clone_into(&mut cfg.attest.p256_key_path),
#[cfg(feature = "bls-threshold")]
Algorithm::Bls12381Threshold => {
return Err(FactoryError::UnknownAlgorithm(
"bls12381-thr key path is not yet configurable".to_owned(),
));
}
}
}
attest_factory::build_signer(layout, spec.algorithm, "repo-key", &cfg)
}
"external" => {
let mut cfg = base.clone();
if let Some(p) = spec.path.as_deref() {
p.clone_into(&mut cfg.attest.external_signer_path);
}
if let Some(argv) = spec.args.as_ref() {
cfg.attest.external_signer_args.clone_from(argv);
}
attest_factory::build_signer(layout, spec.algorithm, "external", &cfg)
}
other => Err(FactoryError::UnknownSignerKind(other.to_owned())),
}
}
pub(crate) fn factory_error_code(e: &FactoryError) -> u8 {
match e {
FactoryError::UnknownSignerKind(_) | FactoryError::UnknownAlgorithm(_) => exit::USAGE,
FactoryError::MissingKeyFile { .. } | FactoryError::MissingKeystoreKey { .. } => {
exit::NOINPUT
}
_ => exit::CONFIG_ERROR,
}
}
fn read_predicate_file(path: &str) -> Result<Vec<u8>, (String, u8)> {
use std::io::Read;
let meta = std::fs::metadata(path)
.map_err(|e| (format!("predicate file '{path}': {e}"), exit::NOINPUT))?;
if meta.len() > MAX_PREDICATE_BYTES {
return Err((
format!("predicate file '{path}' exceeds {MAX_PREDICATE_BYTES}-byte cap"),
exit::DATAERR,
));
}
let file = std::fs::File::open(path)
.map_err(|e| (format!("predicate file '{path}': {e}"), exit::NOINPUT))?;
let mut data = Vec::new();
file.take(MAX_PREDICATE_BYTES + 1)
.read_to_end(&mut data)
.map_err(|e| (format!("predicate file '{path}': {e}"), exit::NOINPUT))?;
if data.len() as u64 > MAX_PREDICATE_BYTES {
return Err((
format!("predicate file '{path}' exceeds {MAX_PREDICATE_BYTES}-byte cap"),
exit::DATAERR,
));
}
Ok(data)
}
fn resolve_commit(layout: &RepoLayout, flag: Option<&str>) -> Result<Hash, (String, u8)> {
if let Some(hex) = flag {
return hash_mod::from_hex(hex)
.map_err(|e| (format!("bad --commit hash: {e}"), exit::DATAERR));
}
match refs::resolve_head(layout) {
Ok(Some(h)) => Ok(h),
Ok(None) => Err(("HEAD has no commit yet".to_owned(), exit::GENERAL_ERROR)),
Err(e) => Err((format!("read HEAD: {e}"), exit::GENERAL_ERROR)),
}
}
use super::error as emit_err;
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn parse_args(args: &[String]) -> Result<Args, clap::Error> {
let mut full: Vec<String> = vec!["mkit attest".into()];
full.extend_from_slice(args);
Args::try_parse_from(full)
}
#[test]
fn parse_args_accepts_all_flags() {
let args = vec![
"--commit".into(),
"abc".into(),
"--algorithm".into(),
"p256".into(),
"--signer".into(),
"external".into(),
"--predicate-type".into(),
"https://example.com/p".into(),
"--predicate-file".into(),
"/tmp/x.json".into(),
];
let p = parse_args(&args).unwrap();
assert_eq!(p.commit.as_deref(), Some("abc"));
assert_eq!(p.algorithm.as_deref(), Some("p256"));
assert_eq!(p.signer.as_deref(), Some("external"));
assert_eq!(p.predicate_type.as_deref(), Some("https://example.com/p"));
assert_eq!(p.predicate_file.as_deref(), Some("/tmp/x.json"));
assert!(p.additional_signers.is_empty());
}
#[test]
fn parse_args_collects_repeatable_external_signer_args() {
let args = vec![
"--external-signer-arg".into(),
"sign".into(),
"--external-signer-arg".into(),
"--tag".into(),
"--external-signer-arg".into(),
"demo".into(),
];
let p = parse_args(&args).unwrap();
let expected = vec!["sign".to_owned(), "--tag".to_owned(), "demo".to_owned()];
assert_eq!(p.external_signer_args(), Some(expected));
}
#[test]
fn parse_args_external_signer_arg_none_when_absent() {
let p = parse_args(&[]).unwrap();
assert!(p.external_signer_args().is_none());
}
#[test]
fn parse_args_collects_multiple_additional_signers() {
let args = vec![
"--additional-signer".into(),
"algorithm=ed25519,signer=repo-key".into(),
"--additional-signer".into(),
"algorithm=p256,signer=external,path=/x".into(),
];
let p = parse_args(&args).unwrap();
assert_eq!(p.additional_signers.len(), 2);
assert_eq!(p.additional_signers[0], "algorithm=ed25519,signer=repo-key");
}
#[test]
fn parse_args_rejects_unknown() {
let args = vec!["--bogus".into(), "x".into()];
assert!(parse_args(&args).is_err());
}
#[test]
fn parse_signer_spec_ok() {
let s = parse_signer_spec("algorithm=secp256k1,signer=repo-key,path=k.key").unwrap();
assert_eq!(s.algorithm, Algorithm::Secp256k1);
assert_eq!(s.signer_kind, "repo-key");
assert_eq!(s.path.as_deref(), Some("k.key"));
}
#[test]
fn parse_signer_spec_with_args() {
let s = parse_signer_spec(
"algorithm=p256,signer=external,path=/usr/bin/signer,args=sign|--tag|demo",
)
.unwrap();
assert_eq!(s.algorithm, Algorithm::P256);
assert_eq!(s.signer_kind, "external");
assert_eq!(s.path.as_deref(), Some("/usr/bin/signer"));
assert_eq!(
s.args.as_deref(),
Some(["sign".to_owned(), "--tag".to_owned(), "demo".to_owned()].as_slice())
);
}
#[test]
fn parse_signer_spec_args_empty_means_zero_argv() {
let s = parse_signer_spec("algorithm=ed25519,signer=external,args=").unwrap();
assert_eq!(s.args.as_deref(), Some([].as_slice()));
}
#[test]
fn parse_signer_spec_without_path() {
let s = parse_signer_spec("algorithm=p256,signer=external").unwrap();
assert_eq!(s.algorithm, Algorithm::P256);
assert_eq!(s.signer_kind, "external");
assert!(s.path.is_none());
}
#[test]
fn parse_signer_spec_missing_algorithm() {
let e = parse_signer_spec("signer=repo-key").unwrap_err();
assert!(e.contains("algorithm"), "{e}");
}
#[test]
fn parse_signer_spec_missing_signer() {
let e = parse_signer_spec("algorithm=ed25519").unwrap_err();
assert!(e.contains("signer"), "{e}");
}
#[test]
fn parse_signer_spec_unknown_algorithm() {
let e = parse_signer_spec("algorithm=rsa,signer=repo-key").unwrap_err();
assert!(e.contains("rsa"), "{e}");
}
#[test]
fn parse_signer_spec_unknown_signer_kind() {
let e = parse_signer_spec("algorithm=ed25519,signer=sigstore").unwrap_err();
assert!(e.contains("sigstore"), "{e}");
}
#[test]
fn parse_signer_spec_not_key_value() {
let e = parse_signer_spec("algorithm=ed25519 signer=repo-key").unwrap_err();
assert!(e.contains("algorithm") || e.contains("key=value"), "{e}");
}
#[test]
fn parse_args_all_defaults_when_empty() {
let p = parse_args(&[]).unwrap();
assert!(p.commit.is_none());
assert!(p.algorithm.is_none());
assert!(p.signer.is_none());
assert!(p.predicate_type.is_none());
assert!(p.predicate_file.is_none());
assert!(p.additional_signers.is_empty());
}
}