Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion card/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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<org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile>("compileKotlinJvm") {
compilerOptions {
freeCompilerArgs.add("-Xadd-modules=java.smartcardio")
}
}
tasks.named<org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile>("compileTestKotlinJvm") {
compilerOptions {
freeCompilerArgs.add("-Xadd-modules=java.smartcardio")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <eric@codebutler.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/

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
}
}
Original file line number Diff line number Diff line change
@@ -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 <eric@codebutler.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/

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() {}
}
Original file line number Diff line number Diff line change
@@ -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 <eric@codebutler.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/

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)
}
}
Original file line number Diff line number Diff line change
@@ -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 <eric@codebutler.com>
*
* 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 <http://www.gnu.org/licenses/>.
*/

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