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