From 471cafdb171caeeea215a6c450c8d8c07c25beea Mon Sep 17 00:00:00 2001 From: "Tha.Les" <31625914+thcp@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:26:55 +0100 Subject: [PATCH] fix: verify macOS FFmpeg download with a pinned SHA256 (#172) (#192) macOS setup ran the downloaded FFmpeg/ffprobe with no integrity check. evermeet publishes no .sha256 companion, so pin a specific versioned build + its SHA256 and verify before extract/exec; mismatch deletes the file. Override URLs may supply STEMDECK_FFMPEG_SHA256/STEMDECK_FFPROBE_SHA256. Adds 4 Rust tests. --- desktop/src-tauri/src/main.rs | 130 +++++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index e76570b..d243eb4 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -20,9 +20,23 @@ use zip::ZipArchive; const SETUP_VERSION: u64 = 1; const DEFAULT_WINDOWS_FFMPEG_URL: &str = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip"; -const DEFAULT_MACOS_FFMPEG_URL: &str = "https://evermeet.cx/ffmpeg/getrelease/ffmpeg/zip"; +// macOS FFmpeg is pinned to a specific evermeet build and verified by SHA256 +// before it is extracted or executed (#172). evermeet publishes no .sha256 +// companion (only a GPG signature and a size), so unlike the Windows gyan.dev +// path we cannot fetch the hash at runtime -- instead we pin the hash of a +// specific versioned zip, captured at build time from evermeet's TLS endpoint +// (the download size matched evermeet's signed release info). Bump the version +// and BOTH hashes together when updating FFmpeg. The rolling getrelease/latest +// URL is intentionally avoided so the pinned hash stays valid. +const DEFAULT_MACOS_FFMPEG_URL: &str = "https://evermeet.cx/ffmpeg/ffmpeg-8.1.1.zip"; #[cfg(target_os = "macos")] -const DEFAULT_MACOS_FFPROBE_URL: &str = "https://evermeet.cx/ffmpeg/getrelease/ffprobe/zip"; +const DEFAULT_MACOS_FFPROBE_URL: &str = "https://evermeet.cx/ffmpeg/ffprobe-8.1.1.zip"; +#[cfg(target_os = "macos")] +const DEFAULT_MACOS_FFMPEG_SHA256: &str = + "4610988e2f54c243c50da73a09e4e2c36d9bb77546f9aa6c84cb328dcb1a98c1"; +#[cfg(target_os = "macos")] +const DEFAULT_MACOS_FFPROBE_SHA256: &str = + "aeade29dee3c3844e9bcc974f4ae4b29cc4f87994177d77003a8589fa531009e"; struct BackendHandles { child: Child, @@ -1788,6 +1802,44 @@ fn ensure_ffmpeg(data_dir: &Path) -> Result { } } +// Pick the SHA256 to enforce for a download: the pinned hash when the URL is the +// built-in default, otherwise an explicit override hash from `env_var` if set, +// else None (skip). Mirrors the Windows path's "verify default, skip override". +#[cfg(target_os = "macos")] +fn expected_ffmpeg_sha256( + url: &str, + default_url: &str, + pinned_sha256: &str, + env_var: &str, +) -> Option { + if url == default_url { + return Some(pinned_sha256.to_ascii_lowercase()); + } + env::var(env_var) + .ok() + .map(|s| s.trim().to_ascii_lowercase()) + .filter(|s| !s.is_empty()) +} + +// Verify a freshly downloaded archive against an expected SHA256 before it is +// extracted or made executable (#172). On mismatch the file is removed so a +// corrupt or tampered binary is never run. `None` means no hash to enforce. +#[cfg(target_os = "macos")] +fn verify_pinned_sha256(path: &Path, expected: Option<&str>, label: &str) -> Result<(), String> { + let Some(expected) = expected else { + return Ok(()); + }; + let actual = sha256_file(path)?; + if !actual.eq_ignore_ascii_case(expected) { + let _ = fs::remove_file(path); + return Err(format!( + "{label} archive checksum mismatch (expected {expected}, got {actual}). \ + The download may be corrupt or tampered. Click Retry to try again." + )); + } + Ok(()) +} + #[cfg(target_os = "macos")] fn download_macos_ffmpeg(data_dir: &Path) -> Result<(), String> { let ffmpeg_url = env_path_override("STEMDECK_FFMPEG_URL") @@ -1796,6 +1848,21 @@ fn download_macos_ffmpeg(data_dir: &Path) -> Result<(), String> { let ffprobe_url = env_path_override("STEMDECK_FFPROBE_URL") .map(|p| p.display().to_string()) .unwrap_or_else(|| DEFAULT_MACOS_FFPROBE_URL.to_string()); + // Verify the pinned hash for the default (built-in) URLs; for custom override + // URLs honour an explicit STEMDECK_FFMPEG_SHA256 / STEMDECK_FFPROBE_SHA256 when + // provided, otherwise skip (parity with the Windows override behaviour). + let ffmpeg_expected = expected_ffmpeg_sha256( + &ffmpeg_url, + DEFAULT_MACOS_FFMPEG_URL, + DEFAULT_MACOS_FFMPEG_SHA256, + "STEMDECK_FFMPEG_SHA256", + ); + let ffprobe_expected = expected_ffmpeg_sha256( + &ffprobe_url, + DEFAULT_MACOS_FFPROBE_URL, + DEFAULT_MACOS_FFPROBE_SHA256, + "STEMDECK_FFPROBE_SHA256", + ); let downloads = data_dir.join("downloads"); let ffmpeg_zip = downloads.join("ffmpeg-macos.zip"); let ffprobe_zip = downloads.join("ffprobe-macos.zip"); @@ -1803,7 +1870,9 @@ fn download_macos_ffmpeg(data_dir: &Path) -> Result<(), String> { .map_err(|e| format!("failed to create {}: {e}", downloads.display()))?; download_file(&ffmpeg_url, &ffmpeg_zip, Duration::from_secs(30 * 60))?; + verify_pinned_sha256(&ffmpeg_zip, ffmpeg_expected.as_deref(), "FFmpeg")?; download_file(&ffprobe_url, &ffprobe_zip, Duration::from_secs(30 * 60))?; + verify_pinned_sha256(&ffprobe_zip, ffprobe_expected.as_deref(), "ffprobe")?; let ffmpeg_dir = data_dir.join("ffmpeg"); fs::create_dir_all(&ffmpeg_dir) @@ -2289,4 +2358,61 @@ mod tests { assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::NotFound); // clear_webkit_data suppresses NotFound — this is the correct behavior. } + + // --- macOS FFmpeg checksum verification (#172) --- + + #[cfg(target_os = "macos")] + #[test] + fn verify_pinned_sha256_accepts_matching_hash() { + let dir = make_tmp(); + let f = dir.path().join("a.bin"); + fs::write(&f, b"hello").unwrap(); + // sha256("hello") + let sha = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; + assert!(super::verify_pinned_sha256(&f, Some(sha), "test").is_ok()); + assert!(f.exists(), "a valid download must be kept"); + } + + #[cfg(target_os = "macos")] + #[test] + fn verify_pinned_sha256_rejects_and_removes_on_mismatch() { + let dir = make_tmp(); + let f = dir.path().join("a.bin"); + fs::write(&f, b"hello").unwrap(); + let wrong = "0000000000000000000000000000000000000000000000000000000000000000"; + assert!(super::verify_pinned_sha256(&f, Some(wrong), "test").is_err()); + assert!(!f.exists(), "a tampered/corrupt download must be removed"); + } + + #[cfg(target_os = "macos")] + #[test] + fn verify_pinned_sha256_none_skips() { + let dir = make_tmp(); + let f = dir.path().join("a.bin"); + fs::write(&f, b"hello").unwrap(); + assert!(super::verify_pinned_sha256(&f, None, "test").is_ok()); + assert!(f.exists()); + } + + #[cfg(target_os = "macos")] + #[test] + fn expected_sha_pins_default_url_and_skips_unknown_override() { + // Default URL -> the pinned hash. + let got = super::expected_ffmpeg_sha256( + super::DEFAULT_MACOS_FFMPEG_URL, + super::DEFAULT_MACOS_FFMPEG_URL, + super::DEFAULT_MACOS_FFMPEG_SHA256, + "STEMDECK_FFMPEG_SHA256_TEST_UNSET_172", + ); + assert_eq!(got.as_deref(), Some(super::DEFAULT_MACOS_FFMPEG_SHA256)); + // Custom override URL with no override hash env set -> None (skip, + // matching the Windows override behaviour). + let none = super::expected_ffmpeg_sha256( + "https://example.com/custom.zip", + super::DEFAULT_MACOS_FFMPEG_URL, + super::DEFAULT_MACOS_FFMPEG_SHA256, + "STEMDECK_FFMPEG_SHA256_TEST_UNSET_172", + ); + assert!(none.is_none()); + } }