/ crates / auths-cli / src / commands / verify_commit.rs
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  }