diff --git a/.github/.cspell/project-dictionary.txt b/.github/.cspell/project-dictionary.txt index c6b121a72..833107f0e 100644 --- a/.github/.cspell/project-dictionary.txt +++ b/.github/.cspell/project-dictionary.txt @@ -14,6 +14,7 @@ mdbook microdnf nextest protoc +pubkey pwsh quickinstall shellcheck diff --git a/.github/.cspell/rust-dependencies.txt b/.github/.cspell/rust-dependencies.txt index d97977e30..f41ad51b6 100644 --- a/.github/.cspell/rust-dependencies.txt +++ b/.github/.cspell/rust-dependencies.txt @@ -1,4 +1,6 @@ // This file is @generated by tidy.sh. // It is not intended for manual editing. +flate +minisign ureq diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c8b1058b..392b3083b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Note: In this file, do not use the hard wrap in the middle of a sentence for com ## [Unreleased] +- Support signature verification. ([#237](https://github.com/taiki-e/install-action/pull/237)) + - Update `syft@latest` to 0.92.0. - Update `cargo-make@latest` to 0.37.2. diff --git a/README.md b/README.md index 21e71ddfb..262c9fa2b 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,8 @@ When installing the tool from GitHub Releases, this action will download the too Additionally, this action will also verify SHA256 checksums for downloaded files in all tools installed from GitHub Releases. This is enabled by default and can be disabled by setting the `checksum` input option to `false`. +Additionally, we also verify signature if the tool distributes signed archives. Signature verification is done at the stage of getting the checksum, so disabling the checksum will also disable signature verification. + See the linked documentation for information on security when installed using [snap](https://snapcraft.io/docs) or [cargo-binstall](https://github.com/cargo-bins/cargo-binstall#faq). ## Compatibility diff --git a/tools/codegen/Cargo.toml b/tools/codegen/Cargo.toml index a3d4e2a8f..9274564b6 100644 --- a/tools/codegen/Cargo.toml +++ b/tools/codegen/Cargo.toml @@ -6,9 +6,13 @@ publish = false [dependencies] anyhow = "1" +flate2 = "1" fs-err = "2" +minisign-verify = "0.2" semver = { version = "1", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" +tar = "0.4" +toml = "0.8" ureq = { version = "2", features = ["json"] } diff --git a/tools/codegen/base/cargo-binstall.json b/tools/codegen/base/cargo-binstall.json index 129647ba8..adaeac4fb 100644 --- a/tools/codegen/base/cargo-binstall.json +++ b/tools/codegen/base/cargo-binstall.json @@ -4,6 +4,9 @@ "rust_crate": "${package}", "asset_name": "${package}-${rust_target}.tgz", "version_range": "latest", + "signing": { + "kind": "minisign-binstall" + }, "platform": { "x86_64_linux_musl": {}, "x86_64_macos": { diff --git a/tools/codegen/src/main.rs b/tools/codegen/src/main.rs index 83267abea..f6b03efc9 100644 --- a/tools/codegen/src/main.rs +++ b/tools/codegen/src/main.rs @@ -1,9 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT +#![allow(clippy::single_match)] + use std::{ cmp::Reverse, collections::{BTreeMap, BTreeSet}, - env, fmt, + env, + ffi::OsStr, + fmt, io::Read, path::{Path, PathBuf}, slice, @@ -118,19 +122,6 @@ fn main() -> Result<()> { } let version_req: Option = match args.get(1) { _ if latest_only => { - if args.get(1).map(String::as_str) == Some("latest") { - if let Some(m) = manifests.map.first_key_value() { - let version = match m.1 { - ManifestRef::Ref { version } => version, - ManifestRef::Real(_) => &m.0 .0, - }; - if !manifests.map.is_empty() - && *version >= releases.first_key_value().unwrap().0 .0.clone().into() - { - return Ok(()); - } - } - } let req = format!("={}", releases.first_key_value().unwrap().0 .0).parse()?; eprintln!("update manifest for versions '{req}'"); Some(req) @@ -154,7 +145,7 @@ fn main() -> Result<()> { if manifests.map.is_empty() { format!("={}", releases.first_key_value().unwrap().0 .0).parse()? } else { - format!(">{}", semver_versions.last().unwrap()).parse()? + format!(">={}", semver_versions.last().unwrap()).parse()? } } else { version_req.parse()? @@ -165,6 +156,7 @@ fn main() -> Result<()> { }; let mut buf = vec![]; + let mut buf2 = vec![]; for (Reverse(semver_version), (version, release)) in &releases { if let Some(version_req) = &version_req { if !version_req.matches(semver_version) { @@ -178,6 +170,7 @@ fn main() -> Result<()> { } let mut download_info = BTreeMap::new(); + let mut pubkey = None; for (&platform, base_download_info) in &base_info.platform { let asset_names = base_download_info .asset_name @@ -204,23 +197,101 @@ fn main() -> Result<()> { } }; - eprintln!("downloading {url} for checksum..."); - let download_cache = download_cache_dir.join(format!( + eprint!("downloading {url} for checksum ... "); + let download_cache = &download_cache_dir.join(format!( "{version}-{platform:?}-{}", Path::new(&url).file_name().unwrap().to_str().unwrap() )); if download_cache.is_file() { - eprintln!(" already downloaded"); + eprintln!("already downloaded"); fs::File::open(download_cache)?.read_to_end(&mut buf)?; } else { download(&url)?.into_reader().read_to_end(&mut buf)?; - eprintln!(" download complete"); + eprintln!("download complete"); fs::write(download_cache, &buf)?; } eprintln!("getting sha256 hash for {url}"); let hash = Sha256::digest(&buf); let hash = format!("{hash:x}"); eprintln!("{hash} *{asset_name}"); + let bin_url = &url; + + match base_info.signing { + Some(Signing { kind: SigningKind::MinisignBinstall }) => { + let url = url.clone() + ".sig"; + let sig_download_cache = &download_cache.with_extension(format!( + "{}.sig", + download_cache.extension().unwrap_or_default().to_str().unwrap() + )); + eprint!("downloading {url} for signature validation ... "); + let sig = if sig_download_cache.is_file() { + eprintln!("already downloaded"); + minisign_verify::Signature::from_file(sig_download_cache)? + } else { + let buf = download(&url)?.into_string()?; + eprintln!("download complete"); + fs::write(sig_download_cache, &buf)?; + minisign_verify::Signature::decode(&buf)? + }; + + let Some(crates_io_info) = &crates_io_info else { + bail!("signing kind minisign-binstall is supported only for rust crate"); + }; + let v = + crates_io_info.versions.iter().find(|v| v.num == *semver_version).unwrap(); + let url = format!("https://crates.io{}", v.dl_path); + let crate_download_cache = + &download_cache_dir.join(format!("{version}-Cargo.toml")); + eprint!("downloading {url} for signature verification ... "); + if crate_download_cache.is_file() { + eprintln!("already downloaded"); + } else { + download(&url)?.into_reader().read_to_end(&mut buf2)?; + let hash = Sha256::digest(&buf2); + if format!("{hash:x}") != v.checksum { + bail!("checksum mismatch for {url}"); + } + let decoder = flate2::read::GzDecoder::new(&*buf2); + let mut archive = tar::Archive::new(decoder); + for entry in archive.entries()? { + let mut entry = entry?; + let path = entry.path()?; + if path.file_name() == Some(OsStr::new("Cargo.toml")) { + entry.unpack(crate_download_cache)?; + break; + } + } + buf2.clear(); + eprintln!("download complete"); + } + if pubkey.is_none() { + let cargo_manifest = toml::from_str::( + &fs::read_to_string(crate_download_cache)?, + )?; + eprintln!( + "algorithm: {}", + cargo_manifest.package.metadata.binstall.signing.algorithm + ); + eprintln!( + "pubkey: {}", + cargo_manifest.package.metadata.binstall.signing.pubkey + ); + assert_eq!( + cargo_manifest.package.metadata.binstall.signing.algorithm, + "minisign" + ); + pubkey = Some(minisign_verify::PublicKey::from_base64( + &cargo_manifest.package.metadata.binstall.signing.pubkey, + )?); + } + let pubkey = pubkey.as_ref().unwrap(); + eprint!("verifying signature for {bin_url} ... "); + let allow_legacy = false; + pubkey.verify(&buf, &sig, allow_legacy)?; + eprintln!("done"); + } + None => {} + } download_info.insert(platform, ManifestDownloadInfo { url: Some(url), @@ -620,10 +691,27 @@ struct BaseManifest { asset_name: Option, /// Path to binary in archive. Default to `${tool}${exe}`. bin: Option, + signing: Option, platform: BTreeMap, version_range: Option, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Signing { + kind: SigningKind, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +#[serde(deny_unknown_fields)] +enum SigningKind { + /// algorithm: minisign + /// public key: package.metadata.binstall.signing.pubkey at Cargo.toml + /// + MinisignBinstall, +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct BaseManifestPlatformInfo { @@ -743,7 +831,39 @@ mod crates_io { #[derive(Debug, Deserialize)] pub struct Version { + pub checksum: String, + pub dl_path: String, pub num: semver::Version, pub yanked: bool, } } + +mod cargo_manifest { + use serde::Deserialize; + + #[derive(Debug, Deserialize)] + pub struct Manifest { + pub package: Package, + } + + #[derive(Debug, Deserialize)] + pub struct Package { + pub metadata: Metadata, + } + + #[derive(Debug, Deserialize)] + pub struct Metadata { + pub binstall: Binstall, + } + + #[derive(Debug, Deserialize)] + pub struct Binstall { + pub signing: BinstallSigning, + } + + #[derive(Debug, Deserialize)] + pub struct BinstallSigning { + pub algorithm: String, + pub pubkey: String, + } +}