Support artifact attestations verification

This commit is contained in:
Taiki Endo
2026-03-21 04:30:53 +09:00
parent 68bba89805
commit 8418e9f725
22 changed files with 2654 additions and 324 deletions

View File

@@ -22,7 +22,7 @@ pub struct BaseManifest {
/// Markdown syntax for links to licenses. Automatically detected if possible.
pub license_markdown: Option<String>,
/// Prefix of release tag.
pub tag_prefix: String,
pub tag_prefix: StringOrArray,
/// Crate name, if this is Rust crate.
pub rust_crate: Option<String>,
pub default_major_version: Option<String>,
@@ -67,6 +67,7 @@ impl BaseManifest {
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Signing {
pub version_range: Option<String>,
pub kind: SigningKind,
}
@@ -74,6 +75,10 @@ pub struct Signing {
#[serde(rename_all = "kebab-case")]
#[serde(deny_unknown_fields)]
pub enum SigningKind {
/// gh attestation
/// <https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations>
#[serde(rename_all = "kebab-case")]
GhAttestation { signer_workflow: String },
/// algorithm: minisign
/// public key: package.metadata.binstall.signing.pubkey at Cargo.toml
/// <https://github.com/cargo-bins/cargo-binstall/blob/HEAD/SIGNING.md>

View File

@@ -1,5 +1,8 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
#[macro_use]
mod process;
use std::{
cmp::Reverse,
collections::{BTreeMap, BTreeSet},
@@ -15,7 +18,7 @@ use anyhow::{Context as _, Result, bail};
use fs_err as fs;
use install_action_internal_codegen::{
BaseManifest, HostPlatform, Manifest, ManifestDownloadInfo, ManifestRef, ManifestTemplate,
ManifestTemplateDownloadInfo, Manifests, Signing, SigningKind, Version, workspace_root,
ManifestTemplateDownloadInfo, Manifests, SigningKind, Version, workspace_root,
};
use spdx::expression::{ExprNode, ExpressionReq, Operator};
@@ -76,7 +79,14 @@ fn main() -> Result<()> {
if release.prerelease {
return None;
}
let version = release.tag_name.strip_prefix(&base_info.tag_prefix)?;
let mut version = None;
for tag_prefix in base_info.tag_prefix.as_slice() {
if let Some(v) = release.tag_name.strip_prefix(tag_prefix) {
version = Some(v);
break;
}
}
let version = version?;
let mut semver_version = version.parse::<semver::Version>();
if semver_version.is_err() {
if let Some(default_major_version) = &base_info.default_major_version {
@@ -215,15 +225,15 @@ fn main() -> Result<()> {
}
}
let version_req: Option<semver::VersionReq> = match version_req {
let version_req: semver::VersionReq = match version_req {
_ if latest_only => {
let req = format!("={}", releases.first_key_value().unwrap().0.0).parse()?;
eprintln!("update manifest for versions '{req}'");
Some(req)
req
}
None => match base_info.version_range {
Some(version_range) => Some(version_range.parse()?),
None => Some(">= 0.0.1".parse()?), // HACK: ignore pre-releases
Some(version_range) => version_range.parse()?,
None => ">= 0.0.1".parse()?, // HACK: ignore pre-releases
},
Some(version_req) => {
for version in manifests.map.keys() {
@@ -247,17 +257,15 @@ fn main() -> Result<()> {
version_req.parse()?
};
eprintln!("update manifest for versions '{req}'");
Some(req)
req
}
};
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) {
continue;
}
if !version_req.matches(semver_version) {
continue;
}
// Specifically skip versions of xbuild with build metadata.
@@ -274,6 +282,28 @@ fn main() -> Result<()> {
continue;
}
let signing_version_req: Option<semver::VersionReq> = match &base_info.signing {
Some(signing) => {
match &signing.version_range {
Some(version_range) => Some(version_range.parse()?),
None => Some(">= 0.0.1".parse()?), // HACK: ignore pre-releases
}
}
None => {
if let Some(asset) = release.assets.iter().find(|asset| {
asset.name.contains(".asc")
|| asset.name.contains(".gpg")
|| asset.name.contains(".sig")
}) {
eprintln!(
"{package} may supports other signing verification method using {}",
asset.name
);
}
None
}
};
let mut download_info = BTreeMap::new();
let mut pubkey = None;
for (&platform, base_download_info) in &base_info.platform {
@@ -323,6 +353,7 @@ fn main() -> Result<()> {
if let Some(entry) = manifest.download_info.get(&platform) {
if entry.etag == etag {
eprintln!("existing etag matched");
// NB: Comment out these two lines when adding verification for old release.
download_info.insert(platform, entry.clone());
continue;
}
@@ -354,81 +385,103 @@ fn main() -> Result<()> {
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)?
};
if let Some(signing) = &base_info.signing {
match &signing.kind {
_ if !signing_version_req.as_ref().unwrap().matches(semver_version) => {}
SigningKind::GhAttestation { signer_workflow } => {
eprintln!("verifying {url} with gh attestation verify");
let signer_workflow = signer_workflow.replace("${repo}", repo);
cmd!(
"gh",
"attestation",
"verify",
"--repo",
repo,
"--signer-workflow",
signer_workflow,
&download_cache
)
.run()?;
}
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 = ring::digest::digest(&ring::digest::SHA256, &buf2);
if format!("{hash:?}").strip_prefix("SHA256:").unwrap() != 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;
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 = ring::digest::digest(&ring::digest::SHA256, &buf2);
if format!("{hash:?}").strip_prefix("SHA256:").unwrap() != 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");
}
buf2.clear();
eprintln!("download complete");
if pubkey.is_none() {
let cargo_manifest = toml::de::from_str::<cargo_manifest::Manifest>(
&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");
}
if pubkey.is_none() {
let cargo_manifest = toml::de::from_str::<cargo_manifest::Manifest>(
&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(

View File

@@ -0,0 +1,155 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
use std::{
fmt, format,
process::{Command, ExitStatus, Output},
str,
string::{String, ToString as _},
};
use anyhow::{Context as _, Error, Result};
macro_rules! cmd {
($program:expr $(, $arg:expr)* $(,)?) => {{
let mut _cmd = std::process::Command::new($program);
$(
_cmd.arg($arg);
)*
$crate::process::ProcessBuilder::from_std(_cmd)
}};
}
// A builder for an external process, inspired by https://github.com/rust-lang/cargo/blob/0.47.0/src/cargo/util/process_builder.rs
#[must_use]
pub(crate) struct ProcessBuilder {
cmd: Command,
}
impl ProcessBuilder {
pub(crate) fn from_std(cmd: Command) -> Self {
Self { cmd }
}
// pub(crate) fn into_std(self) -> Command {
// self.cmd
// }
// /// Adds an argument to pass to the program.
// pub(crate) fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
// self.cmd.arg(arg.as_ref());
// self
// }
// /// Adds multiple arguments to pass to the program.
// pub(crate) fn args(&mut self, args: impl IntoIterator<Item = impl AsRef<OsStr>>) -> &mut Self {
// self.cmd.args(args);
// self
// }
// /// Set a variable in the process's environment.
// pub(crate) fn env(&mut self, key: impl AsRef<OsStr>, val: impl AsRef<OsStr>) -> &mut Self {
// self.cmd.env(key.as_ref(), val.as_ref());
// self
// }
/// Executes a process, waiting for completion, and mapping non-zero exit
/// status to an error.
pub(crate) fn run(&mut self) -> Result<()> {
let status = self.cmd.status().with_context(|| {
process_error(format!("could not execute process {self}"), None, None)
})?;
if status.success() {
Ok(())
} else {
Err(process_error(
format!("process didn't exit successfully: {self}"),
Some(status),
None,
))
}
}
// /// Executes a process, captures its stdio output, returning the captured
// /// output, or an error if non-zero exit status.
// pub(crate) fn run_with_output(&mut self) -> Result<Output> {
// let output = self.cmd.output().with_context(|| {
// process_error(format!("could not execute process {self}"), None, None)
// })?;
// if output.status.success() {
// Ok(output)
// } else {
// Err(process_error(
// format!("process didn't exit successfully: {self}"),
// Some(output.status),
// Some(&output),
// ))
// }
// }
// /// Executes a process, captures its stdio output, returning the captured
// /// standard output as a `String`.
// pub(crate) fn read(&mut self) -> Result<String> {
// let mut output = String::from_utf8(self.run_with_output()?.stdout)
// .with_context(|| format!("failed to parse output from {self}"))?;
// while output.ends_with('\n') || output.ends_with('\r') {
// output.pop();
// }
// Ok(output)
// }
}
// Based on https://github.com/rust-lang/cargo/blob/0.47.0/src/cargo/util/process_builder.rs
impl fmt::Display for ProcessBuilder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if !f.alternate() {
f.write_str("`")?;
}
f.write_str(&self.cmd.get_program().to_string_lossy())?;
for arg in self.cmd.get_args() {
write!(f, " {}", arg.to_string_lossy())?;
}
if !f.alternate() {
f.write_str("`")?;
}
Ok(())
}
}
// Based on https://github.com/rust-lang/cargo/blob/0.47.0/src/cargo/util/errors.rs
/// Creates a new process error.
///
/// `status` can be `None` if the process did not launch.
/// `output` can be `None` if the process did not launch, or output was not captured.
fn process_error(mut msg: String, status: Option<ExitStatus>, output: Option<&Output>) -> Error {
match status {
Some(s) => {
msg.push_str(" (");
msg.push_str(&s.to_string());
msg.push(')');
}
None => msg.push_str(" (never executed)"),
}
if let Some(out) = output {
match str::from_utf8(&out.stdout) {
Ok(s) if !s.trim_start().is_empty() => {
msg.push_str("\n--- stdout\n");
msg.push_str(s);
}
Ok(_) | Err(_) => {}
}
match str::from_utf8(&out.stderr) {
Ok(s) if !s.trim_start().is_empty() => {
msg.push_str("\n--- stderr\n");
msg.push_str(s);
}
Ok(_) | Err(_) => {}
}
}
Error::msg(msg)
}