diff --git a/src/sender_state_machine.cpp b/src/sender_state_machine.cpp index f74f422..f4a2d82 100644 --- a/src/sender_state_machine.cpp +++ b/src/sender_state_machine.cpp @@ -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(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(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(time_valid), + static_cast(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(time_valid), - static_cast(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"); diff --git a/test/test_security_fuzz/test_security_fuzz.cpp b/test/test_security_fuzz/test_security_fuzz.cpp new file mode 100644 index 0000000..3831fdb --- /dev/null +++ b/test/test_security_fuzz/test_security_fuzz.cpp @@ -0,0 +1,406 @@ +#include +#include + +#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(LoraFrameDecodeStatus::InvalidMsgKind), static_cast(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(LoraFrameDecodeStatus::LengthMismatch), static_cast(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(BatchReassemblyStatus::ErrorReset), + static_cast(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(BatchReassemblyStatus::ErrorReset), + static_cast(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(BatchReassemblyStatus::ErrorReset), + static_cast(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(BatchReassemblyStatus::ErrorReset), + static_cast(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(BatchReassemblyStatus::InProgress), + static_cast(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(BatchReassemblyStatus::ErrorReset), + static_cast(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(BatchReassemblyStatus::InProgress), + static_cast(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(BatchReassemblyStatus::ErrorReset), + static_cast(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(BatchReassemblyStatus::ErrorReset), + static_cast(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() {}