Files
ObsidianDragon/src/app_security.cpp
T
DanSandClaude Opus 4.8 d16bbf3695 security: payment-URI amount validation, export filename sanitize, chat crash-guard, HTTPS-only, conf 0600, pool auto-lock
Six bounded defensive fixes:
- Payment-URI amount is now strictly validated (reject NaN/inf/sign/garbage,
  <=8 decimals, 0..21,000,000) at parse time, so a crafted QR/URI can't inject a
  bogus amount into the Send form. The z_sendmany call the user confirms is
  unchanged; this is input validation only.
- export-all-keys filename is sanitized (basename-only, no traversal) via a
  shared util::sanitizeExportFilename (factored out of the CSV export dialog).
- Chat append()/upsert() wrap the strict serialize in try/catch so a malformed
  (invalid-UTF-8, attacker-influenceable) body is skipped, not crashed on —
  matching appendBatch's existing drop behavior.
- http_download (used by the updaters + pool stats) restricts transfers AND
  redirects to HTTPS and bounds the download path's redirects, so a redirect
  can't downgrade to http:// or another scheme.
- The default DRGX.conf (rpcuser/rpcpassword) is written 0600 from the start via
  writeFileAtomically, closing the brief world-readable ofstream+chmod window.
- Auto-lock is suppressed only for SOLO mining (needs the wallet), not pool
  mining (pays an external address), which was holding the wallet open forever.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-19 11:39:38 -05:00

2502 lines
126 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// app_security.cpp — Wallet encryption, lock screen, PIN management
// Split from app.cpp for maintainability.
#include "app.h"
#include "data/pin_vault_policy.h"
#include "data/wallet_security_reconcile.h"
#include "rpc/rpc_client.h"
#include "rpc/rpc_worker.h"
#include "rpc/connection.h"
#include "services/wallet_security_workflow_executor.h"
#include "config/settings.h"
#include "daemon/embedded_daemon.h"
#include "ui/notifications.h"
#include "ui/material/color_theme.h"
#include "ui/material/type.h"
#include "ui/material/typography.h"
#include "ui/material/draw_helpers.h"
#include "ui/schema/ui_schema.h"
#include "ui/theme.h"
#include "ui/effects/imgui_acrylic.h"
#include "ui/windows/mining_tab.h"
#include "util/platform.h"
#include "util/secure_vault.h"
#include "util/perf_log.h"
#include "embedded/IconsMaterialDesign.h"
#include "imgui.h"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cstring>
#include <ctime>
#include <cmath>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <vector>
#include <utility>
#include <sodium.h>
#include <functional>
#include <memory>
#include <utility>
namespace dragonx {
using json = nlohmann::json;
namespace {
class WalletSecurityRpcAdapter : public services::WalletSecurityController::RpcGateway {
public:
explicit WalletSecurityRpcAdapter(rpc::RPCClient* rpc, std::string source = "Security settings")
: rpc_(rpc), source_(std::move(source)) {}
bool encryptWallet(const std::string& passphrase, std::string& error) override {
return callWithError([&] { rpc_->call("encryptwallet", {passphrase}); }, error);
}
bool unlockWallet(const std::string& passphrase, int timeoutSeconds, std::string& error) override {
return callWithError([&] { rpc_->call("walletpassphrase", {passphrase, timeoutSeconds}); }, error);
}
bool exportWallet(const std::string& fileName, long timeoutSeconds, std::string& error) override {
return callWithError([&] { rpc_->call("z_exportwallet", {fileName}, timeoutSeconds); }, error);
}
bool importWallet(const std::string& filePath, long timeoutSeconds, std::string& error) override {
return callWithError([&] { rpc_->call("z_importwallet", {filePath}, timeoutSeconds); }, error);
}
private:
template <typename Fn>
bool callWithError(Fn&& fn, std::string& error) {
if (!rpc_) {
error = "RPC client unavailable";
return false;
}
try {
rpc::RPCClient::TraceScope trace(source_);
fn();
return true;
} catch (const std::exception& e) {
error = e.what();
return false;
}
}
rpc::RPCClient* rpc_ = nullptr;
std::string source_;
};
class WalletSecurityVaultAdapter : public services::WalletSecurityController::VaultGateway {
public:
explicit WalletSecurityVaultAdapter(util::SecureVault* vault) : vault_(vault) {}
bool storePin(const std::string& pin, const std::string& passphrase) override {
return vault_ && vault_->store(pin, passphrase);
}
private:
util::SecureVault* vault_ = nullptr;
};
class WalletSecurityDecryptRpcAdapter : public services::WalletSecurityWorkflowExecutor::RpcGateway {
public:
using StopFn = std::function<bool(rpc::RPCClient&, const char*)>;
WalletSecurityDecryptRpcAdapter(rpc::RPCClient* rpc, StopFn stopFn,
std::string source = "Security / Decrypt wallet workflow")
: rpc_(rpc), stopFn_(std::move(stopFn)), source_(std::move(source)) {}
bool unlockWallet(const std::string& passphrase, int timeoutSeconds, std::string& error) override {
return callWithError([&] { rpc_->call("walletpassphrase", {passphrase, timeoutSeconds}); }, error);
}
bool exportWallet(const std::string& fileName, long timeoutSeconds, std::string& error) override {
return callWithError([&] { rpc_->call("z_exportwallet", {fileName}, timeoutSeconds); }, error);
}
bool requestDaemonStop(std::string& error) override {
if (!rpc_) {
error = "RPC client unavailable";
return false;
}
bool ok = stopFn_ ? stopFn_(*rpc_, "Decrypt export daemon stop") : false;
if (!ok) error = "Stop RPC failed";
return ok;
}
bool probeDaemon(std::string& error) override {
return callWithError([&] { rpc_->call("getinfo"); }, error);
}
private:
template <typename Fn>
bool callWithError(Fn&& fn, std::string& error) {
if (!rpc_) {
error = "RPC client unavailable";
return false;
}
try {
rpc::RPCClient::TraceScope trace(source_);
fn();
return true;
} catch (const std::exception& e) {
error = e.what();
return false;
}
}
rpc::RPCClient* rpc_ = nullptr;
StopFn stopFn_;
std::string source_;
};
class WalletSecurityImportRpcAdapter : public services::WalletSecurityWorkflowExecutor::ImportGateway {
public:
WalletSecurityImportRpcAdapter(rpc::RPCClient* fallbackRpc, rpc::ConnectionConfig config)
: fallbackRpc_(fallbackRpc), config_(std::move(config)) {}
bool importWallet(const std::string& exportPath, long timeoutSeconds, std::string& error) override {
auto importRpc = std::make_unique<rpc::RPCClient>();
bool importRpcOk = importRpc->connect(config_.host, config_.port,
config_.rpcuser, config_.rpcpassword,
config_.use_tls);
if (!importRpcOk) importRpc.reset();
auto* rpcForImport = importRpc ? importRpc.get() : fallbackRpc_;
if (!rpcForImport) {
error = "RPC client unavailable";
return false;
}
try {
rpc::RPCClient::TraceScope trace("Security / Import wallet workflow");
rpcForImport->call("z_importwallet", {exportPath}, timeoutSeconds);
if (importRpc) importRpc->disconnect();
return true;
} catch (const std::exception& e) {
if (importRpc) importRpc->disconnect();
error = e.what();
return false;
}
}
private:
rpc::RPCClient* fallbackRpc_ = nullptr;
rpc::ConnectionConfig config_;
};
class WalletSecurityFileAdapter : public services::WalletSecurityWorkflowExecutor::FileGateway {
public:
std::string dataDir() override { return util::Platform::getDragonXDataDir(); }
bool backupEncryptedWallet(const services::WalletSecurityWorkflowExecutor::WalletFilePlan& filePlan,
std::string& error) override {
std::error_code ec;
if (!std::filesystem::exists(filePlan.walletPath, ec)) return true;
std::filesystem::remove(filePlan.backupPath, ec);
ec.clear();
std::filesystem::rename(filePlan.walletPath, filePlan.backupPath, ec);
if (ec) {
error = ec.message();
return false;
}
return true;
}
};
class WalletSecurityDaemonAdapter : public services::WalletSecurityWorkflowExecutor::DaemonGateway {
public:
WalletSecurityDaemonAdapter(App& app, const util::AsyncTaskManager::Token& token)
: app_(app), token_(token) {}
bool isUsingEmbeddedDaemon() const override { return app_.isUsingEmbeddedDaemon(); }
bool stopEmbeddedDaemon() override { return app_.stopEmbeddedDaemon() == daemon::StopOutcome::Exited; }
bool startEmbeddedDaemon() override { return app_.startEmbeddedDaemon(); }
bool cancelled() const override { return token_.cancelled(); }
bool shuttingDown() const override { return app_.isShuttingDown(); }
void sleepForMs(int milliseconds) override {
std::this_thread::sleep_for(std::chrono::milliseconds(std::max(0, milliseconds)));
}
private:
App& app_;
const util::AsyncTaskManager::Token& token_;
};
} // namespace
// ===========================================================================
// Wallet encryption helpers
// ===========================================================================
// The daemon shuts itself down after encryptwallet. Restart the embedded
// daemon off the main thread (to avoid stalling the UI), or ask the user to
// restart an external daemon. Shared by encryptWalletWithPassphrase() and
// processDeferredEncryption(); must be called on the main thread.
// Best-effort secure delete (overwrite in place, fsync, unlink) of a plaintext key export, so a full
// cleartext dump of every private key is not left readable on disk. Idempotent + error-tolerant
// (safe on a missing/locked file). (H-02)
void App::scrubAndRemoveExport(const std::string& path)
{
if (path.empty()) return;
util::Platform::secureWipeAndRemove(path);
}
// Startup net for H-02: a crash/kill/early-return between exporting the cleartext keys and scrubbing them
// could leave an obsidiandecryptexport* file behind. Purge any found in the data dir on launch.
void App::sweepStaleDecryptExports()
{
std::error_code ec;
const std::string dir = util::Platform::getDragonXDataDir();
std::filesystem::directory_iterator it(dir, ec), end;
for (; it != end; it.increment(ec)) {
if (ec) break;
const std::string name = it->path().filename().string();
if (name.rfind("obsidiandecryptexport", 0) == 0) {
scrubAndRemoveExport(it->path().string());
DEBUG_LOGF("[decrypt] swept stale plaintext key export: %s\n", name.c_str());
}
}
}
void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestartStatus) {
// Both callers have just had encryptwallet succeed. Record it here: the first-run wizard's
// deferred path never did, and the reconnect after the restart usually lands in RPC warmup, where
// the connect-time getwalletinfo fails and can't correct it.
state_.encrypted = true;
state_.locked = true;
state_.unlocked_until = 0;
state_.encryption_state_known = true;
// encryptwallet succeeded: the wizard's persisted request is satisfied. Clear it HERE (not only at the next
// connect) so a quit before the reconnect can't leave a stale flag (C88).
if (settings_ && settings_->getEncryptionPending()) {
settings_->setEncryptionPending(false);
settings_->save();
}
if (isUsingEmbeddedDaemon()) {
if (announceRestartStatus) {
// Update connection_status_ so the loading overlay explains why
// the daemon is restarting.
connection_status_ = TR("restarting_after_encryption");
}
// Gate the main-loop reconnect while the daemon is down (so tryConnect can't hit the stopped
// node or start a duplicate) and block a concurrent wallet switch/rescan, which check this flag.
daemon_restarting_ = true;
// Mark the main client disconnected so the update loop's reconnect path (tryConnect, held back
// by daemon_restarting_) picks the restarted node up. Connected-but-locked no longer re-runs
// the connect sequence on its own. Not onDisconnected(): its state_.clear() would drop the
// encrypted/locked flags just set for the lock screen. Abort any in-flight call first:
// disconnect() takes the curl lock, which a worker call (say a balance scan queued behind
// encryptwallet) holds for its whole transfer, up to the 30 s timeout. open() clears the abort
// when tryConnect reconnects.
if (rpc_) {
rpc_->requestAbort();
rpc_->disconnect();
}
// Give daemon a moment to shut down, then restart
// (do this off the main thread to avoid stalling the UI)
async_tasks_.submit(taskName, [this](const util::AsyncTaskManager::Token& token) {
// daemon_restarting_ MUST be cleared on every exit (incl. an early-out or a throw), else it
// stays stuck true and wedges reconnect + all future switch/rescan/encryption operations.
try {
for (int i = 0; i < 20 && !token.cancelled() && !shutting_down_; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
if (!token.cancelled() && !shutting_down_) {
if (stopEmbeddedDaemon() == daemon::StopOutcome::Exited) {
if (!token.cancelled() && !shutting_down_) startEmbeddedDaemon();
} else if (!shutting_down_) {
// Still running: never start a second node over it. The connect loop goes back to it.
DEBUG_LOGF("[App] encryption restart: the node did not exit; not restarting it\n");
pending_stop_failed_.fetch_or(kStopFailedNotify);
}
}
} catch (...) {}
daemon_restarting_ = false; // re-arm reconnect (tryConnect runs from the update loop)
});
} else {
ui::Notifications::instance().warning(
TR("sec_restart_daemon_for_encryption"));
}
}
void App::encryptWalletWithPassphrase(const std::string& passphrase) {
if (!rpc_ || !rpc_->isConnected()) return;
if (refuseIfNodeBusy()) return; // encrypting ends in a node restart
encrypt_in_progress_ = true;
encrypt_status_ = TR("sec_encrypting_wallet");
if (worker_) {
worker_->post([this, passphrase]() mutable -> rpc::RPCWorker::MainCb {
WalletSecurityRpcAdapter rpcAdapter(rpc_.get());
auto result = wallet_security_.runDeferredEncryption(
{std::move(passphrase), {}}, rpcAdapter, nullptr);
if (result.encrypted) {
return [this]() {
encrypt_in_progress_ = false;
encrypt_status_ = TR("sec_wallet_encrypted_restarting_daemon");
DEBUG_LOGF("[App] Wallet encrypted — restarting daemon\n");
// Immediately update local encryption state so the
// settings page reflects that the wallet is now encrypted
// (the daemon is about to restart, so getwalletinfo won't
// be available for a while).
state_.encrypted = true;
state_.locked = true;
state_.unlocked_until = 0;
state_.encryption_state_known = true;
// Transition settings dialog to PIN setup phase
if (show_encrypt_dialog_ &&
encrypt_dialog_phase_ == EncryptDialogPhase::Encrypting) {
encrypt_dialog_phase_ = EncryptDialogPhase::PinSetup;
}
ui::Notifications::instance().info(
TR("sec_wallet_encrypted_successfully"), 5.0f);
// The daemon shuts itself down after encryptwallet.
// Update connection_status_ so the loading overlay
// explains why the daemon is restarting.
restartDaemonAfterEncryption("encrypt-daemon-restart",
/*announceRestartStatus=*/true);
};
} else {
std::string err = result.error;
return [this, err]() {
encrypt_in_progress_ = false;
encrypt_status_ = std::string(TR("sec_encryption_failed_prefix")) + err;
DEBUG_LOGF("[App] encryptwallet failed: %s\n", err.c_str());
ui::Notifications::instance().error(
std::string(TR("sec_encryption_failed_prefix")) + err);
// Return to passphrase entry on failure
if (show_encrypt_dialog_ &&
encrypt_dialog_phase_ == EncryptDialogPhase::Encrypting) {
encrypt_dialog_phase_ = EncryptDialogPhase::PassphraseEntry;
}
};
}
});
}
}
// ---------------------------------------------------------------------------
// Deferred encryption — runs after wizard exits, encrypts wallet in background
// Called every frame from render() until the task completes.
// ---------------------------------------------------------------------------
void App::processDeferredEncryption() {
if (!wallet_security_.hasDeferredEncryption()) return;
// Phase 1: wait for daemon connection
if (!state_.connected || !rpc_ || !rpc_->isConnected()) {
if (wallet_security_.shouldAttemptDeferredConnect(ImGui::GetTime())) {
if (!connection_in_progress_) {
// Just try to connect — tryConnect is now async
tryConnect();
if (!isEmbeddedDaemonRunning() && isUsingEmbeddedDaemon()) {
startEmbeddedDaemon();
}
}
}
return; // try again next frame
}
// Phase 2: connected — launch encryption
if (!encrypt_in_progress_) {
auto deferredEncryption = wallet_security_.deferredEncryption();
std::string passphrase = std::move(deferredEncryption.passphrase);
std::string pin = std::move(deferredEncryption.pin);
encrypt_in_progress_ = true;
encrypt_status_ = TR("sec_encrypting_wallet");
if (worker_) {
worker_->post([this, request = services::WalletSecurityController::DeferredEncryptionSnapshot{std::move(passphrase), std::move(pin)}]() mutable -> rpc::RPCWorker::MainCb {
WalletSecurityRpcAdapter rpcAdapter(rpc_.get());
WalletSecurityVaultAdapter vaultAdapter(vault_.get());
auto result = wallet_security_.runDeferredEncryption(
std::move(request), rpcAdapter, vault_ ? &vaultAdapter : nullptr);
if (result.encrypted) {
return [this, result]() {
encrypt_in_progress_ = false;
encrypt_status_.clear();
DEBUG_LOGF("[App] Wallet encrypted (deferred)\n");
// Finalize PIN settings on main thread
if (result.pinProvided) {
if (result.pinStored) {
pin_vault_stale_ = false;
lock_use_pin_ = true;
settings_->setPinEnabled(true);
settings_->save();
ui::Notifications::instance().info(TR("sec_wallet_encrypted_and_pin_set"), 5.0f);
} else {
ui::Notifications::instance().warning(
TR("sec_wallet_encrypted_but_pin_vault_failed"));
}
} else {
ui::Notifications::instance().info(TR("sec_wallet_encrypted_successfully"), 5.0f);
}
wallet_security_.clearDeferredEncryption();
// Restart daemon (it shuts itself down after encryptwallet)
restartDaemonAfterEncryption("deferred-encrypt-daemon-restart",
/*announceRestartStatus=*/false);
};
} else {
std::string err = result.error;
return [this, err]() {
encrypt_in_progress_ = false;
encrypt_status_ = std::string(TR("sec_encryption_failed_prefix")) + err;
DEBUG_LOGF("[App] Deferred encryptwallet failed: %s\n", err.c_str());
ui::Notifications::instance().error(std::string(TR("sec_encryption_failed_prefix")) + err);
wallet_security_.clearDeferredEncryption();
};
}
});
}
}
}
// Shared main-thread success continuation for both the passphrase and PIN unlock paths.
// walletpassphrase has already succeeded on the worker thread, so set the unlocked state
// directly rather than issuing another RPC round-trip.
void App::applyUnlockSuccess(const std::string& passphrase, int timeout) {
lock_unlock_in_progress_ = false;
lock_error_msg_.clear();
lock_attempts_ = 0;
// Clear the persisted lockout on a successful unlock (guarded so a normal unlock isn't a disk write).
if (settings_ && settings_->getLockFailedAttempts() != 0) {
settings_->setLockFailedAttempts(0);
settings_->setLockoutUntilEpoch(0);
settings_->save();
}
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
idle_.noteActivity();
state_.encrypted = true;
state_.locked = false;
state_.unlocked_until = std::time(nullptr) + timeout;
unlockTransactionHistoryCacheWithPassphrase(passphrase);
}
// Shared main-thread failure continuation: bump the attempt counter, surface the error,
// and apply the escalating lockout curve. Every failed unlock path routes through here so
// none can silently skip the lockout math (the PIN RPC-error path previously did).
void App::applyUnlockFailure(const std::string& errorMessage) {
lock_unlock_in_progress_ = false;
lock_attempts_++;
lock_error_msg_ = errorMessage;
lock_error_timer_ = 3.0f;
lock_timer_armed_at_ = std::chrono::steady_clock::now();
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
const float baseDelay = ui::schema::UI().drawElement("security", "lockout-base-delay").sizeOr(2.0f);
const int maxAttempts = (int)ui::schema::UI().drawElement("security", "max-attempts-before-lockout").sizeOr(5.0f);
if (lock_attempts_ >= maxAttempts) {
lock_lockout_timer_ = baseDelay * (float)(1 << std::min(lock_attempts_ - maxAttempts, 8));
}
// Persist the attempt count + lockout expiry (keyed to this wallet) so killing and relaunching the
// app can't reset the escalating lockout. The PIN/passphrase itself is never stored.
if (settings_) {
settings_->setLockStateWallet(settings_->getActiveWalletFile());
settings_->setLockFailedAttempts(lock_attempts_);
settings_->setLockoutUntilEpoch(lock_lockout_timer_ > 0.0f
? (long long)std::time(nullptr) + (long long)std::ceil(lock_lockout_timer_) : 0);
settings_->save();
}
DEBUG_LOGF("[App] Wallet unlock failed (attempt %d)\n", lock_attempts_);
}
// Restore the persisted lockout at the start of a lock-screen session so a kill-and-relaunch can't reset
// the escalating failure curve. Only applies when the persisted state is for the active wallet.
void App::restoreLockoutState() {
if (!settings_) return;
if (settings_->getLockStateWallet() != settings_->getActiveWalletFile()) return;
lock_attempts_ = std::max(0, std::min(settings_->getLockFailedAttempts(), 64));
long long remaining = settings_->getLockoutUntilEpoch() - (long long)std::time(nullptr);
// Cap the restored countdown so a forward clock change can't create an absurd wait. The attempt count
// still persists, so rolling the clock back only buys a short window before the next failure re-arms
// the full curve.
const float baseDelay = ui::schema::UI().drawElement("security", "lockout-base-delay").sizeOr(2.0f);
const long long maxLock = (long long)(baseDelay * (float)(1 << 8));
if (remaining > maxLock) remaining = maxLock;
if (remaining > 0) {
lock_lockout_timer_ = (float)remaining;
lock_timer_armed_at_ = std::chrono::steady_clock::now();
}
}
// Failure that is NOT a wrong credential (busy node, not-encrypted, unknown error): surface the
// message and clear the buffer, but do not bump lock_attempts_ or arm the lockout.
void App::applyUnlockNotCounted(const std::string& msg) {
lock_unlock_in_progress_ = false;
lock_error_msg_ = msg;
lock_error_timer_ = 3.0f;
lock_timer_armed_at_ = std::chrono::steady_clock::now();
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
}
void App::unlockWallet(const std::string& passphrase, int timeout) {
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
lock_unlock_in_progress_ = true;
// Use fast-lane worker to bypass head-of-line blocking behind refreshData.
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
w->post([this, r, passphrase = std::string(passphrase), timeout]() mutable -> rpc::RPCWorker::MainCb {
std::string err_msg;
rpc::UnlockFailure cls = rpc::UnlockFailure::Other;
bool ok = false;
try {
rpc::RPCClient::TraceScope trace("Security / Unlock");
r->call("walletpassphrase", {passphrase, timeout});
ok = true;
} catch (const std::exception& e) {
err_msg = e.what();
cls = rpc::classifyUnlockFailure(e);
}
std::string cachePassphrase = passphrase;
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
return [this, ok, cls, err_msg, timeout, passphrase = std::move(cachePassphrase)]() mutable {
if (ok) {
applyUnlockSuccess(passphrase, timeout);
} else {
DEBUG_LOGF("[App] Passphrase unlock RPC error: %s\n", err_msg.c_str());
switch (cls) {
case rpc::UnlockFailure::WrongPassphrase:
applyUnlockFailure(TR("incorrect_passphrase"));
break;
case rpc::UnlockFailure::NodeBusy:
applyUnlockNotCounted(TR("sec_unlock_node_busy"));
break;
case rpc::UnlockFailure::NotEncrypted:
applyUnlockNotCounted(TR("sec_unlock_node_busy"));
refreshWalletEncryptionState();
break;
case rpc::UnlockFailure::Other:
applyUnlockNotCounted(std::string(TR("sec_unlock_failed_prefix")) + err_msg);
break;
}
}
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
};
});
}
void App::lockWallet() {
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
if (lock_unlock_in_progress_) return; // Prevent duplicate async calls
wipeDisplayedSecrets(); // hide any revealed seed/key immediately, before the async lock round-trip
lock_unlock_in_progress_ = true;
// Use fast-lane worker to avoid blocking behind refreshData.
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
w->post([this, r]() -> rpc::RPCWorker::MainCb {
bool ok = false;
try {
r->call("walletlock");
ok = true;
} catch (...) {}
return [this, ok]() {
lock_unlock_in_progress_ = false;
if (ok) {
state_.locked = true;
state_.unlocked_until = 0;
lockTransactionHistoryCacheSession(); // keeps the history's scan progress
lock_failure_warned_ = false;
DEBUG_LOGF("[App] Wallet locked\n");
} else {
// The walletlock RPC failed — the wallet is still UNLOCKED. Surface it (once) rather
// than silently leaving an auto-lock unfulfilled and the wallet exposed (W2-4).
DEBUG_LOGF("[App] walletlock failed — wallet remains unlocked\n");
if (!lock_failure_warned_) {
lock_failure_warned_ = true;
ui::Notifications::instance().warning(
TR("sec_couldnt_lock_wallet"), 12.0f);
}
}
};
});
}
void App::changePassphrase(const std::string& oldPass, const std::string& newPass) {
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
encrypt_in_progress_ = true;
encrypt_status_ = TR("sec_changing_passphrase");
// The vault this change applies to, and whether it changes the passphrase at all. The success
// callback decides about the vault with the state at THAT time (C87).
const std::string vaultPath = vault_ ? vault_->getVaultPath() : std::string();
const bool samePass = (oldPass == newPass);
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
w->post([this,
r,
oldPass = std::string(oldPass),
newPass = std::string(newPass),
vaultPath,
samePass]() mutable -> rpc::RPCWorker::MainCb {
bool ok = false;
std::string err_msg;
try {
r->call("walletpassphrasechange", {oldPass, newPass});
ok = true;
} catch (const std::exception& e) {
err_msg = e.what();
}
std::string cacheNewPass = newPass;
util::SecureVault::secureZero(oldPass.data(), oldPass.size());
util::SecureVault::secureZero(newPass.data(), newPass.size());
return [this,
ok,
err_msg,
newPass = std::move(cacheNewPass),
vaultPath,
samePass]() mutable {
encrypt_in_progress_ = false;
if (ok) {
encrypt_status_.clear();
show_change_passphrase_ = false;
memset(change_old_pass_buf_, 0, sizeof(change_old_pass_buf_));
memset(change_new_pass_buf_, 0, sizeof(change_new_pass_buf_));
memset(change_confirm_buf_, 0, sizeof(change_confirm_buf_));
unlockTransactionHistoryCacheWithPassphrase(newPass);
// New key: the old ciphertext is unreadable with it. Write now unless the unlock just did
// (keyId() is part of the content signature, so Now skips only if the same key is submitted).
storeTransactionHistoryCacheIfAvailable(data::HistoryStoreMode::Now);
const bool vaultNow = vault_ && vault_->hasVault();
if (data::decideVaultAfterPassphraseChange(true, vaultNow, samePass, vaultPath,
vault_ ? vault_->getVaultPath() : std::string()) == data::VaultAfterChange::MarkStale) {
// The PIN vault still seals the OLD passphrase. Keep the file (the user removes the PIN
// and sets it again), but stop offering the PIN this session and say so.
lock_use_pin_ = false;
pin_vault_stale_ = true;
DEBUG_LOGF("[App] Passphrase changed; the PIN vault is kept but marked out of date\n");
ui::Notifications::instance().warning(TR("sec_passphrase_changed_pin_stale"), 10.0f);
} else {
ui::Notifications::instance().info(TR("sec_passphrase_changed_successfully"));
}
} else {
encrypt_status_ = std::string(TR("sec_failed_prefix")) + err_msg;
}
util::SecureVault::secureZero(newPass.data(), newPass.size());
};
});
}
// ===========================================================================
// Refresh wallet encryption state (from getwalletinfo)
// ===========================================================================
void App::refreshWalletEncryptionState() {
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
worker_->post([this]() -> rpc::RPCWorker::MainCb {
json result;
bool ok = false;
try {
rpc::RPCClient::TraceScope trace("Security / Wallet encryption state");
result = rpc_->call("getwalletinfo");
ok = true;
} catch (...) {}
if (!ok) return nullptr;
return [this, result]() {
try {
if (result.contains("unlocked_until")) {
state_.encrypted = true;
int64_t until = result["unlocked_until"].get<int64_t>();
state_.unlocked_until = until;
state_.locked = (until == 0);
state_.encryption_state_known = true;
if (state_.locked) {
lockTransactionHistoryCacheSession();
} else if (state_.transactions.empty()) {
loadTransactionHistoryCacheIfAvailable();
} else {
storeTransactionHistoryCacheIfAvailable();
}
} else {
state_.encrypted = false;
state_.locked = false;
state_.unlocked_until = 0;
state_.encryption_state_known = true;
if (state_.transactions.empty()) {
loadTransactionHistoryCacheIfAvailable();
} else {
storeTransactionHistoryCacheIfAvailable();
}
// Wallet is no longer encrypted — if a PIN vault exists,
// it's stale (passphrase it protects is gone). Reset PIN
// as if it were never set.
if (vault_ && vault_->hasVault()) {
DEBUG_LOGF("[App] Wallet unencrypted but PIN vault exists — removing stale vault\n");
vault_->removeVault();
}
if (settings_ && settings_->getPinEnabled()) {
settings_->setPinEnabled(false);
settings_->save();
}
}
} catch (...) {}
};
});
}
void App::reconcileEncryptionPending(const services::NetworkRefreshService::WalletEncryptionResult& enc)
{
if (!settings_) return;
using Act = data::EncryptionPendingAction;
switch (data::decideEncryptionPending(settings_->getEncryptionPending(), enc.ok, enc.encrypted,
wallet_security_.hasDeferredEncryption(), encrypt_in_progress_,
encryption_incomplete_warned_)) {
case Act::Clear:
// The wallet is encrypted, however it got there (deferred, Settings, another client).
settings_->setEncryptionPending(false);
settings_->save();
break;
case Act::Warn:
// Requested but NOT encrypted, and nothing pending/in flight: lost to a quit/crash or failed connect.
// The flag stays set, so this recurs each launch until the wallet is actually encrypted (W2-2).
encryption_incomplete_warned_ = true;
ui::Notifications::instance().warning(TR("sec_encryption_did_not_complete"), 30.0f);
break;
case Act::None:
break;
}
}
// ===========================================================================
// Auto-lock on idle
// ===========================================================================
void App::checkAutoLock() {
if (capture_mode_) return; // don't auto-lock while a UI sweep forces the encrypted/locked state
if (!state_.isEncrypted() || state_.isLocked()) return;
// Don't auto-lock while SOLO mining — it runs in the daemon and locking the wallet there would stop
// it (and it shields matured rewards, which needs the wallet). Pool mining pays an external address
// and needs no unlocked wallet, so it must NOT hold the wallet open indefinitely.
if (state_.mining.generate) {
// Keep resetting the idle timer so we don't lock the instant solo mining stops.
idle_.noteActivity();
return;
}
int timeout = settings_ ? settings_->getAutoLockTimeout() : 300;
if (timeout <= 0) return; // disabled
const double elapsed = idle_.idleSeconds();
if (elapsed >= (double)timeout) {
// Lite has no daemon `walletlock` — App::lockWallet() early-returns without rpc_, so route
// lite through lockLiteWallet() (locks the backend + tears down the chat session so no
// decrypted store / unlocked DB key survives the idle lock). In full-node builds
// lite_wallet_ is always null, so this branch is a no-op and behaviour is unchanged.
if (lite_wallet_) lockLiteWallet();
else lockWallet();
DEBUG_LOGF("[App] Auto-locked wallet after %d seconds idle\n", timeout);
}
}
void App::reconcileUnlockExpiry()
{
// Full node only: Lite has no daemon timer (unlocked_until stays 0), and its lock is the backend's.
if (lite_wallet_ || !state_.connected || !rpc_ || !rpc_->isConnected()) return;
if (!data::unlockWindowExpired(state_.encrypted, state_.locked, state_.unlocked_until,
static_cast<std::int64_t>(std::time(nullptr)))) return;
if (lock_unlock_in_progress_ || encrypt_in_progress_ || pin_in_progress_) return; // an unlock/lock/PIN change decides it
const auto now = std::chrono::steady_clock::now();
if (now < unlock_reconcile_retry_at_) return;
// Back-off, wall-clock independent. refreshEncryptionState() coalesces via Job::Encryption and its apply path
// (applyWalletEncryptionResult) is authoritative: unlocked_until == 0 => locked (lock screen + tx-cache
// lock); a newer deadline (e.g. a console walletpassphrase, or the lock-screen re-verify) is adopted. If the
// daemon clock is skewed and still reports "unlocked", this retries every 30 s, one cheap-ish getwalletinfo.
unlock_reconcile_retry_at_ = now + std::chrono::seconds(30);
refreshEncryptionState();
}
// ===========================================================================
// Mine when idle — auto-start/stop mining based on system idle state
// Supports two modes:
// 1. Start/Stop mode (default): start mining when idle, stop when active
// 2. Thread scaling mode: mining stays running, thread count changes
// ===========================================================================
void App::checkIdleMining() {
if (!settings_ || !settings_->getMineWhenIdle()) {
// Feature disabled — if we previously auto-started, stop now
if (idle_mining_active_) {
idle_mining_active_ = false;
idle_scaled_to_idle_ = false;
if (settings_ && (settings_->getPoolMode() || !supportsSoloMining())) {
if (xmrig_manager_ && xmrig_manager_->isRunning())
stopPoolMining();
} else {
if (state_.mining.generate)
stopMining();
}
}
// Reset scaling state when feature is off
if (idle_scaled_to_idle_) idle_scaled_to_idle_ = false;
return;
}
// Skip idle mining adjustments while thread benchmark is running
if (ui::IsMiningBenchmarkActive()) return;
// The decisions below work on a seconds scale (the idle delay is tens of seconds or more), so check
// at 1 Hz. This used to run every frame: an X11 idle query and, with GPU-aware idle, an nvidia-smi
// launch on the UI thread per frame. Steady clock rather than ImGui::GetTime(), whose clock stops
// while minimized (update() still runs then).
const auto now = std::chrono::steady_clock::now();
// ...except while a miner this feature started (or scaled up) is running: it must yield on the first
// frame that sees the user back, and frames can be seconds apart when the app idles, so a gated frame
// may be the only one carrying that input. The idle query is cheap; the GPU probe has its own cache.
if (!(idle_mining_active_ || idle_scaled_to_idle_) && now < idle_mining_next_check_) return;
idle_mining_next_check_ = now + std::chrono::seconds(1);
int idleSec = util::Platform::getSystemIdleSeconds();
int delay = settings_->getMineIdleDelay();
bool isPool = settings_->getPoolMode() || !supportsSoloMining();
if (isPool && !supportsPoolMining()) return;
bool threadScaling = settings_->getIdleThreadScaling();
int maxThreads = std::max(1, (int)std::thread::hardware_concurrency());
// Check if mining is already running (manually started by user)
bool miningActive = isPool
? (xmrig_manager_ && xmrig_manager_->isRunning())
: state_.mining.generate;
// Thread scaling only adjusts a miner the user started: with none running there is nothing to
// decide, or to probe for.
if (threadScaling && (!miningActive || mining_toggle_in_progress_.load())) return;
// GPU-aware idle detection: if enabled, treat GPU utilization >= 10% as "user active" (e.g. watching
// a video). Disabled = unrestricted mode that only looks at keyboard/mouse input. The probe can only
// matter once input is idle and a decision can change (start/stop mode leaves a miner the user
// started alone), so it runs only then, at most every 5 s, with hysteresis (busy at >= 10%, clear
// below 5%) so a GPU hovering near 10% can't keep restarting the miner. Unknown (-1) reads as not busy.
const bool inputIdle = idleSec >= delay;
const bool decisionPossible = threadScaling || !miningActive || idle_mining_active_;
bool gpuBusy = false;
if (inputIdle && decisionPossible && settings_->getIdleGpuAware()) {
if (now >= idle_gpu_next_sample_) {
const int gpuUtil = util::Platform::getGpuUtilization();
if (gpuUtil >= 10) idle_gpu_busy_ = true;
else if (gpuUtil < 5) idle_gpu_busy_ = false;
idle_gpu_next_sample_ = now + std::chrono::seconds(5);
}
gpuBusy = idle_gpu_busy_;
} else if (!inputIdle) {
idle_gpu_busy_ = false; // input activity decides now; sample afresh once idle again
idle_gpu_next_sample_ = {};
}
bool systemIdle = inputIdle && !gpuBusy;
if (threadScaling) {
// --- Thread scaling mode ---
// Mining is already running (started by user; checked above). We just adjust threads.
int activeThreads = settings_->getIdleThreadsActive();
int idleThreads = settings_->getIdleThreadsIdle();
// Resolve auto values: active defaults to half, idle defaults to all
if (activeThreads <= 0) activeThreads = std::max(1, maxThreads / 2);
if (idleThreads <= 0) idleThreads = maxThreads;
// Clamp to [1, logical cores] before these reach setgenerate / startPoolMining — a settings field
// could otherwise carry an arbitrary count straight past every bound. (M-06)
activeThreads = std::clamp(activeThreads, 1, maxThreads);
idleThreads = std::clamp(idleThreads, 1, maxThreads);
if (systemIdle) {
// System is idle — scale up to idle thread count
if (!idle_scaled_to_idle_) {
idle_scaled_to_idle_ = true;
if (isPool) {
retargetPoolMining(idleThreads);
} else {
startMining(idleThreads);
}
DEBUG_LOGF("[App] Idle thread scaling: %d -> %d threads (idle)\n", activeThreads, idleThreads);
}
} else {
// User is active (or GPU busy) — scale down to active thread count
if (idle_scaled_to_idle_) {
idle_scaled_to_idle_ = false;
if (isPool) {
retargetPoolMining(activeThreads);
} else {
startMining(activeThreads);
}
DEBUG_LOGF("[App] Idle thread scaling: %d -> %d threads (active)\n", idleThreads, activeThreads);
} else {
// Mining just started while user is active — ensure active
// thread count is applied (grid selection may differ).
// Pool: compare with the count last REQUESTED (a restart still queued on the control thread
// included), not the xmrig API's last report, which lags a restart by seconds.
int currentThreads = isPool
? services::effectivePoolThreads(pool_threads_target_.load(std::memory_order_relaxed),
xmrig_manager_->getRequestedThreads(),
xmrig_manager_->getStats().threads_active)
: state_.mining.genproclimit;
if (currentThreads > 0 && currentThreads != activeThreads) {
if (isPool) {
retargetPoolMining(activeThreads);
} else {
startMining(activeThreads);
}
DEBUG_LOGF("[App] Idle thread scaling: initial %d -> %d threads (active)\n", currentThreads, activeThreads);
}
}
}
} else {
// --- Start/Stop mode (original behavior) ---
if (systemIdle) {
// System is idle — start mining if not already running
if (!miningActive && !idle_mining_active_ && !mining_toggle_in_progress_.load()) {
// For solo mining, need daemon connected and synced
if (!isPool && (!state_.connected || state_.sync.syncing)) return;
int threads = settings_->getPoolThreads();
if (threads <= 0) threads = std::max(1, maxThreads / 2);
idle_mining_active_ = true;
if (isPool)
startPoolMining(threads);
else
startMining(threads);
DEBUG_LOGF("[App] Idle mining started after %d seconds idle\n", idleSec);
}
} else {
// User is active — stop mining if we auto-started it
if (idle_mining_active_) {
idle_mining_active_ = false;
if (isPool) {
if (xmrig_manager_ && xmrig_manager_->isRunning())
stopPoolMining();
} else {
if (state_.mining.generate)
stopMining();
}
DEBUG_LOGF("[App] Idle mining stopped — user returned\n");
}
}
}
}
// ===========================================================================
// Mining thread-count picks — coalesced (one save + at most one miner change per gesture)
// ===========================================================================
namespace {
double miningSteadySeconds()
{
return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
} // namespace
void App::selectMiningThreads(int threads)
{
if (!settings_) return;
threads = std::clamp(threads, 1, std::max(1, (int)std::thread::hardware_concurrency()));
if (settings_->getPoolThreads() != threads) { // in memory now; saved once when the pick settles
settings_->setPoolThreads(threads);
mining_threads_save_pending_ = true;
}
mining_thread_commit_.select(threads, miningSteadySeconds());
}
void App::tickMiningThreadCommit()
{
if (shutting_down_ || !settings_) return; // beginShutdown() saves settings itself
const double now = miningSteadySeconds();
// The grid holds the commit while the button is down. The release is noticed HERE, not by the tab, so
// a drag that ends while the Mining tab isn't drawn (page switch, focus loss) still commits.
bool held = false;
if (mining_thread_drag_) {
if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) held = true;
else { mining_thread_drag_ = false; mining_thread_commit_.release(now); }
}
// The benchmark owns the miner's thread count while it runs (its cancel/finish restore it): keep the
// pick in settings, drop the miner change.
if (ui::IsMiningBenchmarkActive()) {
mining_thread_commit_.cancel();
mining_solo_threads_pending_ = 0;
if (mining_threads_save_pending_) { mining_threads_save_pending_ = false; settings_->save(); }
return;
}
int threads = 0;
if (mining_thread_commit_.take(now, held, threads)) {
if (mining_threads_save_pending_) { mining_threads_save_pending_ = false; settings_->save(); }
retargetPoolMining(threads); // restarts xmrig once, only if it is running
mining_solo_threads_pending_ = threads; // setgenerate, once no other toggle is in flight
} else if (mining_thread_commit_.pending() && !held) {
ui::material::RequestProgressFrame(); // keep frames coming until the debounce deadline
}
// Solo: startMining() drops a call while another setgenerate is in flight, so wait for it (its result
// wakes the loop). Applies only while solo mining is on, as before.
if (mining_solo_threads_pending_ > 0 && !mining_toggle_in_progress_.load(std::memory_order_relaxed)) {
const int t = mining_solo_threads_pending_;
mining_solo_threads_pending_ = 0;
if (state_.mining.generate) startMining(t);
}
}
// ===========================================================================
// Restart Setup Wizard (from Settings)
// ===========================================================================
// ===========================================================================
// Lock Screen Rendering
// ===========================================================================
void App::renderLockScreen() {
using namespace ui::material;
// Restore any persisted lockout once per lock-screen session (re-armed on wallet switch).
if (!lock_state_restored_) {
lock_state_restored_ = true;
restoreLockoutState();
}
// Keep frames coming while something on the lock screen is moving without input: the unlock in
// flight (its dots), the lockout countdown, an error message about to clear.
if (lock_unlock_in_progress_ || lock_lockout_timer_ > 0.0f) {
RequestProgressFrame();
}
// Full-window lock overlay: cover the ENTIRE viewport (sidebar included) so the whole screen is
// obscured while locked, not just the tab-content area. A borderless, transparent, focused window
// over the main viewport (same pattern as the modal overlays; opened from within ##ContentArea,
// like the portfolio modal). Ended at the bottom of this function.
ImGuiViewport* vp = ImGui::GetMainViewport();
ImVec2 winPos = vp->Pos, winSize = vp->Size;
ImGui::SetNextWindowPos(winPos);
ImGui::SetNextWindowSize(winSize);
ImGui::SetNextWindowFocus();
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0, 0, 0, 0)); // transparent — we draw the backdrop
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
ImGui::Begin("##LockOverlay", nullptr,
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse |
ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoSavedSettings);
ImDrawList* dl = ImGui::GetWindowDrawList();
// Consume input to everything behind (sidebar included) while locked. AllowOverlap so the lock
// card's widgets (drawn later, on top of this full-window button) still receive clicks/hover —
// without it this blocker shadows them (dead clicks + a whole-screen hand cursor).
ImGui::SetNextItemAllowOverlap();
ImGui::SetCursorScreenPos(winPos);
ImGui::InvisibleButton("##LockInputBlocker", winSize,
ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight | ImGuiButtonFlags_MouseButtonMiddle);
// Live-blur backdrop over the wallet content while locked — matches the app's modal look AND
// obscures the wallet for privacy (the old backdrop defaulted to 0 = fully visible). Capture-once
// on lock-open + resize; the opaque base inside DrawFullWindowBlurBackdrop guarantees the wallet
// is obscured even when the blur is unavailable (low-spec / acrylic-off / first frame / capture
// failure), so this can't leak content. Purely visual — the auth logic below is untouched.
ImVec2 winMax(winPos.x + winSize.x, winPos.y + winSize.y);
{
BlurCaptureState& cap = BlurCaptureStateFor("##LockScreen");
int frame = ImGui::GetFrameCount();
bool justLocked = (cap.lastSeenFrame < frame - 1);
cap.lastSeenFrame = frame;
if (justLocked || cap.w != winSize.x || cap.h != winSize.y) cap.captureFrames = 3;
cap.w = winSize.x; cap.h = winSize.y;
CapturingBlurBackdropRef() = (cap.captureFrames > 0);
if (cap.captureFrames > 0) {
if (ImDrawCallback liveCap = ui::effects::ImGuiAcrylic::GetLiveCaptureCallback()) {
dl->AddCallback(liveCap, nullptr);
dl->AddCallback(ImDrawCallback_ResetRenderState, nullptr);
}
cap.captureFrames--;
}
DrawFullWindowBlurBackdrop(dl, winPos, winMax, /*allowBlur=*/!justLocked);
MarkBlurOverlayDrawn(nullptr); // suppress foreground theme-effect bleed + re-capture on unlock
}
// Card. The lock screen is fully hand-drawn (dl->AddText at manual y offsets), so — unlike
// BeginOverlayDialog cards — nothing scales automatically. Multiply every geometry literal
// (card size, logo, input/button widths, vertical gaps) by dpiScale() so the card grows with
// OS DPI / font scale instead of stranding a native-size card in a large window. The font
// metrics (LegacySize / CalcTextSize) are already DPI-scaled via the atlas, so leave those.
const float dp = ui::Layout::dpiScale();
const auto& S = ui::schema::UI();
float cardW = S.drawElement("screens.lock-screen", "card").getFloat("width", 400.0f);
float cardH = S.drawElement("screens.lock-screen", "card").height;
if (cardW <= 0) cardW = 400.0f;
if (cardH <= 0) cardH = 320.0f;
cardW *= dp;
cardH *= dp;
float cardX = winPos.x + (winSize.x - cardW) * 0.5f;
float cardY = winPos.y + (winSize.y - cardH) * 0.5f;
ImVec2 cardMin(cardX, cardY);
ImVec2 cardMax(cardX + cardW, cardY + cardH);
// Match the dialog cards: the same glass panel BeginOverlayDialog draws (renders opaque-dark on
// the blur via the sole-consumer fallback), instead of a flat light SurfaceVariant fill.
GlassPanelSpec cardGlass;
cardGlass.rounding = 16.0f;
cardGlass.fillAlpha = 35;
cardGlass.borderAlpha = 50;
cardGlass.borderWidth = 1.0f;
DrawGlassPanel(dl, cardMin, cardMax, cardGlass);
float cy = cardY + 24.0f * dp;
// Logo
float logoSize = S.drawElement("screens.lock-screen", "logo").sizeOr(64.0f) * dp;
if (logo_tex_ != 0) {
float aspect = (logo_h_ > 0) ? (float)logo_w_ / (float)logo_h_ : 1.0f;
float logoW = logoSize * aspect;
float logoX = cardX + (cardW - logoW) * 0.5f;
dl->AddImage(logo_tex_, ImVec2(logoX, cy), ImVec2(logoX + logoW, cy + logoSize));
}
cy += logoSize + 16.0f * dp;
// Title
ImFont* titleFont = S.resolveFont(S.label("screens.lock-screen", "title").font);
if (!titleFont) titleFont = ui::material::Type().h5();
ImFont* captionFont = S.resolveFont(S.label("screens.lock-screen", "error-text").font);
if (!captionFont) captionFont = ui::material::Type().caption();
ImU32 textCol = ui::material::OnSurface();
{
const char* title = TR("sec_wallet_locked_title");
ImVec2 ts = titleFont->CalcTextSizeA(titleFont->LegacySize, FLT_MAX, 0, title);
dl->AddText(titleFont, titleFont->LegacySize,
ImVec2(cardX + (cardW - ts.x) * 0.5f, cy), textCol, title);
cy += ts.y + 20.0f * dp;
}
// Count down only the time since the timers were armed: a failed unlock arms them from an RPC result,
// which can land on a frame that ends an idle gap the countdown would otherwise lose at once.
const float lockDt = std::min(logic_dt_, std::chrono::duration<float>(
std::chrono::steady_clock::now() - lock_timer_armed_at_).count());
// Lockout timer
if (lock_lockout_timer_ > 0.0f) {
lock_lockout_timer_ -= lockDt;
if (lock_lockout_timer_ < 0) lock_lockout_timer_ = 0;
char msg[128];
snprintf(msg, sizeof(msg), TR("sec_too_many_attempts_wait"), lock_lockout_timer_);
ImVec2 ms = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, msg);
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(cardX + (cardW - ms.x) * 0.5f, cy), ui::material::Warning(), msg);
cy += captionFont->LegacySize + 12.0f * dp;
}
// Check if PIN vault is available (per-wallet vault presence; not the global getPinEnabled flag).
bool hasPinVault = vault_ && vault_->hasVault();
// Mode toggle (PIN / Passphrase) — only show if PIN vault exists
if (hasPinVault) {
const char* modeIcon = lock_use_pin_ ? ICON_MD_DIALPAD : ICON_MD_PASSWORD;
const char* modeText = lock_use_pin_ ? " PIN" : TR("sec_mode_passphrase");
const char* switchLabel = lock_use_pin_
? TR("sec_use_passphrase_instead")
: TR("sec_use_pin_instead");
// Current mode indicator — icon with icon font, text with caption font
ImFont* iconFont = ui::material::Type().iconSmall();
ImVec2 iconSize = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, modeIcon);
ImVec2 textSize = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, modeText);
float totalW = iconSize.x + textSize.x;
float startX = cardX + (cardW - totalW) * 0.5f;
float textY = cy + (iconSize.y - textSize.y) * 0.5f; // vertically align text to icon
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(startX, cy), IM_COL32(255,255,255,120), modeIcon);
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(startX + iconSize.x, textY), IM_COL32(255,255,255,120), modeText);
cy += std::max(iconSize.y, textSize.y) + 8.0f * dp;
// Switch link
ImVec2 sls = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, switchLabel);
float switchX = cardX + (cardW - sls.x) * 0.5f;
ImGui::SetCursorScreenPos(ImVec2(switchX, cy));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::Primary()));
if (ImGui::InvisibleButton("##switch_mode", sls)) {
lock_use_pin_ = !lock_use_pin_;
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
memset(lock_pin_buf_, 0, sizeof(lock_pin_buf_));
lock_error_msg_.clear();
lock_screen_was_visible_ = false; // re-trigger auto-focus for new input
}
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(switchX, cy), ui::material::Primary(), switchLabel);
ImGui::PopStyleColor();
cy += captionFont->LegacySize + 12.0f * dp;
} else {
// No PIN vault — don't show toggle, force passphrase mode
lock_use_pin_ = false;
}
// Input field
float inputW = S.drawElement("screens.lock-screen", "input").getFloat("width", 320.0f);
if (inputW <= 0) inputW = 320.0f;
inputW *= dp;
float inputX = cardX + (cardW - inputW) * 0.5f;
bool canSubmit = lock_lockout_timer_ <= 0.0f && !lock_unlock_in_progress_;
bool submitted = false;
if (lock_use_pin_ && hasPinVault) {
// PIN input
ImGui::SetCursorScreenPos(ImVec2(inputX, cy));
ImGui::PushItemWidth(inputW);
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 6.0f * dp);
ImGuiInputTextFlags pinFlags = ImGuiInputTextFlags_Password | ImGuiInputTextFlags_CharsDecimal;
if (canSubmit) pinFlags |= ImGuiInputTextFlags_EnterReturnsTrue;
submitted = ImGui::InputText("##lock_pin", lock_pin_buf_,
sizeof(lock_pin_buf_), pinFlags);
ImGui::PopStyleVar();
ImGui::PopItemWidth();
} else {
// Passphrase input (original)
ImGui::SetCursorScreenPos(ImVec2(inputX, cy));
ImGui::PushItemWidth(inputW);
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 6.0f * dp);
ImGuiInputTextFlags inputFlags = ImGuiInputTextFlags_Password;
if (canSubmit) inputFlags |= ImGuiInputTextFlags_EnterReturnsTrue;
submitted = ImGui::InputText("##lock_pass", lock_passphrase_buf_,
sizeof(lock_passphrase_buf_), inputFlags);
ImGui::PopStyleVar();
ImGui::PopItemWidth();
}
// Focus indicator: a clear accent ring around the input while it's focused/being edited — the
// default frame is subtle on the glass card, so this shows the field is active and ready to type.
if (ImGui::IsItemActive() || ImGui::IsItemFocused()) {
dl->AddRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax(),
ui::material::Primary(), 6.0f * dp, 0, 2.0f * dp);
}
cy += (40.0f + 12.0f) * dp;
// Focus the input when the lock screen first appears.
// IsWindowAppearing() does not work here because the lock screen is
// rendered inside ##ContentArea which has already been alive since the
// first frame. Instead we track the hidden→visible transition ourselves.
if (!lock_screen_was_visible_) {
ImGui::SetKeyboardFocusHere(-1);
lock_screen_was_visible_ = true;
}
// Error message — stays visible until the next unlock attempt / mode switch / success, instead of
// fading after a few seconds (a failed-unlock reason was often gone before it could be read).
if (!lock_error_msg_.empty()) {
ImVec2 es = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, lock_error_msg_.c_str());
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(cardX + (cardW - es.x) * 0.5f, cy), ui::material::Error(),
lock_error_msg_.c_str());
cy += captionFont->LegacySize + 8.0f * dp;
}
// "Unlocking..." feedback while worker thread is running
// Always reserve the vertical space so the button doesn't shift.
{
float rowH = captionFont->LegacySize + 8.0f * dp;
if (lock_unlock_in_progress_) {
// Animated spinner dots
char msg[64];
snprintf(msg, sizeof(msg), TR("sec_unlocking_fmt"), ui::material::LoadingDots());
ImVec2 ms = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, msg);
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(cardX + (cardW - ms.x) * 0.5f, cy),
ui::material::Primary(), msg);
}
cy += rowH;
}
// Unlock button
float unlockW = S.drawElement("screens.lock-screen", "unlock-button").getFloat("width", 320.0f);
float unlockH = S.drawElement("screens.lock-screen", "unlock-button").height;
if (unlockW <= 0) unlockW = 320.0f;
if (unlockH <= 0) unlockH = 44.0f;
unlockW *= dp;
unlockH *= dp;
float unlockX = cardX + (cardW - unlockW) * 0.5f;
ImGui::SetCursorScreenPos(ImVec2(unlockX, cy));
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(ui::material::Primary()));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(ui::material::PrimaryVariant()));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary()));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
ImGui::BeginDisabled(!canSubmit);
bool btnClicked = ui::material::TactileButton(TR("sec_unlock_button"), ImVec2(unlockW, unlockH));
ImGui::EndDisabled();
ImGui::PopStyleVar();
ImGui::PopStyleColor(3);
// Handle submit (Enter key or button click)
if ((submitted || btnClicked) && canSubmit) {
lock_error_msg_.clear(); // the prior error clears only when a new attempt actually starts
// Derive daemon unlock timeout from auto-lock setting:
// 2x the idle timeout so the daemon stays unlocked longer than
// the GUI auto-lock, acting as a safety net if the GUI crashes.
// Floor of 600s (10 min) when auto-lock is off or very short.
int autoLock = settings_ ? settings_->getAutoLockTimeout() : 300;
int timeout = (autoLock > 0) ? std::max(600, autoLock * 2) : 86400;
if (lock_use_pin_ && hasPinVault && strlen(lock_pin_buf_) > 0) {
// PIN unlock — run Argon2id key derivation + RPC off the main
// thread so the UI stays responsive instead of freezing.
std::string pin(lock_pin_buf_);
memset(lock_pin_buf_, 0, sizeof(lock_pin_buf_));
lock_unlock_in_progress_ = true;
// Use fast-lane worker for priority unlock.
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
// The vault this attempt opens: a wallet switch before the answer must not mark the new
// wallet's PIN out of date.
const std::string vaultPath = vault_ ? vault_->getVaultPath() : std::string();
if (w) {
w->post([this, r, pin, timeout, vaultPath]() -> rpc::RPCWorker::MainCb {
// Heavy Argon2id derivation runs here (worker thread)
std::string passphrase;
bool vaultOk = vault_ && vault_->retrieve(pin, passphrase);
if (!vaultOk) {
bool noVault = !vault_ || !vault_->hasVault();
// A file no PIN can open (zero-filled by a removeVault() whose unlink failed,
// truncated, unknown version) counts as no vault: failing it is not a wrong PIN.
bool malformed = !noVault && !vault_->isWellFormed();
return [this, noVault, malformed]() {
if (noVault || malformed) {
// Vault file missing or unreadable — switch to passphrase mode. Not a
// failed attempt, so no counter bump / lockout escalation.
lock_unlock_in_progress_ = false;
lock_error_msg_ = noVault ? TR("pin_not_set") : TR("sec_pin_unreadable");
lock_use_pin_ = false;
lock_error_timer_ = noVault ? 3.0f : 6.0f;
lock_timer_armed_at_ = std::chrono::steady_clock::now();
if (malformed) DEBUG_LOGF("[App] PIN unlock: the vault file is malformed; no penalty\n");
} else {
applyUnlockFailure(TR("incorrect_pin"));
}
};
}
// Vault decrypted — now unlock wallet via RPC (also on worker thread)
bool rpcOk = false;
std::string rpcErr;
int rpcCode = 0;
try {
if (r && r->isConnected()) {
r->call("walletpassphrase", {passphrase, timeout});
rpcOk = true;
} else {
rpcErr = TR("sec_not_connected_to_daemon");
}
} catch (const rpc::RpcError& e) {
rpcErr = e.what(); rpcCode = e.code;
} catch (const std::exception& e) {
rpcErr = e.what();
}
if (rpcOk) {
return [this, timeout, passphrase = std::move(passphrase)]() mutable {
applyUnlockSuccess(passphrase, timeout);
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
};
} else if (data::isStaleVaultUnlockFailure(/*vaultDecrypted=*/true, rpcCode)) {
// The PIN was right (the vault opened) but the wallet refuses the passphrase it
// seals: it was changed after the PIN was set. Not a failed attempt (no lockout)
// and the vault is kept; use the passphrase and mark the PIN out of date.
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
return [this, vaultPath]() {
lock_unlock_in_progress_ = false;
lock_use_pin_ = false;
if (vault_ && vault_->getVaultPath() == vaultPath) pin_vault_stale_ = true;
lock_error_msg_ = TR("sec_pin_stale_msg");
lock_error_timer_ = 6.0f;
lock_timer_armed_at_ = std::chrono::steady_clock::now();
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
DEBUG_LOGF("[App] PIN unlock: the wallet refused the vault's passphrase (-14); "
"PIN marked out of date, no penalty\n");
};
} else {
// Vault decrypt succeeded (the PIN was correct) and the RPC failed for a
// reason other than -14 (busy node, warmup, transport error). That is never
// a wrong credential, so it must not count toward the lockout.
return [this, rpcErr, passphrase = std::move(passphrase)]() mutable {
applyUnlockNotCounted(std::string(TR("sec_unlock_failed_prefix")) + rpcErr);
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
};
}
});
}
} else if (strlen(lock_passphrase_buf_) > 0) {
// Direct passphrase unlock
unlockWallet(std::string(lock_passphrase_buf_), timeout);
}
}
ImGui::End(); // ##LockOverlay
ImGui::PopStyleVar(3); // WindowRounding, WindowBorderSize, WindowPadding
ImGui::PopStyleColor(); // WindowBg
}
// ===========================================================================
// Encrypt Wallet Dialog (post-first-run, from Settings)
// ===========================================================================
void App::renderEncryptWalletDialog() {
if (!show_encrypt_dialog_ && !show_change_passphrase_) return;
using namespace ui::material;
// Encrypt wallet dialog — multi-phase: passphrase → encrypting → PIN setup
if (show_encrypt_dialog_) {
const char* dlgTitle = (encrypt_dialog_phase_ == EncryptDialogPhase::PinSetup)
? TR("wiz_pin_title") : TR("settings_encrypt_wallet");
// Prevent closing via X button while encrypting
bool canClose = (encrypt_dialog_phase_ != EncryptDialogPhase::Encrypting);
bool* pOpen = canClose ? &show_encrypt_dialog_ : nullptr;
OverlayDialogSpec ov;
ov.title = dlgTitle; ov.p_open = pOpen;
ov.style = OverlayStyle::BlurFloat;
ov.cardWidth = 480.0f; ov.idSuffix = "encrypt";
if (BeginOverlayDialog(ov)) {
// ---- Phase 1: Passphrase entry ----
if (encrypt_dialog_phase_ == EncryptDialogPhase::PassphraseEntry) {
DialogWarningHeader(TR("wiz_encrypt_warning"));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("enc_desc"));
ImGui::Spacing();
ImGui::TextUnformatted(TR("wiz_passphrase"));
ImGui::PushItemWidth(-1);
static bool s_showEncPass = false;
const ImGuiInputTextFlags encPwFlags = s_showEncPass ? 0 : ImGuiInputTextFlags_Password;
ImGui::InputText("##enc_pass", encrypt_pass_buf_, sizeof(encrypt_pass_buf_), encPwFlags);
ImGui::PopItemWidth();
ImGui::TextUnformatted(TR("enc_confirm"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##enc_confirm", encrypt_confirm_buf_, sizeof(encrypt_confirm_buf_), encPwFlags);
ImGui::PopItemWidth();
ImGui::Checkbox(TR("enc_show_pass"), &s_showEncPass);
// Strength meter bar
{
size_t len = strlen(encrypt_pass_buf_);
// Character-class diversity: an all-digit or single-class
// string shouldn't score as high as a mixed one.
bool hasDigit = false, hasLower = false, hasUpper = false, hasSymbol = false;
for (const char* c = encrypt_pass_buf_; *c; ++c) {
unsigned char uc = static_cast<unsigned char>(*c);
if (uc >= '0' && uc <= '9') hasDigit = true;
else if (uc >= 'a' && uc <= 'z') hasLower = true;
else if (uc >= 'A' && uc <= 'Z') hasUpper = true;
else hasSymbol = true;
}
int classes = (int)hasDigit + (int)hasLower + (int)hasUpper + (int)hasSymbol;
const char* strengthLabel = TR("wiz_strength_weak");
ImVec4 strengthCol(0.9f, 0.2f, 0.2f, 1.0f);
float strengthPct = 0.25f;
int tier = 0; // 0=Weak, 1=Fair, 2=Good, 3=Strong
if (len >= 16) tier = 3;
else if (len >= 12) tier = 2;
else if (len >= 8) tier = 1;
// Downgrade one tier when only a single character class is used.
if (classes <= 1 && tier > 0) tier -= 1;
if (tier == 3) { strengthLabel = TR("wiz_strength_strong"); strengthCol = ImVec4(0.3f,0.9f,0.5f,1); strengthPct = 1.0f; }
else if (tier == 2) { strengthLabel = TR("wiz_strength_good"); strengthCol = ImVec4(0.3f,0.9f,0.5f,1); strengthPct = 0.75f; }
else if (tier == 1) { strengthLabel = TR("wiz_strength_fair"); strengthCol = ImVec4(1,0.7f,0.3f,1); strengthPct = 0.5f; }
float barW = ImGui::GetContentRegionAvail().x;
float barH = 4.0f * ui::Layout::dpiScale();
ImVec2 p = ImGui::GetCursorScreenPos();
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
IM_COL32(255,255,255,30), 2.0f);
if (len > 0)
dl->AddRectFilled(p, ImVec2(p.x + barW * strengthPct, p.y + barH),
ImGui::ColorConvertFloat4ToU32(strengthCol), 2.0f);
ImGui::Dummy(ImVec2(barW, barH));
ImGui::Text(TR("wiz_strength"), strengthLabel);
}
if (!encrypt_status_.empty()) {
ImGui::TextColored(ui::material::WarningVec4(), "%s", encrypt_status_.c_str());
}
ImGui::Spacing();
const size_t encPassLen = strlen(encrypt_pass_buf_);
const bool encMatch = strcmp(encrypt_pass_buf_, encrypt_confirm_buf_) == 0;
bool valid = encPassLen >= 8 && encMatch;
// Say WHY the button is disabled, once the user has started typing (was silent before).
if (!valid && (encPassLen > 0 || strlen(encrypt_confirm_buf_) > 0)) {
const char* why = (encPassLen < 8) ? TR("enc_min_len")
: (!encMatch ? TR("enc_mismatch") : "");
if (*why) ImGui::TextColored(ui::material::WarningVec4(), "%s", why);
}
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
ImGui::BeginDisabled(!valid || encrypt_in_progress_);
if (ui::material::TactileButton(TR("settings_encrypt_wallet"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
std::string pass(encrypt_pass_buf_);
enc_dlg_saved_passphrase_ = pass;
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
memset(encrypt_confirm_buf_, 0, sizeof(encrypt_confirm_buf_));
encrypt_dialog_phase_ = EncryptDialogPhase::Encrypting;
encryptWalletWithPassphrase(pass);
util::SecureVault::secureZero(&pass[0], pass.size());
}
ImGui::EndDisabled();
ImGui::SameLine();
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
memset(encrypt_confirm_buf_, 0, sizeof(encrypt_confirm_buf_));
show_encrypt_dialog_ = false;
}
// ---- Phase 2: Encrypting in progress ----
} else if (encrypt_dialog_phase_ == EncryptDialogPhase::Encrypting) {
const char* statusTitle = encrypt_in_progress_
? TR("enc_encrypting") : encrypt_status_.c_str();
ImGui::Text("%s", statusTitle);
ImGui::Spacing();
// Indeterminate progress bar
{
float barW = ImGui::GetContentRegionAvail().x;
float barH = 6.0f * ui::Layout::dpiScale();
ImVec2 p = ImGui::GetCursorScreenPos();
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
IM_COL32(255,255,255,25), 3.0f);
float t = (float)ImGui::GetTime();
float pulse = 0.5f + 0.5f * sinf(t * 2.0f);
float segW = barW * 0.3f;
float segX = p.x + (barW - segW) * pulse;
dl->AddRectFilled(ImVec2(segX, p.y), ImVec2(segX + segW, p.y + barH),
ImGui::ColorConvertFloat4ToU32(ImGui::GetStyle().Colors[ImGuiCol_ButtonActive]),
3.0f);
ImGui::Dummy(ImVec2(barW, barH));
}
if (!encrypt_status_.empty()) {
ImGui::TextColored(ImVec4(1, 1, 1, 0.6f), "%s", encrypt_status_.c_str());
}
ImGui::Spacing();
ImGui::TextColored(ImVec4(1,1,1,0.4f), "%s", TR("enc_wait"));
// Transition to PIN phase when encryption finishes successfully
if (!encrypt_in_progress_ && encrypt_dialog_phase_ == EncryptDialogPhase::Encrypting) {
// encryptWalletWithPassphrase callback handles the transition
}
// ---- Phase 3: PIN setup (after successful encryption) ----
} else if (encrypt_dialog_phase_ == EncryptDialogPhase::PinSetup) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.3f, 0.9f, 0.5f, 1));
ImGui::Text(ICON_MD_CHECK_CIRCLE " %s", TR("enc_success"));
ImGui::PopStyleColor();
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("enc_pin_desc"));
ImGui::Spacing();
ImGui::TextUnformatted(TR("wiz_pin_label"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##enc_dlg_pin", enc_dlg_pin_buf_, sizeof(enc_dlg_pin_buf_),
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_CharsDecimal);
ImGui::PopItemWidth();
ImGui::TextUnformatted(TR("wiz_pin_confirm"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##enc_dlg_pin_confirm", enc_dlg_pin_confirm_buf_,
sizeof(enc_dlg_pin_confirm_buf_),
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_CharsDecimal);
ImGui::PopItemWidth();
if (!enc_dlg_pin_status_.empty()) {
ImGui::TextColored(ui::material::WarningVec4(), "%s", enc_dlg_pin_status_.c_str());
}
ImGui::Spacing();
std::string pinStr(enc_dlg_pin_buf_);
std::string pinConfirm(enc_dlg_pin_confirm_buf_);
bool pinValid = util::SecureVault::isValidPin(pinStr) && pinStr == pinConfirm;
bool hasPassphrase = !enc_dlg_saved_passphrase_.empty();
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
ImGui::BeginDisabled(!pinValid || !hasPassphrase || pin_in_progress_);
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
pin_in_progress_ = true;
enc_dlg_pin_status_.clear();
std::string savedPass = enc_dlg_saved_passphrase_;
if (worker_ && vault_) {
worker_->post([this, pinStr, savedPass]() mutable -> rpc::RPCWorker::MainCb {
// Argon2id runs here (worker thread)
bool ok = vault_->store(pinStr, savedPass);
// Scrub the captured PIN + passphrase copies (they live in the worker's task
// queue until this runs); the source member is scrubbed in the MainCb. (L-03)
if (!savedPass.empty()) util::SecureVault::secureZero(&savedPass[0], savedPass.size());
if (!pinStr.empty()) util::SecureVault::secureZero(&pinStr[0], pinStr.size());
return [this, ok]() {
if (ok) {
pin_vault_stale_ = false;
lock_use_pin_ = true;
settings_->setPinEnabled(true);
settings_->save();
pin_in_progress_ = false;
ui::Notifications::instance().info(TR("enc_pin_set_ok"));
// Clean up
if (!enc_dlg_saved_passphrase_.empty()) {
util::SecureVault::secureZero(&enc_dlg_saved_passphrase_[0],
enc_dlg_saved_passphrase_.size());
enc_dlg_saved_passphrase_.clear();
}
memset(enc_dlg_pin_buf_, 0, sizeof(enc_dlg_pin_buf_));
memset(enc_dlg_pin_confirm_buf_, 0, sizeof(enc_dlg_pin_confirm_buf_));
show_encrypt_dialog_ = false;
} else {
enc_dlg_pin_status_ = TR("enc_pin_vault_fail");
pin_in_progress_ = false;
}
};
});
} else {
enc_dlg_pin_status_ = TR("enc_pin_vault_fail");
pin_in_progress_ = false;
}
}
ImGui::EndDisabled();
ImGui::SameLine();
if (ui::material::TactileButton(TR("wiz_skip"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
if (!enc_dlg_saved_passphrase_.empty()) {
util::SecureVault::secureZero(&enc_dlg_saved_passphrase_[0],
enc_dlg_saved_passphrase_.size());
enc_dlg_saved_passphrase_.clear();
}
memset(enc_dlg_pin_buf_, 0, sizeof(enc_dlg_pin_buf_));
memset(enc_dlg_pin_confirm_buf_, 0, sizeof(enc_dlg_pin_confirm_buf_));
show_encrypt_dialog_ = false;
ui::Notifications::instance().info(TR("enc_pin_skipped"));
}
}
EndOverlayDialog();
}
// Clean up saved passphrase if dialog was closed via X button
if (!show_encrypt_dialog_ && !enc_dlg_saved_passphrase_.empty()) {
util::SecureVault::secureZero(&enc_dlg_saved_passphrase_[0],
enc_dlg_saved_passphrase_.size());
enc_dlg_saved_passphrase_.clear();
}
}
// Change passphrase dialog
if (show_change_passphrase_) {
OverlayDialogSpec ov;
ov.title = TR("change_pass_title"); ov.p_open = &show_change_passphrase_;
ov.style = OverlayStyle::BlurFloat;
ov.cardWidth = 460.0f; ov.idSuffix = "changepass";
if (BeginOverlayDialog(ov)) {
// Same fund-loss consequence as Encrypt/Remove Encryption if the new
// passphrase is lost — reuse their warning string/header for consistency.
DialogWarningHeader(TR("wiz_encrypt_warning"));
ImGui::Spacing();
// This wallet has a PIN: it keeps the old passphrase and goes out of date (C87).
if (hasPinVault()) {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetStyleColorVec4(ImGuiCol_TextDisabled));
ImGui::TextWrapped("%s", TR("change_pass_pin_note"));
ImGui::PopStyleColor();
ImGui::Spacing();
}
ImGui::TextUnformatted(TR("change_pass_current"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##chg_old", change_old_pass_buf_, sizeof(change_old_pass_buf_),
ImGuiInputTextFlags_Password);
ImGui::PopItemWidth();
ImGui::TextUnformatted(TR("change_pass_new"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##chg_new", change_new_pass_buf_, sizeof(change_new_pass_buf_),
ImGuiInputTextFlags_Password);
ImGui::PopItemWidth();
ImGui::TextUnformatted(TR("change_pass_confirm"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##chg_confirm", change_confirm_buf_, sizeof(change_confirm_buf_),
ImGuiInputTextFlags_Password);
ImGui::PopItemWidth();
if (!encrypt_status_.empty()) {
ImGui::TextColored(ui::material::WarningVec4(), "%s", encrypt_status_.c_str());
}
ImGui::Spacing();
bool valid = strlen(change_old_pass_buf_) > 0 &&
strlen(change_new_pass_buf_) >= 8 &&
strcmp(change_new_pass_buf_, change_confirm_buf_) == 0;
// Two-button footer (primary + Cancel) to match the encrypt/decrypt siblings.
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
// Not while a PIN operation runs: PIN setup could verify and seal the OLD passphrase.
ImGui::BeginDisabled(!valid || encrypt_in_progress_ || pin_in_progress_);
if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
changePassphrase(std::string(change_old_pass_buf_),
std::string(change_new_pass_buf_));
}
ImGui::EndDisabled();
ImGui::SameLine();
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
// the !show_change_passphrase_ cleanup block below.
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
show_change_passphrase_ = false;
}
EndOverlayDialog();
}
// Wipe the passphrase buffers if the dialog was dismissed (X / Esc /
// outside-click) without submitting. The success path already zeroes
// them in changePassphrase(); the failure path keeps them for retry.
if (!show_change_passphrase_) {
memset(change_old_pass_buf_, 0, sizeof(change_old_pass_buf_));
memset(change_new_pass_buf_, 0, sizeof(change_new_pass_buf_));
memset(change_confirm_buf_, 0, sizeof(change_confirm_buf_));
}
}
}
// ===========================================================================
// Decrypt (Remove Encryption) Dialog
// ===========================================================================
// Flow:
// Phase 0: Enter current passphrase
// Phase 1: Working — unlock → export → stop → rename → restart → import
// Phase 2: Success
// Phase 3: Error
// ===========================================================================
void App::renderDecryptWalletDialog() {
if (!show_decrypt_dialog_) return;
using namespace ui::material;
using DecryptPhase = services::WalletSecurityWorkflow::DecryptPhase;
using DecryptStep = services::WalletSecurityWorkflow::DecryptStep;
auto decryptState = wallet_security_workflow_.snapshot();
bool canClose = wallet_security_workflow_.canClose();
bool* pOpen = canClose ? &show_decrypt_dialog_ : nullptr;
OverlayDialogSpec ov;
ov.title = TR("decrypt_title"); ov.p_open = pOpen;
ov.style = OverlayStyle::BlurFloat;
ov.cardWidth = 480.0f; ov.idSuffix = "decrypt";
if (BeginOverlayDialog(ov)) {
// ---- Phase 0: Passphrase entry ----
if (decryptState.phase == DecryptPhase::PassphraseEntry) {
DialogWarningHeader(TR("decrypt_warning"));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("decrypt_desc"));
ImGui::Spacing();
ImGui::TextUnformatted(TR("change_pass_current"));
ImGui::PushItemWidth(-1);
bool enterPressed = ImGui::InputText("##decrypt_pass", decrypt_pass_buf_,
sizeof(decrypt_pass_buf_), ImGuiInputTextFlags_Password |
ImGuiInputTextFlags_EnterReturnsTrue);
ImGui::PopItemWidth();
if (!decryptState.status.empty()) {
ImGui::TextColored(ui::material::ErrorVec4(), "%s", decryptState.status.c_str());
}
ImGui::Spacing();
bool valid = strlen(decrypt_pass_buf_) >= 1;
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
ImGui::BeginDisabled(!valid || decryptState.inProgress);
// Decrypting stops and restarts the node: not while it is busy (refuseIfNodeBusy() only on a click).
if ((ui::material::TactileButton(TR("settings_remove_encryption"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale())) || (enterPressed && valid)) &&
!refuseIfNodeBusy()) {
std::string passphrase(decrypt_pass_buf_);
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
wallet_security_workflow_.start(std::chrono::steady_clock::now());
// Run entire decrypt flow on worker thread
if (worker_) {
worker_->post([this, passphrase = std::move(passphrase)]() mutable -> rpc::RPCWorker::MainCb {
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
[this](rpc::RPCClient& client, const char* context) {
return sendStopCommandSafely(client, context);
});
auto unlock = services::WalletSecurityWorkflowExecutor::unlockWallet(passphrase, decryptRpc);
// Scrub the passphrase — unlock is its only use in this flow.
if (!passphrase.empty()) sodium_memzero(&passphrase[0], passphrase.size());
if (!unlock.ok) {
return [this]() {
wallet_security_workflow_.failEntry(TR("sec_incorrect_passphrase_decrypt"));
};
}
// Update step on main thread
return [this]() {
wallet_security_workflow_.advanceTo(DecryptStep::ExportKeys,
services::WalletSecurityWorkflow::stepStatus(DecryptStep::ExportKeys),
std::chrono::steady_clock::now());
// Continue with step 2
worker_->post([this]() -> rpc::RPCWorker::MainCb {
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
[this](rpc::RPCClient& client, const char* context) {
return sendStopCommandSafely(client, context);
});
WalletSecurityFileAdapter files;
auto exportOutcome = services::WalletSecurityWorkflowExecutor::exportWallet(
decryptRpc, files, static_cast<std::uint64_t>(std::time(nullptr)));
if (!exportOutcome.ok) {
std::string err = exportOutcome.error;
return [this, err]() {
wallet_security_workflow_.fail(err);
};
}
auto filePlan = exportOutcome.filePlan;
return [this, filePlan]() {
wallet_security_workflow_.advanceTo(DecryptStep::StopDaemon,
services::WalletSecurityWorkflow::stepStatus(DecryptStep::StopDaemon),
std::chrono::steady_clock::now());
// Continue with step 3
worker_->post([this, filePlan]() -> rpc::RPCWorker::MainCb {
// Stop the node and wait for its PROCESS to exit before wallet.dat is
// renamed: dragonxd keeps the file open until it exits, which can take
// minutes on a large wallet (a fixed 3s wait let the rename run under a
// live node). Ours or the one we're connected to; bounded, never killed.
const bool stopped = stopEmbeddedDaemon(StopMode::Background, StopScope::Connected) ==
daemon::StopOutcome::Exited;
if (!stopped) {
App::scrubAndRemoveExport(filePlan.exportPath); // nothing will import it
return [this]() {
wallet_security_workflow_.fail(TR("appx_node_not_stopped"));
};
}
return [this, filePlan]() {
wallet_security_workflow_.advanceTo(DecryptStep::BackupWallet,
services::WalletSecurityWorkflow::stepStatus(DecryptStep::BackupWallet),
std::chrono::steady_clock::now());
// Continue with step 4 (rename)
worker_->post([this, filePlan]() -> rpc::RPCWorker::MainCb {
WalletSecurityFileAdapter files;
auto backup = services::WalletSecurityWorkflowExecutor::backupEncryptedWallet(files, filePlan);
if (!backup.ok) {
std::string err = backup.error;
return [this, err]() {
wallet_security_workflow_.fail(err);
};
}
return [this, exportPath = filePlan.exportPath]() {
wallet_security_workflow_.advanceTo(DecryptStep::RestartDaemon,
services::WalletSecurityWorkflow::stepStatus(DecryptStep::RestartDaemon),
std::chrono::steady_clock::now());
auto restartAndImport = [this, exportPath](const util::AsyncTaskManager::Token& token) {
// Scrub + delete the plaintext key export (obsidiandecryptexport…) on EVERY exit path —
// success, a restart-failure early return, or an exception. A full cleartext dump of all
// private keys must never outlive this step. The startup sweep is a further net for a
// crash/kill mid-flight. (H-02)
struct ExportScrub { std::string p; ~ExportScrub() { App::scrubAndRemoveExport(p); } } exportScrub{exportPath};
WalletSecurityDaemonAdapter daemonAdapter(*this, token);
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
[this](rpc::RPCClient& client, const char* context) {
return sendStopCommandSafely(client, context);
});
auto restart = services::WalletSecurityWorkflowExecutor::restartDaemonAndWait(
daemonAdapter, decryptRpc);
if (!restart.ok) {
if (restart.error.empty()) return;
if (worker_) {
worker_->post([this, err = restart.error]() -> rpc::RPCWorker::MainCb {
return [this, err]() {
wallet_security_workflow_.fail(err);
};
});
}
return;
}
// Update step on main thread — close dialog, import in background
if (worker_) {
worker_->post([this]() -> rpc::RPCWorker::MainCb {
return [this]() {
// Close the decrypt dialog — user can use the wallet now
wallet_security_workflow_.closeDialogForImport();
show_decrypt_dialog_ = false;
// Mark rescanning so status bar picks it up immediately
state_.sync.rescanning = true;
state_.sync.rescan_progress = 0.0f;
// The user chose to remove encryption: any wizard-era "encryption pending" request is moot
// (else the next connect would warn that encryption "did not complete") (C88).
if (settings_ && settings_->getEncryptionPending()) {
settings_->setEncryptionPending(false);
settings_->save();
}
// Clear encryption state early — vault/PIN removed now,
// wallet file is already unencrypted
services::WalletSecurityWorkflowExecutor::cleanupVaultAndPin([this]() {
if (vault_ && vault_->hasVault()) {
vault_->removeVault();
}
if (settings_ && settings_->getPinEnabled()) {
settings_->setPinEnabled(false);
settings_->save();
}
});
ui::Notifications::instance().info(
TR("sec_importing_keys_rescanning"),
8.0f);
};
});
}
WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_);
auto importResult = services::WalletSecurityWorkflowExecutor::importWallet(
importAdapter, exportPath);
// (exportScrub scrubs + deletes the plaintext key export on scope exit — H-02)
if (!importResult.ok) {
std::string err = importResult.error;
if (worker_) {
worker_->post([this, err]() -> rpc::RPCWorker::MainCb {
return [this, err]() {
wallet_security_workflow_.finishImport();
ui::Notifications::instance().error(
err +
TR("sec_encrypted_backup_suffix"),
12.0f);
};
});
}
return;
}
// Success — force full state refresh so peers,
// balances, and addresses are fetched immediately.
if (worker_) {
worker_->post([this]() -> rpc::RPCWorker::MainCb {
return [this]() {
wallet_security_workflow_.finishImport();
// Force address + peer refresh
invalidateAddressValidationCache(); // marks the addresses dirty
markTransactionsDirty();
last_tx_block_height_ = -1;
refreshWalletEncryptionState();
refreshData();
refreshPeerInfo();
ui::Notifications::instance().success(
TR("sec_wallet_decrypted_all_keys_imported"),
8.0f);
DEBUG_LOGF("[App] Wallet decrypted successfully\n");
};
});
}
};
async_tasks_.submit("decrypt-restart-import", restartAndImport);
};
});
};
});
};
});
};
});
}
}
ImGui::EndDisabled();
ImGui::SameLine();
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
show_decrypt_dialog_ = false;
}
// ---- Phase 1: Working ----
} else if (decryptState.phase == DecryptPhase::Working) {
// Step checklist
const char* stepLabels[] = {
TR("decrypt_step_unlock"),
TR("decrypt_step_export"),
TR("decrypt_step_stop"),
TR("decrypt_step_backup"),
TR("decrypt_step_restart")
};
const int numSteps = 5;
// Compute elapsed times
auto now = std::chrono::steady_clock::now();
auto stepElapsed = std::chrono::duration_cast<std::chrono::seconds>(
now - decryptState.stepStarted).count();
auto totalElapsed = std::chrono::duration_cast<std::chrono::seconds>(
now - decryptState.overallStarted).count();
ImGui::Spacing();
for (int i = 0; i < numSteps; i++) {
ImGui::PushFont(Type().iconMed());
if (services::WalletSecurityWorkflow::stepIsComplete(decryptState.step,
services::WalletSecurityWorkflow::stepFromIndex(i))) {
// Completed
ImGui::TextColored(ui::material::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
} else if (i == services::WalletSecurityWorkflow::stepIndex(decryptState.step)) {
// In progress - animate
float alpha = 0.5f + 0.5f * sinf((float)ImGui::GetTime() * 4.0f);
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, alpha), ICON_MD_PENDING);
} else {
// Not started
ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 0.5f), ICON_MD_RADIO_BUTTON_UNCHECKED);
}
ImGui::PopFont();
ImGui::SameLine();
if (i == services::WalletSecurityWorkflow::stepIndex(decryptState.step)) {
// Show step label with elapsed time
int mins = (int)(stepElapsed / 60);
int secs = (int)(stepElapsed % 60);
if (mins > 0) {
ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.6f, 1.0f),
"%s... (%dm %02ds)", stepLabels[i], mins, secs);
} else {
ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.6f, 1.0f),
"%s... (%ds)", stepLabels[i], secs);
}
} else if (services::WalletSecurityWorkflow::stepIsComplete(decryptState.step,
services::WalletSecurityWorkflow::stepFromIndex(i))) {
ImGui::TextColored(ImVec4(0.6f, 0.8f, 0.6f, 1.0f), "%s", stepLabels[i]);
} else {
ImGui::TextDisabled("%s", stepLabels[i]);
}
}
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
// Indeterminate progress bar
{
float barW = ImGui::GetContentRegionAvail().x;
float barH = 6.0f * ui::Layout::dpiScale();
ImVec2 p = ImGui::GetCursorScreenPos();
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
IM_COL32(255, 255, 255, 25), 3.0f);
float t = (float)ImGui::GetTime();
float segW = barW * 0.3f;
float x0 = p.x + (barW + segW) * (0.5f + 0.5f * sinf(t * 2.0f)) - segW;
float x1 = x0 + segW;
x0 = std::max(x0, p.x);
x1 = std::min(x1, p.x + barW);
if (x1 > x0) {
dl->AddRectFilled(ImVec2(x0, p.y), ImVec2(x1, p.y + barH),
IM_COL32(255, 218, 0, 200), 3.0f);
}
ImGui::Dummy(ImVec2(barW, barH));
}
ImGui::Spacing();
// Step-specific hints
if (decryptState.step == DecryptStep::RestartDaemon) {
ImGui::TextWrapped("%s", TR("decrypt_wait_restart"));
} else {
ImGui::TextWrapped("%s", TR("decrypt_wait_general"));
}
// Total elapsed
{
int tMins = (int)(totalElapsed / 60);
int tSecs = (int)(totalElapsed % 60);
ImGui::Spacing();
ImGui::TextDisabled(TR("sec_total_elapsed_fmt"), tMins, tSecs);
}
// ---- Phase 2: Success ----
} else if (decryptState.phase == DecryptPhase::Success) {
ImGui::PushFont(Type().iconLarge());
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.5f, 1.0f), ICON_MD_CHECK_CIRCLE);
ImGui::PopFont();
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.5f, 1.0f), "%s", TR("decrypt_success_title"));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("decrypt_success_desc"));
ImGui::Spacing();
if (ui::material::TactileButton(TR("close"), ImVec2(-1, 40.0f * ui::Layout::dpiScale()))) {
show_decrypt_dialog_ = false;
}
// ---- Phase 3: Error ----
} else if (decryptState.phase == DecryptPhase::Error) {
ImGui::PushFont(Type().iconLarge());
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), ICON_MD_ERROR);
ImGui::PopFont();
ImGui::SameLine();
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "%s", TR("decrypt_error_title"));
ImGui::Spacing();
ImGui::TextWrapped("%s", decryptState.status.c_str());
ImGui::Spacing();
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
if (ui::material::TactileButton(TR("try_again"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
wallet_security_workflow_.reset();
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("close"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
show_decrypt_dialog_ = false;
}
}
EndOverlayDialog();
}
// Wipe the passphrase buffer if the dialog was dismissed (X / Esc / outside-
// click) without submitting. The submit and Cancel paths already memset it;
// this covers the BlurFloat dismiss paths. (canClose is false during Working,
// so an in-flight decrypt cannot be dismissed here.)
if (!show_decrypt_dialog_) {
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
}
}
// ===========================================================================
// PIN Setup / Change / Remove Dialogs (from Settings page)
// ===========================================================================
void App::renderPinDialogs() {
using namespace ui::material;
// ---- Set PIN dialog ----
if (show_pin_setup_) {
OverlayDialogSpec ov;
ov.title = TR("settings_set_pin"); ov.p_open = &show_pin_setup_;
ov.style = OverlayStyle::BlurFloat;
ov.cardWidth = 420.0f; ov.idSuffix = "pinsetup";
if (BeginOverlayDialog(ov)) {
ImGui::TextWrapped("%s", TR("pin_setup_desc"));
ImGui::Spacing();
ImGui::TextUnformatted(TR("pin_wallet_passphrase"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##pin_passphrase", pin_passphrase_buf_, sizeof(pin_passphrase_buf_),
ImGuiInputTextFlags_Password);
ImGui::PopItemWidth();
ImGui::TextUnformatted(TR("pin_new_label"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##pin_new", pin_buf_, sizeof(pin_buf_),
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_CharsDecimal);
ImGui::PopItemWidth();
ImGui::TextUnformatted(TR("wiz_pin_confirm"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##pin_confirm", pin_confirm_buf_, sizeof(pin_confirm_buf_),
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_CharsDecimal);
ImGui::PopItemWidth();
if (!pin_status_.empty()) {
ImGui::TextColored(ui::material::WarningVec4(), "%s", pin_status_.c_str());
}
ImGui::Spacing();
std::string pinStr(pin_buf_);
bool valid = strlen(pin_passphrase_buf_) > 0 &&
util::SecureVault::isValidPin(pinStr) &&
strcmp(pin_buf_, pin_confirm_buf_) == 0;
// Two-button footer (primary + Cancel) to match the encrypt/decrypt siblings.
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
// Not while a passphrase change runs: it could land after the old passphrase is verified.
ImGui::BeginDisabled(!valid || pin_in_progress_ || encrypt_in_progress_);
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
pin_in_progress_ = true;
pin_status_ = TR("sec_verifying_passphrase");
// Verify passphrase + store vault on worker thread to avoid
// blocking the UI with Argon2id key derivation.
std::string passphrase(pin_passphrase_buf_);
std::string pin(pin_buf_);
memset(pin_passphrase_buf_, 0, sizeof(pin_passphrase_buf_));
memset(pin_buf_, 0, sizeof(pin_buf_));
memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_));
if (rpc_ && rpc_->isConnected() && worker_) {
// C90: decided on the main thread (state_ is main-thread data). An unlocked wallet is verified
// with the time it has left and stays unlocked, so the GUI and the daemon keep agreeing.
const data::SetPinVerifyPlan verifyPlan = data::planSetPinVerify(
state_.isUnlocked(), state_.unlocked_until, static_cast<std::int64_t>(std::time(nullptr)));
worker_->post([this, passphrase, pin, verifyPlan]() mutable -> rpc::RPCWorker::MainCb {
// Verify passphrase via RPC (worker thread)
try {
rpc::RPCClient::TraceScope trace("Security / PIN setup");
rpc_->call("walletpassphrase", {passphrase, verifyPlan.timeoutSecs});
} catch (const std::exception& e) {
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
return [this]() {
pin_status_ = TR("sec_incorrect_passphrase_pin_setup");
pin_in_progress_ = false;
};
}
// Passphrase correct — store in vault (Argon2id, worker thread)
bool storeOk = vault_ && vault_->store(pin, passphrase);
// Captured passphrase + PIN are no longer needed — scrub the worker-queue copies. (M-01)
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
// Lock wallet back — only if it was locked before the check (C90).
if (verifyPlan.relockAfter) {
try {
rpc::RPCClient::TraceScope trace("Security / PIN setup");
rpc_->call("walletlock");
} catch (...) {}
}
return [this, storeOk]() {
// Re-read the daemon's lock state either way: it is the truth and self-heals any drift.
refreshEncryptionState();
if (storeOk) {
pin_vault_stale_ = false; // the vault now seals the verified passphrase
lock_use_pin_ = true;
settings_->setPinEnabled(true);
settings_->save();
pin_status_.clear();
pin_in_progress_ = false;
show_pin_setup_ = false;
ui::Notifications::instance().info(TR("sec_pin_set_successfully"));
} else {
pin_status_ = TR("sec_failed_to_create_vault");
pin_in_progress_ = false;
}
};
});
} else {
pin_status_ = TR("sec_not_connected_to_daemon_pin");
pin_in_progress_ = false;
}
}
ImGui::EndDisabled();
ImGui::SameLine();
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
// the !show_pin_setup_ cleanup block below.
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
show_pin_setup_ = false;
}
EndOverlayDialog();
}
// Wipe the passphrase/PIN buffers if the dialog was dismissed (X / Esc /
// outside-click) without submitting. The submit path already memsets them.
if (!show_pin_setup_) {
memset(pin_passphrase_buf_, 0, sizeof(pin_passphrase_buf_));
memset(pin_buf_, 0, sizeof(pin_buf_));
memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_));
}
}
// ---- Change PIN dialog ----
if (show_pin_change_) {
OverlayDialogSpec ov;
ov.title = TR("settings_change_pin"); ov.p_open = &show_pin_change_;
ov.style = OverlayStyle::BlurFloat;
ov.cardWidth = 420.0f; ov.idSuffix = "pinchange";
if (BeginOverlayDialog(ov)) {
ImGui::TextWrapped("%s", TR("pin_change_desc"));
ImGui::Spacing();
ImGui::TextUnformatted(TR("pin_current_label"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##pin_old", pin_old_buf_, sizeof(pin_old_buf_),
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_CharsDecimal);
ImGui::PopItemWidth();
ImGui::TextUnformatted(TR("pin_new_label"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##pin_change_new", pin_buf_, sizeof(pin_buf_),
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_CharsDecimal);
ImGui::PopItemWidth();
ImGui::TextUnformatted(TR("pin_confirm_new_label"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##pin_change_confirm", pin_confirm_buf_, sizeof(pin_confirm_buf_),
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_CharsDecimal);
ImGui::PopItemWidth();
if (!pin_status_.empty()) {
ImGui::TextColored(ui::material::WarningVec4(), "%s", pin_status_.c_str());
}
ImGui::Spacing();
std::string newPin(pin_buf_);
bool valid = strlen(pin_old_buf_) >= 4 &&
util::SecureVault::isValidPin(newPin) &&
strcmp(pin_buf_, pin_confirm_buf_) == 0;
ImGui::BeginDisabled(!valid || pin_in_progress_);
if (ui::material::TactileButton(TR("settings_change_pin"), ImVec2(-1, 40.0f * ui::Layout::dpiScale()))) {
pin_in_progress_ = true;
pin_status_ = TR("sec_changing_pin");
std::string oldPin(pin_old_buf_);
std::string newPinCopy = newPin;
memset(pin_old_buf_, 0, sizeof(pin_old_buf_));
memset(pin_buf_, 0, sizeof(pin_buf_));
memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_));
if (worker_ && vault_) {
worker_->post([this, oldPin, newPinCopy]() -> rpc::RPCWorker::MainCb {
// Argon2id runs here (worker thread)
bool ok = vault_->changePin(oldPin, newPinCopy);
return [this, ok]() {
if (ok) {
pin_status_.clear();
pin_in_progress_ = false;
show_pin_change_ = false;
ui::Notifications::instance().info(TR("sec_pin_changed_successfully"));
} else {
pin_status_ = TR("sec_incorrect_current_pin");
pin_in_progress_ = false;
}
};
});
} else {
pin_status_ = TR("sec_internal_error_change_pin");
pin_in_progress_ = false;
}
}
ImGui::EndDisabled();
EndOverlayDialog();
}
// Wipe the PIN buffers if the dialog was dismissed without submitting.
if (!show_pin_change_) {
memset(pin_old_buf_, 0, sizeof(pin_old_buf_));
memset(pin_buf_, 0, sizeof(pin_buf_));
memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_));
}
}
// ---- Remove PIN dialog ----
if (show_pin_remove_) {
OverlayDialogSpec ov;
ov.title = TR("settings_remove_pin"); ov.p_open = &show_pin_remove_;
ov.style = OverlayStyle::BlurFloat;
ov.cardWidth = 400.0f; ov.idSuffix = "pinremove";
if (BeginOverlayDialog(ov)) {
ImGui::TextWrapped("%s", TR("pin_remove_desc"));
ImGui::Spacing();
ImGui::TextUnformatted(TR("pin_current_label"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##pin_remove", pin_old_buf_, sizeof(pin_old_buf_),
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_CharsDecimal);
ImGui::PopItemWidth();
if (!pin_status_.empty()) {
ImGui::TextColored(ui::material::WarningVec4(), "%s", pin_status_.c_str());
}
ImGui::Spacing();
bool valid = strlen(pin_old_buf_) >= 4;
ImGui::BeginDisabled(!valid || pin_in_progress_);
if (ui::material::TactileButton(TR("settings_remove_pin"), ImVec2(-1, 40.0f * ui::Layout::dpiScale()))) {
pin_in_progress_ = true;
pin_status_ = TR("sec_verifying_pin");
std::string oldPin(pin_old_buf_);
memset(pin_old_buf_, 0, sizeof(pin_old_buf_));
if (worker_ && vault_) {
worker_->post([this, oldPin]() -> rpc::RPCWorker::MainCb {
// Argon2id runs here (worker thread)
std::string passphrase;
bool ok = vault_->retrieve(oldPin, passphrase);
if (ok) {
util::SecureVault::secureZero(&passphrase[0], passphrase.size());
}
return [this, ok]() {
if (ok) {
vault_->removeVault();
pin_vault_stale_ = false;
settings_->setPinEnabled(false);
settings_->save();
pin_status_.clear();
pin_in_progress_ = false;
show_pin_remove_ = false;
ui::Notifications::instance().info(TR("sec_pin_removed"));
} else {
pin_status_ = TR("sec_incorrect_pin_remove");
pin_in_progress_ = false;
}
};
});
} else {
pin_status_ = TR("sec_internal_error_remove_pin");
pin_in_progress_ = false;
}
}
ImGui::EndDisabled();
EndOverlayDialog();
}
// Wipe the PIN buffer if the dialog was dismissed without submitting.
if (!show_pin_remove_) {
memset(pin_old_buf_, 0, sizeof(pin_old_buf_));
}
}
}
} // namespace dragonx