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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions crates/auths-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -144,4 +145,5 @@ pub enum RootCommand {
Log(LogCommand),
#[command(hide = true)]
Account(AccountCommand),
Auth(AuthCommand),
}
36 changes: 23 additions & 13 deletions crates/auths-cli/src/commands/artifact/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,13 @@ pub enum ArtifactSubcommand {
identity_key_alias: Option<String>,

/// 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<String>,

/// Duration in seconds until expiration (per RFC 6749).
#[arg(long = "expires-in", value_name = "N")]
Expand Down Expand Up @@ -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,
Expand Down
111 changes: 111 additions & 0 deletions crates/auths-cli/src/commands/auth.rs
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
7 changes: 4 additions & 3 deletions crates/auths-cli/src/commands/id/register.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
}
Expand Down Expand Up @@ -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,
},
Expand All @@ -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: {}",
Expand Down
118 changes: 118 additions & 0 deletions crates/auths-cli/src/commands/key_detect.rs
Original file line number Diff line number Diff line change
@@ -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<KeyAlias>) -> Vec<KeyAlias> {
aliases
.into_iter()
.filter(|a| !a.as_str().contains("--next-"))
.collect()
}

fn select_device_key_interactive(aliases: &[KeyAlias]) -> Result<String> {
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<String> {
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());
}
}
2 changes: 2 additions & 0 deletions crates/auths-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Loading
Loading