prevent a password change from re-encrypting wallets whose filenames share the same prefix
What changed, and why it matters
This commit fixes a bug in Sparrow Wallet's password-change feature. Previously, when a user changed the password on a wallet file whose name was a prefix of another wallet file (for example, 'Savings' and 'Savings.old'), the underlying H2 database tool would also re-encrypt the sibling wallet file. That could corrupt or lock the sibling wallet. The fix copies the target wallet to its own temporary directory, performs the encryption change there, verifies it, and then atomically replaces the original file so no other wallet files are touched.
Users should upgrade to a Sparrow Wallet release containing this commit. Until then, avoid changing wallet passwords when other wallet files in the same directory share a common prefix with the target wallet name. Wallet backups should be maintained before any password change.
Security signals we found
Unintended re-encryption of sibling wallet files due to prefix matching in H2 ChangeFileEncryption
Potential wallet corruption or loss of access when changing a wallet password
Use of isolated temporary directory to prevent cross-wallet file operations
Atomic move fallback to reduce window for file corruption during replacement
Verification of encryption header before replacing original wallet file
Secure deletion of temporary wallet copy after conversion
Failure propagation added instead of silent swallowing of password-change errors
Evidence from the diff
DbPersistence.updatePassword previously called H2’s ChangeFileEncryption.execute on the wallet’s parent directory using the wallet name as a prefix. H2 matches every file in that directory whose name starts with ‘
Changed components
src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.javasrc/test/java/com/sparrowwallet/sparrow/io/DbPersistenceTest.javaInspect captured patch +153 / −10
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java b/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java
index 3c842b8..16c59f3 100644
--- a/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java
+++ b/src/main/java/com/sparrowwallet/sparrow/io/db/DbPersistence.java
@@ -40,9 +40,12 @@ import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
+import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
+import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.SecureRandom;
+import java.sql.SQLException;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.regex.Pattern;
@@ -636,7 +639,7 @@ public class DbPersistence implements Persistence {
}
}
- private void updatePassword(Storage storage, ECKey encryptionPubKey) {
+ private void updatePassword(Storage storage, ECKey encryptionPubKey) throws StorageException {
String newPassword = getFilePassword(encryptionPubKey);
String currentPassword = getDatasourcePassword();
@@ -647,20 +650,63 @@ public class DbPersistence implements Persistence {
}
try {
- File walletFile = storage.getWalletFile();
- ChangeFileEncryption.execute(walletFile.getParent(), getWalletName(walletFile, null), "AES",
- currentPassword == null ? null : currentPassword.toCharArray(),
- newPassword == null ? null : newPassword.toCharArray(), true);
-
- if(newPassword != null) {
- writeBinaryHeader(walletFile);
- }
+ changeFileEncryption(storage.getWalletFile(), currentPassword, newPassword);
//This sets the new password on the datasource for the next updatePassword check
getDataSource(storage, newPassword);
} catch(Exception e) {
+ //The wallet file is either unchanged or fully converted, so do not go on to write to it with a password it may not have
log.error("Error changing database password", e);
+ throw new StorageException("Failed to change the password of the wallet file.\n" + e.getMessage(), e);
+ }
+ }
+ }
+
+ //H2's ChangeFileEncryption converts every file in the given directory whose name starts with the database name followed by a dot, which also
+ //matches sibling wallet files where the wallet name itself contains a dot. Convert a copy of this wallet file in a temporary directory of its
+ //own so that no other file can be touched, and only replace the original once the conversion has been verified. The temporary directory is
+ //created alongside the wallet file so that the replacement is an atomic move, and so that the copy is not written to a shared temp location.
+ private void changeFileEncryption(File walletFile, String currentPassword, String newPassword) throws IOException, SQLException {
+ File dbFile = walletFile.getAbsoluteFile();
+ Path encryptionDir = Files.createTempDirectory(dbFile.getParentFile().toPath(), "sparrowenc");
+
+ try {
+ Path encryptionFile = encryptionDir.resolve(dbFile.getName());
+ Files.copy(dbFile.toPath(), encryptionFile);
+ ChangeFileEncryption.execute(encryptionDir.toString(), getWalletName(dbFile, null), "AES",
+ currentPassword == null ? null : currentPassword.toCharArray(),
+ newPassword == null ? null : newPassword.toCharArray(), true);
+
+ //Verify the conversion happened before replacing the original
+ if(hasEncryptHeader(encryptionFile.toFile()) == (newPassword == null)) {
+ throw new IOException("The encryption of the wallet file was not changed");
+ }
+
+ //H2 writes a new file header on every conversion, discarding the salt written there previously
+ if(newPassword != null) {
+ writeBinaryHeader(encryptionFile.toFile());
+ }
+
+ try {
+ Files.setPosixFilePermissions(encryptionFile, Files.getPosixFilePermissions(dbFile.toPath()));
+ } catch(UnsupportedOperationException | IOException e) {
+ log.debug("Could not copy permissions to " + encryptionFile, e);
+ }
+
+ try {
+ Files.move(encryptionFile, dbFile.toPath(), StandardCopyOption.ATOMIC_MOVE);
+ } catch(AtomicMoveNotSupportedException e) {
+ Files.move(encryptionFile, dbFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
+ } finally {
+ File[] remainingFiles = encryptionDir.toFile().listFiles();
+ if(remainingFiles != null) {
+ for(File remainingFile : remainingFiles) {
+ IOUtils.secureDelete(remainingFile);
+ }
+ }
+
+ IOUtils.deleteDirectory(encryptionDir.toFile());
}
}
@@ -744,6 +790,10 @@ public class DbPersistence implements Persistence {
return getDatasourcePassword() != null;
}
+ return hasEncryptHeader(walletFile);
+ }
+
+ private boolean hasEncryptHeader(File walletFile) throws IOException {
byte[] header = new byte[H2_ENCRYPT_HEADER.length];
try(InputStream inputStream = new FileInputStream(walletFile)) {
inputStream.read(header, 0, H2_ENCRYPT_HEADER.length);
diff --git a/src/test/java/com/sparrowwallet/sparrow/io/DbPersistenceTest.java b/src/test/java/com/sparrowwallet/sparrow/io/DbPersistenceTest.java
index fcf4647..3ec27b1 100644
--- a/src/test/java/com/sparrowwallet/sparrow/io/DbPersistenceTest.java
+++ b/src/test/java/com/sparrowwallet/sparrow/io/DbPersistenceTest.java
@@ -1,5 +1,17 @@
package com.sparrowwallet.sparrow.io;
+import com.sparrowwallet.drongo.ExtendedKey;
+import com.sparrowwallet.drongo.KeyDerivation;
+import com.sparrowwallet.drongo.crypto.Argon2KeyDeriver;
+import com.sparrowwallet.drongo.crypto.ECKey;
+import com.sparrowwallet.drongo.policy.Policy;
+import com.sparrowwallet.drongo.policy.PolicyType;
+import com.sparrowwallet.drongo.protocol.ScriptType;
+import com.sparrowwallet.drongo.protocol.Sha256Hash;
+import com.sparrowwallet.drongo.wallet.Keystore;
+import com.sparrowwallet.drongo.wallet.KeystoreSource;
+import com.sparrowwallet.drongo.wallet.Wallet;
+import com.sparrowwallet.drongo.wallet.WalletModel;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
@@ -91,4 +103,85 @@ public class DbPersistenceTest {
public void domainRejected() throws Exception {
assertRejected(buildWalletFile("create domain wallet_master.dm as int default 0"));
}
+
+ private static final String TEST_XPUB = "xpub6BrhGFTWPd3DXo8s2BPxHHzCmBCyj8QvamcEUaq8EDwnwXpvvcU9LzpJqENHcqHkqwTn2vPhynGVoEqj3PAB3NxnYZrvCsSfoCniJKaggdy";
+
+ private Wallet createWallet(String walletName) {
+ Wallet wallet = new Wallet(walletName);
+ wallet.setPolicyType(PolicyType.SINGLE_HD);
+ wallet.setScriptType(ScriptType.P2WPKH);
+
+ Keystore keystore = new Keystore("Keystore 1");
+ keystore.setSource(KeystoreSource.SW_WATCH);
+ keystore.setWalletModel(WalletModel.SPARROW);
+ keystore.setKeyDerivation(new KeyDerivation("60bcd3a7", "m/84'/0'/3'"));
+ keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(TEST_XPUB));
+ wallet.getKeystores().add(keystore);
+ wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE_HD, ScriptType.P2WPKH, wallet.getKeystores(), null));
+
+ return wallet;
+ }
+
+ private Storage createUnencryptedWallet(String walletName) throws Exception {
+ Storage storage = new Storage(PersistenceType.DB, tempDir.resolve(walletName + "." + PersistenceType.DB.getExtension()).toFile());
+ storage.setKeyDeriver(new Argon2KeyDeriver());
+ storage.setEncryptionPubKey(Storage.NO_PASSWORD_KEY);
+ storage.saveWallet(createWallet(walletName));
+
+ return storage;
+ }
+
+ private void setPassword(Storage storage, CharSequence password) throws Exception {
+ ECKey encryptionPubKey = password == null ? Storage.NO_PASSWORD_KEY : ECKey.fromPublicOnly(storage.getKeyDeriver().deriveECKey(password));
+ storage.setEncryptionPubKey(encryptionPubKey);
+ storage.saveWallet(createWallet(storage.getWalletName(null)));
+ }
+
+ private Sha256Hash getFileHash(File file) throws Exception {
+ return Sha256Hash.of(Files.readAllBytes(file.toPath()));
+ }
+
+ @Test
+ public void passwordChangeLeavesSiblingWalletUntouched() throws Exception {
+ Storage siblingStorage = createUnencryptedWallet("Savings.old");
+ siblingStorage.closeAndWait();
+ File siblingFile = siblingStorage.getWalletFile();
+ Sha256Hash siblingHash = getFileHash(siblingFile);
+
+ Storage storage = createUnencryptedWallet("Savings");
+ setPassword(storage, "pass");
+ storage.closeAndWait();
+
+ Assertions.assertEquals(siblingHash, getFileHash(siblingFile), "sibling wallet file was rewritten by the password change");
+ Assertions.assertTrue(new Storage(PersistenceType.DB, siblingFile).loadUnencryptedWallet().getWallet().isValid());
+ Assertions.assertTrue(new Storage(PersistenceType.DB, storage.getWalletFile()).loadEncryptedWallet("pass").getWallet().isValid());
+
+ //The conversion must not leave the wallet copy or H2's temp.db behind in the wallets directory
+ String[] tempFiles = tempDir.toFile().list((dir, name) -> name.equals("temp.db") || name.startsWith("sparrowenc"));
+ Assertions.assertEquals(0, tempFiles == null ? -1 : tempFiles.length, "temporary files were left in the wallets directory");
+ }
+
+ @Test
+ public void passwordChangeAppliesToWalletNameContainingDot() throws Exception {
+ Storage siblingStorage = createUnencryptedWallet("Savings");
+ siblingStorage.closeAndWait();
+ Sha256Hash siblingHash = getFileHash(siblingStorage.getWalletFile());
+
+ Storage storage = createUnencryptedWallet("Savings.old");
+ setPassword(storage, "pass");
+ storage.closeAndWait();
+
+ Assertions.assertEquals(siblingHash, getFileHash(siblingStorage.getWalletFile()));
+ Assertions.assertTrue(new Storage(PersistenceType.DB, storage.getWalletFile()).loadEncryptedWallet("pass").getWallet().isValid());
+ }
+
+ @Test
+ public void passwordRemovalDecryptsWalletFile() throws Exception {
+ Storage storage = createUnencryptedWallet("Savings");
+ setPassword(storage, "pass");
+ setPassword(storage, null);
+ storage.closeAndWait();
+
+ Assertions.assertTrue(new Storage(PersistenceType.DB, storage.getWalletFile()).loadUnencryptedWallet().getWallet().isValid());
+ }
}
Why this scored 66/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.