fix(wallet-switch): close gaps found verifying clusters A/B/C
Adversarial verification of the audit fixes found real gaps in them: - HIGH throw-safety: the switch worker and the encryption-restart worker set daemon_restarting_=true but reset it only on their normal/early-return paths — a throw from stop/startEmbeddedDaemon left the flag stuck true, wedging reconnect and every future switch/rescan/encryption. Both now reset it on all paths (try/catch); a throw during a switch is treated as a failed switch and triggers the revert. - HIGH residual chat leak: resetChatSession() cleared the flags but an already- posted z_exportmnemonic worker job still held wallet A's secret, and its completion callback (guarded only by isLocked(), false for an unencrypted wallet) would provision A's identity under B. Add a chat_session_generation_ epoch bumped on every wallet change; the fetch captures it and its callback discards the (previous-wallet) secret if the epoch no longer matches. - LOW vault-scope collision: the per-wallet vault tag was a lossy char-substitution (two distinct files could map to one vault). Append an 8-hex FNV-1a of the raw filename so distinct wallets never share a vault. +unit test. - LOW seed-adopt: removeVault() on adopt so the legacy wallet's PIN passphrase isn't left associated with the new seed wallet (same file name). - LOW: clear lock_unlock_in_progress_ on switch too. Deferred (documented): the 1.5s start grace can't catch a wallet that fails LATE in daemon init (the existing crash-wedge detection still applies); beginShutdown's join of the switch task can briefly freeze the UI during quit (necessary to avoid orphaning the daemon). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1069,6 +1069,7 @@ void App::switchToWallet(const std::string& walletFile)
|
||||
lock_lockout_timer_ = 0.0f;
|
||||
lock_error_msg_.clear();
|
||||
lock_error_timer_ = 0.0f;
|
||||
lock_unlock_in_progress_ = false;
|
||||
util::SecureVault::secureZero(lock_passphrase_buf_, sizeof(lock_passphrase_buf_));
|
||||
util::SecureVault::secureZero(lock_pin_buf_, sizeof(lock_pin_buf_));
|
||||
|
||||
@@ -1086,23 +1087,29 @@ void App::switchToWallet(const std::string& walletFile)
|
||||
ui::Notifications::instance().info("Switching wallet — the node will restart…");
|
||||
|
||||
async_tasks_.submit("Switch wallet", [this, needRescan](const util::AsyncTaskManager::Token&) {
|
||||
// Stop the node fully so it releases the datadir .lock + RPC port before we relaunch.
|
||||
stopEmbeddedDaemon();
|
||||
for (int i = 0; i < 60 && isEmbeddedDaemonRunning(); ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
if (needRescan && daemon_controller_) daemon_controller_->setRescanOnNextStart(true);
|
||||
bool ok = shutting_down_ ? true : startEmbeddedDaemon();
|
||||
// A missing/corrupt wallet makes dragonxd exit during init — confirm the process survived a
|
||||
// moment; if not, the wallet is bad. (A valid launch keeps the process alive while it syncs.)
|
||||
if (ok && !shutting_down_) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
|
||||
if (!isEmbeddedDaemonRunning()) ok = false;
|
||||
bool ok = false;
|
||||
try {
|
||||
// Stop the node fully so it releases the datadir .lock + RPC port before we relaunch.
|
||||
stopEmbeddedDaemon();
|
||||
for (int i = 0; i < 60 && isEmbeddedDaemonRunning(); ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
if (needRescan && daemon_controller_) daemon_controller_->setRescanOnNextStart(true);
|
||||
ok = shutting_down_ ? true : startEmbeddedDaemon();
|
||||
// A missing/corrupt wallet makes dragonxd exit during init — confirm the process survived a
|
||||
// moment; if not, the wallet is bad. (A valid launch keeps the process alive while it syncs.)
|
||||
if (ok && !shutting_down_) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
|
||||
if (!isEmbeddedDaemonRunning()) ok = false;
|
||||
}
|
||||
} catch (...) {
|
||||
ok = false; // a throw during stop/start is a failed switch — revert rather than wedge
|
||||
}
|
||||
if (ok) {
|
||||
daemon_restarting_ = false; // re-arm reconnect once the new daemon is up
|
||||
} else {
|
||||
// Leave the reconnect GATED (daemon_restarting_ stays true) and hand the revert to the main
|
||||
// thread — settings writes + vault re-scope must not run on this worker thread.
|
||||
// thread — settings writes + vault re-scope must not run on this worker thread. The revert
|
||||
// re-arms the gate, so it can never get stuck true.
|
||||
wallet_switch_failed_.store(true);
|
||||
}
|
||||
});
|
||||
@@ -2574,6 +2581,9 @@ void App::resetChatSession()
|
||||
chat_identity_provisioned_ = false;
|
||||
chat_identity_fetch_in_flight_ = false;
|
||||
chat_identity_unavailable_ = false;
|
||||
// Invalidate any in-flight identity fetch that captured the PREVIOUS wallet's secret, so its
|
||||
// completion callback can't provision that secret under the new wallet.
|
||||
++chat_session_generation_;
|
||||
}
|
||||
|
||||
void App::maybeProvisionChatIdentity()
|
||||
@@ -2611,8 +2621,9 @@ void App::maybeProvisionChatIdentity()
|
||||
if (!state_.connected || !rpc_ || !worker_) return;
|
||||
if (!state_.encryption_state_known) return; // don't fetch before we know the lock state
|
||||
const std::string fallbackZaddr = chatReplyZaddr(); // main thread: stable identity source
|
||||
const int fetchGen = chat_session_generation_; // wallet epoch this secret belongs to
|
||||
chat_identity_fetch_in_flight_ = true;
|
||||
worker_->post([this, fallbackZaddr]() -> rpc::RPCWorker::MainCb {
|
||||
worker_->post([this, fallbackZaddr, fetchGen]() -> rpc::RPCWorker::MainCb {
|
||||
std::string secret; // the mnemonic (portable) OR a spending key (legacy fallback)
|
||||
bool transientFail = false; // connection blip — allow a later retry
|
||||
bool unavailable = false; // no usable secret — stop retrying this session
|
||||
@@ -2642,7 +2653,14 @@ void App::maybeProvisionChatIdentity()
|
||||
} catch (const std::exception&) {
|
||||
transientFail = true;
|
||||
}
|
||||
return [this, secret = std::move(secret), transientFail, unavailable]() mutable {
|
||||
return [this, secret = std::move(secret), transientFail, unavailable, fetchGen]() mutable {
|
||||
// The wallet changed while this fetch ran (resetChatSession bumped the generation): this
|
||||
// secret belongs to the PREVIOUS wallet — wipe it and do nothing so it can't provision an
|
||||
// identity under the new wallet. Don't touch the flags either (they belong to the new epoch).
|
||||
if (fetchGen != chat_session_generation_) {
|
||||
if (!secret.empty()) sodium_memzero(&secret[0], secret.size());
|
||||
return;
|
||||
}
|
||||
chat_identity_fetch_in_flight_ = false;
|
||||
if (unavailable) chat_identity_unavailable_ = true;
|
||||
// Provision only on success AND while still unlocked: a lock (e.g. auto-lock) can
|
||||
@@ -3496,6 +3514,10 @@ void App::beginAdoptSeedWallet()
|
||||
// Adopting swaps in a brand-new seed wallet — drop the legacy wallet's chat identity + store so
|
||||
// it re-derives from the new seed (the file name is unchanged, so nothing else detects the swap).
|
||||
resetChatSession();
|
||||
// The PIN vault is name-scoped, and the file name is unchanged, so the legacy wallet's stored
|
||||
// passphrase would otherwise stay associated with the new seed wallet — remove it (the new wallet
|
||||
// has its own passphrase; the user can re-enable PIN quick-unlock for it).
|
||||
if (vault_) vault_->removeVault();
|
||||
const std::string base = seed_migration_temp_dir_;
|
||||
async_tasks_.submit("Adopt seed wallet", [this, base](const util::AsyncTaskManager::Token&) {
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
Reference in New Issue
Block a user