diff --git a/.gitignore b/.gitignore index 6926415..c77d29f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ /subprojects/*/ +!/subprojects/packagefiles/ /build/* +/build_*/ /.vscode/* /.cache/* .* diff --git a/config.h.in b/config.h.in index d8c027f..9681e04 100644 --- a/config.h.in +++ b/config.h.in @@ -1,28 +1,31 @@ #pragma once -//NOLINTNEXTLINE cppcoreguidelines-macro-usage +// NOLINTNEXTLINE cppcoreguidelines-macro-usage #define SPDMD_VERSION @SPDMD_VERSION@ -//NOLINTNEXTLINE cppcoreguidelines-macro-usage +// NOLINTNEXTLINE cppcoreguidelines-macro-usage #define SPDM_WRAPPER_VERSION @SPDMD_VERSION@ -//NOLINTNEXTLINE cppcoreguidelines-macro-usage +// NOLINTNEXTLINE cppcoreguidelines-macro-usage #mesondefine FETCH_SERIALNUMBER_FROM_RESPONDER -//NOLINTNEXTLINE cppcoreguidelines-macro-usage +// NOLINTNEXTLINE cppcoreguidelines-macro-usage #mesondefine DISCOVERY_ONLY_FROM_MCTP_CONTROL -//NOLINTNEXTLINE cppcoreguidelines-macro-usage +// NOLINTNEXTLINE cppcoreguidelines-macro-usage #mesondefine SPDM_JSON_CONF_FILE_NAME -//NOLINTNEXTLINE cppcoreguidelines-macro-usage +// NOLINTNEXTLINE cppcoreguidelines-macro-usage +#mesondefine COMPOSITE_ATTESTER_BACKEND + +// NOLINTNEXTLINE cppcoreguidelines-macro-usage #mesondefine USE_DEFAULT_DBUS -//NOLINTNEXTLINE cppcoreguidelines-macro-usage +// NOLINTNEXTLINE cppcoreguidelines-macro-usage #mesondefine USE_FUZZ -//NOLINTNEXTLINE cppcoreguidelines-macro-usage +// NOLINTNEXTLINE cppcoreguidelines-macro-usage #mesondefine MCTP_IN_KERNEL #mesondefine CSM_SERVICE_ENABLED diff --git a/libspdmcpp/connection.cpp b/libspdmcpp/connection.cpp index c55bef8..a76206f 100644 --- a/libspdmcpp/connection.cpp +++ b/libspdmcpp/connection.cpp @@ -27,8 +27,10 @@ #include #include #include +#include #include #include +#include // NOLINTNEXTLINE(cppcoreguidelines-macro-usage) #define SPDMCPP_CONNECTION_RS_ERROR_RETURN(rs) \ @@ -88,6 +90,35 @@ namespace spdmcpp namespace { + +std::mutex measurementSpecificationsMutex; +std::unordered_map + connectionMeasurementSpecifications; + +uint8_t getMeasurementSpecifications(const ConnectionClass* connection) +{ + std::lock_guard lock(measurementSpecificationsMutex); + if (auto it = connectionMeasurementSpecifications.find(connection); + it != connectionMeasurementSpecifications.end()) + { + return it->second; + } + return ConnectionClass::measurementSpecificationDmtf; +} + +void setMeasurementSpecifications(const ConnectionClass* connection, + uint8_t specifications) +{ + std::lock_guard lock(measurementSpecificationsMutex); + connectionMeasurementSpecifications[connection] = specifications; +} + +void eraseMeasurementSpecifications(const ConnectionClass* connection) +{ + std::lock_guard lock(measurementSpecificationsMutex); + connectionMeasurementSpecifications.erase(connection); +} + /** * @param[in] RTDExp Exponent value of base wait time * @param[in] RTDM RTDM multiplier for maximum allowed time @@ -111,6 +142,19 @@ ConnectionClass::ConnectionClass(const ContextClass& cont, LogClass& log, resetConnection(); } +ConnectionClass::ConnectionClass(const ContextClass& cont, LogClass& log, + uint8_t eid, std::string sockPath, + uint8_t measurementSpecifications) : + ConnectionClass(cont, log, eid, std::move(sockPath)) +{ + setMeasurementSpecifications(this, measurementSpecifications); +} + +ConnectionClass::~ConnectionClass() +{ + eraseMeasurementSpecifications(this); +} + RetStat ConnectionClass::refreshMeasurements(SlotIdx slotidx) { CertificateSlotIdx = slotidx; @@ -179,6 +223,7 @@ void ConnectionClass::resetConnection() respIfReqCode = 0; respIfReadyToken = std::nullopt; DMTFMeasurements.clear(); + DeviceEatToken.clear(); MeasurementsHash.clear(); MeasurementsSignature.clear(); MeasurementNonce.fill(0); @@ -537,7 +582,7 @@ RetStat ConnectionClass::tryNegotiateAlgorithms() PacketNegotiateAlgorithmsRequestVar request; request.Min.Header.MessageVersion = MessageVersion; - request.Min.MeasurementSpecification = 1 << 0; + request.Min.MeasurementSpecification = getMeasurementSpecifications(this); request.Min.BaseAsymAlgo = BaseAsymAlgoFlags::TPM_ALG_ECDSA_ECC_NIST_P256 | BaseAsymAlgoFlags::TPM_ALG_ECDSA_ECC_NIST_P384 | @@ -591,6 +636,19 @@ RetStat ConnectionClass::handleRecv() rs = RetStat::ERROR_INVALID_RESERVED; SPDMCPP_CONNECTION_RS_ERROR_RETURN_WITH_VERSION(rs); } + const uint8_t selectedMeasurementSpecification = + resp.Min.MeasurementSpecification; + const uint8_t supportedMeasurementSpecifications = + getMeasurementSpecifications(this); + if ((!skipMeasurements() && selectedMeasurementSpecification == 0) || + (selectedMeasurementSpecification != 0 && + (std::popcount(selectedMeasurementSpecification) != 1 || + (selectedMeasurementSpecification & + supportedMeasurementSpecifications) == 0))) + { + rs = RetStat::ERROR_WRONG_ALGO_BITS; + SPDMCPP_CONNECTION_RS_ERROR_RETURN_WITH_VERSION(rs); + } if (std::popcount( static_cast>( resp.Min.MeasurementHashAlgo)) > 1) @@ -958,7 +1016,13 @@ RetStat ConnectionClass::handleRecv() // parse and store DMTF Measurements for (const auto& block : resp.MeasurementBlockVector) { - if (block.Min.MeasurementSpecification == 1) + if (block.Min.MeasurementSpecification != + Algorithms.Min.MeasurementSpecification) + { + rs = RetStat::ERROR_WRONG_ALGO_BITS; + SPDMCPP_CONNECTION_RS_ERROR_RETURN_WITH_VERSION(rs); + } + if (block.Min.MeasurementSpecification == measurementSpecificationDmtf) { if (DMTFMeasurements.find(block.Min.Index) != DMTFMeasurements.end()) @@ -984,6 +1048,13 @@ RetStat ConnectionClass::handleRecv() } } } + else if (block.Min.MeasurementSpecification == + measurementSpecificationEat) + { + DeviceEatToken.insert(DeviceEatToken.end(), + block.MeasurementVector.begin(), + block.MeasurementVector.end()); + } } // Reset index if used if (requestedMeasurementIdx == 255) diff --git a/libspdmcpp/headers_public/spdmcpp/connection.hpp b/libspdmcpp/headers_public/spdmcpp/connection.hpp index 52081a7..48205c3 100644 --- a/libspdmcpp/headers_public/spdmcpp/connection.hpp +++ b/libspdmcpp/headers_public/spdmcpp/connection.hpp @@ -154,6 +154,9 @@ class ConnectionClass : public NonCopyable * DSP0274_1.1.1 page 56 */ static constexpr SlotIdx slotNum = 8; + static constexpr uint8_t measurementSpecificationDmtf = 1U << 0U; + static constexpr uint8_t measurementSpecificationEat = 1U << 1U; + /** @brief Main constructor * @param[in] context - Context containing various common configuration and * information @@ -162,7 +165,12 @@ class ConnectionClass : public NonCopyable explicit ConnectionClass(const ContextClass& context, LogClass& log, uint8_t eid, std::string sockPath); - ~ConnectionClass() = default; + /** @brief Constructor with explicit requester measurement specifications. + */ + ConnectionClass(const ContextClass& context, LogClass& log, uint8_t eid, + std::string sockPath, uint8_t measurementSpecifications); + + ~ConnectionClass(); /** @brief get send timeout during the connection * @@ -339,6 +347,13 @@ class ConnectionClass : public NonCopyable return toHash(Algorithms.Min.MeasurementHashAlgo); } + /** @brief Negotiated MeasurementSpecification from ALGORITHMS. */ + uint8_t getMeasurementSpecification() const + { + SPDMCPP_ASSERT(hasInfo(ConnectionInfoEnum::ALGORITHMS)); + return Algorithms.Min.MeasurementSpecification; + } + /** @brief Capabilities flag for responder capabilities * */ @@ -356,11 +371,9 @@ class ConnectionClass : public NonCopyable return MessageVersion; } - /** @brief Returns the certificate chain for the given slot index - * @details Note this function will return false if the certificate chain - * was not fetched for the given slot (even if it is available on the device - * itself) - * @param[out] buf - the buffer into which the certificate chain is written + /** @brief Returns the DER certificate chain for the given slot index + * @details This strips the SPDM certificate-chain header and RootHash. + * @param[out] buf - the buffer into which the DER chain is written * @returns true if the certificate chain was available and written into * buf, false otherwise */ @@ -417,6 +430,12 @@ class ConnectionClass : public NonCopyable return CombinedMeasurementTranscript; } + + /** @brief VERSION, CAPABILITIES, and ALGORITHMS request/response bytes. */ + const std::vector& getVcaTranscript() const + { + return refBuf(BufEnum::A); + } /** @brief The L1/L2 hash of the measurements, as returned by * getSignedMeasurementsBuffer() */ @@ -432,6 +451,11 @@ class ConnectionClass : public NonCopyable { return MeasurementsSignature; } + /** @brief Reassembled EAT token bytes from EAT measurement blocks. */ + const std::vector& getDeviceEatToken() const + { + return DeviceEatToken; + } const nonce_array_32& getMeasurementNonce() const { return MeasurementNonce; @@ -780,6 +804,9 @@ class ConnectionClass : public NonCopyable */ DMTFMeasurementsContainer DMTFMeasurements; + /** @brief Storage for reassembled EAT measurement-block payloads. */ + std::vector DeviceEatToken; + /** @brief Storage for the final L1/L2 hash */ std::vector MeasurementsHash; diff --git a/libspdmcpp/tests/connection_test.cpp b/libspdmcpp/tests/connection_test.cpp index 307e748..c36248f 100644 --- a/libspdmcpp/tests/connection_test.cpp +++ b/libspdmcpp/tests/connection_test.cpp @@ -127,9 +127,11 @@ class ConnectionFixture ContextClass Context; ConnectionClass Connection; - ConnectionFixture() : + explicit ConnectionFixture( + uint8_t measurementSpecifications = + ConnectionClass::measurementSpecificationDmtf) : logg(std::cout), IO(std::make_shared()), - Connection(Context, logg, 0, "pcie") + Connection(Context, logg, 0, "pcie", measurementSpecifications) { #ifndef MCTP_IN_KERNEL Context.registerIo(IO, "pcie"); @@ -260,6 +262,8 @@ void testConnectionFlow(BaseAsymAlgoFlags asymAlgo, BaseHashAlgoFlags hashAlgo) algoResp.Min.BaseAsymAlgo = asymAlgo; algoResp.Min.BaseHashAlgo = hashAlgo; + algoResp.Min.MeasurementSpecification = + ConnectionClass::measurementSpecificationDmtf; algoResp.Min.MeasurementHashAlgo = MeasurementHashAlgoFlags::TPM_ALG_SHA_512; @@ -306,6 +310,8 @@ void testConnectionFlow(BaseAsymAlgoFlags asymAlgo, BaseHashAlgoFlags hashAlgo) PacketNegotiateAlgorithmsRequestVar req; auto rs = fix.interpret(req, MessageHashEnum::M); ASSERT_EQ(rs, RetStat::OK); + EXPECT_EQ(req.Min.MeasurementSpecification, + ConnectionClass::measurementSpecificationDmtf); EXPECT_FLAG_SET(req.Min.BaseAsymAlgo, BaseAsymAlgoFlags::TPM_ALG_ECDSA_ECC_NIST_P256); EXPECT_FLAG_SET(req.Min.BaseHashAlgo, @@ -538,9 +544,15 @@ enum class Spdm12MeasurementsFault : uint8_t void testConnectionFlow_SPDM12( BaseAsymAlgoFlags asymAlgo, BaseHashAlgoFlags hashAlgo, - Spdm12MeasurementsFault fault = Spdm12MeasurementsFault::None) + Spdm12MeasurementsFault fault = Spdm12MeasurementsFault::None, + uint8_t requesterMeasurementSpecifications = + ConnectionClass::measurementSpecificationDmtf, + uint8_t selectedMeasurementSpecification = + ConnectionClass::measurementSpecificationDmtf, + uint8_t blockMeasurementSpecification = 0, + RetStat expectedAlgorithmsResult = RetStat::OK) { - ConnectionFixture fix; + ConnectionFixture fix(requesterMeasurementSpecifications); fix.Connection.refreshMeasurements(0); @@ -550,6 +562,7 @@ void testConnectionFlow_SPDM12( algoResp.Min.BaseAsymAlgo = asymAlgo; algoResp.Min.BaseHashAlgo = hashAlgo; + algoResp.Min.MeasurementSpecification = selectedMeasurementSpecification; algoResp.Min.MeasurementHashAlgo = MeasurementHashAlgoFlags::TPM_ALG_SHA_512; @@ -596,6 +609,8 @@ void testConnectionFlow_SPDM12( PacketNegotiateAlgorithmsRequestVar req; auto rs = fix.interpret(req, MessageHashEnum::M); ASSERT_EQ(rs, RetStat::OK); + EXPECT_EQ(req.Min.MeasurementSpecification, + requesterMeasurementSpecifications); } PacketDecodeInfo info; @@ -656,7 +671,18 @@ void testConnectionFlow_SPDM12( auto rs = fix.push(algoResp, MessageHashEnum::M); ASSERT_EQ(rs, RetStat::OK); rs = fix.handleRecv(); - ASSERT_EQ(rs, RetStat::OK); + if (isError(expectedAlgorithmsResult)) + { + EXPECT_EQ(rs, RetStat::OK); + EXPECT_FALSE( + fix.Connection.hasInfo(ConnectionInfoEnum::ALGORITHMS)); + mbedtls_x509_crt_free(&caCert); + mbedtls_pk_free(&pkctx); + return; + } + ASSERT_EQ(rs, expectedAlgorithmsResult); + EXPECT_EQ(fix.Connection.getMeasurementSpecification(), + selectedMeasurementSpecification); } { @@ -669,6 +695,7 @@ void testConnectionFlow_SPDM12( digestResp.Min.Header.MessageVersion = MessageVersionEnum::SPDM_1_2; PacketCertificateResponseVar certResp; certResp.Min.Header.MessageVersion = MessageVersionEnum::SPDM_1_2; + std::vector expectedCertificateChainDer; { std::vector& certBuf = certResp.CertificateVector; @@ -678,6 +705,7 @@ void testConnectionFlow_SPDM12( // NOLINTNEXTLINE cppcoreguidelines-pro-bounds-pointer-arithmetic std::copy(caCert.raw.p, caCert.raw.p + caCert.raw.len, rootCert.begin()); + expectedCertificateChainDer = rootCert; std::vector rootCertHash; HashClass::compute(rootCertHash, toHash(algoResp.Min.BaseHashAlgo), @@ -720,6 +748,12 @@ void testConnectionFlow_SPDM12( ASSERT_EQ(rs, RetStat::OK); rs = fix.handleRecv(); ASSERT_EQ(rs, RetStat::OK); + + std::vector certificateChainDer; + ASSERT_TRUE(fix.Connection.getCertificatesDER(certificateChainDer, 0)); + EXPECT_EQ(certificateChainDer, expectedCertificateChainDer); + ASSERT_FALSE(certificateChainDer.empty()); + EXPECT_EQ(certificateChainDer.front(), 0x30); } { @@ -738,7 +772,16 @@ void testConnectionFlow_SPDM12( { PacketMeasurementBlockVar block; block.Min.Index = 1; - block.Min.MeasurementSpecification = 1; + block.Min.MeasurementSpecification = + blockMeasurementSpecification == 0 + ? selectedMeasurementSpecification + : blockMeasurementSpecification; + if (block.Min.MeasurementSpecification == + ConnectionClass::measurementSpecificationEat) + { + block.MeasurementVector = {0xD8, 0x3D, 0x84, 0x40}; + } + else { PacketMeasurementFieldVar field; field.Min.Type = 0x80; @@ -804,18 +847,34 @@ void testConnectionFlow_SPDM12( rs = fix.push(resp); ASSERT_EQ(rs, RetStat::OK); rs = fix.handleRecv(); - if (fault == Spdm12MeasurementsFault::None) + const bool specificationMismatch = + blockMeasurementSpecification != 0 && + blockMeasurementSpecification != selectedMeasurementSpecification; + if (fault == Spdm12MeasurementsFault::None && !specificationMismatch) { ASSERT_EQ(rs, RetStat::OK); } + else if (specificationMismatch) + { + EXPECT_EQ(rs, RetStat::OK); + } } - if (fault != Spdm12MeasurementsFault::None) + if (fault != Spdm12MeasurementsFault::None || + (blockMeasurementSpecification != 0 && + blockMeasurementSpecification != selectedMeasurementSpecification)) { EXPECT_FALSE(fix.Connection.hasInfo(ConnectionInfoEnum::MEASUREMENTS)) << "Malformed measurements / attestation (SPDM 1.2) must not mark " "MEASUREMENTS"; } + else if (selectedMeasurementSpecification == + ConnectionClass::measurementSpecificationEat) + { + EXPECT_EQ(fix.Connection.getDeviceEatToken(), + (std::vector{0xD8, 0x3D, 0x84, 0x40})); + EXPECT_TRUE(fix.Connection.getDMTFMeasurements().empty()); + } mbedtls_x509_crt_free(&caCert); mbedtls_pk_free(&pkctx); @@ -833,6 +892,59 @@ TEST(Connection, FullFlow_SPDM12_ECDSA_256_SHA_384) BaseHashAlgoFlags::TPM_ALG_SHA_384); } +TEST(Connection, FullFlow_SPDM12_AdvertisesEatMeasurementSpecification) +{ + constexpr uint8_t supported = + ConnectionClass::measurementSpecificationDmtf | + ConnectionClass::measurementSpecificationEat; + testConnectionFlow_SPDM12( + BaseAsymAlgoFlags::TPM_ALG_ECDSA_ECC_NIST_P256, + BaseHashAlgoFlags::TPM_ALG_SHA_384, Spdm12MeasurementsFault::None, + supported, ConnectionClass::measurementSpecificationEat); +} + +TEST(Connection, SPDM12RejectsZeroMeasurementSpecificationSelection) +{ + testConnectionFlow_SPDM12( + BaseAsymAlgoFlags::TPM_ALG_ECDSA_ECC_NIST_P256, + BaseHashAlgoFlags::TPM_ALG_SHA_384, Spdm12MeasurementsFault::None, + ConnectionClass::measurementSpecificationDmtf, 0, 0, + RetStat::ERROR_WRONG_ALGO_BITS); +} + +TEST(Connection, SPDM12RejectsUnadvertisedMeasurementSpecification) +{ + testConnectionFlow_SPDM12( + BaseAsymAlgoFlags::TPM_ALG_ECDSA_ECC_NIST_P256, + BaseHashAlgoFlags::TPM_ALG_SHA_384, Spdm12MeasurementsFault::None, + ConnectionClass::measurementSpecificationDmtf, + ConnectionClass::measurementSpecificationEat, 0, + RetStat::ERROR_WRONG_ALGO_BITS); +} + +TEST(Connection, SPDM12RejectsMultipleMeasurementSpecificationSelection) +{ + constexpr uint8_t supported = + ConnectionClass::measurementSpecificationDmtf | + ConnectionClass::measurementSpecificationEat; + testConnectionFlow_SPDM12( + BaseAsymAlgoFlags::TPM_ALG_ECDSA_ECC_NIST_P256, + BaseHashAlgoFlags::TPM_ALG_SHA_384, Spdm12MeasurementsFault::None, + supported, supported, 0, RetStat::ERROR_WRONG_ALGO_BITS); +} + +TEST(Connection, SPDM12RejectsMeasurementBlockSpecificationMismatch) +{ + constexpr uint8_t supported = + ConnectionClass::measurementSpecificationDmtf | + ConnectionClass::measurementSpecificationEat; + testConnectionFlow_SPDM12( + BaseAsymAlgoFlags::TPM_ALG_ECDSA_ECC_NIST_P256, + BaseHashAlgoFlags::TPM_ALG_SHA_384, Spdm12MeasurementsFault::None, + supported, ConnectionClass::measurementSpecificationEat, + ConnectionClass::measurementSpecificationDmtf); +} + TEST(Connection, FullFlow_SPDM12_InvalidMeasurementSignature) { testConnectionFlow_SPDM12(BaseAsymAlgoFlags::TPM_ALG_ECDSA_ECC_NIST_P256, diff --git a/meson.build b/meson.build index 0af7e5a..29ab126 100644 --- a/meson.build +++ b/meson.build @@ -39,6 +39,10 @@ conf_data.set( get_option('discovery_only_from_mctp_control').enabled(), ) conf_data.set('SPDM_JSON_CONF_FILE_NAME', get_option('conf_file_name')) +conf_data.set_quoted( + 'COMPOSITE_ATTESTER_BACKEND', + get_option('attester-backend'), +) conf_data.set('USE_DEFAULT_DBUS', get_option('use_default_dbus').enabled()) conf_data.set('MCTP_IN_KERNEL', get_option('enable-in-kernel-mctp').enabled()) @@ -101,6 +105,18 @@ else ) endif +if get_option('composite-attestation').enabled() + # Composite attestation builds all CBOR through tinycbor (deterministic + # subset, with map-key ordering added by composite/cbor_det). Required by + # the producer core and the mock attester. + tinycbor_dep = dependency( + 'tinycbor', + fallback: ['tinycbor', 'tinycbor_dep'], + ) +else + tinycbor_dep = dependency('', required: false, disabler: true) +endif + subdir('libspdmcpp') subdir('tools/libmctppacketcorrupt') diff --git a/meson_options.txt b/meson_options.txt index d19d8f9..952e5e9 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -17,3 +17,8 @@ option('use_fuzz', type: 'feature', description: 'Fuzz testing.', value: 'disabl option('enable-in-kernel-mctp', type: 'feature', value: 'disabled', description: 'Use the in-kernel MCTP stack instead of the demux daemon') option('csm_service_enabled', type: 'feature', description: 'Skip check for ccsm service if the service is disabled. Disable this flag on platforms where ccsm is not supported/enabled', value: 'enabled') + +# Composite attestation (BMC-mediated platform composite attestation). +option('composite-attestation', type: 'feature', value: 'disabled', description: 'Build BMC-mediated composite attestation producer') +# Selects the Lead Attester backend that signs the composite EAT. +option('attester-backend', type: 'combo', choices: ['none', 'mock'], value: 'none', description: 'Lead Attester backend for composite attestation') diff --git a/spdmd/composite/bundle_assembler.cpp b/spdmd/composite/bundle_assembler.cpp new file mode 100644 index 0000000..0211b61 --- /dev/null +++ b/spdmd/composite/bundle_assembler.cpp @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2024 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include "bundle_assembler.hpp" + +#include "cbor_det.hpp" + +namespace spdmd::composite +{ + +std::vector assembleBundle( + std::span compositeEat, + const std::vector>>& + detachedClaimsSets) +{ + // detached-claims-sets: map { env.* => bstr .cbor claims-set }. + cbor::Map csMap; + for (const auto& [env, cs] : detachedClaimsSets) + { + // The value is a byte string whose content is the encoded + // Claims-Set (bstr .cbor). + csMap.addText(env, cbor::bytesVal(cs)); + } + + std::vector out; + cbor::putTag(out, kCborTagDetachedEatBundle); + cbor::putArrayHeader(out, 2); + + // main-token wrapped as a byte string (bstr .cbor signed-EAT). + cbor::putBytes(out, compositeEat); + + const std::vector csMapBytes = csMap.encode(); + out.insert(out.end(), csMapBytes.begin(), csMapBytes.end()); + + return out; +} + +} // namespace spdmd::composite diff --git a/spdmd/composite/bundle_assembler.hpp b/spdmd/composite/bundle_assembler.hpp new file mode 100644 index 0000000..d990322 --- /dev/null +++ b/spdmd/composite/bundle_assembler.hpp @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// BundleAssembler — tag-602 Detached EAT Bundle (RFC 9711, the composite +// attestation profile). +// +// 602([ +// main-token: bstr .cbor 61(18([...])), ; signed EAT bytes +// detached-claims-sets: { env.* => bstr .cbor cs } ; per-device +// ]) +// +// The signed composite EAT is wrapped as a byte string; each detached +// Claims-Set is wrapped as `bstr .cbor`. The submodule digest was already +// computed over the unwrapped Claims-Set bytes , so the bytes +// passed here must be exactly those that were digested. + +#pragma once + +#include +#include +#include +#include +#include + +namespace spdmd::composite +{ + +/// CBOR tag for an RFC 9711 Detached EAT Bundle. +inline constexpr std::uint64_t kCborTagDetachedEatBundle = 602; + +/// Assemble the tag-602 bundle. +/// +/// @param compositeEat Signed composite EAT bytes (CWT(COSE_Sign1)). +/// @param detachedClaimsSets Ordered (env.* , encoded Claims-Set bytes) +/// pairs. Keys are emitted in deterministic CBOR order regardless +/// of input order. +/// @return Encoded tag-602 Detached EAT Bundle bytes. +std::vector assembleBundle( + std::span compositeEat, + const std::vector>>& + detachedClaimsSets); + +} // namespace spdmd::composite diff --git a/spdmd/composite/cbor_det.cpp b/spdmd/composite/cbor_det.cpp new file mode 100644 index 0000000..6c5f42e --- /dev/null +++ b/spdmd/composite/cbor_det.cpp @@ -0,0 +1,215 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2024 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include "cbor_det.hpp" + +#include + +#include +#include +#include + +namespace spdmd::composite::cbor +{ + +namespace +{ + +/// Encode via tinycbor into a growable vector. tinycbor signals overflow +/// through get_extra_bytes_needed(); retry with a larger buffer. +std::vector + encodeToVec(const std::function& fn, + std::size_t initial = 64) +{ + std::vector buf(initial); + for (int attempt = 0; attempt < 6; ++attempt) + { + CborEncoder enc; + cbor_encoder_init(&enc, buf.data(), buf.size(), 0); + fn(enc); + std::size_t extra = cbor_encoder_get_extra_bytes_needed(&enc); + if (extra == 0) + { + buf.resize(cbor_encoder_get_buffer_size(&enc, buf.data())); + return buf; + } + buf.resize(buf.size() + extra); + } + throw std::runtime_error("cbor_det: encode overflow"); +} + +/// Emit a definite-length container head (array/map) in shortest form. +/// tinycbor only writes container heads through create_array/create_map, +/// which buffer until close; for a header-only writer we mirror its +/// shortest-form output here. Major: 4=array, 5=map. +void putContainerHead(std::vector& out, std::uint8_t major, + std::uint64_t n) +{ + const std::uint8_t mt = static_cast(major << 5U); + if (n < 24U) + { + out.push_back(static_cast(mt | n)); + } + else if (n <= 0xFFU) + { + out.push_back(static_cast(mt | 24U)); + out.push_back(static_cast(n)); + } + else if (n <= 0xFFFFU) + { + out.push_back(static_cast(mt | 25U)); + out.push_back(static_cast((n >> 8U) & 0xFFU)); + out.push_back(static_cast(n & 0xFFU)); + } + else + { + out.push_back(static_cast(mt | 26U)); + for (int s = 24; s >= 0; s -= 8) + { + out.push_back(static_cast((n >> s) & 0xFFU)); + } + } +} + +} // namespace + +void putUint(std::vector& out, std::uint64_t v) +{ + auto e = encodeToVec([&](CborEncoder& enc) { cbor_encode_uint(&enc, v); }); + out.insert(out.end(), e.begin(), e.end()); +} + +void putInt(std::vector& out, std::int64_t v) +{ + auto e = encodeToVec([&](CborEncoder& enc) { cbor_encode_int(&enc, v); }); + out.insert(out.end(), e.begin(), e.end()); +} + +void putBytes(std::vector& out, std::span v) +{ + auto e = encodeToVec( + [&](CborEncoder& enc) { + cbor_encode_byte_string(&enc, v.data(), v.size()); + }, + v.size() + 16); + out.insert(out.end(), e.begin(), e.end()); +} + +void putText(std::vector& out, std::string_view v) +{ + auto e = encodeToVec( + [&](CborEncoder& enc) { + cbor_encode_text_string(&enc, v.data(), v.size()); + }, + v.size() + 16); + out.insert(out.end(), e.begin(), e.end()); +} + +void putArrayHeader(std::vector& out, std::size_t n) +{ + putContainerHead(out, 4, n); +} + +void putMapHeader(std::vector& out, std::size_t n) +{ + putContainerHead(out, 5, n); +} + +void putTag(std::vector& out, std::uint64_t tag) +{ + auto e = encodeToVec([&](CborEncoder& enc) { cbor_encode_tag(&enc, tag); }); + out.insert(out.end(), e.begin(), e.end()); +} + +std::vector uintVal(std::uint64_t v) +{ + std::vector o; + putUint(o, v); + return o; +} + +std::vector intVal(std::int64_t v) +{ + std::vector o; + putInt(o, v); + return o; +} + +std::vector bytesVal(std::span v) +{ + std::vector o; + putBytes(o, v); + return o; +} + +std::vector textVal(std::string_view v) +{ + std::vector o; + putText(o, v); + return o; +} + +std::vector + arrayVal(std::span> elems) +{ + std::vector out; + putArrayHeader(out, elems.size()); + for (const auto& e : elems) + { + out.insert(out.end(), e.begin(), e.end()); + } + return out; +} + +void Map::addInt(std::int64_t key, std::vector value) +{ + entries.emplace_back(intVal(key), std::move(value)); +} + +void Map::addText(std::string_view key, std::vector value) +{ + entries.emplace_back(textVal(key), std::move(value)); +} + +std::vector Map::encode() const +{ + // tinycbor produces shortest-form, definite-length items. The one + // determinism rule it does not enforce is map-key ordering, so we + // own that: sort by bytewise lexicographic order of the encoded key + // (RFC 8949 §4.2.1), then concatenate header + key/value pairs. + std::vector< + const std::pair, std::vector>*> + ordered; + ordered.reserve(entries.size()); + for (const auto& e : entries) + { + ordered.push_back(&e); + } + std::sort(ordered.begin(), ordered.end(), + [](const auto* a, const auto* b) { return a->first < b->first; }); + + std::vector out; + putMapHeader(out, ordered.size()); + for (const auto* e : ordered) + { + out.insert(out.end(), e->first.begin(), e->first.end()); + out.insert(out.end(), e->second.begin(), e->second.end()); + } + return out; +} + +} // namespace spdmd::composite::cbor diff --git a/spdmd/composite/cbor_det.hpp b/spdmd/composite/cbor_det.hpp new file mode 100644 index 0000000..8d527d5 --- /dev/null +++ b/spdmd/composite/cbor_det.hpp @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// Deterministic CBOR encoder. +// +// A thin deterministic wrapper over tinycbor. tinycbor handles the +// encoding (definite-length, shortest-form integers/lengths); this layer +// adds the one rule tinycbor does not enforce: map keys sorted in +// bytewise lexicographic order of their deterministic encodings. All +// composite CBOR — claims-sets, the composite EAT, COSE structures, and +// the tag-602 bundle — flows through this single path so the producer and +// verifier agree on byte-exact output. + +#pragma once + +#include +#include +#include +#include +#include + +namespace spdmd::composite::cbor +{ + +// --- Primitive appenders ---------------------------------------------------- + +/// Unsigned integer (major 0). +void putUint(std::vector& out, std::uint64_t v); + +/// Signed integer (major 0 when >= 0, major 1 when negative). +void putInt(std::vector& out, std::int64_t v); + +/// Byte string (major 2). +void putBytes(std::vector& out, std::span v); + +/// Text string (major 3). +void putText(std::vector& out, std::string_view v); + +/// Array header (major 4) — caller appends @p n elements afterward. +void putArrayHeader(std::vector& out, std::size_t n); + +/// Map header (major 5) — caller appends @p n key/value pairs afterward. +void putMapHeader(std::vector& out, std::size_t n); + +/// Tag (major 6). +void putTag(std::vector& out, std::uint64_t tag); + +// --- Value helpers (return a freshly encoded item) -------------------------- + +std::vector uintVal(std::uint64_t v); +std::vector intVal(std::int64_t v); +std::vector bytesVal(std::span v); +std::vector textVal(std::string_view v); + +/// Encode a definite-length array whose elements are pre-encoded items. +std::vector + arrayVal(std::span> elems); + +// --- Deterministic map builder ---------------------------------------------- + +/// Collects (key,value) pairs as pre-encoded byte sequences and emits a +/// definite-length map with keys sorted per RFC 8949 §4.2.1. +class Map +{ + public: + /// Add an entry with an integer key. + void addInt(std::int64_t key, std::vector value); + + /// Add an entry with a text-string key. + void addText(std::string_view key, std::vector value); + + /// Number of entries currently held. + std::size_t size() const + { + return entries.size(); + } + + /// Emit the deterministic CBOR map. + std::vector encode() const; + + private: + // (encoded-key, encoded-value) pairs. + std::vector, std::vector>> + entries; +}; + +} // namespace spdmd::composite::cbor diff --git a/spdmd/composite/claims_set_builder.cpp b/spdmd/composite/claims_set_builder.cpp new file mode 100644 index 0000000..039a8e6 --- /dev/null +++ b/spdmd/composite/claims_set_builder.cpp @@ -0,0 +1,114 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2024 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include "claims_set_builder.hpp" + +#include "cbor_det.hpp" + +#include + +namespace spdmd::composite +{ + +namespace +{ + +// Claims-Set text keys (stable; recognized by verifiers). +constexpr const char* kKeySignedMeasurements = "signed_measurements"; +constexpr const char* kKeyCertChain = "cert_chain"; +constexpr const char* kKeyVca = "vca"; +constexpr const char* kKeyTokenFormat = "token_format"; +constexpr const char* kKeyDeviceToken = "device_token"; + +/// Encode a Pattern A/C byte field either plain or CMW-style typed. +std::vector byteField(std::span bytes, + bool typedValues, + std::uint64_t contentFormat) +{ + if (!typedValues) + { + return cbor::bytesVal(bytes); + } + std::vector> elems; + elems.push_back(cbor::uintVal(contentFormat)); + elems.push_back(cbor::bytesVal(bytes)); + return cbor::arrayVal(elems); +} + +std::vector buildSpdmEvidence(const CollectedEvidence& ev, + bool typedValues) +{ + if (ev.signedMeasurements.empty()) + { + throw std::invalid_argument( + "buildClaimsSet: signed_measurements is empty"); + } + if (ev.certificateChainDer.empty()) + { + throw std::invalid_argument("buildClaimsSet: cert_chain is empty"); + } + if (ev.includeVca && ev.vca.empty()) + { + throw std::invalid_argument("buildClaimsSet: vca is required"); + } + + cbor::Map m; + m.addText( + kKeySignedMeasurements, + byteField(ev.signedMeasurements, typedValues, kCfSpdmMeasurements)); + m.addText(kKeyCertChain, byteField(ev.certificateChainDer, typedValues, + kCfConcatenatedDerCertificates)); + if (ev.includeVca) + { + m.addText(kKeyVca, byteField(ev.vca, typedValues, kCfSpdmVca)); + } + return m.encode(); +} + +std::vector buildDeviceEat(const CollectedEvidence& ev) +{ + if (ev.deviceTokenFormat.empty()) + { + throw std::invalid_argument("buildClaimsSet: token_format is empty"); + } + if (ev.deviceToken.empty()) + { + throw std::invalid_argument("buildClaimsSet: device_token is empty"); + } + + cbor::Map m; + m.addText(kKeyTokenFormat, cbor::textVal(ev.deviceTokenFormat)); + m.addText(kKeyDeviceToken, cbor::bytesVal(ev.deviceToken)); + return m.encode(); +} + +} // namespace + +std::vector buildClaimsSet(const CollectedEvidence& ev, + bool typedValues) +{ + switch (ev.pattern) + { + case EvidencePattern::SpdmMeasurements: + return buildSpdmEvidence(ev, typedValues); + case EvidencePattern::DeviceEat: + return buildDeviceEat(ev); + } + throw std::invalid_argument("buildClaimsSet: unknown pattern"); +} + +} // namespace spdmd::composite diff --git a/spdmd/composite/claims_set_builder.hpp b/spdmd/composite/claims_set_builder.hpp new file mode 100644 index 0000000..5e83026 --- /dev/null +++ b/spdmd/composite/claims_set_builder.hpp @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// ClaimsSetBuilder — deterministic CBOR detached Claims-Sets. +// +// Builds one detached Claims-Set per device. The BMC +// preserves device evidence in its native form; it does not translate +// SPDM transcripts, device EATs, or Concise Evidence into platform- +// authored claims. +// +// Pattern A / C -> spdm-evidence-claims-set +// { "signed_measurements", "cert_chain", ?"vca" } +// cert_chain is concatenated DER certificates. +// Pattern B -> device-eat-claims-set +// { "token_format", "device_token" } +// +// The optional CMW-style typed-value form wraps each byte field as +// [content-format, value]; it does not change the evidence semantics. +// The digest (SubmoduleDigest) is computed over the bytes returned here, +// before any bstr wrapping in the tag-602 bundle. + +#pragma once + +#include "types.hpp" + +#include +#include + +namespace spdmd::composite +{ + +/// CoAP Content-Formats used by the optional typed-value wrapping. These +/// are profile-local placeholders pending registration. +inline constexpr std::uint64_t kCfSpdmMeasurements = 65000; +inline constexpr std::uint64_t kCfConcatenatedDerCertificates = 65001; +inline constexpr std::uint64_t kCfSpdmVca = 65002; + +/// Build the deterministic CBOR detached Claims-Set for one successfully +/// collected device. +/// +/// @param ev Collected evidence (must have success == true). +/// @param typedValues When true, wrap SPDM byte fields as CMW-style +/// [content-format, value] pairs (Pattern A/C only). +/// @return Encoded Claims-Set bytes (unwrapped — feed to SubmoduleDigest +/// and to BundleAssembler's bstr wrapper). +/// @throws std::invalid_argument on empty required fields. +std::vector buildClaimsSet(const CollectedEvidence& ev, + bool typedValues = false); + +} // namespace spdmd::composite diff --git a/spdmd/composite/collection_plan.cpp b/spdmd/composite/collection_plan.cpp new file mode 100644 index 0000000..ba0f01f --- /dev/null +++ b/spdmd/composite/collection_plan.cpp @@ -0,0 +1,277 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2024 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include "collection_plan.hpp" + +#include + +#include +#include + +namespace spdmd::composite +{ + +namespace +{ + +std::optional collectionPlanFromDocument( + const nlohmann::json& doc, std::string& err) +{ + if (doc.is_discarded() || !doc.is_object()) + { + err = "composite.json: not a JSON object"; + return std::nullopt; + } + + CollectionPlan plan; + + if (auto it = doc.find("platformCorimLocator"); it != doc.end()) + { + if (!it->is_string()) + { + err = "composite.json: platformCorimLocator must be a string"; + return std::nullopt; + } + plan.setPlatformCorimLocator(it->get()); + } + + auto envs = doc.find("environments"); + if (envs == doc.end()) + { + return plan; + } + if (!envs->is_array()) + { + err = "composite.json: environments must be an array"; + return std::nullopt; + } + + for (const auto& item : *envs) + { + if (!item.is_object() || !item.contains("env") || + !item["env"].is_string()) + { + err = "composite.json: each environment needs a string 'env'"; + return std::nullopt; + } + CollectionPlan::Entry entry; + entry.env = item["env"].get(); + + auto match = item.find("match"); + if (match == item.end() || !match->is_object()) + { + err = "composite.json: environment match must be an object"; + return std::nullopt; + } + + bool hasPredicate = false; + if (auto value = match->find("mctpEid"); value != match->end()) + { + if (!value->is_number_unsigned() || + value->get() > 0xff) + { + err = "composite.json: mctpEid must be an unsigned byte"; + return std::nullopt; + } + entry.mctpEid = + static_cast(value->get()); + hasPredicate = true; + } + if (auto value = match->find("redfishUri"); value != match->end()) + { + if (!value->is_string()) + { + err = "composite.json: redfishUri must be a string"; + return std::nullopt; + } + entry.redfishUri = value->get(); + hasPredicate = true; + } + if (auto value = match->find("pcieBdf"); value != match->end()) + { + if (!value->is_string()) + { + err = "composite.json: pcieBdf must be a string"; + return std::nullopt; + } + entry.pcieBdf = value->get(); + hasPredicate = true; + } + if (auto value = match->find("i2cAddr"); value != match->end()) + { + if (!value->is_string()) + { + err = "composite.json: i2cAddr must be a string"; + return std::nullopt; + } + entry.i2cAddr = value->get(); + hasPredicate = true; + } + if (!hasPredicate) + { + err = "composite.json: environment match needs a supported " + "predicate"; + return std::nullopt; + } + + if (!plan.addEntry(std::move(entry))) + { + err = "composite.json: invalid env id (must be env.* per " + "the composite attestation profile)"; + return std::nullopt; + } + } + + return plan; +} + +} // namespace + +bool CollectionPlan::isValidEnvId(std::string_view id) +{ + if (id.empty() || id.size() > 64) + { + return false; + } + if (id.size() < 5 || id.substr(0, 4) != "env.") + { + return false; + } + // Lowercase ASCII letters, digits, dot; no leading/trailing/double + // dots; each component non-empty. + bool prevDot = false; + for (std::size_t i = 0; i < id.size(); ++i) + { + const char c = id[i]; + if (c == '.') + { + if (prevDot) + { + return false; // empty component + } + prevDot = true; + continue; + } + prevDot = false; + const bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'); + if (!ok) + { + return false; + } + } + // No trailing dot. + return id.back() != '.'; +} + +bool CollectionPlan::addEntry(Entry e) +{ + if (!isValidEnvId(e.env)) + { + return false; + } + entries.push_back(std::move(e)); + return true; +} + +std::string CollectionPlan::resolve(const Locator& loc) const +{ + for (const auto& e : entries) + { + // An entry matches only if every populated predicate matches. + if (e.mctpEid && *e.mctpEid != loc.eid) + { + continue; + } + if (e.redfishUri && + (!loc.redfishUri || *e.redfishUri != *loc.redfishUri)) + { + continue; + } + if (e.pcieBdf && (!loc.pcieBdf || *e.pcieBdf != *loc.pcieBdf)) + { + continue; + } + if (e.i2cAddr && (!loc.i2cAddr || *e.i2cAddr != *loc.i2cAddr)) + { + continue; + } + // At least one predicate must be populated to count as a rule. + if (e.mctpEid || e.redfishUri || e.pcieBdf || e.i2cAddr) + { + return e.env; + } + } + return "env.unknown." + std::to_string(static_cast(loc.eid)); +} + +std::string CollectionPlan::resolveByEid(std::uint8_t eid) const +{ + Locator loc; + loc.eid = eid; + return resolve(loc); +} + +std::optional CollectionPlan::fromJson(std::string_view json, + std::string& err) +{ + nlohmann::json doc = + nlohmann::json::parse(json, nullptr, false /*no exceptions*/); + return collectionPlanFromDocument(doc, err); +} + +std::optional + parseCompositeConfig(std::string_view json, std::string& err) +{ + nlohmann::json doc = + nlohmann::json::parse(json, nullptr, false /*no exceptions*/); + if (doc.is_discarded() || !doc.is_object()) + { + err = "composite.json: not a JSON object"; + return std::nullopt; + } + + auto plan = collectionPlanFromDocument(doc, err); + if (!plan) + { + return std::nullopt; + } + + ParsedCompositeConfig config; + config.plan = std::move(*plan); + if (auto it = doc.find("allowUnknownEnvironments"); + it != doc.end() && it->is_boolean()) + { + config.allowUnknownEnvironments = it->get(); + } + if (auto it = doc.find("skipDevices"); + it != doc.end() && it->is_array()) + { + for (const auto& eid : *it) + { + if (eid.is_number_unsigned() && + eid.get() <= 0xff) + { + config.skipDevices.push_back( + static_cast(eid.get())); + } + } + } + + return config; +} + +} // namespace spdmd::composite diff --git a/spdmd/composite/collection_plan.hpp b/spdmd/composite/collection_plan.hpp new file mode 100644 index 0000000..06d3e53 --- /dev/null +++ b/spdmd/composite/collection_plan.hpp @@ -0,0 +1,113 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// CollectionPlan — maps discovered SPDM endpoints to stable env.* target +// environment identifiers. +// +// The submods map key is an operator-owned env.* topology label, NOT the +// MCTP EID. This class resolves a runtime locator (MCTP EID, Redfish URI, +// PCIe BDF, I2C address) to an env.* key using an explicit config plan, +// falling back to env.unknown. when no entry matches. The collection +// plan is operator data derived from (but not authoritative to) the +// Platform CoRIM. + +#pragma once + +#include +#include +#include +#include +#include + +namespace spdmd::composite +{ + +class CollectionPlan +{ + public: + /// One environment mapping rule. The first rule whose populated + /// match predicates all match a discovered endpoint wins. + struct Entry + { + std::string env; // env.* target id + std::optional mctpEid; + std::optional redfishUri; + std::optional pcieBdf; + std::optional i2cAddr; + }; + + /// Locator attributes of a discovered endpoint to resolve. + struct Locator + { + std::uint8_t eid = 0; + std::optional redfishUri; + std::optional pcieBdf; + std::optional i2cAddr; + }; + + /// Validate an env.* identifier per the composite attestation profile: + /// lowercase ASCII, dot-separated, first component "env", at least one + /// target component, <= 64 bytes. + static bool isValidEnvId(std::string_view id); + + /// Add a mapping rule. Returns false if env is invalid. + bool addEntry(Entry e); + + /// Resolve a locator to an env.* key. Returns the first matching + /// entry's env, else "env.unknown.". + std::string resolve(const Locator& loc) const; + + /// Convenience: resolve by EID only. + std::string resolveByEid(std::uint8_t eid) const; + + /// Optional Platform CoRIM locator hint for the whole platform. + const std::optional& platformCorimLocator() const + { + return corimLocator; + } + void setPlatformCorimLocator(std::string locator) + { + corimLocator = std::move(locator); + } + + std::size_t size() const + { + return entries.size(); + } + + /// Parse a /etc/spdmd/composite.json document. On error returns + /// std::nullopt and sets @p err. Note: no eat_profile is read — the + /// Lead Attester (RoT) owns the composite EAT profile. + static std::optional fromJson(std::string_view json, + std::string& err); + + private: + std::vector entries; + std::optional corimLocator; +}; + +struct ParsedCompositeConfig +{ + CollectionPlan plan; + std::vector skipDevices; + bool allowUnknownEnvironments = false; +}; + +std::optional + parseCompositeConfig(std::string_view json, std::string& err); + +} // namespace spdmd::composite diff --git a/spdmd/composite/composite_orchestrator.cpp b/spdmd/composite/composite_orchestrator.cpp new file mode 100644 index 0000000..b498163 --- /dev/null +++ b/spdmd/composite/composite_orchestrator.cpp @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2024 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include "composite_orchestrator.hpp" + +#include "bundle_assembler.hpp" +#include "claims_set_builder.hpp" +#include "submodule_digest.hpp" + +#include +#include +#include + +namespace spdmd +{ + +std::string CompositeStatus::toStatusString() const +{ + if (!tokenProduced) + { + return "Error"; + } + if (devicesFailed == 0) + { + return "Success"; + } + return "PartialSuccess"; +} + +CompositeOrchestrator::CompositeOrchestrator(PlatformAttester& a, bool typed) : + attester(a), typedClaimsSets(typed) +{} + +CompositeOrchestrator::Result CompositeOrchestrator::produce( + std::span nonce, + std::span evidences, + std::optional corimLoc) +{ + using composite::buildClaimsSet; + using composite::CompositeEatRequest; + using composite::makeSubmoduleRecord; + using composite::SubmoduleRecord; + + Result out; + out.status.totalDevices = evidences.size(); + + // 1. Build detached Claims-Sets + digests for successful devices. + std::vector>> + detachedClaimsSets; + std::vector records; + + for (const auto& ev : evidences) + { + if (!ev.success) + { + ++out.status.devicesFailed; + out.status.deviceFailures.push_back( + {ev.eid, ev.environmentId, + ev.errorMsg.empty() ? "collection failed" : ev.errorMsg}); + continue; + } + try + { + std::vector cs = buildClaimsSet(ev, typedClaimsSets); + records.push_back(makeSubmoduleRecord(ev.environmentId, cs)); + detachedClaimsSets.emplace_back(ev.environmentId, std::move(cs)); + ++out.status.devicesSucceeded; + } + catch (const std::exception& e) + { + ++out.status.devicesFailed; + out.status.deviceFailures.push_back( + {ev.eid, ev.environmentId, e.what()}); + // A malformed device is treated as a collection failure; it + // contributes no Claims-Set and no submod. + } + } + + // 2. Ask the Lead Attester to build the composite EAT. + CompositeEatRequest req; + std::copy(nonce.begin(), nonce.end(), req.nonce.begin()); + req.deviceRecords = records; + req.platformCorimLocator = std::move(corimLoc); + + composite::CompositeEatResponse attResult = + attester.generateCompositeEat(req); + + out.status.platformAttesterStatus = + std::string{toString(attester.getStatus())}; + out.status.attesterErrorMsg = attResult.errorMsg; + + if (!attResult.success) + { + out.success = false; + out.errorMsg = "attester failed: " + attResult.errorMsg; + return out; + } + out.status.tokenProduced = true; + + // 3. Assemble the tag-602 bundle. + out.bundle = + composite::assembleBundle(attResult.compositeEat, detachedClaimsSets); + out.success = true; + return out; +} + +} // namespace spdmd diff --git a/spdmd/composite/composite_orchestrator.hpp b/spdmd/composite/composite_orchestrator.hpp new file mode 100644 index 0000000..f0c6671 --- /dev/null +++ b/spdmd/composite/composite_orchestrator.hpp @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// CompositeOrchestrator — vendor-neutral composite attestation core. +// +// 1. For each successfully collected device: build a detached +// Claims-Set (ClaimsSetBuilder) and compute its digest +// (SubmoduleDigest) -> SubmoduleRecord keyed by env.*. +// 2. Submit { nonce, device records, optional CoRIM locator } to the +// Lead Attester, which returns the signed composite EAT. +// 3. Assemble the tag-602 Detached EAT Bundle (BundleAssembler) by +// pairing the signed EAT with the detached Claims-Sets. +// +// This class has no dependency on D-Bus, sdbusplus, or JSON. The D-Bus +// binding that translates an incoming Redfish/Generate request into a +// call to produce() lives separately with the per-device responder layer. + +#pragma once + +#include "platform_attester.hpp" +#include "types.hpp" + +#include +#include +#include +#include +#include + +namespace spdmd +{ + +/// Aggregate counts + attester status for surfacing a single status +/// string (Idle / Collecting / Success / PartialSuccess / Error). +struct CompositeStatus +{ + struct DeviceFailure + { + std::uint8_t eid = 0; + std::string environmentId; + std::string errorMsg; + }; + + std::size_t totalDevices = 0; + std::size_t devicesSucceeded = 0; + std::size_t devicesFailed = 0; + bool tokenProduced = false; + std::string platformAttesterStatus; // Ready / SoftwareMock / Unavailable + std::string attesterErrorMsg; // empty if attestation succeeded + std::vector deviceFailures; + + std::string toStatusString() const; +}; + +class CompositeOrchestrator +{ + public: + struct Result + { + std::vector bundle; // tag-602 Detached EAT Bundle + CompositeStatus status; + bool success = false; + std::string errorMsg; + }; + + /// @param attester Lead Attester to delegate signing to. + /// @param typedClaimsSets Use CMW-style typed values in SPDM + /// Claims-Sets (default off). + explicit CompositeOrchestrator(PlatformAttester& attester, + bool typedClaimsSets = false); + + /// Produce the composite bundle from collected evidence. + /// + /// @param nonce Verifier nonce (32 bytes). + /// @param evidences Per-device evidence, including failures. + /// @param corimLoc Optional Platform CoRIM locator hint. + Result produce(std::span nonce, + std::span evidences, + std::optional corimLoc = std::nullopt); + + private: + PlatformAttester& attester; + bool typedClaimsSets; +}; + +} // namespace spdmd diff --git a/spdmd/composite/evidence_builder.cpp b/spdmd/composite/evidence_builder.cpp new file mode 100644 index 0000000..48d616e --- /dev/null +++ b/spdmd/composite/evidence_builder.cpp @@ -0,0 +1,104 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2024 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include "evidence_builder.hpp" + +#include + +namespace spdmd::composite +{ +namespace +{ + +constexpr std::string_view unknownEnvPrefix = "env.unknown."; + +bool isV10or11(std::uint8_t v) +{ + return v == 0x10 || v == 0x11; +} + +} // namespace + +bool isUnknownEnvironmentId(std::string_view env) +{ + return env.rfind(unknownEnvPrefix, 0) == 0; +} + +CollectedEvidence makeFailedEvidence(std::uint8_t eid, + std::string environmentId, + std::string errorMsg) +{ + CollectedEvidence ev; + ev.eid = eid; + ev.environmentId = std::move(environmentId); + ev.success = false; + ev.errorMsg = std::move(errorMsg); + return ev; +} + +CollectedEvidence buildCollectedEvidence(const EvidenceBuilderInput& input) +{ + if (!input.success) + { + return makeFailedEvidence(input.eid, input.environmentId, + input.errorMsg.empty() ? "Refresh failed" + : input.errorMsg); + } + + CollectedEvidence ev; + ev.eid = input.eid; + ev.environmentId = input.environmentId; + ev.success = true; + + ev.pattern = selectEvidencePattern(input.measurementSpecification, + !input.deviceEatToken.empty()); + if (ev.pattern == EvidencePattern::DeviceEat) + { + ev.deviceTokenFormat = "application/eat+cwt"; + ev.deviceToken = input.deviceEatToken; + return ev; + } + + ev.signedMeasurements = input.signedMeasurements; + if (ev.signedMeasurements.empty()) + { + return makeFailedEvidence(input.eid, input.environmentId, + "missing SPDM signed measurements"); + } + + ev.certificateChainDer = input.certificateChainDer; + if (ev.certificateChainDer.empty()) + { + return makeFailedEvidence(input.eid, input.environmentId, + "missing DER certificate chain"); + } + + if (isV10or11(input.spdmVersion)) + { + ev.vca = input.vcaTranscript; + if (ev.vca.empty()) + { + return makeFailedEvidence(input.eid, input.environmentId, + "missing SPDM VCA transcript"); + } + ev.includeVca = true; + } + + return ev; +} + +} // namespace spdmd::composite diff --git a/spdmd/composite/evidence_builder.hpp b/spdmd/composite/evidence_builder.hpp new file mode 100644 index 0000000..55735ac --- /dev/null +++ b/spdmd/composite/evidence_builder.hpp @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#pragma once + +#include "types.hpp" + +#include +#include +#include +#include + +namespace spdmd::composite +{ + +struct EvidenceBuilderInput +{ + std::string environmentId; + std::uint8_t eid = 0; + bool success = false; + std::string errorMsg; + + std::uint8_t spdmVersion = 0; + std::uint8_t measurementSpecification = kSpdmMeasurementSpecDmtf; + + std::vector signedMeasurements; + std::vector certificateChainDer; + std::vector vcaTranscript; + std::vector deviceEatToken; +}; + +CollectedEvidence makeFailedEvidence(std::uint8_t eid, + std::string environmentId, + std::string errorMsg); + +CollectedEvidence buildCollectedEvidence(const EvidenceBuilderInput& input); + +bool isUnknownEnvironmentId(std::string_view env); + +} // namespace spdmd::composite diff --git a/spdmd/composite/meson.build b/spdmd/composite/meson.build new file mode 100644 index 0000000..e89acfe --- /dev/null +++ b/spdmd/composite/meson.build @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Vendor-neutral composite attestation core. +# +# Builds the producer-side modules shared by the daemon and the mock +# attester: the deterministic CBOR writer (tinycbor + key ordering), +# detached Claims-Set builder, submodule digest, env.* collection plan, +# tag-602 bundle assembler, and the orchestrator. + +composite_sources = files( + 'cbor_det.cpp', + 'claims_set_builder.cpp', + 'submodule_digest.cpp', + 'collection_plan.cpp', + 'evidence_builder.cpp', + 'bundle_assembler.cpp', + 'composite_orchestrator.cpp', +) + +# '.' resolves "cbor_det.hpp"; '..' resolves "composite/..." and +# "platform_attester.hpp". +composite_inc = include_directories('.', '..') + +composite_lib = static_library( + 'composite', + composite_sources, + implicit_include_directories: false, + include_directories: composite_inc, + dependencies: [ + crypto_deps, # mbedcrypto (SHA-384) + nlohmann_json, # collection_plan config parsing + tinycbor_dep, # deterministic CBOR + ], +) + +composite_dep = declare_dependency( + include_directories: composite_inc, + link_with: composite_lib, + dependencies: [crypto_deps, nlohmann_json, tinycbor_dep], +) diff --git a/spdmd/composite/submodule_digest.cpp b/spdmd/composite/submodule_digest.cpp new file mode 100644 index 0000000..6a6bfcd --- /dev/null +++ b/spdmd/composite/submodule_digest.cpp @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2024 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include "submodule_digest.hpp" + +#include + +#include +#include +#include + +namespace spdmd::composite +{ + +std::array sha384(std::span b) +{ + std::array out{}; + const mbedtls_md_info_t* info = + mbedtls_md_info_from_type(MBEDTLS_MD_SHA384); + if (info == nullptr) + { + throw std::runtime_error("SubmoduleDigest: SHA-384 is unavailable"); + } + const int rc = mbedtls_md(info, b.data(), b.size(), out.data()); + if (rc != 0) + { + throw std::runtime_error("SubmoduleDigest: SHA-384 failed: " + + std::to_string(rc)); + } + return out; +} + +SubmoduleRecord makeSubmoduleRecord(std::string environmentId, + std::span claimsSet) +{ + SubmoduleRecord r; + r.environmentId = std::move(environmentId); + r.hashAlgId = kCoseAlgSha384; + r.digest = sha384(claimsSet); + return r; +} + +} // namespace spdmd::composite diff --git a/spdmd/composite/submodule_digest.hpp b/spdmd/composite/submodule_digest.hpp new file mode 100644 index 0000000..a5bef2a --- /dev/null +++ b/spdmd/composite/submodule_digest.hpp @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// SubmoduleDigest — SHA-384 over an encoded detached Claims-Set. +// +// Per the composite attestation profile, the detached submodule digest is +// computed over the encoded Claims-Set bytes directly, before the bstr wrapping +// used in the tag-602 bundle and never over a base64 form. This is the single +// value the BMC relays to the Lead Attester per device, and the exact bytes the +// verifier recomputes . + +#pragma once + +#include "types.hpp" + +#include +#include +#include +#include + +namespace spdmd::composite +{ + +/// SHA-384 of arbitrary bytes. Throws std::runtime_error on failure. +std::array sha384(std::span b); + +/// Build a SubmoduleRecord for @p environmentId from the encoded +/// Claims-Set bytes. hashAlgId is COSE SHA-384 (-43). +SubmoduleRecord makeSubmoduleRecord(std::string environmentId, + std::span claimsSet); + +} // namespace spdmd::composite diff --git a/spdmd/composite/types.hpp b/spdmd/composite/types.hpp new file mode 100644 index 0000000..94ba14b --- /dev/null +++ b/spdmd/composite/types.hpp @@ -0,0 +1,156 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// Shared types for BMC-side composite attestation. +// +// Per the BMC-mediated platform composite attestation design: the BMC's SPDM +// daemon collects nonce-bound per-device evidence, packages each device's +// evidence as a deterministic CBOR detached Claims-Set, and relays a +// single digest per device to the Lead Attester (BMC RoT). The Lead +// Attester builds and signs the composite EAT (inserting eat_profile, +// ueid, and measurements itself) and returns it; the BMC then assembles +// the tag-602 Detached EAT Bundle. +// +// These structs define the boundary objects exchanged between the +// collector (orchestrator), the Lead Attester (PlatformAttester), and the +// bundle assembler. The BMC never authors EAT claims, never holds the +// signing key, and never appraises evidence. + +#pragma once + +#include +#include +#include +#include +#include + +namespace spdmd::composite +{ + +/// COSE Algorithms registry id for SHA-384 used as the detached +/// Claims-Set digest algorithm. Independent of the SPDM-negotiated hash. +inline constexpr int kCoseAlgSha384 = -43; + +/// Size of a SHA-384 digest in bytes. +inline constexpr std::size_t kSha384Len = 48; + +/// Size of the verifier nonce in bytes. +inline constexpr std::size_t kNonceLen = 32; + +/// SPDM MeasurementSpecification values used by ALGORITHMS and +/// measurement blocks. DMTF measurements are bit 0 in published SPDM; +/// the EAT value is the proposed bit used by the SPDM EAT work item. +inline constexpr std::uint8_t kSpdmMeasurementSpecDmtf = 1U << 0U; +inline constexpr std::uint8_t kSpdmMeasurementSpecEat = 1U << 1U; + +/// The form of evidence a device produced. The collector picks the +/// retrieval method; the resulting Claims-Set schema follows from it. +enum class EvidencePattern +{ + /// Pattern A / C — SPDM signed measurements (+ optional Concise + /// Evidence carried in the measurement blocks; not parsed here). + SpdmMeasurements, + /// Pattern B — a nonce-bound device EAT. + DeviceEat, +}; + +inline bool hasEatMeasurementSpecification(std::uint8_t spec) +{ + return (spec & kSpdmMeasurementSpecEat) != 0; +} + +inline EvidencePattern selectEvidencePattern(std::uint8_t spec, + bool hasDeviceEatToken) +{ + if (hasEatMeasurementSpecification(spec) && hasDeviceEatToken) + { + return EvidencePattern::DeviceEat; + } + return EvidencePattern::SpdmMeasurements; +} + +/// Per-device evidence collected by the BMC, stored verbatim. The +/// collector never parses measurement-block content. +struct CollectedEvidence +{ + /// env.* target environment identifier (becomes the submod key). + std::string environmentId; + + /// MCTP EID — diagnostic only, never a submod key. + std::uint8_t eid = 0; + + /// Whether per-device SPDM collection succeeded. Failed devices + /// contribute no Claims-Set and no submod. + bool success = false; + std::string errorMsg; + + EvidencePattern pattern = EvidencePattern::SpdmMeasurements; + + // --- Pattern A / C inputs --- + /// Redfish/SPDM SignedMeasurements value, verbatim. + std::vector signedMeasurements; + /// Concatenated DER certificates, excluding the SPDM header and RootHash. + std::vector certificateChainDer; + /// Observed VCA transcript; required for SPDM 1.0/1.1, omitted for + /// 1.2+. Included only when @ref includeVca is true. + std::vector vca; + bool includeVca = false; + + // --- Pattern B inputs --- + /// Token format string, e.g. "application/eat+cwt". + std::string deviceTokenFormat; + /// Device EAT token bytes, verbatim. + std::vector deviceToken; +}; + +/// One detached-submodule-digest record: the only per-device value the +/// BMC sends to the Lead Attester. SHA-384 over the encoded Claims-Set. +struct SubmoduleRecord +{ + std::string environmentId; // env.* submod key + int hashAlgId = kCoseAlgSha384; // COSE alg id + std::array digest{}; +}; + +/// A Lead-Attester (RoT/BMC) measurement entry carried in EAT claim 273. +/// Authored entirely by the Lead Attester; the mock backend fills it. +struct LeadAttesterMeasurement +{ + std::optional contentFormat; // CoAP Content-Format + std::vector value; // opaque payload bytes +}; + +/// Request handed to the Lead Attester. Note: no eat_profile — the RoT +/// owns and inserts the composite EAT profile, ueid, and measurements. +struct CompositeEatRequest +{ + std::array nonce{}; + std::vector deviceRecords; + /// Optional Platform CoRIM locator hint (signed as metadata only). + std::optional platformCorimLocator; +}; + +/// Response returned by the Lead Attester: the signed composite EAT only +/// (NOT the tag-602 bundle, which the BMC assembles). +struct CompositeEatResponse +{ + std::vector compositeEat; // CWT(COSE_Sign1) bytes + bool success = false; + std::string errorMsg; +}; + +} // namespace spdmd::composite diff --git a/spdmd/dbus_impl_composite.cpp b/spdmd/dbus_impl_composite.cpp new file mode 100644 index 0000000..0b5b31d --- /dev/null +++ b/spdmd/dbus_impl_composite.cpp @@ -0,0 +1,318 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2024 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include "dbus_impl_composite.hpp" + +#include "composite/evidence_builder.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace spdmd +{ + +CompositeConfig loadCompositeConfig(const std::string& path) +{ + CompositeConfig cfg; + std::ifstream f(path); + if (!f) + { + return cfg; // defaults: empty plan, default timeouts + } + std::stringstream ss; + ss << f.rdbuf(); + std::string err; + if (auto parsed = composite::parseCompositeConfig(ss.str(), err)) + { + cfg.plan = std::move(parsed->plan); + cfg.skipDevices = std::move(parsed->skipDevices); + cfg.allowUnknownEnvironments = parsed->allowUnknownEnvironments; + } + else + { + std::cerr << err << " - using defaults\n"; + } + return cfg; +} + +namespace +{} // namespace + +DbusImplComposite::DbusImplComposite( + sdbusplus::asio::object_server& objServer_, + CompositeOrchestrator& orchestrator_, + std::vector>& responders_, + boost::asio::io_context& ioCtx_, CompositeConfig config_) : + objServer(objServer_), + orchestrator(orchestrator_), responders(responders_), ioCtx(ioCtx_), + config(std::move(config_)), overallTimer(ioCtx_) +{ + iface = objServer.add_interface(objectPath, interfaceName); + iface->register_property("Status", statusStr); + iface->register_property("Bundle", bundle); + + iface->register_method( + "Generate", [this](const std::vector& n) { + if (n.size() != 32) + { + throw sdbusplus::exception::SdBusError( + -EINVAL, "Nonce must be exactly 32 bytes"); + } + if (inProgress) + { + throw sdbusplus::exception::SdBusError( + -EBUSY, "Composite generation already in progress"); + } + startCollection(n); + }); + + iface->initialize(); +} + +DbusImplComposite::~DbusImplComposite() +{ + if (iface) + { + objServer.remove_interface(iface); + } +} + +void DbusImplComposite::publishStatus(const std::string& s) +{ + statusStr = s; + if (iface) + { + iface->set_property("Status", statusStr); + } +} + +bool DbusImplComposite::isSkipped(std::uint8_t eid) const +{ + return std::find(config.skipDevices.begin(), config.skipDevices.end(), + eid) != config.skipDevices.end(); +} + +void DbusImplComposite::startCollection(const std::vector& n) +{ + std::copy(n.begin(), n.end(), nonce.begin()); + evidences.clear(); + active.clear(); + done.fill(false); + bundle.clear(); + if (iface) + { + iface->set_property("Bundle", bundle); + } + + for (auto& slot : responders) + { + if (slot && !isSkipped(slot->getEid())) + { + active.push_back(slot); + } + } + if (active.empty()) + { + publishStatus("Error"); + return; + } + + pending = active.size(); + inProgress = true; + publishStatus("InProgress"); + + if (config.overallTimeoutMs > 0) + { + overallTimer.expires_after( + std::chrono::milliseconds(config.overallTimeoutMs)); + overallTimer.async_wait([this](const boost::system::error_code& ec) { + if (ec == boost::asio::error::operation_aborted || !inProgress) + { + return; + } + for (auto& r : active) + { + if (r && !done[r->getEid()]) + { + done[r->getEid()] = true; + evidences.push_back(makeEvidence(*r, false)); + } + } + pending = 0; + finalize(); + }); + } + + // Collection fans out to all active responders concurrently: every + // responder is refreshed in parallel and the collection joins when each + // reports completion (or the overall timer fires). This relies on the + // transport supporting multiple in-flight SPDM exchanges. For in-kernel + // AF_MCTP, requests are tagged per peer EID and responses are routed by + // source EID, without a daemon-side request/response buffer. A transport + // that couples responses through one shared buffer needs equivalent + // per-responder isolation before enabling this fan-out. + for (auto& resp : active) + { + const std::uint8_t eid = resp->getEid(); + auto& timer = perDevTimers[eid]; + timer = std::make_unique(ioCtx); + timer->expires_after( + std::chrono::milliseconds(config.perDeviceTimeoutMs)); + timer->async_wait([this, eid](const boost::system::error_code& ec) { + if (ec != boost::asio::error::operation_aborted && !done[eid]) + { + onDeviceComplete(eid, false); + } + }); + resp->setRefreshCompleteCallback( + [this](std::uint8_t e, bool ok) { onDeviceComplete(e, ok); }); + std::vector measurementNonce(nonce.begin(), nonce.end()); + resp->refresh(0u, std::move(measurementNonce), {255}, 0u); + } +} + +composite::CollectedEvidence + DbusImplComposite::makeEvidence(dbus_api::Responder& resp, + bool success) const +{ + const std::uint8_t eid = resp.getEid(); + const std::string environmentId = config.plan.resolveByEid(eid); + if (!config.allowUnknownEnvironments && + composite::isUnknownEnvironmentId(environmentId)) + { + return composite::makeFailedEvidence(eid, environmentId, + "missing environment mapping"); + } + + composite::EvidenceBuilderInput input; + input.eid = eid; + input.environmentId = environmentId; + input.success = success; + input.errorMsg = success ? "" : "Refresh failed"; + + if (!success) + { + // Do not query negotiated SPDM metadata for a responder that failed + // or timed out: on a refresh that never reached ALGORITHMS the + // accessors assert. Failed evidence needs none of it. + return composite::buildCollectedEvidence(input); + } + + input.spdmVersion = resp.version(); + input.measurementSpecification = resp.measurementSpecification(); + + auto sm = resp.signedMeasurements(); + input.signedMeasurements.assign(sm.begin(), sm.end()); + (void)resp.certificateChainDer(input.certificateChainDer, resp.slot()); + + const auto& vca = resp.vcaTranscript(); + input.vcaTranscript.assign(vca.begin(), vca.end()); + + const auto& deviceEatToken = resp.deviceEatToken(); + input.deviceEatToken.assign(deviceEatToken.begin(), deviceEatToken.end()); + + return composite::buildCollectedEvidence(input); +} + +void DbusImplComposite::onDeviceComplete(std::uint8_t eid, bool success) +{ + if (!inProgress) + { + return; + } + if (done[eid]) + { + return; + } + done[eid] = true; + if (auto& t = perDevTimers[eid]) + { + boost::system::error_code ec; + t->cancel(ec); + } + for (auto& r : active) + { + if (r && r->getEid() == eid) + { + evidences.push_back(makeEvidence(*r, success)); + break; + } + } + if (pending > 0) + { + --pending; + } + if (pending == 0) + { + boost::system::error_code ec; + overallTimer.cancel(ec); + finalize(); + } +} + +void DbusImplComposite::finalize() +{ + auto res = orchestrator.produce(std::span{nonce}, + std::span{evidences}, + config.plan.platformCorimLocator()); + for (const auto& failure : res.status.deviceFailures) + { + std::cerr << "composite collection failed for " + << failure.environmentId << " (EID " + << static_cast(failure.eid) + << "): " << failure.errorMsg << '\n'; + } + if (res.success) + { + bundle = std::move(res.bundle); + if (iface) + { + iface->set_property("Bundle", bundle); + } + publishStatus("Ready"); + } + else + { + bundle.clear(); + if (iface) + { + iface->set_property("Bundle", bundle); + } + publishStatus("Error"); + } + inProgress = false; + for (auto& r : active) + { + if (r) + { + r->setRefreshCompleteCallback({}); + } + } + active.clear(); + evidences.clear(); + for (auto& t : perDevTimers) + { + t.reset(); + } +} + +} // namespace spdmd diff --git a/spdmd/dbus_impl_composite.hpp b/spdmd/dbus_impl_composite.hpp new file mode 100644 index 0000000..3fc3b31 --- /dev/null +++ b/spdmd/dbus_impl_composite.hpp @@ -0,0 +1,124 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// DbusImplComposite — D-Bus producer for the composite EAT bundle. +// +// Exposes xyz.openbmc_project.SPDM.CompositeEATBundle on +// /xyz/openbmc_project/SPDM/CompositeEATBundle : +// +// Generate(nonce: ay) start collection (single-flight; EBUSY if busy) +// Status: s Idle / InProgress / Ready / Error +// Bundle: ay raw tag-602 Detached EAT Bundle (valid when Ready) +// +// Collection is fanned out across active per-device Responders; each +// registers a completion callback that decrements a pending counter. +// When all report (or the overall timer fires) evidence is snapshotted, +// mapped to env.* via the CollectionPlan, packaged as detached +// Claims-Sets, and handed to CompositeOrchestrator. The single nonce +// binds the whole collection. bmcweb only triggers and reads bytes. +// +// Depends on the concrete dbus_api::Responder from the per-device SPDM +// porting work, so it sits with the per-device integration layer. The +// vendor- +// neutral core (CompositeOrchestrator + composite/*) is upstream. + +#pragma once + +#include "composite/collection_plan.hpp" +#include "composite/composite_orchestrator.hpp" +#include "dbus_impl_responder.hpp" +#include "platform_attester.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace spdmd +{ + +struct CompositeConfig +{ + std::uint32_t perDeviceTimeoutMs = 30000; + std::uint32_t overallTimeoutMs = 120000; + std::vector skipDevices; + bool allowUnknownEnvironments = false; + composite::CollectionPlan plan; // env.* mapping + CoRIM locator +}; + +/// Load CompositeConfig from a composite.json path. Missing/invalid file +/// yields defaults. Unknown env.* mappings are rejected unless explicitly +/// allowed by config. +CompositeConfig loadCompositeConfig(const std::string& path); + +class DbusImplComposite +{ + public: + static constexpr const char* objectPath = + "/xyz/openbmc_project/SPDM/CompositeEATBundle"; + static constexpr const char* interfaceName = + "xyz.openbmc_project.SPDM.CompositeEATBundle"; + + DbusImplComposite( + sdbusplus::asio::object_server& objServer, + CompositeOrchestrator& orchestrator, + std::vector>& responders, + boost::asio::io_context& ioCtx, CompositeConfig config); + + ~DbusImplComposite(); + + DbusImplComposite(const DbusImplComposite&) = delete; + DbusImplComposite& operator=(const DbusImplComposite&) = delete; + DbusImplComposite(DbusImplComposite&&) = delete; + DbusImplComposite& operator=(DbusImplComposite&&) = delete; + + private: + void startCollection(const std::vector& nonce); + void onDeviceComplete(std::uint8_t eid, bool success); + composite::CollectedEvidence makeEvidence(dbus_api::Responder& resp, + bool success) const; + void finalize(); + bool isSkipped(std::uint8_t eid) const; + void publishStatus(const std::string& s); + + sdbusplus::asio::object_server& objServer; + std::shared_ptr iface; + CompositeOrchestrator& orchestrator; + std::vector>& responders; + boost::asio::io_context& ioCtx; + CompositeConfig config; + + std::string statusStr = "Idle"; + std::vector bundle; + + bool inProgress = false; + std::size_t pending = 0; + std::array nonce{}; + std::array done{}; + std::vector> active; + std::vector evidences; + std::array, 256> perDevTimers; + boost::asio::steady_timer overallTimer; +}; + +} // namespace spdmd diff --git a/spdmd/dbus_impl_responder.cpp b/spdmd/dbus_impl_responder.cpp index 832dfb3..949e9f3 100644 --- a/spdmd/dbus_impl_responder.cpp +++ b/spdmd/dbus_impl_responder.cpp @@ -30,6 +30,20 @@ namespace spdmd namespace dbus_api { +namespace +{ + +#ifdef ENABLE_COMPOSITE_ATTESTATION +constexpr std::uint8_t requesterMeasurementSpecifications = + spdmcpp::ConnectionClass::measurementSpecificationDmtf | + spdmcpp::ConnectionClass::measurementSpecificationEat; +#else +constexpr std::uint8_t requesterMeasurementSpecifications = + spdmcpp::ConnectionClass::measurementSpecificationDmtf; +#endif + +} // namespace + /** * @brief Convert SPDM version to string * @@ -59,7 +73,8 @@ Responder::Responder(SpdmdAppContext& appCtx, const std::string& path, std::string socketPath) : ResponderIntf(appCtx.getConn(), path.c_str(), action::defer_emit), appContext(appCtx), log(appCtx.getLog()), - connection(appCtx.context, log, eid, std::move(socketPath)), + connection(appCtx.context, log, eid, std::move(socketPath), + requesterMeasurementSpecifications), transport(eid, *this, std::move(transportMedium), log), inventoryPath(invPath), bindingType(bindingType), eid(eid) { @@ -326,6 +341,11 @@ void Responder::handleError(spdmcpp::RetStat rs) appContext.reportError(std::string("SPDM other error: ") + get_cstr(rs) + " on " + dbgIdName); } + + if (refreshCompleteCb) + { + refreshCompleteCb(eid, false); + } } spdmcpp::RetStat Responder::handleEventForRefresh(spdmcpp::EventClass& ev) @@ -356,6 +376,10 @@ spdmcpp::RetStat Responder::handleEventForRefresh(spdmcpp::EventClass& ev) syncSlotsInfo(); updateLastUpdateTime(); status(SPDMStatus::Success); + if (refreshCompleteCb) + { + refreshCompleteCb(eid, true); + } } else if (connection.slotHasInfo(slotidx, SlotInfoEnum::CERTIFICATES)) { diff --git a/spdmd/dbus_impl_responder.hpp b/spdmd/dbus_impl_responder.hpp index ef2aad5..969602c 100644 --- a/spdmd/dbus_impl_responder.hpp +++ b/spdmd/dbus_impl_responder.hpp @@ -36,6 +36,7 @@ #include #include +#include #include #include @@ -71,8 +72,8 @@ class MctpTransportClass : public spdmcpp::MctpTransportClass public: MctpTransportClass(uint8_t eid, Responder& resp, std::string medium, spdmcpp::LogClass& logIn) : - spdmcpp::MctpTransportClass(eid), transportMedium(std::move(medium)), - responder(resp), log(logIn) + spdmcpp::MctpTransportClass(eid), + transportMedium(std::move(medium)), responder(resp), log(logIn) {} ~MctpTransportClass() override = default; @@ -159,6 +160,33 @@ class Responder : return eid; } + void setRefreshCompleteCallback( + std::function cb) + { + refreshCompleteCb = std::move(cb); + } + + uint8_t measurementSpecification() const + { + return connection.getMeasurementSpecification(); + } + + const std::vector& deviceEatToken() const + { + return connection.getDeviceEatToken(); + } + + const std::vector& vcaTranscript() const + { + return connection.getVcaTranscript(); + } + + bool certificateChainDer(std::vector& certificateChainDer, + uint8_t slotIdx) const + { + return connection.getCertificatesDER(certificateChainDer, slotIdx); + } + /** @brief Event callback for receiving events * @param[inout] bus - Buffer containing the data, note that after the call * the contents of buf will be effectively clobbered @@ -209,6 +237,8 @@ class Responder : spdmcpp::RetStat handleEventForSerialNumber(spdmcpp::EventClass& event); #endif + std::function refreshCompleteCb; + private: /** @brief Update serial number in the inventory * @param[in] serialData Serial number data diff --git a/spdmd/meson.build b/spdmd/meson.build index 5329375..6b6c3fb 100644 --- a/spdmd/meson.build +++ b/spdmd/meson.build @@ -1,5 +1,28 @@ spdmd_headers = ['.', '..', '../libspdmcpp/headers_public', '../common_headers'] +# Composite attestation: vendor-neutral composite core + optional backend +# libraries. +composite_attestation = get_option('composite-attestation') +attester_backend = get_option('attester-backend') + +attester_extra_link = [] +attester_extra_deps = [] +attester_cpp_args = [] + +if composite_attestation.enabled() + subdir('composite') + attester_extra_link += [composite_lib] + attester_extra_deps += [composite_dep] + attester_cpp_args += ['-DENABLE_COMPOSITE_ATTESTATION'] + + if attester_backend == 'mock' + subdir('mock_attester') + attester_extra_link += [mock_attester_lib] + attester_extra_deps += [mock_attester_dep] + attester_cpp_args += ['-DATTESTER_BACKEND_MOCK'] + endif +endif + sources = [ 'spdmd.cpp', 'dbus_impl_responder.cpp', @@ -7,12 +30,18 @@ sources = [ 'spdmd_app_context.cpp', ] +if composite_attestation.enabled() + sources += [ + 'dbus_impl_composite.cpp', + ] +endif + executable( 'spdmd', sources, implicit_include_directories: false, include_directories: include_directories(spdmd_headers), - link_with: libspdmcpp_requester, + link_with: [libspdmcpp_requester] + attester_extra_link, dependencies: [ conf_h_dep, CLI11_dep, @@ -21,7 +50,8 @@ executable( crypto_deps, nlohmann_json, libspdmcpp_requester_dep, - ], + ] + attester_extra_deps, + cpp_args: attester_cpp_args, install: true, ) diff --git a/spdmd/mock_attester/eat_builder.cpp b/spdmd/mock_attester/eat_builder.cpp new file mode 100644 index 0000000..4888d88 --- /dev/null +++ b/spdmd/mock_attester/eat_builder.cpp @@ -0,0 +1,250 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2024 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include "eat_builder.hpp" + +#include "composite/cbor_det.hpp" + +#include + +#include +#include + +namespace spdmd::mock_attester::eat +{ + +namespace cbor = ::spdmd::composite::cbor; + +namespace +{ + +// COSE header labels (RFC 9052 §3). +constexpr std::int64_t kCoseHeaderAlg = 1; +constexpr std::int64_t kCoseHeaderContentType = 3; +constexpr std::int64_t kCoseHeaderX5chain = 33; + +// EAT claim keys (RFC 9711 / RFC 8392). +constexpr std::int64_t kEatClaimNonce = 10; +constexpr std::int64_t kEatClaimUeid = 256; +constexpr std::int64_t kEatClaimProfile = 265; +constexpr std::int64_t kEatClaimSubmods = 266; +constexpr std::int64_t kEatClaimMeasurements = 273; + +// TBD Platform CoRIM locator claim. Profile-defined, private-use key +// pending a registered allocation. +constexpr std::int64_t kEatClaimPlatformCorimId = -75000; + +// CWT (RFC 8392) and COSE_Sign1 (RFC 9052) CBOR tags. +constexpr std::uint64_t kCborTagCwt = 61; +constexpr std::uint64_t kCborTagCoseSign1 = 18; + +// Measurement entry keys. +constexpr const char* kMeasContentFormat = "content-format"; +constexpr const char* kMeasValue = "value"; + +/// Encode one detached-submodule-digest: [hash-alg, digest]. +std::vector + encodeSubmodDigest(const composite::SubmoduleRecord& r) +{ + std::vector> elems; + elems.push_back(cbor::intVal(r.hashAlgId)); + elems.push_back(cbor::bytesVal(r.digest)); + return cbor::arrayVal(elems); +} + +/// Encode one Lead Attester measurement: {"content-format", "value"}. +std::vector + encodeMeasurement(const composite::LeadAttesterMeasurement& m) +{ + if (!m.contentFormat) + { + throw std::invalid_argument( + "Lead Attester measurement content-format is required"); + } + cbor::Map em; + em.addText(kMeasContentFormat, cbor::uintVal(*m.contentFormat)); + em.addText(kMeasValue, cbor::bytesVal(m.value)); + return em.encode(); +} + +} // namespace + +std::vector buildCompositeClaims( + std::span nonce, + std::span ueid, std::string_view profileUri, + std::span submods, + std::span measurements, + const std::optional& platformCorimLocator) +{ + cbor::Map claims; + + // eat_nonce (10) + claims.addInt(kEatClaimNonce, cbor::bytesVal(nonce)); + + // ueid (256) + claims.addInt(kEatClaimUeid, cbor::bytesVal(ueid)); + + // eat_profile (265) + claims.addInt(kEatClaimProfile, cbor::textVal(profileUri)); + + // submods (266): map { env.* => [hash-alg, digest] } + { + cbor::Map submodMap; + for (const auto& r : submods) + { + submodMap.addText(r.environmentId, encodeSubmodDigest(r)); + } + claims.addInt(kEatClaimSubmods, submodMap.encode()); + } + + // measurements (273): [ {content-format, value}, ... ] + { + std::vector> arr; + arr.reserve(measurements.size()); + for (const auto& m : measurements) + { + arr.push_back(encodeMeasurement(m)); + } + claims.addInt(kEatClaimMeasurements, cbor::arrayVal(arr)); + } + + // Optional Platform CoRIM locator hint. + if (platformCorimLocator) + { + claims.addInt(kEatClaimPlatformCorimId, + cbor::textVal(*platformCorimLocator)); + } + + return claims.encode(); +} + +std::vector buildProtectedHeader(int alg) +{ + cbor::Map hdr; + hdr.addInt(kCoseHeaderAlg, cbor::intVal(alg)); + hdr.addInt(kCoseHeaderContentType, cbor::textVal(kContentTypeEatCwt)); + return hdr.encode(); +} + +std::vector + buildSigStructure(std::span protectedHeader, + std::span payload) +{ + std::vector> elems; + elems.push_back(cbor::textVal("Signature1")); + elems.push_back(cbor::bytesVal(protectedHeader)); + elems.push_back(cbor::bytesVal(std::span{})); + elems.push_back(cbor::bytesVal(payload)); + return cbor::arrayVal(elems); +} + +std::vector + assembleCwtCoseSign1(std::span protectedHeader, + std::span> x5chainDer, + std::span payload, + std::span signature) +{ + std::vector out; + + // 61(18([ protected, unprotected, payload, signature ])) + cbor::putTag(out, kCborTagCwt); + cbor::putTag(out, kCborTagCoseSign1); + cbor::putArrayHeader(out, 4); + + // [0] body_protected: bstr + cbor::putBytes(out, protectedHeader); + + // [1] unprotected: map { 33: [cert_der, ...] } (empty map if no chain) + cbor::Map unprot; + if (!x5chainDer.empty()) + { + std::vector> chain; + chain.reserve(x5chainDer.size()); + for (const auto& der : x5chainDer) + { + chain.push_back(cbor::bytesVal(der)); + } + unprot.addInt(kCoseHeaderX5chain, cbor::arrayVal(chain)); + } + const std::vector unprotBytes = unprot.encode(); + out.insert(out.end(), unprotBytes.begin(), unprotBytes.end()); + + // [2] payload: bstr + cbor::putBytes(out, payload); + + // [3] signature: bstr + cbor::putBytes(out, signature); + + return out; +} + +std::vector> pemToDerChain(std::string_view pem) +{ + static constexpr std::string_view kBegin = "-----BEGIN CERTIFICATE-----"; + static constexpr std::string_view kEnd = "-----END CERTIFICATE-----"; + + std::vector> chain; + std::size_t pos = 0; + + while (pos < pem.size()) + { + std::size_t begin = pem.find(kBegin, pos); + if (begin == std::string_view::npos) + { + break; + } + std::size_t end = pem.find(kEnd, begin); + if (end == std::string_view::npos) + { + break; + } + + std::string b64; + b64.reserve(end - begin); + for (std::size_t i = begin + kBegin.size(); i < end; ++i) + { + char c = pem[i]; + if (c != '\n' && c != '\r' && c != ' ' && c != '\t') + { + b64.push_back(c); + } + } + + std::size_t derLen = 0; + mbedtls_base64_decode( + nullptr, 0, &derLen, + reinterpret_cast(b64.data()), b64.size()); + if (derLen > 0) + { + std::vector der(derLen); + int rc = mbedtls_base64_decode( + der.data(), der.size(), &derLen, + reinterpret_cast(b64.data()), b64.size()); + if (rc == 0) + { + der.resize(derLen); + chain.push_back(std::move(der)); + } + } + + pos = end + kEnd.size(); + } + + return chain; +} + +} // namespace spdmd::mock_attester::eat diff --git a/spdmd/mock_attester/eat_builder.hpp b/spdmd/mock_attester/eat_builder.hpp new file mode 100644 index 0000000..bcc490c --- /dev/null +++ b/spdmd/mock_attester/eat_builder.hpp @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// Composite EAT/COSE_Sign1 builder — internal to the mock attester. +// +// Production attester backends build EAT claims and the COSE_Sign1 +// envelope inside their Root-of-Trust. This header exists ONLY so +// MockAttester can produce verifier-compatible composite EAT bytes on a +// developer workstation; it is compiled only when attester-backend=mock. +// +// Format references: +// RFC 9711 — EAT claims (eat_nonce=10, eat_profile=265, submods=266, +// measurements=273) + ueid (256, RFC 8392). +// RFC 9052 — COSE_Sign1, Sig_Structure, ES384 (alg=-35). +// RFC 8392 — CWT wrapping with tag 61. +// +// All CBOR is produced through the deterministic composite::cbor writer. + +#pragma once + +#include "composite/types.hpp" + +#include +#include +#include +#include +#include +#include + +namespace spdmd::mock_attester::eat +{ + +/// COSE algorithm identifier for ES384 (IANA COSE Algorithms). +constexpr int kAlgEs384 = -35; + +/// Content type declared in the COSE protected header. +constexpr const char* kContentTypeEatCwt = "application/eat+cwt"; + +/// Build the deterministic CBOR composite-eat-claims map . +/// +/// @param nonce 32-byte verifier nonce -> eat_nonce (10). +/// @param ueid Lead Attester identifier -> ueid (256). +/// @param profileUri Composite EAT profile -> eat_profile (265). +/// @param submods Per-device detached digests -> submods (266), +/// keyed by env.* with value [hash-alg, digest]. +/// @param measurements Lead Attester measurements -> measurements (273). +/// @param platformCorimLocator If present -> Platform CoRIM locator hint. +std::vector buildCompositeClaims( + std::span nonce, + std::span ueid, std::string_view profileUri, + std::span submods, + std::span measurements, + const std::optional& platformCorimLocator); + +/// Build CBOR-encoded COSE protected header: {1: alg, 3: content-type}. +std::vector buildProtectedHeader(int alg = kAlgEs384); + +/// Build COSE Sig_Structure per RFC 9052 §4.4 — the bytes the signer +/// signs: ["Signature1", body_protected, h'', payload]. +std::vector + buildSigStructure(std::span protectedHeader, + std::span payload); + +/// Assemble a CWT(COSE_Sign1) token: 61(18([4-tuple])). The unprotected +/// header carries x5chain (label 33) with the DER cert chain. +std::vector + assembleCwtCoseSign1(std::span protectedHeader, + std::span> x5chainDer, + std::span payload, + std::span signature); + +/// Split a PEM cert chain into a vector of DER blobs (leaf-first order +/// preserved from the PEM). +std::vector> pemToDerChain(std::string_view pem); + +} // namespace spdmd::mock_attester::eat diff --git a/spdmd/mock_attester/meson.build b/spdmd/mock_attester/meson.build new file mode 100644 index 0000000..48d390e --- /dev/null +++ b/spdmd/mock_attester/meson.build @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# MockAttester subproject — software-only PlatformAttester backend. +# +# Compiled only when meson option attester-backend=mock is selected. +# Produces a static library with the composite EAT/COSE_Sign1 builder and +# the MockAttester implementation. Depends on the vendor-neutral composite +# core for the deterministic CBOR writer and shared types. + +mock_attester_sources = files( + 'eat_builder.cpp', + 'mock_attester.cpp', +) + +mock_attester_inc = include_directories('.', '..') + +mock_attester_lib = static_library( + 'mock_attester', + mock_attester_sources, + implicit_include_directories: false, + include_directories: mock_attester_inc, + dependencies: [ + crypto_deps, # mbedcrypto + mbedx509 + composite_dep, # cbor_det + composite types + ], + cpp_args: ['-DATTESTER_BACKEND_MOCK'], +) + +mock_attester_dep = declare_dependency( + include_directories: mock_attester_inc, + link_with: mock_attester_lib, + dependencies: [crypto_deps, composite_dep], + compile_args: ['-DATTESTER_BACKEND_MOCK'], +) diff --git a/spdmd/mock_attester/mock_attester.cpp b/spdmd/mock_attester/mock_attester.cpp new file mode 100644 index 0000000..36d34ef --- /dev/null +++ b/spdmd/mock_attester/mock_attester.cpp @@ -0,0 +1,403 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2024 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include "mock_attester.hpp" + +#include "eat_builder.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace spdmd::mock_attester +{ + +namespace +{ + +/// Convert a DER-encoded ECDSA signature into 96-byte fixed-width P1363 +/// form (r||s, each 48 bytes, big-endian, zero-padded). +bool derEcdsaToP1363(const std::uint8_t* der, std::size_t derLen, + std::array& out) +{ + out.fill(0); + if (derLen < 8 || der[0] != 0x30) + { + return false; + } + + std::size_t pos = 2; + if ((der[1] & 0x80U) != 0U) + { + pos = 2 + (der[1] & 0x7FU); + } + + if (pos >= derLen || der[pos] != 0x02) + { + return false; + } + ++pos; + std::size_t rLen = der[pos++]; + if (pos + rLen > derLen) + { + return false; + } + const std::uint8_t* rData = der + pos; + pos += rLen; + + if (pos >= derLen || der[pos] != 0x02) + { + return false; + } + ++pos; + std::size_t sLen = der[pos++]; + if (pos + sLen > derLen) + { + return false; + } + const std::uint8_t* sData = der + pos; + + while (rLen > 48 && rData[0] == 0x00) + { + ++rData; + --rLen; + } + while (sLen > 48 && sData[0] == 0x00) + { + ++sData; + --sLen; + } + if (rLen > 48 || sLen > 48) + { + return false; + } + + std::memcpy(out.data() + (48 - rLen), rData, rLen); + std::memcpy(out.data() + 48 + (48 - sLen), sData, sLen); + return true; +} + +std::string yyyymmddhhmmssUtc(std::time_t t) +{ + std::tm tmv{}; + gmtime_r(&t, &tmv); + char buf[32]; + std::snprintf(buf, sizeof(buf), "%04d%02d%02d%02d%02d%02d", + tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday, tmv.tm_hour, + tmv.tm_min, tmv.tm_sec); + return std::string{buf}; +} + +} // namespace + +struct MockAttester::Impl +{ + MockAttesterConfig cfg; + bool ready = false; + std::string certChainPem; + + // Mock Lead Attester identity material. + std::array ueid{}; + // Mock RoT/BMC measurement value (a fixed digest standing in for + // platform firmware measurements). + std::array bmcMeasurement{}; + + mbedtls_entropy_context entropy{}; + mbedtls_ctr_drbg_context ctrDrbg{}; + mbedtls_pk_context pk{}; + + explicit Impl(MockAttesterConfig c) : cfg(std::move(c)) + { + mbedtls_entropy_init(&entropy); + mbedtls_ctr_drbg_init(&ctrDrbg); + mbedtls_pk_init(&pk); + } + + ~Impl() + { + mbedtls_pk_free(&pk); + mbedtls_ctr_drbg_free(&ctrDrbg); + mbedtls_entropy_free(&entropy); + } + + Impl(const Impl&) = delete; + Impl& operator=(const Impl&) = delete; + Impl(Impl&&) = delete; + Impl& operator=(Impl&&) = delete; + + bool init() + { + static constexpr const char* pers = "spdmd_mock_attester"; + int rc = mbedtls_ctr_drbg_seed( + &ctrDrbg, mbedtls_entropy_func, &entropy, + reinterpret_cast(pers), std::strlen(pers)); + if (rc != 0) + { + std::cerr << "MockAttester: ctr_drbg_seed failed: " << rc << '\n'; + return false; + } + + // Stable-per-instance mock identity material. + if (mbedtls_ctr_drbg_random(&ctrDrbg, ueid.data(), ueid.size()) != 0) + { + return false; + } + // Derive a deterministic mock BMC measurement from a fixed label. + static constexpr const char* label = "mock-bmc-fw-measurement"; + if (mbedtls_md(mbedtls_md_info_from_type(MBEDTLS_MD_SHA384), + reinterpret_cast(label), + std::strlen(label), bmcMeasurement.data()) != 0) + { + return false; + } + + rc = mbedtls_pk_setup(&pk, mbedtls_pk_info_from_type(MBEDTLS_PK_ECKEY)); + if (rc != 0) + { + std::cerr << "MockAttester: pk_setup failed: " << rc << '\n'; + return false; + } + + rc = mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP384R1, mbedtls_pk_ec(pk), + mbedtls_ctr_drbg_random, &ctrDrbg); + if (rc != 0) + { + std::cerr << "MockAttester: ecp_gen_key failed: " << rc << '\n'; + return false; + } + + if (!makeSelfSignedLeaf()) + { + return false; + } + + ready = true; + return true; + } + + bool makeSelfSignedLeaf() + { + mbedtls_x509write_cert crt; + mbedtls_x509write_crt_init(&crt); + + mbedtls_x509write_crt_set_version(&crt, MBEDTLS_X509_CRT_VERSION_3); + mbedtls_x509write_crt_set_md_alg(&crt, MBEDTLS_MD_SHA384); + mbedtls_x509write_crt_set_subject_key(&crt, &pk); + mbedtls_x509write_crt_set_issuer_key(&crt, &pk); + + std::string dn = "CN=" + cfg.leafCn + ",O=spdmd,OU=mock-attester"; + int rc = mbedtls_x509write_crt_set_subject_name(&crt, dn.c_str()); + if (rc != 0) + { + mbedtls_x509write_crt_free(&crt); + return false; + } + rc = mbedtls_x509write_crt_set_issuer_name(&crt, dn.c_str()); + if (rc != 0) + { + mbedtls_x509write_crt_free(&crt); + return false; + } + + const unsigned char serialBytes[] = {0x01}; + mbedtls_x509write_crt_set_serial_raw( + &crt, const_cast(serialBytes), sizeof(serialBytes)); + + std::time_t now = std::time(nullptr); + std::string notBefore = yyyymmddhhmmssUtc(now); + std::string notAfter = yyyymmddhhmmssUtc(now + 10 * 365 * 24 * 3600); + rc = mbedtls_x509write_crt_set_validity(&crt, notBefore.c_str(), + notAfter.c_str()); + if (rc != 0) + { + mbedtls_x509write_crt_free(&crt); + return false; + } + + rc = mbedtls_x509write_crt_set_basic_constraints(&crt, 0, -1); + if (rc != 0) + { + mbedtls_x509write_crt_free(&crt); + return false; + } + + std::array buf{}; + rc = mbedtls_x509write_crt_pem(&crt, buf.data(), buf.size(), + mbedtls_ctr_drbg_random, &ctrDrbg); + mbedtls_x509write_crt_free(&crt); + if (rc != 0) + { + std::cerr << "MockAttester: write_crt_pem failed: " << rc << '\n'; + return false; + } + + certChainPem = reinterpret_cast(buf.data()); + return true; + } + + composite::CompositeEatResponse + generateCompositeEat(const composite::CompositeEatRequest& req) + { + composite::CompositeEatResponse result; + if (!ready) + { + result.errorMsg = "MockAttester not initialized"; + return result; + } + + // RoT-authored measurements (claim 273). One mock entry standing + // in for platform RoT/BMC firmware measurements. + std::vector measurements; + { + composite::LeadAttesterMeasurement m; + m.contentFormat = 42; // application/octet-stream + m.value.assign(bmcMeasurement.begin(), bmcMeasurement.end()); + measurements.push_back(std::move(m)); + } + + // 1. Build composite EAT claims and the COSE protected header. + std::vector claims; + std::vector protectedHdr; + try + { + claims = eat::buildCompositeClaims( + std::span{req.nonce}, + ueid, cfg.profileUri, req.deviceRecords, measurements, + req.platformCorimLocator); + protectedHdr = eat::buildProtectedHeader(); + } + catch (const std::exception& ex) + { + result.errorMsg = std::string{"eat_builder failed: "} + ex.what(); + return result; + } + + // 2. Build COSE Sig_Structure and SHA-384 it for ECDSA. + std::vector sigStruct = + eat::buildSigStructure(protectedHdr, claims); + + std::array hash{}; + if (mbedtls_md(mbedtls_md_info_from_type(MBEDTLS_MD_SHA384), + sigStruct.data(), sigStruct.size(), hash.data()) != 0) + { + result.errorMsg = "SHA-384 failed"; + return result; + } + + // 3. Sign with mbedtls (DER output). + std::array derBuf{}; + std::size_t derLen = 0; + int rc = mbedtls_pk_sign(&pk, MBEDTLS_MD_SHA384, hash.data(), + hash.size(), derBuf.data(), derBuf.size(), + &derLen, mbedtls_ctr_drbg_random, &ctrDrbg); + if (rc != 0) + { + char errbuf[128]; + mbedtls_strerror(rc, errbuf, sizeof(errbuf)); + result.errorMsg = std::string{"mbedtls_pk_sign failed: "} + errbuf; + return result; + } + + // 4. DER -> P1363 (96-byte r||s). + std::array sigP1363{}; + if (!derEcdsaToP1363(derBuf.data(), derLen, sigP1363)) + { + result.errorMsg = "DER -> P1363 conversion failed"; + return result; + } + + // 5. Assemble final CWT(COSE_Sign1) token. + auto chainDer = eat::pemToDerChain(certChainPem); + try + { + result.compositeEat = eat::assembleCwtCoseSign1( + protectedHdr, std::span{chainDer}, claims, std::span{sigP1363}); + } + catch (const std::exception& ex) + { + result.errorMsg = std::string{"COSE assembly failed: "} + ex.what(); + return result; + } + + result.success = true; + return result; + } +}; + +MockAttester::MockAttester(MockAttesterConfig cfg) : + impl(std::make_unique(std::move(cfg))) +{ + if (!impl->init()) + { + std::cerr << "MockAttester: initialization failed\n"; + } +} + +MockAttester::~MockAttester() = default; +MockAttester::MockAttester(MockAttester&&) noexcept = default; +MockAttester& MockAttester::operator=(MockAttester&&) noexcept = default; + +composite::CompositeEatResponse MockAttester::generateCompositeEat( + const composite::CompositeEatRequest& req) +{ + if (!impl) + { + composite::CompositeEatResponse r; + r.errorMsg = "MockAttester moved-from"; + return r; + } + return impl->generateCompositeEat(req); +} + +PlatformAttesterStatus MockAttester::getStatus() const +{ + if (impl && impl->ready) + { + return PlatformAttesterStatus::SoftwareMock; + } + return PlatformAttesterStatus::Unavailable; +} + +const std::string& MockAttester::getCertChainPEM() const +{ + static const std::string empty; + return impl ? impl->certChainPem : empty; +} + +std::span MockAttester::getUeid() const +{ + if (!impl) + { + return {}; + } + return std::span{impl->ueid.data(), impl->ueid.size()}; +} + +} // namespace spdmd::mock_attester diff --git a/spdmd/mock_attester/mock_attester.hpp b/spdmd/mock_attester/mock_attester.hpp new file mode 100644 index 0000000..180e003 --- /dev/null +++ b/spdmd/mock_attester/mock_attester.hpp @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// MockAttester — software-only PlatformAttester backend. +// +// Development backend (attester-backend=mock). Acts as a stand-in Lead +// Attester / RoT: it owns a mock eat_profile, +// a mock ueid, and a mock measurements set, generates an ephemeral +// ECDSA-P384 key + self-signed leaf at construction, and produces +// verifier-compatible CWT(COSE_Sign1) composite EAT tokens in userspace. +// +// Its status is always SoftwareMock when initialized. + +#pragma once + +#include "platform_attester.hpp" + +#include +#include +#include +#include + +namespace spdmd::mock_attester +{ + +struct MockAttesterConfig +{ + /// Composite EAT profile URI the mock RoT writes into eat_profile + /// (265). The Lead Attester owns this — the BMC never supplies it. + std::string profileUri = + "tag:example,2026:platform-composite-attestation-v1"; + + /// Common Name placed in the self-signed mock leaf certificate. + std::string leafCn = "PlatformAttesterMock"; +}; + +class MockAttester : public PlatformAttester +{ + public: + explicit MockAttester(MockAttesterConfig cfg = {}); + ~MockAttester() override; + + MockAttester(const MockAttester&) = delete; + MockAttester& operator=(const MockAttester&) = delete; + MockAttester(MockAttester&&) noexcept; + MockAttester& operator=(MockAttester&&) noexcept; + + composite::CompositeEatResponse generateCompositeEat( + const composite::CompositeEatRequest& req) override; + + PlatformAttesterStatus getStatus() const override; + + /// Test hook: PEM cert chain that signs the composite token. + const std::string& getCertChainPEM() const; + + /// Test hook: the mock Lead Attester ueid bytes (16 bytes). + std::span getUeid() const; + + private: + struct Impl; + std::unique_ptr impl; +}; + +} // namespace spdmd::mock_attester diff --git a/spdmd/platform_attester.hpp b/spdmd/platform_attester.hpp new file mode 100644 index 0000000..f7cbf79 --- /dev/null +++ b/spdmd/platform_attester.hpp @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// PlatformAttester - vendor-neutral Lead Attester interface. +// +// This is the contract between the SPDM daemon and a platform-specific +// backend RoT / Lead Attester. The SPDM daemon sends a +// CompositeEatRequest carrying the verifier-supplied nonce, per-device +// submodule digests, and an optional Platform CoRIM locator hint. The +// PlatformAttester backend generates the Composite EAT and returns it to +// the SPDM daemon. +// +// Vendor integration guidance: +// 1. Implement PlatformAttester in a platform-specific backend. +// 2. Translate CompositeEatRequest into the RoT command/protocol. +// 3. Let the RoT generate and return the Composite EAT bytes. +// 4. Report backend availability through PlatformAttesterStatus. +// +// The transport between the BMC and the platform attester is deliberately +// outside this interface. It may be a mailbox, MCTP VDM, or any other +// platform-specific mechanism. Backends are selected by Meson option and +// compile-time gates; the upstream mock backend is for development and +// test only. + +#pragma once + +#include "composite/types.hpp" + +#include + +namespace spdmd +{ + +enum class PlatformAttesterStatus +{ + Unavailable, + Ready, + SoftwareMock, +}; + +inline std::string_view toString(PlatformAttesterStatus status) +{ + switch (status) + { + case PlatformAttesterStatus::Unavailable: + return "Unavailable"; + case PlatformAttesterStatus::Ready: + return "Ready"; + case PlatformAttesterStatus::SoftwareMock: + return "SoftwareMock"; + } + return "Unavailable"; +} + +class PlatformAttester +{ + public: + virtual ~PlatformAttester() = default; + + /// Generate and return the composite EAT (NOT the tag-602 bundle). + /// The implementation inserts eat_profile, ueid, platform + /// measurements, and signing material internally. + virtual composite::CompositeEatResponse + generateCompositeEat(const composite::CompositeEatRequest& req) = 0; + + /// Backend availability/mode for diagnostics. This is not an + /// appraisal result. + virtual PlatformAttesterStatus getStatus() const = 0; +}; + +} // namespace spdmd diff --git a/spdmd/spdmd.cpp b/spdmd/spdmd.cpp index cbea296..3454025 100644 --- a/spdmd/spdmd.cpp +++ b/spdmd/spdmd.cpp @@ -22,6 +22,16 @@ #include "spdmd_app.hpp" #include "spdmd_version.hpp" +#ifdef ENABLE_COMPOSITE_ATTESTATION +#include "composite/composite_orchestrator.hpp" +#include "dbus_impl_composite.hpp" +#include "platform_attester.hpp" + +#ifdef ATTESTER_BACKEND_MOCK +#include "mock_attester/mock_attester.hpp" +#endif +#endif + #include #include @@ -30,6 +40,7 @@ #include #include #include +#include #include using namespace std; @@ -43,6 +54,37 @@ constexpr auto spdmDefaultService = "xyz.openbmc_project.SPDM"; namespace spdmd { +#ifdef ENABLE_COMPOSITE_ATTESTATION +namespace +{ + +std::unique_ptr createConfiguredPlatformAttester() +{ + constexpr std::string_view backend = COMPOSITE_ATTESTER_BACKEND; + + if (backend == "none") + { + return nullptr; + } + + if (backend == "mock") + { +#ifdef ATTESTER_BACKEND_MOCK + return std::make_unique(); +#else + std::cerr << "PlatformAttester: backend 'mock' requested but " + "ATTESTER_BACKEND_MOCK not compiled in.\n"; + return nullptr; +#endif + } + + std::cerr << "PlatformAttester: unknown backend '" << backend << "'\n"; + return nullptr; +} + +} // namespace +#endif + SpdmdApp::SpdmdApp() : SpdmdAppContext(std::cout) {} @@ -532,6 +574,31 @@ int main(int argc, char** argv) auto& conn = spdmApp.getConn(); sdbusplus::server::manager_t objManager(conn, spdmRootObjectPath); + +#ifdef ENABLE_COMPOSITE_ATTESTATION + // Composite attestation: configured Lead Attester + + // orchestrator + tag-602 bundle producer. The CollectionPlan + // (env.* mapping + Platform CoRIM locator) is loaded from + // /etc/spdmd/composite.json when present. + auto attester = spdmd::createConfiguredPlatformAttester(); + std::unique_ptr orchestrator; + std::unique_ptr compositeObjServer; + std::unique_ptr compositeDbus; + if (attester) + { + orchestrator = + std::make_unique(*attester); + compositeObjServer = + std::make_unique( + spdmApp.getConnPtr()); + spdmd::CompositeConfig cc = + spdmd::loadCompositeConfig("/etc/spdmd/composite.json"); + compositeDbus = std::make_unique( + *compositeObjServer, *orchestrator, spdmApp.getResponders(), + spdmApp.getIo(), std::move(cc)); + } +#endif + returnCode = spdmApp.loop(); } catch (const std::exception& e) diff --git a/spdmd/spdmd_app.hpp b/spdmd/spdmd_app.hpp index d81f9a6..f24be1e 100644 --- a/spdmd/spdmd_app.hpp +++ b/spdmd/spdmd_app.hpp @@ -114,6 +114,11 @@ class SpdmdApp : public SpdmdAppContext */ void discoveryUpdateResponder(const dbus_api::ResponderArgs& respArg); + auto& getResponders() + { + return responders; + } + private: /** @brief SPDMD callback signal called * diff --git a/spdmd/spdmd_app_context.hpp b/spdmd/spdmd_app_context.hpp index 42ed461..d5784dc 100644 --- a/spdmd/spdmd_app_context.hpp +++ b/spdmd/spdmd_app_context.hpp @@ -31,6 +31,7 @@ #include #include +#include #include #include @@ -129,6 +130,13 @@ class SpdmdAppContext return *conn; } + /** @brief Get DBus connection shared pointer */ + auto getConnPtr() + { + return std::shared_ptr( + conn.get(), [](sdbusplus::asio::connection*) {}); + } + /** @brief GET IO context */ auto& getIo() { diff --git a/spdmd/tests/composite/bundle_assembler_test.cpp b/spdmd/tests/composite/bundle_assembler_test.cpp new file mode 100644 index 0000000..437f7ef --- /dev/null +++ b/spdmd/tests/composite/bundle_assembler_test.cpp @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2024 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// Unit tests for BundleAssembler — tag-602 structure, bstr-wrapped +// main-token, and the env.* -> bstr.cbor detached Claims-Set map. + +#include "cbor_test_util.hpp" +#include "composite/bundle_assembler.hpp" + +#include +#include +#include + +#include + +namespace spdmd::composite +{ +namespace +{ + +TEST(BundleAssembler, Tag602Structure) +{ + std::vector token{0xD8, 0x3D, 0x84}; // pretend EAT bytes + std::vector>> cs; + cs.emplace_back("env.nic.0", std::vector{0xA1, 0x01}); + cs.emplace_back("env.gpu.0", std::vector{0xA1, 0x02}); + + auto bundle = assembleBundle(token, cs); + auto root = cbortest::decode(bundle); + + ASSERT_TRUE(root->isTag()); + EXPECT_EQ(root->tag, kCborTagDetachedEatBundle); + + auto arr = root->tagged; + ASSERT_TRUE(arr->isArray()); + ASSERT_EQ(arr->array.size(), 2u); + + // [0] main-token wrapped as bstr. + ASSERT_TRUE(arr->array[0]->isBytes()); + EXPECT_EQ(arr->array[0]->bytes, token); + + // [1] detached-claims-sets map. + auto csMap = arr->array[1]; + ASSERT_TRUE(csMap->isMap()); + EXPECT_EQ(csMap->map.size(), 2u); +} + +TEST(BundleAssembler, ClaimsSetsBstrWrappedAndKeyedByEnv) +{ + std::vector token{0x01}; + std::vector gpuCs{0xA1, 0x02, 0x03}; + std::vector>> cs; + cs.emplace_back("env.gpu.0", gpuCs); + + auto bundle = assembleBundle(token, cs); + auto root = cbortest::decode(bundle); + auto csMap = root->tagged->array[1]; + + auto entry = csMap->atText("env.gpu.0"); + ASSERT_TRUE(entry && entry->isBytes()); + // The value is bstr .cbor: its content is exactly the Claims-Set + // bytes (the digested bytes), not re-encoded. + EXPECT_EQ(entry->bytes, gpuCs); +} + +TEST(BundleAssembler, MapKeysDeterministicallyOrdered) +{ + std::vector token{0x01}; + std::vector>> cs; + // Insert out of order. + cs.emplace_back("env.nic.0", std::vector{0x01}); + cs.emplace_back("env.gpu.0", std::vector{0x02}); + cs.emplace_back("env.cpu.0", std::vector{0x03}); + + auto bundle = assembleBundle(token, cs); + auto root = cbortest::decode(bundle); + auto csMap = root->tagged->array[1]; + ASSERT_EQ(csMap->map.size(), 3u); + // All same length; bytewise ascending: cpu < gpu < nic. + EXPECT_EQ(csMap->map[0].first->text, "env.cpu.0"); + EXPECT_EQ(csMap->map[1].first->text, "env.gpu.0"); + EXPECT_EQ(csMap->map[2].first->text, "env.nic.0"); +} + +TEST(BundleAssembler, EmptyClaimsSetsProducesEmptyMap) +{ + std::vector token{0x01}; + std::vector>> cs; + auto bundle = assembleBundle(token, cs); + auto root = cbortest::decode(bundle); + EXPECT_TRUE(root->tagged->array[1]->isMap()); + EXPECT_EQ(root->tagged->array[1]->map.size(), 0u); +} + +} // namespace +} // namespace spdmd::composite diff --git a/spdmd/tests/composite/cbor_det_test.cpp b/spdmd/tests/composite/cbor_det_test.cpp new file mode 100644 index 0000000..cb5bd0d --- /dev/null +++ b/spdmd/tests/composite/cbor_det_test.cpp @@ -0,0 +1,158 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2024 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// Unit tests for the deterministic CBOR writer (composite::cbor). + +#include "cbor_test_util.hpp" +#include "composite/cbor_det.hpp" + +#include +#include + +#include + +namespace spdmd::composite::cbor +{ +namespace +{ + +std::vector bytes(std::initializer_list l) +{ + return std::vector(l); +} + +TEST(CborDet, UintShortestForm) +{ + EXPECT_EQ(uintVal(0), bytes({0x00})); + EXPECT_EQ(uintVal(23), bytes({0x17})); + EXPECT_EQ(uintVal(24), bytes({0x18, 0x18})); + EXPECT_EQ(uintVal(255), bytes({0x18, 0xFF})); + EXPECT_EQ(uintVal(256), bytes({0x19, 0x01, 0x00})); + EXPECT_EQ(uintVal(65535), bytes({0x19, 0xFF, 0xFF})); + EXPECT_EQ(uintVal(65536), bytes({0x1A, 0x00, 0x01, 0x00, 0x00})); + EXPECT_EQ(uintVal(4294967296ULL), + bytes({0x1B, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00})); +} + +TEST(CborDet, NegativeInt) +{ + EXPECT_EQ(intVal(-1), bytes({0x20})); + EXPECT_EQ(intVal(-24), bytes({0x37})); + EXPECT_EQ(intVal(-25), bytes({0x38, 0x18})); + EXPECT_EQ(intVal(-256), bytes({0x38, 0xFF})); + EXPECT_EQ(intVal(-257), bytes({0x39, 0x01, 0x00})); + // COSE alg ES384 = -35 and SHA-384 = -43. + EXPECT_EQ(intVal(-35), bytes({0x38, 0x22})); + EXPECT_EQ(intVal(-43), bytes({0x38, 0x2A})); +} + +TEST(CborDet, TextAndBytes) +{ + EXPECT_EQ(textVal("env.gpu.0"), + bytes({0x69, 'e', 'n', 'v', '.', 'g', 'p', 'u', '.', '0'})); + std::vector raw{0xDE, 0xAD}; + EXPECT_EQ(bytesVal(raw), bytes({0x42, 0xDE, 0xAD})); +} + +TEST(CborDet, MapCanonicalKeyOrdering) +{ + // Insert keys out of canonical order; encode() must sort them. + Map m; + m.addInt(266, uintVal(1)); // 2-byte key 0x19 0x01 0x0A + m.addInt(10, uintVal(2)); // 1-byte key 0x0A + m.addInt(256, uintVal(3)); // 2-byte key 0x19 0x01 0x00 + m.addInt(-75000, uintVal(4)); + + auto enc = m.encode(); + auto root = cbortest::decode(enc); + ASSERT_TRUE(root->isMap()); + ASSERT_EQ(root->map.size(), 4u); + + // Canonical order: shorter encodings first, then bytewise. So 10 + // (0x0A) first, then 256 (0x19 0x01 0x00), then 266 (0x19 0x01 0x0A), + // then -75000 (major 1, longest). + EXPECT_EQ(root->map[0].first->ival, 10); + EXPECT_EQ(root->map[1].first->ival, 256); + EXPECT_EQ(root->map[2].first->ival, 266); + EXPECT_EQ(root->map[3].first->ival, -75000); +} + +TEST(CborDet, MapTextKeyOrdering) +{ + Map m; + m.addText("vca", uintVal(1)); + m.addText("cert_chain", uintVal(2)); + m.addText("signed_measurements", uintVal(3)); + + auto enc = m.encode(); + auto root = cbortest::decode(enc); + ASSERT_TRUE(root->isMap()); + ASSERT_EQ(root->map.size(), 3u); + // Shorter text first (cert_chain=10, vca=3 -> 'vca' is shorter so + // actually: lengths: cert_chain=10, vca=3, signed_measurements=19. + // Encoded key = head + utf8; head length differs: "vca" -> 0x63..., + // "cert_chain" -> 0x6a..., "signed_measurements" -> 0x73... + // Bytewise: 0x63 < 0x6a < 0x73, so vca, cert_chain, signed_measurements. + EXPECT_EQ(root->map[0].first->text, "vca"); + EXPECT_EQ(root->map[1].first->text, "cert_chain"); + EXPECT_EQ(root->map[2].first->text, "signed_measurements"); +} + +TEST(CborDet, MapDeterministicRepeatable) +{ + Map a; + a.addInt(3, uintVal(1)); + a.addInt(1, uintVal(2)); + a.addInt(2, uintVal(3)); + + Map b; + b.addInt(2, uintVal(3)); + b.addInt(3, uintVal(1)); + b.addInt(1, uintVal(2)); + + EXPECT_EQ(a.encode(), b.encode()); +} + +TEST(CborDet, ArrayValue) +{ + std::vector> elems; + elems.push_back(intVal(-43)); + std::vector dig(48, 0xAB); + elems.push_back(bytesVal(dig)); + auto enc = arrayVal(elems); + + auto root = cbortest::decode(enc); + ASSERT_TRUE(root->isArray()); + ASSERT_EQ(root->array.size(), 2u); + EXPECT_EQ(root->array[0]->ival, -43); + EXPECT_TRUE(root->array[1]->isBytes()); + EXPECT_EQ(root->array[1]->bytes.size(), 48u); +} + +TEST(CborDet, TagRoundTrip) +{ + std::vector out; + putTag(out, 602); + putArrayHeader(out, 0); + auto root = cbortest::decode(out); + ASSERT_TRUE(root->isTag()); + EXPECT_EQ(root->tag, 602u); + EXPECT_TRUE(root->tagged->isArray()); +} + +} // namespace +} // namespace spdmd::composite::cbor diff --git a/spdmd/tests/composite/cbor_test_util.hpp b/spdmd/tests/composite/cbor_test_util.hpp new file mode 100644 index 0000000..b958913 --- /dev/null +++ b/spdmd/tests/composite/cbor_test_util.hpp @@ -0,0 +1,198 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// Test-side CBOR decoder, built on tinycbor. Decodes the deterministic +// subset emitted by spdmd::composite::cbor into an inspectable tree. +// Tests rely on the vetted tinycbor parser rather than a hand-rolled one. + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace cbortest +{ + +struct Item; +using ItemPtr = std::shared_ptr; + +struct Item +{ + int major = -1; // CBOR major type 0..7 + std::uint64_t uarg = 0; // uint value / len + std::int64_t ival = 0; // signed value for major 0/1 + std::vector bytes; + std::string text; + std::vector array; + std::vector> map; + std::uint64_t tag = 0; // major 6 + ItemPtr tagged; // content of a tag + + bool isUint() const + { + return major == 0; + } + bool isInt() const + { + return major == 0 || major == 1; + } + bool isBytes() const + { + return major == 2; + } + bool isText() const + { + return major == 3; + } + bool isArray() const + { + return major == 4; + } + bool isMap() const + { + return major == 5; + } + bool isTag() const + { + return major == 6; + } + + ItemPtr atInt(std::int64_t key) const + { + for (const auto& [k, v] : map) + { + if (k->isInt() && k->ival == key) + { + return v; + } + } + return nullptr; + } + + ItemPtr atText(const std::string& key) const + { + for (const auto& [k, v] : map) + { + if (k->isText() && k->text == key) + { + return v; + } + } + return nullptr; + } +}; + +inline ItemPtr decodeValue(CborValue* it) +{ + auto item = std::make_shared(); + const CborType t = cbor_value_get_type(it); + switch (t) + { + case CborIntegerType: + { + item->major = cbor_value_is_negative_integer(it) ? 1 : 0; + std::int64_t v = 0; + cbor_value_get_int64(it, &v); + item->ival = v; + item->uarg = static_cast(v); + cbor_value_advance_fixed(it); + break; + } + case CborByteStringType: + { + item->major = 2; + std::size_t n = 0; + cbor_value_get_string_length(it, &n); + item->bytes.resize(n); + std::uint8_t one = 0; + std::uint8_t* p = n ? item->bytes.data() : &one; + std::size_t cap = n ? n : 1; + cbor_value_copy_byte_string(it, p, &cap, it); + break; + } + case CborTextStringType: + { + item->major = 3; + std::size_t n = 0; + cbor_value_get_string_length(it, &n); + std::vector buf(n + 1); + std::size_t cap = buf.size(); + cbor_value_copy_text_string(it, buf.data(), &cap, it); + item->text.assign(buf.data(), cap); + break; + } + case CborArrayType: + { + item->major = 4; + CborValue rec; + cbor_value_enter_container(it, &rec); + while (!cbor_value_at_end(&rec)) + { + item->array.push_back(decodeValue(&rec)); + } + cbor_value_leave_container(it, &rec); + break; + } + case CborMapType: + { + item->major = 5; + CborValue rec; + cbor_value_enter_container(it, &rec); + while (!cbor_value_at_end(&rec)) + { + ItemPtr k = decodeValue(&rec); + ItemPtr v = decodeValue(&rec); + item->map.emplace_back(std::move(k), std::move(v)); + } + cbor_value_leave_container(it, &rec); + break; + } + case CborTagType: + { + item->major = 6; + CborTag tg = 0; + cbor_value_get_tag(it, &tg); + item->tag = tg; + cbor_value_advance_fixed(it); + item->tagged = decodeValue(it); + break; + } + default: + throw std::runtime_error("cbortest: unsupported type"); + } + return item; +} + +inline ItemPtr decode(std::span b) +{ + CborParser parser; + CborValue it; + if (cbor_parser_init(b.data(), b.size(), 0, &parser, &it) != CborNoError) + { + throw std::runtime_error("cbortest: parser init failed"); + } + return decodeValue(&it); +} + +} // namespace cbortest diff --git a/spdmd/tests/composite/claims_set_builder_test.cpp b/spdmd/tests/composite/claims_set_builder_test.cpp new file mode 100644 index 0000000..e21d576 --- /dev/null +++ b/spdmd/tests/composite/claims_set_builder_test.cpp @@ -0,0 +1,173 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2024 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// Unit tests for ClaimsSetBuilder — Pattern A/C and Pattern B detached +// Claims-Sets, VCA inclusion rule, typed-value option, and error paths. + +#include "cbor_test_util.hpp" +#include "composite/claims_set_builder.hpp" + +#include +#include + +#include + +namespace spdmd::composite +{ +namespace +{ + +CollectedEvidence spdmDevice(bool withVca) +{ + CollectedEvidence e; + e.environmentId = "env.gpu.0"; + e.eid = 13; + e.success = true; + e.pattern = EvidencePattern::SpdmMeasurements; + e.signedMeasurements = {0x01, 0x02, 0x03, 0x04}; + e.certificateChainDer = {0x30, 0x01, 0xAA, 0x30, 0x01, 0xBB}; + if (withVca) + { + e.vca = {0x10, 0x20}; + e.includeVca = true; + } + return e; +} + +CollectedEvidence eatDevice() +{ + CollectedEvidence e; + e.environmentId = "env.nic.0"; + e.eid = 64; + e.success = true; + e.pattern = EvidencePattern::DeviceEat; + e.deviceTokenFormat = "application/eat+cwt"; + e.deviceToken = {0xD2, 0x84, 0x40}; + return e; +} + +TEST(ClaimsSetBuilder, SpdmPatternFieldsNoVca) +{ + auto cs = buildClaimsSet(spdmDevice(false)); + auto root = cbortest::decode(cs); + ASSERT_TRUE(root->isMap()); + EXPECT_EQ(root->map.size(), 2u); + + auto sm = root->atText("signed_measurements"); + ASSERT_TRUE(sm && sm->isBytes()); + EXPECT_EQ(sm->bytes, (std::vector{0x01, 0x02, 0x03, 0x04})); + + auto cc = root->atText("cert_chain"); + ASSERT_TRUE(cc && cc->isBytes()); + EXPECT_EQ(cc->bytes, + (std::vector{0x30, 0x01, 0xAA, 0x30, 0x01, + 0xBB})); + + EXPECT_EQ(root->atText("vca"), nullptr); +} + +TEST(ClaimsSetBuilder, SpdmPatternIncludesVca) +{ + auto cs = buildClaimsSet(spdmDevice(true)); + auto root = cbortest::decode(cs); + ASSERT_TRUE(root->isMap()); + EXPECT_EQ(root->map.size(), 3u); + auto vca = root->atText("vca"); + ASSERT_TRUE(vca && vca->isBytes()); + EXPECT_EQ(vca->bytes, (std::vector{0x10, 0x20})); +} + +TEST(ClaimsSetBuilder, DeviceEatPattern) +{ + auto cs = buildClaimsSet(eatDevice()); + auto root = cbortest::decode(cs); + ASSERT_TRUE(root->isMap()); + EXPECT_EQ(root->map.size(), 2u); + + auto tf = root->atText("token_format"); + ASSERT_TRUE(tf && tf->isText()); + EXPECT_EQ(tf->text, "application/eat+cwt"); + + auto dt = root->atText("device_token"); + ASSERT_TRUE(dt && dt->isBytes()); + EXPECT_EQ(dt->bytes, (std::vector{0xD2, 0x84, 0x40})); +} + +TEST(ClaimsSetBuilder, TypedValuesWrapSpdmFields) +{ + auto cs = buildClaimsSet(spdmDevice(false), /*typedValues=*/true); + auto root = cbortest::decode(cs); + auto sm = root->atText("signed_measurements"); + ASSERT_TRUE(sm && sm->isArray()); + ASSERT_EQ(sm->array.size(), 2u); + EXPECT_TRUE(sm->array[0]->isUint()); + EXPECT_EQ(sm->array[0]->uarg, kCfSpdmMeasurements); + EXPECT_TRUE(sm->array[1]->isBytes()); + + auto certChain = root->atText("cert_chain"); + ASSERT_TRUE(certChain && certChain->isArray()); + ASSERT_EQ(certChain->array.size(), 2u); + EXPECT_EQ(certChain->array[0]->uarg, + kCfConcatenatedDerCertificates); + EXPECT_EQ(certChain->array[1]->bytes, + spdmDevice(false).certificateChainDer); +} + +TEST(ClaimsSetBuilder, TypedValuesDoNotWrapDeviceEatFields) +{ + auto cs = buildClaimsSet(eatDevice(), /*typedValues=*/true); + auto root = cbortest::decode(cs); + auto token = root->atText("device_token"); + ASSERT_TRUE(token && token->isBytes()); +} + +TEST(ClaimsSetBuilder, Deterministic) +{ + EXPECT_EQ(buildClaimsSet(spdmDevice(true)), + buildClaimsSet(spdmDevice(true))); +} + +TEST(ClaimsSetBuilder, ThrowsOnEmptySignedMeasurements) +{ + auto e = spdmDevice(false); + e.signedMeasurements.clear(); + EXPECT_THROW(buildClaimsSet(e), std::invalid_argument); +} + +TEST(ClaimsSetBuilder, ThrowsOnEmptyCertChain) +{ + auto e = spdmDevice(false); + e.certificateChainDer.clear(); + EXPECT_THROW(buildClaimsSet(e), std::invalid_argument); +} + +TEST(ClaimsSetBuilder, ThrowsWhenVcaRequiredButEmpty) +{ + auto e = spdmDevice(false); + e.includeVca = true; + EXPECT_THROW(buildClaimsSet(e), std::invalid_argument); +} + +TEST(ClaimsSetBuilder, ThrowsOnEmptyDeviceToken) +{ + auto e = eatDevice(); + e.deviceToken.clear(); + EXPECT_THROW(buildClaimsSet(e), std::invalid_argument); +} + +} // namespace +} // namespace spdmd::composite diff --git a/spdmd/tests/composite/collection_plan_test.cpp b/spdmd/tests/composite/collection_plan_test.cpp new file mode 100644 index 0000000..5f91483 --- /dev/null +++ b/spdmd/tests/composite/collection_plan_test.cpp @@ -0,0 +1,187 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2024 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// Unit tests for CollectionPlan — env.* validation, locator resolution, +// fallback, and config JSON parsing. + +#include "composite/collection_plan.hpp" + +#include + +#include + +namespace spdmd::composite +{ +namespace +{ + +TEST(CollectionPlan, EnvIdValidation) +{ + EXPECT_TRUE(CollectionPlan::isValidEnvId("env.gpu.0")); + EXPECT_TRUE(CollectionPlan::isValidEnvId("env.nic.primary")); + EXPECT_TRUE(CollectionPlan::isValidEnvId("env.m2.boot")); + EXPECT_TRUE(CollectionPlan::isValidEnvId("env.pcie.4")); + EXPECT_TRUE(CollectionPlan::isValidEnvId("env.unknown.14")); + + EXPECT_FALSE(CollectionPlan::isValidEnvId("")); + EXPECT_FALSE(CollectionPlan::isValidEnvId("env")); // no target + EXPECT_FALSE(CollectionPlan::isValidEnvId("gpu.0")); // no env prefix + EXPECT_FALSE(CollectionPlan::isValidEnvId("ENV.gpu.0")); // uppercase + EXPECT_FALSE(CollectionPlan::isValidEnvId("env.GPU")); // uppercase + EXPECT_FALSE(CollectionPlan::isValidEnvId("env.")); // trailing dot + EXPECT_FALSE(CollectionPlan::isValidEnvId("env..0")); // empty comp + EXPECT_FALSE(CollectionPlan::isValidEnvId("env.gpu-0")); // bad char + EXPECT_FALSE(CollectionPlan::isValidEnvId( + "env.this.identifier.is.way.too.long.to.be.valid.because.over." + "sixtyfour")); // > 64 bytes +} + +TEST(CollectionPlan, ResolveByEid) +{ + CollectionPlan p; + CollectionPlan::Entry e; + e.env = "env.gpu.0"; + e.mctpEid = 13; + ASSERT_TRUE(p.addEntry(e)); + + EXPECT_EQ(p.resolveByEid(13), "env.gpu.0"); + EXPECT_EQ(p.resolveByEid(99), "env.unknown.99"); +} + +TEST(CollectionPlan, AddEntryRejectsInvalidEnv) +{ + CollectionPlan p; + CollectionPlan::Entry e; + e.env = "not-an-env"; + e.mctpEid = 1; + EXPECT_FALSE(p.addEntry(e)); + EXPECT_EQ(p.size(), 0u); +} + +TEST(CollectionPlan, MultiPredicateMatch) +{ + CollectionPlan p; + CollectionPlan::Entry e; + e.env = "env.nic.0"; + e.mctpEid = 64; + e.pcieBdf = "0000:17:00.0"; + ASSERT_TRUE(p.addEntry(e)); + + CollectionPlan::Locator match; + match.eid = 64; + match.pcieBdf = "0000:17:00.0"; + EXPECT_EQ(p.resolve(match), "env.nic.0"); + + // EID matches but BDF differs -> no match -> fallback. + CollectionPlan::Locator mismatch; + mismatch.eid = 64; + mismatch.pcieBdf = "0000:18:00.0"; + EXPECT_EQ(p.resolve(mismatch), "env.unknown.64"); +} + +TEST(CollectionPlan, FromJsonValid) +{ + const std::string json = R"({ + "platformCorimLocator": "tag:example.com,2026:platform-corim:v7", + "environments": [ + { "env": "env.gpu.0", "match": { "mctpEid": 13 } }, + { "env": "env.nic.0", "match": { "mctpEid": 64 } }, + { "env": "env.rot", "match": { "mctpEid": 12 } } + ] + })"; + + std::string err; + auto plan = CollectionPlan::fromJson(json, err); + ASSERT_TRUE(plan.has_value()) << err; + EXPECT_EQ(plan->size(), 3u); + ASSERT_TRUE(plan->platformCorimLocator().has_value()); + EXPECT_EQ(*plan->platformCorimLocator(), + "tag:example.com,2026:platform-corim:v7"); + EXPECT_EQ(plan->resolveByEid(13), "env.gpu.0"); + EXPECT_EQ(plan->resolveByEid(12), "env.rot"); + EXPECT_EQ(plan->resolveByEid(7), "env.unknown.7"); +} + +TEST(CollectionPlan, ParseCompositeConfig) +{ + const std::string json = R"({ + "allowUnknownEnvironments": true, + "skipDevices": [12, 64, 256, 4294967309, "bad"], + "environments": [ + { "env": "env.gpu.0", "match": { "mctpEid": 13 } } + ] + })"; + + std::string err; + auto config = parseCompositeConfig(json, err); + ASSERT_TRUE(config.has_value()) << err; + EXPECT_TRUE(config->allowUnknownEnvironments); + EXPECT_EQ(config->skipDevices, + (std::vector{12, 64})); + EXPECT_EQ(config->plan.resolveByEid(13), "env.gpu.0"); +} + +TEST(CollectionPlan, FromJsonRejectsInvalidEnv) +{ + const std::string json = R"({ + "environments": [ { "env": "BAD", "match": { "mctpEid": 1 } } ] + })"; + std::string err; + auto plan = CollectionPlan::fromJson(json, err); + EXPECT_FALSE(plan.has_value()); + EXPECT_FALSE(err.empty()); +} + +TEST(CollectionPlan, FromJsonRejectsMalformedMatch) +{ + const std::vector invalid{ + R"({"environments":[{"env":"env.gpu.0"}]})", + R"({"environments":[{"env":"env.gpu.0","match":[]}]})", + R"({"environments":[{"env":"env.gpu.0","match":{}}]})", + R"({"environments":[{"env":"env.gpu.0","match":{"unknown":1}}]})", + R"({"environments":[{"env":"env.gpu.0","match":{"mctpEid":269}}]})", + R"({"environments":[{"env":"env.gpu.0","match":{"mctpEid":4294967309}}]})", + R"({"environments":[{"env":"env.gpu.0","match":{"mctpEid":"13"}}]})", + R"({"environments":[{"env":"env.gpu.0","match":{"pcieBdf":1}}]})", + }; + + for (const auto& json : invalid) + { + std::string err; + EXPECT_FALSE(CollectionPlan::fromJson(json, err).has_value()) << json; + EXPECT_FALSE(err.empty()) << json; + } +} + +TEST(CollectionPlan, FromJsonEmptyPlanIsValid) +{ + std::string err; + auto plan = CollectionPlan::fromJson("{}", err); + ASSERT_TRUE(plan.has_value()) << err; + EXPECT_EQ(plan->size(), 0u); + EXPECT_EQ(plan->resolveByEid(5), "env.unknown.5"); +} + +TEST(CollectionPlan, FromJsonRejectsNonObject) +{ + std::string err; + EXPECT_FALSE(CollectionPlan::fromJson("[]", err).has_value()); + EXPECT_FALSE(CollectionPlan::fromJson("not json", err).has_value()); +} + +} // namespace +} // namespace spdmd::composite diff --git a/spdmd/tests/composite/evidence_builder_test.cpp b/spdmd/tests/composite/evidence_builder_test.cpp new file mode 100644 index 0000000..b48ca9b --- /dev/null +++ b/spdmd/tests/composite/evidence_builder_test.cpp @@ -0,0 +1,133 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2024 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include "composite/evidence_builder.hpp" + +#include + +#include + +namespace spdmd::composite +{ +namespace +{ + +EvidenceBuilderInput baseSpdmInput() +{ + EvidenceBuilderInput input; + input.environmentId = "env.gpu.0"; + input.eid = 13; + input.success = true; + input.spdmVersion = 0x12; + input.measurementSpecification = kSpdmMeasurementSpecDmtf; + input.signedMeasurements = {0x01, 0x02, 0x03}; + input.certificateChainDer = {0x30, 0x82, 0x01, 0x02}; + return input; +} + +TEST(EvidenceBuilder, RefreshFailurePropagatesError) +{ + auto input = baseSpdmInput(); + input.success = false; + input.errorMsg = "timeout"; + + auto ev = buildCollectedEvidence(input); + EXPECT_FALSE(ev.success); + EXPECT_EQ(ev.eid, 13); + EXPECT_EQ(ev.environmentId, "env.gpu.0"); + EXPECT_EQ(ev.errorMsg, "timeout"); +} + +TEST(EvidenceBuilder, SpdmEvidenceUsesDerCertificateChain) +{ + auto input = baseSpdmInput(); + auto ev = buildCollectedEvidence(input); + + ASSERT_TRUE(ev.success) << ev.errorMsg; + EXPECT_EQ(ev.pattern, EvidencePattern::SpdmMeasurements); + EXPECT_EQ(ev.signedMeasurements, + (std::vector{0x01, 0x02, 0x03})); + EXPECT_EQ(ev.certificateChainDer, + (std::vector{0x30, 0x82, 0x01, 0x02})); + EXPECT_FALSE(ev.includeVca); +} + +TEST(EvidenceBuilder, Spdm10RequiresVcaTranscript) +{ + auto input = baseSpdmInput(); + input.spdmVersion = 0x10; + + auto ev = buildCollectedEvidence(input); + EXPECT_FALSE(ev.success); + EXPECT_EQ(ev.errorMsg, "missing SPDM VCA transcript"); +} + +TEST(EvidenceBuilder, Spdm11CopiesVcaTranscript) +{ + auto input = baseSpdmInput(); + input.spdmVersion = 0x11; + input.vcaTranscript = {0xAA, 0xBB}; + + auto ev = buildCollectedEvidence(input); + ASSERT_TRUE(ev.success) << ev.errorMsg; + EXPECT_TRUE(ev.includeVca); + EXPECT_EQ(ev.vca, (std::vector{0xAA, 0xBB})); +} + +TEST(EvidenceBuilder, MissingSignedMeasurementsFails) +{ + auto input = baseSpdmInput(); + input.signedMeasurements.clear(); + + auto ev = buildCollectedEvidence(input); + EXPECT_FALSE(ev.success); + EXPECT_EQ(ev.errorMsg, "missing SPDM signed measurements"); +} + +TEST(EvidenceBuilder, MissingDerCertificateChainFails) +{ + auto input = baseSpdmInput(); + input.certificateChainDer.clear(); + + auto ev = buildCollectedEvidence(input); + EXPECT_FALSE(ev.success); + EXPECT_EQ(ev.errorMsg, "missing DER certificate chain"); +} + +TEST(EvidenceBuilder, EatMeasurementSpecWithTokenBuildsDeviceEatEvidence) +{ + auto input = baseSpdmInput(); + input.measurementSpecification = kSpdmMeasurementSpecEat; + input.deviceEatToken = {0xD8, 0x3D, 0x84}; + + auto ev = buildCollectedEvidence(input); + ASSERT_TRUE(ev.success) << ev.errorMsg; + EXPECT_EQ(ev.pattern, EvidencePattern::DeviceEat); + EXPECT_EQ(ev.deviceTokenFormat, "application/eat+cwt"); + EXPECT_EQ(ev.deviceToken, (std::vector{0xD8, 0x3D, 0x84})); + EXPECT_TRUE(ev.signedMeasurements.empty()); + EXPECT_TRUE(ev.certificateChainDer.empty()); +} + +TEST(EvidenceBuilder, UnknownEnvironmentDetection) +{ + EXPECT_TRUE(isUnknownEnvironmentId("env.unknown.13")); + EXPECT_FALSE(isUnknownEnvironmentId("env.gpu.0")); +} + +} // namespace +} // namespace spdmd::composite diff --git a/spdmd/tests/composite/evidence_pattern_test.cpp b/spdmd/tests/composite/evidence_pattern_test.cpp new file mode 100644 index 0000000..3f81fbe --- /dev/null +++ b/spdmd/tests/composite/evidence_pattern_test.cpp @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2024 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// Unit tests for evidence pattern selection from SPDM MeasurementSpecification. + +#include "composite/types.hpp" + +#include + +namespace spdmd::composite +{ +namespace +{ + +TEST(EvidencePattern, DmtfMeasurementsStaySpdmEvidence) +{ + EXPECT_FALSE(hasEatMeasurementSpecification(kSpdmMeasurementSpecDmtf)); + EXPECT_EQ(selectEvidencePattern(kSpdmMeasurementSpecDmtf, false), + EvidencePattern::SpdmMeasurements); +} + +TEST(EvidencePattern, EatSpecWithoutTokenFallsBackToSpdmEvidence) +{ + EXPECT_TRUE(hasEatMeasurementSpecification(kSpdmMeasurementSpecEat)); + EXPECT_EQ(selectEvidencePattern(kSpdmMeasurementSpecEat, false), + EvidencePattern::SpdmMeasurements); +} + +TEST(EvidencePattern, EatSpecWithTokenSelectsDeviceEatEvidence) +{ + EXPECT_EQ(selectEvidencePattern(kSpdmMeasurementSpecEat, true), + EvidencePattern::DeviceEat); +} + +TEST(EvidencePattern, CombinedSpecWithTokenSelectsDeviceEatEvidence) +{ + const auto spec = static_cast(kSpdmMeasurementSpecDmtf | + kSpdmMeasurementSpecEat); + EXPECT_EQ(selectEvidencePattern(spec, true), EvidencePattern::DeviceEat); +} + +} // namespace +} // namespace spdmd::composite diff --git a/spdmd/tests/composite/meson.build b/spdmd/tests/composite/meson.build new file mode 100644 index 0000000..56c8db4 --- /dev/null +++ b/spdmd/tests/composite/meson.build @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Vendor-neutral composite attestation unit tests. These exercise the +# producer core (deterministic CBOR, Claims-Sets, digests, env.* plan, +# bundle assembly) and need no attester backend. + +composite_unit_tests = { + 'cbor_det_test': files('cbor_det_test.cpp'), + 'claims_set_builder_test': files('claims_set_builder_test.cpp'), + 'submodule_digest_test': files('submodule_digest_test.cpp'), + 'collection_plan_test': files('collection_plan_test.cpp'), + 'bundle_assembler_test': files('bundle_assembler_test.cpp'), + 'evidence_builder_test': files('evidence_builder_test.cpp'), + 'evidence_pattern_test': files('evidence_pattern_test.cpp'), +} + +foreach test_name, test_sources : composite_unit_tests + test( + test_name, + executable( + 'spdmd_' + test_name, + test_sources, + implicit_include_directories: false, + include_directories: include_directories( + '.', # cbor_test_util.hpp + '../..', # spdmd/ (for "composite/...") + ), + dependencies: [ + gtest, + gmock, + composite_dep, + ], + ), + workdir: meson.current_source_dir(), + ) +endforeach diff --git a/spdmd/tests/composite/run_standalone_tests.sh b/spdmd/tests/composite/run_standalone_tests.sh new file mode 100755 index 0000000..ee0cbd9 --- /dev/null +++ b/spdmd/tests/composite/run_standalone_tests.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Local dev helper: standalone build+run of the composite attestation unit +# tests WITHOUT the full meson project (which pulls in libspdmcpp etc.). +# The canonical test path is meson (`meson test`); this script is only a +# fast inner-loop convenience. +# +# Requires: g++-13, system GTest, and mbedtls 3.6.1 static libs. Build the +# latter once with: make -C subprojects/mbedtls-3.6.1 -j lib +set -euo pipefail + +# Repo root = three levels up from this script (spdmd/tests/composite). +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../../.." && pwd)" +SPDMD="$ROOT/spdmd" +MBED="$ROOT/subprojects/mbedtls-3.6.1" +OUT="$ROOT/build_standalone" +mkdir -p "$OUT" + +CXX="g++-13" +TCBOR="$ROOT/subprojects/tinycbor-0.6.0/src" +CXXFLAGS="-std=c++23 -O0 -g -Wall -Wextra -DATTESTER_BACKEND_MOCK -I$SPDMD -I$MBED/include -I$TCBOR" +MBEDLIBS="$MBED/library/libmbedx509.a $MBED/library/libmbedcrypto.a" +GTESTLIBS="-lgtest -lgtest_main -lgmock -lpthread" + +echo "== compiling composite core + mock into libcomposite.a ==" +SRCS=( + "$SPDMD/composite/cbor_det.cpp" + "$SPDMD/composite/claims_set_builder.cpp" + "$SPDMD/composite/submodule_digest.cpp" + "$SPDMD/composite/collection_plan.cpp" + "$SPDMD/composite/evidence_builder.cpp" + "$SPDMD/composite/bundle_assembler.cpp" + "$SPDMD/composite/composite_orchestrator.cpp" + "$SPDMD/mock_attester/eat_builder.cpp" + "$SPDMD/mock_attester/mock_attester.cpp" + "$TCBOR/cborencoder.c" + "$TCBOR/cborparser.c" +) +OBJS=() +for s in "${SRCS[@]}"; do + o="$OUT/$(basename "${s%.*}").o" + if [[ "$s" == *.c ]]; then + gcc-13 -O0 -g -I"$TCBOR" -c "$s" -o "$o" + else + $CXX $CXXFLAGS -c "$s" -o "$o" + fi + OBJS+=("$o") +done +ar rcs "$OUT/libcomposite.a" "${OBJS[@]}" + +echo "== building test executables ==" +declare -A TESTS=( + [cbor_det_test]="$SPDMD/tests/composite/cbor_det_test.cpp" + [claims_set_builder_test]="$SPDMD/tests/composite/claims_set_builder_test.cpp" + [submodule_digest_test]="$SPDMD/tests/composite/submodule_digest_test.cpp" + [collection_plan_test]="$SPDMD/tests/composite/collection_plan_test.cpp" + [evidence_builder_test]="$SPDMD/tests/composite/evidence_builder_test.cpp" + [bundle_assembler_test]="$SPDMD/tests/composite/bundle_assembler_test.cpp" + [evidence_pattern_test]="$SPDMD/tests/composite/evidence_pattern_test.cpp" + [eat_builder_test]="$SPDMD/tests/mock_attester/eat_builder_test.cpp" + [mock_attester_test]="$SPDMD/tests/mock_attester/mock_attester_test.cpp" + [composite_orchestrator_test]="$SPDMD/tests/mock_attester/composite_orchestrator_test.cpp" +) +for name in "${!TESTS[@]}"; do + $CXX $CXXFLAGS "${TESTS[$name]}" "$OUT/libcomposite.a" \ + $MBEDLIBS $GTESTLIBS -o "$OUT/$name" +done + +echo "== running tests ==" +rc=0 +for name in cbor_det_test claims_set_builder_test submodule_digest_test \ + collection_plan_test evidence_builder_test bundle_assembler_test \ + evidence_pattern_test eat_builder_test \ + mock_attester_test composite_orchestrator_test; do + echo "---- $name ----" + "$OUT/$name" --gtest_brief=0 2>&1 | tail -4 || rc=1 +done +exit $rc diff --git a/spdmd/tests/composite/submodule_digest_test.cpp b/spdmd/tests/composite/submodule_digest_test.cpp new file mode 100644 index 0000000..436afbb --- /dev/null +++ b/spdmd/tests/composite/submodule_digest_test.cpp @@ -0,0 +1,104 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2024 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// Unit tests for SubmoduleDigest — SHA-384 correctness and the +// digest-over-encoded-Claims-Set round-trip . + +#include "composite/claims_set_builder.hpp" +#include "composite/submodule_digest.hpp" + +#include +#include +#include + +#include + +namespace spdmd::composite +{ +namespace +{ + +std::string toHex(std::span b) +{ + static constexpr char d[] = "0123456789abcdef"; + std::string s; + for (auto x : b) + { + s.push_back(d[x >> 4]); + s.push_back(d[x & 0xF]); + } + return s; +} + +TEST(SubmoduleDigest, Sha384KnownVector) +{ + // SHA-384("abc") NIST test vector. + std::vector abc{'a', 'b', 'c'}; + auto h = sha384(abc); + EXPECT_EQ(toHex(h), + "cb00753f45a35e8bb5a03d699ac65007272c32ab0eded1631a8b605a43ff" + "5bed8086072ba1e7cc2358baeca134c825a7"); +} + +TEST(SubmoduleDigest, Sha384EmptyVector) +{ + std::vector empty; + auto h = sha384(empty); + EXPECT_EQ(toHex(h), + "38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6" + "e1da274edebfe76f65fbd51ad2f14898b95b"); +} + +TEST(SubmoduleDigest, RecordMatchesClaimsSetDigest) +{ + CollectedEvidence e; + e.environmentId = "env.gpu.0"; + e.success = true; + e.pattern = EvidencePattern::SpdmMeasurements; + e.signedMeasurements = {0x01, 0x02, 0x03}; + e.certificateChainDer = {0x30, 0x01, 0xAA}; + + auto cs = buildClaimsSet(e); + auto rec = makeSubmoduleRecord(e.environmentId, cs); + + EXPECT_EQ(rec.environmentId, "env.gpu.0"); + EXPECT_EQ(rec.hashAlgId, kCoseAlgSha384); + // The record digest is exactly SHA-384 over the encoded Claims-Set + // bytes — the value a verifier recomputes. + auto expected = sha384(cs); + EXPECT_EQ(std::vector(rec.digest.begin(), rec.digest.end()), + std::vector(expected.begin(), expected.end())); +} + +TEST(SubmoduleDigest, DigestIsOverUnwrappedBytes) +{ + // Sanity: the digest is over the Claims-Set bytes, not a wrapped or + // hex form. Two distinct Claims-Sets must produce distinct digests. + CollectedEvidence a; + a.success = true; + a.signedMeasurements = {0x01}; + a.certificateChainDer = {0x30, 0x01, 0x02}; + CollectedEvidence b = a; + b.signedMeasurements = {0x09}; + + auto ra = makeSubmoduleRecord("env.a", buildClaimsSet(a)); + auto rb = makeSubmoduleRecord("env.b", buildClaimsSet(b)); + EXPECT_NE(ra.digest, rb.digest); +} + +} // namespace +} // namespace spdmd::composite diff --git a/spdmd/tests/meson.build b/spdmd/tests/meson.build index 1966b64..5883c98 100644 --- a/spdmd/tests/meson.build +++ b/spdmd/tests/meson.build @@ -72,3 +72,12 @@ foreach test_name, sources : special_tests workdir: meson.current_source_dir(), ) endforeach + +if composite_attestation.enabled() + if attester_backend == 'mock' + subdir('mock_attester') + endif + + # Vendor-neutral composite attestation unit tests (no backend required). + subdir('composite') +endif diff --git a/spdmd/tests/mock_attester/composite_orchestrator_test.cpp b/spdmd/tests/mock_attester/composite_orchestrator_test.cpp new file mode 100644 index 0000000..c377647 --- /dev/null +++ b/spdmd/tests/mock_attester/composite_orchestrator_test.cpp @@ -0,0 +1,243 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2024 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// End-to-end test for CompositeOrchestrator against MockAttester. Builds +// the tag-602 bundle and runs the verifier-side check: +// for every signed submods entry, recompute SHA-384 over the matching +// detached Claims-Set and compare to the signed digest. + +#include "../composite/cbor_test_util.hpp" +#include "composite/bundle_assembler.hpp" +#include "composite/composite_orchestrator.hpp" +#include "composite/submodule_digest.hpp" +#include "mock_attester/mock_attester.hpp" + +#include +#include +#include + +#include + +namespace spdmd +{ +namespace +{ + +composite::CollectedEvidence spdmDev(const std::string& env, std::uint8_t eid, + std::uint8_t fill) +{ + composite::CollectedEvidence e; + e.environmentId = env; + e.eid = eid; + e.success = true; + e.pattern = composite::EvidencePattern::SpdmMeasurements; + e.signedMeasurements = {fill, std::uint8_t(fill + 1), 0x03}; + e.certificateChainDer = {0x30, 0x01, fill}; + return e; +} + +composite::CollectedEvidence failedDev(const std::string& env, std::uint8_t eid) +{ + composite::CollectedEvidence e; + e.environmentId = env; + e.eid = eid; + e.success = false; + e.errorMsg = "collection failed"; + return e; +} + +std::array nonce(std::uint8_t v) +{ + std::array n{}; + n.fill(v); + return n; +} + +TEST(CompositeOrchestrator, ProducesBundleAllSuccess) +{ + mock_attester::MockAttester att; + CompositeOrchestrator orch(att); + + std::vector evs{ + spdmDev("env.gpu.0", 13, 0x10), + spdmDev("env.nic.0", 64, 0x20), + }; + auto n = nonce(0x01); + auto res = orch.produce(n, evs); + + ASSERT_TRUE(res.success) << res.errorMsg; + EXPECT_EQ(res.status.totalDevices, 2u); + EXPECT_EQ(res.status.devicesSucceeded, 2u); + EXPECT_EQ(res.status.devicesFailed, 0u); + EXPECT_TRUE(res.status.tokenProduced); + EXPECT_EQ(res.status.toStatusString(), "Success"); + EXPECT_EQ(res.status.platformAttesterStatus, "SoftwareMock"); + EXPECT_FALSE(res.bundle.empty()); +} + +TEST(CompositeOrchestrator, PartialSuccessExcludesFailedDevice) +{ + mock_attester::MockAttester att; + CompositeOrchestrator orch(att); + + std::vector evs{ + spdmDev("env.gpu.0", 13, 0x10), + failedDev("env.nic.0", 64), + }; + auto n = nonce(0x02); + auto res = orch.produce(n, evs); + + ASSERT_TRUE(res.success) << res.errorMsg; + EXPECT_EQ(res.status.devicesSucceeded, 1u); + EXPECT_EQ(res.status.devicesFailed, 1u); + EXPECT_EQ(res.status.toStatusString(), "PartialSuccess"); + ASSERT_EQ(res.status.deviceFailures.size(), 1u); + EXPECT_EQ(res.status.deviceFailures[0].eid, 64u); + EXPECT_EQ(res.status.deviceFailures[0].environmentId, "env.nic.0"); + EXPECT_EQ(res.status.deviceFailures[0].errorMsg, "collection failed"); + + // The failed device must appear in neither submods nor the detached + // Claims-Set map. + auto bundle = cbortest::decode(res.bundle); + auto csMap = bundle->tagged->array[1]; + EXPECT_TRUE(csMap->atText("env.gpu.0")); + EXPECT_EQ(csMap->atText("env.nic.0"), nullptr); +} + +TEST(CompositeOrchestrator, AllFailedDevicesStillProduceBundle) +{ + mock_attester::MockAttester att; + CompositeOrchestrator orch(att); + std::vector evs{ + failedDev("env.gpu.0", 13), failedDev("env.nic.0", 64)}; + + auto res = orch.produce(nonce(0x03), evs); + + ASSERT_TRUE(res.success) << res.errorMsg; + EXPECT_EQ(res.status.devicesSucceeded, 0u); + EXPECT_EQ(res.status.devicesFailed, 2u); + EXPECT_EQ(res.status.toStatusString(), "PartialSuccess"); + auto bundle = cbortest::decode(res.bundle); + auto csMap = bundle->tagged->array[1]; + ASSERT_TRUE(csMap->isMap()); + EXPECT_TRUE(csMap->map.empty()); +} + +TEST(CompositeOrchestrator, EmptyEvidenceStillProducesBundle) +{ + mock_attester::MockAttester att; + CompositeOrchestrator orch(att); + std::vector evs; + + auto res = orch.produce(nonce(0x04), evs); + + ASSERT_TRUE(res.success) << res.errorMsg; + EXPECT_EQ(res.status.toStatusString(), "Success"); + auto bundle = cbortest::decode(res.bundle); + EXPECT_TRUE(bundle->tagged->array[1]->map.empty()); +} + +TEST(CompositeOrchestrator, MalformedEvidenceReportsFailureReason) +{ + mock_attester::MockAttester att; + CompositeOrchestrator orch(att); + auto malformed = spdmDev("env.gpu.0", 13, 0x10); + malformed.signedMeasurements.clear(); + std::vector evs{std::move(malformed)}; + + auto res = orch.produce(nonce(0x05), evs); + + ASSERT_TRUE(res.success) << res.errorMsg; + ASSERT_EQ(res.status.deviceFailures.size(), 1u); + EXPECT_EQ(res.status.deviceFailures[0].environmentId, "env.gpu.0"); + EXPECT_NE(res.status.deviceFailures[0].errorMsg.find( + "signed_measurements is empty"), + std::string::npos); +} + +// The core the composite attestation profile step-7 verifier check. +TEST(CompositeOrchestrator, SubmodDigestsMatchDetachedClaimsSets) +{ + mock_attester::MockAttester att; + CompositeOrchestrator orch(att); + + std::vector evs{ + spdmDev("env.gpu.0", 13, 0x10), + spdmDev("env.nic.0", 64, 0x20), + spdmDev("env.cpu.0", 29, 0x30), + }; + auto n = nonce(0x07); + auto res = orch.produce(n, evs); + ASSERT_TRUE(res.success) << res.errorMsg; + + // Decode bundle: tag-602 [ main-token (bstr), { env => bstr.cbor cs } ]. + auto bundle = cbortest::decode(res.bundle); + ASSERT_TRUE(bundle->isTag()); + ASSERT_EQ(bundle->tag, composite::kCborTagDetachedEatBundle); + auto mainTokenBytes = bundle->tagged->array[0]->bytes; + auto csMap = bundle->tagged->array[1]; + + // Decode the signed EAT to read submods (266). + auto cwt = cbortest::decode(mainTokenBytes); + auto payload = cwt->tagged->tagged->array[2]->bytes; + auto claims = cbortest::decode(payload); + auto submods = claims->atInt(266); + ASSERT_TRUE(submods && submods->isMap()); + ASSERT_EQ(submods->map.size(), 3u); + + // For each submod, recompute the digest over the detached Claims-Set. + for (const auto& [k, v] : submods->map) + { + ASSERT_TRUE(k->isText()); + const std::string& env = k->text; + ASSERT_TRUE(v->isArray()); + ASSERT_EQ(v->array.size(), 2u); + EXPECT_EQ(v->array[0]->ival, composite::kCoseAlgSha384); + const auto& signedDigest = v->array[1]->bytes; + + auto detached = csMap->atText(env); + ASSERT_TRUE(detached && detached->isBytes()) + << "missing detached claims-set for " << env; + + auto recomputed = composite::sha384(detached->bytes); + EXPECT_EQ( + std::vector(recomputed.begin(), recomputed.end()), + signedDigest) + << "digest mismatch for " << env; + } +} + +TEST(CompositeOrchestrator, NonceBoundIntoSignedToken) +{ + mock_attester::MockAttester att; + CompositeOrchestrator orch(att); + std::vector evs{ + spdmDev("env.gpu.0", 13, 0x10)}; + auto n = nonce(0x9C); + auto res = orch.produce(n, evs); + ASSERT_TRUE(res.success) << res.errorMsg; + + auto bundle = cbortest::decode(res.bundle); + auto cwt = cbortest::decode(bundle->tagged->array[0]->bytes); + auto claims = cbortest::decode(cwt->tagged->tagged->array[2]->bytes); + auto nonceClaim = claims->atInt(10); + ASSERT_TRUE(nonceClaim && nonceClaim->isBytes()); + EXPECT_EQ(nonceClaim->bytes, std::vector(n.begin(), n.end())); +} + +} // namespace +} // namespace spdmd diff --git a/spdmd/tests/mock_attester/eat_builder_test.cpp b/spdmd/tests/mock_attester/eat_builder_test.cpp new file mode 100644 index 0000000..1169891 --- /dev/null +++ b/spdmd/tests/mock_attester/eat_builder_test.cpp @@ -0,0 +1,231 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2024 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// Unit tests for the composite EAT builder — checks the claims map shape +// , submods as detached digests, measurements, the optional +// CoRIM locator, the COSE protected header, Sig_Structure, and the CWT +// envelope tags. + +#include "../composite/cbor_test_util.hpp" +#include "mock_attester/eat_builder.hpp" + +#include +#include +#include +#include + +#include + +namespace spdmd::mock_attester::eat +{ +namespace +{ + +using composite::LeadAttesterMeasurement; +using composite::SubmoduleRecord; + +std::array nonceFill(std::uint8_t v) +{ + std::array n{}; + n.fill(v); + return n; +} + +SubmoduleRecord recordFor(const std::string& env, std::uint8_t fill) +{ + SubmoduleRecord r; + r.environmentId = env; + r.hashAlgId = composite::kCoseAlgSha384; + r.digest.fill(fill); + return r; +} + +// EAT claim keys. +constexpr std::int64_t kNonce = 10; +constexpr std::int64_t kUeid = 256; +constexpr std::int64_t kProfile = 265; +constexpr std::int64_t kSubmods = 266; +constexpr std::int64_t kMeasurements = 273; +constexpr std::int64_t kCorimId = -75000; + +std::vector buildSample(bool withCorim, + std::span recs) +{ + auto nonce = nonceFill(0xAB); + std::vector ueid{0x01, 0x02, 0x03, 0x04}; + std::vector meas; + LeadAttesterMeasurement m; + m.contentFormat = 42; + m.value = {0xDE, 0xAD, 0xBE, 0xEF}; + meas.push_back(m); + + std::optional corim; + if (withCorim) + { + corim = "tag:example.com,2026:platform-corim:v7"; + } + return buildCompositeClaims( + std::span{nonce}, ueid, + "tag:example,2026:platform-composite-attestation-v1", recs, meas, + corim); +} + +TEST(EatBuilder, ClaimsTopLevelKeys) +{ + auto recs = std::vector{recordFor("env.gpu.0", 0x11)}; + auto claims = buildSample(false, recs); + auto root = cbortest::decode(claims); + ASSERT_TRUE(root->isMap()); + + // Mandatory: nonce, ueid, profile, submods, measurements. + auto nonce = root->atInt(kNonce); + ASSERT_TRUE(nonce && nonce->isBytes()); + EXPECT_EQ(nonce->bytes.size(), 32u); + + auto ueid = root->atInt(kUeid); + ASSERT_TRUE(ueid && ueid->isBytes()); + EXPECT_EQ(ueid->bytes, (std::vector{0x01, 0x02, 0x03, 0x04})); + + auto profile = root->atInt(kProfile); + ASSERT_TRUE(profile && profile->isText()); + EXPECT_EQ(profile->text, + "tag:example,2026:platform-composite-attestation-v1"); + + ASSERT_TRUE(root->atInt(kSubmods)); + ASSERT_TRUE(root->atInt(kMeasurements)); + // No CoRIM locator when not supplied. + EXPECT_EQ(root->atInt(kCorimId), nullptr); +} + +TEST(EatBuilder, SubmodsAreDetachedDigests) +{ + std::vector recs{recordFor("env.gpu.0", 0x11), + recordFor("env.nic.0", 0x22)}; + auto claims = buildSample(false, recs); + auto root = cbortest::decode(claims); + auto submods = root->atInt(kSubmods); + ASSERT_TRUE(submods && submods->isMap()); + EXPECT_EQ(submods->map.size(), 2u); + + auto gpu = submods->atText("env.gpu.0"); + ASSERT_TRUE(gpu && gpu->isArray()); + ASSERT_EQ(gpu->array.size(), 2u); + // [hash-alg, digest] + EXPECT_EQ(gpu->array[0]->ival, composite::kCoseAlgSha384); + ASSERT_TRUE(gpu->array[1]->isBytes()); + EXPECT_EQ(gpu->array[1]->bytes.size(), 48u); + EXPECT_EQ(gpu->array[1]->bytes[0], 0x11); +} + +TEST(EatBuilder, MeasurementsArrayShape) +{ + std::vector recs{recordFor("env.rot", 0x33)}; + auto claims = buildSample(false, recs); + auto root = cbortest::decode(claims); + auto meas = root->atInt(kMeasurements); + ASSERT_TRUE(meas && meas->isArray()); + ASSERT_EQ(meas->array.size(), 1u); + auto entry = meas->array[0]; + ASSERT_TRUE(entry->isMap()); + auto cf = entry->atText("content-format"); + ASSERT_TRUE(cf && cf->isUint()); + EXPECT_EQ(cf->uarg, 42u); + auto val = entry->atText("value"); + ASSERT_TRUE(val && val->isBytes()); + EXPECT_EQ(val->bytes, (std::vector{0xDE, 0xAD, 0xBE, 0xEF})); +} + +TEST(EatBuilder, MeasurementRequiresContentFormat) +{ + auto nonce = nonceFill(0xAB); + std::vector ueid{0x01}; + std::vector meas(1); + meas[0].value = {0xDE}; + + EXPECT_THROW(buildCompositeClaims( + std::span{nonce}, ueid, + "tag:example,2026:profile", {}, meas, std::nullopt), + std::invalid_argument); +} + +TEST(EatBuilder, OptionalCorimLocator) +{ + std::vector recs{recordFor("env.gpu.0", 0x11)}; + auto claims = buildSample(true, recs); + auto root = cbortest::decode(claims); + auto corim = root->atInt(kCorimId); + ASSERT_TRUE(corim && corim->isText()); + EXPECT_EQ(corim->text, "tag:example.com,2026:platform-corim:v7"); +} + +TEST(EatBuilder, ProtectedHeader) +{ + auto hdr = buildProtectedHeader(); + auto root = cbortest::decode(hdr); + ASSERT_TRUE(root->isMap()); + auto alg = root->atInt(1); + ASSERT_TRUE(alg && alg->isInt()); + EXPECT_EQ(alg->ival, kAlgEs384); + auto ct = root->atInt(3); + ASSERT_TRUE(ct && ct->isText()); + EXPECT_EQ(ct->text, "application/eat+cwt"); +} + +TEST(EatBuilder, SigStructureShape) +{ + std::vector prot{0x01}; + std::vector payload{0x02, 0x03}; + auto sig = buildSigStructure(prot, payload); + auto root = cbortest::decode(sig); + ASSERT_TRUE(root->isArray()); + ASSERT_EQ(root->array.size(), 4u); + EXPECT_EQ(root->array[0]->text, "Signature1"); + EXPECT_EQ(root->array[1]->bytes, prot); + EXPECT_TRUE(root->array[2]->isBytes()); + EXPECT_EQ(root->array[2]->bytes.size(), 0u); // empty external_aad + EXPECT_EQ(root->array[3]->bytes, payload); +} + +TEST(EatBuilder, CwtCoseSign1Tags) +{ + std::vector prot{0x01}; + std::vector> chain{{0xAA, 0xBB}}; + std::vector payload{0x02}; + std::vector sig(96, 0x05); + + auto token = assembleCwtCoseSign1(prot, chain, payload, sig); + auto cwt = cbortest::decode(token); + ASSERT_TRUE(cwt->isTag()); + EXPECT_EQ(cwt->tag, 61u); // CWT + auto cose = cwt->tagged; + ASSERT_TRUE(cose->isTag()); + EXPECT_EQ(cose->tag, 18u); // COSE_Sign1 + auto arr = cose->tagged; + ASSERT_TRUE(arr->isArray()); + ASSERT_EQ(arr->array.size(), 4u); + + // unprotected header carries x5chain (33). + auto unprot = arr->array[1]; + ASSERT_TRUE(unprot->isMap()); + auto x5 = unprot->atInt(33); + ASSERT_TRUE(x5 && x5->isArray()); + ASSERT_EQ(x5->array.size(), 1u); + EXPECT_EQ(x5->array[0]->bytes, (std::vector{0xAA, 0xBB})); +} + +} // namespace +} // namespace spdmd::mock_attester::eat diff --git a/spdmd/tests/mock_attester/meson.build b/spdmd/tests/mock_attester/meson.build new file mode 100644 index 0000000..e3ecafe --- /dev/null +++ b/spdmd/tests/mock_attester/meson.build @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Mock attester unit tests. Built only when attester-backend=mock. + +mock_attester_tests = { + 'eat_builder_test': files('eat_builder_test.cpp'), + 'mock_attester_test': files('mock_attester_test.cpp'), + # CompositeOrchestrator is vendor-neutral but the end-to-end test + # exercises it against MockAttester, so it lives here. + 'composite_orchestrator_test': files('composite_orchestrator_test.cpp'), +} + +foreach test_name, test_sources : mock_attester_tests + test( + test_name, + executable( + 'spdmd_' + test_name, + test_sources, + implicit_include_directories: false, + include_directories: include_directories( + '..', # tests/ (for composite/cbor_test_util.hpp) + '../..', # spdmd/ (for "mock_attester/..." & "composite/...") + '../../..', # project root + ), + dependencies: [ + gtest, + gmock, + mock_attester_dep, + composite_dep, + ], + ), + workdir: meson.current_source_dir(), + ) +endforeach diff --git a/spdmd/tests/mock_attester/mock_attester_test.cpp b/spdmd/tests/mock_attester/mock_attester_test.cpp new file mode 100644 index 0000000..7516177 --- /dev/null +++ b/spdmd/tests/mock_attester/mock_attester_test.cpp @@ -0,0 +1,206 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2022-2024 NVIDIA CORPORATION & + * AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +// Unit tests for MockAttester — full generateCompositeEat() round-trip: +// decodes the CWT(COSE_Sign1) token, extracts the embedded x5chain, +// parses the leaf cert with mbedtls, verifies the ES384 signature over +// the COSE Sig_Structure, and checks the nonce / ueid / submods claims. + +#include "../composite/cbor_test_util.hpp" +#include "mock_attester/eat_builder.hpp" +#include "mock_attester/mock_attester.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include + +namespace spdmd::mock_attester +{ +namespace +{ + +using composite::CompositeEatRequest; +using composite::SubmoduleRecord; + +SubmoduleRecord recordFor(const std::string& env, std::uint8_t fill) +{ + SubmoduleRecord r; + r.environmentId = env; + r.hashAlgId = composite::kCoseAlgSha384; + r.digest.fill(fill); + return r; +} + +CompositeEatRequest sampleRequest() +{ + CompositeEatRequest req; + req.nonce.fill(0x5A); + req.deviceRecords.push_back(recordFor("env.gpu.0", 0x11)); + req.deviceRecords.push_back(recordFor("env.nic.0", 0x22)); + return req; +} + +// Verify ES384 over the COSE Sig_Structure with the leaf cert pubkey. +bool verifyCose(const std::vector& leafDer, + const std::vector& protectedHdr, + const std::vector& payload, + const std::vector& sig96) +{ + if (sig96.size() != 96) + { + return false; + } + mbedtls_x509_crt crt; + mbedtls_x509_crt_init(&crt); + if (mbedtls_x509_crt_parse_der(&crt, leafDer.data(), leafDer.size()) != 0) + { + mbedtls_x509_crt_free(&crt); + return false; + } + + auto sigStruct = eat::buildSigStructure(protectedHdr, payload); + std::array hash{}; + bool ok = mbedtls_md(mbedtls_md_info_from_type(MBEDTLS_MD_SHA384), + sigStruct.data(), sigStruct.size(), hash.data()) == 0; + + mbedtls_mpi r; + mbedtls_mpi s; + mbedtls_mpi_init(&r); + mbedtls_mpi_init(&s); + ok = ok && mbedtls_mpi_read_binary(&r, sig96.data(), 48) == 0; + ok = ok && mbedtls_mpi_read_binary(&s, sig96.data() + 48, 48) == 0; + + if (ok) + { + mbedtls_ecp_keypair* kp = mbedtls_pk_ec(crt.pk); + ok = mbedtls_ecdsa_verify(&kp->MBEDTLS_PRIVATE(grp), hash.data(), + hash.size(), &kp->MBEDTLS_PRIVATE(Q), &r, + &s) == 0; + } + + mbedtls_mpi_free(&r); + mbedtls_mpi_free(&s); + mbedtls_x509_crt_free(&crt); + return ok; +} + +TEST(MockAttester, ReadyAndStatus) +{ + MockAttester att; + EXPECT_EQ(att.getStatus(), PlatformAttesterStatus::SoftwareMock); + EXPECT_FALSE(att.getCertChainPEM().empty()); + EXPECT_EQ(att.getUeid().size(), 16u); +} + +TEST(MockAttester, AttestCompositeSucceeds) +{ + MockAttester att; + auto res = att.generateCompositeEat(sampleRequest()); + ASSERT_TRUE(res.success) << res.errorMsg; + EXPECT_FALSE(res.compositeEat.empty()); +} + +TEST(MockAttester, TokenSignatureVerifies) +{ + MockAttester att; + auto res = att.generateCompositeEat(sampleRequest()); + ASSERT_TRUE(res.success) << res.errorMsg; + + // Unwrap CWT(61) -> COSE_Sign1(18) -> [protected, unprot, payload, sig]. + auto cwt = cbortest::decode(res.compositeEat); + ASSERT_TRUE(cwt->isTag()); + ASSERT_EQ(cwt->tag, 61u); + auto cose = cwt->tagged; + ASSERT_TRUE(cose->isTag()); + ASSERT_EQ(cose->tag, 18u); + auto arr = cose->tagged; + ASSERT_TRUE(arr->isArray()); + ASSERT_EQ(arr->array.size(), 4u); + + const auto& protectedHdr = arr->array[0]->bytes; + const auto& payload = arr->array[2]->bytes; + const auto& sig = arr->array[3]->bytes; + + auto x5 = arr->array[1]->atInt(33); + ASSERT_TRUE(x5 && x5->isArray()); + ASSERT_GE(x5->array.size(), 1u); + const auto& leafDer = x5->array[0]->bytes; + + EXPECT_TRUE(verifyCose(leafDer, protectedHdr, payload, sig)); +} + +TEST(MockAttester, ClaimsCarryNonceUeidAndSubmods) +{ + MockAttester att; + auto req = sampleRequest(); + auto res = att.generateCompositeEat(req); + ASSERT_TRUE(res.success) << res.errorMsg; + + auto cwt = cbortest::decode(res.compositeEat); + auto payload = cwt->tagged->tagged->array[2]->bytes; + auto claims = cbortest::decode(payload); + + // nonce (10) echoes the request nonce. + auto nonce = claims->atInt(10); + ASSERT_TRUE(nonce && nonce->isBytes()); + EXPECT_EQ(nonce->bytes, + std::vector(req.nonce.begin(), req.nonce.end())); + + // ueid (256) equals the attester's identity. + auto ueid = claims->atInt(256); + ASSERT_TRUE(ueid && ueid->isBytes()); + auto attUeid = att.getUeid(); + EXPECT_EQ(ueid->bytes, + std::vector(attUeid.begin(), attUeid.end())); + + // submods (266) carries both env keys. + auto submods = claims->atInt(266); + ASSERT_TRUE(submods && submods->isMap()); + EXPECT_TRUE(submods->atText("env.gpu.0")); + EXPECT_TRUE(submods->atText("env.nic.0")); + + // measurements (273) present. + ASSERT_TRUE(claims->atInt(273)); +} + +TEST(MockAttester, CorimLocatorPlumbedThrough) +{ + MockAttester att; + auto req = sampleRequest(); + req.platformCorimLocator = "tag:example.com,2026:platform-corim:v7"; + auto res = att.generateCompositeEat(req); + ASSERT_TRUE(res.success) << res.errorMsg; + + auto cwt = cbortest::decode(res.compositeEat); + auto payload = cwt->tagged->tagged->array[2]->bytes; + auto claims = cbortest::decode(payload); + auto corim = claims->atInt(-75000); + ASSERT_TRUE(corim && corim->isText()); + EXPECT_EQ(corim->text, "tag:example.com,2026:platform-corim:v7"); +} + +} // namespace +} // namespace spdmd::mock_attester diff --git a/subprojects/packagefiles/tinycbor/meson.build b/subprojects/packagefiles/tinycbor/meson.build new file mode 100644 index 0000000..c4d2c0b --- /dev/null +++ b/subprojects/packagefiles/tinycbor/meson.build @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: MIT +# +# Meson build file for Intel tinycbor (Concise Binary Object +# Representation library — RFC 8949). Used as a packagefile patch +# because upstream only ships Makefiles. + +project( + 'tinycbor', + 'c', + version: '0.6.0', + license: 'MIT', + default_options: ['c_std=c11'], +) + +tinycbor_sources = files( + 'src/cborencoder.c', + 'src/cborencoder_close_container_checked.c', + 'src/cborencoder_float.c', + 'src/cborerrorstrings.c', + 'src/cborparser.c', + 'src/cborparser_float.c', + 'src/cborvalidation.c', + 'src/cbortojson.c', + 'src/cborpretty.c', +) + +tinycbor_inc = include_directories('src') + +tinycbor_lib = static_library( + 'tinycbor', + tinycbor_sources, + include_directories: tinycbor_inc, + install: false, +) + +tinycbor_dep = declare_dependency( + include_directories: tinycbor_inc, + link_with: tinycbor_lib, +) diff --git a/subprojects/tinycbor.wrap b/subprojects/tinycbor.wrap new file mode 100644 index 0000000..8f03f44 --- /dev/null +++ b/subprojects/tinycbor.wrap @@ -0,0 +1,9 @@ +[wrap-file] +directory = tinycbor-0.6.0 +source_url = https://github.com/intel/tinycbor/archive/refs/tags/v0.6.0.tar.gz +source_filename = tinycbor-0.6.0.tar.gz +source_hash = 512e2c9fce74f60ef9ed3af59161e905f9e19f30a52e433fc55f39f4c70d27e4 +patch_directory = tinycbor + +[provide] +tinycbor = tinycbor_dep