Skip to content

Comments

feat(classic): MIFARE Classic key recovery via PN533 nested attack#240

Open
codebutler wants to merge 14 commits intomasterfrom
classic-key-recovery
Open

feat(classic): MIFARE Classic key recovery via PN533 nested attack#240
codebutler wants to merge 14 commits intomasterfrom
classic-key-recovery

Conversation

@codebutler
Copy link
Owner

Summary

  • Implements MIFARE Classic Crypto1 cipher in pure Kotlin (faithful port of crapto1 by blapost)
  • Adds raw MIFARE Classic communication via PN533 InCommunicateThru, bypassing the chip's internal Crypto1 handling to expose raw authentication nonces
  • Implements the nested attack: when dictionary keys fail and at least one sector key is known, automatically recovers unknown sector keys by exploiting the card's weak 16-bit PRNG
  • Integrated as a transparent fallback in ClassicCardReader — works on PN533 backends (desktop USB + web WebUSB)

New files

File Description
crypto1/Crypto1.kt 48-bit LFSR cipher: filter function, PRNG, parity, state management
crypto1/Crypto1Auth.kt 3-pass mutual auth protocol, CRC-A, encrypt/decrypt
crypto1/Crypto1Recovery.kt lfsr_recovery32 — recovers LFSR states from 32-bit keystream
crypto1/NestedAttack.kt Attack orchestration: PRNG calibration → nonce collection → key recovery
pn533/PN533RawClassic.kt Raw MIFARE via InCommunicateThru with CIU register control
5 test files ~1,400 lines of tests with crapto1-verified reference values

How it works

  1. ClassicCardReader tries known keys (default, MAD, user-provided, dictionary)
  2. If all keys fail for a sector and we're on a PN533 backend and at least one other sector was read:
  3. NestedAttack authenticates with the known key, sends nested AUTH to the target sector
  4. Card responds with encrypted nonce — predictable due to weak 16-bit PRNG
  5. lfsrRecovery32 finds candidate LFSR states from the extracted keystream
  6. Candidates are verified by attempting real authentication
  7. Card reading continues with the recovered key

Platforms

  • Desktop (JVM/USB) and Web (WebUSB) — supported (both use PN533)
  • Android/iOS — not supported (no raw NFC access through platform APIs)

Test plan

  • Verify ./gradlew :card:classic:jvmTest passes (all crypto + recovery tests)
  • Test with a real MIFARE Classic card on desktop with a PN533 reader (e.g. SCL3711)
  • Verify cards with default keys still read normally (no regression)
  • Verify key recovery triggers when a sector has a non-default key and another sector was readable
  • Test with MIFARE Classic 1K and 4K cards

🤖 Generated with Claude Code

Base automatically changed from flipper to master February 22, 2026 05:34
Claude and others added 7 commits February 22, 2026 10:26
Faithful port of the crapto1 reference implementation by blapost. Implements
the 48-bit LFSR cipher used in MIFARE Classic cards, including the nonlinear
filter function, PRNG successor, key load/extract, forward and rollback
clocking, and encrypted mode support. All test vectors verified against
compiled C reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implement MIFARE Classic three-pass mutual authentication handshake
using the Crypto1 cipher: initCipher, computeReaderResponse,
verifyCardResponse, encryptBytes, decryptBytes, and ISO 14443-3A
CRC-A computation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…teThru

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…stream)

Implement LFSR state recovery from 32-bit keystream, ported faithfully from
Proxmark3's crapto1 lfsr_recovery32(). The algorithm splits keystream into
odd/even bits, builds filter-consistent tables, extends them from 20 to 24
bits, then recursively extends with contribution tracking and bucket-sort
intersection to find matching state pairs.

Key implementation details:
- extendTableSimple: in-place table extension for initial 20->24 bit phase
- extendTable: new-array approach with contribution bit tracking
- recover: recursive extension with bucket-sort intersection (replaces
  mfcuk's buggy quicksort/binsearch merge)
- Input parameter transformation matching C: byte-swap and left-shift
- nonceDistance and recoverKeyFromNonces helper functions

Tests verify end-to-end key recovery using:
- mfkey32 attack pattern (ks2 with input=0, encrypted nR rollback)
- Nested attack pattern (ks0 with input=uid^nT)
- Simple and init-only recovery scenarios
- Nonce distance computation
- Filter constraint pruning (candidate count sanity check)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… recovery

Implements NestedAttack class that coordinates the three-phase key recovery
process: PRNG calibration, encrypted nonce collection via nested authentication,
and key recovery using LFSR state recovery. Tests cover the pure-logic components
(PRNG calibration, simulated key recovery) since the full attack requires PN533
hardware.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…eader

Wire the MIFARE Classic nested attack into the card reading flow as a
fallback when all dictionary-based authentication methods fail. When
using a PN533 backend and at least one sector key is already known,
the reader now attempts key recovery via the Crypto1 nested attack
before giving up on a sector.

Changes:
- PN533ClassicTechnology: expose rawPn533, rawUid, and uidAsUInt
  properties so card/classic can construct PN533RawClassic directly
  (avoids circular dependency between card and card/classic modules)
- ClassicCardReader: track successful keys in recoveredKeys map, attempt
  nested attack after global dictionary keys fail, add keyBytesToLong
  and longToKeyBytes helper functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…overy status

Thread an onProgress callback through ClassicCardReader.readCard so the
UI can report nested attack key recovery status. The desktop PN53x backend
prints progress messages to the console. The parameter defaults to null
so existing callers are unaffected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude and others added 7 commits February 22, 2026 14:21
…nsolidate hex utils

- Add hasUnauthorizedSectors() to RawClassicCard for partial Classic auth detection
- Fix AndroidCardScanner to throw CardUnauthorizedException on partially-locked Classic cards
- Wire KeyManagerPlugin (card keys, global keys, key recovery) into Desktop and Web scanners
- Pass KeyManagerPlugin through DesktopCardScanner → PN53x/PcscReaderBackend chain
- Pass KeyManagerPlugin through WebCardScanner for WebUSB Classic card reading
- Update DesktopAppGraph and WebAppGraph DI to inject KeyManagerPlugin into scanners
- Consolidate all duplicate ByteArray.hex() / HEX_CHARS / hexByte() into base/util
- Standardize hex output to uppercase across the codebase
- Replace all direct ByteUtils.getHexString() calls with ByteArray.hex() extension
- Remove redundant .uppercase() calls now that hex() returns uppercase

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Resolve conflicts between key recovery wiring and card reading progress
bottom sheet feature (#243). Key changes in resolution:
- ClassicCardReader.readCard uses master's (Int, Int) onProgress signature
- Key recovery progress messages use println instead of onProgress
- All scanners wire both key recovery (our branch) and progress (master)
- WebUsbPN533Transport device-disconnection checks use error field

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… gracefully

Existing databases created before the key manager feature was added
don't have the global_keys table, causing SQLiteException when
scanning MIFARE Classic cards. Add SQLDelight migration (1.sqm)
to create the table on existing databases, and catch exceptions
in KeyManagerPluginImpl.getGlobalKeys() so card reading continues
even if the table lookup fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… fails

HashUtils.checkKeyHash() was producing uppercase hex digests after the
HEX_CHARS change but all precomputed hash constants are lowercase.

CardViewModel now falls back to parseTransitIdentity() when
parseTransitInfo() returns null (e.g. locked Classic sectors), so
recognized cards show their actual name (e.g. "OV-chipkaart") instead
of "MifareClassic (Unrecognized)".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ads in OVChip check

PN533RawClassic.requestAuth() was disabling parity before sending the
plaintext AUTH command. ISO 14443-3A requires standard parity for the
initial authentication exchange — only the encrypted Crypto1 response
needs software parity. This caused all calibration nonces to fail,
triggering CardLostException and aborting the read with only 2 sectors.

Also fix OVChipTransitFactory.check() to accept partial reads (where
sectors.size < 40 due to early abort), since the identifying header in
sector 0 block 1 is sufficient for detection regardless of how many
sectors were read.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tion

After an incomplete MIFARE Classic authentication (requestAuth() collects
the nonce but doesn't complete the 3-pass handshake), the card enters
HALT state and won't respond to subsequent commands. restoreNormalMode()
only reset the PN533's CIU registers but didn't reset the card itself.

Add reselectCard() to PN533RawClassic that cycles the RF field (off/on)
and re-selects the card via InListPassiveTarget, matching the approach
already used in PN533ClassicTechnology. Use it in NestedAttack's
calibration loop, collection loop, and key verification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ine key recovery

Three fixes:

1. CardUnauthorizedException extended Throwable directly, but the catch
   clause in PN53xReaderBackend catches Exception. The exception escaped
   uncaught, killing the coroutine thread. The "reading" sheet stayed up
   forever and no error dialog appeared. Fix: extend Exception.

2. Remove inline key recovery from home screen scan. Key recovery should
   happen on the dedicated key recovery screen, not during the initial
   card read. This avoids the slow nested attack blocking the scan UI.

3. Fix reselectCard() in PN533RawClassic to not cycle RF field. Instead,
   wait for the card's auth timeout then InRelease + InListPassiveTarget.
   This keeps the card powered so the PRNG continues running — required
   for PRNG distance calibration in the nested attack.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant