use crate::agent::AgentCore;
use crate::agent::AgentHandle;
#[cfg(unix)]
use crate::agent::AgentSession;
use crate::crypto::provider_bridge;
use crate::crypto::signer::extract_seed_from_key_bytes;
use crate::crypto::signer::{decrypt_keypair, encrypt_keypair};
use crate::error::AgentError;
use crate::signing::{PassphraseProvider, PrefilledPassphraseProvider};
use crate::storage::keychain::{KeyAlias, KeyRole, KeyStorage};
use log::{debug, error, info, warn};
#[cfg(target_os = "macos")]
use p256::pkcs8::DecodePrivateKey;
#[cfg(target_os = "macos")]
use pkcs8::PrivateKeyInfo;
#[cfg(target_os = "macos")]
use pkcs8::der::Decode;
#[cfg(target_os = "macos")]
use pkcs8::der::asn1::OctetString;
use serde::Serialize;
#[cfg(unix)]
use ssh_agent_lib;
#[cfg(unix)]
use ssh_agent_lib::agent::listen;
use ssh_key::private::{Ed25519Keypair as SshEdKeypair, KeypairData};
use ssh_key::{
self, LineEnding, PrivateKey as SshPrivateKey, PublicKey as SshPublicKey,
public::Ed25519PublicKey as SshEd25519PublicKey,
};
#[cfg(unix)]
use std::io;
#[cfg(unix)]
use std::sync::Arc;
#[cfg(unix)]
use tokio::net::UnixListener;
use zeroize::Zeroizing;
#[cfg(target_os = "macos")]
use std::io::Write;
#[cfg(target_os = "macos")]
use {
std::fs::{self, Permissions},
std::os::unix::fs::PermissionsExt,
tempfile::Builder as TempFileBuilder,
};
#[cfg(target_os = "macos")]
#[derive(Debug)]
enum SshRegError {
Agent(crate::ports::ssh_agent::SshAgentError),
Io(std::io::Error),
Conversion(String),
BadSeedLength(usize),
}
#[cfg(target_os = "macos")]
impl std::fmt::Display for SshRegError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Agent(e) => write!(f, "ssh agent error: {e}"),
Self::Io(e) => write!(f, "I/O error: {e}"),
Self::Conversion(s) => write!(f, "key conversion failed: {s}"),
Self::BadSeedLength(n) => {
write!(f, "invalid PKCS#8 seed length: expected 32 bytes, got {n}")
}
}
}
}
#[cfg(target_os = "macos")]
fn compute_ssh_fingerprint(pubkey_bytes: &[u8], curve: auths_crypto::CurveType) -> String {
let key_data = match curve {
auths_crypto::CurveType::Ed25519 => SshEd25519PublicKey::try_from(pubkey_bytes)
.map(ssh_key::public::KeyData::Ed25519)
.ok(),
auths_crypto::CurveType::P256 => {
ssh_key::public::EcdsaPublicKey::from_sec1_bytes(pubkey_bytes)
.ok()
.map(ssh_key::public::KeyData::Ecdsa)
}
};
match key_data {
Some(kd) => SshPublicKey::new(kd, "")
.fingerprint(Default::default())
.to_string(),
None => {
warn!("Could not build public key for fingerprint computation");
"unknown_fingerprint".to_string()
}
}
}
#[cfg(target_os = "macos")]
fn build_ssh_keypair_data(
pkcs8_bytes: &[u8],
curve: auths_crypto::CurveType,
) -> Result<KeypairData, SshRegError> {
match curve {
auths_crypto::CurveType::Ed25519 => {
let private_key_info = PrivateKeyInfo::from_der(pkcs8_bytes)
.map_err(|e| SshRegError::Conversion(e.to_string()))?;
let seed_octet_string = OctetString::from_der(private_key_info.private_key)
.map_err(|e| SshRegError::Conversion(e.to_string()))?;
let seed_bytes = seed_octet_string.as_bytes();
if seed_bytes.len() != 32 {
return Err(SshRegError::BadSeedLength(seed_bytes.len()));
}
#[allow(clippy::expect_used)]
let seed_array: [u8; 32] = seed_bytes.try_into().expect("Length checked");
Ok(KeypairData::Ed25519(SshEdKeypair::from_seed(&seed_array)))
}
auths_crypto::CurveType::P256 => {
use p256::elliptic_curve::sec1::ToEncodedPoint;
use ssh_key::private::{EcdsaKeypair, EcdsaPrivateKey};
let secret = p256::SecretKey::from_pkcs8_der(pkcs8_bytes)
.map_err(|e| SshRegError::Conversion(format!("P-256 PKCS#8 parse failed: {e}")))?;
let public = secret.public_key();
Ok(KeypairData::Ecdsa(EcdsaKeypair::NistP256 {
public: public.to_encoded_point(false),
private: EcdsaPrivateKey::from(secret),
}))
}
}
}
#[derive(Serialize, Debug, Clone)]
pub struct KeyLoadStatus {
pub alias: KeyAlias,
pub loaded: bool,
pub error: Option<String>,
}
#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
pub enum RegistrationOutcome {
Added,
AlreadyExists,
AgentNotFound,
CommandFailed,
UnsupportedKeyType,
ConversionFailed,
IoError,
InternalError,
}
#[derive(Serialize, Debug, Clone)]
pub struct KeyRegistrationStatus {
pub fingerprint: String,
pub status: RegistrationOutcome,
pub message: Option<String>,
}
pub fn clear_agent_keys_with_handle(handle: &AgentHandle) -> Result<(), AgentError> {
info!("Clearing all keys from agent handle.");
let mut agent_guard = handle.lock()?;
agent_guard.clear_keys();
debug!("Agent keys cleared.");
Ok(())
}
pub fn load_keys_into_agent_with_handle(
handle: &AgentHandle,
aliases: Vec<String>,
passphrase_provider: &dyn PassphraseProvider,
keychain: &(dyn KeyStorage + Send + Sync),
) -> Result<Vec<KeyLoadStatus>, AgentError> {
info!(
"Attempting to load keys into agent handle for aliases: {:?}",
aliases
);
if aliases.is_empty() {
warn!("load_keys_into_agent_with_handle called with empty alias list. Clearing agent.");
clear_agent_keys_with_handle(handle)?;
return Ok(vec![]);
}
let mut load_statuses = Vec::new();
let mut temp_unlocked_core = AgentCore::default();
for alias in aliases {
debug!("Processing alias for agent load: {}", alias);
let key_alias = KeyAlias::new_unchecked(&alias);
let mut status = KeyLoadStatus {
alias: key_alias.clone(),
loaded: false,
error: None,
};
let load_result = || -> Result<Zeroizing<Vec<u8>>, AgentError> {
if keychain.is_hardware_backend() {
return Err(AgentError::HardwareKeyNotExportable {
operation: "agent key load".to_string(),
});
}
let (_controller_did, _role, encrypted_pkcs8) = keychain.load_key(&key_alias)?;
let prompt = format!(
"Enter passphrase to unlock key '{}' for agent session:",
key_alias
);
let passphrase = passphrase_provider.get_passphrase(&prompt)?;
let pkcs8_bytes = decrypt_keypair(&encrypted_pkcs8, &passphrase)?;
let _ = extract_seed_from_key_bytes(&pkcs8_bytes).map_err(|e| {
AgentError::KeyDeserializationError(format!(
"Failed to parse key for alias '{}' after decryption: {}",
key_alias, e
))
})?;
Ok(pkcs8_bytes)
}();
match load_result {
Ok(pkcs8_bytes) => {
info!("Successfully unlocked key for alias '{}'", key_alias);
match temp_unlocked_core.register_key(pkcs8_bytes) {
Ok(()) => status.loaded = true,
Err(e) => {
error!(
"Failed to register key '{}' in agent core state after successful unlock/parse: {}",
key_alias, e
);
status.error = Some(format!(
"Internal error: Failed to register key in agent core state: {}",
e
));
}
}
}
Err(e) => {
error!(
"Failed to load/decrypt key for alias '{}': {}",
key_alias, e
);
match e {
AgentError::IncorrectPassphrase => {
status.error = Some("Incorrect passphrase".to_string())
}
AgentError::KeyNotFound => status.error = Some("Key not found".to_string()),
AgentError::UserInputCancelled => {
status.error = Some("Operation cancelled by user".to_string())
}
AgentError::KeyDeserializationError(_) => {
status.error = Some(format!("Failed to parse key after decryption: {}", e))
}
_ => status.error = Some(e.to_string()),
}
}
}
load_statuses.push(status);
}
let mut agent_guard = handle.lock()?;
info!(
"Replacing agent core with {} unlocked keys ({} aliases attempted).",
temp_unlocked_core.key_count(),
load_statuses.len()
);
*agent_guard = temp_unlocked_core;
Ok(load_statuses)
}
pub fn rotate_key(
alias: &str,
new_passphrase: &str,
keychain: &(dyn KeyStorage + Send + Sync),
) -> Result<(), AgentError> {
info!(
"[API] Attempting secure storage key rotation for local alias: {}",
alias
);
if alias.trim().is_empty() {
return Err(AgentError::InvalidInput(
"Alias cannot be empty".to_string(),
));
}
if new_passphrase.is_empty() {
return Err(AgentError::InvalidInput(
"New passphrase cannot be empty".to_string(),
));
}
let key_alias = KeyAlias::new_unchecked(alias);
let existing_did = keychain.get_identity_for_alias(&key_alias)?;
info!(
"Found existing key for alias '{}', associated with Controller DID '{}'. Proceeding with rotation.",
alias, existing_did
);
let (seed, pubkey) = provider_bridge::generate_ed25519_keypair_sync()
.map_err(|e| AgentError::CryptoError(format!("Failed to generate new keypair: {}", e)))?;
let new_pkcs8_bytes = auths_crypto::build_ed25519_pkcs8_v2(seed.as_bytes(), &pubkey);
debug!("Generated new keypair via CryptoProvider.");
let encrypted_new_key = encrypt_keypair(&new_pkcs8_bytes, new_passphrase)?;
debug!("Encrypted new keypair with provided passphrase.");
keychain.store_key(
&key_alias,
&existing_did,
KeyRole::Primary,
&encrypted_new_key,
)?;
info!(
"Successfully overwrote secure storage for alias '{}' with new encrypted key.",
alias
);
warn!(
"Secure storage key rotated for alias '{}'. This did NOT update any Git identity representation. The running agent may still hold the old decrypted key. Consider reloading keys into the agent.",
alias
);
Ok(())
}
pub fn agent_sign_with_handle(
handle: &AgentHandle,
pubkey: &[u8],
data: &[u8],
) -> Result<Vec<u8>, AgentError> {
debug!(
"Agent sign request for pubkey starting with: {:x?}...",
&pubkey[..core::cmp::min(pubkey.len(), 8)]
);
handle.sign(pubkey, data)
}
pub fn export_key_openssh_pem(
alias: &str,
passphrase: &str,
keychain: &(dyn KeyStorage + Send + Sync),
) -> Result<Zeroizing<String>, AgentError> {
info!("Exporting PEM for local alias: {}", alias);
if alias.trim().is_empty() {
return Err(AgentError::InvalidInput(
"Alias cannot be empty".to_string(),
));
}
if keychain.is_hardware_backend() {
return Err(AgentError::HardwareKeyNotExportable {
operation: "OpenSSH private key export".to_string(),
});
}
let key_alias = KeyAlias::new_unchecked(alias);
let (_controller_did, _role, encrypted_pkcs8) = keychain.load_key(&key_alias)?;
let pkcs8_bytes = decrypt_keypair(&encrypted_pkcs8, passphrase)?;
let parsed = auths_crypto::parse_key_material(&pkcs8_bytes[..]).map_err(|e| {
AgentError::KeyDeserializationError(format!(
"Failed to parse key material for alias '{}': {}",
alias, e
))
})?;
let keypair_data = build_openssh_keypair_data(&parsed).map_err(|e| {
AgentError::CryptoError(format!(
"Failed to build SSH keypair for alias '{}': {}",
alias, e
))
})?;
let ssh_private_key = SshPrivateKey::new(keypair_data, "").map_err(|e| {
AgentError::CryptoError(format!(
"Failed to create ssh_key::PrivateKey for alias '{}': {}",
alias, e
))
})?;
let pem = ssh_private_key.to_openssh(LineEnding::LF).map_err(|e| {
AgentError::CryptoError(format!(
"Failed to encode OpenSSH PEM for alias '{}': {}",
alias, e
))
})?;
debug!("Successfully generated PEM for alias '{}'", alias);
Ok(pem)
}
fn build_openssh_keypair_data(parsed: &auths_crypto::ParsedKey) -> Result<KeypairData, String> {
match parsed.seed.curve() {
auths_crypto::CurveType::Ed25519 => Ok(KeypairData::Ed25519(SshEdKeypair::from_seed(
parsed.seed.as_bytes(),
))),
auths_crypto::CurveType::P256 => {
use p256::elliptic_curve::sec1::ToEncodedPoint;
use ssh_key::private::{EcdsaKeypair, EcdsaPrivateKey};
let secret = p256::SecretKey::from_slice(parsed.seed.as_bytes())
.map_err(|e| format!("P-256 secret key parse: {e}"))?;
let public = secret.public_key();
Ok(KeypairData::Ecdsa(EcdsaKeypair::NistP256 {
public: public.to_encoded_point(false),
private: EcdsaPrivateKey::from(secret),
}))
}
}
}
pub fn export_key_openssh_pub(
alias: &str,
passphrase: &str,
keychain: &(dyn KeyStorage + Send + Sync),
) -> Result<String, AgentError> {
info!("Exporting OpenSSH public key for local alias: {}", alias);
if alias.trim().is_empty() {
return Err(AgentError::InvalidInput(
"Alias cannot be empty".to_string(),
));
}
let key_alias = KeyAlias::new_unchecked(alias);
let passphrase_provider = PrefilledPassphraseProvider::new(passphrase);
let (pubkey_bytes, curve) = crate::storage::keychain::extract_public_key_bytes(
keychain,
&key_alias,
&passphrase_provider,
)?;
let key_data = match curve {
auths_crypto::CurveType::Ed25519 => {
let pk = SshEd25519PublicKey::try_from(pubkey_bytes.as_slice()).map_err(|e| {
AgentError::CryptoError(format!(
"Failed to create Ed25519PublicKey from bytes: {}",
e
))
})?;
ssh_key::public::KeyData::Ed25519(pk)
}
auths_crypto::CurveType::P256 => {
let pk = ssh_key::public::EcdsaPublicKey::from_sec1_bytes(pubkey_bytes.as_slice())
.map_err(|e| {
AgentError::CryptoError(format!(
"Failed to create EcdsaPublicKey from bytes: {}",
e
))
})?;
ssh_key::public::KeyData::Ecdsa(pk)
}
};
let ssh_pub_key = SshPublicKey::new(key_data, "");
let pubkey_base = ssh_pub_key.to_openssh().map_err(|e| {
AgentError::CryptoError(format!("Failed to format OpenSSH pubkey base: {}", e))
})?;
let formatted_pubkey = format!("{} {}", pubkey_base, alias);
debug!(
"Successfully generated OpenSSH public key string for alias '{}'",
alias
);
Ok(formatted_pubkey)
}
pub fn get_agent_key_count_with_handle(handle: &AgentHandle) -> Result<usize, AgentError> {
handle.key_count()
}
#[cfg(target_os = "macos")]
#[allow(clippy::disallowed_methods)]
#[allow(clippy::disallowed_types)]
pub fn register_keys_with_macos_agent_with_handle(
handle: &AgentHandle,
ssh_agent_socket: Option<&std::path::Path>,
ssh_agent: &dyn crate::ports::ssh_agent::SshAgentPort,
) -> Result<Vec<KeyRegistrationStatus>, AgentError> {
info!("Attempting to register keys from agent handle with system ssh-agent...");
if ssh_agent_socket.is_none() {
warn!("SSH_AUTH_SOCK not configured. System ssh-agent may not be running or configured.");
}
let keys_to_register: Vec<(Vec<u8>, auths_crypto::CurveType, Zeroizing<Vec<u8>>)> = {
let agent_guard = handle.lock()?;
agent_guard
.keys
.iter()
.filter_map(|(pubkey, stored)| {
let typed_seed = match stored.curve {
auths_crypto::CurveType::Ed25519 => {
auths_crypto::TypedSeed::Ed25519(*stored.seed.as_bytes())
}
auths_crypto::CurveType::P256 => {
auths_crypto::TypedSeed::P256(*stored.seed.as_bytes())
}
};
let signer = auths_crypto::TypedSignerKey::from_seed(typed_seed).ok()?;
let pkcs8 = signer.to_pkcs8().ok()?;
Some((
pubkey.clone(),
stored.curve,
Zeroizing::new(pkcs8.as_ref().to_vec()),
))
})
.collect()
};
register_keys_with_macos_agent_internal(keys_to_register, ssh_agent)
}
#[cfg(not(target_os = "macos"))]
pub fn register_keys_with_macos_agent_with_handle(
_handle: &AgentHandle,
_ssh_agent_socket: Option<&std::path::Path>,
_ssh_agent: &dyn crate::ports::ssh_agent::SshAgentPort,
) -> Result<Vec<KeyRegistrationStatus>, AgentError> {
info!("Not on macOS, skipping system ssh-agent registration.");
Ok(vec![])
}
#[cfg(target_os = "macos")]
#[allow(clippy::too_many_lines)]
fn register_keys_with_macos_agent_internal(
keys_to_register: Vec<(Vec<u8>, auths_crypto::CurveType, Zeroizing<Vec<u8>>)>,
ssh_agent: &dyn crate::ports::ssh_agent::SshAgentPort,
) -> Result<Vec<KeyRegistrationStatus>, AgentError> {
use crate::ports::ssh_agent::SshAgentError;
if keys_to_register.is_empty() {
info!("No keys to register with system agent.");
return Ok(vec![]);
}
info!(
"Found {} keys to attempt registration with system agent.",
keys_to_register.len()
);
let mut results = Vec::with_capacity(keys_to_register.len());
for (pubkey_bytes, curve, pkcs8_bytes_zeroizing) in keys_to_register.into_iter() {
let fingerprint_str = compute_ssh_fingerprint(&pubkey_bytes, curve);
let mut status = KeyRegistrationStatus {
fingerprint: fingerprint_str.clone(),
status: RegistrationOutcome::InternalError,
message: None,
};
let result: Result<(), SshRegError> = (|| {
let pkcs8_bytes = pkcs8_bytes_zeroizing.as_ref();
let keypair_data = build_ssh_keypair_data(pkcs8_bytes, curve)?;
let ssh_private_key = SshPrivateKey::new(keypair_data, "")
.map_err(|e| SshRegError::Conversion(e.to_string()))?;
let pem_zeroizing = ssh_private_key
.to_openssh(LineEnding::LF)
.map_err(|e| SshRegError::Conversion(e.to_string()))?;
let pem_string = pem_zeroizing.to_string();
let mut temp_file_guard = TempFileBuilder::new()
.prefix("auths-key-")
.suffix(".pem")
.rand_bytes(5)
.tempfile()
.map_err(SshRegError::Io)?;
if let Err(e) =
fs::set_permissions(temp_file_guard.path(), Permissions::from_mode(0o600))
{
warn!(
"Failed to set 600 permissions on temp file {:?}: {}. Continuing...",
temp_file_guard.path(),
e
);
}
temp_file_guard
.write_all(pem_string.as_bytes())
.map_err(SshRegError::Io)?;
temp_file_guard.flush().map_err(SshRegError::Io)?;
let temp_file_path = temp_file_guard.path().to_path_buf();
debug!(
"Attempting ssh-add for temporary key file: {:?}",
temp_file_path
);
ssh_agent
.register_key(&temp_file_path)
.map_err(SshRegError::Agent)?;
debug!("ssh-add finished for {:?}", temp_file_path);
Ok(())
})();
match result {
Ok(()) => {
info!(
"ssh-add successful for {}: Identity added.",
fingerprint_str
);
status.status = RegistrationOutcome::Added;
status.message = Some("Identity added via ssh-agent port".to_string());
}
Err(e) => {
match &e {
SshRegError::Agent(SshAgentError::NotAvailable(_)) => {
status.status = RegistrationOutcome::AgentNotFound;
}
SshRegError::Agent(SshAgentError::CommandFailed(_)) => {
status.status = RegistrationOutcome::CommandFailed;
}
SshRegError::Agent(SshAgentError::IoError(_)) | SshRegError::Io(_) => {
status.status = RegistrationOutcome::IoError;
}
SshRegError::Conversion(_) | SshRegError::BadSeedLength(_) => {
status.status = RegistrationOutcome::ConversionFailed;
}
}
error!(
"Error during registration process for {}: {:?}",
fingerprint_str, e
);
status.message = Some(format!("Registration error: {}", e));
}
}
results.push(status);
}
info!(
"Finished attempting system agent registration for {} keys.",
results.len()
);
Ok(results)
}
#[cfg(unix)]
#[allow(clippy::disallowed_methods)] pub async fn start_agent_listener_with_handle(handle: Arc<AgentHandle>) -> Result<(), AgentError> {
let socket_path = handle.socket_path();
info!("Attempting to start agent listener at {:?}", socket_path);
if let Some(parent) = socket_path.parent()
&& !parent.exists()
{
debug!("Creating parent directory for socket: {:?}", parent);
if let Err(e) = std::fs::create_dir_all(parent) {
error!("Failed to create parent directory {:?}: {}", parent, e);
return Err(AgentError::IO(e));
}
}
match std::fs::remove_file(socket_path) {
Ok(()) => info!("Removed existing socket file at {:?}", socket_path),
Err(e) if e.kind() == io::ErrorKind::NotFound => {
debug!(
"No existing socket file found at {:?}, proceeding.",
socket_path
);
}
Err(e) => {
warn!(
"Failed to remove existing socket file at {:?}: {}. Binding might fail.",
socket_path, e
);
}
}
let listener = UnixListener::bind(socket_path).map_err(|e| {
error!("Failed to bind listener socket at {:?}: {}", socket_path, e);
AgentError::IO(e)
})?;
let actual_path = socket_path
.canonicalize()
.unwrap_or_else(|_| socket_path.to_path_buf());
info!(
"🚀 Agent listener started successfully at {:?}",
actual_path
);
info!(" Set SSH_AUTH_SOCK={:?} to use this agent.", actual_path);
handle.set_running(true);
let session = AgentSession::new(handle.clone());
let result = listen(listener, session).await;
handle.set_running(false);
if let Err(e) = result {
error!("SSH Agent listener failed: {:?}", e);
return Err(AgentError::IO(io::Error::other(format!(
"SSH Agent listener failed: {}",
e
))));
}
warn!("Agent listener loop exited unexpectedly without error.");
Ok(())
}
#[cfg(unix)]
pub async fn start_agent_listener(socket_path_str: String) -> Result<(), AgentError> {
use std::path::PathBuf;
let handle = Arc::new(AgentHandle::new(PathBuf::from(&socket_path_str)));
start_agent_listener_with_handle(handle).await
}