Initial import of Brizzi HCE project

This commit is contained in:
2026-05-03 10:23:41 +07:00
commit 9994823fb3
693 changed files with 51541 additions and 0 deletions

View 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>

View 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>

View 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
}
}

View 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?,
)

View 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()
}

View 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),
)
}

View File

@ -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"
}

View File

@ -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)
}
}

View 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()
}

View File

@ -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
}
}

View File

@ -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,
)
}
}

View 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
}
}

View 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()
}
}

View 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>

View 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>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.BrizziHce" parent="Theme.MaterialComponents.DayNight.NoActionBar" />
</resources>

View 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>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>

View File

@ -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"))
}
}

View 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

View File

@ -0,0 +1,4 @@
# Invalid SELECT variants: malformed lengths must return wrong length (6700)
00A40400075A0000030300000000 = 6700
00A40400085A000003030000000000 = 6700
00A40400075A0000030300 = 6700

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,4 @@
# ISO SELECT variants expected to route
00A40400085A00000303000000 = 9100
00A4040C085A00000303000000 = 9100
00A40400085A0000030300000000 = 9100

View File

@ -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