feat(storage): encrypted wallet file and persistence
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Core.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Percorsi dati per piattaforma (blueprint §8): ~/.palladium-wallet (Linux) o
|
||||||
|
/// %APPDATA%/PalladiumWallet (Windows), con sottocartella per rete. La modalità
|
||||||
|
/// portable (dati accanto all'eseguibile) si attiva se accanto all'eseguibile
|
||||||
|
/// esiste una cartella "palladium-data".
|
||||||
|
/// </summary>
|
||||||
|
public static class AppPaths
|
||||||
|
{
|
||||||
|
public const string PortableDirName = "palladium-data";
|
||||||
|
|
||||||
|
public static string DataRoot()
|
||||||
|
{
|
||||||
|
var portable = Path.Combine(AppContext.BaseDirectory, PortableDirName);
|
||||||
|
if (Directory.Exists(portable))
|
||||||
|
return portable;
|
||||||
|
|
||||||
|
return OperatingSystem.IsWindows()
|
||||||
|
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "PalladiumWallet")
|
||||||
|
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".palladium-wallet");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Cartella dati della rete (config, wallet, header, certificati).</summary>
|
||||||
|
public static string ForNetwork(NetKind net)
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(DataRoot(), ChainProfiles.For(net).NetName);
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string WalletsDir(NetKind net)
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(ForNetwork(net), "wallets");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string DefaultWalletPath(NetKind net) =>
|
||||||
|
Path.Combine(WalletsDir(net), "default.wallet.json");
|
||||||
|
|
||||||
|
public static string CertificatePinsPath(NetKind net) =>
|
||||||
|
Path.Combine(ForNetwork(net), "server-certs.json");
|
||||||
|
|
||||||
|
public static string ConfigPath() =>
|
||||||
|
Path.Combine(DataRoot(), "config.json");
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Core.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cifratura del file wallet (blueprint §8/§17): AES-256-GCM con chiave derivata
|
||||||
|
/// dalla password via PBKDF2-HMAC-SHA512. Il contenitore è JSON autodescrittivo
|
||||||
|
/// (kdf, parametri, salt, nonce) per consentire upgrade futuri dei parametri.
|
||||||
|
/// </summary>
|
||||||
|
public static class EncryptedFile
|
||||||
|
{
|
||||||
|
private const string Format = "plm-wallet-aesgcm-v1";
|
||||||
|
private const int DefaultIterations = 600_000;
|
||||||
|
private const int SaltSize = 16;
|
||||||
|
private const int NonceSize = 12;
|
||||||
|
private const int TagSize = 16;
|
||||||
|
private const int KeySize = 32;
|
||||||
|
|
||||||
|
private sealed record Container(
|
||||||
|
string Format, int Iterations, string Salt, string Nonce, string Tag, string Data);
|
||||||
|
|
||||||
|
public static bool IsEncrypted(string fileContent)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(fileContent);
|
||||||
|
return doc.RootElement.TryGetProperty("Format", out var f)
|
||||||
|
&& f.GetString() == Format;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Encrypt(string plaintext, string password)
|
||||||
|
{
|
||||||
|
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||||
|
var nonce = RandomNumberGenerator.GetBytes(NonceSize);
|
||||||
|
var key = DeriveKey(password, salt, DefaultIterations);
|
||||||
|
|
||||||
|
var plainBytes = Encoding.UTF8.GetBytes(plaintext);
|
||||||
|
var cipher = new byte[plainBytes.Length];
|
||||||
|
var tag = new byte[TagSize];
|
||||||
|
using (var aes = new AesGcm(key, TagSize))
|
||||||
|
aes.Encrypt(nonce, plainBytes, cipher, tag);
|
||||||
|
CryptographicOperations.ZeroMemory(key);
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(new Container(
|
||||||
|
Format, DefaultIterations,
|
||||||
|
Convert.ToBase64String(salt), Convert.ToBase64String(nonce),
|
||||||
|
Convert.ToBase64String(tag), Convert.ToBase64String(cipher)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lancia <see cref="WrongPasswordException"/> se la password è errata o il file è manomesso.</summary>
|
||||||
|
public static string Decrypt(string fileContent, string password)
|
||||||
|
{
|
||||||
|
var container = JsonSerializer.Deserialize<Container>(fileContent)
|
||||||
|
?? throw new InvalidDataException("Contenitore cifrato non valido.");
|
||||||
|
if (container.Format != Format)
|
||||||
|
throw new InvalidDataException($"Formato sconosciuto: {container.Format}");
|
||||||
|
|
||||||
|
var key = DeriveKey(password, Convert.FromBase64String(container.Salt), container.Iterations);
|
||||||
|
var cipher = Convert.FromBase64String(container.Data);
|
||||||
|
var plain = new byte[cipher.Length];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var aes = new AesGcm(key, TagSize);
|
||||||
|
aes.Decrypt(
|
||||||
|
Convert.FromBase64String(container.Nonce), cipher,
|
||||||
|
Convert.FromBase64String(container.Tag), plain);
|
||||||
|
}
|
||||||
|
catch (AuthenticationTagMismatchException)
|
||||||
|
{
|
||||||
|
// Il tag GCM autentica: password errata e manomissione sono indistinguibili.
|
||||||
|
throw new WrongPasswordException();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(key);
|
||||||
|
}
|
||||||
|
return Encoding.UTF8.GetString(plain);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] DeriveKey(string password, byte[] salt, int iterations) =>
|
||||||
|
Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA512, KeySize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Password errata (o file wallet manomesso: il tag GCM non distingue i due casi).</summary>
|
||||||
|
public sealed class WrongPasswordException : Exception
|
||||||
|
{
|
||||||
|
public WrongPasswordException() : base("Password errata o file wallet danneggiato.") { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Core.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schema del file wallet (blueprint §8), versionato per consentire migrazioni
|
||||||
|
/// automatiche all'apertura. La cifratura (quando c'è una password) avvolge
|
||||||
|
/// l'intero documento via <see cref="EncryptedFile"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WalletDocument
|
||||||
|
{
|
||||||
|
public const int CurrentVersion = 1;
|
||||||
|
|
||||||
|
public int Version { get; set; } = CurrentVersion;
|
||||||
|
|
||||||
|
/// <summary>Nome rete (mainnet/testnet/regtest), come ChainProfile.NetName.</summary>
|
||||||
|
public required string Network { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Tipo di script (nome dell'enum ScriptKind).</summary>
|
||||||
|
public required string ScriptKind { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Mnemonica BIP39 in chiaro nel documento (il documento va cifrato!); null se watch-only.</summary>
|
||||||
|
public string? Mnemonic { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Extension word BIP39 (§4.1); null se assente.</summary>
|
||||||
|
public string? Passphrase { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Path di account (es. "84'/746'/0'").</summary>
|
||||||
|
public required string AccountPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Xpub di account in SLIP-132: basta da sola per il watch-only.</summary>
|
||||||
|
public required string AccountXpub { get; set; }
|
||||||
|
|
||||||
|
public string? MasterFingerprint { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gap limit per la scansione indirizzi (§5), configurabile.</summary>
|
||||||
|
public int GapLimit { get; set; } = 20;
|
||||||
|
|
||||||
|
/// <summary>Etichette per indirizzo/txid (§12).</summary>
|
||||||
|
public Dictionary<string, string> Labels { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>Cache dell'ultimo stato sincronizzato (saldo/storico mostrabili offline).</summary>
|
||||||
|
public SyncCache? Cache { get; set; }
|
||||||
|
|
||||||
|
public bool IsWatchOnly => Mnemonic is null;
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
};
|
||||||
|
|
||||||
|
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
|
||||||
|
|
||||||
|
public static WalletDocument FromJson(string json)
|
||||||
|
{
|
||||||
|
var doc = JsonSerializer.Deserialize<WalletDocument>(json, JsonOptions)
|
||||||
|
?? throw new InvalidDataException("File wallet non valido.");
|
||||||
|
return Migrate(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Migrazioni di schema all'apertura (§8). Per ora esiste solo la v1.</summary>
|
||||||
|
private static WalletDocument Migrate(WalletDocument doc) => doc.Version switch
|
||||||
|
{
|
||||||
|
CurrentVersion => doc,
|
||||||
|
_ => throw new InvalidDataException(
|
||||||
|
$"Versione file wallet {doc.Version} non supportata (max {CurrentVersion})."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stato sincronizzato persistito: permette di mostrare saldo/storico offline.</summary>
|
||||||
|
public sealed class SyncCache
|
||||||
|
{
|
||||||
|
public int TipHeight { get; set; }
|
||||||
|
public long ConfirmedSats { get; set; }
|
||||||
|
public long UnconfirmedSats { get; set; }
|
||||||
|
public int NextReceiveIndex { get; set; }
|
||||||
|
public int NextChangeIndex { get; set; }
|
||||||
|
public List<CachedTx> History { get; set; } = [];
|
||||||
|
public List<CachedUtxo> Utxos { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CachedTx
|
||||||
|
{
|
||||||
|
public required string Txid { get; set; }
|
||||||
|
public int Height { get; set; }
|
||||||
|
public long DeltaSats { get; set; }
|
||||||
|
public bool Verified { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CachedUtxo
|
||||||
|
{
|
||||||
|
public required string Txid { get; set; }
|
||||||
|
public int Vout { get; set; }
|
||||||
|
public long ValueSats { get; set; }
|
||||||
|
public required string Address { get; set; }
|
||||||
|
public bool IsChange { get; set; }
|
||||||
|
public int AddressIndex { get; set; }
|
||||||
|
public int Height { get; set; }
|
||||||
|
public bool Frozen { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
namespace PalladiumWallet.Core.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lettura/scrittura del file wallet su disco (blueprint §8): JSON in chiaro
|
||||||
|
/// senza password, contenitore AES-GCM con password. Scrittura atomica
|
||||||
|
/// (file temporaneo + rename) per non corrompere il wallet su crash.
|
||||||
|
/// </summary>
|
||||||
|
public static class WalletStore
|
||||||
|
{
|
||||||
|
public static bool Exists(string path) => File.Exists(path);
|
||||||
|
|
||||||
|
/// <summary>True se il file richiede una password per l'apertura.</summary>
|
||||||
|
public static bool RequiresPassword(string path) =>
|
||||||
|
EncryptedFile.IsEncrypted(File.ReadAllText(path));
|
||||||
|
|
||||||
|
public static WalletDocument Load(string path, string? password = null)
|
||||||
|
{
|
||||||
|
var content = File.ReadAllText(path);
|
||||||
|
if (EncryptedFile.IsEncrypted(content))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(password))
|
||||||
|
throw new WrongPasswordException();
|
||||||
|
content = EncryptedFile.Decrypt(content, password);
|
||||||
|
}
|
||||||
|
return WalletDocument.FromJson(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Save(WalletDocument doc, string path, string? password = null)
|
||||||
|
{
|
||||||
|
var content = doc.ToJson();
|
||||||
|
if (!string.IsNullOrEmpty(password))
|
||||||
|
content = EncryptedFile.Encrypt(content, password);
|
||||||
|
|
||||||
|
var tmp = path + ".tmp";
|
||||||
|
File.WriteAllText(tmp, content);
|
||||||
|
File.Move(tmp, path, overwrite: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user