diff --git a/crates/aqua-registry/src/lib.rs b/crates/aqua-registry/src/lib.rs index 1bdf889e3f..1a4cbb3301 100644 --- a/crates/aqua-registry/src/lib.rs +++ b/crates/aqua-registry/src/lib.rs @@ -13,8 +13,8 @@ pub use registry::{ AquaRegistryMetadata, DefaultRegistryFetcher, FileCacheStore, NoOpCacheStore, package_ids, }; pub use types::{ - AquaChecksum, AquaChecksumType, AquaMinisignType, AquaPackage, AquaPackageType, AquaVar, - RegistryYaml, + AquaChecksum, AquaChecksumType, AquaCosign, AquaMinisignType, AquaPackage, AquaPackageType, + AquaVar, RegistryYaml, }; use thiserror::Error; diff --git a/crates/aqua-registry/src/types.rs b/crates/aqua-registry/src/types.rs index f7d4da76c9..45e650ede9 100644 --- a/crates/aqua-registry/src/types.rs +++ b/crates/aqua-registry/src/types.rs @@ -45,6 +45,7 @@ pub struct AquaPackage { #[serde(skip)] version_filter_expr: Option, pub version_source: Option, + pub cosign: Option, pub checksum: Option, pub slsa_provenance: Option, pub minisign: Option, @@ -213,6 +214,7 @@ impl Default for AquaPackage { version_filter: None, version_filter_expr: None, version_source: None, + cosign: None, checksum: None, slsa_provenance: None, minisign: None, @@ -663,6 +665,17 @@ fn apply_override(mut orig: AquaPackage, avo: &AquaPackage) -> AquaPackage { } } + if let Some(avo_cosign) = &avo.cosign { + match &mut orig.cosign { + Some(cosign) => { + cosign.merge(avo_cosign.clone()); + } + None => { + orig.cosign = Some(avo_cosign.clone()); + } + } + } + if let Some(avo_slsa_provenance) = avo.slsa_provenance.clone() { match &mut orig.slsa_provenance { Some(slsa_provenance) => { @@ -1258,4 +1271,53 @@ mod tests { "unexpected error: {err}" ); } + + #[test] + fn test_top_level_cosign_is_deserialized() { + let yml = r#" +packages: + - cosign: + bundle: + type: github_release + asset: "{{.Asset}}.sigstore.json" +"#; + let pkg = serde_yaml::from_str::(yml) + .unwrap() + .packages + .into_iter() + .next() + .unwrap(); + assert!(pkg.cosign.is_some()); + assert!(pkg.checksum.is_none()); + } + + #[test] + fn test_top_level_cosign_is_merged_from_version_override() { + let yml = r#" +packages: + - asset: tool-{{.Version}}-{{.OS}}-{{.Arch}} + format: raw + cosign: + bundle: + type: github_release + asset: "{{.Asset}}.sigstore.json" + version_constraint: "false" + version_overrides: + - version_constraint: "true" + cosign: + key: + type: github_release + asset: cosign.pub +"#; + let pkg = serde_yaml::from_str::(yml) + .unwrap() + .packages + .into_iter() + .next() + .unwrap() + .with_version(&["v1.0.0"], "linux", "amd64"); + let cosign = pkg.cosign.unwrap(); + assert!(cosign.bundle.is_some()); + assert!(cosign.key.is_some()); + } } diff --git a/e2e/backend/test_aqua_cosign b/e2e/backend/test_aqua_cosign index 46604c208c..18c46e7a6f 100644 --- a/e2e/backend/test_aqua_cosign +++ b/e2e/backend/test_aqua_cosign @@ -1,7 +1,6 @@ #!/usr/bin/env bash -# Test native Cosign verification for aqua packages -# Uses fork-cleaner which has bundle-based cosign (native verification), -# unlike sops which only has opts-based cosign (CLI pass-through). +# Test native Cosign verification for aqua packages. +# Covers both checksum-level cosign and top-level binary cosign. set -euo pipefail @@ -10,7 +9,7 @@ export MISE_AQUA_COSIGN=true export MISE_AQUA_SLSA=false export MISE_AQUA_GITHUB_ATTESTATIONS=false -echo "=== Testing Native Cosign Verification ===" +echo "=== Testing Native Cosign Verification (checksum-level) ===" # Test: Install fork-cleaner which has cosign bundle verification configured echo "Installing fork-cleaner with native Cosign verification..." @@ -33,8 +32,23 @@ fi assert_contains "mise x aqua:caarlos0/fork-cleaner@2.4.0 -- fork-cleaner --version" "2.4.0" echo "✓ fork-cleaner installed and working correctly" -# Cleanup -mise uninstall aqua:caarlos0/fork-cleaner@2.4.0 || true +echo "=== Testing Native Cosign Verification (top-level binary config) ===" +echo "Installing envsense with top-level binary Cosign verification..." + +output=$(mise install aqua:technicalpickles/envsense@0.3.4 2>&1) +echo "$output" + +if echo "$output" | grep -q "Cosign verified" && ! echo "$output" | grep -q "verify checksums with cosign"; then + echo "✅ Native top-level binary Cosign verification was used" +else + echo "❌ ERROR: top-level binary Cosign verification was not detected in output" + echo "Output was:" + echo "$output" + exit 1 +fi + +assert_contains "mise x aqua:technicalpickles/envsense@0.3.4 -- which envsense" "envsense" +echo "✓ envsense installed and working correctly" echo "" echo "=== Native Cosign Verification Test Passed ✓ ===" diff --git a/e2e/lockfile/test_lockfile_cosign_top_level_binary b/e2e/lockfile/test_lockfile_cosign_top_level_binary new file mode 100644 index 0000000000..1c55408cbf --- /dev/null +++ b/e2e/lockfile/test_lockfile_cosign_top_level_binary @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Regression test: top-level aqua cosign config should record cosign provenance. + +set -euo pipefail + +export MISE_LOCKFILE=1 +export MISE_AQUA_COSIGN=true +export MISE_AQUA_SLSA=false +export MISE_GITHUB_ATTESTATIONS=0 +export MISE_AQUA_GITHUB_ATTESTATIONS=false + +detect_platform +PLATFORM="$MISE_PLATFORM" + +echo "=== Testing top-level cosign lockfile provenance ===" + +cat <mise.toml +[tools] +"aqua:technicalpickles/envsense" = "0.3.4" +EOF + +mise lock --platform "$PLATFORM" +assert "test -f mise.lock" +assert_contains "cat mise.lock" 'provenance = "cosign"' + +echo "=== Testing install with top-level cosign lockfile ===" +mise install + +echo "top-level cosign lockfile test passed!" diff --git a/src/aqua/aqua_registry_wrapper.rs b/src/aqua/aqua_registry_wrapper.rs index 7b40685519..0d7092444b 100644 --- a/src/aqua/aqua_registry_wrapper.rs +++ b/src/aqua/aqua_registry_wrapper.rs @@ -158,5 +158,5 @@ pub fn aqua_suggest(query: &str) -> Vec { // Re-export types and static for compatibility pub use aqua_registry::{ - AquaChecksum, AquaChecksumType, AquaMinisignType, AquaPackage, AquaPackageType, + AquaChecksum, AquaChecksumType, AquaCosign, AquaMinisignType, AquaPackage, AquaPackageType, }; diff --git a/src/backend/aqua.rs b/src/backend/aqua.rs index c9be6508b4..2631f2a0a8 100644 --- a/src/backend/aqua.rs +++ b/src/backend/aqua.rs @@ -17,7 +17,7 @@ use crate::toolset::{EPHEMERAL_OPT_KEYS, ToolRequest, ToolVersion, ToolVersionOp use crate::ui::progress_report::SingleReport; use crate::{ aqua::aqua_registry_wrapper::{ - AQUA_REGISTRY, AquaChecksum, AquaChecksumType, AquaMinisignType, AquaPackage, + AQUA_REGISTRY, AquaChecksum, AquaChecksumType, AquaCosign, AquaMinisignType, AquaPackage, AquaPackageType, }, cache::{CacheManager, CacheManagerBuilder}, @@ -169,12 +169,9 @@ impl Backend for AquaBackend { features.push(SecurityFeature::Slsa { level: None }); } - // Cosign (nested in checksum) - check registry config OR actual release assets + // Cosign - check registry config OR actual release assets let has_cosign_config = all_pkgs.iter().any(|p| { - p.checksum - .as_ref() - .and_then(|c| c.cosign.as_ref()) - .is_some_and(|cosign| cosign.enabled.unwrap_or(true)) + Self::binary_cosign_config(p).is_some() || Self::checksum_cosign_config(p).is_some() }); let has_cosign_assets = release_assets.iter().any(|a| { let name = a.name.to_lowercase(); @@ -864,6 +861,28 @@ impl AquaBackend { } } + fn has_native_cosign(cosign: &AquaCosign) -> bool { + cosign.enabled != Some(false) && (cosign.key.is_some() || cosign.bundle.is_some()) + } + + fn binary_cosign_config(pkg: &AquaPackage) -> Option<&AquaCosign> { + pkg.cosign + .as_ref() + .filter(|cosign| Self::has_native_cosign(cosign)) + } + + fn checksum_cosign_config(pkg: &AquaPackage) -> Option<(&AquaChecksum, &AquaCosign)> { + let checksum = pkg + .checksum + .as_ref() + .filter(|checksum| checksum.enabled())?; + let cosign = checksum + .cosign + .as_ref() + .filter(|cosign| Self::has_native_cosign(cosign))?; + Some((checksum, cosign)) + } + /// Detect provenance type from aqua registry package config. /// /// Returns the highest-priority provenance type that is configured and @@ -899,16 +918,13 @@ impl AquaBackend { return Some(ProvenanceType::Slsa { url: None }); } - // Check for cosign (nested under checksum config, requires checksum enabled) + // Check for cosign. // Only record cosign provenance if we can actually verify it natively // (key-based or bundle-based). Tools that only use opts require the external // cosign CLI which we don't shell out to. if settings.aqua.cosign - && let Some(checksum) = &pkg.checksum - && checksum.enabled() - && let Some(cosign) = checksum.cosign.as_ref() - && cosign.enabled != Some(false) - && (cosign.key.is_some() || cosign.bundle.is_some()) + && (Self::binary_cosign_config(pkg).is_some() + || Self::checksum_cosign_config(pkg).is_some()) { return Some(ProvenanceType::Cosign); } @@ -965,15 +981,19 @@ impl AquaBackend { Ok(ProvenanceType::Minisign) } ProvenanceType::Cosign => { - let checksum_config = pkg - .checksum - .as_ref() - .wrap_err("cosign provenance detected but no checksum config found")?; - let checksum_path = self - .download_checksum_file(checksum_config, pkg, v, tmp_dir.path(), None) - .await?; - self.run_cosign_check(&checksum_path, pkg, v, tmp_dir.path(), None) - .await?; + if let Some(cosign) = Self::binary_cosign_config(pkg) { + self.run_cosign_check(&artifact_path, cosign, pkg, v, tmp_dir.path(), None) + .await?; + } else { + let (checksum_config, cosign) = Self::checksum_cosign_config(pkg).wrap_err( + "cosign provenance detected but no supported binary/checksum config found", + )?; + let checksum_path = self + .download_checksum_file(checksum_config, pkg, v, tmp_dir.path(), None) + .await?; + self.run_cosign_check(&checksum_path, cosign, pkg, v, tmp_dir.path(), None) + .await?; + } Ok(ProvenanceType::Cosign) } } @@ -1128,22 +1148,16 @@ impl AquaBackend { Ok(()) } - /// Download cosign key/signature/bundle and verify checksums file. - /// The checksum file must already be downloaded at `checksum_path`. + /// Download cosign key/signature/bundle and verify a target file. async fn run_cosign_check( &self, - checksum_path: &Path, + target_path: &Path, + cosign: &AquaCosign, pkg: &AquaPackage, v: &str, download_dir: &Path, pr: Option<&dyn SingleReport>, ) -> Result<()> { - let cosign = pkg - .checksum - .as_ref() - .and_then(|c| c.cosign.as_ref()) - .wrap_err("cosign provenance detected but no config found")?; - if let Some(key) = &cosign.key { let mut key_pkg = pkg.clone(); (key_pkg.repo_owner, key_pkg.repo_name) = @@ -1178,11 +1192,11 @@ impl AquaBackend { HTTP.download_file(&sig_url, &path, pr).await?; path } else { - checksum_path.with_extension("sig") + target_path.with_extension("sig") }; match crate::github::sigstore::verify_cosign_signature_with_key( - checksum_path, + target_path, &sig_path, &key_path, ) @@ -1212,8 +1226,7 @@ impl AquaBackend { let bundle_path = download_dir.join(get_filename_from_url(&bundle_url)); HTTP.download_file(&bundle_url, &bundle_path, pr).await?; - match crate::github::sigstore::verify_cosign_signature(checksum_path, &bundle_path) - .await + match crate::github::sigstore::verify_cosign_signature(target_path, &bundle_path).await { Ok(true) => { debug!("cosign (bundle) verified"); @@ -1626,7 +1639,7 @@ impl AquaBackend { } if !skip_minisign { // Short-circuit: if SLSA or GithubAttestations already recorded provenance, skip minisign. - // Cosign runs later in the checksum block, so it cannot be set at this point. + // Cosign runs later, so it cannot be set at this point. let already_verified = tv .lock_platforms .get(&platform_key) @@ -1638,6 +1651,23 @@ impl AquaBackend { } let download_path = tv.download_path(); + let mut cosign_already_verified = tv + .lock_platforms + .get(&platform_key) + .and_then(|pi| pi.provenance.as_ref()) + .is_some_and(|p| *p > ProvenanceType::Cosign); + + if !skip_cosign + && Settings::get().aqua.cosign + && !cosign_already_verified + && let Some(cosign) = Self::binary_cosign_config(pkg) + { + let artifact_path = download_path.join(filename); + self.cosign_artifact(ctx, cosign, pkg, v, tv, &artifact_path) + .await?; + cosign_already_verified = true; + } + if let Some(checksum) = &pkg.checksum && checksum.enabled() { @@ -1648,21 +1678,10 @@ impl AquaBackend { .get(&platform_key) .is_none_or(|pi| pi.checksum.is_none()); - let needs_cosign = !skip_cosign - && Settings::get().aqua.cosign - && checksum - .cosign - .as_ref() - .is_some_and(|c| c.enabled != Some(false)); - // Short-circuit cosign if a higher-priority mechanism already recorded provenance. - // Safe to cache: provenance is only modified by the single-threaded verification - // methods above (attestations, slsa, minisign), all of which have completed by now. - let cosign_already_verified = needs_cosign - && tv - .lock_platforms - .get(&platform_key) - .and_then(|pi| pi.provenance.as_ref()) - .is_some_and(|p| *p > ProvenanceType::Cosign); + let checksum_cosign = (!skip_cosign && Settings::get().aqua.cosign) + .then(|| Self::checksum_cosign_config(pkg).map(|(_, cosign)| cosign)) + .flatten(); + let needs_cosign = checksum_cosign.is_some(); // Re-download only if the checksum file doesn't exist yet. An existing file // from a prior attempt is trusted because the download directory is version-specific // and the final artifact is independently verified by verify_checksum at the end. @@ -1680,8 +1699,11 @@ impl AquaBackend { .await?; } - if needs_cosign && !cosign_already_verified && checksum_path.exists() { - self.cosign_checksums(ctx, pkg, v, tv, &checksum_path, &download_path) + if let Some(cosign) = checksum_cosign + && !cosign_already_verified + && checksum_path.exists() + { + self.cosign_checksums(ctx, cosign, pkg, v, tv, &checksum_path) .await?; } @@ -1861,47 +1883,70 @@ impl AquaBackend { Ok(()) } + async fn cosign_artifact( + &self, + ctx: &InstallContext, + cosign: &AquaCosign, + pkg: &AquaPackage, + v: &str, + tv: &mut ToolVersion, + artifact_path: &Path, + ) -> Result<()> { + let download_path = tv.download_path(); + ctx.pr + .set_message("verify artifact with cosign".to_string()); + self.run_cosign_check( + artifact_path, + cosign, + pkg, + v, + &download_path, + Some(ctx.pr.as_ref()), + ) + .await?; + + ctx.pr.set_message("✓ Cosign verified".to_string()); + self.record_cosign_provenance(tv); + Ok(()) + } + async fn cosign_checksums( &self, ctx: &InstallContext, + cosign: &AquaCosign, pkg: &AquaPackage, v: &str, tv: &mut ToolVersion, checksum_path: &Path, - download_path: &Path, ) -> Result<()> { - if !Settings::get().aqua.cosign { - return Ok(()); - } - if let Some(cosign) = pkg.checksum.as_ref().and_then(|c| c.cosign.as_ref()) { - if cosign.enabled == Some(false) { - debug!("cosign is disabled for {tv}"); - return Ok(()); - } - - // Opts-only config (no key or bundle) — nothing to verify natively - if cosign.key.is_none() && cosign.bundle.is_none() { - debug!("cosign for {tv} uses opts-only config, skipping native verification"); - return Ok(()); - } + let download_path = tv.download_path(); + ctx.pr + .set_message("verify checksums with cosign".to_string()); + self.run_cosign_check( + checksum_path, + cosign, + pkg, + v, + &download_path, + Some(ctx.pr.as_ref()), + ) + .await?; - ctx.pr - .set_message("verify checksums with cosign".to_string()); - self.run_cosign_check(checksum_path, pkg, v, download_path, Some(ctx.pr.as_ref())) - .await?; + ctx.pr.set_message("✓ Cosign verified".to_string()); + self.record_cosign_provenance(tv); + Ok(()) + } - ctx.pr.set_message("✓ Cosign verified".to_string()); - let platform_key = self.get_platform_key(); - let pi = tv.lock_platforms.entry(platform_key).or_default(); - if pi - .provenance - .as_ref() - .is_none_or(|p| *p < ProvenanceType::Cosign) - { - pi.provenance = Some(ProvenanceType::Cosign); - } + fn record_cosign_provenance(&self, tv: &mut ToolVersion) { + let platform_key = self.get_platform_key(); + let pi = tv.lock_platforms.entry(platform_key).or_default(); + if pi + .provenance + .as_ref() + .is_none_or(|p| *p < ProvenanceType::Cosign) + { + pi.provenance = Some(ProvenanceType::Cosign); } - Ok(()) } fn install(