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:
@@ -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<std::uint8_t>& out)
|
||||
{
|
||||
std::vector<std::uint8_t> 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<int>(p - kBase58);
|
||||
for (auto& b : bytes) {
|
||||
carry += static_cast<int>(b) * 58;
|
||||
@@ -39,12 +44,16 @@ bool base58Decode(const std::string& s, std::vector<std::uint8_t>& out)
|
||||
carry >>= 8;
|
||||
}
|
||||
}
|
||||
std::vector<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t>& payloadO
|
||||
if (s.size() < 5 || s.size() > 256) return false;
|
||||
std::vector<std::uint8_t> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "lite_diagnostics.h"
|
||||
#include "lite_result_parsers.h"
|
||||
#include "../data/wallet_state.h"
|
||||
#include "../util/address_validation.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
@@ -40,6 +41,24 @@ std::string extractJsonError(const nlohmann::json& value)
|
||||
return e.is_string() ? e.get<std::string>() : 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<bool>* 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<std::uint8_t> 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 <name>". 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<std::uint64_t>();
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -49,6 +49,7 @@ inline std::atomic<long> g_liteFakeBalanceEntered{0}; // calls that reached "
|
||||
inline std::atomic<bool> g_liteFakeBlockEncrypt{false}; // "encrypt" blocks (before changing state)
|
||||
inline std::atomic<bool> g_liteFakeBlockImport{false}; // "import"/"timport" block
|
||||
inline std::atomic<bool> g_liteFakeImportFails{false}; // "import"/"timport" reject the key (real do_import_key shape)
|
||||
inline std::atomic<bool> g_liteFakeImportEncrypted{false}; // "import"/"timport" refuse: the wallet is encrypted
|
||||
inline std::atomic<bool> g_liteFakeLogEnabled{false};
|
||||
inline std::mutex g_liteFakeLogMutex;
|
||||
inline std::vector<std::string> g_liteFakeCommandLog; // execute() command names, in order, while enabled
|
||||
@@ -73,6 +74,10 @@ inline long liteFakeCount(const std::vector<std::string>& 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: <note>" (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: '<key>' was not recognized as ...".
|
||||
if (g_liteFakeImportFails.load())
|
||||
return liteFakeDup((std::string("Error: '") + (args ? args : "") +
|
||||
|
||||
+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