Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions crates/auths-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,42 @@ auths doctor
auths tutorial
```

## Artifact Signing

Sign, verify, and publish arbitrary files — binaries, packages, container images — with the same identity used for commits.

### Sign an artifact

```bash
auths artifact sign ./release.tar.gz
auths artifact sign ./my-app.whl --note "v2.1.0 release"
auths artifact sign ./build.zip --expires-in 90 # expires in 90 days
```

### Verify an artifact

```bash
auths artifact verify ./release.tar.gz
```

### Batch sign multiple artifacts

```bash
auths artifact batch-sign ./dist/
```

### Publish an attestation to a registry

```bash
auths artifact publish ./release.tar.gz.auths.json --registry https://registry.example.com
```

The `auths sign` shorthand also supports artifact files — if the target is a file on disk, it signs the artifact instead of a commit:

```bash
auths sign ./release.tar.gz # equivalent to: auths artifact sign ./release.tar.gz
```

## Advanced Commands

Run `auths --help-all` to see the full command list:
Expand Down
4 changes: 1 addition & 3 deletions crates/auths-cli/src/adapters/doctor_fixes.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
//! Fix implementations for CLI-only diagnostic checks.

use std::path::PathBuf;
use std::process::Command;

use auths_sdk::ports::diagnostics::{CheckResult, DiagnosticError, DiagnosticFix};
use auths_sdk::workflows::allowed_signers::AllowedSigners;
Expand Down Expand Up @@ -121,8 +120,7 @@ impl DiagnosticFix for GitSigningConfigFix {
}

fn set_git_config_value(key: &str, value: &str) -> Result<(), DiagnosticError> {
let status = Command::new("git")
.args(["config", "--global", key, value])
let status = crate::subprocess::git_command(&["config", "--global", key, value])
.status()
.map_err(|e| DiagnosticError::ExecutionFailed(format!("git config: {e}")))?;
if !status.success() {
Expand Down
6 changes: 2 additions & 4 deletions crates/auths-cli/src/adapters/git_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,7 @@ impl SystemGitConfigProvider {

impl GitConfigProvider for SystemGitConfigProvider {
fn set(&self, key: &str, value: &str) -> Result<(), GitConfigError> {
let mut cmd = std::process::Command::new("git");
cmd.args(["config", self.scope_flag, key, value]);
let mut cmd = crate::subprocess::git_command(&["config", self.scope_flag, key, value]);
if let Some(dir) = &self.working_dir {
cmd.current_dir(dir);
}
Expand All @@ -59,8 +58,7 @@ impl GitConfigProvider for SystemGitConfigProvider {
}

fn unset(&self, key: &str) -> Result<(), GitConfigError> {
let mut cmd = std::process::Command::new("git");
cmd.args(["config", self.scope_flag, "--unset", key]);
let mut cmd = crate::subprocess::git_command(&["config", self.scope_flag, "--unset", key]);
if let Some(dir) = &self.working_dir {
cmd.current_dir(dir);
}
Expand Down
56 changes: 47 additions & 9 deletions crates/auths-cli/src/adapters/system_diagnostic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ pub struct PosixDiagnosticAdapter;

impl GitDiagnosticProvider for PosixDiagnosticAdapter {
fn check_git_version(&self) -> Result<CheckResult, DiagnosticError> {
let output = Command::new("git").arg("--version").output();
let output = crate::subprocess::git_command(&["--version"]).output();
let (passed, message) = match output {
Ok(out) if out.status.success() => {
let version = String::from_utf8_lossy(&out.stdout).trim().to_string();
Expand All @@ -28,8 +28,7 @@ impl GitDiagnosticProvider for PosixDiagnosticAdapter {
}

fn get_git_config(&self, key: &str) -> Result<Option<String>, DiagnosticError> {
let output = Command::new("git")
.args(["config", "--global", "--get", key])
let output = crate::subprocess::git_command(&["config", "--global", "--get", key])
.output()
.map_err(|e| DiagnosticError::ExecutionFailed(e.to_string()))?;

Expand All @@ -45,13 +44,21 @@ impl GitDiagnosticProvider for PosixDiagnosticAdapter {

impl CryptoDiagnosticProvider for PosixDiagnosticAdapter {
fn check_ssh_keygen_available(&self) -> Result<CheckResult, DiagnosticError> {
let output = Command::new("ssh-keygen").arg("-V").output();
// Use `which` crate logic: check if ssh-keygen exists on PATH
let output = Command::new("ssh-keygen")
.arg("-?")
.stderr(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.output();
let (passed, message) = match output {
Ok(out) if out.status.success() => (true, Some("ssh-keygen found on PATH".to_string())),
_ => (
false,
Some("ssh-keygen command not found on PATH".to_string()),
),
Ok(out) if !out.stderr.is_empty() || !out.stdout.is_empty() => {
(true, Some("ssh-keygen found on PATH".to_string()))
}
Ok(_) => (true, Some("ssh-keygen found on PATH".to_string())),
Err(_) => {
let hint = ssh_install_hint();
(false, Some(format!("ssh-keygen not found on PATH. {hint}")))
}
};
Ok(CheckResult {
name: "ssh-keygen installed".to_string(),
Expand All @@ -61,4 +68,35 @@ impl CryptoDiagnosticProvider for PosixDiagnosticAdapter {
category: CheckCategory::Advisory,
})
}

fn check_ssh_version(&self) -> Result<String, DiagnosticError> {
// `ssh -V` writes to stderr, not stdout
let output = Command::new("ssh")
.arg("-V")
.stderr(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.output()
.map_err(|e| DiagnosticError::ExecutionFailed(format!("ssh -V failed: {e}")))?;

let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if !stderr.is_empty() {
return Ok(stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !stdout.is_empty() {
return Ok(stdout);
}
Ok("unknown".to_string())
}
}

/// Platform-specific install hint for ssh-keygen / OpenSSH.
fn ssh_install_hint() -> &'static str {
if cfg!(target_os = "macos") {
"ssh-keygen is normally pre-installed on macOS. Check your PATH."
} else if cfg!(target_os = "windows") {
"Install OpenSSH via Settings > Apps > Optional features, or `winget install Microsoft.OpenSSH.Client`."
} else {
"Install OpenSSH: `sudo apt install openssh-client` (Debian/Ubuntu) or `sudo dnf install openssh-clients` (Fedora/RHEL)."
}
}
6 changes: 2 additions & 4 deletions crates/auths-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,13 @@ pub enum RootCommand {
Init(InitCommand),
Sign(SignCommand),
Verify(UnifiedVerifyCommand),
Artifact(ArtifactCommand),
Status(StatusCommand),
Whoami(WhoamiCommand),

// ── Setup & Troubleshooting ──
Pair(PairCommand),
Trust(TrustCommand),
Doctor(DoctorCommand),
Tutorial(LearnCommand),

Expand All @@ -127,14 +129,10 @@ pub enum RootCommand {
#[command(hide = true)]
Approval(ApprovalCommand),
#[command(hide = true)]
Artifact(ArtifactCommand),
#[command(hide = true)]
Policy(PolicyCommand),
#[command(hide = true)]
Git(GitCommand),
#[command(hide = true)]
Trust(TrustCommand),
#[command(hide = true)]
Namespace(NamespaceCommand),
#[command(hide = true)]
Org(OrgCommand),
Expand Down
2 changes: 1 addition & 1 deletion crates/auths-cli/src/commands/artifact/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ fn resolve_commit_sha_from_flags(
return Ok(None);
}
if let Some(sha) = commit {
let validated = validate_commit_sha(&sha).map_err(|e| anyhow::anyhow!("{}", e))?;
let validated = validate_commit_sha(&sha).map_err(anyhow::Error::from)?;
return Ok(Some(validated));
}
Ok(crate::commands::git_helpers::resolve_head_silent())
Expand Down
8 changes: 5 additions & 3 deletions crates/auths-cli/src/commands/artifact/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,11 @@ pub async fn handle_verify(
Some(sha) => {
// Look up commit attestation via git ref
let commit_ref = format!("refs/auths/commits/{}", sha);
let lookup = std::process::Command::new("git")
.args(["show", &format!("{}:attestation.json", commit_ref)])
.output();
let lookup = crate::subprocess::git_command(&[
"show",
&format!("{}:attestation.json", commit_ref),
])
.output();
match lookup {
Ok(output) if output.status.success() => {
if !is_json_mode() {
Expand Down
2 changes: 1 addition & 1 deletion crates/auths-cli/src/commands/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ fn handle_auth_challenge(nonce: &str, domain: &str, ctx: &CliConfig) -> Result<(
}),
)
.print()
.map_err(|e| anyhow::anyhow!("{e}"))
.map_err(anyhow::Error::from)
} else {
println!("Signature: {}", result.signature_hex);
println!("Public Key: {}", result.public_key_hex);
Expand Down
4 changes: 2 additions & 2 deletions crates/auths-cli/src/commands/device/authorization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ fn display_dry_run_revoke(device_did: &str, identity_key_alias: &str) -> Result<
}),
)
.print()
.map_err(|e| anyhow!("{e}"))
.map_err(anyhow::Error::from)
} else {
let out = crate::ux::format::Output::new();
out.print_info("Dry run mode — no changes will be made");
Expand Down Expand Up @@ -575,7 +575,7 @@ fn list_devices(
}),
)
.print()
.map_err(|e| anyhow!("{e}"));
.map_err(anyhow::Error::from);
}

println!("Devices for identity: {}", identity.controller_did);
Expand Down
4 changes: 2 additions & 2 deletions crates/auths-cli/src/commands/device/pair/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ pub(crate) fn handle_pairing_response(
use auths_storage::git::{RegistryAttestationStorage, RegistryIdentityStorage};
let identity_store = Arc::new(RegistryIdentityStorage::new(auths_dir.to_path_buf()));
let controller_did = pairing::load_controller_did(identity_store.as_ref())
.map_err(|e| anyhow::anyhow!("{}", e))
.map_err(anyhow::Error::from)
.context("Failed to load identity from ~/.auths")?;

println!(
Expand Down Expand Up @@ -298,7 +298,7 @@ pub(crate) fn handle_pairing_response(
passphrase_provider,
&auths_core::ports::clock::SystemClock,
)
.map_err(|e| anyhow::anyhow!("{}", e))
.map_err(anyhow::Error::from)
.context("Pairing completion failed")?
{
PairingCompletionResult::Success {
Expand Down
15 changes: 10 additions & 5 deletions crates/auths-cli/src/commands/device/pair/join.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ pub(crate) async fn handle_join(
registry: &str,
env_config: &EnvironmentConfig,
) -> Result<()> {
let normalized = validate_short_code(code).map_err(|e| anyhow::anyhow!("{}", e))?;
let normalized = validate_short_code(code).map_err(anyhow::Error::from)?;

let formatted = format!("{}-{}", &normalized[..3], &normalized[3..]);

Expand All @@ -42,7 +42,8 @@ pub(crate) async fn handle_join(

let relay = HttpPairingRelayClient::new();

let auths_dir = auths_core::paths::auths_home_with_config(env_config).unwrap_or_default();
let auths_dir = auths_core::paths::auths_home_with_config(env_config)
.context("Could not determine Auths home directory. Check $AUTHS_HOME or $HOME.")?;

if !auths_dir.exists() {
anyhow::bail!("No local identity found. Run 'auths init' first.");
Expand All @@ -57,7 +58,7 @@ pub(crate) async fn handle_join(
let ctx = build_auths_context(&auths_dir, env_config, Some(passphrase_provider))
.context("Failed to build auths context")?;

let material = load_device_signing_material(&ctx).map_err(|e| anyhow::anyhow!("{}", e))?;
let material = load_device_signing_material(&ctx).map_err(anyhow::Error::from)?;

key_spinner.finish_with_message(format!("{CHECK}Device key loaded"));

Expand Down Expand Up @@ -88,7 +89,9 @@ pub(crate) async fn handle_join(
};

if token.is_expired(now) {
anyhow::bail!("Session expired");
anyhow::bail!(
"Pairing session expired. Start a new session with `auths pair` on the controller device."
);
}

let create_spinner = create_wait_spinner(&format!("{GEAR}Creating pairing response..."));
Expand Down Expand Up @@ -150,7 +153,9 @@ pub(crate) async fn handle_join(
if !confirmed {
display_sas_mismatch_warning();
drop(transport_key);
anyhow::bail!("SAS verification failed — pairing aborted");
anyhow::bail!(
"Security codes didn't match — the connection may not be secure. Restart pairing with `auths pair`."
);
}

// Wait for encrypted attestation from initiator
Expand Down
7 changes: 4 additions & 3 deletions crates/auths-cli/src/commands/device/pair/lan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@ pub async fn handle_initiate_lan(
capabilities: &[String],
env_config: &EnvironmentConfig,
) -> Result<()> {
let auths_dir = auths_core::paths::auths_home_with_config(env_config).unwrap_or_default();
let auths_dir = auths_core::paths::auths_home_with_config(env_config)
.context("Could not determine Auths home directory. Check $AUTHS_HOME or $HOME.")?;

let identity_storage = auths_storage::git::RegistryIdentityStorage::new(auths_dir.clone());
let controller_did = auths_sdk::pairing::load_controller_did(&identity_storage)
.map_err(|e| anyhow::anyhow!("{}", e))?;
let controller_did =
auths_sdk::pairing::load_controller_did(&identity_storage).map_err(anyhow::Error::from)?;

// Detect LAN IP
let lan_ip =
Expand Down
3 changes: 2 additions & 1 deletion crates/auths-cli/src/commands/device/pair/offline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ pub(crate) fn handle_initiate_offline(
capabilities: &[String],
) -> Result<()> {
// Try to load controller DID, fall back to placeholder
let auths_dir = auths_core::paths::auths_home().unwrap_or_default();
let auths_dir = auths_core::paths::auths_home()
.context("Could not determine Auths home directory. Check $AUTHS_HOME or $HOME.")?;

let controller_did = if auths_dir.exists() {
let storage = auths_storage::git::RegistryIdentityStorage::new(auths_dir.clone());
Expand Down
9 changes: 5 additions & 4 deletions crates/auths-cli/src/commands/device/pair/online.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ pub(crate) async fn handle_initiate_online(
capabilities: &[String],
env_config: &EnvironmentConfig,
) -> Result<()> {
let auths_dir = auths_core::paths::auths_home_with_config(env_config).unwrap_or_default();
let auths_dir = auths_core::paths::auths_home_with_config(env_config)
.context("Could not determine Auths home directory. Check $AUTHS_HOME or $HOME.")?;

let identity_storage = auths_storage::git::RegistryIdentityStorage::new(auths_dir.clone());
let controller_did = auths_sdk::pairing::load_controller_did(&identity_storage)
.map_err(|e| anyhow::anyhow!("{}", e))?;
let controller_did =
auths_sdk::pairing::load_controller_did(&identity_storage).map_err(anyhow::Error::from)?;

print_pairing_header("ONLINE", registry, &controller_did);

Expand Down Expand Up @@ -116,7 +117,7 @@ pub(crate) async fn handle_initiate_online(

match initiate_online_pairing(params, &relay, &ctx, now, Some(&on_status))
.await
.map_err(|e| anyhow::anyhow!("{}", e))?
.map_err(anyhow::Error::from)?
{
auths_sdk::pairing::PairingCompletionResult::Success {
device_did,
Expand Down
14 changes: 13 additions & 1 deletion crates/auths-cli/src/commands/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,19 @@ fn suggestion_for_check(name: &str) -> Option<String> {
"Git user identity" => Some(
"Run: git config --global user.name \"Your Name\" && git config --global user.email \"you@example.com\"".to_string(),
),
"ssh-keygen installed" => Some("Install OpenSSH for your platform.".to_string()),
"ssh-keygen installed" => {
let hint = if cfg!(target_os = "macos") {
"ssh-keygen is normally pre-installed on macOS. Check your PATH."
} else if cfg!(target_os = "windows") {
"Install OpenSSH via Settings > Apps > Optional features, or `winget install Microsoft.OpenSSH.Client`."
} else {
"Install OpenSSH: `sudo apt install openssh-client` (Debian/Ubuntu) or `sudo dnf install openssh-clients` (Fedora/RHEL)."
};
Some(hint.to_string())
}
"SSH version" => Some(
"Upgrade OpenSSH to 8.2+ for -Y find-principals support. Check with: ssh -V".to_string(),
),
"Git signing config" => Some("Run: auths doctor --fix".to_string()),
"Auths directory" => Some("Run: auths init --profile developer".to_string()),
"Allowed signers file" => Some("Run: auths doctor --fix".to_string()),
Expand Down
Loading
Loading