add ability to import keystores and wallets with QR, add part protocol to QR scanner

This commit is contained in:
Craig Raw 2020-10-09 12:47:43 +02:00
parent 20f5e4d8db
commit 76e148a107
14 changed files with 179 additions and 42 deletions

View file

@ -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");
}
}
}

View file

@ -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<QRScanDialog.Result> 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) {

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<QRScanDialog.Result> {
private final URDecoder decoder;
private final WebcamService webcamService;
private List<String> 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<QRScanDialog.Result> {
//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<QRScanDialog.Result> {
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<QRScanDialog.Result> {
//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<QRScanDialog.Result> {
}
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<QRScanDialog.Result> {
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<QRScanDialog.Result> {
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<QRScanDialog.Result> {
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<QRScanDialog.Result> {
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<QRScanDialog.Result> {
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<QRScanDialog.Result> {
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<QRScanDialog.Result> {
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.error = null;
this.payload = null;
this.exception = exception;
}
}

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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 {

View file

@ -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.";

View file

@ -4,4 +4,5 @@ import java.io.File;
public interface FileImport extends Import {
boolean isEncrypted(File file);
boolean isScannable();
}

View file

@ -75,6 +75,11 @@ public class Specter implements WalletImport, WalletExport {
return false;
}
@Override
public boolean isScannable() {
return true;
}
@Override
public String getName() {
return "Specter";

View file

@ -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());