forked from thijooree/android
187 lines
7.6 KiB
Kotlin
187 lines
7.6 KiB
Kotlin
package sh.sar.basedbank.nfc
|
|
|
|
import android.nfc.Tag
|
|
import android.nfc.tech.IsoDep
|
|
import java.io.ByteArrayOutputStream
|
|
|
|
/**
|
|
* Minimal contactless EMV reader: selects the payment app, runs GPO and reads the
|
|
* AFL records until it finds the PAN (tag 5A / Track 2 tag 57) and expiry (5F24 / Track 2).
|
|
*/
|
|
object EmvCardReader {
|
|
|
|
/** [expiry] is "MM/YY". */
|
|
data class CardData(val pan: String, val expiry: String?)
|
|
|
|
private class Collected {
|
|
var pan: String? = null
|
|
var expiry: String? = null
|
|
val complete get() = pan != null && expiry != null
|
|
fun result() = pan?.let { CardData(it, expiry) }
|
|
}
|
|
|
|
/** Returns the card data, or null if the PAN couldn't be read. Blocking — call off the main thread. */
|
|
fun read(tag: Tag): CardData? {
|
|
val iso = IsoDep.get(tag) ?: return null
|
|
val c = Collected()
|
|
iso.use {
|
|
it.connect()
|
|
it.timeout = 5000
|
|
|
|
val aids = selectPpse(it).ifEmpty { KNOWN_AIDS }
|
|
for (aid in aids) {
|
|
val fci = transceive(it, selectApdu(aid)) ?: continue
|
|
val pdol = findTag(fci, 0x9F38)
|
|
val gpo = transceive(it, gpoApdu(pdol)) ?: continue
|
|
collect(gpo, c)
|
|
if (c.complete) return c.result()
|
|
|
|
// Format 1 (tag 80): AIP (2 bytes) + AFL. Format 2 (tag 77): AFL in tag 94.
|
|
val afl = findTag(gpo, 0x94)
|
|
?: findTag(gpo, 0x80)?.let { b -> if (b.size > 2) b.copyOfRange(2, b.size) else null }
|
|
?: continue
|
|
for (i in 0 until afl.size / 4) {
|
|
val sfi = (afl[i * 4].toInt() and 0xFF) shr 3
|
|
val first = afl[i * 4 + 1].toInt() and 0xFF
|
|
val last = afl[i * 4 + 2].toInt() and 0xFF
|
|
for (rec in first..last) {
|
|
val data = transceive(it, readRecordApdu(sfi, rec)) ?: continue
|
|
collect(data, c)
|
|
if (c.complete) return c.result()
|
|
}
|
|
}
|
|
if (c.pan != null) return c.result()
|
|
}
|
|
}
|
|
return c.result()
|
|
}
|
|
|
|
private val KNOWN_AIDS = listOf(
|
|
"A0000000031010", // Visa
|
|
"A0000000041010", // Mastercard
|
|
"A0000000043060", // Maestro
|
|
"A000000025010801", // Amex
|
|
"A0000003330101", // UnionPay
|
|
).map { hex(it) }
|
|
|
|
private fun selectPpse(iso: IsoDep): List<ByteArray> {
|
|
val resp = transceive(iso, selectApdu("2PAY.SYS.DDF01".toByteArray())) ?: return emptyList()
|
|
return findAllTags(resp, 0x4F)
|
|
}
|
|
|
|
private fun collect(data: ByteArray, c: Collected) {
|
|
findTag(data, 0x5A)?.let { c.pan = c.pan ?: toHex(it).trimEnd('F') }
|
|
findTag(data, 0x57)?.let { raw ->
|
|
val t2 = toHex(raw)
|
|
c.pan = c.pan ?: t2.substringBefore('D')
|
|
// Track 2: PAN 'D' YYMM service-code ...
|
|
val yymm = t2.substringAfter('D', "").take(4)
|
|
if (c.expiry == null && yymm.length == 4) c.expiry = "${yymm.substring(2, 4)}/${yymm.substring(0, 2)}"
|
|
}
|
|
findTag(data, 0x5F24)?.let { raw ->
|
|
val yymmdd = toHex(raw)
|
|
if (yymmdd.length >= 4) c.expiry = "${yymmdd.substring(2, 4)}/${yymmdd.substring(0, 2)}"
|
|
}
|
|
}
|
|
|
|
private fun selectApdu(aid: ByteArray): ByteArray =
|
|
byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00, aid.size.toByte()) + aid + byteArrayOf(0x00)
|
|
|
|
private fun readRecordApdu(sfi: Int, rec: Int): ByteArray =
|
|
byteArrayOf(0x00, 0xB2.toByte(), rec.toByte(), ((sfi shl 3) or 0x04).toByte(), 0x00)
|
|
|
|
/** Builds GPO with the PDOL filled in: sensible TTQ/country/currency/date, zeros otherwise. */
|
|
private fun gpoApdu(pdol: ByteArray?): ByteArray {
|
|
val out = ByteArrayOutputStream()
|
|
if (pdol != null) {
|
|
var i = 0
|
|
while (i < pdol.size) {
|
|
var tag = pdol[i].toInt() and 0xFF
|
|
i++
|
|
if (tag and 0x1F == 0x1F) {
|
|
do {
|
|
tag = (tag shl 8) or (pdol[i].toInt() and 0xFF)
|
|
} while (pdol[i++].toInt() and 0x80 != 0 && i < pdol.size)
|
|
}
|
|
if (i >= pdol.size) break
|
|
val len = pdol[i++].toInt() and 0xFF
|
|
val value = when (tag) {
|
|
0x9F66 -> hex("B620C000") // TTQ: contactless qVSDC, online capable
|
|
0x9F1A, 0x5F2A -> hex("0462") // Maldives / MVR
|
|
0x9A -> hex("260101")
|
|
0x9C -> hex("00")
|
|
0x9F37 -> hex("12345678")
|
|
else -> ByteArray(len)
|
|
}
|
|
out.write(value.copyOf(len))
|
|
}
|
|
}
|
|
val pdolData = out.toByteArray()
|
|
val body = byteArrayOf(0x83.toByte(), pdolData.size.toByte()) + pdolData
|
|
return byteArrayOf(0x80.toByte(), 0xA8.toByte(), 0x00, 0x00, body.size.toByte()) + body + byteArrayOf(0x00)
|
|
}
|
|
|
|
/** Sends an APDU, returning the response data on 9000 (following 61xx / 6Cxx), else null. */
|
|
private fun transceive(iso: IsoDep, apdu: ByteArray): ByteArray? {
|
|
var resp = iso.transceive(apdu)
|
|
if (resp.size < 2) return null
|
|
var sw1 = resp[resp.size - 2].toInt() and 0xFF
|
|
if (sw1 == 0x6C) {
|
|
val retry = apdu.copyOf()
|
|
retry[retry.size - 1] = resp[resp.size - 1]
|
|
resp = iso.transceive(retry)
|
|
sw1 = resp[resp.size - 2].toInt() and 0xFF
|
|
}
|
|
if (sw1 == 0x61) {
|
|
resp = iso.transceive(byteArrayOf(0x00, 0xC0.toByte(), 0x00, 0x00, resp[resp.size - 1]))
|
|
sw1 = resp[resp.size - 2].toInt() and 0xFF
|
|
}
|
|
val sw2 = resp[resp.size - 1].toInt() and 0xFF
|
|
return if (sw1 == 0x90 && sw2 == 0x00) resp.copyOf(resp.size - 2) else null
|
|
}
|
|
|
|
// ── BER-TLV ──────────────────────────────────────────────────────────────
|
|
|
|
private fun findTag(data: ByteArray, target: Int): ByteArray? = findAllTags(data, target).firstOrNull()
|
|
|
|
private fun findAllTags(data: ByteArray, target: Int): List<ByteArray> {
|
|
val found = mutableListOf<ByteArray>()
|
|
walk(data, 0, data.size, target, found)
|
|
return found
|
|
}
|
|
|
|
private fun walk(data: ByteArray, start: Int, end: Int, target: Int, found: MutableList<ByteArray>) {
|
|
var i = start
|
|
while (i < end) {
|
|
val b0 = data[i].toInt() and 0xFF
|
|
if (b0 == 0x00 || b0 == 0xFF) { i++; continue } // padding
|
|
val constructed = b0 and 0x20 != 0
|
|
var tag = b0
|
|
i++
|
|
if (b0 and 0x1F == 0x1F) {
|
|
while (i < end) {
|
|
val b = data[i++].toInt() and 0xFF
|
|
tag = (tag shl 8) or b
|
|
if (b and 0x80 == 0) break
|
|
}
|
|
}
|
|
if (i >= end) return
|
|
var len = data[i++].toInt() and 0xFF
|
|
if (len and 0x80 != 0) {
|
|
val n = len and 0x7F
|
|
len = 0
|
|
repeat(n) { if (i < end) len = (len shl 8) or (data[i++].toInt() and 0xFF) }
|
|
}
|
|
if (len < 0 || i + len > end) return
|
|
if (tag == target) found.add(data.copyOfRange(i, i + len))
|
|
if (constructed) walk(data, i, i + len, target, found)
|
|
i += len
|
|
}
|
|
}
|
|
|
|
private fun hex(s: String): ByteArray =
|
|
ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
|
|
|
|
private fun toHex(b: ByteArray): String = b.joinToString("") { "%02X".format(it) }
|
|
}
|