use anyhow::{Context, Result, anyhow};
use std::path::Path;
use std::sync::Arc;
use auths_infra_http::HttpRegistryClient;
use auths_sdk::keychain::{KeyAlias, KeyStorage, get_platform_keychain};
use auths_sdk::ports::AttestationSource;
use auths_sdk::ports::IdentityStorage;
use auths_sdk::storage::{
GitRegistryBackend, RegistryAttestationStorage, RegistryConfig, RegistryIdentityStorage,
};
use auths_sdk::types::{CiEnvironment, CiIdentityConfig, CreateDeveloperIdentityConfig};
use super::InitCommand;
use super::helpers::{
check_git_version, detect_ci_environment, get_auths_repo_path, select_agent_capabilities,
};
use super::prompts::{prompt_for_alias, prompt_for_conflict_policy, prompt_for_git_scope};
use crate::ux::format::Output;
pub(crate) fn gather_developer_config(
interactive: bool,
out: &Output,
cmd: &InitCommand,
registry_path: &Path,
) -> Result<(
Box<dyn KeyStorage + Send + Sync>,
CreateDeveloperIdentityConfig,
)> {
out.print_info("Checking prerequisites...");
let keychain = check_keychain_access(interactive, out)?;
check_git_version(out)?;
out.print_success("Prerequisites OK");
out.newline();
if interactive {
out.print_info(&format!(
"Passphrase requirements: {}",
auths_sdk::keychain::PASSPHRASE_POLICY_HINT
));
}
let alias = prompt_for_alias(interactive, cmd)?;
let conflict_policy = prompt_for_conflict_policy(interactive, cmd, registry_path, out)?;
let git_scope = prompt_for_git_scope(interactive, cmd.git_scope)?;
let mut builder = CreateDeveloperIdentityConfig::builder(KeyAlias::new_unchecked(&alias))
.with_conflict_policy(conflict_policy)
.with_git_signing_scope(git_scope);
if cmd.register {
let registry = crate::commands::verify_helpers::require_registry(cmd.registry.clone())?;
builder = builder.with_registration(®istry);
}
Ok((keychain, builder.build()))
}
#[allow(clippy::type_complexity)]
pub(crate) fn gather_ci_config(
out: &Output,
repo_opt: Option<&Path>,
) -> Result<(
Option<String>,
CiIdentityConfig,
Box<dyn KeyStorage + Send + Sync>,
String,
)> {
out.print_info("Detecting CI environment...");
let ci_env = detect_ci_environment();
if let Some(ref vendor) = ci_env {
out.print_success(&format!("Detected: {}", vendor));
} else {
out.print_warn("No CI environment detected, proceeding anyway");
}
out.newline();
let registry_path = match repo_opt {
Some(p) => p.to_path_buf(),
None => std::env::current_dir()
.context("Failed to determine current working directory")?
.join(".auths-ci"),
};
let keychain_file = registry_path.join("keys.enc");
#[allow(clippy::disallowed_methods)] let (passphrase, passphrase_source) = match std::env::var("AUTHS_PASSPHRASE") {
Ok(p) => (p, "the AUTHS_PASSPHRASE environment variable"),
Err(_) => (
auths_sdk::types::generate_ci_passphrase(),
"a generated per-identity ephemeral passphrase",
),
};
preflight_passphrase(&passphrase, passphrase_source)?;
unsafe {
std::env::set_var("AUTHS_KEYCHAIN_BACKEND", "file");
std::env::set_var("AUTHS_KEYCHAIN_FILE", &keychain_file);
std::env::set_var("AUTHS_PASSPHRASE", &passphrase);
}
let keychain = get_platform_keychain()
.map_err(|e| anyhow!("Failed to get file-backed keychain: {}", e))?;
out.print_info(&format!("Using keychain: {}", keychain.backend_name()));
let config = CiIdentityConfig {
ci_environment: map_ci_environment(&ci_env),
registry_path,
keychain_file,
passphrase: passphrase.clone(),
};
Ok((ci_env, config, keychain, passphrase))
}
pub(crate) fn gather_agent_config(
interactive: bool,
out: &Output,
cmd: &InitCommand,
) -> Result<(
Box<dyn KeyStorage + Send + Sync>,
auths_sdk::types::CreateAgentIdentityConfig,
)> {
out.print_info("Setting capability scope...");
let capabilities = select_agent_capabilities(interactive, out)?;
let cap_names: Vec<String> = capabilities.iter().map(|c| c.name.clone()).collect();
out.print_success(&format!("Capabilities: {}", cap_names.join(", ")));
out.newline();
let parsed_caps: Vec<auths_verifier::Capability> = cap_names
.into_iter()
.filter_map(|s| auths_verifier::Capability::parse(&s).ok())
.collect();
out.print_info("Checking prerequisites...");
let keychain = check_keychain_access(interactive, out)?;
out.print_success("Prerequisites OK");
out.newline();
let registry_path = get_auths_repo_path()?;
let config = auths_sdk::types::CreateAgentIdentityConfig::builder(
KeyAlias::new_unchecked("agent"),
®istry_path,
)
.with_capabilities(parsed_caps)
.with_expiry(365 * 24 * 3600)
.dry_run(cmd.dry_run)
.build();
Ok((keychain, config))
}
pub(crate) fn submit_registration(
repo_path: &Path,
registry_url: Option<&str>,
proof_url: Option<String>,
skip: bool,
out: &Output,
) -> Option<String> {
if skip {
out.print_info("Registration skipped (pass --register to publish to the registry)");
return None;
}
let Some(registry_url) = registry_url else {
out.print_warn("Registration skipped: no registry configured (--registry <url>)");
return None;
};
out.print_info("Publishing identity to Auths Registry...");
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
out.print_warn(&format!("Could not create async runtime: {e}"));
return None;
}
};
let backend = Arc::new(GitRegistryBackend::from_config_unchecked(
RegistryConfig::single_tenant(repo_path),
));
let identity_storage: Arc<dyn IdentityStorage + Send + Sync> =
Arc::new(RegistryIdentityStorage::new(repo_path.to_path_buf()));
let attestation_store = Arc::new(RegistryAttestationStorage::new(repo_path));
let attestation_source: Arc<dyn AttestationSource + Send + Sync> = attestation_store;
let registry_client = HttpRegistryClient::new();
match rt.block_on(
auths_sdk::domains::identity::registration::register_identity(
identity_storage,
backend,
attestation_source,
registry_url,
proof_url,
®istry_client,
),
) {
Ok(outcome) => {
out.print_success(&format!("Identity registered at {}", outcome.registry));
Some(outcome.registry)
}
Err(auths_sdk::error::RegistrationError::AlreadyRegistered) => {
out.print_success("Already registered on this registry");
Some(registry_url.to_string())
}
Err(auths_sdk::error::RegistrationError::QuotaExceeded) => {
out.print_warn("Registration quota exceeded. Run `auths id register` to retry later.");
None
}
Err(auths_sdk::error::RegistrationError::NetworkError(_)) => {
out.print_warn(
"Could not reach the registry (offline?). Your local setup is complete.",
);
out.println(" Run `auths id register` when you're back online.");
None
}
Err(
e @ (auths_sdk::error::RegistrationError::InvalidDidFormat { .. }
| auths_sdk::error::RegistrationError::IdentityLoadError(_)
| auths_sdk::error::RegistrationError::RegistryReadError(_)
| auths_sdk::error::RegistrationError::SerializationError(_)),
) => {
out.print_warn(&format!("Could not prepare registration payload: {e}"));
out.println(" Run `auths id register` to retry.");
None
}
Err(e) => {
out.print_warn(&format!("Registration failed: {e}"));
None
}
}
}
pub(crate) fn ensure_registry_dir(registry_path: &Path) -> Result<()> {
if !registry_path.exists() {
std::fs::create_dir_all(registry_path).with_context(|| {
format!(
"Failed to create registry directory: {}",
registry_path.display()
)
})?;
}
if git2::Repository::open(registry_path).is_err() {
git2::Repository::init(registry_path).with_context(|| {
format!(
"Failed to initialize git repository: {}",
registry_path.display()
)
})?;
}
auths_sdk::domains::identity::service::install_registry_hook(registry_path);
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum KeychainSelection {
UseSelected,
SwitchToFile,
FailFastHardware,
}
pub(crate) fn select_headless_keychain(
interactive: bool,
has_backend_override: bool,
is_hardware_backend: bool,
file_backend_ready: bool,
) -> KeychainSelection {
if interactive || has_backend_override || !is_hardware_backend {
return KeychainSelection::UseSelected;
}
if file_backend_ready {
KeychainSelection::SwitchToFile
} else {
KeychainSelection::FailFastHardware
}
}
fn headless_hardware_error() -> anyhow::Error {
anyhow::Error::new(auths_sdk::error::InitError::Key(
auths_sdk::error::AgentError::BackendUnavailable {
backend: "secure-enclave",
reason: "Secure Enclave needs an interactive Touch ID prompt, \
unavailable in this headless session"
.into(),
},
))
}
pub(crate) fn check_keychain_access(
interactive: bool,
out: &Output,
) -> Result<Box<dyn KeyStorage + Send + Sync>> {
#[allow(clippy::disallowed_methods)] let has_backend_override = std::env::var_os("AUTHS_KEYCHAIN_BACKEND").is_some();
let keychain = get_platform_keychain().map_err(|e| anyhow!("Keychain not accessible: {e}"))?;
#[allow(clippy::disallowed_methods)] let file_backend_ready = std::env::var_os("AUTHS_KEYCHAIN_FILE").is_some()
&& std::env::var_os("AUTHS_PASSPHRASE").is_some();
match select_headless_keychain(
interactive,
has_backend_override,
keychain.is_hardware_backend(),
file_backend_ready,
) {
KeychainSelection::SwitchToFile => {
unsafe { std::env::set_var("AUTHS_KEYCHAIN_BACKEND", "file") }
let keychain =
get_platform_keychain().map_err(|e| anyhow!("Keychain not accessible: {e}"))?;
out.println(&format!(
" Keychain: {} (headless, file backend)",
keychain.backend_name()
));
preflight_env_passphrase(&*keychain)?;
Ok(keychain)
}
KeychainSelection::FailFastHardware => Err(headless_hardware_error()),
KeychainSelection::UseSelected => {
out.println(&format!(
" Keychain: {} (accessible)",
out.success(keychain.backend_name())
));
preflight_env_passphrase(&*keychain)?;
Ok(keychain)
}
}
}
pub(crate) fn preflight_passphrase(passphrase: &str, source_name: &str) -> Result<()> {
auths_sdk::keychain::validate_passphrase(passphrase).map_err(|e| {
anyhow::Error::new(auths_sdk::error::SetupError::WeakPassphrase {
source_name: source_name.to_string(),
reason: e.to_string(),
})
})
}
fn preflight_env_passphrase(keychain: &(dyn KeyStorage + Send + Sync)) -> Result<()> {
if keychain.is_hardware_backend() {
return Ok(());
}
#[allow(clippy::disallowed_methods)] match std::env::var("AUTHS_PASSPHRASE") {
Ok(passphrase) => {
preflight_passphrase(&passphrase, "the AUTHS_PASSPHRASE environment variable")
}
Err(_) => Ok(()),
}
}
pub(crate) fn map_ci_environment(detected: &Option<String>) -> CiEnvironment {
auths_sdk::domains::ci::map_ci_environment(detected)
}
#[cfg(test)]
mod tests {
use super::*;
use auths_sdk::error::AuthsErrorInfo;
#[test]
fn headless_hardware_without_file_backend_fails_fast() {
assert_eq!(
select_headless_keychain(false, false, true, false),
KeychainSelection::FailFastHardware
);
}
#[test]
fn headless_hardware_with_file_backend_switches_to_file() {
assert_eq!(
select_headless_keychain(false, false, true, true),
KeychainSelection::SwitchToFile
);
}
#[test]
fn interactive_or_override_or_software_uses_selected_backend() {
assert_eq!(
select_headless_keychain(true, false, true, false),
KeychainSelection::UseSelected
);
assert_eq!(
select_headless_keychain(false, true, true, false),
KeychainSelection::UseSelected
);
assert_eq!(
select_headless_keychain(false, false, false, false),
KeychainSelection::UseSelected
);
}
#[test]
fn headless_hardware_error_carries_the_key_code_and_file_remedy() {
let err = headless_hardware_error();
let init = err
.chain()
.find_map(|c| c.downcast_ref::<auths_sdk::error::InitError>())
.expect("headless hardware failure must be a typed InitError");
assert_eq!(init.error_code(), "AUTHS-E4203");
let suggestion = init.suggestion().unwrap_or_default();
assert!(
suggestion.contains("AUTHS_KEYCHAIN_BACKEND=file"),
"E4203 must name the file-backend escape hatch, got: {suggestion}"
);
}
}