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) 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/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/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..41adb5690 --- /dev/null +++ b/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/FakePCSCChannel.kt @@ -0,0 +1,52 @@ +/* + * 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/PCSCCardInfoTest.kt b/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/PCSCCardInfoTest.kt new file mode 100644 index 000000000..24acb3abd --- /dev/null +++ b/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/PCSCCardInfoTest.kt @@ -0,0 +1,119 @@ +/* + * 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) + } +} 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..d6063af5f --- /dev/null +++ b/card/src/jvmTest/kotlin/com/codebutler/farebot/card/nfc/PCSCVicinityTechnologyTest.kt @@ -0,0 +1,53 @@ +/* + * 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) + } +}