diff --git a/drongo b/drongo index 6b4b2529..ee49ddd9 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit 6b4b2529c803581b13e1604acd230dff24c839b4 +Subproject commit ee49ddd94bdfec1e87d334f17833a63382958790 diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index e3310865..6f9de6e9 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -848,6 +848,26 @@ public class AppController implements Initializable { preferencesDialog.showAndWait(); } + public void signVerifyMessage(ActionEvent event) { + MessageSignDialog messageSignDialog = null; + Tab tab = tabs.getSelectionModel().getSelectedItem(); + if(tab != null && tab.getUserData() instanceof WalletTabData) { + WalletTabData walletTabData = (WalletTabData)tab.getUserData(); + Wallet wallet = walletTabData.getWallet(); + if(wallet.getKeystores().size() == 1 && wallet.getKeystores().get(0).hasSeed()) { + //Can sign and verify + messageSignDialog = new MessageSignDialog(wallet); + } + } + + if(messageSignDialog == null) { + //Can verify only + messageSignDialog = new MessageSignDialog(); + } + + messageSignDialog.showAndWait(); + } + public Tab addWalletTab(Storage storage, Wallet wallet) { for(Tab tab : tabs.getTabs()) { TabData tabData = (TabData)tab.getUserData(); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/AddressCell.java b/src/main/java/com/sparrowwallet/sparrow/control/AddressCell.java index 506fcf72..2f98c48e 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/AddressCell.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/AddressCell.java @@ -32,7 +32,7 @@ public class AddressCell extends TreeTableCell { UtxoEntry utxoEntry = (UtxoEntry)entry; Address address = utxoEntry.getAddress(); setText(address.toString()); - setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor())); + setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), null)); Tooltip tooltip = new Tooltip(); tooltip.setText(getTooltipText(utxoEntry)); setTooltip(tooltip); diff --git a/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java index 0f2f0ea7..78d0b5b7 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/EntryCell.java @@ -71,12 +71,13 @@ class EntryCell extends TreeTableCell { NodeEntry nodeEntry = (NodeEntry)entry; Address address = nodeEntry.getAddress(); setText(address.toString()); - setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor())); + setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry)); Tooltip tooltip = new Tooltip(); tooltip.setText(nodeEntry.getNode().getDerivationPath()); setTooltip(tooltip); getStyleClass().add("address-cell"); + HBox actionBox = new HBox(); Button receiveButton = new Button(""); Glyph receiveGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.ARROW_DOWN); receiveGlyph.setFontSize(12); @@ -85,7 +86,21 @@ class EntryCell extends TreeTableCell { EventManager.get().post(new ReceiveActionEvent(nodeEntry)); Platform.runLater(() -> EventManager.get().post(new ReceiveToEvent(nodeEntry))); }); - setGraphic(receiveButton); + actionBox.getChildren().add(receiveButton); + + if(nodeEntry.getWallet().getKeystores().size() == 1 && nodeEntry.getWallet().getKeystores().get(0).hasSeed()) { + Button signMessageButton = new Button(""); + Glyph signMessageGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.PEN_FANCY); + signMessageGlyph.setFontSize(12); + signMessageButton.setGraphic(signMessageGlyph); + signMessageButton.setOnAction(event -> { + MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode()); + messageSignDialog.showAndWait(); + }); + actionBox.getChildren().add(signMessageButton); + } + + setGraphic(actionBox); } else if(entry instanceof HashIndexEntry) { HashIndexEntry hashIndexEntry = (HashIndexEntry)entry; setText(hashIndexEntry.getDescription()); @@ -178,7 +193,7 @@ class EntryCell extends TreeTableCell { } public static class AddressContextMenu extends ContextMenu { - public AddressContextMenu(Address address, String outputDescriptor) { + public AddressContextMenu(Address address, String outputDescriptor, NodeEntry nodeEntry) { MenuItem copyAddress = new MenuItem("Copy Address"); copyAddress.setOnAction(AE -> { hide(); @@ -204,6 +219,17 @@ class EntryCell extends TreeTableCell { }); getItems().addAll(copyAddress, copyHex, copyOutputDescriptor); + + if(nodeEntry != null) { + MenuItem signVerifyMessage = new MenuItem("Sign/Verify Message"); + signVerifyMessage.setOnAction(AE -> { + hide(); + MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode()); + messageSignDialog.showAndWait(); + }); + + getItems().add(signVerifyMessage); + } } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java new file mode 100644 index 00000000..c5d86372 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/MessageSignDialog.java @@ -0,0 +1,286 @@ +package com.sparrowwallet.sparrow.control; + +import com.google.common.eventbus.Subscribe; +import com.sparrowwallet.drongo.SecureString; +import com.sparrowwallet.drongo.address.Address; +import com.sparrowwallet.drongo.address.InvalidAddressException; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.wallet.Keystore; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.drongo.wallet.WalletNode; +import com.sparrowwallet.sparrow.AppController; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.event.OpenWalletsEvent; +import com.sparrowwallet.sparrow.event.RequestOpenWalletsEvent; +import com.sparrowwallet.sparrow.event.StorageEvent; +import com.sparrowwallet.sparrow.event.TimedEvent; +import com.sparrowwallet.sparrow.io.Storage; +import javafx.application.Platform; +import javafx.scene.control.*; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.VBox; +import org.controlsfx.validation.ValidationResult; +import org.controlsfx.validation.ValidationSupport; +import org.controlsfx.validation.decoration.StyleClassValidationDecoration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import tornadofx.control.Field; +import tornadofx.control.Fieldset; +import tornadofx.control.Form; + +import java.security.SignatureException; +import java.util.Arrays; +import java.util.Optional; + +import static com.sparrowwallet.sparrow.AppController.setStageIcon; + +public class MessageSignDialog extends Dialog { + private static final Logger log = LoggerFactory.getLogger(MessageSignDialog.class); + + private final TextField address; + private final TextArea message; + private final TextArea signature; + private final Wallet wallet; + private WalletNode walletNode; + + /** + * Verification only constructor + */ + public MessageSignDialog() { + this(null, null); + } + + /** + * Sign and verify with user entered address + * + * @param wallet Wallet to sign with + */ + public MessageSignDialog(Wallet wallet) { + this(wallet, null); + } + + /** + * Sign and verify with preset address + * + * @param wallet Wallet to sign with + * @param walletNode Wallet node to derive address from + */ + public MessageSignDialog(Wallet wallet, WalletNode walletNode) { + if(wallet != null) { + if(wallet.getKeystores().size() != 1) { + throw new IllegalArgumentException("Cannot sign messages using a wallet with multiple keystores - a single key is required"); + } + if(!wallet.getKeystores().get(0).hasSeed()) { + throw new IllegalArgumentException("Cannot sign messages using a wallet without a seed"); + } + } + + this.wallet = wallet; + this.walletNode = walletNode; + + final DialogPane dialogPane = getDialogPane(); + dialogPane.getStylesheets().add(AppController.class.getResource("general.css").toExternalForm()); + AppController.setStageIcon(dialogPane.getScene().getWindow()); + dialogPane.setHeaderText(wallet == null ? "Verify Message" : "Sign/Verify Message"); + + Image image = new Image("image/seed.png", 50, 50, false, false); + if (!image.isError()) { + ImageView imageView = new ImageView(); + imageView.setSmooth(false); + imageView.setImage(image); + dialogPane.setGraphic(imageView); + } + + VBox vBox = new VBox(); + vBox.setSpacing(20); + + Form form = new Form(); + Fieldset fieldset = new Fieldset(); + fieldset.setText(""); + + Field addressField = new Field(); + addressField.setText("Address:"); + address = new TextField(); + address.getStyleClass().add("id"); + address.setEditable(walletNode == null); + addressField.getInputs().add(address); + + if(walletNode != null) { + address.setText(wallet.getAddress(walletNode).toString()); + } + + Field messageField = new Field(); + messageField.setText("Message:"); + message = new TextArea(); + message.setWrapText(true); + message.setPrefRowCount(10); + message.setStyle("-fx-pref-height: 180px"); + messageField.getInputs().add(message); + + Field signatureField = new Field(); + signatureField.setText("Signature:"); + signature = new TextArea(); + signature.getStyleClass().add("id"); + signature.setPrefRowCount(2); + signature.setStyle("-fx-pref-height: 60px"); + signature.setWrapText(true); + signatureField.getInputs().add(signature); + + fieldset.getChildren().addAll(addressField, messageField, signatureField); + form.getChildren().add(fieldset); + dialogPane.setContent(form); + + ButtonType signButtonType = new javafx.scene.control.ButtonType("Sign", ButtonBar.ButtonData.BACK_PREVIOUS); + ButtonType verifyButtonType = new javafx.scene.control.ButtonType("Verify", ButtonBar.ButtonData.NEXT_FORWARD); + ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.OK_DONE); + dialogPane.getButtonTypes().addAll(signButtonType, verifyButtonType, doneButtonType); + + Button signButton = (Button)dialogPane.lookupButton(signButtonType); + signButton.setDisable(wallet == null); + signButton.setOnAction(event -> { + signMessage(); + }); + + Button verifyButton = (Button)dialogPane.lookupButton(verifyButtonType); + verifyButton.setDefaultButton(false); + verifyButton.setOnAction(event -> { + verifyMessage(); + }); + + boolean validAddress = isValidAddress(); + signButton.setDisable(!validAddress || (wallet == null)); + verifyButton.setDisable(!validAddress); + + ValidationSupport validationSupport = new ValidationSupport(); + Platform.runLater(() -> { + validationSupport.registerValidator(address, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Invalid address", !isValidAddress())); + validationSupport.setValidationDecorator(new StyleClassValidationDecoration()); + }); + + address.textProperty().addListener((observable, oldValue, newValue) -> { + boolean valid = isValidAddress(); + signButton.setDisable(!valid || (wallet == null)); + verifyButton.setDisable(!valid); + + if(valid && wallet != null) { + try { + Address address = getAddress(); + setWalletNodeFromAddress(wallet, address); + } catch(InvalidAddressException e) { + //can't happen + } + } + }); + + EventManager.get().register(this); + setOnCloseRequest(event -> { + if(ButtonBar.ButtonData.APPLY.equals(getResult())) { + event.consume(); + return; + } + + EventManager.get().unregister(this); + }); + + setResultConverter(dialogButton -> dialogButton == signButtonType || dialogButton == verifyButtonType ? ButtonBar.ButtonData.APPLY : ButtonBar.ButtonData.OK_DONE); + } + + private Address getAddress()throws InvalidAddressException { + return Address.fromString(address.getText()); + } + + private boolean isValidAddress() { + try { + getAddress(); + return true; + } catch (InvalidAddressException e) { + return false; + } + } + + private void setWalletNodeFromAddress(Wallet wallet, Address address) { + walletNode = wallet.getWalletAddresses().get(address); + } + + private void signMessage() { + if(walletNode == null) { + AppController.showErrorDialog("Address not in wallet", "The provided address is not present in the currently selected wallet."); + return; + } + + if(wallet.isEncrypted()) { + EventManager.get().post(new RequestOpenWalletsEvent()); + } else { + signUnencryptedKeystore(wallet); + } + } + + private void signUnencryptedKeystore(Wallet decryptedWallet) { + try { + //Note we can expect a single keystore due to the check above + Keystore keystore = decryptedWallet.getKeystores().get(0); + ECKey privKey = keystore.getKey(walletNode); + String signatureText = privKey.signMessage(message.getText(), null); + signature.clear(); + signature.appendText(signatureText); + } catch(Exception e) { + log.error("Could not sign message", e); + AppController.showErrorDialog("Could not sign message", e.getMessage()); + } + } + + private void verifyMessage() { + try { + //Find ECKey from message and signature + //http://www.secg.org/download/aid-780/sec1-v2.pdf section 4.1.6 + + ECKey signedMessageKey = ECKey.signedMessageToKey(message.getText(), signature.getText()); + Address address = getAddress(); + + byte[] pubKeyHash = address.getOutputScriptData(); + byte[] signedKeyHash = signedMessageKey.getPubKeyHash(); + + if(!Arrays.equals(pubKeyHash, signedKeyHash)) { + throw new SignatureException("Address pubkey hash did not match signed pubkey hash"); + } + + Alert alert = new Alert(Alert.AlertType.INFORMATION); + setStageIcon(alert.getDialogPane().getScene().getWindow()); + alert.setTitle("Verification Succeeded"); + alert.setHeaderText("Verification Succeeded"); + alert.setContentText("The signature verified against the message."); + alert.showAndWait(); + } catch(SignatureException e) { + AppController.showErrorDialog("Verification failed", "The provided signature did not match the message for this address."); + } catch(Exception e) { + log.error("Could not verify message", e); + AppController.showErrorDialog("Could not verify message", e.getMessage()); + } + } + + @Subscribe + public void openWallets(OpenWalletsEvent event) { + Storage storage = event.getStorage(wallet); + if(storage == null) { + throw new IllegalStateException("Wallet " + wallet + " without Storage"); + } + + WalletPasswordDialog dlg = new WalletPasswordDialog(WalletPasswordDialog.PasswordRequirement.LOAD); + Optional password = dlg.showAndWait(); + if(password.isPresent()) { + Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(wallet.copy(), password.get()); + decryptWalletService.setOnSucceeded(workerStateEvent -> { + EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Done")); + Wallet decryptedWallet = decryptWalletService.getValue(); + signUnencryptedKeystore(decryptedWallet); + }); + decryptWalletService.setOnFailed(workerStateEvent -> { + EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Failed")); + AppController.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage()); + }); + EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.START, "Decrypting wallet...")); + decryptWalletService.start(); + } + } +} diff --git a/src/main/resources/com/sparrowwallet/sparrow/app.fxml b/src/main/resources/com/sparrowwallet/sparrow/app.fxml index 559c92f7..d43a487d 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/app.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/app.fxml @@ -63,6 +63,9 @@ + + +