From 295555f45be0a206a32269b31584a4d7abbf46e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Feb 2026 17:11:43 -0800 Subject: [PATCH 1/4] feat(desktop): detect NFC-V (ISO 15693) cards from PC/SC ATR Add SS byte constants for ICODE SLI (0x07) and Tag-it HFI (0x0C) to PCSCCardInfo, mapping them to CardType.Vicinity. Includes unit tests for both NFC-V ATR patterns plus a regression test for Classic 1K. Co-Authored-By: Claude Opus 4.6 --- .../farebot/card/nfc/PCSCCardInfo.kt | 8 ++ .../farebot/card/nfc/PCSCCardInfoTest.kt | 77 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/PCSCCardInfoTest.kt diff --git a/card/src/jvmMain/kotlin/com/codebutler/farebot/card/nfc/PCSCCardInfo.kt b/card/src/jvmMain/kotlin/com/codebutler/farebot/card/nfc/PCSCCardInfo.kt index 997e54985..50e84ce09 100644 --- a/card/src/jvmMain/kotlin/com/codebutler/farebot/card/nfc/PCSCCardInfo.kt +++ b/card/src/jvmMain/kotlin/com/codebutler/farebot/card/nfc/PCSCCardInfo.kt @@ -48,6 +48,10 @@ data class PCSCCardInfo( private const val SS_FELICA_424K: Byte = 0x12.toByte() private const val SS_JCOP_DESFIRE: Byte = 0x04 // DESFire via JCOP emulation + // ISO 15693 / NFC-V card types + private const val SS_ICODE_SLI: Byte = 0x07 + private const val SS_TAG_IT_HFI: Byte = 0x0C + // Card Name bytes (NN NN) from ATR for finer identification private const val NN_DESFIRE_EV1: Short = 0x0306 private const val NN_DESFIRE_EV2: Short = 0x0308 @@ -139,6 +143,10 @@ data class PCSCCardInfo( PCSCCardInfo(CardType.FeliCa) } + SS_ICODE_SLI, SS_TAG_IT_HFI -> { + PCSCCardInfo(CardType.Vicinity) + } + else -> { PCSCCardInfo(CardType.MifareDesfire) } // default: try DESFire/ISO7816 diff --git a/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/PCSCCardInfoTest.kt b/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/PCSCCardInfoTest.kt new file mode 100644 index 000000000..2b001e110 --- /dev/null +++ b/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/PCSCCardInfoTest.kt @@ -0,0 +1,77 @@ +/* + * PCSCCardInfoTest.kt + * + * This file is part of FareBot. + * Learn more at: https://codebutler.github.io/farebot/ + * + * Copyright (C) 2025 Eric Butler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.codebutler.farebot.card.nfc + +import com.codebutler.farebot.card.CardType +import kotlin.test.Test +import kotlin.test.assertEquals + +class PCSCCardInfoTest { + @Test + fun testFromATR_vicinityICODESLI() { + // ATR with PC/SC RID (A0 00 00 03 06) followed by SS=0x07 (ICODE SLI) and NN=0x00 0x01 + val atr = byteArrayOf( + 0x3B.toByte(), 0x8F.toByte(), 0x80.toByte(), 0x01, + 0x80.toByte(), 0x4F, 0x0C, + 0xA0.toByte(), 0x00, 0x00, 0x03, 0x06, + 0x07, // SS = ICODE SLI (ISO 15693) + 0x00, 0x01, // NN = card name + 0x00, 0x00, 0x00, 0x00, + 0x00, // TCK + ) + val info = PCSCCardInfo.fromATR(atr) + assertEquals(CardType.Vicinity, info.cardType) + } + + @Test + fun testFromATR_vicinityTagIt() { + // ATR with SS=0x0C (Tag-it HFI) + val atr = byteArrayOf( + 0x3B.toByte(), 0x8F.toByte(), 0x80.toByte(), 0x01, + 0x80.toByte(), 0x4F, 0x0C, + 0xA0.toByte(), 0x00, 0x00, 0x03, 0x06, + 0x0C, // SS = Tag-it HFI Plus (ISO 15693) + 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, + 0x00, + ) + val info = PCSCCardInfo.fromATR(atr) + assertEquals(CardType.Vicinity, info.cardType) + } + + @Test + fun testFromATR_existingClassic1k() { + // Verify existing behavior isn't broken: SS=0x01 → MifareClassic + val atr = byteArrayOf( + 0x3B.toByte(), 0x8F.toByte(), 0x80.toByte(), 0x01, + 0x80.toByte(), 0x4F, 0x0C, + 0xA0.toByte(), 0x00, 0x00, 0x03, 0x06, + 0x01, // SS = MIFARE Classic 1K + 0x00, 0x01, + 0x00, 0x00, 0x00, 0x00, + 0x00, + ) + val info = PCSCCardInfo.fromATR(atr) + assertEquals(CardType.MifareClassic, info.cardType) + } +} From 3ff03a5e1f430a576866d7ae5a480e929b70733f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Feb 2026 17:13:23 -0800 Subject: [PATCH 2/4] feat(desktop): add PCSCVicinityTechnology for ISO 15693 over PC/SC Implements VicinityTechnology using PC/SC transparent pseudo-APDUs (FF 00 00 00) to send raw ISO 15693 commands through the reader's contactless interface. Also adds java.smartcardio module to the test compilation classpath and includes FakePCSCChannel test helper. Co-Authored-By: Claude Opus 4.6 --- card/build.gradle.kts | 7 +- .../card/nfc/PCSCVicinityTechnology.kt | 68 +++++++++++++++++++ .../farebot/card/nfc/FakePCSCChannel.kt | 54 +++++++++++++++ .../card/nfc/PCSCVicinityTechnologyTest.kt | 51 ++++++++++++++ 4 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 card/src/jvmMain/kotlin/com/codebutler/farebot/card/nfc/PCSCVicinityTechnology.kt create mode 100644 card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/FakePCSCChannel.kt create mode 100644 card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/PCSCVicinityTechnologyTest.kt diff --git a/card/build.gradle.kts b/card/build.gradle.kts index 502ffeed9..59a0a8697 100644 --- a/card/build.gradle.kts +++ b/card/build.gradle.kts @@ -39,9 +39,14 @@ kotlin { } // javax.smartcardio is in the java.smartcardio JDK module (not auto-resolved). -// Add it to the Kotlin JVM compilation classpath. +// Add it to the Kotlin JVM compilation classpath (main and test). tasks.named("compileKotlinJvm") { compilerOptions { freeCompilerArgs.add("-Xadd-modules=java.smartcardio") } } +tasks.named("compileTestKotlinJvm") { + compilerOptions { + freeCompilerArgs.add("-Xadd-modules=java.smartcardio") + } +} diff --git a/card/src/jvmMain/kotlin/com/codebutler/farebot/card/nfc/PCSCVicinityTechnology.kt b/card/src/jvmMain/kotlin/com/codebutler/farebot/card/nfc/PCSCVicinityTechnology.kt new file mode 100644 index 000000000..87b605dcd --- /dev/null +++ b/card/src/jvmMain/kotlin/com/codebutler/farebot/card/nfc/PCSCVicinityTechnology.kt @@ -0,0 +1,68 @@ +/* + * PCSCVicinityTechnology.kt + * + * This file is part of FareBot. + * Learn more at: https://codebutler.github.io/farebot/ + * + * Copyright (C) 2025 Eric Butler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.codebutler.farebot.card.nfc + +import javax.smartcardio.CardChannel +import javax.smartcardio.CommandAPDU + +/** + * PC/SC implementation of [VicinityTechnology] for ISO 15693 (NFC-V) cards. + * + * Uses PC/SC transparent pseudo-APDU (FF 00 00 00) to send raw ISO 15693 + * command frames through the reader's contactless interface. The reader + * firmware handles ISO 15693 framing, CRC, and RF modulation. + * + * Requires a PC/SC reader with ISO 15693 firmware support (e.g., ACR1252U, + * HID Omnikey 5022/5427, Identiv uTrust 3700F, SpringCard H663). + * Readers without ISO 15693 support (e.g., ACR122U) will never present + * NFC-V cards, so this code path won't be reached on incompatible hardware. + */ +class PCSCVicinityTechnology( + private val channel: CardChannel, + override val uid: ByteArray, +) : VicinityTechnology { + private var connected = true + + override fun connect() { + connected = true + } + + override fun close() { + connected = false + } + + override val isConnected: Boolean get() = connected + + override suspend fun transceive(data: ByteArray): ByteArray { + // Send raw ISO 15693 command via PC/SC transparent pseudo-APDU: + // CLA=FF INS=00 P1=00 P2=00 Lc={len} Data={ISO 15693 command} + val command = CommandAPDU(0xFF, 0x00, 0x00, 0x00, data) + val response = channel.transmit(command) + if (response.sW1 != 0x90 || response.sW2 != 0x00) { + throw Exception( + "NFC-V transceive failed: SW=%02X%02X".format(response.sW1, response.sW2), + ) + } + return response.data + } +} diff --git a/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/FakePCSCChannel.kt b/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/FakePCSCChannel.kt new file mode 100644 index 000000000..01869ef69 --- /dev/null +++ b/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/FakePCSCChannel.kt @@ -0,0 +1,54 @@ +/* + * FakePCSCChannel.kt + * + * This file is part of FareBot. + * Learn more at: https://codebutler.github.io/farebot/ + * + * Copyright (C) 2025 Eric Butler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.codebutler.farebot.card.nfc + +import java.nio.ByteBuffer +import javax.smartcardio.CardChannel +import javax.smartcardio.CommandAPDU +import javax.smartcardio.ResponseAPDU + +/** + * Minimal fake [CardChannel] for unit testing PC/SC technology classes. + */ +class FakePCSCChannel : CardChannel() { + var lastCommand: CommandAPDU? = null + var nextResponse: ByteArray = byteArrayOf(0x90.toByte(), 0x00) + + override fun getCard() = throw UnsupportedOperationException() + + override fun getChannelNumber() = 0 + + override fun transmit(command: CommandAPDU): ResponseAPDU { + lastCommand = command + return ResponseAPDU(nextResponse) + } + + override fun transmit( + command: ByteBuffer, + response: ByteBuffer, + ): Int { + throw UnsupportedOperationException() + } + + override fun close() {} +} diff --git a/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/PCSCVicinityTechnologyTest.kt b/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/PCSCVicinityTechnologyTest.kt new file mode 100644 index 000000000..511602199 --- /dev/null +++ b/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/PCSCVicinityTechnologyTest.kt @@ -0,0 +1,51 @@ +/* + * PCSCVicinityTechnologyTest.kt + * + * This file is part of FareBot. + * Learn more at: https://codebutler.github.io/farebot/ + * + * Copyright (C) 2025 Eric Butler + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.codebutler.farebot.card.nfc + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PCSCVicinityTechnologyTest { + @Test + fun testUidReturnsProvidedUid() { + val expectedUid = byteArrayOf(0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08) + val tech = PCSCVicinityTechnology( + channel = FakePCSCChannel(), + uid = expectedUid, + ) + assertTrue(tech.uid.contentEquals(expectedUid)) + } + + @Test + fun testConnectAndClose() { + val tech = PCSCVicinityTechnology( + channel = FakePCSCChannel(), + uid = byteArrayOf(), + ) + tech.connect() + assertTrue(tech.isConnected) + tech.close() + assertEquals(false, tech.isConnected) + } +} From 031d8c5c93a03772c689341645712dbd63b35aac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Feb 2026 17:15:34 -0800 Subject: [PATCH 3/4] feat(desktop): wire NFC-V card reading into PC/SC backend Add CardType.Vicinity case to PcscReaderBackend.readCard() that creates a PCSCVicinityTechnology and delegates to VicinityCardReader. Co-Authored-By: Claude Opus 4.6 --- .../com/codebutler/farebot/desktop/PcscReaderBackend.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PcscReaderBackend.kt b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PcscReaderBackend.kt index 44fced94b..01ab52d36 100644 --- a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PcscReaderBackend.kt +++ b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PcscReaderBackend.kt @@ -32,7 +32,9 @@ import com.codebutler.farebot.card.nfc.PCSCCardInfo import com.codebutler.farebot.card.nfc.PCSCCardTransceiver import com.codebutler.farebot.card.nfc.PCSCClassicTechnology import com.codebutler.farebot.card.nfc.PCSCUltralightTechnology +import com.codebutler.farebot.card.nfc.PCSCVicinityTechnology import com.codebutler.farebot.card.ultralight.UltralightCardReader +import com.codebutler.farebot.card.vicinity.VicinityCardReader import com.codebutler.farebot.shared.nfc.ISO7816Dispatcher import com.codebutler.farebot.shared.nfc.ScannedTag import kotlinx.coroutines.runBlocking @@ -144,6 +146,11 @@ class PcscReaderBackend : NfcReaderBackend { CEPASCardReader.readCard(tagId, transceiver) } + CardType.Vicinity -> { + val tech = PCSCVicinityTechnology(channel, tagId) + VicinityCardReader.readCard(tagId, tech) + } + else -> { val transceiver = PCSCCardTransceiver(channel) ISO7816Dispatcher.readCard(tagId, transceiver) From 98f3913609e00698126a08ef800fb6f622e6a0af Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 16 Feb 2026 17:20:22 -0800 Subject: [PATCH 4/4] style: apply ktlintFormat to new test files Co-Authored-By: Claude Opus 4.6 --- .../farebot/card/nfc/FakePCSCChannel.kt | 4 +- .../farebot/card/nfc/PCSCCardInfoTest.kt | 96 +++++++++++++------ .../card/nfc/PCSCVicinityTechnologyTest.kt | 18 ++-- 3 files changed, 80 insertions(+), 38 deletions(-) diff --git a/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/FakePCSCChannel.kt b/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/FakePCSCChannel.kt index 01869ef69..41adb5690 100644 --- a/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/FakePCSCChannel.kt +++ b/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/FakePCSCChannel.kt @@ -46,9 +46,7 @@ class FakePCSCChannel : CardChannel() { override fun transmit( command: ByteBuffer, response: ByteBuffer, - ): Int { - throw UnsupportedOperationException() - } + ): Int = throw UnsupportedOperationException() override fun close() {} } diff --git a/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/PCSCCardInfoTest.kt b/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/PCSCCardInfoTest.kt index 2b001e110..24acb3abd 100644 --- a/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/PCSCCardInfoTest.kt +++ b/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/PCSCCardInfoTest.kt @@ -30,15 +30,29 @@ class PCSCCardInfoTest { @Test fun testFromATR_vicinityICODESLI() { // ATR with PC/SC RID (A0 00 00 03 06) followed by SS=0x07 (ICODE SLI) and NN=0x00 0x01 - val atr = byteArrayOf( - 0x3B.toByte(), 0x8F.toByte(), 0x80.toByte(), 0x01, - 0x80.toByte(), 0x4F, 0x0C, - 0xA0.toByte(), 0x00, 0x00, 0x03, 0x06, - 0x07, // SS = ICODE SLI (ISO 15693) - 0x00, 0x01, // NN = card name - 0x00, 0x00, 0x00, 0x00, - 0x00, // TCK - ) + val atr = + byteArrayOf( + 0x3B.toByte(), + 0x8F.toByte(), + 0x80.toByte(), + 0x01, + 0x80.toByte(), + 0x4F, + 0x0C, + 0xA0.toByte(), + 0x00, + 0x00, + 0x03, + 0x06, + 0x07, // SS = ICODE SLI (ISO 15693) + 0x00, + 0x01, // NN = card name + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, // TCK + ) val info = PCSCCardInfo.fromATR(atr) assertEquals(CardType.Vicinity, info.cardType) } @@ -46,15 +60,29 @@ class PCSCCardInfoTest { @Test fun testFromATR_vicinityTagIt() { // ATR with SS=0x0C (Tag-it HFI) - val atr = byteArrayOf( - 0x3B.toByte(), 0x8F.toByte(), 0x80.toByte(), 0x01, - 0x80.toByte(), 0x4F, 0x0C, - 0xA0.toByte(), 0x00, 0x00, 0x03, 0x06, - 0x0C, // SS = Tag-it HFI Plus (ISO 15693) - 0x00, 0x01, - 0x00, 0x00, 0x00, 0x00, - 0x00, - ) + val atr = + byteArrayOf( + 0x3B.toByte(), + 0x8F.toByte(), + 0x80.toByte(), + 0x01, + 0x80.toByte(), + 0x4F, + 0x0C, + 0xA0.toByte(), + 0x00, + 0x00, + 0x03, + 0x06, + 0x0C, // SS = Tag-it HFI Plus (ISO 15693) + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ) val info = PCSCCardInfo.fromATR(atr) assertEquals(CardType.Vicinity, info.cardType) } @@ -62,15 +90,29 @@ class PCSCCardInfoTest { @Test fun testFromATR_existingClassic1k() { // Verify existing behavior isn't broken: SS=0x01 → MifareClassic - val atr = byteArrayOf( - 0x3B.toByte(), 0x8F.toByte(), 0x80.toByte(), 0x01, - 0x80.toByte(), 0x4F, 0x0C, - 0xA0.toByte(), 0x00, 0x00, 0x03, 0x06, - 0x01, // SS = MIFARE Classic 1K - 0x00, 0x01, - 0x00, 0x00, 0x00, 0x00, - 0x00, - ) + val atr = + byteArrayOf( + 0x3B.toByte(), + 0x8F.toByte(), + 0x80.toByte(), + 0x01, + 0x80.toByte(), + 0x4F, + 0x0C, + 0xA0.toByte(), + 0x00, + 0x00, + 0x03, + 0x06, + 0x01, // SS = MIFARE Classic 1K + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + ) val info = PCSCCardInfo.fromATR(atr) assertEquals(CardType.MifareClassic, info.cardType) } diff --git a/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/PCSCVicinityTechnologyTest.kt b/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/PCSCVicinityTechnologyTest.kt index 511602199..d6063af5f 100644 --- a/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/PCSCVicinityTechnologyTest.kt +++ b/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/PCSCVicinityTechnologyTest.kt @@ -30,19 +30,21 @@ class PCSCVicinityTechnologyTest { @Test fun testUidReturnsProvidedUid() { val expectedUid = byteArrayOf(0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08) - val tech = PCSCVicinityTechnology( - channel = FakePCSCChannel(), - uid = expectedUid, - ) + val tech = + PCSCVicinityTechnology( + channel = FakePCSCChannel(), + uid = expectedUid, + ) assertTrue(tech.uid.contentEquals(expectedUid)) } @Test fun testConnectAndClose() { - val tech = PCSCVicinityTechnology( - channel = FakePCSCChannel(), - uid = byteArrayOf(), - ) + val tech = + PCSCVicinityTechnology( + channel = FakePCSCChannel(), + uid = byteArrayOf(), + ) tech.connect() assertTrue(tech.isConnected) tech.close()