1pub mod pgp_exempt;
40
41use std::collections::{BTreeMap, BTreeSet};
42use std::path::{Path, PathBuf};
43use std::process::Command;
44use std::sync::Arc;
45
46use anyhow::{Context, Result, bail};
47use serde::Serialize;
48use ssh_key::{SshSig, public::KeyData};
49use trql_client::{HttpsTransport, HttpsTransportConfig, TrqlClient, TrqlError, TrqpQuery};
50use vgi_core::{
51 GIT_SSHSIG_NAMESPACE, committer_did, committer_identity, ed25519_keys_from_doc,
52 normalize_sshsig_armor, split_signed_commit,
53};
54use vta_sdk::display_name::{DisplayName, NameBook, NameSource};
55
56use crate::pgp_exempt::ExemptKeyring;
57
58#[derive(Debug, Clone)]
60pub struct VerifyTrustArgs {
61 pub repo_dir: PathBuf,
63 pub range: String,
65 pub max_signers: usize,
74 pub registry_url: String,
76 pub registry_did: String,
78 pub authority_did: String,
80 pub action: String,
82 pub resource: String,
89 pub fallback_resource: Option<String>,
95 pub exempt_keyring: Option<PathBuf>,
99 pub resolve_agent_names: bool,
108 pub json: bool,
110}
111
112#[derive(Debug, Clone, PartialEq, Serialize)]
114#[serde(rename_all = "camelCase", tag = "status", content = "detail")]
115pub enum CommitStatus {
116 Unsigned,
118 Malformed(String),
120 NoSignerDid { committer: String },
123 UnresolvedSigner { did: String, error: String },
126 UnknownKey { did: String, fingerprint: String },
129 BadSignature { signer_did: String },
131 Unauthorized { signer_did: String },
133 RegistryUnavailable { signer_did: String, error: String },
136 PgpRejected { detail: String },
139 Exempt { fingerprint: String },
142 Trusted {
146 signer_did: String,
147 resource: String,
148 },
149}
150
151impl CommitStatus {
152 pub fn is_trusted(&self) -> bool {
154 matches!(self, Self::Trusted { .. })
155 }
156
157 pub fn passes(&self) -> bool {
159 matches!(self, Self::Trusted { .. } | Self::Exempt { .. })
160 }
161}
162
163#[derive(Debug, Clone, Serialize)]
165#[serde(rename_all = "camelCase")]
166pub struct CommitVerdict {
167 pub sha: String,
168 #[serde(flatten)]
169 pub status: CommitStatus,
170}
171
172#[derive(Debug, Serialize)]
174#[serde(rename_all = "camelCase")]
175pub struct TrustReport {
176 pub ok: bool,
177 pub commits: Vec<CommitVerdict>,
178 pub unresolved_signers: BTreeMap<String, String>,
182 pub signer_names: BTreeMap<String, DisplayName>,
186}
187
188#[derive(Debug, Default)]
194pub struct ResolvedSigners {
195 pub keys: BTreeMap<String, Vec<[u8; 32]>>,
199 pub unresolved: BTreeMap<String, String>,
201 pub names: NameBook,
203}
204
205impl ResolvedSigners {
206 #[must_use]
209 pub fn from_keys<I, D>(keys: I) -> Self
210 where
211 I: IntoIterator<Item = (D, Vec<[u8; 32]>)>,
212 D: Into<String>,
213 {
214 Self {
215 keys: keys.into_iter().map(|(did, k)| (did.into(), k)).collect(),
216 ..Self::default()
217 }
218 }
219
220 fn published(&self, did: &str) -> Option<&[[u8; 32]]> {
222 self.keys.get(did).map(Vec::as_slice)
223 }
224}
225
226pub async fn handle_verify_trust(args: VerifyTrustArgs) -> Result<i32> {
229 let exempt = load_exempt_keyring(&args)?;
230 let commits = read_range(&args.repo_dir, &args.range)?;
231 let claimed = claimed_signer_dids(&commits, args.max_signers)?;
232 let signers = resolve_signer_keys(&claimed, args.resolve_agent_names).await?;
233 let report = verify_prepared(&args, &commits, &signers, exempt.as_ref()).await?;
234 print_report(&args, &report)?;
235 Ok(if report.ok { 0 } else { 1 })
236}
237
238#[derive(Debug, Clone)]
241pub struct RangeCommit {
242 pub sha: String,
243 pub raw: Vec<u8>,
244}
245
246pub fn read_range(repo_dir: &Path, range: &str) -> Result<Vec<RangeCommit>> {
248 list_commits(repo_dir, range)?
249 .into_iter()
250 .map(|sha| {
251 let raw = read_commit_raw(repo_dir, &sha)?;
252 Ok(RangeCommit { sha, raw })
253 })
254 .collect()
255}
256
257pub fn claimed_signer_dids(commits: &[RangeCommit], max_signers: usize) -> Result<Vec<String>> {
264 let dids: BTreeSet<String> = commits
265 .iter()
266 .filter_map(|commit| committer_did(&commit.raw))
267 .collect();
268 if dids.len() > max_signers {
269 bail!(
270 "range claims {} distinct signer DIDs, over the limit of {max_signers}; \
271 each costs a resolution to a host the commit's author chose. Raise \
272 --max-signers only if this range is legitimately that wide.",
273 dids.len()
274 );
275 }
276 Ok(dids.into_iter().collect())
277}
278
279pub async fn verify_prepared(
282 args: &VerifyTrustArgs,
283 commits: &[RangeCommit],
284 signers: &ResolvedSigners,
285 exempt: Option<&ExemptKeyring>,
286) -> Result<TrustReport> {
287 let mut checked = Vec::with_capacity(commits.len());
289 let mut signer_dids = BTreeSet::new();
290 for commit in commits {
291 let signature = check_commit_signature(&commit.raw, signers, exempt);
292 if let SignatureCheck::Valid { signer_did } = &signature {
293 signer_dids.insert(signer_did.clone());
294 }
295 checked.push((commit.sha.clone(), signature));
296 }
297
298 let decisions = query_registry(args, &signer_dids).await?;
300
301 let commits: Vec<CommitVerdict> = checked
302 .into_iter()
303 .map(|(sha, signature)| CommitVerdict {
304 sha,
305 status: status_of(signature, &decisions),
306 })
307 .collect();
308
309 let signer_names = signer_dids
312 .iter()
313 .filter_map(|did| {
314 signers
315 .names
316 .get(did)
317 .map(|name| (did.clone(), name.clone()))
318 })
319 .collect();
320
321 let ok = commits.iter().all(|c| c.status.passes());
323 Ok(TrustReport {
324 ok,
325 commits,
326 unresolved_signers: signers.unresolved.clone(),
327 signer_names,
328 })
329}
330
331#[derive(Debug, Clone, PartialEq)]
335pub enum SignatureCheck {
336 Unsigned,
337 Malformed(String),
338 NoSignerDid { committer: String },
339 UnresolvedSigner { did: String, error: String },
340 UnknownKey { did: String, fingerprint: String },
341 BadSignature { signer_did: String },
342 PgpRejected { detail: String },
343 Exempt { fingerprint: String },
344 Valid { signer_did: String },
345}
346
347pub fn check_commit_signature(
355 raw: &[u8],
356 signers: &ResolvedSigners,
357 exempt: Option<&ExemptKeyring>,
358) -> SignatureCheck {
359 let (payload, pem) = match split_signed_commit(raw) {
360 Ok(Some(parts)) => parts,
361 Ok(None) => return SignatureCheck::Unsigned,
362 Err(e) => return SignatureCheck::Malformed(e.to_string()),
363 };
364 if pem.starts_with("-----BEGIN PGP SIGNATURE-----") {
367 let Some(keyring) = exempt else {
368 return SignatureCheck::PgpRejected {
369 detail: "PGP-signed commit, but no exempt keyring is configured".to_string(),
370 };
371 };
372 return match keyring.verify(&pem, &payload) {
373 Ok(fingerprint) => SignatureCheck::Exempt { fingerprint },
374 Err(detail) => SignatureCheck::PgpRejected { detail },
375 };
376 }
377 let sig = match SshSig::from_pem(normalize_sshsig_armor(&pem).as_bytes()) {
378 Ok(sig) => sig,
379 Err(e) => return SignatureCheck::Malformed(format!("sshsig did not parse: {e}")),
380 };
381 let KeyData::Ed25519(embedded) = sig.public_key() else {
382 return SignatureCheck::Malformed(format!(
383 "unsupported signature algorithm: {}",
384 sig.algorithm()
385 ));
386 };
387 let key_bytes: [u8; 32] = embedded.0;
388
389 let Some(claimed) = committer_did(&payload) else {
392 return SignatureCheck::NoSignerDid {
393 committer: committer_identity(&payload).unwrap_or_else(|| "(absent)".to_string()),
394 };
395 };
396 let Some(published) = signers.published(&claimed) else {
397 let error = signers
398 .unresolved
399 .get(&claimed)
400 .cloned()
401 .unwrap_or_else(|| "not resolved".to_string());
402 return SignatureCheck::UnresolvedSigner {
403 did: claimed,
404 error,
405 };
406 };
407 if !published.contains(&key_bytes) {
408 return SignatureCheck::UnknownKey {
409 did: claimed,
410 fingerprint: hex::encode(key_bytes),
411 };
412 }
413 let public_key = ssh_key::PublicKey::from(sig.public_key().clone());
414 match public_key.verify(GIT_SSHSIG_NAMESPACE, &payload, &sig) {
415 Ok(()) => SignatureCheck::Valid {
416 signer_did: claimed,
417 },
418 Err(_) => SignatureCheck::BadSignature {
419 signer_did: claimed,
420 },
421 }
422}
423
424fn load_exempt_keyring(args: &VerifyTrustArgs) -> Result<Option<ExemptKeyring>> {
426 let Some(path) = &args.exempt_keyring else {
427 return Ok(None);
428 };
429 let path = if path.is_absolute() {
430 path.clone()
431 } else {
432 args.repo_dir.join(path)
433 };
434 Ok(Some(ExemptKeyring::load(&path)?))
435}
436
437pub async fn resolve_signer_keys(
449 dids: &[String],
450 resolve_agent_names: bool,
451) -> Result<ResolvedSigners> {
452 use affinidi_tdk::TDK;
453 use affinidi_tdk::common::config::TDKConfig;
454 use affinidi_tdk::did_resolver::config::DIDCacheConfigBuilder;
455
456 let tdk = TDK::new(
460 TDKConfig::builder()
461 .with_load_environment(false)
462 .with_did_resolver_config(
463 DIDCacheConfigBuilder::default()
464 .with_resolve_shortcuts(resolve_agent_names)
465 .build(),
466 )
467 .build()
468 .context("TDK config")?,
469 None,
470 )
471 .await
472 .context("TDK init")?;
473
474 let mut signers = ResolvedSigners::default();
475 for did in dids {
476 match tdk.did_resolver().resolve(did).await {
477 Ok(response) => {
478 let name = signer_display_name(
482 response.shortcut.as_ref().map(|s| s.label()),
483 &vta_sdk::display_name::agent_name::claimed_names(&response.doc),
484 );
485 if let Some(name) = name {
486 signers.names.insert(did.clone(), name);
487 }
488
489 let doc = serde_json::to_value(&response.doc)
490 .with_context(|| format!("DID document for {did} did not serialize"))?;
491 let published = ed25519_keys_from_doc(&doc);
492 if published.is_empty() {
493 signers.unresolved.insert(
498 did.clone(),
499 "DID document publishes no Ed25519 verification keys".to_string(),
500 );
501 } else {
502 signers.keys.insert(did.clone(), published);
503 }
504 }
505 Err(e) => {
506 signers
507 .unresolved
508 .insert(did.clone(), format!("resolution failed: {e}"));
509 }
510 }
511 }
512 Ok(signers)
513}
514
515fn signer_display_name(verified: Option<&str>, claimed: &[String]) -> Option<DisplayName> {
526 if let Some(name) = verified {
527 return Some(DisplayName::new(
528 name,
529 NameSource::AgentName { verified: true },
530 ));
531 }
532 claimed
533 .first()
534 .map(|name| DisplayName::new(name, NameSource::AgentName { verified: false }))
535}
536
537type RegistryDecisions = BTreeMap<String, Result<Option<String>, String>>;
543
544async fn query_registry(
546 args: &VerifyTrustArgs,
547 signer_dids: &BTreeSet<String>,
548) -> Result<RegistryDecisions> {
549 let mut decisions = RegistryDecisions::new();
550 if signer_dids.is_empty() {
551 return Ok(decisions);
552 }
553 let transport = HttpsTransport::new(HttpsTransportConfig::new(&args.registry_url))?;
554 let client = TrqlClient::new(Arc::new(transport), &args.registry_did);
555 let mut resources = vec![args.resource.clone()];
557 if let Some(fallback) = &args.fallback_resource
558 && fallback != &args.resource
559 {
560 resources.push(fallback.clone());
561 }
562 for did in signer_dids {
563 let mut decision: Result<Option<String>, String> = Ok(None);
564 for resource in &resources {
565 let query = TrqpQuery::new(did, &args.authority_did, &args.action, resource);
566 match client.authorization(query).await {
567 Ok(response) if response.authorized => {
568 decision = Ok(Some(resource.clone()));
569 break;
570 }
571 Ok(_) => {}
572 Err(e @ TrqlError::Rejected { .. }) => {
573 tracing::debug!("registry rejected the query for {did}: {e}");
577 }
578 Err(e) => {
579 decision = Err(e.to_string());
582 break;
583 }
584 }
585 }
586 decisions.insert(did.clone(), decision);
587 }
588 Ok(decisions)
589}
590
591fn status_of(signature: SignatureCheck, decisions: &RegistryDecisions) -> CommitStatus {
593 match signature {
594 SignatureCheck::Unsigned => CommitStatus::Unsigned,
595 SignatureCheck::Malformed(detail) => CommitStatus::Malformed(detail),
596 SignatureCheck::NoSignerDid { committer } => CommitStatus::NoSignerDid { committer },
597 SignatureCheck::UnresolvedSigner { did, error } => {
598 CommitStatus::UnresolvedSigner { did, error }
599 }
600 SignatureCheck::UnknownKey { did, fingerprint } => {
601 CommitStatus::UnknownKey { did, fingerprint }
602 }
603 SignatureCheck::BadSignature { signer_did } => CommitStatus::BadSignature { signer_did },
604 SignatureCheck::PgpRejected { detail } => CommitStatus::PgpRejected { detail },
605 SignatureCheck::Exempt { fingerprint } => CommitStatus::Exempt { fingerprint },
606 SignatureCheck::Valid { signer_did } => match decisions.get(&signer_did) {
607 Some(Ok(Some(resource))) => CommitStatus::Trusted {
608 signer_did,
609 resource: resource.clone(),
610 },
611 Some(Ok(None)) => CommitStatus::Unauthorized { signer_did },
612 Some(Err(error)) => CommitStatus::RegistryUnavailable {
613 signer_did,
614 error: error.clone(),
615 },
616 None => CommitStatus::RegistryUnavailable {
617 signer_did,
618 error: "no registry decision recorded".to_string(),
619 },
620 },
621 }
622}
623
624pub fn list_commits(repo_dir: &Path, range: &str) -> Result<Vec<String>> {
628 let output = git(repo_dir, &["rev-list", "--reverse", range])?;
629 Ok(output.lines().map(str::to_string).collect())
630}
631
632pub fn read_commit_raw(repo_dir: &Path, sha: &str) -> Result<Vec<u8>> {
634 let output = Command::new("git")
635 .arg("-C")
636 .arg(repo_dir)
637 .args(["cat-file", "commit", sha])
638 .output()
639 .context("running git cat-file")?;
640 if !output.status.success() {
641 bail!(
642 "git cat-file commit {sha} failed: {}",
643 String::from_utf8_lossy(&output.stderr)
644 );
645 }
646 Ok(output.stdout)
647}
648
649fn git(repo_dir: &Path, args: &[&str]) -> Result<String> {
650 let output = Command::new("git")
651 .arg("-C")
652 .arg(repo_dir)
653 .args(args)
654 .output()
655 .with_context(|| format!("running git {}", args.join(" ")))?;
656 if !output.status.success() {
657 bail!(
658 "git {} failed: {}",
659 args.join(" "),
660 String::from_utf8_lossy(&output.stderr)
661 );
662 }
663 Ok(String::from_utf8(output.stdout)?.trim_end().to_string())
664}
665
666fn print_report(args: &VerifyTrustArgs, report: &TrustReport) -> Result<()> {
669 if args.json {
670 println!("{}", serde_json::to_string_pretty(report)?);
671 return Ok(());
672 }
673
674 let signer = |did: &str| render_signer(report, did);
678
679 for commit in &report.commits {
680 let short = &commit.sha[..commit.sha.len().min(12)];
681 match &commit.status {
682 CommitStatus::Trusted {
683 signer_did,
684 resource,
685 } => {
686 println!(
687 "TRUSTED {short} {} (via {resource})",
688 signer(signer_did)
689 );
690 }
691 CommitStatus::Exempt { fingerprint } => {
692 println!("EXEMPT {short} PGP-signed by exempt platform key {fingerprint}");
693 }
694 CommitStatus::PgpRejected { detail } => {
695 println!("PGP-REJECTED {short} {detail}");
696 }
697 CommitStatus::Unauthorized { signer_did } => {
698 println!(
699 "UNAUTHORIZED {short} {} is not authorized by the registry",
700 signer(signer_did)
701 );
702 }
703 CommitStatus::RegistryUnavailable { signer_did, error } => {
704 println!(
705 "UNAVAILABLE {short} signed by {}; registry check failed: {error}",
706 signer(signer_did)
707 );
708 }
709 CommitStatus::BadSignature { signer_did } => {
710 println!(
711 "BAD-SIG {short} signature by {} does not verify",
712 signer(signer_did)
713 );
714 }
715 CommitStatus::UnknownKey { did, fingerprint } => {
716 println!(
717 "UNKNOWN-KEY {short} {} publishes no key {fingerprint}",
718 signer(did)
719 );
720 }
721 CommitStatus::UnresolvedSigner { did, error } => {
722 println!("UNRESOLVED {short} claimed signer {did} did not resolve: {error}");
723 }
724 CommitStatus::NoSignerDid { committer } => {
725 println!("NO-SIGNER {short} committer <{committer}> is not a DID");
726 }
727 CommitStatus::Malformed(detail) => {
728 println!("MALFORMED {short} {detail}");
729 }
730 CommitStatus::Unsigned => {
731 println!("UNSIGNED {short} commit carries no signature");
732 }
733 }
734 }
735
736 print_signer_block(args, report);
737
738 let passing = report.commits.iter().filter(|c| c.status.passes()).count();
739 println!(
740 "{}: {passing}/{} commits pass",
741 if report.ok { "PASS" } else { "FAIL" },
742 report.commits.len()
743 );
744 Ok(())
745}
746
747fn render_signer(report: &TrustReport, did: &str) -> String {
751 match report.signer_names.get(did) {
752 Some(name) if name.is_trusted() => {
753 format!(
754 "{} ({})",
755 name.name,
756 vta_sdk::display_name::shorten_did(did)
757 )
758 }
759 Some(name) => format!(
760 "{}{} ({})",
761 name.name,
762 vta_sdk::display_name::UNVERIFIED_SUFFIX,
763 vta_sdk::display_name::shorten_did(did)
764 ),
765 None => vta_sdk::display_name::shorten_did(did),
766 }
767}
768
769fn print_signer_block(args: &VerifyTrustArgs, report: &TrustReport) {
774 if report.signer_names.is_empty() {
775 return;
776 }
777 println!();
778 println!("Signers:");
779 for (did, name) in &report.signer_names {
780 let tag = if name.is_trusted() {
781 String::new()
782 } else {
783 format!(" {}", vta_sdk::display_name::UNVERIFIED_SUFFIX.trim())
784 };
785 println!(" {}{tag}", name.name);
786 println!(" {did}");
787 }
788 if !args.resolve_agent_names && report.signer_names.values().any(|n| !n.is_trusted()) {
789 println!();
790 println!(
791 " Names above are claimed by the DID and were not checked. Pass \
792 --resolve-agent-names to resolve each claim back to its DID."
793 );
794 }
795}
796
797#[cfg(test)]
798mod tests {
799 #![allow(clippy::unwrap_used, clippy::expect_used)]
800
801 use super::*;
802 use ed25519_dalek::SigningKey;
803 use vgi_core::create_ssh_signature;
804
805 fn test_key() -> (SigningKey, [u8; 32]) {
806 let signing = SigningKey::from_bytes(&[7u8; 32]);
807 let public = signing.verifying_key().to_bytes();
808 (signing, public)
809 }
810
811 const SIGNER: &str = "did:webvh:QmSigner:example.com";
812
813 fn unsigned_commit() -> String {
816 commit_committed_by(&format!("{SIGNER}#key-0"))
817 }
818
819 fn commit_committed_by(committer: &str) -> String {
820 format!(
821 "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
822 author A U Thor <a@example.com> 1700000000 +0000\n\
823 committer A U Thor <{committer}> 1700000000 +0000\n\
824 \n\
825 a message\n"
826 )
827 }
828
829 fn signers_publishing(public: [u8; 32]) -> ResolvedSigners {
831 ResolvedSigners::from_keys([(SIGNER, vec![public])])
832 }
833
834 fn signed_commit(payload: &str, armored: &str) -> String {
837 let (headers, body) = payload.split_once("\n\n").unwrap();
838 let mut sig_header = String::from("gpgsig ");
839 let mut lines = armored.trim_end().split('\n');
840 sig_header.push_str(lines.next().unwrap());
841 for line in lines {
842 sig_header.push('\n');
843 sig_header.push(' ');
844 sig_header.push_str(line);
845 }
846 format!("{headers}\n{sig_header}\n\n{body}")
847 }
848
849 fn sign_commit(payload: &str, key: &SigningKey) -> String {
850 let armored = create_ssh_signature(
851 key,
852 &key.verifying_key(),
853 GIT_SSHSIG_NAMESPACE,
854 payload.as_bytes(),
855 )
856 .unwrap();
857 signed_commit(payload, &armored)
858 }
859
860 #[test]
861 fn split_returns_none_for_unsigned_commit() {
862 assert!(
863 split_signed_commit(unsigned_commit().as_bytes())
864 .unwrap()
865 .is_none()
866 );
867 }
868
869 #[test]
870 fn split_recovers_exact_payload_and_signature() {
871 let payload = unsigned_commit();
872 let (key, _) = test_key();
873 let commit = sign_commit(&payload, &key);
874
875 let (recovered_payload, pem) = split_signed_commit(commit.as_bytes()).unwrap().unwrap();
876 assert_eq!(recovered_payload, payload.as_bytes());
877 assert!(pem.starts_with("-----BEGIN SSH SIGNATURE-----"));
878 assert!(pem.trim_end().ends_with("-----END SSH SIGNATURE-----"));
879 }
880
881 #[test]
882 fn our_encoder_and_the_decoder_agree() {
883 let payload = unsigned_commit();
886 let (key, public) = test_key();
887 let commit = sign_commit(&payload, &key);
888
889 let check = check_commit_signature(commit.as_bytes(), &signers_publishing(public), None);
890 assert_eq!(
891 check,
892 SignatureCheck::Valid {
893 signer_did: SIGNER.to_string()
894 }
895 );
896 }
897
898 #[test]
899 fn the_signer_is_the_did_the_commit_claims() {
900 let payload = unsigned_commit();
903 let (key, public) = test_key();
904 let commit = sign_commit(&payload, &key);
905
906 let SignatureCheck::Valid { signer_did } =
907 check_commit_signature(commit.as_bytes(), &signers_publishing(public), None)
908 else {
909 panic!("expected a valid signature");
910 };
911 assert_eq!(signer_did, SIGNER, "the bare DID, not the key id");
912 }
913
914 #[test]
915 fn claiming_a_did_that_does_not_publish_the_signing_key_fails() {
916 let payload = commit_committed_by("did:webvh:QmVictim:example.com#key-0");
920 let (key, public) = test_key();
921 let commit = sign_commit(&payload, &key);
922
923 let signers = ResolvedSigners::from_keys([
924 (SIGNER, vec![public]),
925 ("did:webvh:QmVictim:example.com", vec![[0u8; 32]]),
926 ]);
927 assert_eq!(
928 check_commit_signature(commit.as_bytes(), &signers, None),
929 SignatureCheck::UnknownKey {
930 did: "did:webvh:QmVictim:example.com".to_string(),
931 fingerprint: hex::encode(public),
932 },
933 "a key published by another DID must not authenticate this claim"
934 );
935 }
936
937 #[test]
938 fn a_committer_that_is_not_a_did_has_no_identity_to_check() {
939 let payload = commit_committed_by("alice@example.com");
940 let (key, public) = test_key();
941 let commit = sign_commit(&payload, &key);
942
943 assert_eq!(
944 check_commit_signature(commit.as_bytes(), &signers_publishing(public), None),
945 SignatureCheck::NoSignerDid {
946 committer: "alice@example.com".to_string()
947 }
948 );
949 }
950
951 #[test]
952 fn a_claimed_did_that_did_not_resolve_fails_closed() {
953 let payload = unsigned_commit();
954 let (key, _) = test_key();
955 let commit = sign_commit(&payload, &key);
956
957 let mut signers = ResolvedSigners::default();
958 signers
959 .unresolved
960 .insert(SIGNER.to_string(), "resolution failed: no such host".into());
961
962 assert_eq!(
963 check_commit_signature(commit.as_bytes(), &signers, None),
964 SignatureCheck::UnresolvedSigner {
965 did: SIGNER.to_string(),
966 error: "resolution failed: no such host".to_string(),
967 },
968 "an unresolvable signer is not a trusted one"
969 );
970 }
971
972 #[test]
973 fn the_claim_is_read_from_the_bytes_the_signature_covers() {
974 let payload = unsigned_commit();
977 let (key, public) = test_key();
978 let commit = sign_commit(&payload, &key).replace(
979 &format!("{SIGNER}#key-0"),
980 "did:webvh:QmOther:example.com#key-0",
981 );
982
983 let signers = ResolvedSigners::from_keys([
984 (SIGNER, vec![public]),
985 ("did:webvh:QmOther:example.com", vec![public]),
986 ]);
987 assert_eq!(
988 check_commit_signature(commit.as_bytes(), &signers, None),
989 SignatureCheck::BadSignature {
990 signer_did: "did:webvh:QmOther:example.com".to_string()
991 },
992 "tampering with the claimed identity breaks the signature over it"
993 );
994 }
995
996 #[test]
997 fn distinct_claimed_dids_are_deduplicated_and_bounded() {
998 let (key, _) = test_key();
999 let commits: Vec<RangeCommit> = ["QmA", "QmB", "QmA"]
1000 .iter()
1001 .enumerate()
1002 .map(|(i, scid)| RangeCommit {
1003 sha: format!("{i:040}"),
1004 raw: sign_commit(
1005 &commit_committed_by(&format!("did:webvh:{scid}:example.com#key-0")),
1006 &key,
1007 )
1008 .into_bytes(),
1009 })
1010 .collect();
1011
1012 let claimed = claimed_signer_dids(&commits, 32).unwrap();
1013 assert_eq!(
1014 claimed,
1015 vec![
1016 "did:webvh:QmA:example.com".to_string(),
1017 "did:webvh:QmB:example.com".to_string(),
1018 ],
1019 "three commits, two identities, two resolutions"
1020 );
1021 assert!(
1022 claimed_signer_dids(&commits, 1).is_err(),
1023 "a range may not make CI resolve more hosts than the cap allows"
1024 );
1025 }
1026
1027 #[test]
1028 fn legacy_76_column_armor_still_verifies() {
1029 let payload = unsigned_commit();
1032 let (key, public) = test_key();
1033 let armored = create_ssh_signature(
1034 &key,
1035 &key.verifying_key(),
1036 GIT_SSHSIG_NAMESPACE,
1037 payload.as_bytes(),
1038 )
1039 .unwrap();
1040 let body: String = armored
1041 .lines()
1042 .filter(|l| !l.starts_with("-----"))
1043 .collect();
1044 let mut legacy = String::from("-----BEGIN SSH SIGNATURE-----\n");
1045 for chunk in body.as_bytes().chunks(76) {
1046 legacy.push_str(std::str::from_utf8(chunk).unwrap());
1047 legacy.push('\n');
1048 }
1049 legacy.push_str("-----END SSH SIGNATURE-----\n");
1050
1051 let commit = signed_commit(&payload, &legacy);
1052 assert_eq!(
1053 check_commit_signature(commit.as_bytes(), &signers_publishing(public), None),
1054 SignatureCheck::Valid {
1055 signer_did: SIGNER.to_string()
1056 }
1057 );
1058 }
1059
1060 #[test]
1061 fn a_key_the_claimed_did_does_not_publish_is_reported_with_its_fingerprint() {
1062 let payload = unsigned_commit();
1063 let (key, public) = test_key();
1064 let commit = sign_commit(&payload, &key);
1065
1066 let signers = ResolvedSigners::from_keys([(SIGNER, vec![[3u8; 32]])]);
1068 assert_eq!(
1069 check_commit_signature(commit.as_bytes(), &signers, None),
1070 SignatureCheck::UnknownKey {
1071 did: SIGNER.to_string(),
1072 fingerprint: hex::encode(public),
1073 }
1074 );
1075 }
1076
1077 #[test]
1078 fn tampered_payload_is_a_bad_signature() {
1079 let payload = unsigned_commit();
1080 let (key, public) = test_key();
1081 let commit = sign_commit(&payload, &key).replace("a message", "b message");
1082
1083 let check = check_commit_signature(commit.as_bytes(), &signers_publishing(public), None);
1084 assert_eq!(
1085 check,
1086 SignatureCheck::BadSignature {
1087 signer_did: SIGNER.to_string()
1088 }
1089 );
1090 }
1091
1092 #[test]
1093 fn unsigned_commit_is_unsigned() {
1094 let check = check_commit_signature(
1095 unsigned_commit().as_bytes(),
1096 &ResolvedSigners::default(),
1097 None,
1098 );
1099 assert_eq!(check, SignatureCheck::Unsigned);
1100 }
1101
1102 #[test]
1103 fn statuses_compose_signature_and_registry_decisions() {
1104 let did = "did:example:signer".to_string();
1105 let mut decisions = RegistryDecisions::new();
1106 decisions.insert(did.clone(), Ok(Some("example/repo".to_string())));
1107 assert!(
1108 status_of(
1109 SignatureCheck::Valid {
1110 signer_did: did.clone()
1111 },
1112 &decisions
1113 )
1114 .is_trusted()
1115 );
1116
1117 decisions.insert(did.clone(), Ok(None));
1118 assert_eq!(
1119 status_of(
1120 SignatureCheck::Valid {
1121 signer_did: did.clone()
1122 },
1123 &decisions
1124 ),
1125 CommitStatus::Unauthorized {
1126 signer_did: did.clone()
1127 }
1128 );
1129
1130 decisions.insert(did.clone(), Err("connect refused".to_string()));
1131 assert!(matches!(
1132 status_of(SignatureCheck::Valid { signer_did: did }, &decisions),
1133 CommitStatus::RegistryUnavailable { .. }
1134 ));
1135 }
1136
1137 #[test]
1140 fn a_verified_shortcut_is_the_name() {
1141 let name = signer_display_name(
1142 Some("example.com/@alice"),
1143 &["https://example.com/@alice".to_string()],
1144 )
1145 .unwrap();
1146 assert_eq!(name.name, "example.com/@alice");
1147 assert!(name.is_trusted());
1148 }
1149
1150 #[test]
1151 fn an_unchecked_claim_is_never_trusted() {
1152 let name =
1155 signer_display_name(None, &["https://mybank.com/@treasury".to_string()]).unwrap();
1156 assert_eq!(name.source, NameSource::AgentName { verified: false });
1157 assert!(!name.is_trusted());
1158 }
1159
1160 #[test]
1161 fn a_signer_claiming_nothing_has_no_name() {
1162 assert!(signer_display_name(None, &[]).is_none());
1163 }
1164
1165 #[test]
1166 fn an_unverified_name_renders_tagged_beside_its_did() {
1167 let did = "did:webvh:QmScidAbCdEfGhIj:example.com:ops";
1168 let report = TrustReport {
1169 ok: true,
1170 commits: Vec::new(),
1171 unresolved_signers: BTreeMap::new(),
1172 signer_names: BTreeMap::from([(
1173 did.to_string(),
1174 DisplayName::new(
1175 "mybank.com/@treasury",
1176 NameSource::AgentName { verified: false },
1177 ),
1178 )]),
1179 };
1180 let rendered = render_signer(&report, did);
1181 assert!(
1182 rendered.contains("unverified"),
1183 "an unchecked claim must never render as a plain name: {rendered}"
1184 );
1185 assert!(
1186 rendered.contains("example.com"),
1187 "the DID must stay visible beside the name: {rendered}"
1188 );
1189 }
1190
1191 #[test]
1192 fn an_unnamed_signer_falls_back_to_its_did() {
1193 let did = "did:webvh:QmScidAbCdEfGhIj:example.com:ops";
1194 let report = TrustReport {
1195 ok: true,
1196 commits: Vec::new(),
1197 unresolved_signers: BTreeMap::new(),
1198 signer_names: BTreeMap::new(),
1199 };
1200 assert_eq!(
1201 render_signer(&report, did),
1202 vta_sdk::display_name::shorten_did(did)
1203 );
1204 }
1205}