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:
@@ -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";
|
||||
|
||||
122
src/util/wallet_file_probe.h
Normal file
122
src/util/wallet_file_probe.h
Normal 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
|
||||
Reference in New Issue
Block a user