From 63db10d400b4b48f4df8cf5c61544e2ec43f51b6 Mon Sep 17 00:00:00 2001 From: drjones Date: Tue, 7 Apr 2026 21:23:04 -0700 Subject: [PATCH] fw: robust HTTP body reads, CORS preflight, LED off at boot; update docs - 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 --- README.md | 27 +- docs/LIMITATIONS.md | 4 +- docs/PINOUT.md | 10 + .../components/net_service/CMakeLists.txt | 2 +- firmware/components/net_service/app_net.c | 870 +++++++++++++++++- .../include/nfc_engine/nfc_engine.h | 4 + firmware/components/nfc_engine/nfc_brute.c | 5 + firmware/components/nfc_engine/nfc_engine.c | 65 +- .../include/pn532_host/pn532_core.h | 14 + firmware/components/pn532_host/pn532_core.c | 78 ++ .../components/pn532_host/pn532_transport.c | 2 +- firmware/main/main.c | 2 + firmware/sdkconfig.defaults | 3 + 13 files changed, 1070 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 2524820..1870eb0 100644 --- a/README.md +++ b/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`**, password **`nfc-toolkit`** (WPA2). **mDNS** hostname **`pn532tool.local`** (HTTP port **80**). No cloud, no account, no telemetry. +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. @@ -38,9 +38,12 @@ 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`** returns **501** with a stub message — there is **no** in-field OTA in this repo yet; use **`idf.py flash`** (or the included scripts). | +| **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, session stats. | +| **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) @@ -89,16 +92,24 @@ 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` — hex command bytes | +| `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` | **Not implemented** — returns **501** | +| `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`**. @@ -112,7 +123,7 @@ The SPA keeps the **last 5 000** WebSocket events in **sessionStorage**. **Dow ### Card emulation (PN532 target mode) -**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**. +**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. --- @@ -145,6 +156,10 @@ 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). diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index 5de3021..e308986 100644 --- a/docs/LIMITATIONS.md +++ b/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; real-world mimicry depends on timing, UID size, and reader expectations — expose experimentally, not as “propable MIFARE magic.” +- **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. - **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` is a **stub** (`501`) — ship OTA with `esp_https_ota` + signed images when you need production updates. +- **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. diff --git a/docs/PINOUT.md b/docs/PINOUT.md index 28b09b2..c27f3b5 100644 --- a/docs/PINOUT.md +++ b/docs/PINOUT.md @@ -20,3 +20,13 @@ 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. diff --git a/firmware/components/net_service/CMakeLists.txt b/firmware/components/net_service/CMakeLists.txt index b5d345d..89e290f 100644 --- a/firmware/components/net_service/CMakeLists.txt +++ b/firmware/components/net_service/CMakeLists.txt @@ -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 + json spiffs vfs freertos nfc_engine pn532_host esp_https_ota app_update mbedtls ) diff --git a/firmware/components/net_service/app_net.c b/firmware/components/net_service/app_net.c index 2d483ec..f953451 100644 --- a/firmware/components/net_service/app_net.c +++ b/firmware/components/net_service/app_net.c @@ -1,6 +1,8 @@ #include "net_service/app_net.h" #include "esp_event.h" #include "esp_log.h" +#include "esp_crt_bundle.h" +#include "esp_https_ota.h" #include "esp_system.h" #include "esp_http_server.h" #include "esp_netif.h" @@ -36,6 +38,7 @@ static httpd_handle_t s_server; static SemaphoreHandle_t s_nfc_op_mu; static volatile bool s_scan = true; static TaskHandle_t s_scan_task; +static volatile bool s_target_active; static void nfc_access_lock(void) { @@ -105,6 +108,48 @@ static bool copy_json_string(const cJSON *item, char *dst, size_t cap) return true; } +static void bin_to_hex(const uint8_t *src, size_t len, char *dst, size_t dst_cap) +{ + if (!dst || dst_cap == 0) { + return; + } + if (!src || dst_cap < (len * 2 + 1)) { + dst[0] = '\0'; + return; + } + for (size_t i = 0; i < len; i++) { + snprintf(dst + i * 2, 3, "%02X", src[i]); + } + dst[len * 2] = '\0'; +} + +static bool add_hex_to_object(cJSON *obj, const char *name, const uint8_t *src, size_t len) +{ + if (!obj || !name || !src) { + return false; + } + char *hex = calloc(1, len * 2 + 1); + if (!hex) { + return false; + } + bin_to_hex(src, len, hex, len * 2 + 1); + cJSON_AddStringToObject(obj, name, hex); + free(hex); + return true; +} + +static int json_get_int_default(const cJSON *obj, const char *name, int fallback) +{ + cJSON *item = cJSON_GetObjectItemCaseSensitive((cJSON *)obj, name); + return cJSON_IsNumber(item) ? (int)cJSON_GetNumberValue(item) : fallback; +} + +static bool json_get_bool_default(const cJSON *obj, const char *name, bool fallback) +{ + cJSON *item = cJSON_GetObjectItemCaseSensitive((cJSON *)obj, name); + return cJSON_IsBool(item) ? cJSON_IsTrue(item) : fallback; +} + /** Read full POST body (httpd may return partial data in multiple recv calls). */ static int recv_body_capped(httpd_req_t *req, char *buf, size_t cap) { @@ -177,7 +222,7 @@ esp_err_t app_net_broadcast_json(const char *channel, const char *json_text) httpd_ws_frame_t pkt = {.type = HTTPD_WS_TYPE_TEXT, .payload = (uint8_t *)line, .len = (size_t)n}; for (size_t i = 0; i < fdcount; i++) { if (httpd_ws_get_fd_info(s_server, fds[i]) == HTTPD_WS_CLIENT_WEBSOCKET) { - (void)httpd_ws_send_frame_async(s_server, fds[i], &pkt); + (void)httpd_ws_send_data(s_server, fds[i], &pkt); } } return ESP_OK; @@ -250,6 +295,359 @@ static esp_err_t ensure_mifare_classic_tag_locked(nfc_tag_info_t *tag) return nfc_tag_is_mifare_classic(tag) ? ESP_OK : ESP_ERR_NOT_SUPPORTED; } +static esp_err_t reject_if_target_active(httpd_req_t *req) +{ + if (!s_target_active) { + return ESP_OK; + } + return send_json(req, cJSON_CreateString("target mode active; stop target mode first"), 409); +} + +static cJSON *build_probe_locked(const nfc_tag_info_t *tag) +{ + if (!tag) { + return NULL; + } + cJSON *root = cJSON_CreateObject(); + cJSON *tag_json = nfc_tag_to_json(tag); + cJSON *caps = cJSON_CreateObject(); + if (!root || !tag_json || !caps) { + cJSON_Delete(root); + cJSON_Delete(tag_json); + cJSON_Delete(caps); + return NULL; + } + cJSON_AddItemToObject(root, "tag", tag_json); + if (nfc_tag_is_mifare_classic(tag)) { + cJSON_AddStringToObject(caps, "family", nfc_tag_is_mifare_classic_4k(tag) ? "mifareClassic4k" : "mifareClassic"); + cJSON_AddNumberToObject(caps, "sectorCount", nfc_mifare_sector_count(tag)); + cJSON_AddBoolToObject(caps, "canRead", true); + cJSON_AddBoolToObject(caps, "canWrite", true); + cJSON_AddBoolToObject(caps, "canDictionaryAttack", true); + cJSON_AddBoolToObject(caps, "structuredCloneCapture", true); + cJSON_AddBoolToObject(caps, "structuredCloneProgram", true); + cJSON_AddBoolToObject(caps, "manufacturerBlockWritable", false); + cJSON_AddBoolToObject(caps, "targetModeRelevant", false); + cJSON_AddStringToObject(caps, "note", + "Classic clone/program flow can write user blocks and optional trailers, but not manufacturer block 0 on normal cards."); + } else if (nfc_tag_is_type2(tag)) { + cJSON_AddStringToObject(caps, "family", "type2"); + cJSON_AddBoolToObject(caps, "canRead", true); + cJSON_AddBoolToObject(caps, "canWrite", true); + cJSON_AddBoolToObject(caps, "structuredCloneCapture", true); + cJSON_AddBoolToObject(caps, "structuredCloneProgram", true); + cJSON_AddNumberToObject(caps, "safeUserPageStart", 4); + uint8_t version[8]; + if (nfc_type2_get_version(version) == ESP_OK) { + (void)add_hex_to_object(caps, "getVersionHex", version, sizeof(version)); + } + cJSON_AddStringToObject(caps, "note", + "Type 2 clone/program flow writes user pages by default and leaves manufacturer/lock pages untouched unless forced."); + } else { + cJSON_AddStringToObject(caps, "family", "unknown"); + cJSON_AddBoolToObject(caps, "canRead", false); + cJSON_AddBoolToObject(caps, "canWrite", false); + cJSON_AddBoolToObject(caps, "structuredCloneCapture", false); + cJSON_AddBoolToObject(caps, "structuredCloneProgram", false); + cJSON_AddStringToObject(caps, "note", + "Use raw commands and manual analysis; this tag does not match the current structured Classic/Type 2 workflows."); + } + cJSON_AddItemToObject(root, "capabilities", caps); + return root; +} + +static cJSON *find_sector_hit(const cJSON *attack, int sector) +{ + if (!attack) { + return NULL; + } + cJSON *hits = cJSON_GetObjectItemCaseSensitive((cJSON *)attack, "sectorHits"); + if (!cJSON_IsArray(hits)) { + return NULL; + } + int n = cJSON_GetArraySize(hits); + for (int i = 0; i < n; i++) { + cJSON *it = cJSON_GetArrayItem(hits, i); + cJSON *s = cJSON_GetObjectItemCaseSensitive(it, "sector"); + if (cJSON_IsNumber(s) && (int)cJSON_GetNumberValue(s) == sector) { + return it; + } + } + return NULL; +} + +static bool parse_sector_key_hit(const cJSON *hit, nfc_mifare_key_t *key, char *key_hex_out, size_t key_hex_cap) +{ + if (!hit || !key) { + return false; + } + cJSON *kh = cJSON_GetObjectItemCaseSensitive((cJSON *)hit, "keyHex"); + cJSON *kt = cJSON_GetObjectItemCaseSensitive((cJSON *)hit, "keyType"); + if (!cJSON_IsString(kh) || !kh->valuestring || !cJSON_IsString(kt) || !kt->valuestring) { + return false; + } + if (!hex_to_bin(kh->valuestring, key->key, 6)) { + return false; + } + key->key_b = (strcmp(kt->valuestring, "B") == 0); + if (key_hex_out && key_hex_cap > 0) { + size_t len = strlen(kh->valuestring); + if (len >= key_hex_cap) { + return false; + } + memcpy(key_hex_out, kh->valuestring, len + 1); + } + return true; +} + +static cJSON *build_type2_capture_locked(const nfc_tag_info_t *tag, int max_pages) +{ + if (!tag) { + return NULL; + } + if (max_pages < 4) { + max_pages = 4; + } + if (max_pages > 240) { + max_pages = 240; + } + cJSON *root = cJSON_CreateObject(); + cJSON *tag_json = nfc_tag_to_json(tag); + cJSON *pages = cJSON_CreateArray(); + if (!root || !tag_json || !pages) { + cJSON_Delete(root); + cJSON_Delete(tag_json); + cJSON_Delete(pages); + return NULL; + } + cJSON_AddStringToObject(root, "format", "type2"); + cJSON_AddItemToObject(root, "tag", tag_json); + uint8_t version[8]; + if (nfc_type2_get_version(version) == ESP_OK) { + (void)add_hex_to_object(root, "getVersionHex", version, sizeof(version)); + } + for (int page = 0; page < max_pages; page++) { + uint8_t data[4]; + if (nfc_ultralight_read_page((uint8_t)page, data) != ESP_OK) { + break; + } + char hx[9]; + bin_to_hex(data, sizeof(data), hx, sizeof(hx)); + cJSON_AddItemToArray(pages, cJSON_CreateString(hx)); + } + cJSON_AddNumberToObject(root, "pageCount", cJSON_GetArraySize(pages)); + cJSON_AddItemToObject(root, "pagesHex", pages); + cJSON_AddStringToObject(root, "programNote", + "Programming skips pages 0-3 by default because they contain manufacturer and lock/config data."); + return root; +} + +static cJSON *build_classic_capture_locked(const nfc_tag_info_t *tag, const uint8_t *extra_keys, size_t extra_n, + bool variations) +{ + if (!tag) { + return NULL; + } + int sectors = nfc_mifare_sector_count(tag); + if (sectors <= 0) { + return NULL; + } + cJSON *attack = nfc_mifare_dictionary_attack((nfc_tag_info_t *)tag, 0, (uint8_t)(sectors - 1), + extra_keys, extra_n, variations, NULL); + cJSON *root = cJSON_CreateObject(); + cJSON *tag_json = nfc_tag_to_json(tag); + cJSON *arr = cJSON_CreateArray(); + if (!attack || !root || !tag_json || !arr) { + cJSON_Delete(attack); + cJSON_Delete(root); + cJSON_Delete(tag_json); + cJSON_Delete(arr); + return NULL; + } + cJSON_AddStringToObject(root, "format", nfc_tag_is_mifare_classic_4k(tag) ? "mifareClassic4k" : "mifareClassic"); + cJSON_AddItemToObject(root, "tag", tag_json); + cJSON_AddNumberToObject(root, "sectorCount", sectors); + cJSON_AddItemToObject(root, "keySearch", attack); + for (int sector = 0; sector < sectors; sector++) { + int first_block = 0; + int block_count = 0; + uint8_t trailer_block = 0; + cJSON *sec = cJSON_CreateObject(); + if (!sec) { + cJSON_Delete(root); + cJSON_Delete(arr); + return NULL; + } + cJSON_AddNumberToObject(sec, "sector", sector); + if (!nfc_mifare_sector_layout(tag, sector, &first_block, &block_count, &trailer_block)) { + cJSON_AddBoolToObject(sec, "layoutError", true); + cJSON_AddItemToArray(arr, sec); + continue; + } + cJSON_AddNumberToObject(sec, "firstBlock", first_block); + cJSON_AddNumberToObject(sec, "blockCount", block_count); + cJSON_AddNumberToObject(sec, "trailerBlock", trailer_block); + cJSON *hit = find_sector_hit(attack, sector); + nfc_mifare_key_t key; + char key_hex[13]; + if (!parse_sector_key_hit(hit, &key, key_hex, sizeof(key_hex))) { + cJSON_AddBoolToObject(sec, "authFailed", true); + cJSON_AddItemToArray(arr, sec); + continue; + } + cJSON_AddStringToObject(sec, "keyHex", key_hex); + cJSON_AddStringToObject(sec, "keyType", key.key_b ? "B" : "A"); + if (nfc_mifare_authenticate_block(tag, trailer_block, &key) != ESP_OK) { + cJSON_AddBoolToObject(sec, "authFailed", true); + cJSON_AddItemToArray(arr, sec); + continue; + } + cJSON *blocks = cJSON_CreateArray(); + if (!blocks) { + cJSON_Delete(sec); + cJSON_Delete(root); + cJSON_Delete(arr); + return NULL; + } + for (int i = 0; i < block_count; i++) { + uint8_t block_data[NFC_BLOCK_LEN]; + if (nfc_mifare_read_block((uint8_t)(first_block + i), block_data) == ESP_OK) { + char hx[NFC_BLOCK_LEN * 2 + 1]; + bin_to_hex(block_data, sizeof(block_data), hx, sizeof(hx)); + cJSON_AddItemToArray(blocks, cJSON_CreateString(hx)); + } else { + cJSON_AddItemToArray(blocks, cJSON_CreateNull()); + } + } + cJSON_AddItemToObject(sec, "blocksHex", blocks); + cJSON_AddItemToArray(arr, sec); + } + cJSON_AddItemToObject(root, "sectors", arr); + cJSON_AddStringToObject(root, "programNote", + "Programming writes data blocks and optionally trailers; block 0 is always skipped on normal Classic cards."); + return root; +} + +static cJSON *program_type2_snapshot_locked(const cJSON *snapshot, bool include_lock_pages) +{ + cJSON *pages = cJSON_GetObjectItemCaseSensitive((cJSON *)snapshot, "pagesHex"); + if (!cJSON_IsArray(pages)) { + return NULL; + } + int count = cJSON_GetArraySize(pages); + int first_page = include_lock_pages ? 0 : 4; + cJSON *out = cJSON_CreateObject(); + if (!out) { + return NULL; + } + int written = 0; + int skipped = 0; + int failed = 0; + for (int page = 0; page < count; page++) { + if (page < first_page) { + skipped++; + continue; + } + cJSON *it = cJSON_GetArrayItem(pages, page); + if (!cJSON_IsString(it) || !it->valuestring) { + skipped++; + continue; + } + uint8_t data[4]; + if (!hex_to_bin(it->valuestring, data, sizeof(data))) { + failed++; + continue; + } + if (nfc_ultralight_write_page((uint8_t)page, data) == ESP_OK) { + written++; + } else { + failed++; + } + } + cJSON_AddStringToObject(out, "format", "type2"); + cJSON_AddNumberToObject(out, "writtenPages", written); + cJSON_AddNumberToObject(out, "skippedPages", skipped); + cJSON_AddNumberToObject(out, "failedPages", failed); + cJSON_AddBoolToObject(out, "includeLockPages", include_lock_pages); + return out; +} + +static cJSON *program_classic_snapshot_locked(const nfc_tag_info_t *tag, const cJSON *snapshot, bool include_trailers) +{ + if (!tag) { + return NULL; + } + cJSON *sectors = cJSON_GetObjectItemCaseSensitive((cJSON *)snapshot, "sectors"); + if (!cJSON_IsArray(sectors)) { + return NULL; + } + int written = 0; + int skipped = 0; + int failed = 0; + int sectors_n = cJSON_GetArraySize(sectors); + for (int si = 0; si < sectors_n; si++) { + cJSON *sec = cJSON_GetArrayItem(sectors, si); + cJSON *blocks = cJSON_GetObjectItemCaseSensitive(sec, "blocksHex"); + cJSON *first = cJSON_GetObjectItemCaseSensitive(sec, "firstBlock"); + cJSON *count = cJSON_GetObjectItemCaseSensitive(sec, "blockCount"); + cJSON *trailer = cJSON_GetObjectItemCaseSensitive(sec, "trailerBlock"); + if (!cJSON_IsArray(blocks) || !cJSON_IsNumber(first) || !cJSON_IsNumber(count) || !cJSON_IsNumber(trailer)) { + skipped++; + continue; + } + nfc_mifare_key_t key; + if (!parse_sector_key_hit(sec, &key, NULL, 0)) { + failed++; + continue; + } + uint8_t trailer_block = (uint8_t)cJSON_GetNumberValue(trailer); + if (nfc_mifare_authenticate_block(tag, trailer_block, &key) != ESP_OK) { + failed++; + continue; + } + int first_block = (int)cJSON_GetNumberValue(first); + int block_count = (int)cJSON_GetNumberValue(count); + int blocks_n = cJSON_GetArraySize(blocks); + int limit = block_count < blocks_n ? block_count : blocks_n; + for (int bi = 0; bi < limit; bi++) { + int block_no = first_block + bi; + if (block_no == 0) { + skipped++; + continue; + } + if (block_no == trailer_block && !include_trailers) { + skipped++; + continue; + } + cJSON *it = cJSON_GetArrayItem(blocks, bi); + if (!cJSON_IsString(it) || !it->valuestring) { + skipped++; + continue; + } + uint8_t data[NFC_BLOCK_LEN]; + if (!hex_to_bin(it->valuestring, data, sizeof(data))) { + failed++; + continue; + } + if (nfc_mifare_write_block((uint8_t)block_no, data) == ESP_OK) { + written++; + } else { + failed++; + } + } + } + cJSON *out = cJSON_CreateObject(); + if (!out) { + return NULL; + } + cJSON_AddStringToObject(out, "format", "mifareClassic"); + cJSON_AddNumberToObject(out, "writtenBlocks", written); + cJSON_AddNumberToObject(out, "skippedBlocks", skipped); + cJSON_AddNumberToObject(out, "failedBlocks", failed); + cJSON_AddBoolToObject(out, "includeTrailers", include_trailers); + return out; +} + static esp_err_t api_status(httpd_req_t *req) { cJSON *o = cJSON_CreateObject(); @@ -274,6 +672,7 @@ static esp_err_t api_status(httpd_req_t *req) } nfc_access_unlock(); cJSON_AddBoolToObject(o, "scanning", s_scan); + cJSON_AddBoolToObject(o, "targetActive", s_target_active); size_t cap_u = 0; uint32_t cap_l = 0; bool cap_f = false; @@ -365,6 +764,10 @@ static esp_err_t api_session_deep(httpd_req_t *req) static esp_err_t api_nfc_poll(httpd_req_t *req) { + esp_err_t gate = reject_if_target_active(req); + if (gate != ESP_OK) { + return gate; + } char drain[128]; (void)recv_body_capped(req, drain, sizeof(drain)); @@ -411,6 +814,10 @@ static esp_err_t api_scan(httpd_req_t *req) return send_json(req, cJSON_CreateString("bad json"), 400); } cJSON *en = cJSON_GetObjectItem(j, "enable"); + if (cJSON_IsBool(en) && cJSON_IsTrue(en) && s_target_active) { + cJSON_Delete(j); + return send_json(req, cJSON_CreateString("target mode active; stop target mode first"), 409); + } if (cJSON_IsBool(en)) { app_net_set_continuous_scan(cJSON_IsTrue(en)); } @@ -422,6 +829,10 @@ static esp_err_t api_scan(httpd_req_t *req) static esp_err_t api_general_status(httpd_req_t *req) { + esp_err_t gate = reject_if_target_active(req); + if (gate != ESP_OK) { + return gate; + } uint8_t gs[32]; size_t gl = 0; nfc_access_lock(); @@ -445,8 +856,154 @@ static esp_err_t api_general_status(httpd_req_t *req) return send_json(req, o, 200); } +static esp_err_t api_nfc_probe(httpd_req_t *req) +{ + esp_err_t gate = reject_if_target_active(req); + if (gate != ESP_OK) { + return gate; + } + char drain[64]; + (void)recv_body_capped(req, drain, sizeof(drain)); + nfc_tag_info_t tag; + nfc_access_lock(); + esp_err_t err = nfc_poll_passive_target(&tag); + if (err != ESP_OK) { + nfc_access_unlock(); + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "error", err == ESP_ERR_NOT_FOUND ? "no tag" : esp_err_to_name(err)); + return send_json(req, o, err == ESP_ERR_NOT_FOUND ? 400 : 500); + } + cJSON *o = build_probe_locked(&tag); + nfc_access_unlock(); + if (!o) { + return send_json(req, cJSON_CreateString("probe failed"), 500); + } + return send_json(req, o, 200); +} + +static esp_err_t api_clone_capture(httpd_req_t *req) +{ + esp_err_t gate = reject_if_target_active(req); + if (gate != ESP_OK) { + return gate; + } + char *body = NULL; + cJSON *j = NULL; + if (req->content_len > 0) { + body = recv_body_alloc(req, 8192, NULL); + if (!body) { + return send_json(req, cJSON_CreateString("body too large"), 400); + } + j = cJSON_Parse(body); + free(body); + if (!j) { + return send_json(req, cJSON_CreateString("bad json"), 400); + } + } else { + j = cJSON_CreateObject(); + } + char mode[16] = "auto"; + (void)copy_json_string(cJSON_GetObjectItemCaseSensitive(j, "mode"), mode, sizeof(mode)); + int max_pages = json_get_int_default(j, "maxPages", 64); + bool variations = json_get_bool_default(j, "variations", false); + uint8_t extra[96 * 6]; + size_t extra_n = 0; + cJSON *keys = cJSON_GetObjectItemCaseSensitive(j, "keysHex"); + if (cJSON_IsArray(keys)) { + int n = cJSON_GetArraySize(keys); + for (int i = 0; i < n && extra_n < 96; i++) { + cJSON *it = cJSON_GetArrayItem(keys, i); + if (cJSON_IsString(it) && it->valuestring && hex_to_bin(it->valuestring, extra + extra_n * 6, 6)) { + extra_n++; + } + } + } + cJSON_Delete(j); + + nfc_tag_info_t tag; + nfc_access_lock(); + esp_err_t err = nfc_poll_passive_target(&tag); + if (err != ESP_OK) { + nfc_access_unlock(); + return send_json(req, cJSON_CreateString("no tag present"), 400); + } + cJSON *out = NULL; + if (strcmp(mode, "classic") == 0) { + if (!nfc_tag_is_mifare_classic(&tag)) { + nfc_access_unlock(); + return send_json(req, cJSON_CreateString("tag is not MIFARE Classic"), 400); + } + out = build_classic_capture_locked(&tag, extra_n ? extra : NULL, extra_n, variations); + } else if (strcmp(mode, "type2") == 0) { + if (!nfc_tag_is_type2(&tag)) { + nfc_access_unlock(); + return send_json(req, cJSON_CreateString("tag is not Type 2 / Ultralight"), 400); + } + out = build_type2_capture_locked(&tag, max_pages); + } else if (nfc_tag_is_mifare_classic(&tag)) { + out = build_classic_capture_locked(&tag, extra_n ? extra : NULL, extra_n, variations); + } else if (nfc_tag_is_type2(&tag)) { + out = build_type2_capture_locked(&tag, max_pages); + } + nfc_access_unlock(); + if (!out) { + return send_json(req, cJSON_CreateString("capture unsupported or failed"), 400); + } + return send_json(req, out, 200); +} + +static esp_err_t api_clone_program(httpd_req_t *req) +{ + esp_err_t gate = reject_if_target_active(req); + if (gate != ESP_OK) { + return gate; + } + char *body = recv_body_alloc(req, 65536, NULL); + if (!body) { + return send_json(req, cJSON_CreateString("snapshot body too large"), 400); + } + cJSON *j = cJSON_Parse(body); + free(body); + if (!j) { + return send_json(req, cJSON_CreateString("bad json"), 400); + } + cJSON *snapshot = cJSON_GetObjectItemCaseSensitive(j, "snapshot"); + cJSON *format = snapshot ? cJSON_GetObjectItemCaseSensitive(snapshot, "format") : NULL; + bool include_trailers = json_get_bool_default(j, "includeTrailers", false); + bool include_lock_pages = json_get_bool_default(j, "includeLockPages", false); + if (!cJSON_IsObject(snapshot) || !cJSON_IsString(format) || !format->valuestring) { + cJSON_Delete(j); + return send_json(req, cJSON_CreateString("snapshot.format required"), 400); + } + + nfc_tag_info_t tag; + cJSON *out = NULL; + nfc_access_lock(); + if (strcmp(format->valuestring, "type2") == 0) { + esp_err_t err = ensure_ultralight_tag_locked(&tag); + if (err == ESP_OK) { + out = program_type2_snapshot_locked(snapshot, include_lock_pages); + } + } else if (strncmp(format->valuestring, "mifareClassic", 13) == 0) { + esp_err_t err = ensure_mifare_classic_tag_locked(&tag); + if (err == ESP_OK) { + out = program_classic_snapshot_locked(&tag, snapshot, include_trailers); + } + } + nfc_access_unlock(); + cJSON_Delete(j); + if (!out) { + return send_json(req, cJSON_CreateString("program failed or unsupported target"), 400); + } + return send_json(req, out, 200); +} + static esp_err_t api_mifare_read(httpd_req_t *req) { + esp_err_t gate = reject_if_target_active(req); + if (gate != ESP_OK) { + return gate; + } char buf[512]; char key_hex[13]; int r = recv_body_capped(req, buf, sizeof(buf)); @@ -506,6 +1063,10 @@ static esp_err_t api_mifare_read(httpd_req_t *req) static esp_err_t api_mifare_write(httpd_req_t *req) { + esp_err_t gate = reject_if_target_active(req); + if (gate != ESP_OK) { + return gate; + } char buf[512]; char key_hex[13]; char data_hex[33]; @@ -560,6 +1121,10 @@ static esp_err_t api_mifare_write(httpd_req_t *req) static esp_err_t api_ul_read(httpd_req_t *req) { + esp_err_t gate = reject_if_target_active(req); + if (gate != ESP_OK) { + return gate; + } char buf[128]; int r = recv_body_capped(req, buf, sizeof(buf)); if (r < 0) { @@ -602,6 +1167,10 @@ static esp_err_t api_ul_read(httpd_req_t *req) static esp_err_t api_ul_write(httpd_req_t *req) { + esp_err_t gate = reject_if_target_active(req); + if (gate != ESP_OK) { + return gate; + } char buf[128]; char data_hex[9]; int r = recv_body_capped(req, buf, sizeof(buf)); @@ -646,14 +1215,255 @@ static esp_err_t api_ul_write(httpd_req_t *req) return send_json(req, o, 200); } -static esp_err_t api_ota_stub(httpd_req_t *req) +static esp_err_t api_target_status(httpd_req_t *req) { - cJSON *o = cJSON_CreateString("Use idf.py app-flash or extend with esp_https_ota + bundle URL"); - return send_json(req, o, 501); + char drain[64]; + (void)recv_body_capped(req, drain, sizeof(drain)); + cJSON *o = cJSON_CreateObject(); + cJSON_AddBoolToObject(o, "active", s_target_active); + return send_json(req, o, 200); +} + +static esp_err_t api_target_start(httpd_req_t *req) +{ + char *body = req->content_len ? recv_body_alloc(req, 4096, NULL) : NULL; + cJSON *j = body ? cJSON_Parse(body) : cJSON_CreateObject(); + free(body); + if (!j) { + return send_json(req, cJSON_CreateString("bad json"), 400); + } + char raw_params_hex[600] = {0}; + char sens_res_hex[5] = "0400"; + char nfcid1t_hex[7] = "112233"; + char sel_res_hex[3] = "20"; + char felica_hex[37] = "01FEA2A3A4A5A6A7C0C1C2C3C4C5C6C7FFFF"; + char nfcid3t_hex[21] = "F0140102030405060708"; + char gt_hex[256] = {0}; + char tk_hex[256] = {0}; + (void)copy_json_string(cJSON_GetObjectItemCaseSensitive(j, "rawParamsHex"), raw_params_hex, sizeof(raw_params_hex)); + (void)copy_json_string(cJSON_GetObjectItemCaseSensitive(j, "sensResHex"), sens_res_hex, sizeof(sens_res_hex)); + (void)copy_json_string(cJSON_GetObjectItemCaseSensitive(j, "nfcid1tHex"), nfcid1t_hex, sizeof(nfcid1t_hex)); + (void)copy_json_string(cJSON_GetObjectItemCaseSensitive(j, "selResHex"), sel_res_hex, sizeof(sel_res_hex)); + (void)copy_json_string(cJSON_GetObjectItemCaseSensitive(j, "felicaParamsHex"), felica_hex, sizeof(felica_hex)); + (void)copy_json_string(cJSON_GetObjectItemCaseSensitive(j, "nfcid3tHex"), nfcid3t_hex, sizeof(nfcid3t_hex)); + (void)copy_json_string(cJSON_GetObjectItemCaseSensitive(j, "generalTargetHex"), gt_hex, sizeof(gt_hex)); + (void)copy_json_string(cJSON_GetObjectItemCaseSensitive(j, "historicalTargetHex"), tk_hex, sizeof(tk_hex)); + int mode_byte = json_get_int_default(j, "modeByte", 0x04); + int timeout_ms = json_get_int_default(j, "timeoutMs", 1000); + cJSON_Delete(j); + + uint8_t params[256]; + size_t params_len = 0; + if (raw_params_hex[0]) { + if (!hex_decode_flex(raw_params_hex, params, sizeof(params), ¶ms_len)) { + return send_json(req, cJSON_CreateString("rawParamsHex invalid"), 400); + } + } else { + uint8_t sens_res[2], nfcid1t[3], sel_res[1], felica[18], nfcid3t[10], gt[127], tk[127]; + size_t gt_len = 0, tk_len = 0; + if (!hex_to_bin(sens_res_hex, sens_res, sizeof(sens_res)) || + !hex_to_bin(nfcid1t_hex, nfcid1t, sizeof(nfcid1t)) || + !hex_to_bin(sel_res_hex, sel_res, sizeof(sel_res)) || + !hex_to_bin(felica_hex, felica, sizeof(felica)) || + !hex_to_bin(nfcid3t_hex, nfcid3t, sizeof(nfcid3t)) || + (gt_hex[0] && !hex_decode_flex(gt_hex, gt, sizeof(gt), >_len)) || + (tk_hex[0] && !hex_decode_flex(tk_hex, tk, sizeof(tk), &tk_len))) { + return send_json(req, cJSON_CreateString("target parameter hex invalid"), 400); + } + params[params_len++] = (uint8_t)mode_byte; + memcpy(params + params_len, sens_res, sizeof(sens_res)); + params_len += sizeof(sens_res); + memcpy(params + params_len, nfcid1t, sizeof(nfcid1t)); + params_len += sizeof(nfcid1t); + memcpy(params + params_len, sel_res, sizeof(sel_res)); + params_len += sizeof(sel_res); + memcpy(params + params_len, felica, sizeof(felica)); + params_len += sizeof(felica); + memcpy(params + params_len, nfcid3t, sizeof(nfcid3t)); + params_len += sizeof(nfcid3t); + params[params_len++] = (uint8_t)gt_len; + if (gt_len) { + memcpy(params + params_len, gt, gt_len); + params_len += gt_len; + } + params[params_len++] = (uint8_t)tk_len; + if (tk_len) { + memcpy(params + params_len, tk, tk_len); + params_len += tk_len; + } + } + + uint8_t resp[128]; + size_t rlen = 0; + bool prev_scan = s_scan; + s_scan = false; + nfc_access_lock(); + esp_err_t err = pn532_tg_init_as_target(params, params_len, resp, sizeof(resp), &rlen, timeout_ms); + nfc_access_unlock(); + if (err != ESP_OK) { + s_scan = prev_scan; + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "error", esp_err_to_name(err)); + return send_json(req, o, 400); + } + s_target_active = true; + cJSON *o = cJSON_CreateObject(); + cJSON_AddBoolToObject(o, "active", true); + cJSON_AddNumberToObject(o, "modeByte", mode_byte); + (void)add_hex_to_object(o, "responseHex", resp, rlen); + return send_json(req, o, 200); +} + +static esp_err_t api_target_recv(httpd_req_t *req) +{ + char buf[128]; + int timeout_ms = 15000; + int r = recv_body_capped(req, buf, sizeof(buf)); + if (r > 0) { + cJSON *j = cJSON_Parse(buf); + if (j) { + timeout_ms = json_get_int_default(j, "timeoutMs", timeout_ms); + cJSON_Delete(j); + } + } + if (!s_target_active) { + return send_json(req, cJSON_CreateString("target mode not active"), 409); + } + uint8_t resp[300]; + size_t rlen = 0; + nfc_access_lock(); + esp_err_t err = pn532_tg_get_data(resp, sizeof(resp), &rlen, timeout_ms); + nfc_access_unlock(); + if (err != ESP_OK) { + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "error", esp_err_to_name(err)); + return send_json(req, o, 400); + } + cJSON *o = cJSON_CreateObject(); + uint8_t status = rlen ? resp[0] : 0xFF; + cJSON_AddBoolToObject(o, "ok", status == 0x00); + cJSON_AddNumberToObject(o, "status", status); + if (rlen > 1) { + (void)add_hex_to_object(o, "dataHex", resp + 1, rlen - 1); + } else { + cJSON_AddStringToObject(o, "dataHex", ""); + } + return send_json(req, o, 200); +} + +static esp_err_t api_target_send(httpd_req_t *req) +{ + char buf[1024]; + int r = recv_body_capped(req, buf, sizeof(buf)); + if (r < 0) { + return send_json(req, cJSON_CreateString("body required"), 400); + } + cJSON *j = cJSON_Parse(buf); + if (!j) { + return send_json(req, cJSON_CreateString("bad json"), 400); + } + char data_hex[521] = {0}; + (void)copy_json_string(cJSON_GetObjectItemCaseSensitive(j, "dataHex"), data_hex, sizeof(data_hex)); + int timeout_ms = json_get_int_default(j, "timeoutMs", 1000); + cJSON_Delete(j); + if (!s_target_active) { + return send_json(req, cJSON_CreateString("target mode not active"), 409); + } + uint8_t data[260]; + size_t data_len = 0; + if (!data_hex[0] || !hex_decode_flex(data_hex, data, sizeof(data), &data_len)) { + return send_json(req, cJSON_CreateString("dataHex required"), 400); + } + uint8_t resp[64]; + size_t rlen = 0; + nfc_access_lock(); + esp_err_t err = pn532_tg_set_data(data, data_len, resp, sizeof(resp), &rlen, timeout_ms); + nfc_access_unlock(); + if (err != ESP_OK) { + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "error", esp_err_to_name(err)); + return send_json(req, o, 400); + } + cJSON *o = cJSON_CreateObject(); + uint8_t status = rlen ? resp[0] : 0xFF; + cJSON_AddBoolToObject(o, "ok", status == 0x00); + cJSON_AddNumberToObject(o, "status", status); + (void)add_hex_to_object(o, "responseHex", resp, rlen); + return send_json(req, o, 200); +} + +static esp_err_t api_target_stop(httpd_req_t *req) +{ + char drain[64]; + (void)recv_body_capped(req, drain, sizeof(drain)); + nfc_access_lock(); + esp_err_t err = pn532_sam_config_normal(); + nfc_access_unlock(); + s_target_active = false; + s_scan = true; + cJSON *o = cJSON_CreateObject(); + cJSON_AddBoolToObject(o, "active", false); + if (err != ESP_OK) { + cJSON_AddStringToObject(o, "error", esp_err_to_name(err)); + return send_json(req, o, 400); + } + return send_json(req, o, 200); +} + +static esp_err_t api_ota_real(httpd_req_t *req) +{ + char buf[1024]; + int r = recv_body_capped(req, buf, sizeof(buf)); + if (r < 0) { + return send_json(req, cJSON_CreateString("url required"), 400); + } + cJSON *j = cJSON_Parse(buf); + if (!j) { + return send_json(req, cJSON_CreateString("bad json"), 400); + } + char url[512] = {0}; + (void)copy_json_string(cJSON_GetObjectItemCaseSensitive(j, "url"), url, sizeof(url)); + bool reboot = json_get_bool_default(j, "reboot", true); + cJSON_Delete(j); + if (!url[0] || strncmp(url, "https://", 8) != 0) { + return send_json(req, cJSON_CreateString("https url required"), 400); + } + + bool prev_scan = s_scan; + s_scan = false; + esp_http_client_config_t http_cfg = { + .url = url, + .crt_bundle_attach = esp_crt_bundle_attach, + .keep_alive_enable = true, + .timeout_ms = 120000, + }; + esp_https_ota_config_t ota_cfg = { + .http_config = &http_cfg, + }; + esp_err_t err = esp_https_ota(&ota_cfg); + if (err != ESP_OK) { + s_scan = prev_scan; + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "error", esp_err_to_name(err)); + return send_json(req, o, 500); + } + cJSON *o = cJSON_CreateObject(); + cJSON_AddBoolToObject(o, "ok", true); + cJSON_AddBoolToObject(o, "rebooting", reboot); + esp_err_t send_err = send_json(req, o, 200); + if (send_err == ESP_OK && reboot) { + vTaskDelay(pdMS_TO_TICKS(500)); + esp_restart(); + } + return send_err; } static esp_err_t api_mifare_dictionary_attack(httpd_req_t *req) { + esp_err_t gate = reject_if_target_active(req); + if (gate != ESP_OK) { + return gate; + } char *body = recv_body_alloc(req, 8192, NULL); if (!body) { return send_json(req, cJSON_CreateString("no body or too large"), 400); @@ -711,6 +1521,10 @@ static esp_err_t api_mifare_dictionary_attack(httpd_req_t *req) static esp_err_t api_nfc_emulate_raw(httpd_req_t *req) { + esp_err_t gate = reject_if_target_active(req); + if (gate != ESP_OK) { + return gate; + } char buf[1024]; char hex[521]; int r = recv_body_capped(req, buf, sizeof(buf)); @@ -754,6 +1568,10 @@ static esp_err_t api_nfc_emulate_raw(httpd_req_t *req) static esp_err_t api_raw_pn532(httpd_req_t *req) { + esp_err_t gate = reject_if_target_active(req); + if (gate != ESP_OK) { + return gate; + } char buf[1024]; char hex[521]; int r = recv_body_capped(req, buf, sizeof(buf)); @@ -1026,11 +1844,41 @@ static httpd_handle_t start_server(void) httpd_stop(s); return NULL; } + u = (httpd_uri_t){.uri = "/api/nfc/probe", .method = HTTP_POST, .handler = api_nfc_probe}; + if (!register_uri_checked(s, &u)) { + httpd_stop(s); + return NULL; + } u = (httpd_uri_t){.uri = "/api/nfc/scan", .method = HTTP_POST, .handler = api_scan}; if (!register_uri_checked(s, &u)) { httpd_stop(s); return NULL; } + u = (httpd_uri_t){.uri = "/api/nfc/target/status", .method = HTTP_POST, .handler = api_target_status}; + if (!register_uri_checked(s, &u)) { + httpd_stop(s); + return NULL; + } + u = (httpd_uri_t){.uri = "/api/nfc/target/start", .method = HTTP_POST, .handler = api_target_start}; + if (!register_uri_checked(s, &u)) { + httpd_stop(s); + return NULL; + } + u = (httpd_uri_t){.uri = "/api/nfc/target/recv", .method = HTTP_POST, .handler = api_target_recv}; + if (!register_uri_checked(s, &u)) { + httpd_stop(s); + return NULL; + } + u = (httpd_uri_t){.uri = "/api/nfc/target/send", .method = HTTP_POST, .handler = api_target_send}; + if (!register_uri_checked(s, &u)) { + httpd_stop(s); + return NULL; + } + u = (httpd_uri_t){.uri = "/api/nfc/target/stop", .method = HTTP_POST, .handler = api_target_stop}; + if (!register_uri_checked(s, &u)) { + httpd_stop(s); + return NULL; + } u = (httpd_uri_t){.uri = "/api/pn532/general-status", .method = HTTP_GET, .handler = api_general_status}; if (!register_uri_checked(s, &u)) { httpd_stop(s); @@ -1061,7 +1909,17 @@ static httpd_handle_t start_server(void) httpd_stop(s); return NULL; } - u = (httpd_uri_t){.uri = "/api/ota", .method = HTTP_POST, .handler = api_ota_stub}; + u = (httpd_uri_t){.uri = "/api/clone/capture", .method = HTTP_POST, .handler = api_clone_capture}; + if (!register_uri_checked(s, &u)) { + httpd_stop(s); + return NULL; + } + u = (httpd_uri_t){.uri = "/api/clone/program", .method = HTTP_POST, .handler = api_clone_program}; + if (!register_uri_checked(s, &u)) { + httpd_stop(s); + return NULL; + } + u = (httpd_uri_t){.uri = "/api/ota", .method = HTTP_POST, .handler = api_ota_real}; if (!register_uri_checked(s, &u)) { httpd_stop(s); return NULL; @@ -1187,6 +2045,8 @@ esp_err_t app_net_init(void) } if (xTaskCreate(scan_loop_task, "nfc_scan", 20480, NULL, 5, &s_scan_task) != pdPASS) { + httpd_stop(s_server); + s_server = NULL; return ESP_ERR_NO_MEM; } return ESP_OK; diff --git a/firmware/components/nfc_engine/include/nfc_engine/nfc_engine.h b/firmware/components/nfc_engine/include/nfc_engine/nfc_engine.h index 6a553d3..c645f30 100644 --- a/firmware/components/nfc_engine/include/nfc_engine/nfc_engine.h +++ b/firmware/components/nfc_engine/include/nfc_engine/nfc_engine.h @@ -32,6 +32,9 @@ 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); @@ -42,6 +45,7 @@ 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); diff --git a/firmware/components/nfc_engine/nfc_brute.c b/firmware/components/nfc_engine/nfc_brute.c index ea83778..494e7c8 100644 --- a/firmware/components/nfc_engine/nfc_brute.c +++ b/firmware/components/nfc_engine/nfc_brute.c @@ -170,6 +170,7 @@ 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; } @@ -178,6 +179,7 @@ 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; } @@ -202,6 +204,7 @@ 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; } @@ -210,6 +213,7 @@ 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; } @@ -226,6 +230,7 @@ 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; } diff --git a/firmware/components/nfc_engine/nfc_engine.c b/firmware/components/nfc_engine/nfc_engine.c index d393628..ad1eee3 100644 --- a/firmware/components/nfc_engine/nfc_engine.c +++ b/firmware/components/nfc_engine/nfc_engine.c @@ -18,7 +18,12 @@ static void hint_type(nfc_tag_info_t *t) t->type_hint = 1; break; case 0x00: - t->type_hint = 2; + /* 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; + } break; default: t->type_hint = 0; @@ -41,6 +46,45 @@ 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(); @@ -248,6 +292,25 @@ 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) { diff --git a/firmware/components/pn532_host/include/pn532_host/pn532_core.h b/firmware/components/pn532_host/include/pn532_host/pn532_core.h index cedd138..2824df6 100644 --- a/firmware/components/pn532_host/include/pn532_host/pn532_core.h +++ b/firmware/components/pn532_host/include/pn532_host/pn532_core.h @@ -57,6 +57,20 @@ 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); diff --git a/firmware/components/pn532_host/pn532_core.c b/firmware/components/pn532_host/pn532_core.c index 8199644..5c7ee15 100644 --- a/firmware/components/pn532_host/pn532_core.c +++ b/firmware/components/pn532_host/pn532_core.c @@ -190,3 +190,81 @@ 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; +} diff --git a/firmware/components/pn532_host/pn532_transport.c b/firmware/components/pn532_host/pn532_transport.c index b61073a..187658b 100644 --- a/firmware/components/pn532_host/pn532_transport.c +++ b/firmware/components/pn532_host/pn532_transport.c @@ -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) - i2c_wakeup(); + ESP_RETURN_ON_ERROR(i2c_wakeup(), TAG, "i2c wake"); 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"); diff --git a/firmware/main/main.c b/firmware/main/main.c index 5361c31..567f358 100644 --- a/firmware/main/main.c +++ b/firmware/main/main.c @@ -1,4 +1,5 @@ #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" @@ -8,6 +9,7 @@ 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"); ESP_ERROR_CHECK(nfc_engine_init()); diff --git a/firmware/sdkconfig.defaults b/firmware/sdkconfig.defaults index ec202f9..3c6f093 100644 --- a/firmware/sdkconfig.defaults +++ b/firmware/sdkconfig.defaults @@ -31,3 +31,6 @@ CONFIG_LOG_MAXIMUM_LEVEL_VERBOSE=y # mDNS CONFIG_MDNS_MAX_SERVICES=10 + +CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y