From 8c0765396123f678925b5c4e6105dd04de70f254 Mon Sep 17 00:00:00 2001 From: Amaury Chamayou Date: Mon, 10 Jun 2024 20:59:54 +0100 Subject: [PATCH] Verify UVM endorsements signed with ECDSA (#6243) --- .snpcc_canary | 2 +- CHANGELOG.md | 1 + CMakeLists.txt | 12 ++++ include/ccf/crypto/cose_verifier.h | 5 +- src/crypto/openssl/cose_verifier.cpp | 16 ++--- src/crypto/openssl/cose_verifier.h | 16 ++++- src/endpoints/authentication/cose_auth.cpp | 4 +- src/node/did.h | 3 +- src/node/test/endorsements.cpp | 79 +++++++++++++++++++++ src/node/uvm_endorsements.h | 53 ++++++++++---- tests/uvm_endorsements/ecdsa_test1.cose | Bin 0 -> 1721 bytes tests/uvm_endorsements/rsa_test1.cose | Bin 0 -> 10845 bytes 12 files changed, 159 insertions(+), 32 deletions(-) create mode 100644 src/node/test/endorsements.cpp create mode 100644 tests/uvm_endorsements/ecdsa_test1.cose create mode 100644 tests/uvm_endorsements/rsa_test1.cose diff --git a/.snpcc_canary b/.snpcc_canary index a363e415e58..1e94f06b536 100644 --- a/.snpcc_canary +++ b/.snpcc_canary @@ -3,4 +3,4 @@ O \ o | / /-xXx--//-----x=x--/-xXx--/---x---->>>--/ ... -.. \ No newline at end of file +... \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index ede37832dff..9c69516ccca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - Added TypeScript `TypedKvSet` and `ccfapp.typedKv` to facilitate set handling from application code. +- Added support for UVM endorsements signed with EC keys (#6231). ### Removed diff --git a/CMakeLists.txt b/CMakeLists.txt index 4388baddd98..3d0eb11951c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -936,6 +936,18 @@ if(BUILD_TESTS) http_parser.host ) + add_unit_test( + endorsements_test + ${CMAKE_CURRENT_SOURCE_DIR}/src/node/test/endorsements.cpp + ) + set_property( + TEST endorsements_test + APPEND + PROPERTY + ENVIRONMENT + "TEST_ENDORSEMENTS_PATH=${CMAKE_CURRENT_SOURCE_DIR}/tests/uvm_endorsements" + ) + add_unit_test( historical_queries_test ${CMAKE_CURRENT_SOURCE_DIR}/src/node/test/historical_queries.cpp diff --git a/include/ccf/crypto/cose_verifier.h b/include/ccf/crypto/cose_verifier.h index ff474237730..0d30c270a41 100644 --- a/include/ccf/crypto/cose_verifier.h +++ b/include/ccf/crypto/cose_verifier.h @@ -22,6 +22,7 @@ namespace crypto using COSEVerifierUniquePtr = std::unique_ptr; - COSEVerifierUniquePtr make_cose_verifier(const std::vector& cert); - COSEVerifierUniquePtr make_cose_verifier(const RSAPublicKeyPtr& pubk_ptr); + COSEVerifierUniquePtr make_cose_verifier_from_cert( + const std::vector& cert); + COSEVerifierUniquePtr make_cose_verifier_from_key(const Pem& public_key); } diff --git a/src/crypto/openssl/cose_verifier.cpp b/src/crypto/openssl/cose_verifier.cpp index 5c5666655f3..f2fe746c6f8 100644 --- a/src/crypto/openssl/cose_verifier.cpp +++ b/src/crypto/openssl/cose_verifier.cpp @@ -19,7 +19,7 @@ namespace crypto { using namespace OpenSSL; - COSEVerifier_OpenSSL::COSEVerifier_OpenSSL( + COSECertVerifier_OpenSSL::COSECertVerifier_OpenSSL( const std::vector& certificate) { Unique_BIO certbio(certificate); @@ -53,10 +53,9 @@ namespace crypto } } - COSEVerifier_OpenSSL::COSEVerifier_OpenSSL(const RSAPublicKeyPtr& pubk_ptr) + COSEKeyVerifier_OpenSSL::COSEKeyVerifier_OpenSSL(const Pem& public_key_) { - public_key = - std::make_shared(pubk_ptr->public_key_pem()); + public_key = std::make_shared(public_key_); } COSEVerifier_OpenSSL::~COSEVerifier_OpenSSL() = default; @@ -92,13 +91,14 @@ namespace crypto return false; } - COSEVerifierUniquePtr make_cose_verifier(const std::vector& cert) + COSEVerifierUniquePtr make_cose_verifier_from_cert( + const std::vector& cert) { - return std::make_unique(cert); + return std::make_unique(cert); } - COSEVerifierUniquePtr make_cose_verifier(const RSAPublicKeyPtr& pubk_ptr) + COSEVerifierUniquePtr make_cose_verifier_from_key(const Pem& public_key) { - return std::make_unique(pubk_ptr); + return std::make_unique(public_key); } } diff --git a/src/crypto/openssl/cose_verifier.h b/src/crypto/openssl/cose_verifier.h index 34a4f28610f..a906f4860de 100644 --- a/src/crypto/openssl/cose_verifier.h +++ b/src/crypto/openssl/cose_verifier.h @@ -15,15 +15,25 @@ namespace crypto { class COSEVerifier_OpenSSL : public COSEVerifier { - private: + protected: std::shared_ptr public_key; public: - COSEVerifier_OpenSSL(const std::vector& certificate); - COSEVerifier_OpenSSL(const RSAPublicKeyPtr& pubk_ptr); virtual ~COSEVerifier_OpenSSL() override; virtual bool verify( const std::span& buf, std::span& authned_content) const override; }; + + class COSECertVerifier_OpenSSL : public COSEVerifier_OpenSSL + { + public: + COSECertVerifier_OpenSSL(const std::vector& certificate); + }; + + class COSEKeyVerifier_OpenSSL : public COSEVerifier_OpenSSL + { + public: + COSEKeyVerifier_OpenSSL(const Pem& public_key); + }; } diff --git a/src/endpoints/authentication/cose_auth.cpp b/src/endpoints/authentication/cose_auth.cpp index 2a81b0da756..1574982a3ae 100644 --- a/src/endpoints/authentication/cose_auth.cpp +++ b/src/endpoints/authentication/cose_auth.cpp @@ -302,7 +302,7 @@ namespace ccf auto member_cert = member_certs->get(phdr.kid); if (member_cert.has_value()) { - auto verifier = crypto::make_cose_verifier(member_cert->raw()); + auto verifier = crypto::make_cose_verifier_from_cert(member_cert->raw()); std::span body = { ctx->get_request_body().data(), ctx->get_request_body().size()}; @@ -441,7 +441,7 @@ namespace ccf auto user_cert = user_certs->get(phdr.kid); if (user_cert.has_value()) { - auto verifier = crypto::make_cose_verifier(user_cert->raw()); + auto verifier = crypto::make_cose_verifier_from_cert(user_cert->raw()); std::span body = { ctx->get_request_body().data(), ctx->get_request_body().size()}; diff --git a/src/node/did.h b/src/node/did.h index eb45f2ae3c9..929c504e835 100644 --- a/src/node/did.h +++ b/src/node/did.h @@ -18,8 +18,7 @@ namespace ccf::did std::string id; std::string type; std::string controller; - std::optional public_key_jwk = - std::nullopt; // Note: Only supports RSA for now + std::optional public_key_jwk = std::nullopt; bool operator==(const DIDDocumentVerificationMethod&) const = default; }; diff --git a/src/node/test/endorsements.cpp b/src/node/test/endorsements.cpp new file mode 100644 index 00000000000..de4f3524d94 --- /dev/null +++ b/src/node/test/endorsements.cpp @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. + +#include "ccf/pal/measurement.h" +#include "crypto/openssl/hash.h" +#include "ds/files.h" +#include "node/uvm_endorsements.h" + +#define DOCTEST_CONFIG_IMPLEMENT +#include +#include + +TEST_CASE("Check RSA Production endorsement") +{ + char* end_path = std::getenv("TEST_ENDORSEMENTS_PATH"); + REQUIRE(end_path != nullptr); + + auto endorsement = files::slurp(fmt::format("{}/rsa_test1.cose", end_path)); + REQUIRE(!endorsement.empty()); + + ccf::pal::SnpAttestationMeasurement measurement( + "02c3b0d5bf1d256fa4e3b5deefc07b55ff2f7029085ed350f60959140a1a51f1310753ba5a" + "b2c03a0536b1c0c193af47"); + ccf::pal::PlatformAttestationMeasurement uvm_measurement(measurement); + auto endorsements = + ccf::verify_uvm_endorsements(endorsement, uvm_measurement); + REQUIRE( + endorsements == + ccf::UVMEndorsements{ + "did:x509:0:sha256:I__iuL25oXEVFdTP_aBLx_eT1RPHbCQ_ECBQfYZpt9s::eku:1.3." + "6.1.4.1.311.76.59.1.2", + "ContainerPlat-AMD-UVM", + "100"}); +} + +TEST_CASE("Check ECDSA Test endorsement") +{ + char* end_path = std::getenv("TEST_ENDORSEMENTS_PATH"); + REQUIRE(end_path != nullptr); + + auto endorsement = files::slurp(fmt::format("{}/ecdsa_test1.cose", end_path)); + REQUIRE(!endorsement.empty()); + + ccf::pal::SnpAttestationMeasurement measurement( + "5a84c66e9c8dd1a991e6d8b43a8aaae488940f87ce25ef6a62ad180cc3c73554ed7e4ccd10" + "13456602758778d9d65c48"); + ccf::pal::PlatformAttestationMeasurement uvm_measurement(measurement); + REQUIRE_THROWS_WITH_AS( + ccf::verify_uvm_endorsements(endorsement, uvm_measurement), + "UVM endorsements did " + "did:x509:0:sha256:VFsRLNBh5Zy1HRtVl2IIXAl0lUs-xobEbskZ3XRDpCY::subject:CN:" + "Test%20Leaf%20%28DO%20NOT%20TRUST%29, feed ConfAKS-AMD-UVM-Test, svn 0 do " + "not match any of the known UVM roots of trust", + std::logic_error); + + std::vector custom_roots_of_trust = { + ccf::UVMEndorsements{ + "did:x509:0:sha256:VFsRLNBh5Zy1HRtVl2IIXAl0lUs-xobEbskZ3XRDpCY::subject:" + "CN:Test%20Leaf%20%28DO%20NOT%20TRUST%29", + "ConfAKS-AMD-UVM-Test", + "0"}}; + + auto endorsements = ccf::verify_uvm_endorsements( + endorsement, uvm_measurement, custom_roots_of_trust); + REQUIRE(endorsements == custom_roots_of_trust[0]); +} + +int main(int argc, char** argv) +{ + logger::config::default_init(); + crypto::openssl_sha256_init(); + doctest::Context context; + context.applyCommandLine(argc, argv); + int res = context.run(); + crypto::openssl_sha256_shutdown(); + if (context.shouldExit()) + return res; + return res; +} \ No newline at end of file diff --git a/src/node/uvm_endorsements.h b/src/node/uvm_endorsements.h index 7cd23077bd5..6e94a95dce0 100644 --- a/src/node/uvm_endorsements.h +++ b/src/node/uvm_endorsements.h @@ -4,6 +4,7 @@ #include "ccf/crypto/base64.h" #include "ccf/ds/json.h" +#include "ccf/pal/measurement.h" #include "ccf/service/tables/uvm_endorsements.h" #include "crypto/openssl/cose_verifier.h" #include "node/cose_common.h" @@ -52,19 +53,21 @@ namespace ccf }; // Roots of trust for UVM endorsements/measurement in AMD SEV-SNP attestations - static std::vector uvm_roots_of_trust = { + static std::vector default_uvm_roots_of_trust = { // Confidential Azure Kubertnetes Service (AKS) {"did:x509:0:sha256:I__iuL25oXEVFdTP_aBLx_eT1RPHbCQ_ECBQfYZpt9s::eku:1.3.6." "1.4.1.311.76.59.1.2", "ContainerPlat-AMD-UVM", - "0"}, + "100"}, // Confidential Azure Container Instances (ACI) {"did:x509:0:sha256:I__iuL25oXEVFdTP_aBLx_eT1RPHbCQ_ECBQfYZpt9s::eku:1.3.6." "1.4.1.311.76.59.1.5", "ConfAKS-AMD-UVM", "0"}}; - bool inline matches_uvm_roots_of_trust(const UVMEndorsements& endorsements) + bool inline matches_uvm_roots_of_trust( + const UVMEndorsements& endorsements, + const std::vector& uvm_roots_of_trust) { for (const auto& uvm_root_of_trust : uvm_roots_of_trust) { @@ -248,10 +251,10 @@ namespace ccf } static std::span verify_uvm_endorsements_signature( - const crypto::RSAPublicKeyPtr& leef_cert_pub_key, + const crypto::Pem& leaf_cert_pub_key, const std::vector& uvm_endorsements_raw) { - auto verifier = crypto::make_cose_verifier(leef_cert_pub_key); + auto verifier = crypto::make_cose_verifier_from_key(leaf_cert_pub_key); std::span payload; if (!verifier->verify(uvm_endorsements_raw, payload)) @@ -264,14 +267,16 @@ namespace ccf static UVMEndorsements verify_uvm_endorsements( const std::vector& uvm_endorsements_raw, - const pal::PlatformAttestationMeasurement& uvm_measurement) + const pal::PlatformAttestationMeasurement& uvm_measurement, + const std::vector& uvm_roots_of_trust = + default_uvm_roots_of_trust) { auto phdr = cose::decode_protected_header(uvm_endorsements_raw); - if (!cose::is_rsa_alg(phdr.alg)) + if (!(cose::is_rsa_alg(phdr.alg) || cose::is_ecdsa_alg(phdr.alg))) { - throw std::logic_error( - fmt::format("Signature algorithm {} is not expected RSA", phdr.alg)); + throw std::logic_error(fmt::format( + "Signature algorithm {} is not one of expected: RSA, ECDSA", phdr.alg)); } std::string pem_chain; @@ -292,17 +297,37 @@ namespace ccf did_document_str)); } - crypto::RSAPublicKeyPtr pubk = nullptr; + crypto::Pem pubk; for (auto const& vm : did_document.verification_method) { if (vm.controller == did && vm.public_key_jwk.has_value()) { - pubk = crypto::make_rsa_public_key(vm.public_key_jwk.value()); - break; + auto jwk = vm.public_key_jwk.value().get(); + switch (jwk.kty) + { + case crypto::JsonWebKeyType::RSA: + { + auto rsa_jwk = + vm.public_key_jwk->get(); + pubk = crypto::make_rsa_public_key(rsa_jwk)->public_key_pem(); + break; + } + case crypto::JsonWebKeyType::EC: + { + auto ec_jwk = vm.public_key_jwk->get(); + pubk = crypto::make_public_key(ec_jwk)->public_key_pem(); + break; + } + default: + { + throw std::logic_error(fmt::format( + "Unsupported public key type ({}) for DID {}", jwk.kty, did)); + } + } } } - if (pubk == nullptr) + if (pubk.empty()) { throw std::logic_error(fmt::format( "Could not find matching public key for DID {} in {}", @@ -341,7 +366,7 @@ namespace ccf UVMEndorsements end{did, phdr.feed, payload.sevsnpvm_guest_svn}; - if (!matches_uvm_roots_of_trust(end)) + if (!matches_uvm_roots_of_trust(end, uvm_roots_of_trust)) { throw std::logic_error(fmt::format( "UVM endorsements did {}, feed {}, svn {} " diff --git a/tests/uvm_endorsements/ecdsa_test1.cose b/tests/uvm_endorsements/ecdsa_test1.cose new file mode 100644 index 0000000000000000000000000000000000000000..e167c8c80a8a5a060f20f50b4365b0d42ea978cf GIT binary patch literal 1721 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 literal 0 HcmV?d00001