pub mod pgp_exempt;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use serde::Serialize;
use ssh_key::{SshSig, public::KeyData};
use trql_client::{
HttpsTransport, HttpsTransportConfig, ServiceCapabilities, TransportKind, TrqlClient,
TrqlError, TrqpQuery,
};
use vgi_core::{
GIT_SSHSIG_NAMESPACE, committer_did, committer_identity, ed25519_keys_from_doc,
normalize_sshsig_armor, split_signed_commit,
};
use vta_sdk::display_name::{DisplayName, NameBook, NameSource};
use crate::pgp_exempt::ExemptKeyring;
#[derive(Debug, Clone)]
pub struct VerifyTrustArgs {
pub repo_dir: PathBuf,
pub range: String,
pub max_signers: usize,
pub registry_url: Option<String>,
pub registry_did: String,
pub vtc_did: String,
pub action: String,
pub resource: String,
pub fallback_resource: Option<String>,
pub exempt_keyring: Option<PathBuf>,
pub resolve_agent_names: bool,
pub json: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", tag = "status", content = "detail")]
pub enum CommitStatus {
Unsigned,
Malformed(String),
NoSignerDid { committer: String },
UnresolvedSigner { did: String, error: String },
UnknownKey { did: String, fingerprint: String },
BadSignature { signer_did: String },
Unauthorized { signer_did: String },
RegistryUnavailable { signer_did: String, error: String },
PgpRejected { detail: String },
Exempt { fingerprint: String },
Trusted {
signer_did: String,
resource: String,
},
}
impl CommitStatus {
pub fn is_trusted(&self) -> bool {
matches!(self, Self::Trusted { .. })
}
pub fn passes(&self) -> bool {
matches!(self, Self::Trusted { .. } | Self::Exempt { .. })
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CommitVerdict {
pub sha: String,
#[serde(flatten)]
pub status: CommitStatus,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TrustReport {
pub ok: bool,
pub commits: Vec<CommitVerdict>,
pub unresolved_signers: BTreeMap<String, String>,
pub signer_names: BTreeMap<String, DisplayName>,
}
#[derive(Debug, Default)]
pub struct ResolvedSigners {
pub keys: BTreeMap<String, Vec<[u8; 32]>>,
pub unresolved: BTreeMap<String, String>,
pub names: NameBook,
}
impl ResolvedSigners {
#[must_use]
pub fn from_keys<I, D>(keys: I) -> Self
where
I: IntoIterator<Item = (D, Vec<[u8; 32]>)>,
D: Into<String>,
{
Self {
keys: keys.into_iter().map(|(did, k)| (did.into(), k)).collect(),
..Self::default()
}
}
fn published(&self, did: &str) -> Option<&[[u8; 32]]> {
self.keys.get(did).map(Vec::as_slice)
}
}
pub async fn handle_verify_trust(mut args: VerifyTrustArgs) -> Result<i32> {
let exempt = load_exempt_keyring(&args)?;
let commits = read_range(&args.repo_dir, &args.range)?;
let claimed = claimed_signer_dids(&commits, args.max_signers)?;
let tdk = build_resolver(args.resolve_agent_names).await?;
if args.registry_url.is_none() {
args.registry_url = Some(resolve_registry_endpoint(&tdk, &args.registry_did).await?);
}
let signers = resolve_signer_keys(&tdk, &claimed).await?;
let report = verify_prepared(&args, &commits, &signers, exempt.as_ref()).await?;
print_report(&args, &report)?;
Ok(if report.ok { 0 } else { 1 })
}
#[derive(Debug, Clone)]
pub struct RangeCommit {
pub sha: String,
pub raw: Vec<u8>,
}
pub fn read_range(repo_dir: &Path, range: &str) -> Result<Vec<RangeCommit>> {
list_commits(repo_dir, range)?
.into_iter()
.map(|sha| {
let raw = read_commit_raw(repo_dir, &sha)?;
Ok(RangeCommit { sha, raw })
})
.collect()
}
pub fn claimed_signer_dids(commits: &[RangeCommit], max_signers: usize) -> Result<Vec<String>> {
let dids: BTreeSet<String> = commits
.iter()
.filter_map(|commit| committer_did(&commit.raw))
.collect();
if dids.len() > max_signers {
bail!(
"range claims {} distinct signer DIDs, over the limit of {max_signers}; \
each costs a resolution to a host the commit's author chose. Raise \
--max-signers only if this range is legitimately that wide.",
dids.len()
);
}
Ok(dids.into_iter().collect())
}
pub async fn verify_prepared(
args: &VerifyTrustArgs,
commits: &[RangeCommit],
signers: &ResolvedSigners,
exempt: Option<&ExemptKeyring>,
) -> Result<TrustReport> {
let mut checked = Vec::with_capacity(commits.len());
let mut signer_dids = BTreeSet::new();
for commit in commits {
let signature = check_commit_signature(&commit.raw, signers, exempt);
if let SignatureCheck::Valid { signer_did } = &signature {
signer_dids.insert(signer_did.clone());
}
checked.push((commit.sha.clone(), signature));
}
let decisions = query_registry(args, &signer_dids).await?;
let commits: Vec<CommitVerdict> = checked
.into_iter()
.map(|(sha, signature)| CommitVerdict {
sha,
status: status_of(signature, &decisions),
})
.collect();
let signer_names = signer_dids
.iter()
.filter_map(|did| {
signers
.names
.get(did)
.map(|name| (did.clone(), name.clone()))
})
.collect();
let ok = commits.iter().all(|c| c.status.passes());
Ok(TrustReport {
ok,
commits,
unresolved_signers: signers.unresolved.clone(),
signer_names,
})
}
#[derive(Debug, Clone, PartialEq)]
pub enum SignatureCheck {
Unsigned,
Malformed(String),
NoSignerDid { committer: String },
UnresolvedSigner { did: String, error: String },
UnknownKey { did: String, fingerprint: String },
BadSignature { signer_did: String },
PgpRejected { detail: String },
Exempt { fingerprint: String },
Valid { signer_did: String },
}
pub fn check_commit_signature(
raw: &[u8],
signers: &ResolvedSigners,
exempt: Option<&ExemptKeyring>,
) -> SignatureCheck {
let (payload, pem) = match split_signed_commit(raw) {
Ok(Some(parts)) => parts,
Ok(None) => return SignatureCheck::Unsigned,
Err(e) => return SignatureCheck::Malformed(e.to_string()),
};
if pem.starts_with("-----BEGIN PGP SIGNATURE-----") {
let Some(keyring) = exempt else {
return SignatureCheck::PgpRejected {
detail: "PGP-signed commit, but no exempt keyring is configured".to_string(),
};
};
return match keyring.verify(&pem, &payload) {
Ok(fingerprint) => SignatureCheck::Exempt { fingerprint },
Err(detail) => SignatureCheck::PgpRejected { detail },
};
}
let sig = match SshSig::from_pem(normalize_sshsig_armor(&pem).as_bytes()) {
Ok(sig) => sig,
Err(e) => return SignatureCheck::Malformed(format!("sshsig did not parse: {e}")),
};
let KeyData::Ed25519(embedded) = sig.public_key() else {
return SignatureCheck::Malformed(format!(
"unsupported signature algorithm: {}",
sig.algorithm()
));
};
let key_bytes: [u8; 32] = embedded.0;
let Some(claimed) = committer_did(&payload) else {
return SignatureCheck::NoSignerDid {
committer: committer_identity(&payload).unwrap_or_else(|| "(absent)".to_string()),
};
};
let Some(published) = signers.published(&claimed) else {
let error = signers
.unresolved
.get(&claimed)
.cloned()
.unwrap_or_else(|| "not resolved".to_string());
return SignatureCheck::UnresolvedSigner {
did: claimed,
error,
};
};
if !published.contains(&key_bytes) {
return SignatureCheck::UnknownKey {
did: claimed,
fingerprint: hex::encode(key_bytes),
};
}
let public_key = ssh_key::PublicKey::from(sig.public_key().clone());
match public_key.verify(GIT_SSHSIG_NAMESPACE, &payload, &sig) {
Ok(()) => SignatureCheck::Valid {
signer_did: claimed,
},
Err(_) => SignatureCheck::BadSignature {
signer_did: claimed,
},
}
}
fn load_exempt_keyring(args: &VerifyTrustArgs) -> Result<Option<ExemptKeyring>> {
let Some(path) = &args.exempt_keyring else {
return Ok(None);
};
let path = if path.is_absolute() {
path.clone()
} else {
args.repo_dir.join(path)
};
Ok(Some(ExemptKeyring::load(&path)?))
}
pub async fn build_resolver(resolve_agent_names: bool) -> Result<affinidi_tdk::TDK> {
use affinidi_tdk::TDK;
use affinidi_tdk::common::config::TDKConfig;
use affinidi_tdk::did_resolver::config::DIDCacheConfigBuilder;
TDK::new(
TDKConfig::builder()
.with_load_environment(false)
.with_did_resolver_config(
DIDCacheConfigBuilder::default()
.with_resolve_shortcuts(resolve_agent_names)
.build(),
)
.build()
.context("TDK config")?,
None,
)
.await
.context("TDK init")
}
pub async fn resolve_registry_endpoint(
tdk: &affinidi_tdk::TDK,
registry_did: &str,
) -> Result<String> {
let response = tdk
.did_resolver()
.resolve(registry_did)
.await
.map_err(|e| anyhow::anyhow!("could not resolve registry DID {registry_did}: {e}"))?;
let doc = serde_json::to_value(&response.doc)
.with_context(|| format!("DID document for {registry_did} did not serialize"))?;
let capabilities = ServiceCapabilities::from_document(&doc);
let choice = capabilities
.select(&TransportKind::compiled())
.with_context(|| format!("no usable Trust Registry transport on {registry_did}"))?;
match choice.kind {
TransportKind::Https => {
tracing::debug!(endpoint = %choice.endpoint, "discovered registry REST endpoint");
Ok(choice.endpoint)
}
kind => bail!(
"registry {registry_did} was selected for the {kind} binding, whose endpoint \
({}) is a mediator DID rather than a URL; verify-trust can only query over \
HTTPS. Set --registry-url to a REST endpoint.",
choice.endpoint
),
}
}
pub async fn resolve_signer_keys(
tdk: &affinidi_tdk::TDK,
dids: &[String],
) -> Result<ResolvedSigners> {
let mut signers = ResolvedSigners::default();
for did in dids {
match tdk.did_resolver().resolve(did).await {
Ok(response) => {
let name = signer_display_name(
response.shortcut.as_ref().map(|s| s.label()),
&vta_sdk::display_name::agent_name::claimed_names(&response.doc),
);
if let Some(name) = name {
signers.names.insert(did.clone(), name);
}
let doc = serde_json::to_value(&response.doc)
.with_context(|| format!("DID document for {did} did not serialize"))?;
let published = ed25519_keys_from_doc(&doc);
if published.is_empty() {
signers.unresolved.insert(
did.clone(),
"DID document publishes no Ed25519 verification keys".to_string(),
);
} else {
signers.keys.insert(did.clone(), published);
}
}
Err(e) => {
signers
.unresolved
.insert(did.clone(), format!("resolution failed: {e}"));
}
}
}
Ok(signers)
}
fn signer_display_name(verified: Option<&str>, claimed: &[String]) -> Option<DisplayName> {
if let Some(name) = verified {
return Some(DisplayName::new(
name,
NameSource::AgentName { verified: true },
));
}
claimed
.first()
.map(|name| DisplayName::new(name, NameSource::AgentName { verified: false }))
}
type RegistryDecisions = BTreeMap<String, Result<Option<String>, String>>;
async fn query_registry(
args: &VerifyTrustArgs,
signer_dids: &BTreeSet<String>,
) -> Result<RegistryDecisions> {
let mut decisions = RegistryDecisions::new();
if signer_dids.is_empty() {
return Ok(decisions);
}
let registry_url = args.registry_url.as_deref().context(
"registry URL not resolved: discover it from --registry-did or pass --registry-url",
)?;
let transport = HttpsTransport::new(HttpsTransportConfig::new(registry_url))?;
let client = TrqlClient::new(Arc::new(transport), &args.registry_did);
let mut resources = vec![args.resource.clone()];
if let Some(fallback) = &args.fallback_resource
&& fallback != &args.resource
{
resources.push(fallback.clone());
}
for did in signer_dids {
let mut decision: Result<Option<String>, String> = Ok(None);
for resource in &resources {
let query = TrqpQuery::new(did, &args.vtc_did, &args.action, resource);
match client.authorization(query).await {
Ok(response) if response.authorized => {
decision = Ok(Some(resource.clone()));
break;
}
Ok(_) => {}
Err(e @ TrqlError::Rejected { .. }) => {
tracing::debug!("registry rejected the query for {did}: {e}");
}
Err(e) => {
decision = Err(e.to_string());
break;
}
}
}
decisions.insert(did.clone(), decision);
}
Ok(decisions)
}
fn status_of(signature: SignatureCheck, decisions: &RegistryDecisions) -> CommitStatus {
match signature {
SignatureCheck::Unsigned => CommitStatus::Unsigned,
SignatureCheck::Malformed(detail) => CommitStatus::Malformed(detail),
SignatureCheck::NoSignerDid { committer } => CommitStatus::NoSignerDid { committer },
SignatureCheck::UnresolvedSigner { did, error } => {
CommitStatus::UnresolvedSigner { did, error }
}
SignatureCheck::UnknownKey { did, fingerprint } => {
CommitStatus::UnknownKey { did, fingerprint }
}
SignatureCheck::BadSignature { signer_did } => CommitStatus::BadSignature { signer_did },
SignatureCheck::PgpRejected { detail } => CommitStatus::PgpRejected { detail },
SignatureCheck::Exempt { fingerprint } => CommitStatus::Exempt { fingerprint },
SignatureCheck::Valid { signer_did } => match decisions.get(&signer_did) {
Some(Ok(Some(resource))) => CommitStatus::Trusted {
signer_did,
resource: resource.clone(),
},
Some(Ok(None)) => CommitStatus::Unauthorized { signer_did },
Some(Err(error)) => CommitStatus::RegistryUnavailable {
signer_did,
error: error.clone(),
},
None => CommitStatus::RegistryUnavailable {
signer_did,
error: "no registry decision recorded".to_string(),
},
},
}
}
pub fn list_commits(repo_dir: &Path, range: &str) -> Result<Vec<String>> {
let output = git(repo_dir, &["rev-list", "--reverse", range])?;
Ok(output.lines().map(str::to_string).collect())
}
pub fn read_commit_raw(repo_dir: &Path, sha: &str) -> Result<Vec<u8>> {
let output = Command::new("git")
.arg("-C")
.arg(repo_dir)
.args(["cat-file", "commit", sha])
.output()
.context("running git cat-file")?;
if !output.status.success() {
bail!(
"git cat-file commit {sha} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(output.stdout)
}
fn git(repo_dir: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.arg("-C")
.arg(repo_dir)
.args(args)
.output()
.with_context(|| format!("running git {}", args.join(" ")))?;
if !output.status.success() {
bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8(output.stdout)?.trim_end().to_string())
}
fn print_report(args: &VerifyTrustArgs, report: &TrustReport) -> Result<()> {
if args.json {
println!("{}", serde_json::to_string_pretty(report)?);
return Ok(());
}
let signer = |did: &str| render_signer(report, did);
for commit in &report.commits {
let short = &commit.sha[..commit.sha.len().min(12)];
match &commit.status {
CommitStatus::Trusted {
signer_did,
resource,
} => {
println!(
"TRUSTED {short} {} (via {resource})",
signer(signer_did)
);
}
CommitStatus::Exempt { fingerprint } => {
println!("EXEMPT {short} PGP-signed by exempt platform key {fingerprint}");
}
CommitStatus::PgpRejected { detail } => {
println!("PGP-REJECTED {short} {detail}");
}
CommitStatus::Unauthorized { signer_did } => {
println!(
"UNAUTHORIZED {short} {} is not authorized by the registry",
signer(signer_did)
);
}
CommitStatus::RegistryUnavailable { signer_did, error } => {
println!(
"UNAVAILABLE {short} signed by {}; registry check failed: {error}",
signer(signer_did)
);
}
CommitStatus::BadSignature { signer_did } => {
println!(
"BAD-SIG {short} signature by {} does not verify",
signer(signer_did)
);
}
CommitStatus::UnknownKey { did, fingerprint } => {
println!(
"UNKNOWN-KEY {short} {} publishes no key {fingerprint}",
signer(did)
);
}
CommitStatus::UnresolvedSigner { did, error } => {
println!("UNRESOLVED {short} claimed signer {did} did not resolve: {error}");
}
CommitStatus::NoSignerDid { committer } => {
println!("NO-SIGNER {short} committer <{committer}> is not a DID");
}
CommitStatus::Malformed(detail) => {
println!("MALFORMED {short} {detail}");
}
CommitStatus::Unsigned => {
println!("UNSIGNED {short} commit carries no signature");
}
}
}
print_signer_block(args, report);
let passing = report.commits.iter().filter(|c| c.status.passes()).count();
println!(
"{}: {passing}/{} commits pass",
if report.ok { "PASS" } else { "FAIL" },
report.commits.len()
);
Ok(())
}
fn render_signer(report: &TrustReport, did: &str) -> String {
match report.signer_names.get(did) {
Some(name) if name.is_trusted() => {
format!(
"{} ({})",
name.name,
vta_sdk::display_name::shorten_did(did)
)
}
Some(name) => format!(
"{}{} ({})",
name.name,
vta_sdk::display_name::UNVERIFIED_SUFFIX,
vta_sdk::display_name::shorten_did(did)
),
None => vta_sdk::display_name::shorten_did(did),
}
}
fn print_signer_block(args: &VerifyTrustArgs, report: &TrustReport) {
if report.signer_names.is_empty() {
return;
}
println!();
println!("Signers:");
for (did, name) in &report.signer_names {
let tag = if name.is_trusted() {
String::new()
} else {
format!(" {}", vta_sdk::display_name::UNVERIFIED_SUFFIX.trim())
};
println!(" {}{tag}", name.name);
println!(" {did}");
}
if !args.resolve_agent_names && report.signer_names.values().any(|n| !n.is_trusted()) {
println!();
println!(
" Names above are claimed by the DID and were not checked. Pass \
--resolve-agent-names to resolve each claim back to its DID."
);
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use ed25519_dalek::SigningKey;
use vgi_core::create_ssh_signature;
fn test_key() -> (SigningKey, [u8; 32]) {
let signing = SigningKey::from_bytes(&[7u8; 32]);
let public = signing.verifying_key().to_bytes();
(signing, public)
}
const SIGNER: &str = "did:webvh:QmSigner:example.com";
fn unsigned_commit() -> String {
commit_committed_by(&format!("{SIGNER}#key-0"))
}
fn commit_committed_by(committer: &str) -> String {
format!(
"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
author A U Thor <a@example.com> 1700000000 +0000\n\
committer A U Thor <{committer}> 1700000000 +0000\n\
\n\
a message\n"
)
}
fn signers_publishing(public: [u8; 32]) -> ResolvedSigners {
ResolvedSigners::from_keys([(SIGNER, vec![public])])
}
fn signed_commit(payload: &str, armored: &str) -> String {
let (headers, body) = payload.split_once("\n\n").unwrap();
let mut sig_header = String::from("gpgsig ");
let mut lines = armored.trim_end().split('\n');
sig_header.push_str(lines.next().unwrap());
for line in lines {
sig_header.push('\n');
sig_header.push(' ');
sig_header.push_str(line);
}
format!("{headers}\n{sig_header}\n\n{body}")
}
fn sign_commit(payload: &str, key: &SigningKey) -> String {
let armored = create_ssh_signature(
key,
&key.verifying_key(),
GIT_SSHSIG_NAMESPACE,
payload.as_bytes(),
)
.unwrap();
signed_commit(payload, &armored)
}
#[test]
fn split_returns_none_for_unsigned_commit() {
assert!(
split_signed_commit(unsigned_commit().as_bytes())
.unwrap()
.is_none()
);
}
#[test]
fn split_recovers_exact_payload_and_signature() {
let payload = unsigned_commit();
let (key, _) = test_key();
let commit = sign_commit(&payload, &key);
let (recovered_payload, pem) = split_signed_commit(commit.as_bytes()).unwrap().unwrap();
assert_eq!(recovered_payload, payload.as_bytes());
assert!(pem.starts_with("-----BEGIN SSH SIGNATURE-----"));
assert!(pem.trim_end().ends_with("-----END SSH SIGNATURE-----"));
}
#[test]
fn our_encoder_and_the_decoder_agree() {
let payload = unsigned_commit();
let (key, public) = test_key();
let commit = sign_commit(&payload, &key);
let check = check_commit_signature(commit.as_bytes(), &signers_publishing(public), None);
assert_eq!(
check,
SignatureCheck::Valid {
signer_did: SIGNER.to_string()
}
);
}
#[test]
fn the_signer_is_the_did_the_commit_claims() {
let payload = unsigned_commit();
let (key, public) = test_key();
let commit = sign_commit(&payload, &key);
let SignatureCheck::Valid { signer_did } =
check_commit_signature(commit.as_bytes(), &signers_publishing(public), None)
else {
panic!("expected a valid signature");
};
assert_eq!(signer_did, SIGNER, "the bare DID, not the key id");
}
#[test]
fn claiming_a_did_that_does_not_publish_the_signing_key_fails() {
let payload = commit_committed_by("did:webvh:QmVictim:example.com#key-0");
let (key, public) = test_key();
let commit = sign_commit(&payload, &key);
let signers = ResolvedSigners::from_keys([
(SIGNER, vec![public]),
("did:webvh:QmVictim:example.com", vec![[0u8; 32]]),
]);
assert_eq!(
check_commit_signature(commit.as_bytes(), &signers, None),
SignatureCheck::UnknownKey {
did: "did:webvh:QmVictim:example.com".to_string(),
fingerprint: hex::encode(public),
},
"a key published by another DID must not authenticate this claim"
);
}
#[test]
fn a_committer_that_is_not_a_did_has_no_identity_to_check() {
let payload = commit_committed_by("alice@example.com");
let (key, public) = test_key();
let commit = sign_commit(&payload, &key);
assert_eq!(
check_commit_signature(commit.as_bytes(), &signers_publishing(public), None),
SignatureCheck::NoSignerDid {
committer: "alice@example.com".to_string()
}
);
}
#[test]
fn a_claimed_did_that_did_not_resolve_fails_closed() {
let payload = unsigned_commit();
let (key, _) = test_key();
let commit = sign_commit(&payload, &key);
let mut signers = ResolvedSigners::default();
signers
.unresolved
.insert(SIGNER.to_string(), "resolution failed: no such host".into());
assert_eq!(
check_commit_signature(commit.as_bytes(), &signers, None),
SignatureCheck::UnresolvedSigner {
did: SIGNER.to_string(),
error: "resolution failed: no such host".to_string(),
},
"an unresolvable signer is not a trusted one"
);
}
#[test]
fn the_claim_is_read_from_the_bytes_the_signature_covers() {
let payload = unsigned_commit();
let (key, public) = test_key();
let commit = sign_commit(&payload, &key).replace(
&format!("{SIGNER}#key-0"),
"did:webvh:QmOther:example.com#key-0",
);
let signers = ResolvedSigners::from_keys([
(SIGNER, vec![public]),
("did:webvh:QmOther:example.com", vec![public]),
]);
assert_eq!(
check_commit_signature(commit.as_bytes(), &signers, None),
SignatureCheck::BadSignature {
signer_did: "did:webvh:QmOther:example.com".to_string()
},
"tampering with the claimed identity breaks the signature over it"
);
}
#[test]
fn distinct_claimed_dids_are_deduplicated_and_bounded() {
let (key, _) = test_key();
let commits: Vec<RangeCommit> = ["QmA", "QmB", "QmA"]
.iter()
.enumerate()
.map(|(i, scid)| RangeCommit {
sha: format!("{i:040}"),
raw: sign_commit(
&commit_committed_by(&format!("did:webvh:{scid}:example.com#key-0")),
&key,
)
.into_bytes(),
})
.collect();
let claimed = claimed_signer_dids(&commits, 32).unwrap();
assert_eq!(
claimed,
vec![
"did:webvh:QmA:example.com".to_string(),
"did:webvh:QmB:example.com".to_string(),
],
"three commits, two identities, two resolutions"
);
assert!(
claimed_signer_dids(&commits, 1).is_err(),
"a range may not make CI resolve more hosts than the cap allows"
);
}
#[test]
fn legacy_76_column_armor_still_verifies() {
let payload = unsigned_commit();
let (key, public) = test_key();
let armored = create_ssh_signature(
&key,
&key.verifying_key(),
GIT_SSHSIG_NAMESPACE,
payload.as_bytes(),
)
.unwrap();
let body: String = armored
.lines()
.filter(|l| !l.starts_with("-----"))
.collect();
let mut legacy = String::from("-----BEGIN SSH SIGNATURE-----\n");
for chunk in body.as_bytes().chunks(76) {
legacy.push_str(std::str::from_utf8(chunk).unwrap());
legacy.push('\n');
}
legacy.push_str("-----END SSH SIGNATURE-----\n");
let commit = signed_commit(&payload, &legacy);
assert_eq!(
check_commit_signature(commit.as_bytes(), &signers_publishing(public), None),
SignatureCheck::Valid {
signer_did: SIGNER.to_string()
}
);
}
#[test]
fn a_key_the_claimed_did_does_not_publish_is_reported_with_its_fingerprint() {
let payload = unsigned_commit();
let (key, public) = test_key();
let commit = sign_commit(&payload, &key);
let signers = ResolvedSigners::from_keys([(SIGNER, vec![[3u8; 32]])]);
assert_eq!(
check_commit_signature(commit.as_bytes(), &signers, None),
SignatureCheck::UnknownKey {
did: SIGNER.to_string(),
fingerprint: hex::encode(public),
}
);
}
#[test]
fn tampered_payload_is_a_bad_signature() {
let payload = unsigned_commit();
let (key, public) = test_key();
let commit = sign_commit(&payload, &key).replace("a message", "b message");
let check = check_commit_signature(commit.as_bytes(), &signers_publishing(public), None);
assert_eq!(
check,
SignatureCheck::BadSignature {
signer_did: SIGNER.to_string()
}
);
}
#[test]
fn unsigned_commit_is_unsigned() {
let check = check_commit_signature(
unsigned_commit().as_bytes(),
&ResolvedSigners::default(),
None,
);
assert_eq!(check, SignatureCheck::Unsigned);
}
fn registry_document() -> serde_json::Value {
serde_json::json!({
"id": "did:webvh:QmRegistryScid:registry.example",
"service": [
{
"id": "did:webvh:QmRegistryScid:registry.example#rest",
"type": ["TRQPRest", "TrustRegistry"],
"serviceEndpoint": {
"uri": "https://registry.example",
"profile": "https://trustoverip.org/profiles/trp/v2"
}
},
{
"id": "did:webvh:QmRegistryScid:registry.example#didcomm",
"type": "DIDCommMessaging",
"serviceEndpoint": {
"uri": "did:web:mediator.example",
"accept": ["didcomm/v2"],
"routingKeys": []
}
},
{
"id": "did:webvh:QmRegistryScid:registry.example#tsp",
"type": "TSPTransport",
"serviceEndpoint": "did:web:mediator.example"
}
]
})
}
#[test]
fn all_three_bindings_are_parsed_from_the_registry_document() {
let caps = ServiceCapabilities::from_document(®istry_document());
assert_eq!(caps.https.as_deref(), Some("https://registry.example"));
assert_eq!(caps.tsp.as_deref(), Some("did:web:mediator.example"));
assert_eq!(caps.didcomm.as_deref(), Some("did:web:mediator.example"));
assert_eq!(
caps.advertised(),
vec![
TransportKind::Tsp,
TransportKind::Didcomm,
TransportKind::Https
],
"advertised order is the preference order: TSP, DIDComm, HTTPS"
);
}
#[test]
fn selection_prefers_tsp_then_didcomm_then_https() {
let caps = ServiceCapabilities::from_document(®istry_document());
assert_eq!(
caps.select(&[
TransportKind::Tsp,
TransportKind::Didcomm,
TransportKind::Https
])
.unwrap()
.kind,
TransportKind::Tsp
);
assert_eq!(
caps.select(&[TransportKind::Didcomm, TransportKind::Https])
.unwrap()
.kind,
TransportKind::Didcomm
);
}
#[test]
fn this_build_selects_https_because_that_is_what_it_can_construct() {
let compiled = TransportKind::compiled();
assert_eq!(compiled, vec![TransportKind::Https]);
let choice = ServiceCapabilities::from_document(®istry_document())
.select(&compiled)
.unwrap();
assert_eq!(choice.kind, TransportKind::Https);
assert_eq!(choice.endpoint, "https://registry.example");
}
#[test]
fn a_registry_offering_no_binding_we_speak_fails_with_both_sides_named() {
let doc = serde_json::json!({
"id": "did:webvh:QmRegistryScid:registry.example",
"service": [{
"id": "did:webvh:QmRegistryScid:registry.example#tsp",
"type": "TSPTransport",
"serviceEndpoint": "did:web:mediator.example"
}]
});
let error = ServiceCapabilities::from_document(&doc)
.select(&[TransportKind::Https])
.unwrap_err();
let rendered = error.to_string();
assert!(
rendered.contains("https") && rendered.contains("tsp"),
"the error must name both sides' transports: {rendered}"
);
}
#[test]
fn a_document_advertising_nothing_yields_no_endpoint() {
let caps = ServiceCapabilities::from_document(&serde_json::json!({
"id": "did:webvh:QmRegistryScid:registry.example"
}));
assert_eq!(caps, ServiceCapabilities::default());
assert!(caps.select(&TransportKind::compiled()).is_err());
}
#[test]
fn statuses_compose_signature_and_registry_decisions() {
let did = "did:example:signer".to_string();
let mut decisions = RegistryDecisions::new();
decisions.insert(did.clone(), Ok(Some("example/repo".to_string())));
assert!(
status_of(
SignatureCheck::Valid {
signer_did: did.clone()
},
&decisions
)
.is_trusted()
);
decisions.insert(did.clone(), Ok(None));
assert_eq!(
status_of(
SignatureCheck::Valid {
signer_did: did.clone()
},
&decisions
),
CommitStatus::Unauthorized {
signer_did: did.clone()
}
);
decisions.insert(did.clone(), Err("connect refused".to_string()));
assert!(matches!(
status_of(SignatureCheck::Valid { signer_did: did }, &decisions),
CommitStatus::RegistryUnavailable { .. }
));
}
#[test]
fn a_verified_shortcut_is_the_name() {
let name = signer_display_name(
Some("example.com/@alice"),
&["https://example.com/@alice".to_string()],
)
.unwrap();
assert_eq!(name.name, "example.com/@alice");
assert!(name.is_trusted());
}
#[test]
fn an_unchecked_claim_is_never_trusted() {
let name =
signer_display_name(None, &["https://mybank.com/@treasury".to_string()]).unwrap();
assert_eq!(name.source, NameSource::AgentName { verified: false });
assert!(!name.is_trusted());
}
#[test]
fn a_signer_claiming_nothing_has_no_name() {
assert!(signer_display_name(None, &[]).is_none());
}
#[test]
fn an_unverified_name_renders_tagged_beside_its_did() {
let did = "did:webvh:QmScidAbCdEfGhIj:example.com:ops";
let report = TrustReport {
ok: true,
commits: Vec::new(),
unresolved_signers: BTreeMap::new(),
signer_names: BTreeMap::from([(
did.to_string(),
DisplayName::new(
"mybank.com/@treasury",
NameSource::AgentName { verified: false },
),
)]),
};
let rendered = render_signer(&report, did);
assert!(
rendered.contains("unverified"),
"an unchecked claim must never render as a plain name: {rendered}"
);
assert!(
rendered.contains("example.com"),
"the DID must stay visible beside the name: {rendered}"
);
}
#[test]
fn an_unnamed_signer_falls_back_to_its_did() {
let did = "did:webvh:QmScidAbCdEfGhIj:example.com:ops";
let report = TrustReport {
ok: true,
commits: Vec::new(),
unresolved_signers: BTreeMap::new(),
signer_names: BTreeMap::new(),
};
assert_eq!(
render_signer(&report, did),
vta_sdk::display_name::shorten_did(did)
);
}
}