diff --git a/crates/auths-cli/src/commands/artifact/verify.rs b/crates/auths-cli/src/commands/artifact/verify.rs index ca0aab99..88b28f28 100644 --- a/crates/auths-cli/src/commands/artifact/verify.rs +++ b/crates/auths-cli/src/commands/artifact/verify.rs @@ -501,9 +501,30 @@ pub async fn handle_verify(file: &Path, args: VerifyArtifactArgs) -> Result<()> valid = false; } Some(sha) => { - // Verify the commit is signed by a trusted key. - // Uses in-process verification via auths-verifier (no git shell-out). - let commit_sig_ok = verify_commit_in_process(sha).await; + // Verify the commit is signed by a trusted key. With an identity + // bundle the commit leg verifies against the bundle's evidenced + // KELs (the stateless CI posture — same doctrine as + // `auths verify --identity-bundle`); otherwise the local + // registry resolves the signer in-process (no git shell-out). + let commit_sig_ok = match &identity_bundle { + Some(bundle_path) => { + match crate::commands::verify_commit::commit_trusted_via_bundle( + sha, + bundle_path, + ) + .await + { + Ok(()) => true, + Err(reason) => { + if !is_json_mode() { + eprintln!(" Commit-anchor leg failed: {reason}"); + } + false + } + } + } + None => verify_commit_in_process(sha).await, + }; if !commit_sig_ok { valid = false; @@ -643,7 +664,12 @@ pub async fn handle_verify(file: &Path, args: VerifyArtifactArgs) -> Result<()> commit_sha: commit_sha_val, commit_verified, oidc_join, - error: log_anchor_error.or(oidc_error), + error: log_anchor_error.or(oidc_error).or_else(|| { + (!valid).then(|| { + "attestation chain or its commit-anchor leg did not verify (see lines above)" + .to_string() + }) + }), }, ) } @@ -703,6 +729,18 @@ fn resolve_identity_key( identity_bundle: &Option, attestation: &Attestation, ) -> Result<(auths_verifier::DevicePublicKey, CanonicalDid)> { + // An ephemeral (`did:key:`) attestation is SELF-signed: its chain leg can only + // verify against the issuer's own in-band key — the bundle's root never signed + // it. The bundle still anchors the COMMIT leg (artifact ← ephemeral key ← + // commit ← maintainer), enforced in the ephemeral commit-anchor step below. + if attestation.issuer.as_str().starts_with("did:key:") { + let issuer = &attestation.issuer; + let (pk_bytes, curve) = resolve_pk_from_did(issuer) + .with_context(|| format!("Failed to resolve public key from issuer DID '{issuer}'"))?; + let pk = auths_verifier::DevicePublicKey::try_new(curve, &pk_bytes) + .map_err(|e| anyhow!("Invalid issuer public key resolved from DID: {e}"))?; + return Ok((pk, issuer.clone())); + } if let Some(bundle_path) = identity_bundle { let bundle_content = fs::read_to_string(bundle_path) .with_context(|| format!("Failed to read identity bundle: {:?}", bundle_path))?; diff --git a/crates/auths-cli/src/commands/verify_commit.rs b/crates/auths-cli/src/commands/verify_commit.rs index f8faba30..7b0fc0d8 100644 --- a/crates/auths-cli/src/commands/verify_commit.rs +++ b/crates/auths-cli/src/commands/verify_commit.rs @@ -603,6 +603,98 @@ fn raw_commit_object(sha: &str) -> Result { /// Map a [`CommitVerdict`] onto a CLI result row: the valid flag, the verified signer, /// and a human-readable reason for every failure mode. +/// Bundle-evidence commit trust for sibling commands — the artifact verifier's +/// ephemeral commit-anchor leg. Same doctrine as `auths verify +/// --identity-bundle`: the repo's pinned roots are the anchor, the bundle is KEL +/// EVIDENCE only (RT-005), and the verdict tolerates the bundle's offline +/// freshness grade exactly as the commit command does. +/// +/// Args: +/// * `sha`: the full commit SHA the attestation anchors to. +/// * `bundle_path`: the identity bundle supplied to the artifact verify. +/// +/// Usage: +/// ```ignore +/// commit_trusted_via_bundle(&sha, Path::new(".auths/ci-bundle.json")).await?; +/// ``` +pub(crate) async fn commit_trusted_via_bundle( + sha: &str, + bundle_path: &std::path::Path, +) -> std::result::Result<(), String> { + let raw_commit = raw_commit_object(sha).map_err(|e| e.to_string())?; + let (trailer_root_did, device_did) = + auths_sdk::workflows::commit_trust::commit_signer_trailers(&raw_commit) + .ok_or_else(|| "commit carries no Auths-Id/Auths-Device trailers".to_string())?; + + let pinned_roots = super::verify_helpers::load_project_pinned_roots(); + if pinned_roots.is_empty() { + return Err( + "no pinned roots (.auths/roots) — a bundle is evidence for a pinned root, \ + never the source of the pin" + .to_string(), + ); + } + #[allow(clippy::disallowed_methods)] // presentation boundary: freshness needs the wall clock + let now = chrono::Utc::now(); + let (root, kel, device_kels) = load_bundle_trust(bundle_path, now)?; + if !pinned_roots.contains(&root) { + return Err(format!( + "identity bundle root {root} is not independently trusted: add it to .auths/roots" + )); + } + let mut bundle_kels: Vec = Vec::new(); + if !kel.is_empty() { + bundle_kels.push(BundleKel { + did: root, + events: kel, + }); + } + bundle_kels.extend( + device_kels + .into_iter() + .map(|(did, events)| BundleKel { did, events }), + ); + + let auths_home = auths_sdk::paths::auths_home().map_err(|e| e.to_string())?; + let registry = + GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(&auths_home)); + let device_kel = resolve_signer_kel(®istry, &bundle_kels, &device_did) + .await + .map_err(|e| format!("device KEL for {device_did} could not be resolved: {e}"))?; + let root_did = match device_kel.first().and_then(|e| e.delegator()) { + Some(delegator) => format!("did:keri:{delegator}"), + None => trailer_root_did, + }; + let root_kel = resolve_signer_kel(®istry, &bundle_kels, &root_did) + .await + .map_err(|e| format!("root KEL for {root_did} could not be resolved: {e}"))?; + + let provider = auths_crypto::RingCryptoProvider; + let receipt_lookup = GitWitnessReceiptLookup::new(&auths_home); + let witnessed = verify_commit_against_kel_witnessed_scoped( + raw_commit.as_bytes(), + &device_kel, + &root_kel, + &pinned_roots, + &provider, + &receipt_lookup, + VerifierWitnessPolicy::Warn, + now.timestamp(), + ) + .await; + let verdict = witnessed + .verdict + .with_freshness(&FreshnessPolicy::default(), FreshnessEvidence::Offline); + let result = verdict_to_result(sha.to_string(), verdict); + if result.valid { + Ok(()) + } else { + Err(result.error.unwrap_or_else(|| { + "commit did not verify against the bundle-evidenced KELs".to_string() + })) + } +} + fn verdict_to_result(commit: String, verdict: CommitVerdict) -> VerifyCommitResult { let mut result = VerifyCommitResult::failure(commit, String::new()); // The stable machine code travels in the `status` field regardless of the