Compare commits

...

2 commits

Author SHA1 Message Date
002c90ba11 Prefer APDU mode when advertised by CCID reader
All checks were successful
Build Debug APKs / build-debug (push) Successful in 4m23s
2026-03-23 21:57:11 -04:00
34c7ffb0d5 Support TPDU-based CCID readers (#295)
resolves #37

For TPDU based readers like USB 2.0-CRW 2 additional commands are needed in initialisation. Add them

Reviewed-on: #295
Reviewed-by: septs <github@septs.pw>
Co-authored-by: Vladimir Serbinenko <phcoder@gmail.com>
Co-committed-by: Vladimir Serbinenko <phcoder@gmail.com>
2026-03-23 21:40:15 -04:00
3 changed files with 147 additions and 1 deletions

View file

@ -21,9 +21,70 @@ class UsbApduInterface(
private var channels = mutableSetOf<Int>()
// ATR parser
// Specs: ISO/IEC 7816-3:2006 8.2 Answer-to-Reset
// See also: https://en.wikipedia.org/wiki/Answer_to_reset
class ParsedAtr private constructor(val ts: Byte?, val t0: Byte?, val ta1: Byte?, val tb1: Byte?, val tc1: Byte?, val td1: Byte?, val ta2: Byte?, val tb2: Byte?, val tc2: Byte?, val td2: Byte?) {
companion object {
fun parse(atr: ByteArray): ParsedAtr {
val ts = atr[0]
val t0 = atr[1]
val tx1 = arrayOf<Byte?>(null, null, null, null)
val tx2 = arrayOf<Byte?>(null, null, null, null)
var pointer = 2
for (i in 0..3) {
if (t0.toInt() and (0x10 shl i) != 0) {
tx1[i] = atr[pointer]
pointer++
}
}
val td1 = tx1[3] ?: 0
for (i in 0..3) {
if (td1.toInt() and (0x10 shl i) != 0) {
tx2[i] = atr[pointer]
pointer++
}
}
return ParsedAtr(ts=ts, t0=t0, ta1=tx1[0], tb1=tx1[1], tc1=tx1[2], td1=tx1[3],
ta2=tx2[0], tb2=tx2[1], tc2=tx2[2], td2=tx2[3],
)
}
}
}
override fun connect() {
ccidCtx.connect()
if (ccidCtx.transceiver.useTpdu) {
// Send parameter selection
// Specs: USB-CCID 3.2.1 TPDU level of exchange
val parsedAtr = ParsedAtr.parse(atr!!)
val ta1 = parsedAtr.ta1 ?: 0x11.toByte()
val pts1 = ta1 // TODO: Check that reader supports baud rate proposed by the card
val pps = byteArrayOf(0xff.toByte(), 0x10.toByte(), pts1, 0x00.toByte())
Log.d(TAG, "PTS1=${pts1} PPS: ${pps.encodeHex()}")
ccidCtx.transceiver.sendXfrBlock(pps)
// Send Set Parameters
// Specs: USB-CCID 6.1.7 PC_to_RDR_SetParameters
val param = byteArrayOf(
pts1,
(if (parsedAtr.ts == 0x3F.toByte()) 0x02 else 0x00),
parsedAtr.tc1 ?: 0,
parsedAtr.tc2 ?: 0x0A,
0x00
)
Log.d(TAG, "Param: ${param.encodeHex()}")
ccidCtx.transceiver.sendParamBlock(param)
}
// Send Terminal Capabilities
// Specs: ETSI TS 102 221 v15.0.0 - 11.1.19 TERMINAL CAPABILITY
val terminalCapabilities = buildCmd(

View file

@ -84,6 +84,10 @@ data class UsbCcidDescription(
private fun hasFeature(feature: Int) = (dwFeatures and feature) != 0
private fun hasTpduLevel() = hasFeature(FEATURE_EXCHANGE_LEVEL_TPDU)
private fun hasShortApduLevel() = hasFeature(FEATURE_EXCHANGE_LEVEL_SHORT_APDU)
private fun hasExtendedApduLevel() = hasFeature(FEATURE_EXCHANGE_LEVEL_EXTENDED_APDU)
val voltages: List<Voltage>
get() {
if (hasFeature(FEATURE_AUTOMATIC_VOLTAGE)) return listOf(Voltage.AUTO)
@ -95,4 +99,7 @@ data class UsbCcidDescription(
val hasT0Protocol: Boolean
get() = (dwProtocols and MASK_T0_PROTO) != 0
}
val useTpdu: Boolean
get() = hasTpduLevel() && !hasShortApduLevel() && !hasExtendedApduLevel()
}

View file

@ -143,6 +143,12 @@ class UsbCcidTransceiver(
val hasAutomaticPps = usbCcidDescription.hasAutomaticPps
/**
* True if this reader only supports TPDU exchange level (no APDU bits advertised).
* These readers require PPS + SetParameters handshake during connect().
*/
val useTpdu = usbCcidDescription.useTpdu
private val inputBuffer = ByteArray(usbBulkIn.maxPacketSize)
private var currentSequenceNumber: Byte = 0
@ -158,6 +164,46 @@ class UsbCcidTransceiver(
}
}
private fun receiveParamBlock(expectedSequenceNumber: Byte): ByteArray {
var response: ByteArray?
do {
response = receiveParamBlockImmediate(expectedSequenceNumber)
} while (response!![7] == 0x80.toByte())
return response
}
private fun receiveParamBlockImmediate(expectedSequenceNumber: Byte): ByteArray {
/*
* Some USB CCID devices (notably NitroKey 3) may time-out and need a subsequent poke to
* carry on communications. No particular reason why the number 3 was chosen. If we get a
* zero-sized reply (or a time-out), we try again. Clamped retries prevent an infinite loop
* if things really turn sour.
*/
var attempts = 3
Log.d(TAG, "Receive data block immediate seq=$expectedSequenceNumber")
var readBytes: Int
do {
readBytes = usbConnection.bulkTransfer(
usbBulkIn, inputBuffer, inputBuffer.size, DEVICE_COMMUNICATE_TIMEOUT_MILLIS
)
if (runBlocking { verboseLoggingFlow.first() }) {
Log.d(TAG, "Received $readBytes bytes: ${inputBuffer.encodeHex()}")
}
} while (readBytes <= 0 && attempts-- > 0)
if (inputBuffer[0] != 0x82.toByte()) {
throw UsbTransportException(buildString {
append("USB-CCID error - bad CCID header")
append(", type ")
append("%d (expected %d)".format(inputBuffer[0], MESSAGE_TYPE_RDR_TO_PC_DATA_BLOCK))
if (expectedSequenceNumber != inputBuffer[6]) {
append(", sequence number ")
append("%d (expected %d)".format(inputBuffer[6], expectedSequenceNumber))
}
})
}
return inputBuffer
}
private fun receiveDataBlock(expectedSequenceNumber: Byte): CcidDataBlock {
var response: CcidDataBlock?
do {
@ -283,6 +329,38 @@ class UsbCcidTransceiver(
return ccidDataBlock
}
fun sendParamBlock(
payload: ByteArray
): ByteArray {
val startTime = SystemClock.elapsedRealtime()
val l = payload.size
val sequenceNumber: Byte = currentSequenceNumber++
val headerData = byteArrayOf(
0x61.toByte(),
l.toByte(),
(l shr 8).toByte(),
(l shr 16).toByte(),
(l shr 24).toByte(),
SLOT_NUMBER.toByte(),
sequenceNumber,
0x00.toByte(),
0x00.toByte(),
0x00.toByte()
)
val data: ByteArray = headerData + payload
Log.d(TAG, "USB ParamBlock: ${data.encodeHex()}")
var sentBytes = 0
while (sentBytes < data.size) {
val bytesToSend = usbBulkOut.maxPacketSize.coerceAtMost(data.size - sentBytes)
sendRaw(data, sentBytes, bytesToSend)
sentBytes += bytesToSend
}
val ccidDataBlock = receiveParamBlock(sequenceNumber)
val elapsedTime = SystemClock.elapsedRealtime() - startTime
Log.d(TAG, "USB ParamBlock call took ${elapsedTime}ms")
return ccidDataBlock
}
fun iccPowerOn(): CcidDataBlock {
val startTime = SystemClock.elapsedRealtime()
skipAvailableInput()