fix(lite): classify imported keys before calling the backend

importKeyCore guessed the key type from its first character, then
retried a failed import with the other command. That retry is what sent
shielded keys to the backend's transparent-key decoder, which panicked
on them and poisoned the wallet lock. A WIF typo that was still
valid base58 also reached the backend unchecked.

- The key is classified first, and exactly one backend command runs, or
  none:
  - secret-extended-key-... and zxview... go to "import";
  - a Base58Check WIF with version 0xBC or 0x80 and the compressed flag
    goes to "timport";
  - everything else is refused before any backend call.
  There is never a retry with the other command. A testnet (0xEF) WIF
  is not accepted, because the Lite app is mainnet-only.
- New fixed, untranslated reasons: "uncompressed transparent keys are
  not supported by the Lite wallet", a full-node incoming viewing key
  (zivks) is explained as such, and anything else is "unrecognized key
  format". The key is never echoed.
- The backend's refusal while the wallet is encrypted now maps to "wallet
  is encrypted - remove encryption to import a private key". It is
  checked before "locked", because unlocking would not help.
- base58Decode reserves its buffers up front, and it and
  decodeBase58Check wipe their copies of the decoded bytes with
  sodium_memzero on every return path, since the input can be a private
  key.
- exportSeed rejects the all-zero-entropy phrase (23 x "abandon" + "art").
  A backend built before the seed-export fix returns it for a wallet
  whose lock failed halfway (seed wiped, wallet still unlocked); it is
  never a real backup and must not seed a chat identity.
- An open that the backend recovered from the .bak ("OK: recovered from
  backup; damaged file kept as <name>") is written to the lite log.

Tests: the retry control now expects one "import" and no "timport", and
"typo-key" makes no backend call; the controller's transparent-import
test uses a real compressed WIF. A new classifier test covers both WIF
versions, the shielded and viewing keys, a single call on failure, the
uncompressed, zivks, testnet, bad-flag and bad-checksum refusals, the
encrypted reason and its lite-log line, the zero-entropy seed and the
recovery log line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:22:00 -05:00
co-authored by Claude Opus 5.5
parent b6cffdf942
commit e52c9e1b92
4 changed files with 295 additions and 29 deletions
+187 -6
View File
@@ -8249,10 +8249,12 @@ void testLiteWalletControllerM4()
EXPECT_TRUE(r.detail.find("zs1imported") != std::string::npos);
}
// import transparent WIF (begins with K) -> "timport"
// import transparent WIF (Base58Check, version 0x80, compressed; key = 1) -> "timport"
{
std::vector<std::uint8_t> payload;
EXPECT_TRUE(dragonx::util::decodeBase58Check("KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn", payload));
auto c = openController();
const auto r = c->importKey("Kx1faketransparentwif");
const auto r = c->importKey("KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn");
EXPECT_TRUE(r.ok);
}
@@ -9750,7 +9752,8 @@ void testLiteWalletControllerRetireStopsOpFollowUps()
EXPECT_EQ(n, 1);
dragonx::test::g_liteFakeLogEnabled = false;
}
{ // control: an unrecognized key is retried with the other import command
{ // control: a failed shielded import calls the backend once and is never retried with the other command;
// a key no classifier recognizes calls neither command
auto c = openController();
dragonx::test::g_liteFakeImportFails = true;
int n = 0; bool ok = true;
@@ -9762,12 +9765,22 @@ void testLiteWalletControllerRetireStopsOpFollowUps()
std::vector<std::string> log;
drainLog(log);
EXPECT_EQ(dragonx::test::liteFakeCount(log, "import"), 1L);
EXPECT_EQ(dragonx::test::liteFakeCount(log, "timport"), 1L);
EXPECT_EQ(dragonx::test::liteFakeCount(log, "timport"), 0L);
int n2 = 0; LiteImportResult got; got.ok = true;
EXPECT_TRUE(c->importKeyAsync("typo-key", [&](const LiteImportResult& r) { ++n2; got = r; }));
EXPECT_TRUE(c->waitWalletOpsIdle(std::chrono::milliseconds(3000)));
c->pumpWalletOps();
EXPECT_EQ(n2, 1);
EXPECT_FALSE(got.ok);
EXPECT_EQ(got.error, std::string("unrecognized key format"));
drainLog(log);
EXPECT_EQ(dragonx::test::liteFakeCount(log, "import"), 1L);
EXPECT_EQ(dragonx::test::liteFakeCount(log, "timport"), 0L);
c.reset();
dragonx::test::g_liteFakeImportFails = false;
dragonx::test::g_liteFakeLogEnabled = false;
}
{ // the first import attempt in flight (and failing) -> retire -> the fallback never runs
{ // the single import call in flight (and failing) -> retire -> delivered once, never retried with timport
auto c = openController();
dragonx::test::g_liteFakeImportFails = true;
dragonx::test::g_liteFakeBlockImport = true;
@@ -9825,7 +9838,7 @@ void testLiteWalletControllerImportErrorNeverRepeatsKey()
return false;
};
{ // synchronous: both attempts fail; neither the result nor the log carries the key
{ // synchronous: the single import call fails; neither the result nor the log carries the key
const auto r = c.importKey("secret-extended-key-typoMARKSYNC");
EXPECT_FALSE(r.ok);
EXPECT_EQ(r.error, std::string("unrecognized key format"));
@@ -9846,6 +9859,173 @@ void testLiteWalletControllerImportErrorNeverRepeatsKey()
dragonx::test::g_liteFakeImportFails = false;
}
// C251/C252 (X1): importKeyCore classifies the key, then calls exactly one backend command or none, never the other
// command as a retry. Refusals are fixed reasons. Also the encrypted-wallet reason (s1-D4), the zero-entropy seed
// guard and the logged backup recovery (s2-D14).
void testLiteWalletControllerImportClassifier()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
const auto conn = defaultLiteConnectionSettings();
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeEncrypted = false;
dragonx::test::g_liteFakeLocked = false;
dragonx::test::g_liteFakeWalletExists = true;
const auto logMentions = [](const std::string& needle) {
for (const auto& line : LiteDiagnostics::instance().snapshot())
if (line.find(needle) != std::string::npos) return true;
return false;
};
auto c = std::make_unique<LiteWalletController>(caps, conn,
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
EXPECT_TRUE(c->createWallet(LiteWalletCreateRequest{}).walletReady);
for (int i = 0; i < 400 && !c->syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5));
struct Outcome { LiteImportResult r; long imports = 0; long timports = 0; };
const auto importCounting = [&c](const std::string& key) {
(void)dragonx::test::liteFakeTakeLog();
dragonx::test::g_liteFakeLogEnabled = true;
Outcome o;
o.r = c->importKey(key);
dragonx::test::g_liteFakeLogEnabled = false;
const auto log = dragonx::test::liteFakeTakeLog();
o.imports = dragonx::test::liteFakeCount(log, "import");
o.timports = dragonx::test::liteFakeCount(log, "timport");
return o;
};
const std::string kCompressed80 = "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn"; // key = 1, 0x80
const std::string kCompressedBC = "Up1YVLk7uuErCHVQyFCtfinZngmdwfyfc47WCQ8oJxgowjVzNeqs"; // key = 1, 0xBC
const std::string kUncompressed = "5HpHagT65TZzG1PH3CSu63k8DbpvD8s5ip4nEB3kEsreAnchuDf"; // key = 1, 0x80
const std::string kTestnet = "cMahea7zqjxrtgAbB7LSGbcQUr1uX1ojuat9jZodMN87JcbXMTcA"; // key = 1, 0xEF
const std::string kBadFlag = "Up1YVLk7uuErCHVQyFCtfinZngmdwfyfc47WCQ8oJxgowjcCjSha"; // 0xBC, flag 0x02
{
std::vector<std::uint8_t> p;
EXPECT_TRUE(dragonx::util::decodeBase58Check(kCompressed80, p));
EXPECT_TRUE(p.size() == 34 && p[0] == 0x80 && p[33] == 0x01);
EXPECT_TRUE(dragonx::util::decodeBase58Check(kCompressedBC, p));
EXPECT_TRUE(p.size() == 34 && p[0] == 0xBC && p[33] == 0x01);
EXPECT_TRUE(dragonx::util::decodeBase58Check(kUncompressed, p));
EXPECT_EQ(p.size(), std::size_t(33));
EXPECT_TRUE(dragonx::util::decodeBase58Check(kTestnet, p));
EXPECT_TRUE(p.size() == 34 && p[0] == 0xEF);
EXPECT_TRUE(dragonx::util::decodeBase58Check(kBadFlag, p));
EXPECT_TRUE(p.size() == 34 && p[33] == 0x02);
// one changed character breaks the checksum
EXPECT_FALSE(dragonx::util::decodeBase58Check(kCompressed80.substr(0, kCompressed80.size() - 1) + "m", p));
}
{ // compressed WIFs (0x80 and the DragonX 0xBC) -> "timport" only
for (const auto& wif : {kCompressed80, kCompressedBC}) {
const auto o = importCounting(wif);
EXPECT_TRUE(o.r.ok);
EXPECT_EQ(o.timports, 1L);
EXPECT_EQ(o.imports, 0L);
}
}
{ // shielded spending key and extended full viewing key -> "import" only
for (const char* key : {"secret-extended-key-main1fakeshieldedkey", "zxviews1fakeviewingkey"}) {
const auto o = importCounting(key);
EXPECT_TRUE(o.r.ok);
EXPECT_EQ(o.imports, 1L);
EXPECT_EQ(o.timports, 0L);
}
}
{ // a failed import calls the backend exactly once (it was retried with the other command before)
dragonx::test::g_liteFakeImportFails = true;
const auto z = importCounting("secret-extended-key-main1typo");
EXPECT_FALSE(z.r.ok);
EXPECT_EQ(z.imports + z.timports, 1L);
const auto t = importCounting(kCompressed80);
EXPECT_FALSE(t.r.ok);
EXPECT_EQ(t.timports, 1L);
EXPECT_EQ(t.imports, 0L);
dragonx::test::g_liteFakeImportFails = false;
}
{ // refused before any backend call, with a fixed reason
LiteDiagnostics::instance().clear();
const auto u = importCounting(kUncompressed);
EXPECT_FALSE(u.r.ok);
EXPECT_EQ(u.r.error, std::string("uncompressed transparent keys are not supported by the Lite wallet"));
EXPECT_EQ(u.imports + u.timports, 0L);
EXPECT_TRUE(logMentions("Key import failed: uncompressed transparent keys are not supported"));
const auto ivk = importCounting("zivks1fakeincomingviewingkeyfromthefullnode");
EXPECT_FALSE(ivk.r.ok);
EXPECT_TRUE(ivk.r.error.find("full-node incoming viewing key (zivks)") != std::string::npos);
EXPECT_EQ(ivk.imports + ivk.timports, 0L);
for (const std::string& bad : {kTestnet, kBadFlag, kCompressed80.substr(0, kCompressed80.size() - 1) + "m",
std::string("typo-key"), std::string("Kx1faketransparentwif")}) {
const auto o = importCounting(bad);
EXPECT_FALSE(o.r.ok);
EXPECT_EQ(o.r.error, std::string("unrecognized key format"));
EXPECT_EQ(o.imports + o.timports, 0L);
}
}
{ // the backend's encrypted-wallet refusal maps to its own reason, ahead of "locked"
LiteDiagnostics::instance().clear();
dragonx::test::g_liteFakeImportEncrypted = true;
const auto z = importCounting("secret-extended-key-main1fakeshieldedkey");
EXPECT_FALSE(z.r.ok);
EXPECT_EQ(z.r.error, std::string("wallet is encrypted - remove encryption to import a private key"));
EXPECT_EQ(z.imports, 1L);
const auto t = importCounting(kCompressedBC);
EXPECT_FALSE(t.r.ok);
EXPECT_EQ(t.r.error, std::string("wallet is encrypted - remove encryption to import a private key"));
EXPECT_EQ(t.timports, 1L);
EXPECT_TRUE(logMentions("Key import failed: wallet is encrypted"));
dragonx::test::g_liteFakeImportEncrypted = false;
}
{ // seed export never returns the all-zero-entropy phrase (a stale backend's answer for a locked wallet)
std::string zero;
for (int i = 0; i < 23; ++i) zero += "abandon ";
dragonx::test::g_liteFakeSeedPhrase = zero + "art";
auto s = c->exportSeed();
EXPECT_FALSE(s.ok);
EXPECT_TRUE(s.seedPhrase.empty());
EXPECT_FALSE(s.error.empty());
// near misses are real phrases
dragonx::test::g_liteFakeSeedPhrase = zero + "about";
s = c->exportSeed();
EXPECT_TRUE(s.ok);
dragonx::test::g_liteFakeSeedPhrase = zero + "art art";
s = c->exportSeed();
EXPECT_TRUE(s.ok);
dragonx::test::g_liteFakeSeedPhrase.clear();
s = c->exportSeed();
EXPECT_TRUE(s.ok);
EXPECT_EQ(s.seedPhrase, std::string("fake seed phrase words"));
}
c.reset();
{ // s2-D14: an open that recovered from the backup is logged; a create never logs its response (the seed)
LiteDiagnostics::instance().clear();
dragonx::test::g_liteFakeOpenRecoveredNote =
"recovered from backup; damaged file kept as silentdragonxlite-wallet.dat.corrupt-1700000000";
LiteWalletController opened(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
EXPECT_TRUE(opened.beginOpenExisting());
for (int i = 0; i < 400 && opened.openInProgress(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5));
opened.pumpAsyncOpen();
EXPECT_TRUE(opened.walletOpen());
EXPECT_TRUE(logMentions("OK: recovered from backup; damaged file kept as silentdragonxlite-wallet.dat.corrupt-"));
dragonx::test::g_liteFakeOpenRecoveredNote.clear();
}
{
LiteDiagnostics::instance().clear();
LiteWalletController created(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
EXPECT_TRUE(created.beginCreateWallet());
for (int i = 0; i < 400 && created.openInProgress(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5));
created.pumpAsyncOpen();
EXPECT_TRUE(created.walletOpen());
EXPECT_FALSE(logMentions("fake seed phrase words"));
for (const auto& line : LiteDiagnostics::instance().snapshot()) {
const std::string msg = line.size() > 10 ? line.substr(10) : line; // drop the "HH:MM:SS " stamp
EXPECT_TRUE(msg != " " && msg != " OK");
}
}
}
void testLiteWalletControllerEncryptsBeforeSyncStarts()
{
using namespace dragonx::wallet;
@@ -15554,6 +15734,7 @@ int main()
testLiteWalletControllerRetire();
testLiteWalletControllerRetireStopsOpFollowUps();
testLiteWalletControllerImportErrorNeverRepeatsKey();
testLiteWalletControllerImportClassifier();
testLiteWalletControllerEncryptsBeforeSyncStarts();
testLiteWalletControllerWorkerBacksOffOnSyncReadFailure();
testLiteWalletControllerDestroyWithBlockedOp();