verify_commit.rs
1 use crate::ux::format::is_json_mode; 2 use anyhow::{Context, Result, anyhow}; 3 use auths_verifier::witness::{WitnessQuorum, WitnessReceipt, WitnessVerifyConfig}; 4 use auths_verifier::{ 5 IdentityBundle, VerificationReport, verify_chain, verify_chain_with_witnesses, 6 }; 7 use base64; 8 use chrono::{Duration, Utc}; 9 use clap::Parser; 10 use serde::Serialize; 11 use std::fs; 12 use std::io::Write; 13 use std::path::{Path, PathBuf}; 14 use std::process::{Command, Stdio}; 15 use tempfile::NamedTempFile; 16 17 use super::verify_helpers::parse_witness_keys; 18 19 #[derive(Parser, Debug, Clone)] 20 #[command(about = "Verify Git commit signatures against Auths identity.")] 21 pub struct VerifyCommitCommand { 22 /// Commit SHA, range (e.g., HEAD~5..HEAD), or "HEAD" (default). 23 #[arg(default_value = "HEAD")] 24 pub commit: String, 25 26 /// Path to allowed signers file. 27 #[arg(long, default_value = ".auths/allowed_signers")] 28 pub allowed_signers: PathBuf, 29 30 /// Path to identity bundle JSON (for CI/CD stateless verification). 31 /// 32 /// When provided, verification uses the bundle's public key instead of 33 /// the allowed_signers file. This enables stateless verification without 34 /// requiring access to identity repositories. 35 #[arg(long, value_parser, help = "Path to identity bundle JSON (for CI)")] 36 pub identity_bundle: Option<PathBuf>, 37 38 /// Path to witness receipts JSON file. 39 #[arg(long)] 40 pub witness_receipts: Option<PathBuf>, 41 42 /// Witness quorum threshold (default: 1). 43 #[arg(long, default_value = "1")] 44 pub witness_threshold: usize, 45 46 /// Witness public keys as DID:hex pairs (e.g., "did:key:z6Mk...:abcd1234..."). 47 #[arg(long, num_args = 1..)] 48 pub witness_keys: Vec<String>, 49 } 50 51 #[derive(Serialize)] 52 struct VerifyCommitResult { 53 commit: String, 54 valid: bool, 55 #[serde(skip_serializing_if = "Option::is_none")] 56 ssh_valid: Option<bool>, 57 #[serde(skip_serializing_if = "Option::is_none")] 58 chain_valid: Option<bool>, 59 #[serde(skip_serializing_if = "Option::is_none")] 60 chain_report: Option<VerificationReport>, 61 #[serde(skip_serializing_if = "Option::is_none")] 62 witness_quorum: Option<WitnessQuorum>, 63 #[serde(skip_serializing_if = "Option::is_none")] 64 signer: Option<String>, 65 #[serde(skip_serializing_if = "Option::is_none")] 66 error: Option<String>, 67 #[serde(skip_serializing_if = "Vec::is_empty")] 68 warnings: Vec<String>, 69 } 70 71 impl VerifyCommitResult { 72 fn failure(commit: String, error: String) -> Self { 73 Self { 74 commit, 75 valid: false, 76 ssh_valid: None, 77 chain_valid: None, 78 chain_report: None, 79 witness_quorum: None, 80 signer: None, 81 error: Some(error), 82 warnings: Vec::new(), 83 } 84 } 85 } 86 87 /// Source of allowed signers for SSH verification. 88 enum SignersSource { 89 /// User-provided allowed_signers file. 90 File(PathBuf), 91 /// Identity bundle (creates temp signers file from bundle's public key). 92 Bundle { 93 temp_signers: NamedTempFile, 94 bundle: IdentityBundle, 95 }, 96 } 97 98 impl SignersSource { 99 fn signers_path(&self) -> &Path { 100 match self { 101 SignersSource::File(p) => p, 102 SignersSource::Bundle { temp_signers, .. } => temp_signers.path(), 103 } 104 } 105 106 fn bundle(&self) -> Option<&IdentityBundle> { 107 match self { 108 SignersSource::File(_) => None, 109 SignersSource::Bundle { bundle, .. } => Some(bundle), 110 } 111 } 112 } 113 114 /// Handle verify-commit command. 115 /// Exit codes: 0=valid, 1=invalid/unsigned, 2=error 116 pub async fn handle_verify_commit(cmd: VerifyCommitCommand) -> Result<()> { 117 if let Err(e) = check_ssh_keygen() { 118 return handle_error(&cmd, 2, &format!("OpenSSH required: {}", e)); 119 } 120 121 let source = match resolve_signers_source(&cmd) { 122 Ok(s) => s, 123 Err(e) => return handle_error(&cmd, 2, &e.to_string()), 124 }; 125 126 let results = match verify_commits(&cmd, &source).await { 127 Ok(r) => r, 128 Err(e) => return handle_error(&cmd, 2, &e.to_string()), 129 }; 130 131 output_results(&results) 132 } 133 134 /// Build a SignersSource from either --identity-bundle or --allowed-signers. 135 fn resolve_signers_source(cmd: &VerifyCommitCommand) -> Result<SignersSource> { 136 if let Some(ref bundle_path) = cmd.identity_bundle { 137 let bundle_content = fs::read_to_string(bundle_path) 138 .with_context(|| format!("Failed to read identity bundle: {:?}", bundle_path))?; 139 140 let bundle: IdentityBundle = serde_json::from_str(&bundle_content) 141 .with_context(|| format!("Failed to parse identity bundle: {:?}", bundle_path))?; 142 143 let public_key_bytes = 144 hex::decode(&bundle.public_key_hex).context("Invalid public key hex in bundle")?; 145 146 let ssh_key = format_ed25519_as_ssh(&public_key_bytes)?; 147 let temp_signers_content = format!("{} {}", bundle.identity_did, ssh_key); 148 149 let mut temp_signers = 150 NamedTempFile::new().context("Failed to create temporary allowed_signers file")?; 151 temp_signers 152 .write_all(temp_signers_content.as_bytes()) 153 .context("Failed to write temporary allowed_signers")?; 154 temp_signers.flush()?; 155 156 Ok(SignersSource::Bundle { 157 temp_signers, 158 bundle, 159 }) 160 } else { 161 if !cmd.allowed_signers.exists() { 162 return Err(anyhow!( 163 "Allowed signers file not found: {:?}\n\nCreate it with:\n mkdir -p .auths\n echo 'user@example.com ssh-ed25519 AAAA...' > .auths/allowed_signers", 164 cmd.allowed_signers 165 )); 166 } 167 Ok(SignersSource::File(cmd.allowed_signers.clone())) 168 } 169 } 170 171 /// Resolve the commit spec to a list of commit SHAs. 172 fn resolve_commits(commit_spec: &str) -> Result<Vec<String>> { 173 if commit_spec.contains("..") { 174 // Commit range — use git rev-list 175 let output = Command::new("git") 176 .args(["rev-list", commit_spec]) 177 .output() 178 .context("Failed to run git rev-list")?; 179 180 if !output.status.success() { 181 let stderr = String::from_utf8_lossy(&output.stderr); 182 return Err(anyhow!("Invalid commit range: {}", stderr.trim())); 183 } 184 185 let commits: Vec<String> = std::str::from_utf8(&output.stdout) 186 .context("Invalid UTF-8 in git output")? 187 .lines() 188 .map(|s| s.to_string()) 189 .collect(); 190 191 if commits.is_empty() { 192 return Err(anyhow!("No commits in specified range")); 193 } 194 Ok(commits) 195 } else { 196 // Single commit — resolve via rev-parse 197 let sha = resolve_commit_sha(commit_spec)?; 198 Ok(vec![sha]) 199 } 200 } 201 202 /// Verify all commits in the list. 203 async fn verify_commits( 204 cmd: &VerifyCommitCommand, 205 source: &SignersSource, 206 ) -> Result<Vec<VerifyCommitResult>> { 207 let commits = resolve_commits(&cmd.commit)?; 208 let mut results = Vec::with_capacity(commits.len()); 209 210 for sha in &commits { 211 let result = verify_one_commit(cmd, source, sha).await; 212 results.push(result); 213 } 214 215 Ok(results) 216 } 217 218 /// Verify a single commit: SSH signature + optional chain + optional witnesses. 219 async fn verify_one_commit( 220 cmd: &VerifyCommitCommand, 221 source: &SignersSource, 222 commit_sha: &str, 223 ) -> VerifyCommitResult { 224 // Resolve commit ref to SHA 225 let sha = match resolve_commit_sha(commit_sha) { 226 Ok(sha) => sha, 227 Err(e) => { 228 return VerifyCommitResult::failure( 229 commit_sha.to_string(), 230 format!("Failed to resolve commit: {}", e), 231 ); 232 } 233 }; 234 235 // Get commit signature info 236 let sig_info = match get_commit_signature(&sha) { 237 Ok(info) => info, 238 Err(e) => return VerifyCommitResult::failure(sha, e.to_string()), 239 }; 240 241 // 1. SSH signature check 242 let (ssh_valid, signer) = match sig_info { 243 SignatureInfo::None => { 244 return VerifyCommitResult::failure(sha, "No signature found".to_string()); 245 } 246 SignatureInfo::Gpg => { 247 return VerifyCommitResult::failure( 248 sha, 249 "GPG signatures not supported, use SSH signing".to_string(), 250 ); 251 } 252 SignatureInfo::Ssh { signature, payload } => { 253 match verify_ssh_signature(source.signers_path(), &signature, &payload) { 254 Ok(signer) => (true, Some(signer)), 255 Err(e) => { 256 return VerifyCommitResult { 257 commit: sha, 258 valid: false, 259 ssh_valid: Some(false), 260 chain_valid: None, 261 chain_report: None, 262 witness_quorum: None, 263 signer: None, 264 error: Some(e.to_string()), 265 warnings: Vec::new(), 266 }; 267 } 268 } 269 } 270 }; 271 272 let mut warnings = Vec::new(); 273 274 // 2. Attestation chain verification (only when bundle is present) 275 let (chain_valid, chain_report) = if let Some(bundle) = source.bundle() { 276 let (cv, cr, cw) = verify_bundle_chain(bundle).await; 277 warnings.extend(cw); 278 (cv, cr) 279 } else { 280 (None, None) 281 }; 282 283 // 3. Witness verification 284 let witness_quorum = match verify_witnesses(cmd, source.bundle()).await { 285 Ok(q) => q, 286 Err(e) => { 287 return VerifyCommitResult { 288 commit: sha, 289 valid: false, 290 ssh_valid: Some(ssh_valid), 291 chain_valid, 292 chain_report, 293 witness_quorum: None, 294 signer, 295 error: Some(format!("Witness verification error: {}", e)), 296 warnings, 297 }; 298 } 299 }; 300 301 // 4. Compute overall verdict 302 let mut valid = ssh_valid; 303 304 if let Some(cv) = chain_valid 305 && !cv 306 { 307 valid = false; 308 } 309 310 if let Some(ref q) = witness_quorum 311 && q.verified < q.required 312 { 313 valid = false; 314 } 315 316 VerifyCommitResult { 317 commit: sha, 318 valid, 319 ssh_valid: Some(ssh_valid), 320 chain_valid, 321 chain_report, 322 witness_quorum, 323 signer, 324 error: None, 325 warnings, 326 } 327 } 328 329 /// Verify the attestation chain from an identity bundle. 330 /// 331 /// Returns (chain_valid, chain_report, warnings). 332 async fn verify_bundle_chain( 333 bundle: &IdentityBundle, 334 ) -> (Option<bool>, Option<VerificationReport>, Vec<String>) { 335 if let Err(e) = bundle.check_freshness(Utc::now()) { 336 return ( 337 Some(false), 338 None, 339 vec![format!("Bundle freshness check failed: {}", e)], 340 ); 341 } 342 343 if bundle.attestation_chain.is_empty() { 344 return ( 345 None, 346 None, 347 vec!["No attestation chain in bundle; SSH-only verification".to_string()], 348 ); 349 } 350 351 let root_pk = match hex::decode(&bundle.public_key_hex) { 352 Ok(pk) => pk, 353 Err(e) => { 354 return ( 355 Some(false), 356 None, 357 vec![format!("Invalid public key hex in bundle: {}", e)], 358 ); 359 } 360 }; 361 362 match verify_chain(&bundle.attestation_chain, &root_pk).await { 363 Ok(report) => { 364 let mut warnings = Vec::new(); 365 366 // Scan for upcoming expiry (< 30 days) 367 for att in &bundle.attestation_chain { 368 if let Some(exp) = att.expires_at { 369 let remaining = exp - Utc::now(); 370 if remaining < Duration::zero() { 371 // Already expired — chain_valid will be false from the report 372 } else if remaining < Duration::days(30) { 373 warnings.push(format!( 374 "Attestation for {} expires in {} days", 375 att.subject, 376 remaining.num_days() 377 )); 378 } 379 } 380 } 381 382 let is_valid = report.is_valid(); 383 (Some(is_valid), Some(report), warnings) 384 } 385 Err(e) => ( 386 Some(false), 387 None, 388 vec![format!("Chain verification error: {}", e)], 389 ), 390 } 391 } 392 393 /// Verify witness receipts if --witness-receipts was provided. 394 async fn verify_witnesses( 395 cmd: &VerifyCommitCommand, 396 bundle: Option<&IdentityBundle>, 397 ) -> Result<Option<WitnessQuorum>> { 398 let receipts_path = match cmd.witness_receipts { 399 Some(ref p) => p, 400 None => return Ok(None), 401 }; 402 403 let receipts_bytes = fs::read(receipts_path) 404 .with_context(|| format!("Failed to read witness receipts: {:?}", receipts_path))?; 405 406 let receipts: Vec<WitnessReceipt> = 407 serde_json::from_slice(&receipts_bytes).context("Failed to parse witness receipts JSON")?; 408 409 let witness_keys = parse_witness_keys(&cmd.witness_keys)?; 410 411 let config = WitnessVerifyConfig { 412 receipts: &receipts, 413 witness_keys: &witness_keys, 414 threshold: cmd.witness_threshold, 415 }; 416 417 // If bundle has attestation chain, do combined chain + witness verification 418 if let Some(bundle) = bundle 419 && !bundle.attestation_chain.is_empty() 420 { 421 let root_pk = 422 hex::decode(&bundle.public_key_hex).context("Invalid public key hex in bundle")?; 423 424 let report = verify_chain_with_witnesses(&bundle.attestation_chain, &root_pk, &config) 425 .await 426 .context("Witness chain verification failed")?; 427 428 return Ok(report.witness_quorum); 429 } 430 431 // Standalone witness receipt verification (no chain) 432 let provider = auths_crypto::RingCryptoProvider; 433 let quorum = auths_verifier::witness::verify_witness_receipts(&config, &provider).await; 434 Ok(Some(quorum)) 435 } 436 437 /// Unified output for all results, with JSON/text formatting and exit codes. 438 fn output_results(results: &[VerifyCommitResult]) -> Result<()> { 439 let all_valid = results.iter().all(|r| r.valid); 440 441 if is_json_mode() { 442 if results.len() == 1 { 443 println!("{}", serde_json::to_string(&results[0]).unwrap()); 444 } else { 445 println!("{}", serde_json::to_string(&results).unwrap()); 446 } 447 } else if results.len() == 1 { 448 let r = &results[0]; 449 if r.valid { 450 if let Some(ref signer) = r.signer { 451 print!("Commit {} verified: signed by {}", r.commit, signer); 452 } else { 453 print!("Commit {} verified", r.commit); 454 } 455 print_chain_witness_summary(r); 456 println!(); 457 } else { 458 eprint!("Verification failed for {}", r.commit); 459 if let Some(ref error) = r.error { 460 eprint!(": {}", error); 461 } 462 print_chain_witness_summary_stderr(r); 463 eprintln!(); 464 } 465 for w in &r.warnings { 466 eprintln!("Warning: {}", w); 467 } 468 } else { 469 for r in results { 470 print!( 471 "{}: {}", 472 &r.commit[..8.min(r.commit.len())], 473 format_result_text(r) 474 ); 475 println!(); 476 } 477 } 478 479 if all_valid { 480 Ok(()) 481 } else { 482 std::process::exit(1); 483 } 484 } 485 486 /// Format a single result as a human-readable line (for range output). 487 fn format_result_text(result: &VerifyCommitResult) -> String { 488 let status = if result.valid { "valid" } else { "INVALID" }; 489 490 let mut parts = vec![status.to_string()]; 491 492 if let Some(ref signer) = result.signer { 493 parts.push(format!("signer: {}", signer)); 494 } 495 496 if let Some(cv) = result.chain_valid { 497 let chain_desc = if cv { 498 "chain: valid".to_string() 499 } else if let Some(ref report) = result.chain_report { 500 format!("chain: {}", format_chain_status(&report.status)) 501 } else { 502 "chain: invalid".to_string() 503 }; 504 parts.push(chain_desc); 505 } 506 507 if let Some(ref q) = result.witness_quorum { 508 parts.push(format!("witnesses: {}/{}", q.verified, q.required)); 509 } 510 511 if let Some(ref error) = result.error 512 && result.signer.is_none() 513 && result.chain_valid.is_none() 514 && result.witness_quorum.is_none() 515 { 516 parts.push(error.clone()); 517 } 518 519 if parts.len() == 1 { 520 parts[0].clone() 521 } else { 522 format!("{} ({})", parts[0], parts[1..].join(", ")) 523 } 524 } 525 526 /// Format a VerificationStatus for display. 527 fn format_chain_status(status: &auths_verifier::VerificationStatus) -> String { 528 match status { 529 auths_verifier::VerificationStatus::Valid => "valid".to_string(), 530 auths_verifier::VerificationStatus::Expired { at } => { 531 format!("expired at {}", at.to_rfc3339()) 532 } 533 auths_verifier::VerificationStatus::Revoked { at } => match at { 534 Some(t) => format!("revoked at {}", t.to_rfc3339()), 535 None => "revoked".to_string(), 536 }, 537 auths_verifier::VerificationStatus::InvalidSignature { step } => { 538 format!("invalid signature at step {}", step) 539 } 540 auths_verifier::VerificationStatus::BrokenChain { missing_link } => { 541 format!("broken chain: {}", missing_link) 542 } 543 auths_verifier::VerificationStatus::InsufficientWitnesses { required, verified } => { 544 format!("witnesses: {}/{} quorum not met", verified, required) 545 } 546 } 547 } 548 549 /// Print chain/witness summary to stdout (for valid single-commit output). 550 fn print_chain_witness_summary(r: &VerifyCommitResult) { 551 if let Some(cv) = r.chain_valid { 552 if cv { 553 print!(" (chain: valid"); 554 } else { 555 print!(" (chain: invalid"); 556 } 557 if let Some(ref q) = r.witness_quorum { 558 print!(", witnesses: {}/{}", q.verified, q.required); 559 } 560 print!(")"); 561 } else if let Some(ref q) = r.witness_quorum { 562 print!(" (witnesses: {}/{})", q.verified, q.required); 563 } 564 } 565 566 /// Print chain/witness summary to stderr (for invalid single-commit output). 567 fn print_chain_witness_summary_stderr(r: &VerifyCommitResult) { 568 if let Some(cv) = r.chain_valid 569 && !cv 570 && let Some(ref report) = r.chain_report 571 { 572 eprint!(" (chain: {})", format_chain_status(&report.status)); 573 } 574 if let Some(ref q) = r.witness_quorum 575 && q.verified < q.required 576 { 577 eprint!(" (witnesses: {}/{} quorum not met)", q.verified, q.required); 578 } 579 } 580 581 // ============================================================================ 582 // Internal helpers (unchanged SSH / Git plumbing) 583 // ============================================================================ 584 585 /// Format an Ed25519 public key as an SSH public key string. 586 fn format_ed25519_as_ssh(public_key: &[u8]) -> Result<String> { 587 use base64::Engine; 588 589 if public_key.len() != 32 { 590 return Err(anyhow!( 591 "Invalid Ed25519 public key length: expected 32, got {}", 592 public_key.len() 593 )); 594 } 595 596 let key_type = b"ssh-ed25519"; 597 let mut blob = Vec::new(); 598 blob.extend_from_slice(&(key_type.len() as u32).to_be_bytes()); 599 blob.extend_from_slice(key_type); 600 blob.extend_from_slice(&(public_key.len() as u32).to_be_bytes()); 601 blob.extend_from_slice(public_key); 602 603 let encoded = base64::engine::general_purpose::STANDARD.encode(&blob); 604 Ok(format!("ssh-ed25519 {}", encoded)) 605 } 606 607 enum SignatureInfo { 608 None, 609 Gpg, 610 Ssh { signature: String, payload: String }, 611 } 612 613 fn resolve_commit_sha(commit_ref: &str) -> Result<String> { 614 let output = Command::new("git") 615 .args(["rev-parse", commit_ref]) 616 .output() 617 .context("Failed to run git rev-parse")?; 618 619 if !output.status.success() { 620 let stderr = String::from_utf8_lossy(&output.stderr); 621 return Err(anyhow!("Invalid commit reference: {}", stderr.trim())); 622 } 623 624 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) 625 } 626 627 fn get_commit_signature(sha: &str) -> Result<SignatureInfo> { 628 let output = Command::new("git") 629 .args(["cat-file", "commit", sha]) 630 .output() 631 .context("Failed to run git cat-file")?; 632 633 if !output.status.success() { 634 let stderr = String::from_utf8_lossy(&output.stderr); 635 return Err(anyhow!("Failed to read commit: {}", stderr.trim())); 636 } 637 638 let commit_content = String::from_utf8_lossy(&output.stdout); 639 640 if commit_content.contains("-----BEGIN PGP SIGNATURE-----") { 641 return Ok(SignatureInfo::Gpg); 642 } 643 644 if commit_content.contains("-----BEGIN SSH SIGNATURE-----") { 645 let (signature, payload) = extract_ssh_signature(&commit_content)?; 646 return Ok(SignatureInfo::Ssh { signature, payload }); 647 } 648 649 let show_output = Command::new("git") 650 .args(["log", "-1", "--format=%G?", sha]) 651 .output() 652 .context("Failed to run git log")?; 653 654 if show_output.status.success() { 655 let sig_status = String::from_utf8_lossy(&show_output.stdout) 656 .trim() 657 .to_string(); 658 match sig_status.as_str() { 659 "N" => return Ok(SignatureInfo::None), 660 "G" | "U" | "X" | "Y" | "R" | "E" | "B" => { 661 return Ok(SignatureInfo::Gpg); 662 } 663 _ => {} 664 } 665 } 666 667 Ok(SignatureInfo::None) 668 } 669 670 fn extract_ssh_signature(commit_content: &str) -> Result<(String, String)> { 671 // Process the commit object content preserving exact byte content for the payload. 672 // git signs/verifies the raw commit bytes with the gpgsig header block removed; 673 // any deviation (missing trailing \n, wrong line endings) causes "incorrect signature". 674 let mut sig_lines: Vec<&str> = Vec::new(); 675 let mut payload = String::with_capacity(commit_content.len()); 676 let mut in_sig = false; 677 678 let mut remaining = commit_content; 679 while !remaining.is_empty() { 680 // Consume one line, keeping its \n terminator intact. 681 let (line_with_nl, rest) = match remaining.find('\n') { 682 Some(i) => (&remaining[..=i], &remaining[i + 1..]), 683 None => (remaining, ""), 684 }; 685 remaining = rest; 686 687 // Line content without the trailing \n, for prefix checks. 688 let line = line_with_nl.strip_suffix('\n').unwrap_or(line_with_nl); 689 690 if line.starts_with("gpgsig ") { 691 in_sig = true; 692 sig_lines.push(line.strip_prefix("gpgsig ").unwrap_or(line)); 693 // gpgsig lines are excluded from the payload. 694 } else if in_sig && line.starts_with(' ') { 695 // Continuation line of the gpgsig block. 696 sig_lines.push(line.strip_prefix(' ').unwrap_or(line)); 697 } else { 698 in_sig = false; 699 // All non-signature lines go into the payload verbatim, \n included. 700 payload.push_str(line_with_nl); 701 } 702 } 703 704 if sig_lines.is_empty() { 705 return Err(anyhow!("No SSH signature found in commit")); 706 } 707 708 // PEM lines are joined with \n (no trailing \n on the last line). 709 let signature = sig_lines.join("\n"); 710 711 Ok((signature, payload)) 712 } 713 714 fn verify_ssh_signature(signers_path: &Path, signature: &str, payload: &str) -> Result<String> { 715 let mut sig_file = NamedTempFile::new().context("Failed to create temp signature file")?; 716 sig_file 717 .write_all(signature.as_bytes()) 718 .context("Failed to write signature")?; 719 sig_file.flush()?; 720 721 // Step 1: find-principals — resolves the signer identity from the allowed_signers file. 722 // This must come before verify because `-I "*"` is not a valid wildcard for ssh-keygen 723 // on all OpenSSH versions; using the actual identity is required for verify to succeed. 724 let find_output = Command::new("ssh-keygen") 725 .args([ 726 "-Y", 727 "find-principals", 728 "-f", 729 signers_path.to_str().unwrap(), 730 "-s", 731 sig_file.path().to_str().unwrap(), 732 ]) 733 .output() 734 .context("Failed to run ssh-keygen find-principals")?; 735 736 if !find_output.status.success() { 737 return Err(anyhow!("Signature from non-allowed signer")); 738 } 739 let identity = String::from_utf8_lossy(&find_output.stdout) 740 .trim() 741 .to_string(); 742 if identity.is_empty() { 743 return Err(anyhow!("Signature from non-allowed signer")); 744 } 745 746 // Step 2: cryptographically verify with the resolved identity. 747 // Write payload to a temp file and pass as stdin to avoid deadlock on piped stdin. 748 let mut payload_file = NamedTempFile::new().context("Failed to create temp payload file")?; 749 payload_file 750 .write_all(payload.as_bytes()) 751 .context("Failed to write payload")?; 752 payload_file.flush()?; 753 754 let stdin_file = 755 std::fs::File::open(payload_file.path()).context("Failed to open payload file as stdin")?; 756 757 let output = Command::new("ssh-keygen") 758 .args([ 759 "-Y", 760 "verify", 761 "-f", 762 signers_path.to_str().unwrap(), 763 "-I", 764 &identity, 765 "-n", 766 "git", 767 "-s", 768 sig_file.path().to_str().unwrap(), 769 ]) 770 .stdin(stdin_file) 771 .stdout(Stdio::piped()) 772 .stderr(Stdio::piped()) 773 .output() 774 .context("Failed to run ssh-keygen")?; 775 776 if output.status.success() { 777 return Ok(identity); 778 } 779 780 // ssh-keygen writes errors to stdout on some platforms; check both. 781 let stdout = String::from_utf8_lossy(&output.stdout); 782 let stderr = String::from_utf8_lossy(&output.stderr); 783 let msg = if !stdout.trim().is_empty() { 784 stdout.trim().to_string() 785 } else { 786 stderr.trim().to_string() 787 }; 788 789 if msg.contains("no principal matched") || msg.contains("NONE_ACCEPTED") { 790 return Err(anyhow!("Signature from non-allowed signer")); 791 } 792 793 Err(anyhow!("Signature verification failed: {}", msg)) 794 } 795 796 fn check_ssh_keygen() -> Result<()> { 797 let output = Command::new("ssh-keygen") 798 .arg("-?") 799 .stderr(Stdio::piped()) 800 .output() 801 .context("ssh-keygen not found in PATH")?; 802 803 if output.stderr.is_empty() && output.stdout.is_empty() { 804 return Err(anyhow!("ssh-keygen not functioning")); 805 } 806 807 Ok(()) 808 } 809 810 fn handle_error(cmd: &VerifyCommitCommand, exit_code: i32, message: &str) -> Result<()> { 811 if is_json_mode() { 812 let result = VerifyCommitResult::failure(cmd.commit.clone(), message.to_string()); 813 println!("{}", serde_json::to_string(&result).unwrap()); 814 } else { 815 eprintln!("Error: {}", message); 816 } 817 std::process::exit(exit_code); 818 } 819 820 impl crate::commands::executable::ExecutableCommand for VerifyCommitCommand { 821 fn execute(&self, _ctx: &crate::config::CliConfig) -> anyhow::Result<()> { 822 let rt = tokio::runtime::Runtime::new()?; 823 rt.block_on(handle_verify_commit(self.clone())) 824 } 825 } 826 827 #[cfg(test)] 828 mod tests { 829 use super::*; 830 831 #[test] 832 fn verify_commit_result_failure_helper() { 833 let r = VerifyCommitResult::failure("abc123".into(), "bad sig".into()); 834 assert!(!r.valid); 835 assert_eq!(r.commit, "abc123"); 836 assert_eq!(r.error.as_deref(), Some("bad sig")); 837 assert!(r.ssh_valid.is_none()); 838 assert!(r.chain_valid.is_none()); 839 assert!(r.witness_quorum.is_none()); 840 } 841 842 #[test] 843 fn verify_commit_result_json_includes_new_fields() { 844 let r = VerifyCommitResult { 845 commit: "abc123".into(), 846 valid: true, 847 ssh_valid: Some(true), 848 chain_valid: Some(true), 849 chain_report: None, 850 witness_quorum: Some(WitnessQuorum { 851 required: 2, 852 verified: 2, 853 receipts: vec![], 854 }), 855 signer: Some("did:keri:test".into()), 856 error: None, 857 warnings: vec!["expiring soon".into()], 858 }; 859 let json = serde_json::to_string(&r).unwrap(); 860 assert!(json.contains("\"ssh_valid\":true")); 861 assert!(json.contains("\"chain_valid\":true")); 862 assert!(json.contains("\"witness_quorum\"")); 863 assert!(json.contains("\"warnings\":[\"expiring soon\"]")); 864 } 865 866 #[test] 867 fn verify_commit_result_json_omits_none_fields() { 868 let r = VerifyCommitResult::failure("abc".into(), "err".into()); 869 let json = serde_json::to_string(&r).unwrap(); 870 assert!(!json.contains("ssh_valid")); 871 assert!(!json.contains("chain_valid")); 872 assert!(!json.contains("chain_report")); 873 assert!(!json.contains("witness_quorum")); 874 assert!(!json.contains("warnings")); 875 } 876 877 #[test] 878 fn format_result_text_valid_ssh_only() { 879 let r = VerifyCommitResult { 880 commit: "abc12345".into(), 881 valid: true, 882 ssh_valid: Some(true), 883 chain_valid: None, 884 chain_report: None, 885 witness_quorum: None, 886 signer: Some("did:keri:test".into()), 887 error: None, 888 warnings: vec![], 889 }; 890 let text = format_result_text(&r); 891 assert!(text.contains("valid")); 892 assert!(text.contains("signer: did:keri:test")); 893 } 894 895 #[test] 896 fn format_result_text_valid_with_chain_and_witnesses() { 897 let r = VerifyCommitResult { 898 commit: "abc12345".into(), 899 valid: true, 900 ssh_valid: Some(true), 901 chain_valid: Some(true), 902 chain_report: Some(VerificationReport::valid(vec![])), 903 witness_quorum: Some(WitnessQuorum { 904 required: 2, 905 verified: 2, 906 receipts: vec![], 907 }), 908 signer: Some("did:keri:test".into()), 909 error: None, 910 warnings: vec![], 911 }; 912 let text = format_result_text(&r); 913 assert!(text.contains("chain: valid")); 914 assert!(text.contains("witnesses: 2/2")); 915 } 916 917 #[test] 918 fn format_result_text_invalid_with_error() { 919 let r = VerifyCommitResult::failure("abc12345".into(), "No signature found".into()); 920 let text = format_result_text(&r); 921 assert!(text.contains("INVALID")); 922 assert!(text.contains("No signature found")); 923 } 924 925 #[tokio::test] 926 async fn verify_bundle_chain_empty_chain() { 927 let bundle = IdentityBundle { 928 identity_did: "did:keri:test".into(), 929 public_key_hex: "aa".repeat(32), 930 attestation_chain: vec![], 931 bundle_timestamp: Utc::now(), 932 max_valid_for_secs: 86400, 933 }; 934 let (cv, cr, warnings) = verify_bundle_chain(&bundle).await; 935 assert!(cv.is_none()); 936 assert!(cr.is_none()); 937 assert!(!warnings.is_empty()); 938 assert!(warnings[0].contains("No attestation chain")); 939 } 940 941 #[tokio::test] 942 async fn verify_bundle_chain_invalid_hex() { 943 let bundle = IdentityBundle { 944 identity_did: "did:keri:test".into(), 945 public_key_hex: "not_hex".into(), 946 attestation_chain: vec![auths_verifier::core::Attestation { 947 version: 1, 948 rid: "test".into(), 949 issuer: "did:keri:test".into(), 950 subject: auths_verifier::DeviceDID::new("did:key:test"), 951 device_public_key: auths_verifier::Ed25519PublicKey::from_bytes([0u8; 32]), 952 identity_signature: auths_verifier::core::Ed25519Signature::empty(), 953 device_signature: auths_verifier::core::Ed25519Signature::empty(), 954 revoked_at: None, 955 expires_at: None, 956 timestamp: None, 957 note: None, 958 payload: None, 959 role: None, 960 capabilities: vec![], 961 delegated_by: None, 962 signer_type: None, 963 }], 964 bundle_timestamp: Utc::now(), 965 max_valid_for_secs: 86400, 966 }; 967 let (cv, _cr, warnings) = verify_bundle_chain(&bundle).await; 968 assert_eq!(cv, Some(false)); 969 assert!(warnings[0].contains("Invalid public key hex")); 970 } 971 972 // ------------------------------------------------------------------------- 973 // extract_ssh_signature regression tests 974 // ------------------------------------------------------------------------- 975 976 /// Minimal realistic git commit object containing an SSH signature. 977 /// 978 /// Note: written with `concat!` rather than `\` line continuation because 979 /// Rust's `\` continuation eats all leading whitespace on the next source 980 /// line, which would silently strip the ` ` (space) prefix that git uses 981 /// for gpgsig continuation lines. 982 const COMMIT_WITH_SIG: &str = concat!( 983 "tree 16b8274d517c97653341495042b037c0d74ccfc3\n", 984 "parent 8113dc5221881e744ef8b80597ae4da696c10e67\n", 985 "author Test User <test@example.com> 1700000000 +0000\n", 986 "committer Test User <test@example.com> 1700000000 +0000\n", 987 "gpgsig -----BEGIN SSH SIGNATURE-----\n", 988 " U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgVQuMGFzwtirJulb4hTBb39CGs2\n", 989 " y7l5SUeOmXTFtZmF0AAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\n", 990 " AAAAQJKNt8cKSbaYtOwUMSKU2dVXJMbbJBy5xEdq6TsLh+P47QI+pNDhilsn4XeDjo9B3+\n", 991 " wTsG+4p0du0SnsFkUGTgU=\n", 992 " -----END SSH SIGNATURE-----\n", 993 "\n", 994 "commit message\n", 995 ); 996 997 #[test] 998 fn test_extract_ssh_signature_removes_gpgsig_from_payload() { 999 let (_, payload) = extract_ssh_signature(COMMIT_WITH_SIG).unwrap(); 1000 assert!( 1001 !payload.contains("gpgsig"), 1002 "payload must not contain the gpgsig header" 1003 ); 1004 assert!( 1005 !payload.contains("BEGIN SSH SIGNATURE"), 1006 "payload must not contain the signature PEM" 1007 ); 1008 } 1009 1010 #[test] 1011 fn test_extract_ssh_signature_payload_ends_with_newline() { 1012 // Regression: the old lines()+join("\n") approach dropped the trailing \n. 1013 // ssh-keygen verifies against the raw commit bytes, which end with \n. 1014 // A missing trailing newline causes "incorrect signature". 1015 let (_, payload) = extract_ssh_signature(COMMIT_WITH_SIG).unwrap(); 1016 assert!( 1017 payload.ends_with('\n'), 1018 "payload must end with \\n to match what git signed (got: {:?})", 1019 &payload[payload.len().saturating_sub(10)..] 1020 ); 1021 } 1022 1023 #[test] 1024 fn test_extract_ssh_signature_payload_contains_non_sig_headers() { 1025 let (_, payload) = extract_ssh_signature(COMMIT_WITH_SIG).unwrap(); 1026 assert!(payload.contains("tree ")); 1027 assert!(payload.contains("author ")); 1028 assert!(payload.contains("committer ")); 1029 assert!(payload.contains("commit message\n")); 1030 } 1031 1032 #[test] 1033 fn test_extract_ssh_signature_pem_stripped_of_continuation_spaces() { 1034 let (sig, _) = extract_ssh_signature(COMMIT_WITH_SIG).unwrap(); 1035 // PEM lines must not start with a space (continuation prefix removed) 1036 for line in sig.lines() { 1037 assert!( 1038 !line.starts_with(' '), 1039 "signature line must not start with a space: {:?}", 1040 line 1041 ); 1042 } 1043 assert!(sig.starts_with("-----BEGIN SSH SIGNATURE-----")); 1044 assert!(sig.contains("-----END SSH SIGNATURE-----")); 1045 } 1046 1047 #[test] 1048 fn test_extract_ssh_signature_no_sig_returns_error() { 1049 let no_sig = "tree abc\nauthor foo <foo@bar.com> 1234 +0000\n\nmessage\n"; 1050 assert!(extract_ssh_signature(no_sig).is_err()); 1051 } 1052 }