Skip to main content

auths_cli/commands/artifact/
verify.rs

1use anyhow::{Context, Result, anyhow};
2use serde::Serialize;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use auths_keri::witness::SignedReceipt;
7use auths_verifier::core::Attestation;
8use auths_verifier::evidence_pack::{
9    TransparencyInclusion, parse_log_key_hex, verify_artifact_log_inclusion,
10};
11use auths_verifier::freshness::FreshnessPolicy;
12use auths_verifier::oidc_policy::{OidcPolicyJoin, OidcSubjectPolicy};
13use auths_verifier::witness::{WitnessQuorum, WitnessVerifyConfig};
14use auths_verifier::{
15    CanonicalDid, IdentityBundle, VerificationReport, verify_chain, verify_chain_with_witnesses,
16};
17
18use super::core::{ArtifactMetadata, ArtifactSource};
19use super::file::FileArtifact;
20use crate::commands::verify_helpers::parse_witness_keys;
21use crate::ux::format::is_json_mode;
22
23/// JSON output for `artifact verify --json`.
24#[derive(Serialize)]
25struct VerifyArtifactResult {
26    file: String,
27    valid: bool,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    digest_match: Option<bool>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    chain_valid: Option<bool>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    chain_report: Option<VerificationReport>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    witness_quorum: Option<WitnessQuorum>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    issuer: Option<String>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    commit_sha: Option<String>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    commit_verified: Option<bool>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    oidc_join: Option<OidcPolicyJoin>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    error: Option<String>,
46}
47
48/// How an ephemeral (`did:key:`) attestation's commit-anchor leg is treated.
49///
50/// An ephemeral CI signature trust-chains to a maintainer through its
51/// `commit_sha` (artifact ← ephemeral key ← commit ← maintainer). Resolving
52/// that leg needs the maintainer's repository and pinned roots — which the
53/// scrubbed runner that *produced* the signature does not have. The runner
54/// can still confirm what it just emitted: the artifact's digest and the
55/// ephemeral signature over it. That standalone self-check is [`Self::SignatureOnly`].
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum EphemeralAnchor {
58    /// Trust an ephemeral attestation only after its commit-anchor leg
59    /// resolves to a trusted maintainer. The full chain; the default.
60    Required,
61    /// Confirm an ephemeral attestation from its digest + signature alone,
62    /// without the commit-anchor leg. The signer's self-check: valid means
63    /// "this artifact's digest matches and the ephemeral key signed it",
64    /// not "this signer trust-chains to a maintainer".
65    SignatureOnly,
66}
67
68/// Inputs for [`handle_verify`] beyond the artifact path — named fields
69/// (the `AttestationInput` pattern) so call sites stay readable as the
70/// verify surface grows.
71pub struct VerifyArtifactArgs {
72    /// Path to the signature file (defaults to `<FILE>.auths.json`).
73    pub signature: Option<PathBuf>,
74    /// Identity bundle for stateless verification.
75    pub identity_bundle: Option<PathBuf>,
76    /// Witness receipts file.
77    pub witness_receipts: Option<PathBuf>,
78    /// Witness public keys as DID:hex pairs.
79    pub witness_keys: Vec<String>,
80    /// Number of witnesses required.
81    pub witness_threshold: usize,
82    /// Also verify the source commit's signing attestation.
83    pub verify_commit: bool,
84    /// How to treat an ephemeral attestation's commit-anchor leg.
85    pub ephemeral_anchor: EphemeralAnchor,
86    /// OIDC-subject policy to JOIN against the signed OIDC binding.
87    pub oidc_policy: Option<PathBuf>,
88    /// Org `did:keri:` whose KEL-anchored OIDC-subject policy to resolve and
89    /// JOIN — the witnessed log as the policy's source of truth.
90    pub oidc_policy_did: Option<String>,
91    /// Offline transparency-log inclusion evidence (`auths log prove --out`).
92    pub log_evidence: Option<PathBuf>,
93    /// The log operator's pinned Ed25519 key (64 hex chars); paired with
94    /// `log_evidence` at the clap boundary.
95    pub log_key: Option<String>,
96    /// Require the cryptographically-verified signer (the attestation issuer) to be
97    /// exactly this identity. Applied AFTER verification as an allowlist — it can only
98    /// narrow a `valid` verdict to invalid on a signer mismatch, never widen it.
99    pub expect_signer: Option<String>,
100    /// Require the verified signer to be a rooted `did:keri` identity (a rotatable, revocable
101    /// key-state log), rejecting a bare `did:key` self-attestation. Applied AFTER verification;
102    /// it can only narrow a `valid` verdict to invalid, never widen it.
103    pub require_rooted_signer: bool,
104}
105
106/// Decide whether the verified signer satisfies an `--expect-signer` allowlist.
107///
108/// Pure: a string-equality allowlist applied only once an attestation has otherwise
109/// verified. Returns the mismatch message to fail closed on, or `None` to proceed.
110///
111/// Args:
112/// * `issuer`: the verified attestation issuer (the signer).
113/// * `expect`: the `--expect-signer` value, if given.
114fn expected_signer_mismatch(issuer: &str, expect: Option<&str>) -> Option<String> {
115    match expect {
116        Some(want) if want != issuer => Some(format!(
117            "Signer mismatch: verified signer {issuer} is not the expected signer {want}"
118        )),
119        _ => None,
120    }
121}
122
123/// Decide whether the verified signer satisfies a "require a rooted signer" policy.
124///
125/// Pure: a bare `did:key` signer is a self-attestation — the key is its own identity, with no
126/// key-state log behind it, so it cannot be rotated or revoked (a leaked key stays valid
127/// forever). When the caller requires a rooted signer, only a `did:keri` issuer — whose authority
128/// is a replayable, rotatable, revocable key-state log — is accepted; a `did:key`
129/// self-attestation fails closed. Applied only after an attestation has otherwise verified, and
130/// it can only narrow a verdict to invalid, never widen it.
131///
132/// Args:
133/// * `issuer`: the verified attestation issuer (the signer).
134/// * `require_rooted`: whether the caller demands a rooted (`did:keri`) signer.
135fn unrooted_signer_rejected(issuer: &str, require_rooted: bool) -> Option<String> {
136    if require_rooted && !issuer.starts_with("did:keri:") {
137        Some(format!(
138            "Signer not root-authorized: {issuer} is a did:key self-attestation, not a \
139             rotatable did:keri identity backed by a key-state log"
140        ))
141    } else {
142        None
143    }
144}
145
146/// Execute the `artifact verify` command.
147///
148/// Exit codes: 0=valid, 1=invalid, 2=error.
149pub async fn handle_verify(file: &Path, args: VerifyArtifactArgs) -> Result<()> {
150    let VerifyArtifactArgs {
151        signature,
152        identity_bundle,
153        witness_receipts,
154        witness_keys,
155        witness_threshold,
156        verify_commit,
157        ephemeral_anchor,
158        oidc_policy,
159        oidc_policy_did,
160        log_evidence,
161        log_key,
162        expect_signer,
163        require_rooted_signer,
164    } = args;
165    let witness_keys = &witness_keys;
166    let file_str = file.to_string_lossy().to_string();
167
168    // 0. Resolve the OIDC-subject policy up front: an unreadable or malformed
169    //    policy is a "could not attempt" (exit 2), not a verdict — except a
170    //    KEL-anchored blob that fails its digest check, which IS a verdict (1).
171    let oidc_policy = match (&oidc_policy, &oidc_policy_did) {
172        (None, None) => None,
173        (Some(path), _) => {
174            let raw = match fs::read_to_string(path) {
175                Ok(r) => r,
176                Err(e) => {
177                    return output_error(
178                        &file_str,
179                        2,
180                        &format!("Failed to read OIDC policy {path:?}: {e}"),
181                    );
182                }
183            };
184            match OidcSubjectPolicy::parse(&raw) {
185                Ok(p) => Some(p),
186                Err(e) => {
187                    return output_error(&file_str, 2, &format!("{e}"));
188                }
189            }
190        }
191        (None, Some(org_did)) => match resolve_anchored_oidc_policy(org_did) {
192            Ok(p) => Some(p),
193            Err((exit_code, message)) => {
194                return output_error(&file_str, exit_code, &message);
195            }
196        },
197    };
198
199    // 1. Locate and load signature file
200    let sig_path = signature.unwrap_or_else(|| {
201        let mut p = file.to_path_buf();
202        let new_name = format!(
203            "{}.auths.json",
204            p.file_name().unwrap_or_default().to_string_lossy()
205        );
206        p.set_file_name(new_name);
207        p
208    });
209
210    let sig_content = match fs::read_to_string(&sig_path) {
211        Ok(c) => c,
212        Err(e) => {
213            return output_error(
214                &file_str,
215                2,
216                &format!("Failed to read signature file {:?}: {}", sig_path, e),
217            );
218        }
219    };
220
221    // 2. Parse attestation
222    let attestation: Attestation = match serde_json::from_str(&sig_content) {
223        Ok(a) => a,
224        Err(e) => {
225            return output_error(&file_str, 2, &format!("Failed to parse attestation: {}", e));
226        }
227    };
228
229    // 3. Extract artifact metadata from payload
230    let artifact_meta: ArtifactMetadata = match &attestation.payload {
231        Some(payload) => match serde_json::from_value(payload.clone()) {
232            Ok(m) => m,
233            Err(e) => {
234                return output_error(
235                    &file_str,
236                    2,
237                    &format!("Failed to parse artifact metadata from payload: {}", e),
238                );
239            }
240        },
241        None => {
242            return output_error(
243                &file_str,
244                2,
245                "Attestation has no payload (expected artifact metadata)",
246            );
247        }
248    };
249
250    // 4. Compute file digest and compare
251    let file_artifact = FileArtifact::new(file);
252    let file_digest = match file_artifact.digest() {
253        Ok(d) => d,
254        Err(e) => {
255            return output_error(
256                &file_str,
257                2,
258                &format!("Failed to compute file digest: {}", e),
259            );
260        }
261    };
262
263    if file_digest != artifact_meta.digest {
264        return output_result(
265            1,
266            VerifyArtifactResult {
267                file: file_str.clone(),
268                valid: false,
269                digest_match: Some(false),
270                chain_valid: None,
271                chain_report: None,
272                witness_quorum: None,
273                issuer: Some(attestation.issuer.to_string()),
274                commit_sha: attestation.commit_sha.clone(),
275                commit_verified: None,
276                oidc_join: None,
277                error: Some(format!(
278                    "Digest mismatch: file={}, attestation={}",
279                    file_digest.hex, artifact_meta.digest.hex
280                )),
281            },
282        );
283    }
284
285    // 5. Resolve identity public key
286    // Exit-code contract: 0 = verified, 1 = verification failed (a trust or
287    // signature verdict — an unresolvable/untrusted issuer is a verdict), 2 =
288    // could not attempt (I/O, malformed input).
289    let (root_pk, identity_did) = match resolve_identity_key(&identity_bundle, &attestation) {
290        Ok(v) => v,
291        Err(e) => {
292            return output_error(&file_str, 1, &format!("{e:#}"));
293        }
294    };
295
296    // 6. Verify attestation chain authenticity (signatures, linkage, expiry).
297    //    Capability authority is no longer gated here: an artifact-signer capability
298    //    grant must come from a holder-verified ACDC credential, not the attestation.
299    let chain = vec![attestation.clone()];
300    let chain_result = verify_chain(&chain, &root_pk).await;
301
302    let (chain_valid, mut chain_report) = match chain_result {
303        Ok(mut report) => {
304            if let Ok(home) = auths_sdk::paths::auths_home() {
305                let storage = auths_sdk::storage::RegistryAttestationStorage::new(&home);
306                if let Ok(enriched) = storage.load_all_enriched() {
307                    let anchor_set: std::collections::HashSet<auths_keri::Said> = enriched
308                        .iter()
309                        .filter(|e| e.anchor == auths_keri::AnchorStatus::Anchored)
310                        .map(|e| e.said.clone())
311                        .collect();
312                    let all_anchored = chain.iter().all(|att| {
313                        auths_sdk::attestation::canonical_said(att)
314                            .is_some_and(|s| anchor_set.contains(&s))
315                    });
316                    report.anchored = Some(if all_anchored {
317                        auths_keri::AnchorStatus::Anchored
318                    } else {
319                        auths_keri::AnchorStatus::NotAnchored
320                    });
321                }
322            }
323            let is_valid = report.is_valid();
324            (Some(is_valid), Some(report))
325        }
326        Err(e) => {
327            return output_error(&file_str, 1, &format!("Chain verification failed: {}", e));
328        }
329    };
330
331    // --expect-signer: an allowlist applied AFTER cryptographic verification. A signer
332    // mismatch fails the verdict closed; it can only narrow `valid`, never widen it.
333    if let Some(msg) =
334        expected_signer_mismatch(attestation.issuer.as_str(), expect_signer.as_deref())
335    {
336        return output_result(
337            1,
338            VerifyArtifactResult {
339                file: file_str.clone(),
340                valid: false,
341                digest_match: Some(true),
342                chain_valid,
343                chain_report: chain_report.clone(),
344                witness_quorum: None,
345                issuer: Some(attestation.issuer.to_string()),
346                commit_sha: attestation.commit_sha.clone(),
347                commit_verified: None,
348                oidc_join: None,
349                error: Some(msg),
350            },
351        );
352    }
353
354    // --require-rooted-signer: a bare did:key self-attestation has no key-state log, so it cannot
355    // be rotated or revoked. When a rooted signer is demanded, reject it; narrows the verdict only.
356    if let Some(msg) = unrooted_signer_rejected(attestation.issuer.as_str(), require_rooted_signer)
357    {
358        return output_result(
359            1,
360            VerifyArtifactResult {
361                file: file_str.clone(),
362                valid: false,
363                digest_match: Some(true),
364                chain_valid,
365                chain_report: chain_report.clone(),
366                witness_quorum: None,
367                issuer: Some(attestation.issuer.to_string()),
368                commit_sha: attestation.commit_sha.clone(),
369                commit_verified: None,
370                oidc_join: None,
371                error: Some(msg),
372            },
373        );
374    }
375
376    // 6b. Offline transparency anchoring. With inclusion evidence supplied,
377    //     the verdict's `anchored` field is decided by the proof: Anchored
378    //     only when the evidence binds to THIS artifact's digest, its Merkle
379    //     inclusion verifies, and the checkpoint is attested by the pinned
380    //     log key. Fail-closed: evidence that does not prove is a verdict
381    //     (exit 1), never a skip.
382    let mut log_anchor_error: Option<String> = None;
383    if let Some(evidence_path) = &log_evidence {
384        let raw = match fs::read_to_string(evidence_path) {
385            Ok(r) => r,
386            Err(e) => {
387                return output_error(
388                    &file_str,
389                    2,
390                    &format!("Failed to read log evidence {evidence_path:?}: {e}"),
391                );
392            }
393        };
394        let evidence: TransparencyInclusion = match serde_json::from_str(&raw) {
395            Ok(t) => t,
396            Err(e) => {
397                return output_error(&file_str, 2, &format!("Failed to parse log evidence: {e}"));
398            }
399        };
400        // clap enforces the pair; a bare evidence path here is could-not-attempt.
401        let Some(key_hex) = log_key.as_deref() else {
402            return output_error(&file_str, 2, "--log-evidence requires --log-key");
403        };
404        let pinned_key = match parse_log_key_hex(key_hex) {
405            Ok(k) => k,
406            Err(e) => return output_error(&file_str, 2, &format!("{e}")),
407        };
408        // The log's leaf data is the canonical `sha256:<hex>` digest string —
409        // derive it through the same parsed type the append path uses.
410        let canonical_digest = match auths_sdk::workflows::compliance::ArtifactDigest::parse(
411            &format!("{}:{}", file_digest.algorithm, file_digest.hex),
412        ) {
413            Ok(d) => d,
414            Err(e) => {
415                return output_error(
416                    &file_str,
417                    2,
418                    &format!("Cannot derive the artifact's canonical log leaf: {e}"),
419                );
420            }
421        };
422        match verify_artifact_log_inclusion(canonical_digest.as_str(), &evidence, &pinned_key) {
423            Ok(()) => {
424                if let Some(report) = chain_report.as_mut() {
425                    report.anchored = Some(auths_keri::AnchorStatus::Anchored);
426                }
427                if !is_json_mode() {
428                    eprintln!(
429                        "  Transparency: {} anchored in log '{}' \
430                         (offline inclusion proof, operator key pinned)",
431                        canonical_digest.as_str(),
432                        evidence.signed_checkpoint.checkpoint.origin
433                    );
434                }
435            }
436            Err(e) => {
437                if let Some(report) = chain_report.as_mut() {
438                    report.anchored = Some(auths_keri::AnchorStatus::NotAnchored);
439                }
440                log_anchor_error = Some(format!("Transparency anchoring failed: {e}"));
441            }
442        }
443    }
444
445    // 7. Optional witness verification
446    let witness_quorum = match verify_witnesses(
447        &chain,
448        &root_pk,
449        &witness_receipts,
450        witness_keys,
451        witness_threshold,
452    )
453    .await
454    {
455        Ok(q) => q,
456        Err(e) => {
457            return output_error(&file_str, 2, &format!("Witness verification error: {}", e));
458        }
459    };
460
461    // 8. Compute overall verdict
462    let mut valid = chain_valid.unwrap_or(false);
463
464    if let Some(ref q) = witness_quorum
465        && q.verified < q.required
466    {
467        valid = false;
468    }
469
470    if let Some(ref msg) = log_anchor_error {
471        valid = false;
472        if !is_json_mode() {
473            eprintln!("  {msg}");
474        }
475    }
476
477    // 8a. Ephemeral attestation: trust chains through the commit anchor.
478    //     `--signature-only` confines the verdict to digest + signature — the
479    //     self-check the runner that emitted the attestation can run without the
480    //     maintainer's repo/roots. It does NOT chase the commit-anchor leg, so it
481    //     never claims the signer trust-chains to a maintainer.
482    let is_ephemeral = attestation.issuer.as_str().starts_with("did:key:");
483    if is_ephemeral && valid {
484        match ephemeral_anchor {
485            EphemeralAnchor::SignatureOnly => {
486                if !is_json_mode() {
487                    eprintln!(
488                        "  Signature-only: ephemeral signature over the artifact digest \
489                         verifies; commit-anchor leg NOT checked (signer self-check)."
490                    );
491                }
492            }
493            EphemeralAnchor::Required => match &attestation.commit_sha {
494                None => {
495                    if !is_json_mode() {
496                        eprintln!(
497                            "Error: ephemeral attestation (did:key issuer) requires commit_sha. \
498                             This attestation is unsigned provenance without a commit anchor."
499                        );
500                    }
501                    valid = false;
502                }
503                Some(sha) => {
504                    // Verify the commit is signed by a trusted key. With an identity
505                    // bundle the commit leg verifies against the bundle's evidenced
506                    // KELs (the stateless CI posture — same doctrine as
507                    // `auths verify <sha> --identity-bundle`); otherwise the local
508                    // registry resolves the signer in-process (no git shell-out).
509                    let commit_sig_ok = match &identity_bundle {
510                        Some(bundle_path) => {
511                            match crate::commands::verify_commit::commit_trusted_via_bundle(
512                                sha,
513                                bundle_path,
514                            )
515                            .await
516                            {
517                                Ok(()) => true,
518                                Err(reason) => {
519                                    if !is_json_mode() {
520                                        eprintln!("  Commit-anchor leg failed: {reason}");
521                                    }
522                                    false
523                                }
524                            }
525                        }
526                        None => verify_commit_in_process(sha).await,
527                    };
528
529                    if !commit_sig_ok {
530                        valid = false;
531                    }
532
533                    if !is_json_mode() {
534                        if commit_sig_ok {
535                            eprintln!(
536                                "  Trust chain: artifact <- ephemeral key <- commit {} <- maintainer",
537                                &sha[..8.min(sha.len())]
538                            );
539                        } else {
540                            eprintln!(
541                                "  Commit {} is not signed by a trusted maintainer.",
542                                &sha[..8.min(sha.len())]
543                            );
544                        }
545                    }
546                }
547            },
548        }
549    }
550
551    // 8a2. The keyless exchange, verify side: JOIN the attestation's
552    //      signature-covered OIDC binding against the org's pinned policy.
553    //      Fail-closed: only a chain-valid attestation has a trustworthy
554    //      binding, and a missing binding or any claim mismatch is a
555    //      verification failure, never a pass.
556    let mut oidc_error: Option<String> = None;
557    let oidc_join = match &oidc_policy {
558        None => None,
559        Some(_) if !valid => {
560            // Already failing — the binding's claims can't be trusted, so the
561            // join is not attempted (and cannot rescue the verdict).
562            None
563        }
564        Some(policy) => match &attestation.oidc_binding {
565            None => {
566                valid = false;
567                oidc_error = Some(
568                    "OIDC policy join failed: attestation carries no OIDC binding \
569                     — signer presented no verified OIDC identity"
570                        .to_string(),
571                );
572                None
573            }
574            Some(binding) => match policy.join(binding) {
575                Ok(join) => {
576                    if !is_json_mode() {
577                        eprintln!(
578                            "  OIDC policy join: {} via {} (issuer {})",
579                            join.repository,
580                            join.workflow_ref.as_deref().unwrap_or("any workflow"),
581                            join.issuer
582                        );
583                    }
584                    Some(join)
585                }
586                Err(e) => {
587                    valid = false;
588                    oidc_error = Some(format!("OIDC policy join failed: {e}"));
589                    None
590                }
591            },
592        },
593    };
594    if let Some(ref msg) = oidc_error
595        && !is_json_mode()
596    {
597        eprintln!("  {msg}");
598    }
599
600    // 8b. Display commit linkage info (always, when present)
601    let commit_sha_val = attestation.commit_sha.clone();
602    if let Some(ref sha) = commit_sha_val
603        && !is_json_mode()
604        && !is_ephemeral
605    {
606        eprintln!("  Commit: {}", sha);
607    }
608
609    // 8c. Optional commit attestation verification
610    let commit_verified = if verify_commit {
611        match &commit_sha_val {
612            None => {
613                if !is_json_mode() {
614                    eprintln!(
615                        "warning: artifact attestation has no commit_sha field; \
616                         re-sign with: auths artifact sign --commit <SHA>"
617                    );
618                }
619                None
620            }
621            Some(sha) => {
622                // Look up commit attestation via git ref
623                let commit_ref = format!("refs/auths/commits/{}", sha);
624                let lookup = crate::subprocess::git_command(&[
625                    "show",
626                    &format!("{}:attestation.json", commit_ref),
627                ])
628                .output();
629                match lookup {
630                    Ok(output) if output.status.success() => {
631                        if !is_json_mode() {
632                            eprintln!("  Commit {}: signing attestation found", &sha[..12]);
633                        }
634                        Some(true)
635                    }
636                    _ => {
637                        if !is_json_mode() {
638                            eprintln!(
639                                "warning: no signing attestation found for commit {}",
640                                &sha[..std::cmp::min(sha.len(), 12)]
641                            );
642                        }
643                        Some(false)
644                    }
645                }
646            }
647        }
648    } else {
649        None
650    };
651
652    let exit_code = if valid { 0 } else { 1 };
653
654    output_result(
655        exit_code,
656        VerifyArtifactResult {
657            file: file_str,
658            valid,
659            digest_match: Some(true),
660            chain_valid,
661            chain_report,
662            witness_quorum,
663            issuer: Some(identity_did.to_string()),
664            commit_sha: commit_sha_val,
665            commit_verified,
666            oidc_join,
667            error: log_anchor_error.or(oidc_error).or_else(|| {
668                (!valid).then(|| {
669                    "attestation chain or its commit-anchor leg did not verify (see lines above)"
670                        .to_string()
671                })
672            }),
673        },
674    )
675}
676
677/// Resolve the org's KEL-anchored OIDC-subject policy from the local registry.
678///
679/// The org seals the policy digest on its KEL (`auths org anchor-oidc-policy`);
680/// this reads the latest seal, loads the content-addressed blob, and refuses a
681/// digest mismatch — the verifier trusts the org's witnessed log, not a file
682/// someone handed them. Errors carry the verify exit-code contract: a tampered
683/// blob is a verdict (1); everything else is could-not-attempt (2).
684fn resolve_anchored_oidc_policy(org_did: &str) -> Result<OidcSubjectPolicy, (i32, String)> {
685    use auths_sdk::workflows::org::load_org_oidc_policy;
686
687    let Some(prefix) = org_did.strip_prefix("did:keri:") else {
688        return Err((
689            2,
690            format!("--oidc-policy-did requires a did:keri: identifier, got '{org_did}'"),
691        ));
692    };
693    let auths_home = auths_sdk::paths::auths_home()
694        .map_err(|e| (2, format!("Could not locate ~/.auths: {e}")))?;
695    let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
696        auths_sdk::storage::RegistryConfig::single_tenant(&auths_home),
697    );
698    let org_prefix = auths_verifier::Prefix::new_unchecked(prefix.to_string());
699
700    match load_org_oidc_policy(&registry, &org_prefix) {
701        Ok(Some(loaded)) => {
702            if !is_json_mode() {
703                eprintln!(
704                    "  OIDC policy resolved from the org KEL (digest {})",
705                    loaded.policy_digest
706                );
707            }
708            Ok(loaded.policy)
709        }
710        Ok(None) => Err((
711            2,
712            format!(
713                "organization {org_did} has no OIDC-subject policy anchored on its KEL \
714                 — anchor one with `auths org anchor-oidc-policy`"
715            ),
716        )),
717        Err(e @ auths_sdk::domains::org::error::OrgError::PolicyIntegrity { .. }) => {
718            Err((1, format!("OIDC policy resolution failed: {e}")))
719        }
720        Err(e) => Err((
721            2,
722            format!("Failed to resolve the anchored OIDC policy: {e}"),
723        )),
724    }
725}
726
727/// Resolve identity public key from bundle or from the attestation's issuer DID.
728fn resolve_identity_key(
729    identity_bundle: &Option<PathBuf>,
730    attestation: &Attestation,
731) -> Result<(auths_verifier::DevicePublicKey, CanonicalDid)> {
732    // An ephemeral (`did:key:`) attestation is SELF-signed: its chain leg can only
733    // verify against the issuer's own in-band key — the bundle's root never signed
734    // it. The bundle still anchors the COMMIT leg (artifact ← ephemeral key ←
735    // commit ← maintainer), enforced in the ephemeral commit-anchor step below.
736    if attestation.issuer.as_str().starts_with("did:key:") {
737        let issuer = &attestation.issuer;
738        let (pk_bytes, curve) = resolve_pk_from_did(issuer)
739            .with_context(|| format!("Failed to resolve public key from issuer DID '{issuer}'"))?;
740        let pk = auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes)
741            .map_err(|e| anyhow!("Invalid issuer public key resolved from DID: {e}"))?;
742        return Ok((pk, issuer.clone()));
743    }
744    if let Some(bundle_path) = identity_bundle {
745        let bundle_content = fs::read_to_string(bundle_path)
746            .with_context(|| format!("Failed to read identity bundle: {:?}", bundle_path))?;
747        let bundle: IdentityBundle = serde_json::from_str(&bundle_content)
748            .with_context(|| format!("Failed to parse identity bundle: {:?}", bundle_path))?;
749
750        // The bundle is attacker-controlled input. Authenticate it into a trust
751        // anchor before believing anything it claims. `BundleTrust::parse`
752        // enforces freshness + RT-005 self-certification (the bundle's
753        // `identity_did` MUST name the inception its KEL carries) + RT-002 KEL
754        // signature authentication. RT-005 is what kills the impersonation:
755        // without it an attacker could export their OWN valid bundle, rewrite
756        // only `identity_did` to a victim's DID, and have the artifact certified
757        // "signed by <victim>". The DID and the key material would come from the
758        // same forged input.
759        let trust = auths_verifier::BundleTrust::parse(&bundle, chrono::Utc::now())
760            .map_err(|e| anyhow!("identity bundle is not a trustworthy anchor: {e}"))?;
761
762        // Derive the verification key from the AUTHENTICATED KEL's current
763        // key-state — never from the bundle's self-asserted `public_key_hex`,
764        // which an attacker can set to any value. Replaying the trusted KEL
765        // yields the post-rotation current key and fails closed for an empty KEL
766        // (no events → no current key → rejected). Mirrors the stateful resolver
767        // (auths-sdk keri::resolver::resolve_current_public_key) on the
768        // in-memory KEL.
769        let state = auths_keri::TrustedKel::from_trusted_source(trust.kel())
770            .replay()
771            .map_err(|e| anyhow!("identity bundle KEL is not replayable: {e}"))?;
772        let key = state
773            .current_key()
774            .ok_or_else(|| anyhow!("identity bundle KEL has no current key"))?;
775        let (key_bytes, curve) = match key
776            .parse()
777            .map_err(|e| anyhow!("identity bundle current key is unsupported: {e}"))?
778        {
779            auths_keri::KeriPublicKey::Ed25519 { key, .. } => {
780                (key.to_vec(), auths_crypto::CurveType::Ed25519)
781            }
782            auths_keri::KeriPublicKey::P256 { key, .. } => {
783                (key.to_vec(), auths_crypto::CurveType::P256)
784            }
785        };
786        let pk = auths_verifier::DevicePublicKey::try_new(curve, &key_bytes)
787            .map_err(|e| anyhow!("Invalid bundle public key: {e}"))?;
788
789        // The identity_did is now PROVEN equal to the authenticated KEL's
790        // inception (RT-005), so it is safe to return as the certified signer.
791        let (root_did, _kel, _device_kels) = trust.into_parts();
792        Ok((pk, CanonicalDid::new_unchecked(root_did)))
793    } else {
794        // Resolve public key from the issuer DID
795        let issuer = &attestation.issuer;
796        let (pk_bytes, curve) = resolve_pk_from_did(issuer)
797            .with_context(|| format!("Failed to resolve public key from issuer DID '{}'. Use --identity-bundle for stateless verification.", issuer))?;
798        let pk = auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes)
799            .map_err(|e| anyhow!("Invalid issuer public key resolved from DID: {e}"))?;
800        Ok((pk, issuer.clone()))
801    }
802}
803
804/// Resolve a DID's current public key bytes.
805///
806/// `did:keri:` resolves by replaying the issuer's KEL from the local registry —
807/// a KERI prefix is a digest of its inception event, never raw key bytes, and
808/// only KEL replay yields the post-rotation *current* key. This is what makes
809/// self-verification work: the signer's own KEL is always in the local registry.
810/// `did:key:` decodes in-band (the key IS the identifier).
811fn resolve_pk_from_did(did: &str) -> Result<(Vec<u8>, auths_crypto::CurveType)> {
812    if did.starts_with("did:keri:") {
813        let auths_home = auths_sdk::paths::auths_home()
814            .map_err(|e| anyhow!("Could not locate ~/.auths: {e}"))?;
815        let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
816            auths_sdk::storage::RegistryConfig::single_tenant(&auths_home),
817        );
818        let (pk, curve) = auths_sdk::keri::resolve_current_public_key(&registry, did)?;
819        Ok((pk, curve))
820    } else if did.starts_with("did:key:z") {
821        match auths_crypto::did_key_decode(did) {
822            Ok(auths_crypto::DecodedDidKey::Ed25519(pk)) => {
823                Ok((pk.to_vec(), auths_crypto::CurveType::Ed25519))
824            }
825            Ok(auths_crypto::DecodedDidKey::P256(pk)) => Ok((pk, auths_crypto::CurveType::P256)),
826            Err(e) => Err(anyhow!("Failed to resolve did:key: {}", e)),
827        }
828    } else {
829        Err(anyhow!(
830            "Unsupported DID method: {}. Use --identity-bundle instead.",
831            did
832        ))
833    }
834}
835
836/// Verify witness receipts if provided.
837async fn verify_witnesses(
838    chain: &[Attestation],
839    root_pk: &auths_verifier::DevicePublicKey,
840    receipts_path: &Option<PathBuf>,
841    witness_keys_raw: &[String],
842    threshold: usize,
843) -> Result<Option<WitnessQuorum>> {
844    let receipts_path = match receipts_path {
845        Some(p) => p,
846        None => return Ok(None),
847    };
848
849    let receipts_bytes = fs::read(receipts_path)
850        .with_context(|| format!("Failed to read witness receipts: {:?}", receipts_path))?;
851    let receipts: Vec<SignedReceipt> =
852        serde_json::from_slice(&receipts_bytes).context("Failed to parse witness receipts JSON")?;
853
854    let witness_keys = parse_witness_keys(witness_keys_raw)?;
855
856    let config = WitnessVerifyConfig {
857        receipts: &receipts,
858        witness_keys: &witness_keys,
859        threshold,
860    };
861
862    let report = verify_chain_with_witnesses(chain, root_pk, &config)
863        .await
864        .context("Witness chain verification failed")?;
865
866    Ok(report.witness_quorum)
867}
868
869fn output_error(file: &str, exit_code: i32, message: &str) -> Result<()> {
870    if is_json_mode() {
871        let result = VerifyArtifactResult {
872            file: file.to_string(),
873            valid: false,
874            digest_match: None,
875            chain_valid: None,
876            chain_report: None,
877            witness_quorum: None,
878            issuer: None,
879            commit_sha: None,
880            commit_verified: None,
881            oidc_join: None,
882            error: Some(message.to_string()),
883        };
884        println!("{}", serde_json::to_string(&result)?);
885    } else {
886        eprintln!("Error: {}", message);
887    }
888    std::process::exit(exit_code);
889}
890
891use crate::commands::verify_helpers::freshness_label;
892
893/// The one-line human summary for a verified artifact.
894///
895/// Always names the verdict's freshness when a chain report is present, so an offline verify
896/// renders "verified (freshness unknown)" rather than a bare success that reads as real-time
897/// fresh. Pure (no I/O) so the surfacing is unit-testable without running the command.
898///
899/// Args:
900/// * `result`: the assembled artifact-verify result.
901fn verified_summary(result: &VerifyArtifactResult) -> String {
902    let mut line = String::from("Artifact verified");
903    if let Some(ref issuer) = result.issuer {
904        line.push_str(&format!(": signed by {issuer}"));
905    }
906    if let Some(freshness) = result.chain_report.as_ref().map(|r| r.freshness()) {
907        line.push_str(&format!(" (freshness {})", freshness_label(freshness)));
908    }
909    if let Some(ref q) = result.witness_quorum {
910        line.push_str(&format!(" (witnesses: {}/{})", q.verified, q.required));
911    }
912    line
913}
914
915/// Output the verification result.
916fn output_result(exit_code: i32, result: VerifyArtifactResult) -> Result<()> {
917    if is_json_mode() {
918        println!("{}", serde_json::to_string(&result)?);
919    } else if result.valid {
920        println!("{}", verified_summary(&result));
921    } else {
922        eprint!("Verification failed");
923        if let Some(ref error) = result.error {
924            eprint!(": {}", error);
925        }
926        eprintln!();
927    }
928
929    if exit_code != 0 {
930        std::process::exit(exit_code);
931    }
932    Ok(())
933}
934
935/// Verify the commit an ephemeral attestation is bound to, KEL-natively.
936///
937/// Reads the raw commit via git2, then delegates trust to the SDK commit-trust
938/// resolver: the signer must be a device delegated under a root pinned in
939/// `.auths/roots`. No `.auths/allowed_signers`, no `ssh-keygen` allowlist, no
940/// `git verify-commit --raw` shell-out.
941async fn verify_commit_in_process(sha: &str) -> bool {
942    // Open the repository
943    let repo = match git2::Repository::discover(".") {
944        Ok(r) => r,
945        Err(e) => {
946            if !is_json_mode() {
947                eprintln!("Failed to open git repository: {e}");
948            }
949            return false;
950        }
951    };
952
953    // Parse the commit SHA
954    let oid = match git2::Oid::from_str(sha) {
955        Ok(o) => o,
956        Err(e) => {
957            if !is_json_mode() {
958                eprintln!("Invalid commit SHA '{}': {e}", &sha[..8.min(sha.len())]);
959            }
960            return false;
961        }
962    };
963
964    // The SSH signature is computed over the commit object's EXACT bytes
965    // (what `git cat-file commit <sha>` prints). Read them from the object
966    // database — never reconstruct them by joining header/message strings;
967    // a single byte of drift makes a valid signature unverifiable.
968    let commit_content = match raw_commit_bytes(&repo, oid) {
969        Ok(bytes) => String::from_utf8_lossy(&bytes).to_string(),
970        Err(e) => {
971            if !is_json_mode() {
972                eprintln!("Commit {} not found: {e}", &sha[..8.min(sha.len())]);
973            }
974            return false;
975        }
976    };
977
978    // KEL-native trust: the commit's signer must be a device delegated under a root
979    // pinned in `.auths/roots`. The verdict logic lives in the SDK commit-trust resolver.
980    let provider = auths_crypto::RingCryptoProvider;
981    let auths_home = match auths_sdk::paths::auths_home() {
982        Ok(h) => h,
983        Err(e) => {
984            if !is_json_mode() {
985                eprintln!("Could not locate ~/.auths: {e}");
986            }
987            return false;
988        }
989    };
990    let registry = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
991        auths_sdk::storage::RegistryConfig::single_tenant(&auths_home),
992    );
993    let pinned_roots = crate::commands::verify_helpers::load_project_pinned_roots();
994
995    let short = &sha[..8.min(sha.len())];
996    match auths_sdk::workflows::commit_trust::verify_commit_local(
997        &registry,
998        &pinned_roots,
999        commit_content.as_bytes(),
1000        &provider,
1001    )
1002    .await
1003    {
1004        Ok(verdict) if verdict.is_trusted(&FreshnessPolicy::default()) => true,
1005        Ok(verdict) => {
1006            if !is_json_mode() {
1007                eprintln!("Commit {short} is not authorized by a pinned trusted root: {verdict:?}");
1008            }
1009            false
1010        }
1011        Err(e) => {
1012            if !is_json_mode() {
1013                eprintln!("Commit {short} trust could not be resolved: {e}");
1014            }
1015            false
1016        }
1017    }
1018}
1019
1020/// Verify an air-gapped org bundle entirely offline (zero network), fail-closed.
1021///
1022/// Reads the fn-154.5 bundle, loads the verifier's pinned roots (from `roots` or the
1023/// default `.auths/roots`, falling back to the bundle's declared roots if neither
1024/// exists), and classifies the optional `member` at `signed_at` purely from the
1025/// bundle's KEL contents. Exits non-zero on any non-authorized verdict so it can gate
1026/// CI.
1027///
1028/// Args:
1029/// * `file`: Path to the air-gapped bundle (`auths org bundle` output).
1030/// * `roots`: Optional pinned-roots file (default `.auths/roots`).
1031/// * `member`: Optional member `did:keri` to classify authority for.
1032/// * `signed_at`: Optional in-band signing KEL position for the member's artifact.
1033/// * `json`: Emit the typed report as JSON.
1034///
1035/// Usage:
1036/// ```ignore
1037/// handle_offline_verify(Path::new("acme.auths-offline"), None, None, None, false)?;
1038/// ```
1039pub fn handle_offline_verify(
1040    file: &Path,
1041    roots: Option<&Path>,
1042    member: Option<&str>,
1043    signed_at: Option<u128>,
1044    json: bool,
1045) -> Result<()> {
1046    use auths_sdk::workflows::org::{AirGappedOrgBundle, AuthorityAtSigning, verify_org_bundle};
1047    use auths_sdk::workflows::roots::parse_roots_typed;
1048    use auths_verifier::Prefix;
1049    use auths_verifier::types::IdentityDID;
1050
1051    let bundle_json =
1052        fs::read_to_string(file).with_context(|| format!("Failed to read bundle file {file:?}"))?;
1053    let bundle = AirGappedOrgBundle::from_json(&bundle_json)
1054        .context("Failed to parse air-gapped org bundle")?;
1055
1056    let roots_path = roots
1057        .map(Path::to_path_buf)
1058        .unwrap_or_else(|| PathBuf::from(".auths/roots"));
1059    let pinned_roots: Vec<IdentityDID> = if roots_path.exists() {
1060        let content = fs::read_to_string(&roots_path)
1061            .with_context(|| format!("Failed to read roots file {roots_path:?}"))?;
1062        parse_roots_typed(&content).context("Failed to parse pinned roots")?
1063    } else {
1064        // No verifier-side roots configured — trust the bundle's declared roots
1065        // (trust-on-first-use). Supply --roots to pin explicitly.
1066        bundle.pinned_roots.clone()
1067    };
1068
1069    let member_prefix =
1070        member.map(|m| Prefix::new_unchecked(m.strip_prefix("did:keri:").unwrap_or(m).to_string()));
1071    let query = member_prefix.as_ref().map(|p| (p, signed_at));
1072
1073    let report =
1074        verify_org_bundle(&bundle, &pinned_roots, query).context("Offline verification failed")?;
1075
1076    if json {
1077        println!("{}", serde_json::to_string_pretty(&report)?);
1078    } else {
1079        println!("Air-gapped verification of {file:?}");
1080        println!("  Org:            {}", report.org_did.as_str());
1081        println!(
1082            "  Verified as-of: KEL seq {} (by position, not wall-clock)",
1083            report.as_of_org_seq
1084        );
1085        let root = if report.root_pinned {
1086            "✅ yes"
1087        } else {
1088            "🛑 NO (untrusted root)"
1089        };
1090        println!("  Root pinned:    {root}");
1091        let dup = if report.duplicity_detected {
1092            "🛑 DETECTED"
1093        } else {
1094            "✅ none"
1095        };
1096        println!("  Duplicity:      {dup}");
1097        if let Some(authority) = &report.authority {
1098            match authority {
1099                AuthorityAtSigning::AuthorizedBeforeRevocation => {
1100                    println!("  Authority:      ✅ AuthorizedBeforeRevocation")
1101                }
1102                AuthorityAtSigning::RejectedAfterRevocation { revoked_at } => {
1103                    println!(
1104                        "  Authority:      🛑 RejectedAfterRevocation {{ revoked_at: {revoked_at} }}"
1105                    )
1106                }
1107                AuthorityAtSigning::RejectedRevokedPositionUnknown { revoked_at } => {
1108                    println!(
1109                        "  Authority:      🛑 RejectedRevokedPositionUnknown {{ revoked_at: {revoked_at} }}"
1110                    )
1111                }
1112                AuthorityAtSigning::NeverDelegated => {
1113                    println!("  Authority:      ❌ NeverDelegated")
1114                }
1115            }
1116        }
1117    }
1118
1119    // Fail-closed exit: anything short of a trusted, non-duplicitous, authorized
1120    // verdict is a hard failure (so CI gates reject it).
1121    if !report.root_pinned {
1122        return Err(anyhow!(
1123            "unauthorized: the bundle's org is not in the pinned trust roots"
1124        ));
1125    }
1126    if report.duplicity_detected {
1127        return Err(anyhow!(
1128            "org KEL duplicity detected — divergent history; resolve before trusting"
1129        ));
1130    }
1131    match report.authority {
1132        None | Some(AuthorityAtSigning::AuthorizedBeforeRevocation) => Ok(()),
1133        Some(AuthorityAtSigning::RejectedAfterRevocation { revoked_at }) => Err(anyhow!(
1134            "unauthorized: signed at/after revocation (KEL seq {revoked_at})"
1135        )),
1136        Some(AuthorityAtSigning::RejectedRevokedPositionUnknown { revoked_at }) => Err(anyhow!(
1137            "unauthorized: member revoked at KEL seq {revoked_at}; artifact has no in-band signing position"
1138        )),
1139        Some(AuthorityAtSigning::NeverDelegated) => {
1140            Err(anyhow!("unauthorized: the org never delegated this member"))
1141        }
1142    }
1143}
1144
1145/// The raw commit object bytes, exactly as `git cat-file commit <oid>` prints
1146/// them — the payload an SSH commit signature is computed over.
1147///
1148/// Args:
1149/// * `repo`: An open git repository.
1150/// * `oid`: The commit's object id.
1151///
1152/// Usage:
1153/// ```ignore
1154/// let bytes = raw_commit_bytes(&repo, oid)?;
1155/// ```
1156pub(crate) fn raw_commit_bytes(repo: &git2::Repository, oid: git2::Oid) -> Result<Vec<u8>> {
1157    let odb = repo.odb().context("open git object database")?;
1158    let obj = odb.read(oid).context("read commit object")?;
1159    Ok(obj.data().to_vec())
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164    use super::expected_signer_mismatch;
1165    use super::raw_commit_bytes;
1166    use super::unrooted_signer_rejected;
1167    use super::{VerifyArtifactResult, verified_summary};
1168    use auths_verifier::Freshness;
1169    use std::process::Command;
1170
1171    #[test]
1172    fn unrooted_signer_rejected_blocks_did_key_self_attestation() {
1173        // A bare did:key signer is a self-attestation with no key-state log: when a
1174        // rooted signer is required, it must fail closed.
1175        assert!(
1176            unrooted_signer_rejected("did:key:z6MkExample", true).is_some(),
1177            "a did:key self-attestation must be rejected when a rooted signer is required"
1178        );
1179        // A did:keri signer is backed by a rotatable, revocable KEL — accepted.
1180        assert!(
1181            unrooted_signer_rejected("did:keri:EExample", true).is_none(),
1182            "a did:keri signer is root-authorized and must pass"
1183        );
1184        // When the policy is off, the verdict is not narrowed.
1185        assert!(unrooted_signer_rejected("did:key:z6MkExample", false).is_none());
1186    }
1187
1188    /// A `VerifyArtifactResult` carrying only the fields the summary reads.
1189    fn verified_result(report: Option<auths_verifier::VerificationReport>) -> VerifyArtifactResult {
1190        VerifyArtifactResult {
1191            file: "x.tar".into(),
1192            valid: true,
1193            digest_match: Some(true),
1194            chain_valid: Some(true),
1195            chain_report: report,
1196            witness_quorum: None,
1197            issuer: Some("did:keri:Esigner".into()),
1198            commit_sha: None,
1199            commit_verified: None,
1200            oidc_join: None,
1201            error: None,
1202        }
1203    }
1204
1205    #[test]
1206    fn verified_summary_names_offline_freshness_never_bare() {
1207        // An offline chain verify carries Unknown freshness; the human summary must say so,
1208        // never render a bare "verified" that reads as real-time fresh.
1209        let report = auths_verifier::VerificationReport::valid(vec![]);
1210        let line = verified_summary(&verified_result(Some(report)));
1211        assert!(
1212            line.contains("freshness unknown"),
1213            "offline verify must surface freshness, got: {line}"
1214        );
1215    }
1216
1217    #[test]
1218    fn verified_summary_names_a_fresh_verdict_when_carried() {
1219        let report =
1220            auths_verifier::VerificationReport::valid(vec![]).with_freshness(Freshness::Fresh);
1221        let line = verified_summary(&verified_result(Some(report)));
1222        assert!(line.contains("freshness fresh"), "got: {line}");
1223    }
1224
1225    /// Regression: the bytes the verifier checks the SSH signature over must
1226    /// be byte-identical to `git cat-file commit`. A prior implementation
1227    /// reconstructed them from raw_header + "\n\n" + message, drifting by one
1228    /// newline and making every valid signature report SshSignatureInvalid.
1229    #[test]
1230    fn raw_commit_bytes_matches_git_cat_file() {
1231        let (dir, repo) = auths_test_utils::git::init_test_repo();
1232        let sig = git2::Signature::now("t", "t@example.com").expect("sig");
1233        let tree_id = {
1234            let mut index = repo.index().expect("index");
1235            index.write_tree().expect("tree")
1236        };
1237        let tree = repo.find_tree(tree_id).expect("find tree");
1238        let oid = repo
1239            .commit(
1240                Some("HEAD"),
1241                &sig,
1242                &sig,
1243                "subject line\n\nbody with trailing newline drift potential\n",
1244                &tree,
1245                &[],
1246            )
1247            .expect("commit");
1248
1249        let via_helper = raw_commit_bytes(&repo, oid).expect("helper");
1250        let via_git = Command::new("git")
1251            .args(["cat-file", "commit", &oid.to_string()])
1252            .current_dir(dir.path())
1253            .output()
1254            .expect("git cat-file");
1255        assert!(via_git.status.success());
1256        assert_eq!(
1257            via_helper, via_git.stdout,
1258            "verifier payload must be byte-identical to git cat-file commit"
1259        );
1260    }
1261
1262    #[test]
1263    fn expect_signer_is_an_allowlist_applied_after_verification() {
1264        // No --expect-signer: never a mismatch (existing behavior preserved).
1265        assert!(expected_signer_mismatch("did:keri:Erelease", None).is_none());
1266        // The verified signer is exactly the expected one: proceed.
1267        assert!(expected_signer_mismatch("did:keri:Erelease", Some("did:keri:Erelease")).is_none());
1268        // A signer other than the expected one: fail closed, naming the expected signer.
1269        let m = expected_signer_mismatch("did:keri:Eattacker", Some("did:keri:Erelease"));
1270        assert!(
1271            m.as_deref().is_some_and(
1272                |s| s.contains("did:keri:Erelease") && s.contains("did:keri:Eattacker")
1273            ),
1274            "a non-expected signer must be rejected with a message naming both, got {m:?}"
1275        );
1276    }
1277}