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

+5
View File
@@ -9,6 +9,11 @@ release and covers the full history from the initial commit.
### Fixed
- The wallet now verifies the chain identity of a server right after connecting: the
`genesis_hash` from `server.features` must match the profile and, on mainnet (which shares
Bitcoin's genesis), the header at the highest checkpoint not above the server's tip must match
the hardcoded one. A server on another chain is refused with an explicit message and the next
known server is tried. Applies to the GUI, peer discovery, the CLI and the server probe.
- `ElectrumClient`: a single server reply is now capped at 16 MiB (`maxLineBytes` in
`ConnectAsync`). A hostile server could stream an endless line with no newline and exhaust the
client's memory; the connection now fails with `InvalidDataException` as soon as the cap is
+2
View File
@@ -41,6 +41,8 @@ It cannot (given correct Merkle verification):
- Fabricate a confirmed transaction with a valid Merkle proof
- Forge a payment to a wrong address
**Chain identity.** Right after the handshake the wallet checks that the server is on the Palladium chain (`Core/Spv/ChainVerifier.cs`, ADR 0036): `genesis_hash` from `server.features` must match the profile, and the header at the highest checkpoint not above the server's tip must hash to the hardcoded checkpoint. Mainnet shares Bitcoin's genesis, so the checkpoint header is what actually distinguishes it; testnet/regtest (no checkpoints) rely on the genesis alone. A server whose tip is below the first checkpoint is accepted on the genesis check alone.
**Network resource limits.** Server input is bounded so a hostile server cannot hang or kill the
client: every request has a timeout (30 s; 10 s for the handshake; a timeout drops the connection)
and a single reply line is capped at 16 MiB (`Core/Net/ElectrumClient.cs`, ADR 0034 and 0035).
+1
View File
@@ -834,6 +834,7 @@ Reset SSL certificates* — see
| *Wallet already open in another instance of the application.* | Another running process holds this wallet's lock. | Close the other instance (GUI or CLI). |
| *The TLS certificate of host:port has changed…* | Certificate pin mismatch — renewal or interception. | Read [12.2](#122-tls-certificate-pinning-tofu--read-before-fixing-a-certificate-error) **before** resetting certificates. |
| Sync aborts, "server is not trustworthy" | A Merkle proof failed, or (mainnet) a block header did not chain back to a known checkpoint. | Switch to another server. Your local data is intact; the wallet refused the bad data. |
| *The server host:port is not on the Palladium chain (different genesis or checkpoint)…* | The server answered, but serves another chain (or a fork) than the wallet expects. Nothing was synced from it. | Pick another server; the wallet moves on to the next known one on its own. See [12.3](#123-what-synchronization-actually-does). |
| *Insufficient funds* with a non-zero balance | Funds are unconfirmed, under 6 confirmations, or immature coinbase; or the fee doesn't fit. | See [8.4](#84-why-insufficient-funds-can-appear-despite-a-visible-balance). Wait, or use Send all. |
| Restored wallet shows zero balance | Wrong passphrase, wrong script type, or funds beyond the gap limit. | See [15.2](#152-restoring-step-by-step) — the coins are not lost. |
| *Invalid mnemonic (wrong words or checksum)* | A word is misspelled, not on the BIP39 list, or the checksum fails. | Compare against your paper backup, word by word. |
@@ -0,0 +1,35 @@
# 0036. Verify the chain identity of a server right after connecting
- **Status:** Accepted
- **Area:** SPV
- **Recorded:** 2026-11-03
## Context
The wallet connected to any Electrum server that answered on the configured port and found out it was on another chain only indirectly, and only for transactions at or above the first checkpoint, with a message that did not name the cause. The server probe checked the genesis hash; the wallet did not. A further complication: the mainnet profile reuses Bitcoin's genesis hash, and registry servers report exactly that value, so a genesis check alone cannot tell Palladium from Bitcoin.
## Decision
After the handshake, `ChainVerifier.VerifyChainAsync` (`Core/Spv/ChainVerifier.cs`) runs once per connection:
1. `server.features`: `genesis_hash` must be present and equal the profile's genesis (case-insensitive). A server that does not report it, or errors on the method, is rejected: the protocol requires it, and on testnet/regtest it is the only identity check.
2. `blockchain.headers.subscribe` gives the tip height. The highest hardcoded checkpoint with `Height <= tip` is selected.
3. The header at that height (`blockchain.block.header`) is hashed and compared with the checkpoint. A mismatch, a server error, or a malformed header means a different chain.
Any failure throws `WrongChainException` (server, reason). The GUI (connect and peer discovery), the CLI and the server probe all call the same helper; the GUI disposes the connection and moves on to the next candidate server, showing a localised message if none is usable.
A tip **below the first checkpoint** (a young testnet, a server still syncing) has nothing to compare and is accepted on the genesis check alone. A tip below the *latest* checkpoint is checked against the highest one it has reached; being behind is not an error.
## Consequences
- A server on another chain or fork is refused at connect time with an explicit message instead of failing indirectly during a sync.
- Mainnet is protected by the checkpoint header, not by the Bitcoin-shared genesis. Testnet and regtest (no checkpoints yet) rely on the genesis alone.
- Two extra requests per connection (`server.features`, `blockchain.headers.subscribe`, plus one header on mainnet). The sync asks for the tip again anyway.
- The check is made once, at connect time; a server that changes chain mid-connection is still caught by the per-transaction anchoring.
## References
- `src/Core/Spv/ChainVerifier.cs`
- `tests/PalladiumWallet.Tests/Spv/ChainVerifierTests.cs`
- `tests/PalladiumWallet.ServerProbe/Program.cs`
- Related: 0009, 0034
+1
View File
@@ -39,6 +39,7 @@ One ADR per feature or significant design decision. Format: `template.md`. Workf
| 0033 | [Test the UI layer and CLI headlessly, gate coverage](0033-test-strategy-beyond-core.md) | Testing | Accepted |
| 0034 | [Bound every network request with a timeout](0034-bound-every-network-request.md) | Network | Accepted |
| 0035 | [Cap the length of a server line](0035-cap-server-line-length.md) | Network | Accepted |
| 0036 | [Verify the chain identity of a server right after connecting](0036-verify-chain-identity-on-connect.md) | SPV | Accepted |
ADRs `0002` onwards were written retroactively on 2026-11-03 from the code, comments and changelog.
If one misstates the original reasoning, fix it in a pull request.
+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})");
}
}
+6 -2
View File
@@ -101,13 +101,17 @@ async Task<(bool Ok, int? Height)> Probe((string Host, int Port, bool Ssl) s)
var r = await c.RequestAsync("server.features", ct);
var genesis = r.TryGetProperty("genesis_hash", out var g) ? g.GetString() : null;
var hashFn = r.TryGetProperty("hash_function", out var hf) ? hf.GetString() : null;
if (genesis != profile.GenesisHash)
throw new Exception($"genesis_hash {genesis} != expected {profile.GenesisHash} (wrong chain)");
if (hashFn != "sha256") throw new Exception($"hash_function '{hashFn}' (expected sha256)");
var pruning = r.TryGetProperty("pruning", out var pr) && pr.ValueKind == JsonValueKind.Number;
return (0, $"genesis ok, pruning={(pruning ? pr.ToString() : "none")}", pruning);
});
await Step("chain identity", async ct =>
{
await c.VerifyChainAsync(profile, ct);
return (0, "genesis and checkpoint match the profile", false);
});
var tipInfo = await Step("headers.subscribe", async ct =>
{
var r = await c.RequestAsync("blockchain.headers.subscribe", ct);
@@ -170,6 +170,7 @@ public sealed class CliTests : IDisposable
}
await using var server = new FakeElectrumServer();
server.Handle("server.features", _ => new { genesis_hash = ChainProfiles.Regtest.GenesisHash });
server.Handle("blockchain.headers.subscribe", _ => new { height = 300, hex = Header(uint256.One, 300) });
server.Handle("blockchain.scripthash.subscribe", _ => null);
server.Handle("blockchain.scripthash.get_history", p =>
@@ -198,4 +199,18 @@ public sealed class CliTests : IDisposable
Assert.Equal(txid, tx.Inputs[0].PrevOut.Hash);
Assert.Equal(0, server.CallCount("blockchain.transaction.broadcast")); // nothing broadcast without --broadcast
}
[Fact]
public async Task Sync_refuses_a_server_on_another_chain()
{
await using var server = new FakeElectrumServer(); // default features: mainnet genesis
await Cli("restore", Mnemonic, "--net", "regtest", "--file", WalletFile());
var (exit, _, err) = await Cli("sync", "--server", $"{server.Host}:{server.Port}",
"--net", "regtest", "--file", WalletFile());
Assert.Equal(1, exit);
Assert.Contains("not on the expected chain", err);
Assert.Equal(0, server.CallCount("blockchain.headers.subscribe")); // rejected before syncing
}
}
@@ -44,6 +44,12 @@ public sealed class FakeElectrumServer : IAsyncDisposable
_listener = new TcpListener(IPAddress.Loopback, port);
_listener.Start();
Handle("server.version", _ => new[] { "FakeElectrumX 1.0", "1.4" });
// Reports the mainnet genesis, as a real server does; override to simulate another chain.
Handle("server.features", _ => new
{
genesis_hash = PalladiumWallet.Core.Chain.ChainProfiles.Mainnet.GenesisHash,
hash_function = "sha256",
});
_acceptLoop = Task.Run(AcceptLoopAsync);
}
@@ -0,0 +1,162 @@
using NBitcoin.Crypto;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Spv;
using PalladiumWallet.Tests.Net;
namespace PalladiumWallet.Tests.Spv;
/// <summary>Chain-identity check after the handshake (genesis + highest checkpoint ≤ tip).</summary>
public class ChainVerifierTests
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
// 80 arbitrary bytes are a valid "header" for hashing purposes; seed makes them distinct.
private static string HeaderHex(int seed) =>
Convert.ToHexString(Hashes.SHA256(BitConverter.GetBytes(seed))
.Concat(Hashes.SHA256(BitConverter.GetBytes(seed + 1_000)))
.Concat(new byte[16]).ToArray()).ToLowerInvariant();
private static Checkpoint CheckpointFor(int height, int seed) =>
new(height, BlockHeaderInfo.Parse(HeaderHex(seed)).Hash.ToString(), 0x1d00ffff);
private static ChainProfile Profile(params Checkpoint[] checkpoints) =>
ChainProfiles.Mainnet with { Checkpoints = checkpoints };
private static async Task<ElectrumClient> ConnectAsync(FakeElectrumServer s) =>
await ElectrumClient.ConnectAsync(s.Host, s.Port, useSsl: false);
private static void Tip(FakeElectrumServer s, int height) =>
s.Handle("blockchain.headers.subscribe", _ => new { height, hex = HeaderHex(0) });
[Fact]
public async Task Accepts_a_server_with_the_expected_genesis_and_checkpoint_header()
{
await using var server = new FakeElectrumServer();
Tip(server, 150);
server.Handle("blockchain.block.header", p => p[0].GetInt32() == 100 ? HeaderHex(7) : throw new FakeElectrumError(-1, "no"));
await using var client = await ConnectAsync(server);
await client.VerifyChainAsync(Profile(CheckpointFor(100, 7)), default).WaitAsync(Timeout);
}
[Fact]
public async Task Uses_the_highest_checkpoint_not_above_the_tip()
{
await using var server = new FakeElectrumServer();
Tip(server, 150);
var asked = new List<int>();
server.Handle("blockchain.block.header", p => { asked.Add(p[0].GetInt32()); return HeaderHex(asked[^1]); });
await using var client = await ConnectAsync(server);
await client.VerifyChainAsync(
Profile(CheckpointFor(50, 50), CheckpointFor(100, 100), CheckpointFor(200, 200)), default).WaitAsync(Timeout);
Assert.Equal([100], asked);
}
[Fact]
public async Task Rejects_a_different_header_at_the_checkpoint_height()
{
await using var server = new FakeElectrumServer();
Tip(server, 150);
server.Handle("blockchain.block.header", _ => HeaderHex(999)); // other chain
await using var client = await ConnectAsync(server);
var ex = await Assert.ThrowsAsync<WrongChainException>(
() => client.VerifyChainAsync(Profile(CheckpointFor(100, 7)), default).WaitAsync(Timeout));
Assert.Equal(server.Port, ex.Port);
Assert.Contains("checkpoint", ex.Message);
}
[Fact]
public async Task Rejects_a_different_genesis_hash_before_asking_for_headers()
{
await using var server = new FakeElectrumServer();
server.Handle("server.features", _ => new { genesis_hash = ChainProfiles.Testnet.GenesisHash });
await using var client = await ConnectAsync(server);
await Assert.ThrowsAsync<WrongChainException>(
() => client.VerifyChainAsync(Profile(), default).WaitAsync(Timeout));
Assert.Equal(0, server.CallCount("blockchain.headers.subscribe"));
}
[Fact]
public async Task Genesis_comparison_is_case_insensitive()
{
await using var server = new FakeElectrumServer();
server.Handle("server.features", _ => new { genesis_hash = ChainProfiles.Mainnet.GenesisHash.ToUpperInvariant() });
Tip(server, 10);
await using var client = await ConnectAsync(server);
await client.VerifyChainAsync(Profile(), default).WaitAsync(Timeout);
}
[Theory]
[InlineData("error")]
[InlineData("no-genesis")]
[InlineData("not-an-object")]
public async Task Rejects_a_server_that_does_not_report_a_genesis_hash(string mode)
{
await using var server = new FakeElectrumServer();
server.Handle("server.features", _ => mode switch
{
"error" => throw new FakeElectrumError(-32601, "unknown method"),
"no-genesis" => new { hash_function = "sha256" },
_ => "nope",
});
await using var client = await ConnectAsync(server);
await Assert.ThrowsAsync<WrongChainException>(
() => client.VerifyChainAsync(Profile(), default).WaitAsync(Timeout));
}
[Fact]
public async Task A_tip_below_the_first_checkpoint_is_accepted_on_the_genesis_alone()
{
await using var server = new FakeElectrumServer();
Tip(server, 50);
await using var client = await ConnectAsync(server);
await client.VerifyChainAsync(Profile(CheckpointFor(100, 7)), default).WaitAsync(Timeout);
Assert.Equal(0, server.CallCount("blockchain.block.header"));
}
[Fact]
public async Task A_profile_without_checkpoints_relies_on_the_genesis_alone()
{
await using var server = new FakeElectrumServer();
Tip(server, 5_000_000);
await using var client = await ConnectAsync(server);
await client.VerifyChainAsync(Profile(), default).WaitAsync(Timeout);
Assert.Equal(0, server.CallCount("blockchain.block.header"));
}
[Theory]
[InlineData("server-error")]
[InlineData("short-header")]
[InlineData("not-hex")]
public async Task A_missing_or_malformed_checkpoint_header_is_a_wrong_chain(string mode)
{
await using var server = new FakeElectrumServer();
Tip(server, 150);
server.Handle("blockchain.block.header", _ => mode switch
{
"server-error" => throw new FakeElectrumError(-1, "no such block"),
"short-header" => "00ff",
_ => "zz",
});
await using var client = await ConnectAsync(server);
await Assert.ThrowsAsync<WrongChainException>(
() => client.VerifyChainAsync(Profile(CheckpointFor(100, 7)), default).WaitAsync(Timeout));
}
[Fact]
public void The_shipped_mainnet_profile_has_checkpoints_the_check_can_use()
{
Assert.NotEmpty(ChainProfiles.Mainnet.Checkpoints);
Assert.All(ChainProfiles.Mainnet.Checkpoints, c => Assert.Equal(64, c.BlockHash.Length));
}
}