wallet: store the expanded BIP39 seed for mnemonic wallets
Mnemonic wallets stored the 32-byte BIP39 entropy as the HD seed and relied on CHDChain.fMnemonicSeed to tell the deriver to expand it first. That flag is version-gated in the CHDChain serialisation, so a binary that predates it reads the record, never consumes the trailing byte, and derives from the raw entropy -- a different key tree, silently, with no error. Store the expanded 64-byte BIP39 seed instead, with fMnemonic = false, and keep the entropy in its own display-only record. Derivation then reads the stored bytes directly on every binary, old or new, so key trees are identical and no CHDChain version bump or minversion fence is needed. The wallet format stays readable by earlier releases rather than becoming one-way. Addresses are unchanged: the previous format expanded the entropy on every read and fed the same 64 bytes to Master(). This is also the form the tree already round-trips through -- z_exportwallet dumps the expanded seed, and restoring that hex via -hdseed installs it with fMnemonic = false. Write order is load-bearing: seed first, entropy second. A crash between them leaves a wallet with a seed and no phrase, which is merely inconvenient. The reverse would leave an entropy record with no seed, and the next start would mint a different seed while the wallet still held a phrase for the old one. -usemnemonic still defaults to false; only explicit opt-in and -mnemonic restores take this path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -139,3 +139,103 @@ TEST(mnemonic_compat, RawEntropyDiffersFromMnemonicSeed)
|
||||
const std::string entropyT = DeriveTAddrFromSeedBytes(zeros); // wrong (32-byte)
|
||||
EXPECT_NE(seedT, entropyT);
|
||||
}
|
||||
// New storage form: the HD seed IS the expanded 64-byte BIP39 seed, the chain is
|
||||
// NOT flagged mnemonic, and the phrase comes from the separate entropy record.
|
||||
TEST(mnemonic_compat, ExpandedSeedIsStoredDirectly)
|
||||
{
|
||||
SelectParams(CBaseChainParams::MAIN);
|
||||
|
||||
CWallet wallet;
|
||||
ASSERT_TRUE(wallet.SetHDSeedFromMnemonic(ABANDON_ART));
|
||||
|
||||
// Stored bytes == the 64-byte BIP39 seed, fed to derivation unchanged.
|
||||
HDSeed stored;
|
||||
ASSERT_TRUE(wallet.GetHDSeed(stored));
|
||||
auto raw = stored.RawSeed();
|
||||
EXPECT_EQ(raw.size(), (size_t)64);
|
||||
EXPECT_EQ(HexStr(raw.begin(), raw.end()), std::string(SEED64_HEX));
|
||||
|
||||
// No CHDChain version bump / no mnemonic flag: an older binary reads this
|
||||
// wallet and derives the same tree.
|
||||
EXPECT_FALSE(wallet.GetHDChain().fMnemonicSeed);
|
||||
EXPECT_LT(wallet.GetHDChain().nVersion, CHDChain::VERSION_HD_MNEMONIC);
|
||||
|
||||
HDSeed forDerivation;
|
||||
ASSERT_TRUE(wallet.GetHDSeedForDerivation(forDerivation));
|
||||
EXPECT_EQ(forDerivation.RawSeed(), raw);
|
||||
|
||||
// The phrase is still exportable, and IsMnemonicSeed() (which gates
|
||||
// z_exportmnemonic) still says yes.
|
||||
EXPECT_TRUE(wallet.IsMnemonicSeed());
|
||||
EXPECT_TRUE(wallet.HaveMnemonicEntropy());
|
||||
std::string exported;
|
||||
ASSERT_TRUE(wallet.GetMnemonicPhrase(exported));
|
||||
EXPECT_EQ(exported, std::string(ABANDON_ART));
|
||||
}
|
||||
|
||||
// Backwards compatibility: a wallet in the OLD form (stored HD seed == 32-byte
|
||||
// entropy, fMnemonicSeed = true) must still derive and still export its phrase.
|
||||
TEST(mnemonic_compat, LegacyEntropySeedStillWorks)
|
||||
{
|
||||
SelectParams(CBaseChainParams::MAIN);
|
||||
|
||||
RawHDSeed zeros(32, 0), seed64;
|
||||
ASSERT_TRUE(Bip39SeedFromEntropy(zeros, seed64));
|
||||
|
||||
CWallet wallet;
|
||||
{
|
||||
LOCK(wallet.cs_wallet);
|
||||
RawHDSeed entropy(32, 0);
|
||||
HDSeed legacy(entropy);
|
||||
ASSERT_TRUE(wallet.InstallHDSeed(legacy, true, 1));
|
||||
}
|
||||
EXPECT_TRUE(wallet.GetHDChain().fMnemonicSeed);
|
||||
EXPECT_FALSE(wallet.HaveMnemonicEntropy());
|
||||
EXPECT_TRUE(wallet.IsMnemonicSeed());
|
||||
|
||||
// Still expanded on read -> same key tree as the new form.
|
||||
HDSeed forDerivation;
|
||||
ASSERT_TRUE(wallet.GetHDSeedForDerivation(forDerivation));
|
||||
EXPECT_EQ(forDerivation.RawSeed(), seed64);
|
||||
{
|
||||
LOCK(wallet.cs_wallet);
|
||||
EXPECT_EQ(EncodePaymentAddress(wallet.GenerateNewSaplingZKey()),
|
||||
DeriveZAddrFromSeed64(seed64));
|
||||
}
|
||||
|
||||
std::string exported;
|
||||
ASSERT_TRUE(wallet.GetMnemonicPhrase(exported));
|
||||
EXPECT_EQ(exported, std::string(ABANDON_ART));
|
||||
}
|
||||
|
||||
// The entropy record refuses replacement, and a phrase that does not restore the
|
||||
// installed seed is never printed.
|
||||
TEST(mnemonic_compat, MnemonicEntropyGuards)
|
||||
{
|
||||
SelectParams(CBaseChainParams::MAIN);
|
||||
|
||||
RawHDSeed zeros(32, 0), ones(32, 1);
|
||||
|
||||
// Refuse-to-replace.
|
||||
CWallet wallet;
|
||||
ASSERT_TRUE(wallet.SetHDSeedFromMnemonic(ABANDON_ART));
|
||||
EXPECT_FALSE(wallet.SetMnemonicEntropy(ones));
|
||||
EXPECT_FALSE(wallet.SetMnemonicEntropy(RawHDSeed())); // empty is not "installed"
|
||||
|
||||
// Mismatched entropy -> no phrase. Install a 64-byte seed that is NOT the
|
||||
// expansion of `zeros`, then attach `zeros` as entropy.
|
||||
RawHDSeed otherSeed64;
|
||||
ASSERT_TRUE(Bip39SeedFromEntropy(ones, otherSeed64));
|
||||
CWallet mismatched;
|
||||
ASSERT_TRUE(mismatched.SetHDSeedFromHex(HexStr(otherSeed64.begin(), otherSeed64.end())));
|
||||
ASSERT_TRUE(mismatched.SetMnemonicEntropy(zeros));
|
||||
std::string phrase;
|
||||
EXPECT_FALSE(mismatched.GetMnemonicPhrase(phrase));
|
||||
|
||||
// Matching entropy attached to a hex-restored wallet -> phrase available.
|
||||
CWallet matched;
|
||||
ASSERT_TRUE(matched.SetHDSeedFromHex(std::string(SEED64_HEX)));
|
||||
ASSERT_TRUE(matched.SetMnemonicEntropy(zeros));
|
||||
ASSERT_TRUE(matched.GetMnemonicPhrase(phrase));
|
||||
EXPECT_EQ(phrase, std::string(ABANDON_ART));
|
||||
}
|
||||
|
||||
@@ -2480,9 +2480,30 @@ void CWallet::GenerateNewSeed()
|
||||
RawHDSeed entropy;
|
||||
if (!GenerateMnemonicEntropy(256, entropy))
|
||||
throw std::runtime_error(std::string(__func__) + ": -usemnemonic entropy generation failed");
|
||||
HDSeed seed(entropy);
|
||||
if (!InstallHDSeed(seed, true, nCreationTime))
|
||||
|
||||
// Store the EXPANDED 64-byte BIP39 seed as the HD seed, with
|
||||
// fMnemonic = false. Every binary -- old or new -- then feeds the stored
|
||||
// bytes straight into derivation, so the key tree is identical
|
||||
// everywhere and no CHDChain version bump or minversion fence is needed.
|
||||
// The 32-byte entropy is kept in a separate, display-only record purely
|
||||
// so the phrase can be reprinted. Addresses are unchanged from the
|
||||
// previous format, which expanded the stored entropy on every read.
|
||||
RawHDSeed seed64;
|
||||
if (!Bip39SeedFromEntropy(entropy, seed64))
|
||||
throw std::runtime_error(std::string(__func__) + ": BIP39 seed expansion failed");
|
||||
HDSeed seed(seed64);
|
||||
|
||||
// ORDER IS LOAD-BEARING: seed first, entropy second, never the reverse.
|
||||
// A crash between the two leaves a wallet with a seed and no phrase --
|
||||
// recoverable via z_exportwallet, merely inconvenient. The reverse order
|
||||
// would leave entropy with no seed; the next start would mint a
|
||||
// DIFFERENT seed while the wallet still held a phrase for the old one.
|
||||
// (GetMnemonicPhrase cross-checks the two and would refuse to print it,
|
||||
// but do not rely on that here.)
|
||||
if (!InstallHDSeed(seed, false, nCreationTime))
|
||||
throw std::runtime_error(std::string(__func__) + ": installing the mnemonic HD seed failed");
|
||||
if (!SetMnemonicEntropy(entropy))
|
||||
throw std::runtime_error(std::string(__func__) + ": storing the mnemonic entropy failed");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2713,10 +2734,29 @@ bool CWallet::SetHDSeedFromMnemonic(const std::string& phrase)
|
||||
if (!MnemonicToEntropy(phrase, entropy))
|
||||
return false;
|
||||
|
||||
// Store the BIP39 entropy as the HDSeed (SilentDragonXLite's on-disk
|
||||
// convention); the 64-byte seed is expanded from it on demand.
|
||||
HDSeed seed(entropy);
|
||||
return InstallHDSeed(seed, true, 1); // birthday = genesis for a restore
|
||||
// Store the EXPANDED 64-byte BIP39 seed as the HD seed (fMnemonic = false);
|
||||
// the entropy goes in its own record and is used only to reprint the phrase.
|
||||
// Derivation therefore reads the stored bytes directly on any binary, and
|
||||
// the resulting addresses are byte-identical to the previous format, which
|
||||
// expanded the stored entropy on every derivation. SilentDragonXLite
|
||||
// interop is unaffected: the same words still yield the same seed64.
|
||||
RawHDSeed seed64;
|
||||
if (!Bip39SeedFromEntropy(entropy, seed64))
|
||||
return false;
|
||||
HDSeed seed(seed64);
|
||||
|
||||
// Seed first, entropy second -- see the ordering note in GenerateNewSeed.
|
||||
if (!InstallHDSeed(seed, false, 1)) // birthday = genesis for a restore
|
||||
return false;
|
||||
|
||||
// Non-fatal on a restore, unlike GenerateNewSeed: the user already holds the
|
||||
// phrase (they just typed it), the seed is installed and the wallet is fully
|
||||
// functional; only z_exportmnemonic is lost.
|
||||
if (!SetMnemonicEntropy(entropy)) {
|
||||
LogPrintf("%s: WARNING: HD seed installed but the mnemonic entropy record could not be "
|
||||
"stored; z_exportmnemonic will be unavailable on this wallet\n", __func__);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CWallet::GetHDSeedForDerivation(HDSeed& seedOut) const
|
||||
|
||||
Reference in New Issue
Block a user