diff --git a/src/main/java/com/sparrowwallet/sparrow/io/CardApi.java b/src/main/java/com/sparrowwallet/sparrow/io/CardApi.java
index c3e7f451..e010143e 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 00000000..580d5e18
--- /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 00000000..795db671
--- /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 00000000..ab4ba794
--- /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 00000000..9b95935b
--- /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 00000000..e5e5bf33
--- /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 00000000..247a8c38
--- /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 00000000..2054c591
--- /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 00000000..ef81e24a
--- /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.
+ *
+ * A valid string is composed of a minimum of one and a maximum of 11 components separated by "/".
+ *
+ * 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 ".".
+ *
+ * All other components are positive integers fitting in 31 bit, eventually suffixed by an apostrophe (') sign,
+ * which indicates an hardened key.
+ *
+ * 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 00000000..1ca147d2
--- /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 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 00000000..c591223b
--- /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 getAuthDelayService() throws CardException {
+ return null;
+ }
+
+ @Override
+ public boolean requiresBackup() throws CardException {
+ return false;
+ }
+
+ @Override
+ public Service 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 derivation) throws CardException {
+ this.basePath = KeyDerivation.writePath(derivation);
+ }
+
+ @Override
+ public Service getInitializationService(byte[] seedBytes, StringProperty messageProperty) {
+ return new KeycardApi.CardInitializationService(seedBytes, messageProperty);
+
+ }
+
+ @Override
+ public Service getImportService(List 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 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 getSignService(Wallet wallet, PSBT psbt, StringProperty messageProperty) {
+ return new KeycardApi.SignService(wallet, psbt, messageProperty);
+ }
+
+ void sign(Wallet wallet, PSBT psbt) throws CardException {
+ Map signingNodes = wallet.getSigningNodes(psbt);
+ for(PSBTInput psbtInput : psbt.getPsbtInputs()) {
+ if(!psbtInput.isSigned()) {
+ WalletNode signingNode = signingNodes.get(psbtInput);
+ List 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 getSignMessageService(String message, ScriptType scriptType, List derivation, StringProperty messageProperty) {
+ return new KeycardApi.SignMessageService(message, scriptType, derivation, messageProperty);
+ }
+
+ String signMessage(String message, ScriptType scriptType, List 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 getPrivateKeyService(Integer slot, StringProperty messageProperty) {
+ throw new UnsupportedOperationException("Keycard does not support private key export");
+ }
+
+ @Override
+ public Service 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 {
+ private final byte[] seedBytes;
+ private final StringProperty messageProperty;
+
+ public CardInitializationService(byte[] seedBytes, StringProperty messageProperty) {
+ this.seedBytes = seedBytes;
+ this.messageProperty = messageProperty;
+ }
+
+ @Override
+ protected Task 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 {
+ 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 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 {
+ private final String message;
+ private final ScriptType scriptType;
+ private final List derivation;
+ private final StringProperty messageProperty;
+
+ public SignMessageService(String message, ScriptType scriptType, List derivation, StringProperty messageProperty) {
+ this.message = message;
+ this.scriptType = scriptType;
+ this.derivation = derivation;
+ this.messageProperty = messageProperty;
+ }
+
+ @Override
+ protected Task 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 00000000..e526809c
--- /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 00000000..1a925785
--- /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 terminals = tf.terminals().list();
+ if(terminals.isEmpty()) {
+ throw new IllegalStateException("No reader connected");
+ }
+
+ Card connection = null;
+ for(Iterator 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 00000000..714d63c4
--- /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 00000000..2346cfe6
--- /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 00000000..f89ec34c
--- /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 00000000..a952dbcc
--- /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 063e0281..dfa69c47 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 cardImporters = List.of(new Tapsigner(), new Satochip(), new Satschip());
+ List 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());