diff --git a/demo-android/.gitignore b/demo-android/.gitignore deleted file mode 100644 index 796b96d..0000000 --- a/demo-android/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/demo-android/build.gradle b/demo-android/build.gradle deleted file mode 100644 index 4660811..0000000 --- a/demo-android/build.gradle +++ /dev/null @@ -1,40 +0,0 @@ -apply plugin: 'com.android.application' - -android { - compileSdkVersion 28 - defaultConfig { - applicationId "im.status.keycard.demo" - minSdkVersion 19 - targetSdkVersion 28 - versionCode 300 - versionName "3.0.0" - testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" - } - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - } - } - - lintOptions { - lintConfig file("lint-config.xml") - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } -} - -dependencies { - implementation 'com.android.support:appcompat-v7:28.0.0' - implementation 'com.android.support.constraint:constraint-layout:1.1.3' - implementation 'org.bouncycastle:bcprov-jdk15on:1.60' - - implementation project(':android') - - 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/demo-android/lint-config.xml b/demo-android/lint-config.xml deleted file mode 100644 index e1957ce..0000000 --- a/demo-android/lint-config.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/demo-android/proguard-rules.pro b/demo-android/proguard-rules.pro deleted file mode 100644 index f1b4245..0000000 --- a/demo-android/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# 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/demo-android/src/main/AndroidManifest.xml b/demo-android/src/main/AndroidManifest.xml deleted file mode 100644 index 9955e6f..0000000 --- a/demo-android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/demo-android/src/main/java/im/status/keycard/app/MainActivity.java b/demo-android/src/main/java/im/status/keycard/app/MainActivity.java deleted file mode 100644 index 82bad57..0000000 --- a/demo-android/src/main/java/im/status/keycard/app/MainActivity.java +++ /dev/null @@ -1,197 +0,0 @@ -package im.status.keycard.app; - -import android.bluetooth.BluetoothAdapter; -import android.bluetooth.BluetoothDevice; -import android.nfc.NfcAdapter; -import android.os.Bundle; -import android.support.v7.app.AppCompatActivity; -import android.util.Log; -import im.status.keycard.android.LedgerBLEManager; -import im.status.keycard.demo.R; -import im.status.keycard.io.CardChannel; -import im.status.keycard.io.CardListener; -import im.status.keycard.android.NFCCardManager; -import im.status.keycard.applet.*; -import org.bouncycastle.util.encoders.Hex; - -public class MainActivity extends AppCompatActivity { - - private static final String TAG = "MainActivity"; - - private NfcAdapter nfcAdapter; - private NFCCardManager cardManager; - //private LedgerBLEManager cardManager; - //private boolean connected; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - nfcAdapter = NfcAdapter.getDefaultAdapter(this); - cardManager = new NFCCardManager(); - //cardManager = new LedgerBLEManager(this); - cardManager.setCardListener(new CardListener() { - @Override - public void onConnected(CardChannel cardChannel) { - try { - // Applet-specific code - KeycardCommandSet cmdSet = new KeycardCommandSet(cardChannel); - - Log.i(TAG, "Applet selection successful"); - - // First thing to do is selecting the applet on the card. - ApplicationInfo info = new ApplicationInfo(cmdSet.select().checkOK().getData()); - - // If the card is not initialized, the INIT apdu must be sent. The actual PIN, PUK and pairing password values - // 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", "KeycardTest").checkOK(); - info = new ApplicationInfo(cmdSet.select().checkOK().getData()); - } - - Log.i(TAG, "Instance UID: " + Hex.toHexString(info.getInstanceUID())); - Log.i(TAG, "Secure channel public key: " + Hex.toHexString(info.getSecureChannelPubKey())); - Log.i(TAG, "Application version: " + info.getAppVersionString()); - Log.i(TAG, "Free pairing slots: " + info.getFreePairingSlots()); - if (info.hasMasterKey()) { - Log.i(TAG, "Key UID: " + Hex.toHexString(info.getKeyUID())); - } else { - Log.i(TAG, "The card has no master key"); - } - Log.i(TAG, String.format("Capabilities: %02X", info.getCapabilities())); - Log.i(TAG, "Has Secure Channel: " + info.hasSecureChannelCapability()); - Log.i(TAG, "Has Key Management: " + info.hasKeyManagementCapability()); - Log.i(TAG, "Has Credentials Management: " + info.hasCredentialsManagementCapability()); - Log.i(TAG, "Has NDEF capability: " + info.hasNDEFCapability()); - - if (info.hasSecureChannelCapability()) { - // In real projects, the pairing key should be saved and used for all new sessions. - cmdSet.autoPair("KeycardTest"); - Pairing pairing = cmdSet.getPairing(); - - // Never log the pairing key in a real application! - Log.i(TAG, "Pairing with card is done."); - Log.i(TAG, "Pairing index: " + pairing.getPairingIndex()); - Log.i(TAG, "Pairing key: " + Hex.toHexString(pairing.getPairingKey())); - - // Opening a Secure Channel is needed for all other applet commands - cmdSet.autoOpenSecureChannel(); - - 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(KeycardCommandSet.GET_STATUS_P1_APPLICATION).checkOK().getData()); - - Log.i(TAG, "PIN retry counter: " + status.getPINRetryCount()); - Log.i(TAG, "PUK retry counter: " + status.getPUKRetryCount()); - Log.i(TAG, "Has master key: " + status.hasMasterKey()); - - if (info.hasKeyManagementCapability()) { - // 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(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. - mnemonic.fetchBIP39EnglishWordlist(); - - Log.i(TAG, "Generated mnemonic phrase: " + mnemonic.toMnemonicPhrase()); - Log.i(TAG, "Binary seed: " + Hex.toHexString(mnemonic.toBinarySeed())); - } - - if (info.hasCredentialsManagementCapability()) { - // PIN authentication allows execution of privileged commands - cmdSet.verifyPIN("000000").checkAuthOK(); - - Log.i(TAG, "Pin Verified."); - } - - // If the card has no keys, we generate a new set. Keys can also be loaded on the card starting from a binary - // seed generated from a mnemonic phrase. In alternative, we could load the generated keypair as shown in the - // commented line of code. - if (!status.hasMasterKey() && info.hasKeyManagementCapability()) { - cmdSet.generateKey(); - //cmdSet.loadKey(mnemonic.toBIP32KeyPair()); - } - - // Get the current key path using GET STATUS - 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")) { - // Key derivation is needed to select the desired key. The derived key remains current until a new derive - // command is sent (it is not lost on power loss). - cmdSet.deriveKey("m/44'/0'/0'/0/0").checkOK(); - Log.i(TAG, "Derived m/44'/0'/0'/0/0"); - } - - // We retrieve the wallet public key - BIP32KeyPair walletPublicKey = BIP32KeyPair.fromTLV(cmdSet.exportCurrentKey(true).checkOK().getData()); - - Log.i(TAG, "Wallet public key: " + Hex.toHexString(walletPublicKey.getPublicKey())); - Log.i(TAG, "Wallet address: " + Hex.toHexString(walletPublicKey.toEthereumAddress())); - - byte[] hash = "thiscouldbeahashintheorysoitisok".getBytes(); - - RecoverableSignature signature = new RecoverableSignature(hash, cmdSet.sign(hash).checkOK().getData()); - - Log.i(TAG, "Signed hash: " + Hex.toHexString(hash)); - Log.i(TAG, "Recovery ID: " + signature.getRecId()); - Log.i(TAG, "R: " + Hex.toHexString(signature.getR())); - Log.i(TAG, "S: " + Hex.toHexString(signature.getS())); - - if (info.hasSecureChannelCapability()) { - // 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, "Unpaired."); - } - } catch (Exception e) { - Log.e(TAG, e.getMessage()); - } - - } - - @Override - public void onDisconnected() { - Log.i(TAG, "Card disconnected."); - } - }); - cardManager.start(); - /*connected = false; - cardManager.startScan(new BluetoothAdapter.LeScanCallback() { - @Override - public void onLeScan(BluetoothDevice device, int rssi, byte[] scanRecord) { - if (connected) { - return; - } - - connected = true; - cardManager.stopScan(this); - cardManager.connectDevice(device); - } - });*/ - } - - @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/demo-android/src/main/res/drawable-v24/ic_launcher_foreground.xml b/demo-android/src/main/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 6348baa..0000000 --- a/demo-android/src/main/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - diff --git a/demo-android/src/main/res/drawable/ic_launcher_background.xml b/demo-android/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index 867f14a..0000000 --- a/demo-android/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/demo-android/src/main/res/layout/activity_main.xml b/demo-android/src/main/res/layout/activity_main.xml deleted file mode 100644 index 8952209..0000000 --- a/demo-android/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/demo-android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/demo-android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index bbd3e02..0000000 --- a/demo-android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/demo-android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/demo-android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index bbd3e02..0000000 --- a/demo-android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/demo-android/src/main/res/mipmap-hdpi/ic_launcher.png b/demo-android/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index a2f5908..0000000 Binary files a/demo-android/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/demo-android/src/main/res/mipmap-hdpi/ic_launcher_round.png b/demo-android/src/main/res/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index 1b52399..0000000 Binary files a/demo-android/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/demo-android/src/main/res/mipmap-mdpi/ic_launcher.png b/demo-android/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index ff10afd..0000000 Binary files a/demo-android/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/demo-android/src/main/res/mipmap-mdpi/ic_launcher_round.png b/demo-android/src/main/res/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index 115a4c7..0000000 Binary files a/demo-android/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/demo-android/src/main/res/mipmap-xhdpi/ic_launcher.png b/demo-android/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index dcd3cd8..0000000 Binary files a/demo-android/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/demo-android/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/demo-android/src/main/res/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index 459ca60..0000000 Binary files a/demo-android/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/demo-android/src/main/res/mipmap-xxhdpi/ic_launcher.png b/demo-android/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 8ca12fe..0000000 Binary files a/demo-android/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/demo-android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/demo-android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index 8e19b41..0000000 Binary files a/demo-android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/demo-android/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/demo-android/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index b824ebd..0000000 Binary files a/demo-android/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/demo-android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/demo-android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index 4c19a13..0000000 Binary files a/demo-android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/demo-android/src/main/res/values/colors.xml b/demo-android/src/main/res/values/colors.xml deleted file mode 100644 index 3ab3e9c..0000000 --- a/demo-android/src/main/res/values/colors.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - #3F51B5 - #303F9F - #FF4081 - diff --git a/demo-android/src/main/res/values/strings.xml b/demo-android/src/main/res/values/strings.xml deleted file mode 100644 index d7a6510..0000000 --- a/demo-android/src/main/res/values/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - KeycardAndroid - diff --git a/demo-android/src/main/res/values/styles.xml b/demo-android/src/main/res/values/styles.xml deleted file mode 100644 index 5885930..0000000 --- a/demo-android/src/main/res/values/styles.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/lib/src/main/java/im/status/keycard/applet/ApplicationInfo.java b/lib/src/main/java/im/status/keycard/applet/ApplicationInfo.java index 9aee41b..f4ad2e0 100644 --- a/lib/src/main/java/im/status/keycard/applet/ApplicationInfo.java +++ b/lib/src/main/java/im/status/keycard/applet/ApplicationInfo.java @@ -12,12 +12,15 @@ public class ApplicationInfo { private byte freePairingSlots; private byte[] keyUID; private byte capabilities; + private byte[] certData; + private byte appStatus; public static final byte TLV_APPLICATION_INFO_TEMPLATE = (byte) 0xA4; public static final byte TLV_PUB_KEY = (byte) 0x80; public static final byte TLV_UID = (byte) 0x8F; public static final byte TLV_KEY_UID = (byte) 0x8E; public static final byte TLV_CAPABILITIES = (byte) 0x8D; + public static final byte TLV_STATUS = (byte) 0x8C; static final byte CAPABILITY_SECURE_CHANNEL = (byte) 0x01; static final byte CAPABILITY_KEY_MANAGEMENT = (byte) 0x02; @@ -27,6 +30,9 @@ public class ApplicationInfo { static final byte CAPABILITIES_ALL = CAPABILITY_SECURE_CHANNEL | CAPABILITY_KEY_MANAGEMENT | CAPABILITY_CREDENTIALS_MANAGEMENT | CAPABILITY_NDEF | CAPABILITY_FACTORY_RESET; + static final byte APP_STATUS_INITIALIZED = 0x10; + static final byte APP_STATUS_LEE_MODE = 0x20; + /** * Constructs an object by parsing the TLV data. * @@ -36,10 +42,7 @@ public class ApplicationInfo { public ApplicationInfo(byte[] tlvData) throws IllegalArgumentException { TinyBERTLV tlv = new TinyBERTLV(tlvData); - int topTag = tlv.readTag(); - tlv.unreadLastTag(); - - if (topTag == TLV_PUB_KEY) { + if (tlv.nextTagIs(TLV_PUB_KEY)) { secureChannelPubKey = tlv.readPrimitive(TLV_PUB_KEY); initializedCard = false; capabilities = CAPABILITY_CREDENTIALS_MANAGEMENT; @@ -52,20 +55,50 @@ public ApplicationInfo(byte[] tlvData) throws IllegalArgumentException { } tlv.enterConstructed(TLV_APPLICATION_INFO_TEMPLATE); - instanceUID = tlv.readPrimitive(TLV_UID); - secureChannelPubKey = tlv.readPrimitive(TLV_PUB_KEY); + + // Parse fields conditionally by tag for cross-version compatibility. + + // instanceUID (0x8F) - present in V1-V3, absent in V4+ + if (tlv.nextTagIs(TLV_UID)) { + instanceUID = tlv.readPrimitive(TLV_UID); + } + + // secureChannelPubKey (0x80) - present in V1-V3, absent in V4+ + if (tlv.nextTagIs(TLV_PUB_KEY)) { + secureChannelPubKey = tlv.readPrimitive(TLV_PUB_KEY); + } + + // appVersion (INTEGER 0x02) - present in all versions appVersion = (short) tlv.readInt(); - freePairingSlots = (byte) tlv.readInt(); + + // appStatud (0x8C) - initialized, lee mode, pin retries + if (tlv.nextTagIs(TLV_STATUS)) { + appStatus = tlv.readPrimitive(TLV_STATUS)[0]; + initializedCard = (appStatus & APP_STATUS_INITIALIZED) == APP_STATUS_INITIALIZED; + } else { + appStatus = APP_STATUS_INITIALIZED; + initializedCard = true; + } + + // freePairingSlots (INTEGER 0x02) - present in V1-V3, absent in V4+ + if (tlv.nextTagIs(TinyBERTLV.TLV_INT)) { + freePairingSlots = (byte) tlv.readInt(); + } + + // keyUID (0x8E) - present in all versions keyUID = tlv.readPrimitive(TLV_KEY_UID); - if (tlv.readTag() != TinyBERTLV.END_OF_TLV) { - tlv.unreadLastTag(); + // capabilities (0x8D) - present in V2+ + if (tlv.nextTagIs(TLV_CAPABILITIES)) { capabilities = tlv.readPrimitive(TLV_CAPABILITIES)[0]; } else { capabilities = CAPABILITIES_ALL; } - initializedCard = true; + // Parse certificate - present in V4+ + if (tlv.nextTagIs(Certificate.TLV_CERT)) { + certData = tlv.readPrimitive(Certificate.TLV_CERT); + } } /** @@ -200,5 +233,40 @@ public boolean hasNDEFCapability() { */ public boolean hasFactoryResetCapability() { return (capabilities & CAPABILITY_FACTORY_RESET) == CAPABILITY_FACTORY_RESET; - } + } + + /** + * Returns true if the device has loaded LEE keys. + * + * @return true or false + */ + public boolean isLEEMode() { + return (appStatus & APP_STATUS_LEE_MODE) == APP_STATUS_LEE_MODE; + } + + /** + * Returns the number of remaining PIN retries. + * Only available on applet V4+, returns -1 on older cards. + * + * @return pin retry counter + */ + public byte getPINRetries() { + if (appVersion < 0x0400) { + return -1; + } + + return (byte) (appStatus & 0x0f); + } + + /** + * Returns the raw identity certificate data (V2 only). + * + * This is the 98-byte certificate: compressed_pubkey(33) || r(32) || s(32) || v(1). + * Used by {@link SecureChannelV2} for card authentication during handshake. + * + * @return the certificate data, or null if not present (V1 cards) + */ + public byte[] getCertData() { + return certData; + } } diff --git a/lib/src/main/java/im/status/keycard/applet/Certificate.java b/lib/src/main/java/im/status/keycard/applet/Certificate.java index 601bbd3..aac98c8 100644 --- a/lib/src/main/java/im/status/keycard/applet/Certificate.java +++ b/lib/src/main/java/im/status/keycard/applet/Certificate.java @@ -119,6 +119,15 @@ public static byte[] verifyIdentity(byte[] hash, byte[] tlvData) { } } + /** + * Returns the card's identity public key (compressed, 33 bytes). + * + * @return the compressed identity public key + */ + public byte[] getIdentPub() { + return identPub; + } + public byte[] toStoreData() { if (identPriv == null) { throw new IllegalStateException("The private key must be set."); diff --git a/lib/src/main/java/im/status/keycard/applet/KeycardCommandSet.java b/lib/src/main/java/im/status/keycard/applet/KeycardCommandSet.java index 7f4b12e..3edf88e 100644 --- a/lib/src/main/java/im/status/keycard/applet/KeycardCommandSet.java +++ b/lib/src/main/java/im/status/keycard/applet/KeycardCommandSet.java @@ -10,6 +10,8 @@ import java.io.IOException; import java.security.KeyPair; import java.util.Arrays; +import java.util.Collections; +import java.util.List; /** * This class is used to send APDU to the applet. Each method corresponds to an APDU as defined in the APPLICATION.md @@ -33,8 +35,10 @@ public class KeycardCommandSet { static final byte INS_SIGN = (byte) 0xC0; static final byte INS_SET_PINLESS_PATH = (byte) 0xC1; static final byte INS_EXPORT_KEY = (byte) 0xC2; + static final byte INS_EXPORT_LEE = (byte) 0xC3; static final byte INS_GET_DATA = (byte) 0xCA; static final byte INS_STORE_DATA = (byte) 0xE2; + static final byte INS_GET_CHALLENGE = (byte) 0x84; public static final byte CHANGE_PIN_P1_USER_PIN = 0x00; public static final byte CHANGE_PIN_P1_PUK = 0x01; @@ -46,6 +50,7 @@ public class KeycardCommandSet { public static final byte LOAD_KEY_P1_EC = 0x01; public static final byte LOAD_KEY_P1_EXT_EC = 0x02; public static final byte LOAD_KEY_P1_SEED = 0x03; + public static final byte LOAD_KEY_P1_LEE = 0x04; public static final byte DERIVE_P1_SOURCE_MASTER = (byte) 0x00; public static final byte DERIVE_P1_SOURCE_PARENT = (byte) 0x40; @@ -57,7 +62,9 @@ public class KeycardCommandSet { static final byte SIGN_P1_PINLESS = 0x03; public static final byte SIGN_P2_ECDSA = 0x00; - public static final byte SIGN_P2_BLS12_381 = 0x01; + public static final byte SIGN_P2_EDDSA_ED25519 = 0x01; + public static final byte SIGN_P2_BLS12_381 = 0x02; + public static final byte SIGN_P2_BIP340_SCHNORR = 0x03; public static final byte STORE_DATA_P1_PUBLIC = 0x00; public static final byte STORE_DATA_P1_NDEF = 0x01; @@ -87,17 +94,67 @@ public class KeycardCommandSet { static final byte TLV_APPLICATION_INFO_TEMPLATE = (byte) 0xA4; + /** + * Default Status CA public key (compressed secp256k1, 33 bytes). + */ + public static final byte[] DEFAULT_CA_PUBLIC_KEY = { + (byte) 0x02, + (byte) 0x9a, (byte) 0xb9, (byte) 0x9e, (byte) 0xe1, (byte) 0xe7, (byte) 0xa7, (byte) 0x1b, + (byte) 0xdf, (byte) 0x45, (byte) 0xb3, (byte) 0xf9, (byte) 0xc5, (byte) 0x8c, (byte) 0x99, + (byte) 0x86, (byte) 0x6f, (byte) 0xf1, (byte) 0x29, (byte) 0x4d, (byte) 0x2c, (byte) 0x1e, + (byte) 0x30, (byte) 0x4e, (byte) 0x22, (byte) 0x8a, (byte) 0x86, (byte) 0xe1, (byte) 0x0c, + (byte) 0x33, (byte) 0x43, (byte) 0x50, (byte) 0x1c + }; + private final CardChannel apduChannel; - private SecureChannelSession secureChannel; + private SecureChannel secureChannel; private ApplicationInfo info; + private final List caPublicKeys; + private final List whitelistedCardPublicKeys; /** - * Creates a KeycardCommandSet using the given APDU Channel + * Creates a KeycardCommandSet using the given APDU Channel. + * The secure channel version (V1 or V2) is auto-detected based on the applet + * version after the first SELECT command. + * Uses the default Status CA public key for V2 certificate verification. + * * @param apduChannel APDU channel */ public KeycardCommandSet(CardChannel apduChannel) { + this(apduChannel, DEFAULT_CA_PUBLIC_KEY); + } + + /** + * Creates a KeycardCommandSet using the given APDU Channel and a single + * CA public key for V2 certificate verification. + * + * @param apduChannel APDU channel + * @param caPublicKey compressed secp256k1 CA public key (33 bytes) + */ + public KeycardCommandSet(CardChannel apduChannel, byte[] caPublicKey) { + this(apduChannel, Collections.singletonList(caPublicKey), Collections.emptyList()); + } + + /** + * Creates a KeycardCommandSet using the given APDU Channel with the given + * set of trusted CA public keys and optionally whitelisted card identity + * public keys for V2 certificate verification. + * + * @param apduChannel APDU channel + * @param caPublicKeys array of compressed secp256k1 CA public keys (33 bytes each), may be empty but not null + * @param whitelistedCardPublicKeys array of compressed card identity public keys (33 bytes each), may be empty but not null + */ + public KeycardCommandSet(CardChannel apduChannel, List caPublicKeys, List whitelistedCardPublicKeys) { + if (caPublicKeys == null) { + throw new IllegalArgumentException("caPublicKeys must not be null"); + } + if (whitelistedCardPublicKeys == null) { + throw new IllegalArgumentException("whitelistedCardPublicKeys must not be null"); + } this.apduChannel = apduChannel; - this.secureChannel = new SecureChannelSession(); + this.caPublicKeys = caPublicKeys; + this.whitelistedCardPublicKeys = whitelistedCardPublicKeys; + this.secureChannel = new SecureChannelV2(caPublicKeys, whitelistedCardPublicKeys); } /** @@ -111,11 +168,12 @@ public ApplicationInfo getApplicationInfo() { } /** - * Set the SecureChannel object - * @param secureChannel secure channel + * Returns the current secure channel implementation. + * + * @return the secure channel */ - protected void setSecureChannel(SecureChannelSession secureChannel) { - this.secureChannel = secureChannel; + public SecureChannel getSecureChannel() { + return secureChannel; } /** @@ -156,13 +214,19 @@ public APDUResponse select(int instanceIdx) throws IOException { APDUCommand selectApplet = new APDUCommand(0x00, 0xA4, 4, 0, Identifiers.getKeycardInstanceAID(instanceIdx)); APDUResponse resp = apduChannel.send(selectApplet); - if (resp.getSw() == 0x9000) { info = new ApplicationInfo(resp.getData()); if (info.hasSecureChannelCapability()) { - this.secureChannel.generateSecret(info.getSecureChannelPubKey()); - this.secureChannel.reset(); + if (isSecureChannelV2(info)) { + SecureChannelV2 scV2 = new SecureChannelV2(caPublicKeys, whitelistedCardPublicKeys); + scV2.setCardCertificate(info.getCertData()); + this.secureChannel = scV2; + } else { + SecureChannelV1 scV1 = new SecureChannelV1(); + scV1.generateSecret(info.getSecureChannelPubKey()); + this.secureChannel = scV1; + } } } @@ -426,6 +490,19 @@ public APDUResponse loadKey(byte[] seed) throws IOException { return loadKey(seed, LOAD_KEY_P1_SEED); } + /** + * Sends a LOAD KEY APDU. The given seed is sent as-is and the P1 of the command is set to LOAD_KEY_P1_LEE (0x04). + * This works on cards which support public key derivation. The loaded keyset is extended and support further + * key derivation. + * + * @param seed the binary seed + * @return the raw card response + * @throws IOException communication error + */ + public APDUResponse loadLEEKey(byte[] seed) throws IOException { + return loadKey(seed, LOAD_KEY_P1_LEE); + } + /** * 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. @@ -556,11 +633,26 @@ public APDUResponse sign(byte[] hash) throws IOException { * @throws IOException communication error */ public APDUResponse signWithPath(byte[] hash, String path, boolean makeCurrent) throws IOException { + return signWithPath(hash, path, SIGN_P2_ECDSA, makeCurrent); + } + + /** + * Sends a SIGN APDU. This signs a precomputed hash that must be exactly 32-bytes long. The key used to sign is given + * as a parameter. + * + * @param hash the hash to sign + * @params path the path of the key to use + * @oarams algo the signing algorithm + * @param makeCurrent ture if the key used to sign should become the current key, false otherwise + * @return the raw card response + * @throws IOException communication error + */ + public APDUResponse signWithPath(byte[] hash, String path, int algo, boolean makeCurrent) throws IOException { KeyPath keyPath = new KeyPath(path); byte[] pathData = keyPath.getData(); byte[] data = Arrays.copyOf(hash, hash.length + pathData.length); System.arraycopy(pathData, 0, data, hash.length, pathData.length); - return sign(data, keyPath.getSource() | (makeCurrent ? SIGN_P1_DERIVE_AND_MAKE_CURRENT : SIGN_P1_DERIVE)); + return sign(data, keyPath.getSource() | (makeCurrent ? SIGN_P1_DERIVE_AND_MAKE_CURRENT : SIGN_P1_DERIVE), algo); } /** @@ -585,10 +677,15 @@ public APDUResponse signPinless(byte[] hash) throws IOException { * @throws IOException communication error */ public APDUResponse sign(byte[] data, int p1) throws IOException { - APDUCommand sign = secureChannel.protectedCommand(0x80, INS_SIGN, p1, 0x00, data); + return sign(data, p1, SIGN_P2_ECDSA); + } + + public APDUResponse sign(byte[] data, int p1, int p2) throws IOException { + APDUCommand sign = secureChannel.protectedCommand(0x80, INS_SIGN, p1, p2, data); return secureChannel.transmit(apduChannel, sign); } + /** * Sends a DERIVE KEY APDU with the given key path. * @@ -772,6 +869,31 @@ public APDUResponse exportKey(int derivationOptions, byte p2, byte[] keypath) th return secureChannel.transmit(apduChannel, exportKey); } + /** + * Sends an EXPORT LEE APDU. + * + * @param keypath the derivation path + * @return the raw card response + * @throws IOException communication error + */ + public APDUResponse exportLEEKey(String keyPath) throws IOException { + KeyPath path = new KeyPath(keyPath); + return exportLEEKey(path.getData(), path.getSource()); + } + + /** + * Sends an EXPORT LEE APDU. + * + * @param path the derivation path + * @param source the derivation source + * @return the raw card response + * @throws IOException communication error + */ + public APDUResponse exportLEEKey(byte[] path, int source) throws IOException { + APDUCommand exportLee = secureChannel.protectedCommand(0x80, INS_EXPORT_LEE, source, 0, path); + return secureChannel.transmit(apduChannel, exportLee); + } + /** * Sends a GET DATA APDU. * @@ -784,6 +906,11 @@ public APDUResponse getData(byte dataType) throws IOException { return secureChannel.transmit(apduChannel, getData); } + public APDUResponse getChallenge(int len) throws IOException { + APDUCommand getChallenge = secureChannel.protectedCommand(0x80, INS_GET_CHALLENGE, len, 0, new byte[0]); + return secureChannel.transmit(apduChannel, getChallenge); + } + /** * Sends a STORE DATA APDU for NDEF. * @@ -860,7 +987,7 @@ public APDUResponse storeData(byte[] data, byte dataType, short off) throws IOEx * @return the raw card response * @throws IOException communication error */ - public APDUResponse init(String pin, String puk, String pairingPassword) throws IOException { + public APDUResponse init(String pin, String puk, String pairingPassword) throws IOException, APDUException { return this.init(pin, puk, pairingPassword, (byte) 0, (byte) 0); } @@ -875,7 +1002,7 @@ public APDUResponse init(String pin, String puk, String pairingPassword) throws * @return the raw card response * @throws IOException communication error */ - public APDUResponse init(String pin, String puk, String pairingPassword, byte pinRetries, byte pukRetries) throws IOException { + public APDUResponse init(String pin, String puk, String pairingPassword, byte pinRetries, byte pukRetries) throws IOException, APDUException { return this.init(pin, null, puk, pairingPasswordToSecret(pairingPassword), pinRetries, pukRetries); } @@ -891,7 +1018,7 @@ public APDUResponse init(String pin, String puk, String pairingPassword, byte pi * @return the raw card response * @throws IOException communication error */ - public APDUResponse init(String pin, String altPin, String puk, String pairingPassword, byte pinRetries, byte pukRetries) throws IOException { + public APDUResponse init(String pin, String altPin, String puk, String pairingPassword, byte pinRetries, byte pukRetries) throws IOException, APDUException { return this.init(pin, altPin, puk, pairingPasswordToSecret(pairingPassword), pinRetries, pukRetries); } @@ -904,7 +1031,7 @@ public APDUResponse init(String pin, String altPin, String puk, String pairingPa * @return the raw card response * @throws IOException communication error */ - public APDUResponse init(String pin, String puk, byte[] sharedSecret) throws IOException { + public APDUResponse init(String pin, String puk, byte[] sharedSecret) throws IOException, APDUException { return init(pin, null, puk, sharedSecret, (byte) 0, (byte) 0); } @@ -912,15 +1039,16 @@ public APDUResponse init(String pin, String puk, byte[] sharedSecret) throws IOE * Sends the INIT command to the card. If either pinRetries or pukRetries is zero, neither will be sent. * * @param pin the PIN - * @param pin the alternative + * @param altPin the alternative PIN (may be null) * @param puk the PUK - * @param sharedSecret the shared secret for pairing + * @param sharedSecret the shared secret for pairing (ignored for V2) * @param pinRetries the number of allowed PIN retries * @param pukRetries the number of allowed PUK retries * @return the raw card response * @throws IOException communication error */ - public APDUResponse init(String pin, String altPin, String puk, byte[] sharedSecret, byte pinRetries, byte pukRetries) throws IOException { + public APDUResponse init(String pin, String altPin, String puk, byte[] sharedSecret, byte pinRetries, byte pukRetries) throws IOException, APDUException { + // Build init data: PIN + PUK [+ retries(2) [+ altPIN]] int baselen = pin.length() + puk.length() + sharedSecret.length; int extlen; @@ -931,7 +1059,7 @@ public APDUResponse init(String pin, String altPin, String puk, byte[] sharedSec } else { extlen = 0; } - + byte[] initData = Arrays.copyOf(pin.getBytes(), baselen + extlen); System.arraycopy(puk.getBytes(), 0, initData, pin.length(), puk.length()); System.arraycopy(sharedSecret, 0, initData, pin.length() + puk.length(), sharedSecret.length); @@ -940,13 +1068,21 @@ public APDUResponse init(String pin, String altPin, String puk, byte[] sharedSec initData[baselen] = pinRetries; initData[baselen + 1] = pukRetries; - if (extlen > 2) { + if (extlen > 2 && altPin != null) { System.arraycopy(altPin.getBytes(), 0, initData, baselen + 2, altPin.length()); } } - APDUCommand init = new APDUCommand(0x80, INS_INIT, 0, 0, secureChannel.oneShotEncrypt(initData)); - return apduChannel.send(init); + if (secureChannel instanceof SecureChannelV2) { + // V2: open secure channel first, then send INIT as a normal encrypted command + secureChannel.autoOpenSecureChannel(apduChannel); + APDUCommand initCmd = secureChannel.protectedCommand(0x80, INS_INIT, 0, 0, initData); + return secureChannel.transmit(apduChannel, initCmd); + } else { + // V1: use one-shot encryption with the static shared secret + APDUCommand initCmd = new APDUCommand(0x80, INS_INIT, 0, 0, ((SecureChannelV1) secureChannel).oneShotEncrypt(initData)); + return apduChannel.send(initCmd); + } } /** @@ -958,5 +1094,15 @@ public APDUResponse init(String pin, String altPin, String puk, byte[] sharedSec public APDUResponse factoryReset() throws IOException { APDUCommand factoryReset = new APDUCommand(0x80, INS_FACTORY_RESET, FACTORY_RESET_P1_MAGIC, FACTORY_RESET_P2_MAGIC, new byte[0]); return apduChannel.send(factoryReset); - } + } + + /** + * Returns true if the applet uses Secure Channel V2 (app version >= 4.0). + * + * @param appInfo the application info from SELECT + * @return true for Secure Channel V2, false for V1 + */ + private boolean isSecureChannelV2(ApplicationInfo appInfo) { + return appInfo.getAppVersion() >= 0x0400; + } } diff --git a/lib/src/main/java/im/status/keycard/applet/SecureChannel.java b/lib/src/main/java/im/status/keycard/applet/SecureChannel.java new file mode 100644 index 0000000..b1b112f --- /dev/null +++ b/lib/src/main/java/im/status/keycard/applet/SecureChannel.java @@ -0,0 +1,164 @@ +package im.status.keycard.applet; + +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 java.io.IOException; + +/** + * Common interface for Secure Channel implementations. + * + * V1 (SecureChannelSession): AES-CBC with CMAC, pairing-based key derivation. + * V2 (SecureChannelV2Client): ECDHE on secp256k1, HKDF-SHA256, AES-128-CCM. + */ +public interface SecureChannel { + + /** + * Establishes a Secure Channel with the card, performing the full handshake + * including mutual authentication (V1) or card authentication (V2). + * + * @param apduChannel the APDU channel + * @throws IOException communication error + * @throws APDUException secure channel error + */ + void autoOpenSecureChannel(CardChannel apduChannel) throws IOException, APDUException; + + /** + * Performs the pairing procedure (V1 only). + * + * @param apduChannel the APDU channel + * @param pairingMode the pairing mode + * @param sharedSecret the shared secret + * @throws IOException communication error + * @throws APDUException pairing error + * @throws UnsupportedOperationException if called on a V2 channel + */ + void autoPair(CardChannel apduChannel, byte pairingMode, byte[] sharedSecret) + throws IOException, APDUException; + + /** + * Unpairs the current paired key (V1 only). + * + * @param apduChannel the APDU channel + * @throws IOException communication error + * @throws APDUException unpairing error + * @throws UnsupportedOperationException if called on a V2 channel + */ + void autoUnpair(CardChannel apduChannel) throws IOException, APDUException; + + /** + * Unpair all other clients (V1 only). + * + * @param apduChannel the APDU channel + * @throws IOException communication error + * @throws APDUException unpairing error + * @throws UnsupportedOperationException if called on a V2 channel + */ + void unpairOthers(CardChannel apduChannel) throws IOException, APDUException; + + /** + * Sends an OPEN SECURE CHANNEL APDU. + * + * @param apduChannel the APDU channel + * @param index the P1 parameter (pairing index for V1, ignored for V2) + * @param data the data + * @return the raw card response + * @throws IOException communication error + */ + APDUResponse openSecureChannel(CardChannel apduChannel, byte index, byte[] data) + throws IOException; + + /** + * Sends a MUTUALLY AUTHENTICATE APDU (V1 only). + * + * @param apduChannel the APDU channel + * @return the raw card response + * @throws IOException communication error + * @throws UnsupportedOperationException if called on a V2 channel + */ + APDUResponse mutuallyAuthenticate(CardChannel apduChannel) throws IOException; + + /** + * Sends a MUTUALLY AUTHENTICATE APDU (V1 only). + * + * @param apduChannel the APDU channel + * @param data the data + * @return the raw card response + * @throws IOException communication error + * @throws UnsupportedOperationException if called on a V2 channel + */ + APDUResponse mutuallyAuthenticate(CardChannel apduChannel, byte[] data) throws IOException; + + /** + * Sends a PAIR APDU (V1 only). + * + * @param apduChannel the APDU channel + * @param p1 the P1 parameter + * @param p2 the P2 parameter + * @param data the data + * @return the raw card response + * @throws IOException communication error + * @throws UnsupportedOperationException if called on a V2 channel + */ + APDUResponse pair(CardChannel apduChannel, byte p1, byte p2, byte[] data) throws IOException; + + /** + * Sends an UNPAIR APDU (V1 only). + * + * @param apduChannel the APDU channel + * @param p1 the P1 parameter + * @return the raw card response + * @throws IOException communication error + * @throws UnsupportedOperationException if called on a V2 channel + */ + APDUResponse unpair(CardChannel apduChannel, byte p1) throws IOException; + + /** + * Returns a command APDU with the secure channel wrapper applied. + * + * For V1: encrypts the data with AES-CBC and prepends the IV. + * For V2: wraps the full inner APDU in the secured command format (CLA=0x80, INS=0x18). + * + * @param cla the CLA byte of the inner command + * @param ins the INS byte of the inner command + * @param p1 the P1 byte of the inner command + * @param p2 the P2 byte of the inner command + * @param data the data of the inner command + * @return the wrapped command APDU + */ + APDUCommand protectedCommand(int cla, int ins, int p1, int p2, byte[] data); + + /** + * Transmits a protected command APDU and unwraps the response. + * + * For V1: verifies the MAC, decrypts the data, extracts the inner SW. + * For V2: decrypts the AES-CCM payload, extracts the inner APDU data and SW. + * + * @param apduChannel the APDU channel + * @param apdu the APDU to send + * @return the unwrapped response APDU (data only, SW from inner payload) + * @throws IOException transmission error + */ + APDUResponse transmit(CardChannel apduChannel, APDUCommand apdu) throws IOException; + + /** + * Returns the current pairing data (V1 only). + * + * @return the pairing, or null for V2 + */ + Pairing getPairing(); + + /** + * Sets the pairing data (V1 only). No-op for V2. + * + * @param pairing the pairing data + */ + void setPairing(Pairing pairing); + + /** + * Resets the secure channel, invalidating the current session. + */ + void reset(); +} diff --git a/lib/src/main/java/im/status/keycard/applet/SecureChannelSession.java b/lib/src/main/java/im/status/keycard/applet/SecureChannelV1.java similarity index 97% rename from lib/src/main/java/im/status/keycard/applet/SecureChannelSession.java rename to lib/src/main/java/im/status/keycard/applet/SecureChannelV1.java index adaa4d8..3e5d5b9 100644 --- a/lib/src/main/java/im/status/keycard/applet/SecureChannelSession.java +++ b/lib/src/main/java/im/status/keycard/applet/SecureChannelV1.java @@ -21,9 +21,14 @@ import java.util.Arrays; /** - * Handles a SecureChannel session with the card. + * Handles a SecureChannel V1 session with the card. + * + * Uses AES-128-CBC with CMAC for encryption and authentication, + * with pairing-based key derivation via ECDH on secp256k1. + * + * Implements the {@link SecureChannel} interface for interoperability with V2. */ -public class SecureChannelSession { +public class SecureChannelV1 implements SecureChannel { public static final short SC_SECRET_LENGTH = 32; public static final short SC_BLOCK_SIZE = 16; @@ -54,7 +59,7 @@ public class SecureChannelSession { /** * Constructs a SecureChannel session on the client. */ - public SecureChannelSession() { + public SecureChannelV1() { random = new SecureRandom(); open = false; } diff --git a/lib/src/main/java/im/status/keycard/applet/SecureChannelV2.java b/lib/src/main/java/im/status/keycard/applet/SecureChannelV2.java new file mode 100644 index 0000000..96a68c2 --- /dev/null +++ b/lib/src/main/java/im/status/keycard/applet/SecureChannelV2.java @@ -0,0 +1,518 @@ +package im.status.keycard.applet; + +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.bouncycastle.jce.ECNamedCurveTable; +import org.bouncycastle.jce.interfaces.ECPublicKey; +import org.bouncycastle.jce.spec.ECParameterSpec; +import org.bouncycastle.jce.spec.ECPublicKeySpec; + +import javax.crypto.Cipher; +import javax.crypto.KeyAgreement; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.Signature; +import java.security.spec.ECGenParameterSpec; +import java.util.Collections; +import java.util.List; +import java.util.Arrays; + +/** + * Client-side implementation of the Secure Channel Protocol v2 (AES-CCM variant). + * + * Protocol flow: + *
    + *
  1. Handshake: ECDHE on secp256k1 + HKDF-SHA256 key derivation
  2. + *
  3. Card authentication: ECDSA-SHA256 signature over the full key exchange transcript
  4. + *
  5. Encrypted commands: AES-128-CCM (T=8, L=13) with implicit per-session nonce counter
  6. + *
+ * + * All traffic after the handshake is wrapped in a single command type: + * {@code [0x80 | 0x18 | 0x00 | 0x00 | LC'] || ciphertext || tag(8B)} + * + * The inner APDU (including CLA, INS, P1, P2, LC, and data) is fully encrypted. + * The ISO-level SW is always 0x9000 (decrypt OK) or 0x6982 (decrypt error); + * the real command SW is inside the encrypted response payload. + * + * Unlike V1, V2 does not use pairing. Each session establishes independent keys + * via ECDHE, authenticated by the card's persistent identity key certificate. + */ +public class SecureChannelV2 implements SecureChannel { + + // Protocol constants + private static final byte[] PROTOCOL_LABEL = { 's', 'c', '_', 'v', '2', '_', 'c', 'c', 'm' }; + + static final short HKDF_SALT_SIZE = 32; + static final short PUBKEY_SIZE = 65; // uncompressed secp256k1 point + static final short ECDH_SHARED_X_SIZE = 32; + static final short OKM_SIZE = 32; + static final short AES_KEY_SIZE = 16; + static final short CCM_TAG_SIZE = 8; + static final short CCM_NONCE_SIZE = 13; + static final short SIGNATURE_DOMAIN_LEN = 5; + + static final byte INS_OPEN_SECURE_CHANNEL = (byte) 0x10; + static final byte INS_SECURED_APDU = (byte) 0x18; + + // Trusted CA public keys for certificate verification (compressed, 33 bytes each) + private final List caPublicKeys; + // Whitelisted card identity public keys (compressed, 33 bytes each) + // Allows accepting specific cards even when their CA is not trusted + private final List whitelistedCardPublicKeys; + + // Session state + private SecretKeySpec keyH2C; + private SecretKeySpec keyC2H; + private Cipher aesCCM; + private byte[] nonceCounter; + private boolean open; + + // Card identity (set during handshake) + private byte[] cardIdentPub; // compressed, 33 bytes + + // Handshake ephemeral key (kept for debug/testing) + private byte[] clientEphPub; + + private final SecureRandom random; + + /** + * Creates a V2 secure channel client with the given set of trusted CA public keys + * and optionally whitelisted card identity public keys. + * + * During certificate verification, a card is accepted if either: + *
    + *
  • The CA public key recovered from its certificate matches one of the trusted CA keys, or
  • + *
  • The card's identity public key is in the whitelist
  • + *
+ * + * @param caPublicKeys list of compressed secp256k1 CA public keys (33 bytes each) + * @param whitelistedCardPublicKeys list of compressed card identity public keys (33 bytes each) + */ + public SecureChannelV2(List caPublicKeys, List whitelistedCardPublicKeys) { + if (caPublicKeys == null) { + throw new IllegalArgumentException("caPublicKeys must not be null"); + } + if (whitelistedCardPublicKeys == null) { + throw new IllegalArgumentException("whitelistedCardPublicKeys must not be null"); + } + this.caPublicKeys = Collections.unmodifiableList(caPublicKeys); + this.whitelistedCardPublicKeys = Collections.unmodifiableList(whitelistedCardPublicKeys); + this.random = new SecureRandom(); + initCiphers(); + } + + private void initCiphers() { + try { + aesCCM = Cipher.getInstance("AES/CCM/NoPadding", "BC"); + nonceCounter = new byte[CCM_NONCE_SIZE]; + } catch (Exception e) { + throw new RuntimeException("Is BouncyCastle in the classpath?", e); + } + } + + // ── SecureChannel interface implementation ────────────────────────── + + @Override + public void autoOpenSecureChannel(CardChannel apduChannel) + throws IOException, APDUException { + byte[] salt = new byte[HKDF_SALT_SIZE]; + random.nextBytes(salt); + + // Generate client ephemeral key pair + KeyPair ephKeyPair = generateEphemeralKeyPair(); + clientEphPub = getUncompressedPublicKey(ephKeyPair.getPublic()); + + // Build request: hkdf_salt || client_eph_pub (uncompressed) + byte[] requestData = new byte[(int) (HKDF_SALT_SIZE + PUBKEY_SIZE)]; + System.arraycopy(salt, 0, requestData, 0, HKDF_SALT_SIZE); + System.arraycopy(clientEphPub, 0, requestData, HKDF_SALT_SIZE, PUBKEY_SIZE); + + // Send OPEN_SECURE_CHANNEL + APDUCommand cmd = new APDUCommand(0x80, INS_OPEN_SECURE_CHANNEL, 0, 0, requestData); + APDUResponse resp = apduChannel.send(cmd); + resp.checkOK("OPEN SECURE CHANNEL failed"); + + processHandshakeResponse(salt, ephKeyPair.getPrivate(), resp.getData()); + } + + @Override + public void autoPair(CardChannel apduChannel, byte pairingMode, byte[] sharedSecret) + throws IOException, APDUException { + throw new UnsupportedOperationException("Pairing is not supported in Secure Channel V2"); + } + + @Override + public void autoUnpair(CardChannel apduChannel) throws IOException, APDUException { + throw new UnsupportedOperationException("Unpairing is not supported in Secure Channel V2"); + } + + @Override + public void unpairOthers(CardChannel apduChannel) throws IOException, APDUException { + throw new UnsupportedOperationException("Unpairing is not supported in Secure Channel V2"); + } + + @Override + public APDUResponse openSecureChannel(CardChannel apduChannel, byte index, byte[] data) + throws IOException { + open = false; + APDUCommand cmd = new APDUCommand(0x80, INS_OPEN_SECURE_CHANNEL, 0, 0, data); + return apduChannel.send(cmd); + } + + @Override + public APDUResponse mutuallyAuthenticate(CardChannel apduChannel) throws IOException { + throw new UnsupportedOperationException( + "Mutual authentication is not a separate step in Secure Channel V2"); + } + + @Override + public APDUResponse mutuallyAuthenticate(CardChannel apduChannel, byte[] data) + throws IOException { + throw new UnsupportedOperationException( + "Mutual authentication is not a separate step in Secure Channel V2"); + } + + @Override + public APDUResponse pair(CardChannel apduChannel, byte p1, byte p2, byte[] data) + throws IOException { + throw new UnsupportedOperationException("Pairing is not supported in Secure Channel V2"); + } + + @Override + public APDUResponse unpair(CardChannel apduChannel, byte p1) throws IOException { + throw new UnsupportedOperationException("Unpairing is not supported in Secure Channel V2"); + } + + @Override + public APDUCommand protectedCommand(int cla, int ins, int p1, int p2, byte[] data) { + if (!open) { + return new APDUCommand(cla, ins, p1, p2, data); + } + + // Build inner APDU: CLA | INS | P1 | P2 | LC | data + ByteArrayOutputStream inner = new ByteArrayOutputStream(); + inner.write(cla & 0xFF); + inner.write(ins & 0xFF); + inner.write(p1 & 0xFF); + inner.write(p2 & 0xFF); + inner.write(data.length & 0xFF); + inner.write(data, 0, data.length); + + byte[] innerApdu = inner.toByteArray(); + byte[] ciphertext = encryptCCM(innerApdu); + + return new APDUCommand(0x80, INS_SECURED_APDU, 0, 0, ciphertext); + } + + @Override + public APDUResponse transmit(CardChannel apduChannel, APDUCommand apdu) throws IOException { + APDUResponse resp = apduChannel.send(apdu); + + if (resp.getSw() != 0x9000) { + open = false; + return resp; + } + + if (!open) { + return resp; + } + + byte[] ciphertext = resp.getData(); + byte[] plaintext = decryptCCM(ciphertext); + + incrementNonce(); + + return new APDUResponse(plaintext); + } + + @Override + public Pairing getPairing() { + return null; // V2 does not use pairing + } + + @Override + public void setPairing(Pairing pairing) { + // No-op: V2 does not use pairing + } + + @Override + public void reset() { + open = false; + keyH2C = null; + keyC2H = null; + Arrays.fill(nonceCounter, (byte) 0); + cardIdentPub = null; + clientEphPub = null; + } + + // ── Handshake processing ──────────────────────────────────────────── + + /** + * Processes the card's handshake response. + * + * @param salt the HKDF salt sent by the client + * @param clientEphPriv the client's ephemeral private key + * @param cardResponse the raw response data (card_eph_pub || DER_signature) + */ + void processHandshakeResponse(byte[] salt, PrivateKey clientEphPriv, byte[] cardResponse) throws APDUException { + // Parse card response: card_eph_pub (65B) || sig (DER, variable) + if (cardResponse.length < PUBKEY_SIZE + 2) { + throw new APDUException("Invalid handshake response: too short"); + } + + byte[] cardEphPub = Arrays.copyOfRange(cardResponse, 0, (int) PUBKEY_SIZE); + byte[] signature = Arrays.copyOfRange(cardResponse, (int) PUBKEY_SIZE, cardResponse.length); + + // ECDH key agreement + byte[] sharedSecret = computeECDH(clientEphPriv, cardEphPub); + + // HKDF-SHA256 key derivation + byte[] okm = hkdfExpand(salt, sharedSecret, PROTOCOL_LABEL, OKM_SIZE); + + // Set session keys: key_h2c = OKM[0..15], key_c2h = OKM[16..31] + keyH2C = new SecretKeySpec(okm, 0, AES_KEY_SIZE, "AES"); + keyC2H = new SecretKeySpec(okm, AES_KEY_SIZE, AES_KEY_SIZE, "AES"); + + initCiphers(); + + // Verify card's ECDSA signature over transcript + // transcript = hkdf_salt || client_eph_pub || card_eph_pub + verifyCardSignature(salt, clientEphPub, cardEphPub, signature); + + // Initialize nonce counter to zero + Arrays.fill(nonceCounter, (byte) 0); + open = true; + } + + private void verifyCardSignature(byte[] salt, byte[] clientPub, byte[] cardPub, byte[] signature) throws APDUException { + try { + // Hash the transcript + MessageDigest md = MessageDigest.getInstance("SHA-256", "BC"); + md.update(PROTOCOL_LABEL); + md.update(salt); + md.update(clientPub); + md.update(cardPub); + byte[] transcriptHash = md.digest(); + + if (cardIdentPub == null) { + throw new APDUException("Card identity public key not available"); + } + + // Verify the ECDSA signature using standard JCA API + Signature verifier = Signature.getInstance("NONEwithECDSA", "BC"); + ECParameterSpec ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1"); + ECPublicKeySpec keySpec = new ECPublicKeySpec(ecSpec.getCurve().decodePoint(cardIdentPub), ecSpec); + ECPublicKey identKey = (ECPublicKey) KeyFactory.getInstance("EC", "BC").generatePublic(keySpec); + verifier.initVerify(identKey); + verifier.update(transcriptHash); + + if (!verifier.verify(signature)) { + throw new APDUException("Card authentication failed: invalid signature"); + } + } catch (APDUException e) { + throw e; + } catch (Exception e) { + throw new APDUException("Card authentication failed: " + e.getMessage()); + } + } + + // ── Certificate handling ──────────────────────────────────────────── + + /** + * Parses the card's identity certificate from the SELECT response and + * validates the CA public key against the known anchor. + * + * @param certData the 98-byte certificate from the SELECT response + * @throws IOException if CA verification fails + */ + public void setCardCertificate(byte[] certData) throws IOException { + try { + Certificate cert = Certificate.fromTLV(certData); + cardIdentPub = cert.getIdentPub(); + + // Check if the card's identity public key is whitelisted + boolean whitelisted = isCardWhitelisted(cardIdentPub); + + // Check if the CA public key is trusted + byte[] caPub = cert.getPublicKey(); // recovered CA public key (compressed) + boolean caTrusted = isCaTrusted(caPub); + + if (!caTrusted && !whitelisted) { + throw new IOException("Card certificate verification failed: unknown CA public key and card not whitelisted"); + } + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException("Failed to parse card certificate: " + e.getMessage()); + } + } + + /** + * Returns the card's identity public key (set during handshake). + * + * @return compressed public key, or null if not yet set + */ + public byte[] getCardIdentPub() { + return cardIdentPub; + } + + /** + * Checks if the given card identity public key is in the whitelist. + * + * @param identPub compressed card identity public key (33 bytes) + * @return true if the key is whitelisted + */ + public boolean isCardWhitelisted(byte[] identPub) { + for (byte[] key : whitelistedCardPublicKeys) { + if (Arrays.equals(key, identPub)) { + return true; + } + } + return false; + } + + /** + * Checks if the given CA public key is trusted. + * + * @param caPub compressed CA public key (33 bytes) + * @return true if the key is trusted + */ + public boolean isCaTrusted(byte[] caPub) { + for (byte[] key : caPublicKeys) { + if (Arrays.equals(key, caPub)) { + return true; + } + } + return false; + } + + // ── Cryptographic helpers ─────────────────────────────────────────── + + private KeyPair generateEphemeralKeyPair() { + try { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("ECDSA", "BC"); + kpg.initialize(new ECGenParameterSpec("secp256k1"), random); + return kpg.generateKeyPair(); + } catch (Exception e) { + throw new RuntimeException("Is BouncyCastle in the classpath?", e); + } + } + + private byte[] getUncompressedPublicKey(PublicKey pubKey) { + return ((ECPublicKey) pubKey).getQ().getEncoded(false); + } + + private byte[] computeECDH(PrivateKey clientPriv, byte[] cardPubUncompressed) { + try { + ECParameterSpec ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1"); + + ECPublicKeySpec cardKeySpec = new ECPublicKeySpec(ecSpec.getCurve().decodePoint(cardPubUncompressed), ecSpec); + ECPublicKey cardPub = (ECPublicKey) KeyFactory.getInstance("EC", "BC").generatePublic(cardKeySpec); + + KeyAgreement ka = KeyAgreement.getInstance("ECDH", "BC"); + ka.init(clientPriv); + ka.doPhase(cardPub, true); + return ka.generateSecret(); + } catch (Exception e) { + throw new RuntimeException("ECDH key agreement failed", e); + } + } + + /** + * HKDF-SHA256 (Extract-then-Expand) as defined in RFC 5869. + * Implements the N=1 case used by the protocol. + * + * @param salt the salt (32 bytes) + * @param ikm the input keying material (shared secret) + * @param info the context and application specific information + * @param length the length of the output keying material + * @return the derived key material + */ + private byte[] hkdfExpand(byte[] salt, byte[] ikm, byte[] info, short length) { + try { + // Extract: PRK = HMAC-SHA256(salt, IKM) + javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(salt, "HmacSHA256")); + byte[] prk = mac.doFinal(ikm); + + // Expand: OKM = HKDF-Expand(PRK, info, L) + // T(0) = empty string (no input to MAC) + // T(1) = HMAC-Hash(PRK, T(0) || info || 0x01) + mac = javax.crypto.Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(prk, "HmacSHA256")); + mac.update(info); + mac.update((byte) 0x01); + return mac.doFinal(); + } catch (Exception e) { + throw new RuntimeException("HKDF failed", e); + } + } + + /** + * Encrypts plaintext with AES-128-CCM using the client-to-card key. + * + * @param plaintext the data to encrypt + * @return ciphertext with appended 8-byte tag + */ + private byte[] encryptCCM(byte[] plaintext) { + try { + byte[] nonce = Arrays.copyOf(nonceCounter, CCM_NONCE_SIZE); + GCMParameterSpec spec = new GCMParameterSpec(CCM_TAG_SIZE * 8, nonce); + + aesCCM.init(Cipher.ENCRYPT_MODE, keyH2C, spec); + byte[] ciphertext = aesCCM.doFinal(plaintext); + + return ciphertext; // CCM appends tag automatically + } catch (Exception e) { + throw new RuntimeException("AES-CCM encryption failed", e); + } + } + + /** + * Decrypts ciphertext with AES-128-CCM using the card-to-client key. + * + * @param ciphertext the data to decrypt (includes appended 8-byte tag) + * @return the decrypted plaintext + */ + private byte[] decryptCCM(byte[] ciphertext) { + try { + byte[] nonce = Arrays.copyOf(nonceCounter, CCM_NONCE_SIZE); + GCMParameterSpec spec = new GCMParameterSpec(CCM_TAG_SIZE * 8, nonce); + + aesCCM.init(Cipher.DECRYPT_MODE, keyC2H, spec); + return aesCCM.doFinal(ciphertext); + } catch (Exception e) { + open = false; + throw new RuntimeException("AES-CCM decryption failed", e); + } + } + + /** + * Increments the 13-byte nonce counter as a big-endian integer. + * + * @throws RuntimeException on overflow (2^104 would require session reset) + */ + private void incrementNonce() { + for (int i = CCM_NONCE_SIZE - 1; i >= 0; i--) { + nonceCounter[i]++; + if (nonceCounter[i] != 0) { + return; + } + } + // Overflow — session must be reset + open = false; + throw new RuntimeException("Nonce counter overflow — secure channel session expired"); + } +} diff --git a/lib/src/main/java/im/status/keycard/applet/TinyBERTLV.java b/lib/src/main/java/im/status/keycard/applet/TinyBERTLV.java index 66a480f..84f7d04 100644 --- a/lib/src/main/java/im/status/keycard/applet/TinyBERTLV.java +++ b/lib/src/main/java/im/status/keycard/applet/TinyBERTLV.java @@ -147,6 +147,21 @@ public int readTag() { return (pos < buffer.length) ? buffer[pos++] : END_OF_TLV; } + /** + * Checks the next tag. The current implementation only reads tags on one byte. Can be extended if needed. + * + * @return the tag + */ + public boolean nextTagIs(int expectedTag) { + int nextTag = readTag(); + + if (nextTag != END_OF_TLV) { + unreadLastTag(); + } + + return nextTag == expectedTag; + } + /** * Reads the next tag. The current implementation only reads length on one and two bytes. Can be extended if needed. * diff --git a/lib/src/main/java/im/status/keycard/io/LedgerUtil.java b/lib/src/main/java/im/status/keycard/io/LedgerUtil.java deleted file mode 100644 index 8def964..0000000 --- a/lib/src/main/java/im/status/keycard/io/LedgerUtil.java +++ /dev/null @@ -1,153 +0,0 @@ -package im.status.keycard.io; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; - -public class LedgerUtil { - private static final int LEDGER_DEFAULT_CHANNEL = 1; - private static final int TAG_APDU = 0x05; - - private LedgerUtil() {} - - public interface Callback { - void write(byte[] chunk) throws IOException; - void read(byte[] chunk) throws IOException; - } - - public static APDUResponse send(APDUCommand cmd, int segmentSize, boolean channelInfo, LedgerUtil.Callback cb) throws IOException { - int offset = 0; - - byte[] command = LedgerUtil.wrapCommandAPDU(cmd.serialize(), segmentSize, channelInfo); - byte[] chunk = new byte[segmentSize]; - - while(offset != command.length) { - System.arraycopy(command, offset, chunk, 0, segmentSize); - cb.write(chunk); - offset += segmentSize; - } - - ByteArrayOutputStream response = new ByteArrayOutputStream(); - byte[] responseData = null; - - while ((responseData = LedgerUtil.unwrapResponseAPDU(response.toByteArray(), segmentSize, channelInfo)) == null) { - cb.read(chunk); - response.write(chunk, 0, segmentSize); - } - - return new APDUResponse(responseData); - } - - private static byte[] unwrapResponseAPDU(byte[] data, int segmentSize, boolean channelInfo) throws IOException { - if ((data == null) || (data.length < 7 + 5)) { - return null; - } - - int sequenceIdx = 0; - int offset = checkResponseHeader(data, 0, sequenceIdx, channelInfo); - - int responseLength = ((data[offset++] & 0xff) << 8); - responseLength |= (data[offset++] & 0xff); - - if (data.length < 7 + responseLength) { - return null; - } - - ByteArrayOutputStream response = new ByteArrayOutputStream(); - - int headerSize = channelInfo ? 5 : 3; - int initialHeaderSize = headerSize + 2; - - int blockSize = (responseLength > segmentSize - initialHeaderSize ? segmentSize - initialHeaderSize : responseLength); - response.write(data, offset, blockSize); - offset += blockSize; - - while (response.size() != responseLength) { - sequenceIdx++; - - if (offset == data.length) { - return null; - } - - offset = checkResponseHeader(data, offset, sequenceIdx, channelInfo); - - blockSize = (responseLength - response.size() > segmentSize - headerSize ? segmentSize - headerSize : responseLength - response.size()); - if (blockSize > data.length - offset) { - return null; - } - response.write(data, offset, blockSize); - offset += blockSize; - } - - return response.toByteArray(); - } - - private static int checkResponseHeader(byte[] data, int offset, int sequenceIdx, boolean channelInfo) throws IOException { - if (channelInfo) { - if (data[offset++] != (LEDGER_DEFAULT_CHANNEL >> 8)) { - throw new IOException("Invalid channel"); - } - - if (data[offset++] != (LEDGER_DEFAULT_CHANNEL & 0xff)) { - throw new IOException("Invalid channel"); - } - } - - if (data[offset++] != TAG_APDU) { - throw new IOException("Invalid tag"); - } - - if (data[offset++] != (sequenceIdx >> 8)) { - throw new IOException("Invalid sequence"); - } - - if (data[offset++] != (sequenceIdx & 0xff)) { - throw new IOException("Invalid sequence"); - } - return offset; - } - - private static byte[] wrapCommandAPDU(byte[] command, int segmentSize, boolean channelInfo) { - ByteArrayOutputStream output = new ByteArrayOutputStream(); - - int headerSize = channelInfo ? 5 : 3; - int initialHeaderSize = headerSize + 2; - - int sequenceIdx = 0; - int offset = 0; - writeCommandHeader(output, sequenceIdx, channelInfo); - sequenceIdx++; - - output.write(command.length >> 8); - output.write(command.length); - int blockSize = (command.length > (segmentSize - initialHeaderSize) ? (segmentSize - initialHeaderSize) : command.length); - output.write(command, offset, blockSize); - offset += blockSize; - - while (offset != command.length) { - writeCommandHeader(output, sequenceIdx, channelInfo); - sequenceIdx++; - - blockSize = ((command.length - offset) > (segmentSize - headerSize) ? (segmentSize - headerSize) : (command.length - offset)); - output.write(command, offset, blockSize); - offset += blockSize; - } - - if ((output.size() % segmentSize) != 0) { - byte[] padding = new byte[segmentSize - (output.size() % segmentSize)]; - output.write(padding, 0, padding.length); - } - - return output.toByteArray(); - } - - private static void writeCommandHeader(ByteArrayOutputStream output, int sequenceIdx, boolean channelInfo) { - if (channelInfo) { - output.write(LEDGER_DEFAULT_CHANNEL >> 8); - output.write(LEDGER_DEFAULT_CHANNEL); - } - - output.write(TAG_APDU); - output.write(sequenceIdx >> 8); - output.write(sequenceIdx); - } -} diff --git a/settings.gradle b/settings.gradle index bf5b867..0d6f95f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,3 @@ include 'lib' -include 'android' -include 'desktop' -include 'demo-android' +//include 'android' +include 'desktop' \ No newline at end of file