Files
QSafe/windows/QSafe.Core/SyncClient.cs
T

155 lines
9.1 KiB
C#

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<Connection>(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<byte[]> 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<byte[]> Download(CancellationToken ct = default) => (await Request(HttpMethod.Get, null, null, ct)).Blob ?? throw new IOException("На сервере пока нет хранилища.");
public async Task<SyncResult> 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<SyncResult> 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("База на сервере меняется. Повторите синхронизацию.");