feat(crypto): BIP-32/39/84, HD accounts, SLIP-132
This commit is contained in:
@@ -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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user