From 884c8c596285d293687c53239c7ff978a8c0ab68 Mon Sep 17 00:00:00 2001 From: Eugene Dobry Date: Thu, 30 Apr 2026 00:44:02 -0400 Subject: [PATCH 1/5] feat: add `inspect` command to decode saved MIFARE Classic dumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decodes value blocks (laundry/vending balances, with USD-cents interpretation), surfaces printable ASCII markers, and emits a compact hex dump with zero-runs collapsed. Locates dumps by UID via tag.dumpFile, ~/hf-mf--dump.bin, or CWD — so existing tags saved before the dumpFile field will still work. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 15 ++ src/commands/inspect.ts | 156 +++++++++++++++++++ src/index.ts | 10 ++ src/lib/mf-data.ts | 151 ++++++++++++++++++ tests/commands/inspect.test.ts | 166 ++++++++++++++++++++ tests/lib/mf-data.test.ts | 271 +++++++++++++++++++++++++++++++++ 6 files changed, 769 insertions(+) create mode 100644 src/commands/inspect.ts create mode 100644 src/lib/mf-data.ts create mode 100644 tests/commands/inspect.test.ts create mode 100644 tests/lib/mf-data.test.ts diff --git a/README.md b/README.md index dc3261d..7201da8 100644 --- a/README.md +++ b/README.md @@ -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. @@ -177,6 +180,16 @@ 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--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). + ## 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). @@ -222,11 +235,13 @@ src/ 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) 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 diff --git a/src/commands/inspect.ts b/src/commands/inspect.ts new file mode 100644 index 0000000..6febc93 --- /dev/null +++ b/src/commands/inspect.ts @@ -0,0 +1,156 @@ +import * as p from "@clack/prompts"; +import { CardType } from "../lib/constants.js"; +import { printNoSavedTags, printTagNotFound } from "../lib/display.js"; +import { + type AsciiRun, + findAsciiStrings, + findValueBlocks, + loadDumpFile, + locateDumpFile, + type MfBlock, + type MfDump, + type ValueBlock, +} from "../lib/mf-data.js"; +import { selectTag } from "../lib/prompts.js"; +import { getTag, loadTags, type Tag } from "../lib/store.js"; + +const MIFARE_CLASSIC_TYPES: ReadonlySet = new Set([CardType.MIFARE_CLASSIC_1K, CardType.MIFARE_CLASSIC_4K]); + +function formatValue(value: number): string { + const dollars = (value / 100).toFixed(2); + return `${value} (= $${dollars} if cents)`; +} + +function formatHex(bytes: Buffer): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0").toUpperCase()) + .join(" "); +} + +function isZeroBlock(block: MfBlock): boolean { + return block.bytes.every((b) => b === 0); +} + +/** Render a compact block dump: collapse consecutive zero data blocks. */ +function renderBlocks(dump: MfDump): string { + const lines: string[] = []; + let lastSector = -1; + let zeroRunStart = -1; + const flushZeros = (endBlock: number) => { + if (zeroRunStart < 0) return; + if (zeroRunStart === endBlock) { + lines.push(`${String(zeroRunStart).padStart(3)}: ${"00 ".repeat(16).trim()}`); + } else { + lines.push(`${String(zeroRunStart).padStart(3)}-${String(endBlock).padStart(3)}: (all zero)`); + } + zeroRunStart = -1; + }; + for (const block of dump.blocks) { + if (block.sector !== lastSector) { + flushZeros(block.index - 1); + lines.push(`-- sector ${block.sector} --`); + lastSector = block.sector; + } + if (!block.isTrailer && isZeroBlock(block)) { + if (zeroRunStart < 0) zeroRunStart = block.index; + continue; + } + flushZeros(block.index - 1); + const suffix = block.isTrailer ? " (trailer)" : ""; + lines.push(`${String(block.index).padStart(3)}: ${formatHex(block.bytes)}${suffix}`); + } + flushZeros(dump.blocks[dump.blocks.length - 1].index); + return lines.join("\n"); +} + +function renderValueBlocks(values: ValueBlock[]): string { + const header = "block sector value"; + const rows = values.map( + (v) => `${String(v.blockIndex).padStart(5)} ${String(v.sector).padStart(6)} ${formatValue(v.value)}`, + ); + return [header, ...rows].join("\n"); +} + +function renderAsciiRuns(runs: AsciiRun[]): string { + return runs + .map((r) => `block ${String(r.blockIndex).padStart(3)} @${String(r.offset).padStart(2)}: ${r.text}`) + .join("\n"); +} + +export async function inspect(name?: string): Promise { + const tags = await loadTags(); + if (tags.length === 0) { + printNoSavedTags(); + return false; + } + + let target: Tag | undefined; + if (name) { + target = await getTag(name); + if (!target) { + printTagNotFound(name); + return false; + } + } else { + const candidates = tags.filter((t) => MIFARE_CLASSIC_TYPES.has(t.type)); + if (candidates.length === 0) { + p.log.warn("No saved MIFARE Classic tags. Inspection requires a Classic dump."); + return false; + } + const picked = await selectTag(candidates, "Which tag to inspect?"); + target = await getTag(picked); + if (!target) { + printTagNotFound(picked); + return false; + } + } + + if (!MIFARE_CLASSIC_TYPES.has(target.type)) { + p.log.warn(`"${target.name}" is ${target.type} — only MIFARE Classic dumps can be inspected.`); + return false; + } + + const dumpPath = await locateDumpFile(target.id, target.dumpFile); + if (!dumpPath) { + p.log.error(`No dump file found for ${target.id}.`); + p.log.info(`Looked for hf-mf-${target.id}-dump.bin in ~ and the current directory.`); + p.log.info("Run `keyfabe clone` to create one (it will crack keys and dump all blocks)."); + return false; + } + + let dump: MfDump; + try { + dump = await loadDumpFile(dumpPath); + } catch (err) { + p.log.error(`Failed to read dump: ${(err as Error).message}`); + return false; + } + + const valueBlocks = findValueBlocks(dump); + const asciiRuns = findAsciiStrings(dump, 4); + + p.note( + [ + `Name: ${target.name}`, + `Type: ${target.type}`, + `UID: ${target.id}`, + `Dump: ${dumpPath}`, + `Size: ${dump.sizeBytes} bytes (${dump.blocks.length} blocks)`, + ].join("\n"), + "Tag", + ); + + if (valueBlocks.length > 0) { + p.note(renderValueBlocks(valueBlocks), "Value Blocks (likely balances/counters)"); + } else { + p.log.info("No MIFARE value blocks detected."); + } + + if (asciiRuns.length > 0) { + p.note(renderAsciiRuns(asciiRuns), "Printable strings"); + } + + p.note(renderBlocks(dump), "Blocks"); + + return true; +} diff --git a/src/index.ts b/src/index.ts index 8deef46..4d4ce39 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { deleteTag } from "./commands/delete.js"; import { doctor } from "./commands/doctor.js"; import { exportTags } from "./commands/export.js"; import { importFile } from "./commands/import.js"; +import { inspect } from "./commands/inspect.js"; import { list } from "./commands/list.js"; import { read } from "./commands/read.js"; import { rename } from "./commands/rename.js"; @@ -44,6 +45,7 @@ program.action( { value: "read", label: "Read a tag", hint: "identify and save" }, { value: "write", label: "Write a saved identity", hint: "write to blank tag" }, { value: "verify", label: "Verify a tag", hint: "compare to saved identity" }, + { value: "inspect", label: "Inspect tag data", hint: "decode value blocks (e.g. laundry balance)" }, { value: "list", label: "List saved tags" }, { value: "doctor", label: "Health check", hint: "diagnose device" }, { value: "setup", label: "Firmware setup", hint: "flash Iceman firmware" }, @@ -64,6 +66,8 @@ program.action( return write(); case "verify": return verify(); + case "inspect": + return inspect(); case "list": return list(); case "doctor": @@ -145,4 +149,10 @@ program .description("Repair a bricked magic card with corrupted block 0 (bad BCC)") .action(withExitCode(repair)); +program + .command("inspect") + .description("Decode the data on a saved MIFARE Classic tag's dump (value blocks, ASCII strings, hex)") + .argument("[name]", "name of the saved tag identity") + .action(withExitCode(inspect)); + program.parse(); diff --git a/src/lib/mf-data.ts b/src/lib/mf-data.ts new file mode 100644 index 0000000..49cb0ce --- /dev/null +++ b/src/lib/mf-data.ts @@ -0,0 +1,151 @@ +import { access, readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export const MF_BLOCK_SIZE = 16; +export const MF_1K_BLOCKS = 64; +export const MF_4K_BLOCKS = 256; +export const MF_1K_SIZE = MF_1K_BLOCKS * MF_BLOCK_SIZE; +export const MF_4K_SIZE = MF_4K_BLOCKS * MF_BLOCK_SIZE; + +export interface MfBlock { + index: number; + sector: number; + isTrailer: boolean; + bytes: Buffer; +} + +export interface MfDump { + blocks: MfBlock[]; + sizeBytes: number; +} + +export interface ValueBlock { + blockIndex: number; + sector: number; + value: number; + addr: number; +} + +export interface AsciiRun { + blockIndex: number; + offset: number; + text: string; +} + +/** MIFARE Classic 1K has 16 sectors of 4 blocks. 4K has 32×4 + 8×16 sectors. */ +export function sectorOf(block: number): number { + if (block < 128) return Math.floor(block / 4); + return 32 + Math.floor((block - 128) / 16); +} + +export function isSectorTrailer(block: number): boolean { + if (block < 128) return block % 4 === 3; + return (block - 128) % 16 === 15; +} + +export function parseMfDump(buf: Buffer): MfDump { + if (buf.length !== MF_1K_SIZE && buf.length !== MF_4K_SIZE) { + throw new Error(`Unexpected dump size: ${buf.length} bytes (expected ${MF_1K_SIZE} or ${MF_4K_SIZE})`); + } + const blocks: MfBlock[] = []; + const totalBlocks = buf.length / MF_BLOCK_SIZE; + for (let i = 0; i < totalBlocks; i++) { + const start = i * MF_BLOCK_SIZE; + blocks.push({ + index: i, + sector: sectorOf(i), + isTrailer: isSectorTrailer(i), + bytes: buf.subarray(start, start + MF_BLOCK_SIZE), + }); + } + return { blocks, sizeBytes: buf.length }; +} + +/** + * MIFARE Classic value block layout: + * value (4B LE) | ~value (4B LE) | value (4B LE) | addr | ~addr | addr | ~addr + * + * Returns null if the block does not satisfy the integrity invariants. + * Skips trailers (which contain keys, not data). + */ +export function parseValueBlock(block: MfBlock): ValueBlock | null { + if (block.isTrailer) return null; + const b = block.bytes; + const v1 = b.readInt32LE(0); + const v2 = b.readInt32LE(4); + const v3 = b.readInt32LE(8); + const a1 = b.readUInt8(12); + const a2 = b.readUInt8(13); + const a3 = b.readUInt8(14); + const a4 = b.readUInt8(15); + + const valueOk = v1 === v3 && (~v1 | 0) === v2; + const addrOk = a1 === a3 && a2 === a4 && a1 === (~a2 & 0xff); + if (!valueOk || !addrOk) return null; + + return { blockIndex: block.index, sector: block.sector, value: v1, addr: a1 }; +} + +export function findValueBlocks(dump: MfDump): ValueBlock[] { + const out: ValueBlock[] = []; + for (const block of dump.blocks) { + const vb = parseValueBlock(block); + if (vb) out.push(vb); + } + return out; +} + +/** Find printable ASCII runs of length >= minLength. Skips sector trailers. */ +export function findAsciiStrings(dump: MfDump, minLength = 4): AsciiRun[] { + const runs: AsciiRun[] = []; + for (const block of dump.blocks) { + if (block.isTrailer) continue; + const b = block.bytes; + let start = -1; + for (let i = 0; i <= b.length; i++) { + const ch = i < b.length ? b[i] : 0; + const printable = ch >= 0x20 && ch <= 0x7e; + if (printable) { + if (start < 0) start = i; + } else { + if (start >= 0 && i - start >= minLength) { + runs.push({ + blockIndex: block.index, + offset: start, + text: b.subarray(start, i).toString("ascii"), + }); + } + start = -1; + } + } + } + return runs; +} + +/** + * Locate a dump file for the given UID. Search order: + * 1. explicitPath (from tag.dumpFile, if set) + * 2. ~/hf-mf--dump.bin (pm3 default save path) + * 3. CWD/hf-mf--dump.bin + */ +export async function locateDumpFile(uid: string, explicitPath?: string): Promise { + const candidates: string[] = []; + if (explicitPath) candidates.push(explicitPath); + candidates.push(join(homedir(), `hf-mf-${uid}-dump.bin`)); + candidates.push(join(process.cwd(), `hf-mf-${uid}-dump.bin`)); + for (const path of candidates) { + try { + await access(path); + return path; + } catch { + // try next + } + } + return null; +} + +export async function loadDumpFile(path: string): Promise { + const buf = await readFile(path); + return parseMfDump(buf); +} diff --git a/tests/commands/inspect.test.ts b/tests/commands/inspect.test.ts new file mode 100644 index 0000000..5f873fe --- /dev/null +++ b/tests/commands/inspect.test.ts @@ -0,0 +1,166 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mockClack, setupBeforeEach } from "../helpers/mocks.js"; + +mockClack(); + +vi.mock("../../src/lib/store.js", () => ({ + getTag: vi.fn(), + loadTags: vi.fn(), +})); + +vi.mock("../../src/lib/prompts.js", () => ({ + selectTag: vi.fn(), +})); + +import * as p from "@clack/prompts"; +import { inspect } from "../../src/commands/inspect.js"; +import { MF_1K_SIZE } from "../../src/lib/mf-data.js"; +import { selectTag } from "../../src/lib/prompts.js"; +import { getTag, loadTags } from "../../src/lib/store.js"; + +const mockGetTag = vi.mocked(getTag); +const mockLoadTags = vi.mocked(loadTags); +const mockSelectTag = vi.mocked(selectTag); +const mockNote = vi.mocked(p.note); +const mockLogWarn = vi.mocked(p.log.warn); +const mockLogError = vi.mocked(p.log.error); +const mockLogInfo = vi.mocked(p.log.info); + +beforeEach(() => { + setupBeforeEach(); +}); + +/** Build a 1K dump with sector 4 holding three value blocks: 1150, 0, 2000. */ +function buildLaundryDump(): Buffer { + const buf = Buffer.alloc(MF_1K_SIZE); + const writeValueBlock = (idx: number, value: number) => { + const off = idx * 16; + buf.writeInt32LE(value, off); + buf.writeInt32LE(~value | 0, off + 4); + buf.writeInt32LE(value, off + 8); + buf.writeUInt8(0, off + 12); + buf.writeUInt8(0xff, off + 13); + buf.writeUInt8(0, off + 14); + buf.writeUInt8(0xff, off + 15); + }; + // Block 1: "UINHOUSELAU" + Buffer.from("UINHOUSELAU").copy(buf, 16); + writeValueBlock(16, 1150); + writeValueBlock(17, 0); + writeValueBlock(18, 2000); + return buf; +} + +describe("inspect", () => { + let dir: string; + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "inspect-")); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("no saved tags → false", async () => { + mockLoadTags.mockResolvedValue([]); + expect(await inspect()).toBe(false); + expect(mockLogWarn).toHaveBeenCalled(); + }); + + it("named tag not found → false", async () => { + mockLoadTags.mockResolvedValue([ + { name: "other", type: "MIFARE Classic 1K", id: "AAAA", savedAt: "2024-01-01T00:00:00.000Z" }, + ]); + mockGetTag.mockResolvedValue(undefined); + expect(await inspect("missing")).toBe(false); + expect(mockLogError).toHaveBeenCalledWith(expect.stringContaining("missing")); + }); + + it("non-Classic tag → warns and returns false", async () => { + const tag = { name: "fob", type: "EM410x", id: "1A2B3C4D5E", savedAt: "2024-01-01T00:00:00.000Z" }; + mockLoadTags.mockResolvedValue([tag]); + mockGetTag.mockResolvedValue(tag); + expect(await inspect("fob")).toBe(false); + expect(mockLogWarn).toHaveBeenCalledWith(expect.stringContaining("only MIFARE Classic")); + }); + + it("no Classic tags in interactive picker → warns and returns false", async () => { + mockLoadTags.mockResolvedValue([ + { name: "fob", type: "EM410x", id: "1A2B3C4D5E", savedAt: "2024-01-01T00:00:00.000Z" }, + ]); + expect(await inspect()).toBe(false); + expect(mockSelectTag).not.toHaveBeenCalled(); + expect(mockLogWarn).toHaveBeenCalledWith(expect.stringContaining("No saved MIFARE Classic")); + }); + + it("dump file missing → reports clear error", async () => { + const tag = { + name: "laundry", + type: "MIFARE Classic 1K", + id: "FFFFFFFF", + savedAt: "2024-01-01T00:00:00.000Z", + }; + mockLoadTags.mockResolvedValue([tag]); + mockGetTag.mockResolvedValue(tag); + expect(await inspect("laundry")).toBe(false); + expect(mockLogError).toHaveBeenCalledWith(expect.stringContaining("FFFFFFFF")); + expect(mockLogInfo).toHaveBeenCalledWith(expect.stringContaining("keyfabe clone")); + }); + + it("decodes value blocks and ASCII strings from a real dump", async () => { + const dumpPath = join(dir, "laundry.bin"); + await writeFile(dumpPath, buildLaundryDump()); + const tag = { + name: "494 laundry", + type: "MIFARE Classic 1K", + id: "815498C5", + dumpFile: dumpPath, + savedAt: "2024-01-01T00:00:00.000Z", + }; + mockLoadTags.mockResolvedValue([tag]); + mockGetTag.mockResolvedValue(tag); + + expect(await inspect("494 laundry")).toBe(true); + + const noteCalls = mockNote.mock.calls.map(([content, title]) => ({ content: String(content), title })); + const tagNote = noteCalls.find((n) => n.title === "Tag"); + const valueNote = noteCalls.find((n) => n.title?.toString().startsWith("Value")); + const asciiNote = noteCalls.find((n) => n.title === "Printable strings"); + const blocksNote = noteCalls.find((n) => n.title === "Blocks"); + + expect(tagNote?.content).toContain("815498C5"); + expect(tagNote?.content).toContain("MIFARE Classic 1K"); + + expect(valueNote?.content).toContain("1150"); + expect(valueNote?.content).toContain("$11.50"); + expect(valueNote?.content).toContain("2000"); + expect(valueNote?.content).toContain("$20.00"); + + expect(asciiNote?.content).toContain("UINHOUSELAU"); + + expect(blocksNote?.content).toContain("sector 4"); + expect(blocksNote?.content).toContain("(all zero)"); + }); + + it("interactive picker filters to MIFARE Classic tags only", async () => { + const dumpPath = join(dir, "pick.bin"); + await writeFile(dumpPath, buildLaundryDump()); + const lf = { name: "fob", type: "EM410x", id: "1A2B3C4D5E", savedAt: "2024-01-01T00:00:00.000Z" }; + const hf = { + name: "laundry", + type: "MIFARE Classic 1K", + id: "815498C5", + dumpFile: dumpPath, + savedAt: "2024-01-01T00:00:00.000Z", + }; + mockLoadTags.mockResolvedValue([lf, hf]); + mockSelectTag.mockResolvedValue("laundry"); + mockGetTag.mockResolvedValue(hf); + + expect(await inspect()).toBe(true); + const offered = mockSelectTag.mock.calls[0][0]; + expect(offered).toEqual([hf]); + }); +}); diff --git a/tests/lib/mf-data.test.ts b/tests/lib/mf-data.test.ts new file mode 100644 index 0000000..1315267 --- /dev/null +++ b/tests/lib/mf-data.test.ts @@ -0,0 +1,271 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + findAsciiStrings, + findValueBlocks, + isSectorTrailer, + loadDumpFile, + locateDumpFile, + MF_1K_BLOCKS, + MF_1K_SIZE, + MF_4K_SIZE, + parseMfDump, + parseValueBlock, + sectorOf, +} from "../../src/lib/mf-data.js"; + +/** Build a MIFARE Classic value block for `value` and `addr`. */ +function makeValueBlock(value: number, addr = 0): Buffer { + const b = Buffer.alloc(16); + b.writeInt32LE(value, 0); + b.writeInt32LE(~value | 0, 4); + b.writeInt32LE(value, 8); + b.writeUInt8(addr, 12); + b.writeUInt8(~addr & 0xff, 13); + b.writeUInt8(addr, 14); + b.writeUInt8(~addr & 0xff, 15); + return b; +} + +function makeBlock(...bytes: number[]): Buffer { + return Buffer.from(bytes); +} + +/** Construct a 1K dump where the caller can inject specific blocks by index. */ +function build1KDump(overrides: Record = {}): Buffer { + const buf = Buffer.alloc(MF_1K_SIZE); + for (const [idx, blk] of Object.entries(overrides)) { + const i = parseInt(idx, 10); + blk.copy(buf, i * 16); + } + return buf; +} + +describe("sectorOf / isSectorTrailer", () => { + it("maps 1K blocks to 16 sectors of 4 blocks", () => { + expect(sectorOf(0)).toBe(0); + expect(sectorOf(3)).toBe(0); + expect(sectorOf(4)).toBe(1); + expect(sectorOf(63)).toBe(15); + }); + + it("flags every 4th block as a sector trailer in 1K range", () => { + expect(isSectorTrailer(3)).toBe(true); + expect(isSectorTrailer(7)).toBe(true); + expect(isSectorTrailer(63)).toBe(true); + expect(isSectorTrailer(0)).toBe(false); + expect(isSectorTrailer(16)).toBe(false); + }); + + it("handles 4K extended sectors (16-block sectors past block 128)", () => { + expect(sectorOf(128)).toBe(32); + expect(sectorOf(143)).toBe(32); + expect(sectorOf(144)).toBe(33); + expect(isSectorTrailer(143)).toBe(true); + expect(isSectorTrailer(159)).toBe(true); + expect(isSectorTrailer(128)).toBe(false); + }); +}); + +describe("parseMfDump", () => { + it("parses a 1K dump into 64 blocks", () => { + const dump = parseMfDump(Buffer.alloc(MF_1K_SIZE)); + expect(dump.sizeBytes).toBe(MF_1K_SIZE); + expect(dump.blocks).toHaveLength(MF_1K_BLOCKS); + expect(dump.blocks[3].isTrailer).toBe(true); + }); + + it("parses a 4K dump", () => { + const dump = parseMfDump(Buffer.alloc(MF_4K_SIZE)); + expect(dump.sizeBytes).toBe(MF_4K_SIZE); + expect(dump.blocks).toHaveLength(256); + }); + + it("rejects unexpected sizes", () => { + expect(() => parseMfDump(Buffer.alloc(512))).toThrow(/Unexpected dump size/); + }); +}); + +describe("parseValueBlock", () => { + it("decodes a valid value block", () => { + // Block 16 from the real laundry card dump: value=1150, addr=0 + const buf = build1KDump({ + 16: makeBlock( + 0x7e, + 0x04, + 0x00, + 0x00, + 0x81, + 0xfb, + 0xff, + 0xff, + 0x7e, + 0x04, + 0x00, + 0x00, + 0x00, + 0xff, + 0x00, + 0xff, + ), + }); + const dump = parseMfDump(buf); + const vb = parseValueBlock(dump.blocks[16]); + expect(vb).toEqual({ blockIndex: 16, sector: 4, value: 1150, addr: 0 }); + }); + + it("decodes value=2000 (laundry block 18)", () => { + const buf = build1KDump({ + 18: makeBlock( + 0xd0, + 0x07, + 0x00, + 0x00, + 0x2f, + 0xf8, + 0xff, + 0xff, + 0xd0, + 0x07, + 0x00, + 0x00, + 0x00, + 0xff, + 0x00, + 0xff, + ), + }); + const dump = parseMfDump(buf); + const vb = parseValueBlock(dump.blocks[18]); + expect(vb?.value).toBe(2000); + }); + + it("recognizes value=0 with valid integrity", () => { + const buf = build1KDump({ + 17: makeBlock( + 0x00, + 0x00, + 0x00, + 0x00, + 0xff, + 0xff, + 0xff, + 0xff, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0xff, + 0x00, + 0xff, + ), + }); + const dump = parseMfDump(buf); + expect(parseValueBlock(dump.blocks[17])?.value).toBe(0); + }); + + it("rejects blocks that fail value integrity", () => { + const buf = build1KDump({ 16: Buffer.alloc(16, 0x42) }); + const dump = parseMfDump(buf); + expect(parseValueBlock(dump.blocks[16])).toBeNull(); + }); + + it("rejects sector trailers even if data looks like a value block", () => { + const buf = build1KDump({ 3: makeValueBlock(42) }); + const dump = parseMfDump(buf); + expect(parseValueBlock(dump.blocks[3])).toBeNull(); + }); +}); + +describe("findValueBlocks", () => { + it("finds all three value blocks in the laundry sector 4", () => { + const buf = build1KDump({ + 16: makeValueBlock(1150), + 17: makeValueBlock(0), + 18: makeValueBlock(2000), + }); + const found = findValueBlocks(parseMfDump(buf)); + expect(found.map((v) => v.value)).toEqual([1150, 0, 2000]); + expect(found.every((v) => v.sector === 4)).toBe(true); + }); +}); + +describe("findAsciiStrings", () => { + it("extracts the UINHOUSELAU marker from block 1", () => { + // 55 49 4E 48 4F 55 53 45 4C 41 55 = "UINHOUSELAU" + const buf = build1KDump({ + 1: makeBlock(0x55, 0x49, 0x4e, 0x48, 0x4f, 0x55, 0x53, 0x45, 0x4c, 0x41, 0x55, 0, 0, 0, 0x06, 0x08), + }); + const runs = findAsciiStrings(parseMfDump(buf), 4); + expect(runs).toHaveLength(1); + expect(runs[0].text).toBe("UINHOUSELAU"); + expect(runs[0].blockIndex).toBe(1); + }); + + it("ignores runs shorter than minLength", () => { + const buf = build1KDump({ + 1: makeBlock(0x41, 0x42, 0, 0x43, 0x44, 0x45, 0x46, 0x47, 0, 0, 0, 0, 0, 0, 0, 0), + }); + const runs = findAsciiStrings(parseMfDump(buf), 4); + expect(runs.map((r) => r.text)).toEqual(["CDEFG"]); + }); + + it("does not scan sector trailers (they hold keys, not text)", () => { + // Block 3 is a trailer; even if it has ASCII bytes, skip it. + const buf = build1KDump({ + 3: makeBlock( + 0x41, + 0x42, + 0x43, + 0x44, + 0x45, + 0x46, + 0xff, + 0x07, + 0x80, + 0x69, + 0x47, + 0x48, + 0x49, + 0x4a, + 0x4b, + 0x4c, + ), + }); + const runs = findAsciiStrings(parseMfDump(buf), 4); + expect(runs).toEqual([]); + }); +}); + +describe("locateDumpFile + loadDumpFile", () => { + let dir: string; + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "mfdata-")); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("returns explicit path when provided and exists", async () => { + const path = join(dir, "explicit.bin"); + await writeFile(path, Buffer.alloc(MF_1K_SIZE)); + expect(await locateDumpFile("DEADBEEF", path)).toBe(path); + }); + + it("returns null when no dump exists anywhere", async () => { + // A UID nothing on disk should match + expect(await locateDumpFile("ZZZZZZZZ-NOPE")).toBeNull(); + }); + + it("loadDumpFile reads a binary dump", async () => { + const path = join(dir, "load.bin"); + const buf = build1KDump({ 16: makeValueBlock(1150) }); + await writeFile(path, buf); + const dump = await loadDumpFile(path); + expect(dump.blocks).toHaveLength(MF_1K_BLOCKS); + expect(findValueBlocks(dump)[0].value).toBe(1150); + }); +}); From 70368a7adced18ff7687ead90a130524cb15897b Mon Sep 17 00:00:00 2001 From: Eugene Dobry Date: Fri, 3 Jul 2026 19:27:07 -0400 Subject: [PATCH 2/5] feat: add `identify` command to match a tag against all saved identities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `verify` only compares one chosen identity by UID, so it green-lights a UID-only clone (correct UID, empty data sectors, factory keys) that a stored-value reader rejects as unformatted. `identify` reads the tag on the antenna and cross-references every saved identity at once, and for MIFARE Classic probes data fidelity — reading a data block with the default key to tell a full clone (custom sector keys) from a UID-only clone (blank/default). - add probeMifareDataFidelity() + parseReadBlock() with the default-key probe - add DEFAULT_MIFARE_KEY constant - register `identify` in the CLI and interactive menu - tests for the parser, the probe, and all identify branches - README: document the command and the full-vs-UID-only distinction Co-Authored-By: Claude Opus 4.8 --- README.md | 5 ++ src/commands/identify.ts | 90 +++++++++++++++++++++++++ src/index.ts | 9 +++ src/lib/card-ops.ts | 26 ++++++++ src/lib/constants.ts | 3 + src/lib/parsers.ts | 12 ++++ tests/commands/identify.test.ts | 115 ++++++++++++++++++++++++++++++++ tests/lib/card-ops.test.ts | 40 ++++++++++- tests/lib/parsers.test.ts | 24 +++++++ 9 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 src/commands/identify.ts create mode 100644 tests/commands/identify.test.ts diff --git a/README.md b/README.md index 7201da8..13d2d1d 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,10 @@ 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. +### `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. @@ -228,6 +232,7 @@ 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 diff --git a/src/commands/identify.ts b/src/commands/identify.ts new file mode 100644 index 0000000..b003c1b --- /dev/null +++ b/src/commands/identify.ts @@ -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 = 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 ` 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 { + 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; +} diff --git a/src/index.ts b/src/index.ts index 4d4ce39..457cc0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { clone } from "./commands/clone.js"; import { deleteTag } from "./commands/delete.js"; import { doctor } from "./commands/doctor.js"; import { exportTags } from "./commands/export.js"; +import { identify } from "./commands/identify.js"; import { importFile } from "./commands/import.js"; import { inspect } from "./commands/inspect.js"; import { list } from "./commands/list.js"; @@ -45,6 +46,7 @@ program.action( { value: "read", label: "Read a tag", hint: "identify and save" }, { value: "write", label: "Write a saved identity", hint: "write to blank tag" }, { value: "verify", label: "Verify a tag", hint: "compare to saved identity" }, + { value: "identify", label: "Identify a tag", hint: "read + match against all saved" }, { value: "inspect", label: "Inspect tag data", hint: "decode value blocks (e.g. laundry balance)" }, { value: "list", label: "List saved tags" }, { value: "doctor", label: "Health check", hint: "diagnose device" }, @@ -66,6 +68,8 @@ program.action( return write(); case "verify": return verify(); + case "identify": + return identify(); case "inspect": return inspect(); case "list": @@ -144,6 +148,11 @@ program .argument("[name]", "name of the saved tag identity") .action(withExitCode(verify)); +program + .command("identify") + .description("Read a tag and match it against all saved identities (flags UID-only clones)") + .action(withExitCode(identify)); + program .command("repair") .description("Repair a bricked magic card with corrupted block 0 (bad BCC)") diff --git a/src/lib/card-ops.ts b/src/lib/card-ops.ts index 4a8d5d9..8b01f51 100644 --- a/src/lib/card-ops.ts +++ b/src/lib/card-ops.ts @@ -3,6 +3,7 @@ import { buildBlock0 } from "./block0.js"; import { CardType, cardFrequency, + DEFAULT_MIFARE_KEY, DetectionKind, type DetectionKindName, MagicCardType, @@ -18,6 +19,7 @@ import { parseHfSearch, parseLfSearch, parseMagicType, + parseReadBlock, parseT55xxDetect, } from "./parsers.js"; import { Pm3Error, pm3Exec } from "./pm3.js"; @@ -68,6 +70,30 @@ export async function searchCardWithDiagnosis(): Promise { return { card: null, diagnosis: DetectionKind.NONE }; } +export type MifareDataFidelity = "custom-keys" | "blank-default" | "data-default" | "unknown"; + +/** + * Probe whether a MIFARE Classic card actually carries data, by reading a data + * block with the factory-default key: + * - default key rejected -> "custom-keys" (real data behind operator keys) + * - reads all-zero -> "blank-default" (UID-only clone / unwritten card) + * - reads non-zero -> "data-default" (data present under default keys) + * + * Distinguishes a working full clone from a UID-only clone that shares its UID. + */ +export async function probeMifareDataFidelity(): Promise { + try { + const cmd = Pm3Cmd.HF_MF_RDBL.arg("--blk", "1").arg("-k", DEFAULT_MIFARE_KEY); + const { stdout } = await pm3Exec(cmd); + const { authError, bytes } = parseReadBlock(stdout); + if (bytes) return /^0+$/.test(bytes) ? "blank-default" : "data-default"; + if (authError) return "custom-keys"; + return "unknown"; + } catch { + return "unknown"; + } +} + /** Detect magic card type by running hf search and parsing capabilities. */ export async function detectMagicType(): Promise { const { stdout } = await pm3Exec(Pm3Cmd.HF_SEARCH); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index f3e7d86..a266600 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -20,6 +20,9 @@ export function cardFrequency(type: string): "LF" | "HF" { } } +/** MIFARE Classic factory-default key — the Key A/B on virgin cards and UID-only clones. */ +export const DEFAULT_MIFARE_KEY = "FFFFFFFFFFFF"; + export const WriteTarget = { LF: "blank T55x7 tag", HF: "magic card (Gen1A or Gen2/CUID)", diff --git a/src/lib/parsers.ts b/src/lib/parsers.ts index 23f417c..e0c4b94 100644 --- a/src/lib/parsers.ts +++ b/src/lib/parsers.ts @@ -130,6 +130,18 @@ export function parseBlock0Data(output: string): string | null { return match[1].replace(/\s+/g, "").toUpperCase(); } +export interface ReadBlockResult { + authError: boolean; + bytes: string | null; +} + +/** Parse `hf mf rdbl` output: extract the 16 data bytes, or flag an authentication failure. */ +export function parseReadBlock(output: string): ReadBlockResult { + const authError = /auth\s*error|can'?t\s*read|cannot\s*read|read\s*block\s*failed|error=/i.test(output); + const bytes = parseBlock0Data(output); + return { authError, bytes }; +} + export function parseCloneResult(output: string): CloneResult { const hasError = /error/i.test(output) && !/errorrate/i.test(output); const hasDone = diff --git a/tests/commands/identify.test.ts b/tests/commands/identify.test.ts new file mode 100644 index 0000000..40e502b --- /dev/null +++ b/tests/commands/identify.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mockClack, mockPm3Module, setupBeforeEach } from "../helpers/mocks.js"; + +mockPm3Module(); +mockClack(); + +vi.mock("../../src/lib/card-ops.js", () => ({ + searchCardWithDiagnosis: vi.fn(), + probeMifareDataFidelity: vi.fn(), +})); + +vi.mock("../../src/lib/store.js", () => ({ + loadTags: vi.fn().mockResolvedValue([]), +})); + +import * as p from "@clack/prompts"; +import { identify } from "../../src/commands/identify.js"; +import { probeMifareDataFidelity, searchCardWithDiagnosis } from "../../src/lib/card-ops.js"; +import { Pm3Error, requireDevice } from "../../src/lib/pm3.js"; +import { loadTags } from "../../src/lib/store.js"; + +const mockSearch = vi.mocked(searchCardWithDiagnosis); +const mockProbe = vi.mocked(probeMifareDataFidelity); +const mockRequireDevice = vi.mocked(requireDevice); +const mockLoadTags = vi.mocked(loadTags); +const mockNote = vi.mocked(p.note); +const mockLogWarn = vi.mocked(p.log.warn); +const mockLogSuccess = vi.mocked(p.log.success); +const MockPm3Error = Pm3Error as any; + +const mifareTag = { + name: "494 laundry", + type: "MIFARE Classic 1K", + id: "815498C5", + dumpFile: "/home/u/hf-mf-815498C5-current-dump.bin", + savedAt: "2026-02-14", +}; + +beforeEach(() => { + setupBeforeEach(); + mockRequireDevice.mockResolvedValue(true); + mockLoadTags.mockResolvedValue([]); +}); + +describe("identify", () => { + it("no device → false, does not read", async () => { + mockRequireDevice.mockResolvedValueOnce(false); + + expect(await identify()).toBe(false); + expect(mockSearch).not.toHaveBeenCalled(); + }); + + it("no tag detected → false", async () => { + mockSearch.mockResolvedValueOnce({ card: null, diagnosis: "none" }); + + expect(await identify()).toBe(false); + expect(mockProbe).not.toHaveBeenCalled(); + }); + + it("LF card matching a saved identity → true, no MIFARE fidelity probe", async () => { + mockSearch.mockResolvedValueOnce({ + card: { type: "EM410x", id: "040064DACA", encoding: "RF/64" }, + diagnosis: "none", + }); + mockLoadTags.mockResolvedValueOnce([{ name: "mckibbin", type: "EM410x", id: "040064DACA", savedAt: "x" }]); + + expect(await identify()).toBe(true); + expect(mockNote).toHaveBeenCalledWith(expect.stringContaining("mckibbin"), "Matches saved identity"); + expect(mockProbe).not.toHaveBeenCalled(); + }); + + it("MIFARE full clone (custom keys) → true, reports real data", async () => { + mockSearch.mockResolvedValueOnce({ + card: { type: "MIFARE Classic 1K", id: "815498C5" }, + diagnosis: "none", + }); + mockLoadTags.mockResolvedValueOnce([mifareTag]); + mockProbe.mockResolvedValueOnce("custom-keys"); + + expect(await identify()).toBe(true); + expect(mockProbe).toHaveBeenCalled(); + expect(mockLogSuccess).toHaveBeenCalledWith(expect.stringContaining("custom keys")); + }); + + it("MIFARE UID-only clone (blank data) → true, warns it will be rejected", async () => { + mockSearch.mockResolvedValueOnce({ + card: { type: "MIFARE Classic 1K", id: "815498C5" }, + diagnosis: "none", + }); + mockLoadTags.mockResolvedValueOnce([mifareTag]); + mockProbe.mockResolvedValueOnce("blank-default"); + + expect(await identify()).toBe(true); + expect(mockLogWarn).toHaveBeenCalledWith(expect.stringContaining("UID-only clone")); + }); + + it("no saved identity matches → true, warns unknown card", async () => { + mockSearch.mockResolvedValueOnce({ + card: { type: "MIFARE Classic 1K", id: "13929131" }, + diagnosis: "none", + }); + mockLoadTags.mockResolvedValueOnce([mifareTag]); + mockProbe.mockResolvedValueOnce("blank-default"); + + expect(await identify()).toBe(true); + expect(mockLogWarn).toHaveBeenCalledWith(expect.stringContaining("No saved identity matches")); + expect(mockNote).not.toHaveBeenCalledWith(expect.anything(), "Matches saved identity"); + }); + + it("pm3 error during read → false", async () => { + mockSearch.mockRejectedValueOnce(new MockPm3Error("pm3 command not found", "", "")); + + expect(await identify()).toBe(false); + }); +}); diff --git a/tests/lib/card-ops.test.ts b/tests/lib/card-ops.test.ts index 15cd6eb..2087030 100644 --- a/tests/lib/card-ops.test.ts +++ b/tests/lib/card-ops.test.ts @@ -4,7 +4,12 @@ import { mockClack, mockPm3Module, setupBeforeEach } from "../helpers/mocks.js"; mockPm3Module(); mockClack(); -import { searchCard, searchCardWithDiagnosis, writeAndVerify } from "../../src/lib/card-ops.js"; +import { + probeMifareDataFidelity, + searchCard, + searchCardWithDiagnosis, + writeAndVerify, +} from "../../src/lib/card-ops.js"; import { Pm3Error, pm3Exec } from "../../src/lib/pm3.js"; const mockPm3Exec = vi.mocked(pm3Exec); @@ -264,3 +269,36 @@ describe("writeAndVerify", () => { expect(await writeAndVerify(mifareCard)).toBe(false); }); }); + +describe("probeMifareDataFidelity", () => { + it("default key rejected → custom-keys (real data behind operator keys)", async () => { + mockPm3Exec.mockResolvedValueOnce({ stdout: "[#] Auth error\n[!!] Can't read block. error=-1", stderr: "" }); + expect(await probeMifareDataFidelity()).toBe("custom-keys"); + }); + + it("reads all-zero data → blank-default (UID-only clone / unwritten)", async () => { + mockPm3Exec.mockResolvedValueOnce({ + stdout: "[=] 1 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................", + stderr: "", + }); + expect(await probeMifareDataFidelity()).toBe("blank-default"); + }); + + it("reads non-zero data → data-default", async () => { + mockPm3Exec.mockResolvedValueOnce({ + stdout: "[=] 1 | 55 49 4E 48 4F 55 53 45 4C 41 55 00 00 00 06 08 | UINHOUSELAU.....", + stderr: "", + }); + expect(await probeMifareDataFidelity()).toBe("data-default"); + }); + + it("read failure with no bytes and no auth error → unknown", async () => { + mockPm3Exec.mockResolvedValueOnce({ stdout: "[=] nothing here", stderr: "" }); + expect(await probeMifareDataFidelity()).toBe("unknown"); + }); + + it("pm3 throws → unknown (never propagates)", async () => { + mockPm3Exec.mockRejectedValueOnce(new MockPm3Error("pm3 command not found", "", "")); + expect(await probeMifareDataFidelity()).toBe("unknown"); + }); +}); diff --git a/tests/lib/parsers.test.ts b/tests/lib/parsers.test.ts index 80b6971..90635a0 100644 --- a/tests/lib/parsers.test.ts +++ b/tests/lib/parsers.test.ts @@ -11,6 +11,7 @@ import { parseHwTune, parseLfSearch, parseMagicType, + parseReadBlock, parseRestore, parseT55xxDetect, } from "../../src/lib/parsers.js"; @@ -421,3 +422,26 @@ describe("parseFm11rf08sRecovery", () => { expect(result.keyFile).toBeNull(); }); }); + +describe("parseReadBlock", () => { + it("extracts the 16 data bytes from a successful read", () => { + const output = "[=] 1 | 55 49 4E 48 4F 55 53 45 4C 41 55 00 00 00 06 08 | UINHOUSELAU....."; + const result = parseReadBlock(output); + expect(result.authError).toBe(false); + expect(result.bytes).toBe("55494E484F5553454C41550000000608"); + }); + + it("returns all-zero bytes for a blank block", () => { + const output = "[=] 16 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 | ................"; + const result = parseReadBlock(output); + expect(result.authError).toBe(false); + expect(result.bytes).toBe("00000000000000000000000000000000"); + }); + + it("flags an authentication failure with no bytes", () => { + const output = "[#] Auth error\n[!!] Can't read block. error=-1"; + const result = parseReadBlock(output); + expect(result.authError).toBe(true); + expect(result.bytes).toBeNull(); + }); +}); From a91a984bfab11f3d63dd7ba970de4b030c3e1085 Mon Sep 17 00:00:00 2001 From: Eugene Dobry Date: Fri, 3 Jul 2026 19:35:49 -0400 Subject: [PATCH 3/5] feat: add `verify --deep` to compare on-card value blocks, not just UID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `verify` matched on UID alone, so a UID-only clone (right UID, empty data) passed. `--deep` reads the live card's value blocks with the keys from the saved dump, reports the current on-card balances, and fails when nothing is readable under those keys — the signature of a UID-only clone. - readLiveValueBlocks() reads live value blocks using the saved dump's keys - sectorKeyA() / decodeValueBlockBytes() helpers in mf-data - --deep flag on the verify CLI command - tests for the helpers, the op, and the deep-verify pass/fail paths Co-Authored-By: Claude Opus 4.8 --- README.md | 2 + src/commands/verify.ts | 92 ++++++++++++++++++++++++++++++----- src/index.ts | 3 +- src/lib/mf-data.ts | 16 ++++++ src/lib/mf-ops.ts | 40 ++++++++++++++- tests/commands/verify.test.ts | 72 +++++++++++++++++++++++++++ tests/lib/mf-data.test.ts | 32 ++++++++++++ tests/lib/mf-ops.test.ts | 49 ++++++++++++++++++- 8 files changed, 290 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 13d2d1d..3862c8d 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,8 @@ 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. diff --git a/src/commands/verify.ts b/src/commands/verify.ts index b396d6d..395b8ce 100644 --- a/src/commands/verify.ts +++ b/src/commands/verify.ts @@ -1,12 +1,71 @@ import * as p from "@clack/prompts"; import { searchCardWithDiagnosis } from "../lib/card-ops.js"; -import { DetectionSummary } from "../lib/constants.js"; +import { CardType, DetectionSummary } from "../lib/constants.js"; import { printDetectionHint, printDoctorHint, printNoSavedTags, printTagNotFound } from "../lib/display.js"; +import { loadDumpFile, locateDumpFile } from "../lib/mf-data.js"; +import { readLiveValueBlocks } from "../lib/mf-ops.js"; +import type { CardInfo } from "../lib/parsers.js"; import { Pm3Error, requireDevice } from "../lib/pm3.js"; import { selectTag, waitForEnter } from "../lib/prompts.js"; -import { getTag, loadTags } from "../lib/store.js"; +import { getTag, loadTags, type Tag } from "../lib/store.js"; -export async function verify(name?: string): Promise { +const MIFARE_CLASSIC_TYPES: ReadonlySet = new Set([CardType.MIFARE_CLASSIC_1K, CardType.MIFARE_CLASSIC_4K]); + +function formatCents(value: number): string { + return `$${(value / 100).toFixed(2)}`; +} + +/** + * Beyond a UID match, read the live card's value blocks with the saved dump's + * keys and compare on-card data. Returns "no-data" when the card is a UID-only + * clone (nothing readable under the saved keys), "ok" when real data is present, + * or "skip" when a deep comparison isn't possible. + */ +async function deepVerify(tag: Tag): Promise<"ok" | "no-data" | "skip"> { + if (!MIFARE_CLASSIC_TYPES.has(tag.type)) return "skip"; + if (!tag.dumpFile) { + p.log.info("No saved dump for this identity — deep verify needs a full-card dump. Checked UID only."); + return "skip"; + } + + const dumpPath = await locateDumpFile(tag.id, tag.dumpFile); + if (!dumpPath) { + p.log.warn("Saved dump file not found — deep verify skipped."); + return "skip"; + } + + let dump: Awaited>; + try { + dump = await loadDumpFile(dumpPath); + } catch { + p.log.warn("Could not read the saved dump — deep verify skipped."); + return "skip"; + } + + const reads = await readLiveValueBlocks(dump); + if (reads.length === 0) { + p.log.info("Saved dump has no value blocks to compare."); + return "skip"; + } + + const anyData = reads.some((r) => !r.authError && r.liveValue !== null); + if (!anyData) { + p.log.error("UID matches, but the card carries no data under the saved keys."); + p.log.error("This is a UID-only clone — a stored-value reader will reject it as unformatted."); + return "no-data"; + } + + const lines = reads.map((r) => { + if (r.authError || r.liveValue === null) + return `block ${r.blockIndex}: (unreadable) saved ${formatCents(r.savedValue)}`; + const drift = r.liveValue === r.savedValue ? "" : ` (dump had ${formatCents(r.savedValue)})`; + return `block ${r.blockIndex}: ${formatCents(r.liveValue)}${drift}`; + }); + p.note(lines.join("\n"), "On-card value blocks (live)"); + return "ok"; +} + +export async function verify(name?: string, options?: { deep?: boolean }): Promise { p.intro("Verify Tag"); if (!name) { @@ -45,20 +104,16 @@ export async function verify(name?: string): Promise { const idMatch = card.id === tag.id; const typeMatch = card.type === tag.type; - if (idMatch && typeMatch) { - p.log.success(`Match! ID ${card.id} matches "${tag.name}".`); - p.outro("Verification passed."); - return true; + if (!idMatch) { + p.log.error(`Mismatch: read ${card.id}, expected ${tag.id}.`); + return false; } - if (idMatch) { - p.log.warn(`ID matches (${card.id}) but type differs: read ${card.type}, expected ${tag.type}.`); - p.outro("Partial match — ID is correct."); - return true; + if (options?.deep && (await deepVerify(tag)) === "no-data") { + return false; } - p.log.error(`Mismatch: read ${card.id}, expected ${tag.id}.`); - return false; + return reportUidMatch(card, tag, typeMatch); } catch (err) { if (err instanceof Pm3Error) { spinner.stop(err.message); @@ -71,3 +126,14 @@ export async function verify(name?: string): Promise { return false; } } + +function reportUidMatch(card: CardInfo, tag: Tag, typeMatch: boolean): boolean { + if (typeMatch) { + p.log.success(`Match! ID ${card.id} matches "${tag.name}".`); + p.outro("Verification passed."); + return true; + } + p.log.warn(`ID matches (${card.id}) but type differs: read ${card.type}, expected ${tag.type}.`); + p.outro("Partial match — ID is correct."); + return true; +} diff --git a/src/index.ts b/src/index.ts index 457cc0a..5e9cf62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -146,7 +146,8 @@ program .command("verify") .description("Read a tag and compare it against a saved identity") .argument("[name]", "name of the saved tag identity") - .action(withExitCode(verify)); + .option("--deep", "for MIFARE Classic, also compare on-card value blocks (not just the UID)") + .action(withExitCode((name: string | undefined, opts: { deep?: boolean }) => verify(name, opts))); program .command("identify") diff --git a/src/lib/mf-data.ts b/src/lib/mf-data.ts index 49cb0ce..0be510f 100644 --- a/src/lib/mf-data.ts +++ b/src/lib/mf-data.ts @@ -87,6 +87,22 @@ export function parseValueBlock(block: MfBlock): ValueBlock | null { return { blockIndex: block.index, sector: block.sector, value: v1, addr: a1 }; } +/** Key A (first 6 bytes) of a sector's trailer, as uppercase hex — for authenticating to that sector. */ +export function sectorKeyA(dump: MfDump, sector: number): string | null { + const trailer = dump.blocks.find((b) => b.sector === sector && b.isTrailer); + if (!trailer) return null; + return Array.from(trailer.bytes.subarray(0, 6)) + .map((b) => b.toString(16).padStart(2, "0").toUpperCase()) + .join(""); +} + +/** Decode a raw 16-byte block as a MIFARE value block, or null if it fails the integrity invariants. */ +export function decodeValueBlockBytes(bytes: Buffer): number | null { + if (bytes.length !== MF_BLOCK_SIZE) return null; + const vb = parseValueBlock({ index: 0, sector: 0, isTrailer: false, bytes }); + return vb ? vb.value : null; +} + export function findValueBlocks(dump: MfDump): ValueBlock[] { const out: ValueBlock[] = []; for (const block of dump.blocks) { diff --git a/src/lib/mf-ops.ts b/src/lib/mf-ops.ts index 0fcc54d..07b079b 100644 --- a/src/lib/mf-ops.ts +++ b/src/lib/mf-ops.ts @@ -1,5 +1,6 @@ import { Pm3Cmd } from "./constants.js"; -import { parseAutopwn, parseDump, parseFm11rf08sRecovery, parseRestore } from "./parsers.js"; +import { decodeValueBlockBytes, findValueBlocks, type MfDump, sectorKeyA } from "./mf-data.js"; +import { parseAutopwn, parseDump, parseFm11rf08sRecovery, parseReadBlock, parseRestore } from "./parsers.js"; import { pm3Exec } from "./pm3.js"; export interface MfCrackResult { @@ -53,6 +54,43 @@ export async function dumpCard(_uid: string, cardType: string, keyFile: string): return null; } +export interface LiveValueRead { + blockIndex: number; + sector: number; + savedValue: number; + liveValue: number | null; + authError: boolean; +} + +/** + * Read the live card's value blocks using the keys from a saved dump, so a deep + * verify can compare on-card balances rather than just the UID. An auth error on + * every block means the live card doesn't hold the saved data (a UID-only clone). + */ +export async function readLiveValueBlocks(dump: MfDump): Promise { + const results: LiveValueRead[] = []; + for (const vb of findValueBlocks(dump)) { + const key = sectorKeyA(dump, vb.sector); + if (!key) continue; + const cmd = Pm3Cmd.HF_MF_RDBL.arg("--blk", String(vb.blockIndex)).arg("-k", key); + try { + const { stdout } = await pm3Exec(cmd); + const { authError, bytes } = parseReadBlock(stdout); + const liveValue = bytes ? decodeValueBlockBytes(Buffer.from(bytes, "hex")) : null; + results.push({ blockIndex: vb.blockIndex, sector: vb.sector, savedValue: vb.value, liveValue, authError }); + } catch { + results.push({ + blockIndex: vb.blockIndex, + sector: vb.sector, + savedValue: vb.value, + liveValue: null, + authError: true, + }); + } + } + return results; +} + /** Restore all blocks to a blank magic card. */ export async function restoreCard(dumpFile: string, keyFile: string, cardType: string): Promise { const sizeFlag = cardType.includes("4K") ? "--4k" : "--1k"; diff --git a/tests/commands/verify.test.ts b/tests/commands/verify.test.ts index 8998cd9..a80a7ca 100644 --- a/tests/commands/verify.test.ts +++ b/tests/commands/verify.test.ts @@ -18,8 +18,19 @@ vi.mock("../../src/lib/prompts.js", () => ({ selectTag: vi.fn(), })); +vi.mock("../../src/lib/mf-ops.js", () => ({ + readLiveValueBlocks: vi.fn(), +})); + +vi.mock("../../src/lib/mf-data.js", () => ({ + locateDumpFile: vi.fn(), + loadDumpFile: vi.fn(), +})); + import { verify } from "../../src/commands/verify.js"; import { searchCardWithDiagnosis } from "../../src/lib/card-ops.js"; +import { loadDumpFile, locateDumpFile } from "../../src/lib/mf-data.js"; +import { readLiveValueBlocks } from "../../src/lib/mf-ops.js"; import { Pm3Error, requireDevice } from "../../src/lib/pm3.js"; import { getTag, loadTags } from "../../src/lib/store.js"; @@ -27,6 +38,9 @@ const mockSearchCardWithDiagnosis = vi.mocked(searchCardWithDiagnosis); const mockRequireDevice = vi.mocked(requireDevice); const mockGetTag = vi.mocked(getTag); const mockLoadTags = vi.mocked(loadTags); +const mockReadLiveValueBlocks = vi.mocked(readLiveValueBlocks); +const mockLocateDumpFile = vi.mocked(locateDumpFile); +const mockLoadDumpFile = vi.mocked(loadDumpFile); const MockPm3Error = Pm3Error as any; beforeEach(() => { @@ -131,3 +145,61 @@ describe("verify", () => { expect(await verify("my-tag")).toBe(false); }); }); + +describe("verify --deep", () => { + const laundryTag = { + name: "494 laundry", + type: "MIFARE Classic 1K", + id: "815498C5", + dumpFile: "/home/u/hf-mf-815498C5-current-dump.bin", + savedAt: "2026-02-14", + }; + + function placeMatchingCard() { + mockSearchCardWithDiagnosis.mockResolvedValueOnce({ + card: { type: "MIFARE Classic 1K", id: "815498C5" }, + diagnosis: "none", + }); + } + + it("full clone (data readable under saved keys) → true", async () => { + mockGetTag.mockResolvedValueOnce(laundryTag); + placeMatchingCard(); + mockLocateDumpFile.mockResolvedValueOnce("/home/u/hf-mf-815498C5-current-dump.bin"); + mockLoadDumpFile.mockResolvedValueOnce({ blocks: [], sizeBytes: 1024 }); + mockReadLiveValueBlocks.mockResolvedValueOnce([ + { blockIndex: 16, sector: 4, savedValue: 2000, liveValue: 225, authError: false }, + ]); + + expect(await verify("494 laundry", { deep: true })).toBe(true); + expect(mockReadLiveValueBlocks).toHaveBeenCalled(); + }); + + it("UID-only clone (all blocks auth-fail under saved keys) → false", async () => { + mockGetTag.mockResolvedValueOnce(laundryTag); + placeMatchingCard(); + mockLocateDumpFile.mockResolvedValueOnce("/home/u/hf-mf-815498C5-current-dump.bin"); + mockLoadDumpFile.mockResolvedValueOnce({ blocks: [], sizeBytes: 1024 }); + mockReadLiveValueBlocks.mockResolvedValueOnce([ + { blockIndex: 16, sector: 4, savedValue: 2000, liveValue: null, authError: true }, + ]); + + expect(await verify("494 laundry", { deep: true })).toBe(false); + }); + + it("no saved dump → skips deep compare, still passes on UID", async () => { + mockGetTag.mockResolvedValueOnce({ ...laundryTag, dumpFile: undefined }); + placeMatchingCard(); + + expect(await verify("494 laundry", { deep: true })).toBe(true); + expect(mockReadLiveValueBlocks).not.toHaveBeenCalled(); + }); + + it("without --deep, does not read value blocks", async () => { + mockGetTag.mockResolvedValueOnce(laundryTag); + placeMatchingCard(); + + expect(await verify("494 laundry")).toBe(true); + expect(mockReadLiveValueBlocks).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/lib/mf-data.test.ts b/tests/lib/mf-data.test.ts index 1315267..f1f9806 100644 --- a/tests/lib/mf-data.test.ts +++ b/tests/lib/mf-data.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + decodeValueBlockBytes, findAsciiStrings, findValueBlocks, isSectorTrailer, @@ -13,6 +14,7 @@ import { MF_4K_SIZE, parseMfDump, parseValueBlock, + sectorKeyA, sectorOf, } from "../../src/lib/mf-data.js"; @@ -43,6 +45,36 @@ function build1KDump(overrides: Record = {}): Buffer { return buf; } +describe("sectorKeyA", () => { + it("returns Key A from a sector's trailer block", () => { + // Sector 4's trailer is block 19; Key A is the first 6 bytes. + const trailer = makeBlock(0xec, 0x19, 0x5d, 0x46, 0xd5, 0x5d, 0xff, 0x07, 0x80, 0x69, 0, 0, 0, 0, 0, 0); + const dump = parseMfDump(build1KDump({ 19: trailer })); + expect(sectorKeyA(dump, 4)).toBe("EC195D46D55D"); + }); + + it("returns null for a sector with no trailer in the dump", () => { + const dump = parseMfDump(build1KDump()); + // sector 99 does not exist in a 1K dump + expect(sectorKeyA(dump, 99)).toBeNull(); + }); +}); + +describe("decodeValueBlockBytes", () => { + it("decodes a valid value block", () => { + expect(decodeValueBlockBytes(makeValueBlock(225))).toBe(225); + expect(decodeValueBlockBytes(makeValueBlock(2000))).toBe(2000); + }); + + it("returns null for a non-value block", () => { + expect(decodeValueBlockBytes(makeBlock(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16))).toBeNull(); + }); + + it("returns null for wrong-length input", () => { + expect(decodeValueBlockBytes(Buffer.alloc(8))).toBeNull(); + }); +}); + describe("sectorOf / isSectorTrailer", () => { it("maps 1K blocks to 16 sectors of 4 blocks", () => { expect(sectorOf(0)).toBe(0); diff --git a/tests/lib/mf-ops.test.ts b/tests/lib/mf-ops.test.ts index 07becc5..6038ab9 100644 --- a/tests/lib/mf-ops.test.ts +++ b/tests/lib/mf-ops.test.ts @@ -3,7 +3,8 @@ import { mockPm3Module, setupBeforeEach } from "../helpers/mocks.js"; mockPm3Module(); -import { crackKeys, dumpCard, restoreCard } from "../../src/lib/mf-ops.js"; +import { MF_1K_SIZE, parseMfDump } from "../../src/lib/mf-data.js"; +import { crackKeys, dumpCard, readLiveValueBlocks, restoreCard } from "../../src/lib/mf-ops.js"; import { pm3Exec } from "../../src/lib/pm3.js"; const mockPm3Exec = vi.mocked(pm3Exec); @@ -12,6 +13,52 @@ beforeEach(() => { setupBeforeEach(); }); +/** A 1K dump with one value block (2000) at block 16 and Key A EC195D46D55D on sector 4's trailer. */ +function buildDumpWithValueBlock() { + const buf = Buffer.alloc(MF_1K_SIZE); + const v = 16 * 16; + buf.writeInt32LE(2000, v); + buf.writeInt32LE(~2000 | 0, v + 4); + buf.writeInt32LE(2000, v + 8); + buf.writeUInt8(0, v + 12); + buf.writeUInt8(0xff, v + 13); + buf.writeUInt8(0, v + 14); + buf.writeUInt8(0xff, v + 15); + const t = 19 * 16; + [0xec, 0x19, 0x5d, 0x46, 0xd5, 0x5d].forEach((b, i) => { + buf.writeUInt8(b, t + i); + }); + return parseMfDump(buf); +} + +describe("readLiveValueBlocks", () => { + it("reads the live value block with the saved sector key", async () => { + mockPm3Exec.mockResolvedValueOnce({ + stdout: "[=] 16 | E1 00 00 00 1E FF FF FF E1 00 00 00 00 FF 00 FF | ................", + stderr: "", + }); + + const res = await readLiveValueBlocks(buildDumpWithValueBlock()); + expect(res).toEqual([{ blockIndex: 16, sector: 4, savedValue: 2000, liveValue: 225, authError: false }]); + expect(mockPm3Exec.mock.calls[0][0].toString()).toContain("EC195D46D55D"); + }); + + it("flags an auth error when the live card rejects the saved key (UID-only clone)", async () => { + mockPm3Exec.mockResolvedValueOnce({ stdout: "[#] Auth error", stderr: "" }); + + const res = await readLiveValueBlocks(buildDumpWithValueBlock()); + expect(res).toEqual([{ blockIndex: 16, sector: 4, savedValue: 2000, liveValue: null, authError: true }]); + }); + + it("treats a thrown pm3 error as an unreadable block", async () => { + mockPm3Exec.mockRejectedValueOnce(new Error("boom")); + + const res = await readLiveValueBlocks(buildDumpWithValueBlock()); + expect(res[0].authError).toBe(true); + expect(res[0].liveValue).toBeNull(); + }); +}); + describe("crackKeys", () => { it("autopwn succeeds → returns autopwn method", async () => { mockPm3Exec.mockResolvedValueOnce({ From 43d2d59ae8542566d137ab812b619e53495d11f4 Mon Sep 17 00:00:00 2001 From: Eugene Dobry Date: Fri, 3 Jul 2026 19:40:03 -0400 Subject: [PATCH 4/5] feat: surface full-vs-UID-only clone fidelity in the store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A MIFARE Classic identity with no saved dump can only be written as a UID-only clone — which `verify` passed but a stored-value reader rejects. Nothing in the tool flagged that ambiguity, so two same-UID identities looked identical. - tagFidelity() classifies an identity as full / uid-only / n/a - `show` reports the data fidelity and warns on UID-only identities - `list` adds a Data column for MIFARE Classic tags - `write` warns before writing a UID-only MIFARE identity - tests for the classifier and each surfaced warning/column Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- src/commands/list.ts | 16 +++++++++++++++- src/commands/show.ts | 13 ++++++++++++- src/commands/write.ts | 7 +++++++ src/lib/store.ts | 14 ++++++++++++++ tests/commands/list.test.ts | 31 ++++++++++++++++++++++++++++++- tests/commands/show.test.ts | 31 ++++++++++++++++++++++++++++++- tests/commands/write.test.ts | 5 ++++- tests/lib/store.test.ts | 19 +++++++++++++++++++ 9 files changed, 132 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3862c8d..c25d2e0 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ Reads whatever tag is on the antenna and matches it against **all** saved identi ### `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]` diff --git a/src/commands/list.ts b/src/commands/list.ts index 871ec21..696ba45 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,5 +1,11 @@ import * as p from "@clack/prompts"; -import { loadTags } from "../lib/store.js"; +import { loadTags, tagFidelity } from "../lib/store.js"; + +function fidelityLabel(fidelity: ReturnType): string { + if (fidelity === "full") return "full"; + if (fidelity === "uid-only") return "uid-only"; + return ""; +} export async function list(options: { json?: boolean } = {}): Promise { const tags = await loadTags(); @@ -15,18 +21,23 @@ export async function list(options: { json?: boolean } = {}): Promise { } const hasEncoding = tags.some((f) => f.encoding); + const hasFidelity = tags.some((f) => tagFidelity(f) !== "n/a"); const cols = { name: Math.max(12, ...tags.map((f) => f.name.length + 2)), type: Math.max(10, ...tags.map((f) => f.type.length + 2)), id: Math.max(16, ...tags.map((f) => f.id.length + 2)), encoding: hasEncoding ? Math.max(12, ...tags.map((f) => (f.encoding ?? "").length + 2)) : 0, + data: hasFidelity ? Math.max(10, ...tags.map((f) => fidelityLabel(tagFidelity(f)).length + 2)) : 0, }; let header = "Name".padEnd(cols.name) + "Type".padEnd(cols.type) + "ID".padEnd(cols.id); if (hasEncoding) { header += "Encoding".padEnd(cols.encoding); } + if (hasFidelity) { + header += "Data".padEnd(cols.data); + } header += "Saved"; const rows: string[] = []; @@ -36,6 +47,9 @@ export async function list(options: { json?: boolean } = {}): Promise { if (hasEncoding) { row += (tag.encoding ?? "").padEnd(cols.encoding); } + if (hasFidelity) { + row += fidelityLabel(tagFidelity(tag)).padEnd(cols.data); + } row += date; rows.push(row); } diff --git a/src/commands/show.ts b/src/commands/show.ts index 8d9ff1a..aabf5d0 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -1,7 +1,7 @@ import * as p from "@clack/prompts"; import { printNoSavedTags, printTagNotFound } from "../lib/display.js"; import { selectTag } from "../lib/prompts.js"; -import { getTag, loadTags } from "../lib/store.js"; +import { getTag, loadTags, tagFidelity } from "../lib/store.js"; export async function show(name?: string): Promise { if (!name) { @@ -19,13 +19,24 @@ export async function show(name?: string): Promise { return false; } + const fidelity = tagFidelity(tag); const lines = [ `Name: ${tag.name}`, `Type: ${tag.type}`, `ID: ${tag.id}`, ...(tag.encoding ? [`Encoding: ${tag.encoding}`] : []), + ...(fidelity !== "n/a" + ? [`Data: ${fidelity === "full" ? "full dump on file" : "UID only (no dump)"}`] + : []), `Saved: ${tag.savedAt.slice(0, 10)}`, ]; p.note(lines.join("\n"), "Tag Details"); + + if (fidelity === "uid-only") { + p.log.warn("UID-only identity — writing it copies just the UID, not the card's data or balance."); + p.log.warn( + "Re-clone the original with `keyfabe clone` to capture a full dump for a working stored-value card.", + ); + } return true; } diff --git a/src/commands/write.ts b/src/commands/write.ts index cd03823..343de1d 100644 --- a/src/commands/write.ts +++ b/src/commands/write.ts @@ -36,6 +36,13 @@ export async function write(name?: string): Promise { return writeFullCard(tag); } + if ((tag.type === CardType.MIFARE_CLASSIC_1K || tag.type === CardType.MIFARE_CLASSIC_4K) && !tag.dumpFile) { + p.log.warn("No saved data dump for this identity — only the UID will be written."); + p.log.warn( + "A stored-value card (laundry, transit) needs its data too; re-clone with `keyfabe clone` for a full dump.", + ); + } + const freq = cardFrequency(tag.type); await waitForEnter(`Place a ${WriteTarget[freq]} on the antenna.`); diff --git a/src/lib/store.ts b/src/lib/store.ts index 988c28c..572bb61 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -1,6 +1,7 @@ import { access, rename as fsRename, mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; +import { CardType } from "./constants.js"; const STORE_DIR = process.env.KEYFABE_STORE_PATH ? dirname(process.env.KEYFABE_STORE_PATH) @@ -17,6 +18,19 @@ export interface Tag { savedAt: string; } +export type TagFidelity = "full" | "uid-only" | "n/a"; + +/** + * How completely a saved identity can be reproduced. + * - "full" : MIFARE Classic with a saved dump — write restores all data. + * - "uid-only" : MIFARE Classic without a dump — write copies only the UID. + * - "n/a" : LF/simple cards where the UID *is* the whole identity. + */ +export function tagFidelity(tag: Tag): TagFidelity { + if (tag.type !== CardType.MIFARE_CLASSIC_1K && tag.type !== CardType.MIFARE_CLASSIC_4K) return "n/a"; + return tag.dumpFile ? "full" : "uid-only"; +} + async function migrateStore(): Promise { try { await access(OLD_STORE_PATH); diff --git a/tests/commands/list.test.ts b/tests/commands/list.test.ts index 624e2a7..5ee1dfe 100644 --- a/tests/commands/list.test.ts +++ b/tests/commands/list.test.ts @@ -3,7 +3,8 @@ import { getOutput, mockClack, setupBeforeEach } from "../helpers/mocks.js"; mockClack(); -vi.mock("../../src/lib/store.js", () => ({ +vi.mock("../../src/lib/store.js", async () => ({ + ...(await vi.importActual("../../src/lib/store.js")), loadTags: vi.fn(), })); @@ -85,4 +86,32 @@ describe("list", () => { const noteContent = mockNote.mock.calls[0][0] as string; expect(noteContent).not.toContain("Encoding"); }); + + it("shows Data column with fidelity for MIFARE Classic tags", async () => { + mockLoadTags.mockResolvedValue([ + { + name: "494 laundry", + type: "MIFARE Classic 1K", + id: "815498C5", + dumpFile: "/d.bin", + savedAt: "2026-02-14T00:00:00.000Z", + }, + { name: "laundry 2", type: "MIFARE Classic 1K", id: "815498C5", savedAt: "2026-04-30T00:00:00.000Z" }, + ]); + + expect(await list()).toBe(true); + const noteContent = mockNote.mock.calls[0][0] as string; + expect(noteContent).toContain("Data"); + expect(noteContent).toContain("full"); + expect(noteContent).toContain("uid-only"); + }); + + it("omits Data column when no MIFARE Classic tags present", async () => { + mockLoadTags.mockResolvedValue([ + { name: "front-door", type: "EM410x", id: "1A2B3C4D5E", savedAt: "2024-06-15T12:00:00.000Z" }, + ]); + + expect(await list()).toBe(true); + expect(mockNote.mock.calls[0][0] as string).not.toContain("Data"); + }); }); diff --git a/tests/commands/show.test.ts b/tests/commands/show.test.ts index 907a491..62c9e66 100644 --- a/tests/commands/show.test.ts +++ b/tests/commands/show.test.ts @@ -3,7 +3,8 @@ import { mockClack, setupBeforeEach } from "../helpers/mocks.js"; mockClack(); -vi.mock("../../src/lib/store.js", () => ({ +vi.mock("../../src/lib/store.js", async () => ({ + ...(await vi.importActual("../../src/lib/store.js")), getTag: vi.fn(), loadTags: vi.fn(), })); @@ -18,6 +19,7 @@ import { getTag } from "../../src/lib/store.js"; const mockGetTag = vi.mocked(getTag); const mockNote = vi.mocked(p.note); +const mockLogWarn = vi.mocked(p.log.warn); beforeEach(() => { setupBeforeEach(); @@ -61,4 +63,31 @@ describe("show", () => { expect(await show("nonexistent")).toBe(false); expect(mockNote).not.toHaveBeenCalled(); }); + + it("MIFARE full-dump identity → shows 'full dump on file', no warning", async () => { + mockGetTag.mockResolvedValue({ + name: "494 laundry", + type: "MIFARE Classic 1K", + id: "815498C5", + dumpFile: "/d.bin", + savedAt: "2026-02-14T00:00:00.000Z", + }); + + expect(await show("494 laundry")).toBe(true); + expect(mockNote.mock.calls[0][0] as string).toContain("full dump on file"); + expect(mockLogWarn).not.toHaveBeenCalled(); + }); + + it("MIFARE UID-only identity → shows 'UID only' and warns", async () => { + mockGetTag.mockResolvedValue({ + name: "laundry 2", + type: "MIFARE Classic 1K", + id: "815498C5", + savedAt: "2026-04-30T00:00:00.000Z", + }); + + expect(await show("laundry 2")).toBe(true); + expect(mockNote.mock.calls[0][0] as string).toContain("UID only"); + expect(mockLogWarn).toHaveBeenCalledWith(expect.stringContaining("UID-only identity")); + }); }); diff --git a/tests/commands/write.test.ts b/tests/commands/write.test.ts index e2cf03f..1175673 100644 --- a/tests/commands/write.test.ts +++ b/tests/commands/write.test.ts @@ -33,6 +33,7 @@ vi.mock("../../src/lib/display.js", () => ({ printNotMagicHint: vi.fn(), })); +import * as p from "@clack/prompts"; import { write } from "../../src/commands/write.js"; import { detectMagicType, writeAndVerify } from "../../src/lib/card-ops.js"; import { restoreCard } from "../../src/lib/mf-ops.js"; @@ -45,6 +46,7 @@ const mockRequireDevice = vi.mocked(requireDevice); const mockRestoreCard = vi.mocked(restoreCard); const mockDetectMagicType = vi.mocked(detectMagicType); const mockPm3Exec = vi.mocked(pm3Exec); +const mockLogWarn = vi.mocked(p.log.warn); beforeEach(() => { setupBeforeEach(); @@ -97,7 +99,7 @@ describe("write", () => { expect(await write("front-door")).toBe(false); }); - it("tag without dumpFile → existing UID-only path (regression)", async () => { + it("tag without dumpFile → existing UID-only path (regression) + warns it carries no data", async () => { mockGetTag.mockResolvedValue({ name: "mifare-uid", type: "MIFARE Classic 1K", @@ -109,6 +111,7 @@ describe("write", () => { expect(await write("mifare-uid")).toBe(true); expect(mockWriteAndVerify).toHaveBeenCalled(); expect(mockRestoreCard).not.toHaveBeenCalled(); + expect(mockLogWarn).toHaveBeenCalledWith(expect.stringContaining("only the UID will be written")); }); }); diff --git a/tests/lib/store.test.ts b/tests/lib/store.test.ts index 36e3aa9..ddc3440 100644 --- a/tests/lib/store.test.ts +++ b/tests/lib/store.test.ts @@ -193,3 +193,22 @@ describe("removeTag", () => { expect(result).toBe(false); }); }); + +describe("tagFidelity", () => { + it("MIFARE Classic with a saved dump → full", async () => { + const { tagFidelity } = await importStore(); + expect(tagFidelity({ name: "x", type: "MIFARE Classic 1K", id: "AA", dumpFile: "/d.bin", savedAt: "x" })).toBe( + "full", + ); + }); + + it("MIFARE Classic without a dump → uid-only", async () => { + const { tagFidelity } = await importStore(); + expect(tagFidelity({ name: "x", type: "MIFARE Classic 4K", id: "AA", savedAt: "x" })).toBe("uid-only"); + }); + + it("LF/simple card → n/a (UID is the whole identity)", async () => { + const { tagFidelity } = await importStore(); + expect(tagFidelity({ name: "x", type: "EM410x", id: "AA", savedAt: "x" })).toBe("n/a"); + }); +}); From 87cb51ba9bc8b6f0b8848b9549dcd865f25e0411 Mon Sep 17 00:00:00 2001 From: Eugene Dobry Date: Fri, 3 Jul 2026 19:46:09 -0400 Subject: [PATCH 5/5] feat: add `value` command and stored-value card docs Adds `keyfabe value --block [--get|--set|--inc|--dec]` to read or write a MIFARE Classic value block (a stored-value balance), wrapping pm3's `hf mf value`. The sector key comes from the named identity's saved dump or an explicit --key. Writes warn, confirm, and read back to verify. - readValueBlock() / writeValueBlock() ops + HF_MF_VALUE command - value command with key resolution, confirmation, and read-back - docs/stored-value-cards.md: how balance-on-card systems work, value-block format, full-vs-UID-only clones, why hollow clones format-error, and the real limits of writing a balance (MAC / counter / server reconciliation) - README + CLAUDE.md reference the new command and doc - tests for the ops and every value command branch Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 1 + README.md | 13 +++- docs/stored-value-cards.md | 79 ++++++++++++++++++++++ src/commands/value.ts | 117 +++++++++++++++++++++++++++++++++ src/index.ts | 13 ++++ src/lib/constants.ts | 1 + src/lib/mf-ops.ts | 18 +++++ tests/commands/value.test.ts | 123 +++++++++++++++++++++++++++++++++++ tests/lib/mf-ops.test.ts | 53 ++++++++++++++- 9 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 docs/stored-value-cards.md create mode 100644 src/commands/value.ts create mode 100644 tests/commands/value.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 678ad5a..1831912 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index c25d2e0..5f86f16 100644 --- a/README.md +++ b/README.md @@ -196,9 +196,19 @@ Decodes the contents of a saved MIFARE Classic dump. Useful for inspecting cards The dump file is located by UID — checked first at `tag.dumpFile` (set when `keyfabe clone` does a full-card clone), then `~/hf-mf--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 `. + +- `--block ` — which block to operate on (required). +- `--get` — read the current value (the default when no write flag is given). +- `--set ` / `--inc ` / `--dec ` — 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 | |------|-----------|------|-------|-------| @@ -243,6 +253,7 @@ src/ 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 diff --git a/docs/stored-value-cards.md b/docs/stored-value-cards.md new file mode 100644 index 0000000..3b5d065 --- /dev/null +++ b/docs/stored-value-cards.md @@ -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 ` — 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 --block [--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. diff --git a/src/commands/value.ts b/src/commands/value.ts new file mode 100644 index 0000000..b13155f --- /dev/null +++ b/src/commands/value.ts @@ -0,0 +1,117 @@ +import * as p from "@clack/prompts"; +import { loadDumpFile, locateDumpFile, sectorKeyA, sectorOf } from "../lib/mf-data.js"; +import { readValueBlock, type ValueOp, writeValueBlock } from "../lib/mf-ops.js"; +import { requireDevice } from "../lib/pm3.js"; +import { confirm } from "../lib/prompts.js"; +import { getTag } from "../lib/store.js"; + +export interface ValueOptions { + block?: string; + get?: boolean; + set?: string; + inc?: string; + dec?: string; + key?: string; +} + +function formatValue(v: number): string { + return `${v} (= $${(v / 100).toFixed(2)} if cents)`; +} + +/** Resolve the sector key for a block: an explicit --key, else Key A from a saved identity's dump. */ +async function resolveKey(name: string | undefined, block: number, explicitKey?: string): Promise { + if (explicitKey) return explicitKey.toUpperCase(); + if (!name) return null; + const tag = await getTag(name); + if (!tag?.dumpFile) return null; + const dumpPath = await locateDumpFile(tag.id, tag.dumpFile); + if (!dumpPath) return null; + try { + const dump = await loadDumpFile(dumpPath); + return sectorKeyA(dump, sectorOf(block)); + } catch { + return null; + } +} + +export async function value(name?: string, options: ValueOptions = {}): Promise { + p.intro("Value Block"); + + const writeOps = (["set", "inc", "dec"] as const).filter((k) => options[k] !== undefined); + if (writeOps.length > 1) { + p.log.error("Choose only one of --set, --inc, or --dec."); + return false; + } + const op = writeOps[0] as ValueOp | undefined; + + if (options.block === undefined) { + p.log.error("Specify the value block with --block ."); + return false; + } + const block = Number(options.block); + if (!Number.isInteger(block) || block < 0) { + p.log.error(`Invalid block number: ${options.block}`); + return false; + } + + let amount = 0; + if (op) { + amount = Number(options[op]); + if (!Number.isInteger(amount)) { + p.log.error(`Invalid value for --${op}: ${options[op]}`); + return false; + } + } + + const key = await resolveKey(name, block, options.key); + if (!key) { + p.log.error("No key available. Pass --key , or name a saved MIFARE identity that has a full dump."); + return false; + } + + if (!(await requireDevice())) return false; + + if (!op) { + const current = await readValueBlock(block, key); + if (current === null) { + p.log.error(`Could not read a value block at block ${block} with that key.`); + return false; + } + p.log.info(`Block ${block}: ${formatValue(current)}`); + p.outro("Done."); + return true; + } + + p.log.warn("This writes a balance directly onto the card on the antenna."); + p.log.warn( + "Only meaningful on a card you own — systems with server reconciliation or a MAC/counter may reject or revert it.", + ); + + const before = await readValueBlock(block, key); + if (before !== null) p.log.info(`Current block ${block}: ${formatValue(before)}`); + + const proceed = await confirm(`${op} block ${block} ${op === "set" ? "to" : "by"} ${amount}?`); + if (!proceed) { + p.cancel("Aborted — card unchanged."); + return false; + } + + const spinner = p.spinner(); + spinner.start("Writing value block..."); + const ok = await writeValueBlock(block, key, op, amount); + if (!ok) { + spinner.stop("Write reported failure."); + p.log.error("pm3 did not confirm the value write. Make sure the right card is on the antenna."); + return false; + } + spinner.stop("Value block written"); + + const after = await readValueBlock(block, key); + if (after === null) { + p.log.warn("Wrote the value, but could not read it back to confirm."); + return true; + } + p.log.success(`Block ${block} is now ${formatValue(after)}.`); + p.outro("Done."); + return true; +} diff --git a/src/index.ts b/src/index.ts index 5e9cf62..d74b6b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import { rename } from "./commands/rename.js"; import { repair } from "./commands/repair.js"; import { setup } from "./commands/setup.js"; import { show } from "./commands/show.js"; +import { type ValueOptions, value } from "./commands/value.js"; import { verify } from "./commands/verify.js"; import { write } from "./commands/write.js"; @@ -165,4 +166,16 @@ program .argument("[name]", "name of the saved tag identity") .action(withExitCode(inspect)); +program + .command("value") + .description("Read or set a MIFARE Classic value block (e.g. a stored-value balance) on the card") + .argument("[name]", "saved MIFARE identity to source the sector key from") + .option("--block ", "block number of the value block") + .option("--get", "read the current value (default when no --set/--inc/--dec given)") + .option("--set ", "set the value block to this integer") + .option("--inc ", "increment the value block by this integer") + .option("--dec ", "decrement the value block by this integer") + .option("--key ", "sector key (6 hex bytes) — overrides the saved dump's key") + .action(withExitCode((name: string | undefined, opts: ValueOptions) => value(name, opts))); + program.parse(); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index a266600..2f9a8a9 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -120,6 +120,7 @@ export const Pm3Cmd = { HF_MF_CSETUID: mf.sub("csetuid"), HF_MF_WRBL: mf.sub("wrbl"), HF_MF_RDBL: mf.sub("rdbl"), + HF_MF_VALUE: mf.sub("value"), HF_MF_AUTOPWN: mf.sub("autopwn"), HF_MF_DUMP: mf.sub("dump"), HF_MF_RESTORE: mf.sub("restore"), diff --git a/src/lib/mf-ops.ts b/src/lib/mf-ops.ts index 07b079b..dc1cb90 100644 --- a/src/lib/mf-ops.ts +++ b/src/lib/mf-ops.ts @@ -54,6 +54,24 @@ export async function dumpCard(_uid: string, cardType: string, keyFile: string): return null; } +/** Read a single block with a key and decode it as a value block (null if unreadable or not a value block). */ +export async function readValueBlock(block: number, key: string): Promise { + const cmd = Pm3Cmd.HF_MF_RDBL.arg("--blk", String(block)).arg("-k", key); + const { stdout } = await pm3Exec(cmd); + const { bytes } = parseReadBlock(stdout); + return bytes ? decodeValueBlockBytes(Buffer.from(bytes, "hex")) : null; +} + +export type ValueOp = "set" | "inc" | "dec"; + +/** Set / increment / decrement a MIFARE value block via `hf mf value`. Returns false on a reported failure. */ +export async function writeValueBlock(block: number, key: string, op: ValueOp, amount: number): Promise { + const flag = op === "set" ? "--set" : op === "inc" ? "--inc" : "--dec"; + const cmd = Pm3Cmd.HF_MF_VALUE.arg("--blk", String(block)).arg("-k", key).arg(flag, String(amount)); + const { stdout } = await pm3Exec(cmd); + return !/\[-\]|failed|can'?t select|error(?!rate)/i.test(stdout); +} + export interface LiveValueRead { blockIndex: number; sector: number; diff --git a/tests/commands/value.test.ts b/tests/commands/value.test.ts new file mode 100644 index 0000000..2f3a6f7 --- /dev/null +++ b/tests/commands/value.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mockClack, mockPm3Module, setupBeforeEach } from "../helpers/mocks.js"; + +mockPm3Module(); +mockClack(); + +vi.mock("../../src/lib/mf-ops.js", () => ({ + readValueBlock: vi.fn(), + writeValueBlock: vi.fn(), +})); + +vi.mock("../../src/lib/mf-data.js", () => ({ + loadDumpFile: vi.fn(), + locateDumpFile: vi.fn(), + sectorKeyA: vi.fn(), + sectorOf: vi.fn((b: number) => Math.floor(b / 4)), +})); + +vi.mock("../../src/lib/store.js", () => ({ + getTag: vi.fn(), +})); + +vi.mock("../../src/lib/prompts.js", () => ({ + confirm: vi.fn().mockResolvedValue(true), +})); + +import * as p from "@clack/prompts"; +import { value } from "../../src/commands/value.js"; +import { loadDumpFile, locateDumpFile, sectorKeyA } from "../../src/lib/mf-data.js"; +import { readValueBlock, writeValueBlock } from "../../src/lib/mf-ops.js"; +import { requireDevice } from "../../src/lib/pm3.js"; +import { confirm } from "../../src/lib/prompts.js"; +import { getTag } from "../../src/lib/store.js"; + +const mockReadValueBlock = vi.mocked(readValueBlock); +const mockWriteValueBlock = vi.mocked(writeValueBlock); +const mockGetTag = vi.mocked(getTag); +const mockConfirm = vi.mocked(confirm); +const mockRequireDevice = vi.mocked(requireDevice); +const mockLocateDumpFile = vi.mocked(locateDumpFile); +const mockLoadDumpFile = vi.mocked(loadDumpFile); +const mockSectorKeyA = vi.mocked(sectorKeyA); +const mockLogSuccess = vi.mocked(p.log.success); + +const KEY = "EC195D46D55D"; + +beforeEach(() => { + setupBeforeEach(); + mockRequireDevice.mockResolvedValue(true); + mockConfirm.mockResolvedValue(true); +}); + +describe("value", () => { + it("no --block → false", async () => { + expect(await value(undefined, { key: KEY, get: true })).toBe(false); + }); + + it("more than one write op → false", async () => { + expect(await value(undefined, { block: "16", key: KEY, set: "10", inc: "5" })).toBe(false); + }); + + it("no resolvable key → false", async () => { + expect(await value(undefined, { block: "16", get: true })).toBe(false); + expect(mockReadValueBlock).not.toHaveBeenCalled(); + }); + + it("--get reads and reports the value", async () => { + mockReadValueBlock.mockResolvedValueOnce(225); + expect(await value(undefined, { block: "16", key: KEY })).toBe(true); + expect(mockReadValueBlock).toHaveBeenCalledWith(16, KEY); + }); + + it("--get on an unreadable block → false", async () => { + mockReadValueBlock.mockResolvedValueOnce(null); + expect(await value(undefined, { block: "16", key: KEY, get: true })).toBe(false); + }); + + it("--set writes after confirm and reports the read-back value", async () => { + mockReadValueBlock.mockResolvedValueOnce(225).mockResolvedValueOnce(1000); + mockWriteValueBlock.mockResolvedValueOnce(true); + + expect(await value(undefined, { block: "16", key: KEY, set: "1000" })).toBe(true); + expect(mockWriteValueBlock).toHaveBeenCalledWith(16, KEY, "set", 1000); + expect(mockLogSuccess).toHaveBeenCalledWith(expect.stringContaining("1000")); + }); + + it("declining the confirmation leaves the card unchanged → false", async () => { + mockReadValueBlock.mockResolvedValueOnce(225); + mockConfirm.mockResolvedValueOnce(false); + + expect(await value(undefined, { block: "16", key: KEY, set: "1000" })).toBe(false); + expect(mockWriteValueBlock).not.toHaveBeenCalled(); + }); + + it("a failed write → false", async () => { + mockReadValueBlock.mockResolvedValueOnce(225); + mockWriteValueBlock.mockResolvedValueOnce(false); + + expect(await value(undefined, { block: "16", key: KEY, dec: "50" })).toBe(false); + }); + + it("resolves the key from a saved identity's dump when --key is omitted", async () => { + mockGetTag.mockResolvedValueOnce({ + name: "494 laundry", + type: "MIFARE Classic 1K", + id: "815498C5", + dumpFile: "/d.bin", + savedAt: "2026-02-14", + }); + mockLocateDumpFile.mockResolvedValueOnce("/d.bin"); + mockLoadDumpFile.mockResolvedValueOnce({ blocks: [], sizeBytes: 1024 }); + mockSectorKeyA.mockReturnValueOnce(KEY); + mockReadValueBlock.mockResolvedValueOnce(225); + + expect(await value("494 laundry", { block: "16" })).toBe(true); + expect(mockReadValueBlock).toHaveBeenCalledWith(16, KEY); + }); + + it("no device → false", async () => { + mockRequireDevice.mockResolvedValueOnce(false); + expect(await value(undefined, { block: "16", key: KEY, get: true })).toBe(false); + }); +}); diff --git a/tests/lib/mf-ops.test.ts b/tests/lib/mf-ops.test.ts index 6038ab9..467ad83 100644 --- a/tests/lib/mf-ops.test.ts +++ b/tests/lib/mf-ops.test.ts @@ -4,7 +4,14 @@ import { mockPm3Module, setupBeforeEach } from "../helpers/mocks.js"; mockPm3Module(); import { MF_1K_SIZE, parseMfDump } from "../../src/lib/mf-data.js"; -import { crackKeys, dumpCard, readLiveValueBlocks, restoreCard } from "../../src/lib/mf-ops.js"; +import { + crackKeys, + dumpCard, + readLiveValueBlocks, + readValueBlock, + restoreCard, + writeValueBlock, +} from "../../src/lib/mf-ops.js"; import { pm3Exec } from "../../src/lib/pm3.js"; const mockPm3Exec = vi.mocked(pm3Exec); @@ -31,6 +38,50 @@ function buildDumpWithValueBlock() { return parseMfDump(buf); } +describe("readValueBlock", () => { + it("decodes a value block read with the given key", async () => { + mockPm3Exec.mockResolvedValueOnce({ + stdout: "[=] 16 | E1 00 00 00 1E FF FF FF E1 00 00 00 00 FF 00 FF | ................", + stderr: "", + }); + expect(await readValueBlock(16, "EC195D46D55D")).toBe(225); + }); + + it("returns null on an auth error", async () => { + mockPm3Exec.mockResolvedValueOnce({ stdout: "[#] Auth error", stderr: "" }); + expect(await readValueBlock(16, "FFFFFFFFFFFF")).toBeNull(); + }); + + it("returns null when the block is not a value block", async () => { + mockPm3Exec.mockResolvedValueOnce({ + stdout: "[=] 16 | 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10 | ................", + stderr: "", + }); + expect(await readValueBlock(16, "FFFFFFFFFFFF")).toBeNull(); + }); +}); + +describe("writeValueBlock", () => { + it("issues --set and reports success", async () => { + mockPm3Exec.mockResolvedValueOnce({ stdout: "[+] value updated", stderr: "" }); + expect(await writeValueBlock(16, "EC195D46D55D", "set", 1000)).toBe(true); + expect(mockPm3Exec.mock.calls[0][0].toString()).toContain("--set 1000"); + }); + + it("maps inc and dec to the right flags", async () => { + mockPm3Exec.mockResolvedValue({ stdout: "ok", stderr: "" }); + await writeValueBlock(16, "AABBCCDDEEFF", "inc", 5); + expect(mockPm3Exec.mock.calls[0][0].toString()).toContain("--inc 5"); + await writeValueBlock(16, "AABBCCDDEEFF", "dec", 7); + expect(mockPm3Exec.mock.calls[1][0].toString()).toContain("--dec 7"); + }); + + it("returns false on a failure marker", async () => { + mockPm3Exec.mockResolvedValueOnce({ stdout: "[-] failed to update value block", stderr: "" }); + expect(await writeValueBlock(16, "AABBCCDDEEFF", "set", 1)).toBe(false); + }); +}); + describe("readLiveValueBlocks", () => { it("reads the live value block with the saved sector key", async () => { mockPm3Exec.mockResolvedValueOnce({