add support for keycard via smart card interface
What changed, and why it matters
This commit adds a brand-new feature to Sparrow Wallet: support for Keycard hardware wallets via a smart card interface. It introduces many new Java files that handle low-level smart card communication, secure channel encryption, PIN handling, key derivation, and signing. The change is a large feature addition (+3,250 lines) rather than a small bug fix. There is no direct evidence in the commit message or diff that this fixes a known security vulnerability, and no external references were provided. Some implementation details—such as hardcoded pairing passwords, a TODO comment about device certificate verification, and a fallback to a default derivation path—could become security concerns if misused, but they are not proven vulnerabilities on their own.
Treat this as a feature commit requiring security review rather than an emergency patch. Reviewers should audit the secure channel implementation for correct IV handling, CMAC verification, and side-channel resistance; verify that hardcoded pairing passwords and fallback derivation paths are acceptable for the intended threat model; complete the TODO for device certificate verification; and perform fuzzing/integration testing against a real Keycard applet before release.
Security signals we found
Large feature addition introducing smart card cryptography and secure channel code
Hardcoded default pairing password 'KeycardDefaultPairing' in KeycardApi.initialize()
TODO comment in initialize() deferring device certificate verification
Fallback to default derivation path in getKeystore() when basePath is null
New secure channel implementation handling ECDH, AES-CBC, CMAC, and PIN encryption
Evidence from the diff
The commit implements a Keycard JavaCard integration for Sparrow Wallet. New packages include APDU command/response parsing, TLV parsing, secure channel session management (AES-CBC-ISO7816-4Padding, CMAC, ECDH pairing), BIP32 keypair handling, and Keycard-specific commands. CardApi is extended to register WalletModel.KEYCARD. KeycardApi wires initialization, xpub export, PSBT signing, and message signing. Notable observations from the diff only: (1) KeycardApi.initialize() uses a hardcoded pairing password ‘KeycardDefaultPairing’ and a TODO regarding device certificate verification; (2) KeycardApi.getKeystore() falls back to a default derivation path when basePath is null; (3) KeycardTransport connects to the first available terminal and selects the applet; (4) SecureChannelSession uses a static salt string ‘Keycard Pairing Password Salt’ with PBKDF2-HMAC-SHA256. These are design/implementation choices that may affect security posture but are not demonstrated flaws in this isolated commit.
Changed components
com.sparrowwallet.sparrow.io.CardApicom.sparrowwallet.sparrow.io.keycard.*com.sparrowwallet.sparrow.keystoreimport.HwAirgappedControllerInspect captured patch +3250 / −1
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/CardApi.java b/src/main/java/com/sparrowwallet/sparrow/io/CardApi.java
index c3e7f45..e010143 100644
--- a/src/main/java/com/sparrowwallet/sparrow/io/CardApi.java
+++ b/src/main/java/com/sparrowwallet/sparrow/io/CardApi.java
@@ -11,6 +11,7 @@ import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.io.ckcard.CkCardApi;
+import com.sparrowwallet.sparrow.io.keycard.KeycardApi;
import com.sparrowwallet.sparrow.io.satochip.SatoCardApi;
import javafx.beans.property.StringProperty;
import javafx.concurrent.Service;
@@ -58,6 +59,13 @@ public abstract class CardApi {
//ignore
}
+ try {
+ KeycardApi keycardApi = new KeycardApi(null, null);
+ cards.add(keycardApi.getCardType());
+ } catch(Exception e) {
+ //ignore
+ }
+
return cards;
}
@@ -70,6 +78,10 @@ public abstract class CardApi {
return new SatoCardApi(walletModel, pin);
}
+ if(walletModel == WalletModel.KEYCARD) {
+ return new KeycardApi(walletModel, pin);
+ }
+
throw new IllegalArgumentException("Cannot create card API for " + walletModel.toDisplayString());
}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/keycard/APDUCommand.java b/src/main/java/com/sparrowwallet/sparrow/io/keycard/APDUCommand.java
new file mode 100644
index 0000000..580d5e1
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/io/keycard/APDUCommand.java
@@ -0,0 +1,125 @@
+package com.sparrowwallet.sparrow.io.keycard;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+/**
+ * ISO7816-4 APDU.
+ */
+public class APDUCommand {
+ protected int cla;
+ protected int ins;
+ protected int p1;
+ protected int p2;
+ protected int lc;
+ protected byte[] data;
+ protected boolean needsLE;
+
+ /**
+ * Constructs an APDU with no response data length field. The data field cannot be null, but can be a zero-length array.
+ *
+ * @param cla class byte
+ * @param ins instruction code
+ * @param p1 P1 parameter
+ * @param p2 P2 parameter
+ * @param data the APDU data
+ */
+ public APDUCommand(int cla, int ins, int p1, int p2, byte[] data) {
+ this(cla, ins, p1, p2, data, false);
+ }
+
+ /**
+ * Constructs an APDU with an optional data length field. The data field cannot be null, but can be a zero-length array.
+ * The LE byte, if sent, is set to 0.
+ *
+ * @param cla class byte
+ * @param ins instruction code
+ * @param p1 P1 parameter
+ * @param p2 P2 parameter
+ * @param data the APDU data
+ * @param needsLE whether the LE byte should be sent or not
+ */
+ public APDUCommand(int cla, int ins, int p1, int p2, byte[] data, boolean needsLE) {
+ this.cla = cla & 0xff;
+ this.ins = ins & 0xff;
+ this.p1 = p1 & 0xff;
+ this.p2 = p2 & 0xff;
+ this.data = data;
+ this.needsLE = needsLE;
+ }
+
+ /**
+ * Serializes the APDU in order to send it to the card.
+ *
+ * @return the byte array representation of the APDU
+ */
+ public byte[] serialize() throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ out.write(this.cla);
+ out.write(this.ins);
+ out.write(this.p1);
+ out.write(this.p2);
+ out.write(this.data.length);
+ out.write(this.data);
+
+ if(this.needsLE) {
+ out.write(0); // Response length
+ }
+
+ return out.toByteArray();
+ }
+
+ /**
+ * Returns the CLA of the APDU
+ *
+ * @return the CLA of the APDU
+ */
+ public int getCla() {
+ return cla;
+ }
+
+ /**
+ * Returns the INS of the APDU
+ *
+ * @return the INS of the APDU
+ */
+ public int getIns() {
+ return ins;
+ }
+
+ /**
+ * Returns the P1 of the APDU
+ *
+ * @return the P1 of the APDU
+ */
+ public int getP1() {
+ return p1;
+ }
+
+ /**
+ * Returns the P2 of the APDU
+ *
+ * @return the P2 of the APDU
+ */
+ public int getP2() {
+ return p2;
+ }
+
+ /**
+ * Returns the data field of the APDU
+ *
+ * @return the data field of the APDU
+ */
+ public byte[] getData() {
+ return data;
+ }
+
+ /**
+ * Returns whether LE is sent or not.
+ *
+ * @return whether LE is sent or not
+ */
+ public boolean getNeedsLE() {
+ return this.needsLE;
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/keycard/APDUException.java b/src/main/java/com/sparrowwallet/sparrow/io/keycard/APDUException.java
new file mode 100644
index 0000000..795db67
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/io/keycard/APDUException.java
@@ -0,0 +1,29 @@
+package com.sparrowwallet.sparrow.io.keycard;
+
+/**
+ * Exception thrown when the response APDU from the card contains unexpected SW or data.
+ */
+public class APDUException extends Exception {
+ public final int sw;
+
+ /**
+ * Creates an exception with SW and message.
+ *
+ * @param sw the status word
+ * @param message a descriptive message of the error
+ */
+ public APDUException(int sw, String message) {
+ super(message + ", 0x" + String.format("%04X", sw));
+ this.sw = sw;
+ }
+
+ /**
+ * Creates an exception with a message.
+ *
+ * @param message a descriptive message of the error
+ */
+ public APDUException(String message) {
+ super(message);
+ this.sw = 0;
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/keycard/APDUResponse.java b/src/main/java/com/sparrowwallet/sparrow/io/keycard/APDUResponse.java
new file mode 100644
index 0000000..ab4ba79
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/io/keycard/APDUResponse.java
@@ -0,0 +1,181 @@
+package com.sparrowwallet.sparrow.io.keycard;
+
+import com.sparrowwallet.sparrow.io.CardAuthorizationException;
+
+/**
+ * ISO7816-4 APDU response.
+ */
+public class APDUResponse {
+ public static final int SW_OK = 0x9000;
+ public static final int SW_SECURITY_CONDITION_NOT_SATISFIED = 0x6982;
+ public static final int SW_AUTHENTICATION_METHOD_BLOCKED = 0x6983;
+ public static final int SW_CARD_LOCKED = 0x6283;
+ public static final int SW_REFERENCED_DATA_NOT_FOUND = 0x6A88;
+ public static final int SW_CONDITIONS_OF_USE_NOT_SATISFIED = 0x6985; // applet may be already installed
+ public static final int SW_WRONG_PIN_MASK = 0x63C0;
+
+ private byte[] apdu;
+ private byte[] data;
+ private int sw;
+ private int sw1;
+ private int sw2;
+
+ /**
+ * Creates an APDU object by parsing the raw response from the card.
+ *
+ * @param apdu the raw response from the card.
+ */
+ public APDUResponse(byte[] apdu) {
+ if(apdu.length < 2) {
+ throw new IllegalArgumentException("APDU response must be at least 2 bytes");
+ }
+ this.apdu = apdu;
+ this.parse();
+ }
+
+ /**
+ * Parses the APDU response, separating the response data from SW.
+ */
+ private void parse() {
+ int length = this.apdu.length;
+
+ this.sw1 = this.apdu[length - 2] & 0xff;
+ this.sw2 = this.apdu[length - 1] & 0xff;
+ this.sw = (this.sw1 << 8) | this.sw2;
+
+ this.data = new byte[length - 2];
+ System.arraycopy(this.apdu, 0, this.data, 0, length - 2);
+ }
+
+ /**
+ * Returns true if the SW is 0x9000.
+ *
+ * @return true if the SW is 0x9000.
+ */
+ public boolean isOK() {
+ return this.sw == SW_OK;
+ }
+
+ /**
+ * Asserts that the SW is 0x9000. Throws an exception if it isn't
+ *
+ * @return this object, to simplify chaining
+ * @throws APDUException if the SW is not 0x9000
+ */
+ public APDUResponse checkOK() throws APDUException {
+ return this.checkSW(SW_OK);
+ }
+
+ /**
+ * Asserts that the SW is contained in the given list. Throws an exception if it isn't.
+ *
+ * @param codes the list of SWs to match.
+ * @return this object, to simplify chaining
+ * @throws APDUException if the SW is not 0x9000
+ */
+ public APDUResponse checkSW(int... codes) throws APDUException {
+ for(int code : codes) {
+ if(this.sw == code) {
+ return this;
+ }
+ }
+
+ switch(this.sw) {
+ case SW_SECURITY_CONDITION_NOT_SATISFIED:
+ throw new APDUException(this.sw, "security condition not satisfied");
+ case SW_AUTHENTICATION_METHOD_BLOCKED:
+ throw new APDUException(this.sw, "authentication method blocked");
+ default:
+ throw new APDUException(this.sw, "Unexpected error SW");
+ }
+ }
+
+ /**
+ * Asserts that the SW is 0x9000. Throws an exception with the given message if it isn't
+ *
+ * @param message the error message
+ * @return this object, to simplify chaining
+ * @throws APDUException if the SW is not 0x9000
+ */
+ public APDUResponse checkOK(String message) throws APDUException {
+ return checkSW(message, SW_OK);
+ }
+
+ /**
+ * Asserts that the SW is contained in the given list. Throws an exception with the given message if it isn't.
+ *
+ * @param message the error message
+ * @param codes the list of SWs to match.
+ * @return this object, to simplify chaining
+ * @throws APDUException if the SW is not 0x9000
+ */
+ public APDUResponse checkSW(String message, int... codes) throws APDUException {
+ for(int code : codes) {
+ if(this.sw == code) {
+ return this;
+ }
+ }
+
+ throw new APDUException(this.sw, message);
+ }
+
+ /**
+ * Checks response from an authentication command (VERIFY PIN, UNBLOCK PUK)
+ *
+ * @throws CardAuthorizationException wrong PIN
+ * @throws APDUException unexpected response
+ */
+ public APDUResponse checkAuthOK() throws CardAuthorizationException, APDUException {
+ if((this.sw & SW_WRONG_PIN_MASK) == SW_WRONG_PIN_MASK) {
+ int retryAttempts = sw2 & 0x0F;
+ throw new CardAuthorizationException("Wrong PIN, remaining tries: " + retryAttempts);
+ } else {
+ return checkOK();
+ }
+ }
+
+ /**
+ * Returns the data field of this APDU.
+ *
+ * @return the data field of this APDU
+ */
+ public byte[] getData() {
+ return this.data;
+ }
+
+ /**
+ * Returns the Status Word.
+ *
+ * @return the status word
+ */
+ public int getSw() {
+ return this.sw;
+ }
+
+ /**
+ * Returns the SW1 byte
+ *
+ * @return SW1
+ */
+ public int getSw1() {
+ return this.sw1;
+ }
+
+ /**
+ * Returns the SW2 byte
+ *
+ * @return SW2
+ */
+ public int getSw2() {
+ return this.sw2;
+ }
+
+ /**
+ * Returns the raw unparsed response.
+ *
+ * @return raw APDU data
+ */
+ public byte[] getBytes() {
+ return this.apdu;
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/keycard/ApplicationInfo.java b/src/main/java/com/sparrowwallet/sparrow/io/keycard/ApplicationInfo.java
new file mode 100644
index 0000000..9b95935
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/io/keycard/ApplicationInfo.java
@@ -0,0 +1,207 @@
+package com.sparrowwallet.sparrow.io.keycard;
+
+/**
+ * Parses the response from a SELECT command. If the card has not yet received the INIT command the isInitializedCard
+ * will return false and only the getSecureChannelPubKey method will return a valid value.
+ */
+public class ApplicationInfo {
+ private boolean initializedCard;
+ private byte[] instanceUID;
+ private byte[] secureChannelPubKey;
+ private short appVersion;
+ private byte freePairingSlots;
+ private byte[] keyUID;
+ private byte capabilities;
+
+ public static final byte TLV_APPLICATION_INFO_TEMPLATE = (byte) 0xA4;
+ public static final byte TLV_PUB_KEY = (byte) 0x80;
+ public static final byte TLV_UID = (byte) 0x8F;
+ public static final byte TLV_KEY_UID = (byte) 0x8E;
+ public static final byte TLV_CAPABILITIES = (byte) 0x8D;
+
+ static final byte CAPABILITY_SECURE_CHANNEL = (byte) 0x01;
+ static final byte CAPABILITY_KEY_MANAGEMENT = (byte) 0x02;
+ static final byte CAPABILITY_CREDENTIALS_MANAGEMENT = (byte) 0x04;
+ static final byte CAPABILITY_NDEF = (byte) 0x08;
+ static final byte CAPABILITY_FACTORY_RESET = (byte) 0x10;
+
+ static final byte CAPABILITIES_ALL = CAPABILITY_SECURE_CHANNEL | CAPABILITY_KEY_MANAGEMENT | CAPABILITY_CREDENTIALS_MANAGEMENT | CAPABILITY_NDEF | CAPABILITY_FACTORY_RESET;
+
+ /**
+ * Constructs an object by parsing the TLV data.
+ *
+ * @param tlvData the raw response data from the card
+ * @throws IllegalArgumentException the TLV does not follow the allowed format
+ */
+ public ApplicationInfo(byte[] tlvData) throws IllegalArgumentException {
+ TinyBERTLV tlv = new TinyBERTLV(tlvData);
+
+ int topTag = tlv.readTag();
+ tlv.unreadLastTag();
+
+ if(topTag == TLV_PUB_KEY) {
+ secureChannelPubKey = tlv.readPrimitive(TLV_PUB_KEY);
+ initializedCard = false;
+ capabilities = CAPABILITY_CREDENTIALS_MANAGEMENT;
+
+ if(secureChannelPubKey.length > 0) {
+ capabilities |= CAPABILITY_SECURE_CHANNEL;
+ }
+
+ return;
+ }
+
+ tlv.enterConstructed(TLV_APPLICATION_INFO_TEMPLATE);
+ instanceUID = tlv.readPrimitive(TLV_UID);
+ secureChannelPubKey = tlv.readPrimitive(TLV_PUB_KEY);
+ appVersion = (short) tlv.readInt();
+ freePairingSlots = (byte) tlv.readInt();
+ keyUID = tlv.readPrimitive(TLV_KEY_UID);
+
+ if(tlv.readTag() != TinyBERTLV.END_OF_TLV) {
+ tlv.unreadLastTag();
+ capabilities = tlv.readPrimitive(TLV_CAPABILITIES)[0];
+ } else {
+ capabilities = CAPABILITIES_ALL;
+ }
+
+ initializedCard = true;
+ }
+
+ /**
+ * Returns if the card is initialized or not. If this method returns false, only the getSecureChannelPubKey method
+ * will return a valid value.
+ *
+ * @return true if initialized, false otherwise
+ */
+ public boolean isInitializedCard() {
+ return initializedCard;
+ }
+
+ /**
+ * Utility method to discover if the card has a master key.
+ *
+ * @return true if the card has a master key, false otherwise
+ */
+ public boolean hasMasterKey() {
+ return (keyUID != null) && (keyUID.length != 0);
+ }
+
+ /**
+ * The instance UID of the applet. This ID never changes for the lifetime of the applet.
+ *
+ * @return the instance UID
+ */
+ public byte[] getInstanceUID() {
+ return instanceUID;
+ }
+
+ /**
+ * The public key to be used for secure channel opening. Usually handled internally by the KeycardCommandSet.
+ *
+ * @return the public key
+ */
+ public byte[] getSecureChannelPubKey() {
+ return secureChannelPubKey;
+ }
+
+ /**
+ * The application version, encoded as a short. The msb is the major revision number and the lsb is the minor one.
+ *
+ * @return the application version
+ */
+ public short getAppVersion() {
+ return appVersion;
+ }
+
+ /**
+ * A formatted application version.
+ *
+ * @return the string representation of the application version
+ */
+ public String getAppVersionString() {
+ return getAppVersionString(appVersion);
+ }
+
+ /**
+ * A formatted application version.
+ *
+ * @return the string representation of the application version
+ */
+ static String getAppVersionString(short appVersion) {
+ return (appVersion >> 8) + "." + (appVersion & 0xff);
+ }
+
+ /**
+ * The number of remaining pairing slots. If zero is returned, no further pairing is possible.
+ *
+ * @return the number of remaining pairing slots
+ */
+ public byte getFreePairingSlots() {
+ return freePairingSlots;
+ }
+
+ /**
+ * The UID of the master key on this card. Changes every time a different master key is stored. It has zero length if
+ * no key is on the card.
+ *
+ * @return the Key UID.
+ */
+ public byte[] getKeyUID() {
+ return keyUID;
+ }
+
+ /**
+ * Returns the capability descriptor for the device.
+ *
+ * @return the capability descriptor for the device.
+ */
+ public byte getCapabilities() {
+ return capabilities;
+ }
+
+ /**
+ * Returns true if the device supports the Secure Channel capability.
+ *
+ * @return true or false
+ */
+ public boolean hasSecureChannelCapability() {
+ return (capabilities & CAPABILITY_SECURE_CHANNEL) == CAPABILITY_SECURE_CHANNEL;
+ }
+
+ /**
+ * Returns true if the device supports the Key Management capability.
+ *
+ * @return true or false
+ */
+ public boolean hasKeyManagementCapability() {
+ return (capabilities & CAPABILITY_KEY_MANAGEMENT) == CAPABILITY_KEY_MANAGEMENT;
+ }
+
+ /**
+ * Returns true if the device supports the Credentials Management capability.
+ *
+ * @return true or false
+ */
+ public boolean hasCredentialsManagementCapability() {
+ return (capabilities & CAPABILITY_CREDENTIALS_MANAGEMENT) == CAPABILITY_CREDENTIALS_MANAGEMENT;
+ }
+
+ /**
+ * Returns true if the device supports the NDEF capability.
+ *
+ * @return true or false
+ */
+ public boolean hasNDEFCapability() {
+ return (capabilities & CAPABILITY_NDEF) == CAPABILITY_NDEF;
+ }
+
+ /**
+ * Returns true if the device supports the Factory Reset capability.
+ *
+ * @return true or false
+ */
+ public boolean hasFactoryResetCapability() {
+ return (capabilities & CAPABILITY_FACTORY_RESET) == CAPABILITY_FACTORY_RESET;
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/keycard/BIP32KeyPair.java b/src/main/java/com/sparrowwallet/sparrow/io/keycard/BIP32KeyPair.java
new file mode 100644
index 0000000..e5e5bf3
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/io/keycard/BIP32KeyPair.java
@@ -0,0 +1,188 @@
+package com.sparrowwallet.sparrow.io.keycard;
+
+import com.sparrowwallet.drongo.crypto.ECKey;
+
+/**
+ * Represents a BIP32 keypair. This can be a master key or any other key in the path. Contains convenience method to
+ * read and write formats the the card understands.
+ */
+public class BIP32KeyPair {
+ private byte[] privateKey;
+ private byte[] chainCode;
+ private byte[] publicKey;
+
+ static final byte TLV_KEY_TEMPLATE = (byte) 0xA1;
+ static final byte TLV_PUB_KEY = (byte) 0x80;
+ static final byte TLV_PRIV_KEY = (byte) 0x81;
+ static final byte TLV_CHAIN_CODE = (byte) 0x82;
+
+ /**
+ * Constructs a BIP32 keypair from a KEY TEMPLATE TLV. Data can be the output of the EXPORT KEY command.
+ *
+ * @param tlvData the TLV data
+ * @return the BIP32 keypair
+ */
+ public static BIP32KeyPair fromTLV(byte[] tlvData) {
+ TinyBERTLV tlv = new TinyBERTLV(tlvData);
+ tlv.enterConstructed(TLV_KEY_TEMPLATE);
+
+ byte[] pubKey = null;
+ byte[] privKey = null;
+ byte[] chainCode = null;
+
+ int tag = tlv.readTag();
+
+ if(tag == TLV_PUB_KEY) {
+ tlv.unreadLastTag();
+ pubKey = tlv.readPrimitive(TLV_PUB_KEY);
+ tag = tlv.readTag();
+ }
+
+ if(tag == TLV_PRIV_KEY) {
+ tlv.unreadLastTag();
+ privKey = tlv.readPrimitive(TLV_PRIV_KEY);
+ tag = tlv.readTag();
+ }
+
+ if(tag == TLV_CHAIN_CODE) {
+ tlv.unreadLastTag();
+ chainCode = tlv.readPrimitive(TLV_CHAIN_CODE);
+ }
+
+ return new BIP32KeyPair(privKey, chainCode, pubKey);
+ }
+
+ /**
+ * Low level constructor. If the private key is not null, the public key can be omitted and it will be calculated
+ * automatically.
+ *
+ * @param privateKey the private key
+ * @param chainCode the chain code
+ * @param publicKey the public key
+ */
+ public BIP32KeyPair(byte[] privateKey, byte[] chainCode, byte[] publicKey) {
+ this.privateKey = privateKey;
+ this.chainCode = chainCode;
+
+ if(publicKey != null) {
+ this.publicKey = publicKey;
+ } else {
+ calculatePublicKey();
+ }
+ }
+
+ private void calculatePublicKey() {
+ ECKey key = ECKey.fromPrivate(this.privateKey, false);
+ this.publicKey = key.getPubKey();
+ }
+
+ /**
+ * Returns the TLV representation of this object.
+ *
+ * @return the TLV representation of this object.
+ */
+ public byte[] toTLV() {
+ return toTLV(true);
+ }
+
+ /**
+ * Returns the TLV representation of this object, optionally omitting the public component.
+ *
+ * @return the TLV representation of this object.
+ */
+ public byte[] toTLV(boolean includePublic) {
+ int privLen = privateKey.length;
+ int privOff = 0;
+
+ if(privateKey[0] == 0x00) {
+ privOff++;
+ privLen--;
+ }
+
+ int off = 0;
+ int totalLength = includePublic ? (publicKey.length + 2) : 0;
+ totalLength += (privLen + 2);
+ totalLength += isExtended() ? (chainCode.length + 2) : 0;
+
+ if(totalLength > 127) {
+ totalLength += 3;
+ } else {
+ totalLength += 2;
+ }
+
+ byte[] data = new byte[totalLength];
+ data[off++] = TLV_KEY_TEMPLATE;
+
+ if(totalLength > 127) {
+ data[off++] = (byte) 0x81;
+ data[off++] = (byte) (totalLength - 3);
+ } else {
+ data[off++] = (byte) (totalLength - 2);
+ }
+
+ if(includePublic) {
+ data[off++] = TLV_PUB_KEY;
+ data[off++] = (byte) publicKey.length;
+ System.arraycopy(publicKey, 0, data, off, publicKey.length);
+ off += publicKey.length;
+ }
+
+ data[off++] = TLV_PRIV_KEY;
+ data[off++] = (byte) privLen;
+ System.arraycopy(privateKey, privOff, data, off, privLen);
+ off += privLen;
+
+ if(isExtended()) {
+ data[off++] = (byte) TLV_CHAIN_CODE;
+ data[off++] = (byte) chainCode.length;
+ System.arraycopy(chainCode, 0, data, off, chainCode.length);
+ }
+
+ return data;
+ }
+
+ /**
+ * Returns the private key. Might be null.
+ *
+ * @return the private key
+ */
+ public byte[] getPrivateKey() {
+ return privateKey;
+ }
+
+ /**
+ * Returns the chain code. Might be null.
+ *
+ * @return the chain code
+ */
+ public byte[] getChainCode() {
+ return chainCode;
+ }
+
+ /**
+ * Returns the public key. Is never null.
+ *
+ * @return the public key
+ */
+ public byte[] getPublicKey() {
+ return publicKey;
+ }
+
+ /**
+ * True if only the public key is contained, false otherwise.
+ *
+ * @return true or false
+ */
+ public boolean isPublicOnly() {
+ return privateKey == null;
+ }
+
+ /**
+ * True if the chain code is contained, false otherwise.
+ *
+ * @return true or false
+ */
+ public boolean isExtended() {
+ return chainCode != null;
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/keycard/CardChannel.java b/src/main/java/com/sparrowwallet/sparrow/io/keycard/CardChannel.java
new file mode 100644
index 0000000..247a8c3
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/io/keycard/CardChannel.java
@@ -0,0 +1,36 @@
+package com.sparrowwallet.sparrow.io.keycard;
+
+import java.io.IOException;
+
+/**
+ * A channel to transcieve ISO7816-4 APDUs.
+ */
+public interface CardChannel {
+ /**
+ * Sends the given C-APDU and returns an R-APDU.
+ *
+ * @param cmd the command to send
+ * @return the card response
+ * @throws IOException communication error
+ */
+ APDUResponse send(APDUCommand cmd) throws IOException;
+
+ /**
+ * True if connected, false otherwise
+ *
+ * @return true if connected, false otherwise
+ */
+ boolean isConnected();
+
+ /**
+ * Returns the iteration count for deriving the pairing key from the pairing password. The default is 50000 and is
+ * should only be changed for devices where the PBKDF2 is calculated on-board and the resource do not permit a
+ * high iteration count. If a lower count is used other security mechanism should be used to prevent brute force
+ * attacks.
+ *
+ * @return the iteration count
+ */
+ default int pairingPasswordPBKDF2IterationCount() {
+ return 50000;
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/keycard/Identifiers.java b/src/main/java/com/sparrowwallet/sparrow/io/keycard/Identifiers.java
new file mode 100644
index 0000000..2054c59
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/io/keycard/Identifiers.java
@@ -0,0 +1,46 @@
+package com.sparrowwallet.sparrow.io.keycard;
+
+import com.sparrowwallet.drongo.Utils;
+
+import java.util.Arrays;
+
+public class Identifiers {
+ public static final byte[] PACKAGE_AID = Utils.hexToBytes("A0000008040001");
+
+ public static final byte[] KEYCARD_AID = Utils.hexToBytes("A000000804000101");
+ public static final int KEYCARD_DEFAULT_INSTANCE_IDX = 1;
+
+ public static final byte[] NDEF_AID = Utils.hexToBytes("A000000804000102");
+ public static final byte[] NDEF_INSTANCE_AID = Utils.hexToBytes("D2760000850101");
+
+ public static final byte[] CASH_AID = Utils.hexToBytes("A000000804000103");
+ public static final byte[] CASH_INSTANCE_AID = Utils.hexToBytes("A00000080400010301");
+
+ public static final byte[] IDENT_AID = Utils.hexToBytes("A000000804000104");
+ public static final byte[] IDENT_INSTANCE_AID = Utils.hexToBytes("A00000080400010401");
+
+ /**
+ * Gets the instance AID of the default instance of the Keycard applet.
+ *
+ * @return the instance AID of the Keycard applet
+ */
+ public static byte[] getKeycardInstanceAID() {
+ return getKeycardInstanceAID(KEYCARD_DEFAULT_INSTANCE_IDX);
+ }
+
+ /**
+ * Gets the instance AID of the Keycard applet with the given index. Since multiple instances of the Keycard applet
+ * could be installed in parallel, this method allows selecting a specific instance. The index is between 01 and ff
+ *
+ * @return the instance AID of the Keycard applet
+ */
+ public static byte[] getKeycardInstanceAID(int instanceIdx) {
+ if(instanceIdx < 0x01 || instanceIdx > 0xff) {
+ throw new IllegalArgumentException("The instance index must be between 1 and 255");
+ }
+
+ byte[] instanceAID = Arrays.copyOf(KEYCARD_AID, KEYCARD_AID.length + 1);
+ instanceAID[KEYCARD_AID.length] = (byte) instanceIdx;
+ return instanceAID;
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/keycard/KeyPath.java b/src/main/java/com/sparrowwallet/sparrow/io/keycard/KeyPath.java
new file mode 100644
index 0000000..ef81e24
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/io/keycard/KeyPath.java
@@ -0,0 +1,144 @@
+package com.sparrowwallet.sparrow.io.keycard;
+
+import java.util.StringTokenizer;
+
+/**
+ * Keypath object to be used with the KeycardCommandSet
+ */
+public class KeyPath {
+ private int source;
+ private byte[] data;
+
+ /**
+ * Parses a keypath into a byte array and source parameter to be used with the KeycardCommandSet object.
+ * <p>
+ * A valid string is composed of a minimum of one and a maximum of 11 components separated by "/".
+ * <p>
+ * The first component can be either "m", indicating the master key, "..", indicating the parent of the current key,
+ * or "." indicating the current key. It can also be omitted, in which case it is considered the same as being ".".
+ * <p>
+ * All other components are positive integers fitting in 31 bit, eventually suffixed by an apostrophe (') sign,
+ * which indicates an hardened key.
+ * <p>
+ * An example of a valid path is "m/44'/0'/0'/0/0"
+ *
+ * @param keypath the keypath as a string
+ */
+ public KeyPath(String keypath) {
+ StringTokenizer tokenizer = new StringTokenizer(keypath, "/");
+
+ String sourceOrFirstElement = tokenizer.nextToken();
+
+ switch(sourceOrFirstElement) {
+ case "m":
+ source = KeycardCommandSet.DERIVE_P1_SOURCE_MASTER;
+ break;
+ case "..":
+ source = KeycardCommandSet.DERIVE_P1_SOURCE_PARENT;
+ break;
+ case ".":
+ source = KeycardCommandSet.DERIVE_P1_SOURCE_CURRENT;
+ break;
+ default:
+ source = KeycardCommandSet.DERIVE_P1_SOURCE_CURRENT;
+ tokenizer = new StringTokenizer(keypath, "/"); // rewind
+ break;
+ }
+
+ int componentCount = tokenizer.countTokens();
+ if(componentCount > 10) {
+ throw new IllegalArgumentException("Too many components");
+ }
+
+ data = new byte[4 * componentCount];
+
+ for(int i = 0; i < componentCount; i++) {
+ long component = parseComponent(tokenizer.nextToken());
+ writeComponent(component, i);
+ }
+ }
+
+ public KeyPath(byte[] data, int source) {
+ this.data = data;
+ this.source = source;
+ }
+
+ public KeyPath(byte[] data) {
+ this(data, KeycardCommandSet.DERIVE_P1_SOURCE_MASTER);
+ }
+
+ private long parseComponent(String num) {
+ long sign;
+
+ if(num.endsWith("'")) {
+ sign = 0x80000000L;
+ num = num.substring(0, (num.length() - 1));
+ } else {
+ sign = 0L;
+ }
+
+ if(num.startsWith("+") || num.startsWith("-")) {
+ throw new NumberFormatException("No sign allowed");
+ }
+ return (sign | Long.parseLong(num));
+ }
+
+ private void writeComponent(long component, int i) {
+ int off = (i * 4);
+ data[off] = (byte) ((component >> 24) & 0xff);
+ data[off + 1] = (byte) ((component >> 16) & 0xff);
+ data[off + 2] = (byte) ((component >> 8) & 0xff);
+ data[off + 3] = (byte) (component & 0xff);
+ }
+
+ /**
+ * The source of the derive command.
+ *
+ * @return the source of the derive command
+ */
+ public int getSource() {
+ return source;
+ }
+
+ /**
+ * The byte encoded key path.
+ *
+ * @return byte encoded key path
+ */
+ public byte[] getData() {
+ return data;
+ }
+
+ @Override
+ public String toString() {
+ StringBuffer sb = new StringBuffer();
+
+ switch(source) {
+ case KeycardCommandSet.DERIVE_P1_SOURCE_MASTER:
+ sb.append('m');
+ break;
+ case KeycardCommandSet.DERIVE_P1_SOURCE_PARENT:
+ sb.append("..");
+ break;
+ case KeycardCommandSet.DERIVE_P1_SOURCE_CURRENT:
+ sb.append('.');
+ break;
+ }
+
+ for(int i = 0; i < this.data.length; i += 4) {
+ sb.append('/');
+ appendComponent(sb, i);
+ }
+
+ return sb.toString();
+ }
+
+ private void appendComponent(StringBuffer sb, int i) {
+ int num = ((this.data[i] & 0x7f) << 24) | ((this.data[i + 1] & 0xff) << 16) | ((this.data[i + 2] & 0xff) << 8) | (this.data[i + 3] & 0xff);
+ sb.append(num);
+
+ if((this.data[i] & 0x80) == 0x80) {
+ sb.append('\'');
+ }
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/keycard/Keycard.java b/src/main/java/com/sparrowwallet/sparrow/io/keycard/Keycard.java
new file mode 100644
index 0000000..1ca147d
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/io/keycard/Keycard.java
@@ -0,0 +1,95 @@
+package com.sparrowwallet.sparrow.io.keycard;
+
+import com.sparrowwallet.drongo.crypto.ChildNumber;
+import com.sparrowwallet.drongo.wallet.Keystore;
+import com.sparrowwallet.drongo.wallet.WalletModel;
+import com.sparrowwallet.sparrow.io.ImportException;
+import com.sparrowwallet.sparrow.io.KeystoreCardImport;
+import javafx.beans.property.StringProperty;
+import org.apache.commons.lang3.StringUtils;
+
+import javax.smartcardio.CardException;
+import java.util.List;
+
+public class Keycard implements KeystoreCardImport {
+
+ @Override
+ public boolean isInitialized() throws CardException {
+ KeycardApi cardApi = null;
+ try {
+ cardApi = new KeycardApi(WalletModel.KEYCARD, null);
+ return cardApi.isInitialized();
+ } finally {
+ if(cardApi != null) {
+ cardApi.disconnect();
+ }
+ }
+ }
+
+ @Override
+ public void initialize(String pin, byte[] entropy, StringProperty messageProperty) throws CardException {
+ if(!StringUtils.isNumeric(pin)) {
+ throw new CardException("PIN must be all digits.");
+ }
+
+ if(pin.length() != 6) {
+ throw new CardException("PIN must be 6 digit longs.");
+ }
+
+ KeycardApi cardApi = null;
+ try {
+ cardApi = new KeycardApi(WalletModel.KEYCARD, pin);
+ if(cardApi.isInitialized()) {
+ throw new IllegalStateException("Card is already initialized.");
+ }
+
+ cardApi.initialize(0, entropy);
+ } finally {
+ if(cardApi != null) {
+ cardApi.disconnect();
+ }
+ }
+ }
+
+ @Override
+ public Keystore getKeystore(String pin, List<ChildNumber> derivation, StringProperty messageProperty) throws ImportException {
+ if(!StringUtils.isNumeric(pin)) {
+ throw new ImportException("PIN must be all digits.");
+ }
+
+ if(pin.length() != 6) {
+ throw new ImportException("PIN must be 6 digit longs.");
+ }
+
+ KeycardApi cardApi = null;
+ try {
+ cardApi = new KeycardApi(WalletModel.KEYCARD, pin);
+ if(!cardApi.isInitialized()) {
+ throw new IllegalStateException("Card is not initialized.");
+ }
+ cardApi.setDerivation(derivation);
+ return cardApi.getKeystore();
+ } catch(Exception e) {
+ throw new ImportException(e);
+ } finally {
+ if(cardApi != null) {
+ cardApi.disconnect();
+ }
+ }
+ }
+
+ @Override
+ public String getKeystoreImportDescription(int account) {
+ return "Import the keystore from your Keycard by inserting or placing it on the card reader.";
+ }
+
+ @Override
+ public String getName() {
+ return "Keycard";
+ }
+
+ @Override
+ public WalletModel getWalletModel() {
+ return WalletModel.KEYCARD;
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/keycard/KeycardApi.java b/src/main/java/com/sparrowwallet/sparrow/io/keycard/KeycardApi.java
new file mode 100644
index 0000000..c591223
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/io/keycard/KeycardApi.java
@@ -0,0 +1,426 @@
+package com.sparrowwallet.sparrow.io.keycard;
+
+import com.sparrowwallet.drongo.*;
+import com.sparrowwallet.drongo.address.Address;
+import com.sparrowwallet.drongo.crypto.ChildNumber;
+import com.sparrowwallet.drongo.crypto.ECDSASignature;
+import com.sparrowwallet.drongo.crypto.ECKey;
+import com.sparrowwallet.drongo.protocol.*;
+import com.sparrowwallet.drongo.psbt.PSBT;
+import com.sparrowwallet.drongo.psbt.PSBTInput;
+import com.sparrowwallet.drongo.psbt.PSBTInputSigner;
+import com.sparrowwallet.drongo.wallet.*;
+import com.sparrowwallet.sparrow.control.CardImportPane;
+import com.sparrowwallet.sparrow.io.CardApi;
+import com.sparrowwallet.sparrow.io.CardAuthorizationException;
+import javafx.beans.property.StringProperty;
+import javafx.concurrent.Service;
+import javafx.concurrent.Task;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.smartcardio.CardException;
+import java.io.IOException;
+import java.math.BigInteger;
+import java.nio.ByteBuffer;
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+public class KeycardApi extends CardApi {
+ private static final Logger log = LoggerFactory.getLogger(KeycardApi.class);
+
+ private final WalletModel cardType;
+ private final KeycardTransport cardTransport;
+ private final KeycardCommandSet cardProtocol;
+ private final String pin;
+ private String basePath = null;
+
+ public KeycardApi(WalletModel cardType, String pin) throws CardException {
+ this.cardType = cardType;
+ this.cardTransport = new KeycardTransport(Identifiers.getKeycardInstanceAID());
+ this.cardProtocol = new KeycardCommandSet(cardTransport);
+ this.pin = pin;
+
+ try {
+ this.cardProtocol.select().checkOK();
+ } catch(IOException | APDUException e) {
+ throw new CardException(e);
+ }
+ }
+
+ @Override
+ public boolean isInitialized() throws CardException {
+ return getStatus().hasMasterKey();
+ }
+
+ //TODO
+ @Override
+ public void initialize(int slot, byte[] seedBytes) throws CardException {
+ // TODO check device certificate
+ ApplicationInfo cardStatus = this.getStatus();
+
+ if(!cardStatus.isInitializedCard()) {
+ try {
+ String puk = String.format("%012d", new SecureRandom().nextLong(999999999999L));
+ this.cardProtocol.init(pin, puk, "KeycardDefaultPairing").checkOK();
+ this.cardProtocol.select().checkOK();
+ } catch(IOException | APDUException e) {
+ throw new CardException(e);
+ }
+ }
+
+ if(!cardStatus.hasMasterKey()) {
+ try {
+ this.authenticate();
+ this.cardProtocol.autoUnpair();
+ this.cardProtocol.loadKey(seedBytes).checkOK();
+ } catch(IOException | APDUException e) {
+ throw new CardException(e);
+ }
+ }
+ }
+
+ @Override
+ public WalletModel getCardType() throws CardException {
+ return WalletModel.KEYCARD;
+ }
+
+ @Override
+ public int getCurrentSlot() throws CardException {
+ throw new CardException("Keycard does not support slots");
+ }
+
+ @Override
+ public ScriptType getDefaultScriptType() {
+ return ScriptType.P2WPKH;
+ }
+
+ ApplicationInfo getStatus() {
+ return this.cardProtocol.getApplicationInfo();
+ }
+
+ void authenticate() throws IOException, APDUException, CardAuthorizationException {
+ this.cardProtocol.autoPair("KeycardDefaultPairing");
+ this.cardProtocol.autoOpenSecureChannel();
+ this.cardProtocol.verifyPIN(pin).checkAuthOK();
+ this.cardProtocol.autoUnpair();
+ }
+
+ @Override
+ public Service<Void> getAuthDelayService() throws CardException {
+ return null;
+ }
+
+ @Override
+ public boolean requiresBackup() throws CardException {
+ return false;
+ }
+
+ @Override
+ public Service<String> getBackupService() {
+ return null;
+ }
+
+ @Override
+ public boolean changePin(String newPin) throws CardException {
+ try {
+ this.authenticate();
+ this.cardProtocol.changePIN(newPin).checkOK();
+ } catch(IOException | APDUException e) {
+ throw new CardException(e);
+ }
+ return true;
+ }
+
+ void setDerivation(List<ChildNumber> derivation) throws CardException {
+ this.basePath = KeyDerivation.writePath(derivation);
+ }
+
+ @Override
+ public Service<Void> getInitializationService(byte[] seedBytes, StringProperty messageProperty) {
+ return new KeycardApi.CardInitializationService(seedBytes, messageProperty);
+
+ }
+
+ @Override
+ public Service<Keystore> getImportService(List<ChildNumber> derivation, StringProperty messageProperty) {
+ return new CardImportPane.CardImportService(new Keycard(), pin, derivation, messageProperty);
+ }
+
+ private byte[] compressedPub(byte[] uncompressedPub) {
+ byte[] compressed = Arrays.copyOfRange(uncompressedPub, 0, 33);
+ compressed[0] = (byte) (0x02 | (uncompressedPub[64] & 0x1));
+ return compressed;
+ }
+
+ private String cardBip32GetXpub(String stringPath, ExtendedKey.Header xtype) throws IOException, APDUException {
+ KeyPath keyPath = new KeyPath(stringPath);
+ int bytepathLen = keyPath.getData().length;
+ int depth = bytepathLen / 4;
+ APDUResponse rapdu = this.cardProtocol.exportKey(keyPath.getData(), keyPath.getSource(), false, KeycardCommandSet.EXPORT_KEY_P2_EXTENDED_PUBLIC).checkOK();
+ BIP32KeyPair extendedkey = BIP32KeyPair.fromTLV(rapdu.getData());
+
+ byte[] fingerprint = new byte[4];
+ byte[] childNumber = new byte[4];
+
+ if(depth == 0) { //masterkey
+ // fingerprint and childnumber set to all-zero bytes by default
+ //fingerprint= bytes([0,0,0,0])
+ //childNumber= bytes([0,0,0,0])
+ } else { //get parent info
+ APDUResponse rapdu2 = this.cardProtocol.exportKey(Arrays.copyOfRange(keyPath.getData(), 0, bytepathLen - 4), keyPath.getSource(), false, KeycardCommandSet.EXPORT_KEY_P2_PUBLIC_ONLY).checkOK();
+ BIP32KeyPair keyParent = BIP32KeyPair.fromTLV(rapdu2.getData());
+ byte[] identifier = Utils.sha256hash160(compressedPub(keyParent.getPublicKey()));
+ fingerprint = Arrays.copyOfRange(identifier, 0, 4);
+ childNumber = Arrays.copyOfRange(keyPath.getData(), bytepathLen - 4, bytepathLen);
+ }
+
+ ByteBuffer buffer = ByteBuffer.allocate(78);
+ buffer.putInt(xtype.getHeader());
+ buffer.put((byte) depth);
+ buffer.put(fingerprint);
+ buffer.put(childNumber);
+ buffer.put(extendedkey.getChainCode()); // chaincode
+ buffer.put(compressedPub(extendedkey.getPublicKey())); // pubkey (compressed)
+ byte[] xpubByte = buffer.array();
+
+ return Base58.encodeChecked(xpubByte);
+ }
+
+ /*
+ * Keycard derives BIP32 keys based on the fullPath (from masterseed to leaf), not the partial path from a given xpub.
+ * the basePath (from masterseed to xpub) is only provided in Keycard.java:getKeystore(String pin, List<ChildNumber> derivation, StringProperty messageProperty)
+ * In Keycard:getKeystore(), no derivation path (i.e. basePath from masterSeed to xpub or relative path) is given and no derivation is reliably available as a object field.
+ * currently, we try to get the path from this.basePath if available (or use a default value) but it's not reliable enough
+ */
+ @Override
+ public Keystore getKeystore() throws CardException {
+ String keyDerivationString = (this.basePath != null ? this.basePath : getDefaultScriptType().getDefaultDerivationPath());
+ ExtendedKey.Header xtype = Network.get().getXpubHeader();
+
+ String xpub;
+ String masterXpub;
+ try {
+ this.authenticate();
+ xpub = this.cardBip32GetXpub(keyDerivationString, xtype);
+ masterXpub = this.cardBip32GetXpub("m", xtype);
+ } catch(IOException | APDUException e) {
+ throw new CardException(e);
+ }
+
+ ExtendedKey extendedKey = ExtendedKey.fromDescriptor(xpub);
+ ExtendedKey masterExtendedKey = ExtendedKey.fromDescriptor(masterXpub);
+ String masterFingerprint = Utils.bytesToHex(masterExtendedKey.getKey().getFingerprint());
+ KeyDerivation keyDerivation = new KeyDerivation(masterFingerprint, keyDerivationString, true);
+
+ Keystore keystore = new Keystore();
+ keystore.setLabel(WalletModel.KEYCARD.toDisplayString());
+ keystore.setKeyDerivation(keyDerivation);
+ keystore.setSource(KeystoreSource.HW_USB);
+ keystore.setExtendedPublicKey(extendedKey);
+ keystore.setWalletModel(WalletModel.KEYCARD);
+
+ return keystore;
+ }
+
+ @Override
+ public Service<PSBT> getSignService(Wallet wallet, PSBT psbt, StringProperty messageProperty) {
+ return new KeycardApi.SignService(wallet, psbt, messageProperty);
+ }
+
+ void sign(Wallet wallet, PSBT psbt) throws CardException {
+ Map<PSBTInput, WalletNode> signingNodes = wallet.getSigningNodes(psbt);
+ for(PSBTInput psbtInput : psbt.getPsbtInputs()) {
+ if(!psbtInput.isSigned()) {
+ WalletNode signingNode = signingNodes.get(psbtInput);
+ List<Keystore> keystores = wallet.getKeystores();
+ // recover derivation path from Keycard keystore
+ String fullPath = null;
+ for(int i = 0; i < keystores.size(); i++) {
+ Keystore keystore = keystores.get(i);
+ WalletModel walletModel = keystore.getWalletModel();
+ if(walletModel == WalletModel.KEYCARD) {
+ String basePath = keystore.getKeyDerivation().getDerivationPath();
+ String extendedPath = signingNode.getDerivationPath().substring(1);
+ fullPath = basePath + extendedPath;
+ break;
+ }
+ }
+ if(fullPath == null) {
+ // recover a default derivation path from first keystore
+ Keystore keystore = keystores.get(0);
+ String basePath = keystore.getKeyDerivation().getDerivationPath();
+ String extendedPath = signingNode.getDerivationPath().substring(1);
+ fullPath = basePath + extendedPath;
+ }
+ psbtInput.sign(new KeycardApi.CardPSBTInputSigner(signingNode, fullPath));
+ }
+ }
+ }
+
+ @Override
+ public Service<String> getSignMessageService(String message, ScriptType scriptType, List<ChildNumber> derivation, StringProperty messageProperty) {
+ return new KeycardApi.SignMessageService(message, scriptType, derivation, messageProperty);
+ }
+
+ String signMessage(String message, ScriptType scriptType, List<ChildNumber> derivation) throws CardException {
+ String fullpath = KeyDerivation.writePath(derivation);
+ ECKey pubkey;
+
+ try {
+ authenticate();
+ APDUResponse rapdu = cardProtocol.exportKey(fullpath, false, KeycardCommandSet.EXPORT_KEY_P2_PUBLIC_ONLY).checkOK();
+ BIP32KeyPair keys = BIP32KeyPair.fromTLV(rapdu.getData());
+ pubkey = ECKey.fromPublicOnly(compressedPub(keys.getPublicKey()));
+ } catch(IOException | APDUException e) {
+ throw new CardException(e);
+ }
+
+ // sign msg
+ return pubkey.signMessage(message, scriptType, hash -> {
+ try {
+ // do the signature with Keycard
+ APDUResponse rapdu2 = cardProtocol.signWithPath(hash.getBytes(), fullpath, false).checkOK();
+ RecoverableSignature sig = new RecoverableSignature(hash.getBytes(), rapdu2.getData());
+ return new ECDSASignature(new BigInteger(1, sig.getR()), new BigInteger(1, sig.getS()));
+ } catch(Exception e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ @Override
+ public Service<ECKey> getPrivateKeyService(Integer slot, StringProperty messageProperty) {
+ throw new UnsupportedOperationException("Keycard does not support private key export");
+ }
+
+ @Override
+ public Service<Address> getAddressService(StringProperty messageProperty) {
+ return null;
+ }
+
+ @Override
+ public void disconnect() {
+ try {
+ cardTransport.disconnect();
+ } catch(CardException e) {
+ log.error("Error disconnecting Keycard" + e);
+ }
+ }
+
+ public class CardInitializationService extends Service<Void> {
+ private final byte[] seedBytes;
+ private final StringProperty messageProperty;
+
+ public CardInitializationService(byte[] seedBytes, StringProperty messageProperty) {
+ this.seedBytes = seedBytes;
+ this.messageProperty = messageProperty;
+ }
+
+ @Override
+ protected Task<Void> createTask() {
+ return new Task<>() {
+ @Override
+ protected Void call() throws Exception {
+ if(seedBytes == null) {
+ throw new CardException("Failed to initialize Keycard - no seed provided");
+ }
+
+ initialize(0, seedBytes);
+ return null;
+ }
+ };
+ }
+ }
+
+ public class SignService extends Service<PSBT> {
+ private final Wallet wallet;
+ private final PSBT psbt;
+ private final StringProperty messageProperty;
+
+ public SignService(Wallet wallet, PSBT psbt, StringProperty messageProperty) {
+ this.wallet = wallet;
+ this.psbt = psbt;
+ this.messageProperty = messageProperty;
+ }
+
+ @Override
+ protected Task<PSBT> createTask() {
+ return new Task<>() {
+ @Override
+ protected PSBT call() throws Exception {
+ sign(wallet, psbt);
+ return psbt;
+ }
+ };
+ }
+ }
+
+ private class CardPSBTInputSigner implements PSBTInputSigner {
+ private final WalletNode signingNode;
+ private final String fullPath;
+ private ECKey pubkey;
+
+ // todo: provide derivationpath instead of WalletNode??
+ public CardPSBTInputSigner(WalletNode signingNode, String fullPath) {
+ this.signingNode = signingNode;
+ this.fullPath = fullPath;
+ }
+
+ @Override
+ public TransactionSignature sign(Sha256Hash hash, SigHash sigHash, TransactionSignature.Type signatureType) {
+ try {
+ // verify PIN
+ authenticate();
+
+ if(signatureType == TransactionSignature.Type.ECDSA) {
+ // do the signature with Keycard
+ APDUResponse rapdu = cardProtocol.signWithPath(hash.getBytes(), fullPath, false).checkOK();
+ RecoverableSignature sig = new RecoverableSignature(hash.getBytes(), rapdu.getData());
+ pubkey = ECKey.fromPublicOnly(compressedPub(sig.getPublicKey()));
+
+ ECDSASignature ecdsaSig = new ECDSASignature(new BigInteger(1, sig.getR()), new BigInteger(1, sig.getS())).toCanonicalised();
+ TransactionSignature txSig = new TransactionSignature(ecdsaSig, sigHash);
+
+ boolean isCorrect = pubkey.verify(hash, txSig);
+ return txSig;
+ } else {
+ throw new CardException(WalletModel.KEYCARD.toDisplayString() + " cannot sign Taproot transactions");
+ }
+ } catch(Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public ECKey getPubKey() {
+ return pubkey;
+ }
+ }
+
+ public class SignMessageService extends Service<String> {
+ private final String message;
+ private final ScriptType scriptType;
+ private final List<ChildNumber> derivation;
+ private final StringProperty messageProperty;
+
+ public SignMessageService(String message, ScriptType scriptType, List<ChildNumber> derivation, StringProperty messageProperty) {
+ this.message = message;
+ this.scriptType = scriptType;
+ this.derivation = derivation;
+ this.messageProperty = messageProperty;
+ }
+
+ @Override
+ protected Task<String> createTask() {
+ return new Task<>() {
+ @Override
+ protected String call() throws Exception {
+ return signMessage(message, scriptType, derivation);
+ }
+ };
+ }
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/keycard/KeycardCommandSet.java b/src/main/java/com/sparrowwallet/sparrow/io/keycard/KeycardCommandSet.java
new file mode 100644
index 0000000..e526809
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/io/keycard/KeycardCommandSet.java
@@ -0,0 +1,870 @@
+package com.sparrowwallet.sparrow.io.keycard;
+
+import com.sparrowwallet.drongo.Drongo;
+
+import javax.crypto.SecretKey;
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.PBEKeySpec;
+import java.io.IOException;
+import java.util.Arrays;
+
+/**
+ * This class is used to send APDU to the applet. Each method corresponds to an APDU as defined in the APPLICATION.md
+ * file. Some APDUs map to multiple methods for the sake of convenience since their payload or response require some
+ * pre/post processing.
+ */
+public class KeycardCommandSet {
+ static final byte INS_INIT = (byte) 0xFE;
+ static final byte INS_FACTORY_RESET = (byte) 0xFD;
+ static final byte INS_GET_STATUS = (byte) 0xF2;
+ static final byte INS_SET_NDEF = (byte) 0xF3;
+ static final byte INS_IDENTIFY_CARD = (byte) 0x14;
+ static final byte INS_VERIFY_PIN = (byte) 0x20;
+ static final byte INS_CHANGE_PIN = (byte) 0x21;
+ static final byte INS_UNBLOCK_PIN = (byte) 0x22;
+ static final byte INS_LOAD_KEY = (byte) 0xD0;
+ static final byte INS_DERIVE_KEY = (byte) 0xD1;
+ static final byte INS_GENERATE_MNEMONIC = (byte) 0xD2;
+ static final byte INS_REMOVE_KEY = (byte) 0xD3;
+ static final byte INS_GENERATE_KEY = (byte) 0xD4;
+ static final byte INS_SIGN = (byte) 0xC0;
+ static final byte INS_SET_PINLESS_PATH = (byte) 0xC1;
+ static final byte INS_EXPORT_KEY = (byte) 0xC2;
+ static final byte INS_GET_DATA = (byte) 0xCA;
+ static final byte INS_STORE_DATA = (byte) 0xE2;
+
+ public static final byte CHANGE_PIN_P1_USER_PIN = 0x00;
+ public static final byte CHANGE_PIN_P1_PUK = 0x01;
+ public static final byte CHANGE_PIN_P1_PAIRING_SECRET = 0x02;
+
+ public static final byte GET_STATUS_P1_APPLICATION = 0x00;
+ public static final byte GET_STATUS_P1_KEY_PATH = 0x01;
+
+ public static final byte LOAD_KEY_P1_EC = 0x01;
+ public static final byte LOAD_KEY_P1_EXT_EC = 0x02;
+ public static final byte LOAD_KEY_P1_SEED = 0x03;
+
+ public static final byte DERIVE_P1_SOURCE_MASTER = (byte) 0x00;
+ public static final byte DERIVE_P1_SOURCE_PARENT = (byte) 0x40;
+ public static final byte DERIVE_P1_SOURCE_CURRENT = (byte) 0x80;
+
+ static final byte SIGN_P1_CURRENT_KEY = 0x00;
+ static final byte SIGN_P1_DERIVE = 0x01;
+ static final byte SIGN_P1_DERIVE_AND_MAKE_CURRENT = 0x02;
+ static final byte SIGN_P1_PINLESS = 0x03;
+
+ public static final byte SIGN_P2_ECDSA = 0x00;
+ public static final byte SIGN_P2_BLS12_381 = 0x01;
+
+ public static final byte STORE_DATA_P1_PUBLIC = 0x00;
+ public static final byte STORE_DATA_P1_NDEF = 0x01;
+ public static final byte STORE_DATA_P1_CASH = 0x02;
+
+ public static final int GENERATE_MNEMONIC_12_WORDS = 0x04;
+ public static final int GENERATE_MNEMONIC_15_WORDS = 0x05;
+ public static final int GENERATE_MNEMONIC_18_WORDS = 0x06;
+ public static final int GENERATE_MNEMONIC_21_WORDS = 0x07;
+ public static final int GENERATE_MNEMONIC_24_WORDS = 0x08;
+
+ static final byte EXPORT_KEY_P1_CURRENT = 0x00;
+ static final byte EXPORT_KEY_P1_DERIVE = 0x01;
+ static final byte EXPORT_KEY_P1_DERIVE_AND_MAKE_CURRENT = 0x02;
+
+ public static final byte EXPORT_KEY_P2_PRIVATE_AND_PUBLIC = 0x00;
+ public static final byte EXPORT_KEY_P2_PUBLIC_ONLY = 0x01;
+ public static final byte EXPORT_KEY_P2_EXTENDED_PUBLIC = 0x02;
+
+ static final byte FACTORY_RESET_P1_MAGIC = (byte) 0xAA;
+ static final byte FACTORY_RESET_P2_MAGIC = 0x55;
+
+ static final byte TLV_APPLICATION_INFO_TEMPLATE = (byte) 0xA4;
+
+ private final CardChannel apduChannel;
+ private SecureChannelSession secureChannel;
+ private ApplicationInfo info;
+
+ /**
+ * Creates a KeycardCommandSet using the given APDU Channel
+ *
+ * @param apduChannel APDU channel
+ */
+ public KeycardCommandSet(CardChannel apduChannel) {
+ this.apduChannel = apduChannel;
+ this.secureChannel = new SecureChannelSession();
+ }
+
+ /**
+ * Returns the application info as stored from the last sent SELECT command. Returns null if no succesful SELECT
+ * command has been sent using this command set.
+ *
+ * @return the application info object
+ */
+ public ApplicationInfo getApplicationInfo() {
+ return info;
+ }
+
+ /**
+ * Set the SecureChannel object
+ *
+ * @param secureChannel secure channel
+ */
+ protected void setSecureChannel(SecureChannelSession secureChannel) {
+ this.secureChannel = secureChannel;
+ }
+
+ /**
+ * Returns the current pairing data.
+ */
+ public Pairing getPairing() {
+ return secureChannel.getPairing();
+ }
+
+ /**
+ * Sets the pairing data.
+ *
+ * @param pairing data from an existing pairing
+ */
+ public void setPairing(Pairing pairing) {
+ secureChannel.setPairing(pairing);
+ }
+
+ /**
+ * Selects the default instance of the Keycard applet. The applet is assumed to have been installed with its default
+ * AID. The returned data is a public key which must be used to initialize the secure channel.
+ *
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse select() throws IOException {
+ return select(Identifiers.KEYCARD_DEFAULT_INSTANCE_IDX);
+ }
+
+ /**
+ * Selects a Keycard instance. The applet is assumed to have been installed with its default AID. The returned data is
+ * a public key which must be used to initialize the secure channel.
+ *
+ * @param instanceIdx the instance index
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse select(int instanceIdx) throws IOException {
+ APDUCommand selectApplet = new APDUCommand(0x00, 0xA4, 4, 0, Identifiers.getKeycardInstanceAID(instanceIdx));
+ APDUResponse resp = apduChannel.send(selectApplet);
+
+
+ if(resp.getSw() == 0x9000) {
+ info = new ApplicationInfo(resp.getData());
+
+ if(info.hasSecureChannelCapability()) {
+ this.secureChannel.generateSecret(info.getSecureChannelPubKey());
+ this.secureChannel.reset();
+ }
+ }
+
+ return resp;
+ }
+
+ /**
+ * Opens the secure channel. Calls the corresponding method of the SecureChannel class.
+ *
+ * @throws IOException communication error
+ * @throws APDUException secure channel error
+ */
+ public void autoOpenSecureChannel() throws IOException, APDUException {
+ secureChannel.autoOpenSecureChannel(apduChannel);
+ }
+
+ /**
+ * Automatically pairs. Derives the secret from the given password.
+ *
+ * @throws IOException communication error
+ * @throws APDUException pairing error
+ */
+ public void autoPair(String pairingPassword) throws IOException, APDUException {
+ byte[] secret = pairingPasswordToSecret(pairingPassword);
+
+ secureChannel.autoPair(apduChannel, secret);
+ }
+
+ /**
+ * Converts a pairing password to a binary pairing secret.
+ *
+ * @param pairingPassword the pairing password
+ * @return the pairing secret
+ */
+ public byte[] pairingPasswordToSecret(String pairingPassword) {
+ SecretKey key;
+
+ try {
+ SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256", Drongo.getProvider());
+ PBEKeySpec spec = new PBEKeySpec(pairingPassword.toCharArray(), "Keycard Pairing Password Salt".getBytes(), apduChannel.pairingPasswordPBKDF2IterationCount(), 32 * 8);
+ key = skf.generateSecret(spec);
+ } catch(Exception e) {
+ throw new RuntimeException(e);
+ }
+ return key.getEncoded();
+ }
+
+ /**
+ * Automatically pairs. Calls the corresponding method of the SecureChannel class.
+ *
+ * @throws IOException communication error
+ * @throws APDUException pairing error
+ */
+ public void autoPair(byte[] sharedSecret) throws IOException, APDUException {
+ secureChannel.autoPair(apduChannel, sharedSecret);
+ }
+
+ /**
+ * Automatically unpairs. Calls the corresponding method of the SecureChannel class.
+ *
+ * @throws IOException communication error
+ * @throws APDUException unpairing error
+ */
+ public void autoUnpair() throws IOException, APDUException {
+ secureChannel.autoUnpair(apduChannel);
+ }
+
+ /**
+ * Sends a OPEN SECURE CHANNEL APDU. Calls the corresponding method of the SecureChannel class.
+ */
+ public APDUResponse openSecureChannel(byte index, byte[] data) throws IOException {
+ return secureChannel.openSecureChannel(apduChannel, index, data);
+ }
+
+ /**
+ * Sends a MUTUALLY AUTHENTICATE APDU. Calls the corresponding method of the SecureChannel class.
+ */
+ public APDUResponse mutuallyAuthenticate() throws IOException {
+ return secureChannel.mutuallyAuthenticate(apduChannel);
+ }
+
+ /**
+ * Sends a MUTUALLY AUTHENTICATE APDU. Calls the corresponding method of the SecureChannel class.
+ */
+ public APDUResponse mutuallyAuthenticate(byte[] data) throws IOException {
+ return secureChannel.mutuallyAuthenticate(apduChannel, data);
+ }
+
+ /**
+ * Sends a PAIR APDU. Calls the corresponding method of the SecureChannel class.
+ */
+ public APDUResponse pair(byte p1, byte[] data) throws IOException {
+ return secureChannel.pair(apduChannel, p1, data);
+ }
+
+ /**
+ * Sends a UNPAIR APDU. Calls the corresponding method of the SecureChannel class.
+ */
+ public APDUResponse unpair(byte p1) throws IOException {
+ return secureChannel.unpair(apduChannel, p1);
+ }
+
+ /**
+ * Unpair all other clients.
+ *
+ * @throws IOException communication error
+ * @throws APDUException unpairing error
+ */
+ public void unpairOthers() throws IOException, APDUException {
+ secureChannel.unpairOthers(apduChannel);
+ }
+
+ /**
+ * Sends an IDENTIFY CARD APDU. The challenge is sent as APDU data as-is. It must be 32 bytes long
+ *
+ * @param challenge the data of the APDU
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse identifyCard(byte[] challenge) throws IOException {
+ APDUCommand identifyCard = secureChannel.protectedCommand(0x80, INS_IDENTIFY_CARD, 0, 0, challenge);
+ return secureChannel.transmit(apduChannel, identifyCard);
+ }
+
+ /**
+ * Sends a GET STATUS APDU. The info byte is the P1 parameter of the command, valid constants are defined in the applet
+ * class itself.
+ *
+ * @param info the P1 of the APDU
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse getStatus(byte info) throws IOException {
+ APDUCommand getStatus = secureChannel.protectedCommand(0x80, INS_GET_STATUS, info, 0, new byte[0]);
+ return secureChannel.transmit(apduChannel, getStatus);
+ }
+
+ /**
+ * Sends a VERIFY PIN APDU. The raw bytes of the given string are encrypted using the secure channel and used as APDU
+ * data.
+ *
+ * @param pin the PIN
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse verifyPIN(String pin) throws IOException {
+ APDUCommand verifyPIN = secureChannel.protectedCommand(0x80, INS_VERIFY_PIN, 0, 0, pin.getBytes());
+ return secureChannel.transmit(apduChannel, verifyPIN);
+ }
+
+ /**
+ * Sends a CHANGE PIN APDU to change the user PIN.
+ *
+ * @param pin the new PIN
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse changePIN(String pin) throws IOException {
+ return changePIN(CHANGE_PIN_P1_USER_PIN, pin.getBytes());
+ }
+
+ /**
+ * Sends a CHANGE PIN APDU to change the PUK.
+ *
+ * @param puk the new PUK
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse changePUK(String puk) throws IOException {
+ return changePIN(CHANGE_PIN_P1_PUK, puk.getBytes());
+ }
+
+ /**
+ * Sends a CHANGE PIN APDU to change the pairing password. This does not break existing pairings, but new pairings
+ * will be made using the new password.
+ *
+ * @param pairingPassword the new pairing password
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse changePairingPassword(String pairingPassword) throws IOException {
+ return changePIN(CHANGE_PIN_P1_PAIRING_SECRET, pairingPasswordToSecret(pairingPassword));
+ }
+
+ /**
+ * Sends a CHANGE PIN APDU. The raw bytes of the given string are encrypted using the secure channel and used as APDU
+ * data.
+ *
+ * @param pinType the PIN type
+ * @param pin the new PIN
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse changePIN(int pinType, String pin) throws IOException {
+ return changePIN(pinType, pin.getBytes());
+ }
+
+ /**
+ * Sends a CHANGE PIN APDU. The raw bytes of the given string are encrypted using the secure channel and used as APDU
+ * data.
+ *
+ * @param pinType the PIN type
+ * @param pin the new PIN
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse changePIN(int pinType, byte[] pin) throws IOException {
+ APDUCommand changePIN = secureChannel.protectedCommand(0x80, INS_CHANGE_PIN, pinType, 0, pin);
+ return secureChannel.transmit(apduChannel, changePIN);
+ }
+
+ /**
+ * Sends an UNBLOCK PIN APDU. The PUK and PIN are concatenated and the raw bytes are encrypted using the secure
+ * channel and used as APDU data.
+ *
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse unblockPIN(String puk, String newPin) throws IOException {
+ APDUCommand unblockPIN = secureChannel.protectedCommand(0x80, INS_UNBLOCK_PIN, 0, 0, (puk + newPin).getBytes());
+ return secureChannel.transmit(apduChannel, unblockPIN);
+ }
+
+ /**
+ * Sends a LOAD KEY APDU. The given seed is sent as-is and the P1 of the command is set to LOAD_KEY_P1_SEED (0x03).
+ * This works on cards which support public key derivation. The loaded keyset is extended and support further
+ * key derivation.
+ *
+ * @param seed the binary seed
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse loadKey(byte[] seed) throws IOException {
+ return loadKey(seed, LOAD_KEY_P1_SEED);
+ }
+
+ /**
+ * Sends a LOAD KEY APDU. The key is sent in TLV format. The public key is included if not null. The chain code is
+ * included if not null. P1 is set automatically to either LOAD_KEY_P1_EC or
+ * LOAD_KEY_P1_EXT_EC depending on the presence of the chainCode.
+ *
+ * @param publicKey a raw public key
+ * @param privateKey a raw private key
+ * @param chainCode the chain code
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse loadKey(byte[] publicKey, byte[] privateKey, byte[] chainCode) throws IOException {
+ return loadKey(new BIP32KeyPair(privateKey, chainCode, publicKey), publicKey == null);
+ }
+
+ public APDUResponse loadKey(BIP32KeyPair keyPair) throws IOException {
+ return loadKey(keyPair, false);
+ }
+
+ public APDUResponse loadKey(BIP32KeyPair keyPair, boolean omitPublic) throws IOException {
+ byte p1;
+
+ if(keyPair.isExtended()) {
+ p1 = LOAD_KEY_P1_EXT_EC;
+ } else {
+ p1 = LOAD_KEY_P1_EC;
+ }
+
+ return loadKey(keyPair.toTLV(!omitPublic), p1);
+ }
+
+ /**
+ * Sends a LOAD KEY APDU. The data is encrypted and sent as-is. The keyType parameter is used as P1.
+ *
+ * @param data key data
+ * @param keyType the P1 parameter
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse loadKey(byte[] data, byte keyType) throws IOException {
+ APDUCommand loadKey = secureChannel.protectedCommand(0x80, INS_LOAD_KEY, keyType, 0, data);
+ return secureChannel.transmit(apduChannel, loadKey);
+ }
+
+ /**
+ * Sends a GENERATE MNEMONIC APDU. The cs parameter is the length of the checksum and is used as P1.
+ *
+ * @param cs the P1 parameter
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse generateMnemonic(int cs) throws IOException {
+ APDUCommand generateMnemonic = secureChannel.protectedCommand(0x80, INS_GENERATE_MNEMONIC, cs, 0, new byte[0]);
+ return secureChannel.transmit(apduChannel, generateMnemonic);
+ }
+
+ /**
+ * Sends a REMOVE KEY APDU.
+ *
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse removeKey() throws IOException {
+ APDUCommand removeKey = secureChannel.protectedCommand(0x80, INS_REMOVE_KEY, 0, 0, new byte[0]);
+ return secureChannel.transmit(apduChannel, removeKey);
+ }
+
+ /**
+ * Sends a GENERATE KEY APDU.
+ *
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse generateKey() throws IOException {
+ APDUCommand generateKey = secureChannel.protectedCommand(0x80, INS_GENERATE_KEY, 0, 0, new byte[0]);
+ return secureChannel.transmit(apduChannel, generateKey);
+ }
+
+ /**
+ * Sends a SIGN APDU. This signs a precomputed hash that must be exactly 32-bytes long.
+ *
+ * @param hash the hash to sign
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse sign(byte[] hash) throws IOException {
+ return sign(hash, SIGN_P1_CURRENT_KEY);
+ }
+
+ /**
+ * Sends a SIGN APDU. This signs a precomputed hash that must be exactly 32-bytes long. The key used to sign is given
+ * as a parameter.
+ *
+ * @param hash the hash to sign
+ * @param makeCurrent ture if the key used to sign should become the current key, false otherwise
+ * @return the raw card response
+ * @throws IOException communication error
+ * @params path the path of the key to use
+ */
+ public APDUResponse signWithPath(byte[] hash, String path, boolean makeCurrent) throws IOException {
+ KeyPath keyPath = new KeyPath(path);
+ byte[] pathData = keyPath.getData();
+ byte[] data = Arrays.copyOf(hash, hash.length + pathData.length);
+ System.arraycopy(pathData, 0, data, hash.length, pathData.length);
+ return sign(data, keyPath.getSource() | (makeCurrent ? SIGN_P1_DERIVE_AND_MAKE_CURRENT : SIGN_P1_DERIVE));
+ }
+
+ /**
+ * Sends a SIGN APDU. This signs a precomputed hash that must be exactly 32-bytes long. The pinless path will be used
+ * to sign. This command is the only variant of SIGN which can also be executed without a Secure Channel.
+ *
+ * @param hash the hash to sign
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse signPinless(byte[] hash) throws IOException {
+ return sign(hash, SIGN_P1_PINLESS);
+ }
+
+ /**
+ * Sends a SIGN APDU. This signs a precomputed hash so the input must be exactly 32-bytes long, eventually followed by
+ * a derivation path.
+ *
+ * @param p1 the p1 parameter
+ * @param data the data to sign
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse sign(byte[] data, int p1) throws IOException {
+ APDUCommand sign = secureChannel.protectedCommand(0x80, INS_SIGN, p1, 0x01, data);
+ return secureChannel.transmit(apduChannel, sign);
+ }
+
+ /**
+ * Sends a DERIVE KEY APDU with the given key path.
+ *
+ * @param keypath the string key path
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse deriveKey(String keypath) throws IOException {
+ KeyPath path = new KeyPath(keypath);
+ return deriveKey(path.getData(), path.getSource());
+ }
+
+ /**
+ * Sends a DERIVE KEY APDU. The data is encrypted and sent as-is. The P1 is forced to 0, meaning that the derivation
+ * starts from the master key.
+ *
+ * @param data the raw key path
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse deriveKey(byte[] data) throws IOException {
+ return deriveKey(data, DERIVE_P1_SOURCE_MASTER);
+ }
+
+ /**
+ * Sends a DERIVE KEY APDU. The data is encrypted and sent as-is. The source parameter is used as P1.
+ *
+ * @param data the raw key path or a public key
+ * @param source the source to start derivation
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse deriveKey(byte[] data, int source) throws IOException {
+ APDUCommand deriveKey = secureChannel.protectedCommand(0x80, INS_DERIVE_KEY, source, 0x00, data);
+ return secureChannel.transmit(apduChannel, deriveKey);
+ }
+
+ /**
+ * Sends a SET PINLESS PATH APDU. The path must be absolute, that is starting from the master key.
+ *
+ * @param path the path. Must be an absolute path (i.e: starting from the master key)
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse setPinlessPath(String path) throws IOException {
+ KeyPath keyPath = new KeyPath(path);
+ if(keyPath.getSource() != DERIVE_P1_SOURCE_MASTER) {
+ throw new IllegalArgumentException("Only absolute paths can be set as PINLESS path");
+ }
+
+ return setPinlessPath(keyPath.getData());
+ }
+
+ /**
+ * Sends an empty SET PINLESS PATH APDU, resetting it. After this command the card does not have a PINless path until
+ * a new one is set.
+ *
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse resetPinlessPath() throws IOException {
+ return setPinlessPath(new byte[]{});
+ }
+
+ /**
+ * Sends a SET PINLESS PATH APDU. The data is encrypted and sent as-is.
+ *
+ * @param data the raw key path
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse setPinlessPath(byte[] data) throws IOException {
+ APDUCommand setPinlessPath = secureChannel.protectedCommand(0x80, INS_SET_PINLESS_PATH, 0x00, 0x00, data);
+ return secureChannel.transmit(apduChannel, setPinlessPath);
+ }
+
+ private byte poToP2(boolean publicOnly) {
+ return publicOnly ? EXPORT_KEY_P2_PUBLIC_ONLY : EXPORT_KEY_P2_PRIVATE_AND_PUBLIC;
+ }
+
+ /**
+ * Sends an EXPORT KEY APDU to export the current key.
+ *
+ * @param publicOnly exports only the public key
+ * @return the raw card reponse
+ * @throws IOException communication error
+ */
+ public APDUResponse exportCurrentKey(boolean publicOnly) throws IOException {
+ return exportCurrentKey(poToP2(publicOnly));
+ }
+
+ /**
+ * Sends an EXPORT KEY APDU to export the current key.
+ *
+ * @param p2 the p2 parameter
+ * @return the raw card reponse
+ * @throws IOException communication error
+ */
+ public APDUResponse exportCurrentKey(byte p2) throws IOException {
+ return exportKey(EXPORT_KEY_P1_CURRENT, p2, new byte[0]);
+ }
+
+ /**
+ * Sends an EXPORT KEY APDU. Performs derivation of the given keypath and optionally makes it the current key.
+ *
+ * @param keyPath the keypath to export
+ * @param makeCurrent if the key should be made current or not
+ * @param publicOnly the P2 parameter
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse exportKey(String keyPath, boolean makeCurrent, boolean publicOnly) throws IOException {
+ return exportKey(keyPath, makeCurrent, poToP2(publicOnly));
+ }
+
+ /**
+ * Sends an EXPORT KEY APDU. Performs derivation of the given keypath and optionally makes it the current key.
+ *
+ * @param keyPath the keypath to export
+ * @param makeCurrent if the key should be made current or not
+ * @param p2 the P2 parameter
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse exportKey(String keyPath, boolean makeCurrent, byte p2) throws IOException {
+ KeyPath path = new KeyPath(keyPath);
+ return exportKey(path.getData(), path.getSource(), makeCurrent, p2);
+ }
+
+ /**
+ * Sends an EXPORT KEY APDU. Performs derivation of the given keypath and optionally makes it the current key.
+ *
+ * @param keyPath the keypath to export
+ * @param makeCurrent if the key should be made current or not
+ * @param publicOnly the P2 parameter
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse exportKey(byte[] keyPath, int source, boolean makeCurrent, boolean publicOnly) throws IOException {
+ return exportKey(keyPath, source, makeCurrent, poToP2(publicOnly));
+ }
+
+ /**
+ * Sends an EXPORT KEY APDU. Performs derivation of the given keypath and optionally makes it the current key.
+ *
+ * @param keyPath the keypath to export
+ * @param makeCurrent if the key should be made current or not
+ * @param p2 the P2 parameter
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse exportKey(byte[] keyPath, int source, boolean makeCurrent, byte p2) throws IOException {
+ int p1 = source | (makeCurrent ? EXPORT_KEY_P1_DERIVE_AND_MAKE_CURRENT : EXPORT_KEY_P1_DERIVE);
+ return exportKey(p1, p2, keyPath);
+ }
+
+ /**
+ * Sends an EXPORT KEY APDU. The parameters are sent as-is.
+ *
+ * @param derivationOptions the P1 parameter
+ * @param publicOnly the P2 parameter
+ * @param keypath the data parameter
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse exportKey(int derivationOptions, boolean publicOnly, byte[] keypath) throws IOException {
+ return exportKey(derivationOptions, poToP2(publicOnly), keypath);
+ }
+
+ /**
+ * Sends an EXPORT KEY APDU. The parameters are sent as-is.
+ *
+ * @param derivationOptions the P1 parameter
+ * @param p2 the P2 parameter
+ * @param keypath the data parameter
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse exportKey(int derivationOptions, byte p2, byte[] keypath) throws IOException {
+ APDUCommand exportKey = secureChannel.protectedCommand(0x80, INS_EXPORT_KEY, derivationOptions, p2, keypath);
+ return secureChannel.transmit(apduChannel, exportKey);
+ }
+
+ /**
+ * Sends a GET DATA APDU.
+ *
+ * @param dataType the type of data to be stored
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse getData(byte dataType) throws IOException {
+ APDUCommand getData = secureChannel.protectedCommand(0x80, INS_GET_DATA, dataType, 0, new byte[0]);
+ return secureChannel.transmit(apduChannel, getData);
+ }
+
+ /**
+ * Sends a STORE DATA APDU for NDEF.
+ *
+ * @param ndef the data field of the APDU
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse setNDEF(byte[] ndef) throws IOException {
+ if((info.getAppVersion() >> 8) > 2) {
+ if((ndef.length - 2) != ((ndef[0] << 8) | ndef[1])) {
+ byte[] tmp = new byte[ndef.length + 2];
+ tmp[0] = (byte) (ndef.length >> 8);
+ tmp[1] = (byte) (ndef.length & 0xff);
+ System.arraycopy(ndef, 0, tmp, 2, ndef.length);
+ ndef = tmp;
+ }
+
+ return storeData(ndef, STORE_DATA_P1_NDEF);
+ } else {
+ APDUCommand setNDEF = secureChannel.protectedCommand(0x80, INS_SET_NDEF, 0, 0, ndef);
+ return secureChannel.transmit(apduChannel, setNDEF);
+ }
+ }
+
+ /**
+ * Sends a STORE DATA APDU.
+ *
+ * @param data the data field of the APDU
+ * @param dataType the type of data to be stored
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse storeData(byte[] data, byte dataType) throws IOException {
+ APDUCommand storeData = secureChannel.protectedCommand(0x80, INS_STORE_DATA, dataType, 0, data);
+ return secureChannel.transmit(apduChannel, storeData);
+ }
+
+ /**
+ * Sends the INIT command to the card. If either pinRetries or pukRetries is zero, neither will be sent.
+ *
+ * @param pin the PIN
+ * @param puk the PUK
+ * @param pairingPassword pairing password
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse init(String pin, String puk, String pairingPassword) throws IOException {
+ return this.init(pin, puk, pairingPassword, (byte) 0, (byte) 0);
+ }
+
+ /**
+ * Sends the INIT command to the card.
+ *
+ * @param pin the PIN
+ * @param puk the PUK
+ * @param pairingPassword pairing password
+ * @param pinRetries the number of allowed PIN retries
+ * @param pukRetries the number of allowed PUK retries
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse init(String pin, String puk, String pairingPassword, byte pinRetries, byte pukRetries) throws IOException {
+ return this.init(pin, null, puk, pairingPasswordToSecret(pairingPassword), pinRetries, pukRetries);
+ }
+
+ /**
+ * Sends the INIT command to the card.
+ *
+ * @param pin the PIN
+ * @param altPin the alternative PIN
+ * @param puk the PUK
+ * @param pairingPassword pairing password
+ * @param pinRetries the number of allowed PIN retries
+ * @param pukRetries the number of allowed PUK retries
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse init(String pin, String altPin, String puk, String pairingPassword, byte pinRetries, byte pukRetries) throws IOException {
+ return this.init(pin, altPin, puk, pairingPasswordToSecret(pairingPassword), pinRetries, pukRetries);
+ }
+
+ /**
+ * Sends the INIT command to the card.
+ *
+ * @param pin the PIN
+ * @param puk the PUK
+ * @param sharedSecret the shared secret for pairing
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse init(String pin, String puk, byte[] sharedSecret) throws IOException {
+ return init(pin, null, puk, sharedSecret, (byte) 0, (byte) 0);
+ }
+
+ /**
+ * Sends the INIT command to the card. If either pinRetries or pukRetries is zero, neither will be sent.
+ *
+ * @param pin the PIN
+ * @param pin the alternative
+ * @param puk the PUK
+ * @param sharedSecret the shared secret for pairing
+ * @param pinRetries the number of allowed PIN retries
+ * @param pukRetries the number of allowed PUK retries
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse init(String pin, String altPin, String puk, byte[] sharedSecret, byte pinRetries, byte pukRetries) throws IOException {
+ int baselen = pin.length() + puk.length() + sharedSecret.length;
+ int extlen;
+
+ if(altPin != null) {
+ extlen = 2 + altPin.length();
+ } else if((pinRetries != 0) || (pukRetries != 0)) {
+ extlen = 2;
+ } else {
+ extlen = 0;
+ }
+
+ byte[] initData = Arrays.copyOf(pin.getBytes(), baselen + extlen);
+ System.arraycopy(puk.getBytes(), 0, initData, pin.length(), puk.length());
+ System.arraycopy(sharedSecret, 0, initData, pin.length() + puk.length(), sharedSecret.length);
+
+ if(extlen > 0) {
+ initData[baselen] = pinRetries;
+ initData[baselen + 1] = pukRetries;
+
+ if(extlen > 2) {
+ System.arraycopy(altPin.getBytes(), 0, initData, baselen + 2, altPin.length());
+ }
+ }
+
+ APDUCommand init = new APDUCommand(0x80, INS_INIT, 0, 0, secureChannel.oneShotEncrypt(initData));
+ return apduChannel.send(init);
+ }
+
+ /**
+ * Sends the FACTORY RESET command to the card.
+ *
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse factoryReset() throws IOException {
+ APDUCommand factoryReset = new APDUCommand(0x80, INS_FACTORY_RESET, FACTORY_RESET_P1_MAGIC, FACTORY_RESET_P2_MAGIC, new byte[0]);
+ return apduChannel.send(factoryReset);
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/keycard/KeycardTransport.java b/src/main/java/com/sparrowwallet/sparrow/io/keycard/KeycardTransport.java
new file mode 100644
index 0000000..1a92578
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/io/keycard/KeycardTransport.java
@@ -0,0 +1,74 @@
+package com.sparrowwallet.sparrow.io.keycard;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.smartcardio.*;
+import java.io.IOException;
+import java.util.Iterator;
+import java.util.List;
+
+public class KeycardTransport implements CardChannel {
+ private static final Logger log = LoggerFactory.getLogger(KeycardTransport.class);
+
+ private final Card connection;
+
+ KeycardTransport(byte[] appletAid) throws CardException {
+ TerminalFactory tf = TerminalFactory.getDefault();
+ List<CardTerminal> terminals = tf.terminals().list();
+ if(terminals.isEmpty()) {
+ throw new IllegalStateException("No reader connected");
+ }
+
+ Card connection = null;
+ for(Iterator<CardTerminal> iter = terminals.iterator(); iter.hasNext(); ) {
+ try {
+ connection = getConnection(iter.next(), appletAid);
+ break;
+ } catch(CardException e) {
+ if(!iter.hasNext()) {
+ log.info(e.getMessage());
+ throw e;
+ }
+ }
+ }
+
+ this.connection = connection;
+ }
+
+ private Card getConnection(CardTerminal cardTerminal, byte[] appletAid) throws CardException {
+ Card connection = cardTerminal.connect("*");
+
+ javax.smartcardio.CardChannel cardChannel = connection.getBasicChannel();
+ ResponseAPDU resp = cardChannel.transmit(new CommandAPDU(0, 0xA4, 4, 0, appletAid));
+ if(resp.getSW() != APDUResponse.SW_OK) {
+ throw new CardException("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()));
+ }
+
+ return connection;
+ }
+
+ public APDUResponse send(APDUCommand capdu) throws IOException {
+ javax.smartcardio.CardChannel cardChannel = this.connection.getBasicChannel();
+
+ CommandAPDU cmd = new CommandAPDU(capdu.getCla(), capdu.getIns(), capdu.getP1(), capdu.getP2(), capdu.getData());
+ ResponseAPDU resp;
+
+ try {
+ resp = cardChannel.transmit(cmd);
+ } catch(CardException e) {
+ throw new IOException(e);
+ }
+
+ return new APDUResponse(resp.getBytes());
+ }
+
+ @Override
+ public boolean isConnected() {
+ return false;
+ }
+
+ void disconnect() throws CardException {
+ connection.disconnect(true);
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/keycard/Pairing.java b/src/main/java/com/sparrowwallet/sparrow/io/keycard/Pairing.java
new file mode 100644
index 0000000..714d63c
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/io/keycard/Pairing.java
@@ -0,0 +1,62 @@
+package com.sparrowwallet.sparrow.io.keycard;
+
+import java.util.Arrays;
+import java.util.Base64;
+
+/**
+ * Stores pairing information.
+ */
+public class Pairing {
+ private byte[] pairingKey;
+ private byte pairingIndex;
+
+ /**
+ * Constructor. The pairingKey and pairingIndex are those generated at the end of a successful pairing.
+ *
+ * @param pairingKey the pairing key
+ * @param pairingIndex the pairing index
+ */
+ public Pairing(byte[] pairingKey, byte pairingIndex) {
+ this.pairingKey = pairingKey;
+ this.pairingIndex = pairingIndex;
+ }
+
+ /**
+ * Constructor. Initializes from a byte array previously generated from the toByteArray method
+ *
+ * @param fromByteArray the result of a previous toByteArray invocation
+ */
+ public Pairing(byte[] fromByteArray) {
+ pairingIndex = fromByteArray[0];
+ pairingKey = Arrays.copyOfRange(fromByteArray, 1, fromByteArray.length);
+ }
+
+ /**
+ * Constructor. Initializes from a String previously generated from the toBase64 method
+ *
+ * @param base64 the result of a previous toBase64 invocation
+ */
+ public Pairing(String base64) {
+ this(Base64.getDecoder().decode(base64));
+ }
+
+ public byte[] getPairingKey() {
+ return pairingKey;
+ }
+
+ public byte getPairingIndex() {
+ return pairingIndex;
+ }
+
+ public byte[] toByteArray() {
+ byte[] res = new byte[pairingKey.length + 1];
+ res[0] = pairingIndex;
+ System.arraycopy(pairingKey, 0, res, 1, pairingKey.length);
+
+ return res;
+ }
+
+ public String toBase64() {
+ return Base64.getEncoder().encodeToString(toByteArray());
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/keycard/RecoverableSignature.java b/src/main/java/com/sparrowwallet/sparrow/io/keycard/RecoverableSignature.java
new file mode 100644
index 0000000..2346cfe
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/io/keycard/RecoverableSignature.java
@@ -0,0 +1,142 @@
+package com.sparrowwallet.sparrow.io.keycard;
+
+import com.sparrowwallet.drongo.crypto.ECDSASignature;
+import com.sparrowwallet.drongo.crypto.ECKey;
+import com.sparrowwallet.drongo.protocol.Sha256Hash;
+
+import java.math.BigInteger;
+import java.util.Arrays;
+
+/**
+ * Signature with recoverable public key.
+ */
+public class RecoverableSignature {
+ private byte[] publicKey;
+ private int recId;
+ private byte[] r;
+ private byte[] s;
+ private boolean compressed;
+
+ public static final byte TLV_SIGNATURE_TEMPLATE = (byte) 0xA0;
+ public static final byte TLV_RAW_SIGNATURE = (byte) 0x80;
+ public static final byte TLV_ECDSA_TEMPLATE = (byte) 0x30;
+
+ /**
+ * Parses a signature from the card and calculates the recovery ID.
+ *
+ * @param hash the message being signed
+ * @param tlvData the signature as returned from the card
+ */
+ public RecoverableSignature(byte[] hash, byte[] tlvData) {
+ TinyBERTLV tlv = new TinyBERTLV(tlvData);
+ int tag = tlv.readTag();
+ tlv.unreadLastTag();
+
+ if(tag == TLV_RAW_SIGNATURE) {
+ initFromRawSignature(hash, tlv.readPrimitive(tag));
+ } else if(tag == TLV_SIGNATURE_TEMPLATE) {
+ initFromLegacy(hash, tlv);
+ } else {
+ throw new IllegalArgumentException("invalid tlv");
+ }
+ }
+
+ private void initFromLegacy(byte[] hash, TinyBERTLV tlv) {
+ tlv.enterConstructed(TLV_SIGNATURE_TEMPLATE);
+ this.publicKey = tlv.readPrimitive(ApplicationInfo.TLV_PUB_KEY);
+ tlv.enterConstructed(TLV_ECDSA_TEMPLATE);
+ this.r = toUInt(tlv.readPrimitive(TinyBERTLV.TLV_INT));
+ this.s = toUInt(tlv.readPrimitive(TinyBERTLV.TLV_INT));
+ this.compressed = false;
+
+ calculateRecID(hash);
+ }
+
+ private void initFromRawSignature(byte[] hash, byte[] signature) {
+ this.r = Arrays.copyOfRange(signature, 0, 32);
+ this.s = Arrays.copyOfRange(signature, 32, 64);
+ this.recId = signature[64];
+ this.compressed = false;
+ this.publicKey = recoverFromSignature(this.recId, hash, this.r, this.s, this.compressed);
+ }
+
+ public RecoverableSignature(byte[] publicKey, boolean compressed, byte[] r, byte[] s, int recId) {
+ this.publicKey = publicKey;
+ this.r = r;
+ this.s = s;
+ this.compressed = compressed;
+ this.recId = recId;
+ }
+
+ void calculateRecID(byte[] hash) {
+ recId = -1;
+
+ for(int i = 0; i < 4; i++) {
+ byte[] candidate = recoverFromSignature(i, hash, r, s, compressed);
+
+ if(Arrays.equals(candidate, publicKey)) {
+ recId = i;
+ break;
+ }
+ }
+
+ if(recId == -1) {
+ throw new IllegalArgumentException("Unrecoverable signature, cannot find recId");
+ }
+ }
+
+ static byte[] toUInt(byte[] signedInt) {
+ if(signedInt[0] == 0) {
+ return Arrays.copyOfRange(signedInt, 1, signedInt.length);
+ } else {
+ return signedInt;
+ }
+ }
+
+ /**
+ * The public key associated to this signature.
+ *
+ * @return the public key associated to this signature
+ */
+ public byte[] getPublicKey() {
+ return publicKey;
+ }
+
+ /**
+ * The recovery ID
+ *
+ * @return recovery ID
+ */
+ public int getRecId() {
+ return recId;
+ }
+
+ /**
+ * The R value.
+ *
+ * @return r
+ */
+ public byte[] getR() {
+ return r;
+ }
+
+ /**
+ * The S value
+ *
+ * @return s
+ */
+ public byte[] getS() {
+ return s;
+ }
+
+ static byte[] recoverFromSignature(int recId, byte[] hash, byte[] r, byte[] s, boolean compressed) {
+ ECDSASignature sig = new ECDSASignature(new BigInteger(1, r), new BigInteger(1, s));
+ ECKey key = ECKey.recoverFromSignature(recId, sig, Sha256Hash.wrap(hash), compressed);
+
+ if(key == null) {
+ return null;
+ }
+
+ return key.getPubKey();
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/keycard/SecureChannelSession.java b/src/main/java/com/sparrowwallet/sparrow/io/keycard/SecureChannelSession.java
new file mode 100644
index 0000000..f89ec34
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/io/keycard/SecureChannelSession.java
@@ -0,0 +1,444 @@
+package com.sparrowwallet.sparrow.io.keycard;
+
+import com.sparrowwallet.drongo.Drongo;
+import com.sparrowwallet.drongo.bip47.SecretPoint;
+import com.sparrowwallet.drongo.crypto.ECKey;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.io.IOException;
+import java.security.*;
+import java.util.Arrays;
+
+/**
+ * Handles a SecureChannel session with the card.
+ */
+public class SecureChannelSession {
+ public static final short SC_SECRET_LENGTH = 32;
+ public static final short SC_BLOCK_SIZE = 16;
+
+ public static final byte INS_OPEN_SECURE_CHANNEL = 0x10;
+ public static final byte INS_MUTUALLY_AUTHENTICATE = 0x11;
+ public static final byte INS_PAIR = 0x12;
+ public static final byte INS_UNPAIR = 0x13;
+
+ public static final byte PAIR_P1_FIRST_STEP = 0x00;
+ public static final byte PAIR_P1_LAST_STEP = 0x01;
+
+ public static final int PAYLOAD_MAX_SIZE = 223;
+
+ static final byte PAIRING_MAX_CLIENT_COUNT = 5;
+
+
+ private byte[] secret;
+ private byte[] publicKey;
+ private byte[] iv;
+ private Pairing pairing;
+ private Cipher sessionCipher;
+ private Cipher sessionMac;
+ private SecretKeySpec sessionEncKey;
+ private SecretKeySpec sessionMacKey;
+ private SecureRandom random;
+ private boolean open;
+
+ /**
+ * Constructs a SecureChannel session on the client.
+ */
+ public SecureChannelSession() {
+ random = new SecureRandom();
+ open = false;
+ }
+
+ /**
+ * Generates a pairing secret. This should be called before each session. The public key of the card is used as input
+ * for the EC-DH algorithm. The output is stored as the secret.
+ *
+ * @param keyData the public key returned by the applet as response to the SELECT command
+ */
+ public void generateSecret(byte[] keyData) {
+ try {
+ ECKey key = new ECKey();
+ SecretPoint secretPoint = new SecretPoint(key.getPrivKeyBytes(), keyData);
+ publicKey = key.getPubKey(false);
+ secret = secretPoint.ECDHSecretAsBytes();
+ } catch(Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Returns the public key
+ *
+ * @return the public key
+ */
+ public byte[] getPublicKey() {
+ return publicKey;
+ }
+
+ /**
+ * Returns the pairing information
+ *
+ * @return the pairing information
+ */
+ public Pairing getPairing() {
+ return pairing;
+ }
+
+ /**
+ * Sets pairing information needed to open a secure channel.
+ *
+ * @param pairing the pairing information
+ */
+ public void setPairing(Pairing pairing) {
+ this.pairing = pairing;
+ }
+
+ /**
+ * Establishes a Secure Channel with the card. The command parameters are the public key generated in the first step.
+ * Follows the specifications from the SECURE_CHANNEL.md document.
+ *
+ * @param apduChannel the apdu channel
+ * @throws IOException communication error
+ */
+ public void autoOpenSecureChannel(CardChannel apduChannel) throws IOException, APDUException {
+ APDUResponse response = openSecureChannel(apduChannel, pairing.getPairingIndex(), publicKey);
+ response.checkOK("OPEN SECURE CHANNEL failed");
+ processOpenSecureChannelResponse(response);
+
+ response = mutuallyAuthenticate(apduChannel);
+ response.checkOK("MUTUALLY AUTHENTICATE failed");
+ verifyMutuallyAuthenticateResponse(response);
+ }
+
+ /**
+ * Processes the response from OPEN SECURE CHANNEL. This initialize the session keys, Cipher and MAC internally.
+ *
+ * @param response the card response
+ */
+ public void processOpenSecureChannelResponse(APDUResponse response) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA512");
+ md.update(secret);
+ md.update(pairing.getPairingKey());
+ byte[] data = response.getData();
+ byte[] keyData = md.digest(Arrays.copyOf(data, SC_SECRET_LENGTH));
+ iv = Arrays.copyOfRange(data, SC_SECRET_LENGTH, data.length);
+
+ sessionEncKey = new SecretKeySpec(Arrays.copyOf(keyData, SC_SECRET_LENGTH), "AES");
+ sessionMacKey = new SecretKeySpec(Arrays.copyOfRange(keyData, SC_SECRET_LENGTH, keyData.length), "AES");
+ sessionCipher = Cipher.getInstance("AES/CBC/ISO7816-4Padding", Drongo.getProvider());
+ sessionMac = Cipher.getInstance("AES/CBC/NoPadding", Drongo.getProvider());
+ open = true;
+ } catch(Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Verify that the response from MUTUALLY AUTHENTICATE is correct.
+ *
+ * @param response the card response
+ * @return true if response is correct, false otherwise
+ */
+ public void verifyMutuallyAuthenticateResponse(APDUResponse response) throws APDUException {
+ if(response.getData().length != SC_SECRET_LENGTH) {
+ throw new APDUException("Invalid authentication data from the card");
+ }
+ }
+
+ /**
+ * Handles the entire pairing procedure in order to be able to use the secure channel
+ *
+ * @param apduChannel the apdu channel
+ * @throws IOException communication error
+ */
+ public void autoPair(CardChannel apduChannel, byte[] sharedSecret) throws IOException, APDUException {
+ byte[] challenge = new byte[32];
+ random.nextBytes(challenge);
+ APDUResponse resp = pair(apduChannel, PAIR_P1_FIRST_STEP, challenge).checkOK("Pairing failed on step 1");
+
+ byte[] respData = resp.getData();
+ byte[] cardCryptogram = Arrays.copyOf(respData, 32);
+ byte[] cardChallenge = Arrays.copyOfRange(respData, 32, respData.length);
+ byte[] checkCryptogram;
+
+ MessageDigest md;
+
+ try {
+ md = MessageDigest.getInstance("SHA256");
+ } catch(Exception e) {
+ throw new RuntimeException(e);
+ }
+
+ md.update(sharedSecret);
+ checkCryptogram = md.digest(challenge);
+
+ if(!Arrays.equals(checkCryptogram, cardCryptogram)) {
+ throw new APDUException("Invalid card cryptogram");
+ }
+
+ md.update(sharedSecret);
+ checkCryptogram = md.digest(cardChallenge);
+
+ resp = pair(apduChannel, PAIR_P1_LAST_STEP, checkCryptogram).checkOK("Pairing failed on step 2");
+ respData = resp.getData();
+ md.update(sharedSecret);
+ pairing = new Pairing(md.digest(Arrays.copyOfRange(respData, 1, respData.length)), respData[0]);
+ }
+
+ /**
+ * Unpairs the current paired key
+ *
+ * @param apduChannel the apdu channel
+ * @throws IOException communication error
+ */
+ public void autoUnpair(CardChannel apduChannel) throws IOException, APDUException {
+ unpair(apduChannel, pairing.getPairingIndex()).checkOK("Unpairing failed");
+ }
+
+ /**
+ * Sends a OPEN SECURE CHANNEL APDU.
+ *
+ * @param apduChannel the apdu channel
+ * @param index the P1 parameter
+ * @param data the data
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse openSecureChannel(CardChannel apduChannel, byte index, byte[] data) throws IOException {
+ open = false;
+ APDUCommand openSecureChannel = new APDUCommand(0x80, INS_OPEN_SECURE_CHANNEL, index, 0, data);
+ return apduChannel.send(openSecureChannel);
+ }
+
+ /**
+ * Sends a MUTUALLY AUTHENTICATE APDU. The data is generated automatically
+ *
+ * @param apduChannel the apdu channel
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse mutuallyAuthenticate(CardChannel apduChannel) throws IOException {
+ byte[] data = new byte[SC_SECRET_LENGTH];
+ random.nextBytes(data);
+
+ return mutuallyAuthenticate(apduChannel, data);
+ }
+
+ /**
+ * Sends a MUTUALLY AUTHENTICATE APDU.
+ *
+ * @param apduChannel the apdu channel
+ * @param data the data
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse mutuallyAuthenticate(CardChannel apduChannel, byte[] data) throws IOException {
+ APDUCommand mutuallyAuthenticate = protectedCommand(0x80, INS_MUTUALLY_AUTHENTICATE, 0, 0, data);
+ return transmit(apduChannel, mutuallyAuthenticate);
+ }
+
+ /**
+ * Sends a PAIR APDU.
+ *
+ * @param apduChannel the apdu channel
+ * @param p1 the P1 parameter
+ * @param data the data
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse pair(CardChannel apduChannel, byte p1, byte[] data) throws IOException {
+ APDUCommand pair = new APDUCommand(0x80, INS_PAIR, p1, 0, data);
+ return transmit(apduChannel, pair);
+ }
+
+ /**
+ * Sends a UNPAIR APDU.
+ *
+ * @param apduChannel the apdu channel
+ * @param p1 the P1 parameter
+ * @return the raw card response
+ * @throws IOException communication error
+ */
+ public APDUResponse unpair(CardChannel apduChannel, byte p1) throws IOException {
+ APDUCommand unpair = protectedCommand(0x80, INS_UNPAIR, p1, 0, new byte[0]);
+ return transmit(apduChannel, unpair);
+ }
+
+ /**
+ * Unpair all other clients
+ *
+ * @param apduChannel the apdu channel
+ * @throws IOException communication error
+ */
+ public void unpairOthers(CardChannel apduChannel) throws IOException, APDUException {
+ for(int i = 0; i < PAIRING_MAX_CLIENT_COUNT; i++) {
+ if(i != pairing.getPairingIndex()) {
+ APDUCommand unpair = protectedCommand(0x80, INS_UNPAIR, i, 0, new byte[0]);
+ transmit(apduChannel, unpair).checkOK();
+ }
+ }
+ }
+
+ /**
+ * Encrypts the plaintext data using the session key. The maximum plaintext size is 223 bytes. The returned ciphertext
+ * already includes the IV and padding and can be sent as-is in the APDU payload. If the input is an empty byte array
+ * the returned data will still contain the IV and padding.
+ *
+ * @param data the plaintext data
+ * @return the encrypted data
+ */
+ private byte[] encryptAPDU(byte[] data) {
+ assert data.length <= PAYLOAD_MAX_SIZE;
+
+ try {
+ IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
+
+ sessionCipher.init(Cipher.ENCRYPT_MODE, sessionEncKey, ivParameterSpec);
+ return sessionCipher.doFinal(data);
+ } catch(Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Decrypts the response from the card using the session key. The returned data is already stripped from IV and padding
+ * and can be potentially empty.
+ *
+ * @param data the ciphetext
+ * @return the plaintext
+ */
+ private byte[] decryptAPDU(byte[] data) {
+ try {
+ IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
+ sessionCipher.init(Cipher.DECRYPT_MODE, sessionEncKey, ivParameterSpec);
+ return sessionCipher.doFinal(data);
+ } catch(Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Returns a command APDU with MAC and encrypted data.
+ *
+ * @param cla the CLA byte
+ * @param ins the INS byte
+ * @param p1 the P1 byte
+ * @param p2 the P2 byte
+ * @param data the data, can be an empty array but not null
+ * @return the command APDU
+ */
+ public APDUCommand protectedCommand(int cla, int ins, int p1, int p2, byte[] data) {
+ byte[] finalData;
+
+ if(open) {
+ data = encryptAPDU(data);
+ byte[] meta = new byte[]{(byte) cla, (byte) ins, (byte) p1, (byte) p2, (byte) (data.length + SC_BLOCK_SIZE), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
+ updateIV(meta, data);
+
+ finalData = Arrays.copyOf(iv, iv.length + data.length);
+ System.arraycopy(data, 0, finalData, iv.length, data.length);
+ } else {
+ finalData = data;
+ }
+
+ return new APDUCommand(cla, ins, p1, p2, finalData);
+ }
+
+ /**
+ * Transmits a protected command APDU and unwraps the response data. The MAC is verified, the data decrypted and the
+ * SW read from the payload.
+ *
+ * @param apduChannel the APDU channel
+ * @param apdu the APDU to send
+ * @return the unwrapped response APDU
+ * @throws IOException transmission error
+ */
+ public APDUResponse transmit(CardChannel apduChannel, APDUCommand apdu) throws IOException {
+ APDUResponse resp = apduChannel.send(apdu);
+
+ if(resp.getSw() == 0x6982) {
+ open = false;
+ }
+
+ if(open) {
+ byte[] data = resp.getData();
+ byte[] meta = new byte[]{(byte) data.length, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
+ byte[] mac = Arrays.copyOf(data, iv.length);
+ data = Arrays.copyOfRange(data, iv.length, data.length);
+
+ byte[] plainData = decryptAPDU(data);
+
+ updateIV(meta, data);
+
+ if(!Arrays.equals(iv, mac)) {
+ throw new IOException("Invalid MAC");
+ }
+
+ return new APDUResponse(plainData);
+ } else {
+ return resp;
+ }
+ }
+
+ /**
+ * Marks the SecureChannel as closed
+ */
+ public void reset() {
+ open = false;
+ }
+
+ /**
+ * Encrypts the payload for the INIT command
+ *
+ * @param initData the payload for the INIT command
+ * @return the encrypted buffer
+ */
+ public byte[] oneShotEncrypt(byte[] initData) {
+ try {
+ iv = new byte[SC_BLOCK_SIZE];
+ random.nextBytes(iv);
+ IvParameterSpec ivParameterSpec = new IvParameterSpec(iv);
+ sessionEncKey = new SecretKeySpec(secret, "AES");
+ sessionCipher = Cipher.getInstance("AES/CBC/ISO7816-4Padding", Drongo.getProvider());
+ sessionCipher.init(Cipher.ENCRYPT_MODE, sessionEncKey, ivParameterSpec);
+ initData = sessionCipher.doFinal(initData);
+ byte[] encrypted = new byte[1 + publicKey.length + iv.length + initData.length];
+ encrypted[0] = (byte) publicKey.length;
+ System.arraycopy(publicKey, 0, encrypted, 1, publicKey.length);
+ System.arraycopy(iv, 0, encrypted, (1 + publicKey.length), iv.length);
+ System.arraycopy(initData, 0, encrypted, (1 + publicKey.length + iv.length), initData.length);
+ return encrypted;
+ } catch(Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Marks the SecureChannel as open. Only to be used when writing tests for the SecureChannel, in normal operation this
+ * would only make things wrong.
+ *
+ */
+ protected void setOpen() {
+ open = true;
+ }
+
+ /**
+ * Calculates a CMAC from the metadata and data provided and sets it as the IV for the next message.
+ *
+ * @param meta metadata
+ * @param data data
+ */
+ private void updateIV(byte[] meta, byte[] data) {
+ try {
+ IvParameterSpec ivParameterSpec = new IvParameterSpec(new byte[SC_BLOCK_SIZE]);
+ sessionMac.init(Cipher.ENCRYPT_MODE, sessionMacKey, ivParameterSpec);
+ sessionMac.update(meta);
+ byte[] tmp = sessionMac.doFinal(data);
+ iv = Arrays.copyOfRange(tmp, tmp.length - SC_BLOCK_SIZE, tmp.length);
+ } catch(Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/io/keycard/TinyBERTLV.java b/src/main/java/com/sparrowwallet/sparrow/io/keycard/TinyBERTLV.java
new file mode 100644
index 0000000..a952dbc
--- /dev/null
+++ b/src/main/java/com/sparrowwallet/sparrow/io/keycard/TinyBERTLV.java
@@ -0,0 +1,167 @@
+package com.sparrowwallet.sparrow.io.keycard;
+
+import java.io.ByteArrayOutputStream;
+import java.util.Arrays;
+
+/**
+ * Tiny BER-TLV implementation. Not for general usage, but fast and easy to use for this project.
+ */
+public class TinyBERTLV {
+ public static final byte TLV_BOOL = (byte) 0x01;
+ public static final byte TLV_INT = (byte) 0x02;
+
+ public static final int END_OF_TLV = (int) 0xffffffff;
+
+ private byte[] buffer;
+ private int pos;
+
+ public static int[] readNum(byte[] buf, int off) {
+ int len = buf[off++] & 0xff;
+ int lenlen = 0;
+
+ if((len & 0x80) == 0x80) {
+ lenlen = len & 0x7f;
+ len = readVal(buf, off, lenlen);
+ }
+
+ return new int[]{len, off + lenlen};
+ }
+
+ public static int readVal(byte[] val, int off, int len) {
+ switch(len) {
+ case 1:
+ return val[off] & 0xff;
+ case 2:
+ return ((val[off] & 0xff) << 8) | (val[off + 1] & 0xff);
+ case 3:
+ return ((val[off] & 0xff) << 16) | ((val[off + 1] & 0xff) << 8) | (val[off + 2] & 0xff);
+ case 4:
+ return ((val[off] & 0xff) << 24) | ((val[off + 1] & 0xff) << 16) | ((val[off + 2] & 0xff) << 8) | (val[off + 3] & 0xff);
+ default:
+ throw new IllegalArgumentException("Integers of length " + len + " are unsupported");
+ }
+ }
+
+ public static void writeNum(ByteArrayOutputStream os, int len) {
+ if((len & 0xff000000) != 0) {
+ os.write(0x84);
+ os.write((len & 0xff000000) >> 24);
+ os.write((len & 0x00ff0000) >> 16);
+ os.write((len & 0x0000ff00) >> 8);
+ os.write(len & 0x000000ff);
+ } else if((len & 0x00ff0000) != 0) {
+ os.write(0x83);
+ os.write((len & 0x00ff0000) >> 16);
+ os.write((len & 0x0000ff00) >> 8);
+ os.write(len & 0x000000ff);
+ } else if((len & 0x0000ff00) != 0) {
+ os.write(0x82);
+ os.write((len & 0x0000ff00) >> 8);
+ os.write(len & 0x000000ff);
+ } else if((len & 0x00000080) != 0) {
+ os.write(0x81);
+ os.write(len & 0x000000ff);
+ } else {
+ os.write(len);
+ }
+ }
+
+ public TinyBERTLV(byte[] buffer) {
+ this.buffer = buffer;
+ this.pos = 0;
+ }
+
+ /**
+ * Enters a constructed TLV with the given tag
+ *
+ * @param tag the tag to enter
+ * @return the length of the TLV
+ * @throws IllegalArgumentException if the next tag does not match the given one
+ */
+ public int enterConstructed(int tag) throws IllegalArgumentException {
+ checkTag(tag, readTag());
+ return readLength();
+ }
+
+ /**
+ * Reads a primitive TLV with the given tag
+ *
+ * @param tag the tag to read
+ * @return the body of the TLV
+ * @throws IllegalArgumentException if the next tag does not match the given one
+ */
+ public byte[] readPrimitive(int tag) throws IllegalArgumentException {
+ checkTag(tag, readTag());
+ int len = readLength();
+ pos += len;
+ return Arrays.copyOfRange(buffer, (pos - len), pos);
+ }
+
+ /**
+ * Reads a boolean TLV.
+ *
+ * @return the boolean value of the TLV
+ * @throws IllegalArgumentException if the next tag is not a boolean
+ */
+ public boolean readBoolean() throws IllegalArgumentException {
+ byte[] val = readPrimitive(TLV_BOOL);
+ return ((val[0] & 0xff) == 0xff);
+ }
+
+ /**
+ * Reads an integer TLV.
+ *
+ * @return the integer value of the TLV
+ * @throws IllegalArgumentException if the next tlv is not an integer or is of unsupported length
+ */
+ public int readInt() throws IllegalArgumentException {
+ byte[] val = readPrimitive(TLV_INT);
+ return TinyBERTLV.readVal(val, 0, val.length);
+ }
+
+ /**
+ * Returns all unread bytes in the TLV.
+ *
+ * @return all unread bytes
+ */
+ byte[] peekUnread() {
+ return Arrays.copyOfRange(buffer, pos, buffer.length);
+ }
+
+ /**
+ * Low-level method to unread the last read tag. Only valid if the previous call was readTag(). Does nothing if the
+ * end of the TLV has been reached.
+ */
+ public void unreadLastTag() {
+ if(pos < buffer.length) {
+ pos--;
+ }
+ }
+
+ /**
+ * Reads the next tag. The current implementation only reads tags on one byte. Can be extended if needed.
+ *
+ * @return the tag
+ */
+ public int readTag() {
+ return (pos < buffer.length) ? buffer[pos++] : END_OF_TLV;
+ }
+
+ /**
+ * Reads the next tag. The current implementation only reads length on one and two bytes. Can be extended if needed.
+ *
+ * @return the tag
+ */
+ public int readLength() {
+ int[] len = TinyBERTLV.readNum(buffer, pos);
+ pos = len[1];
+ return len[0];
+ }
+
+ private void checkTag(int expected, int actual) throws IllegalArgumentException {
+ if(expected != actual) {
+ unreadLastTag();
+ throw new IllegalArgumentException("Expected tag: " + expected + ", received: " + actual);
+ }
+ }
+}
diff --git a/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwAirgappedController.java b/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwAirgappedController.java
index 063e028..dfa69c4 100644
--- a/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwAirgappedController.java
+++ b/src/main/java/com/sparrowwallet/sparrow/keystoreimport/HwAirgappedController.java
@@ -7,6 +7,7 @@ import com.sparrowwallet.sparrow.control.TitledDescriptionPane;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.io.ckcard.Satschip;
import com.sparrowwallet.sparrow.io.ckcard.Tapsigner;
+import com.sparrowwallet.sparrow.io.keycard.Keycard;
import com.sparrowwallet.sparrow.io.satochip.Satochip;
import javafx.fxml.FXML;
import javafx.scene.control.Accordion;
@@ -40,7 +41,7 @@ public class HwAirgappedController extends KeystoreImportDetailController {
}
}
- List<KeystoreCardImport> cardImporters = List.of(new Tapsigner(), new Satochip(), new Satschip());
+ List<KeystoreCardImport> cardImporters = List.of(new Tapsigner(), new Satochip(), new Satschip(), new Keycard());
for(KeystoreCardImport importer : cardImporters) {
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
CardImportPane importPane = new CardImportPane(getMasterController().getWallet(), importer, getMasterController().getDefaultDerivation(), getMasterController().getRequiredDerivation());
Why this scored 25/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.