sec(sender): ACK rate-limiting, unknown device-ID rejection, fuzz tests

- Add 500 ms minimum interval between accepted ACKs to mitigate replay floods
- Reject ACK packets from unrecognised device IDs (DeviceIdMismatch)
- Add test/test_security_fuzz: negative/boundary tests for decode_batch,
  uleb128_decode, svarint_decode, lora_parse_frame entry points
This commit is contained in:
2026-03-17 12:31:36 +01:00
parent 5edb79f372
commit 664ff1d744
2 changed files with 444 additions and 18 deletions

View File

@@ -126,6 +126,9 @@ static uint32_t g_sender_rx_reject_log_ms = 0;
static RxRejectReason g_receiver_rx_reject_reason = RxRejectReason::None;
static uint32_t g_receiver_rx_reject_log_ms = 0;
static MeterData g_last_meter_data = {};
// Rate-limit: track ACK accept timestamps to detect replay floods.
static uint32_t g_ack_accept_last_ms = 0;
static constexpr uint32_t ACK_MIN_INTERVAL_MS = 500;
static bool g_last_meter_valid = false;
static uint32_t g_last_meter_rx_ms = 0;
static uint32_t g_meter_stale_seconds = 0;
@@ -1422,32 +1425,49 @@ static void sender_loop() {
static_cast<unsigned>(ack_pkt.payload_len),
ack_id);
}
} else if (sender_id_from_short_id(ack_pkt.device_id_short) == 0 &&
ack_pkt.device_id_short != g_short_id) {
// Reject ACKs from unknown device IDs to prevent spoofing.
sender_note_rx_reject(RxRejectReason::DeviceIdMismatch, "ack");
if (SERIAL_DEBUG_MODE) {
serial_debug_printf("ack: reject device_id=%04X (unknown)",
ack_pkt.device_id_short);
}
} else {
uint8_t time_valid = ack_pkt.payload[0] & 0x01;
uint16_t ack_id = read_u16_be(&ack_pkt.payload[1]);
uint32_t ack_epoch = read_u32_be(&ack_pkt.payload[3]);
bool set_time = false;
if (g_batch_ack_pending && ack_id == g_last_sent_batch_id) {
ack_accepted = true;
g_sender_ack_rtt_last_ms = rx_elapsed;
if (g_sender_ack_rtt_ewma_ms == 0) {
g_sender_ack_rtt_ewma_ms = rx_elapsed;
// Rate-limit: reject if another ACK was accepted less than ACK_MIN_INTERVAL_MS ago
if (g_ack_accept_last_ms != 0 && (millis() - g_ack_accept_last_ms < ACK_MIN_INTERVAL_MS)) {
if (SERIAL_DEBUG_MODE) {
serial_debug_printf("ack: rate-limited (last accepted %lums ago)",
static_cast<unsigned long>(millis() - g_ack_accept_last_ms));
}
} else {
g_sender_ack_rtt_ewma_ms = (g_sender_ack_rtt_ewma_ms * 3U + rx_elapsed + 1U) / 4U;
ack_accepted = true;
g_ack_accept_last_ms = millis();
g_sender_ack_rtt_last_ms = rx_elapsed;
if (g_sender_ack_rtt_ewma_ms == 0) {
g_sender_ack_rtt_ewma_ms = rx_elapsed;
} else {
g_sender_ack_rtt_ewma_ms = (g_sender_ack_rtt_ewma_ms * 3U + rx_elapsed + 1U) / 4U;
}
if (time_valid == 1 && ack_epoch >= MIN_ACCEPTED_EPOCH_UTC) {
time_set_utc(ack_epoch);
g_time_acquired = true;
sender_reset_fault_stats_on_first_sync(ack_epoch);
set_time = true;
}
g_last_acked_batch_id = ack_id;
serial_debug_printf("ack: rx ok batch_id=%u time_valid=%u epoch=%lu set=%u",
ack_id,
static_cast<unsigned>(time_valid),
static_cast<unsigned long>(ack_epoch),
set_time ? 1 : 0);
finish_inflight_batch();
}
if (time_valid == 1 && ack_epoch >= MIN_ACCEPTED_EPOCH_UTC) {
time_set_utc(ack_epoch);
g_time_acquired = true;
sender_reset_fault_stats_on_first_sync(ack_epoch);
set_time = true;
}
g_last_acked_batch_id = ack_id;
serial_debug_printf("ack: rx ok batch_id=%u time_valid=%u epoch=%lu set=%u",
ack_id,
static_cast<unsigned>(time_valid),
static_cast<unsigned long>(ack_epoch),
set_time ? 1 : 0);
finish_inflight_batch();
} else {
if (ack_id != g_last_sent_batch_id) {
sender_note_rx_reject(RxRejectReason::BatchIdMismatch, "ack");