1pub mod pgp_exempt;
51
52use std::collections::{BTreeMap, BTreeSet};
53use std::path::{Path, PathBuf};
54use std::process::Command;
55use std::sync::Arc;
56
57use anyhow::{Context, Result, bail};
58use serde::Serialize;
59use ssh_key::{SshSig, public::KeyData};
60use trql_client::{
61 HttpsTransport, HttpsTransportConfig, ServiceCapabilities, TransportKind, TrqlClient,
62 TrqlError, TrqpQuery,
63};
64use vgi_core::{
65 GIT_SSHSIG_NAMESPACE, committer_did, committer_identity, ed25519_keys_from_doc,
66 normalize_sshsig_armor, split_signed_commit,
67};
68use vta_sdk::display_name::{DisplayName, NameBook, NameSource};
69
70use crate::pgp_exempt::ExemptKeyring;
71
72#[derive(Debug, Clone)]
74pub struct VerifyTrustArgs {
75 pub repo_dir: PathBuf,
77 pub range: String,
79 pub max_signers: usize,
88 pub registry_url: Option<String>,
102 pub registry_did: String,
105 pub vtc_did: String,
108 pub action: String,
110 pub resource: String,
117 pub fallback_resource: Option<String>,
123 pub exempt_keyring: Option<PathBuf>,
127 pub resolve_agent_names: bool,
136 pub json: bool,
138}
139
140#[derive(Debug, Clone, PartialEq, Serialize)]
142#[serde(rename_all = "camelCase", tag = "status", content = "detail")]
143pub enum CommitStatus {
144 Unsigned,
146 Malformed(String),
148 NoSignerDid { committer: String },
151 UnresolvedSigner { did: String, error: String },
154 UnknownKey { did: String, fingerprint: String },
157 BadSignature { signer_did: String },
159 Unauthorized { signer_did: String },
161 RegistryUnavailable { signer_did: String, error: String },
164 PgpRejected { detail: String },
167 Exempt { fingerprint: String },
170 Trusted {
174 signer_did: String,
175 resource: String,
176 },
177}
178
179impl CommitStatus {
180 pub fn is_trusted(&self) -> bool {
182 matches!(self, Self::Trusted { .. })
183 }
184
185 pub fn passes(&self) -> bool {
187 matches!(self, Self::Trusted { .. } | Self::Exempt { .. })
188 }
189}
190
191#[derive(Debug, Clone, Serialize)]
193#[serde(rename_all = "camelCase")]
194pub struct CommitVerdict {
195 pub sha: String,
196 #[serde(flatten)]
197 pub status: CommitStatus,
198}
199
200#[derive(Debug, Serialize)]
202#[serde(rename_all = "camelCase")]
203pub struct TrustReport {
204 pub ok: bool,
205 pub commits: Vec<CommitVerdict>,
206 pub unresolved_signers: BTreeMap<String, String>,
210 pub signer_names: BTreeMap<String, DisplayName>,
214}
215
216#[derive(Debug, Default)]
222pub struct ResolvedSigners {
223 pub keys: BTreeMap<String, Vec<[u8; 32]>>,
227 pub unresolved: BTreeMap<String, String>,
229 pub names: NameBook,
231}
232
233impl ResolvedSigners {
234 #[must_use]
237 pub fn from_keys<I, D>(keys: I) -> Self
238 where
239 I: IntoIterator<Item = (D, Vec<[u8; 32]>)>,
240 D: Into<String>,
241 {
242 Self {
243 keys: keys.into_iter().map(|(did, k)| (did.into(), k)).collect(),
244 ..Self::default()
245 }
246 }
247
248 fn published(&self, did: &str) -> Option<&[[u8; 32]]> {
250 self.keys.get(did).map(Vec::as_slice)
251 }
252}
253
254pub async fn handle_verify_trust(mut args: VerifyTrustArgs) -> Result<i32> {
258 let exempt = load_exempt_keyring(&args)?;
259 let commits = read_range(&args.repo_dir, &args.range)?;
260 let claimed = claimed_signer_dids(&commits, args.max_signers)?;
261
262 let tdk = build_resolver(args.resolve_agent_names).await?;
265 if args.registry_url.is_none() {
266 args.registry_url = Some(resolve_registry_endpoint(&tdk, &args.registry_did).await?);
267 }
268 let signers = resolve_signer_keys(&tdk, &claimed).await?;
269
270 let report = verify_prepared(&args, &commits, &signers, exempt.as_ref()).await?;
271 print_report(&args, &report)?;
272 Ok(if report.ok { 0 } else { 1 })
273}
274
275#[derive(Debug, Clone)]
278pub struct RangeCommit {
279 pub sha: String,
280 pub raw: Vec<u8>,
281}
282
283pub fn read_range(repo_dir: &Path, range: &str) -> Result<Vec<RangeCommit>> {
285 list_commits(repo_dir, range)?
286 .into_iter()
287 .map(|sha| {
288 let raw = read_commit_raw(repo_dir, &sha)?;
289 Ok(RangeCommit { sha, raw })
290 })
291 .collect()
292}
293
294pub fn claimed_signer_dids(commits: &[RangeCommit], max_signers: usize) -> Result<Vec<String>> {
301 let dids: BTreeSet<String> = commits
302 .iter()
303 .filter_map(|commit| committer_did(&commit.raw))
304 .collect();
305 if dids.len() > max_signers {
306 bail!(
307 "range claims {} distinct signer DIDs, over the limit of {max_signers}; \
308 each costs a resolution to a host the commit's author chose. Raise \
309 --max-signers only if this range is legitimately that wide.",
310 dids.len()
311 );
312 }
313 Ok(dids.into_iter().collect())
314}
315
316pub async fn verify_prepared(
319 args: &VerifyTrustArgs,
320 commits: &[RangeCommit],
321 signers: &ResolvedSigners,
322 exempt: Option<&ExemptKeyring>,
323) -> Result<TrustReport> {
324 let mut checked = Vec::with_capacity(commits.len());
326 let mut signer_dids = BTreeSet::new();
327 for commit in commits {
328 let signature = check_commit_signature(&commit.raw, signers, exempt);
329 if let SignatureCheck::Valid { signer_did } = &signature {
330 signer_dids.insert(signer_did.clone());
331 }
332 checked.push((commit.sha.clone(), signature));
333 }
334
335 let decisions = query_registry(args, &signer_dids).await?;
337
338 let commits: Vec<CommitVerdict> = checked
339 .into_iter()
340 .map(|(sha, signature)| CommitVerdict {
341 sha,
342 status: status_of(signature, &decisions),
343 })
344 .collect();
345
346 let signer_names = signer_dids
349 .iter()
350 .filter_map(|did| {
351 signers
352 .names
353 .get(did)
354 .map(|name| (did.clone(), name.clone()))
355 })
356 .collect();
357
358 let ok = commits.iter().all(|c| c.status.passes());
360 Ok(TrustReport {
361 ok,
362 commits,
363 unresolved_signers: signers.unresolved.clone(),
364 signer_names,
365 })
366}
367
368#[derive(Debug, Clone, PartialEq)]
372pub enum SignatureCheck {
373 Unsigned,
374 Malformed(String),
375 NoSignerDid { committer: String },
376 UnresolvedSigner { did: String, error: String },
377 UnknownKey { did: String, fingerprint: String },
378 BadSignature { signer_did: String },
379 PgpRejected { detail: String },
380 Exempt { fingerprint: String },
381 Valid { signer_did: String },
382}
383
384pub fn check_commit_signature(
392 raw: &[u8],
393 signers: &ResolvedSigners,
394 exempt: Option<&ExemptKeyring>,
395) -> SignatureCheck {
396 let (payload, pem) = match split_signed_commit(raw) {
397 Ok(Some(parts)) => parts,
398 Ok(None) => return SignatureCheck::Unsigned,
399 Err(e) => return SignatureCheck::Malformed(e.to_string()),
400 };
401 if pem.starts_with("-----BEGIN PGP SIGNATURE-----") {
404 let Some(keyring) = exempt else {
405 return SignatureCheck::PgpRejected {
406 detail: "PGP-signed commit, but no exempt keyring is configured".to_string(),
407 };
408 };
409 return match keyring.verify(&pem, &payload) {
410 Ok(fingerprint) => SignatureCheck::Exempt { fingerprint },
411 Err(detail) => SignatureCheck::PgpRejected { detail },
412 };
413 }
414 let sig = match SshSig::from_pem(normalize_sshsig_armor(&pem).as_bytes()) {
415 Ok(sig) => sig,
416 Err(e) => return SignatureCheck::Malformed(format!("sshsig did not parse: {e}")),
417 };
418 let KeyData::Ed25519(embedded) = sig.public_key() else {
419 return SignatureCheck::Malformed(format!(
420 "unsupported signature algorithm: {}",
421 sig.algorithm()
422 ));
423 };
424 let key_bytes: [u8; 32] = embedded.0;
425
426 let Some(claimed) = committer_did(&payload) else {
429 return SignatureCheck::NoSignerDid {
430 committer: committer_identity(&payload).unwrap_or_else(|| "(absent)".to_string()),
431 };
432 };
433 let Some(published) = signers.published(&claimed) else {
434 let error = signers
435 .unresolved
436 .get(&claimed)
437 .cloned()
438 .unwrap_or_else(|| "not resolved".to_string());
439 return SignatureCheck::UnresolvedSigner {
440 did: claimed,
441 error,
442 };
443 };
444 if !published.contains(&key_bytes) {
445 return SignatureCheck::UnknownKey {
446 did: claimed,
447 fingerprint: hex::encode(key_bytes),
448 };
449 }
450 let public_key = ssh_key::PublicKey::from(sig.public_key().clone());
451 match public_key.verify(GIT_SSHSIG_NAMESPACE, &payload, &sig) {
452 Ok(()) => SignatureCheck::Valid {
453 signer_did: claimed,
454 },
455 Err(_) => SignatureCheck::BadSignature {
456 signer_did: claimed,
457 },
458 }
459}
460
461fn load_exempt_keyring(args: &VerifyTrustArgs) -> Result<Option<ExemptKeyring>> {
463 let Some(path) = &args.exempt_keyring else {
464 return Ok(None);
465 };
466 let path = if path.is_absolute() {
467 path.clone()
468 } else {
469 args.repo_dir.join(path)
470 };
471 Ok(Some(ExemptKeyring::load(&path)?))
472}
473
474pub async fn build_resolver(resolve_agent_names: bool) -> Result<affinidi_tdk::TDK> {
482 use affinidi_tdk::TDK;
483 use affinidi_tdk::common::config::TDKConfig;
484 use affinidi_tdk::did_resolver::config::DIDCacheConfigBuilder;
485
486 TDK::new(
490 TDKConfig::builder()
491 .with_load_environment(false)
492 .with_did_resolver_config(
493 DIDCacheConfigBuilder::default()
494 .with_resolve_shortcuts(resolve_agent_names)
495 .build(),
496 )
497 .build()
498 .context("TDK config")?,
499 None,
500 )
501 .await
502 .context("TDK init")
503}
504
505pub async fn resolve_registry_endpoint(
520 tdk: &affinidi_tdk::TDK,
521 registry_did: &str,
522) -> Result<String> {
523 let response = tdk
524 .did_resolver()
525 .resolve(registry_did)
526 .await
527 .map_err(|e| anyhow::anyhow!("could not resolve registry DID {registry_did}: {e}"))?;
528 let doc = serde_json::to_value(&response.doc)
529 .with_context(|| format!("DID document for {registry_did} did not serialize"))?;
530
531 let capabilities = ServiceCapabilities::from_document(&doc);
532 let choice = capabilities
533 .select(&TransportKind::compiled())
534 .with_context(|| format!("no usable Trust Registry transport on {registry_did}"))?;
535
536 match choice.kind {
537 TransportKind::Https => {
538 tracing::debug!(endpoint = %choice.endpoint, "discovered registry REST endpoint");
539 Ok(choice.endpoint)
540 }
541 kind => bail!(
545 "registry {registry_did} was selected for the {kind} binding, whose endpoint \
546 ({}) is a mediator DID rather than a URL; verify-trust can only query over \
547 HTTPS. Set --registry-url to a REST endpoint.",
548 choice.endpoint
549 ),
550 }
551}
552
553pub async fn resolve_signer_keys(
558 tdk: &affinidi_tdk::TDK,
559 dids: &[String],
560) -> Result<ResolvedSigners> {
561 let mut signers = ResolvedSigners::default();
562 for did in dids {
563 match tdk.did_resolver().resolve(did).await {
564 Ok(response) => {
565 let name = signer_display_name(
569 response.shortcut.as_ref().map(|s| s.label()),
570 &vta_sdk::display_name::agent_name::claimed_names(&response.doc),
571 );
572 if let Some(name) = name {
573 signers.names.insert(did.clone(), name);
574 }
575
576 let doc = serde_json::to_value(&response.doc)
577 .with_context(|| format!("DID document for {did} did not serialize"))?;
578 let published = ed25519_keys_from_doc(&doc);
579 if published.is_empty() {
580 signers.unresolved.insert(
585 did.clone(),
586 "DID document publishes no Ed25519 verification keys".to_string(),
587 );
588 } else {
589 signers.keys.insert(did.clone(), published);
590 }
591 }
592 Err(e) => {
593 signers
594 .unresolved
595 .insert(did.clone(), format!("resolution failed: {e}"));
596 }
597 }
598 }
599 Ok(signers)
600}
601
602fn signer_display_name(verified: Option<&str>, claimed: &[String]) -> Option<DisplayName> {
613 if let Some(name) = verified {
614 return Some(DisplayName::new(
615 name,
616 NameSource::AgentName { verified: true },
617 ));
618 }
619 claimed
620 .first()
621 .map(|name| DisplayName::new(name, NameSource::AgentName { verified: false }))
622}
623
624type RegistryDecisions = BTreeMap<String, Result<Option<String>, String>>;
630
631async fn query_registry(
633 args: &VerifyTrustArgs,
634 signer_dids: &BTreeSet<String>,
635) -> Result<RegistryDecisions> {
636 let mut decisions = RegistryDecisions::new();
637 if signer_dids.is_empty() {
638 return Ok(decisions);
639 }
640 let registry_url = args.registry_url.as_deref().context(
643 "registry URL not resolved: discover it from --registry-did or pass --registry-url",
644 )?;
645 let transport = HttpsTransport::new(HttpsTransportConfig::new(registry_url))?;
646 let client = TrqlClient::new(Arc::new(transport), &args.registry_did);
647 let mut resources = vec![args.resource.clone()];
649 if let Some(fallback) = &args.fallback_resource
650 && fallback != &args.resource
651 {
652 resources.push(fallback.clone());
653 }
654 for did in signer_dids {
655 let mut decision: Result<Option<String>, String> = Ok(None);
656 for resource in &resources {
657 let query = TrqpQuery::new(did, &args.vtc_did, &args.action, resource);
659 match client.authorization(query).await {
660 Ok(response) if response.authorized => {
661 decision = Ok(Some(resource.clone()));
662 break;
663 }
664 Ok(_) => {}
665 Err(e @ TrqlError::Rejected { .. }) => {
666 tracing::debug!("registry rejected the query for {did}: {e}");
670 }
671 Err(e) => {
672 decision = Err(e.to_string());
675 break;
676 }
677 }
678 }
679 decisions.insert(did.clone(), decision);
680 }
681 Ok(decisions)
682}
683
684fn status_of(signature: SignatureCheck, decisions: &RegistryDecisions) -> CommitStatus {
686 match signature {
687 SignatureCheck::Unsigned => CommitStatus::Unsigned,
688 SignatureCheck::Malformed(detail) => CommitStatus::Malformed(detail),
689 SignatureCheck::NoSignerDid { committer } => CommitStatus::NoSignerDid { committer },
690 SignatureCheck::UnresolvedSigner { did, error } => {
691 CommitStatus::UnresolvedSigner { did, error }
692 }
693 SignatureCheck::UnknownKey { did, fingerprint } => {
694 CommitStatus::UnknownKey { did, fingerprint }
695 }
696 SignatureCheck::BadSignature { signer_did } => CommitStatus::BadSignature { signer_did },
697 SignatureCheck::PgpRejected { detail } => CommitStatus::PgpRejected { detail },
698 SignatureCheck::Exempt { fingerprint } => CommitStatus::Exempt { fingerprint },
699 SignatureCheck::Valid { signer_did } => match decisions.get(&signer_did) {
700 Some(Ok(Some(resource))) => CommitStatus::Trusted {
701 signer_did,
702 resource: resource.clone(),
703 },
704 Some(Ok(None)) => CommitStatus::Unauthorized { signer_did },
705 Some(Err(error)) => CommitStatus::RegistryUnavailable {
706 signer_did,
707 error: error.clone(),
708 },
709 None => CommitStatus::RegistryUnavailable {
710 signer_did,
711 error: "no registry decision recorded".to_string(),
712 },
713 },
714 }
715}
716
717pub fn list_commits(repo_dir: &Path, range: &str) -> Result<Vec<String>> {
721 let output = git(repo_dir, &["rev-list", "--reverse", range])?;
722 Ok(output.lines().map(str::to_string).collect())
723}
724
725pub fn read_commit_raw(repo_dir: &Path, sha: &str) -> Result<Vec<u8>> {
727 let output = Command::new("git")
728 .arg("-C")
729 .arg(repo_dir)
730 .args(["cat-file", "commit", sha])
731 .output()
732 .context("running git cat-file")?;
733 if !output.status.success() {
734 bail!(
735 "git cat-file commit {sha} failed: {}",
736 String::from_utf8_lossy(&output.stderr)
737 );
738 }
739 Ok(output.stdout)
740}
741
742fn git(repo_dir: &Path, args: &[&str]) -> Result<String> {
743 let output = Command::new("git")
744 .arg("-C")
745 .arg(repo_dir)
746 .args(args)
747 .output()
748 .with_context(|| format!("running git {}", args.join(" ")))?;
749 if !output.status.success() {
750 bail!(
751 "git {} failed: {}",
752 args.join(" "),
753 String::from_utf8_lossy(&output.stderr)
754 );
755 }
756 Ok(String::from_utf8(output.stdout)?.trim_end().to_string())
757}
758
759fn print_report(args: &VerifyTrustArgs, report: &TrustReport) -> Result<()> {
762 if args.json {
763 println!("{}", serde_json::to_string_pretty(report)?);
764 return Ok(());
765 }
766
767 let signer = |did: &str| render_signer(report, did);
771
772 for commit in &report.commits {
773 let short = &commit.sha[..commit.sha.len().min(12)];
774 match &commit.status {
775 CommitStatus::Trusted {
776 signer_did,
777 resource,
778 } => {
779 println!(
780 "TRUSTED {short} {} (via {resource})",
781 signer(signer_did)
782 );
783 }
784 CommitStatus::Exempt { fingerprint } => {
785 println!("EXEMPT {short} PGP-signed by exempt platform key {fingerprint}");
786 }
787 CommitStatus::PgpRejected { detail } => {
788 println!("PGP-REJECTED {short} {detail}");
789 }
790 CommitStatus::Unauthorized { signer_did } => {
791 println!(
792 "UNAUTHORIZED {short} {} is not authorized by the registry",
793 signer(signer_did)
794 );
795 }
796 CommitStatus::RegistryUnavailable { signer_did, error } => {
797 println!(
798 "UNAVAILABLE {short} signed by {}; registry check failed: {error}",
799 signer(signer_did)
800 );
801 }
802 CommitStatus::BadSignature { signer_did } => {
803 println!(
804 "BAD-SIG {short} signature by {} does not verify",
805 signer(signer_did)
806 );
807 }
808 CommitStatus::UnknownKey { did, fingerprint } => {
809 println!(
810 "UNKNOWN-KEY {short} {} publishes no key {fingerprint}",
811 signer(did)
812 );
813 }
814 CommitStatus::UnresolvedSigner { did, error } => {
815 println!("UNRESOLVED {short} claimed signer {did} did not resolve: {error}");
816 }
817 CommitStatus::NoSignerDid { committer } => {
818 println!("NO-SIGNER {short} committer <{committer}> is not a DID");
819 }
820 CommitStatus::Malformed(detail) => {
821 println!("MALFORMED {short} {detail}");
822 }
823 CommitStatus::Unsigned => {
824 println!("UNSIGNED {short} commit carries no signature");
825 }
826 }
827 }
828
829 print_signer_block(args, report);
830
831 let passing = report.commits.iter().filter(|c| c.status.passes()).count();
832 println!(
833 "{}: {passing}/{} commits pass",
834 if report.ok { "PASS" } else { "FAIL" },
835 report.commits.len()
836 );
837 Ok(())
838}
839
840fn render_signer(report: &TrustReport, did: &str) -> String {
844 match report.signer_names.get(did) {
845 Some(name) if name.is_trusted() => {
846 format!(
847 "{} ({})",
848 name.name,
849 vta_sdk::display_name::shorten_did(did)
850 )
851 }
852 Some(name) => format!(
853 "{}{} ({})",
854 name.name,
855 vta_sdk::display_name::UNVERIFIED_SUFFIX,
856 vta_sdk::display_name::shorten_did(did)
857 ),
858 None => vta_sdk::display_name::shorten_did(did),
859 }
860}
861
862fn print_signer_block(args: &VerifyTrustArgs, report: &TrustReport) {
867 if report.signer_names.is_empty() {
868 return;
869 }
870 println!();
871 println!("Signers:");
872 for (did, name) in &report.signer_names {
873 let tag = if name.is_trusted() {
874 String::new()
875 } else {
876 format!(" {}", vta_sdk::display_name::UNVERIFIED_SUFFIX.trim())
877 };
878 println!(" {}{tag}", name.name);
879 println!(" {did}");
880 }
881 if !args.resolve_agent_names && report.signer_names.values().any(|n| !n.is_trusted()) {
882 println!();
883 println!(
884 " Names above are claimed by the DID and were not checked. Pass \
885 --resolve-agent-names to resolve each claim back to its DID."
886 );
887 }
888}
889
890#[cfg(test)]
891mod tests {
892 #![allow(clippy::unwrap_used, clippy::expect_used)]
893
894 use super::*;
895 use ed25519_dalek::SigningKey;
896 use vgi_core::create_ssh_signature;
897
898 fn test_key() -> (SigningKey, [u8; 32]) {
899 let signing = SigningKey::from_bytes(&[7u8; 32]);
900 let public = signing.verifying_key().to_bytes();
901 (signing, public)
902 }
903
904 const SIGNER: &str = "did:webvh:QmSigner:example.com";
905
906 fn unsigned_commit() -> String {
909 commit_committed_by(&format!("{SIGNER}#key-0"))
910 }
911
912 fn commit_committed_by(committer: &str) -> String {
913 format!(
914 "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
915 author A U Thor <a@example.com> 1700000000 +0000\n\
916 committer A U Thor <{committer}> 1700000000 +0000\n\
917 \n\
918 a message\n"
919 )
920 }
921
922 fn signers_publishing(public: [u8; 32]) -> ResolvedSigners {
924 ResolvedSigners::from_keys([(SIGNER, vec![public])])
925 }
926
927 fn signed_commit(payload: &str, armored: &str) -> String {
930 let (headers, body) = payload.split_once("\n\n").unwrap();
931 let mut sig_header = String::from("gpgsig ");
932 let mut lines = armored.trim_end().split('\n');
933 sig_header.push_str(lines.next().unwrap());
934 for line in lines {
935 sig_header.push('\n');
936 sig_header.push(' ');
937 sig_header.push_str(line);
938 }
939 format!("{headers}\n{sig_header}\n\n{body}")
940 }
941
942 fn sign_commit(payload: &str, key: &SigningKey) -> String {
943 let armored = create_ssh_signature(
944 key,
945 &key.verifying_key(),
946 GIT_SSHSIG_NAMESPACE,
947 payload.as_bytes(),
948 )
949 .unwrap();
950 signed_commit(payload, &armored)
951 }
952
953 #[test]
954 fn split_returns_none_for_unsigned_commit() {
955 assert!(
956 split_signed_commit(unsigned_commit().as_bytes())
957 .unwrap()
958 .is_none()
959 );
960 }
961
962 #[test]
963 fn split_recovers_exact_payload_and_signature() {
964 let payload = unsigned_commit();
965 let (key, _) = test_key();
966 let commit = sign_commit(&payload, &key);
967
968 let (recovered_payload, pem) = split_signed_commit(commit.as_bytes()).unwrap().unwrap();
969 assert_eq!(recovered_payload, payload.as_bytes());
970 assert!(pem.starts_with("-----BEGIN SSH SIGNATURE-----"));
971 assert!(pem.trim_end().ends_with("-----END SSH SIGNATURE-----"));
972 }
973
974 #[test]
975 fn our_encoder_and_the_decoder_agree() {
976 let payload = unsigned_commit();
979 let (key, public) = test_key();
980 let commit = sign_commit(&payload, &key);
981
982 let check = check_commit_signature(commit.as_bytes(), &signers_publishing(public), None);
983 assert_eq!(
984 check,
985 SignatureCheck::Valid {
986 signer_did: SIGNER.to_string()
987 }
988 );
989 }
990
991 #[test]
992 fn the_signer_is_the_did_the_commit_claims() {
993 let payload = unsigned_commit();
996 let (key, public) = test_key();
997 let commit = sign_commit(&payload, &key);
998
999 let SignatureCheck::Valid { signer_did } =
1000 check_commit_signature(commit.as_bytes(), &signers_publishing(public), None)
1001 else {
1002 panic!("expected a valid signature");
1003 };
1004 assert_eq!(signer_did, SIGNER, "the bare DID, not the key id");
1005 }
1006
1007 #[test]
1008 fn claiming_a_did_that_does_not_publish_the_signing_key_fails() {
1009 let payload = commit_committed_by("did:webvh:QmVictim:example.com#key-0");
1013 let (key, public) = test_key();
1014 let commit = sign_commit(&payload, &key);
1015
1016 let signers = ResolvedSigners::from_keys([
1017 (SIGNER, vec![public]),
1018 ("did:webvh:QmVictim:example.com", vec![[0u8; 32]]),
1019 ]);
1020 assert_eq!(
1021 check_commit_signature(commit.as_bytes(), &signers, None),
1022 SignatureCheck::UnknownKey {
1023 did: "did:webvh:QmVictim:example.com".to_string(),
1024 fingerprint: hex::encode(public),
1025 },
1026 "a key published by another DID must not authenticate this claim"
1027 );
1028 }
1029
1030 #[test]
1031 fn a_committer_that_is_not_a_did_has_no_identity_to_check() {
1032 let payload = commit_committed_by("alice@example.com");
1033 let (key, public) = test_key();
1034 let commit = sign_commit(&payload, &key);
1035
1036 assert_eq!(
1037 check_commit_signature(commit.as_bytes(), &signers_publishing(public), None),
1038 SignatureCheck::NoSignerDid {
1039 committer: "alice@example.com".to_string()
1040 }
1041 );
1042 }
1043
1044 #[test]
1045 fn a_claimed_did_that_did_not_resolve_fails_closed() {
1046 let payload = unsigned_commit();
1047 let (key, _) = test_key();
1048 let commit = sign_commit(&payload, &key);
1049
1050 let mut signers = ResolvedSigners::default();
1051 signers
1052 .unresolved
1053 .insert(SIGNER.to_string(), "resolution failed: no such host".into());
1054
1055 assert_eq!(
1056 check_commit_signature(commit.as_bytes(), &signers, None),
1057 SignatureCheck::UnresolvedSigner {
1058 did: SIGNER.to_string(),
1059 error: "resolution failed: no such host".to_string(),
1060 },
1061 "an unresolvable signer is not a trusted one"
1062 );
1063 }
1064
1065 #[test]
1066 fn the_claim_is_read_from_the_bytes_the_signature_covers() {
1067 let payload = unsigned_commit();
1070 let (key, public) = test_key();
1071 let commit = sign_commit(&payload, &key).replace(
1072 &format!("{SIGNER}#key-0"),
1073 "did:webvh:QmOther:example.com#key-0",
1074 );
1075
1076 let signers = ResolvedSigners::from_keys([
1077 (SIGNER, vec![public]),
1078 ("did:webvh:QmOther:example.com", vec![public]),
1079 ]);
1080 assert_eq!(
1081 check_commit_signature(commit.as_bytes(), &signers, None),
1082 SignatureCheck::BadSignature {
1083 signer_did: "did:webvh:QmOther:example.com".to_string()
1084 },
1085 "tampering with the claimed identity breaks the signature over it"
1086 );
1087 }
1088
1089 #[test]
1090 fn distinct_claimed_dids_are_deduplicated_and_bounded() {
1091 let (key, _) = test_key();
1092 let commits: Vec<RangeCommit> = ["QmA", "QmB", "QmA"]
1093 .iter()
1094 .enumerate()
1095 .map(|(i, scid)| RangeCommit {
1096 sha: format!("{i:040}"),
1097 raw: sign_commit(
1098 &commit_committed_by(&format!("did:webvh:{scid}:example.com#key-0")),
1099 &key,
1100 )
1101 .into_bytes(),
1102 })
1103 .collect();
1104
1105 let claimed = claimed_signer_dids(&commits, 32).unwrap();
1106 assert_eq!(
1107 claimed,
1108 vec![
1109 "did:webvh:QmA:example.com".to_string(),
1110 "did:webvh:QmB:example.com".to_string(),
1111 ],
1112 "three commits, two identities, two resolutions"
1113 );
1114 assert!(
1115 claimed_signer_dids(&commits, 1).is_err(),
1116 "a range may not make CI resolve more hosts than the cap allows"
1117 );
1118 }
1119
1120 #[test]
1121 fn legacy_76_column_armor_still_verifies() {
1122 let payload = unsigned_commit();
1125 let (key, public) = test_key();
1126 let armored = create_ssh_signature(
1127 &key,
1128 &key.verifying_key(),
1129 GIT_SSHSIG_NAMESPACE,
1130 payload.as_bytes(),
1131 )
1132 .unwrap();
1133 let body: String = armored
1134 .lines()
1135 .filter(|l| !l.starts_with("-----"))
1136 .collect();
1137 let mut legacy = String::from("-----BEGIN SSH SIGNATURE-----\n");
1138 for chunk in body.as_bytes().chunks(76) {
1139 legacy.push_str(std::str::from_utf8(chunk).unwrap());
1140 legacy.push('\n');
1141 }
1142 legacy.push_str("-----END SSH SIGNATURE-----\n");
1143
1144 let commit = signed_commit(&payload, &legacy);
1145 assert_eq!(
1146 check_commit_signature(commit.as_bytes(), &signers_publishing(public), None),
1147 SignatureCheck::Valid {
1148 signer_did: SIGNER.to_string()
1149 }
1150 );
1151 }
1152
1153 #[test]
1154 fn a_key_the_claimed_did_does_not_publish_is_reported_with_its_fingerprint() {
1155 let payload = unsigned_commit();
1156 let (key, public) = test_key();
1157 let commit = sign_commit(&payload, &key);
1158
1159 let signers = ResolvedSigners::from_keys([(SIGNER, vec![[3u8; 32]])]);
1161 assert_eq!(
1162 check_commit_signature(commit.as_bytes(), &signers, None),
1163 SignatureCheck::UnknownKey {
1164 did: SIGNER.to_string(),
1165 fingerprint: hex::encode(public),
1166 }
1167 );
1168 }
1169
1170 #[test]
1171 fn tampered_payload_is_a_bad_signature() {
1172 let payload = unsigned_commit();
1173 let (key, public) = test_key();
1174 let commit = sign_commit(&payload, &key).replace("a message", "b message");
1175
1176 let check = check_commit_signature(commit.as_bytes(), &signers_publishing(public), None);
1177 assert_eq!(
1178 check,
1179 SignatureCheck::BadSignature {
1180 signer_did: SIGNER.to_string()
1181 }
1182 );
1183 }
1184
1185 #[test]
1186 fn unsigned_commit_is_unsigned() {
1187 let check = check_commit_signature(
1188 unsigned_commit().as_bytes(),
1189 &ResolvedSigners::default(),
1190 None,
1191 );
1192 assert_eq!(check, SignatureCheck::Unsigned);
1193 }
1194
1195 fn registry_document() -> serde_json::Value {
1202 serde_json::json!({
1203 "id": "did:webvh:QmRegistryScid:registry.example",
1204 "service": [
1205 {
1206 "id": "did:webvh:QmRegistryScid:registry.example#rest",
1207 "type": ["TRQPRest", "TrustRegistry"],
1208 "serviceEndpoint": {
1209 "uri": "https://registry.example",
1210 "profile": "https://trustoverip.org/profiles/trqp/v2"
1211 }
1212 },
1213 {
1214 "id": "did:webvh:QmRegistryScid:registry.example#didcomm",
1215 "type": "DIDCommMessaging",
1216 "serviceEndpoint": {
1217 "uri": "did:web:mediator.example",
1218 "accept": ["didcomm/v2"],
1219 "routingKeys": []
1220 }
1221 },
1222 {
1223 "id": "did:webvh:QmRegistryScid:registry.example#tsp",
1224 "type": "TSPTransport",
1225 "serviceEndpoint": "did:web:mediator.example"
1226 }
1227 ]
1228 })
1229 }
1230
1231 #[test]
1232 fn all_three_bindings_are_parsed_from_the_registry_document() {
1233 let caps = ServiceCapabilities::from_document(®istry_document());
1234 assert_eq!(caps.https.as_deref(), Some("https://registry.example"));
1235 assert_eq!(caps.tsp.as_deref(), Some("did:web:mediator.example"));
1236 assert_eq!(caps.didcomm.as_deref(), Some("did:web:mediator.example"));
1237 assert_eq!(
1238 caps.advertised(),
1239 vec![
1240 TransportKind::Tsp,
1241 TransportKind::Didcomm,
1242 TransportKind::Https
1243 ],
1244 "advertised order is the preference order: TSP, DIDComm, HTTPS"
1245 );
1246 }
1247
1248 #[test]
1249 fn selection_prefers_tsp_then_didcomm_then_https() {
1250 let caps = ServiceCapabilities::from_document(®istry_document());
1251 assert_eq!(
1253 caps.select(&[
1254 TransportKind::Tsp,
1255 TransportKind::Didcomm,
1256 TransportKind::Https
1257 ])
1258 .unwrap()
1259 .kind,
1260 TransportKind::Tsp
1261 );
1262 assert_eq!(
1264 caps.select(&[TransportKind::Didcomm, TransportKind::Https])
1265 .unwrap()
1266 .kind,
1267 TransportKind::Didcomm
1268 );
1269 }
1270
1271 #[test]
1272 fn this_build_selects_https_because_that_is_what_it_can_construct() {
1273 let compiled = TransportKind::compiled();
1279 assert_eq!(compiled, vec![TransportKind::Https]);
1280
1281 let choice = ServiceCapabilities::from_document(®istry_document())
1282 .select(&compiled)
1283 .unwrap();
1284 assert_eq!(choice.kind, TransportKind::Https);
1285 assert_eq!(choice.endpoint, "https://registry.example");
1286 }
1287
1288 #[test]
1289 fn a_registry_offering_no_binding_we_speak_fails_with_both_sides_named() {
1290 let doc = serde_json::json!({
1293 "id": "did:webvh:QmRegistryScid:registry.example",
1294 "service": [{
1295 "id": "did:webvh:QmRegistryScid:registry.example#tsp",
1296 "type": "TSPTransport",
1297 "serviceEndpoint": "did:web:mediator.example"
1298 }]
1299 });
1300 let error = ServiceCapabilities::from_document(&doc)
1301 .select(&[TransportKind::Https])
1302 .unwrap_err();
1303 let rendered = error.to_string();
1304 assert!(
1305 rendered.contains("https") && rendered.contains("tsp"),
1306 "the error must name both sides' transports: {rendered}"
1307 );
1308 }
1309
1310 #[test]
1311 fn a_document_advertising_nothing_yields_no_endpoint() {
1312 let caps = ServiceCapabilities::from_document(&serde_json::json!({
1315 "id": "did:webvh:QmRegistryScid:registry.example"
1316 }));
1317 assert_eq!(caps, ServiceCapabilities::default());
1318 assert!(caps.select(&TransportKind::compiled()).is_err());
1319 }
1320
1321 #[test]
1322 fn statuses_compose_signature_and_registry_decisions() {
1323 let did = "did:example:signer".to_string();
1324 let mut decisions = RegistryDecisions::new();
1325 decisions.insert(did.clone(), Ok(Some("example/repo".to_string())));
1326 assert!(
1327 status_of(
1328 SignatureCheck::Valid {
1329 signer_did: did.clone()
1330 },
1331 &decisions
1332 )
1333 .is_trusted()
1334 );
1335
1336 decisions.insert(did.clone(), Ok(None));
1337 assert_eq!(
1338 status_of(
1339 SignatureCheck::Valid {
1340 signer_did: did.clone()
1341 },
1342 &decisions
1343 ),
1344 CommitStatus::Unauthorized {
1345 signer_did: did.clone()
1346 }
1347 );
1348
1349 decisions.insert(did.clone(), Err("connect refused".to_string()));
1350 assert!(matches!(
1351 status_of(SignatureCheck::Valid { signer_did: did }, &decisions),
1352 CommitStatus::RegistryUnavailable { .. }
1353 ));
1354 }
1355
1356 #[test]
1359 fn a_verified_shortcut_is_the_name() {
1360 let name = signer_display_name(
1361 Some("example.com/@alice"),
1362 &["https://example.com/@alice".to_string()],
1363 )
1364 .unwrap();
1365 assert_eq!(name.name, "example.com/@alice");
1366 assert!(name.is_trusted());
1367 }
1368
1369 #[test]
1370 fn an_unchecked_claim_is_never_trusted() {
1371 let name =
1374 signer_display_name(None, &["https://mybank.com/@treasury".to_string()]).unwrap();
1375 assert_eq!(name.source, NameSource::AgentName { verified: false });
1376 assert!(!name.is_trusted());
1377 }
1378
1379 #[test]
1380 fn a_signer_claiming_nothing_has_no_name() {
1381 assert!(signer_display_name(None, &[]).is_none());
1382 }
1383
1384 #[test]
1385 fn an_unverified_name_renders_tagged_beside_its_did() {
1386 let did = "did:webvh:QmScidAbCdEfGhIj:example.com:ops";
1387 let report = TrustReport {
1388 ok: true,
1389 commits: Vec::new(),
1390 unresolved_signers: BTreeMap::new(),
1391 signer_names: BTreeMap::from([(
1392 did.to_string(),
1393 DisplayName::new(
1394 "mybank.com/@treasury",
1395 NameSource::AgentName { verified: false },
1396 ),
1397 )]),
1398 };
1399 let rendered = render_signer(&report, did);
1400 assert!(
1401 rendered.contains("unverified"),
1402 "an unchecked claim must never render as a plain name: {rendered}"
1403 );
1404 assert!(
1405 rendered.contains("example.com"),
1406 "the DID must stay visible beside the name: {rendered}"
1407 );
1408 }
1409
1410 #[test]
1411 fn an_unnamed_signer_falls_back_to_its_did() {
1412 let did = "did:webvh:QmScidAbCdEfGhIj:example.com:ops";
1413 let report = TrustReport {
1414 ok: true,
1415 commits: Vec::new(),
1416 unresolved_signers: BTreeMap::new(),
1417 signer_names: BTreeMap::new(),
1418 };
1419 assert_eq!(
1420 render_signer(&report, did),
1421 vta_sdk::display_name::shorten_did(did)
1422 );
1423 }
1424}