securegit 0.8.5

Zero-trust git replacement with 12 built-in security scanners, LLM redteam bridge, universal undo, durable backups, and a 50-tool MCP server
Documentation
use crate::auth::secure_string::SecureString;
use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::debug;

/// Store a token for a host.
/// Credentials are encrypted at rest using a machine-specific key.
pub fn store_token(host: &str, token: &SecureString) -> Result<()> {
    let path = credentials_path()?;
    let mut creds = load_credentials(&path);
    creds.insert(host.to_string(), token.as_str().to_string());
    save_credentials(&path, &creds)?;
    debug!("Stored token for {} in {}", host, path.display());
    Ok(())
}

/// Retrieve a stored token for a host.
pub fn get_token(host: &str) -> Option<SecureString> {
    let path = credentials_path().ok()?;
    let creds = load_credentials(&path);
    creds
        .get(host)
        .filter(|s| !s.is_empty())
        .map(|s| SecureString::from_string(s.clone()))
}

/// Delete a stored token for a host.
pub fn delete_token(host: &str) -> Result<()> {
    let path = credentials_path()?;
    let mut creds = load_credentials(&path);
    creds.remove(host);
    save_credentials(&path, &creds)?;
    debug!("Deleted token for {}", host);
    Ok(())
}

/// Delete all stored tokens.
pub fn delete_all_tokens() -> Result<()> {
    let path = credentials_path()?;
    save_credentials(&path, &HashMap::new())?;
    Ok(())
}

/// List all hosts that have stored tokens.
pub fn list_stored_hosts() -> Vec<String> {
    let path = match credentials_path() {
        Ok(p) => p,
        Err(_) => return vec![],
    };
    let creds = load_credentials(&path);
    creds
        .keys()
        .filter(|k| !creds[*k].is_empty())
        .cloned()
        .collect()
}

fn credentials_path() -> Result<PathBuf> {
    let home = dirs::home_dir().context("Could not determine home directory")?;
    let dir = home.join(".config/securegit");
    std::fs::create_dir_all(&dir).context("Could not create config directory")?;
    Ok(dir.join("credentials.json"))
}

/// Derive a machine-specific encryption key from hostname + username + path.
/// This ties credentials to the current user on the current machine.
fn derive_key() -> [u8; 32] {
    let mut hasher = Sha256::new();

    // Include hostname
    if let Ok(hostname) = std::env::var("HOSTNAME")
        .or_else(|_| std::env::var("HOST"))
        .or_else(|_| std::fs::read_to_string("/etc/hostname").map(|s| s.trim().to_string()))
    {
        hasher.update(hostname.as_bytes());
    }

    // Include username
    if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
        hasher.update(user.as_bytes());
    }

    // Include the credentials file path as additional entropy
    if let Ok(path) = credentials_path() {
        hasher.update(path.to_string_lossy().as_bytes());
    }

    // Static salt to prevent rainbow table attacks
    hasher.update(b"securegit-credential-store-v1");

    hasher.finalize().into()
}

/// Encrypt data using XOR with a SHA-256 derived keystream.
/// Returns hex-encoded ciphertext.
fn encrypt(plaintext: &[u8]) -> String {
    let key = derive_key();
    let mut ciphertext = Vec::with_capacity(plaintext.len());

    for (i, byte) in plaintext.iter().enumerate() {
        // Extend the key using SHA-256 in counter mode
        let block_index = i / 32;
        let byte_index = i % 32;

        if byte_index == 0 && block_index > 0 {
            // For blocks beyond the first, derive additional key material
            let mut hasher = Sha256::new();
            hasher.update(key);
            hasher.update((block_index as u64).to_le_bytes());
            let extended: [u8; 32] = hasher.finalize().into();
            ciphertext.push(byte ^ extended[byte_index]);
        } else {
            ciphertext.push(byte ^ key[byte_index]);
        }
    }

    hex::encode(ciphertext)
}

/// Decrypt hex-encoded ciphertext using XOR with the same derived keystream.
fn decrypt(hex_ciphertext: &str) -> Result<Vec<u8>> {
    let ciphertext =
        hex::decode(hex_ciphertext).context("Failed to decode credential ciphertext")?;
    let key = derive_key();
    let mut plaintext = Vec::with_capacity(ciphertext.len());

    for (i, byte) in ciphertext.iter().enumerate() {
        let block_index = i / 32;
        let byte_index = i % 32;

        if byte_index == 0 && block_index > 0 {
            let mut hasher = Sha256::new();
            hasher.update(key);
            hasher.update((block_index as u64).to_le_bytes());
            let extended: [u8; 32] = hasher.finalize().into();
            plaintext.push(byte ^ extended[byte_index]);
        } else {
            plaintext.push(byte ^ key[byte_index]);
        }
    }

    Ok(plaintext)
}

fn load_credentials(path: &Path) -> HashMap<String, String> {
    if !path.exists() {
        return HashMap::new();
    }
    match std::fs::read_to_string(path) {
        Ok(content) => {
            // Try encrypted format first (hex string)
            if let Ok(decrypted) = decrypt(content.trim()) {
                if let Ok(json_str) = std::str::from_utf8(&decrypted) {
                    if let Ok(creds) = serde_json::from_str(json_str) {
                        return creds;
                    }
                }
            }
            // Fall back to legacy plaintext JSON for migration
            serde_json::from_str(&content).unwrap_or_default()
        }
        Err(_) => HashMap::new(),
    }
}

fn save_credentials(path: &Path, creds: &HashMap<String, String>) -> Result<()> {
    let json = serde_json::to_string(creds)?;
    let encrypted = encrypt(json.as_bytes());
    std::fs::write(path, &encrypted).context("Failed to write credentials file")?;

    // Set restrictive permissions on Unix
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    // ── encrypt / decrypt unit tests ────────────────────────────────

    #[test]
    fn test_encrypt_decrypt_roundtrip() {
        let plaintext = b"ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
        let ciphertext = encrypt(plaintext);
        let decrypted = decrypt(&ciphertext).expect("decrypt should succeed");
        assert_eq!(decrypted, plaintext);
    }

    #[test]
    fn test_encrypt_produces_hex() {
        let ciphertext = encrypt(b"test-token-value");
        // Every character must be a valid hex digit
        assert!(
            ciphertext.chars().all(|c| c.is_ascii_hexdigit()),
            "Ciphertext should be valid hex, got: {}",
            ciphertext
        );
        // Hex encoding doubles the byte length
        assert_eq!(ciphertext.len(), "test-token-value".len() * 2);
    }

    #[test]
    fn test_decrypt_invalid_hex_fails() {
        let result = decrypt("not-valid-hex!!!");
        assert!(result.is_err(), "Decrypting garbage hex should fail");
    }

    #[test]
    fn test_decrypt_odd_length_hex_fails() {
        // Odd-length hex string is invalid
        let result = decrypt("abc");
        assert!(result.is_err(), "Decrypting odd-length hex should fail");
    }

    #[test]
    fn test_encrypt_different_inputs_different_outputs() {
        let ct1 = encrypt(b"token-alpha");
        let ct2 = encrypt(b"token-bravo");
        assert_ne!(
            ct1, ct2,
            "Different plaintexts must produce different ciphertexts"
        );
    }

    #[test]
    fn test_encrypt_empty_data() {
        let ciphertext = encrypt(b"");
        assert_eq!(
            ciphertext, "",
            "Encrypting empty data should produce empty hex"
        );
        let decrypted = decrypt(&ciphertext).expect("decrypt empty should succeed");
        assert!(decrypted.is_empty());
    }

    #[test]
    fn test_encrypt_large_data() {
        // 1MB+ of data
        let large = vec![0x42u8; 1_048_576 + 100];
        let ciphertext = encrypt(&large);
        let decrypted = decrypt(&ciphertext).expect("decrypt large data should succeed");
        assert_eq!(decrypted, large);
    }

    #[test]
    fn test_encrypt_deterministic() {
        // Same input should always produce same output (key is derived deterministically)
        let ct1 = encrypt(b"deterministic-test");
        let ct2 = encrypt(b"deterministic-test");
        assert_eq!(ct1, ct2, "Encryption should be deterministic with same key");
    }

    // ── Credential store / retrieve / delete (file I/O) ─────────────

    #[test]
    fn test_store_and_retrieve_token() {
        let tmpdir = tempfile::tempdir().expect("create tempdir");
        let cred_path = tmpdir.path().join("credentials.json");

        // Store
        let mut creds = HashMap::new();
        creds.insert("github.com".to_string(), "ghp_test123".to_string());
        save_credentials(&cred_path, &creds).expect("save should succeed");

        // File should exist
        assert!(cred_path.exists());

        // Retrieve
        let loaded = load_credentials(&cred_path);
        assert_eq!(
            loaded.get("github.com").map(|s| s.as_str()),
            Some("ghp_test123")
        );
    }

    #[test]
    fn test_store_multiple_hosts() {
        let tmpdir = tempfile::tempdir().expect("create tempdir");
        let cred_path = tmpdir.path().join("credentials.json");

        let mut creds = HashMap::new();
        creds.insert("github.com".to_string(), "ghp_abc".to_string());
        creds.insert("gitlab.com".to_string(), "glpat-xyz".to_string());
        creds.insert("bitbucket.org".to_string(), "bb_token".to_string());
        save_credentials(&cred_path, &creds).expect("save should succeed");

        let loaded = load_credentials(&cred_path);
        assert_eq!(loaded.len(), 3);
        assert_eq!(
            loaded.get("github.com").map(|s| s.as_str()),
            Some("ghp_abc")
        );
        assert_eq!(
            loaded.get("gitlab.com").map(|s| s.as_str()),
            Some("glpat-xyz")
        );
        assert_eq!(
            loaded.get("bitbucket.org").map(|s| s.as_str()),
            Some("bb_token")
        );
    }

    #[test]
    fn test_delete_token_from_store() {
        let tmpdir = tempfile::tempdir().expect("create tempdir");
        let cred_path = tmpdir.path().join("credentials.json");

        let mut creds = HashMap::new();
        creds.insert("github.com".to_string(), "ghp_abc".to_string());
        creds.insert("gitlab.com".to_string(), "glpat-xyz".to_string());
        save_credentials(&cred_path, &creds).expect("save should succeed");

        // Remove one
        let mut loaded = load_credentials(&cred_path);
        loaded.remove("github.com");
        save_credentials(&cred_path, &loaded).expect("save after delete");

        let reloaded = load_credentials(&cred_path);
        assert!(reloaded.get("github.com").is_none());
        assert_eq!(
            reloaded.get("gitlab.com").map(|s| s.as_str()),
            Some("glpat-xyz")
        );
    }

    #[test]
    fn test_list_hosts() {
        let tmpdir = tempfile::tempdir().expect("create tempdir");
        let cred_path = tmpdir.path().join("credentials.json");

        let mut creds = HashMap::new();
        creds.insert("github.com".to_string(), "token1".to_string());
        creds.insert("gitlab.com".to_string(), "token2".to_string());
        creds.insert("empty.com".to_string(), "".to_string()); // empty token
        save_credentials(&cred_path, &creds).expect("save should succeed");

        let loaded = load_credentials(&cred_path);
        let hosts: Vec<String> = loaded
            .keys()
            .filter(|k| !loaded[*k].is_empty())
            .cloned()
            .collect();
        assert!(hosts.contains(&"github.com".to_string()));
        assert!(hosts.contains(&"gitlab.com".to_string()));
        assert!(!hosts.contains(&"empty.com".to_string()));
    }

    #[test]
    fn test_load_nonexistent_file() {
        let tmpdir = tempfile::tempdir().expect("create tempdir");
        let cred_path = tmpdir.path().join("does-not-exist.json");
        let loaded = load_credentials(&cred_path);
        assert!(loaded.is_empty());
    }

    #[test]
    fn test_encrypted_file_not_plaintext() {
        let tmpdir = tempfile::tempdir().expect("create tempdir");
        let cred_path = tmpdir.path().join("credentials.json");

        let mut creds = HashMap::new();
        creds.insert("github.com".to_string(), "ghp_secret_value".to_string());
        save_credentials(&cred_path, &creds).expect("save should succeed");

        // Read raw file content -- it should NOT contain the plaintext token
        let raw = std::fs::read_to_string(&cred_path).expect("read file");
        assert!(
            !raw.contains("ghp_secret_value"),
            "Credentials file must not contain plaintext tokens"
        );
        assert!(
            !raw.contains("github.com"),
            "Credentials file must not contain plaintext host names"
        );
    }
}