Skip to main content

auths_core/api/
runtime.rs

1//! Application-level runtime API for managing the identity agent and keys.
2//!
3//! Provides functions to interact with core components: secure key storage (`KeyStorage`),
4//! cryptographic operations, the in-memory agent (`AgentCore`), and the agent listener.
5//! Uses `AgentHandle` for lifecycle management of agent instances.
6//! Also includes functions for interacting with the platform's SSH agent (on macOS).
7
8use crate::agent::AgentCore;
9use crate::agent::AgentHandle;
10#[cfg(unix)]
11use crate::agent::PeerAuthorizedAgent;
12use crate::crypto::provider_bridge;
13use crate::crypto::signer::extract_seed_from_key_bytes;
14use crate::crypto::signer::{decrypt_keypair, encrypt_keypair};
15use crate::error::AgentError;
16use crate::signing::{PassphraseProvider, PrefilledPassphraseProvider};
17use crate::storage::keychain::{KeyAlias, KeyRole, KeyStorage};
18use log::{debug, error, info, warn};
19#[cfg(target_os = "macos")]
20use p256::pkcs8::DecodePrivateKey;
21#[cfg(target_os = "macos")]
22use pkcs8::PrivateKeyInfo;
23#[cfg(target_os = "macos")]
24use pkcs8::der::Decode;
25#[cfg(target_os = "macos")]
26use pkcs8::der::asn1::OctetString;
27use serde::Serialize;
28#[cfg(unix)]
29use ssh_agent_lib;
30#[cfg(unix)]
31use ssh_agent_lib::agent::listen;
32use ssh_key::private::{Ed25519Keypair as SshEdKeypair, KeypairData};
33use ssh_key::{
34    self, LineEnding, PrivateKey as SshPrivateKey, PublicKey as SshPublicKey,
35    public::Ed25519PublicKey as SshEd25519PublicKey,
36};
37#[cfg(unix)]
38use std::io;
39#[cfg(unix)]
40use std::sync::Arc;
41#[cfg(unix)]
42use tokio::net::UnixListener;
43use zeroize::Zeroizing;
44
45#[cfg(target_os = "macos")]
46use std::io::Write;
47
48#[cfg(target_os = "macos")]
49use {
50    std::fs::{self, Permissions},
51    std::os::unix::fs::PermissionsExt,
52    tempfile::Builder as TempFileBuilder,
53};
54
55#[cfg(target_os = "macos")]
56#[derive(Debug)]
57enum SshRegError {
58    Agent(crate::ports::ssh_agent::SshAgentError),
59    Io(std::io::Error),
60    Conversion(String),
61    BadSeedLength(usize),
62}
63
64#[cfg(target_os = "macos")]
65impl std::fmt::Display for SshRegError {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match self {
68            Self::Agent(e) => write!(f, "ssh agent error: {e}"),
69            Self::Io(e) => write!(f, "I/O error: {e}"),
70            Self::Conversion(s) => write!(f, "key conversion failed: {s}"),
71            Self::BadSeedLength(n) => {
72                write!(f, "invalid PKCS#8 seed length: expected 32 bytes, got {n}")
73            }
74        }
75    }
76}
77
78/// Compute the SSH fingerprint for a public key of the given curve.
79#[cfg(target_os = "macos")]
80fn compute_ssh_fingerprint(pubkey_bytes: &[u8], curve: auths_crypto::CurveType) -> String {
81    let key_data = match curve {
82        auths_crypto::CurveType::Ed25519 => SshEd25519PublicKey::try_from(pubkey_bytes)
83            .map(ssh_key::public::KeyData::Ed25519)
84            .ok(),
85        auths_crypto::CurveType::P256 => {
86            ssh_key::public::EcdsaPublicKey::from_sec1_bytes(pubkey_bytes)
87                .ok()
88                .map(ssh_key::public::KeyData::Ecdsa)
89        }
90    };
91    match key_data {
92        Some(kd) => SshPublicKey::new(kd, "")
93            .fingerprint(Default::default())
94            .to_string(),
95        None => {
96            warn!("Could not build public key for fingerprint computation");
97            "unknown_fingerprint".to_string()
98        }
99    }
100}
101
102/// Parse a PKCS#8 key blob into an `ssh_key::private::KeypairData` variant matching the curve.
103#[cfg(target_os = "macos")]
104fn build_ssh_keypair_data(
105    pkcs8_bytes: &[u8],
106    curve: auths_crypto::CurveType,
107) -> Result<KeypairData, SshRegError> {
108    match curve {
109        auths_crypto::CurveType::Ed25519 => {
110            let private_key_info = PrivateKeyInfo::from_der(pkcs8_bytes)
111                .map_err(|e| SshRegError::Conversion(e.to_string()))?;
112            let seed_octet_string = OctetString::from_der(private_key_info.private_key)
113                .map_err(|e| SshRegError::Conversion(e.to_string()))?;
114            let seed_bytes = seed_octet_string.as_bytes();
115            if seed_bytes.len() != 32 {
116                return Err(SshRegError::BadSeedLength(seed_bytes.len()));
117            }
118            #[allow(clippy::expect_used)]
119            // INVARIANT: length validated by the 32-byte check above.
120            let seed_array: [u8; 32] = seed_bytes.try_into().expect("Length checked");
121            Ok(KeypairData::Ed25519(SshEdKeypair::from_seed(&seed_array)))
122        }
123        auths_crypto::CurveType::P256 => {
124            use p256::elliptic_curve::sec1::ToEncodedPoint;
125            use ssh_key::private::{EcdsaKeypair, EcdsaPrivateKey};
126
127            let secret = p256::SecretKey::from_pkcs8_der(pkcs8_bytes)
128                .map_err(|e| SshRegError::Conversion(format!("P-256 PKCS#8 parse failed: {e}")))?;
129            let public = secret.public_key();
130            Ok(KeypairData::Ecdsa(EcdsaKeypair::NistP256 {
131                public: public.to_encoded_point(false),
132                private: EcdsaPrivateKey::from(secret),
133            }))
134        }
135    }
136}
137
138// --- Public Structs ---
139
140/// Represents the result of trying to load a single key into the agent core.
141#[derive(Serialize, Debug, Clone)]
142pub struct KeyLoadStatus {
143    /// Key alias.
144    pub alias: KeyAlias,
145    /// Whether the key was successfully loaded.
146    pub loaded: bool,
147    /// Load error message, if any.
148    pub error: Option<String>,
149}
150
151/// Represents the outcome of attempting to register a key with the system SSH agent.
152#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
153pub enum RegistrationOutcome {
154    /// Key was successfully added to the agent.
155    Added,
156    /// Key already exists in the agent.
157    AlreadyExists,
158    /// The SSH agent process was not found.
159    AgentNotFound,
160    /// The agent command failed.
161    CommandFailed,
162    /// The key type is not supported by this agent.
163    UnsupportedKeyType,
164    /// Key format conversion failed.
165    ConversionFailed,
166    /// An I/O error occurred.
167    IoError,
168    /// An unexpected internal error occurred.
169    InternalError,
170}
171
172/// Represents the status of registering a single key with the system SSH agent.
173#[derive(Serialize, Debug, Clone)]
174pub struct KeyRegistrationStatus {
175    /// Key fingerprint.
176    pub fingerprint: String,
177    /// Registration outcome.
178    pub status: RegistrationOutcome,
179    /// Additional message, if any.
180    pub message: Option<String>,
181}
182
183// --- Public API Functions ---
184
185/// Clears all unlocked keys from the specified agent handle.
186///
187/// This effectively locks the agent until keys are reloaded.
188///
189/// # Arguments
190/// * `handle` - The agent handle to clear keys from
191///
192/// # Example
193/// ```rust,ignore
194/// use auths_core::AgentHandle;
195/// use auths_core::api::clear_agent_keys_with_handle;
196///
197/// let handle = AgentHandle::new(socket_path);
198/// clear_agent_keys_with_handle(&handle)?;
199/// ```
200pub fn clear_agent_keys_with_handle(handle: &AgentHandle) -> Result<(), AgentError> {
201    info!("Clearing all keys from agent handle.");
202    let mut agent_guard = handle.lock()?;
203    agent_guard.clear_keys();
204    debug!("Agent keys cleared.");
205    Ok(())
206}
207
208/// Loads specific keys (by alias) from secure storage into the specified agent handle.
209///
210/// Requires the correct passphrase for each key, obtained via the `passphrase_provider`.
211/// Replaces any keys currently loaded in the agent. Stores decrypted PKCS#8 bytes securely
212/// in memory using `zeroize`.
213///
214/// # Arguments
215/// * `handle` - The agent handle to load keys into
216/// * `aliases`: A list of key aliases to load from secure storage.
217/// * `passphrase_provider`: A component responsible for securely obtaining passphrases.
218/// * `keychain`: The key storage backend to load keys from.
219///
220/// # Returns
221/// A `Result` containing a list of `KeyLoadStatus` structs, indicating the outcome
222/// for each requested alias, or an `AgentError` if a fatal error occurs.
223pub fn load_keys_into_agent_with_handle(
224    handle: &AgentHandle,
225    aliases: Vec<String>,
226    passphrase_provider: &dyn PassphraseProvider,
227    keychain: &(dyn KeyStorage + Send + Sync),
228) -> Result<Vec<KeyLoadStatus>, AgentError> {
229    info!(
230        "Attempting to load keys into agent handle for aliases: {:?}",
231        aliases
232    );
233    if aliases.is_empty() {
234        warn!("load_keys_into_agent_with_handle called with empty alias list. Clearing agent.");
235        clear_agent_keys_with_handle(handle)?;
236        return Ok(vec![]);
237    }
238
239    let mut load_statuses = Vec::new();
240    let mut temp_unlocked_core = AgentCore::default();
241
242    for alias in aliases {
243        debug!("Processing alias for agent load: {}", alias);
244        let key_alias = KeyAlias::new_unchecked(&alias);
245        let mut status = KeyLoadStatus {
246            alias: key_alias.clone(),
247            loaded: false,
248            error: None,
249        };
250
251        let load_result = || -> Result<Zeroizing<Vec<u8>>, AgentError> {
252            if keychain.is_hardware_backend() {
253                return Err(AgentError::HardwareKeyNotExportable {
254                    operation: "agent key load".to_string(),
255                });
256            }
257            let (_controller_did, _role, encrypted_pkcs8) = keychain.load_key(&key_alias)?;
258            let prompt = format!(
259                "Enter passphrase to unlock key '{}' for agent session:",
260                key_alias
261            );
262            let passphrase = passphrase_provider.get_passphrase(&prompt)?;
263            let pkcs8_bytes = decrypt_keypair(&encrypted_pkcs8, &passphrase)?;
264            let _ = extract_seed_from_key_bytes(&pkcs8_bytes).map_err(|e| {
265                AgentError::KeyDeserializationError(format!(
266                    "Failed to parse key for alias '{}' after decryption: {}",
267                    key_alias, e
268                ))
269            })?;
270            Ok(pkcs8_bytes)
271        }();
272
273        match load_result {
274            Ok(pkcs8_bytes) => {
275                info!("Successfully unlocked key for alias '{}'", key_alias);
276                match temp_unlocked_core.register_key(pkcs8_bytes) {
277                    Ok(()) => status.loaded = true,
278                    Err(e) => {
279                        error!(
280                            "Failed to register key '{}' in agent core state after successful unlock/parse: {}",
281                            key_alias, e
282                        );
283                        status.error = Some(format!(
284                            "Internal error: Failed to register key in agent core state: {}",
285                            e
286                        ));
287                    }
288                }
289            }
290            Err(e) => {
291                error!(
292                    "Failed to load/decrypt key for alias '{}': {}",
293                    key_alias, e
294                );
295                match e {
296                    AgentError::IncorrectPassphrase => {
297                        status.error = Some("Incorrect passphrase".to_string())
298                    }
299                    AgentError::KeyNotFound => status.error = Some("Key not found".to_string()),
300                    AgentError::UserInputCancelled => {
301                        status.error = Some("Operation cancelled by user".to_string())
302                    }
303                    AgentError::KeyDeserializationError(_) => {
304                        status.error = Some(format!("Failed to parse key after decryption: {}", e))
305                    }
306                    _ => status.error = Some(e.to_string()),
307                }
308            }
309        }
310        load_statuses.push(status);
311    }
312
313    // Atomically update the agent state
314    let mut agent_guard = handle.lock()?;
315    info!(
316        "Replacing agent core with {} unlocked keys ({} aliases attempted).",
317        temp_unlocked_core.key_count(),
318        load_statuses.len()
319    );
320    *agent_guard = temp_unlocked_core;
321
322    Ok(load_statuses)
323}
324
325/// Rotates the keypair for a given alias *in the secure storage only*.
326///
327/// This generates a new Ed25519 keypair, encrypts it with the `new_passphrase`,
328/// and overwrites the existing entry for `alias` in the platform's keychain or
329/// secure storage. The key remains associated with the *same Controller DID*
330/// as the original key.
331///
332/// **Warning:** This function does *not* update any corresponding identity
333/// representation in a Git repository (e.g., changing the Controller DID stored
334/// in an identity commit or creating a KERI rotation event). Using this function
335/// alone may lead to inconsistencies if the identity representation relies on the
336/// public key associated with the Controller DID. It also does not automatically
337/// update the key loaded in the running agent; `load_keys_into_agent` or restarting
338/// the agent may be required.
339///
340/// # Arguments
341/// * `alias`: The alias of the key entry in secure storage to rotate.
342/// * `new_passphrase`: The passphrase to encrypt the *new* private key with.
343///
344/// # Returns
345/// `Ok(())` on success, or an `AgentError` if the alias is not found, key generation
346/// fails, encryption fails, or storage fails.
347pub fn rotate_key(
348    alias: &str,
349    new_passphrase: &str,
350    keychain: &(dyn KeyStorage + Send + Sync),
351) -> Result<(), AgentError> {
352    info!(
353        "[API] Attempting secure storage key rotation for local alias: {}",
354        alias
355    );
356    if alias.trim().is_empty() {
357        return Err(AgentError::InvalidInput(
358            "Alias cannot be empty".to_string(),
359        ));
360    }
361    if new_passphrase.is_empty() {
362        return Err(AgentError::InvalidInput(
363            "New passphrase cannot be empty".to_string(),
364        ));
365    }
366
367    // 1. Verify the alias exists and retrieve its associated Controller DID
368    let key_alias = KeyAlias::new_unchecked(alias);
369    let existing_did = keychain.get_identity_for_alias(&key_alias)?;
370    info!(
371        "Found existing key for alias '{}', associated with Controller DID '{}'. Proceeding with rotation.",
372        alias, existing_did
373    );
374
375    // 2. Generate new keypair via CryptoProvider
376    let (seed, pubkey) = provider_bridge::generate_ed25519_keypair_sync()
377        .map_err(|e| AgentError::CryptoError(format!("Failed to generate new keypair: {}", e)))?;
378    // Build PKCS#8 v2 DER for storage compatibility
379    let new_pkcs8_bytes = auths_crypto::build_ed25519_pkcs8_v2(seed.as_bytes(), &pubkey);
380    debug!("Generated new keypair via CryptoProvider.");
381
382    // 3. Encrypt the new keypair with the new passphrase
383    let encrypted_new_key = encrypt_keypair(&new_pkcs8_bytes, new_passphrase)?;
384    debug!("Encrypted new keypair with provided passphrase.");
385
386    // 4. Overwrite the existing entry in secure storage with the new encrypted key,
387    //    keeping the original Controller DID association.
388    keychain.store_key(
389        &key_alias,
390        &existing_did,
391        KeyRole::Primary,
392        &encrypted_new_key,
393    )?;
394    info!(
395        "Successfully overwrote secure storage for alias '{}' with new encrypted key.",
396        alias
397    );
398
399    warn!(
400        "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.",
401        alias
402    );
403    Ok(())
404}
405
406/// Signs a message using a key currently loaded in the specified agent handle.
407///
408/// This retrieves the decrypted key material from the agent handle based on the
409/// provided public key bytes and performs the signing operation. It does *not*
410/// require a passphrase as the key is assumed to be already unlocked.
411///
412/// # Arguments
413/// * `handle` - The agent handle containing the loaded keys
414/// * `pubkey`: The public key bytes of the key to use for signing.
415/// * `data`: The data bytes to sign.
416///
417/// # Returns
418/// The raw signature bytes, or an `AgentError` if the key is not found in the
419/// agent core or if the signing operation fails internally.
420pub fn agent_sign_with_handle(
421    handle: &AgentHandle,
422    pubkey: &[u8],
423    data: &[u8],
424) -> Result<Vec<u8>, AgentError> {
425    debug!(
426        "Agent sign request for pubkey starting with: {:x?}...",
427        &pubkey[..core::cmp::min(pubkey.len(), 8)]
428    );
429
430    // Use the handle's sign method which includes lock check
431    handle.sign(pubkey, data)
432}
433
434/// Exports the decrypted private key in OpenSSH PEM format.
435///
436/// Retrieves the encrypted key from secure storage, decrypts it using the
437/// provided passphrase, and formats it as a standard OpenSSH PEM private key string.
438///
439/// # Arguments
440/// * `alias`: The alias of the key in secure storage.
441/// * `passphrase`: The passphrase to decrypt the key.
442///
443/// # Returns
444/// A `Zeroizing<String>` containing the PEM data on success, or an `AgentError`.
445pub fn export_key_openssh_pem(
446    alias: &str,
447    passphrase: &str,
448    keychain: &(dyn KeyStorage + Send + Sync),
449) -> Result<Zeroizing<String>, AgentError> {
450    info!("Exporting PEM for local alias: {}", alias);
451    if alias.trim().is_empty() {
452        return Err(AgentError::InvalidInput(
453            "Alias cannot be empty".to_string(),
454        ));
455    }
456    if keychain.is_hardware_backend() {
457        return Err(AgentError::HardwareKeyNotExportable {
458            operation: "OpenSSH private key export".to_string(),
459        });
460    }
461    // 1. Load encrypted key data
462    let key_alias = KeyAlias::new_unchecked(alias);
463    let (_controller_did, _role, encrypted_pkcs8) = keychain.load_key(&key_alias)?;
464
465    // 2. Decrypt key data
466    let pkcs8_bytes = decrypt_keypair(&encrypted_pkcs8, passphrase)?;
467
468    // 3. Parse the key material (auto-detects curve)
469    let parsed = auths_crypto::parse_key_material(&pkcs8_bytes[..]).map_err(|e| {
470        AgentError::KeyDeserializationError(format!(
471            "Failed to parse key material for alias '{}': {}",
472            alias, e
473        ))
474    })?;
475
476    let keypair_data = build_openssh_keypair_data(&parsed).map_err(|e| {
477        AgentError::CryptoError(format!(
478            "Failed to build SSH keypair for alias '{}': {}",
479            alias, e
480        ))
481    })?;
482
483    let ssh_private_key = SshPrivateKey::new(keypair_data, "").map_err(|e| {
484        AgentError::CryptoError(format!(
485            "Failed to create ssh_key::PrivateKey for alias '{}': {}",
486            alias, e
487        ))
488    })?;
489
490    let pem = ssh_private_key.to_openssh(LineEnding::LF).map_err(|e| {
491        AgentError::CryptoError(format!(
492            "Failed to encode OpenSSH PEM for alias '{}': {}",
493            alias, e
494        ))
495    })?;
496
497    debug!("Successfully generated PEM for alias '{}'", alias);
498    Ok(pem)
499}
500
501/// Build `ssh_key::private::KeypairData` from a parsed key material, dispatching on curve.
502fn build_openssh_keypair_data(parsed: &auths_crypto::ParsedKey) -> Result<KeypairData, String> {
503    match parsed.seed.curve() {
504        auths_crypto::CurveType::Ed25519 => Ok(KeypairData::Ed25519(SshEdKeypair::from_seed(
505            parsed.seed.as_bytes(),
506        ))),
507        auths_crypto::CurveType::P256 => {
508            use p256::elliptic_curve::sec1::ToEncodedPoint;
509            use ssh_key::private::{EcdsaKeypair, EcdsaPrivateKey};
510
511            let secret = p256::SecretKey::from_slice(parsed.seed.as_bytes())
512                .map_err(|e| format!("P-256 secret key parse: {e}"))?;
513            let public = secret.public_key();
514            Ok(KeypairData::Ecdsa(EcdsaKeypair::NistP256 {
515                public: public.to_encoded_point(false),
516                private: EcdsaPrivateKey::from(secret),
517            }))
518        }
519    }
520}
521
522/// Exports the public key in OpenSSH `.pub` format.
523///
524/// Retrieves the encrypted key from secure storage, decrypts it using the
525/// provided passphrase, derives the public key, and formats it as a standard
526/// OpenSSH `.pub` line (including the alias as a comment).
527///
528/// # Arguments
529/// * `alias`: The alias of the key in secure storage.
530/// * `passphrase`: The passphrase to decrypt the key.
531///
532/// # Returns
533/// A `String` containing the public key line on success, or an `AgentError`.
534pub fn export_key_openssh_pub(
535    alias: &str,
536    passphrase: &str,
537    keychain: &(dyn KeyStorage + Send + Sync),
538) -> Result<String, AgentError> {
539    info!("Exporting OpenSSH public key for local alias: {}", alias);
540    if alias.trim().is_empty() {
541        return Err(AgentError::InvalidInput(
542            "Alias cannot be empty".to_string(),
543        ));
544    }
545    // 1. Obtain public key bytes (hardware-aware; SE returns pubkey without decryption)
546    let key_alias = KeyAlias::new_unchecked(alias);
547    let passphrase_provider = PrefilledPassphraseProvider::new(passphrase);
548    let (pubkey_bytes, curve) = crate::storage::keychain::extract_public_key_bytes(
549        keychain,
550        &key_alias,
551        &passphrase_provider,
552    )?;
553    let key_data = match curve {
554        auths_crypto::CurveType::Ed25519 => {
555            let pk = SshEd25519PublicKey::try_from(pubkey_bytes.as_slice()).map_err(|e| {
556                AgentError::CryptoError(format!(
557                    "Failed to create Ed25519PublicKey from bytes: {}",
558                    e
559                ))
560            })?;
561            ssh_key::public::KeyData::Ed25519(pk)
562        }
563        auths_crypto::CurveType::P256 => {
564            let pk = ssh_key::public::EcdsaPublicKey::from_sec1_bytes(pubkey_bytes.as_slice())
565                .map_err(|e| {
566                    AgentError::CryptoError(format!(
567                        "Failed to create EcdsaPublicKey from bytes: {}",
568                        e
569                    ))
570                })?;
571            ssh_key::public::KeyData::Ecdsa(pk)
572        }
573    };
574
575    // 5. Create the ssh-key PublicKey object (comment is optional here)
576    let ssh_pub_key = SshPublicKey::new(key_data, ""); // Use empty comment for base formatting
577
578    // 6. Format the base public key string (type and key material)
579    let pubkey_base = ssh_pub_key.to_openssh().map_err(|e| {
580        // Use CryptoError for formatting failure
581        AgentError::CryptoError(format!("Failed to format OpenSSH pubkey base: {}", e))
582    })?;
583
584    // 7. Manually append the alias as the comment part of the .pub line
585    let formatted_pubkey = format!("{} {}", pubkey_base, alias);
586
587    debug!(
588        "Successfully generated OpenSSH public key string for alias '{}'",
589        alias
590    );
591    Ok(formatted_pubkey)
592}
593
594/// Returns the number of keys currently loaded in the specified agent handle.
595///
596/// # Arguments
597/// * `handle` - The agent handle to query
598///
599/// # Returns
600/// The number of keys currently loaded.
601pub fn get_agent_key_count_with_handle(handle: &AgentHandle) -> Result<usize, AgentError> {
602    handle.key_count()
603}
604
605/// Attempts to register all keys currently loaded in the specified agent handle
606/// with the system's running SSH agent via the injected `SshAgentPort`.
607///
608/// This iterates through the unlocked keys in the agent core, converts each to
609/// OpenSSH PEM format, writes it to a temporary file, and delegates to the
610/// provided `ssh_agent` port for the actual registration.
611///
612/// Args:
613/// * `handle` - The agent handle containing the keys to register.
614/// * `ssh_agent_socket` - Optional path to the SSH agent socket (for diagnostics).
615/// * `ssh_agent` - Port implementation that registers keys with the system agent.
616///
617/// Usage:
618/// ```ignore
619/// use auths_core::api::runtime::register_keys_with_macos_agent_with_handle;
620///
621/// let statuses = register_keys_with_macos_agent_with_handle(&handle, None, &adapter)?;
622/// ```
623#[cfg(target_os = "macos")]
624#[allow(clippy::disallowed_methods)]
625// INVARIANT: macOS SSH agent registration — temp file creation and permissions are inherently I/O
626#[allow(clippy::disallowed_types)]
627pub fn register_keys_with_macos_agent_with_handle(
628    handle: &AgentHandle,
629    ssh_agent_socket: Option<&std::path::Path>,
630    ssh_agent: &dyn crate::ports::ssh_agent::SshAgentPort,
631) -> Result<Vec<KeyRegistrationStatus>, AgentError> {
632    info!("Attempting to register keys from agent handle with system ssh-agent...");
633    if ssh_agent_socket.is_none() {
634        warn!("SSH_AUTH_SOCK not configured. System ssh-agent may not be running or configured.");
635    }
636
637    let keys_to_register: Vec<(Vec<u8>, auths_crypto::CurveType, Zeroizing<Vec<u8>>)> = {
638        let agent_guard = handle.lock()?;
639        agent_guard
640            .keys
641            .iter()
642            .filter_map(|(pubkey, stored)| {
643                let typed_seed = match stored.curve {
644                    auths_crypto::CurveType::Ed25519 => {
645                        auths_crypto::TypedSeed::Ed25519(*stored.seed.as_bytes())
646                    }
647                    auths_crypto::CurveType::P256 => {
648                        auths_crypto::TypedSeed::P256(*stored.seed.as_bytes())
649                    }
650                };
651                let signer = auths_crypto::TypedSignerKey::from_seed(typed_seed).ok()?;
652                let pkcs8 = signer.to_pkcs8().ok()?;
653                Some((
654                    pubkey.clone(),
655                    stored.curve,
656                    Zeroizing::new(pkcs8.as_ref().to_vec()),
657                ))
658            })
659            .collect()
660    };
661
662    register_keys_with_macos_agent_internal(keys_to_register, ssh_agent)
663}
664
665/// Stub function for non-macOS platforms.
666#[cfg(not(target_os = "macos"))]
667pub fn register_keys_with_macos_agent_with_handle(
668    _handle: &AgentHandle,
669    _ssh_agent_socket: Option<&std::path::Path>,
670    _ssh_agent: &dyn crate::ports::ssh_agent::SshAgentPort,
671) -> Result<Vec<KeyRegistrationStatus>, AgentError> {
672    info!("Not on macOS, skipping system ssh-agent registration.");
673    Ok(vec![])
674}
675
676/// Internal helper that performs the actual system SSH agent registration.
677///
678/// Converts each PKCS#8 key to OpenSSH PEM, writes to a temp file, and
679/// delegates to the injected `SshAgentPort` for the actual `ssh-add` call.
680#[cfg(target_os = "macos")]
681#[allow(clippy::too_many_lines)]
682fn register_keys_with_macos_agent_internal(
683    keys_to_register: Vec<(Vec<u8>, auths_crypto::CurveType, Zeroizing<Vec<u8>>)>,
684    ssh_agent: &dyn crate::ports::ssh_agent::SshAgentPort,
685) -> Result<Vec<KeyRegistrationStatus>, AgentError> {
686    use crate::ports::ssh_agent::SshAgentError;
687
688    if keys_to_register.is_empty() {
689        info!("No keys to register with system agent.");
690        return Ok(vec![]);
691    }
692    info!(
693        "Found {} keys to attempt registration with system agent.",
694        keys_to_register.len()
695    );
696
697    let mut results = Vec::with_capacity(keys_to_register.len());
698
699    for (pubkey_bytes, curve, pkcs8_bytes_zeroizing) in keys_to_register.into_iter() {
700        let fingerprint_str = compute_ssh_fingerprint(&pubkey_bytes, curve);
701
702        let mut status = KeyRegistrationStatus {
703            fingerprint: fingerprint_str.clone(),
704            status: RegistrationOutcome::InternalError,
705            message: None,
706        };
707
708        let result: Result<(), SshRegError> = (|| {
709            let pkcs8_bytes = pkcs8_bytes_zeroizing.as_ref();
710            let keypair_data = build_ssh_keypair_data(pkcs8_bytes, curve)?;
711            let ssh_private_key = SshPrivateKey::new(keypair_data, "")
712                .map_err(|e| SshRegError::Conversion(e.to_string()))?;
713            let pem_zeroizing = ssh_private_key
714                .to_openssh(LineEnding::LF)
715                .map_err(|e| SshRegError::Conversion(e.to_string()))?;
716            let pem_string = pem_zeroizing.to_string();
717
718            let mut temp_file_guard = TempFileBuilder::new()
719                .prefix("auths-key-")
720                .suffix(".pem")
721                .rand_bytes(5)
722                .tempfile()
723                .map_err(SshRegError::Io)?;
724            if let Err(e) =
725                fs::set_permissions(temp_file_guard.path(), Permissions::from_mode(0o600))
726            {
727                warn!(
728                    "Failed to set 600 permissions on temp file {:?}: {}. Continuing...",
729                    temp_file_guard.path(),
730                    e
731                );
732            }
733            temp_file_guard
734                .write_all(pem_string.as_bytes())
735                .map_err(SshRegError::Io)?;
736            temp_file_guard.flush().map_err(SshRegError::Io)?;
737            let temp_file_path = temp_file_guard.path().to_path_buf();
738
739            debug!(
740                "Attempting ssh-add for temporary key file: {:?}",
741                temp_file_path
742            );
743            ssh_agent
744                .register_key(&temp_file_path)
745                .map_err(SshRegError::Agent)?;
746            debug!("ssh-add finished for {:?}", temp_file_path);
747            Ok(())
748        })();
749
750        match result {
751            Ok(()) => {
752                info!(
753                    "ssh-add successful for {}: Identity added.",
754                    fingerprint_str
755                );
756                status.status = RegistrationOutcome::Added;
757                status.message = Some("Identity added via ssh-agent port".to_string());
758            }
759            Err(e) => {
760                match &e {
761                    SshRegError::Agent(SshAgentError::NotAvailable(_)) => {
762                        status.status = RegistrationOutcome::AgentNotFound;
763                    }
764                    SshRegError::Agent(SshAgentError::CommandFailed(_)) => {
765                        status.status = RegistrationOutcome::CommandFailed;
766                    }
767                    SshRegError::Agent(SshAgentError::IoError(_)) | SshRegError::Io(_) => {
768                        status.status = RegistrationOutcome::IoError;
769                    }
770                    SshRegError::Conversion(_) | SshRegError::BadSeedLength(_) => {
771                        status.status = RegistrationOutcome::ConversionFailed;
772                    }
773                }
774                error!(
775                    "Error during registration process for {}: {:?}",
776                    fingerprint_str, e
777                );
778                status.message = Some(format!("Registration error: {}", e));
779            }
780        }
781        results.push(status);
782    }
783
784    info!(
785        "Finished attempting system agent registration for {} keys.",
786        results.len()
787    );
788    Ok(results)
789}
790
791/// Ensures the directory that holds the agent socket is restricted to the owner.
792///
793/// If the directory does not exist it is created (with parents) and locked to `0o700`.
794/// If it already exists it is accepted only when it is already owner-only (no group or
795/// other access) and owned by this user; otherwise it is refused (fail closed) rather
796/// than silently widening or narrowing a directory the agent did not create. A
797/// non-directory or a symlink at the path is also refused.
798///
799/// Args:
800/// * `dir`: The directory the socket lives in.
801///
802/// Usage:
803/// ```ignore
804/// harden_socket_dir(socket_path.parent().expect("socket has a parent"))?;
805/// ```
806#[cfg(unix)]
807fn harden_socket_dir(dir: &std::path::Path) -> std::io::Result<()> {
808    use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
809
810    match std::fs::symlink_metadata(dir) {
811        Ok(meta) if meta.file_type().is_dir() => {
812            let mode = meta.permissions().mode() & 0o777;
813            if mode & 0o077 != 0 {
814                return Err(io::Error::new(
815                    io::ErrorKind::PermissionDenied,
816                    format!(
817                        "socket directory {dir:?} is group/other-accessible (mode {mode:o}); refusing"
818                    ),
819                ));
820            }
821            if meta.uid() != current_euid() {
822                return Err(io::Error::new(
823                    io::ErrorKind::PermissionDenied,
824                    format!("socket directory {dir:?} is not owned by this user; refusing"),
825                ));
826            }
827            Ok(())
828        }
829        Ok(_) => Err(io::Error::new(
830            io::ErrorKind::AlreadyExists,
831            format!("socket directory path {dir:?} exists but is not a directory; refusing"),
832        )),
833        Err(e) if e.kind() == io::ErrorKind::NotFound => {
834            std::fs::create_dir_all(dir)?;
835            std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
836        }
837        Err(e) => Err(e),
838    }
839}
840
841/// Restricts a bound agent socket to owner-only read/write (`0o600`) so only the
842/// owning user can connect and request signatures.
843///
844/// This is best-effort defense-in-depth: there is a brief window between `bind` and
845/// this call. The authoritative, race-free control is the owner-only (`0o700`) socket
846/// directory established by [`harden_socket_dir`] — no other user can traverse into it
847/// to reach the socket regardless of the socket file's own mode.
848///
849/// Args:
850/// * `socket_path`: The path of the already-bound Unix-domain socket.
851///
852/// Usage:
853/// ```ignore
854/// harden_socket_file(socket_path)?;
855/// ```
856#[cfg(unix)]
857fn harden_socket_file(socket_path: &std::path::Path) -> std::io::Result<()> {
858    use std::os::unix::fs::PermissionsExt as _;
859    std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o600))
860}
861
862/// Returns the directory that must be restricted to the owner for a given socket
863/// path, refusing a path that has no such directory (a bare relative name) rather
864/// than leaving the socket in an unrestricted location.
865///
866/// Args:
867/// * `socket_path`: The socket path whose containing directory will be locked down.
868///
869/// Usage:
870/// ```ignore
871/// let dir = socket_dir_to_harden(socket_path)?;
872/// ```
873#[cfg(unix)]
874fn socket_dir_to_harden(socket_path: &std::path::Path) -> Result<&std::path::Path, AgentError> {
875    match socket_path.parent() {
876        Some(parent) if !parent.as_os_str().is_empty() => Ok(parent),
877        _ => Err(AgentError::IO(io::Error::new(
878            io::ErrorKind::InvalidInput,
879            "agent socket path must include a directory that can be restricted to the owner",
880        ))),
881    }
882}
883
884/// Returns the effective user id of the current process — the only user permitted
885/// to connect to the agent socket.
886#[cfg(unix)]
887fn current_euid() -> u32 {
888    // SAFETY: `geteuid` takes no arguments, has no preconditions, and always succeeds.
889    unsafe { libc::geteuid() }
890}
891
892/// Chooses how often to check the agent for auto-lock, bounded to a sensible range.
893/// Returns `None` when both the idle timeout and the absolute unlock cap are disabled.
894#[cfg(unix)]
895fn idle_monitor_interval(
896    idle_timeout: std::time::Duration,
897    max_unlock_ttl: std::time::Duration,
898) -> Option<std::time::Duration> {
899    use std::time::Duration;
900    let shortest = [idle_timeout, max_unlock_ttl]
901        .into_iter()
902        .filter(|d| !d.is_zero())
903        .min()?;
904    Some((shortest / 4).clamp(Duration::from_secs(1), Duration::from_secs(60)))
905}
906
907/// Locks the agent once it has been idle past its timeout, clearing its keys, so a
908/// single unlock does not leave signing capability available indefinitely.
909///
910/// Runs until the agent stops; intended to be spawned in the background.
911///
912/// Args:
913/// * `handle`: The agent handle to monitor and lock when idle.
914/// * `interval`: How often to check for idle timeout.
915///
916/// Usage:
917/// ```ignore
918/// tokio::spawn(run_idle_monitor(handle.clone(), interval));
919/// ```
920#[cfg(unix)]
921async fn run_idle_monitor(handle: Arc<AgentHandle>, interval: std::time::Duration) {
922    loop {
923        tokio::time::sleep(interval).await;
924        if !handle.is_running() {
925            break;
926        }
927        if let Err(e) = handle.check_idle_timeout() {
928            warn!("Idle timeout check failed: {e}");
929        }
930    }
931}
932
933/// Starts the SSH agent listener using the provided `AgentHandle`.
934///
935/// Binds to the socket path from the handle, restricts the socket and its directory
936/// to the owner, and enters an asynchronous loop (`ssh_agent_lib::listen`) that
937/// authorizes each connection by peer UID before serving it.
938///
939/// Requires a `tokio` runtime context. Runs indefinitely on success.
940///
941/// # Arguments
942/// * `handle`: The agent handle containing the socket path and agent core.
943///
944/// # Returns
945/// - `Ok(())` if the listener starts successfully (runs indefinitely).
946/// - `Err(AgentError)` if binding/setup fails or the listener loop exits with an error.
947#[cfg(unix)]
948#[allow(clippy::disallowed_methods)] // INVARIANT: Unix socket lifecycle — socket dir creation and cleanup is inherently I/O
949pub async fn start_agent_listener_with_handle(
950    handle: Arc<AgentHandle>,
951    authorizer: Arc<dyn crate::agent::SignAuthorizer>,
952) -> Result<(), AgentError> {
953    let socket_path = handle.socket_path();
954    info!("Attempting to start agent listener at {:?}", socket_path);
955
956    // --- Ensure the socket lives in an owner-only directory ---
957    let socket_dir = socket_dir_to_harden(socket_path)?;
958    if let Err(e) = harden_socket_dir(socket_dir) {
959        error!(
960            "Failed to prepare owner-only socket directory {:?}: {}",
961            socket_dir, e
962        );
963        return Err(AgentError::IO(e));
964    }
965
966    // --- Clean up existing socket file (if any) ---
967    match std::fs::remove_file(socket_path) {
968        Ok(()) => info!("Removed existing socket file at {:?}", socket_path),
969        Err(e) if e.kind() == io::ErrorKind::NotFound => {
970            debug!(
971                "No existing socket file found at {:?}, proceeding.",
972                socket_path
973            );
974        }
975        Err(e) => {
976            warn!(
977                "Failed to remove existing socket file at {:?}: {}. Binding might fail.",
978                socket_path, e
979            );
980        }
981    }
982
983    // --- Bind the listener ---
984    let listener = UnixListener::bind(socket_path).map_err(|e| {
985        error!("Failed to bind listener socket at {:?}: {}", socket_path, e);
986        AgentError::IO(e)
987    })?;
988
989    // --- Restrict the socket to the owner before serving any request ---
990    if let Err(e) = harden_socket_file(socket_path) {
991        error!(
992            "Failed to restrict agent socket {:?} to owner-only: {}",
993            socket_path, e
994        );
995        return Err(AgentError::IO(e));
996    }
997
998    // --- Listener started successfully ---
999    let actual_path = socket_path
1000        .canonicalize()
1001        .unwrap_or_else(|_| socket_path.to_path_buf());
1002    info!(
1003        "🚀 Agent listener started successfully at {:?}",
1004        actual_path
1005    );
1006    info!("   Set SSH_AUTH_SOCK={:?} to use this agent.", actual_path);
1007
1008    // Mark agent as running
1009    handle.set_running(true);
1010
1011    // --- Auto-lock the agent after it has been idle, or unlocked past its cap ---
1012    if let Some(interval) = idle_monitor_interval(handle.idle_timeout(), handle.max_unlock_ttl()) {
1013        tokio::spawn(run_idle_monitor(handle.clone(), interval));
1014    }
1015
1016    // --- Create the peer-authorizing session factory ---
1017    // Per-request signing is gated by the injected SignAuthorizer; the host chooses the
1018    // policy (per-caller approval for an interactive agent, permissive for headless).
1019    let agent = PeerAuthorizedAgent::new(handle.clone(), current_euid(), authorizer);
1020
1021    // --- Start the main listener loop from ssh_agent_lib ---
1022    let result = listen(listener, agent).await;
1023
1024    // Mark agent as no longer running
1025    handle.set_running(false);
1026
1027    if let Err(e) = result {
1028        error!("SSH Agent listener failed: {:?}", e);
1029        return Err(AgentError::IO(io::Error::other(format!(
1030            "SSH Agent listener failed: {}",
1031            e
1032        ))));
1033    }
1034
1035    warn!("Agent listener loop exited unexpectedly without error.");
1036    Ok(())
1037}
1038
1039/// Starts the SSH agent listener on the specified Unix domain socket path.
1040///
1041/// This is a convenience function that creates an `AgentHandle` internally.
1042/// For more control over the agent lifecycle, use `start_agent_listener_with_handle`
1043/// with your own `AgentHandle`.
1044///
1045/// Requires a `tokio` runtime context. Runs indefinitely on success.
1046///
1047/// # Arguments
1048/// * `socket_path_str`: The filesystem path for the Unix domain socket.
1049///
1050/// # Returns
1051/// - `Ok(())` if the listener starts successfully (runs indefinitely).
1052/// - `Err(AgentError)` if binding/setup fails or the listener loop exits with an error.
1053#[cfg(unix)]
1054pub async fn start_agent_listener(socket_path_str: String) -> Result<(), AgentError> {
1055    use std::path::PathBuf;
1056    let handle = Arc::new(AgentHandle::new(PathBuf::from(&socket_path_str)));
1057    start_agent_listener_with_handle(handle, Arc::new(crate::agent::AllowAllSigning)).await
1058}
1059
1060#[cfg(all(test, unix))]
1061mod hardening_tests {
1062    use super::{
1063        AgentHandle, harden_socket_dir, harden_socket_file, idle_monitor_interval,
1064        run_idle_monitor, socket_dir_to_harden,
1065    };
1066    use std::os::unix::fs::PermissionsExt;
1067    use std::path::PathBuf;
1068    use std::sync::Arc;
1069    use std::time::Duration;
1070    use tempfile::TempDir;
1071
1072    fn mode_of(path: &std::path::Path) -> u32 {
1073        match std::fs::metadata(path) {
1074            Ok(meta) => meta.permissions().mode() & 0o777,
1075            Err(e) => panic!("metadata({path:?}): {e}"),
1076        }
1077    }
1078
1079    #[test]
1080    fn socket_dir_is_owner_only_after_harden() {
1081        let tmp = TempDir::new().unwrap();
1082        let dir = tmp.path().join("auths-agent");
1083        harden_socket_dir(&dir).expect("harden dir");
1084        assert!(dir.is_dir(), "directory must be created");
1085        assert_eq!(
1086            mode_of(&dir),
1087            0o700,
1088            "agent socket directory must be owner-only (0o700)"
1089        );
1090    }
1091
1092    #[test]
1093    fn socket_file_is_owner_only_after_harden() {
1094        let tmp = TempDir::new().unwrap();
1095        let sock = tmp.path().join("agent.sock");
1096        let _listener = std::os::unix::net::UnixListener::bind(&sock).expect("bind socket");
1097        harden_socket_file(&sock).expect("harden socket");
1098        assert_eq!(
1099            mode_of(&sock),
1100            0o600,
1101            "agent socket must be owner-only (0o600) so no other process can connect"
1102        );
1103    }
1104
1105    #[test]
1106    fn refuses_preexisting_group_accessible_socket_dir() {
1107        let tmp = TempDir::new().unwrap();
1108        let dir = tmp.path().join("loose");
1109        std::fs::create_dir(&dir).unwrap();
1110        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
1111        // A pre-existing directory we did not lock down must be refused, not narrowed.
1112        assert!(harden_socket_dir(&dir).is_err());
1113        assert_eq!(
1114            mode_of(&dir),
1115            0o755,
1116            "must not silently chmod a directory it does not own"
1117        );
1118    }
1119
1120    #[test]
1121    fn accepts_preexisting_owner_only_socket_dir() {
1122        let tmp = TempDir::new().unwrap();
1123        let dir = tmp.path().join("tight");
1124        std::fs::create_dir(&dir).unwrap();
1125        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
1126        assert!(harden_socket_dir(&dir).is_ok());
1127    }
1128
1129    #[test]
1130    fn socket_with_no_restrictable_directory_is_rejected() {
1131        // A bare/relative socket name has no directory we can lock down to 0o700,
1132        // so the listener must refuse it rather than skip directory hardening.
1133        assert!(socket_dir_to_harden(std::path::Path::new("agent.sock")).is_err());
1134        assert!(socket_dir_to_harden(std::path::Path::new("")).is_err());
1135        let dir = socket_dir_to_harden(std::path::Path::new("/home/u/.auths/agent.sock"))
1136            .expect("an absolute socket path has a directory to harden");
1137        assert_eq!(dir, std::path::Path::new("/home/u/.auths"));
1138    }
1139
1140    #[test]
1141    fn idle_monitor_disabled_when_timeout_is_zero() {
1142        assert!(idle_monitor_interval(Duration::ZERO, Duration::ZERO).is_none());
1143    }
1144
1145    #[test]
1146    fn idle_monitor_interval_is_bounded() {
1147        let interval = idle_monitor_interval(
1148            Duration::from_secs(30 * 60),
1149            Duration::from_secs(8 * 60 * 60),
1150        )
1151        .expect("some interval");
1152        assert!(interval >= Duration::from_secs(1));
1153        assert!(interval <= Duration::from_secs(60));
1154    }
1155
1156    #[tokio::test]
1157    async fn idle_monitor_locks_idle_agent() {
1158        let handle = Arc::new(AgentHandle::with_timeout(
1159            PathBuf::from("/tmp/idle-monitor.sock"),
1160            Duration::from_millis(80),
1161        ));
1162        handle.set_running(true);
1163        assert!(!handle.is_agent_locked());
1164
1165        tokio::spawn(run_idle_monitor(handle.clone(), Duration::from_millis(20)));
1166        tokio::time::sleep(Duration::from_millis(300)).await;
1167
1168        assert!(
1169            handle.is_agent_locked(),
1170            "an agent left idle past its timeout must auto-lock"
1171        );
1172    }
1173}