using System.Net; using System.Net.Http.Headers; using System.Text.Json; using System.Text.RegularExpressions; using System.Net.Sockets; using System.Security.Cryptography.X509Certificates; namespace QSafe.Core; public sealed class Connection { public string Url { get; set; } = ""; public string HomeWifiSsid { get; set; } = ""; public string CertificateSha256 { get; set; } = ""; public string Token { get; set; } = ""; public string Etag { get; set; } = ""; public DateTimeOffset? LastSync { get; set; } public bool AutoSync { get; set; } = true; public void Validate() { LanPolicy.Target(Url); Url = new Uri(Url.Trim()).AbsoluteUri.TrimEnd('/') + "/"; if (string.IsNullOrWhiteSpace(HomeWifiSsid) || System.Text.Encoding.UTF8.GetByteCount(HomeWifiSsid) > 32) throw new InvalidDataException("Укажите точное имя домашнего Wi-Fi (SSID), до 32 байт."); if (CertificateSha256 == null || !Regex.IsMatch(CertificateSha256, "\\A[0-9a-fA-F]{64}\\z")) throw new InvalidDataException("Нужен SHA-256 локального сертификата Pi из личного файла подключения."); if (Token == null || !Regex.IsMatch(Token, "\\A[A-Za-z0-9_-]{32,128}\\z")) throw new InvalidDataException("Неверный ключ подключения."); } public static Connection Parse(byte[] bytes) { if (bytes.Length > 65536) throw new InvalidDataException("Слишком большой файл подключения."); var c = JsonSerializer.Deserialize(bytes, Vault.Json) ?? throw new InvalidDataException("Пустой файл подключения."); c.Validate(); c.Etag = ""; c.LastSync = null; return c; } } public sealed record SyncResult(VaultData Data, byte[] Envelope, int Conflicts); public sealed class SyncClient : IDisposable { private readonly HttpClient http; private readonly Connection connection; private readonly ILanNetwork network; public SyncClient(Connection config, ILanNetwork network, HttpMessageHandler? handler = null) { config.Validate(); connection = config; this.network = network; http = new HttpClient(handler ?? CreateHandler()) { Timeout = TimeSpan.FromSeconds(40) }; } private LanBinding RequireHome() { connection.Validate(); var binding = network.Current(connection); LanPolicy.Check(connection, binding); return binding; } private SocketsHttpHandler CreateHandler() { var handler = new SocketsHttpHandler { AllowAutoRedirect = false, UseProxy = false, PooledConnectionLifetime = TimeSpan.Zero, ConnectTimeout = TimeSpan.FromSeconds(10) }; handler.SslOptions.CertificateRevocationCheckMode = X509RevocationMode.NoCheck; handler.SslOptions.CertificateChainPolicy = new X509ChainPolicy { DisableCertificateDownloads = true, RevocationMode = X509RevocationMode.NoCheck }; handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, _, errors) => LanPolicy.CertificateMatches(certificate, errors, connection.CertificateSha256); handler.ConnectCallback = async (context, ct) => { var binding = RequireHome(); var ip = LanPolicy.Target(connection.Url); var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); try { const int IpUnicastIf = 31; // Windows ws2ipdef.h IP_UNICAST_IF; index is in network byte order. socket.SetSocketOption(SocketOptionLevel.IP, (SocketOptionName)IpUnicastIf, IPAddress.HostToNetworkOrder(binding.InterfaceIndex)); socket.Bind(new IPEndPoint(binding.Address, 0)); await socket.ConnectAsync(new IPEndPoint(ip, context.DnsEndPoint.Port), ct); return new NetworkStream(socket, ownsSocket: true); } catch { socket.Dispose(); throw; } }; return handler; } public static async Task Bounded(Stream stream, int limit, CancellationToken ct = default) { using var output = new MemoryStream(); var buffer = new byte[8192]; int n; while ((n = await stream.ReadAsync(buffer, ct)) > 0) { if (output.Length + n > limit) throw new InvalidDataException("Файл превышает допустимый размер."); await output.WriteAsync(buffer.AsMemory(0, n), ct); } return output.ToArray(); } private async Task<(byte[]? Blob, string Etag)> Request(HttpMethod method, byte[]? blob, string? etag, CancellationToken ct) { RequireHome(); // Applies to every manual, automatic, restore, GET and PUT request before any transport access. using var request = new HttpRequestMessage(method, connection.Url + "v1/vault"); request.Headers.ConnectionClose = true; request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", connection.Token); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/octet-stream")); if (blob != null) { request.Content = new ByteArrayContent(blob); request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); request.Headers.TryAddWithoutValidation(etag == null ? "If-None-Match" : "If-Match", etag == null ? "*" : '"' + etag + '"'); } using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) throw new IOException("Сервер отклонил ключ подключения."); if (response.StatusCode == HttpStatusCode.PreconditionFailed) throw new SyncConflictException(); if (method == HttpMethod.Get && response.StatusCode == HttpStatusCode.NotFound) return (null, ""); if (response.StatusCode != HttpStatusCode.OK) throw new IOException($"Сервер вернул HTTP {(int)response.StatusCode}."); var hash = response.Headers.ETag?.Tag.Trim('"') ?? ""; if (method != HttpMethod.Get) { if (hash != VaultCrypto.Hash(blob!)) throw new IOException("Сервер не подтвердил контрольную сумму записи."); return (null, hash); } if (response.Content.Headers.ContentLength > VaultCrypto.MaxBytes) throw new InvalidDataException("Ответ сервера слишком большой."); await using var stream = await response.Content.ReadAsStreamAsync(ct); var bytes = await Bounded(stream, VaultCrypto.MaxBytes, ct); VaultCrypto.Validate(bytes); if (hash != VaultCrypto.Hash(bytes)) throw new InvalidDataException("Не совпадает контрольная сумма ответа сервера."); return (bytes, hash); } public async Task Download(CancellationToken ct = default) => (await Request(HttpMethod.Get, null, null, ct)).Blob ?? throw new IOException("На сервере пока нет хранилища."); public async Task Sync(byte[] local, byte[]? ancestor, VaultKey key, CancellationToken ct = default) { for (int attempt = 0; ; attempt++) { try { return await SyncOnce(local, ancestor, key, ct); } catch (SyncConflictException) when (attempt < 2) { } } } private async Task SyncOnce(byte[] local, byte[]? ancestor, VaultKey key, CancellationToken ct) { var remote = await Request(HttpMethod.Get, null, null, ct); var localData = Vault.Read(local, key); var localHash = VaultCrypto.Hash(local); if (remote.Blob == null) { if (connection.Etag.Length > 0 || ancestor != null) throw new IOException("Хранилище исчезло с сервера. Проверьте сервер и резервные копии."); await Request(HttpMethod.Put, local, null, ct); return new(localData, local, 0); } if (remote.Etag == localHash) return new(localData, local, 0); var remoteData = Vault.Read(remote.Blob, key); if (localData.Id != remoteData.Id) throw new InvalidDataException("На сервере другое хранилище. Сначала экспортируйте локальную копию."); if (remote.Etag == connection.Etag) { await Request(HttpMethod.Put, local, remote.Etag, ct); return new(localData, local, 0); } if (ancestor != null && VaultCrypto.Hash(ancestor) == localHash) return new(remoteData, remote.Blob, 0); var merged = Vault.Merge(ancestor == null ? null : Vault.Read(ancestor, key), localData, remoteData); var next = Vault.Encrypt(merged.Data, key); await Request(HttpMethod.Put, next, remote.Etag, ct); return new(merged.Data, next, merged.Conflicts); } public void Dispose() => http.Dispose(); } public sealed class SyncConflictException() : IOException("База на сервере меняется. Повторите синхронизацию.");