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 #[arg(default_value = "HEAD")]
24 pub commit: String,
25
26 #[arg(long, default_value = ".auths/allowed_signers")]
28 pub allowed_signers: PathBuf,
29
30 #[arg(long, value_parser, help = "Path to identity bundle JSON (for CI)")]
36 pub identity_bundle: Option<PathBuf>,
37
38 #[arg(long)]
40 pub witness_receipts: Option<PathBuf>,
41
42 #[arg(long, default_value = "1")]
44 pub witness_threshold: usize,
45
46 #[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
87enum SignersSource {
89 File(PathBuf),
91 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
114pub 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
134fn 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
171fn resolve_commits(commit_spec: &str) -> Result<Vec<String>> {
173 if commit_spec.contains("..") {
174 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 let sha = resolve_commit_sha(commit_spec)?;
198 Ok(vec![sha])
199 }
200}
201
202async 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
218async fn verify_one_commit(
220 cmd: &VerifyCommitCommand,
221 source: &SignersSource,
222 commit_sha: &str,
223) -> VerifyCommitResult {
224 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 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 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 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 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 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
329async 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 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 } 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
393async 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 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 let provider = auths_crypto::RingCryptoProvider;
433 let quorum = auths_verifier::witness::verify_witness_receipts(&config, &provider).await;
434 Ok(Some(quorum))
435}
436
437fn 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
486fn 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
526fn 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
549fn 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
566fn 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
581fn 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 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 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 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 } else if in_sig && line.starts_with(' ') {
695 sig_lines.push(line.strip_prefix(' ').unwrap_or(line));
697 } else {
698 in_sig = false;
699 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 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 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 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 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 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 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 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}