apply the wallet lock to label import, sweep and show paynym, and to password prompts and unlocks completing after the wallet was locked
What changed, and why it matters
This commit fixes a timing issue in the Sparrow Bitcoin wallet where a user could still perform sensitive actions on a wallet that had been locked while a password prompt or background unlock was in progress. The patch makes sure that if a wallet is locked before the password is entered or the unlock finishes, the action is cancelled or blocked. It also disables certain wallet features (label import, sweeping private keys, and showing a PayNym) when the wallet is locked. This is a defensive hardening fix rather than a remote-exploitable vulnerability.
Treat as a security hardening fix and include in the next release. Users who share a machine or leave Sparrow open should continue to lock wallets when unattended. No immediate emergency response is indicated because exploitation requires local access and a specific user-interaction timing window.
Security signals we found
TOCTOU (time-of-check/time-of-use) race between wallet lock state and password-prompt/unlock completion
Missing authorization check on sensitive wallet operations (sweep private key, label import, show PayNym)
UI control state not synchronized with wallet lock state
Local-only attack surface; no network or cryptographic primitive change
Evidence from the diff
The patch hardens wallet-lock enforcement across the desktop and terminal UIs. Key changes: (1) AppController filters out locked wallet forms before opening the wallet-import dialog and before sweeping a private key, and disables the showPayNym control on lock events. (2) WalletController and SparrowTerminal introduce a monotonic lockCount so that an unlock operation that completes after a subsequent lock event is ignored (TOCTOU mitigation). (3) KeystoreController and SettingsController check walletForm.isLocked() after password entry and abort with an error dialog if the wallet was locked in the meantime. (4) SettingsWalletForm delegates isLocked() to the underlying app wallet form. (5) Terminal SettingsDialog refuses to save a wallet if it has since been locked. The changes are local UI/UX hardening and require physical/local access and user interaction.
Changed components
com.sparrowwallet.sparrow.AppControllercom.sparrowwallet.sparrow.terminal.MasterActionListBoxcom.sparrowwallet.sparrow.terminal.SparrowTerminalcom.sparrowwallet.sparrow.terminal.wallet.SettingsDialogcom.sparrowwallet.sparrow.wallet.KeystoreControllercom.sparrowwallet.sparrow.wallet.SettingsControllercom.sparrowwallet.sparrow.wallet.SettingsWalletFormcom.sparrowwallet.sparrow.wallet.WalletControllerInspect captured patch +54 / −11
### src/main/java/com/sparrowwallet/sparrow/AppController.java
@@ -1283,7 +1283,7 @@ private void openWallet(Storage storage, WalletAndKey walletAndKey, AppControlle
}
public void importWallet(ActionEvent event) {
- List<WalletForm> selectedWalletForms = getSelectedWalletForms();
+ List<WalletForm> selectedWalletForms = getSelectedWalletForms().stream().filter(walletForm -> !walletForm.isLocked()).collect(Collectors.toList());
WalletImportDialog dlg = new WalletImportDialog(selectedWalletForms);
dlg.initOwner(rootStack.getScene().getWindow());
Optional<List<Wallet>> optionalWallets = dlg.showAndWait();
@@ -1558,7 +1558,7 @@ private void sendToMany(List<Payment> initialPayments) {
public void sweepPrivateKey(ActionEvent event) {
Wallet wallet = null;
WalletForm selectedWalletForm = getSelectedWalletForm();
- if(selectedWalletForm != null && selectedWalletForm.getWallet().isValid()) {
+ if(selectedWalletForm != null && selectedWalletForm.getWallet().isValid() && !selectedWalletForm.isLocked()) {
wallet = selectedWalletForm.getWallet();
}
@@ -3439,6 +3439,7 @@ public void walletLock(WalletLockEvent event) {
if(selectedWalletForm != null && selectedWalletForm.getMasterWallet().equals(event.getWallet())) {
lockWallet.setDisable(true);
exportWallet.setDisable(true);
+ showPayNym.setDisable(true);
}
lockAllWallets.setDisable(allWalletsLocked(event.getWallet()));
@@ -3450,6 +3451,7 @@ public void walletUnlock(WalletUnlockEvent event) {
if(selectedWalletForm != null && selectedWalletForm.getMasterWallet().equals(event.getWallet())) {
lockWallet.setDisable(false);
exportWallet.setDisable(!event.getWallet().isValid());
+ showPayNym.setDisable(exportWallet.isDisable() || !selectedWalletForm.getWallet().hasPaymentCode());
lockAllWallets.setDisable(false);
}
}
### src/main/java/com/sparrowwallet/sparrow/terminal/MasterActionListBox.java
@@ -97,6 +97,7 @@ public MasterActionListBox(SparrowTerminal sparrowTerminal) {
private static void openLoadedWallet(Storage storage, Wallet wallet) {
if(SparrowTerminal.get().isLocked(storage)) {
String walletId = storage.getWalletId(wallet);
+ int lockCount = SparrowTerminal.get().getLockCount(storage);
TextInputDialogBuilder builder = new TextInputDialogBuilder().setTitle("Wallet Password");
builder.setDescription("Enter the wallet password:");
@@ -108,8 +109,9 @@ private static void openLoadedWallet(Storage storage, Wallet wallet) {
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, new SecureString(password), true);
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done"));
- SparrowTerminal.get().unlockWallet(storage);
- SparrowTerminal.get().getGuiThread().invokeLater(() -> LoadWallet.getOpeningDialog(storage, wallet).showDialog(SparrowTerminal.get().getGui()));
+ if(SparrowTerminal.get().unlockWallet(storage, lockCount)) {
+ SparrowTerminal.get().getGuiThread().invokeLater(() -> LoadWallet.getOpeningDialog(storage, wallet).showDialog(SparrowTerminal.get().getGui()));
+ }
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed"));
### src/main/java/com/sparrowwallet/sparrow/terminal/SparrowTerminal.java
@@ -41,6 +41,7 @@ public class SparrowTerminal extends Application {
private final Map<String, WalletData> walletData = new HashMap<>();
private final Set<File> lockedWallets = new HashSet<>();
+ private final Map<File, Integer> lockCounts = new HashMap<>();
private static final javafx.stage.Window DEFAULT_WINDOW = new Window() { };
@@ -157,15 +158,20 @@ public static void addWallet(Storage storage, Wallet wallet) {
EventManager.get().post(new WalletOpenedEvent(storage, wallet));
}
- public boolean isLocked(Storage storage) {
+ public synchronized boolean isLocked(Storage storage) {
return lockedWallets.contains(storage.getWalletFile());
}
- public void lockWallet(Storage storage) {
+ public synchronized void lockWallet(Storage storage) {
lockedWallets.add(storage.getWalletFile());
+ lockCounts.merge(storage.getWalletFile(), 1, Integer::sum);
}
- public void unlockWallet(Storage storage) {
- lockedWallets.remove(storage.getWalletFile());
+ public synchronized int getLockCount(Storage storage) {
+ return lockCounts.getOrDefault(storage.getWalletFile(), 0);
+ }
+
+ public synchronized boolean unlockWallet(Storage storage, int lockCount) {
+ return lockCount == getLockCount(storage) && lockedWallets.remove(storage.getWalletFile());
}
}
### src/main/java/com/sparrowwallet/sparrow/terminal/wallet/SettingsDialog.java
@@ -240,7 +240,7 @@ private boolean saveWallet(boolean changePassword, boolean suggestChangePassword
Key existingKey = key;
key = null;
SparrowTerminal.get().getGuiThread().invokeLater(() -> {
- boolean saving = saveWallet(true, false);
+ boolean saving = !SparrowTerminal.get().isLocked(walletForm.getStorage()) && saveWallet(true, false);
Platform.runLater(() -> {
//If a new password is not provided, re-encrypt with the existing key rather than leaving the wallet decrypted for the session
if(!saving) {
### src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java
@@ -508,6 +508,12 @@ public void showPrivate(ActionEvent event) {
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(copy, password.get());
decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(getWalletForm().getWalletId(), TimedEvent.Action.END, "Done"));
+
+ if(getWalletForm().isLocked()) {
+ AppServices.showErrorDialog("Wallet Locked", "The wallet was locked before the keystore could be displayed.");
+ return;
+ }
+
Wallet decryptedWallet = decryptWalletService.getValue();
showPrivate(decryptedWallet.getKeystores().get(keystoreIndex));
});
### src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java
@@ -1046,7 +1046,12 @@ private boolean saveWallet(boolean changePassword, boolean suggestChangePassword
WalletPasswordDialog dlg = new WalletPasswordDialog(null, requirement, suggestChangePassword);
dlg.initOwner(apply.getScene().getWindow());
- Optional<SecureString> password = dlg.showAndWait();
+ Optional<SecureString> password = walletForm.isLocked() ? Optional.empty() : dlg.showAndWait();
+ if(password.isPresent() && walletForm.isLocked()) {
+ AppServices.showErrorDialog("Wallet Locked", "The wallet was locked before the password was entered. Unlock the wallet and apply the change again.");
+ password = Optional.empty();
+ }
+
if(password.isPresent()) {
if(dlg.isBackupExisting()) {
try {
### src/main/java/com/sparrowwallet/sparrow/wallet/SettingsWalletForm.java
@@ -10,6 +10,7 @@
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.io.StorageException;
+import javafx.beans.property.BooleanProperty;
import java.io.IOException;
import java.util.ArrayList;
@@ -46,6 +47,21 @@ public WalletForm getAppWalletForm() {
return appWalletForm;
}
+ @Override
+ public boolean isLocked() {
+ return appWalletForm.isLocked();
+ }
+
+ @Override
+ public BooleanProperty lockedProperty() {
+ return appWalletForm.lockedProperty();
+ }
+
+ @Override
+ public void setLocked(boolean locked) {
+ appWalletForm.setLocked(locked);
+ }
+
@Override
public void revert() {
this.walletCopy = super.getWallet().copy();
### src/main/java/com/sparrowwallet/sparrow/wallet/WalletController.java
@@ -54,6 +54,8 @@ public class WalletController extends WalletFormController implements Initializa
private CustomPasswordField passwordField;
+ private int lockCount;
+
private final BooleanProperty walletEncryptedProperty = new SimpleBooleanProperty(false);
private final ChangeListener<Boolean> lockFocusListener = new ChangeListener<>() {
@@ -195,12 +197,15 @@ private void unlockWallet(CustomPasswordField passwordField) {
if(walletEncryptedProperty.get()) {
String walletId = walletForm.getWalletId();
SecureString password = new SecureString(passwordField.getText());
+ int lockCount = this.lockCount;
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(walletForm.getStorage(), password, true);
keyDerivationService.setOnSucceeded(workerStateEvent -> {
passwordField.clear();
password.clear();
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done"));
- unlockWallet();
+ if(lockCount == this.lockCount) {
+ unlockWallet();
+ }
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed"));
@@ -251,6 +256,7 @@ public void walletLock(WalletLockEvent event) {
getWalletForm().setLocked(true);
lockPane.setViewOrder(-1);
+ lockCount++;
}
}
Why this scored 46/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.