Skip to main content

auths_cli/commands/
verify_commit.rs

1use crate::ux::format::is_json_mode;
2use anyhow::{Context, Result, anyhow};
3use auths_verifier::witness::{WitnessQuorum, WitnessReceipt, WitnessVerifyConfig};
4use auths_verifier::{
5    IdentityBundle, VerificationReport, verify_chain, verify_chain_with_witnesses,
6};
7use base64;
8use chrono::{Duration, Utc};
9use clap::Parser;
10use serde::Serialize;
11use std::fs;
12use std::io::Write;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use tempfile::NamedTempFile;
16
17use super::verify_helpers::parse_witness_keys;
18
19#[derive(Parser, Debug, Clone)]
20#[command(about = "Verify Git commit signatures against Auths identity.")]
21pub 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)]
52struct 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
71impl 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.
88enum 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
98impl 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
116pub 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.
135fn 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.
172fn 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.
203async 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.
219async 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).
332async 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.
394async 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.
438fn 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).
487fn 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.
527fn 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).
550fn 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).
567fn 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.
586fn 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
607enum SignatureInfo {
608    None,
609    Gpg,
610    Ssh { signature: String, payload: String },
611}
612
613fn 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
627fn 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
670fn 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
714fn 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
796fn 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
810fn 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
820impl 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)]
828mod 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}