fix(security): follow the daemon's lock state on Set-PIN and expiry
Setting a PIN always unlocked the wallet with a 5 s probe and then re-locked it, even when the wallet was already unlocked, so a PIN check silently re-locked an unlocked wallet. And once the daemon's absolute unlock window ran out the GUI still showed "unlocked", so the next send failed with nothing on screen explaining it. - The Set-PIN check now decides on the main thread: an unlocked wallet is verified with the time it has left (capped at seven days) and is not re-locked, so the GUI and the daemon keep agreeing; a locked wallet still uses the 5 s probe and re-locks. Either way the daemon's lock state is re-read afterwards, which self-heals any drift. - When the daemon's unlock window has expired but the GUI still says "unlocked", a bounded reconcile (once per expiry, 30 s back-off) re-reads getwalletinfo so the lock screen and the transaction-cache lock follow the daemon. Lite wallets, which have no daemon timer, are left alone. Behaviour change: with auto-lock disabled the daemon still re-locks at its window, and the GUI now lands on the lock screen instead of appearing unlocked until a send fails. Tests: the Set-PIN verify plan (unlocked keeps its remaining time and skips the re-lock, locked or near-expiry falls back to the 5 s probe and re-lock, long windows are capped) and the unlock-expiry predicate across encrypted, locked, no-deadline and not-yet-expired cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1070,6 +1070,7 @@ void App::update()
|
|||||||
// Auto-lock check (only when connected + encrypted + unlocked)
|
// Auto-lock check (only when connected + encrypted + unlocked)
|
||||||
if (state_.connected && state_.isUnlocked()) {
|
if (state_.connected && state_.isUnlocked()) {
|
||||||
checkAutoLock();
|
checkAutoLock();
|
||||||
|
reconcileUnlockExpiry(); // C90: follow the daemon when its absolute unlock window ran out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply a settled mining thread-count pick: one settings save + at most one miner change per gesture.
|
// Apply a settled mining thread-count pick: one settings save + at most one miner change per gesture.
|
||||||
|
|||||||
@@ -1942,6 +1942,8 @@ private:
|
|||||||
// Auto-lock on idle. Fed by real input events (noteUserInput, from main.cpp's event ingest), NOT by
|
// Auto-lock on idle. Fed by real input events (noteUserInput, from main.cpp's event ingest), NOT by
|
||||||
// widget state: a focused-but-untouched text field must not keep the wallet unlocked (C21).
|
// widget state: a focused-but-untouched text field must not keep the wallet unlocked (C21).
|
||||||
util::IdleTracker idle_;
|
util::IdleTracker idle_;
|
||||||
|
// C90: back-off clock for the daemon-unlock-expiry reconcile (steady clock, wall-clock independent).
|
||||||
|
std::chrono::steady_clock::time_point unlock_reconcile_retry_at_{};
|
||||||
|
|
||||||
// Wall-clock delta for logic timers (see logicDeltaTime()).
|
// Wall-clock delta for logic timers (see logicDeltaTime()).
|
||||||
std::chrono::steady_clock::time_point last_update_time_{};
|
std::chrono::steady_clock::time_point last_update_time_{};
|
||||||
@@ -2052,6 +2054,9 @@ private:
|
|||||||
bool shouldRunWalletTransactionRefresh() const;
|
bool shouldRunWalletTransactionRefresh() const;
|
||||||
bool shouldRefreshRecentTransactions() const;
|
bool shouldRefreshRecentTransactions() const;
|
||||||
void checkAutoLock();
|
void checkAutoLock();
|
||||||
|
// C90: the daemon's absolute unlock window ran out while the GUI still says "unlocked": re-read getwalletinfo
|
||||||
|
// (once per expiry, backed off) so the lock screen / tx-cache lock follow the daemon.
|
||||||
|
void reconcileUnlockExpiry();
|
||||||
void checkIdleMining();
|
void checkIdleMining();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+32
-7
@@ -758,6 +758,23 @@ void App::checkAutoLock() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Mine when idle — auto-start/stop mining based on system idle state
|
||||||
// Supports two modes:
|
// Supports two modes:
|
||||||
@@ -2184,11 +2201,15 @@ void App::renderPinDialogs() {
|
|||||||
memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_));
|
memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_));
|
||||||
|
|
||||||
if (rpc_ && rpc_->isConnected() && worker_) {
|
if (rpc_ && rpc_->isConnected() && worker_) {
|
||||||
worker_->post([this, passphrase, pin]() mutable -> rpc::RPCWorker::MainCb {
|
// 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)
|
// Verify passphrase via RPC (worker thread)
|
||||||
try {
|
try {
|
||||||
rpc::RPCClient::TraceScope trace("Security / PIN setup");
|
rpc::RPCClient::TraceScope trace("Security / PIN setup");
|
||||||
rpc_->call("walletpassphrase", {passphrase, 5});
|
rpc_->call("walletpassphrase", {passphrase, verifyPlan.timeoutSecs});
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
|
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
|
||||||
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
|
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
|
||||||
@@ -2204,13 +2225,17 @@ void App::renderPinDialogs() {
|
|||||||
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
|
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
|
||||||
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
|
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
|
||||||
|
|
||||||
// Lock wallet back
|
// Lock wallet back — only if it was locked before the check (C90).
|
||||||
try {
|
if (verifyPlan.relockAfter) {
|
||||||
rpc::RPCClient::TraceScope trace("Security / PIN setup");
|
try {
|
||||||
rpc_->call("walletlock");
|
rpc::RPCClient::TraceScope trace("Security / PIN setup");
|
||||||
} catch (...) {}
|
rpc_->call("walletlock");
|
||||||
|
} catch (...) {}
|
||||||
|
}
|
||||||
|
|
||||||
return [this, storeOk]() {
|
return [this, storeOk]() {
|
||||||
|
// Re-read the daemon's lock state either way: it is the truth and self-heals any drift.
|
||||||
|
refreshEncryptionState();
|
||||||
if (storeOk) {
|
if (storeOk) {
|
||||||
pin_vault_stale_ = false; // the vault now seals the verified passphrase
|
pin_vault_stale_ = false; // the vault now seals the verified passphrase
|
||||||
lock_use_pin_ = true;
|
lock_use_pin_ = true;
|
||||||
|
|||||||
@@ -19,5 +19,32 @@ inline EncryptionPendingAction decideEncryptionPending(bool flagSet, bool rpcOk,
|
|||||||
return EncryptionPendingAction::Warn;
|
return EncryptionPendingAction::Warn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- C90 ----
|
||||||
|
constexpr int kSetPinProbeSecs = 5; // the verification unlock used when the wallet is locked
|
||||||
|
|
||||||
|
struct SetPinVerifyPlan {
|
||||||
|
int timeoutSecs; // walletpassphrase timeout for the passphrase check
|
||||||
|
bool relockAfter; // call walletlock afterwards (only if the wallet was locked before)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set PIN verifies the passphrase with walletpassphrase. If the wallet is unlocked now, re-unlock with the time
|
||||||
|
// it has left (replaces the daemon's timer with the same deadline) and DO NOT relock; otherwise a 5 s probe + relock.
|
||||||
|
inline SetPinVerifyPlan planSetPinVerify(bool guiUnlocked, std::int64_t unlockedUntil, std::int64_t now)
|
||||||
|
{
|
||||||
|
const std::int64_t left = unlockedUntil - now;
|
||||||
|
if (guiUnlocked && left > kSetPinProbeSecs) {
|
||||||
|
const std::int64_t capped = left > 86400 * 7 ? 86400 * 7 : left;
|
||||||
|
return {static_cast<int>(capped), false};
|
||||||
|
}
|
||||||
|
return {kSetPinProbeSecs, true};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The daemon's absolute unlock window has run out but the GUI still says "unlocked". Wall clock, because the
|
||||||
|
// deadline is an absolute unix time. unlockedUntil == 0 means "no deadline known" => never expired here.
|
||||||
|
inline bool unlockWindowExpired(bool encrypted, bool locked, std::int64_t unlockedUntil, std::int64_t now)
|
||||||
|
{
|
||||||
|
return encrypted && !locked && unlockedUntil > 0 && now >= unlockedUntil;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace data
|
} // namespace data
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
@@ -1169,6 +1169,26 @@ void testEncryptionPendingPolicy()
|
|||||||
EXPECT_TRUE(decideEncryptionPending(false, true, false, false, false, false) == EncryptionPendingAction::None);
|
EXPECT_TRUE(decideEncryptionPending(false, true, false, false, false, false) == EncryptionPendingAction::None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void testWalletLockReconcilePolicy()
|
||||||
|
{
|
||||||
|
using namespace dragonx::data;
|
||||||
|
// Set PIN while unlocked keeps the wallet unlocked for the time it has left.
|
||||||
|
auto p = planSetPinVerify(true, 1000 + 1700, 1000);
|
||||||
|
EXPECT_EQ(p.timeoutSecs, 1700); EXPECT_FALSE(p.relockAfter);
|
||||||
|
// Locked, or about to expire (<= 5 s left), or no deadline: 5 s probe + relock as before.
|
||||||
|
p = planSetPinVerify(false, 0, 1000); EXPECT_EQ(p.timeoutSecs, 5); EXPECT_TRUE(p.relockAfter);
|
||||||
|
p = planSetPinVerify(true, 1000 + 5, 1000); EXPECT_EQ(p.timeoutSecs, 5); EXPECT_TRUE(p.relockAfter);
|
||||||
|
p = planSetPinVerify(true, 0, 1000); EXPECT_TRUE(p.relockAfter);
|
||||||
|
p = planSetPinVerify(true, 1000 + 99999999, 1000); EXPECT_EQ(p.timeoutSecs, 86400 * 7); // capped
|
||||||
|
// Expiry predicate.
|
||||||
|
EXPECT_TRUE(unlockWindowExpired(true, false, 1000, 1000));
|
||||||
|
EXPECT_TRUE(unlockWindowExpired(true, false, 1000, 5000));
|
||||||
|
EXPECT_FALSE(unlockWindowExpired(true, false, 1000, 999));
|
||||||
|
EXPECT_FALSE(unlockWindowExpired(true, true, 1000, 5000)); // already locked in the GUI
|
||||||
|
EXPECT_FALSE(unlockWindowExpired(false, false, 1000, 5000)); // unencrypted
|
||||||
|
EXPECT_FALSE(unlockWindowExpired(true, false, 0, 5000)); // no deadline known (Lite / unknown)
|
||||||
|
}
|
||||||
|
|
||||||
void testRefreshSchedulerDeferFor()
|
void testRefreshSchedulerDeferFor()
|
||||||
{
|
{
|
||||||
using dragonx::services::RefreshScheduler;
|
using dragonx::services::RefreshScheduler;
|
||||||
@@ -17514,6 +17534,7 @@ int main()
|
|||||||
testRefreshScheduler();
|
testRefreshScheduler();
|
||||||
testIdleTracker();
|
testIdleTracker();
|
||||||
testEncryptionPendingPolicy();
|
testEncryptionPendingPolicy();
|
||||||
|
testWalletLockReconcilePolicy();
|
||||||
testRefreshSchedulerDeferFor();
|
testRefreshSchedulerDeferFor();
|
||||||
testRpcBusyGate();
|
testRpcBusyGate();
|
||||||
testIsLongWalletMethod();
|
testIsLongWalletMethod();
|
||||||
|
|||||||
Reference in New Issue
Block a user