From 4dcd463d92058fad1bf3ccd5d8f51b6fdeae9eac Mon Sep 17 00:00:00 2001 From: Ian McKenzie <13459320+ikmckenz@users.noreply.github.com> Date: Tue, 9 Dec 2025 02:04:35 -0800 Subject: [PATCH] add codex32 importer to software keystore import workflow --- .../control/CodexKeystoreImportPane.java | 251 ++++++++++++++++++ .../com/sparrowwallet/sparrow/io/Bip93.java | 43 +++ .../sparrow/io/KeystoreCodexImport.java | 10 + .../sparrow/keystoreimport/SwController.java | 4 +- 4 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/sparrowwallet/sparrow/control/CodexKeystoreImportPane.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/Bip93.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/KeystoreCodexImport.java diff --git a/src/main/java/com/sparrowwallet/sparrow/control/CodexKeystoreImportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/CodexKeystoreImportPane.java new file mode 100644 index 00000000..cd5d390f --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/CodexKeystoreImportPane.java @@ -0,0 +1,251 @@ +package com.sparrowwallet.sparrow.control; + +import com.sparrowwallet.drongo.KeyDerivation; +import com.sparrowwallet.drongo.crypto.ChildNumber; +import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.drongo.wallet.Keystore; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.event.KeystoreImportEvent; +import com.sparrowwallet.sparrow.glyphfont.GlyphUtils; +import com.sparrowwallet.sparrow.io.ImportException; +import com.sparrowwallet.sparrow.io.KeystoreCodexImport; +import javafx.application.Platform; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.value.ChangeListener; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.ContentDisplay; +import javafx.scene.control.Control; +import javafx.scene.control.Label; +import javafx.scene.control.MenuItem; +import javafx.scene.control.SplitMenuButton; +import javafx.scene.control.TextField; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.AnchorPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import org.controlsfx.validation.ValidationResult; +import org.controlsfx.validation.ValidationSupport; +import org.controlsfx.validation.Validator; +import org.controlsfx.validation.decoration.StyleClassValidationDecoration; + +import java.util.List; + +public class CodexKeystoreImportPane extends TitledDescriptionPane { + protected final Wallet wallet; + private final KeystoreCodexImport importer; + private final KeyDerivation defaultDerivation; + + private SplitMenuButton importButton; + + private Button enterCodexButton; + private Button calculateButton; + + protected Label validLabel; + protected Label invalidLabel; + + protected final SimpleStringProperty secretShareProperty = new SimpleStringProperty(""); + + public CodexKeystoreImportPane(Wallet wallet, KeystoreCodexImport importer, KeyDerivation defaultDerivation) { + super(importer.getName(), "Enter secret share", importer.getKeystoreImportDescription(), importer.getWalletModel()); + this.wallet = wallet; + this.importer = importer; + this.defaultDerivation = defaultDerivation; + + createImportButton(); + buttonBox.getChildren().add(importButton); + } + + @Override + protected Control createButton() { + enterCodexButton = new Button("Enter Secret Share"); + enterCodexButton.managedProperty().bind(enterCodexButton.visibleProperty()); + enterCodexButton.setOnAction(event -> { + enterCodex(); + }); + + return enterCodexButton; + } + + private void enterCodex() { + setDescription("Enter secret share"); + showHideLink.setVisible(false); + setContent(getSecretShareEntry()); + setExpanded(true); + } + + private void importKeystore(List derivation) { + importButton.setDisable(true); + try { + Keystore keystore = importer.getKeystore(derivation, secretShareProperty.get()); + EventManager.get().post(new KeystoreImportEvent(keystore)); + } catch(ImportException e) { + String errorMessage = e.getMessage(); + if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) { + errorMessage = e.getCause().getMessage(); + } + setError("Import Error", errorMessage); + importButton.setDisable(false); + } + } + + private void createImportButton() { + importButton = new SplitMenuButton(); + importButton.setAlignment(Pos.CENTER_RIGHT); + importButton.setText("Import Keystore"); + importButton.getStyleClass().add("default-button"); + importButton.setOnAction(event -> { + importButton.setDisable(true); + importKeystore(getDefaultDerivation()); + }); + String[] accounts = new String[]{"Import Default Account #0", "Import Account #1", "Import Account #2", "Import Account #3", "Import Account #4", "Import Account #5", "Import Account #6", "Import Account #7", "Import Account #8", "Import Account #9"}; + int scriptAccountsLength = ScriptType.P2SH.equals(wallet.getScriptType()) ? 1 : accounts.length; + for(int i = 0; i < scriptAccountsLength; i++) { + MenuItem item = new MenuItem(accounts[i]); + final List derivation = wallet.getScriptType().getDefaultDerivation(i); + item.setOnAction(event -> { + importButton.setDisable(true); + importKeystore(derivation); + }); + importButton.getItems().add(item); + } + + importButton.managedProperty().bind(importButton.visibleProperty()); + importButton.setVisible(false); + } + + private List getDefaultDerivation() { + return defaultDerivation == null || defaultDerivation.getDerivation().isEmpty() ? wallet.getScriptType().getDefaultDerivation() : defaultDerivation.getDerivation(); + } + + private void onInputChange(boolean empty, boolean validChecksum) { + if(!empty) { + try { + importer.getKeystore(ScriptType.P2WPKH.getDefaultDerivation(), secretShareProperty.get()); + validChecksum = true; + } catch(ImportException e) { + invalidLabel.setText("Invalid checksum"); + invalidLabel.setTooltip(null); + } + } + + calculateButton.setDisable(!validChecksum); + validLabel.setVisible(validChecksum); + invalidLabel.setVisible(!validChecksum && !empty); + } + + private Node getSecretShareEntry() { + VBox vBox = new VBox(20); + vBox.setPadding(new Insets(10, 30, 10, 30)); + + HBox shareEntry = new HBox(10); + shareEntry.setAlignment(Pos.CENTER_LEFT); + Label shareLabel = new Label("Secret:"); + TextField shareField = new TextField(); + HBox.setHgrow(shareField, Priority.ALWAYS); + shareField.setPromptText("ms..."); + shareField.textProperty().addListener((observable, oldValue, newValue) -> { + secretShareProperty.set(newValue); + }); + shareEntry.getChildren().addAll(shareLabel, shareField); + vBox.getChildren().add(shareEntry); + + AnchorPane buttonPane = new AnchorPane(); + + validLabel = new Label("Valid checksum", GlyphUtils.getSuccessGlyph()); + validLabel.setContentDisplay(ContentDisplay.LEFT); + validLabel.setGraphicTextGap(5.0); + validLabel.managedProperty().bind(validLabel.visibleProperty()); + validLabel.setVisible(false); + buttonPane.getChildren().add(validLabel); + AnchorPane.setTopAnchor(validLabel, 5.0); + AnchorPane.setLeftAnchor(validLabel, 0.0); + + invalidLabel = new Label("Invalid checksum", GlyphUtils.getInvalidGlyph()); + invalidLabel.setContentDisplay(ContentDisplay.LEFT); + invalidLabel.setGraphicTextGap(5.0); + invalidLabel.managedProperty().bind(invalidLabel.visibleProperty()); + invalidLabel.setVisible(false); + buttonPane.getChildren().add(invalidLabel); + AnchorPane.setTopAnchor(invalidLabel, 5.0); + AnchorPane.setLeftAnchor(invalidLabel, 0.0); + + secretShareProperty.addListener((ChangeListener) (c, oldval, newval) -> { + boolean empty = secretShareProperty.isEmpty().get(); + boolean validChecksum = false; + onInputChange(empty, validChecksum); + }); + + HBox rightBox = new HBox(); + rightBox.setSpacing(10); + + calculateButton = new Button("Create Keystore"); + calculateButton.setDisable(true); + calculateButton.setDefaultButton(true); + calculateButton.managedProperty().bind(calculateButton.visibleProperty()); + calculateButton.setTooltip(new Tooltip("Create the keystore from the provided secret share")); + calculateButton.setOnAction(event -> { + setExpanded(true); + enterCodexButton.setVisible(false); + importButton.setVisible(true); + importButton.setDisable(false); + setDescription("Ready to import"); + showHideLink.setText("Show Derivation..."); + showHideLink.setVisible(false); + setContent(getDerivationEntry(getDefaultDerivation())); + }); + + rightBox.getChildren().add(calculateButton); + + buttonPane.getChildren().add(rightBox); + AnchorPane.setRightAnchor(rightBox, 0.0); + + vBox.getChildren().add(buttonPane); + + Platform.runLater(shareField::requestFocus); + + return vBox; + } + + private Node getDerivationEntry(List derivation) { + TextField derivationField = new TextField(); + derivationField.setPromptText("Derivation path"); + derivationField.setText(KeyDerivation.writePath(derivation)); + HBox.setHgrow(derivationField, Priority.ALWAYS); + + ValidationSupport validationSupport = new ValidationSupport(); + validationSupport.setValidationDecorator(new StyleClassValidationDecoration()); + validationSupport.registerValidator(derivationField, Validator.combine( + Validator.createEmptyValidator("Derivation is required"), + (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Invalid derivation", !KeyDerivation.isValid(newValue)) + )); + + Button importDerivationButton = new Button("Import Custom Derivation Keystore"); + importDerivationButton.setDisable(true); + importDerivationButton.setOnAction(event -> { + showHideLink.setVisible(true); + setExpanded(false); + List importDerivation = KeyDerivation.parsePath(derivationField.getText()); + importKeystore(importDerivation); + }); + + derivationField.textProperty().addListener((observable, oldValue, newValue) -> { + importButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue) || !KeyDerivation.parsePath(newValue).equals(derivation)); + importDerivationButton.setDisable(newValue.isEmpty() || !KeyDerivation.isValid(newValue) || KeyDerivation.parsePath(newValue).equals(derivation)); + }); + + HBox contentBox = new HBox(); + contentBox.setAlignment(Pos.TOP_RIGHT); + contentBox.setSpacing(20); + contentBox.getChildren().add(derivationField); + contentBox.getChildren().add(importDerivationButton); + contentBox.setPadding(new Insets(10, 30, 10, 30)); + contentBox.setPrefHeight(60); + + return contentBox; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Bip93.java b/src/main/java/com/sparrowwallet/sparrow/io/Bip93.java new file mode 100644 index 00000000..1502ae6e --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/Bip93.java @@ -0,0 +1,43 @@ +package com.sparrowwallet.sparrow.io; + +import com.sparrowwallet.drongo.crypto.ChildNumber; +import com.sparrowwallet.drongo.crypto.DeterministicKey; +import com.sparrowwallet.drongo.crypto.HDKeyDerivation; +import com.sparrowwallet.drongo.wallet.Keystore; +import com.sparrowwallet.drongo.wallet.MasterPrivateExtendedKey; +import com.sparrowwallet.drongo.wallet.MnemonicException; +import com.sparrowwallet.drongo.wallet.WalletModel; +import com.sparrowwallet.drongo.wallet.bip93.Codex32; + +import java.util.List; + +public class Bip93 implements KeystoreCodexImport { + @Override + public String getName() { + return "Codex32 (BIP93)"; + } + + @Override + public WalletModel getWalletModel() { + return WalletModel.SEED; + } + + @Override + public String getKeystoreImportDescription() { + return "Import your Codex32 secret share. Only a single share is currently supported."; + } + + @Override + public Keystore getKeystore(List derivation, String secretShare) throws ImportException { + try { + Codex32.Codex32Data secretData = Codex32.decode(secretShare); + DeterministicKey key = HDKeyDerivation.createMasterPrivateKey(secretData.payloadToBip32Secret()); + MasterPrivateExtendedKey mpek = new MasterPrivateExtendedKey(key); + Keystore keystore = Keystore.fromMasterPrivateExtendedKey(mpek, derivation); + keystore.setLabel("BIP93"); + return keystore; + } catch(MnemonicException e) { + throw new ImportException(e); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/KeystoreCodexImport.java b/src/main/java/com/sparrowwallet/sparrow/io/KeystoreCodexImport.java new file mode 100644 index 00000000..a02c2ec4 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/KeystoreCodexImport.java @@ -0,0 +1,10 @@ +package com.sparrowwallet.sparrow.io; + +import com.sparrowwallet.drongo.crypto.ChildNumber; +import com.sparrowwallet.drongo.wallet.Keystore; + +import java.util.List; + +public interface KeystoreCodexImport extends KeystoreImport { + Keystore getKeystore(List derivation, String secretShare) throws ImportException; +} diff --git a/src/main/java/com/sparrowwallet/sparrow/keystoreimport/SwController.java b/src/main/java/com/sparrowwallet/sparrow/keystoreimport/SwController.java index 68b669e8..a9b4fca9 100644 --- a/src/main/java/com/sparrowwallet/sparrow/keystoreimport/SwController.java +++ b/src/main/java/com/sparrowwallet/sparrow/keystoreimport/SwController.java @@ -12,7 +12,7 @@ public class SwController extends KeystoreImportDetailController { private Accordion importAccordion; public void initializeView() { - List importers = List.of(new Bip39(), new Bip32(), new Slip39()); + List importers = List.of(new Bip39(), new Bip32(), new Slip39(), new Bip93()); for(KeystoreImport importer : importers) { if(importer.isDeprecated() && !Config.get().isShowDeprecatedImportExport()) { @@ -29,6 +29,8 @@ public class SwController extends KeystoreImportDetailController { importPane = new XprvKeystoreImportPane(getMasterController().getWallet(), (KeystoreXprvImport)importer, getMasterController().getDefaultDerivation()); } else if(importer instanceof KeystoreMnemonicShareImport) { importPane = new MnemonicShareKeystoreImportPane(getMasterController().getWallet(), (KeystoreMnemonicShareImport)importer, getMasterController().getDefaultDerivation()); + } else if (importer instanceof KeystoreCodexImport) { + importPane = new CodexKeystoreImportPane(getMasterController().getWallet(), (KeystoreCodexImport)importer, getMasterController().getDefaultDerivation()); } else { throw new IllegalArgumentException("Could not create ImportPane for importer of type " + importer.getClass()); }