blob: b37b2b18753143d8f9bb2b594f0ffc26b5ae7e4c [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 harness for the AEAD and KDF primitives that sit underneath
* every encrypted Matter session: CASE and PASE derive keys with
* HKDF-SHA256, and every secured message is AES-CCM.
*
* Each parameter has its own typed domain, seeded from this directory's
* AES-CCM and HKDF test vectors, so the checks are functional invariants
* rather than only "does not crash":
*
* - AES-CCM round-trip: decrypt(encrypt(pt)) recovers pt.
* - AES-CCM authenticity: a tampered tag or ciphertext must not decrypt.
* - HKDF determinism: identical inputs produce identical output.
*
* Seeds are attached to the individual domains rather than to the
* FUZZ_TEST registration, because only domain-level seeds are injected
* when the suite runs under the libFuzzer compatibility engine.
*/
#include <array>
#include <cstdint>
#include <cstring>
#include <iterator>
#include <vector>
#include <fuzztest/fuzztest_core.h>
#include <gtest/gtest.h>
#include <crypto/CHIPCryptoPAL.h>
#include <crypto/DefaultSessionKeystore.h>
#include <lib/support/CHIPMem.h>
#include <lib/support/CodeUtils.h>
#if CHIP_CRYPTO_PSA
#include <psa/crypto.h>
#endif
#include "AES_CCM_128_test_vectors.h"
#include "DerSigConversion_test_vectors.h"
#include "HKDF_SHA256_test_vectors.h"
namespace {
using namespace chip;
using namespace chip::Crypto;
using namespace fuzztest;
using KeyArray = std::array<uint8_t, kAES_CCM128_Key_Length>;
using Bytes = std::vector<uint8_t>;
// The test vectors carry nonces of 8 and 12 bytes; Matter itself mandates 13.
// Allow a little headroom either side so nonce-length handling is exercised.
constexpr size_t kMaxNonceLen = kAES_CCM128_Nonce_Length + 3;
constexpr size_t kMaxAadLen = 1024;
constexpr size_t kMaxTextLen = 2048;
void EnsureInitialized()
{
static const bool sInitialized = [] {
VerifyOrDie(Platform::MemoryInit() == CHIP_NO_ERROR);
#if CHIP_CRYPTO_PSA
// The PSA backend rejects every key operation until the driver is
// initialized, which would otherwise turn each property below into a
// vacuous pass.
VerifyOrDie(psa_crypto_init() == PSA_SUCCESS);
#endif
return true;
}();
(void) sInitialized;
}
Bytes ToBytes(const uint8_t * data, size_t len)
{
return (data == nullptr) ? Bytes{} : Bytes(data, data + len);
}
// Pull one field out of every AES-CCM test vector to use as domain seeds.
template <typename Selector>
std::vector<Bytes> CcmSeeds(Selector select, size_t maxLen)
{
std::vector<Bytes> seeds;
for (const auto * vector : ccm_128_test_vectors)
{
Bytes value = select(vector);
if (value.size() <= maxLen)
{
seeds.push_back(std::move(value));
}
}
return seeds;
}
std::vector<KeyArray> CcmKeySeeds()
{
std::vector<KeyArray> seeds;
for (const auto * vector : ccm_128_test_vectors)
{
if (vector->key_len != kAES_CCM128_Key_Length)
{
continue;
}
KeyArray key{};
memcpy(key.data(), vector->key, key.size());
seeds.push_back(key);
}
return seeds;
}
auto AnyKey()
{
return Arbitrary<KeyArray>().WithSeeds(CcmKeySeeds());
}
auto AnyNonce()
{
return Arbitrary<Bytes>()
.WithMaxSize(kMaxNonceLen)
.WithSeeds(CcmSeeds([](const ccm_128_test_vector * v) { return ToBytes(v->nonce, v->nonce_len); }, kMaxNonceLen));
}
auto AnyAad()
{
return Arbitrary<Bytes>()
.WithMaxSize(kMaxAadLen)
.WithSeeds(CcmSeeds([](const ccm_128_test_vector * v) { return ToBytes(v->aad, v->aad_len); }, kMaxAadLen));
}
auto AnyPlaintext()
{
return Arbitrary<Bytes>()
.WithMaxSize(kMaxTextLen)
.WithSeeds(CcmSeeds([](const ccm_128_test_vector * v) { return ToBytes(v->pt, v->pt_len); }, kMaxTextLen));
}
auto AnyCiphertext()
{
return Arbitrary<Bytes>()
.WithMaxSize(kMaxTextLen)
.WithSeeds(CcmSeeds([](const ccm_128_test_vector * v) { return ToBytes(v->ct, v->ct_len); }, kMaxTextLen));
}
auto AnyTag()
{
return Arbitrary<Bytes>()
.WithMaxSize(kAES_CCM128_Tag_Length)
.WithSeeds(CcmSeeds([](const ccm_128_test_vector * v) { return ToBytes(v->tag, v->tag_len); }, kAES_CCM128_Tag_Length));
}
// AES-CCM permits these MIC/tag lengths.
auto AnyTagLen()
{
return ElementOf<size_t>({ 4, 6, 8, 10, 12, 14, 16 });
}
bool MakeKey(const KeyArray & raw, DefaultSessionKeystore & keystore, Aes128KeyHandle & out)
{
Symmetric128BitsKeyByteArray material;
memcpy(material, raw.data(), raw.size());
return keystore.CreateKey(material, out) == CHIP_NO_ERROR;
}
// Drive the decryptor with fully fuzzer-controlled inputs. The authenticity
// check is expected to reject them; the contract under test is that it does so
// without reading or writing out of bounds.
void AesCcmDecryptNoCrash(const KeyArray & key, const Bytes & nonce, const Bytes & aad, const Bytes & ciphertext,
const Bytes & tagBytes, size_t tagLen)
{
EnsureInitialized();
DefaultSessionKeystore keystore;
Aes128KeyHandle handle;
if (!MakeKey(key, keystore, handle))
{
return;
}
Bytes tag(tagLen, 0);
for (size_t i = 0; i < tagLen && i < tagBytes.size(); ++i)
{
tag[i] = tagBytes[i];
}
Bytes plaintext(ciphertext.size());
RETURN_SAFELY_IGNORED AES_CCM_decrypt(ciphertext.data(), ciphertext.size(), aad.data(), aad.size(), tag.data(), tagLen, handle,
nonce.data(), nonce.size(), plaintext.data());
keystore.DestroyKey(handle);
}
FUZZ_TEST(CryptoPrimitives, AesCcmDecryptNoCrash)
.WithDomains(AnyKey(), AnyNonce(), AnyAad(), AnyCiphertext(), AnyTag(), AnyTagLen());
// Round-trip plus authenticity: an honest decrypt must recover the plaintext,
// and any single-bit change to the tag or the ciphertext must be rejected.
void AesCcmRoundtrip(const KeyArray & key, const Bytes & nonce, const Bytes & aad, const Bytes & plaintext, size_t tagLen)
{
EnsureInitialized();
DefaultSessionKeystore keystore;
Aes128KeyHandle handle;
if (!MakeKey(key, keystore, handle))
{
return;
}
Bytes ciphertext(plaintext.size());
Bytes tag(tagLen);
CHIP_ERROR encryptError = AES_CCM_encrypt(plaintext.data(), plaintext.size(), aad.data(), aad.size(), handle, nonce.data(),
nonce.size(), ciphertext.data(), tag.data(), tagLen);
if (encryptError != CHIP_NO_ERROR)
{
keystore.DestroyKey(handle);
return;
}
Bytes recovered(plaintext.size());
ASSERT_EQ(AES_CCM_decrypt(ciphertext.data(), ciphertext.size(), aad.data(), aad.size(), tag.data(), tagLen, handle,
nonce.data(), nonce.size(), recovered.data()),
CHIP_NO_ERROR);
ASSERT_EQ(recovered, plaintext);
Bytes sink(plaintext.size());
Bytes tamperedTag = tag;
tamperedTag[0] ^= 0x01;
ASSERT_NE(AES_CCM_decrypt(ciphertext.data(), ciphertext.size(), aad.data(), aad.size(), tamperedTag.data(), tagLen, handle,
nonce.data(), nonce.size(), sink.data()),
CHIP_NO_ERROR);
if (!ciphertext.empty())
{
Bytes tamperedCiphertext = ciphertext;
tamperedCiphertext[0] ^= 0x01;
ASSERT_NE(AES_CCM_decrypt(tamperedCiphertext.data(), tamperedCiphertext.size(), aad.data(), aad.size(), tag.data(), tagLen,
handle, nonce.data(), nonce.size(), sink.data()),
CHIP_NO_ERROR);
}
keystore.DestroyKey(handle);
}
FUZZ_TEST(CryptoPrimitives, AesCcmRoundtrip).WithDomains(AnyKey(), AnyNonce(), AnyAad(), AnyPlaintext(), AnyTagLen());
// AES-CTR carries no authentication tag: it is the keystream XOR used to
// obfuscate the privacy header of a group message, and on receive it runs over
// the datagram before the AES-CCM tag is checked.
void AesCtrCryptNoCrash(const KeyArray & key, const Bytes & nonce, const Bytes & input)
{
EnsureInitialized();
DefaultSessionKeystore keystore;
Aes128KeyHandle handle;
if (!MakeKey(key, keystore, handle))
{
return;
}
Bytes output(input.size());
RETURN_SAFELY_IGNORED AES_CTR_crypt(input.data(), input.size(), handle, nonce.data(), nonce.size(), output.data());
keystore.DestroyKey(handle);
}
FUZZ_TEST(CryptoPrimitives, AesCtrCryptNoCrash).WithDomains(AnyKey(), AnyNonce(), AnyCiphertext());
// CTR mode is its own inverse, so applying it twice must return the input.
void AesCtrRoundtrip(const KeyArray & key, const Bytes & nonce, const Bytes & plaintext)
{
EnsureInitialized();
DefaultSessionKeystore keystore;
Aes128KeyHandle handle;
if (!MakeKey(key, keystore, handle))
{
return;
}
Bytes ciphertext(plaintext.size());
if (AES_CTR_crypt(plaintext.data(), plaintext.size(), handle, nonce.data(), nonce.size(), ciphertext.data()) == CHIP_NO_ERROR)
{
Bytes recovered(plaintext.size());
ASSERT_EQ(AES_CTR_crypt(ciphertext.data(), ciphertext.size(), handle, nonce.data(), nonce.size(), recovered.data()),
CHIP_NO_ERROR);
ASSERT_EQ(recovered, plaintext);
}
keystore.DestroyKey(handle);
}
FUZZ_TEST(CryptoPrimitives, AesCtrRoundtrip).WithDomains(AnyKey(), AnyNonce(), AnyPlaintext());
// Epoch keys and compressed fabric ids from the group key derivation vectors in
// TestChipCryptoPAL.cpp.
const uint8_t kEpochKey1[] = { 0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf };
const uint8_t kEpochKey2[] = { 0xb0, 0xb1, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf };
const uint8_t kEpochKey3[] = { 0x23, 0x5b, 0xf7, 0xe6, 0x28, 0x23, 0xd3, 0x58, 0xdc, 0xa4, 0xba, 0x50, 0xb1, 0x53, 0x5f, 0x4b };
const uint8_t kCompressedFabricId1[] = { 0x29, 0x06, 0xc9, 0x08, 0xd1, 0x15, 0xd3, 0x62 };
const uint8_t kCompressedFabricId2[] = { 0x87, 0xe1, 0xb0, 0x04, 0xe2, 0x35, 0xa1, 0x30 };
auto AnyEpochKey()
{
return Arbitrary<Bytes>()
.WithMaxSize(CHIP_CRYPTO_SYMMETRIC_KEY_LENGTH_BYTES * 2)
.WithSeeds({ Bytes(std::begin(kEpochKey1), std::end(kEpochKey1)), Bytes(std::begin(kEpochKey2), std::end(kEpochKey2)),
Bytes(std::begin(kEpochKey3), std::end(kEpochKey3)) });
}
auto AnyCompressedFabricId()
{
return Arbitrary<Bytes>()
.WithMaxSize(kCompressedFabricIdentifierSize * 2)
.WithSeeds({ Bytes(std::begin(kCompressedFabricId1), std::end(kCompressedFabricId1)),
Bytes(std::begin(kCompressedFabricId2), std::end(kCompressedFabricId2)) });
}
// The group key schedule: an epoch key plus the compressed fabric id yield the
// encryption key, the privacy key and the session id used for group messaging.
void GroupKeyDerivation(const Bytes & epochKey, const Bytes & compressedFabricId)
{
EnsureInitialized();
const ByteSpan epochKeySpan(epochKey.data(), epochKey.size());
const ByteSpan fabricIdSpan(compressedFabricId.data(), compressedFabricId.size());
uint8_t operationalKeyBuf[CHIP_CRYPTO_SYMMETRIC_KEY_LENGTH_BYTES] = { 0 };
MutableByteSpan operationalKey(operationalKeyBuf);
if (DeriveGroupOperationalKey(epochKeySpan, fabricIdSpan, operationalKey) == CHIP_NO_ERROR)
{
// The session id is derived from the operational key, so it is only
// meaningful once that derivation has succeeded.
uint16_t sessionId = 0;
RETURN_SAFELY_IGNORED DeriveGroupSessionId(ByteSpan(operationalKey.data(), operationalKey.size()), sessionId);
}
// Fed the raw fuzzer bytes rather than the derived operational key that
// production uses, so that the length-rejection path is exercised too.
uint8_t privacyKeyBuf[CHIP_CRYPTO_SYMMETRIC_KEY_LENGTH_BYTES] = { 0 };
MutableByteSpan privacyKey(privacyKeyBuf);
RETURN_SAFELY_IGNORED DeriveGroupPrivacyKey(epochKeySpan, privacyKey);
GroupOperationalCredentials credentials;
RETURN_SAFELY_IGNORED DeriveGroupOperationalCredentials(epochKeySpan, fabricIdSpan, credentials);
}
FUZZ_TEST(CryptoPrimitives, GroupKeyDerivation).WithDomains(AnyEpochKey(), AnyCompressedFabricId());
// The combined credentials call must agree with running the same schedule by
// hand. Note the chaining: the privacy key is derived from the *operational*
// key, not from the epoch key -- DeriveGroupPrivacyKey names its parameter
// encryption_key, and both keys are 16 bytes, so feeding it the epoch key is
// accepted and silently yields a different key.
void GroupKeyDerivationAgrees(const Bytes & epochKey, const Bytes & compressedFabricId)
{
EnsureInitialized();
const ByteSpan epochKeySpan(epochKey.data(), epochKey.size());
const ByteSpan fabricIdSpan(compressedFabricId.data(), compressedFabricId.size());
GroupOperationalCredentials credentials;
if (DeriveGroupOperationalCredentials(epochKeySpan, fabricIdSpan, credentials) != CHIP_NO_ERROR)
{
return;
}
uint8_t operationalKeyBuf[CHIP_CRYPTO_SYMMETRIC_KEY_LENGTH_BYTES] = { 0 };
MutableByteSpan operationalKey(operationalKeyBuf);
ASSERT_EQ(DeriveGroupOperationalKey(epochKeySpan, fabricIdSpan, operationalKey), CHIP_NO_ERROR);
ASSERT_EQ(memcmp(credentials.encryption_key, operationalKey.data(), operationalKey.size()), 0);
uint8_t privacyKeyBuf[CHIP_CRYPTO_SYMMETRIC_KEY_LENGTH_BYTES] = { 0 };
MutableByteSpan privacyKey(privacyKeyBuf);
ASSERT_EQ(DeriveGroupPrivacyKey(ByteSpan(operationalKey.data(), operationalKey.size()), privacyKey), CHIP_NO_ERROR);
ASSERT_EQ(memcmp(credentials.privacy_key, privacyKey.data(), privacyKey.size()), 0);
uint16_t sessionId = 0;
ASSERT_EQ(DeriveGroupSessionId(ByteSpan(operationalKey.data(), operationalKey.size()), sessionId), CHIP_NO_ERROR);
ASSERT_EQ(sessionId, credentials.hash);
}
FUZZ_TEST(CryptoPrimitives, GroupKeyDerivationAgrees).WithDomains(AnyEpochKey(), AnyCompressedFabricId());
// ECDSA signatures cross this boundary in both directions: raw fixed-width r||s
// on the Matter wire, X9.62 DER inside certificates.
std::vector<Bytes> DerSigSeeds(bool wantDer)
{
std::vector<Bytes> seeds;
for (const auto & vector : kDerSigConvTestVectors)
{
seeds.push_back(wantDer ? ToBytes(vector.der_version, vector.der_version_length)
: ToBytes(vector.raw_version, vector.raw_version_length));
}
return seeds;
}
// The vectors use 32- and 64-byte field elements, so the buffers here are sized
// past P-256's kMax_ECDSA_Signature_Length rather than from it. Fuzzing the
// width a little beyond the largest vector exercises the length validation.
constexpr size_t kMaxFeLen = 80;
constexpr size_t kMaxRawSigLen = 2 * kMaxFeLen;
constexpr size_t kMaxDerSigLen = kMaxRawSigLen + kMax_ECDSA_X9Dot62_Asn1_Overhead;
auto AnyFeLength()
{
return InRange<size_t>(0, kMaxFeLen);
}
void EcdsaSignatureRawToDer(size_t feLengthBytes, const Bytes & rawSig)
{
EnsureInitialized();
uint8_t derBuf[kMaxDerSigLen];
MutableByteSpan derSig(derBuf);
RETURN_SAFELY_IGNORED EcdsaRawSignatureToAsn1(feLengthBytes, ByteSpan(rawSig.data(), rawSig.size()), derSig);
}
FUZZ_TEST(CryptoPrimitives, EcdsaSignatureRawToDer)
.WithDomains(AnyFeLength(), Arbitrary<Bytes>().WithMaxSize(kMaxRawSigLen).WithSeeds(DerSigSeeds(false)));
void EcdsaSignatureDerToRaw(size_t feLengthBytes, const Bytes & derSig)
{
EnsureInitialized();
uint8_t rawBuf[kMaxRawSigLen];
MutableByteSpan rawSig(rawBuf);
RETURN_SAFELY_IGNORED EcdsaAsn1SignatureToRaw(feLengthBytes, ByteSpan(derSig.data(), derSig.size()), rawSig);
}
FUZZ_TEST(CryptoPrimitives, EcdsaSignatureDerToRaw)
.WithDomains(AnyFeLength(), Arbitrary<Bytes>().WithMaxSize(kMaxDerSigLen).WithSeeds(DerSigSeeds(true)));
// The two converters are inverses for a well-formed raw signature, so a
// successful round-trip must reproduce the input exactly.
void EcdsaSignatureConversionRoundtrip(const Bytes & rawSig)
{
EnsureInitialized();
// Both directions take the field-element width, which is half of r||s.
if (rawSig.empty() || (rawSig.size() % 2) != 0)
{
return;
}
const size_t feLengthBytes = rawSig.size() / 2;
uint8_t derBuf[kMaxDerSigLen];
MutableByteSpan derSig(derBuf);
if (EcdsaRawSignatureToAsn1(feLengthBytes, ByteSpan(rawSig.data(), rawSig.size()), derSig) != CHIP_NO_ERROR)
{
return;
}
uint8_t rawBuf[kMaxRawSigLen];
MutableByteSpan recovered(rawBuf);
ASSERT_EQ(EcdsaAsn1SignatureToRaw(feLengthBytes, ByteSpan(derSig.data(), derSig.size()), recovered), CHIP_NO_ERROR);
ASSERT_EQ(recovered.size(), rawSig.size());
ASSERT_EQ(memcmp(recovered.data(), rawSig.data(), rawSig.size()), 0);
}
FUZZ_TEST(CryptoPrimitives, EcdsaSignatureConversionRoundtrip)
.WithDomains(Arbitrary<Bytes>().WithMaxSize(kMaxRawSigLen).WithSeeds(DerSigSeeds(false)));
template <typename Selector>
std::vector<Bytes> HkdfSeeds(Selector select, size_t maxLen)
{
std::vector<Bytes> seeds;
for (const auto & vector : hkdf_sha256_test_vectors)
{
Bytes value = select(vector);
if (value.size() <= maxLen)
{
seeds.push_back(std::move(value));
}
}
return seeds;
}
constexpr size_t kMaxHkdfInputLen = 512;
auto AnyHkdfSecret()
{
return Arbitrary<Bytes>()
.WithMaxSize(kMaxHkdfInputLen)
.WithSeeds(
HkdfSeeds([](const hkdf_sha256_vector & v) { return ToBytes(v.initial_key_material, v.initial_key_material_length); },
kMaxHkdfInputLen));
}
auto AnyHkdfSalt()
{
return Arbitrary<Bytes>()
.WithMaxSize(kMaxHkdfInputLen)
.WithSeeds(HkdfSeeds([](const hkdf_sha256_vector & v) { return ToBytes(v.salt, v.salt_length); }, kMaxHkdfInputLen));
}
auto AnyHkdfInfo()
{
return Arbitrary<Bytes>()
.WithMaxSize(kMaxHkdfInputLen)
.WithSeeds(HkdfSeeds([](const hkdf_sha256_vector & v) { return ToBytes(v.info, v.info_length); }, kMaxHkdfInputLen));
}
void HkdfSha256NoCrash(const Bytes & secret, const Bytes & salt, const Bytes & info, size_t outLen)
{
EnsureInitialized();
Bytes out(outLen);
HKDF_sha hkdf;
RETURN_SAFELY_IGNORED hkdf.HKDF_SHA256(secret.data(), secret.size(), salt.data(), salt.size(), info.data(), info.size(),
out.data(), outLen);
}
FUZZ_TEST(CryptoPrimitives, HkdfSha256NoCrash).WithDomains(AnyHkdfSecret(), AnyHkdfSalt(), AnyHkdfInfo(), InRange<size_t>(1, 4096));
// HKDF is a pure function: the same inputs must always produce the same output.
void HkdfSha256Deterministic(const Bytes & secret, const Bytes & salt, const Bytes & info, size_t outLen)
{
EnsureInitialized();
HKDF_sha hkdf;
Bytes first(outLen);
Bytes second(outLen);
CHIP_ERROR firstError =
hkdf.HKDF_SHA256(secret.data(), secret.size(), salt.data(), salt.size(), info.data(), info.size(), first.data(), outLen);
CHIP_ERROR secondError =
hkdf.HKDF_SHA256(secret.data(), secret.size(), salt.data(), salt.size(), info.data(), info.size(), second.data(), outLen);
ASSERT_EQ(firstError, secondError);
if (firstError == CHIP_NO_ERROR)
{
ASSERT_EQ(first, second);
}
}
FUZZ_TEST(CryptoPrimitives, HkdfSha256Deterministic)
.WithDomains(AnyHkdfSecret(), AnyHkdfSalt(), AnyHkdfInfo(), InRange<size_t>(1, 1024));
} // namespace