1pub mod pgp_exempt;
26
27use std::collections::{BTreeMap, BTreeSet, HashMap};
28use std::path::{Path, PathBuf};
29use std::process::Command;
30use std::sync::Arc;
31
32use anyhow::{Context, Result, bail};
33use serde::Serialize;
34use ssh_key::{SshSig, public::KeyData};
35use trql_client::{HttpsTransport, HttpsTransportConfig, TrqlClient, TrqlError, TrqpQuery};
36use vgi_core::{
37 GIT_SSHSIG_NAMESPACE, ed25519_keys_from_doc, normalize_sshsig_armor, split_signed_commit,
38};
39
40use crate::pgp_exempt::ExemptKeyring;
41
42#[derive(Debug, Clone)]
44pub struct VerifyTrustArgs {
45 pub repo_dir: PathBuf,
47 pub range: String,
49 pub signers_file: PathBuf,
51 pub registry_url: String,
53 pub registry_did: String,
55 pub authority_did: String,
57 pub action: String,
59 pub resource: String,
61 pub fallback_resource: Option<String>,
67 pub exempt_keyring: Option<PathBuf>,
71 pub json: bool,
73}
74
75#[derive(Debug, Clone, PartialEq, Serialize)]
77#[serde(rename_all = "camelCase", tag = "status", content = "detail")]
78pub enum CommitStatus {
79 Unsigned,
81 Malformed(String),
83 UnknownKey { fingerprint: String },
85 BadSignature { signer_did: String },
87 Unauthorized { signer_did: String },
89 RegistryUnavailable { signer_did: String, error: String },
92 PgpRejected { detail: String },
95 Exempt { fingerprint: String },
98 Trusted {
102 signer_did: String,
103 resource: String,
104 },
105}
106
107impl CommitStatus {
108 pub fn is_trusted(&self) -> bool {
110 matches!(self, Self::Trusted { .. })
111 }
112
113 pub fn passes(&self) -> bool {
115 matches!(self, Self::Trusted { .. } | Self::Exempt { .. })
116 }
117}
118
119#[derive(Debug, Clone, Serialize)]
121#[serde(rename_all = "camelCase")]
122pub struct CommitVerdict {
123 pub sha: String,
124 #[serde(flatten)]
125 pub status: CommitStatus,
126}
127
128#[derive(Debug, Serialize)]
130#[serde(rename_all = "camelCase")]
131pub struct TrustReport {
132 pub ok: bool,
133 pub commits: Vec<CommitVerdict>,
134 pub unresolved_signers: BTreeMap<String, String>,
137}
138
139pub async fn handle_verify_trust(args: VerifyTrustArgs) -> Result<i32> {
142 let signer_dids = load_signers(&args.repo_dir, &args.signers_file)?;
143 let exempt = load_exempt_keyring(&args)?;
144 let (keys, unresolved) = resolve_signer_keys(&signer_dids).await?;
145 let report = verify_with_keys(&args, &keys, exempt.as_ref(), unresolved).await?;
146 print_report(&args, &report)?;
147 Ok(if report.ok { 0 } else { 1 })
148}
149
150pub async fn verify_with_keys(
153 args: &VerifyTrustArgs,
154 signer_keys: &HashMap<[u8; 32], String>,
155 exempt: Option<&ExemptKeyring>,
156 unresolved_signers: BTreeMap<String, String>,
157) -> Result<TrustReport> {
158 let shas = list_commits(&args.repo_dir, &args.range)?;
159
160 let mut checked = Vec::with_capacity(shas.len());
162 let mut signer_dids = BTreeSet::new();
163 for sha in shas {
164 let raw = read_commit_raw(&args.repo_dir, &sha)?;
165 let signature = check_commit_signature(&raw, signer_keys, exempt);
166 if let SignatureCheck::Valid { signer_did } = &signature {
167 signer_dids.insert(signer_did.clone());
168 }
169 checked.push((sha, signature));
170 }
171
172 let decisions = query_registry(args, &signer_dids).await?;
174
175 let commits: Vec<CommitVerdict> = checked
176 .into_iter()
177 .map(|(sha, signature)| CommitVerdict {
178 sha,
179 status: status_of(signature, &decisions),
180 })
181 .collect();
182
183 let ok = commits.iter().all(|c| c.status.passes());
185 Ok(TrustReport {
186 ok,
187 commits,
188 unresolved_signers,
189 })
190}
191
192#[derive(Debug, Clone, PartialEq)]
196pub enum SignatureCheck {
197 Unsigned,
198 Malformed(String),
199 UnknownKey { fingerprint: String },
200 BadSignature { signer_did: String },
201 PgpRejected { detail: String },
202 Exempt { fingerprint: String },
203 Valid { signer_did: String },
204}
205
206pub fn check_commit_signature(
208 raw: &[u8],
209 signer_keys: &HashMap<[u8; 32], String>,
210 exempt: Option<&ExemptKeyring>,
211) -> SignatureCheck {
212 let (payload, pem) = match split_signed_commit(raw) {
213 Ok(Some(parts)) => parts,
214 Ok(None) => return SignatureCheck::Unsigned,
215 Err(e) => return SignatureCheck::Malformed(e.to_string()),
216 };
217 if pem.starts_with("-----BEGIN PGP SIGNATURE-----") {
220 let Some(keyring) = exempt else {
221 return SignatureCheck::PgpRejected {
222 detail: "PGP-signed commit, but no exempt keyring is configured".to_string(),
223 };
224 };
225 return match keyring.verify(&pem, &payload) {
226 Ok(fingerprint) => SignatureCheck::Exempt { fingerprint },
227 Err(detail) => SignatureCheck::PgpRejected { detail },
228 };
229 }
230 let sig = match SshSig::from_pem(normalize_sshsig_armor(&pem).as_bytes()) {
231 Ok(sig) => sig,
232 Err(e) => return SignatureCheck::Malformed(format!("sshsig did not parse: {e}")),
233 };
234 let KeyData::Ed25519(embedded) = sig.public_key() else {
235 return SignatureCheck::Malformed(format!(
236 "unsupported signature algorithm: {}",
237 sig.algorithm()
238 ));
239 };
240 let key_bytes: [u8; 32] = embedded.0;
241 let Some(signer_did) = signer_keys.get(&key_bytes) else {
242 return SignatureCheck::UnknownKey {
243 fingerprint: hex::encode(key_bytes),
244 };
245 };
246 let public_key = ssh_key::PublicKey::from(sig.public_key().clone());
247 match public_key.verify(GIT_SSHSIG_NAMESPACE, &payload, &sig) {
248 Ok(()) => SignatureCheck::Valid {
249 signer_did: signer_did.clone(),
250 },
251 Err(_) => SignatureCheck::BadSignature {
252 signer_did: signer_did.clone(),
253 },
254 }
255}
256
257fn load_exempt_keyring(args: &VerifyTrustArgs) -> Result<Option<ExemptKeyring>> {
259 let Some(path) = &args.exempt_keyring else {
260 return Ok(None);
261 };
262 let path = if path.is_absolute() {
263 path.clone()
264 } else {
265 args.repo_dir.join(path)
266 };
267 Ok(Some(ExemptKeyring::load(&path)?))
268}
269
270pub fn load_signers(repo_dir: &Path, signers_file: &Path) -> Result<Vec<String>> {
276 let path = if signers_file.is_absolute() {
277 signers_file.to_path_buf()
278 } else {
279 repo_dir.join(signers_file)
280 };
281 let text = std::fs::read_to_string(&path)
282 .with_context(|| format!("cannot read signer index {}", path.display()))?;
283 let dids = parse_signers(&text)?;
284 if dids.is_empty() {
285 bail!("signer index {} declares no DIDs", path.display());
286 }
287 Ok(dids)
288}
289
290pub fn parse_signers(text: &str) -> Result<Vec<String>> {
293 let mut dids = Vec::new();
294 for (number, line) in text.lines().enumerate() {
295 let entry = line.trim();
296 if entry.is_empty() || entry.starts_with('#') {
297 continue;
298 }
299 if !entry.starts_with("did:") {
300 bail!("signer index line {}: not a DID: {entry}", number + 1);
301 }
302 dids.push(entry.to_string());
303 }
304 Ok(dids)
305}
306
307pub async fn resolve_signer_keys(
311 dids: &[String],
312) -> Result<(HashMap<[u8; 32], String>, BTreeMap<String, String>)> {
313 use affinidi_tdk::TDK;
314 use affinidi_tdk::common::config::TDKConfig;
315
316 let tdk = TDK::new(
317 TDKConfig::builder()
318 .with_load_environment(false)
319 .build()
320 .context("TDK config")?,
321 None,
322 )
323 .await
324 .context("TDK init")?;
325
326 let mut keys = HashMap::new();
327 let mut unresolved = BTreeMap::new();
328 for did in dids {
329 match tdk.did_resolver().resolve(did).await {
330 Ok(response) => {
331 let doc = serde_json::to_value(&response.doc)
332 .with_context(|| format!("DID document for {did} did not serialize"))?;
333 let published = ed25519_keys_from_doc(&doc);
334 if published.is_empty() {
335 unresolved.insert(
336 did.clone(),
337 "DID document publishes no Ed25519 verification keys".to_string(),
338 );
339 }
340 for key in published {
341 keys.insert(key, did.clone());
342 }
343 }
344 Err(e) => {
345 unresolved.insert(did.clone(), format!("resolution failed: {e}"));
346 }
347 }
348 }
349 Ok((keys, unresolved))
350}
351
352type RegistryDecisions = BTreeMap<String, Result<Option<String>, String>>;
358
359async fn query_registry(
361 args: &VerifyTrustArgs,
362 signer_dids: &BTreeSet<String>,
363) -> Result<RegistryDecisions> {
364 let mut decisions = RegistryDecisions::new();
365 if signer_dids.is_empty() {
366 return Ok(decisions);
367 }
368 let transport = HttpsTransport::new(HttpsTransportConfig::new(&args.registry_url))?;
369 let client = TrqlClient::new(Arc::new(transport), &args.registry_did);
370 let mut resources = vec![args.resource.clone()];
372 if let Some(fallback) = &args.fallback_resource
373 && fallback != &args.resource
374 {
375 resources.push(fallback.clone());
376 }
377 for did in signer_dids {
378 let mut decision: Result<Option<String>, String> = Ok(None);
379 for resource in &resources {
380 let query = TrqpQuery::new(did, &args.authority_did, &args.action, resource);
381 match client.authorization(query).await {
382 Ok(response) if response.authorized => {
383 decision = Ok(Some(resource.clone()));
384 break;
385 }
386 Ok(_) => {}
387 Err(e @ TrqlError::Rejected { .. }) => {
388 tracing::debug!("registry rejected the query for {did}: {e}");
392 }
393 Err(e) => {
394 decision = Err(e.to_string());
397 break;
398 }
399 }
400 }
401 decisions.insert(did.clone(), decision);
402 }
403 Ok(decisions)
404}
405
406fn status_of(signature: SignatureCheck, decisions: &RegistryDecisions) -> CommitStatus {
408 match signature {
409 SignatureCheck::Unsigned => CommitStatus::Unsigned,
410 SignatureCheck::Malformed(detail) => CommitStatus::Malformed(detail),
411 SignatureCheck::UnknownKey { fingerprint } => CommitStatus::UnknownKey { fingerprint },
412 SignatureCheck::BadSignature { signer_did } => CommitStatus::BadSignature { signer_did },
413 SignatureCheck::PgpRejected { detail } => CommitStatus::PgpRejected { detail },
414 SignatureCheck::Exempt { fingerprint } => CommitStatus::Exempt { fingerprint },
415 SignatureCheck::Valid { signer_did } => match decisions.get(&signer_did) {
416 Some(Ok(Some(resource))) => CommitStatus::Trusted {
417 signer_did,
418 resource: resource.clone(),
419 },
420 Some(Ok(None)) => CommitStatus::Unauthorized { signer_did },
421 Some(Err(error)) => CommitStatus::RegistryUnavailable {
422 signer_did,
423 error: error.clone(),
424 },
425 None => CommitStatus::RegistryUnavailable {
426 signer_did,
427 error: "no registry decision recorded".to_string(),
428 },
429 },
430 }
431}
432
433pub fn list_commits(repo_dir: &Path, range: &str) -> Result<Vec<String>> {
437 let output = git(repo_dir, &["rev-list", "--reverse", range])?;
438 Ok(output.lines().map(str::to_string).collect())
439}
440
441pub fn read_commit_raw(repo_dir: &Path, sha: &str) -> Result<Vec<u8>> {
443 let output = Command::new("git")
444 .arg("-C")
445 .arg(repo_dir)
446 .args(["cat-file", "commit", sha])
447 .output()
448 .context("running git cat-file")?;
449 if !output.status.success() {
450 bail!(
451 "git cat-file commit {sha} failed: {}",
452 String::from_utf8_lossy(&output.stderr)
453 );
454 }
455 Ok(output.stdout)
456}
457
458fn git(repo_dir: &Path, args: &[&str]) -> Result<String> {
459 let output = Command::new("git")
460 .arg("-C")
461 .arg(repo_dir)
462 .args(args)
463 .output()
464 .with_context(|| format!("running git {}", args.join(" ")))?;
465 if !output.status.success() {
466 bail!(
467 "git {} failed: {}",
468 args.join(" "),
469 String::from_utf8_lossy(&output.stderr)
470 );
471 }
472 Ok(String::from_utf8(output.stdout)?.trim_end().to_string())
473}
474
475fn print_report(args: &VerifyTrustArgs, report: &TrustReport) -> Result<()> {
478 if args.json {
479 println!("{}", serde_json::to_string_pretty(report)?);
480 return Ok(());
481 }
482 for commit in &report.commits {
483 let short = &commit.sha[..commit.sha.len().min(12)];
484 match &commit.status {
485 CommitStatus::Trusted {
486 signer_did,
487 resource,
488 } => {
489 println!("TRUSTED {short} {signer_did} (via {resource})");
490 }
491 CommitStatus::Exempt { fingerprint } => {
492 println!("EXEMPT {short} PGP-signed by exempt platform key {fingerprint}");
493 }
494 CommitStatus::PgpRejected { detail } => {
495 println!("PGP-REJECTED {short} {detail}");
496 }
497 CommitStatus::Unauthorized { signer_did } => {
498 println!("UNAUTHORIZED {short} {signer_did} is not authorized by the registry");
499 }
500 CommitStatus::RegistryUnavailable { signer_did, error } => {
501 println!(
502 "UNAVAILABLE {short} signed by {signer_did}; registry check failed: {error}"
503 );
504 }
505 CommitStatus::BadSignature { signer_did } => {
506 println!("BAD-SIG {short} signature by {signer_did} does not verify");
507 }
508 CommitStatus::UnknownKey { fingerprint } => {
509 println!(
510 "UNKNOWN-KEY {short} key {fingerprint} is published by no declared signer"
511 );
512 }
513 CommitStatus::Malformed(detail) => {
514 println!("MALFORMED {short} {detail}");
515 }
516 CommitStatus::Unsigned => {
517 println!("UNSIGNED {short} commit carries no signature");
518 }
519 }
520 }
521 for (did, reason) in &report.unresolved_signers {
522 println!("WARNING declared signer {did}: {reason}");
523 }
524 let passing = report.commits.iter().filter(|c| c.status.passes()).count();
525 println!(
526 "{}: {passing}/{} commits pass",
527 if report.ok { "PASS" } else { "FAIL" },
528 report.commits.len()
529 );
530 Ok(())
531}
532
533#[cfg(test)]
534mod tests {
535 #![allow(clippy::unwrap_used, clippy::expect_used)]
536
537 use super::*;
538 use ed25519_dalek::SigningKey;
539 use vgi_core::create_ssh_signature;
540
541 fn test_key() -> (SigningKey, [u8; 32]) {
542 let signing = SigningKey::from_bytes(&[7u8; 32]);
543 let public = signing.verifying_key().to_bytes();
544 (signing, public)
545 }
546
547 fn unsigned_commit() -> String {
548 "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\
549 author A U Thor <a@example.com> 1700000000 +0000\n\
550 committer A U Thor <a@example.com> 1700000000 +0000\n\
551 \n\
552 a message\n"
553 .to_string()
554 }
555
556 fn signed_commit(payload: &str, armored: &str) -> String {
559 let (headers, body) = payload.split_once("\n\n").unwrap();
560 let mut sig_header = String::from("gpgsig ");
561 let mut lines = armored.trim_end().split('\n');
562 sig_header.push_str(lines.next().unwrap());
563 for line in lines {
564 sig_header.push('\n');
565 sig_header.push(' ');
566 sig_header.push_str(line);
567 }
568 format!("{headers}\n{sig_header}\n\n{body}")
569 }
570
571 fn sign_commit(payload: &str, key: &SigningKey) -> String {
572 let armored = create_ssh_signature(
573 key,
574 &key.verifying_key(),
575 GIT_SSHSIG_NAMESPACE,
576 payload.as_bytes(),
577 )
578 .unwrap();
579 signed_commit(payload, &armored)
580 }
581
582 #[test]
583 fn split_returns_none_for_unsigned_commit() {
584 assert!(
585 split_signed_commit(unsigned_commit().as_bytes())
586 .unwrap()
587 .is_none()
588 );
589 }
590
591 #[test]
592 fn split_recovers_exact_payload_and_signature() {
593 let payload = unsigned_commit();
594 let (key, _) = test_key();
595 let commit = sign_commit(&payload, &key);
596
597 let (recovered_payload, pem) = split_signed_commit(commit.as_bytes()).unwrap().unwrap();
598 assert_eq!(recovered_payload, payload.as_bytes());
599 assert!(pem.starts_with("-----BEGIN SSH SIGNATURE-----"));
600 assert!(pem.trim_end().ends_with("-----END SSH SIGNATURE-----"));
601 }
602
603 #[test]
604 fn our_encoder_and_the_decoder_agree() {
605 let payload = unsigned_commit();
608 let (key, public) = test_key();
609 let commit = sign_commit(&payload, &key);
610 let keys = HashMap::from([(public, "did:example:signer".to_string())]);
611
612 let check = check_commit_signature(commit.as_bytes(), &keys, None);
613 assert_eq!(
614 check,
615 SignatureCheck::Valid {
616 signer_did: "did:example:signer".to_string()
617 }
618 );
619 }
620
621 #[test]
622 fn legacy_76_column_armor_still_verifies() {
623 let payload = unsigned_commit();
626 let (key, public) = test_key();
627 let armored = create_ssh_signature(
628 &key,
629 &key.verifying_key(),
630 GIT_SSHSIG_NAMESPACE,
631 payload.as_bytes(),
632 )
633 .unwrap();
634 let body: String = armored
635 .lines()
636 .filter(|l| !l.starts_with("-----"))
637 .collect();
638 let mut legacy = String::from("-----BEGIN SSH SIGNATURE-----\n");
639 for chunk in body.as_bytes().chunks(76) {
640 legacy.push_str(std::str::from_utf8(chunk).unwrap());
641 legacy.push('\n');
642 }
643 legacy.push_str("-----END SSH SIGNATURE-----\n");
644
645 let commit = signed_commit(&payload, &legacy);
646 let keys = HashMap::from([(public, "did:example:signer".to_string())]);
647 assert_eq!(
648 check_commit_signature(commit.as_bytes(), &keys, None),
649 SignatureCheck::Valid {
650 signer_did: "did:example:signer".to_string()
651 }
652 );
653 }
654
655 #[test]
656 fn unknown_key_is_reported_with_fingerprint() {
657 let payload = unsigned_commit();
658 let (key, _) = test_key();
659 let commit = sign_commit(&payload, &key);
660
661 let check = check_commit_signature(commit.as_bytes(), &HashMap::new(), None);
662 assert!(matches!(check, SignatureCheck::UnknownKey { .. }));
663 }
664
665 #[test]
666 fn tampered_payload_is_a_bad_signature() {
667 let payload = unsigned_commit();
668 let (key, public) = test_key();
669 let commit = sign_commit(&payload, &key).replace("a message", "b message");
670 let keys = HashMap::from([(public, "did:example:signer".to_string())]);
671
672 let check = check_commit_signature(commit.as_bytes(), &keys, None);
673 assert_eq!(
674 check,
675 SignatureCheck::BadSignature {
676 signer_did: "did:example:signer".to_string()
677 }
678 );
679 }
680
681 #[test]
682 fn unsigned_commit_is_unsigned() {
683 let check = check_commit_signature(unsigned_commit().as_bytes(), &HashMap::new(), None);
684 assert_eq!(check, SignatureCheck::Unsigned);
685 }
686
687 #[test]
688 fn signers_index_parses_and_rejects_non_dids() {
689 let parsed =
690 parse_signers("# team\n did:webvh:abc:example.com \n\ndid:webvh:def:example.com\n")
691 .unwrap();
692 assert_eq!(parsed.len(), 2);
693 assert!(parse_signers("not-a-did\n").is_err());
694 }
695
696 #[test]
697 fn statuses_compose_signature_and_registry_decisions() {
698 let did = "did:example:signer".to_string();
699 let mut decisions = RegistryDecisions::new();
700 decisions.insert(did.clone(), Ok(Some("example/repo".to_string())));
701 assert!(
702 status_of(
703 SignatureCheck::Valid {
704 signer_did: did.clone()
705 },
706 &decisions
707 )
708 .is_trusted()
709 );
710
711 decisions.insert(did.clone(), Ok(None));
712 assert_eq!(
713 status_of(
714 SignatureCheck::Valid {
715 signer_did: did.clone()
716 },
717 &decisions
718 ),
719 CommitStatus::Unauthorized {
720 signer_did: did.clone()
721 }
722 );
723
724 decisions.insert(did.clone(), Err("connect refused".to_string()));
725 assert!(matches!(
726 status_of(SignatureCheck::Valid { signer_did: did }, &decisions),
727 CommitStatus::RegistryUnavailable { .. }
728 ));
729 }
730}