confirm the imported quorum and cosigners before completing an incomplete multisig wallet
What changed, and why it matters
This commit adds a confirmation step when completing an unfinished multi-signature (multisig) Bitcoin wallet by importing a coordinator's configuration. Before the change, Sparrow would silently replace the user's local cosigner list and signing rules with whatever the imported file contained. After the change, the user must review and approve the required number of signatures (quorum), the list of cosigners, and which of their own keys are included before the wallet is updated. This reduces the risk that a malicious or incorrect import could trick the user into a wallet they cannot truly control or co-sign.
Treat this as a security-hardening fix and include it in the next release. Users relying on multisig import should upgrade. No immediate incident response is indicated, but wallet developers should verify that similar silent policy-adoption paths do not exist elsewhere.
Security signals we found
Adds explicit user confirmation before adopting imported multisig policy
Requires exact xpub match for own keystore identification (BIP129 compliance)
Adds validation of derivation paths and wallet validity before replacement
Prevents silent replacement of local cosigners and quorum settings
Displays quorum, cosigner key origins, and removed keystores to the user
Evidence from the diff
The patch modifies the incomplete multisig wallet completion flow. In AppController.java, imported extended public keys are filtered to ignore null values. In SettingsController.java, the existingWalletImported handler now: (1) maps imported keystores to local keystores by exact xpub match rather than replacing blindly, (2) validates derivation paths and wallet validity, (3) presents a warning dialog showing the imported quorum (threshold and total cosigners), each cosigner’s label/key origin, which indexes belong to the user, and any locally configured keystores that will be removed, and (4) only substitutes the user’s own keystores and replaces the wallet after explicit OK. The change aligns with BIP129’s requirement that a signer verify its own key by exact match and be shown the policy before adoption.
Changed components
Sparrow Wallet desktop applicationAppController.java importWallet flowSettingsController.java existingWalletImported handlerMultisig wallet import/completion featureInspect captured patch +72 / −8
### src/main/java/com/sparrowwallet/sparrow/AppController.java
@@ -1276,7 +1276,7 @@ public void importWallet(ActionEvent event) {
for(Wallet wallet : wallets) {
List<WalletTabData> walletTabData = getOpenWalletTabData();
- List<ExtendedKey> xpubs = wallet.getKeystores().stream().map(Keystore::getExtendedPublicKey).collect(Collectors.toList());
+ List<ExtendedKey> xpubs = wallet.getKeystores().stream().map(Keystore::getExtendedPublicKey).filter(Objects::nonNull).collect(Collectors.toList());
Optional<WalletForm> optNewWalletForm = walletTabData.stream()
.map(WalletTabData::getWalletForm)
.filter(wf -> wf.getSettingsWalletForm() != null && wf.getSettingsWalletForm().getWallet().getPolicyType() == PolicyType.MULTI_HD &&
### src/main/java/com/sparrowwallet/sparrow/wallet/SettingsController.java
@@ -44,6 +44,7 @@
import java.util.*;
import java.util.regex.Matcher;
import java.util.stream.Collectors;
+import java.util.stream.IntStream;
import static com.sparrowwallet.drongo.OutputDescriptor.KEY_ORIGIN_PATTERN;
import static com.sparrowwallet.drongo.OutputDescriptor.LEGACY_MULTI_PATTERN;
@@ -941,19 +942,82 @@ public void walletGapLimitChanged(WalletGapLimitChangedEvent event) {
@Subscribe
public void existingWalletImported(ExistingWalletImportedEvent event) {
if(event.getExistingWalletId().equals(getWalletForm().getWalletId())) {
- List<Keystore> importedKeystores = event.getImportedWallet().getKeystores();
+ Wallet importedWallet = event.getImportedWallet();
+ List<Keystore> importedKeystores = importedWallet.getKeystores();
List<Keystore> nonWatchKeystores = walletForm.getWallet().getKeystores().stream().filter(k -> k.isValid() && k.getSource() != KeystoreSource.SW_WATCH).collect(Collectors.toList());
+
+ //BIP129 requires a signer to verify its own key is present in the imported descriptor by an exact match, and not by a shortcut such as the master fingerprint
+ Map<Integer, Keystore> ownKeystores = new LinkedHashMap<>();
+ List<Keystore> absentKeystores = new ArrayList<>();
for(Keystore nonWatchKeystore : nonWatchKeystores) {
- Optional<Keystore> optReplacedKeystore = importedKeystores.stream().filter(k -> Objects.equals(nonWatchKeystore.getExtendedPublicKey(), k.getExtendedPublicKey())).findFirst();
- if(optReplacedKeystore.isPresent()) {
- int index = importedKeystores.indexOf(optReplacedKeystore.get());
- importedKeystores.remove(index);
- importedKeystores.add(index, nonWatchKeystore);
+ int index = IntStream.range(0, importedKeystores.size())
+ .filter(i -> Objects.equals(nonWatchKeystore.getExtendedPublicKey(), importedKeystores.get(i).getExtendedPublicKey())).findFirst().orElse(-1);
+ if(index >= 0) {
+ ownKeystores.put(index, nonWatchKeystore);
+ } else {
+ absentKeystores.add(nonWatchKeystore);
}
}
- replaceWallet(event.getImportedWallet());
+ if(AppServices.disallowAnyInvalidDerivationPaths(importedWallet)) {
+ return;
+ }
+
+ try {
+ importedWallet.checkWallet();
+ } catch(InvalidWalletException e) {
+ showErrorDialog("Error Importing Wallet", "The imported wallet is not valid: " + e.getMessage());
+ return;
+ }
+
+ if(!confirmImportedPolicy(importedWallet, ownKeystores.keySet(), absentKeystores)) {
+ return;
+ }
+
+ //Substituted only once the import is accepted, so the current wallet is untouched if it is not. Cosigners of the same model share a label, and the coordinator has
+ //already made those unique, so the label of the replaced cosigner is taken rather than the local one - which would often duplicate another cosigner's label.
+ ownKeystores.forEach((index, ownKeystore) -> {
+ ownKeystore.setLabel(importedKeystores.get(index).getLabel());
+ importedKeystores.set(index, ownKeystore);
+ });
+
+ replaceWallet(importedWallet);
+ }
+ }
+
+ //The imported policy, cosigners and the position of the user's own keys are adopted wholesale, so BIP129 requires the signer be shown them before they are
+ private boolean confirmImportedPolicy(Wallet importedWallet, Set<Integer> ownIndexes, List<Keystore> absentKeystores) {
+ List<Keystore> importedKeystores = importedWallet.getKeystores();
+ int threshold = importedWallet.getDefaultPolicy().getNumSignaturesRequired();
+
+ StringBuilder content = new StringBuilder("This import completes the configuration of ").append(walletForm.getWallet().getFullDisplayName()).append(".\n\n");
+ content.append("Quorum: ").append(threshold).append(" of ").append(importedKeystores.size());
+ int configuredThreshold = (int)multisigControl.getLowValue();
+ int configuredCosigners = (int)multisigControl.getHighValue();
+ if(threshold != configuredThreshold || importedKeystores.size() != configuredCosigners) {
+ content.append(", replacing the ").append(configuredThreshold).append(" of ").append(configuredCosigners).append(" configured here");
+ }
+ content.append("\n\n");
+
+ for(int i = 0; i < importedKeystores.size(); i++) {
+ Keystore keystore = importedKeystores.get(i);
+ KeyDerivation keyDerivation = keystore.getKeyDerivation();
+ content.append("Cosigner ").append(i + 1).append(": ").append(keystore.getLabel());
+ content.append(" [").append(keyDerivation.getMasterFingerprint()).append(KeyDerivation.writePath(keyDerivation.getDerivation()).substring(1)).append("]");
+ if(ownIndexes.contains(i)) {
+ content.append(" - your keystore");
+ }
+ content.append("\n");
+ }
+
+ for(Keystore absentKeystore : absentKeystores) {
+ content.append("\nThe keystore ").append(absentKeystore.getLabel()).append(" configured here is not present in this import and will be removed. You will not be able to sign with it.\n");
}
+
+ content.append("\nVerify the quorum and the cosigner key origins above against those provided by the coordinator before applying.");
+
+ Optional<ButtonType> optResponse = showWarningDialog("Complete Multisig Wallet?", content.toString(), ButtonType.CANCEL, ButtonType.OK);
+ return optResponse.isPresent() && optResponse.get() == ButtonType.OK;
}
//Returns true if the wallet save was initiated, and false if it was abandoned without any change to the wallet or its storageWhy this scored 44/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.