diff --git a/build.gradle b/build.gradle index cb6a7b77..a4761cca 100644 --- a/build.gradle +++ b/build.gradle @@ -161,7 +161,8 @@ run { "--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow", "--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow", "--add-opens=java.base/java.net=com.sparrowwallet.sparrow", - "--add-opens=java.base/java.io=com.google.gson"] + "--add-opens=java.base/java.io=com.google.gson", + "--add-opens=java.smartcardio/sun.security.smartcardio=com.sparrowwallet.sparrow"] if(os.macOsX) { applicationDefaultJvmArgs += ["-Dprism.lcdtext=false", "-Xdock:name=Sparrow", "-Xdock:icon=/Users/scy/git/sparrow/src/main/resources/sparrow-large.png", @@ -210,6 +211,7 @@ jlink { "--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow", "--add-opens=java.base/java.net=com.sparrowwallet.sparrow", "--add-opens=java.base/java.io=com.google.gson", + "--add-opens=java.smartcardio/sun.security.smartcardio=com.sparrowwallet.sparrow", "--add-reads=com.sparrowwallet.merged.module=java.desktop", "--add-reads=com.sparrowwallet.merged.module=java.sql", "--add-reads=com.sparrowwallet.merged.module=com.sparrowwallet.sparrow", diff --git a/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java b/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java index 3b2699bb..91a701d9 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/DevicePane.java @@ -5,6 +5,7 @@ import com.sparrowwallet.drongo.ExtendedKey; import com.sparrowwallet.drongo.KeyDerivation; import com.sparrowwallet.drongo.OutputDescriptor; import com.sparrowwallet.drongo.crypto.ChildNumber; +import com.sparrowwallet.drongo.crypto.ECKey; import com.sparrowwallet.drongo.policy.Policy; import com.sparrowwallet.drongo.policy.PolicyType; import com.sparrowwallet.drongo.protocol.ScriptType; @@ -65,6 +66,7 @@ public class DevicePane extends TitledDescriptionPane { private Button displayAddressButton; private Button signMessageButton; private Button discoverKeystoresButton; + private Button unsealButton; private final SimpleStringProperty passphrase = new SimpleStringProperty(""); private final SimpleStringProperty pin = new SimpleStringProperty(""); @@ -199,6 +201,32 @@ public class DevicePane extends TitledDescriptionPane { buttonBox.getChildren().addAll(setPassphraseButton, discoverKeystoresButton); } + public DevicePane(Device device, boolean defaultDevice) { + super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png"); + this.deviceOperation = DeviceOperation.UNSEAL; + this.wallet = null; + this.psbt = null; + this.outputDescriptor = null; + this.keyDerivation = null; + this.message = null; + this.device = device; + this.defaultDevice = defaultDevice; + this.availableAccounts = null; + + setDefaultStatus(); + showHideLink.setVisible(false); + + createUnsealButton(); + + initialise(device); + + messageProperty.addListener((observable, oldValue, newValue) -> { + Platform.runLater(() -> setDescription(newValue)); + }); + + buttonBox.getChildren().add(unsealButton); + } + private void initialise(Device device) { if(device.isNeedsPinSent()) { unlockButton.setDefaultButton(defaultDevice); @@ -342,6 +370,17 @@ public class DevicePane extends TitledDescriptionPane { discoverKeystoresButton.setVisible(false); } + private void createUnsealButton() { + unsealButton = new Button("Unseal"); + unsealButton.setAlignment(Pos.CENTER_RIGHT); + unsealButton.setOnAction(event -> { + unsealButton.setDisable(true); + unseal(); + }); + unsealButton.managedProperty().bind(unsealButton.visibleProperty()); + unsealButton.setVisible(false); + } + private void unlock(Device device) { if(device.getModel().requiresPinPrompt()) { promptPin(); @@ -816,6 +855,22 @@ public class DevicePane extends TitledDescriptionPane { getXpubsService.start(); } + private void unseal() { + if(device.isCard()) { + try { + CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get()); + Service unsealService = cardApi.getUnsealService(messageProperty); + handleCardOperation(unsealService, unsealButton, "Unseal", event -> { + EventManager.get().post(new DeviceUnsealedEvent(unsealService.getValue(), cardApi.getDefaultScriptType())); + }); + } catch(Exception e) { + log.error("Unseal Error: " + e.getMessage(), e); + setError("Unseal Error", e.getMessage()); + unsealButton.setDisable(false); + } + } + } + private void showOperationButton() { if(deviceOperation.equals(DeviceOperation.IMPORT)) { if(defaultDevice) { @@ -842,6 +897,10 @@ public class DevicePane extends TitledDescriptionPane { discoverKeystoresButton.setDefaultButton(defaultDevice); discoverKeystoresButton.setVisible(true); showHideLink.setVisible(false); + } else if(deviceOperation.equals(DeviceOperation.UNSEAL)) { + unsealButton.setDefaultButton(defaultDevice); + unsealButton.setVisible(true); + showHideLink.setVisible(false); } } @@ -959,6 +1018,6 @@ public class DevicePane extends TitledDescriptionPane { } public enum DeviceOperation { - IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE, DISCOVER_KEYSTORES; + IMPORT, SIGN, DISPLAY_ADDRESS, SIGN_MESSAGE, DISCOVER_KEYSTORES, UNSEAL; } } diff --git a/src/main/java/com/sparrowwallet/sparrow/control/DeviceUnsealDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/DeviceUnsealDialog.java new file mode 100644 index 00000000..034bfd57 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/control/DeviceUnsealDialog.java @@ -0,0 +1,32 @@ +package com.sparrowwallet.sparrow.control; + +import com.google.common.eventbus.Subscribe; +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.protocol.ScriptType; +import com.sparrowwallet.sparrow.EventManager; +import com.sparrowwallet.sparrow.event.DeviceUnsealedEvent; +import com.sparrowwallet.sparrow.io.Device; + +import java.util.List; + +public class DeviceUnsealDialog extends DeviceDialog { + public DeviceUnsealDialog(List operationFingerprints) { + super(operationFingerprints); + EventManager.get().register(this); + setOnCloseRequest(event -> { + EventManager.get().unregister(this); + }); + } + + @Override + protected DevicePane getDevicePane(Device device, boolean defaultDevice) { + return new DevicePane(device, defaultDevice); + } + + @Subscribe + public void deviceUnsealed(DeviceUnsealedEvent event) { + setResult(new UnsealedKey(event.getPrivateKey(), event.getScriptType())); + } + + public record UnsealedKey(ECKey privateKey, ScriptType scriptType) {} +} diff --git a/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java b/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java index 3b8372be..6438ad8b 100644 --- a/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java +++ b/src/main/java/com/sparrowwallet/sparrow/control/PrivateKeySweepDialog.java @@ -14,6 +14,7 @@ import com.sparrowwallet.drongo.psbt.PSBTInput; import com.sparrowwallet.drongo.wallet.Wallet; import com.sparrowwallet.sparrow.AppServices; import com.sparrowwallet.sparrow.glyphfont.FontAwesome5; +import com.sparrowwallet.sparrow.io.CardApi; import com.sparrowwallet.sparrow.net.ElectrumServer; import javafx.application.Platform; import javafx.collections.FXCollections; @@ -42,7 +43,7 @@ import tornadofx.control.Form; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -96,6 +97,14 @@ public class PrivateKeySweepDialog extends Dialog { HBox.setHgrow(key, Priority.ALWAYS); keyField.getInputs().add(keyBox); + if(CardApi.isReaderAvailable()) { + VBox cardButtonBox = new VBox(5); + Button cardKey = new Button("", getGlyph(FontAwesome5.Glyph.WIFI)); + cardKey.setOnAction(event -> unsealPrivateKey()); + cardButtonBox.getChildren().add(cardKey); + keyBox.getChildren().add(cardButtonBox); + } + Field keyScriptTypeField = new Field(); keyScriptTypeField.setText("Script Type:"); keyScriptType = new ComboBox<>(); @@ -279,6 +288,16 @@ public class PrivateKeySweepDialog extends Dialog { } } + private void unsealPrivateKey() { + DeviceUnsealDialog deviceUnsealDialog = new DeviceUnsealDialog(Collections.emptyList()); + Optional optPrivateKey = deviceUnsealDialog.showAndWait(); + if(optPrivateKey.isPresent()) { + DeviceUnsealDialog.UnsealedKey unsealedKey = optPrivateKey.get(); + key.setText(unsealedKey.privateKey().getPrivateKeyEncoded().toBase58()); + keyScriptType.setValue(unsealedKey.scriptType()); + } + } + private void createTransaction() { try { DumpedPrivateKey privateKey = getPrivateKey(); diff --git a/src/main/java/com/sparrowwallet/sparrow/event/DeviceUnsealedEvent.java b/src/main/java/com/sparrowwallet/sparrow/event/DeviceUnsealedEvent.java new file mode 100644 index 00000000..b8507442 --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/event/DeviceUnsealedEvent.java @@ -0,0 +1,22 @@ +package com.sparrowwallet.sparrow.event; + +import com.sparrowwallet.drongo.crypto.ECKey; +import com.sparrowwallet.drongo.protocol.ScriptType; + +public class DeviceUnsealedEvent { + private final ECKey privateKey; + private final ScriptType scriptType; + + public DeviceUnsealedEvent(ECKey privateKey, ScriptType scriptType) { + this.privateKey = privateKey; + this.scriptType = scriptType; + } + + public ECKey getPrivateKey() { + return privateKey; + } + + public ScriptType getScriptType() { + return scriptType; + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/CardApi.java b/src/main/java/com/sparrowwallet/sparrow/io/CardApi.java index b7a3f3a4..318f9d3a 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/CardApi.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/CardApi.java @@ -1,6 +1,8 @@ package com.sparrowwallet.sparrow.io; +import com.google.common.base.Throwables; import com.sparrowwallet.drongo.crypto.ChildNumber; +import com.sparrowwallet.drongo.crypto.ECKey; import com.sparrowwallet.drongo.protocol.ScriptType; import com.sparrowwallet.drongo.psbt.PSBT; import com.sparrowwallet.drongo.wallet.Keystore; @@ -13,7 +15,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.smartcardio.CardException; +import javax.smartcardio.CardTerminals; import javax.smartcardio.TerminalFactory; +import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.Collections; import java.util.List; @@ -45,6 +50,8 @@ public abstract class CardApi { public abstract WalletModel getCardType() throws CardException; + public abstract ScriptType getDefaultScriptType(); + public abstract Service getAuthDelayService() throws CardException; public abstract boolean requiresBackup() throws CardException; @@ -63,6 +70,8 @@ public abstract class CardApi { public abstract Service getSignMessageService(String message, ScriptType scriptType, List derivation, StringProperty messageProperty); + public abstract Service getUnsealService(StringProperty messageProperty); + public abstract void disconnect(); public static boolean isReaderAvailable() { @@ -70,9 +79,53 @@ public abstract class CardApi { TerminalFactory tf = TerminalFactory.getDefault(); return !tf.terminals().list().isEmpty(); } catch(Exception e) { - log.error("Error detecting card terminals", e); + Throwable cause = Throwables.getRootCause(e); + if(cause.getMessage().equals("SCARD_E_NO_SERVICE")) { + recoverNoService(); + } else { + log.error("Error detecting card terminals", e); + } } return false; } + + private static void recoverNoService() { + try { + Class pcscterminal = Class.forName("sun.security.smartcardio.PCSCTerminals"); + Field contextId = pcscterminal.getDeclaredField("contextId"); + contextId.setAccessible(true); + + if(contextId.getLong(pcscterminal) != 0L) + { + // First get a new context value + Class pcsc = Class.forName("sun.security.smartcardio.PCSC"); + Method SCardEstablishContext = pcsc.getDeclaredMethod( + "SCardEstablishContext", + Integer.TYPE); + SCardEstablishContext.setAccessible(true); + + Field SCARD_SCOPE_USER = pcsc.getDeclaredField("SCARD_SCOPE_USER"); + SCARD_SCOPE_USER.setAccessible(true); + + long newId = ((Long)SCardEstablishContext.invoke(pcsc, + new Object[] { SCARD_SCOPE_USER.getInt(pcsc) } + )); + contextId.setLong(pcscterminal, newId); + + + // Then clear the terminals in cache + TerminalFactory factory = TerminalFactory.getDefault(); + CardTerminals terminals = factory.terminals(); + Field fieldTerminals = pcscterminal.getDeclaredField("terminals"); + fieldTerminals.setAccessible(true); + Class classMap = Class.forName("java.util.Map"); + Method clearMap = classMap.getDeclaredMethod("clear"); + + clearMap.invoke(fieldTerminals.get(terminals)); + } + } catch(Exception e) { + log.error("Failed to recover card service", e); + } + } } diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardProtocol.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardProtocol.java index 96ca9c70..628d0c01 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardProtocol.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardProtocol.java @@ -188,6 +188,16 @@ public class CardProtocol { return gson.fromJson(backup, CardBackup.class); } + public CardUnseal unseal(String cvc) throws CardException { + CardStatus status = getStatus(); + + Map args = new HashMap<>(); + args.put("slot", status.getSlot()); + + JsonObject unseal = sendAuth("unseal", args, cvc); + return gson.fromJson(unseal, CardUnseal.class); + } + public void disconnect() throws CardException { cardTransport.disconnect(); } @@ -206,11 +216,18 @@ public class CardProtocol { } private JsonObject sendAuth(String cmd, Map args, String cvc) throws CardException { - addAuth(cmd, args, cvc); - return send(cmd, args); + byte[] sessionKey = addAuth(cmd, args, cvc); + JsonObject jsonObject = send(cmd, args); + + if(jsonObject.get("privkey") != null) { + byte[] privKeyBytes = Utils.hexToBytes(jsonObject.get("privkey").getAsString()); + jsonObject.add("privkey", new JsonPrimitive(Utils.bytesToHex(Utils.xor(sessionKey, privKeyBytes)))); + } + + return jsonObject; } - public void addAuth(String cmd, Map args, String cvc) throws CardException { + public byte[] 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()); } @@ -235,6 +252,10 @@ public class CardProtocol { } else if(cmd.equals("change") && args.get("data") instanceof byte[] dataBytes) { args.put("data", Utils.xor(dataBytes, Arrays.copyOf(sessionKey, dataBytes.length))); } + + return sessionKey; + } else { + throw new IllegalStateException("Native library libsecp256k1 required but not enabled"); } } catch(NativeSecp256k1Util.AssertFailException e) { throw new RuntimeException(e); diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardStatus.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardStatus.java index 26f6e7e0..2d133696 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardStatus.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardStatus.java @@ -17,12 +17,14 @@ public class CardStatus extends CardResponse { boolean tapsigner; List path; BigInteger num_backups; + List slots; + String addr; byte[] pubkey; BigInteger auth_delay; boolean testnet; public boolean isInitialized() { - return path != null; + return getCardType() != WalletModel.TAPSIGNER || path != null; } public String getIdentifier() { @@ -47,6 +49,14 @@ public class CardStatus extends CardResponse { return tapsigner ? WalletModel.TAPSIGNER : WalletModel.SATSCARD; } + public int getSlot() { + if(slots == null || slots.isEmpty()) { + return 0; + } + + return slots.get(0).intValue(); + } + @Override public String toString() { return "CardStatus{" + @@ -56,6 +66,8 @@ public class CardStatus extends CardResponse { ", tapsigner=" + tapsigner + ", path=" + path + ", num_backups=" + num_backups + + ", slots=" + slots + + ", addr='" + addr + '\'' + ", pubkey=" + Arrays.toString(pubkey) + ", auth_delay=" + auth_delay + ", testnet=" + testnet + diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardUnseal.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardUnseal.java new file mode 100644 index 00000000..67a8885d --- /dev/null +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CardUnseal.java @@ -0,0 +1,15 @@ +package com.sparrowwallet.sparrow.io.ckcard; + +import com.sparrowwallet.drongo.crypto.ECKey; + +public class CardUnseal extends CardResponse { + int slot; + byte[] privkey; + byte[] pubkey; + byte[] master_pk; + byte[] chain_code; + + public ECKey getPrivateKey() { + return ECKey.fromPrivate(privkey); + } +} diff --git a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CkCardApi.java b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CkCardApi.java index 0b90ec04..2fb4f811 100644 --- a/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CkCardApi.java +++ b/src/main/java/com/sparrowwallet/sparrow/io/ckcard/CkCardApi.java @@ -61,6 +61,11 @@ public class CkCardApi extends CardApi { return cardStatus.getCardType(); } + @Override + public ScriptType getDefaultScriptType() { + return ScriptType.P2WPKH; + } + CardStatus getStatus() throws CardException { CardStatus cardStatus = cardProtocol.getStatus(); if(cardType != null && cardStatus.getCardType() != cardType) { @@ -239,6 +244,16 @@ public class CkCardApi extends CardApi { } } + @Override + public Service getUnsealService(StringProperty messageProperty) { + return new UnsealService(messageProperty); + } + + ECKey unseal() throws CardException { + CardUnseal cardUnseal = cardProtocol.unseal(cvc); + return cardUnseal.getPrivateKey(); + } + @Override public void disconnect() { try { @@ -302,6 +317,10 @@ public class CkCardApi extends CardApi { @Override protected PSBT call() throws Exception { CardStatus cardStatus = getStatus(); + if(cardStatus.getCardType() != WalletModel.TAPSIGNER) { + throw new IllegalStateException("Please use a " + WalletModel.TAPSIGNER.toDisplayString() + " to sign transactions."); + } + checkWait(cardStatus, new SimpleIntegerProperty(), messageProperty); sign(wallet, psbt); @@ -359,6 +378,10 @@ public class CkCardApi extends CardApi { @Override protected String call() throws Exception { CardStatus cardStatus = getStatus(); + if(cardStatus.getCardType() != WalletModel.TAPSIGNER) { + throw new IllegalStateException("Please use a " + WalletModel.TAPSIGNER.toDisplayString() + " to sign messages."); + } + checkWait(cardStatus, new SimpleIntegerProperty(), messageProperty); return signMessage(message, scriptType, derivation); @@ -366,4 +389,29 @@ public class CkCardApi extends CardApi { }; } } + + public class UnsealService extends Service { + private final StringProperty messageProperty; + + public UnsealService(StringProperty messageProperty) { + this.messageProperty = messageProperty; + } + + @Override + protected Task createTask() { + return new Task<>() { + @Override + protected ECKey call() throws Exception { + CardStatus cardStatus = getStatus(); + if(cardStatus.getCardType() != WalletModel.SATSCARD) { + throw new IllegalStateException("Please use a " + WalletModel.SATSCARD.toDisplayString() + " to unseal private keys."); + } + + checkWait(cardStatus, new SimpleIntegerProperty(), messageProperty); + + return unseal(); + } + }; + } + } } diff --git a/src/main/resources/image/satscard.png b/src/main/resources/image/satscard.png new file mode 100644 index 00000000..cafed848 Binary files /dev/null and b/src/main/resources/image/satscard.png differ diff --git a/src/main/resources/image/satscard@2x.png b/src/main/resources/image/satscard@2x.png new file mode 100644 index 00000000..ec5686f1 Binary files /dev/null and b/src/main/resources/image/satscard@2x.png differ diff --git a/src/main/resources/image/satscard@3x.png b/src/main/resources/image/satscard@3x.png new file mode 100644 index 00000000..4440c1c5 Binary files /dev/null and b/src/main/resources/image/satscard@3x.png differ