commit 60f1315263938ffa9083136f847b79edebac74ec Author: Michele Balistreri Date: Tue Oct 23 13:34:17 2018 +0200 initial import diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5edb4ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle +/local.properties +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +.DS_Store +/build +/captures +.externalNativeBuild diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..471fe28 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,30 @@ +apply plugin: 'com.android.application' + +android { + compileSdkVersion 28 + defaultConfig { + applicationId "im.status.hardwallet_lite_android" + minSdkVersion 19 + targetSdkVersion 28 + versionCode 1 + versionName "1.0" + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation 'com.android.support:appcompat-v7:28.0.0' + implementation 'com.android.support.constraint:constraint-layout:1.1.3' + implementation 'com.madgag.spongycastle:core:1.58.0.0' + implementation 'com.madgag.spongycastle:prov:1.58.0.0' + testImplementation 'junit:junit:4.12' + androidTestImplementation 'com.android.support.test:runner:1.0.2' + androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/app/src/androidTest/java/im/status/hardwallet_lite_android/ExampleInstrumentedTest.java b/app/src/androidTest/java/im/status/hardwallet_lite_android/ExampleInstrumentedTest.java new file mode 100644 index 0000000..dad4568 --- /dev/null +++ b/app/src/androidTest/java/im/status/hardwallet_lite_android/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package im.status.hardwallet_lite_android; + +import android.content.Context; +import android.support.test.InstrumentationRegistry; +import android.support.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getTargetContext(); + + assertEquals("im.status.hardwallet_lite_android", appContext.getPackageName()); + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6cedd88 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/im/status/hardwallet_lite_android/app/MainActivity.java b/app/src/main/java/im/status/hardwallet_lite_android/app/MainActivity.java new file mode 100644 index 0000000..27baabd --- /dev/null +++ b/app/src/main/java/im/status/hardwallet_lite_android/app/MainActivity.java @@ -0,0 +1,43 @@ +package im.status.hardwallet_lite_android.app; + +import android.nfc.NfcAdapter; +import android.support.v7.app.AppCompatActivity; +import android.os.Bundle; +import im.status.hardwallet_lite_android.R; +import im.status.hardwallet_lite_android.io.CardManager; + +import java.security.Security; + +public class MainActivity extends AppCompatActivity { + static { + Security.insertProviderAt(new org.spongycastle.jce.provider.BouncyCastleProvider(), 1); + } + + private NfcAdapter nfcAdapter; + private CardManager cardManager; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + nfcAdapter = NfcAdapter.getDefaultAdapter(this); + this.cardManager = new CardManager(); + this.cardManager.start(); + } + + @Override + public void onResume() { + super.onResume(); + if (nfcAdapter != null) { + nfcAdapter.enableReaderMode(this, this.cardManager, NfcAdapter.FLAG_READER_NFC_A | NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, null); + } + } + + @Override + public void onPause() { + super.onPause(); + if (nfcAdapter != null) { + nfcAdapter.disableReaderMode(this); + } + } +} diff --git a/app/src/main/java/im/status/hardwallet_lite_android/io/APDUCommand.java b/app/src/main/java/im/status/hardwallet_lite_android/io/APDUCommand.java new file mode 100644 index 0000000..05da8af --- /dev/null +++ b/app/src/main/java/im/status/hardwallet_lite_android/io/APDUCommand.java @@ -0,0 +1,67 @@ +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; + this.ins = ins; + this.p1 = p1; + this.p2 = p2; + 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; + } +} diff --git a/app/src/main/java/im/status/hardwallet_lite_android/io/APDUException.java b/app/src/main/java/im/status/hardwallet_lite_android/io/APDUException.java new file mode 100644 index 0000000..ad764b2 --- /dev/null +++ b/app/src/main/java/im/status/hardwallet_lite_android/io/APDUException.java @@ -0,0 +1,15 @@ +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; + } +} diff --git a/app/src/main/java/im/status/hardwallet_lite_android/io/APDUResponse.java b/app/src/main/java/im/status/hardwallet_lite_android/io/APDUResponse.java new file mode 100644 index 0000000..3938d4c --- /dev/null +++ b/app/src/main/java/im/status/hardwallet_lite_android/io/APDUResponse.java @@ -0,0 +1,67 @@ +package im.status.hardwallet_lite_android.io; + +public class APDUResponse { + public static int SW_OK = 0x9000; + public static int SW_SECURITY_CONDITION_NOT_SATISFIED = 0x6982; + public static int SW_AUTHENTICATION_METHOD_BLOCKED = 0x6983; + public static int SW_CARD_LOCKED = 0x6283; + public static int SW_REFERENCED_DATA_NOT_FOUND = 0x6A88; + public static 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 { + if (!isOK()) { + throw new APDUException(this.getSw(), "Unexpected error SW"); + } + + return this; + } + + 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; + } +} diff --git a/app/src/main/java/im/status/hardwallet_lite_android/io/CardChannel.java b/app/src/main/java/im/status/hardwallet_lite_android/io/CardChannel.java new file mode 100644 index 0000000..5528a53 --- /dev/null +++ b/app/src/main/java/im/status/hardwallet_lite_android/io/CardChannel.java @@ -0,0 +1,25 @@ +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 %s", Hex.toHexString(apdu))); + byte[] resp = this.isoDep.transceive(apdu); + Log.d(TAG, String.format("RESPONSE %s %n-----------------------", Hex.toHexString(resp))); + return new APDUResponse(resp); + } +} diff --git a/app/src/main/java/im/status/hardwallet_lite_android/io/CardManager.java b/app/src/main/java/im/status/hardwallet_lite_android/io/CardManager.java new file mode 100644 index 0000000..cd55e41 --- /dev/null +++ b/app/src/main/java/im/status/hardwallet_lite_android/io/CardManager.java @@ -0,0 +1,101 @@ +package im.status.hardwallet_lite_android.io; + +import android.nfc.NfcAdapter; +import android.nfc.Tag; +import android.nfc.tech.IsoDep; +import android.util.Log; +import im.status.hardwallet_lite_android.wallet.WalletAppletCommandSet; +import org.spongycastle.util.encoders.Hex; + +import java.io.IOException; + +public class CardManager extends Thread implements NfcAdapter.ReaderCallback { + private static final String TAG = "CardManager"; + + private IsoDep isoDep; + private boolean isRunning; + + public boolean isConnected() { + return this.isoDep != null && this.isoDep.isConnected(); + } + + @Override + public void onTagDiscovered(Tag tag) { + this.isoDep = IsoDep.get(tag); + + try { + this.isoDep = IsoDep.get(tag); + this.isoDep.connect(); + this.isoDep.setTimeout(120000); + } catch (IOException e) { + Log.e(TAG, "error connecting to tag"); + } + } + + public void run() { + boolean connected = this.isConnected(); + + while(true) { + boolean newConnected = this.isConnected(); + if (newConnected != connected) { + connected = newConnected; + Log.i(TAG, "tag " + (connected ? "connected" : "disconnected")); + + if (connected && !isRunning) { + this.onCardConnected(); + } else { + this.onCardDisconnected(); + } + } + + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Log.e(TAG, "error in TagManager thread: " + e.getMessage()); + this.interrupt(); + } + } + } + + private void onCardConnected() { + this.isRunning = true; + + try { + CardChannel cardChannel = new CardChannel(this.isoDep); + // Applet-specific code + WalletAppletCommandSet cmdSet = new WalletAppletCommandSet(cardChannel); + + // First thing to do is selecting the applet on the card. + cmdSet.select().checkOK(); + + // In real projects, the pairing key should be saved and used for all new sessions. + cmdSet.autoPair("WalletAppletTest"); + + // Opening a Secure Channel is needed for all other applet commands + cmdSet.autoOpenSecureChannel(); + + // We send a GET STATUS command, which does not require PIN authentication + APDUResponse resp = cmdSet.getStatus(WalletAppletCommandSet.GET_STATUS_P1_APPLICATION).checkOK(); + + // PIN authentication allows execution of privileged commands + cmdSet.verifyPIN("000000").checkOK(); + + // Cleanup, in a real application you would not unpair and instead keep the pairing key for successive interactions. + // We also remove all other pairings so that we do not fill all slots with failing runs. Again in real application + // this would be a very bad idea to do. + cmdSet.unpairOthers(); + cmdSet.autoUnpair(); + + Log.i(TAG, "GET STATUS response: " + Hex.toHexString(resp.getData())); + } catch (Exception e) { + Log.e(TAG, e.getMessage()); + } + + this.isRunning = false; + } + + private void onCardDisconnected() { + this.isRunning = false; + this.isoDep = null; + } +} diff --git a/app/src/main/java/im/status/hardwallet_lite_android/wallet/SecureChannelSession.java b/app/src/main/java/im/status/hardwallet_lite_android/wallet/SecureChannelSession.java new file mode 100644 index 0000000..bcfe76e --- /dev/null +++ b/app/src/main/java/im/status/hardwallet_lite_android/wallet/SecureChannelSession.java @@ -0,0 +1,476 @@ +package im.status.hardwallet_lite_android.wallet; + +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 org.spongycastle.crypto.engines.AESEngine; +import org.spongycastle.crypto.macs.CBCBlockCipherMac; +import org.spongycastle.crypto.params.KeyParameter; +import org.spongycastle.jce.ECNamedCurveTable; +import org.spongycastle.jce.interfaces.ECPublicKey; +import org.spongycastle.jce.spec.ECParameterSpec; +import org.spongycastle.jce.spec.ECPublicKeySpec; + +import javax.crypto.Cipher; +import javax.crypto.KeyAgreement; +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[] pairingKey; + private byte[] iv; + private byte pairingIndex; + private Cipher sessionCipher; + private CBCBlockCipherMac sessionMac; + private SecretKeySpec sessionEncKey; + private KeyParameter sessionMacKey; + private SecureRandom random; + private boolean open; + + /** + * Constructs a SecureChannel session on the client. The client should generate a fresh key pair for 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 SecureChannelSession(byte[] keyData) { + random = new SecureRandom(); + generateSecret(keyData); + open = false; + } + + public void generateSecret(byte[] keyData) { + try { + random = new SecureRandom(); + ECParameterSpec ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1"); + KeyPairGenerator g = KeyPairGenerator.getInstance("ECDH"); + g.initialize(ecSpec, random); + + KeyPair keyPair = g.generateKeyPair(); + + publicKey = ((ECPublicKey) keyPair.getPublic()).getQ().getEncoded(false); + KeyAgreement keyAgreement = KeyAgreement.getInstance("ECDH"); + keyAgreement.init(keyPair.getPrivate()); + + ECPublicKeySpec cardKeySpec = new ECPublicKeySpec(ecSpec.getCurve().decodePoint(keyData), ecSpec); + ECPublicKey cardKey = (ECPublicKey) KeyFactory.getInstance("ECDSA").generatePublic(cardKeySpec); + + keyAgreement.doPhase(cardKey, true); + secret = keyAgreement.generateSecret(); + } catch (Exception e) { + throw new RuntimeException("Is BouncyCastle in the classpath?", e); + } + } + + /** + * Returns the public key + * @return the public key + */ + public byte[] getPublicKey() { + return publicKey; + } + + /** + * Returns the pairing index + * @return the pairing index + */ + public byte getPairingIndex() { + return pairingIndex; + } + + /** + * 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 + * @return the card response + * @throws IOException communication error + */ + public void autoOpenSecureChannel(CardChannel apduChannel) throws IOException { + APDUResponse response = openSecureChannel(apduChannel, pairingIndex, publicKey); + + if (response.getSw() != 0x9000) { + throw new IOException("OPEN SECURE CHANNEL failed"); + } + + processOpenSecureChannelResponse(response); + + response = mutuallyAuthenticate(apduChannel); + + if (response.getSw() != 0x9000) { + throw new IOException("MUTUALLY AUTHENTICATE failed"); + } + + if(!verifyMutuallyAuthenticateResponse(response)) { + throw new IOException("Invalid authentication data from the card"); + } + } + + /** + * 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(pairingKey); + 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 KeyParameter(keyData, SC_SECRET_LENGTH, SC_SECRET_LENGTH); + sessionCipher = Cipher.getInstance("AES/CBC/ISO7816-4Padding"); + sessionMac = new CBCBlockCipherMac(new AESEngine(), 128, null); + open = true; + } catch(Exception e) { + throw new RuntimeException("Is BouncyCastle in the classpath?", e); + } + } + + /** + * Verify that the response from MUTUALLY AUTHENTICATE is correct. + * + * @param response the card response + * @return true if response is correct, false otherwise + */ + public boolean verifyMutuallyAuthenticateResponse(APDUResponse response) { + return response.getData().length == SC_SECRET_LENGTH; + } + + /** + * 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 { + byte[] challenge = new byte[32]; + random.nextBytes(challenge); + APDUResponse resp = pair(apduChannel, PAIR_P1_FIRST_STEP, challenge); + + if (resp.getSw() != 0x9000) { + throw new IOException("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("Is BouncyCastle in the classpath?", e); + } + + md.update(sharedSecret); + checkCryptogram = md.digest(challenge); + + if (!Arrays.equals(checkCryptogram, cardCryptogram)) { + throw new IOException("Invalid card cryptogram"); + } + + md.update(sharedSecret); + checkCryptogram = md.digest(cardChallenge); + + resp = pair(apduChannel, PAIR_P1_LAST_STEP, checkCryptogram); + + if (resp.getSw() != 0x9000) { + throw new IOException("Pairing failed on step 2"); + } + + respData = resp.getData(); + md.update(sharedSecret); + pairingKey = md.digest(Arrays.copyOfRange(respData, 1, respData.length)); + pairingIndex = respData[0]; + } + + /** + * Unpairs the current paired key + * + * @param apduChannel the apdu channel + * @throws IOException communication error + */ + public void autoUnpair(CardChannel apduChannel) throws IOException { + APDUResponse resp = unpair(apduChannel, pairingIndex); + + if (resp.getSw() != 0x9000) { + throw new IOException("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 openSecureChannel = new APDUCommand(0x80, INS_PAIR, p1, 0, data); + return transmit(apduChannel, openSecureChannel); + } + + /** + * 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 openSecureChannel = protectedCommand(0x80, INS_UNPAIR, p1, 0, new byte[0]); + return transmit(apduChannel, openSecureChannel); + } + + /** + * Unpair all other clients + * + * @param apduChannel the apdu channel + * @return the raw card response + * @throws IOException communication error + */ + public void unpairOthers(CardChannel apduChannel) throws IOException, APDUException { + for (int i = 0; i < PAIRING_MAX_CLIENT_COUNT; i++) { + if (i != pairingIndex) { + APDUCommand openSecureChannel = protectedCommand(0x80, INS_UNPAIR, i, 0, new byte[0]); + transmit(apduChannel, openSecureChannel).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("Is BouncyCastle in the classpath?", 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("Is BouncyCastle in the classpath?", 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"); + 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("Is BouncyCastle in the classpath?", 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. + * + */ + 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 { + sessionMac.init(sessionMacKey); + sessionMac.update(meta, 0, meta.length); + sessionMac.update(data, 0, data.length); + sessionMac.doFinal(iv, 0); + } catch (Exception e) { + throw new RuntimeException("Is BouncyCastle in the classpath?", e); + } + } +} diff --git a/app/src/main/java/im/status/hardwallet_lite_android/wallet/WalletAppletCommandSet.java b/app/src/main/java/im/status/hardwallet_lite_android/wallet/WalletAppletCommandSet.java new file mode 100644 index 0000000..698beab --- /dev/null +++ b/app/src/main/java/im/status/hardwallet_lite_android/wallet/WalletAppletCommandSet.java @@ -0,0 +1,542 @@ +package im.status.hardwallet_lite_android.wallet; + +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 org.spongycastle.jce.interfaces.ECPrivateKey; +import org.spongycastle.jce.interfaces.ECPublicKey; +import org.spongycastle.util.encoders.Hex; + +import javax.crypto.SecretKey; +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; + +/** + * 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 WalletAppletCommandSet { + static final byte INS_INIT = (byte) 0xFE; + static final byte INS_GET_STATUS = (byte) 0xF2; + 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_SIGN = (byte) 0xC0; + static final byte INS_SET_PINLESS_PATH = (byte) 0xC1; + static final byte INS_EXPORT_KEY = (byte) 0xC2; + + public static final byte GET_STATUS_P1_APPLICATION = 0x00; + + static final byte LOAD_KEY_P1_EC = 0x01; + static final byte LOAD_KEY_P1_EXT_EC = 0x02; + static final byte LOAD_KEY_P1_SEED = 0x03; + + static final byte DERIVE_P1_ASSISTED_MASK = 0x01; + static final byte DERIVE_P1_SOURCE_MASTER = (byte) 0x00; + + static final byte DERIVE_P2_KEY_PATH = 0x00; + static final byte DERIVE_P2_PUBLIC_KEY = 0x01; + + static final byte EXPORT_KEY_P2_PRIVATE_AND_PUBLIC = 0x00; + static final byte EXPORT_KEY_P2_PUBLIC_ONLY = 0x01; + + static final byte TLV_PUB_KEY = (byte) 0x80; + static final byte TLV_PRIV_KEY = (byte) 0x81; + static final byte TLV_CHAIN_CODE = (byte) 0x82; + static final byte TLV_APPLICATION_INFO_TEMPLATE = (byte) 0xA4; + + public static final String APPLET_AID = "53746174757357616C6C6574417070"; + public static final byte[] APPLET_AID_BYTES = Hex.decode(APPLET_AID); + + private final CardChannel apduChannel; + private SecureChannelSession secureChannel; + + public WalletAppletCommandSet(CardChannel apduChannel) { + this.apduChannel = apduChannel; + } + + public void setSecureChannel(SecureChannelSession secureChannel) { + this.secureChannel = secureChannel; + } + + /** + * Selects the 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 + */ + /** + * Selects the 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 { + APDUCommand selectApplet = new APDUCommand(0x00, 0xA4, 4, 0, APPLET_AID_BYTES); + APDUResponse resp = apduChannel.send(selectApplet); + + if (resp.getSw() == 0x9000) { + byte[] keyData = extractPublicKeyFromSelect(resp.getData()); + this.secureChannel = new SecureChannelSession(keyData); + } + + return resp; + } + + /** + * Opens the secure channel. Calls the corresponding method of the SecureChannel class. + * + * @return the raw card response + * @throws IOException communication error + */ + public void autoOpenSecureChannel() throws IOException { + secureChannel.autoOpenSecureChannel(apduChannel); + } + + /** + * Automatically pairs. Derives the secret from the given password. + * + * @throws IOException communication error + */ + public void autoPair(String pairingPassword) throws IOException { + SecretKey key; + + try { + SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + PBEKeySpec spec = new PBEKeySpec(pairingPassword.toCharArray(), "Status Hardware Wallet Lite".getBytes(), 50000, 32 * 8); + key = skf.generateSecret(spec); + } catch (Exception e) { + throw new RuntimeException("Is Bouncycastle correctly initialized?"); + } + + secureChannel.autoPair(apduChannel, key.getEncoded()); + } + + /** + * Automatically pairs. Calls the corresponding method of the SecureChannel class. + * + * @throws IOException communication error + */ + public void autoPair(byte[] sharedSecret) throws IOException { + secureChannel.autoPair(apduChannel, sharedSecret); + } + + /** + * Automatically unpairs. Calls the corresponding method of the SecureChannel class. + * + * @throws IOException communication error + */ + public void autoUnpair() throws IOException { + 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. + */ + public void unpairOthers() throws IOException, APDUException { + secureChannel.unpairOthers(apduChannel); + } + + /** + * 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 GET STATUS APDU to retrieve the APPLICATION STATUS template and reads the byte indicating public key + * derivation support. + * + * @return whether public key derivation is supported or not + * @throws IOException communication error + */ + public boolean getPublicKeyDerivationSupport() throws IOException { + APDUResponse resp = getStatus(GET_STATUS_P1_APPLICATION); + byte[] data = resp.getData(); + return data[data.length - 1] != 0x00; + } + + /** + * Sends a GET STATUS APDU to retrieve the APPLICATION STATUS template and reads the byte indicating key initialization + * status + * + * @return whether public key derivation is supported or not + * @throws IOException communication error + */ + public boolean getKeyInitializationStatus() throws IOException { + APDUResponse resp = getStatus(GET_STATUS_P1_APPLICATION); + byte[] data = resp.getData(); + return data[data.length - 4] != 0x00; + } + + /** + * 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. 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 private key and chain code are formatted as a raw binary seed 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 aPrivate a private key + * @param chainCode the chain code + * @return the raw card response + * @throws IOException communication error + */ + public APDUResponse loadKey(PrivateKey aPrivate, byte[] chainCode) throws IOException { + byte[] privateKey = ((ECPrivateKey) aPrivate).getD().toByteArray(); + + int privLen = privateKey.length; + int privOff = 0; + + if(privateKey[0] == 0x00) { + privOff++; + privLen--; + } + + byte[] data = new byte[chainCode.length + privLen]; + System.arraycopy(privateKey, privOff, data, 0, privLen); + System.arraycopy(chainCode, 0, data, privLen, chainCode.length); + + return loadKey(data, LOAD_KEY_P1_SEED); + } + + /** + * Sends a LOAD KEY APDU. The key is sent in TLV format, includes the public key and no chain code, meaning that + * the card will not be able to do further key derivation. + * + * @param ecKeyPair a key pair + * @return the raw card response + * @throws IOException communication error + */ + public APDUResponse loadKey(KeyPair ecKeyPair) throws IOException { + return loadKey(ecKeyPair, false, null); + } + + /** + * Sends a LOAD KEY APDU. The key is sent in TLV format. The public key is included or not depending on the value + * of the omitPublicKey parameter. The chain code is included if the chainCode is 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 keyPair a key pair + * @param omitPublicKey whether the public key is sent or not + * @param chainCode the chain code + * @return the raw card response + * @throws IOException communication error + */ + public APDUResponse loadKey(KeyPair keyPair, boolean omitPublicKey, byte[] chainCode) throws IOException { + byte[] publicKey = omitPublicKey ? null : ((ECPublicKey) keyPair.getPublic()).getQ().getEncoded(false); + byte[] privateKey = ((ECPrivateKey) keyPair.getPrivate()).getD().toByteArray(); + + return loadKey(publicKey, privateKey, chainCode); + } + + /** + * 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 { + int privLen = privateKey.length; + int privOff = 0; + + if(privateKey[0] == 0x00) { + privOff++; + privLen--; + } + + int off = 0; + int totalLength = publicKey == null ? 0 : (publicKey.length + 2); + totalLength += (privLen + 2); + totalLength += chainCode == null ? 0 : (chainCode.length + 2); + + if (totalLength > 127) { + totalLength += 3; + } else { + totalLength += 2; + } + + byte[] data = new byte[totalLength]; + data[off++] = (byte) 0xA1; + + if (totalLength > 127) { + data[off++] = (byte) 0x81; + data[off++] = (byte) (totalLength - 3); + } else { + data[off++] = (byte) (totalLength - 2); + } + + if (publicKey != null) { + 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; + + byte p1; + + if (chainCode != null) { + p1 = LOAD_KEY_P1_EXT_EC; + data[off++] = (byte) TLV_CHAIN_CODE; + data[off++] = (byte) chainCode.length; + System.arraycopy(chainCode, 0, data, off, chainCode.length); + } else { + p1 = LOAD_KEY_P1_EC; + } + + return loadKey(data, 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 SIGN APDU. The dataType is P1 as defined in the applet. The isFirst and isLast arguments are used to form + * the P2 parameter. The data is the data to sign, or part of it. Only when sending the last block a signature is + * generated and thus returned. When signing a precomputed hash it must be done in a single block, so isFirst and + * isLast will always be true at the same time. + * + * @param data the data to sign + * @param dataType the P1 parameter + * @param isFirst whether this is the first block of the command or not + * @param isLast whether this is the last block of the command or not + * @return the raw card response + * @throws IOException communication error + */ + public APDUResponse sign(byte[] data, byte dataType, boolean isFirst, boolean isLast) throws IOException { + byte p2 = (byte) ((isFirst ? 0x01 : 0x00) | (isLast ? 0x80 : 0x00)); + APDUCommand sign = secureChannel.protectedCommand(0x80, INS_SIGN, dataType, p2, data); + return secureChannel.transmit(apduChannel, sign); + } + + /** + * Sends a DERIVE KEY APDU. The data is encrypted and sent as-is. The P1 and P2 parameters are forced to 0, meaning + * that the derivation starts from the master key and is non-assisted. + * + * @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, false, false); + } + + /** + * Sends a DERIVE KEY APDU. The data is encrypted and sent as-is. The reset and assisted parameters are combined to + * form P1. The isPublicKey parameter is used for P2. + * + * @param data the raw key path or a public key + * @param source the source to start derivation + * @param assisted whether we are doing assisted derivation or not + * @param isPublicKey whether we are sending a public key or a key path (only make sense during assisted derivation) + * @return the raw card response + * @throws IOException communication error + */ + public APDUResponse deriveKey(byte[] data, int source, boolean assisted, boolean isPublicKey) throws IOException { + byte p1 = assisted ? DERIVE_P1_ASSISTED_MASK : 0; + p1 |= source; + byte p2 = isPublicKey ? DERIVE_P2_PUBLIC_KEY : DERIVE_P2_KEY_PATH; + + APDUCommand deriveKey = secureChannel.protectedCommand(0x80, INS_DERIVE_KEY, p1, p2, data); + return secureChannel.transmit(apduChannel, deriveKey); + } + + /** + * 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); + } + + /** + * Sends an EXPORT KEY APDU. The keyPathIndex is used as P1. Valid values are defined in the applet itself + * + * @param keyPathIndex the P1 parameter + * @param publicOnly the P2 parameter + * @return the raw card response + * @throws IOException communication error + */ + public APDUResponse exportKey(byte keyPathIndex, boolean publicOnly) throws IOException { + byte p2 = publicOnly ? EXPORT_KEY_P2_PUBLIC_ONLY : EXPORT_KEY_P2_PRIVATE_AND_PUBLIC; + APDUCommand exportKey = secureChannel.protectedCommand(0x80, INS_EXPORT_KEY, keyPathIndex, p2, new byte[0]); + return secureChannel.transmit(apduChannel, exportKey); + } + + /** + * 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 { + byte[] initData = Arrays.copyOf(pin.getBytes(), pin.length() + puk.length() + sharedSecret.length); + System.arraycopy(puk.getBytes(), 0, initData, pin.length(), puk.length()); + System.arraycopy(sharedSecret, 0, initData, pin.length() + puk.length(), sharedSecret.length); + APDUCommand init = new APDUCommand(0x80, INS_INIT, 0, 0, secureChannel.oneShotEncrypt(initData)); + return apduChannel.send(init); + } + + private byte[] extractPublicKeyFromSelect(byte[] select) { + if (select[0] == TLV_APPLICATION_INFO_TEMPLATE) { + return Arrays.copyOfRange(select, 22, 22 + select[21]); + } else if (select[0] == TLV_PUB_KEY) { + return Arrays.copyOfRange(select, 2, select.length); + } else { + throw new RuntimeException("Unexpected card response"); + } + } +} diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..6348baa --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..867f14a --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..c4b6c12 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..bbd3e02 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..bbd3e02 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..a2f5908 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..1b52399 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..ff10afd Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..115a4c7 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..dcd3cd8 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..459ca60 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..8ca12fe Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8e19b41 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..b824ebd Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..4c19a13 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..3ab3e9c --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #3F51B5 + #303F9F + #FF4081 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..be02186 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + HardwalletLiteAndroid + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..5885930 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/test/java/im/status/hardwallet_lite_android/ExampleUnitTest.java b/app/src/test/java/im/status/hardwallet_lite_android/ExampleUnitTest.java new file mode 100644 index 0000000..24683d0 --- /dev/null +++ b/app/src/test/java/im/status/hardwallet_lite_android/ExampleUnitTest.java @@ -0,0 +1,17 @@ +package im.status.hardwallet_lite_android; + +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + @Test + public void addition_isCorrect() { + assertEquals(4, 2 + 2); + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..47825b2 --- /dev/null +++ b/build.gradle @@ -0,0 +1,27 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + + repositories { + google() + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:3.1.0' + + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..743d692 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,13 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..7a3265e Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ff9909b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue Oct 23 10:14:17 CEST 2018 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.4-all.zip diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..f955316 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..e7b4def --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +include ':app'