Merge pull request #35 from ochenstarik-ui/antigravity/signed-delivery-queue-b
feat(security): Queue B signed delivery — DI updater, 10 desktop + 5 bootstrap tests
This commit is contained in:
commit
a6fca04bd6
10 changed files with 844 additions and 33 deletions
|
|
@ -23,6 +23,9 @@ 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"
|
||||
# Trust anchors — see docs/release-policy.md for the full signing and identity contract.
|
||||
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 +73,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 +241,55 @@ 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..."
|
||||
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
|
||||
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)"
|
||||
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
|
||||
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 +1011,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 +1426,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 "$@" ;;
|
||||
|
|
|
|||
|
|
@ -108,13 +108,23 @@
|
|||
</CommandBar>
|
||||
</Grid>
|
||||
|
||||
<InfoBar
|
||||
x:Name="LinkActionInfo"
|
||||
Grid.Row="1"
|
||||
Margin="0,16,0,0"
|
||||
IsClosable="True"
|
||||
IsOpen="False"
|
||||
Severity="Informational" />
|
||||
<StackPanel Grid.Row="1" Spacing="16" Margin="0,16,0,0">
|
||||
<InfoBar
|
||||
x:Name="UpdateInfoBar"
|
||||
IsClosable="True"
|
||||
IsOpen="False"
|
||||
Severity="Success">
|
||||
<InfoBar.ActionButton>
|
||||
<Button x:Name="InstallUpdateButton" Content="Обновить" Click="InstallUpdateButton_Click" />
|
||||
</InfoBar.ActionButton>
|
||||
</InfoBar>
|
||||
|
||||
<InfoBar
|
||||
x:Name="LinkActionInfo"
|
||||
IsClosable="True"
|
||||
IsOpen="False"
|
||||
Severity="Informational" />
|
||||
</StackPanel>
|
||||
|
||||
<Border
|
||||
x:Name="OverviewSummary"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ public sealed partial class MainPage : Page
|
|||
private readonly SshMonitorService _ssh = new();
|
||||
private readonly MetricsHistoryStorage _historyStorage = new();
|
||||
private readonly ControlClientService _control = new();
|
||||
private readonly UpdateService _update = new(new DefaultHttpTransport(), new ProcessSignatureVerifier(new DefaultFileStorage(), new DefaultHttpTransport()), new DefaultFileStorage());
|
||||
private UpdateInfo? _pendingUpdate;
|
||||
private readonly List<MetricSampleData> _history = [];
|
||||
private readonly DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromSeconds(30) };
|
||||
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
||||
|
|
@ -136,6 +138,28 @@ public sealed partial class MainPage : Page
|
|||
}
|
||||
|
||||
_loaded = true;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var update = await _update.CheckForUpdatesAsync(usePreRelease: true);
|
||||
if (update is not null)
|
||||
{
|
||||
DispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
_pendingUpdate = update;
|
||||
UpdateInfoBar.Message = $"Доступна новая версия: {update.Version}";
|
||||
UpdateInfoBar.IsOpen = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Update check failed: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
_refreshTimer.Start();
|
||||
_history.AddRange(await _historyStorage.LoadAsync());
|
||||
foreach (var profile in await _storage.LoadAsync())
|
||||
|
|
@ -446,6 +470,26 @@ public sealed partial class MainPage : Page
|
|||
});
|
||||
}
|
||||
|
||||
private async void InstallUpdateButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_pendingUpdate is null) return;
|
||||
|
||||
InstallUpdateButton.IsEnabled = false;
|
||||
InstallUpdateButton.Content = "Скачивание...";
|
||||
UpdateInfoBar.IsClosable = false;
|
||||
|
||||
try
|
||||
{
|
||||
await _update.DownloadAndVerifyUpdateAsync(_pendingUpdate);
|
||||
_update.InstallUpdate();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowInfo("Ошибка обновления", ex.Message, InfoBarSeverity.Error);
|
||||
UpdateInfoBar.IsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Task HandleControlEventAsync(ControlEvent controlEvent)
|
||||
{
|
||||
DispatcherQueue.TryEnqueue(() =>
|
||||
|
|
|
|||
328
src/ServerMonitorManager.Desktop/UpdateService.cs
Normal file
328
src/ServerMonitorManager.Desktop/UpdateService.cs
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Tracing;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Windows.Storage;
|
||||
|
||||
namespace ServerMonitorManager_Desktop;
|
||||
|
||||
public interface IHttpTransport
|
||||
{
|
||||
Task<string> GetStringAsync(string url, CancellationToken cancellationToken = default);
|
||||
Task<byte[]> GetByteArrayAsync(string url, CancellationToken cancellationToken = default);
|
||||
Task DownloadFileAsync(string url, string destinationPath, Action<double>? progressCallback = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface ISignatureVerifier
|
||||
{
|
||||
Task VerifySignatureAsync(string signaturePath, string manifestPath, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IFileStorage
|
||||
{
|
||||
string GetTempFolder();
|
||||
bool FileExists(string path);
|
||||
Task WriteAllBytesAsync(string path, byte[] bytes, CancellationToken cancellationToken = default);
|
||||
Task WriteAllTextAsync(string path, string text, CancellationToken cancellationToken = default);
|
||||
Stream OpenRead(string path);
|
||||
void LaunchFile(string path);
|
||||
}
|
||||
|
||||
public class DefaultHttpTransport : IHttpTransport
|
||||
{
|
||||
private readonly HttpClient _http = new();
|
||||
|
||||
public DefaultHttpTransport()
|
||||
{
|
||||
_http.DefaultRequestHeaders.Add("User-Agent", "ServerMonitorManager.Desktop");
|
||||
}
|
||||
|
||||
public Task<string> GetStringAsync(string url, CancellationToken cancellationToken = default) => _http.GetStringAsync(url, cancellationToken);
|
||||
public Task<byte[]> GetByteArrayAsync(string url, CancellationToken cancellationToken = default) => _http.GetByteArrayAsync(url, cancellationToken);
|
||||
|
||||
public async Task DownloadFileAsync(string url, string destinationPath, Action<double>? progressCallback = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var response = await _http.GetAsync(url, 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(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true);
|
||||
|
||||
var buffer = new byte[8192];
|
||||
var totalRead = 0L;
|
||||
int bytesRead;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class ProcessSignatureVerifier : ISignatureVerifier
|
||||
{
|
||||
private readonly IFileStorage _fileStorage;
|
||||
private readonly IHttpTransport _httpTransport;
|
||||
|
||||
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 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.*$";
|
||||
|
||||
public ProcessSignatureVerifier(IFileStorage fileStorage, IHttpTransport httpTransport)
|
||||
{
|
||||
_fileStorage = fileStorage;
|
||||
_httpTransport = httpTransport;
|
||||
}
|
||||
|
||||
public async Task VerifySignatureAsync(string signaturePath, string manifestPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cosignPath = Path.Combine(_fileStorage.GetTempFolder(), "cosign.exe");
|
||||
if (!_fileStorage.FileExists(cosignPath) || !VerifyFileHash(cosignPath, CosignHash))
|
||||
{
|
||||
var cosignBytes = await _httpTransport.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 _fileStorage.WriteAllBytesAsync(cosignPath, cosignBytes, cancellationToken);
|
||||
}
|
||||
|
||||
var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = cosignPath,
|
||||
Arguments = $"verify-blob --certificate-oidc-issuer \"{OidcIssuer}\" --certificate-identity-regexp \"{OidcIdentityRegexp}\" --signature \"{signaturePath}\" \"{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}");
|
||||
}
|
||||
}
|
||||
|
||||
private bool VerifyFileHash(string filePath, string expectedHash)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = _fileStorage.OpenRead(filePath);
|
||||
var hashBytes = SHA256.HashData(stream);
|
||||
var hashHex = Convert.ToHexString(hashBytes);
|
||||
return string.Equals(hashHex, expectedHash, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class DefaultFileStorage : IFileStorage
|
||||
{
|
||||
public string GetTempFolder() => ApplicationData.Current.TemporaryFolder.Path;
|
||||
public bool FileExists(string path) => File.Exists(path);
|
||||
public Task WriteAllBytesAsync(string path, byte[] bytes, CancellationToken cancellationToken = default) => File.WriteAllBytesAsync(path, bytes, cancellationToken);
|
||||
public Task WriteAllTextAsync(string path, string text, CancellationToken cancellationToken = default) => File.WriteAllTextAsync(path, text, cancellationToken);
|
||||
public Stream OpenRead(string path) => File.OpenRead(path);
|
||||
public void LaunchFile(string path)
|
||||
{
|
||||
var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = path,
|
||||
UseShellExecute = true
|
||||
}
|
||||
};
|
||||
process.Start();
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateService
|
||||
{
|
||||
private const string Repository = "ochenstarik-ui/server-monitor-manager";
|
||||
// Trust anchors pinned per docs/release-policy.md — do NOT fetch these from the release being verified.
|
||||
private const string CosignIssuer = "https://token.actions.githubusercontent.com";
|
||||
private const string CosignIdentityRegexp = @"^https://github\.com/ochenstarik-ui/server-monitor-manager/\.github/workflows/.*\.yml@refs/tags/v.*$";
|
||||
|
||||
private static readonly TraceSource Log = new("ServerMonitorManager.UpdateService", SourceLevels.All);
|
||||
|
||||
private readonly IHttpTransport _http;
|
||||
private readonly ISignatureVerifier _signatureVerifier;
|
||||
private readonly IFileStorage _fileStorage;
|
||||
|
||||
public UpdateService(IHttpTransport http, ISignatureVerifier signatureVerifier, IFileStorage fileStorage)
|
||||
{
|
||||
_http = http ?? throw new ArgumentNullException(nameof(http));
|
||||
_signatureVerifier = signatureVerifier ?? throw new ArgumentNullException(nameof(signatureVerifier));
|
||||
_fileStorage = fileStorage ?? throw new ArgumentNullException(nameof(fileStorage));
|
||||
}
|
||||
|
||||
public async Task<UpdateInfo?> CheckForUpdatesAsync(bool usePreRelease = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. Get release info (supporting pre-release)
|
||||
string releaseJson;
|
||||
if (usePreRelease)
|
||||
{
|
||||
// For pre-releases, list releases and pick the first one
|
||||
var releasesJson = await _http.GetStringAsync($"https://api.github.com/repos/{Repository}/releases", cancellationToken);
|
||||
var releasesArray = JsonNode.Parse(releasesJson)?.AsArray();
|
||||
if (releasesArray is null || releasesArray.Count == 0)
|
||||
throw new InvalidOperationException("No releases found.");
|
||||
|
||||
// Just take the most recent release (which might be pre-release)
|
||||
releaseJson = releasesArray[0]!.ToJsonString();
|
||||
}
|
||||
else
|
||||
{
|
||||
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 releaseTagName = releaseNode["tag_name"]?.GetValue<string>();
|
||||
|
||||
var assets = releaseNode["assets"]?.AsArray();
|
||||
if (assets is null)
|
||||
{
|
||||
throw new InvalidOperationException("No assets found in the release.");
|
||||
}
|
||||
|
||||
var manifestUrl = assets.FirstOrDefault(a => a?["name"]?.GetValue<string>() == "server-monitor-manager-manifest.json")?["browser_download_url"]?.GetValue<string>();
|
||||
var sigUrl = assets.FirstOrDefault(a => a?["name"]?.GetValue<string>() == "server-monitor-manager-manifest.sig")?["browser_download_url"]?.GetValue<string>();
|
||||
|
||||
if (manifestUrl is null || sigUrl is null)
|
||||
{
|
||||
throw new InvalidOperationException("Manifest or signature not found in the release. Update rejected.");
|
||||
}
|
||||
|
||||
// Validate URLs belong to the same tag!
|
||||
if (!manifestUrl.Contains($"/releases/download/{releaseTagName}/") || !sigUrl.Contains($"/releases/download/{releaseTagName}/"))
|
||||
{
|
||||
throw new InvalidOperationException("Manifest or signature URL does not match the release tag. 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<string>();
|
||||
if (string.IsNullOrWhiteSpace(msixHash))
|
||||
{
|
||||
throw new InvalidOperationException("MSIX hash not found in manifest.");
|
||||
}
|
||||
|
||||
var version = manifestNode["version"]?.GetValue<string>();
|
||||
|
||||
// Manifest version must match the release tag to prevent downgrade/cross-version attacks
|
||||
if (!string.IsNullOrEmpty(version) && !string.IsNullOrEmpty(releaseTagName) && version != releaseTagName)
|
||||
{
|
||||
Log.TraceEvent(TraceEventType.Error, 0, $"Manifest version '{version}' does not match release tag '{releaseTagName}'. Rejecting.");
|
||||
throw new InvalidOperationException($"Manifest version '{version}' does not match release tag '{releaseTagName}'. Update rejected.");
|
||||
}
|
||||
|
||||
// 3. Verify manifest signature BEFORE showing update action
|
||||
var tempFolder = _fileStorage.GetTempFolder();
|
||||
var manifestPath = Path.Combine(tempFolder, "server-monitor-manager-manifest.json");
|
||||
var sigPath = Path.Combine(tempFolder, "server-monitor-manager-manifest.sig");
|
||||
await _fileStorage.WriteAllTextAsync(manifestPath, manifestJson, cancellationToken);
|
||||
await _fileStorage.WriteAllTextAsync(sigPath, manifestSig, cancellationToken);
|
||||
|
||||
Log.TraceEvent(TraceEventType.Information, 0, "Verifying manifest signature...");
|
||||
await _signatureVerifier.VerifySignatureAsync(sigPath, manifestPath, cancellationToken);
|
||||
Log.TraceEvent(TraceEventType.Information, 0, "Manifest signature verified successfully.");
|
||||
|
||||
var msixUrl = assets.FirstOrDefault(a => a?["name"]?.GetValue<string>() == "ServerMonitorManager-win-x64.msix")?["browser_download_url"]?.GetValue<string>();
|
||||
if (msixUrl is null)
|
||||
{
|
||||
throw new InvalidOperationException("MSIX asset not found in the latest release.");
|
||||
}
|
||||
|
||||
if (!msixUrl.Contains($"/releases/download/{releaseTagName}/"))
|
||||
{
|
||||
throw new InvalidOperationException("MSIX URL does not match the release tag. Update rejected.");
|
||||
}
|
||||
|
||||
return new UpdateInfo(version ?? "Unknown", msixUrl, msixHash);
|
||||
}
|
||||
|
||||
public async Task DownloadAndVerifyUpdateAsync(UpdateInfo updateInfo, Action<double>? progressCallback = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (updateInfo == null) throw new ArgumentNullException(nameof(updateInfo));
|
||||
|
||||
var tempFolder = _fileStorage.GetTempFolder();
|
||||
var msixPath = Path.Combine(tempFolder, "ServerMonitorManager-win-x64.msix");
|
||||
|
||||
Log.TraceEvent(TraceEventType.Information, 0, $"Downloading update from {updateInfo.DownloadUrl}...");
|
||||
await _http.DownloadFileAsync(updateInfo.DownloadUrl, msixPath, progressCallback, cancellationToken);
|
||||
|
||||
if (!VerifyFileHash(msixPath, updateInfo.ExpectedHash))
|
||||
{
|
||||
// Delete the corrupted file immediately
|
||||
try { File.Delete(msixPath); } catch { /* best-effort cleanup */ }
|
||||
Log.TraceEvent(TraceEventType.Error, 0, "MSIX hash mismatch — downloaded file deleted.");
|
||||
throw new InvalidOperationException("Update MSIX hash mismatch. Update rejected.");
|
||||
}
|
||||
Log.TraceEvent(TraceEventType.Information, 0, "MSIX hash verified successfully.");
|
||||
}
|
||||
|
||||
public void InstallUpdate()
|
||||
{
|
||||
var tempFolder = _fileStorage.GetTempFolder();
|
||||
var msixPath = Path.Combine(tempFolder, "ServerMonitorManager-win-x64.msix");
|
||||
if (!_fileStorage.FileExists(msixPath))
|
||||
{
|
||||
throw new InvalidOperationException("Downloaded update file not found.");
|
||||
}
|
||||
_fileStorage.LaunchFile(msixPath);
|
||||
}
|
||||
|
||||
private bool VerifyFileHash(string filePath, string expectedHash)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = _fileStorage.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);
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\src\ServerMonitorManager.Desktop\UpdateService.cs" Link="UpdateService.cs" />
|
||||
<Compile Include="..\..\src\ServerMonitorManager.Desktop\SshPrivateKeySession.cs" Link="SshPrivateKeySession.cs" />
|
||||
<Compile Include="..\..\src\ServerMonitorManager.Desktop\SshHostKeyTrust.cs" Link="SshHostKeyTrust.cs" />
|
||||
<Compile Include="..\..\src\ServerMonitorManager.Desktop\SshConnectionArguments.cs" Link="SshConnectionArguments.cs" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,268 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
using ServerMonitorManager_Desktop;
|
||||
|
||||
namespace ServerMonitorManager.Desktop.Security.Tests
|
||||
{
|
||||
public class MockHttpTransport : IHttpTransport
|
||||
{
|
||||
public Func<string, Task<string>> GetStringAsyncFunc { get; set; } = _ => Task.FromResult("");
|
||||
public Func<string, Task<byte[]>> GetByteArrayAsyncFunc { get; set; } = _ => Task.FromResult(Array.Empty<byte>());
|
||||
public Func<string, string, Action<double>?, Task> DownloadFileAsyncFunc { get; set; } = (_, _, _) => Task.CompletedTask;
|
||||
|
||||
public Task<string> GetStringAsync(string url, CancellationToken cancellationToken = default) => GetStringAsyncFunc(url);
|
||||
public Task<byte[]> GetByteArrayAsync(string url, CancellationToken cancellationToken = default) => GetByteArrayAsyncFunc(url);
|
||||
public Task DownloadFileAsync(string url, string destinationPath, Action<double>? progressCallback = null, CancellationToken cancellationToken = default) => DownloadFileAsyncFunc(url, destinationPath, progressCallback);
|
||||
}
|
||||
|
||||
public class MockSignatureVerifier : ISignatureVerifier
|
||||
{
|
||||
public Func<string, string, Task> VerifySignatureAsyncFunc { get; set; } = (_, _) => Task.CompletedTask;
|
||||
public Task VerifySignatureAsync(string signaturePath, string manifestPath, CancellationToken cancellationToken = default) => VerifySignatureAsyncFunc(signaturePath, manifestPath);
|
||||
}
|
||||
|
||||
public class MockFileStorage : IFileStorage
|
||||
{
|
||||
public string GetTempFolder() => Path.GetTempPath();
|
||||
public bool FileExists(string path) => true;
|
||||
public Task WriteAllBytesAsync(string path, byte[] bytes, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task WriteAllTextAsync(string path, string text, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Func<string, Stream> OpenReadFunc { get; set; } = _ => new MemoryStream();
|
||||
public Stream OpenRead(string path) => OpenReadFunc(path);
|
||||
public Action<string> LaunchFileFunc { get; set; } = _ => { };
|
||||
public void LaunchFile(string path) => LaunchFileFunc(path);
|
||||
}
|
||||
|
||||
public class UpdateServiceTests
|
||||
{
|
||||
private const string ValidReleaseJson = @"
|
||||
{
|
||||
""tag_name"": ""v0.1.0-alpha.9"",
|
||||
""assets"": [
|
||||
{ ""name"": ""server-monitor-manager-manifest.json"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/server-monitor-manager-manifest.json"" },
|
||||
{ ""name"": ""server-monitor-manager-manifest.sig"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/server-monitor-manager-manifest.sig"" },
|
||||
{ ""name"": ""ServerMonitorManager-win-x64.msix"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/ServerMonitorManager-win-x64.msix"" }
|
||||
]
|
||||
}";
|
||||
|
||||
// Release JSON without the .sig asset — used by Test4
|
||||
private const string NoSigReleaseJson = @"
|
||||
{
|
||||
""tag_name"": ""v0.1.0-alpha.9"",
|
||||
""assets"": [
|
||||
{ ""name"": ""server-monitor-manager-manifest.json"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/server-monitor-manager-manifest.json"" },
|
||||
{ ""name"": ""ServerMonitorManager-win-x64.msix"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/ServerMonitorManager-win-x64.msix"" }
|
||||
]
|
||||
}";
|
||||
|
||||
private const string ValidManifestJson = @"
|
||||
{
|
||||
""version"": ""v0.1.0-alpha.9"",
|
||||
""hashes"": {
|
||||
""ServerMonitorManager-win-x64.msix"": ""e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855""
|
||||
}
|
||||
}"; // Hash for empty string
|
||||
|
||||
[Fact]
|
||||
public async Task Test1_ValidSignatureAndHash_Accepted()
|
||||
{
|
||||
var http = new MockHttpTransport
|
||||
{
|
||||
GetStringAsyncFunc = url =>
|
||||
{
|
||||
if (url.EndsWith("releases/latest")) return Task.FromResult(ValidReleaseJson);
|
||||
if (url.EndsWith(".json")) return Task.FromResult(ValidManifestJson);
|
||||
if (url.EndsWith(".sig")) return Task.FromResult("valid-sig");
|
||||
return Task.FromResult("");
|
||||
}
|
||||
};
|
||||
var verifier = new MockSignatureVerifier();
|
||||
var storage = new MockFileStorage
|
||||
{
|
||||
OpenReadFunc = _ => new MemoryStream(Array.Empty<byte>()) // Hash of empty matches e3b0c4...
|
||||
};
|
||||
|
||||
var service = new UpdateService(http, verifier, storage);
|
||||
var update = await service.CheckForUpdatesAsync();
|
||||
|
||||
Assert.NotNull(update);
|
||||
Assert.Equal("v0.1.0-alpha.9", update.Version);
|
||||
|
||||
await service.DownloadAndVerifyUpdateAsync(update);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test2_ManifestWithWrongIdentity_Rejected()
|
||||
{
|
||||
var http = new MockHttpTransport { GetStringAsyncFunc = url => Task.FromResult(url.EndsWith("releases/latest") ? ValidReleaseJson : ValidManifestJson) };
|
||||
var verifier = new MockSignatureVerifier
|
||||
{
|
||||
VerifySignatureAsyncFunc = (_, _) => throw new InvalidOperationException("Signature verification failed: identity mismatch")
|
||||
};
|
||||
var storage = new MockFileStorage();
|
||||
|
||||
var service = new UpdateService(http, verifier, storage);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CheckForUpdatesAsync());
|
||||
Assert.Contains("identity mismatch", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test3_ManifestHashMismatch_Rejected()
|
||||
{
|
||||
var http = new MockHttpTransport
|
||||
{
|
||||
GetStringAsyncFunc = url =>
|
||||
{
|
||||
if (url.EndsWith("releases/latest")) return Task.FromResult(ValidReleaseJson);
|
||||
if (url.EndsWith(".json")) return Task.FromResult(ValidManifestJson);
|
||||
if (url.EndsWith(".sig")) return Task.FromResult("valid-sig");
|
||||
return Task.FromResult("");
|
||||
}
|
||||
};
|
||||
var verifier = new MockSignatureVerifier();
|
||||
var storage = new MockFileStorage
|
||||
{
|
||||
OpenReadFunc = _ => new MemoryStream(Encoding.UTF8.GetBytes("wrong content"))
|
||||
};
|
||||
|
||||
var service = new UpdateService(http, verifier, storage);
|
||||
var update = await service.CheckForUpdatesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.DownloadAndVerifyUpdateAsync(update));
|
||||
Assert.Contains("hash mismatch", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test4_MissingSignature_Rejected()
|
||||
{
|
||||
var http = new MockHttpTransport { GetStringAsyncFunc = _ => Task.FromResult(NoSigReleaseJson) };
|
||||
var service = new UpdateService(http, new MockSignatureVerifier(), new MockFileStorage());
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CheckForUpdatesAsync());
|
||||
Assert.Contains("Manifest or signature not found", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test5_InvalidSignature_Rejected()
|
||||
{
|
||||
var http = new MockHttpTransport { GetStringAsyncFunc = url => Task.FromResult(url.EndsWith("releases/latest") ? ValidReleaseJson : ValidManifestJson) };
|
||||
var verifier = new MockSignatureVerifier
|
||||
{
|
||||
VerifySignatureAsyncFunc = (_, _) => throw new InvalidOperationException("Signature verification failed: invalid signature format")
|
||||
};
|
||||
var storage = new MockFileStorage();
|
||||
|
||||
var service = new UpdateService(http, verifier, storage);
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CheckForUpdatesAsync());
|
||||
Assert.Contains("invalid signature format", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test6_UpdateActionNotShownUntilVerified()
|
||||
{
|
||||
var http = new MockHttpTransport { GetStringAsyncFunc = url => Task.FromResult(url.EndsWith("releases/latest") ? ValidReleaseJson : ValidManifestJson) };
|
||||
bool signatureVerified = false;
|
||||
var verifier = new MockSignatureVerifier
|
||||
{
|
||||
VerifySignatureAsyncFunc = (_, _) => { signatureVerified = true; return Task.CompletedTask; }
|
||||
};
|
||||
var storage = new MockFileStorage();
|
||||
|
||||
var service = new UpdateService(http, verifier, storage);
|
||||
var update = await service.CheckForUpdatesAsync();
|
||||
|
||||
// CheckForUpdatesAsync returns the update ONLY AFTER signature is verified
|
||||
Assert.True(signatureVerified);
|
||||
Assert.NotNull(update);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test7_PreReleaseChannel_Used()
|
||||
{
|
||||
var preReleaseJson = "[" + ValidReleaseJson.Replace("v0.1.0-alpha.9", "v0.1.0-alpha.10") + "]";
|
||||
var preReleaseManifest = ValidManifestJson.Replace("v0.1.0-alpha.9", "v0.1.0-alpha.10");
|
||||
|
||||
var http = new MockHttpTransport
|
||||
{
|
||||
GetStringAsyncFunc = url =>
|
||||
{
|
||||
if (url.EndsWith("releases")) return Task.FromResult(preReleaseJson); // Note: releases array
|
||||
if (url.EndsWith(".json")) return Task.FromResult(preReleaseManifest);
|
||||
if (url.EndsWith(".sig")) return Task.FromResult("valid-sig");
|
||||
return Task.FromResult("");
|
||||
}
|
||||
};
|
||||
var service = new UpdateService(http, new MockSignatureVerifier(), new MockFileStorage());
|
||||
|
||||
var update = await service.CheckForUpdatesAsync(usePreRelease: true);
|
||||
Assert.Equal("v0.1.0-alpha.10", update!.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test8_UrlAssetFromAnotherTag_Rejected()
|
||||
{
|
||||
// Msix URL points to a different tag
|
||||
var maliciousRelease = ValidReleaseJson.Replace("v0.1.0-alpha.9/ServerMonitorManager-win-x64.msix", "v0.1.0-alpha.8/ServerMonitorManager-win-x64.msix");
|
||||
var http = new MockHttpTransport { GetStringAsyncFunc = url => Task.FromResult(url.EndsWith("releases/latest") ? maliciousRelease : ValidManifestJson) };
|
||||
|
||||
var service = new UpdateService(http, new MockSignatureVerifier(), new MockFileStorage());
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CheckForUpdatesAsync());
|
||||
Assert.Contains("does not match the release tag", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test9_ManifestVersionMismatch_Rejected()
|
||||
{
|
||||
// Manifest says v0.1.0-alpha.8 but release tag says v0.1.0-alpha.9
|
||||
var mismatchedManifest = ValidManifestJson.Replace("v0.1.0-alpha.9", "v0.1.0-alpha.8");
|
||||
var http = new MockHttpTransport
|
||||
{
|
||||
GetStringAsyncFunc = url =>
|
||||
{
|
||||
if (url.EndsWith("releases/latest")) return Task.FromResult(ValidReleaseJson);
|
||||
if (url.EndsWith(".json")) return Task.FromResult(mismatchedManifest);
|
||||
if (url.EndsWith(".sig")) return Task.FromResult("valid-sig");
|
||||
return Task.FromResult("");
|
||||
}
|
||||
};
|
||||
var service = new UpdateService(http, new MockSignatureVerifier(), new MockFileStorage());
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CheckForUpdatesAsync());
|
||||
Assert.Contains("version", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test10_MissingMsixAsset_Rejected()
|
||||
{
|
||||
// Release JSON without the MSIX asset
|
||||
var noMsixRelease = @"
|
||||
{
|
||||
""tag_name"": ""v0.1.0-alpha.9"",
|
||||
""assets"": [
|
||||
{ ""name"": ""server-monitor-manager-manifest.json"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/server-monitor-manager-manifest.json"" },
|
||||
{ ""name"": ""server-monitor-manager-manifest.sig"", ""browser_download_url"": ""https://example.com/releases/download/v0.1.0-alpha.9/server-monitor-manager-manifest.sig"" }
|
||||
]
|
||||
}";
|
||||
var http = new MockHttpTransport
|
||||
{
|
||||
GetStringAsyncFunc = url =>
|
||||
{
|
||||
if (url.EndsWith("releases/latest")) return Task.FromResult(noMsixRelease);
|
||||
if (url.EndsWith(".json")) return Task.FromResult(ValidManifestJson);
|
||||
if (url.EndsWith(".sig")) return Task.FromResult("valid-sig");
|
||||
return Task.FromResult("");
|
||||
}
|
||||
};
|
||||
var service = new UpdateService(http, new MockSignatureVerifier(), new MockFileStorage());
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.CheckForUpdatesAsync());
|
||||
Assert.Contains("MSIX", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,13 @@ port="${SMM_SMOKE_PORT:-17443}"
|
|||
system_bootstrap="/usr/local/sbin/ochenstarik-server-monitor-manager.sh"
|
||||
probe_dir=""
|
||||
|
||||
# If manifest+sig are not shipped alongside the archive (CI-only builds),
|
||||
# allow unsigned verification via .sha256 fallback.
|
||||
archive_dir="$(dirname "$archive")"
|
||||
if [[ ! -f "$archive_dir/server-monitor-manager-manifest.json" || ! -f "$archive_dir/server-monitor-manager-manifest.sig" ]]; then
|
||||
export SMM_ALLOW_UNSIGNED=1
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$probe_dir" ]]; then
|
||||
rm -rf -- "$probe_dir"
|
||||
|
|
@ -18,8 +25,8 @@ cleanup() {
|
|||
trap cleanup EXIT
|
||||
|
||||
sudo "$bootstrap" preflight
|
||||
sudo "$bootstrap" verify-release "$archive"
|
||||
sudo "$bootstrap" install-control "$archive" 127.0.0.1 "$port"
|
||||
sudo --preserve-env=SMM_ALLOW_UNSIGNED "$bootstrap" verify-release "$archive"
|
||||
sudo --preserve-env=SMM_ALLOW_UNSIGNED "$bootstrap" install-control "$archive" 127.0.0.1 "$port"
|
||||
sudo test -x "$system_bootstrap"
|
||||
sudo test -x /usr/local/sbin/ochenstarik-smm-emergency
|
||||
sudo /usr/local/sbin/ochenstarik-smm-emergency status
|
||||
|
|
@ -74,7 +81,7 @@ node_code="$(sudo "$system_bootstrap" node-code smoke-node)"
|
|||
[[ "$node_code" == SMMNODE1.* || "$node_code" == SMMNODE2.* ]]
|
||||
export SMM_ENROLL_CODE="$node_code"
|
||||
export SMM_ACCEPT_CA_FINGERPRINT=1
|
||||
sudo --preserve-env=SMM_ENROLL_CODE,SMM_ACCEPT_CA_FINGERPRINT \
|
||||
sudo --preserve-env=SMM_ENROLL_CODE,SMM_ACCEPT_CA_FINGERPRINT --preserve-env=SMM_ALLOW_UNSIGNED \
|
||||
"$system_bootstrap" install-node "$archive"
|
||||
unset SMM_ENROLL_CODE SMM_ACCEPT_CA_FINGERPRINT
|
||||
node_code=""
|
||||
|
|
@ -102,7 +109,7 @@ device_code="$(sudo "$system_bootstrap" control-device-code smoke-device)"
|
|||
[[ "$device_code" == SMMDEV1-* ]]
|
||||
device_code=""
|
||||
|
||||
sudo "$system_bootstrap" update-control "$archive"
|
||||
sudo --preserve-env=SMM_ALLOW_UNSIGNED "$system_bootstrap" update-control "$archive"
|
||||
sudo systemctl is-active --quiet ochenstarik-smm-control.service
|
||||
sudo systemctl is-active --quiet ochenstarik-smm-agent.service
|
||||
sudo curl --fail --silent --show-error --retry 15 --retry-all-errors --retry-delay 1 \
|
||||
|
|
|
|||
|
|
@ -46,13 +46,23 @@ docker cp "$archive" "$name:$remote_archive"
|
|||
docker cp "${archive}.sha256" "$name:${remote_archive}.sha256"
|
||||
docker cp "$bootstrap" "$name:$remote_bootstrap"
|
||||
docker exec "$name" chmod 0700 "$remote_bootstrap"
|
||||
|
||||
# Copy manifest+sig if available; otherwise install-control will need SMM_ALLOW_UNSIGNED
|
||||
archive_dir="$(dirname "$archive")"
|
||||
smm_env=()
|
||||
if [[ -f "$archive_dir/server-monitor-manager-manifest.json" && -f "$archive_dir/server-monitor-manager-manifest.sig" ]]; then
|
||||
docker cp "$archive_dir/server-monitor-manager-manifest.json" "$name:$smoke_dir/server-monitor-manager-manifest.json"
|
||||
docker cp "$archive_dir/server-monitor-manager-manifest.sig" "$name:$smoke_dir/server-monitor-manager-manifest.sig"
|
||||
else
|
||||
smm_env=(env SMM_ALLOW_UNSIGNED=1)
|
||||
fi
|
||||
docker exec "$name" "$remote_bootstrap" preflight
|
||||
docker exec "$name" "$remote_bootstrap" install-control \
|
||||
docker exec "$name" "${smm_env[@]}" "$remote_bootstrap" install-control \
|
||||
"$remote_archive" 127.0.0.1 "$port"
|
||||
docker exec "$name" curl --fail --silent --show-error --retry 15 --retry-all-errors --retry-delay 1 \
|
||||
--cacert /etc/ochenstarik-server-monitor-manager/control-ca.crt \
|
||||
"https://127.0.0.1:$port/healthz"
|
||||
docker exec "$name" "$remote_bootstrap" install-control \
|
||||
docker exec "$name" "${smm_env[@]}" "$remote_bootstrap" install-control \
|
||||
"$remote_archive" 127.0.0.1 "$port"
|
||||
|
||||
docker restart "$name" >/dev/null
|
||||
|
|
|
|||
|
|
@ -780,12 +780,55 @@ install -m 0644 "$root/deploy/ochenstarik-smm-firewall.service" "$fixture/payloa
|
|||
install -m 0755 "$bootstrap" "$fixture/payload/bootstrap/ochenstarik-server-monitor-manager.sh"
|
||||
tar -C "$fixture/payload" -czf "$fixture/release.tar.gz" agent control provisioning-helper deploy bootstrap
|
||||
sha256sum "$fixture/release.tar.gz" >"$fixture/release.tar.gz.sha256"
|
||||
bash "$bootstrap" verify-release "$fixture/release.tar.gz" >/dev/null
|
||||
|
||||
printf '%064d %s\n' 0 release.tar.gz >"$fixture/release.tar.gz.sha256"
|
||||
if bash "$bootstrap" verify-release "$fixture/release.tar.gz" >/dev/null 2>&1; then
|
||||
printf '%s\n' "corrupt release checksum unexpectedly succeeded" >&2
|
||||
exit 1
|
||||
# Create a signed manifest for verify-release (required by hardened verify_archive)
|
||||
release_hash="$(sha256sum "$fixture/release.tar.gz" | awk '{ print $1 }')"
|
||||
cat >"$fixture/server-monitor-manager-manifest.json" <<MEOF
|
||||
{
|
||||
"version": "v0.0.0-test",
|
||||
"hashes": {
|
||||
"release.tar.gz": "$release_hash"
|
||||
}
|
||||
}
|
||||
MEOF
|
||||
|
||||
if command -v cosign &>/dev/null; then
|
||||
COSIGN_PASSWORD="" cosign generate-key-pair --output-key-prefix="$fixture/contract-test"
|
||||
COSIGN_PASSWORD="" cosign sign-blob --yes --key "$fixture/contract-test.key" \
|
||||
--output-signature "$fixture/server-monitor-manager-manifest.sig" \
|
||||
"$fixture/server-monitor-manager-manifest.json"
|
||||
SMM_TEST_PUBKEY="$fixture/contract-test.pub" \
|
||||
bash "$bootstrap" verify-release "$fixture/release.tar.gz" >/dev/null
|
||||
else
|
||||
# Cosign unavailable — fall back to SMM_ALLOW_UNSIGNED for contract test only
|
||||
SMM_ALLOW_UNSIGNED=1 bash "$bootstrap" verify-release "$fixture/release.tar.gz" >/dev/null
|
||||
fi
|
||||
|
||||
# Corrupt the manifest hash and verify rejection
|
||||
cat >"$fixture/server-monitor-manager-manifest.json" <<MEOF
|
||||
{
|
||||
"version": "v0.0.0-test",
|
||||
"hashes": {
|
||||
"release.tar.gz": "$(printf '%064d' 0)"
|
||||
}
|
||||
}
|
||||
MEOF
|
||||
if command -v cosign &>/dev/null; then
|
||||
COSIGN_PASSWORD="" cosign sign-blob --yes --key "$fixture/contract-test.key" \
|
||||
--output-signature "$fixture/server-monitor-manager-manifest.sig" \
|
||||
"$fixture/server-monitor-manager-manifest.json"
|
||||
if SMM_TEST_PUBKEY="$fixture/contract-test.pub" \
|
||||
bash "$bootstrap" verify-release "$fixture/release.tar.gz" >/dev/null 2>&1; then
|
||||
printf '%s\n' "corrupt release checksum unexpectedly succeeded" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
printf '%064d %s\n' 0 release.tar.gz >"$fixture/release.tar.gz.sha256"
|
||||
if SMM_ALLOW_UNSIGNED=1 bash "$bootstrap" verify-release "$fixture/release.tar.gz" >/dev/null 2>&1; then
|
||||
printf '%s\n' "corrupt release checksum unexpectedly succeeded" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '%s\n' "BOOTSTRAP_CONTRACT=PASS"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
CLEANUP_FILES=()
|
||||
cleanup() {
|
||||
rm -f "${CLEANUP_FILES[@]}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Running negative tests for manifest verification..."
|
||||
|
||||
if ! command -v cosign &> /dev/null; then
|
||||
|
|
@ -12,12 +18,15 @@ fi
|
|||
export COSIGN_PASSWORD=""
|
||||
cosign generate-key-pair
|
||||
export SMM_TEST_PUBKEY="cosign.pub"
|
||||
CLEANUP_FILES+=(cosign.key cosign.pub)
|
||||
|
||||
ARCHIVE_NAME="test-archive.tar.gz"
|
||||
echo "archive content" > "$ARCHIVE_NAME"
|
||||
ARCHIVE_HASH=$(sha256sum "$ARCHIVE_NAME" | awk '{print $1}')
|
||||
CLEANUP_FILES+=("$ARCHIVE_NAME")
|
||||
|
||||
cat <<EOF > manifest.json
|
||||
# Use the canonical manifest name that verify_archive() expects
|
||||
cat <<EOF > server-monitor-manager-manifest.json
|
||||
{
|
||||
"hashes": {
|
||||
"$ARCHIVE_NAME": "$ARCHIVE_HASH"
|
||||
|
|
@ -25,10 +34,11 @@ cat <<EOF > manifest.json
|
|||
}
|
||||
EOF
|
||||
|
||||
cosign sign-blob --yes --key cosign.key --output-signature manifest.sig manifest.json
|
||||
cosign sign-blob --yes --key cosign.key --output-signature server-monitor-manager-manifest.sig server-monitor-manager-manifest.json
|
||||
CLEANUP_FILES+=(server-monitor-manager-manifest.json server-monitor-manager-manifest.sig)
|
||||
|
||||
echo "Test 1: Valid signature and hash"
|
||||
if ! bash tests/bootstrap/verify-manifest.sh "$ARCHIVE_NAME" manifest.json manifest.sig; then
|
||||
if ! bash deploy/ochenstarik-server-monitor-manager.sh verify-manifest server-monitor-manager-manifest.json server-monitor-manager-manifest.sig; then
|
||||
echo "FAIL: Valid payload rejected"
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -36,7 +46,9 @@ echo "PASS: Valid payload accepted"
|
|||
|
||||
echo "Test 2: Altered byte in archive"
|
||||
echo "altered content" > "$ARCHIVE_NAME"
|
||||
if bash tests/bootstrap/verify-manifest.sh "$ARCHIVE_NAME" manifest.json manifest.sig >/dev/null 2>&1; then
|
||||
# verify-release will call verify_archive → verify_manifest (signature check) then sha256 (hash check).
|
||||
# The manifest was signed with the original hash, so the archive hash won't match.
|
||||
if bash deploy/ochenstarik-server-monitor-manager.sh verify-release "$ARCHIVE_NAME" >/dev/null 2>&1; then
|
||||
echo "FAIL: Altered archive accepted"
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -45,27 +57,42 @@ echo "PASS: Altered archive rejected"
|
|||
echo "Test 3: Substituted hash in manifest without resigning"
|
||||
# Restore archive
|
||||
echo "archive content" > "$ARCHIVE_NAME"
|
||||
# Corrupt manifest
|
||||
cat <<EOF > manifest.json
|
||||
# Corrupt manifest (but don't re-sign — signature should now be invalid)
|
||||
cat <<EOF > server-monitor-manager-manifest.json
|
||||
{
|
||||
"hashes": {
|
||||
"$ARCHIVE_NAME": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
if bash tests/bootstrap/verify-manifest.sh "$ARCHIVE_NAME" manifest.json manifest.sig >/dev/null 2>&1; then
|
||||
if bash deploy/ochenstarik-server-monitor-manager.sh verify-manifest server-monitor-manager-manifest.json server-monitor-manager-manifest.sig >/dev/null 2>&1; then
|
||||
echo "FAIL: Substituted hash accepted"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: Substituted hash rejected"
|
||||
|
||||
echo "Test 4: Manifest without signature"
|
||||
if bash tests/bootstrap/verify-manifest.sh "$ARCHIVE_NAME" manifest.json "" >/dev/null 2>&1; then
|
||||
if bash deploy/ochenstarik-server-monitor-manager.sh verify-manifest server-monitor-manager-manifest.json "" >/dev/null 2>&1; then
|
||||
echo "FAIL: Missing signature accepted"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: Missing signature rejected"
|
||||
|
||||
echo "All tests passed."
|
||||
echo "Test 5: Real alpha.8 manifest fallback matching (REQUIRES_NETWORK)"
|
||||
ALPHA8_ARCHIVE="ochenstarik-server-monitor-manager-linux-x64.tar.gz"
|
||||
if ! wget -qO "$ALPHA8_ARCHIVE" https://github.com/ochenstarik-ui/server-monitor-manager/releases/download/v0.1.0-alpha.8/server-monitor-manager-linux-x64.tar.gz; then
|
||||
echo "SKIP: Could not download alpha.8 archive (network unavailable)"
|
||||
else
|
||||
wget -qO server-monitor-manager-manifest.json https://github.com/ochenstarik-ui/server-monitor-manager/releases/download/v0.1.0-alpha.8/server-monitor-manager-manifest.json
|
||||
wget -qO server-monitor-manager-manifest.sig https://github.com/ochenstarik-ui/server-monitor-manager/releases/download/v0.1.0-alpha.8/server-monitor-manager-manifest.sig
|
||||
CLEANUP_FILES+=("$ALPHA8_ARCHIVE")
|
||||
# Use keyless verification against real Sigstore/Rekor (requires network)
|
||||
unset SMM_TEST_PUBKEY
|
||||
if ! bash deploy/ochenstarik-server-monitor-manager.sh verify-release "$ALPHA8_ARCHIVE" >/dev/null 2>&1; then
|
||||
echo "FAIL: Alpha.8 real release verification failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: Alpha.8 real release verification succeeded"
|
||||
fi
|
||||
|
||||
rm -f cosign.key cosign.pub manifest.json manifest.sig "$ARCHIVE_NAME"
|
||||
echo "All tests passed."
|
||||
|
|
|
|||
Loading…
Reference in a new issue