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_identity, conflicting_signer_dids, ed25519_keys_from_doc,
66 normalize_sshsig_armor, signer_did, 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 ConflictingSignerDids { trailer: String, committer: String },
154 UnresolvedSigner { did: String, error: String },
157 UnknownKey { did: String, fingerprint: String },
160 BadSignature { signer_did: String },
162 Unauthorized { signer_did: String },
164 RegistryUnavailable { signer_did: String, error: String },
167 PgpRejected { detail: String },
170 Exempt { fingerprint: String },
173 Trusted {
177 signer_did: String,
178 resource: String,
179 },
180}
181
182impl CommitStatus {
183 pub fn is_trusted(&self) -> bool {
185 matches!(self, Self::Trusted { .. })
186 }
187
188 pub fn passes(&self) -> bool {
190 matches!(self, Self::Trusted { .. } | Self::Exempt { .. })
191 }
192}
193
194#[derive(Debug, Clone, Serialize)]
196#[serde(rename_all = "camelCase")]
197pub struct CommitVerdict {
198 pub sha: String,
199 #[serde(flatten)]
200 pub status: CommitStatus,
201}
202
203#[derive(Debug, Serialize)]
205#[serde(rename_all = "camelCase")]
206pub struct TrustReport {
207 pub ok: bool,
208 pub commits: Vec<CommitVerdict>,
209 pub unresolved_signers: BTreeMap<String, String>,
213 pub signer_names: BTreeMap<String, DisplayName>,
217}
218
219#[derive(Debug, Default)]
225pub struct ResolvedSigners {
226 pub keys: BTreeMap<String, Vec<[u8; 32]>>,
230 pub unresolved: BTreeMap<String, String>,
232 pub names: NameBook,
234}
235
236impl ResolvedSigners {
237 #[must_use]
240 pub fn from_keys<I, D>(keys: I) -> Self
241 where
242 I: IntoIterator<Item = (D, Vec<[u8; 32]>)>,
243 D: Into<String>,
244 {
245 Self {
246 keys: keys.into_iter().map(|(did, k)| (did.into(), k)).collect(),
247 ..Self::default()
248 }
249 }
250
251 fn published(&self, did: &str) -> Option<&[[u8; 32]]> {
253 self.keys.get(did).map(Vec::as_slice)
254 }
255}
256
257pub async fn handle_verify_trust(mut args: VerifyTrustArgs) -> Result<i32> {
261 let exempt = load_exempt_keyring(&args)?;
262 let commits = read_range(&args.repo_dir, &args.range)?;
263 let claimed = claimed_signer_dids(&commits, args.max_signers)?;
264
265 let tdk = build_resolver(args.resolve_agent_names).await?;
268 if args.registry_url.is_none() {
269 args.registry_url = Some(resolve_registry_endpoint(&tdk, &args.registry_did).await?);
270 }
271 let signers = resolve_signer_keys(&tdk, &claimed).await?;
272
273 let report = verify_prepared(&args, &commits, &signers, exempt.as_ref()).await?;
274 print_report(&args, &report)?;
275 Ok(if report.ok { 0 } else { 1 })
276}
277
278#[derive(Debug, Clone)]
281pub struct RangeCommit {
282 pub sha: String,
283 pub raw: Vec<u8>,
284}
285
286pub fn read_range(repo_dir: &Path, range: &str) -> Result<Vec<RangeCommit>> {
288 list_commits(repo_dir, range)?
289 .into_iter()
290 .map(|sha| {
291 let raw = read_commit_raw(repo_dir, &sha)?;
292 Ok(RangeCommit { sha, raw })
293 })
294 .collect()
295}
296
297pub fn claimed_signer_dids(commits: &[RangeCommit], max_signers: usize) -> Result<Vec<String>> {
304 let dids: BTreeSet<String> = commits
305 .iter()
306 .filter(|commit| conflicting_signer_dids(&commit.raw).is_none())
307 .filter_map(|commit| signer_did(&commit.raw))
308 .collect();
309 if dids.len() > max_signers {
310 bail!(
311 "range claims {} distinct signer DIDs, over the limit of {max_signers}; \
312 each costs a resolution to a host the commit's author chose. Raise \
313 --max-signers only if this range is legitimately that wide.",
314 dids.len()
315 );
316 }
317 Ok(dids.into_iter().collect())
318}
319
320pub async fn verify_prepared(
323 args: &VerifyTrustArgs,
324 commits: &[RangeCommit],
325 signers: &ResolvedSigners,
326 exempt: Option<&ExemptKeyring>,
327) -> Result<TrustReport> {
328 let mut checked = Vec::with_capacity(commits.len());
330 let mut signer_dids = BTreeSet::new();
331 for commit in commits {
332 let signature = check_commit_signature(&commit.raw, signers, exempt);
333 if let SignatureCheck::Valid { signer_did } = &signature {
334 signer_dids.insert(signer_did.clone());
335 }
336 checked.push((commit.sha.clone(), signature));
337 }
338
339 let decisions = query_registry(args, &signer_dids).await?;
341
342 let commits: Vec<CommitVerdict> = checked
343 .into_iter()
344 .map(|(sha, signature)| CommitVerdict {
345 sha,
346 status: status_of(signature, &decisions),
347 })
348 .collect();
349
350 let signer_names = signer_dids
353 .iter()
354 .filter_map(|did| {
355 signers
356 .names
357 .get(did)
358 .map(|name| (did.clone(), name.clone()))
359 })
360 .collect();
361
362 let ok = commits.iter().all(|c| c.status.passes());
364 Ok(TrustReport {
365 ok,
366 commits,
367 unresolved_signers: signers.unresolved.clone(),
368 signer_names,
369 })
370}
371
372#[derive(Debug, Clone, PartialEq)]
376pub enum SignatureCheck {
377 Unsigned,
378 Malformed(String),
379 NoSignerDid { committer: String },
380 ConflictingSignerDids { trailer: String, committer: String },
381 UnresolvedSigner { did: String, error: String },
382 UnknownKey { did: String, fingerprint: String },
383 BadSignature { signer_did: String },
384 PgpRejected { detail: String },
385 Exempt { fingerprint: String },
386 Valid { signer_did: String },
387}
388
389pub fn check_commit_signature(
397 raw: &[u8],
398 signers: &ResolvedSigners,
399 exempt: Option<&ExemptKeyring>,
400) -> SignatureCheck {
401 let (payload, pem) = match split_signed_commit(raw) {
402 Ok(Some(parts)) => parts,
403 Ok(None) => return SignatureCheck::Unsigned,
404 Err(e) => return SignatureCheck::Malformed(e.to_string()),
405 };
406 if pem.starts_with("-----BEGIN PGP SIGNATURE-----") {
409 let Some(keyring) = exempt else {
410 return SignatureCheck::PgpRejected {
411 detail: "PGP-signed commit, but no exempt keyring is configured".to_string(),
412 };
413 };
414 return match keyring.verify(&pem, &payload) {
415 Ok(fingerprint) => SignatureCheck::Exempt { fingerprint },
416 Err(detail) => SignatureCheck::PgpRejected { detail },
417 };
418 }
419 let sig = match SshSig::from_pem(normalize_sshsig_armor(&pem).as_bytes()) {
420 Ok(sig) => sig,
421 Err(e) => return SignatureCheck::Malformed(format!("sshsig did not parse: {e}")),
422 };
423 let KeyData::Ed25519(embedded) = sig.public_key() else {
424 return SignatureCheck::Malformed(format!(
425 "unsupported signature algorithm: {}",
426 sig.algorithm()
427 ));
428 };
429 let key_bytes: [u8; 32] = embedded.0;
430
431 if let Some((trailer, committer)) = conflicting_signer_dids(&payload) {
434 return SignatureCheck::ConflictingSignerDids { trailer, committer };
435 }
436 let Some(claimed) = signer_did(&payload) else {
437 return SignatureCheck::NoSignerDid {
438 committer: committer_identity(&payload).unwrap_or_else(|| "(absent)".to_string()),
439 };
440 };
441 let Some(published) = signers.published(&claimed) else {
442 let error = signers
443 .unresolved
444 .get(&claimed)
445 .cloned()
446 .unwrap_or_else(|| "not resolved".to_string());
447 return SignatureCheck::UnresolvedSigner {
448 did: claimed,
449 error,
450 };
451 };
452 if !published.contains(&key_bytes) {
453 return SignatureCheck::UnknownKey {
454 did: claimed,
455 fingerprint: hex::encode(key_bytes),
456 };
457 }
458 let public_key = ssh_key::PublicKey::from(sig.public_key().clone());
459 match public_key.verify(GIT_SSHSIG_NAMESPACE, &payload, &sig) {
460 Ok(()) => SignatureCheck::Valid {
461 signer_did: claimed,
462 },
463 Err(_) => SignatureCheck::BadSignature {
464 signer_did: claimed,
465 },
466 }
467}
468
469fn load_exempt_keyring(args: &VerifyTrustArgs) -> Result<Option<ExemptKeyring>> {
471 let Some(path) = &args.exempt_keyring else {
472 return Ok(None);
473 };
474 let path = if path.is_absolute() {
475 path.clone()
476 } else {
477 args.repo_dir.join(path)
478 };
479 Ok(Some(ExemptKeyring::load(&path)?))
480}
481
482pub async fn build_resolver(resolve_agent_names: bool) -> Result<affinidi_tdk::TDK> {
507 use affinidi_did_resolver_cache_sdk::network_resolvers::HostPolicy;
508 use affinidi_tdk::TDK;
509 use affinidi_tdk::common::config::TDKConfig;
510 use affinidi_tdk::did_resolver::config::DIDCacheConfigBuilder;
511
512 TDK::new(
516 TDKConfig::builder()
517 .with_load_environment(false)
518 .with_did_resolver_config(
519 DIDCacheConfigBuilder::default()
520 .with_host_policy(HostPolicy::PublicOnly)
521 .with_resolve_shortcuts(resolve_agent_names)
522 .build(),
523 )
524 .build()
525 .context("TDK config")?,
526 None,
527 )
528 .await
529 .context("TDK init")
530}
531
532pub async fn resolve_registry_endpoint(
547 tdk: &affinidi_tdk::TDK,
548 registry_did: &str,
549) -> Result<String> {
550 let response = tdk
551 .did_resolver()
552 .resolve(registry_did)
553 .await
554 .map_err(|e| anyhow::anyhow!("could not resolve registry DID {registry_did}: {e}"))?;
555 let doc = serde_json::to_value(&response.doc)
556 .with_context(|| format!("DID document for {registry_did} did not serialize"))?;
557
558 let capabilities = ServiceCapabilities::from_document(&doc);
559 let choice = capabilities
560 .select(&TransportKind::compiled())
561 .with_context(|| format!("no usable Trust Registry transport on {registry_did}"))?;
562
563 match choice.kind {
564 TransportKind::Https => {
565 tracing::debug!(endpoint = %choice.endpoint, "discovered registry REST endpoint");
566 Ok(choice.endpoint)
567 }
568 kind => bail!(
572 "registry {registry_did} was selected for the {kind} binding, whose endpoint \
573 ({}) is a mediator DID rather than a URL; verify-trust can only query over \
574 HTTPS. Set --registry-url to a REST endpoint.",
575 choice.endpoint
576 ),
577 }
578}
579
580pub async fn resolve_signer_keys(
585 tdk: &affinidi_tdk::TDK,
586 dids: &[String],
587) -> Result<ResolvedSigners> {
588 let mut signers = ResolvedSigners::default();
589 for did in dids {
590 match tdk.did_resolver().resolve(did).await {
591 Ok(response) => {
592 let name = signer_display_name(
596 response.shortcut.as_ref().map(|s| s.label()),
597 &vta_sdk::display_name::agent_name::claimed_names(&response.doc),
598 );
599 if let Some(name) = name {
600 signers.names.insert(did.clone(), name);
601 }
602
603 let doc = serde_json::to_value(&response.doc)
604 .with_context(|| format!("DID document for {did} did not serialize"))?;
605 let published = ed25519_keys_from_doc(&doc);
606 if published.is_empty() {
607 signers.unresolved.insert(
612 did.clone(),
613 "DID document publishes no Ed25519 verification keys".to_string(),
614 );
615 } else {
616 signers.keys.insert(did.clone(), published);
617 }
618 }
619 Err(e) => {
620 signers
621 .unresolved
622 .insert(did.clone(), format!("resolution failed: {e}"));
623 }
624 }
625 }
626 Ok(signers)
627}
628
629fn signer_display_name(verified: Option<&str>, claimed: &[String]) -> Option<DisplayName> {
640 if let Some(name) = verified {
641 return Some(DisplayName::new(
642 name,
643 NameSource::AgentName { verified: true },
644 ));
645 }
646 claimed
647 .first()
648 .map(|name| DisplayName::new(name, NameSource::AgentName { verified: false }))
649}
650
651type RegistryDecisions = BTreeMap<String, Result<Option<String>, String>>;
657
658async fn query_registry(
660 args: &VerifyTrustArgs,
661 signer_dids: &BTreeSet<String>,
662) -> Result<RegistryDecisions> {
663 let mut decisions = RegistryDecisions::new();
664 if signer_dids.is_empty() {
665 return Ok(decisions);
666 }
667 let registry_url = args.registry_url.as_deref().context(
670 "registry URL not resolved: discover it from --registry-did or pass --registry-url",
671 )?;
672 let transport = HttpsTransport::new(HttpsTransportConfig::new(registry_url))?;
673 let client = TrqlClient::new(Arc::new(transport), &args.registry_did);
674 let mut resources = vec![args.resource.clone()];
676 if let Some(fallback) = &args.fallback_resource
677 && fallback != &args.resource
678 {
679 resources.push(fallback.clone());
680 }
681 for did in signer_dids {
682 let mut decision: Result<Option<String>, String> = Ok(None);
683 for resource in &resources {
684 let query = TrqpQuery::new(did, &args.vtc_did, &args.action, resource);
686 match client.authorization(query).await {
687 Ok(response) if response.authorized => {
688 decision = Ok(Some(resource.clone()));
689 break;
690 }
691 Ok(_) => {}
692 Err(e @ TrqlError::Rejected { .. }) => {
693 tracing::debug!("registry rejected the query for {did}: {e}");
697 }
698 Err(e) => {
699 decision = Err(e.to_string());
702 break;
703 }
704 }
705 }
706 decisions.insert(did.clone(), decision);
707 }
708 Ok(decisions)
709}
710
711fn status_of(signature: SignatureCheck, decisions: &RegistryDecisions) -> CommitStatus {
713 match signature {
714 SignatureCheck::Unsigned => CommitStatus::Unsigned,
715 SignatureCheck::Malformed(detail) => CommitStatus::Malformed(detail),
716 SignatureCheck::NoSignerDid { committer } => CommitStatus::NoSignerDid { committer },
717 SignatureCheck::ConflictingSignerDids { trailer, committer } => {
718 CommitStatus::ConflictingSignerDids { trailer, committer }
719 }
720 SignatureCheck::UnresolvedSigner { did, error } => {
721 CommitStatus::UnresolvedSigner { did, error }
722 }
723 SignatureCheck::UnknownKey { did, fingerprint } => {
724 CommitStatus::UnknownKey { did, fingerprint }
725 }
726 SignatureCheck::BadSignature { signer_did } => CommitStatus::BadSignature { signer_did },
727 SignatureCheck::PgpRejected { detail } => CommitStatus::PgpRejected { detail },
728 SignatureCheck::Exempt { fingerprint } => CommitStatus::Exempt { fingerprint },
729 SignatureCheck::Valid { signer_did } => match decisions.get(&signer_did) {
730 Some(Ok(Some(resource))) => CommitStatus::Trusted {
731 signer_did,
732 resource: resource.clone(),
733 },
734 Some(Ok(None)) => CommitStatus::Unauthorized { signer_did },
735 Some(Err(error)) => CommitStatus::RegistryUnavailable {
736 signer_did,
737 error: error.clone(),
738 },
739 None => CommitStatus::RegistryUnavailable {
740 signer_did,
741 error: "no registry decision recorded".to_string(),
742 },
743 },
744 }
745}
746
747pub fn list_commits(repo_dir: &Path, range: &str) -> Result<Vec<String>> {
755 if range.starts_with('-') {
756 bail!("--range must be a revision range, not an option: {range:?}");
757 }
758 let output = git(
759 repo_dir,
760 &["rev-list", "--reverse", "--end-of-options", range],
761 )?;
762 Ok(output.lines().map(str::to_string).collect())
763}
764
765pub fn read_commit_raw(repo_dir: &Path, sha: &str) -> Result<Vec<u8>> {
767 let output = Command::new("git")
768 .arg("-C")
769 .arg(repo_dir)
770 .args(["cat-file", "commit", sha])
771 .output()
772 .context("running git cat-file")?;
773 if !output.status.success() {
774 bail!(
775 "git cat-file commit {sha} failed: {}",
776 String::from_utf8_lossy(&output.stderr)
777 );
778 }
779 Ok(output.stdout)
780}
781
782fn git(repo_dir: &Path, args: &[&str]) -> Result<String> {
783 let output = Command::new("git")
784 .arg("-C")
785 .arg(repo_dir)
786 .args(args)
787 .output()
788 .with_context(|| format!("running git {}", args.join(" ")))?;
789 if !output.status.success() {
790 bail!(
791 "git {} failed: {}",
792 args.join(" "),
793 String::from_utf8_lossy(&output.stderr)
794 );
795 }
796 Ok(String::from_utf8(output.stdout)?.trim_end().to_string())
797}
798
799fn print_report(args: &VerifyTrustArgs, report: &TrustReport) -> Result<()> {
802 if args.json {
803 println!("{}", serde_json::to_string_pretty(report)?);
804 return Ok(());
805 }
806
807 let signer = |did: &str| render_signer(report, did);
811
812 for commit in &report.commits {
813 let short = &commit.sha[..commit.sha.len().min(12)];
814 match &commit.status {
815 CommitStatus::Trusted {
816 signer_did,
817 resource,
818 } => {
819 println!(
820 "TRUSTED {short} {} (via {resource})",
821 signer(signer_did)
822 );
823 }
824 CommitStatus::Exempt { fingerprint } => {
825 println!("EXEMPT {short} PGP-signed by exempt platform key {fingerprint}");
826 }
827 CommitStatus::PgpRejected { detail } => {
828 println!("PGP-REJECTED {short} {detail}");
829 }
830 CommitStatus::Unauthorized { signer_did } => {
831 println!(
832 "UNAUTHORIZED {short} {} is not authorized by the registry",
833 signer(signer_did)
834 );
835 }
836 CommitStatus::RegistryUnavailable { signer_did, error } => {
837 println!(
838 "UNAVAILABLE {short} signed by {}; registry check failed: {error}",
839 signer(signer_did)
840 );
841 }
842 CommitStatus::BadSignature { signer_did } => {
843 println!(
844 "BAD-SIG {short} signature by {} does not verify",
845 signer(signer_did)
846 );
847 }
848 CommitStatus::UnknownKey { did, fingerprint } => {
849 println!(
850 "UNKNOWN-KEY {short} {} publishes no key {fingerprint}",
851 signer(did)
852 );
853 }
854 CommitStatus::UnresolvedSigner { did, error } => {
855 println!("UNRESOLVED {short} claimed signer {did} did not resolve: {error}");
856 }
857 CommitStatus::NoSignerDid { committer } => {
858 println!("NO-SIGNER {short} committer <{committer}> is not a DID");
859 }
860 CommitStatus::ConflictingSignerDids { trailer, committer } => {
861 println!(
862 "CONFLICT {short} Signed-by-DID {trailer} disagrees with committer DID {committer}"
863 );
864 }
865 CommitStatus::Malformed(detail) => {
866 println!("MALFORMED {short} {detail}");
867 }
868 CommitStatus::Unsigned => {
869 println!("UNSIGNED {short} commit carries no signature");
870 }
871 }
872 }
873
874 print_signer_block(args, report);
875
876 let passing = report.commits.iter().filter(|c| c.status.passes()).count();
877 println!(
878 "{}: {passing}/{} commits pass",
879 if report.ok { "PASS" } else { "FAIL" },
880 report.commits.len()
881 );
882 Ok(())
883}
884
885fn render_signer(report: &TrustReport, did: &str) -> String {
889 match report.signer_names.get(did) {
890 Some(name) if name.is_trusted() => {
891 format!(
892 "{} ({})",
893 name.name,
894 vta_sdk::display_name::shorten_did(did)
895 )
896 }
897 Some(name) => format!(
898 "{}{} ({})",
899 name.name,
900 vta_sdk::display_name::UNVERIFIED_SUFFIX,
901 vta_sdk::display_name::shorten_did(did)
902 ),
903 None => vta_sdk::display_name::shorten_did(did),
904 }
905}
906
907fn print_signer_block(args: &VerifyTrustArgs, report: &TrustReport) {
912 if report.signer_names.is_empty() {
913 return;
914 }
915 println!();
916 println!("Signers:");
917 for (did, name) in &report.signer_names {
918 let tag = if name.is_trusted() {
919 String::new()
920 } else {
921 format!(" {}", vta_sdk::display_name::UNVERIFIED_SUFFIX.trim())
922 };
923 println!(" {}{tag}", name.name);
924 println!(" {did}");
925 }
926 if !args.resolve_agent_names && report.signer_names.values().any(|n| !n.is_trusted()) {
927 println!();
928 println!(
929 " Names above are claimed by the DID and were not checked. Pass \
930 --resolve-agent-names to resolve each claim back to its DID."
931 );
932 }
933}
934
935#[cfg(test)]
936mod tests {
937 #![allow(clippy::unwrap_used, clippy::expect_used)]
938
939 use super::*;
940 use ed25519_dalek::SigningKey;
941 use vgi_core::create_ssh_signature;
942
943 fn test_key() -> (SigningKey, [u8; 32]) {
944 let signing = SigningKey::from_bytes(&[7u8; 32]);
945 let public = signing.verifying_key().to_bytes();
946 (signing, public)
947 }
948
949 const SIGNER: &str = "did:webvh:QmSigner:example.com";
950
951 fn unsigned_commit() -> String {
954 commit_committed_by(&format!("{SIGNER}#key-0"))
955 }
956
957 fn commit_committed_by(committer: &str) -> String {
958 format!(
959 "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
960 author A U Thor <a@example.com> 1700000000 +0000\n\
961 committer A U Thor <{committer}> 1700000000 +0000\n\
962 \n\
963 a message\n"
964 )
965 }
966
967 fn signers_publishing(public: [u8; 32]) -> ResolvedSigners {
969 ResolvedSigners::from_keys([(SIGNER, vec![public])])
970 }
971
972 fn signed_commit(payload: &str, armored: &str) -> String {
975 let (headers, body) = payload.split_once("\n\n").unwrap();
976 let mut sig_header = String::from("gpgsig ");
977 let mut lines = armored.trim_end().split('\n');
978 sig_header.push_str(lines.next().unwrap());
979 for line in lines {
980 sig_header.push('\n');
981 sig_header.push(' ');
982 sig_header.push_str(line);
983 }
984 format!("{headers}\n{sig_header}\n\n{body}")
985 }
986
987 fn sign_commit(payload: &str, key: &SigningKey) -> String {
988 let armored = create_ssh_signature(
989 key,
990 &key.verifying_key(),
991 GIT_SSHSIG_NAMESPACE,
992 payload.as_bytes(),
993 )
994 .unwrap();
995 signed_commit(payload, &armored)
996 }
997
998 #[test]
999 fn split_returns_none_for_unsigned_commit() {
1000 assert!(
1001 split_signed_commit(unsigned_commit().as_bytes())
1002 .unwrap()
1003 .is_none()
1004 );
1005 }
1006
1007 #[test]
1008 fn split_recovers_exact_payload_and_signature() {
1009 let payload = unsigned_commit();
1010 let (key, _) = test_key();
1011 let commit = sign_commit(&payload, &key);
1012
1013 let (recovered_payload, pem) = split_signed_commit(commit.as_bytes()).unwrap().unwrap();
1014 assert_eq!(recovered_payload, payload.as_bytes());
1015 assert!(pem.starts_with("-----BEGIN SSH SIGNATURE-----"));
1016 assert!(pem.trim_end().ends_with("-----END SSH SIGNATURE-----"));
1017 }
1018
1019 #[test]
1020 fn our_encoder_and_the_decoder_agree() {
1021 let payload = unsigned_commit();
1024 let (key, public) = test_key();
1025 let commit = sign_commit(&payload, &key);
1026
1027 let check = check_commit_signature(commit.as_bytes(), &signers_publishing(public), None);
1028 assert_eq!(
1029 check,
1030 SignatureCheck::Valid {
1031 signer_did: SIGNER.to_string()
1032 }
1033 );
1034 }
1035
1036 #[test]
1037 fn the_signer_is_the_did_the_commit_claims() {
1038 let payload = unsigned_commit();
1041 let (key, public) = test_key();
1042 let commit = sign_commit(&payload, &key);
1043
1044 let SignatureCheck::Valid { signer_did } =
1045 check_commit_signature(commit.as_bytes(), &signers_publishing(public), None)
1046 else {
1047 panic!("expected a valid signature");
1048 };
1049 assert_eq!(signer_did, SIGNER, "the bare DID, not the key id");
1050 }
1051
1052 #[test]
1053 fn claiming_a_did_that_does_not_publish_the_signing_key_fails() {
1054 let payload = commit_committed_by("did:webvh:QmVictim:example.com#key-0");
1058 let (key, public) = test_key();
1059 let commit = sign_commit(&payload, &key);
1060
1061 let signers = ResolvedSigners::from_keys([
1062 (SIGNER, vec![public]),
1063 ("did:webvh:QmVictim:example.com", vec![[0u8; 32]]),
1064 ]);
1065 assert_eq!(
1066 check_commit_signature(commit.as_bytes(), &signers, None),
1067 SignatureCheck::UnknownKey {
1068 did: "did:webvh:QmVictim:example.com".to_string(),
1069 fingerprint: hex::encode(public),
1070 },
1071 "a key published by another DID must not authenticate this claim"
1072 );
1073 }
1074
1075 #[test]
1076 fn a_committer_that_is_not_a_did_has_no_identity_to_check() {
1077 let payload = commit_committed_by("alice@example.com");
1078 let (key, public) = test_key();
1079 let commit = sign_commit(&payload, &key);
1080
1081 assert_eq!(
1082 check_commit_signature(commit.as_bytes(), &signers_publishing(public), None),
1083 SignatureCheck::NoSignerDid {
1084 committer: "alice@example.com".to_string()
1085 }
1086 );
1087 }
1088
1089 #[test]
1090 fn conflicting_trailer_and_committer_dids_fail_closed() {
1091 let payload = format!(
1092 "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
1093 author A U Thor <a@example.com> 1700000000 +0000\n\
1094 committer A U Thor <did:webvh:QmCommitter:example.com#key-0> 1700000000 +0000\n\
1095 \n\
1096 a message\n\
1097 \n\
1098 Signed-by-DID: {SIGNER}#key-0\n"
1099 );
1100 let (key, public) = test_key();
1101 let commit = sign_commit(&payload, &key);
1102
1103 assert_eq!(
1104 check_commit_signature(commit.as_bytes(), &signers_publishing(public), None),
1105 SignatureCheck::ConflictingSignerDids {
1106 trailer: SIGNER.to_string(),
1107 committer: "did:webvh:QmCommitter:example.com".to_string(),
1108 }
1109 );
1110 }
1111
1112 #[test]
1113 fn a_claimed_did_that_did_not_resolve_fails_closed() {
1114 let payload = unsigned_commit();
1115 let (key, _) = test_key();
1116 let commit = sign_commit(&payload, &key);
1117
1118 let mut signers = ResolvedSigners::default();
1119 signers
1120 .unresolved
1121 .insert(SIGNER.to_string(), "resolution failed: no such host".into());
1122
1123 assert_eq!(
1124 check_commit_signature(commit.as_bytes(), &signers, None),
1125 SignatureCheck::UnresolvedSigner {
1126 did: SIGNER.to_string(),
1127 error: "resolution failed: no such host".to_string(),
1128 },
1129 "an unresolvable signer is not a trusted one"
1130 );
1131 }
1132
1133 #[test]
1134 fn the_claim_is_read_from_the_bytes_the_signature_covers() {
1135 let payload = unsigned_commit();
1138 let (key, public) = test_key();
1139 let commit = sign_commit(&payload, &key).replace(
1140 &format!("{SIGNER}#key-0"),
1141 "did:webvh:QmOther:example.com#key-0",
1142 );
1143
1144 let signers = ResolvedSigners::from_keys([
1145 (SIGNER, vec![public]),
1146 ("did:webvh:QmOther:example.com", vec![public]),
1147 ]);
1148 assert_eq!(
1149 check_commit_signature(commit.as_bytes(), &signers, None),
1150 SignatureCheck::BadSignature {
1151 signer_did: "did:webvh:QmOther:example.com".to_string()
1152 },
1153 "tampering with the claimed identity breaks the signature over it"
1154 );
1155 }
1156
1157 #[test]
1158 fn distinct_claimed_dids_are_deduplicated_and_bounded() {
1159 let (key, _) = test_key();
1160 let commits: Vec<RangeCommit> = ["QmA", "QmB", "QmA"]
1161 .iter()
1162 .enumerate()
1163 .map(|(i, scid)| RangeCommit {
1164 sha: format!("{i:040}"),
1165 raw: sign_commit(
1166 &commit_committed_by(&format!("did:webvh:{scid}:example.com#key-0")),
1167 &key,
1168 )
1169 .into_bytes(),
1170 })
1171 .collect();
1172
1173 let claimed = claimed_signer_dids(&commits, 32).unwrap();
1174 assert_eq!(
1175 claimed,
1176 vec![
1177 "did:webvh:QmA:example.com".to_string(),
1178 "did:webvh:QmB:example.com".to_string(),
1179 ],
1180 "three commits, two identities, two resolutions"
1181 );
1182 assert!(
1183 claimed_signer_dids(&commits, 1).is_err(),
1184 "a range may not make CI resolve more hosts than the cap allows"
1185 );
1186 }
1187
1188 #[test]
1189 fn legacy_76_column_armor_still_verifies() {
1190 let payload = unsigned_commit();
1193 let (key, public) = test_key();
1194 let armored = create_ssh_signature(
1195 &key,
1196 &key.verifying_key(),
1197 GIT_SSHSIG_NAMESPACE,
1198 payload.as_bytes(),
1199 )
1200 .unwrap();
1201 let body: String = armored
1202 .lines()
1203 .filter(|l| !l.starts_with("-----"))
1204 .collect();
1205 let mut legacy = String::from("-----BEGIN SSH SIGNATURE-----\n");
1206 for chunk in body.as_bytes().chunks(76) {
1207 legacy.push_str(std::str::from_utf8(chunk).unwrap());
1208 legacy.push('\n');
1209 }
1210 legacy.push_str("-----END SSH SIGNATURE-----\n");
1211
1212 let commit = signed_commit(&payload, &legacy);
1213 assert_eq!(
1214 check_commit_signature(commit.as_bytes(), &signers_publishing(public), None),
1215 SignatureCheck::Valid {
1216 signer_did: SIGNER.to_string()
1217 }
1218 );
1219 }
1220
1221 #[test]
1222 fn a_key_the_claimed_did_does_not_publish_is_reported_with_its_fingerprint() {
1223 let payload = unsigned_commit();
1224 let (key, public) = test_key();
1225 let commit = sign_commit(&payload, &key);
1226
1227 let signers = ResolvedSigners::from_keys([(SIGNER, vec![[3u8; 32]])]);
1229 assert_eq!(
1230 check_commit_signature(commit.as_bytes(), &signers, None),
1231 SignatureCheck::UnknownKey {
1232 did: SIGNER.to_string(),
1233 fingerprint: hex::encode(public),
1234 }
1235 );
1236 }
1237
1238 #[test]
1239 fn tampered_payload_is_a_bad_signature() {
1240 let payload = unsigned_commit();
1241 let (key, public) = test_key();
1242 let commit = sign_commit(&payload, &key).replace("a message", "b message");
1243
1244 let check = check_commit_signature(commit.as_bytes(), &signers_publishing(public), None);
1245 assert_eq!(
1246 check,
1247 SignatureCheck::BadSignature {
1248 signer_did: SIGNER.to_string()
1249 }
1250 );
1251 }
1252
1253 #[test]
1254 fn unsigned_commit_is_unsigned() {
1255 let check = check_commit_signature(
1256 unsigned_commit().as_bytes(),
1257 &ResolvedSigners::default(),
1258 None,
1259 );
1260 assert_eq!(check, SignatureCheck::Unsigned);
1261 }
1262
1263 fn registry_document() -> serde_json::Value {
1270 serde_json::json!({
1271 "id": "did:webvh:QmRegistryScid:registry.example",
1272 "service": [
1273 {
1274 "id": "did:webvh:QmRegistryScid:registry.example#rest",
1275 "type": ["TRQPRest", "TrustRegistry"],
1276 "serviceEndpoint": {
1277 "uri": "https://registry.example",
1278 "profile": "https://trustoverip.org/profiles/trqp/v2"
1279 }
1280 },
1281 {
1282 "id": "did:webvh:QmRegistryScid:registry.example#didcomm",
1283 "type": "DIDCommMessaging",
1284 "serviceEndpoint": {
1285 "uri": "did:web:mediator.example",
1286 "accept": ["didcomm/v2"],
1287 "routingKeys": []
1288 }
1289 },
1290 {
1291 "id": "did:webvh:QmRegistryScid:registry.example#tsp",
1292 "type": "TSPTransport",
1293 "serviceEndpoint": "did:web:mediator.example"
1294 }
1295 ]
1296 })
1297 }
1298
1299 #[test]
1300 fn all_three_bindings_are_parsed_from_the_registry_document() {
1301 let caps = ServiceCapabilities::from_document(®istry_document());
1302 assert_eq!(caps.https.as_deref(), Some("https://registry.example"));
1303 assert_eq!(caps.tsp.as_deref(), Some("did:web:mediator.example"));
1304 assert_eq!(caps.didcomm.as_deref(), Some("did:web:mediator.example"));
1305 assert_eq!(
1306 caps.advertised(),
1307 vec![
1308 TransportKind::Tsp,
1309 TransportKind::Didcomm,
1310 TransportKind::Https
1311 ],
1312 "advertised order is the preference order: TSP, DIDComm, HTTPS"
1313 );
1314 }
1315
1316 #[test]
1317 fn selection_prefers_tsp_then_didcomm_then_https() {
1318 let caps = ServiceCapabilities::from_document(®istry_document());
1319 assert_eq!(
1321 caps.select(&[
1322 TransportKind::Tsp,
1323 TransportKind::Didcomm,
1324 TransportKind::Https
1325 ])
1326 .unwrap()
1327 .kind,
1328 TransportKind::Tsp
1329 );
1330 assert_eq!(
1332 caps.select(&[TransportKind::Didcomm, TransportKind::Https])
1333 .unwrap()
1334 .kind,
1335 TransportKind::Didcomm
1336 );
1337 }
1338
1339 #[test]
1340 fn this_build_selects_https_because_that_is_what_it_can_construct() {
1341 let compiled = TransportKind::compiled();
1347 assert_eq!(compiled, vec![TransportKind::Https]);
1348
1349 let choice = ServiceCapabilities::from_document(®istry_document())
1350 .select(&compiled)
1351 .unwrap();
1352 assert_eq!(choice.kind, TransportKind::Https);
1353 assert_eq!(choice.endpoint, "https://registry.example");
1354 }
1355
1356 #[test]
1357 fn a_registry_offering_no_binding_we_speak_fails_with_both_sides_named() {
1358 let doc = serde_json::json!({
1361 "id": "did:webvh:QmRegistryScid:registry.example",
1362 "service": [{
1363 "id": "did:webvh:QmRegistryScid:registry.example#tsp",
1364 "type": "TSPTransport",
1365 "serviceEndpoint": "did:web:mediator.example"
1366 }]
1367 });
1368 let error = ServiceCapabilities::from_document(&doc)
1369 .select(&[TransportKind::Https])
1370 .unwrap_err();
1371 let rendered = error.to_string();
1372 assert!(
1373 rendered.contains("https") && rendered.contains("tsp"),
1374 "the error must name both sides' transports: {rendered}"
1375 );
1376 }
1377
1378 #[test]
1379 fn a_document_advertising_nothing_yields_no_endpoint() {
1380 let caps = ServiceCapabilities::from_document(&serde_json::json!({
1383 "id": "did:webvh:QmRegistryScid:registry.example"
1384 }));
1385 assert_eq!(caps, ServiceCapabilities::default());
1386 assert!(caps.select(&TransportKind::compiled()).is_err());
1387 }
1388
1389 #[test]
1390 fn statuses_compose_signature_and_registry_decisions() {
1391 let did = "did:example:signer".to_string();
1392 let mut decisions = RegistryDecisions::new();
1393 decisions.insert(did.clone(), Ok(Some("example/repo".to_string())));
1394 assert!(
1395 status_of(
1396 SignatureCheck::Valid {
1397 signer_did: did.clone()
1398 },
1399 &decisions
1400 )
1401 .is_trusted()
1402 );
1403
1404 decisions.insert(did.clone(), Ok(None));
1405 assert_eq!(
1406 status_of(
1407 SignatureCheck::Valid {
1408 signer_did: did.clone()
1409 },
1410 &decisions
1411 ),
1412 CommitStatus::Unauthorized {
1413 signer_did: did.clone()
1414 }
1415 );
1416
1417 decisions.insert(did.clone(), Err("connect refused".to_string()));
1418 assert!(matches!(
1419 status_of(SignatureCheck::Valid { signer_did: did }, &decisions),
1420 CommitStatus::RegistryUnavailable { .. }
1421 ));
1422 }
1423
1424 #[test]
1427 fn a_verified_shortcut_is_the_name() {
1428 let name = signer_display_name(
1429 Some("example.com/@alice"),
1430 &["https://example.com/@alice".to_string()],
1431 )
1432 .unwrap();
1433 assert_eq!(name.name, "example.com/@alice");
1434 assert!(name.is_trusted());
1435 }
1436
1437 #[test]
1438 fn an_unchecked_claim_is_never_trusted() {
1439 let name =
1442 signer_display_name(None, &["https://mybank.com/@treasury".to_string()]).unwrap();
1443 assert_eq!(name.source, NameSource::AgentName { verified: false });
1444 assert!(!name.is_trusted());
1445 }
1446
1447 #[test]
1448 fn a_signer_claiming_nothing_has_no_name() {
1449 assert!(signer_display_name(None, &[]).is_none());
1450 }
1451
1452 #[test]
1453 fn an_unverified_name_renders_tagged_beside_its_did() {
1454 let did = "did:webvh:QmScidAbCdEfGhIj:example.com:ops";
1455 let report = TrustReport {
1456 ok: true,
1457 commits: Vec::new(),
1458 unresolved_signers: BTreeMap::new(),
1459 signer_names: BTreeMap::from([(
1460 did.to_string(),
1461 DisplayName::new(
1462 "mybank.com/@treasury",
1463 NameSource::AgentName { verified: false },
1464 ),
1465 )]),
1466 };
1467 let rendered = render_signer(&report, did);
1468 assert!(
1469 rendered.contains("unverified"),
1470 "an unchecked claim must never render as a plain name: {rendered}"
1471 );
1472 assert!(
1473 rendered.contains("example.com"),
1474 "the DID must stay visible beside the name: {rendered}"
1475 );
1476 }
1477
1478 #[test]
1479 fn an_unnamed_signer_falls_back_to_its_did() {
1480 let did = "did:webvh:QmScidAbCdEfGhIj:example.com:ops";
1481 let report = TrustReport {
1482 ok: true,
1483 commits: Vec::new(),
1484 unresolved_signers: BTreeMap::new(),
1485 signer_names: BTreeMap::new(),
1486 };
1487 assert_eq!(
1488 render_signer(&report, did),
1489 vta_sdk::display_name::shorten_did(did)
1490 );
1491 }
1492}