diff --git a/build.gradle b/build.gradle index 2f49c0a7..cb6a7b77 100644 --- a/build.gradle +++ b/build.gradle @@ -493,6 +493,8 @@ extraJavaModuleInfo { } module('cbor-0.9.jar', 'co.nstant.in.cbor', '0.9') { exports('co.nstant.in.cbor') + exports('co.nstant.in.cbor.model') + exports('co.nstant.in.cbor.builder') } module('nightjar-0.2.34.jar', 'com.sparrowwallet.nightjar', '0.2.34') { requires('com.google.common') diff --git a/drongo b/drongo index c642f741..2168c56d 160000 --- a/drongo +++ b/drongo @@ -1 +1 @@ -Subproject commit c642f7414a35c9ad69439687ba8724b76d9a6fb1 +Subproject commit 2168c56de924dd7bff5458b32f9a60a6c79066a1 diff --git a/src/main/java/com/sparrowwallet/sparrow/control/CardImportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/CardImportPane.java new file mode 100644 index 00000000..6afa5f87 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/CardImportPane.java @@ -0,0 +1,148 @@ +package com.sparrowwallet.sparrow.control; + +import com.google.common.base.Throwables; +import com.sparrowwallet.drongo.KeyDerivation; +import com.sparrowwallet.drongo.crypto.ChildNumber; +import com.sparrowwallet.drongo.wallet.Keystore; +import com.sparrowwallet.drongo.wallet.Wallet; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.event.KeystoreImportEvent; +import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; +import com.sparrowwallet.sparrow.io.KeystoreCardImport; +import com.sparrowwallet.sparrow.io.ckcard.CardAuthorizationException; +import javafx.beans.property.SimpleStringProperty; +import javafx.concurrent.Service; +import javafx.concurrent.Task; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Control; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import org.controlsfx.control.textfield.CustomPasswordField; +import org.controlsfx.glyphfont.Glyph; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +public class CardImportPane extends TitledDescriptionPane { + private static final Logger log = LoggerFactory.getLogger(CardImportPane.class); + + private final KeystoreCardImport importer; + private final List derivation; + protected Button importButton; + private final SimpleStringProperty pin = new SimpleStringProperty(""); + private final SimpleStringProperty errorText = new SimpleStringProperty(""); + + public CardImportPane(Wallet wallet, KeystoreCardImport importer, KeyDerivation requiredDerivation) { + super(importer.getName(), "Keystore import", importer.getKeystoreImportDescription(getAccount(wallet, requiredDerivation)), "image/" + importer.getWalletModel().getType() + ".png"); + this.importer = importer; + this.derivation = requiredDerivation == null ? wallet.getScriptType().getDefaultDerivation() : requiredDerivation.getDerivation(); + } + + @Override + protected Control createButton() { + importButton = new Button("Tap"); + Glyph tapGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WIFI); + tapGlyph.setFontSize(12); + importButton.setGraphic(tapGlyph); + importButton.setAlignment(Pos.CENTER_RIGHT); + importButton.setOnAction(event -> { + importCard(); + }); + return importButton; + } + + private void importCard() { + if(pin.get().isEmpty()) { + setDescription("Enter PIN code"); + setContent(getPinEntry()); + setExpanded(true); + return; + } + + CardImportService cardImportService = new CardImportService(importer, pin.get(), derivation); + cardImportService.messageProperty().addListener((observable, oldValue, newValue) -> { + setDescription(newValue); + }); + cardImportService.setOnSucceeded(event -> { + EventManager.get().post(new KeystoreImportEvent(cardImportService.getValue())); + }); + cardImportService.setOnFailed(event -> { + log.error("Error importing keystore from card", event.getSource().getException()); + Throwable rootCause = Throwables.getRootCause(event.getSource().getException()); + if(rootCause instanceof CardAuthorizationException) { + setError("Import Error", "Incorrect PIN code, try again:"); + } else { + setError("Import Error", rootCause.getMessage()); + } + }); + cardImportService.start(); + } + + @Override + protected void setError(String title, String detail) { + super.setError(title, null); + errorText.set(detail); + setContent(getPinEntry()); + setExpanded(true); + } + + private Node getPinEntry() { + VBox vBox = new VBox(); + + if(!errorText.get().isEmpty()) { + Node contextBox = getContentBox(errorText.get()); + if(contextBox instanceof HBox hBox && hBox.getPrefHeight() == 60) { + hBox.setPrefHeight(50); + } + vBox.getChildren().add(contextBox); + } + + CustomPasswordField pinField = new ViewPasswordField(); + pinField.setPromptText("PIN Code"); + pinField.setText(pin.get()); + importButton.setDefaultButton(true); + pin.bind(pinField.textProperty()); + HBox.setHgrow(pinField, Priority.ALWAYS); + + HBox contentBox = new HBox(); + contentBox.setAlignment(Pos.TOP_RIGHT); + contentBox.setSpacing(20); + contentBox.getChildren().add(pinField); + contentBox.setPadding(new Insets(errorText.get().isEmpty() ? 10 : 0, 30, 10, 30)); + contentBox.setPrefHeight(50); + + vBox.getChildren().add(contentBox); + + return vBox; + } + + public static class CardImportService extends Service { + private final KeystoreCardImport cardImport; + private final String pin; + private final List derivation; + + public CardImportService(KeystoreCardImport cardImport, String pin, List derivation) { + this.cardImport = cardImport; + this.pin = pin; + this.derivation = derivation; + } + + @Override + protected Task createTask() { + return new Task<>() { + @Override + protected Keystore call() throws Exception { + cardImport.messageProperty().addListener((observable, oldValue, newValue) -> { + updateMessage(newValue); + }); + return cardImport.getKeystore(pin, derivation); + } + }; + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/control/FileKeystoreImportPane.java b/src/main/java/com/sparrowwallet/sparrow/control/FileKeystoreImportPane.java index fbb82cab..05cdc424 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/FileKeystoreImportPane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/FileKeystoreImportPane.java @@ -34,17 +34,4 @@ public class FileKeystoreImportPane extends FileImportPane { EventManager.get().post(new KeystoreImportEvent(keystore)); } } - - private static int getAccount(Wallet wallet, KeyDerivation requiredDerivation) { - if(wallet == null || requiredDerivation == null) { - return 0; - } - - int account = wallet.getScriptType().getAccount(requiredDerivation.getDerivationPath()); - if(account < 0) { - account = 0; - } - - return account; - } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/TitledDescriptionPane.java b/src/main/java/com/sparrowwallet/sparrow/control/TitledDescriptionPane.java index 7cd41edd..b87017d6 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/TitledDescriptionPane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/TitledDescriptionPane.java @@ -1,5 +1,7 @@ package com.sparrowwallet.sparrow.control; +import com.sparrowwallet.drongo.KeyDerivation; +import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.sparrow.AppServices; import javafx.application.Platform; import javafx.geometry.Insets; @@ -159,4 +161,17 @@ public class TitledDescriptionPane extends TitledPane { } }); } + + protected static int getAccount(Wallet wallet, KeyDerivation requiredDerivation) { + if(wallet == null || requiredDerivation == null) { + return 0; + } + + int account = wallet.getScriptType().getAccount(requiredDerivation.getDerivationPath()); + if(account < 0) { + account = 0; + } + + return account; + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/WalletIcon.java b/src/main/java/com/sparrowwallet/sparrow/control/WalletIcon.java index 0a5f2c29..d5dca06f 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/WalletIcon.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/WalletIcon.java @@ -36,10 +36,18 @@ public class WalletIcon extends StackPane { private final Storage storage; private final ObjectProperty walletProperty = new SimpleObjectProperty<>(); + private final Keystore keystore; + private final Glyph fallbackIcon; public WalletIcon(Storage storage, Wallet wallet) { + this(storage, wallet, null, null); + } + + public WalletIcon(Storage storage, Wallet wallet, Keystore keystore, Glyph fallbackIcon) { super(); this.storage = storage; + this.keystore = keystore; + this.fallbackIcon = fallbackIcon; setPrefSize(WIDTH, HEIGHT); walletProperty.addListener((observable, oldValue, newValue) -> { refresh(); @@ -58,8 +66,8 @@ public class WalletIcon extends StackPane { } else { Platform.runLater(() -> addWalletIcon(walletId)); } - } else if(wallet.getKeystores().size() == 1) { - Keystore keystore = wallet.getKeystores().get(0); + } else if(this.keystore != null || wallet.getKeystores().size() == 1) { + Keystore keystore = this.keystore == null ? wallet.getKeystores().get(0) : this.keystore; if(keystore.getSource() == KeystoreSource.HW_USB || keystore.getSource() == KeystoreSource.HW_AIRGAPPED) { WalletModel walletModel = keystore.getWalletModel(); @@ -96,7 +104,10 @@ public class WalletIcon extends StackPane { } if(getChildren().isEmpty()) { - Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WALLET); + Glyph glyph = fallbackIcon; + if(glyph == null) { + glyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WALLET); + } glyph.setFontSize(10.0); getChildren().add(glyph); } diff --git a/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java b/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java index e66bd20c..76410f75 100644 --- a/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java +++ b/src/main/java/com/sparrowwallet/sparrow/glyphfont/FontAwesome5.java @@ -82,7 +82,8 @@ public class FontAwesome5 extends GlyphFont { USER_PLUS('\uf234'), USER_SLASH('\uf506'), WALLET('\uf555'), - WEIGHT('\uf496'); + WEIGHT('\uf496'), + WIFI('\uf1eb'); private final char ch; diff --git a/src/main/java/com/sparrowwallet/sparrow/io/CardImport.java b/src/main/java/com/sparrowwallet/sparrow/io/CardImport.java new file mode 100644 index 00000000..f6a2aaa4 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/CardImport.java @@ -0,0 +1,7 @@ +package com.sparrowwallet.sparrow.io; + +import javafx.beans.property.StringProperty; + +public interface CardImport extends ImportExport { + StringProperty messageProperty(); +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/KeystoreCardImport.java b/src/main/java/com/sparrowwallet/sparrow/io/KeystoreCardImport.java new file mode 100644 index 00000000..05d36ea6 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/KeystoreCardImport.java @@ -0,0 +1,11 @@ +package com.sparrowwallet.sparrow.io; + +import com.sparrowwallet.drongo.crypto.ChildNumber; +import com.sparrowwallet.drongo.wallet.Keystore; + +import java.util.List; + +public interface KeystoreCardImport extends CardImport { + Keystore getKeystore(String pin, List derivation) throws ImportException; + String getKeystoreImportDescription(int account); +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardApi.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardApi.java new file mode 100644 index 00000000..9aadb989 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardApi.java @@ -0,0 +1,84 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +import com.sparrowwallet.drongo.ExtendedKey; +import com.sparrowwallet.drongo.KeyDerivation; +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.crypto.ChildNumber; +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.StringProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.smartcardio.CardException; +import java.util.List; + +public class CardApi { + private static final Logger log = LoggerFactory.getLogger(CardApi.class); + + private final CardProtocol cardProtocol; + private String cvc; + + public CardApi(String cvc) throws CardException { + this.cardProtocol = new CardProtocol(); + this.cvc = cvc; + } + + public void initialize() throws CardException { + cardProtocol.verify(); + cardProtocol.setup(cvc, null); + } + + CardStatus getStatus() throws CardException { + return cardProtocol.getStatus(); + } + + void checkWait(CardStatus cardStatus, StringProperty messageProperty) throws CardException { + if(cardStatus.auth_delay != null) { + int delay = cardStatus.auth_delay.intValue(); + while(delay > 0) { + messageProperty.set("Auth delay, waiting " + delay + "s..."); + CardWait cardWait = cardProtocol.authWait(); + if(cardWait.success) { + delay = cardWait.auth_delay == null ? 0 : cardWait.auth_delay.intValue(); + } + } + } + } + + public void setDerivation(List derivation) throws CardException { + cardProtocol.derive(cvc, derivation); + } + + public Keystore getKeystore() throws CardException { + CardStatus cardStatus = cardProtocol.getStatus(); + + CardXpub masterXpub = cardProtocol.xpub(cvc, true); + ExtendedKey masterXpubkey = ExtendedKey.fromDescriptor(Base58.encodeChecked(masterXpub.xpub)); + String masterFingerprint = Utils.bytesToHex(masterXpubkey.getKey().getFingerprint()); + + KeyDerivation keyDerivation = new KeyDerivation(masterFingerprint, cardStatus.getDerivation()); + + CardXpub derivedXpub = cardProtocol.xpub(cvc, false); + ExtendedKey derivedXpubkey = ExtendedKey.fromDescriptor(Base58.encodeChecked(derivedXpub.xpub)); + + Keystore keystore = new Keystore(); + keystore.setLabel(WalletModel.TAPSIGNER.toDisplayString()); + keystore.setKeyDerivation(keyDerivation); + keystore.setSource(KeystoreSource.HW_AIRGAPPED); + keystore.setExtendedPublicKey(derivedXpubkey); + keystore.setWalletModel(WalletModel.TAPSIGNER); + + return keystore; + } + + public void disconnect() { + try { + cardProtocol.disconnect(); + } catch(CardException e) { + log.warn("Error disconnecting from card reader", e); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardAuthorizationException.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardAuthorizationException.java new file mode 100644 index 00000000..4322f45d --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardAuthorizationException.java @@ -0,0 +1,17 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +import javax.smartcardio.CardException; + +public class CardAuthorizationException extends CardException { + public CardAuthorizationException(String message) { + super(message); + } + + public CardAuthorizationException(Throwable cause) { + super(cause); + } + + public CardAuthorizationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardBackup.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardBackup.java new file mode 100644 index 00000000..21ec551b --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardBackup.java @@ -0,0 +1,5 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +public class CardBackup extends CardResponse { + byte[] data; +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardCerts.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardCerts.java new file mode 100644 index 00000000..05a6ef4f --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardCerts.java @@ -0,0 +1,7 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +import java.util.List; + +public class CardCerts { + List cert_chain; +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardChange.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardChange.java new file mode 100644 index 00000000..ddb96e27 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardChange.java @@ -0,0 +1,5 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +public class CardChange extends CardResponse { + boolean success; +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardDerive.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardDerive.java new file mode 100644 index 00000000..c555110f --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardDerive.java @@ -0,0 +1,19 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +import com.sparrowwallet.drongo.crypto.ECDSASignature; + +import java.math.BigInteger; +import java.util.Arrays; + +public class CardDerive extends CardResponse { + byte[] sig; + byte[] chain_code; + byte[] master_pubkey; + byte[] pubkey; + + public ECDSASignature getSignature() { + BigInteger r = new BigInteger(1, Arrays.copyOfRange(sig, 0, 32)); + BigInteger s = new BigInteger(1, Arrays.copyOfRange(sig, 32, 64)); + return new ECDSASignature(r, s); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardProtocol.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardProtocol.java new file mode 100644 index 00000000..55da7e66 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardProtocol.java @@ -0,0 +1,266 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +import com.google.gson.*; +import com.sparrowwallet.drongo.Utils; +import com.sparrowwallet.drongo.crypto.ChildNumber; +import com.sparrowwallet.drongo.crypto.ECDSASignature; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.protocol.Sha256Hash; +import org.bitcoin.NativeSecp256k1; +import org.bitcoin.NativeSecp256k1Util; +import org.bitcoin.Secp256k1Context; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.smartcardio.CardException; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.security.SignatureException; +import java.util.*; +import java.util.stream.Collectors; + +public class CardProtocol { + private static final Logger log = LoggerFactory.getLogger(CardProtocol.class); + + public static final byte[] OPENDIME_HEADER = "OPENDIME".getBytes(StandardCharsets.UTF_8); + public static final List FACTORY_ROOT_KEYS = List.of(Utils.hexToBytes("03028a0e89e70d0ec0d932053a89ab1da7d9182bdc6d2f03e706ee99517d05d9e1")); + public static final int MAX_PATH_DEPTH = 8; + + private final CardTransport cardTransport; + private final Gson gson; + + private final SecureRandom secureRandom = new SecureRandom(); + + private byte[] cardPubkey; + private byte[] lastCardNonce; + + public CardProtocol() throws CardException { + this.cardTransport = new CardTransport(); + this.gson = new GsonBuilder().registerTypeAdapter(byte[].class, new ByteArrayToHexTypeAdapter()).create(); + } + + public CardStatus getStatus() throws CardException { + JsonObject status = send("status"); + CardStatus cardStatus = gson.fromJson(status, CardStatus.class); + if(cardPubkey == null) { + cardPubkey = cardStatus.pubkey; + } + + return cardStatus; + } + + public CardCerts getCerts() throws CardException { + JsonObject certs = send("certs"); + return gson.fromJson(certs, CardCerts.class); + } + + public void verify() throws CardException { + CardCerts cardCerts = getCerts(); + + byte[] userNonce = getNonce(); + byte[] cardNonce = lastCardNonce; + JsonObject certs = send("check", Map.of("nonce", userNonce)); + CardSignature cardSignature = gson.fromJson(certs, CardSignature.class); + Sha256Hash verificationData = getVerificationData(cardNonce, userNonce, new byte[0]); + ECDSASignature ecdsaSignature = cardSignature.getSignature(); + if(!ecdsaSignature.verify(verificationData.getBytes(), cardPubkey)) { + throw new CardException("Card authentication failure: Provided signature did not match public key"); + } + + byte[] pubkey = cardPubkey; + for(byte[] cert : cardCerts.cert_chain) { + Sha256Hash pubkeyHash = Sha256Hash.of(pubkey); + + try { + ECKey recoveredKey = ECKey.signedHashToKey(pubkeyHash, cert, false); + pubkey = recoveredKey.getPubKey(); + } catch(SignatureException e) { + throw new CardException("Card signature error", e); + } + } + + byte[] rootPubKey = pubkey; + if(FACTORY_ROOT_KEYS.stream().noneMatch(key -> Arrays.equals(key, rootPubKey))) { + throw new CardException("Card authentication failure: Could not verify to root certificate"); + } + } + + public CardRead read(String cvc) throws CardException { + Map args = new HashMap<>(); + args.put("nonce", getNonce()); + + JsonObject read = sendAuth("read", args, cvc); + return gson.fromJson(read, CardRead.class); + } + + public CardSetup setup(String cvc, byte[] entropy) throws CardException { + if(entropy == null) { + entropy = Sha256Hash.hashTwice(secureRandom.generateSeed(128)); + } + + if(entropy.length != 32) { + throw new IllegalArgumentException("Invalid entropy length of " + entropy.length); + } + + Map args = new HashMap<>(); + args.put("chain_code", entropy); + JsonObject setup = sendAuth("new", args, cvc); + return gson.fromJson(setup, CardSetup.class); + } + + public CardWait authWait() throws CardException { + JsonObject wait = send("wait"); + return gson.fromJson(wait, CardWait.class); + } + + public CardXpub xpub(String cvc, boolean master) throws CardException { + Map args = new HashMap<>(); + args.put("master", master); + + JsonObject xpub = sendAuth("xpub", args, cvc); + return gson.fromJson(xpub, CardXpub.class); + } + + public CardDerive derive(String cvc, List path) throws CardException { + if(path.stream().anyMatch(childNumber -> !childNumber.isHardened())) { + throw new IllegalArgumentException("Derivation path cannot contain unhardened components"); + } + + if(path.size() > MAX_PATH_DEPTH) { + throw new IllegalArgumentException("Derivation path cannot have more than " + MAX_PATH_DEPTH + " components"); + } + + if(lastCardNonce == null || cardPubkey == null) { + getStatus(); + } + + byte[] userNonce = getNonce(); + Map args = new HashMap<>(); + args.put("path", path.stream().map(cn -> Integer.toUnsignedLong(cn.i())).collect(Collectors.toList())); + args.put("nonce", userNonce); + + JsonObject derive = sendAuth("derive", args, cvc); + return gson.fromJson(derive, CardDerive.class); + } + + public CardSign sign(String cvc, List subpath, Sha256Hash digest) throws CardException { + if(subpath.stream().anyMatch(ChildNumber::isHardened)) { + throw new IllegalArgumentException("Derivation path cannot contain hardened components"); + } + + Map args = new HashMap<>(); + args.put("subpath", subpath.stream().map(cn -> Integer.toUnsignedLong(cn.i())).collect(Collectors.toList())); + args.put("digest", digest.getBytes()); + + for(int attempt = 0; attempt < 5; attempt++) { + try { + JsonObject sign = sendAuth("sign", args, cvc); + CardSign cardSign = gson.fromJson(sign, CardSign.class); + if(!cardSign.getSignature().verify(digest.getBytes(), cardSign.pubkey)) { + continue; + } + + return cardSign; + } catch(CardUnluckyNumberException e) { + log.debug("Got unlucky number signing, trying again..."); + } + } + + throw new CardSignFailedException("Failed to sign digest after 5 tries."); + } + + public CardChange change(String currentCvc, String newCvc) throws CardException { + if(newCvc.length() < 6 || newCvc.length() > 32) { + throw new IllegalArgumentException("CVC cannot be of length " + newCvc.length()); + } + + Map args = new HashMap<>(); + args.put("data", newCvc.getBytes(StandardCharsets.UTF_8)); + JsonObject change = sendAuth("change", args, currentCvc); + return gson.fromJson(change, CardChange.class); + } + + public CardBackup backup(String cvc) throws CardException { + JsonObject backup = sendAuth("backup", new HashMap<>(), cvc); + return gson.fromJson(backup, CardBackup.class); + } + + public void disconnect() throws CardException { + cardTransport.disconnect(); + } + + private JsonObject send(String cmd) throws CardException { + return send(cmd, Collections.emptyMap()); + } + + private JsonObject send(String cmd, Map args) throws CardException { + JsonObject jsonObject = cardTransport.send(cmd, args); + if(jsonObject.get("card_nonce") != null) { + lastCardNonce = gson.fromJson(jsonObject, CardResponse.class).card_nonce; + } + + return jsonObject; + } + + private JsonObject sendAuth(String cmd, Map args, String cvc) throws CardException { + addAuth(cmd, args, cvc); + return send(cmd, args); + } + + public void addAuth(String cmd, Map args, String cvc) throws CardException { + if(cvc.length() < 6 || cvc.length() > 32) { + throw new IllegalArgumentException("CVC cannot be of length " + cvc.length()); + } + + if(lastCardNonce == null || cardPubkey == null) { + getStatus(); + } + + try { + ECKey ephemeralKey = new ECKey(secureRandom); + if(Secp256k1Context.isEnabled()) { + byte[] sessionKey = NativeSecp256k1.createECDHSecret(ephemeralKey.getPrivKeyBytes(), cardPubkey); + byte[] md = Sha256Hash.hash(Utils.concat(lastCardNonce, cmd.getBytes(StandardCharsets.UTF_8))); + byte[] mask = Arrays.copyOf(Utils.xor(sessionKey, md), cvc.length()); + byte[] xcvc = Utils.xor(cvc.getBytes(StandardCharsets.UTF_8), mask); + + args.put("epubkey", ephemeralKey.getPubKey()); + args.put("xcvc", xcvc); + + if(cmd.equals("sign") && args.get("digest") instanceof byte[] digestBytes) { + args.put("digest", Utils.xor(digestBytes, sessionKey)); + } else if(cmd.equals("change") && args.get("data") instanceof byte[] dataBytes) { + args.put("data", Utils.xor(dataBytes, Arrays.copyOf(sessionKey, dataBytes.length))); + } + } + } catch(NativeSecp256k1Util.AssertFailException e) { + throw new RuntimeException(e); + } + } + + private Sha256Hash getVerificationData(byte[] cardNonce, byte[] userNonce, byte[] commandData) { + byte[] data = new byte[40 + commandData.length]; + System.arraycopy(OPENDIME_HEADER, 0, data, 0, 8); + System.arraycopy(cardNonce, 0, data, 8, 16); + System.arraycopy(userNonce, 0, data, 24, 16); + System.arraycopy(commandData, 0, data, 40, commandData.length); + return Sha256Hash.of(data); + } + + private byte[] getNonce() { + byte[] nonce = new byte[16]; + secureRandom.nextBytes(nonce); + return nonce; + } + + private static class ByteArrayToHexTypeAdapter implements JsonSerializer, JsonDeserializer { + public byte[] deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + return Utils.hexToBytes(json.getAsString()); + } + + public JsonElement serialize(byte[] src, Type typeOfSrc, JsonSerializationContext context) { + return new JsonPrimitive(Utils.bytesToHex(src)); + } + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardRead.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardRead.java new file mode 100644 index 00000000..d04e237d --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardRead.java @@ -0,0 +1,7 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +public class CardRead { + byte[] sig; + byte[] pubkey; + byte[] card_nonce; +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardResponse.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardResponse.java new file mode 100644 index 00000000..746b0a1e --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardResponse.java @@ -0,0 +1,5 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +public class CardResponse { + byte[] card_nonce; +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardSetup.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardSetup.java new file mode 100644 index 00000000..cec08355 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardSetup.java @@ -0,0 +1,5 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +public class CardSetup extends CardResponse { + int slot; +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardSign.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardSign.java new file mode 100644 index 00000000..42afd4aa --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardSign.java @@ -0,0 +1,22 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +import com.sparrowwallet.drongo.crypto.ECDSASignature; + +import java.math.BigInteger; +import java.util.Arrays; + +public class CardSign extends CardResponse { + int slot; + byte[] sig; + byte[] pubkey; + + public ECDSASignature getSignature() { + if(sig != null) { + BigInteger r = new BigInteger(1, Arrays.copyOfRange(sig, 0, 32)); + BigInteger s = new BigInteger(1, Arrays.copyOfRange(sig, 32, 64)); + return new ECDSASignature(r, s); + } + + return null; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardSignFailedException.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardSignFailedException.java new file mode 100644 index 00000000..00fbb992 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardSignFailedException.java @@ -0,0 +1,17 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +import javax.smartcardio.CardException; + +public class CardSignFailedException extends CardException { + public CardSignFailedException(String message) { + super(message); + } + + public CardSignFailedException(Throwable cause) { + super(cause); + } + + public CardSignFailedException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardSignature.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardSignature.java new file mode 100644 index 00000000..996855f1 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardSignature.java @@ -0,0 +1,16 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +import com.sparrowwallet.drongo.crypto.ECDSASignature; + +import java.math.BigInteger; +import java.util.Arrays; + +public class CardSignature extends CardResponse { + byte[] auth_sig; + + public ECDSASignature getSignature() { + BigInteger r = new BigInteger(1, Arrays.copyOfRange(auth_sig, 0, 32)); + BigInteger s = new BigInteger(1, Arrays.copyOfRange(auth_sig, 32, 64)); + return new ECDSASignature(r, s); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardStatus.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardStatus.java new file mode 100644 index 00000000..2d865b57 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardStatus.java @@ -0,0 +1,55 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +import com.google.common.io.BaseEncoding; +import com.sparrowwallet.drongo.crypto.ChildNumber; +import com.sparrowwallet.drongo.protocol.Sha256Hash; + +import java.math.BigInteger; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +public class CardStatus extends CardResponse { + int proto; + String ver; + BigInteger birth; + Boolean tapsigner; + List path; + BigInteger num_backups; + byte[] pubkey; + BigInteger auth_delay; + boolean testnet; + + public boolean isInitialized() { + return path != null; + } + + public String getIdentifier() { + byte[] pubkeyHash = Sha256Hash.hash(pubkey); + String base32 = BaseEncoding.base32().encode(Arrays.copyOfRange(pubkeyHash, 8, 32)); + return base32.replaceAll("(.{5})", "$1-"); + } + + public List getDerivation() { + if(isInitialized()) { + return path.stream().map(i -> new ChildNumber(i.intValue())).collect(Collectors.toList()); + } + + return null; + } + + @Override + public String toString() { + return "CardStatus{" + + "proto=" + proto + + ", ver='" + ver + '\'' + + ", birth=" + birth + + ", tapsigner=" + tapsigner + + ", path=" + path + + ", num_backups=" + num_backups + + ", pubkey=" + Arrays.toString(pubkey) + + ", auth_delay=" + auth_delay + + ", testnet=" + testnet + + '}'; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardTransport.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardTransport.java new file mode 100644 index 00000000..ed167fee --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardTransport.java @@ -0,0 +1,163 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +import co.nstant.in.cbor.CborBuilder; +import co.nstant.in.cbor.CborDecoder; +import co.nstant.in.cbor.CborEncoder; +import co.nstant.in.cbor.CborException; +import co.nstant.in.cbor.builder.ArrayBuilder; +import co.nstant.in.cbor.builder.MapBuilder; +import co.nstant.in.cbor.model.*; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.sparrowwallet.drongo.Utils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.smartcardio.*; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class CardTransport { + private static final Logger log = LoggerFactory.getLogger(CardTransport.class); + + public static final String APPID = "f0436f696e6b697465434152447631"; + private static final int CBOR_CLA = 0x00; + private static final int CBOR_INS = 0xCB; + private static final int SW_OKAY = 0x9000; + + private final Card connection; + + public CardTransport() throws CardException { + TerminalFactory tf = TerminalFactory.getDefault(); + List terminals = tf.terminals().list(); + if(terminals.isEmpty()) { + throw new IllegalStateException("No reader connected"); + } + + CardTerminal cardTerminal = (CardTerminal)terminals.get(0); + connection = cardTerminal.connect("*"); + + CardChannel cardChannel = connection.getBasicChannel(); + ResponseAPDU resp = cardChannel.transmit(new CommandAPDU(0, 0xA4, 4, 0, Utils.hexToBytes(APPID.toUpperCase()))); + if(resp.getSW() != SW_OKAY) { + throw new CardException("Card returned response of " + resp.getSW()); + } + } + + public JsonObject send(String cmd, Map args) throws CardException { + Map sendMap = new LinkedHashMap<>(); + sendMap.put("cmd", cmd); + sendMap.putAll(args); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + MapBuilder cborBuilder = new CborBuilder().addMap(); + for(Map.Entry entry : sendMap.entrySet()) { + if(entry.getValue() instanceof String strValue) { + cborBuilder.put(entry.getKey(), strValue); + } else if(entry.getValue() instanceof byte[] byteValue) { + cborBuilder.put(entry.getKey(), byteValue); + } else if(entry.getValue() instanceof Long longValue) { + cborBuilder.put(entry.getKey(), longValue); + } else if(entry.getValue() instanceof Boolean booleanValue) { + cborBuilder.put(entry.getKey(), booleanValue); + } else if(entry.getValue() instanceof List listValue) { + ArrayBuilder> arrayBuilder = cborBuilder.putArray(entry.getKey()); + for(Object value : listValue) { + if(value instanceof String strValue) { + arrayBuilder.add(strValue); + } else if(value instanceof byte[] byteValue) { + arrayBuilder.add(byteValue); + } else if(value instanceof Long longValue) { + arrayBuilder.add(longValue); + } else if(value instanceof Boolean booleanValue) { + arrayBuilder.add(booleanValue); + } + } + arrayBuilder.end(); + } + } + + try { + new CborEncoder(baos).encode(cborBuilder.end().build()); + byte[] sendBytes = baos.toByteArray(); + + CardChannel cardChannel = connection.getBasicChannel(); + ResponseAPDU resp = cardChannel.transmit(new CommandAPDU(CBOR_CLA, CBOR_INS, 0, 0, sendBytes)); + + if(resp.getSW() != SW_OKAY) { + throw new CardException("Received error SW value " + resp.getSW()); + } + + ByteArrayInputStream bais = new ByteArrayInputStream(resp.getData()); + List dataItems = new CborDecoder(bais).decode(); + for(DataItem dataItem : dataItems) { + if(dataItem instanceof co.nstant.in.cbor.model.Map map) { + JsonObject result = new JsonObject(); + for(DataItem key : map.getKeys()) { + String strKey = key.toString(); + result.add(strKey, getJsonElement(map.get(key))); + } + + 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); + } else if(code == 401) { + throw new CardAuthorizationException(message); + } + + throw new CardException(code + " on " + cmd + ": " + msg); + } + + return result; + } + } + } catch(CborException e) { + e.printStackTrace(); + } + + return new JsonObject(); + } + + private JsonElement getJsonElement(DataItem dataItem) { + if(dataItem instanceof UnicodeString strValue) { + return new JsonPrimitive(strValue.toString()); + } else if(dataItem instanceof ByteString byteString) { + return new JsonPrimitive(Utils.bytesToHex(byteString.getBytes())); + } else if(dataItem instanceof UnsignedInteger unsignedInteger) { + return new JsonPrimitive(unsignedInteger.getValue()); + } else if(dataItem instanceof SimpleValue simpleValue) { + return new JsonPrimitive(simpleValue.getValue() == SimpleValueType.TRUE.getValue()); + } else if(dataItem instanceof Array array) { + JsonArray jsonArray = new JsonArray(); + for(DataItem item : array.getDataItems()) { + jsonArray.add(getJsonElement(item)); + } + return jsonArray; + } + + throw new IllegalArgumentException("Cannot convert dataItem of type " + dataItem.getClass() + "to JsonElement"); + } + + public void disconnect() throws CardException { + connection.disconnect(true); + } + + 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/io/ckcard/CardUnluckyNumberException.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardUnluckyNumberException.java new file mode 100644 index 00000000..32a39339 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardUnluckyNumberException.java @@ -0,0 +1,17 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +import javax.smartcardio.CardException; + +public class CardUnluckyNumberException extends CardException { + public CardUnluckyNumberException(String message) { + super(message); + } + + public CardUnluckyNumberException(Throwable cause) { + super(cause); + } + + public CardUnluckyNumberException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardWait.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardWait.java new file mode 100644 index 00000000..48cbc6dc --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardWait.java @@ -0,0 +1,8 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +import java.math.BigInteger; + +public class CardWait { + boolean success; + BigInteger auth_delay; +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardXpub.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardXpub.java new file mode 100644 index 00000000..49ddf476 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardXpub.java @@ -0,0 +1,5 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +public class CardXpub { + byte[] xpub; +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CkCard.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CkCard.java new file mode 100644 index 00000000..0f83e107 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CkCard.java @@ -0,0 +1,68 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +import com.sparrowwallet.drongo.crypto.ChildNumber; +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.SimpleStringProperty; +import javafx.beans.property.StringProperty; + +import javax.smartcardio.CardException; +import java.util.List; + +public class CkCard implements KeystoreCardImport { + private final StringProperty messageProperty = new SimpleStringProperty(""); + + @Override + public Keystore getKeystore(String pin, List derivation) throws ImportException { + if(pin.length() < 6) { + throw new ImportException("PIN too short"); + } + + if(pin.length() > 32) { + throw new ImportException("PIN too long"); + } + + CardApi cardApi = null; + try { + cardApi = new CardApi(pin); + CardStatus cardStatus = cardApi.getStatus(); + if(!cardStatus.isInitialized()) { + cardApi.initialize(); + cardStatus = cardApi.getStatus(); + } + cardApi.checkWait(cardStatus, messageProperty); + + if(!derivation.equals(cardStatus.getDerivation())) { + cardApi.setDerivation(derivation); + } + return cardApi.getKeystore(); + } catch(CardException e) { + throw new ImportException(e); + } finally { + if(cardApi != null) { + cardApi.disconnect(); + } + } + } + + @Override + public String getKeystoreImportDescription(int account) { + return "Import the keystore from your Tapsigner by placing it on the card reader."; + } + + @Override + public String getName() { + return "Tapsigner"; + } + + @Override + public WalletModel getWalletModel() { + return WalletModel.TAPSIGNER; + } + + public StringProperty messageProperty() { + return messageProperty; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwAirgappedController.java b/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwAirgappedController.java index 933793b1..3ca40f45 100644 --- a/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwAirgappedController.java +++ b/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwAirgappedController.java @@ -1,29 +1,36 @@ package com.sparrowwallet.sparrow.keystoreimport; import com.sparrowwallet.drongo.policy.PolicyType; +import com.sparrowwallet.sparrow.control.CardImportPane; import com.sparrowwallet.sparrow.control.FileKeystoreImportPane; import com.sparrowwallet.sparrow.control.TitledDescriptionPane; import com.sparrowwallet.sparrow.io.*; +import com.sparrowwallet.sparrow.io.ckcard.CkCard; import javafx.fxml.FXML; 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; public class HwAirgappedController extends KeystoreImportDetailController { + private static final Logger log = LoggerFactory.getLogger(HwAirgappedController.class); + @FXML private Accordion importAccordion; public void initializeView() { - List importers = Collections.emptyList(); + List fileImporters = Collections.emptyList(); if(getMasterController().getWallet().getPolicyType().equals(PolicyType.SINGLE)) { - importers = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY()); + fileImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY()); } else if(getMasterController().getWallet().getPolicyType().equals(PolicyType.MULTI)) { - importers = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Jade(), new KeystoneMultisig(), new PassportMultisig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY()); + fileImporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Jade(), new KeystoneMultisig(), new PassportMultisig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY()); } - for(KeystoreFileImport importer : importers) { + for(KeystoreFileImport importer : fileImporters) { if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) { FileKeystoreImportPane importPane = new FileKeystoreImportPane(getMasterController().getWallet(), importer, getMasterController().getRequiredDerivation()); if(getMasterController().getRequiredModel() == null || getMasterController().getRequiredModel() == importer.getWalletModel()) { @@ -32,6 +39,30 @@ public class HwAirgappedController extends KeystoreImportDetailController { } } + List cardImporters = Collections.emptyList(); + if(isReaderAvailable()) { + cardImporters = List.of(new CkCard()); + } + for(KeystoreCardImport importer : cardImporters) { + if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) { + CardImportPane importPane = new CardImportPane(getMasterController().getWallet(), importer, getMasterController().getRequiredDerivation()); + if(getMasterController().getRequiredModel() == null || getMasterController().getRequiredModel() == importer.getWalletModel()) { + importAccordion.getPanes().add(importPane); + } + } + } + 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 ccda0146..c5c01e95 100644 --- a/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java +++ b/src/main/java/com/sparrowwallet/sparrow/wallet/KeystoreController.java @@ -15,6 +15,7 @@ import javafx.application.Platform; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.fxml.Initializable; +import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.input.Clipboard; import javafx.scene.input.ClipboardContent; @@ -306,7 +307,11 @@ public class KeystoreController extends WalletFormController implements Initiali } } - private Glyph getTypeIcon(Keystore keystore) { + private Node getTypeIcon(Keystore keystore) { + return new WalletIcon(getWalletForm().getStorage(), getWalletForm().getWallet(), keystore, getDefaultTypeIcon(keystore)); + } + + private Glyph getDefaultTypeIcon(Keystore keystore) { switch (keystore.getSource()) { case HW_USB: return new Glyph(FontAwesome5Brands.FONT_NAME, FontAwesome5Brands.Glyph.USB); diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index fc404818..c8be4c62 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -49,4 +49,5 @@ open module com.sparrowwallet.sparrow { requires net.coobird.thumbnailator; requires com.github.hervegirod; requires com.sparrowwallet.toucan; + requires java.smartcardio; } \ No newline at end of file diff --git a/src/main/resources/com/sparrowwallet/sparrow/wallet/keystore.fxml b/src/main/resources/com/sparrowwallet/sparrow/wallet/keystore.fxml index 919cc19a..87f6a460 100644 --- a/src/main/resources/com/sparrowwallet/sparrow/wallet/keystore.fxml +++ b/src/main/resources/com/sparrowwallet/sparrow/wallet/keystore.fxml @@ -23,7 +23,7 @@
-