use super::types::UpdateError;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
use which::which;
#[derive(Debug)]
#[allow(dead_code)]
pub struct VerificationResult {
pub checksum_valid: bool,
pub signature_valid: Option<bool>,
}
pub async fn verify_checksum(
file_path: &std::path::Path,
expected: &str,
) -> Result<VerificationResult, UpdateError> {
verify_checksum_and_signature(file_path, expected, None).await
}
pub async fn verify_checksum_and_signature(
file_path: &std::path::Path,
expected: &str,
signature_bundle: Option<&std::path::Path>,
) -> Result<VerificationResult, UpdateError> {
let mut file = tokio::fs::File::open(file_path)
.await
.map_err(|e| UpdateError::InstallFailed(format!("Failed to open file: {}", e)))?;
let mut hasher = blake3::Hasher::new();
let mut buffer = vec![0u8; 64 * 1024];
loop {
let bytes_read = file
.read(&mut buffer)
.await
.map_err(|e| UpdateError::InstallFailed(format!("Failed to read file: {}", e)))?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
}
let actual = hasher.finalize().to_hex().to_string();
let sha256_actual = compute_sha256(file_path).await?;
let checksum_valid =
actual.eq_ignore_ascii_case(expected) || sha256_actual.eq_ignore_ascii_case(expected);
if !checksum_valid {
return Err(UpdateError::ChecksumMismatch {
expected: expected.to_string(),
actual: sha256_actual,
});
}
let signature_valid = if let Some(bundle_path) = signature_bundle {
Some(verify_signature(file_path, bundle_path).await?)
} else {
None
};
Ok(VerificationResult {
checksum_valid: true,
signature_valid,
})
}
async fn compute_sha256(file_path: &std::path::Path) -> Result<String, UpdateError> {
use std::io::Read;
let path = file_path.to_path_buf();
tokio::task::spawn_blocking(move || {
let mut file = std::fs::File::open(&path)
.map_err(|e| UpdateError::InstallFailed(format!("Failed to open file: {}", e)))?;
let mut hasher = sha2::Sha256::new();
let mut buffer = vec![0u8; 64 * 1024];
loop {
let bytes_read = file
.read(&mut buffer)
.map_err(|e| UpdateError::InstallFailed(format!("Failed to read file: {}", e)))?;
if bytes_read == 0 {
break;
}
use sha2::Digest;
hasher.update(&buffer[..bytes_read]);
}
use sha2::Digest;
Ok(bytes_to_lower_hex(hasher.finalize()))
})
.await
.map_err(|e| UpdateError::InstallFailed(format!("Task failed: {}", e)))?
}
fn bytes_to_lower_hex(bytes: impl AsRef<[u8]>) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let bytes = bytes.as_ref();
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(HEX[(byte >> 4) as usize] as char);
output.push(HEX[(byte & 0x0f) as usize] as char);
}
output
}
const GITHUB_ACTIONS_OIDC_ISSUER: &str = "https://token.actions.githubusercontent.com";
const RCH_RELEASE_IDENTITY_PATTERN: &str = r"^https://github\.com/Dicklesworthstone/remote_compilation_helper/\.github/workflows/release\.yml@refs/.*$";
pub(crate) async fn verify_signature(
file_path: &std::path::Path,
bundle_path: &std::path::Path,
) -> Result<bool, UpdateError> {
which("cosign").map_err(|_| {
UpdateError::SignatureVerificationFailed("cosign not found in PATH".to_string())
})?;
let output = Command::new("cosign")
.arg("verify-blob")
.arg("--bundle")
.arg(bundle_path)
.arg("--certificate-identity-regexp")
.arg(RCH_RELEASE_IDENTITY_PATTERN)
.arg("--certificate-oidc-issuer")
.arg(GITHUB_ACTIONS_OIDC_ISSUER)
.arg(file_path)
.output()
.await
.map_err(|e| {
UpdateError::SignatureVerificationFailed(format!("Failed to execute cosign: {}", e))
})?;
if output.status.success() {
Ok(true)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
Err(UpdateError::SignatureVerificationFailed(format!(
"cosign verify-blob failed: {}{}",
stderr.trim(),
if stdout.trim().is_empty() {
String::new()
} else {
format!(" | {}", stdout.trim())
}
)))
}
}
pub(crate) const RELEASE_MINISIGN_PUBLIC_KEY: &str =
"RWSoYi6NXJWzaRs1mJmOwwXrZfPWcq6MXnQlNMLBYKzlIQTLwuVQG6uO";
pub(crate) fn verify_minisign(
archive: &std::path::Path,
signature_text: &str,
) -> Result<(), UpdateError> {
verify_minisign_with_key(archive, signature_text, RELEASE_MINISIGN_PUBLIC_KEY)
}
fn verify_minisign_with_key(
archive: &std::path::Path,
signature_text: &str,
public_key: &str,
) -> Result<(), UpdateError> {
let fail = |detail: String| UpdateError::SignatureVerificationFailed(detail);
let key = minisign_verify::PublicKey::from_base64(public_key)
.map_err(|e| fail(format!("invalid pinned minisign key: {e}")))?;
let signature = minisign_verify::Signature::decode(signature_text)
.map_err(|e| fail(format!("malformed .minisig: {e}")))?;
let bytes = std::fs::read(archive)
.map_err(|e| fail(format!("cannot read {}: {e}", archive.display())))?;
key.verify(&bytes, &signature, false)
.map_err(|e| fail(format!("minisign verification failed: {e}")))
}
#[allow(dead_code)]
pub fn verify_sha256_bytes(content: &[u8], expected: &str) -> Result<(), UpdateError> {
use sha2::Digest;
let mut hasher = sha2::Sha256::new();
hasher.update(content);
let actual = bytes_to_lower_hex(hasher.finalize());
if actual.eq_ignore_ascii_case(expected) {
Ok(())
} else {
Err(UpdateError::ChecksumMismatch {
expected: expected.to_string(),
actual,
})
}
}
#[cfg(test)]
mod minisign_tests {
use super::*;
const TEST_KEY: &str = "RWTZPaZ0gaEhqMa/pPZEdaALWkxxAm/EtQzB6egPldD4Uh1cCRlw/b0w";
const PAYLOAD: &[u8] = b"rch minisign test payload v1\n";
const SIGNATURE: &str = "untrusted comment: signature from minisign secret key\n\
RUTZPaZ0gaEhqDSXunnChY49+8G9WeMdP7wwnCG9s382LwYf7h09hfwypAG0pKwYrhcX+N1Ujyzv1r4ZpRClO4To6jTL1DR1ewo=\n\
trusted comment: rch test fixture\n\
xfmvGjlaWGXfdNKtFwBNm45V0V6VPZ3lLFhIzfLmQ7xUjPzacD1iHPC0MO2oczUBCt2gAZtoGnuQMxpswMpMCw==\n";
fn archive(bytes: &[u8]) -> tempfile::NamedTempFile {
let file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(file.path(), bytes).unwrap();
file
}
#[test]
fn genuine_signature_verifies() {
let file = archive(PAYLOAD);
verify_minisign_with_key(file.path(), SIGNATURE, TEST_KEY).unwrap();
}
#[test]
fn tampered_archive_is_refused_even_though_it_hashes_cleanly() {
let file = archive(b"rch minisign test payload v2\n");
let error = verify_minisign_with_key(file.path(), SIGNATURE, TEST_KEY).unwrap_err();
assert!(
error.to_string().contains("minisign verification failed"),
"{error}"
);
}
#[test]
fn signature_from_another_key_is_refused() {
let file = archive(PAYLOAD);
assert!(verify_minisign(file.path(), SIGNATURE).is_err());
}
#[test]
fn malformed_signature_is_refused() {
let file = archive(PAYLOAD);
let error = verify_minisign_with_key(file.path(), "not a minisig", TEST_KEY).unwrap_err();
assert!(error.to_string().contains("malformed .minisig"), "{error}");
}
#[test]
fn pinned_release_key_parses() {
minisign_verify::PublicKey::from_base64(RELEASE_MINISIGN_PUBLIC_KEY).unwrap();
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_verify_sha256_bytes() {
let content = b"test content";
let expected = "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72";
assert!(verify_sha256_bytes(content, expected).is_ok());
}
#[test]
fn test_verify_sha256_bytes_mismatch() {
let content = b"test content";
let wrong = "0000000000000000000000000000000000000000000000000000000000000000";
let result = verify_sha256_bytes(content, wrong);
assert!(matches!(result, Err(UpdateError::ChecksumMismatch { .. })));
}
#[tokio::test]
async fn test_compute_sha256() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("test.txt");
std::fs::write(&file_path, "test content").unwrap();
let hash = compute_sha256(&file_path.to_path_buf()).await.unwrap();
assert_eq!(
hash,
"6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72"
);
}
#[test]
fn test_verify_sha256_bytes_case_insensitive() {
let content = b"test content";
let expected_upper = "6AE8A75555209FD6C44157C0AED8016E763FF435A19CF186F76863140143FF72";
assert!(verify_sha256_bytes(content, expected_upper).is_ok());
}
#[test]
fn test_verify_sha256_bytes_empty_content() {
let content = b"";
let expected = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
assert!(verify_sha256_bytes(content, expected).is_ok());
}
#[tokio::test]
async fn test_compute_sha256_large_file() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("large.bin");
let content = vec![0u8; 1024 * 1024];
std::fs::write(&file_path, &content).unwrap();
let hash = compute_sha256(&file_path.to_path_buf()).await.unwrap();
assert_eq!(
hash,
"30e14955ebf1352266dc2ff8067e68104607e750abb9d3b36582b8af909fcb58"
);
}
#[tokio::test]
async fn test_verify_checksum_sha256() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("test.txt");
std::fs::write(&file_path, "test content").unwrap();
let expected = "6ae8a75555209fd6c44157c0aed8016e763ff435a19cf186f76863140143ff72";
let result = verify_checksum(&file_path, expected).await.unwrap();
assert!(result.checksum_valid);
}
#[tokio::test]
async fn test_verify_checksum_mismatch() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("test.txt");
std::fs::write(&file_path, "test content").unwrap();
let wrong = "0000000000000000000000000000000000000000000000000000000000000000";
let result = verify_checksum(&file_path, wrong).await;
assert!(matches!(result, Err(UpdateError::ChecksumMismatch { .. })));
}
#[test]
fn test_verify_sha256_bytes_binary_content() {
let content: &[u8] = &[0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD];
let expected = "feb1aba6fea741741b1bbcc974f74fed337b535b8eec7223b6dd15d7108f08e3";
assert!(verify_sha256_bytes(content, expected).is_ok());
}
#[tokio::test]
async fn test_verify_checksum_file_not_found() {
let temp = TempDir::new().unwrap();
let nonexistent = temp.path().join("does_not_exist.bin");
let result = verify_checksum(&nonexistent, "0".repeat(64).as_str()).await;
assert!(matches!(
result,
Err(UpdateError::InstallFailed(msg)) if msg.contains("Failed to open file")
));
}
#[tokio::test]
async fn test_compute_sha256_file_not_found() {
let temp = TempDir::new().unwrap();
let nonexistent = temp.path().join("nonexistent.txt");
let result = compute_sha256(&nonexistent).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_verify_checksum_blake3() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("blake3_test.txt");
std::fs::write(&file_path, "test content").unwrap();
let mut hasher = blake3::Hasher::new();
hasher.update(b"test content");
let blake3_hash = hasher.finalize().to_hex().to_string();
let result = verify_checksum(&file_path, &blake3_hash).await.unwrap();
assert!(result.checksum_valid);
}
#[test]
fn test_verification_result_fields() {
let result = VerificationResult {
checksum_valid: true,
signature_valid: Some(true),
};
assert!(result.checksum_valid);
assert_eq!(result.signature_valid, Some(true));
let result_no_sig = VerificationResult {
checksum_valid: false,
signature_valid: None,
};
assert!(!result_no_sig.checksum_valid);
assert!(result_no_sig.signature_valid.is_none());
}
#[test]
fn test_verify_sha256_bytes_invalid_hex_length() {
let content = b"test";
let short_hash = "abc123";
let result = verify_sha256_bytes(content, short_hash);
assert!(result.is_err());
}
#[tokio::test]
async fn test_verify_checksum_with_mixed_case_hex() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("mixedcase.txt");
std::fs::write(&file_path, "test content").unwrap();
let expected = "6Ae8A75555209fD6C44157c0AED8016E763Ff435a19cF186F76863140143Ff72";
let result = verify_checksum(&file_path, expected).await.unwrap();
assert!(result.checksum_valid);
}
#[tokio::test]
async fn test_compute_sha256_empty_file() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("empty.txt");
std::fs::write(&file_path, "").unwrap();
let hash = compute_sha256(&file_path).await.unwrap();
assert_eq!(
hash,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[tokio::test]
async fn test_signature_missing_bundle_yields_none() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("artifact.bin");
std::fs::write(&file_path, b"hello world").unwrap();
let mut hasher = sha2::Sha256::new();
use sha2::Digest;
hasher.update(b"hello world");
let expected = bytes_to_lower_hex(hasher.finalize());
let result = verify_checksum_and_signature(&file_path, &expected, None)
.await
.unwrap();
assert!(result.checksum_valid);
assert!(
result.signature_valid.is_none(),
"missing signature bundle must yield signature_valid=None, got {:?}",
result.signature_valid
);
}
#[tokio::test]
async fn test_signature_invalid_bundle_returns_err() {
if which::which("cosign").is_err() {
eprintln!("SKIP test_signature_invalid_bundle_returns_err: cosign not installed");
return;
}
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("artifact.bin");
let bundle_path = temp.path().join("artifact.sig");
std::fs::write(&file_path, b"hello world").unwrap();
std::fs::write(&bundle_path, b"\x00\x01\x02 totally not a real bundle").unwrap();
let result = verify_signature(&file_path, &bundle_path).await;
assert!(matches!(
result,
Err(UpdateError::SignatureVerificationFailed(_)) | Ok(false)
));
}
#[test]
fn test_rch_release_identity_pattern_is_anchored() {
assert!(
RCH_RELEASE_IDENTITY_PATTERN.starts_with('^'),
"identity pattern must start with ^"
);
assert!(
RCH_RELEASE_IDENTITY_PATTERN.ends_with("$"),
"identity pattern must end with $"
);
assert!(
RCH_RELEASE_IDENTITY_PATTERN.contains(r"github\.com"),
"github.com dot must be escaped"
);
assert!(
RCH_RELEASE_IDENTITY_PATTERN.contains(r"\.github/"),
".github dot must be escaped"
);
assert!(
RCH_RELEASE_IDENTITY_PATTERN.contains(r"release\.yml"),
"release.yml dot must be escaped"
);
}
#[test]
fn test_rch_release_identity_pattern_accepts_canonical_url() {
let re = regex::Regex::new(RCH_RELEASE_IDENTITY_PATTERN).unwrap();
let canonical = "https://github.com/Dicklesworthstone/remote_compilation_helper/.github/workflows/release.yml@refs/tags/v1.0.0";
assert!(
re.is_match(canonical),
"canonical release URL should match: {}",
canonical
);
let branch_ref = "https://github.com/Dicklesworthstone/remote_compilation_helper/.github/workflows/release.yml@refs/heads/main";
assert!(re.is_match(branch_ref), "branch ref form should match");
}
#[test]
fn test_rch_release_identity_pattern_rejects_substring_attacks() {
let re = regex::Regex::new(RCH_RELEASE_IDENTITY_PATTERN).unwrap();
let attack1 = "https://github.com/Attacker/remote_compilation_helper/.github/workflows/release.yml@refs/tags/v1.0.0";
assert!(
!re.is_match(attack1),
"different-owner URL must not match: {}",
attack1
);
let attack2 = "https://github.com.evil.example/Dicklesworthstone/remote_compilation_helper/.github/workflows/release.yml@refs/tags/v1.0.0";
assert!(
!re.is_match(attack2),
"subdomain-prefix attack must not match: {}",
attack2
);
let attack3 = "https://github.com/Dicklesworthstone/remote_compilation_helper/.github/workflows/sneaky.yml@refs/tags/v1.0.0";
assert!(
!re.is_match(attack3),
"different-workflow URL must not match: {}",
attack3
);
let attack4 = "http://github.com/Dicklesworthstone/remote_compilation_helper/.github/workflows/release.yml@refs/tags/v1.0.0";
assert!(
!re.is_match(attack4),
"http (not https) must not match: {}",
attack4
);
let attack5 = "evilhttps://github.com/Dicklesworthstone/remote_compilation_helper/.github/workflows/release.yml@refs/tags/v1.0.0";
assert!(
!re.is_match(attack5),
"lookalike-host prefix must not match: {}",
attack5
);
let attack6 = "https://github.com/Dicklesworthstone/remote_compilation_helper/Xgithub/workflows/release.yml@refs/tags/v1.0.0";
assert!(
!re.is_match(attack6),
"missing .github literal must not match: {}",
attack6
);
}
#[tokio::test]
async fn integration_signature_verifies_with_real_cosign() {
if std::env::var("RCH_TEST_REAL_SIG").is_err() {
eprintln!(
"SKIP integration_signature_verifies_with_real_cosign: RCH_TEST_REAL_SIG not set"
);
return;
}
if which::which("cosign").is_err() {
eprintln!("SKIP integration_signature_verifies_with_real_cosign: cosign not in PATH");
return;
}
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let bin = manifest_dir.join("tests/fixtures/update/rch_release_sample.bin");
let sig = manifest_dir.join("tests/fixtures/update/rch_release_sample.sig");
if !bin.exists() || !sig.exists() {
eprintln!(
"SKIP integration_signature_verifies_with_real_cosign: fixtures missing at {} and/or {}",
bin.display(),
sig.display()
);
return;
}
let result = verify_signature(&bin, &sig).await;
assert!(
result.is_ok() && result.unwrap(),
"real cosign-signed binary should verify"
);
}
}