diff --git a/src/core/meshcop/commissioner.cpp b/src/core/meshcop/commissioner.cpp index b1def2d1a..cb5b71f1f 100644 --- a/src/core/meshcop/commissioner.cpp +++ b/src/core/meshcop/commissioner.cpp @@ -911,6 +911,15 @@ template <> void Commissioner::HandleTmf(Coap::Msg &aMsg) break; case kErrorNotFound: + // Fail closed: when the commissioner has a provisioning URL + // configured, a `JOIN_FIN.req` that omits the Provisioning URL TLV + // must be rejected as well; otherwise the URL check is joiner-opt-in + // and omitting the TLV skips it. Joiners that predate the TLV can + // still join commissioners with no provisioning URL configured. + if (mProvisioningUrl[0] != kNullChar) + { + state = StateTlv::kReject; + } break; default: diff --git a/src/core/meshcop/joiner.cpp b/src/core/meshcop/joiner.cpp index d4f9df432..309dc73d8 100644 --- a/src/core/meshcop/joiner.cpp +++ b/src/core/meshcop/joiner.cpp @@ -45,6 +45,7 @@ RegisterLogModule("Joiner"); Joiner::Joiner(Instance &aInstance) : InstanceLocator(aInstance) , mState(kStateIdle) + , mFinalizeRejected(false) , mFinalizeMessage(nullptr) , mTimer(aInstance) { @@ -145,6 +146,7 @@ Error Joiner::Start(const char *aPskd, aVendorData)); mCompletionCallback.Set(aCallback, aContext); + mFinalizeRejected = false; SetState(kStateDiscover); error = Get().Start(EvaluateScanResult, this); @@ -403,15 +405,28 @@ void Joiner::HandleJoinerFinalizeResponse(Coap::Msg *aMsg, Error aResult) SuccessOrExit(Tlv::Find(aMsg->mMessage, state)); - SetState(kStateEntrust); - mTimer.Start(kResponseTimeout); - LogInfo("Received %s %d", UriToString(), state); #if OPENTHREAD_CONFIG_REFERENCE_DEVICE_ENABLE LogCertMessage("[THCI] direction=recv | type=JOIN_FIN.rsp |", aMsg->mMessage); #endif + // Only an explicit Accept state in the Finalize Response lets the + // joining process succeed. Any other state (Reject, Pending, or an + // unknown value) is a rejection by the commissioner and ends the + // joining process with `kErrorRejected`. The Joiner Entrust exchange + // itself still takes place after a reject (certification test + // 1.1.8.1.6, steps 10-11): the joiner waits for and acknowledges the + // entrust, but does not adopt its content. + if (state != StateTlv::kAccept) + { + LogWarn("Commissioner rejected %s (state %d)", UriToString(), state); + mFinalizeRejected = true; + } + + SetState(kStateEntrust); + mTimer.Start(kResponseTimeout); + exit: Get().Disconnect(); Get().Stop(); @@ -428,6 +443,18 @@ template <> void Joiner::HandleTmf(Coap::Msg &aMsg) LogInfo("Received %s", UriToString()); LogCert("[THCI] direction=recv | type=JOIN_ENT.ntf"); + if (mFinalizeRejected) + { + // The commissioner rejected the JOIN_FIN.req: acknowledge the + // entrust message (the exchange is part of the certified message + // flow) without adopting the delivered credentials. The joining + // process then completes with `kErrorRejected` from the timer. + error = kErrorNone; + SendJoinerEntrustResponse(aMsg); + mTimer.Start(kConfigExtAddressDelay); + ExitNow(); + } + datasetInfo.Clear(); SuccessOrExit(error = Tlv::Find(aMsg.mMessage, datasetInfo.Update())); @@ -462,7 +489,10 @@ void Joiner::SendJoinerEntrustResponse(const Coap::Msg &aMsg) responseInfo.GetSockAddr().Clear(); SuccessOrExit(error = Get().SendMessage(*message, responseInfo)); - SetState(kStateJoined); + if (!mFinalizeRejected) + { + SetState(kStateJoined); + } LogInfo("Sent %s response", UriToString()); LogCert("[THCI] direction=send | type=JOIN_ENT.rsp"); @@ -478,10 +508,16 @@ void Joiner::HandleTimer(void) switch (mState) { case kStateConnected: - case kStateEntrust: error = kErrorResponseTimeout; break; + case kStateEntrust: + // After a rejected JOIN_FIN.rsp the joining process always ends + // with `kErrorRejected`, whether the entrust was acknowledged + // (short delay) or never arrived (response timeout). + error = mFinalizeRejected ? kErrorRejected : kErrorResponseTimeout; + break; + case kStateJoined: Mac::ExtAddress extAddress; diff --git a/src/core/meshcop/joiner.hpp b/src/core/meshcop/joiner.hpp index 60804d155..211672085 100644 --- a/src/core/meshcop/joiner.hpp +++ b/src/core/meshcop/joiner.hpp @@ -210,6 +210,7 @@ private: Mac::ExtAddress mId; JoinerDiscerner mDiscerner; State mState; + bool mFinalizeRejected; Callback mCompletionCallback; Coap::Message *mFinalizeMessage; JoinerTimer mTimer; diff --git a/tests/nexus/CMakeLists.txt b/tests/nexus/CMakeLists.txt index e674d6ece..f34033e04 100644 --- a/tests/nexus/CMakeLists.txt +++ b/tests/nexus/CMakeLists.txt @@ -511,3 +511,5 @@ if(TARGET nexus_live_demo) target_compile_definitions(nexus_live_demo PRIVATE OPENTHREAD_NEXUS_CONFIG_GRPC_ENABLE=1) endif() endif() + +ot_nexus_test(joiner_finalize_reject "nexus") diff --git a/tests/nexus/test_joiner_finalize_reject.cpp b/tests/nexus/test_joiner_finalize_reject.cpp new file mode 100644 index 000000000..bc07f6303 --- /dev/null +++ b/tests/nexus/test_joiner_finalize_reject.cpp @@ -0,0 +1,166 @@ +/* + * 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. + */ + +/* + * Regression test for JOIN_FIN.rsp state handling and Provisioning URL requirements: + * + * 1) A joiner whose JOIN_FIN.req the commissioner REJECTS (provisioning URL mismatch) + * must NOT complete joining: it must finish with `kErrorRejected` and must not + * adopt the credentials delivered in the Joiner Entrust. The entrust exchange + * itself still takes place and is acknowledged (certification test 1.1.8.1.6, + * steps 10-11). + * 2) A joiner that OMITS the Provisioning URL TLV while the commissioner HAS a + * provisioning URL configured must be rejected too (the URL check must not be + * joiner-opt-in). + * 3) Positive control: a joiner presenting the matching provisioning URL joins + * successfully through the identical flow. + * 4) Interop control: a joiner that omits the TLV still joins a commissioner with + * NO provisioning URL configured (joiners predating the TLV keep working). + * + * FAILS without the fix (a rejected joiner adopts the entrust and reports a + * successful join; an omitted TLV is accepted despite a configured URL); PASSES + * with it (the joiner reports `kErrorRejected` on any non-Accept state and leaves + * the delivered dataset unadopted; the commissioner rejects an omitted Provisioning + * URL TLV when one is configured). + */ + +#include +#include + +#include +#include +#include + +#include "platform/nexus_core.hpp" +#include "platform/nexus_node.hpp" + +namespace ot { +namespace Nexus { + +static constexpr uint32_t kFormNetworkTime = 13 * 1000; + +static bool sJoinerCallbackInvoked; +static Error sJoinerResult; + +static void HandleJoinerComplete(otError aError, void *) +{ + sJoinerCallbackInvoked = true; + sJoinerResult = static_cast(aError); +} + +// aCommissionerUrl == nullptr => no provisioning URL configured on the commissioner. +// aJoinerUrl == nullptr => the joiner omits the Provisioning URL TLV entirely. +static void RunJoin(const char *aCommissionerUrl, const char *aJoinerUrl, bool aExpectSuccess) +{ + Core nexus; + Node &commissioner = nexus.CreateNode(); + Node &joiner = nexus.CreateNode(); + + sJoinerCallbackInvoked = false; + sJoinerResult = kErrorNone; + + commissioner.Form(); + nexus.AdvanceTime(kFormNetworkTime); + VerifyOrQuit(commissioner.Get().IsLeader()); + + SuccessOrQuit(otCommissionerStart(&commissioner.GetInstance(), nullptr, nullptr, nullptr)); + nexus.AdvanceTime(10 * 1000); + VerifyOrQuit(otCommissionerGetState(&commissioner.GetInstance()) == OT_COMMISSIONER_STATE_ACTIVE); + + if (aCommissionerUrl != nullptr) + { + SuccessOrQuit(otCommissionerSetProvisioningUrl(&commissioner.GetInstance(), aCommissionerUrl)); + } + + SuccessOrQuit(otCommissionerAddJoiner(&commissioner.GetInstance(), nullptr, "J01NME", 300)); + nexus.AdvanceTime(2 * 1000); + + SuccessOrQuit(otIp6SetEnabled(&joiner.GetInstance(), true)); + SuccessOrQuit(otJoinerStart(&joiner.GetInstance(), "J01NME", aJoinerUrl, "VendorX", "ModelY", "1.0", nullptr, + HandleJoinerComplete, nullptr)); + + for (int i = 0; (i < 600) && !sJoinerCallbackInvoked; i++) + { + nexus.AdvanceTime(100); + } + + { + otOperationalDataset dataset; + bool hasNetworkKey = (otDatasetGetActive(&joiner.GetInstance(), &dataset) == OT_ERROR_NONE) && + dataset.mComponents.mIsNetworkKeyPresent; + + Log("commissioner url=%s joiner url=%s: callback=%s result=%d networkKeyAdopted=%s", + (aCommissionerUrl != nullptr) ? aCommissionerUrl : "(none)", + (aJoinerUrl != nullptr) ? aJoinerUrl : "(TLV omitted)", sJoinerCallbackInvoked ? "invoked" : "NOT-invoked", + static_cast(sJoinerResult), hasNetworkKey ? "YES" : "no"); + + VerifyOrQuit(sJoinerCallbackInvoked); + + if (aExpectSuccess) + { + VerifyOrQuit(sJoinerResult == kErrorNone); + VerifyOrQuit(hasNetworkKey); + } + else + { + // A rejected joiner must report `kErrorRejected` and must not + // have adopted the credentials delivered in the Joiner Entrust. + VerifyOrQuit(sJoinerResult == kErrorRejected); + VerifyOrQuit(!hasNetworkKey); + } + } +} + +void TestJoinerFinalizeReject(void) +{ + // printf (not nexus Log) for the banners: Log needs a live Core and each + // case creates and destroys its own. + printf("Case 1: provisioning URL mismatch - commissioner rejects - join must FAIL\n"); + RunJoin("good-url", "bad-url", /* aExpectSuccess */ false); + + printf("Case 2: ProvisioningUrl TLV omitted while commissioner has a URL configured - join must FAIL\n"); + RunJoin("good-url", nullptr, /* aExpectSuccess */ false); + + printf("Case 3 (positive control): matching provisioning URL - join must SUCCEED\n"); + RunJoin("good-url", "good-url", /* aExpectSuccess */ true); + + printf("Case 4 (interop control): no URL configured, joiner omits the TLV - join must SUCCEED\n"); + RunJoin(nullptr, nullptr, /* aExpectSuccess */ true); + + printf("TestJoinerFinalizeReject passed\n"); +} + +} // namespace Nexus +} // namespace ot + +int main(void) +{ + ot::Nexus::TestJoinerFinalizeReject(); + printf("All tests passed\n"); + return 0; +}