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