Files
PalladiumWallet/src/Core/Net/CertificatePinStore.cs
T
davide 27231c8eec fix(core): stop corrupted state from permanently breaking SSL and wallet loading
CertificatePinStore.Load threw on a corrupted pin file, blocking every SSL
connection until the user manually deleted it; EncryptedFile.IsEncrypted
threw on valid JSON with a non-object root or invalid UTF-16 instead of
returning false. Both now degrade gracefully. Found by property-based tests
while extending the test suite.
2026-07-02 23:19:46 +02:00

84 lines
2.6 KiB
C#

using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
namespace PalladiumWallet.Core.Net;
/// <summary>
/// TLS "trust on first use" pinning (blueprint §9): on the first contact the
/// server certificate's SHA-256 fingerprint is saved; on subsequent ones it is
/// compared. If it changes, the connection must be rejected and the user can
/// unlock with an explicit reset (typical case: renewed self-signed server).
/// </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>
/// TOFU check: true if the certificate is the one already seen (or if it is the
/// first contact, in which case it is saved). False = certificate changed.
/// </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 the saved certificate for a server ("reset SSL certificates", §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()
{
if (!File.Exists(filePath))
return [];
try
{
return JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(filePath)) ?? [];
}
catch (JsonException)
{
// A corrupt pin file must not make every SSL connection fail forever:
// fall back to first-contact TOFU (same trust level as the bootstrap).
return [];
}
}
private void Save(Dictionary<string, string> pins)
{
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
File.WriteAllText(filePath, JsonSerializer.Serialize(pins,
new JsonSerializerOptions { WriteIndented = true }));
}
}