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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ This project interacts with real RFID hardware. Protocol-level knowledge (card t
- **Document learnings in `docs/`** whenever working with hardware protocols reveals non-obvious behavior — byte order issues, card type quirks, recovery procedures, etc.
- Reference docs exist:
- `docs/magic-cards.md` — magic card types, block 0 format, BCC calculation, ATQA byte order, recovery procedures
- `docs/stored-value-cards.md` — how balance-on-card systems work, value-block format, full-vs-UID-only clone distinction, why hollow clones format-error, limits of directly writing a balance
- When adding support for new card types or write methods, update the relevant doc alongside the code.
- If a hardware interaction fails in an unexpected way, document the root cause and fix in the appropriate doc before moving on.

Expand Down
37 changes: 35 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ keyfabe import tags.json

# repair a bricked magic card (bad BCC/anticollision)
keyfabe repair

# decode the data on a saved MIFARE Classic dump (e.g. laundry card balance)
keyfabe inspect [name]
```

Commands that take `[name]` arguments are fully optional — when omitted, you'll get an interactive tag picker.
Expand Down Expand Up @@ -149,9 +152,15 @@ Writes a previously-saved identity to a blank tag. Without a name, presents an i

Reads whatever tag is on the antenna and compares its ID against a saved identity. Reports match, partial match (ID matches but type differs), or mismatch. Without a name, presents an interactive picker.

Pass `--deep` to go beyond the UID for MIFARE Classic identities that have a saved full-card dump: it reads the live card's value blocks with the saved keys, reports the current on-card balances, and **fails** if the card carries no data under those keys (a UID-only clone that would otherwise pass on UID alone).

### `keyfabe identify`

Reads whatever tag is on the antenna and matches it against **all** saved identities at once — no name needed. Reports which saved tags share the UID (and whether a full dump is on file for them). For MIFARE Classic cards it also probes data fidelity, distinguishing a working full clone (custom sector keys) from a **UID-only clone** (factory-default keys, empty data) that would pass `verify` on UID alone but be rejected as unformatted by a stored-value reader.

### `keyfabe list`

Lists all saved tag identities from `~/.keyfabe/tags.json`. Use `--json` for machine-readable output.
Lists all saved tag identities from `~/.keyfabe/tags.json`. Use `--json` for machine-readable output. For MIFARE Classic identities a **Data** column shows `full` (a dump is on file, so `write` restores all data) or `uid-only` (only the UID would be written — no balance/data).

### `keyfabe show [name]`

Expand All @@ -177,9 +186,29 @@ Imports tag identities from a JSON file. New names are added, existing names are

Repairs a bricked magic card that has a corrupted block 0 (bad BCC, broken anticollision). Automates the recovery process: bypasses the broken anticollision, reads the current block 0, computes and writes the correct BCC, then verifies after power-cycle. See [Magic Card Reference](docs/magic-cards.md) for details.

### `keyfabe inspect [name]`

Decodes the contents of a saved MIFARE Classic dump. Useful for inspecting cards that store value on-chip (laundry, vending, transit). Output includes:

- **Value blocks** — every block matching the MIFARE Classic value-block layout (4-byte little-endian value with bitwise-complement integrity check), decoded as a raw integer and as USD-cents (e.g. `1150 (= $11.50 if cents)`).
- **Printable strings** — ASCII runs ≥4 chars (e.g. `UINHOUSELAU` for Mitech in-house laundry systems).
- **Block dump** — all 64 (1K) or 256 (4K) blocks in hex, grouped by sector, with consecutive zero data blocks collapsed.

The dump file is located by UID — checked first at `tag.dumpFile` (set when `keyfabe clone` does a full-card clone), then `~/hf-mf-<UID>-dump.bin` (pm3's default save path), then the current directory. If no dump exists, run `keyfabe clone` to create one (cracking keys + dumping all blocks; up to ~28 min on FM11RF08S chips).

### `keyfabe value [name]`

Reads or sets a MIFARE Classic **value block** (e.g. a stored-value balance) on the card on the antenna. The sector key is taken from the named identity's saved dump, or from an explicit `--key <hex>`.

- `--block <n>` — which block to operate on (required).
- `--get` — read the current value (the default when no write flag is given).
- `--set <v>` / `--inc <v>` / `--dec <v>` — set, increment, or decrement the value (integers; cents for laundry systems).

Writes prompt for confirmation and read back the block to confirm. This is for **your own card** — systems with server reconciliation, a transaction MAC, or a monotonic counter may reject or revert a directly-written balance. See the [Stored-Value Card Reference](docs/stored-value-cards.md).

## Supported Card Types

For detailed information on magic card types, block 0 format, and recovery procedures, see the [Magic Card Reference](docs/magic-cards.md).
For detailed information on magic card types, block 0 format, and recovery procedures, see the [Magic Card Reference](docs/magic-cards.md). For how balance-on-card systems work and the full-vs-UID-only clone distinction, see the [Stored-Value Card Reference](docs/stored-value-cards.md).

| Type | Frequency | Read | Clone | Notes |
|------|-----------|------|-------|-------|
Expand Down Expand Up @@ -215,18 +244,22 @@ src/
clone.ts # guided clone flow
write.ts # write saved identity
verify.ts # verify tag against saved identity
identify.ts # match tag against all saved identities + data fidelity
list.ts # list saved identities
show.ts # show saved identity details
rename.ts # rename saved identity
delete.ts # delete saved identity
export.ts # export identities as JSON
import.ts # import identities from JSON
repair.ts # repair bricked magic cards
inspect.ts # decode saved MIFARE Classic dump (value blocks, ASCII)
value.ts # read/set a MIFARE Classic value block (balance)
lib/
pm3.ts # spawns pm3 process, sends commands
firmware.ts # build/flash subprocess helpers
parsers.ts # parse pm3 output (card type, ID, voltages)
block0.ts # MIFARE Classic block 0 utilities (BCC, builder)
mf-data.ts # MIFARE Classic dump parsing (sector layout, value blocks, ASCII)
store.ts # read/write ~/.keyfabe/tags.json
constants.ts # shared card type and pm3 command constants
card-ops.ts # search, write-and-verify logic shared by commands
Expand Down
79 changes: 79 additions & 0 deletions docs/stored-value-cards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Stored-Value MIFARE Cards (Laundry, Vending, Transit)

This document captures protocol- and system-level knowledge about MIFARE Classic cards that store a **balance on the card itself**, learned through real-world debugging with keyfabe and a Proxmark3.

## Two families of card systems

| Family | Where the balance lives | What the card must carry | Clone requirement |
|--------|-------------------------|--------------------------|-------------------|
| **Offline stored-value** | On the card, in MIFARE value blocks | UID **+ sector keys + value blocks + vendor data** | Full-card clone |
| **Online / account-based** | On a server, keyed by UID | Just the UID (sometimes a card number) | UID-only clone may suffice |

A quick way to tell which one you have: dump the card and look for **value blocks** with plausible dollar amounts (see below). If they're present and change as you spend, it's an offline stored-value system — the money is on the card.

## MIFARE value block format

A MIFARE Classic value block is a 16-byte block with a specific, self-checking layout:

```
value (4B LE) | ~value (4B LE) | value (4B LE) | addr | ~addr | addr | ~addr
```

- The value is stored three times: twice straight and once bitwise-inverted, so a reader can detect corruption.
- The trailing 4 bytes are an address and its complement, repeated.

keyfabe decodes these in `inspect` and `verify --deep` (`parseValueBlock` / `decodeValueBlockBytes` in `src/lib/mf-data.ts`). A block only counts as a value block if it passes the redundancy invariants — random data won't be misread as a balance.

Example, a real laundry card's balance block (`$2.25`):

```
E1 00 00 00 1E FF FF FF E1 00 00 00 00 FF 00 FF
└─ 0x000000E1 = 225 ─┘ └ ~225 ┘ └ 225 ┘ addr bytes
```

## Full clone vs UID-only clone — the trap

There are two very different things people call "cloning" a MIFARE card:

- **Full clone** — copy the UID **and** every sector (keys + data + value blocks). Requires cracking the card's sector keys, dumping all blocks, and restoring them to a magic card. This is what `keyfabe clone` does for MIFARE Classic, and what makes a working stored-value card.
- **UID-only clone** — copy just the UID onto a magic card, leaving factory-default keys and empty data. This is all that's needed for a UID/account-based system, but for a stored-value card it produces a **hollow card**.

A UID-only clone is dangerous precisely because it *looks* right:

| | Full clone | UID-only clone |
|---|---|---|
| UID | correct | correct |
| Sector keys | custom (operator's) | factory default `FFFFFFFFFFFF` |
| Value blocks / vendor data | present | all zero |
| `keyfabe verify` (UID only) | ✅ passes | ✅ **passes (misleading)** |
| At a stored-value reader | works | **"format error"** |

The reader authenticates its data sector with the operator's keys and expects its format there. A UID-only clone has default keys and empty blocks, so the reader can't find its structure → a generic **format error** (not "insufficient funds").

### Telling them apart

- `keyfabe identify` — reads the card, matches it against all saved identities, and probes data fidelity (`probeMifareDataFidelity`): reading a data block with the default key. A default-key rejection means custom keys → real data; an all-zero read means a blank/UID-only clone.
- `keyfabe verify --deep <name>` — for an identity with a saved dump, reads the live value blocks with the saved keys and reports the current balance; **fails** if nothing is readable under those keys.
- `keyfabe list` / `show` — flag a saved identity as `full` or `uid-only` based on whether a dump is on file.

## Why a card that "worked yesterday" can fail

If a card that genuinely worked now throws a format error and its data still looks valid, the problem usually isn't the card — the system changed its mind about it. Even a **perfect** full clone can be rejected by systems that do:

- **Originality signatures** — genuine NXP silicon answers a challenge that magic clones can't reproduce.
- **Transaction MAC / counter** — a cryptographic signature or monotonic counter over the balance; a restored older snapshot fails validation or reads as a rollback.
- **Server-side reconciliation** — a networked reader compares the card's balance/counter against the last value it recorded for that UID and rejects mismatches.

**keyfabe cannot defeat these, and shouldn't be expected to.** For stored-value/transit cards, treat a working clone as best-effort, and expect that writing an old balance back (via full restore or `keyfabe value`) may be caught by a system that tracks state off-card.

## Working with the balance directly

`keyfabe value <name> --block <n> [--get | --set X | --inc X | --dec X]` reads or writes a value block directly (wrapping pm3's `hf mf value`). It sources the sector key from the named identity's saved dump, or takes an explicit `--key`.

This is a tool for **your own card** — restoring or correcting a balance you own. On a naive offline system a `--set` sticks; on any system with the protections above it will likely be rejected or reverted. keyfabe warns and asks for confirmation before writing.

## Related

- `docs/magic-cards.md` — magic card types and how the UID/block 0 gets written.
- `src/lib/mf-data.ts` — value-block parsing, dump loading, sector-key extraction.
- `src/lib/mf-ops.ts` — key cracking, full dump/restore, live value-block reads.
90 changes: 90 additions & 0 deletions src/commands/identify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import * as p from "@clack/prompts";
import { type MifareDataFidelity, probeMifareDataFidelity, searchCardWithDiagnosis } from "../lib/card-ops.js";
import { CardType, DetectionSummary } from "../lib/constants.js";
import { printCardInfo, printDetectionHint, printDoctorHint } from "../lib/display.js";
import type { CardInfo } from "../lib/parsers.js";
import { Pm3Error, requireDevice } from "../lib/pm3.js";
import { loadTags, type Tag } from "../lib/store.js";

const MIFARE_CLASSIC_TYPES: ReadonlySet<string> = new Set([CardType.MIFARE_CLASSIC_1K, CardType.MIFARE_CLASSIC_4K]);

/** Render the matched saved identities (or lack thereof) for the card on the reader. */
function reportMatches(card: CardInfo, matches: Tag[]): void {
if (matches.length === 0) {
p.log.warn("No saved identity matches this UID — unknown or blank card.");
return;
}
const lines = matches.map((t) => {
const typeNote = t.type === card.type ? "" : ` (saved as ${t.type})`;
const dumpNote = t.dumpFile ? " [full dump on file]" : "";
return `• ${t.name}${typeNote}${dumpNote}`;
});
p.note(lines.join("\n"), matches.length === 1 ? "Matches saved identity" : "Matches saved identities");
}

/** Report what a MIFARE Classic card's sectors reveal about whether it carries real data. */
function reportFidelity(fidelity: MifareDataFidelity, matches: Tag[]): void {
switch (fidelity) {
case "custom-keys":
p.log.success("Data sectors use custom keys — this card carries real data (full clone or genuine card).");
if (matches.some((t) => t.dumpFile)) {
p.log.info("Run `keyfabe inspect <name>` to decode its saved value blocks / balance.");
}
break;
case "blank-default":
p.log.warn("Data sectors are blank with factory-default keys — a UID-only clone or unwritten card.");
p.log.warn(
"Even though the UID matches, a stored-value reader (e.g. laundry) will reject it as unformatted.",
);
break;
case "data-default":
p.log.info("Data sectors are populated but still use factory-default keys.");
break;
default:
p.log.info("Could not determine data fidelity (sector keys unknown).");
}
}

export async function identify(): Promise<boolean> {
if (!(await requireDevice())) return false;

p.intro("Identify Tag");

const spinner = p.spinner();
spinner.start("Reading tag...");

let card: CardInfo;
try {
const { card: found, diagnosis } = await searchCardWithDiagnosis();
if (!found) {
spinner.stop(DetectionSummary[diagnosis]);
p.log.error(DetectionSummary[diagnosis]);
printDetectionHint(diagnosis);
return false;
}
card = found;
spinner.stop(`Read: ${card.type} ${card.id}`);
} catch (err) {
if (err instanceof Pm3Error) {
spinner.stop(err.message);
if (err.message.includes("not found")) printDoctorHint();
} else {
spinner.stop("Failed to read tag.");
}
return false;
}

printCardInfo(card);

const tags = await loadTags();
const matches = tags.filter((t) => t.id === card.id);
reportMatches(card, matches);

if (MIFARE_CLASSIC_TYPES.has(card.type)) {
const fidelity = await probeMifareDataFidelity();
reportFidelity(fidelity, matches);
}

p.outro("Done.");
return true;
}
Loading
Loading