pub mod pgp_exempt;
use std::collections::{BTreeMap, BTreeSet, HashMap};
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, TrqlClient, TrqlError, TrqpQuery};
use vgi_core::{
GIT_SSHSIG_NAMESPACE, 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 signers_file: PathBuf,
pub registry_url: String,
pub registry_did: String,
pub authority_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),
UnknownKey { 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: HashMap<[u8; 32], String>,
pub unresolved: BTreeMap<String, String>,
pub names: NameBook,
}
impl ResolvedSigners {
#[must_use]
pub fn from_keys(keys: HashMap<[u8; 32], String>) -> Self {
Self {
keys,
..Self::default()
}
}
}
pub async fn handle_verify_trust(args: VerifyTrustArgs) -> Result<i32> {
let signer_dids = load_signers(&args.repo_dir, &args.signers_file)?;
let exempt = load_exempt_keyring(&args)?;
let signers = resolve_signer_keys(&signer_dids, args.resolve_agent_names).await?;
let report = verify_with_keys(&args, &signers, exempt.as_ref()).await?;
print_report(&args, &report)?;
Ok(if report.ok { 0 } else { 1 })
}
pub async fn verify_with_keys(
args: &VerifyTrustArgs,
signers: &ResolvedSigners,
exempt: Option<&ExemptKeyring>,
) -> Result<TrustReport> {
let shas = list_commits(&args.repo_dir, &args.range)?;
let mut checked = Vec::with_capacity(shas.len());
let mut signer_dids = BTreeSet::new();
for sha in shas {
let raw = read_commit_raw(&args.repo_dir, &sha)?;
let signature = check_commit_signature(&raw, &signers.keys, exempt);
if let SignatureCheck::Valid { signer_did } = &signature {
signer_dids.insert(signer_did.clone());
}
checked.push((sha, 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),
UnknownKey { fingerprint: String },
BadSignature { signer_did: String },
PgpRejected { detail: String },
Exempt { fingerprint: String },
Valid { signer_did: String },
}
pub fn check_commit_signature(
raw: &[u8],
signer_keys: &HashMap<[u8; 32], String>,
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(signer_did) = signer_keys.get(&key_bytes) else {
return SignatureCheck::UnknownKey {
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: signer_did.clone(),
},
Err(_) => SignatureCheck::BadSignature {
signer_did: signer_did.clone(),
},
}
}
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 fn load_signers(repo_dir: &Path, signers_file: &Path) -> Result<Vec<String>> {
let path = if signers_file.is_absolute() {
signers_file.to_path_buf()
} else {
repo_dir.join(signers_file)
};
let text = std::fs::read_to_string(&path)
.with_context(|| format!("cannot read signer index {}", path.display()))?;
let dids = parse_signers(&text)?;
if dids.is_empty() {
bail!("signer index {} declares no DIDs", path.display());
}
Ok(dids)
}
pub fn parse_signers(text: &str) -> Result<Vec<String>> {
let mut dids = Vec::new();
for (number, line) in text.lines().enumerate() {
let entry = line.trim();
if entry.is_empty() || entry.starts_with('#') {
continue;
}
if !entry.starts_with("did:") {
bail!("signer index line {}: not a DID: {entry}", number + 1);
}
dids.push(entry.to_string());
}
Ok(dids)
}
pub async fn resolve_signer_keys(
dids: &[String],
resolve_agent_names: bool,
) -> Result<ResolvedSigners> {
use affinidi_tdk::TDK;
use affinidi_tdk::common::config::TDKConfig;
use affinidi_tdk::did_resolver::config::DIDCacheConfigBuilder;
let tdk = 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")?;
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(),
);
}
for key in published {
signers.keys.insert(key, did.clone());
}
}
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 transport = HttpsTransport::new(HttpsTransportConfig::new(&args.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.authority_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::UnknownKey { fingerprint } => CommitStatus::UnknownKey { 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 { fingerprint } => {
println!(
"UNKNOWN-KEY {short} key {fingerprint} is published by no declared signer"
);
}
CommitStatus::Malformed(detail) => {
println!("MALFORMED {short} {detail}");
}
CommitStatus::Unsigned => {
println!("UNSIGNED {short} commit carries no signature");
}
}
}
for (did, reason) in &report.unresolved_signers {
println!("WARNING declared signer {did}: {reason}");
}
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)
}
fn unsigned_commit() -> String {
"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
author A U Thor <a@example.com> 1700000000 +0000\n\
committer A U Thor <a@example.com> 1700000000 +0000\n\
\n\
a message\n"
.to_string()
}
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 keys = HashMap::from([(public, "did:example:signer".to_string())]);
let check = check_commit_signature(commit.as_bytes(), &keys, None);
assert_eq!(
check,
SignatureCheck::Valid {
signer_did: "did:example:signer".to_string()
}
);
}
#[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);
let keys = HashMap::from([(public, "did:example:signer".to_string())]);
assert_eq!(
check_commit_signature(commit.as_bytes(), &keys, None),
SignatureCheck::Valid {
signer_did: "did:example:signer".to_string()
}
);
}
#[test]
fn unknown_key_is_reported_with_fingerprint() {
let payload = unsigned_commit();
let (key, _) = test_key();
let commit = sign_commit(&payload, &key);
let check = check_commit_signature(commit.as_bytes(), &HashMap::new(), None);
assert!(matches!(check, SignatureCheck::UnknownKey { .. }));
}
#[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 keys = HashMap::from([(public, "did:example:signer".to_string())]);
let check = check_commit_signature(commit.as_bytes(), &keys, None);
assert_eq!(
check,
SignatureCheck::BadSignature {
signer_did: "did:example:signer".to_string()
}
);
}
#[test]
fn unsigned_commit_is_unsigned() {
let check = check_commit_signature(unsigned_commit().as_bytes(), &HashMap::new(), None);
assert_eq!(check, SignatureCheck::Unsigned);
}
#[test]
fn signers_index_parses_and_rejects_non_dids() {
let parsed =
parse_signers("# team\n did:webvh:abc:example.com \n\ndid:webvh:def:example.com\n")
.unwrap();
assert_eq!(parsed.len(), 2);
assert!(parse_signers("not-a-did\n").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)
);
}
}