From 21bf0263880c280c30a5dc3083ecc5573f67787d Mon Sep 17 00:00:00 2001 From: BlackSpirits Date: Fri, 11 Sep 2026 19:30:03 +0200 Subject: [PATCH 1/2] Verify CrispEmbed runtime downloads --- .../Download/CrispEmbedDownloadService.cs | 69 ++++++++++++++++--- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/src/ui/Logic/Download/CrispEmbedDownloadService.cs b/src/ui/Logic/Download/CrispEmbedDownloadService.cs index 1b689ba5ce9..f8f468ea1e6 100644 --- a/src/ui/Logic/Download/CrispEmbedDownloadService.cs +++ b/src/ui/Logic/Download/CrispEmbedDownloadService.cs @@ -4,6 +4,7 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using Nikse.SubtitleEdit.UiLogic; namespace Nikse.SubtitleEdit.Logic.Download; @@ -41,27 +42,28 @@ public CrispEmbedDownloadService(HttpClient httpClient) public async Task DownloadEngine(Stream stream, IProgress? progress, CancellationToken cancellationToken) { - await DownloadHelper.DownloadFileAsync(_httpClient, GetUrl(), stream, progress, cancellationToken); + var download = GetDefaultDownload(); + await DownloadAndVerifyAsync(download.Url, download.HashKey, stream, progress, cancellationToken); } public async Task DownloadEngineWindowsCuda(Stream stream, IProgress? progress, CancellationToken cancellationToken) { - await DownloadHelper.DownloadFileAsync(_httpClient, WindowsCudaUrl, stream, progress, cancellationToken); + await DownloadAndVerifyAsync(WindowsCudaUrl, DownloadHashManager.CrispEmbed.WindowsCuda, stream, progress, cancellationToken); } public async Task DownloadEngineWindowsVulkan(Stream stream, IProgress? progress, CancellationToken cancellationToken) { - await DownloadHelper.DownloadFileAsync(_httpClient, WindowsVulkanUrl, stream, progress, cancellationToken); + await DownloadAndVerifyAsync(WindowsVulkanUrl, DownloadHashManager.CrispEmbed.WindowsVulkan, stream, progress, cancellationToken); } public async Task DownloadEngineWindowsCpu(Stream stream, IProgress? progress, CancellationToken cancellationToken) { - await DownloadHelper.DownloadFileAsync(_httpClient, WindowsCpuUrl, stream, progress, cancellationToken); + await DownloadAndVerifyAsync(WindowsCpuUrl, DownloadHashManager.CrispEmbed.WindowsCpu, stream, progress, cancellationToken); } public async Task DownloadEngineLinuxCuda(Stream stream, IProgress? progress, CancellationToken cancellationToken) { - await DownloadHelper.DownloadFileAsync(_httpClient, LinuxCudaUrl, stream, progress, cancellationToken); + await DownloadAndVerifyAsync(LinuxCudaUrl, DownloadHashManager.CrispEmbed.LinuxCuda, stream, progress, cancellationToken); } public async Task DownloadModel(string url, string destinationFileName, IProgress? progress, CancellationToken cancellationToken) @@ -69,21 +71,70 @@ public async Task DownloadModel(string url, string destinationFileName, IProgres await DownloadHelper.DownloadFileAsync(_httpClient, url, destinationFileName, progress, cancellationToken); } - private static string GetUrl() + private async Task DownloadAndVerifyAsync( + string url, + string hashKey, + Stream stream, + IProgress? progress, + CancellationToken cancellationToken) + { + await DownloadHelper.DownloadFileAsync(_httpClient, url, stream, progress, cancellationToken); + await VerifyArchiveAsync(stream, hashKey, cancellationToken); + } + + internal static async Task VerifyArchiveAsync(Stream stream, string? hashKey, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(hashKey)) + { + throw new InvalidOperationException("No SHA-256 key is registered for the CrispEmbed runtime."); + } + + var expected = DownloadHashManager.GetLatestKnownHash(hashKey); + if (string.IsNullOrEmpty(expected)) + { + throw new InvalidOperationException($"No SHA-256 is registered for CrispEmbed runtime key '{hashKey}'."); + } + + if (!stream.CanRead || !stream.CanSeek) + { + throw new InvalidOperationException("CrispEmbed runtime integrity verification requires a readable, seekable stream."); + } + + string actual; + stream.Position = 0; + try + { + actual = await Sha256Util.ComputeSha256Async(stream, cancellationToken); + } + finally + { + stream.Position = 0; + } + + if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException( + $"CrispEmbed runtime download failed integrity check (expected SHA-256 {expected}, got {actual})."); + } + } + + private static (string Url, string HashKey) GetDefaultDownload() { if (OperatingSystem.IsWindows()) { - return WindowsVulkanUrl; + return (WindowsVulkanUrl, DownloadHashManager.CrispEmbed.WindowsVulkan); } if (OperatingSystem.IsLinux()) { - return RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? LinuxArmUrl : LinuxUrl; + return RuntimeInformation.ProcessArchitecture == Architecture.Arm64 + ? (LinuxArmUrl, DownloadHashManager.CrispEmbed.LinuxArm) + : (LinuxUrl, DownloadHashManager.CrispEmbed.Linux); } if (OperatingSystem.IsMacOS()) { - return MacUrl; + return (MacUrl, DownloadHashManager.CrispEmbed.MacOs); } throw new PlatformNotSupportedException(); From 5bbcda0219d02ad2cd4e8628341e6eb41a5d7996 Mon Sep 17 00:00:00 2001 From: BlackSpirits Date: Fri, 11 Sep 2026 19:30:05 +0200 Subject: [PATCH 2/2] Add CrispEmbed download integrity regressions --- .../CrispEmbedDownloadServiceTests.cs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/UI/Logic/Download/CrispEmbedDownloadServiceTests.cs diff --git a/tests/UI/Logic/Download/CrispEmbedDownloadServiceTests.cs b/tests/UI/Logic/Download/CrispEmbedDownloadServiceTests.cs new file mode 100644 index 00000000000..d80e641f928 --- /dev/null +++ b/tests/UI/Logic/Download/CrispEmbedDownloadServiceTests.cs @@ -0,0 +1,79 @@ +using System.Net; +using System.Text; +using Nikse.SubtitleEdit.Logic.Download; + +namespace UITests.Logic.Download; + +public class CrispEmbedDownloadServiceTests +{ + [Theory] + [InlineData(DownloadHashManager.CrispEmbed.WindowsCuda, "89bcdde6d81634461278bd877e6e35f477cd6771dec7de40f0cfeeca1c5b732a")] + [InlineData(DownloadHashManager.CrispEmbed.WindowsVulkan, "ce2d3eb91b6dda3b9a50b96cf26e70010854c0b28fa38413c87cefe732dd9fc2")] + [InlineData(DownloadHashManager.CrispEmbed.WindowsCpu, "dabf9483f1a2e6e801b85a3fe67a6dab2b55359fc604c91e80a44756a21e4b07")] + [InlineData(DownloadHashManager.CrispEmbed.MacOs, "b85b636dfc5dfb2e9d7b7d6403931864bc42a756629a3962396cf750d6f604ca")] + [InlineData(DownloadHashManager.CrispEmbed.Linux, "775b138650a60064b66f976da3e22c8f5b36fa605f7ebd767967fb0c3b412984")] + [InlineData(DownloadHashManager.CrispEmbed.LinuxCuda, "3e1447bc93f8f7039c94b91d4bb97c739cc3b754d9b1ee3006616dbd445a1b6f")] + [InlineData(DownloadHashManager.CrispEmbed.LinuxArm, "247915ad0c870814498a81731ddc037a79d00be5f05aee7c7c85a5dd5fae9c40")] + public void RegistryHash_MatchesPublishedReleaseDigest(string key, string expected) + { + Assert.Equal(expected, DownloadHashManager.GetLatestKnownHash(key)); + } + + [Fact] + public async Task DownloadEngine_TamperedPayload_RejectsDownloadedBytes() + { + if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + { + Assert.Skip("CrispEmbed runtime is not supported on this operating system."); + } + + using var httpClient = new HttpClient(new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered"))); + var service = new CrispEmbedDownloadService(httpClient); + await using var stream = new MemoryStream(); + + await Assert.ThrowsAsync(() => + service.DownloadEngine( + stream, + progress: null, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, stream.Position); + } + + [Fact] + public async Task VerifyArchiveAsync_UnknownKey_FailsClosed() + { + await using var stream = new MemoryStream(Encoding.ASCII.GetBytes("abc")); + + await Assert.ThrowsAsync(() => + CrispEmbedDownloadService.VerifyArchiveAsync( + stream, + "CrispEmbed.Unknown", + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task VerifyArchiveAsync_TamperedPayload_RewindsStream() + { + await using var stream = new MemoryStream(Encoding.ASCII.GetBytes("tampered")); + + await Assert.ThrowsAsync(() => + CrispEmbedDownloadService.VerifyArchiveAsync( + stream, + DownloadHashManager.CrispEmbed.Linux, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, stream.Position); + } + + private sealed class StaticResponseHandler(byte[] payload) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(payload), + }); + } + } +}