feat(wallets): recursive subdir scan + wallet.dat encryption/seed badges

Search user-added wallet folders RECURSIVELY (bounded: depth/hit/visit caps,
skip_permission_denied, no symlink-follow, node-junk + node-subtree pruning);
the datadir stays top-level. r.dir is each file's real parent so Open/import
resolve subdir wallets. Auto-suffix the import destination on a name collision.

Add util/wallet_file_probe.h: read encryption/seed/shielded flags straight off
a wallet.dat WITHOUT loading it — validate the Berkeley DB btree magic, then a
bounded streaming byte-scan for the length-prefixed record markers the daemon
writes (mkey -> encrypted; hdseed/hdchain -> seed/HD; zkey/sapzkey -> shielded).
Reads no key material. The Wallets dialog shows Encrypted / Seed / Legacy badges,
plus an Unknown badge when a large file couldn't be fully scanned (so absence of
a lock never falsely reads as unencrypted). Unit-tested + validated read-only
against real wallets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 12:32:59 -05:00
parent 57147fd06b
commit 112c581247
4 changed files with 319 additions and 22 deletions

View File

@@ -36,6 +36,7 @@
#include "util/pool_registry.h"
#include "util/daemon_updater.h"
#include "util/lite_server_probe.h"
#include "util/wallet_file_probe.h"
#include "wallet/lite_connection_service.h"
#include "wallet/lite_diagnostics.h"
#include "wallet/lite_owned_string.h"
@@ -737,6 +738,60 @@ void testPaymentUri()
EXPECT_EQ(invalid.error, std::string("Invalid negative amount"));
}
void testWalletFileProbe()
{
namespace fs = std::filesystem;
using dragonx::util::probeWalletFile;
const fs::path dir = fs::path("/tmp/dragonx") / "probe";
std::error_code ec; fs::create_directories(dir, ec);
auto writef = [](const fs::path& p, const std::string& d) {
std::ofstream o(p, std::ios::binary); o.write(d.data(), (std::streamsize)d.size());
};
// A valid Berkeley DB btree metapage has the magic 0x00053162 at byte offset 12 (little-endian here).
auto bdb = []() { std::string h(16, '\0'); h[12] = 0x62; h[13] = 0x31; h[14] = 0x05; h[15] = 0x00; return h; };
// Wallet records are keyed by a CompactSize-length-prefixed name, e.g. "mkey" -> {0x04,'m','k','e','y'}.
auto rec = [](const std::string& name) { return std::string(1, (char)name.size()) + name; };
// 1) Non-BDB file → not recognized, no flags.
writef(dir / "junk.dat", std::string(4096, 'x'));
{ auto p = probeWalletFile((dir / "junk.dat").string());
EXPECT_FALSE(p.isBerkeleyDB); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed); }
// 2) BDB with no markers → legacy plaintext; a complete scan makes the negatives trustworthy.
writef(dir / "legacy.dat", bdb() + std::string(2000, '\0'));
{ auto p = probeWalletFile((dir / "legacy.dat").string());
EXPECT_TRUE(p.isBerkeleyDB); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed);
EXPECT_FALSE(p.hasShielded); EXPECT_TRUE(p.scanComplete); }
// 3) Encrypted (mkey) + HD (hdchain) + shielded (csapzkey).
writef(dir / "enc.dat", bdb() + std::string(500, 'a') + rec("mkey") + std::string(300, 'b') +
rec("hdchain") + std::string(300, 'c') + rec("csapzkey") + std::string(500, 'd'));
{ auto p = probeWalletFile((dir / "enc.dat").string());
EXPECT_TRUE(p.encrypted); EXPECT_TRUE(p.hdSeed); EXPECT_TRUE(p.hasShielded); }
// 4) False-positive guard: the bare word "mkey" WITHOUT the 0x04 length prefix must not count.
writef(dir / "bare.dat", bdb() + std::string(100, '\0') + "mkey" + std::string(100, '\0'));
{ auto p = probeWalletFile((dir / "bare.dat").string()); EXPECT_FALSE(p.encrypted); }
// 5) Byte cap: a marker beyond maxBytes is missed AND the scan is flagged incomplete (so a caller
// won't wrongly assert "legacy"); the same file scanned fully finds it and reports complete.
{ std::string d = bdb(); d.resize(2u * 1024 * 1024, 'q'); d += rec("mkey"); writef(dir / "deep.dat", d);
auto capped = probeWalletFile((dir / "deep.dat").string(), 1024u * 1024u);
EXPECT_TRUE(capped.isBerkeleyDB); EXPECT_FALSE(capped.encrypted); EXPECT_FALSE(capped.scanComplete);
EXPECT_TRUE(capped.bytesRead <= 1024u * 1024u); // budget accounting reflects a truncated read
auto full = probeWalletFile((dir / "deep.dat").string());
EXPECT_TRUE(full.encrypted); EXPECT_TRUE(full.scanComplete); }
// 6) Exact-boundary case: a file whose length is an exact 1 MiB multiple (and == maxBytes) must still
// report scanComplete=true (the peek() guard), so a fully-scanned wallet isn't mislabeled truncated.
{ std::string d = bdb(); d.resize(1024u * 1024u, 'e'); // exactly one 1 MiB chunk, no markers
writef(dir / "exact.dat", d);
auto p = probeWalletFile((dir / "exact.dat").string(), 1024u * 1024u);
EXPECT_TRUE(p.isBerkeleyDB); EXPECT_TRUE(p.scanComplete); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed); }
fs::remove_all(dir, ec);
}
void testAmountFormatting()
{
EXPECT_EQ(dragonx::util::formatAmountFixed(1.0), std::string("1.00000000"));
@@ -6263,6 +6318,7 @@ int main()
testHushChatOutgoing();
testHushChatTransport();
testHushChatShuffledReceive();
testWalletFileProbe();
testAddressChecksumValidation();
testLiteServerProbeLive();
testXmrigLiveInstall();