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
+21
View File
@@ -1169,6 +1169,26 @@ void testEncryptionPendingPolicy()
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()
{
using dragonx::services::RefreshScheduler;
@@ -17514,6 +17534,7 @@ int main()
testRefreshScheduler();
testIdleTracker();
testEncryptionPendingPolicy();
testWalletLockReconcilePolicy();
testRefreshSchedulerDeferFor();
testRpcBusyGate();
testIsLongWalletMethod();