Skip to main content

auths_cli/commands/artifact/
mod.rs

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 an artifact file with your Auths identity.
46    Sign {
47        /// Path to the artifact file to sign.
48        #[arg(help = "Path to the artifact file to sign.")]
49        file: PathBuf,
50
51        /// Output path for the signature file. Defaults to <FILE>.auths.json.
52        #[arg(long = "sig-output", value_name = "PATH")]
53        sig_output: Option<PathBuf>,
54
55        /// Local alias of the identity key (used for signing). Omit for CI device-only signing.
56        #[arg(
57            long,
58            help = "Local alias of the identity key. Omit for device-only CI signing."
59        )]
60        key: Option<String>,
61
62        /// Local alias of the device key (used for dual-signing).
63        /// Auto-detected when only one key exists for the identity.
64        #[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        /// Duration in seconds until expiration (per RFC 6749).
71        #[arg(long = "expires-in", value_name = "N")]
72        expires_in: Option<u64>,
73
74        /// Optional note to embed in the attestation.
75        #[arg(long)]
76        note: Option<String>,
77
78        /// Git commit SHA to embed in the attestation (auto-detected from HEAD if omitted).
79        #[arg(long, conflicts_with = "no_commit")]
80        commit: Option<String>,
81
82        /// Do not embed any commit SHA in the attestation.
83        #[arg(long, conflicts_with = "commit")]
84        no_commit: bool,
85
86        /// Use ephemeral CI signing (no keychain needed). Requires --commit.
87        #[arg(long)]
88        ci: bool,
89
90        /// CI platform override when --ci is used outside a detected CI environment.
91        #[arg(long, requires = "ci")]
92        ci_platform: Option<String>,
93
94        /// Transparency log to submit to (overrides default from trust config).
95        #[arg(long, value_name = "LOG_ID")]
96        log: Option<String>,
97
98        /// Skip transparency log submission (local testing only).
99        /// Produces an unlogged attestation that verifiers reject by default.
100        #[arg(long)]
101        allow_unlogged: bool,
102    },
103
104    /// Sign and publish an artifact attestation to a registry.
105    ///
106    /// Auto-signs the artifact when no --signature is provided.
107    Publish {
108        /// Artifact file to sign and publish (auto-signs if no --signature).
109        #[arg(help = "Artifact file to sign and publish (auto-signs if no --signature).")]
110        file: Option<PathBuf>,
111
112        /// Path to an existing .auths.json signature file. Defaults to <FILE>.auths.json.
113        #[arg(long, value_name = "PATH")]
114        signature: Option<PathBuf>,
115
116        /// Package identifier for registry indexing (e.g., npm:react@18.3.0).
117        #[arg(long)]
118        package: Option<String>,
119
120        /// Registry URL to publish to.
121        #[arg(long, env = "AUTHS_REGISTRY_URL", default_value = DEFAULT_REGISTRY_URL)]
122        registry: String,
123
124        /// Local alias of the identity key. Omit for device-only CI signing.
125        #[arg(long)]
126        key: Option<String>,
127
128        /// Local alias of the device key. Auto-detected when only one key exists.
129        #[arg(long)]
130        device_key: Option<String>,
131
132        /// Duration in seconds until expiration.
133        #[arg(long = "expires-in", value_name = "N")]
134        expires_in: Option<u64>,
135
136        /// Optional note to embed in the attestation.
137        #[arg(long)]
138        note: Option<String>,
139
140        /// Git commit SHA to embed in the attestation (auto-detected from HEAD if omitted).
141        #[arg(long, conflicts_with = "no_commit")]
142        commit: Option<String>,
143
144        /// Do not embed any commit SHA in the attestation.
145        #[arg(long, conflicts_with = "commit")]
146        no_commit: bool,
147    },
148
149    /// Verify an artifact's signature against an Auths identity.
150    Verify {
151        /// Path to the artifact file to verify.
152        #[arg(help = "Path to the artifact file to verify.")]
153        file: PathBuf,
154
155        /// Path to the signature file. Defaults to <FILE>.auths.json.
156        #[arg(long, value_name = "PATH")]
157        signature: Option<PathBuf>,
158
159        /// Path to identity bundle JSON (for CI/CD stateless verification).
160        #[arg(long, value_parser)]
161        identity_bundle: Option<PathBuf>,
162
163        /// Path to witness signatures JSON file.
164        #[arg(long = "witness-signatures")]
165        witness_receipts: Option<PathBuf>,
166
167        /// Witness public keys as DID:hex pairs (e.g., "did:key:z6Mk...:abcd1234...").
168        #[arg(long, num_args = 1..)]
169        witness_keys: Vec<String>,
170
171        /// Number of witnesses required (default: 1).
172        #[arg(long = "witnesses-required", default_value = "1")]
173        witness_threshold: usize,
174
175        /// Also verify the source commit's signing attestation.
176        #[arg(long)]
177        verify_commit: bool,
178
179        /// Verify an air-gapped org bundle entirely offline (no network access).
180        #[arg(long)]
181        offline: bool,
182
183        /// Override the pinned trust roots path (default: `.auths/roots`).
184        #[arg(long, value_name = "PATH")]
185        roots: Option<PathBuf>,
186
187        /// (offline) Member `did:keri` to classify authority for.
188        #[arg(long = "member", visible_alias = "member-did")]
189        member: Option<String>,
190
191        /// (offline) The artifact's in-band signing KEL position.
192        #[arg(long)]
193        signed_at: Option<u128>,
194
195        /// (offline) Emit the typed verdict as JSON.
196        #[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
219/// Re-export DSSE PAE from the SDK for use in CLI signing paths.
220pub use auths_sdk::domains::signing::service::dsse_pae;
221
222/// Submit an attestation to a transparency log and return the JSON to embed.
223///
224/// The `dsse_signature` is the signature over the DSSE PAE of the attestation,
225/// computed by the caller while the signing key is still available.
226///
227/// Returns `None` if `allow_unlogged` is set or `--log` wasn't passed.
228fn 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 wasn't passed, skip silently (non-CI default behavior)
243    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    // device_public_key may be a hex string or {"curve": "...", "key": "..."}
254    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
316/// Merge transparency JSON into an attestation and return the final JSON string.
317fn 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
327/// Resolve the commit SHA from CLI flags.
328fn 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
342/// Handle the `artifact` command dispatch.
343pub 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                // Ephemeral CI signing — no keychain, no passphrase
366                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                // Explicit --ci-platform takes precedence over auto-detection so
376                // tests can opt out of the CI runner's auto-detected platform.
377                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                // Submit to transparency log (unless --allow-unlogged)
424                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                // Standard device-key signing
461                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(), &registry)
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}