using System.Diagnostics; using System.Net; using System.Net.Http.Headers; using System.Reflection; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text.Json; using System.Text.Json.Serialization; using KronaStaffTerminal.Models; namespace KronaStaffTerminal.Services; public sealed record AvailableUpdate( Version Version, string DisplayVersion, string ReleaseName, string ReleaseNotes, Uri ReleasePage, Uri InstallerUrl, Uri ChecksumUrl, string InstallerFileName); public interface IUpdateSource { Task FindUpdateAsync(TerminalSettings settings, CancellationToken cancellationToken); Task DownloadAndVerifyAsync( AvailableUpdate update, TerminalSettings settings, IProgress? progress, CancellationToken cancellationToken); } public sealed class ForgejoUpdateSource : IUpdateSource { private const string InstallerPrefix = "KronaStaffTerminal-Setup-"; private static readonly HttpClient HttpClient = CreateHttpClient(); public async Task FindUpdateAsync( TerminalSettings settings, CancellationToken cancellationToken) { if (!Uri.TryCreate(settings.UpdateFeedUrl, UriKind.Absolute, out var feedUri) || !IsSecureUpdateUri(feedUri)) { throw new InvalidOperationException( "Адрес обновлений должен быть защищённым HTTPS-адресом Forgejo."); } using var request = CreateRequest(HttpMethod.Get, feedUri, settings.UpdateAccessToken, feedUri); using var response = await HttpClient.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden or HttpStatusCode.NotFound) { throw new InvalidOperationException( "Нет доступа к обновлениям. Укажите токен чтения приватного репозитория в настройках."); } response.EnsureSuccessStatusCode(); await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); var release = await JsonSerializer.DeserializeAsync( stream, cancellationToken: cancellationToken) ?? throw new InvalidDataException("Forgejo вернул пустое описание релиза."); if (!TryParseVersion(release.TagName, out var releaseVersion) || releaseVersion <= CurrentVersion) { return null; } var installer = release.Assets.FirstOrDefault(asset => asset.Name.StartsWith(InstallerPrefix, StringComparison.OrdinalIgnoreCase) && asset.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)); var checksum = release.Assets.FirstOrDefault(asset => string.Equals(asset.Name, installer?.Name + ".sha256", StringComparison.OrdinalIgnoreCase)); if (installer is null || checksum is null || !Uri.TryCreate(installer.BrowserDownloadUrl, UriKind.Absolute, out var installerUri) || !Uri.TryCreate(checksum.BrowserDownloadUrl, UriKind.Absolute, out var checksumUri) || !Uri.TryCreate(release.HtmlUrl, UriKind.Absolute, out var releasePage) || !IsSameUpdateOrigin(feedUri, installerUri) || !IsSameUpdateOrigin(feedUri, checksumUri) || !IsSameUpdateOrigin(feedUri, releasePage)) { throw new InvalidDataException( "В релизе нет установщика Windows или файла контрольной суммы."); } return new AvailableUpdate( releaseVersion, releaseVersion.ToString(3), string.IsNullOrWhiteSpace(release.Name) ? $"Krona Staff {releaseVersion.ToString(3)}" : release.Name, release.Body ?? "", releasePage, installerUri, checksumUri, installer.Name); } public async Task DownloadAndVerifyAsync( AvailableUpdate update, TerminalSettings settings, IProgress? progress, CancellationToken cancellationToken) { if (!Uri.TryCreate(settings.UpdateFeedUrl, UriKind.Absolute, out var feedUri) || !IsSecureUpdateUri(feedUri)) { throw new InvalidOperationException("Адрес обновлений должен быть защищённым HTTPS-адресом Forgejo."); } var updateDirectory = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Krona", "StaffTerminal", "updates", update.DisplayVersion); Directory.CreateDirectory(updateDirectory); var installerPath = Path.Combine( updateDirectory, Path.GetFileName(update.InstallerFileName)); var temporaryPath = installerPath + ".download"; using var checksumRequest = CreateRequest( HttpMethod.Get, update.ChecksumUrl, settings.UpdateAccessToken, feedUri); using var checksumResponse = await HttpClient.SendAsync( checksumRequest, cancellationToken); checksumResponse.EnsureSuccessStatusCode(); var checksumText = await checksumResponse.Content.ReadAsStringAsync(cancellationToken); var expectedHash = checksumText .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) .FirstOrDefault(); if (expectedHash is null || expectedHash.Length != 64 || expectedHash.Any(character => !Uri.IsHexDigit(character))) { throw new InvalidDataException( "Файл контрольной суммы обновления повреждён."); } using var installerRequest = CreateRequest( HttpMethod.Get, update.InstallerUrl, settings.UpdateAccessToken, feedUri); using var installerResponse = await HttpClient.SendAsync( installerRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken); installerResponse.EnsureSuccessStatusCode(); var totalBytes = installerResponse.Content.Headers.ContentLength; await using (var source = await installerResponse.Content.ReadAsStreamAsync(cancellationToken)) await using (var destination = new FileStream( temporaryPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous)) { var buffer = new byte[81920]; long received = 0; int count; while ((count = await source.ReadAsync(buffer, cancellationToken)) > 0) { await destination.WriteAsync(buffer.AsMemory(0, count), cancellationToken); received += count; if (totalBytes is > 0) { progress?.Report((double)received / totalBytes.Value); } } await destination.FlushAsync(cancellationToken); } await using var downloaded = File.OpenRead(temporaryPath); var actualHash = Convert.ToHexString( await SHA256.HashDataAsync(downloaded, cancellationToken)); if (!CryptographicOperations.FixedTimeEquals( Convert.FromHexString(expectedHash), Convert.FromHexString(actualHash))) { File.Delete(temporaryPath); throw new InvalidDataException( "Контрольная сумма обновления не совпала. Установка отменена."); } VerifyInstallerPublisher(temporaryPath, settings.UpdateTrustedSignerThumbprint); File.Move(temporaryPath, installerPath, true); return installerPath; } public static Version CurrentVersion => Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0); public static void LaunchInstaller(string installerPath) => Process.Start(new ProcessStartInfo { FileName = installerPath, UseShellExecute = true }); private static HttpRequestMessage CreateRequest( HttpMethod method, Uri uri, string token, Uri trustedOrigin) { if (!IsSameUpdateOrigin(trustedOrigin, uri)) { throw new InvalidDataException("Forgejo вернул файл обновления с недоверенного адреса."); } var request = new HttpRequestMessage(method, uri); if (!string.IsNullOrWhiteSpace(token)) { request.Headers.Authorization = new AuthenticationHeaderValue("token", token.Trim()); } return request; } private static HttpClient CreateHttpClient() { var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = TimeSpan.FromMinutes(10) }; client.DefaultRequestHeaders.UserAgent.ParseAdd("KronaStaffTerminal/0.2"); return client; } internal static bool IsSameUpdateOrigin(Uri trustedOrigin, Uri candidate) => IsSecureUpdateUri(trustedOrigin) && IsSecureUpdateUri(candidate) && string.Equals(trustedOrigin.IdnHost, candidate.IdnHost, StringComparison.OrdinalIgnoreCase) && trustedOrigin.Port == candidate.Port; private static bool IsSecureUpdateUri(Uri uri) => uri.Scheme == Uri.UriSchemeHttps && string.IsNullOrEmpty(uri.UserInfo) && !string.IsNullOrWhiteSpace(uri.IdnHost); private static void VerifyInstallerPublisher(string installerPath, string configuredThumbprint) { var expectedThumbprint = NormalizeThumbprint(configuredThumbprint); if (expectedThumbprint.Length == 0) { throw new InvalidOperationException( "Автоматическая установка заблокирована: не настроен отпечаток сертификата издателя обновлений."); } if (!AuthenticodeTrust.IsTrusted(installerPath)) { throw new InvalidDataException( "Цифровая подпись установщика отсутствует, повреждена или не доверена Windows."); } #pragma warning disable SYSLIB0057 using var certificate = new X509Certificate2(X509Certificate.CreateFromSignedFile(installerPath)); #pragma warning restore SYSLIB0057 var actualThumbprint = NormalizeThumbprint(certificate.Thumbprint ?? ""); if (!CryptographicOperations.FixedTimeEquals( Convert.FromHexString(expectedThumbprint), Convert.FromHexString(actualThumbprint))) { throw new InvalidDataException("Установщик подписан неизвестным издателем. Установка отменена."); } } private static string NormalizeThumbprint(string value) { var normalized = new string(value.Where(Uri.IsHexDigit).ToArray()).ToUpperInvariant(); return normalized.Length is 40 or 64 ? normalized : ""; } private static bool TryParseVersion(string? value, out Version version) { var normalized = (value ?? "") .Trim() .TrimStart('v', 'V') .Split('-', '+')[0]; if (Version.TryParse(normalized, out var parsed)) { version = parsed; return true; } version = new Version(0, 0, 0); return false; } private sealed class ForgejoRelease { [JsonPropertyName("tag_name")] public string TagName { get; set; } = ""; [JsonPropertyName("name")] public string Name { get; set; } = ""; [JsonPropertyName("body")] public string? Body { get; set; } [JsonPropertyName("html_url")] public string HtmlUrl { get; set; } = ""; [JsonPropertyName("assets")] public List Assets { get; set; } = []; } private sealed class ForgejoAsset { [JsonPropertyName("name")] public string Name { get; set; } = ""; [JsonPropertyName("browser_download_url")] public string BrowserDownloadUrl { get; set; } = ""; } private static class AuthenticodeTrust { private static readonly Guid VerifyAction = new("00AAC56B-CD44-11d0-8CC2-00C04FC295EE"); public static bool IsTrusted(string path) { using var fileInfo = new WinTrustFileInfo(path); var fileInfoPointer = Marshal.AllocHGlobal(Marshal.SizeOf()); var trustDataPointer = IntPtr.Zero; try { Marshal.StructureToPtr(fileInfo, fileInfoPointer, false); var trustData = new WinTrustData(fileInfoPointer); trustDataPointer = Marshal.AllocHGlobal(Marshal.SizeOf()); Marshal.StructureToPtr(trustData, trustDataPointer, false); return WinVerifyTrust(new IntPtr(-1), VerifyAction, trustDataPointer) == 0; } finally { if (trustDataPointer != IntPtr.Zero) Marshal.FreeHGlobal(trustDataPointer); Marshal.FreeHGlobal(fileInfoPointer); } } [DllImport("wintrust.dll", ExactSpelling = true, CharSet = CharSet.Unicode)] private static extern int WinVerifyTrust( IntPtr windowHandle, [MarshalAs(UnmanagedType.LPStruct)] Guid actionId, IntPtr trustData); [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] private sealed class WinTrustFileInfo : IDisposable { public uint StructSize = (uint)Marshal.SizeOf(); public IntPtr FilePath; public IntPtr FileHandle = IntPtr.Zero; public IntPtr KnownSubject = IntPtr.Zero; public WinTrustFileInfo(string path) => FilePath = Marshal.StringToCoTaskMemUni(path); public void Dispose() => Marshal.FreeCoTaskMem(FilePath); } [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] private sealed class WinTrustData { public uint StructSize = (uint)Marshal.SizeOf(); public IntPtr PolicyCallbackData = IntPtr.Zero; public IntPtr SipClientData = IntPtr.Zero; public uint UiChoice = 2; public uint RevocationChecks = 1; public uint UnionChoice = 1; public IntPtr FileInfo; public uint StateAction = 0; public IntPtr StateData = IntPtr.Zero; public IntPtr UrlReference = IntPtr.Zero; public uint ProviderFlags = 0; public uint UiContext = 0; public WinTrustData(IntPtr fileInfo) => FileInfo = fileInfo; } } }