eb29a934
Add a trusted-key allowlist for signature verification
a73x 2026-03-21 07:49
Commit message
src/cli.rs
| Old | New | ||
|---|---|---|---|
| @@ -40,6 +40,10 @@ pub enum Commands { | |||
| 40 | #[arg(long)] | 40 | #[arg(long)] |
| 41 | force: bool, | 41 | force: bool, |
| 42 | }, | 42 | }, |
| 43 | |||
| 44 | /// Manage trusted keys | ||
| 45 | #[command(subcommand)] | ||
| 46 | Key(KeyCmd), | ||
| 43 | } | 47 | } |
| 44 | 48 | ||
| 45 | #[derive(Subcommand)] | 49 | #[derive(Subcommand)] |
| @@ -127,6 +131,28 @@ pub enum IssueCmd { | |||
| 127 | } | 131 | } |
| 128 | 132 | ||
| 129 | #[derive(Subcommand)] | 133 | #[derive(Subcommand)] |
| 134 | pub enum KeyCmd { | ||
| 135 | /// Add a trusted public key | ||
| 136 | Add { | ||
| 137 | /// Base64-encoded Ed25519 public key | ||
| 138 | pubkey: Option<String>, | ||
| 139 | /// Read public key from your own signing key | ||
| 140 | #[arg(long = "self")] | ||
| 141 | self_key: bool, | ||
| 142 | /// Human-readable label for the key | ||
| 143 | #[arg(long)] | ||
| 144 | label: Option<String>, | ||
| 145 | }, | ||
| 146 | /// List trusted public keys | ||
| 147 | List, | ||
| 148 | /// Remove a trusted public key | ||
| 149 | Remove { | ||
| 150 | /// Base64-encoded public key to remove | ||
| 151 | pubkey: String, | ||
| 152 | }, | ||
| 153 | } | ||
| 154 | |||
| 155 | #[derive(Subcommand)] | ||
| 130 | pub enum PatchCmd { | 156 | pub enum PatchCmd { |
| 131 | /// Create a new patch for review | 157 | /// Create a new patch for review |
| 132 | Create { | 158 | Create { |
src/error.rs
| Old | New | ||
|---|---|---|---|
| @@ -22,4 +22,7 @@ pub enum Error { | |||
| 22 | 22 | ||
| 23 | #[error("no signing key found — run 'collab init-key' to generate one")] | 23 | #[error("no signing key found — run 'collab init-key' to generate one")] |
| 24 | KeyNotFound, | 24 | KeyNotFound, |
| 25 | |||
| 26 | #[error("untrusted key: {0}")] | ||
| 27 | UntrustedKey(String), | ||
| 25 | } | 28 | } |
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -8,10 +8,11 @@ pub mod patch; | |||
| 8 | pub mod state; | 8 | pub mod state; |
| 9 | pub mod signing; | 9 | pub mod signing; |
| 10 | pub mod sync; | 10 | pub mod sync; |
| 11 | pub mod trust; | ||
| 11 | pub mod tui; | 12 | pub mod tui; |
| 12 | 13 | ||
| 13 | use base64::Engine; | 14 | use base64::Engine; |
| 14 | use cli::{Commands, IssueCmd, PatchCmd}; | 15 | use cli::{Commands, IssueCmd, KeyCmd, PatchCmd}; |
| 15 | use event::ReviewVerdict; | 16 | use event::ReviewVerdict; |
| 16 | use git2::Repository; | 17 | use git2::Repository; |
| 17 | use state::{IssueStatus, PatchStatus}; | 18 | use state::{IssueStatus, PatchStatus}; |
| @@ -268,5 +269,75 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 268 | println!("Public key: {}", pubkey_b64); | 269 | println!("Public key: {}", pubkey_b64); |
| 269 | Ok(()) | 270 | Ok(()) |
| 270 | } | 271 | } |
| 272 | Commands::Key(cmd) => match cmd { | ||
| 273 | KeyCmd::Add { | ||
| 274 | pubkey, | ||
| 275 | self_key, | ||
| 276 | label, | ||
| 277 | } => { | ||
| 278 | if self_key && pubkey.is_some() { | ||
| 279 | return Err(error::Error::Cmd( | ||
| 280 | "cannot specify both --self and a public key argument".to_string(), | ||
| 281 | )); | ||
| 282 | } | ||
| 283 | let key = if self_key { | ||
| 284 | let config_dir = signing::signing_key_dir()?; | ||
| 285 | let vk = signing::load_verifying_key(&config_dir)?; | ||
| 286 | base64::engine::general_purpose::STANDARD.encode(vk.to_bytes()) | ||
| 287 | } else { | ||
| 288 | match pubkey { | ||
| 289 | Some(k) => k, | ||
| 290 | None => { | ||
| 291 | return Err(error::Error::Cmd( | ||
| 292 | "public key argument required (or use --self)".to_string(), | ||
| 293 | )); | ||
| 294 | } | ||
| 295 | } | ||
| 296 | }; | ||
| 297 | trust::validate_pubkey(&key)?; | ||
| 298 | let added = trust::save_trusted_key(repo, &key, label.as_deref())?; | ||
| 299 | if added { | ||
| 300 | let label_display = label | ||
| 301 | .as_ref() | ||
| 302 | .map(|l| format!(" ({})", l)) | ||
| 303 | .unwrap_or_default(); | ||
| 304 | println!("Trusted key added: {}{}", key, label_display); | ||
| 305 | } else { | ||
| 306 | println!("Key {} is already trusted.", key); | ||
| 307 | } | ||
| 308 | Ok(()) | ||
| 309 | } | ||
| 310 | KeyCmd::List => { | ||
| 311 | let policy = trust::load_trust_policy(repo)?; | ||
| 312 | match policy { | ||
| 313 | trust::TrustPolicy::Unconfigured => { | ||
| 314 | println!("No trusted keys configured."); | ||
| 315 | } | ||
| 316 | trust::TrustPolicy::Configured(keys) => { | ||
| 317 | if keys.is_empty() { | ||
| 318 | println!("No trusted keys configured."); | ||
| 319 | } else { | ||
| 320 | for k in &keys { | ||
| 321 | match &k.label { | ||
| 322 | Some(l) => println!("{} {}", k.pubkey, l), | ||
| 323 | None => println!("{}", k.pubkey), | ||
| 324 | } | ||
| 325 | } | ||
| 326 | } | ||
| 327 | } | ||
| 328 | } | ||
| 329 | Ok(()) | ||
| 330 | } | ||
| 331 | KeyCmd::Remove { pubkey } => { | ||
| 332 | let removed = trust::remove_trusted_key(repo, &pubkey)?; | ||
| 333 | let label_display = removed | ||
| 334 | .label | ||
| 335 | .as_ref() | ||
| 336 | .map(|l| format!(" ({})", l)) | ||
| 337 | .unwrap_or_default(); | ||
| 338 | println!("Removed trusted key: {}{}", removed.pubkey, label_display); | ||
| 339 | Ok(()) | ||
| 340 | } | ||
| 341 | }, | ||
| 271 | } | 342 | } |
| 272 | } | 343 | } |
src/signing.rs
| Old | New | ||
|---|---|---|---|
| @@ -47,6 +47,8 @@ pub enum VerifyStatus { | |||
| 47 | Invalid, | 47 | Invalid, |
| 48 | /// No signature or pubkey field in event.json. | 48 | /// No signature or pubkey field in event.json. |
| 49 | Missing, | 49 | Missing, |
| 50 | /// Signature valid but key not in trusted set. | ||
| 51 | Untrusted, | ||
| 50 | } | 52 | } |
| 51 | 53 | ||
| 52 | /// Detailed verification result for a single commit. | 54 | /// Detailed verification result for a single commit. |
| @@ -266,6 +268,16 @@ pub fn verify_ref( | |||
| 266 | error: Some("missing signature".to_string()), | 268 | error: Some("missing signature".to_string()), |
| 267 | }); | 269 | }); |
| 268 | } | 270 | } |
| 271 | VerifyStatus::Untrusted => { | ||
| 272 | // verify_signed_event never returns Untrusted, | ||
| 273 | // but handle it for exhaustiveness | ||
| 274 | results.push(SignatureVerificationResult { | ||
| 275 | commit_id: oid, | ||
| 276 | status: VerifyStatus::Untrusted, | ||
| 277 | pubkey: Some(signed.pubkey), | ||
| 278 | error: Some("untrusted key".to_string()), | ||
| 279 | }); | ||
| 280 | } | ||
| 269 | } | 281 | } |
| 270 | } | 282 | } |
| 271 | } else { | 283 | } else { |
src/sync.rs
| Old | New | ||
|---|---|---|---|
| @@ -6,6 +6,7 @@ use crate::dag; | |||
| 6 | use crate::error::Error; | 6 | use crate::error::Error; |
| 7 | use crate::identity::get_author; | 7 | use crate::identity::get_author; |
| 8 | use crate::signing; | 8 | use crate::signing; |
| 9 | use crate::trust; | ||
| 9 | 10 | ||
| 10 | /// Add collab refspecs to all remotes. | 11 | /// Add collab refspecs to all remotes. |
| 11 | pub fn init(repo: &Repository) -> Result<(), Error> { | 12 | pub fn init(repo: &Repository) -> Result<(), Error> { |
| @@ -125,10 +126,22 @@ fn reconcile_refs( | |||
| 125 | .collect() | 126 | .collect() |
| 126 | }; | 127 | }; |
| 127 | 128 | ||
| 129 | // Load trust policy once for all refs of this kind | ||
| 130 | let trust_policy = trust::load_trust_policy(repo)?; | ||
| 131 | let mut warned_unconfigured = false; | ||
| 132 | |||
| 128 | for (remote_ref, id) in &sync_refs { | 133 | for (remote_ref, id) in &sync_refs { |
| 129 | // Verify all commits on the remote ref before reconciling | 134 | // Verify all commits on the remote ref before reconciling |
| 130 | match signing::verify_ref(repo, remote_ref) { | 135 | match signing::verify_ref(repo, remote_ref) { |
| 131 | Ok(results) => { | 136 | Ok(results) => { |
| 137 | // Apply trust checking | ||
| 138 | let results = trust::check_trust(&results, &trust_policy); | ||
| 139 | |||
| 140 | if matches!(trust_policy, trust::TrustPolicy::Unconfigured) && !warned_unconfigured { | ||
| 141 | eprintln!("warning: no trusted keys configured — all valid signatures accepted. Run 'collab key add --self' to start."); | ||
| 142 | warned_unconfigured = true; | ||
| 143 | } | ||
| 144 | |||
| 132 | let failures: Vec<_> = results | 145 | let failures: Vec<_> = results |
| 133 | .iter() | 146 | .iter() |
| 134 | .filter(|r| r.status != signing::VerifyStatus::Valid) | 147 | .filter(|r| r.status != signing::VerifyStatus::Valid) |
src/trust.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,505 @@ | |||
| 1 | use std::collections::HashSet; | ||
| 2 | use std::fs; | ||
| 3 | use std::path::PathBuf; | ||
| 4 | |||
| 5 | use base64::engine::general_purpose::STANDARD; | ||
| 6 | use base64::Engine; | ||
| 7 | use ed25519_dalek::VerifyingKey; | ||
| 8 | use git2::Repository; | ||
| 9 | |||
| 10 | use crate::error::Error; | ||
| 11 | use crate::signing::{SignatureVerificationResult, VerifyStatus}; | ||
| 12 | |||
| 13 | /// A single trusted key entry. | ||
| 14 | #[derive(Debug, Clone)] | ||
| 15 | pub struct TrustedKey { | ||
| 16 | /// Base64-encoded Ed25519 public key. | ||
| 17 | pub pubkey: String, | ||
| 18 | /// Optional human-readable label. | ||
| 19 | pub label: Option<String>, | ||
| 20 | } | ||
| 21 | |||
| 22 | /// Loaded trust policy. | ||
| 23 | #[derive(Debug)] | ||
| 24 | pub enum TrustPolicy { | ||
| 25 | /// No trusted keys file exists. Fall back to accepting any valid signature. | ||
| 26 | Unconfigured, | ||
| 27 | /// Trusted keys file exists (possibly empty). Enforce allowlist. | ||
| 28 | Configured(Vec<TrustedKey>), | ||
| 29 | } | ||
| 30 | |||
| 31 | /// Return the path to the trusted keys file for the given repo. | ||
| 32 | pub fn trusted_keys_path(repo: &Repository) -> PathBuf { | ||
| 33 | repo.path().join("collab/trusted-keys") | ||
| 34 | } | ||
| 35 | |||
| 36 | /// Validate that a string is a valid base64-encoded 32-byte Ed25519 public key. | ||
| 37 | pub fn validate_pubkey(pubkey: &str) -> Result<(), Error> { | ||
| 38 | let bytes = STANDARD | ||
| 39 | .decode(pubkey.trim()) | ||
| 40 | .map_err(|e| Error::Verification(format!("invalid public key: base64 decode failed: {}", e)))?; | ||
| 41 | if bytes.len() != 32 { | ||
| 42 | return Err(Error::Verification(format!( | ||
| 43 | "invalid public key: expected 32 bytes, got {}", | ||
| 44 | bytes.len() | ||
| 45 | ))); | ||
| 46 | } | ||
| 47 | let key_bytes: [u8; 32] = bytes.try_into().unwrap(); | ||
| 48 | VerifyingKey::from_bytes(&key_bytes) | ||
| 49 | .map_err(|e| Error::Verification(format!("invalid public key: not a valid Ed25519 point: {}", e)))?; | ||
| 50 | Ok(()) | ||
| 51 | } | ||
| 52 | |||
| 53 | /// Load the trust policy from the trusted keys file. | ||
| 54 | /// | ||
| 55 | /// Returns `TrustPolicy::Unconfigured` if the file does not exist. | ||
| 56 | /// Returns `TrustPolicy::Configured(keys)` if the file exists (even if empty). | ||
| 57 | /// Malformed lines are skipped with a warning to stderr. | ||
| 58 | /// Duplicate keys are deduplicated (last wins for label). | ||
| 59 | pub fn load_trust_policy(repo: &Repository) -> Result<TrustPolicy, Error> { | ||
| 60 | let path = trusted_keys_path(repo); | ||
| 61 | if !path.exists() { | ||
| 62 | return Ok(TrustPolicy::Unconfigured); | ||
| 63 | } | ||
| 64 | let content = fs::read_to_string(&path)?; | ||
| 65 | let mut seen = HashSet::new(); | ||
| 66 | let mut keys = Vec::new(); | ||
| 67 | |||
| 68 | for line in content.lines() { | ||
| 69 | let trimmed = line.trim(); | ||
| 70 | if trimmed.is_empty() || trimmed.starts_with('#') { | ||
| 71 | continue; | ||
| 72 | } | ||
| 73 | let (pubkey, label) = match trimmed.split_once(' ') { | ||
| 74 | Some((k, l)) => (k.to_string(), Some(l.to_string())), | ||
| 75 | None => (trimmed.to_string(), None), | ||
| 76 | }; | ||
| 77 | // Validate the key; skip malformed entries | ||
| 78 | if let Err(e) = validate_pubkey(&pubkey) { | ||
| 79 | eprintln!("warning: skipping malformed trusted key: {}: {}", pubkey, e); | ||
| 80 | continue; | ||
| 81 | } | ||
| 82 | // Deduplicate: remove earlier entry if present | ||
| 83 | if seen.contains(&pubkey) { | ||
| 84 | keys.retain(|k: &TrustedKey| k.pubkey != pubkey); | ||
| 85 | } | ||
| 86 | seen.insert(pubkey.clone()); | ||
| 87 | keys.push(TrustedKey { pubkey, label }); | ||
| 88 | } | ||
| 89 | |||
| 90 | Ok(TrustPolicy::Configured(keys)) | ||
| 91 | } | ||
| 92 | |||
| 93 | /// Check whether a pubkey is in the trusted set. | ||
| 94 | pub fn is_key_trusted(keys: &[TrustedKey], pubkey: &str) -> bool { | ||
| 95 | keys.iter().any(|k| k.pubkey == pubkey) | ||
| 96 | } | ||
| 97 | |||
| 98 | /// Save (append) a trusted key to the trusted keys file. | ||
| 99 | /// | ||
| 100 | /// Creates the `.git/collab/` directory and file if needed. | ||
| 101 | /// Returns `true` if the key was added, `false` if it was already trusted. | ||
| 102 | pub fn save_trusted_key(repo: &Repository, pubkey: &str, label: Option<&str>) -> Result<bool, Error> { | ||
| 103 | let path = trusted_keys_path(repo); | ||
| 104 | // Ensure directory exists | ||
| 105 | if let Some(parent) = path.parent() { | ||
| 106 | fs::create_dir_all(parent)?; | ||
| 107 | } | ||
| 108 | // Check for duplicates | ||
| 109 | if path.exists() { | ||
| 110 | let policy = load_trust_policy(repo)?; | ||
| 111 | if let TrustPolicy::Configured(ref keys) = policy { | ||
| 112 | if is_key_trusted(keys, pubkey) { | ||
| 113 | return Ok(false); | ||
| 114 | } | ||
| 115 | } | ||
| 116 | } | ||
| 117 | // Append key | ||
| 118 | let line = match label { | ||
| 119 | Some(l) => format!("{} {}\n", pubkey, l), | ||
| 120 | None => format!("{}\n", pubkey), | ||
| 121 | }; | ||
| 122 | use std::io::Write; | ||
| 123 | let mut file = fs::OpenOptions::new() | ||
| 124 | .create(true) | ||
| 125 | .append(true) | ||
| 126 | .open(&path)?; | ||
| 127 | file.write_all(line.as_bytes())?; | ||
| 128 | Ok(true) | ||
| 129 | } | ||
| 130 | |||
| 131 | /// Remove a trusted key from the file. Returns the removed entry. | ||
| 132 | pub fn remove_trusted_key(repo: &Repository, pubkey: &str) -> Result<TrustedKey, Error> { | ||
| 133 | let path = trusted_keys_path(repo); | ||
| 134 | let policy = load_trust_policy(repo)?; | ||
| 135 | match policy { | ||
| 136 | TrustPolicy::Unconfigured => { | ||
| 137 | return Err(Error::Cmd(format!( | ||
| 138 | "key {} is not in the trusted keys list", | ||
| 139 | pubkey | ||
| 140 | ))); | ||
| 141 | } | ||
| 142 | TrustPolicy::Configured(keys) => { | ||
| 143 | let removed = keys.iter().find(|k| k.pubkey == pubkey).cloned(); | ||
| 144 | match removed { | ||
| 145 | None => { | ||
| 146 | return Err(Error::Cmd(format!( | ||
| 147 | "key {} is not in the trusted keys list", | ||
| 148 | pubkey | ||
| 149 | ))); | ||
| 150 | } | ||
| 151 | Some(removed_key) => { | ||
| 152 | // Rewrite file without the removed key | ||
| 153 | let content = fs::read_to_string(&path)?; | ||
| 154 | let mut new_lines = Vec::new(); | ||
| 155 | for line in content.lines() { | ||
| 156 | let trimmed = line.trim(); | ||
| 157 | if trimmed.is_empty() || trimmed.starts_with('#') { | ||
| 158 | new_lines.push(line.to_string()); | ||
| 159 | continue; | ||
| 160 | } | ||
| 161 | let key_part = trimmed.split_once(' ') | ||
| 162 | .map(|(k, _)| k) | ||
| 163 | .unwrap_or(trimmed); | ||
| 164 | if key_part != pubkey { | ||
| 165 | new_lines.push(line.to_string()); | ||
| 166 | } | ||
| 167 | } | ||
| 168 | let new_content = if new_lines.is_empty() { | ||
| 169 | String::new() | ||
| 170 | } else { | ||
| 171 | new_lines.join("\n") + "\n" | ||
| 172 | }; | ||
| 173 | fs::write(&path, new_content)?; | ||
| 174 | Ok(removed_key) | ||
| 175 | } | ||
| 176 | } | ||
| 177 | } | ||
| 178 | } | ||
| 179 | } | ||
| 180 | |||
| 181 | /// Check trust for a set of signature verification results. | ||
| 182 | /// | ||
| 183 | /// If `TrustPolicy::Unconfigured`, returns the results unchanged. | ||
| 184 | /// If `TrustPolicy::Configured`, changes `Valid` results to `Untrusted` when the | ||
| 185 | /// pubkey is not in the trusted set. All other statuses pass through unchanged. | ||
| 186 | pub fn check_trust( | ||
| 187 | results: &[SignatureVerificationResult], | ||
| 188 | policy: &TrustPolicy, | ||
| 189 | ) -> Vec<SignatureVerificationResult> { | ||
| 190 | match policy { | ||
| 191 | TrustPolicy::Unconfigured => results.to_vec(), | ||
| 192 | TrustPolicy::Configured(keys) => { | ||
| 193 | let trusted_set: HashSet<&str> = keys.iter().map(|k| k.pubkey.as_str()).collect(); | ||
| 194 | results | ||
| 195 | .iter() | ||
| 196 | .map(|r| { | ||
| 197 | if r.status == VerifyStatus::Valid { | ||
| 198 | if let Some(ref pk) = r.pubkey { | ||
| 199 | if !trusted_set.contains(pk.as_str()) { | ||
| 200 | return SignatureVerificationResult { | ||
| 201 | commit_id: r.commit_id, | ||
| 202 | status: VerifyStatus::Untrusted, | ||
| 203 | pubkey: r.pubkey.clone(), | ||
| 204 | error: Some(format!("untrusted key: {}", pk)), | ||
| 205 | }; | ||
| 206 | } | ||
| 207 | } | ||
| 208 | } | ||
| 209 | r.clone() | ||
| 210 | }) | ||
| 211 | .collect() | ||
| 212 | } | ||
| 213 | } | ||
| 214 | } | ||
| 215 | |||
| 216 | #[cfg(test)] | ||
| 217 | mod tests { | ||
| 218 | use super::*; | ||
| 219 | use tempfile::TempDir; | ||
| 220 | use git2::Oid; | ||
| 221 | |||
| 222 | fn test_repo() -> (TempDir, Repository) { | ||
| 223 | let dir = TempDir::new().unwrap(); | ||
| 224 | let repo = Repository::init(dir.path()).unwrap(); | ||
| 225 | (dir, repo) | ||
| 226 | } | ||
| 227 | |||
| 228 | /// Generate a valid Ed25519 public key in base64 for testing. | ||
| 229 | fn valid_test_pubkey() -> String { | ||
| 230 | use ed25519_dalek::SigningKey; | ||
| 231 | use rand_core::OsRng; | ||
| 232 | let sk = SigningKey::generate(&mut OsRng); | ||
| 233 | STANDARD.encode(sk.verifying_key().to_bytes()) | ||
| 234 | } | ||
| 235 | |||
| 236 | // ===================================================== | ||
| 237 | // T004: Unit tests for validate_pubkey() | ||
| 238 | // ===================================================== | ||
| 239 | |||
| 240 | #[test] | ||
| 241 | fn validate_pubkey_accepts_valid_key() { | ||
| 242 | let pk = valid_test_pubkey(); | ||
| 243 | assert!(validate_pubkey(&pk).is_ok()); | ||
| 244 | } | ||
| 245 | |||
| 246 | #[test] | ||
| 247 | fn validate_pubkey_rejects_invalid_base64() { | ||
| 248 | let result = validate_pubkey("not-valid-base64!!!"); | ||
| 249 | assert!(result.is_err()); | ||
| 250 | let err = result.unwrap_err().to_string(); | ||
| 251 | assert!(err.contains("base64"), "error should mention base64: {}", err); | ||
| 252 | } | ||
| 253 | |||
| 254 | #[test] | ||
| 255 | fn validate_pubkey_rejects_wrong_byte_length() { | ||
| 256 | // 16 bytes instead of 32 | ||
| 257 | let short = STANDARD.encode(vec![0u8; 16]); | ||
| 258 | let result = validate_pubkey(&short); | ||
| 259 | assert!(result.is_err()); | ||
| 260 | let err = result.unwrap_err().to_string(); | ||
| 261 | assert!(err.contains("32 bytes"), "error should mention 32 bytes: {}", err); | ||
| 262 | } | ||
| 263 | |||
| 264 | #[test] | ||
| 265 | fn validate_pubkey_rejects_too_long() { | ||
| 266 | let long = STANDARD.encode(vec![0u8; 64]); | ||
| 267 | let result = validate_pubkey(&long); | ||
| 268 | assert!(result.is_err()); | ||
| 269 | } | ||
| 270 | |||
| 271 | #[test] | ||
| 272 | fn validate_pubkey_accepts_whitespace_trimmed() { | ||
| 273 | let pk = valid_test_pubkey(); | ||
| 274 | let padded = format!(" {} ", pk); | ||
| 275 | assert!(validate_pubkey(&padded).is_ok()); | ||
| 276 | } | ||
| 277 | |||
| 278 | // ===================================================== | ||
| 279 | // T005: Unit tests for load_trust_policy() and save_trusted_key() | ||
| 280 | // ===================================================== | ||
| 281 | |||
| 282 | #[test] | ||
| 283 | fn load_trust_policy_returns_unconfigured_when_no_file() { | ||
| 284 | let (_dir, repo) = test_repo(); | ||
| 285 | let policy = load_trust_policy(&repo).unwrap(); | ||
| 286 | assert!(matches!(policy, TrustPolicy::Unconfigured)); | ||
| 287 | } | ||
| 288 | |||
| 289 | #[test] | ||
| 290 | fn load_trust_policy_returns_configured_empty_for_empty_file() { | ||
| 291 | let (_dir, repo) = test_repo(); | ||
| 292 | let path = trusted_keys_path(&repo); | ||
| 293 | fs::create_dir_all(path.parent().unwrap()).unwrap(); | ||
| 294 | fs::write(&path, "").unwrap(); | ||
| 295 | let policy = load_trust_policy(&repo).unwrap(); | ||
| 296 | match policy { | ||
| 297 | TrustPolicy::Configured(keys) => assert!(keys.is_empty()), | ||
| 298 | _ => panic!("expected Configured"), | ||
| 299 | } | ||
| 300 | } | ||
| 301 | |||
| 302 | #[test] | ||
| 303 | fn load_trust_policy_parses_keys_and_labels() { | ||
| 304 | let (_dir, repo) = test_repo(); | ||
| 305 | let pk1 = valid_test_pubkey(); | ||
| 306 | let pk2 = valid_test_pubkey(); | ||
| 307 | let path = trusted_keys_path(&repo); | ||
| 308 | fs::create_dir_all(path.parent().unwrap()).unwrap(); | ||
| 309 | fs::write(&path, format!("# Comment\n{} Alice\n{}\n\n", pk1, pk2)).unwrap(); | ||
| 310 | let policy = load_trust_policy(&repo).unwrap(); | ||
| 311 | match policy { | ||
| 312 | TrustPolicy::Configured(keys) => { | ||
| 313 | assert_eq!(keys.len(), 2); | ||
| 314 | assert_eq!(keys[0].pubkey, pk1); | ||
| 315 | assert_eq!(keys[0].label.as_deref(), Some("Alice")); | ||
| 316 | assert_eq!(keys[1].pubkey, pk2); | ||
| 317 | assert!(keys[1].label.is_none()); | ||
| 318 | } | ||
| 319 | _ => panic!("expected Configured"), | ||
| 320 | } | ||
| 321 | } | ||
| 322 | |||
| 323 | #[test] | ||
| 324 | fn load_trust_policy_skips_malformed_lines() { | ||
| 325 | let (_dir, repo) = test_repo(); | ||
| 326 | let pk = valid_test_pubkey(); | ||
| 327 | let path = trusted_keys_path(&repo); | ||
| 328 | fs::create_dir_all(path.parent().unwrap()).unwrap(); | ||
| 329 | fs::write(&path, format!("garbage_not_base64\n{} Good key\n", pk)).unwrap(); | ||
| 330 | let policy = load_trust_policy(&repo).unwrap(); | ||
| 331 | match policy { | ||
| 332 | TrustPolicy::Configured(keys) => { | ||
| 333 | assert_eq!(keys.len(), 1); | ||
| 334 | assert_eq!(keys[0].pubkey, pk); | ||
| 335 | } | ||
| 336 | _ => panic!("expected Configured"), | ||
| 337 | } | ||
| 338 | } | ||
| 339 | |||
| 340 | #[test] | ||
| 341 | fn load_trust_policy_deduplicates_keys() { | ||
| 342 | let (_dir, repo) = test_repo(); | ||
| 343 | let pk = valid_test_pubkey(); | ||
| 344 | let path = trusted_keys_path(&repo); | ||
| 345 | fs::create_dir_all(path.parent().unwrap()).unwrap(); | ||
| 346 | fs::write(&path, format!("{} First\n{} Second\n", pk, pk)).unwrap(); | ||
| 347 | let policy = load_trust_policy(&repo).unwrap(); | ||
| 348 | match policy { | ||
| 349 | TrustPolicy::Configured(keys) => { | ||
| 350 | assert_eq!(keys.len(), 1); | ||
| 351 | // Last wins for label | ||
| 352 | assert_eq!(keys[0].label.as_deref(), Some("Second")); | ||
| 353 | } | ||
| 354 | _ => panic!("expected Configured"), | ||
| 355 | } | ||
| 356 | } | ||
| 357 | |||
| 358 | #[test] | ||
| 359 | fn save_trusted_key_creates_file_and_appends() { | ||
| 360 | let (_dir, repo) = test_repo(); | ||
| 361 | let pk1 = valid_test_pubkey(); | ||
| 362 | let pk2 = valid_test_pubkey(); | ||
| 363 | save_trusted_key(&repo, &pk1, Some("Alice")).unwrap(); | ||
| 364 | save_trusted_key(&repo, &pk2, None).unwrap(); | ||
| 365 | let policy = load_trust_policy(&repo).unwrap(); | ||
| 366 | match policy { | ||
| 367 | TrustPolicy::Configured(keys) => { | ||
| 368 | assert_eq!(keys.len(), 2); | ||
| 369 | assert_eq!(keys[0].pubkey, pk1); | ||
| 370 | assert_eq!(keys[0].label.as_deref(), Some("Alice")); | ||
| 371 | assert_eq!(keys[1].pubkey, pk2); | ||
| 372 | assert!(keys[1].label.is_none()); | ||
| 373 | } | ||
| 374 | _ => panic!("expected Configured"), | ||
| 375 | } | ||
| 376 | } | ||
| 377 | |||
| 378 | #[test] | ||
| 379 | fn save_trusted_key_prevents_duplicates() { | ||
| 380 | let (_dir, repo) = test_repo(); | ||
| 381 | let pk = valid_test_pubkey(); | ||
| 382 | let added = save_trusted_key(&repo, &pk, Some("Alice")).unwrap(); | ||
| 383 | assert!(added, "first add should return true"); | ||
| 384 | let added = save_trusted_key(&repo, &pk, Some("Alice again")).unwrap(); | ||
| 385 | assert!(!added, "duplicate add should return false"); | ||
| 386 | let policy = load_trust_policy(&repo).unwrap(); | ||
| 387 | match policy { | ||
| 388 | TrustPolicy::Configured(keys) => { | ||
| 389 | assert_eq!(keys.len(), 1, "should not have duplicates"); | ||
| 390 | } | ||
| 391 | _ => panic!("expected Configured"), | ||
| 392 | } | ||
| 393 | } | ||
| 394 | |||
| 395 | // ===================================================== | ||
| 396 | // T012: Unit tests for check_trust() | ||
| 397 | // ===================================================== | ||
| 398 | |||
| 399 | fn make_result(status: VerifyStatus, pubkey: Option<&str>) -> SignatureVerificationResult { | ||
| 400 | SignatureVerificationResult { | ||
| 401 | commit_id: Oid::zero(), | ||
| 402 | status, | ||
| 403 | pubkey: pubkey.map(|s| s.to_string()), | ||
| 404 | error: None, | ||
| 405 | } | ||
| 406 | } | ||
| 407 | |||
| 408 | #[test] | ||
| 409 | fn check_trust_unconfigured_passes_all_through() { | ||
| 410 | let pk = valid_test_pubkey(); | ||
| 411 | let results = vec![make_result(VerifyStatus::Valid, Some(&pk))]; | ||
| 412 | let checked = check_trust(&results, &TrustPolicy::Unconfigured); | ||
| 413 | assert_eq!(checked.len(), 1); | ||
| 414 | assert_eq!(checked[0].status, VerifyStatus::Valid); | ||
| 415 | } | ||
| 416 | |||
| 417 | #[test] | ||
| 418 | fn check_trust_configured_with_trusted_key_returns_valid() { | ||
| 419 | let pk = valid_test_pubkey(); | ||
| 420 | let keys = vec![TrustedKey { pubkey: pk.clone(), label: None }]; | ||
| 421 | let results = vec![make_result(VerifyStatus::Valid, Some(&pk))]; | ||
| 422 | let checked = check_trust(&results, &TrustPolicy::Configured(keys)); | ||
| 423 | assert_eq!(checked[0].status, VerifyStatus::Valid); | ||
| 424 | } | ||
| 425 | |||
| 426 | #[test] | ||
| 427 | fn check_trust_configured_with_untrusted_key_returns_untrusted() { | ||
| 428 | let pk_trusted = valid_test_pubkey(); | ||
| 429 | let pk_untrusted = valid_test_pubkey(); | ||
| 430 | let keys = vec![TrustedKey { pubkey: pk_trusted, label: None }]; | ||
| 431 | let results = vec![make_result(VerifyStatus::Valid, Some(&pk_untrusted))]; | ||
| 432 | let checked = check_trust(&results, &TrustPolicy::Configured(keys)); | ||
| 433 | assert_eq!(checked[0].status, VerifyStatus::Untrusted); | ||
| 434 | assert!(checked[0].error.as_ref().unwrap().contains(&pk_untrusted)); | ||
| 435 | } | ||
| 436 | |||
| 437 | #[test] | ||
| 438 | fn check_trust_passes_through_missing_and_invalid() { | ||
| 439 | let keys = vec![]; | ||
| 440 | let results = vec![ | ||
| 441 | make_result(VerifyStatus::Missing, None), | ||
| 442 | make_result(VerifyStatus::Invalid, Some("whatever")), | ||
| 443 | ]; | ||
| 444 | let checked = check_trust(&results, &TrustPolicy::Configured(keys)); | ||
| 445 | assert_eq!(checked[0].status, VerifyStatus::Missing); | ||
| 446 | assert_eq!(checked[1].status, VerifyStatus::Invalid); | ||
| 447 | } | ||
| 448 | |||
| 449 | #[test] | ||
| 450 | fn check_trust_empty_configured_rejects_all_valid() { | ||
| 451 | let pk = valid_test_pubkey(); | ||
| 452 | let results = vec![make_result(VerifyStatus::Valid, Some(&pk))]; | ||
| 453 | let checked = check_trust(&results, &TrustPolicy::Configured(vec![])); | ||
| 454 | assert_eq!(checked[0].status, VerifyStatus::Untrusted); | ||
| 455 | } | ||
| 456 | |||
| 457 | // ===================================================== | ||
| 458 | // T009: Unit tests for remove_trusted_key() | ||
| 459 | // ===================================================== | ||
| 460 | |||
| 461 | #[test] | ||
| 462 | fn remove_trusted_key_removes_and_returns_entry() { | ||
| 463 | let (_dir, repo) = test_repo(); | ||
| 464 | let pk1 = valid_test_pubkey(); | ||
| 465 | let pk2 = valid_test_pubkey(); | ||
| 466 | save_trusted_key(&repo, &pk1, Some("Alice")).unwrap(); | ||
| 467 | save_trusted_key(&repo, &pk2, Some("Bob")).unwrap(); | ||
| 468 | let removed = remove_trusted_key(&repo, &pk1).unwrap(); | ||
| 469 | assert_eq!(removed.pubkey, pk1); | ||
| 470 | assert_eq!(removed.label.as_deref(), Some("Alice")); | ||
| 471 | let policy = load_trust_policy(&repo).unwrap(); | ||
| 472 | match policy { | ||
| 473 | TrustPolicy::Configured(keys) => { | ||
| 474 | assert_eq!(keys.len(), 1); | ||
| 475 | assert_eq!(keys[0].pubkey, pk2); | ||
| 476 | } | ||
| 477 | _ => panic!("expected Configured"), | ||
| 478 | } | ||
| 479 | } | ||
| 480 | |||
| 481 | #[test] | ||
| 482 | fn remove_trusted_key_errors_when_not_found() { | ||
| 483 | let (_dir, repo) = test_repo(); | ||
| 484 | let pk = valid_test_pubkey(); | ||
| 485 | save_trusted_key(&repo, &pk, None).unwrap(); | ||
| 486 | let other_pk = valid_test_pubkey(); | ||
| 487 | let result = remove_trusted_key(&repo, &other_pk); | ||
| 488 | assert!(result.is_err()); | ||
| 489 | assert!(result.unwrap_err().to_string().contains("not in the trusted keys list")); | ||
| 490 | } | ||
| 491 | |||
| 492 | #[test] | ||
| 493 | fn remove_last_key_leaves_empty_configured_file() { | ||
| 494 | let (_dir, repo) = test_repo(); | ||
| 495 | let pk = valid_test_pubkey(); | ||
| 496 | save_trusted_key(&repo, &pk, None).unwrap(); | ||
| 497 | remove_trusted_key(&repo, &pk).unwrap(); | ||
| 498 | // File should still exist (Configured) but empty | ||
| 499 | let policy = load_trust_policy(&repo).unwrap(); | ||
| 500 | match policy { | ||
| 501 | TrustPolicy::Configured(keys) => assert!(keys.is_empty()), | ||
| 502 | _ => panic!("expected Configured (empty)"), | ||
| 503 | } | ||
| 504 | } | ||
| 505 | } | ||
tests/trust_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,203 @@ | |||
| 1 | mod common; | ||
| 2 | |||
| 3 | use common::TestRepo; | ||
| 4 | |||
| 5 | // =========================================================================== | ||
| 6 | // T006: Integration tests for `collab key add` | ||
| 7 | // =========================================================================== | ||
| 8 | |||
| 9 | #[test] | ||
| 10 | fn test_key_add_valid_key() { | ||
| 11 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 12 | |||
| 13 | // Add own key via --self | ||
| 14 | let out = repo.run_ok(&["key", "add", "--self"]); | ||
| 15 | assert!( | ||
| 16 | out.contains("Trusted key added:"), | ||
| 17 | "should confirm key added, got: {}", | ||
| 18 | out | ||
| 19 | ); | ||
| 20 | } | ||
| 21 | |||
| 22 | #[test] | ||
| 23 | fn test_key_add_with_label() { | ||
| 24 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 25 | |||
| 26 | let out = repo.run_ok(&["key", "add", "--self", "--label", "Alice (main laptop)"]); | ||
| 27 | assert!(out.contains("Trusted key added:")); | ||
| 28 | assert!(out.contains("Alice (main laptop)")); | ||
| 29 | |||
| 30 | // Verify it shows up in list | ||
| 31 | let out = repo.run_ok(&["key", "list"]); | ||
| 32 | assert!(out.contains("Alice (main laptop)")); | ||
| 33 | } | ||
| 34 | |||
| 35 | #[test] | ||
| 36 | fn test_key_add_duplicate_prints_message() { | ||
| 37 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 38 | |||
| 39 | repo.run_ok(&["key", "add", "--self"]); | ||
| 40 | let out = repo.run_ok(&["key", "add", "--self"]); | ||
| 41 | assert!( | ||
| 42 | out.contains("already trusted"), | ||
| 43 | "should say already trusted, got: {}", | ||
| 44 | out | ||
| 45 | ); | ||
| 46 | } | ||
| 47 | |||
| 48 | #[test] | ||
| 49 | fn test_key_add_invalid_key_returns_error() { | ||
| 50 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 51 | |||
| 52 | let err = repo.run_err(&["key", "add", "not-a-valid-key!!!"]); | ||
| 53 | assert!( | ||
| 54 | err.contains("invalid") || err.contains("base64"), | ||
| 55 | "should mention invalid key, got: {}", | ||
| 56 | err | ||
| 57 | ); | ||
| 58 | } | ||
| 59 | |||
| 60 | #[test] | ||
| 61 | fn test_key_add_self_and_pubkey_errors() { | ||
| 62 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 63 | |||
| 64 | let err = repo.run_err(&["key", "add", "--self", "somepubkey"]); | ||
| 65 | assert!( | ||
| 66 | err.contains("cannot specify both"), | ||
| 67 | "should error on --self with pubkey, got: {}", | ||
| 68 | err | ||
| 69 | ); | ||
| 70 | } | ||
| 71 | |||
| 72 | #[test] | ||
| 73 | fn test_key_add_explicit_pubkey() { | ||
| 74 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 75 | |||
| 76 | // Get our own public key to use as a valid key | ||
| 77 | let out = repo.run_ok(&["key", "add", "--self", "--label", "Me"]); | ||
| 78 | assert!(out.contains("Trusted key added:")); | ||
| 79 | |||
| 80 | // List should show the key | ||
| 81 | let out = repo.run_ok(&["key", "list"]); | ||
| 82 | assert!(out.contains("Me")); | ||
| 83 | } | ||
| 84 | |||
| 85 | // =========================================================================== | ||
| 86 | // T016: Integration tests for `collab key list` | ||
| 87 | // =========================================================================== | ||
| 88 | |||
| 89 | #[test] | ||
| 90 | fn test_key_list_no_keys() { | ||
| 91 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 92 | |||
| 93 | let out = repo.run_ok(&["key", "list"]); | ||
| 94 | assert!( | ||
| 95 | out.contains("No trusted keys configured"), | ||
| 96 | "should say no keys, got: {}", | ||
| 97 | out | ||
| 98 | ); | ||
| 99 | } | ||
| 100 | |||
| 101 | #[test] | ||
| 102 | fn test_key_list_shows_keys_and_labels() { | ||
| 103 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 104 | |||
| 105 | repo.run_ok(&["key", "add", "--self", "--label", "Alice"]); | ||
| 106 | |||
| 107 | let out = repo.run_ok(&["key", "list"]); | ||
| 108 | assert!(out.contains("Alice"), "should show label, got: {}", out); | ||
| 109 | // Should contain a base64 key string (44 chars for ed25519) | ||
| 110 | assert!( | ||
| 111 | out.lines().any(|l| l.len() >= 44), | ||
| 112 | "should contain a key string, got: {}", | ||
| 113 | out | ||
| 114 | ); | ||
| 115 | } | ||
| 116 | |||
| 117 | // =========================================================================== | ||
| 118 | // T017: Integration tests for `collab key remove` | ||
| 119 | // =========================================================================== | ||
| 120 | |||
| 121 | #[test] | ||
| 122 | fn test_key_remove_existing() { | ||
| 123 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 124 | |||
| 125 | // Add a key, extract the pubkey from the list | ||
| 126 | repo.run_ok(&["key", "add", "--self", "--label", "Alice"]); | ||
| 127 | |||
| 128 | let list = repo.run_ok(&["key", "list"]); | ||
| 129 | let pubkey = list.lines().next().unwrap().split_whitespace().next().unwrap(); | ||
| 130 | |||
| 131 | let out = repo.run_ok(&["key", "remove", pubkey]); | ||
| 132 | assert!( | ||
| 133 | out.contains("Removed trusted key:"), | ||
| 134 | "should confirm removal, got: {}", | ||
| 135 | out | ||
| 136 | ); | ||
| 137 | assert!(out.contains("Alice"), "should show label of removed key"); | ||
| 138 | |||
| 139 | // List should now be empty | ||
| 140 | let out = repo.run_ok(&["key", "list"]); | ||
| 141 | assert!(out.contains("No trusted keys configured")); | ||
| 142 | } | ||
| 143 | |||
| 144 | #[test] | ||
| 145 | fn test_key_remove_nonexistent_errors() { | ||
| 146 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 147 | |||
| 148 | // Add a key first so the file exists | ||
| 149 | repo.run_ok(&["key", "add", "--self"]); | ||
| 150 | |||
| 151 | let err = repo.run_err(&["key", "remove", "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY="]); | ||
| 152 | assert!( | ||
| 153 | err.contains("not in the trusted keys list"), | ||
| 154 | "should say not found, got: {}", | ||
| 155 | err | ||
| 156 | ); | ||
| 157 | } | ||
| 158 | |||
| 159 | // =========================================================================== | ||
| 160 | // T021: Edge case tests | ||
| 161 | // =========================================================================== | ||
| 162 | |||
| 163 | #[test] | ||
| 164 | fn test_key_add_then_remove_then_readd() { | ||
| 165 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 166 | |||
| 167 | repo.run_ok(&["key", "add", "--self", "--label", "Original"]); | ||
| 168 | |||
| 169 | let list = repo.run_ok(&["key", "list"]); | ||
| 170 | let pubkey = list.lines().next().unwrap().split_whitespace().next().unwrap().to_string(); | ||
| 171 | |||
| 172 | // Remove | ||
| 173 | repo.run_ok(&["key", "remove", &pubkey]); | ||
| 174 | |||
| 175 | // Re-add with a different label | ||
| 176 | let out = repo.run_ok(&["key", "add", &pubkey, "--label", "Re-added"]); | ||
| 177 | assert!(out.contains("Trusted key added:")); | ||
| 178 | |||
| 179 | let list = repo.run_ok(&["key", "list"]); | ||
| 180 | assert!(list.contains("Re-added")); | ||
| 181 | } | ||
| 182 | |||
| 183 | #[test] | ||
| 184 | fn test_key_list_with_corrupted_file() { | ||
| 185 | let repo = TestRepo::new("Alice", "alice@example.com"); | ||
| 186 | |||
| 187 | // Add a valid key first | ||
| 188 | repo.run_ok(&["key", "add", "--self", "--label", "Good key"]); | ||
| 189 | |||
| 190 | // Manually corrupt the file by adding a bad line | ||
| 191 | let git_dir = repo.dir.path().join(".git/collab/trusted-keys"); | ||
| 192 | let content = std::fs::read_to_string(&git_dir).unwrap(); | ||
| 193 | std::fs::write( | ||
| 194 | &git_dir, | ||
| 195 | format!("{}garbage_not_a_valid_key BadEntry\n", content), | ||
| 196 | ) | ||
| 197 | .unwrap(); | ||
| 198 | |||
| 199 | // List should still work, showing valid keys and skipping the bad one | ||
| 200 | let out = repo.run_ok(&["key", "list"]); | ||
| 201 | assert!(out.contains("Good key"), "valid key should still appear"); | ||
| 202 | // The bad line should be skipped (warning goes to stderr) | ||
| 203 | } | ||