diff --git a/.runDebug/GreenAndroid.run.xml b/.runDebug/GreenAndroid.run.xml new file mode 100644 index 000000000..032e62a9b --- /dev/null +++ b/.runDebug/GreenAndroid.run.xml @@ -0,0 +1,72 @@ + + + + \ No newline at end of file diff --git a/common/src/androidMain/kotlin/com/blockstream/common/devices/ActivityProvider.kt b/common/src/androidMain/kotlin/com/blockstream/common/devices/ActivityProvider.kt new file mode 100644 index 000000000..2ca832674 --- /dev/null +++ b/common/src/androidMain/kotlin/com/blockstream/common/devices/ActivityProvider.kt @@ -0,0 +1,7 @@ +package com.blockstream.common.devices + +import android.app.Activity + +interface ActivityProvider { + fun getCurrentActivity(): Activity? +} \ No newline at end of file diff --git a/common/src/androidMain/kotlin/com/blockstream/common/devices/AndroidActivityProvider.kt b/common/src/androidMain/kotlin/com/blockstream/common/devices/AndroidActivityProvider.kt new file mode 100644 index 000000000..8692f772c --- /dev/null +++ b/common/src/androidMain/kotlin/com/blockstream/common/devices/AndroidActivityProvider.kt @@ -0,0 +1,21 @@ +package com.blockstream.common.devices + +import android.app.Activity +import java.lang.ref.WeakReference + +// In Android module +class AndroidActivityProvider : ActivityProvider { + private var weakActivity: WeakReference? = null + + fun setActivity(activity: Activity) { + weakActivity = WeakReference(activity) + } + + override fun getCurrentActivity(): Activity? { + return weakActivity?.get() + } + + fun clearActivity() { + weakActivity = null + } +} \ No newline at end of file diff --git a/common/src/androidMain/kotlin/com/blockstream/common/devices/ApduCommand.kt b/common/src/androidMain/kotlin/com/blockstream/common/devices/ApduCommand.kt new file mode 100644 index 000000000..63d0180ea --- /dev/null +++ b/common/src/androidMain/kotlin/com/blockstream/common/devices/ApduCommand.kt @@ -0,0 +1,92 @@ +package com.blockstream.common.devices + +import java.io.ByteArrayOutputStream +import java.io.IOException + +/** + * ISO7816-4 APDU. + */ +class ApduCommand { + public val cla: Int + public val ins: Int + public val p1: Int + public val p2: Int + public val data: ByteArray + public val needsLE: Boolean + + /** + * Constructs an APDU with no response data length field. The data field cannot be null, but can be a zero-length array. + * + * @param cla class byte + * @param ins instruction code + * @param p1 P1 parameter + * @param p2 P2 parameter + * @param data the APDU data + */ + constructor(cla: Int, ins: Int, p1: Int, p2: Int, data: ByteArray) : this(cla, ins, p1, p2, data, false) + + /** + * Constructs an APDU with an optional data length field. The data field cannot be null, but can be a zero-length array. + * The LE byte, if sent, is set to 0. + * + * @param cla class byte + * @param ins instruction code + * @param p1 P1 parameter + * @param p2 P2 parameter + * @param data the APDU data + * @param needsLE whether the LE byte should be sent or not + */ + constructor(cla: Int, ins: Int, p1: Int, p2: Int, data: ByteArray, needsLE: Boolean) { + this.cla = cla and 0xff + this.ins = ins and 0xff + this.p1 = p1 and 0xff + this.p2 = p2 and 0xff + this.data = data + this.needsLE = needsLE + } + + /** + * Serializes the APDU in order to send it to the card. + * + * @return the byte array representation of the APDU + */ + @Throws(IOException::class) + fun serialize(): ByteArray { + val out = ByteArrayOutputStream() + out.write(cla) + out.write(ins) + out.write(p1) + out.write(p2) + out.write(data.size) + out.write(data) + + if (needsLE) { + out.write(0) // Response length + } + + return out.toByteArray() + } + + /** + * Serializes the APDU to human readable hex string format + * + * @return the hex string representation of the APDU + */ + fun toHexString(): String { + return try { + val raw = serialize() + StringBuilder(2 * raw.size).apply { + raw.forEach { b -> + append(HEXES[(b.toInt() and 0xF0) shr 4]) + append(HEXES[b.toInt() and 0x0F]) + } + }.toString() + } catch (e: Exception) { + "Exception in ApduCommand.toHexString()" + } + } + + companion object { + const val HEXES = "0123456789ABCDEF" + } +} \ No newline at end of file diff --git a/common/src/androidMain/kotlin/com/blockstream/common/devices/ApduException.kt b/common/src/androidMain/kotlin/com/blockstream/common/devices/ApduException.kt new file mode 100644 index 000000000..28c37920d --- /dev/null +++ b/common/src/androidMain/kotlin/com/blockstream/common/devices/ApduException.kt @@ -0,0 +1,27 @@ +package com.blockstream.common.devices + +/** + * Exception thrown when the response APDU from the card contains unexpected SW or data. + */ +class ApduException : Exception { + val sw: Int + + /** + * Creates an exception with SW and message. + * + * @param sw the status word + * @param message a descriptive message of the error + */ + constructor(sw: Int, message: String) : super("$message, 0x${String.format("%04X", sw)}") { + this.sw = sw + } + + /** + * Creates an exception with a message. + * + * @param message a descriptive message of the error + */ + constructor(message: String) : super(message) { + this.sw = 0 + } +} \ No newline at end of file diff --git a/common/src/androidMain/kotlin/com/blockstream/common/devices/ApduResponse.kt b/common/src/androidMain/kotlin/com/blockstream/common/devices/ApduResponse.kt new file mode 100644 index 000000000..84c5e2499 --- /dev/null +++ b/common/src/androidMain/kotlin/com/blockstream/common/devices/ApduResponse.kt @@ -0,0 +1,158 @@ +package com.blockstream.common.devices + +/** + * ISO7816-4 APDU response. + */ +class ApduResponse { + private var apdu: ByteArray = byteArrayOf() + private var data: ByteArray = byteArrayOf() + private var sw: Int = 0 + private var sw1: Int = 0 + private var sw2: Int = 0 + + /** + * Creates an APDU object by parsing the raw response from the card. + * + * @param apdu the raw response from the card. + */ + constructor(apdu: ByteArray) { + require(apdu.size >= 2) { "APDU response must be at least 2 bytes" } + this.apdu = apdu + parse() + } + + constructor(data: ByteArray, sw1: Byte, sw2: Byte) { + val apduArray = ByteArray(data.size + 2) + System.arraycopy(data, 0, apduArray, 0, data.size) + apduArray[data.size] = sw1 + apduArray[data.size + 1] = sw2 + this.apdu = apduArray + parse() + } + + /** + * Parses the APDU response, separating the response data from SW. + */ + private fun parse() { + val length = apdu.size + + sw1 = apdu[length - 2].toInt() and 0xff + sw2 = apdu[length - 1].toInt() and 0xff + sw = (sw1 shl 8) or sw2 + + data = ByteArray(length - 2) + System.arraycopy(apdu, 0, data, 0, length - 2) + } + + /** + * Returns true if the SW is 0x9000. + * + * @return true if the SW is 0x9000. + */ + fun isOK(): Boolean = sw == SW_OK + + /** + * Asserts that the SW is 0x9000. Throws an exception if it isn't + * + * @return this object, to simplify chaining + * @throws ApduException if the SW is not 0x9000 + */ + @Throws(ApduException::class) + fun checkOK(): ApduResponse = checkSW(SW_OK) + + /** + * Asserts that the SW is contained in the given list. Throws an exception if it isn't. + * + * @param codes the list of SWs to match. + * @return this object, to simplify chaining + * @throws ApduException if the SW is not 0x9000 + */ + @Throws(ApduException::class) + fun checkSW(vararg codes: Int): ApduResponse { + for (code in codes) { + if (sw == code) { + return this + } + } + + when (sw) { + SW_SECURITY_CONDITION_NOT_SATISFIED -> + throw ApduException(sw, "security condition not satisfied") + SW_AUTHENTICATION_METHOD_BLOCKED -> + throw ApduException(sw, "authentication method blocked") + else -> + throw ApduException(sw, "Unexpected error SW") + } + } + + /** + * Asserts that the SW is 0x9000. Throws an exception with the given message if it isn't + * + * @param message the error message + * @return this object, to simplify chaining + * @throws ApduException if the SW is not 0x9000 + */ + @Throws(ApduException::class) + fun checkOK(message: String): ApduResponse = checkSW(message, SW_OK) + + /** + * Asserts that the SW is contained in the given list. Throws an exception with the given message if it isn't. + * + * @param message the error message + * @param codes the list of SWs to match. + * @return this object, to simplify chaining + * @throws ApduException if the SW is not 0x9000 + */ + @Throws(ApduException::class) + fun checkSW(message: String, vararg codes: Int): ApduResponse { + for (code in codes) { + if (sw == code) { + return this + } + } + throw ApduException(sw, message) + } + + /** + * Serializes the APDU to human readable hex string format + * + * @return the hex string representation of the APDU + */ + fun toHexString(): String { + return try { + if (apdu.isEmpty()) { + "" + } else { + StringBuilder(2 * apdu.size).apply { + apdu.forEach { b -> + append(HEXES[(b.toInt() and 0xF0) shr 4]) + append(HEXES[b.toInt() and 0x0F]) + } + }.toString() + } + } catch (e: Exception) { + "Exception in ApduResponse.toHexString()" + } + } + + companion object { + const val SW_OK = 0x9000 + const val SW_SECURITY_CONDITION_NOT_SATISFIED = 0x6982 + const val SW_AUTHENTICATION_METHOD_BLOCKED = 0x6983 + const val SW_CARD_LOCKED = 0x6283 + const val SW_REFERENCED_DATA_NOT_FOUND = 0x6A88 + const val SW_CONDITIONS_OF_USE_NOT_SATISFIED = 0x6985 // applet may be already installed + const val SW_WRONG_PIN_MASK = 0x63C0 + const val SW_WRONG_PIN_LEGACY = 0x9C02 + const val SW_BLOCKED_PIN = 0x9C0C + const val SW_FACTORY_RESET = 0xFF00 + const val HEXES = "0123456789ABCDEF" + } + + // Getters converted to Kotlin properties + fun getData(): ByteArray = data + fun getSw(): Int = sw + fun getSw1(): Int = sw1 + fun getSw2(): Int = sw2 + fun getBytes(): ByteArray = apdu +} \ No newline at end of file diff --git a/common/src/androidMain/kotlin/com/blockstream/common/devices/CardChannel.kt b/common/src/androidMain/kotlin/com/blockstream/common/devices/CardChannel.kt new file mode 100644 index 000000000..c742d77d9 --- /dev/null +++ b/common/src/androidMain/kotlin/com/blockstream/common/devices/CardChannel.kt @@ -0,0 +1,21 @@ +package com.blockstream.common.devices + +/** + * A channel to transcieve ISO7816-4 APDUs. + */ +interface CardChannel { + /** + * Sends the given C-APDU and returns an R-APDU. + * + * @param cmd the command to send + * @return the card response + * @throws IOException communication error + */ + fun send(cmd: ApduCommand): ApduResponse + + /** + * True if connected, false otherwise + * @return true if connected, false otherwise + */ + fun isConnected(): Boolean +} \ No newline at end of file diff --git a/common/src/androidMain/kotlin/com/blockstream/common/devices/CardListener.kt b/common/src/androidMain/kotlin/com/blockstream/common/devices/CardListener.kt new file mode 100644 index 000000000..e54eceb73 --- /dev/null +++ b/common/src/androidMain/kotlin/com/blockstream/common/devices/CardListener.kt @@ -0,0 +1,18 @@ +package com.blockstream.common.devices + +/** + * Listener for card connection events. + */ +interface CardListener { + /** + * Executes when the card channel is connected. + * + * @param channel the connected card channel + */ + fun onConnected(channel: CardChannel) + + /** + * Executes when a previously connected card is disconnected. + */ + fun onDisconnected() +} \ No newline at end of file diff --git a/common/src/androidMain/kotlin/com/blockstream/common/devices/DeviceManagerAndroid.kt b/common/src/androidMain/kotlin/com/blockstream/common/devices/DeviceManagerAndroid.kt index a49500319..2e472b04d 100644 --- a/common/src/androidMain/kotlin/com/blockstream/common/devices/DeviceManagerAndroid.kt +++ b/common/src/androidMain/kotlin/com/blockstream/common/devices/DeviceManagerAndroid.kt @@ -10,6 +10,7 @@ import android.content.Intent import android.content.IntentFilter import android.hardware.usb.UsbDevice import android.hardware.usb.UsbManager +import android.nfc.NfcAdapter import androidx.core.content.ContextCompat import androidx.core.content.IntentCompat import com.benasher44.uuid.Uuid @@ -25,20 +26,26 @@ import com.juul.kable.PlatformAdvertisement import com.juul.kable.peripheral import java.lang.ref.WeakReference - class DeviceManagerAndroid constructor( scope: ApplicationScope, + val activityProvider: ActivityProvider, // provide Activity reference needed by NfcAdapter val context: Context, sessionManager: SessionManager, bluetoothManager: BluetoothManager, val usbManager: UsbManager, supportedBleDevices: List, val deviceMapper: ( - deviceManager: DeviceManagerAndroid, usbDevice: UsbDevice?, bleService: Uuid?, + deviceManager: DeviceManagerAndroid, + usbDevice: UsbDevice?, + bleService: Uuid?, peripheral: Peripheral?, - isBonded: Boolean? + isBonded: Boolean?, + nfcDevice: NfcDevice?, + activityProvider: ActivityProvider?, ) -> AndroidDevice? -): DeviceManager(scope, sessionManager, bluetoothManager, supportedBleDevices) { +): CardListener, DeviceManager(scope, sessionManager, bluetoothManager, supportedBleDevices) { + + private val nfcAdapter = NfcAdapter.getDefaultAdapter(context) private var onPermissionSuccess: WeakReference<(() -> Unit)>? = null private var onPermissionError: WeakReference<((throwable: Throwable?) -> Unit)>? = null @@ -89,8 +96,9 @@ class DeviceManagerAndroid constructor( ContextCompat.RECEIVER_EXPORTED ) - scanUsbDevices() + scanNfcDevices() + scanUsbDevices() } override fun advertisedDevice(advertisement: PlatformAdvertisement) { @@ -103,7 +111,7 @@ class DeviceManagerAndroid constructor( val peripheral = scope.peripheral(advertisement) val bleService = advertisement.uuids.firstOrNull() - deviceMapper.invoke(this, null, bleService, peripheral, advertisement.isBonded()) + deviceMapper.invoke(this, null, bleService, peripheral, advertisement.isBonded(), null,null) ?.also { addBluetoothDevice(it) } @@ -124,7 +132,7 @@ class DeviceManagerAndroid constructor( override fun refreshDevices(){ super.refreshDevices() - + scanNfcDevices() scanUsbDevices() } @@ -149,7 +157,7 @@ class DeviceManagerAndroid constructor( // Jade or UsbDeviceMapper (JadeUsbDevice.fromUsbDevice(deviceManager = this, usbDevice = usbDevice) - ?: deviceMapper.invoke(this, usbDevice, null, null, null))?.let { + ?: deviceMapper.invoke(this, usbDevice, null, null, null, null,null))?.let { newDevices += it } } @@ -158,6 +166,87 @@ class DeviceManagerAndroid constructor( usbDevices.value = oldDevices + newDevices } + fun scanNfcDevices() { + logger.i { "Scan for NFC devices" } + + val cardManager = NfcCardManager() + cardManager.setCardListener(this) + cardManager.start() + + val activity = activityProvider.getCurrentActivity() + + nfcAdapter?.enableReaderMode( + activity, + cardManager, + NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_NFC_B or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, + null + ) + } + + override fun onConnected(channel: CardChannel) { + logger.i { "SATODEBUG DeviceManagerAndroid onConnected" } + for (nfcDeviceType in NfcDeviceType.entries) { + try { + val cmdSet = SatochipCommandSet(channel) + + // try to select applet according to device candidate + cmdSet.cardSelect(nfcDeviceType).checkOK() + + // device found, create new device + val nfcDevice = NfcDevice(nfcDeviceType) + + // for Satochip devices, get card status + if (nfcDeviceType == NfcDeviceType.SATOCHIP){ + val statusApdu = cmdSet.satochipGetStatus().checkOK() + + val statusBytes = statusApdu.getData() + val statusSize = statusBytes.size + logger.i { "SATODEBUG DeviceManagerAndroid onConnected: statusSize: $statusSize" } + + // check if card is seeded + val isSeeded = if ((statusBytes[9].toInt() == 0X00)) false else true + nfcDevice.isSeeded = isSeeded + logger.i { "SATODEBUG DeviceManagerAndroid onConnected: isSeeded: $isSeeded" } + + // check that 2FA is not enabled + val needs2FA = if ((statusBytes[8].toInt() == 0X00)) false else true + logger.i { "SATODEBUG DeviceManagerAndroid onConnected: needs2FA: $needs2FA" } + + // check if Liquid is supported + val supportsLiquid = if ((statusSize>15) && (statusBytes[15].toInt() == 0X00)) true else false + nfcDevice.supportsLiquid = supportsLiquid + logger.i { "SATODEBUG DeviceManagerAndroid onConnected: supportsLiquid: $supportsLiquid" } + } + + // add device + val newDevices = mutableListOf() + deviceMapper.invoke(this, null, null, null, null, nfcDevice, activityProvider) + ?.let { + newDevices += it + } + logger.i { "SATODEBUG DeviceManagerAndroid onConnected newDevices: ${newDevices}" } + nfcDevices.value = newDevices + + // disconnect card + onDisconnected() + + // stop polling + val activity = activityProvider.getCurrentActivity() + nfcAdapter?.disableReaderMode(activity) + + // should probably avoid to connect to multiple NFC devices at the same time? + return + } catch (e: Exception) { + logger.i { "SATODEBUG DeviceManagerAndroid onConnected: failed to connect to device: $nfcDeviceType" } + } + } + } + + override fun onDisconnected() { + logger.i { "SATODEBUG DeviceManagerAndroid onDisconnected: Card disconnected!" } + } + + companion object : Loggable() { private const val ACTION_USB_PERMISSION = "com.blockstream.green.USB_PERMISSION" } diff --git a/common/src/androidMain/kotlin/com/blockstream/common/devices/NfcCardChannel.kt b/common/src/androidMain/kotlin/com/blockstream/common/devices/NfcCardChannel.kt new file mode 100644 index 000000000..49d220b8f --- /dev/null +++ b/common/src/androidMain/kotlin/com/blockstream/common/devices/NfcCardChannel.kt @@ -0,0 +1,32 @@ +package com.blockstream.common.devices + +import android.nfc.tech.IsoDep +import android.util.Log + +/** + * Implementation of the CardChannel interface using the Android NFC API. + */ +class NfcCardChannel(private val isoDep: IsoDep) : CardChannel { + companion object { + private const val TAG = "CardChannel" + } + + override fun send(cmd: ApduCommand): ApduResponse { + val apdu = cmd.serialize() + Log.d(TAG, "COMMAND CLA: %02X INS: %02X P1: %02X P2: %02X LC: %02X".format( + cmd.cla, cmd.ins, cmd.p1, cmd.p2, cmd.data.size + )) + + val resp = isoDep.transceive(apdu) + val response = ApduResponse(resp) + Log.d(TAG, "RESPONSE LEN: %02X, SW: %04X %n-----------------------".format( + response.getData().size, response.getSw() + )) + + return response + } + + override fun isConnected(): Boolean { + return isoDep.isConnected + } +} \ No newline at end of file diff --git a/common/src/androidMain/kotlin/com/blockstream/common/devices/NfcCardManager.kt b/common/src/androidMain/kotlin/com/blockstream/common/devices/NfcCardManager.kt new file mode 100644 index 000000000..dc161c27a --- /dev/null +++ b/common/src/androidMain/kotlin/com/blockstream/common/devices/NfcCardManager.kt @@ -0,0 +1,101 @@ +package com.blockstream.common.devices + +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.IsoDep +import android.os.SystemClock +import android.util.Log +import kotlinx.io.IOException + +/** + * Manages connection of NFC-based cards. Extends Thread and must be started using the start() method. The thread has + * a runloop which monitors the connection and from which CardListener callbacks are called. + */ +class NfcCardManager @JvmOverloads constructor( + private val loopSleepMS: Long = DEFAULT_LOOP_SLEEP_MS +) : Thread(), NfcAdapter.ReaderCallback { + + private var isoDep: IsoDep? = null + private var isRunning = false + private var cardListener: CardListener? = null + + /** + * True if connected, false otherwise. + * @return if connected, false otherwise + */ + val isConnected: Boolean + get() = try { + isoDep?.isConnected == true + } catch (e: Exception) { + e.printStackTrace() + false + } + + override fun onTagDiscovered(tag: Tag) { + isoDep = IsoDep.get(tag)?.apply { + try { + connect() + timeout = 120000 + } catch (e: IOException) { + Log.e(TAG, "error connecting to tag") + } + } + } + + /** + * Runloop. Do NOT invoke directly. Use start() instead. + */ + override fun run() { + var connected = isConnected + + while (true) { + val newConnected = isConnected + if (newConnected != connected) { + connected = newConnected + Log.i(TAG, "tag ${if (connected) "connected" else "disconnected"}") + + if (connected && !isRunning) { + onCardConnected() + } else { + onCardDisconnected() + } + } + + SystemClock.sleep(loopSleepMS) + } + } + + /** + * Reacts on card connected by calling the callback of the registered listener. + */ + private fun onCardConnected() { + isRunning = true + + cardListener?.onConnected(NfcCardChannel(isoDep!!)) + + isRunning = false + } + + /** + * Reacts on card disconnected by calling the callback of the registered listener. + */ + private fun onCardDisconnected() { + isRunning = false + isoDep = null + cardListener?.onDisconnected() + } + + /** + * Sets the card listener. + * + * @param listener the new listener + */ + fun setCardListener(listener: CardListener) { + cardListener = listener + } + + companion object { + private const val TAG = "NFCCardManager" + private const val DEFAULT_LOOP_SLEEP_MS: Long = 50 + } +} \ No newline at end of file diff --git a/common/src/androidMain/kotlin/com/blockstream/common/devices/NfcDevice.kt b/common/src/androidMain/kotlin/com/blockstream/common/devices/NfcDevice.kt new file mode 100644 index 000000000..1f4955d90 --- /dev/null +++ b/common/src/androidMain/kotlin/com/blockstream/common/devices/NfcDevice.kt @@ -0,0 +1,13 @@ +package com.blockstream.common.devices + + +enum class NfcDeviceType { + SATOCHIP +} + +class NfcDevice(val type: NfcDeviceType) { + + var isSeeded = true + var supportsLiquid = true + +} \ No newline at end of file diff --git a/common/src/androidMain/kotlin/com/blockstream/common/devices/SatochipCommandSet.kt b/common/src/androidMain/kotlin/com/blockstream/common/devices/SatochipCommandSet.kt new file mode 100644 index 000000000..b5414b9eb --- /dev/null +++ b/common/src/androidMain/kotlin/com/blockstream/common/devices/SatochipCommandSet.kt @@ -0,0 +1,73 @@ +package com.blockstream.common.devices + +import java.io.IOException +import java.util.logging.Level +import java.util.logging.Logger + +/** + * 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. + */ +class SatochipCommandSet(private val apduChannel: CardChannel) { + + companion object { + private val logger: Logger = Logger.getLogger("org.satochip.client") + + val SATOCHIP_AID: ByteArray = hexToBytes("5361746f43686970") // SatoChip + + /* s must be an even-length string. */ + fun hexToBytes(s: String): ByteArray { + val len = s.length + val data = ByteArray(len / 2) + for (i in 0 until len step 2) { + data[i / 2] = ((Character.digit(s[i], 16) shl 4) + + Character.digit(s[i + 1], 16)).toByte() + } + return data + } + } + + // Satochip or... + private var cardType: NfcDeviceType = NfcDeviceType.SATOCHIP + + init { + logger.level = Level.WARNING + } + + @Throws(IOException::class) + fun cardSelect(nfcDeviceType: NfcDeviceType): ApduResponse { + val selectApplet = when (nfcDeviceType) { + NfcDeviceType.SATOCHIP-> ApduCommand(0x00, 0xA4, 0x04, 0x00, SATOCHIP_AID) + } + + logger.info("SATOCHIPLIB: C-APDU cardSelect:${selectApplet.toHexString()}") + val respApdu = apduChannel.send(selectApplet) + logger.info("SATOCHIPLIB: R-APDU cardSelect:${respApdu.toHexString()}") + + if (respApdu.getSw() == 0x9000) { + this.cardType = cardType + logger.info("SATOCHIPLIB: Satochip-java: CardSelect: found a ${this.cardType}") + } + return respApdu + } + + fun satochipGetStatus(): ApduResponse { + + val plainApdu: ApduCommand = ApduCommand( + 0xB0, + 0x3C, + 0x00, + 0x00, + ByteArray(0) + ) + + logger.info("SATOCHIPLIB: C-APDU satochipGetStatus:" + plainApdu.toHexString()) + val respApdu: ApduResponse = apduChannel.send(plainApdu) + logger.info("SATOCHIPLIB: R-APDU satochipGetStatus:" + respApdu.toHexString()) + + return respApdu + } + + +} \ No newline at end of file diff --git a/common/src/commonMain/composeResources/drawable/nfc_scan.xml b/common/src/commonMain/composeResources/drawable/nfc_scan.xml new file mode 100644 index 000000000..2a4ac0db6 --- /dev/null +++ b/common/src/commonMain/composeResources/drawable/nfc_scan.xml @@ -0,0 +1,13 @@ + + + diff --git a/common/src/commonMain/composeResources/values-cs/strings.xml b/common/src/commonMain/composeResources/values-cs/strings.xml index 7a032616f..bd5444dfe 100644 --- a/common/src/commonMain/composeResources/values-cs/strings.xml +++ b/common/src/commonMain/composeResources/values-cs/strings.xml @@ -1092,6 +1092,7 @@ Skenovat QR pomocí zařízení Jade Naskenujte QR kód pomocí autentikační aplikace Skenovat k odeslat sem + Naskenujte svou kartu Zámek obrazovky Hledat Hledat adresu diff --git a/common/src/commonMain/composeResources/values-de/strings.xml b/common/src/commonMain/composeResources/values-de/strings.xml index fc3532552..0d69510e7 100644 --- a/common/src/commonMain/composeResources/values-de/strings.xml +++ b/common/src/commonMain/composeResources/values-de/strings.xml @@ -1092,6 +1092,7 @@ Scan QR with Jade Scanne den QR-Code mit einer Authenticator-App Hier scannen und senden + Scannen Sie Ihre Karte Bildschirmsperre Suche Adresse suchen diff --git a/common/src/commonMain/composeResources/values-es/strings.xml b/common/src/commonMain/composeResources/values-es/strings.xml index 00e01132f..a3ec3f1a9 100644 --- a/common/src/commonMain/composeResources/values-es/strings.xml +++ b/common/src/commonMain/composeResources/values-es/strings.xml @@ -1092,6 +1092,7 @@ Scan QR with Jade Escanee el código QR con una app autenticadora Escanear para enviar aquí + Escanea tu tarjeta Bloqueo de pantalla Buscar Buscar una dirección diff --git a/common/src/commonMain/composeResources/values-fr/strings.xml b/common/src/commonMain/composeResources/values-fr/strings.xml index 5ebf12150..dab204199 100644 --- a/common/src/commonMain/composeResources/values-fr/strings.xml +++ b/common/src/commonMain/composeResources/values-fr/strings.xml @@ -1092,6 +1092,7 @@ Scan QR with Jade Scannez le code QR avec une application Authenticator. Scanner pour envoyer ici + Scannez votre carte Verrouillage de l'écran Recherche Recherche d'adresse diff --git a/common/src/commonMain/composeResources/values-he/strings.xml b/common/src/commonMain/composeResources/values-he/strings.xml index c2c4adda5..4b1d08aad 100644 --- a/common/src/commonMain/composeResources/values-he/strings.xml +++ b/common/src/commonMain/composeResources/values-he/strings.xml @@ -1092,6 +1092,7 @@ Scan QR with Jade Scan the QR Code with an Authenticator app Scan to send here + Scan your card Screen Lock Search Search address diff --git a/common/src/commonMain/composeResources/values-it/strings.xml b/common/src/commonMain/composeResources/values-it/strings.xml index f8961816e..a79909dc3 100644 --- a/common/src/commonMain/composeResources/values-it/strings.xml +++ b/common/src/commonMain/composeResources/values-it/strings.xml @@ -1092,6 +1092,7 @@ Scan QR with Jade Scansiona il QR code con una app Authenticator Scansiona per inviare qui + Scansiona la tua carta Blocca Schermo Cerca Cerca indirizzo diff --git a/common/src/commonMain/composeResources/values-ja/strings.xml b/common/src/commonMain/composeResources/values-ja/strings.xml index 3b5c260c1..f3d9ebb89 100644 --- a/common/src/commonMain/composeResources/values-ja/strings.xml +++ b/common/src/commonMain/composeResources/values-ja/strings.xml @@ -1092,6 +1092,7 @@ Scan QR with Jade AuthenticatorアプリでQRコードをスキャンする スキャンしてここに送る + カードをスキャン 画面ロック 検索 アドレスを検索する diff --git a/common/src/commonMain/composeResources/values-ko/strings.xml b/common/src/commonMain/composeResources/values-ko/strings.xml index 297a75652..a3e875277 100644 --- a/common/src/commonMain/composeResources/values-ko/strings.xml +++ b/common/src/commonMain/composeResources/values-ko/strings.xml @@ -721,6 +721,7 @@ Scan QR code Scan the QR Code with an Authenticator app Scan to send here + 카드를 스캔하세요 화면 잠금 검색 Search address diff --git a/common/src/commonMain/composeResources/values-nl/strings.xml b/common/src/commonMain/composeResources/values-nl/strings.xml index 7603ea594..f77ba30c5 100644 --- a/common/src/commonMain/composeResources/values-nl/strings.xml +++ b/common/src/commonMain/composeResources/values-nl/strings.xml @@ -1092,6 +1092,7 @@ Scan QR with Jade Scan de QR-code met een Authenticator-app Scan hier om te sturen + Scan je kaart Schermvergrendeling Zoeken Adres zoeken diff --git a/common/src/commonMain/composeResources/values-pt-rBR/strings.xml b/common/src/commonMain/composeResources/values-pt-rBR/strings.xml index da338a77c..47ae1ee11 100644 --- a/common/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/common/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -1092,6 +1092,7 @@ Scan QR with Jade Leia o código QR usando o app autenticador Leia este código QR ou envie para o endereço abaixo + Escaneie seu cartão Bloqueio de tela Buscar Procurar endereço diff --git a/common/src/commonMain/composeResources/values-ro/strings.xml b/common/src/commonMain/composeResources/values-ro/strings.xml index 4cdceff14..d44a8ce3a 100644 --- a/common/src/commonMain/composeResources/values-ro/strings.xml +++ b/common/src/commonMain/composeResources/values-ro/strings.xml @@ -1092,6 +1092,7 @@ Scan QR with Jade Scanați un cod QR folosind aplicația Authenticator Scanați pentru a trimite aici + Scanați cardul dumneavoastră Blocare ecran Căutare Caută adresa diff --git a/common/src/commonMain/composeResources/values-ru/strings.xml b/common/src/commonMain/composeResources/values-ru/strings.xml index 54e994a68..f68599dae 100644 --- a/common/src/commonMain/composeResources/values-ru/strings.xml +++ b/common/src/commonMain/composeResources/values-ru/strings.xml @@ -1092,6 +1092,7 @@ Scan QR with Jade Отсканируйте QR-код с помощью приложения Authenticator Отсканируйте, чтобы отправить сюда + Сканируйте вашу карту Блокировка экрана Поиск Адрес поиска diff --git a/common/src/commonMain/composeResources/values-uk/strings.xml b/common/src/commonMain/composeResources/values-uk/strings.xml index fa1889427..ff0a2cc5f 100644 --- a/common/src/commonMain/composeResources/values-uk/strings.xml +++ b/common/src/commonMain/composeResources/values-uk/strings.xml @@ -1092,6 +1092,7 @@ Scan QR with Jade Відскануте QR-код за допомогою додатку Authenticator Відскануйте, щоб відправити сюди + Скануйте вашу картку Блокування екрану Пошук Адреса пошуку diff --git a/common/src/commonMain/composeResources/values-vi/strings.xml b/common/src/commonMain/composeResources/values-vi/strings.xml index adafc802c..afbae403d 100644 --- a/common/src/commonMain/composeResources/values-vi/strings.xml +++ b/common/src/commonMain/composeResources/values-vi/strings.xml @@ -1092,6 +1092,7 @@ Scan QR with Jade Scan the QR Code with an Authenticator app Scan to send here + Quét thẻ của bạn Khóa màn hình Tìm kiếm Search address diff --git a/common/src/commonMain/composeResources/values-zh/strings.xml b/common/src/commonMain/composeResources/values-zh/strings.xml index e416d70de..0d69d581a 100644 --- a/common/src/commonMain/composeResources/values-zh/strings.xml +++ b/common/src/commonMain/composeResources/values-zh/strings.xml @@ -1092,6 +1092,7 @@ Scan QR with Jade 使用Authenticator app扫描二维码 扫描并发送到这里 + 扫描您的卡片 锁屏 搜索 搜索地址 diff --git a/common/src/commonMain/composeResources/values/strings.xml b/common/src/commonMain/composeResources/values/strings.xml index 403963e58..f7bc13a5d 100644 --- a/common/src/commonMain/composeResources/values/strings.xml +++ b/common/src/commonMain/composeResources/values/strings.xml @@ -1092,6 +1092,7 @@ Scan QR with Jade Scan the QR Code with an Authenticator app Scan to send here + Scan your card Screen Lock Search Search address diff --git a/common/src/commonMain/kotlin/com/blockstream/common/devices/ConnectionType.kt b/common/src/commonMain/kotlin/com/blockstream/common/devices/ConnectionType.kt index 199a2ee3c..ed9ea4bb7 100644 --- a/common/src/commonMain/kotlin/com/blockstream/common/devices/ConnectionType.kt +++ b/common/src/commonMain/kotlin/com/blockstream/common/devices/ConnectionType.kt @@ -1,5 +1,5 @@ package com.blockstream.common.devices enum class ConnectionType { - USB, BLUETOOTH, QR + USB, BLUETOOTH, QR, NFC } \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/blockstream/common/devices/DeviceBrand.kt b/common/src/commonMain/kotlin/com/blockstream/common/devices/DeviceBrand.kt index 7d53188af..07ea3619b 100644 --- a/common/src/commonMain/kotlin/com/blockstream/common/devices/DeviceBrand.kt +++ b/common/src/commonMain/kotlin/com/blockstream/common/devices/DeviceBrand.kt @@ -1,7 +1,7 @@ package com.blockstream.common.devices enum class DeviceBrand(val brand: String) { - Blockstream("Blockstream"), Ledger("Ledger"), Trezor("Trezor"), Generic("Generic"); + Blockstream("Blockstream"), Ledger("Ledger"), Trezor("Trezor"), Satochip("Satochip"), Generic("Generic"); val isTrezor get() = this == Trezor @@ -12,6 +12,9 @@ enum class DeviceBrand(val brand: String) { val isJade get() = this == Blockstream + val isSatochip + get() = this == Satochip + val isGeneric get() = this == Generic diff --git a/common/src/commonMain/kotlin/com/blockstream/common/devices/DeviceModel.kt b/common/src/commonMain/kotlin/com/blockstream/common/devices/DeviceModel.kt index 1e842f806..be07d5186 100644 --- a/common/src/commonMain/kotlin/com/blockstream/common/devices/DeviceModel.kt +++ b/common/src/commonMain/kotlin/com/blockstream/common/devices/DeviceModel.kt @@ -12,6 +12,7 @@ enum class DeviceModel(val deviceModel: String) { LedgerGeneric("Ledger"), LedgerNanoS("Ledger Nano S"), LedgerNanoX("Ledger Nano X"), + SatochipGeneric("Satochip"), Generic("Generic Hardware Wallet"); val deviceBrand: DeviceBrand @@ -20,6 +21,7 @@ enum class DeviceModel(val deviceModel: String) { TrezorGeneric, TrezorModelT, TrezorModelOne -> DeviceBrand.Trezor LedgerGeneric, LedgerNanoS, LedgerNanoX -> DeviceBrand.Ledger Generic -> DeviceBrand.Generic + SatochipGeneric -> DeviceBrand.Satochip } val isJade: Boolean @@ -38,6 +40,7 @@ enum class DeviceModel(val deviceModel: String) { LedgerNanoX -> "ledger_nano_x" TrezorGeneric -> "trezor" LedgerGeneric -> "ledger" + SatochipGeneric -> "satochip" Generic -> "generic" } } \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/blockstream/common/devices/GreenDevice.kt b/common/src/commonMain/kotlin/com/blockstream/common/devices/GreenDevice.kt index da0dd2521..735e770e6 100644 --- a/common/src/commonMain/kotlin/com/blockstream/common/devices/GreenDevice.kt +++ b/common/src/commonMain/kotlin/com/blockstream/common/devices/GreenDevice.kt @@ -40,6 +40,7 @@ interface GreenDevice: DeviceOperatingNetwork { val isBonded: Boolean val isUsb: Boolean val isBle: Boolean + val isNfc: Boolean val deviceState: StateFlow val firmwareState: StateFlow val name: String @@ -47,6 +48,7 @@ interface GreenDevice: DeviceOperatingNetwork { val isJade: Boolean val isTrezor: Boolean val isLedger: Boolean + val isSatochip: Boolean val isOffline: Boolean val isConnected: Boolean val heartbeat: Long @@ -105,6 +107,9 @@ abstract class GreenDeviceImpl constructor( override val isBle get() = type == ConnectionType.BLUETOOTH + override val isNfc + get() = type == ConnectionType.NFC + override val isOffline: Boolean get() = deviceState.value == DeviceState.DISCONNECTED @@ -120,6 +125,9 @@ abstract class GreenDeviceImpl constructor( override val isLedger: Boolean get() = deviceBrand.isLedger + override val isSatochip: Boolean + get() = deviceBrand.isSatochip + override var gdkHardwareWallet: GdkHardwareWallet? by Delegates.observable(null) { _, _, gdkHardwareWallet -> logger.i { "Set GdkHardwareWallet" } diff --git a/common/src/commonMain/kotlin/com/blockstream/common/gdk/GdkSession.kt b/common/src/commonMain/kotlin/com/blockstream/common/gdk/GdkSession.kt index 49463d565..5341c21ea 100644 --- a/common/src/commonMain/kotlin/com/blockstream/common/gdk/GdkSession.kt +++ b/common/src/commonMain/kotlin/com/blockstream/common/gdk/GdkSession.kt @@ -1346,6 +1346,7 @@ class GdkSession constructor( DeviceBrand.Ledger -> DeviceModel.LedgerGeneric DeviceBrand.Trezor -> DeviceModel.TrezorGeneric DeviceBrand.Generic -> DeviceModel.Generic + DeviceBrand.Satochip -> DeviceModel.SatochipGeneric } } diff --git a/common/src/commonMain/kotlin/com/blockstream/common/gdk/data/Device.kt b/common/src/commonMain/kotlin/com/blockstream/common/gdk/data/Device.kt index aae192bf6..068fa2bc8 100644 --- a/common/src/commonMain/kotlin/com/blockstream/common/gdk/data/Device.kt +++ b/common/src/commonMain/kotlin/com/blockstream/common/gdk/data/Device.kt @@ -35,6 +35,9 @@ data class Device constructor( val isLedger get() = name.lowercase() == "ledger" + val isSatochip + get() = name.lowercase() == "satochip" + override fun kSerializer(): KSerializer { return serializer() } diff --git a/common/src/commonMain/kotlin/com/blockstream/common/gdk/device/GdkHardwareWallet.kt b/common/src/commonMain/kotlin/com/blockstream/common/gdk/device/GdkHardwareWallet.kt index ffa7d1016..c4e3c5dec 100644 --- a/common/src/commonMain/kotlin/com/blockstream/common/gdk/device/GdkHardwareWallet.kt +++ b/common/src/commonMain/kotlin/com/blockstream/common/gdk/device/GdkHardwareWallet.kt @@ -25,6 +25,7 @@ interface HardwareWalletInteraction{ fun interactionRequest(gdkHardwareWallet: GdkHardwareWallet, message: String?, isMasterBlindingKeyRequest: Boolean, completable: CompletableDeferred?) fun requestPinMatrix(deviceBrand: DeviceBrand?): String? fun requestPassphrase(deviceBrand: DeviceBrand?): String? + fun requestNfcToast(deviceBrand: DeviceBrand?, message: String?, completable: CompletableDeferred?) } abstract class GdkHardwareWallet { diff --git a/common/src/commonMain/kotlin/com/blockstream/common/managers/DeviceManager.kt b/common/src/commonMain/kotlin/com/blockstream/common/managers/DeviceManager.kt index be8fc0b8c..96bd341eb 100644 --- a/common/src/commonMain/kotlin/com/blockstream/common/managers/DeviceManager.kt +++ b/common/src/commonMain/kotlin/com/blockstream/common/managers/DeviceManager.kt @@ -55,6 +55,7 @@ open class DeviceManager constructor( protected val usbDevices = MutableStateFlow>(listOf()) private val bleDevices = MutableStateFlow>(listOf()) + protected val nfcDevices = MutableStateFlow>(listOf()) private val _status = MutableStateFlow(ScanStatus.Stopped) val status = _status.asStateFlow() @@ -71,8 +72,8 @@ open class DeviceManager constructor( } } - val devices = combine(usbDevices, bleDevices, disconnectEvent) { usb, ble, _ -> - ble.filter { it.deviceState.value == DeviceState.CONNECTED } + usb + val devices = combine(usbDevices, bleDevices, disconnectEvent, nfcDevices) { usb, ble, _, nfc-> + ble.filter { it.deviceState.value == DeviceState.CONNECTED } + usb + nfc }.stateIn(scope, SharingStarted.Eagerly, emptyList()) var savedDevice: GreenDevice? = null diff --git a/common/src/commonMain/kotlin/com/blockstream/common/models/GreenViewModel.kt b/common/src/commonMain/kotlin/com/blockstream/common/models/GreenViewModel.kt index c01c2a019..631dfbe1c 100644 --- a/common/src/commonMain/kotlin/com/blockstream/common/models/GreenViewModel.kt +++ b/common/src/commonMain/kotlin/com/blockstream/common/models/GreenViewModel.kt @@ -236,7 +236,8 @@ open class GreenViewModel constructor( val promo: MutableStateFlow = MutableStateFlow(null) private var promoImpression: Boolean = false - private var _deviceRequest: CompletableDeferred? = null + //private var _deviceRequest: CompletableDeferred? = null //satodebug use compagnon object + private var _bootstrapped: Boolean = false open val isLoginRequired: Boolean = greenWalletOrNull != null @@ -871,6 +872,10 @@ open class GreenViewModel constructor( } } + final override fun requestNfcToast(deviceBrand: DeviceBrand?, message: String?, completable: CompletableDeferred?) { + postSideEffect(SideEffects.DeviceRequestNfcToast(message, completable = completable)) + } + protected open suspend fun denominatedValue(): DenominatedValue? = null protected open fun setDenominatedValue(denominatedValue: DenominatedValue) { } protected open fun errorReport(exception: Throwable): SupportData? { return null} @@ -1019,5 +1024,6 @@ open class GreenViewModel constructor( companion object: Loggable(){ fun preview() = object : GreenViewModel() { } + private var _deviceRequest: CompletableDeferred? = null } } diff --git a/common/src/commonMain/kotlin/com/blockstream/common/sideeffects/SideEffects.kt b/common/src/commonMain/kotlin/com/blockstream/common/sideeffects/SideEffects.kt index d39510c0d..e4b2cea6b 100644 --- a/common/src/commonMain/kotlin/com/blockstream/common/sideeffects/SideEffects.kt +++ b/common/src/commonMain/kotlin/com/blockstream/common/sideeffects/SideEffects.kt @@ -60,6 +60,7 @@ class SideEffects : SideEffect { data object AppReview: SideEffect data object DeviceRequestPassphrase: SideEffect data object DeviceRequestPin: SideEffect + data class DeviceRequestNfcToast(val message: String?, val completable: CompletableDeferred?): SideEffect data class DeviceInteraction( val deviceId: String?, val message: String?, diff --git a/compose/src/androidMain/kotlin/com/blockstream/compose/devices/SatochipDevice.kt b/compose/src/androidMain/kotlin/com/blockstream/compose/devices/SatochipDevice.kt new file mode 100644 index 000000000..1b40373ba --- /dev/null +++ b/compose/src/androidMain/kotlin/com/blockstream/compose/devices/SatochipDevice.kt @@ -0,0 +1,73 @@ +package com.blockstream.compose.devices + +import android.content.Context +import android.hardware.usb.UsbDevice +import com.blockstream.common.devices.ActivityProvider +import com.blockstream.common.devices.AndroidDevice +import com.blockstream.common.devices.ConnectionType +import com.blockstream.common.devices.DeviceBrand +import com.blockstream.common.devices.DeviceManagerAndroid +import com.blockstream.common.devices.GreenDevice +import com.blockstream.common.devices.NfcDevice +import com.blockstream.common.devices.NfcDeviceType +import com.blockstream.common.gdk.Gdk +import com.blockstream.common.gdk.data.Network +import com.blockstream.common.gdk.device.HardwareConnectInteraction +import com.blockstream.common.utils.Loggable +import com.juul.kable.Peripheral + +class SatochipDevice constructor( + context: Context, + deviceManager: DeviceManagerAndroid, + usbDevice: UsbDevice? = null, + type: ConnectionType, + peripheral: Peripheral? = null, + isBonded: Boolean = false, + nfcDevice: NfcDevice? = null, + activityProvider: ActivityProvider? = null +) : AndroidDevice( + context = context, + deviceManager = deviceManager, + usbDevice = usbDevice, + deviceBrand = DeviceBrand.Satochip, + type = type, + peripheral = peripheral, + isBonded = isBonded +) { + + val nfcDevice: NfcDevice? = nfcDevice + val activityProvider: ActivityProvider? = activityProvider + + override suspend fun getOperatingNetworkForEnviroment(greenDevice: GreenDevice, gdk: Gdk, isTestnet: Boolean): Network = + if (isTestnet) gdk.networks().testnetBitcoinElectrum else gdk.networks().bitcoinElectrum + + override suspend fun getOperatingNetwork( + greenDevice: GreenDevice, + gdk: Gdk, + interaction: HardwareConnectInteraction + ): Network? = interaction.requestNetwork() + + companion object : Loggable() { + + fun fromNfcDevice( + deviceManager: DeviceManagerAndroid, + nfcDevice: NfcDevice, + activityProvider: ActivityProvider?, + ): SatochipDevice? { + if (nfcDevice.type == NfcDeviceType.SATOCHIP) { + return SatochipDevice( + context = deviceManager.context, + deviceManager = deviceManager, + type = ConnectionType.NFC, + usbDevice = null, + nfcDevice = nfcDevice, + activityProvider = activityProvider, + ) + } else { + return null + } + } + + + } +} \ No newline at end of file diff --git a/compose/src/androidMain/kotlin/com/blockstream/compose/managers/DeviceConnectionManagerAndroid.kt b/compose/src/androidMain/kotlin/com/blockstream/compose/managers/DeviceConnectionManagerAndroid.kt index bf18e8680..cc700b490 100644 --- a/compose/src/androidMain/kotlin/com/blockstream/compose/managers/DeviceConnectionManagerAndroid.kt +++ b/compose/src/androidMain/kotlin/com/blockstream/compose/managers/DeviceConnectionManagerAndroid.kt @@ -1,5 +1,6 @@ package com.blockstream.compose.managers +import android.app.Activity import android.bluetooth.BluetoothAdapter import com.blockstream.common.data.AppInfo import com.blockstream.common.devices.AndroidDevice @@ -14,6 +15,7 @@ import com.blockstream.common.gdk.data.Network import com.blockstream.common.gdk.device.HardwareConnectInteraction import com.blockstream.common.interfaces.ConnectionResult import com.blockstream.compose.devices.LedgerDevice +import com.blockstream.compose.devices.SatochipDevice import com.blockstream.compose.devices.TrezorDevice import com.blockstream.jade.HttpRequestHandler import com.blockstream.jade.JadeAPI @@ -26,6 +28,7 @@ import com.btchip.comm.BTChipTransport import com.btchip.comm.android.BTChipTransportAndroid import com.greenaddress.greenbits.wallets.BTChipHWWallet import com.greenaddress.greenbits.wallets.LedgerBLEAdapter +import com.greenaddress.greenbits.wallets.SatochipHWWallet import com.greenaddress.greenbits.wallets.TrezorHWWallet import com.satoshilabs.trezor.Trezor import kotlinx.coroutines.CoroutineScope @@ -58,6 +61,8 @@ class DeviceConnectionManagerAndroid constructor( connectTrezorDevice(it, interaction) } ?: (device as? LedgerDevice)?.let { connectLedgerDevice(it, interaction) + } ?: (device as? SatochipDevice)?.let { + connectSatochipDevice(it, interaction) } ?: super.connectDevice(device, httpRequestHandler, interaction)) } @@ -146,6 +151,34 @@ class DeviceConnectionManagerAndroid constructor( return ConnectionResult() } + private suspend fun connectSatochipDevice(device: SatochipDevice, interaction: HardwareConnectInteraction): ConnectionResult { + + if (device.nfcDevice?.isSeeded == false) { + interaction.showError("Satochip card is not setup with a seed! Please import a seed first.") + throw Exception("Satochip card is not setup with a seed! Please import a seed first.") + } + + val satoDevice = com.blockstream.common.gdk.data.Device( + name = "Satochip", + supportsArbitraryScripts = true, + supportsLowR = false, + supportsHostUnblinding = true, + supportsExternalBlinding = false, + supportsLiquid = if (device.nfcDevice?.supportsLiquid == true) DeviceSupportsLiquid.Lite else DeviceSupportsLiquid.None, + supportsAntiExfilProtocol = DeviceSupportsAntiExfilProtocol.None + ) + + val pin: String? = null; + //val pin: String? = satochipInteraction?.requestPassphrase(DeviceBrand.Satochip) + + // provide activity and context needed for NFC + val activity: Activity? = device.activityProvider?.getCurrentActivity() + + device.gdkHardwareWallet = SatochipHWWallet(satoDevice, pin, activity, device.context) + + return ConnectionResult() + } + private suspend fun connectLedgerDevice( device: LedgerDevice, interaction: HardwareConnectInteraction diff --git a/compose/src/commonMain/kotlin/com/blockstream/compose/extensions/Resources.kt b/compose/src/commonMain/kotlin/com/blockstream/compose/extensions/Resources.kt index 113a46947..61849f6d2 100644 --- a/compose/src/commonMain/kotlin/com/blockstream/compose/extensions/Resources.kt +++ b/compose/src/commonMain/kotlin/com/blockstream/compose/extensions/Resources.kt @@ -45,6 +45,7 @@ import blockstream_green.common.generated.resources.ledger_device import blockstream_green.common.generated.resources.lightning_fill import blockstream_green.common.generated.resources.liquid import blockstream_green.common.generated.resources.liquid_testnet +import blockstream_green.common.generated.resources.nfc_scan import blockstream_green.common.generated.resources.qr_code import blockstream_green.common.generated.resources.spv_error import blockstream_green.common.generated.resources.spv_in_progress @@ -112,6 +113,7 @@ fun GreenDevice?.actionIcon(): DrawableResource = this?.deviceModel?.actionIcon( fun DeviceBrand.deviceBrandIcon(): DrawableResource = when (this) { DeviceBrand.Ledger -> Res.drawable.ledger_device DeviceBrand.Trezor -> Res.drawable.trezor_device + DeviceBrand.Satochip -> Res.drawable.nfc_scan DeviceBrand.Generic -> Res.drawable.generic_device else -> Res.drawable.blockstream_devices } @@ -123,6 +125,7 @@ fun DeviceModel.icon(): DrawableResource = when (this) { DeviceModel.TrezorGeneric, DeviceModel.TrezorModelT, DeviceModel.TrezorModelOne -> Res.drawable.trezor_device DeviceModel.LedgerGeneric, DeviceModel.LedgerNanoS, DeviceModel.LedgerNanoX -> Res.drawable.ledger_device DeviceModel.Generic -> Res.drawable.generic_device + DeviceModel.SatochipGeneric -> Res.drawable.nfc_scan } fun DeviceModel.actionIcon(): DrawableResource = when (this) { diff --git a/compose/src/commonMain/kotlin/com/blockstream/compose/sheets/DeviceInteractionBottomSheet.kt b/compose/src/commonMain/kotlin/com/blockstream/compose/sheets/DeviceInteractionBottomSheet.kt index bb0271dca..91ee066bc 100644 --- a/compose/src/commonMain/kotlin/com/blockstream/compose/sheets/DeviceInteractionBottomSheet.kt +++ b/compose/src/commonMain/kotlin/com/blockstream/compose/sheets/DeviceInteractionBottomSheet.kt @@ -97,7 +97,7 @@ fun DeviceInteractionBottomSheet( val title = when { isMasterBlindingKeyRequest -> null - transactionConfirmLook != null || verifyAddress != null -> stringResource(Res.string.id_confirm_on_your_device) + transactionConfirmLook != null || verifyAddress != null -> if (viewModel.device?.isNfc == true) "Scan your card" else stringResource(Res.string.id_confirm_on_your_device) else -> { message?.stringOrNull() } diff --git a/compose/src/commonMain/kotlin/com/blockstream/compose/sheets/NfcToastBottomSheet.kt b/compose/src/commonMain/kotlin/com/blockstream/compose/sheets/NfcToastBottomSheet.kt new file mode 100644 index 000000000..02b78fd87 --- /dev/null +++ b/compose/src/commonMain/kotlin/com/blockstream/compose/sheets/NfcToastBottomSheet.kt @@ -0,0 +1,128 @@ +package com.blockstream.compose.sheets + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import blockstream_green.common.generated.resources.Res +import blockstream_green.common.generated.resources.blockstream_devices +import blockstream_green.common.generated.resources.id_change +import blockstream_green.common.generated.resources.id_confirm_on_your_device +import blockstream_green.common.generated.resources.id_fee +import blockstream_green.common.generated.resources.id_green_needs_the_master_blinding +import blockstream_green.common.generated.resources.id_sent_to +import blockstream_green.common.generated.resources.id_to_show_balances_and +import blockstream_green.common.generated.resources.nfc_scan +import blockstream_green.common.generated.resources.phone_keys +import cafe.adriel.voyager.koin.koinScreenModel +import com.blockstream.common.Parcelable +import com.blockstream.common.Parcelize +import com.blockstream.common.Urls +import com.blockstream.common.data.GreenWallet +import com.blockstream.common.events.Events +import com.blockstream.common.looks.transaction.TransactionConfirmLook +import com.blockstream.common.managers.DeviceManager +import com.blockstream.common.models.SimpleGreenViewModel +import com.blockstream.common.utils.StringHolder +import com.blockstream.compose.components.GreenAddress +import com.blockstream.compose.components.GreenAmount +import com.blockstream.compose.components.GreenBottomSheet +import com.blockstream.compose.components.GreenColumn +import com.blockstream.compose.components.LearnMoreButton +import com.blockstream.compose.extensions.actionIcon +import com.blockstream.compose.extensions.icon +import com.blockstream.compose.theme.bodyLarge +import com.blockstream.compose.theme.titleSmall +import com.blockstream.compose.theme.whiteMedium +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.koin.core.parameter.parametersOf + +@Parcelize +data class NfcToastBottomSheet( + val message: String? = null +) : BottomScreen(), Parcelable { + + @Composable + override fun Content() { + + + val viewModel = koinScreenModel { + parametersOf(null, null, "Scan card") + } + + NfcToastBottomSheet( + viewModel = viewModel, + message = StringHolder.create(message), + onDismissRequest = onDismissRequest() + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NfcToastBottomSheet( + viewModel: SimpleGreenViewModel, + message: StringHolder? = null, + onDismissRequest: () -> Unit, +) { + + val title = "Scan your card" //stringResource(Res.string.id_scan_your_card) + + val deviceIcon = viewModel.device?.icon() + + GreenBottomSheet( + title = title, + viewModel = viewModel, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + onDismissRequest = onDismissRequest + ) { + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + + Image( + //painter = painterResource(deviceIcon ?: Res.drawable.blockstream_devices), + //painter = painterResource(Res.drawable.phone_keys), + painter = painterResource(Res.drawable.nfc_scan), + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .height(160.dp) + .padding(bottom = 16.dp) + ) + + GreenColumn( + padding = 0, + space = 16, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .verticalScroll( + rememberScrollState() + ) + ) { + if (message != null) { + Text( + text = message.string(), + color = whiteMedium, + textAlign = TextAlign.Center, + style = titleSmall, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } +} diff --git a/compose/src/commonMain/kotlin/com/blockstream/compose/utils/SideEffects.kt b/compose/src/commonMain/kotlin/com/blockstream/compose/utils/SideEffects.kt index 6e063ddf0..d750e52b4 100644 --- a/compose/src/commonMain/kotlin/com/blockstream/compose/utils/SideEffects.kt +++ b/compose/src/commonMain/kotlin/com/blockstream/compose/utils/SideEffects.kt @@ -145,6 +145,7 @@ import com.blockstream.compose.sheets.JadeFirmwareUpdateBottomSheet import com.blockstream.compose.sheets.LightningNodeBottomSheet import com.blockstream.compose.sheets.LocalBottomSheetNavigatorM3 import com.blockstream.compose.sheets.NewJadeConnectedBottomSheet +import com.blockstream.compose.sheets.NfcToastBottomSheet import com.blockstream.compose.sheets.NoteBottomSheet import com.blockstream.compose.sheets.PassphraseBottomSheet import com.blockstream.compose.sheets.PinMatrixBottomSheet @@ -545,6 +546,18 @@ fun HandleSideEffect( bottomSheetNavigator?.show(PinMatrixBottomSheet) } + is SideEffects.DeviceRequestNfcToast -> { + val screen = bottomSheetNavigator?.show(NfcToastBottomSheet(message = it.message) ) + + scope.launch (context = handleException()) { + it.completable?.also { completable -> + completable.await() + } ?: run { delay(3000L) } + + bottomSheetNavigator?.hide(screen) + } + } + is SideEffects.DeviceInteraction -> { val screen = bottomSheetNavigator?.show( DeviceInteractionBottomSheet( diff --git a/gradle.properties b/gradle.properties index 894bfcec1..62d68bdfe 100644 --- a/gradle.properties +++ b/gradle.properties @@ -37,4 +37,7 @@ kapt.use.k2=true org.gradle.caching=true #org.gradle.configuration-cache=true -#kotlin.incremental.native=true \ No newline at end of file +#kotlin.incremental.native=true + +# satochip debug +org.gradle.dependency.verification=off \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aa091488c..c373e5558 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -74,6 +74,7 @@ room = "2.5.2" protobuf-java = "3.4.0" media3 = "1.4.0" +bouncycastle = "1.69" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } @@ -174,6 +175,8 @@ voyager-koin = { module = "cafe.adriel.voyager:voyager-koin", version.ref = "voy androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } +org-bouncycastle = { module = "org.bouncycastle:bcprov-jdk15to18", version.ref = "bouncycastle" } + [plugins] kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/green/build.gradle.kts b/green/build.gradle.kts index f613ced79..14c182fcc 100644 --- a/green/build.gradle.kts +++ b/green/build.gradle.kts @@ -200,8 +200,8 @@ dependencies { /** --- Koin ----------------------------------------------------------------------------- */ // For instrumentation tests - androidTestImplementation(libs.koin.test) - androidTestImplementation(libs.koin.test.junit4) + //androidTestImplementation(libs.koin.test) + //androidTestImplementation(libs.koin.test.junit4) // For local unit tests testImplementation(libs.koin.test) diff --git a/green/src/main/java/com/blockstream/green/GreenActivity.kt b/green/src/main/java/com/blockstream/green/GreenActivity.kt index 4a1e904ad..20d19a8f8 100644 --- a/green/src/main/java/com/blockstream/green/GreenActivity.kt +++ b/green/src/main/java/com/blockstream/green/GreenActivity.kt @@ -17,6 +17,7 @@ import com.blockstream.common.data.CredentialType import com.blockstream.common.data.GreenWallet import com.blockstream.common.database.Database import com.blockstream.common.database.LoginCredentials +import com.blockstream.common.devices.AndroidActivityProvider import com.blockstream.common.events.Events import com.blockstream.common.gdk.Gdk import com.blockstream.common.gdk.data.Network @@ -47,6 +48,7 @@ class GreenActivity : FragmentActivity() { private val database: Database by inject() private val sessionManager: SessionManager by inject() private val settingsManager by inject() + private val activityProvider: AndroidActivityProvider by inject() private val mainViewModel: MainViewModel by viewModel() @@ -54,6 +56,7 @@ class GreenActivity : FragmentActivity() { installSplashScreen() super.onCreate(savedInstanceState) + activityProvider.setActivity(this) enableEdgeToEdge() setContent { @@ -183,6 +186,11 @@ class GreenActivity : FragmentActivity() { countly.onStop() } + override fun onDestroy() { + super.onDestroy() + activityProvider.clearActivity() + } + override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) countly.onConfigurationChanged(newConfig) diff --git a/green/src/main/java/com/blockstream/green/di/GreenModules.kt b/green/src/main/java/com/blockstream/green/di/GreenModules.kt index cd85a8fd9..1d4108a50 100644 --- a/green/src/main/java/com/blockstream/green/di/GreenModules.kt +++ b/green/src/main/java/com/blockstream/green/di/GreenModules.kt @@ -6,12 +6,16 @@ import android.content.Context import android.hardware.usb.UsbDevice import android.hardware.usb.UsbManager import com.benasher44.uuid.Uuid +import com.blockstream.common.devices.ActivityProvider +import com.blockstream.common.devices.AndroidActivityProvider import com.blockstream.common.devices.DeviceManagerAndroid +import com.blockstream.common.devices.NfcDevice import com.blockstream.common.fcm.FcmCommon import com.blockstream.common.interfaces.DeviceConnectionInterface import com.blockstream.common.managers.DeviceManager import com.blockstream.common.managers.NotificationManager import com.blockstream.compose.devices.LedgerDevice +import com.blockstream.compose.devices.SatochipDevice import com.blockstream.compose.devices.TrezorDevice import com.blockstream.compose.managers.DeviceConnectionManager import com.blockstream.compose.managers.DeviceConnectionManagerAndroid @@ -36,15 +40,19 @@ val greenModules = module { get() ) } binds (arrayOf(NotificationManagerAndroid::class, NotificationManager::class)) + + single { AndroidActivityProvider() } binds arrayOf(ActivityProvider::class) + single { DeviceManagerAndroid( + get(), get(), androidContext(), get(), get(), get(), listOf(LedgerDeviceBLE.SERVICE_UUID.toString(), JadeBleConnection.JADE_SERVICE) - ) { deviceManagerAndroid: DeviceManagerAndroid, usbDevice: UsbDevice?, bleService: Uuid?, peripheral: Peripheral?, isBonded: Boolean? -> + ) { deviceManagerAndroid: DeviceManagerAndroid, usbDevice: UsbDevice?, bleService: Uuid?, peripheral: Peripheral?, isBonded: Boolean?, nfcDevice: NfcDevice?, activityProvider: ActivityProvider? -> usbDevice?.let { TrezorDevice.fromUsbDevice(deviceManager = deviceManagerAndroid, usbDevice = usbDevice) ?: LedgerDevice.fromUsbDevice( @@ -53,6 +61,8 @@ val greenModules = module { ) } ?: peripheral?.let { LedgerDevice.fromScan(deviceManager = deviceManagerAndroid, bleService = bleService, peripheral = peripheral, isBonded = isBonded == true) + } ?: nfcDevice?.let { + SatochipDevice.fromNfcDevice(deviceManager = deviceManagerAndroid, nfcDevice= nfcDevice, activityProvider = activityProvider) } } } binds (arrayOf(DeviceManager::class, DeviceManagerAndroid::class)) diff --git a/hardware/build.gradle.kts b/hardware/build.gradle.kts index 714446cc7..b7bba24a0 100644 --- a/hardware/build.gradle.kts +++ b/hardware/build.gradle.kts @@ -42,6 +42,9 @@ dependencies { implementation(libs.jackson.datatype.json.org) /** ----------------------------------------------------------------------------------------- */ + /** For satochip */ + implementation(libs.org.bouncycastle) + testImplementation(libs.junit) testImplementation(libs.androidx.core.testing) testImplementation(libs.mockito.kotlin) diff --git a/hardware/src/main/java/com/greenaddress/greenbits/wallets/SatochipHWWallet.java b/hardware/src/main/java/com/greenaddress/greenbits/wallets/SatochipHWWallet.java new file mode 100644 index 000000000..f3ba54ba1 --- /dev/null +++ b/hardware/src/main/java/com/greenaddress/greenbits/wallets/SatochipHWWallet.java @@ -0,0 +1,763 @@ +package com.greenaddress.greenbits.wallets; + +import static io.ktor.util.CryptoKt.hex; + +import android.app.Activity; +import android.content.Context; +import android.nfc.NfcAdapter; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + +import com.blockstream.common.devices.DeviceBrand; +import com.blockstream.common.devices.DeviceModel; +import com.blockstream.common.gdk.data.Account; +import com.blockstream.common.gdk.data.Device; +import com.blockstream.common.gdk.data.InputOutput; +import com.blockstream.common.gdk.data.Network; +import com.blockstream.common.gdk.device.BlindingFactorsResult; +import com.blockstream.common.gdk.device.GdkHardwareWallet; +import com.blockstream.common.gdk.device.HardwareWalletInteraction; +import com.blockstream.common.gdk.device.SignMessageResult; +import com.blockstream.common.gdk.device.SignTransactionResult; +import com.blockstream.libwally.Wally; +import com.btchip.utils.BufferUtils; +import com.btchip.utils.VarintUtils; +import com.google.common.base.Joiner; +import com.satochip.ApduResponse; +import com.satochip.ApplicationStatus; +import com.satochip.Bip32Path; +import com.satochip.BlockedPINException; +import com.satochip.CardChannel; +import com.satochip.CardListener; +import com.satochip.NfcActionObject; +import com.satochip.NfcActionStatus; +import com.satochip.NfcActionType; +import com.satochip.NfcCardManager; +import com.satochip.SatochipCommandSet; +import com.satochip.SatochipException; +import com.satochip.SatochipParser; +import com.satochip.WrongPINException; +import com.satochip.WrongPINLegacyException; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import kotlinx.coroutines.CompletableDeferred; +import kotlinx.coroutines.CompletableDeferredKt; +import kotlinx.coroutines.flow.MutableStateFlow; + + +public class SatochipHWWallet extends GdkHardwareWallet implements CardListener { + + private static final String TAG = SatochipHWWallet.class.getSimpleName(); + + /** The string that prefixes all text messages signed using Bitcoin keys. */ + private static final String BITCOIN_SIGNED_MESSAGE_HEADER = "Bitcoin Signed Message:\n"; + private static final byte[] BITCOIN_SIGNED_MESSAGE_HEADER_BYTES = BITCOIN_SIGNED_MESSAGE_HEADER.getBytes(StandardCharsets.UTF_8); + + private static final byte SIGHASH_ALL = 1; + + private final Map mUserXPubs = new HashMap<>(); + + private final Device device; + private final DeviceModel model; + private final String firmwareVersion = "x.y-z.w"; //todo + + private String pin; + private final Activity activity; + private final Context context; + private final CardChannel channel = null; + private NfcAdapter nfcAdapter = null; + private NfcActionObject actionObject = new NfcActionObject(); + + + public SatochipHWWallet(Device device, String pin, Activity activity, Context context){ + this.device = device; + this.model = DeviceModel.SatochipGeneric; + this.pin = pin; + this.activity = activity; + this.context = context; + + NfcCardManager cardManager = new NfcCardManager(); + cardManager.setCardListener(this); + cardManager.start(); + + nfcAdapter = NfcAdapter.getDefaultAdapter(this.context); + nfcAdapter.enableReaderMode( + activity, + cardManager, + NfcAdapter.FLAG_READER_NFC_A | NfcAdapter.FLAG_READER_NFC_B | NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, + null + ); + + } + + @Override + public synchronized void disconnect() { + Log.i(TAG, "SATODEBUG SatochipHWWallet disconnect start"); + // No-op + } + + + public void onConnected(CardChannel channel) { + + Log.i(TAG, "SATODEBUG SatochipHWWallet onConnected() Card is connected"); + try { + + if (this.actionObject.actionType == NfcActionType.none) { + this.actionObject.actionStatus = NfcActionStatus.none; + Log.i(TAG, "SATODEBUG SatochipHWWallet onConnected() nothing to do => disconnection!"); + onDisconnected(); + return; + } + + SatochipCommandSet cmdSet = new SatochipCommandSet(channel); + + ApduResponse rapduSelect = cmdSet.cardSelect("satochip").checkOK(); + Log.i(TAG, "SATODEBUG SatochipHWWallet onConnected() applet selected"); + // cardStatus + ApduResponse rapduStatus = cmdSet.cardGetStatus();//To update status if it's not the first reading + ApplicationStatus cardStatus = cmdSet.getApplicationStatus(); //applicationStatus ?: return + Log.i(TAG, "SATODEBUG SatochipHWWallet readCard cardStatus: $cardStatus"); + + // verify PIN + if (this.pin != null) { + byte[] pinBytes = this.pin.getBytes(Charset.forName("UTF-8")); + ApduResponse rapduPin = cmdSet.cardVerifyPIN(pinBytes); + } else { + if (actionObject.hwInteraction != null){ + this.pin = actionObject.hwInteraction.requestPassphrase(DeviceBrand.Satochip); + byte[] pinBytes = this.pin.getBytes(Charset.forName("UTF-8")); + ApduResponse rapduPin = cmdSet.cardVerifyPIN(pinBytes); + } else { + throw new SatochipException(SatochipException.ExceptionReason.PIN_UNDEFINED); + } + } + + // execute commands depending on actionType + if (this.actionObject.actionType == NfcActionType.getXpubs){ + onConnectedGetXpubs(cmdSet); + } else if (this.actionObject.actionType == NfcActionType.signMessage) { + onConnectedSignMessage(cmdSet); + } else if (this.actionObject.actionType == NfcActionType.signTransaction){ + onConnectedSignTransaction(cmdSet); + } else if (this.actionObject.actionType == NfcActionType.getMasterBlindingKey){ + onConnectedGetMasterBlindingKey(cmdSet); + } + + // disconnect + Log.i(TAG, "SATODEBUG SatochipHWWallet onConnected() trigger disconnection!"); + onDisconnected(); + + } catch (SatochipException e) { + this.pin = null; + Log.e(TAG, "SATODEBUG onConnected: ERROR: "+ e); + onDisconnected(); + if (actionObject.hwInteraction != null){ + actionObject.hwInteraction.interactionRequest(this, "No PIN available", false, null); + } + } catch (WrongPINException e) { + this.pin = null; + Log.e(TAG, "SATODEBUG onConnected: WRONG PIN! "+ e); + onDisconnected(); + if (actionObject.hwInteraction != null){ + String message = "WRONG PIN!\n"+e.getRetryAttempts() + " tries remaining"; + actionObject.hwInteraction.interactionRequest(this, message, false, null); + } + } catch (WrongPINLegacyException e) { + this.pin = null; + Log.e(TAG, "SATODEBUG onConnected: WRONG PIN LEGACY! "+ e); + onDisconnected(); + if (actionObject.hwInteraction != null){ + String message = "WRONG PIN!\n"; + actionObject.hwInteraction.interactionRequest(this, message, false, null); + } + } catch (BlockedPINException e) { + this.pin = null; + Log.e(TAG, "SATODEBUG onConnected exception: "+ e); + onDisconnected(); + if (actionObject.hwInteraction != null){ + String message = "CARD BLOCKED!\nYou need to reset your card."; + actionObject.hwInteraction.interactionRequest(this, message, false, null); + } + } catch (Exception e) { + Log.e(TAG, "SATODEBUG onConnected exception: " + e); + e.printStackTrace(); + onDisconnected(); + } + } + + public void onConnectedGetXpubs(SatochipCommandSet cmdSet) throws Exception { + + // get paths + List> paths = this.actionObject.pathsParam; + final List xpubs = new ArrayList<>(paths.size()); + + for (List path : paths) { + Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedGetXpubs() path: " + path); + final String key = Joiner.on("/").join(path); + + if (!mUserXPubs.containsKey(key)) { + + Bip32Path bip32path = pathToBip32Path(path); +// Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedGetXpubs() depth: " + bip32path.getDepth()); //debug +// Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedGetXpubs() bip32path: " + hex(bip32path.getBytes())); //debug + + // get xpub from satochip + int xtype = this.actionObject.networkParam.getVerPublic(); + String xpub = cmdSet.cardBip32GetXpub(bip32path, xtype); + Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedGetXpubs() xpub: " + xpub); + + // cache xpub + mUserXPubs.put(key, xpub); + + }{ + Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedGetXpubs() recovered xpub from CACHE"); + } + // update xpubs list + xpubs.add(mUserXPubs.get(key)); + } + + // action finished + this.actionObject.xpubsResult = xpubs; + this.actionObject.actionStatus = NfcActionStatus.finished; + } + + public void onConnectedSignMessage(SatochipCommandSet cmdSet) throws Exception { + + // get path + List path = this.actionObject.pathParam; + Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignMessage() path: " + path); + + // derive key + Bip32Path bip32path = pathToBip32Path(path); + //Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignMessage() depth: " + bip32path.getDepth()); //debug + //Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignMessage() bip32path: " + hex(bip32path.getBytes())); //debug + byte[][] extendedKey = cmdSet.cardBip32GetExtendedKey(bip32path); + //Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignMessage() extendedKey_0: " + hex(extendedKey[0])); //debug + //Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignMessage() extendedKey_1: " + hex(extendedKey[1])); //debug + + // compute message hash + String message = this.actionObject.messageParam; + //String message = "DEBUG TEST SATOCHIP"; + Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignMessage() message: " + message); //debug + byte[] messageBytes = message.getBytes(StandardCharsets.UTF_8); + Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignMessage() messageBytes: " + hex(messageBytes)); //debug + byte[] formatedMessageBytes; + try { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + bos.write(BITCOIN_SIGNED_MESSAGE_HEADER_BYTES.length); + bos.write(BITCOIN_SIGNED_MESSAGE_HEADER_BYTES); + VarintUtils.write(bos, messageBytes.length); + bos.write(messageBytes); + formatedMessageBytes = bos.toByteArray(); + //Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignMessage() formatedMessageBytes: " + hex(formatedMessageBytes)); //debug + } catch (IOException e) { + throw new RuntimeException(e); // Cannot happen. + } + byte[] hashBytes = Wally.sha256d(formatedMessageBytes); // double hash + //Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignMessage() hashBytes: " + hex(hashBytes)); //debug + +// // debug +// byte[] messageBytesWally = Wally.format_bitcoin_message(messageBytes, 0); // should be equal to formatedMessageBytes +// Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignMessage() messageBytesWally: " + hex(messageBytesWally)); //debug +// byte[] messageBytesWally2 = Wally.format_bitcoin_message(messageBytes, 1); // should be equal to hashBytes +// Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignMessage() messageBytesWally2: " + hex(messageBytesWally2)); //debug + + // sign hash + byte keynbr = (byte) 0xFF; + byte[] chalresponse = null; + ApduResponse rapdu = cmdSet.cardSignHash(keynbr, hashBytes, chalresponse); + byte[] sigBytes = rapdu.getData(); + //Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignMessage() sigBytes: " + hex(sigBytes)); //debug + + // verify sig + SatochipParser parser = new SatochipParser(); + boolean isOk = parser.verifySig(Wally.sha256(formatedMessageBytes), sigBytes, extendedKey[0]); + Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignMessage() isOk: " + isOk); //debug + + // format signature + final String sigHex = Wally.hex_from_bytes(sigBytes); + Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignMessage() sigHex: " + sigHex); //debug + + // action finished + this.actionObject.signatureResult = sigHex; + this.actionObject.actionStatus = NfcActionStatus.finished; + } + + public void onConnectedSignTransaction(SatochipCommandSet cmdSet) throws Exception { + + // get paths, hashes & set sig result + final List> paths = this.actionObject.pathsParam; + final List hashes = this.actionObject.hashesParam; + final int inputSize = paths.size(); + List signaturesResult = new ArrayList<>(inputSize); + for (int i = 0; i < inputSize; ++i) { + + // get path + List path = paths.get(i); + //Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignTransaction() path: " + path); + + // derive key + Bip32Path bip32path = pathToBip32Path(path); + //Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignTransaction() depth: " + bip32path.getDepth()); //debug + //Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignTransaction() bip32path: " + hex(bip32path.getBytes())); //debug + byte[][] extendedKey = cmdSet.cardBip32GetExtendedKey(bip32path); + //Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignTransaction() extendedKey_0: " + hex(extendedKey[0])); //debug + //Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignTransaction() extendedKey_1: " + hex(extendedKey[1])); //debug + + // compress pubkey + byte[] compressedPubkey = Arrays.copyOf(extendedKey[0], Wally.EC_PUBLIC_KEY_LEN); + if (compressedPubkey[Wally.EC_PUBLIC_KEY_LEN-1]%2 == 0){ + compressedPubkey[0] = 0x02; + } else { + compressedPubkey[0] = 0x03; + } + + // get & sign hash + byte[] hashBytes = hashes.get(i); + byte keynbr = (byte) 0xFF; + byte[] chalresponse = null; + ApduResponse rapdu = cmdSet.cardSignHash(keynbr, hashBytes, chalresponse); + byte[] sigBytes = rapdu.getData(); + Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignTransaction() sigBytes: " + hex(sigBytes)); //debug + + // verify sig +// SatochipParser parser = new SatochipParser(); +// boolean isOk = parser.verifySig(hashBytes, sigBytes, extendedKey[0]); +// Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignTransaction() isOk: " + isOk); //debug + + // convert to compact + byte[] compactSigBytes = Wally.ec_sig_from_der(sigBytes); + Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignTransaction() compactSigBytes: " + hex(compactSigBytes)); //debug + + // verify sig +// int compactSigVerif = Wally.ec_sig_verify(compressedPubkey, hashBytes, Wally.EC_FLAG_ECDSA, compactSigBytes); +// Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignTransaction() compactSigVerif: " + compactSigVerif); //debug + + // sanitize sig + byte[] lowsCompactSigBytes = new byte[Wally.EC_SIGNATURE_LEN]; + Wally.ec_sig_normalize(compactSigBytes, lowsCompactSigBytes); + Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignTransaction() lowsCompactSigBytes: " + hex(lowsCompactSigBytes)); //debug + + // verify sig +// int lowsCompactSigVerif = Wally.ec_sig_verify(compressedPubkey, hashBytes, Wally.EC_FLAG_ECDSA, lowsCompactSigBytes); +// Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignTransaction() lowsCompactSigVerif: " + lowsCompactSigVerif); //debug + + // convert back to der + final byte[] derSigBytes = new byte[Wally.EC_SIGNATURE_DER_MAX_LEN]; + final int len = Wally.ec_sig_to_der(lowsCompactSigBytes, derSigBytes); + final String sigHex = Wally.hex_from_bytes(Arrays.copyOf(derSigBytes, len)) + "01"; + + Log.i(TAG, "SATODEBUG SatochipHWWallet onConnectedSignTransaction() sigHex: " + sigHex); //debug + signaturesResult.add(sigHex); + } + + // action finished + this.actionObject.signaturesResult = signaturesResult; + this.actionObject.actionStatus = NfcActionStatus.finished; + } + + public void onConnectedGetMasterBlindingKey(SatochipCommandSet cmdSet) throws Exception { + + byte[] blindingKey = cmdSet.cardBip32GetLiquidMasterBlindingKey(); + + // action finished + this.actionObject.blindingKeyResult = blindingKey; + this.actionObject.actionStatus = NfcActionStatus.finished; + } + + + public void onDisconnected() { + Log.i(TAG, "SATODEBUG SatochipHWWallet onDisconnected: Card disconnected!"); + } + + @NonNull + @Override + public synchronized List getXpubs(@NonNull Network network, @NonNull List> paths, @Nullable HardwareWalletInteraction hwInteraction) { + Log.i("SatochipHWWallet", "SATODEBUG SatochipHWWallet getXpubs start"); + Log.i("SatochipHWWallet", "SATODEBUG SatochipHWWallet getXpubs paths: " + paths); + + + // first step: check if xpubs are all available in cache + boolean isCached = true; + final List cachedXpubs = new ArrayList<>(paths.size()); + for (List path : paths) { + //Log.i(TAG, "SATODEBUG SatochipHWWallet getXpubs() path: " + path); + final String key = Joiner.on("/").join(path); + if (mUserXPubs.containsKey(key)) { + cachedXpubs.add(mUserXPubs.get(key)); + } else { + isCached = false; + } + } + if (isCached){ + Log.i(TAG, "SATODEBUG SatochipHWWallet getXpubs() XPUBS IN CACHE!"); + return cachedXpubs; + } + + CompletableDeferred completable = CompletableDeferredKt.CompletableDeferred(null); + + // request to card if not cached already + try { + if(hwInteraction != null) { + hwInteraction.requestNfcToast(DeviceBrand.Satochip, "Exporting xpub...", completable); + } + + this.actionObject.actionStatus = NfcActionStatus.busy; + this.actionObject.actionType = NfcActionType.getXpubs; + this.actionObject.networkParam = network; + this.actionObject.pathsParam = paths; + this.actionObject.hwInteraction = hwInteraction; + + // poll for result from cardListener onConnected + while (this.actionObject.actionStatus == NfcActionStatus.busy) { + TimeUnit.MILLISECONDS.sleep(500); + Log.i(TAG, "SATODEBUG SatochipHWWallet getXpubs() SLEEP"); + } + + // get result and reset action + this.actionObject.actionStatus = NfcActionStatus.none; + this.actionObject.actionType = NfcActionType.none; + final List xpubs = this.actionObject.xpubsResult; + return xpubs; + + } catch (Exception e) { + Log.e("SatochipHWWallet", "getXpubs exception: " + e); + } finally { + completable.complete(true); + } + + return null; + } + + @NonNull + @Override + public SignMessageResult signMessage(@NonNull List path, @NonNull String message, boolean useAeProtocol, @Nullable String aeHostCommitment, @Nullable String aeHostEntropy, @Nullable HardwareWalletInteraction hwInteraction) { + Log.i("SatochipHWWallet", "signMessage start"); + Log.i("SatochipHWWallet", "signMessage start path: " + path); + //Log.i("SatochipHWWallet", "signMessage start message: " + message); + + if (useAeProtocol) { + throw new RuntimeException("Hardware Wallet does not support the Anti-Exfil protocol"); + } + + CompletableDeferred completable = CompletableDeferredKt.CompletableDeferred(null); + + try { + if(hwInteraction != null) { + hwInteraction.requestNfcToast(DeviceBrand.Satochip, "Signing login message...", completable); + } + + this.actionObject.actionStatus = NfcActionStatus.busy; + this.actionObject.actionType = NfcActionType.signMessage; + this.actionObject.pathParam = path; + this.actionObject.messageParam = message; + this.actionObject.hwInteraction = hwInteraction; + + + // poll for result from cardListener onConnected + while (this.actionObject.actionStatus == NfcActionStatus.busy) { + TimeUnit.MILLISECONDS.sleep(500); + Log.i(TAG, "SATODEBUG SatochipHWWallet signMessage() SLEEP"); + } + + // get result and reset action + this.actionObject.actionStatus = NfcActionStatus.none; + this.actionObject.actionType = NfcActionType.none; + final String signatureResult = this.actionObject.signatureResult; + Log.i(TAG, "SATODEBUG SatochipHWWallet signMessage() signatureResult: " + signatureResult); + return new SignMessageResult(signatureResult, null); + } catch (Exception e) { + Log.e(TAG, "signMessage exception: " + e); + } finally { + completable.complete(true); + } + + //TODO + String signature = "TODO-SATOCHIP-SIGN-MSG"; + return new SignMessageResult(signature, null); + } + + @NonNull + @Override + public SignTransactionResult signTransaction(@NonNull Network network, @NonNull String transaction, @NonNull List inputs, @NonNull List outputs, @Nullable Map transactions, boolean useAeProtocol, @Nullable HardwareWalletInteraction hwInteraction) { + Log.i("SatochipHWWallet", "signTransaction start"); + + CompletableDeferred completable = CompletableDeferredKt.CompletableDeferred(null); + + final byte[] txBytes = Wally.hex_to_bytes(transaction); + + if(network.isLiquid()){ + + try { + if (hwInteraction != null) { + hwInteraction.requestNfcToast(DeviceBrand.Satochip, "Signing transaction...", completable); + } + + final Object wallyTx = Wally.tx_from_bytes(txBytes, Wally.WALLY_TX_FLAG_USE_ELEMENTS); + + // get tx hash signature for each inputs + final int inputSize = inputs.size(); + final List> pathsParam = new ArrayList<>(inputSize); + final List hashesParam = new ArrayList<>(inputSize); + for (int i = 0; i < inputSize; ++i) { + Log.i("SatochipHWWallet", "signTransaction() input index: " + i); + final InputOutput in = inputs.get(i); + Log.i("SatochipHWWallet", "signTransaction() inputs[i]: " + in); + final byte[] script = Wally.hex_to_bytes(in.getPrevoutScript()); + Log.i("SatochipHWWallet", "signTransaction() input script[i]: " + Wally.hex_from_bytes(script)); + final byte[] satoshi_bytes = Wally.hex_to_bytes(in.getCommitment()); // ok! + Log.i("SatochipHWWallet", "signTransaction() input satoshi_bytes[i]: " + Wally.hex_from_bytes(satoshi_bytes)); + final long sighash = SIGHASH_ALL; + final long flags = Wally.WALLY_TX_FLAG_USE_WITNESS; + + byte[] hash_out = new byte[32]; + Wally.tx_get_elements_signature_hash( + wallyTx, + i, + script, + satoshi_bytes, + sighash, + flags, + hash_out + ); + Log.i("SatochipHWWallet", "signTransaction() input hash_out[i]: " + Wally.hex_from_bytes(hash_out)); + hashesParam.add(hash_out); + + // get derivation path for each input + List path = in.getUserPathAsInts(); + Log.i("SatochipHWWallet", "signTransaction() input path[i]: " + path); + pathsParam.add(in.getUserPathAsInts()); + } + + // create action + this.actionObject.actionStatus = NfcActionStatus.busy; + this.actionObject.actionType = NfcActionType.signTransaction; + this.actionObject.pathsParam = pathsParam; + this.actionObject.hashesParam = hashesParam; + this.actionObject.hwInteraction = hwInteraction; + + // poll for result from cardListener onConnected + while (this.actionObject.actionStatus == NfcActionStatus.busy) { + TimeUnit.MILLISECONDS.sleep(500); + Log.i(TAG, "SATODEBUG SatochipHWWallet signTransaction() SLEEP"); + } + + // get result and reset action + this.actionObject.actionStatus = NfcActionStatus.none; + this.actionObject.actionType = NfcActionType.none; + List sigs = this.actionObject.signaturesResult; + Log.i(TAG, "SATODEBUG SatochipHWWallet signTransaction() signatureResult: " + sigs); + return new SignTransactionResult(sigs, null); + + } catch (final Exception e) { + e.printStackTrace(); + throw new RuntimeException("Signing Error: " + e.getMessage()); + } finally { + completable.complete(true); + } + + } + + try { + + if (useAeProtocol) { + throw new RuntimeException("Hardware Wallet does not support the Anti-Exfil protocol"); + } + + if(hwInteraction != null) { + hwInteraction.requestNfcToast(DeviceBrand.Satochip, "Signing transaction...", completable); + } + + final Object wallyTx = Wally.tx_from_bytes(txBytes, Wally.WALLY_TX_FLAG_USE_WITNESS); + + // get tx hash signature for each inputs + final int inputSize = inputs.size(); + final List> pathsParam = new ArrayList<>(inputSize); + final List hashesParam = new ArrayList<>(inputSize); + Log.i("SatochipHWWallet", "signTransaction() inputs.size(): " + inputSize); + for (int i = 0; i < inputSize; ++i) { + Log.i("SatochipHWWallet", "signTransaction() input index: " + i); + final InputOutput in = inputs.get(i); + Log.i("SatochipHWWallet", "signTransaction() inputs[i]: " + in); + final byte[] script = Wally.hex_to_bytes(in.getPrevoutScript()); + Log.i("SatochipHWWallet", "signTransaction() input script[i]: " + Wally.hex_from_bytes(script)); + final long satoshi = in.getSatoshi(); + Log.i("SatochipHWWallet", "signTransaction() input satoshi[i]: " + satoshi); + final long sighash = SIGHASH_ALL; + final long flags = Wally.WALLY_TX_FLAG_USE_WITNESS; + + byte[] hash_out = new byte[32]; + byte[] hash = Wally.tx_get_btc_signature_hash( + wallyTx, + i, + script, + satoshi, + sighash, + flags, + hash_out + ); + Log.i("SatochipHWWallet", "signTransaction() input hash_out[i]: " + Wally.hex_from_bytes(hash_out)); + hashesParam.add(hash_out); + + // get derivation path for each input + List path = in.getUserPathAsInts(); + Log.i("SatochipHWWallet", "signTransaction() input path[i]: " + path); + pathsParam.add(in.getUserPathAsInts()); + } + + // create action + this.actionObject.actionStatus = NfcActionStatus.busy; + this.actionObject.actionType = NfcActionType.signTransaction; + this.actionObject.pathsParam = pathsParam; + this.actionObject.hashesParam = hashesParam; + this.actionObject.hwInteraction = hwInteraction; + + // poll for result from cardListener onConnected + while (this.actionObject.actionStatus == NfcActionStatus.busy) { + TimeUnit.MILLISECONDS.sleep(500); + Log.i(TAG, "SATODEBUG SatochipHWWallet signTransaction() SLEEP"); + } + + // get result and reset action + this.actionObject.actionStatus = NfcActionStatus.none; + this.actionObject.actionType = NfcActionType.none; + List sigs = this.actionObject.signaturesResult; + Log.i(TAG, "SATODEBUG SatochipHWWallet signTransaction() signatureResult: " + sigs); + return new SignTransactionResult(sigs, null); + + } catch (final Exception e) { + e.printStackTrace(); + throw new RuntimeException("Signing Error: " + e.getMessage()); + } finally { + completable.complete(true); + } + + } + + + @NonNull + @Override + public synchronized String getMasterBlindingKey(@Nullable HardwareWalletInteraction hwInteraction) { + + CompletableDeferred completable = CompletableDeferredKt.CompletableDeferred(null); + + try { + if(hwInteraction != null) { + hwInteraction.requestNfcToast(DeviceBrand.Satochip, "Exporting Liquid Master Blinding Key...", completable); + } + + this.actionObject.actionStatus = NfcActionStatus.busy; + this.actionObject.actionType = NfcActionType.getMasterBlindingKey; + this.actionObject.hwInteraction = hwInteraction; + + // poll for result from cardListener onConnected + while (this.actionObject.actionStatus == NfcActionStatus.busy) { + TimeUnit.MILLISECONDS.sleep(500); + Log.i(TAG, "SATODEBUG SatochipHWWallet getMasterBlindingKey() SLEEP"); + } + + // get result and reset action + this.actionObject.actionStatus = NfcActionStatus.none; + this.actionObject.actionType = NfcActionType.none; + final byte[] blindingKey= this.actionObject.blindingKeyResult; + return Wally.hex_from_bytes(blindingKey); + + } catch (Exception e) { + Log.e("SatochipHWWallet", "getMasterBlindingKey exception: " + e); + } finally { + completable.complete(true); + } + + return null; + } + + @Override + public synchronized String getBlindingKey(String scriptHex, @Nullable HardwareWalletInteraction hwInteraction) { + throw new RuntimeException("Master Blinding Key is not supported"); + } + + @Override + public synchronized String getBlindingNonce(String pubkey, String scriptHex, @Nullable HardwareWalletInteraction hwInteraction) { + throw new RuntimeException("Master Blinding Key is not supported"); + } + + @Override + public synchronized BlindingFactorsResult getBlindingFactors(final List inputs, final List outputs, @Nullable HardwareWalletInteraction hwInteraction) { + throw new RuntimeException("Master Blinding Key is not supported"); + } + + @Override + public synchronized String getGreenAddress(final Network network, final Account account, final List path, final long csvBlocks, HardwareWalletInteraction hwInteraction) { + if (network.isMultisig()) { + throw new RuntimeException("Hardware Wallet does not support displaying Green Multisig Shield addresses"); + } + + if (network.isLiquid()) { + throw new RuntimeException("Hardware Wallet does not support displaying Liquid addresses"); + } + + // todo + return "TODO"; + } + + @Nullable + @Override + public MutableStateFlow getDisconnectEvent() { + Log.i("SatochipHWWallet", "SATODEBUG SatochipHWWallet getDisconnectEvent start"); + return null; + } + + @Nullable + @Override + public String getFirmwareVersion() { + Log.i("SatochipHWWallet", "SATODEBUG SatochipHWWallet getFirmwareVersion start"); + return firmwareVersion; + } + + @NonNull + @Override + public DeviceModel getModel() { + Log.i("SatochipHWWallet", "SATODEBUG SatochipHWWallet getModel start"); + return model; + } + + @NonNull + @Override + public Device getDevice() { + Log.i("SatochipHWWallet", "SATODEBUG SatochipHWWallet getDevice start"); + return device; + } + + + /* */ + private Bip32Path pathToBip32Path(final List path) throws Exception { + final int depth = path.size(); + if (depth == 0) { + Bip32Path bip32Path = new Bip32Path(0, new byte[0]); + } + if (depth > 10) { + throw new Exception("Path too long"); + } + ByteArrayOutputStream result = new ByteArrayOutputStream(); + for (final Integer element : path) { + BufferUtils.writeUint32BE(result, element); + } + Bip32Path bip32Path = new Bip32Path(depth, result.toByteArray()); + return bip32Path; + } + + +} diff --git a/hardware/src/main/java/com/satochip/ApduCommand.java b/hardware/src/main/java/com/satochip/ApduCommand.java new file mode 100644 index 000000000..820324dbf --- /dev/null +++ b/hardware/src/main/java/com/satochip/ApduCommand.java @@ -0,0 +1,147 @@ +package com.satochip; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +/** + * ISO7816-4 APDU. + */ +public class ApduCommand { + protected int cla; + protected int ins; + protected int p1; + protected int p2; + protected int lc; + protected byte[] data; + protected boolean needsLE; + public static final String HEXES = "0123456789ABCDEF"; + + /** + * Constructs an APDU with no response data length field. The data field cannot be null, but can be a zero-length array. + * + * @param cla class byte + * @param ins instruction code + * @param p1 P1 parameter + * @param p2 P2 parameter + * @param data the APDU data + */ + public ApduCommand(int cla, int ins, int p1, int p2, byte[] data) { + this(cla, ins, p1, p2, data, false); + } + + /** + * Constructs an APDU with an optional data length field. The data field cannot be null, but can be a zero-length array. + * The LE byte, if sent, is set to 0. + * + * @param cla class byte + * @param ins instruction code + * @param p1 P1 parameter + * @param p2 P2 parameter + * @param data the APDU data + * @param needsLE whether the LE byte should be sent or not + */ + public ApduCommand(int cla, int ins, int p1, int p2, byte[] data, boolean needsLE) { + this.cla = cla & 0xff; + this.ins = ins & 0xff; + this.p1 = p1 & 0xff; + this.p2 = p2 & 0xff; + this.data = data; + this.needsLE = needsLE; + } + + /** + * Serializes the APDU in order to send it to the card. + * + * @return the byte array representation of the APDU + */ + public byte[] serialize() throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(this.cla); + out.write(this.ins); + out.write(this.p1); + out.write(this.p2); + out.write(this.data.length); + out.write(this.data); + + if (this.needsLE) { + out.write(0); // Response length + } + + return out.toByteArray(); + } + + /** + * Serializes the APDU to human readable hex string format + * + * @return the hex string representation of the APDU + */ + public String toHexString() { + try{ + byte[] raw= this.serialize(); + if ( raw == null ) { + return ""; + } + final StringBuilder hex = new StringBuilder( 2 * raw.length ); + for ( final byte b : raw ) { + hex.append(HEXES.charAt((b & 0xF0) >> 4)).append(HEXES.charAt((b & 0x0F))); + } + return hex.toString(); + } catch (Exception e){ + return "Exception in ApduCommand.toHexString()"; + } + } + + /** + * Returns the CLA of the APDU + * + * @return the CLA of the APDU + */ + public int getCla() { + return cla; + } + + /** + * Returns the INS of the APDU + * + * @return the INS of the APDU + */ + public int getIns() { + return ins; + } + + /** + * Returns the P1 of the APDU + * + * @return the P1 of the APDU + */ + public int getP1() { + return p1; + } + + /** + * Returns the P2 of the APDU + * + * @return the P2 of the APDU + */ + public int getP2() { + return p2; + } + + /** + * Returns the data field of the APDU + * + * @return the data field of the APDU + */ + public byte[] getData() { + return data; + } + + /** + * Returns whether LE is sent or not. + * + * @return whether LE is sent or not + */ + public boolean getNeedsLE() { + return this.needsLE; + } +} diff --git a/hardware/src/main/java/com/satochip/ApduException.java b/hardware/src/main/java/com/satochip/ApduException.java new file mode 100644 index 000000000..b43b7390a --- /dev/null +++ b/hardware/src/main/java/com/satochip/ApduException.java @@ -0,0 +1,29 @@ +package com.satochip; + +/** + * Exception thrown when the response APDU from the card contains unexpected SW or data. + */ +public class ApduException extends Exception { + public final int sw; + + /** + * Creates an exception with SW and message. + * + * @param sw the status word + * @param message a descriptive message of the error + */ + public ApduException(int sw, String message) { + super(message + ", 0x" + String.format("%04X", sw)); + this.sw = sw; + } + + /** + * Creates an exception with a message. + * + * @param message a descriptive message of the error + */ + public ApduException(String message) { + super(message); + this.sw = 0; + } +} diff --git a/hardware/src/main/java/com/satochip/ApduResponse.java b/hardware/src/main/java/com/satochip/ApduResponse.java new file mode 100644 index 000000000..c406c0491 --- /dev/null +++ b/hardware/src/main/java/com/satochip/ApduResponse.java @@ -0,0 +1,218 @@ +package com.satochip; + +/** + * ISO7816-4 APDU response. + */ +public class ApduResponse { + public static final int SW_OK = 0x9000; + public static final int SW_SECURITY_CONDITION_NOT_SATISFIED = 0x6982; + public static final int SW_AUTHENTICATION_METHOD_BLOCKED = 0x6983; + public static final int SW_CARD_LOCKED = 0x6283; + public static final int SW_REFERENCED_DATA_NOT_FOUND = 0x6A88; + public static final int SW_CONDITIONS_OF_USE_NOT_SATISFIED = 0x6985; // applet may be already installed + public static final int SW_WRONG_PIN_MASK = 0x63C0; + public static final int SW_WRONG_PIN_LEGACY = 0x9C02; + public static final int SW_BLOCKED_PIN = 0x9C0C; + public static final int SW_FACTORY_RESET = 0xFF00; + public static final String HEXES = "0123456789ABCDEF"; + + private byte[] apdu; + private byte[] data; + private int sw; + private int sw1; + private int sw2; + + /** + * Creates an APDU object by parsing the raw response from the card. + * + * @param apdu the raw response from the card. + */ + public ApduResponse(byte[] apdu) { + if (apdu.length < 2) { + throw new IllegalArgumentException("APDU response must be at least 2 bytes"); + } + this.apdu = apdu; + this.parse(); + } + + public ApduResponse(byte[] data, byte sw1, byte sw2) { + byte[] apdu= new byte[data.length + 2]; + System.arraycopy(data, 0, apdu, 0, data.length); + apdu[data.length]= sw1; + apdu[data.length+1]= sw2; + this.apdu = apdu; + this.parse(); + } + + + /** + * Parses the APDU response, separating the response data from SW. + */ + private void parse() { + int length = this.apdu.length; + + this.sw1 = this.apdu[length - 2] & 0xff; + this.sw2 = this.apdu[length - 1] & 0xff; + this.sw = (this.sw1 << 8) | this.sw2; + + this.data = new byte[length - 2]; + System.arraycopy(this.apdu, 0, this.data, 0, length - 2); + } + + /** + * Returns true if the SW is 0x9000. + * + * @return true if the SW is 0x9000. + */ + public boolean isOK() { + return this.sw == SW_OK; + } + + /** + * Asserts that the SW is 0x9000. Throws an exception if it isn't + * + * @return this object, to simplify chaining + * @throws ApduException if the SW is not 0x9000 + */ + public ApduResponse checkOK() throws ApduException { + return this.checkSW(SW_OK); + } + + /** + * Asserts that the SW is contained in the given list. Throws an exception if it isn't. + * + * @param codes the list of SWs to match. + * @return this object, to simplify chaining + * @throws ApduException if the SW is not 0x9000 + */ + public ApduResponse checkSW(int... codes) throws ApduException { + for (int code : codes) { + if (this.sw == code) { + return this; + } + } + + switch (this.sw) { + case SW_SECURITY_CONDITION_NOT_SATISFIED: + throw new ApduException(this.sw, "security condition not satisfied"); + case SW_AUTHENTICATION_METHOD_BLOCKED: + throw new ApduException(this.sw, "authentication method blocked"); + default: + throw new ApduException(this.sw, "Unexpected error SW"); + } + } + + /** + * Asserts that the SW is 0x9000. Throws an exception with the given message if it isn't + * + * @param message the error message + * @return this object, to simplify chaining + * @throws ApduException if the SW is not 0x9000 + */ + public ApduResponse checkOK(String message) throws ApduException { + return checkSW(message, SW_OK); + } + + /** + * Asserts that the SW is contained in the given list. Throws an exception with the given message if it isn't. + * + * @param message the error message + * @param codes the list of SWs to match. + * @return this object, to simplify chaining + * @throws ApduException if the SW is not 0x9000 + */ + public ApduResponse checkSW(String message, int... codes) throws ApduException { + for (int code : codes) { + if (this.sw == code) { + return this; + } + } + + throw new ApduException(this.sw, message); + } + + /** + * Checks response from an authentication command (VERIFY PIN, UNBLOCK PUK) + * + * @throws WrongPINException wrong PIN + * @throws ApduException unexpected response + */ + public ApduResponse checkAuthOK() throws WrongPINException, WrongPINLegacyException, BlockedPINException, ApduException { + if ((this.sw & SW_WRONG_PIN_MASK) == SW_WRONG_PIN_MASK) { + throw new WrongPINException(sw2 & 0x0F); + } else if (this.sw == SW_WRONG_PIN_LEGACY) { + throw new WrongPINLegacyException(); + } else if (this.sw == SW_BLOCKED_PIN) { + throw new BlockedPINException(); + } else if (this.sw == SW_FACTORY_RESET) { + throw new ResetToFactoryException(); + } else { + return checkOK(); + } + } + + /** + * Returns the data field of this APDU. + * + * @return the data field of this APDU + */ + public byte[] getData() { + return this.data; + } + + /** + * Returns the Status Word. + * + * @return the status word + */ + public int getSw() { + return this.sw; + } + + /** + * Returns the SW1 byte + * @return SW1 + */ + public int getSw1() { + return this.sw1; + } + + /** + * Returns the SW2 byte + * @return SW2 + */ + public int getSw2() { + return this.sw2; + } + + /** + * Returns the raw unparsed response. + * + * @return raw APDU data + */ + public byte[] getBytes() { + return this.apdu; + } + + /** + * Serializes the APDU to human readable hex string format + * + * @return the hex string representation of the APDU + */ + public String toHexString() { + byte[] raw= this.apdu; + try{ + if ( raw == null ) { + return ""; + } + final StringBuilder hex = new StringBuilder( 2 * raw.length ); + for ( final byte b : raw ) { + hex.append(HEXES.charAt((b & 0xF0) >> 4)) + .append(HEXES.charAt((b & 0x0F))); + } + return hex.toString(); + } catch(Exception e){ + return "Exception in ApduResponse.toHexString()"; + } + } +} diff --git a/hardware/src/main/java/com/satochip/ApplicationStatus.java b/hardware/src/main/java/com/satochip/ApplicationStatus.java new file mode 100644 index 000000000..503407eed --- /dev/null +++ b/hardware/src/main/java/com/satochip/ApplicationStatus.java @@ -0,0 +1,133 @@ +package com.satochip; + +import com.satochip.ApduResponse; + +/** + * Parses the result of a GET STATUS command retrieving application status. + */ +public class ApplicationStatus { + + private boolean setup_done= false; + private boolean is_seeded= false; + private boolean needs_secure_channel= false; + private boolean needs_2FA= false; + + private byte protocol_major_version= (byte)0; + private byte protocol_minor_version= (byte)0; + private byte applet_major_version= (byte)0; + private byte applet_minor_version= (byte)0; + + private byte PIN0_remaining_tries= (byte)0; + private byte PUK0_remaining_tries= (byte)0; + private byte PIN1_remaining_tries= (byte)0; + private byte PUK1_remaining_tries= (byte)0; + + private int protocol_version= 0; //(d["protocol_major_version"]<<8)+d["protocol_minor_version"] + + // todo: remove + // private byte pinRetryCount; + // private byte pukRetryCount; + // private boolean hasMasterKey; + + + /** + * Constructor from TLV data + * @param tlvData the TLV data + * @throws IllegalArgumentException if the TLV does not follow the expected format + */ + public ApplicationStatus(ApduResponse rapdu) { + + int sw= rapdu.getSw(); + + if (sw==0x9000){ + + byte[] data= rapdu.getData(); + protocol_major_version= data[0]; + protocol_minor_version= data[1]; + applet_major_version= data[2]; + applet_minor_version= data[3]; + protocol_version= (protocol_major_version<<8) + protocol_minor_version; + + if (data.length >=8){ + PIN0_remaining_tries= data[4]; + PUK0_remaining_tries= data[5]; + PIN1_remaining_tries= data[6]; + PUK1_remaining_tries= data[7]; + needs_2FA= false; //default value + } + if (data.length >=9){ + needs_2FA= (data[8]==0X00)? false : true; + } + if (data.length >=10){ + is_seeded= (data[9]==0X00)? false : true; + } + if (data.length >=11){ + setup_done= (data[10]==0X00)? false : true; + } else { + setup_done= true; + } + if (data.length >=12){ + needs_secure_channel= (data[11]==0X00)? false : true; + } else { + needs_secure_channel= false; + needs_2FA= false; //default value + } + } else if (sw==0x9c04){ + setup_done= false; + is_seeded= false; + needs_secure_channel= false; + } else{ + //throws IllegalArgumentException("Wrong getStatus data!"); // should not happen + } + } + + // getters + public boolean isSeeded() { + return is_seeded; + } + public boolean isSetupDone() { + return setup_done; + } + public boolean needsSecureChannel() { + return needs_secure_channel; + } + + // TODO: other gettters + public byte getPin0RemainingCounter(){ + return PIN0_remaining_tries; + } + public byte getPuk0RemainingCounter(){ + return PUK0_remaining_tries; + } + + public String toString(){ + String status_info= "setup_done: " + setup_done + "\n"+ + "is_seeded: " + is_seeded + "\n"+ + "needs_2FA: " + needs_2FA + "\n"+ + "needs_secure_channel: " + needs_secure_channel + "\n"+ + "protocol_major_version: " + protocol_major_version + "\n"+ + "protocol_minor_version: " + protocol_minor_version + "\n"+ + "applet_major_version: " + applet_major_version + "\n"+ + "applet_minor_version: " + applet_minor_version; + return status_info; + } + public int getCardVersionInt() { + return ((int) protocol_major_version * (1 << 24)) + + ((int) protocol_minor_version * (1 << 16)) + + ((int) applet_major_version * (1 << 8)) + + ((int) applet_minor_version); + } + + public String getCardVersionString() { + String version_string = + protocol_major_version + "." + + protocol_minor_version + "-" + + applet_major_version + "." + + applet_minor_version; + return version_string; + } + + public int getProtocolVersion() { + return protocol_version; + } +} diff --git a/hardware/src/main/java/com/satochip/Bip32Path.java b/hardware/src/main/java/com/satochip/Bip32Path.java new file mode 100644 index 000000000..bfd3a6450 --- /dev/null +++ b/hardware/src/main/java/com/satochip/Bip32Path.java @@ -0,0 +1,26 @@ +package com.satochip; + +public class Bip32Path { + + private final Integer depth; + private final byte[] bytes; + //private final String bip32Path; + + public Bip32Path(Integer depth, byte[] bytes) { + this.depth = depth; + this.bytes = bytes; + //this.bip32Path = bip32Path; + } + + public Integer getDepth() { + return depth; + } + + public byte[] getBytes() { + return bytes; + } + +// public String getBip32Path() { +// return bip32Path; +// } +} \ No newline at end of file diff --git a/hardware/src/main/java/com/satochip/BlockedPINException.java b/hardware/src/main/java/com/satochip/BlockedPINException.java new file mode 100644 index 000000000..97e6aa680 --- /dev/null +++ b/hardware/src/main/java/com/satochip/BlockedPINException.java @@ -0,0 +1,14 @@ +package com.satochip; + +/** + * Exception thrown when checking PIN/PUK + */ +public class BlockedPINException extends ApduException { + + /** + * Construct an exception with the given number of retry attempts. + */ + public BlockedPINException() { + super("PIN blocked"); + } +} diff --git a/hardware/src/main/java/com/satochip/CardChannel.java b/hardware/src/main/java/com/satochip/CardChannel.java new file mode 100644 index 000000000..3b4c4599e --- /dev/null +++ b/hardware/src/main/java/com/satochip/CardChannel.java @@ -0,0 +1,35 @@ +package com.satochip; + +import java.io.IOException; + +/** + * A channel to transcieve ISO7816-4 APDUs. + */ +public interface CardChannel { + /** + * Sends the given C-APDU and returns an R-APDU. + * + * @param cmd the command to send + * @return the card response + * @throws IOException communication error + */ + ApduResponse send(ApduCommand cmd) throws IOException; + + /** + * True if connected, false otherwise + * @return true if connected, false otherwise + */ + boolean isConnected(); + + /** + * Returns the iteration count for deriving the pairing key from the pairing password. The default is 50000 and is + * should only be changed for devices where the PBKDF2 is calculated on-board and the resource do not permit a + * high iteration count. If a lower count is used other security mechanism should be used to prevent brute force + * attacks. + * + * @return the iteration count + */ + default int pairingPasswordPBKDF2IterationCount() { + return 50000; + } +} diff --git a/hardware/src/main/java/com/satochip/CardListener.java b/hardware/src/main/java/com/satochip/CardListener.java new file mode 100644 index 000000000..fd9684d1e --- /dev/null +++ b/hardware/src/main/java/com/satochip/CardListener.java @@ -0,0 +1,18 @@ +package com.satochip; + +/** + * Listener for card connection events. + */ +public interface CardListener { + /** + * Executes when the card channel is connected. + * + * @param channel the connected card channel + */ + void onConnected(CardChannel channel); + + /** + * Executes when a previously connected card is disconnected. + */ + void onDisconnected(); +} diff --git a/hardware/src/main/java/com/satochip/Constants.java b/hardware/src/main/java/com/satochip/Constants.java new file mode 100644 index 000000000..e6b621c8f --- /dev/null +++ b/hardware/src/main/java/com/satochip/Constants.java @@ -0,0 +1,186 @@ +package com.satochip; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.HashMap; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; +import java.util.Collections; + +public final class Constants { + + // Prevents instanciation of class + private Constants() {} + + /**************************************** + * Instruction codes * + ****************************************/ + public final static byte CLA = (byte)0xB0; + // Applet initialization + public final static byte INS_SETUP = (byte) 0x2A; + // Keys' use and management + public final static byte INS_IMPORT_KEY = (byte) 0x32; + public final static byte INS_RESET_KEY = (byte) 0x33; + public final static byte INS_GET_PUBLIC_FROM_PRIVATE= (byte)0x35; + // External authentication + public final static byte INS_CREATE_PIN = (byte) 0x40; //TODO: remove? + public final static byte INS_VERIFY_PIN = (byte) 0x42; + public final static byte INS_CHANGE_PIN = (byte) 0x44; + public final static byte INS_UNBLOCK_PIN = (byte) 0x46; + public final static byte INS_LOGOUT_ALL = (byte) 0x60; + // Status information + public final static byte INS_LIST_PINS = (byte) 0x48; + public final static byte INS_GET_STATUS = (byte) 0x3C; + public final static byte INS_CARD_LABEL = (byte) 0x3D; + // HD wallet + public final static byte INS_BIP32_IMPORT_SEED= (byte) 0x6C; + public final static byte INS_BIP32_RESET_SEED= (byte) 0x77; + public final static byte INS_BIP32_GET_AUTHENTIKEY= (byte) 0x73; + public final static byte INS_BIP32_SET_AUTHENTIKEY_PUBKEY= (byte)0x75; + public final static byte INS_BIP32_GET_EXTENDED_KEY= (byte) 0x6D; + public final static byte INS_BIP32_SET_EXTENDED_PUBKEY= (byte) 0x74; + public final static byte INS_SIGN_MESSAGE= (byte) 0x6E; + public final static byte INS_SIGN_SHORT_MESSAGE= (byte) 0x72; + public final static byte INS_SIGN_TRANSACTION= (byte) 0x6F; + public final static byte INS_PARSE_TRANSACTION = (byte) 0x71; + public final static byte INS_CRYPT_TRANSACTION_2FA = (byte) 0x76; + public final static byte INS_SET_2FA_KEY = (byte) 0x79; + public final static byte INS_RESET_2FA_KEY = (byte) 0x78; + public final static byte INS_SIGN_TRANSACTION_HASH= (byte) 0x7A; + // secure channel + public final static byte INS_INIT_SECURE_CHANNEL = (byte) 0x81; + public final static byte INS_PROCESS_SECURE_CHANNEL = (byte) 0x82; + // secure import from SeedKeeper + public final static byte INS_IMPORT_ENCRYPTED_SECRET = (byte) 0xAC; + public final static byte INS_IMPORT_TRUSTED_PUBKEY = (byte) 0xAA; + public final static byte INS_EXPORT_TRUSTED_PUBKEY = (byte) 0xAB; + public final static byte INS_EXPORT_AUTHENTIKEY= (byte) 0xAD; + // Personalization PKI support + public final static byte INS_IMPORT_PKI_CERTIFICATE = (byte) 0x92; + public final static byte INS_EXPORT_PKI_CERTIFICATE = (byte) 0x93; + public final static byte INS_SIGN_PKI_CSR = (byte) 0x94; + public final static byte INS_EXPORT_PKI_PUBKEY = (byte) 0x98; + public final static byte INS_LOCK_PKI = (byte) 0x99; + public final static byte INS_CHALLENGE_RESPONSE_PKI= (byte) 0x9A; + // reset to factory settings + public final static byte INS_RESET_TO_FACTORY = (byte) 0xFF; + + + + + + + + /**************************************** + * Error codes * + ****************************************/ + + /** Entered PIN is not correct */ + public final static short SW_PIN_FAILED = (short)0x63C0;// includes number of tries remaining + ///** DEPRECATED - Entered PIN is not correct */ + //public final static short SW_AUTH_FAILED = (short) 0x9C02; + /** Required operation is not allowed in actual circumstances */ + public final static short SW_OPERATION_NOT_ALLOWED = (short) 0x9C03; + /** Required setup is not not done */ + public final static short SW_SETUP_NOT_DONE = (short) 0x9C04; + /** Required setup is already done */ + public final static short SW_SETUP_ALREADY_DONE = (short) 0x9C07; + /** Required feature is not (yet) supported */ + final static short SW_UNSUPPORTED_FEATURE = (short) 0x9C05; + /** Required operation was not authorized because of a lack of privileges */ + public final static short SW_UNAUTHORIZED = (short) 0x9C06; + /** Algorithm specified is not correct */ + public final static short SW_INCORRECT_ALG = (short) 0x9C09; + + /** There have been memory problems on the card */ + public final static short SW_NO_MEMORY_LEFT = (short) 0x9C01; + ///** DEPRECATED - Required object is missing */ + //public final static short SW_OBJECT_NOT_FOUND= (short) 0x9C07; + + /** Incorrect P1 parameter */ + public final static short SW_INCORRECT_P1 = (short) 0x9C10; + /** Incorrect P2 parameter */ + public final static short SW_INCORRECT_P2 = (short) 0x9C11; + /** Invalid input parameter to command */ + public final static short SW_INVALID_PARAMETER = (short) 0x9C0F; + + /** Eckeys initialized */ + public final static short SW_ECKEYS_INITIALIZED_KEY = (short) 0x9C1A; + + /** Verify operation detected an invalid signature */ + public final static short SW_SIGNATURE_INVALID = (short) 0x9C0B; + /** Operation has been blocked for security reason */ + public final static short SW_IDENTITY_BLOCKED = (short) 0x9C0C; + /** For debugging purposes */ + public final static short SW_INTERNAL_ERROR = (short) 0x9CFF; + /** Very low probability error */ + public final static short SW_BIP32_DERIVATION_ERROR = (short) 0x9C0E; + /** Incorrect initialization of method */ + public final static short SW_INCORRECT_INITIALIZATION = (short) 0x9C13; + /** Bip32 seed is not initialized*/ + public final static short SW_BIP32_UNINITIALIZED_SEED = (short) 0x9C14; + /** Bip32 seed is already initialized (must be reset before change)*/ + public final static short SW_BIP32_INITIALIZED_SEED = (short) 0x9C17; + //** DEPRECATED - Bip32 authentikey pubkey is not initialized*/ + //public final static short SW_BIP32_UNINITIALIZED_AUTHENTIKEY_PUBKEY= (short) 0x9C16; + /** Incorrect transaction hash */ + public final static short SW_INCORRECT_TXHASH = (short) 0x9C15; + + /** 2FA already initialized*/ + public final static short SW_2FA_INITIALIZED_KEY = (short) 0x9C18; + /** 2FA uninitialized*/ + public final static short SW_2FA_UNINITIALIZED_KEY = (short) 0x9C19; + + /** HMAC errors */ + static final short SW_HMAC_UNSUPPORTED_KEYSIZE = (short) 0x9c1E; + static final short SW_HMAC_UNSUPPORTED_MSGSIZE = (short) 0x9c1F; + + /** Secure channel */ + public final static short SW_SECURE_CHANNEL_REQUIRED = (short) 0x9C20; + public final static short SW_SECURE_CHANNEL_UNINITIALIZED = (short) 0x9C21; + public final static short SW_SECURE_CHANNEL_WRONG_IV= (short) 0x9C22; + public final static short SW_SECURE_CHANNEL_WRONG_MAC= (short) 0x9C23; + + /** Secret data is too long for import **/ + public final static short SW_IMPORTED_DATA_TOO_LONG = (short) 0x9C32; + /** Wrong HMAC when importing Secret through Secure import **/ + public final static short SW_SECURE_IMPORT_WRONG_MAC = (short) 0x9C33; + /** Wrong Fingerprint when importing Secret through Secure import **/ + public final static short SW_SECURE_IMPORT_WRONG_FINGERPRINT = (short) 0x9C34; + /** No Trusted Pubkey when importing Secret through Secure import **/ + public final static short SW_SECURE_IMPORT_NO_TRUSTEDPUBKEY = (short) 0x9C35; + + /** PKI perso error */ + public final static short SW_PKI_ALREADY_LOCKED = (short) 0x9C40; + /** CARD HAS BEEN RESET TO FACTORY */ + public final static short SW_RESET_TO_FACTORY = (short) 0xFF00; + /** For instructions that have been deprecated*/ + public final static short SW_INS_DEPRECATED = (short) 0x9C26; + /** For debugging purposes 2 */ + public final static short SW_DEBUG_FLAG = (short) 0x9FFF; + + /**************************************** + * Other constants * + ****************************************/ + + // KeyBlob Encoding in Key Blobs + public final static byte BLOB_ENC_PLAIN = (byte) 0x00; + + // Cipher Operations admitted in ComputeCrypt() + public final static byte OP_INIT = (byte) 0x01; + public final static byte OP_PROCESS = (byte) 0x02; + public final static byte OP_FINALIZE = (byte) 0x03; + + // JC API 2.2.2 does not define these constants: + public final static byte ALG_ECDSA_SHA_256= (byte) 33; + public final static byte ALG_EC_SVDP_DH_PLAIN= (byte) 3; //https://javacard.kenai.com/javadocs/connected/javacard/security/KeyAgreement.html#ALG_EC_SVDP_DH_PLAIN + public final static byte ALG_EC_SVDP_DH_PLAIN_XY= (byte) 6; //https://docs.oracle.com/javacard/3.0.5/api/javacard/security/KeyAgreement.html#ALG_EC_SVDP_DH_PLAIN_XY + public final static short LENGTH_EC_FP_256= (short) 256; + + + + + +} diff --git a/hardware/src/main/java/com/satochip/NfcActionObject.java b/hardware/src/main/java/com/satochip/NfcActionObject.java new file mode 100644 index 000000000..fad8277fb --- /dev/null +++ b/hardware/src/main/java/com/satochip/NfcActionObject.java @@ -0,0 +1,40 @@ +package com.satochip; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.blockstream.common.gdk.data.Network; +import com.blockstream.common.gdk.device.HardwareWalletInteraction; +import com.blockstream.common.gdk.device.SignMessageResult; + +import java.util.ArrayList; +import java.util.List; + +public class NfcActionObject { + + public NfcActionType actionType = NfcActionType.none; + public NfcActionStatus actionStatus = NfcActionStatus.none; + + public HardwareWalletInteraction hwInteraction = null; + + // getXpubs + // List getXpubs(@NonNull Network network, @NonNull List> paths, @Nullable HardwareWalletInteraction hwInteraction) + public Network networkParam = null; + public List> pathsParam = new ArrayList<>(); + public List xpubsResult = new ArrayList<>(); + + // signMessage + // SignMessageResult signMessage(@NonNull List path, @NonNull String message, boolean useAeProtocol, @Nullable String aeHostCommitment, @Nullable String aeHostEntropy, @Nullable HardwareWalletInteraction hwInteraction) + public List pathParam = new ArrayList<>(); + public String messageParam = ""; + public String signatureResult = ""; + + // signTransaction + //public List> pathsParam = new ArrayList<>(); + public List hashesParam = new ArrayList<>(); + public List signaturesResult = new ArrayList<>(); + + // getMasterBlindingKey + public byte[] blindingKeyResult = new byte[32]; + +} diff --git a/hardware/src/main/java/com/satochip/NfcActionStatus.java b/hardware/src/main/java/com/satochip/NfcActionStatus.java new file mode 100644 index 000000000..c45407580 --- /dev/null +++ b/hardware/src/main/java/com/satochip/NfcActionStatus.java @@ -0,0 +1,8 @@ +package com.satochip; + +public enum NfcActionStatus { + none, + error, + busy, + finished, +} diff --git a/hardware/src/main/java/com/satochip/NfcActionType.java b/hardware/src/main/java/com/satochip/NfcActionType.java new file mode 100644 index 000000000..458f3b112 --- /dev/null +++ b/hardware/src/main/java/com/satochip/NfcActionType.java @@ -0,0 +1,9 @@ +package com.satochip; + +public enum NfcActionType { + none, + getXpubs, + signMessage, + signTransaction, + getMasterBlindingKey, +} diff --git a/hardware/src/main/java/com/satochip/NfcCardChannel.java b/hardware/src/main/java/com/satochip/NfcCardChannel.java new file mode 100644 index 000000000..52066d706 --- /dev/null +++ b/hardware/src/main/java/com/satochip/NfcCardChannel.java @@ -0,0 +1,34 @@ +package com.satochip; + +import android.nfc.tech.IsoDep; +import android.util.Log; + +import java.io.IOException; + +/** + * Implementation of the CardChannel interface using the Android NFC API. + */ +public class NfcCardChannel implements CardChannel { + private static final String TAG = "CardChannel"; + + private IsoDep isoDep; + + public NfcCardChannel(IsoDep isoDep) { + this.isoDep = isoDep; + } + + @Override + public ApduResponse send(ApduCommand cmd) throws IOException { + byte[] apdu = cmd.serialize(); + Log.d(TAG, String.format("COMMAND CLA: %02X INS: %02X P1: %02X P2: %02X LC: %02X", cmd.getCla(), cmd.getIns(), cmd.getP1(), cmd.getP2(), cmd.getData().length)); + byte[] resp = this.isoDep.transceive(apdu); + ApduResponse response = new ApduResponse(resp); + Log.d(TAG, String.format("RESPONSE LEN: %02X, SW: %04X %n-----------------------", response.getData().length, response.getSw())); + return response; + } + + @Override + public boolean isConnected() { + return this.isoDep.isConnected(); + } +} diff --git a/hardware/src/main/java/com/satochip/NfcCardManager.java b/hardware/src/main/java/com/satochip/NfcCardManager.java new file mode 100644 index 000000000..79d5e97fa --- /dev/null +++ b/hardware/src/main/java/com/satochip/NfcCardManager.java @@ -0,0 +1,124 @@ +package com.satochip; + +import android.nfc.NfcAdapter; +import android.nfc.Tag; +import android.nfc.tech.IsoDep; +import android.os.SystemClock; +import android.util.Log; + +import java.io.IOException; + +/** + * Manages connection of NFC-based cards. Extends Thread and must be started using the start() method. The thread has + * a runloop which monitors the connection and from which CardListener callbacks are called. + */ +public class NfcCardManager extends Thread implements NfcAdapter.ReaderCallback { + private static final String TAG = "NFCCardManager"; + private static final int DEFAULT_LOOP_SLEEP_MS = 50; + + private IsoDep isoDep; + private boolean isRunning; + private CardListener cardListener; + private int loopSleepMS; + +// static { +// Crypto.addBouncyCastleProvider(); +// } + + /** + * Constructs an NFC Card Manager with default delay between loop iterations. + */ + public NfcCardManager() { + this(DEFAULT_LOOP_SLEEP_MS); + } + + /** + * Constructs an NFC Card Manager with the given delay between loop iterations. + * + * @param loopSleepMS time to sleep between loops + */ + public NfcCardManager(int loopSleepMS) { + this.loopSleepMS = loopSleepMS; + } + + /** + * True if connected, false otherwise. + * @return if connected, false otherwise + */ + public boolean isConnected() { + try { + return isoDep != null && isoDep.isConnected(); + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + @Override + public void onTagDiscovered(Tag tag) { + isoDep = IsoDep.get(tag); + try { + isoDep = IsoDep.get(tag); + isoDep.connect(); + isoDep.setTimeout(120000); + } catch (IOException e) { + Log.e(TAG, "error connecting to tag"); + } + } + + /** + * Runloop. Do NOT invoke directly. Use start() instead. + */ + public void run() { + boolean connected = isConnected(); + + while (true) { + boolean newConnected = isConnected(); + if (newConnected != connected) { + connected = newConnected; + Log.i(TAG, "tag " + (connected ? "connected" : "disconnected")); + + if (connected && !isRunning) { + onCardConnected(); + } else { + onCardDisconnected(); + } + } + + SystemClock.sleep(loopSleepMS); + } + } + + /** + * Reacts on card connected by calling the callback of the registered listener. + */ + private void onCardConnected() { + isRunning = true; + + if (cardListener != null) { + cardListener.onConnected(new NfcCardChannel(isoDep)); + } + + isRunning = false; + } + + /** + * Reacts on card disconnected by calling the callback of the registered listener. + */ + private void onCardDisconnected() { + isRunning = false; + isoDep = null; + if (cardListener != null) { + cardListener.onDisconnected(); + } + } + + /** + * Sets the card listener. + * + * @param listener the new listener + */ + public void setCardListener(CardListener listener) { + cardListener = listener; + } +} diff --git a/hardware/src/main/java/com/satochip/ResetToFactoryException.java b/hardware/src/main/java/com/satochip/ResetToFactoryException.java new file mode 100644 index 000000000..7fce2fd05 --- /dev/null +++ b/hardware/src/main/java/com/satochip/ResetToFactoryException.java @@ -0,0 +1,14 @@ +package com.satochip; + +/** + * Exception thrown when checking PIN/PUK + */ +public class ResetToFactoryException extends ApduException { + + /** + * Construct an exception with the given number of retry attempts. + */ + public ResetToFactoryException() { + super("Card reset to factory"); + } +} diff --git a/hardware/src/main/java/com/satochip/SatochipCommandSet.java b/hardware/src/main/java/com/satochip/SatochipCommandSet.java new file mode 100644 index 000000000..29d091938 --- /dev/null +++ b/hardware/src/main/java/com/satochip/SatochipCommandSet.java @@ -0,0 +1,493 @@ +package com.satochip; + + +import static com.satochip.Constants.*; + + +import androidx.annotation.NonNull; + +import com.blockstream.libwally.Wally; + +import org.bouncycastle.crypto.digests.RIPEMD160Digest; +import org.bouncycastle.util.encoders.Hex; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.logging.Logger; +import java.util.logging.Level; +import java.io.IOException; + +/** + * 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 SatochipCommandSet { + + private static final Logger logger = Logger.getLogger("org.satochip.client"); + + private final CardChannel apduChannel; + private SecureChannelSession secureChannel; + private ApplicationStatus status; + private SatochipParser parser = null; + + private byte[] pin0 = null; + private List possibleAuthentikeys = new ArrayList(); + + private byte[] extendedKey = null; + private byte[] extendedChaincode = null; + + // should be Satochip + private String cardType = null; + + public static final byte[] SATOCHIP_AID = Hex.decode("5361746f43686970"); //SatoChip + + /** + * Creates a SatochipCommandSet using the given APDU Channel + * + * @param apduChannel APDU channel + */ + public SatochipCommandSet(CardChannel apduChannel) { + this.apduChannel = apduChannel; + this.secureChannel = new SecureChannelSession(); + this.parser = new SatochipParser(); + logger.setLevel(Level.WARNING); + } + + public void setLoggerLevel(String level) { + switch (level) { + case "info": + logger.setLevel(Level.INFO); + break; + case "warning": + logger.setLevel(Level.WARNING); + break; + default: + logger.setLevel(Level.WARNING); + break; + } + } + + public void setLoggerLevel(Level level) { + logger.setLevel(level); + } + + /** + * Returns the application info as stored from the last sent SELECT command. Returns null if no succesful SELECT + * command has been sent using this command set. + * + * @return the application info object + */ + public ApplicationStatus getApplicationStatus() { + return status; + } + + + public SatochipParser getParser() { + return parser; + } + + public ApduResponse cardTransmit(ApduCommand plainApdu) { + + // we try to transmit the APDU until we receive the answer or we receive an unrecoverable error + boolean isApduTransmitted = false; + do { + try { + byte[] apduBytes = plainApdu.serialize(); + byte ins = apduBytes[1]; + boolean isEncrypted = false; + + // check if status available + if (status == null) { + ApduCommand statusCapdu = new ApduCommand(0xB0, INS_GET_STATUS, 0x00, 0x00, new byte[0]); + ApduResponse statusRapdu = apduChannel.send(statusCapdu); + status = new ApplicationStatus(statusRapdu); + logger.info("SATOCHIPLIB: Status cardGetStatus:" + status.toString()); + } + + ApduCommand capdu = null; + if (status.needsSecureChannel() && (ins != 0xA4) && (ins != 0x81) && (ins != 0x82) && (ins != INS_GET_STATUS)) { + + if (!secureChannel.initializedSecureChannel()) { + cardInitiateSecureChannel(); + logger.info("SATOCHIPLIB: secure Channel initiated!"); + } + // encrypt apdu + //logger.info("SATOCHIPLIB: Capdu before encryption:"+ plainApdu.toHexString()); + capdu = secureChannel.encrypt_secure_channel(plainApdu); + isEncrypted = true; + //logger.info("SATOCHIPLIB: Capdu encrypted:"+ capdu.toHexString()); + } else { + // plain adpu + capdu = plainApdu; + } + + ApduResponse rapdu = apduChannel.send(capdu); + int sw12 = rapdu.getSw(); + + // check answer + if (sw12 == 0x9000) { // ok! + if (isEncrypted) { + // decrypt + //logger.info("SATOCHIPLIB: Rapdu encrypted:"+ rapdu.toHexString()); + rapdu = secureChannel.decrypt_secure_channel(rapdu); + //logger.info("SATOCHIPLIB: Rapdu decrypted:"+ rapdu.toHexString()); + } + isApduTransmitted = true; // leave loop + return rapdu; + } + // PIN authentication is required + else if (sw12 == 0x9C06) { + cardVerifyPIN(); + } + // SecureChannel is not initialized + else if (sw12 == 0x9C21) { + secureChannel.resetSecureChannel(); + } else { + // cannot resolve issue at this point + isApduTransmitted = true; // leave loop + return rapdu; + } + + } catch (Exception e) { + logger.warning("SATOCHIPLIB: Exception in cardTransmit: " + e); + return new ApduResponse(new byte[0], (byte) 0x00, (byte) 0x00); // return empty ApduResponse + } + + } while (!isApduTransmitted); + + return new ApduResponse(new byte[0], (byte) 0x00, (byte) 0x00); // should not happen + } + + public void cardDisconnect() { + secureChannel.resetSecureChannel(); + status = null; + pin0 = null; + } + + /** + * Selects a Satochip instance. The applet is assumed to have been installed with its default AID. + * + * @return the raw card response + * @throws IOException communication error + */ + public ApduResponse cardSelect(String cardType) throws IOException { + + ApduCommand selectApplet; + if (cardType.equals("satochip")) { + selectApplet = new ApduCommand(0x00, 0xA4, 0x04, 0x00, SATOCHIP_AID); + } else { + selectApplet = new ApduCommand(0x00, 0xA4, 0x04, 0x00, null); + } + + logger.info("SATOCHIPLIB: C-APDU cardSelect:" + selectApplet.toHexString()); + ApduResponse respApdu = apduChannel.send(selectApplet); + logger.info("SATOCHIPLIB: R-APDU cardSelect:" + respApdu.toHexString()); + + if (respApdu.getSw() == 0x9000) { + this.cardType = cardType; + logger.info("SATOCHIPLIB: Satochip-java: CardSelect: found a " + this.cardType); + } + return respApdu; + } + + public ApduResponse cardGetStatus() { + ApduCommand plainApdu = new ApduCommand(0xB0, INS_GET_STATUS, 0x00, 0x00, new byte[0]); + + logger.info("SATOCHIPLIB: C-APDU cardGetStatus:" + plainApdu.toHexString()); + ApduResponse respApdu = this.cardTransmit(plainApdu); + logger.info("SATOCHIPLIB: R-APDU cardGetStatus:" + respApdu.toHexString()); + + status = new ApplicationStatus(respApdu); + logger.info("SATOCHIPLIB: Status from cardGetStatus:" + status.toString()); + + return respApdu; + } + + // do setup secure channel in this method + public List cardInitiateSecureChannel() throws IOException { + + byte[] pubkey = secureChannel.getPublicKey(); + + ApduCommand plainApdu = new ApduCommand(0xB0, INS_INIT_SECURE_CHANNEL, 0x00, 0x00, pubkey); + + logger.info("SATOCHIPLIB: C-APDU cardInitiateSecureChannel:" + plainApdu.toHexString()); + ApduResponse respApdu = apduChannel.send(plainApdu); + logger.info("SATOCHIPLIB: R-APDU cardInitiateSecureChannel:" + respApdu.toHexString()); + + byte[] keyData = parser.parseInitiateSecureChannel(respApdu); + possibleAuthentikeys = parser.parseInitiateSecureChannelGetPossibleAuthentikeys(respApdu); + // setup secure channel + secureChannel.initiateSecureChannel(keyData); + + return possibleAuthentikeys; + } + + /**************************************** + * PIN MGMT * + ****************************************/ + public void setPin0(byte[] pin) { + this.pin0 = new byte[pin.length]; + System.arraycopy(pin, 0, this.pin0, 0, pin.length); + } + + public ApduResponse cardVerifyPIN(byte[] pin) throws Exception { + + byte[] mypin = pin; + if (mypin == null){ + if (pin0 == null) { + // TODO: specific exception + throw new RuntimeException("PIN required!"); + } + mypin = pin0; + } + + try { + ApduCommand plainApdu = new ApduCommand(0xB0, INS_VERIFY_PIN, 0x00, 0x00, mypin); + //logger.info("SATOCHIPLIB: C-APDU cardVerifyPIN:" + plainApdu.toHexString()); + logger.info("SATOCHIPLIB: C-APDU cardVerifyPIN"); + ApduResponse rapdu = this.cardTransmit(plainApdu); + logger.info("SATOCHIPLIB: R-APDU cardVerifyPIN:" + rapdu.toHexString()); + + rapdu.checkAuthOK(); + this.pin0 = mypin; // cache new pin + return rapdu; + + } catch (WrongPINException | WrongPINLegacyException | BlockedPINException e) { + this.pin0 = null; + throw e; + } catch (ApduException e){ + this.pin0 = null; + throw e; + } catch (Exception e){ + this.pin0 = null; + throw e; + } + } + + public ApduResponse cardVerifyPIN() throws Exception { + return cardVerifyPIN(this.pin0); + } + + /**************************************** + * BIP32 * + ****************************************/ + + public byte[][] cardBip32GetExtendedKey(String stringPath) throws Exception { + Bip32Path parsedPath = parser.parseBip32PathToBytes(stringPath); + return cardBip32GetExtendedKey(parsedPath); + } + + public byte[][] cardBip32GetExtendedKey(@NonNull Bip32Path parsedPath) throws Exception { + logger.warning("SATOCHIPLIB: cardBip32GetExtendedKey"); + if (parsedPath.getDepth() > 10) { + throw new Exception("Path length exceeds maximum depth of 10: " + parsedPath.getDepth()); + } + + byte p1 = parsedPath.getDepth().byteValue(); + byte optionFlags = (byte) 0x40; + byte p2 = optionFlags; + + byte[] data = parsedPath.getBytes(); + + while (true) { + ApduCommand plainApdu = new ApduCommand( + 0xB0, + INS_BIP32_GET_EXTENDED_KEY, + p1, + p2, + data + ); + logger.warning("SATOCHIPLIB: C-APDU cardBip32GetExtendedKey:" + plainApdu.toHexString()); + ApduResponse respApdu = this.cardTransmit(plainApdu); + logger.warning("SATOCHIPLIB: R-APDU cardBip32GetExtendedKey:" + respApdu.toHexString()); + if (respApdu.getSw() == 0x9C01) { + logger.warning("SATOCHIPLIB: cardBip32GetExtendedKey: Reset memory..."); + // reset memory flag + p2 = (byte) (p2 ^ 0x80); + plainApdu = new ApduCommand( + 0xB0, + INS_BIP32_GET_EXTENDED_KEY, + p1, + p2, + data + ); + respApdu = this.cardTransmit(plainApdu); + // reset the flag then restart + p2 = optionFlags; + continue; + } + // other (unexpected) error + if (respApdu.getSw() != 0x9000) { + throw new Exception("SATOCHIPLIB: cardBip32GetExtendedKey:" + + "Unexpected error during BIP32 derivation. SW: " + + respApdu.getSw() + " " + respApdu.toHexString() + ); + } + // success + if (respApdu.getSw() == 0x9000) { + logger.warning("SATOCHIPLIB: cardBip32GetExtendedKey: return 0x9000"); + byte[] response = respApdu.getData(); + if ((response[32] & 0x80) == 0x80) { + logger.info("SATOCHIPLIB: cardBip32GetExtendedKey: Child Derivation optimization..."); + throw new Exception("Unsupported legacy option during BIP32 derivation"); + } + byte[][] extendedKeyData = parser.parseBip32GetExtendedKey(respApdu); + extendedKey = extendedKeyData[0]; + extendedChaincode = extendedKeyData[1]; + //String extendedKeyHex = SatochipParser.toHexString(extendedKey); + return extendedKeyData; + } + } + } + + /* + * Get the BIP32 xpub for given path. + * + * Parameters: + * path (str): the path; if given as a string, it will be converted to bytes (4 bytes for each path index) + * xtype (str): the type of transaction such as 'standard', 'p2wpkh-p2sh', 'p2wpkh', 'p2wsh-p2sh', 'p2wsh' + * is_mainnet (bool): is mainnet or testnet + * + * Return: + * xpub (str): the corresponding xpub value + */ + public String cardBip32GetXpub(String path, int xtype) throws Exception { + Bip32Path bip32Path = parser.parseBip32PathToBytes(path); + return cardBip32GetXpub(bip32Path, xtype); + } + public String cardBip32GetXpub(Bip32Path bip32Path, int xtype) throws Exception { + logger.warning("SATOCHIPLIB: cardBip32GetXpub"); + + byte[] childPubkey, childChaincode; + + // Get extended key + logger.warning("SATOCHIPLIB: cardBip32GetXpub: getting card cardBip32GetExtendedKey"); + cardBip32GetExtendedKey(bip32Path); + logger.warning("SATOCHIPLIB: cardBip32GetXpub: got it "+ extendedKey.length); + + childPubkey = extendedKey; + childChaincode = extendedChaincode; + + // Pubkey should be in compressed form + if (extendedKey.length != 33) { + childPubkey = parser.compressPubKey(extendedKey); + } + + //Bip32Path parsedPath = parser.parseBip32PathToBytes(path); + int depth = bip32Path.getDepth(); + byte[] bytePath = bip32Path.getBytes(); + byte[] fingerprintBytes = new byte[4]; + byte[] childNumberBytes = new byte[4]; + + if (depth > 0) { + // Get parent info + byte[] parentBytePath = Arrays.copyOfRange(bytePath, 0, bytePath.length-4); + Bip32Path parentBip32Path = new Bip32Path(depth-1, parentBytePath); + + cardBip32GetExtendedKey(parentBip32Path); + byte[] parentPubkeyBytes = extendedKey; + + // Pubkey should be in compressed form + if (parentPubkeyBytes.length != 33) { + parentPubkeyBytes = parser.compressPubKey(parentPubkeyBytes); + } + + fingerprintBytes = Arrays.copyOfRange(digestRipeMd160(Wally.sha256(parentPubkeyBytes)), 0, 4); + + childNumberBytes = Arrays.copyOfRange(bytePath, bytePath.length - 4, bytePath.length); + } + + byte[] xtypeBytes = ByteBuffer.allocate(4).putInt((int) xtype).array(); + byte[] xpubBytes = new byte[78]; + System.arraycopy(xtypeBytes, 0, xpubBytes, 0, 4); + xpubBytes[4] = (byte) depth; + System.arraycopy(fingerprintBytes, 0, xpubBytes, 5, 4); + System.arraycopy(childNumberBytes, 0, xpubBytes, 9, 4); + System.arraycopy(childChaincode, 0, xpubBytes, 13, 32); + System.arraycopy(childPubkey, 0, xpubBytes, 45, 33); + + String xpub = Wally.base58check_from_bytes(xpubBytes); + logger.info("SATOCHIPLIB: cardBip32GetXpub() xpub: " + xpub); + return xpub; + } + + public byte[] cardBip32GetLiquidMasterBlindingKey() throws Exception { + logger.warning("SATOCHIPLIB: cardBip32GetLiquidMasterBlindingKey"); + + byte p1 = 0x00; + byte p2 = 0x00; + byte[] data = new byte[0]; + ApduCommand plainApdu = new ApduCommand( + 0xB0, + 0x7D, + p1, + p2, + data + ); + ApduResponse respApdu = this.cardTransmit(plainApdu); + + if (respApdu.getSw() != 0x9000) { + throw new Exception("SATOCHIPLIB: cardBip32GetLiquidMasterBlindingKey error: " + respApdu.toHexString()); + } + + byte[] response = respApdu.getData(); + int offset=0; + int keySize= 256*(response[offset++] & 0xff) + response[offset++]; + byte[] blindingKey= new byte[keySize]; + System.arraycopy(response, offset, blindingKey, 0, keySize); + offset+=keySize; + + int sigSize= 256*response[offset++] + response[offset++]; + byte[] sig= new byte[sigSize]; + System.arraycopy(response, offset, sig, 0, sigSize); + offset+=sigSize; + + return blindingKey; + } + + public static byte[] digestRipeMd160(byte[] input) { + RIPEMD160Digest digest = new RIPEMD160Digest(); + digest.update(input, 0, input.length); + byte[] ripmemdHash = new byte[20]; + digest.doFinal(ripmemdHash, 0); + return ripmemdHash; + } + + /**************************************** + * SIGNATURES * + ****************************************/ + + public ApduResponse cardSignHash(byte keynbr, byte[] txhash, byte[] chalresponse) { + + byte[] data; + if (txhash.length != 32) { + throw new RuntimeException("Wrong txhash length (should be 32)"); + } + if (chalresponse == null) { + data = new byte[32]; + System.arraycopy(txhash, 0, data, 0, txhash.length); + } else if (chalresponse.length == 20) { + data = new byte[32 + 2 + 20]; + int offset = 0; + System.arraycopy(txhash, 0, data, offset, txhash.length); + offset += 32; + data[offset++] = (byte) 0x80; // 2 middle bytes for 2FA flag + data[offset++] = (byte) 0x00; + System.arraycopy(chalresponse, 0, data, offset, chalresponse.length); + } else { + throw new RuntimeException("Wrong challenge-response length (should be 20)"); + } + ApduCommand plainApdu = new ApduCommand(0xB0, INS_SIGN_TRANSACTION_HASH, keynbr, 0x00, data); + + logger.info("SATOCHIPLIB: C-APDU cardSignTransactionHash:" + plainApdu.toHexString()); + ApduResponse respApdu = this.cardTransmit(plainApdu); + logger.info("SATOCHIPLIB: R-APDU cardSignTransactionHash:" + respApdu.toHexString()); + // TODO: check SW code for particular status + + return respApdu; + } + +} diff --git a/hardware/src/main/java/com/satochip/SatochipException.java b/hardware/src/main/java/com/satochip/SatochipException.java new file mode 100644 index 000000000..6583b7b82 --- /dev/null +++ b/hardware/src/main/java/com/satochip/SatochipException.java @@ -0,0 +1,43 @@ +package com.satochip; + +import com.btchip.comm.LedgerException; + +public class SatochipException extends RuntimeException { + + + public enum ExceptionReason { + PIN_UNDEFINED, /** Returned if no pin is defined */ + INVALID_PARAMETER, /** Returned if a parameter passed to a function is invalid */ + IO_ERROR, /** Returned if the communication with the device fails */ + APPLICATION_ERROR, /** Returned if an unexpected message is received from the device */ + INTERNAL_ERROR, /** Returned if an unexpected protocol error occurs when communicating with the device */ + OTHER + }; + + private SatochipException.ExceptionReason reason; + private int sw; + + public SatochipException(SatochipException.ExceptionReason reason) { + this.reason = reason; + } + + public SatochipException(SatochipException.ExceptionReason reason, String details) { + super(details); + this.reason = reason; + } + + public SatochipException(String message) { + super(message); + this.reason = SatochipException.ExceptionReason.OTHER; + } + + public SatochipException.ExceptionReason getReason() { + return reason; + } + + public String toString() { + return reason.toString() + " " + super.toString(); + } + + +} diff --git a/hardware/src/main/java/com/satochip/SatochipParser.java b/hardware/src/main/java/com/satochip/SatochipParser.java new file mode 100644 index 000000000..a834826af --- /dev/null +++ b/hardware/src/main/java/com/satochip/SatochipParser.java @@ -0,0 +1,607 @@ +package com.satochip; + +import org.bouncycastle.asn1.*; +import org.bouncycastle.asn1.x9.X9ECParameters; +import org.bouncycastle.asn1.x9.X9IntegerConverter; +import org.bouncycastle.crypto.digests.SHA256Digest; +import org.bouncycastle.crypto.ec.CustomNamedCurves; +import org.bouncycastle.crypto.params.*; +import org.bouncycastle.crypto.signers.ECDSASigner; +import org.bouncycastle.math.ec.ECAlgorithms; +import org.bouncycastle.math.ec.ECPoint; +import org.bouncycastle.math.ec.custom.sec.SecP256K1Curve; +import org.bouncycastle.util.Properties; + +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.math.BigInteger; +import java.io.IOException; +import java.util.Arrays; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Logger; + +public class SatochipParser{ + + private static final Logger logger = Logger.getLogger("org.satochip.client"); + + public static final String HEXES = "0123456789ABCDEF"; + private static final X9ECParameters CURVE_PARAMS = CustomNamedCurves.getByName("secp256k1"); + public static final ECDomainParameters CURVE; + public static final BigInteger HALF_CURVE_ORDER, CURVE_ORDER; + static { + // Tell Bouncy Castle to precompute data that's needed during secp256k1 calculations. + //FixedPointUtil.precompute(CURVE_PARAMS.getG()); + CURVE = new ECDomainParameters(CURVE_PARAMS.getCurve(), CURVE_PARAMS.getG(), CURVE_PARAMS.getN(), CURVE_PARAMS.getH()); + CURVE_ORDER= CURVE_PARAMS.getN(); + HALF_CURVE_ORDER = CURVE_PARAMS.getN().shiftRight(1); + } + + private byte[] authentikey= null; + + public SatochipParser(){ + + } + + public byte[] compressPubKey(byte[] pubkey) throws Exception { + if (pubkey.length == 33) { + // Already compressed + return pubkey; + } else if (pubkey.length == 65) { + // In uncompressed form + byte[] pubkeyComp = Arrays.copyOfRange(pubkey, 0, 33); + // Compute compression byte + int parity = pubkey[64] % 2; + if (parity == 0) { + pubkeyComp[0] = (byte) 0x02; + } else { + pubkeyComp[0] = (byte) 0x03; + } + return pubkeyComp; + } else { + throw new Exception("Wrong public key length: " + pubkey.length + ", expected: 65"); + } + } + + /**************************************** + * PARSER * + ****************************************/ + + public Bip32Path parseBip32PathToBytes(String bip32path) throws Exception { + logger.info("SATOCHIPLIB: parseBip32PathToBytes: Start "); + + String[] splitPath = bip32path.split("/"); + if (splitPath[0].equals("m")) { + splitPath = Arrays.copyOfRange(splitPath, 1, splitPath.length); + } + + int depth = splitPath.length; + byte[] bytePath = new byte[depth * 4]; + + int byteIndex = 0; + for (int index = 0; index < depth; index++) { + String subpathString = splitPath[index]; + long subpathInt; + if (subpathString.endsWith("'") || subpathString.endsWith("h")) { + subpathString = subpathString.replace("'", "").replace("h", ""); + try { + long tmp = Long.parseLong(subpathString); + subpathInt = tmp + 0x80000000L; + } catch (NumberFormatException e) { + throw new Exception("Failed to parse Bip32 path: " + bip32path); + } + } else { + try { + subpathInt = Long.parseLong(subpathString); + } catch (NumberFormatException e) { + throw new Exception("Failed to parse Bip32 path: " + bip32path); + } + } + byte[] subPathBytes = ByteBuffer.allocate(4).putInt((int) subpathInt).array(); + System.arraycopy(subPathBytes, 0, bytePath, byteIndex, subPathBytes.length); + byteIndex += 4; + } + + return new Bip32Path(depth, bytePath); + } + + public byte[] parseInitiateSecureChannel(ApduResponse rapdu){ + + try{ + byte[] data= rapdu.getData(); + logger.info("SATOCHIPLIB: parseInitiateSecureChannel data: " + toHexString(data)); + + // data= [coordxSize | coordx | sig1Size | sig1 | sig2Size | sig2] + int offset=0; + int coordxSize= 256*data[offset++] + data[offset++]; + + byte[] coordx= new byte[coordxSize]; + System.arraycopy(data, offset, coordx, 0, coordxSize); + offset+=coordxSize; + + // msg1 is [coordx_size | coordx] + byte[] msg1= new byte[2+coordxSize]; + System.arraycopy(data, 0, msg1, 0, msg1.length); + + int sig1Size= 256*data[offset++] + data[offset++]; + byte[] sig1= new byte[sig1Size]; + System.arraycopy(data, offset, sig1, 0, sig1Size); + offset+=sig1Size; + + // msg2 is [coordxSize | coordx | sig1Size | sig1] + byte[] msg2= new byte[2+coordxSize + 2 + sig1Size]; + System.arraycopy(data, 0, msg2, 0, msg2.length); + + int sig2Size= 256*data[offset++] + data[offset++]; + byte[] sig2= new byte[sig2Size]; + System.arraycopy(data, offset, sig2, 0, sig2Size); + offset+=sig2Size; + + byte[] pubkey= recoverPubkey(msg1, sig1, coordx); + + return pubkey; + } catch(Exception e) { + throw new RuntimeException("Exception in parseInitiateSecureChannel: ", e); + } + } + + + public List parseInitiateSecureChannelGetPossibleAuthentikeys(ApduResponse rapdu){ + + try{ + byte[] data= rapdu.getData(); + int dataLength = data.length; + logger.info("SATOCHIPLIB: parseInitiateSecureChannel data: " + toHexString(data)); + + // data= [coordxSize | coordx | sig1Size | sig1 | sig2Size | sig2 | coordxSize(optional) | coordxAuthentikey(optional)] + int offset=0; + int coordxSize= 256*data[offset++] + data[offset++]; + + byte[] coordx= new byte[coordxSize]; + System.arraycopy(data, offset, coordx, 0, coordxSize); + offset+=coordxSize; + + // msg1 is [coordx_size | coordx] + byte[] msg1= new byte[2+coordxSize]; + System.arraycopy(data, 0, msg1, 0, msg1.length); + + int sig1Size= 256*data[offset++] + data[offset++]; + byte[] sig1= new byte[sig1Size]; + System.arraycopy(data, offset, sig1, 0, sig1Size); + offset+=sig1Size; + + // msg2 is [coordxSize | coordx | sig1Size | sig1] + byte[] msg2= new byte[2+coordxSize + 2 + sig1Size]; + System.arraycopy(data, 0, msg2, 0, msg2.length); + + int sig2Size= 256*data[offset++] + data[offset++]; + byte[] sig2= new byte[sig2Size]; + System.arraycopy(data, offset, sig2, 0, sig2Size); + offset+=sig2Size; + + // if authentikey coordx are available + // (currently only for Seedkeeper v0.2 and higher) + if (dataLength>offset+1){ + int coordxAuthentikeySize = 256*data[offset++] + data[offset++]; + if (dataLength>offset+coordxAuthentikeySize){} + byte[] coordxAuthentikey= new byte[coordxAuthentikeySize]; + System.arraycopy(data, offset, coordxAuthentikey, 0, coordxAuthentikeySize); + + byte[] authentikey= recoverPubkey(msg2, sig2, coordxAuthentikey); + List possibleAuthentikeys = new ArrayList(); + possibleAuthentikeys.add(authentikey); + return possibleAuthentikeys; + + } else { + // if authentikey coordx is not provided, two possible pubkeys can be recovered as par ECDSA properties + // recover all possible authentikeys from msg2, sig2 + List possibleAuthentikeys = recoverPossiblePubkeys(msg2, sig2); + + return possibleAuthentikeys; + } + + } catch(Exception e) { + throw new RuntimeException("Exception in parseInitiateSecureChannelGetPossibleAuthentikeys:", e); + } + } + + + public byte[] parseBip32GetAuthentikey(ApduResponse rapdu){ + try{ + byte[] data= rapdu.getData(); + logger.info("SATOCHIPLIB: parseBip32GetAuthentikey data: " + toHexString(data)); + // data: [coordx_size(2b) | coordx | sig_size(2b) | sig ] + + int offset=0; + int coordxSize= 256*(data[offset++] & 0xff) + data[offset++]; + byte[] coordx= new byte[coordxSize]; + System.arraycopy(data, offset, coordx, 0, coordxSize); + offset+=coordxSize; + + // msg1 is [coordx_size | coordx] + byte[] msg1= new byte[2+coordxSize]; + System.arraycopy(data, 0, msg1, 0, msg1.length); + + int sig1Size= 256*data[offset++] + data[offset++]; + byte[] sig1= new byte[sig1Size]; + System.arraycopy(data, offset, sig1, 0, sig1Size); + offset+=sig1Size; + + byte[] pubkey= recoverPubkey(msg1, sig1, coordx); + authentikey= new byte[pubkey.length]; + System.arraycopy(pubkey, 0, authentikey, 0, pubkey.length); + return pubkey; + } catch(Exception e) { + throw new RuntimeException("Exception during Authentikey recovery", e); + } + } + + public byte[][] parseBip32GetExtendedKey(ApduResponse rapdu){//todo: return a wrapped + + try{ + byte[] data= rapdu.getData(); + logger.info("SATOCHIPLIB: parseBip32GetExtendedKey data: " + toHexString(data)); + //data: [chaincode(32b) | coordx_size(2b) | coordx | sig_size(2b) | sig | sig_size(2b) | sig2] + + int offset=0; + byte[] chaincode= new byte[32]; + System.arraycopy(data, offset, chaincode, 0, chaincode.length); + offset+=32; + + int coordxSize= 256*(data[offset++] & 0x7f) + data[offset++]; // (data[32] & 0x80) is ignored (optimization flag) + byte[] coordx= new byte[coordxSize]; + System.arraycopy(data, offset, coordx, 0, coordxSize); + offset+=coordxSize; + + // msg1 is [chaincode | coordx_size | coordx] + byte[] msg1= new byte[32+2+coordxSize]; + System.arraycopy(data, 0, msg1, 0, msg1.length); + + int sig1Size= 256*data[offset++] + data[offset++]; + byte[] sig1= new byte[sig1Size]; + System.arraycopy(data, offset, sig1, 0, sig1Size); + offset+=sig1Size; + + // msg2 is [chaincode | coordxSize | coordx | sig1Size | sig1] + byte[] msg2= new byte[32 + 2+coordxSize + 2 + sig1Size]; + System.arraycopy(data, 0, msg2, 0, msg2.length); + + int sig2Size= 256*data[offset++] + data[offset++]; + byte[] sig2= new byte[sig2Size]; + System.arraycopy(data, offset, sig2, 0, sig2Size); + offset+=sig2Size; + + byte[] pubkey= recoverPubkey(msg1, sig1, coordx); + + // todo: recover from si2 + return new byte[][] {pubkey, chaincode}; + } catch(Exception e) { + System.out.println("SATOCHIPLIB parseBip32GetExtendedKey() exception: "+e); + e.printStackTrace(); + throw new RuntimeException("Is BouncyCastle in the classpath?", e); + } + } + + /**************************************** + * recovery methods * + ****************************************/ + + // based on https://github.com/bitcoinj/bitcoinj/blob/4dc4cf743df9de996282b1aa3fd1d092859774cb/core/src/main/java/org/bitcoinj/core/ECKey.java#L977 + + public int recoverRecId(byte[] hash, BigInteger[] sigBig, byte[] coordx){ + + ECPoint point=null; + for (int recid=0; recid<4; recid++){ + point= Recover(hash, sigBig, recid, false); + + // convert to byte[] + byte[] pubkey= point.getEncoded(false); // uncompressed + byte[] coordx2= new byte[32]; + System.arraycopy(pubkey, 1, coordx2, 0, 32); + + // compare with known coordx + if (Arrays.equals(coordx, coordx2)){ + logger.info("SATOCHIPLIB: Found coordx: " + toHexString(coordx2)); + logger.info("SATOCHIPLIB: Found pubkey: " + toHexString(pubkey)); + return recid; + } + } + return -1; // could not recover pubkey + + } + + public byte[] recoverPubkey(byte[] msg, byte[] sig, byte[] coordx) { + + // convert msg to hash + MessageDigest md; + try { + md = MessageDigest.getInstance("SHA256", "BC"); + } catch(Exception e) { + System.out.println("SATOCHIPLIB recoverPubkey() exception: "+e); + e.printStackTrace(); + throw new RuntimeException("Is BouncyCastle in the classpath?", e); + } + byte[] hash= md.digest(msg); + + // convert sig array to big integer + byte[] sigCompact= parseToCompactSignature(sig); + byte[] r= new byte[32]; + System.arraycopy(sigCompact, 0, r, 0, 32); + byte[] s= new byte[32]; + System.arraycopy(sigCompact, 32, s, 0, 32); + + BigInteger[] sigBig= new BigInteger[2] ; + sigBig[0]= new BigInteger(1, r); + sigBig[1]= new BigInteger(1, s); + + ECPoint point=null; + for (int recid=0; recid<4; recid++){ + point= Recover(hash, sigBig, recid, false); + + // convert to byte[] + byte[] pubkey= point.getEncoded(false); // uncompressed + byte[] coordx2= new byte[32]; + System.arraycopy(pubkey, 1, coordx2, 0, 32); + + //BigInteger xx= point.getAffineX(); + //byte[] coordx2 = X9IntegerConverter.IntegerToBytes(xx, X9IntegerConverter.GetByteLength(CURVE)); + + // compare with known coordx + if (Arrays.equals(coordx, coordx2)){ + logger.info("SATOCHIPLIB: Found coordx: " + toHexString(coordx2)); + //BigInteger yy= point.getAffineY(); + //byte[] coordy = X9IntegerConverter.IntegerToBytes(yy, X9IntegerConverter.GetByteLength(CURVE)); + //byte[] pubkey = new byte[1 + coordx2.Length + coordy.length]; + //pubkey[0]= 0x04; + //System.arraycopy(coordx2, 0, pubkey, 1, coordx2.lenght); + //System.arraycopy(coordy, 0, pubkey, 33, coordy.lenght); + logger.info("SATOCHIPLIB: Found pubkey: " + toHexString(pubkey)); + return pubkey; + } + } + return null; // could not recover pubkey + } + + public List recoverPossiblePubkeys(byte[] msg, byte[] sig) { + List pubkeys = new ArrayList(); + + // convert msg to hash + MessageDigest md; + try { + md = MessageDigest.getInstance("SHA256", "BC"); + } catch(Exception e) { + System.out.println("SATOCHIPLIB recoverPossiblePubkeys() exception: "+e); + e.printStackTrace(); + throw new RuntimeException("Is BouncyCastle in the classpath?", e); + } + byte[] hash= md.digest(msg); + + // convert sig array to big integer + byte[] sigCompact= parseToCompactSignature(sig); + byte[] r= new byte[32]; + System.arraycopy(sigCompact, 0, r, 0, 32); + byte[] s= new byte[32]; + System.arraycopy(sigCompact, 32, s, 0, 32); + + BigInteger[] sigBig= new BigInteger[2] ; + sigBig[0]= new BigInteger(1, r); + sigBig[1]= new BigInteger(1, s); + + ECPoint point=null; + for (int recid=0; recid<4; recid++){ + point= Recover(hash, sigBig, recid, false); + if (point==null){ + logger.warning("SATOCHIPLIB: null point for recid: " + recid); + continue; + } + + // convert to byte[] + byte[] pubkey= point.getEncoded(false); // uncompressed + + // add to list + pubkeys.add(pubkey); + logger.warning("SATOCHIPLIB: Found potential pubkey: " + toHexString(pubkey)); + } + return pubkeys; + } + + public byte[] parseToCompactSignature(byte[] sigIn){ + + // sig is DER format, starting with 30 45 + int sigInSize= sigIn.length; + + int offset=0; + if (sigIn[offset++] != 0x30){ + throw new RuntimeException("Wrong signature byte (should be 0x30) !"); + } + int lt= sigIn[offset++]; + int check= sigIn[offset++]; + if (check != 0x02){ + throw new RuntimeException("Wrong signature check byte (should be 0x02) !"); + } + + int lr= sigIn[offset++]; // should be 0x20 or 0x21 if first r msb is 1 + byte[] r= new byte[32]; + if (lr== 0x20){ + System.arraycopy(sigIn, offset, r, 0, 32); + offset+=32; + }else if (lr== 0x21){ + offset++; // skip zero byte + System.arraycopy(sigIn, offset, r, 0, 32); + offset+=32; + } + else{ + throw new RuntimeException("Wrong signature r length: " + lr + " (should be 0x20 or 0x21) !"); + } + + check= sigIn[offset++]; + if (check != 0x02){ + throw new RuntimeException("Wrong signature check byte (should be 0x02) !"); + } + + int ls= sigIn[offset++]; // should be 0x20 or 0x21 if first s msb is 1 + byte[] s= new byte[32]; + if (ls== 0x20){ + System.arraycopy(sigIn, offset, s, 0, 32); + offset+=32; + } else if (ls== 0x21){ + offset++; // skip zero byte + System.arraycopy(sigIn, offset, s, 0, 32); + offset+=32; + } else{ + throw new RuntimeException("Wrong signature s length: " + ls + " (should be 0x20 or 0x21) !"); + } + + int sigOutSize= 64; + byte[] sigOut= new byte[sigOutSize]; + System.arraycopy(r, 0, sigOut, 0, r.length); + System.arraycopy(s, 0, sigOut, 32, s.length); + + return sigOut; + } + + public ECPoint Recover(byte[] hash, BigInteger[] sig, int recId, boolean check){ + + BigInteger r= sig[0]; + BigInteger s= sig[1]; + + BigInteger n = CURVE.getN(); // Curve order. + BigInteger i = BigInteger.valueOf((long) recId / 2); + BigInteger x = r.add(i.multiply(n)); + BigInteger prime = SecP256K1Curve.q; + + if (x.compareTo(prime) >= 0) { + // Cannot have point co-ordinates larger than this as everything takes place modulo Q. + return null; + } + ECPoint R = decompressKey(x, (recId & 1) == 1); + if (!R.multiply(n).isInfinity()) + return null; + + BigInteger e = new BigInteger(1, hash); //message.toBigInteger(); + + BigInteger eInv = BigInteger.ZERO.subtract(e).mod(n); + BigInteger rInv = r.modInverse(n); + BigInteger srInv = rInv.multiply(s).mod(n); + BigInteger eInvrInv = rInv.multiply(eInv).mod(n); + ECPoint q = ECAlgorithms.sumOfTwoMultiplies(CURVE.getG(), eInvrInv, R, srInv); + + return q; + //return ECKey.fromPublicOnly(q, compressed); + } + + /** Decompress a compressed public key (x co-ord and low-bit of y-coord). */ + private ECPoint decompressKey(BigInteger xBN, boolean yBit) { + X9IntegerConverter x9 = new X9IntegerConverter(); + byte[] compEnc = x9.integerToBytes(xBN, 1 + x9.getByteLength(CURVE.getCurve())); + compEnc[0] = (byte)(yBit ? 0x03 : 0x02); + return CURVE.getCurve().decodePoint(compEnc); + } + + + //based on https://github.com/bitcoinj/bitcoinj/blob/master/core/src/main/java/org/bitcoinj/core/ECKey.java + public BigInteger[] decodeFromDER(byte[] bytes) { + ASN1InputStream decoder = null; + try { + // BouncyCastle by default is strict about parsing ASN.1 integers. We relax this check, because some + // Bitcoin signatures would not parse. + Properties.setThreadOverride("org.bouncycastle.asn1.allow_unsafe_integer", true); + decoder = new ASN1InputStream(bytes); + final ASN1Primitive seqObj = decoder.readObject(); + if (seqObj == null) + throw new RuntimeException("Reached past end of ASN.1 stream."); + if (!(seqObj instanceof DLSequence)) + throw new RuntimeException("Read unexpected class: " + seqObj.getClass().getName()); + final DLSequence seq = (DLSequence) seqObj; + ASN1Integer r, s; + try { + r = (ASN1Integer) seq.getObjectAt(0); + s = (ASN1Integer) seq.getObjectAt(1); + } catch (Exception e) { + throw new RuntimeException(e); + } + // enforce low-S signature (BIP 62) + BigInteger s2= s.getPositiveValue(); + if (s2.compareTo(HALF_CURVE_ORDER) > 0){ + s2= CURVE_ORDER.subtract(s2); + } + + BigInteger[] sigBig= new BigInteger[2]; + sigBig[0]= r.getPositiveValue(); + sigBig[1]= s2; //s.getPositiveValue(); + return sigBig; + + // OpenSSL deviates from the DER spec by interpreting these values as unsigned, though they should not be + // Thus, we always use the positive versions. See: http://r6.ca/blog/20111119T211504Z.html + //return new ECDSASignature(r.getPositiveValue(), s.getPositiveValue()); + } catch (Exception e) { + //throw new SignatureDecodeException(e); + throw new RuntimeException("Exception in decodeFromDER() ", e); + } finally { + if (decoder != null) + try { decoder.close(); } catch (IOException x) {} + Properties.removeThreadOverride("org.bouncycastle.asn1.allow_unsafe_integer"); + } + } + + public boolean verifySig(byte[] msg, byte[] dersig, byte[] pub) { + logger.info("SATOCHIPLIB: In verifySig() "); + logger.info("SATOCHIPLIB: verifySig: authentikey: " + toHexString(pub)); + + // compute hash of message + SHA256Digest digest = new SHA256Digest(); + byte[] hash= new byte[digest.getDigestSize()]; + digest.update(msg, 0, msg.length); + digest.doFinal(hash, 0); + logger.info("SATOCHIPLIB: verifySig: hash: " + toHexString(hash)); + + // convert der-sig to bigInteger[] + BigInteger[] rs= decodeFromDER(dersig); + + ECDSASigner signer = new ECDSASigner(); + ECPublicKeyParameters params = new ECPublicKeyParameters(CURVE.getCurve().decodePoint(pub), CURVE); + signer.init(false, params); + try { + logger.info("SATOCHIPLIB: verifySig: hash: verifySignature: Start" ); + return signer.verifySignature(hash, rs[0], rs[1]); + } catch (NullPointerException e) { + logger.warning("SATOCHIPLIB: Caught NPE inside bouncy castle"+ e); + return false; + } + } + + + /** + * Serializes the APDU to human readable hex string format + * + * @return the hex string representation of the APDU + */ + public static String toHexString(byte[] raw) { + try{ + if ( raw == null ) { + return ""; + } + final StringBuilder hex = new StringBuilder( 2 * raw.length ); + for ( final byte b : raw ) { + hex.append(HEXES.charAt((b & 0xF0) >> 4)).append(HEXES.charAt((b & 0x0F))); + } + return hex.toString(); + } catch(Exception e){ + return "Exception in Util.toHexString()"; + } + } + + public static byte[] fromHexString(String hex){ + + if ((hex.length() % 2) != 0) + throw new IllegalArgumentException("Input string must contain an even number of characters"); + + int len = hex.length(); + byte[] data = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4) + Character.digit(hex.charAt(i+1), 16)); + } + return data; + } + +} \ No newline at end of file diff --git a/hardware/src/main/java/com/satochip/SecureChannelSession.java b/hardware/src/main/java/com/satochip/SecureChannelSession.java new file mode 100644 index 000000000..a45cadc41 --- /dev/null +++ b/hardware/src/main/java/com/satochip/SecureChannelSession.java @@ -0,0 +1,260 @@ +package com.satochip; + +import org.bouncycastle.crypto.params.KeyParameter; +import org.bouncycastle.jce.ECNamedCurveTable; +import org.bouncycastle.jce.interfaces.ECPublicKey; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.jce.spec.ECParameterSpec; +import org.bouncycastle.jce.spec.ECPublicKeySpec; +import org.bouncycastle.crypto.digests.SHA1Digest; +import org.bouncycastle.crypto.macs.HMac; + +import javax.crypto.Cipher; +import javax.crypto.KeyAgreement; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.security.*; +import java.util.logging.Logger; +import java.nio.ByteBuffer; + +/** + * Handles a SecureChannel session with the card. + */ +public class SecureChannelSession { + + private static final Logger logger = Logger.getLogger("org.satochip.client"); + + public static final int SC_SECRET_LENGTH = 16; + public static final int SC_BLOCK_SIZE = 16; + public static final int IV_SIZE = 16; + public static final int MAC_SIZE= 20; + + // secure channel constants + private final static byte INS_INIT_SECURE_CHANNEL = (byte) 0x81; + private final static byte INS_PROCESS_SECURE_CHANNEL = (byte) 0x82; + private final static short SW_SECURE_CHANNEL_REQUIRED = (short) 0x9C20; + private final static short SW_SECURE_CHANNEL_UNINITIALIZED = (short) 0x9C21; + private final static short SW_SECURE_CHANNEL_WRONG_IV= (short) 0x9C22; + private final static short SW_SECURE_CHANNEL_WRONG_MAC= (short) 0x9C23; + + private boolean initialized_secure_channel= false; + + // secure channel keys + private byte[] secret; + private byte[] iv; + private int ivCounter; + byte[] derived_key; + byte[] mac_key; + + // for ECDH + ECParameterSpec ecSpec; + private KeyPair keyPair; + private byte[] publicKey; + + // for session encryption + private Cipher sessionCipher; + private SecretKeySpec sessionEncKey; + private SecureRandom random; + private boolean open; + + /** + * Constructs a SecureChannel session on the client. + */ + public SecureChannelSession() { + random = new SecureRandom(); + open = false; + + try { + // generate keypair + Security.removeProvider("BC"); + Security.insertProviderAt(new BouncyCastleProvider(), 1); + ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1"); + KeyPairGenerator g = KeyPairGenerator.getInstance("EC"); + g.initialize(ecSpec, random); + keyPair = g.generateKeyPair(); + publicKey = ((ECPublicKey) keyPair.getPublic()).getQ().getEncoded(false); + } catch (Exception e) { + logger.warning("SATOCHIPLIB: Exception in SecureChannelSession() constructor: "+ e); + System.out.println("SATOCHIPLIB SecureChannelSession() exception: "+e); + e.printStackTrace(); + throw new RuntimeException("Is BouncyCastle in the classpath?", e); + } + } + + + /** + * Generates a pairing secret. This should be called before each session. The public key of the card is used as input + * for the EC-DH algorithm. The output is stored as the secret. + * + * @param keyData the public key returned by the applet as response to the SELECT command + */ + public void initiateSecureChannel(byte[] keyData) { //TODO: check keyData format + try { + + // Diffie-Hellman + // ECParameterSpec ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1"); + // KeyPairGenerator g = KeyPairGenerator.getInstance("ECDH", "BC"); + // g.initialize(ecSpec, random); + // KeyPair keyPair = g.generateKeyPair(); + // publicKey = ((ECPublicKey) keyPair.getPublic()).getQ().getEncoded(false); + + KeyAgreement keyAgreement = KeyAgreement.getInstance("ECDH", "BC"); + keyAgreement.init(keyPair.getPrivate()); + + ECPublicKeySpec cardKeySpec = new ECPublicKeySpec(ecSpec.getCurve().decodePoint(keyData), ecSpec); + ECPublicKey cardKey = (ECPublicKey) KeyFactory.getInstance("ECDSA", "BC").generatePublic(cardKeySpec); + + keyAgreement.doPhase(cardKey, true); + secret = keyAgreement.generateSecret(); + + // derive session keys + HMac hMac = new HMac(new SHA1Digest()); + hMac.init(new KeyParameter(secret)); + byte[] msg_key= "sc_key".getBytes(); + hMac.update(msg_key, 0, msg_key.length); + byte[] out = new byte[20]; + hMac.doFinal(out, 0); + derived_key= new byte[16]; + System.arraycopy(out, 0, derived_key, 0, 16); + + hMac.reset(); + byte[] msg_mac= "sc_mac".getBytes(); + hMac.update(msg_mac, 0, msg_mac.length); + mac_key = new byte[20]; + hMac.doFinal(mac_key, 0); + + ivCounter= 1; + initialized_secure_channel= true; + } catch (Exception e) { + logger.warning("SATOCHIPLIB: Exception in initiateSecureChannel: "+ e); + System.out.println("SATOCHIPLIB initiateSecureChannel() exception: "+e); + e.printStackTrace(); + throw new RuntimeException("Is BouncyCastle in the classpath?", e); + } + } + + public ApduCommand encrypt_secure_channel(ApduCommand plainApdu){ + + try { + + byte[] plainBytes= plainApdu.serialize(); + + // set iv + iv = new byte[SC_BLOCK_SIZE]; + random.nextBytes(iv); + ByteBuffer bb = ByteBuffer.allocate(4); + bb.putInt(ivCounter); // big endian + byte[] ivCounterBytes= bb.array(); + System.arraycopy(ivCounterBytes, 0, iv, 12, 4); + ivCounter+=2; + logger.info("SATOCHIPLIB: ivCounter: "+ ivCounter); + logger.info("SATOCHIPLIB: ivCounterBytes: "+ SatochipParser.toHexString(ivCounterBytes)); + logger.info("SATOCHIPLIB: iv: "+ SatochipParser.toHexString(iv)); + + // encrypt data + IvParameterSpec ivParameterSpec = new IvParameterSpec(iv); + sessionEncKey = new SecretKeySpec(derived_key, "AES"); + sessionCipher = Cipher.getInstance("AES/CBC/PKCS7PADDING", "BC"); + sessionCipher.init(Cipher.ENCRYPT_MODE, sessionEncKey, ivParameterSpec); + byte[] encrypted = sessionCipher.doFinal(plainBytes); + // logger.info("SATOCHIPLIB: encrypted: "+ SatochipParser.toHexString(derived_key)); + // logger.info("SATOCHIPLIB: encrypted: "+ SatochipParser.toHexString(encrypted)); + + // mac + int offset= 0; + byte[] data_to_mac= new byte[IV_SIZE + 2 + encrypted.length]; + System.arraycopy(iv, offset, data_to_mac, offset, IV_SIZE); + offset+=IV_SIZE; + data_to_mac[offset++]= (byte)(encrypted.length>>8); + data_to_mac[offset++]= (byte)(encrypted.length%256); + System.arraycopy(encrypted, 0, data_to_mac, offset, encrypted.length); + // logger.info("SATOCHIPLIB: data_to_mac: "+ SatochipParser.toHexString(data_to_mac)); + + HMac hMac = new HMac(new SHA1Digest()); + hMac.init(new KeyParameter(mac_key)); + hMac.update(data_to_mac, 0, data_to_mac.length); + byte[] mac = new byte[20]; + hMac.doFinal(mac, 0); + // logger.info("SATOCHIPLIB: mac: "+ SatochipParser.toHexString(mac)); + + //data= list(iv) + [len(ciphertext)>>8, len(ciphertext)&0xff] + list(ciphertext) + [len(mac)>>8, len(mac)&0xff] + list(mac) + byte[] data= new byte[IV_SIZE + 2 + encrypted.length + 2 + MAC_SIZE]; + offset= 0; + System.arraycopy(iv, offset, data, offset, IV_SIZE); + offset+=IV_SIZE; + data[offset++]= (byte)(encrypted.length>>8); + data[offset++]= (byte)(encrypted.length%256); + System.arraycopy(encrypted, 0, data, offset, encrypted.length); + offset+=encrypted.length; + data[offset++]= (byte)(mac.length>>8); + data[offset++]= (byte)(mac.length%256); + System.arraycopy(mac, 0, data, offset, mac.length); + // logger.info("SATOCHIPLIB: data: "+ SatochipParser.toHexString(data)); + + // convert to C-APDU + ApduCommand encryptedApdu= new ApduCommand(0xB0, INS_PROCESS_SECURE_CHANNEL, 0x00, 0x00, data); + return encryptedApdu; + + } catch (Exception e) { + e.printStackTrace(); + logger.warning("SATOCHIPLIB: Exception in encrypt_secure_channel: "+ e); + System.out.println("SATOCHIPLIB encrypt_secure_channel() exception: "+e); + e.printStackTrace(); + throw new RuntimeException("Is BouncyCastle in the classpath?", e); + } + + } + + public ApduResponse decrypt_secure_channel(ApduResponse encryptedApdu){ + + try { + + byte[] encryptedBytes= encryptedApdu.getData(); + if (encryptedBytes.length==0){ + return encryptedApdu; // no decryption needed + } else if (encryptedBytes.length<18){ + throw new RuntimeException("Encrypted response has wrong length!"); + } + + byte[] iv= new byte[IV_SIZE]; + int offset= 0; + System.arraycopy(encryptedBytes, offset, iv, 0, IV_SIZE); + offset+=IV_SIZE; + int ciphertext_size= ((encryptedBytes[offset++] & 0xff)<<8) + (encryptedBytes[offset++] & 0xff); + if ((encryptedBytes.length - offset)!= ciphertext_size){ + throw new RuntimeException("Encrypted response has wrong length!"); + } + byte[] ciphertext= new byte[ciphertext_size]; + System.arraycopy(encryptedBytes, offset, ciphertext, 0, ciphertext.length); + + // decrypt data + IvParameterSpec ivParameterSpec = new IvParameterSpec(iv); + sessionEncKey = new SecretKeySpec(derived_key, "AES"); + sessionCipher = Cipher.getInstance("AES/CBC/PKCS7PADDING", "BC"); + sessionCipher.init(Cipher.DECRYPT_MODE, sessionEncKey, ivParameterSpec); + byte[] decrypted = sessionCipher.doFinal(ciphertext); + + ApduResponse plainResponse= new ApduResponse(decrypted, (byte)0x90, (byte)0x00); + return plainResponse; + + } catch (Exception e) { + e.printStackTrace(); + logger.warning("SATOCHIPLIB: Exception in decrypt_secure_channel: "+ e); + throw new RuntimeException("Exception during secure channel decryption: ", e); + } + + } + + public boolean initializedSecureChannel(){ + return initialized_secure_channel; + } + + public byte[] getPublicKey(){ + return publicKey; + } + + public void resetSecureChannel(){ + initialized_secure_channel= false; + } + +} diff --git a/hardware/src/main/java/com/satochip/WrongPINException.java b/hardware/src/main/java/com/satochip/WrongPINException.java new file mode 100644 index 000000000..bc6bb5752 --- /dev/null +++ b/hardware/src/main/java/com/satochip/WrongPINException.java @@ -0,0 +1,27 @@ +package com.satochip; + +/** + * Exception thrown when checking PIN/PUK + */ +public class WrongPINException extends ApduException { + private int retryAttempts; + + /** + * Construct an exception with the given number of retry attempts. + * + * @param retryAttempts the number of retry attempts + */ + public WrongPINException(int retryAttempts) { + super("Wrong PIN"); + this.retryAttempts = retryAttempts; + } + + /** + * Returns the number of available retry attempts. + * + * @return the number of retry attempts + */ + public int getRetryAttempts() { + return retryAttempts; + } +} diff --git a/hardware/src/main/java/com/satochip/WrongPINLegacyException.java b/hardware/src/main/java/com/satochip/WrongPINLegacyException.java new file mode 100644 index 000000000..9b3379a66 --- /dev/null +++ b/hardware/src/main/java/com/satochip/WrongPINLegacyException.java @@ -0,0 +1,14 @@ +package com.satochip; + +/** + * Exception thrown when checking PIN/PUK + */ +public class WrongPINLegacyException extends ApduException { + + /** + * Construct an exception with the given number of retry attempts. + */ + public WrongPINLegacyException() { + super("Wrong PIN Legacy"); + } +}