diff --git a/src/Core/Storage/AppPaths.cs b/src/Core/Storage/AppPaths.cs new file mode 100644 index 0000000..3117e14 --- /dev/null +++ b/src/Core/Storage/AppPaths.cs @@ -0,0 +1,49 @@ +using PalladiumWallet.Core.Chain; + +namespace PalladiumWallet.Core.Storage; + +/// +/// 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". +/// +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"); + } + + /// Cartella dati della rete (config, wallet, header, certificati). + 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"); +} diff --git a/src/Core/Storage/EncryptedFile.cs b/src/Core/Storage/EncryptedFile.cs new file mode 100644 index 0000000..47f665a --- /dev/null +++ b/src/Core/Storage/EncryptedFile.cs @@ -0,0 +1,95 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace PalladiumWallet.Core.Storage; + +/// +/// 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. +/// +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))); + } + + /// Lancia se la password è errata o il file è manomesso. + public static string Decrypt(string fileContent, string password) + { + var container = JsonSerializer.Deserialize(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); +} + +/// Password errata (o file wallet manomesso: il tag GCM non distingue i due casi). +public sealed class WrongPasswordException : Exception +{ + public WrongPasswordException() : base("Password errata o file wallet danneggiato.") { } +} diff --git a/src/Core/Storage/WalletDocument.cs b/src/Core/Storage/WalletDocument.cs new file mode 100644 index 0000000..2139f9a --- /dev/null +++ b/src/Core/Storage/WalletDocument.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PalladiumWallet.Core.Storage; + +/// +/// Schema del file wallet (blueprint §8), versionato per consentire migrazioni +/// automatiche all'apertura. La cifratura (quando c'è una password) avvolge +/// l'intero documento via . +/// +public sealed class WalletDocument +{ + public const int CurrentVersion = 1; + + public int Version { get; set; } = CurrentVersion; + + /// Nome rete (mainnet/testnet/regtest), come ChainProfile.NetName. + public required string Network { get; set; } + + /// Tipo di script (nome dell'enum ScriptKind). + public required string ScriptKind { get; set; } + + /// Mnemonica BIP39 in chiaro nel documento (il documento va cifrato!); null se watch-only. + public string? Mnemonic { get; set; } + + /// Extension word BIP39 (§4.1); null se assente. + public string? Passphrase { get; set; } + + /// Path di account (es. "84'/746'/0'"). + public required string AccountPath { get; set; } + + /// Xpub di account in SLIP-132: basta da sola per il watch-only. + public required string AccountXpub { get; set; } + + public string? MasterFingerprint { get; set; } + + /// Gap limit per la scansione indirizzi (§5), configurabile. + public int GapLimit { get; set; } = 20; + + /// Etichette per indirizzo/txid (§12). + public Dictionary Labels { get; set; } = []; + + /// Cache dell'ultimo stato sincronizzato (saldo/storico mostrabili offline). + 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(json, JsonOptions) + ?? throw new InvalidDataException("File wallet non valido."); + return Migrate(doc); + } + + /// Migrazioni di schema all'apertura (§8). Per ora esiste solo la v1. + private static WalletDocument Migrate(WalletDocument doc) => doc.Version switch + { + CurrentVersion => doc, + _ => throw new InvalidDataException( + $"Versione file wallet {doc.Version} non supportata (max {CurrentVersion})."), + }; +} + +/// Stato sincronizzato persistito: permette di mostrare saldo/storico offline. +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 History { get; set; } = []; + public List 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; } +} diff --git a/src/Core/Storage/WalletStore.cs b/src/Core/Storage/WalletStore.cs new file mode 100644 index 0000000..1264e1b --- /dev/null +++ b/src/Core/Storage/WalletStore.cs @@ -0,0 +1,38 @@ +namespace PalladiumWallet.Core.Storage; + +/// +/// 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. +/// +public static class WalletStore +{ + public static bool Exists(string path) => File.Exists(path); + + /// True se il file richiede una password per l'apertura. + 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); + } +}