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:
33
TASKS.md
33
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,<hostname>,motion=... rate=... wander=...`
|
||||
- ALERT_DATA format: `ALERT_DATA,<hostname>,<deauth|disassoc>,<sender_mac>,<target_mac>,<rssi>`
|
||||
- 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,<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=`, `csi_mode=`, `hybrid_n=`, `auth=`, `flood_thresh=`
|
||||
- PROBE_DATA format: `PROBE_DATA,<hostname>,<mac>,<rssi>,<ssid>`
|
||||
- Probe requests deduped per MAC (default 10s cooldown, tunable via PROBERATE)
|
||||
- mDNS service: `_esp-csi._udp` on data port (for sensor discovery)
|
||||
|
||||
@@ -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> SCANRATE 60 # BLE scan restart interval (5-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
|
||||
```
|
||||
|
||||
@@ -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>:<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
|
||||
|
||||
```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,<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
|
||||
EVENT,<hostname>,motion=0|1 rate=<hz> wander=<value>
|
||||
ALERT_DATA,<hostname>,deauth|disassoc,sender_mac,target_mac,rssi
|
||||
ALERT_DATA,<hostname>,deauth_flood,<count>,<window_s>
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
idf_component_register(SRC_DIRS "."
|
||||
INCLUDE_DIRS ".")
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES mbedtls)
|
||||
|
||||
@@ -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>:<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 --- */
|
||||
|
||||
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 <url> */
|
||||
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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user