This commit is contained in:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user