From 76e148a107a9999aa8e8517d6fae8caca11d110c Mon Sep 17 00:00:00 2001 From: Craig Raw Date: Fri, 9 Oct 2020 12:47:43 +0200 Subject: [PATCH] add ability to import keystores and wallets with QR, add part protocol to QR scanner --- .../sparrowwallet/sparrow/AppController.java | 11 +-- .../sparrow/control/FileImportPane.java | 79 ++++++++++++++++--- .../control/FileKeystoreImportPane.java | 2 +- .../sparrow/control/FileWalletImportPane.java | 2 +- .../control/FileWalletKeystoreImportPane.java | 2 +- .../sparrow/control/QRScanDialog.java | 67 +++++++++++++--- .../sparrow/io/CoboVaultMultisig.java | 9 ++- .../sparrow/io/CoboVaultSinglesig.java | 15 +++- .../sparrow/io/ColdcardMultisig.java | 16 +++- .../sparrow/io/ColdcardSinglesig.java | 5 ++ .../sparrowwallet/sparrow/io/Electrum.java | 5 ++ .../sparrowwallet/sparrow/io/FileImport.java | 1 + .../com/sparrowwallet/sparrow/io/Specter.java | 5 ++ .../sparrow/wallet/KeystoreController.java | 2 - 14 files changed, 179 insertions(+), 42 deletions(-) diff --git a/src/main/java/com/sparrowwallet/sparrow/AppController.java b/src/main/java/com/sparrowwallet/sparrow/AppController.java index b6628243..15f95125 100644 --- a/src/main/java/com/sparrowwallet/sparrow/AppController.java +++ b/src/main/java/com/sparrowwallet/sparrow/AppController.java @@ -559,17 +559,14 @@ public class AppController implements Initializable { if(result.transaction != null) { Tab tab = addTransactionTab(null, result.transaction); tabs.getSelectionModel().select(tab); - } - if(result.psbt != null) { + } else if(result.psbt != null) { Tab tab = addTransactionTab(null, result.psbt); tabs.getSelectionModel().select(tab); - } - if(result.error != null) { - showErrorDialog("Invalid QR Code", result.error); - } - if(result.exception != null) { + } else if(result.exception != null) { log.error("Error opening webcam", result.exception); showErrorDialog("Error opening webcam", result.exception.getMessage()); + } else { + AppController.showErrorDialog("Invalid QR Code", "Cannot parse QR code into a transaction or PSBT"); } } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/FileImportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/FileImportPane.java index 2175ed09..1ac561b3 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/FileImportPane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/FileImportPane.java @@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control; import com.google.gson.JsonParseException; import com.sparrowwallet.drongo.crypto.InvalidPasswordException; +import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; import com.sparrowwallet.sparrow.io.FileImport; import com.sparrowwallet.sparrow.io.ImportException; import javafx.beans.property.SimpleStringProperty; @@ -9,41 +10,72 @@ import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.Button; +import javafx.scene.control.ButtonBase; import javafx.scene.control.Control; +import javafx.scene.control.ToggleButton; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.stage.FileChooser; import javafx.stage.Stage; +import org.controlsfx.control.SegmentedButton; import org.controlsfx.control.textfield.CustomPasswordField; import org.controlsfx.control.textfield.TextFields; +import org.controlsfx.glyphfont.Glyph; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.BufferedInputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.InputStream; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.Optional; public abstract class FileImportPane extends TitledDescriptionPane { private static final Logger log = LoggerFactory.getLogger(FileImportPane.class); private final FileImport importer; - protected Button importButton; + protected ButtonBase importButton; private final SimpleStringProperty password = new SimpleStringProperty(""); + private final boolean scannable; - public FileImportPane(FileImport importer, String title, String description, String content, String imageUrl) { + public FileImportPane(FileImport importer, String title, String description, String content, String imageUrl, boolean scannable) { super(title, description, content, imageUrl); this.importer = importer; + this.scannable = scannable; + + buttonBox.getChildren().clear(); + buttonBox.getChildren().add(createButton()); } @Override protected Control createButton() { - importButton = new Button("Import File..."); - importButton.setAlignment(Pos.CENTER_RIGHT); - importButton.setOnAction(event -> { - importFile(); - }); - return importButton; + if(scannable) { + ToggleButton scanButton = new ToggleButton("Scan..."); + Glyph cameraGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CAMERA); + cameraGlyph.setFontSize(12); + scanButton.setGraphic(cameraGlyph); + scanButton.setOnAction(event -> { + scanButton.setSelected(false); + importQR(); + }); + + ToggleButton fileButton = new ToggleButton("Import File..."); + fileButton.setAlignment(Pos.CENTER_RIGHT); + fileButton.setOnAction(event -> { + fileButton.setSelected(false); + importFile(); + }); + importButton = fileButton; + + SegmentedButton segmentedButton = new SegmentedButton(); + segmentedButton.getButtons().addAll(scanButton, fileButton); + return segmentedButton; + } else { + importButton = new Button("Import File..."); + importButton.setAlignment(Pos.CENTER_RIGHT); + importButton.setOnAction(event -> { + importFile(); + }); + return importButton; + } } private void importFile() { @@ -94,6 +126,29 @@ public abstract class FileImportPane extends TitledDescriptionPane { } } + private void importQR() { + QRScanDialog qrScanDialog = new QRScanDialog(); + Optional optionalResult = qrScanDialog.showAndWait(); + if(optionalResult.isPresent()) { + QRScanDialog.Result result = optionalResult.get(); + if(result.payload != null) { + try { + importFile(importer.getName(), new ByteArrayInputStream(result.payload.getBytes(StandardCharsets.UTF_8)), null); + } catch(Exception e) { + log.error("Error importing QR", e); + String errorMessage = e.getMessage(); + if(e.getCause() != null && e.getCause().getMessage() != null && !e.getCause().getMessage().isEmpty()) { + errorMessage = e.getCause().getMessage(); + } + if(e instanceof JsonParseException || e.getCause() instanceof JsonParseException) { + errorMessage = "QR contents were not in JSON format"; + } + setError("Import Error", errorMessage); + } + } + } + } + protected abstract void importFile(String fileName, InputStream inputStream, String password) throws ImportException; private Node getPasswordEntry(File file) { diff --git a/src/main/java/com/sparrowwallet/sparrow/control/FileKeystoreImportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/FileKeystoreImportPane.java index 2b55b755..5d612e0f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/FileKeystoreImportPane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/FileKeystoreImportPane.java @@ -14,7 +14,7 @@ public class FileKeystoreImportPane extends FileImportPane { private final KeystoreFileImport importer; public FileKeystoreImportPane(Wallet wallet, KeystoreFileImport importer) { - super(importer, importer.getName(), "Keystore file import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png"); + super(importer, importer.getName(), "Keystore import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isScannable()); this.wallet = wallet; this.importer = importer; } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/FileWalletImportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/FileWalletImportPane.java index 3c356dcf..4be440fc 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/FileWalletImportPane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/FileWalletImportPane.java @@ -12,7 +12,7 @@ public class FileWalletImportPane extends FileImportPane { private final WalletImport importer; public FileWalletImportPane(WalletImport importer) { - super(importer, importer.getName(), "Wallet file import", importer.getWalletImportDescription(), "image/" + importer.getWalletModel().getType() + ".png"); + super(importer, importer.getName(), "Wallet import", importer.getWalletImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isScannable()); this.importer = importer; } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/FileWalletKeystoreImportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/FileWalletKeystoreImportPane.java index e160cdf6..799eafd9 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/FileWalletKeystoreImportPane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/FileWalletKeystoreImportPane.java @@ -36,7 +36,7 @@ public class FileWalletKeystoreImportPane extends FileImportPane { private byte[] fileBytes; public FileWalletKeystoreImportPane(KeystoreFileImport importer) { - super(importer, importer.getName(), "Wallet file import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png"); + super(importer, importer.getName(), "Wallet import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isScannable()); this.importer = importer; } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java index 3d3a3d64..44969247 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/QRScanDialog.java @@ -22,13 +22,23 @@ import javafx.scene.control.DialogPane; import javafx.scene.layout.StackPane; import org.controlsfx.tools.Borders; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.IntStream; + public class QRScanDialog extends Dialog { private final URDecoder decoder; private final WebcamService webcamService; + private List parts; private boolean isUr; private QRScanDialog.Result result; + private static final Pattern PART_PATTERN = Pattern.compile("p(\\d+)of(\\d+) (.+)"); + public QRScanDialog() { this.decoder = new URDecoder(); @@ -69,6 +79,8 @@ public class QRScanDialog extends Dialog { //Try text first String qrtext = qrResult.getText(); + Matcher partMatcher = PART_PATTERN.matcher(qrtext); + if(isUr || qrtext.toLowerCase().startsWith(UR.UR_PREFIX)) { isUr = true; decoder.receivePart(qrtext); @@ -102,6 +114,37 @@ public class QRScanDialog extends Dialog { result = new Result(urResult.error); } } + } else if(partMatcher.matches()) { + int m = Integer.parseInt(partMatcher.group(1)); + int n = Integer.parseInt(partMatcher.group(2)); + String payload = partMatcher.group(3); + + if(parts == null) { + parts = new ArrayList<>(n); + IntStream.range(0, n).forEach(i -> parts.add(null)); + } + parts.set(m - 1, payload); + + if(parts.stream().filter(Objects::nonNull).count() == n) { + String complete = String.join("", parts); + try { + PSBT psbt = PSBT.fromString(complete); + result = new Result(psbt); + return; + } catch(Exception e) { + //ignore, bytes not parsable as PSBT + } + + try { + Transaction transaction = new Transaction(Utils.hexToBytes(complete)); + result = new Result(transaction); + return; + } catch(Exception e) { + //ignore, bytes not parsable as tx + } + + result = new Result("Parsed QR parts were not a PSBT or transaction"); + } } else { PSBT psbt; Transaction transaction; @@ -166,7 +209,7 @@ public class QRScanDialog extends Dialog { //Try Base43 used by Electrum try { - psbt = new PSBT(Base43.decode(qrResult.getText())); + psbt = new PSBT(Base43.decode(qrtext)); result = new Result(psbt); return; } catch(Exception e) { @@ -174,14 +217,14 @@ public class QRScanDialog extends Dialog { } try { - transaction = new Transaction(Base43.decode(qrResult.getText())); + transaction = new Transaction(Base43.decode(qrtext)); result = new Result(transaction); return; } catch(Exception e) { //Ignore, not parseable as base43 decoded bytes } - result = new Result("Cannot parse QR code into a PSBT, transaction or address"); + result = new Result(qrtext); } } } @@ -191,7 +234,7 @@ public class QRScanDialog extends Dialog { public final PSBT psbt; public final BitcoinURI uri; public final ExtendedKey extendedKey; - public final String error; + public final String payload; public final Throwable exception; public Result(Transaction transaction) { @@ -199,7 +242,7 @@ public class QRScanDialog extends Dialog { this.psbt = null; this.uri = null; this.extendedKey = null; - this.error = null; + this.payload = null; this.exception = null; } @@ -208,7 +251,7 @@ public class QRScanDialog extends Dialog { this.psbt = psbt; this.uri = null; this.extendedKey = null; - this.error = null; + this.payload = null; this.exception = null; } @@ -217,7 +260,7 @@ public class QRScanDialog extends Dialog { this.psbt = null; this.uri = uri; this.extendedKey = null; - this.error = null; + this.payload = null; this.exception = null; } @@ -226,7 +269,7 @@ public class QRScanDialog extends Dialog { this.psbt = null; this.uri = BitcoinURI.fromAddress(address); this.extendedKey = null; - this.error = null; + this.payload = null; this.exception = null; } @@ -235,16 +278,16 @@ public class QRScanDialog extends Dialog { this.psbt = null; this.uri = null; this.extendedKey = extendedKey; - this.error = null; + this.payload = null; this.exception = null; } - public Result(String error) { + public Result(String payload) { this.transaction = null; this.psbt = null; this.uri = null; this.extendedKey = null; - this.error = error; + this.payload = payload; this.exception = null; } @@ -253,7 +296,7 @@ public class QRScanDialog extends Dialog { this.psbt = null; this.uri = null; this.extendedKey = null; - this.error = null; + this.payload = null; this.exception = exception; } } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/CoboVaultMultisig.java b/src/main/java/com/sparrowwallet/sparrow/io/CoboVaultMultisig.java index c5d61ff2..4ee9bfa0 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/CoboVaultMultisig.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/CoboVaultMultisig.java @@ -29,7 +29,7 @@ public class CoboVaultMultisig extends ColdcardMultisig { @Override public String getKeystoreImportDescription() { - return "Import file created by using the Multisig Wallet > Show/Export XPUB > Export All > Export feature on your Cobo Vault."; + return "Import file or QR created by using the Multisig Wallet > Show/Export XPUB > Export All > Export feature on your Cobo Vault."; } @Override @@ -45,11 +45,16 @@ public class CoboVaultMultisig extends ColdcardMultisig { @Override public String getWalletImportDescription() { - return "Import file created by using the Multisig Wallet > Create Multisig Wallet feature on your Cobo Vault."; + return "Import file or QR created by using the Multisig Wallet > Create Multisig Wallet feature on your Cobo Vault."; } @Override public String getWalletExportDescription() { return "Export file that can be read by your Cobo Vault using the Multisig Wallet > Import Multisig Wallet feature."; } + + @Override + public boolean isScannable() { + return true; + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/CoboVaultSinglesig.java b/src/main/java/com/sparrowwallet/sparrow/io/CoboVaultSinglesig.java index 58e5bb2f..f569c955 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/CoboVaultSinglesig.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/CoboVaultSinglesig.java @@ -23,7 +23,7 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport { @Override public String getKeystoreImportDescription() { - return "Import file created by using the Watch-Only Wallet > Generic Wallet > Export Wallet feature on your Cobo Vault."; + return "Import file or QR created by using the Watch-Only Wallet > Generic Wallet > Export Wallet feature on your Cobo Vault."; } @Override @@ -35,7 +35,11 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport { public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException { try { Gson gson = new Gson(); - CoboVaultKeystore coboKeystore = gson.fromJson(new InputStreamReader(inputStream), CoboVaultKeystore.class); + CoboVaultSinglesigKeystore coboKeystore = gson.fromJson(new InputStreamReader(inputStream), CoboVaultSinglesigKeystore.class); + + if(coboKeystore.MasterFingerprint == null || coboKeystore.AccountKeyPath == null || coboKeystore.ExtPubKey == null) { + throw new ImportException("Not a valid " + getName() + " keystore export"); + } Keystore keystore = new Keystore(); keystore.setLabel(getName()); @@ -83,7 +87,12 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport { return false; } - private static class CoboVaultKeystore { + @Override + public boolean isScannable() { + return true; + } + + private static class CoboVaultSinglesigKeystore { public String ExtPubKey; public String MasterFingerprint; public String AccountKeyPath; diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ColdcardMultisig.java b/src/main/java/com/sparrowwallet/sparrow/io/ColdcardMultisig.java index b7f1bcf8..b0be5e48 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/ColdcardMultisig.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/ColdcardMultisig.java @@ -37,7 +37,14 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle keystore.setSource(KeystoreSource.HW_AIRGAPPED); keystore.setWalletModel(WalletModel.COLDCARD); - if(scriptType.equals(ScriptType.P2SH)) { + if(cck.xpub != null && cck.path != null) { + ExtendedKey.Header header = ExtendedKey.Header.fromExtendedKey(cck.xpub); + if(header.getDefaultScriptType() != scriptType) { + throw new ImportException("This wallet's script type (" + scriptType + ") does not match the " + getName() + " script type (" + header.getDefaultScriptType() + ")"); + } + keystore.setKeyDerivation(new KeyDerivation(cck.xfp, cck.path)); + keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(cck.xpub)); + } else if(scriptType.equals(ScriptType.P2SH)) { keystore.setKeyDerivation(new KeyDerivation(cck.xfp, cck.p2sh_deriv)); keystore.setExtendedPublicKey(ExtendedKey.fromDescriptor(cck.p2sh)); } else if(scriptType.equals(ScriptType.P2SH_P2WSH)) { @@ -60,6 +67,8 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle public String p2wsh_p2sh; public String p2wsh_deriv; public String p2wsh; + public String xpub; + public String path; public String xfp; } @@ -200,4 +209,9 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle public boolean isEncrypted(File file) { return false; } + + @Override + public boolean isScannable() { + return false; + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ColdcardSinglesig.java b/src/main/java/com/sparrowwallet/sparrow/io/ColdcardSinglesig.java index 790bb454..acbdb34d 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/ColdcardSinglesig.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/ColdcardSinglesig.java @@ -40,6 +40,11 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport { return false; } + @Override + public boolean isScannable() { + return false; + } + @Override public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException { try { diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Electrum.java b/src/main/java/com/sparrowwallet/sparrow/io/Electrum.java index 13ffbd6d..e91a76d6 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Electrum.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Electrum.java @@ -317,6 +317,11 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport return (FileType.BINARY.equals(IOUtils.getFileType(file))); } + @Override + public boolean isScannable() { + return false; + } + @Override public String getWalletExportDescription() { return "Export this wallet as an Electrum wallet file."; diff --git a/src/main/java/com/sparrowwallet/sparrow/io/FileImport.java b/src/main/java/com/sparrowwallet/sparrow/io/FileImport.java index ef2a6114..1cf3bc8c 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/FileImport.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/FileImport.java @@ -4,4 +4,5 @@ import java.io.File; public interface FileImport extends Import { boolean isEncrypted(File file); + boolean isScannable(); } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/Specter.java b/src/main/java/com/sparrowwallet/sparrow/io/Specter.java index 98890bc8..c9fb4dfe 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/Specter.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/Specter.java @@ -75,6 +75,11 @@ public class Specter implements WalletImport, WalletExport { return false; } + @Override + public boolean isScannable() { + return true; + } + @Override public String getName() { return "Specter"; diff --git a/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java b/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java index 6ff07449..fbcd36e7 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java @@ -362,8 +362,6 @@ public class KeystoreController extends WalletFormController implements Initiali QRScanDialog.Result result = optionalResult.get(); if(result.extendedKey != null && result.extendedKey.getKey().isPubKeyOnly()) { xpub.setText(result.extendedKey.getExtendedKey()); - } else if(result.error != null) { - AppController.showErrorDialog("Invalid QR Code", result.error); } else if(result.exception != null) { log.error("Error opening webcam", result.exception); AppController.showErrorDialog("Error opening webcam", result.exception.getMessage());