feat(net): ElectrumX client with TLS pinning
This commit is contained in:
@@ -0,0 +1,72 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CertificatePinStore(string filePath)
|
||||||
|
{
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
public static string Fingerprint(X509Certificate certificate) =>
|
||||||
|
Convert.ToHexString(SHA256.HashData(certificate.GetRawCertData())).ToLowerInvariant();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifica TOFU: true se il certificato è quello già visto (o se è il primo
|
||||||
|
/// contatto, nel qual caso viene salvato). False = certificato cambiato.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reset del certificato salvato per un server ("reset certificati SSL", §9).</summary>
|
||||||
|
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<string, string> Load() =>
|
||||||
|
File.Exists(filePath)
|
||||||
|
? JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(filePath)) ?? []
|
||||||
|
: [];
|
||||||
|
|
||||||
|
private void Save(Dictionary<string, string> pins)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
|
||||||
|
File.WriteAllText(filePath, JsonSerializer.Serialize(pins,
|
||||||
|
new JsonSerializerOptions { WriteIndented = true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Core.Net;
|
||||||
|
|
||||||
|
/// <summary>Voce di storico di uno scripthash (blockchain.scripthash.get_history).</summary>
|
||||||
|
public readonly record struct HistoryItem(string TxHash, int Height, long FeeSats);
|
||||||
|
|
||||||
|
/// <summary>UTXO riportato dal server (blockchain.scripthash.listunspent).</summary>
|
||||||
|
public readonly record struct UnspentItem(string TxHash, int TxPos, long ValueSats, int Height);
|
||||||
|
|
||||||
|
/// <summary>Prova di Merkle (blockchain.transaction.get_merkle).</summary>
|
||||||
|
public sealed record MerkleProofResponse(int BlockHeight, int Pos, IReadOnlyList<string> Merkle);
|
||||||
|
|
||||||
|
/// <summary>Tip della catena notificato da blockchain.headers.subscribe.</summary>
|
||||||
|
public readonly record struct ChainTip(int Height, string HeaderHex);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper tipizzati sui metodi del protocollo (blueprint §10), sopra il
|
||||||
|
/// trasporto JSON-RPC generico di <see cref="ElectrumClient"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class ElectrumApi
|
||||||
|
{
|
||||||
|
public static async Task<ChainTip> 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()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Status corrente dello scripthash (null = mai usato).</summary>
|
||||||
|
public static async Task<string?> 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<IReadOnlyList<HistoryItem>> 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<IReadOnlyList<UnspentItem>> 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<string> 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<MerkleProofResponse> 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<string> 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<string> BroadcastAsync(this ElectrumClient c, string rawTxHex,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var r = await c.RequestAsync("blockchain.transaction.broadcast", ct, rawTxHex);
|
||||||
|
return r.GetString()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fee stimata in coin/kB per il target dato; -1 se il server non sa stimare.</summary>
|
||||||
|
public static async Task<decimal> 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<decimal> RelayFeeAsync(this ElectrumClient c, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var r = await c.RequestAsync("blockchain.relayfee", ct);
|
||||||
|
return r.GetDecimal();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<string> 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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="NotificationReceived"/>.
|
||||||
|
/// </summary>
|
||||||
|
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<long, TaskCompletionSource<JsonElement>> _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;
|
||||||
|
|
||||||
|
/// <summary>(metodo, parametri) per le notifiche di subscription.</summary>
|
||||||
|
public event Action<string, JsonElement>? NotificationReceived;
|
||||||
|
|
||||||
|
/// <summary>Scatta quando la connessione cade (errore di lettura o chiusura remota).</summary>
|
||||||
|
public event Action<Exception?>? 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connette al server. Con <paramref name="useSsl"/> la validazione del
|
||||||
|
/// certificato è TOFU tramite <paramref name="pins"/> (§9): primo contatto
|
||||||
|
/// salva, contatti successivi confrontano; mismatch ⇒ <see cref="CertificatePinMismatchException"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<ElectrumClient> 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<JsonElement> RequestAsync(string method, CancellationToken ct = default,
|
||||||
|
params object?[] parameters)
|
||||||
|
{
|
||||||
|
var id = Interlocked.Increment(ref _nextId);
|
||||||
|
var tcs = new TaskCompletionSource<JsonElement>(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Errore restituito dal server (campo "error" della risposta JSON-RPC).</summary>
|
||||||
|
public sealed class ElectrumServerException(string error) : Exception(error);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Il certificato del server è cambiato rispetto a quello salvato (TOFU, §9).
|
||||||
|
/// Si sblocca con il reset esplicito dei certificati.
|
||||||
|
/// </summary>
|
||||||
|
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.");
|
||||||
Reference in New Issue
Block a user