#![doc = include_str!("../docs/verification.md")]
use std::fmt::Write as _;
use std::fs::File;
use std::io::Read;
use std::path::Path;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use sha2::{Digest, Sha256};
use crate::error::{Error, Result};
const CHUNK: usize = 64 * 1024;
pub fn sha256_hex(path: &Path) -> Result<String> {
let mut hasher = Sha256::new();
for_each_chunk(path, |chunk| hasher.update(chunk))?;
let mut out = String::with_capacity(64);
for byte in hasher.finalize() {
let _ = write!(out, "{byte:02x}");
}
Ok(out)
}
fn for_each_chunk(path: &Path, mut consume: impl FnMut(&[u8])) -> Result<()> {
let mut file = File::open(path)?;
let mut buf = vec![0u8; CHUNK];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
return Ok(());
}
consume(&buf[..n]);
}
}
pub fn verify_sha256(path: &Path, expected_hex: &str) -> Result<()> {
let actual = sha256_hex(path)?;
if actual.eq_ignore_ascii_case(expected_hex.trim()) {
Ok(())
} else {
Err(Error::ChecksumMismatch {
expected: expected_hex.trim().to_ascii_lowercase(),
actual,
})
}
}
pub fn verify_minisign(path: &Path, public_key: &str, signature: &str) -> Result<()> {
verify_minisign_file(path, &[public_key], signature).map(drop)
}
pub fn verify_minisign_file(path: &Path, public_keys: &[&str], signature: &str) -> Result<String> {
let sig = decode_minisign(signature)?;
let mut last = Error::Signature("no minisign public key configured".into());
for key in public_keys {
let pk = match decode_minisign_key(key) {
Ok(pk) => pk,
Err(e) => {
last = e;
continue;
}
};
let mut verifier = match pk.verify_stream(&sig) {
Ok(verifier) => verifier,
Err(e) => {
last = Error::Signature(e.to_string());
continue;
}
};
for_each_chunk(path, |chunk| verifier.update(chunk))?;
match verifier.finalize() {
Ok(()) => return Ok(sig.trusted_comment().to_string()),
Err(e) => last = Error::Signature(e.to_string()),
}
}
Err(last)
}
pub fn verify_minisign_bytes(data: &[u8], public_keys: &[&str], signature: &str) -> Result<String> {
let sig = decode_minisign(signature)?;
let mut last = Error::Signature("no minisign public key configured".into());
for key in public_keys {
let verified = decode_minisign_key(key).and_then(|pk| {
pk.verify(data, &sig, false)
.map_err(|e| Error::Signature(e.to_string()))
});
match verified {
Ok(()) => return Ok(sig.trusted_comment().to_string()),
Err(e) => last = e,
}
}
Err(last)
}
fn decode_minisign(signature: &str) -> Result<minisign_verify::Signature> {
minisign_verify::Signature::decode(signature)
.map_err(|e| Error::Signature(format!("invalid signature: {e}")))
}
fn decode_minisign_key(key: &str) -> Result<minisign_verify::PublicKey> {
minisign_verify::PublicKey::from_base64(key.trim())
.map_err(|e| Error::Signature(format!("invalid public key: {e}")))
}
pub fn signed_version(trusted_comment: &str) -> Option<&str> {
trusted_comment
.split_whitespace()
.find_map(|token| token.strip_prefix("version:"))
.filter(|v| !v.is_empty())
}
pub(crate) fn sha256_from_sums(sums: &str, asset_name: &str) -> Option<String> {
for line in sums.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut parts = line.split_whitespace();
let (Some(hash), Some(path)) = (parts.next(), parts.next()) else {
continue;
};
let path = path.strip_prefix('*').unwrap_or(path);
let base = path.rsplit(['/', '\\']).next().unwrap_or(path);
if base == asset_name {
return Some(hash.to_ascii_lowercase());
}
}
None
}
pub fn verify_ed25519(path: &Path, public_key: &str, signature: &str) -> Result<()> {
let pk = decode_base64("public key", public_key).and_then(|bytes| {
ed25519_compact::PublicKey::from_slice(&bytes)
.map_err(|e| Error::Signature(format!("invalid Ed25519 public key: {e}")))
})?;
let sig = decode_base64("signature", signature).and_then(|bytes| {
ed25519_compact::Signature::from_slice(&bytes)
.map_err(|e| Error::Signature(format!("invalid Ed25519 signature: {e}")))
})?;
let mut state = pk
.verify_incremental(&sig)
.map_err(|e| Error::Signature(format!("invalid Ed25519 signature: {e}")))?;
for_each_chunk(path, |chunk| state.absorb(chunk))?;
state
.verify()
.map_err(|_| Error::Signature("Ed25519 signature does not match the file".into()))
}
fn decode_base64(what: &str, value: &str) -> Result<Vec<u8>> {
BASE64
.decode(value.trim())
.map_err(|e| Error::Signature(format!("Ed25519 {what} is not valid base64: {e}")))
}
#[cfg(test)]
pub(crate) mod test_support {
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::BASE64;
use base64::Engine as _;
pub(crate) struct Ed25519Fixture {
pub(crate) path: PathBuf,
pub(crate) public_key: String,
pub(crate) signature: String,
}
impl Ed25519Fixture {
pub(crate) fn new(contents: &[u8]) -> Self {
let seed = ed25519_compact::Seed::new([7; ed25519_compact::Seed::BYTES]);
let pair = ed25519_compact::KeyPair::from_seed(seed);
let signature = pair.sk.sign(contents, None);
let path = unique_temp_path("ed25519");
std::fs::write(&path, contents).unwrap();
Self {
path,
public_key: BASE64.encode(*pair.pk),
signature: BASE64.encode(*signature),
}
}
}
impl Drop for Ed25519Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
pub(crate) fn unique_temp_path(tag: &str) -> PathBuf {
static NEXT: AtomicUsize = AtomicUsize::new(0);
let n = NEXT.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"gpui-updater-test-{}-{tag}-{n}.bin",
std::process::id()
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn temp_file(contents: &[u8]) -> std::path::PathBuf {
let path = test_support::unique_temp_path("sha256");
let mut f = File::create(&path).unwrap();
f.write_all(contents).unwrap();
path
}
#[test]
fn sha256_matches_known_vector() {
let path = temp_file(b"abc");
let got = sha256_hex(&path).unwrap();
assert_eq!(
got,
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert!(verify_sha256(&path, &got.to_uppercase()).is_ok());
assert!(verify_sha256(&path, "deadbeef").is_err());
let _ = std::fs::remove_file(&path);
}
#[test]
fn reads_the_signed_version_token() {
assert_eq!(
signed_version("timestamp:1700000000\tfile:App.dmg\tversion:1.2.3"),
Some("1.2.3")
);
assert_eq!(
signed_version("version:v2.0.0-rc.1 file:x"),
Some("v2.0.0-rc.1")
);
assert_eq!(signed_version("timestamp:1 file:App.dmg"), None);
assert_eq!(signed_version("version: 1.2.3"), None);
}
#[test]
fn rejects_signatures_without_keys_or_with_garbage() {
assert!(matches!(
verify_minisign_bytes(b"x", &[], "not a signature"),
Err(Error::Signature(_))
));
}
#[test]
fn streams_minisign_verification_across_chunks() {
let data: Vec<u8> = (0u8..=255).cycle().take(CHUNK * 3 + 17).collect();
let (signer, other) = (
minisign::KeyPair::generate_unencrypted_keypair().unwrap(),
minisign::KeyPair::generate_unencrypted_keypair().unwrap(),
);
let signature = minisign::sign(
None,
&signer.sk,
std::io::Cursor::new(&data),
Some("file:app.dmg\tversion:1.0.0"),
None,
)
.unwrap()
.to_string();
let (key, other_key) = (signer.pk.to_base64(), other.pk.to_base64());
let path = temp_file(&data);
let comment = verify_minisign_file(&path, &[&other_key, &key], &signature).unwrap();
assert_eq!(comment, "file:app.dmg\tversion:1.0.0");
assert!(verify_minisign(&path, &key, &signature).is_ok());
assert!(matches!(
verify_minisign_file(&path, &[&other_key], &signature),
Err(Error::Signature(_))
));
let mut tampered = data;
tampered[CHUNK * 2] ^= 1;
std::fs::write(&path, &tampered).unwrap();
assert!(matches!(
verify_minisign_file(&path, &[&key], &signature),
Err(Error::Signature(_))
));
let _ = std::fs::remove_file(&path);
}
#[test]
fn parses_checksum_by_basename_ignoring_dir_prefix() {
let sums = "\
abc123 dist/app-macos.dmg
DEF456 *dist/app-linux.tar.gz
";
assert_eq!(
sha256_from_sums(sums, "app-macos.dmg").as_deref(),
Some("abc123")
);
assert_eq!(
sha256_from_sums(sums, "app-linux.tar.gz").as_deref(),
Some("def456")
);
assert!(sha256_from_sums(sums, "app-windows.exe").is_none());
}
#[test]
fn ed25519_accepts_valid_and_rejects_tampered_files() {
let fixture = test_support::Ed25519Fixture::new(b"abc");
assert!(verify_ed25519(&fixture.path, &fixture.public_key, &fixture.signature).is_ok());
std::fs::write(&fixture.path, b"abd").unwrap();
assert!(matches!(
verify_ed25519(&fixture.path, &fixture.public_key, &fixture.signature),
Err(Error::Signature(_))
));
}
#[test]
fn ed25519_rejects_malformed_keys_and_signatures() {
let fixture = test_support::Ed25519Fixture::new(b"abc");
for (key, sig) in [
("not base64!", fixture.signature.as_str()),
("AAAA", fixture.signature.as_str()),
(fixture.public_key.as_str(), "AAAA"),
] {
assert!(matches!(
verify_ed25519(&fixture.path, key, sig),
Err(Error::Signature(_))
));
}
}
#[test]
fn ed25519_verifies_signatures_made_by_sparkle_sign_update() {
const SPARKLE_PUBLIC_KEY: &str = "6kpsY+KcUgq+9VB7Ey7F+ZVHdq6+vnuSQh7qaRRG0iw=";
const SPARKLE_SIGNATURE: &str = "fkZSsqZWXCJYAnlGh01EaGCGfLf0DLUpVDF6d7qpDPyexRhMbj85AAF61DR7ksXSCa/Yw1lZk6kDSYJOvioFAQ==";
let payload: Vec<u8> = (0..70 * 1024u32)
.map(|i| u8::try_from((i * 31 + 7) % 251).unwrap())
.collect();
let path = temp_file(&payload);
assert!(verify_ed25519(&path, SPARKLE_PUBLIC_KEY, SPARKLE_SIGNATURE).is_ok());
let mut tampered = payload;
tampered[CHUNK] ^= 1;
std::fs::write(&path, &tampered).unwrap();
assert!(verify_ed25519(&path, SPARKLE_PUBLIC_KEY, SPARKLE_SIGNATURE).is_err());
let _ = std::fs::remove_file(&path);
}
}