feat(spv): verify the chain identity of a server on connect

After the handshake the wallet checks server.features genesis_hash and,
when the profile has checkpoints, the header at the highest checkpoint
not above the server's tip (mainnet shares Bitcoin's genesis, so the
checkpoint is what tells the chains apart). A server on another chain is
refused with WrongChainException and the next candidate is tried. Used by
the GUI, peer discovery, the CLI and the server probe. See ADR 0036.
This commit is contained in:
davide committed 2026-11-03 14:01:34 +01:00
1 parent 77f00d1b4e
commit 8bcee27c23
13 files changed
+341 -6

No files matched your search

+9
View File
@@ -467,6 +467,15 @@ public sealed class Loc
"Das TLS-Zertifikat von {0} hat sich gegenüber dem gespeicherten geändert. Wenn der Server das Zertifikat erneuert hat, setzen Sie die SSL-Zertifikate zurück.",
"{0} 的 TLS 证书与已保存的证书不同。如果服务器更新了证书,请重置 SSL 证书。"],
["msg.chain.wrong"] = [
"Il server {0} non è sulla catena Palladium (genesi o checkpoint diversi). Scegli un altro server.",
"The server {0} is not on the Palladium chain (different genesis or checkpoint). Choose another server.",
"El servidor {0} no está en la cadena Palladium (génesis o punto de control distintos). Elige otro servidor.",
"Le serveur {0} n'est pas sur la chaîne Palladium (genèse ou point de contrôle différent). Choisissez un autre serveur.",
"O servidor {0} não está na cadeia Palladium (gênese ou checkpoint diferente). Escolha outro servidor.",
"Der Server {0} befindet sich nicht auf der Palladium-Chain (anderer Genesis-Block oder Checkpoint). Wählen Sie einen anderen Server.",
"服务器 {0} 不在 Palladium 链上(创世块或检查点不同)。请选择其他服务器。"],
// Contacts
["contacts.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name", "姓名"],
["contacts.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse", "地址"],
+18 -2
View File
@@ -215,7 +215,14 @@ public partial class MainWindowViewModel
try
{
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
_client = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins, ct);
var connected = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins, ct);
try { await connected.VerifyChainAsync(Profile, ct); }
catch
{
await connected.DisposeAsync();
throw;
}
_client = connected;
_client.NotificationReceived += OnServerNotification;
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
{
@@ -428,7 +435,14 @@ public partial class MainWindowViewModel
try
{
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
temp = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins);
var fresh = await ElectrumClient.ConnectAsync(h, p, UseSsl, pins);
try { await fresh.VerifyChainAsync(Profile); }
catch
{
await fresh.DisposeAsync();
throw;
}
temp = fresh;
lastError = null;
break;
}
@@ -480,6 +494,8 @@ public partial class MainWindowViewModel
{
CertificatePinMismatchException cert =>
string.Format(Loc.Tr("msg.cert.mismatch"), $"{cert.Host}:{cert.Port}"),
WrongChainException chain =>
string.Format(Loc.Tr("msg.chain.wrong"), $"{chain.Host}:{chain.Port}"),
_ => ex.Message,
};
+10 -2
View File
@@ -28,7 +28,8 @@ try
};
}
catch (Exception ex) when (ex is WalletSpendException or WrongPasswordException
or CertificatePinMismatchException or ElectrumServerException or SpvVerificationException)
or CertificatePinMismatchException or ElectrumServerException or SpvVerificationException
or WrongChainException)
{
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
@@ -363,7 +364,14 @@ static async Task<ElectrumClient> Connect(string[] o, ChainProfile profile)
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(profile.Kind));
Console.WriteLine($"Connecting to {host}:{port}{(useSsl ? " (TLS)" : "")}…");
return await ElectrumClient.ConnectAsync(host, port, useSsl, pins);
var client = await ElectrumClient.ConnectAsync(host, port, useSsl, pins);
try { await client.VerifyChainAsync(profile); }
catch
{
await client.DisposeAsync();
throw;
}
return client;
}
static string? Opt(string[] options, string name)
+71
View File
@@ -0,0 +1,71 @@
using System.Text.Json;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Net;
namespace PalladiumWallet.Core.Spv;
/// <summary>The server is serving a different chain than the wallet's network profile.</summary>
public sealed class WrongChainException(string host, int port, string reason) : Exception(
$"The server {host}:{port} is not on the expected chain: {reason}")
{
public string Host { get; } = host;
public int Port { get; } = port;
public string Reason { get; } = reason;
}
/// <summary>
/// Chain-identity check run right after the handshake (ADR 0036). Mainnet reuses Bitcoin's
/// genesis hash, so the genesis alone cannot tell Palladium from Bitcoin: the check also pins
/// the header at the highest hardcoded checkpoint not above the server's tip.
/// </summary>
public static class ChainVerifier
{
/// <summary>
/// Throws <see cref="WrongChainException"/> if the server reports another genesis, or serves
/// at the highest checkpoint height (≤ its tip) a header other than the checkpoint. A tip
/// below the first checkpoint (young testnet, server still syncing) has nothing to compare
/// and is accepted on the genesis check alone.
/// </summary>
public static async Task VerifyChainAsync(this ElectrumClient client, ChainProfile profile,
CancellationToken ct = default)
{
string? genesis = null;
try
{
var features = await client.RequestAsync("server.features", ct);
if (features.ValueKind == JsonValueKind.Object
&& features.TryGetProperty("genesis_hash", out var g) && g.ValueKind == JsonValueKind.String)
genesis = g.GetString();
}
catch (ElectrumServerException) { /* reported below as a missing genesis_hash */ }
if (genesis is null)
throw new WrongChainException(client.Host, client.Port, "it does not report a genesis_hash");
if (!string.Equals(genesis, profile.GenesisHash, StringComparison.OrdinalIgnoreCase))
throw new WrongChainException(client.Host, client.Port,
$"genesis {genesis} (expected {profile.GenesisHash})");
var tip = await client.SubscribeHeadersAsync(ct);
Checkpoint? best = null;
foreach (var cp in profile.Checkpoints)
if (cp.Height <= tip.Height && (best is null || cp.Height > best.Value.Height))
best = cp;
if (best is not { } anchor)
return;
BlockHeaderInfo header;
try
{
header = BlockHeaderInfo.Parse(await client.GetBlockHeaderAsync(anchor.Height, ct));
}
catch (Exception ex) when (ex is ElectrumServerException or ArgumentException or FormatException
or JsonException or InvalidOperationException)
{
throw new WrongChainException(client.Host, client.Port,
$"no valid header at checkpoint height {anchor.Height} ({ex.Message})");
}
if (!header.MatchesCheckpoint(anchor))
throw new WrongChainException(client.Host, client.Port,
$"header at height {anchor.Height} is {header.Hash} (checkpoint {anchor.BlockHash})");
}
}