Skip to main content

verify_trust/
lib.rs

1//! `verify-trust`: verify a git commit range against the VTC Trust Registry.
2//!
3//! For every commit in a range this module answers two questions, in order:
4//!
5//! 1. **Who signed it, cryptographically?** The commit's `gpgsig` header is
6//!    parsed as a PROTOCOL.sshsig blob; the Ed25519 public key embedded in it
7//!    is matched against the keys published in the DID documents of the
8//!    repository's declared signers, and the signature is verified over the
9//!    exact bytes git signed.
10//! 2. **Is that DID trusted, right now?** The signer DID is checked against
11//!    the Trust Registry with a TRQP authorization query
12//!    (`{entity: signer, authority, action, resource}`) via `trql-client`.
13//!
14//! The signer set comes from a committed index file (default `.did-signers`,
15//! one DID per line) that lists *identities, not keys* — keys are resolved
16//! from each DID document at verification time, so key rotation never
17//! requires touching the repository, and revoking a signer is a registry
18//! operation that takes effect on the next run.
19//!
20//! Failure is closed at every layer: an unsigned commit, a signature by an
21//! unpublished key, a cryptographically invalid signature, an unauthorized
22//! DID, and an unreachable registry all fail the check — each with its own
23//! status so an operator can tell which remediation applies.
24
25pub 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/// Everything `verify-trust` needs for one run.
43#[derive(Debug, Clone)]
44pub struct VerifyTrustArgs {
45    /// Repository to verify (a working tree with `git` available).
46    pub repo_dir: PathBuf,
47    /// Commit range in `git rev-list` syntax, e.g. `origin/main..HEAD`.
48    pub range: String,
49    /// Signer index file; relative paths resolve against `repo_dir`.
50    pub signers_file: PathBuf,
51    /// Base URL of the Trust Registry (`POST <url>/trust-tasks`).
52    pub registry_url: String,
53    /// DID of the registry (the `recipient` on every query document).
54    pub registry_did: String,
55    /// DID of the authority the tuple is evaluated under.
56    pub authority_did: String,
57    /// TRQP action, e.g. `git.commit.sign`.
58    pub action: String,
59    /// TRQP resource, e.g. the `org/repo` slug.
60    pub resource: String,
61    /// Broader resource to try when the primary one does not authorize
62    /// (e.g. the org for an org-wide grant). Grant semantics are
63    /// `resource OR fallback`: the registry's wire contract cannot
64    /// distinguish "no record" from an explicit `authorized: false`, so a
65    /// repo-level record cannot veto an org-level grant.
66    pub fallback_resource: Option<String>,
67    /// Optional armored PGP keyring of exempt platform keys (e.g. GitHub's
68    /// web-flow key); relative paths resolve against `repo_dir`. Absent means
69    /// no exemptions: every PGP-signed commit fails.
70    pub exempt_keyring: Option<PathBuf>,
71    /// Emit machine-readable JSON on stdout instead of human lines.
72    pub json: bool,
73}
74
75/// Outcome for one commit. Ordered worst-first so a report can sort on it.
76#[derive(Debug, Clone, PartialEq, Serialize)]
77#[serde(rename_all = "camelCase", tag = "status", content = "detail")]
78pub enum CommitStatus {
79    /// No `gpgsig` header on the commit.
80    Unsigned,
81    /// The signature did not parse as an Ed25519 sshsig.
82    Malformed(String),
83    /// The embedded key is published by none of the declared signers.
84    UnknownKey { fingerprint: String },
85    /// The key maps to a signer, but the signature does not verify.
86    BadSignature { signer_did: String },
87    /// Valid signature, but the registry did not authorize the signer.
88    Unauthorized { signer_did: String },
89    /// Valid signature, but the registry could not be consulted. Fails the
90    /// run (closed), distinctly from a denial.
91    RegistryUnavailable { signer_did: String, error: String },
92    /// PGP-signed (a platform commit), but the signature verifies against no
93    /// key in the exempt keyring — or no keyring is configured.
94    PgpRejected { detail: String },
95    /// PGP-signed by a key in the committed exempt keyring (e.g. a GitHub
96    /// web-UI merge commit). Passes, reported distinctly from `Trusted`.
97    Exempt { fingerprint: String },
98    /// Valid signature by a registry-authorized signer. `resource` is the
99    /// tuple resource the grant was found under (the primary one or the
100    /// fallback).
101    Trusted {
102        signer_did: String,
103        resource: String,
104    },
105}
106
107impl CommitStatus {
108    /// Signed by a registry-authorized DID.
109    pub fn is_trusted(&self) -> bool {
110        matches!(self, Self::Trusted { .. })
111    }
112
113    /// Whether the commit passes the check: DID-trusted or keyring-exempt.
114    pub fn passes(&self) -> bool {
115        matches!(self, Self::Trusted { .. } | Self::Exempt { .. })
116    }
117}
118
119/// One commit's verdict, as reported.
120#[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/// The full report for a range.
129#[derive(Debug, Serialize)]
130#[serde(rename_all = "camelCase")]
131pub struct TrustReport {
132    pub ok: bool,
133    pub commits: Vec<CommitVerdict>,
134    /// Signer DIDs whose resolution failed (their commits show as
135    /// `unknownKey`); surfaced so the cause is visible.
136    pub unresolved_signers: BTreeMap<String, String>,
137}
138
139/// Run the check end to end: resolve the declared signers' keys, then verify
140/// the range. Returns the process exit code (0 = every commit trusted).
141pub 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
150/// Verify the range against an already-resolved key→DID map. Split from
151/// [`handle_verify_trust`] so tests can supply keys without a live resolver.
152pub 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    // Pass 1: cryptographic verification, collecting the DIDs that signed.
161    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    // Pass 2: one registry query per distinct signer DID.
173    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    // An empty range passes vacuously (nothing new to verify).
184    let ok = commits.iter().all(|c| c.status.passes());
185    Ok(TrustReport {
186        ok,
187        commits,
188        unresolved_signers,
189    })
190}
191
192// --- signature layer ---------------------------------------------------------
193
194/// Result of the cryptographic check for one commit.
195#[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
206/// Verify one raw commit object against the signer key map.
207pub 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    // Platform commits (GitHub web-UI merges, Dependabot) are PGP-signed;
218    // they pass only via the explicitly committed exempt keyring.
219    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
257/// Load the exempt keyring named by the args, resolving relative to the repo.
258fn 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
270// --- signer index & DID resolution -------------------------------------------
271
272/// Read and parse the signer index: one DID per line, `#` comments allowed.
273/// A missing or malformed file is a hard error — with no declared signers
274/// there is nothing to verify against, and the check must not silently pass.
275pub 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
290/// Parse signer-index text. Rejects non-DID entries outright rather than
291/// skipping them: a typo must fail loudly, not silently drop a signer.
292pub 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
307/// Resolve every declared signer DID and collect the Ed25519 keys their DID
308/// documents publish. A DID that fails to resolve is recorded (its commits
309/// will fail as `unknownKey`) without blocking the other signers.
310pub 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
352// --- registry layer -----------------------------------------------------------
353
354/// Per-DID registry decision: `Ok(Some(resource))` = authorized under that
355/// tuple resource, `Ok(None)` = denied everywhere queried, `Err` =
356/// registry unavailable.
357type RegistryDecisions = BTreeMap<String, Result<Option<String>, String>>;
358
359/// One TRQP authorization query per distinct signer DID.
360async 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    // The primary resource, then the broader fallback if it did not grant.
371    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                    // The registry answered and said no (e.g. unknown tuple
389                    // rejected rather than answered false) — a denial, not
390                    // an availability problem; the fallback may still grant.
391                    tracing::debug!("registry rejected the query for {did}: {e}");
392                }
393                Err(e) => {
394                    // Fail closed: with any scope undeterminable, "denied"
395                    // cannot be distinguished from "unreachable".
396                    decision = Err(e.to_string());
397                    break;
398                }
399            }
400        }
401        decisions.insert(did.clone(), decision);
402    }
403    Ok(decisions)
404}
405
406/// Combine the signature check with the registry decision.
407fn 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
433// --- git plumbing --------------------------------------------------------------
434
435/// List the commits in `range`, oldest first.
436pub 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
441/// Read one raw commit object.
442pub 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
475// --- reporting ------------------------------------------------------------------
476
477fn 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    /// Insert a gpgsig header before the blank line, continuation-indented,
557    /// exactly as git stores it.
558    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        // Cross-check: a signature produced by sign.rs verifies through the
606        // ssh-key crate's independent implementation.
607        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        // Signatures created before sign.rs matched ssh-keygen's 70-column
624        // wrapping are permanent in git history and must keep verifying.
625        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}