stand 0.2.2

A CLI tool for explicit environment variable management
Documentation
//! Key management for Stand encryption.
//!
//! Handles generation, saving, and loading of age X25519 key pairs.

use std::fs;
use std::io::Write;
use std::path::Path;

use age::secrecy::ExposeSecret;
use age::x25519::{Identity, Recipient};

use super::CryptoError;

/// A key pair consisting of a public key (for encryption) and a private key (for decryption).
#[derive(Clone)]
pub struct KeyPair {
    /// The public key string (age1...)
    pub public_key: String,
    /// The private key string (AGE-SECRET-KEY-1...)
    pub private_key: String,
}

impl std::fmt::Debug for KeyPair {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("KeyPair")
            .field("public_key", &self.public_key)
            .field("private_key", &"[REDACTED]")
            .finish()
    }
}

impl KeyPair {
    /// Creates a new KeyPair from existing key strings.
    pub fn new(public_key: String, private_key: String) -> Self {
        Self {
            public_key,
            private_key,
        }
    }

    /// Parses the public key into an age Recipient.
    pub fn to_recipient(&self) -> Result<Recipient, CryptoError> {
        self.public_key
            .parse::<Recipient>()
            .map_err(|e| CryptoError::InvalidPublicKey(e.to_string()))
    }

    /// Parses the private key into an age Identity.
    pub fn to_identity(&self) -> Result<Identity, CryptoError> {
        self.private_key
            .parse::<Identity>()
            .map_err(|e| CryptoError::InvalidPrivateKey(e.to_string()))
    }
}

/// Generates a new X25519 key pair.
pub fn generate_key_pair() -> KeyPair {
    let identity = Identity::generate();
    let recipient = identity.to_public();

    KeyPair {
        public_key: recipient.to_string(),
        private_key: identity.to_string().expose_secret().to_string(),
    }
}

/// Saves the private key to a file.
///
/// The file is created with restricted permissions (0600 on Unix).
/// On non-Unix platforms, the file is created with default permissions.
pub fn save_private_key(path: &Path, private_key: &str) -> Result<(), CryptoError> {
    let content = format!(
        "# Stand encryption keys - DO NOT COMMIT TO VERSION CONTROL\n\
         # Generated by: stand encrypt enable\n\
         \n\
         STAND_PRIVATE_KEY={}\n",
        private_key
    );

    // On Unix, create file with 0600 permissions atomically to prevent race conditions
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        let mut file = fs::OpenOptions::new()
            .write(true)
            .create(true)
            .truncate(true)
            .mode(0o600)
            .open(path)?;
        file.write_all(content.as_bytes())?;
    }

    // On non-Unix platforms, create file with default permissions
    #[cfg(not(unix))]
    {
        let mut file = fs::File::create(path)?;
        file.write_all(content.as_bytes())?;
    }

    Ok(())
}

/// Loads the private key from a file.
///
/// # Errors
/// Returns `CryptoError::IoError` if the file cannot be read.
/// Returns `CryptoError::NoPrivateKey` if the file does not contain a `STAND_PRIVATE_KEY=` line.
pub fn load_private_key(path: &Path) -> Result<String, CryptoError> {
    let content = fs::read_to_string(path)?;

    for line in content.lines() {
        let line = line.trim();
        // Use pattern matching instead of unwrap for safety
        if let Some(key) = line.strip_prefix("STAND_PRIVATE_KEY=") {
            if key.trim().is_empty() {
                return Err(CryptoError::InvalidPrivateKey(
                    "Private key value is empty in .stand.keys file".to_string(),
                ));
            }
            return Ok(key.to_string());
        }
    }

    Err(CryptoError::NoPrivateKey)
}

/// Loads the private key from an environment variable.
///
/// # Returns
/// - `Ok(Some(key))` if the environment variable is set and valid UTF-8
/// - `Ok(None)` if the environment variable is not set
/// - `Err(CryptoError::InvalidPrivateKey)` if the environment variable contains invalid UTF-8
pub fn load_private_key_from_env() -> Result<Option<String>, CryptoError> {
    match std::env::var("STAND_PRIVATE_KEY") {
        Ok(key) if key.trim().is_empty() => Err(CryptoError::InvalidPrivateKey(
            "STAND_PRIVATE_KEY environment variable is empty".to_string(),
        )),
        Ok(key) => Ok(Some(key)),
        Err(std::env::VarError::NotPresent) => Ok(None),
        Err(std::env::VarError::NotUnicode(_)) => Err(CryptoError::InvalidPrivateKey(
            "STAND_PRIVATE_KEY environment variable contains invalid UTF-8".to_string(),
        )),
    }
}

/// Parses a public key string into an age Recipient.
pub fn parse_public_key(public_key: &str) -> Result<Recipient, CryptoError> {
    public_key
        .parse::<Recipient>()
        .map_err(|e| CryptoError::InvalidPublicKey(e.to_string()))
}

/// Parses a private key string into an age Identity.
pub fn parse_private_key(private_key: &str) -> Result<Identity, CryptoError> {
    private_key
        .parse::<Identity>()
        .map_err(|e| CryptoError::InvalidPrivateKey(e.to_string()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use serial_test::serial;
    use tempfile::tempdir;

    #[test]
    fn test_generate_key_pair() {
        let key_pair = generate_key_pair();

        // Public key should start with "age1"
        assert!(
            key_pair.public_key.starts_with("age1"),
            "Public key should start with 'age1', got: {}",
            key_pair.public_key
        );

        // Private key should start with "AGE-SECRET-KEY-1"
        assert!(
            key_pair.private_key.starts_with("AGE-SECRET-KEY-1"),
            "Private key should start with 'AGE-SECRET-KEY-1', got: {}",
            key_pair.private_key
        );
    }

    #[test]
    fn test_key_pair_to_recipient_and_identity() {
        let key_pair = generate_key_pair();

        // Should be able to parse back to age types
        assert!(key_pair.to_recipient().is_ok());
        assert!(key_pair.to_identity().is_ok());
    }

    #[test]
    fn test_save_and_load_private_key() {
        let dir = tempdir().unwrap();
        let key_file = dir.path().join(".stand.keys");

        let key_pair = generate_key_pair();
        save_private_key(&key_file, &key_pair.private_key).unwrap();

        let loaded = load_private_key(&key_file).unwrap();
        assert_eq!(loaded, key_pair.private_key);
    }

    #[test]
    fn test_load_private_key_missing_file() {
        let result = load_private_key(Path::new("/nonexistent/.stand.keys"));
        assert!(result.is_err());
    }

    #[test]
    fn test_parse_invalid_public_key() {
        let result = parse_public_key("invalid-key");
        assert!(result.is_err());
        assert!(matches!(result, Err(CryptoError::InvalidPublicKey(_))));
    }

    #[test]
    fn test_parse_invalid_private_key() {
        let result = parse_private_key("invalid-key");
        assert!(result.is_err());
        assert!(matches!(result, Err(CryptoError::InvalidPrivateKey(_))));
    }

    #[test]
    #[serial]
    fn test_load_private_key_from_env() {
        let key_pair = generate_key_pair();
        std::env::set_var("STAND_PRIVATE_KEY", &key_pair.private_key);

        let result = load_private_key_from_env();
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), Some(key_pair.private_key));

        std::env::remove_var("STAND_PRIVATE_KEY");
    }

    #[test]
    #[serial]
    fn test_load_private_key_from_env_not_set() {
        std::env::remove_var("STAND_PRIVATE_KEY");

        let result = load_private_key_from_env();
        assert!(result.is_ok());
        assert!(result.unwrap().is_none());
    }

    #[test]
    fn test_load_private_key_empty_value() {
        let dir = tempdir().unwrap();
        let key_file = dir.path().join(".stand.keys");
        std::fs::write(&key_file, "STAND_PRIVATE_KEY=\n").unwrap();

        let result = load_private_key(&key_file);
        assert!(result.is_err());
        assert!(matches!(result, Err(CryptoError::InvalidPrivateKey(_))));
        let err_msg = result.unwrap_err().to_string();
        assert!(
            err_msg.contains("empty"),
            "Error should mention 'empty', got: {}",
            err_msg
        );
    }

    #[test]
    #[serial]
    fn test_load_private_key_from_env_empty() {
        std::env::set_var("STAND_PRIVATE_KEY", "");

        let result = load_private_key_from_env();
        assert!(result.is_err());
        assert!(matches!(result, Err(CryptoError::InvalidPrivateKey(_))));
        let err_msg = result.unwrap_err().to_string();
        assert!(
            err_msg.contains("empty"),
            "Error should mention 'empty', got: {}",
            err_msg
        );

        std::env::remove_var("STAND_PRIVATE_KEY");
    }

    #[test]
    fn test_key_pair_debug_masks_private_key() {
        let key_pair = KeyPair::new(
            "age1public".to_string(),
            "AGE-SECRET-KEY-1SECRET".to_string(),
        );
        let debug_output = format!("{:?}", key_pair);

        assert!(
            debug_output.contains("age1public"),
            "Debug output should contain the public key"
        );
        assert!(
            !debug_output.contains("AGE-SECRET-KEY-1SECRET"),
            "Debug output must NOT contain the private key"
        );
        assert!(
            debug_output.contains("[REDACTED]"),
            "Debug output should contain [REDACTED] for private key"
        );
    }

    #[test]
    #[cfg(unix)]
    fn test_save_private_key_sets_secure_permissions() {
        use std::os::unix::fs::PermissionsExt;

        let dir = tempdir().unwrap();
        let key_file = dir.path().join(".stand.keys");
        let key_pair = generate_key_pair();

        save_private_key(&key_file, &key_pair.private_key).unwrap();

        let metadata = std::fs::metadata(&key_file).unwrap();
        let mode = metadata.permissions().mode() & 0o777;
        assert_eq!(mode, 0o600, "File should have 0600 permissions");
    }
}