Skip to content

Commit 502969b

Browse files
Claudeclaude
andcommitted
fix(classic): show locked card dialog instead of crashing, remove inline key recovery
Three fixes: 1. CardUnauthorizedException extended Throwable directly, but the catch clause in PN53xReaderBackend catches Exception. The exception escaped uncaught, killing the coroutine thread. The "reading" sheet stayed up forever and no error dialog appeared. Fix: extend Exception. 2. Remove inline key recovery from home screen scan. Key recovery should happen on the dedicated key recovery screen, not during the initial card read. This avoids the slow nested attack blocking the scan UI. 3. Fix reselectCard() in PN533RawClassic to not cycle RF field. Instead, wait for the card's auth timeout then InRelease + InListPassiveTarget. This keeps the card powered so the PRNG continues running — required for PRNG distance calibration in the nested attack. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bd17643 commit 502969b

File tree

4 files changed

+27
-15
lines changed

4 files changed

+27
-15
lines changed

app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PN53xReaderBackend.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,10 @@ abstract class PN53xReaderBackend(
179179
val tagIdHex = tagId.hex()
180180
val cardKeys = keyManagerPlugin?.getCardKeysForTag(tagIdHex)
181181
val globalKeys = keyManagerPlugin?.getGlobalKeys()
182-
val recovery = keyManagerPlugin?.classicKeyRecovery
182+
// Don't attempt key recovery during initial scan — that happens
183+
// on the dedicated key recovery screen after user interaction.
183184
val rawCard =
184-
ClassicCardReader.readCard(tagId, tech, cardKeys, globalKeys, recovery, onProgress)
185+
ClassicCardReader.readCard(tagId, tech, cardKeys, globalKeys, onProgress = onProgress)
185186
if (rawCard.hasUnauthorizedSectors()) {
186187
throw CardUnauthorizedException(rawCard.tagId(), rawCard.cardType())
187188
}

app/src/commonMain/kotlin/com/codebutler/farebot/shared/nfc/CardUnauthorizedException.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@ import com.codebutler.farebot.card.CardType
55
class CardUnauthorizedException(
66
val tagId: ByteArray,
77
val cardType: CardType,
8-
) : Throwable("Unauthorized")
8+
) : Exception("Unauthorized")

app/web/src/wasmJsMain/kotlin/com/codebutler/farebot/web/WebCardScanner.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,9 +229,10 @@ class WebCardScanner(
229229
val tagIdHex = tagId.hex()
230230
val cardKeys = keyManagerPlugin?.getCardKeysForTag(tagIdHex)
231231
val globalKeys = keyManagerPlugin?.getGlobalKeys()
232-
val recovery = keyManagerPlugin?.classicKeyRecovery
232+
// Don't attempt key recovery during initial scan — that happens
233+
// on the dedicated key recovery screen after user interaction.
233234
val rawCard =
234-
ClassicCardReader.readCard(tagId, tech, cardKeys, globalKeys, recovery, onProgress)
235+
ClassicCardReader.readCard(tagId, tech, cardKeys, globalKeys, onProgress = onProgress)
235236
if (rawCard.hasUnauthorizedSectors()) {
236237
throw CardUnauthorizedException(rawCard.tagId(), rawCard.cardType())
237238
}

keymanager/src/commonMain/kotlin/com/codebutler/farebot/keymanager/pn533/PN533RawClassic.kt

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -114,22 +114,32 @@ class PN533RawClassic(
114114
}
115115

116116
/**
117-
* Reset the card by cycling the RF field and re-selecting.
117+
* Re-select the card without cycling the RF field.
118118
*
119119
* After an incomplete MIFARE Classic authentication (e.g., requestAuth()
120-
* collects the nonce but doesn't complete the handshake), the card enters
121-
* HALT state and won't respond to subsequent commands. Cycling the RF field
122-
* resets the card, and InListPassiveTarget re-selects it.
120+
* collects the nonce but doesn't complete the handshake), the card
121+
* returns to IDLE state after its Frame Waiting Time expires (~5ms).
122+
* We wait for that timeout, release the PN533's internal target tracking,
123+
* then re-select with InListPassiveTarget (which sends REQA).
124+
*
125+
* Crucially, this keeps the RF field powered — the card's PRNG continues
126+
* running from its original seed, which is required for PRNG distance
127+
* calibration in the nested attack.
123128
*
124129
* @return true if the card was successfully re-selected
125130
*/
126131
suspend fun reselectCard(): Boolean {
127132
restoreNormalMode()
133+
// Wait for card's auth timeout (FWT ~5ms) so it returns to IDLE state
134+
delay(CARD_AUTH_TIMEOUT_MS)
128135
return try {
129-
pn533.rfFieldOff()
130-
delay(RF_RESET_DELAY_MS)
131-
pn533.rfFieldOn()
132-
delay(RF_RESET_DELAY_MS)
136+
// Release PN533's internal target tracking
137+
try {
138+
pn533.inRelease(0)
139+
} catch (_: PN533Exception) {
140+
// May fail if no target was listed — that's fine
141+
}
142+
// Re-select card (REQA → anti-collision → SELECT)
133143
pn533.inListPassiveTarget(baudRate = PN533.BAUD_RATE_106_ISO14443A) != null
134144
} catch (_: PN533Exception) {
135145
false
@@ -306,8 +316,8 @@ class PN533RawClassic(
306316
/** CIU Status2 register — Bit 3 = Crypto1 active */
307317
const val REG_CIU_STATUS2 = 0x6338
308318

309-
/** Delay in ms for RF field cycling during card reset */
310-
private const val RF_RESET_DELAY_MS = 50L
319+
/** Wait time in ms for card's auth timeout (FWT) before re-selecting */
320+
private const val CARD_AUTH_TIMEOUT_MS = 10L
311321

312322
/**
313323
* Build a MIFARE Classic AUTH command with CRC.

0 commit comments

Comments
 (0)