Compare commits
1 Commits
docs/readm
...
d7cd79b7c7
| Author | SHA1 | Date | |
|---|---|---|---|
| d7cd79b7c7 |
27
README.md
27
README.md
@@ -13,7 +13,7 @@ ESP32-S3 + PN532 + a serious web UI — no desktop app, no dongle software, no m
|
||||
|
||||
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.
|
||||
4. **Self-hosted on the device** — Default **SoftAP**: SSID **`PN532-Toolkit`**, password **`nfc-toolkit`** (WPA2). **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.
|
||||
|
||||
@@ -38,12 +38,9 @@ This stack is **not** a Proxmark replacement (no LF, no raw carrier manipulation
|
||||
| **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. |
|
||||
| **OTA endpoint** | **`POST /api/ota`** returns **501** with a stub message — there is **no** in-field OTA in this repo yet; use **`idf.py flash`** (or the included scripts). |
|
||||
| **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. |
|
||||
| **Status** | **`/api/status`**: uptime, free heap, Wi‑Fi mode, **PN532 firmware version** (when reachable), scan flag, session stats. |
|
||||
|
||||
### Web UI (each page)
|
||||
|
||||
@@ -92,24 +89,16 @@ This stack is **not** a Proxmark replacement (no LF, no raw carrier manipulation
|
||||
| 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/raw/pn532` | `frame` — hex command bytes |
|
||||
| `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` |
|
||||
| `POST` | `/api/ota` | **Not implemented** — returns **501** |
|
||||
|
||||
**WebSocket:** `GET /ws` — JSON text frames as above; send **`ping`**, receive **`pong`**.
|
||||
|
||||
@@ -123,7 +112,7 @@ The SPA keeps the **last 5 000** WebSocket events in **sessionStorage**. **Dow
|
||||
|
||||
### 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.
|
||||
**Emulate** sends raw **`TgInitAsTarget` (0x8C)** payloads via **`POST /api/nfc/emulate-raw`**. You own the bytes (see NXP **UM0701**). This is **experimental**; a bad sequence can leave the front end wedged until **power cycle**.
|
||||
|
||||
---
|
||||
|
||||
@@ -156,10 +145,6 @@ idf.py menuconfig # PN532 Host: SPI / I2C / UART + pins; Board indicators (RGB
|
||||
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).
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
The PN532 is a **hosted NFC controller**, not a low-level RF lab instrument. This project exposes PN532 features honestly:
|
||||
|
||||
- **ISO14443-B**: reader support exists with **chip-level caveats** (anticollision / stack-dependent behavior). No promises of full mobile/PICC coverage.
|
||||
- **Card emulation / TG modes**: PN532 firmware supports target commands and this repo now exposes a structured target-mode API, but real-world mimicry still depends on timing, UID size, ATS/general bytes, and reader expectations. Treat it as a controllable subsystem, not magic full-card impersonation.
|
||||
- **Card emulation / TG modes**: PN532 firmware supports target commands; real-world mimicry depends on timing, UID size, and reader expectations — expose experimentally, not as “propable MIFARE magic.”
|
||||
- **Key recovery**: on-device “brute force” at Proxmark scale is **infeasible**. The UI provides **dictionary / manual** key workflows.
|
||||
- **Signal / RF metrics**: diagnostics use **PN532 status / timings / retries**, not calibrated dBm.
|
||||
- **OTA via UI**: `POST /api/ota` now performs real HTTPS OTA with the ESP-IDF certificate bundle. For production fleets, add your own release signing, manifest control, and hardware validation gates before broad rollout.
|
||||
- **OTA via UI**: `POST /api/ota` is a **stub** (`501`) — ship OTA with `esp_https_ota` + signed images when you need production updates.
|
||||
|
||||
@@ -20,13 +20,3 @@ The firmware Kconfig ships **example** GPIOs:
|
||||
## Transport selection
|
||||
|
||||
Start with the bus your breakout is jumpered for — **I2C is often simplest** on ESP32 for bring-up; SPI may require **lower clock** initially (e.g. **100 kHz**).
|
||||
|
||||
## Common red module switch matrix
|
||||
|
||||
For the common Elechouse-style red PN532 board, the onboard two-position switch matrix is typically:
|
||||
|
||||
- **HSU**: `OFF/OFF`
|
||||
- **I2C**: `ON/OFF`
|
||||
- **SPI**: `OFF/ON`
|
||||
|
||||
Many of these boards boot in **HSU** by default, and the **I2C** and **HSU** labels often refer to the same physical header pins from opposite sides of the PCB. If the bus looks dead, check the switches before changing firmware.
|
||||
|
||||
@@ -2,5 +2,5 @@ idf_component_register(
|
||||
SRCS "app_net.c"
|
||||
INCLUDE_DIRS "include"
|
||||
REQUIRES esp_http_server http_parser esp_wifi esp_netif nvs_flash mdns esp_timer
|
||||
json spiffs vfs freertos nfc_engine pn532_host esp_https_ota app_update mbedtls
|
||||
json spiffs vfs freertos nfc_engine pn532_host
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,26 +26,12 @@ typedef struct {
|
||||
bool key_b;
|
||||
} nfc_mifare_key_t;
|
||||
|
||||
/** Legacy: full init including transport — aborts on failure via caller's ESP_ERROR_CHECK. */
|
||||
esp_err_t nfc_engine_init(void);
|
||||
|
||||
/** Non-fatal first-boot init. Returns true if PN532 is present and configured. */
|
||||
bool nfc_engine_try_init(void);
|
||||
|
||||
/** Soft re-attach after transport is already open — skips bus re-init, tries chip commands.
|
||||
* Call from a background retry loop; always safe to call even if already ready. */
|
||||
bool nfc_engine_try_reattach(void);
|
||||
|
||||
/** Returns true when the PN532 was successfully initialised (or re-attached). */
|
||||
bool nfc_engine_is_ready(void);
|
||||
|
||||
esp_err_t nfc_poll_passive_target(nfc_tag_info_t *out);
|
||||
bool nfc_tag_is_mifare_classic(const nfc_tag_info_t *tag);
|
||||
bool nfc_tag_is_mifare_classic_4k(const nfc_tag_info_t *tag);
|
||||
bool nfc_tag_is_type2(const nfc_tag_info_t *tag);
|
||||
int nfc_mifare_sector_count(const nfc_tag_info_t *tag);
|
||||
bool nfc_mifare_sector_layout(const nfc_tag_info_t *tag, int sector, int *first_block, int *num_blocks,
|
||||
uint8_t *trailer_block);
|
||||
|
||||
esp_err_t nfc_mifare_authenticate_block(const nfc_tag_info_t *tag, uint8_t block_no,
|
||||
const nfc_mifare_key_t *key);
|
||||
@@ -56,7 +42,6 @@ esp_err_t nfc_ultralight_read_page(uint8_t page, uint8_t data[4]);
|
||||
esp_err_t nfc_ultralight_write_page(uint8_t page, const uint8_t data[4]);
|
||||
|
||||
esp_err_t nfc_ul_fast_read(uint8_t start_page, uint8_t *out, size_t out_max, size_t *got);
|
||||
esp_err_t nfc_type2_get_version(uint8_t version[8]);
|
||||
|
||||
/** Build JSON snapshot of last seen tag + optional blocks (caller frees cJSON). */
|
||||
cJSON *nfc_tag_to_json(const nfc_tag_info_t *tag);
|
||||
|
||||
@@ -170,7 +170,6 @@ cJSON *nfc_mifare_dictionary_attack(nfc_tag_info_t *tag, uint8_t sector_first, u
|
||||
attempts++;
|
||||
if (try_key_on_trailer(tag, trailer, trial, false)) {
|
||||
if (!add_sector_hit(hits, sec, trial, "A")) {
|
||||
cJSON_Delete(hits);
|
||||
cJSON_Delete(root);
|
||||
return NULL;
|
||||
}
|
||||
@@ -179,7 +178,6 @@ cJSON *nfc_mifare_dictionary_attack(nfc_tag_info_t *tag, uint8_t sector_first, u
|
||||
}
|
||||
if (try_key_on_trailer(tag, trailer, trial, true)) {
|
||||
if (!add_sector_hit(hits, sec, trial, "B")) {
|
||||
cJSON_Delete(hits);
|
||||
cJSON_Delete(root);
|
||||
return NULL;
|
||||
}
|
||||
@@ -204,7 +202,6 @@ cJSON *nfc_mifare_dictionary_attack(nfc_tag_info_t *tag, uint8_t sector_first, u
|
||||
attempts++;
|
||||
if (try_key_on_trailer(tag, trailer, trial, false)) {
|
||||
if (!add_sector_hit(hits, sec, trial, "A")) {
|
||||
cJSON_Delete(hits);
|
||||
cJSON_Delete(root);
|
||||
return NULL;
|
||||
}
|
||||
@@ -213,7 +210,6 @@ cJSON *nfc_mifare_dictionary_attack(nfc_tag_info_t *tag, uint8_t sector_first, u
|
||||
}
|
||||
if (try_key_on_trailer(tag, trailer, trial, true)) {
|
||||
if (!add_sector_hit(hits, sec, trial, "B")) {
|
||||
cJSON_Delete(hits);
|
||||
cJSON_Delete(root);
|
||||
return NULL;
|
||||
}
|
||||
@@ -230,7 +226,6 @@ cJSON *nfc_mifare_dictionary_attack(nfc_tag_info_t *tag, uint8_t sector_first, u
|
||||
if (!got) {
|
||||
cJSON *h = cJSON_CreateObject();
|
||||
if (!h) {
|
||||
cJSON_Delete(hits);
|
||||
cJSON_Delete(root);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
static const char *TAG = "nfc_engine";
|
||||
|
||||
static uint8_t s_tg = 1;
|
||||
static volatile bool s_pn532_ready = false;
|
||||
|
||||
bool nfc_engine_is_ready(void) { return s_pn532_ready; }
|
||||
|
||||
static void hint_type(nfc_tag_info_t *t)
|
||||
{
|
||||
@@ -21,12 +18,7 @@ static void hint_type(nfc_tag_info_t *t)
|
||||
t->type_hint = 1;
|
||||
break;
|
||||
case 0x00:
|
||||
/* Typical Type 2 inventory tuple is ATQA 0x0044 and 7-byte UID. */
|
||||
if ((t->atqa == 0x4400 || t->atqa == 0x0044) && t->uid_len == 7) {
|
||||
t->type_hint = 2;
|
||||
} else {
|
||||
t->type_hint = 0;
|
||||
}
|
||||
t->type_hint = 2;
|
||||
break;
|
||||
default:
|
||||
t->type_hint = 0;
|
||||
@@ -49,45 +41,6 @@ bool nfc_tag_is_type2(const nfc_tag_info_t *tag)
|
||||
return tag && tag->type_hint == 2;
|
||||
}
|
||||
|
||||
int nfc_mifare_sector_count(const nfc_tag_info_t *tag)
|
||||
{
|
||||
if (!nfc_tag_is_mifare_classic(tag)) {
|
||||
return 0;
|
||||
}
|
||||
return nfc_tag_is_mifare_classic_4k(tag) ? 40 : 16;
|
||||
}
|
||||
|
||||
bool nfc_mifare_sector_layout(const nfc_tag_info_t *tag, int sector, int *first_block, int *num_blocks,
|
||||
uint8_t *trailer_block)
|
||||
{
|
||||
if (!nfc_tag_is_mifare_classic(tag) || !first_block || !num_blocks || !trailer_block) {
|
||||
return false;
|
||||
}
|
||||
if (!nfc_tag_is_mifare_classic_4k(tag)) {
|
||||
if (sector < 0 || sector > 15) {
|
||||
return false;
|
||||
}
|
||||
*first_block = sector * 4;
|
||||
*num_blocks = 4;
|
||||
*trailer_block = (uint8_t)(sector * 4 + 3);
|
||||
return true;
|
||||
}
|
||||
if (sector < 0 || sector > 39) {
|
||||
return false;
|
||||
}
|
||||
if (sector <= 31) {
|
||||
*first_block = sector * 4;
|
||||
*num_blocks = 4;
|
||||
*trailer_block = (uint8_t)(sector * 4 + 3);
|
||||
} else {
|
||||
int r = sector - 32;
|
||||
*first_block = 128 + r * 16;
|
||||
*num_blocks = 16;
|
||||
*trailer_block = (uint8_t)(128 + r * 16 + 15);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
esp_err_t nfc_engine_init(void)
|
||||
{
|
||||
esp_err_t e = pn532_core_init();
|
||||
@@ -105,51 +58,9 @@ esp_err_t nfc_engine_init(void)
|
||||
if (pn532_rf_max_retries() != ESP_OK) {
|
||||
ESP_LOGW(TAG, "RF max retries config failed");
|
||||
}
|
||||
s_pn532_ready = true;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool nfc_engine_try_init(void)
|
||||
{
|
||||
esp_err_t e = pn532_core_init();
|
||||
if (e != ESP_OK) {
|
||||
ESP_LOGW(TAG, "PN532 not found (%s) — AP running, will retry every 5 s", esp_err_to_name(e));
|
||||
s_pn532_ready = false;
|
||||
return false;
|
||||
}
|
||||
e = pn532_sam_config_normal();
|
||||
if (e != ESP_OK) {
|
||||
ESP_LOGW(TAG, "SAM config: %s", esp_err_to_name(e));
|
||||
}
|
||||
uint8_t ic = 0, hi = 0, lo = 0;
|
||||
if (pn532_get_firmware_version(&ic, &hi, &lo) == ESP_OK) {
|
||||
ESP_LOGI(TAG, "PN532 fw ic=0x%02x %u.%u", ic, hi, lo);
|
||||
}
|
||||
if (pn532_rf_max_retries() != ESP_OK) {
|
||||
ESP_LOGW(TAG, "RF max retries config failed");
|
||||
}
|
||||
s_pn532_ready = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool nfc_engine_try_reattach(void)
|
||||
{
|
||||
/* Transport already open — just ping the chip and re-apply configuration. */
|
||||
esp_err_t e = pn532_sam_config_normal();
|
||||
if (e != ESP_OK) {
|
||||
return false;
|
||||
}
|
||||
uint8_t ic = 0, hi = 0, lo = 0;
|
||||
if (pn532_get_firmware_version(&ic, &hi, &lo) == ESP_OK) {
|
||||
ESP_LOGI(TAG, "PN532 reattached ic=0x%02x %u.%u", ic, hi, lo);
|
||||
}
|
||||
if (pn532_rf_max_retries() != ESP_OK) {
|
||||
ESP_LOGW(TAG, "RF max retries config failed");
|
||||
}
|
||||
s_pn532_ready = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
esp_err_t nfc_poll_passive_target(nfc_tag_info_t *out)
|
||||
{
|
||||
if (!out) {
|
||||
@@ -337,25 +248,6 @@ esp_err_t nfc_ul_fast_read(uint8_t start_page, uint8_t *out, size_t out_max, siz
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t nfc_type2_get_version(uint8_t version[8])
|
||||
{
|
||||
if (!version) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
uint8_t d[] = {0x60};
|
||||
uint8_t resp[32];
|
||||
size_t rlen = 0;
|
||||
esp_err_t e = in_data_tg(d, sizeof(d), resp, sizeof(resp), &rlen);
|
||||
if (e != ESP_OK) {
|
||||
return e;
|
||||
}
|
||||
if (rlen < 2 + 8 || resp[0] != (uint8_t)(PN532_CMD_INDATAEXCHANGE + 1) || resp[1] != 0x00) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
memcpy(version, resp + 2, 8);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
cJSON *nfc_tag_to_json(const nfc_tag_info_t *tag)
|
||||
{
|
||||
if (!tag || tag->uid_len > NFC_MAX_UID_LEN) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#include "nfc_engine/session_capture.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "esp_log.h"
|
||||
#include <string.h>
|
||||
|
||||
#define SESSION_CAPTURE_BYTES (48 * 1024)
|
||||
@@ -16,10 +15,6 @@ static SemaphoreHandle_t s_mu;
|
||||
void session_capture_init(void)
|
||||
{
|
||||
s_mu = xSemaphoreCreateMutex();
|
||||
if (!s_mu) {
|
||||
ESP_LOGE("session_capture", "mutex create failed — aborting");
|
||||
abort();
|
||||
}
|
||||
session_capture_clear();
|
||||
s_deep = false;
|
||||
}
|
||||
|
||||
@@ -57,20 +57,6 @@ esp_err_t pn532_in_communicate_thru(const uint8_t *data, size_t data_len,
|
||||
uint8_t *response, size_t response_max,
|
||||
size_t *response_len);
|
||||
|
||||
/** Enter PN532 target mode using TgInitAsTarget parameters (mode + target descriptors + optional GT). */
|
||||
esp_err_t pn532_tg_init_as_target(const uint8_t *params, size_t params_len,
|
||||
uint8_t *response, size_t response_max,
|
||||
size_t *response_len, int timeout_ms);
|
||||
|
||||
/** Receive bytes from the initiator while PN532 is in target mode. */
|
||||
esp_err_t pn532_tg_get_data(uint8_t *response, size_t response_max,
|
||||
size_t *response_len, int timeout_ms);
|
||||
|
||||
/** Send bytes back to the initiator while PN532 is in target mode. */
|
||||
esp_err_t pn532_tg_set_data(const uint8_t *data, size_t data_len,
|
||||
uint8_t *response, size_t response_max,
|
||||
size_t *response_len, int timeout_ms);
|
||||
|
||||
/** RF field on/off via RFConfiguration (0x32) item 0x01, RF field */
|
||||
esp_err_t pn532_rf_field(bool on);
|
||||
|
||||
|
||||
@@ -190,81 +190,3 @@ esp_err_t pn532_in_communicate_thru(const uint8_t *data, size_t data_len, uint8_
|
||||
*response_len = payload_len;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t pn532_tg_init_as_target(const uint8_t *params, size_t params_len, uint8_t *response,
|
||||
size_t response_max, size_t *response_len, int timeout_ms)
|
||||
{
|
||||
if (!params || !response || !response_len || params_len == 0 || params_len > PN532_EEPROM_MAX_CMD_PAYLOAD - 1) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
uint8_t buf[PN532_EEPROM_MAX_CMD_PAYLOAD];
|
||||
buf[0] = PN532_CMD_TGINITASTARGET;
|
||||
memcpy(buf + 1, params, params_len);
|
||||
uint8_t raw[PN532_EEPROM_MAX_CMD_PAYLOAD];
|
||||
size_t raw_len = 0;
|
||||
esp_err_t e = pn532_send_cmd(buf, 1 + params_len, raw, sizeof(raw), &raw_len, timeout_ms);
|
||||
if (e != ESP_OK) {
|
||||
return e;
|
||||
}
|
||||
if (raw_len < 2 || raw[0] != (uint8_t)(PN532_CMD_TGINITASTARGET + 1)) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
size_t payload_len = raw_len - 1;
|
||||
if (payload_len > response_max) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
memcpy(response, raw + 1, payload_len);
|
||||
*response_len = payload_len;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t pn532_tg_get_data(uint8_t *response, size_t response_max, size_t *response_len, int timeout_ms)
|
||||
{
|
||||
if (!response || !response_len) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
uint8_t cmd = PN532_CMD_TGGETDATA;
|
||||
uint8_t raw[PN532_EEPROM_MAX_CMD_PAYLOAD];
|
||||
size_t raw_len = 0;
|
||||
esp_err_t e = pn532_send_cmd(&cmd, 1, raw, sizeof(raw), &raw_len, timeout_ms);
|
||||
if (e != ESP_OK) {
|
||||
return e;
|
||||
}
|
||||
if (raw_len < 2 || raw[0] != (uint8_t)(PN532_CMD_TGGETDATA + 1)) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
size_t payload_len = raw_len - 1;
|
||||
if (payload_len > response_max) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
memcpy(response, raw + 1, payload_len);
|
||||
*response_len = payload_len;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t pn532_tg_set_data(const uint8_t *data, size_t data_len, uint8_t *response, size_t response_max,
|
||||
size_t *response_len, int timeout_ms)
|
||||
{
|
||||
if (!data || !response || !response_len || data_len == 0 || data_len > PN532_EEPROM_MAX_CMD_PAYLOAD - 1) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
uint8_t buf[PN532_EEPROM_MAX_CMD_PAYLOAD];
|
||||
buf[0] = PN532_CMD_TGSETDATA;
|
||||
memcpy(buf + 1, data, data_len);
|
||||
uint8_t raw[PN532_EEPROM_MAX_CMD_PAYLOAD];
|
||||
size_t raw_len = 0;
|
||||
esp_err_t e = pn532_send_cmd(buf, 1 + data_len, raw, sizeof(raw), &raw_len, timeout_ms);
|
||||
if (e != ESP_OK) {
|
||||
return e;
|
||||
}
|
||||
if (raw_len < 2 || raw[0] != (uint8_t)(PN532_CMD_TGSETDATA + 1)) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
size_t payload_len = raw_len - 1;
|
||||
if (payload_len > response_max) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
memcpy(response, raw + 1, payload_len);
|
||||
*response_len = payload_len;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ esp_err_t pn532_transport_exchange(const uint8_t *tx_body, size_t tx_body_len, u
|
||||
#if defined(CONFIG_PN532_TRANSPORT_SPI)
|
||||
ESP_RETURN_ON_ERROR(spi_write_frame(frame, pos), TAG, "spi wr");
|
||||
#elif defined(CONFIG_PN532_TRANSPORT_I2C)
|
||||
ESP_RETURN_ON_ERROR(i2c_wakeup(), TAG, "i2c wake");
|
||||
i2c_wakeup();
|
||||
ESP_RETURN_ON_ERROR(i2c_write_raw(frame, pos), TAG, "i2c wr");
|
||||
#elif defined(CONFIG_PN532_TRANSPORT_HSU)
|
||||
ESP_RETURN_ON_ERROR(hsu_write_raw(frame, pos), TAG, "hsu wr");
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
firmware/data/assets/index-hoMg1Qkq.css
Normal file
1
firmware/data/assets/index-hoMg1Qkq.css
Normal file
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
||||
href="https://fonts.googleapis.com/css2?family=Audiowide&family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=Orbitron:wght@500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script type="module" crossorigin src="/assets/index-BmIGATlK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bb0xTRCn.css">
|
||||
<script type="module" crossorigin src="/assets/index-9oJp152C.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-hoMg1Qkq.css">
|
||||
</head>
|
||||
<body class="bg-bubble-950 text-slate-200 antialiased selection:bg-bubble-accent/40 selection:text-bubble-950">
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "esp_log.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "board_rgb_off.h"
|
||||
#include "nfc_engine/nfc_engine.h"
|
||||
#include "nfc_engine/session_capture.h"
|
||||
@@ -9,15 +8,10 @@ static const char *TAG = "main";
|
||||
|
||||
void app_main(void)
|
||||
{
|
||||
(void)esp_ota_mark_app_valid_cancel_rollback();
|
||||
board_rgb_led_quiet();
|
||||
ESP_LOGI(TAG, "PN532 NFC Toolkit starting");
|
||||
nfc_engine_try_init(); /* non-fatal: logs warning if PN532 absent, AP starts regardless */
|
||||
ESP_ERROR_CHECK(nfc_engine_init());
|
||||
session_capture_init();
|
||||
ESP_ERROR_CHECK(app_net_init());
|
||||
if (nfc_engine_is_ready()) {
|
||||
ESP_LOGI(TAG, "PN532 ready · AP SSID PN532-Toolkit → http://192.168.4.1");
|
||||
} else {
|
||||
ESP_LOGW(TAG, "PN532 not found at boot — AP running, retrying · http://192.168.4.1");
|
||||
}
|
||||
ESP_LOGI(TAG, "Open AP SSID PN532-Toolkit — http://192.168.4.1");
|
||||
}
|
||||
|
||||
@@ -31,6 +31,3 @@ CONFIG_LOG_MAXIMUM_LEVEL_VERBOSE=y
|
||||
|
||||
# mDNS
|
||||
CONFIG_MDNS_MAX_SERVICES=10
|
||||
|
||||
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
|
||||
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NavLink, Route, Routes } from "react-router-dom";
|
||||
import BrowserLogBar from "./BrowserLogBar";
|
||||
import FlashBackdrop from "./FlashBackdrop";
|
||||
import ScanCashFlourish from "./ScanCashFlourish";
|
||||
import { NfcWsProvider, useNfcWs } from "./NfcWsContext";
|
||||
import { NfcWsProvider } from "./NfcWsContext";
|
||||
import { ToastHost } from "./toast";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import ReadAnalyze from "./pages/ReadAnalyze";
|
||||
@@ -17,29 +17,6 @@ import Brute from "./pages/Brute";
|
||||
import Emulate from "./pages/Emulate";
|
||||
import KeyLab from "./pages/KeyLab";
|
||||
|
||||
function HeaderBadge() {
|
||||
const { wsOk, pn532Connected } = useNfcWs();
|
||||
return (
|
||||
<span className="hidden font-mono text-[9px] text-bubble-mint/40 md:inline lg:text-[10px]">
|
||||
<span className={wsOk ? "text-bubble-accent/90" : "animate-pulse text-bubble-rose/60"}>●</span>{" "}
|
||||
RF_STACK{" "}
|
||||
<span
|
||||
className={
|
||||
!wsOk
|
||||
? "animate-pulse text-bubble-rose/80"
|
||||
: pn532Connected
|
||||
? "font-bold text-bubble-mint"
|
||||
: "animate-pulse text-amber-400"
|
||||
}
|
||||
>
|
||||
{!wsOk ? "WAIT" : pn532Connected ? "LIVE" : "NO RF"}
|
||||
</span>
|
||||
<span className="mx-1.5 text-bubble-mint/25">│</span>
|
||||
<span className="text-bubble-mint/50">ws://stream</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const nav = [
|
||||
["/", "Dash"],
|
||||
["/capture", "Read-all"],
|
||||
@@ -93,7 +70,12 @@ export default function App() {
|
||||
MAXIMAL
|
||||
</span>
|
||||
</motion.div>
|
||||
<HeaderBadge />
|
||||
<span className="hidden font-mono text-[9px] text-bubble-mint/40 md:inline lg:text-[10px]">
|
||||
<span className="text-bubble-accent/90">●</span> RF_STACK{" "}
|
||||
<span className="text-bubble-rose/80">LIVE</span>
|
||||
<span className="mx-1.5 text-bubble-mint/25">│</span>
|
||||
<span className="text-bubble-mint/50">ws://stream</span>
|
||||
</span>
|
||||
</div>
|
||||
<nav className="flex max-w-full flex-wrap justify-end gap-1 text-[10px] font-mono sm:gap-1.5 sm:text-[11px]">
|
||||
{nav.map(([to, label]) => (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { apiGet, type Status, type Tag } from "./api";
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import type { Tag } from "./api";
|
||||
import { useToolkitWs } from "./useWebSocket";
|
||||
|
||||
export type BrowserLogEntry = {
|
||||
@@ -12,7 +12,6 @@ export type CashVariant = "tag" | "vault";
|
||||
|
||||
type NfcCtx = {
|
||||
wsOk: boolean;
|
||||
pn532Connected: boolean;
|
||||
lastTag: Tag | null;
|
||||
tagPresent: boolean;
|
||||
log: BrowserLogEntry[];
|
||||
@@ -90,7 +89,6 @@ function isCaptureRecorded(payload: unknown): boolean {
|
||||
export function NfcWsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [lastTag, setLastTag] = useState<Tag | null>(null);
|
||||
const [tagPresent, setTagPresent] = useState(false);
|
||||
const [pn532Connected, setPn532Connected] = useState(false);
|
||||
const [log, setLog] = useState<BrowserLogEntry[]>(() => loadPersisted());
|
||||
const [cashWave, setCashWave] = useState(0);
|
||||
const [cashVariant, setCashVariant] = useState<CashVariant>("tag");
|
||||
@@ -123,11 +121,6 @@ export function NfcWsProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
} else if (ch === "capture" && isCaptureRecorded(o.payload)) {
|
||||
bump("vault");
|
||||
} else if (ch === "pn532") {
|
||||
const p = o.payload as { connected?: boolean } | undefined;
|
||||
if (typeof p?.connected === "boolean") {
|
||||
setPn532Connected(p.connected);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -138,19 +131,6 @@ export function NfcWsProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const wsOk = useToolkitWs(onMsg);
|
||||
|
||||
/* Fetch initial PN532 state as soon as WS connects (avoids waiting for first broadcast). */
|
||||
useEffect(() => {
|
||||
if (wsOk) {
|
||||
apiGet<Status>("/api/status")
|
||||
.then((s) => {
|
||||
if (typeof s.pn532Connected === "boolean") {
|
||||
setPn532Connected(s.pn532Connected);
|
||||
}
|
||||
})
|
||||
.catch(() => { /* silently ignore — badge will update on next WS event */ });
|
||||
}
|
||||
}, [wsOk]);
|
||||
|
||||
const applyScanPoll = useCallback(
|
||||
(present: boolean, tag?: Tag) => {
|
||||
if (!present) {
|
||||
@@ -185,7 +165,6 @@ export function NfcWsProvider({ children }: { children: React.ReactNode }) {
|
||||
const v = useMemo(
|
||||
() => ({
|
||||
wsOk,
|
||||
pn532Connected,
|
||||
lastTag,
|
||||
tagPresent,
|
||||
log,
|
||||
@@ -195,7 +174,7 @@ export function NfcWsProvider({ children }: { children: React.ReactNode }) {
|
||||
exportBrowserLog,
|
||||
applyScanPoll,
|
||||
}),
|
||||
[wsOk, pn532Connected, lastTag, tagPresent, log, cashWave, cashVariant, clearBrowserLog, exportBrowserLog, applyScanPoll],
|
||||
[wsOk, lastTag, tagPresent, log, cashWave, cashVariant, clearBrowserLog, exportBrowserLog, applyScanPoll],
|
||||
);
|
||||
|
||||
return <Ctx.Provider value={v}>{children}</Ctx.Provider>;
|
||||
|
||||
@@ -42,7 +42,6 @@ export type Status = {
|
||||
uptimeMs: number;
|
||||
freeHeap: number;
|
||||
wifiMode: number;
|
||||
pn532Connected: boolean;
|
||||
pn532?: { ic: number; fwHi: number; fwLo: number };
|
||||
scanning: boolean;
|
||||
session?: SessionInfo;
|
||||
|
||||
@@ -19,14 +19,7 @@ export default function Brute() {
|
||||
const keysHex = extraKeys
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.replace(/\s/g, "").toUpperCase())
|
||||
.filter((l) => l.length === 12 && /^[0-9A-F]+$/.test(l));
|
||||
const invalidCount = extraKeys
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.replace(/\s/g, ""))
|
||||
.filter((l) => l.length > 0 && (l.length !== 12 || !/^[0-9A-Fa-f]+$/.test(l))).length;
|
||||
if (invalidCount > 0) {
|
||||
toast(`${invalidCount} line(s) not valid 12-hex keys — skipped, firmware built-ins still run`);
|
||||
}
|
||||
.filter((l) => l.length === 12);
|
||||
const body = {
|
||||
readerType: reader,
|
||||
variations,
|
||||
|
||||
@@ -12,8 +12,8 @@ export default function Capture() {
|
||||
const refresh = useCallback(() => {
|
||||
apiGet<Status>("/api/status")
|
||||
.then(setSt)
|
||||
.catch(() => toast("Status unreachable", "err"));
|
||||
}, [toast]);
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
|
||||
@@ -152,22 +152,9 @@ export default function Dashboard() {
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-slate-500">PN532</span>
|
||||
{st.pn532Connected ? (
|
||||
<span className="flex items-center gap-2">
|
||||
{st.pn532 && (
|
||||
<span className="text-[10px] text-slate-500">
|
||||
IC{st.pn532.ic} v{st.pn532.fwHi}.{st.pn532.fwLo}
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded px-1.5 py-0.5 bg-bubble-mint/20 text-bubble-mint font-bold text-[10px] tracking-widest">
|
||||
READY
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="animate-pulse rounded px-1.5 py-0.5 bg-amber-500/20 text-amber-400 font-bold text-[10px] tracking-widest">
|
||||
SEARCHING
|
||||
</span>
|
||||
)}
|
||||
<span className="text-bubble-accent">
|
||||
{st.pn532 ? `IC${st.pn532.ic} v${st.pn532.fwHi}.${st.pn532.fwLo}` : "n/a"}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
|
||||
@@ -11,26 +11,13 @@ export default function Emulate() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const send = async () => {
|
||||
const cleanHex = hex.replace(/\s/g, "");
|
||||
if (!cleanHex) {
|
||||
toast("Hex payload is empty", "err");
|
||||
return;
|
||||
}
|
||||
if (cleanHex.length % 2 !== 0) {
|
||||
toast("Hex must have an even number of characters", "err");
|
||||
return;
|
||||
}
|
||||
if (!/^[0-9A-Fa-f]+$/.test(cleanHex)) {
|
||||
toast("Hex must contain only 0-9 A-F characters", "err");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setOut("");
|
||||
try {
|
||||
const r = await fetch(apiUrl("/api/nfc/emulate-raw"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ hex: cleanHex }),
|
||||
body: JSON.stringify({ hex: hex.replace(/\s/g, "") }),
|
||||
});
|
||||
const t = await r.text();
|
||||
if (!r.ok) {
|
||||
|
||||
@@ -19,8 +19,6 @@ export default function RawConsole() {
|
||||
push(`RX ${j.response || j.error || "?"}`);
|
||||
if (j.response) {
|
||||
toast("Frame OK");
|
||||
} else if (j.error) {
|
||||
toast(j.error, "err");
|
||||
}
|
||||
} catch (e) {
|
||||
push(`ERR ${String(e)}`);
|
||||
|
||||
@@ -31,12 +31,10 @@ export default function ReadAnalyze() {
|
||||
|
||||
const readUl = async () => {
|
||||
try {
|
||||
const j = await apiPost<{ data?: string; error?: string }>("/api/ul/read-page", { page: block });
|
||||
const j = await apiPost<{ data?: string }>("/api/ul/read-page", { page: block });
|
||||
if (j.data) {
|
||||
setHex(j.data + " (UL page)");
|
||||
toast("UL read OK");
|
||||
} else {
|
||||
toast(j.error ?? "no data returned", "err");
|
||||
}
|
||||
} catch (e) {
|
||||
toast(String(e), "err");
|
||||
|
||||
@@ -17,7 +17,6 @@ export default function WriteClone() {
|
||||
const [labBlob, setLabBlob] = useState<BinaryCardFixture | null>(null);
|
||||
const [ulPage, setUlPage] = useState(4);
|
||||
const [ulData, setUlData] = useState("00000000");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const write = async () => {
|
||||
const cleanKey = key.replace(/\s/g, "");
|
||||
@@ -37,14 +36,11 @@ export default function WriteClone() {
|
||||
if (!confirm("Write will modify tag memory. Continue?")) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiPost("/api/mifare/write-block", { block, key: cleanKey, keyB, data: cleanData });
|
||||
toast("Write OK");
|
||||
} catch (e) {
|
||||
toast(String(e), "err");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -57,14 +53,11 @@ export default function WriteClone() {
|
||||
if (!confirm("Ultralight page write — can brick OTP/lock bytes if misused. Continue?")) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiPost("/api/ul/write-page", { page: ulPage, data: h });
|
||||
toast("UL page write OK");
|
||||
} catch (e) {
|
||||
toast(String(e), "err");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -137,10 +130,9 @@ export default function WriteClone() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={write}
|
||||
disabled={busy}
|
||||
className="rounded-2xl bg-gradient-to-r from-bubble-rose to-orange-400 px-6 py-3 font-bold text-white shadow-glow disabled:opacity-50"
|
||||
className="rounded-2xl bg-gradient-to-r from-bubble-rose to-orange-400 px-6 py-3 font-bold text-white shadow-glow"
|
||||
>
|
||||
{busy ? "Writing…" : "Write block"}
|
||||
Write block
|
||||
</button>
|
||||
|
||||
<div className="border-t border-white/10 pt-8">
|
||||
@@ -170,10 +162,9 @@ export default function WriteClone() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={writeUl}
|
||||
disabled={busy}
|
||||
className="mt-4 rounded-2xl border border-bubble-accent/50 bg-bubble-accent/20 px-6 py-3 font-bold text-bubble-accent disabled:opacity-50"
|
||||
className="mt-4 rounded-2xl border border-bubble-accent/50 bg-bubble-accent/20 px-6 py-3 font-bold text-bubble-accent"
|
||||
>
|
||||
{busy ? "Writing…" : "Write UL page"}
|
||||
Write UL page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ const Ctx = createContext<(msg: string, kind?: Toast["kind"]) => void>(() => {})
|
||||
export function ToastHost({ children }: { children: React.ReactNode }) {
|
||||
const [list, setList] = useState<Toast[]>([]);
|
||||
const push = useCallback((msg: string, kind: Toast["kind"] = "info") => {
|
||||
const id = Date.now() + Math.random();
|
||||
const id = Date.now();
|
||||
setList((x) => [...x, { id, msg, kind }]);
|
||||
setTimeout(() => setList((x) => x.filter((t) => t.id !== id)), 4200);
|
||||
}, []);
|
||||
|
||||
Reference in New Issue
Block a user