diff --git a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PN53xReaderBackend.kt b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PN53xReaderBackend.kt index 9b273215c..b8bfc3e52 100644 --- a/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PN53xReaderBackend.kt +++ b/app/desktop/src/jvmMain/kotlin/com/codebutler/farebot/desktop/PN53xReaderBackend.kt @@ -163,7 +163,9 @@ abstract class PN53xReaderBackend( CardType.MifareClassic -> { val tech = PN533ClassicTechnology(pn533, target.tg, tagId, info) - ClassicCardReader.readCard(tagId, tech, null) + ClassicCardReader.readCard(tagId, tech, null) { progress -> + println("[$name] $progress") + } } CardType.MifareUltralight -> { diff --git a/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/ClassicCardReader.kt b/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/ClassicCardReader.kt index 9c88513b4..b312c4070 100644 --- a/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/ClassicCardReader.kt +++ b/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/ClassicCardReader.kt @@ -24,12 +24,15 @@ package com.codebutler.farebot.card.classic import com.codebutler.farebot.card.CardLostException +import com.codebutler.farebot.card.classic.crypto1.NestedAttack import com.codebutler.farebot.card.classic.key.ClassicCardKeys import com.codebutler.farebot.card.classic.key.ClassicSectorKey +import com.codebutler.farebot.card.classic.pn533.PN533RawClassic import com.codebutler.farebot.card.classic.raw.RawClassicBlock import com.codebutler.farebot.card.classic.raw.RawClassicCard import com.codebutler.farebot.card.classic.raw.RawClassicSector import com.codebutler.farebot.card.nfc.ClassicTechnology +import com.codebutler.farebot.card.nfc.pn533.PN533ClassicTechnology import kotlin.time.Clock object ClassicCardReader { @@ -49,8 +52,10 @@ object ClassicCardReader { tech: ClassicTechnology, cardKeys: ClassicCardKeys?, globalKeys: List? = null, + onProgress: ((String) -> Unit)? = null, ): RawClassicCard { val sectors = ArrayList() + val recoveredKeys = mutableMapOf>() for (sectorIndex in 0 until tech.sectorCount) { try { @@ -155,7 +160,55 @@ object ClassicCardReader { } } + // Try key recovery via nested attack (PN533 only) + if (!authSuccess && tech is PN533ClassicTechnology) { + val knownEntry = recoveredKeys.entries.firstOrNull() + if (knownEntry != null) { + val (knownSector, knownKeyInfo) = knownEntry + val (knownKeyBytes, knownIsKeyA) = knownKeyInfo + val knownKey = keyBytesToLong(knownKeyBytes) + val knownKeyType: Byte = if (knownIsKeyA) 0x60 else 0x61 + val knownBlock = tech.sectorToBlock(knownSector) + val targetBlock = tech.sectorToBlock(sectorIndex) + + val rawClassic = PN533RawClassic(tech.rawPn533, tech.rawUid) + val attack = NestedAttack(rawClassic, tech.uidAsUInt) + + onProgress?.invoke("Sector $sectorIndex: attempting key recovery...") + + val recoveredKey = attack.recoverKey( + knownKeyType = knownKeyType, + knownSectorBlock = knownBlock, + knownKey = knownKey, + targetKeyType = 0x60, + targetBlock = targetBlock, + onProgress = onProgress, + ) + + if (recoveredKey != null) { + val keyBytes = longToKeyBytes(recoveredKey) + authSuccess = tech.authenticateSectorWithKeyA(sectorIndex, keyBytes) + if (authSuccess) { + successfulKey = keyBytes + isKeyA = true + } else { + // Try as Key B + authSuccess = tech.authenticateSectorWithKeyB(sectorIndex, keyBytes) + if (authSuccess) { + successfulKey = keyBytes + isKeyA = false + } + } + if (authSuccess) { + onProgress?.invoke("Sector $sectorIndex: key recovered!") + } + } + } + } + if (authSuccess && successfulKey != null) { + recoveredKeys[sectorIndex] = Pair(successfulKey, isKeyA) + val blocks = ArrayList() // FIXME: First read trailer block to get type of other blocks. val firstBlockIndex = tech.sectorToBlock(sectorIndex) @@ -197,4 +250,15 @@ object ClassicCardReader { return RawClassicCard.create(tagId, Clock.System.now(), sectors) } + + private fun keyBytesToLong(key: ByteArray): Long { + var result = 0L + for (i in 0 until minOf(6, key.size)) { + result = (result shl 8) or (key[i].toLong() and 0xFF) + } + return result + } + + private fun longToKeyBytes(key: Long): ByteArray = + ByteArray(6) { i -> ((key ushr ((5 - i) * 8)) and 0xFF).toByte() } } diff --git a/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/crypto1/Crypto1.kt b/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/crypto1/Crypto1.kt new file mode 100644 index 000000000..5c0d6dcfc --- /dev/null +++ b/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/crypto1/Crypto1.kt @@ -0,0 +1,293 @@ +/* + * Crypto1.kt + * + * Copyright 2026 Eric Butler + * + * Faithful port of crapto1 by bla + * Original: crypto1.c, crapto1.c, crapto1.h from mfcuk/mfoc + * + * 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.classic.crypto1 + +/** + * Crypto1 48-bit LFSR stream cipher used in MIFARE Classic cards. + * + * Static utility functions for the cipher: filter function, PRNG, + * parity computation, and endian swapping. + * + * Ported from crapto1 by bla . + */ +object Crypto1 { + /** LFSR feedback polynomial taps — odd half */ + const val LF_POLY_ODD: UInt = 0x29CE5Cu + + /** LFSR feedback polynomial taps — even half */ + const val LF_POLY_EVEN: UInt = 0x870804u + + /** + * Nonlinear 20-bit to 1-bit filter function. + * + * Two-layer Boolean function using lookup tables. + * Layer 1: 5 lookup tables, each mapping a 4-bit nibble to a single bit. + * Layer 2: 5-bit result from layer 1 selects one bit from fc constant. + * + * Faithfully ported from crapto1.h filter(). + */ + fun filter(x: UInt): Int { + var f: UInt + f = (0xf22c0u shr (x.toInt() and 0xf)) and 16u + f = f or ((0x6c9c0u shr ((x shr 4).toInt() and 0xf)) and 8u) + f = f or ((0x3c8b0u shr ((x shr 8).toInt() and 0xf)) and 4u) + f = f or ((0x1e458u shr ((x shr 12).toInt() and 0xf)) and 2u) + f = f or ((0x0d938u shr ((x shr 16).toInt() and 0xf)) and 1u) + return ((0xEC57E80Au shr f.toInt()) and 1u).toInt() + } + + /** + * MIFARE Classic 16-bit PRNG successor function. + * + * Polynomial: x^16 + x^14 + x^13 + x^11 + 1 + * Operates on a 32-bit big-endian packed state. + * Taps: x>>16 xor x>>18 xor x>>19 xor x>>21 + * + * Faithfully ported from crypto1.c prng_successor(). + */ + fun prngSuccessor(x: UInt, n: UInt): UInt { + var state = swapEndian(x) + var count = n + while (count-- > 0u) { + state = state shr 1 or + ((state shr 16 xor (state shr 18) xor (state shr 19) xor (state shr 21)) shl 31) + } + return swapEndian(state) + } + + /** + * XOR parity of all bits in a 32-bit value. + * + * Uses the nibble-lookup trick: fold to 4 bits, then lookup in 0x6996. + * + * Faithfully ported from crapto1.h parity(). + */ + fun parity(x: UInt): UInt { + var v = x + v = v xor (v shr 16) + v = v xor (v shr 8) + v = v xor (v shr 4) + return (0x6996u shr (v.toInt() and 0xf)) and 1u + } + + /** + * Byte-swap a 32-bit value (reverse byte order). + * + * Faithfully ported from crypto1.c SWAPENDIAN macro. + */ + fun swapEndian(x: UInt): UInt { + // First swap bytes within 16-bit halves, then swap the halves + var v = (x shr 8 and 0x00ff00ffu) or ((x and 0x00ff00ffu) shl 8) + v = (v shr 16) or (v shl 16) + return v + } + + /** + * Extract bit n from value x. + * + * Equivalent to crapto1.h BIT(x, n). + */ + internal fun bit(x: UInt, n: Int): UInt = (x shr n) and 1u + + /** + * Extract bit n from value x with big-endian byte adjustment. + * + * Equivalent to crapto1.h BEBIT(x, n) = BIT(x, n ^ 24). + */ + internal fun bebit(x: UInt, n: Int): UInt = bit(x, n xor 24) + + /** + * Extract bit n from a Long (64-bit) value. + */ + internal fun bit64(x: Long, n: Int): UInt = ((x shr n) and 1L).toUInt() +} + +/** + * Mutable Crypto1 cipher state. + * + * Contains the 48-bit LFSR split into two 24-bit halves: + * [odd] holds bits at odd positions and [even] holds bits at even positions. + * + * Ported from crapto1 struct Crypto1State. + */ +class Crypto1State( + var odd: UInt = 0u, + var even: UInt = 0u, +) { + /** + * Load a 48-bit key into the LFSR. + * + * Key bit at position i goes to odd[i/2] if i is odd, even[i/2] if i is even. + * Key bits are indexed 47 downTo 0. + * + * Faithfully ported from crypto1.c crypto1_create(). + * Note: The C code uses BIT(key, (i-1)^7) for odd and BIT(key, i^7) for even, + * where ^7 reverses the bit order within each byte. + */ + fun loadKey(key: Long) { + odd = 0u + even = 0u + var i = 47 + while (i > 0) { + odd = odd shl 1 or Crypto1.bit64(key, (i - 1) xor 7) + even = even shl 1 or Crypto1.bit64(key, i xor 7) + i -= 2 + } + } + + /** + * Clock LFSR once, returning one keystream bit. + * + * Returns the filter output (keystream bit) BEFORE clocking. + * Feedback = input (optionally XORed with output if [isEncrypted]) + * XOR parity(odd AND LF_POLY_ODD) XOR parity(even AND LF_POLY_EVEN). + * Shift: even becomes the new odd, feedback bit enters even MSB. + * + * Faithfully ported from crypto1.c crypto1_bit(). + */ + fun lfsrBit(input: Int, isEncrypted: Boolean): Int { + val ret = Crypto1.filter(odd) + + var feedin: UInt = (ret.toUInt() and (if (isEncrypted) 1u else 0u)) + feedin = feedin xor (if (input != 0) 1u else 0u) + feedin = feedin xor (Crypto1.LF_POLY_ODD and odd) + feedin = feedin xor (Crypto1.LF_POLY_EVEN and even) + even = even shl 1 or Crypto1.parity(feedin) + + // Swap odd and even: s->odd ^= (s->odd ^= s->even, s->even ^= s->odd) + // This is a three-way XOR swap + odd = odd xor even + even = even xor odd + odd = odd xor even + + return ret + } + + /** + * Clock LFSR 8 times, processing one byte. + * + * Packs keystream bits LSB first. + * + * Faithfully ported from crypto1.c crypto1_byte(). + */ + fun lfsrByte(input: Int, isEncrypted: Boolean): Int { + var ret = 0 + for (i in 0 until 8) { + ret = ret or (lfsrBit((input shr i) and 1, isEncrypted) shl i) + } + return ret + } + + /** + * Clock LFSR 32 times, processing one word. + * + * Uses BEBIT (big-endian bit) addressing for input/output. + * Packs keystream bits LSB first within each byte, big-endian byte order. + * + * Faithfully ported from crypto1.c crypto1_word(). + */ + fun lfsrWord(input: UInt, isEncrypted: Boolean): UInt { + var ret = 0u + for (i in 0 until 32) { + ret = ret or (lfsrBit( + Crypto1.bebit(input, i).toInt(), + isEncrypted, + ).toUInt() shl (i xor 24)) + } + return ret + } + + /** + * Reverse one LFSR step, undoing the shift to recover the previous state. + * + * Returns the filter output at the recovered state. + * + * Faithfully ported from crapto1.c lfsr_rollback_bit(). + */ + fun lfsrRollbackBit(input: Int, isEncrypted: Boolean): Int { + // Mask odd to 24 bits + odd = odd and 0xFFFFFFu + + // Swap odd and even (reverse the swap done in lfsrBit) + odd = odd xor even + even = even xor odd + odd = odd xor even + + // Extract LSB of even + val out: UInt = even and 1u + // Shift even right by 1 + even = even shr 1 + + // Compute feedback (what was at MSB of even before) + var feedback = out + feedback = feedback xor (Crypto1.LF_POLY_EVEN and even) + feedback = feedback xor (Crypto1.LF_POLY_ODD and odd) + feedback = feedback xor (if (input != 0) 1u else 0u) + + val ret = Crypto1.filter(odd) + feedback = feedback xor (ret.toUInt() and (if (isEncrypted) 1u else 0u)) + + even = even or (Crypto1.parity(feedback) shl 23) + + return ret + } + + /** + * Reverse 32 LFSR steps. + * + * Processes bits 31 downTo 0, using BEBIT addressing. + * + * Faithfully ported from crapto1.c lfsr_rollback_word(). + */ + fun lfsrRollbackWord(input: UInt, isEncrypted: Boolean): UInt { + var ret = 0u + for (i in 31 downTo 0) { + ret = ret or (lfsrRollbackBit( + Crypto1.bebit(input, i).toInt(), + isEncrypted, + ).toUInt() shl (i xor 24)) + } + return ret + } + + /** + * Extract the 48-bit key from the current LFSR state. + * + * Interleaves odd and even halves back into a 48-bit key value. + * + * Faithfully ported from crypto1.c crypto1_get_lfsr(). + */ + fun getKey(): Long { + var lfsr = 0L + for (i in 23 downTo 0) { + lfsr = lfsr shl 1 or Crypto1.bit(odd, i xor 3).toLong() + lfsr = lfsr shl 1 or Crypto1.bit(even, i xor 3).toLong() + } + return lfsr + } + + /** + * Deep copy of this cipher state. + */ + fun copy(): Crypto1State = Crypto1State(odd, even) +} diff --git a/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/crypto1/Crypto1Auth.kt b/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/crypto1/Crypto1Auth.kt new file mode 100644 index 000000000..ebca31bad --- /dev/null +++ b/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/crypto1/Crypto1Auth.kt @@ -0,0 +1,141 @@ +/* + * Crypto1Auth.kt + * + * Copyright 2026 Eric Butler + * + * MIFARE Classic authentication protocol helpers using the Crypto1 cipher. + * + * Implements the three-pass mutual authentication handshake: + * 1. Reader sends AUTH command, card responds with nonce nT + * 2. Reader sends encrypted {nR}{aR} where aR = suc^64(nT) + * 3. Card responds with encrypted aT where aT = suc^96(nT) + * + * 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.classic.crypto1 + +/** + * MIFARE Classic authentication protocol operations. + * + * Provides functions for the three-pass mutual authentication handshake, + * data encryption/decryption, and ISO 14443-3A CRC computation. + */ +object Crypto1Auth { + /** + * Initialize cipher for an authentication session. + * + * Loads the 48-bit key into the LFSR, then feeds uid XOR nT + * through the cipher to establish the initial authenticated state. + * + * @param key 48-bit MIFARE key (6 bytes packed into a Long) + * @param uid Card UID (4 bytes) + * @param nT Card nonce (tag nonce) + * @return Initialized cipher state ready for authentication + */ + fun initCipher(key: Long, uid: UInt, nT: UInt): Crypto1State { + val state = Crypto1State() + state.loadKey(key) + state.lfsrWord(uid xor nT, false) + return state + } + + /** + * Compute the encrypted reader response {nR}{aR}. + * + * The reader challenge nR is encrypted with the keystream. + * The reader answer aR = suc^64(nT) is also encrypted with the keystream. + * + * @param state Initialized cipher state (from [initCipher]) + * @param nR Reader nonce (random challenge from the reader) + * @param nT Card nonce (tag nonce, received from card) + * @return Pair of (encrypted nR, encrypted aR) + */ + fun computeReaderResponse(state: Crypto1State, nR: UInt, nT: UInt): Pair { + val aR = Crypto1.prngSuccessor(nT, 64u) + val nREnc = nR xor state.lfsrWord(nR, false) + val aREnc = aR xor state.lfsrWord(0u, false) + return Pair(nREnc, aREnc) + } + + /** + * Verify the card's encrypted response. + * + * The card should respond with encrypted aT where aT = suc^96(nT). + * This function decrypts the card's response and compares it to the expected value. + * + * @param state Cipher state (after [computeReaderResponse]) + * @param aTEnc Encrypted card answer received from the card + * @param nT Card nonce (tag nonce) + * @return true if the card's response is valid + */ + fun verifyCardResponse(state: Crypto1State, aTEnc: UInt, nT: UInt): Boolean { + val expectedAT = Crypto1.prngSuccessor(nT, 96u) + val aT = aTEnc xor state.lfsrWord(0u, false) + return aT == expectedAT + } + + /** + * Encrypt data using the cipher state. + * + * Each byte of the input is XORed with a keystream byte produced by the cipher. + * + * @param state Cipher state (mutated by this operation) + * @param data Plaintext data to encrypt + * @return Encrypted data + */ + fun encryptBytes(state: Crypto1State, data: ByteArray): ByteArray { + return ByteArray(data.size) { i -> + (data[i].toInt() xor state.lfsrByte(0, false)).toByte() + } + } + + /** + * Decrypt data using the cipher state. + * + * Symmetric with [encryptBytes] since XOR is its own inverse. + * + * @param state Cipher state (mutated by this operation) + * @param data Encrypted data to decrypt + * @return Decrypted data + */ + fun decryptBytes(state: Crypto1State, data: ByteArray): ByteArray { + return ByteArray(data.size) { i -> + (data[i].toInt() xor state.lfsrByte(0, false)).toByte() + } + } + + /** + * Compute ISO 14443-3A CRC (CRC-A). + * + * Polynomial: x^16 + x^12 + x^5 + 1 + * Initial value: 0x6363 + * + * @param data Input data bytes + * @return 2-byte CRC in little-endian order (LSB first) + */ + fun crcA(data: ByteArray): ByteArray { + var crc = 0x6363 + for (byte in data) { + var b = (byte.toInt() and 0xFF) xor (crc and 0xFF) + b = (b xor ((b shl 4) and 0xFF)) and 0xFF + crc = (crc shr 8) xor (b shl 8) xor (b shl 3) xor (b shr 4) + crc = crc and 0xFFFF + } + return byteArrayOf( + (crc and 0xFF).toByte(), + ((crc shr 8) and 0xFF).toByte(), + ) + } +} diff --git a/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/crypto1/Crypto1Recovery.kt b/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/crypto1/Crypto1Recovery.kt new file mode 100644 index 000000000..b6658eb9e --- /dev/null +++ b/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/crypto1/Crypto1Recovery.kt @@ -0,0 +1,373 @@ +/* + * Crypto1Recovery.kt + * + * Copyright 2026 Eric Butler + * + * MIFARE Classic Crypto1 key recovery algorithms. + * Faithful port of crapto1 by bla . + * + * 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.classic.crypto1 + +/** + * MIFARE Classic Crypto1 key recovery algorithms. + * + * Implements LFSR state recovery from known keystream, based on the + * approach from crapto1 by bla. Given 32 bits of known keystream + * (extracted during authentication), this recovers candidate + * 48-bit LFSR states that could have produced that keystream. + * + * The recovered states can then be rolled back through the authentication + * initialization to extract the 48-bit sector key. + * + * Reference: crapto1 lfsr_recovery32() from Proxmark3 / mfoc / mfcuk + */ +@OptIn(ExperimentalUnsignedTypes::class) +object Crypto1Recovery { + + /** + * Recover candidate LFSR states from 32 bits of known keystream. + * + * Port of crapto1's lfsr_recovery32(). The algorithm: + * 1. Split keystream into odd-indexed and even-indexed bits (BEBIT order) + * 2. Build tables of filter-consistent 20-bit values for each half + * 3. Extend tables to 24 bits using 4 more keystream bits each + * 4. Recursively extend and merge using feedback relation + * 5. Return all matching (odd, even) state pairs + * + * @param ks2 32 bits of known keystream + * @param input The value that was fed into the LFSR during keystream generation. + * Use 0 if keystream was generated with no input (e.g., mfkey32 attack). + * Use uid XOR nT if keystream was generated during cipher init + * (e.g., nested attack on the encrypted nonce). + * @return List of candidate [Crypto1State] objects. + */ + fun lfsrRecovery32(ks2: UInt, input: UInt): List { + // Split keystream into odd-indexed and even-indexed bits. + var oks = 0u + var eks = 0u + var i = 31 + while (i >= 0) { + oks = oks shl 1 or Crypto1.bebit(ks2, i) + i -= 2 + } + i = 30 + while (i >= 0) { + eks = eks shl 1 or Crypto1.bebit(ks2, i) + i -= 2 + } + + // Allocate arrays large enough for in-place extend_table_simple. + val arraySize = 1 shl 22 + val oddTbl = UIntArray(arraySize) + val evenTbl = UIntArray(arraySize) + var oddEnd = -1 + var evenEnd = -1 + + // Fill initial tables: all values in [0, 2^20] whose filter + // output matches the first keystream bit for each half. + for (v in (1 shl 20) downTo 0) { + if (Crypto1.filter(v.toUInt()).toUInt() == (oks and 1u)) { + oddTbl[++oddEnd] = v.toUInt() + } + if (Crypto1.filter(v.toUInt()).toUInt() == (eks and 1u)) { + evenTbl[++evenEnd] = v.toUInt() + } + } + + // Extend tables from 20 bits to 24 bits (4 rounds of extend_table_simple). + for (round in 0 until 4) { + oks = oks shr 1 + oddEnd = extendTableSimpleInPlace(oddTbl, oddEnd, (oks and 1u).toInt()) + eks = eks shr 1 + evenEnd = extendTableSimpleInPlace(evenTbl, evenEnd, (eks and 1u).toInt()) + } + + // Copy to right-sized arrays for recovery phase + val oddArr = oddTbl.copyOfRange(0, oddEnd + 1) + val evenArr = evenTbl.copyOfRange(0, evenEnd + 1) + + // Transform the input parameter for recover(), matching C code: + // in = (in >> 16 & 0xff) | (in << 16) | (in & 0xff00) + val transformedInput = ((input shr 16) and 0xFFu) or + (input shl 16) or + (input and 0xFF00u) + + // Recover matching state pairs. + val results = mutableListOf() + recover( + oddArr, oddArr.size, oks, + evenArr, evenArr.size, eks, + 11, results, transformedInput shl 1, + ) + + return results + } + + /** + * In-place extend_table_simple, faithfully matching crapto1's pointer logic. + * + * @return New end index (inclusive) + */ + private fun extendTableSimpleInPlace(tbl: UIntArray, endIdx: Int, bit: Int): Int { + var end = endIdx + var idx = 0 + + while (idx <= end) { + tbl[idx] = tbl[idx] shl 1 + val f0 = Crypto1.filter(tbl[idx]) + val f1 = Crypto1.filter(tbl[idx] or 1u) + + if (f0 != f1) { + // Uniquely determined: set LSB = filter(v) ^ bit + tbl[idx] = tbl[idx] or ((f0 xor bit).toUInt()) + idx++ + } else if (f0 == bit) { + // Both match: keep both variants + end++ + tbl[end] = tbl[idx + 1] + tbl[idx + 1] = tbl[idx] or 1u + idx += 2 + } else { + // Neither matches: drop (replace with last entry) + tbl[idx] = tbl[end] + end-- + } + } + return end + } + + /** + * Extend a table of candidate values by one bit with contribution tracking. + * Creates a NEW output array. + * + * Port of crapto1's extend_table(). + */ + private fun extendTable( + data: UIntArray, + size: Int, + bit: UInt, + m1: UInt, + m2: UInt, + inputBit: UInt, + ): Pair { + val inShifted = inputBit shl 24 + val output = UIntArray(size * 2 + 1) + var outIdx = 0 + + for (idx in 0 until size) { + val shifted = data[idx] shl 1 + + val f0 = Crypto1.filter(shifted).toUInt() + val f1 = Crypto1.filter(shifted or 1u).toUInt() + + if (f0 != f1) { + output[outIdx] = shifted or (f0 xor bit) + updateContribution(output, outIdx, m1, m2) + output[outIdx] = output[outIdx] xor inShifted + outIdx++ + } else if (f0 == bit) { + output[outIdx] = shifted + updateContribution(output, outIdx, m1, m2) + output[outIdx] = output[outIdx] xor inShifted + outIdx++ + + output[outIdx] = shifted or 1u + updateContribution(output, outIdx, m1, m2) + output[outIdx] = output[outIdx] xor inShifted + outIdx++ + } + // else: discard + } + + return Pair(output, outIdx) + } + + /** + * Update the contribution bits (upper 8 bits) of a table entry. + * Faithfully ported from crapto1's update_contribution(). + */ + private fun updateContribution(data: UIntArray, idx: Int, m1: UInt, m2: UInt) { + val item = data[idx] + var p = item shr 25 + p = p shl 1 or Crypto1.parity(item and m1) + p = p shl 1 or Crypto1.parity(item and m2) + data[idx] = p shl 24 or (item and 0xFFFFFFu) + } + + /** + * Recursively extend odd and even tables, then bucket-sort intersect + * to find matching pairs. + * + * Port of Proxmark3's recover() using bucket sort for intersection. + */ + private fun recover( + oddData: UIntArray, + oddSize: Int, + oks: UInt, + evenData: UIntArray, + evenSize: Int, + eks: UInt, + rem: Int, + results: MutableList, + input: UInt, + ) { + if (oddSize == 0 || evenSize == 0) return + + if (rem == -1) { + // Base case: assemble state pairs. + for (eIdx in 0 until evenSize) { + val eVal = evenData[eIdx] + val eModified = (eVal shl 1) xor + Crypto1.parity(eVal and Crypto1.LF_POLY_EVEN) xor + (if (input and 4u != 0u) 1u else 0u) + for (oIdx in 0 until oddSize) { + val oVal = oddData[oIdx] + results.add( + Crypto1State( + even = oVal, + odd = eModified xor Crypto1.parity(oVal and Crypto1.LF_POLY_ODD), + ), + ) + } + } + return + } + + // Extend both tables by up to 4 more keystream bits + var curOddData = oddData + var curOddSize = oddSize + var curEvenData = evenData + var curEvenSize = evenSize + var oksLocal = oks + var eksLocal = eks + var inputLocal = input + var remLocal = rem + + for (round in 0 until 4) { + // C: for(i = 0; i < 4 && rem--; i++) + if (remLocal == 0) { + remLocal = -1 + break + } + remLocal-- + + oksLocal = oksLocal shr 1 + eksLocal = eksLocal shr 1 + inputLocal = inputLocal shr 2 + + val oddResult = extendTable( + curOddData, + curOddSize, + oksLocal and 1u, + Crypto1.LF_POLY_EVEN shl 1 or 1u, + Crypto1.LF_POLY_ODD shl 1, + 0u, + ) + curOddData = oddResult.first + curOddSize = oddResult.second + if (curOddSize == 0) return + + val evenResult = extendTable( + curEvenData, + curEvenSize, + eksLocal and 1u, + Crypto1.LF_POLY_ODD, + Crypto1.LF_POLY_EVEN shl 1 or 1u, + inputLocal and 3u, + ) + curEvenData = evenResult.first + curEvenSize = evenResult.second + if (curEvenSize == 0) return + } + + // Bucket sort intersection on upper 8 bits (contribution bits). + val oddBuckets = HashMap>() + for (idx in 0 until curOddSize) { + val bucket = (curOddData[idx] shr 24).toInt() + oddBuckets.getOrPut(bucket) { mutableListOf() }.add(idx) + } + + val evenBuckets = HashMap>() + for (idx in 0 until curEvenSize) { + val bucket = (curEvenData[idx] shr 24).toInt() + evenBuckets.getOrPut(bucket) { mutableListOf() }.add(idx) + } + + for ((bucket, oddIndices) in oddBuckets) { + val evenIndices = evenBuckets[bucket] ?: continue + + val oddSub = UIntArray(oddIndices.size) { curOddData[oddIndices[it]] } + val evenSub = UIntArray(evenIndices.size) { curEvenData[evenIndices[it]] } + + recover( + oddSub, oddSub.size, oksLocal, + evenSub, evenSub.size, eksLocal, + remLocal, results, inputLocal, + ) + } + } + + /** + * Calculate the distance (number of PRNG steps) between two nonces. + * + * @param n1 Starting nonce + * @param n2 Target nonce + * @return Number of PRNG steps from [n1] to [n2], or [UInt.MAX_VALUE] + * if [n2] is not reachable from [n1] within 65536 steps. + */ + fun nonceDistance(n1: UInt, n2: UInt): UInt { + var state = n1 + for (i in 0u until 65536u) { + if (state == n2) return i + state = Crypto1.prngSuccessor(state, 1u) + } + return UInt.MAX_VALUE + } + + /** + * High-level key recovery from nested authentication data. + * + * @param uid Card UID (4 bytes) + * @param knownNT Card nonce from the known-key authentication + * @param encryptedNT Encrypted nonce from the nested authentication + * @param knownKey The known sector key (48 bits) + * @return List of candidate 48-bit keys for the target sector + */ + fun recoverKeyFromNonces( + uid: UInt, + knownNT: UInt, + encryptedNT: UInt, + knownKey: Long, + ): List { + val recoveredKeys = mutableListOf() + + val state = Crypto1Auth.initCipher(knownKey, uid, knownNT) + state.lfsrWord(0u, false) + state.lfsrWord(0u, false) + val ks = state.lfsrWord(0u, false) + val candidateNT = encryptedNT xor ks + + val candidates = lfsrRecovery32(ks, candidateNT) + for (candidate in candidates) { + val s = candidate.copy() + s.lfsrRollbackWord(uid xor candidateNT, false) + recoveredKeys.add(s.getKey()) + } + + return recoveredKeys + } +} diff --git a/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/crypto1/NestedAttack.kt b/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/crypto1/NestedAttack.kt new file mode 100644 index 000000000..f82ec2bc4 --- /dev/null +++ b/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/crypto1/NestedAttack.kt @@ -0,0 +1,298 @@ +/* + * NestedAttack.kt + * + * Copyright 2026 Eric Butler + * + * MIFARE Classic nested attack orchestration. + * + * Coordinates the key recovery process for MIFARE Classic cards: + * 1. Calibrate PRNG timing by collecting nonces from repeated authentications + * 2. Collect encrypted nonces via nested authentication + * 3. Predict plaintext nonces using PRNG distance + * 4. Recover keys using LFSR state recovery + * + * Reference: mfoc (MIFARE Classic Offline Cracker), Proxmark3 nested attack + * + * 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.classic.crypto1 + +import com.codebutler.farebot.card.classic.pn533.PN533RawClassic + +/** + * MIFARE Classic nested attack for key recovery. + * + * Given one known sector key, recovers unknown keys for other sectors by + * exploiting the weak PRNG and Crypto1 cipher of MIFARE Classic cards. + * + * The attack works in three phases: + * + * **Phase 1 (Calibration):** Authenticate multiple times with the known key, + * collecting the card's PRNG nonces. Compute the PRNG distance between + * consecutive nonces to characterize the card's timing. + * + * **Phase 2 (Collection):** For each round, authenticate with the known key, + * then immediately perform a nested authentication to the target sector. + * The card responds with an encrypted nonce. Store each encrypted nonce + * along with a snapshot of the cipher state at that point. + * + * **Phase 3 (Recovery):** For each collected encrypted nonce, use the PRNG + * distance to predict the plaintext nonce. Compute the keystream by XORing + * the encrypted and predicted nonces. Feed the keystream into + * [Crypto1Recovery.lfsrRecovery32] to find candidate LFSR states. Roll back + * each candidate to extract a candidate key and verify it by attempting a + * real authentication. + * + * @param rawClassic Raw PN533 MIFARE Classic interface for hardware communication + * @param uid Card UID (4 bytes as UInt, big-endian) + */ +class NestedAttack( + private val rawClassic: PN533RawClassic, + private val uid: UInt, +) { + + /** + * Data collected during a single nested authentication attempt. + * + * @param encryptedNonce The encrypted 4-byte nonce received from the card + * during the nested authentication (before decryption). + * @param cipherStateAtNested A snapshot of the Crypto1 cipher state at the + * point just before the nested authentication command was sent. This state + * can be used to compute the keystream that encrypted the nested nonce. + */ + data class NestedNonceData( + val encryptedNonce: UInt, + val cipherStateAtNested: Crypto1State, + ) + + /** + * Recover an unknown sector key using the nested attack. + * + * Requires one known key for any sector on the card. Uses the known key + * to establish an authenticated session, then performs nested authentication + * to the target sector to collect encrypted nonces for key recovery. + * + * @param knownKeyType 0x60 for Key A, 0x61 for Key B + * @param knownSectorBlock A block number in the sector with the known key + * @param knownKey The known 48-bit key (6 bytes packed into a Long) + * @param targetKeyType 0x60 for Key A, 0x61 for Key B (key to recover) + * @param targetBlock A block number in the target sector + * @param onProgress Optional callback for progress reporting + * @return The recovered 48-bit key, or null if recovery failed + */ + suspend fun recoverKey( + knownKeyType: Byte, + knownSectorBlock: Int, + knownKey: Long, + targetKeyType: Byte, + targetBlock: Int, + onProgress: ((String) -> Unit)? = null, + ): Long? { + // ---- Phase 1: Calibrate PRNG ---- + onProgress?.invoke("Phase 1: Calibrating PRNG timing...") + + val nonces = mutableListOf() + for (i in 0 until CALIBRATION_ROUNDS) { + val nonce = rawClassic.requestAuth(knownKeyType, knownSectorBlock) + if (nonce != null) { + nonces.add(nonce) + } + // Reset the card state between attempts + rawClassic.restoreNormalMode() + } + + if (nonces.size < MIN_CALIBRATION_NONCES) { + onProgress?.invoke("Calibration failed: only ${nonces.size} nonces collected (need $MIN_CALIBRATION_NONCES)") + return null + } + + val distances = calibratePrng(nonces) + if (distances.isEmpty()) { + onProgress?.invoke("Calibration failed: could not compute PRNG distances") + return null + } + + // Get median distance + val sortedDistances = distances.filter { it != UInt.MAX_VALUE }.sorted() + if (sortedDistances.isEmpty()) { + onProgress?.invoke("Calibration failed: all distances unreachable") + return null + } + val medianDistance = sortedDistances[sortedDistances.size / 2] + onProgress?.invoke("PRNG calibrated: median distance = $medianDistance (from ${sortedDistances.size} valid distances)") + + // ---- Phase 2: Collect encrypted nonces ---- + onProgress?.invoke("Phase 2: Collecting encrypted nonces...") + + val collectedNonces = mutableListOf() + for (i in 0 until COLLECTION_ROUNDS) { + // Authenticate with the known key + rawClassic.restoreNormalMode() + val authState = rawClassic.authenticate(knownKeyType, knownSectorBlock, knownKey) + ?: continue + + // Save a copy of the cipher state before nested auth + val cipherStateCopy = authState.copy() + + // Perform nested auth to the target sector + val encNonce = rawClassic.nestedAuth(targetKeyType, targetBlock, authState) + ?: continue + + collectedNonces.add(NestedNonceData(encNonce, cipherStateCopy)) + + if ((i + 1) % 10 == 0) { + onProgress?.invoke("Collected ${collectedNonces.size} nonces ($i/$COLLECTION_ROUNDS rounds)") + } + } + + if (collectedNonces.size < MIN_NONCES_FOR_RECOVERY) { + onProgress?.invoke("Collection failed: only ${collectedNonces.size} nonces (need $MIN_NONCES_FOR_RECOVERY)") + return null + } + onProgress?.invoke("Collected ${collectedNonces.size} encrypted nonces") + + // ---- Phase 3: Recover key ---- + onProgress?.invoke("Phase 3: Attempting key recovery...") + + for ((index, nonceData) in collectedNonces.withIndex()) { + onProgress?.invoke("Trying nonce ${index + 1}/${collectedNonces.size}...") + + // The cipher state at the point of nested auth was producing keystream. + // The nested AUTH command was encrypted with this state, and the card's + // response (encrypted nonce) was also encrypted with the continuing stream. + // + // To recover the target key, we need to predict what the plaintext nonce was. + // The card's PRNG was running during the time between authentications, so + // we try multiple candidate plaintext nonces near the predicted PRNG state. + + // Generate keystream from the saved cipher state + val ksCopy = nonceData.cipherStateAtNested.copy() + // The nested auth command is 4 bytes; clock the state through those bytes + // to get to the point where the nonce keystream starts + val ks = ksCopy.lfsrWord(0u, false) + + // Candidate plaintext nonce = encrypted nonce XOR keystream + val candidateNT = nonceData.encryptedNonce xor ks + + // Use LFSR recovery to find candidate states for the target key + // The keystream that encrypted the nonce was generated by the TARGET key's + // cipher, initialized with targetKey, uid XOR candidateNT + // + // Actually, the encrypted nonce from nested auth is encrypted by the CURRENT + // session's cipher (the known key's cipher). To recover the target key, we need + // to know that the card initialized a new Crypto1 session with the target key + // after receiving the nested AUTH command. + // + // The card responds with nT2 encrypted under the NEW cipher: + // encrypted_nT2 = nT2 XOR ks_target + // where ks_target is the first 32 bits of keystream from: + // targetKey loaded, then feeding uid XOR nT2 + // + // We don't know nT2, but we can predict it from the PRNG calibration. + // For now, try the XOR approach: the encrypted nonce we see is encrypted + // by the ongoing known-key cipher stream. + + // Try to predict the actual plaintext nonce using PRNG distance + // The nonce the card sends is its PRNG state at the time of the nested auth + // Try a range of PRNG steps around the median distance from the last known nonce + val searchRange = 30u + val minDist = if (medianDistance > searchRange) medianDistance - searchRange else 0u + val maxDist = medianDistance + searchRange + + for (dist in minDist..maxDist) { + val predictedNT = Crypto1.prngSuccessor(candidateNT, dist) + + // The target key's cipher produces keystream: loadKey(targetKey), then + // lfsrWord(uid XOR predictedNT, false) -> ks_init + // encryptedNonce = predictedNT XOR ks_init + // + // So: ks_init = encryptedNonce XOR predictedNT... but we used candidateNT + // which already accounts for the known-key cipher's keystream. + + // Try lfsrRecovery32 with various approaches + val ksTarget = nonceData.encryptedNonce xor predictedNT + val candidates = Crypto1Recovery.lfsrRecovery32(ksTarget, uid xor predictedNT) + + for (candidate in candidates) { + val s = candidate.copy() + s.lfsrRollbackWord(uid xor predictedNT, false) + val recoveredKey = s.getKey() + + // Verify the candidate key by attempting real authentication + if (verifyKey(targetKeyType, targetBlock, recoveredKey)) { + onProgress?.invoke("Key recovered: 0x${recoveredKey.toString(16).padStart(12, '0')}") + return recoveredKey + } + } + } + } + + onProgress?.invoke("Key recovery failed after trying all collected nonces") + return null + } + + /** + * Verify a candidate key by attempting authentication with the card. + * + * Restores normal CIU mode, attempts a full authentication with the + * candidate key, and restores normal mode again regardless of the result. + * + * @param keyType 0x60 for Key A, 0x61 for Key B + * @param block Block number to authenticate against + * @param key Candidate 48-bit key to verify + * @return true if authentication succeeds (key is valid) + */ + suspend fun verifyKey(keyType: Byte, block: Int, key: Long): Boolean { + rawClassic.restoreNormalMode() + val result = rawClassic.authenticate(keyType, block, key) + rawClassic.restoreNormalMode() + return result != null + } + + companion object { + /** Number of authentication rounds for PRNG calibration. */ + const val CALIBRATION_ROUNDS = 20 + + /** Minimum number of valid nonces required for calibration. */ + const val MIN_CALIBRATION_NONCES = 10 + + /** Number of rounds for encrypted nonce collection. */ + const val COLLECTION_ROUNDS = 50 + + /** Minimum number of collected nonces required for recovery. */ + const val MIN_NONCES_FOR_RECOVERY = 5 + + /** + * Compute PRNG distances between consecutive nonces. + * + * For each consecutive pair of nonces (n[i], n[i+1]), calculates + * the number of PRNG steps required to advance from n[i] to n[i+1] + * using [Crypto1Recovery.nonceDistance]. + * + * @param nonces List of nonces collected from successive authentications + * @return List of PRNG distances between consecutive nonces + */ + fun calibratePrng(nonces: List): List { + if (nonces.size < 2) return emptyList() + + val distances = mutableListOf() + for (i in 0 until nonces.size - 1) { + val distance = Crypto1Recovery.nonceDistance(nonces[i], nonces[i + 1]) + distances.add(distance) + } + return distances + } + } +} diff --git a/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/pn533/PN533RawClassic.kt b/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/pn533/PN533RawClassic.kt new file mode 100644 index 000000000..c5e64fbd0 --- /dev/null +++ b/card/classic/src/commonMain/kotlin/com/codebutler/farebot/card/classic/pn533/PN533RawClassic.kt @@ -0,0 +1,332 @@ +/* + * PN533RawClassic.kt + * + * Copyright 2026 Eric Butler + * + * Raw MIFARE Classic communication via PN533 InCommunicateThru, + * bypassing the chip's built-in Crypto1 handling to expose raw + * authentication nonces needed for key recovery attacks. + * + * 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.classic.pn533 + +import com.codebutler.farebot.card.classic.crypto1.Crypto1Auth +import com.codebutler.farebot.card.classic.crypto1.Crypto1State +import com.codebutler.farebot.card.nfc.pn533.PN533 + +/** + * Raw MIFARE Classic interface using PN533 InCommunicateThru. + * + * Bypasses the PN533's built-in Crypto1 handling by directly controlling + * the CIU (Contactless Interface Unit) registers for CRC generation, + * parity, and crypto state. This allows software-side Crypto1 operations, + * which is required for key recovery (exposing raw nonces). + * + * Reference: + * - NXP PN533 User Manual (CIU register map) + * - ISO 14443-3A (CRC-A, MIFARE Classic auth protocol) + * - mfoc/mfcuk (nested attack implementation) + * + * @param pn533 PN533 controller instance + * @param uid 4-byte card UID (used in Crypto1 cipher initialization) + */ +class PN533RawClassic( + private val pn533: PN533, + private val uid: ByteArray, +) { + /** + * Disable CRC generation/checking in the CIU. + * + * Clears bit 7 of both TxMode and RxMode registers so the PN533 + * does not append/verify CRC bytes. Required for raw Crypto1 + * communication where CRC is computed in software. + */ + suspend fun disableCrc() { + pn533.writeRegister(REG_CIU_TX_MODE, 0x00) + pn533.writeRegister(REG_CIU_RX_MODE, 0x00) + } + + /** + * Enable CRC generation/checking in the CIU. + * + * Sets bit 7 of both TxMode and RxMode registers for normal + * CRC-appended communication. + */ + suspend fun enableCrc() { + pn533.writeRegister(REG_CIU_TX_MODE, 0x80) + pn533.writeRegister(REG_CIU_RX_MODE, 0x80) + } + + /** + * Disable parity generation/checking in the CIU. + * + * Sets bit 4 of ManualRCV register. Required for raw Crypto1 + * communication where parity is handled in software. + */ + suspend fun disableParity() { + pn533.writeRegister(REG_CIU_MANUAL_RCV, 0x10) + } + + /** + * Enable parity generation/checking in the CIU. + * + * Clears bit 4 of ManualRCV register for normal parity handling. + */ + suspend fun enableParity() { + pn533.writeRegister(REG_CIU_MANUAL_RCV, 0x00) + } + + /** + * Clear the Crypto1 active flag in the CIU. + * + * Clears bit 3 of Status2 register, telling the PN533 that + * no hardware Crypto1 session is active. + */ + suspend fun clearCrypto1() { + pn533.writeRegister(REG_CIU_STATUS2, 0x00) + } + + /** + * Restore normal CIU operating mode. + * + * Re-enables CRC, parity, and clears any Crypto1 state. + * Call this after raw communication is complete. + */ + suspend fun restoreNormalMode() { + enableCrc() + enableParity() + clearCrypto1() + } + + /** + * Send a raw AUTH command and receive the card nonce. + * + * Prepares the CIU for raw communication (disable CRC, parity, + * clear crypto1), then sends the AUTH command via InCommunicateThru. + * The card responds with a 4-byte plaintext nonce (nT). + * + * @param keyType 0x60 for Key A, 0x61 for Key B + * @param blockIndex Block number to authenticate against + * @return 4-byte card nonce as UInt (big-endian), or null on failure + */ + suspend fun requestAuth(keyType: Byte, blockIndex: Int): UInt? { + disableCrc() + disableParity() + clearCrypto1() + + val cmd = buildAuthCommand(keyType, blockIndex) + val response = try { + pn533.inCommunicateThru(cmd) + } catch (_: Exception) { + return null + } + + if (response.size < 4) return null + return parseNonce(response) + } + + /** + * Perform a full software Crypto1 authentication. + * + * Executes the complete three-pass mutual authentication handshake: + * 1. Send AUTH command, receive card nonce nT + * 2. Initialize cipher with key, UID, and nT + * 3. Compute and send encrypted {nR}{aR} + * 4. Receive and verify encrypted {aT} + * + * @param keyType 0x60 for Key A, 0x61 for Key B + * @param blockIndex Block number to authenticate against + * @param key 48-bit MIFARE key (6 bytes packed into a Long) + * @return Cipher state on success (ready for encrypted communication), null on failure + */ + suspend fun authenticate(keyType: Byte, blockIndex: Int, key: Long): Crypto1State? { + // Step 1: Request auth and get card nonce + val nT = requestAuth(keyType, blockIndex) ?: return null + + // Step 2: Initialize cipher with key, UID XOR nT + val uidInt = bytesToUInt(uid) + val state = Crypto1Auth.initCipher(key, uidInt, nT) + + // Step 3: Compute reader response {nR}{aR} + // Use a fixed reader nonce (in real attacks this could be random) + val nR = 0x01020304u + val (nREnc, aREnc) = Crypto1Auth.computeReaderResponse(state, nR, nT) + + // Step 4: Send {nR}{aR} via InCommunicateThru + val readerMsg = uintToBytes(nREnc) + uintToBytes(aREnc) + val cardResponse = try { + pn533.inCommunicateThru(readerMsg) + } catch (_: Exception) { + return null + } + + // Step 5: Verify card's response {aT} + if (cardResponse.size < 4) return null + val aTEnc = bytesToUInt(cardResponse) + if (!Crypto1Auth.verifyCardResponse(state, aTEnc, nT)) { + return null + } + + return state + } + + /** + * Perform a nested authentication within an existing encrypted session. + * + * Sends an AUTH command encrypted with the current Crypto1 state. + * The card responds with an encrypted nonce. The encrypted nonce + * is returned raw (not decrypted) for use in key recovery attacks. + * + * @param keyType 0x60 for Key A, 0x61 for Key B + * @param blockIndex Block number to authenticate against + * @param currentState Current Crypto1 cipher state from a previous authentication + * @return Encrypted 4-byte card nonce as UInt (big-endian), or null on failure + */ + suspend fun nestedAuth(keyType: Byte, blockIndex: Int, currentState: Crypto1State): UInt? { + // Build plaintext AUTH command (with CRC) + val plainCmd = buildAuthCommand(keyType, blockIndex) + + // Encrypt the command with the current cipher state + val encCmd = Crypto1Auth.encryptBytes(currentState, plainCmd) + + // Send encrypted AUTH command + val response = try { + pn533.inCommunicateThru(encCmd) + } catch (_: Exception) { + return null + } + + if (response.size < 4) return null + + // Return the encrypted nonce (raw, for key recovery) + return bytesToUInt(response) + } + + /** + * Read a block using software Crypto1 encryption. + * + * Encrypts a READ command with the current cipher state, sends it, + * and decrypts the 16-byte response. + * + * @param blockIndex Block number to read + * @param state Current Crypto1 cipher state (from a successful authentication) + * @return Decrypted 16-byte block data, or null on failure + */ + suspend fun readBlockEncrypted(blockIndex: Int, state: Crypto1State): ByteArray? { + // Build plaintext READ command (with CRC) + val plainCmd = buildReadCommand(blockIndex) + + // Encrypt the command + val encCmd = Crypto1Auth.encryptBytes(state, plainCmd) + + // Send via InCommunicateThru + val response = try { + pn533.inCommunicateThru(encCmd) + } catch (_: Exception) { + return null + } + + // Response should be 16 bytes data + 2 bytes CRC = 18 bytes + if (response.size < 16) return null + + // Decrypt the response + val decrypted = Crypto1Auth.decryptBytes(state, response) + + // Return the 16-byte data (strip CRC if present) + return decrypted.copyOfRange(0, 16) + } + + companion object { + /** CIU TxMode register — Bit 7 = TX CRC enable */ + const val REG_CIU_TX_MODE = 0x6302 + + /** CIU RxMode register — Bit 7 = RX CRC enable */ + const val REG_CIU_RX_MODE = 0x6303 + + /** CIU ManualRCV register — Bit 4 = parity disable */ + const val REG_CIU_MANUAL_RCV = 0x630D + + /** CIU Status2 register — Bit 3 = Crypto1 active */ + const val REG_CIU_STATUS2 = 0x6338 + + /** + * Build a MIFARE Classic AUTH command with CRC. + * + * Format: [keyType, blockIndex, CRC_L, CRC_H] + * + * @param keyType 0x60 for Key A, 0x61 for Key B + * @param blockIndex Block number to authenticate against + * @return 4-byte command with ISO 14443-3A CRC appended + */ + fun buildAuthCommand(keyType: Byte, blockIndex: Int): ByteArray { + val data = byteArrayOf(keyType, blockIndex.toByte()) + val crc = Crypto1Auth.crcA(data) + return data + crc + } + + /** + * Build a MIFARE Classic READ command with CRC. + * + * Format: [0x30, blockIndex, CRC_L, CRC_H] + * + * @param blockIndex Block number to read + * @return 4-byte command with ISO 14443-3A CRC appended + */ + fun buildReadCommand(blockIndex: Int): ByteArray { + val data = byteArrayOf(0x30, blockIndex.toByte()) + val crc = Crypto1Auth.crcA(data) + return data + crc + } + + /** + * Parse a 4-byte response into a card nonce (big-endian). + * + * @param response At least 4 bytes from the card + * @return UInt nonce value (big-endian interpretation) + */ + fun parseNonce(response: ByteArray): UInt { + return bytesToUInt(response) + } + + /** + * Convert 4 bytes (big-endian) to a UInt. + * + * @param bytes At least 4 bytes, big-endian (MSB first) + * @return UInt value + */ + fun bytesToUInt(bytes: ByteArray): UInt { + return ((bytes[0].toInt() and 0xFF).toUInt() shl 24) or + ((bytes[1].toInt() and 0xFF).toUInt() shl 16) or + ((bytes[2].toInt() and 0xFF).toUInt() shl 8) or + (bytes[3].toInt() and 0xFF).toUInt() + } + + /** + * Convert a UInt to 4 bytes (big-endian). + * + * @param value UInt value to convert + * @return 4-byte array, big-endian (MSB first) + */ + fun uintToBytes(value: UInt): ByteArray { + return byteArrayOf( + ((value shr 24) and 0xFFu).toByte(), + ((value shr 16) and 0xFFu).toByte(), + ((value shr 8) and 0xFFu).toByte(), + (value and 0xFFu).toByte(), + ) + } + } +} diff --git a/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/crypto1/Crypto1AuthTest.kt b/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/crypto1/Crypto1AuthTest.kt new file mode 100644 index 000000000..ac9731075 --- /dev/null +++ b/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/crypto1/Crypto1AuthTest.kt @@ -0,0 +1,253 @@ +/* + * Crypto1AuthTest.kt + * + * Copyright 2026 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.classic.crypto1 + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Tests for the MIFARE Classic authentication protocol helpers. + */ +class Crypto1AuthTest { + // Common test constants + private val testKey = 0xFFFFFFFFFFFF // Default MIFARE key (all 0xFF bytes) + private val testKeyA0 = 0xA0A1A2A3A4A5L + private val testUid = 0xDEADBEEFu + private val testNT = 0x12345678u + private val testNR = 0xAABBCCDDu + + @Test + fun testInitCipher() { + // Verify initCipher produces a non-zero state for a non-zero key + val state = Crypto1Auth.initCipher(testKey, testUid, testNT) + // After loading key and feeding uid^nT, state should be non-trivial + assertTrue( + state.odd != 0u || state.even != 0u, + "Initialized cipher state should be non-zero", + ) + + // Verify determinism: same inputs produce same state + val state2 = Crypto1Auth.initCipher(testKey, testUid, testNT) + assertEquals(state.odd, state2.odd, "initCipher should be deterministic (odd)") + assertEquals(state.even, state2.even, "initCipher should be deterministic (even)") + + // Different keys should produce different states + val state3 = Crypto1Auth.initCipher(testKeyA0, testUid, testNT) + assertTrue( + state.odd != state3.odd || state.even != state3.even, + "Different keys should produce different states", + ) + + // Different UIDs should produce different states + val state4 = Crypto1Auth.initCipher(testKey, 0x01020304u, testNT) + assertTrue( + state.odd != state4.odd || state.even != state4.even, + "Different UIDs should produce different states", + ) + + // Different nonces should produce different states + val state5 = Crypto1Auth.initCipher(testKey, testUid, 0x87654321u) + assertTrue( + state.odd != state5.odd || state.even != state5.even, + "Different nonces should produce different states", + ) + } + + @Test + fun testComputeReaderResponse() { + val state = Crypto1Auth.initCipher(testKey, testUid, testNT) + val (nREnc, aREnc) = Crypto1Auth.computeReaderResponse(state, testNR, testNT) + + // Encrypted values should differ from plaintext + assertNotEquals(testNR, nREnc, "Encrypted nR should differ from plaintext nR") + + val aR = Crypto1.prngSuccessor(testNT, 64u) + assertNotEquals(aR, aREnc, "Encrypted aR should differ from plaintext aR") + + // Verify determinism: same inputs produce same encrypted outputs + val state2 = Crypto1Auth.initCipher(testKey, testUid, testNT) + val (nREnc2, aREnc2) = Crypto1Auth.computeReaderResponse(state2, testNR, testNT) + assertEquals(nREnc, nREnc2, "computeReaderResponse should be deterministic (nR)") + assertEquals(aREnc, aREnc2, "computeReaderResponse should be deterministic (aR)") + } + + @Test + fun testFullAuthRoundtrip() { + // Simulate a full three-pass mutual authentication between reader and card. + // + // Protocol: + // 1. Card sends nT + // 2. Reader computes {nR}{aR} where aR = suc^64(nT) + // 3. Card verifies aR and responds with {aT} where aT = suc^96(nT) + // 4. Reader verifies aT + // + // Both sides initialize with the same key and uid^nT. + + val key = testKeyA0 + val uid = 0x01020304u + val nT = 0xCAFEBABEu + val nR = 0xDEAD1234u + + // --- Reader side --- + val readerState = Crypto1Auth.initCipher(key, uid, nT) + val (nREnc, aREnc) = Crypto1Auth.computeReaderResponse(readerState, nR, nT) + + // --- Card side --- + // Card initializes its own cipher the same way + val cardState = Crypto1Auth.initCipher(key, uid, nT) + + // Card decrypts nR using encrypted mode: this feeds the plaintext nR bits + // into the LFSR (since isEncrypted=true, feedback = ciphertext XOR keystream = plaintext). + // This matches the reader side which fed nR via lfsrWord(nR, false). + val nRDecrypted = nREnc xor cardState.lfsrWord(nREnc, true) + + // Card decrypts aR: both sides feed 0 into the LFSR for the aR portion. + // The reader used lfsrWord(0, false), so the card must also feed 0 + // and XOR the keystream with the ciphertext externally. + val expectedAR = Crypto1.prngSuccessor(nT, 64u) + val aRKeystream = cardState.lfsrWord(0u, false) + val aRDecrypted = aREnc xor aRKeystream + assertEquals(expectedAR, aRDecrypted, "Card should decrypt aR to suc^64(nT)") + + // Card computes and encrypts aT = suc^96(nT) + // Both sides feed 0 for the aT portion as well. + val aT = Crypto1.prngSuccessor(nT, 96u) + val aTEnc = aT xor cardState.lfsrWord(0u, false) + + // --- Reader side verifies card response --- + val verified = Crypto1Auth.verifyCardResponse(readerState, aTEnc, nT) + assertTrue(verified, "Reader should verify card's response successfully") + } + + @Test + fun testVerifyCardResponseRejectsWrongValue() { + val key = testKey + val uid = testUid + val nT = testNT + val nR = testNR + + val state = Crypto1Auth.initCipher(key, uid, nT) + Crypto1Auth.computeReaderResponse(state, nR, nT) + + // Send a wrong encrypted aT + val wrongATEnc = 0xBADF00Du + val verified = Crypto1Auth.verifyCardResponse(state, wrongATEnc, nT) + assertFalse(verified, "verifyCardResponse should reject incorrect card response") + } + + @Test + fun testCrcA() { + // ISO 14443-3A CRC test vectors. + // + // CRC_A of AUTH command (0x60) for block 0 (0x00): + // AUTH_READ = 0x60, block = 0x00 -> CRC = [0xF5, 0x7B] + // This is a well-known test vector from the MIFARE specification. + val authCmd = byteArrayOf(0x60, 0x00) + val crc = Crypto1Auth.crcA(authCmd) + assertEquals(2, crc.size, "CRC-A should be 2 bytes") + assertContentEquals(byteArrayOf(0xF5.toByte(), 0x7B), crc, "CRC-A of [0x60, 0x00]") + + // CRC of empty data should be initial value split into bytes: [0x63, 0x63] + val emptyCrc = Crypto1Auth.crcA(byteArrayOf()) + assertContentEquals( + byteArrayOf(0x63, 0x63), + emptyCrc, + "CRC-A of empty data should be [0x63, 0x63]", + ) + + // CRC of a single zero byte + val zeroCrc = Crypto1Auth.crcA(byteArrayOf(0x00)) + assertEquals(2, zeroCrc.size, "CRC-A should always be 2 bytes") + + // CRC of READ command (0x30) for block 0 (0x00) + val readCmd = byteArrayOf(0x30, 0x00) + val readCrc = Crypto1Auth.crcA(readCmd) + assertContentEquals(byteArrayOf(0x02, 0xA8.toByte()), readCrc, "CRC-A of [0x30, 0x00]") + } + + @Test + fun testEncryptDecryptRoundtrip() { + val key = testKeyA0 + val uid = 0x01020304u + val nT = 0xABCD1234u + + val plaintext = byteArrayOf( + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, + ) + + // Encrypt with one cipher state + val encState = Crypto1Auth.initCipher(key, uid, nT) + val ciphertext = Crypto1Auth.encryptBytes(encState, plaintext) + + // Ciphertext should differ from plaintext + assertFalse( + plaintext.contentEquals(ciphertext), + "Ciphertext should differ from plaintext", + ) + + // Decrypt with a fresh cipher state (same initialization) + val decState = Crypto1Auth.initCipher(key, uid, nT) + val decrypted = Crypto1Auth.decryptBytes(decState, ciphertext) + + assertContentEquals(plaintext, decrypted, "Decrypt(Encrypt(data)) should return original data") + } + + @Test + fun testEncryptDecryptEmptyData() { + val state = Crypto1Auth.initCipher(testKey, testUid, testNT) + val result = Crypto1Auth.encryptBytes(state, byteArrayOf()) + assertContentEquals(byteArrayOf(), result, "Encrypting empty data should return empty") + } + + @Test + fun testEncryptDecryptSingleByte() { + val key = testKey + val uid = testUid + val nT = testNT + + val plaintext = byteArrayOf(0x42) + + val encState = Crypto1Auth.initCipher(key, uid, nT) + val ciphertext = Crypto1Auth.encryptBytes(encState, plaintext) + assertEquals(1, ciphertext.size, "Single-byte encrypt should produce one byte") + + val decState = Crypto1Auth.initCipher(key, uid, nT) + val decrypted = Crypto1Auth.decryptBytes(decState, ciphertext) + assertContentEquals(plaintext, decrypted, "Single-byte roundtrip should work") + } + + @Test + fun testCrcAMultipleBytes() { + // Additional CRC-A test: WRITE command (0xA0) for block 4 (0x04) + val writeCmd = byteArrayOf(0xA0.toByte(), 0x04) + val crc = Crypto1Auth.crcA(writeCmd) + assertEquals(2, crc.size) + // Verify the CRC is not the initial value (confirms computation happened) + assertFalse( + crc[0] == 0x63.toByte() && crc[1] == 0x63.toByte(), + "CRC of non-empty data should differ from initial value", + ) + } +} diff --git a/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/crypto1/Crypto1RecoveryTest.kt b/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/crypto1/Crypto1RecoveryTest.kt new file mode 100644 index 000000000..42c1cc7bf --- /dev/null +++ b/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/crypto1/Crypto1RecoveryTest.kt @@ -0,0 +1,299 @@ +/* + * Crypto1RecoveryTest.kt + * + * Copyright 2026 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.classic.crypto1 + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Tests for the Crypto1 key recovery algorithm. + * + * Tests simulate the mfkey32 attack: given authentication data (uid, nonce, + * reader nonce, reader response), recover the keystream, feed it to + * lfsrRecovery32, and verify the correct key can be extracted by rolling + * back the LFSR state. + * + * IMPORTANT: In the real MIFARE Classic protocol, the reader nonce (nR) phase + * uses encrypted mode (isEncrypted=true). The forward simulation MUST use + * encrypted mode for nR to produce the correct cipher state, otherwise the + * keystream at the aR phase will be wrong and recovery will fail. + */ +class Crypto1RecoveryTest { + + /** + * Simulate a full MIFARE Classic authentication and verify that + * lfsrRecovery32 can recover the key from the observed data. + * + * This follows the mfkey32 attack approach: + * 1. Initialize cipher with key, feed uid^nT (not encrypted) + * 2. Process reader nonce nR (encrypted mode - as in real protocol) + * 3. Generate keystream for reader response aR (generates ks2 with input=0) + * 4. Recover LFSR state from ks2 + * 5. Roll back through ks2, nR (encrypted), and uid^nT to extract the key + */ + @Test + fun testRecoverKeyMfkey32Style() { + val key = 0xA0A1A2A3A4A5L + val uid = 0xDEADBEEFu + val nT = 0x12345678u + val nR = 0x87654321u + + // Simulate full auth with correct encrypted mode for nR + val state = Crypto1State() + state.loadKey(key) + state.lfsrWord(uid xor nT, false) // init - not encrypted + state.lfsrWord(nR, true) // reader nonce - ENCRYPTED (as in real protocol) + val ks2 = state.lfsrWord(0u, false) // keystream for reader response (input=0) + + // Recovery: ks2 was generated with input=0 + val candidates = Crypto1Recovery.lfsrRecovery32(ks2, 0u) + + assertTrue( + candidates.isNotEmpty(), + "Should find at least one candidate state. ks2=0x${ks2.toString(16)}", + ) + + // Roll back each candidate to extract the key + val foundKey = candidates.any { candidate -> + val s = candidate.copy() + s.lfsrRollbackWord(0u, false) // undo ks2 generation (input=0) + s.lfsrRollbackWord(nR, true) // undo reader nonce (encrypted) + s.lfsrRollbackWord(uid xor nT, false) // undo init + s.getKey() == key + } + + assertTrue(foundKey, "Correct key 0x${key.toString(16)} should be recoverable from candidates") + } + + @Test + fun testRecoverKeyMfkey32StyleDifferentKey() { + val key = 0xFFFFFFFFFFFFL + val uid = 0x01020304u + val nT = 0xAABBCCDDu + val nR = 0x11223344u + + val state = Crypto1State() + state.loadKey(key) + state.lfsrWord(uid xor nT, false) + state.lfsrWord(nR, true) // ENCRYPTED + val ks2 = state.lfsrWord(0u, false) + + val candidates = Crypto1Recovery.lfsrRecovery32(ks2, 0u) + + assertTrue( + candidates.isNotEmpty(), + "Should find at least one candidate. ks2=0x${ks2.toString(16)}", + ) + + val foundKey = candidates.any { candidate -> + val s = candidate.copy() + s.lfsrRollbackWord(0u, false) + s.lfsrRollbackWord(nR, true) + s.lfsrRollbackWord(uid xor nT, false) + s.getKey() == key + } + + assertTrue(foundKey, "Key FFFFFFFFFFFF should be recoverable") + } + + @Test + fun testRecoverKeyMfkey32StyleZeroKey() { + val key = 0x000000000000L + val uid = 0x11223344u + val nT = 0x55667788u + val nR = 0xAABBCCDDu + + val state = Crypto1State() + state.loadKey(key) + state.lfsrWord(uid xor nT, false) + state.lfsrWord(nR, true) // ENCRYPTED + val ks2 = state.lfsrWord(0u, false) + + val candidates = Crypto1Recovery.lfsrRecovery32(ks2, 0u) + + assertTrue( + candidates.isNotEmpty(), + "Should find at least one candidate. ks2=0x${ks2.toString(16)}", + ) + + val foundKey = candidates.any { candidate -> + val s = candidate.copy() + s.lfsrRollbackWord(0u, false) + s.lfsrRollbackWord(nR, true) + s.lfsrRollbackWord(uid xor nT, false) + s.getKey() == key + } + + assertTrue(foundKey, "Zero key should be recoverable") + } + + @Test + fun testRecoverKeyNestedStyle() { + // Simulate nested authentication recovery. + // The keystream is generated during cipher initialization (uid^nT feeding), + // so the input parameter is uid^nT. + val key = 0xA0A1A2A3A4A5L + val uid = 0xDEADBEEFu + val nT = 0x12345678u + + // Generate keystream during init (this is what encrypts the nested nonce) + val state = Crypto1State() + state.loadKey(key) + val ks0 = state.lfsrWord(uid xor nT, false) // keystream while feeding uid^nT + + // Recovery: ks0 was generated with input=uid^nT + val candidates = Crypto1Recovery.lfsrRecovery32(ks0, uid xor nT) + + assertTrue( + candidates.isNotEmpty(), + "Should find at least one candidate for nested recovery. ks0=0x${ks0.toString(16)}", + ) + + // Per mfkey32_nested: rollback uid^nT, then get key. + val foundKey = candidates.any { candidate -> + val s = candidate.copy() + s.lfsrRollbackWord(uid xor nT, false) + s.getKey() == key + } + + // Also try direct extraction (in case the state is already at key position) + val foundKeyDirect = candidates.any { candidate -> + candidate.copy().getKey() == key + } + + assertTrue( + foundKey || foundKeyDirect, + "Key should be recoverable from nested candidates", + ) + } + + @Test + fun testRecoverKeySimple() { + // Simplest case: key -> ks (no init, no nR) + // This tests the basic recovery without any protocol overhead. + val key = 0xA0A1A2A3A4A5L + + val state = Crypto1State() + state.loadKey(key) + val ks = state.lfsrWord(0u, false) // keystream with no input + + val candidates = Crypto1Recovery.lfsrRecovery32(ks, 0u) + + assertTrue( + candidates.isNotEmpty(), + "Should find at least one candidate", + ) + + // Single rollback to undo the ks generation + val foundKey = candidates.any { candidate -> + val s = candidate.copy() + s.lfsrRollbackWord(0u, false) // undo ks + s.getKey() == key + } + + assertTrue(foundKey, "Key should be recoverable from simple ks-only case") + } + + @Test + fun testRecoverKeyWithInit() { + // Key -> init(uid^nT) -> ks + val key = 0xA0A1A2A3A4A5L + val uid = 0xDEADBEEFu + val nT = 0x12345678u + + val state = Crypto1State() + state.loadKey(key) + state.lfsrWord(uid xor nT, false) // init + val ks = state.lfsrWord(0u, false) // ks with input=0 + + val candidates = Crypto1Recovery.lfsrRecovery32(ks, 0u) + + assertTrue( + candidates.isNotEmpty(), + "Should find at least one candidate", + ) + + val foundKey = candidates.any { candidate -> + val s = candidate.copy() + s.lfsrRollbackWord(0u, false) // undo ks + s.lfsrRollbackWord(uid xor nT, false) // undo init + s.getKey() == key + } + + assertTrue(foundKey, "Key should be recoverable with init rollback") + } + + @Test + fun testNonceDistance() { + val n1 = 0x01020304u + val n2 = Crypto1.prngSuccessor(n1, 100u) + val distance = Crypto1Recovery.nonceDistance(n1, n2) + assertEquals(100u, distance, "Distance should be exactly 100 PRNG steps") + } + + @Test + fun testNonceDistanceZero() { + val n = 0xDEADBEEFu + val distance = Crypto1Recovery.nonceDistance(n, n) + assertEquals(0u, distance, "Distance from nonce to itself should be 0") + } + + @Test + fun testNonceDistanceWraparound() { + val n1 = 0xCAFEBABEu + val steps = 50000u + val n2 = Crypto1.prngSuccessor(n1, steps) + val distance = Crypto1Recovery.nonceDistance(n1, n2) + assertEquals(steps, distance, "Distance should work for large step counts within PRNG cycle") + } + + @Test + fun testNonceDistanceNotFound() { + val distance = Crypto1Recovery.nonceDistance(0u, 0x12345678u) + assertEquals( + UInt.MAX_VALUE, + distance, + "Should return UInt.MAX_VALUE for unreachable nonces", + ) + } + + @Test + fun testFilterConstraintPruning() { + // Verify that the number of candidates is reasonable (much less than 2^24). + val key = 0x123456789ABCL + val uid = 0x11223344u + val nT = 0x55667788u + val nR = 0xAABBCCDDu + + val state = Crypto1State() + state.loadKey(key) + state.lfsrWord(uid xor nT, false) + state.lfsrWord(nR, true) // ENCRYPTED + val ks2 = state.lfsrWord(0u, false) + + val candidates = Crypto1Recovery.lfsrRecovery32(ks2, 0u) + + assertTrue( + candidates.size < 100000, + "Filter constraints should produce a manageable number of candidates, got ${candidates.size}", + ) + } +} diff --git a/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/crypto1/Crypto1Test.kt b/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/crypto1/Crypto1Test.kt new file mode 100644 index 000000000..c3094e678 --- /dev/null +++ b/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/crypto1/Crypto1Test.kt @@ -0,0 +1,280 @@ +/* + * Crypto1Test.kt + * + * Copyright 2026 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.classic.crypto1 + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Tests for the Crypto1 LFSR stream cipher implementation. + * + * Reference values verified against the crapto1 C implementation by bla . + */ +class Crypto1Test { + @Test + fun testFilterFunction() { + // Verified against crapto1.h filter() compiled from C reference. + assertEquals(0, Crypto1.filter(0x00000u)) + assertEquals(0, Crypto1.filter(0x00001u)) + assertEquals(1, Crypto1.filter(0x00002u)) + assertEquals(1, Crypto1.filter(0x00003u)) + assertEquals(1, Crypto1.filter(0x00005u)) + assertEquals(0, Crypto1.filter(0x00008u)) + assertEquals(0, Crypto1.filter(0x00010u)) + assertEquals(0, Crypto1.filter(0x10000u)) + assertEquals(1, Crypto1.filter(0xFFFFFu)) + assertEquals(1, Crypto1.filter(0x12345u)) + assertEquals(1, Crypto1.filter(0xABCDEu)) + } + + @Test + fun testParity() { + // Verified against crapto1.h parity() compiled from C reference. + assertEquals(0u, Crypto1.parity(0u)) + assertEquals(1u, Crypto1.parity(1u)) + assertEquals(1u, Crypto1.parity(2u)) + assertEquals(0u, Crypto1.parity(3u)) + assertEquals(0u, Crypto1.parity(0xFFu)) + assertEquals(1u, Crypto1.parity(0x80u)) + assertEquals(0u, Crypto1.parity(0xFFFFFFFFu)) + assertEquals(1u, Crypto1.parity(0x7FFFFFFFu)) + assertEquals(0u, Crypto1.parity(0xAAAAAAAAu)) + assertEquals(0u, Crypto1.parity(0x55555555u)) + assertEquals(1u, Crypto1.parity(0x12345678u)) + } + + @Test + fun testPrngSuccessor() { + // Verified against crypto1.c prng_successor() compiled from C reference. + + // Successor of 0 should be 0 (all zero LFSR stays zero) + assertEquals(0u, Crypto1.prngSuccessor(0u, 1u)) + + // Test advancing by 0 steps returns the same value + assertEquals(0xAABBCCDDu, Crypto1.prngSuccessor(0xAABBCCDDu, 0u)) + + // Test specific known values + assertEquals(0x8b92ec40u, Crypto1.prngSuccessor(0x12345678u, 32u)) + assertEquals(0xcdd2b112u, Crypto1.prngSuccessor(0x12345678u, 64u)) + + // Test that advancing by N and then M steps equals advancing by N+M + val after32 = Crypto1.prngSuccessor(0x12345678u, 32u) + val after32Then32 = Crypto1.prngSuccessor(after32, 32u) + assertEquals(0xcdd2b112u, after32Then32) + } + + @Test + fun testPrngSuccessor64() { + // Verify suc^96(n) == suc^32(suc^64(n)) + // Verified against C reference. + val n = 0xDEADBEEFu + val suc96 = Crypto1.prngSuccessor(n, 96u) + val suc64 = Crypto1.prngSuccessor(n, 64u) + val suc32of64 = Crypto1.prngSuccessor(suc64, 32u) + assertEquals(0xe63e7417u, suc96) + assertEquals(suc96, suc32of64) + + // Also verify with a different starting value + val n2 = 0x01020304u + val suc96_2 = Crypto1.prngSuccessor(n2, 96u) + val suc64_2 = Crypto1.prngSuccessor(n2, 64u) + val suc32of64_2 = Crypto1.prngSuccessor(suc64_2, 32u) + assertEquals(suc96_2, suc32of64_2) + } + + @Test + fun testLoadKeyAndGetKey() { + // Verified against crypto1.c crypto1_create + crypto1_get_lfsr compiled from C reference. + + // All-ones key: odd=0xFFFFFF, even=0xFFFFFF + val state1 = Crypto1State() + state1.loadKey(0xFFFFFFFFFFFFL) + assertEquals(0xFFFFFFu, state1.odd) + assertEquals(0xFFFFFFu, state1.even) + assertEquals(0xFFFFFFFFFFFFL, state1.getKey()) + + // Real-world key: odd=0x33BB33, even=0x08084C + val state2 = Crypto1State() + state2.loadKey(0xA0A1A2A3A4A5L) + assertEquals(0x33BB33u, state2.odd) + assertEquals(0x08084Cu, state2.even) + assertEquals(0xA0A1A2A3A4A5L, state2.getKey()) + + // Zero key: odd=0, even=0 + val state3 = Crypto1State() + state3.loadKey(0L) + assertEquals(0u, state3.odd) + assertEquals(0u, state3.even) + assertEquals(0L, state3.getKey()) + + // Alternating bits: 0xAAAAAAAAAAAA => odd=0xFFFFFF, even=0x000000 + val state4 = Crypto1State() + state4.loadKey(0xAAAAAAAAAAAAL) + assertEquals(0xFFFFFFu, state4.odd) + assertEquals(0x000000u, state4.even) + assertEquals(0xAAAAAAAAAAAAL, state4.getKey()) + + // Alternating bits (other pattern): 0x555555555555 => odd=0x000000, even=0xFFFFFF + val state5 = Crypto1State() + state5.loadKey(0x555555555555L) + assertEquals(0x000000u, state5.odd) + assertEquals(0xFFFFFFu, state5.even) + assertEquals(0x555555555555L, state5.getKey()) + } + + @Test + fun testLfsrBit() { + // Verified against crypto1.c crypto1_bit() compiled from C reference. + // Key 0xFFFFFFFFFFFF produces all-ones odd register, and filter(0xFFFFFF) = 1. + // All 8 keystream bits should be 1 for this key with zero input. + val state = Crypto1State() + state.loadKey(0xFFFFFFFFFFFFL) + val bits = IntArray(8) { state.lfsrBit(0, false) } + for (i in 0 until 8) { + assertEquals(1, bits[i], "Keystream bit $i should be 1 for all-ones key") + } + + // Verify determinism: same key produces same keystream + val state2 = Crypto1State() + state2.loadKey(0xFFFFFFFFFFFFL) + val bits2 = IntArray(8) { state2.lfsrBit(0, false) } + for (i in 0 until 8) { + assertEquals(bits[i], bits2[i], "Keystream bit $i mismatch (determinism)") + } + } + + @Test + fun testLfsrByteConsistency() { + // lfsrByte should produce the same output as 8 calls to lfsrBit. + // Verified against C reference: lfsrByte(key=0xA0A1A2A3A4A5, input=0x5A) = 0x30 + val key = 0xA0A1A2A3A4A5L + val inputByte = 0x5A + + // Method 1: lfsrByte + val state1 = Crypto1State() + state1.loadKey(key) + val byteResult = state1.lfsrByte(inputByte, false) + assertEquals(0x30, byteResult) + + // Method 2: 8 individual lfsrBit calls + val state2 = Crypto1State() + state2.loadKey(key) + var bitResult = 0 + for (i in 0 until 8) { + bitResult = bitResult or (state2.lfsrBit((inputByte shr i) and 1, false) shl i) + } + assertEquals(byteResult, bitResult, "lfsrByte and manual lfsrBit should produce identical output") + } + + @Test + fun testLfsrWordRoundtrip() { + // Verified against C reference: word output = 0x30794609, rollback restores state. + val key = 0xA0A1A2A3A4A5L + val state = Crypto1State() + state.loadKey(key) + + val initialOdd = state.odd + val initialEven = state.even + + // Advance 32 steps + val input = 0x12345678u + val wordOutput = state.lfsrWord(input, false) + assertEquals(0x30794609u, wordOutput) + + // Roll back 32 steps + val rollbackOutput = state.lfsrRollbackWord(input, false) + assertEquals(0x30794609u, rollbackOutput) + + // State should be restored + assertEquals(initialOdd, state.odd, "Odd register not restored after rollback") + assertEquals(initialEven, state.even, "Even register not restored after rollback") + } + + @Test + fun testLfsrRollbackBitRestoresState() { + val key = 0xA0A1A2A3A4A5L + val state = Crypto1State() + state.loadKey(key) + + val initialOdd = state.odd + val initialEven = state.even + + // Advance one step + state.lfsrBit(1, false) + + // Roll back one step + state.lfsrRollbackBit(1, false) + + assertEquals(initialOdd, state.odd, "Odd not restored after single rollback") + assertEquals(initialEven, state.even, "Even not restored after single rollback") + } + + @Test + fun testSwapEndian() { + // Verified against C SWAPENDIAN macro. + assertEquals(0x78563412u, Crypto1.swapEndian(0x12345678u)) + assertEquals(0x00000000u, Crypto1.swapEndian(0x00000000u)) + assertEquals(0xFFFFFFFFu, Crypto1.swapEndian(0xFFFFFFFFu)) + assertEquals(0x04030201u, Crypto1.swapEndian(0x01020304u)) + assertEquals(0xDDCCBBAAu, Crypto1.swapEndian(0xAABBCCDDu)) + } + + @Test + fun testCopy() { + val key = 0xA0A1A2A3A4A5L + val state = Crypto1State() + state.loadKey(key) + + val copy = state.copy() + assertEquals(state.odd, copy.odd) + assertEquals(state.even, copy.even) + + // Modify original, copy should be unaffected + state.lfsrBit(0, false) + assertEquals(key, copy.getKey(), "Copy should be independent of original") + } + + @Test + fun testEncryptedMode() { + // Verified against C reference. + // In encrypted mode, the output keystream bit is XORed into feedback. + // Output keystream is the same (filter computed before feedback), but states diverge. + val key = 0xA0A1A2A3A4A5L + + val stateEnc = Crypto1State() + stateEnc.loadKey(key) + val encByte = stateEnc.lfsrByte(0x00, true) + + val stateNoEnc = Crypto1State() + stateNoEnc.loadKey(key) + val noEncByte = stateNoEnc.lfsrByte(0x00, false) + + // Output should be the same: 0x70 + assertEquals(0x70, encByte) + assertEquals(0x70, noEncByte) + assertEquals(encByte, noEncByte, "Keystream output should be same regardless of encrypted flag") + + // Internal states should differ + val encKey = stateEnc.getKey() + val noEncKey = stateNoEnc.getKey() + assertEquals(0xa1a2a3a4a5f6L, encKey) + assertEquals(0xa1a2a3a4a586L, noEncKey) + } +} diff --git a/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/crypto1/NestedAttackTest.kt b/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/crypto1/NestedAttackTest.kt new file mode 100644 index 000000000..64389d5da --- /dev/null +++ b/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/crypto1/NestedAttackTest.kt @@ -0,0 +1,340 @@ +/* + * NestedAttackTest.kt + * + * Copyright 2026 Eric Butler + * + * Tests for the MIFARE Classic nested attack orchestration. + * + * Since the full attack requires PN533 hardware, these tests focus on the + * pure-logic components: PRNG calibration, nonce data construction, and + * simulated key recovery using the Crypto1 cipher in software. + * + * 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.classic.crypto1 + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Tests for the MIFARE Classic nested attack logic. + * + * The full [NestedAttack.recoverKey] method requires a PN533 hardware device, + * so these tests verify the testable pure-logic components: + * - PRNG calibration (distance computation between consecutive nonces) + * - NestedNonceData construction + * - Simulated end-to-end key recovery using software Crypto1 + */ +class NestedAttackTest { + + /** + * Test PRNG calibration with nonces that are exactly 160 steps apart. + * + * Generates a sequence of nonces where each one is prngSuccessor(prev, 160), + * then verifies that calibratePrng returns the correct distance of 160 + * for each consecutive pair. + */ + @Test + fun testCalibratePrng() { + val startNonce = 0xCAFEBABEu + val expectedDistance = 160u + val nonces = mutableListOf() + + // Generate 15 nonces, each 160 PRNG steps from the previous + var current = startNonce + for (i in 0 until 15) { + nonces.add(current) + current = Crypto1.prngSuccessor(current, expectedDistance) + } + + val distances = NestedAttack.calibratePrng(nonces) + + // Should have 14 distances (one fewer than nonces) + assertEquals(14, distances.size, "Should have nonces.size - 1 distances") + + // All distances should be exactly 160 + for ((i, d) in distances.withIndex()) { + assertEquals( + expectedDistance, + d, + "Distance at index $i should be $expectedDistance, got $d", + ) + } + } + + /** + * Test PRNG calibration with varying distances (simulating jitter). + * + * In practice, the PRNG distance between consecutive nonces from the card + * isn't perfectly constant due to timing variations. The calibration should + * handle small variations gracefully, and the median should recover the + * dominant distance. + */ + @Test + fun testCalibratePrngWithJitter() { + val startNonce = 0x12345678u + val baseDistance = 160u + // Distances with jitter: most are 160, a few are 155 or 165 + val jitteredDistances = listOf(160u, 155u, 160u, 165u, 160u, 160u, 158u, 160u, 162u, 160u) + + val nonces = mutableListOf() + var current = startNonce + nonces.add(current) + for (d in jitteredDistances) { + current = Crypto1.prngSuccessor(current, d) + nonces.add(current) + } + + val distances = NestedAttack.calibratePrng(nonces) + + assertEquals(jitteredDistances.size, distances.size, "Should have correct number of distances") + + // Verify the computed distances match what we put in + for (i in distances.indices) { + assertEquals( + jitteredDistances[i], + distances[i], + "Distance at index $i should match input jittered distance", + ) + } + + // Verify median is the base distance (160 appears most often) + val sorted = distances.sorted() + val median = sorted[sorted.size / 2] + assertEquals(baseDistance, median, "Median distance should be the base distance $baseDistance") + } + + /** + * Test simulated nested attack key recovery entirely in software. + * + * This simulates the full nested authentication sequence: + * 1. Authenticate with a known key (software Crypto1) + * 2. Perform nested auth to get an encrypted nonce + * 3. Use the cipher state at the point of nested auth to compute keystream + * 4. XOR the encrypted nonce with keystream to get the candidate plaintext nonce + * 5. Run lfsrRecovery32 with the keystream + * 6. Roll back recovered states to extract the target key + * 7. Verify the recovered key matches the target key + */ + @Test + fun testCollectAndRecoverSimulated() { + val uid = 0xDEADBEEFu + val knownKey = 0xA0A1A2A3A4A5L + val targetKey = 0xB0B1B2B3B4B5L + val knownNT = 0x12345678u // nonce from the known-key auth + val targetNT = 0xAABBCCDDu // nonce from the target sector (the card's PRNG output) + + // Step 1: Simulate authentication with the known key. + // After auth, the cipher state is ready for encrypted communication. + val authState = Crypto1Auth.initCipher(knownKey, uid, knownNT) + // Simulate the reader nonce and response phases + val nR = 0x01020304u + Crypto1Auth.computeReaderResponse(authState, nR, knownNT) + // After computeReaderResponse, authState has been clocked through nR and aR phases + + // Step 2: Save the cipher state at the point of nested auth + val cipherStateAtNested = authState.copy() + + // Step 3: Simulate the nested auth — the card sends targetNT encrypted with + // the AUTH command keystream. In nested auth, the reader sends an encrypted AUTH + // command, and the card responds with a new nonce encrypted with the Crypto1 stream. + // + // The encrypted nonce is: targetNT XOR keystream + // where keystream comes from clocking the cipher state during nested auth processing. + // + // For the nested attack recovery, what matters is: + // - The target sector's key is used to init a NEW cipher: targetKey, uid, targetNT + // - The keystream from THAT initialization encrypts the nonce that the card sends + // + // Actually, in the real nested attack, we use a different approach: + // We know the encrypted nonce and we need to find the keystream. + // The keystream comes from the TARGET key's cipher initialization. + // + // Let's simulate what the card does: initialize cipher with targetKey and uid^targetNT + val targetCipherState = Crypto1State() + targetCipherState.loadKey(targetKey) + val ks0 = targetCipherState.lfsrWord(uid xor targetNT, false) + + // The encrypted nonce as seen by the reader + val encryptedNT = targetNT xor ks0 + + // Step 4: Recovery — we know encryptedNT and need to find targetKey. + // The keystream ks0 was generated with input = uid XOR targetNT. + // But we don't know targetNT yet... we need to predict it. + // + // In the real attack, the reader predicts targetNT from the PRNG distance. + // For this test, we just use the known targetNT directly. + val ks = encryptedNT xor targetNT // = ks0 + + // Use lfsrRecovery32 with input = uid XOR targetNT + val candidates = Crypto1Recovery.lfsrRecovery32(ks, uid xor targetNT) + + assertTrue( + candidates.isNotEmpty(), + "Should find at least one candidate state", + ) + + // Step 5: Roll back each candidate to extract the key + val recoveredKey = candidates.firstNotNullOfOrNull { candidate -> + val s = candidate.copy() + s.lfsrRollbackWord(uid xor targetNT, false) // undo the init feeding + val key = s.getKey() + if (key == targetKey) key else null + } + + assertNotNull(recoveredKey, "Should recover the target key from candidates") + assertEquals(targetKey, recoveredKey, "Recovered key should match target key") + } + + /** + * Test simulated recovery using recoverKeyFromNonces helper. + * + * This tests the Crypto1Recovery.recoverKeyFromNonces function which + * encapsulates the nested key recovery logic. + */ + @Test + fun testRecoverKeyFromNoncesSimulated() { + val uid = 0x01020304u + val targetKey = 0x112233445566L + val targetNT = 0xDEAD1234u + + // Simulate what the card does: encrypt targetNT with the target key + val targetState = Crypto1State() + targetState.loadKey(targetKey) + val ks0 = targetState.lfsrWord(uid xor targetNT, false) + val encryptedNT = targetNT xor ks0 + + // Use lfsrRecovery32 with the keystream and input = uid XOR targetNT + val candidates = Crypto1Recovery.lfsrRecovery32(ks0, uid xor targetNT) + + assertTrue(candidates.isNotEmpty(), "Should find candidates") + + // Recover key by rolling back + val foundKey = candidates.any { candidate -> + val s = candidate.copy() + s.lfsrRollbackWord(uid xor targetNT, false) + s.getKey() == targetKey + } + + assertTrue(foundKey, "Target key should be among recovered candidates") + } + + /** + * Test NestedNonceData construction. + * + * Verifies that the data class correctly stores the encrypted nonce + * and cipher state snapshot. + */ + @Test + fun testNestedNonceDataCreation() { + val encNonce = 0xAABBCCDDu + val state = Crypto1State(odd = 0x123456u, even = 0x789ABCu) + + val data = NestedAttack.NestedNonceData( + encryptedNonce = encNonce, + cipherStateAtNested = state, + ) + + assertEquals(encNonce, data.encryptedNonce, "Encrypted nonce should be stored correctly") + assertEquals(0x123456u, data.cipherStateAtNested.odd, "Cipher state odd should be preserved") + assertEquals(0x789ABCu, data.cipherStateAtNested.even, "Cipher state even should be preserved") + } + + /** + * Test that calibratePrng handles a minimal nonce list (2 nonces = 1 distance). + */ + @Test + fun testCalibratePrngMinimal() { + val n1 = 0x11223344u + val n2 = Crypto1.prngSuccessor(n1, 200u) + + val distances = NestedAttack.calibratePrng(listOf(n1, n2)) + + assertEquals(1, distances.size, "Should have 1 distance for 2 nonces") + assertEquals(200u, distances[0], "Single distance should be 200") + } + + /** + * Test that calibratePrng returns empty list for a single nonce. + */ + @Test + fun testCalibratePrngSingleNonce() { + val distances = NestedAttack.calibratePrng(listOf(0xDEADBEEFu)) + assertTrue(distances.isEmpty(), "Should return empty list for single nonce") + } + + /** + * Test that calibratePrng returns empty list for empty input. + */ + @Test + fun testCalibratePrngEmpty() { + val distances = NestedAttack.calibratePrng(emptyList()) + assertTrue(distances.isEmpty(), "Should return empty list for empty input") + } + + /** + * Test multiple simulated recoveries with different key values to ensure + * the recovery logic is robust across different key spaces. + */ + @Test + fun testRecoverMultipleKeys() { + val uid = 0xCAFEBABEu + val keysToTest = listOf( + 0x000000000000L, + 0xFFFFFFFFFFFFL, + 0xA0A1A2A3A4A5L, + 0x112233445566L, + ) + + for (targetKey in keysToTest) { + val targetNT = 0x55667788u + + val targetState = Crypto1State() + targetState.loadKey(targetKey) + val ks0 = targetState.lfsrWord(uid xor targetNT, false) + + val candidates = Crypto1Recovery.lfsrRecovery32(ks0, uid xor targetNT) + + assertTrue( + candidates.isNotEmpty(), + "Should find candidates for key 0x${targetKey.toString(16)}", + ) + + val foundKey = candidates.any { candidate -> + val s = candidate.copy() + s.lfsrRollbackWord(uid xor targetNT, false) + s.getKey() == targetKey + } + + assertTrue( + foundKey, + "Should recover key 0x${targetKey.toString(16)} from candidates", + ) + } + } + + /** + * Test companion object constants are defined correctly. + */ + @Test + fun testConstants() { + assertEquals(20, NestedAttack.CALIBRATION_ROUNDS) + assertEquals(10, NestedAttack.MIN_CALIBRATION_NONCES) + assertEquals(50, NestedAttack.COLLECTION_ROUNDS) + assertEquals(5, NestedAttack.MIN_NONCES_FOR_RECOVERY) + } +} diff --git a/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/crypto1/PN533RawClassicTest.kt b/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/crypto1/PN533RawClassicTest.kt new file mode 100644 index 000000000..021cf83d2 --- /dev/null +++ b/card/classic/src/commonTest/kotlin/com/codebutler/farebot/card/classic/crypto1/PN533RawClassicTest.kt @@ -0,0 +1,164 @@ +/* + * PN533RawClassicTest.kt + * + * Copyright 2026 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.classic.crypto1 + +import com.codebutler.farebot.card.classic.pn533.PN533RawClassic +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +/** + * Tests for [PN533RawClassic] static helper functions. + * + * These are pure unit tests that do not require real PN533 hardware. + */ +class PN533RawClassicTest { + @Test + fun testBuildAuthCommand() { + // AUTH command for key A (0x60), block 0 + val cmd = PN533RawClassic.buildAuthCommand(0x60, 0) + assertEquals(4, cmd.size, "Auth command should be 4 bytes: [keyType, block, CRC_L, CRC_H]") + assertEquals(0x60.toByte(), cmd[0], "First byte should be key type") + assertEquals(0x00.toByte(), cmd[1], "Second byte should be block index") + + // Verify CRC is correct ISO 14443-3A CRC of [0x60, 0x00] + val expectedCrc = Crypto1Auth.crcA(byteArrayOf(0x60, 0x00)) + assertEquals(expectedCrc[0], cmd[2], "CRC low byte mismatch") + assertEquals(expectedCrc[1], cmd[3], "CRC high byte mismatch") + + // AUTH command for key B (0x61), block 4 + val cmdB = PN533RawClassic.buildAuthCommand(0x61, 4) + assertEquals(0x61.toByte(), cmdB[0]) + assertEquals(0x04.toByte(), cmdB[1]) + val expectedCrcB = Crypto1Auth.crcA(byteArrayOf(0x61, 0x04)) + assertEquals(expectedCrcB[0], cmdB[2]) + assertEquals(expectedCrcB[1], cmdB[3]) + } + + @Test + fun testBuildReadCommand() { + // READ command for block 0 + val cmd = PN533RawClassic.buildReadCommand(0) + assertEquals(4, cmd.size, "Read command should be 4 bytes: [0x30, block, CRC_L, CRC_H]") + assertEquals(0x30.toByte(), cmd[0], "First byte should be MIFARE READ (0x30)") + assertEquals(0x00.toByte(), cmd[1], "Second byte should be block index") + + // Verify CRC + val expectedCrc = Crypto1Auth.crcA(byteArrayOf(0x30, 0x00)) + assertEquals(expectedCrc[0], cmd[2], "CRC low byte mismatch") + assertEquals(expectedCrc[1], cmd[3], "CRC high byte mismatch") + + // READ command for block 63 + val cmd63 = PN533RawClassic.buildReadCommand(63) + assertEquals(0x30.toByte(), cmd63[0]) + assertEquals(63.toByte(), cmd63[1]) + val expectedCrc63 = Crypto1Auth.crcA(byteArrayOf(0x30, 63)) + assertEquals(expectedCrc63[0], cmd63[2]) + assertEquals(expectedCrc63[1], cmd63[3]) + } + + @Test + fun testParseNonce() { + // 4 bytes big-endian: 0xDEADBEEF + val bytes = byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte()) + val nonce = PN533RawClassic.parseNonce(bytes) + assertEquals(0xDEADBEEFu, nonce) + + // Zero nonce + val zeroBytes = byteArrayOf(0x00, 0x00, 0x00, 0x00) + assertEquals(0u, PN533RawClassic.parseNonce(zeroBytes)) + + // Max value + val maxBytes = byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()) + assertEquals(0xFFFFFFFFu, PN533RawClassic.parseNonce(maxBytes)) + + // Verify byte order: MSB first + val ordered = byteArrayOf(0x01, 0x02, 0x03, 0x04) + assertEquals(0x01020304u, PN533RawClassic.parseNonce(ordered)) + } + + @Test + fun testBytesToUInt() { + // Same as parseNonce but using the explicit bytesToUInt method + val bytes = byteArrayOf(0x12, 0x34, 0x56, 0x78) + assertEquals(0x12345678u, PN533RawClassic.bytesToUInt(bytes)) + + val zero = byteArrayOf(0x00, 0x00, 0x00, 0x00) + assertEquals(0u, PN533RawClassic.bytesToUInt(zero)) + + val max = byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()) + assertEquals(0xFFFFFFFFu, PN533RawClassic.bytesToUInt(max)) + + // Single high byte + val highByte = byteArrayOf(0x80.toByte(), 0x00, 0x00, 0x00) + assertEquals(0x80000000u, PN533RawClassic.bytesToUInt(highByte)) + } + + @Test + fun testUintToBytes() { + val bytes = PN533RawClassic.uintToBytes(0x12345678u) + assertContentEquals(byteArrayOf(0x12, 0x34, 0x56, 0x78), bytes) + + val zero = PN533RawClassic.uintToBytes(0u) + assertContentEquals(byteArrayOf(0x00, 0x00, 0x00, 0x00), zero) + + val max = PN533RawClassic.uintToBytes(0xFFFFFFFFu) + assertContentEquals(byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()), max) + + val deadbeef = PN533RawClassic.uintToBytes(0xDEADBEEFu) + assertContentEquals( + byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte()), + deadbeef, + ) + } + + @Test + fun testUintToBytesRoundtrip() { + // Convert UInt -> bytes -> UInt should be identity + val values = listOf( + 0u, + 1u, + 0x12345678u, + 0xDEADBEEFu, + 0xFFFFFFFFu, + 0x80000000u, + 0x00000001u, + 0xCAFEBABEu, + ) + for (value in values) { + val bytes = PN533RawClassic.uintToBytes(value) + val result = PN533RawClassic.bytesToUInt(bytes) + assertEquals(value, result, "Roundtrip failed for 0x${value.toString(16)}") + } + + // Convert bytes -> UInt -> bytes should be identity + val byteArrays = listOf( + byteArrayOf(0x01, 0x02, 0x03, 0x04), + byteArrayOf(0xAB.toByte(), 0xCD.toByte(), 0xEF.toByte(), 0x01), + byteArrayOf(0x00, 0x00, 0x00, 0x00), + byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()), + ) + for (bytes in byteArrays) { + val value = PN533RawClassic.bytesToUInt(bytes) + val result = PN533RawClassic.uintToBytes(value) + assertContentEquals(bytes, result, "Roundtrip failed for byte array") + } + } +} diff --git a/card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533ClassicTechnology.kt b/card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533ClassicTechnology.kt index 567cdcf89..35699dc9f 100644 --- a/card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533ClassicTechnology.kt +++ b/card/src/commonMain/kotlin/com/codebutler/farebot/card/nfc/pn533/PN533ClassicTechnology.kt @@ -42,6 +42,22 @@ class PN533ClassicTechnology( ) : ClassicTechnology { private var connected = true + /** The underlying PN533 instance. Exposed for raw MIFARE operations (key recovery). */ + val rawPn533: PN533 get() = pn533 + + /** The card UID bytes. */ + val rawUid: ByteArray get() = uid + + /** UID as UInt (first 4 bytes, big-endian). */ + val uidAsUInt: UInt + get() { + val b = if (uid.size >= 4) uid.copyOfRange(0, 4) else uid + return ((b[0].toUInt() and 0xFFu) shl 24) or + ((b[1].toUInt() and 0xFFu) shl 16) or + ((b[2].toUInt() and 0xFFu) shl 8) or + (b[3].toUInt() and 0xFFu) + } + override fun connect() { connected = true }