diff --git a/Cargo.toml b/Cargo.toml index aa94f5bf..2eed8dfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,7 +63,7 @@ auths-pairing-daemon = { path = "crates/auths-pairing-daemon", version = "0.0.1- auths-pairing-protocol = { path = "crates/auths-pairing-protocol", version = "0.0.1-rc.7" } auths-storage = { path = "crates/auths-storage", version = "0.0.1-rc.4" } auths-transparency = { path = "crates/auths-transparency", version = "0.0.1-rc.8", default-features = false } -auths-utils = { path = "crates/auths-utils" } +auths-utils = { path = "crates/auths-utils", version = "0.0.1-rc.9" } insta = { version = "1", features = ["json"] } # Compile crypto-heavy crates with optimizations even in dev/test builds. diff --git a/README.md b/README.md index 4f4d2929..306da4fc 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ One identity, multiple devices, Git-native storage. Homebrew: ```bash -brew install auths-dev/auths-cli/auths +brew tap auths-dev/auths-cli +brew install auths ``` Install from source: diff --git a/crates/auths-cli/src/cli.rs b/crates/auths-cli/src/cli.rs index 2df9da2a..ec070f4b 100644 --- a/crates/auths-cli/src/cli.rs +++ b/crates/auths-cli/src/cli.rs @@ -8,6 +8,7 @@ use crate::commands::agent::AgentCommand; use crate::commands::approval::ApprovalCommand; use crate::commands::artifact::ArtifactCommand; use crate::commands::audit::AuditCommand; +use crate::commands::auth::AuthCommand; use crate::commands::commit::CommitCmd; use crate::commands::completions::CompletionsCommand; use crate::commands::config::ConfigCommand; @@ -144,4 +145,5 @@ pub enum RootCommand { Log(LogCommand), #[command(hide = true)] Account(AccountCommand), + Auth(AuthCommand), } diff --git a/crates/auths-cli/src/commands/artifact/mod.rs b/crates/auths-cli/src/commands/artifact/mod.rs index b19bc462..669be6f5 100644 --- a/crates/auths-cli/src/commands/artifact/mod.rs +++ b/crates/auths-cli/src/commands/artifact/mod.rs @@ -40,12 +40,13 @@ pub enum ArtifactSubcommand { identity_key_alias: Option, /// Local alias of the device key (used for dual-signing). + /// Auto-detected when only one key exists for the identity. #[arg( long, visible_alias = "dka", - help = "Local alias of the device key (used for dual-signing)." + help = "Local alias of the device key. Auto-detected when only one key exists." )] - device_key_alias: String, + device_key_alias: Option, /// Duration in seconds until expiration (per RFC 6749). #[arg(long = "expires-in", value_name = "N")] @@ -114,17 +115,26 @@ pub fn handle_artifact( device_key_alias, expires_in, note, - } => sign::handle_sign( - &file, - sig_output, - identity_key_alias.as_deref(), - &device_key_alias, - expires_in, - note, - repo_opt, - passphrase_provider, - env_config, - ), + } => { + let resolved_alias = match device_key_alias { + Some(alias) => alias, + None => crate::commands::key_detect::auto_detect_device_key( + repo_opt.as_deref(), + env_config, + )?, + }; + sign::handle_sign( + &file, + sig_output, + identity_key_alias.as_deref(), + &resolved_alias, + expires_in, + note, + repo_opt, + passphrase_provider, + env_config, + ) + } ArtifactSubcommand::Publish { signature, package, diff --git a/crates/auths-cli/src/commands/auth.rs b/crates/auths-cli/src/commands/auth.rs new file mode 100644 index 00000000..532a5c17 --- /dev/null +++ b/crates/auths-cli/src/commands/auth.rs @@ -0,0 +1,111 @@ +use anyhow::{Context, Result, anyhow}; +use clap::{Parser, Subcommand}; + +use auths_core::crypto::provider_bridge; +use auths_core::crypto::signer::decrypt_keypair; +use auths_core::crypto::ssh::extract_seed_from_pkcs8; +use auths_core::storage::keychain::{KeyStorage, get_platform_keychain_with_config}; +use auths_crypto::Pkcs8Der; +use auths_id::storage::identity::IdentityStorage; +use auths_id::storage::layout; +use auths_sdk::workflows::auth::sign_auth_challenge; +use auths_storage::git::RegistryIdentityStorage; + +use crate::commands::executable::ExecutableCommand; +use crate::config::CliConfig; +use crate::ux::format::{JsonResponse, is_json_mode}; + +/// Authenticate with external services using your auths identity. +#[derive(Parser, Debug, Clone)] +pub struct AuthCommand { + #[clap(subcommand)] + pub subcommand: AuthSubcommand, +} + +/// Subcommands for authentication operations. +#[derive(Subcommand, Debug, Clone)] +pub enum AuthSubcommand { + /// Sign an authentication challenge for DID-based login + Challenge { + /// The challenge nonce from the authentication server + #[arg(long)] + nonce: String, + + /// The domain requesting authentication + #[arg(long, default_value = "auths.dev")] + domain: String, + }, +} + +fn handle_auth_challenge(nonce: &str, domain: &str, ctx: &CliConfig) -> Result<()> { + let repo_path = layout::resolve_repo_path(ctx.repo_path.clone())?; + let passphrase_provider = ctx.passphrase_provider.clone(); + + let identity_storage = RegistryIdentityStorage::new(repo_path.clone()); + let managed = identity_storage + .load_identity() + .context("No identity found. Run `auths init` first.")?; + + let controller_did = &managed.controller_did; + + let key_alias_str = + super::key_detect::auto_detect_device_key(ctx.repo_path.as_deref(), &ctx.env_config)?; + let key_alias = auths_core::storage::keychain::KeyAlias::new(&key_alias_str) + .map_err(|e| anyhow!("Invalid key alias: {e}"))?; + + let keychain = get_platform_keychain_with_config(&ctx.env_config)?; + let (_stored_did, _role, encrypted_key) = keychain + .load_key(&key_alias) + .with_context(|| format!("Failed to load key '{}'", key_alias_str))?; + + let passphrase = + passphrase_provider.get_passphrase(&format!("Enter passphrase for '{}':", key_alias))?; + let pkcs8_bytes = decrypt_keypair(&encrypted_key, &passphrase) + .context("Failed to decrypt key (invalid passphrase?)")?; + + let pkcs8 = Pkcs8Der::new(&pkcs8_bytes[..]); + let seed = + extract_seed_from_pkcs8(&pkcs8).context("Failed to extract seed from key material")?; + + // Derive public key from the seed instead of resolving via KEL + let public_key_bytes = provider_bridge::ed25519_public_key_from_seed_sync(&seed) + .context("Failed to derive public key from seed")?; + let public_key_hex = hex::encode(public_key_bytes); + + let result = sign_auth_challenge( + nonce, + domain, + &seed, + &public_key_hex, + controller_did.as_str(), + ) + .context("Failed to sign auth challenge")?; + + if is_json_mode() { + JsonResponse::success( + "auth challenge", + &serde_json::json!({ + "signature": result.signature_hex, + "public_key": result.public_key_hex, + "did": result.did, + }), + ) + .print() + .map_err(|e| anyhow::anyhow!("{e}")) + } else { + println!("Signature: {}", result.signature_hex); + println!("Public Key: {}", result.public_key_hex); + println!("DID: {}", result.did); + Ok(()) + } +} + +impl ExecutableCommand for AuthCommand { + fn execute(&self, ctx: &CliConfig) -> Result<()> { + match &self.subcommand { + AuthSubcommand::Challenge { nonce, domain } => { + handle_auth_challenge(nonce, domain, ctx) + } + } + } +} diff --git a/crates/auths-cli/src/commands/id/register.rs b/crates/auths-cli/src/commands/id/register.rs index 4b3f1f06..44a9232e 100644 --- a/crates/auths-cli/src/commands/id/register.rs +++ b/crates/auths-cli/src/commands/id/register.rs @@ -2,6 +2,7 @@ use std::path::Path; use std::sync::Arc; use anyhow::{Result, bail}; +use auths_verifier::IdentityDID; use serde::Serialize; use auths_id::ports::registry::RegistryBackend; @@ -19,7 +20,7 @@ use crate::ux::format::{JsonResponse, Output, is_json_mode}; #[derive(Serialize)] struct RegisterJsonResponse { - did_prefix: String, + did: IdentityDID, registry: String, platform_claims_indexed: usize, } @@ -84,7 +85,7 @@ fn display_registration_result(outcome: &RegistrationOutcome) -> Result<()> { let json_resp = JsonResponse::success( "id register", RegisterJsonResponse { - did_prefix: outcome.did_prefix.clone(), + did: outcome.did.clone(), registry: outcome.registry.clone(), platform_claims_indexed: outcome.platform_claims_indexed, }, @@ -97,7 +98,7 @@ fn display_registration_result(outcome: &RegistrationOutcome) -> Result<()> { out.success("Success!"), out.bold(&outcome.registry) ); - println!("DID: {}", out.info(&outcome.did_prefix)); + println!("DID: {}", out.info(&outcome.did)); if outcome.platform_claims_indexed > 0 { println!( "Platform claims indexed: {}", diff --git a/crates/auths-cli/src/commands/key_detect.rs b/crates/auths-cli/src/commands/key_detect.rs new file mode 100644 index 00000000..0f204341 --- /dev/null +++ b/crates/auths-cli/src/commands/key_detect.rs @@ -0,0 +1,118 @@ +//! Shared device key alias auto-detection. + +use std::path::Path; + +use anyhow::{Context, Result, anyhow}; +use auths_core::config::EnvironmentConfig; +use auths_core::storage::keychain::{KeyAlias, KeyStorage}; +use auths_id::storage::identity::IdentityStorage; +use auths_storage::git::RegistryIdentityStorage; +use dialoguer::Select; +use std::io::IsTerminal; + +use crate::ux::format::is_json_mode; + +fn filter_signing_aliases(aliases: Vec) -> Vec { + aliases + .into_iter() + .filter(|a| !a.as_str().contains("--next-")) + .collect() +} + +fn select_device_key_interactive(aliases: &[KeyAlias]) -> Result { + let display_items: Vec<&str> = aliases.iter().map(|a| a.as_str()).collect(); + + let selection = Select::new() + .with_prompt("Select signing key") + .items(&display_items) + .default(0) + .interact() + .context("Key selection cancelled")?; + + Ok(aliases[selection].as_str().to_string()) +} + +/// Auto-detect the device key alias when not explicitly provided. +/// +/// Loads the identity from the repository, then lists all key aliases +/// associated with that identity. Filters out `--next-` rotation keys, +/// then either auto-selects (single key) or prompts interactively (multiple keys). +/// +/// Args: +/// * `repo_opt`: Optional path to the identity repository. +/// * `env_config`: Environment configuration for keychain access. +/// +/// Usage: +/// ```ignore +/// let alias = auto_detect_device_key(repo_opt.as_deref(), env_config)?; +/// ``` +pub fn auto_detect_device_key( + repo_opt: Option<&Path>, + env_config: &EnvironmentConfig, +) -> Result { + let repo_path = + auths_id::storage::layout::resolve_repo_path(repo_opt.map(|p| p.to_path_buf()))?; + let identity_storage = RegistryIdentityStorage::new(repo_path.clone()); + let identity = identity_storage + .load_identity() + .map_err(|_| anyhow!("No identity found. Run `auths init` to get started."))?; + + let keychain = auths_core::storage::keychain::get_platform_keychain_with_config(env_config) + .context("Failed to access keychain")?; + let aliases = keychain + .list_aliases_for_identity(&identity.controller_did) + .map_err(|e| anyhow!("Failed to list key aliases: {e}"))?; + + let signing_aliases = filter_signing_aliases(aliases); + + match signing_aliases.len() { + 0 => Err(anyhow!( + "No signing keys found for identity {}.\n\n\ + All keys are rotation keys (--next- prefixed) or no keys exist.\n\ + Run `auths status` to see your identity details, or `auths device link` to authorize a device.", + identity.controller_did + )), + 1 => Ok(signing_aliases[0].as_str().to_string()), + _ => { + if std::io::stdin().is_terminal() && !is_json_mode() { + select_device_key_interactive(&signing_aliases) + } else { + let alias_list: Vec<&str> = signing_aliases.iter().map(|a| a.as_str()).collect(); + Err(anyhow!( + "Multiple device keys found. Specify with --device-key-alias.\n\n\ + Available aliases: {}", + alias_list.join(", ") + )) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use auths_core::storage::keychain::KeyAlias; + + #[test] + fn filter_removes_next_aliases() { + let aliases = vec![ + KeyAlias::new("main").unwrap(), + KeyAlias::new("main--next-0").unwrap(), + KeyAlias::new("secondary").unwrap(), + KeyAlias::new("main--next-1").unwrap(), + ]; + let result = filter_signing_aliases(aliases); + let names: Vec<&str> = result.iter().map(|a| a.as_str()).collect(); + assert_eq!(names, vec!["main", "secondary"]); + } + + #[test] + fn filter_all_next_returns_empty() { + let aliases = vec![ + KeyAlias::new("main--next-0").unwrap(), + KeyAlias::new("main--next-1").unwrap(), + ]; + let result = filter_signing_aliases(aliases); + assert!(result.is_empty()); + } +} diff --git a/crates/auths-cli/src/commands/mod.rs b/crates/auths-cli/src/commands/mod.rs index 8a0d10c5..b0761957 100644 --- a/crates/auths-cli/src/commands/mod.rs +++ b/crates/auths-cli/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod agent; pub mod approval; pub mod artifact; pub mod audit; +pub mod auth; pub mod cache; pub mod commit; pub mod completions; @@ -20,6 +21,7 @@ pub mod id; pub mod index; pub mod init; pub mod key; +pub mod key_detect; pub mod learn; pub mod log; pub mod namespace; diff --git a/crates/auths-cli/src/commands/sign.rs b/crates/auths-cli/src/commands/sign.rs index 77362ed1..a0cfa7ab 100644 --- a/crates/auths-cli/src/commands/sign.rs +++ b/crates/auths-cli/src/commands/sign.rs @@ -7,9 +7,6 @@ use std::sync::Arc; use auths_core::config::EnvironmentConfig; use auths_core::signing::PassphraseProvider; -use auths_core::storage::keychain::KeyStorage; -use auths_id::storage::identity::IdentityStorage; -use auths_storage::git::RegistryIdentityStorage; use super::artifact::sign::handle_sign as handle_artifact_sign; @@ -135,7 +132,7 @@ pub fn handle_sign_unified( SignTarget::Artifact(path) => { let device_key_alias = match cmd.device_key_alias.as_deref() { Some(alias) => alias.to_string(), - None => auto_detect_device_key(repo_opt.as_deref(), env_config)?, + None => super::key_detect::auto_detect_device_key(repo_opt.as_deref(), env_config)?, }; handle_artifact_sign( &path, @@ -153,44 +150,6 @@ pub fn handle_sign_unified( } } -/// Auto-detect the device key alias when not explicitly provided. -/// -/// Loads the identity from the registry, then lists all key aliases associated -/// with that identity. If exactly one alias exists, it is returned. Otherwise, -/// an error with actionable guidance is returned. -fn auto_detect_device_key( - repo_opt: Option<&Path>, - env_config: &EnvironmentConfig, -) -> Result { - let repo_path = - auths_id::storage::layout::resolve_repo_path(repo_opt.map(|p| p.to_path_buf()))?; - let identity_storage = RegistryIdentityStorage::new(repo_path.clone()); - let identity = identity_storage - .load_identity() - .map_err(|_| anyhow!("No identity found. Run `auths init` to get started."))?; - - let keychain = auths_core::storage::keychain::get_platform_keychain_with_config(env_config) - .context("Failed to access keychain")?; - let aliases = keychain - .list_aliases_for_identity(&identity.controller_did) - .map_err(|e| anyhow!("Failed to list key aliases: {e}"))?; - - match aliases.len() { - 0 => Err(anyhow!( - "No device keys found for identity {}.\n\nRun `auths device link` to authorize a device.", - identity.controller_did - )), - 1 => Ok(aliases[0].as_str().to_string()), - _ => { - let alias_list: Vec<&str> = aliases.iter().map(|a| a.as_str()).collect(); - Err(anyhow!( - "Multiple device keys found. Specify with --device-key-alias.\n\nAvailable aliases: {}", - alias_list.join(", ") - )) - } - } -} - impl crate::commands::executable::ExecutableCommand for SignCommand { fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> { handle_sign_unified( diff --git a/crates/auths-cli/src/commands/snapshots/auths_cli__commands__status__tests__status_json_snapshot.snap b/crates/auths-cli/src/commands/snapshots/auths_cli__commands__status__tests__status_json_snapshot.snap index 485442dc..ce106705 100644 --- a/crates/auths-cli/src/commands/snapshots/auths_cli__commands__status__tests__status_json_snapshot.snap +++ b/crates/auths-cli/src/commands/snapshots/auths_cli__commands__status__tests__status_json_snapshot.snap @@ -5,7 +5,10 @@ expression: report { "identity": { "controller_did": "did:keri:ETestController123", - "alias": "dev-machine" + "alias": "dev-machine", + "key_aliases": [ + "main" + ] }, "agent": { "running": true, diff --git a/crates/auths-cli/src/commands/status.rs b/crates/auths-cli/src/commands/status.rs index 3356b7ed..9beb493c 100644 --- a/crates/auths-cli/src/commands/status.rs +++ b/crates/auths-cli/src/commands/status.rs @@ -2,6 +2,8 @@ use crate::ux::format::{JsonResponse, Output, is_json_mode}; use anyhow::{Result, anyhow}; +use auths_core::config::EnvironmentConfig; +use auths_core::storage::keychain::KeyStorage; use auths_id::storage::attestation::AttestationSource; use auths_id::storage::identity::IdentityStorage; use auths_id::storage::layout; @@ -36,6 +38,7 @@ pub struct IdentityStatus { pub controller_did: String, #[serde(skip_serializing_if = "Option::is_none")] pub alias: Option, + pub key_aliases: Vec, } /// Agent status information. @@ -78,10 +81,14 @@ pub struct ExpiringDevice { /// Handle the status command. #[allow(clippy::disallowed_methods)] -pub fn handle_status(_cmd: StatusCommand, repo: Option) -> Result<()> { +pub fn handle_status( + _cmd: StatusCommand, + repo: Option, + env_config: &EnvironmentConfig, +) -> Result<()> { let now = Utc::now(); let repo_path = resolve_repo_path(repo)?; - let identity = load_identity_status(&repo_path); + let identity = load_identity_status(&repo_path, env_config); let agent = get_agent_status(); let devices = load_devices_summary(&repo_path, now); @@ -106,12 +113,17 @@ fn print_status(report: &StatusReport, now: DateTime) { // Identity if let Some(ref id) = report.identity { - out.println(&format!("Identity: {}", out.info(&id.controller_did))); + out.println(&format!("Identity: {}", out.info(&id.controller_did))); if let Some(ref alias) = id.alias { - out.println(&format!("Alias: {}", alias)); + out.println(&format!("Alias: {}", alias)); + } + if id.key_aliases.is_empty() { + out.println(&format!("Key aliases: {}", out.dim("none"))); + } else { + out.println(&format!("Key aliases: {}", id.key_aliases.join(", "))); } } else { - out.println(&format!("Identity: {}", out.dim("not initialized"))); + out.println(&format!("Identity: {}", out.dim("not initialized"))); } // Agent @@ -230,18 +242,35 @@ fn display_device_expiry(expires_at: Option>, out: &Output, now: D } } -/// Load identity status from the repository. -fn load_identity_status(repo_path: &PathBuf) -> Option { +/// Load identity status from the repository, including key aliases from the keychain. +fn load_identity_status( + repo_path: &PathBuf, + env_config: &EnvironmentConfig, +) -> Option { if crate::factories::storage::open_git_repo(repo_path).is_err() { return None; } let storage = RegistryIdentityStorage::new(repo_path); match storage.load_identity() { - Ok(identity) => Some(IdentityStatus { - controller_did: identity.controller_did.to_string(), - alias: None, // Would need to look up from keychain - }), + Ok(identity) => { + let key_aliases = + auths_core::storage::keychain::get_platform_keychain_with_config(env_config) + .ok() + .and_then(|keychain| { + keychain + .list_aliases_for_identity(&identity.controller_did) + .ok() + }) + .map(|aliases| aliases.iter().map(|a| a.as_str().to_string()).collect()) + .unwrap_or_default(); + + Some(IdentityStatus { + controller_did: identity.controller_did.to_string(), + alias: None, + key_aliases, + }) + } Err(_) => None, } } @@ -408,7 +437,7 @@ fn is_process_running(_pid: u32) -> bool { impl crate::commands::executable::ExecutableCommand for StatusCommand { fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> { - handle_status(self.clone(), ctx.repo_path.clone()) + handle_status(self.clone(), ctx.repo_path.clone(), &ctx.env_config) } } @@ -431,6 +460,7 @@ mod tests { identity: Some(IdentityStatus { controller_did: "did:keri:ETestController123".to_string(), alias: Some("dev-machine".to_string()), + key_aliases: vec!["main".to_string()], }), agent: AgentStatusInfo { running: true, diff --git a/crates/auths-cli/src/errors/renderer.rs b/crates/auths-cli/src/errors/renderer.rs index 12e348bf..6d42d503 100644 --- a/crates/auths-cli/src/errors/renderer.rs +++ b/crates/auths-cli/src/errors/renderer.rs @@ -4,7 +4,7 @@ use auths_sdk::error::{ ApprovalError, DeviceError, DeviceExtensionError, McpAuthError, OrgError, RegistrationError, RotationError, SetupError, }; -use auths_sdk::signing::SigningError; +use auths_sdk::signing::{ArtifactSigningError, SigningError}; use auths_sdk::workflows::allowed_signers::AllowedSignersError; use auths_verifier::AttestationError; use colored::Colorize; @@ -61,6 +61,7 @@ fn extract_error_info(err: &Error) -> Option<(&str, &str, Option<&str>)> { OrgError, ApprovalError, AllowedSignersError, + ArtifactSigningError, SigningError, ); } @@ -86,7 +87,7 @@ fn render_text(err: &Error) { out.print_error(&format!("{prefix} {}", out.bold(message))); eprintln!(); if let Some(suggestion) = suggestion { - eprintln!(" fix: {suggestion}"); + eprintln!(" fix: {}", suggestion.blue()); } if let Some(url) = docs_url(code) { eprintln!(" docs: {url}"); diff --git a/crates/auths-cli/src/main.rs b/crates/auths-cli/src/main.rs index 7ada302f..29d54812 100644 --- a/crates/auths-cli/src/main.rs +++ b/crates/auths-cli/src/main.rs @@ -102,6 +102,7 @@ fn run() -> Result<()> { 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 { diff --git a/crates/auths-radicle/Cargo.toml b/crates/auths-radicle/Cargo.toml index 84fe3b75..7d5b2425 100644 --- a/crates/auths-radicle/Cargo.toml +++ b/crates/auths-radicle/Cargo.toml @@ -30,8 +30,8 @@ auths-verifier = { workspace = true, default-features = false } bs58 = "0.5.1" json-canon = "=0.1.3" # Git deps; overridden locally via [patch] in workspace root -radicle-core = { git = "https://github.com/bordumb/heartwood", branch = "dev-authsIntegration-1.6.1", features = ["serde"] } -radicle-crypto = { git = "https://github.com/bordumb/heartwood", branch = "dev-authsIntegration-1.6.1" } +radicle-core = { version = "0.1.0", git = "https://github.com/bordumb/heartwood", branch = "dev-authsIntegration-1.6.1", features = ["serde"] } +radicle-crypto = { version = "0.14.0", git = "https://github.com/bordumb/heartwood", branch = "dev-authsIntegration-1.6.1" } serde = { version = "1", features = ["derive"] } serde_json = "1.0" thiserror.workspace = true diff --git a/crates/auths-sdk/src/registration.rs b/crates/auths-sdk/src/registration.rs index a4afeaef..c9b7628b 100644 --- a/crates/auths-sdk/src/registration.rs +++ b/crates/auths-sdk/src/registration.rs @@ -3,10 +3,11 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; use auths_core::ports::network::{NetworkError, RegistryClient}; -use auths_id::keri::Prefix; use auths_id::ports::registry::RegistryBackend; use auths_id::storage::attestation::AttestationSource; use auths_id::storage::identity::IdentityStorage; +use auths_verifier::IdentityDID; +use auths_verifier::keri::Prefix; use crate::error::RegistrationError; use crate::result::RegistrationOutcome; @@ -23,7 +24,7 @@ struct RegistryOnboardingPayload { #[derive(Deserialize)] struct RegistrationResponse { - did_prefix: String, + did: IdentityDID, platform_claims_indexed: usize, } @@ -56,15 +57,11 @@ pub async fn register_identity( .load_identity() .map_err(RegistrationError::IdentityLoadError)?; - let did_prefix = identity - .controller_did - .as_str() - .strip_prefix("did:keri:") - .ok_or_else(|| RegistrationError::InvalidDidFormat { + let prefix = Prefix::from_did(&identity.controller_did).map_err(|_| { + RegistrationError::InvalidDidFormat { did: identity.controller_did.to_string(), - })?; - - let prefix = Prefix::new_unchecked(did_prefix.to_string()); + } + })?; let inception = registry .get_event(&prefix, 0) .map_err(RegistrationError::RegistryReadError)?; @@ -103,7 +100,7 @@ pub async fn register_identity( })?; Ok(RegistrationOutcome { - did_prefix: body.did_prefix, + did: body.did, registry: registry_url.to_string(), platform_claims_indexed: body.platform_claims_indexed, }) diff --git a/crates/auths-sdk/src/result.rs b/crates/auths-sdk/src/result.rs index 88cc89c5..fe4a8ff1 100644 --- a/crates/auths-sdk/src/result.rs +++ b/crates/auths-sdk/src/result.rs @@ -161,13 +161,13 @@ pub struct PlatformClaimResult { /// Usage: /// ```ignore /// if let Some(reg) = result.registered { -/// println!("Registered {} at {}", reg.did_prefix, reg.registry); +/// println!("Registered {} at {}", reg.did, reg.registry); /// } /// ``` #[derive(Debug, Clone)] pub struct RegistrationOutcome { - /// The KERI prefix portion of the registered DID. - pub did_prefix: String, + /// The DID returned by the registry (e.g. `did:keri:EABC...`). + pub did: IdentityDID, /// The registry URL where the identity was registered. pub registry: String, /// Number of platform claims indexed by the registry. diff --git a/crates/auths-sdk/src/signing.rs b/crates/auths-sdk/src/signing.rs index e97cc542..d707ff58 100644 --- a/crates/auths-sdk/src/signing.rs +++ b/crates/auths-sdk/src/signing.rs @@ -262,6 +262,36 @@ pub enum ArtifactSigningError { ResignFailed(String), } +impl auths_core::error::AuthsErrorInfo for ArtifactSigningError { + fn error_code(&self) -> &'static str { + match self { + Self::IdentityNotFound => "AUTHS-E5801", + Self::KeyResolutionFailed(_) => "AUTHS-E5802", + Self::KeyDecryptionFailed(_) => "AUTHS-E5803", + Self::DigestFailed(_) => "AUTHS-E5804", + Self::AttestationFailed(_) => "AUTHS-E5805", + Self::ResignFailed(_) => "AUTHS-E5806", + } + } + + fn suggestion(&self) -> Option<&'static str> { + match self { + Self::IdentityNotFound => { + Some("Run `auths init` to create an identity, or `auths key import` to restore one") + } + Self::KeyResolutionFailed(_) => { + Some("Run `auths status` to see available device aliases") + } + Self::KeyDecryptionFailed(_) => Some("Check your passphrase and try again"), + Self::DigestFailed(_) => Some("Verify the file exists and is readable"), + Self::AttestationFailed(_) => Some("Check identity storage with `auths status`"), + Self::ResignFailed(_) => { + Some("Verify your device key is accessible with `auths status`") + } + } + } +} + /// A `SecureSigner` backed by pre-resolved in-memory seeds. /// /// Seeds are keyed by alias. The passphrase provider is never called because diff --git a/crates/auths-sdk/src/workflows/artifact.rs b/crates/auths-sdk/src/workflows/artifact.rs index e66e3e5e..dd7962bc 100644 --- a/crates/auths-sdk/src/workflows/artifact.rs +++ b/crates/auths-sdk/src/workflows/artifact.rs @@ -87,7 +87,7 @@ pub async fn publish_artifact( serde_json::to_vec(&body).map_err(|e| ArtifactPublishError::Serialize(e.to_string()))?; let response = registry - .post_json(&config.registry_url, "v1/artifacts/publish", &json_bytes) + .post_json(&config.registry_url, "v1/artifacts", &json_bytes) .await?; match response.status { diff --git a/crates/auths-sdk/src/workflows/auth.rs b/crates/auths-sdk/src/workflows/auth.rs new file mode 100644 index 00000000..bfeedf07 --- /dev/null +++ b/crates/auths-sdk/src/workflows/auth.rs @@ -0,0 +1,188 @@ +use auths_core::crypto::provider_bridge; +use auths_core::crypto::ssh::SecureSeed; +use auths_core::error::AuthsErrorInfo; +use thiserror::Error; + +/// Result of signing an authentication challenge. +/// +/// Args: +/// * `signature_hex`: Hex-encoded Ed25519 signature over the canonical payload. +/// * `public_key_hex`: Hex-encoded Ed25519 public key. +/// * `did`: The identity's DID (e.g. `"did:keri:EPREFIX"`). +/// +/// Usage: +/// ```ignore +/// let result = sign_auth_challenge("abc123", "auths.dev", &seed, "deadbeef...", "did:keri:E...")?; +/// println!("Signature: {}", result.signature_hex); +/// ``` +#[derive(Debug, Clone)] +pub struct SignedAuthChallenge { + /// Hex-encoded Ed25519 signature over the canonical JSON payload. + pub signature_hex: String, + /// Hex-encoded Ed25519 public key of the signer. + pub public_key_hex: String, + /// The signer's identity DID (e.g. `"did:keri:EPREFIX"`). + pub did: String, +} + +/// Errors from the auth challenge signing workflow. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum AuthChallengeError { + /// The nonce was empty. + #[error("nonce must not be empty")] + EmptyNonce, + + /// The domain was empty. + #[error("domain must not be empty")] + EmptyDomain, + + /// Canonical JSON serialization failed. + #[error("canonical JSON serialization failed: {0}")] + Canonicalization(String), + + /// The Ed25519 signing operation failed. + #[error("signing failed: {0}")] + SigningFailed(String), +} + +impl AuthsErrorInfo for AuthChallengeError { + fn error_code(&self) -> &'static str { + match self { + Self::EmptyNonce => "AUTHS-E6001", + Self::EmptyDomain => "AUTHS-E6002", + Self::Canonicalization(_) => "AUTHS-E6003", + Self::SigningFailed(_) => "AUTHS-E6004", + } + } + + fn suggestion(&self) -> Option<&'static str> { + match self { + Self::EmptyNonce => Some("Provide the nonce from the authentication challenge"), + Self::EmptyDomain => Some("Provide the domain (e.g. auths.dev)"), + Self::Canonicalization(_) => { + Some("This is an internal error; please report it as a bug") + } + Self::SigningFailed(_) => { + Some("Check that your identity key is accessible with `auths key list`") + } + } + } +} + +/// Signs an authentication challenge for DID-based login. +/// +/// Constructs a canonical JSON payload `{"domain":"...","nonce":"..."}` and signs +/// it with Ed25519. The output matches the auth-server's expected `VerifyRequest` format. +/// +/// Args: +/// * `nonce`: The challenge nonce from the authentication server. +/// * `domain`: The domain requesting authentication (e.g. `"auths.dev"`). +/// * `seed`: The Ed25519 signing seed. +/// * `public_key_hex`: Hex-encoded Ed25519 public key of the signer. +/// * `did`: The signer's identity DID. +/// +/// Usage: +/// ```ignore +/// let result = sign_auth_challenge("abc123", "auths.dev", &seed, "deadbeef...", "did:keri:E...")?; +/// ``` +pub fn sign_auth_challenge( + nonce: &str, + domain: &str, + seed: &SecureSeed, + public_key_hex: &str, + did: &str, +) -> Result { + if nonce.is_empty() { + return Err(AuthChallengeError::EmptyNonce); + } + if domain.is_empty() { + return Err(AuthChallengeError::EmptyDomain); + } + + let payload = serde_json::json!({ + "domain": domain, + "nonce": nonce, + }); + let canonical = json_canon::to_string(&payload) + .map_err(|e| AuthChallengeError::Canonicalization(e.to_string()))?; + + let signature_bytes = provider_bridge::sign_ed25519_sync(seed, canonical.as_bytes()) + .map_err(|e| AuthChallengeError::SigningFailed(e.to_string()))?; + + Ok(SignedAuthChallenge { + signature_hex: hex::encode(&signature_bytes), + public_key_hex: public_key_hex.to_string(), + did: did.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use auths_core::crypto::provider_bridge; + + #[test] + fn sign_and_verify_roundtrip() { + let (seed, pubkey_bytes) = + provider_bridge::generate_ed25519_keypair_sync().expect("keygen should succeed"); + let public_key_hex = hex::encode(pubkey_bytes); + let did = "did:keri:Etest1234"; + + let result = sign_auth_challenge("test-nonce-42", "auths.dev", &seed, &public_key_hex, did) + .expect("signing should succeed"); + + assert_eq!(result.public_key_hex, public_key_hex); + assert_eq!(result.did, did); + assert!(!result.signature_hex.is_empty()); + + let canonical = json_canon::to_string(&serde_json::json!({ + "domain": "auths.dev", + "nonce": "test-nonce-42", + })) + .expect("canonical JSON"); + + let sig_bytes = hex::decode(&result.signature_hex).expect("valid hex"); + let verify_result = + provider_bridge::verify_ed25519_sync(&pubkey_bytes, canonical.as_bytes(), &sig_bytes); + assert!(verify_result.is_ok(), "signature should verify"); + } + + #[test] + fn empty_nonce_rejected() { + let (seed, pubkey_bytes) = + provider_bridge::generate_ed25519_keypair_sync().expect("keygen should succeed"); + let result = sign_auth_challenge( + "", + "auths.dev", + &seed, + &hex::encode(pubkey_bytes), + "did:keri:E1", + ); + assert!(matches!(result, Err(AuthChallengeError::EmptyNonce))); + } + + #[test] + fn empty_domain_rejected() { + let (seed, pubkey_bytes) = + provider_bridge::generate_ed25519_keypair_sync().expect("keygen should succeed"); + let result = sign_auth_challenge( + "nonce", + "", + &seed, + &hex::encode(pubkey_bytes), + "did:keri:E1", + ); + assert!(matches!(result, Err(AuthChallengeError::EmptyDomain))); + } + + #[test] + fn canonical_json_sorts_keys_alphabetically() { + let payload = serde_json::json!({ + "nonce": "abc", + "domain": "xyz", + }); + let canonical = json_canon::to_string(&payload).expect("canonical"); + assert_eq!(canonical, r#"{"domain":"xyz","nonce":"abc"}"#); + } +} diff --git a/crates/auths-sdk/src/workflows/mod.rs b/crates/auths-sdk/src/workflows/mod.rs index b947a9cd..7f802df5 100644 --- a/crates/auths-sdk/src/workflows/mod.rs +++ b/crates/auths-sdk/src/workflows/mod.rs @@ -2,6 +2,8 @@ pub mod allowed_signers; pub mod approval; pub mod artifact; pub mod audit; +/// DID-based authentication challenge signing workflow. +pub mod auth; pub mod diagnostics; pub mod git_integration; #[cfg(feature = "mcp")] diff --git a/crates/auths-utils/Cargo.toml b/crates/auths-utils/Cargo.toml index 99c4ecd7..070ef721 100644 --- a/crates/auths-utils/Cargo.toml +++ b/crates/auths-utils/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition = "2024" rust-version = "1.93" license.workspace = true -publish = false +publish = true description = "Internal shared utilities for the Auths workspace" [dependencies] diff --git a/crates/auths-verifier/src/keri.rs b/crates/auths-verifier/src/keri.rs index 75c315a7..fea8b1df 100644 --- a/crates/auths-verifier/src/keri.rs +++ b/crates/auths-verifier/src/keri.rs @@ -77,6 +77,24 @@ impl Prefix { Self(s) } + /// Extracts the KERI prefix from an `IdentityDID`. + /// + /// Args: + /// * `did`: A validated `IdentityDID` (e.g., `did:keri:ETest123`). + /// + /// Usage: + /// ```rust + /// # use auths_verifier::{IdentityDID, keri::Prefix}; + /// let did = IdentityDID::parse("did:keri:ETest123").unwrap(); + /// let prefix = Prefix::from_did(&did).unwrap(); + /// assert_eq!(prefix.as_str(), "ETest123"); + /// ``` + pub fn from_did(did: &crate::types::IdentityDID) -> Result { + let raw = did.prefix(); + validate_keri_derivation_code(raw, "Prefix")?; + Ok(Self(raw.to_string())) + } + /// Returns the inner string slice. pub fn as_str(&self) -> &str { &self.0 diff --git a/crates/auths-verifier/tests/cases/newtypes.rs b/crates/auths-verifier/tests/cases/newtypes.rs index b922a308..410f2a0d 100644 --- a/crates/auths-verifier/tests/cases/newtypes.rs +++ b/crates/auths-verifier/tests/cases/newtypes.rs @@ -1,4 +1,6 @@ -use auths_verifier::{CommitOid, CommitOidError, PolicyId, PublicKeyHex, PublicKeyHexError}; +use auths_verifier::{ + CommitOid, CommitOidError, IdentityDID, PolicyId, PublicKeyHex, PublicKeyHexError, keri::Prefix, +}; // ============================================================================= // CommitOid tests @@ -91,6 +93,25 @@ fn commit_oid_normalizes_to_lowercase() { assert_eq!(oid.as_str(), "a".repeat(40)); } +// ============================================================================= +// Prefix::from_did tests +// ============================================================================= + +#[test] +fn prefix_from_did_extracts_keri_prefix() { + let did = IdentityDID::parse("did:keri:ETest123abc").unwrap(); + let prefix = Prefix::from_did(&did).unwrap(); + assert_eq!(prefix.as_str(), "ETest123abc"); +} + +#[test] +fn prefix_from_did_roundtrips_with_identity_did() { + let did = IdentityDID::parse("did:keri:EMyPrefix456").unwrap(); + let prefix = Prefix::from_did(&did).unwrap(); + let reconstructed = IdentityDID::from_prefix(prefix.as_str()).unwrap(); + assert_eq!(did, reconstructed); +} + // ============================================================================= // PublicKeyHex tests // ============================================================================= diff --git a/deploy/get-auths-dev/src/worker.js b/deploy/get-auths-dev/src/worker.js new file mode 100644 index 00000000..83f39917 --- /dev/null +++ b/deploy/get-auths-dev/src/worker.js @@ -0,0 +1,56 @@ +// Cloudflare Worker that serves the auths install script at get.auths.dev +// +// Deploy: +// cd deploy/get-auths-dev +// npx wrangler deploy +// +// The worker fetches install.sh from the main branch on GitHub and +// caches it at the edge for 5 minutes so updates propagate quickly. + +const SCRIPT_URL = + "https://raw.githubusercontent.com/auths-dev/auths/main/scripts/install.sh"; + +export default { + async fetch(request) { + const url = new URL(request.url); + + // Health check + if (url.pathname === "/health") { + return new Response("ok", { status: 200 }); + } + + // Serve install script for root path (what curl hits) + if (url.pathname === "/" || url.pathname === "") { + const cached = await caches.default.match(request); + if (cached) return cached; + + const upstream = await fetch(SCRIPT_URL, { + headers: { "User-Agent": "get-auths-dev-worker" }, + }); + + if (!upstream.ok) { + return new Response("Failed to fetch install script", { status: 502 }); + } + + const body = await upstream.text(); + const response = new Response(body, { + headers: { + "Content-Type": "application/x-sh", + "Cache-Control": "public, max-age=300", + }, + }); + + const ctx = { waitUntil: (p) => p }; + try { + await caches.default.put(request, response.clone()); + } catch (_) { + // Edge cache put can fail in local dev + } + + return response; + } + + // Anything else: redirect to docs + return Response.redirect("https://auths.dev/docs/getting-started", 302); + }, +}; diff --git a/deploy/get-auths-dev/wrangler.toml b/deploy/get-auths-dev/wrangler.toml new file mode 100644 index 00000000..964e26e7 --- /dev/null +++ b/deploy/get-auths-dev/wrangler.toml @@ -0,0 +1,11 @@ +name = "get-auths-dev" +main = "src/worker.js" +compatibility_date = "2024-12-01" + +# After deploying, add a custom domain in the Cloudflare dashboard: +# Workers & Pages → get-auths-dev → Settings → Triggers → Custom Domains +# Add: get.auths.dev +# +# Or via wrangler (requires auths.dev zone on your Cloudflare account): +# [routes] +# routes = [{ pattern = "get.auths.dev", zone_name = "auths.dev" }] diff --git a/docs/cli/commands/advanced.md b/docs/cli/commands/advanced.md index 07ddaf1a..205b1882 100644 --- a/docs/cli/commands/advanced.md +++ b/docs/cli/commands/advanced.md @@ -1060,7 +1060,7 @@ Sign an artifact file with your Auths identity | `` | — | Path to the artifact file to sign. | | `--sig-output ` | — | Output path for the signature file. Defaults to .auths.json | | `--identity-key-alias ` | — | Local alias of the identity key. Omit for device-only CI signing. [aliases: --ika] | -| `--device-key-alias ` | — | Local alias of the device key (used for dual-signing). [aliases: --dka] | +| `--device-key-alias ` | — | Local alias of the device key. Auto-detected when only one key exists. [aliases: --dka] | | `--expires-in ` | — | Duration in seconds until expiration (per RFC 6749) | | `--note ` | — | Optional note to embed in the attestation | | `--json` | — | Emit machine-readable JSON | diff --git a/packages/auths-python/Cargo.lock b/packages/auths-python/Cargo.lock index 695b9514..15426233 100644 --- a/packages/auths-python/Cargo.lock +++ b/packages/auths-python/Cargo.lock @@ -124,7 +124,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "auths-core" -version = "0.0.1-rc.8" +version = "0.0.1-rc.9" dependencies = [ "aes-gcm", "argon2", @@ -168,7 +168,7 @@ dependencies = [ [[package]] name = "auths-crypto" -version = "0.0.1-rc.8" +version = "0.0.1-rc.9" dependencies = [ "async-trait", "base64", @@ -182,7 +182,7 @@ dependencies = [ [[package]] name = "auths-id" -version = "0.0.1-rc.8" +version = "0.0.1-rc.9" dependencies = [ "async-trait", "auths-core", @@ -218,7 +218,7 @@ dependencies = [ [[package]] name = "auths-infra-git" -version = "0.0.1-rc.8" +version = "0.0.1-rc.9" dependencies = [ "auths-core", "auths-sdk", @@ -231,7 +231,7 @@ dependencies = [ [[package]] name = "auths-pairing-daemon" -version = "0.0.1-rc.8" +version = "0.0.1-rc.9" dependencies = [ "auths-core", "axum", @@ -249,7 +249,7 @@ dependencies = [ [[package]] name = "auths-pairing-protocol" -version = "0.0.1-rc.8" +version = "0.0.1-rc.9" dependencies = [ "auths-crypto", "base64", @@ -268,7 +268,7 @@ dependencies = [ [[package]] name = "auths-policy" -version = "0.0.1-rc.8" +version = "0.0.1-rc.9" dependencies = [ "auths-verifier", "blake3", @@ -308,7 +308,7 @@ dependencies = [ [[package]] name = "auths-sdk" -version = "0.0.1-rc.8" +version = "0.0.1-rc.9" dependencies = [ "auths-core", "auths-crypto", @@ -334,7 +334,7 @@ dependencies = [ [[package]] name = "auths-storage" -version = "0.0.1-rc.8" +version = "0.0.1-rc.9" dependencies = [ "async-trait", "auths-core", @@ -357,7 +357,7 @@ dependencies = [ [[package]] name = "auths-telemetry" -version = "0.0.1-rc.8" +version = "0.0.1-rc.9" dependencies = [ "chrono", "metrics", @@ -372,7 +372,7 @@ dependencies = [ [[package]] name = "auths-transparency" -version = "0.0.1-rc.8" +version = "0.0.1-rc.9" dependencies = [ "async-trait", "auths-crypto", @@ -392,7 +392,7 @@ dependencies = [ [[package]] name = "auths-utils" -version = "0.0.1-rc.8" +version = "0.0.1-rc.9" dependencies = [ "dirs", "thiserror 2.0.18", @@ -400,7 +400,7 @@ dependencies = [ [[package]] name = "auths-verifier" -version = "0.0.1-rc.8" +version = "0.0.1-rc.9" dependencies = [ "async-trait", "auths-crypto", diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 00000000..84ce0cdd --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,140 @@ +#!/bin/sh +# Auths universal installer — https://auths.dev +# Usage: curl -fsSL https://get.auths.dev | sh +set -eu + +REPO="auths-dev/auths" +INSTALL_DIR="${AUTHS_INSTALL_DIR:-$HOME/.auths/bin}" + +main() { + detect_platform + resolve_version + download_and_install + print_success +} + +detect_platform() { + OS="$(uname -s)" + ARCH="$(uname -m)" + + case "$OS" in + Linux) os="linux" ;; + Darwin) os="macos" ;; + *) err "Unsupported OS: $OS (auths supports Linux and macOS)" ;; + esac + + case "$ARCH" in + x86_64 | amd64) arch="x86_64" ;; + aarch64 | arm64) arch="aarch64" ;; + *) err "Unsupported architecture: $ARCH" ;; + esac + + # macOS only ships aarch64 binaries — Intel Macs can use Rosetta or Homebrew + if [ "$os" = "macos" ] && [ "$arch" = "x86_64" ]; then + err "Pre-built binaries for macOS x86_64 are not available yet. + Install via Homebrew instead: + brew tap auths-dev/auths-cli && brew install auths + Or build from source: + cargo install --git https://github.com/${REPO}.git auths_cli" + fi + + ASSET="auths-${os}-${arch}.tar.gz" +} + +resolve_version() { + if [ -n "${AUTHS_VERSION:-}" ]; then + VERSION="$AUTHS_VERSION" + return + fi + + say "Fetching latest release..." + VERSION="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name"' \ + | sed -E 's/.*"([^"]+)".*/\1/')" \ + || err "Failed to fetch latest version from GitHub. + You can set AUTHS_VERSION manually: + AUTHS_VERSION=v0.0.1-rc.9 curl -fsSL https://get.auths.dev | sh" + + if [ -z "$VERSION" ]; then + err "Could not determine latest version" + fi +} + +download_and_install() { + URL="https://github.com/${REPO}/releases/download/${VERSION}/${ASSET}" + TMPDIR="$(mktemp -d)" + trap 'rm -rf "$TMPDIR"' EXIT + + say "Downloading auths ${VERSION} (${os}/${arch})..." + curl -fsSL "$URL" -o "${TMPDIR}/archive.tar.gz" \ + || err "Download failed: $URL + Check that ${VERSION} has a release asset for your platform." + + say "Verifying checksum..." + CHECKSUM_URL="${URL}.sha256" + if curl -fsSL "$CHECKSUM_URL" -o "${TMPDIR}/expected.sha256" 2>/dev/null; then + EXPECTED="$(awk '{print $1}' "${TMPDIR}/expected.sha256")" + if command -v sha256sum >/dev/null 2>&1; then + ACTUAL="$(sha256sum "${TMPDIR}/archive.tar.gz" | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then + ACTUAL="$(shasum -a 256 "${TMPDIR}/archive.tar.gz" | awk '{print $1}')" + else + say " (no sha256sum/shasum found, skipping verification)" + ACTUAL="$EXPECTED" + fi + if [ "$EXPECTED" != "$ACTUAL" ]; then + err "Checksum mismatch! + Expected: $EXPECTED + Got: $ACTUAL" + fi + say " Checksum OK" + else + say " (no checksum file available, skipping verification)" + fi + + tar -xzf "${TMPDIR}/archive.tar.gz" -C "$TMPDIR" + + mkdir -p "$INSTALL_DIR" + for bin in auths auths-sign auths-verify; do + if [ -f "${TMPDIR}/${bin}" ]; then + mv "${TMPDIR}/${bin}" "${INSTALL_DIR}/${bin}" + chmod +x "${INSTALL_DIR}/${bin}" + fi + done +} + +print_success() { + say "" + say " auths ${VERSION} installed to ${INSTALL_DIR}/auths" + say "" + + case ":${PATH}:" in + *":${INSTALL_DIR}:"*) ;; + *) + say " Add auths to your PATH by adding this to your shell profile:" + say "" + say " export PATH=\"${INSTALL_DIR}:\$PATH\"" + say "" + say " Then restart your shell or run:" + say "" + say " source ~/.bashrc # or ~/.zshrc" + say "" + ;; + esac + + say " Get started:" + say "" + say " auths init" + say "" +} + +say() { + printf '%s\n' "$@" +} + +err() { + say "Error: $1" >&2 + exit 1 +} + +main diff --git a/scripts/releases/2_crates.py b/scripts/releases/2_crates.py index 1fb053a4..fbfbc126 100644 --- a/scripts/releases/2_crates.py +++ b/scripts/releases/2_crates.py @@ -20,8 +20,9 @@ - git tag v{version} must exist (run github.py --push first) Publish order (dependency layers): - Batch 1: auths, auths-crypto, auths-jwt, auths-policy, auths-telemetry - Batch 2: auths-verifier, auths-keri, auths-pairing-protocol + Batch 1: auths, auths-crypto, auths-jwt, auths-verifier, auths-telemetry, auths-utils + Batch 2: auths-policy + Batch 3: auths-keri, auths-pairing-protocol Batch 3: auths-core, auths-index Batch 4: auths-infra-http, auths-mcp-server Batch 5: auths-id (depends on core, crypto, policy, verifier, infra-http) @@ -42,12 +43,14 @@ CRATES_IO_API = "https://crates.io/api/v1/crates" PUBLISH_BATCHES: list[list[str]] = [ - ["auths", "auths-crypto", "auths-jwt", "auths-policy", "auths-telemetry"], - ["auths-verifier", "auths-keri", "auths-pairing-protocol"], + ["auths", "auths-crypto", "auths-jwt", "auths-verifier", "auths-telemetry", "auths-utils"], + ["auths-policy"], + ["auths-keri", "auths-pairing-protocol"], ["auths-core", "auths-index"], ["auths-infra-http", "auths-mcp-server"], ["auths-id"], - ["auths-storage", "auths-sdk", "auths-radicle", "auths-pairing-daemon"], + ["auths-storage", "auths-pairing-daemon"], + ["auths-sdk"], ["auths-infra-git"], ["auths-cli"], ] @@ -121,10 +124,16 @@ def publish_crate(crate_name: str) -> bool: print(f" Publishing {crate_name}...", flush=True) result = subprocess.run( ["cargo", "publish", "-p", crate_name], + capture_output=True, + text=True, cwd=CARGO_TOML.parent, ) if result.returncode != 0: + if "already exists" in result.stderr: + print(f" {crate_name} already published — skipping.", flush=True) + return True print(f" ERROR: cargo publish -p {crate_name} failed (exit {result.returncode})", file=sys.stderr) + print(result.stderr, file=sys.stderr) return False print(f" {crate_name} published.", flush=True) return True @@ -140,16 +149,23 @@ def main() -> None: print(f"Workspace version: {version}") print(f"Crates to publish: {len(all_crates)}") - # Check that the auths root crate isn't already at this version - published = get_crate_published_version("auths") - if published: - print(f"crates.io version: {published}") - if published == version: - print(f"\nERROR: Version {version} is already published on crates.io.", file=sys.stderr) - print("Bump the version in Cargo.toml before publishing.", file=sys.stderr) - sys.exit(1) - else: - print("crates.io version: (not found or not published yet)") + # Check which crates still need publishing + already_published = [] + needs_publish = [] + for crate_name in all_crates: + pub_ver = get_crate_published_version(crate_name) + if pub_ver == version: + already_published.append(crate_name) + else: + needs_publish.append(crate_name) + + print(f"Already at {version}: {len(already_published)}") + print(f"Need publishing: {len(needs_publish)}") + if already_published: + print(f" Skipping: {', '.join(already_published)}") + if not needs_publish: + print(f"\nAll {len(all_crates)} crates are already published at {version}. Nothing to do.") + return # Check git tag exists (should run github.py --push first) if not tag_exists(tag):