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

View File

@@ -0,0 +1,406 @@
#include <Arduino.h>
#include <unity.h>
#include "dd3_legacy_core.h"
#include "payload_codec.h"
#include "lora_frame_logic.h"
#include "batch_reassembly_logic.h"
// ===========================================================================
// Fuzz / negative tests for parser entry points (frame, ACK, payload codec,
// batch reassembly). Goal: every malformed input must be rejected without
// crash, OOB read/write, or undefined behaviour.
// ===========================================================================
// ---- decode_batch: negative / boundary tests ----
static void test_decode_batch_null_args() {
uint8_t dummy[32] = {};
BatchInput out = {};
TEST_ASSERT_FALSE(decode_batch(nullptr, 24, &out));
TEST_ASSERT_FALSE(decode_batch(dummy, 24, nullptr));
TEST_ASSERT_FALSE(decode_batch(nullptr, 0, nullptr));
}
static void test_decode_batch_zero_length() {
uint8_t dummy[1] = {0};
BatchInput out = {};
TEST_ASSERT_FALSE(decode_batch(dummy, 0, &out));
}
static void test_decode_batch_minimal_valid_sync() {
// Sync-only (n=0) payload: 24 bytes header, no samples.
uint8_t buf[24] = {};
// magic 0xDDB3 LE
buf[0] = 0xB3; buf[1] = 0xDD;
buf[2] = 3; // schema
buf[3] = 0x01; // flags
// sender_id=1
buf[4] = 0x01; buf[5] = 0x00;
// batch_id=1
buf[6] = 0x01; buf[7] = 0x00;
// t_last=1769904000 LE
uint32_t t = 1769904000UL;
buf[8] = t & 0xFF; buf[9] = (t >> 8) & 0xFF;
buf[10] = (t >> 16) & 0xFF; buf[11] = (t >> 24) & 0xFF;
// present_mask=0
buf[12] = 0; buf[13] = 0; buf[14] = 0; buf[15] = 0;
// n=0
buf[16] = 0;
// battery_mV=3750 LE
buf[17] = 0xA6; buf[18] = 0x0E;
// err fields
buf[19] = 0; buf[20] = 0; buf[21] = 0; buf[22] = 0; buf[23] = 0;
BatchInput out = {};
TEST_ASSERT_TRUE(decode_batch(buf, 24, &out));
TEST_ASSERT_EQUAL_UINT8(0, out.n);
TEST_ASSERT_EQUAL_UINT32(0, out.present_mask);
}
static void test_decode_batch_n_exceeds_30() {
// Forge a header with n=31, which should be rejected.
uint8_t buf[24] = {};
buf[0] = 0xB3; buf[1] = 0xDD;
buf[2] = 3; buf[3] = 0x01;
buf[4] = 0x01; buf[5] = 0x00;
buf[6] = 0x01; buf[7] = 0x00;
uint32_t t = 1769904000UL;
buf[8] = t & 0xFF; buf[9] = (t >> 8) & 0xFF;
buf[10] = (t >> 16) & 0xFF; buf[11] = (t >> 24) & 0xFF;
buf[12] = 0xFF; buf[13] = 0xFF; buf[14] = 0xFF; buf[15] = 0x3F; // all 30 bits set
buf[16] = 31; // n=31 → must reject
buf[17] = 0xA6; buf[18] = 0x0E;
buf[19] = 0; buf[20] = 0; buf[21] = 0; buf[22] = 0; buf[23] = 0;
BatchInput out = {};
TEST_ASSERT_FALSE(decode_batch(buf, 24, &out));
}
static void test_decode_batch_present_mask_n_mismatch() {
// present_mask has 3 bits but n=5 → must reject.
uint8_t buf[24] = {};
buf[0] = 0xB3; buf[1] = 0xDD;
buf[2] = 3; buf[3] = 0x01;
buf[4] = 0x01; buf[5] = 0x00;
buf[6] = 0x01; buf[7] = 0x00;
uint32_t t = 1769904000UL;
buf[8] = t & 0xFF; buf[9] = (t >> 8) & 0xFF;
buf[10] = (t >> 16) & 0xFF; buf[11] = (t >> 24) & 0xFF;
buf[12] = 0x07; buf[13] = 0; buf[14] = 0; buf[15] = 0; // 3 bits
buf[16] = 5; // n=5 but only 3 mask bits
buf[17] = 0xA6; buf[18] = 0x0E;
buf[19] = 0; buf[20] = 0; buf[21] = 0; buf[22] = 0; buf[23] = 0;
BatchInput out = {};
TEST_ASSERT_FALSE(decode_batch(buf, 24, &out));
}
static void test_decode_batch_reserved_mask_bits() {
// Bit 30 or 31 set → must reject (only bits 0-29 valid).
uint8_t buf[24] = {};
buf[0] = 0xB3; buf[1] = 0xDD;
buf[2] = 3; buf[3] = 0x01;
buf[4] = 0x01; buf[5] = 0x00;
buf[6] = 0x01; buf[7] = 0x00;
uint32_t t = 1769904000UL;
buf[8] = t & 0xFF; buf[9] = (t >> 8) & 0xFF;
buf[10] = (t >> 16) & 0xFF; buf[11] = (t >> 24) & 0xFF;
buf[12] = 0x01; buf[13] = 0; buf[14] = 0; buf[15] = 0x40; // bit 30
buf[16] = 1;
buf[17] = 0xA6; buf[18] = 0x0E;
buf[19] = 0; buf[20] = 0; buf[21] = 0; buf[22] = 0; buf[23] = 0;
BatchInput out = {};
TEST_ASSERT_FALSE(decode_batch(buf, 24, &out));
}
// ---- uleb128_decode: negative tests ----
static void test_uleb128_decode_unterminated() {
// 5 continuation bytes without termination → reject.
uint8_t data[] = {0x80, 0x80, 0x80, 0x80, 0x80};
size_t pos = 0;
uint32_t val = 0;
TEST_ASSERT_FALSE(uleb128_decode(data, sizeof(data), &pos, &val));
}
static void test_uleb128_decode_overflow() {
// 5th byte has bits in upper nibble → overflow.
uint8_t data[] = {0x80, 0x80, 0x80, 0x80, 0x10};
size_t pos = 0;
uint32_t val = 0;
TEST_ASSERT_FALSE(uleb128_decode(data, sizeof(data), &pos, &val));
}
static void test_uleb128_decode_null_args() {
size_t pos = 0;
uint32_t val = 0;
uint8_t data[] = {0x00};
TEST_ASSERT_FALSE(uleb128_decode(nullptr, 1, &pos, &val));
TEST_ASSERT_FALSE(uleb128_decode(data, 1, nullptr, &val));
TEST_ASSERT_FALSE(uleb128_decode(data, 1, &pos, nullptr));
}
static void test_uleb128_decode_empty_buffer() {
size_t pos = 0;
uint32_t val = 0;
uint8_t data[1] = {};
TEST_ASSERT_FALSE(uleb128_decode(data, 0, &pos, &val));
}
// ---- svarint_decode: negative tests ----
static void test_svarint_decode_overflow() {
// The underlying uleb128 overflows
uint8_t data[] = {0x80, 0x80, 0x80, 0x80, 0x10};
size_t pos = 0;
int32_t val = 0;
TEST_ASSERT_FALSE(svarint_decode(data, sizeof(data), &pos, &val));
}
// ---- lora_parse_frame: fuzz seeds ----
static void test_frame_parse_all_zeros() {
uint8_t buf[5] = {0, 0, 0, 0, 0};
uint8_t kind = 0xFF;
uint16_t dev = 0xFFFF;
uint8_t payload[16] = {};
size_t plen = 0;
// All-zero frame: CRC of first 3 bytes won't match last 2 → CrcFail.
LoraFrameDecodeStatus s = lora_parse_frame(buf, sizeof(buf), 1, &kind, &dev, payload, sizeof(payload), &plen);
TEST_ASSERT_TRUE(s == LoraFrameDecodeStatus::CrcFail || s == LoraFrameDecodeStatus::Ok);
}
static void test_frame_parse_max_msg_kind_reject() {
// Build valid frame with msg_kind=2, then parse with max_msg_kind=1.
uint8_t payload[] = {0x42};
uint8_t frame[32] = {};
size_t flen = 0;
TEST_ASSERT_TRUE(lora_build_frame(2, 0xABCD, payload, 1, frame, sizeof(frame), flen));
uint8_t kind = 0;
uint16_t dev = 0;
uint8_t out[8] = {};
size_t olen = 0;
LoraFrameDecodeStatus s = lora_parse_frame(frame, flen, 1, &kind, &dev, out, sizeof(out), &olen);
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(LoraFrameDecodeStatus::InvalidMsgKind), static_cast<uint8_t>(s));
}
static void test_frame_parse_payload_too_large_for_output() {
// Build valid frame with 4 bytes payload, parse into 2-byte output → LengthMismatch.
uint8_t payload[] = {1, 2, 3, 4};
uint8_t frame[32] = {};
size_t flen = 0;
TEST_ASSERT_TRUE(lora_build_frame(0, 0x1234, payload, 4, frame, sizeof(frame), flen));
uint8_t kind = 0;
uint16_t dev = 0;
uint8_t out[2] = {};
size_t olen = 0;
LoraFrameDecodeStatus s = lora_parse_frame(frame, flen, 1, &kind, &dev, out, sizeof(out), &olen);
TEST_ASSERT_EQUAL_UINT8(static_cast<uint8_t>(LoraFrameDecodeStatus::LengthMismatch), static_cast<uint8_t>(s));
}
static void test_frame_build_null_args() {
uint8_t buf[32] = {};
size_t len = 0;
TEST_ASSERT_FALSE(lora_build_frame(0, 0, nullptr, 5, buf, sizeof(buf), len));
TEST_ASSERT_FALSE(lora_build_frame(0, 0, buf, 0, nullptr, sizeof(buf), len));
}
// ---- batch_reassembly: negative / abuse tests ----
static void test_reassembly_null_buffer() {
BatchReassemblyState state = {};
batch_reassembly_reset(state);
uint8_t chunk[] = {1, 2, 3};
uint16_t clen = 0;
TEST_ASSERT_EQUAL_UINT8(
static_cast<uint8_t>(BatchReassemblyStatus::ErrorReset),
static_cast<uint8_t>(batch_reassembly_push(state, 1, 0, 1, 3, chunk, 3, 100, 5000, 64, nullptr, 0, clen)));
}
static void test_reassembly_null_chunk_data() {
BatchReassemblyState state = {};
batch_reassembly_reset(state);
uint8_t buffer[32] = {};
uint16_t clen = 0;
TEST_ASSERT_EQUAL_UINT8(
static_cast<uint8_t>(BatchReassemblyStatus::ErrorReset),
static_cast<uint8_t>(batch_reassembly_push(state, 1, 0, 1, 3, nullptr, 3, 100, 5000, 64, buffer, sizeof(buffer), clen)));
}
static void test_reassembly_total_len_zero_with_data() {
BatchReassemblyState state = {};
batch_reassembly_reset(state);
uint8_t buffer[32] = {};
uint8_t chunk[] = {1};
uint16_t clen = 0;
// total_len=0 but chunk_len>0 → must reject.
TEST_ASSERT_EQUAL_UINT8(
static_cast<uint8_t>(BatchReassemblyStatus::ErrorReset),
static_cast<uint8_t>(batch_reassembly_push(state, 1, 0, 1, 0, chunk, 1, 100, 5000, 64, buffer, sizeof(buffer), clen)));
}
static void test_reassembly_total_len_exceeds_max() {
BatchReassemblyState state = {};
batch_reassembly_reset(state);
uint8_t buffer[32] = {};
uint8_t chunk[] = {1};
uint16_t clen = 0;
// total_len=5000 > max_total_len=64 → must reject.
TEST_ASSERT_EQUAL_UINT8(
static_cast<uint8_t>(BatchReassemblyStatus::ErrorReset),
static_cast<uint8_t>(batch_reassembly_push(state, 1, 0, 1, 5000, chunk, 1, 100, 5000, 64, buffer, sizeof(buffer), clen)));
}
static void test_reassembly_timeout_resets() {
BatchReassemblyState state = {};
batch_reassembly_reset(state);
uint8_t buffer[32] = {};
uint8_t chunk1[] = {1, 2};
uint8_t chunk2[] = {3};
uint16_t clen = 0;
// First chunk at t=1000.
TEST_ASSERT_EQUAL_UINT8(
static_cast<uint8_t>(BatchReassemblyStatus::InProgress),
static_cast<uint8_t>(batch_reassembly_push(state, 10, 0, 2, 3, chunk1, 2, 1000, 500, 32, buffer, sizeof(buffer), clen)));
// Second chunk at t=2000 (>500ms after last) → timeout → ErrorReset.
TEST_ASSERT_EQUAL_UINT8(
static_cast<uint8_t>(BatchReassemblyStatus::ErrorReset),
static_cast<uint8_t>(batch_reassembly_push(state, 10, 1, 2, 3, chunk2, 1, 2000, 500, 32, buffer, sizeof(buffer), clen)));
}
static void test_reassembly_different_batch_id_resets() {
BatchReassemblyState state = {};
batch_reassembly_reset(state);
uint8_t buffer[32] = {};
uint8_t chunk[] = {1, 2};
uint16_t clen = 0;
// Start batch 10.
TEST_ASSERT_EQUAL_UINT8(
static_cast<uint8_t>(BatchReassemblyStatus::InProgress),
static_cast<uint8_t>(batch_reassembly_push(state, 10, 0, 2, 3, chunk, 2, 100, 5000, 32, buffer, sizeof(buffer), clen)));
// Receive chunk for batch 11 (different), but index=1 → ErrorReset (non-zero index for new batch).
TEST_ASSERT_EQUAL_UINT8(
static_cast<uint8_t>(BatchReassemblyStatus::ErrorReset),
static_cast<uint8_t>(batch_reassembly_push(state, 11, 1, 2, 3, chunk, 1, 200, 5000, 32, buffer, sizeof(buffer), clen)));
}
static void test_reassembly_overflow_buffer() {
BatchReassemblyState state = {};
batch_reassembly_reset(state);
uint8_t buffer[4] = {};
uint8_t chunk[] = {1, 2, 3, 4, 5};
uint16_t clen = 0;
// total_len=5 but buffer_cap=4 → chunk overflows buffer → ErrorReset.
TEST_ASSERT_EQUAL_UINT8(
static_cast<uint8_t>(BatchReassemblyStatus::ErrorReset),
static_cast<uint8_t>(batch_reassembly_push(state, 1, 0, 1, 5, chunk, 5, 100, 5000, 64, buffer, sizeof(buffer), clen)));
}
// ---- Byte-flip fuzz of a valid encoded payload ----
static void test_decode_batch_byte_flip_fuzz() {
// Encode a valid batch, then flip each byte and ensure decode either
// returns false or produces a valid output (no crash, no UB).
BatchInput in = {};
in.sender_id = 1;
in.batch_id = 42;
in.t_last = 1769904000UL;
in.present_mask = 0x07; // bits 0-2
in.n = 3;
in.battery_mV = 3750;
in.energy_wh[0] = 100000;
in.energy_wh[1] = 100010;
in.energy_wh[2] = 100020;
in.p1_w[0] = 100; in.p1_w[1] = 110; in.p1_w[2] = 120;
in.p2_w[0] = 200; in.p2_w[1] = 210; in.p2_w[2] = 220;
in.p3_w[0] = 300; in.p3_w[1] = 310; in.p3_w[2] = 320;
uint8_t encoded[256] = {};
size_t encoded_len = 0;
TEST_ASSERT_TRUE(encode_batch(in, encoded, sizeof(encoded), &encoded_len));
TEST_ASSERT_TRUE(encoded_len > 0);
for (size_t i = 0; i < encoded_len; ++i) {
uint8_t mutated[256];
memcpy(mutated, encoded, encoded_len);
mutated[i] ^= 0xFF; // flip all bits of byte i
BatchInput out = {};
// Must not crash. Return value may be true (if flip is benign) or false.
(void)decode_batch(mutated, encoded_len, &out);
}
// Verify original still decodes correctly.
BatchInput verify = {};
TEST_ASSERT_TRUE(decode_batch(encoded, encoded_len, &verify));
TEST_ASSERT_EQUAL_UINT8(in.n, verify.n);
}
// ---- lora_parse_frame byte-flip ----
static void test_frame_byte_flip_fuzz() {
uint8_t payload[] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07};
uint8_t frame[32] = {};
size_t frame_len = 0;
TEST_ASSERT_TRUE(lora_build_frame(1, 0xF19C, payload, sizeof(payload), frame, sizeof(frame), frame_len));
for (size_t i = 0; i < frame_len; ++i) {
uint8_t mutated[32];
memcpy(mutated, frame, frame_len);
mutated[i] ^= 0xFF;
uint8_t kind = 0;
uint16_t dev = 0;
uint8_t out[16] = {};
size_t olen = 0;
// Must not crash.
(void)lora_parse_frame(mutated, frame_len, 1, &kind, &dev, out, sizeof(out), &olen);
}
}
void setup() {
dd3_legacy_core_force_link();
UNITY_BEGIN();
// decode_batch negative tests
RUN_TEST(test_decode_batch_null_args);
RUN_TEST(test_decode_batch_zero_length);
RUN_TEST(test_decode_batch_minimal_valid_sync);
RUN_TEST(test_decode_batch_n_exceeds_30);
RUN_TEST(test_decode_batch_present_mask_n_mismatch);
RUN_TEST(test_decode_batch_reserved_mask_bits);
// uleb128 / svarint negative tests
RUN_TEST(test_uleb128_decode_unterminated);
RUN_TEST(test_uleb128_decode_overflow);
RUN_TEST(test_uleb128_decode_null_args);
RUN_TEST(test_uleb128_decode_empty_buffer);
RUN_TEST(test_svarint_decode_overflow);
// lora_parse_frame negative tests
RUN_TEST(test_frame_parse_all_zeros);
RUN_TEST(test_frame_parse_max_msg_kind_reject);
RUN_TEST(test_frame_parse_payload_too_large_for_output);
RUN_TEST(test_frame_build_null_args);
// batch_reassembly negative tests
RUN_TEST(test_reassembly_null_buffer);
RUN_TEST(test_reassembly_null_chunk_data);
RUN_TEST(test_reassembly_total_len_zero_with_data);
RUN_TEST(test_reassembly_total_len_exceeds_max);
RUN_TEST(test_reassembly_timeout_resets);
RUN_TEST(test_reassembly_different_batch_id_resets);
RUN_TEST(test_reassembly_overflow_buffer);
// Byte-flip fuzz tests
RUN_TEST(test_decode_batch_byte_flip_fuzz);
RUN_TEST(test_frame_byte_flip_fuzz);
UNITY_END();
}
void loop() {}