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
+17 -1
View File
@@ -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 : "") +