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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user