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
## 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)

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> 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

View File

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

View File

@@ -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);

View File

@@ -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)

View File

@@ -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]

View File

@@ -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: