fix(wallet-switch): cluster A — identity/secret teardown on wallet switch
From the wallet-switching audit (HIGH-severity cross-wallet leaks): - Chat identity leak: the full-node switch (switchToWallet) and seed-migration adopt reset state_ but NOT the HushChat identity, so wallet A's decrypted conversations surfaced under wallet B and outgoing chat was signed with A's keypair. Factor the existing teardown into App::resetChatSession() and call it on both wallet-change paths (the lite path already reset it via rebuildLiteWallet, which now uses the helper too). - Global PIN vault: the PIN quick-unlock vault was a single vault.dat, so after a switch wallet A's stored passphrase was offered/applied to encrypted wallet B. SecureVault is now scoped per wallet (vault-<walletfile>.dat); the default wallet keeps the legacy vault.dat for back-compat. vault_ is constructed for the active wallet and re-scoped on switch, so B has its own (empty) vault. - Lock-screen state: switching now clears the carried-over failed-attempt counter + lockout timer and secure-zeroes the passphrase/PIN entry buffers so the previous wallet's unlock state can't apply to the new one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1046,6 +1046,17 @@ void App::switchToWallet(const std::string& walletFile)
|
||||
settings_->setActiveWalletFile(walletFile);
|
||||
settings_->save();
|
||||
|
||||
// Re-scope the PIN vault to the new wallet (its own vault-<scope>.dat), and clear any carried-over
|
||||
// lock-screen attempt/lockout state + wipe the passphrase/PIN entry buffers — the previous wallet's
|
||||
// failed-unlock counter and typed secrets must not apply to the new wallet.
|
||||
if (vault_) vault_->setWalletScope(walletFile);
|
||||
lock_attempts_ = 0;
|
||||
lock_lockout_timer_ = 0.0f;
|
||||
lock_error_msg_.clear();
|
||||
lock_error_timer_ = 0.0f;
|
||||
util::SecureVault::secureZero(lock_passphrase_buf_, sizeof(lock_passphrase_buf_));
|
||||
util::SecureVault::secureZero(lock_pin_buf_, sizeof(lock_pin_buf_));
|
||||
|
||||
// Same restart coordination as the seed-adopt flow: gate the main-loop reconnect, disconnect,
|
||||
// then stop + restart on a background thread. syncSettings() re-reads active_wallet_file on
|
||||
// start, so the daemon comes up on -wallet=<name>. WalletState clears on disconnect and the
|
||||
@@ -1053,6 +1064,10 @@ void App::switchToWallet(const std::string& walletFile)
|
||||
daemon_restarting_ = true;
|
||||
if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
|
||||
onDisconnected("Switching wallet");
|
||||
// The loaded wallet is changing — drop the old wallet's chat identity + decrypted store so it
|
||||
// can't surface (or sign outgoing chat) under the new wallet. It re-provisions from the new
|
||||
// wallet's seed once it connects. onDisconnected alone doesn't cover chat.
|
||||
resetChatSession();
|
||||
ui::Notifications::instance().info("Switching wallet — the node will restart…");
|
||||
|
||||
async_tasks_.submit("Switch wallet", [this, needRescan](const util::AsyncTaskManager::Token&) {
|
||||
@@ -2506,6 +2521,18 @@ void App::provisionChatIdentityFromSecret(std::string secret)
|
||||
// tick; cheap early-outs keep it idle until it can act. Both variants derive the SAME identity
|
||||
// because they feed the SAME SDXLite-compatible mnemonic into the KDF (identity derivation is
|
||||
// local — peers only ever exchange public keys).
|
||||
void App::resetChatSession()
|
||||
{
|
||||
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
|
||||
// Drop identity + decrypted plaintext in RAM, lock the seed-encrypted DB, re-arm provisioning.
|
||||
chat_service_.clearIdentity();
|
||||
chat_service_.store().clear();
|
||||
chat_db_.lock();
|
||||
chat_identity_provisioned_ = false;
|
||||
chat_identity_fetch_in_flight_ = false;
|
||||
chat_identity_unavailable_ = false;
|
||||
}
|
||||
|
||||
void App::maybeProvisionChatIdentity()
|
||||
{
|
||||
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
|
||||
@@ -2514,13 +2541,7 @@ void App::maybeProvisionChatIdentity()
|
||||
// in-memory identity and re-arm so it re-derives on the next unlock. Unencrypted wallets report
|
||||
// isLocked()==false and fall through.
|
||||
if (state_.isLocked()) {
|
||||
if (chat_identity_provisioned_ || chat_service_.hasIdentity()) {
|
||||
chat_service_.clearIdentity();
|
||||
chat_service_.store().clear(); // decrypted plaintext in RAM — drop it; DB reloads on unlock
|
||||
chat_db_.lock();
|
||||
chat_identity_provisioned_ = false;
|
||||
chat_identity_unavailable_ = false;
|
||||
}
|
||||
if (chat_identity_provisioned_ || chat_service_.hasIdentity()) resetChatSession();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3429,6 +3450,9 @@ void App::beginAdoptSeedWallet()
|
||||
daemon_restarting_ = true;
|
||||
if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
|
||||
onDisconnected("Adopting seed wallet");
|
||||
// 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();
|
||||
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