1pub mod core;
2pub mod file;
3pub mod publish;
4pub mod sign;
5pub mod verify;
6
7use clap::{Args, Subcommand};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use anyhow::{Context, Result, bail};
12use auths_sdk::core_config::EnvironmentConfig;
13use auths_sdk::registration::DEFAULT_REGISTRY_URL;
14use auths_sdk::signing::PassphraseProvider;
15use auths_sdk::signing::validate_commit_sha;
16
17#[derive(Args, Debug, Clone)]
18#[command(
19 about = "Sign and verify arbitrary artifacts (tarballs, binaries, etc.).",
20 after_help = "Examples:
21 auths artifact sign package.tar.gz # Sign an artifact
22 auths artifact sign package.tar.gz --expires-in 2592000
23 # Sign with 30-day expiry
24 auths artifact verify package.tar.gz.auths.json
25 # Verify artifact signature
26 auths artifact publish package.tar.gz --package npm:react@18.3.0
27 # Sign and publish to registry
28
29Signature Files:
30 Signatures are stored as <file>.auths.json next to the artifact.
31 Contains identity, device, and signature information.
32
33Related:
34 auths sign — Sign commits and other files
35 auths verify — Verify signatures
36 auths trust — Manage trusted identities"
37)]
38pub struct ArtifactCommand {
39 #[command(subcommand)]
40 pub command: ArtifactSubcommand,
41}
42
43#[derive(Subcommand, Debug, Clone)]
44pub enum ArtifactSubcommand {
45 Sign {
47 #[arg(help = "Path to the artifact file to sign.")]
49 file: PathBuf,
50
51 #[arg(long = "sig-output", value_name = "PATH")]
53 sig_output: Option<PathBuf>,
54
55 #[arg(
57 long,
58 help = "Local alias of the identity key. Omit for device-only CI signing."
59 )]
60 key: Option<String>,
61
62 #[arg(
65 long,
66 help = "Local alias of the device key. Auto-detected when only one key exists."
67 )]
68 device_key: Option<String>,
69
70 #[arg(long = "expires-in", value_name = "N")]
72 expires_in: Option<u64>,
73
74 #[arg(long)]
76 note: Option<String>,
77
78 #[arg(long, conflicts_with = "no_commit")]
80 commit: Option<String>,
81
82 #[arg(long, conflicts_with = "commit")]
84 no_commit: bool,
85
86 #[arg(long)]
88 ci: bool,
89
90 #[arg(long, requires = "ci")]
92 ci_platform: Option<String>,
93
94 #[arg(long, value_name = "LOG_ID")]
96 log: Option<String>,
97
98 #[arg(long)]
101 allow_unlogged: bool,
102 },
103
104 Publish {
108 #[arg(help = "Artifact file to sign and publish (auto-signs if no --signature).")]
110 file: Option<PathBuf>,
111
112 #[arg(long, value_name = "PATH")]
114 signature: Option<PathBuf>,
115
116 #[arg(long)]
118 package: Option<String>,
119
120 #[arg(long, env = "AUTHS_REGISTRY_URL", default_value = DEFAULT_REGISTRY_URL)]
122 registry: String,
123
124 #[arg(long)]
126 key: Option<String>,
127
128 #[arg(long)]
130 device_key: Option<String>,
131
132 #[arg(long = "expires-in", value_name = "N")]
134 expires_in: Option<u64>,
135
136 #[arg(long)]
138 note: Option<String>,
139
140 #[arg(long, conflicts_with = "no_commit")]
142 commit: Option<String>,
143
144 #[arg(long, conflicts_with = "commit")]
146 no_commit: bool,
147 },
148
149 Verify {
151 #[arg(help = "Path to the artifact file to verify.")]
153 file: PathBuf,
154
155 #[arg(long, value_name = "PATH")]
157 signature: Option<PathBuf>,
158
159 #[arg(long, value_parser)]
161 identity_bundle: Option<PathBuf>,
162
163 #[arg(long = "witness-signatures")]
165 witness_receipts: Option<PathBuf>,
166
167 #[arg(long, num_args = 1..)]
169 witness_keys: Vec<String>,
170
171 #[arg(long = "witnesses-required", default_value = "1")]
173 witness_threshold: usize,
174
175 #[arg(long)]
177 verify_commit: bool,
178
179 #[arg(long)]
181 offline: bool,
182
183 #[arg(long, value_name = "PATH")]
185 roots: Option<PathBuf>,
186
187 #[arg(long = "member", visible_alias = "member-did")]
189 member: Option<String>,
190
191 #[arg(long)]
193 signed_at: Option<u128>,
194
195 #[arg(long)]
197 json: bool,
198 },
199}
200
201fn is_rate_limited(err: &auths_sdk::workflows::log_submit::LogSubmitError) -> bool {
202 matches!(
203 err,
204 auths_sdk::workflows::log_submit::LogSubmitError::LogError(
205 auths_sdk::ports::LogError::RateLimited { .. }
206 )
207 )
208}
209
210fn rate_limit_secs(err: &auths_sdk::workflows::log_submit::LogSubmitError) -> u64 {
211 match err {
212 auths_sdk::workflows::log_submit::LogSubmitError::LogError(
213 auths_sdk::ports::LogError::RateLimited { retry_after_secs },
214 ) => *retry_after_secs,
215 _ => 10,
216 }
217}
218
219pub use auths_sdk::domains::signing::service::dsse_pae;
221
222fn submit_to_log(
229 attestation_json: &str,
230 log: &Option<String>,
231 allow_unlogged: bool,
232 dsse_signature: Option<&[u8]>,
233) -> Result<Option<serde_json::Value>> {
234 if allow_unlogged {
235 eprintln!(
236 "WARNING: Signing without transparency log. \
237 This artifact will not be verifiable against any log."
238 );
239 return Ok(None);
240 }
241
242 if log.is_none() {
244 return Ok(None);
245 }
246
247 let sig_bytes = dsse_signature
248 .ok_or_else(|| anyhow::anyhow!("DSSE signature required for log submission"))?;
249
250 let attestation_value: serde_json::Value = serde_json::from_str(attestation_json)
251 .map_err(|e| anyhow::anyhow!("Failed to parse attestation: {e}"))?;
252
253 let pk_hex = if let Some(s) = attestation_value["device_public_key"].as_str() {
255 s.to_string()
256 } else if let Some(key_field) = attestation_value["device_public_key"]["key"].as_str() {
257 key_field.to_string()
258 } else {
259 return Err(anyhow::anyhow!("missing device_public_key"));
260 };
261 let pk_bytes =
262 hex::decode(&pk_hex).map_err(|e| anyhow::anyhow!("invalid public key hex: {e}"))?;
263
264 let pk_curve = match attestation_value["device_public_key"]["curve"].as_str() {
265 Some("ed25519") | Some("Ed25519") => auths_crypto::CurveType::Ed25519,
266 _ => auths_crypto::CurveType::P256,
267 };
268
269 let rt = tokio::runtime::Runtime::new()
270 .map_err(|e| anyhow::anyhow!("Failed to create async runtime: {e}"))?;
271
272 let log_client: std::sync::Arc<dyn auths_sdk::ports::TransparencyLog> = match log.as_deref() {
273 Some("sigstore-rekor") => std::sync::Arc::new(
274 auths_infra_rekor::RekorClient::public()
275 .map_err(|e| anyhow::anyhow!("Failed to create Rekor client: {e}"))?,
276 ),
277 Some(other) => bail!("Unknown log '{}'. Available: sigstore-rekor", other),
278 None => unreachable!(),
279 };
280
281 let submit = || {
282 rt.block_on(auths_sdk::workflows::log_submit::submit_attestation_to_log(
283 attestation_json.as_bytes(),
284 &pk_bytes,
285 pk_curve,
286 sig_bytes,
287 log_client.as_ref(),
288 ))
289 };
290
291 let submission_result = match submit() {
292 Ok(bundle) => Ok(bundle),
293 Err(ref e) if is_rate_limited(e) => {
294 let secs = rate_limit_secs(e);
295 eprintln!("Rate limited by transparency log. Retrying in {secs}s...");
296 std::thread::sleep(std::time::Duration::from_secs(secs));
297 submit()
298 }
299 Err(e) => Err(e),
300 };
301
302 match submission_result {
303 Ok(bundle) => {
304 eprintln!(
305 " Logged to {} at index {}",
306 bundle.log_id, bundle.leaf_index
307 );
308 Ok(Some(serde_json::to_value(&bundle).map_err(|e| {
309 anyhow::anyhow!("Failed to serialize: {e}")
310 })?))
311 }
312 Err(e) => Err(anyhow::anyhow!("Transparency log submission failed: {e}")),
313 }
314}
315
316fn merge_transparency(attestation_json: &str, transparency: serde_json::Value) -> Result<String> {
318 let mut attestation: serde_json::Value = serde_json::from_str(attestation_json)
319 .map_err(|e| anyhow::anyhow!("Failed to re-parse attestation: {e}"))?;
320 if let serde_json::Value::Object(ref mut map) = attestation {
321 map.insert("transparency".to_string(), transparency);
322 }
323 serde_json::to_string_pretty(&attestation)
324 .map_err(|e| anyhow::anyhow!("Failed to serialize attestation: {e}"))
325}
326
327fn resolve_commit_sha_from_flags(
329 commit: Option<String>,
330 no_commit: bool,
331) -> Result<Option<String>> {
332 if no_commit {
333 return Ok(None);
334 }
335 if let Some(sha) = commit {
336 let validated = validate_commit_sha(&sha).map_err(anyhow::Error::from)?;
337 return Ok(Some(validated));
338 }
339 Ok(crate::commands::git_helpers::resolve_head_silent())
340}
341
342pub fn handle_artifact(
344 cmd: ArtifactCommand,
345 repo_opt: Option<PathBuf>,
346 passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
347 env_config: &EnvironmentConfig,
348) -> Result<()> {
349 match cmd.command {
350 ArtifactSubcommand::Sign {
351 file,
352 sig_output,
353 key,
354 device_key,
355 expires_in,
356 note,
357 commit,
358 no_commit,
359 ci,
360 ci_platform,
361 log,
362 allow_unlogged,
363 } => {
364 if ci {
365 use auths_sdk::domains::signing::ci_env::{
367 CiEnvironment, CiPlatform, detect_ci_environment,
368 };
369
370 let commit_sha = match commit {
371 Some(sha) => sha,
372 None => bail!("--ci requires --commit <sha>. Pass the commit SHA explicitly."),
373 };
374
375 let ci_env = match ci_platform.as_deref() {
378 Some("local") => CiEnvironment {
379 platform: CiPlatform::Local,
380 workflow_ref: None,
381 run_id: None,
382 actor: None,
383 runner_os: None,
384 },
385 Some(name) => CiEnvironment {
386 platform: CiPlatform::Generic,
387 workflow_ref: None,
388 run_id: None,
389 actor: None,
390 runner_os: Some(name.to_string()),
391 },
392 None => match detect_ci_environment() {
393 Some(env) => env,
394 None => bail!(
395 "No CI environment detected. If this is intentional (e.g., testing), \
396 pass --ci-platform local. Otherwise run inside GitHub Actions, \
397 GitLab CI, or a recognized CI runner."
398 ),
399 },
400 };
401
402 let ci_env_json = serde_json::to_value(&ci_env)
403 .map_err(|e| anyhow::anyhow!("Failed to serialize CI env: {}", e))?;
404
405 let data = std::fs::read(&file)
406 .with_context(|| format!("Failed to read artifact {:?}", file))?;
407 let artifact_name = file.file_name().map(|n| n.to_string_lossy().to_string());
408
409 #[allow(clippy::disallowed_methods)]
410 let now = chrono::Utc::now();
411
412 let result = auths_sdk::domains::signing::service::sign_artifact_ephemeral(
413 now,
414 &data,
415 artifact_name,
416 commit_sha,
417 expires_in,
418 note,
419 Some(ci_env_json),
420 )
421 .map_err(|e| anyhow::anyhow!("Ephemeral signing failed: {}", e))?;
422
423 let transparency_json = submit_to_log(
425 &result.attestation_json,
426 &log,
427 allow_unlogged,
428 result.dsse_signature.as_deref(),
429 )?;
430
431 let final_json = if let Some(transparency) = transparency_json {
432 merge_transparency(&result.attestation_json, transparency)?
433 } else {
434 result.attestation_json.clone()
435 };
436
437 let output_path = sig_output.unwrap_or_else(|| {
438 let mut p = file.clone();
439 let new_name = format!(
440 "{}.auths.json",
441 p.file_name().unwrap_or_default().to_string_lossy()
442 );
443 p.set_file_name(new_name);
444 p
445 });
446
447 std::fs::write(&output_path, &final_json)
448 .with_context(|| format!("Failed to write signature to {:?}", output_path))?;
449
450 println!(
451 "Signed {:?} -> {:?} (ephemeral CI key)",
452 file.file_name().unwrap_or_default(),
453 output_path
454 );
455 println!(" RID: {}", result.rid);
456 println!(" Digest: sha256:{}", result.digest);
457
458 Ok(())
459 } else {
460 let commit_sha = resolve_commit_sha_from_flags(commit, no_commit)?;
462 let resolved_alias = match device_key {
463 Some(alias) => alias,
464 None => crate::commands::key_detect::auto_detect_device_key(
465 repo_opt.as_deref(),
466 env_config,
467 )?,
468 };
469 sign::handle_sign(
470 &file,
471 sig_output,
472 key.as_deref(),
473 &resolved_alias,
474 expires_in,
475 note,
476 commit_sha,
477 repo_opt,
478 passphrase_provider,
479 env_config,
480 &log,
481 allow_unlogged,
482 )
483 }
484 }
485 ArtifactSubcommand::Publish {
486 file,
487 signature,
488 package,
489 registry,
490 key,
491 device_key,
492 expires_in,
493 note,
494 commit,
495 no_commit,
496 } => {
497 let commit_sha = resolve_commit_sha_from_flags(commit, no_commit)?;
498 let sig_path = match (signature, file.as_ref()) {
499 (Some(sig), _) => sig,
500 (None, Some(artifact)) => {
501 let default_sig = derive_signature_path(artifact);
502 if default_sig.exists() {
503 default_sig
504 } else {
505 let resolved_alias = match device_key {
506 Some(alias) => alias,
507 None => crate::commands::key_detect::auto_detect_device_key(
508 repo_opt.as_deref(),
509 env_config,
510 )?,
511 };
512 sign::handle_sign(
513 artifact,
514 None,
515 key.as_deref(),
516 &resolved_alias,
517 expires_in,
518 note,
519 commit_sha,
520 repo_opt.clone(),
521 passphrase_provider,
522 env_config,
523 &None,
524 false,
525 )?;
526 default_sig
527 }
528 }
529 (None, None) => bail!(
530 "Provide an artifact file to sign-and-publish, or --signature for an existing signature"
531 ),
532 };
533 publish::handle_publish(&sig_path, package.as_deref(), ®istry)
534 }
535 ArtifactSubcommand::Verify {
536 file,
537 signature,
538 identity_bundle,
539 witness_receipts,
540 witness_keys,
541 witness_threshold,
542 verify_commit,
543 offline,
544 roots,
545 member,
546 signed_at,
547 json,
548 } => {
549 if offline {
550 return verify::handle_offline_verify(
551 &file,
552 roots.as_deref(),
553 member.as_deref(),
554 signed_at,
555 json,
556 );
557 }
558 let rt = tokio::runtime::Runtime::new()?;
559 rt.block_on(verify::handle_verify(
560 &file,
561 signature,
562 identity_bundle,
563 witness_receipts,
564 &witness_keys,
565 witness_threshold,
566 verify_commit,
567 ))
568 }
569 }
570}
571
572fn derive_signature_path(file: &Path) -> PathBuf {
573 let mut p = file.to_path_buf();
574 let new_name = format!(
575 "{}.auths.json",
576 p.file_name().unwrap_or_default().to_string_lossy()
577 );
578 p.set_file_name(new_name);
579 p
580}
581
582impl crate::commands::executable::ExecutableCommand for ArtifactCommand {
583 fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
584 handle_artifact(
585 self.clone(),
586 ctx.repo_path.clone(),
587 ctx.passphrase_provider.clone(),
588 &ctx.env_config,
589 )
590 }
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596 use clap::Parser;
597
598 #[derive(Parser)]
599 struct Cli {
600 #[command(subcommand)]
601 command: ArtifactSubcommand,
602 }
603
604 #[test]
605 fn derive_signature_path_appends_auths_json() {
606 let path = derive_signature_path(Path::new("/tmp/my-pkg-1.0.0.tar.gz"));
607 assert_eq!(path, PathBuf::from("/tmp/my-pkg-1.0.0.tar.gz.auths.json"));
608 }
609
610 #[test]
611 fn derive_signature_path_handles_bare_filename() {
612 let path = derive_signature_path(Path::new("artifact.bin"));
613 assert_eq!(path, PathBuf::from("artifact.bin.auths.json"));
614 }
615
616 #[test]
617 fn publish_accepts_file_positional_arg() {
618 let cli = Cli::try_parse_from(["test", "publish", "my-file.tar.gz"]).unwrap();
619 match cli.command {
620 ArtifactSubcommand::Publish {
621 file, signature, ..
622 } => {
623 assert_eq!(file, Some(PathBuf::from("my-file.tar.gz")));
624 assert!(signature.is_none());
625 }
626 _ => panic!("expected Publish"),
627 }
628 }
629
630 #[test]
631 fn publish_accepts_signature_flag_without_file() {
632 let cli =
633 Cli::try_parse_from(["test", "publish", "--signature", "my-file.auths.json"]).unwrap();
634 match cli.command {
635 ArtifactSubcommand::Publish {
636 file, signature, ..
637 } => {
638 assert!(file.is_none());
639 assert_eq!(signature, Some(PathBuf::from("my-file.auths.json")));
640 }
641 _ => panic!("expected Publish"),
642 }
643 }
644
645 #[test]
646 fn publish_accepts_both_file_and_signature() {
647 let cli = Cli::try_parse_from([
648 "test",
649 "publish",
650 "my-file.tar.gz",
651 "--signature",
652 "custom.auths.json",
653 ])
654 .unwrap();
655 match cli.command {
656 ArtifactSubcommand::Publish {
657 file, signature, ..
658 } => {
659 assert_eq!(file, Some(PathBuf::from("my-file.tar.gz")));
660 assert_eq!(signature, Some(PathBuf::from("custom.auths.json")));
661 }
662 _ => panic!("expected Publish"),
663 }
664 }
665
666 #[test]
667 fn publish_accepts_no_args() {
668 let cli = Cli::try_parse_from(["test", "publish"]).unwrap();
669 match cli.command {
670 ArtifactSubcommand::Publish {
671 file, signature, ..
672 } => {
673 assert!(file.is_none());
674 assert!(signature.is_none());
675 }
676 _ => panic!("expected Publish"),
677 }
678 }
679
680 #[test]
681 fn publish_forwards_signing_flags() {
682 let cli = Cli::try_parse_from([
683 "test",
684 "publish",
685 "my-file.tar.gz",
686 "--key",
687 "main",
688 "--device-key",
689 "device-1",
690 "--expires-in",
691 "3600",
692 "--note",
693 "release build",
694 ])
695 .unwrap();
696 match cli.command {
697 ArtifactSubcommand::Publish {
698 key,
699 device_key,
700 expires_in,
701 note,
702 ..
703 } => {
704 assert_eq!(key.as_deref(), Some("main"));
705 assert_eq!(device_key.as_deref(), Some("device-1"));
706 assert_eq!(expires_in, Some(3600));
707 assert_eq!(note.as_deref(), Some("release build"));
708 }
709 _ => panic!("expected Publish"),
710 }
711 }
712}