diff --git a/TASKS.md b/TASKS.md index 29814b0..96b5244 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,17 +2,42 @@ **Last Updated:** 2026-02-04 -## Current Sprint: v1.1 - Passive Sensing & Multi-Sensor +## Current Sprint: v1.3 - OSINT & Fleet Ops ### P2 - Normal - [ ] Multi-sensor BLE correlation in esp-ctl (zone tracking) - [ ] Test OTA rollback (flash bad firmware, verify auto-revert) +- [ ] Create HA webhook automations for deauth_flood / unknown_probe ### P3 - Low - [ ] Document esp-crab dual-antenna capabilities - [ ] Document esp-radar console features - [ ] Pin mapping for ESP32-DevKitC V1 +## Completed: v1.3 - Security & OSINT + +- [x] HMAC command authentication (firmware + esp-ctl/esp-cmd/esp-fleet/esp-ota) +- [x] AUTH command (set/query/disable secret, NVS persisted) +- [x] auth=on/off in STATUS +- [x] Deauth flood detection (ring buffer, aggregate ALERT_DATA) +- [x] FLOODTHRESH command (count + window, NVS persisted) +- [x] flood_thresh field in STATUS +- [x] MAC OUI vendor lookup (`esp-ctl oui`, IEEE CSV database) +- [x] OSINT SQLite database (probe_ssids, device_sightings tables) +- [x] Watch daemon (`esp-ctl watch` — listen + enrich + store) +- [x] OSINT query CLI (`esp-ctl osint probes/devices/mac/stats`) +- [x] Home Assistant webhook integration (deauth_flood, unknown_probe, unknown_ble) +- [x] Watch config file (`~/.config/esp-ctl/watch.yaml`) + +## Completed: v1.2 + +- [x] On-device CSI feature extraction (amp_rms, amp_std, amp_max, amp_max_idx, energy) +- [x] CSIMODE command: RAW, COMPACT, HYBRID N (NVS persisted) +- [x] Compact payload format `"F:rms,std,max,idx,energy"` (~80% bandwidth reduction) +- [x] Hybrid mode: compact every packet, raw every Nth +- [x] STATUS fields: csi_mode, hybrid_n +- [x] Adaptive sampling reuses extracted energy (no duplicate computation in COMPACT/HYBRID) + ## Completed: v1.1 - [x] Sensor ID in data packets (hostname prefix on CSI_DATA, BLE_DATA, EVENT) @@ -98,10 +123,10 @@ ## Notes - Adaptive threshold varies by environment; 0.001-0.01 is a good starting range -- NVS keys: `send_rate`, `tx_power`, `adaptive`, `threshold`, `ble_scan`, `target_ip`, `target_port`, `hostname`, `boot_count` +- NVS keys: `send_rate`, `tx_power`, `adaptive`, `threshold`, `ble_scan`, `target_ip`, `target_port`, `hostname`, `boot_count`, `csi_mode`, `hybrid_n`, `auth_secret`, `flood_thresh`, `flood_window` - EVENT packets now include sensor hostname: `EVENT,,motion=... rate=... wander=...` -- ALERT_DATA format: `ALERT_DATA,,,,,` -- STATUS fields: `uptime=`, `uptime_s=`, `heap=`, `rssi=`, `channel=`, `tx_power=`, `rate=`, `csi_rate=`, `hostname=`, `version=`, `adaptive=`, `motion=`, `ble=`, `target=`, `temp=`, `csi_count=`, `boots=`, `rssi_min=`, `rssi_max=` +- ALERT_DATA format: `ALERT_DATA,,,,,` or `ALERT_DATA,,deauth_flood,,` +- STATUS fields: `uptime=`, `uptime_s=`, `heap=`, `rssi=`, `channel=`, `tx_power=`, `rate=`, `csi_rate=`, `hostname=`, `version=`, `adaptive=`, `motion=`, `ble=`, `target=`, `temp=`, `csi_count=`, `boots=`, `rssi_min=`, `rssi_max=`, `csi_mode=`, `hybrid_n=`, `auth=`, `flood_thresh=` - PROBE_DATA format: `PROBE_DATA,,,,` - Probe requests deduped per MAC (default 10s cooldown, tunable via PROBERATE) - mDNS service: `_esp-csi._udp` on data port (for sensor discovery) diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index 9e7845a..dff4dee 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -46,6 +46,15 @@ esp-cmd OTA http://pi:8070/fw # Trigger OTA update (use esp-ota instea esp-cmd HOSTNAME mydevice # Set hostname (NVS saved, mDNS updated) esp-cmd SCANRATE 60 # BLE scan restart interval (5-300s) esp-cmd PROBERATE 5 # Probe dedup cooldown (1-300s) +esp-cmd CSIMODE # Query current CSI output mode +esp-cmd CSIMODE RAW # Full I/Q array (default, ~900 B/pkt) +esp-cmd CSIMODE COMPACT # Features only (~200 B/pkt) +esp-cmd CSIMODE HYBRID 10 # Compact + raw every Nth packet +esp-cmd AUTH # Query auth status (on/off) +esp-cmd AUTH mysecret123 # Enable HMAC auth (8-64 char secret) +esp-cmd AUTH OFF # Disable auth +esp-cmd FLOODTHRESH # Query deauth flood threshold (5/10s) +esp-cmd FLOODTHRESH 10 30 # Set: 10 deauths in 30s = flood esp-cmd REBOOT # Restart device ``` @@ -91,6 +100,33 @@ After that, all updates are OTA. If new firmware crashes or hangs, the 30s watchdog reboots and bootloader automatically rolls back to the previous firmware. +## CSI Output Modes + +| Mode | Payload | Size | BW @ 100 Hz | +|------|---------|------|-------------| +| RAW (default) | `"[I,Q,I,Q,...]"` (128 values) | ~900 B | ~90 KB/s | +| COMPACT | `"F:rms,std,max,idx,energy"` | ~200 B | ~20 KB/s | +| HYBRID N | Compact every packet, raw every Nth | ~270 B avg (N=10) | ~27 KB/s | + +Compact features (per packet, from 64 I/Q subcarrier pairs): + +| Feature | Type | Description | +|---------|------|-------------| +| amp_rms | float | RMS amplitude = sqrt(mean(I²+Q²)) | +| amp_std | float | Std dev of per-subcarrier amplitudes | +| amp_max | float | Peak subcarrier amplitude | +| amp_max_idx | uint8 | Index (0-63) of peak subcarrier | +| energy | uint32 | L1 norm (same as adaptive sampling) | + +```bash +esp-cmd amber-maple.local CSIMODE COMPACT # Switch to compact +esp-cmd amber-maple.local CSIMODE HYBRID 10 # Raw every 10th packet +esp-cmd amber-maple.local CSIMODE RAW # Back to full I/Q +esp-cmd amber-maple.local CSIMODE # Query current mode +``` + +Mode is NVS-persisted and survives reboots. + ## Adaptive Sampling When enabled, the device automatically adjusts ping rate based on CSI wander: @@ -127,6 +163,48 @@ esp-ctl target --discover # Query targets via discovery Requires firmware with `_esp-csi._udp` mDNS service (v1.1+). +## HMAC Command Authentication + +```bash +# Set auth secret on device +esp-ctl cmd amber-maple.local "AUTH mysecretkey123" + +# Set env var so all tools sign commands automatically +export ESP_CMD_SECRET="mysecretkey123" # add to ~/.bashrc.secrets + +# All esp-cmd/esp-ctl/esp-fleet/esp-ota commands auto-sign when ESP_CMD_SECRET is set +# Unsigned commands are rejected with "ERR AUTH required" + +esp-ctl cmd amber-maple.local "AUTH OFF" # Disable auth +``` + +Protocol: `HMAC:<16hex>:` — first 16 hex chars of HMAC-SHA256(secret, cmd). + +## OUI Vendor Lookup + +```bash +esp-ctl oui --update # Download IEEE OUI database (~30k entries) +esp-ctl oui b0:be:76:a1:2d:c0 # Look up vendor for a MAC +``` + +## Watch Daemon & OSINT + +```bash +esp-ctl watch # Listen on :5500, store probes/BLE/alerts in DB +esp-ctl watch -c ~/my-config.yaml # Custom config (HA webhooks, known MACs) +esp-ctl watch -v # Verbose logging + +esp-ctl osint probes # Probe SSID history table +esp-ctl osint probes --mac AA:BB:CC:DD:EE:FF # Filter by MAC +esp-ctl osint devices # All device sightings +esp-ctl osint devices -t ble # BLE only +esp-ctl osint mac AA:BB:CC:DD:EE:FF # Full profile for one MAC +esp-ctl osint stats # Summary counts +``` + +DB: `~/.local/share/esp-ctl/osint.db` (SQLite with WAL). +Config: `~/.config/esp-ctl/watch.yaml` (HA webhooks, known MACs file). + ## Test CSI Reception ```bash @@ -168,13 +246,17 @@ esp-ctl listen -f alert # Monitor deauth/disassoc alerts (ESP32- All data packets include sensor hostname after the type tag: ``` -CSI_DATA,,seq,mac,rssi,rate,...,len,first_word,"[I,Q,...]" +CSI_DATA,,seq,mac,rssi,rate,...,len,first_word,"[I,Q,...]" # RAW mode +CSI_DATA,,seq,mac,rssi,rate,...,len,first_word,"F:rms,std,max,idx,energy" # COMPACT mode BLE_DATA,,mac,rssi,pub|rnd,name EVENT,,motion=0|1 rate= wander= ALERT_DATA,,deauth|disassoc,sender_mac,target_mac,rssi +ALERT_DATA,,deauth_flood,, PROBE_DATA,,mac,rssi,ssid ``` +**CSI mode discriminator:** quoted field starts with `[` (raw) or `F:` (compact). + **Note:** On original ESP32, promiscuous mode (ALERT_DATA, PROBE_DATA) is disabled because it breaks CSI data collection at the driver level. These packet types are only generated on ESP32-C6 and newer chips. @@ -202,6 +284,10 @@ only generated on ESP32-C6 and newer chips. | boots | 3 | Boot count (NVS persisted) | | rssi_min | -71 | Lowest RSSI since boot | | rssi_max | -62 | Highest RSSI since boot | +| csi_mode | raw/compact/hybrid | CSI output mode | +| hybrid_n | 10 | Raw packet interval (hybrid mode) | +| auth | on/off | HMAC command authentication | +| flood_thresh | 5/10 | Deauth flood: count/window_seconds | ## PROFILE Sections diff --git a/get-started/csi_recv_router/main/CMakeLists.txt b/get-started/csi_recv_router/main/CMakeLists.txt index a941e22..e03523a 100644 --- a/get-started/csi_recv_router/main/CMakeLists.txt +++ b/get-started/csi_recv_router/main/CMakeLists.txt @@ -1,2 +1,3 @@ idf_component_register(SRC_DIRS "." - INCLUDE_DIRS ".") + INCLUDE_DIRS "." + REQUIRES mbedtls) diff --git a/get-started/csi_recv_router/main/app_main.c b/get-started/csi_recv_router/main/app_main.c index d58b2cf..ad9b00d 100644 --- a/get-started/csi_recv_router/main/app_main.c +++ b/get-started/csi_recv_router/main/app_main.c @@ -41,6 +41,7 @@ #include "driver/temperature_sensor.h" #endif #include "mdns.h" +#include "mbedtls/md.h" #include "lwip/inet.h" #include "lwip/netdb.h" @@ -95,6 +96,24 @@ static volatile int8_t s_rssi_min = 0; static volatile int8_t s_rssi_max = -128; static uint32_t s_boot_count = 0; +/* CSI output mode */ +typedef enum { + CSI_MODE_RAW = 0, + CSI_MODE_COMPACT = 1, + CSI_MODE_HYBRID = 2, +} csi_mode_t; + +typedef struct { + float amp_rms; + float amp_std; + float amp_max; + uint8_t amp_max_idx; + uint32_t energy; +} csi_features_t; + +static csi_mode_t s_csi_mode = CSI_MODE_RAW; +static int s_hybrid_interval = 10; + /* Adaptive sampling */ #define WANDER_WINDOW 50 #define RATE_ACTIVE 100 @@ -128,6 +147,7 @@ static char s_udp_buffer[2048]; static char s_target_ip[16]; /* runtime target IP (NVS or Kconfig default) */ static uint16_t s_target_port; /* runtime target port */ static char s_hostname[32]; /* runtime hostname (NVS or Kconfig default) */ +static char s_auth_secret[65] = ""; /* empty = auth disabled */ /* --- NVS helpers --- */ @@ -170,10 +190,28 @@ static void config_load_nvs(void) } size_t hn_len = sizeof(s_hostname); nvs_get_str(h, "hostname", s_hostname, &hn_len); + int8_t csi_mode; + if (nvs_get_i8(h, "csi_mode", &csi_mode) == ESP_OK && csi_mode >= 0 && csi_mode <= 2) { + s_csi_mode = (csi_mode_t)csi_mode; + } + int32_t hybrid_n; + if (nvs_get_i32(h, "hybrid_n", &hybrid_n) == ESP_OK && hybrid_n >= 1 && hybrid_n <= 100) { + s_hybrid_interval = (int)hybrid_n; + } + size_t sec_len = sizeof(s_auth_secret); + nvs_get_str(h, "auth_secret", s_auth_secret, &sec_len); + int32_t flood_t; + if (nvs_get_i32(h, "flood_thresh", &flood_t) == ESP_OK && flood_t >= 1 && flood_t <= 100) { + s_flood_thresh = (int)flood_t; + } + int32_t flood_w; + if (nvs_get_i32(h, "flood_window", &flood_w) == ESP_OK && flood_w >= 1 && flood_w <= 300) { + s_flood_window_s = (int)flood_w; + } nvs_close(h); - ESP_LOGI(TAG, "NVS loaded: hostname=%s rate=%d tx_power=%d adaptive=%d threshold=%.6f ble=%d target=%s:%d", + ESP_LOGI(TAG, "NVS loaded: hostname=%s rate=%d tx_power=%d adaptive=%d threshold=%.6f ble=%d target=%s:%d csi_mode=%d hybrid_n=%d", s_hostname, s_send_frequency, s_tx_power_dbm, s_adaptive, s_motion_threshold, s_ble_enabled, - s_target_ip, s_target_port); + s_target_ip, s_target_port, (int)s_csi_mode, s_hybrid_interval); } else { ESP_LOGI(TAG, "NVS: no saved config, using defaults"); } @@ -298,6 +336,52 @@ static void led_task(void *arg) } } +/* --- CSI feature extraction --- */ + +static void csi_extract_features(const int8_t *buf, int len, float gain, csi_features_t *out) +{ + int n_pairs = len / 2; + if (n_pairs > 64) n_pairs = 64; + + float amps[64]; + float sum_sq = 0.0f; + float sum_amp = 0.0f; + float max_amp = 0.0f; + uint8_t max_idx = 0; + uint32_t energy = 0; + + /* Pass 1: compute per-subcarrier amplitude, accumulate sums */ + for (int i = 0; i < n_pairs; i++) { + float iv = gain * buf[i * 2]; + float qv = gain * buf[i * 2 + 1]; + float amp = sqrtf(iv * iv + qv * qv); + amps[i] = amp; + sum_amp += amp; + sum_sq += iv * iv + qv * qv; + energy += abs(buf[i * 2]) + abs(buf[i * 2 + 1]); + if (amp > max_amp) { + max_amp = amp; + max_idx = (uint8_t)i; + } + } + + float mean_amp = (n_pairs > 0) ? sum_amp / n_pairs : 0.0f; + + /* Pass 2: compute variance */ + float var = 0.0f; + for (int i = 0; i < n_pairs; i++) { + float d = amps[i] - mean_amp; + var += d * d; + } + var = (n_pairs > 0) ? var / n_pairs : 0.0f; + + out->amp_rms = (n_pairs > 0) ? sqrtf(sum_sq / n_pairs) : 0.0f; + out->amp_std = sqrtf(var); + out->amp_max = max_amp; + out->amp_max_idx = max_idx; + out->energy = energy; +} + /* --- CSI callback --- */ static void wifi_csi_rx_cb(void *ctx, wifi_csi_info_t *info) @@ -339,6 +423,17 @@ static void wifi_csi_rx_cb(void *ctx, wifi_csi_info_t *info) ESP_LOGD(TAG, "compensate_gain %f, agc_gain %d, fft_gain %d", compensate_gain, agc_gain, fft_gain); #endif + /* Extract features (used for compact mode and adaptive sampling) */ + csi_features_t features = {0}; + if (s_csi_mode != CSI_MODE_RAW || s_adaptive) { + csi_extract_features(info->buf, info->len, compensate_gain, &features); + } + + /* Determine whether to send raw I/Q data this packet */ + csi_mode_t mode = s_csi_mode; + bool send_raw = (mode == CSI_MODE_RAW) || + (mode == CSI_MODE_HYBRID && (s_csi_count % s_hybrid_interval) == 0); + /* Build CSI data into buffer for UDP transmission */ int pos = 0; @@ -364,35 +459,41 @@ static void wifi_csi_rx_cb(void *ctx, wifi_csi_info_t *info) rx_ctrl->timestamp, rx_ctrl->ant, rx_ctrl->sig_len, rx_ctrl->rx_state); #endif + if (send_raw) { + /* Raw I/Q array payload */ #if (CONFIG_IDF_TARGET_ESP32C5 || CONFIG_IDF_TARGET_ESP32C61) && CSI_FORCE_LLTF - int16_t csi = ((int16_t)(((((uint16_t)info->buf[1]) << 8) | info->buf[0]) << 4) >> 4); - pos += snprintf(s_udp_buffer + pos, sizeof(s_udp_buffer) - pos, - ",%d,%d,\"[%d", (info->len - 2) / 2, info->first_word_invalid, (int16_t)(compensate_gain * csi)); - for (int i = 2; i < (info->len - 2); i += 2) { - csi = ((int16_t)(((((uint16_t)info->buf[i + 1]) << 8) | info->buf[i]) << 4) >> 4); - pos += snprintf(s_udp_buffer + pos, sizeof(s_udp_buffer) - pos, ",%d", (int16_t)(compensate_gain * csi)); - } + int16_t csi = ((int16_t)(((((uint16_t)info->buf[1]) << 8) | info->buf[0]) << 4) >> 4); + pos += snprintf(s_udp_buffer + pos, sizeof(s_udp_buffer) - pos, + ",%d,%d,\"[%d", (info->len - 2) / 2, info->first_word_invalid, (int16_t)(compensate_gain * csi)); + for (int i = 2; i < (info->len - 2); i += 2) { + csi = ((int16_t)(((((uint16_t)info->buf[i + 1]) << 8) | info->buf[i]) << 4) >> 4); + pos += snprintf(s_udp_buffer + pos, sizeof(s_udp_buffer) - pos, ",%d", (int16_t)(compensate_gain * csi)); + } #else - pos += snprintf(s_udp_buffer + pos, sizeof(s_udp_buffer) - pos, - ",%d,%d,\"[%d", info->len, info->first_word_invalid, (int16_t)(compensate_gain * info->buf[0])); - for (int i = 1; i < info->len; i++) { - pos += snprintf(s_udp_buffer + pos, sizeof(s_udp_buffer) - pos, ",%d", (int16_t)(compensate_gain * info->buf[i])); - } + pos += snprintf(s_udp_buffer + pos, sizeof(s_udp_buffer) - pos, + ",%d,%d,\"[%d", info->len, info->first_word_invalid, (int16_t)(compensate_gain * info->buf[0])); + for (int i = 1; i < info->len; i++) { + pos += snprintf(s_udp_buffer + pos, sizeof(s_udp_buffer) - pos, ",%d", (int16_t)(compensate_gain * info->buf[i])); + } #endif - pos += snprintf(s_udp_buffer + pos, sizeof(s_udp_buffer) - pos, "]\"\n"); + pos += snprintf(s_udp_buffer + pos, sizeof(s_udp_buffer) - pos, "]\"\n"); + } else { + /* Compact feature payload */ + pos += snprintf(s_udp_buffer + pos, sizeof(s_udp_buffer) - pos, + ",%d,%d,\"F:%.1f,%.1f,%.1f,%u,%lu\"\n", + info->len, info->first_word_invalid, + features.amp_rms, features.amp_std, features.amp_max, + (unsigned)features.amp_max_idx, (unsigned long)features.energy); + } /* Send via UDP */ if (s_udp_socket >= 0) { sendto(s_udp_socket, s_udp_buffer, pos, 0, (struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr)); } - /* Compute CSI energy for adaptive sampling */ + /* Adaptive sampling: reuse extracted energy (features always computed when adaptive is on) */ if (s_adaptive) { - uint32_t energy = 0; - for (int i = 0; i < info->len; i++) { - energy += abs(info->buf[i]); - } - s_energy_buf[s_energy_idx % WANDER_WINDOW] = energy; + s_energy_buf[s_energy_idx % WANDER_WINDOW] = features.energy; s_energy_idx++; } @@ -723,6 +824,19 @@ typedef struct { uint16_t seq_ctrl; } __attribute__((packed)) wifi_ieee80211_mac_hdr_t; +/* Deauth flood detection */ +#define FLOOD_WINDOW_DEFAULT 10 +#define FLOOD_THRESH_DEFAULT 5 +#define FLOOD_RING_SIZE 64 + +static int s_flood_thresh = FLOOD_THRESH_DEFAULT; +static int s_flood_window_s = FLOOD_WINDOW_DEFAULT; +static bool s_flood_active = false; +static int64_t s_flood_alert_ts = 0; +static struct { int64_t ts; } s_deauth_ring[FLOOD_RING_SIZE]; +static int s_deauth_ring_head = 0; +static int s_deauth_ring_count = 0; + /* Probe request deduplication: report each MAC at most once per N seconds */ #define PROBE_DEDUP_SIZE 32 #define PROBE_DEDUP_DEFAULT_US 10000000LL @@ -759,6 +873,28 @@ static bool probe_dedup_check(const uint8_t *mac) return false; } +static int deauth_flood_check(void) +{ + int64_t now = esp_timer_get_time(); + int64_t window_us = (int64_t)s_flood_window_s * 1000000LL; + + /* Record this event */ + s_deauth_ring[s_deauth_ring_head].ts = now; + s_deauth_ring_head = (s_deauth_ring_head + 1) % FLOOD_RING_SIZE; + if (s_deauth_ring_count < FLOOD_RING_SIZE) { + s_deauth_ring_count++; + } + + /* Count events within window */ + int count = 0; + for (int i = 0; i < s_deauth_ring_count; i++) { + if (now - s_deauth_ring[i].ts <= window_us) { + count++; + } + } + return count; +} + static void wifi_promiscuous_cb(void *buf, wifi_promiscuous_pkt_type_t type) { if (type != WIFI_PKT_MGMT) return; @@ -772,31 +908,56 @@ static void wifi_promiscuous_cb(void *buf, wifi_promiscuous_pkt_type_t type) if (subtype == 0x0C || subtype == 0x0A) { const char *type_str = (subtype == 0x0C) ? "deauth" : "disassoc"; - char alert[160]; - int len = snprintf(alert, sizeof(alert), - "ALERT_DATA,%s,%s," - "%02x:%02x:%02x:%02x:%02x:%02x," - "%02x:%02x:%02x:%02x:%02x:%02x," - "%d\n", - s_hostname, type_str, - hdr->addr2[0], hdr->addr2[1], hdr->addr2[2], - hdr->addr2[3], hdr->addr2[4], hdr->addr2[5], - hdr->addr1[0], hdr->addr1[1], hdr->addr1[2], - hdr->addr1[3], hdr->addr1[4], hdr->addr1[5], - pkt->rx_ctrl.rssi); + int flood_count = deauth_flood_check(); + char alert[192]; + int len; - if (s_udp_socket >= 0) { - sendto(s_udp_socket, alert, len, 0, - (struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr)); + if (flood_count >= s_flood_thresh) { + /* Flood detected — send aggregate alert, suppress individual */ + int64_t now = esp_timer_get_time(); + if (!s_flood_active || (now - s_flood_alert_ts > 5000000LL)) { + /* Send flood alert at most every 5 seconds */ + s_flood_active = true; + s_flood_alert_ts = now; + len = snprintf(alert, sizeof(alert), + "ALERT_DATA,%s,deauth_flood,%d,%d\n", + s_hostname, flood_count, s_flood_window_s); + if (s_udp_socket >= 0) { + sendto(s_udp_socket, alert, len, 0, + (struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr)); + } + ESP_LOGW(TAG, "ALERT: deauth_flood count=%d window=%ds", flood_count, s_flood_window_s); + } + } else { + /* Below threshold — send individual alert */ + if (s_flood_active) { + s_flood_active = false; + } + len = snprintf(alert, sizeof(alert), + "ALERT_DATA,%s,%s," + "%02x:%02x:%02x:%02x:%02x:%02x," + "%02x:%02x:%02x:%02x:%02x:%02x," + "%d\n", + s_hostname, type_str, + hdr->addr2[0], hdr->addr2[1], hdr->addr2[2], + hdr->addr2[3], hdr->addr2[4], hdr->addr2[5], + hdr->addr1[0], hdr->addr1[1], hdr->addr1[2], + hdr->addr1[3], hdr->addr1[4], hdr->addr1[5], + pkt->rx_ctrl.rssi); + + if (s_udp_socket >= 0) { + sendto(s_udp_socket, alert, len, 0, + (struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr)); + } + + ESP_LOGW(TAG, "ALERT: %s from " MACSTR " -> " MACSTR " rssi=%d", + type_str, + hdr->addr2[0], hdr->addr2[1], hdr->addr2[2], + hdr->addr2[3], hdr->addr2[4], hdr->addr2[5], + hdr->addr1[0], hdr->addr1[1], hdr->addr1[2], + hdr->addr1[3], hdr->addr1[4], hdr->addr1[5], + pkt->rx_ctrl.rssi); } - - ESP_LOGW(TAG, "ALERT: %s from " MACSTR " -> " MACSTR " rssi=%d", - type_str, - hdr->addr2[0], hdr->addr2[1], hdr->addr2[2], - hdr->addr2[3], hdr->addr2[4], hdr->addr2[5], - hdr->addr1[0], hdr->addr1[1], hdr->addr1[2], - hdr->addr1[3], hdr->addr1[4], hdr->addr1[5], - pkt->rx_ctrl.rssi); return; } @@ -845,6 +1006,63 @@ static void wifi_promiscuous_init(void) ESP_LOGI(TAG, "Promiscuous mode: deauth/disassoc/probe detection enabled"); } +/* --- HMAC command authentication --- */ + +/** + * Verify HMAC-signed command. Format: "HMAC:<16hex>:" + * Returns pointer to actual command on success, or NULL on failure + * (with error message written to reply). + */ +static const char *auth_verify(const char *input, char *reply, size_t reply_size) +{ + /* No secret configured — accept everything */ + if (s_auth_secret[0] == '\0') { + return input; + } + + /* Check for HMAC: prefix */ + if (strncmp(input, "HMAC:", 5) != 0) { + snprintf(reply, reply_size, "ERR AUTH required"); + return NULL; + } + + /* Find second colon after 16 hex chars */ + if (strlen(input) < 5 + 16 + 1) { + snprintf(reply, reply_size, "ERR AUTH malformed"); + return NULL; + } + if (input[5 + 16] != ':') { + snprintf(reply, reply_size, "ERR AUTH malformed"); + return NULL; + } + + const char *cmd = input + 5 + 16 + 1; + + /* Compute HMAC-SHA256 of the command */ + uint8_t hmac[32]; + mbedtls_md_context_t ctx; + mbedtls_md_init(&ctx); + const mbedtls_md_info_t *md_info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); + mbedtls_md_setup(&ctx, md_info, 1); + mbedtls_md_hmac_starts(&ctx, (const uint8_t *)s_auth_secret, strlen(s_auth_secret)); + mbedtls_md_hmac_update(&ctx, (const uint8_t *)cmd, strlen(cmd)); + mbedtls_md_hmac_finish(&ctx, hmac); + mbedtls_md_free(&ctx); + + /* Format first 8 bytes as 16 hex chars */ + char expected[17]; + for (int i = 0; i < 8; i++) { + snprintf(expected + i * 2, 3, "%02x", hmac[i]); + } + + if (strncmp(input + 5, expected, 16) != 0) { + snprintf(reply, reply_size, "ERR AUTH failed"); + return NULL; + } + + return cmd; +} + /* --- Command handler --- */ static void reboot_after_delay(void *arg) @@ -906,17 +1124,24 @@ static int cmd_handle(const char *cmd, char *reply, size_t reply_size) snprintf(uptime_str, sizeof(uptime_str), "%dm", mins); } + const char *csi_mode_str = (s_csi_mode == CSI_MODE_COMPACT) ? "compact" : + (s_csi_mode == CSI_MODE_HYBRID) ? "hybrid" : "raw"; + snprintf(reply, reply_size, "OK STATUS uptime=%s uptime_s=%lld heap=%lu rssi=%d channel=%d tx_power=%d rate=%d csi_rate=%d" " hostname=%s version=%s adaptive=%s motion=%d ble=%s target=%s:%d" - " temp=%.1f csi_count=%lu boots=%lu rssi_min=%d rssi_max=%d", + " temp=%.1f csi_count=%lu boots=%lu rssi_min=%d rssi_max=%d" + " csi_mode=%s hybrid_n=%d auth=%s flood_thresh=%d/%d", uptime_str, (long long)up, (unsigned long)heap, rssi, channel, (int)s_tx_power_dbm, s_send_frequency, actual_rate, s_hostname, app_desc->version, s_adaptive ? "on" : "off", s_motion_detected ? 1 : 0, s_ble_enabled ? "on" : "off", s_target_ip, s_target_port, chip_temp, (unsigned long)s_csi_count, (unsigned long)s_boot_count, - (int)s_rssi_min, (int)s_rssi_max); + (int)s_rssi_min, (int)s_rssi_max, + csi_mode_str, s_hybrid_interval, + s_auth_secret[0] ? "on" : "off", + s_flood_thresh, s_flood_window_s); return strlen(reply); } @@ -1084,6 +1309,47 @@ static int cmd_handle(const char *cmd, char *reply, size_t reply_size) return strlen(reply); } + /* CSIMODE [RAW|COMPACT|HYBRID N] */ + if (strcmp(cmd, "CSIMODE") == 0) { + const char *mode_str = (s_csi_mode == CSI_MODE_COMPACT) ? "COMPACT" : + (s_csi_mode == CSI_MODE_HYBRID) ? "HYBRID" : "RAW"; + if (s_csi_mode == CSI_MODE_HYBRID) { + snprintf(reply, reply_size, "OK CSIMODE %s %d", mode_str, s_hybrid_interval); + } else { + snprintf(reply, reply_size, "OK CSIMODE %s", mode_str); + } + return strlen(reply); + } + if (strncmp(cmd, "CSIMODE ", 8) == 0) { + const char *arg = cmd + 8; + if (strncmp(arg, "RAW", 3) == 0) { + s_csi_mode = CSI_MODE_RAW; + config_save_i8("csi_mode", (int8_t)CSI_MODE_RAW); + snprintf(reply, reply_size, "OK CSIMODE RAW"); + } else if (strncmp(arg, "COMPACT", 7) == 0) { + s_csi_mode = CSI_MODE_COMPACT; + config_save_i8("csi_mode", (int8_t)CSI_MODE_COMPACT); + snprintf(reply, reply_size, "OK CSIMODE COMPACT"); + } else if (strncmp(arg, "HYBRID", 6) == 0) { + int n = 10; + if (arg[6] == ' ') { + n = atoi(arg + 7); + } + if (n < 1 || n > 100) { + snprintf(reply, reply_size, "ERR CSIMODE HYBRID N range 1-100"); + return strlen(reply); + } + s_csi_mode = CSI_MODE_HYBRID; + s_hybrid_interval = n; + config_save_i8("csi_mode", (int8_t)CSI_MODE_HYBRID); + config_save_i32("hybrid_n", (int32_t)n); + snprintf(reply, reply_size, "OK CSIMODE HYBRID %d", n); + } else { + snprintf(reply, reply_size, "ERR CSIMODE RAW|COMPACT|HYBRID [N]"); + } + return strlen(reply); + } + /* PROFILE */ if (strcmp(cmd, "PROFILE") == 0) { int pos = 0; @@ -1133,6 +1399,54 @@ static int cmd_handle(const char *cmd, char *reply, size_t reply_size) return pos; } + /* FLOODTHRESH [count [window_s]] */ + if (strcmp(cmd, "FLOODTHRESH") == 0) { + snprintf(reply, reply_size, "OK FLOODTHRESH %d/%ds", s_flood_thresh, s_flood_window_s); + return strlen(reply); + } + if (strncmp(cmd, "FLOODTHRESH ", 12) == 0) { + int count = 0, window = s_flood_window_s; + if (sscanf(cmd + 12, "%d %d", &count, &window) < 1 || count < 1 || count > 100) { + snprintf(reply, reply_size, "ERR FLOODTHRESH <1-100> [window_s 1-300]"); + return strlen(reply); + } + if (window < 1 || window > 300) { + snprintf(reply, reply_size, "ERR FLOODTHRESH window range 1-300"); + return strlen(reply); + } + s_flood_thresh = count; + s_flood_window_s = window; + config_save_i32("flood_thresh", (int32_t)count); + config_save_i32("flood_window", (int32_t)window); + snprintf(reply, reply_size, "OK FLOODTHRESH %d/%ds", s_flood_thresh, s_flood_window_s); + return strlen(reply); + } + + /* AUTH [secret|OFF] */ + if (strcmp(cmd, "AUTH") == 0) { + snprintf(reply, reply_size, "OK AUTH %s", s_auth_secret[0] ? "on" : "off"); + return strlen(reply); + } + if (strncmp(cmd, "AUTH ", 5) == 0) { + const char *arg = cmd + 5; + if (strcmp(arg, "OFF") == 0) { + s_auth_secret[0] = '\0'; + config_save_str("auth_secret", ""); + snprintf(reply, reply_size, "OK AUTH off"); + } else { + size_t alen = strlen(arg); + if (alen < 8 || alen > 64) { + snprintf(reply, reply_size, "ERR AUTH secret length 8-64"); + return strlen(reply); + } + strncpy(s_auth_secret, arg, sizeof(s_auth_secret) - 1); + s_auth_secret[sizeof(s_auth_secret) - 1] = '\0'; + config_save_str("auth_secret", s_auth_secret); + snprintf(reply, reply_size, "OK AUTH on"); + } + return strlen(reply); + } + /* OTA */ if (strncmp(cmd, "OTA ", 4) == 0) { const char *url = cmd + 4; @@ -1183,7 +1497,7 @@ static void cmd_task(void *arg) ESP_LOGI(TAG, "Command listener on UDP port %d", CONFIG_CSI_CMD_PORT); - char rx_buf[128]; + char rx_buf[192]; char reply_buf[1400]; struct sockaddr_in src_addr; socklen_t src_len; @@ -1206,7 +1520,13 @@ static void cmd_task(void *arg) ESP_LOGI(TAG, "CMD rx: \"%s\"", rx_buf); - int reply_len = cmd_handle(rx_buf, reply_buf, sizeof(reply_buf)); + const char *verified = auth_verify(rx_buf, reply_buf, sizeof(reply_buf)); + int reply_len; + if (verified) { + reply_len = cmd_handle(verified, reply_buf, sizeof(reply_buf)); + } else { + reply_len = strlen(reply_buf); + } sendto(sock, reply_buf, reply_len, 0, (struct sockaddr *)&src_addr, src_len); diff --git a/tools/esp-cmd b/tools/esp-cmd index 08caf7a..17c37d4 100755 --- a/tools/esp-cmd +++ b/tools/esp-cmd @@ -4,6 +4,8 @@ import socket import sys +from esp_ctl.auth import sign_command + DEFAULT_PORT = 5501 TIMEOUT = 2.0 @@ -41,7 +43,7 @@ def main(): sys.exit(0 if sys.argv[1:] and sys.argv[1] in ("-h", "--help") else 2) host = sys.argv[1] - cmd = " ".join(sys.argv[2:]).strip() + cmd = sign_command(" ".join(sys.argv[2:]).strip()) ip = resolve(host) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) diff --git a/tools/esp-fleet b/tools/esp-fleet index d9b061b..c7a45a9 100755 --- a/tools/esp-fleet +++ b/tools/esp-fleet @@ -7,6 +7,8 @@ import socket import subprocess import sys +from esp_ctl.auth import sign_command + DEFAULT_PORT = 5501 TIMEOUT = 2.0 @@ -41,6 +43,7 @@ Examples: def query(name, host, cmd): """Send command to one sensor, return (name, reply_or_error).""" + cmd = sign_command(cmd) try: info = socket.getaddrinfo(host, DEFAULT_PORT, socket.AF_INET, socket.SOCK_DGRAM) ip = info[0][4][0] diff --git a/tools/esp-ota b/tools/esp-ota index 7801200..f908395 100755 --- a/tools/esp-ota +++ b/tools/esp-ota @@ -9,6 +9,8 @@ import sys import threading import time +from esp_ctl.auth import sign_command + DEFAULT_CMD_PORT = 5501 DEFAULT_HTTP_PORT = 8070 DEFAULT_FW = os.path.expanduser( @@ -32,6 +34,7 @@ def resolve(host: str) -> str: def udp_cmd(ip: str, cmd: str, timeout: float = TIMEOUT) -> str: """Send UDP command and return reply.""" + cmd = sign_command(cmd) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(timeout) try: