pub mod core;
pub mod file;
pub mod publish;
pub mod sign;
pub mod verify;
use clap::{Args, Subcommand};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use auths_sdk::core_config::EnvironmentConfig;
use auths_sdk::registration::DEFAULT_REGISTRY_URL;
use auths_sdk::signing::PassphraseProvider;
use auths_sdk::signing::validate_commit_sha;
#[derive(Args, Debug, Clone)]
#[command(
about = "Sign and verify arbitrary artifacts (tarballs, binaries, etc.).",
after_help = "Examples:
auths artifact sign package.tar.gz # Sign an artifact
auths artifact sign package.tar.gz --expires-in 2592000
# Sign with 30-day expiry
auths artifact verify package.tar.gz.auths.json
# Verify artifact signature
auths artifact publish package.tar.gz --package npm:react@18.3.0
# Sign and publish to registry
Signature Files:
Signatures are stored as <file>.auths.json next to the artifact.
Contains identity, device, and signature information.
Related:
auths sign — Sign commits and other files
auths verify — Verify signatures
auths trust — Manage trusted identities"
)]
pub struct ArtifactCommand {
#[command(subcommand)]
pub command: ArtifactSubcommand,
}
#[derive(Subcommand, Debug, Clone)]
pub enum ArtifactSubcommand {
Sign {
#[arg(help = "Path to the artifact file to sign.")]
file: PathBuf,
#[arg(long = "sig-output", value_name = "PATH")]
sig_output: Option<PathBuf>,
#[arg(
long,
help = "Local alias of the identity key. Omit for device-only CI signing."
)]
key: Option<String>,
#[arg(
long,
help = "Local alias of the device key. Auto-detected when only one key exists."
)]
device_key: Option<String>,
#[arg(long = "expires-in", value_name = "N")]
expires_in: Option<u64>,
#[arg(long)]
note: Option<String>,
#[arg(long, conflicts_with = "no_commit")]
commit: Option<String>,
#[arg(long, conflicts_with = "commit")]
no_commit: bool,
#[arg(long)]
ci: bool,
#[arg(long, requires = "ci")]
ci_platform: Option<String>,
#[arg(long, value_name = "LOG_ID")]
log: Option<String>,
#[arg(long)]
allow_unlogged: bool,
},
Publish {
#[arg(help = "Artifact file to sign and publish (auto-signs if no --signature).")]
file: Option<PathBuf>,
#[arg(long, value_name = "PATH")]
signature: Option<PathBuf>,
#[arg(long)]
package: Option<String>,
#[arg(long, env = "AUTHS_REGISTRY_URL", default_value = DEFAULT_REGISTRY_URL)]
registry: String,
#[arg(long)]
key: Option<String>,
#[arg(long)]
device_key: Option<String>,
#[arg(long = "expires-in", value_name = "N")]
expires_in: Option<u64>,
#[arg(long)]
note: Option<String>,
#[arg(long, conflicts_with = "no_commit")]
commit: Option<String>,
#[arg(long, conflicts_with = "commit")]
no_commit: bool,
},
Verify {
#[arg(help = "Path to the artifact file to verify.")]
file: PathBuf,
#[arg(long, value_name = "PATH")]
signature: Option<PathBuf>,
#[arg(long, value_parser)]
identity_bundle: Option<PathBuf>,
#[arg(long = "witness-signatures")]
witness_receipts: Option<PathBuf>,
#[arg(long, num_args = 1..)]
witness_keys: Vec<String>,
#[arg(long = "witnesses-required", default_value = "1")]
witness_threshold: usize,
#[arg(long)]
verify_commit: bool,
#[arg(long)]
offline: bool,
#[arg(long, value_name = "PATH")]
roots: Option<PathBuf>,
#[arg(long = "member", visible_alias = "member-did")]
member: Option<String>,
#[arg(long)]
signed_at: Option<u128>,
#[arg(long)]
json: bool,
},
}
fn is_rate_limited(err: &auths_sdk::workflows::log_submit::LogSubmitError) -> bool {
matches!(
err,
auths_sdk::workflows::log_submit::LogSubmitError::LogError(
auths_sdk::ports::LogError::RateLimited { .. }
)
)
}
fn rate_limit_secs(err: &auths_sdk::workflows::log_submit::LogSubmitError) -> u64 {
match err {
auths_sdk::workflows::log_submit::LogSubmitError::LogError(
auths_sdk::ports::LogError::RateLimited { retry_after_secs },
) => *retry_after_secs,
_ => 10,
}
}
pub use auths_sdk::domains::signing::service::dsse_pae;
fn submit_to_log(
attestation_json: &str,
log: &Option<String>,
allow_unlogged: bool,
dsse_signature: Option<&[u8]>,
) -> Result<Option<serde_json::Value>> {
if allow_unlogged {
eprintln!(
"WARNING: Signing without transparency log. \
This artifact will not be verifiable against any log."
);
return Ok(None);
}
if log.is_none() {
return Ok(None);
}
let sig_bytes = dsse_signature
.ok_or_else(|| anyhow::anyhow!("DSSE signature required for log submission"))?;
let attestation_value: serde_json::Value = serde_json::from_str(attestation_json)
.map_err(|e| anyhow::anyhow!("Failed to parse attestation: {e}"))?;
let pk_hex = if let Some(s) = attestation_value["device_public_key"].as_str() {
s.to_string()
} else if let Some(key_field) = attestation_value["device_public_key"]["key"].as_str() {
key_field.to_string()
} else {
return Err(anyhow::anyhow!("missing device_public_key"));
};
let pk_bytes =
hex::decode(&pk_hex).map_err(|e| anyhow::anyhow!("invalid public key hex: {e}"))?;
let pk_curve = match attestation_value["device_public_key"]["curve"].as_str() {
Some("ed25519") | Some("Ed25519") => auths_crypto::CurveType::Ed25519,
_ => auths_crypto::CurveType::P256,
};
let rt = tokio::runtime::Runtime::new()
.map_err(|e| anyhow::anyhow!("Failed to create async runtime: {e}"))?;
let log_client: std::sync::Arc<dyn auths_sdk::ports::TransparencyLog> = match log.as_deref() {
Some("sigstore-rekor") => std::sync::Arc::new(
auths_infra_rekor::RekorClient::public()
.map_err(|e| anyhow::anyhow!("Failed to create Rekor client: {e}"))?,
),
Some(other) => bail!("Unknown log '{}'. Available: sigstore-rekor", other),
None => unreachable!(),
};
let submit = || {
rt.block_on(auths_sdk::workflows::log_submit::submit_attestation_to_log(
attestation_json.as_bytes(),
&pk_bytes,
pk_curve,
sig_bytes,
log_client.as_ref(),
))
};
let submission_result = match submit() {
Ok(bundle) => Ok(bundle),
Err(ref e) if is_rate_limited(e) => {
let secs = rate_limit_secs(e);
eprintln!("Rate limited by transparency log. Retrying in {secs}s...");
std::thread::sleep(std::time::Duration::from_secs(secs));
submit()
}
Err(e) => Err(e),
};
match submission_result {
Ok(bundle) => {
eprintln!(
" Logged to {} at index {}",
bundle.log_id, bundle.leaf_index
);
Ok(Some(serde_json::to_value(&bundle).map_err(|e| {
anyhow::anyhow!("Failed to serialize: {e}")
})?))
}
Err(e) => Err(anyhow::anyhow!("Transparency log submission failed: {e}")),
}
}
fn merge_transparency(attestation_json: &str, transparency: serde_json::Value) -> Result<String> {
let mut attestation: serde_json::Value = serde_json::from_str(attestation_json)
.map_err(|e| anyhow::anyhow!("Failed to re-parse attestation: {e}"))?;
if let serde_json::Value::Object(ref mut map) = attestation {
map.insert("transparency".to_string(), transparency);
}
serde_json::to_string_pretty(&attestation)
.map_err(|e| anyhow::anyhow!("Failed to serialize attestation: {e}"))
}
fn resolve_commit_sha_from_flags(
commit: Option<String>,
no_commit: bool,
) -> Result<Option<String>> {
if no_commit {
return Ok(None);
}
if let Some(sha) = commit {
let validated = validate_commit_sha(&sha).map_err(anyhow::Error::from)?;
return Ok(Some(validated));
}
Ok(crate::commands::git_helpers::resolve_head_silent())
}
pub fn handle_artifact(
cmd: ArtifactCommand,
repo_opt: Option<PathBuf>,
passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
env_config: &EnvironmentConfig,
) -> Result<()> {
match cmd.command {
ArtifactSubcommand::Sign {
file,
sig_output,
key,
device_key,
expires_in,
note,
commit,
no_commit,
ci,
ci_platform,
log,
allow_unlogged,
} => {
if ci {
use auths_sdk::domains::signing::ci_env::{
CiEnvironment, CiPlatform, detect_ci_environment,
};
let commit_sha = match commit {
Some(sha) => sha,
None => bail!("--ci requires --commit <sha>. Pass the commit SHA explicitly."),
};
let ci_env = match ci_platform.as_deref() {
Some("local") => CiEnvironment {
platform: CiPlatform::Local,
workflow_ref: None,
run_id: None,
actor: None,
runner_os: None,
},
Some(name) => CiEnvironment {
platform: CiPlatform::Generic,
workflow_ref: None,
run_id: None,
actor: None,
runner_os: Some(name.to_string()),
},
None => match detect_ci_environment() {
Some(env) => env,
None => bail!(
"No CI environment detected. If this is intentional (e.g., testing), \
pass --ci-platform local. Otherwise run inside GitHub Actions, \
GitLab CI, or a recognized CI runner."
),
},
};
let ci_env_json = serde_json::to_value(&ci_env)
.map_err(|e| anyhow::anyhow!("Failed to serialize CI env: {}", e))?;
let data = std::fs::read(&file)
.with_context(|| format!("Failed to read artifact {:?}", file))?;
let artifact_name = file.file_name().map(|n| n.to_string_lossy().to_string());
#[allow(clippy::disallowed_methods)]
let now = chrono::Utc::now();
let result = auths_sdk::domains::signing::service::sign_artifact_ephemeral(
now,
&data,
artifact_name,
commit_sha,
expires_in,
note,
Some(ci_env_json),
)
.map_err(|e| anyhow::anyhow!("Ephemeral signing failed: {}", e))?;
let transparency_json = submit_to_log(
&result.attestation_json,
&log,
allow_unlogged,
result.dsse_signature.as_deref(),
)?;
let final_json = if let Some(transparency) = transparency_json {
merge_transparency(&result.attestation_json, transparency)?
} else {
result.attestation_json.clone()
};
let output_path = sig_output.unwrap_or_else(|| {
let mut p = file.clone();
let new_name = format!(
"{}.auths.json",
p.file_name().unwrap_or_default().to_string_lossy()
);
p.set_file_name(new_name);
p
});
std::fs::write(&output_path, &final_json)
.with_context(|| format!("Failed to write signature to {:?}", output_path))?;
println!(
"Signed {:?} -> {:?} (ephemeral CI key)",
file.file_name().unwrap_or_default(),
output_path
);
println!(" RID: {}", result.rid);
println!(" Digest: sha256:{}", result.digest);
Ok(())
} else {
let commit_sha = resolve_commit_sha_from_flags(commit, no_commit)?;
let resolved_alias = match device_key {
Some(alias) => alias,
None => crate::commands::key_detect::auto_detect_device_key(
repo_opt.as_deref(),
env_config,
)?,
};
sign::handle_sign(
&file,
sig_output,
key.as_deref(),
&resolved_alias,
expires_in,
note,
commit_sha,
repo_opt,
passphrase_provider,
env_config,
&log,
allow_unlogged,
)
}
}
ArtifactSubcommand::Publish {
file,
signature,
package,
registry,
key,
device_key,
expires_in,
note,
commit,
no_commit,
} => {
let commit_sha = resolve_commit_sha_from_flags(commit, no_commit)?;
let sig_path = match (signature, file.as_ref()) {
(Some(sig), _) => sig,
(None, Some(artifact)) => {
let default_sig = derive_signature_path(artifact);
if default_sig.exists() {
default_sig
} else {
let resolved_alias = match device_key {
Some(alias) => alias,
None => crate::commands::key_detect::auto_detect_device_key(
repo_opt.as_deref(),
env_config,
)?,
};
sign::handle_sign(
artifact,
None,
key.as_deref(),
&resolved_alias,
expires_in,
note,
commit_sha,
repo_opt.clone(),
passphrase_provider,
env_config,
&None,
false,
)?;
default_sig
}
}
(None, None) => bail!(
"Provide an artifact file to sign-and-publish, or --signature for an existing signature"
),
};
publish::handle_publish(&sig_path, package.as_deref(), ®istry)
}
ArtifactSubcommand::Verify {
file,
signature,
identity_bundle,
witness_receipts,
witness_keys,
witness_threshold,
verify_commit,
offline,
roots,
member,
signed_at,
json,
} => {
if offline {
return verify::handle_offline_verify(
&file,
roots.as_deref(),
member.as_deref(),
signed_at,
json,
);
}
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(verify::handle_verify(
&file,
signature,
identity_bundle,
witness_receipts,
&witness_keys,
witness_threshold,
verify_commit,
))
}
}
}
fn derive_signature_path(file: &Path) -> PathBuf {
let mut p = file.to_path_buf();
let new_name = format!(
"{}.auths.json",
p.file_name().unwrap_or_default().to_string_lossy()
);
p.set_file_name(new_name);
p
}
impl crate::commands::executable::ExecutableCommand for ArtifactCommand {
fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
handle_artifact(
self.clone(),
ctx.repo_path.clone(),
ctx.passphrase_provider.clone(),
&ctx.env_config,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[derive(Parser)]
struct Cli {
#[command(subcommand)]
command: ArtifactSubcommand,
}
#[test]
fn derive_signature_path_appends_auths_json() {
let path = derive_signature_path(Path::new("/tmp/my-pkg-1.0.0.tar.gz"));
assert_eq!(path, PathBuf::from("/tmp/my-pkg-1.0.0.tar.gz.auths.json"));
}
#[test]
fn derive_signature_path_handles_bare_filename() {
let path = derive_signature_path(Path::new("artifact.bin"));
assert_eq!(path, PathBuf::from("artifact.bin.auths.json"));
}
#[test]
fn publish_accepts_file_positional_arg() {
let cli = Cli::try_parse_from(["test", "publish", "my-file.tar.gz"]).unwrap();
match cli.command {
ArtifactSubcommand::Publish {
file, signature, ..
} => {
assert_eq!(file, Some(PathBuf::from("my-file.tar.gz")));
assert!(signature.is_none());
}
_ => panic!("expected Publish"),
}
}
#[test]
fn publish_accepts_signature_flag_without_file() {
let cli =
Cli::try_parse_from(["test", "publish", "--signature", "my-file.auths.json"]).unwrap();
match cli.command {
ArtifactSubcommand::Publish {
file, signature, ..
} => {
assert!(file.is_none());
assert_eq!(signature, Some(PathBuf::from("my-file.auths.json")));
}
_ => panic!("expected Publish"),
}
}
#[test]
fn publish_accepts_both_file_and_signature() {
let cli = Cli::try_parse_from([
"test",
"publish",
"my-file.tar.gz",
"--signature",
"custom.auths.json",
])
.unwrap();
match cli.command {
ArtifactSubcommand::Publish {
file, signature, ..
} => {
assert_eq!(file, Some(PathBuf::from("my-file.tar.gz")));
assert_eq!(signature, Some(PathBuf::from("custom.auths.json")));
}
_ => panic!("expected Publish"),
}
}
#[test]
fn publish_accepts_no_args() {
let cli = Cli::try_parse_from(["test", "publish"]).unwrap();
match cli.command {
ArtifactSubcommand::Publish {
file, signature, ..
} => {
assert!(file.is_none());
assert!(signature.is_none());
}
_ => panic!("expected Publish"),
}
}
#[test]
fn publish_forwards_signing_flags() {
let cli = Cli::try_parse_from([
"test",
"publish",
"my-file.tar.gz",
"--key",
"main",
"--device-key",
"device-1",
"--expires-in",
"3600",
"--note",
"release build",
])
.unwrap();
match cli.command {
ArtifactSubcommand::Publish {
key,
device_key,
expires_in,
note,
..
} => {
assert_eq!(key.as_deref(), Some("main"));
assert_eq!(device_key.as_deref(), Some("device-1"));
assert_eq!(expires_in, Some(3600));
assert_eq!(note.as_deref(), Some("release build"));
}
_ => panic!("expected Publish"),
}
}
}