use std::fmt;
use std::fs;
use std::io::Read;
use std::path::Path;
use anyhow::{anyhow, Context, Result};
use minisign_verify::{PublicKey, Signature};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VerifyError {
Signature(String),
Checksum(String),
}
impl fmt::Display for VerifyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
VerifyError::Signature(detail) => write!(f, "Signature check failed: {detail}"),
VerifyError::Checksum(detail) => write!(f, "Checksum check failed: {detail}"),
}
}
}
impl std::error::Error for VerifyError {}
pub fn verify_signature(checksums: &[u8], signature: &str, public_key_b64: &str) -> Result<()> {
let public_key = PublicKey::from_base64(public_key_b64)
.map_err(|error| anyhow!("Embedded minisign public key is invalid: {error}"))?;
let signature = Signature::decode(signature).map_err(|error| {
VerifyError::Signature(format!("signature file is malformed ({error})"))
})?;
public_key
.verify(checksums, &signature, false)
.map_err(|error| {
VerifyError::Signature(format!(
"checksums.txt is not signed by the Arete release key ({error})"
))
})?;
Ok(())
}
pub fn parse_checksums(text: &str) -> Vec<(String, String)> {
text.lines()
.filter_map(|line| {
let mut parts = line.split_whitespace();
let sha = parts.next()?;
let name = parts.next()?.trim_start_matches('*');
if sha.len() != 64 || !sha.chars().all(|c| c.is_ascii_hexdigit()) || name.is_empty() {
return None;
}
Some((sha.to_ascii_lowercase(), name.to_string()))
})
.collect()
}
pub fn expected_sha256(checksums: &str, asset_name: &str) -> Result<String> {
parse_checksums(checksums)
.into_iter()
.find(|(_, name)| name == asset_name)
.map(|(sha, _)| sha)
.ok_or_else(|| {
VerifyError::Checksum(format!("checksums.txt has no entry for {asset_name}")).into()
})
}
pub fn sha256_file(path: &Path) -> Result<String> {
let mut file =
fs::File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 64 * 1024];
loop {
let read = file
.read(&mut buffer)
.with_context(|| format!("Failed to read {}", path.display()))?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(format!("{:x}", hasher.finalize()))
}
pub fn verify_sha256(checksums: &str, asset: &Path, asset_name: &str) -> Result<()> {
let expected = expected_sha256(checksums, asset_name)?;
let actual = sha256_file(asset)?;
if actual != expected {
return Err(VerifyError::Checksum(format!(
"sha256 of {} is {actual} but checksums.txt says {expected} for {asset_name}",
asset.display()
))
.into());
}
Ok(())
}
pub fn verify_release_asset(
checksums_path: &Path,
signature_path: &Path,
asset: &Path,
asset_name: &str,
public_key_b64: &str,
) -> Result<()> {
let checksums = fs::read(checksums_path)
.with_context(|| format!("Failed to read {}", checksums_path.display()))?;
let signature = fs::read_to_string(signature_path)
.with_context(|| format!("Failed to read {}", signature_path.display()))?;
verify_signature(&checksums, &signature, public_key_b64)?;
let checksums = String::from_utf8(checksums)
.map_err(|_| VerifyError::Checksum("checksums.txt is not UTF-8".to_string()))?;
verify_sha256(&checksums, asset, asset_name)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
const TEST_PUBLIC_KEY: &str = "RWSD+pKuMle9GDuXt7KwcW0cF6M+NhhAH31FkxTMNh72Ldc2jkOL0XB0";
fn fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/selfhost")
.join(name)
}
#[test]
fn valid_signature_and_checksum_pass() {
verify_release_asset(
&fixture("checksums.txt"),
&fixture("checksums.txt.minisig"),
&fixture("a4-linux-x64"),
"a4-linux-x64",
TEST_PUBLIC_KEY,
)
.unwrap();
for name in [
"a4-darwin-arm64",
"a4-darwin-x64",
"a4-linux-arm64",
"a4-win32-x64.exe",
] {
verify_release_asset(
&fixture("checksums.txt"),
&fixture("checksums.txt.minisig"),
&fixture(name),
name,
TEST_PUBLIC_KEY,
)
.unwrap();
}
}
#[test]
fn wrong_key_signature_is_rejected_and_named() {
let error = verify_release_asset(
&fixture("checksums.txt"),
&fixture("checksums.txt.wrong-key.minisig"),
&fixture("a4-linux-x64"),
"a4-linux-x64",
TEST_PUBLIC_KEY,
)
.unwrap_err();
assert!(matches!(
error.downcast_ref::<VerifyError>(),
Some(VerifyError::Signature(_))
));
assert!(
error.to_string().starts_with("Signature check failed"),
"{error}"
);
}
#[test]
fn production_key_rejects_fixture_signature() {
let error = verify_release_asset(
&fixture("checksums.txt"),
&fixture("checksums.txt.minisig"),
&fixture("a4-linux-x64"),
"a4-linux-x64",
super::super::keys::MINISIGN_PUBLIC_KEY,
)
.unwrap_err();
assert!(
error.to_string().starts_with("Signature check failed"),
"{error}"
);
}
#[test]
fn tampered_checksums_fail_signature() {
let mut checksums = fs::read(fixture("checksums.txt")).unwrap();
checksums[0] = if checksums[0] == b'0' { b'1' } else { b'0' };
let signature = fs::read_to_string(fixture("checksums.txt.minisig")).unwrap();
let error = verify_signature(&checksums, &signature, TEST_PUBLIC_KEY).unwrap_err();
assert!(
error.to_string().starts_with("Signature check failed"),
"{error}"
);
}
#[test]
fn tampered_asset_fails_checksum_with_detail() {
let dir = tempfile::tempdir().unwrap();
let asset = dir.path().join("a4-linux-x64");
fs::write(&asset, "not the signed binary").unwrap();
let error = verify_release_asset(
&fixture("checksums.txt"),
&fixture("checksums.txt.minisig"),
&asset,
"a4-linux-x64",
TEST_PUBLIC_KEY,
)
.unwrap_err();
assert!(matches!(
error.downcast_ref::<VerifyError>(),
Some(VerifyError::Checksum(_))
));
assert!(error.to_string().contains("checksums.txt says"), "{error}");
}
#[test]
fn missing_asset_entry_is_a_checksum_failure() {
let checksums = fs::read_to_string(fixture("checksums.txt")).unwrap();
let error = expected_sha256(&checksums, "a4-freebsd-x64").unwrap_err();
assert_eq!(
error.to_string(),
"Checksum check failed: checksums.txt has no entry for a4-freebsd-x64"
);
}
#[test]
fn parser_tolerates_binary_marker_and_junk() {
let text = "9b184204d2d540076afbc52c75dd5acd6acff062e4446a83c1672d41837cdf1d *a4-linux-x64\nnot a line\n\nABCD short\n";
let parsed = parse_checksums(text);
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].1, "a4-linux-x64");
}
#[test]
fn malformed_signature_file_is_named() {
let error = verify_signature(b"x", "garbage", TEST_PUBLIC_KEY).unwrap_err();
assert!(error.to_string().contains("malformed"), "{error}");
}
}