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

@@ -262,6 +262,10 @@ void I18n::loadBuiltinEnglish()
strings_["wallets_col_opened"] = "Last opened";
strings_["wallets_current"] = "current";
strings_["wallets_active"] = "Active";
strings_["wallets_badge_encrypted"] = "Encrypted (passphrase-protected)";
strings_["wallets_badge_seed"] = "Seed phrase wallet (HD)";
strings_["wallets_badge_legacy"] = "Legacy wallet (no seed phrase)";
strings_["wallets_badge_unknown"] = "Wallet type not fully determined (large file — open to confirm)";
strings_["wallets_open"] = "Open";
strings_["wallets_never"] = "Never";
strings_["wallets_import_hint"] = "Import to open";

View File

@@ -0,0 +1,122 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// wallet_file_probe.h — read light metadata off a wallet.dat WITHOUT loading it into the daemon.
//
// DragonX wallet.dat is a Berkeley DB (btree) key/value store. Only the private-key material is
// encrypted (ckey/czkey/csapzkey/chdseed); the record *keys* and non-secret metadata live in
// plaintext, so a handful of boolean facts can be recovered cheaply by (a) validating the BDB btree
// magic and (b) scanning the raw bytes for the length-prefixed record names the daemon writes
// (a std::string is serialized as CompactSize(len)+bytes, so e.g. an "mkey" record's key begins with
// the 5 bytes 0x04 'm' 'k' 'e' 'y'). This is a heuristic PRESENCE test — reliable for booleans, not
// for exact counts — with no libdb dependency and no daemon. It never reads or exposes key material.
#pragma once
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <fstream>
#include <string>
#include <utility>
#include <vector>
namespace dragonx {
namespace util {
struct WalletFileProbe {
bool isBerkeleyDB = false; ///< file has a valid BDB btree metapage magic (looks like a real wallet.dat)
bool encrypted = false; ///< has an "mkey" master-key record → passphrase-encrypted
bool hdSeed = false; ///< has hdseed/chdseed/hdchain → HD/seed wallet (else legacy)
bool hasShielded = false; ///< has zkey/czkey/sapzkey/csapzkey → holds shielded addresses
// A found marker is always definitive; a MISSING marker is only trustworthy when the scan actually
// covered the whole file. `scanComplete` is true when we reached EOF, or early-exited having found
// everything — false only when the byte cap cut the scan short. Callers should treat negative flags
// (e.g. "legacy" = !hdSeed, or "not encrypted" = !encrypted) as reliable only when scanComplete is true.
bool scanComplete = false;
std::size_t bytesRead = 0; ///< bytes actually consumed (early-exit reads far less than the file) — for budgeting
};
// Probe a wallet.dat by header-validating it as a BDB btree then byte-scanning (bounded, streaming,
// early-exit) for record markers. `maxBytes` caps how far we read so a pathologically large file can't
// stall the caller. Returns an all-false probe (isBerkeleyDB=false) for anything that isn't a readable
// BDB btree file. Safe to call on a wallet currently open by the daemon (read-only, pattern scan only).
inline WalletFileProbe probeWalletFile(const std::string& path,
std::size_t maxBytes = 96u * 1024u * 1024u) {
WalletFileProbe out;
std::ifstream f(path, std::ios::binary);
if (!f) return out;
// --- 1) Validate the Berkeley DB btree metapage magic (offset 12, either endianness). ---
unsigned char hdr[16];
f.read(reinterpret_cast<char*>(hdr), sizeof(hdr));
if (f.gcount() < static_cast<std::streamsize>(sizeof(hdr))) return out;
auto rd32 = [](const unsigned char* p, bool le) -> uint32_t {
return le ? (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24)
: (uint32_t)p[3] | ((uint32_t)p[2] << 8) | ((uint32_t)p[1] << 16) | ((uint32_t)p[0] << 24);
};
constexpr uint32_t kBtreeMagic = 0x00053162u; // DB_BTREEMAGIC
if (rd32(hdr + 12, true) != kBtreeMagic && rd32(hdr + 12, false) != kBtreeMagic) return out;
out.isBerkeleyDB = true;
// --- 2) Streaming byte-scan for length-prefixed record markers. ---
// group: 0 = encrypted, 1 = hd/seed, 2 = shielded.
const std::vector<std::pair<std::string, int>> patterns = {
{std::string("\x04", 1) + "mkey", 0}, // master key → wallet is encrypted
{std::string("\x06", 1) + "hdseed", 1}, // plaintext HD seed
{std::string("\x07", 1) + "chdseed", 1}, // encrypted HD seed
{std::string("\x07", 1) + "hdchain", 1}, // HD chain counter (present in both HD variants)
{std::string("\x04", 1) + "zkey", 2}, // sprout shielded key
{std::string("\x05", 1) + "czkey", 2}, // encrypted sprout key
{std::string("\x07", 1) + "sapzkey", 2}, // sapling shielded key
{std::string("\x08", 1) + "csapzkey", 2}, // encrypted sapling key
};
std::size_t maxPat = 0;
for (const auto& p : patterns) maxPat = std::max(maxPat, p.first.size());
const std::size_t overlap = maxPat > 0 ? maxPat - 1 : 0; // bytes carried between chunks for split matches
f.clear();
f.seekg(0, std::ios::beg);
constexpr std::size_t kChunk = 1u << 20; // 1 MiB
std::vector<char> buf(kChunk);
std::string carry;
std::size_t readTotal = 0;
bool enc = false, hd = false, sh = false;
bool eof = false, allFound = false;
while (readTotal < maxBytes) {
const std::size_t want = std::min(kChunk, maxBytes - readTotal);
f.read(buf.data(), static_cast<std::streamsize>(want));
const std::streamsize got = f.gcount();
if (got <= 0) { eof = true; break; }
readTotal += static_cast<std::size_t>(got);
std::string window;
window.reserve(carry.size() + static_cast<std::size_t>(got));
window.assign(carry);
window.append(buf.data(), static_cast<std::size_t>(got));
for (const auto& p : patterns) {
bool& flag = (p.second == 0) ? enc : (p.second == 1) ? hd : sh;
if (!flag && window.find(p.first) != std::string::npos) flag = true;
}
if (enc && hd && sh) { allFound = true; break; } // nothing more to learn
if (window.size() > overlap) carry.assign(window.data() + window.size() - overlap, overlap);
else carry.assign(window);
if (static_cast<std::size_t>(got) < want) { eof = true; break; } // reached EOF
}
out.encrypted = enc;
out.hdSeed = hd;
out.hasShielded = sh;
out.bytesRead = readTotal;
// false only if the byte cap cut a larger file short. If we stopped because readTotal hit maxBytes on
// an exact chunk boundary, ifstream never set eofbit — peek() tells us whether the file actually ended
// there (EOF → complete) or there's more beyond the cap (a real byte → truncated).
out.scanComplete = allFound || eof;
if (!out.scanComplete && f && f.peek() == std::ifstream::traits_type::eof()) out.scanComplete = true;
return out;
}
} // namespace util
} // namespace dragonx