Initial import of Brizzi HCE project
This commit is contained in:
40
app/src/debug/res/xml/apdu_service.xml
Normal file
40
app/src/debug/res/xml/apdu_service.xml
Normal file
@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/service_description"
|
||||
android:requireDeviceUnlock="false">
|
||||
<!--
|
||||
TESTING ONLY:
|
||||
Gunakan kategori other agar Android tidak memicu payment app chooser.
|
||||
File ini hanya dipakai pada buildType debug.
|
||||
Kembali ke model produksi dengan payment category di main/apdu_service.xml.
|
||||
-->
|
||||
|
||||
<aid-group
|
||||
android:category="other"
|
||||
android:description="@string/aid_group_description">
|
||||
<aid-filter android:name="325041592E5359532E4444463031" />
|
||||
<aid-filter android:name="F0010203040506" />
|
||||
<aid-filter android:name="5A000003" />
|
||||
<aid-filter android:name="A000000003" />
|
||||
<aid-filter android:name="A000000004" />
|
||||
<aid-filter android:name="A00000000301000000" />
|
||||
<aid-filter android:name="A00000000303000000" />
|
||||
<aid-filter android:name="5A00000301000000" />
|
||||
<aid-filter android:name="5A00000303000000" />
|
||||
<!--
|
||||
Tambahan untuk membantu debug terhadap berbagai AID dari reader.
|
||||
-->
|
||||
<aid-filter android:name="0000000000000001" />
|
||||
<aid-filter android:name="A000424E49100001" />
|
||||
<aid-filter android:name="A0000005714E4A43" />
|
||||
<aid-filter android:name="D3600000030003" />
|
||||
<aid-filter android:name="A0000000180F0000018001" />
|
||||
<aid-filter android:name="D4100000030001" />
|
||||
<aid-filter android:name="D360" />
|
||||
<aid-filter android:name="D410" />
|
||||
<aid-filter android:name="A000424E49" />
|
||||
<aid-filter android:name="A000000571" />
|
||||
<aid-filter android:name="A000000018" />
|
||||
<aid-filter android:name="D276" />
|
||||
</aid-group>
|
||||
</host-apdu-service>
|
||||
43
app/src/main/AndroidManifest.xml
Normal file
43
app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.nfc.hce"
|
||||
android:required="true" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="false"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.BrizziHce">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".hce.BrizziHostApduService"
|
||||
android:description="@string/service_description"
|
||||
android:exported="true"
|
||||
android:directBootAware="true"
|
||||
android:permission="android.permission.BIND_NFC_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.nfc.cardemulation.host_apdu_service"
|
||||
android:resource="@xml/apdu_service" />
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
217
app/src/main/java/com/korancrew/brizzi/MainActivity.kt
Normal file
217
app/src/main/java/com/korancrew/brizzi/MainActivity.kt
Normal file
@ -0,0 +1,217 @@
|
||||
package com.korancrew.brizzi
|
||||
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.ComponentName
|
||||
import android.os.Bundle
|
||||
import android.nfc.NfcAdapter
|
||||
import android.nfc.cardemulation.CardEmulation
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
import com.korancrew.brizzi.hce.BrizziSecurityMetrics
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Entry point UI aplikasi.
|
||||
*
|
||||
* Peran utama:
|
||||
* - Menampilkan status sederhana metrik keamanan (hanya mode debug).
|
||||
* - Menyediakan action helper via adb untuk dump/reset metrik runtime.
|
||||
*
|
||||
* Catatan:
|
||||
* - Semua metrik dan status sensitif tidak dimasukkan ke UI produksi.
|
||||
* - FLAG_SECURE dipasang agar layar tidak bisa di-screenshot / screen-record dengan mudah.
|
||||
*/
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private val uiHandler = Handler(Looper.getMainLooper())
|
||||
private var apduLogRefreshRunnable: Runnable? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
handleDebugIntent(intent)
|
||||
|
||||
val clearApduButton = findViewById<Button>(R.id.clearApduLogButton)
|
||||
clearApduButton.setOnClickListener {
|
||||
clearApduLogFile()
|
||||
refreshApduLog()
|
||||
findViewById<TextView>(R.id.messageView).text = "APDU log cleared"
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
setPreferredPaymentService()
|
||||
if (isDebuggable()) {
|
||||
refreshApduLog()
|
||||
scheduleApduLogRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
clearPreferredPaymentService()
|
||||
stopApduLogRefresh()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: android.content.Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleDebugIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleDebugIntent(intent: android.content.Intent?) {
|
||||
val messageView = findViewById<TextView>(R.id.messageView)
|
||||
val action = intent?.action
|
||||
val debugPrefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
|
||||
if (isDebuggable()) {
|
||||
when (action) {
|
||||
ACTION_DUMP_METRICS -> {
|
||||
// Dump metrik runtime ke UI untuk cepat verifikasi health/incident saat QA.
|
||||
val metrics = BrizziSecurityMetrics(this)
|
||||
val output = buildString {
|
||||
append("Health: ").append(metrics.exportHealthSnapshot()).append('\n')
|
||||
append("Incidents: ").append(metrics.incidentSummary()).append('\n')
|
||||
append("Snapshot: ").append(metrics.snapshot()).append('\n')
|
||||
append("MetricsJson: ").append(metrics.reportJson())
|
||||
}
|
||||
messageView.text = output
|
||||
}
|
||||
ACTION_RESET_METRICS -> {
|
||||
// Reset seluruh counter metrik dan hapus export file terakhir.
|
||||
BrizziSecurityMetrics(this).reset()
|
||||
runCatching { File(filesDir, "security_metrics_report.txt").delete() }
|
||||
messageView.text = "Security metrics reset"
|
||||
}
|
||||
ACTION_ENABLE_TEST_MODE -> {
|
||||
debugPrefs.edit().putBoolean(KEY_TEST_MODE_ENABLED, true).apply()
|
||||
messageView.text = "HCE TEST MODE: ENABLED"
|
||||
}
|
||||
ACTION_DISABLE_TEST_MODE -> {
|
||||
debugPrefs.edit().putBoolean(KEY_TEST_MODE_ENABLED, false).apply()
|
||||
messageView.text = "HCE TEST MODE: DISABLED"
|
||||
}
|
||||
ACTION_SET_PREFERRED_PAYMENT -> {
|
||||
val ok = setPreferredPaymentService()
|
||||
messageView.text = if (ok) "Preferred payment service: SET" else "Preferred payment service: FAILED"
|
||||
}
|
||||
ACTION_CLEAR_PREFERRED_PAYMENT -> {
|
||||
val ok = clearPreferredPaymentService()
|
||||
messageView.text = if (ok) "Preferred payment service: CLEARED" else "Preferred payment service: CLEAR FAILED"
|
||||
}
|
||||
ACTION_DUMP_APDU_LOG -> {
|
||||
refreshApduLog()
|
||||
messageView.text = "APDU log loaded"
|
||||
}
|
||||
ACTION_CLEAR_APDU_LOG -> {
|
||||
clearApduLogFile()
|
||||
messageView.text = "APDU log cleared"
|
||||
}
|
||||
}
|
||||
val isTestMode = debugPrefs.getBoolean(KEY_TEST_MODE_ENABLED, false)
|
||||
if (messageView.text.isBlank() || messageView.text == resources.getText(R.string.main_message)) {
|
||||
messageView.text = "HCE TEST MODE: ${if (isTestMode) "ENABLED" else "DISABLED"}"
|
||||
}
|
||||
if (action == ACTION_DUMP_APDU_LOG || action == ACTION_CLEAR_APDU_LOG) {
|
||||
refreshApduLog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleApduLogRefresh() {
|
||||
stopApduLogRefresh()
|
||||
val runnable = object : Runnable {
|
||||
override fun run() {
|
||||
refreshApduLog()
|
||||
apduLogRefreshRunnable = this
|
||||
uiHandler.postDelayed(this, APDU_LOG_REFRESH_MS)
|
||||
}
|
||||
}
|
||||
apduLogRefreshRunnable = runnable
|
||||
uiHandler.post(runnable)
|
||||
}
|
||||
|
||||
private fun stopApduLogRefresh() {
|
||||
apduLogRefreshRunnable?.let { uiHandler.removeCallbacks(it) }
|
||||
apduLogRefreshRunnable = null
|
||||
}
|
||||
|
||||
private fun refreshApduLog() {
|
||||
val apduLogView = findViewById<TextView>(R.id.apduLogView)
|
||||
val lines = runCatching {
|
||||
val file = File(filesDir, APDU_EXCHANGE_LOG_FILE)
|
||||
if (!file.exists()) {
|
||||
return@runCatching emptyList()
|
||||
}
|
||||
file.readLines().takeLast(APDU_LOG_MAX_LINES)
|
||||
}.getOrElse {
|
||||
apduLogView.text = "Gagal membaca log APDU: ${it.message}"
|
||||
emptyList()
|
||||
}
|
||||
if (lines.isEmpty()) {
|
||||
apduLogView.text = "Belum ada log APDU."
|
||||
} else {
|
||||
apduLogView.text = lines.joinToString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearApduLogFile() {
|
||||
runCatching {
|
||||
File(filesDir, APDU_EXCHANGE_LOG_FILE).delete()
|
||||
findViewById<TextView>(R.id.apduLogView).text = "Belum ada log APDU."
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDebuggable(): Boolean =
|
||||
(applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
|
||||
private fun setPreferredPaymentService(): Boolean {
|
||||
return runCatching {
|
||||
val adapter = NfcAdapter.getDefaultAdapter(this) ?: return false
|
||||
val ok = CardEmulation.getInstance(adapter).setPreferredService(
|
||||
this,
|
||||
ComponentName(this, com.korancrew.brizzi.hce.BrizziHostApduService::class.java)
|
||||
)
|
||||
Log.i("BrizziDebug", "setPreferredService -> $ok")
|
||||
ok
|
||||
}.getOrElse {
|
||||
Log.w("BrizziDebug", "setPreferredPaymentService failed", it)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearPreferredPaymentService(): Boolean {
|
||||
return runCatching {
|
||||
val adapter = NfcAdapter.getDefaultAdapter(this) ?: return false
|
||||
CardEmulation.getInstance(adapter).unsetPreferredService(this)
|
||||
Log.i("BrizziDebug", "unsetPreferredService invoked")
|
||||
true
|
||||
}.getOrElse {
|
||||
Log.w("BrizziDebug", "clearPreferredPaymentService failed", it)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Intent action untuk menampilkan summary metrik runtime secara debug. */
|
||||
const val ACTION_DUMP_METRICS = "com.korancrew.brizzi.ACTION_DUMP_METRICS"
|
||||
/** Intent action untuk menghapus counter metrik runtime secara debug. */
|
||||
const val ACTION_RESET_METRICS = "com.korancrew.brizzi.ACTION_RESET_METRICS"
|
||||
const val ACTION_ENABLE_TEST_MODE = "com.korancrew.brizzi.ACTION_ENABLE_TEST_MODE"
|
||||
const val ACTION_DISABLE_TEST_MODE = "com.korancrew.brizzi.ACTION_DISABLE_TEST_MODE"
|
||||
const val ACTION_SET_PREFERRED_PAYMENT = "com.korancrew.brizzi.ACTION_SET_PREFERRED_PAYMENT"
|
||||
const val ACTION_CLEAR_PREFERRED_PAYMENT = "com.korancrew.brizzi.ACTION_CLEAR_PREFERRED_PAYMENT"
|
||||
const val ACTION_DUMP_APDU_LOG = "com.korancrew.brizzi.ACTION_DUMP_APDU_LOG"
|
||||
const val ACTION_CLEAR_APDU_LOG = "com.korancrew.brizzi.ACTION_CLEAR_APDU_LOG"
|
||||
private const val PREFS_NAME = "brizzi_hce_config"
|
||||
private const val KEY_TEST_MODE_ENABLED = "hce_test_mode_enabled"
|
||||
private const val APDU_EXCHANGE_LOG_FILE = "apdu_exchange_log.txt"
|
||||
private const val APDU_LOG_MAX_LINES = 200
|
||||
private const val APDU_LOG_REFRESH_MS = 1500L
|
||||
}
|
||||
}
|
||||
97
app/src/main/java/com/korancrew/brizzi/hce/ApduParser.kt
Normal file
97
app/src/main/java/com/korancrew/brizzi/hce/ApduParser.kt
Normal file
@ -0,0 +1,97 @@
|
||||
package com.korancrew.brizzi.hce
|
||||
|
||||
/**
|
||||
* Struktur hasil parsing APDU:
|
||||
* - CLA/INS/P1/P2
|
||||
* - Lc (opsional), data (opsional), Le (opsional)
|
||||
* - raw bytes dan representasi hex
|
||||
*/
|
||||
data class ParsedApdu(
|
||||
val cla: Int,
|
||||
val ins: Int,
|
||||
val p1: Int,
|
||||
val p2: Int,
|
||||
val lc: Int?,
|
||||
val data: ByteArray,
|
||||
val le: Int?,
|
||||
val raw: ByteArray,
|
||||
val rawHex: String,
|
||||
) {
|
||||
val hasData: Boolean = data.isNotEmpty()
|
||||
val isCase1: Boolean = lc == null && le == null
|
||||
val isCase2: Boolean = lc == null && le != null
|
||||
val isCase3: Boolean = lc != null && le == null
|
||||
val isCase4: Boolean = lc != null && le != null
|
||||
}
|
||||
|
||||
class ApduParseException(message: String) : IllegalArgumentException(message)
|
||||
|
||||
/**
|
||||
* Parser APDU minimal untuk jalur HCE Brizzi.
|
||||
* Mendukung case 1/2/3/4:
|
||||
* - 4 byte + (opsional) data + (opsional) Le.
|
||||
*/
|
||||
object ApduParser {
|
||||
fun parse(bytes: ByteArray): ParsedApdu {
|
||||
if (bytes.isEmpty()) throw ApduParseException("APDU empty")
|
||||
if (bytes.size < 4) {
|
||||
return ParsedApdu(
|
||||
cla = bytes[0].toInt() and 0xFF,
|
||||
ins = if (bytes.size >= 2) bytes[1].toInt() and 0xFF else 0,
|
||||
p1 = if (bytes.size >= 3) bytes[2].toInt() and 0xFF else 0,
|
||||
p2 = if (bytes.size >= 4) bytes[3].toInt() and 0xFF else 0,
|
||||
lc = if (bytes.isNotEmpty()) 0 else null,
|
||||
data = ByteArray(0),
|
||||
le = null,
|
||||
raw = bytes,
|
||||
rawHex = bytes.toHex(),
|
||||
)
|
||||
}
|
||||
|
||||
val cla = bytes[0].toInt() and 0xFF
|
||||
val ins = bytes[1].toInt() and 0xFF
|
||||
val p1 = bytes[2].toInt() and 0xFF
|
||||
val p2 = bytes[3].toInt() and 0xFF
|
||||
var index = 4
|
||||
val remaining = bytes.size - index
|
||||
|
||||
val (lc, data, le) = when {
|
||||
remaining == 0 -> ParsedPayload(null, ByteArray(0), null)
|
||||
remaining == 1 -> ParsedPayload(null, ByteArray(0), bytes[4].toInt() and 0xFF)
|
||||
else -> {
|
||||
val lcValue = bytes[index].toInt() and 0xFF
|
||||
index += 1
|
||||
if (index + lcValue > bytes.size) {
|
||||
throw ApduParseException("Lc exceeds APDU length")
|
||||
}
|
||||
|
||||
val data = if (lcValue == 0) ByteArray(0) else bytes.copyOfRange(index, index + lcValue)
|
||||
index += lcValue
|
||||
|
||||
when {
|
||||
index == bytes.size -> ParsedPayload(lcValue, data, null)
|
||||
index == bytes.size - 1 -> ParsedPayload(lcValue, data, bytes[index].toInt() and 0xFF)
|
||||
else -> throw ApduParseException("Unexpected trailing bytes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ParsedApdu(
|
||||
cla = cla,
|
||||
ins = ins,
|
||||
p1 = p1,
|
||||
p2 = p2,
|
||||
lc = lc,
|
||||
data = data,
|
||||
le = le,
|
||||
raw = bytes,
|
||||
rawHex = bytes.toHex(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class ParsedPayload(
|
||||
val lc: Int?,
|
||||
val data: ByteArray,
|
||||
val le: Int?,
|
||||
)
|
||||
990
app/src/main/java/com/korancrew/brizzi/hce/BrizziApduRouter.kt
Normal file
990
app/src/main/java/com/korancrew/brizzi/hce/BrizziApduRouter.kt
Normal file
@ -0,0 +1,990 @@
|
||||
package com.korancrew.brizzi.hce
|
||||
|
||||
/**
|
||||
* Router utama untuk semua command APDU yang masuk.
|
||||
*
|
||||
* Menjaga flow:
|
||||
* - sesi AID (AID1, AID3),
|
||||
* - fase autentikasi,
|
||||
* - transaksi debit/credit/log/last transaction,
|
||||
* - commit/abort,
|
||||
* - serta proteksi keamanan (timeout, replay, malformed validation).
|
||||
*/
|
||||
class BrizziApduRouter(
|
||||
private val routingAidHex: String,
|
||||
private var card: BrizziCard = BrizziCard(),
|
||||
private val session: BrizziSession = BrizziSession(),
|
||||
private val onCardUpdated: ((BrizziCard) -> Unit)? = null,
|
||||
private val onSecurityEvent: ((String) -> Unit)? = null,
|
||||
) {
|
||||
companion object {
|
||||
private const val MAX_APDU_HEX_CHARS = 2048
|
||||
private const val ALLOWED_MAX_TRANSACTION_AMOUNT = 9_900_000
|
||||
private const val ALLOWED_MAX_BALANCE = 9_999_999
|
||||
private const val SESSION_TIMEOUT_MS = 120_000L
|
||||
private const val REPLAY_WINDOW_MS = 800L
|
||||
private const val READER_AID1_HEX = "A0000000180F0000018001"
|
||||
private const val READER_AID1_ALT_HEX = "D4100000030001"
|
||||
private const val READER_AID3_HEX = "D3600000030003"
|
||||
private const val PPSE_AID_HEX = "325041592E5359532E4444463031"
|
||||
private const val AID1_HEX = "5A00000301000000"
|
||||
private const val AID3_HEX = "5A00000303000000"
|
||||
|
||||
private val BALANCE_READ_DATA_TAGS = setOf(
|
||||
"9F7900",
|
||||
"9F7902",
|
||||
"9F7904",
|
||||
"9F7906",
|
||||
"9F7908",
|
||||
"5F36",
|
||||
"5A",
|
||||
"9F79",
|
||||
)
|
||||
|
||||
private val HISTORY_READ_DATA_TAGS = setOf(
|
||||
"9F4F00",
|
||||
"9F4F",
|
||||
"8A",
|
||||
"8C",
|
||||
"57",
|
||||
"9F6F",
|
||||
"9F6A",
|
||||
"FF",
|
||||
"57",
|
||||
"8F",
|
||||
)
|
||||
}
|
||||
|
||||
private val routingAidHexes = routingAidHex
|
||||
.split(",")
|
||||
.map { it.trim().uppercase() }
|
||||
.filter { it.isNotEmpty() }
|
||||
|
||||
fun snapshot(): BrizziCardSnapshot = BrizziCardSnapshot(
|
||||
balance = currentBalance(),
|
||||
lastTransactionDateHex = currentLastTransactionDateHex(),
|
||||
akumDebetHex = currentAkumDebetHex(),
|
||||
logs = card.logs,
|
||||
selectedAid = session.selectedAid,
|
||||
authenticated = session.authenticated,
|
||||
pendingTransaction = session.pendingTransaction,
|
||||
)
|
||||
|
||||
/**
|
||||
* Proses 1 command secara berurutan:
|
||||
* 1) cek timeout sesi,
|
||||
* 2) parse APDU,
|
||||
* 3) cek replay untuk command sensitif,
|
||||
* 4) jalankan command handler.
|
||||
*/
|
||||
@Synchronized
|
||||
fun handle(commandApdu: ByteArray): ByteArray {
|
||||
val now = System.currentTimeMillis()
|
||||
if (session.isTimedOut(now, SESSION_TIMEOUT_MS)) {
|
||||
session.reset()
|
||||
onSecurityEvent?.invoke("SESSION_TIMEOUT")
|
||||
}
|
||||
session.touch(now)
|
||||
if (commandApdu.isEmpty()) {
|
||||
onSecurityEvent?.invoke("APDU_EMPTY")
|
||||
return BrizziResponse.wrongLength
|
||||
}
|
||||
if (commandApdu.size > 1024) {
|
||||
onSecurityEvent?.invoke("APDU_TOO_LARGE")
|
||||
return BrizziResponse.wrongLength
|
||||
}
|
||||
val parsed = try {
|
||||
ApduParser.parse(commandApdu)
|
||||
} catch (_: ApduParseException) {
|
||||
onSecurityEvent?.invoke("APDU_PARSE_FAILED")
|
||||
return BrizziResponse.wrongLength
|
||||
}
|
||||
val hex = parsed.rawHex
|
||||
if (hex.length > MAX_APDU_HEX_CHARS) {
|
||||
onSecurityEvent?.invoke("APDU_HEX_TOO_LONG")
|
||||
return BrizziResponse.wrongLength
|
||||
}
|
||||
if (isReplaySensitiveHex(hex) && session.isReplay(REPLAY_WINDOW_MS, hex, now)) {
|
||||
onSecurityEvent?.invoke("REPLAY_DETECTED")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
return try {
|
||||
handleParsed(parsed)
|
||||
} catch (_: IndexOutOfBoundsException) {
|
||||
onSecurityEvent?.invoke("APDU_HANDLE_INDEX_ERROR")
|
||||
BrizziResponse.wrongLength
|
||||
} catch (_: ConditionsNotSatisfiedException) {
|
||||
onSecurityEvent?.invoke("CONDITIONS_NOT_SATISFIED")
|
||||
BrizziResponse.conditionsNotSatisfied
|
||||
} catch (_: IllegalArgumentException) {
|
||||
onSecurityEvent?.invoke("APDU_ARGUMENT_INVALID")
|
||||
BrizziResponse.wrongLength
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset hanya sesi transaksi/state, tidak menghapus data kartu. */
|
||||
fun resetSession() = session.reset()
|
||||
|
||||
/**
|
||||
* Ringkasan diagnostik parsing APDU (untuk log debug), tanpa throw.
|
||||
*/
|
||||
fun parseApduSummary(hex: String): String {
|
||||
return try {
|
||||
val parsed = ApduParser.parse(hex.hexToBytes())
|
||||
val cla = parsed.cla.toString(16).uppercase().padStart(2, '0')
|
||||
val ins = parsed.ins.toString(16).uppercase().padStart(2, '0')
|
||||
val p1 = parsed.p1.toString(16).uppercase().padStart(2, '0')
|
||||
val p2 = parsed.p2.toString(16).uppercase().padStart(2, '0')
|
||||
val lc = parsed.lc?.toString(16)?.uppercase()?.padStart(2, '0') ?: "??"
|
||||
val dataLen = parsed.data.size
|
||||
"CLA=$cla INS=$ins P1=$p1 P2=$p2 Lc=$lc data-len=$dataLen le=${parsed.le ?: -1}"
|
||||
} catch (_: Exception) {
|
||||
if (hex.length < 8) return "len=${hex.length / 2} incomplete"
|
||||
"invalid-apdu"
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleParsed(parsed: ParsedApdu): ByteArray {
|
||||
val hex = parsed.rawHex
|
||||
if (isIsoSelectMalformed(hex)) {
|
||||
onSecurityEvent?.invoke("ISO_SELECT_MALFORMED")
|
||||
return BrizziResponse.wrongLength
|
||||
}
|
||||
|
||||
if (isGetProcessingOptions(parsed, hex)) {
|
||||
onSecurityEvent?.invoke("GET_PROCESSING_OPTIONS")
|
||||
// Respons sederhana yang tetap valid untuk NFC framework agar melanjutkan alur baca data.
|
||||
// Diberikan tanpa struktur AIP/AFL penuh agar tidak mengubah flow existing.
|
||||
return if (hex.startsWith("90")) {
|
||||
BrizziResponse.iso("80020000")
|
||||
} else {
|
||||
BrizziResponse.native("80020000")
|
||||
}
|
||||
}
|
||||
|
||||
if (isIsoSelectPrefix(hex)) {
|
||||
session.reset()
|
||||
val selectedAid = parseIsoSelectAid(hex.uppercase())
|
||||
onSecurityEvent?.invoke("ISO_SELECT_OK")
|
||||
if (selectedAid == null) {
|
||||
onSecurityEvent?.invoke("ISO_SELECT_PARSE_FAIL")
|
||||
return BrizziResponse.fileNotFound
|
||||
}
|
||||
|
||||
// Jika AID diketahui, gunakan FCI yang sesuai.
|
||||
// Jika tidak dikenali, balas 6A82 agar reader berhenti berputar di daftar probe AID.
|
||||
val normalizedAid = selectedAid.uppercase()
|
||||
val aidResponse = if (normalizedAid == PPSE_AID_HEX) {
|
||||
buildPpseResponse(READER_AID1_HEX, "4252495A5A49")
|
||||
} else if (
|
||||
isAidMatchPrefix(selectedAid, READER_AID1_HEX) ||
|
||||
isAidMatchPrefix(selectedAid, READER_AID1_ALT_HEX) ||
|
||||
selectedAid.startsWith(AID1_HEX) ||
|
||||
isAidMatchPrefix(selectedAid, AID1_HEX)
|
||||
) {
|
||||
session.selectAid1()
|
||||
onSecurityEvent?.invoke("SELECT_AID1")
|
||||
aidFciResponse(normalizedAid, "4252495A5A492042414C")
|
||||
} else if (
|
||||
isAidMatchPrefix(selectedAid, READER_AID3_HEX) ||
|
||||
selectedAid.startsWith(AID3_HEX) ||
|
||||
isAidMatchPrefix(selectedAid, AID3_HEX)
|
||||
) {
|
||||
session.selectAid3()
|
||||
onSecurityEvent?.invoke("SELECT_AID3")
|
||||
aidFciResponse(normalizedAid, "4252495A5A492048495354")
|
||||
} else {
|
||||
onSecurityEvent?.invoke("ISO_SELECT_UNKNOWN_AID")
|
||||
return BrizziResponse.fileNotFound
|
||||
}
|
||||
|
||||
return BrizziResponse.iso7816(aidResponse)
|
||||
}
|
||||
|
||||
if (hex == BrizziCommandCatalog.SELECT_AID_1_ISO) {
|
||||
session.reset()
|
||||
session.selectAid1()
|
||||
onSecurityEvent?.invoke("SELECT_AID1_WRAPPED")
|
||||
return BrizziResponse.isoSuccess
|
||||
}
|
||||
|
||||
if (hex == BrizziCommandCatalog.SELECT_AID_3_ISO) {
|
||||
session.reset()
|
||||
session.selectAid3()
|
||||
onSecurityEvent?.invoke("SELECT_AID3_WRAPPED")
|
||||
return BrizziResponse.isoSuccess
|
||||
}
|
||||
|
||||
if (hex.startsWith("00A4010002")) {
|
||||
onSecurityEvent?.invoke("ISO_SELECT_FILE_OK")
|
||||
return BrizziResponse.iso7816()
|
||||
}
|
||||
|
||||
if (isIsoReadBinary0200(parsed, hex)) {
|
||||
onSecurityEvent?.invoke("ISO_READ_BINARY_0200")
|
||||
val payload = buildIsoBinary0200(parsed.le)
|
||||
return BrizziResponse.iso7816(payload)
|
||||
}
|
||||
|
||||
if (hex == "9060000000") {
|
||||
onSecurityEvent?.invoke("GET_VERSION")
|
||||
session.continuationFrames = mutableListOf(
|
||||
card.versionFrame1Hex,
|
||||
card.versionFrame2Hex,
|
||||
card.versionFrame3Hex,
|
||||
)
|
||||
val first = session.continuationFrames.removeFirst()
|
||||
return BrizziResponse.isoAdditionalFrame(first)
|
||||
}
|
||||
|
||||
if (hex == "90BB0000070100000000000000" || hex == "BB01000000000000") {
|
||||
onSecurityEvent?.invoke("GET_LOG")
|
||||
if (card.logs.isEmpty()) {
|
||||
return if (hex.startsWith("90")) BrizziResponse.isoNoLog() else BrizziResponse.nativeNoLog()
|
||||
}
|
||||
val records = card.logs.map { it.toHex() }
|
||||
val first = records.first()
|
||||
val remaining = records.drop(1)
|
||||
session.continuationFrames = (remaining + "").toMutableList()
|
||||
return if (hex.startsWith("90")) {
|
||||
BrizziResponse.isoAdditionalFrame(first)
|
||||
} else {
|
||||
BrizziResponse.native(first)
|
||||
}
|
||||
}
|
||||
|
||||
if (isReadableDataCommand(parsed, hex)) {
|
||||
if (!session.isReadyForReadOnlyCommand()) {
|
||||
onSecurityEvent?.invoke("GET_DATA_WITHOUT_SELECTION")
|
||||
session.selectUnknownAid()
|
||||
}
|
||||
if (isBalanceDataTag(parsed, hex)) {
|
||||
onSecurityEvent?.invoke("GET_BALANCE")
|
||||
session.readableFallbackStep = 0
|
||||
val payload = intToReversedHex(currentBalance(), 4)
|
||||
return if (hex.startsWith("90")) BrizziResponse.iso(payload) else BrizziResponse.native(payload)
|
||||
}
|
||||
if (isHistoryDataTag(parsed, hex)) {
|
||||
onSecurityEvent?.invoke("GET_HISTORY")
|
||||
session.readableFallbackStep = 0
|
||||
return readTransactionHistory(hex)
|
||||
}
|
||||
val fallback = resolveUnknownReadableAsFallback(hex)
|
||||
if (fallback != null) {
|
||||
return fallback
|
||||
}
|
||||
onSecurityEvent?.invoke("GET_DATA_UNKNOWN")
|
||||
return BrizziResponse.fileNotFound
|
||||
}
|
||||
|
||||
if (
|
||||
hex.startsWith("90BD000007BD0000000017") ||
|
||||
hex.startsWith("90BD0000070000000017") ||
|
||||
hex == "BD00000000170000"
|
||||
) {
|
||||
if (session.selectedAid != SelectedAid.AID1) {
|
||||
onSecurityEvent?.invoke("CARD_INFO_ACCESS_DENIED")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
onSecurityEvent?.invoke("GET_CARD_INFO")
|
||||
val payload = "425249${card.cardNumberHex}${card.persoDateHex}${card.issuerCodeHex}${card.cardInfoTailHex}"
|
||||
return if (hex.startsWith("90")) BrizziResponse.iso(payload) else BrizziResponse.native(payload)
|
||||
}
|
||||
|
||||
if (
|
||||
hex.startsWith("90BD0000070100000020") ||
|
||||
hex == "BD0100000200000"
|
||||
) {
|
||||
if (session.selectedAid != SelectedAid.AID1) {
|
||||
onSecurityEvent?.invoke("CARD_STATUS_ACCESS_DENIED")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
onSecurityEvent?.invoke("GET_CARD_STATUS")
|
||||
val payload = buildString {
|
||||
append(card.persoDateHex)
|
||||
append(card.cardStatusHex)
|
||||
append("00".repeat(27))
|
||||
}
|
||||
return if (hex.startsWith("90")) BrizziResponse.iso(payload) else BrizziResponse.native(payload)
|
||||
}
|
||||
|
||||
if (hex == "900A0000010000" || hex == "0A00") {
|
||||
if (session.selectedAid != SelectedAid.AID3) {
|
||||
onSecurityEvent?.invoke("KEY_CARD_DENIED")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
onSecurityEvent?.invoke("GET_KEY_CARD_00")
|
||||
return if (hex.startsWith("90")) {
|
||||
BrizziResponse.isoAdditionalFrame(card.keyCard00Hex)
|
||||
} else {
|
||||
BrizziResponse.native(card.keyCard00Hex)
|
||||
}
|
||||
}
|
||||
|
||||
if (hex == "900A0000010100" || hex == "0A01") {
|
||||
if (session.selectedAid != SelectedAid.AID3) {
|
||||
onSecurityEvent?.invoke("KEY_CARD_DENIED")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
onSecurityEvent?.invoke("GET_KEY_CARD_01")
|
||||
return if (hex.startsWith("90")) {
|
||||
BrizziResponse.isoAdditionalFrame(card.keyCard01Hex)
|
||||
} else {
|
||||
BrizziResponse.native(card.keyCard01Hex)
|
||||
}
|
||||
}
|
||||
|
||||
if (hex == "FFCA000000") {
|
||||
return BrizziResponse.iso(card.uidHex)
|
||||
}
|
||||
|
||||
if (isAuthenticateCommand(hex)) {
|
||||
if (session.phase != SessionPhase.AID3_SELECTED && session.phase != SessionPhase.AID3_AUTHENTICATED) {
|
||||
onSecurityEvent?.invoke("AUTH_DENIED")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
onSecurityEvent?.invoke("AUTH_SUCCESS")
|
||||
session.authenticate()
|
||||
return if (hex.startsWith("90")) BrizziResponse.iso(card.randomNumberHex) else BrizziResponse.native(card.randomNumberHex)
|
||||
}
|
||||
|
||||
if (
|
||||
hex.startsWith("90BD0000070300000007") ||
|
||||
hex == "BD03000000070000"
|
||||
) {
|
||||
if (session.selectedAid != SelectedAid.AID3 &&
|
||||
session.selectedAid != SelectedAid.OTHER
|
||||
) {
|
||||
onSecurityEvent?.invoke("BALANCE_AKUM_WRONG_AID")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
if (!session.isReadyForReadOnlyCommand()) {
|
||||
onSecurityEvent?.invoke("BALANCE_AKUM_ACCESS_DENIED")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
onSecurityEvent?.invoke("GET_LAST_TXN_AND_AKUM")
|
||||
val payload = currentLastTransactionDateHex() + currentAkumDebetHex()
|
||||
return if (hex.startsWith("90")) BrizziResponse.iso(payload) else BrizziResponse.native(payload)
|
||||
}
|
||||
|
||||
if (isGetBalanceCommand(parsed, hex)) {
|
||||
if (!session.isReadyForReadOnlyCommand()) {
|
||||
onSecurityEvent?.invoke("GET_BALANCE_DENIED")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
if (session.selectedAid != SelectedAid.AID3 &&
|
||||
session.selectedAid != SelectedAid.OTHER
|
||||
) {
|
||||
onSecurityEvent?.invoke("GET_BALANCE_WRONG_AID")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
if (!session.isReadyForCommandWithAuth()) {
|
||||
onSecurityEvent?.invoke("GET_BALANCE_NO_AUTH_BYPASS")
|
||||
}
|
||||
onSecurityEvent?.invoke("GET_BALANCE")
|
||||
session.readableFallbackStep = 0
|
||||
val payload = intToReversedHex(currentBalance(), 4)
|
||||
return if (hex.startsWith("90")) BrizziResponse.iso(payload) else BrizziResponse.native(payload)
|
||||
}
|
||||
|
||||
if (isReadHistoryRecord(parsed, hex)) {
|
||||
if (!session.isReadyForReadOnlyCommand()) {
|
||||
onSecurityEvent?.invoke("GET_HISTORY_DENIED")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
if (session.selectedAid != SelectedAid.AID3 &&
|
||||
session.selectedAid != SelectedAid.OTHER
|
||||
) {
|
||||
onSecurityEvent?.invoke("GET_HISTORY_WRONG_AID")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
onSecurityEvent?.invoke("GET_HISTORY_B2")
|
||||
session.readableFallbackStep = 0
|
||||
return readTransactionHistory(hex)
|
||||
}
|
||||
|
||||
if (isGetHistoryCommand(parsed, hex)) {
|
||||
if (!session.isReadyForReadOnlyCommand()) {
|
||||
onSecurityEvent?.invoke("GET_HISTORY_DENIED")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
if (session.selectedAid != SelectedAid.AID3 &&
|
||||
session.selectedAid != SelectedAid.OTHER
|
||||
) {
|
||||
onSecurityEvent?.invoke("GET_HISTORY_WRONG_AID")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
if (!session.isReadyForCommandWithAuth()) {
|
||||
onSecurityEvent?.invoke("GET_HISTORY_NO_AUTH_BYPASS")
|
||||
}
|
||||
onSecurityEvent?.invoke("GET_HISTORY")
|
||||
session.readableFallbackStep = 0
|
||||
return readTransactionHistory(hex)
|
||||
}
|
||||
|
||||
if (hex == "AF" || hex == "90AF000000") {
|
||||
val next = session.continuationFrames.removeFirstOrNull()
|
||||
?: run {
|
||||
onSecurityEvent?.invoke("GET_LOG_CONTINUATION_MISSING")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
onSecurityEvent?.invoke("GET_LOG_CONTINUATION")
|
||||
return if (hex.startsWith("90")) {
|
||||
if (next.isEmpty()) {
|
||||
BrizziResponse.iso()
|
||||
} else if (session.continuationFrames.isEmpty()) {
|
||||
BrizziResponse.iso(next)
|
||||
} else {
|
||||
BrizziResponse.isoAdditionalFrame(next)
|
||||
}
|
||||
} else {
|
||||
BrizziResponse.native(next)
|
||||
}
|
||||
}
|
||||
|
||||
if (isNativeDebit(hex) || isWrappedDebit(hex)) {
|
||||
if (!session.isReadyForNewTransaction()) {
|
||||
onSecurityEvent?.invoke("DEBIT_REJECTED")
|
||||
throw ConditionsNotSatisfiedException
|
||||
}
|
||||
val amount = validateTransactionAmount(extractAmount(hex))
|
||||
val before = currentBalance()
|
||||
if (amount > before) {
|
||||
onSecurityEvent?.invoke("DEBIT_INSUFFICIENT_FUNDS")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
val after = (before - amount).coerceAtLeast(0)
|
||||
onSecurityEvent?.invoke("DEBIT_PREPARED")
|
||||
session.startDebitTransaction()
|
||||
session.pendingTransaction = PendingTransaction(TransactionKind.DEBIT, amount, before, after)
|
||||
val payload = intToReversedHex(after, 4)
|
||||
return if (hex.startsWith("90")) BrizziResponse.iso(payload) else BrizziResponse.native(payload)
|
||||
}
|
||||
|
||||
if (isNativeCredit(hex) || isWrappedCredit(hex)) {
|
||||
if (!session.isReadyForNewTransaction()) {
|
||||
onSecurityEvent?.invoke("CREDIT_REJECTED")
|
||||
throw ConditionsNotSatisfiedException
|
||||
}
|
||||
val amount = validateTransactionAmount(extractAmount(hex))
|
||||
val before = currentBalance()
|
||||
if (before + amount > ALLOWED_MAX_BALANCE) {
|
||||
onSecurityEvent?.invoke("CREDIT_MAX_BALANCE_EXCEEDED")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
val after = before + amount
|
||||
onSecurityEvent?.invoke("CREDIT_PREPARED")
|
||||
session.startCreditTransaction()
|
||||
session.pendingTransaction = PendingTransaction(TransactionKind.CREDIT, amount, before, after)
|
||||
val payload = intToReversedHex(after, 4)
|
||||
return if (hex.startsWith("90")) BrizziResponse.iso(payload) else BrizziResponse.native(payload)
|
||||
}
|
||||
|
||||
if (isWriteLogCommand(hex)) {
|
||||
if (!isValidWriteLogPayloadShape(hex)) {
|
||||
onSecurityEvent?.invoke("WRITE_LOG_PAYLOAD_INVALID")
|
||||
return BrizziResponse.wrongLength
|
||||
}
|
||||
if (!session.isReadyForCommandWithAuth()) {
|
||||
onSecurityEvent?.invoke("WRITE_LOG_DENIED")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
if (session.hasPendingTransactionState()) {
|
||||
onSecurityEvent?.invoke("WRITE_LOG_REJECTED_PENDING")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
onSecurityEvent?.invoke("UPDATE_LOG")
|
||||
session.startLogUpdate()
|
||||
val payload = extractWriteLogPayload(hex)
|
||||
session.pendingLogHex = payload
|
||||
return if (hex.startsWith("90")) BrizziResponse.iso() else BrizziResponse.native()
|
||||
}
|
||||
|
||||
if (isLastTransactionCommand(hex)) {
|
||||
if (!isValidLastTransactionPayloadShape(hex)) {
|
||||
onSecurityEvent?.invoke("LAST_TXN_PAYLOAD_INVALID")
|
||||
return BrizziResponse.wrongLength
|
||||
}
|
||||
if (!session.isReadyForCommandWithAuth()) {
|
||||
onSecurityEvent?.invoke("LAST_TXN_DENIED")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
if (session.hasPendingTransactionState()) {
|
||||
onSecurityEvent?.invoke("LAST_TXN_REJECTED_PENDING")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
onSecurityEvent?.invoke("UPDATE_LAST_TXN")
|
||||
session.startLastTxnUpdate()
|
||||
val (dateHex, akumDebetHex) = extractLastTransactionPayload(hex)
|
||||
session.pendingLastTransactionDateHex = dateHex
|
||||
session.pendingAkumDebetHex = akumDebetHex
|
||||
return if (hex.startsWith("90")) BrizziResponse.iso() else BrizziResponse.native()
|
||||
}
|
||||
|
||||
if (hex == "C7" || hex == "90C7000000") {
|
||||
if (!session.hasPendingTransactionState()) {
|
||||
onSecurityEvent?.invoke("COMMIT_REJECTED")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
onSecurityEvent?.invoke("COMMIT_OK")
|
||||
commitPendingState()
|
||||
session.clearReplayWindow()
|
||||
session.phase = SessionPhase.AID3_AUTHENTICATED
|
||||
return if (hex.startsWith("90")) BrizziResponse.iso() else BrizziResponse.native()
|
||||
}
|
||||
|
||||
if (hex == "A7" || hex == "90A7000000") {
|
||||
if (!session.hasPendingTransactionState()) {
|
||||
onSecurityEvent?.invoke("ABORT_REJECTED")
|
||||
return BrizziResponse.conditionsNotSatisfied
|
||||
}
|
||||
onSecurityEvent?.invoke("ABORT_OK")
|
||||
session.clearPendingTransactionState()
|
||||
session.clearReplayWindow()
|
||||
if (session.phase == SessionPhase.AID3_AUTHENTICATED) {
|
||||
session.phase = SessionPhase.AID3_AUTHENTICATED
|
||||
}
|
||||
return if (hex.startsWith("90")) BrizziResponse.iso() else BrizziResponse.native()
|
||||
}
|
||||
|
||||
onSecurityEvent?.invoke("UNSUPPORTED_INSTRUCTION")
|
||||
return BrizziResponse.unsupportedInstruction
|
||||
}
|
||||
|
||||
private fun isIsoSelectAid(hex: String): Boolean {
|
||||
val normalized = hex.uppercase()
|
||||
if (!isIsoSelectPrefix(normalized)) return false
|
||||
val aid = parseIsoSelectAid(normalized) ?: return false
|
||||
return routingAidHexes.any { routingAid ->
|
||||
routingAid.startsWith(aid)
|
||||
|| isAidMatchPrefix(aid, routingAid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAidMatchPrefix(selectedAid: String, configuredAid: String): Boolean {
|
||||
if (configuredAid.isEmpty()) return false
|
||||
return if (selectedAid.length >= configuredAid.length) {
|
||||
selectedAid.startsWith(configuredAid)
|
||||
} else {
|
||||
configuredAid.startsWith(selectedAid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isIsoSelectMalformed(hex: String): Boolean {
|
||||
val normalized = hex.uppercase()
|
||||
if (!isIsoSelectPrefix(normalized)) return false
|
||||
if (normalized.length < 10) return true
|
||||
val lc = normalized.substring(8, 10).toIntOrNull(16) ?: return true
|
||||
val dataLength = lc * 2
|
||||
val expectedDataLength = 10 + dataLength
|
||||
if (normalized.length < expectedDataLength) return true
|
||||
return normalized.length !in expectedDataLength..expectedDataLength + 2
|
||||
}
|
||||
|
||||
private fun isIsoSelectPrefix(hex: String): Boolean {
|
||||
return hex.startsWith("00A404") || hex.startsWith("90A404") || hex.startsWith("80A404")
|
||||
}
|
||||
|
||||
private fun isReplaySensitiveHex(hex: String): Boolean {
|
||||
return when {
|
||||
isNativeDebit(hex) || isWrappedDebit(hex) -> true
|
||||
isNativeCredit(hex) || isWrappedCredit(hex) -> true
|
||||
isCommitOrAbort(hex) -> true
|
||||
isWriteLogCommand(hex) -> true
|
||||
isLastTransactionCommand(hex) -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isGetBalanceCommand(parsed: ParsedApdu, hex: String): Boolean {
|
||||
val normalized = hex.uppercase()
|
||||
val tagHint = getReadableDataTag(parsed, normalized)
|
||||
return when {
|
||||
parsed.ins == 0xCA && parsed.p1 == 0x9F && parsed.p2 in 0x79..0x79 -> true
|
||||
tagHint.isNotBlank() && containsAnyTag(tagHint, BALANCE_READ_DATA_TAGS) -> true
|
||||
normalized.startsWith("906C0000010000") -> true
|
||||
parsed.ins == 0xCA && containsAnyTag(normalized, BALANCE_READ_DATA_TAGS) -> true
|
||||
parsed.ins == 0x00CA && containsAnyTag(normalized, BALANCE_READ_DATA_TAGS) -> true
|
||||
parsed.ins == 0x80CA && containsAnyTag(normalized, BALANCE_READ_DATA_TAGS) -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isGetProcessingOptions(parsed: ParsedApdu, hex: String): Boolean {
|
||||
val normalized = hex.uppercase()
|
||||
return parsed.ins == 0xA8 ||
|
||||
normalized.startsWith("80A8") ||
|
||||
normalized.startsWith("00A8") ||
|
||||
normalized.startsWith("90A8")
|
||||
}
|
||||
|
||||
private fun isReadableDataCommand(parsed: ParsedApdu, hex: String): Boolean {
|
||||
return when (parsed.ins) {
|
||||
0xCA, 0xB2, 0xBB -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBalanceDataTag(parsed: ParsedApdu, hex: String): Boolean {
|
||||
val normalized = hex.uppercase()
|
||||
val tag = getReadableDataTag(parsed, normalized)
|
||||
if (tag.isBlank()) return isKnownBalanceTag(normalized)
|
||||
return containsAnyTag(tag, BALANCE_READ_DATA_TAGS) || isKnownBalanceTag(normalized)
|
||||
}
|
||||
|
||||
private fun isHistoryDataTag(parsed: ParsedApdu, hex: String): Boolean {
|
||||
val normalized = hex.uppercase()
|
||||
val tag = getReadableDataTag(parsed, normalized)
|
||||
if (tag.isBlank()) return isKnownHistoryTag(normalized)
|
||||
return containsAnyTag(tag, HISTORY_READ_DATA_TAGS) || isKnownHistoryTag(normalized)
|
||||
}
|
||||
|
||||
private fun getReadableDataTag(parsed: ParsedApdu, normalized: String): String {
|
||||
if (parsed.ins == 0xCA) {
|
||||
val p1 = normalized.substringOrEmpty(4, 6).padEnd(2, '0')
|
||||
val p2 = normalized.substringOrEmpty(6, 8).padEnd(2, '0')
|
||||
return "$p1$p2"
|
||||
}
|
||||
if (parsed.data.isNotEmpty()) {
|
||||
return parsed.data.toHex().uppercase().substringOrEmpty(0, 4)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private fun isKnownBalanceTag(normalized: String): Boolean {
|
||||
return containsAnyTag(normalized, BALANCE_READ_DATA_TAGS)
|
||||
}
|
||||
|
||||
private fun isKnownHistoryTag(normalized: String): Boolean {
|
||||
return containsAnyTag(normalized, HISTORY_READ_DATA_TAGS)
|
||||
}
|
||||
|
||||
private fun String.substringOrEmpty(start: Int, end: Int): String {
|
||||
return if (start >= this.length || start >= end) {
|
||||
""
|
||||
} else {
|
||||
this.substring(start, end.coerceAtMost(this.length))
|
||||
}
|
||||
}
|
||||
|
||||
private fun aidFciResponse(aidHex: String, labelHex: String): String {
|
||||
return buildFciForAid(aidHex, labelHex)
|
||||
}
|
||||
|
||||
private fun buildPpseResponse(aidHex: String, labelHex: String): String {
|
||||
val entry = buildTlv("61", buildTlv("4F", aidHex) + buildTlv("50", labelHex))
|
||||
val issuerDiscretionary = buildTlv("BF0C", entry)
|
||||
val fciPayload = buildTlv("84", PPSE_AID_HEX) + buildTlv("A5", issuerDiscretionary)
|
||||
return buildTlv("6F", fciPayload)
|
||||
}
|
||||
|
||||
private fun buildFciForAid(aidHex: String, labelHex: String): String {
|
||||
val aid = aidHex.uppercase()
|
||||
val proprietaryTemplate = buildTlv("50", labelHex) + buildTlv("9F38", "")
|
||||
val fciPayload = buildTlv("84", aid) + buildTlv("A5", proprietaryTemplate)
|
||||
return buildTlv("6F", fciPayload)
|
||||
}
|
||||
|
||||
private fun buildTlv(tagHex: String, payloadHex: String): String {
|
||||
val normalizedPayload = payloadHex.uppercase()
|
||||
return "$tagHex${encodeTlvLength(normalizedPayload.length / 2)}$normalizedPayload"
|
||||
}
|
||||
|
||||
private fun encodeTlvLength(length: Int): String {
|
||||
return when {
|
||||
length <= 0x7F -> length.toString(16).uppercase().padStart(2, '0')
|
||||
length <= 0xFF -> "81" + length.toString(16).uppercase().padStart(2, '0')
|
||||
else -> "82" + length.toString(16).uppercase().padStart(4, '0')
|
||||
}
|
||||
}
|
||||
|
||||
private fun isReadHistoryRecord(parsed: ParsedApdu, hex: String): Boolean {
|
||||
return when {
|
||||
parsed.ins == 0xB2 -> true
|
||||
parsed.ins == 0xBB -> true
|
||||
else -> false
|
||||
} && isReadyToLogOrHistory(hex.uppercase())
|
||||
}
|
||||
|
||||
private fun isGetHistoryCommand(parsed: ParsedApdu, hex: String): Boolean {
|
||||
val normalized = hex.uppercase()
|
||||
return when (parsed.ins) {
|
||||
0xB2 -> true
|
||||
0xBB -> true
|
||||
0xCA -> normalized.startsWith("90CA") || normalized.startsWith("00CA") || normalized.startsWith("80CA")
|
||||
else -> false
|
||||
} && isReadyToLogOrHistory(normalized)
|
||||
}
|
||||
|
||||
private fun isIsoReadBinary0200(parsed: ParsedApdu, hex: String): Boolean {
|
||||
val normalized = hex.uppercase()
|
||||
return parsed.ins == 0xB0 &&
|
||||
parsed.p1 == 0x81 &&
|
||||
parsed.p2 == 0x00 &&
|
||||
normalized.startsWith("00B08100")
|
||||
}
|
||||
|
||||
private fun buildIsoBinary0200(le: Int?): String {
|
||||
val fileHex = buildString {
|
||||
append("425249")
|
||||
append(card.cardNumberHex)
|
||||
append(card.persoDateHex)
|
||||
append(card.issuerCodeHex)
|
||||
append(card.cardInfoTailHex)
|
||||
append(card.persoDateHex)
|
||||
append(card.cardStatusHex)
|
||||
append(card.uidHex)
|
||||
append(card.keyCard00Hex)
|
||||
append(card.randomNumberHex)
|
||||
append(card.lastTransactionDateHex)
|
||||
append(card.akumDebetHex.take(8))
|
||||
append(intToReversedHex(card.balance, 4))
|
||||
append(card.logs.joinToString("") { it.toHex() })
|
||||
}
|
||||
val targetBytes = le ?: (fileHex.length / 2)
|
||||
val normalized = if (fileHex.length / 2 >= targetBytes) {
|
||||
fileHex.take(targetBytes * 2)
|
||||
} else {
|
||||
fileHex.padEnd(targetBytes * 2, '0')
|
||||
}
|
||||
return normalized.uppercase()
|
||||
}
|
||||
|
||||
private fun resolveUnknownReadableAsFallback(hex: String): ByteArray? {
|
||||
return when (session.readableFallbackStep) {
|
||||
0 -> {
|
||||
onSecurityEvent?.invoke("GET_BALANCE_FALLBACK")
|
||||
session.readableFallbackStep = 1
|
||||
val payload = intToReversedHex(currentBalance(), 4)
|
||||
if (hex.startsWith("90")) BrizziResponse.iso(payload) else BrizziResponse.native(payload)
|
||||
}
|
||||
1 -> {
|
||||
onSecurityEvent?.invoke("GET_HISTORY_FALLBACK")
|
||||
session.readableFallbackStep = 2
|
||||
readTransactionHistory(hex)
|
||||
}
|
||||
else -> {
|
||||
onSecurityEvent?.invoke("GET_READABLE_FALLBACK_DONE")
|
||||
if (hex.startsWith("90")) {
|
||||
BrizziResponse.isoNoLog()
|
||||
} else {
|
||||
BrizziResponse.nativeNoLog()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun containsAnyTag(normalized: String, tags: Set<String>): Boolean {
|
||||
return tags.any { tag -> normalized.contains(tag) }
|
||||
}
|
||||
|
||||
private fun isReadyToLogOrHistory(normalized: String): Boolean {
|
||||
return when {
|
||||
normalized.startsWith("90AF") || normalized == "AF" -> true
|
||||
normalized.startsWith("C7") || normalized.startsWith("A7") -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
private fun readTransactionHistory(hex: String): ByteArray {
|
||||
if (card.logs.isEmpty()) {
|
||||
return if (hex.startsWith("90")) BrizziResponse.isoNoLog() else BrizziResponse.nativeNoLog()
|
||||
}
|
||||
val records = card.logs.map { it.toHex() }
|
||||
val first = records.first()
|
||||
val remaining = records.drop(1)
|
||||
session.continuationFrames = remaining.toMutableList()
|
||||
return if (hex.startsWith("90")) {
|
||||
if (remaining.isEmpty()) BrizziResponse.iso(first) else BrizziResponse.isoAdditionalFrame(first)
|
||||
} else {
|
||||
BrizziResponse.native(first)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseIsoSelectAid(hex: String): String? {
|
||||
if (hex.length < 10) return null
|
||||
val lc = hex.substring(8, 10).toIntOrNull(16) ?: return null
|
||||
val start = 10
|
||||
val end = start + lc * 2
|
||||
if (hex.length < end) return null
|
||||
return hex.substring(start, end)
|
||||
}
|
||||
|
||||
private fun isAuthenticateCommand(hex: String): Boolean {
|
||||
val normalized = hex.uppercase()
|
||||
return when {
|
||||
normalized.startsWith("90AF000010") && normalized.length >= 28 && normalized.endsWith("00") -> true
|
||||
normalized.startsWith("AF") && normalized.length == 34 -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isNativeDebit(hex: String): Boolean {
|
||||
val normalized = hex.uppercase()
|
||||
return normalized.startsWith("DC000004") &&
|
||||
normalized.length == 16 &&
|
||||
normalized.endsWith("00")
|
||||
}
|
||||
|
||||
private fun validateTransactionAmount(amount: Int): Int {
|
||||
require(amount > 0) { "Transaction amount must be positive" }
|
||||
require(amount <= ALLOWED_MAX_TRANSACTION_AMOUNT) { "Transaction amount too large" }
|
||||
return amount
|
||||
}
|
||||
|
||||
private fun isWrappedDebit(hex: String): Boolean {
|
||||
val normalized = hex.uppercase()
|
||||
return normalized.startsWith("90DC000004") &&
|
||||
normalized.length == 20
|
||||
}
|
||||
|
||||
private fun isNativeCredit(hex: String): Boolean {
|
||||
val normalized = hex.uppercase()
|
||||
return normalized.startsWith("0C000004") &&
|
||||
normalized.length == 16 &&
|
||||
normalized.endsWith("00")
|
||||
}
|
||||
|
||||
private fun isWrappedCredit(hex: String): Boolean {
|
||||
val normalized = hex.uppercase()
|
||||
return normalized.startsWith("900C000004") &&
|
||||
normalized.length == 20
|
||||
}
|
||||
|
||||
private fun isCommitOrAbort(hex: String): Boolean {
|
||||
return hex == "90C7000000" || hex == "C7" || hex == "90A7000000" || hex == "A7"
|
||||
}
|
||||
|
||||
private fun isWriteLogCommand(hex: String): Boolean {
|
||||
return hex.startsWith("3B01") || hex.startsWith("903B01")
|
||||
}
|
||||
|
||||
private fun isLastTransactionCommand(hex: String): Boolean {
|
||||
return hex.startsWith("3D03") || hex.startsWith("903D03")
|
||||
}
|
||||
|
||||
private fun isValidWriteLogPayloadShape(hex: String): Boolean {
|
||||
return when {
|
||||
hex.startsWith("3B01") -> hex.length >= 18
|
||||
hex.startsWith("903B01") -> hex.length >= 20
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isValidLastTransactionPayloadShape(hex: String): Boolean {
|
||||
return when {
|
||||
hex.startsWith("3D03") -> hex.length >= 20
|
||||
hex.startsWith("903D03") -> hex.length >= 28
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractAmount(hex: String): Int {
|
||||
val normalized = hex.uppercase()
|
||||
val amountHex = when {
|
||||
normalized.startsWith("90DC000004") || normalized.startsWith("900C000004") -> {
|
||||
safeAmountRange(normalized, 10, 16)
|
||||
}
|
||||
normalized.startsWith("DC000004") || normalized.startsWith("0C000004") -> {
|
||||
safeAmountRange(normalized, 8, 14)
|
||||
}
|
||||
else -> return 0
|
||||
}
|
||||
return reversedHexToInt(amountHex)
|
||||
}
|
||||
|
||||
private fun safeAmountRange(hex: String, start: Int, end: Int): String {
|
||||
if (start < 0 || end < 0 || start >= end || end > hex.length) {
|
||||
throw IllegalArgumentException("Invalid transaction command length")
|
||||
}
|
||||
if (start + 6 > end) {
|
||||
throw IllegalArgumentException("Invalid transaction command length")
|
||||
}
|
||||
return hex.substring(start, end)
|
||||
}
|
||||
|
||||
private fun reversedHexToInt(reversedHex: String): Int {
|
||||
val bytes = reversedHex.hexToBytes().reversedArray()
|
||||
return bytes.toHex().toInt(16)
|
||||
}
|
||||
|
||||
private fun intToReversedHex(value: Int, byteCount: Int): String {
|
||||
val bigEndian = value.toString(16).uppercase().padStart(byteCount * 2, '0')
|
||||
return bigEndian.chunked(2).reversed().joinToString("")
|
||||
}
|
||||
|
||||
private fun currentBalance(): Int = session.pendingTransaction?.balanceAfter ?: card.balance
|
||||
|
||||
private fun currentLastTransactionDateHex(): String =
|
||||
session.pendingLastTransactionDateHex ?: card.lastTransactionDateHex
|
||||
|
||||
private fun currentAkumDebetHex(): String =
|
||||
(session.pendingAkumDebetHex ?: card.akumDebetHex).take(8)
|
||||
|
||||
private fun requireAid3Authenticated() {
|
||||
if (!session.isReadyForCommandWithAuth()) {
|
||||
throw ConditionsNotSatisfiedException
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractWriteLogPayload(hex: String): String =
|
||||
when {
|
||||
hex.startsWith("3B01") -> {
|
||||
val payload = safeSlice(hex, "3B01000000200000".length)
|
||||
payload.take(64)
|
||||
}
|
||||
hex.startsWith("903B") && hex.length >= 20 -> {
|
||||
val payload = safeSliceRange(hex, 12, hex.length - 2)
|
||||
payload.take(64)
|
||||
}
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun extractLastTransactionPayload(hex: String): Pair<String, String> =
|
||||
when {
|
||||
hex.startsWith("3D03") -> {
|
||||
val payload = safeSlice(hex, "3D03000000070000".length)
|
||||
if (payload.length < 14) {
|
||||
"000000" to "00000000"
|
||||
} else {
|
||||
payload.substring(0, 6) to payload.substring(6, 14)
|
||||
}
|
||||
}
|
||||
hex.startsWith("903D") && hex.length >= 28 -> {
|
||||
val payload = safeSliceRange(hex, 12, hex.length - 2)
|
||||
if (payload.length < 14) {
|
||||
"000000" to "00000000"
|
||||
} else {
|
||||
payload.substring(0, 6) to payload.substring(6, 14)
|
||||
}
|
||||
}
|
||||
else -> "000000" to "00000000"
|
||||
}
|
||||
|
||||
private fun commitPendingState() {
|
||||
val nextLogs = session.pendingLogHex?.let { listOf(parseBrizziLogRecord(it)) + card.logs } ?: card.logs
|
||||
val nextBalance = session.pendingTransaction?.balanceAfter ?: card.balance
|
||||
val nextLastDate = session.pendingLastTransactionDateHex ?: card.lastTransactionDateHex
|
||||
val nextAkumDebet = session.pendingAkumDebetHex ?: card.akumDebetHex
|
||||
card = card.copy(
|
||||
balance = nextBalance,
|
||||
logs = nextLogs.take(12),
|
||||
lastTransactionDateHex = nextLastDate,
|
||||
akumDebetHex = nextAkumDebet,
|
||||
)
|
||||
onCardUpdated?.invoke(card)
|
||||
session.clearPendingTransactionState()
|
||||
session.phase = SessionPhase.AID3_AUTHENTICATED
|
||||
}
|
||||
|
||||
private fun safeSlice(input: String, start: Int): String =
|
||||
if (start >= input.length) "" else input.substring(start)
|
||||
|
||||
private fun safeSliceRange(input: String, start: Int, end: Int): String {
|
||||
if (start < 0 || end < 0 || start >= end || start >= input.length) return ""
|
||||
val safeEnd = end.coerceAtMost(input.length)
|
||||
return input.substring(start, safeEnd)
|
||||
}
|
||||
|
||||
private object ConditionsNotSatisfiedException : IllegalStateException()
|
||||
}
|
||||
82
app/src/main/java/com/korancrew/brizzi/hce/BrizziCard.kt
Normal file
82
app/src/main/java/com/korancrew/brizzi/hce/BrizziCard.kt
Normal file
@ -0,0 +1,82 @@
|
||||
package com.korancrew.brizzi.hce
|
||||
|
||||
/** Catatan transaksi log single-entry (payload yang disimpan di card log stack). */
|
||||
data class BrizziLogRecord(
|
||||
val merchantIdHex: String,
|
||||
val terminalIdAsciiHex: String,
|
||||
val trxDateHex: String,
|
||||
val trxTimeHex: String,
|
||||
val trxTypeHex: String,
|
||||
val amountReversedHex: String,
|
||||
val balanceBeforeReversedHex: String,
|
||||
val balanceAfterReversedHex: String,
|
||||
) {
|
||||
fun toHex(): String = buildString {
|
||||
append(merchantIdHex)
|
||||
append(terminalIdAsciiHex)
|
||||
append(trxDateHex)
|
||||
append(trxTimeHex)
|
||||
append(trxTypeHex)
|
||||
append(amountReversedHex)
|
||||
append(balanceBeforeReversedHex)
|
||||
append(balanceAfterReversedHex)
|
||||
}
|
||||
}
|
||||
|
||||
/** Model kartu emulated yang dipakai di runtime HCE. */
|
||||
data class BrizziCard(
|
||||
val cardNumberHex: String = "6013501801640532",
|
||||
val persoDateHex: String = "070618",
|
||||
val issuerCodeHex: String = "3112",
|
||||
val cardInfoTailHex: String = "99020631380000",
|
||||
val cardStatusHex: String = "6161",
|
||||
val uidHex: String = "0489634ADC5A80",
|
||||
val versionFrame1Hex: String = "04010101001A05",
|
||||
val versionFrame2Hex: String = "04010101041A05",
|
||||
val versionFrame3Hex: String = "0489634ADC5A80B90C1755205217",
|
||||
val keyCard00Hex: String = "19180F5342630D03",
|
||||
val keyCard01Hex: String = "0123456789ABCD12",
|
||||
val randomNumberHex: String = "189DFB4D7E42DD33",
|
||||
val lastTransactionDateHex: String = "260427",
|
||||
val akumDebetHex: String = "00000048",
|
||||
val balance: Int = 21_000,
|
||||
val logs: List<BrizziLogRecord> = listOf(
|
||||
"00000017273400003134323735383637220426155025EB881300906500085200",
|
||||
"4E45574252494D4F4E45574252494D4F230426103843EC50C300085200581501",
|
||||
"00000019295700603130303835353639230426105640EB803E0001155800D6D8",
|
||||
"00000199914095600000000010721822230426105310EB4C1D00D8D6008CB900",
|
||||
"00000019988100093130373136343436230426112353EB7C150000B98C00A410",
|
||||
"00000019988100083130373136343237230426180344EB7C150000A410008E94",
|
||||
"00000199910224000000000010853934230426183111EBCC5B00948E00C83200",
|
||||
"4E45574252494D4F4E45574252494D4F270426093435EC204E00C83200E88000",
|
||||
"00000017273400143130303937323335270426115241EB881300E88000606D00",
|
||||
"00000019992031053130333431363235270426140259EBAC0D00606D00B45F00",
|
||||
"00000019992031613130333432383034270426175607EBAC0D00B45F00085200",
|
||||
).map(::parseBrizziLogRecord),
|
||||
)
|
||||
|
||||
/** Snapshot read-only yang dipakai untuk observability/testing. */
|
||||
data class BrizziCardSnapshot(
|
||||
val balance: Int,
|
||||
val lastTransactionDateHex: String,
|
||||
val akumDebetHex: String,
|
||||
val logs: List<BrizziLogRecord>,
|
||||
val selectedAid: SelectedAid,
|
||||
val authenticated: Boolean,
|
||||
val pendingTransaction: PendingTransaction?,
|
||||
)
|
||||
|
||||
/** Parse 32-byte hex log record menjadi struktur [BrizziLogRecord]. */
|
||||
fun parseBrizziLogRecord(hex: String): BrizziLogRecord {
|
||||
require(hex.length >= 64) { "BRIZZI log record must be at least 32 bytes" }
|
||||
return BrizziLogRecord(
|
||||
merchantIdHex = hex.substring(0, 16),
|
||||
terminalIdAsciiHex = hex.substring(16, 32),
|
||||
trxDateHex = hex.substring(32, 38),
|
||||
trxTimeHex = hex.substring(38, 44),
|
||||
trxTypeHex = hex.substring(44, 46),
|
||||
amountReversedHex = hex.substring(46, 52),
|
||||
balanceBeforeReversedHex = hex.substring(52, 58),
|
||||
balanceAfterReversedHex = hex.substring(58, 64),
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package com.korancrew.brizzi.hce
|
||||
|
||||
/**
|
||||
* Daftar command literal resmi yang sering dipakai pada unit test / tracing.
|
||||
*/
|
||||
object BrizziCommandCatalog {
|
||||
const val SELECT_AID_1_NATIVE_ISO = "00A40400085A00000301000000"
|
||||
const val SELECT_AID_1_BY_NAME_NATIVE_ISO = "00A4040C085A00000301000000"
|
||||
const val SELECT_AID_1_ISO = "905A00000301000000"
|
||||
const val SELECT_AID_3_NATIVE_ISO = "00A40400085A00000303000000"
|
||||
const val SELECT_AID_3_BY_NAME_NATIVE_ISO = "00A4040C085A00000303000000"
|
||||
const val SELECT_AID_3_ISO = "905A00000303000000"
|
||||
const val SELECT_UNKNOWN_AID_NATIVE_ISO = "00A40400085A00009999000000"
|
||||
const val GET_CARD_INFO_ISO = "90BD000007BD0000000017000000"
|
||||
const val GET_CARD_STATUS_ISO = "90BD0000070100000020000000"
|
||||
const val REQUEST_KEY_CARD_00_ISO = "900A0000010000"
|
||||
const val REQUEST_KEY_CARD_01_ISO = "900A0000010100"
|
||||
const val GET_UID_PCSC = "FFCA000000"
|
||||
const val GET_LAST_TXN_AND_AKUM_DEBET_ISO = "90BD0000070300000007000000"
|
||||
const val GET_BALANCE_ISO = "906C0000010000"
|
||||
const val GET_LOG_TRANSACTION_ISO = "90BB0000070100000000000000"
|
||||
const val ABORT_TRANSACTION_ISO = "90A7000000"
|
||||
const val COMMIT_TRANSACTION_ISO = "90C7000000"
|
||||
const val ABORT_TRANSACTION_NATIVE = "A7"
|
||||
const val COMMIT_TRANSACTION_NATIVE = "C7"
|
||||
const val GET_BALANCE_NATIVE = "6C00"
|
||||
|
||||
fun authenticateIso(randomKeyHex: String): String = "90AF000010${randomKeyHex}00"
|
||||
fun creditIso(amountReversedHex: String): String = "900C000004${amountReversedHex}0000"
|
||||
fun debitIso(amountReversedHex: String): String = "90DC000004${amountReversedHex}0000"
|
||||
fun creditNative(amountReversedHex: String): String = "0C000004${amountReversedHex}00"
|
||||
fun debitNative(amountReversedHex: String): String = "DC000004${amountReversedHex}00"
|
||||
}
|
||||
@ -0,0 +1,430 @@
|
||||
package com.korancrew.brizzi.hce
|
||||
|
||||
import android.nfc.cardemulation.HostApduService
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.util.Log
|
||||
import java.text.SimpleDateFormat
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
|
||||
/**
|
||||
* Entry point resmi HCE (Host Card Emulation service).
|
||||
*
|
||||
* Flow di level service:
|
||||
* 1) validasi input APDU (null/ukuran),
|
||||
* 2) rate-limit anti-flood,
|
||||
* 3) delegasi parsing+routing ke [BrizziApduRouter],
|
||||
* 4) pencatatan metrik + incident,
|
||||
* 5) persist snapshot health ke file internal.
|
||||
*/
|
||||
class BrizziHostApduService : HostApduService() {
|
||||
private lateinit var router: BrizziApduRouter
|
||||
private lateinit var storage: BrizziSecureStorage
|
||||
private lateinit var metrics: BrizziSecurityMetrics
|
||||
private var commandWindowStartMs: Long = 0L
|
||||
private var commandCountInWindow: Int = 0
|
||||
private var lastCommandMs: Long = 0L
|
||||
private var activeTapStartedAtWallClockMs: Long = 0L
|
||||
private var commandCountInTap: Int = 0
|
||||
private var activeTapLastSelectAid: String = ""
|
||||
private var lastIncidentLogMs: Long = 0L
|
||||
private var isTapSessionActive: Boolean = false
|
||||
private var currentTapId: Int = 0
|
||||
private val preferences: SharedPreferences by lazy {
|
||||
getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
|
||||
}
|
||||
private fun shouldLogApdu() = isDebuggable() && LOG_VERBOSE
|
||||
|
||||
private fun isDebuggable(): Boolean =
|
||||
(applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i(TAG, "BrizziHostApduService onCreate")
|
||||
Log.i(TAG, "HCE test mode enabled: ${isTestMode()}")
|
||||
storage = BrizziSecureStorage(this)
|
||||
metrics = BrizziSecurityMetrics(this)
|
||||
router = BrizziApduRouter(
|
||||
routingAidHex = ROUTING_AID_HEX,
|
||||
card = storage.loadCard() ?: BrizziCard(),
|
||||
onCardUpdated = { updatedCard ->
|
||||
storage.saveCard(updatedCard)
|
||||
metrics.track("CARD_UPDATED")
|
||||
},
|
||||
onSecurityEvent = { event ->
|
||||
metrics.track(event)
|
||||
},
|
||||
)
|
||||
metrics.track("SERVICE_STARTED")
|
||||
Log.i(TAG, "BrizziHostApduService started")
|
||||
isTapSessionActive = false
|
||||
activeTapStartedAtWallClockMs = 0L
|
||||
lastIncidentLogMs = 0L
|
||||
currentTapId = 0
|
||||
}
|
||||
|
||||
private fun toLogSafeApduHex(commandApdu: ByteArray, response: ByteArray): String {
|
||||
val code = commandApdu.firstOrNull()?.toInt()?.and(0xFF)?.toString(16)?.uppercase()?.padStart(2, '0')
|
||||
val responseHex = runCatching { response.toHex().uppercase() }.getOrElse { "" }
|
||||
val status = if (responseHex.length >= 4) responseHex.takeLast(4) else "N/A"
|
||||
return "len=${commandApdu.size}|code=$code|status=$status"
|
||||
}
|
||||
|
||||
/**
|
||||
* Menerima APDU dari reader NFC.
|
||||
*
|
||||
* Setiap request selalu di-track minimal:
|
||||
* - COMMAND_RECEIVED
|
||||
* - COMMAND_TOTAL
|
||||
* dan diikuti event spesifik (success/fail/response code).
|
||||
*/
|
||||
override fun processCommandApdu(commandApdu: ByteArray?, extras: Bundle?): ByteArray {
|
||||
Log.i(TAG, "processCommandApdu invoked")
|
||||
val commandReceiveAtMs = SystemClock.elapsedRealtime()
|
||||
val commandReceivedAtWallClockMs = System.currentTimeMillis()
|
||||
val commandHex = commandApdu.toHexOrNull() ?: "null"
|
||||
clearApduLogIfNeeded(commandReceivedAtWallClockMs, commandHex)
|
||||
if (isTestMode()) {
|
||||
Log.w(TAG, "TEST MODE active: short-circuit APDU handling")
|
||||
metrics.track("COMMAND_IN_TEST_MODE")
|
||||
val response = handleTestModeCommand(commandApdu)
|
||||
metrics.track("COMMAND_RESPONDED")
|
||||
logApduExchange(commandHex, response)
|
||||
reportIncidentsIfNeeded()
|
||||
persistHealthSnapshot("test-mode-command")
|
||||
return response
|
||||
}
|
||||
metrics.track("COMMAND_RECEIVED")
|
||||
metrics.track("COMMAND_TOTAL")
|
||||
if (commandApdu == null) {
|
||||
metrics.track("COMMAND_REJECTED_NULL")
|
||||
metrics.track("COMMAND_FAIL")
|
||||
val response = BrizziResponse.wrongLength
|
||||
logApduExchange("null", response)
|
||||
reportIncidentsIfNeeded()
|
||||
return response
|
||||
}
|
||||
if (commandApdu.isEmpty() || commandApdu.size > MAX_APDU_BYTES) {
|
||||
metrics.track("COMMAND_REJECTED_INVALID_SIZE")
|
||||
metrics.track("COMMAND_FAIL")
|
||||
val response = BrizziResponse.wrongLength
|
||||
logApduExchange(commandHex, response, "invalid-size")
|
||||
reportIncidentsIfNeeded()
|
||||
return response
|
||||
}
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val rateCheck = checkRateLimit(now, commandHex)
|
||||
if (rateCheck != null) {
|
||||
if (rateCheck.contentEquals(BrizziResponse.rateLimitExceeded)) {
|
||||
metrics.track("COMMAND_RATE_LIMIT")
|
||||
metrics.track("COMMAND_FAIL")
|
||||
}
|
||||
logApduExchange(commandHex, rateCheck, "rate-limit")
|
||||
reportIncidentsIfNeeded()
|
||||
return rateCheck
|
||||
}
|
||||
|
||||
if (shouldLogApdu()) {
|
||||
val parsed = router.parseApduSummary(commandHex)
|
||||
Log.d(TAG, "APDU recv: $parsed")
|
||||
if (
|
||||
commandHex.startsWith("00A4") ||
|
||||
commandHex.startsWith("80A4") ||
|
||||
commandHex.startsWith("90A4") ||
|
||||
commandHex.startsWith("905A") ||
|
||||
commandHex.startsWith("90BD") ||
|
||||
commandHex.startsWith("900A")
|
||||
) {
|
||||
Log.d(TAG, "APDU select raw: $commandHex")
|
||||
}
|
||||
}
|
||||
|
||||
val response = router.handle(commandApdu)
|
||||
metrics.track("COMMAND_RESPONDED")
|
||||
when (response.toHex()) {
|
||||
BrizziResponse.wrongLength.toHex() -> metrics.track("RESPONSE_WRONG_LENGTH")
|
||||
BrizziResponse.conditionsNotSatisfied.toHex() -> metrics.track("RESPONSE_CONDITIONS_NOT_SATISFIED")
|
||||
BrizziResponse.unsupportedInstruction.toHex() -> metrics.track("RESPONSE_UNSUPPORTED")
|
||||
BrizziResponse.rateLimitExceeded.toHex() -> metrics.track("RESPONSE_RATE_LIMIT")
|
||||
BrizziResponse.securityStatusNotSatisfied.toHex() -> metrics.track("RESPONSE_SECURITY_STATUS")
|
||||
}
|
||||
if (response.toHex().endsWith("9100") || response.contentEquals(BrizziResponse.nativeSuccess)
|
||||
|| response.toHex() == "BE" || response.toHex() == "91BE"
|
||||
) {
|
||||
metrics.track("COMMAND_SUCCESS")
|
||||
} else {
|
||||
metrics.track("COMMAND_FAIL")
|
||||
}
|
||||
reportIncidentsIfNeeded()
|
||||
if (shouldLogApdu()) {
|
||||
Log.d(TAG, "APDU summary: ${toLogSafeApduHex(commandApdu, response)}")
|
||||
}
|
||||
logApduExchange(commandHex, response)
|
||||
if (isDebuggable()) {
|
||||
Log.d(TAG, "security metrics snapshot: ${metrics.snapshot()}")
|
||||
}
|
||||
persistHealthSnapshot("command")
|
||||
return response
|
||||
}
|
||||
|
||||
private fun handleTestModeCommand(commandApdu: ByteArray?): ByteArray {
|
||||
if (commandApdu == null) {
|
||||
metrics.track("COMMAND_REJECTED_NULL")
|
||||
logApduExchange("null", BrizziResponse.wrongLength, "test-mode-invalid")
|
||||
return BrizziResponse.wrongLength
|
||||
}
|
||||
if (commandApdu.isEmpty() || commandApdu.size > MAX_APDU_BYTES) {
|
||||
metrics.track("COMMAND_REJECTED_INVALID_SIZE")
|
||||
logApduExchange(commandApdu.toHexOrNull() ?: "null", BrizziResponse.wrongLength, "test-mode-invalid-size")
|
||||
return BrizziResponse.wrongLength
|
||||
}
|
||||
val hex = commandApdu.toHex().uppercase()
|
||||
return when {
|
||||
hex.startsWith("00A4") || hex.startsWith("80A4") || hex.startsWith("90A4") -> {
|
||||
metrics.track("TEST_MODE_SELECT")
|
||||
Log.d(TAG, "TEST MODE SELECT command: $hex")
|
||||
BrizziResponse.iso("B2495A5A5A")
|
||||
}
|
||||
hex == "80CA9F7F00" || hex == "CA9F7F00" || hex == "00CA9F7F00" -> {
|
||||
metrics.track("TEST_MODE_HEALTH_PING")
|
||||
Log.d(TAG, "TEST MODE health ping: $hex")
|
||||
BrizziResponse.native("484953544F4D45")
|
||||
}
|
||||
else -> {
|
||||
metrics.track("TEST_MODE_DEFAULT_OK")
|
||||
Log.d(TAG, "TEST MODE default response for: $hex")
|
||||
BrizziResponse.nativeSuccess
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dipanggil saat HCE dinonaktifkan (mis. kartu dijauhkan / sesi selesai).
|
||||
* Kita reset state di router supaya command berikutnya tidak lanjut dari state lama.
|
||||
*/
|
||||
override fun onDeactivated(reason: Int) {
|
||||
Log.d(TAG, "HCE deactivated: $reason")
|
||||
router.resetSession()
|
||||
if (isDebuggable()) {
|
||||
Log.d(TAG, "security metrics: ${metrics.snapshot()}")
|
||||
Log.d(TAG, "security health: ${metrics.exportHealthSnapshot()}")
|
||||
}
|
||||
appendTapFooter(reason)
|
||||
isTapSessionActive = false
|
||||
activeTapStartedAtWallClockMs = 0L
|
||||
persistHealthSnapshot("deactivated-$reason")
|
||||
metrics.track("SERVICE_DEACTIVATED")
|
||||
commandWindowStartMs = 0L
|
||||
commandCountInWindow = 0
|
||||
lastCommandMs = 0L
|
||||
lastIncidentLogMs = 0L
|
||||
}
|
||||
|
||||
private fun persistHealthSnapshot(reason: String) {
|
||||
Log.d(TAG, "persistHealthSnapshot reason=$reason")
|
||||
runCatching {
|
||||
val file = File(filesDir, METRICS_EXPORT_FILE)
|
||||
file.appendText("${System.currentTimeMillis()},$reason,${metrics.exportHealthSnapshot()}\n")
|
||||
}
|
||||
}
|
||||
|
||||
private fun reportIncidentsIfNeeded() {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (now - lastIncidentLogMs < INCIDENT_LOG_THROTTLE_MS) return
|
||||
val incidents = metrics.activeIncidents()
|
||||
if (incidents.isEmpty()) return
|
||||
lastIncidentLogMs = now
|
||||
Log.w(TAG, "security incidents: ${metrics.incidentSummary()}")
|
||||
}
|
||||
|
||||
private fun checkRateLimit(nowMs: Long, commandHex: String): ByteArray? {
|
||||
if (commandWindowStartMs == 0L) {
|
||||
commandWindowStartMs = nowMs
|
||||
}
|
||||
if (nowMs - commandWindowStartMs >= RATE_WINDOW_MS) {
|
||||
commandWindowStartMs = nowMs
|
||||
commandCountInWindow = 0
|
||||
}
|
||||
commandCountInWindow += 1
|
||||
if (commandCountInWindow > MAX_COMMANDS_PER_WINDOW) {
|
||||
return BrizziResponse.rateLimitExceeded
|
||||
}
|
||||
val minInterval = if (isDebuggable()) DEBUG_MIN_COMMAND_INTERVAL_MS else MIN_COMMAND_INTERVAL_MS
|
||||
if (isAggressiveBurstSensitive(commandHex) &&
|
||||
lastCommandMs != 0L &&
|
||||
nowMs - lastCommandMs < minInterval
|
||||
) {
|
||||
return BrizziResponse.rateLimitExceeded
|
||||
}
|
||||
lastCommandMs = nowMs
|
||||
return null
|
||||
}
|
||||
|
||||
private fun isAggressiveBurstSensitive(commandHex: String): Boolean {
|
||||
val normalized = commandHex.uppercase()
|
||||
return when {
|
||||
normalized.startsWith("00A404") -> true
|
||||
normalized == "6C00" -> true
|
||||
normalized.contains("9F7900") && normalized.substring(2).isNotEmpty() && normalized.substring(2, 4) == "CA" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTestMode(): Boolean {
|
||||
if (!isDebuggable()) return false
|
||||
return preferences.getBoolean(KEY_TEST_MODE_ENABLED, false)
|
||||
}
|
||||
|
||||
private fun logApduExchange(
|
||||
commandHex: String,
|
||||
response: ByteArray,
|
||||
extra: String? = null
|
||||
) {
|
||||
if (!isDebuggable()) return
|
||||
runCatching {
|
||||
val timestamp = LOG_TIME_FORMAT.format(System.currentTimeMillis())
|
||||
val commandIndex = ++commandCountInTap
|
||||
val reason = extra?.let { " | $it" } ?: ""
|
||||
val line = "$timestamp | TAP=$currentTapId CMD#$commandIndex | REQ=$commandHex | RSP=${response.toHex().uppercase()}$reason"
|
||||
val file = File(filesDir, APDU_EXCHANGE_LOG_FILE)
|
||||
val lines = if (file.exists()) file.readLines() else emptyList()
|
||||
val trimmedLines = if (lines.size >= APDU_LOG_MAX_LINES) {
|
||||
lines.takeLast(APDU_LOG_MAX_LINES - 1)
|
||||
} else {
|
||||
lines
|
||||
}
|
||||
file.writeText((trimmedLines + line).joinToString(System.lineSeparator()) + System.lineSeparator())
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Failed to write APDU exchange log", it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearApduLogIfNeeded(nowWallClockMs: Long, commandHex: String) {
|
||||
if (!isDebuggable()) return
|
||||
val normalized = commandHex.uppercase()
|
||||
val isTapStart = isTapStartCommand(normalized)
|
||||
val currentAid = extractSelectAid(normalized)
|
||||
val isAidChangedTapStart = isTapStart &&
|
||||
activeTapLastSelectAid.isNotBlank() &&
|
||||
currentAid != null &&
|
||||
currentAid.isNotBlank() &&
|
||||
currentAid != activeTapLastSelectAid
|
||||
val isTimedOut = isTapSessionActive &&
|
||||
activeTapStartedAtWallClockMs != 0L &&
|
||||
nowWallClockMs - activeTapStartedAtWallClockMs > TAP_CLEAR_IDLE_MS
|
||||
|
||||
if (!isTapSessionActive || isTimedOut || isAidChangedTapStart || (isTapStart && activeTapLastSelectAid.isBlank())) {
|
||||
startNewTap(nowWallClockMs, normalized)
|
||||
return
|
||||
}
|
||||
|
||||
if (isTapSessionActive) {
|
||||
activeTapStartedAtWallClockMs = nowWallClockMs
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearTapExchangeLog() {
|
||||
runCatching {
|
||||
File(filesDir, APDU_EXCHANGE_LOG_FILE).delete()
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Failed to clear APDU exchange log", it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startNewTap(nowWallClockMs: Long, commandHex: String) {
|
||||
currentTapId += 1
|
||||
clearTapExchangeLog()
|
||||
isTapSessionActive = true
|
||||
commandCountInTap = 0
|
||||
activeTapStartedAtWallClockMs = nowWallClockMs
|
||||
activeTapLastSelectAid = extractSelectAid(commandHex) ?: ""
|
||||
appendTapHeader(commandHex)
|
||||
}
|
||||
|
||||
private fun appendTapHeader(normalizedCommandHex: String) {
|
||||
if (!isDebuggable()) return
|
||||
runCatching {
|
||||
val file = File(filesDir, APDU_EXCHANGE_LOG_FILE)
|
||||
val timestamp = LOG_TIME_FORMAT.format(System.currentTimeMillis())
|
||||
val headerAid = if (activeTapLastSelectAid.isBlank()) "none" else activeTapLastSelectAid
|
||||
val header = "$timestamp | TAP=$currentTapId START | CMD=${normalizedCommandHex.take(24)} | AID=$headerAid"
|
||||
val lines = if (file.exists()) file.readLines() else emptyList()
|
||||
val trimmed = if (lines.size >= APDU_LOG_MAX_LINES) {
|
||||
lines.takeLast(APDU_LOG_MAX_LINES - 1)
|
||||
} else {
|
||||
lines
|
||||
}
|
||||
file.writeText((trimmed + header).joinToString(System.lineSeparator()) + System.lineSeparator())
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Failed to write tap header", it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun appendTapFooter(reason: Int) {
|
||||
if (!isDebuggable() || !isTapSessionActive) return
|
||||
runCatching {
|
||||
val file = File(filesDir, APDU_EXCHANGE_LOG_FILE)
|
||||
val timestamp = LOG_TIME_FORMAT.format(System.currentTimeMillis())
|
||||
val footer = "$timestamp | TAP=$currentTapId END | reason=$reason | cmds=${commandCountInTap}"
|
||||
val lines = if (file.exists()) file.readLines() else emptyList()
|
||||
val trimmed = if (lines.size >= APDU_LOG_MAX_LINES) {
|
||||
lines.takeLast(APDU_LOG_MAX_LINES - 1)
|
||||
} else {
|
||||
lines
|
||||
}
|
||||
file.writeText((trimmed + footer).joinToString(System.lineSeparator()) + System.lineSeparator())
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Failed to write tap footer", it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTapStartCommand(normalizedCommandHex: String): Boolean {
|
||||
return normalizedCommandHex.startsWith("00A4") ||
|
||||
normalizedCommandHex.startsWith("90A4") ||
|
||||
normalizedCommandHex.startsWith("80A4")
|
||||
}
|
||||
|
||||
private fun extractSelectAid(normalizedCommandHex: String): String? {
|
||||
if (!isTapStartCommand(normalizedCommandHex) || normalizedCommandHex.length < 10) return null
|
||||
val lc = normalizedCommandHex.substring(8, 10).toIntOrNull(16) ?: return null
|
||||
if (lc == 0) return ""
|
||||
val start = 10
|
||||
val end = start + (lc * 2)
|
||||
if (end > normalizedCommandHex.length) return null
|
||||
return normalizedCommandHex.substring(start, end)
|
||||
}
|
||||
|
||||
private fun ByteArray?.toHexOrNull(): String? = this?.toHex()?.uppercase()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BrizziHce"
|
||||
/** Debug logging hex APDU aktif/inaktif. Jika true, service log ke logcat. */
|
||||
private const val LOG_VERBOSE = true
|
||||
private const val MAX_APDU_BYTES = 1024
|
||||
/** Jendela sliding 1 detik untuk hitung rate-limiting. */
|
||||
private const val RATE_WINDOW_MS = 1000L
|
||||
/** Maksimum request dalam [RATE_WINDOW_MS]. */
|
||||
private const val MAX_COMMANDS_PER_WINDOW = 80
|
||||
/** Jarak minimum antar request agar tidak dianggap burst. */
|
||||
private const val MIN_COMMAND_INTERVAL_MS = 30L
|
||||
private const val DEBUG_MIN_COMMAND_INTERVAL_MS = 0L
|
||||
private const val INCIDENT_LOG_THROTTLE_MS = 60_000L
|
||||
/** File internal yang menyimpan line-by-line snapshot kesehatan. */
|
||||
private const val METRICS_EXPORT_FILE = "security_metrics_report.txt"
|
||||
private const val PREFS_NAME = "brizzi_hce_config"
|
||||
private const val KEY_TEST_MODE_ENABLED = "hce_test_mode_enabled"
|
||||
// Keep this as primary routing AID for emulator activation.
|
||||
// Additional routing AIDs can be passed as a comma-separated list via this string if needed.
|
||||
private const val ROUTING_AID_HEX =
|
||||
"A0000000180F0000018001,D4100000030001,D3600000030003,5A00000301000000,5A00000303000000"
|
||||
private const val APDU_EXCHANGE_LOG_FILE = "apdu_exchange_log.txt"
|
||||
private const val APDU_LOG_MAX_LINES = 200
|
||||
private const val TAP_CLEAR_IDLE_MS = 1_200L
|
||||
private val LOG_TIME_FORMAT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
|
||||
}
|
||||
}
|
||||
35
app/src/main/java/com/korancrew/brizzi/hce/BrizziResponse.kt
Normal file
35
app/src/main/java/com/korancrew/brizzi/hce/BrizziResponse.kt
Normal file
@ -0,0 +1,35 @@
|
||||
package com.korancrew.brizzi.hce
|
||||
|
||||
/**
|
||||
* Koleksi constant dan helper response APDU.
|
||||
* Semua status/proses response mengikuti format Brizzi/ISO-like yang dipakai reader.
|
||||
*/
|
||||
object BrizziResponse {
|
||||
val isoSuccess: ByteArray = "9100".hexToBytes()
|
||||
val iso7816Success: ByteArray = "9000".hexToBytes()
|
||||
val nativeSuccess: ByteArray = "00".hexToBytes()
|
||||
val fileNotFound: ByteArray = "6A82".hexToBytes()
|
||||
val wrongLength: ByteArray = "6700".hexToBytes()
|
||||
val conditionsNotSatisfied: ByteArray = "6985".hexToBytes()
|
||||
val unsupportedInstruction: ByteArray = "6D00".hexToBytes()
|
||||
val securityStatusNotSatisfied: ByteArray = "6982".hexToBytes()
|
||||
val rateLimitExceeded: ByteArray = "6FFF".hexToBytes()
|
||||
|
||||
fun iso(payloadHex: String = ""): ByteArray = (payloadHex + "9100").hexToBytes()
|
||||
|
||||
fun iso7816(payloadHex: String = ""): ByteArray = (payloadHex + "9000").hexToBytes()
|
||||
|
||||
fun isoAdditionalFrame(payloadHex: String = ""): ByteArray =
|
||||
(payloadHex + "91AF").hexToBytes()
|
||||
|
||||
fun isoNoLog(): ByteArray = "91BE".hexToBytes()
|
||||
|
||||
fun native(payloadHex: String = ""): ByteArray =
|
||||
if (payloadHex.isEmpty()) {
|
||||
"9100".hexToBytes()
|
||||
} else {
|
||||
(payloadHex + "9100").hexToBytes()
|
||||
}
|
||||
|
||||
fun nativeNoLog(): ByteArray = "BE".hexToBytes()
|
||||
}
|
||||
@ -0,0 +1,159 @@
|
||||
package com.korancrew.brizzi.hce
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* Persistensi data kartu di SharedPreferences terenkripsi.
|
||||
*
|
||||
* Data JSON disimpan di:
|
||||
* - Nama prefs: brizzi_hce_secure
|
||||
* - Kunci utama: card_json
|
||||
*
|
||||
* Kunci dan value dienkripsi menggunakan Android Keystore MasterKey.
|
||||
*/
|
||||
class BrizziSecureStorage(context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
private val prefs: SharedPreferences? by lazy {
|
||||
runCatching {
|
||||
val masterKey = MasterKey.Builder(appContext, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
appContext,
|
||||
PREF_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/** Ambil data card dari storage. Jika rusak/tidak ada, return null. */
|
||||
fun loadCard(): BrizziCard? {
|
||||
val raw = prefs?.getString(KEY_CARD_JSON, null) ?: return null
|
||||
return runCatching {
|
||||
val json = JSONObject(raw)
|
||||
BrizziCard(
|
||||
cardNumberHex = json.getString(KEY_CARD_NUMBER),
|
||||
persoDateHex = json.getString(KEY_PERSO_DATE),
|
||||
issuerCodeHex = json.getString(KEY_ISSUER),
|
||||
cardStatusHex = json.getString(KEY_CARD_STATUS),
|
||||
uidHex = json.getString(KEY_UID),
|
||||
keyCard00Hex = json.getString(KEY_KEY_CARD_00),
|
||||
keyCard01Hex = json.getString(KEY_KEY_CARD_01),
|
||||
randomNumberHex = json.getString(KEY_RANDOM),
|
||||
lastTransactionDateHex = json.getString(KEY_LAST_TXN_DATE),
|
||||
akumDebetHex = json.getString(KEY_AKUM_DEBET),
|
||||
balance = json.getInt(KEY_BALANCE),
|
||||
logs = parseLogs(json.getJSONArray(KEY_LOGS)),
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpan snapshot card.
|
||||
* Hanya field yang didefinisikan di [BrizziCard] yang diserialisasi ke JSON.
|
||||
*/
|
||||
fun saveCard(card: BrizziCard) {
|
||||
val json = JSONObject().apply {
|
||||
put(KEY_CARD_NUMBER, card.cardNumberHex)
|
||||
put(KEY_PERSO_DATE, card.persoDateHex)
|
||||
put(KEY_ISSUER, card.issuerCodeHex)
|
||||
put(KEY_CARD_STATUS, card.cardStatusHex)
|
||||
put(KEY_UID, card.uidHex)
|
||||
put(KEY_KEY_CARD_00, card.keyCard00Hex)
|
||||
put(KEY_KEY_CARD_01, card.keyCard01Hex)
|
||||
put(KEY_RANDOM, card.randomNumberHex)
|
||||
put(KEY_LAST_TXN_DATE, card.lastTransactionDateHex)
|
||||
put(KEY_AKUM_DEBET, card.akumDebetHex)
|
||||
put(KEY_BALANCE, card.balance)
|
||||
put(
|
||||
KEY_LOGS,
|
||||
JSONArray().apply {
|
||||
card.logs.take(MAX_LOGS).forEach { log ->
|
||||
put(
|
||||
JSONObject().apply {
|
||||
put(KEY_LOG_MERCHANT, log.merchantIdHex)
|
||||
put(KEY_LOG_TERMINAL, log.terminalIdAsciiHex)
|
||||
put(KEY_LOG_DATE, log.trxDateHex)
|
||||
put(KEY_LOG_TIME, log.trxTimeHex)
|
||||
put(KEY_LOG_TYPE, log.trxTypeHex)
|
||||
put(KEY_LOG_AMOUNT, log.amountReversedHex)
|
||||
put(KEY_LOG_BAL_BEFORE, log.balanceBeforeReversedHex)
|
||||
put(KEY_LOG_BAL_AFTER, log.balanceAfterReversedHex)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
prefs?.edit()
|
||||
?.putString(KEY_CARD_JSON, json.toString())
|
||||
?.apply()
|
||||
}
|
||||
|
||||
/** Hapus data card tersimpan. */
|
||||
fun clear() {
|
||||
prefs?.edit()?.remove(KEY_CARD_JSON)?.apply()
|
||||
}
|
||||
|
||||
/** Parse list log dari JSONArray dengan default fallback value agar resilient. */
|
||||
private fun parseLogs(jsonArray: JSONArray): List<BrizziLogRecord> {
|
||||
val result = mutableListOf<BrizziLogRecord>()
|
||||
for (index in 0 until jsonArray.length()) {
|
||||
val raw = jsonArray.optJSONObject(index) ?: continue
|
||||
val merchant = raw.optString(KEY_LOG_MERCHANT, "0000000000000000")
|
||||
val terminal = raw.optString(KEY_LOG_TERMINAL, "0000000000000000")
|
||||
val date = raw.optString(KEY_LOG_DATE, "000000")
|
||||
val time = raw.optString(KEY_LOG_TIME, "000000")
|
||||
val type = raw.optString(KEY_LOG_TYPE, "00")
|
||||
val amount = raw.optString(KEY_LOG_AMOUNT, "000000")
|
||||
val before = raw.optString(KEY_LOG_BAL_BEFORE, "000000")
|
||||
val after = raw.optString(KEY_LOG_BAL_AFTER, "000000")
|
||||
result.add(
|
||||
BrizziLogRecord(
|
||||
merchantIdHex = merchant,
|
||||
terminalIdAsciiHex = terminal,
|
||||
trxDateHex = date,
|
||||
trxTimeHex = time,
|
||||
trxTypeHex = type,
|
||||
amountReversedHex = amount,
|
||||
balanceBeforeReversedHex = before,
|
||||
balanceAfterReversedHex = after,
|
||||
),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREF_NAME = "brizzi_hce_secure"
|
||||
private const val KEY_CARD_JSON = "card_json"
|
||||
private const val KEY_CARD_NUMBER = "cardNumberHex"
|
||||
private const val KEY_PERSO_DATE = "persoDateHex"
|
||||
private const val KEY_ISSUER = "issuerCodeHex"
|
||||
private const val KEY_CARD_STATUS = "cardStatusHex"
|
||||
private const val KEY_UID = "uidHex"
|
||||
private const val KEY_KEY_CARD_00 = "keyCard00Hex"
|
||||
private const val KEY_KEY_CARD_01 = "keyCard01Hex"
|
||||
private const val KEY_RANDOM = "randomNumberHex"
|
||||
private const val KEY_LAST_TXN_DATE = "lastTransactionDateHex"
|
||||
private const val KEY_AKUM_DEBET = "akumDebetHex"
|
||||
private const val KEY_BALANCE = "balance"
|
||||
private const val KEY_LOGS = "logs"
|
||||
private const val KEY_LOG_MERCHANT = "merchantIdHex"
|
||||
private const val KEY_LOG_TERMINAL = "terminalIdAsciiHex"
|
||||
private const val KEY_LOG_DATE = "trxDateHex"
|
||||
private const val KEY_LOG_TIME = "trxTimeHex"
|
||||
private const val KEY_LOG_TYPE = "trxTypeHex"
|
||||
private const val KEY_LOG_AMOUNT = "amountReversedHex"
|
||||
private const val KEY_LOG_BAL_BEFORE = "balanceBeforeReversedHex"
|
||||
private const val KEY_LOG_BAL_AFTER = "balanceAfterReversedHex"
|
||||
private const val MAX_LOGS = 12
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,156 @@
|
||||
package com.korancrew.brizzi.hce
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import org.json.JSONObject
|
||||
|
||||
private data class MetricSnapshot(val total: Long, val byEvent: Map<String, Long>)
|
||||
data class SecurityIncident(val event: String, val count: Long, val threshold: Long)
|
||||
|
||||
/**
|
||||
* Pengumpul metrik runtime (in-app, ringan, tanpa raw APDU).
|
||||
*
|
||||
* Counter disimpan di SharedPreferences dengan key event string uppercase.
|
||||
* Digunakan untuk:
|
||||
* - observability,
|
||||
* - ambang incident,
|
||||
* - health snapshot.
|
||||
*/
|
||||
class BrizziSecurityMetrics(context: Context) {
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("brizzi_hce_security_metrics", Context.MODE_PRIVATE)
|
||||
|
||||
/** Tambah 1 event. Key akan dinormalisasi ke uppercase. */
|
||||
fun track(event: String) {
|
||||
val key = event.trim().uppercase()
|
||||
if (key.isEmpty()) return
|
||||
val nextEvent = if (prefs.getLong(key, 0L) + 1L < 1L) 1L else prefs.getLong(key, 0L) + 1L
|
||||
val total = prefs.getLong(KEY_TOTAL_EVENTS, 0L) + 1L
|
||||
prefs.edit().apply {
|
||||
putLong(key, nextEvent)
|
||||
putLong(KEY_TOTAL_EVENTS, total)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
/** Export semua metrik sebagai string JSON. */
|
||||
fun reportJson(): String {
|
||||
val events = JSONObject().apply {
|
||||
val all = prefs.all
|
||||
all.entries.sortedBy { it.key }.forEach { (name, value) ->
|
||||
val num = when (value) {
|
||||
is Long -> value
|
||||
is Int -> value.toLong()
|
||||
is String -> value.toLongOrNull() ?: 0L
|
||||
else -> 0L
|
||||
}
|
||||
put(name, num)
|
||||
}
|
||||
}
|
||||
return events.toString()
|
||||
}
|
||||
|
||||
/** Rasio sukses = COMMAND_SUCCESS / totalCommand. */
|
||||
fun successRate(totalCommand: Long): Double {
|
||||
if (totalCommand == 0L) return 0.0
|
||||
val ok = getCount(EVENT_COMMAND_SUCCESS)
|
||||
return ok.toDouble() / totalCommand.toDouble()
|
||||
}
|
||||
|
||||
/** Ringkasan rate sukses/gagal secara human-readable. */
|
||||
fun commandVolumeByType(totalCommand: Long): String {
|
||||
val success = getCount(EVENT_COMMAND_SUCCESS)
|
||||
val fail = if (totalCommand - success < 0L) 0L else totalCommand - success
|
||||
val failRate = if (totalCommand == 0L) 0.0 else fail.toDouble() / totalCommand.toDouble()
|
||||
return "success=$success fail=$fail failRate=${String.format("%.2f", failRate * 100)}%"
|
||||
}
|
||||
|
||||
fun activeIncidents(): List<SecurityIncident> =
|
||||
INCIDENT_THRESHOLDS.mapNotNull { (event, threshold) ->
|
||||
val count = getCount(event)
|
||||
if (count >= threshold) SecurityIncident(event, count, threshold.toLong()) else null
|
||||
}.sortedBy { it.event }
|
||||
|
||||
/** Reset semua counter event. */
|
||||
fun reset() {
|
||||
val editor = prefs.edit()
|
||||
for (key in prefs.all.keys) {
|
||||
editor.remove(key)
|
||||
}
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
/** Snapshot pendek untuk quick-read dalam log/debug UI. */
|
||||
fun snapshot(): String {
|
||||
val total = prefs.getLong("TOTAL_EVENTS", 0L)
|
||||
val summary = mutableMapOf<String, Long>()
|
||||
for ((name, value) in prefs.all) {
|
||||
if (name == "TOTAL_EVENTS") continue
|
||||
summary[name] = when (value) {
|
||||
is Long -> value
|
||||
is Int -> value.toLong()
|
||||
is String -> value.toLongOrNull() ?: 0L
|
||||
else -> 0L
|
||||
}
|
||||
}
|
||||
val snapshot = MetricSnapshot(total, summary)
|
||||
return buildString {
|
||||
append("total=").append(snapshot.total)
|
||||
append(" COMMAND_TOTAL=").append(getCount(EVENT_COMMAND_SUCCESS) + getCount(EVENT_COMMAND_FAIL))
|
||||
append(" ")
|
||||
snapshot.byEvent.entries.sortedBy { it.key }.forEachIndexed { idx, (name, count) ->
|
||||
if (idx > 0) append(", ")
|
||||
append(name).append("=").append(count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Helper baca count dengan key langsung (tanpa normalisasi manual di caller). */
|
||||
private fun getCount(event: String): Long {
|
||||
return when (val raw = prefs.getLong(event, 0L)) {
|
||||
in Long.MIN_VALUE..Long.MAX_VALUE -> raw
|
||||
else -> 0L
|
||||
}
|
||||
}
|
||||
|
||||
/** Baca counter event; selalu uppercase dan fallback 0. */
|
||||
fun getCountOrZero(event: String): Long = getCount(event.uppercase())
|
||||
|
||||
/** Ringkas daftar incident aktif (default max 8). */
|
||||
fun incidentSummary(limit: Int = 8): String {
|
||||
val incidents = activeIncidents().take(limit)
|
||||
return if (incidents.isEmpty()) {
|
||||
"no_incidents"
|
||||
} else {
|
||||
incidents.joinToString("|") { "${it.event}:${it.count}/${it.threshold}" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Health snapshot siap diparsing untuk metric dashboard/OPS. */
|
||||
fun exportHealthSnapshot(): String = buildString {
|
||||
val commandTotal = getCount(EVENT_COMMAND_TOTAL)
|
||||
append("command_total=").append(commandTotal)
|
||||
append(" command_success=").append(getCount(EVENT_COMMAND_SUCCESS))
|
||||
append(" command_fail=").append(if (commandTotal - getCount(EVENT_COMMAND_SUCCESS) < 0L) 0L else commandTotal - getCount(EVENT_COMMAND_SUCCESS))
|
||||
append(" timeout=").append(getCount("SESSION_TIMEOUT"))
|
||||
append(" rate_limit=").append(getCount("COMMAND_RATE_LIMIT"))
|
||||
append(" replay=").append(getCount("REPLAY_DETECTED"))
|
||||
append(" wrong_len=").append(getCount("APDU_PARSE_FAILED"))
|
||||
append(" fail_rate=").append(String.format("%.2f", 1.0 - successRate(commandTotal)))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_TOTAL_EVENTS = "TOTAL_EVENTS"
|
||||
private const val EVENT_COMMAND_SUCCESS = "COMMAND_SUCCESS"
|
||||
private const val EVENT_COMMAND_FAIL = "COMMAND_FAIL"
|
||||
private const val EVENT_COMMAND_TOTAL = "COMMAND_TOTAL"
|
||||
private val INCIDENT_THRESHOLDS = mapOf(
|
||||
"COMMAND_RATE_LIMIT" to 10L,
|
||||
"REPLAY_DETECTED" to 3L,
|
||||
"APDU_PARSE_FAILED" to 20L,
|
||||
"AUTH_DENIED" to 30L,
|
||||
"SESSION_TIMEOUT" to 50L,
|
||||
"REPLAY_SENSITIVE_RETRY" to 20L,
|
||||
"CONDITIONS_NOT_SATISFIED" to 100L,
|
||||
)
|
||||
}
|
||||
}
|
||||
189
app/src/main/java/com/korancrew/brizzi/hce/BrizziSession.kt
Normal file
189
app/src/main/java/com/korancrew/brizzi/hce/BrizziSession.kt
Normal file
@ -0,0 +1,189 @@
|
||||
package com.korancrew.brizzi.hce
|
||||
|
||||
/**
|
||||
* AID yang dikenali saat ini.
|
||||
*/
|
||||
enum class SelectedAid {
|
||||
NONE,
|
||||
OTHER,
|
||||
AID1,
|
||||
AID3,
|
||||
}
|
||||
|
||||
/** Tipe transaksi yang dapat berjalan di sesi. */
|
||||
enum class TransactionKind {
|
||||
DEBIT,
|
||||
CREDIT,
|
||||
}
|
||||
|
||||
/** Fase transaksi dalam satu sesi transaksi AID3. */
|
||||
enum class TransactionPhase {
|
||||
NONE,
|
||||
DEBIT_PENDING,
|
||||
CREDIT_PENDING,
|
||||
LOG_PENDING,
|
||||
LAST_TXN_PENDING,
|
||||
}
|
||||
|
||||
/** Fase autentikasi kartu/NFC session. */
|
||||
enum class SessionPhase {
|
||||
NONE,
|
||||
AID1_SELECTED,
|
||||
AID3_SELECTED,
|
||||
AID3_AUTHENTICATED,
|
||||
}
|
||||
|
||||
data class PendingTransaction(
|
||||
val kind: TransactionKind,
|
||||
val amount: Int,
|
||||
val balanceBefore: Int,
|
||||
val balanceAfter: Int,
|
||||
)
|
||||
|
||||
class BrizziSession {
|
||||
var selectedAid: SelectedAid = SelectedAid.NONE
|
||||
var authenticated: Boolean = false
|
||||
var continuationFrames: MutableList<String> = mutableListOf()
|
||||
var phase: SessionPhase = SessionPhase.NONE
|
||||
var transactionPhase: TransactionPhase = TransactionPhase.NONE
|
||||
var pendingTransaction: PendingTransaction? = null
|
||||
var pendingLogHex: String? = null
|
||||
var pendingLastTransactionDateHex: String? = null
|
||||
var pendingAkumDebetHex: String? = null
|
||||
var readableFallbackStep: Int = 0
|
||||
var lastTouchedAtEpochMs: Long = System.currentTimeMillis()
|
||||
private var replayWindowCommandHex: String? = null
|
||||
private var replayWindowAtEpochMs: Long = 0L
|
||||
|
||||
/**
|
||||
* Reset total state (dipanggil saat deactivation / start sesi baru).
|
||||
*/
|
||||
fun reset() {
|
||||
selectedAid = SelectedAid.NONE
|
||||
authenticated = false
|
||||
phase = SessionPhase.NONE
|
||||
transactionPhase = TransactionPhase.NONE
|
||||
continuationFrames.clear()
|
||||
pendingTransaction = null
|
||||
pendingLogHex = null
|
||||
pendingLastTransactionDateHex = null
|
||||
pendingAkumDebetHex = null
|
||||
readableFallbackStep = 0
|
||||
lastTouchedAtEpochMs = System.currentTimeMillis()
|
||||
replayWindowCommandHex = null
|
||||
replayWindowAtEpochMs = 0L
|
||||
}
|
||||
|
||||
fun clearPendingTransactionState() {
|
||||
pendingTransaction = null
|
||||
pendingLogHex = null
|
||||
pendingLastTransactionDateHex = null
|
||||
pendingAkumDebetHex = null
|
||||
transactionPhase = TransactionPhase.NONE
|
||||
}
|
||||
|
||||
/** Bolehkan command read-only (mis. saldo/log) setelah AID dipilih. */
|
||||
fun isReadyForReadOnlyCommand(): Boolean =
|
||||
selectedAid != SelectedAid.NONE
|
||||
|
||||
/** Pilih AID tidak dikenal dari reader dan reset state transaksi sebelumnya. */
|
||||
fun selectUnknownAid() {
|
||||
selectedAid = SelectedAid.OTHER
|
||||
authenticated = false
|
||||
phase = SessionPhase.NONE
|
||||
transactionPhase = TransactionPhase.NONE
|
||||
readableFallbackStep = 0
|
||||
clearPendingTransactionState()
|
||||
continuationFrames.clear()
|
||||
replayWindowCommandHex = null
|
||||
replayWindowAtEpochMs = 0L
|
||||
}
|
||||
|
||||
/** Pilih AID1 dan reset state transaksi sebelumnya. */
|
||||
fun selectAid1() {
|
||||
selectedAid = SelectedAid.AID1
|
||||
authenticated = false
|
||||
phase = SessionPhase.AID1_SELECTED
|
||||
transactionPhase = TransactionPhase.NONE
|
||||
readableFallbackStep = 0
|
||||
clearPendingTransactionState()
|
||||
continuationFrames.clear()
|
||||
replayWindowCommandHex = null
|
||||
replayWindowAtEpochMs = 0L
|
||||
}
|
||||
|
||||
/** Pilih AID3 dan reset state transaksi sebelumnya. */
|
||||
fun selectAid3() {
|
||||
selectedAid = SelectedAid.AID3
|
||||
authenticated = false
|
||||
phase = SessionPhase.AID3_SELECTED
|
||||
transactionPhase = TransactionPhase.NONE
|
||||
readableFallbackStep = 0
|
||||
clearPendingTransactionState()
|
||||
continuationFrames.clear()
|
||||
replayWindowCommandHex = null
|
||||
replayWindowAtEpochMs = 0L
|
||||
}
|
||||
|
||||
/** Tandai sesi sudah melewati autentikasi key exchange (khusus AID3). */
|
||||
fun authenticate() {
|
||||
authenticated = true
|
||||
if (selectedAid == SelectedAid.AID3) {
|
||||
phase = SessionPhase.AID3_AUTHENTICATED
|
||||
}
|
||||
}
|
||||
|
||||
fun startDebitTransaction() {
|
||||
transactionPhase = TransactionPhase.DEBIT_PENDING
|
||||
}
|
||||
|
||||
fun startCreditTransaction() {
|
||||
transactionPhase = TransactionPhase.CREDIT_PENDING
|
||||
}
|
||||
|
||||
fun startLogUpdate() {
|
||||
transactionPhase = TransactionPhase.LOG_PENDING
|
||||
}
|
||||
|
||||
fun startLastTxnUpdate() {
|
||||
transactionPhase = TransactionPhase.LAST_TXN_PENDING
|
||||
}
|
||||
|
||||
fun isReadyForCommandWithAuth(): Boolean =
|
||||
phase == SessionPhase.AID3_AUTHENTICATED && selectedAid == SelectedAid.AID3
|
||||
|
||||
/** Baru menerima command transaksi bila belum ada pending transaction lain. */
|
||||
fun isReadyForNewTransaction(): Boolean =
|
||||
isReadyForCommandWithAuth() && transactionPhase == TransactionPhase.NONE
|
||||
|
||||
/** true jika sedang ada debit/credit/log/last transaction yang belum di-commit. */
|
||||
fun hasPendingTransactionState(): Boolean = transactionPhase != TransactionPhase.NONE
|
||||
|
||||
/** true bila sesi idle lebih lama dari timeout yang diset. */
|
||||
fun isTimedOut(nowEpochMs: Long, timeoutMs: Long): Boolean =
|
||||
(nowEpochMs - lastTouchedAtEpochMs) > timeoutMs
|
||||
|
||||
/** Update heartbeat sesi untuk mencegah timeout. */
|
||||
fun touch(nowEpochMs: Long = System.currentTimeMillis()) {
|
||||
lastTouchedAtEpochMs = nowEpochMs
|
||||
}
|
||||
|
||||
/**
|
||||
* Deteksi replay untuk command sensitif.
|
||||
* Jika command sama datang lagi di dalam window, return true.
|
||||
*/
|
||||
fun isReplay(windowMillis: Long, commandHex: String, nowEpochMs: Long): Boolean {
|
||||
val normalized = commandHex.uppercase()
|
||||
if (replayWindowCommandHex == normalized && (nowEpochMs - replayWindowAtEpochMs) <= windowMillis) {
|
||||
return true
|
||||
}
|
||||
replayWindowCommandHex = normalized
|
||||
replayWindowAtEpochMs = nowEpochMs
|
||||
return false
|
||||
}
|
||||
|
||||
fun clearReplayWindow() {
|
||||
replayWindowCommandHex = null
|
||||
replayWindowAtEpochMs = 0L
|
||||
}
|
||||
}
|
||||
24
app/src/main/java/com/korancrew/brizzi/hce/Hex.kt
Normal file
24
app/src/main/java/com/korancrew/brizzi/hce/Hex.kt
Normal file
@ -0,0 +1,24 @@
|
||||
package com.korancrew.brizzi.hce
|
||||
|
||||
private val HEX_CHARS = "0123456789ABCDEF".toCharArray()
|
||||
|
||||
/** Convert byte[] ke string hex uppercase tanpa spasi (tanpa pemisah). */
|
||||
fun ByteArray.toHex(): String {
|
||||
val result = StringBuilder(size * 2)
|
||||
forEach { byte ->
|
||||
val value = byte.toInt() and 0xFF
|
||||
result.append(HEX_CHARS[value ushr 4])
|
||||
result.append(HEX_CHARS[value and 0x0F])
|
||||
}
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
/** Convert string hex uppercase ke byte[], melempar jika panjang hex ganjil. */
|
||||
fun String.hexToBytes(): ByteArray {
|
||||
val normalized = replace(" ", "").uppercase()
|
||||
require(normalized.length % 2 == 0) { "Hex string length must be even" }
|
||||
return ByteArray(normalized.length / 2) { index ->
|
||||
val offset = index * 2
|
||||
normalized.substring(offset, offset + 2).toInt(16).toByte()
|
||||
}
|
||||
}
|
||||
51
app/src/main/res/layout/activity_main.xml
Normal file
51
app/src/main/res/layout/activity_main.xml
Normal file
@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="24dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/messageView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/main_message"
|
||||
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/apduLogHeader"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="Log APDU (terkini)"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/clearApduLogButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="Clear Log APDU" />
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginTop="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/apduLogView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="?attr/colorOnBackground"
|
||||
android:textIsSelectable="true"
|
||||
android:fontFamily="monospace"
|
||||
android:textSize="11sp"
|
||||
android:text="Belum ada log APDU." />
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
7
app/src/main/res/values/strings.xml
Normal file
7
app/src/main/res/values/strings.xml
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">BRIZZI HCE</string>
|
||||
<string name="service_description">BRIZZI HCE service</string>
|
||||
<string name="aid_group_description">AID routing for BRIZZI HCE</string>
|
||||
<string name="main_message">BRIZZI HCE emulation app for card transaction command handling.</string>
|
||||
</resources>
|
||||
4
app/src/main/res/values/themes.xml
Normal file
4
app/src/main/res/values/themes.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.BrizziHce" parent="Theme.MaterialComponents.DayNight.NoActionBar" />
|
||||
</resources>
|
||||
15
app/src/main/res/xml/apdu_service.xml
Normal file
15
app/src/main/res/xml/apdu_service.xml
Normal file
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/service_description"
|
||||
android:requireDeviceUnlock="false">
|
||||
<aid-group
|
||||
android:category="payment"
|
||||
android:description="@string/aid_group_description">
|
||||
<aid-filter android:name="325041592E5359532E4444463031" />
|
||||
<aid-filter android:name="A0000000180F0000018001" />
|
||||
<aid-filter android:name="D4100000030001" />
|
||||
<aid-filter android:name="D3600000030003" />
|
||||
<aid-filter android:name="5A00000301000000" />
|
||||
<aid-filter android:name="5A00000303000000" />
|
||||
</aid-group>
|
||||
</host-apdu-service>
|
||||
4
app/src/main/res/xml/network_security_config.xml
Normal file
4
app/src/main/res/xml/network_security_config.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
</network-security-config>
|
||||
@ -0,0 +1,222 @@
|
||||
package com.korancrew.brizzi.hce
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class BrizziApduRouterTest {
|
||||
private val router = BrizziApduRouter(routingAidHex = "F0010203040506")
|
||||
|
||||
@Test
|
||||
fun `select aid1 then read card identity`() {
|
||||
assertEquals("9100", handle(BrizziCommandCatalog.SELECT_AID_1_ISO))
|
||||
assertEquals(
|
||||
"425249601350013966565806081400349100",
|
||||
handle(BrizziCommandCatalog.GET_CARD_INFO_ISO),
|
||||
)
|
||||
assertEquals(
|
||||
"0608146161" + "00".repeat(27) + "9100",
|
||||
handle(BrizziCommandCatalog.GET_CARD_STATUS_ISO),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `debit is visible before commit and rolled back by abort`() {
|
||||
authenticateAid3()
|
||||
|
||||
assertEquals("A08601009100", handle(BrizziCommandCatalog.GET_BALANCE_ISO))
|
||||
assertEquals("000000009100", handle(BrizziCommandCatalog.debitIso("A08601")))
|
||||
assertEquals("000000009100", handle(BrizziCommandCatalog.GET_BALANCE_ISO))
|
||||
assertEquals("9100", handle(BrizziCommandCatalog.ABORT_TRANSACTION_ISO))
|
||||
assertEquals("A08601009100", handle(BrizziCommandCatalog.GET_BALANCE_ISO))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `credit persists after log and commit`() {
|
||||
authenticateAid3()
|
||||
|
||||
assertEquals("40420F009100", handle(BrizziCommandCatalog.creditIso("A0BB0D")))
|
||||
assertEquals(
|
||||
"9100",
|
||||
handle("3B0100000020000000000019384600003130303030303030151117151112EFA0BB0DA0860140420F"),
|
||||
)
|
||||
assertEquals("9100", handle("3D03000000070000151117A0860100"))
|
||||
assertEquals("9100", handle(BrizziCommandCatalog.COMMIT_TRANSACTION_ISO))
|
||||
assertEquals("40420F009100", handle(BrizziCommandCatalog.GET_BALANCE_ISO))
|
||||
assertEquals("151117A08601009100", handle(BrizziCommandCatalog.GET_LAST_TXN_AND_AKUM_DEBET_ISO))
|
||||
assertEquals(1_000_000, router.snapshot().balance)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get log transaction returns known sample record`() {
|
||||
authenticateAid3()
|
||||
|
||||
assertEquals(
|
||||
"00000019384600003130303030303030151117151112EFA0BB0DA0860140420F9100",
|
||||
handle(BrizziCommandCatalog.GET_LOG_TRANSACTION_ISO),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reading transaction area before authentication is rejected`() {
|
||||
assertEquals("9100", handle(BrizziCommandCatalog.SELECT_AID_3_ISO))
|
||||
assertEquals("6985", handle(BrizziCommandCatalog.GET_BALANCE_ISO))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset clears auth state to avoid transaction leakage`() {
|
||||
authenticateAid3()
|
||||
assertEquals("A08601009100", handle(BrizziCommandCatalog.GET_BALANCE_ISO))
|
||||
|
||||
router.resetSession()
|
||||
assertEquals("9100", handle(BrizziCommandCatalog.SELECT_AID_3_ISO))
|
||||
assertEquals("6985", handle(BrizziCommandCatalog.GET_BALANCE_ISO))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed or unsupported commands are handled without crash`() {
|
||||
assertEquals("6A82", handle(byteArrayOf(0x5A.toByte())))
|
||||
assertEquals("6A82", handle(byteArrayOf(0x90.toByte())))
|
||||
assertEquals("6A82", handle(byteArrayOf(0x90.toByte(), 0xBD.toByte())))
|
||||
assertEquals("6A82", handle(byteArrayOf(0x90.toByte(), 0xDC.toByte(), 0x00.toByte(), 0x00.toByte())))
|
||||
assertEquals("6700", handle(byteArrayOf(0x00.toByte(), 0xA4.toByte(), 0x04.toByte())))
|
||||
|
||||
val parsed = BrizziApduRouter.parseApduSummary("90BD000007BD0000000017000000")
|
||||
assertEquals("CLA=90 INS=BD P1=00 P2=00 Lc=07 data-len=9", parsed)
|
||||
assertEquals("6700", handle(byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00)))
|
||||
assertEquals("6700", handle("00A40408075A".hexToBytes()))
|
||||
assertEquals("6700", handle("00A40400075A0000030300".hexToBytes()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replay select variants fixture`() {
|
||||
replayFixture("fixtures/brizzi_reader_trace_select_variants.txt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `native and wrapped debit credit variants behave consistently`() {
|
||||
assertEquals("9100", handle(BrizziCommandCatalog.SELECT_AID_3_ISO))
|
||||
assertEquals("5B907A2C398EFC91AF", handle(BrizziCommandCatalog.REQUEST_KEY_CARD_00_ISO))
|
||||
assertEquals(
|
||||
"56192EA6E27939539100",
|
||||
handle(BrizziCommandCatalog.authenticateIso("09B2D1F3B54A9F9B0CA3F33CC0B5321D")),
|
||||
)
|
||||
|
||||
handle(BrizziCommandCatalog.GET_BALANCE_NATIVE)
|
||||
val wrappedDebit = handle(BrizziCommandCatalog.debitIso("A08601"))
|
||||
val nativeDebit = handle(BrizziCommandCatalog.debitNative("A08601"))
|
||||
assertEquals(wrappedDebit, nativeDebit)
|
||||
assertEquals("9100", handle(BrizziCommandCatalog.ABORT_TRANSACTION_ISO))
|
||||
val wrappedCredit = handle(BrizziCommandCatalog.creditIso("A0BB0D"))
|
||||
assertEquals("9100", handle(BrizziCommandCatalog.ABORT_TRANSACTION_ISO))
|
||||
val nativeCredit = handle(BrizziCommandCatalog.creditNative("A0BB0D"))
|
||||
assertEquals(wrappedCredit, nativeCredit)
|
||||
|
||||
assertEquals("9100", handle(BrizziCommandCatalog.COMMIT_TRANSACTION_ISO))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replay reader trace fixture`() {
|
||||
replayFixture("fixtures/brizzi_reader_trace.txt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replay full topup trace fixture`() {
|
||||
replayFixture("fixtures/brizzi_reader_trace_full_topup.txt", routingAidHex = "5A00000303000000")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replay full deduct trace fixture`() {
|
||||
replayFixture("fixtures/brizzi_reader_trace_full_deduct.txt", routingAidHex = "5A00000303000000")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replay negative out-of-order fixture`() {
|
||||
replayFixture("fixtures/brizzi_reader_trace_negative_out_of_order.txt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replay unauthenticated transaction fixture`() {
|
||||
replayFixture("fixtures/brizzi_reader_trace_unauthenticated_transactions.txt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replay bad select fixture`() {
|
||||
replayFixture("fixtures/brizzi_reader_trace_bad_select_variants.txt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `iso select command by AID length field is accepted`() {
|
||||
val routingRouter = BrizziApduRouter(
|
||||
routingAidHex = "5A00000301000000,5A00000303000000",
|
||||
)
|
||||
assertEquals("9100", routingRouter.handle(BrizziCommandCatalog.SELECT_AID_1_NATIVE_ISO.hexToBytes()).toHex())
|
||||
assertEquals("9100", routingRouter.handle(BrizziCommandCatalog.SELECT_AID_3_NATIVE_ISO.hexToBytes()).toHex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `select by name APDU (00A4040C) is also accepted`() {
|
||||
val routingRouter = BrizziApduRouter(
|
||||
routingAidHex = "5A00000301000000,5A00000303000000",
|
||||
)
|
||||
assertEquals("9100", routingRouter.handle(BrizziCommandCatalog.SELECT_AID_1_BY_NAME_NATIVE_ISO.hexToBytes()).toHex())
|
||||
assertEquals("9100", routingRouter.handle(BrizziCommandCatalog.SELECT_AID_3_BY_NAME_NATIVE_ISO.hexToBytes()).toHex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown routing aid select is still accepted with generic FCI`() {
|
||||
val routingRouter = BrizziApduRouter(routingAidHex = "5A00000301000000")
|
||||
assertEquals("9100", routingRouter.handle(BrizziCommandCatalog.SELECT_UNKNOWN_AID_NATIVE_ISO.hexToBytes()).toHex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `requesting continuation frame without queued records returns conditions not satisfied`() {
|
||||
assertEquals("6985", handle(byteArrayOf(0xAF.toByte())))
|
||||
assertEquals("6985", handle("90AF000000".hexToBytes()))
|
||||
}
|
||||
|
||||
private fun authenticateAid3() {
|
||||
assertEquals("9100", handle(BrizziCommandCatalog.SELECT_AID_3_ISO))
|
||||
assertEquals("5B907A2C398EFC91AF", handle(BrizziCommandCatalog.REQUEST_KEY_CARD_00_ISO))
|
||||
assertEquals(
|
||||
"56192EA6E27939539100",
|
||||
handle(BrizziCommandCatalog.authenticateIso("09B2D1F3B54A9F9B0CA3F33CC0B5321D")),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handle(hex: String): String = router.handle(hex.hexToBytes()).toHex()
|
||||
private fun handle(bytes: ByteArray): String = router.handle(bytes).toHex()
|
||||
|
||||
private fun replayFixture(path: String, routingAidHex: String = "5A00000301000000,5A00000303000000") {
|
||||
val resource = javaClass.classLoader?.getResourceAsStream(path)
|
||||
?: throw IllegalArgumentException("Missing fixture file: $path")
|
||||
val lines = resource.bufferedReader().readLines()
|
||||
val traceRouter = BrizziApduRouter(routingAidHex = routingAidHex)
|
||||
for (line in lines) {
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.isEmpty() || trimmed.startsWith("#")) continue
|
||||
val parts = trimmed.split("=").map { it.trim() }
|
||||
require(parts.size == 2) { "Invalid fixture line: $trimmed" }
|
||||
val request = parts[0]
|
||||
val expected = parts[1]
|
||||
assertEquals(expected, traceRouter.handle(request.hexToBytes()).toHex())
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertError(hex: String, expectedStatus: String, routingAidHex: String = "F0010203040506") {
|
||||
val routingRouter = BrizziApduRouter(routingAidHex = routingAidHex)
|
||||
assertEquals(expectedStatus, routingRouter.handle(hex.hexToBytes()).toHex())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transaction commands without authentication are rejected`() {
|
||||
assertEquals("9100", handle(BrizziCommandCatalog.SELECT_AID_3_ISO))
|
||||
assertEquals("6985", handle(BrizziCommandCatalog.GET_BALANCE_ISO))
|
||||
assertEquals("6985", handle(BrizziCommandCatalog.debitIso("A08601")))
|
||||
assertEquals("6985", handle(BrizziCommandCatalog.creditIso("A0BB0D")))
|
||||
assertEquals("6985", handle(BrizziCommandCatalog.GET_LAST_TXN_AND_AKUM_DEBET_ISO))
|
||||
assertEquals("6985", handle("0C000004A0BB0D00"))
|
||||
assertEquals("6985", handle("DC000004A0860100"))
|
||||
assertEquals("6985", handle("3B0100000020000000000019384600003130303030303030151117151112EFA0BB0DA0860140420F00"))
|
||||
assertEquals("6985", handle("3D0300000007000015111711A0860100"))
|
||||
}
|
||||
}
|
||||
8
app/src/test/resources/fixtures/brizzi_reader_trace.txt
Normal file
8
app/src/test/resources/fixtures/brizzi_reader_trace.txt
Normal file
@ -0,0 +1,8 @@
|
||||
# format: APDU_HEX = EXPECTED_RESPONSE_HEX
|
||||
905A00000303000000 = 9100
|
||||
900A0000010000 = 5B907A2C398EFC91AF
|
||||
90AF00001009B2D1F3B54A9F9B0CA3F33CC0B5321D00 = 56192EA6E27939539100
|
||||
6C00 = A08601009100
|
||||
90DC000004A0860100 = 000000009100
|
||||
DC000004A0860100 = 000000009100
|
||||
90A7000000 = 9100
|
||||
@ -0,0 +1,4 @@
|
||||
# Invalid SELECT variants: malformed lengths must return wrong length (6700)
|
||||
00A40400075A0000030300000000 = 6700
|
||||
00A40400085A000003030000000000 = 6700
|
||||
00A40400075A0000030300 = 6700
|
||||
@ -0,0 +1,8 @@
|
||||
# BRIZZI deduct-like flow: select AID3, auth, debit, commit
|
||||
905A00000303000000 = 9100
|
||||
900A0000010000 = 5B907A2C398EFC91AF
|
||||
90AF00001009B2D1F3B54A9F9B0CA3F33CC0B5321D00 = 56192EA6E27939539100
|
||||
90DC000004A0860100 = 000000009100
|
||||
90C7000000 = 9100
|
||||
6C00 = 000000009100
|
||||
90BD0000070300000007000000 = 151117A08601009100
|
||||
@ -0,0 +1,10 @@
|
||||
# BRIZZI topup-like flow: select AID3, auth, credit, write log, commit
|
||||
905A00000303000000 = 9100
|
||||
900A0000010000 = 5B907A2C398EFC91AF
|
||||
90AF00001009B2D1F3B54A9F9B0CA3F33CC0B5321D00 = 56192EA6E27939539100
|
||||
900C000004A0BB0D0000 = 40420F009100
|
||||
3B0100000020000000000019384600003130303030303030151117151112EFA0BB0DA0860140420F = 9100
|
||||
3D03000000070000151117A0860100 = 9100
|
||||
90C7000000 = 9100
|
||||
6C00 = 40420F009100
|
||||
90BD0000070300000007000000 = 151117A08601009100
|
||||
@ -0,0 +1,9 @@
|
||||
# Negative flow: commands sent before routing/auth
|
||||
90DC000004A0860100 = 6985
|
||||
90C7000000 = 9100
|
||||
3B0100000020000000000019384600003130303030303030151117151112EFA0BB0DA0860140420F = 6985
|
||||
903D03000000070000151117A0860100 = 6985
|
||||
900C000004A0BB0D0000 = 6985
|
||||
90A7000000 = 9100
|
||||
900A0000010000 = 6985
|
||||
00A40400085A00009999000000 = 6A82
|
||||
@ -0,0 +1,4 @@
|
||||
# ISO SELECT variants expected to route
|
||||
00A40400085A00000303000000 = 9100
|
||||
00A4040C085A00000303000000 = 9100
|
||||
00A40400085A0000030300000000 = 9100
|
||||
@ -0,0 +1,12 @@
|
||||
# Negative flow: AID3 selected but authentication skipped.
|
||||
905A00000303000000 = 9100
|
||||
900C000004A0BB0D00 = 6985
|
||||
90DC000004A0860100 = 6985
|
||||
0C000004A0BB0D00 = 6985
|
||||
DC000004A0860100 = 6985
|
||||
906C0000010000 = 6985
|
||||
90BD0000070300000007000000 = 6985
|
||||
3B0100000020000000000019384600003130303030303030151117151112EFA0BB0DA0860140420F = 6985
|
||||
3D0300000007000015111711A0860100 = 6985
|
||||
90A7000000 = 9100
|
||||
90C7000000 = 9100
|
||||
Reference in New Issue
Block a user