Skip to content

Commit 549da32

Browse files
codebutlerclaude
andauthored
Store authentication keys in RawClassicSector (#211)
* Port CEPASProtocol from Metrodroid to fix APDU framing Fixes #191 The old FareBot CEPASProtocol manually built APDU commands but omitted the Le (expected response length) byte, causing cards to reject with 6D00 (instruction not supported). Replaced with Metrodroid's approach that delegates to ISO7816Protocol.sendRequest() for proper APDU framing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Store authentication keys in RawClassicSector Add keyA and keyB fields to RawClassicSector to match Metrodroid's ClassicSectorRaw implementation. Track which keys successfully authenticated each sector during card reading and store them in the raw sector data. Changes: - Add keyA/keyB nullable ByteArray fields to RawClassicSector with @contextual serialization - Update factory methods (createData, createInvalid, createUnauthorized) to accept optional key parameters - Refactor ClassicCardReader to track successful authentication keys and pass them to RawClassicSector Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Apply ktlintFormat Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add regression tests for Classic auth key storage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 95a1b47 commit 549da32

File tree

3 files changed

+206
-6
lines changed

3 files changed

+206
-6
lines changed

card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/ClassicCardReader.kt

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,22 @@ object ClassicCardReader {
5353
for (sectorIndex in 0 until tech.sectorCount) {
5454
try {
5555
var authSuccess = false
56+
var successfulKeyA: ByteArray? = null
57+
var successfulKeyB: ByteArray? = null
5658

5759
// Try the default keys first
5860
if (!authSuccess && sectorIndex == 0) {
5961
authSuccess = tech.authenticateSectorWithKeyA(sectorIndex, PREAMBLE_KEY)
62+
if (authSuccess) {
63+
successfulKeyA = PREAMBLE_KEY
64+
}
6065
}
6166

6267
if (!authSuccess) {
6368
authSuccess = tech.authenticateSectorWithKeyA(sectorIndex, ClassicTechnology.KEY_DEFAULT)
69+
if (authSuccess) {
70+
successfulKeyA = ClassicTechnology.KEY_DEFAULT
71+
}
6472
}
6573

6674
if (cardKeys != null) {
@@ -69,8 +77,13 @@ object ClassicCardReader {
6977
val sectorKey: ClassicSectorKey? = cardKeys.keyForSector(sectorIndex)
7078
if (sectorKey != null) {
7179
authSuccess = tech.authenticateSectorWithKeyA(sectorIndex, sectorKey.keyA)
72-
if (!authSuccess) {
80+
if (authSuccess) {
81+
successfulKeyA = sectorKey.keyA
82+
} else {
7383
authSuccess = tech.authenticateSectorWithKeyB(sectorIndex, sectorKey.keyB)
84+
if (authSuccess) {
85+
successfulKeyB = sectorKey.keyB
86+
}
7487
}
7588
}
7689
}
@@ -94,12 +107,17 @@ object ClassicCardReader {
94107
keys[keyIndex].keyA,
95108
)
96109

97-
if (!authSuccess) {
110+
if (authSuccess) {
111+
successfulKeyA = keys[keyIndex].keyA
112+
} else {
98113
authSuccess =
99114
tech.authenticateSectorWithKeyB(
100115
sectorIndex,
101116
keys[keyIndex].keyB,
102117
)
118+
if (authSuccess) {
119+
successfulKeyB = keys[keyIndex].keyB
120+
}
103121
}
104122

105123
if (authSuccess) {
@@ -118,7 +136,14 @@ object ClassicCardReader {
118136
val data = tech.readBlock(firstBlockIndex + blockIndex)
119137
blocks.add(RawClassicBlock.create(blockIndex, data))
120138
}
121-
sectors.add(RawClassicSector.createData(sectorIndex, blocks))
139+
sectors.add(
140+
RawClassicSector.createData(
141+
sectorIndex,
142+
blocks,
143+
successfulKeyA,
144+
successfulKeyB,
145+
),
146+
)
122147
} else {
123148
sectors.add(RawClassicSector.createUnauthorized(sectorIndex))
124149
}

card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/raw/RawClassicSector.kt

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,16 @@ import com.codebutler.farebot.card.classic.ClassicSector
2727
import com.codebutler.farebot.card.classic.DataClassicSector
2828
import com.codebutler.farebot.card.classic.InvalidClassicSector
2929
import com.codebutler.farebot.card.classic.UnauthorizedClassicSector
30+
import kotlinx.serialization.Contextual
3031
import kotlinx.serialization.Serializable
3132

3233
@Serializable
3334
data class RawClassicSector(
3435
val type: String,
3536
val index: Int,
3637
val blocks: List<RawClassicBlock>? = null,
38+
@Contextual val keyA: ByteArray? = null,
39+
@Contextual val keyB: ByteArray? = null,
3740
val errorMessage: String? = null,
3841
) {
3942
fun parse(): ClassicSector =
@@ -55,13 +58,21 @@ data class RawClassicSector(
5558
fun createData(
5659
index: Int,
5760
blocks: List<RawClassicBlock>,
58-
): RawClassicSector = RawClassicSector(TYPE_DATA, index, blocks, null)
61+
keyA: ByteArray? = null,
62+
keyB: ByteArray? = null,
63+
): RawClassicSector = RawClassicSector(TYPE_DATA, index, blocks, keyA, keyB, null)
5964

6065
fun createInvalid(
6166
index: Int,
6267
errorMessage: String,
63-
): RawClassicSector = RawClassicSector(TYPE_INVALID, index, null, errorMessage)
68+
keyA: ByteArray? = null,
69+
keyB: ByteArray? = null,
70+
): RawClassicSector = RawClassicSector(TYPE_INVALID, index, null, keyA, keyB, errorMessage)
6471

65-
fun createUnauthorized(index: Int): RawClassicSector = RawClassicSector(TYPE_UNAUTHORIZED, index, null, null)
72+
fun createUnauthorized(
73+
index: Int,
74+
keyA: ByteArray? = null,
75+
keyB: ByteArray? = null,
76+
): RawClassicSector = RawClassicSector(TYPE_UNAUTHORIZED, index, null, keyA, keyB, null)
6677
}
6778
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/*
2+
* RawClassicSectorTest.kt
3+
*
4+
* Copyright 2025 Eric Butler <eric@codebutler.com>
5+
*
6+
* This program is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU General Public License as published by
8+
* the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU General Public License
17+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
18+
*/
19+
20+
package com.codebutler.farebot.card.classic.raw
21+
22+
import com.codebutler.farebot.card.classic.DataClassicSector
23+
import com.codebutler.farebot.card.classic.InvalidClassicSector
24+
import com.codebutler.farebot.card.classic.UnauthorizedClassicSector
25+
import kotlin.test.Test
26+
import kotlin.test.assertContentEquals
27+
import kotlin.test.assertEquals
28+
import kotlin.test.assertNotNull
29+
import kotlin.test.assertNull
30+
import kotlin.test.assertTrue
31+
32+
@OptIn(ExperimentalStdlibApi::class)
33+
class RawClassicSectorTest {
34+
private val testKeyA = "A0A1A2A3A4A5".hexToByteArray()
35+
private val testKeyB = "B0B1B2B3B4B5".hexToByteArray()
36+
37+
private fun createTestBlocks(): List<RawClassicBlock> =
38+
listOf(
39+
RawClassicBlock.create(0, ByteArray(16)),
40+
RawClassicBlock.create(1, ByteArray(16)),
41+
RawClassicBlock.create(2, ByteArray(16)),
42+
RawClassicBlock.create(3, ByteArray(16)),
43+
)
44+
45+
@Test
46+
fun testCreateDataWithKeys() {
47+
val sector = RawClassicSector.createData(0, createTestBlocks(), testKeyA, testKeyB)
48+
49+
assertEquals(RawClassicSector.TYPE_DATA, sector.type)
50+
assertEquals(0, sector.index)
51+
val blocks = sector.blocks
52+
assertNotNull(blocks)
53+
assertEquals(4, blocks.size)
54+
assertContentEquals(testKeyA, sector.keyA)
55+
assertContentEquals(testKeyB, sector.keyB)
56+
assertNull(sector.errorMessage)
57+
}
58+
59+
@Test
60+
fun testCreateDataWithOnlyKeyA() {
61+
val sector = RawClassicSector.createData(1, createTestBlocks(), keyA = testKeyA)
62+
63+
assertContentEquals(testKeyA, sector.keyA)
64+
assertNull(sector.keyB)
65+
}
66+
67+
@Test
68+
fun testCreateDataWithOnlyKeyB() {
69+
val sector = RawClassicSector.createData(2, createTestBlocks(), keyB = testKeyB)
70+
71+
assertNull(sector.keyA)
72+
assertContentEquals(testKeyB, sector.keyB)
73+
}
74+
75+
@Test
76+
fun testCreateDataWithoutKeys() {
77+
val sector = RawClassicSector.createData(0, createTestBlocks())
78+
79+
assertEquals(RawClassicSector.TYPE_DATA, sector.type)
80+
assertNull(sector.keyA)
81+
assertNull(sector.keyB)
82+
}
83+
84+
@Test
85+
fun testCreateUnauthorizedWithKeys() {
86+
val sector = RawClassicSector.createUnauthorized(5, testKeyA, testKeyB)
87+
88+
assertEquals(RawClassicSector.TYPE_UNAUTHORIZED, sector.type)
89+
assertEquals(5, sector.index)
90+
assertNull(sector.blocks)
91+
assertContentEquals(testKeyA, sector.keyA)
92+
assertContentEquals(testKeyB, sector.keyB)
93+
assertNull(sector.errorMessage)
94+
}
95+
96+
@Test
97+
fun testCreateUnauthorizedWithoutKeys() {
98+
val sector = RawClassicSector.createUnauthorized(3)
99+
100+
assertEquals(RawClassicSector.TYPE_UNAUTHORIZED, sector.type)
101+
assertNull(sector.keyA)
102+
assertNull(sector.keyB)
103+
}
104+
105+
@Test
106+
fun testCreateInvalidWithKeys() {
107+
val sector = RawClassicSector.createInvalid(7, "Read error", testKeyA, testKeyB)
108+
109+
assertEquals(RawClassicSector.TYPE_INVALID, sector.type)
110+
assertEquals(7, sector.index)
111+
assertNull(sector.blocks)
112+
assertContentEquals(testKeyA, sector.keyA)
113+
assertContentEquals(testKeyB, sector.keyB)
114+
assertEquals("Read error", sector.errorMessage)
115+
}
116+
117+
@Test
118+
fun testCreateInvalidWithoutKeys() {
119+
val sector = RawClassicSector.createInvalid(4, "Timeout")
120+
121+
assertEquals(RawClassicSector.TYPE_INVALID, sector.type)
122+
assertNull(sector.keyA)
123+
assertNull(sector.keyB)
124+
assertEquals("Timeout", sector.errorMessage)
125+
}
126+
127+
@Test
128+
fun testParseDataSectorWithKeys() {
129+
val sector = RawClassicSector.createData(0, createTestBlocks(), testKeyA, testKeyB)
130+
val parsed = sector.parse()
131+
132+
assertTrue(parsed is DataClassicSector)
133+
assertEquals(0, parsed.index)
134+
assertEquals(4, parsed.blocks.size)
135+
}
136+
137+
@Test
138+
fun testParseUnauthorizedSectorWithKeys() {
139+
val sector = RawClassicSector.createUnauthorized(5, testKeyA, testKeyB)
140+
val parsed = sector.parse()
141+
142+
assertTrue(parsed is UnauthorizedClassicSector)
143+
assertEquals(5, parsed.index)
144+
}
145+
146+
@Test
147+
fun testParseInvalidSectorWithKeys() {
148+
val sector = RawClassicSector.createInvalid(7, "Read error", testKeyA, testKeyB)
149+
val parsed = sector.parse()
150+
151+
assertTrue(parsed is InvalidClassicSector)
152+
assertEquals(7, parsed.index)
153+
}
154+
155+
@Test
156+
fun testKeyDefaultKey() {
157+
// Verify that the default MIFARE key (FF FF FF FF FF FF) can be stored
158+
val defaultKey = "FFFFFFFFFFFF".hexToByteArray()
159+
val sector = RawClassicSector.createData(0, createTestBlocks(), keyA = defaultKey)
160+
161+
assertContentEquals(defaultKey, sector.keyA)
162+
assertNull(sector.keyB)
163+
}
164+
}

0 commit comments

Comments
 (0)