diff --git a/examples/platforms/simulation/openthread-core-simulation-config.h b/examples/platforms/simulation/openthread-core-simulation-config.h index 9cee9af8b..7985f4e1d 100644 --- a/examples/platforms/simulation/openthread-core-simulation-config.h +++ b/examples/platforms/simulation/openthread-core-simulation-config.h @@ -77,6 +77,9 @@ #define OPENTHREAD_CONFIG_MAC_SOFTWARE_TX_TIMING_ENABLE 1 #endif +#ifndef OPENTHREAD_CONFIG_MAC_SOFTWARE_RETX_SECURITY_ENABLE +#define OPENTHREAD_CONFIG_MAC_SOFTWARE_RETX_SECURITY_ENABLE 1 +#endif #endif // OPENTHREAD_RADIO #ifndef OPENTHREAD_CONFIG_PLATFORM_USEC_TIMER_ENABLE diff --git a/examples/platforms/utils/mac_frame.cpp b/examples/platforms/utils/mac_frame.cpp index 0152da40a..04ddc971b 100644 --- a/examples/platforms/utils/mac_frame.cpp +++ b/examples/platforms/utils/mac_frame.cpp @@ -404,6 +404,12 @@ exit: otError otMacFrameProcessTxSfd(otRadioFrame *aFrame, uint64_t aRadioTime, otRadioContext *aRadioContext) { + otError error = OT_ERROR_NONE; + + aFrame->mInfo.mTxInfo.mTimestamp = aRadioTime; + + VerifyOrExit(!otMacFrameIsSecurityEnabled(aFrame) || !aFrame->mInfo.mTxInfo.mIsSecurityProcessed); + #if OPENTHREAD_CONFIG_MAC_CSL_RECEIVER_ENABLE if (aRadioContext->mCslPresent) // CSL IE should be filled for every transmit attempt { @@ -413,8 +419,10 @@ otError otMacFrameProcessTxSfd(otRadioFrame *aFrame, uint64_t aRadioTime, otRadi #if OPENTHREAD_CONFIG_TIME_SYNC_ENABLE otMacFrameUpdateTimeIe(aFrame, aRadioTime, aRadioContext); #endif - aFrame->mInfo.mTxInfo.mTimestamp = aRadioTime; - return otMacFrameProcessTransmitSecurity(aFrame, aRadioContext); + error = otMacFrameProcessTransmitSecurity(aFrame, aRadioContext); + +exit: + return error; } bool otMacFrameSrcAddrMatchCslReceiverPeer(const otRadioFrame *aFrame, const otRadioContext *aRadioContext) diff --git a/src/core/config/mac.h b/src/core/config/mac.h index 896455c02..b8510e22f 100644 --- a/src/core/config/mac.h +++ b/src/core/config/mac.h @@ -382,6 +382,15 @@ #define OPENTHREAD_CONFIG_MAC_SOFTWARE_RX_ON_WHEN_IDLE_ENABLE 0 #endif +/** + * @def OPENTHREAD_CONFIG_MAC_SOFTWARE_RETX_SECURITY_ENABLE + * + * Define to 1 to enable software retransmission security logic. + */ +#ifndef OPENTHREAD_CONFIG_MAC_SOFTWARE_RETX_SECURITY_ENABLE +#define OPENTHREAD_CONFIG_MAC_SOFTWARE_RETX_SECURITY_ENABLE 1 +#endif + /** * @def OPENTHREAD_CONFIG_MAC_CSL_TRANSMITTER_ENABLE * diff --git a/src/core/mac/mac_frame.cpp b/src/core/mac/mac_frame.cpp index 10de7c873..09d85ce06 100644 --- a/src/core/mac/mac_frame.cpp +++ b/src/core/mac/mac_frame.cpp @@ -41,7 +41,8 @@ #include "common/log.hpp" #include "common/num_utils.hpp" #include "radio/trel_link.hpp" -#if OPENTHREAD_FTD || OPENTHREAD_MTD || OPENTHREAD_CONFIG_MAC_SOFTWARE_TX_SECURITY_ENABLE +#if OPENTHREAD_FTD || OPENTHREAD_MTD || OPENTHREAD_CONFIG_MAC_SOFTWARE_TX_SECURITY_ENABLE || \ + (OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT && OPENTHREAD_CONFIG_MAC_SOFTWARE_RETX_SECURITY_ENABLE) #include "crypto/aes_ccm.hpp" #endif @@ -1404,6 +1405,39 @@ exit: #endif // OPENTHREAD_FTD || OPENTHREAD_MTD || OPENTHREAD_CONFIG_MAC_SOFTWARE_TX_SECURITY_ENABLE } +#if OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT && OPENTHREAD_CONFIG_MAC_SOFTWARE_RETX_SECURITY_ENABLE +void TxFrame::DecryptTransmitAesCcm(const ExtAddress &aExtAddress) +{ + uint32_t frameCounter = 0; + uint8_t securityLevel; + uint8_t nonce[Crypto::AesCcm::kNonceSize]; + uint8_t tagLength; + Crypto::AesCcm aesCcm; + + VerifyOrExit(GetSecurityEnabled() && IsSecurityProcessed()); + + SuccessOrExit(GetSecurityLevel(securityLevel)); + SuccessOrExit(GetFrameCounter(frameCounter)); + + Crypto::AesCcm::GenerateNonce(aExtAddress, frameCounter, securityLevel, nonce); + + aesCcm.SetKey(GetAesKey()); + tagLength = GetFooterLength() - GetFcsSize(); + + aesCcm.Init(GetHeaderLength(), GetPayloadLength(), tagLength, nonce, sizeof(nonce)); + aesCcm.Header(GetHeader(), GetHeaderLength()); + aesCcm.Payload(GetPayload(), GetPayload(), GetPayloadLength(), Crypto::AesCcm::kDecrypt); + // Note: We skip aesCcm.Finalize() checking because we are only decrypting back to plaintext, + // and we know the ciphertext was generated correctly by us previously. + + SetIsSecurityProcessed(false); + SetIsHeaderUpdated(false); + +exit: + return; +} +#endif // OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT && OPENTHREAD_CONFIG_MAC_SOFTWARE_RETX_SECURITY_ENABLE + void TxFrame::GenerateImmAck(const RxFrame &aFrame, bool aIsFramePending) { uint16_t fcf = static_cast(kTypeAck) | aFrame.GetVersion(); diff --git a/src/core/mac/mac_frame.hpp b/src/core/mac/mac_frame.hpp index 7f44be404..57a48ebbe 100644 --- a/src/core/mac/mac_frame.hpp +++ b/src/core/mac/mac_frame.hpp @@ -626,6 +626,14 @@ public: #endif // OPENTHREAD_CONFIG_TIME_SYNC_ENABLE #if OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT + /** + * Indicates whether the frame contains header IEs. + * + * @retval TRUE The frame contains header IEs. + * @retval FALSE The frame contains no header IEs. + */ + bool HasHeaderIe(void) const { return FindHeaderIeIndex() != kInvalidIndex; } + /** * Returns a pointer to the Header IE. * @@ -1240,6 +1248,14 @@ public: */ void ProcessTransmitAesCcm(const ExtAddress &aExtAddress); + /** + * Decrypts the frame which was previously encrypted. + * + * @param[in] aExtAddress A reference to the extended address, which will be used to generate nonce + * for AES CCM computation. + */ + void DecryptTransmitAesCcm(const ExtAddress &aExtAddress); + /** * Indicates whether or not the frame has security processed. * diff --git a/src/core/mac/sub_mac.cpp b/src/core/mac/sub_mac.cpp index 03e083468..4669162d1 100644 --- a/src/core/mac/sub_mac.cpp +++ b/src/core/mac/sub_mac.cpp @@ -63,6 +63,10 @@ SubMac::SubMac(Instance &aInstance) mCslParentAccuracy.Init(); #endif +#if OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT && !OPENTHREAD_CONFIG_MAC_SOFTWARE_RETX_SECURITY_ENABLE + // Assuming the platform must deal with the retransmission security correctly. + OT_ASSERT(mRadioCaps & OT_RADIO_CAPS_TRANSMIT_RETRIES); +#endif Init(); } @@ -601,6 +605,15 @@ void SubMac::HandleTransmitDone(TxFrame &aFrame, RxFrame *aAckFrame, Error aErro mTransmitRetries++; aFrame.SetIsARetransmission(true); +#if OPENTHREAD_CONFIG_MAC_HEADER_IE_SUPPORT && OPENTHREAD_CONFIG_MAC_SOFTWARE_RETX_SECURITY_ENABLE + if (aFrame.GetSecurityEnabled() && aFrame.IsSecurityProcessed() && aFrame.HasHeaderIe()) + { + aFrame.DecryptTransmitAesCcm(GetExtAddress()); + } + + ProcessTransmitSecurity(); +#endif + #if OPENTHREAD_CONFIG_MAC_ADD_DELAY_ON_NO_ACK_ERROR_BEFORE_RETRY if (aError == kErrorNoAck) { diff --git a/tests/nexus/CMakeLists.txt b/tests/nexus/CMakeLists.txt index b47dd2d4b..c14051f4e 100644 --- a/tests/nexus/CMakeLists.txt +++ b/tests/nexus/CMakeLists.txt @@ -394,6 +394,7 @@ ot_nexus_test(1_4_PIC_TC_3 "cert;nexus") ot_nexus_test(1_4_PIC_TC_4 "cert;nexus") ot_nexus_test(1_4_CS_TC_3 "cert;nexus") ot_nexus_test(inform_previous_parent_on_reattach "cert;nexus") +ot_nexus_test(retransmission_security "core;nexus") # Misc tests ot_nexus_test(anycast "core;nexus") diff --git a/tests/nexus/openthread-core-nexus-config.h b/tests/nexus/openthread-core-nexus-config.h index 89ef0acb2..0de1947e4 100644 --- a/tests/nexus/openthread-core-nexus-config.h +++ b/tests/nexus/openthread-core-nexus-config.h @@ -74,6 +74,7 @@ #define OPENTHREAD_CONFIG_DNS_CLIENT_ENABLE 1 #define OPENTHREAD_CONFIG_DNS_CLIENT_BIND_UDP_TO_THREAD_NETIF 1 #define OPENTHREAD_CONFIG_DNS_DSO_ENABLE 0 +#define OPENTHREAD_CONFIG_MAC_SOFTWARE_RETX_SECURITY_ENABLE 1 #define OPENTHREAD_CONFIG_DNS_UPSTREAM_QUERY_ENABLE 1 #define OPENTHREAD_CONFIG_DNSSD_DISCOVERY_PROXY_ENABLE 1 #define OPENTHREAD_CONFIG_DNSSD_SERVER_ENABLE 1 diff --git a/tests/nexus/run_nexus_tests.sh b/tests/nexus/run_nexus_tests.sh index 7fe71226e..c4afdbc45 100755 --- a/tests/nexus/run_nexus_tests.sh +++ b/tests/nexus/run_nexus_tests.sh @@ -238,6 +238,7 @@ DEFAULT_TESTS=( "leader_reboot_multiple_link_request" "router_reboot_multiple_link_request" "pbbr_aloc" + "retransmission_security" ) # Use provided arguments or the default test list diff --git a/tests/nexus/test_retransmission_security.cpp b/tests/nexus/test_retransmission_security.cpp new file mode 100644 index 000000000..cca73846c --- /dev/null +++ b/tests/nexus/test_retransmission_security.cpp @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026, The OpenThread Authors. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of the copyright holder nor the + * names of its contributors may be used to endorse or promote products + * derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include + +#include "platform/nexus_core.hpp" +#include "platform/nexus_node.hpp" + +namespace ot { +namespace Nexus { + +/** + * Time to advance for a node to form a network and become leader, in milliseconds. + */ +static constexpr uint32_t kFormNetworkTime = 13 * 1000; + +/** + * Time to advance for a node to join as a SSED. + */ +static constexpr uint32_t kAttachAsSsedTime = 20 * 1000; + +/** + * CSL Period in milliseconds. + */ +static constexpr uint32_t kCslPeriodMs = 100; + +/** + * CSL Period in units of 10 symbols. + */ +static constexpr uint32_t kCslPeriod = kCslPeriodMs * 1000 / OT_US_PER_TEN_SYMBOLS; + +/** + * Time to advance for CSL synchronization to complete, in milliseconds. + */ +static constexpr uint32_t kCslSyncTime = 5 * 1000; + +/** + * Payload size for a standard ICMPv6 Echo Request. + */ +static constexpr uint16_t kEchoPayloadSize = 10; + +void TestRetransmissionSecurity(void) +{ + /** + * Retransmission Security Test + * + * Topology: + * - Leader (DUT) + * - SSED_1 + * + * Purpose: + * Validate that retransmitted frames with CSL IE use an incremented frame counter + * and are correctly encrypted for each attempt. + */ + + Core nexus; + + Node &leader = nexus.CreateNode(); + Node &ssed1 = nexus.CreateNode(); + + leader.SetName("LEADER"); + ssed1.SetName("SSED_1"); + + nexus.AdvanceTime(0); + + SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote)); + + Log("---------------------------------------------------------------------------------------"); + Log("Step 1: Network Formation"); + + AllowLinkBetween(leader, ssed1); + + leader.Form(); + nexus.AdvanceTime(kFormNetworkTime); + VerifyOrQuit(leader.Get().IsLeader()); + + ssed1.Join(leader, Node::kAsSed); + nexus.AdvanceTime(kAttachAsSsedTime); + VerifyOrQuit(ssed1.Get().IsAttached()); + + ssed1.Get().SetCslPeriod(kCslPeriod); + nexus.AdvanceTime(kCslSyncTime); + VerifyOrQuit(ssed1.Get().IsCslEnabled()); + + Log("---------------------------------------------------------------------------------------"); + Log("Step 2: Trigger Retransmissions (SSED to Leader)"); + + // Turn off leader radio so it misses SSED frames and SSED retries + SuccessOrQuit(otPlatRadioSleep(&leader.GetInstance())); + + ssed1.SendEchoRequest(leader.Get().GetMeshLocalEid(), 0x1234, kEchoPayloadSize); + + // Advance time enough for multiple retries. + nexus.AdvanceTime(2000); + + // Turn leader radio back on + SuccessOrQuit(otPlatRadioReceive(&leader.GetInstance(), leader.Get().GetPanChannel())); + + nexus.SaveTestInfo("test_retransmission_security.json"); +} + +} // namespace Nexus +} // namespace ot + +int main(void) +{ + ot::Nexus::TestRetransmissionSecurity(); + printf("All tests passed\n"); + return 0; +} diff --git a/tests/nexus/verify_retransmission_security.py b/tests/nexus/verify_retransmission_security.py new file mode 100644 index 000000000..bdf5cd7b9 --- /dev/null +++ b/tests/nexus/verify_retransmission_security.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2026, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# + +import sys +import os + +# Add the current directory to sys.path to find verify_utils +CUR_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.append(CUR_DIR) + +import verify_utils +from pktverify import consts +from pktverify.layer_fields_container import LayerFieldsContainer + + +def get_val(field): + if field is None: + return None + s = str(field) + if s.lower() == 'null' or s == '': + return None + return field + + +def get_int(field): + val = get_val(field) + if val is None: + return None + try: + return int(str(val), 0) + except ValueError: + return None + + +def verify(pv): + pkts = pv.pkts + + LEADER = pv.vars['LEADER'] + SSED_1 = pv.vars['SSED_1'] + + LEADER_RLOC16 = get_int(pv.vars.get('LEADER_RLOC16')) + SSED_1_RLOC16 = get_int(pv.vars.get('SSED_1_RLOC16')) + + LEADER_STR = str(LEADER).lower() + SSED_1_STR = str(SSED_1).lower() + + print(f"LEADER: {LEADER}, RLOC16: {hex(LEADER_RLOC16) if LEADER_RLOC16 is not None else 'None'}") + print(f"SSED_1: {SSED_1}, RLOC16: {hex(SSED_1_RLOC16) if SSED_1_RLOC16 is not None else 'None'}") + + groups = {} + + # 1. Collect all MAC Data frames + for i in range(len(pkts)): + p = pkts[i] + if not hasattr(p, 'wpan') or get_int(p.wpan.frame_type) != consts.MAC_FRAME_TYPE_DATA: + continue + + src16 = get_int(getattr(p.wpan, 'src16', None)) + src64 = get_val(getattr(p.wpan, 'src64', None)) + dst16 = get_int(getattr(p.wpan, 'dst16', None)) + seq = get_int(p.wpan.seq_no) + + if dst16 == 0xffff: # Skip broadcast + continue + + # Check if from LEADER or SSED_1 + is_relevant_src = False + if src64 is not None: + src_str = str(src64).lower() + if src_str == LEADER_STR or src_str == SSED_1_STR: + is_relevant_src = True + elif src16 is not None: + if src16 == LEADER_RLOC16 or src16 == SSED_1_RLOC16: + is_relevant_src = True + + if not is_relevant_src: + continue + + # Group key identifies the (source, sequence) pair. + # Destination might change between RLOC16 and ExtAddr in some edge cases, + # but sequence numbers are usually unique enough within a short window. + src_id = str(src64).lower() if src64 is not None else hex(src16) + group_key = (src_id, seq) + if group_key not in groups: + groups[group_key] = [] + groups[group_key].append(p) + + # 2. Filter groups that are retransmissions (> 1 packet) + retry_groups = [g for k, g in groups.items() if len(g) > 1] + print(f"Found {len(retry_groups)} retransmission groups.") + + if not retry_groups: + raise RuntimeError("No retransmissions detected in pcap. The test might not have triggered retries correctly.") + + found_csl_retry = False + + # 3. Verify each group + for group in retry_groups: + p0 = group[0] + src_id = str(p0.wpan.src64).lower() if get_val(getattr(p0.wpan, 'src64', None)) else hex(get_int( + p0.wpan.src16)) + seq = get_int(p0.wpan.seq_no) + + # Check for CSL IE in any packet of the group (it should be in all if it's a CSL retry) + has_csl = False + for p in group: + has_csl = p.wpan.has('header_ie.csl.period') + if has_csl: + csl_period = p.wpan.header_ie.csl.period + has_csl = str(csl_period) != 'null' + + if has_csl: + break + + type_str = "CSL" if has_csl else "Non-CSL" + print(f"Verifying {type_str} seq={seq} from {src_id} ({len(group)} attempts)") + + if has_csl: + found_csl_retry = True + + last_counter = -1 + last_phase = -1 + for i, p in enumerate(group): + counter = get_int(getattr(getattr(p.wpan, 'aux_sec', None), 'frame_counter', None)) + has_header_ie = get_val(getattr(p.wpan, 'header_ie', None)) is not None + + if has_csl: + print(f" Attempt {i}: Counter={counter}, HeaderIE={has_header_ie}") + if counter is not None and has_header_ie: + if last_counter != -1 and counter <= last_counter: + raise RuntimeError( + f"Nonce reuse detected! Frame counter {counter} did not increment for CSL seq {seq}") + last_counter = counter + + # Check CSL phase + try: + phase = get_int(p.wpan.header_ie.csl.phase) + if phase is not None: + print(f" Attempt {i}: CSL Phase={phase}") + if phase == last_phase: + print( + f" Warning: CSL phase did not update between attempts for seq {seq} (likely fast retry)" + ) + last_phase = phase + else: + print(f" Attempt {i}: CSL Phase parsed as None") + except (AttributeError, ValueError): + print(f" Attempt {i}: CSL Phase not parsed") + else: + # For non-CSL, same counter is expected/allowed in standard 15.4 retries + print(f" Attempt {i}: Counter={counter} (Allowed to be same as previous)") + if counter is not None: + if counter < last_counter: + raise RuntimeError(f"Frame counter {counter} went backwards for Non-CSL seq {seq}") + last_counter = counter + + if not found_csl_retry: + raise RuntimeError("No CSL retransmissions found. Expected SSED to Leader retries with CSL IEs.") + + print("All verification steps passed!") + + +if __name__ == '__main__': + verify_utils.run_main(verify)