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:
+187
-6
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user