diff --git a/src/crypto/X509CertStore.cpp b/src/crypto/X509CertStore.cpp index c1ac0a968..14fb3c438 100644 --- a/src/crypto/X509CertStore.cpp +++ b/src/crypto/X509CertStore.cpp @@ -53,6 +53,38 @@ int storeContextValidityIndex() return index; } +bool verifyLegacySignature(X509 *cert, EVP_PKEY *key) +{ +#ifndef _WIN32 + if(int digest = NID_undef; + cert && key && OBJ_find_sigid_algs(X509_get_signature_nid(cert), &digest, nullptr) == 1 && digest == NID_sha1) + { + // X509_verify uses the certificate's context, not X509_STORE_CTX. + if(auto local = X509CertStore::legacyCertificate(cert); local && X509_verify(local.get(), key) == 1) + return true; + } +#endif + return false; +} + +bool issuedBy(X509 *cert, const X509Cert &issuer) +{ + if(X509_check_issued(issuer.handle(), cert) == X509_V_OK) + { + auto *key = X509_get0_pubkey(issuer.handle()); + if(X509_verify(cert, key) == 1 || verifyLegacySignature(cert, key)) + return true; + } + ERR_clear_error(); + return false; +} + +bool validAt(X509 *cert, time_t time) +{ + return X509_cmp_time(X509_get0_notBefore(cert), &time) < 0 && + X509_cmp_time(X509_get0_notAfter(cert), &time) > 0; +} + } const X509CertStore::Type X509CertStore::CA { @@ -68,7 +100,21 @@ const X509CertStore::Type X509CertStore::OCSP { "http://uri.etsi.org/TrstSvc/Svctype/Certstatus/OCSP/QC", }; -struct X509CertStore::Private: public vector {}; +struct X509CertStore::Private: public vector +{ + void enumServices(const Type &type, const string &time, const auto &callback) const + { + for(const TSL::Service &service: *this) + { + auto i = service.validity.upper_bound(time); + if(i == service.validity.cbegin()) + continue; + --i; + if(i->second && contains(type, i->second->type) && callback(service, *i->second)) + return; + } + } +}; /** * X509CertStore constructor. @@ -160,6 +206,9 @@ unique_free_t X509CertStore::createStore(const Type &type, tm &tm) if (!store) THROW_OPENSSLEXCEPTION("Failed to create X509_STORE_CTX"); X509_STORE_set_verify_cb(store.get(), X509CertStore::validate); + X509_STORE_set_get_issuer(store.get(), [](X509 **issuer, X509_STORE_CTX *ctx, auto *cert) { + return X509CertStore::getIssuer(issuer, ctx, const_cast(cert)); + }); const int typeIndex = storeTypeIndex(); const int validityIndex = storeContextValidityIndex(); if(typeIndex < 0 || validityIndex < 0 || @@ -202,10 +251,64 @@ unique_free_t X509CertStore::legacyCertificate(X509 *cert) return {nullptr, X509_free}; } +int X509CertStore::getIssuer(X509 **issuer, X509_STORE_CTX *ctx, X509 *cert) +{ + *issuer = nullptr; + const auto *type = static_cast( + X509_STORE_get_ex_data(X509_STORE_CTX_get0_store(ctx), storeTypeIndex())); + if(!type) + return 0; + auto time = X509_VERIFY_PARAM_get_time(X509_STORE_CTX_get0_param(ctx)); + auto current = util::date::to_string(time); + X509 *fallback{}; + const TSL::Validity *fallbackValidity{}; + auto useIssuer = [&](X509 *candidate, const TSL::Validity *validity) { + if(X509_STORE_CTX_set_ex_data(ctx, storeContextValidityIndex(), + const_cast(validity)) != 1 || X509_up_ref(candidate) != 1) + return -1; + *issuer = candidate; + return 1; + }; + int result = 0; + instance()->d->enumServices(*type, current, [&](const TSL::Service &service, const TSL::Validity &validity) { + for(const X509Cert &candidate: service.certs) + { + if(!issuedBy(cert, candidate)) + continue; + if(validAt(candidate.handle(), time)) + { + result = useIssuer(candidate.handle(), &validity); + return true; + } + if(!fallback) + { + fallback = candidate.handle(); + fallbackValidity = &validity; + } + } + return false; + }); + if(result != 0) + return result; + return fallback ? useIssuer(fallback, fallbackValidity) : 0; +} + int X509CertStore::validate(int ok, X509_STORE_CTX *ctx) { switch(X509_STORE_CTX_get_error(ctx)) { + case X509_V_ERR_CERT_SIGNATURE_FAILURE: + { + if(ok) + return ok; + auto *chain = X509_STORE_CTX_get0_chain(ctx); + int depth = X509_STORE_CTX_get_error_depth(ctx); + int count = sk_X509_num(chain); + if(depth < 0 || depth >= count) + return ok; + X509 *issuer = sk_X509_value(chain, depth + 1 < count ? depth + 1 : depth); + return verifyLegacySignature(X509_STORE_CTX_get_current_cert(ctx), X509_get0_pubkey(issuer)) ? 1 : ok; + } case X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT: case X509_V_ERR_DEPTH_ZERO_SELF_SIGNED_CERT: case X509_V_ERR_SELF_SIGNED_CERT_IN_CHAIN: @@ -224,42 +327,16 @@ int X509CertStore::validate(int ok, X509_STORE_CTX *ctx) return 0; auto current = util::date::to_string(X509_VERIFY_PARAM_get_time(X509_STORE_CTX_get0_param(ctx))); auto trusted = [&](X509 *x509) -> const TSL::Validity* { - for(const TSL::Service &s: *instance()->d) - { - for(auto i = s.validity.crbegin(), end = s.validity.crend(); i != end; ++i) - { - if(current < i->first) // Search older status - continue; - if(!i->second.has_value()) // Has revoked - break; - if(!contains(*type, i->second->type)) // correct service type - break; - if(none_of(s.certs, [&](const X509Cert &issuer) { - if(issuer == x509) // certificate is listed by service - return true; - if(X509_check_issued(issuer.handle(), x509) == X509_V_OK) // certificate is issued by service (function checks only issuer name) - { - auto *key = X509_get0_pubkey(issuer.handle()); - if(X509_verify(x509, key) == 1) // certificate is signed by service - return true; -#ifndef _WIN32 - if(int digest = NID_undef; - OBJ_find_sigid_algs(X509_get_signature_nid(x509), &digest, nullptr) == 1 && digest == NID_sha1) - { - // X509_verify uses the context of the certificate, not X509_STORE_CTX. - if(auto local = legacyCertificate(x509); local && X509_verify(local.get(), key) == 1) - return true; - } -#endif - } - ERR_clear_error(); - return false; - })) // certificate is trusted by service - break; - return &i->second.value(); - } - } - return nullptr; + const TSL::Validity *result{}; + instance()->d->enumServices(*type, current, [&](const TSL::Service &service, const TSL::Validity &validity) { + if(none_of(service.certs, [&](const X509Cert &issuer) { + return issuer == x509 || issuedBy(x509, issuer); // certificate is listed or issued by service + })) // certificate is trusted by service + return false; + result = &validity; + return true; + }); + return result; }; X509 *current_cert = X509_STORE_CTX_get_current_cert(ctx); X509 *target_cert = X509_STORE_CTX_get0_cert(ctx); diff --git a/src/crypto/X509CertStore.h b/src/crypto/X509CertStore.h index c742ee226..d8a5bcd0f 100644 --- a/src/crypto/X509CertStore.h +++ b/src/crypto/X509CertStore.h @@ -61,6 +61,7 @@ namespace digidoc DISABLE_COPY(X509CertStore); static int validate(int ok, X509_STORE_CTX *ctx); + static int getIssuer(X509 **issuer, X509_STORE_CTX *ctx, X509 *cert); struct Private; std::unique_ptr d; }; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d391d09fa..5420eee6e 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -6,15 +6,21 @@ add_definitions( ) add_executable(unittests libdigidocpp_boost.cpp) add_executable(TSLTests TSLTests.cpp) +add_executable(X509CertStoreTests X509CertStoreTests.cpp ../src/crypto/X509CertStore.cpp) target_link_libraries(unittests digidocpp digidocpp_tsl Boost::unit_test_framework) -target_link_libraries(TSLTests digidocpp digidocpp_util Boost::unit_test_framework) +target_link_libraries(TSLTests digidocpp digidocpp_tsl Boost::unit_test_framework) +target_link_libraries(X509CertStoreTests digidocpp digidocpp_tsl Boost::unit_test_framework ${CMAKE_DL_LIBS}) if(WIN32) - set_target_properties(unittests TSLTests PROPERTIES COMPILE_FLAGS "/bigobj") + set_target_properties(unittests TSLTests X509CertStoreTests PROPERTIES COMPILE_FLAGS "/bigobj") endif() add_test(NAME unittests COMMAND unittests --build_info=YES --report_level=no -- ${CMAKE_CURRENT_SOURCE_DIR}/data WORKING_DIRECTORY $ ) +add_test(NAME X509CertStoreTests + COMMAND X509CertStoreTests -- ${CMAKE_CURRENT_SOURCE_DIR}/data + WORKING_DIRECTORY $ +) # Bad status tests foreach(TEST CA-non-qa no_QCStatement no_QCSD diff --git a/test/X509CertStoreTests.cpp b/test/X509CertStoreTests.cpp new file mode 100644 index 000000000..6bbea0366 --- /dev/null +++ b/test/X509CertStoreTests.cpp @@ -0,0 +1,231 @@ +/* + * SPDX-FileCopyrightText: Estonian Information System Authority + * SPDX-License-Identifier: LGPL-2.1-or-later + */ + +#define BOOST_TEST_MODULE "X509 certificate store tests" +#include "test.h" + +#include +#include +#include + +#include + +struct StoreFixture: DigiDocPPFixture +{ + StoreFixture() + { + initialize("X509CertStoreTests"); + // This target compiles the private store implementation without exporting it. + X509CertStore::instance()->update(); + } + +}; + +BOOST_GLOBAL_FIXTURE(StoreFixture); + +namespace { + +struct RestoreStore +{ + ~RestoreStore() { X509CertStore::instance()->update(); } +}; + +auto verify(const X509Cert &cert, const vector &untrusted = {}, + const X509CertStore::Type &type = X509CertStore::CA) +{ + tm time{}; + ASN1_TIME_to_tm(X509_get0_notBefore(cert.handle()), &time); + auto store = X509CertStore::createStore(type, time); + auto stack = make_unique_ptr(sk_X509_new_null(), [](auto *sk) { sk_X509_free(sk); }); + for(const auto &candidate: untrusted) + sk_X509_push(stack.get(), candidate.handle()); + auto ctx = make_unique_ptr(X509_STORE_CTX_new()); + BOOST_REQUIRE_EQUAL(X509_STORE_CTX_init(ctx.get(), store.get(), cert.handle(), stack.get()), 1); + int result = X509_verify_cert(ctx.get()); + vector chain; + auto *built = X509_STORE_CTX_get0_chain(ctx.get()); + for(int i = 0; i < sk_X509_num(built); ++i) + chain.emplace_back(sk_X509_value(built, i)); + return pair{result, std::move(chain)}; +} + +} + +BOOST_AUTO_TEST_CASE(tsl_issuer_precedes_supplied_candidates) +{ + PKCS12Signer signer("signer1.p12", "signer1"); + X509Cert issuer("inter.crt"); + X509Cert root("ca.crt"); + for(bool sameKey: {false, true}) + { + BOOST_TEST_CONTEXT("same public key: " << sameKey) + { + auto decoy = make_unique_ptr(X509_dup(issuer.handle())); + BOOST_REQUIRE(decoy); + if(!sameKey) + BOOST_REQUIRE_EQUAL(X509_set_pubkey(decoy.get(), X509_get0_pubkey(root.handle())), 1); + int index = X509_get_ext_by_NID(decoy.get(), NID_subject_key_identifier, -1); + BOOST_REQUIRE_GE(index, 0); + X509_EXTENSION_free(X509_delete_ext(decoy.get(), index)); + BOOST_REQUIRE_EQUAL(ASN1_TIME_set_string(X509_getm_notBefore(decoy.get()), "20990101000000Z"), 1); + BOOST_REQUIRE_GT(i2d_re_X509_tbs(decoy.get(), nullptr), 0); + X509Cert candidate(decoy.get()); + BOOST_REQUIRE_EQUAL(X509_check_issued(candidate.handle(), signer.cert().handle()), X509_V_OK); + auto [result, chain] = verify(signer.cert(), {candidate}); + BOOST_REQUIRE_EQUAL(result, 1); + BOOST_REQUIRE_EQUAL(chain.size(), 2U); + BOOST_CHECK_EQUAL(chain[1], issuer); + } + } +} + +BOOST_AUTO_TEST_CASE(sha1_certificate_uses_tsl_issuer) +{ + auto container = Container::openPtr("sha1-cert-ocsp.bdoc"); + BOOST_REQUIRE_EQUAL(container->signatures().size(), 1U); + auto cert = container->signatures().front()->signingCertificate(); + BOOST_REQUIRE_EQUAL(X509_get_signature_nid(cert.handle()), NID_sha1WithRSAEncryption); + auto issuer = X509CertStore::instance()->findIssuer(cert, X509CertStore::CA); + BOOST_REQUIRE(issuer); + auto [result, chain] = verify(cert); + BOOST_REQUIRE_EQUAL(result, 1); + BOOST_REQUIRE_EQUAL(chain.size(), 2U); + BOOST_CHECK_EQUAL(chain.back(), issuer); +} + +BOOST_AUTO_TEST_CASE(legacy_signature_retry_preserves_other_failures) +{ + auto container = Container::openPtr("sha1-cert-ocsp.bdoc"); + BOOST_REQUIRE_EQUAL(container->signatures().size(), 1U); + auto cert = container->signatures().front()->signingCertificate(); + auto issuer = X509CertStore::instance()->findIssuer(cert, X509CertStore::CA); + BOOST_REQUIRE(issuer); + auto callbackResult = [](const X509Cert &cert, const X509Cert &issuer, int error) { + tm time{}; + ASN1_TIME_to_tm(X509_get0_notBefore(cert.handle()), &time); + auto store = X509CertStore::createStore(X509CertStore::CA, time); + auto ctx = make_unique_ptr(X509_STORE_CTX_new()); + BOOST_REQUIRE_EQUAL(X509_STORE_CTX_init(ctx.get(), store.get(), cert.handle(), nullptr), 1); + auto chain = make_unique_ptr(sk_X509_new_null(), [](auto *sk) { sk_X509_pop_free(sk, X509_free); }); + BOOST_REQUIRE(chain); + for(const auto *candidate: {&cert, &issuer}) + { + auto copy = make_unique_ptr(X509_dup(candidate->handle())); + BOOST_REQUIRE(copy); + BOOST_REQUIRE_GT(sk_X509_push(chain.get(), copy.get()), 0); + copy.release(); + } + X509_STORE_CTX_set_current_cert(ctx.get(), sk_X509_value(chain.get(), 0)); + X509_STORE_CTX_set0_verified_chain(ctx.get(), chain.release()); + X509_STORE_CTX_set_error_depth(ctx.get(), 0); + X509_STORE_CTX_set_error(ctx.get(), error); + return X509_STORE_CTX_get_verify_cb(ctx.get())(0, ctx.get()); + }; + + // Only Fedora/RHEL supplies the private-context SHA-1 compatibility hook. + bool legacyAvailable = bool(X509CertStore::legacyCertificate(cert.handle())); + BOOST_CHECK_EQUAL(callbackResult(cert, issuer, X509_V_ERR_CERT_SIGNATURE_FAILURE), legacyAvailable ? 1 : 0); + BOOST_CHECK_EQUAL(callbackResult(cert, issuer, X509_V_ERR_CERT_HAS_EXPIRED), 0); + BOOST_CHECK_EQUAL(callbackResult(cert, issuer, X509_V_ERR_CERT_NOT_YET_VALID), 0); + BOOST_CHECK_EQUAL(callbackResult(cert, X509Cert("ca.crt"), X509_V_ERR_CERT_SIGNATURE_FAILURE), 0); + vector damaged = cert; + damaged.back() ^= 1; + BOOST_CHECK_EQUAL(callbackResult(X509Cert(damaged), issuer, X509_V_ERR_CERT_SIGNATURE_FAILURE), 0); + PKCS12Signer signer("signer1.p12", "signer1"); + BOOST_REQUIRE_NE(X509_get_signature_nid(signer.cert().handle()), NID_sha1WithRSAEncryption); + BOOST_CHECK_EQUAL(callbackResult(signer.cert(), X509Cert("inter.crt"), X509_V_ERR_CERT_SIGNATURE_FAILURE), 0); +} + +BOOST_AUTO_TEST_CASE(tsl_intermediate_does_not_require_root) +{ + PKCS12Signer signer("signer1.p12", "signer1"); + auto [result, chain] = verify(signer.cert()); + BOOST_REQUIRE_EQUAL(result, 1); + BOOST_CHECK_EQUAL(chain.size(), 2U); +} + +BOOST_AUTO_TEST_CASE(untrusted_chain_requires_tsl_anchor) +{ + X509Cert root("ca.crt"); + auto [result, chain] = verify(root, {root}); + BOOST_CHECK_EQUAL(result, 0); +} + +BOOST_AUTO_TEST_CASE(supplied_intermediate_completes_chain_to_tsl) +{ + X509Cert leaf("issuer-chain-leaf.crt", X509Cert::Der); + auto [missing, incomplete] = verify(leaf); + BOOST_CHECK_EQUAL(missing, 0); + auto [result, chain] = verify(leaf, {X509Cert("issuer-chain-intermediate.crt", X509Cert::Der)}); + BOOST_REQUIRE_EQUAL(result, 1); + BOOST_REQUIRE_EQUAL(chain.size(), 3U); + BOOST_CHECK_EQUAL(chain.back(), X509Cert("inter.crt")); +} + +BOOST_AUTO_TEST_CASE(service_type_is_enforced) +{ + PKCS12Signer signer("signer1.p12", "signer1"); + auto [result, chain] = verify(signer.cert(), {X509Cert("inter.crt")}, X509CertStore::TSA); + BOOST_CHECK_EQUAL(result, 0); +} + +BOOST_AUTO_TEST_CASE(tsl_qualifiers_remain_available) +{ + PKCS12Signer signer("signer1.p12", "signer1"); + BOOST_CHECK(signer.cert().verify(false)); +} + +BOOST_AUTO_TEST_CASE(renewed_tsl_issuer_does_not_shadow_historical_certificate) +{ + RestoreStore restore; + X509Cert cert("issuer-chain-intermediate.crt", X509Cert::Der); + X509Cert original("inter.crt"); + auto historical = X509CertStore::instance()->findIssuer(cert, X509CertStore::CA); + BOOST_REQUIRE(historical); + auto certs = X509CertStore::instance()->certs(X509CertStore::CA); + BOOST_REQUIRE(!certs.empty()); + // Replace an earlier, granted CA identity in this process's private test store. + // Its certificate signature is irrelevant: the TSL authenticates its name/key. + auto &renewed = certs.front(); + BOOST_REQUIRE(renewed != original); + BOOST_REQUIRE_EQUAL(X509_set_subject_name(renewed.handle(), X509_get_subject_name(original.handle())), 1); + BOOST_REQUIRE_EQUAL(X509_set_pubkey(renewed.handle(), X509_get0_pubkey(original.handle())), 1); + int index = X509_get_ext_by_NID(renewed.handle(), NID_subject_key_identifier, -1); + BOOST_REQUIRE_GE(index, 0); + X509_EXTENSION_free(X509_delete_ext(renewed.handle(), index)); + BOOST_REQUIRE_EQUAL(ASN1_TIME_set_string(X509_getm_notBefore(renewed.handle()), "20400101000000Z"), 1); + BOOST_REQUIRE_EQUAL(ASN1_TIME_set_string(X509_getm_notAfter(renewed.handle()), "20500101000000Z"), 1); + BOOST_REQUIRE_GT(i2d_re_X509_tbs(renewed.handle(), nullptr), 0); + // Reparse after changing extensions to invalidate OpenSSL's cached key identifier. + vector der(size_t(i2d_X509(renewed.handle(), nullptr))); + auto *out = der.data(); + BOOST_REQUIRE_GT(i2d_X509(renewed.handle(), &out), 0); + const auto *in = der.data(); + X509 *reused = renewed.handle(); + BOOST_REQUIRE(d2i_X509(&reused, &in, long(der.size()))); + BOOST_REQUIRE_EQUAL(reused, renewed.handle()); + BOOST_REQUIRE_EQUAL(X509_check_issued(renewed.handle(), cert.handle()), X509_V_OK); + for(bool futureFirst: {true, false}) + { + BOOST_TEST_CONTEXT("future identity first: " << futureFirst) + { + for(auto [candidate, future]: {pair{renewed.handle(), futureFirst}, + pair{historical.handle(), !futureFirst}}) + { + BOOST_REQUIRE_EQUAL(ASN1_TIME_set_string(X509_getm_notBefore(candidate), + future ? "20400101000000Z" : "20240101000000Z"), 1); + BOOST_REQUIRE_EQUAL(ASN1_TIME_set_string(X509_getm_notAfter(candidate), + future ? "20500101000000Z" : "20340101000000Z"), 1); + BOOST_REQUIRE_GT(i2d_re_X509_tbs(candidate, nullptr), 0); + } + auto [result, chain] = verify(cert); + BOOST_REQUIRE_EQUAL(result, 1); + BOOST_REQUIRE_EQUAL(chain.size(), 2U); + const auto &expected = futureFirst ? historical : renewed; + BOOST_CHECK_EQUAL(ASN1_TIME_compare(X509_get0_notBefore(chain.back().handle()), + X509_get0_notBefore(expected.handle())), 0); + } + } +} diff --git a/test/data/README.md b/test/data/README.md index b6dde9662..d2fd73657 100644 --- a/test/data/README.md +++ b/test/data/README.md @@ -13,6 +13,11 @@ certificate elements in KeyInfo or CertificateValues. The empty elements must be ignored without introducing signer or OCSP validation failures. These samples use a TSA not trusted by the test TSL, which the regression test allows separately. +* `issuer-chain-leaf.crt` and `issuer-chain-intermediate.crt` - Public DER test + certificates valid from 2025 to 2030. The leaf requires a supplied self-issued + key-rollover intermediate to reach the TSL-listed `inter.crt`; its root + `ca.crt` is not needed. The rollover uses a new EC key and does not consume + `inter.crt`'s path-length constraint. ## Certificate fixtures diff --git a/test/data/issuer-chain-intermediate.crt b/test/data/issuer-chain-intermediate.crt new file mode 100644 index 000000000..1c608f8da Binary files /dev/null and b/test/data/issuer-chain-intermediate.crt differ diff --git a/test/data/issuer-chain-leaf.crt b/test/data/issuer-chain-leaf.crt new file mode 100644 index 000000000..a26af12b8 Binary files /dev/null and b/test/data/issuer-chain-leaf.crt differ diff --git a/test/test.h b/test/test.h index 71c2996e2..2b51faf70 100644 --- a/test/test.h +++ b/test/test.h @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -135,6 +136,7 @@ class DigiDocPPFixture virtual ~DigiDocPPFixture() { + XMLSchema::clearCache(); digidoc::terminate(); //BOOST_MESSAGE("unloading libdigidocpp"); }