diff --git a/src/main/java/com/sparrowwallet/sparrow/control/CardPinDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/CardPinDialog.java new file mode 100644 index 00000000..91cc911e --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/CardPinDialog.java @@ -0,0 +1,93 @@ +package com.sparrowwallet.sparrow.control; + +import com.sparrowwallet.sparrow.AppServices; +import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.beans.binding.BooleanBinding; +import javafx.scene.control.*; +import org.controlsfx.control.textfield.CustomPasswordField; +import org.controlsfx.glyphfont.FontAwesome; +import org.controlsfx.glyphfont.Glyph; +import org.controlsfx.validation.ValidationResult; +import org.controlsfx.validation.ValidationSupport; +import org.controlsfx.validation.decoration.StyleClassValidationDecoration; +import tornadofx.control.Field; +import tornadofx.control.Fieldset; +import tornadofx.control.Form; + +public class CardPinDialog extends Dialog { + private final CustomPasswordField existingPin; + private final CustomPasswordField newPin; + private final CustomPasswordField newPinConfirm; + private final CheckBox backupFirst; + private final ButtonType okButtonType; + + public CardPinDialog() { + this.existingPin = new ViewPasswordField(); + this.newPin = new ViewPasswordField(); + this.newPinConfirm = new ViewPasswordField(); + this.backupFirst = new CheckBox(); + + final DialogPane dialogPane = getDialogPane(); + setTitle("Change Card PIN"); + dialogPane.setHeaderText("Enter the current PIN, and then the new PIN twice. PIN must be between 6 and 32 digits."); + dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm()); + AppServices.setStageIcon(dialogPane.getScene().getWindow()); + dialogPane.getButtonTypes().addAll(ButtonType.CANCEL); + dialogPane.setPrefWidth(380); + dialogPane.setPrefHeight(260); + AppServices.moveToActiveWindowScreen(this); + + Glyph lock = new Glyph("FontAwesome", FontAwesome.Glyph.LOCK); + lock.setFontSize(50); + dialogPane.setGraphic(lock); + + Form form = new Form(); + Fieldset fieldset = new Fieldset(); + fieldset.setText(""); + fieldset.setSpacing(10); + + Field currentField = new Field(); + currentField.setText("Current PIN:"); + currentField.getInputs().add(existingPin); + + Field newField = new Field(); + newField.setText("New PIN:"); + newField.getInputs().add(newPin); + + Field confirmField = new Field(); + confirmField.setText("Confirm new PIN:"); + confirmField.getInputs().add(newPinConfirm); + + Field backupField = new Field(); + backupField.setText("Backup First:"); + backupField.getInputs().add(backupFirst); + + fieldset.getChildren().addAll(currentField, newField, confirmField, backupField); + form.getChildren().add(fieldset); + dialogPane.setContent(form); + + ValidationSupport validationSupport = new ValidationSupport(); + Platform.runLater( () -> { + validationSupport.setValidationDecorator(new StyleClassValidationDecoration()); + validationSupport.registerValidator(existingPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", existingPin.getText().length() < 6 || existingPin.getText().length() > 32)); + validationSupport.registerValidator(newPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", newPin.getText().length() < 6 || newPin.getText().length() > 32)); + validationSupport.registerValidator(newPinConfirm, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "PIN confirmation does not match", !newPinConfirm.getText().equals(newPin.getText()))); + }); + + okButtonType = new javafx.scene.control.ButtonType("Change", ButtonBar.ButtonData.OK_DONE); + dialogPane.getButtonTypes().addAll(okButtonType); + Button okButton = (Button) dialogPane.lookupButton(okButtonType); + okButton.setPrefWidth(130); + BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> existingPin.getText().length() < 6 || existingPin.getText().length() > 32 + || newPin.getText().length() < 6 || newPin.getText().length() > 32 + || !newPin.getText().equals(newPinConfirm.getText()), + existingPin.textProperty(), newPin.textProperty(), newPinConfirm.textProperty()); + okButton.disableProperty().bind(isInvalid); + + Platform.runLater(existingPin::requestFocus); + setResultConverter(dialogButton -> dialogButton == okButtonType ? new CardPinChange(existingPin.getText(), newPin.getText(), backupFirst.isSelected()) : null); + } + + public record CardPinChange(String currentPin, String newPin, boolean backupFirst) { } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardApi.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardApi.java index 9aadb989..5d94ce62 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardApi.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardApi.java @@ -8,7 +8,12 @@ import com.sparrowwallet.drongo.protocol.Base58; import com.sparrowwallet.drongo.wallet.Keystore; import com.sparrowwallet.drongo.wallet.KeystoreSource; import com.sparrowwallet.drongo.wallet.WalletModel; +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; @@ -35,10 +40,11 @@ public class CardApi { return cardProtocol.getStatus(); } - void checkWait(CardStatus cardStatus, StringProperty messageProperty) throws CardException { + void checkWait(CardStatus cardStatus, IntegerProperty delayProperty, StringProperty messageProperty) throws CardException { if(cardStatus.auth_delay != null) { int delay = cardStatus.auth_delay.intValue(); while(delay > 0) { + delayProperty.set(delay); messageProperty.set("Auth delay, waiting " + delay + "s..."); CardWait cardWait = cardProtocol.authWait(); if(cardWait.success) { @@ -48,6 +54,38 @@ public class CardApi { } } + public Service getAuthDelayService() throws CardException { + CardStatus cardStatus = getStatus(); + if(cardStatus.auth_delay != null) { + return new AuthDelayService(cardStatus); + } + + return null; + } + + public boolean requiresBackup() throws CardException { + CardStatus cardStatus = getStatus(); + return cardStatus.requiresBackup(); + } + + public Service getBackupService() { + return new BackupService(); + } + + String getBackup() throws CardException { + CardBackup cardBackup = cardProtocol.backup(cvc); + return Utils.bytesToHex(cardBackup.data); + } + + public boolean changePin(String newCvc) throws CardException { + CardChange cardChange = cardProtocol.change(cvc, newCvc); + if(cardChange.success) { + cvc = newCvc; + } + + return cardChange.success; + } + public void setDerivation(List derivation) throws CardException { cardProtocol.derive(cvc, derivation); } @@ -81,4 +119,45 @@ public class CardApi { log.warn("Error disconnecting from card reader", e); } } + + public static boolean isReaderAvailable() { + return CardTransport.isReaderAvailable(); + } + + public class AuthDelayService extends Service { + private final CardStatus cardStatus; + private final IntegerProperty delayProperty; + private final StringProperty messageProperty; + + AuthDelayService(CardStatus cardStatus) { + this.cardStatus = cardStatus; + this.delayProperty = new SimpleIntegerProperty(); + this.messageProperty = new SimpleStringProperty(); + } + + @Override + protected Task createTask() { + return new Task<>() { + @Override + protected Void call() throws Exception { + delayProperty.addListener((observable, oldValue, newValue) -> updateProgress(cardStatus.auth_delay.intValue() - newValue.intValue(), cardStatus.auth_delay.intValue())); + messageProperty.addListener((observable, oldValue, newValue) -> updateMessage(newValue)); + checkWait(cardStatus, delayProperty, messageProperty); + return null; + } + }; + } + } + + public class BackupService extends Service { + @Override + protected Task createTask() { + return new Task<>() { + @Override + protected String call() throws Exception { + return getBackup(); + } + }; + } + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardStatus.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardStatus.java index 2d865b57..857f4661 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardStatus.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardStatus.java @@ -38,6 +38,10 @@ public class CardStatus extends CardResponse { return null; } + public boolean requiresBackup() { + return num_backups == null || num_backups.intValue() == 0; + } + @Override public String toString() { return "CardStatus{" + diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardTransport.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardTransport.java index ed167fee..0dd67552 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardTransport.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardTransport.java @@ -32,7 +32,7 @@ public class CardTransport { private final Card connection; - public CardTransport() throws CardException { + CardTransport() throws CardException { TerminalFactory tf = TerminalFactory.getDefault(); List terminals = tf.terminals().list(); if(terminals.isEmpty()) { @@ -49,7 +49,7 @@ public class CardTransport { } } - public JsonObject send(String cmd, Map args) throws CardException { + JsonObject send(String cmd, Map args) throws CardException { Map sendMap = new LinkedHashMap<>(); sendMap.put("cmd", cmd); sendMap.putAll(args); @@ -106,11 +106,10 @@ public class CardTransport { if(result.get("error") != null) { String msg = result.get("error").getAsString(); int code = result.get("code") == null ? 500 : result.get("code").getAsInt(); - String message = code + " on " + cmd + ": " + msg; if(code == 205) { - throw new CardUnluckyNumberException(message); + throw new CardUnluckyNumberException("Card chose unlucky number, please retry."); } else if(code == 401) { - throw new CardAuthorizationException(message); + throw new CardAuthorizationException("Incorrect PIN provided."); } throw new CardException(code + " on " + cmd + ": " + msg); @@ -146,11 +145,11 @@ public class CardTransport { throw new IllegalArgumentException("Cannot convert dataItem of type " + dataItem.getClass() + "to JsonElement"); } - public void disconnect() throws CardException { + void disconnect() throws CardException { connection.disconnect(true); } - public static boolean isReaderAvailable() { + static boolean isReaderAvailable() { try { TerminalFactory tf = TerminalFactory.getDefault(); return !tf.terminals().list().isEmpty(); diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CkCard.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CkCard.java index 0f83e107..cc5df767 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CkCard.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CkCard.java @@ -5,6 +5,7 @@ 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.SimpleStringProperty; import javafx.beans.property.StringProperty; @@ -32,7 +33,7 @@ public class CkCard implements KeystoreCardImport { cardApi.initialize(); cardStatus = cardApi.getStatus(); } - cardApi.checkWait(cardStatus, messageProperty); + cardApi.checkWait(cardStatus, new SimpleIntegerProperty(), messageProperty); if(!derivation.equals(cardStatus.getDerivation())) { cardApi.setDerivation(derivation); diff --git a/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwAirgappedController.java b/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwAirgappedController.java index 3ca40f45..b2f838b3 100644 --- a/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwAirgappedController.java +++ b/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwAirgappedController.java @@ -11,11 +11,12 @@ import javafx.scene.control.Accordion; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import javax.smartcardio.TerminalFactory; import java.util.Collections; import java.util.Comparator; import java.util.List; +import static com.sparrowwallet.sparrow.io.ckcard.CardApi.isReaderAvailable; + public class HwAirgappedController extends KeystoreImportDetailController { private static final Logger log = LoggerFactory.getLogger(HwAirgappedController.class); @@ -54,15 +55,4 @@ public class HwAirgappedController extends KeystoreImportDetailController { importAccordion.getPanes().sort(Comparator.comparing(o -> ((TitledDescriptionPane) o).getTitle())); } - - public static boolean isReaderAvailable() { - try { - TerminalFactory tf = TerminalFactory.getDefault(); - return !tf.terminals().list().isEmpty(); - } catch(Exception e) { - log.error("Error detecting card terminals", e); - } - - return false; - } } diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java index c5c01e95..aff05531 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java @@ -10,8 +10,10 @@ import com.sparrowwallet.sparrow.event.*; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands; import com.sparrowwallet.sparrow.io.Storage; +import com.sparrowwallet.sparrow.io.ckcard.CardApi; import com.sparrowwallet.sparrow.keystoreimport.KeystoreImportDialog; import javafx.application.Platform; +import javafx.concurrent.Service; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.fxml.Initializable; @@ -29,11 +31,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import tornadofx.control.Field; +import javax.smartcardio.CardException; import java.net.URL; import java.util.Optional; import java.util.ResourceBundle; import java.util.stream.Collectors; +import static com.sparrowwallet.sparrow.io.ckcard.CardApi.isReaderAvailable; + public class KeystoreController extends WalletFormController implements Initializable { private static final Logger log = LoggerFactory.getLogger(KeystoreController.class); @@ -56,6 +61,9 @@ public class KeystoreController extends WalletFormController implements Initiali @FXML private Button viewKeyButton; + @FXML + private Button changePinButton; + @FXML private Button importButton; @@ -117,6 +125,7 @@ public class KeystoreController extends WalletFormController implements Initiali viewSeedButton.managedProperty().bind(viewSeedButton.visibleProperty()); viewKeyButton.managedProperty().bind(viewKeyButton.visibleProperty()); + changePinButton.managedProperty().bind(changePinButton.visibleProperty()); scanXpubQR.managedProperty().bind(scanXpubQR.visibleProperty()); displayXpubQR.managedProperty().bind(displayXpubQR.visibleProperty()); displayXpubQR.visibleProperty().bind(scanXpubQR.visibleProperty().not()); @@ -273,6 +282,7 @@ public class KeystoreController extends WalletFormController implements Initiali type.setGraphic(getTypeIcon(keystore)); viewSeedButton.setVisible(keystore.getSource() == KeystoreSource.SW_SEED && keystore.hasSeed()); viewKeyButton.setVisible(keystore.getSource() == KeystoreSource.SW_SEED && keystore.hasMasterPrivateExtendedKey()); + changePinButton.setVisible(keystore.getWalletModel() == WalletModel.TAPSIGNER); importButton.setText(keystore.getSource() == KeystoreSource.SW_WATCH ? "Import..." : "Replace..."); importButton.setTooltip(new Tooltip(keystore.getSource() == KeystoreSource.SW_WATCH ? "Import a keystore from an external source" : "Replace this keystore with another source")); @@ -404,6 +414,84 @@ public class KeystoreController extends WalletFormController implements Initiali } } + public void changeCardPin(ActionEvent event) { + if(!isReaderAvailable()) { + AppServices.showErrorDialog("No card reader", "Connect a card reader to change the card PIN."); + return; + } + + CardPinDialog cardPinDialog = new CardPinDialog(); + Optional optPinChange = cardPinDialog.showAndWait(); + if(optPinChange.isPresent()) { + String currentPin = optPinChange.get().currentPin(); + String newPin = optPinChange.get().newPin(); + boolean backupFirst = optPinChange.get().backupFirst(); + try { + CardApi cardApi = new CardApi(currentPin); + Service authDelayService = cardApi.getAuthDelayService(); + if(authDelayService != null) { + authDelayService.setOnSucceeded(event1 -> { + try { + changeCardPin(newPin, backupFirst, cardApi); + } catch(CardException e) { + log.error("Error communicating with card", e); + AppServices.showErrorDialog("Error communicating with card", e.getMessage()); + } + }); + authDelayService.setOnFailed(event1 -> { + Throwable e = event1.getSource().getException(); + log.error("Error communicating with card", e); + AppServices.showErrorDialog("Error communicating with card", e.getMessage()); + }); + ServiceProgressDialog serviceProgressDialog = new ServiceProgressDialog("Authentication Delay", "Waiting for authenication delay to clear...", "/image/tapsigner.png", authDelayService); + AppServices.moveToActiveWindowScreen(serviceProgressDialog); + authDelayService.start(); + } else { + changeCardPin(newPin, backupFirst, cardApi); + } + } catch(CardException e) { + log.error("Error communicating with card", e); + AppServices.showErrorDialog("Error communicating with card", e.getMessage()); + } + } + } + + private void changeCardPin(String newPin, boolean backupFirst, CardApi cardApi) throws CardException { + boolean requiresBackup = cardApi.requiresBackup(); + if(backupFirst || requiresBackup) { + Service backupService = cardApi.getBackupService(); + backupService.setOnSucceeded(event -> { + String backup = backupService.getValue(); + TextAreaDialog backupDialog = new TextAreaDialog(backup, false); + backupDialog.setTitle("Backup Private Key"); + backupDialog.getDialogPane().setHeaderText((requiresBackup ? "Please backup first by saving" : "Save") + " the following text in a safe place. It contains an encrypted copy of the card's private key, and can be decrypted using the backup key written on the back of the card."); + backupDialog.showAndWait(); + try { + changePin(newPin, cardApi); + } catch(Exception e) { + log.error("Error communicating with card", e); + AppServices.showErrorDialog("Error communicating with card", e.getMessage()); + } + }); + backupService.setOnFailed(event -> { + Throwable e = event.getSource().getException(); + log.error("Error communicating with card", e); + AppServices.showErrorDialog("Error communicating with card", e.getMessage()); + }); + backupService.start(); + } else { + changePin(newPin, cardApi); + } + } + + private void changePin(String newPin, CardApi cardApi) throws CardException { + if(cardApi.changePin(newPin)) { + AppServices.showSuccessDialog("PIN changed", "The card's PIN has been changed."); + } else { + AppServices.showSuccessDialog("Could not change PIN", "The card's PIN was not changed."); + } + } + public void scanXpubQR(ActionEvent event) { QRScanDialog qrScanDialog = new QRScanDialog(); Optional optionalResult = qrScanDialog.showAndWait(); diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/keystore.fxml b/src/main/resources/com/sparrowwallet/sparrow/wallet/keystore.fxml index 87f6a460..f157d981 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/keystore.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/keystore.fxml @@ -40,6 +40,14 @@ +