feat(crypto): BIP-32/39/84, HD accounts, SLIP-132

This commit is contained in:
2026-06-11 10:46:36 +02:00
parent cdd19024ad
commit 8a0dc920f6
4 changed files with 407 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
using NBitcoin;
namespace PalladiumWallet.Core.Crypto;
/// <summary>
/// Lingue wordlist BIP39 supportate (blueprint §4.1). Le liste sono incorporate
/// in NBitcoin: nessun accesso a rete o filesystem.
/// </summary>
public enum MnemonicLanguage
{
English,
Spanish,
Japanese,
PortugueseBrazil,
ChineseSimplified,
ChineseTraditional,
French,
Czech,
}
/// <summary>Numero di parole supportato in creazione (blueprint §4.1: 12 o 24).</summary>
public enum MnemonicLength
{
Twelve = 12,
TwentyFour = 24,
}
/// <summary>
/// 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.
/// </summary>
public static class Bip39
{
/// <summary>Genera una nuova mnemonica con entropia dal CSPRNG di sistema.</summary>
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);
}
/// <summary>
/// Riconosce e valida una mnemonica BIP39: numero di parole, appartenenza alla
/// wordlist e checksum. Se <paramref name="language"/> è 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).
/// </summary>
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<Wordlist> 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;
}
/// <summary>
/// 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).
/// </summary>
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)),
};
}
+89
View File
@@ -0,0 +1,89 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Core.Crypto;
/// <summary>
/// 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.
/// </summary>
public static class DerivationPaths
{
/// <summary>Purpose BIP44 (P2PKH legacy).</summary>
public const int PurposeLegacy = 44;
/// <summary>Purpose BIP49 (P2SH-P2WPKH).</summary>
public const int PurposeWrappedSegwit = 49;
/// <summary>Purpose BIP84 (P2WPKH nativo).</summary>
public const int PurposeNativeSegwit = 84;
/// <summary>Purpose BIP48 (multisig — fase successiva, §16 passo 8).</summary>
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)),
};
/// <summary>
/// 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).
/// </summary>
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)),
};
/// <summary>
/// Path di account relativo alla root: purpose'/coin'/account' (§4.2).
/// Il coin_type è quello del profilo (746 mainnet, 1 testnet).
/// </summary>
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}'");
}
/// <summary>Sottopath non-hardened change/index sotto l'account (change=0 receiving, change=1 change).</summary>
public static KeyPath AddressSubPath(bool isChange, int index)
{
if (index < 0)
throw new ArgumentOutOfRangeException(nameof(index));
return new KeyPath($"{(isChange ? 1 : 0)}/{index}");
}
/// <summary>
/// Parsing di derivation path personalizzati in import (Sparrow-like, §4.2):
/// accetta il prefisso "m/" opzionale e gli hardened marker ' oppure h.
/// </summary>
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;
}
}
}
+116
View File
@@ -0,0 +1,116 @@
using NBitcoin;
using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Core.Crypto;
/// <summary>
/// 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).
/// </summary>
public sealed class HdAccount
{
private readonly ExtKey? _accountXprv;
public ScriptKind Kind { get; }
public ChainProfile Profile { get; }
/// <summary>Path dell'account relativo alla root (es. 84'/746'/0', o personalizzato).</summary>
public KeyPath AccountPath { get; }
/// <summary>
/// Fingerprint della chiave master: con <see cref="AccountPath"/> forma le
/// origin info richieste dalle PSBT (§6.5). Zero se ignota (xpub importata
/// senza metadati).
/// </summary>
public HDFingerprint MasterFingerprint { get; }
public ExtPubKey AccountXpub { get; }
/// <summary>True se l'account conosce solo la xpub: costruisce ma non firma (§4.5).</summary>
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;
}
/// <summary>Caso principale: mnemonica BIP39 (+ passphrase opzionale) → account standard.</summary>
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));
/// <summary>
/// Import con derivation path personalizzato (§4.2, Sparrow-like):
/// <paramref name="kind"/> determina solo il tipo di indirizzo generato.
/// </summary>
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());
}
/// <summary>
/// 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).
/// </summary>
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);
/// <summary>Import spendibile da xprv di account.</summary>
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;
/// <summary>
/// Chiave privata estesa di un indirizzo. Lancia se watch-only: nessuna
/// chiave privata è derivabile dalle sole pubbliche (§17).
/// </summary>
public ExtKey GetExtPrivateKey(bool isChange, int index) =>
(_accountXprv ?? throw new InvalidOperationException("Account watch-only: non può firmare."))
.Derive(DerivationPaths.AddressSubPath(isChange, index));
/// <summary>Xpub di account in formato SLIP-132 (xpub/ypub/zpub secondo Kind).</summary>
public string ToSlip132() => Slip132.Encode(AccountXpub, Kind, Profile);
/// <summary>Xprv di account in formato SLIP-132. Lancia se watch-only.</summary>
public string ToSlip132Private() =>
Slip132.Encode(
_accountXprv ?? throw new InvalidOperationException("Account watch-only: nessuna chiave privata."),
Kind, Profile);
}
+96
View File
@@ -0,0 +1,96 @@
using NBitcoin;
using NBitcoin.DataEncoders;
using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Core.Crypto;
/// <summary>
/// Serializzazione e parsing SLIP-132 delle chiavi estese (xprv/yprv/zprv e
/// varianti pubbliche) con gli header di <see cref="ChainProfile.ExtKeyHeaders"/>
/// (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 ).
/// </summary>
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()]);
/// <summary>
/// Riconosce l'header (→ ScriptKind) e decodifica una chiave pubblica estesa:
/// è la via dell'import watch-only (§4.4).
/// </summary>
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;
}
}
/// <summary>Come <see cref="TryDecodePublic"/>, per chiavi private (import spendibile).</summary>
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;
}
}