a73x

4e47c384

Add global trust store for signing keys

a73x   2026-03-21 16:28

Repos now inherit trusted keys from ~/.config/git-collab/trusted-keys
(loaded first), then merge with repo-local .git/collab/trusted-keys
(repo takes precedence on label conflicts). Adds --global flag to
key add/list/remove subcommands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

diff --git a/src/cli.rs b/src/cli.rs
index 9c1a270..8f643f9 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -176,13 +176,23 @@ pub enum KeyCmd {
        /// Human-readable label for the key
        #[arg(long)]
        label: Option<String>,
        /// Store in global trust store (~/.config/git-collab/trusted-keys)
        #[arg(long)]
        global: bool,
    },
    /// List trusted public keys
    List,
    List {
        /// Show only global trusted keys
        #[arg(long)]
        global: bool,
    },
    /// Remove a trusted public key
    Remove {
        /// Base64-encoded public key to remove
        pubkey: String,
        /// Remove from global trust store (~/.config/git-collab/trusted-keys)
        #[arg(long)]
        global: bool,
    },
}

diff --git a/src/lib.rs b/src/lib.rs
index a773cd3..8801a3c 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -356,6 +356,7 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
                pubkey,
                self_key,
                label,
                global,
            } => {
                if self_key && pubkey.is_some() {
                    return Err(error::Error::Cmd(
@@ -377,32 +378,52 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
                    }
                };
                trust::validate_pubkey(&key)?;
                let added = trust::save_trusted_key(repo, &key, label.as_deref())?;
                let added = if global {
                    trust::save_trusted_key_global(&key, label.as_deref())?
                } else {
                    trust::save_trusted_key(repo, &key, label.as_deref())?
                };
                if added {
                    let label_display = label
                        .as_ref()
                        .map(|l| format!(" ({})", l))
                        .unwrap_or_default();
                    println!("Trusted key added: {}{}", key, label_display);
                    let scope = if global { " (global)" } else { "" };
                    println!("Trusted key added{}: {}{}", scope, key, label_display);
                } else {
                    println!("Key {} is already trusted.", key);
                }
                Ok(())
            }
            KeyCmd::List => {
                let policy = trust::load_trust_policy(repo)?;
                match policy {
                    trust::TrustPolicy::Unconfigured => {
                        println!("No trusted keys configured.");
            KeyCmd::List { global } => {
                if global {
                    let path = trust::global_trusted_keys_path()?;
                    let keys = trust::parse_trusted_keys_file(&path)?;
                    if keys.is_empty() {
                        println!("No global trusted keys configured.");
                    } else {
                        for k in &keys {
                            match &k.label {
                                Some(l) => println!("{}  {}", k.pubkey, l),
                                None => println!("{}", k.pubkey),
                            }
                        }
                    }
                    trust::TrustPolicy::Configured(keys) => {
                        if keys.is_empty() {
                } else {
                    let policy = trust::load_trust_policy(repo)?;
                    match policy {
                        trust::TrustPolicy::Unconfigured => {
                            println!("No trusted keys configured.");
                        } else {
                            for k in &keys {
                                match &k.label {
                                    Some(l) => println!("{}  {}", k.pubkey, l),
                                    None => println!("{}", k.pubkey),
                        }
                        trust::TrustPolicy::Configured(keys) => {
                            if keys.is_empty() {
                                println!("No trusted keys configured.");
                            } else {
                                for k in &keys {
                                    match &k.label {
                                        Some(l) => println!("{}  {}", k.pubkey, l),
                                        None => println!("{}", k.pubkey),
                                    }
                                }
                            }
                        }
@@ -410,14 +431,19 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> {
                }
                Ok(())
            }
            KeyCmd::Remove { pubkey } => {
                let removed = trust::remove_trusted_key(repo, &pubkey)?;
            KeyCmd::Remove { pubkey, global } => {
                let removed = if global {
                    trust::remove_trusted_key_global(&pubkey)?
                } else {
                    trust::remove_trusted_key(repo, &pubkey)?
                };
                let label_display = removed
                    .label
                    .as_ref()
                    .map(|l| format!(" ({})", l))
                    .unwrap_or_default();
                println!("Removed trusted key: {}{}", removed.pubkey, label_display);
                let scope = if global { " (global)" } else { "" };
                println!("Removed trusted key{}: {}{}", scope, removed.pubkey, label_display);
                Ok(())
            }
        },
diff --git a/src/trust.rs b/src/trust.rs
index 34b68aa..1c9f16f 100644
--- a/src/trust.rs
+++ b/src/trust.rs
@@ -1,6 +1,6 @@
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};

use base64::engine::general_purpose::STANDARD;
use base64::Engine;
@@ -33,6 +33,12 @@ pub fn trusted_keys_path(repo: &Repository) -> PathBuf {
    repo.path().join("collab/trusted-keys")
}

/// Return the path to the global trusted keys file (~/.config/git-collab/trusted-keys).
pub fn global_trusted_keys_path() -> Result<PathBuf, Error> {
    let config_dir = crate::signing::signing_key_dir()?;
    Ok(config_dir.join("trusted-keys"))
}

/// Validate that a string is a valid base64-encoded 32-byte Ed25519 public key.
pub fn validate_pubkey(pubkey: &str) -> Result<(), Error> {
    let bytes = STANDARD
@@ -50,18 +56,19 @@ pub fn validate_pubkey(pubkey: &str) -> Result<(), Error> {
    Ok(())
}

/// Load the trust policy from the trusted keys file.
///
/// Returns `TrustPolicy::Unconfigured` if the file does not exist.
/// Returns `TrustPolicy::Configured(keys)` if the file exists (even if empty).
/// Parse trusted keys from a file. Returns an empty Vec if the file does not exist.
/// Returns `None` if the file does not exist (to distinguish from an empty file).
/// Malformed lines are skipped with a warning to stderr.
/// Duplicate keys are deduplicated (last wins for label).
pub fn load_trust_policy(repo: &Repository) -> Result<TrustPolicy, Error> {
    let path = trusted_keys_path(repo);
pub fn parse_trusted_keys_file(path: &Path) -> Result<Vec<TrustedKey>, Error> {
    if !path.exists() {
        return Ok(TrustPolicy::Unconfigured);
        return Ok(Vec::new());
    }
    let content = fs::read_to_string(&path)?;
    parse_trusted_keys_content(&fs::read_to_string(path)?)
}

/// Parse trusted keys from a string content.
fn parse_trusted_keys_content(content: &str) -> Result<Vec<TrustedKey>, Error> {
    let mut seen = HashSet::new();
    let mut keys = Vec::new();

@@ -87,7 +94,74 @@ pub fn load_trust_policy(repo: &Repository) -> Result<TrustPolicy, Error> {
        keys.push(TrustedKey { pubkey, label });
    }

    Ok(TrustPolicy::Configured(keys))
    Ok(keys)
}

/// Merge two key lists. Keys from `later` override labels from `earlier` on duplicates.
fn merge_keys(earlier: Vec<TrustedKey>, later: Vec<TrustedKey>) -> Vec<TrustedKey> {
    let mut seen = HashSet::new();
    let mut merged = Vec::new();

    // Add all from earlier
    for k in earlier {
        seen.insert(k.pubkey.clone());
        merged.push(k);
    }

    // Add/override from later
    for k in later {
        if seen.contains(&k.pubkey) {
            // Replace earlier entry with later (last wins for label)
            for existing in merged.iter_mut() {
                if existing.pubkey == k.pubkey {
                    existing.label = k.label.clone();
                    break;
                }
            }
        } else {
            seen.insert(k.pubkey.clone());
            merged.push(k);
        }
    }

    merged
}

/// Load the trust policy from both global and repo-local trusted keys files.
///
/// Returns `TrustPolicy::Unconfigured` if neither file exists.
/// Returns `TrustPolicy::Configured(keys)` if either file exists (even if empty).
/// Global keys are loaded first, then repo-local keys are merged in.
/// Repo-local keys take precedence for labels on duplicate keys.
pub fn load_trust_policy(repo: &Repository) -> Result<TrustPolicy, Error> {
    let global_path = global_trusted_keys_path().ok();
    load_trust_policy_with_global(repo, global_path.as_deref())
}

/// Load trust policy with an explicit global path (for testing).
pub fn load_trust_policy_with_global(repo: &Repository, global_path: Option<&Path>) -> Result<TrustPolicy, Error> {
    let repo_path = trusted_keys_path(repo);
    let repo_exists = repo_path.exists();
    let global_exists = global_path.is_some_and(|p| p.exists());

    if !repo_exists && !global_exists {
        return Ok(TrustPolicy::Unconfigured);
    }

    let global_keys = if let Some(gp) = global_path {
        parse_trusted_keys_file(gp)?
    } else {
        Vec::new()
    };

    let repo_keys = if repo_exists {
        parse_trusted_keys_file(&repo_path)?
    } else {
        Vec::new()
    };

    let merged = merge_keys(global_keys, repo_keys);
    Ok(TrustPolicy::Configured(merged))
}

/// Check whether a pubkey is in the trusted set.
@@ -95,23 +169,20 @@ pub fn is_key_trusted(keys: &[TrustedKey], pubkey: &str) -> bool {
    keys.iter().any(|k| k.pubkey == pubkey)
}

/// Save (append) a trusted key to the trusted keys file.
/// Save (append) a trusted key to an arbitrary trusted keys file.
///
/// Creates the `.git/collab/` directory and file if needed.
/// Returns `true` if the key was added, `false` if it was already trusted.
pub fn save_trusted_key(repo: &Repository, pubkey: &str, label: Option<&str>) -> Result<bool, Error> {
    let path = trusted_keys_path(repo);
/// Creates the parent directory and file if needed.
/// Returns `true` if the key was added, `false` if it was already present.
pub fn save_trusted_key_to_file(path: &Path, pubkey: &str, label: Option<&str>) -> Result<bool, Error> {
    // Ensure directory exists
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    // Check for duplicates
    if path.exists() {
        let policy = load_trust_policy(repo)?;
        if let TrustPolicy::Configured(ref keys) = policy {
            if is_key_trusted(keys, pubkey) {
                return Ok(false);
            }
        let keys = parse_trusted_keys_file(path)?;
        if is_key_trusted(&keys, pubkey) {
            return Ok(false);
        }
    }
    // Append key
@@ -123,61 +194,84 @@ pub fn save_trusted_key(repo: &Repository, pubkey: &str, label: Option<&str>) ->
    let mut file = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)?;
        .open(path)?;
    file.write_all(line.as_bytes())?;
    Ok(true)
}

/// Remove a trusted key from the file. Returns the removed entry.
pub fn remove_trusted_key(repo: &Repository, pubkey: &str) -> Result<TrustedKey, Error> {
/// Save (append) a trusted key to the repo-local trusted keys file.
///
/// Creates the `.git/collab/` directory and file if needed.
/// Returns `true` if the key was added, `false` if it was already trusted.
pub fn save_trusted_key(repo: &Repository, pubkey: &str, label: Option<&str>) -> Result<bool, Error> {
    let path = trusted_keys_path(repo);
    let policy = load_trust_policy(repo)?;
    match policy {
        TrustPolicy::Unconfigured => {
            Err(Error::Cmd(format!(
                "key {} is not in the trusted keys list",
                pubkey
            )))
        }
        TrustPolicy::Configured(keys) => {
            let removed = keys.iter().find(|k| k.pubkey == pubkey).cloned();
            match removed {
                None => {
                    Err(Error::Cmd(format!(
                        "key {} is not in the trusted keys list",
                        pubkey
                    )))
    save_trusted_key_to_file(&path, pubkey, label)
}

/// Save (append) a trusted key to the global trusted keys file.
///
/// Creates `~/.config/git-collab/` directory and file if needed.
/// Returns `true` if the key was added, `false` if it was already trusted.
pub fn save_trusted_key_global(pubkey: &str, label: Option<&str>) -> Result<bool, Error> {
    let path = global_trusted_keys_path()?;
    save_trusted_key_to_file(&path, pubkey, label)
}

/// Remove a trusted key from an arbitrary trusted keys file. Returns the removed entry.
pub fn remove_trusted_key_from_file(path: &Path, pubkey: &str) -> Result<TrustedKey, Error> {
    if !path.exists() {
        return Err(Error::Cmd(format!(
            "key {} is not in the trusted keys list",
            pubkey
        )));
    }
    let keys = parse_trusted_keys_file(path)?;
    let removed = keys.iter().find(|k| k.pubkey == pubkey).cloned();
    match removed {
        None => Err(Error::Cmd(format!(
            "key {} is not in the trusted keys list",
            pubkey
        ))),
        Some(removed_key) => {
            // Rewrite file without the removed key
            let content = fs::read_to_string(path)?;
            let mut new_lines = Vec::new();
            for line in content.lines() {
                let trimmed = line.trim();
                if trimmed.is_empty() || trimmed.starts_with('#') {
                    new_lines.push(line.to_string());
                    continue;
                }
                Some(removed_key) => {
                    // Rewrite file without the removed key
                    let content = fs::read_to_string(&path)?;
                    let mut new_lines = Vec::new();
                    for line in content.lines() {
                        let trimmed = line.trim();
                        if trimmed.is_empty() || trimmed.starts_with('#') {
                            new_lines.push(line.to_string());
                            continue;
                        }
                        let key_part = trimmed.split_once(' ')
                            .map(|(k, _)| k)
                            .unwrap_or(trimmed);
                        if key_part != pubkey {
                            new_lines.push(line.to_string());
                        }
                    }
                    let new_content = if new_lines.is_empty() {
                        String::new()
                    } else {
                        new_lines.join("\n") + "\n"
                    };
                    fs::write(&path, new_content)?;
                    Ok(removed_key)
                let key_part = trimmed.split_once(' ')
                    .map(|(k, _)| k)
                    .unwrap_or(trimmed);
                if key_part != pubkey {
                    new_lines.push(line.to_string());
                }
            }
            let new_content = if new_lines.is_empty() {
                String::new()
            } else {
                new_lines.join("\n") + "\n"
            };
            fs::write(path, new_content)?;
            Ok(removed_key)
        }
    }
}

/// Remove a trusted key from the repo-local file. Returns the removed entry.
pub fn remove_trusted_key(repo: &Repository, pubkey: &str) -> Result<TrustedKey, Error> {
    let path = trusted_keys_path(repo);
    remove_trusted_key_from_file(&path, pubkey)
}

/// Remove a trusted key from the global file. Returns the removed entry.
pub fn remove_trusted_key_global(pubkey: &str) -> Result<TrustedKey, Error> {
    let path = global_trusted_keys_path()?;
    remove_trusted_key_from_file(&path, pubkey)
}

/// Check trust for a set of signature verification results.
///
/// If `TrustPolicy::Unconfigured`, returns the results unchanged.
@@ -502,4 +596,185 @@ mod tests {
            _ => panic!("expected Configured (empty)"),
        }
    }

    // =====================================================
    // Global trust store tests
    // =====================================================

    #[test]
    fn global_trusted_keys_path_returns_config_path() {
        let path = global_trusted_keys_path().unwrap();
        assert!(path.to_string_lossy().contains("git-collab"));
        assert!(path.to_string_lossy().ends_with("trusted-keys"));
    }

    #[test]
    fn load_trust_policy_merges_global_and_repo_keys() {
        let (_dir, repo) = test_repo();
        let global_dir = TempDir::new().unwrap();
        let global_path = global_dir.path().join("trusted-keys");

        let pk_global = valid_test_pubkey();
        let pk_repo = valid_test_pubkey();

        // Write a global key
        fs::write(&global_path, format!("{} GlobalAlice\n", pk_global)).unwrap();

        // Write a repo key
        save_trusted_key(&repo, &pk_repo, Some("RepoAlice")).unwrap();

        // Load with custom global path
        let policy = load_trust_policy_with_global(&repo, Some(&global_path)).unwrap();
        match policy {
            TrustPolicy::Configured(keys) => {
                assert_eq!(keys.len(), 2, "should have both global and repo keys");
                let pubkeys: Vec<&str> = keys.iter().map(|k| k.pubkey.as_str()).collect();
                assert!(pubkeys.contains(&pk_global.as_str()), "should contain global key");
                assert!(pubkeys.contains(&pk_repo.as_str()), "should contain repo key");
            }
            _ => panic!("expected Configured"),
        }
    }

    #[test]
    fn load_trust_policy_repo_key_overrides_global_label() {
        let (_dir, repo) = test_repo();
        let global_dir = TempDir::new().unwrap();
        let global_path = global_dir.path().join("trusted-keys");

        let pk = valid_test_pubkey();

        // Same key in both global and repo, with different labels
        fs::write(&global_path, format!("{} GlobalLabel\n", pk)).unwrap();
        save_trusted_key(&repo, &pk, Some("RepoLabel")).unwrap();

        let policy = load_trust_policy_with_global(&repo, Some(&global_path)).unwrap();
        match policy {
            TrustPolicy::Configured(keys) => {
                assert_eq!(keys.len(), 1, "duplicates should be merged");
                // Repo-local takes precedence (loaded second, last wins)
                assert_eq!(keys[0].label.as_deref(), Some("RepoLabel"));
            }
            _ => panic!("expected Configured"),
        }
    }

    #[test]
    fn load_trust_policy_global_only_no_repo_file() {
        let (_dir, repo) = test_repo();
        let global_dir = TempDir::new().unwrap();
        let global_path = global_dir.path().join("trusted-keys");

        let pk = valid_test_pubkey();
        fs::write(&global_path, format!("{} GlobalOnly\n", pk)).unwrap();

        let policy = load_trust_policy_with_global(&repo, Some(&global_path)).unwrap();
        match policy {
            TrustPolicy::Configured(keys) => {
                assert_eq!(keys.len(), 1);
                assert_eq!(keys[0].pubkey, pk);
                assert_eq!(keys[0].label.as_deref(), Some("GlobalOnly"));
            }
            _ => panic!("expected Configured"),
        }
    }

    #[test]
    fn load_trust_policy_no_global_file_falls_back_to_repo_only() {
        let (_dir, repo) = test_repo();
        let global_dir = TempDir::new().unwrap();
        let global_path = global_dir.path().join("nonexistent-trusted-keys");

        let pk = valid_test_pubkey();
        save_trusted_key(&repo, &pk, Some("RepoOnly")).unwrap();

        let policy = load_trust_policy_with_global(&repo, Some(&global_path)).unwrap();
        match policy {
            TrustPolicy::Configured(keys) => {
                assert_eq!(keys.len(), 1);
                assert_eq!(keys[0].pubkey, pk);
            }
            _ => panic!("expected Configured"),
        }
    }

    #[test]
    fn load_trust_policy_neither_global_nor_repo_returns_unconfigured() {
        let (_dir, repo) = test_repo();
        let global_dir = TempDir::new().unwrap();
        let global_path = global_dir.path().join("nonexistent-trusted-keys");

        let policy = load_trust_policy_with_global(&repo, Some(&global_path)).unwrap();
        assert!(matches!(policy, TrustPolicy::Unconfigured));
    }

    #[test]
    fn save_trusted_key_global_creates_file_and_appends() {
        let global_dir = TempDir::new().unwrap();
        let global_path = global_dir.path().join("trusted-keys");

        let pk1 = valid_test_pubkey();
        let pk2 = valid_test_pubkey();

        let added = save_trusted_key_to_file(&global_path, &pk1, Some("Alice")).unwrap();
        assert!(added, "first add should return true");

        let added = save_trusted_key_to_file(&global_path, &pk2, None).unwrap();
        assert!(added, "second add should return true");

        let keys = parse_trusted_keys_file(&global_path).unwrap();
        assert_eq!(keys.len(), 2);
        assert_eq!(keys[0].pubkey, pk1);
        assert_eq!(keys[0].label.as_deref(), Some("Alice"));
        assert_eq!(keys[1].pubkey, pk2);
        assert!(keys[1].label.is_none());
    }

    #[test]
    fn save_trusted_key_global_prevents_duplicates() {
        let global_dir = TempDir::new().unwrap();
        let global_path = global_dir.path().join("trusted-keys");

        let pk = valid_test_pubkey();
        let added = save_trusted_key_to_file(&global_path, &pk, Some("Alice")).unwrap();
        assert!(added);
        let added = save_trusted_key_to_file(&global_path, &pk, Some("Alice again")).unwrap();
        assert!(!added, "duplicate add should return false");

        let keys = parse_trusted_keys_file(&global_path).unwrap();
        assert_eq!(keys.len(), 1);
    }

    #[test]
    fn remove_trusted_key_from_file_works() {
        let global_dir = TempDir::new().unwrap();
        let global_path = global_dir.path().join("trusted-keys");

        let pk1 = valid_test_pubkey();
        let pk2 = valid_test_pubkey();
        save_trusted_key_to_file(&global_path, &pk1, Some("Alice")).unwrap();
        save_trusted_key_to_file(&global_path, &pk2, Some("Bob")).unwrap();

        let removed = remove_trusted_key_from_file(&global_path, &pk1).unwrap();
        assert_eq!(removed.pubkey, pk1);
        assert_eq!(removed.label.as_deref(), Some("Alice"));

        let keys = parse_trusted_keys_file(&global_path).unwrap();
        assert_eq!(keys.len(), 1);
        assert_eq!(keys[0].pubkey, pk2);
    }

    #[test]
    fn remove_trusted_key_from_file_errors_when_not_found() {
        let global_dir = TempDir::new().unwrap();
        let global_path = global_dir.path().join("trusted-keys");

        let pk = valid_test_pubkey();
        save_trusted_key_to_file(&global_path, &pk, None).unwrap();

        let other_pk = valid_test_pubkey();
        let result = remove_trusted_key_from_file(&global_path, &other_pk);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("not in the trusted keys list"));
    }
}