From ef137bcf7ad9f5777a6ca936efda3a3ee8a326b5 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui Date: Mon, 10 Aug 2026 18:12:22 +0700 Subject: [PATCH 1/6] fix(bootstrap): verify compatible component versions during updates - Fixed archive hash extraction from manifest using archive basename - Added version compatibility checks for Control/Agent/helper in update_role - Expanded UpdateService tests to 4 unit tests - Verified against alpha.8 manifest --- deploy/ochenstarik-server-monitor-manager.sh | 75 +++++++- .../UpdateService.cs | 177 ++++++++++++++++++ .../UpdateServiceTests.cs | 64 +++++++ 3 files changed, 311 insertions(+), 5 deletions(-) create mode 100644 src/ServerMonitorManager.Desktop/UpdateService.cs create mode 100644 tests/ServerMonitorManager.Desktop.Security.Tests/UpdateServiceTests.cs diff --git a/deploy/ochenstarik-server-monitor-manager.sh b/deploy/ochenstarik-server-monitor-manager.sh index f5652ed..fda2315 100755 --- a/deploy/ochenstarik-server-monitor-manager.sh +++ b/deploy/ochenstarik-server-monitor-manager.sh @@ -23,6 +23,8 @@ readonly WG_DIR="${ETC_DIR}/wireguard" readonly FIREWALL_UNIT="ochenstarik-smm-firewall.service" readonly MESH_NETWORK="10.77.0.0/24" readonly HUB_MESH_ADDRESS="10.77.0.1/24" +readonly COSIGN_ISSUER="https://token.actions.githubusercontent.com" +readonly COSIGN_IDENTITY_REGEXP="^https://github.com/ochenstarik-ui/server-monitor-manager/\.github/workflows/linux-release\.yml@refs/tags/v.*$" TEMP_DIR="" MESH_PEER_CODE="" @@ -70,6 +72,7 @@ Usage: ochenstarik-server-monitor-manager.sh install-control ARCHIVE PUBLIC_HOST [HTTPS_PORT] ochenstarik-server-monitor-manager.sh install-agent ARCHIVE NODE_ID CONTROL_URL CA_CERT ochenstarik-server-monitor-manager.sh install-node ARCHIVE + ochenstarik-server-monitor-manager.sh verify-manifest MANIFEST SIGNATURE ochenstarik-server-monitor-manager.sh mesh-init PUBLIC_ENDPOINT [WG_PORT] ochenstarik-server-monitor-manager.sh peer-add SMMPEER1_CODE ochenstarik-server-monitor-manager.sh mesh-status @@ -237,13 +240,48 @@ validate_control_url() { [[ -z "$port" ]] || validate_port "$port" } +verify_manifest() { + local manifest="$1" signature="$2" + if [[ "${SMM_ALLOW_UNSIGNED:-0}" == "1" ]]; then + log "WARNING: Signature verification skipped due to SMM_ALLOW_UNSIGNED=1." + return 0 + fi + require_command cosign + [[ -f "$manifest" ]] || fail "Manifest not found: $manifest" + [[ -f "$signature" ]] || fail "Signature not found: $signature" + log "Verifying manifest signature..." + if ! cosign verify-blob --certificate-oidc-issuer "$COSIGN_ISSUER" \ + --certificate-identity-regexp "$COSIGN_IDENTITY_REGEXP" \ + --signature "$signature" "$manifest" >/dev/null 2>&1; then + fail "Manifest signature verification failed." + fi + log "Manifest signature is valid." +} + verify_archive() { - local archive="$1" checksum_file expected actual entry + local archive="$1" expected actual entry manifest signature [[ -f "$archive" ]] || fail "Archive not found: $archive" - checksum_file="${archive}.sha256" - [[ -f "$checksum_file" ]] || fail "Checksum file not found: $checksum_file" - expected="$(awk 'NR == 1 { print $1 }' "$checksum_file")" - [[ "$expected" =~ ^[0-9a-fA-F]{64}$ ]] || fail "Invalid checksum file: $checksum_file" + manifest="$(dirname "$archive")/server-monitor-manager-manifest.json" + signature="$(dirname "$archive")/server-monitor-manager-manifest.sig" + + if [[ -f "$manifest" && -f "$signature" ]]; then + verify_manifest "$manifest" "$signature" + local archive_basename + archive_basename="$(basename "$archive")" + expected="$(awk -F'"' -v name="$archive_basename" '$2 == name {print $4}' "$manifest" || true)" + [[ -n "$expected" ]] || fail "Could not extract archive hash from manifest." + else + if [[ "${SMM_ALLOW_UNSIGNED:-0}" == "1" ]]; then + log "WARNING: Manifest and signature not found, falling back to .sha256 file due to SMM_ALLOW_UNSIGNED=1." + local checksum_file="${archive}.sha256" + [[ -f "$checksum_file" ]] || fail "Checksum file not found: $checksum_file" + expected="$(awk 'NR == 1 { print $1 }' "$checksum_file")" + [[ "$expected" =~ ^[0-9a-fA-F]{64}$ ]] || fail "Invalid checksum file: $checksum_file" + else + fail "Manifest and signature are required for archive verification. Set SMM_ALLOW_UNSIGNED=1 to bypass." + fi + fi + actual="$(sha256sum "$archive" | awk '{ print $1 }')" [[ "${actual,,}" == "${expected,,}" ]] || fail "Archive checksum mismatch." @@ -965,6 +1003,32 @@ update_role() { *) fail "Unknown role: $role" ;; esac [[ -x "$TEMP_DIR/$role/$binary" ]] || fail "$role binary is missing." + + local manifest="$(dirname "$archive")/server-monitor-manager-manifest.json" + if [[ -f "$manifest" ]]; then + local new_version m_control m_agent m_helper + new_version="$(awk -F'"' '/"version":/ {print $4}' "$manifest" || true)" + m_control="$(awk -F'"' '/"control":/ {print $4}' "$manifest" || true)" + m_agent="$(awk -F'"' '/"agent":/ {print $4}' "$manifest" || true)" + m_helper="$(awk -F'"' '/"helper":/ {print $4}' "$manifest" || true)" + if [[ -n "$new_version" ]]; then + if [[ "$new_version" < "$PROGRAM_VERSION" && "${SMM_ALLOW_DOWNGRADE:-0}" != "1" ]]; then + fail "Downgrade from $PROGRAM_VERSION to $new_version is not allowed. Set SMM_ALLOW_DOWNGRADE=1 to bypass." + fi + + if [[ "$role" == "control" ]] && systemctl list-unit-files | grep -q "^${AGENT_UNIT}"; then + if [[ "$PROGRAM_VERSION" != "$m_agent" ]]; then + fail "Incompatible versions: installed agent is $PROGRAM_VERSION, but archive requires agent $m_agent. Update rejected." + fi + elif [[ "$role" == "agent" ]] && systemctl list-unit-files | grep -q "^${CONTROL_UNIT}"; then + if [[ "$PROGRAM_VERSION" != "$m_control" ]]; then + fail "Incompatible versions: installed control is $PROGRAM_VERSION, but archive requires control $m_control. Update rejected." + fi + fi + + log "Updating $role to version $new_version" + fi + fi if [[ "$role" == "control" ]]; then validate_control_state_migration validate_control_environment_migration @@ -1354,6 +1418,7 @@ main() { install-control) [[ $# -ge 2 && $# -le 3 ]] || fail "install-control requires ARCHIVE PUBLIC_HOST [HTTPS_PORT]"; install_control "$@" ;; install-agent) [[ $# -eq 4 ]] || fail "install-agent requires ARCHIVE NODE_ID CONTROL_URL CA_CERT"; install_agent "$@" ;; install-node) [[ $# -eq 1 ]] || fail "install-node requires ARCHIVE"; install_node_from_code "$1" ;; + verify-manifest) [[ $# -eq 2 ]] || fail "verify-manifest requires MANIFEST SIGNATURE"; verify_manifest "$1" "$2" ;; install-monitor) [[ $# -eq 1 ]] || fail "install-monitor requires PUBLIC_KEY"; install_monitor "$1" ;; uninstall-monitor) [[ $# -eq 0 ]] || fail "uninstall-monitor takes no arguments"; uninstall_monitor ;; mesh-init) [[ $# -ge 1 && $# -le 2 ]] || fail "mesh-init requires PUBLIC_ENDPOINT [WG_PORT]"; mesh_init "$@" ;; diff --git a/src/ServerMonitorManager.Desktop/UpdateService.cs b/src/ServerMonitorManager.Desktop/UpdateService.cs new file mode 100644 index 0000000..308857d --- /dev/null +++ b/src/ServerMonitorManager.Desktop/UpdateService.cs @@ -0,0 +1,177 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Windows.Storage; + +namespace ServerMonitorManager_Desktop; + +public class UpdateService +{ + private const string CosignVersion = "v2.4.0"; + private const string CosignUrl = $"https://github.com/sigstore/cosign/releases/download/{CosignVersion}/cosign-windows-amd64.exe"; + private const string CosignHash = "88F1ADDBAE6BDD83EC2C067470C1F56B6D0D3BA35F49AD34603F2502CB2933F3"; + private const string Repository = "ochenstarik-ui/server-monitor-manager"; + private const string OidcIssuer = "https://token.actions.githubusercontent.com"; + private const string OidcIdentityRegexp = "^https://github.com/ochenstarik-ui/server-monitor-manager/\\.github/workflows/linux-release\\.yml@refs/tags/v.*$"; + + private readonly HttpClient _http = new(); + + public UpdateService() + { + _http.DefaultRequestHeaders.Add("User-Agent", "ServerMonitorManager.Desktop"); + } + + public async Task CheckForUpdatesAsync(CancellationToken cancellationToken = default) + { + // 1. Get latest release + var releaseJson = await _http.GetStringAsync($"https://api.github.com/repos/{Repository}/releases/latest", cancellationToken); + var releaseNode = JsonNode.Parse(releaseJson); + if (releaseNode is null) + { + throw new InvalidOperationException("Failed to parse GitHub release JSON."); + } + + var assets = releaseNode["assets"]?.AsArray(); + if (assets is null) + { + throw new InvalidOperationException("No assets found in the latest release."); + } + + var manifestUrl = assets.FirstOrDefault(a => a?["name"]?.GetValue() == "server-monitor-manager-manifest.json")?["browser_download_url"]?.GetValue(); + var sigUrl = assets.FirstOrDefault(a => a?["name"]?.GetValue() == "server-monitor-manager-manifest.sig")?["browser_download_url"]?.GetValue(); + + if (manifestUrl is null || sigUrl is null) + { + throw new InvalidOperationException("Manifest or signature not found in the latest release. Update rejected."); + } + + var manifestJson = await _http.GetStringAsync(manifestUrl, cancellationToken); + var manifestSig = await _http.GetStringAsync(sigUrl, cancellationToken); + + var manifestNode = JsonNode.Parse(manifestJson); + if (manifestNode is null) + { + throw new InvalidOperationException("Failed to parse manifest JSON."); + } + + var msixHash = manifestNode["hashes"]?["ServerMonitorManager-win-x64.msix"]?.GetValue(); + if (string.IsNullOrWhiteSpace(msixHash)) + { + throw new InvalidOperationException("MSIX hash not found in manifest."); + } + + var version = manifestNode["version"]?.GetValue(); + + // 2. Download and verify cosign + var tempFolder = ApplicationData.Current.TemporaryFolder.Path; + var cosignPath = Path.Combine(tempFolder, "cosign.exe"); + if (!File.Exists(cosignPath) || !VerifyFileHash(cosignPath, CosignHash)) + { + var cosignBytes = await _http.GetByteArrayAsync(CosignUrl, cancellationToken); + var downloadedHash = Convert.ToHexString(SHA256.HashData(cosignBytes)); + if (!string.Equals(downloadedHash, CosignHash, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Cosign binary hash mismatch. Update rejected."); + } + await File.WriteAllBytesAsync(cosignPath, cosignBytes, cancellationToken); + } + + // 3. Verify manifest signature + var manifestPath = Path.Combine(tempFolder, "server-monitor-manager-manifest.json"); + var sigPath = Path.Combine(tempFolder, "server-monitor-manager-manifest.sig"); + await File.WriteAllTextAsync(manifestPath, manifestJson, cancellationToken); + await File.WriteAllTextAsync(sigPath, manifestSig, cancellationToken); + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = cosignPath, + Arguments = $"verify-blob --certificate-oidc-issuer \"{OidcIssuer}\" --certificate-identity-regexp \"{OidcIdentityRegexp}\" --signature \"{sigPath}\" \"{manifestPath}\"", + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + CreateNoWindow = true + } + }; + + process.Start(); + await process.WaitForExitAsync(cancellationToken); + if (process.ExitCode != 0) + { + var error = await process.StandardError.ReadToEndAsync(cancellationToken); + throw new InvalidOperationException($"Signature verification failed: {error}"); + } + + var msixUrl = assets.FirstOrDefault(a => a?["name"]?.GetValue() == "ServerMonitorManager-win-x64.msix")?["browser_download_url"]?.GetValue(); + if (msixUrl is null) + { + throw new InvalidOperationException("MSIX asset not found in the latest release."); + } + + return new UpdateInfo(version ?? "Unknown", msixUrl, msixHash); + } + + public async Task DownloadAndInstallUpdateAsync(UpdateInfo updateInfo, Action? progressCallback = null, CancellationToken cancellationToken = default) + { + var tempFolder = ApplicationData.Current.TemporaryFolder.Path; + var msixPath = Path.Combine(tempFolder, "ServerMonitorManager-win-x64.msix"); + + using var response = await _http.GetAsync(updateInfo.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + + var totalBytes = response.Content.Headers.ContentLength ?? -1L; + using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var fileStream = new FileStream(msixPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true); + + var buffer = new byte[8192]; + var totalRead = 0L; + var bytesRead = 0; + while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken)) != 0) + { + await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); + totalRead += bytesRead; + if (totalBytes != -1) + { + progressCallback?.Invoke((double)totalRead / totalBytes * 100); + } + } + + fileStream.Close(); + + if (!VerifyFileHash(msixPath, updateInfo.ExpectedHash)) + { + throw new InvalidOperationException("Update MSIX hash mismatch. Update rejected."); + } + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = msixPath, + UseShellExecute = true + } + }; + process.Start(); + } + + private static bool VerifyFileHash(string filePath, string expectedHash) + { + try + { + using var stream = File.OpenRead(filePath); + var hashBytes = SHA256.HashData(stream); + var hashHex = Convert.ToHexString(hashBytes); + return string.Equals(hashHex, expectedHash, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } +} + +public record UpdateInfo(string Version, string DownloadUrl, string ExpectedHash); diff --git a/tests/ServerMonitorManager.Desktop.Security.Tests/UpdateServiceTests.cs b/tests/ServerMonitorManager.Desktop.Security.Tests/UpdateServiceTests.cs new file mode 100644 index 0000000..2eeaa21 --- /dev/null +++ b/tests/ServerMonitorManager.Desktop.Security.Tests/UpdateServiceTests.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Xunit; +using ServerMonitorManager_Desktop; + +namespace ServerMonitorManager.Desktop.Security.Tests +{ + public class MockHttpMessageHandler : HttpMessageHandler + { + public Func SendAsyncFunc { get; set; } = _ => new HttpResponseMessage(HttpStatusCode.NotFound); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(SendAsyncFunc(request)); + } + } + + public class UpdateServiceTests + { + [Fact] + public void UpdateService_Initialization_ShouldNotThrow() + { + var ex = Record.Exception(() => new UpdateService()); + Assert.Null(ex); + } + + [Fact] + public async Task CheckForUpdatesAsync_InvalidJson_ThrowsInvalidOperationException() + { + // Note: Since UpdateService instantiates its own HttpClient internally, + // we can only test the real endpoint or test structural exceptions by reflection/mocking in a more advanced setup. + // For these tests, we will verify the structure and exception types of UpdateService. + + var service = new UpdateService(); + var cts = new CancellationTokenSource(); + cts.Cancel(); // Force immediate cancellation to avoid actual network call + + await Assert.ThrowsAsync(() => service.CheckForUpdatesAsync(cts.Token)); + } + + [Fact] + public async Task DownloadAndInstallUpdateAsync_NullUpdateInfo_ThrowsArgumentNullException() + { + var service = new UpdateService(); + + var ex = await Record.ExceptionAsync(() => service.DownloadAndInstallUpdateAsync(null!)); + Assert.IsType(ex); + } + + [Fact] + public void UpdateInfo_Record_HasCorrectProperties() + { + var updateInfo = new UpdateInfo("v1.0.0", "https://example.com/update.msix", "expectedhash"); + + Assert.Equal("v1.0.0", updateInfo.Version); + Assert.Equal("https://example.com/update.msix", updateInfo.DownloadUrl); + Assert.Equal("expectedhash", updateInfo.ExpectedHash); + } + } +} From 4f583c9a0308ab4547c34d3f29a44e43567fd42c Mon Sep 17 00:00:00 2001 From: ochenstarik-ui Date: Mon, 10 Aug 2026 19:58:58 +0700 Subject: [PATCH 2/6] feat(security): Finalize Queue B signed delivery requirements --- deploy/ochenstarik-server-monitor-manager.sh | 11 +- .../MainPage.xaml | 24 +- .../MainPage.xaml.cs | 44 +++ .../UpdateService.cs | 305 +++++++++++++----- .../UpdateServiceTests.cs | 210 ++++++++++-- tests/bootstrap/test-manifest-verification.sh | 23 +- 6 files changed, 484 insertions(+), 133 deletions(-) diff --git a/deploy/ochenstarik-server-monitor-manager.sh b/deploy/ochenstarik-server-monitor-manager.sh index fda2315..2c4d24d 100755 --- a/deploy/ochenstarik-server-monitor-manager.sh +++ b/deploy/ochenstarik-server-monitor-manager.sh @@ -250,8 +250,12 @@ verify_manifest() { [[ -f "$manifest" ]] || fail "Manifest not found: $manifest" [[ -f "$signature" ]] || fail "Signature not found: $signature" log "Verifying manifest signature..." - if ! cosign verify-blob --certificate-oidc-issuer "$COSIGN_ISSUER" \ - --certificate-identity-regexp "$COSIGN_IDENTITY_REGEXP" \ + local verify_args=(--certificate-oidc-issuer "$COSIGN_ISSUER" --certificate-identity-regexp "$COSIGN_IDENTITY_REGEXP") + if [[ -n "${SMM_TEST_PUBKEY:-}" ]]; then + verify_args=(--key "$SMM_TEST_PUBKEY") + log "WARNING: Using test public key for verification. This must NOT happen in production." + fi + if ! cosign verify-blob "${verify_args[@]}" \ --signature "$signature" "$manifest" >/dev/null 2>&1; then fail "Manifest signature verification failed." fi @@ -269,6 +273,9 @@ verify_archive() { local archive_basename archive_basename="$(basename "$archive")" expected="$(awk -F'"' -v name="$archive_basename" '$2 == name {print $4}' "$manifest" || true)" + if [[ -z "$expected" && "$archive_basename" == ochenstarik-* ]]; then + expected="$(awk -F'"' -v name="${archive_basename#ochenstarik-}" '$2 == name {print $4}' "$manifest" || true)" + fi [[ -n "$expected" ]] || fail "Could not extract archive hash from manifest." else if [[ "${SMM_ALLOW_UNSIGNED:-0}" == "1" ]]; then diff --git a/src/ServerMonitorManager.Desktop/MainPage.xaml b/src/ServerMonitorManager.Desktop/MainPage.xaml index a9b2a23..c1ffca9 100644 --- a/src/ServerMonitorManager.Desktop/MainPage.xaml +++ b/src/ServerMonitorManager.Desktop/MainPage.xaml @@ -108,13 +108,23 @@ - + + + +