diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 74a8691..c5e3102 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -36,7 +36,12 @@ jobs: run: ./tests/windows/Test-DesktopContracts.ps1 - name: Test Desktop security - run: dotnet test tests/ServerMonitorManager.Desktop.Security.Tests/ServerMonitorManager.Desktop.Security.Tests.csproj --configuration Release -p:RestoreLockedMode=true + run: dotnet test tests/ServerMonitorManager.Desktop.Security.Tests/ServerMonitorManager.Desktop.Security.Tests.csproj --configuration Release -p:RestoreLockedMode=true --filter Category!=LiveRelease + + - name: Test Desktop security (live release) + env: + SMM_TEST_RELEASE_TAG: v0.1.0-alpha.18 + run: dotnet test tests/ServerMonitorManager.Desktop.Security.Tests/ServerMonitorManager.Desktop.Security.Tests.csproj --configuration Release -p:RestoreLockedMode=true --filter Category=LiveRelease - name: Build test-signed MSIX installer shell: pwsh diff --git a/tests/ServerMonitorManager.Desktop.Security.Tests/LiveReleaseUpdateVerificationTests.cs b/tests/ServerMonitorManager.Desktop.Security.Tests/LiveReleaseUpdateVerificationTests.cs new file mode 100644 index 0000000..5e8ab03 --- /dev/null +++ b/tests/ServerMonitorManager.Desktop.Security.Tests/LiveReleaseUpdateVerificationTests.cs @@ -0,0 +1,242 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using ServerMonitorManager_Desktop; +using Xunit; + +namespace ServerMonitorManager.Desktop.Security.Tests; + +[Trait("Category", "LiveRelease")] +public sealed class LiveReleaseUpdateVerificationTests : IAsyncDisposable +{ + private const string DefaultReleaseTag = "v0.1.0-alpha.14"; + private const string Repository = "ochenstarik-ui/server-monitor-manager"; + private readonly string _tempDir; + private readonly HttpClient _http; + private readonly string _tag; + + public LiveReleaseUpdateVerificationTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"smm-live-release-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _http = new HttpClient(); + _http.DefaultRequestHeaders.Add("User-Agent", "ServerMonitorManager.Desktop.Tests"); + _tag = Environment.GetEnvironmentVariable("SMM_TEST_RELEASE_TAG") ?? DefaultReleaseTag; + } + + private async Task<(string ManifestPath, string SigPath, string PemPath)> DownloadReleaseArtifactsAsync() + { + var manifestUrl = $"https://github.com/{Repository}/releases/download/{_tag}/server-monitor-manager-manifest.json"; + var sigUrl = $"https://github.com/{Repository}/releases/download/{_tag}/server-monitor-manager-manifest.sig"; + var pemUrl = $"https://github.com/{Repository}/releases/download/{_tag}/server-monitor-manager-manifest.pem"; + + var manifestPath = Path.Combine(_tempDir, "server-monitor-manager-manifest.json"); + var sigPath = Path.Combine(_tempDir, "server-monitor-manager-manifest.sig"); + var pemPath = Path.Combine(_tempDir, "server-monitor-manager-manifest.pem"); + + var manifestBytes = await _http.GetByteArrayAsync(manifestUrl, TestContext.Current.CancellationToken); + var sigBytes = await _http.GetByteArrayAsync(sigUrl, TestContext.Current.CancellationToken); + var pemBytes = await _http.GetByteArrayAsync(pemUrl, TestContext.Current.CancellationToken); + + await File.WriteAllBytesAsync(manifestPath, manifestBytes, TestContext.Current.CancellationToken); + await File.WriteAllBytesAsync(sigPath, sigBytes, TestContext.Current.CancellationToken); + await File.WriteAllBytesAsync(pemPath, pemBytes, TestContext.Current.CancellationToken); + + return (manifestPath, sigPath, pemPath); + } + + [Fact] + public async Task AcceptanceRealReleaseManifestAndSignatureAreAccepted() + { + var (manifestPath, sigPath, pemPath) = await DownloadReleaseArtifactsAsync(); + + var fileStorage = new TestDirectoryFileStorage(_tempDir); + var httpTransport = new DefaultHttpTransport(); + var verifier = new ProcessSignatureVerifier(fileStorage, httpTransport); + + // 1. ProcessSignatureVerifier directly verifies real release material + await verifier.VerifySignatureAsync(sigPath, manifestPath, pemPath, TestContext.Current.CancellationToken); + + // 2. Parse and verify manifest content + var manifestJson = await File.ReadAllTextAsync(manifestPath, TestContext.Current.CancellationToken); + var node = JsonNode.Parse(manifestJson); + Assert.NotNull(node); + Assert.Equal(_tag, node["version"]?.GetValue()); + + var msixHash = node["hashes"]?["ServerMonitorManager-win-x64.msix"]?.GetValue(); + Assert.False(string.IsNullOrWhiteSpace(msixHash)); + Assert.Equal(64, msixHash.Length); + + // 3. UpdateService end-to-end against real release material + var mockHttp = new MockHttpTransport + { + GetStringAsyncFunc = url => + { + if (url.EndsWith("releases/latest", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult($@"{{ + ""tag_name"": ""{_tag}"", + ""assets"": [ + {{ ""name"": ""server-monitor-manager-manifest.json"", ""browser_download_url"": ""https://github.com/{Repository}/releases/download/{_tag}/server-monitor-manager-manifest.json"" }}, + {{ ""name"": ""server-monitor-manager-manifest.sig"", ""browser_download_url"": ""https://github.com/{Repository}/releases/download/{_tag}/server-monitor-manager-manifest.sig"" }}, + {{ ""name"": ""server-monitor-manager-manifest.pem"", ""browser_download_url"": ""https://github.com/{Repository}/releases/download/{_tag}/server-monitor-manager-manifest.pem"" }}, + {{ ""name"": ""ServerMonitorManager-win-x64.msix"", ""browser_download_url"": ""https://github.com/{Repository}/releases/download/{_tag}/ServerMonitorManager-win-x64.msix"" }} + ] + }}"); + } + if (url.EndsWith("server-monitor-manager-manifest.json", StringComparison.OrdinalIgnoreCase)) + { + return File.ReadAllTextAsync(manifestPath, TestContext.Current.CancellationToken); + } + if (url.EndsWith("server-monitor-manager-manifest.sig", StringComparison.OrdinalIgnoreCase)) + { + return File.ReadAllTextAsync(sigPath, TestContext.Current.CancellationToken); + } + if (url.EndsWith("server-monitor-manager-manifest.pem", StringComparison.OrdinalIgnoreCase)) + { + return File.ReadAllTextAsync(pemPath, TestContext.Current.CancellationToken); + } + throw new InvalidOperationException($"Unexpected URL: {url}"); + } + }; + + var service = new UpdateService(mockHttp, verifier, fileStorage); + var updateInfo = await service.CheckForUpdatesAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(updateInfo); + Assert.Equal(_tag, updateInfo.Version); + Assert.Equal(msixHash, updateInfo.ExpectedHash); + Assert.Contains(_tag, updateInfo.DownloadUrl, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Negative1TamperedHashInRealManifestIsRejected() + { + var (manifestPath, sigPath, pemPath) = await DownloadReleaseArtifactsAsync(); + + // Alter manifest content by tampering with the hash + var originalManifest = await File.ReadAllTextAsync(manifestPath, TestContext.Current.CancellationToken); + var node = JsonNode.Parse(originalManifest); + Assert.NotNull(node); + if (node["hashes"]?["ServerMonitorManager-win-x64.msix"] is not null) + { + node["hashes"]!["ServerMonitorManager-win-x64.msix"] = "0000000000000000000000000000000000000000000000000000000000000000"; + } + else + { + node["hashes"] = new JsonObject + { + ["ServerMonitorManager-win-x64.msix"] = "0000000000000000000000000000000000000000000000000000000000000000" + }; + } + var tamperedManifest = node.ToJsonString(); + + var tamperedManifestPath = Path.Combine(_tempDir, "tampered-manifest.json"); + await File.WriteAllTextAsync(tamperedManifestPath, tamperedManifest, TestContext.Current.CancellationToken); + + var fileStorage = new TestDirectoryFileStorage(_tempDir); + var httpTransport = new DefaultHttpTransport(); + var verifier = new ProcessSignatureVerifier(fileStorage, httpTransport); + + // Verification of tampered manifest with real signature must fail + var ex = await Assert.ThrowsAsync(() => + verifier.VerifySignatureAsync(sigPath, tamperedManifestPath, pemPath, TestContext.Current.CancellationToken)); + Assert.Contains("Signature verification failed", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Negative2RealManifestWithSignatureFromDifferentIdentityIsRejected() + { + var (manifestPath, _, pemPath) = await DownloadReleaseArtifactsAsync(); + + // Create a fake signature signed by a different key + using var rsa = RSA.Create(); + var fakeSigBytes = rsa.SignData( + await File.ReadAllBytesAsync(manifestPath, TestContext.Current.CancellationToken), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + var fakeSigPath = Path.Combine(_tempDir, "fake-identity.sig"); + await File.WriteAllTextAsync(fakeSigPath, Convert.ToBase64String(fakeSigBytes), TestContext.Current.CancellationToken); + + var fileStorage = new TestDirectoryFileStorage(_tempDir); + var httpTransport = new DefaultHttpTransport(); + var verifier = new ProcessSignatureVerifier(fileStorage, httpTransport); + + var ex = await Assert.ThrowsAsync(() => + verifier.VerifySignatureAsync(fakeSigPath, manifestPath, pemPath, TestContext.Current.CancellationToken)); + Assert.Contains("Signature verification failed", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Negative3MissingCertificateIsRejected() + { + var (manifestPath, sigPath, _) = await DownloadReleaseArtifactsAsync(); + var nonExistentPemPath = Path.Combine(_tempDir, "non-existent-certificate.pem"); + + var fileStorage = new TestDirectoryFileStorage(_tempDir); + var httpTransport = new DefaultHttpTransport(); + var verifier = new ProcessSignatureVerifier(fileStorage, httpTransport); + + var ex = await Assert.ThrowsAsync(() => + verifier.VerifySignatureAsync(sigPath, manifestPath, nonExistentPemPath, TestContext.Current.CancellationToken)); + Assert.Contains("Signature verification failed", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Negative4CertificateFromDifferentWorkflowIsRejected() + { + var (manifestPath, sigPath, _) = await DownloadReleaseArtifactsAsync(); + + // Create a custom self-signed certificate with a different subject/workflow identity + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var req = new CertificateRequest( + "CN=Untrusted Workflow Fake Cert", + ecdsa, + HashAlgorithmName.SHA256); + using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1)); + var fakePemPath = Path.Combine(_tempDir, "fake-workflow-cert.pem"); + await File.WriteAllTextAsync(fakePemPath, cert.ExportCertificatePem(), TestContext.Current.CancellationToken); + + var fileStorage = new TestDirectoryFileStorage(_tempDir); + var httpTransport = new DefaultHttpTransport(); + var verifier = new ProcessSignatureVerifier(fileStorage, httpTransport); + + var ex = await Assert.ThrowsAsync(() => + verifier.VerifySignatureAsync(sigPath, manifestPath, fakePemPath, TestContext.Current.CancellationToken)); + Assert.Contains("Signature verification failed", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + public async ValueTask DisposeAsync() + { + _http.Dispose(); + try + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + catch + { + // Best effort cleanup + } + await Task.CompletedTask; + } + + private sealed class TestDirectoryFileStorage(string directory) : IFileStorage + { + public string GetTempFolder() => directory; + public bool FileExists(string path) => File.Exists(path); + public Task WriteAllBytesAsync(string path, byte[] bytes, System.Threading.CancellationToken cancellationToken = default) => + File.WriteAllBytesAsync(path, bytes, cancellationToken); + public Task WriteAllTextAsync(string path, string text, System.Threading.CancellationToken cancellationToken = default) => + File.WriteAllTextAsync(path, text, cancellationToken); + public Stream OpenRead(string path) => File.OpenRead(path); + public void LaunchFile(string path) { } + } +}