From ee6589991dd0d2f3dc7bed5aaf73ed8291951353 Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Tue, 31 Oct 2023 09:56:31 +0100 Subject: [PATCH] add initial satochip card support --- drongo | 2 +- .../sparrow/control/CardImportPane.java | 91 +++- .../sparrow/control/CardPinDialog.java | 17 +- .../sparrow/control/DevicePane.java | 77 ++- .../com/sparrowwallet/sparrow/io/CardApi.java | 22 +- .../sparrowwallet/sparrow/io/CardImport.java | 2 +- .../sparrow/io/ckcard/CardTransport.java | 4 +- .../sparrow/io/ckcard/Tapsigner.java | 4 +- .../sparrow/io/satochip/APDUCommand.java | 139 +++++ .../sparrow/io/satochip/APDUResponse.java | 119 +++++ .../sparrow/io/satochip/Constants.java | 214 ++++++++ .../sparrow/io/satochip/KeyPath.java | 101 ++++ .../sparrow/io/satochip/SatoCardApi.java | 388 ++++++++++++++ .../sparrow/io/satochip/SatoCardStatus.java | 119 +++++ .../io/satochip/SatoCardTransport.java | 63 +++ .../sparrow/io/satochip/Satochip.java | 95 ++++ .../io/satochip/SatochipCommandSet.java | 488 ++++++++++++++++++ .../sparrow/io/satochip/SatochipParser.java | 130 +++++ .../io/satochip/SecureChannelSession.java | 199 +++++++ .../keystoreimport/HwAirgappedController.java | 3 +- .../sparrow/wallet/KeystoreController.java | 6 +- .../sparrow/wallet/keystore.fxml | 2 +- .../resources/image/satochip-icon-invert.svg | 8 + src/main/resources/image/satochip-icon.svg | 8 + src/main/resources/image/satochip.png | Bin 0 -> 4536 bytes src/main/resources/image/satochip@2x.png | Bin 0 -> 7739 bytes src/main/resources/image/satochip@3x.png | Bin 0 -> 15554 bytes 27 files changed, 2268 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/satochip/APDUCommand.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/satochip/APDUResponse.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/satochip/Constants.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/satochip/KeyPath.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/satochip/SatoCardApi.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/satochip/SatoCardStatus.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/satochip/SatoCardTransport.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/satochip/Satochip.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/satochip/SatochipCommandSet.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/satochip/SatochipParser.java create mode 100644 src/main/java/com/sparrowwallet/sparrow/io/satochip/SecureChannelSession.java create mode 100644 src/main/resources/image/satochip-icon-invert.svg create mode 100644 src/main/resources/image/satochip-icon.svg create mode 100644 src/main/resources/image/satochip.png create mode 100644 src/main/resources/image/satochip@2x.png create mode 100644 src/main/resources/image/satochip@3x.png diff --git a/drongo b/drongo index 30aff119..12db57c8 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit 30aff119081a4a13f931ea6625f69d7974addb04 +Subproject commit 12db57c8d75c6f9eb96a8da89e80139850cc450b diff --git a/src/main/java/com/sparrowwallet/sparrow/control/CardImportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/CardImportPane.java index 6e7ceb4c..fe466794 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/CardImportPane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/CardImportPane.java @@ -4,8 +4,7 @@ import com.google.common.base.Throwables; import com.sparrowwallet.drongo.KeyDerivation; import com.sparrowwallet.drongo.crypto.ChildNumber; import com.sparrowwallet.drongo.protocol.Sha256Hash; -import com.sparrowwallet.drongo.wallet.Keystore; -import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.drongo.wallet.*; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.event.KeystoreImportEvent; @@ -36,6 +35,7 @@ import org.slf4j.LoggerFactory; import javax.smartcardio.CardException; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.Optional; import static com.sparrowwallet.sparrow.io.CardApi.isReaderAvailable; @@ -74,21 +74,21 @@ public class CardImportPane extends TitledDescriptionPane { return; } - if(pin.get().length() < 6) { - setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short"); - setContent(getPinAndDerivationEntry()); - showHideLink.setVisible(false); - setExpanded(true); - importButton.setDisable(false); - return; - } - StringProperty messageProperty = new SimpleStringProperty(); messageProperty.addListener((observable, oldValue, newValue) -> { Platform.runLater(() -> setDescription(newValue)); }); try { + if(pin.get().length() < importer.getWalletModel().getMinPinLength()) { + setDescription(pin.get().isEmpty() ? (!importer.getWalletModel().hasDefaultPin() && !importer.isInitialized() ? "Choose a PIN code" : "Enter PIN code") : "PIN code too short"); + setContent(getPinAndDerivationEntry()); + showHideLink.setVisible(false); + setExpanded(true); + importButton.setDisable(false); + return; + } + if(!importer.isInitialized()) { setDescription("Card not initialized"); setContent(getInitializationPanel(messageProperty)); @@ -121,6 +121,75 @@ public class CardImportPane extends TitledDescriptionPane { } private Node getInitializationPanel(StringProperty messageProperty) { + if(importer.getWalletModel().requiresSeedInitialization()) { + return getSeedInitializationPanel(messageProperty); + } + + return getEntropyInitializationPanel(messageProperty); + } + + private Node getSeedInitializationPanel(StringProperty messageProperty) { + VBox confirmationBox = new VBox(5); + CustomPasswordField confirmationPin = new ViewPasswordField(); + confirmationPin.setPromptText("Re-enter chosen PIN"); + confirmationBox.getChildren().add(confirmationPin); + + Button initializeButton = new Button("Initialize"); + initializeButton.setDefaultButton(true); + initializeButton.setOnAction(event -> { + initializeButton.setDisable(true); + if(!pin.get().equals(confirmationPin.getText())) { + setError("PIN Error", "The confirmation PIN did not match"); + return; + } + int pinSize = pin.get().length(); + if(pinSize < importer.getWalletModel().getMinPinLength() || pinSize > importer.getWalletModel().getMaxPinLength()) { + setError("PIN Error", "PIN length must be between " + importer.getWalletModel().getMinPinLength() + " and " + importer.getWalletModel().getMaxPinLength() + " characters"); + return; + } + + SeedEntryDialog seedEntryDialog = new SeedEntryDialog(importer.getWalletModel().toDisplayString() + " Seed Words", 12); + seedEntryDialog.initOwner(this.getScene().getWindow()); + Optional> optWords = seedEntryDialog.showAndWait(); + if(optWords.isPresent()) { + try { + List mnemonicWords = optWords.get(); + Bip39MnemonicCode.INSTANCE.check(mnemonicWords); + DeterministicSeed seed = new DeterministicSeed(mnemonicWords, "", System.currentTimeMillis(), DeterministicSeed.Type.BIP39); + byte[] seedBytes = seed.getSeedBytes(); + + CardInitializationService cardInitializationService = new CardInitializationService(importer, pin.get(), seedBytes, messageProperty); + cardInitializationService.setOnSucceeded(successEvent -> { + AppServices.showSuccessDialog("Card Initialized", "The card was successfully initialized.\n\nYou can now import the keystore."); + setDescription("Leave card on reader"); + setExpanded(false); + importButton.setDisable(false); + }); + cardInitializationService.setOnFailed(failEvent -> { + log.error("Error initializing card", failEvent.getSource().getException()); + AppServices.showErrorDialog("Card Initialization Failed", "The card was not initialized.\n\n" + failEvent.getSource().getException().getMessage()); + initializeButton.setDisable(false); + }); + cardInitializationService.start(); + } catch(MnemonicException e) { + log.error("Invalid seed entered", e); + AppServices.showErrorDialog("Invalid seed entered", "The seed was invalid.\n\n" + e.getMessage()); + initializeButton.setDisable(false); + } + } else { + initializeButton.setDisable(false); + } + }); + + HBox contentBox = new HBox(20); + contentBox.getChildren().addAll(confirmationBox, initializeButton); + contentBox.setPadding(new Insets(10, 30, 10, 30)); + HBox.setHgrow(confirmationBox, Priority.ALWAYS); + + return contentBox; + } + + private Node getEntropyInitializationPanel(StringProperty messageProperty) { VBox initTypeBox = new VBox(5); RadioButton automatic = new RadioButton("Automatic (Recommended)"); RadioButton advanced = new RadioButton("Advanced"); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/CardPinDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/CardPinDialog.java index 5f31e0d1..f7cbce39 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/CardPinDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/CardPinDialog.java @@ -1,5 +1,6 @@ package com.sparrowwallet.sparrow.control; +import com.sparrowwallet.drongo.wallet.WalletModel; import com.sparrowwallet.sparrow.AppServices; import javafx.application.Platform; import javafx.beans.binding.Bindings; @@ -22,7 +23,7 @@ public class CardPinDialog extends Dialog { private final CheckBox backupFirst; private final ButtonType okButtonType; - public CardPinDialog(boolean backupOnly) { + public CardPinDialog(WalletModel walletModel, boolean backupOnly) { this.existingPin = new ViewPasswordField(); this.newPin = new ViewPasswordField(); this.newPinConfirm = new ViewPasswordField(); @@ -71,7 +72,11 @@ public class CardPinDialog extends Dialog { if(backupOnly) { fieldset.getChildren().addAll(currentField); } else { - fieldset.getChildren().addAll(currentField, newField, confirmField, backupField); + fieldset.getChildren().addAll(currentField, newField, confirmField); + } + + if(walletModel.supportsBackup()) { + fieldset.getChildren().add(backupField); } form.getChildren().add(fieldset); @@ -80,8 +85,8 @@ public class CardPinDialog extends Dialog { ValidationSupport validationSupport = new ValidationSupport(); Platform.runLater( () -> { validationSupport.setValidationDecorator(new StyleClassValidationDecoration()); - validationSupport.registerValidator(existingPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", existingPin.getText().length() < 6 || existingPin.getText().length() > 32)); - validationSupport.registerValidator(newPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", newPin.getText().length() < 6 || newPin.getText().length() > 32)); + validationSupport.registerValidator(existingPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", existingPin.getText().length() < walletModel.getMinPinLength() || existingPin.getText().length() > walletModel.getMaxPinLength())); + validationSupport.registerValidator(newPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", newPin.getText().length() < walletModel.getMinPinLength() || newPin.getText().length() > walletModel.getMaxPinLength())); validationSupport.registerValidator(newPinConfirm, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "PIN confirmation does not match", !newPinConfirm.getText().equals(newPin.getText()))); }); @@ -89,8 +94,8 @@ public class CardPinDialog extends Dialog { dialogPane.getButtonTypes().addAll(okButtonType); Button okButton = (Button) dialogPane.lookupButton(okButtonType); okButton.setPrefWidth(130); - BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> existingPin.getText().length() < 6 || existingPin.getText().length() > 32 - || newPin.getText().length() < 6 || newPin.getText().length() > 32 + BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> existingPin.getText().length() < walletModel.getMinPinLength() || existingPin.getText().length() > walletModel.getMaxPinLength() + || newPin.getText().length() < walletModel.getMinPinLength() || newPin.getText().length() > walletModel.getMaxPinLength() || !newPin.getText().equals(newPinConfirm.getText()), existingPin.textProperty(), newPin.textProperty(), newPinConfirm.textProperty()); okButton.disableProperty().bind(isInvalid); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java b/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java index 20d9e4f8..e8122641 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java @@ -673,8 +673,8 @@ public class DevicePane extends TitledDescriptionPane { try { CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get()); if(!cardApi.isInitialized()) { - if(pin.get().length() < 6) { - setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short"); + if(pin.get().length() < device.getModel().getMinPinLength()) { + setDescription(pin.get().isEmpty() ? (device.getModel().hasDefaultPin() ? "Enter PIN code" : "Choose a PIN code") : "PIN code too short"); setContent(getCardPinEntry(importButton)); showHideLink.setVisible(false); setExpanded(true); @@ -795,7 +795,7 @@ public class DevicePane extends TitledDescriptionPane { } private void handleCardOperation(Service service, ButtonBase operationButton, String operationDescription, boolean pinRequired, EventHandler successHandler) { - if(pinRequired && pin.get().length() < 6) { + if(pinRequired && pin.get().length() < device.getModel().getMinPinLength()) { setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short"); setContent(getCardPinEntry(operationButton)); showHideLink.setVisible(false); @@ -940,7 +940,7 @@ public class DevicePane extends TitledDescriptionPane { try { CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get()); if(!cardApi.isInitialized()) { - if(pin.get().length() < 6) { + if(pin.get().length() < device.getModel().getMinPinLength()) { setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short"); setContent(getCardPinEntry(getAddressButton)); showHideLink.setVisible(false); @@ -1047,6 +1047,75 @@ public class DevicePane extends TitledDescriptionPane { } private Node getCardInitializationPanel(CardApi cardApi, ButtonBase operationButton, DeviceOperation deviceOperation) { + if(device.getModel().requiresSeedInitialization()) { + return getCardSeedInitializationPanel(cardApi, operationButton, deviceOperation); + } + + return getCardEntropyInitializationPanel(cardApi, operationButton, deviceOperation); + } + + private Node getCardSeedInitializationPanel(CardApi cardApi, ButtonBase operationButton, DeviceOperation deviceOperation) { + VBox confirmationBox = new VBox(5); + CustomPasswordField confirmationPin = new ViewPasswordField(); + confirmationPin.setPromptText("Re-enter chosen PIN"); + confirmationBox.getChildren().add(confirmationPin); + + Button initializeButton = new Button("Initialize"); + initializeButton.setDefaultButton(true); + initializeButton.setOnAction(event -> { + initializeButton.setDisable(true); + if(!pin.get().equals(confirmationPin.getText())) { + setError("PIN Error", "The confirmation PIN did not match"); + return; + } + int pinSize = pin.get().length(); + if(pinSize < device.getModel().getMinPinLength() || pinSize > device.getModel().getMaxPinLength()) { + setError("PIN Error", "PIN length must be between " + device.getModel().getMinPinLength() + " and " + device.getModel().getMaxPinLength() + " characters"); + return; + } + + SeedEntryDialog seedEntryDialog = new SeedEntryDialog(device.getModel().toDisplayString() + " Seed Words", 12); + seedEntryDialog.initOwner(this.getScene().getWindow()); + Optional> optWords = seedEntryDialog.showAndWait(); + if(optWords.isPresent()) { + try { + List mnemonicWords = optWords.get(); + Bip39MnemonicCode.INSTANCE.check(mnemonicWords); + DeterministicSeed seed = new DeterministicSeed(mnemonicWords, "", System.currentTimeMillis(), DeterministicSeed.Type.BIP39); + byte[] seedBytes = seed.getSeedBytes(); + + Service cardInitializationService = cardApi.getInitializationService(seedBytes, messageProperty); + cardInitializationService.setOnSucceeded(successEvent -> { + AppServices.showSuccessDialog("Card Initialized", "The card was successfully initialized.\n\nYou can now import the keystore."); + operationButton.setDisable(false); + setDefaultStatus(); + setExpanded(false); + }); + cardInitializationService.setOnFailed(failEvent -> { + log.error("Error initializing card", failEvent.getSource().getException()); + AppServices.showErrorDialog("Card Initialization Failed", "The card was not initialized.\n\n" + failEvent.getSource().getException().getMessage()); + initializeButton.setDisable(false); + }); + cardInitializationService.start(); + } catch(MnemonicException e) { + log.error("Invalid seed entered", e); + AppServices.showErrorDialog("Invalid seed entered", "The seed was invalid.\n\n" + e.getMessage()); + initializeButton.setDisable(false); + } + } else { + initializeButton.setDisable(false); + } + }); + + HBox contentBox = new HBox(20); + contentBox.getChildren().addAll(confirmationBox, initializeButton); + contentBox.setPadding(new Insets(10, 30, 10, 30)); + HBox.setHgrow(confirmationBox, Priority.ALWAYS); + + return contentBox; + } + + private Node getCardEntropyInitializationPanel(CardApi cardApi, ButtonBase operationButton, DeviceOperation deviceOperation) { VBox initTypeBox = new VBox(5); RadioButton automatic = new RadioButton("Automatic (Recommended)"); RadioButton advanced = new RadioButton("Advanced"); diff --git a/src/main/java/com/sparrowwallet/sparrow/io/CardApi.java b/src/main/java/com/sparrowwallet/sparrow/io/CardApi.java index 4fdf859d..1ea660c6 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/CardApi.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/CardApi.java @@ -10,6 +10,7 @@ import com.sparrowwallet.drongo.wallet.Keystore; import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.drongo.wallet.WalletModel; import com.sparrowwallet.sparrow.io.ckcard.CkCardApi; +import com.sparrowwallet.sparrow.io.satochip.SatoCardApi; import javafx.beans.property.StringProperty; import javafx.concurrent.Service; import org.controlsfx.tools.Platform; @@ -23,6 +24,7 @@ import javax.smartcardio.TerminalFactory; import java.io.File; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -34,19 +36,29 @@ public abstract class CardApi { new File("/usr/local/lib/libpcsclite.so.1"), new File("/lib/x86_64-linux-gnu/libpcsclite.so.1"), new File("/lib/aarch64-linux-gnu/libpcsclite.so.1"), - new File("/usr/lib64/libpcsclite.so.1")}; + new File("/usr/lib64/libpcsclite.so.1"), + new File("/usr/lib/x86_64-linux-gnu/libpcsclite.so.1")}; private static boolean initialized; public static List getConnectedCards() throws CardException { + List cards = new ArrayList<>(); + try { CkCardApi ckCardApi = new CkCardApi(null, null); - return List.of(ckCardApi.getCardType()); + cards.add(ckCardApi.getCardType()); } catch(Exception e) { //ignore } - return Collections.emptyList(); + try { + SatoCardApi satoCardApi = new SatoCardApi(null, null); + cards.add(satoCardApi.getCardType()); + } catch(Exception e) { + //ignore + } + + return cards; } public static CardApi getCardApi(WalletModel walletModel, String pin) throws CardException { @@ -54,6 +66,10 @@ public abstract class CardApi { return new CkCardApi(walletModel, pin); } + if(walletModel == WalletModel.SATOCHIP) { + return new SatoCardApi(walletModel, pin); + } + throw new IllegalArgumentException("Cannot create card API for " + walletModel.toDisplayString()); } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/CardImport.java b/src/main/java/com/sparrowwallet/sparrow/io/CardImport.java index 7c6099c5..3d057763 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/CardImport.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/CardImport.java @@ -6,5 +6,5 @@ import javax.smartcardio.CardException; public interface CardImport extends ImportExport { boolean isInitialized() throws CardException; - void initialize(String pin, byte[] chainCode, StringProperty messageProperty) throws CardException; + void initialize(String pin, byte[] entropy, StringProperty messageProperty) throws CardException; } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardTransport.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardTransport.java index dfc03ec0..5a6e5018 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardTransport.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardTransport.java @@ -47,7 +47,7 @@ public class CardTransport { break; } catch(CardException e) { if(!iter.hasNext()) { - log.error(e.getMessage()); + log.debug(e.getMessage()); throw e; } } @@ -62,7 +62,7 @@ public class CardTransport { CardChannel cardChannel = connection.getBasicChannel(); ResponseAPDU resp = cardChannel.transmit(new CommandAPDU(0, 0xA4, 4, 0, Utils.hexToBytes(APPID.toUpperCase()))); if(resp.getSW() != SW_OKAY) { - throw new CardException("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()) + ". Note that only the Tapsigner is currently supported."); + throw new CardException("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW())); } return connection; diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/Tapsigner.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/Tapsigner.java index 2f270cb8..a526506d 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/Tapsigner.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/Tapsigner.java @@ -26,7 +26,7 @@ public class Tapsigner implements KeystoreCardImport { } @Override - public void initialize(String pin, byte[] chainCode, StringProperty messageProperty) throws CardException { + public void initialize(String pin, byte[] entropy, StringProperty messageProperty) throws CardException { if(pin.length() < 6) { throw new CardException("PIN too short."); } @@ -43,7 +43,7 @@ public class Tapsigner implements KeystoreCardImport { throw new IllegalStateException("Card is already initialized."); } cardApi.checkWait(cardStatus, new SimpleIntegerProperty(), messageProperty); - cardApi.initialize(0, chainCode); + cardApi.initialize(0, entropy); } finally { if(cardApi != null) { cardApi.disconnect(); diff --git a/src/main/java/com/sparrowwallet/sparrow/io/satochip/APDUCommand.java b/src/main/java/com/sparrowwallet/sparrow/io/satochip/APDUCommand.java new file mode 100644 index 00000000..ae9afa57 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/APDUCommand.java @@ -0,0 +1,139 @@ +package com.sparrowwallet.sparrow.io.satochip; + +import com.sparrowwallet.drongo.Utils; + +import java.io.ByteArrayOutputStream; + +/** + * ISO7816-4 APDU. + */ +public class APDUCommand { + protected int cla; + protected int ins; + protected int p1; + protected int p2; + protected byte[] data; + protected boolean needsLE; + + /** + * Constructs an APDU with no response data length field. The data field cannot be null, but can be a zero-length array. + * + * @param cla class byte + * @param ins instruction code + * @param p1 P1 parameter + * @param p2 P2 parameter + * @param data the APDU data + */ + public APDUCommand(int cla, int ins, int p1, int p2, byte[] data) { + this(cla, ins, p1, p2, data, false); + } + + /** + * Constructs an APDU with an optional data length field. The data field cannot be null, but can be a zero-length array. + * The LE byte, if sent, is set to 0. + * + * @param cla class byte + * @param ins instruction code + * @param p1 P1 parameter + * @param p2 P2 parameter + * @param data the APDU data + * @param needsLE whether the LE byte should be sent or not + */ + public APDUCommand(int cla, int ins, int p1, int p2, byte[] data, boolean needsLE) { + this.cla = cla & 0xff; + this.ins = ins & 0xff; + this.p1 = p1 & 0xff; + this.p2 = p2 & 0xff; + this.data = data; + this.needsLE = needsLE; + } + + /** + * Serializes the APDU in order to send it to the card. + * + * @return the byte array representation of the APDU + */ + public byte[] serialize() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(this.cla); + out.write(this.ins); + out.write(this.p1); + out.write(this.p2); + out.write(this.data.length); + out.write(this.data, 0, this.data.length); + + if(this.needsLE) { + out.write(0); // Response length + } + + return out.toByteArray(); + } + + /** + * Serializes the APDU to human readable hex string format + * + * @return the hex string representation of the APDU + */ + public String toHexString() { + byte[] raw = this.serialize(); + if(raw == null) { + return ""; + } + + return Utils.bytesToHex(raw); + } + + /** + * Returns the CLA of the APDU + * + * @return the CLA of the APDU + */ + public int getCla() { + return cla; + } + + /** + * Returns the INS of the APDU + * + * @return the INS of the APDU + */ + public int getIns() { + return ins; + } + + /** + * Returns the P1 of the APDU + * + * @return the P1 of the APDU + */ + public int getP1() { + return p1; + } + + /** + * Returns the P2 of the APDU + * + * @return the P2 of the APDU + */ + public int getP2() { + return p2; + } + + /** + * Returns the data field of the APDU + * + * @return the data field of the APDU + */ + public byte[] getData() { + return data; + } + + /** + * Returns whether LE is sent or not. + * + * @return whether LE is sent or not + */ + public boolean getNeedsLE() { + return this.needsLE; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/satochip/APDUResponse.java b/src/main/java/com/sparrowwallet/sparrow/io/satochip/APDUResponse.java new file mode 100644 index 00000000..54a9a9ab --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/APDUResponse.java @@ -0,0 +1,119 @@ +package com.sparrowwallet.sparrow.io.satochip; + +import com.sparrowwallet.drongo.Utils; + +/** + * ISO7816-4 APDU response. + */ +public class APDUResponse { + public static final int SW_OK = 0x9000; + public static final int SW_SECURITY_CONDITION_NOT_SATISFIED = 0x6982; + public static final int SW_AUTHENTICATION_METHOD_BLOCKED = 0x6983; + public static final int SW_CARD_LOCKED = 0x6283; + public static final int SW_REFERENCED_DATA_NOT_FOUND = 0x6A88; + public static final int SW_CONDITIONS_OF_USE_NOT_SATISFIED = 0x6985; // applet may be already installed + public static final int SW_WRONG_PIN_MASK = 0x63C0; + public static final String HEXES = "0123456789ABCDEF"; + + private final byte[] apdu; + private byte[] data; + private int sw; + private int sw1; + private int sw2; + + /** + * Creates an APDU object by parsing the raw response from the card. + * + * @param apdu the raw response from the card. + */ + public APDUResponse(byte[] apdu) { + if(apdu.length < 2) { + throw new IllegalArgumentException("APDU response must be at least 2 bytes"); + } + this.apdu = apdu; + this.parse(); + } + + public APDUResponse(byte[] data, byte sw1, byte sw2) { + byte[] apdu = new byte[data.length + 2]; + System.arraycopy(data, 0, apdu, 0, data.length); + apdu[data.length] = sw1; + apdu[data.length + 1] = sw2; + this.apdu = apdu; + this.parse(); + } + + + /** + * Parses the APDU response, separating the response data from SW. + */ + private void parse() { + int length = this.apdu.length; + + this.sw1 = this.apdu[length - 2] & 0xff; + this.sw2 = this.apdu[length - 1] & 0xff; + this.sw = (this.sw1 << 8) | this.sw2; + + this.data = new byte[length - 2]; + System.arraycopy(this.apdu, 0, this.data, 0, length - 2); + } + + /** + * Returns the data field of this APDU. + * + * @return the data field of this APDU + */ + public byte[] getData() { + return this.data; + } + + /** + * Returns the Status Word. + * + * @return the status word + */ + public int getSw() { + return this.sw; + } + + /** + * Returns the SW1 byte + * + * @return SW1 + */ + public int getSw1() { + return this.sw1; + } + + /** + * Returns the SW2 byte + * + * @return SW2 + */ + public int getSw2() { + return this.sw2; + } + + /** + * Returns the raw unparsed response. + * + * @return raw APDU data + */ + public byte[] getBytes() { + return this.apdu; + } + + /** + * Serializes the APDU to human readable hex string format + * + * @return the hex string representation of the APDU + */ + public String toHexString() { + byte[] raw = this.apdu; + if(raw == null) { + return ""; + } + + return Utils.bytesToHex(raw); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/satochip/Constants.java b/src/main/java/com/sparrowwallet/sparrow/io/satochip/Constants.java new file mode 100644 index 00000000..f87f04b5 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/Constants.java @@ -0,0 +1,214 @@ +package com.sparrowwallet.sparrow.io.satochip; + +public final class Constants { + + // Prevents instantiation of class + private Constants() { + } + + /**************************************** + * Instruction codes * + ****************************************/ + public static final byte CLA = (byte) 0xB0; + // Applet initialization + public static final byte INS_SETUP = (byte) 0x2A; + // Keys' use and management + public static final byte INS_IMPORT_KEY = (byte) 0x32; + public static final byte INS_RESET_KEY = (byte) 0x33; + public static final byte INS_GET_PUBLIC_FROM_PRIVATE = (byte) 0x35; + // External authentication + public static final byte INS_CREATE_PIN = (byte) 0x40; //TODO: remove? + public static final byte INS_VERIFY_PIN = (byte) 0x42; + public static final byte INS_CHANGE_PIN = (byte) 0x44; + public static final byte INS_UNBLOCK_PIN = (byte) 0x46; + public static final byte INS_LOGOUT_ALL = (byte) 0x60; + // Status information + public static final byte INS_LIST_PINS = (byte) 0x48; + public static final byte INS_GET_STATUS = (byte) 0x3C; + public static final byte INS_CARD_LABEL = (byte) 0x3D; + // HD wallet + public static final byte INS_BIP32_IMPORT_SEED = (byte) 0x6C; + public static final byte INS_BIP32_RESET_SEED = (byte) 0x77; + public static final byte INS_BIP32_GET_AUTHENTIKEY = (byte) 0x73; + public static final byte INS_BIP32_SET_AUTHENTIKEY_PUBKEY = (byte) 0x75; + public static final byte INS_BIP32_GET_EXTENDED_KEY = (byte) 0x6D; + public static final byte INS_BIP32_SET_EXTENDED_PUBKEY = (byte) 0x74; + public static final byte INS_SIGN_MESSAGE = (byte) 0x6E; + public static final byte INS_SIGN_SHORT_MESSAGE = (byte) 0x72; + public static final byte INS_SIGN_TRANSACTION = (byte) 0x6F; + public static final byte INS_PARSE_TRANSACTION = (byte) 0x71; + public static final byte INS_CRYPT_TRANSACTION_2FA = (byte) 0x76; + public static final byte INS_SET_2FA_KEY = (byte) 0x79; + public static final byte INS_RESET_2FA_KEY = (byte) 0x78; + public static final byte INS_SIGN_TRANSACTION_HASH = (byte) 0x7A; + // secure channel + public static final byte INS_INIT_SECURE_CHANNEL = (byte) 0x81; + public static final byte INS_PROCESS_SECURE_CHANNEL = (byte) 0x82; + // secure import from SeedKeeper + public static final byte INS_IMPORT_ENCRYPTED_SECRET = (byte) 0xAC; + public static final byte INS_IMPORT_TRUSTED_PUBKEY = (byte) 0xAA; + public static final byte INS_EXPORT_TRUSTED_PUBKEY = (byte) 0xAB; + public static final byte INS_EXPORT_AUTHENTIKEY = (byte) 0xAD; + // Personalization PKI support + public static final byte INS_IMPORT_PKI_CERTIFICATE = (byte) 0x92; + public static final byte INS_EXPORT_PKI_CERTIFICATE = (byte) 0x93; + public static final byte INS_SIGN_PKI_CSR = (byte) 0x94; + public static final byte INS_EXPORT_PKI_PUBKEY = (byte) 0x98; + public static final byte INS_LOCK_PKI = (byte) 0x99; + public static final byte INS_CHALLENGE_RESPONSE_PKI = (byte) 0x9A; + // reset to factory settings + public static final byte INS_RESET_TO_FACTORY = (byte) 0xFF; + + /**************************************** + * Error codes * + ****************************************/ + + /** + * Entered PIN is not correct + */ + public static final short SW_PIN_FAILED = (short) 0x63C0;// includes number of tries remaining + ///** DEPRECATED - Entered PIN is not correct */ + //public static final short SW_AUTH_FAILED = (short) 0x9C02; + /** + * Required operation is not allowed in actual circumstances + */ + public static final short SW_OPERATION_NOT_ALLOWED = (short) 0x9C03; + /** + * Required setup is not not done + */ + public static final short SW_SETUP_NOT_DONE = (short) 0x9C04; + /** + * Required setup is already done + */ + public static final short SW_SETUP_ALREADY_DONE = (short) 0x9C07; + /** + * Required feature is not (yet) supported + */ + public static final short SW_UNSUPPORTED_FEATURE = (short) 0x9C05; + /** + * Required operation was not authorized because of a lack of privileges + */ + public static final short SW_UNAUTHORIZED = (short) 0x9C06; + /** + * Algorithm specified is not correct + */ + public static final short SW_INCORRECT_ALG = (short) 0x9C09; + + /** + * There have been memory problems on the card + */ + public static final short SW_NO_MEMORY_LEFT = (short) 0x9C01; + ///** DEPRECATED - Required object is missing */ + //public static final short SW_OBJECT_NOT_FOUND= (short) 0x9C07; + + /** + * Incorrect P1 parameter + */ + public static final short SW_INCORRECT_P1 = (short) 0x9C10; + /** + * Incorrect P2 parameter + */ + public static final short SW_INCORRECT_P2 = (short) 0x9C11; + /** + * Invalid input parameter to command + */ + public static final short SW_INVALID_PARAMETER = (short) 0x9C0F; + + /** + * Eckeys initialized + */ + public static final short SW_ECKEYS_INITIALIZED_KEY = (short) 0x9C1A; + + /** + * Verify operation detected an invalid signature + */ + public static final short SW_SIGNATURE_INVALID = (short) 0x9C0B; + /** + * Operation has been blocked for security reason + */ + public static final short SW_IDENTITY_BLOCKED = (short) 0x9C0C; + /** + * For debugging purposes + */ + public static final short SW_INTERNAL_ERROR = (short) 0x9CFF; + /** + * Very low probability error + */ + public static final short SW_BIP32_DERIVATION_ERROR = (short) 0x9C0E; + /** + * Incorrect initialization of method + */ + public static final short SW_INCORRECT_INITIALIZATION = (short) 0x9C13; + /** + * Bip32 seed is not initialized + */ + public static final short SW_BIP32_UNINITIALIZED_SEED = (short) 0x9C14; + /** + * Bip32 seed is already initialized (must be reset before change) + */ + public static final short SW_BIP32_INITIALIZED_SEED = (short) 0x9C17; + //** DEPRECATED - Bip32 authentikey pubkey is not initialized*/ + //public static final short SW_BIP32_UNINITIALIZED_AUTHENTIKEY_PUBKEY= (short) 0x9C16; + /** + * Incorrect transaction hash + */ + public static final short SW_INCORRECT_TXHASH = (short) 0x9C15; + + /** + * 2FA already initialized + */ + public static final short SW_2FA_INITIALIZED_KEY = (short) 0x9C18; + /** + * 2FA uninitialized + */ + public static final short SW_2FA_UNINITIALIZED_KEY = (short) 0x9C19; + + /** + * HMAC errors + */ + public static final short SW_HMAC_UNSUPPORTED_KEYSIZE = (short) 0x9c1E; + public static final short SW_HMAC_UNSUPPORTED_MSGSIZE = (short) 0x9c1F; + + /** + * Secure channel + */ + public static final short SW_SECURE_CHANNEL_REQUIRED = (short) 0x9C20; + public static final short SW_SECURE_CHANNEL_UNINITIALIZED = (short) 0x9C21; + public static final short SW_SECURE_CHANNEL_WRONG_IV = (short) 0x9C22; + public static final short SW_SECURE_CHANNEL_WRONG_MAC = (short) 0x9C23; + + /** + * Secret data is too long for import + **/ + public static final short SW_IMPORTED_DATA_TOO_LONG = (short) 0x9C32; + /** + * Wrong HMAC when importing Secret through Secure import + **/ + public static final short SW_SECURE_IMPORT_WRONG_MAC = (short) 0x9C33; + /** + * Wrong Fingerprint when importing Secret through Secure import + **/ + public static final short SW_SECURE_IMPORT_WRONG_FINGERPRINT = (short) 0x9C34; + /** + * No Trusted Pubkey when importing Secret through Secure import + **/ + public static final short SW_SECURE_IMPORT_NO_TRUSTEDPUBKEY = (short) 0x9C35; + + /** + * PKI perso error + */ + public static final short SW_PKI_ALREADY_LOCKED = (short) 0x9C40; + /** + * CARD HAS BEEN RESET TO FACTORY + */ + public static final short SW_RESET_TO_FACTORY = (short) 0xFF00; + /** + * For instructions that have been deprecated + */ + public static final short SW_INS_DEPRECATED = (short) 0x9C26; + /** + * For debugging purposes 2 + */ + public static final short SW_DEBUG_FLAG = (short) 0x9FFF; + +} \ No newline at end of file diff --git a/src/main/java/com/sparrowwallet/sparrow/io/satochip/KeyPath.java b/src/main/java/com/sparrowwallet/sparrow/io/satochip/KeyPath.java new file mode 100644 index 00000000..aec935dc --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/KeyPath.java @@ -0,0 +1,101 @@ +package com.sparrowwallet.sparrow.io.satochip; + +import java.util.StringTokenizer; + +/** + * Keypath object to be used with the SatochipCommandSet + */ +public class KeyPath { + private final byte[] data; + + /** + * Parses a keypath into a byte array to be used with the SatochipCommandSet object. + *

+ * A valid string is composed of a minimum of one and a maximum of 11 components separated by "/". + *

+ * The first component should be "m", indicating the master key. + *

+ * All other components are positive integers fitting in 31 bit, eventually suffixed by an apostrophe (') sign, + * which indicates an hardened key. + *

+ * An example of a valid path is "m/44'/0'/0'/0/0" + * + * @param keypath the keypath as a string + */ + public KeyPath(String keypath) { + StringTokenizer tokenizer = new StringTokenizer(keypath, "/"); + + String sourceOrFirstElement = tokenizer.nextToken(); // m + + int componentCount = tokenizer.countTokens(); + if(componentCount > 10) { + throw new IllegalArgumentException("Too many components"); + } + + data = new byte[4 * componentCount]; + + for(int i = 0; i < componentCount; i++) { + long component = parseComponent(tokenizer.nextToken()); + writeComponent(component, i); + } + } + + public KeyPath(byte[] data) { + this.data = data; + } + + private long parseComponent(String num) { + long sign; + + if(num.endsWith("'")) { + sign = 0x80000000L; + num = num.substring(0, (num.length() - 1)); + } else { + sign = 0L; + } + + if(num.startsWith("+") || num.startsWith("-")) { + throw new NumberFormatException("No sign allowed"); + } + return (sign | Long.parseLong(num)); + } + + private void writeComponent(long component, int i) { + int off = (i * 4); + data[off] = (byte) ((component >> 24) & 0xff); + data[off + 1] = (byte) ((component >> 16) & 0xff); + data[off + 2] = (byte) ((component >> 8) & 0xff); + data[off + 3] = (byte) (component & 0xff); + } + + /** + * The byte encoded key path. + * + * @return byte encoded key path + */ + public byte[] getData() { + return data; + } + + @Override + public String toString() { + StringBuffer sb = new StringBuffer(); + sb.append('m'); + + for(int i = 0; i < this.data.length; i += 4) { + sb.append('/'); + appendComponent(sb, i); + } + + return sb.toString(); + } + + private void appendComponent(StringBuffer sb, int i) { + int num = ((this.data[i] & 0x7f) << 24) | ((this.data[i + 1] & 0xff) << 16) | ((this.data[i + 2] & 0xff) << 8) | (this.data[i + 3] & 0xff); + sb.append(num); + + if((this.data[i] & 0x80) == 0x80) { + sb.append('\''); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/satochip/SatoCardApi.java b/src/main/java/com/sparrowwallet/sparrow/io/satochip/SatoCardApi.java new file mode 100644 index 00000000..b7b4cbb9 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/SatoCardApi.java @@ -0,0 +1,388 @@ +package com.sparrowwallet.sparrow.io.satochip; + +import com.sparrowwallet.drongo.ExtendedKey; +import com.sparrowwallet.drongo.KeyDerivation; +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.Network; +import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.crypto.ChildNumber; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.crypto.ECDSASignature; +import com.sparrowwallet.drongo.crypto.SchnorrSignature; +import com.sparrowwallet.drongo.protocol.*; +import com.sparrowwallet.drongo.psbt.PSBT; +import com.sparrowwallet.drongo.psbt.PSBTInput; +import com.sparrowwallet.drongo.psbt.PSBTInputSigner; +import com.sparrowwallet.drongo.wallet.*; +import com.sparrowwallet.sparrow.control.CardImportPane; +import com.sparrowwallet.sparrow.io.CardApi; +import javafx.beans.property.StringProperty; +import javafx.concurrent.Service; +import javafx.concurrent.Task; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.smartcardio.*; +import java.util.*; +import java.nio.charset.StandardCharsets; + +public class SatoCardApi extends CardApi { + private static final Logger log = LoggerFactory.getLogger(SatoCardApi.class); + + private final WalletModel cardType; + private final SatochipCommandSet cardProtocol; + private final String pin; + private String basePath = null; + + public SatoCardApi(WalletModel cardType, String pin) throws CardException { + this.cardType = cardType; + this.cardProtocol = new SatochipCommandSet(); + this.pin = pin; + } + + @Override + public boolean isInitialized() throws CardException { + SatoCardStatus cardStatus = this.getStatus(); + return cardStatus.isInitialized(); // setupDone && isSeeded + } + + //TODO + @Override + public void initialize(int slot, byte[] seedBytes) throws CardException { + // TODO check device certificate + SatoCardStatus cardStatus = this.getStatus(); + + APDUResponse rapdu; + if(!cardStatus.isSetupDone()) { + byte maxPinTries = 5; + rapdu = this.cardProtocol.cardSetup(maxPinTries, pin.getBytes(StandardCharsets.UTF_8)); + // check ok + } + + if(!cardStatus.isSeeded()) { + // check pin + rapdu = this.cardProtocol.cardVerifyPIN(0, pin); + // todo: check PIN response + + rapdu = this.cardProtocol.cardBip32ImportSeed(seedBytes); + // check ok + } + } + + @Override + public WalletModel getCardType() throws CardException { + return WalletModel.SATOCHIP; + } + + //TODO + @Override + public int getCurrentSlot() throws CardException { + throw new CardException("Satochip does not support slots"); + } + + //TODO + @Override + public ScriptType getDefaultScriptType() { + return ScriptType.P2WPKH; + } + + SatoCardStatus getStatus() throws CardException { + return this.cardProtocol.getApplicationStatus(); + } + + @Override + public Service getAuthDelayService() throws CardException { + return null; + } + + @Override + public boolean requiresBackup() throws CardException { + return false; + } + + @Override + public Service getBackupService() { + return null; + } + + @Override + public boolean changePin(String newPin) throws CardException { + this.cardProtocol.cardChangePIN((byte) 0, this.pin, newPin); + return true; + } + + void setDerivation(List derivation) throws CardException { + this.basePath = KeyDerivation.writePath(derivation); + } + + @Override + public Service getInitializationService(byte[] seedBytes, StringProperty messageProperty) { + return new CardInitializationService(seedBytes, messageProperty); + + } + + @Override + public Service getImportService(List derivation, StringProperty messageProperty) { + return new CardImportPane.CardImportService(new Satochip(), pin, derivation, messageProperty); + } + + /* + * Satochip derives BIP32 keys based on the fullPath (from masterseed to leaf), not the partial path from a given xpub. + * the basePath (from masterseed to xpub) is only provided in Satochip.java:getKeystore(String pin, List derivation, StringProperty messageProperty) + * In SatoCardApi:getKeystore(), no derivation path (i.e. basePath from masterSeed to xpub or relative path) is given and no derivation is reliably available as a object field. + * currently, we try to get the path from this.basePath if available (or use a default value) but it's not reliable enough + */ + @Override + public Keystore getKeystore() throws CardException { + this.cardProtocol.cardVerifyPIN(0, pin); + String keyDerivationString = (this.basePath != null ? this.basePath : getDefaultScriptType().getDefaultDerivationPath()); + ExtendedKey.Header xtype = Network.get().getXpubHeader(); + String xpub = this.cardProtocol.cardBip32GetXpub(keyDerivationString, xtype); + ExtendedKey extendedKey = ExtendedKey.fromDescriptor(xpub); + String masterFingerprint = Utils.bytesToHex(extendedKey.getKey().getFingerprint()); + KeyDerivation keyDerivation = new KeyDerivation(masterFingerprint, keyDerivationString); + + Keystore keystore = new Keystore(); + keystore.setLabel(WalletModel.SATOCHIP.toDisplayString()); + keystore.setKeyDerivation(keyDerivation); + keystore.setSource(KeystoreSource.HW_USB); + keystore.setExtendedPublicKey(extendedKey); + keystore.setWalletModel(WalletModel.SATOCHIP); + + return keystore; + } + + @Override + public Service getSignService(Wallet wallet, PSBT psbt, StringProperty messageProperty) { + return new SignService(wallet, psbt, messageProperty); + } + + void sign(Wallet wallet, PSBT psbt) throws CardException { + Map signingNodes = wallet.getSigningNodes(psbt); + for(PSBTInput psbtInput : psbt.getPsbtInputs()) { + if(!psbtInput.isSigned()) { + WalletNode signingNode = signingNodes.get(psbtInput); + String fullPath = null; + List keystores = wallet.getKeystores(); + for(int i = 0; i < keystores.size(); i++) { + Keystore keystore = keystores.get(i); + WalletModel walletModel = keystore.getWalletModel(); + if(walletModel == WalletModel.SATOCHIP) { + String basePath = keystore.getKeyDerivation().getDerivationPath(); + String extendedPath = signingNode.getDerivationPath().substring(1); + fullPath = basePath + extendedPath; + keystore.getPubKey(signingNode); + break; + } + } + + psbtInput.sign(new CardPSBTInputSigner(signingNode, fullPath)); + } + } + } + + @Override + public Service getSignMessageService(String message, ScriptType scriptType, List derivation, StringProperty messageProperty) { + return new SignMessageService(message, scriptType, derivation, messageProperty); + } + + String signMessage(String message, ScriptType scriptType, List derivation) throws CardException { + String fullpath = KeyDerivation.writePath(derivation); + cardProtocol.cardVerifyPIN(0, pin); + + // 2FA is optionnal, currently not supported in sparrow as it requires to send 2FA to a mobile app through a server. + SatoCardStatus cardStatus = this.getStatus(); + if(cardStatus.needs2FA()) { + throw new CardException("Satochip 2FA is not (yet) supported within Sparrow"); + } + + // derive the correct key in satochip + APDUResponse rapdu = cardProtocol.cardBip32GetExtendedKey(fullpath); + // recover pubkey + SatochipParser parser = new SatochipParser(); + byte[][] extendeKeyBytes = parser.parseBip32GetExtendedKey(rapdu); + ECKey pubkey = ECKey.fromPublicOnly(extendeKeyBytes[0]); + + // sign msg + return pubkey.signMessage(message, scriptType, hash -> { + try { + // do the signature with satochip + byte keynbr = (byte) 0xFF; + byte[] chalresponse = null; + APDUResponse rapdu2 = cardProtocol.cardSignTransactionHash(keynbr, hash.getBytes(), chalresponse); + byte[] sigBytes = rapdu2.getData(); + return ECDSASignature.decodeFromDER(sigBytes); + } catch(Exception e) { + throw new RuntimeException(e); + } + }); + } + + @Override + public Service getPrivateKeyService(Integer slot, StringProperty messageProperty) { + throw new UnsupportedOperationException("Satochip does not support private key export"); + } + + @Override + public Service

getAddressService(StringProperty messageProperty) { + return null; + } + + @Override + public void disconnect() { + cardProtocol.cardDisconnect(); + } + + public class CardInitializationService extends Service { + private final byte[] seedBytes; + private final StringProperty messageProperty; + + public CardInitializationService(byte[] seedBytes, StringProperty messageProperty) { + this.seedBytes = seedBytes; + this.messageProperty = messageProperty; + } + + @Override + protected Task createTask() { + return new Task<>() { + @Override + protected Void call() throws Exception { + if(seedBytes == null) { + throw new CardException("Failed to initialize Satochip - no seed provided"); + } + + initialize(0, seedBytes); + return null; + } + }; + } + } + + public class SignService extends Service { + private final Wallet wallet; + private final PSBT psbt; + private final StringProperty messageProperty; + + public SignService(Wallet wallet, PSBT psbt, StringProperty messageProperty) { + this.wallet = wallet; + this.psbt = psbt; + this.messageProperty = messageProperty; + } + + @Override + protected Task createTask() { + return new Task<>() { + @Override + protected PSBT call() throws Exception { + sign(wallet, psbt); + return psbt; + } + }; + } + } + + private class CardPSBTInputSigner implements PSBTInputSigner { + private final WalletNode signingNode; + private final String fullPath; + private ECKey pubkey; + + // todo: provide derivationpath instead of WalletNode?? + public CardPSBTInputSigner(WalletNode signingNode, String fullPath) { + this.signingNode = signingNode; + this.fullPath = fullPath; + } + + @Override + public TransactionSignature sign(Sha256Hash hash, SigHash sigHash, TransactionSignature.Type signatureType) { + try { + // 2FA is optional, currently not supported in sparrow as it requires to send 2FA to a mobile app through a server. + SatoCardStatus cardStatus = getStatus(); + if(cardStatus.needs2FA()) { + throw new CardException("Satochip 2FA is not (yet) supported within Sparrow"); + } + + // verify PIN + APDUResponse rapdu0 = cardProtocol.cardVerifyPIN(0, pin); + + // derive the correct key in satochip and recover pubkey + APDUResponse rapdu = cardProtocol.cardBip32GetExtendedKey(fullPath); + SatochipParser parser = new SatochipParser(); + byte[][] extendeKeyBytes = parser.parseBip32GetExtendedKey(rapdu); + ECKey internalPubkey = ECKey.fromPublicOnly(extendeKeyBytes[0]); + + if(signatureType == TransactionSignature.Type.ECDSA) { + // for ECDSA, pubkey is the same as internalPubkey + pubkey = internalPubkey; + // do the signature with satochip + byte keynbr = (byte) 0xFF; + byte[] chalresponse = null; + APDUResponse rapdu2 = cardProtocol.cardSignTransactionHash(keynbr, hash.getBytes(), chalresponse); + byte[] sigBytes = rapdu2.getData(); + ECDSASignature ecdsaSig = ECDSASignature.decodeFromDER(sigBytes).toCanonicalised(); + TransactionSignature txSig = new TransactionSignature(ecdsaSig, sigHash); + + // verify + boolean isCorrect = pubkey.verify(hash, txSig); + return txSig; + } else { + // Satochip supports schnorr signature only for version >= 0.14 ! + byte[] versionBytes = cardStatus.getCardVersion(); + int protocolVersion = versionBytes[0] * 256 + versionBytes[1]; + if(protocolVersion < (256 * 0 + 14)) { + throw new CardException(WalletModel.SATOCHIP.toDisplayString() + " (with version below v0.14) cannot sign Taproot transactions"); + } + + // tweak the bip32 key according to bip341 + byte keynbr = (byte) 0xFF; + byte[] tweak = null; + APDUResponse rapduTweak = cardProtocol.cardTaprootTweakPrivkey(keynbr, tweak); + byte[] tweakedPubkeyBytes = new byte[65]; + System.arraycopy(rapduTweak.getData(), 2, tweakedPubkeyBytes, 0, 65); + pubkey = ECKey.fromPublicOnly(tweakedPubkeyBytes); + + byte[] chalresponse = null; + APDUResponse rapdu2 = cardProtocol.cardSignSchnorrHash(keynbr, hash.getBytes(), chalresponse); + byte[] sigBytes = rapdu2.getData(); + SchnorrSignature schnorrSig = SchnorrSignature.decode(sigBytes); + TransactionSignature txSig = new TransactionSignature(schnorrSig, sigHash); + + // verify sig with outputPubkey... + boolean isCorrect2 = pubkey.verify(hash, txSig); + + return txSig; //new TransactionSignature(schnorrSig, sigHash); + } + } catch(Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public ECKey getPubKey() { + return pubkey; + } + } + + public class SignMessageService extends Service { + private final String message; + private final ScriptType scriptType; + private final List derivation; + private final StringProperty messageProperty; + + public SignMessageService(String message, ScriptType scriptType, List derivation, StringProperty messageProperty) { + this.message = message; + this.scriptType = scriptType; + this.derivation = derivation; + this.messageProperty = messageProperty; + } + + @Override + protected Task createTask() { + return new Task<>() { + @Override + protected String call() throws Exception { + return signMessage(message, scriptType, derivation); + } + }; + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/satochip/SatoCardStatus.java b/src/main/java/com/sparrowwallet/sparrow/io/satochip/SatoCardStatus.java new file mode 100644 index 00000000..2122724c --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/SatoCardStatus.java @@ -0,0 +1,119 @@ +package com.sparrowwallet.sparrow.io.satochip; + +/** + * Parses the result of a GET STATUS command retrieving application status. + */ +public class SatoCardStatus { + private boolean setup_done = false; + private boolean is_seeded = false; + private boolean needs_secure_channel = false; + private boolean needs_2FA = false; + + private byte protocol_major_version = (byte) 0; + private byte protocol_minor_version = (byte) 0; + private byte applet_major_version = (byte) 0; + private byte applet_minor_version = (byte) 0; + + private byte PIN0_remaining_tries = (byte) 0; + private byte PUK0_remaining_tries = (byte) 0; + private byte PIN1_remaining_tries = (byte) 0; + private byte PUK1_remaining_tries = (byte) 0; + + private int protocol_version = 0; //(d["protocol_major_version"]<<8)+d["protocol_minor_version"] + + /** + * Constructor from TLV data + * + * @param rapdu the TLV data + * @throws IllegalArgumentException if the TLV does not follow the expected format + */ + public SatoCardStatus(APDUResponse rapdu) { + int sw = rapdu.getSw(); + + if(sw == 0x9000) { + byte[] data = rapdu.getData(); + protocol_major_version = data[0]; + protocol_minor_version = data[1]; + applet_major_version = data[2]; + applet_minor_version = data[3]; + protocol_version = (protocol_major_version << 8) + protocol_minor_version; + + if(data.length >= 8) { + PIN0_remaining_tries = data[4]; + PUK0_remaining_tries = data[5]; + PIN1_remaining_tries = data[6]; + PUK1_remaining_tries = data[7]; + needs_2FA = false; //default value + } + if(data.length >= 9) { + needs_2FA = data[8] != 0X00; + } + if(data.length >= 10) { + is_seeded = data[9] != 0X00; + } + if(data.length >= 11) { + setup_done = data[10] != 0X00; + } else { + setup_done = true; + } + if(data.length >= 12) { + needs_secure_channel = data[11] != 0X00; + } else { + needs_secure_channel = false; + needs_2FA = false; //default value + } + } else if(sw == 0x9c04) { + setup_done = false; + is_seeded = false; + needs_secure_channel = false; + } else { + throw new IllegalArgumentException("Invalid getStatus data"); + } + } + + // getters + public boolean isSeeded() { + return is_seeded; + } + + public boolean isSetupDone() { + return setup_done; + } + + public boolean isInitialized() { + return (setup_done && is_seeded); + } + + public boolean needsSecureChannel() { + return needs_secure_channel; + } + + public boolean needs2FA() { + return needs_2FA; + } + + public byte getPin0RemainingCounter() { + return PIN0_remaining_tries; + } + + public byte[] getCardVersion() { + byte[] versionBytes = new byte[4]; + versionBytes[0] = protocol_major_version; + versionBytes[1] = protocol_minor_version; + versionBytes[2] = applet_major_version; + versionBytes[3] = applet_minor_version; + return versionBytes; + } + + @Override + public String toString() { + return "setup_done: " + setup_done + "\n" + + "is_seeded: " + is_seeded + "\n" + + "needs_2FA: " + needs_2FA + "\n" + + "needs_secure_channel: " + needs_secure_channel + "\n" + + "protocol_major_version: " + protocol_major_version + "\n" + + "protocol_minor_version: " + protocol_minor_version + "\n" + + "applet_major_version: " + applet_major_version + "\n" + + "applet_minor_version: " + applet_minor_version; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/satochip/SatoCardTransport.java b/src/main/java/com/sparrowwallet/sparrow/io/satochip/SatoCardTransport.java new file mode 100644 index 00000000..0949426b --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/SatoCardTransport.java @@ -0,0 +1,63 @@ +package com.sparrowwallet.sparrow.io.satochip; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.smartcardio.*; +import javax.smartcardio.CardChannel; +import java.util.List; +import java.util.*; + +public class SatoCardTransport { + private static final Logger log = LoggerFactory.getLogger(SatoCardTransport.class); + + private final Card connection; + + SatoCardTransport(byte[] appletAid) throws CardException { + TerminalFactory tf = TerminalFactory.getDefault(); + List terminals = tf.terminals().list(); + if(terminals.isEmpty()) { + throw new IllegalStateException("No reader connected"); + } + + Card connection = null; + for(Iterator iter = terminals.iterator(); iter.hasNext(); ) { + try { + connection = getConnection(iter.next(), appletAid); + break; + } catch(CardException e) { + if(!iter.hasNext()) { + log.info(e.getMessage()); + throw e; + } + } + } + + this.connection = connection; + } + + private Card getConnection(CardTerminal cardTerminal, byte[] appletAid) throws CardException { + Card connection = cardTerminal.connect("*"); + + CardChannel cardChannel = connection.getBasicChannel(); + ResponseAPDU resp = cardChannel.transmit(new CommandAPDU(0, 0xA4, 4, 0, appletAid)); + if(resp.getSW() != APDUResponse.SW_OK) { + throw new CardException("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW())); + } + + return connection; + } + + APDUResponse send(APDUCommand capdu) throws CardException { + javax.smartcardio.CardChannel cardChannel = this.connection.getBasicChannel(); + + CommandAPDU cmd = new CommandAPDU(capdu.getCla(), capdu.getIns(), capdu.getP1(), capdu.getP2(), capdu.getData()); + ResponseAPDU resp = cardChannel.transmit(cmd); + + return new APDUResponse(resp.getBytes()); + } + + void disconnect() throws CardException { + connection.disconnect(true); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/satochip/Satochip.java b/src/main/java/com/sparrowwallet/sparrow/io/satochip/Satochip.java new file mode 100644 index 00000000..8c16150e --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/Satochip.java @@ -0,0 +1,95 @@ +package com.sparrowwallet.sparrow.io.satochip; + +import com.sparrowwallet.drongo.crypto.ChildNumber; +import com.sparrowwallet.drongo.wallet.Keystore; +import com.sparrowwallet.drongo.wallet.WalletModel; +import com.sparrowwallet.sparrow.io.KeystoreCardImport; +import com.sparrowwallet.sparrow.io.ImportException; +import javafx.beans.property.StringProperty; + +import javax.smartcardio.CardException; +import java.util.List; + +public class Satochip implements KeystoreCardImport { + @Override + public boolean isInitialized() throws CardException { + SatoCardApi cardApi = null; + try { + cardApi = new SatoCardApi(WalletModel.SATOCHIP, null); + return cardApi.isInitialized(); + } finally { + if(cardApi != null) { + cardApi.disconnect(); + } + } + } + + @Override + public void initialize(String pin, byte[] entropy, StringProperty messageProperty) throws CardException { + if(pin.length() < 4) { + throw new CardException("PIN too short."); + } + + if(pin.length() > 16) { + throw new CardException("PIN too long."); + } + + SatoCardApi cardApi = null; + try { + cardApi = new SatoCardApi(WalletModel.SATOCHIP, pin); + SatoCardStatus cardStatus = cardApi.getStatus(); + if(cardStatus.isInitialized()) { + throw new IllegalStateException("Card is already initialized."); + } + + cardApi.initialize(0, entropy); + } finally { + if(cardApi != null) { + cardApi.disconnect(); + } + } + } + + @Override + public Keystore getKeystore(String pin, List derivation, StringProperty messageProperty) throws ImportException { + if(pin.length() < 4) { + throw new ImportException("PIN too short."); + } + + if(pin.length() > 16) { + throw new ImportException("PIN too long."); + } + + SatoCardApi cardApi = null; + try { + cardApi = new SatoCardApi(WalletModel.SATOCHIP, pin); + SatoCardStatus cardStatus = cardApi.getStatus(); + if(!cardStatus.isInitialized()) { + throw new IllegalStateException("Card is not initialized."); + } + cardApi.setDerivation(derivation); + return cardApi.getKeystore(); + } catch(Exception e) { + throw new ImportException(e); + } finally { + if(cardApi != null) { + cardApi.disconnect(); + } + } + } + + @Override + public String getKeystoreImportDescription(int account) { + return "Import the keystore from your Satochip by inserting or placing it on the card reader."; + } + + @Override + public String getName() { + return "Satochip"; + } + + @Override + public WalletModel getWalletModel() { + return WalletModel.SATOCHIP; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/satochip/SatochipCommandSet.java b/src/main/java/com/sparrowwallet/sparrow/io/satochip/SatochipCommandSet.java new file mode 100644 index 00000000..32ba3464 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/SatochipCommandSet.java @@ -0,0 +1,488 @@ +package com.sparrowwallet.sparrow.io.satochip; + +import com.sparrowwallet.drongo.ExtendedKey; +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.protocol.Base58; + +import com.sparrowwallet.sparrow.io.CardAuthorizationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.util.*; +import java.nio.charset.StandardCharsets; + +import java.security.SecureRandom; + +import static com.sparrowwallet.sparrow.io.satochip.Constants.*; + +import javax.smartcardio.*; + +/** + * This class is used to send APDU to the applet. Each method corresponds to an APDU as defined in the APPLICATION.md + * file. Some APDUs map to multiple methods for the sake of convenience since their payload or response require some + * pre/post processing. + */ +public class SatochipCommandSet { + + private static final Logger log = LoggerFactory.getLogger(SatochipCommandSet.class); + + private final SatoCardTransport cardTransport; + private final SecureChannelSession secureChannel; + private SatoCardStatus status; + private SatochipParser parser = null; + + private String pinCached = null; + + public static final byte[] SATOCHIP_AID = Utils.hexToBytes("5361746f43686970"); //SatoChip + + /** + * Creates a SatochipCommandSet using the given APDU Channel + */ + public SatochipCommandSet() throws CardException { + this.cardTransport = new SatoCardTransport(SATOCHIP_AID); + this.secureChannel = new SecureChannelSession(); + this.parser = new SatochipParser(); + } + + /** + * Returns the application info as stored from the last sent SELECT command. Returns null if no succesful SELECT + * command has been sent using this command set. + * + * @return the application info object + */ + public SatoCardStatus getApplicationStatus() { + if(this.status == null) { + this.cardGetStatus(); + } + + return this.status; + } + + /**************************************** + * AUTHENTIKEY * + ****************************************/ + + public APDUResponse cardTransmit(APDUCommand plainApdu) { + // we try to transmit the APDU until we receive the answer or we receive an unrecoverable error + boolean isApduTransmitted = false; + do { + try { + byte[] apduBytes = plainApdu.serialize(); + byte ins = apduBytes[1]; + boolean isEncrypted = false; + + // check if status available + if(status == null) { + APDUCommand statusCapdu = new APDUCommand(0xB0, INS_GET_STATUS, 0x00, 0x00, new byte[0]); + APDUResponse statusRapdu = this.cardTransport.send(statusCapdu); + status = new SatoCardStatus(statusRapdu); + } + + APDUCommand capdu = plainApdu; + if(status.needsSecureChannel() && (ins != 0xA4) && (ins != 0x81) && (ins != 0x82) && (ins != INS_GET_STATUS)) { + if(!secureChannel.initializedSecureChannel()) { + // get card's public key + APDUResponse secChannelRapdu = this.cardInitiateSecureChannel(); + byte[] pubkey = this.parser.parseInitiateSecureChannel(secChannelRapdu); + // setup secure channel + this.secureChannel.initiateSecureChannel(pubkey); + } + // encrypt apdu + capdu = secureChannel.encryptSecureChannel(plainApdu); + isEncrypted = true; + } + + APDUResponse rapdu = this.cardTransport.send(capdu); + int sw12 = rapdu.getSw(); + + // check answer + if(sw12 == 0x9000) { // ok! + if(isEncrypted) { + // decrypt + rapdu = secureChannel.decryptSecureChannel(rapdu); + } + isApduTransmitted = true; // leave loop + return rapdu; + } + // PIN authentication is required + else if(sw12 == 0x9C06) { + //cardVerifyPIN(); + log.error("Error, Satochip PIN required"); + throw new CardAuthorizationException("PIN is required"); + } + // SecureChannel is not initialized + else if(sw12 == 0x9C21) { + log.error("Error, Satochip secure channel required"); + secureChannel.resetSecureChannel(); + } else { + // cannot resolve issue at this point + isApduTransmitted = true; // leave loop + return rapdu; + } + } catch(Exception e) { + log.warn("Error transmitting Satochip command set" + e); + return new APDUResponse(new byte[0], (byte) 0x00, (byte) 0x00); // return empty APDUResponse + } + } while(!isApduTransmitted); + + return new APDUResponse(new byte[0], (byte) 0x00, (byte) 0x00); // should not happen + } + + public void cardDisconnect() { + secureChannel.resetSecureChannel(); + status = null; + pinCached = null; + try { + cardTransport.disconnect(); + } catch(CardException e) { + log.error("Error disconnecting Satochip" + e); + } + } + + public void cardGetStatus() { + APDUCommand plainApdu = new APDUCommand(0xB0, INS_GET_STATUS, 0x00, 0x00, new byte[0]); + APDUResponse respApdu = this.cardTransmit(plainApdu); + this.status = new SatoCardStatus(respApdu); + } + + public APDUResponse cardInitiateSecureChannel() throws CardException { + byte[] pubkey = secureChannel.getPublicKey(); + APDUCommand plainApdu = new APDUCommand(0xB0, INS_INIT_SECURE_CHANNEL, 0x00, 0x00, pubkey); + + return this.cardTransport.send(plainApdu); + } + + /**************************************** + * CARD MGMT * + ****************************************/ + + public APDUResponse cardSetup(byte pin_tries0, byte[] pin0) { + // use random values for pin1, ublk0, ublk1 + SecureRandom random = new SecureRandom(); + byte[] ublk0 = new byte[8]; + byte[] ublk1 = new byte[8]; + byte[] pin1 = new byte[8]; + random.nextBytes(ublk0); + random.nextBytes(ublk1); + random.nextBytes(pin1); + + byte ublk_tries0 = (byte) 0x01; + byte ublk_tries1 = (byte) 0x01; + byte pin_tries1 = (byte) 0x01; + + return cardSetup(pin_tries0, ublk_tries0, pin0, ublk0, pin_tries1, ublk_tries1, pin1, ublk1); + } + + public APDUResponse cardSetup(byte pin_tries0, byte ublk_tries0, byte[] pin0, byte[] ublk0, + byte pin_tries1, byte ublk_tries1, byte[] pin1, byte[] ublk1) { + + byte[] pin = {0x4D, 0x75, 0x73, 0x63, 0x6C, 0x65, 0x30, 0x30}; //default pin + byte cla = (byte) 0xB0; + byte ins = INS_SETUP; + byte p1 = 0; + byte p2 = 0; + + // data=[pin_length(1) | pin | + // pin_tries0(1) | ublk_tries0(1) | pin0_length(1) | pin0 | ublk0_length(1) | ublk0 | + // pin_tries1(1) | ublk_tries1(1) | pin1_length(1) | pin1 | ublk1_length(1) | ublk1 | + // memsize(2) | memsize2(2) | ACL(3) | + // option_flags(2) | hmacsha160_key(20) | amount_limit(8)] + int optionsize = 0; + int option_flags = 0; // do not use option (mostly deprecated) + int offset = 0; + int datasize = 16 + pin.length + pin0.length + pin1.length + ublk0.length + ublk1.length + optionsize; + byte[] data = new byte[datasize]; + + data[offset++] = (byte) pin.length; + System.arraycopy(pin, 0, data, offset, pin.length); + offset += pin.length; + // pin0 & ublk0 + data[offset++] = pin_tries0; + data[offset++] = ublk_tries0; + data[offset++] = (byte) pin0.length; + System.arraycopy(pin0, 0, data, offset, pin0.length); + offset += pin0.length; + data[offset++] = (byte) ublk0.length; + System.arraycopy(ublk0, 0, data, offset, ublk0.length); + offset += ublk0.length; + // pin1 & ublk1 + data[offset++] = pin_tries1; + data[offset++] = ublk_tries1; + data[offset++] = (byte) pin1.length; + System.arraycopy(pin1, 0, data, offset, pin1.length); + offset += pin1.length; + data[offset++] = (byte) ublk1.length; + System.arraycopy(ublk1, 0, data, offset, ublk1.length); + offset += ublk1.length; + + // memsize default (deprecated) + data[offset++] = (byte) 00; + data[offset++] = (byte) 32; + data[offset++] = (byte) 00; + data[offset++] = (byte) 32; + + // ACL (deprecated) + data[offset++] = (byte) 0x01; + data[offset++] = (byte) 0x01; + data[offset++] = (byte) 0x01; + + APDUCommand plainApdu = new APDUCommand(cla, ins, p1, p2, data); + APDUResponse respApdu = this.cardTransmit(plainApdu); + + if(respApdu.getSw() == 0x9000) { + //setPin0(pin0); // todo: cache value... + } else { + log.error("Error " + respApdu.toHexString()); + } + + return respApdu; + } + + + /**************************************** + * PIN MGMT * + ****************************************/ + + public APDUResponse cardVerifyPIN() throws CardException { + return this.cardVerifyPIN((byte) 0, pinCached); + } + + public APDUResponse cardVerifyPIN(int pinNbr, String pin) throws CardException { + if(pin == null) { + if(pinCached == null) { + throw new CardException("PIN required!"); + } + pin = this.pinCached; + } + + byte[] pinBytes = pin.getBytes(StandardCharsets.UTF_8); + if(pinBytes.length > 16) { + throw new CardException("PIN should be maximum 16 characters!"); + } + + APDUCommand capdu = new APDUCommand(0xB0, INS_VERIFY_PIN, (byte) pinNbr, 0x00, pinBytes); + APDUResponse rapdu = this.cardTransmit(capdu); + + // correct PIN: cache PIN value + int sw = rapdu.getSw(); + if(sw == 0x9000) { + this.pinCached = pin; //set cached PIN value + } + // wrong PIN, get remaining tries available (since v0.11) + else if((sw & 0xffc0) == 0x63c0) { + this.pinCached = null; //reset cached PIN value + int pinLeft = (sw & ~0xffc0); + throw new CardAuthorizationException("Wrong PIN, remaining tries: " + pinLeft); + } + // wrong PIN (legacy before v0.11) + else if(sw == 0x9c02) { + this.pinCached = null; //reset cached PIN value + SatoCardStatus cardStatus = this.getApplicationStatus(); + int pinLeft = cardStatus.getPin0RemainingCounter(); + throw new CardAuthorizationException("Wrong PIN, remaining tries: " + pinLeft); + } + // blocked PIN + else if(sw == 0x9c0c) { + throw new CardException("Card is blocked!"); + } + + return rapdu; + } + + public void cardChangePIN(int pinNbr, String oldPin, String newPin) throws CardException { + byte[] oldPinBytes = oldPin.getBytes(StandardCharsets.UTF_8); + byte[] newPinBytes = newPin.getBytes(StandardCharsets.UTF_8); + int lc = 1 + oldPinBytes.length + 1 + newPinBytes.length; + byte[] data = new byte[lc]; + + data[0] = (byte) oldPinBytes.length; + int offset = 1; + System.arraycopy(oldPinBytes, 0, data, offset, oldPinBytes.length); + offset += oldPinBytes.length; + data[offset] = (byte) newPinBytes.length; + offset += 1; + System.arraycopy(newPinBytes, 0, data, offset, newPinBytes.length); + + APDUCommand capdu = new APDUCommand(0xB0, INS_CHANGE_PIN, (byte) pinNbr, 0x00, data); + APDUResponse rapdu = this.cardTransmit(capdu); + + // correct PIN: cache PIN value + int sw = rapdu.getSw(); + if(sw == 0x9000) { + this.pinCached = newPin; + } + // wrong PIN, get remaining tries available (since v0.11) + else if((sw & 0xffc0) == 0x63c0) { + int pinLeft = (sw & ~0xffc0); + throw new CardAuthorizationException("Wrong PIN, remaining tries: " + pinLeft); + } + // wrong PIN (legacy before v0.11) + else if(sw == 0x9c02) { + SatoCardStatus cardStatus = this.getApplicationStatus(); + int pinLeft = cardStatus.getPin0RemainingCounter(); + throw new CardAuthorizationException("Wrong PIN, remaining tries: " + pinLeft); + } + // blocked PIN + else if(sw == 0x9c0c) { + throw new CardException("Card is blocked!"); + } + } + + /**************************************** + * BIP32 * + ****************************************/ + + public APDUResponse cardBip32ImportSeed(byte[] masterseed) { + APDUCommand plainApdu = new APDUCommand(0xB0, INS_BIP32_IMPORT_SEED, masterseed.length, 0x00, masterseed); + return this.cardTransmit(plainApdu); + } + + public APDUResponse cardBip32GetExtendedKey(String stringPath) { + KeyPath keyPath = new KeyPath(stringPath); + byte[] bytePath = keyPath.getData(); + return cardBip32GetExtendedKey(bytePath); + } + + public APDUResponse cardBip32GetExtendedKey(byte[] bytePath) { + byte p1 = (byte) (bytePath.length / 4); + + APDUCommand plainApdu = new APDUCommand(0xB0, INS_BIP32_GET_EXTENDED_KEY, p1, 0x40, bytePath); + return this.cardTransmit(plainApdu); + } + + /* + * Get the BIP32 xpub for given path. + * + * Parameters: + * path (str): the path; if given as a string, it will be converted to bytes (4 bytes for each path index) + * xtype (str): the type of transaction such as 'standard', 'p2wpkh-p2sh', 'p2wpkh', 'p2wsh-p2sh', 'p2wsh' + * is_mainnet (bool): is mainnet or testnet + * + * Return: + * xpub (str): the corresponding xpub value + */ + public String cardBip32GetXpub(String stringPath, ExtendedKey.Header xtype) throws CardException { + // path is of the form 44'/0'/1' + KeyPath keyPath = new KeyPath(stringPath); + byte[] bytePath = keyPath.getData(); + int depth = bytePath.length / 4; + + APDUResponse rapdu = this.cardBip32GetExtendedKey(bytePath); + byte[][] extendedkey = this.parser.parseBip32GetExtendedKey(rapdu); + + byte[] fingerprint = new byte[4]; + byte[] childNumber = new byte[4]; + if(depth == 0) { //masterkey + // fingerprint and childnumber set to all-zero bytes by default + //fingerprint= bytes([0,0,0,0]) + //childNumber= bytes([0,0,0,0]) + } else { //get parent info + byte[] bytePathParent = Arrays.copyOfRange(bytePath, 0, bytePath.length - 4); + APDUResponse rapdu2 = this.cardBip32GetExtendedKey(bytePathParent); + byte[][] extendedkeyParent = this.parser.parseBip32GetExtendedKey(rapdu2); + byte[] identifier = Utils.sha256hash160(extendedkeyParent[0]); + fingerprint = Arrays.copyOfRange(identifier, 0, 4); + childNumber = Arrays.copyOfRange(bytePath, bytePath.length - 4, bytePath.length); + } + + ByteBuffer buffer = ByteBuffer.allocate(78); + buffer.putInt(xtype.getHeader()); + buffer.put((byte) depth); + buffer.put(fingerprint); + buffer.put(childNumber); + buffer.put(extendedkey[1]); // chaincode + buffer.put(extendedkey[0]); // pubkey (compressed) + byte[] xpubByte = buffer.array(); + + return Base58.encodeChecked(xpubByte); + } + + /**************************************** + * SIGNATURES * + ****************************************/ + + public APDUResponse cardSignTransactionHash(byte keynbr, byte[] txhash, byte[] chalresponse) throws CardException { + byte[] data; + if(txhash.length != 32) { + throw new CardException("Wrong txhash length (should be 32)"); + } + if(chalresponse == null) { + data = new byte[32]; + System.arraycopy(txhash, 0, data, 0, txhash.length); + } else if(chalresponse.length == 20) { + data = new byte[32 + 2 + 20]; + int offset = 0; + System.arraycopy(txhash, 0, data, offset, txhash.length); + offset += 32; + data[offset++] = (byte) 0x80; // 2 middle bytes for 2FA flag + data[offset++] = (byte) 0x00; + System.arraycopy(chalresponse, 0, data, offset, chalresponse.length); + } else { + throw new CardException("Wrong challenge-response length (should be 20)"); + } + + APDUCommand plainApdu = new APDUCommand(0xB0, INS_SIGN_TRANSACTION_HASH, keynbr, 0x00, data); + return this.cardTransmit(plainApdu); + } + + /** + * This function signs a given hash with a std or the last extended key + * If 2FA is enabled, a HMAC must be provided as an additional security layer. * + * ins: 0x7B + * p1: key number or 0xFF for the last derived Bip32 extended key + * p2: 0x00 + * data: [hash(32b) | option: 2FA-flag(2b)|hmac(20b)] + * return: [sig] + */ + public APDUResponse cardSignSchnorrHash(byte keynbr, byte[] txhash, byte[] chalresponse) throws CardException { + byte[] data; + if(txhash.length != 32) { + throw new CardException("Wrong txhash length (should be 32)"); + } + if(chalresponse == null) { + data = new byte[32]; + System.arraycopy(txhash, 0, data, 0, txhash.length); + } else if(chalresponse.length == 20) { + data = new byte[32 + 2 + 20]; + int offset = 0; + System.arraycopy(txhash, 0, data, offset, txhash.length); + offset += 32; + data[offset++] = (byte) 0x80; // 2 middle bytes for 2FA flag + data[offset++] = (byte) 0x00; + System.arraycopy(chalresponse, 0, data, offset, chalresponse.length); + } else { + throw new CardException("Wrong challenge-response length (should be 20)"); + } + + APDUCommand plainApdu = new APDUCommand(0xB0, 0x7B, keynbr, 0x00, data); + return this.cardTransmit(plainApdu); + } + + /** + * This function tweak the currently available private stored in the Satochip. + * Tweaking is based on the 'taproot_tweak_seckey(seckey0, h)' algorithm specification defined here: + * https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs + *

+ * ins: 0x7C + * p1: key number or 0xFF for the last derived Bip32 extended key + * p2: 0x00 + * data: [hash(32b) | option: 2FA-flag(2b)|hmac(20b)] + * return: [sig] + */ + public APDUResponse cardTaprootTweakPrivkey(byte keynbr, byte[] tweak) throws CardException { + byte[] data; + if(tweak == null) { + tweak = new byte[32]; // by default use a 32-byte vector filled with '0x00' + } + if(tweak.length != 32) { + throw new CardException("Wrong tweak length (should be 32)"); + } + data = new byte[33]; + data[0] = (byte) 32; + System.arraycopy(tweak, 0, data, 1, tweak.length); + + APDUCommand plainApdu = new APDUCommand(0xB0, 0x7C, keynbr, 0x00, data); + return this.cardTransmit(plainApdu); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/satochip/SatochipParser.java b/src/main/java/com/sparrowwallet/sparrow/io/satochip/SatochipParser.java new file mode 100644 index 00000000..cdd2ef86 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/SatochipParser.java @@ -0,0 +1,130 @@ +package com.sparrowwallet.sparrow.io.satochip; + +import com.sparrowwallet.drongo.protocol.Sha256Hash; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.crypto.ECDSASignature; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.smartcardio.CardException; +import java.util.Arrays; + +public class SatochipParser { + private static final Logger log = LoggerFactory.getLogger(SatochipParser.class); + + public SatochipParser() { + } + + /**************************************** + * PARSER * + ****************************************/ + + public byte[] parseInitiateSecureChannel(APDUResponse rapdu) throws CardException { + try { + byte[] data = rapdu.getData(); + + // data= [coordxSize | coordx | sig1Size | sig1 | sig2Size | sig2] + int offset = 0; + int coordxSize = 256 * data[offset++] + data[offset++]; + + byte[] coordx = new byte[coordxSize]; + System.arraycopy(data, offset, coordx, 0, coordxSize); + offset += coordxSize; + + // msg1 is [coordx_size | coordx] + byte[] msg1 = new byte[2 + coordxSize]; + System.arraycopy(data, 0, msg1, 0, msg1.length); + + int sig1Size = 256 * data[offset++] + data[offset++]; + byte[] sig1 = new byte[sig1Size]; + System.arraycopy(data, offset, sig1, 0, sig1Size); + offset += sig1Size; + + // msg2 is [coordxSize | coordx | sig1Size | sig1] + byte[] msg2 = new byte[2 + coordxSize + 2 + sig1Size]; + System.arraycopy(data, 0, msg2, 0, msg2.length); + + int sig2Size = 256 * data[offset++] + data[offset++]; + byte[] sig2 = new byte[sig2Size]; + System.arraycopy(data, offset, sig2, 0, sig2Size); + offset += sig2Size; + + return recoverPubkey(msg1, sig1, coordx, false); + } catch(Exception e) { + throw new CardException("Error parsing Satochip response", e); + } + } + + public byte[][] parseBip32GetExtendedKey(APDUResponse rapdu) throws CardException { + try { + byte[][] extendedkey = new byte[2][]; + extendedkey[0] = new byte[33]; // pubkey + extendedkey[1] = new byte[32]; // chaincode + + byte[] data = rapdu.getData(); + //data: [chaincode(32b) | coordx_size(2b) | coordx | sig_size(2b) | sig | sig_size(2b) | sig2] + + int offset = 0; + byte[] chaincode = new byte[32]; + System.arraycopy(data, offset, chaincode, 0, chaincode.length); + offset += 32; + + int coordxSize = 256 * (data[offset++] & 0x7f) + data[offset++]; // (data[32] & 0x80) is ignored (optimization flag) + byte[] coordx = new byte[coordxSize]; + System.arraycopy(data, offset, coordx, 0, coordxSize); + offset += coordxSize; + + // msg1 is [chaincode | coordx_size | coordx] + byte[] msg1 = new byte[32 + 2 + coordxSize]; + System.arraycopy(data, 0, msg1, 0, msg1.length); + + int sig1Size = 256 * data[offset++] + data[offset++]; + byte[] sig1 = new byte[sig1Size]; + System.arraycopy(data, offset, sig1, 0, sig1Size); + offset += sig1Size; + + // msg2 is [chaincode | coordxSize | coordx | sig1Size | sig1] + byte[] msg2 = new byte[32 + 2 + coordxSize + 2 + sig1Size]; + System.arraycopy(data, 0, msg2, 0, msg2.length); + + int sig2Size = 256 * data[offset++] + data[offset++]; + byte[] sig2 = new byte[sig2Size]; + System.arraycopy(data, offset, sig2, 0, sig2Size); + offset += sig2Size; + + byte[] pubkey = recoverPubkey(msg1, sig1, coordx, true); // true: compressed (33 bytes) + + // todo: recover from si2 + System.arraycopy(pubkey, 0, extendedkey[0], 0, pubkey.length); + System.arraycopy(chaincode, 0, extendedkey[1], 0, chaincode.length); + return extendedkey; + } catch(Exception e) { + throw new CardException("Error parsing Satochip extended key", e); + } + } + + /**************************************** + * recovery methods * + ****************************************/ + + public byte[] recoverPubkey(byte[] msg, byte[] dersig, byte[] coordx, Boolean compressed) throws CardException { + // convert msg to hash + //byte[] hash = Sha256Hash.hash(msg); + ECDSASignature ecdsaSig = ECDSASignature.decodeFromDER(dersig); + + byte recId = -1; + ECKey k = null; + for(byte i = 0; i < 4; i++) { + k = ECKey.recoverFromSignature(i, ecdsaSig, Sha256Hash.of(msg), compressed); + if(k != null && Arrays.equals(k.getPubKeyXCoord(), coordx)) { + recId = i; + break; + } + } + if(recId == -1) { + throw new CardException("Could not construct a recoverable key. This should never happen."); + } + + return k.getPubKey(); + } +} \ No newline at end of file diff --git a/src/main/java/com/sparrowwallet/sparrow/io/satochip/SecureChannelSession.java b/src/main/java/com/sparrowwallet/sparrow/io/satochip/SecureChannelSession.java new file mode 100644 index 00000000..35115861 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/SecureChannelSession.java @@ -0,0 +1,199 @@ +package com.sparrowwallet.sparrow.io.satochip; + +import com.sparrowwallet.drongo.crypto.AESKeyCrypter; + +import com.sparrowwallet.drongo.bip47.SecretPoint; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.crypto.Key; +import com.sparrowwallet.drongo.crypto.EncryptedData; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import javax.smartcardio.CardException; +import java.security.SecureRandom; +import java.nio.ByteBuffer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Handles a SecureChannel session with the card. + */ +public class SecureChannelSession { + private static final Logger log = LoggerFactory.getLogger(SecureChannelSession.class); + + public static final int SC_SECRET_LENGTH = 16; + public static final int SC_BLOCK_SIZE = 16; + public static final int IV_SIZE = 16; + public static final int MAC_SIZE = 20; + + // secure channel constants + private final static byte INS_INIT_SECURE_CHANNEL = (byte) 0x81; + private final static byte INS_PROCESS_SECURE_CHANNEL = (byte) 0x82; + private final static short SW_SECURE_CHANNEL_REQUIRED = (short) 0x9C20; + private final static short SW_SECURE_CHANNEL_UNINITIALIZED = (short) 0x9C21; + private final static short SW_SECURE_CHANNEL_WRONG_IV = (short) 0x9C22; + private final static short SW_SECURE_CHANNEL_WRONG_MAC = (short) 0x9C23; + + private boolean initialized_secure_channel = false; + + // secure channel keys + private byte[] secret; + private byte[] iv; + private int ivCounter; + byte[] derived_key; + byte[] mac_key; + + // for ECDH + private SecretPoint secretPoint; + private final ECKey eckey; + + // for session encryption + private final SecureRandom random; + private final AESKeyCrypter aesCipher; + + /** + * Constructs a SecureChannel session on the client. + */ + public SecureChannelSession() { + random = new SecureRandom(); + + // generate keypair + eckey = new ECKey(); + aesCipher = new AESKeyCrypter(); + } + + /** + * Generates a pairing secret. This should be called before each session. The public key of the card is used as input + * for the EC-DH algorithm. The output is stored as the secret. + * + * @param pubkeyData the public key returned by the applet as response to the SELECT command + */ + public void initiateSecureChannel(byte[] pubkeyData) { //TODO: check keyData format + try { + byte[] privkeyData = this.eckey.getPrivKeyBytes(); + secretPoint = new SecretPoint(privkeyData, pubkeyData); + secret = secretPoint.ECDHSecretAsBytes(); + //log.trace("SATOCHIP SecureChannelSession initiateSecureChannel() secret: " + Utils.bytesToHex(secret)); + + // derive session encryption key + byte[] msg_key = "sc_key".getBytes(); + byte[] derived_key_2Ob = getHmacSha1Hash(secret, msg_key); + derived_key = new byte[16]; + System.arraycopy(derived_key_2Ob, 0, derived_key, 0, 16); + //log.trace("SATOCHIP SecureChannelSession initiateSecureChannel() derived_key: " + Utils.bytesToHex(derived_key)); + // derive session mac key + byte[] msg_mac = "sc_mac".getBytes(); + mac_key = getHmacSha1Hash(secret, msg_mac); + //log.trace("SATOCHIP SecureChannelSession initiateSecureChannel() mac_key: " + Utils.bytesToHex(mac_key)); + + ivCounter = 1; + initialized_secure_channel = true; + } catch(Exception e) { + log.error("Error initiating secure channel", e); + } + } + + public APDUCommand encryptSecureChannel(APDUCommand plainApdu) throws CardException { + try { + byte[] plainBytes = plainApdu.serialize(); + + // set iv + iv = new byte[SC_BLOCK_SIZE]; + random.nextBytes(iv); + ByteBuffer bb = ByteBuffer.allocate(4); + bb.putInt(ivCounter); // big endian + byte[] ivCounterBytes = bb.array(); + System.arraycopy(ivCounterBytes, 0, iv, 12, 4); + ivCounter += 2; + + // encrypt data + Key aesKey = new Key(derived_key, null, null); + byte[] encrypted = aesCipher.encrypt(plainBytes, iv, aesKey).getEncryptedBytes(); + + // mac + int offset = 0; + byte[] data_to_mac = new byte[IV_SIZE + 2 + encrypted.length]; + System.arraycopy(iv, offset, data_to_mac, offset, IV_SIZE); + offset += IV_SIZE; + data_to_mac[offset++] = (byte) (encrypted.length >> 8); + data_to_mac[offset++] = (byte) (encrypted.length % 256); + System.arraycopy(encrypted, 0, data_to_mac, offset, encrypted.length); + // log.trace("SATOCHIP data_to_mac: "+ SatochipParser.toHexString(data_to_mac)); + byte[] mac = getHmacSha1Hash(mac_key, data_to_mac); + + // copy all data to new data buffer + offset = 0; + byte[] data = new byte[IV_SIZE + 2 + encrypted.length + 2 + MAC_SIZE]; + System.arraycopy(iv, offset, data, offset, IV_SIZE); + offset += IV_SIZE; + data[offset++] = (byte) (encrypted.length >> 8); + data[offset++] = (byte) (encrypted.length % 256); + System.arraycopy(encrypted, 0, data, offset, encrypted.length); + offset += encrypted.length; + data[offset++] = (byte) (mac.length >> 8); + data[offset++] = (byte) (mac.length % 256); + System.arraycopy(mac, 0, data, offset, mac.length); + + // convert to C-APDU + return new APDUCommand(0xB0, INS_PROCESS_SECURE_CHANNEL, 0x00, 0x00, data); + } catch(Exception e) { + throw new CardException("Error encrypting secure channel", e); + } + } + + public APDUResponse decryptSecureChannel(APDUResponse encryptedApdu) throws CardException { + try { + byte[] encryptedBytes = encryptedApdu.getData(); + if(encryptedBytes.length == 0) { + return encryptedApdu; // no decryption needed + } else if(encryptedBytes.length < 40) { + // has at least (IV_SIZE + 2 + 2 + 20) + throw new RuntimeException("Encrypted response has wrong length: " + encryptedBytes.length); + } + + int offset = 0; + byte[] iv = new byte[IV_SIZE]; + System.arraycopy(encryptedBytes, offset, iv, 0, IV_SIZE); + offset += IV_SIZE; + int ciphertext_size = ((encryptedBytes[offset++] & 0xff) << 8) + (encryptedBytes[offset++] & 0xff); + if((encryptedBytes.length - offset) != ciphertext_size) { + throw new RuntimeException("Encrypted response has wrong length ciphertext_size: " + ciphertext_size); + } + byte[] ciphertext = new byte[ciphertext_size]; + System.arraycopy(encryptedBytes, offset, ciphertext, 0, ciphertext.length); + + // decrypt data + Key aesKey = new Key(derived_key, null, null); + EncryptedData encryptedData = new EncryptedData(iv, ciphertext, null, null); + byte[] decrypted = aesCipher.decrypt(encryptedData, aesKey); + + return new APDUResponse(decrypted, (byte) 0x90, (byte) 0x00); + } catch(Exception e) { + throw new CardException("Error decrypting secure channel", e); + } + } + + public boolean initializedSecureChannel() { + return initialized_secure_channel; + } + + public byte[] getPublicKey() { + return eckey.getPubKey(false); + } + + public void resetSecureChannel() { + initialized_secure_channel = false; + } + + public static byte[] getHmacSha1Hash(byte[] key, byte[] data) { + try { + SecretKeySpec secretKeySpec = new SecretKeySpec(key, "HmacSHA1"); + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(secretKeySpec); + return mac.doFinal(data); + } catch(Exception e) { + throw new RuntimeException("Error computing HmacSHA1", e); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwAirgappedController.java b/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwAirgappedController.java index 4effeaf1..d91bd691 100644 --- a/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwAirgappedController.java +++ b/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwAirgappedController.java @@ -6,6 +6,7 @@ import com.sparrowwallet.sparrow.control.FileKeystoreImportPane; import com.sparrowwallet.sparrow.control.TitledDescriptionPane; import com.sparrowwallet.sparrow.io.*; import com.sparrowwallet.sparrow.io.ckcard.Tapsigner; +import com.sparrowwallet.sparrow.io.satochip.Satochip; import javafx.fxml.FXML; import javafx.scene.control.Accordion; import org.slf4j.Logger; @@ -38,7 +39,7 @@ public class HwAirgappedController extends KeystoreImportDetailController { } } - List cardImporters = List.of(new Tapsigner()); + List cardImporters = List.of(new Tapsigner(), new Satochip()); for(KeystoreCardImport importer : cardImporters) { if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) { CardImportPane importPane = new CardImportPane(getMasterController().getWallet(), importer, getMasterController().getRequiredDerivation()); diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java index d6d7fe10..9fe828ea 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java @@ -75,6 +75,9 @@ public class KeystoreController extends WalletFormController implements Initiali @FXML private SegmentedButton cardServiceButtons; + @FXML + private ToggleButton backupButton; + @FXML private Button importButton; @@ -300,6 +303,7 @@ public class KeystoreController extends WalletFormController implements Initiali viewSeedButton.setVisible(keystore.getSource() == KeystoreSource.SW_SEED && keystore.hasSeed()); viewKeyButton.setVisible(keystore.getSource() == KeystoreSource.SW_SEED && keystore.hasMasterPrivateExtendedKey()); cardServiceButtons.setVisible(keystore.getWalletModel().isCard()); + backupButton.setDisable(!keystore.getWalletModel().supportsBackup()); importButton.setText(keystore.getSource() == KeystoreSource.SW_WATCH ? "Import..." : "Replace..."); importButton.setTooltip(new Tooltip(keystore.getSource() == KeystoreSource.SW_WATCH ? "Import a keystore from an external source" : "Replace this keystore with another source")); @@ -465,7 +469,7 @@ public class KeystoreController extends WalletFormController implements Initiali return; } - CardPinDialog cardPinDialog = new CardPinDialog(backupOnly); + CardPinDialog cardPinDialog = new CardPinDialog(keystore.getWalletModel(), backupOnly); cardPinDialog.initOwner(cardServiceButtons.getScene().getWindow()); Optional optPinChange = cardPinDialog.showAndWait(); if(optPinChange.isPresent()) { diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/keystore.fxml b/src/main/resources/com/sparrowwallet/sparrow/wallet/keystore.fxml index 0e7aea35..0654f9be 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/keystore.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/keystore.fxml @@ -54,7 +54,7 @@ - + diff --git a/src/main/resources/image/satochip-icon-invert.svg b/src/main/resources/image/satochip-icon-invert.svg new file mode 100644 index 00000000..4b31c041 --- /dev/null +++ b/src/main/resources/image/satochip-icon-invert.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/main/resources/image/satochip-icon.svg b/src/main/resources/image/satochip-icon.svg new file mode 100644 index 00000000..4ee563a7 --- /dev/null +++ b/src/main/resources/image/satochip-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/main/resources/image/satochip.png b/src/main/resources/image/satochip.png new file mode 100644 index 0000000000000000000000000000000000000000..96c80e0fcc67ea576221630f7cd288228b703890 GIT binary patch literal 4536 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 YQu-?v*Gr0A<^I2EYwciFX@N`pAC(YUp#T5? literal 0 HcmV?d00001 diff --git a/src/main/resources/image/satochip@3x.png b/src/main/resources/image/satochip@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..a2c2cdf0d59d89c09003dd9e5b6e4ea8c36f2547 GIT binary patch literal 15554 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