390 lines
15 KiB
C#
390 lines
15 KiB
C#
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<AvailableUpdate?> FindUpdateAsync(TerminalSettings settings, CancellationToken cancellationToken);
|
||
Task<string> DownloadAndVerifyAsync(
|
||
AvailableUpdate update,
|
||
TerminalSettings settings,
|
||
IProgress<double>? progress,
|
||
CancellationToken cancellationToken);
|
||
}
|
||
|
||
public sealed class ForgejoUpdateSource : IUpdateSource
|
||
{
|
||
private const string InstallerPrefix = "KronaStaffTerminal-Setup-";
|
||
private static readonly HttpClient HttpClient = CreateHttpClient();
|
||
|
||
public async Task<AvailableUpdate?> 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<ForgejoRelease>(
|
||
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<string> DownloadAndVerifyAsync(
|
||
AvailableUpdate update,
|
||
TerminalSettings settings,
|
||
IProgress<double>? 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<ForgejoAsset> 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<WinTrustFileInfo>());
|
||
var trustDataPointer = IntPtr.Zero;
|
||
try
|
||
{
|
||
Marshal.StructureToPtr(fileInfo, fileInfoPointer, false);
|
||
var trustData = new WinTrustData(fileInfoPointer);
|
||
trustDataPointer = Marshal.AllocHGlobal(Marshal.SizeOf<WinTrustData>());
|
||
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<WinTrustFileInfo>();
|
||
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<WinTrustData>();
|
||
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;
|
||
}
|
||
}
|
||
}
|