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:
2026-10-12 21:42:23 -05:00
co-authored by Claude Opus 4.8
parent 2fce9a7860
commit fbe09c6a4c
5 changed files with 86 additions and 7 deletions
+32 -7
View File
@@ -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
// Supports two modes:
@@ -2184,11 +2201,15 @@ void App::renderPinDialogs() {
memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_));
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)
try {
rpc::RPCClient::TraceScope trace("Security / PIN setup");
rpc_->call("walletpassphrase", {passphrase, 5});
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());
@@ -2204,13 +2225,17 @@ void App::renderPinDialogs() {
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
// Lock wallet back
try {
rpc::RPCClient::TraceScope trace("Security / PIN setup");
rpc_->call("walletlock");
} catch (...) {}
// 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;