From c35d18270ed5854e49216c966eeba9fefcf55940 Mon Sep 17 00:00:00 2001 From: Taiki Endo Date: Sun, 22 Mar 2026 01:45:04 +0900 Subject: [PATCH] Support signature verification for mise and syft --- .github/.cspell/project-dictionary.txt | 3 + CHANGELOG.md | 6 + manifests/knope.json | 36 +++- manifests/mise.json | 32 +++- tools/codegen/base/mise.json | 3 + tools/codegen/base/syft.json | 4 + tools/codegen/src/lib.rs | 2 + tools/codegen/src/main.rs | 242 ++++++++++++++++++++++--- tools/manifest.sh | 5 + 9 files changed, 298 insertions(+), 35 deletions(-) diff --git a/.github/.cspell/project-dictionary.txt b/.github/.cspell/project-dictionary.txt index c99a1675..45caa446 100644 --- a/.github/.cspell/project-dictionary.txt +++ b/.github/.cspell/project-dictionary.txt @@ -20,6 +20,7 @@ libicu linkcheck mdbook microdnf +minisig mirrorlist nextest pluginconf @@ -30,6 +31,8 @@ rclone rdme rootfs sccache +SHASUMS +sigstore syft tombi udeps diff --git a/CHANGELOG.md b/CHANGELOG.md index e9865836..22448209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ Note: In this file, do not use the hard wrap in the middle of a sentence for com ## [Unreleased] +- Support signature verification for `mise` and `syft`. ([#1611](https://github.com/taiki-e/install-action/pull/1611)) + +- Update `mise@latest` to 2026.3.10. + +- Update `knope@latest` to 0.22.4. + - Update `cargo-binstall@latest` to 1.17.8. - Update `tombi@latest` to 0.9.9. diff --git a/manifests/knope.json b/manifests/knope.json index 76d50667..4672bd8e 100644 --- a/manifests/knope.json +++ b/manifests/knope.json @@ -3,10 +3,42 @@ "template": null, "license_markdown": "[MIT](https://github.com/knope-dev/knope/blob/main/LICENSE)", "latest": { - "version": "0.22.3" + "version": "0.22.4" }, "0.22": { - "version": "0.22.3" + "version": "0.22.4" + }, + "0.22.4": { + "x86_64_linux_musl": { + "url": "https://github.com/knope-dev/knope/releases/download/knope/v0.22.4/knope-x86_64-unknown-linux-musl.tgz", + "etag": "0x8DE8761D8F513DE", + "hash": "45a74925ae9f4c9c2c33b51992ae50241ec4fa836bf8d2977c0b8e8172dd69cf", + "bin": "knope-x86_64-unknown-linux-musl/knope" + }, + "x86_64_macos": { + "url": "https://github.com/knope-dev/knope/releases/download/knope/v0.22.4/knope-x86_64-apple-darwin.tgz", + "etag": "0x8DE8761D8E4D27D", + "hash": "010dc197bf159bbd9d60e897252248ba2b0e204beae7250ce54a9deae1ec4876", + "bin": "knope-x86_64-apple-darwin/knope" + }, + "x86_64_windows": { + "url": "https://github.com/knope-dev/knope/releases/download/knope/v0.22.4/knope-x86_64-pc-windows-msvc.tgz", + "etag": "0x8DE8761D8EAE61C", + "hash": "09f735b2da42cd594189042d1379c0a3a350a8c0ccb741015a84c6ff334543b1", + "bin": "knope-x86_64-pc-windows-msvc/knope.exe" + }, + "aarch64_linux_musl": { + "url": "https://github.com/knope-dev/knope/releases/download/knope/v0.22.4/knope-aarch64-unknown-linux-musl.tgz", + "etag": "0x8DE8761D8EE649C", + "hash": "95e882afdb4154c5baaba91f7bbd1fb1d41cec6898363a2b30e7abad4057b83b", + "bin": "knope-aarch64-unknown-linux-musl/knope" + }, + "aarch64_macos": { + "url": "https://github.com/knope-dev/knope/releases/download/knope/v0.22.4/knope-aarch64-apple-darwin.tgz", + "etag": "0x8DE8761D8EC1D47", + "hash": "02131f284315c8ece8a4ef69a0aff5f658309d4df73b95cfdfbe0fbd9e9ce259", + "bin": "knope-aarch64-apple-darwin/knope" + } }, "0.22.3": { "x86_64_linux_musl": { diff --git a/manifests/mise.json b/manifests/mise.json index 1156f593..2a7a7432 100644 --- a/manifests/mise.json +++ b/manifests/mise.json @@ -28,13 +28,39 @@ }, "license_markdown": "[MIT](https://github.com/jdx/mise/blob/main/LICENSE)", "latest": { - "version": "2026.3.9" + "version": "2026.3.10" }, "2026": { - "version": "2026.3.9" + "version": "2026.3.10" }, "2026.3": { - "version": "2026.3.9" + "version": "2026.3.10" + }, + "2026.3.10": { + "x86_64_linux_musl": { + "etag": "0x8DE8749F9B8C65D", + "hash": "b0c2fd25fe95cd1ed3f178b95690608472aa8a27ce2f6e63eaebda52a238e570" + }, + "x86_64_macos": { + "etag": "0x8DE8749FBD637A3", + "hash": "5ed1a2a6a79aab33e67d21156ec42b22d3cde1ceef09eb08c0ccd9b429795e6a" + }, + "x86_64_windows": { + "etag": "0x8DE8749FC98ACBC", + "hash": "bf5e86077f652caca0413155e33886c3459d3f2963f9f186be76c8c05c2accb6" + }, + "aarch64_linux_musl": { + "etag": "0x8DE8749F6470833", + "hash": "9730abf52c93c7945f907f4fe6f731b79d74671705656fa36fa45008933e88c7" + }, + "aarch64_macos": { + "etag": "0x8DE8749FB48C7AB", + "hash": "85b5e577a5ed34431718091122ea7ec9cf7d4e1d8e5e4dc298cdb02d8dbd97b3" + }, + "aarch64_windows": { + "etag": "0x8DE8749FC8C37A5", + "hash": "82a702481c9e877b28f82eb60a0d3be2d393fc9b7915283992b1cd0263724d2b" + } }, "2026.3.9": { "x86_64_linux_musl": { diff --git a/tools/codegen/base/mise.json b/tools/codegen/base/mise.json index 5af27cc2..8847ad2a 100644 --- a/tools/codegen/base/mise.json +++ b/tools/codegen/base/mise.json @@ -4,6 +4,9 @@ "rust_crate": "${package}", "bin": "mise/bin/${package}${exe}", "version_range": ">= 2025.9.7", + "signing": { + "kind": "custom" + }, "platform": { "x86_64_linux_musl": { "asset_name": "${package}-v${version}-${rust_target_os}-x64-musl.tar.gz" diff --git a/tools/codegen/base/syft.json b/tools/codegen/base/syft.json index 2e2ddffa..45528dc7 100644 --- a/tools/codegen/base/syft.json +++ b/tools/codegen/base/syft.json @@ -3,6 +3,10 @@ "tag_prefix": "v", "bin": "${package}${exe}", "version_range": ">= 0.83.0", + "signing": { + "version_range": ">= 0.104.0", + "kind": "custom" + }, "platform": { "x86_64_linux_musl": { "asset_name": "${package}_${version}_linux_amd64.tar.gz" diff --git a/tools/codegen/src/lib.rs b/tools/codegen/src/lib.rs index fdf0244b..21f2e74c 100644 --- a/tools/codegen/src/lib.rs +++ b/tools/codegen/src/lib.rs @@ -95,6 +95,8 @@ pub enum SigningKind { /// public key: package.metadata.binstall.signing.pubkey at Cargo.toml /// MinisignBinstall, + /// tool-specific + Custom, } #[derive(Debug, Deserialize)] diff --git a/tools/codegen/src/main.rs b/tools/codegen/src/main.rs index 6bdb74ca..905c468a 100644 --- a/tools/codegen/src/main.rs +++ b/tools/codegen/src/main.rs @@ -9,7 +9,7 @@ use std::{ env, ffi::OsStr, io::Read as _, - path::Path, + path::{Path, PathBuf}, sync::{LazyLock, RwLock}, time::Duration, }; @@ -20,6 +20,7 @@ use install_action_internal_codegen::{ BaseManifest, HostPlatform, Manifest, ManifestDownloadInfo, ManifestRef, ManifestTemplate, ManifestTemplateDownloadInfo, Manifests, SigningKind, Version, workspace_root, }; +use serde::de::DeserializeOwned; use spdx::expression::{ExprNode, ExpressionReq, Operator}; fn main() { @@ -30,7 +31,7 @@ fn main() { ); std::process::exit(1); } - let package = &args[0]; + let package = &*args[0]; let version_req = args.get(1); let version_req_given = version_req.is_some(); let skip_existing_manifest_versions = std::env::var("SKIP_EXISTING_MANIFEST_VERSIONS").is_ok(); @@ -56,8 +57,7 @@ fn main() { .unwrap(); eprintln!("downloading metadata from {GITHUB_API_START}repos/{repo}"); - let repo_info: github::RepoMetadata = - download(&format!("{GITHUB_API_START}repos/{repo}")).unwrap().into_json().unwrap(); + let repo_info: github::RepoMetadata = download_json(&format!("{GITHUB_API_START}repos/{repo}")); eprintln!("downloading releases from {GITHUB_API_START}repos/{repo}/releases"); let mut releases: github::Releases = vec![]; @@ -65,12 +65,9 @@ fn main() { // is greater than 100, multiple fetches are needed. for page in 1.. { let per_page = 100; - let mut r: github::Releases = download(&format!( + let mut r: github::Releases = download_json(&format!( "{GITHUB_API_START}repos/{repo}/releases?per_page={per_page}&page={page}" - )) - .unwrap() - .into_json() - .unwrap(); + )); // If version_req is latest, it is usually sufficient to look at the latest 100 releases. if r.len() < per_page || version_req.is_some_and(|req| req == "latest") { releases.append(&mut r); @@ -112,17 +109,14 @@ fn main() { .unwrap(); if let Some(crate_name) = &base_info.rust_crate { eprintln!("downloading crate info from https://crates.io/api/v1/crates/{crate_name}"); - let info = download(&format!("https://crates.io/api/v1/crates/{crate_name}")) - .unwrap() - .into_json::() - .unwrap(); + let info: crates_io::Crate = + download_json(&format!("https://crates.io/api/v1/crates/{crate_name}")); let latest_version = &info.versions[0].num; crates_io_version_detail = Some( - download(&format!("https://crates.io/api/v1/crates/{crate_name}/{latest_version}")) - .unwrap() - .into_json::() - .unwrap() - .version, + download_json::(&format!( + "https://crates.io/api/v1/crates/{crate_name}/{latest_version}" + )) + .version, ); if let Some(crate_repository) = info.crate_.repository.clone() { @@ -261,6 +255,14 @@ fn main() { } }; + let signing_version_req: Option = + base_info.signing.as_ref().map(|signing| { + match &signing.version_range { + Some(version_range) => version_range.parse().unwrap(), + None => ">= 0.0.1".parse().unwrap(), // HACK: ignore pre-releases + } + }); + let mut buf = vec![]; let mut buf2 = vec![]; for (Reverse(semver_version), (version, release)) in &releases { @@ -282,11 +284,154 @@ fn main() { continue; } - let signing_version_req: Option = match &base_info.signing { + let mut verified_checksum: Option> = None; + match &base_info.signing { Some(signing) => { - match &signing.version_range { - Some(version_range) => Some(version_range.parse().unwrap()), - None => Some(">= 0.0.1".parse().unwrap()), // HACK: ignore pre-releases + if let SigningKind::Custom = signing.kind { + match package { + _ if !signing_version_req.as_ref().unwrap().matches(semver_version) => {} + "mise" => { + // Refs: https://github.com/jdx/mise/blob/v2026.3.9/src/minisign.rs + let crates_io_info = crates_io_info.as_ref().unwrap(); + let [checksum, sig] = + ["SHASUMS256.txt", "SHASUMS256.txt.minisig"].map(|f| { + let Some(asset) = + release.assets.iter().find(|asset| asset.name == f) + else { + // There is broken release which has no release assets: https://github.com/jdx/mise/releases/tag/v2026.2.14 + return PathBuf::new(); + }; + let download_cache = + download_cache_dir.join(format!("{version}-{f}")); + let url = &asset.browser_download_url; + eprint!("downloading {url} for signature verification ... "); + if download_cache.is_file() { + eprintln!("already downloaded"); + } else { + download_to_buf(url, &mut buf); + eprintln!("download complete"); + fs::write(&download_cache, &buf).unwrap(); + buf.clear(); + } + download_cache + }); + if checksum.as_os_str().is_empty() || sig.as_os_str().is_empty() { + continue; + } + + let v = crates_io_info + .versions + .iter() + .find(|v| v.num == *semver_version) + .unwrap(); + let url = format!("https://crates.io{}", v.dl_path); + let pubkey_download_cache = + &download_cache_dir.join(format!("{version}-minisign.pub")); + eprint!("downloading {url} for signature verification ... "); + if pubkey_download_cache.is_file() { + eprintln!("already downloaded"); + } else { + download_to_buf(&url, &mut buf); + let hash = ring::digest::digest(&ring::digest::SHA256, &buf); + if format!("{hash:?}").strip_prefix("SHA256:").unwrap() + != v.checksum + { + panic!("checksum mismatch for {url}"); + } + let decoder = flate2::read::GzDecoder::new(&*buf); + let mut archive = tar::Archive::new(decoder); + for entry in archive.entries().unwrap() { + let mut entry = entry.unwrap(); + let path = entry.path().unwrap(); + if path.file_name() == Some(OsStr::new("minisign.pub")) { + entry.unpack(pubkey_download_cache).unwrap(); + break; + } + } + buf.clear(); + eprintln!("download complete"); + } + let pubkey = + minisign_verify::PublicKey::from_file(pubkey_download_cache) + .unwrap(); + eprint!("verifying checksum file for {package}@{version} ... "); + let allow_legacy = false; + pubkey + .verify( + &fs::read(&checksum).unwrap(), + &minisign_verify::Signature::from_file(sig).unwrap(), + allow_legacy, + ) + .unwrap(); + verified_checksum = Some( + fs::read_to_string(checksum) + .unwrap() + .lines() + .filter_map(|l| l.split_once(" ")) + .map(|(h, f)| { + (f.trim_ascii().to_owned(), h.trim_ascii().to_owned()) + }) + .collect(), + ); + eprintln!("done"); + } + "syft" => { + // Refs: https://oss.anchore.com/docs/installation/verification/ + let [checksum, certificate, signature] = + ["checksums.txt", "checksums.txt.pem", "checksums.txt.sig"].map( + |f| { + let asset = release + .assets + .iter() + .find(|asset| asset.name.ends_with(f)) + .unwrap(); + let download_cache = + download_cache_dir.join(format!("{version}-{f}")); + let url = &asset.browser_download_url; + eprint!( + "downloading {url} for signature verification ... " + ); + if download_cache.is_file() { + eprintln!("already downloaded"); + } else { + download_to_buf(url, &mut buf); + eprintln!("download complete"); + fs::write(&download_cache, &buf).unwrap(); + buf.clear(); + } + download_cache + }, + ); + eprint!("verifying checksum file for {package}@{version} ... "); + cmd!( + "cosign", + "verify-blob", + &checksum, + "--certificate", + certificate, + "--signature", + signature, + "--certificate-identity-regexp", + format!("https://github\\.com/{repo}/\\.github/workflows/.+"), + "--certificate-oidc-issuer", + "https://token.actions.githubusercontent.com" + ) + .run() + .unwrap(); + verified_checksum = Some( + fs::read_to_string(checksum) + .unwrap() + .lines() + .filter_map(|l| l.split_once(" ")) + .map(|(h, f)| { + (f.trim_ascii().to_owned(), h.trim_ascii().to_owned()) + }) + .collect(), + ); + eprintln!("done"); + } + _ => {} + } } } None => { @@ -294,18 +439,22 @@ fn main() { asset.name.contains(".asc") || asset.name.contains(".gpg") || asset.name.contains(".sig") + || asset.name.contains(".minisig") + || asset.name.contains(".pem") + || asset.name.contains(".crt") + || asset.name.contains(".key") + || asset.name.contains(".pub") }) { eprintln!( - "{package} may supports other signing verification method using {}", + "{package} may supports other signature verification method using {}", asset.name ); } - None } - }; + } let mut download_info = BTreeMap::new(); - let mut pubkey = None; + let mut minisign_binstall_pubkey = None; for (&platform, base_download_info) in &base_info.platform { let asset_names = base_download_info .asset_name @@ -440,7 +589,7 @@ fn main() { if crate_download_cache.is_file() { eprintln!("already downloaded"); } else { - download(&url).unwrap().into_reader().read_to_end(&mut buf2).unwrap(); + download_to_buf(&url, &mut buf2); let hash = ring::digest::digest(&ring::digest::SHA256, &buf2); if format!("{hash:?}").strip_prefix("SHA256:").unwrap() != v.checksum { panic!("checksum mismatch for {url}"); @@ -458,7 +607,7 @@ fn main() { buf2.clear(); eprintln!("download complete"); } - if pubkey.is_none() { + if minisign_binstall_pubkey.is_none() { let cargo_manifest = toml::de::from_str::( &fs::read_to_string(crate_download_cache).unwrap(), ) @@ -475,19 +624,42 @@ fn main() { cargo_manifest.package.metadata.binstall.signing.algorithm, "minisign" ); - pubkey = Some( + minisign_binstall_pubkey = Some( minisign_verify::PublicKey::from_base64( &cargo_manifest.package.metadata.binstall.signing.pubkey, ) .unwrap(), ); } - let pubkey = pubkey.as_ref().unwrap(); + let pubkey = minisign_binstall_pubkey.as_ref().unwrap(); eprint!("verifying signature for {bin_url} ... "); let allow_legacy = false; pubkey.verify(&buf, &sig, allow_legacy).unwrap(); eprintln!("done"); } + SigningKind::Custom => { + if let Some(verified_checksum) = &verified_checksum { + let asset_name_cwd = format!("./{asset_name}"); + let mut checked = false; + for (f, h) in verified_checksum { + if *f == asset_name || *f == asset_name_cwd { + checked = true; + assert_eq!( + hash, *h, + "verified checksum doesn't match with sha256 hash of {asset_name} in {package}@{version}" + ); + } + } + assert!( + checked, + "{asset_name} not found in verified checksum for {package}@{version}" + ); + } else { + unimplemented!( + "unimplemented tool-specific signing handling for {package}" + ); + } + } } } @@ -863,6 +1035,16 @@ fn download(url: &str) -> Result { Err(last_error.unwrap().into()) } +#[track_caller] +fn download_to_buf(url: &str, buf: &mut Vec) { + download(url).unwrap().into_reader().read_to_end(buf).unwrap(); +} + +#[track_caller] +fn download_json(url: &str) -> T { + download(url).unwrap().into_json().unwrap() +} + fn github_head(url: &str) -> Result<()> { eprintln!("fetching head of {url} .."); let mut token = GITHUB_TOKENS.get(url); diff --git a/tools/manifest.sh b/tools/manifest.sh index d5b446a2..9fc4eca1 100755 --- a/tools/manifest.sh +++ b/tools/manifest.sh @@ -11,6 +11,11 @@ cd -- "$(dirname -- "$0")"/.. # ./tools/manifest.sh [PACKAGE [VERSION_REQ]] # ./tools/manifest.sh full +if [[ -n "${GITHUB_ACTIONS:-}" ]] && ! type -P cosign; then + go install github.com/sigstore/cosign/v3/cmd/cosign@latest + sudo mv -- ~/go/bin/cosign /usr/local/bin +fi + if [[ $# -eq 1 ]] && [[ "$1" == "full" ]]; then for manifest in tools/codegen/base/*.json; do package="${manifest##*/}"