name the db file migrated from a json wallet after the opened file and improve handling for existing wallets
What changed, and why it matters
This commit fixes a bug in Sparrow Wallet where opening an older JSON-format wallet could accidentally overwrite or create files in unexpected locations. Previously, the app named the new database file using the wallet's internal name rather than the file the user actually opened, and it would silently delete an existing database with the same internal name. The patch makes the new file match the opened filename, refuses to overwrite an existing file, and cleans up partial files if migration fails. The included tests show the old behavior could overwrite another wallet or write outside the chosen folder.
Treat this as a security-hardening fix and include it in the next release. Users who previously opened JSON wallets with internal names differing from filenames should verify no unexpected wallet files were created or overwritten. No immediate emergency response is indicated, but the fix should be shipped promptly because the pre-patch behavior could cause data loss or wallet file confusion.
Security signals we found
Path traversal / unsafe filename construction from user-controlled wallet name prevented
Silent deletion of existing wallet file on name collision removed
Partial migration rollback added to avoid corrupt leftover database
New unit tests explicitly model overwrite and directory-escape scenarios
Evidence from the diff
Storage.migrateType() previously constructed the migrated walletFile from wallet.getName() + extension, then deleted any existing file at that path before saving. The patch derives the target filename from persistence.getWalletName(walletFile, null) (the opened file’s basename), checks for existence and throws StorageException if it exists, and on any exception rolls back by closing the new persistence, deleting the partial migrated file, and restoring the original persistence and walletFile. Tests demonstrate that a JSON wallet whose internal name differs from its filename now migrates to a DB named after the opened file, that an existing DB with the same internal name is no longer overwritten, that path traversal via the wallet name cannot write outside the opened file’s directory, and that failed migrations leave no partial DB behind.
Changed components
src/main/java/com/sparrowwallet/sparrow/io/Storage.javaWallet migration from JSON to DB persistenceFile naming and collision handling during wallet open/migrateInspect captured patch +197 / −6
### src/main/java/com/sparrowwallet/sparrow/io/Storage.java
@@ -306,15 +306,19 @@ private WalletAndKey migrateToDb(WalletAndKey masterWalletAndKey) throws IOExcep
private WalletAndKey migrateType(PersistenceType type, Wallet wallet, ECKey encryptionKey) throws IOException, StorageException {
File existingFile = walletFile;
+ Persistence existingPersistence = persistence;
+ String walletName = persistence.getWalletName(walletFile, null);
+ File migratedFile = new File(walletFile.getParentFile(), walletName + "." + type.getExtension());
+ if(migratedFile.exists()) {
+ throw new StorageException("Cannot migrate " + existingFile.getName() + " as " + migratedFile.getName() + " already exists. Move or rename one of these files and try again.");
+ }
try {
AsymmetricKeyDeriver keyDeriver = persistence.getKeyDeriver();
persistence = type.getInstance();
persistence.setKeyDeriver(keyDeriver);
- walletFile = new File(walletFile.getParentFile(), wallet.getName() + "." + type.getExtension());
- if(walletFile.exists()) {
- walletFile.delete();
- }
+ walletFile = migratedFile;
+ wallet.setName(walletName);
saveWallet(wallet);
if(type == PersistenceType.DB) {
@@ -329,6 +333,11 @@ private WalletAndKey migrateType(PersistenceType type, Wallet wallet, ECKey encr
return persistence.loadWallet(this, null, encryptionKey);
} catch(Exception e) {
+ //Remove the partial migration and return to the original file so opening it again can retry
+ persistence.close();
+ migratedFile.delete();
+ persistence = existingPersistence;
+ walletFile = existingFile;
existingFile = null;
throw e;
} finally {
### src/test/java/com/sparrowwallet/sparrow/io/StorageTest.java
@@ -1,22 +1,51 @@
package com.sparrowwallet.sparrow.io;
+import com.sparrowwallet.drongo.ExtendedKey;
+import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Utils;
+import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
+import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.MnemonicException;
import com.sparrowwallet.drongo.wallet.Wallet;
+import org.jdbi.v3.core.statement.UnableToExecuteStatementException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
+import java.util.Comparator;
+import java.util.stream.Stream;
public class StorageTest extends IoTest {
+ private static final String EXISTING_MNEMONIC = "response seminar brave tip suit recall often sound stick owner lottery motion";
+ private static final String OPENED_MNEMONIC = "aware report movie exile buyer drum poverty supreme gym oppose float elegant";
+ private static final String DERIVATION = "m/84'/0'/0'";
+
+ private static String existingXpub;
+ private static String openedXpub;
+
+ private Path tempDir;
+
+ @BeforeAll
+ static void deriveXpubs() throws Exception {
+ existingXpub = seedXpub(EXISTING_MNEMONIC);
+ openedXpub = seedXpub(OPENED_MNEMONIC);
+ }
+
+ @BeforeEach
+ void setUp() throws IOException {
+ tempDir = Files.createTempDirectory("sparrow-storage");
+ }
+
@Test
public void loadWallet() throws IOException, MnemonicException, StorageException {
System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_NETWORKS_PROPERTY, "true");
@@ -47,7 +76,7 @@ public void loadSeedWallet() throws IOException, MnemonicException, StorageExcep
Assertions.assertTrue(wallet.isValid());
- Assertions.assertEquals("testd2", wallet.getName());
+ Assertions.assertTrue(wallet.getName().startsWith("sparrow-single-seed-wallet"));
Assertions.assertEquals(PolicyType.SINGLE_HD, wallet.getPolicyType());
Assertions.assertEquals(ScriptType.P2WPKH, wallet.getScriptType());
Assertions.assertEquals(1, wallet.getDefaultPolicy().getNumSignaturesRequired());
@@ -114,6 +143,91 @@ public void getBackupsTreatsAWalletNameLiterally() throws IOException {
assertBackups(backupDir, PersistenceType.DB, "Savings.old.mv.db");
}
+ @Test
+ public void migrateNamesWalletAfterOpenedFile() throws Exception {
+ File walletsDir = Files.createDirectory(tempDir.resolve("wallets")).toFile();
+ File json = writeJsonWallet(new File(walletsDir, "mywallet.json"), watchOnly("../elsewhere", existingXpub));
+
+ Storage storage = new Storage(json);
+ try {
+ Wallet wallet = storage.loadUnencryptedWallet().getWallet();
+ Assertions.assertEquals("mywallet", wallet.getName());
+ } finally {
+ storage.closeAndWait();
+ }
+
+ File migrated = new File(walletsDir, "mywallet.mv.db");
+ Assertions.assertTrue(migrated.exists(), "mywallet.mv.db missing after migrate");
+ Assertions.assertFalse(json.exists(), "opened JSON file should be removed after migrate");
+ Assertions.assertFalse(new File(tempDir.toFile(), "elsewhere.mv.db").exists(), "wallet written using the name stored inside the JSON");
+ Assertions.assertEquals(existingXpub, readDbXpub(migrated));
+ }
+
+ @Test
+ public void migrateDoesNotReplaceWalletWithSameInternalName() throws Exception {
+ File walletsDir = Files.createDirectory(tempDir.resolve("wallets")).toFile();
+ File existing = saveDbWallet(walletsDir, watchOnly("savings", existingXpub));
+ Assertions.assertEquals(existingXpub, readDbXpub(existing));
+
+ File opened = writeJsonWallet(new File(walletsDir, "statement.json"), watchOnly("savings", openedXpub));
+ openWallet(opened);
+
+ Assertions.assertTrue(existing.exists(), "savings.mv.db missing after migrate");
+ Assertions.assertEquals(existingXpub, readDbXpub(existing), "savings.mv.db was replaced by the opened JSON wallet");
+ }
+
+ @Test
+ public void migrateDoesNotWriteOutsideFolder() throws Exception {
+ File walletsDir = Files.createDirectory(tempDir.resolve("wallets")).toFile();
+ File downloads = Files.createDirectory(tempDir.resolve("downloads")).toFile();
+ File existing = saveDbWallet(walletsDir, watchOnly("coldstorage", existingXpub));
+ Assertions.assertEquals(existingXpub, readDbXpub(existing));
+
+ File opened = writeJsonWallet(new File(downloads, "invoice.json"), watchOnly("../wallets/coldstorage", openedXpub));
+ openWallet(opened);
+
+ Assertions.assertTrue(existing.exists(), "wallet outside the opened file's folder missing after migrate");
+ Assertions.assertEquals(existingXpub, readDbXpub(existing), "wallet outside the opened file's folder was overwritten");
+ }
+
+ @Test
+ public void migrateRefusesExistingTarget() throws Exception {
+ File walletsDir = Files.createDirectory(tempDir.resolve("wallets")).toFile();
+ File existing = saveDbWallet(walletsDir, watchOnly("foo", existingXpub));
+ File json = writeJsonWallet(new File(walletsDir, "foo.json"), watchOnly("foo", openedXpub));
+ byte[] jsonContents = Files.readAllBytes(json.toPath());
+
+ Storage storage = new Storage(json);
+ try {
+ Assertions.assertThrows(StorageException.class, storage::loadUnencryptedWallet);
+ } finally {
+ storage.closeAndWait();
+ }
+
+ Assertions.assertEquals(existingXpub, readDbXpub(existing), "foo.mv.db was changed by the refused migration");
+ Assertions.assertArrayEquals(jsonContents, Files.readAllBytes(json.toPath()), "foo.json was changed by the refused migration");
+ }
+
+ @Test
+ public void failedMigrateRemovesPartialFile() throws Exception {
+ File walletsDir = Files.createDirectory(tempDir.resolve("wallets")).toFile();
+ Wallet wallet = watchOnly("broken", openedXpub);
+ //A master fingerprint longer than the keystore column allows is only rejected by the insert, after the DB file is created
+ wallet.getKeystores().getFirst().setKeyDerivation(new KeyDerivation("60bcd3a7ff", DERIVATION));
+ File json = writeJsonWallet(new File(walletsDir, "broken.json"), wallet);
+ byte[] jsonContents = Files.readAllBytes(json.toPath());
+
+ Storage storage = new Storage(json);
+ try {
+ Assertions.assertThrows(UnableToExecuteStatementException.class, storage::loadUnencryptedWallet);
+ } finally {
+ storage.closeAndWait();
+ }
+
+ Assertions.assertFalse(new File(walletsDir, "broken.mv.db").exists(), "partial broken.mv.db left after failed migrate");
+ Assertions.assertArrayEquals(jsonContents, Files.readAllBytes(json.toPath()), "broken.json was changed by the failed migration");
+ }
+
private File createBackupDir(String... backupNames) throws IOException {
Path backupDir = Files.createTempDirectory("sprw-backup");
backupDir.toFile().deleteOnExit();
@@ -132,8 +246,76 @@ private void assertBackups(File backupDir, PersistenceType persistenceType, Stri
Assertions.assertArrayEquals(expectedBackupNames, Arrays.stream(backups).map(File::getName).toArray(String[]::new));
}
+ private static String seedXpub(String mnemonic) throws Exception {
+ DeterministicSeed seed = new DeterministicSeed(mnemonic, "", 0, DeterministicSeed.Type.BIP39);
+ Keystore keystore = Keystore.fromSeed(seed, PolicyType.SINGLE_HD, KeyDerivation.parsePath(DERIVATION));
+ return keystore.getExtendedPublicKey().toString();
+ }
+
+ private Wallet watchOnly(String name, String xpub) {
+ Wallet wallet = new Wallet(name);
+ wallet.setPolicyType(PolicyType.SINGLE_HD);
+ wallet.setScriptType(ScriptType.P2WPKH);
+ Keystore keystore = new Keystore("Keystore 1");
+ keystore.setKeyDerivation(new KeyDerivation("60bcd3a7", DERIVATION));
+ keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(xpub));
+ wallet.getKeystores().add(keystore);
+ wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE_HD, ScriptType.P2WPKH, wallet.getKeystores(), null));
+ return wallet;
+ }
+
+ private File saveDbWallet(File dir, Wallet wallet) throws Exception {
+ File file = new File(dir, wallet.getName() + "." + PersistenceType.DB.getExtension());
+ Storage storage = new Storage(PersistenceType.DB, file);
+ storage.setEncryptionPubKey(Storage.NO_PASSWORD_KEY);
+ try {
+ storage.saveWallet(wallet);
+ } finally {
+ storage.closeAndWait();
+ }
+ return storage.getWalletFile();
+ }
+
+ private File writeJsonWallet(File file, Wallet wallet) throws Exception {
+ Storage storage = new Storage(PersistenceType.JSON, file);
+ storage.setEncryptionPubKey(Storage.NO_PASSWORD_KEY);
+ try {
+ storage.saveWallet(wallet);
+ } finally {
+ storage.closeAndWait();
+ }
+ return storage.getWalletFile();
+ }
+
+ private String readDbXpub(File dbFile) throws Exception {
+ Storage storage = new Storage(PersistenceType.DB, dbFile);
+ try {
+ Wallet w = storage.loadUnencryptedWallet().getWallet();
+ return w.getKeystores().getFirst().getExtendedPublicKey().toString();
+ } finally {
+ storage.closeAndWait();
+ }
+ }
+
+ private void openWallet(File file) {
+ Storage storage = new Storage(file);
+ Assertions.assertEquals(PersistenceType.JSON, storage.getType());
+ try {
+ storage.loadUnencryptedWallet();
+ } catch(Exception e) {
+ //Refusing to open the file is acceptable
+ } finally {
+ storage.closeAndWait();
+ }
+ }
+
@AfterEach
- void tearDown() {
+ void tearDown() throws IOException {
System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_NETWORKS_PROPERTY, "false");
+ if(tempDir != null) {
+ try(Stream<Path> paths = Files.walk(tempDir)) {
+ paths.sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete);
+ }
+ }
}
}Why this scored 60/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.