From 755208a8a82b207050ca66e26272de68833a04e0 Mon Sep 17 00:00:00 2001 From: Toporin Date: Tue, 5 Sep 2023 14:14:53 +0100 Subject: [PATCH] Add Satochip support: Initial commit --- .../sparrow/control/CardImportPane.java | 3 +- .../sparrow/control/DevicePane.java | 206 ++++-- .../com/sparrowwallet/sparrow/io/CardApi.java | 14 + .../sparrow/io/satochip/APDUCommand.java | 148 +++++ .../sparrow/io/satochip/APDUResponse.java | 125 ++++ .../sparrow/io/satochip/Constants.java | 149 +++++ .../sparrow/io/satochip/KeyPath.java | 102 +++ .../sparrow/io/satochip/SatoCardApi.java | 560 +++++++++++++++++ .../sparrow/io/satochip/SatoCardStatus.java | 124 ++++ .../io/satochip/SatoCardTransport.java | 73 +++ .../sparrow/io/satochip/Satochip.java | 110 ++++ .../io/satochip/SatochipCommandSet.java | 591 ++++++++++++++++++ .../sparrow/io/satochip/SatochipParser.java | 155 +++++ .../io/satochip/SecureChannelSession.java | 245 ++++++++ .../keystoreimport/HwAirgappedController.java | 6 +- .../HwUsbDevicesController.java | 2 + src/main/resources/image/satochip.png | Bin 0 -> 3682 bytes src/main/resources/image/satochip@2x.png | Bin 0 -> 6805 bytes src/main/resources/image/satochip@3x.png | Bin 0 -> 13966 bytes 19 files changed, 2553 insertions(+), 60 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.png create mode 100644 src/main/resources/image/satochip@2x.png create mode 100644 src/main/resources/image/satochip@3x.png diff --git a/src/main/java/com/sparrowwallet/sparrow/control/CardImportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/CardImportPane.java index 6e7ceb4c..247ff12e 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/CardImportPane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/CardImportPane.java @@ -6,6 +6,7 @@ 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.WalletModel; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.event.KeystoreImportEvent; @@ -74,7 +75,7 @@ public class CardImportPane extends TitledDescriptionPane { return; } - if(pin.get().length() < 6) { + if(pin.get().length() < importer.getWalletModel().getMinPinLength()) { setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short"); setContent(getPinAndDerivationEntry()); showHideLink.setVisible(false); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java b/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java index 4b2df0b9..e502854e 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java @@ -13,6 +13,7 @@ import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.drongo.protocol.Sha256Hash; import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.wallet.*; +import com.sparrowwallet.drongo.Utils; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.EventManager; import com.sparrowwallet.sparrow.event.*; @@ -673,7 +674,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(importButton)); showHideLink.setVisible(false); @@ -690,7 +691,7 @@ public class DevicePane extends TitledDescriptionPane { } Service importService = cardApi.getImportService(derivation, messageProperty); - handleCardOperation(importService, importButton, "Import", true, event -> { + handleCardOperation(importService, importButton, "Import", true, device.getModel().getMinPinLength(), event -> { importKeystore(derivation, importService.getValue()); }); } catch(Exception e) { @@ -769,7 +770,7 @@ public class DevicePane extends TitledDescriptionPane { try { CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get()); Service signService = cardApi.getSignService(wallet, psbt, messageProperty); - handleCardOperation(signService, signButton, "Signing", true, event -> { + handleCardOperation(signService, signButton, "Signing", true, device.getModel().getMinPinLength(), event -> { EventManager.get().post(new PSBTSignedEvent(psbt, signService.getValue())); }); } catch(Exception e) { @@ -784,7 +785,7 @@ public class DevicePane extends TitledDescriptionPane { EventManager.get().post(new PSBTSignedEvent(psbt, signedPsbt)); }); signPSBTService.setOnFailed(workerStateEvent -> { - setError("Signing Error", signPSBTService.getException().getMessage()); + setError("Signing Error ", signPSBTService.getException().getMessage()); log.error("Signing Error: " + signPSBTService.getException().getMessage(), signPSBTService.getException()); signButton.setDisable(false); }); @@ -794,8 +795,8 @@ public class DevicePane extends TitledDescriptionPane { } } - private void handleCardOperation(Service service, ButtonBase operationButton, String operationDescription, boolean pinRequired, EventHandler successHandler) { - if(pinRequired && pin.get().length() < 6) { + private void handleCardOperation(Service service, ButtonBase operationButton, String operationDescription, boolean pinRequired, int pinMinLength, EventHandler successHandler) { + if(pinRequired && pin.get().length() < pinMinLength) { setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short"); setContent(getCardPinEntry(operationButton)); showHideLink.setVisible(false); @@ -838,7 +839,7 @@ public class DevicePane extends TitledDescriptionPane { try { CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get()); Service signMessageService = cardApi.getSignMessageService(message, wallet.getScriptType(), keyDerivation.getDerivation(), messageProperty); - handleCardOperation(signMessageService, signMessageButton, "Signing", true, event -> { + handleCardOperation(signMessageService, signMessageButton, "Signing", true, device.getModel().getMinPinLength(), event -> { String signature = signMessageService.getValue(); EventManager.get().post(new MessageSignedEvent(wallet, signature)); }); @@ -924,7 +925,7 @@ public class DevicePane extends TitledDescriptionPane { try { CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get()); Service privateKeyService = cardApi.getPrivateKeyService(slot, messageProperty); - handleCardOperation(privateKeyService, getPrivateKeyButton, "Private Key", true, event -> { + handleCardOperation(privateKeyService, getPrivateKeyButton, "Private Key", true, device.getModel().getMinPinLength(), event -> { EventManager.get().post(new DeviceGetPrivateKeyEvent(privateKeyService.getValue(), cardApi.getDefaultScriptType())); }); } catch(Exception e) { @@ -940,7 +941,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); @@ -957,7 +958,7 @@ public class DevicePane extends TitledDescriptionPane { } Service
addressService = cardApi.getAddressService(messageProperty); - handleCardOperation(addressService, getAddressButton, "Address", false, event -> { + handleCardOperation(addressService, getAddressButton, "Address", false, device.getModel().getMinPinLength(), event -> { EventManager.get().post(new DeviceAddressEvent(addressService.getValue())); }); } catch(Exception e) { @@ -1047,60 +1048,149 @@ public class DevicePane extends TitledDescriptionPane { } private Node getCardInitializationPanel(CardApi cardApi, ButtonBase operationButton, DeviceOperation deviceOperation) { - VBox initTypeBox = new VBox(5); - RadioButton automatic = new RadioButton("Automatic (Recommended)"); - RadioButton advanced = new RadioButton("Advanced"); - TextField entropy = new TextField(); - entropy.setPromptText("Enter input for user entropy"); - entropy.setDisable(true); + WalletModel cardModel=null; + try{ + cardModel = cardApi.getCardType(); + } catch (Exception e){ + log.error("SATOCHIP DevicePane getCardInitializationPanel() exception: " + e); + //do nothing? + } + if (cardModel == WalletModel.SATOCHIP){ + log.debug("SATOCHIP DevicePane getCardInitializationPanel() cardModel == Satochip"); + VBox initTypeBox = new VBox(5); + // ideally, use UX like MnemonicKeystorePane to import seed + CustomPasswordField repeatedPin = new ViewPasswordField(); + CustomPasswordField mnemonic = new ViewPasswordField(); + CustomPasswordField passphrase = new ViewPasswordField(); + repeatedPin.setPromptText("Confirm entered PIN to initialize your Satochip"); + repeatedPin.setDisable(false); + mnemonic.setPromptText("Enter a Seed to initialize your Satochip"); + mnemonic.setDisable(false); + passphrase.setPromptText("Enter a passphrase (optional)"); + passphrase.setDisable(false); + initTypeBox.getChildren().addAll(repeatedPin, mnemonic, passphrase); - ToggleGroup toggleGroup = new ToggleGroup(); - automatic.setToggleGroup(toggleGroup); - advanced.setToggleGroup(toggleGroup); - automatic.setSelected(true); - toggleGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> { - entropy.setDisable(newValue == automatic); - }); - - initTypeBox.getChildren().addAll(automatic, advanced, entropy); - - Button initializeButton = new Button("Initialize"); - initializeButton.setDefaultButton(true); - initializeButton.setOnAction(event -> { - initializeButton.setDisable(true); - byte[] chainCode = toggleGroup.getSelectedToggle() == automatic ? null : Sha256Hash.hashTwice(entropy.getText().getBytes(StandardCharsets.UTF_8)); - Service cardInitializationService = cardApi.getInitializationService(chainCode, messageProperty); - cardInitializationService.setOnSucceeded(successEvent -> { - if(deviceOperation == DeviceOperation.IMPORT) { - AppServices.showSuccessDialog("Card Initialized", "The card was successfully initialized.\n\nYou can now import the keystore."); - } else if(deviceOperation == DeviceOperation.GET_ADDRESS) { - AppServices.showSuccessDialog("Card Reinitialized", "The card was successfully reinitialized.\n\nYou can now retrieve the new deposit address."); - } - operationButton.setDisable(false); - setDefaultStatus(); - setExpanded(false); - }); - cardInitializationService.setOnFailed(failEvent -> { - Throwable rootCause = Throwables.getRootCause(failEvent.getSource().getException()); - if(rootCause instanceof CardAuthorizationException) { - setError(rootCause.getMessage(), null); - setContent(getCardPinEntry(operationButton)); - operationButton.setDisable(false); + Button initializeButton = new Button("Initialize"); + initializeButton.setDefaultButton(true); + initializeButton.setOnAction(event -> { + initializeButton.setDisable(true); + /* + log.trace("SATOCHIP DevicePane getCardInitializationPanel() pin.get(): " + pin.get()); + log.trace("SATOCHIP DevicePane getCardInitializationPanel() repeatedPin.getText(): " + repeatedPin.getText()); + log.trace("SATOCHIP DevicePane getCardInitializationPanel() mnemonic.getText(): "+ mnemonic.getText()); + log.trace("SATOCHIP DevicePane getCardInitializationPanel() passphrase.getText(): "+ passphrase.getText()); + */ + + byte[] seedBytes; + // check that pin and previous pin match + if ( !pin.get().equals(repeatedPin.getText()) ){ + seedBytes = null; // will display a proper error later in cardApi.getInitializationService() + messageProperty.set("The two entered Pin values do not correspond!"); } else { - 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); + try{ + // check bip39 is correct & convert seed to masterseed bytes + List mnemonicWords = Arrays.asList(mnemonic.getText().split("[\\s,]+")); // \\s*,\\s* + Bip39MnemonicCode.INSTANCE.check(mnemonicWords); + DeterministicSeed seed = new DeterministicSeed(mnemonicWords, passphrase.getText(), System.currentTimeMillis(), DeterministicSeed.Type.BIP39); + seedBytes = seed.getSeedBytes(); + } catch (Exception e) { + seedBytes = null; // will display a proper error later in cardApi.getInitializationService() + messageProperty.set("Failed to parse the seed with error: " + e); + } } + + Service cardInitializationService = cardApi.getInitializationService(seedBytes, messageProperty); + cardInitializationService.setOnSucceeded(successEvent -> { + log.debug("SATOCHIP DevicePane getCardInitializationPanel() Card initialized!"); + if(deviceOperation == DeviceOperation.IMPORT) { + AppServices.showSuccessDialog("Card Initialized", "The card was successfully initialized.\n\nYou can now import the keystore."); + } else if(deviceOperation == DeviceOperation.GET_ADDRESS) { + AppServices.showSuccessDialog("Card Reinitialized", "The card was successfully reinitialized.\n\nYou can now retrieve the new deposit address."); + } + operationButton.setDisable(false); + setDefaultStatus(); + setExpanded(false); + }); + cardInitializationService.setOnFailed(failEvent -> { + log.error("SATOCHIP DevicePane getCardInitializationPanel() failed to initialize card!"); + /*Throwable rootCause = Throwables.getRootCause(failEvent.getSource().getException()); + if(rootCause instanceof CardAuthorizationException) { + setError(rootCause.getMessage(), null); + setContent(getCardPinEntry(operationButton)); + operationButton.setDisable(false); + } else {*/ + 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(); }); - cardInitializationService.start(); - }); - HBox contentBox = new HBox(20); - contentBox.getChildren().addAll(initTypeBox, initializeButton); - contentBox.setPadding(new Insets(10, 30, 10, 30)); - HBox.setHgrow(initTypeBox, Priority.ALWAYS); + HBox contentBox = new HBox(20); + contentBox.getChildren().addAll(initTypeBox, initializeButton); + contentBox.setPadding(new Insets(10, 30, 10, 30)); + HBox.setHgrow(initTypeBox, Priority.ALWAYS); - return contentBox; + return contentBox; + + } else { + VBox initTypeBox = new VBox(5); + RadioButton automatic = new RadioButton("Automatic (Recommended)"); + RadioButton advanced = new RadioButton("Advanced"); + TextField entropy = new TextField(); + entropy.setPromptText("Enter input for user entropy"); + entropy.setDisable(true); + + ToggleGroup toggleGroup = new ToggleGroup(); + automatic.setToggleGroup(toggleGroup); + advanced.setToggleGroup(toggleGroup); + automatic.setSelected(true); + toggleGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> { + entropy.setDisable(newValue == automatic); + }); + + initTypeBox.getChildren().addAll(automatic, advanced, entropy); + + Button initializeButton = new Button("Initialize"); + initializeButton.setDefaultButton(true); + initializeButton.setOnAction(event -> { + initializeButton.setDisable(true); + byte[] chainCode = toggleGroup.getSelectedToggle() == automatic ? null : Sha256Hash.hashTwice(entropy.getText().getBytes(StandardCharsets.UTF_8)); + Service cardInitializationService = cardApi.getInitializationService(chainCode, messageProperty); + cardInitializationService.setOnSucceeded(successEvent -> { + if(deviceOperation == DeviceOperation.IMPORT) { + AppServices.showSuccessDialog("Card Initialized", "The card was successfully initialized.\n\nYou can now import the keystore."); + } else if(deviceOperation == DeviceOperation.GET_ADDRESS) { + AppServices.showSuccessDialog("Card Reinitialized", "The card was successfully reinitialized.\n\nYou can now retrieve the new deposit address."); + } + operationButton.setDisable(false); + setDefaultStatus(); + setExpanded(false); + }); + cardInitializationService.setOnFailed(failEvent -> { + Throwable rootCause = Throwables.getRootCause(failEvent.getSource().getException()); + if(rootCause instanceof CardAuthorizationException) { + setError(rootCause.getMessage(), null); + setContent(getCardPinEntry(operationButton)); + operationButton.setDisable(false); + } else { + 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(); + }); + + HBox contentBox = new HBox(20); + contentBox.getChildren().addAll(initTypeBox, initializeButton); + contentBox.setPadding(new Insets(10, 30, 10, 30)); + HBox.setHgrow(initTypeBox, Priority.ALWAYS); + + return contentBox; + } + } private Node getCardPinEntry(ButtonBase operationButton) { diff --git a/src/main/java/com/sparrowwallet/sparrow/io/CardApi.java b/src/main/java/com/sparrowwallet/sparrow/io/CardApi.java index 1167d79b..f82b6ba3 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; @@ -30,6 +31,7 @@ public abstract class CardApi { private static final Logger log = LoggerFactory.getLogger(CardApi.class); private static File[] LINUX_PCSC_LIBS = new File[] { + new File("/usr/lib/x86_64-linux-gnu/libpcsclite.so.1"), new File("/usr/lib/libpcsclite.so.1"), new File("/usr/local/lib/libpcsclite.so.1"), new File("/lib/x86_64-linux-gnu/libpcsclite.so.1"), @@ -45,6 +47,14 @@ public abstract class CardApi { //ignore } + try { + log.error("SATOCHIP in CardApi getConnectedCards() new SatoCardApi()"); + SatoCardApi satoCardApi = new SatoCardApi(null, null); + return List.of(satoCardApi.getCardType()); + } catch(Exception e) { + //ignore + } + return Collections.emptyList(); } @@ -53,6 +63,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/satochip/APDUCommand.java b/src/main/java/com/sparrowwallet/sparrow/io/satochip/APDUCommand.java new file mode 100644 index 00000000..b645e58d --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/APDUCommand.java @@ -0,0 +1,148 @@ +package com.sparrowwallet.sparrow.io.satochip; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.*; + +/** + * ISO7816-4 APDU. + */ +public class APDUCommand { + protected int cla; + protected int ins; + protected int p1; + protected int p2; + protected int lc; + protected byte[] data; + protected boolean needsLE; + public static final String HEXES = "0123456789ABCDEF"; + + /** + * 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() throws IOException { + 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); + + 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() { + try{ + byte[] raw= this.serialize(); + if ( raw == null ) { + return ""; + } + final StringBuilder hex = new StringBuilder( 2 * raw.length ); + for ( final byte b : raw ) { + hex.append(HEXES.charAt((b & 0xF0) >> 4)).append(HEXES.charAt((b & 0x0F))); + } + return hex.toString(); + } catch (Exception e){ + return "Exception in APDUCommand.toHexString()"; + } + } + + /** + * 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..a3f136ef --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/APDUResponse.java @@ -0,0 +1,125 @@ +package com.sparrowwallet.sparrow.io.satochip; + +import java.io.*; + +/** + * 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 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; + try{ + if ( raw == null ) { + return ""; + } + final StringBuilder hex = new StringBuilder( 2 * raw.length ); + for ( final byte b : raw ) { + hex.append(HEXES.charAt((b & 0xF0) >> 4)) + .append(HEXES.charAt((b & 0x0F))); + } + return hex.toString(); + } catch(Exception e){ + return "Exception in APDUResponse.toHexString()"; + } + } +} 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..16c57723 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/Constants.java @@ -0,0 +1,149 @@ +package com.sparrowwallet.sparrow.io.satochip; + +public final class Constants { + + // Prevents instanciation of class + private Constants() {} + + /**************************************** + * Instruction codes * + ****************************************/ + public final static byte CLA = (byte)0xB0; + // Applet initialization + public final static byte INS_SETUP = (byte) 0x2A; + // Keys' use and management + public final static byte INS_IMPORT_KEY = (byte) 0x32; + public final static byte INS_RESET_KEY = (byte) 0x33; + public final static byte INS_GET_PUBLIC_FROM_PRIVATE= (byte)0x35; + // External authentication + public final static byte INS_CREATE_PIN = (byte) 0x40; //TODO: remove? + public final static byte INS_VERIFY_PIN = (byte) 0x42; + public final static byte INS_CHANGE_PIN = (byte) 0x44; + public final static byte INS_UNBLOCK_PIN = (byte) 0x46; + public final static byte INS_LOGOUT_ALL = (byte) 0x60; + // Status information + public final static byte INS_LIST_PINS = (byte) 0x48; + public final static byte INS_GET_STATUS = (byte) 0x3C; + public final static byte INS_CARD_LABEL = (byte) 0x3D; + // HD wallet + public final static byte INS_BIP32_IMPORT_SEED= (byte) 0x6C; + public final static byte INS_BIP32_RESET_SEED= (byte) 0x77; + public final static byte INS_BIP32_GET_AUTHENTIKEY= (byte) 0x73; + public final static byte INS_BIP32_SET_AUTHENTIKEY_PUBKEY= (byte)0x75; + public final static byte INS_BIP32_GET_EXTENDED_KEY= (byte) 0x6D; + public final static byte INS_BIP32_SET_EXTENDED_PUBKEY= (byte) 0x74; + public final static byte INS_SIGN_MESSAGE= (byte) 0x6E; + public final static byte INS_SIGN_SHORT_MESSAGE= (byte) 0x72; + public final static byte INS_SIGN_TRANSACTION= (byte) 0x6F; + public final static byte INS_PARSE_TRANSACTION = (byte) 0x71; + public final static byte INS_CRYPT_TRANSACTION_2FA = (byte) 0x76; + public final static byte INS_SET_2FA_KEY = (byte) 0x79; + public final static byte INS_RESET_2FA_KEY = (byte) 0x78; + public final static byte INS_SIGN_TRANSACTION_HASH= (byte) 0x7A; + // secure channel + public final static byte INS_INIT_SECURE_CHANNEL = (byte) 0x81; + public final static byte INS_PROCESS_SECURE_CHANNEL = (byte) 0x82; + // secure import from SeedKeeper + public final static byte INS_IMPORT_ENCRYPTED_SECRET = (byte) 0xAC; + public final static byte INS_IMPORT_TRUSTED_PUBKEY = (byte) 0xAA; + public final static byte INS_EXPORT_TRUSTED_PUBKEY = (byte) 0xAB; + public final static byte INS_EXPORT_AUTHENTIKEY= (byte) 0xAD; + // Personalization PKI support + public final static byte INS_IMPORT_PKI_CERTIFICATE = (byte) 0x92; + public final static byte INS_EXPORT_PKI_CERTIFICATE = (byte) 0x93; + public final static byte INS_SIGN_PKI_CSR = (byte) 0x94; + public final static byte INS_EXPORT_PKI_PUBKEY = (byte) 0x98; + public final static byte INS_LOCK_PKI = (byte) 0x99; + public final static byte INS_CHALLENGE_RESPONSE_PKI= (byte) 0x9A; + // reset to factory settings + public final static byte INS_RESET_TO_FACTORY = (byte) 0xFF; + + /**************************************** + * Error codes * + ****************************************/ + + /** Entered PIN is not correct */ + public final static short SW_PIN_FAILED = (short)0x63C0;// includes number of tries remaining + ///** DEPRECATED - Entered PIN is not correct */ + //public final static short SW_AUTH_FAILED = (short) 0x9C02; + /** Required operation is not allowed in actual circumstances */ + public final static short SW_OPERATION_NOT_ALLOWED = (short) 0x9C03; + /** Required setup is not not done */ + public final static short SW_SETUP_NOT_DONE = (short) 0x9C04; + /** Required setup is already done */ + public final static short SW_SETUP_ALREADY_DONE = (short) 0x9C07; + /** Required feature is not (yet) supported */ + final static short SW_UNSUPPORTED_FEATURE = (short) 0x9C05; + /** Required operation was not authorized because of a lack of privileges */ + public final static short SW_UNAUTHORIZED = (short) 0x9C06; + /** Algorithm specified is not correct */ + public final static short SW_INCORRECT_ALG = (short) 0x9C09; + + /** There have been memory problems on the card */ + public final static short SW_NO_MEMORY_LEFT = (short) 0x9C01; + ///** DEPRECATED - Required object is missing */ + //public final static short SW_OBJECT_NOT_FOUND= (short) 0x9C07; + + /** Incorrect P1 parameter */ + public final static short SW_INCORRECT_P1 = (short) 0x9C10; + /** Incorrect P2 parameter */ + public final static short SW_INCORRECT_P2 = (short) 0x9C11; + /** Invalid input parameter to command */ + public final static short SW_INVALID_PARAMETER = (short) 0x9C0F; + + /** Eckeys initialized */ + public final static short SW_ECKEYS_INITIALIZED_KEY = (short) 0x9C1A; + + /** Verify operation detected an invalid signature */ + public final static short SW_SIGNATURE_INVALID = (short) 0x9C0B; + /** Operation has been blocked for security reason */ + public final static short SW_IDENTITY_BLOCKED = (short) 0x9C0C; + /** For debugging purposes */ + public final static short SW_INTERNAL_ERROR = (short) 0x9CFF; + /** Very low probability error */ + public final static short SW_BIP32_DERIVATION_ERROR = (short) 0x9C0E; + /** Incorrect initialization of method */ + public final static short SW_INCORRECT_INITIALIZATION = (short) 0x9C13; + /** Bip32 seed is not initialized*/ + public final static short SW_BIP32_UNINITIALIZED_SEED = (short) 0x9C14; + /** Bip32 seed is already initialized (must be reset before change)*/ + public final static short SW_BIP32_INITIALIZED_SEED = (short) 0x9C17; + //** DEPRECATED - Bip32 authentikey pubkey is not initialized*/ + //public final static short SW_BIP32_UNINITIALIZED_AUTHENTIKEY_PUBKEY= (short) 0x9C16; + /** Incorrect transaction hash */ + public final static short SW_INCORRECT_TXHASH = (short) 0x9C15; + + /** 2FA already initialized*/ + public final static short SW_2FA_INITIALIZED_KEY = (short) 0x9C18; + /** 2FA uninitialized*/ + public final static short SW_2FA_UNINITIALIZED_KEY = (short) 0x9C19; + + /** HMAC errors */ + static final short SW_HMAC_UNSUPPORTED_KEYSIZE = (short) 0x9c1E; + static final short SW_HMAC_UNSUPPORTED_MSGSIZE = (short) 0x9c1F; + + /** Secure channel */ + public final static short SW_SECURE_CHANNEL_REQUIRED = (short) 0x9C20; + public final static short SW_SECURE_CHANNEL_UNINITIALIZED = (short) 0x9C21; + public final static short SW_SECURE_CHANNEL_WRONG_IV= (short) 0x9C22; + public final static short SW_SECURE_CHANNEL_WRONG_MAC= (short) 0x9C23; + + /** Secret data is too long for import **/ + public final static short SW_IMPORTED_DATA_TOO_LONG = (short) 0x9C32; + /** Wrong HMAC when importing Secret through Secure import **/ + public final static short SW_SECURE_IMPORT_WRONG_MAC = (short) 0x9C33; + /** Wrong Fingerprint when importing Secret through Secure import **/ + public final static short SW_SECURE_IMPORT_WRONG_FINGERPRINT = (short) 0x9C34; + /** No Trusted Pubkey when importing Secret through Secure import **/ + public final static short SW_SECURE_IMPORT_NO_TRUSTEDPUBKEY = (short) 0x9C35; + + /** PKI perso error */ + public final static short SW_PKI_ALREADY_LOCKED = (short) 0x9C40; + /** CARD HAS BEEN RESET TO FACTORY */ + public final static short SW_RESET_TO_FACTORY = (short) 0xFF00; + /** For instructions that have been deprecated*/ + public final static short SW_INS_DEPRECATED = (short) 0x9C26; + /** For debugging purposes 2 */ + public final static short SW_DEBUG_FLAG = (short) 0x9FFF; + +} 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..dfbdaddf --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/KeyPath.java @@ -0,0 +1,102 @@ +package com.sparrowwallet.sparrow.io.satochip; + +import java.util.StringTokenizer; + +/** + * Keypath object to be used with the SatochipCommandSet + */ +public class KeyPath { + private 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..8641026e --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/SatoCardApi.java @@ -0,0 +1,560 @@ +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.IntegerProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.beans.property.SimpleStringProperty; +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 SatochipCommandSet cardProtocol; + private String pin; + private String basePath = null; // = "m/84'/1'/0'" + + public SatoCardApi(WalletModel cardType, String pin) throws CardException { + log.debug("SATOCHIP SatoCardApi() cardType: " + cardType); + this.cardType = cardType; + this.cardProtocol = new SatochipCommandSet(); + this.pin = pin; + } + + @Override + public boolean isInitialized() throws CardException { + log.debug("SATOCHIP SatoCardApi isInitialized() START"); + SatoCardStatus cardStatus = this.getStatus(); + return cardStatus.isInitialized(); // setupDone && isSeeded + } + + //TODO + @Override + public void initialize(int slot, byte[] seedBytes) throws CardException { + log.debug("SATOCHIP SatoCardApi initialize() START"); + // 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 'getCurrentSlot' !"); + } + + //TODO + @Override + public ScriptType getDefaultScriptType() { + return ScriptType.P2WPKH; + } + + SatoCardStatus getStatus() throws CardException { + log.debug("SATOCHIP SatoCardApi getStatus() START"); + SatoCardStatus cardStatus = this.cardProtocol.getApplicationStatus(); + return cardStatus; + } + + @Override + public Service getAuthDelayService() throws CardException { + log.debug("SATOCHIP SatoCardApi getAuthDelayService() START"); + return null; + } + + @Override + public boolean requiresBackup() throws CardException { + log.debug("SATOCHIP SatoCardApi requiresBackup() START"); + return false; // todo? + } + + @Override + public Service getBackupService() { + log.debug("SATOCHIP SatoCardApi getBackupService() START"); + return null; //new BackupService(); + } + + @Override + public boolean changePin(String newPin) throws CardException { + log.debug("SATOCHIP SatoCardApi changePin() START"); + try{ + this.cardProtocol.cardChangePIN((byte)0, this.pin, newPin); + return true; + } catch(Exception e) { + log.error("SATOCHIP SatoCardApi changePin() exception: " + e); + return false; + } + } + + // TODO: remove? + void setDerivation(List derivation) throws CardException { + log.debug("SATOCHIP SatoCardApi setDerivation() START"); + // convert to string representation + String derivationString = "m"; + for(int i=0;i getInitializationService(byte[] seedBytes, StringProperty messageProperty) { + log.debug("SATOCHIP SatoCardApi getInitializationService() START"); + return new CardInitializationService(seedBytes, messageProperty); + + } + + @Override + public Service getImportService(List derivation, StringProperty messageProperty) { + log.debug("SATOCHIP SatoCardApi getImportService() START"); + log.debug("SATOCHIP SatoCardApi getImportService() derivation: " + derivation); + return new CardImportPane.CardImportService(new Satochip(), pin, derivation, messageProperty); + } + + /* todo: provide derivation path? + * 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 { + log.debug("SATOCHIP SatoCardApi getKeystore() START"); + + APDUResponse rapdu = this.cardProtocol.cardVerifyPIN(0, pin); + log.debug("SATOCHIP SatoCardApi getKeystore() cardVerifyPIN rapdu: " + Utils.bytesToHex(rapdu.getBytes())); + + String keyDerivationString = (this.basePath != null)? this.basePath : "m/84'/1'/0'"; + log.debug("SATOCHIP SatoCardApi getKeystore() keyDerivationString: " + keyDerivationString); + + ExtendedKey.Header xtype = Network.get().getXpubHeader(); + String xpub = this.cardProtocol.cardBip32GetXpub(keyDerivationString, xtype); + log.debug("SATOCHIP SatoCardApi getKeystore() xpub: " + xpub); + + ExtendedKey extendedKey = ExtendedKey.fromDescriptor(xpub); + log.debug("SATOCHIP SatoCardApi getKeystore() extendedKey: " + extendedKey); + + String masterFingerprint = Utils.bytesToHex(extendedKey.getKey().getFingerprint()); + log.debug("SATOCHIP SatoCardApi getKeystore() masterFingerprint: " + masterFingerprint); + + KeyDerivation keyDerivation = new KeyDerivation(masterFingerprint, keyDerivationString); + log.debug("SATOCHIP SatoCardApi getKeystore() keyDerivation: " + keyDerivation); + + Keystore keystore = new Keystore(); + keystore.setLabel(WalletModel.SATOCHIP.toDisplayString()); + keystore.setKeyDerivation(keyDerivation); + keystore.setSource(KeystoreSource.HW_USB); + keystore.setExtendedPublicKey(extendedKey); + keystore.setWalletModel(WalletModel.SATOCHIP); + log.debug("SATOCHIP SatoCardApi getKeystore() keystore: " + keystore); + return keystore; + } + + @Override + public Service getSignService(Wallet wallet, PSBT psbt, StringProperty messageProperty) { + log.debug("SATOCHIP SatoCardApi getSignService() START"); + log.debug("SATOCHIP SatoCardApi getSignService() wallet: " + wallet); + //log.debug("SATOCHIP SatoCardApi getSignService() psbt: " + psbt); + return new SignService(wallet, psbt, messageProperty); + } + + void sign(Wallet wallet, PSBT psbt) throws CardException { + log.debug("SATOCHIP SatoCardApi sign() START"); + log.debug("SATOCHIP SatoCardApi sign() wallet: " + wallet); + log.debug("SATOCHIP SatoCardApi sign() psbt: " + psbt); + + // debug psbt + /*log.debug("SATOCHIP SatoCardApi sign() psbt.hasSignatures(): " + psbt.hasSignatures()); + log.debug("SATOCHIP SatoCardApi sign() psbt.isSigned(): " + psbt.isSigned()); + log.debug("SATOCHIP SatoCardApi sign() psbt.isFinalized(): " + psbt.isFinalized()); + log.debug("SATOCHIP SatoCardApi sign() psbt.verifySignatures:");*/ + /*try{ + psbt.verifySignatures(); + log.debug("SATOCHIP SatoCardApi sign() psbt signature verified!"); + } catch(Exception e) { + log.debug("SATOCHIP SatoCardApi sign() failed to verify signatures with error: " + e); + } + log.debug("SATOCHIP SatoCardApi sign() psbt.parse(false):"); + try{ + psbt.parse(false); + log.debug("SATOCHIP SatoCardApi sign() psbt.parse(false) finished!"); + } catch(Exception e) { + log.debug("SATOCHIP SatoCardApi sign() failed psbt.parse(false) with error: " + e); + } + log.debug("SATOCHIP SatoCardApi sign() psbt.parse(false) end:"); + log.debug("SATOCHIP SatoCardApi sign() psbt.parse(true):"); + try{ + psbt.parse(true); + log.debug("SATOCHIP SatoCardApi sign() psbt.parse(true) finished!"); + } catch(Exception e) { + log.debug("SATOCHIP SatoCardApi sign() failed psbt.parse(true) with error: " + e); + } + log.debug("SATOCHIP SatoCardApi sign() psbt.parse(true) end:");*/ + // endbug psbt + + Map signingNodes = wallet.getSigningNodes(psbt); + //log.debug("SATOCHIP SatoCardApi sign() signingNodes: " + signingNodes); + for(PSBTInput psbtInput : psbt.getPsbtInputs()) { + if(!psbtInput.isSigned()) { + WalletNode signingNode = signingNodes.get(psbtInput); + log.debug("SATOCHIP SatoCardApi sign() signingNode: " + signingNode); + log.debug("SATOCHIP SatoCardApi sign() signingNode.getDerivationPath(): " + signingNode.getDerivationPath()); // m/0/0 + try { + /*// debug + Map mapPubKey = psbtInput.getDerivedPublicKeys(); + log.debug("SATOCHIP SatoCardApi sign() mapPubKey.size(): " + mapPubKey.size()); + log.debug("SATOCHIP SatoCardApi sign() mapPubKey: " + mapPubKey); + for (Map.Entry entry : mapPubKey.entrySet()) { + ECKey key = entry.getKey(); + KeyDerivation value = entry.getValue(); + log.debug("SATOCHIP SatoCardApi sign() mapPubKey pubkey: " + Utils.bytesToHex(key.getPubKey())); + log.debug("SATOCHIP SatoCardApi sign() mapPubKey derivation: " + value.getDerivationPath()); + } + log.debug("SATOCHIP SatoCardApi sign() mapPubKey END"); + // endbug*/ + + String fullPath= null; + List keystores = wallet.getKeystores(); + log.debug("SATOCHIP SatoCardApi sign() keystores.size(): " + keystores.size()); + for(int i=0;i getSignMessageService(String message, ScriptType scriptType, List derivation, StringProperty messageProperty) { + log.debug("SATOCHIP SatoCardApi getSignMessageService() START"); + return new SignMessageService(message, scriptType, derivation, messageProperty); + } + + String signMessage(String message, ScriptType scriptType, List derivation) throws CardException { + log.debug("SATOCHIP SatoCardApi signMessage() START"); + log.debug("SATOCHIP SatoCardApi signMessage() message: " + message); + log.debug("SATOCHIP SatoCardApi signMessage() scriptType: " + scriptType); + log.debug("SATOCHIP SatoCardApi signMessage() derivation: " + derivation); + String fullpath = KeyDerivation.writePath(derivation); + log.debug("SATOCHIP SatoCardApi signMessage() fullpath: " + fullpath); + + try { + APDUResponse rapdu0 = 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 with 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]); + log.debug("SATOCHIP SatoCardApi signMessage() pubkey: " + Utils.bytesToHex(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(); + log.debug("SATOCHIP SatoCardApi sign() sigBytes: " + Utils.bytesToHex(sigBytes)); + ECDSASignature ecdsaSig = ECDSASignature.decodeFromDER(sigBytes); + return ecdsaSig; + } catch(Exception e) { + throw new RuntimeException(e); + } + }); + } finally { + // + } + } + + @Override + public Service getPrivateKeyService(Integer slot, StringProperty messageProperty) { + log.debug("SATOCHIP SatoCardApi getPrivateKeyService() START"); + throw new RuntimeException("Satochip does not support private key export!"); + } + + // todo: remove? + ECKey getPrivateKey(int slot, int currentSlot) throws CardException { + log.debug("SATOCHIP SatoCardApi getPrivateKey() START"); + throw new CardException("Satochip does not support 'getPrivateKey'!"); + } + + @Override + public Service
getAddressService(StringProperty messageProperty) { + log.debug("SATOCHIP SatoCardApi getAddressService() START"); + // throw new runtimeException("Satochip does not support 'getAddress' currently!"); + return null; //new AddressService(messageProperty); + } + + // todo: remove? + Address getAddress(int currentSlot, int lastSlot, String addr) throws CardException { + log.debug("SATOCHIP SatoCardApi getAddress() START"); + throw new CardException("Satochip does not support 'getAddress' currently!"); + } + + @Override + public void disconnect() { + log.debug("SATOCHIP SatoCardApi disconnect() START"); + 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 { + log.debug("SATOCHIP CardInitializationService createTask() START"); + if (seedBytes == null){ + // will show error message to user + log.debug("SATOCHIP CardInitializationService createTask() Error: seedBytes is null"); + throw new Exception("Failed to initialize Satochip: " + messageProperty.get()); + } + + 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 { + log.debug("SATOCHIP SatoCardApi.SignService createTask() START"); + 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) { + log.debug("SATOCHIP SatoCardApi.CardPSBTInputSigner sign() START"); + log.debug("SATOCHIP SatoCardApi.CardPSBTInputSigner sign() hash:" + hash); + log.debug("SATOCHIP SatoCardApi.CardPSBTInputSigner sign() sigHash:" + sigHash); + log.debug("SATOCHIP SatoCardApi.CardPSBTInputSigner sign() signatureType:" + signatureType); + + try { + log.debug("SATOCHIP SatoCardApi.CardPSBTInputSigner sign() fullPath:" + this.fullPath); + log.debug("SATOCHIP SatoCardApi.CardPSBTInputSigner sign() signingNode.getDerivationPath():" + signingNode.getDerivationPath()); + + // 2FA is optionnal, 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 with 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]); + log.debug("SATOCHIP SatoCardApi.CardPSBTInputSigner sign() pubkey: " + Utils.bytesToHex(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(); + log.debug("SATOCHIP SatoCardApi.CardPSBTInputSigner sign() sigBytes: " + Utils.bytesToHex(sigBytes)); + ECDSASignature ecdsaSig = ECDSASignature.decodeFromDER(sigBytes).toCanonicalised(); + TransactionSignature txSig = new TransactionSignature(ecdsaSig, sigHash); + + // verify + boolean isCorrect = pubkey.verify(hash, txSig); + log.debug("SATOCHIP SatoCardApi.CardPSBTInputSigner sign() ECDSA verify with pubkey: " + isCorrect); + + 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 RuntimeException(WalletModel.SATOCHIP.toDisplayString() + " (with version below v0.14) cannot sign " + signatureType + " transactions!"); + } + + // tweak the bip32 key according to bip341 + byte keynbr = (byte)0xFF; + byte[] tweak = null; + APDUResponse rapduTweak = cardProtocol.cardTaprootTweakPrivkey(keynbr, tweak); + log.debug("SATOCHIP SatoCardApi.CardPSBTInputSigner sign() cardTaprootTweakPrivkey(): " + rapduTweak.toHexString()); + 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(); + log.debug("SATOCHIP SatoCardApi.CardPSBTInputSigner sign() SCHNORR sigBytes: " + Utils.bytesToHex(sigBytes)); + SchnorrSignature schnorrSig = SchnorrSignature.decode(sigBytes); + TransactionSignature txSig = new TransactionSignature(schnorrSig, sigHash); + + // verify sig with outputPubkey... + boolean isCorrect2 = pubkey.verify(hash, txSig); + log.debug("SATOCHIP SatoCardApi.CardPSBTInputSigner sign() SCHNORR verify with outputPubkey: " + isCorrect2); + + return txSig; //new TransactionSignature(schnorrSig, sigHash); + } + + } catch(Exception e) { + e.printStackTrace(); + log.error("SATOCHIP SatoCardApi.CardPSBTInputSigner sign() 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 { + log.debug("SATOCHIP SatoCardApi.SignMessageService createTask() message: " + message); + log.debug("SATOCHIP SatoCardApi.SignMessageService createTask() scriptType: " + scriptType); + log.debug("SATOCHIP SatoCardApi.SignMessageService createTask() derivation: " + derivation); + 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..82d708d6 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/SatoCardStatus.java @@ -0,0 +1,124 @@ +package com.sparrowwallet.sparrow.io.satochip; + +//import org.satochip.io.APDUResponse; + +/** + * 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 tlvData 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)? false : true; + } + if (data.length >=10){ + is_seeded= (data[9]==0X00)? false : true; + } + if (data.length >=11){ + setup_done= (data[10]==0X00)? false : true; + } else { + setup_done= true; + } + if (data.length >=12){ + needs_secure_channel= (data[11]==0X00)? false : true; + } 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{ + //throws IllegalArgumentException("Wrong getStatus data!"); // should not happen + } + } + + // 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; + } + + // TODO: other gettters + 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; + } + + public String toString(){ + String status_info= "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; + return status_info; + } + +} 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..d68af463 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/SatoCardTransport.java @@ -0,0 +1,73 @@ +package com.sparrowwallet.sparrow.io.satochip; + +import com.sparrowwallet.drongo.Utils; +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 static final int SW_OKAY = 0x9000; + + private final Card connection; + + SatoCardTransport(byte[] applet_aid) 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(), applet_aid); + break; + } catch(CardException e) { + if(!iter.hasNext()) { + log.error(e.getMessage()); + throw e; + } + } + } + + this.connection = connection; + } + + private Card getConnection(CardTerminal cardTerminal, byte[] applet_aid) throws CardException { + Card connection = cardTerminal.connect("*"); + + CardChannel cardChannel = connection.getBasicChannel(); + ResponseAPDU resp = cardChannel.transmit(new CommandAPDU(0, 0xA4, 4, 0, applet_aid)); + if(resp.getSW() != SW_OKAY) { + 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(); + + // todo: convert APDUCommand to CommansdApdu?? + log.trace("SATOCHIP SatoCardTransport send capdu:" + capdu.toHexString()); + CommandAPDU cmd = new CommandAPDU(capdu.getCla(), capdu.getIns(), capdu.getP1(), capdu.getP2(), capdu.getData()); + ResponseAPDU resp = cardChannel.transmit(cmd); + + // convert back to APDUResponse... (todo?) + APDUResponse rapdu = new APDUResponse(resp.getBytes()); + log.trace("SATOCHIP SatoCardTransport send rapdu:" + rapdu.toHexString()); + return rapdu; + } + + 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..706aba1f --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/Satochip.java @@ -0,0 +1,110 @@ +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.SimpleIntegerProperty; +import javafx.beans.property.StringProperty; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.smartcardio.CardException; +import java.util.List; + +public class Satochip implements KeystoreCardImport { + private static final Logger log = LoggerFactory.getLogger(Satochip.class); + + @Override + public boolean isInitialized() throws CardException { + log.debug("SATOCHIP Satochip isInitialized()"); + 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[] chainCode, StringProperty messageProperty) throws CardException { + log.debug("SATOCHIP Satochip initialize()"); + 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."); + } + // TODO! + // not used currently + // initialization is done through SatoCardApi.initialize() + } finally { + if(cardApi != null) { + cardApi.disconnect(); + } + } + } + + @Override + public Keystore getKeystore(String pin, List derivation, StringProperty messageProperty) throws ImportException { + log.debug("SATOCHIP Satochip getKeystore() derivation:" + derivation); + 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) { + e.printStackTrace(); + log.error("SATOCHIP Satochip getKeystore() Exception: " + e); + throw new ImportException(e); + } finally { + if(cardApi != null) { + cardApi.disconnect(); + } + } + } + + @Override + public String getKeystoreImportDescription(int account) { + log.debug("SATOCHIP Satochip getKeystoreImportDescription()"); + return "Import the keystore from your Satochip by inserting it in the card reader."; + } + + @Override + public String getName() { + log.debug("SATOCHIP Satochip getName()"); + return "Satochip"; + } + + @Override + public WalletModel getWalletModel() { + log.debug("SATOCHIP Satochip 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..c53cfd3b --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/SatochipCommandSet.java @@ -0,0 +1,591 @@ +package com.sparrowwallet.sparrow.io.satochip; + +import com.sparrowwallet.drongo.ExtendedKey; +import com.sparrowwallet.drongo.Network; +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.protocol.Base58; + +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 SecureChannelSession secureChannel; + private SatoCardStatus status; + private SatochipParser parser=null; + + private String pinCached = null; + private byte[] authentikey= null; + private String authentikeyHex= null; + private String defaultBip32path= null; + + // Satodime, SeedKeeper or Satochip? + private String cardType= null; + private String certPem= null; // PEM certificate of device, if any + + public static final byte[] SATOCHIP_AID = Utils.hexToBytes("5361746f43686970"); //SatoChip + + /** + * Creates a SatochipCommandSet using the given APDU Channel + * @param apduChannel 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) { + APDUResponse rapdu = this.cardGetStatus(); + } + return this.status; + } + + /**************************************** + * AUTHENTIKEY * + ****************************************/ + + public APDUResponse cardTransmit(APDUCommand plainApdu) { + log.trace("SATOCHIP: SatochipCommandSet cardTransmit() START"); + + // 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){ + log.trace("SATOCHIP: SatochipCommandSet cardTransmit() get cardStatus..."); + APDUCommand statusCapdu = new APDUCommand(0xB0, INS_GET_STATUS, 0x00, 0x00, new byte[0]); + APDUResponse statusRapdu = this.cardTransport.send(statusCapdu); + status= new SatoCardStatus(statusRapdu); + log.trace("SATOCHIP: SatochipCommandSet cardTransmit() status: " + status.toString()); + } + + APDUCommand capdu=plainApdu; + if (status.needsSecureChannel() && (ins!=0xA4) && (ins!=0x81) && (ins!=0x82) && (ins!=INS_GET_STATUS)){ + log.trace("SATOCHIP: SatochipCommandSet cardTransmit() needsSecureChannel..."); + if (!secureChannel.initializedSecureChannel()){ + log.trace("SATOCHIP: SatochipCommandSet cardTransmit() initiateSecureChannel..."); + // get card's public key + APDUResponse secChannelRapdu= this.cardInitiateSecureChannel(); + byte[] pubkey= this.parser.parseInitiateSecureChannel(secChannelRapdu); + // setup secure channel + this.secureChannel.initiateSecureChannel(pubkey); + log.trace("SATOCHIP: SatochipCommandSet cardTransmit() secureChannel initiated cardPubkey: " + Utils.bytesToHex(pubkey)); + } + // encrypt apdu + log.trace("SATOCHIP: SatochipCommandSet cardTransmit() capdu plain: " + plainApdu.toHexString()); + capdu= secureChannel.encrypt_secure_channel(plainApdu); + isEncrypted=true; + log.trace("SATOCHIP: SatochipCommandSet cardTransmit() capdu cipher: " + capdu.toHexString()); + } + + APDUResponse rapdu = this.cardTransport.send(capdu); + int sw12= rapdu.getSw() ; + + // check answer + if (sw12==0x9000){ // ok! + if (isEncrypted){ + // decrypt + //log.info("SATOCHIP Rapdu encrypted:"+ rapdu.toHexString()); + rapdu = secureChannel.decrypt_secure_channel(rapdu); + //log.info("SATOCHIP Rapdu decrypted:"+ rapdu.toHexString()); + } + isApduTransmitted= true; // leave loop + return rapdu; + } + // PIN authentication is required + else if (sw12==0x9C06){ + //cardVerifyPIN(); + log.error("SATOCHIP: SatochipCommandSet cardTransmit() sw12==0x9C06: PIN required!"); + //TODO: throw? + //TODO: verify PIN? + } + // SecureChannel is not initialized + else if (sw12==0x9C21){ + log.error("SATOCHIP: SatochipCommandSet cardTransmit() sw12==0x9C21: secureChannel required!"); + secureChannel.resetSecureChannel(); + } + else { + // cannot resolve issue at this point + isApduTransmitted= true; // leave loop + return rapdu; + } + + } catch(Exception e) { + log.warn("SATOCHIP: SatochipCommandSet cardTransmit() Exception: "+ 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("SATOCHIP SatochipCommandSet cardDisconnect() Exception: " + e); + } + } + + public APDUResponse cardGetStatus() { + APDUCommand plainApdu = new APDUCommand(0xB0, INS_GET_STATUS, 0x00, 0x00, new byte[0]); + + log.trace("SATOCHIP SatochipCommandSet cardGetStatus() capdu: "+ plainApdu.toHexString()); + APDUResponse respApdu = this.cardTransmit(plainApdu); + log.trace("SATOCHIP SatochipCommandSet cardGetStatus() rapdu: "+ respApdu.toHexString()); + + this.status= new SatoCardStatus(respApdu); + log.debug("SATOCHIP SatochipCommandSet cardGetStatus(): "+ this.status.toString()); + + return respApdu; + } + + public APDUResponse cardInitiateSecureChannel() throws CardException{ + + byte[] pubkey= secureChannel.getPublicKey(); + + APDUCommand plainApdu = new APDUCommand(0xB0, INS_INIT_SECURE_CHANNEL, 0x00, 0x00, pubkey); + + log.trace("SATOCHIP SatochipCommandSet cardInitiateSecureChannel capdu: "+ plainApdu.toHexString()); + APDUResponse respApdu = this.cardTransport.send(plainApdu); + log.trace("SATOCHIP SatochipCommandSet cardInitiateSecureChannel rapdu: "+ respApdu.toHexString()); + + return respApdu; + } + + /**************************************** + * CARD MGMT * + ****************************************/ + + public APDUResponse cardSetup(byte pin_tries0, byte[] pin0){ + log.debug("SATOCHIP SatochipCommandSet cardSetup()"); + // 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){ + log.debug("SATOCHIP SatochipCommandSet cardSetup()"); + + 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); + log.trace("SATOCHIP SatochipCommandSet cardSetup capdu: "+ plainApdu.toHexString()); + APDUResponse respApdu = this.cardTransmit(plainApdu); + log.trace("SATOCHIP SatochipCommandSet cardSetup rapdu:"+ respApdu.toHexString()); + + if (respApdu.getSw() == 0x9000){ + //setPin0(pin0); // todo: cache value... + } else { + log.error("SATOCHIP SatochipCommandSet cardSetup error:"+ respApdu.toHexString()); + } + + return respApdu; + } + + + /**************************************** + * PIN MGMT * + ****************************************/ + + public APDUResponse cardVerifyPIN() { + return this.cardVerifyPIN((byte)0, pinCached); + } + + public APDUResponse cardVerifyPIN(int pinNbr, String pin) { + log.debug("SATOCHIP SatochipCommandSet cardVerifyPIN()"); + if (pin == null){ + if (pinCached == null){ + // TODO: specific exception + throw new RuntimeException("PIN required!"); + } + pin = this.pinCached; + } + byte[] pinBytes = pin.getBytes(StandardCharsets.UTF_8); + + APDUCommand capdu = new APDUCommand(0xB0, INS_VERIFY_PIN, (byte)pinNbr, 0x00, pinBytes); + log.trace("SATOCHIP SatochipCommandSet cardVerifyPIN() capdu:"+ capdu.toHexString()); + APDUResponse rapdu = this.cardTransmit(capdu); + log.trace("SATOCHIP SatochipCommandSet cardVerifyPIN() rapdu: "+ rapdu.toHexString()); + + // 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 RuntimeException("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 RuntimeException("Wrong PIN, remaining tries: " + pinLeft); + } + // blocked PIN + else if (sw == 0x9c0c){ + throw new RuntimeException("Card is blocked!"); + } + return rapdu; + } + + public APDUResponse cardChangePIN(int pinNbr, String oldPin, String newPin) { + log.debug("SATOCHIP SatochipCommandSet cardChangePIN()"); + 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); + log.trace("SATOCHIP SatochipCommandSet cardChangePIN() capdu: "+ capdu.toHexString()); + APDUResponse rapdu = this.cardTransmit(capdu); + log.trace("SATOCHIP SatochipCommandSet cardChangePIN() rapdu: "+ rapdu.toHexString()); + + // 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 RuntimeException("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 RuntimeException("Wrong PIN, remaining tries: " + pinLeft); + } + // blocked PIN + else if (sw == 0x9c0c){ + throw new RuntimeException("Card is blocked!"); + } + return rapdu; + } + + /**************************************** + * BIP32 * + ****************************************/ + + public APDUResponse cardBip32ImportSeed(byte[] masterseed){ + //TODO: check seed (length...) + APDUCommand plainApdu = new APDUCommand(0xB0, INS_BIP32_IMPORT_SEED, masterseed.length, 0x00, masterseed); + + log.trace("SATOCHIP SatochipCommandSet cardBip32ImportSeed capdu: "+ plainApdu.toHexString()); + APDUResponse respApdu = this.cardTransmit(plainApdu); + log.trace("SATOCHIP SatochipCommandSet cardBip32ImportSeed rapdu: "+ respApdu.toHexString()); + + return respApdu; + } + + public APDUResponse cardBip32GetExtendedKey(String stringPath){ + log.debug("SATOCHIP SatochipCommandSet cardBip32GetExtendedKey() stringPath: " + stringPath); + KeyPath keyPath = new KeyPath(stringPath); + byte[] bytePath = keyPath.getData(); + //log.trace("SATOCHIP SatochipCommandSet cardBip32GetExtendedKey() bytePath: " + Utils.bytesToHex(bytePath)); + return cardBip32GetExtendedKey(bytePath); + } + + public APDUResponse cardBip32GetExtendedKey(byte[] bytePath){ + log.debug("SATOCHIP SatochipCommandSet cardBip32GetExtendedKey() bytePath: " + Utils.bytesToHex(bytePath)); + byte p1= (byte) (bytePath.length/4); + + APDUCommand plainApdu = new APDUCommand(0xB0, INS_BIP32_GET_EXTENDED_KEY, p1, 0x40, bytePath); + log.trace("SATOCHIP SatochipCommandSet cardBip32GetExtendedKey() capdu: "+ plainApdu.toHexString()); + APDUResponse respApdu = this.cardTransmit(plainApdu); + log.trace("SATOCHIP SatochipCommandSet cardBip32GetExtendedKey() rapdu: "+ respApdu.toHexString()); + // TODO: check SW code for particular status + + // TODO: parse apdu to extract data? + + return respApdu; + } + + /* + * 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){ + + // path is of the form 44'/0'/1' + log.debug("SATOCHIP SatochipCommandSet cardBip32GetXpub() path: " + stringPath); + KeyPath keyPath = new KeyPath(stringPath); + byte[] bytePath = keyPath.getData(); + int depth = bytePath.length/4; + log.debug("SATOCHIP SatochipCommandSet cardBip32GetXpub() bytePath: " + Utils.bytesToHex(bytePath)); + + 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); + } + + log.debug("SATOCHIP SatochipCommandSet cardBip32GetXpub() xtype: " + xtype); + log.debug("SATOCHIP SatochipCommandSet cardBip32GetXpub() Network.get().getXpubHeader(): " + Network.get().getXpubHeader()); + + 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(); + log.debug("SATOCHIP SatochipCommandSet cardBip32GetXpub() xpubByte: " + Utils.bytesToHex(xpubByte)); + + String xpub = Base58.encodeChecked(xpubByte); + log.debug("SATOCHIP SatochipCommandSet cardBip32GetXpub() xpub: " + xpub); + return xpub; + } + + /**************************************** + * SIGNATURES * + ****************************************/ + + public APDUResponse cardSignTransactionHash(byte keynbr, byte[] txhash, byte[] chalresponse){ + log.debug("SATOCHIP SatochipCommandSet cardSignTransactionHash()"); + byte[] data; + if (txhash.length !=32){ + throw new RuntimeException("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 { + log.error("SATOCHIP SatochipCommandSet cardSignTransactionHash() error: " + "Wrong challenge-response length (should be 20)"); + throw new RuntimeException("Wrong challenge-response length (should be 20)"); + } + APDUCommand plainApdu = new APDUCommand(0xB0, INS_SIGN_TRANSACTION_HASH, keynbr, 0x00, data); + + log.trace("SATOCHIP SatochipCommandSet cardSignTransactionHash() capdu: "+ plainApdu.toHexString()); + APDUResponse respApdu = this.cardTransmit(plainApdu); + log.trace("SATOCHIP SatochipCommandSet cardSignTransactionHash() rapdu: "+ respApdu.toHexString()); + // TODO: check SW code for particular status + + return respApdu; + } + + /** + * 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){ + log.debug("SATOCHIP SatochipCommandSet cardSignSchnorrHash()"); + byte[] data; + if (txhash.length !=32){ + throw new RuntimeException("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 { + log.error("SATOCHIP SatochipCommandSet cardSignSchnorrHash() error: " + "Wrong challenge-response length (should be 20)"); + throw new RuntimeException("Wrong challenge-response length (should be 20)"); + } + APDUCommand plainApdu = new APDUCommand(0xB0, 0x7B, keynbr, 0x00, data); + + log.trace("SATOCHIP SatochipCommandSet cardSignSchnorrHash() capdu: "+ plainApdu.toHexString()); + APDUResponse respApdu = this.cardTransmit(plainApdu); + log.trace("SATOCHIP SatochipCommandSet cardSignSchnorrHash() rapdu: "+ respApdu.toHexString()); + // TODO: check SW code for particular status + + return respApdu; + } + + /** + * 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){ + log.debug("SATOCHIP SatochipCommandSet cardTaprootTweakPrivkey()"); + 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 RuntimeException("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); + + log.trace("SATOCHIP SatochipCommandSet cardTaprootTweakPrivkey() capdu: "+ plainApdu.toHexString()); + APDUResponse respApdu = this.cardTransmit(plainApdu); + log.trace("SATOCHIP SatochipCommandSet cardTaprootTweakPrivkey() rapdu: "+ respApdu.toHexString()); + // TODO: check SW code for particular status + + return respApdu; + } + + /**************************************** + * 2FA commands * + ****************************************/ + //todo + + + /**************************************** + * PKI commands * + ****************************************/ + // todo +} 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..862f1eee --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/SatochipParser.java @@ -0,0 +1,155 @@ +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 com.sparrowwallet.drongo.Utils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.math.BigInteger; +import java.io.IOException; +import java.util.Arrays; +import java.util.Base64; + +public class SatochipParser{ + + private static final Logger log = LoggerFactory.getLogger(SatochipParser.class); + + private byte[] authentikey= null; + + public SatochipParser(){ + } + + /**************************************** + * PARSER * + ****************************************/ + + public byte[] parseInitiateSecureChannel(APDUResponse rapdu){ + + try{ + byte[] data= rapdu.getData(); + log.trace("SATOCHIP SatochipParser parseInitiateSecureChannel() data: " + Utils.bytesToHex(data)); + + // 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; + + byte[] pubkey= recoverPubkey(msg1, sig1, coordx, false); // false: uncompressed + log.trace("SATOCHIP SatochipParser parseInitiateSecureChannel() pubkey: " + Utils.bytesToHex(pubkey)); + + // todo: recover from sig2 + + return pubkey; + } catch(Exception e) { + log.error("SATOCHIP SatochipParser parseInitiateSecureChannel() exception: " + e); + throw new RuntimeException("SATOCHIP SatochipParser parseInitiateSecureChannel() exception: ", e); + } + + } + + public byte[][] parseBip32GetExtendedKey(APDUResponse rapdu){ + log.trace("SATOCHIP SatochipParser parseBip32GetExtendedKey()"); + try{ + byte[][] extendedkey = new byte[2][]; + extendedkey[0] = new byte[33]; // pubkey + extendedkey[1] = new byte[32]; // chaincode + + byte[] data= rapdu.getData(); + log.trace("SATOCHIP SatochipParser parseBip32GetExtendedKey data: " + Utils.bytesToHex(data)); + //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) + log.trace("SATOCHIP SatochipParser parseBip32GetExtendedKey pubkey: " + Utils.bytesToHex(pubkey)); + + // 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) { + log.error("SATOCHIP SatochipParser parseBip32GetExtendedKey() exception: " + e); + throw new RuntimeException("SATOCHIP SatochipParser parseBip32GetExtendedKey() exception: ", e); + } + + } + + /**************************************** + * recovery methods * + ****************************************/ + + public byte[] recoverPubkey(byte[] msg, byte[] dersig, byte[] coordx, Boolean compressed) { + log.trace("SATOCHIP SatochipParser recoverPubkey() coordx: " + Utils.bytesToHex(coordx)); + + // 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 RuntimeException("SATOCHIP SatochipParser recoverPubkey() Exception: Could not construct a recoverable key. This should never happen."); + } + + log.trace("SATOCHIP SatochipParser recoverPubkey() recoveredPubkey: " + Utils.bytesToHex(k.getPubKey())); + 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..a77396c3 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/satochip/SecureChannelSession.java @@ -0,0 +1,245 @@ +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 com.sparrowwallet.drongo.Utils; + +import javax.crypto.Mac; +import javax.crypto.Cipher; +import javax.crypto.KeyAgreement; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; +import java.security.*; // todo remove? +import java.security.SecureRandom; +import java.util.Arrays; +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 ECKey eckey; + + // for session encryption + private SecureRandom random; + private AESKeyCrypter aesCipher; + + /** + * Constructs a SecureChannel session on the client. + */ + public SecureChannelSession() { + random = new SecureRandom(); + + try { + // generate keypair + eckey = new ECKey(); + aesCipher = new AESKeyCrypter(); + + } catch (Exception e) { + log.error("SATOCHIP SecureChannelSession() exception: " + e); + } + } + + + /** + * 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 + log.trace("SATOCHIP SecureChannelSession initiateSecureChannel()"); + 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 = this.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 = this.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("SATOCHIP SecureChannelSession initiateSecureChannel() exception:" + e); + } + } + + public APDUCommand encrypt_secure_channel(APDUCommand plainApdu){ + log.trace("SATOCHIP: SecureChannelSession encrypt_secure_channel()"); + 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; + log.trace("SATOCHIP: SecureChannelSession encrypt_secure_channel() ivCounter: "+ ivCounter); + log.trace("SATOCHIP: SecureChannelSession encrypt_secure_channel() ivCounterBytes: "+ Utils.bytesToHex(ivCounterBytes)); + log.trace("SATOCHIP: SecureChannelSession encrypt_secure_channel() iv: "+ Utils.bytesToHex(iv)); + + // encrypt data + Key aesKey = new Key(derived_key, null, null); + byte[] encrypted = aesCipher.encrypt(plainBytes, iv, aesKey).getEncryptedBytes(); + log.trace("SATOCHIP: SecureChannelSession encrypt_secure_channel() encrypted: "+ Utils.bytesToHex(encrypted)); + + // 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 = this.getHmacSha1Hash(mac_key, data_to_mac); + log.trace("SATOCHIP: SecureChannelSession encrypt_secure_channel() mac: "+ Utils.bytesToHex(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); + log.trace("SATOCHIP: SecureChannelSession encrypt_secure_channel() full_encrypted_data: " + Utils.bytesToHex(data)); + + // convert to C-APDU + APDUCommand encryptedApdu= new APDUCommand(0xB0, INS_PROCESS_SECURE_CHANNEL, 0x00, 0x00, data); + return encryptedApdu; + + } catch (Exception e) { + e.printStackTrace(); + log.error("SATOCHIP: Exception in encrypt_secure_channel: "+ e); + throw new RuntimeException("SATOCHIP: Exception in encrypt_secure_channel:", e); + } + + } + + public APDUResponse decrypt_secure_channel(APDUResponse encryptedApdu){ + log.trace("SATOCHIP SecureChannelSession decrypt_secure_channel()"); + 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); + log.trace("SATOCHIP SecureChannelSession decrypt_secure_channel() iv: " + Utils.bytesToHex(iv)); + log.trace("SATOCHIP SecureChannelSession decrypt_secure_channel() ciphertext: " + Utils.bytesToHex(ciphertext)); + + // decrypt data + Key aesKey = new Key(derived_key, null, null); + EncryptedData encryptedData = new EncryptedData(iv, ciphertext, null, null); + byte[] decrypted = aesCipher.decrypt(encryptedData, aesKey); + log.trace("SATOCHIP SecureChannelSession decrypt_secure_channel() decrypted: " + Utils.bytesToHex(decrypted)); + + APDUResponse plainResponse= new APDUResponse(decrypted, (byte)0x90, (byte)0x00); + return plainResponse; + + } catch (Exception e) { + e.printStackTrace(); + log.error("SATOCHIP SecureChannelSession decrypt_secure_channel() Exception: " + e); + throw new RuntimeException("Exception during secure channel decryption: ", e); + } + + } + + public boolean initializedSecureChannel(){ + return initialized_secure_channel; + } + + public byte[] getPublicKey(){ + log.trace("SATOCHIP SecureChannelSession getPublicKey() eckey.getPubKey(): " + Utils.bytesToHex(eckey.getPubKey(false))); + return eckey.getPubKey(false); // false: uncompressed + } + + public void resetSecureChannel(){ + initialized_secure_channel= false; + // todo: generate new eckey? + return; + } + + public static byte[] getHmacSha1Hash(byte[] key, byte[] data) { + try{ + //log.trace("SATOCHIP SecureChannelSession getHmacSha1Hash() START"); + SecretKeySpec secretKeySpec = new SecretKeySpec(key, "HmacSHA1"); + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(secretKeySpec); + byte[] macData = mac.doFinal(data); + return macData; + } catch (Exception e) { + e.printStackTrace(); + throw new RuntimeException("Failed to compute 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 0a1ec44f..f2a3ce92 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; @@ -22,6 +23,7 @@ public class HwAirgappedController extends KeystoreImportDetailController { private Accordion importAccordion; public void initializeView() { + log.debug("SATOCHIP HwAirgappedController START"); List fileImporters = Collections.emptyList(); if(getMasterController().getWallet().getPolicyType().equals(PolicyType.SINGLE)) { fileImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY()); @@ -38,7 +40,8 @@ public class HwAirgappedController extends KeystoreImportDetailController { } } - List cardImporters = List.of(new Tapsigner()); + log.debug("SATOCHIP HwAirgappedController initializeView() - BEFORE cardImporters"); + 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()); @@ -47,6 +50,7 @@ public class HwAirgappedController extends KeystoreImportDetailController { } } } + log.debug("SATOCHIP HwAirgappedController initializeView() - AFTER cardImporters"); importAccordion.getPanes().sort(Comparator.comparing(o -> ((TitledDescriptionPane) o).getTitle())); } diff --git a/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwUsbDevicesController.java b/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwUsbDevicesController.java index c99f9a9b..05db183a 100644 --- a/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwUsbDevicesController.java +++ b/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwUsbDevicesController.java @@ -12,7 +12,9 @@ public class HwUsbDevicesController extends KeystoreImportDetailController { private Accordion deviceAccordion; public void initializeView(List devices) { + //log.debug("SATOCHIP HwUsbDevicesController initializeView START"); for(Device device : devices) { + //log.debug("SATOCHIP HwUsbDevicesController initializeView device: " + device); DevicePane devicePane = new DevicePane(getMasterController().getWallet(), device, devices.size() == 1, getMasterController().getRequiredDerivation()); if(getMasterController().getRequiredModel() == null || getMasterController().getRequiredModel() == device.getModel()) { deviceAccordion.getPanes().add(devicePane); diff --git a/src/main/resources/image/satochip.png b/src/main/resources/image/satochip.png new file mode 100644 index 0000000000000000000000000000000000000000..4937fd01460c50ef323dfe8052a38e2f6c8207b0 GIT binary patch literal 3682 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 literal 0 HcmV?d00001 diff --git a/src/main/resources/image/satochip@2x.png b/src/main/resources/image/satochip@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..2e7c3263318e0b47c09133a590dc4407558caac9 GIT binary patch literal 6805 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 NvM{kWt}wh1{XeAo`xO8H 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..a2e5a2684baf0a13a3a07b6b7fb35f046dbe6cbf GIT binary patch literal 13966 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½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