- app_net.c: replace bare httpd_req_recv with recv_body_capped/alloc helpers (TCP-safe, full-body reads); add OPTIONS/* CORS preflight handler; bump WS broadcast buffer to 2048; add CORS Allow-Methods - CMakeLists (net_service): add http_parser dep for HTTP_OPTIONS - nfc_engine/pn532_core: add nfc_access_lock/unlock mutex, board-RGB quiet helper, UL type detection, general-status improvements - pn532_transport: minor cleanup - main.c: call board_rgb_led_quiet() at boot to kill onboard LED - sdkconfig.defaults: add board RGB Kconfig defaults - README, docs/LIMITATIONS, docs/PINOUT: expand and correct Made-with: Cursor
177 lines
14 KiB
Markdown
177 lines
14 KiB
Markdown
# PN532 NFC Toolkit
|
||
|
||
**The browser-native NFC lab that fits in your pocket.**
|
||
ESP32-S3 + PN532 + a serious web UI — no desktop app, no dongle software, no mystery binaries. You join Wi‑Fi, open a URL, and you’re operating the reader from **any phone or laptop**.
|
||
|
||
---
|
||
|
||
## Why this is the best tool for PN532 workflows
|
||
|
||
1. **Full remote control in the browser** — Dashboard, live scan, deep capture, read/write helpers, dictionary attack, raw PN532 commands, and live status — all over **HTTP + WebSocket**. You can stand across the room with your phone while the hardware sits on the bench.
|
||
|
||
2. **“Deep capture” is actually deep (for a PN532)** — On each **new UID** the firmware doesn’t stop at inventory: it builds a **JSON profile** with **MIFARE Classic** sector sweeps (default keys, Key **A** then **B** per sector trailer, **1K and 4K layouts**) with **every readable block as hex**, or an **Ultralight/NTAG-style page sweep** (up to **240 pages** in firmware). Unknown or non-Classic paths still get solid **ATQA/SAK/UID** plus whatever the stack can return — nothing is silently dropped.
|
||
|
||
3. **RAM session buffer with a hard stop + phone download** — Captures live in **on-chip RAM** (**48 KB** buffer, NDJSON lines). When the buffer is full, **RF polling pauses** so you never lose data to silent overflow. You tap **Download NDJSON**, get one file with every profile, then **Clear** to resume. That workflow is built for field audits, bench sessions, and anything where “I need the dump on my phone, now” matters.
|
||
|
||
4. **Self-hosted on the device** — Default **SoftAP**: SSID **`PN532-Toolkit`**, **open network** (no password, lab default). **mDNS** hostname **`pn532tool.local`** (HTTP port **80**). No cloud, no account, no telemetry.
|
||
|
||
5. **Honest architecture** — **ESP-IDF**, explicit components (`pn532_host`, `nfc_engine`, `net_service`), **Vite/React** UI embedded in **SPIFFS**. You extend it like real firmware, not a black-box sketch. PN532 talks **SPI, I2C, or UART (HSU)** — pins and bus are **menuconfig**, not guesses.
|
||
|
||
6. **Raw PN532 path** — When the high-level UI isn’t enough, **Raw** sends **command bytes**; the stack handles transport framing. **`GET /api/pn532/general-status`** exposes the controller’s **general status bytes** for debugging. That’s how you stay aligned with the real chip, not a toy abstraction.
|
||
|
||
7. **UI that matches the ambition** — Live **WebSocket** stream (scan + capture), **browser-side log** (thousands of events, exportable JSON), optional **`apiBase`** for talking to the box from another host, **HashRouter** so refreshes don’t fight the server, and **lab fixtures** (synthetic card blobs, keys, SHA checks) so you can validate the UI without burning tags.
|
||
|
||
This stack is **not** a Proxmark replacement (no LF, no raw carrier manipulation, no FPGA tricks). For **hosted NFC with a PN532**, it’s the **full pocket operator**: remote UI, deep reads, session export, Classic dictionary work, Ultralight read/write, card emulation hooks, and a straight line to the command set when you need it.
|
||
|
||
---
|
||
|
||
## Every feature, in one pass
|
||
|
||
### Firmware (what the chip actually does)
|
||
|
||
| Capability | What you get |
|
||
|------------|----------------|
|
||
| **Transport** | PN532 over **SPI** (default pins in Kconfig), **I2C**, or **UART (HSU)** — bit rate and GPIOs set in `menuconfig`. |
|
||
| **Continuous scan** | Background task polls passive targets; **~65 ms** when not in deep mode, **~220 ms** when deep capture is on (more time per tag for heavy work). Toggle via **`POST /api/nfc/scan`** or the Dashboard. |
|
||
| **RF sensitivity** | On init, **high passive-activation retries** (`RFConfiguration` **0x05**) so marginal tags get more chances to answer. |
|
||
| **Deep profile** | Per new UID: Classic sector layout detection (1K / 4K), per-sector auth with built-in default keys, block hex arrays; Ultralight/NTAG page reads up to firmware limit; structured **JSON** for export. |
|
||
| **Session RAM** | **~48 KB** NDJSON capture buffer; mutex-protected; **full** flag stops polling until you clear. |
|
||
| **HTTP API** | JSON in/out on documented routes; **CORS** headers on API responses; **`OPTIONS /*`** preflight for cross-origin clients. POST bodies are read **completely** (chunked TCP-safe). |
|
||
| **WebSocket** | **`/ws`**: JSON envelopes `{"channel":"…","payload":…}` — **`scan`** (inventory / `present:false` when tag leaves), **`capture`** (`recorded`, `bufferFull`, etc.), **`pong`** for keepalive. |
|
||
| **OTA endpoint** | **`POST /api/ota`** now performs a real **HTTPS OTA** with the ESP-IDF certificate bundle; successful updates reboot into the new slot and rollback support is enabled. |
|
||
| **Onboard RGB (DevKit-style)** | Optional **boot-time** shutdown of a **WS2812/SK6812** on **GPIO 48** (Kconfig) so the addressable LED isn’t left on a random color — **not** on all boards; power LEDs are hardware. |
|
||
| **Status** | **`/api/status`**: uptime, free heap, Wi‑Fi mode, **PN532 firmware version** (when reachable), scan flag, `targetActive`, and session stats. |
|
||
| **Probe API** | **`POST /api/nfc/probe`** builds a richer capability profile for the present tag, including structured-clone suitability and Type 2 `GET_VERSION` when supported. |
|
||
| **Structured clone flow** | **`POST /api/clone/capture`** normalizes MIFARE Classic and Type 2 tags into JSON snapshots; **`POST /api/clone/program`** writes supported snapshots back to a destination tag with safe defaults. |
|
||
| **Target mode** | **`POST /api/nfc/target/*`** provides `status`, `start`, `recv`, `send`, and `stop` around **`TgInitAsTarget` / `TgGetData` / `TgSetData`**, with a raw-parameter escape hatch when you need exact bytes. |
|
||
|
||
### Web UI (each page)
|
||
|
||
| Page | Purpose |
|
||
|------|---------|
|
||
| **Dash** | Live **status** poll, **continuous scan** on/off, **manual poll** (uses live tag from WebSocket when present), hero readout. |
|
||
| **Read-all (Capture)** | Toggle **deep capture**, **export** NDJSON from device RAM, **clear** buffer, progress vs **48 KB** budget. |
|
||
| **Read** | **MIFARE** block read with key + Key A/B; **Ultralight** page read; paste a **sector trailer** to decode **access bits** in the browser. |
|
||
| **Write** | **MIFARE** block write (16-byte hex, confirm dialog); **Ul** page write; **lab fixture** loader for offline testing flows; ties into **live UID** from WebSocket when available. |
|
||
| **Brute** | **Classic 1K / 4K** dictionary attack: firmware’s built-in key set + your lines, optional **bounded variations**; shows JSON result. |
|
||
| **Emu** | **`TgInitAsTarget`** and friends via **`POST /api/nfc/emulate-raw`** — you supply hex; **experimental** (bad sequences can require power cycle). |
|
||
| **KeyLab** | **XOR span** across up to three 6-byte base keys (all combinations → candidates for Brute/Keys); **Shannon entropy** on arbitrary hex blobs. **Stage for Keys** copies candidates into **sessionStorage** for the Keys page. |
|
||
| **Lib** | **Saved hex snippets** in localStorage; **seed** from built-in **lab catalog** samples; fixture loader for repeatable demos. |
|
||
| **Keys** | **Browser-local** key dictionary (defaults + your lines); **merge lab keys**; **import Key Lab stash** from staged session. Used when you copy keys into Brute manually — **not** auto-uploaded to flash. |
|
||
| **Raw** | Hex **command** payload → **`/api/raw/pn532`**; **general status** dump from **`/api/pn532/general-status`**; local TX/RX log. |
|
||
| **Set** | **`apiBase`** (empty = same host), **theme** (light/dark), reminder of SoftAP + mDNS. |
|
||
|
||
### Browser extras (no extra server)
|
||
|
||
- **Top bar** — **Download log JSON**: last **5 000** WebSocket messages from **sessionStorage** (scan + capture + anything else the firmware sends).
|
||
- **Scan feedback** — Visual/audio “hit” flourishes on new tag / capture events (debounced so it doesn’t spam).
|
||
- **Validation fixtures** — Shared **lab** blobs and notes (`validationFixtures.ts`) for UI testing and teaching.
|
||
|
||
---
|
||
|
||
## Remote deep capture (how to use it)
|
||
|
||
1. **Power on** → firmware starts **continuous scan** by default (toggle on the Dashboard).
|
||
2. Open **Read-all (Capture)** → enable **passive read-all** (`POST /api/session/deep`).
|
||
3. Present tags; each **new UID** appends one **NDJSON** line: full deep profile JSON.
|
||
4. When the **progress bar hits the end**, scanning **pauses** automatically.
|
||
5. Tap **Download NDJSON** — browser saves `pn532-deep-capture-*.ndjson`.
|
||
6. Tap **Clear buffer** to free RAM and **resume**.
|
||
|
||
### Session API (automation)
|
||
|
||
| Method | Path | Purpose |
|
||
|--------|------|---------|
|
||
| `GET` | `/api/status` | App name, uptime, heap, Wi‑Fi mode, PN532 version, `scanning`, `session` (`usedBytes`, `maxBytes`, `lines`, `full`, `deepCapture`) |
|
||
| `POST` | `/api/session/deep` | Body `{"enable": true}` or `false` |
|
||
| `GET` | `/api/session/export` | `text/plain` / NDJSON attachment |
|
||
| `POST` | `/api/session/clear` | Clears RAM buffer |
|
||
|
||
### Live NFC API
|
||
|
||
| Method | Path | Purpose |
|
||
|--------|------|---------|
|
||
| `POST` | `/api/nfc/poll` | One-shot poll; body may be `{}` — returns `present`, optional `tag` |
|
||
| `POST` | `/api/nfc/probe` | Rich capability probe for the current tag; adds clone/program hints and Type 2 `GET_VERSION` when available |
|
||
| `POST` | `/api/nfc/scan` | Body `{"enable": true}` or `false` — continuous background scan |
|
||
| `GET` | `/api/pn532/general-status` | Raw PN532 **general status** byte array |
|
||
| `POST` | `/api/mifare/read-block` | `block`, `key` (12 hex), `keyB` |
|
||
| `POST` | `/api/mifare/write-block` | `block`, `key`, `keyB`, `data` (32 hex) |
|
||
| `POST` | `/api/ul/read-page` | `page` |
|
||
| `POST` | `/api/ul/write-page` | `page`, `data` (8 hex) |
|
||
| `POST` | `/api/raw/pn532` | `frame` — PN532 command bytes (not a full transport frame) |
|
||
| `POST` | `/api/mifare/dictionary-attack` | `readerType` (`classic1k` / `classic4k`), `variations`, optional `keysHex[]`, optional `sectorFirst` / `sectorLast` |
|
||
| `POST` | `/api/clone/capture` | Optional `mode` (`auto` / `classic` / `type2`), optional `keysHex[]`, `variations`, `maxPages` — returns a structured snapshot for supported tags |
|
||
| `POST` | `/api/clone/program` | `snapshot` plus optional `includeTrailers` / `includeLockPages` — programs a supported destination tag with safe skips by default |
|
||
| `POST` | `/api/nfc/target/status` | Returns whether PN532 target mode is active |
|
||
| `POST` | `/api/nfc/target/start` | Start target mode from semantic JSON fields or `rawParamsHex` |
|
||
| `POST` | `/api/nfc/target/recv` | Wait for initiator bytes and return status + payload hex |
|
||
| `POST` | `/api/nfc/target/send` | Send target response bytes back to the initiator |
|
||
| `POST` | `/api/nfc/target/stop` | Return PN532 to normal SAM mode |
|
||
| `POST` | `/api/nfc/emulate-raw` | `hex` — raw command bytes (e.g. **0x8C** sequences) |
|
||
| `POST` | `/api/ota` | `url` (HTTPS), optional `reboot` — performs a real OTA update via `esp_https_ota` |
|
||
|
||
**WebSocket:** `GET /ws` — JSON text frames as above; send **`ping`**, receive **`pong`**.
|
||
|
||
### Browser memory log
|
||
|
||
The SPA keeps the **last 5 000** WebSocket events in **sessionStorage**. **Download log JSON** saves a pretty file on your phone or PC **without** using device RAM. Clear that log separately from the device capture buffer.
|
||
|
||
### Dictionary “brute” (Classic)
|
||
|
||
**Brute** page → **Classic 1K / 4K**, optional **variations**, paste extra keys. Firmware runs **`POST /api/mifare/dictionary-attack`**: built-in **public default keys** (same family as community lists like [Proxmark3 `mfc_default_keys.dic`](https://github.com/RfidResearchGroup/proxmark3/blob/master/client/dictionaries/mfc_default_keys.dic)) plus your lines, Key **A** then **B** per sector trailer. **Variations** add bounded XOR / nibble-style tweaks — **not** a full 2⁴⁸ search. You won’t magically open every sector; you **systematically** try keys people actually leak in the wild.
|
||
|
||
### Card emulation (PN532 target mode)
|
||
|
||
**Emulate** now has two layers: a higher-level **`/api/nfc/target/*`** subsystem for `TgInitAsTarget` / `TgGetData` / `TgSetData`, plus **`POST /api/nfc/emulate-raw`** when you want to drive the exact byte stream yourself. The structured path is safer; raw mode is still experimental and bad sequences can wedge the front end until re-init or power cycle.
|
||
|
||
---
|
||
|
||
## Build
|
||
|
||
### One-command local run
|
||
|
||
```bash
|
||
./start-firmware.sh
|
||
```
|
||
|
||
What it does (see script for flags): bootstraps ESP-IDF under a **local prefix** if needed, builds the web UI into **`firmware/data`**, targets **esp32s3**, auto-detects serial when possible, **builds and flashes**.
|
||
|
||
Options include **`--build-only`**, **`--monitor`**, **`--port`**, etc.
|
||
|
||
### Web UI → SPIFFS image
|
||
|
||
```bash
|
||
cd web
|
||
npm install
|
||
npm run build:fw
|
||
```
|
||
|
||
### Firmware (ESP-IDF 5.x)
|
||
|
||
```bash
|
||
cd firmware
|
||
idf.py set-target esp32s3
|
||
idf.py menuconfig # PN532 Host: SPI / I2C / UART + pins; Board indicators (RGB) if you use DevKitC-1
|
||
idf.py build flash monitor
|
||
```
|
||
|
||
### Common red PN532 module notes
|
||
|
||
The common red Elechouse-style PN532 board usually selects its bus with two onboard switches: **HSU = OFF/OFF**, **I2C = ON/OFF**, **SPI = OFF/ON**. HSU and I2C usually share header pins, and many boards ship in **HSU** by default, so a “dead” SPI/I2C setup is often just a switch mismatch rather than bad firmware.
|
||
|
||
From `firmware/`, with IDF already in your environment, you can also **`./flash.sh`** (uses **`ESPPORT`** or the first argument as the serial device).
|
||
|
||
See [docs/FLASHING.md](docs/FLASHING.md) and [docs/PINOUT.md](docs/PINOUT.md). Limitations and scope notes: [docs/LIMITATIONS.md](docs/LIMITATIONS.md).
|
||
|
||
---
|
||
|
||
## Repo layout
|
||
|
||
| Path | Role |
|
||
|------|------|
|
||
| `firmware/` | ESP-IDF: PN532 transport, NFC engine, **session buffer**, **deep profile**, HTTP/WebSocket, SPIFFS |
|
||
| `web/` | React UI: Dashboard, Capture, Read, Write, Brute, Emulate, KeyLab, Library, Keys, Raw, Settings |
|
||
| `docs/` | Flashing, pinout, workflows, limitations |
|
||
| `start-firmware.sh` | One-shot IDF bootstrap + build + flash |
|