tap-to-pay part 1
Auto Tag on Version Change / check-version (push) Failing after 14m38s

This commit is contained in:
2026-05-29 15:43:13 +05:00
parent 71e893faf8
commit 0f77216d2d
9 changed files with 813 additions and 5 deletions
@@ -0,0 +1,179 @@
package sh.sar.basedbank.nfc
import android.nfc.cardemulation.HostApduService
import android.os.Bundle
import android.util.Log
import sh.sar.basedbank.api.bml.BmlWalletToken
/**
* HCE service that emulates a BML contactless payment card.
*
* Implements the minimal EMV mag-stripe contactless flow:
* SELECT PPSE → SELECT AID → GET PROCESSING OPTIONS → READ RECORD
*
* Each BmlWalletToken is single-use and is set via [setToken] before tapping.
* After READ RECORD is sent the [onTransactionComplete] callback fires.
*/
class BmlHostCardEmulatorService : HostApduService() {
private var gpoSent = false
override fun processCommandApdu(commandApdu: ByteArray?, extras: Bundle?): ByteArray {
if (commandApdu == null) return SW_UNKNOWN_ERROR
val apdu = Apdu(commandApdu)
if (apdu.isError) return apdu.errorResponse()
return when (apdu.ins) {
INS_SELECT -> handleSelect(apdu)
INS_GPO -> handleGpo()
INS_READ -> handleReadRecord()
else -> SW_UNKNOWN_ERROR
}
}
override fun onDeactivated(reason: Int) {
if (!gpoSent) onTransactionComplete?.invoke(false)
gpoSent = false
}
// ── APDU handlers ──────────────────────────────────────────────────────────
private fun handleSelect(apdu: Apdu): ByteArray {
val data = apdu.data ?: return SW_UNKNOWN_ERROR
val token = activeToken ?: return SW_UNKNOWN_ERROR
return when {
data.contentEquals(PPSE_BYTES) -> {
// SELECT PPSE
val resp = buildSelectPpseResponse(token.appCode, applicationLabel(token.appCode), "01")
hexToBytes(resp)
}
data.contentEquals(hexToBytes(token.appCode)) -> {
// SELECT AID
val resp = buildSelectAidResponse(token.appCode, applicationLabel(token.appCode))
hexToBytes(resp)
}
else -> SW_UNKNOWN_ERROR
}
}
private fun handleGpo(): ByteArray {
gpoSent = true
// AIP=0080 (mag-stripe mode), AFL=08010100 (SFI=1, record 1-1, offline 0)
val miscData = "008008010100"
val body = tlv("80", miscData)
return hexToBytes(body + SW_OK_HEX)
}
private fun handleReadRecord(): ByteArray {
val token = activeToken ?: return SW_UNKNOWN_ERROR
val track2 = buildTrack2(token)
val body = tlv("70", tlv("57", track2))
val response = hexToBytes(body + SW_OK_HEX)
onTransactionComplete?.invoke(true)
return response
}
// ── TLV / APDU response builders ───────────────────────────────────────────
private fun buildSelectPpseResponse(aid: String, label: String, priority: String): String {
val priorityTlv = tlv("87", priority) // tag 87
val aidTlv = tlv("4F", aid) // tag 4F (ADF Name)
val appEntry = tlv("61", aidTlv + priorityTlv) // tag 61
val ppseTlv = tlv("84", PPSE_HEX) // tag 84 (DF Name)
val inner = tlv("BF0C", appEntry) // tag BF0C
val propTemplate = tlv("A5", inner) // tag A5
val fci = tlv("6F", ppseTlv + propTemplate) // tag 6F
return fci + SW_OK_HEX
}
private fun buildSelectAidResponse(aid: String, label: String): String {
val aidTlv = tlv("84", aid) // tag 84
val labelTlv = tlv("50", asciiToHex(label)) // tag 50
val pdolTlv = tlv("9F38", "9F6602") // PDOL: TTQ 2 bytes
val propTemplate = tlv("A5", labelTlv + pdolTlv) // tag A5
val fci = tlv("6F", aidTlv + propTemplate) // tag 6F
return fci + SW_OK_HEX
}
private fun buildTrack2(token: BmlWalletToken): String {
var t2 = "${token.token}D${token.expiry}${token.serviceCode}${token.data}"
if (t2.length % 2 != 0) t2 += "F"
return t2
}
// ── Helpers ─────────────────────────────────────────────────────────────────
/** Build BER-TLV: tag (hex string, 1 or 2 bytes) + DER length + data (hex string). */
private fun tlv(tagHex: String, dataHex: String): String {
val lenBytes = dataHex.length / 2
val lenHex = when {
lenBytes <= 0x7F -> lenBytes.toHexByte()
lenBytes <= 0xFF -> "81" + lenBytes.toHexByte()
else -> "82" + (lenBytes shr 8).toHexByte() + (lenBytes and 0xFF).toHexByte()
}
return tagHex + lenHex + dataHex
}
private fun Int.toHexByte(): String = toString(16).padStart(2, '0').uppercase()
private fun asciiToHex(s: String): String =
s.toByteArray(Charsets.US_ASCII).joinToString("") { "%02X".format(it) }
private fun hexToBytes(hex: String): ByteArray {
val s = hex.uppercase()
return ByteArray(s.length / 2) { i ->
s.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
}
// ── APDU parser ─────────────────────────────────────────────────────────────
private inner class Apdu(raw: ByteArray) {
val isError: Boolean
val ins: Int
val data: ByteArray?
init {
if (raw.size < 4) {
isError = true; ins = -1; data = null
} else {
isError = false
ins = raw[1].toInt() and 0xFF
val lc = if (raw.size > 4) raw[4].toInt() and 0xFF else 0
data = if (lc > 0 && raw.size >= 5 + lc) raw.copyOfRange(5, 5 + lc) else null
}
}
fun errorResponse() = SW_UNKNOWN_ERROR
}
companion object {
private const val TAG = "BmlHCE"
private const val INS_SELECT = 0xA4
private const val INS_GPO = 0xA8
private const val INS_READ = 0xB2
private val PPSE_HEX = "325041592E5359532E4444463031" // "2PAY.SYS.DDF01"
private val PPSE_BYTES = byteArrayOf(
0x32,0x50,0x41,0x59,0x2E,0x53,0x59,0x53,0x2E,0x44,0x44,0x46,0x30,0x31
)
private const val SW_OK_HEX = "9000"
private val SW_UNKNOWN_ERROR = byteArrayOf(0x6F.toByte(), 0x00.toByte())
@Volatile var activeToken: BmlWalletToken? = null
@Volatile var onTransactionComplete: ((success: Boolean) -> Unit)? = null
fun setToken(token: BmlWalletToken) { activeToken = token }
fun clearToken() { activeToken = null }
fun applicationLabel(aidHex: String): String = when {
aidHex.startsWith("A0000000031010", ignoreCase = true) -> "VISA"
aidHex.startsWith("A0000000041010", ignoreCase = true) -> "MASTERCARD"
aidHex.startsWith("A000000025", ignoreCase = true) -> "AMEX"
else -> "BML"
}
}
}
@@ -0,0 +1,105 @@
package sh.sar.basedbank.nfc
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.api.bml.BmlWalletToken
/**
* Encrypted local store for a pool of single-use HCE payment tokens.
* Tokens are consumed one per NFC transaction.
*/
class HceTokenStore(context: Context) {
private val prefs: SharedPreferences = try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"hce_token_store",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (_: Exception) {
context.getSharedPreferences("hce_token_store_fallback", Context.MODE_PRIVATE)
}
fun saveTokens(tokens: List<BmlWalletToken>) {
val arr = JSONArray()
tokens.forEach { t ->
arr.put(JSONObject().apply {
put("token", t.token)
put("expiry", t.expiry)
put("app_code", t.appCode)
put("service_code", t.serviceCode)
put("data", t.data)
put("valid_until", t.validUntil)
})
}
prefs.edit().putString("tokens", arr.toString()).apply()
}
/** Remove and return the next token, or null if pool is empty / all expired. */
fun popToken(): BmlWalletToken? {
val raw = prefs.getString("tokens", null) ?: return null
val arr = try { JSONArray(raw) } catch (_: Exception) { return null }
if (arr.length() == 0) return null
val now = System.currentTimeMillis()
var found: BmlWalletToken? = null
var foundIndex = -1
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
val validUntil = o.optString("valid_until", "")
if (validUntil.isNotBlank() && isExpired(validUntil, now)) continue
found = BmlWalletToken(
token = o.getString("token"),
expiry = o.getString("expiry"),
appCode = o.getString("app_code"),
serviceCode = o.getString("service_code"),
data = o.optString("data", ""),
validUntil = validUntil
)
foundIndex = i
break
}
if (found == null || foundIndex < 0) {
prefs.edit().remove("tokens").apply()
return null
}
// Remove consumed token
val remaining = JSONArray()
for (i in 0 until arr.length()) {
if (i != foundIndex) remaining.put(arr.getJSONObject(i))
}
prefs.edit().putString("tokens", remaining.toString()).apply()
return found
}
fun remainingCount(): Int {
val raw = prefs.getString("tokens", null) ?: return 0
return try { JSONArray(raw).length() } catch (_: Exception) { 0 }
}
fun hasTokens(): Boolean = remainingCount() > 0
fun clear() {
prefs.edit().remove("tokens").apply()
}
/** "YYYY-MM-DD HH:mm:ss.SSS" → check if past now */
private fun isExpired(validUntil: String, nowMs: Long): Boolean = try {
val fmt = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", java.util.Locale.US)
fmt.timeZone = java.util.TimeZone.getTimeZone("UTC")
val d = fmt.parse(validUntil) ?: return false
d.time < nowMs
} catch (_: Exception) { false }
}