blob: 95b4b89fc91c3d9012e742631fe9793d56c9217a [file]
/*
*
* Copyright (c) 2026 Project CHIP Authors
* All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file
* FuzzTest harnesses for the Check-In message client path:
* CheckinMessage::ParseCheckinMessagePayload and its caller
* DefaultICDClientStorage::ProcessCheckInPayload.
*
* A flat-buffer fuzzer makes very little progress here. The parser
* accepts only payloads of exactly nonce(13) + counter(4) + appData(n) +
* MIC(16) bytes, and ProcessCheckInPayload's kAppDataLength(6) work
* buffer caps n at 2 -- so the whole accept window is 33..35 bytes and
* everything else returns before any crypto runs. Past the length gates
* sits an AES-CCM decrypt and an HMAC-derived nonce comparison, both of
* which need the entry's key to satisfy.
*
* So the harnesses below construct payloads with GenerateCheckinMessagePayload
* under a known key and then let the fuzzer mutate them, rather than
* asking the mutator to discover a valid MIC. That puts the counter
* decode, the nonce recomputation and comparison, the appData memmove
* and span narrowing, and (for ProcessCheckInPayload) the multi-entry
* key-iteration loop over a fixed-size work buffer inside the reachable
* set.
*
* Three properties:
* - ParseRejectsArbitraryPayload -- unconstrained bytes; covers the
* length/bounds arms ahead of the crypto, seeded with valid payloads
* and with sizes on both sides of the accept window.
* - ParseHandlesMutatedValidPayload -- a valid payload with one
* fuzzer-chosen byte overwritten; reaches the decrypt/nonce/appData
* tail. appData sizes here exceed what ProcessCheckInPayload's work
* buffer allows, so this is the only property that drives the
* non-empty memmove.
* - ProcessSearchesAllStoredEntries -- the storage-level caller with
* several stored entries, exercising the iterate-every-key loop and
* the 6-byte work buffer at and past its appData limit.
*
* Oracle: no sanitizer error, plus the API contracts that the callers
* rely on -- on success the counter is the one that was encoded and
* appData is narrowed to exactly the encoded application data; on
* failure the call returns an error rather than reporting success.
*/
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <utility>
#include <vector>
#include <fuzztest/fuzztest_core.h>
#include <gtest/gtest.h>
#include <app/icd/client/DefaultICDClientStorage.h>
#include <app/icd/client/ICDClientInfo.h>
#include <crypto/CryptoBuildConfig.h>
#include <crypto/DefaultSessionKeystore.h>
#if CHIP_CRYPTO_PSA
#include <psa/crypto.h>
#endif
#include <lib/core/CHIPError.h>
#include <lib/core/ScopedNodeId.h>
#include <lib/support/CHIPMem.h>
#include <lib/support/CodeUtils.h>
#include <lib/support/Span.h>
#include <lib/support/TestPersistentStorageDelegate.h>
#include <lib/support/logging/Constants.h>
#include <lib/support/logging/TextOnlyLogging.h>
#include <protocols/secure_channel/CheckinMessage.h>
namespace {
using namespace chip;
using namespace chip::app;
using namespace chip::Crypto;
using namespace chip::Protocols::SecureChannel;
using namespace fuzztest;
constexpr size_t kKeyLength = sizeof(Symmetric128BitsKeyByteArray);
// Application data that ParseCheckinMessagePayload can accept when the caller
// supplies a work buffer larger than ProcessCheckInPayload's kAppDataLength.
constexpr size_t kMaxParseAppDataSize = 32;
void EnsureInitialized()
{
static const bool sInitialized = [] {
VerifyOrDie(Platform::MemoryInit() == CHIP_NO_ERROR);
#if CHIP_CRYPTO_PSA
// Fuzz binaries use gmock_main and so miss the unit-test main's PSA
// initialization; the backend must be initialized before any crypto runs.
VerifyOrDie(psa_crypto_init() == PSA_SUCCESS);
#endif
// The storage layer logs a line per stored entry, which at fuzzing rates dominates
// both runtime and output volume.
Logging::SetLogFilter(Logging::kLogCategory_None);
return true;
}();
(void) sInitialized;
}
// Distinct key material per index so that a payload built for one entry does
// not authenticate against another. Both the AES and HMAC handle for an entry
// are derived from the same material, matching how SetKey provisions them.
void FillKeyMaterial(uint8_t index, uint8_t (&out)[kKeyLength])
{
for (size_t i = 0; i < kKeyLength; i++)
{
out[i] = static_cast<uint8_t>(index * 0x11u + i);
}
}
// Builds a Check-In payload under `index`'s key. Returns an empty vector when
// the parameters cannot produce one, so callers can simply skip the iteration.
std::vector<uint8_t> BuildPayload(uint8_t index, uint32_t counter, const std::vector<uint8_t> & appData)
{
DefaultSessionKeystore keystore;
uint8_t material[kKeyLength];
FillKeyMaterial(index, material);
Symmetric128BitsKeyByteArray aesMaterial;
memcpy(aesMaterial, material, kKeyLength);
Symmetric128BitsKeyByteArray hmacMaterial;
memcpy(hmacMaterial, material, kKeyLength);
Aes128KeyHandle aesHandle;
Hmac128KeyHandle hmacHandle;
if (keystore.CreateKey(aesMaterial, aesHandle) != CHIP_NO_ERROR)
{
return {};
}
if (keystore.CreateKey(hmacMaterial, hmacHandle) != CHIP_NO_ERROR)
{
keystore.DestroyKey(aesHandle);
return {};
}
std::vector<uint8_t> payload(CheckinMessage::GetCheckinPayloadSize(appData.size()));
MutableByteSpan output(payload.data(), payload.size());
CHIP_ERROR err = CheckinMessage::GenerateCheckinMessagePayload(aesHandle, hmacHandle, counter,
ByteSpan(appData.data(), appData.size()), output);
keystore.DestroyKey(aesHandle);
keystore.DestroyKey(hmacHandle);
if (err != CHIP_NO_ERROR)
{
return {};
}
payload.resize(output.size());
return payload;
}
// A handful of well-formed payloads plus sizes either side of the accept
// window, so the mutator starts from inputs that already clear the length
// gates instead of having to find a 33-byte shape on its own.
std::vector<std::vector<uint8_t>> PayloadSeeds()
{
// Called while the FUZZ_TEST domains are registered, which happens before
// any property body runs -- so this, not the property, is the first thing
// to construct a keystore and must do the initialization itself.
EnsureInitialized();
std::vector<std::vector<uint8_t>> seeds;
for (uint8_t index = 0; index < 2; index++)
{
for (uint32_t counter : { 0u, 1u, 0x7FFFFFFFu, 0x80000000u, 0xFFFFFFFFu })
{
for (size_t appDataSize : { 0u, 1u, 2u, 3u })
{
std::vector<uint8_t> appData(appDataSize, 0xAB);
std::vector<uint8_t> payload = BuildPayload(index, counter, appData);
if (!payload.empty())
{
seeds.push_back(std::move(payload));
}
}
}
}
// Boundary sizes around kMinPayloadSize that carry no valid MIC.
seeds.push_back({});
seeds.push_back(std::vector<uint8_t>(CheckinMessage::kMinPayloadSize - 1, 0x00));
seeds.push_back(std::vector<uint8_t>(CheckinMessage::kMinPayloadSize, 0x00));
seeds.push_back(std::vector<uint8_t>(CheckinMessage::kMinPayloadSize + 1, 0xFF));
return seeds;
}
// ---------------------------------------------------------------------------
// Unconstrained bytes against the parser under a fixed key. Almost every input
// is rejected before the crypto; the value is in covering which arm rejects it.
void ParseRejectsArbitraryPayload(const std::vector<uint8_t> & payload)
{
EnsureInitialized();
DefaultSessionKeystore keystore;
uint8_t material[kKeyLength];
FillKeyMaterial(0, material);
Symmetric128BitsKeyByteArray aesMaterial;
memcpy(aesMaterial, material, kKeyLength);
Symmetric128BitsKeyByteArray hmacMaterial;
memcpy(hmacMaterial, material, kKeyLength);
Aes128KeyHandle aesHandle;
Hmac128KeyHandle hmacHandle;
ASSERT_EQ(keystore.CreateKey(aesMaterial, aesHandle), CHIP_NO_ERROR);
ASSERT_EQ(keystore.CreateKey(hmacMaterial, hmacHandle), CHIP_NO_ERROR);
uint8_t appDataBuffer[sizeof(CounterType) + kMaxParseAppDataSize];
MutableByteSpan appData(appDataBuffer);
CounterType counter = 0;
CHIP_ERROR err = CheckinMessage::ParseCheckinMessagePayload(aesHandle, hmacHandle, ByteSpan(payload.data(), payload.size()),
counter, appData);
// A payload too short to hold nonce + counter + MIC, or one whose application
// data cannot fit the work buffer alongside the counter, can never be accepted.
if (payload.size() < CheckinMessage::kMinPayloadSize || payload.size() > CheckinMessage::kMinPayloadSize + kMaxParseAppDataSize)
{
EXPECT_NE(err, CHIP_NO_ERROR);
}
if (err == CHIP_NO_ERROR)
{
// A success implies the payload was well formed: appData must have been
// narrowed to exactly the application-data length the size arithmetic
// predicts, and must still sit inside the buffer that was handed in.
EXPECT_EQ(appData.size(), CheckinMessage::GetAppDataSize(ByteSpan(payload.data(), payload.size())));
EXPECT_LE(appData.size(), sizeof(appDataBuffer));
EXPECT_EQ(appData.data(), appDataBuffer);
}
keystore.DestroyKey(aesHandle);
keystore.DestroyKey(hmacHandle);
}
// A valid payload with one byte overwritten. Mutating a byte in the nonce, the
// ciphertext or the MIC selects which of the post-length checks rejects it;
// leaving the payload intact (when the mutation is a no-op) drives the success
// tail including the appData memmove.
void ParseHandlesMutatedValidPayload(uint32_t encodedCounter, const std::vector<uint8_t> & appDataIn, uint16_t mutationIndex,
uint8_t mutationValue)
{
EnsureInitialized();
std::vector<uint8_t> payload = BuildPayload(0, encodedCounter, appDataIn);
if (payload.empty())
{
return;
}
const bool mutated = mutationIndex < payload.size() && payload[mutationIndex] != mutationValue;
if (mutationIndex < payload.size())
{
payload[mutationIndex] = mutationValue;
}
DefaultSessionKeystore keystore;
uint8_t material[kKeyLength];
FillKeyMaterial(0, material);
Symmetric128BitsKeyByteArray aesMaterial;
memcpy(aesMaterial, material, kKeyLength);
Symmetric128BitsKeyByteArray hmacMaterial;
memcpy(hmacMaterial, material, kKeyLength);
Aes128KeyHandle aesHandle;
Hmac128KeyHandle hmacHandle;
ASSERT_EQ(keystore.CreateKey(aesMaterial, aesHandle), CHIP_NO_ERROR);
ASSERT_EQ(keystore.CreateKey(hmacMaterial, hmacHandle), CHIP_NO_ERROR);
uint8_t appDataBuffer[sizeof(CounterType) + kMaxParseAppDataSize];
MutableByteSpan appData(appDataBuffer);
CounterType decodedCounter = 0;
CHIP_ERROR err = CheckinMessage::ParseCheckinMessagePayload(aesHandle, hmacHandle, ByteSpan(payload.data(), payload.size()),
decodedCounter, appData);
// Any byte actually changed invalidates the MIC, so the parse must fail. A
// mutated payload that still authenticates would itself be a finding.
if (mutated)
{
EXPECT_NE(err, CHIP_NO_ERROR);
}
else
{
// Conversely an untouched payload was generated under the same key the
// parser is given, so rejecting it is a defect. Without this a parser
// that always fails would satisfy every other assertion here.
ASSERT_EQ(err, CHIP_NO_ERROR);
}
if (err == CHIP_NO_ERROR)
{
// An untouched payload must round-trip exactly.
if (!mutated)
{
EXPECT_EQ(decodedCounter, encodedCounter);
ASSERT_EQ(appData.size(), appDataIn.size());
EXPECT_EQ(memcmp(appData.data(), appDataIn.data(), appDataIn.size()), 0);
}
EXPECT_EQ(appData.size(), CheckinMessage::GetAppDataSize(ByteSpan(payload.data(), payload.size())));
}
keystore.DestroyKey(aesHandle);
keystore.DestroyKey(hmacHandle);
}
// The storage-level caller: several entries stored across distinct fabrics,
// with a payload built for one of them. ProcessCheckInPayload has to walk every
// stored key until one decrypts, using a kAppDataLength work buffer that is
// smaller than what the parser alone accepts.
void ProcessSearchesAllStoredEntries(uint8_t entryCount, uint8_t targetEntry, uint32_t encodedCounter,
const std::vector<uint8_t> & appDataIn, uint16_t mutationIndex, uint8_t mutationValue)
{
EnsureInitialized();
// Keep the fabric count inside what the storage will accept and make sure
// the target names one of the entries actually stored.
const uint8_t entries = static_cast<uint8_t>(1 + (entryCount % 4));
const uint8_t target = static_cast<uint8_t>(targetEntry % entries);
TestPersistentStorageDelegate clientInfoStore;
DefaultSessionKeystore keystore;
DefaultICDClientStorage storage;
ASSERT_EQ(storage.Init(&clientInfoStore, &keystore), CHIP_NO_ERROR);
for (uint8_t index = 0; index < entries; index++)
{
const FabricIndex fabricIndex = static_cast<FabricIndex>(index + 1);
uint8_t material[kKeyLength];
FillKeyMaterial(index, material);
ICDClientInfo clientInfo;
clientInfo.peer_node = ScopedNodeId(static_cast<NodeId>(0x1000 + index), fabricIndex);
clientInfo.check_in_node = clientInfo.peer_node;
clientInfo.start_icd_counter = encodedCounter;
clientInfo.offset = 0;
ASSERT_EQ(storage.UpdateFabricList(fabricIndex), CHIP_NO_ERROR);
ASSERT_EQ(storage.SetKey(clientInfo, ByteSpan(material, kKeyLength)), CHIP_NO_ERROR);
ASSERT_EQ(storage.StoreEntry(clientInfo), CHIP_NO_ERROR);
}
std::vector<uint8_t> payload = BuildPayload(target, encodedCounter, appDataIn);
const bool mutated = !payload.empty() && mutationIndex < payload.size() && payload[mutationIndex] != mutationValue;
if (!payload.empty() && mutationIndex < payload.size())
{
payload[mutationIndex] = mutationValue;
}
ICDClientInfo matchedInfo;
CounterType decodedCounter = 0;
CHIP_ERROR err = storage.ProcessCheckInPayload(ByteSpan(payload.data(), payload.size()), matchedInfo, decodedCounter);
// A changed byte breaks the MIC, and application data larger than the
// kAppDataLength work buffer leaves alongside the counter cannot be accepted.
if (mutated || appDataIn.size() > DefaultICDClientStorage::kAppDataLength - sizeof(CounterType))
{
EXPECT_NE(err, CHIP_NO_ERROR);
}
else if (!payload.empty())
{
// An untouched payload whose application data fits the work buffer is
// valid for exactly one stored entry, so it must be found -- and must
// resolve to that entry rather than any other.
ASSERT_EQ(err, CHIP_NO_ERROR);
EXPECT_EQ(decodedCounter, encodedCounter);
EXPECT_EQ(matchedInfo.peer_node, ScopedNodeId(static_cast<NodeId>(0x1000 + target), static_cast<FabricIndex>(target + 1)));
}
if (err == CHIP_NO_ERROR)
{
EXPECT_LE(appDataIn.size(), DefaultICDClientStorage::kAppDataLength - sizeof(CounterType));
}
// SetKey imports two key handles per entry and Shutdown does not release
// them, so under a keystore with finite slots they would accumulate for the
// life of the run.
for (uint8_t index = 0; index < entries; index++)
{
EXPECT_EQ(storage.DeleteAllEntries(static_cast<FabricIndex>(index + 1)), CHIP_NO_ERROR);
}
storage.Shutdown();
}
// ---------------------------------------------------------------------------
FUZZ_TEST(ICDCheckInPW, ParseRejectsArbitraryPayload)
.WithDomains(Arbitrary<std::vector<uint8_t>>().WithSeeds(PayloadSeeds()).WithMaxSize(128));
FUZZ_TEST(ICDCheckInPW, ParseHandlesMutatedValidPayload)
.WithDomains(Arbitrary<uint32_t>(), Arbitrary<std::vector<uint8_t>>().WithMaxSize(kMaxParseAppDataSize), Arbitrary<uint16_t>(),
Arbitrary<uint8_t>());
FUZZ_TEST(ICDCheckInPW, ProcessSearchesAllStoredEntries)
.WithDomains(Arbitrary<uint8_t>(), Arbitrary<uint8_t>(), Arbitrary<uint32_t>(),
Arbitrary<std::vector<uint8_t>>().WithMaxSize(8), Arbitrary<uint16_t>(), Arbitrary<uint8_t>());
} // namespace