add functionality for tapsigner backup and pin change

This commit is contained in:
Craig Raw 2023-01-26 13:00:25 +02:00
parent 6b59ff60ad
commit 3ddf4ed4b2
8 changed files with 283 additions and 21 deletions

View file

@ -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<CardPinDialog.CardPinChange> {
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) { }
}

View file

@ -8,7 +8,12 @@ import com.sparrowwallet.drongo.protocol.Base58;
import com.sparrowwallet.drongo.wallet.Keystore; import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource; import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.WalletModel; 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.beans.property.StringProperty;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -35,10 +40,11 @@ public class CardApi {
return cardProtocol.getStatus(); 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) { if(cardStatus.auth_delay != null) {
int delay = cardStatus.auth_delay.intValue(); int delay = cardStatus.auth_delay.intValue();
while(delay > 0) { while(delay > 0) {
delayProperty.set(delay);
messageProperty.set("Auth delay, waiting " + delay + "s..."); messageProperty.set("Auth delay, waiting " + delay + "s...");
CardWait cardWait = cardProtocol.authWait(); CardWait cardWait = cardProtocol.authWait();
if(cardWait.success) { if(cardWait.success) {
@ -48,6 +54,38 @@ public class CardApi {
} }
} }
public Service<Void> 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<String> 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<ChildNumber> derivation) throws CardException { public void setDerivation(List<ChildNumber> derivation) throws CardException {
cardProtocol.derive(cvc, derivation); cardProtocol.derive(cvc, derivation);
} }
@ -81,4 +119,45 @@ public class CardApi {
log.warn("Error disconnecting from card reader", e); log.warn("Error disconnecting from card reader", e);
} }
} }
public static boolean isReaderAvailable() {
return CardTransport.isReaderAvailable();
}
public class AuthDelayService extends Service<Void> {
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<Void> 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<String> {
@Override
protected Task<String> createTask() {
return new Task<>() {
@Override
protected String call() throws Exception {
return getBackup();
}
};
}
}
} }

View file

@ -38,6 +38,10 @@ public class CardStatus extends CardResponse {
return null; return null;
} }
public boolean requiresBackup() {
return num_backups == null || num_backups.intValue() == 0;
}
@Override @Override
public String toString() { public String toString() {
return "CardStatus{" + return "CardStatus{" +

View file

@ -32,7 +32,7 @@ public class CardTransport {
private final Card connection; private final Card connection;
public CardTransport() throws CardException { CardTransport() throws CardException {
TerminalFactory tf = TerminalFactory.getDefault(); TerminalFactory tf = TerminalFactory.getDefault();
List<CardTerminal> terminals = tf.terminals().list(); List<CardTerminal> terminals = tf.terminals().list();
if(terminals.isEmpty()) { if(terminals.isEmpty()) {
@ -49,7 +49,7 @@ public class CardTransport {
} }
} }
public JsonObject send(String cmd, Map<String, Object> args) throws CardException { JsonObject send(String cmd, Map<String, Object> args) throws CardException {
Map<String, Object> sendMap = new LinkedHashMap<>(); Map<String, Object> sendMap = new LinkedHashMap<>();
sendMap.put("cmd", cmd); sendMap.put("cmd", cmd);
sendMap.putAll(args); sendMap.putAll(args);
@ -106,11 +106,10 @@ public class CardTransport {
if(result.get("error") != null) { if(result.get("error") != null) {
String msg = result.get("error").getAsString(); String msg = result.get("error").getAsString();
int code = result.get("code") == null ? 500 : result.get("code").getAsInt(); int code = result.get("code") == null ? 500 : result.get("code").getAsInt();
String message = code + " on " + cmd + ": " + msg;
if(code == 205) { if(code == 205) {
throw new CardUnluckyNumberException(message); throw new CardUnluckyNumberException("Card chose unlucky number, please retry.");
} else if(code == 401) { } else if(code == 401) {
throw new CardAuthorizationException(message); throw new CardAuthorizationException("Incorrect PIN provided.");
} }
throw new CardException(code + " on " + cmd + ": " + msg); 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"); throw new IllegalArgumentException("Cannot convert dataItem of type " + dataItem.getClass() + "to JsonElement");
} }
public void disconnect() throws CardException { void disconnect() throws CardException {
connection.disconnect(true); connection.disconnect(true);
} }
public static boolean isReaderAvailable() { static boolean isReaderAvailable() {
try { try {
TerminalFactory tf = TerminalFactory.getDefault(); TerminalFactory tf = TerminalFactory.getDefault();
return !tf.terminals().list().isEmpty(); return !tf.terminals().list().isEmpty();

View file

@ -5,6 +5,7 @@ import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.WalletModel; import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.io.KeystoreCardImport; import com.sparrowwallet.sparrow.io.KeystoreCardImport;
import com.sparrowwallet.sparrow.io.ImportException; import com.sparrowwallet.sparrow.io.ImportException;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty; import javafx.beans.property.StringProperty;
@ -32,7 +33,7 @@ public class CkCard implements KeystoreCardImport {
cardApi.initialize(); cardApi.initialize();
cardStatus = cardApi.getStatus(); cardStatus = cardApi.getStatus();
} }
cardApi.checkWait(cardStatus, messageProperty); cardApi.checkWait(cardStatus, new SimpleIntegerProperty(), messageProperty);
if(!derivation.equals(cardStatus.getDerivation())) { if(!derivation.equals(cardStatus.getDerivation())) {
cardApi.setDerivation(derivation); cardApi.setDerivation(derivation);

View file

@ -11,11 +11,12 @@ import javafx.scene.control.Accordion;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import javax.smartcardio.TerminalFactory;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import static com.sparrowwallet.sparrow.io.ckcard.CardApi.isReaderAvailable;
public class HwAirgappedController extends KeystoreImportDetailController { public class HwAirgappedController extends KeystoreImportDetailController {
private static final Logger log = LoggerFactory.getLogger(HwAirgappedController.class); 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())); 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;
}
} }

View file

@ -10,8 +10,10 @@ import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.Storage; import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.io.ckcard.CardApi;
import com.sparrowwallet.sparrow.keystoreimport.KeystoreImportDialog; import com.sparrowwallet.sparrow.keystoreimport.KeystoreImportDialog;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.concurrent.Service;
import javafx.event.ActionEvent; import javafx.event.ActionEvent;
import javafx.fxml.FXML; import javafx.fxml.FXML;
import javafx.fxml.Initializable; import javafx.fxml.Initializable;
@ -29,11 +31,14 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import tornadofx.control.Field; import tornadofx.control.Field;
import javax.smartcardio.CardException;
import java.net.URL; import java.net.URL;
import java.util.Optional; import java.util.Optional;
import java.util.ResourceBundle; import java.util.ResourceBundle;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.io.ckcard.CardApi.isReaderAvailable;
public class KeystoreController extends WalletFormController implements Initializable { public class KeystoreController extends WalletFormController implements Initializable {
private static final Logger log = LoggerFactory.getLogger(KeystoreController.class); private static final Logger log = LoggerFactory.getLogger(KeystoreController.class);
@ -56,6 +61,9 @@ public class KeystoreController extends WalletFormController implements Initiali
@FXML @FXML
private Button viewKeyButton; private Button viewKeyButton;
@FXML
private Button changePinButton;
@FXML @FXML
private Button importButton; private Button importButton;
@ -117,6 +125,7 @@ public class KeystoreController extends WalletFormController implements Initiali
viewSeedButton.managedProperty().bind(viewSeedButton.visibleProperty()); viewSeedButton.managedProperty().bind(viewSeedButton.visibleProperty());
viewKeyButton.managedProperty().bind(viewKeyButton.visibleProperty()); viewKeyButton.managedProperty().bind(viewKeyButton.visibleProperty());
changePinButton.managedProperty().bind(changePinButton.visibleProperty());
scanXpubQR.managedProperty().bind(scanXpubQR.visibleProperty()); scanXpubQR.managedProperty().bind(scanXpubQR.visibleProperty());
displayXpubQR.managedProperty().bind(displayXpubQR.visibleProperty()); displayXpubQR.managedProperty().bind(displayXpubQR.visibleProperty());
displayXpubQR.visibleProperty().bind(scanXpubQR.visibleProperty().not()); displayXpubQR.visibleProperty().bind(scanXpubQR.visibleProperty().not());
@ -273,6 +282,7 @@ public class KeystoreController extends WalletFormController implements Initiali
type.setGraphic(getTypeIcon(keystore)); type.setGraphic(getTypeIcon(keystore));
viewSeedButton.setVisible(keystore.getSource() == KeystoreSource.SW_SEED && keystore.hasSeed()); viewSeedButton.setVisible(keystore.getSource() == KeystoreSource.SW_SEED && keystore.hasSeed());
viewKeyButton.setVisible(keystore.getSource() == KeystoreSource.SW_SEED && keystore.hasMasterPrivateExtendedKey()); 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.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")); 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<CardPinDialog.CardPinChange> 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<Void> 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<String> 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) { public void scanXpubQR(ActionEvent event) {
QRScanDialog qrScanDialog = new QRScanDialog(); QRScanDialog qrScanDialog = new QRScanDialog();
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait(); Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();

View file

@ -40,6 +40,14 @@
<Tooltip text="View master private key"/> <Tooltip text="View master private key"/>
</tooltip> </tooltip>
</Button> </Button>
<Button fx:id="changePinButton" text="Change Pin..." graphicTextGap="5" onAction="#changeCardPin">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="WIFI" />
</graphic>
<tooltip>
<Tooltip text="Change the PIN of current card"/>
</tooltip>
</Button>
<Pane HBox.hgrow="ALWAYS" /> <Pane HBox.hgrow="ALWAYS" />
<Button fx:id="importButton" text="Import..." graphicTextGap="5" onAction="#importKeystore"> <Button fx:id="importButton" text="Import..." graphicTextGap="5" onAction="#importKeystore">
<graphic> <graphic>