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:
12
src/app.cpp
12
src/app.cpp
@@ -334,8 +334,9 @@ bool App::init()
|
|||||||
// Ensure ObsidianDragon config directory and template files exist
|
// Ensure ObsidianDragon config directory and template files exist
|
||||||
util::Platform::ensureObsidianDragonSetup();
|
util::Platform::ensureObsidianDragonSetup();
|
||||||
|
|
||||||
// Initialize PIN vault
|
// Initialize PIN vault, scoped to the active wallet so one wallet's stored passphrase is never
|
||||||
vault_ = std::make_unique<util::SecureVault>();
|
// offered for another (the default wallet keeps the legacy vault.dat).
|
||||||
|
vault_ = std::make_unique<util::SecureVault>(settings_ ? settings_->getActiveWalletFile() : "");
|
||||||
|
|
||||||
// Theme is now applied via SkinManager below after UISchema loads.
|
// Theme is now applied via SkinManager below after UISchema loads.
|
||||||
// The old SetThemeById() C++ fallback is no longer needed at startup
|
// The old SetThemeById() C++ fallback is no longer needed at startup
|
||||||
@@ -634,12 +635,7 @@ void App::rebuildLiteWallet(bool force)
|
|||||||
// A rebuilt controller may back a different wallet (server switch / re-open); drop any chat
|
// A rebuilt controller may back a different wallet (server switch / re-open); drop any chat
|
||||||
// identity + decrypted messages, lock the DB, and re-arm provisioning so it re-derives (and
|
// identity + decrypted messages, lock the DB, and re-arm provisioning so it re-derives (and
|
||||||
// reloads the right wallet's history) from the newly opened wallet's seed.
|
// reloads the right wallet's history) from the newly opened wallet's seed.
|
||||||
chat_service_.clearIdentity();
|
resetChatSession();
|
||||||
chat_service_.store().clear();
|
|
||||||
chat_db_.lock();
|
|
||||||
chat_identity_provisioned_ = false;
|
|
||||||
chat_identity_fetch_in_flight_ = false;
|
|
||||||
chat_identity_unavailable_ = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void App::update()
|
void App::update()
|
||||||
|
|||||||
@@ -658,6 +658,10 @@ private:
|
|||||||
// variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the
|
// variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the
|
||||||
// feature is off, already provisioned, in flight, or unavailable.
|
// feature is off, already provisioned, in flight, or unavailable.
|
||||||
void maybeProvisionChatIdentity();
|
void maybeProvisionChatIdentity();
|
||||||
|
// Drop the in-memory chat identity + decrypted message store, lock the chat DB, and re-arm
|
||||||
|
// provisioning. MUST be called whenever the loaded wallet changes (switch / seed-migration adopt)
|
||||||
|
// so wallet A's private chat can't surface — or be signed with A's keys — under wallet B.
|
||||||
|
void resetChatSession();
|
||||||
// One-time nudge: on a full-node wallet that has a mnemonic, remind the user (once per
|
// One-time nudge: on a full-node wallet that has a mnemonic, remind the user (once per
|
||||||
// install) to back up their seed phrase. Cheap early-outs keep it idle until it can act.
|
// install) to back up their seed phrase. Cheap early-outs keep it idle until it can act.
|
||||||
void maybeRemindSeedBackup();
|
void maybeRemindSeedBackup();
|
||||||
|
|||||||
@@ -1046,6 +1046,17 @@ void App::switchToWallet(const std::string& walletFile)
|
|||||||
settings_->setActiveWalletFile(walletFile);
|
settings_->setActiveWalletFile(walletFile);
|
||||||
settings_->save();
|
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,
|
// 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
|
// 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
|
// 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;
|
daemon_restarting_ = true;
|
||||||
if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
|
if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
|
||||||
onDisconnected("Switching wallet");
|
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…");
|
ui::Notifications::instance().info("Switching wallet — the node will restart…");
|
||||||
|
|
||||||
async_tasks_.submit("Switch wallet", [this, needRescan](const util::AsyncTaskManager::Token&) {
|
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
|
// 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
|
// because they feed the SAME SDXLite-compatible mnemonic into the KDF (identity derivation is
|
||||||
// local — peers only ever exchange public keys).
|
// 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()
|
void App::maybeProvisionChatIdentity()
|
||||||
{
|
{
|
||||||
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
|
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
|
// in-memory identity and re-arm so it re-derives on the next unlock. Unencrypted wallets report
|
||||||
// isLocked()==false and fall through.
|
// isLocked()==false and fall through.
|
||||||
if (state_.isLocked()) {
|
if (state_.isLocked()) {
|
||||||
if (chat_identity_provisioned_ || chat_service_.hasIdentity()) {
|
if (chat_identity_provisioned_ || chat_service_.hasIdentity()) resetChatSession();
|
||||||
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;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3429,6 +3450,9 @@ void App::beginAdoptSeedWallet()
|
|||||||
daemon_restarting_ = true;
|
daemon_restarting_ = true;
|
||||||
if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
|
if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
|
||||||
onDisconnected("Adopting seed wallet");
|
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_;
|
const std::string base = seed_migration_temp_dir_;
|
||||||
async_tasks_.submit("Adopt seed wallet", [this, base](const util::AsyncTaskManager::Token&) {
|
async_tasks_.submit("Adopt seed wallet", [this, base](const util::AsyncTaskManager::Token&) {
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <cctype>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include "../util/logger.h"
|
#include "../util/logger.h"
|
||||||
@@ -31,18 +32,31 @@ static constexpr uint8_t VAULT_VERSION = 0x01;
|
|||||||
static constexpr unsigned long long ARGON2_MEMLIMIT = crypto_pwhash_MEMLIMIT_MODERATE;
|
static constexpr unsigned long long ARGON2_MEMLIMIT = crypto_pwhash_MEMLIMIT_MODERATE;
|
||||||
static constexpr unsigned long long ARGON2_OPSLIMIT = crypto_pwhash_OPSLIMIT_MODERATE;
|
static constexpr unsigned long long ARGON2_OPSLIMIT = crypto_pwhash_OPSLIMIT_MODERATE;
|
||||||
|
|
||||||
SecureVault::SecureVault() {
|
SecureVault::SecureVault(const std::string& walletFile) {
|
||||||
// Ensure libsodium is initialized
|
// Ensure libsodium is initialized
|
||||||
if (sodium_init() < 0) {
|
if (sodium_init() < 0) {
|
||||||
// sodium_init returns 0 on success, 1 if already initialized, -1 on failure
|
// sodium_init returns 0 on success, 1 if already initialized, -1 on failure
|
||||||
// We'll proceed anyway — the functions will fail gracefully
|
// We'll proceed anyway — the functions will fail gracefully
|
||||||
}
|
}
|
||||||
|
setWalletScope(walletFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureVault::~SecureVault() = default;
|
SecureVault::~SecureVault() = default;
|
||||||
|
|
||||||
std::string SecureVault::getVaultPath() {
|
void SecureVault::setWalletScope(const std::string& walletFile) {
|
||||||
return (fs::path(Platform::getConfigDir()) / "vault.dat").string();
|
// The default wallet keeps the legacy global "vault.dat" (empty scope) for backward compatibility;
|
||||||
|
// any other wallet gets a filesystem-safe per-wallet tag so its vault is separate.
|
||||||
|
if (walletFile.empty() || walletFile == "wallet.dat") { scope_.clear(); return; }
|
||||||
|
std::string s;
|
||||||
|
s.reserve(walletFile.size());
|
||||||
|
for (unsigned char c : walletFile)
|
||||||
|
s += (std::isalnum(c) || c == '-' || c == '_') ? static_cast<char>(c) : '_';
|
||||||
|
scope_ = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string SecureVault::getVaultPath() const {
|
||||||
|
const std::string name = scope_.empty() ? std::string("vault.dat") : ("vault-" + scope_ + ".dat");
|
||||||
|
return (fs::path(Platform::getConfigDir()) / name).string();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool SecureVault::hasVault() const {
|
bool SecureVault::hasVault() const {
|
||||||
|
|||||||
@@ -29,9 +29,15 @@ namespace util {
|
|||||||
*/
|
*/
|
||||||
class SecureVault {
|
class SecureVault {
|
||||||
public:
|
public:
|
||||||
SecureVault();
|
// `walletFile` scopes the vault to a specific wallet.dat so one wallet's PIN passphrase is never
|
||||||
|
// offered for another. The default wallet ("wallet.dat" / empty) keeps the legacy global vault.dat
|
||||||
|
// path for backward compatibility; other wallets get their own vault-<scope>.dat.
|
||||||
|
explicit SecureVault(const std::string& walletFile = "");
|
||||||
~SecureVault();
|
~SecureVault();
|
||||||
|
|
||||||
|
// Re-scope to a different wallet (called when the active wallet changes).
|
||||||
|
void setWalletScope(const std::string& walletFile);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Check if a vault file exists on disk
|
* @brief Check if a vault file exists on disk
|
||||||
*/
|
*/
|
||||||
@@ -78,15 +84,17 @@ public:
|
|||||||
static void secureZero(void* ptr, size_t len);
|
static void secureZero(void* ptr, size_t len);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Get the vault file path
|
* @brief Get this vault's file path (scoped to its wallet)
|
||||||
*/
|
*/
|
||||||
static std::string getVaultPath();
|
std::string getVaultPath() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Derive a 32-byte key from PIN + salt using Argon2id
|
// Derive a 32-byte key from PIN + salt using Argon2id
|
||||||
bool deriveKey(const std::string& pin,
|
bool deriveKey(const std::string& pin,
|
||||||
const uint8_t* salt, size_t saltLen,
|
const uint8_t* salt, size_t saltLen,
|
||||||
uint8_t* key, size_t keyLen) const;
|
uint8_t* key, size_t keyLen) const;
|
||||||
|
|
||||||
|
std::string scope_; // "" = default/legacy wallet (vault.dat); else a sanitized wallet-file tag
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace util
|
} // namespace util
|
||||||
|
|||||||
Reference in New Issue
Block a user