Skip to content

Commit 5836ef1

Browse files
authored
Merge pull request #159 from auths-dev/dev-cliHardening
Dev cli hardening
2 parents bea1b6a + 6138f3e commit 5836ef1

13 files changed

Lines changed: 293 additions & 25 deletions

File tree

.cargo/audit.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,4 @@ ignore = [
2525

2626
[yanked]
2727
# uds_windows 1.2.0 is yanked but is a transitive dep of zbus; no direct fix available.
28-
ignore = true
28+
enabled = false

crates/auths-cli/src/cli.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use crate::commands::commit::CommitCmd;
1414
use crate::commands::completions::CompletionsCommand;
1515
use crate::commands::config::ConfigCommand;
1616
use crate::commands::debug::DebugCmd;
17+
use crate::commands::demo::DemoCommand;
1718
use crate::commands::device::DeviceCommand;
1819
use crate::commands::device::pair::PairCommand;
1920
use crate::commands::doctor::DoctorCommand;
@@ -28,6 +29,7 @@ use crate::commands::log::LogCommand;
2829
use crate::commands::namespace::NamespaceCommand;
2930
use crate::commands::org::OrgCommand;
3031
use crate::commands::policy::PolicyCommand;
32+
use crate::commands::publish::PublishCommand;
3133
use crate::commands::reset::ResetCommand;
3234
use crate::commands::scim::ScimCommand;
3335
use crate::commands::sign::SignCommand;
@@ -87,11 +89,11 @@ pub enum RootCommand {
8789
Init(InitCommand),
8890
Sign(SignCommand),
8991
Verify(UnifiedVerifyCommand),
90-
Artifact(ArtifactCommand),
9192
Status(StatusCommand),
9293
Whoami(WhoamiCommand),
9394

9495
// ── Setup & Troubleshooting ──
96+
Demo(DemoCommand),
9597
Pair(PairCommand),
9698
Trust(TrustCommand),
9799
Doctor(DoctorCommand),
@@ -106,6 +108,10 @@ pub enum RootCommand {
106108

107109
// ── Advanced (visible via --help-all) ──
108110
#[command(hide = true)]
111+
Publish(PublishCommand),
112+
#[command(hide = true)]
113+
Artifact(ArtifactCommand),
114+
#[command(hide = true)]
109115
Reset(ResetCommand),
110116
#[command(hide = true)]
111117
SignCommit(SignCommitCommand),
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
use std::io::Write as _;
2+
use std::sync::Arc;
3+
use std::time::Instant;
4+
5+
use anyhow::{Context, Result, anyhow};
6+
use serde_json::Value;
7+
use tempfile::NamedTempFile;
8+
9+
use auths_sdk::domains::signing::service::{
10+
ArtifactSigningParams, SigningKeyMaterial, sign_artifact,
11+
};
12+
use auths_sdk::keychain::KeyAlias;
13+
14+
use crate::commands::artifact::file::FileArtifact;
15+
use crate::commands::executable::ExecutableCommand;
16+
use crate::commands::key_detect::auto_detect_device_key;
17+
use crate::config::CliConfig;
18+
use crate::factories::storage::build_auths_context;
19+
use crate::ux::format::Output;
20+
21+
#[derive(Debug, clap::Args)]
22+
#[command(about = "Sign and verify a demo artifact — works offline, no registry needed")]
23+
pub struct DemoCommand {}
24+
25+
impl ExecutableCommand for DemoCommand {
26+
fn execute(&self, ctx: &CliConfig) -> Result<()> {
27+
let out = Output::new();
28+
29+
// 1. Create a temp file with known content
30+
let mut tmp = NamedTempFile::new().context("failed to create temp file")?;
31+
writeln!(tmp, "Hello, Auths!").context("failed to write demo content")?;
32+
let path = tmp.path().to_path_buf();
33+
34+
// 2. Auto-detect the device key alias (errors out cleanly if identity missing)
35+
let device_key_alias = auto_detect_device_key(ctx.repo_path.as_deref(), &ctx.env_config)
36+
.context("No identity found — run `auths init` first")?;
37+
38+
// 3. Build SDK context
39+
let repo_path = auths_sdk::storage_layout::resolve_repo_path(ctx.repo_path.clone())?;
40+
let sdk_ctx = build_auths_context(
41+
&repo_path,
42+
&ctx.env_config,
43+
Some(ctx.passphrase_provider.clone()),
44+
)?;
45+
46+
// 4. Sign using SDK directly (no intermediate CLI output)
47+
let t_sign = Instant::now();
48+
let sign_result = sign_artifact(
49+
ArtifactSigningParams {
50+
artifact: Arc::new(FileArtifact::new(&path)),
51+
identity_key: None,
52+
device_key: SigningKeyMaterial::Alias(KeyAlias::new_unchecked(&device_key_alias)),
53+
expires_in: None,
54+
note: Some("auths demo — local only".into()),
55+
commit_sha: None,
56+
},
57+
&sdk_ctx,
58+
)
59+
.map_err(|e| anyhow!("{}", e))?;
60+
let sign_ms = t_sign.elapsed().as_millis();
61+
62+
// 5. Verify: parse attestation and confirm digest integrity (fully local)
63+
let t_verify = Instant::now();
64+
let attestation: Value = serde_json::from_str(&sign_result.attestation_json)
65+
.context("failed to parse attestation")?;
66+
let stored_digest = attestation
67+
.pointer("/payload/digest/hex")
68+
.and_then(|v| v.as_str())
69+
.context("attestation missing payload digest")?;
70+
if stored_digest != sign_result.digest {
71+
anyhow::bail!(
72+
"demo verification failed: digest mismatch\n expected: {}\n got: {}",
73+
sign_result.digest,
74+
stored_digest
75+
);
76+
}
77+
let verify_ms = t_verify.elapsed().as_millis();
78+
79+
// 6. Extract issuer DID from the attestation
80+
let issuer = attestation
81+
.pointer("/issuer")
82+
.and_then(|v| v.as_str())
83+
.unwrap_or("(unknown)");
84+
85+
// 7. Print result banner
86+
out.print_heading("Auths Demo");
87+
out.println("");
88+
out.key_value("Your identity", issuer);
89+
out.key_value("Signed in ", &format!("{}ms", sign_ms));
90+
out.key_value("Verified in ", &format!("{}ms", verify_ms));
91+
out.println("");
92+
out.print_success("No network required.");
93+
94+
Ok(())
95+
}
96+
}

crates/auths-cli/src/commands/init/display.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,12 @@ pub(crate) fn display_developer_result(
2828
));
2929
}
3030
out.newline();
31+
out.key_value("Next step ", "auths sign <file> or auths git setup");
32+
out.key_value("Share identity", "auths export --bundle | pbcopy");
33+
out.newline();
3134
out.print_success("Your next commit will be signed with Auths!");
3235
out.println(" Run `auths status` to check your identity");
36+
out.println(" Run `auths demo` to test sign + verify right now");
3337
}
3438

3539
pub(crate) fn display_ci_result(

crates/auths-cli/src/commands/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub mod commit;
1313
pub mod completions;
1414
pub mod config;
1515
pub mod debug;
16+
pub mod demo;
1617
pub mod device;
1718
pub mod doctor;
1819
pub mod emergency;
@@ -30,6 +31,7 @@ pub mod namespace;
3031
pub mod org;
3132
pub mod policy;
3233
pub mod provision;
34+
pub mod publish;
3335
pub mod reset;
3436
pub mod scim;
3537
pub mod sign;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
use anyhow::Result;
2+
use std::path::PathBuf;
3+
4+
use crate::commands::artifact::publish::handle_publish;
5+
use crate::commands::executable::ExecutableCommand;
6+
use crate::config::CliConfig;
7+
8+
/// Top-level `auths publish` command: sign and publish a signed artifact attestation.
9+
#[derive(Debug, clap::Args)]
10+
#[command(
11+
about = "Publish a signed artifact attestation to the Auths registry.",
12+
after_help = "Examples:
13+
auths publish package.tar.gz # Sign and publish
14+
auths publish --signature package.tar.gz.auths.json # Publish existing signature
15+
auths publish package.tar.gz --package npm:react@18.3.0
16+
17+
Related:
18+
auths sign — Sign an artifact without publishing
19+
auths verify — Verify a signed artifact"
20+
)]
21+
pub struct PublishCommand {
22+
/// Artifact file to sign and publish. Omit if providing --signature directly.
23+
#[arg(help = "Artifact file to sign and publish.")]
24+
pub file: Option<PathBuf>,
25+
26+
/// Path to an existing .auths.json signature file. Defaults to <FILE>.auths.json.
27+
#[arg(long, value_name = "PATH")]
28+
pub signature: Option<PathBuf>,
29+
30+
/// Package identifier for registry indexing (e.g., npm:react@18.3.0).
31+
#[arg(long)]
32+
pub package: Option<String>,
33+
34+
/// Registry URL to publish to.
35+
#[arg(long, default_value = "https://auths-registry.fly.dev")]
36+
pub registry: String,
37+
}
38+
39+
impl ExecutableCommand for PublishCommand {
40+
fn execute(&self, ctx: &CliConfig) -> Result<()> {
41+
let sig_path = match (&self.signature, &self.file) {
42+
(Some(sig), _) => sig.clone(),
43+
(None, Some(file)) => {
44+
let mut p = file.clone();
45+
p.set_file_name(format!(
46+
"{}.auths.json",
47+
p.file_name().unwrap_or_default().to_string_lossy()
48+
));
49+
if !p.exists() {
50+
crate::commands::artifact::sign::handle_sign(
51+
file,
52+
None,
53+
None,
54+
&crate::commands::key_detect::auto_detect_device_key(
55+
ctx.repo_path.as_deref(),
56+
&ctx.env_config,
57+
)?,
58+
None,
59+
None,
60+
crate::commands::git_helpers::resolve_head_silent(),
61+
ctx.repo_path.clone(),
62+
ctx.passphrase_provider.clone(),
63+
&ctx.env_config,
64+
)?;
65+
}
66+
p
67+
}
68+
(None, None) => anyhow::bail!("Provide an artifact file or --signature path"),
69+
};
70+
71+
handle_publish(&sig_path, self.package.as_deref(), &self.registry)
72+
}
73+
}

crates/auths-cli/src/commands/unified_verify.rs

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,25 @@ use clap::Parser;
55
use std::path::{Path, PathBuf};
66

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

1011
/// What kind of target the user provided.
1112
pub enum VerifyTarget {
1213
GitRef(String),
1314
Attestation(String),
15+
ArtifactFile(PathBuf), // binary artifact, will look up .auths.json sidecar
1416
}
1517

1618
/// Determine whether `raw_target` is a Git reference or an attestation path.
1719
///
1820
/// Rules (evaluated in order):
1921
/// 1. "-" → stdin attestation
20-
/// 2. Path exists on disk → attestation file
21-
/// 3. Contains ".." (range notation) → git ref
22-
/// 4. Is "HEAD" or matches ^[0-9a-f]{4,40}$ → git ref
23-
/// 5. Otherwise → git ref (assume the user knows what they're typing)
22+
/// 2. Path exists on disk and is JSON → attestation file
23+
/// 3. Path exists on disk and is not JSON → artifact file (sidecar lookup)
24+
/// 4. Contains ".." (range notation) → git ref
25+
/// 5. Is "HEAD" or matches ^[0-9a-f]{4,40}$ → git ref
26+
/// 6. Otherwise → git ref (assume the user knows what they're typing)
2427
///
2528
/// Args:
2629
/// * `raw_target` - Raw CLI input string.
@@ -36,7 +39,11 @@ pub fn parse_verify_target(raw_target: &str) -> VerifyTarget {
3639
}
3740
let path = Path::new(raw_target);
3841
if path.exists() {
39-
return VerifyTarget::Attestation(raw_target.to_string());
42+
if is_attestation_path(raw_target) {
43+
return VerifyTarget::Attestation(raw_target.to_string());
44+
} else {
45+
return VerifyTarget::ArtifactFile(path.to_path_buf());
46+
}
4047
}
4148
if raw_target.contains("..") {
4249
return VerifyTarget::GitRef(raw_target.to_string());
@@ -57,28 +64,31 @@ pub fn parse_verify_target(raw_target: &str) -> VerifyTarget {
5764
VerifyTarget::GitRef(raw_target.to_string())
5865
}
5966

67+
/// Returns true if the path looks like an attestation/JSON file rather than a binary artifact.
68+
fn is_attestation_path(path: &str) -> bool {
69+
let lower = path.to_lowercase();
70+
lower.ends_with(".json")
71+
}
72+
6073
/// Unified verify command: verifies a signed commit or an attestation.
6174
#[derive(Parser, Debug, Clone)]
6275
#[command(
6376
about = "Verify a signed commit or attestation.",
6477
after_help = "Examples:
6578
auths verify HEAD # Verify current commit signature
6679
auths verify main..HEAD # Verify range of commits
67-
auths verify artifact.json # Verify signed artifact
80+
auths verify release.tar.gz # Verify artifact (finds .auths.json sidecar)
81+
auths verify release.tar.gz.auths.json # Verify attestation file directly
6882
auths verify - < artifact.json # Verify from stdin
6983
70-
Trust Policies:
71-
Defaults to TOFU (Trust-On-First-Use) on interactive terminals.
72-
Use --trust explicit in CI/CD to reject unknown identities.
73-
7484
Artifact Verification:
75-
File signatures are stored as <file>.auths.json.
76-
JSON attestations can be verified directly.
85+
Pass the artifact file directly — auths finds <file>.auths.json automatically.
86+
Pass --signature to override the default sidecar path.
7787
7888
Related:
79-
auths trust add <did> — Add an identity to your trust store
80-
auths sign — Create signatures
81-
auths --help-all — See all commands"
89+
auths sign — Create signatures
90+
auths publish — Sign and publish to registry
91+
auths trust — Manage trusted identities"
8292
)]
8393
pub struct UnifiedVerifyCommand {
8494
/// Git ref, commit hash, range (e.g. HEAD, abc1234, main..HEAD),
@@ -113,6 +123,11 @@ pub struct UnifiedVerifyCommand {
113123
/// Witness public keys as DID:hex pairs.
114124
#[arg(long, num_args = 1..)]
115125
pub witness_keys: Vec<String>,
126+
127+
/// Path to signature file. Only used when verifying an artifact file (not a commit).
128+
/// Defaults to <FILE>.auths.json.
129+
#[arg(long, value_name = "PATH")]
130+
pub signature: Option<PathBuf>,
116131
}
117132

118133
/// Handle the unified verify command.
@@ -148,6 +163,18 @@ pub async fn handle_verify_unified(cmd: UnifiedVerifyCommand) -> Result<()> {
148163
};
149164
handle_verify(verify_cmd).await
150165
}
166+
VerifyTarget::ArtifactFile(artifact_path) => {
167+
handle_artifact_verify(
168+
&artifact_path,
169+
cmd.signature,
170+
cmd.identity_bundle,
171+
cmd.witness_receipts,
172+
&cmd.witness_keys,
173+
cmd.witness_threshold,
174+
false,
175+
)
176+
.await
177+
}
151178
}
152179
}
153180

@@ -202,4 +229,37 @@ mod tests {
202229
let target = parse_verify_target(f.to_str().unwrap());
203230
assert!(matches!(target, VerifyTarget::Attestation(_)));
204231
}
232+
233+
#[test]
234+
fn test_parse_verify_target_binary_file_routes_to_artifact() {
235+
use std::fs::File;
236+
use tempfile::tempdir;
237+
let dir = tempdir().unwrap();
238+
let artifact = dir.path().join("release.tar.gz");
239+
File::create(&artifact).unwrap();
240+
let target = parse_verify_target(artifact.to_str().unwrap());
241+
assert!(matches!(target, VerifyTarget::ArtifactFile(_)));
242+
}
243+
244+
#[test]
245+
fn test_parse_verify_target_json_file_routes_to_attestation() {
246+
use std::fs::File;
247+
use tempfile::tempdir;
248+
let dir = tempdir().unwrap();
249+
let attest = dir.path().join("release.auths.json");
250+
File::create(&attest).unwrap();
251+
let target = parse_verify_target(attest.to_str().unwrap());
252+
assert!(matches!(target, VerifyTarget::Attestation(_)));
253+
}
254+
255+
#[test]
256+
fn test_parse_verify_target_plain_json_routes_to_attestation() {
257+
use std::fs::File;
258+
use tempfile::tempdir;
259+
let dir = tempdir().unwrap();
260+
let f = dir.path().join("attestation.json");
261+
File::create(&f).unwrap();
262+
let target = parse_verify_target(f.to_str().unwrap());
263+
assert!(matches!(target, VerifyTarget::Attestation(_)));
264+
}
205265
}

0 commit comments

Comments
 (0)