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
2 changes: 1 addition & 1 deletion .cargo/audit.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ ignore = [

[yanked]
# uds_windows 1.2.0 is yanked but is a transitive dep of zbus; no direct fix available.
ignore = true
enabled = false
8 changes: 7 additions & 1 deletion crates/auths-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use crate::commands::commit::CommitCmd;
use crate::commands::completions::CompletionsCommand;
use crate::commands::config::ConfigCommand;
use crate::commands::debug::DebugCmd;
use crate::commands::demo::DemoCommand;
use crate::commands::device::DeviceCommand;
use crate::commands::device::pair::PairCommand;
use crate::commands::doctor::DoctorCommand;
Expand All @@ -28,6 +29,7 @@ use crate::commands::log::LogCommand;
use crate::commands::namespace::NamespaceCommand;
use crate::commands::org::OrgCommand;
use crate::commands::policy::PolicyCommand;
use crate::commands::publish::PublishCommand;
use crate::commands::reset::ResetCommand;
use crate::commands::scim::ScimCommand;
use crate::commands::sign::SignCommand;
Expand Down Expand Up @@ -87,11 +89,11 @@ pub enum RootCommand {
Init(InitCommand),
Sign(SignCommand),
Verify(UnifiedVerifyCommand),
Artifact(ArtifactCommand),
Status(StatusCommand),
Whoami(WhoamiCommand),

// ── Setup & Troubleshooting ──
Demo(DemoCommand),
Pair(PairCommand),
Trust(TrustCommand),
Doctor(DoctorCommand),
Expand All @@ -106,6 +108,10 @@ pub enum RootCommand {

// ── Advanced (visible via --help-all) ──
#[command(hide = true)]
Publish(PublishCommand),
#[command(hide = true)]
Artifact(ArtifactCommand),
#[command(hide = true)]
Reset(ResetCommand),
#[command(hide = true)]
SignCommit(SignCommitCommand),
Expand Down
96 changes: 96 additions & 0 deletions crates/auths-cli/src/commands/demo.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
use std::io::Write as _;
use std::sync::Arc;
use std::time::Instant;

use anyhow::{Context, Result, anyhow};
use serde_json::Value;
use tempfile::NamedTempFile;

use auths_sdk::domains::signing::service::{
ArtifactSigningParams, SigningKeyMaterial, sign_artifact,
};
use auths_sdk::keychain::KeyAlias;

use crate::commands::artifact::file::FileArtifact;
use crate::commands::executable::ExecutableCommand;
use crate::commands::key_detect::auto_detect_device_key;
use crate::config::CliConfig;
use crate::factories::storage::build_auths_context;
use crate::ux::format::Output;

#[derive(Debug, clap::Args)]
#[command(about = "Sign and verify a demo artifact — works offline, no registry needed")]
pub struct DemoCommand {}

impl ExecutableCommand for DemoCommand {
fn execute(&self, ctx: &CliConfig) -> Result<()> {
let out = Output::new();

// 1. Create a temp file with known content
let mut tmp = NamedTempFile::new().context("failed to create temp file")?;
writeln!(tmp, "Hello, Auths!").context("failed to write demo content")?;
let path = tmp.path().to_path_buf();

// 2. Auto-detect the device key alias (errors out cleanly if identity missing)
let device_key_alias = auto_detect_device_key(ctx.repo_path.as_deref(), &ctx.env_config)
.context("No identity found — run `auths init` first")?;

// 3. Build SDK context
let repo_path = auths_sdk::storage_layout::resolve_repo_path(ctx.repo_path.clone())?;
let sdk_ctx = build_auths_context(
&repo_path,
&ctx.env_config,
Some(ctx.passphrase_provider.clone()),
)?;

// 4. Sign using SDK directly (no intermediate CLI output)
let t_sign = Instant::now();
let sign_result = sign_artifact(
ArtifactSigningParams {
artifact: Arc::new(FileArtifact::new(&path)),
identity_key: None,
device_key: SigningKeyMaterial::Alias(KeyAlias::new_unchecked(&device_key_alias)),
expires_in: None,
note: Some("auths demo — local only".into()),
commit_sha: None,
},
&sdk_ctx,
)
.map_err(|e| anyhow!("{}", e))?;
let sign_ms = t_sign.elapsed().as_millis();

// 5. Verify: parse attestation and confirm digest integrity (fully local)
let t_verify = Instant::now();
let attestation: Value = serde_json::from_str(&sign_result.attestation_json)
.context("failed to parse attestation")?;
let stored_digest = attestation
.pointer("/payload/digest/hex")
.and_then(|v| v.as_str())
.context("attestation missing payload digest")?;
if stored_digest != sign_result.digest {
anyhow::bail!(
"demo verification failed: digest mismatch\n expected: {}\n got: {}",
sign_result.digest,
stored_digest
);
}
let verify_ms = t_verify.elapsed().as_millis();

// 6. Extract issuer DID from the attestation
let issuer = attestation
.pointer("/issuer")
.and_then(|v| v.as_str())
.unwrap_or("(unknown)");

// 7. Print result banner
out.print_heading("Auths Demo");
out.println("");
out.key_value("Your identity", issuer);
out.key_value("Signed in ", &format!("{}ms", sign_ms));
out.key_value("Verified in ", &format!("{}ms", verify_ms));
out.println("");
out.print_success("No network required.");

Ok(())
}
}
4 changes: 4 additions & 0 deletions crates/auths-cli/src/commands/init/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,12 @@ pub(crate) fn display_developer_result(
));
}
out.newline();
out.key_value("Next step ", "auths sign <file> or auths git setup");
out.key_value("Share identity", "auths export --bundle | pbcopy");
out.newline();
out.print_success("Your next commit will be signed with Auths!");
out.println(" Run `auths status` to check your identity");
out.println(" Run `auths demo` to test sign + verify right now");
}

pub(crate) fn display_ci_result(
Expand Down
2 changes: 2 additions & 0 deletions crates/auths-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub mod commit;
pub mod completions;
pub mod config;
pub mod debug;
pub mod demo;
pub mod device;
pub mod doctor;
pub mod emergency;
Expand All @@ -30,6 +31,7 @@ pub mod namespace;
pub mod org;
pub mod policy;
pub mod provision;
pub mod publish;
pub mod reset;
pub mod scim;
pub mod sign;
Expand Down
73 changes: 73 additions & 0 deletions crates/auths-cli/src/commands/publish.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
use anyhow::Result;
use std::path::PathBuf;

use crate::commands::artifact::publish::handle_publish;
use crate::commands::executable::ExecutableCommand;
use crate::config::CliConfig;

/// Top-level `auths publish` command: sign and publish a signed artifact attestation.
#[derive(Debug, clap::Args)]
#[command(
about = "Publish a signed artifact attestation to the Auths registry.",
after_help = "Examples:
auths publish package.tar.gz # Sign and publish
auths publish --signature package.tar.gz.auths.json # Publish existing signature
auths publish package.tar.gz --package npm:react@18.3.0

Related:
auths sign — Sign an artifact without publishing
auths verify — Verify a signed artifact"
)]
pub struct PublishCommand {
/// Artifact file to sign and publish. Omit if providing --signature directly.
#[arg(help = "Artifact file to sign and publish.")]
pub file: Option<PathBuf>,

/// Path to an existing .auths.json signature file. Defaults to <FILE>.auths.json.
#[arg(long, value_name = "PATH")]
pub signature: Option<PathBuf>,

/// Package identifier for registry indexing (e.g., npm:react@18.3.0).
#[arg(long)]
pub package: Option<String>,

/// Registry URL to publish to.
#[arg(long, default_value = "https://auths-registry.fly.dev")]
pub registry: String,
}

impl ExecutableCommand for PublishCommand {
fn execute(&self, ctx: &CliConfig) -> Result<()> {
let sig_path = match (&self.signature, &self.file) {
(Some(sig), _) => sig.clone(),
(None, Some(file)) => {
let mut p = file.clone();
p.set_file_name(format!(
"{}.auths.json",
p.file_name().unwrap_or_default().to_string_lossy()
));
if !p.exists() {
crate::commands::artifact::sign::handle_sign(
file,
None,
None,
&crate::commands::key_detect::auto_detect_device_key(
ctx.repo_path.as_deref(),
&ctx.env_config,
)?,
None,
None,
crate::commands::git_helpers::resolve_head_silent(),
ctx.repo_path.clone(),
ctx.passphrase_provider.clone(),
&ctx.env_config,
)?;
}
p
}
(None, None) => anyhow::bail!("Provide an artifact file or --signature path"),
};

handle_publish(&sig_path, self.package.as_deref(), &self.registry)
}
}
90 changes: 75 additions & 15 deletions crates/auths-cli/src/commands/unified_verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,25 @@ use clap::Parser;
use std::path::{Path, PathBuf};

use super::verify_commit::{VerifyCommitCommand, handle_verify_commit};
use crate::commands::artifact::verify::handle_verify as handle_artifact_verify;
use crate::commands::device::verify_attestation::{VerifyCommand, handle_verify};

/// What kind of target the user provided.
pub enum VerifyTarget {
GitRef(String),
Attestation(String),
ArtifactFile(PathBuf), // binary artifact, will look up .auths.json sidecar
}

/// Determine whether `raw_target` is a Git reference or an attestation path.
///
/// Rules (evaluated in order):
/// 1. "-" → stdin attestation
/// 2. Path exists on disk → attestation file
/// 3. Contains ".." (range notation) → git ref
/// 4. Is "HEAD" or matches ^[0-9a-f]{4,40}$ → git ref
/// 5. Otherwise → git ref (assume the user knows what they're typing)
/// 2. Path exists on disk and is JSON → attestation file
/// 3. Path exists on disk and is not JSON → artifact file (sidecar lookup)
/// 4. Contains ".." (range notation) → git ref
/// 5. Is "HEAD" or matches ^[0-9a-f]{4,40}$ → git ref
/// 6. Otherwise → git ref (assume the user knows what they're typing)
///
/// Args:
/// * `raw_target` - Raw CLI input string.
Expand All @@ -36,7 +39,11 @@ pub fn parse_verify_target(raw_target: &str) -> VerifyTarget {
}
let path = Path::new(raw_target);
if path.exists() {
return VerifyTarget::Attestation(raw_target.to_string());
if is_attestation_path(raw_target) {
return VerifyTarget::Attestation(raw_target.to_string());
} else {
return VerifyTarget::ArtifactFile(path.to_path_buf());
}
}
if raw_target.contains("..") {
return VerifyTarget::GitRef(raw_target.to_string());
Expand All @@ -57,28 +64,31 @@ pub fn parse_verify_target(raw_target: &str) -> VerifyTarget {
VerifyTarget::GitRef(raw_target.to_string())
}

/// Returns true if the path looks like an attestation/JSON file rather than a binary artifact.
fn is_attestation_path(path: &str) -> bool {
let lower = path.to_lowercase();
lower.ends_with(".json")
}

/// Unified verify command: verifies a signed commit or an attestation.
#[derive(Parser, Debug, Clone)]
#[command(
about = "Verify a signed commit or attestation.",
after_help = "Examples:
auths verify HEAD # Verify current commit signature
auths verify main..HEAD # Verify range of commits
auths verify artifact.json # Verify signed artifact
auths verify release.tar.gz # Verify artifact (finds .auths.json sidecar)
auths verify release.tar.gz.auths.json # Verify attestation file directly
auths verify - < artifact.json # Verify from stdin

Trust Policies:
Defaults to TOFU (Trust-On-First-Use) on interactive terminals.
Use --trust explicit in CI/CD to reject unknown identities.

Artifact Verification:
File signatures are stored as <file>.auths.json.
JSON attestations can be verified directly.
Pass the artifact file directly — auths finds <file>.auths.json automatically.
Pass --signature to override the default sidecar path.

Related:
auths trust add <did> — Add an identity to your trust store
auths sign — Create signatures
auths --help-all — See all commands"
auths sign — Create signatures
auths publish — Sign and publish to registry
auths trust — Manage trusted identities"
)]
pub struct UnifiedVerifyCommand {
/// Git ref, commit hash, range (e.g. HEAD, abc1234, main..HEAD),
Expand Down Expand Up @@ -113,6 +123,11 @@ pub struct UnifiedVerifyCommand {
/// Witness public keys as DID:hex pairs.
#[arg(long, num_args = 1..)]
pub witness_keys: Vec<String>,

/// Path to signature file. Only used when verifying an artifact file (not a commit).
/// Defaults to <FILE>.auths.json.
#[arg(long, value_name = "PATH")]
pub signature: Option<PathBuf>,
}

/// Handle the unified verify command.
Expand Down Expand Up @@ -148,6 +163,18 @@ pub async fn handle_verify_unified(cmd: UnifiedVerifyCommand) -> Result<()> {
};
handle_verify(verify_cmd).await
}
VerifyTarget::ArtifactFile(artifact_path) => {
handle_artifact_verify(
&artifact_path,
cmd.signature,
cmd.identity_bundle,
cmd.witness_receipts,
&cmd.witness_keys,
cmd.witness_threshold,
false,
)
.await
}
}
}

Expand Down Expand Up @@ -202,4 +229,37 @@ mod tests {
let target = parse_verify_target(f.to_str().unwrap());
assert!(matches!(target, VerifyTarget::Attestation(_)));
}

#[test]
fn test_parse_verify_target_binary_file_routes_to_artifact() {
use std::fs::File;
use tempfile::tempdir;
let dir = tempdir().unwrap();
let artifact = dir.path().join("release.tar.gz");
File::create(&artifact).unwrap();
let target = parse_verify_target(artifact.to_str().unwrap());
assert!(matches!(target, VerifyTarget::ArtifactFile(_)));
}

#[test]
fn test_parse_verify_target_json_file_routes_to_attestation() {
use std::fs::File;
use tempfile::tempdir;
let dir = tempdir().unwrap();
let attest = dir.path().join("release.auths.json");
File::create(&attest).unwrap();
let target = parse_verify_target(attest.to_str().unwrap());
assert!(matches!(target, VerifyTarget::Attestation(_)));
}

#[test]
fn test_parse_verify_target_plain_json_routes_to_attestation() {
use std::fs::File;
use tempfile::tempdir;
let dir = tempdir().unwrap();
let f = dir.path().join("attestation.json");
File::create(&f).unwrap();
let target = parse_verify_target(f.to_str().unwrap());
assert!(matches!(target, VerifyTarget::Attestation(_)));
}
}
Loading
Loading