[WIP] rename hardwallet to keycard (#11)

* rename hardwallet to keycard

* rename hardwallet to keycard

* rename hardwallet to keycard

* change constants

* add convenience GlobalPlatform methods

* add javadoc to all classes
This commit is contained in:
Bitgamma
2018-12-07 16:44:02 +03:00
committed by GitHub
parent c749e5a744
commit 07fc087cb3
48 changed files with 1563 additions and 958 deletions
+3 -3
View File
@@ -1,8 +1,8 @@
# Hardwallet Android SDK
# Keycard Android SDK
This SDK simplifies integration with the [Status Keycard](https://github.com/status-im/hardware-wallet) in Android
This SDK simplifies integration with the [Status Keycard](https://github.com/status-im/status-keycard) in Android
applications. In this SDK you find both the classes needed for generic communication with SmartCards as well as classes
specifically addressing the Keycard.
To get started, check the file demo/src/main/java/im/status/hardwallet_lite_android/app/MainActivity.java which a simple
To get started, check the file demo/src/main/java/im/status/keycard/app/MainActivity.java which a simple
demo application showing how the SDK works and what you can do with it.
+1 -1
View File
@@ -3,7 +3,7 @@ apply plugin: 'com.android.application'
android {
compileSdkVersion 28
defaultConfig {
applicationId "im.status.hardwallet_lite_android.demo"
applicationId "im.status.keycard.demo"
minSdkVersion 19
targetSdkVersion 28
versionCode 1
+2 -2
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="im.status.hardwallet_lite_android.demo">
package="im.status.keycard.demo">
<application
android:allowBackup="true"
@@ -9,7 +9,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name="im.status.hardwallet_lite_android.app.MainActivity">
<activity android:name="im.status.keycard.app.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
@@ -1,14 +1,14 @@
package im.status.hardwallet_lite_android.app;
package im.status.keycard.app;
import android.nfc.NfcAdapter;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import im.status.hardwallet_lite_android.demo.R;
import im.status.hardwallet_lite_android.io.CardChannel;
import im.status.hardwallet_lite_android.io.CardListener;
import im.status.hardwallet_lite_android.io.CardManager;
import im.status.hardwallet_lite_android.wallet.*;
import im.status.keycard.demo.R;
import im.status.keycard.io.CardChannel;
import im.status.keycard.io.CardListener;
import im.status.keycard.io.NFCCardManager;
import im.status.keycard.applet.*;
import org.spongycastle.util.encoders.Hex;
public class MainActivity extends AppCompatActivity {
@@ -16,21 +16,21 @@ public class MainActivity extends AppCompatActivity {
private static final String TAG = "MainActivity";
private NfcAdapter nfcAdapter;
private CardManager cardManager;
private NFCCardManager cardManager;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
nfcAdapter = NfcAdapter.getDefaultAdapter(this);
cardManager = new CardManager();
cardManager = new NFCCardManager();
cardManager.setCardListener(new CardListener() {
@Override
public void onConnected(CardChannel cardChannel) {
try {
// Applet-specific code
WalletAppletCommandSet cmdSet = new WalletAppletCommandSet(cardChannel);
KeycardCommandSet cmdSet = new KeycardCommandSet(cardChannel);
Log.i(TAG, "Applet selection successful");
@@ -41,7 +41,7 @@ public class MainActivity extends AppCompatActivity {
// can be either generated or chosen by the user. Using fixed values is highly discouraged.
if (!info.isInitializedCard()) {
Log.i(TAG, "Initializing card with test secrets");
cmdSet.init("000000", "123456789012", "WalletAppletTest").checkOK();
cmdSet.init("000000", "123456789012", "KeycardTest").checkOK();
info = new ApplicationInfo(cmdSet.select().checkOK().getData());
}
@@ -56,7 +56,7 @@ public class MainActivity extends AppCompatActivity {
}
// In real projects, the pairing key should be saved and used for all new sessions.
cmdSet.autoPair("WalletAppletTest");
cmdSet.autoPair("KeycardTest");
Pairing pairing = cmdSet.getPairing();
// Never log the pairing key in a real application!
@@ -70,7 +70,7 @@ public class MainActivity extends AppCompatActivity {
Log.i(TAG, "Secure channel opened. Getting applet status.");
// We send a GET STATUS command, which does not require PIN authentication
ApplicationStatus status = new ApplicationStatus(cmdSet.getStatus(WalletAppletCommandSet.GET_STATUS_P1_APPLICATION).checkOK().getData());
ApplicationStatus status = new ApplicationStatus(cmdSet.getStatus(KeycardCommandSet.GET_STATUS_P1_APPLICATION).checkOK().getData());
Log.i(TAG, "PIN retry counter: " + status.getPINRetryCount());
Log.i(TAG, "PUK retry counter: " + status.getPUKRetryCount());
@@ -79,7 +79,7 @@ public class MainActivity extends AppCompatActivity {
// A mnemonic can be generated before PIN authentication. Generating a mnemonic does not create keys on the
// card. a subsequent loadKey step must be performed after PIN authentication. In this example we will only
// show how to convert the output of the card to a usable format but won't actually load the key
Mnemonic mnemonic = new Mnemonic(cmdSet.generateMnemonic(WalletAppletCommandSet.GENERATE_MNEMONIC_12_WORDS).checkOK().getData());
Mnemonic mnemonic = new Mnemonic(cmdSet.generateMnemonic(KeycardCommandSet.GENERATE_MNEMONIC_12_WORDS).checkOK().getData());
// We need to set a wordlist if we plan using this object to derive the binary seed. If we just need the word
// indexes we can skip this step and call mnemonic.getIndexes() instead.
@@ -102,7 +102,7 @@ public class MainActivity extends AppCompatActivity {
}
// Get the current key path using GET STATUS
KeyPath currentPath = new KeyPath(cmdSet.getStatus(WalletAppletCommandSet.GET_STATUS_P1_KEY_PATH).checkOK().getData());
KeyPath currentPath = new KeyPath(cmdSet.getStatus(KeycardCommandSet.GET_STATUS_P1_KEY_PATH).checkOK().getData());
Log.i(TAG, "Current key path: " + currentPath);
if (!currentPath.toString().equals("m/44'/0'/0'/0/0")) {
+1 -1
View File
@@ -5,7 +5,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="im.status.hardwallet_lite_android.app.MainActivity">
tools:context="im.status.keycard.app.MainActivity">
<TextView
android:layout_width="wrap_content"
+1 -1
View File
@@ -1,3 +1,3 @@
<resources>
<string name="app_name">HardwalletLiteAndroid</string>
<string name="app_name">KeycardAndroid</string>
</resources>
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="im.status.hardwallet_lite_android">
package="im.status.keycard">
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.INTERNET" />
@@ -1,20 +0,0 @@
package im.status.hardwallet_lite_android.globalplatform;
import im.status.hardwallet_lite_android.wallet.TinyBERTLV;
public class ApplicationID {
public static final byte TLV_FILE_CONTROL_INFORMATION_TEMPLATE = (byte) 0x6F;
public static final byte TLV_APPLICATION_AID = (byte) 0x84;
private byte[] aid;
public ApplicationID(byte[] tlvData) throws IllegalArgumentException {
TinyBERTLV tlv = new TinyBERTLV(tlvData);
tlv.enterConstructed(TLV_FILE_CONTROL_INFORMATION_TEMPLATE);
this.aid = tlv.readPrimitive(TLV_APPLICATION_AID);
}
public byte[] getAID() {
return aid;
}
}
@@ -1,154 +0,0 @@
package im.status.hardwallet_lite_android.globalplatform;
import android.util.Base64;
import java.security.GeneralSecurityException;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.util.Arrays;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import static android.util.Base64.NO_PADDING;
public class Crypto {
public static final byte[] NullBytes8 = new byte[]{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
public static long PIN_BOUND = 999999L;
public static long PUK_BOUND = 999999999999L;
public static byte[] deriveSCP02SessionKey(byte[] cardKey, byte[] seq, byte[] purposeData) {
byte[] key24 = resizeKey24(cardKey);
try {
byte[] derivationData = new byte[16];
// 2 bytes constant
System.arraycopy(purposeData, 0, derivationData, 0, 2);
// 2 bytes sequence counter + 12 bytes 0x00
System.arraycopy(seq, 0, derivationData, 2, 2);
SecretKeySpec tmpKey = new SecretKeySpec(key24, "DESede");
Cipher cipher = Cipher.getInstance("DESede/CBC/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, tmpKey, new IvParameterSpec(NullBytes8));
return cipher.doFinal(derivationData);
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
throw new IllegalStateException("error generating session keys.", e);
} catch (InvalidKeyException | IllegalBlockSizeException | BadPaddingException | InvalidAlgorithmParameterException e) {
throw new RuntimeException("error generating session keys.", e);
}
}
public static byte[] appendDESPadding(byte[] data) {
int paddingLength = 8 - (data.length % 8);
byte[] newData = new byte[data.length + paddingLength];
System.arraycopy(data, 0, newData, 0, data.length);
newData[data.length] = (byte)0x80;
return newData;
}
public static boolean verifyCryptogram(byte[] key, byte[] hostChallenge, byte[] cardChallenge, byte[] cardCryptogram) {
byte[] data = new byte[hostChallenge.length + cardChallenge.length];
System.arraycopy(hostChallenge, 0, data, 0, hostChallenge.length);
System.arraycopy(cardChallenge, 0, data, hostChallenge.length, cardChallenge.length);
byte[] paddedData = appendDESPadding(data);
byte[] calculated = mac3des(key, paddedData, NullBytes8);
return Arrays.equals(calculated , cardCryptogram);
}
public static byte[] mac3des(byte[] keyData, byte[] data, byte[] iv) {
try {
SecretKeySpec key = new SecretKeySpec(resizeKey24(keyData), "DESede");
Cipher cipher = Cipher.getInstance("DESede/CBC/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv));
byte[] result = cipher.doFinal(data, 0, 24);
byte[] tail = new byte[8];
System.arraycopy(result, 16, tail, 0, 8);
return tail;
} catch (GeneralSecurityException e) {
throw new RuntimeException("error calculating mac.", e);
}
}
public static byte[] macFull3des(byte[] keyData, byte[] data, byte[] iv) {
try {
SecretKeySpec keyDes = new SecretKeySpec(resizeKey8(keyData), "DES");
Cipher cipherDes = Cipher.getInstance("DES/CBC/NoPadding");
cipherDes.init(Cipher.ENCRYPT_MODE, keyDes, new IvParameterSpec(iv));
SecretKeySpec keyDes3 = new SecretKeySpec(resizeKey24(keyData), "DESede");
Cipher cipherDes3 = Cipher.getInstance("DESede/CBC/NoPadding");
byte[] des3Iv = iv.clone();
if (data.length > 8) {
byte[] tmp = cipherDes.doFinal(data, 0, data.length - 8);
System.arraycopy(tmp, tmp.length - 8, des3Iv, 0, 8);
}
cipherDes3.init(Cipher.ENCRYPT_MODE, keyDes3, new IvParameterSpec(des3Iv));
byte[] result = cipherDes3.doFinal(data, data.length - 8, 8);
byte[] tail = new byte[8];
System.arraycopy(result, result.length - 8, tail, 0, 8);
return tail;
} catch (GeneralSecurityException e) {
throw new RuntimeException("error generating full triple DES MAC.", e);
}
}
public static byte[] resizeKey24(byte[] keyData) {
byte[] key = new byte[24];
System.arraycopy(keyData, 0, key, 0, 16);
System.arraycopy(keyData, 0, key, 16, 8);
return key;
}
public static byte[] resizeKey8(byte[] keyData) {
byte[] key = new byte[8];
System.arraycopy(keyData, 0, key, 0, 8);
return key;
}
public static byte[] encryptICV(byte[] macKeyData, byte[] mac) {
try {
Cipher cipher = Cipher.getInstance("DES/ECB/NoPadding");
SecretKeySpec key = new SecretKeySpec(resizeKey8(macKeyData), "DES");
cipher.init(Cipher.ENCRYPT_MODE, key);
return cipher.doFinal(mac);
} catch (GeneralSecurityException e) {
throw new RuntimeException("error generating ICV.", e);
}
}
public static byte[] randomBytes(int length) {
SecureRandom random = new SecureRandom();
byte data[] = new byte[length];
random.nextBytes(data);
return data;
}
public static long randomLong(long bound) {
SecureRandom random = new SecureRandom();
return Math.abs(random.nextLong()) % bound;
}
public static String randomToken(int length) {
return Base64.encodeToString(randomBytes(length),NO_PADDING);
}
}
@@ -1,130 +0,0 @@
package im.status.hardwallet_lite_android.globalplatform;
import org.spongycastle.util.encoders.Hex;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import im.status.hardwallet_lite_android.io.APDUCommand;
import im.status.hardwallet_lite_android.io.APDUException;
import im.status.hardwallet_lite_android.io.APDUResponse;
import im.status.hardwallet_lite_android.io.CardChannel;
public class GlobalPlatformCommandSet {
static final byte INS_SELECT = (byte) 0xA4;
static final byte INS_INITIALIZE_UPDATE = (byte) 0x50;
static final byte INS_EXTERNAL_AUTHENTICATE = (byte) 0x82;
static final byte INS_DELETE = (byte) 0xE4;
static final byte INS_INSTALL = (byte) 0xE6;
static final byte INS_LOAD = (byte) 0xE8;
static final byte SELECT_P1_BY_NAME = (byte) 0x04;
static final byte EXTERNAL_AUTHENTICATE_P1 = (byte) 0x01;
static final byte INSTALL_FOR_LOAD_P1 = (byte) 0x02;
static final byte INSTALL_FOR_INSTALL_P1 = (byte) 0x0C;
static final byte LOAD_P1_MORE_BLOCKS = (byte) 0x00;
static final byte LOAD_P1_LAST_BLOCK = (byte) 0x80;
private final CardChannel apduChannel;
private SecureChannel secureChannel;
private SCP02Keys cardKeys;
private Session session;
private final byte[] testKey = Hex.decode("404142434445464748494a4b4c4d4e4f");
public GlobalPlatformCommandSet(CardChannel apduChannel) {
this.apduChannel = apduChannel;
this.cardKeys = new SCP02Keys(testKey, testKey);
}
public APDUResponse select() throws IOException {
APDUCommand cmd = new APDUCommand(0x00, INS_SELECT, SELECT_P1_BY_NAME, 0, new byte[0]);
return apduChannel.send(cmd);
}
public APDUResponse initializeUpdate(byte[] hostChallenge) throws IOException, APDUException {
APDUCommand cmd = new APDUCommand(0x80, INS_INITIALIZE_UPDATE, 0, 0, hostChallenge, true);
APDUResponse resp = apduChannel.send(cmd);
if (resp.isOK()) {
this.session = SecureChannel.verifyChallenge(hostChallenge, this.cardKeys, resp);
this.secureChannel = new SecureChannel(this.apduChannel, this.session.getKeys());
}
return resp;
}
public APDUResponse externalAuthenticate(byte[] hostChallenge) throws IOException {
byte[] cardChallenge = this.session.getCardChallenge();
byte[] data = new byte[cardChallenge.length + hostChallenge.length];
System.arraycopy(cardChallenge, 0, data, 0, cardChallenge.length);
System.arraycopy(hostChallenge, 0, data, cardChallenge.length, hostChallenge.length);
byte[] paddedData = Crypto.appendDESPadding(data);
byte[] hostCryptogram = Crypto.mac3des(this.session.getKeys().encKeyData, paddedData, Crypto.NullBytes8);
APDUCommand cmd = new APDUCommand(0x84, INS_EXTERNAL_AUTHENTICATE, EXTERNAL_AUTHENTICATE_P1, 0, hostCryptogram);
return this.secureChannel.send(cmd);
}
public APDUResponse delete(byte[] aid) throws IOException {
byte[] data = new byte[aid.length + 2];
data[0] = 0x4F;
data[1] = (byte) aid.length;
System.arraycopy(aid, 0, data, 2, aid.length);
APDUCommand cmd = new APDUCommand(0x80, INS_DELETE, 0, 0, data);
return this.secureChannel.send(cmd);
}
public APDUResponse installForLoad(byte[] aid, byte[] sdaid) throws IOException {
ByteArrayOutputStream data = new ByteArrayOutputStream();
data.write(aid.length);
data.write(aid);
data.write(sdaid.length);
data.write(sdaid);
// empty hash length and hash
data.write(0x00);
data.write(0x00);
data.write(0x00);
APDUCommand cmd = new APDUCommand(0x80, INS_INSTALL, INSTALL_FOR_LOAD_P1, 0, data.toByteArray());
return this.secureChannel.send(cmd);
}
public APDUResponse load(byte[] data, int count, boolean hasMoreBlocks) throws IOException {
int p1 = hasMoreBlocks ? LOAD_P1_MORE_BLOCKS : LOAD_P1_LAST_BLOCK;
APDUCommand cmd = new APDUCommand(0x80, INS_LOAD, p1, count, data);
return this.secureChannel.send(cmd);
}
public APDUResponse installForInstall(byte[] packageAID, byte[] appletAID, byte[] instanceAID, byte[] params) throws IOException {
ByteArrayOutputStream data = new ByteArrayOutputStream();
data.write(packageAID.length);
data.write(packageAID);
data.write(appletAID.length);
data.write(appletAID);
data.write(instanceAID.length);
data.write(instanceAID);
byte[] privileges = new byte[]{0x00};
data.write(privileges.length);
data.write(privileges);
byte[] fullParams = new byte[2 + params.length];
fullParams[0] = (byte) 0xC9;
fullParams[1] = (byte) params.length;
System.arraycopy(params, 0, fullParams, 2, params.length);
data.write(fullParams.length);
data.write(fullParams);
// empty perform token
data.write(0x00);
APDUCommand cmd = new APDUCommand(0x80, INS_INSTALL, INSTALL_FOR_INSTALL_P1, 0, data.toByteArray());
return this.secureChannel.send(cmd);
}
}
@@ -1,138 +0,0 @@
package im.status.hardwallet_lite_android.globalplatform;
import java.io.ByteArrayOutputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import im.status.hardwallet_lite_android.io.APDUCommand;
public class Load {
static final byte CLA = (byte) 0x80;
static final byte INS = (byte) 0xE8;
static final int BLOCK_SIZE = 147; // 255 - 8 bytes for MAC
private static String[] fileNames = {"Header", "Directory", "Import", "Applet",
"Class", "Method", "StaticField", "Export", "ConstantPool", "RefLocation"};
private String path;
private int offset;
private int count;
private byte[] fullData;
public Load(InputStream in) throws FileNotFoundException, IOException {
this.path = path;
this.offset = 0;
this.count = 0;
Map<String, byte[]> files = this.loadFiles(in);
in.close();
this.fullData = this.getCode(files);
}
public Map<String, byte[]> loadFiles(InputStream in) throws IOException {
Map<String, byte[]> files = new LinkedHashMap<>();
ZipInputStream zip = new ZipInputStream(in);
ZipEntry entry = zip.getNextEntry();
while (entry != null) {
ByteArrayOutputStream data = new ByteArrayOutputStream();
byte[] buf = new byte[1024];
int count;
while ((count = zip.read(buf)) != -1) {
data.write(buf, 0, count);
}
String name = baseName(entry.getName());
files.put(name, data.toByteArray());
entry = zip.getNextEntry();
}
return files;
}
private String baseName(String path) {
String[] parts = path.split("[/.]");
return parts[parts.length - 2];
}
public int blocksCount() {
return (int) Math.ceil(this.fullData.length / (float) BLOCK_SIZE);
}
public byte[] nextDataBlock() {
if (this.offset >= this.fullData.length) {
return null;
}
int rangeEnd = this.offset + BLOCK_SIZE;
if (rangeEnd >= this.fullData.length) {
rangeEnd = this.fullData.length;
}
int size = rangeEnd - offset;
byte[] data = new byte[size];
System.arraycopy(this.fullData, this.offset, data, 0, size);
this.count++;
this.offset += size;
return data;
}
public boolean hasMore() {
return this.offset < this.fullData.length;
}
private byte[] encodeFullLength(int length) {
if (length < 0x80) {
return new byte[]{(byte) length};
} else if (length < 0xFF) {
return new byte[]{(byte) 0x81, (byte) length};
} else if (length < 0xFFFF) {
return new byte[]{
(byte) 0x82,
(byte) ((length & 0xFF00) >> 8),
(byte) (length & 0xFF),
};
} else {
return new byte[]{
(byte) 0x83,
(byte) ((length & 0xFF0000) >> 16),
(byte) ((length & 0xFF00) >> 8),
(byte) (length & 0xFF),
};
}
}
public byte[] getCode(Map<String, byte[]> files) throws IOException {
ByteArrayOutputStream dataStream = new ByteArrayOutputStream();
for (String name : fileNames) {
byte[] fileData = files.get(name);
if (fileData == null) {
continue;
}
dataStream.write(fileData);
}
byte[] data = dataStream.toByteArray();
byte[] encodedFullLength = encodeFullLength(data.length);
byte[] fullData = new byte[1 + encodedFullLength.length + data.length];
fullData[0] = (byte) 0xC4;
System.arraycopy(encodedFullLength, 0, fullData, 1, encodedFullLength.length);
System.arraycopy(data, 0, fullData, 1 + encodedFullLength.length, data.length);
return fullData;
}
public int getCount() {
return count;
}
}
@@ -1,19 +0,0 @@
package im.status.hardwallet_lite_android.globalplatform;
public class SCP02Keys {
public byte[] encKeyData;
public byte[] macKeyData;
public SCP02Keys(byte[] encKeyData, byte[] macKeyData) {
this.encKeyData = encKeyData;
this.macKeyData = macKeyData;
}
public byte[] getEncKeyData() {
return encKeyData;
}
public byte[] getMacKeyData() {
return macKeyData;
}
}
@@ -1,56 +0,0 @@
package im.status.hardwallet_lite_android.globalplatform;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Arrays;
import im.status.hardwallet_lite_android.io.APDUCommand;
public class SCP02Wrapper {
private byte[] macKeyData;
private byte[] icv;
public SCP02Wrapper(byte[] macKeyData) {
this.macKeyData = macKeyData;
this.icv = Crypto.NullBytes8.clone();
}
public APDUCommand wrap(APDUCommand cmd) {
try {
int cla = (cmd.getCla() | 0x04) & 0xff;
byte[] data = cmd.getData();
ByteArrayOutputStream macData = new ByteArrayOutputStream();
macData.write(cla);
macData.write(cmd.getIns());
macData.write(cmd.getP1());
macData.write(cmd.getP2());
macData.write(data.length + 8);
macData.write(data);
byte[] icv;
if (Arrays.equals(this.icv, Crypto.NullBytes8)) {
icv = this.icv;
} else {
icv = Crypto.encryptICV(this.macKeyData, this.icv);
}
byte[] mac = Crypto.macFull3des(this.macKeyData, Crypto.appendDESPadding(macData.toByteArray()), icv);
byte[] newData = new byte[data.length + mac.length];
System.arraycopy(data, 0, newData, 0, data.length );
System.arraycopy(mac, 0, newData, data.length, mac.length );
APDUCommand wrapped = new APDUCommand(cla, cmd.getIns(), cmd.getP1(), cmd.getP2(), newData, cmd.getNeedsLE());
this.icv = mac.clone();
return wrapped;
} catch (IOException e) {
throw new RuntimeException("error wrapping APDU command.", e);
}
}
public byte[] getICV() {
return this.icv;
}
}
@@ -1,64 +0,0 @@
package im.status.hardwallet_lite_android.globalplatform;
import java.io.IOException;
import im.status.hardwallet_lite_android.io.APDUCommand;
import im.status.hardwallet_lite_android.io.APDUException;
import im.status.hardwallet_lite_android.io.APDUResponse;
import im.status.hardwallet_lite_android.io.CardChannel;
public class SecureChannel {
private CardChannel channel;
private SCP02Wrapper wrapper;
public static byte[] DERIVATION_PURPOSE_ENC = new byte[]{(byte) 0x01, (byte) 0x82};
public static byte[] DERIVATION_PURPOSE_MAC = new byte[]{(byte) 0x01, (byte) 0x01};
public static byte[] DERIVATION_PURPOSE_DEK = new byte[]{(byte) 0x01, (byte) 0x81};
public SecureChannel(CardChannel channel, SCP02Keys keys) {
this.channel = channel;
this.wrapper = new SCP02Wrapper(keys.getMacKeyData());
}
public APDUResponse send(APDUCommand cmd) throws IOException {
APDUCommand wrappedCommand = this.wrapper.wrap(cmd);
return this.channel.send(wrappedCommand);
}
public static Session verifyChallenge(byte[] hostChallenge, SCP02Keys cardKeys, APDUResponse resp) throws APDUException {
if (resp.getSw() == APDUResponse.SW_SECURITY_CONDITION_NOT_SATISFIED) {
throw new APDUException(resp.getSw(), "security condition not satisfied");
}
if (resp.getSw() == APDUResponse.SW_AUTHENTICATION_METHOD_BLOCKED) {
throw new APDUException(resp.getSw(), "authentication method blocked");
}
byte[] data = resp.getData();
if (data.length != 28) {
throw new APDUException(resp.getSw(), String.format("bad data length, expected 28, got %d", data.length));
}
byte[] cardChallenge = new byte[8];
System.arraycopy(data, 12, cardChallenge, 0, 8);
byte[] cardCryptogram = new byte[8];
System.arraycopy(data, 20, cardCryptogram, 0, 8);
byte[] seq = new byte[2];
System.arraycopy(data, 12, seq, 0, 2);
byte[] sessionEncKey = Crypto.deriveSCP02SessionKey(cardKeys.getEncKeyData(), seq, DERIVATION_PURPOSE_ENC);
byte[] sessionMacKey = Crypto.deriveSCP02SessionKey(cardKeys.getMacKeyData(), seq, DERIVATION_PURPOSE_MAC);
SCP02Keys sessionKeys = new SCP02Keys(sessionEncKey, sessionMacKey);
boolean verified = Crypto.verifyCryptogram(sessionKeys.getEncKeyData(), hostChallenge, cardChallenge, cardCryptogram);
if (!verified) {
throw new APDUException("error verifying card cryptogram.");
}
return new Session(sessionKeys, cardChallenge);
}
}
@@ -1,19 +0,0 @@
package im.status.hardwallet_lite_android.globalplatform;
public class Session {
private SCP02Keys keys;
private byte[] cardChallenge;
public Session(SCP02Keys keys, byte[] cardChallenge) {
this.keys = keys;
this.cardChallenge = cardChallenge;
}
public SCP02Keys getKeys() {
return keys;
}
public byte[] getCardChallenge() {
return cardChallenge;
}
}
@@ -1,67 +0,0 @@
package im.status.hardwallet_lite_android.io;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
public class APDUCommand {
protected int cla;
protected int ins;
protected int p1;
protected int p2;
protected int lc;
protected byte[] data;
protected boolean needsLE;
public APDUCommand(int cla, int ins, int p1, int p2, byte[] data) {
this(cla, ins, p1, p2, data, false);
}
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;
}
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();
}
public int getCla() {
return cla;
}
public int getIns() {
return ins;
}
public int getP1() {
return p1;
}
public int getP2() {
return p2;
}
public byte[] getData() {
return data;
}
public boolean getNeedsLE() {
return this.needsLE;
}
}
@@ -1,15 +0,0 @@
package im.status.hardwallet_lite_android.io;
public class APDUException extends Exception {
public final int sw;
public APDUException(int sw, String message) {
super(message + ", 0x" + String.format("%04X", sw));
this.sw = sw;
}
public APDUException(String message) {
super(message);
this.sw = 0;
}
}
@@ -1,81 +0,0 @@
package im.status.hardwallet_lite_android.io;
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
private byte[] apdu;
private byte[] data;
private int sw;
private int sw1;
private int sw2;
public APDUResponse(byte[] apdu) {
if (apdu.length < 2) {
throw new IllegalArgumentException("APDU response must be at least 2 bytes");
}
this.apdu = apdu;
this.parse();
}
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);
}
public boolean isOK() {
return this.sw == SW_OK;
}
public APDUResponse checkOK() throws APDUException {
this.checkSW(SW_OK);
return this;
}
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");
}
}
public byte[] getData() {
return this.data;
}
public int getSw() {
return this.sw;
}
public int getSw1() {
return this.sw1;
}
public int getSw2() {
return this.sw2;
}
public byte[] getBytes() {
return this.apdu;
}
}
@@ -1,30 +0,0 @@
package im.status.hardwallet_lite_android.io;
import android.nfc.tech.IsoDep;
import android.util.Log;
import org.spongycastle.util.encoders.Hex;
import java.io.IOException;
public class CardChannel {
private static final String TAG = "CardChannel";
private IsoDep isoDep;
public CardChannel(IsoDep isoDep) {
this.isoDep = isoDep;
}
public APDUResponse send(APDUCommand cmd) throws IOException {
byte[] apdu = cmd.serialize();
Log.d(TAG, String.format("COMMAND CLA: %02X INS: %02X P1: %02X P2: %02X LC: %02X", cmd.getCla(), cmd.getIns(), cmd.getP1(), cmd.getP2(), cmd.getData().length));
byte[] resp = this.isoDep.transceive(apdu);
APDUResponse response = new APDUResponse(resp);
Log.d(TAG, String.format("RESPONSE LEN: %02X, SW: %04X %n-----------------------", response.getData().length, response.getSw()));
return response;
}
public boolean isConnected() {
return this.isoDep.isConnected();
}
}
@@ -1,6 +0,0 @@
package im.status.hardwallet_lite_android.io;
public interface CardListener {
void onConnected(CardChannel channel);
void onDisconnected();
}
@@ -1,86 +0,0 @@
package im.status.hardwallet_lite_android.io;
import android.nfc.NfcAdapter;
import android.nfc.Tag;
import android.nfc.tech.IsoDep;
import android.os.SystemClock;
import android.util.Log;
import java.io.IOException;
import java.security.Security;
import java.util.logging.Logger;
public class CardManager extends Thread implements NfcAdapter.ReaderCallback {
private static final String TAG = "CardManager";
private static final int DEFAULT_LOOP_SLEEP_MS = 50;
private IsoDep isoDep;
private boolean isRunning;
private CardListener cardListener;
private int loopSleepMS;
public boolean isConnected() {
return isoDep != null && isoDep.isConnected();
}
public CardManager() {
this(DEFAULT_LOOP_SLEEP_MS);
}
public CardManager(int loopSleepMS) {
this.loopSleepMS = loopSleepMS;
Security.insertProviderAt(new org.spongycastle.jce.provider.BouncyCastleProvider(), 1);
}
@Override
public void onTagDiscovered(Tag tag) {
isoDep = IsoDep.get(tag);
try {
isoDep = IsoDep.get(tag);
isoDep.connect();
isoDep.setTimeout(120000);
} catch (IOException e) {
Log.e(TAG, "error connecting to tag");
}
}
public void run() {
boolean connected = isConnected();
while (true) {
boolean newConnected = isConnected();
if (newConnected != connected) {
connected = newConnected;
Log.i(TAG, "tag " + (connected ? "connected" : "disconnected"));
if (connected && !isRunning) {
onCardConnected();
} else {
onCardDisconnected();
}
}
SystemClock.sleep(loopSleepMS);
}
}
private void onCardConnected() {
isRunning = true;
if (cardListener != null) {
cardListener.onConnected(new CardChannel(isoDep));
}
isRunning = false;
}
private void onCardDisconnected() {
isRunning = false;
isoDep = null;
if (cardListener != null) {
cardListener.onDisconnected();
}
}
public void setCardListener(CardListener listener) {
cardListener = listener;
}
}
@@ -1,4 +1,4 @@
package im.status.hardwallet_lite_android.wallet;
package im.status.keycard.applet;
/**
* Parses the response from a SELECT command. If the card has not yet received the INIT command the isInitializedCard
@@ -73,7 +73,7 @@ public class ApplicationInfo {
}
/**
* The public key to be used for secure channel opening. Usually handled internally by the WalletAppletCommandSet.
* The public key to be used for secure channel opening. Usually handled internally by the KeycardCommandSet.
*
* @return the public key
*/
@@ -1,4 +1,4 @@
package im.status.hardwallet_lite_android.wallet;
package im.status.keycard.applet;
/**
* Parses the result of a GET STATUS command retrieving application status.
@@ -1,4 +1,4 @@
package im.status.hardwallet_lite_android.wallet;
package im.status.keycard.applet;
import org.spongycastle.crypto.digests.KeccakDigest;
import org.spongycastle.math.ec.ECPoint;
@@ -1,7 +1,7 @@
package im.status.hardwallet_lite_android.wallet;
package im.status.keycard.applet;
import im.status.hardwallet_lite_android.io.APDUException;
import im.status.hardwallet_lite_android.io.CardChannel;
import im.status.keycard.io.APDUException;
import im.status.keycard.io.CardChannel;
import java.io.IOException;
import java.security.SecureRandom;
@@ -24,8 +24,8 @@ public class CardDuplicator {
random.nextBytes(secret);
}
private WalletAppletCommandSet preamble(CardChannel channel, Pairing pairing, String pin) throws IOException, APDUException {
WalletAppletCommandSet cmdSet = new WalletAppletCommandSet(channel);
private KeycardCommandSet preamble(CardChannel channel, Pairing pairing, String pin) throws IOException, APDUException {
KeycardCommandSet cmdSet = new KeycardCommandSet(channel);
cmdSet.select().checkOK();
cmdSet.setPairing(pairing);
cmdSet.autoOpenSecureChannel();
@@ -45,7 +45,7 @@ public class CardDuplicator {
* @throws APDUException
*/
public void startDuplication(CardChannel channel, Pairing pairing, String pin, int deviceCount) throws IOException, APDUException {
WalletAppletCommandSet cmdSet = preamble(channel, pairing, pin);
KeycardCommandSet cmdSet = preamble(channel, pairing, pin);
cmdSet.duplicateKeyStart(deviceCount, secret).checkOK();
}
@@ -60,7 +60,7 @@ public class CardDuplicator {
* @throws APDUException
*/
public byte[] exportKey(CardChannel channel, Pairing pairing, String pin) throws IOException, APDUException {
WalletAppletCommandSet cmdSet = preamble(channel, pairing, pin);
KeycardCommandSet cmdSet = preamble(channel, pairing, pin);
return cmdSet.duplicateKeyExport().checkOK().getData();
}
@@ -75,7 +75,7 @@ public class CardDuplicator {
* @throws APDUException
*/
public byte[] importKey(CardChannel channel, Pairing pairing, String pin, byte[] key) throws IOException, APDUException {
WalletAppletCommandSet cmdSet = preamble(channel, pairing, pin);
KeycardCommandSet cmdSet = preamble(channel, pairing, pin);
return cmdSet.duplicateKeyImport(key).checkOK().getData();
}
@@ -88,7 +88,7 @@ public class CardDuplicator {
* @throws APDUException
*/
public void addEntropy(CardChannel channel) throws IOException, APDUException {
WalletAppletCommandSet cmdSet = new WalletAppletCommandSet(channel);
KeycardCommandSet cmdSet = new KeycardCommandSet(channel);
cmdSet.select().checkOK();
cmdSet.duplicateKeyAddEntropy(secret).checkOK();
}
@@ -0,0 +1,22 @@
package im.status.keycard.applet;
import org.spongycastle.util.encoders.Hex;
public class Identifiers {
public static final byte[] PACKAGE_AID = Hex.decode("53746174757357616C6C6574");
public static final byte[] KEYCARD_AID = Hex.decode("53746174757357616C6C6574417070");
public static final byte[] NDEF_AID = Hex.decode("53746174757357616C6C65744E4643");
public static final byte[] NDEF_INSTANCE_AID = Hex.decode("D2760000850101");
/**
* Gets the instance AID of the Keycard applet. Since multiple instances this is a method instead of a constant.
* Soon a method taking an additional instance index will be added.
*
* @return the instance AID of the Keycard applet
*/
public static byte[] getKeycardInstanceAID() {
return KEYCARD_AID;
}
}
@@ -1,16 +1,16 @@
package im.status.hardwallet_lite_android.wallet;
package im.status.keycard.applet;
import java.util.StringTokenizer;
/**
* Keypath object to be used with the WalletAppletCommandSet
* 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 WalletAppletCommandSet object.
* 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 "/".
*
@@ -32,16 +32,16 @@ public class KeyPath {
switch(sourceOrFirstElement) {
case "m":
source = WalletAppletCommandSet.DERIVE_P1_SOURCE_MASTER;
source = KeycardCommandSet.DERIVE_P1_SOURCE_MASTER;
break;
case "..":
source = WalletAppletCommandSet.DERIVE_P1_SOURCE_PARENT;
source = KeycardCommandSet.DERIVE_P1_SOURCE_PARENT;
break;
case ".":
source = WalletAppletCommandSet.DERIVE_P1_SOURCE_CURRENT;
source = KeycardCommandSet.DERIVE_P1_SOURCE_CURRENT;
break;
default:
source = WalletAppletCommandSet.DERIVE_P1_SOURCE_CURRENT;
source = KeycardCommandSet.DERIVE_P1_SOURCE_CURRENT;
tokenizer = new StringTokenizer(keypath, "/"); // rewind
break;
}
@@ -65,7 +65,7 @@ public class KeyPath {
}
public KeyPath(byte[] data) {
this(data, WalletAppletCommandSet.DERIVE_P1_SOURCE_MASTER);
this(data, KeycardCommandSet.DERIVE_P1_SOURCE_MASTER);
}
private long parseComponent(String num) {
@@ -115,13 +115,13 @@ public class KeyPath {
StringBuffer sb = new StringBuffer();
switch(source) {
case WalletAppletCommandSet.DERIVE_P1_SOURCE_MASTER:
case KeycardCommandSet.DERIVE_P1_SOURCE_MASTER:
sb.append('m');
break;
case WalletAppletCommandSet.DERIVE_P1_SOURCE_PARENT:
case KeycardCommandSet.DERIVE_P1_SOURCE_PARENT:
sb.append("..");
break;
case WalletAppletCommandSet.DERIVE_P1_SOURCE_CURRENT:
case KeycardCommandSet.DERIVE_P1_SOURCE_CURRENT:
sb.append('.');
break;
}
@@ -1,9 +1,9 @@
package im.status.hardwallet_lite_android.wallet;
package im.status.keycard.applet;
import im.status.hardwallet_lite_android.io.APDUCommand;
import im.status.hardwallet_lite_android.io.APDUException;
import im.status.hardwallet_lite_android.io.APDUResponse;
import im.status.hardwallet_lite_android.io.CardChannel;
import im.status.keycard.io.APDUCommand;
import im.status.keycard.io.APDUException;
import im.status.keycard.io.APDUResponse;
import im.status.keycard.io.CardChannel;
import org.spongycastle.jce.interfaces.ECPrivateKey;
import org.spongycastle.jce.interfaces.ECPublicKey;
import org.spongycastle.util.encoders.Hex;
@@ -13,7 +13,6 @@ import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.io.IOException;
import java.security.KeyPair;
import java.security.PrivateKey;
import java.util.Arrays;
/**
@@ -21,7 +20,7 @@ import java.util.Arrays;
* file. Some APDUs map to multiple methods for the sake of convenience since their payload or response require some
* pre/post processing.
*/
public class WalletAppletCommandSet {
public class KeycardCommandSet {
static final byte INS_INIT = (byte) 0xFE;
static final byte INS_GET_STATUS = (byte) 0xF2;
static final byte INS_SET_NDEF = (byte) 0xF3;
@@ -69,14 +68,10 @@ public class WalletAppletCommandSet {
static final byte TLV_APPLICATION_INFO_TEMPLATE = (byte) 0xA4;
public static final String APPLET_AID = "53746174757357616C6C6574417070";
static final byte[] APPLET_AID_BYTES = Hex.decode(APPLET_AID);
private final CardChannel apduChannel;
private SecureChannelSession secureChannel;
public WalletAppletCommandSet(CardChannel apduChannel) {
public KeycardCommandSet(CardChannel apduChannel) {
this.apduChannel = apduChannel;
this.secureChannel = new SecureChannelSession();
}
@@ -108,7 +103,7 @@ public class WalletAppletCommandSet {
* @throws IOException communication error
*/
public APDUResponse select() throws IOException {
APDUCommand selectApplet = new APDUCommand(0x00, 0xA4, 4, 0, APPLET_AID_BYTES);
APDUCommand selectApplet = new APDUCommand(0x00, 0xA4, 4, 0, Identifiers.getKeycardInstanceAID());
APDUResponse resp = apduChannel.send(selectApplet);
if (resp.getSw() == 0x9000) {
@@ -151,7 +146,7 @@ public class WalletAppletCommandSet {
try {
SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
PBEKeySpec spec = new PBEKeySpec(pairingPassword.toCharArray(), "Status Hardware Wallet Lite".getBytes(), 50000, 32 * 8);
PBEKeySpec spec = new PBEKeySpec(pairingPassword.toCharArray(), "Keycard Pairing Password Salt".getBytes(), 50000, 32 * 8);
key = skf.generateSecret(spec);
} catch (Exception e) {
throw new RuntimeException("Is Bouncycastle correctly initialized?");
@@ -1,6 +1,4 @@
package im.status.hardwallet_lite_android.wallet;
import android.text.TextUtils;
package im.status.keycard.applet;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
@@ -99,7 +97,7 @@ public class Mnemonic {
* @return the mnemonic phrase
*/
public String toMnemonicPhrase() {
return TextUtils.join(" ", getWords());
return join(" ", getWords());
}
/**
@@ -152,4 +150,28 @@ public class Mnemonic {
return key.getEncoded();
}
/**
* String join. Used instead of Android TextUtils.join or Java 8 String.join method for compatibility reasons.
*
* @param list the list of words
* @param conjunction the conjunction
*
* @return the joined string
*/
private String join(String conjunction, String[] list) {
StringBuilder sb = new StringBuilder();
boolean first = true;
for (String item : list) {
if (first) {
first = false;
} else {
sb.append(conjunction);
}
sb.append(item);
}
return sb.toString();
}
}
@@ -1,4 +1,4 @@
package im.status.hardwallet_lite_android.wallet;
package im.status.keycard.applet;
import org.spongycastle.util.encoders.Base64;
@@ -1,4 +1,4 @@
package im.status.hardwallet_lite_android.wallet;
package im.status.keycard.applet;
import org.spongycastle.asn1.x9.X9ECParameters;
import org.spongycastle.asn1.x9.X9IntegerConverter;
@@ -1,9 +1,9 @@
package im.status.hardwallet_lite_android.wallet;
package im.status.keycard.applet;
import im.status.hardwallet_lite_android.io.APDUCommand;
import im.status.hardwallet_lite_android.io.APDUException;
import im.status.hardwallet_lite_android.io.APDUResponse;
import im.status.hardwallet_lite_android.io.CardChannel;
import im.status.keycard.io.APDUCommand;
import im.status.keycard.io.APDUException;
import im.status.keycard.io.APDUResponse;
import im.status.keycard.io.CardChannel;
import org.spongycastle.crypto.engines.AESEngine;
import org.spongycastle.crypto.macs.CBCBlockCipherMac;
import org.spongycastle.crypto.params.KeyParameter;
@@ -1,4 +1,4 @@
package im.status.hardwallet_lite_android.wallet;
package im.status.keycard.applet;
import java.util.Arrays;
@@ -0,0 +1,219 @@
package im.status.keycard.globalplatform;
import java.security.GeneralSecurityException;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Arrays;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/**
* Crypto utilities for Global Platform.
*/
public class Crypto {
public static final byte[] NullBytes8 = new byte[]{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
public static long PIN_BOUND = 999999L;
public static long PUK_BOUND = 999999999999L;
/**
* Derives a session key for SCP02.
*
* @param cardKey the key to derive
* @param seq the sequence number
* @param purposeData purpose data
*
* @return the derived key
*/
public static byte[] deriveSCP02SessionKey(byte[] cardKey, byte[] seq, byte[] purposeData) {
byte[] key24 = resizeKey24(cardKey);
try {
byte[] derivationData = new byte[16];
// 2 bytes constant
System.arraycopy(purposeData, 0, derivationData, 0, 2);
// 2 bytes sequence counter + 12 bytes 0x00
System.arraycopy(seq, 0, derivationData, 2, 2);
SecretKeySpec tmpKey = new SecretKeySpec(key24, "DESede");
Cipher cipher = Cipher.getInstance("DESede/CBC/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, tmpKey, new IvParameterSpec(NullBytes8));
return cipher.doFinal(derivationData);
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
throw new IllegalStateException("error generating session keys.", e);
} catch (InvalidKeyException | IllegalBlockSizeException | BadPaddingException | InvalidAlgorithmParameterException e) {
throw new RuntimeException("error generating session keys.", e);
}
}
/**
* Padding for SCP02 encryption.
*
* @param data data to pad
* @return the padded data
*/
public static byte[] appendDESPadding(byte[] data) {
int paddingLength = 8 - (data.length % 8);
byte[] newData = new byte[data.length + paddingLength];
System.arraycopy(data, 0, newData, 0, data.length);
newData[data.length] = (byte)0x80;
return newData;
}
/**
* Verifies a card cryptogram received using during SCP02 channel establishment.
*
* @param key the key
* @param hostChallenge host challenge
* @param cardChallenge card challenge
* @param cardCryptogram cryptogram to verify
* @return true if correct, false otherwise
*/
public static boolean verifyCryptogram(byte[] key, byte[] hostChallenge, byte[] cardChallenge, byte[] cardCryptogram) {
byte[] data = new byte[hostChallenge.length + cardChallenge.length];
System.arraycopy(hostChallenge, 0, data, 0, hostChallenge.length);
System.arraycopy(cardChallenge, 0, data, hostChallenge.length, cardChallenge.length);
byte[] paddedData = appendDESPadding(data);
byte[] calculated = mac3des(key, paddedData, NullBytes8);
return Arrays.equals(calculated , cardCryptogram);
}
/**
* Calculates a 3DES MAC for SCP02 channel establishment
*
* @param keyData key
* @param data data to sign
* @param iv IV
* @return the MAC
*/
public static byte[] mac3des(byte[] keyData, byte[] data, byte[] iv) {
try {
SecretKeySpec key = new SecretKeySpec(resizeKey24(keyData), "DESede");
Cipher cipher = Cipher.getInstance("DESede/CBC/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv));
byte[] result = cipher.doFinal(data, 0, 24);
byte[] tail = new byte[8];
System.arraycopy(result, 16, tail, 0, 8);
return tail;
} catch (GeneralSecurityException e) {
throw new RuntimeException("error calculating mac.", e);
}
}
/**
* Generates a 3DES MAC for SCP02 communication
*
* @param keyData key
* @param data data to sign
* @param iv IV
* @return the MAC
*/
public static byte[] macFull3des(byte[] keyData, byte[] data, byte[] iv) {
try {
SecretKeySpec keyDes = new SecretKeySpec(resizeKey8(keyData), "DES");
Cipher cipherDes = Cipher.getInstance("DES/CBC/NoPadding");
cipherDes.init(Cipher.ENCRYPT_MODE, keyDes, new IvParameterSpec(iv));
SecretKeySpec keyDes3 = new SecretKeySpec(resizeKey24(keyData), "DESede");
Cipher cipherDes3 = Cipher.getInstance("DESede/CBC/NoPadding");
byte[] des3Iv = iv.clone();
if (data.length > 8) {
byte[] tmp = cipherDes.doFinal(data, 0, data.length - 8);
System.arraycopy(tmp, tmp.length - 8, des3Iv, 0, 8);
}
cipherDes3.init(Cipher.ENCRYPT_MODE, keyDes3, new IvParameterSpec(des3Iv));
byte[] result = cipherDes3.doFinal(data, data.length - 8, 8);
byte[] tail = new byte[8];
System.arraycopy(result, result.length - 8, tail, 0, 8);
return tail;
} catch (GeneralSecurityException e) {
throw new RuntimeException("error generating full triple DES MAC.", e);
}
}
/**
* Used during key derivation .
*
* @param keyData the key data
*
* @return the resized key
*/
private static byte[] resizeKey24(byte[] keyData) {
byte[] key = new byte[24];
System.arraycopy(keyData, 0, key, 0, 16);
System.arraycopy(keyData, 0, key, 16, 8);
return key;
}
/**
* Used during MAC generation.
*
* @param keyData the key data
*
* @return the resized key
*/
private static byte[] resizeKey8(byte[] keyData) {
byte[] key = new byte[8];
System.arraycopy(keyData, 0, key, 0, 8);
return key;
}
/**
* Encrypts the ICV
*
* @param macKeyData MAC Key
* @param mac mac
*
* @return encrypted ICV
*/
public static byte[] encryptICV(byte[] macKeyData, byte[] mac) {
try {
Cipher cipher = Cipher.getInstance("DES/ECB/NoPadding");
SecretKeySpec key = new SecretKeySpec(resizeKey8(macKeyData), "DES");
cipher.init(Cipher.ENCRYPT_MODE, key);
return cipher.doFinal(mac);
} catch (GeneralSecurityException e) {
throw new RuntimeException("error generating ICV.", e);
}
}
/**
* Generates the given number of random bytes.
*
* @param length the number of bytes to generate
* @return random bytes
*/
public static byte[] randomBytes(int length) {
SecureRandom random = new SecureRandom();
byte data[] = new byte[length];
random.nextBytes(data);
return data;
}
/**
* Generates a random long between 0 and then given boundary
*
* @param bound the maximum value to generate
* @return the random number
*/
public static long randomLong(long bound) {
SecureRandom random = new SecureRandom();
return Math.abs(random.nextLong()) % bound;
}
}
@@ -0,0 +1,316 @@
package im.status.keycard.globalplatform;
import im.status.keycard.applet.Identifiers;
import org.spongycastle.util.encoders.Hex;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.SecureRandom;
import im.status.keycard.io.APDUCommand;
import im.status.keycard.io.APDUException;
import im.status.keycard.io.APDUResponse;
import im.status.keycard.io.CardChannel;
/**
* Command set used for loading, installing and removing applets and packages. This class is generic and can work with
* any package and applet, but utility methods specific to the Keycard have been provided.
*/
public class GlobalPlatformCommandSet {
static final byte INS_SELECT = (byte) 0xA4;
static final byte INS_INITIALIZE_UPDATE = (byte) 0x50;
static final byte INS_EXTERNAL_AUTHENTICATE = (byte) 0x82;
static final byte INS_DELETE = (byte) 0xE4;
static final byte INS_INSTALL = (byte) 0xE6;
static final byte INS_LOAD = (byte) 0xE8;
static final byte SELECT_P1_BY_NAME = (byte) 0x04;
static final byte EXTERNAL_AUTHENTICATE_P1 = (byte) 0x01;
static final byte INSTALL_FOR_LOAD_P1 = (byte) 0x02;
static final byte INSTALL_FOR_INSTALL_P1 = (byte) 0x0C;
static final byte LOAD_P1_MORE_BLOCKS = (byte) 0x00;
static final byte LOAD_P1_LAST_BLOCK = (byte) 0x80;
private final CardChannel apduChannel;
private SecureChannel secureChannel;
private SCP02Keys cardKeys;
private Session session;
private final byte[] testKey = Hex.decode("404142434445464748494a4b4c4d4e4f");
/**
* Constructs a new command set with the given CardChannel.
*
* @param apduChannel the channel to the card
*/
public GlobalPlatformCommandSet(CardChannel apduChannel) {
this.apduChannel = apduChannel;
this.cardKeys = new SCP02Keys(testKey, testKey);
}
/**
* Selects the ISD of the card.
*
* @return the card response
*
* @throws IOException communication error
*/
public APDUResponse select() throws IOException {
APDUCommand cmd = new APDUCommand(0x00, INS_SELECT, SELECT_P1_BY_NAME, 0, new byte[0]);
return apduChannel.send(cmd);
}
/**
* Sends an INITIALIZE UPDATE command. Use the openSecureChannel method instead of calling this directly, unless you
* need to use a specific host challenge.
*
* @param hostChallenge the host challenge.
* @return the card response
*
* @throws IOException communication error
*/
public APDUResponse initializeUpdate(byte[] hostChallenge) throws IOException, APDUException {
APDUCommand cmd = new APDUCommand(0x80, INS_INITIALIZE_UPDATE, 0, 0, hostChallenge, true);
APDUResponse resp = apduChannel.send(cmd);
if (resp.isOK()) {
this.session = SecureChannel.verifyChallenge(hostChallenge, this.cardKeys, resp);
this.secureChannel = new SecureChannel(this.apduChannel, this.session.getKeys());
}
return resp;
}
/**
* Sends an EXTERNAL AUTHENTICATE command. Use the openSecureChannel method instead of calling this directly, unless you
* need to use a specific host challenge.
*
* @param hostChallenge the host challenge.
* @return the card response
*
* @throws IOException communication error
*/
public APDUResponse externalAuthenticate(byte[] hostChallenge) throws IOException {
byte[] cardChallenge = this.session.getCardChallenge();
byte[] data = new byte[cardChallenge.length + hostChallenge.length];
System.arraycopy(cardChallenge, 0, data, 0, cardChallenge.length);
System.arraycopy(hostChallenge, 0, data, cardChallenge.length, hostChallenge.length);
byte[] paddedData = Crypto.appendDESPadding(data);
byte[] hostCryptogram = Crypto.mac3des(this.session.getKeys().encKeyData, paddedData, Crypto.NullBytes8);
APDUCommand cmd = new APDUCommand(0x84, INS_EXTERNAL_AUTHENTICATE, EXTERNAL_AUTHENTICATE_P1, 0, hostCryptogram);
return this.secureChannel.send(cmd);
}
/**
* Opens an SCP02 secure channel with default keys.
*
* @throws APDUException the card didn't respond 0x9000 to either INITIALIZE UPDATE or EXTERNAL AUTHENTICATE
* @throws IOException communication error
*/
public void openSecureChannel() throws APDUException, IOException {
SecureRandom random = new SecureRandom();
byte[] hostChallenge = new byte[8];
random.nextBytes(hostChallenge);
initializeUpdate(hostChallenge).checkOK();
externalAuthenticate(hostChallenge).checkOK();
}
/**
* Deletes the Keycard applet instance.
*
* @return the card response
* @throws IOException communication error
*/
public APDUResponse deleteKeycardInstance() throws IOException {
return delete(Identifiers.getKeycardInstanceAID());
}
/**
* Deletes the NDEF applet instance.
*
* @return the card response
* @throws IOException communication error
*/
public APDUResponse deleteNDEFInstance() throws IOException {
return delete(Identifiers.NDEF_INSTANCE_AID);
}
/**
* Deletes the Keycard package.
*
* @return the card response
* @throws IOException communication error
*/
public APDUResponse deleteKeycardPackage() throws IOException {
return delete(Identifiers.PACKAGE_AID);
}
/**
* Deletes the Keycard package and all applets installed from it. This is the method to use to remove a Keycard
* installation.
*
* @throws APDUException one of the DELETE commands failed
* @throws IOException communication error
*/
public void deleteKeycardInstancesAndPackage() throws IOException, APDUException {
deleteNDEFInstance().checkSW(APDUResponse.SW_OK, APDUResponse.SW_REFERENCED_DATA_NOT_FOUND);
deleteKeycardInstance().checkSW(APDUResponse.SW_OK, APDUResponse.SW_REFERENCED_DATA_NOT_FOUND);
deleteKeycardPackage().checkSW(APDUResponse.SW_OK, APDUResponse.SW_REFERENCED_DATA_NOT_FOUND);
}
/**
* Sends a DELETE APDU with the given AID
* @param aid the AID to the delete
* @return the raw card response
*
* @throws IOException communication error.
*/
public APDUResponse delete(byte[] aid) throws IOException {
byte[] data = new byte[aid.length + 2];
data[0] = 0x4F;
data[1] = (byte) aid.length;
System.arraycopy(aid, 0, data, 2, aid.length);
APDUCommand cmd = new APDUCommand(0x80, INS_DELETE, 0, 0, data);
return this.secureChannel.send(cmd);
}
/**
* Loads the Keycard package.
*
* @param in the CAP file as an InputStream
* @param cb the progress callback
*
* @throws IOException communication error
* @throws APDUException one of the INSTALL [for Load] or LOAD commands failed
*/
public void loadKeycardPackage(InputStream in, LoadCallback cb) throws IOException, APDUException {
installForLoad(Identifiers.PACKAGE_AID).checkOK();
Load load = new Load(in);
byte[] block;
int steps = load.blocksCount();
while((block = load.nextDataBlock()) != null) {
load(block, (load.getCount() - 1), load.hasMore()).checkOK();
cb.blockLoaded(load.getCount(), steps);
}
}
/**
* Sends an INSTALL [for LOAD] APDU. Use only if loading something other than the Keycard package.
*
* @param aid the AID
*
* @return the card response
* @throws IOException communication error
*/
public APDUResponse installForLoad(byte[] aid) throws IOException {
return installForLoad(aid, new byte[0]);
}
/**
* Sends an INSTALL [for LOAD] APDU with package extradition. Use only if loading something other than the Keycard package.
*
* @param aid the AID
* @param sdaid the AID of the SD target of the extradition
*
* @return the card response
* @throws IOException communication error
*/
public APDUResponse installForLoad(byte[] aid, byte[] sdaid) throws IOException {
ByteArrayOutputStream data = new ByteArrayOutputStream();
data.write(aid.length);
data.write(aid);
data.write(sdaid.length);
data.write(sdaid);
// empty hash length and hash
data.write(0x00);
data.write(0x00);
data.write(0x00);
APDUCommand cmd = new APDUCommand(0x80, INS_INSTALL, INSTALL_FOR_LOAD_P1, 0, data.toByteArray());
return this.secureChannel.send(cmd);
}
/**
* Sends a single LOAD APDU. Use only if loading something other than the Keycard package.
*
* @param data the data of the block
* @param count the block number
* @param hasMoreBlocks whether there are more blocks coming or not
* @return the card response
* @throws IOException communication error
*/
public APDUResponse load(byte[] data, int count, boolean hasMoreBlocks) throws IOException {
int p1 = hasMoreBlocks ? LOAD_P1_MORE_BLOCKS : LOAD_P1_LAST_BLOCK;
APDUCommand cmd = new APDUCommand(0x80, INS_LOAD, p1, count, data);
return this.secureChannel.send(cmd);
}
/**
* Sends an INSTALL [for Install & Make Selectable] command. Use only if not installing applets part of the Keycard
* package
*
* @param packageAID the package AID
* @param appletAID the applet AID
* @param instanceAID the instance AID
* @param params the installation parameters
* @return the card response
* @throws IOException communication error
*/
public APDUResponse installForInstall(byte[] packageAID, byte[] appletAID, byte[] instanceAID, byte[] params) throws IOException {
ByteArrayOutputStream data = new ByteArrayOutputStream();
data.write(packageAID.length);
data.write(packageAID);
data.write(appletAID.length);
data.write(appletAID);
data.write(instanceAID.length);
data.write(instanceAID);
byte[] privileges = new byte[]{0x00};
data.write(privileges.length);
data.write(privileges);
byte[] fullParams = new byte[2 + params.length];
fullParams[0] = (byte) 0xC9;
fullParams[1] = (byte) params.length;
System.arraycopy(params, 0, fullParams, 2, params.length);
data.write(fullParams.length);
data.write(fullParams);
// empty perform token
data.write(0x00);
APDUCommand cmd = new APDUCommand(0x80, INS_INSTALL, INSTALL_FOR_INSTALL_P1, 0, data.toByteArray());
return this.secureChannel.send(cmd);
}
/**
* Installs the NDEF applet from the Keycard package.
*
* @param ndefRecord the initial NDEF record. Can be a zero-length array but not null
* @return the card response
* @throws IOException communication error
*/
public APDUResponse installNDEFApplet(byte[] ndefRecord) throws IOException {
return installForInstall(Identifiers.PACKAGE_AID, Identifiers.NDEF_AID, Identifiers.NDEF_INSTANCE_AID, ndefRecord);
}
/**
* Installs the Keycard applet.
*
* @return the card response
* @throws IOException communication error.
*/
public APDUResponse installKeycardApplet() throws IOException {
return installForInstall(Identifiers.PACKAGE_AID, Identifiers.KEYCARD_AID, Identifiers.getKeycardInstanceAID(), new byte[0]);
}
}
@@ -0,0 +1,189 @@
package im.status.keycard.globalplatform;
import java.io.ByteArrayOutputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/**
* A loadable CAP file.
*/
public class Load {
static final byte CLA = (byte) 0x80;
static final byte INS = (byte) 0xE8;
static final int BLOCK_SIZE = 247; // 255 - 8 bytes for MAC
private static String[] fileNames = {"Header", "Directory", "Import", "Applet",
"Class", "Method", "StaticField", "Export", "ConstantPool", "RefLocation"};
private int offset;
private int count;
private byte[] fullData;
/**
* Reads a CAP file from the given input stream.
*
* @param in the inpu stream
* @throws FileNotFoundException
* @throws IOException
*/
public Load(InputStream in) throws FileNotFoundException, IOException {
this.offset = 0;
this.count = 0;
Map<String, byte[]> files = this.loadFiles(in);
in.close();
this.fullData = this.getCode(files);
}
/**
* Reads the components of the CAP file
* @param in the input stream
* @return the map of component name and values
*
* @throws IOException IO error
*/
private Map<String, byte[]> loadFiles(InputStream in) throws IOException {
Map<String, byte[]> files = new LinkedHashMap<>();
ZipInputStream zip = new ZipInputStream(in);
ZipEntry entry = zip.getNextEntry();
while (entry != null) {
ByteArrayOutputStream data = new ByteArrayOutputStream();
byte[] buf = new byte[1024];
int count;
while ((count = zip.read(buf)) != -1) {
data.write(buf, 0, count);
}
String name = baseName(entry.getName());
files.put(name, data.toByteArray());
entry = zip.getNextEntry();
}
return files;
}
/**
* The basename of the zip entry
* @param path the path
* @return the base name
*/
private String baseName(String path) {
String[] parts = path.split("[/.]");
return parts[parts.length - 2];
}
/**
* Counts the number of blocks needed to load the entire file. Keeps in account the overhead of SCP02 secure channel
*
* @return the block count
*/
public int blocksCount() {
return (int) Math.ceil(this.fullData.length / (float) BLOCK_SIZE);
}
/**
* Returns the next data block
*
* @return the data block
*/
public byte[] nextDataBlock() {
if (this.offset >= this.fullData.length) {
return null;
}
int rangeEnd = this.offset + BLOCK_SIZE;
if (rangeEnd >= this.fullData.length) {
rangeEnd = this.fullData.length;
}
int size = rangeEnd - offset;
byte[] data = new byte[size];
System.arraycopy(this.fullData, this.offset, data, 0, size);
this.count++;
this.offset += size;
return data;
}
/**
* True if more blocks are present, false otherwise.
*
* @return true if more blocks are present, false otherwise.
*/
public boolean hasMore() {
return this.offset < this.fullData.length;
}
/**
* Encodes the length of the load TLV component
*
* @param length the length as integer
* @return the length encoded as for BER-TLV
*/
private byte[] encodeFullLength(int length) {
if (length < 0x80) {
return new byte[]{(byte) length};
} else if (length < 0xFF) {
return new byte[]{(byte) 0x81, (byte) length};
} else if (length < 0xFFFF) {
return new byte[]{
(byte) 0x82,
(byte) ((length & 0xFF00) >> 8),
(byte) (length & 0xFF),
};
} else {
return new byte[]{
(byte) 0x83,
(byte) ((length & 0xFF0000) >> 16),
(byte) ((length & 0xFF00) >> 8),
(byte) (length & 0xFF),
};
}
}
/**
* Serializes the CAP section in a single block.
*
* @param files the components to serialize
* @return the serialized load file
*
*/
private byte[] getCode(Map<String, byte[]> files) throws IOException {
ByteArrayOutputStream dataStream = new ByteArrayOutputStream();
for (String name : fileNames) {
byte[] fileData = files.get(name);
if (fileData == null) {
continue;
}
dataStream.write(fileData);
}
byte[] data = dataStream.toByteArray();
byte[] encodedFullLength = encodeFullLength(data.length);
byte[] fullData = new byte[1 + encodedFullLength.length + data.length];
fullData[0] = (byte) 0xC4;
System.arraycopy(encodedFullLength, 0, fullData, 1, encodedFullLength.length);
System.arraycopy(data, 0, fullData, 1 + encodedFullLength.length, data.length);
return fullData;
}
/**
* Returns the current block number
*
* @return the current block number
*/
public int getCount() {
return count;
}
}
@@ -0,0 +1,14 @@
package im.status.keycard.globalplatform;
/**
* Callback interface using during package loading process.
*/
public interface LoadCallback {
/**
* Called when a block is loaded.
*
* @param loadedBlock The number of the loaded block (1 based)
* @param blockCount the total number of blocks.
*/
void blockLoaded(int loadedBlock, int blockCount);
}
@@ -0,0 +1,37 @@
package im.status.keycard.globalplatform;
/**
* Keeps keys for SCP02.
*/
public class SCP02Keys {
public byte[] encKeyData;
public byte[] macKeyData;
/**
* Constructor. Takes the ENC and MAC keys.
*
* @param encKeyData encryption key
* @param macKeyData mac key
*/
public SCP02Keys(byte[] encKeyData, byte[] macKeyData) {
this.encKeyData = encKeyData;
this.macKeyData = macKeyData;
}
/**
* The encryption key
* @return the encryption key
*/
public byte[] getEncKeyData() {
return encKeyData;
}
/**
* The MAC key
*
* @return the MAC key
*/
public byte[] getMacKeyData() {
return macKeyData;
}
}
@@ -0,0 +1,73 @@
package im.status.keycard.globalplatform;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Arrays;
import im.status.keycard.io.APDUCommand;
/**
* Adds a SCP02 MAC to APDUs.
*/
public class SCP02Wrapper {
private byte[] macKeyData;
private byte[] icv;
/**
* Constructs a new SCP02Wrapper.
*
* @param macKeyData the MAC key
*/
public SCP02Wrapper(byte[] macKeyData) {
this.macKeyData = macKeyData;
this.icv = Crypto.NullBytes8.clone();
}
/**
* Wraps an APDU with SCP02 MAC
* @param cmd the APDU to wrap
* @return the wrapped APDU
*/
public APDUCommand wrap(APDUCommand cmd) {
try {
int cla = (cmd.getCla() | 0x04) & 0xff;
byte[] data = cmd.getData();
ByteArrayOutputStream macData = new ByteArrayOutputStream();
macData.write(cla);
macData.write(cmd.getIns());
macData.write(cmd.getP1());
macData.write(cmd.getP2());
macData.write(data.length + 8);
macData.write(data);
byte[] icv;
if (Arrays.equals(this.icv, Crypto.NullBytes8)) {
icv = this.icv;
} else {
icv = Crypto.encryptICV(this.macKeyData, this.icv);
}
byte[] mac = Crypto.macFull3des(this.macKeyData, Crypto.appendDESPadding(macData.toByteArray()), icv);
byte[] newData = new byte[data.length + mac.length];
System.arraycopy(data, 0, newData, 0, data.length );
System.arraycopy(mac, 0, newData, data.length, mac.length );
APDUCommand wrapped = new APDUCommand(cla, cmd.getIns(), cmd.getP1(), cmd.getP2(), newData, cmd.getNeedsLE());
this.icv = mac.clone();
return wrapped;
} catch (IOException e) {
throw new RuntimeException("error wrapping APDU command.", e);
}
}
/**
* Returns the ICV
* @return the ICV
*/
public byte[] getICV() {
return this.icv;
}
}
@@ -0,0 +1,90 @@
package im.status.keycard.globalplatform;
import java.io.IOException;
import im.status.keycard.io.APDUCommand;
import im.status.keycard.io.APDUException;
import im.status.keycard.io.APDUResponse;
import im.status.keycard.io.CardChannel;
/**
* An SCP02 Secure Channel. Wraps a CardChannel to allow transparent handling of the scure channel.
*/
public class SecureChannel {
private CardChannel channel;
private SCP02Wrapper wrapper;
public static byte[] DERIVATION_PURPOSE_ENC = new byte[]{(byte) 0x01, (byte) 0x82};
public static byte[] DERIVATION_PURPOSE_MAC = new byte[]{(byte) 0x01, (byte) 0x01};
public static byte[] DERIVATION_PURPOSE_DEK = new byte[]{(byte) 0x01, (byte) 0x81};
/**
* Constructs an SCP02 secure channel, wrapping a regular CardChannel.
*
* @param channel the channel to wrap
* @param keys the keys
*/
public SecureChannel(CardChannel channel, SCP02Keys keys) {
this.channel = channel;
this.wrapper = new SCP02Wrapper(keys.getMacKeyData());
}
/**
* Protects the given command with SCP02 and forwards it to the underlying CardChannel.
*
* @param cmd the command to send
* @return the response from the card
*
* @throws IOException communication error
*/
public APDUResponse send(APDUCommand cmd) throws IOException {
APDUCommand wrappedCommand = this.wrapper.wrap(cmd);
return this.channel.send(wrappedCommand);
}
/**
* Verifies the card challenge and builds an SCP02 session object.
*
* @param hostChallenge the host challenge
* @param cardKeys the SCP02 keys
* @param resp the response from the card to the INITIALIZE UPDATE oommand
* @return
* @throws APDUException
*/
public static Session verifyChallenge(byte[] hostChallenge, SCP02Keys cardKeys, APDUResponse resp) throws APDUException {
if (resp.getSw() == APDUResponse.SW_SECURITY_CONDITION_NOT_SATISFIED) {
throw new APDUException(resp.getSw(), "security condition not satisfied");
}
if (resp.getSw() == APDUResponse.SW_AUTHENTICATION_METHOD_BLOCKED) {
throw new APDUException(resp.getSw(), "authentication method blocked");
}
byte[] data = resp.getData();
if (data.length != 28) {
throw new APDUException(resp.getSw(), String.format("bad data length, expected 28, got %d", data.length));
}
byte[] cardChallenge = new byte[8];
System.arraycopy(data, 12, cardChallenge, 0, 8);
byte[] cardCryptogram = new byte[8];
System.arraycopy(data, 20, cardCryptogram, 0, 8);
byte[] seq = new byte[2];
System.arraycopy(data, 12, seq, 0, 2);
byte[] sessionEncKey = Crypto.deriveSCP02SessionKey(cardKeys.getEncKeyData(), seq, DERIVATION_PURPOSE_ENC);
byte[] sessionMacKey = Crypto.deriveSCP02SessionKey(cardKeys.getMacKeyData(), seq, DERIVATION_PURPOSE_MAC);
SCP02Keys sessionKeys = new SCP02Keys(sessionEncKey, sessionMacKey);
boolean verified = Crypto.verifyCryptogram(sessionKeys.getEncKeyData(), hostChallenge, cardChallenge, cardCryptogram);
if (!verified) {
throw new APDUException("error verifying card cryptogram.");
}
return new Session(sessionKeys, cardChallenge);
}
}
@@ -0,0 +1,36 @@
package im.status.keycard.globalplatform;
/**
* SCP02 Session.
*/
public class Session {
private SCP02Keys keys;
private byte[] cardChallenge;
/**
* Constructs the SCP02 session.
*
* @param keys the session keys
* @param cardChallenge the card challenge
*/
public Session(SCP02Keys keys, byte[] cardChallenge) {
this.keys = keys;
this.cardChallenge = cardChallenge;
}
/**
* The SCP02 keys
* @return SCP02 keys
*/
public SCP02Keys getKeys() {
return keys;
}
/**
* The card challenge
* @return card challenge
*/
public byte[] getCardChallenge() {
return cardChallenge;
}
}
@@ -0,0 +1,125 @@
package im.status.keycard.io;
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;
}
}
@@ -0,0 +1,29 @@
package im.status.keycard.io;
/**
* 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;
}
}
@@ -0,0 +1,132 @@
package im.status.keycard.io;
/**
* 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
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");
}
}
/**
* 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;
}
}
@@ -0,0 +1,23 @@
package im.status.keycard.io;
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();
}
@@ -0,0 +1,18 @@
package im.status.keycard.io;
/**
* Listener for card connection events.
*/
public interface CardListener {
/**
* Executes when the card channel is connected.
*
* @param channel the connected card channel
*/
void onConnected(CardChannel channel);
/**
* Executes when a previously connected card is disconnected.
*/
void onDisconnected();
}
@@ -0,0 +1,34 @@
package im.status.keycard.io;
import android.nfc.tech.IsoDep;
import android.util.Log;
import java.io.IOException;
/**
* Implementation of the CardChannel interface using the Android NFC API.
*/
public class NFCCardChannel implements CardChannel {
private static final String TAG = "CardChannel";
private IsoDep isoDep;
public NFCCardChannel(IsoDep isoDep) {
this.isoDep = isoDep;
}
@Override
public APDUResponse send(APDUCommand cmd) throws IOException {
byte[] apdu = cmd.serialize();
Log.d(TAG, String.format("COMMAND CLA: %02X INS: %02X P1: %02X P2: %02X LC: %02X", cmd.getCla(), cmd.getIns(), cmd.getP1(), cmd.getP2(), cmd.getData().length));
byte[] resp = this.isoDep.transceive(apdu);
APDUResponse response = new APDUResponse(resp);
Log.d(TAG, String.format("RESPONSE LEN: %02X, SW: %04X %n-----------------------", response.getData().length, response.getSw()));
return response;
}
@Override
public boolean isConnected() {
return this.isoDep.isConnected();
}
}
@@ -0,0 +1,116 @@
package im.status.keycard.io;
import android.nfc.NfcAdapter;
import android.nfc.Tag;
import android.nfc.tech.IsoDep;
import android.os.SystemClock;
import android.util.Log;
import java.io.IOException;
import java.security.Security;
/**
* Manages connection of NFC-based cards. Extends Thread and must be started using the start() method. The thread has
* a runloop which monitors the connection and from which CardListener callbacks are called.
*/
public class NFCCardManager extends Thread implements NfcAdapter.ReaderCallback {
private static final String TAG = "NFCCardManager";
private static final int DEFAULT_LOOP_SLEEP_MS = 50;
private IsoDep isoDep;
private boolean isRunning;
private CardListener cardListener;
private int loopSleepMS;
/**
* Constructs an NFC Card Manager with default delay between loop iterations.
*/
public NFCCardManager() {
this(DEFAULT_LOOP_SLEEP_MS);
}
/**
* Constructs an NFC Card Manager with the given delay between loop iterations.
*
* @param loopSleepMS time to sleep between loops
*/
public NFCCardManager(int loopSleepMS) {
this.loopSleepMS = loopSleepMS;
Security.insertProviderAt(new org.spongycastle.jce.provider.BouncyCastleProvider(), 1);
}
/**
* True if connected, false otherwise.
* @return if connected, false otherwise
*/
public boolean isConnected() {
return isoDep != null && isoDep.isConnected();
}
@Override
public void onTagDiscovered(Tag tag) {
isoDep = IsoDep.get(tag);
try {
isoDep = IsoDep.get(tag);
isoDep.connect();
isoDep.setTimeout(120000);
} catch (IOException e) {
Log.e(TAG, "error connecting to tag");
}
}
/**
* Runloop. Do NOT invoke directly. Use start() instead.
*/
public void run() {
boolean connected = isConnected();
while (true) {
boolean newConnected = isConnected();
if (newConnected != connected) {
connected = newConnected;
Log.i(TAG, "tag " + (connected ? "connected" : "disconnected"));
if (connected && !isRunning) {
onCardConnected();
} else {
onCardDisconnected();
}
}
SystemClock.sleep(loopSleepMS);
}
}
/**
* Reacts on card connected by calling the callback of the registered listener.
*/
private void onCardConnected() {
isRunning = true;
if (cardListener != null) {
cardListener.onConnected(new NFCCardChannel(isoDep));
}
isRunning = false;
}
/**
* Reacts on card disconnected by calling the callback of the registered listener.
*/
private void onCardDisconnected() {
isRunning = false;
isoDep = null;
if (cardListener != null) {
cardListener.onDisconnected();
}
}
/**
* Sets the card listener.
*
* @param listener the new listener
*/
public void setCardListener(CardListener listener) {
cardListener = listener;
}
}