From ee4b37fe936c9176a8de9d97f38d3885a8153ddf Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 4 Apr 2026 02:30:39 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20CLI=20error=20message=20quality=20sweep?= =?UTF-8?q?=20=E2=80=94=20git=20subprocess=20LC=5FALL=3DC=20helper,=20expa?= =?UTF-8?q?nded=20renderer=20coverage,=20actionable=20error=20messages,=20?= =?UTF-8?q?and=20jargon=20reduction=20across=20pairing/trust/key/policy=20?= =?UTF-8?q?commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/auths-cli/README.md | 36 +++ crates/auths-cli/src/adapters/doctor_fixes.rs | 4 +- crates/auths-cli/src/adapters/git_config.rs | 6 +- .../src/adapters/system_diagnostic.rs | 56 +++- crates/auths-cli/src/cli.rs | 6 +- crates/auths-cli/src/commands/artifact/mod.rs | 2 +- .../auths-cli/src/commands/artifact/verify.rs | 8 +- crates/auths-cli/src/commands/auth.rs | 2 +- .../src/commands/device/authorization.rs | 4 +- .../src/commands/device/pair/common.rs | 4 +- .../src/commands/device/pair/join.rs | 15 +- .../auths-cli/src/commands/device/pair/lan.rs | 7 +- .../src/commands/device/pair/offline.rs | 3 +- .../src/commands/device/pair/online.rs | 9 +- crates/auths-cli/src/commands/doctor.rs | 14 +- crates/auths-cli/src/commands/git_helpers.rs | 34 ++- crates/auths-cli/src/commands/id/claim.rs | 8 +- crates/auths-cli/src/commands/id/identity.rs | 10 +- crates/auths-cli/src/commands/id/migrate.rs | 24 +- crates/auths-cli/src/commands/init/helpers.rs | 14 +- crates/auths-cli/src/commands/init/mod.rs | 4 +- crates/auths-cli/src/commands/key.rs | 10 +- crates/auths-cli/src/commands/learn.rs | 21 +- crates/auths-cli/src/commands/org.rs | 2 +- crates/auths-cli/src/commands/policy.rs | 2 +- crates/auths-cli/src/commands/provision.rs | 2 +- crates/auths-cli/src/commands/sign.rs | 52 +++- crates/auths-cli/src/commands/sign_commit.rs | 28 +- crates/auths-cli/src/commands/signers.rs | 40 +-- crates/auths-cli/src/commands/trust.rs | 16 +- .../auths-cli/src/commands/verify_commit.rs | 76 +++-- .../auths-cli/src/commands/verify_helpers.rs | 2 +- crates/auths-cli/src/errors/renderer.rs | 29 +- crates/auths-cli/src/lib.rs | 1 + crates/auths-cli/src/main.rs | 31 +- crates/auths-cli/src/subprocess.rs | 117 ++++++++ .../auths-sdk/src/domains/diagnostics/mod.rs | 1 - .../src/domains/diagnostics/service.rs | 270 ------------------ crates/auths-sdk/src/ports/diagnostics.rs | 10 + .../src/testing/fakes/diagnostics.rs | 19 +- crates/auths-sdk/src/workflows/diagnostics.rs | 127 ++++++++ packages/auths-node/lib/index.ts | 6 +- packages/auths-node/lib/native.ts | 7 + packages/auths-node/lib/testing.ts | 66 +++++ packages/auths-node/src/identity.rs | 38 ++- packages/auths-node/src/types.rs | 8 + .../auths-python/python/auths/__init__.py | 1 + .../auths-python/python/auths/__init__.pyi | 17 ++ packages/auths-python/python/auths/testing.py | 60 ++++ packages/auths-python/src/identity.rs | 39 +++ packages/auths-python/src/lib.rs | 1 + 51 files changed, 885 insertions(+), 484 deletions(-) create mode 100644 crates/auths-cli/src/subprocess.rs delete mode 100644 crates/auths-sdk/src/domains/diagnostics/service.rs create mode 100644 packages/auths-node/lib/testing.ts create mode 100644 packages/auths-python/python/auths/testing.py diff --git a/crates/auths-cli/README.md b/crates/auths-cli/README.md index dc896105..55efc7d9 100644 --- a/crates/auths-cli/README.md +++ b/crates/auths-cli/README.md @@ -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: diff --git a/crates/auths-cli/src/adapters/doctor_fixes.rs b/crates/auths-cli/src/adapters/doctor_fixes.rs index 6b768868..4cbfa429 100644 --- a/crates/auths-cli/src/adapters/doctor_fixes.rs +++ b/crates/auths-cli/src/adapters/doctor_fixes.rs @@ -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; @@ -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() { diff --git a/crates/auths-cli/src/adapters/git_config.rs b/crates/auths-cli/src/adapters/git_config.rs index 56587f4e..5d032acf 100644 --- a/crates/auths-cli/src/adapters/git_config.rs +++ b/crates/auths-cli/src/adapters/git_config.rs @@ -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); } @@ -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); } diff --git a/crates/auths-cli/src/adapters/system_diagnostic.rs b/crates/auths-cli/src/adapters/system_diagnostic.rs index 23e6061a..a7af5d69 100644 --- a/crates/auths-cli/src/adapters/system_diagnostic.rs +++ b/crates/auths-cli/src/adapters/system_diagnostic.rs @@ -10,7 +10,7 @@ pub struct PosixDiagnosticAdapter; impl GitDiagnosticProvider for PosixDiagnosticAdapter { fn check_git_version(&self) -> Result { - 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(); @@ -28,8 +28,7 @@ impl GitDiagnosticProvider for PosixDiagnosticAdapter { } fn get_git_config(&self, key: &str) -> Result, 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()))?; @@ -45,13 +44,21 @@ impl GitDiagnosticProvider for PosixDiagnosticAdapter { impl CryptoDiagnosticProvider for PosixDiagnosticAdapter { fn check_ssh_keygen_available(&self) -> Result { - 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(), @@ -61,4 +68,35 @@ impl CryptoDiagnosticProvider for PosixDiagnosticAdapter { category: CheckCategory::Advisory, }) } + + fn check_ssh_version(&self) -> Result { + // `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)." + } } diff --git a/crates/auths-cli/src/cli.rs b/crates/auths-cli/src/cli.rs index 0e2848e9..6fc9a8f1 100644 --- a/crates/auths-cli/src/cli.rs +++ b/crates/auths-cli/src/cli.rs @@ -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), @@ -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), diff --git a/crates/auths-cli/src/commands/artifact/mod.rs b/crates/auths-cli/src/commands/artifact/mod.rs index 1631662c..89aa8a48 100644 --- a/crates/auths-cli/src/commands/artifact/mod.rs +++ b/crates/auths-cli/src/commands/artifact/mod.rs @@ -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()) diff --git a/crates/auths-cli/src/commands/artifact/verify.rs b/crates/auths-cli/src/commands/artifact/verify.rs index 24d2d1cd..6996d47e 100644 --- a/crates/auths-cli/src/commands/artifact/verify.rs +++ b/crates/auths-cli/src/commands/artifact/verify.rs @@ -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() { diff --git a/crates/auths-cli/src/commands/auth.rs b/crates/auths-cli/src/commands/auth.rs index adf957a7..611fc498 100644 --- a/crates/auths-cli/src/commands/auth.rs +++ b/crates/auths-cli/src/commands/auth.rs @@ -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); diff --git a/crates/auths-cli/src/commands/device/authorization.rs b/crates/auths-cli/src/commands/device/authorization.rs index bce9fcc0..922a4bc5 100644 --- a/crates/auths-cli/src/commands/device/authorization.rs +++ b/crates/auths-cli/src/commands/device/authorization.rs @@ -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"); @@ -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); diff --git a/crates/auths-cli/src/commands/device/pair/common.rs b/crates/auths-cli/src/commands/device/pair/common.rs index fb6baac1..06350dc1 100644 --- a/crates/auths-cli/src/commands/device/pair/common.rs +++ b/crates/auths-cli/src/commands/device/pair/common.rs @@ -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!( @@ -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 { diff --git a/crates/auths-cli/src/commands/device/pair/join.rs b/crates/auths-cli/src/commands/device/pair/join.rs index 0e0ca9b4..8071df6b 100644 --- a/crates/auths-cli/src/commands/device/pair/join.rs +++ b/crates/auths-cli/src/commands/device/pair/join.rs @@ -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..]); @@ -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."); @@ -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")); @@ -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...")); @@ -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 diff --git a/crates/auths-cli/src/commands/device/pair/lan.rs b/crates/auths-cli/src/commands/device/pair/lan.rs index dacae6d3..7b5702f7 100644 --- a/crates/auths-cli/src/commands/device/pair/lan.rs +++ b/crates/auths-cli/src/commands/device/pair/lan.rs @@ -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 = diff --git a/crates/auths-cli/src/commands/device/pair/offline.rs b/crates/auths-cli/src/commands/device/pair/offline.rs index 2a227cf2..4f46bb60 100644 --- a/crates/auths-cli/src/commands/device/pair/offline.rs +++ b/crates/auths-cli/src/commands/device/pair/offline.rs @@ -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()); diff --git a/crates/auths-cli/src/commands/device/pair/online.rs b/crates/auths-cli/src/commands/device/pair/online.rs index af6e93bd..c99e7180 100644 --- a/crates/auths-cli/src/commands/device/pair/online.rs +++ b/crates/auths-cli/src/commands/device/pair/online.rs @@ -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); @@ -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, diff --git a/crates/auths-cli/src/commands/doctor.rs b/crates/auths-cli/src/commands/doctor.rs index 941aa1d9..50ca256e 100644 --- a/crates/auths-cli/src/commands/doctor.rs +++ b/crates/auths-cli/src/commands/doctor.rs @@ -309,7 +309,19 @@ fn suggestion_for_check(name: &str) -> Option { "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()), diff --git a/crates/auths-cli/src/commands/git_helpers.rs b/crates/auths-cli/src/commands/git_helpers.rs index f9421aee..a4d61b83 100644 --- a/crates/auths-cli/src/commands/git_helpers.rs +++ b/crates/auths-cli/src/commands/git_helpers.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result, anyhow}; -use std::process::Command; + +use crate::subprocess::{git_command, git_silent}; /// Resolve a git ref to a full commit SHA. /// @@ -12,13 +13,29 @@ use std::process::Command; /// let sha = resolve_commit_sha("v1.0.0")?; /// ``` pub fn resolve_commit_sha(commit_ref: &str) -> Result { - let output = Command::new("git") - .args(["rev-parse", commit_ref]) + let output = git_command(&["rev-parse", commit_ref]) .output() .context("Failed to resolve commit reference")?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let lower = stderr.to_lowercase(); + + if lower.contains("unknown revision") || lower.contains("bad revision") { + let hint = if commit_ref.contains('~') || commit_ref.contains('^') { + "This repository may not have enough commits. \ + Try `git log --oneline` to see available history." + } else { + "Verify the ref exists with `git branch -a` or `git tag -l`." + }; + return Err(anyhow!( + "Cannot resolve '{}': {}\n\nHint: {}", + commit_ref, + stderr.trim(), + hint + )); + } + return Err(anyhow!( "Invalid commit reference '{}': {}", commit_ref, @@ -38,12 +55,5 @@ pub fn resolve_commit_sha(commit_ref: &str) -> Result { /// let sha = resolve_head_silent(); // Some("abc123...") or None /// ``` pub fn resolve_head_silent() -> Option { - Command::new("git") - .args(["rev-parse", "--verify", "--quiet", "HEAD"]) - .output() - .ok() - .filter(|o| o.status.success()) - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) + git_silent(&["rev-parse", "--verify", "--quiet", "HEAD"]) } diff --git a/crates/auths-cli/src/commands/id/claim.rs b/crates/auths-cli/src/commands/id/claim.rs index dc718f97..b2c9ea0f 100644 --- a/crates/auths-cli/src/commands/id/claim.rs +++ b/crates/auths-cli/src/commands/id/claim.rs @@ -117,7 +117,7 @@ pub fn handle_claim( now, &on_device_code, )) - .map_err(|e| anyhow::anyhow!("{}", e))?; + .map_err(anyhow::Error::from)?; print_response(&response.message)?; } @@ -146,7 +146,7 @@ pub fn handle_claim( let profile = rt .block_on(npm_provider.verify_token(npm_token.trim())) - .map_err(|e| anyhow::anyhow!("{}", e))?; + .map_err(anyhow::Error::from)?; println!( " {} Authenticated as {}", @@ -165,7 +165,7 @@ pub fn handle_claim( config, now, )) - .map_err(|e| anyhow::anyhow!("{}", e))?; + .map_err(anyhow::Error::from)?; print_response(&response.message)?; } @@ -214,7 +214,7 @@ pub fn handle_claim( config, now, )) - .map_err(|e| anyhow::anyhow!("{}", e))?; + .map_err(anyhow::Error::from)?; print_response(&response.message)?; } diff --git a/crates/auths-cli/src/commands/id/identity.rs b/crates/auths-cli/src/commands/id/identity.rs index 47e5d689..ee82316b 100644 --- a/crates/auths-cli/src/commands/id/identity.rs +++ b/crates/auths-cli/src/commands/id/identity.rs @@ -245,7 +245,7 @@ fn display_dry_run_rotate( }), ) .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"); @@ -721,7 +721,7 @@ pub fn handle_id( let device_code = rt .block_on(oauth.request_device_code(&client_id, GITHUB_SSH_UPLOAD_SCOPES)) - .map_err(|e| anyhow::anyhow!("{e}"))?; + .map_err(anyhow::Error::from)?; out.println(&format!( " Enter this code: {}", @@ -748,18 +748,18 @@ pub fn handle_id( interval, expires_in, )) - .map_err(|e| anyhow::anyhow!("{e}"))?; + .map_err(anyhow::Error::from)?; let profile = rt .block_on(oauth.fetch_user_profile(&access_token)) - .map_err(|e| anyhow::anyhow!("{e}"))?; + .map_err(anyhow::Error::from)?; out.print_success(&format!("Re-authenticated as @{}", profile.login)); // Try to get device public key and upload SSH key let controller_did = auths_sdk::pairing::load_controller_did(ctx.identity_storage.as_ref()) - .map_err(|e| anyhow::anyhow!("{e}"))?; + .map_err(anyhow::Error::from)?; #[allow(clippy::disallowed_methods)] let identity_did = IdentityDID::new_unchecked(controller_did.clone()); diff --git a/crates/auths-cli/src/commands/id/migrate.rs b/crates/auths-cli/src/commands/id/migrate.rs index ebaa6fb7..9b55b35a 100644 --- a/crates/auths-cli/src/commands/id/migrate.rs +++ b/crates/auths-cli/src/commands/id/migrate.rs @@ -4,6 +4,7 @@ //! - GPG keys (`auths migrate from-gpg`) //! - SSH keys (`auths migrate from-ssh`) +use crate::subprocess::git_command; use crate::ux::format::{Output, is_json_mode}; use anyhow::{Context, Result, anyhow}; use clap::{Parser, Subcommand}; @@ -397,8 +398,7 @@ fn perform_gpg_migration( // Initialize Git repo if needed if !repo_path.join(".git").exists() { - std::process::Command::new("git") - .args(["init"]) + git_command(&["init"]) .current_dir(&repo_path) .output() .context("Failed to initialize Git repository")?; @@ -795,8 +795,7 @@ fn perform_ssh_migration( // Initialize Git repo if needed if !repo_path.join(".git").exists() { - Command::new("git") - .args(["init"]) + git_command(&["init"]) .current_dir(&repo_path) .output() .context("Failed to initialize Git repository")?; @@ -1164,15 +1163,14 @@ fn analyze_commit_signatures( // Use git log to get commit info with signatures // %GS = signer identity (SSH keys show "ssh-ed25519 ...", GPG shows key ID/email) // %GK = signing key fingerprint - let output = Command::new("git") - .args([ - "log", - &format!("-{}", count), - "--pretty=format:%H|%an|%ae|%G?|%GK|%GS", - ]) - .current_dir(repo_path) - .output() - .context("Failed to run git log")?; + let output = git_command(&[ + "log", + &format!("-{}", count), + "--pretty=format:%H|%an|%ae|%G?|%GK|%GS", + ]) + .current_dir(repo_path) + .output() + .context("Failed to run git log")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); diff --git a/crates/auths-cli/src/commands/init/helpers.rs b/crates/auths-cli/src/commands/init/helpers.rs index b2eae457..1044b7a9 100644 --- a/crates/auths-cli/src/commands/init/helpers.rs +++ b/crates/auths-cli/src/commands/init/helpers.rs @@ -11,6 +11,7 @@ use auths_sdk::workflows::allowed_signers::AllowedSigners; use auths_sdk::workflows::diagnostics::{MIN_GIT_VERSION, parse_git_version}; use auths_storage::git::RegistryAttestationStorage; +use crate::subprocess::git_command; use crate::ux::format::Output; pub(crate) fn get_auths_repo_path() -> Result { @@ -18,8 +19,7 @@ pub(crate) fn get_auths_repo_path() -> Result { } pub(crate) fn check_git_version(out: &Output) -> Result<()> { - let output = Command::new("git") - .arg("--version") + let output = git_command(&["--version"]) .output() .context("Failed to run git --version")?; @@ -116,8 +116,7 @@ pub(crate) fn write_allowed_signers(key_alias: &str, out: &Output) -> Result<()> } fn set_git_config(key: &str, value: &str, scope: &str) -> Result<()> { - let status = Command::new("git") - .args(["config", scope, key, value]) + let status = git_command(&["config", scope, key, value]) .status() .with_context(|| format!("Failed to run git config {scope} {key} {value}"))?; @@ -190,8 +189,7 @@ pub(crate) fn scaffold_github_action(out: &Output) -> Result<()> { out.newline(); // Check we're in a git repo - let git_root = Command::new("git") - .args(["rev-parse", "--show-toplevel"]) + let git_root = git_command(&["rev-parse", "--show-toplevel"]) .output() .context("Failed to run git rev-parse")?; @@ -204,9 +202,7 @@ pub(crate) fn scaffold_github_action(out: &Output) -> Result<()> { let root = PathBuf::from(String::from_utf8_lossy(&git_root.stdout).trim()); // Check for GitHub remote - let remote_output = Command::new("git") - .args(["remote", "get-url", "origin"]) - .output(); + let remote_output = git_command(&["remote", "get-url", "origin"]).output(); match remote_output { Ok(ref output) if output.status.success() => { diff --git a/crates/auths-cli/src/commands/init/mod.rs b/crates/auths-cli/src/commands/init/mod.rs index 6f4ae47f..314b8c85 100644 --- a/crates/auths-cli/src/commands/init/mod.rs +++ b/crates/auths-cli/src/commands/init/mod.rs @@ -271,9 +271,7 @@ fn run_developer_setup( // Also write repo-local .auths/allowed_signers if we're inside a git repo, // so `auths verify` works immediately without extra flags. - if let Ok(output) = std::process::Command::new("git") - .args(["rev-parse", "--show-toplevel"]) - .output() + if let Ok(output) = crate::subprocess::git_command(&["rev-parse", "--show-toplevel"]).output() && output.status.success() { let root = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()); diff --git a/crates/auths-cli/src/commands/key.rs b/crates/auths-cli/src/commands/key.rs index db2fd5c8..0ccee49d 100644 --- a/crates/auths-cli/src/commands/key.rs +++ b/crates/auths-cli/src/commands/key.rs @@ -212,7 +212,7 @@ fn key_list() -> Result<()> { response.print()?; } else { // Use eprintln for status messages to not interfere with potential stdout parsing - eprintln!("Using keychain backend: {}", backend_name); + eprintln!("Using key storage: {}", backend_name); if aliases.is_empty() { println!("No keys found in keychain for this application."); @@ -240,7 +240,7 @@ fn key_export(alias: &str, passphrase: &str, format: ExportFormat) -> Result<()> }; if ptr.is_null() { anyhow::bail!( - "❌ Failed to export PEM private key (check alias/passphrase or logs)" + "Failed to export PEM private key. Check the key alias with `auths key list` or verify your passphrase." ); } let pem_string = unsafe { @@ -260,7 +260,9 @@ fn key_export(alias: &str, passphrase: &str, format: ExportFormat) -> Result<()> ffi::ffi_export_public_key_openssh(c_alias.as_ptr(), c_passphrase.as_ptr()) }; if ptr.is_null() { - anyhow::bail!("❌ Failed to export public key (check alias/passphrase or logs)"); + anyhow::bail!( + "Failed to export public key. Check the key alias with `auths key list` or verify your passphrase." + ); } let pub_string = unsafe { // Safety: ptr is not null and points to a C string allocated by FFI @@ -299,7 +301,7 @@ fn key_export(alias: &str, passphrase: &str, format: ExportFormat) -> Result<()> /// Deletes a key from the platform's secure storage by its alias. fn key_delete(alias: &str) -> Result<()> { let keychain: Box = get_platform_keychain()?; - eprintln!("🔍 Using keychain backend: {}", keychain.backend_name()); + eprintln!("Using key storage: {}", keychain.backend_name()); match keychain.delete_key(&KeyAlias::new_unchecked(alias)) { Ok(_) => { diff --git a/crates/auths-cli/src/commands/learn.rs b/crates/auths-cli/src/commands/learn.rs index 16a2400b..952b71ac 100644 --- a/crates/auths-cli/src/commands/learn.rs +++ b/crates/auths-cli/src/commands/learn.rs @@ -4,7 +4,8 @@ use colored::Colorize; use std::fs; use std::io::{self, Write}; use std::path::PathBuf; -use std::process::Command as ProcessCommand; + +use crate::subprocess::git_command; /// Interactive tutorial for learning Auths concepts. #[derive(Parser, Debug, Clone)] @@ -377,18 +378,15 @@ fn section_creating_identity(tutorial: &Tutorial) -> Result<()> { fs::create_dir_all(&sandbox_repo)?; // Initialize git repo - ProcessCommand::new("git") - .args(["init", "--quiet"]) + git_command(&["init", "--quiet"]) .current_dir(&sandbox_repo) .status()?; - ProcessCommand::new("git") - .args(["config", "user.email", "tutorial@auths.io"]) + git_command(&["config", "user.email", "tutorial@auths.io"]) .current_dir(&sandbox_repo) .status()?; - ProcessCommand::new("git") - .args(["config", "user.name", "Tutorial User"]) + git_command(&["config", "user.name", "Tutorial User"]) .current_dir(&sandbox_repo) .status()?; } @@ -430,13 +428,11 @@ fn section_signing_commit(tutorial: &Tutorial) -> Result<()> { fs::write(&test_file, "Hello from Auths tutorial!\n")?; - ProcessCommand::new("git") - .args(["add", "test.txt"]) + git_command(&["add", "test.txt"]) .current_dir(&sandbox_repo) .status()?; - ProcessCommand::new("git") - .args(["commit", "--quiet", "-m", "Tutorial: First signed commit"]) + git_command(&["commit", "--quiet", "-m", "Tutorial: First signed commit"]) .current_dir(&sandbox_repo) .status()?; @@ -444,8 +440,7 @@ fn section_signing_commit(tutorial: &Tutorial) -> Result<()> { println!(); // Show the commit - let output = ProcessCommand::new("git") - .args(["log", "--oneline", "-1"]) + let output = git_command(&["log", "--oneline", "-1"]) .current_dir(&sandbox_repo) .output()?; diff --git a/crates/auths-cli/src/commands/org.rs b/crates/auths-cli/src/commands/org.rs index 7b42d7d9..540cd6d9 100644 --- a/crates/auths-cli/src/commands/org.rs +++ b/crates/auths-cli/src/commands/org.rs @@ -1106,7 +1106,7 @@ fn display_dry_run_revoke_member(org: &str, member: &str, invoker_did: &str) -> }), ) .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"); diff --git a/crates/auths-cli/src/commands/policy.rs b/crates/auths-cli/src/commands/policy.rs index 41f42b8d..1a56535b 100644 --- a/crates/auths-cli/src/commands/policy.rs +++ b/crates/auths-cli/src/commands/policy.rs @@ -627,7 +627,7 @@ fn compute_policy_stats(expr: &CompiledExpr) -> PolicyStats { fn build_eval_context(test: &TestContext, now: DateTime) -> Result { let mut ctx = EvalContext::try_from_strings(now, &test.issuer, &test.subject) - .map_err(|e| anyhow!("invalid DID: {}", e))?; + .map_err(|e| anyhow!("Invalid identity format: {}", e))?; ctx = ctx.revoked(test.revoked); ctx = ctx.chain_depth(test.chain_depth); diff --git a/crates/auths-cli/src/commands/provision.rs b/crates/auths-cli/src/commands/provision.rs index bb0ddc3a..725ba84b 100644 --- a/crates/auths-cli/src/commands/provision.rs +++ b/crates/auths-cli/src/commands/provision.rs @@ -94,7 +94,7 @@ pub fn handle_provision( registry, identity_storage, ) - .map_err(|e| anyhow::anyhow!("{}", e))? + .map_err(anyhow::Error::from)? { None => { out.print_success("Identity already exists and matches — no changes needed."); diff --git a/crates/auths-cli/src/commands/sign.rs b/crates/auths-cli/src/commands/sign.rs index 5858bfd7..107a4e03 100644 --- a/crates/auths-cli/src/commands/sign.rs +++ b/crates/auths-cli/src/commands/sign.rs @@ -33,23 +33,49 @@ pub fn parse_sign_target(raw_target: &str) -> SignTarget { if path.exists() { SignTarget::Artifact(path.to_path_buf()) } else { + if looks_like_artifact_path(raw_target) { + eprintln!( + "Warning: '{}' looks like an artifact file path, but the file does not exist.\n\ + Treating as a git commit range. If you meant to sign a file, check the path.", + raw_target + ); + } SignTarget::CommitRange(raw_target.to_string()) } } +/// Heuristic: does the target look like an artifact file path rather than a git ref? +fn looks_like_artifact_path(target: &str) -> bool { + // Path-shaped strings + if target.starts_with("./") || target.starts_with("../") || target.contains('/') { + let lower = target.to_lowercase(); + return ARTIFACT_EXTENSIONS.iter().any(|ext| lower.ends_with(ext)); + } + // Bare filename with artifact extension + let lower = target.to_lowercase(); + ARTIFACT_EXTENSIONS.iter().any(|ext| lower.ends_with(ext)) +} + +const ARTIFACT_EXTENSIONS: &[&str] = &[ + ".tar.gz", ".tgz", ".zip", ".whl", ".gem", ".jar", ".deb", ".rpm", ".dmg", ".exe", ".msi", + ".pkg", ".nupkg", +]; + /// Execute `git rebase --exec "git commit --amend --no-edit" ` to re-sign a range. /// /// Args: /// * `base` - The exclusive base ref (commits after this ref will be re-signed). fn execute_git_rebase(base: &str) -> Result<()> { - use std::process::Command; - let output = Command::new("git") - .args(["rebase", "--exec", "git commit --amend --no-edit", base]) - .output() - .context("Failed to spawn git rebase")?; + let output = + crate::subprocess::git_command(&["rebase", "--exec", "git commit --amend --no-edit", base]) + .output() + .context("Failed to spawn git rebase")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow!("git rebase failed: {}", stderr.trim())); + return Err(anyhow!( + "Failed to re-sign commits. Check for uncommitted changes or rebase conflicts.\n\nGit reported: {}", + stderr.trim() + )); } Ok(()) } @@ -59,20 +85,22 @@ fn execute_git_rebase(base: &str) -> Result<()> { /// Args: /// * `range` - A git ref or range (e.g., "HEAD", "main..HEAD"). fn sign_commit_range(range: &str) -> Result<()> { - use std::process::Command; let is_range = range.contains(".."); if is_range { let parts: Vec<&str> = range.splitn(2, "..").collect(); let base = parts[0]; execute_git_rebase(base)?; } else { - let output = Command::new("git") - .args(["commit", "--amend", "--no-edit", "--no-verify"]) - .output() - .context("Failed to spawn git commit --amend")?; + let output = + crate::subprocess::git_command(&["commit", "--amend", "--no-edit", "--no-verify"]) + .output() + .context("Failed to spawn git commit --amend")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow!("git commit --amend failed: {}", stderr.trim())); + return Err(anyhow!( + "Failed to amend commit with signature. Ensure you have a commit to amend and no conflicting changes.\n\nGit reported: {}", + stderr.trim() + )); } } if crate::ux::format::is_json_mode() { diff --git a/crates/auths-cli/src/commands/sign_commit.rs b/crates/auths-cli/src/commands/sign_commit.rs index 7c7e0c6f..84b73c63 100644 --- a/crates/auths-cli/src/commands/sign_commit.rs +++ b/crates/auths-cli/src/commands/sign_commit.rs @@ -1,13 +1,13 @@ //! Sign a Git commit with machine identity and OIDC binding. -use anyhow::{Context, Result, anyhow}; +use anyhow::{Result, anyhow}; use auths_core::paths::auths_home_with_config; use clap::Parser; use serde::Serialize; -use std::process::Command; use crate::config::CliConfig; use crate::factories::storage::build_auths_context; +use crate::subprocess::git_stdout; /// Sign a Git commit with the current identity. /// @@ -60,30 +60,14 @@ struct AttestationDisplay { /// Get commit message from git. fn get_commit_message(commit_sha: &str) -> Result { - let output = Command::new("git") - .args(["log", "-1", "--pretty=format:%s", commit_sha]) - .output() - .context("Failed to get commit message")?; - - if !output.status.success() { - return Err(anyhow!("Invalid commit reference: {}", commit_sha)); - } - - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + git_stdout(&["log", "-1", "--pretty=format:%s", commit_sha]) + .map_err(|_| anyhow!("Invalid commit reference: {}", commit_sha)) } /// Get commit author from git. fn get_commit_author(commit_sha: &str) -> Result { - let output = Command::new("git") - .args(["log", "-1", "--pretty=format:%an", commit_sha]) - .output() - .context("Failed to get commit author")?; - - if !output.status.success() { - return Err(anyhow!("Could not retrieve author for {}", commit_sha)); - } - - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + git_stdout(&["log", "-1", "--pretty=format:%an", commit_sha]) + .map_err(|_| anyhow!("Could not retrieve author for {}", commit_sha)) } /// Handle the sign-commit command. diff --git a/crates/auths-cli/src/commands/signers.rs b/crates/auths-cli/src/commands/signers.rs index 0c9f4238..f1d1db2c 100644 --- a/crates/auths-cli/src/commands/signers.rs +++ b/crates/auths-cli/src/commands/signers.rs @@ -100,18 +100,11 @@ pub struct SignersAddFromGithubArgs { } fn resolve_signers_path() -> Result { - let output = std::process::Command::new("git") - .args(["config", "--get", "gpg.ssh.allowedSignersFile"]) - .output(); - - if let Ok(out) = output - && out.status.success() + if let Some(path_str) = + crate::subprocess::git_silent(&["config", "--get", "gpg.ssh.allowedSignersFile"]) { - let path_str = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if !path_str.is_empty() { - let path = PathBuf::from(&path_str); - return Ok(expand_tilde(&path)?); - } + let path = PathBuf::from(&path_str); + return Ok(expand_tilde(&path)?); } let home = dirs::home_dir().context("Could not determine home directory")?; @@ -159,15 +152,14 @@ fn handle_add(args: &SignersAddArgs) -> Result<()> { let mut signers = AllowedSigners::load(&path, &FileAllowedSignersStore) .with_context(|| format!("Failed to load {}", path.display()))?; - let principal = SignerPrincipal::Email( - EmailAddress::new(&args.email).map_err(|e| anyhow::anyhow!("{}", e))?, - ); + let principal = + SignerPrincipal::Email(EmailAddress::new(&args.email).map_err(anyhow::Error::from)?); let pubkey = parse_ssh_pubkey(&args.pubkey)?; signers .add(principal, pubkey, SignerSource::Manual) - .map_err(|e| anyhow::anyhow!("{}", e))?; + .map_err(anyhow::Error::from)?; signers .save(&FileAllowedSignersStore) .with_context(|| format!("Failed to write {}", path.display()))?; @@ -181,9 +173,8 @@ fn handle_remove(args: &SignersRemoveArgs) -> Result<()> { let mut signers = AllowedSigners::load(&path, &FileAllowedSignersStore) .with_context(|| format!("Failed to load {}", path.display()))?; - let principal = SignerPrincipal::Email( - EmailAddress::new(&args.email).map_err(|e| anyhow::anyhow!("{}", e))?, - ); + let principal = + SignerPrincipal::Email(EmailAddress::new(&args.email).map_err(anyhow::Error::from)?); match signers.remove(&principal) { Ok(true) => { @@ -202,7 +193,7 @@ fn handle_remove(args: &SignersRemoveArgs) -> Result<()> { ); std::process::exit(1); } - Err(e) => return Err(anyhow::anyhow!("{}", e)), + Err(e) => return Err(anyhow::Error::from(e)), } Ok(()) @@ -228,7 +219,7 @@ pub(crate) fn sync_signers( pub(crate) fn handle_sync(args: &SignersSyncArgs) -> Result<()> { let repo_path = expand_tilde(&args.repo)?; let path = if let Some(ref output) = args.output_file { - expand_tilde(output).map_err(|e| anyhow::anyhow!("{}", e))? + expand_tilde(output).map_err(anyhow::Error::from)? } else { resolve_signers_path()? }; @@ -276,8 +267,7 @@ fn handle_add_from_github(args: &SignersAddFromGithubArgs) -> Result<()> { .with_context(|| format!("Failed to load {}", path.display()))?; let email = format!("{}@github.com", args.username); - let principal = - SignerPrincipal::Email(EmailAddress::new(&email).map_err(|e| anyhow::anyhow!("{}", e))?); + let principal = SignerPrincipal::Email(EmailAddress::new(&email).map_err(anyhow::Error::from)?); let mut added = 0; for key_str in &ed25519_keys { @@ -292,9 +282,7 @@ fn handle_add_from_github(args: &SignersAddFromGithubArgs) -> Result<()> { // For multiple keys, append index to email to avoid duplicates let p = if ed25519_keys.len() > 1 && added > 0 { let indexed_email = format!("{}+{}@github.com", args.username, added); - SignerPrincipal::Email( - EmailAddress::new(&indexed_email).map_err(|e| anyhow::anyhow!("{}", e))?, - ) + SignerPrincipal::Email(EmailAddress::new(&indexed_email).map_err(anyhow::Error::from)?) } else { principal.clone() }; @@ -304,7 +292,7 @@ fn handle_add_from_github(args: &SignersAddFromGithubArgs) -> Result<()> { Err(AllowedSignersError::DuplicatePrincipal(p)) => { eprintln!("Skipping duplicate: {}", p); } - Err(e) => return Err(anyhow::anyhow!("{}", e)), + Err(e) => return Err(anyhow::Error::from(e)), } } diff --git a/crates/auths-cli/src/commands/trust.rs b/crates/auths-cli/src/commands/trust.rs index 04794edf..3018d5d9 100644 --- a/crates/auths-cli/src/commands/trust.rs +++ b/crates/auths-cli/src/commands/trust.rs @@ -230,7 +230,11 @@ fn handle_remove(cmd: TrustRemoveCommand) -> Result<()> { // Check if exists if store.lookup(&cmd.did)?.is_none() { - anyhow::bail!("Identity {} is not pinned.", cmd.did); + anyhow::bail!( + "Identity {} is not pinned. Pin it first with: auths trust pin {}", + cmd.did, + cmd.did + ); } store.remove(&cmd.did)?; @@ -258,9 +262,13 @@ fn handle_remove(cmd: TrustRemoveCommand) -> Result<()> { fn handle_show(cmd: TrustShowCommand) -> Result<()> { let store = PinnedIdentityStore::new(PinnedIdentityStore::default_path()); - let pin = store - .lookup(&cmd.did)? - .ok_or_else(|| anyhow!("Identity {} is not pinned.", cmd.did))?; + let pin = store.lookup(&cmd.did)?.ok_or_else(|| { + anyhow!( + "Identity {} is not pinned. Pin it first with: auths trust pin {}", + cmd.did, + cmd.did + ) + })?; if is_json_mode() { JsonResponse::success( diff --git a/crates/auths-cli/src/commands/verify_commit.rs b/crates/auths-cli/src/commands/verify_commit.rs index d37c63f9..764a15b3 100644 --- a/crates/auths-cli/src/commands/verify_commit.rs +++ b/crates/auths-cli/src/commands/verify_commit.rs @@ -12,6 +12,8 @@ use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; + +use crate::subprocess::git_command; use tempfile::NamedTempFile; use super::verify_helpers::parse_witness_keys; @@ -198,13 +200,21 @@ fn resolve_signers_source(cmd: &VerifyCommitCommand) -> Result { fn resolve_commits(commit_spec: &str) -> Result> { if commit_spec.contains("..") { // Commit range — use git rev-list - let output = Command::new("git") - .args(["rev-list", commit_spec]) + let output = git_command(&["rev-list", commit_spec]) .output() .context("Failed to run git rev-list")?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let lower = stderr.to_lowercase(); + + if lower.contains("unknown revision") || lower.contains("bad revision") { + return Err(anyhow!( + "{}", + format_commit_range_hint(commit_spec, stderr.trim()) + )); + } + return Err(anyhow!("Invalid commit range: {}", stderr.trim())); } @@ -225,6 +235,22 @@ fn resolve_commits(commit_spec: &str) -> Result> { } } +/// Build a contextual hint when a commit range fails to resolve. +fn format_commit_range_hint(commit_spec: &str, raw_stderr: &str) -> String { + let hint = if commit_spec.contains('~') || commit_spec.contains('^') { + "This repository may not have enough commits for that range. \ + Try a smaller offset (e.g. HEAD~1..HEAD) or verify with `git log --oneline`." + } else if commit_spec.contains("..") { + "One or both refs in the range do not exist. \ + Check branch/tag names with `git branch -a` or `git tag -l`." + } else { + "The commit reference could not be resolved. \ + Verify it exists with `git log --oneline`." + }; + + format!("Failed to resolve commit range '{commit_spec}': {raw_stderr}\n\nHint: {hint}") +} + /// Load an attestation from git ref `refs/auths/commits/`. /// /// Attestations are stored as JSON in git refs using the naming convention @@ -235,17 +261,8 @@ fn resolve_commits(commit_spec: &str) -> Result> { fn try_load_attestation_from_ref(commit_sha: &str) -> Option { let ref_name = format!("refs/auths/commits/{}", commit_sha); - let output = Command::new("git") - .args(["show", &ref_name]) - .output() - .ok()?; - - if !output.status.success() { - return None; - } - - let attestation_json = String::from_utf8_lossy(&output.stdout); - serde_json::from_str(&attestation_json).ok() + let stdout = crate::subprocess::git_silent(&["show", &ref_name])?; + serde_json::from_str(&stdout).ok() } /// Extract OIDC binding display from an attestation. @@ -708,14 +725,17 @@ fn resolve_commit_sha(commit_ref: &str) -> Result { } fn get_commit_signature(sha: &str) -> Result { - let output = Command::new("git") - .args(["cat-file", "commit", sha]) + let output = git_command(&["cat-file", "commit", sha]) .output() .context("Failed to run git cat-file")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow!("Failed to read commit: {}", stderr.trim())); + return Err(anyhow!( + "Failed to read commit object for '{}'. Ensure the SHA exists in this repository.\n\nGit reported: {}", + sha, + stderr.trim() + )); } let commit_content = String::from_utf8_lossy(&output.stdout); @@ -729,8 +749,7 @@ fn get_commit_signature(sha: &str) -> Result { return Ok(SignatureInfo::Ssh { signature, payload }); } - let show_output = Command::new("git") - .args(["log", "-1", "--format=%G?", sha]) + let show_output = git_command(&["log", "-1", "--format=%G?", sha]) .output() .context("Failed to run git log")?; @@ -868,16 +887,31 @@ fn check_ssh_keygen() -> Result<()> { let output = Command::new("ssh-keygen") .arg("-?") .stderr(Stdio::piped()) + .stdout(Stdio::piped()) .output() - .context("ssh-keygen not found in PATH")?; + .with_context(|| { + let hint = platform_ssh_install_hint(); + format!("ssh-keygen not found in PATH. {hint}") + })?; if output.stderr.is_empty() && output.stdout.is_empty() { - return Err(anyhow!("ssh-keygen not functioning")); + let hint = platform_ssh_install_hint(); + return Err(anyhow!("ssh-keygen not functioning. {hint}")); } Ok(()) } +fn platform_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)." + } +} + fn handle_error(cmd: &VerifyCommitCommand, exit_code: i32, message: &str) -> Result<()> { if is_json_mode() { let result = VerifyCommitResult::failure(cmd.commit.clone(), message.to_string()); diff --git a/crates/auths-cli/src/commands/verify_helpers.rs b/crates/auths-cli/src/commands/verify_helpers.rs index 41e6dd42..67ce2bb6 100644 --- a/crates/auths-cli/src/commands/verify_helpers.rs +++ b/crates/auths-cli/src/commands/verify_helpers.rs @@ -7,7 +7,7 @@ pub fn parse_witness_keys(keys: &[String]) -> Result)>> { // Find the last ':' to split DID from hex key let last_colon = s .rfind(':') - .ok_or_else(|| anyhow!("Invalid witness key format '{}': expected DID:hex", s))?; + .ok_or_else(|| anyhow!("Invalid witness key format '{}': expected format: : (e.g. did:key:z6Mk...:abcd1234)", s))?; let did = &s[..last_colon]; let pk_hex = &s[last_colon + 1..]; let pk_bytes = hex::decode(pk_hex) diff --git a/crates/auths-cli/src/errors/renderer.rs b/crates/auths-cli/src/errors/renderer.rs index 325990b3..f1db597b 100644 --- a/crates/auths-cli/src/errors/renderer.rs +++ b/crates/auths-cli/src/errors/renderer.rs @@ -1,12 +1,18 @@ use anyhow::Error; +use auths_core::error::TrustError as CoreTrustError; use auths_core::error::{AgentError, AuthsErrorInfo}; +use auths_core::pairing::PairingError; +use auths_id::error::StorageError as IdStorageError; +use auths_id::error::{FreezeError, InitError}; +use auths_id::storage::StorageError as IdDriverStorageError; use auths_sdk::domains::signing::service::{ArtifactSigningError, SigningError}; use auths_sdk::error::{ ApprovalError, DeviceError, DeviceExtensionError, McpAuthError, OrgError, RegistrationError, - RotationError, SetupError, + RotationError, SdkStorageError, SetupError, TrustError, }; use auths_sdk::workflows::allowed_signers::AllowedSignersError; -use auths_verifier::AttestationError; +use auths_sdk::workflows::auth::AuthChallengeError; +use auths_verifier::{AttestationError, CommitVerificationError}; use colored::Colorize; use crate::errors::cli_error::CliError; @@ -63,6 +69,16 @@ fn extract_error_info(err: &Error) -> Option<(&str, &str, Option<&str>)> { AllowedSignersError, ArtifactSigningError, SigningError, + SdkStorageError, + TrustError, + AuthChallengeError, + CommitVerificationError, + PairingError, + FreezeError, + InitError, + CoreTrustError, + IdStorageError, + IdDriverStorageError, ); } @@ -107,6 +123,15 @@ fn render_text(err: &Error) { s if s.contains("ssh-keygen") && s.contains("not found") => Some( "ssh-keygen not found on PATH.\n\n Install OpenSSH:\n Ubuntu: sudo apt install openssh-client\n macOS: ssh-keygen is pre-installed\n Windows: Install OpenSSH via Settings > Apps > Optional features".to_string() ), + s if s.contains("not a git repository") => Some( + "This command must be run inside a Git repository.\nRun `git init` first, or navigate to an existing repo.".to_string() + ), + s if s.contains("permission denied") || s.contains("Permission denied") => Some(format!( + "Permission denied. Check file permissions on the relevant path.\n Run `auths doctor` for a full health check.\n See: {DOCS_BASE_URL}/cli/troubleshooting/" + )), + s if s.contains("connection refused") || s.contains("timed out") || s.contains("timeout") => Some( + "Network connection failed. Check your internet connection and try again.\nIf using a registry, verify the URL with `auths config show`.".to_string() + ), _ => None, }; diff --git a/crates/auths-cli/src/lib.rs b/crates/auths-cli/src/lib.rs index 55570f23..5453595e 100644 --- a/crates/auths-cli/src/lib.rs +++ b/crates/auths-cli/src/lib.rs @@ -8,6 +8,7 @@ pub mod constants; pub mod core; pub mod errors; pub mod factories; +pub mod subprocess; pub mod telemetry; pub mod ux; diff --git a/crates/auths-cli/src/main.rs b/crates/auths-cli/src/main.rs index da2c256c..d585e51a 100644 --- a/crates/auths-cli/src/main.rs +++ b/crates/auths-cli/src/main.rs @@ -73,40 +73,45 @@ fn run() -> Result<()> { let action = audit_action(&command); let result = match command { - RootCommand::Error(cmd) => cmd.execute(&ctx), + // Primary RootCommand::Init(cmd) => cmd.execute(&ctx), - RootCommand::Reset(cmd) => cmd.execute(&ctx), RootCommand::Sign(cmd) => cmd.execute(&ctx), - RootCommand::SignCommit(cmd) => cmd.execute(&ctx), RootCommand::Verify(cmd) => cmd.execute(&ctx), + RootCommand::Artifact(cmd) => cmd.execute(&ctx), RootCommand::Status(cmd) => cmd.execute(&ctx), RootCommand::Whoami(cmd) => cmd.execute(&ctx), - RootCommand::Tutorial(cmd) => cmd.execute(&ctx), - RootCommand::Doctor(cmd) => cmd.execute(&ctx), - RootCommand::Signers(cmd) => cmd.execute(&ctx), + // Setup & Troubleshooting RootCommand::Pair(cmd) => cmd.execute(&ctx), + RootCommand::Trust(cmd) => cmd.execute(&ctx), + RootCommand::Doctor(cmd) => cmd.execute(&ctx), + RootCommand::Tutorial(cmd) => cmd.execute(&ctx), + // Utilities + RootCommand::Config(cmd) => cmd.execute(&ctx), RootCommand::Completions(cmd) => cmd.execute(&ctx), - RootCommand::Emergency(cmd) => cmd.execute(&ctx), + // Advanced + RootCommand::Reset(cmd) => cmd.execute(&ctx), + RootCommand::SignCommit(cmd) => cmd.execute(&ctx), + RootCommand::Signers(cmd) => cmd.execute(&ctx), + RootCommand::Error(cmd) => cmd.execute(&ctx), RootCommand::Id(cmd) => cmd.execute(&ctx), RootCommand::Device(cmd) => cmd.execute(&ctx), RootCommand::Key(cmd) => cmd.execute(&ctx), RootCommand::Approval(cmd) => cmd.execute(&ctx), - RootCommand::Artifact(cmd) => cmd.execute(&ctx), RootCommand::Policy(cmd) => cmd.execute(&ctx), RootCommand::Git(cmd) => cmd.execute(&ctx), - RootCommand::Trust(cmd) => cmd.execute(&ctx), RootCommand::Namespace(cmd) => cmd.execute(&ctx), RootCommand::Org(cmd) => cmd.execute(&ctx), RootCommand::Audit(cmd) => cmd.execute(&ctx), + RootCommand::Auth(cmd) => cmd.execute(&ctx), + // Internal + RootCommand::Emergency(cmd) => cmd.execute(&ctx), RootCommand::Agent(cmd) => cmd.execute(&ctx), RootCommand::Witness(cmd) => cmd.execute(&ctx), RootCommand::Scim(cmd) => cmd.execute(&ctx), - RootCommand::Config(cmd) => cmd.execute(&ctx), RootCommand::Commit(cmd) => cmd.execute(&ctx), RootCommand::Debug(cmd) => cmd.execute(&ctx), RootCommand::Log(cmd) => cmd.execute(&ctx), RootCommand::Account(cmd) => cmd.execute(&ctx), - RootCommand::Auth(cmd) => cmd.execute(&ctx), }; if let Some(action) = action { @@ -130,11 +135,11 @@ struct CommandGroup { const HELP_GROUPS: &[CommandGroup] = &[ CommandGroup { heading: "Primary", - commands: &["init", "sign", "verify", "status", "whoami"], + commands: &["init", "sign", "verify", "artifact", "status", "whoami"], }, CommandGroup { heading: "Setup & Troubleshooting", - commands: &["pair", "doctor", "tutorial"], + commands: &["pair", "trust", "doctor", "tutorial"], }, CommandGroup { heading: "Utilities", diff --git a/crates/auths-cli/src/subprocess.rs b/crates/auths-cli/src/subprocess.rs new file mode 100644 index 00000000..ec8c1727 --- /dev/null +++ b/crates/auths-cli/src/subprocess.rs @@ -0,0 +1,117 @@ +//! Subprocess helpers — standardized command builders for external tools. +//! +//! All git subprocess calls should use [`git_command`] to ensure locale +//! normalization (`LC_ALL=C`), which prevents non-English error messages +//! from breaking stderr pattern matching. + +use std::path::Path; +use std::process::{Command, Output}; + +use anyhow::{Context, Result, anyhow}; + +/// Build a `git` command with `LC_ALL=C` pre-set. +/// +/// Callers can chain additional configuration (`.current_dir()`, `.stdin()`, etc.) +/// before calling `.output()` or `.status()`. +/// +/// Args: +/// * `args`: Arguments to pass to git. +/// +/// Usage: +/// ```ignore +/// let output = git_command(&["rev-parse", "HEAD"]).output()?; +/// let output = git_command(&["log", "--oneline"]).current_dir(&repo).output()?; +/// ``` +pub fn git_command(args: &[&str]) -> Command { + let mut cmd = Command::new("git"); + cmd.args(args).env("LC_ALL", "C"); + cmd +} + +/// Run a git command and return its stdout as a trimmed string. +/// +/// Returns an error with stderr context if the command fails. +/// +/// Args: +/// * `args`: Arguments to pass to git. +/// +/// Usage: +/// ```ignore +/// let sha = git_stdout(&["rev-parse", "HEAD"])?; +/// ``` +pub fn git_stdout(args: &[&str]) -> Result { + let output = git_command(args) + .output() + .with_context(|| format!("Failed to run: git {}", args.join(" ")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!("git {} failed: {}", args.join(" "), stderr.trim())); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// Run a git command in a specific directory and return stdout as a trimmed string. +/// +/// Args: +/// * `args`: Arguments to pass to git. +/// * `dir`: Working directory for the git command. +/// +/// Usage: +/// ```ignore +/// let log = git_stdout_in(&["log", "--oneline", "-1"], &repo_path)?; +/// ``` +pub fn git_stdout_in(args: &[&str], dir: &Path) -> Result { + let output = git_command(args) + .current_dir(dir) + .output() + .with_context(|| format!("Failed to run: git {}", args.join(" ")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow!("git {} failed: {}", args.join(" "), stderr.trim())); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// Run a git command, returning stdout on success or `None` on failure. +/// +/// Use for optional checks where failure is not an error condition. +/// +/// Args: +/// * `args`: Arguments to pass to git. +/// +/// Usage: +/// ```ignore +/// if let Some(sha) = git_silent(&["rev-parse", "--verify", "HEAD"]) { ... } +/// ``` +pub fn git_silent(args: &[&str]) -> Option { + git_command(args) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Run a git command and return the raw [`Output`]. +/// +/// Always sets `LC_ALL=C`. Use when you need to inspect both stdout and stderr, +/// or when the caller has custom status-checking logic. +/// +/// Args: +/// * `args`: Arguments to pass to git. +/// +/// Usage: +/// ```ignore +/// let output = git_output(&["rev-list", "HEAD~5..HEAD"])?; +/// if !output.status.success() { /* custom handling */ } +/// ``` +pub fn git_output(args: &[&str]) -> Result { + git_command(args) + .output() + .with_context(|| format!("Failed to run: git {}", args.join(" "))) +} diff --git a/crates/auths-sdk/src/domains/diagnostics/mod.rs b/crates/auths-sdk/src/domains/diagnostics/mod.rs index 5f948616..33fb9f6c 100644 --- a/crates/auths-sdk/src/domains/diagnostics/mod.rs +++ b/crates/auths-sdk/src/domains/diagnostics/mod.rs @@ -1,7 +1,6 @@ //! Domain services for diagnostics. pub mod error; -pub mod service; /// Diagnostics types and configuration pub mod types; diff --git a/crates/auths-sdk/src/domains/diagnostics/service.rs b/crates/auths-sdk/src/domains/diagnostics/service.rs deleted file mode 100644 index f2a89707..00000000 --- a/crates/auths-sdk/src/domains/diagnostics/service.rs +++ /dev/null @@ -1,270 +0,0 @@ -//! Diagnostics workflow — orchestrates system health checks via injected providers. - -use crate::ports::diagnostics::{ - CheckCategory, CheckResult, ConfigIssue, CryptoDiagnosticProvider, DiagnosticError, - DiagnosticReport, GitDiagnosticProvider, -}; - -/// Minimum Git version required for SSH signing support. -pub const MIN_GIT_VERSION: (u32, u32, u32) = (2, 34, 0); - -/// Parses a Git version string into a `(major, minor, patch)` tuple. -/// -/// Args: -/// * `version_str`: Raw output from `git --version`, e.g. `"git version 2.39.0"`. -/// -/// Usage: -/// ```ignore -/// let v = parse_git_version("git version 2.39.0"); -/// assert_eq!(v, Some((2, 39, 0))); -/// ``` -pub fn parse_git_version(version_str: &str) -> Option<(u32, u32, u32)> { - let version_part = version_str - .split_whitespace() - .find(|s| s.chars().next().is_some_and(|c| c.is_ascii_digit()))?; - - let numbers: Vec = version_part - .split('.') - .take(3) - .filter_map(|s| { - s.chars() - .take_while(|c| c.is_ascii_digit()) - .collect::() - .parse() - .ok() - }) - .collect(); - - match numbers.as_slice() { - [major, minor, patch, ..] => Some((*major, *minor, *patch)), - [major, minor] => Some((*major, *minor, 0)), - [major] => Some((*major, 0, 0)), - _ => None, - } -} - -/// Orchestrates diagnostic checks without subprocess calls. -/// -/// Args: -/// * `G`: A [`GitDiagnosticProvider`] implementation. -/// * `C`: A [`CryptoDiagnosticProvider`] implementation. -/// -/// Usage: -/// ```ignore -/// let workflow = DiagnosticsWorkflow::new(posix_adapter.clone(), posix_adapter); -/// let report = workflow.run()?; -/// ``` -pub struct DiagnosticsWorkflow { - git: G, - crypto: C, -} - -impl DiagnosticsWorkflow { - /// Create a new diagnostics workflow with the given providers. - pub fn new(git: G, crypto: C) -> Self { - Self { git, crypto } - } - - /// Names of all available checks. - pub fn available_checks() -> &'static [&'static str] { - &[ - "git_version", - "git_version_minimum", - "ssh_keygen", - "git_signing_config", - "git_user_config", - ] - } - - /// Run a single diagnostic check by name. - /// - /// Returns `Err(DiagnosticError::CheckNotFound)` if the name is unknown. - pub fn run_single(&self, name: &str) -> Result { - match name { - "git_version" => self.git.check_git_version(), - "git_version_minimum" => { - let git_check = self.git.check_git_version()?; - let mut checks = Vec::new(); - self.check_git_version_minimum(&git_check, &mut checks); - checks - .into_iter() - .next() - .ok_or_else(|| DiagnosticError::CheckNotFound(name.to_string())) - } - "ssh_keygen" => self.crypto.check_ssh_keygen_available(), - "git_signing_config" => { - let mut checks = Vec::new(); - self.check_git_signing_config(&mut checks)?; - checks - .into_iter() - .next() - .ok_or_else(|| DiagnosticError::CheckNotFound(name.to_string())) - } - "git_user_config" => { - let mut checks = Vec::new(); - self.check_git_user_config(&mut checks)?; - checks - .into_iter() - .next() - .ok_or_else(|| DiagnosticError::CheckNotFound(name.to_string())) - } - _ => Err(DiagnosticError::CheckNotFound(name.to_string())), - } - } - - /// Run all diagnostic checks and return the aggregated report. - /// - /// Usage: - /// ```ignore - /// let report = workflow.run()?; - /// assert!(report.checks.iter().all(|c| c.passed)); - /// ``` - pub fn run(&self) -> Result { - let mut checks = Vec::new(); - - let git_check = self.git.check_git_version()?; - self.check_git_version_minimum(&git_check, &mut checks); - checks.push(git_check); - - checks.push(self.crypto.check_ssh_keygen_available()?); - - self.check_git_user_config(&mut checks)?; - self.check_git_signing_config(&mut checks)?; - - Ok(DiagnosticReport { checks }) - } - - fn check_git_version_minimum(&self, git_check: &CheckResult, checks: &mut Vec) { - let version_str = git_check.message.as_deref().unwrap_or(""); - match parse_git_version(version_str) { - Some(version) if version >= MIN_GIT_VERSION => { - checks.push(CheckResult { - name: "Git version".to_string(), - passed: true, - message: Some(format!( - "{}.{}.{} (>= {}.{}.{})", - version.0, - version.1, - version.2, - MIN_GIT_VERSION.0, - MIN_GIT_VERSION.1, - MIN_GIT_VERSION.2, - )), - config_issues: vec![], - category: CheckCategory::Critical, - }); - } - Some(version) => { - checks.push(CheckResult { - name: "Git version".to_string(), - passed: false, - message: Some(format!( - "{}.{}.{} found, need >= {}.{}.{} for SSH signing", - version.0, - version.1, - version.2, - MIN_GIT_VERSION.0, - MIN_GIT_VERSION.1, - MIN_GIT_VERSION.2, - )), - config_issues: vec![], - category: CheckCategory::Critical, - }); - } - None => { - if !git_check.passed { - return; - } - checks.push(CheckResult { - name: "Git version".to_string(), - passed: false, - message: Some(format!("Could not parse version from: {version_str}")), - config_issues: vec![], - category: CheckCategory::Advisory, - }); - } - } - } - - fn check_git_user_config(&self, checks: &mut Vec) -> Result<(), DiagnosticError> { - let name = self.git.get_git_config("user.name")?; - let email = self.git.get_git_config("user.email")?; - - let mut issues: Vec = Vec::new(); - if name.is_none() { - issues.push(ConfigIssue::Absent("user.name".to_string())); - } - if email.is_none() { - issues.push(ConfigIssue::Absent("user.email".to_string())); - } - - let passed = issues.is_empty(); - let message = if passed { - Some(format!( - "{} <{}>", - name.unwrap_or_default(), - email.unwrap_or_default() - )) - } else { - None - }; - - checks.push(CheckResult { - name: "Git user identity".to_string(), - passed, - message, - config_issues: issues, - category: CheckCategory::Advisory, - }); - - Ok(()) - } - - fn check_git_signing_config( - &self, - checks: &mut Vec, - ) -> Result<(), DiagnosticError> { - let required = [ - ("gpg.format", "ssh"), - ("commit.gpgsign", "true"), - ("tag.gpgsign", "true"), - ]; - let presence_only = ["user.signingkey", "gpg.ssh.program"]; - - let mut issues: Vec = Vec::new(); - - for (key, expected) in &required { - match self.git.get_git_config(key)? { - Some(val) if val == *expected => {} - Some(actual) => { - issues.push(ConfigIssue::Mismatch { - key: key.to_string(), - expected: expected.to_string(), - actual, - }); - } - None => { - issues.push(ConfigIssue::Absent(key.to_string())); - } - } - } - - for key in &presence_only { - if self.git.get_git_config(key)?.is_none() { - issues.push(ConfigIssue::Absent(key.to_string())); - } - } - - let passed = issues.is_empty(); - - checks.push(CheckResult { - name: "Git signing config".to_string(), - passed, - message: None, - config_issues: issues, - category: CheckCategory::Critical, - }); - - Ok(()) - } -} diff --git a/crates/auths-sdk/src/ports/diagnostics.rs b/crates/auths-sdk/src/ports/diagnostics.rs index 39e4e799..e1859a37 100644 --- a/crates/auths-sdk/src/ports/diagnostics.rs +++ b/crates/auths-sdk/src/ports/diagnostics.rs @@ -98,6 +98,13 @@ pub trait GitDiagnosticProvider: Send + Sync { pub trait CryptoDiagnosticProvider: Send + Sync { /// Check that ssh-keygen is available on the system. fn check_ssh_keygen_available(&self) -> Result; + + /// Return the raw SSH version string from `ssh -V`. + /// + /// Default returns "unknown" so existing implementors are not broken. + fn check_ssh_version(&self) -> Result { + Ok("unknown".to_string()) + } } impl GitDiagnosticProvider for &T { @@ -146,4 +153,7 @@ impl CryptoDiagnosticProvider for &T { fn check_ssh_keygen_available(&self) -> Result { (**self).check_ssh_keygen_available() } + fn check_ssh_version(&self) -> Result { + (**self).check_ssh_version() + } } diff --git a/crates/auths-sdk/src/testing/fakes/diagnostics.rs b/crates/auths-sdk/src/testing/fakes/diagnostics.rs index 29346b36..ed3692d5 100644 --- a/crates/auths-sdk/src/testing/fakes/diagnostics.rs +++ b/crates/auths-sdk/src/testing/fakes/diagnostics.rs @@ -63,6 +63,7 @@ impl GitDiagnosticProvider for FakeGitDiagnosticProvider { /// Configurable fake for [`CryptoDiagnosticProvider`]. pub struct FakeCryptoDiagnosticProvider { ssh_keygen_passes: bool, + ssh_version_string: String, } impl FakeCryptoDiagnosticProvider { @@ -71,7 +72,19 @@ impl FakeCryptoDiagnosticProvider { /// Args: /// * `ssh_keygen_passes`: Whether `check_ssh_keygen_available` should report success. pub fn new(ssh_keygen_passes: bool) -> Self { - Self { ssh_keygen_passes } + Self { + ssh_keygen_passes, + ssh_version_string: "OpenSSH_9.6p1, LibreSSL 3.3.6".to_string(), + } + } + + /// Override the SSH version string returned by `check_ssh_version`. + /// + /// Args: + /// * `version`: Raw version string, e.g. `"OpenSSH_8.1p1, LibreSSL 3.3.6"`. + pub fn with_ssh_version(mut self, version: &str) -> Self { + self.ssh_version_string = version.to_string(); + self } } @@ -85,6 +98,10 @@ impl CryptoDiagnosticProvider for FakeCryptoDiagnosticProvider { category: CheckCategory::Advisory, }) } + + fn check_ssh_version(&self) -> Result { + Ok(self.ssh_version_string.clone()) + } } #[cfg(test)] diff --git a/crates/auths-sdk/src/workflows/diagnostics.rs b/crates/auths-sdk/src/workflows/diagnostics.rs index f2a89707..c3d07a43 100644 --- a/crates/auths-sdk/src/workflows/diagnostics.rs +++ b/crates/auths-sdk/src/workflows/diagnostics.rs @@ -8,6 +8,9 @@ use crate::ports::diagnostics::{ /// Minimum Git version required for SSH signing support. pub const MIN_GIT_VERSION: (u32, u32, u32) = (2, 34, 0); +/// Minimum OpenSSH version required (`-Y find-principals` was added in 8.2). +pub const MIN_SSH_VERSION: (u32, u32, u32) = (8, 2, 0); + /// Parses a Git version string into a `(major, minor, patch)` tuple. /// /// Args: @@ -43,6 +46,63 @@ pub fn parse_git_version(version_str: &str) -> Option<(u32, u32, u32)> { } } +/// Parses an OpenSSH version string into a `(major, minor, patch)` tuple. +/// +/// Handles formats from `ssh -V` (written to stderr): +/// - `OpenSSH_9.6p1, LibreSSL 3.3.6` (macOS/Linux) +/// - `OpenSSH_for_Windows_8.6p1, LibreSSL 3.4.3` (Windows) +/// +/// Args: +/// * `version_str`: Raw stderr output from `ssh -V`. +/// +/// Usage: +/// ```ignore +/// let v = parse_ssh_version("OpenSSH_9.6p1, LibreSSL 3.3.6"); +/// assert_eq!(v, Some((9, 6, 1))); +/// ``` +pub fn parse_ssh_version(version_str: &str) -> Option<(u32, u32, u32)> { + // Find the "OpenSSH_X.Yp1" or "OpenSSH_for_Windows_X.Yp1" portion + let ssh_part = version_str.split(',').next()?.trim(); + + // Extract the version after the last underscore + let version_segment = ssh_part.rsplit('_').next()?; + + // Split on '.' to get major, then minor+patch + let mut parts = version_segment.split('.'); + let major: u32 = parts.next()?.parse().ok()?; + + let minor_patch = parts.next().unwrap_or("0"); + // minor_patch is like "6p1" or "6" — split on 'p' for patch + let mut mp = minor_patch.splitn(2, 'p'); + let minor: u32 = mp.next()?.parse().ok()?; + let patch: u32 = mp + .next() + .and_then(|p| { + p.chars() + .take_while(|c| c.is_ascii_digit()) + .collect::() + .parse() + .ok() + }) + .unwrap_or(0); + + Some((major, minor, patch)) +} + +/// Check whether the given SSH version meets the minimum requirement. +/// +/// Args: +/// * `version`: Parsed `(major, minor, patch)` tuple. +/// +/// Usage: +/// ```ignore +/// assert!(check_ssh_version_minimum((9, 6, 1))); +/// assert!(!check_ssh_version_minimum((7, 9, 0))); +/// ``` +pub fn check_ssh_version_minimum(version: (u32, u32, u32)) -> bool { + version >= MIN_SSH_VERSION +} + /// Orchestrates diagnostic checks without subprocess calls. /// /// Args: @@ -71,6 +131,7 @@ impl DiagnosticsWorkflow< "git_version", "git_version_minimum", "ssh_keygen", + "ssh_version", "git_signing_config", "git_user_config", ] @@ -92,6 +153,14 @@ impl DiagnosticsWorkflow< .ok_or_else(|| DiagnosticError::CheckNotFound(name.to_string())) } "ssh_keygen" => self.crypto.check_ssh_keygen_available(), + "ssh_version" => { + let mut checks = Vec::new(); + self.check_ssh_version_minimum(&mut checks); + checks + .into_iter() + .next() + .ok_or_else(|| DiagnosticError::CheckNotFound(name.to_string())) + } "git_signing_config" => { let mut checks = Vec::new(); self.check_git_signing_config(&mut checks)?; @@ -127,6 +196,7 @@ impl DiagnosticsWorkflow< checks.push(git_check); checks.push(self.crypto.check_ssh_keygen_available()?); + self.check_ssh_version_minimum(&mut checks); self.check_git_user_config(&mut checks)?; self.check_git_signing_config(&mut checks)?; @@ -186,6 +256,63 @@ impl DiagnosticsWorkflow< } } + fn check_ssh_version_minimum(&self, checks: &mut Vec) { + let version_str = match self.crypto.check_ssh_version() { + Ok(v) => v, + Err(_) => return, + }; + + if version_str == "unknown" { + return; + } + + match parse_ssh_version(&version_str) { + Some(version) if check_ssh_version_minimum(version) => { + checks.push(CheckResult { + name: "SSH version".to_string(), + passed: true, + message: Some(format!( + "{}.{}.{} (>= {}.{}.{})", + version.0, + version.1, + version.2, + MIN_SSH_VERSION.0, + MIN_SSH_VERSION.1, + MIN_SSH_VERSION.2, + )), + config_issues: vec![], + category: CheckCategory::Advisory, + }); + } + Some(version) => { + checks.push(CheckResult { + name: "SSH version".to_string(), + passed: false, + message: Some(format!( + "{}.{}.{} found, need >= {}.{}.{} for -Y find-principals", + version.0, + version.1, + version.2, + MIN_SSH_VERSION.0, + MIN_SSH_VERSION.1, + MIN_SSH_VERSION.2, + )), + config_issues: vec![], + category: CheckCategory::Advisory, + }); + } + None => { + checks.push(CheckResult { + name: "SSH version".to_string(), + passed: false, + message: Some(format!("Could not parse version from: {version_str}")), + config_issues: vec![], + category: CheckCategory::Advisory, + }); + } + } + } + fn check_git_user_config(&self, checks: &mut Vec) -> Result<(), DiagnosticError> { let name = self.git.get_git_config("user.name")?; let email = self.git.get_git_config("user.email")?; diff --git a/packages/auths-node/lib/index.ts b/packages/auths-node/lib/index.ts index ee5bb67b..e0c346af 100644 --- a/packages/auths-node/lib/index.ts +++ b/packages/auths-node/lib/index.ts @@ -121,9 +121,13 @@ export { type IdentityBundle, } from './types' +export { EphemeralIdentity } from './testing' + import native from './native' -import type { NapiArtifactResult } from './native' +import type { NapiArtifactResult, NapiInMemoryKeypair } from './native' +export type { NapiInMemoryKeypair as InMemoryKeypair } from './native' export const version: () => string = native.version +export const generateInmemoryKeypair: () => NapiInMemoryKeypair = native.generateInmemoryKeypair export const signBytesRaw: (privateKeyHex: string, message: Buffer) => string = native.signBytesRaw export const signActionRaw: (privateKeyHex: string, actionType: string, payloadJson: string, identityDid: string) => string = native.signActionRaw export const signArtifactBytesRaw: (data: Buffer, privateKeyHex: string, identityDid: string, expiresIn?: number | null, note?: string | null) => NapiArtifactResult = native.signArtifactBytesRaw diff --git a/packages/auths-node/lib/native.ts b/packages/auths-node/lib/native.ts index 37205fb9..27864df5 100644 --- a/packages/auths-node/lib/native.ts +++ b/packages/auths-node/lib/native.ts @@ -51,6 +51,12 @@ export interface NapiDelegatedAgentBundle { repoPath?: string | null } +export interface NapiInMemoryKeypair { + privateKeyHex: string + publicKeyHex: string + did: string +} + export interface NapiRotationResult { controllerDid: string newKeyFingerprint: string @@ -176,6 +182,7 @@ export interface NativeBindings { version(): string // Identity + generateInmemoryKeypair(): NapiInMemoryKeypair createIdentity(keyAlias: string, repoPath: string, passphrase?: string | null): NapiIdentityResult createAgentIdentity(agentName: string, capabilities: string[], repoPath: string, passphrase?: string | null): NapiAgentIdentityBundle delegateAgent(agentName: string, capabilities: string[], parentRepoPath: string, passphrase?: string | null, expiresInDays?: number | null, identityDid?: string | null): NapiDelegatedAgentBundle diff --git a/packages/auths-node/lib/testing.ts b/packages/auths-node/lib/testing.ts new file mode 100644 index 00000000..29470f62 --- /dev/null +++ b/packages/auths-node/lib/testing.ts @@ -0,0 +1,66 @@ +/** + * Testing helpers — lightweight, no filesystem/keychain/Git required. + */ +import native from './native' +import type { NapiInMemoryKeypair, NapiVerificationResult } from './native' + +/** + * In-memory identity for tests, demos, and CI. + * + * Generates a fresh Ed25519 keypair on construction. The resulting DID is + * `did:key:z...` (not `did:keri:`), which is valid for `signActionRaw` + * and `verifyActionEnvelope` but cannot be used with KERI operations. + * + * @example + * ```ts + * import { EphemeralIdentity } from '@auths/node/testing' + * + * const alice = new EphemeralIdentity() + * const sig = alice.sign(Buffer.from('hello')) + * const envelope = alice.signAction('tool_call', '{"tool": "web_search"}') + * const result = alice.verifyAction(envelope) + * console.log(result.valid) // true + * ``` + */ +export class EphemeralIdentity { + private readonly _keypair: NapiInMemoryKeypair + + constructor() { + this._keypair = native.generateInmemoryKeypair() + } + + /** The `did:key:z...` identifier for this ephemeral identity. */ + get did(): string { + return this._keypair.did + } + + /** Hex-encoded 32-byte Ed25519 public key. */ + get publicKeyHex(): string { + return this._keypair.publicKeyHex + } + + /** Hex-encoded 32-byte Ed25519 seed (private key). */ + get privateKeyHex(): string { + return this._keypair.privateKeyHex + } + + /** Sign arbitrary bytes. Returns hex-encoded signature. */ + sign(message: Buffer): string { + return native.signBytesRaw(this._keypair.privateKeyHex, message) + } + + /** Sign an action envelope. Returns JSON envelope string. */ + signAction(actionType: string, payloadJson: string): string { + return native.signActionRaw( + this._keypair.privateKeyHex, + actionType, + payloadJson, + this._keypair.did, + ) + } + + /** Verify an action envelope against this identity's public key. */ + verifyAction(envelopeJson: string): NapiVerificationResult { + return native.verifyActionEnvelope(envelopeJson, this._keypair.publicKeyHex) + } +} diff --git a/packages/auths-node/src/identity.rs b/packages/auths-node/src/identity.rs index 6382a414..9d4f5ed9 100644 --- a/packages/auths-node/src/identity.rs +++ b/packages/auths-node/src/identity.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use auths_core::crypto::signer::encrypt_keypair; use auths_core::signing::PrefilledPassphraseProvider; use auths_core::storage::keychain::{KeyAlias, KeyRole, get_platform_keychain_with_config}; +use auths_crypto::ed25519_pubkey_to_did_key; use auths_id::identity::helpers::{encode_seed_as_pkcs8, extract_seed_bytes}; use auths_id::identity::initialize::initialize_registry_identity; use auths_id::storage::attestation::AttestationSource; @@ -24,7 +25,8 @@ use ring::signature::{Ed25519KeyPair, KeyPair}; use crate::error::format_error; use crate::helpers::{make_env_config, resolve_key_alias, resolve_passphrase}; use crate::types::{ - NapiAgentIdentityBundle, NapiDelegatedAgentBundle, NapiIdentityResult, NapiRotationResult, + NapiAgentIdentityBundle, NapiDelegatedAgentBundle, NapiIdentityResult, NapiInMemoryKeypair, + NapiRotationResult, }; fn init_backend(repo: &PathBuf) -> napi::Result> { @@ -465,3 +467,37 @@ pub fn get_identity_public_key( })?; Ok(hex::encode(pub_bytes)) } + +/// Generate an in-memory Ed25519 keypair without keychain, Git, or filesystem access. +/// +/// Args: +/// (no arguments) +/// +/// Usage: +/// ```ignore +/// let kp = generate_inmemory_keypair()?; +/// // kp.private_key_hex, kp.public_key_hex, kp.did +/// ``` +#[napi] +pub fn generate_inmemory_keypair() -> napi::Result { + let rng = SystemRandom::new(); + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng) + .map_err(|e| format_error("AUTHS_CRYPTO_ERROR", format!("Key generation failed: {e}")))?; + let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()) + .map_err(|e| format_error("AUTHS_CRYPTO_ERROR", format!("Key parsing failed: {e}")))?; + + let seed = extract_seed_bytes(pkcs8.as_ref()) + .map_err(|e| format_error("AUTHS_CRYPTO_ERROR", format!("Seed extraction failed: {e}")))?; + + let pub_bytes = keypair.public_key().as_ref(); + let pub_array: &[u8; 32] = pub_bytes.try_into().map_err(|_| { + format_error("AUTHS_CRYPTO_ERROR", "Invalid public key length") + })?; + let did = ed25519_pubkey_to_did_key(pub_array); + + Ok(NapiInMemoryKeypair { + private_key_hex: hex::encode(seed), + public_key_hex: hex::encode(pub_bytes), + did, + }) +} diff --git a/packages/auths-node/src/types.rs b/packages/auths-node/src/types.rs index 59d02d47..db5d6819 100644 --- a/packages/auths-node/src/types.rs +++ b/packages/auths-node/src/types.rs @@ -161,6 +161,14 @@ pub struct NapiDelegatedAgentBundle { pub repo_path: Option, } +#[napi(object)] +#[derive(Clone)] +pub struct NapiInMemoryKeypair { + pub private_key_hex: String, + pub public_key_hex: String, + pub did: String, +} + #[napi(object)] #[derive(Clone)] pub struct NapiRotationResult { diff --git a/packages/auths-python/python/auths/__init__.py b/packages/auths-python/python/auths/__init__.py index 3d92c597..b3dff2c1 100644 --- a/packages/auths-python/python/auths/__init__.py +++ b/packages/auths-python/python/auths/__init__.py @@ -17,6 +17,7 @@ VerificationReport, VerificationResult, VerificationStatus, + generate_inmemory_keypair, get_token, sign_action, sign_artifact_bytes_raw, diff --git a/packages/auths-python/python/auths/__init__.pyi b/packages/auths-python/python/auths/__init__.pyi index 99a82c14..616e40ef 100644 --- a/packages/auths-python/python/auths/__init__.pyi +++ b/packages/auths-python/python/auths/__init__.pyi @@ -101,6 +101,12 @@ def verify_attestation_with_capability(attestation_json: str, issuer_pk_hex: str def verify_chain_with_capability(attestations_json: list[str], root_pk_hex: str, required_capability: str) -> VerificationReport: ... def verify_device_authorization(identity_did: str, device_did: str, attestations_json: list[str], identity_pk_hex: str) -> VerificationReport: ... +# -- In-memory keypair -- + +def generate_inmemory_keypair() -> tuple[str, str, str]: + """Generate an in-memory Ed25519 keypair. Returns (private_key_hex, public_key_hex, did_key).""" + ... + # -- Sign functions -- def sign_bytes(private_key_hex: str, message: bytes) -> str: ... @@ -454,6 +460,17 @@ class DoctorService: def check(self, repo_path: str | None = None) -> DiagnosticReport: ... def check_one(self, name: str, repo_path: str | None = None) -> Check: ... +# -- Testing -- + +class EphemeralIdentity: + did: str + public_key_hex: str + private_key_hex: str + def __init__(self) -> None: ... + def sign(self, message: bytes) -> str: ... + def sign_action(self, action_type: str, payload_json: str) -> str: ... + def verify_action(self, envelope_json: str) -> VerificationResult: ... + # -- Pairing -- @dataclass diff --git a/packages/auths-python/python/auths/testing.py b/packages/auths-python/python/auths/testing.py new file mode 100644 index 00000000..d68fdef3 --- /dev/null +++ b/packages/auths-python/python/auths/testing.py @@ -0,0 +1,60 @@ +"""Testing helpers — lightweight, no filesystem/keychain/Git required.""" + +from auths._native import ( + generate_inmemory_keypair, + sign_bytes, + sign_action, + verify_action_envelope, +) + + +class EphemeralIdentity: + """In-memory identity for tests, demos, and CI. + + Generates a fresh Ed25519 keypair on construction. The resulting DID is + ``did:key:z...`` (not ``did:keri:``), which is valid for ``sign_action`` + and ``verify_action_envelope`` but cannot be used with KERI operations. + + Usage:: + + from auths.testing import EphemeralIdentity + + alice = EphemeralIdentity() + sig = alice.sign(b"hello") + envelope = alice.sign_action("tool_call", '{"tool": "web_search"}') + result = alice.verify_action(envelope) + assert result.valid + """ + + def __init__(self) -> None: + private_key, public_key, did = generate_inmemory_keypair() + self._private_key_hex = private_key + self._public_key_hex = public_key + self._did = did + + @property + def did(self) -> str: + """The ``did:key:z...`` identifier for this ephemeral identity.""" + return self._did + + @property + def public_key_hex(self) -> str: + """Hex-encoded 32-byte Ed25519 public key.""" + return self._public_key_hex + + @property + def private_key_hex(self) -> str: + """Hex-encoded 32-byte Ed25519 seed (private key).""" + return self._private_key_hex + + def sign(self, message: bytes) -> str: + """Sign arbitrary bytes. Returns hex-encoded signature.""" + return sign_bytes(self._private_key_hex, message) + + def sign_action(self, action_type: str, payload_json: str) -> str: + """Sign an action envelope. Returns JSON envelope string.""" + return sign_action(self._private_key_hex, action_type, payload_json, self._did) + + def verify_action(self, envelope_json: str): + """Verify an action envelope against this identity's public key.""" + return verify_action_envelope(envelope_json, self._public_key_hex) diff --git a/packages/auths-python/src/identity.rs b/packages/auths-python/src/identity.rs index 47fc13a3..b70d1e4f 100644 --- a/packages/auths-python/src/identity.rs +++ b/packages/auths-python/src/identity.rs @@ -7,6 +7,7 @@ use auths_core::signing::PrefilledPassphraseProvider; use auths_core::storage::keychain::{ IdentityDID, KeyAlias, KeyRole, KeyStorage, get_platform_keychain_with_config, }; +use auths_crypto::ed25519_pubkey_to_did_key; use auths_id::identity::helpers::encode_seed_as_pkcs8; use auths_id::identity::helpers::extract_seed_bytes; use auths_id::identity::initialize::initialize_registry_identity; @@ -629,3 +630,41 @@ pub fn revoke_device_from_identity( Ok(()) } } + +/// Generate an in-memory Ed25519 keypair without keychain, Git, or filesystem access. +/// +/// Returns `(private_key_hex, public_key_hex, did_key_string)` where: +/// - `private_key_hex`: 32-byte seed encoded as hex (use with `sign_bytes` / `sign_action`) +/// - `public_key_hex`: 32-byte public key encoded as hex (use with `verify_action_envelope`) +/// - `did_key_string`: `did:key:z...` encoding of the public key +/// +/// Args: +/// (no arguments) +/// +/// Usage: +/// ```python +/// from auths import generate_inmemory_keypair +/// private_key, public_key, did = generate_inmemory_keypair() +/// ``` +#[pyfunction] +pub fn generate_inmemory_keypair() -> PyResult<(String, String, String)> { + let rng = SystemRandom::new(); + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).map_err(|e| { + PyRuntimeError::new_err(format!("[AUTHS_CRYPTO_ERROR] Key generation failed: {e}")) + })?; + let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).map_err(|e| { + PyRuntimeError::new_err(format!("[AUTHS_CRYPTO_ERROR] Key parsing failed: {e}")) + })?; + + let seed = extract_seed_bytes(pkcs8.as_ref()).map_err(|e| { + PyRuntimeError::new_err(format!("[AUTHS_CRYPTO_ERROR] Seed extraction failed: {e}")) + })?; + + let pub_bytes = keypair.public_key().as_ref(); + let pub_array: &[u8; 32] = pub_bytes.try_into().map_err(|_| { + PyRuntimeError::new_err("[AUTHS_CRYPTO_ERROR] Invalid public key length") + })?; + let did = ed25519_pubkey_to_did_key(pub_array); + + Ok((hex::encode(seed), hex::encode(pub_bytes), did)) +} diff --git a/packages/auths-python/src/lib.rs b/packages/auths-python/src/lib.rs index 0ccd7c30..eb35a40f 100644 --- a/packages/auths-python/src/lib.rs +++ b/packages/auths-python/src/lib.rs @@ -61,6 +61,7 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(identity::delegate_agent, m)?)?; m.add_function(wrap_pyfunction!(identity::link_device_to_identity, m)?)?; m.add_function(wrap_pyfunction!(identity::revoke_device_from_identity, m)?)?; + m.add_function(wrap_pyfunction!(identity::generate_inmemory_keypair, m)?)?; m.add_function(wrap_pyfunction!(identity_sign::sign_as_identity, m)?)?; m.add_function(wrap_pyfunction!(identity_sign::sign_action_as_identity, m)?)?;