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")); } }