wallet: add an optional mnemonic-entropy secret to the key stores

Additive plumbing for storing a BIP39 entropy alongside the HD seed, mirroring
how the seed itself is handled through both key store layers: a plaintext member
on CBasicKeyStore, an encrypted pair on CCryptoKeyStore, encryption during the
unencrypted-to-encrypted conversion in EncryptKeys with the plaintext cleared,
and decryption on unlock.

RawHDSeed and CKeyingMaterial are the same secure_allocator vector type, so the
entropy passes through EncryptSecret/DecryptSecret with no adaptation.

Nothing calls this yet; there is no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 06:23:09 +02:00
parent 143c33de48
commit 2a7fdcc1db
4 changed files with 222 additions and 0 deletions

View File

@@ -68,6 +68,42 @@ bool CBasicKeyStore::GetHDSeed(HDSeed& seedOut) const
} }
} }
bool CBasicKeyStore::SetMnemonicEntropy(const RawHDSeed& entropy)
{
LOCK(cs_SpendingKeyStore);
if (entropy.empty()) {
// Never "install" nothing: HaveMnemonicEntropy() would stay false while
// the caller was told the call succeeded.
return false;
}
if (!mnemonicEntropy.empty()) {
// Same refuse-to-replace rule as SetHDSeed above, for a sharper reason:
// this is the printable form of the seed. If it could be swapped while
// hdSeed stayed put, the wallet would print a seed phrase that does not
// restore it -- strictly worse than printing none.
return false;
}
mnemonicEntropy = entropy;
return true;
}
bool CBasicKeyStore::HaveMnemonicEntropy() const
{
LOCK(cs_SpendingKeyStore);
return !mnemonicEntropy.empty();
}
bool CBasicKeyStore::GetMnemonicEntropy(RawHDSeed& entropyOut) const
{
LOCK(cs_SpendingKeyStore);
if (mnemonicEntropy.empty()) {
return false;
} else {
entropyOut = mnemonicEntropy;
return true;
}
}
bool CBasicKeyStore::AddKeyPubKey(const CKey& key, const CPubKey &pubkey) bool CBasicKeyStore::AddKeyPubKey(const CKey& key, const CPubKey &pubkey)
{ {
LOCK(cs_KeyStore); LOCK(cs_KeyStore);

View File

@@ -117,6 +117,12 @@ class CBasicKeyStore : public CKeyStore
{ {
protected: protected:
HDSeed hdSeed; HDSeed hdSeed;
// BIP39 entropy for a mnemonic-recoverable wallet, kept BESIDE hdSeed, never
// instead of it. hdSeed holds the bytes actually fed to derivation (the
// expanded 64-byte BIP39 seed on new wallets); this record exists only so the
// seed phrase can be reprinted. Empty on legacy and hex-restored wallets,
// which is a normal state, not an error.
RawHDSeed mnemonicEntropy;
KeyMap mapKeys; KeyMap mapKeys;
ScriptMap mapScripts; ScriptMap mapScripts;
WatchOnlySet setWatchOnly; WatchOnlySet setWatchOnly;
@@ -129,6 +135,18 @@ public:
bool SetHDSeed(const HDSeed& seed); bool SetHDSeed(const HDSeed& seed);
bool HaveHDSeed() const; bool HaveHDSeed() const;
bool GetHDSeed(HDSeed& seedOut) const; bool GetHDSeed(HDSeed& seedOut) const;
//! Mnemonic entropy: optional, present only on phrase-recoverable wallets.
//! Unlike the three seed accessors above -- which override pure virtuals on
//! CKeyStore (keystore.h:48-51) and therefore dispatch dynamically -- these
//! are plain non-virtual members: CKeyStore declares nothing for them and
//! nothing reaches the entropy through a base pointer. Every caller holds a
//! CWallet*, whose static type resolves to the CCryptoKeyStore overloads.
//! Do NOT add them to CKeyStore: that would force all four subclasses
//! (CBasicKeyStore, CCryptoKeyStore, CWallet, gtest's TestCCryptoKeyStore)
//! to implement them for zero call sites.
bool SetMnemonicEntropy(const RawHDSeed& entropy);
bool HaveMnemonicEntropy() const;
bool GetMnemonicEntropy(RawHDSeed& entropyOut) const;
bool AddKeyPubKey(const CKey& key, const CPubKey &pubkey); bool AddKeyPubKey(const CKey& key, const CPubKey &pubkey);
bool HaveKey(const CKeyID &address) const bool HaveKey(const CKeyID &address) const

View File

@@ -159,6 +159,34 @@ static bool DecryptHDSeed(
return seed.Fingerprint() == seedFp; return seed.Fingerprint() == seedFp;
} }
uint256 MnemonicEntropyFingerprint(const RawHDSeed& entropy)
{
// The local copy is not gratuitous -- see the declaration in crypter.h.
// It is secure_allocator-backed, so it is memory_cleanse()d on destruction
// (support/allocators/secure.h:45-52).
RawHDSeed tmp(entropy);
return HDSeed(tmp).Fingerprint();
}
static bool DecryptMnemonicEntropy(
const CKeyingMaterial& vMasterKey,
const std::vector<unsigned char>& vchCryptedSecret,
const uint256& entropyFp,
RawHDSeed& entropyOut)
{
CKeyingMaterial vchSecret;
// Use the entropy's fingerprint as IV, mirroring DecryptHDSeed above.
if (!DecryptSecret(vMasterKey, vchCryptedSecret, entropyFp, vchSecret))
return false;
// RawHDSeed and CKeyingMaterial are the SAME type (both are
// std::vector<unsigned char, secure_allocator<unsigned char>>), so this is a
// plain copy of the same bytes, not a reinterpretation.
entropyOut = vchSecret;
return MnemonicEntropyFingerprint(entropyOut) == entropyFp;
}
static bool DecryptKey(const CKeyingMaterial& vMasterKey, const std::vector<unsigned char>& vchCryptedSecret, const CPubKey& vchPubKey, CKey& key) static bool DecryptKey(const CKeyingMaterial& vMasterKey, const std::vector<unsigned char>& vchCryptedSecret, const CPubKey& vchPubKey, CKey& key)
{ {
CKeyingMaterial vchSecret; CKeyingMaterial vchSecret;
@@ -233,6 +261,19 @@ bool CCryptoKeyStore::Unlock(const CKeyingMaterial& vMasterKeyIn)
keyPass = true; keyPass = true;
} }
} }
// Deliberately NO arm here for cryptedMnemonicEntropy. This function is
// the "some keys decrypt but not all" corruption detector and a keyFail
// ends at the assert(false) below. The mnemonic entropy is an optional,
// non-spending, display-only record: legacy wallets, hex-restored
// wallets and every wallet predating this feature legitimately have a
// seed and no entropy, and a wallet whose every key decrypts while its
// entropy does not is not corrupt in any sense that should abort the
// process -- it simply cannot print its seed phrase. It is decrypted
// lazily in GetMnemonicEntropy() instead, so that case becomes a false
// return from one RPC while derivation and spending (which read the
// seed, not the entropy) carry on. Note the arm above caches nothing
// either -- `seed` is discarded; it only votes keyPass/keyFail -- so
// nothing is lost by omitting one here.
CryptedKeyMap::const_iterator mi = mapCryptedKeys.begin(); CryptedKeyMap::const_iterator mi = mapCryptedKeys.begin();
for (; mi != mapCryptedKeys.end(); ++mi) for (; mi != mapCryptedKeys.end(); ++mi)
{ {
@@ -344,6 +385,82 @@ bool CCryptoKeyStore::GetHDSeed(HDSeed& seedOut) const
return DecryptHDSeed(vMasterKey, cryptedHDSeed.second, cryptedHDSeed.first, seedOut); return DecryptHDSeed(vMasterKey, cryptedHDSeed.second, cryptedHDSeed.first, seedOut);
} }
bool CCryptoKeyStore::SetMnemonicEntropy(const RawHDSeed& entropy)
{
{
LOCK(cs_SpendingKeyStore);
if (!IsCrypted()) {
return CBasicKeyStore::SetMnemonicEntropy(entropy);
}
if (IsLocked())
return false;
if (entropy.empty())
return false;
std::vector<unsigned char> vchCryptedSecret;
// Use the entropy's fingerprint as IV
// TODO: Handle this properly when we make encryption a supported feature
auto entropyFp = MnemonicEntropyFingerprint(entropy);
// RawHDSeed IS CKeyingMaterial, so `entropy` binds directly here.
if (!EncryptSecret(vMasterKey, entropy, entropyFp, vchCryptedSecret))
return false;
// Virtual: this calls into CWallet to store the crypted entropy to disk.
if (!SetCryptedMnemonicEntropy(entropyFp, vchCryptedSecret))
return false;
}
return true;
}
bool CCryptoKeyStore::SetCryptedMnemonicEntropy(
const uint256& entropyFp,
const std::vector<unsigned char>& vchCryptedSecret)
{
{
LOCK(cs_SpendingKeyStore);
if (!IsCrypted()) {
return false;
}
if (!cryptedMnemonicEntropy.first.IsNull()) {
// Don't allow existing entropy to be changed, mirroring
// SetCryptedHDSeed: a phrase that no longer matches the installed
// seed is worse than no phrase at all.
return false;
}
cryptedMnemonicEntropy = std::make_pair(entropyFp, vchCryptedSecret);
}
return true;
}
bool CCryptoKeyStore::HaveMnemonicEntropy() const
{
LOCK(cs_SpendingKeyStore);
if (!IsCrypted())
return CBasicKeyStore::HaveMnemonicEntropy();
return !cryptedMnemonicEntropy.second.empty();
}
bool CCryptoKeyStore::GetMnemonicEntropy(RawHDSeed& entropyOut) const
{
LOCK(cs_SpendingKeyStore);
if (!IsCrypted())
return CBasicKeyStore::GetMnemonicEntropy(entropyOut);
if (cryptedMnemonicEntropy.second.empty())
return false;
// Decrypted lazily, on demand, and deliberately NOT in Unlock(): see the
// comment there for why the entropy must not vote in the keyPass/keyFail
// corruption detector.
return DecryptMnemonicEntropy(vMasterKey, cryptedMnemonicEntropy.second,
cryptedMnemonicEntropy.first, entropyOut);
}
bool CCryptoKeyStore::AddKeyPubKey(const CKey& key, const CPubKey &pubkey) bool CCryptoKeyStore::AddKeyPubKey(const CKey& key, const CPubKey &pubkey)
{ {
{ {
@@ -505,6 +622,30 @@ bool CCryptoKeyStore::EncryptKeys(CKeyingMaterial& vMasterKeyIn)
} }
hdSeed = HDSeed(); hdSeed = HDSeed();
} }
if (!mnemonicEntropy.empty()) {
{
std::vector<unsigned char> vchCryptedSecret;
// Use the entropy's fingerprint as IV
// TODO: Handle this properly when we make encryption a supported feature
auto entropyFp = MnemonicEntropyFingerprint(mnemonicEntropy);
if (!EncryptSecret(vMasterKeyIn, mnemonicEntropy, entropyFp, vchCryptedSecret)) {
return false;
}
// Virtual: calls into CWallet to store the crypted entropy to disk.
if (!SetCryptedMnemonicEntropy(entropyFp, vchCryptedSecret)) {
return false;
}
}
// Drop the plaintext. swap() rather than `= RawHDSeed()`: assigning a
// shorter vector destroys the elements but KEEPS the capacity, so the
// old bytes would linger in the locked buffer. swap() hands the buffer
// to a temporary whose destructor deallocates it, and
// secure_allocator::deallocate memory_cleanse()s
// (support/allocators/secure.h:45-52). The `hdSeed = HDSeed();` above
// has the same weakness but cannot be fixed here: HDSeed's raw vector
// is private with no swap accessor (zip32.h:23-33).
RawHDSeed().swap(mnemonicEntropy);
}
BOOST_FOREACH(KeyMap::value_type& mKey, mapKeys) BOOST_FOREACH(KeyMap::value_type& mKey, mapKeys)
{ {
const CKey &key = mKey.second; const CKey &key = mKey.second;

View File

@@ -138,6 +138,21 @@ public:
} }
}; };
/** Keystore which keeps the private keys encrypted.
* It derives from the basic key store, which is used if no encryption is active.
*/
//! Fingerprint of a BIP39 entropy blob, computed exactly as HDSeed::Fingerprint
//! does (BLAKE2b, ZCASH_HD_SEED_FP_PERSONAL). It is an IV / integrity tag and a
//! wallet.dat record key -- never a key-derivation input. Declared here rather
//! than duplicated because three call sites must produce identical bytes:
//! CCryptoKeyStore::SetMnemonicEntropy, CCryptoKeyStore::EncryptKeys, and
//! CWallet::SetMnemonicEntropy (which keys the plaintext record with it).
//!
//! It takes a copy internally on purpose: HDSeed's constructor takes a NON-const
//! RawHDSeed& (zip32.h:28), so HDSeed(entropy).Fingerprint() does not compile
//! against a const reference or a member read from a const method.
uint256 MnemonicEntropyFingerprint(const RawHDSeed& entropy);
/** Keystore which keeps the private keys encrypted. /** Keystore which keeps the private keys encrypted.
* It derives from the basic key store, which is used if no encryption is active. * It derives from the basic key store, which is used if no encryption is active.
*/ */
@@ -145,6 +160,10 @@ class CCryptoKeyStore : public CBasicKeyStore
{ {
private: private:
std::pair<uint256, std::vector<unsigned char>> cryptedHDSeed; std::pair<uint256, std::vector<unsigned char>> cryptedHDSeed;
// Encrypted mnemonic entropy, shaped exactly like cryptedHDSeed above:
// .first is the entropy's fingerprint (AES IV + integrity tag on decrypt),
// .second is the ciphertext.
std::pair<uint256, std::vector<unsigned char>> cryptedMnemonicEntropy;
CryptedKeyMap mapCryptedKeys; CryptedKeyMap mapCryptedKeys;
//CryptedSproutSpendingKeyMap mapCryptedSproutSpendingKeys; //CryptedSproutSpendingKeyMap mapCryptedSproutSpendingKeys;
CryptedSaplingSpendingKeyMap mapCryptedSaplingSpendingKeys; CryptedSaplingSpendingKeyMap mapCryptedSaplingSpendingKeys;
@@ -194,6 +213,14 @@ public:
bool SetHDSeed(const HDSeed& seed); bool SetHDSeed(const HDSeed& seed);
bool HaveHDSeed() const; bool HaveHDSeed() const;
bool GetHDSeed(HDSeed& seedOut) const; bool GetHDSeed(HDSeed& seedOut) const;
//! Mnemonic entropy, mirroring the four HD-seed members above.
//! SetCryptedMnemonicEntropy MUST stay virtual for the same reason
//! SetCryptedHDSeed is: CWallet overrides it to persist the record, and
//! SetMnemonicEntropy() below reaches that override through the vtable.
virtual bool SetCryptedMnemonicEntropy(const uint256& entropyFp, const std::vector<unsigned char> &vchCryptedSecret);
bool SetMnemonicEntropy(const RawHDSeed& entropy);
bool HaveMnemonicEntropy() const;
bool GetMnemonicEntropy(RawHDSeed& entropyOut) const;
virtual bool AddCryptedKey(const CPubKey &vchPubKey, const std::vector<unsigned char> &vchCryptedSecret); virtual bool AddCryptedKey(const CPubKey &vchPubKey, const std::vector<unsigned char> &vchCryptedSecret);
bool AddKeyPubKey(const CKey& key, const CPubKey &pubkey); bool AddKeyPubKey(const CKey& key, const CPubKey &pubkey);