155 lines
9.1 KiB
C#
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("База на сервере меняется. Повторите синхронизацию.");
|