diff --git a/src/Core/Net/CertificatePinStore.cs b/src/Core/Net/CertificatePinStore.cs new file mode 100644 index 0000000..89ebdf6 --- /dev/null +++ b/src/Core/Net/CertificatePinStore.cs @@ -0,0 +1,72 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; + +namespace PalladiumWallet.Core.Net; + +/// +/// Pinning TLS "trust on first use" (blueprint §9): al primo contatto la +/// fingerprint SHA-256 del certificato del server viene salvata; ai successivi +/// viene confrontata. Se cambia, la connessione va rifiutata e l'utente può +/// sbloccare con un reset esplicito (caso tipico: server self-signed rinnovato). +/// +public sealed class CertificatePinStore(string filePath) +{ + private readonly object _lock = new(); + + public static string Fingerprint(X509Certificate certificate) => + Convert.ToHexString(SHA256.HashData(certificate.GetRawCertData())).ToLowerInvariant(); + + /// + /// Verifica TOFU: true se il certificato è quello già visto (o se è il primo + /// contatto, nel qual caso viene salvato). False = certificato cambiato. + /// + public bool VerifyOrPin(string host, int port, X509Certificate certificate) + { + var key = $"{host}:{port}"; + var fingerprint = Fingerprint(certificate); + lock (_lock) + { + var pins = Load(); + if (pins.TryGetValue(key, out var pinned)) + return pinned == fingerprint; + pins[key] = fingerprint; + Save(pins); + return true; + } + } + + /// Reset del certificato salvato per un server ("reset certificati SSL", §9). + public bool Reset(string host, int port) + { + lock (_lock) + { + var pins = Load(); + if (!pins.Remove($"{host}:{port}")) + return false; + Save(pins); + return true; + } + } + + public void ResetAll() + { + lock (_lock) + { + if (File.Exists(filePath)) + File.Delete(filePath); + } + } + + private Dictionary Load() => + File.Exists(filePath) + ? JsonSerializer.Deserialize>(File.ReadAllText(filePath)) ?? [] + : []; + + private void Save(Dictionary pins) + { + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllText(filePath, JsonSerializer.Serialize(pins, + new JsonSerializerOptions { WriteIndented = true })); + } +} diff --git a/src/Core/Net/ElectrumApi.cs b/src/Core/Net/ElectrumApi.cs new file mode 100644 index 0000000..9098696 --- /dev/null +++ b/src/Core/Net/ElectrumApi.cs @@ -0,0 +1,111 @@ +using System.Text.Json; + +namespace PalladiumWallet.Core.Net; + +/// Voce di storico di uno scripthash (blockchain.scripthash.get_history). +public readonly record struct HistoryItem(string TxHash, int Height, long FeeSats); + +/// UTXO riportato dal server (blockchain.scripthash.listunspent). +public readonly record struct UnspentItem(string TxHash, int TxPos, long ValueSats, int Height); + +/// Prova di Merkle (blockchain.transaction.get_merkle). +public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList Merkle); + +/// Tip della catena notificato da blockchain.headers.subscribe. +public readonly record struct ChainTip(int Height, string HeaderHex); + +/// +/// Helper tipizzati sui metodi del protocollo (blueprint §10), sopra il +/// trasporto JSON-RPC generico di . +/// +public static class ElectrumApi +{ + public static async Task SubscribeHeadersAsync(this ElectrumClient c, CancellationToken ct = default) + { + var r = await c.RequestAsync("blockchain.headers.subscribe", ct); + return new ChainTip(r.GetProperty("height").GetInt32(), r.GetProperty("hex").GetString()!); + } + + /// Status corrente dello scripthash (null = mai usato). + public static async Task SubscribeScripthashAsync(this ElectrumClient c, string scripthash, + CancellationToken ct = default) + { + var r = await c.RequestAsync("blockchain.scripthash.subscribe", ct, scripthash); + return r.ValueKind == JsonValueKind.Null ? null : r.GetString(); + } + + public static async Task> GetHistoryAsync(this ElectrumClient c, + string scripthash, CancellationToken ct = default) + { + var r = await c.RequestAsync("blockchain.scripthash.get_history", ct, scripthash); + return [.. r.EnumerateArray().Select(e => new HistoryItem( + e.GetProperty("tx_hash").GetString()!, + e.GetProperty("height").GetInt32(), + e.TryGetProperty("fee", out var fee) ? fee.GetInt64() : 0))]; + } + + public static async Task> ListUnspentAsync(this ElectrumClient c, + string scripthash, CancellationToken ct = default) + { + var r = await c.RequestAsync("blockchain.scripthash.listunspent", ct, scripthash); + return [.. r.EnumerateArray().Select(e => new UnspentItem( + e.GetProperty("tx_hash").GetString()!, + e.GetProperty("tx_pos").GetInt32(), + e.GetProperty("value").GetInt64(), + e.GetProperty("height").GetInt32()))]; + } + + public static async Task GetTransactionAsync(this ElectrumClient c, string txid, + CancellationToken ct = default) + { + var r = await c.RequestAsync("blockchain.transaction.get", ct, txid); + return r.GetString()!; + } + + public static async Task GetMerkleAsync(this ElectrumClient c, string txid, + int height, CancellationToken ct = default) + { + var r = await c.RequestAsync("blockchain.transaction.get_merkle", ct, txid, height); + return new MerkleProofResponse( + r.GetProperty("block_height").GetInt32(), + r.GetProperty("pos").GetInt32(), + [.. r.GetProperty("merkle").EnumerateArray().Select(m => m.GetString()!)]); + } + + public static async Task GetBlockHeaderAsync(this ElectrumClient c, int height, + CancellationToken ct = default) + { + var r = await c.RequestAsync("blockchain.block.header", ct, height); + return r.GetString()!; + } + + public static async Task BroadcastAsync(this ElectrumClient c, string rawTxHex, + CancellationToken ct = default) + { + var r = await c.RequestAsync("blockchain.transaction.broadcast", ct, rawTxHex); + return r.GetString()!; + } + + /// Fee stimata in coin/kB per il target dato; -1 se il server non sa stimare. + public static async Task EstimateFeeAsync(this ElectrumClient c, int targetBlocks, + CancellationToken ct = default) + { + var r = await c.RequestAsync("blockchain.estimatefee", ct, targetBlocks); + return r.GetDecimal(); + } + + public static async Task RelayFeeAsync(this ElectrumClient c, CancellationToken ct = default) + { + var r = await c.RequestAsync("blockchain.relayfee", ct); + return r.GetDecimal(); + } + + public static async Task BannerAsync(this ElectrumClient c, CancellationToken ct = default) + { + var r = await c.RequestAsync("server.banner", ct); + return r.GetString()!; + } + + public static Task PingAsync(this ElectrumClient c, CancellationToken ct = default) => + c.RequestAsync("server.ping", ct); +} diff --git a/src/Core/Net/ElectrumClient.cs b/src/Core/Net/ElectrumClient.cs new file mode 100644 index 0000000..674e88b --- /dev/null +++ b/src/Core/Net/ElectrumClient.cs @@ -0,0 +1,196 @@ +using System.Collections.Concurrent; +using System.Net.Security; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; + +namespace PalladiumWallet.Core.Net; + +/// +/// Client del protocollo del server di indicizzazione (blueprint §10): +/// JSON-RPC 2.0 newline-delimited su TCP, opzionalmente TLS con pinning TOFU. +/// Le notifiche (subscription) arrivano sull'evento . +/// +public sealed class ElectrumClient : IAsyncDisposable +{ + public const string ClientName = "PalladiumWallet"; + public const string ProtocolVersion = "1.4"; + + private readonly TcpClient _tcp; + private readonly Stream _stream; + private readonly SemaphoreSlim _writeLock = new(1, 1); + private readonly ConcurrentDictionary> _pending = new(); + private readonly CancellationTokenSource _cts = new(); + private readonly Task _readLoop; + private long _nextId; + + public string Host { get; } + public int Port { get; } + public bool UseSsl { get; } + public bool IsConnected => _tcp.Connected && !_cts.IsCancellationRequested; + + /// (metodo, parametri) per le notifiche di subscription. + public event Action? NotificationReceived; + + /// Scatta quando la connessione cade (errore di lettura o chiusura remota). + public event Action? Disconnected; + + private ElectrumClient(TcpClient tcp, Stream stream, string host, int port, bool useSsl) + { + _tcp = tcp; + _stream = stream; + Host = host; + Port = port; + UseSsl = useSsl; + _readLoop = Task.Run(ReadLoopAsync); + } + + /// + /// Connette al server. Con la validazione del + /// certificato è TOFU tramite (§9): primo contatto + /// salva, contatti successivi confrontano; mismatch ⇒ . + /// + public static async Task ConnectAsync(string host, int port, bool useSsl, + CertificatePinStore? pins = null, CancellationToken ct = default) + { + var tcp = new TcpClient(); + try + { + await tcp.ConnectAsync(host, port, ct); + Stream stream = tcp.GetStream(); + if (useSsl) + { + var pinOk = true; + var ssl = new SslStream(stream, leaveInnerStreamOpen: false, + (_, cert, _, _) => + { + // I server Electrum sono tipicamente self-signed: la + // fiducia è il pin TOFU, non la catena CA (§9). + if (cert is null) + return false; + pinOk = pins is null || pins.VerifyOrPin(host, port, cert); + return pinOk; + }); + try + { + await ssl.AuthenticateAsClientAsync( + new SslClientAuthenticationOptions { TargetHost = host }, ct); + } + catch (System.Security.Authentication.AuthenticationException) when (!pinOk) + { + throw new CertificatePinMismatchException(host, port); + } + stream = ssl; + } + + var client = new ElectrumClient(tcp, stream, host, port, useSsl); + // Negoziazione obbligatoria prima di ogni altra richiesta. + await client.RequestAsync("server.version", ct, ClientName, ProtocolVersion); + return client; + } + catch + { + tcp.Dispose(); + throw; + } + } + + public async Task RequestAsync(string method, CancellationToken ct = default, + params object?[] parameters) + { + var id = Interlocked.Increment(ref _nextId); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pending[id] = tcs; + + var payload = JsonSerializer.SerializeToUtf8Bytes(new + { + jsonrpc = "2.0", + id, + method, + @params = parameters, + }); + + await _writeLock.WaitAsync(ct); + try + { + await _stream.WriteAsync(payload, ct); + await _stream.WriteAsync("\n"u8.ToArray(), ct); + await _stream.FlushAsync(ct); + } + finally + { + _writeLock.Release(); + } + + await using var registration = ct.Register(() => tcs.TrySetCanceled(ct)); + return await tcs.Task; + } + + private async Task ReadLoopAsync() + { + Exception? failure = null; + try + { + using var reader = new StreamReader(_stream, Encoding.UTF8, leaveOpen: true); + while (!_cts.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(_cts.Token); + if (line is null) + break; // chiusura remota + if (string.IsNullOrWhiteSpace(line)) + continue; + + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + + if (root.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.Number) + { + if (!_pending.TryRemove(idEl.GetInt64(), out var tcs)) + continue; + if (root.TryGetProperty("error", out var error) && error.ValueKind != JsonValueKind.Null) + tcs.TrySetException(new ElectrumServerException(error.ToString())); + else + tcs.TrySetResult(root.GetProperty("result").Clone()); + } + else if (root.TryGetProperty("method", out var methodEl)) + { + NotificationReceived?.Invoke( + methodEl.GetString()!, + root.GetProperty("params").Clone()); + } + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + failure = ex; + } + finally + { + foreach (var (_, tcs) in _pending) + tcs.TrySetException(failure ?? new IOException("Connessione al server chiusa.")); + _pending.Clear(); + Disconnected?.Invoke(failure); + } + } + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + _tcp.Close(); + try { await _readLoop; } catch { /* in chiusura */ } + _cts.Dispose(); + _writeLock.Dispose(); + } +} + +/// Errore restituito dal server (campo "error" della risposta JSON-RPC). +public sealed class ElectrumServerException(string error) : Exception(error); + +/// +/// Il certificato del server è cambiato rispetto a quello salvato (TOFU, §9). +/// Si sblocca con il reset esplicito dei certificati. +/// +public sealed class CertificatePinMismatchException(string host, int port) : Exception( + $"Il certificato TLS di {host}:{port} è cambiato rispetto a quello salvato. " + + "Se il server ha rinnovato il certificato, esegui il reset dei certificati SSL.");