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
+23 -6
View File
@@ -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;
}