feat: Add HMAC command auth, deauth flood detection, sign all tools

Firmware:
- HMAC-SHA256 command authentication (AUTH command, NVS persisted)
- Deauth flood detection with ring buffer and aggregate ALERT_DATA
- FLOODTHRESH command (count + window, NVS persisted)
- New STATUS fields: auth=on/off, flood_thresh=5/10
- mbedtls dependency in CMakeLists.txt, rx_buf increased to 192

Tools:
- esp-cmd/esp-fleet/esp-ota import sign_command from esp_ctl.auth
- Commands auto-signed when ESP_CMD_SECRET env var is set

Docs:
- CHEATSHEET: AUTH, FLOODTHRESH, HMAC auth, OUI, watch, osint sections
- TASKS: v1.3 completed section with all new features
This commit is contained in:
user
2026-02-04 21:07:00 +01:00
parent 7ca58fee72
commit 2586234473
7 changed files with 495 additions and 55 deletions

View File

@@ -2,17 +2,42 @@
**Last Updated:** 2026-02-04 **Last Updated:** 2026-02-04
## Current Sprint: v1.1 - Passive Sensing & Multi-Sensor ## Current Sprint: v1.3 - OSINT & Fleet Ops
### P2 - Normal ### P2 - Normal
- [ ] Multi-sensor BLE correlation in esp-ctl (zone tracking) - [ ] Multi-sensor BLE correlation in esp-ctl (zone tracking)
- [ ] Test OTA rollback (flash bad firmware, verify auto-revert) - [ ] Test OTA rollback (flash bad firmware, verify auto-revert)
- [ ] Create HA webhook automations for deauth_flood / unknown_probe
### P3 - Low ### P3 - Low
- [ ] Document esp-crab dual-antenna capabilities - [ ] Document esp-crab dual-antenna capabilities
- [ ] Document esp-radar console features - [ ] Document esp-radar console features
- [ ] Pin mapping for ESP32-DevKitC V1 - [ ] 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 ## Completed: v1.1
- [x] Sensor ID in data packets (hostname prefix on CSI_DATA, BLE_DATA, EVENT) - [x] Sensor ID in data packets (hostname prefix on CSI_DATA, BLE_DATA, EVENT)
@@ -98,10 +123,10 @@
## Notes ## Notes
- Adaptive threshold varies by environment; 0.001-0.01 is a good starting range - 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,<hostname>,motion=... rate=... wander=...` - EVENT packets now include sensor hostname: `EVENT,<hostname>,motion=... rate=... wander=...`
- ALERT_DATA format: `ALERT_DATA,<hostname>,<deauth|disassoc>,<sender_mac>,<target_mac>,<rssi>` - ALERT_DATA format: `ALERT_DATA,<hostname>,<deauth|disassoc>,<sender_mac>,<target_mac>,<rssi>` or `ALERT_DATA,<hostname>,deauth_flood,<count>,<window_s>`
- 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=` - 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,<hostname>,<mac>,<rssi>,<ssid>` - PROBE_DATA format: `PROBE_DATA,<hostname>,<mac>,<rssi>,<ssid>`
- Probe requests deduped per MAC (default 10s cooldown, tunable via PROBERATE) - Probe requests deduped per MAC (default 10s cooldown, tunable via PROBERATE)
- mDNS service: `_esp-csi._udp` on data port (for sensor discovery) - mDNS service: `_esp-csi._udp` on data port (for sensor discovery)

View File

@@ -46,6 +46,15 @@ esp-cmd <host> OTA http://pi:8070/fw # Trigger OTA update (use esp-ota instea
esp-cmd <host> HOSTNAME mydevice # Set hostname (NVS saved, mDNS updated) esp-cmd <host> HOSTNAME mydevice # Set hostname (NVS saved, mDNS updated)
esp-cmd <host> SCANRATE 60 # BLE scan restart interval (5-300s) esp-cmd <host> SCANRATE 60 # BLE scan restart interval (5-300s)
esp-cmd <host> PROBERATE 5 # Probe dedup cooldown (1-300s) esp-cmd <host> PROBERATE 5 # Probe dedup cooldown (1-300s)
esp-cmd <host> CSIMODE # Query current CSI output mode
esp-cmd <host> CSIMODE RAW # Full I/Q array (default, ~900 B/pkt)
esp-cmd <host> CSIMODE COMPACT # Features only (~200 B/pkt)
esp-cmd <host> CSIMODE HYBRID 10 # Compact + raw every Nth packet
esp-cmd <host> AUTH # Query auth status (on/off)
esp-cmd <host> AUTH mysecret123 # Enable HMAC auth (8-64 char secret)
esp-cmd <host> AUTH OFF # Disable auth
esp-cmd <host> FLOODTHRESH # Query deauth flood threshold (5/10s)
esp-cmd <host> FLOODTHRESH 10 30 # Set: 10 deauths in 30s = flood
esp-cmd <host> REBOOT # Restart device esp-cmd <host> 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 If new firmware crashes or hangs, the 30s watchdog reboots and bootloader
automatically rolls back to the previous firmware. 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 ## Adaptive Sampling
When enabled, the device automatically adjusts ping rate based on CSI wander: 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+). 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>:<cmd>` — 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 ## Test CSI Reception
```bash ```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: All data packets include sensor hostname after the type tag:
``` ```
CSI_DATA,<hostname>,seq,mac,rssi,rate,...,len,first_word,"[I,Q,...]" CSI_DATA,<hostname>,seq,mac,rssi,rate,...,len,first_word,"[I,Q,...]" # RAW mode
CSI_DATA,<hostname>,seq,mac,rssi,rate,...,len,first_word,"F:rms,std,max,idx,energy" # COMPACT mode
BLE_DATA,<hostname>,mac,rssi,pub|rnd,name BLE_DATA,<hostname>,mac,rssi,pub|rnd,name
EVENT,<hostname>,motion=0|1 rate=<hz> wander=<value> EVENT,<hostname>,motion=0|1 rate=<hz> wander=<value>
ALERT_DATA,<hostname>,deauth|disassoc,sender_mac,target_mac,rssi ALERT_DATA,<hostname>,deauth|disassoc,sender_mac,target_mac,rssi
ALERT_DATA,<hostname>,deauth_flood,<count>,<window_s>
PROBE_DATA,<hostname>,mac,rssi,ssid PROBE_DATA,<hostname>,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 **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 because it breaks CSI data collection at the driver level. These packet types are
only generated on ESP32-C6 and newer chips. 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) | | boots | 3 | Boot count (NVS persisted) |
| rssi_min | -71 | Lowest RSSI since boot | | rssi_min | -71 | Lowest RSSI since boot |
| rssi_max | -62 | Highest 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 ## PROFILE Sections

View File

@@ -1,2 +1,3 @@
idf_component_register(SRC_DIRS "." idf_component_register(SRC_DIRS "."
INCLUDE_DIRS ".") INCLUDE_DIRS "."
REQUIRES mbedtls)

View File

@@ -41,6 +41,7 @@
#include "driver/temperature_sensor.h" #include "driver/temperature_sensor.h"
#endif #endif
#include "mdns.h" #include "mdns.h"
#include "mbedtls/md.h"
#include "lwip/inet.h" #include "lwip/inet.h"
#include "lwip/netdb.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 volatile int8_t s_rssi_max = -128;
static uint32_t s_boot_count = 0; 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 */ /* Adaptive sampling */
#define WANDER_WINDOW 50 #define WANDER_WINDOW 50
#define RATE_ACTIVE 100 #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 char s_target_ip[16]; /* runtime target IP (NVS or Kconfig default) */
static uint16_t s_target_port; /* runtime target port */ static uint16_t s_target_port; /* runtime target port */
static char s_hostname[32]; /* runtime hostname (NVS or Kconfig default) */ static char s_hostname[32]; /* runtime hostname (NVS or Kconfig default) */
static char s_auth_secret[65] = ""; /* empty = auth disabled */
/* --- NVS helpers --- */ /* --- NVS helpers --- */
@@ -170,10 +190,28 @@ static void config_load_nvs(void)
} }
size_t hn_len = sizeof(s_hostname); size_t hn_len = sizeof(s_hostname);
nvs_get_str(h, "hostname", s_hostname, &hn_len); 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); 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_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 { } else {
ESP_LOGI(TAG, "NVS: no saved config, using defaults"); 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 --- */ /* --- CSI callback --- */
static void wifi_csi_rx_cb(void *ctx, wifi_csi_info_t *info) 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); ESP_LOGD(TAG, "compensate_gain %f, agc_gain %d, fft_gain %d", compensate_gain, agc_gain, fft_gain);
#endif #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 */ /* Build CSI data into buffer for UDP transmission */
int pos = 0; 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); rx_ctrl->timestamp, rx_ctrl->ant, rx_ctrl->sig_len, rx_ctrl->rx_state);
#endif #endif
if (send_raw) {
/* Raw I/Q array payload */
#if (CONFIG_IDF_TARGET_ESP32C5 || CONFIG_IDF_TARGET_ESP32C61) && CSI_FORCE_LLTF #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); 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, 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)); ",%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) { 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); 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)); pos += snprintf(s_udp_buffer + pos, sizeof(s_udp_buffer) - pos, ",%d", (int16_t)(compensate_gain * csi));
} }
#else #else
pos += snprintf(s_udp_buffer + pos, sizeof(s_udp_buffer) - pos, 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])); ",%d,%d,\"[%d", info->len, info->first_word_invalid, (int16_t)(compensate_gain * info->buf[0]));
for (int i = 1; i < info->len; i++) { 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", (int16_t)(compensate_gain * info->buf[i]));
} }
#endif #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 */ /* Send via UDP */
if (s_udp_socket >= 0) { if (s_udp_socket >= 0) {
sendto(s_udp_socket, s_udp_buffer, pos, 0, (struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr)); 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) { if (s_adaptive) {
uint32_t energy = 0; s_energy_buf[s_energy_idx % WANDER_WINDOW] = features.energy;
for (int i = 0; i < info->len; i++) {
energy += abs(info->buf[i]);
}
s_energy_buf[s_energy_idx % WANDER_WINDOW] = energy;
s_energy_idx++; s_energy_idx++;
} }
@@ -723,6 +824,19 @@ typedef struct {
uint16_t seq_ctrl; uint16_t seq_ctrl;
} __attribute__((packed)) wifi_ieee80211_mac_hdr_t; } __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 */ /* Probe request deduplication: report each MAC at most once per N seconds */
#define PROBE_DEDUP_SIZE 32 #define PROBE_DEDUP_SIZE 32
#define PROBE_DEDUP_DEFAULT_US 10000000LL #define PROBE_DEDUP_DEFAULT_US 10000000LL
@@ -759,6 +873,28 @@ static bool probe_dedup_check(const uint8_t *mac)
return false; 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) static void wifi_promiscuous_cb(void *buf, wifi_promiscuous_pkt_type_t type)
{ {
if (type != WIFI_PKT_MGMT) return; 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) { if (subtype == 0x0C || subtype == 0x0A) {
const char *type_str = (subtype == 0x0C) ? "deauth" : "disassoc"; const char *type_str = (subtype == 0x0C) ? "deauth" : "disassoc";
char alert[160]; int flood_count = deauth_flood_check();
int len = snprintf(alert, sizeof(alert), char alert[192];
"ALERT_DATA,%s,%s," int len;
"%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) { if (flood_count >= s_flood_thresh) {
sendto(s_udp_socket, alert, len, 0, /* Flood detected — send aggregate alert, suppress individual */
(struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr)); 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; return;
} }
@@ -845,6 +1006,63 @@ static void wifi_promiscuous_init(void)
ESP_LOGI(TAG, "Promiscuous mode: deauth/disassoc/probe detection enabled"); ESP_LOGI(TAG, "Promiscuous mode: deauth/disassoc/probe detection enabled");
} }
/* --- HMAC command authentication --- */
/**
* Verify HMAC-signed command. Format: "HMAC:<16hex>:<cmd>"
* 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 --- */ /* --- Command handler --- */
static void reboot_after_delay(void *arg) 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); 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, 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" "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" " 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, uptime_str, (long long)up, (unsigned long)heap, rssi, channel, (int)s_tx_power_dbm,
s_send_frequency, actual_rate, s_send_frequency, actual_rate,
s_hostname, app_desc->version, s_hostname, app_desc->version,
s_adaptive ? "on" : "off", s_motion_detected ? 1 : 0, s_adaptive ? "on" : "off", s_motion_detected ? 1 : 0,
s_ble_enabled ? "on" : "off", s_target_ip, s_target_port, s_ble_enabled ? "on" : "off", s_target_ip, s_target_port,
chip_temp, (unsigned long)s_csi_count, (unsigned long)s_boot_count, 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); return strlen(reply);
} }
@@ -1084,6 +1309,47 @@ static int cmd_handle(const char *cmd, char *reply, size_t reply_size)
return strlen(reply); 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 */ /* PROFILE */
if (strcmp(cmd, "PROFILE") == 0) { if (strcmp(cmd, "PROFILE") == 0) {
int pos = 0; int pos = 0;
@@ -1133,6 +1399,54 @@ static int cmd_handle(const char *cmd, char *reply, size_t reply_size)
return pos; 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 <url> */ /* OTA <url> */
if (strncmp(cmd, "OTA ", 4) == 0) { if (strncmp(cmd, "OTA ", 4) == 0) {
const char *url = cmd + 4; 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); 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]; char reply_buf[1400];
struct sockaddr_in src_addr; struct sockaddr_in src_addr;
socklen_t src_len; socklen_t src_len;
@@ -1206,7 +1520,13 @@ static void cmd_task(void *arg)
ESP_LOGI(TAG, "CMD rx: \"%s\"", rx_buf); 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, sendto(sock, reply_buf, reply_len, 0,
(struct sockaddr *)&src_addr, src_len); (struct sockaddr *)&src_addr, src_len);

View File

@@ -4,6 +4,8 @@
import socket import socket
import sys import sys
from esp_ctl.auth import sign_command
DEFAULT_PORT = 5501 DEFAULT_PORT = 5501
TIMEOUT = 2.0 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) sys.exit(0 if sys.argv[1:] and sys.argv[1] in ("-h", "--help") else 2)
host = sys.argv[1] host = sys.argv[1]
cmd = " ".join(sys.argv[2:]).strip() cmd = sign_command(" ".join(sys.argv[2:]).strip())
ip = resolve(host) ip = resolve(host)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

View File

@@ -7,6 +7,8 @@ import socket
import subprocess import subprocess
import sys import sys
from esp_ctl.auth import sign_command
DEFAULT_PORT = 5501 DEFAULT_PORT = 5501
TIMEOUT = 2.0 TIMEOUT = 2.0
@@ -41,6 +43,7 @@ Examples:
def query(name, host, cmd): def query(name, host, cmd):
"""Send command to one sensor, return (name, reply_or_error).""" """Send command to one sensor, return (name, reply_or_error)."""
cmd = sign_command(cmd)
try: try:
info = socket.getaddrinfo(host, DEFAULT_PORT, socket.AF_INET, socket.SOCK_DGRAM) info = socket.getaddrinfo(host, DEFAULT_PORT, socket.AF_INET, socket.SOCK_DGRAM)
ip = info[0][4][0] ip = info[0][4][0]

View File

@@ -9,6 +9,8 @@ import sys
import threading import threading
import time import time
from esp_ctl.auth import sign_command
DEFAULT_CMD_PORT = 5501 DEFAULT_CMD_PORT = 5501
DEFAULT_HTTP_PORT = 8070 DEFAULT_HTTP_PORT = 8070
DEFAULT_FW = os.path.expanduser( 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: def udp_cmd(ip: str, cmd: str, timeout: float = TIMEOUT) -> str:
"""Send UDP command and return reply.""" """Send UDP command and return reply."""
cmd = sign_command(cmd)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(timeout) sock.settimeout(timeout)
try: try: