diff --git a/include/evse_security/evse_security.hpp b/include/evse_security/evse_security.hpp index bd04580..18366f8 100644 --- a/include/evse_security/evse_security.hpp +++ b/include/evse_security/evse_security.hpp @@ -229,7 +229,7 @@ class EvseSecurity { FRIEND_TEST(EvseSecurityTests, verify_full_filesystem_install_reject); FRIEND_TEST(EvseSecurityTests, verify_full_filesystem); FRIEND_TEST(EvseSecurityTests, verify_expired_csr_deletion); - FRIEND_TEST(EvseSecurityTests, verify_expired_leaf_deletion); + FRIEND_TEST(EvseSecurityTestsExpired, verify_expired_leaf_deletion); #endif }; diff --git a/lib/evse_security/evse_security.cpp b/lib/evse_security/evse_security.cpp index 4d5da39..ce235b2 100644 --- a/lib/evse_security/evse_security.cpp +++ b/lib/evse_security/evse_security.cpp @@ -1105,21 +1105,31 @@ void EvseSecurity::garbage_collect() { // Only handle if we have more than the minimum certificates entry if (expired_certs.get_certificate_chains_count() > DEFAULT_MINIMUM_CERTIFICATE_ENTRIES) { + fs::path key_directory = key_dir; int skipped = 0; // Order by expiry date, and keep even expired certificates with a minimum of 10 certificates expired_certs.for_each_chain_ordered( - [&invalid_certificate_files, &skipped](const fs::path& file, const std::vector& chain) { + [this, &invalid_certificate_files, &skipped, &key_directory](const fs::path& file, + const std::vector& chain) { // By default delete all empty if (chain.size() <= 0) { invalid_certificate_files.emplace(file); } - if (skipped++ > DEFAULT_MINIMUM_CERTIFICATE_ENTRIES) { + if (++skipped > DEFAULT_MINIMUM_CERTIFICATE_ENTRIES) { // If the chain contains the first expired (leafs are the first) if (chain.size()) { if (chain[0].is_expired()) { invalid_certificate_files.emplace(file); + + // Also attempt to add the key for deletion + try { + fs::path key_file = get_private_key_path_of_certificate(chain[0], key_directory, + this->private_key_password); + invalid_certificate_files.emplace(key_file); + } catch (NoPrivateKeyException& e) { + } } } } @@ -1139,8 +1149,10 @@ void EvseSecurity::garbage_collect() { } for (const auto& expired_certificate_file : invalid_certificate_files) { - EVLOG_debug << "Deleted expired certificate file: " << expired_certificate_file; - filesystem_utils::delete_file(expired_certificate_file); + if (filesystem_utils::delete_file(expired_certificate_file)) + EVLOG_debug << "Deleted expired certificate file: " << expired_certificate_file; + else + EVLOG_warning << "Error deleting expired certificate file: " << expired_certificate_file; } // In case of a reset, the managed CSRs can be lost. In that case add them back to the list @@ -1223,7 +1235,8 @@ bool EvseSecurity::is_filesystem_full() { EVLOG_debug << "Total entries used: " << total_entries; if (total_entries > max_fs_certificate_store_entries) { - EVLOG_warning << "Exceeded maximum entries: " << total_entries; + EVLOG_warning << "Exceeded maximum entries: " << max_fs_certificate_store_entries << " with :" << total_entries + << " total entries"; return true; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 471317b..05882f5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -59,6 +59,12 @@ install( FILES_MATCHING PATTERN "*" ) +install( + DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/expired_runtime" + DESTINATION "${CMAKE_BINARY_DIR}/tests" + FILES_MATCHING PATTERN "*" +) + install( PROGRAMS "${CMAKE_CURRENT_SOURCE_DIR}/create-pki.sh" DESTINATION "${CMAKE_BINARY_DIR}/tests" @@ -67,4 +73,4 @@ install( install( FILES "${CMAKE_CURRENT_SOURCE_DIR}/openssl-pki.conf" DESTINATION "${CMAKE_BINARY_DIR}/tests" -) +) \ No newline at end of file diff --git a/tests/expired_runtime/conf.cnf b/tests/expired_runtime/conf.cnf new file mode 100644 index 0000000..f26dcb8 --- /dev/null +++ b/tests/expired_runtime/conf.cnf @@ -0,0 +1,30 @@ +[ ca ] +default_ca = CA_default # The default ca section + +[ CA_default ] + +dir = . # top dir +database = expired_bulk/index.txt # index file. +new_certs_dir = $dir/expired_bulk # new certs dir + +certificate = $dir/cert.pem # The CA cert +serial = $dir/expired_bulk/serial # serial no file +private_key = $dir/cert.key # CA private key +RANDFILE = $dir/private/.rand # random number file + +default_days = 365 # how long to certify for +default_md = md5 # md to use + +policy = policy_any # default policy +email_in_dn = no # Don't add the email into cert DN + +name_opt = ca_default # Subject name display option +cert_opt = ca_default # Certificate display option +copy_extensions = none # Don't copy extensions from request + +[ policy_any ] +countryName = supplied +stateOrProvinceName = optional +organizationName = optional +organizationalUnitName = optional +commonName = supplied diff --git a/tests/tests.cpp b/tests/tests.cpp index 35b3ea7..72785ea 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -129,6 +129,9 @@ class EvseSecurityTests : public ::testing::Test { std::unique_ptr evse_security; void SetUp() override { + fs::remove_all("certs"); + fs::remove_all("csr"); + install_certs(); if (!fs::exists("key")) @@ -153,6 +156,154 @@ class EvseSecurityTests : public ::testing::Test { } }; +class EvseSecurityTestsExpired : public EvseSecurityTests { +protected: + static constexpr int GEN_CERTIFICATES = 30; + + std::set generated_bulk_certificates; + + void SetUp() override { + EvseSecurityTests::SetUp(); + fs::remove_all("expired_bulk"); + + fs::create_directory("expired_bulk"); + std::system("touch expired_bulk/index.txt"); + std::system("echo \"1000\" > expired_bulk/serial"); + + // Generate many expired certificates + int serial = 4096; // Hex 1000 + + // Generate N certificates, N-5 expired, 5 non-expired + std::time_t t = std::time(nullptr); + std::tm* const time_info = std::localtime(&t); + int current_year = 1900 + time_info->tm_year; + + for (int i = 0; i < GEN_CERTIFICATES; i++) { + std::string CN = "Pionix"; + CN += std::to_string(i); + + char buffer[2048]; + + bool expired = (i < (GEN_CERTIFICATES - 5)); + int start_year; + int end_year; + + if (expired) { + start_year = (current_year - 5 - i); + end_year = (current_year - 1 - i); + } else { + start_year = current_year; + end_year = (current_year + i); + } + + memset(buffer, 0, sizeof(buffer)); + std::sprintf( + buffer, + "openssl req -newkey rsa:512 -keyout expired_bulk/cert.key -out expired_bulk/cert.csr -nodes -subj " + "\"/C=DE/L=Schonborn/CN=[%s]/emailAddress=email@pionix.com\"", + CN.c_str()); + std::system(buffer); + + memset(buffer, 0, sizeof(buffer)); + std::sprintf( + buffer, + "openssl ca -selfsign -config expired_runtime/conf.cnf -batch -keyfile expired_bulk/cert.key -in " + "expired_bulk/cert.csr -out expired_bulk/cert.pem -notext -startdate %d1213000000Z -enddate " + "%d1213000000Z", + start_year, end_year); + std::system(buffer); + + // Copy certificates/keys over + std::string cert_filename = "expired_bulk/cert.pem"; + std::string ckey_filename = "expired_bulk/cert.key"; + + std::string target_cert = + std::string(expired ? "certs/client/cso/SECC_LEAF_EXPIRED_" : "certs/client/cso/SECC_LEAF_VALID_") + + +"st_" + std::to_string(start_year) + "_en_" + std::to_string(end_year) + ".pem"; + std::string target_ckey = + std::string(expired ? "certs/client/cso/SECC_LEAF_EXPIRED_" : "certs/client/cso/SECC_LEAF_VALID_") + + +"st_" + std::to_string(start_year) + "_en_" + std::to_string(end_year) + ".key"; + + fs::copy(cert_filename, target_cert); + fs::copy(ckey_filename, target_ckey); + + generated_bulk_certificates.emplace(target_cert); + generated_bulk_certificates.emplace(target_ckey); + + fs::remove(cert_filename); + fs::remove(ckey_filename); + } + } + + void TearDown() override { + EvseSecurityTests::TearDown(); + + fs::remove_all("expired_bulk"); + } +}; + +TEST_F(EvseSecurityTestsExpired, verify_expired_leaf_deletion) { + // Check that the FS is not full + ASSERT_FALSE(evse_security->is_filesystem_full()); + + // List of date sorted certificates + std::vector sorted; + std::vector sorded_should_delete; + std::vector sorded_should_keep; + + // Ensure that we have GEN_CERTIFICATES + 2 (CPO_CERT_CHAIN.pem + SECC_LEAF.pem) + { + X509CertificateBundle full_certs(fs::path("certs/client/cso"), EncodingFormat::PEM); + ASSERT_EQ(full_certs.get_certificate_chains_count(), GEN_CERTIFICATES + 2); + + full_certs.for_each_chain([&sorted](const fs::path& path, const std::vector& certifs) { + sorted.push_back(certifs.at(0)); + + return true; + }); + + ASSERT_EQ(sorted.size(), GEN_CERTIFICATES + 2); + } + + // Sort by end expiry date + std::sort(std::begin(sorted), std::end(sorted), + [](X509Wrapper& a, X509Wrapper& b) { return (a.get_valid_to() > b.get_valid_to()); }); + + // Collect all should-delete and kept certificates + int skipped = 0; + + for (const auto& cert : sorted) { + if (++skipped > DEFAULT_MINIMUM_CERTIFICATE_ENTRIES) { + sorded_should_delete.push_back(cert.get_file().value()); + } else { + sorded_should_keep.push_back(cert.get_file().value()); + } + } + + // Fill the disk + evse_security->max_fs_certificate_store_entries = 20; + + ASSERT_TRUE(evse_security->is_filesystem_full()); + + // Garbage collect + evse_security->garbage_collect(); + + // Ensure that we have 10 certificates, since we only keep 10, the newest + { + X509CertificateBundle full_certs(fs::path("certs/client/cso"), EncodingFormat::PEM); + ASSERT_EQ(full_certs.get_certificate_chains_count(), DEFAULT_MINIMUM_CERTIFICATE_ENTRIES); + + // Ensure that we only have the newest ones + for (const auto& deleted : sorded_should_delete) { + ASSERT_FALSE(fs::exists(deleted)); + } + + for (const auto& deleted : sorded_should_keep) { + ASSERT_TRUE(fs::exists(deleted)); + } + } +} + TEST_F(EvseSecurityTests, verify_expired_csr_deletion) { // Generate a CSR std::string csr =