wallet: harden HD seed/chain persistence and record seed provenance

Autoshield sends mined coinbase to a seed-derived z-address, so the records that
decide how the seed derives keys become fund-safety critical. Three of them were
not treated that way.

InstallHDSeed wrote the seed record before the chain record, non-transactionally.
A crash between the two left a wallet holding a seed with no hdchain: on the next
load hdChain silently reverts to defaults, clearing fMnemonicSeed -- which
switches the derivation input from the expanded BIP39 seed to the raw entropy --
and resetting saplingAccountCounter. Write the chain first; the opposite torn
state is harmless and self-heals, because HaveHDSeed() is then false and init
installs again.

The hdchain record was read as a bare deserialise inside a catch-all with strErr
never set, and it is not a key type, so a corrupt record was downgraded to a
non-critical error and the node booted into the wrong key tree. Report it, track
whether it was read, and refuse to load a wallet that holds a seed but no
readable hdchain. Also preserve hdchain through a keys-only salvage, which would
otherwise drop it and produce exactly the state we now refuse.

GenerateNewSeed silently fell back to a random seed when BIP39 generation failed,
producing a wallet that looks mnemonic-capable but whose words can never be
exported and which no seed phrase can restore. A user who asked for -usemnemonic
now gets that or a hard failure.

Finally, record how the seed came to exist -- created on an empty wallet,
restored from -mnemonic/-hdseed, retrofitted onto a pre-existing seedless wallet,
or predating this record. A retrofitted seed is in no backup the user already
holds, so a feature that moves funds into addresses only that seed can re-derive
must not enable itself there by default. Nothing consumes this yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 03:18:35 +02:00
co-authored by Claude Opus 5
parent dad162a89a
commit a494eabdce
5 changed files with 147 additions and 24 deletions
+47 -2
View File
@@ -216,6 +216,12 @@ bool CWalletDB::WriteWitnessCacheSize(int64_t nWitnessCacheSize)
return Write(std::string("witnesscachesize"), nWitnessCacheSize);
}
bool CWalletDB::WriteHDSeedOrigin(int64_t nOrigin)
{
nWalletDBUpdated++;
return Write(std::string("hdseedorigin"), nOrigin);
}
bool CWalletDB::ReadPool(int64_t nPool, CKeyPool& keypool)
{
return Read(std::make_pair(std::string("pool"), nPool), keypool);
@@ -403,12 +409,15 @@ public:
bool fAnyUnordered;
int nFileVersion;
vector<uint256> vWalletUpgrade;
// True once a well-formed "hdchain" record has been loaded.
bool fHDChainRead;
CWalletScanState() {
nKeys = nCKeys = nKeyMeta = nZKeys = nCZKeys = nZKeyMeta = nSapZAddrs = 0;
fIsEncrypted = false;
fAnyUnordered = false;
nFileVersion = 0;
fHDChainRead = false;
}
};
@@ -833,9 +842,24 @@ ReadKeyValue(CWallet* pwallet, CDataStream& ssKey, CDataStream& ssValue,
else if (strType == "hdchain")
{
CHDChain chain;
ssValue >> chain;
try {
ssValue >> chain;
} catch (...) {
// Do not let this land in the "user can live with it" bucket:
// report it, and leave wss.fHDChainRead false so LoadWallet
// turns it into DB_CORRUPT when a seed is present.
strErr = "Error reading wallet database: hdchain record is corrupt";
return false;
}
wss.fHDChainRead = true;
pwallet->SetHDChain(chain, true);
}
else if (strType == "hdseedorigin")
{
int64_t nOrigin = 0;
ssValue >> nOrigin;
pwallet->hdSeedOrigin = (int)nOrigin;
}
} catch (...)
{
return false;
@@ -947,6 +971,21 @@ DBErrors CWalletDB::LoadWallet(CWallet* pwallet)
if (fNoncriticalErrors && result == DB_LOAD_OK)
result = DB_NONCRITICAL_ERROR;
// A wallet that holds an HD seed but whose hdchain record is missing or
// unreadable is NOT safe to run. hdChain would fall back to its SetNull
// defaults (walletdb.h:105-113), which (a) clears fMnemonicSeed, switching
// HD derivation from the 64-byte BIP39 seed to the raw 32-byte entropy
// (CWallet::GetHDSeedForDerivation, wallet.cpp:2615-2633) -> an entirely
// different key tree, and (b) resets saplingAccountCounter to 0, so the
// next GenerateNewSaplingZKey walks back over accounts that already exist.
// Both are silent today (a bad hdchain read is only DB_NONCRITICAL_ERROR).
// Fail loud instead of quietly deriving into the wrong tree.
if (pwallet->HaveHDSeed() && !wss.fHDChainRead)
{
LogPrintf("Error loading wallet.dat: HD seed present but the hdchain record is missing or corrupt\n");
return DB_CORRUPT;
}
// Any wallet corruption at all: skip any rewriting or
// upgrading, we don't want to make it worse.
if (result != DB_LOAD_OK)
@@ -1240,7 +1279,13 @@ bool CWalletDB::Recover(CDBEnv& dbenv, const std::string& filename, bool fOnlyKe
fReadOK = ReadKeyValue(&dummyWallet, ssKey, ssValue,
wss, strType, strErr);
}
if (!IsKeyType(strType))
// "hdchain" is not a key type, but it must survive a keys-only
// salvage: a recovered wallet that keeps its seed while losing its
// hdchain silently derives from a different key tree (fMnemonicSeed
// cleared -> raw entropy instead of the 64-byte BIP39 seed) and
// re-issues sapling accounts from 0. CWalletDB::LoadWallet now
// refuses such a wallet outright, so preserve the record here.
if (!IsKeyType(strType) && strType != "hdchain")
continue;
if (!fReadOK)
{