From c436e5fe4b7474acfe7108a69b68c5829f1c7607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=AA=20Trung=20Hi=E1=BA=BFu?= Date: Wed, 8 Jul 2026 08:48:42 +0700 Subject: [PATCH] feat: verify release asset integrity with SHA256 checksums - Add AssetVerifier helper to compute and verify SHA256 hashes. - Add ChecksumUrl to UpdateInfo and download SHA256SUMS.txt during updates. - Verify downloaded asset hash before replacing the active plugin file. - Update CI to generate SHA256SUMS.txt and attach it to releases. - Add 7 unit tests for AssetVerifier; dotnet test passes 60/60. Closes #34. --- .github/workflows/release.yml | 16 +++++ src/AssetVerifier.cs | 70 ++++++++++++++++++ src/KeePassAutoReload.csproj | 1 + src/KeePassAutoReloadExt.cs | 34 +++++++++ src/UpdateChecker.cs | 8 +++ tests/KeePassAutoReload.Tests.csproj | 1 + tests/Tests.cs | 104 +++++++++++++++++++++++++++ 7 files changed, 234 insertions(+) create mode 100644 src/AssetVerifier.cs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aff70fb..fc26194 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,8 +76,23 @@ jobs: "$dist/KeePassAutoReload.Updater.exe" -Force Copy-Item "README.md" "$dist/README.md" -Force + $dllHash = (Get-FileHash "$dist/KeePassAutoReload.dll" -Algorithm SHA256).Hash.ToLower() + $updaterHash = (Get-FileHash "$dist/KeePassAutoReload.Updater.exe" -Algorithm SHA256).Hash.ToLower() + $zipHash = $null + Compress-Archive -Path "$dist/KeePassAutoReload.dll", "$dist/KeePassAutoReload.Updater.exe", "$dist/README.md" ` -DestinationPath "$dist/KeePassAutoReload.zip" -Force + $zipHash = (Get-FileHash "$dist/KeePassAutoReload.zip" -Algorithm SHA256).Hash.ToLower() + + $checksums = @( + "$dllHash KeePassAutoReload.dll", + "$updaterHash KeePassAutoReload.Updater.exe", + "$zipHash KeePassAutoReload.zip" + ) + $checksums | Out-File "$dist/SHA256SUMS.txt" -Encoding utf8 + + Write-Host "SHA256SUMS.txt contents:" + Get-Content "$dist/SHA256SUMS.txt" - name: Upload build artifact uses: actions/upload-artifact@v7 @@ -100,3 +115,4 @@ jobs: dist/KeePassAutoReload.dll dist/KeePassAutoReload.Updater.exe dist/KeePassAutoReload.zip + dist/SHA256SUMS.txt diff --git a/src/AssetVerifier.cs b/src/AssetVerifier.cs new file mode 100644 index 0000000..78486f3 --- /dev/null +++ b/src/AssetVerifier.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Text; + +namespace KeePassAutoReload +{ + internal static class AssetVerifier + { + public static string ComputeSha256(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) throw new ArgumentException("filePath"); + + using (FileStream stream = File.OpenRead(filePath)) + using (SHA256 sha256 = SHA256.Create()) + { + byte[] hash = sha256.ComputeHash(stream); + return BytesToHex(hash); + } + } + + public static Dictionary ParseChecksums(string checksumsText) + { + Dictionary entries = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(checksumsText)) return entries; + + using (StringReader reader = new StringReader(checksumsText)) + { + string line; + while ((line = reader.ReadLine()) != null) + { + string trimmed = line.Trim(); + if (string.IsNullOrEmpty(trimmed)) continue; + + int separatorIndex = trimmed.IndexOf(' '); + if (separatorIndex <= 0) continue; + + string hash = trimmed.Substring(0, separatorIndex).Trim(); + string fileName = trimmed.Substring(separatorIndex + 1).Trim(); + + if (string.IsNullOrEmpty(hash) || string.IsNullOrEmpty(fileName)) continue; + + entries[fileName] = hash; + } + } + + return entries; + } + + public static bool VerifyFile(string filePath, string expectedHash) + { + if (string.IsNullOrWhiteSpace(filePath) || string.IsNullOrWhiteSpace(expectedHash)) return false; + if (!File.Exists(filePath)) return false; + + string actualHash = ComputeSha256(filePath); + return string.Equals(actualHash, expectedHash.Trim(), StringComparison.OrdinalIgnoreCase); + } + + private static string BytesToHex(byte[] bytes) + { + StringBuilder builder = new StringBuilder(bytes.Length * 2); + foreach (byte b in bytes) + { + builder.Append(b.ToString("x2")); + } + return builder.ToString(); + } + } +} diff --git a/src/KeePassAutoReload.csproj b/src/KeePassAutoReload.csproj index 7473bdc..a53b5be 100644 --- a/src/KeePassAutoReload.csproj +++ b/src/KeePassAutoReload.csproj @@ -25,6 +25,7 @@ + diff --git a/src/KeePassAutoReloadExt.cs b/src/KeePassAutoReloadExt.cs index 2059023..bab0f58 100644 --- a/src/KeePassAutoReloadExt.cs +++ b/src/KeePassAutoReloadExt.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Threading.Tasks; @@ -179,16 +180,37 @@ private async Task InstallUpdate(UpdateInfo info) { string targetPath = GetPluginPackagePath(); string tempPath = targetPath + ".download"; + string checksumPath = targetPath + ".sha256"; using (HttpUpdateClient client = new HttpUpdateClient()) { await client.DownloadFileAsync(info.AssetUrl, tempPath); + if (!string.IsNullOrWhiteSpace(info.ChecksumUrl)) + { + try + { + await client.DownloadFileAsync(info.ChecksumUrl, checksumPath); + } + catch (Exception checksumEx) + { + File.Delete(tempPath); + throw new InvalidOperationException("Failed to download release checksums: " + checksumEx.Message, checksumEx); + } + } + } + + if (File.Exists(checksumPath) && !VerifyDownloadedAsset(tempPath, checksumPath, Path.GetFileName(info.AssetUrl))) + { + File.Delete(tempPath); + File.Delete(checksumPath); + throw new InvalidOperationException("Downloaded asset failed hash verification."); } try { File.Copy(tempPath, targetPath, true); File.Delete(tempPath); + if (File.Exists(checksumPath)) File.Delete(checksumPath); ShowOnUi(delegate { @@ -202,6 +224,7 @@ private async Task InstallUpdate(UpdateInfo info) string pendingPath = targetPath + ".new"; File.Copy(tempPath, pendingPath, true); File.Delete(tempPath); + if (File.Exists(checksumPath)) File.Delete(checksumPath); bool updaterScheduled = TryScheduleUpdater(targetPath, pendingPath); @@ -234,6 +257,17 @@ private async Task InstallUpdate(UpdateInfo info) } } + private static bool VerifyDownloadedAsset(string assetPath, string checksumPath, string assetFileName) + { + if (!File.Exists(checksumPath)) return true; + + string checksumsText = File.ReadAllText(checksumPath); + Dictionary checksums = AssetVerifier.ParseChecksums(checksumsText); + if (!checksums.ContainsKey(assetFileName)) return true; + + return AssetVerifier.VerifyFile(assetPath, checksums[assetFileName]); + } + private static string GetPluginPackagePath() { return PluginPathResolver.ResolvePluginPackagePath( diff --git a/src/UpdateChecker.cs b/src/UpdateChecker.cs index 59832b1..88fb9f5 100644 --- a/src/UpdateChecker.cs +++ b/src/UpdateChecker.cs @@ -16,6 +16,7 @@ internal sealed class UpdateInfo public string LatestVersion; public string ReleaseUrl; public string AssetUrl; + public string ChecksumUrl; public bool IsUpdateAvailable; } @@ -115,6 +116,7 @@ public static async Task CheckLatestAsync(IUpdateClient client, Plug info.LatestVersion = tagName; info.ReleaseUrl = BuildReleaseUrl(tagName); info.AssetUrl = BuildAssetUrl(tagName, format); + info.ChecksumUrl = BuildChecksumUrl(tagName); info.IsUpdateAvailable = IsNewerVersion(GetCurrentVersion(), tagName); return info; } @@ -181,6 +183,12 @@ private static string BuildReleaseUrl(string tagName) return ReleasesUrl + "/tag/" + tagName; } + private static string BuildChecksumUrl(string tagName) + { + if (string.IsNullOrEmpty(tagName)) return ReleasesUrl; + return "https://github.com/hieuck/KeePassAutoReload/releases/download/" + tagName + "/SHA256SUMS.txt"; + } + private static List ExtractJsonStrings(string json, string name) { List values = new List(); diff --git a/tests/KeePassAutoReload.Tests.csproj b/tests/KeePassAutoReload.Tests.csproj index 00cb9dc..05cb3e4 100644 --- a/tests/KeePassAutoReload.Tests.csproj +++ b/tests/KeePassAutoReload.Tests.csproj @@ -6,6 +6,7 @@ false + diff --git a/tests/Tests.cs b/tests/Tests.cs index cc400e8..56ceeec 100644 --- a/tests/Tests.cs +++ b/tests/Tests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Net; using System.Security.Authentication; @@ -532,6 +533,109 @@ public void TryScheduleUpdate_ReturnsTrueAndStartsUpdaterWhenFilesExist() } } + public class AssetVerifierTests + { + [Fact] + public void ComputeSha256_ReturnsHexHashOfFileContents() + { + string tempFile = Path.GetTempFileName(); + try + { + File.WriteAllText(tempFile, "hello world"); + string hash = AssetVerifier.ComputeSha256(tempFile); + Assert.Equal("B94D27B9934D3E08A52E52D7DA7DABFAC484EFE37A5380EE9088F7ACE2EFCDE9", hash.ToUpperInvariant()); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public void ComputeSha256_ThrowsWhenFileDoesNotExist() + { + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + string tempFile = Path.Combine(tempDir, "missing.dll"); + Assert.Throws(() => AssetVerifier.ComputeSha256(tempFile)); + } + + [Fact] + public void ParseChecksums_ReturnsExpectedEntries() + { + string checksums = + "abc123 KeePassAutoReload.dll\n" + + "def456 KeePassAutoReload.Updater.exe\n"; + + Dictionary entries = AssetVerifier.ParseChecksums(checksums); + + Assert.Equal(2, entries.Count); + Assert.Equal("abc123", entries["KeePassAutoReload.dll"]); + Assert.Equal("def456", entries["KeePassAutoReload.Updater.exe"]); + } + + [Fact] + public void ParseChecksums_IgnoresBlankLinesAndMalformedEntries() + { + string checksums = + "abc123 KeePassAutoReload.dll\n" + + "\n" + + "badline\n" + + "def456 KeePassAutoReload.Updater.exe\n"; + + Dictionary entries = AssetVerifier.ParseChecksums(checksums); + + Assert.Equal(2, entries.Count); + } + + [Fact] + public void VerifyFile_ReturnsTrueWhenHashMatches() + { + string tempFile = Path.GetTempFileName(); + try + { + File.WriteAllText(tempFile, "hello world"); + string hash = AssetVerifier.ComputeSha256(tempFile); + Assert.True(AssetVerifier.VerifyFile(tempFile, hash)); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public void VerifyFile_ReturnsFalseWhenHashDoesNotMatch() + { + string tempFile = Path.GetTempFileName(); + try + { + File.WriteAllText(tempFile, "hello world"); + Assert.False(AssetVerifier.VerifyFile(tempFile, "0000000000000000000000000000000000000000000000000000000000000000")); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public void VerifyFile_IsCaseInsensitive() + { + string tempFile = Path.GetTempFileName(); + try + { + File.WriteAllText(tempFile, "hello world"); + string hash = AssetVerifier.ComputeSha256(tempFile).ToLowerInvariant(); + Assert.True(AssetVerifier.VerifyFile(tempFile, hash.ToUpperInvariant())); + } + finally + { + File.Delete(tempFile); + } + } + } + internal sealed class FakeUpdateClient : IUpdateClient { public string Response { get; set; }