From 8a0dc920f68536e77a3e30d024d2da3abd54cf5e Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Thu, 11 Jun 2026 10:46:36 +0200 Subject: [PATCH] feat(crypto): BIP-32/39/84, HD accounts, SLIP-132 --- src/Core/Crypto/Bip39.cs | 106 ++++++++++++++++++++++++++ src/Core/Crypto/DerivationPaths.cs | 89 ++++++++++++++++++++++ src/Core/Crypto/HdAccount.cs | 116 +++++++++++++++++++++++++++++ src/Core/Crypto/Slip132.cs | 96 ++++++++++++++++++++++++ 4 files changed, 407 insertions(+) create mode 100644 src/Core/Crypto/Bip39.cs create mode 100644 src/Core/Crypto/DerivationPaths.cs create mode 100644 src/Core/Crypto/HdAccount.cs create mode 100644 src/Core/Crypto/Slip132.cs diff --git a/src/Core/Crypto/Bip39.cs b/src/Core/Crypto/Bip39.cs new file mode 100644 index 0000000..d20b62f --- /dev/null +++ b/src/Core/Crypto/Bip39.cs @@ -0,0 +1,106 @@ +using NBitcoin; + +namespace PalladiumWallet.Core.Crypto; + +/// +/// Lingue wordlist BIP39 supportate (blueprint §4.1). Le liste sono incorporate +/// in NBitcoin: nessun accesso a rete o filesystem. +/// +public enum MnemonicLanguage +{ + English, + Spanish, + Japanese, + PortugueseBrazil, + ChineseSimplified, + ChineseTraditional, + French, + Czech, +} + +/// Numero di parole supportato in creazione (blueprint §4.1: 12 o 24). +public enum MnemonicLength +{ + Twelve = 12, + TwentyFour = 24, +} + +/// +/// Facciata BIP39 su NBitcoin.Mnemonic (blueprint §4.1). NBitcoin copre già +/// entropia→parole, checksum, normalizzazione NFKD e PBKDF2-HMAC-SHA512 a 2048 +/// round con salt "mnemonic"+passphrase: qui si restringe l'API ai casi del +/// blueprint e si centralizza la mappa delle lingue. Quando arriverà il seed +/// nativo versionato (§4.1 punto 1), il riconoscimento multi-schema sarà una +/// catena di TryParse per schema. +/// +public static class Bip39 +{ + /// Genera una nuova mnemonica con entropia dal CSPRNG di sistema. + public static Mnemonic Generate(MnemonicLength length, MnemonicLanguage language = MnemonicLanguage.English) + { + var wordCount = length == MnemonicLength.Twelve ? WordCount.Twelve : WordCount.TwentyFour; + return new Mnemonic(ToWordlist(language), wordCount); + } + + /// + /// Riconosce e valida una mnemonica BIP39: numero di parole, appartenenza alla + /// wordlist e checksum. Se è indicata viene provata + /// per prima; altrimenti la lingua è auto-rilevata (parole condivise tra liste + /// possono rendere ambiguo l'autodetect: in import l'utente può forzarla). + /// + public static bool TryParse(string text, out Mnemonic? mnemonic, MnemonicLanguage? language = null) + { + mnemonic = null; + if (string.IsNullOrWhiteSpace(text)) + return false; + + // Solo trim esterno: la normalizzazione NFKD e lo spazio ideografico + // giapponese li gestisce NBitcoin, il testo non va alterato (§4.1). + text = text.Trim(); + + IEnumerable candidates = language is not null + ? [ToWordlist(language.Value)] + : [Wordlist.AutoDetect(text)]; + + foreach (var wordlist in candidates) + { + try + { + var parsed = new Mnemonic(text, wordlist); + // Il costruttore NON verifica il checksum: controllo esplicito obbligatorio. + if (parsed.IsValidChecksum && parsed.Words.Length is 12 or 15 or 18 or 21 or 24) + { + mnemonic = parsed; + return true; + } + } + catch (FormatException) + { + // Parole fuori wordlist o conteggio non valido: si prova oltre. + } + } + + return false; + } + + /// + /// Mnemonica → seed di 64 byte (PBKDF2-HMAC-SHA512, 2048 round, salt + /// "mnemonic"+passphrase). La passphrase cambia completamente il wallet + /// derivato: avvisi UI obbligatori (§4.1). + /// + public static byte[] ToSeed(Mnemonic mnemonic, string? passphrase = null) => + mnemonic.DeriveSeed(passphrase); + + internal static Wordlist ToWordlist(MnemonicLanguage language) => language switch + { + MnemonicLanguage.English => Wordlist.English, + MnemonicLanguage.Spanish => Wordlist.Spanish, + MnemonicLanguage.Japanese => Wordlist.Japanese, + MnemonicLanguage.PortugueseBrazil => Wordlist.PortugueseBrazil, + MnemonicLanguage.ChineseSimplified => Wordlist.ChineseSimplified, + MnemonicLanguage.ChineseTraditional => Wordlist.ChineseTraditional, + MnemonicLanguage.French => Wordlist.French, + MnemonicLanguage.Czech => Wordlist.Czech, + _ => throw new ArgumentOutOfRangeException(nameof(language)), + }; +} diff --git a/src/Core/Crypto/DerivationPaths.cs b/src/Core/Crypto/DerivationPaths.cs new file mode 100644 index 0000000..40731bb --- /dev/null +++ b/src/Core/Crypto/DerivationPaths.cs @@ -0,0 +1,89 @@ +using NBitcoin; +using PalladiumWallet.Core.Chain; + +namespace PalladiumWallet.Core.Crypto; + +/// +/// Costruzione dei derivation path BIP44/49/84 (blueprint §4.2) e mappatura +/// ScriptKind → purpose / ScriptPubKeyType. I purpose sono costanti di protocollo +/// BIP (chain-independent); il coin_type viene sempre dal ChainProfile. +/// +public static class DerivationPaths +{ + /// Purpose BIP44 (P2PKH legacy). + public const int PurposeLegacy = 44; + + /// Purpose BIP49 (P2SH-P2WPKH). + public const int PurposeWrappedSegwit = 49; + + /// Purpose BIP84 (P2WPKH nativo). + public const int PurposeNativeSegwit = 84; + + /// Purpose BIP48 (multisig — fase successiva, §16 passo 8). + public const int PurposeMultisig = 48; + + public static int PurposeFor(ScriptKind kind) => kind switch + { + ScriptKind.Legacy => PurposeLegacy, + ScriptKind.WrappedSegwit => PurposeWrappedSegwit, + ScriptKind.NativeSegwit => PurposeNativeSegwit, + ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig => PurposeMultisig, + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + + /// + /// Tipo di scriptPubKey NBitcoin per la derivazione da pubkey singola. + /// I tipi multisig derivano da redeem script, non da una pubkey: arriveranno + /// con i wallet M-di-N (§4.5). + /// + public static ScriptPubKeyType ScriptPubKeyTypeFor(ScriptKind kind) => kind switch + { + ScriptKind.Legacy => ScriptPubKeyType.Legacy, + ScriptKind.WrappedSegwit => ScriptPubKeyType.SegwitP2SH, + ScriptKind.NativeSegwit => ScriptPubKeyType.Segwit, + ScriptKind.WrappedSegwitMultisig or ScriptKind.NativeSegwitMultisig => + throw new NotSupportedException( + "I tipi multisig derivano da redeem script: supporto previsto con i wallet M-di-N (§4.5)."), + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + + /// + /// Path di account relativo alla root: purpose'/coin'/account' (§4.2). + /// Il coin_type è quello del profilo (746 mainnet, 1 testnet). + /// + public static KeyPath AccountPath(ScriptKind kind, ChainProfile profile, int account = 0) + { + if (account < 0) + throw new ArgumentOutOfRangeException(nameof(account)); + return new KeyPath( + $"{PurposeFor(kind)}'/{profile.Bip44CoinType}'/{account}'"); + } + + /// Sottopath non-hardened change/index sotto l'account (change=0 receiving, change=1 change). + public static KeyPath AddressSubPath(bool isChange, int index) + { + if (index < 0) + throw new ArgumentOutOfRangeException(nameof(index)); + return new KeyPath($"{(isChange ? 1 : 0)}/{index}"); + } + + /// + /// Parsing di derivation path personalizzati in import (Sparrow-like, §4.2): + /// accetta il prefisso "m/" opzionale e gli hardened marker ' oppure h. + /// + public static bool TryParse(string path, out KeyPath? keyPath) + { + keyPath = null; + if (string.IsNullOrWhiteSpace(path)) + return false; + try + { + keyPath = KeyPath.Parse(path.Trim()); + return true; + } + catch (FormatException) + { + return false; + } + } +} diff --git a/src/Core/Crypto/HdAccount.cs b/src/Core/Crypto/HdAccount.cs new file mode 100644 index 0000000..045d0ca --- /dev/null +++ b/src/Core/Crypto/HdAccount.cs @@ -0,0 +1,116 @@ +using NBitcoin; +using PalladiumWallet.Core.Chain; + +namespace PalladiumWallet.Core.Crypto; + +/// +/// Account HD single-sig (blueprint §4.2/§4.4): chiave estesa di account (xprv, +/// oppure solo xpub = watch-only) + tipo di script + profilo di rete. Deriva +/// indirizzi receiving (change=0) e change (change=1) on-demand per indice. +/// Gli indirizzi si derivano sempre dall'xpub di account (sottopath non-hardened), +/// quindi il watch-only funziona per costruzione. Il keystore completo +/// (cifratura, factory dal file wallet, §4.5) arriva con la persistenza (§8). +/// +public sealed class HdAccount +{ + private readonly ExtKey? _accountXprv; + + public ScriptKind Kind { get; } + public ChainProfile Profile { get; } + + /// Path dell'account relativo alla root (es. 84'/746'/0', o personalizzato). + public KeyPath AccountPath { get; } + + /// + /// Fingerprint della chiave master: con forma le + /// origin info richieste dalle PSBT (§6.5). Zero se ignota (xpub importata + /// senza metadati). + /// + public HDFingerprint MasterFingerprint { get; } + + public ExtPubKey AccountXpub { get; } + + /// True se l'account conosce solo la xpub: costruisce ma non firma (§4.5). + public bool IsWatchOnly => _accountXprv is null; + + private HdAccount(ExtKey? accountXprv, ExtPubKey accountXpub, ScriptKind kind, + ChainProfile profile, KeyPath accountPath, HDFingerprint masterFingerprint) + { + // Valida subito la mappatura (i tipi multisig non sono supportati qui). + DerivationPaths.ScriptPubKeyTypeFor(kind); + _accountXprv = accountXprv; + AccountXpub = accountXpub; + Kind = kind; + Profile = profile; + AccountPath = accountPath; + MasterFingerprint = masterFingerprint; + } + + /// Caso principale: mnemonica BIP39 (+ passphrase opzionale) → account standard. + public static HdAccount FromMnemonic(Mnemonic mnemonic, string? passphrase, + ScriptKind kind, ChainProfile profile, int account = 0) => + FromSeed(Bip39.ToSeed(mnemonic, passphrase), kind, profile, account); + + public static HdAccount FromSeed(byte[] seed, ScriptKind kind, ChainProfile profile, int account = 0) => + FromSeed(seed, kind, profile, DerivationPaths.AccountPath(kind, profile, account)); + + /// + /// Import con derivation path personalizzato (§4.2, Sparrow-like): + /// determina solo il tipo di indirizzo generato. + /// + public static HdAccount FromSeed(byte[] seed, ScriptKind kind, ChainProfile profile, KeyPath accountPath) + { + var root = ExtKey.CreateFromSeed(seed); + // L'account si deriva sempre dalla xprv: i livelli hardened del path + // non sono derivabili da una xpub. + var accountXprv = root.Derive(accountPath); + return new HdAccount(accountXprv, accountXprv.Neuter(), kind, profile, + accountPath, root.Neuter().PubKey.GetHDFingerPrint()); + } + + /// + /// Watch-only da xpub di account (§4.4): fingerprint e path sono noti solo + /// se forniti da chi importa (servono per le origin info PSBT). + /// + public static HdAccount FromAccountXpub(ExtPubKey accountXpub, ScriptKind kind, + ChainProfile profile, KeyPath? accountPath = null, HDFingerprint? masterFingerprint = null) => + new(null, accountXpub, kind, profile, + accountPath ?? DerivationPaths.AccountPath(kind, profile), + masterFingerprint ?? default); + + /// Import spendibile da xprv di account. + public static HdAccount FromAccountXprv(ExtKey accountXprv, ScriptKind kind, + ChainProfile profile, KeyPath? accountPath = null, HDFingerprint? masterFingerprint = null) => + new(accountXprv, accountXprv.Neuter(), kind, profile, + accountPath ?? DerivationPaths.AccountPath(kind, profile), + masterFingerprint ?? default); + + public BitcoinAddress GetAddress(bool isChange, int index) => + GetPublicKey(isChange, index).GetAddress( + DerivationPaths.ScriptPubKeyTypeFor(Kind), + PalladiumNetworks.For(Profile.Kind)); + + public BitcoinAddress GetReceiveAddress(int index) => GetAddress(isChange: false, index); + + public BitcoinAddress GetChangeAddress(int index) => GetAddress(isChange: true, index); + + public PubKey GetPublicKey(bool isChange, int index) => + AccountXpub.Derive(DerivationPaths.AddressSubPath(isChange, index)).PubKey; + + /// + /// Chiave privata estesa di un indirizzo. Lancia se watch-only: nessuna + /// chiave privata è derivabile dalle sole pubbliche (§17). + /// + public ExtKey GetExtPrivateKey(bool isChange, int index) => + (_accountXprv ?? throw new InvalidOperationException("Account watch-only: non può firmare.")) + .Derive(DerivationPaths.AddressSubPath(isChange, index)); + + /// Xpub di account in formato SLIP-132 (xpub/ypub/zpub secondo Kind). + public string ToSlip132() => Slip132.Encode(AccountXpub, Kind, Profile); + + /// Xprv di account in formato SLIP-132. Lancia se watch-only. + public string ToSlip132Private() => + Slip132.Encode( + _accountXprv ?? throw new InvalidOperationException("Account watch-only: nessuna chiave privata."), + Kind, Profile); +} diff --git a/src/Core/Crypto/Slip132.cs b/src/Core/Crypto/Slip132.cs new file mode 100644 index 0000000..97bdd35 --- /dev/null +++ b/src/Core/Crypto/Slip132.cs @@ -0,0 +1,96 @@ +using NBitcoin; +using NBitcoin.DataEncoders; +using PalladiumWallet.Core.Chain; + +namespace PalladiumWallet.Core.Crypto; + +/// +/// Serializzazione e parsing SLIP-132 delle chiavi estese (xprv/yprv/zprv e +/// varianti pubbliche) con gli header di +/// (blueprint §3). NBitcoin serializza con i soli header registrati nella Network +/// (xprv/xpub): le altre varianti si compongono qui come +/// Base58Check( header 4 byte BE || payload BIP32 74 byte ). +/// +public static class Slip132 +{ + public static string Encode(ExtPubKey key, ScriptKind kind, ChainProfile profile) => + Encoders.Base58Check.EncodeData([.. profile.ExtKeyHeaders[kind].PublicBytes(), .. key.ToBytes()]); + + public static string Encode(ExtKey key, ScriptKind kind, ChainProfile profile) => + Encoders.Base58Check.EncodeData([.. profile.ExtKeyHeaders[kind].PrivateBytes(), .. key.ToBytes()]); + + /// + /// Riconosce l'header (→ ScriptKind) e decodifica una chiave pubblica estesa: + /// è la via dell'import watch-only (§4.4). + /// + public static bool TryDecodePublic(string encoded, ChainProfile profile, + out ExtPubKey? key, out ScriptKind kind) + { + key = null; + kind = default; + if (!TryDecodePayload(encoded, profile, isPrivate: false, out var payload, out kind)) + return false; + try + { + key = new ExtPubKey(payload!); + return true; + } + catch (FormatException) + { + return false; + } + } + + /// Come , per chiavi private (import spendibile). + public static bool TryDecodePrivate(string encoded, ChainProfile profile, + out ExtKey? key, out ScriptKind kind) + { + key = null; + kind = default; + if (!TryDecodePayload(encoded, profile, isPrivate: true, out var payload, out kind)) + return false; + try + { + key = ExtKey.CreateFromBytes(payload!); + return true; + } + catch (FormatException) + { + return false; + } + } + + private static bool TryDecodePayload(string encoded, ChainProfile profile, bool isPrivate, + out byte[]? payload, out ScriptKind kind) + { + payload = null; + kind = default; + if (string.IsNullOrWhiteSpace(encoded)) + return false; + + byte[] data; + try + { + data = Encoders.Base58Check.DecodeData(encoded.Trim()); + } + catch (FormatException) + { + return false; + } + + if (data.Length != 78) + return false; + + var header = (uint)(data[0] << 24 | data[1] << 16 | data[2] << 8 | data[3]); + foreach (var (candidate, headers) in profile.ExtKeyHeaders) + { + if (header != (isPrivate ? headers.Private : headers.Public)) + continue; + kind = candidate; + payload = data[4..]; + return true; + } + + return false; + } +}