From e52c9e1b921d6cbac2c1ab1cadc6eef34f20d5e3 Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 12 Oct 2026 13:22:00 -0500 Subject: [PATCH] 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 ") 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 --- src/util/address_validation.cpp | 29 +++- src/wallet/lite_wallet_controller.cpp | 84 ++++++++--- tests/fake_lite_backend.h | 18 ++- tests/test_phase4.cpp | 193 +++++++++++++++++++++++++- 4 files changed, 295 insertions(+), 29 deletions(-) diff --git a/src/util/address_validation.cpp b/src/util/address_validation.cpp index f725053..7abd144 100644 --- a/src/util/address_validation.cpp +++ b/src/util/address_validation.cpp @@ -21,13 +21,18 @@ constexpr const char* kBase58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; constexpr const char* kBech32 = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; -// Decode a Base58 string to bytes (big-endian), preserving leading-zero bytes. +// Decode a Base58 string to bytes (big-endian), preserving leading-zero bytes. The input may be a private key +// (WIF), so the scratch buffer is reserved up front (no reallocation leaves copies behind) and wiped on return. bool base58Decode(const std::string& s, std::vector& out) { std::vector bytes; // little-endian during accumulation + bytes.reserve(s.size()); // a base58 string never decodes to more bytes than it has characters for (char ch : s) { const char* p = std::strchr(kBase58, ch); - if (p == nullptr || ch == '\0') return false; + if (p == nullptr || ch == '\0') { + if (!bytes.empty()) sodium_memzero(bytes.data(), bytes.size()); + return false; + } int carry = static_cast(p - kBase58); for (auto& b : bytes) { carry += static_cast(b) * 58; @@ -39,12 +44,16 @@ bool base58Decode(const std::string& s, std::vector& out) carry >>= 8; } } - std::vector result; + std::size_t leadingZeros = 0; for (char ch : s) { // leading '1's map to leading zero bytes - if (ch == '1') result.push_back(0); + if (ch == '1') ++leadingZeros; else break; } + std::vector result; + result.reserve(leadingZeros + bytes.size()); + result.assign(leadingZeros, 0); result.insert(result.end(), bytes.rbegin(), bytes.rend()); + if (!bytes.empty()) sodium_memzero(bytes.data(), bytes.size()); out = std::move(result); return true; } @@ -81,15 +90,23 @@ bool decodeBase58Check(const std::string& s, std::vector& payloadO if (s.size() < 5 || s.size() > 256) return false; std::vector data; if (!base58Decode(s, data)) return false; - if (data.size() < 5) return false; // need at least version(1) + checksum(4) + // `data` may hold a private key (a WIF): wipe it on every return path. + if (data.size() < 5) { // need at least version(1) + checksum(4) + if (!data.empty()) sodium_memzero(data.data(), data.size()); + return false; + } const std::size_t payloadLen = data.size() - 4; unsigned char h1[crypto_hash_sha256_BYTES]; unsigned char h2[crypto_hash_sha256_BYTES]; crypto_hash_sha256(h1, data.data(), payloadLen); crypto_hash_sha256(h2, h1, sizeof(h1)); - if (std::memcmp(h2, data.data() + payloadLen, 4) != 0) return false; + if (std::memcmp(h2, data.data() + payloadLen, 4) != 0) { + sodium_memzero(data.data(), data.size()); + return false; + } payloadOut.assign(data.begin(), data.begin() + payloadLen); + sodium_memzero(data.data(), data.size()); return true; } diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index 1f39078..cb36597 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -7,6 +7,7 @@ #include "lite_diagnostics.h" #include "lite_result_parsers.h" #include "../data/wallet_state.h" +#include "../util/address_validation.h" #include #include @@ -40,6 +41,24 @@ std::string extractJsonError(const nlohmann::json& value) return e.is_string() ? e.get() : e.dump(); } +// True for the BIP39 phrase of all-zero entropy (23 x "abandon", then "art"), compared word by word in place so the +// secret is never copied. A real wallet has this seed with probability 2^-256. +bool isZeroEntropySeedPhrase(const std::string& phrase) +{ + const auto isSpace = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }; + std::size_t i = 0, words = 0; + while (true) { + while (i < phrase.size() && isSpace(phrase[i])) ++i; + if (i == phrase.size()) break; + std::size_t end = i; + while (end < phrase.size() && !isSpace(phrase[end])) ++end; + ++words; + if (words > 24 || phrase.compare(i, end - i, words < 24 ? "abandon" : "art") != 0) return false; + i = end; + } + return words == 24; +} + // Parse a send/shield response: success is {"txid":".."}, failure is {"error":".."} (NOT an // "Error:"-prefixed string), and malformed args make the backend return plain-text help. LiteBroadcastResult parseBroadcastResponse(const LiteBridgeStringResult& bridgeCall) @@ -169,6 +188,8 @@ std::string liteImportFailureReason(const std::string& backendError) { const auto has = [&backendError](const char* s) { return backendError.find(s) != std::string::npos; }; if (has("was not recognized")) return "unrecognized key format"; + // Checked before "locked": unlocking does not help, the backend refuses private-key imports while encrypted. + if (has("encrypted")) return "wallet is encrypted - remove encryption to import a private key"; if (has("locked")) return "wallet is locked"; if (has("already exists")) return "key already exists"; if (has("decode")) return "key could not be decoded"; @@ -178,15 +199,31 @@ std::string liteImportFailureReason(const std::string& backendError) // `key` must be non-empty; it is wiped before returning. A failure's error is a fixed reason, never backend text. LiteImportResult importKeyCore(LiteClientBridge& bridge, std::string& key, const std::atomic* retired = nullptr) { - // Transparent WIFs begin with U/5/K/L (TImportCommand); shielded keys begin with - // "secret-..." / viewing keys "zxview...", so this prefix check usually won't collide. + // Classify first, then call exactly one backend command, or none (C251). There is no retry with the other + // command: the wrong command never imported the key anyway, and that retry is what fed shielded keys to the + // backend's transparent-key decoder. // NB: the lite/SDXL backend's viewing key is an *extended full* viewing key ("zxviews…", // hrp_sapling_viewing_key), which is genuinely correct here — do NOT "harmonize" this with the // full node, whose z_importviewingkey takes an *incoming* viewing key ("zivks…") instead. The - // two variants accept different viewing-key forms; a VK is not portable between them. Regardless, - // the two-command fallback below means a mis-guessed prefix never rejects an otherwise-valid key. - const char first = key[0]; - const bool transparentFirst = (first == 'U' || first == '5' || first == 'K' || first == 'L'); + // two variants accept different viewing-key forms; a VK is not portable between them, so a + // "zivks…" key is refused here with a fixed reason. + enum class Kind { Shielded, FullViewing, FullNodeIvk, TransparentWif, UncompressedWif, Unknown }; + Kind kind = Kind::Unknown; + if (key.rfind("secret-extended-key-", 0) == 0) kind = Kind::Shielded; + else if (key.rfind("zxview", 0) == 0) kind = Kind::FullViewing; // SDXL extended full viewing key + else if (key.rfind("zivks", 0) == 0) kind = Kind::FullNodeIvk; // full-node incoming VK: not importable + else { + // A transparent WIF is Base58Check with the mainnet (0xBC) or Bitcoin-style (0x80) version byte and the + // compressed-key flag. The Lite app is mainnet-only, so a testnet (0xEF) WIF is not accepted. + std::vector payload; + if (util::decodeBase58Check(key, payload)) { + if (payload.size() == 34 && payload[33] == 0x01 && (payload[0] == 0xBC || payload[0] == 0x80)) + kind = Kind::TransparentWif; + else if (payload.size() == 33) + kind = Kind::UncompressedWif; + } + if (!payload.empty()) sodium_memzero(payload.data(), payload.size()); + } auto runImport = [&](const char* command) -> LiteImportResult { LiteImportResult r; @@ -209,18 +246,23 @@ LiteImportResult importKeyCore(LiteClientBridge& bridge, std::string& key, const return r; }; - LiteImportResult out = runImport(transparentFirst ? "timport" : "import"); - if (!out.ok && liteOpRetired(retired)) { - out.error = kLiteOpRetiredError; // the fallback would import the key on the NEXT server's client - } else if (!out.ok) { - // The single-char heuristic can mis-route (e.g. testnet/regtest WIFs). Try the other - // command before giving up — the wrong command rejects the key (each validates the - // encoding), it never imports it as the wrong type. - LiteImportResult alt = runImport(transparentFirst ? "import" : "timport"); - if (alt.ok) out = alt; + LiteImportResult out; + switch (kind) { + case Kind::Shielded: + case Kind::FullViewing: out = runImport("import"); break; + case Kind::TransparentWif: out = runImport("timport"); break; + case Kind::FullNodeIvk: + out.error = "this is a full-node incoming viewing key (zivks); the Lite wallet needs an extended viewing key " + "(zxviews)"; + break; + case Kind::UncompressedWif: + out.error = "uncompressed transparent keys are not supported by the Lite wallet"; + break; + default: out.error = "unrecognized key format"; break; } + if (!out.ok && liteOpRetired(retired)) out.error = kLiteOpRetiredError; if (!out.ok) liteLog("Key import failed: " + out.error); // a fixed reason: the backend's text can repeat the key - secureWipeLiteSecret(key); // wipe our copy after both attempts + secureWipeLiteSecret(key); // wipe our copy return out; } @@ -535,6 +577,9 @@ bool LiteWalletController::beginAsyncLifecycle(bool create) if (create) secureWipeLiteSecret(call.value); if (ready) { liteLog(" " + url + ": connected"); + // s2-D14: an open that fell back to the backup reports "OK: recovered from backup; damaged file kept + // as ". Never for create: its value was the seed (wiped above). + if (!create && call.value != "OK") liteLog(" " + call.value); outcome.ok = true; outcome.serverUrl = url; break; @@ -1156,6 +1201,13 @@ LiteSeedResult LiteWalletController::exportSeed() if (j.contains("birthday") && j.at("birthday").is_number_unsigned()) { out.birthday = j.at("birthday").get(); } + if (isZeroEntropySeedPhrase(out.seedPhrase)) { + // A backend built before the seed-export fix returns this for a locked wallet whose seed is + // wiped. It is never the real backup, so it must not be shown or used to derive anything. + secureWipeLiteSecret(out.seedPhrase); + out.error = "backend returned the all-zero placeholder seed (is the wallet locked?)"; + return out; + } out.ok = !out.seedPhrase.empty(); if (!out.ok) out.error = "backend returned an empty seed"; return out; diff --git a/tests/fake_lite_backend.h b/tests/fake_lite_backend.h index 9edb823..9612985 100644 --- a/tests/fake_lite_backend.h +++ b/tests/fake_lite_backend.h @@ -49,6 +49,7 @@ inline std::atomic g_liteFakeBalanceEntered{0}; // calls that reached " inline std::atomic g_liteFakeBlockEncrypt{false}; // "encrypt" blocks (before changing state) inline std::atomic g_liteFakeBlockImport{false}; // "import"/"timport" block inline std::atomic g_liteFakeImportFails{false}; // "import"/"timport" reject the key (real do_import_key shape) +inline std::atomic g_liteFakeImportEncrypted{false}; // "import"/"timport" refuse: the wallet is encrypted inline std::atomic g_liteFakeLogEnabled{false}; inline std::mutex g_liteFakeLogMutex; inline std::vector g_liteFakeCommandLog; // execute() command names, in order, while enabled @@ -73,6 +74,10 @@ inline long liteFakeCount(const std::vector& log, const char* comma inline std::string g_liteFakeDeadServerSubstr; // initialize_existing returns a warming-up (-28) error for URLs containing this substring. inline std::string g_liteFakeWarmupServerSubstr; +// When non-empty, initialize_existing returns "OK: " (a wallet recovered from its backup, s2-D14). +inline std::string g_liteFakeOpenRecoveredNote; +// When non-empty, "seed" returns this phrase instead of the default one. +inline std::string g_liteFakeSeedPhrase; inline void resetLiteFakeCounters() { @@ -81,6 +86,8 @@ inline void resetLiteFakeCounters() g_liteFakeShutdownCalled = false; g_liteFakeDeadServerSubstr.clear(); g_liteFakeWarmupServerSubstr.clear(); + g_liteFakeOpenRecoveredNote.clear(); + g_liteFakeSeedPhrase.clear(); g_liteFakeBlockNew = false; g_liteFakeBlockSave = false; g_liteFakeBlockBalance = false; @@ -88,6 +95,7 @@ inline void resetLiteFakeCounters() g_liteFakeBlockEncrypt = false; g_liteFakeBlockImport = false; g_liteFakeImportFails = false; + g_liteFakeImportEncrypted = false; g_liteFakeLogEnabled = false; (void)liteFakeTakeLog(); } @@ -142,6 +150,7 @@ inline char* liteFakeInitExisting(bool, const char* server) return liteFakeDup("Error: could not connect to server"); // bridge maps to ok=false } } + if (!g_liteFakeOpenRecoveredNote.empty()) return liteFakeDup(("OK: " + g_liteFakeOpenRecoveredNote).c_str()); return liteFakeDup("OK"); } inline char* liteFakeExecute(const char* command, const char* args) @@ -207,11 +216,18 @@ inline char* liteFakeExecute(const char* command, const char* args) if (std::strcmp(c, "export") == 0) return liteFakeDup("[{\"address\":\"zs1fakeaddr\",\"private_key\":\"SECRET-ZKEY\"," "\"viewing_key\":\"zxviewsFAKE\"}]"); - if (std::strcmp(c, "seed") == 0) + if (std::strcmp(c, "seed") == 0) { + if (!g_liteFakeSeedPhrase.empty()) + return liteFakeDup(("{\"seed\":\"" + g_liteFakeSeedPhrase + "\",\"birthday\":0}").c_str()); return liteFakeDup("{\"seed\":\"fake seed phrase words\",\"birthday\":0}"); + } // import (shielded key) / timport (transparent WIF): do_import_* pretty-print JSON. if (std::strcmp(c, "import") == 0 || std::strcmp(c, "timport") == 0) { liteFakeWaitWhile(g_liteFakeBlockImport); + // The real refusal (lightclient do_import_tk/do_import_sk, prefixed by the console command). + if (g_liteFakeImportEncrypted.load()) + return liteFakeDup("Error: Can't import a private key while the wallet is encrypted - remove " + "encryption, import, then encrypt again"); // The real do_import_key error REPEATS the key: "Error: '' was not recognized as ...". if (g_liteFakeImportFails.load()) return liteFakeDup((std::string("Error: '") + (args ? args : "") + diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 808101f..e928dad 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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 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 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(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 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();