From fbc3670dcde1bb734066e5609e2590e291a1f776 Mon Sep 17 00:00:00 2001 From: Emma Stensland Date: Wed, 15 Jul 2026 16:00:30 -0600 Subject: [PATCH] Add custom PEM bundle parsing and CRL extraction logic --- manpages/wolfssl-verify.1 | 24 ++- src/sign-verify/clu_x509_verify.c | 278 ++++++++++++++++++++++++------ tests/x509/x509-verify-test.py | 76 +++++++- 3 files changed, 312 insertions(+), 66 deletions(-) diff --git a/manpages/wolfssl-verify.1 b/manpages/wolfssl-verify.1 index 3f785bd2..f8781622 100644 --- a/manpages/wolfssl-verify.1 +++ b/manpages/wolfssl-verify.1 @@ -12,11 +12,9 @@ revocation. .SH OPTIONS -CAfile file trusted CA certificate to verify against (PEM only). .br - Must be a self-signed root CA unless \-partial_chain + May be a bundle; must contain a self-signed root CA .br - is given; an intermediate CA causes "CA file is not -.br - root CA". + unless \-partial_chain is given. See NOTES. .br .LP -untrusted file intermediate certificate file. Only one intermediate @@ -52,11 +50,19 @@ wolfssl verify -CAfile ca-and-crl.pem -crl_check server-cert.pem .BR wolfssl-x509(1) ", " wolfssl-crl(1) .SH NOTES The certificate to verify is given as the final argument. -CAfile must be -a PEM file. Unless -partial_chain is given, -CAfile must be a self-signed -root CA; supplying an intermediate CA without -partial_chain fails with -"CA file is not root CA". With -crl_check, revocation data must already be -present in -CAfile (for example by concatenating the CRL PEM after the CA -certificate). Available only when wolfSSL is built with filesystem support. +a PEM file, and may contain a bundle of multiple certificates; the bundle +is scanned in any order for a self-signed root CA. Every candidate CA +certificate, including the self-signed root, must assert basicConstraints +CA:TRUE; certificates without it are skipped when searching for a root +unless -partial_chain is given. This is intentional hardening versus +older wolfssl-clu releases, which accepted a self-signed root lacking +basicConstraints. Unless -partial_chain is given, the bundle must contain +a self-signed root CA; if none is found, verification fails with "CA file +does not contain a self-signed root CA (use -partial_chain to trust an +intermediate)". With -crl_check, revocation +data must already be present in -CAfile (for example by concatenating the +CRL PEM after the CA certificate). Available only when wolfSSL is built +with filesystem support. .SH BUGS No known bugs at this time. .SH AUTHOR diff --git a/src/sign-verify/clu_x509_verify.c b/src/sign-verify/clu_x509_verify.c index e5720975..470bb40c 100644 --- a/src/sign-verify/clu_x509_verify.c +++ b/src/sign-verify/clu_x509_verify.c @@ -32,6 +32,7 @@ static const struct option verify_options[] = { {"-untrusted", required_argument, 0, WOLFCLU_INTERMEDIATE }, {"-crl_check", no_argument, 0, WOLFCLU_CHECK_CRL }, {"-partial_chain", no_argument, 0, WOLFCLU_PARTIAL_CHAIN }, + {"-inform", required_argument, 0, WOLFCLU_INFORM }, {"-help", no_argument, 0, WOLFCLU_HELP }, {"-h", no_argument, 0, WOLFCLU_HELP }, @@ -43,15 +44,56 @@ static void wolfCLU_x509VerifyHelp(void) { WOLFCLU_LOG(WOLFCLU_L0, "./wolfssl verify -CAfile " "[-untrusted ] [-crl_check] " - "[-partial_chain] "); + "[-partial_chain] [-inform pem|der] "); WOLFCLU_LOG(WOLFCLU_L0, "Note: Current support only allows for loading " "1 cert as -untrusted"); + WOLFCLU_LOG(WOLFCLU_L0, "Note: -inform is accepted for compatibility " + "and ignored; input format is auto-detected"); +} + +/*Returns 1 if cert is a self-signed root, 0 otherwise (or on hard error, with *hardErr set to a non-WOLFCLU_SUCCESS code). */ +static int cert_is_self_signed_root(WOLFSSL_X509* cert, int* hardErr) { + WOLFSSL_X509_NAME* subj = wolfSSL_X509_get_subject_name(cert); + WOLFSSL_X509_NAME* issu = wolfSSL_X509_get_issuer_name(cert); + + *hardErr = WOLFCLU_SUCCESS; + if (subj == NULL || issu == NULL || + wolfSSL_X509_NAME_cmp(subj, issu) != 0) { + return 0; + } + + { + WOLFSSL_EVP_PKEY* pubKey = wolfSSL_X509_get_pubkey(cert); + int isRoot; + if (pubKey == NULL) { + *hardErr = WOLFCLU_FATAL_ERROR; + return 0; + } + isRoot = (wolfSSL_X509_verify(cert, pubKey) == 1); + wolfSSL_EVP_PKEY_free(pubKey); + return isRoot; + } +} + +/*Returns 1 if cert should be skipped as a non-CA trust anchor/issuer, 0 if it's acceptable to add to the CA bundle's trust store. */ +static int should_skip_non_ca_cert(WOLFSSL_X509* cert) { + if (wolfSSL_X509_get_isCA(cert) == 1) { + return 0; + } + if (wolfSSL_X509_ext_isSet_by_NID(cert, WC_NID_basic_constraints)) { + wolfCLU_Log(WOLFCLU_L0, "Skipping CA bundle cert that explicitly " + "asserts basicConstraints CA:FALSE"); + return 1; + } + wolfCLU_Log(WOLFCLU_L0, "Warning: CA bundle cert has no basicConstraints " + "extension (pre-dates RFC 5280); treating it as a legacy CA " + "rather than rejecting it"); + return 0; } -#endif -static X509* load_cert_from_file(const char* filename) { +static WOLFSSL_X509* load_cert_from_file(const char* filename) { WOLFSSL_BIO* bio = NULL; WOLFSSL_X509* cert = NULL; @@ -73,12 +115,12 @@ static X509* load_cert_from_file(const char* filename) { return cert; } +#endif /* !WOLFCLU_NO_FILESYSTEM */ int wolfCLU_x509Verify(int argc, char** argv) { #ifndef WOLFCLU_NO_FILESYSTEM int ret = WOLFCLU_SUCCESS; - int inForm = PEM_FORM; int crlCheck = 0; int partialChain = 0; int longIndex = 1; @@ -87,11 +129,15 @@ int wolfCLU_x509Verify(int argc, char** argv) char* verifyCert = NULL; char* intermCert = NULL; WOLFSSL_X509_STORE* store = NULL; - WOLFSSL_X509_LOOKUP* lookup = NULL; WOLFSSL_X509_STORE_CTX* ctx = NULL; WOLFSSL_X509* cert = NULL; WOLFSSL_X509* intermediate = NULL; STACK_OF(WOLFSSL_X509)* intermStack = NULL; + int loaded = 0; + int foundRoot = 0; + int hardErr = WOLFCLU_SUCCESS; + int rootErr = WOLFCLU_SUCCESS; + WOLFSSL_X509* caX509 = NULL; /* last parameter is the certificate to verify */ if (XSTRNCMP("-h", argv[argc-1], 2) == 0) { @@ -137,7 +183,14 @@ int wolfCLU_x509Verify(int argc, char** argv) break; case WOLFCLU_INFORM: - inForm = wolfCLU_checkInform(optarg); + /* Format is auto-detected; -inform is a compat no-op. */ + if (optarg != NULL && + XSTRNCMP(optarg, "pem", 4) != 0 && + XSTRNCMP(optarg, "PEM", 4) != 0) { + WOLFCLU_LOG(WOLFCLU_L0, + "Warning: -inform %s is ignored; " + "verify auto-detects PEM then DER", optarg); + } break; case WOLFCLU_HELP: @@ -155,15 +208,17 @@ int wolfCLU_x509Verify(int argc, char** argv) } } - cert = load_cert_from_file(verifyCert); - if (!cert) { - wolfCLU_LogError("Failed to load cert: %s\n", verifyCert); - ret = WOLFCLU_FATAL_ERROR; + if (ret == WOLFCLU_SUCCESS) { + cert = load_cert_from_file(verifyCert); + if (cert == NULL) { + wolfCLU_LogError("Failed to load cert: %s\n", verifyCert); + ret = WOLFCLU_FATAL_ERROR; + } } - if (ret == WOLFCLU_SUCCESS && intermCert) { + if (ret == WOLFCLU_SUCCESS && intermCert != NULL) { intermediate = load_cert_from_file(intermCert); - if (!intermediate) { + if (intermediate == NULL) { wolfCLU_LogError("Failed to load cert: %s\n", intermCert); ret = WOLFCLU_FATAL_ERROR; } @@ -176,53 +231,176 @@ int wolfCLU_x509Verify(int argc, char** argv) } } - if (ret == WOLFCLU_SUCCESS) { - if (inForm != PEM_FORM) { - wolfCLU_LogError("Only handling PEM CA files"); - ret = WOLFCLU_FATAL_ERROR; - } - } - - if (ret == WOLFCLU_SUCCESS) { - lookup = wolfSSL_X509_STORE_add_lookup(store, - wolfSSL_X509_LOOKUP_file()); - if (lookup == NULL) { - wolfCLU_LogError("Failed to setup lookup"); + /* Require -CAfile to contain a self-signed root CA unless -partial_chain. */ + if (ret == WOLFCLU_SUCCESS && caCert != NULL) { + byte* pemBuf = NULL; + int pemSz = 0; + long max_sz = 10 * 1024 * 1024; /* 10 MB limit for CA bundle */ + loaded = 0; + + wolfSSL_ERR_clear_error(); + if (wolfCLU_ReadFileToBuffer(caCert, max_sz, &pemBuf, &pemSz) != WOLFCLU_SUCCESS) { + wolfCLU_LogError("Failed to open or read CA file %s", caCert); ret = WOLFCLU_FATAL_ERROR; } - } - - /* Confirm CA file is root CA unless partialChain enabled */ - if (ret == WOLFCLU_SUCCESS){ - if (!partialChain && caCert != NULL){ - int error; - - error = wolfSSL_CertManagerVerify(store->cm, caCert, - WOLFSSL_FILETYPE_PEM); + else { + char* pem = (char*)pemBuf; + long sz = (long)pemSz; + + if (ret == WOLFCLU_SUCCESS && pem != NULL) { + char* curr = pem; + char* nextCert = XSTRSTR(curr, "-----BEGIN CERTIFICATE-----"); + char* nextTrusted = XSTRSTR(curr, "-----BEGIN TRUSTED CERTIFICATE-----"); + char* nextCrl = XSTRSTR(curr, "-----BEGIN X509 CRL-----"); + + while (curr && curr < pem + sz && ret == WOLFCLU_SUCCESS) { + char* best = NULL; + int isCert = 0; + int isCrl = 0; + int isTrusted = 0; + + if (nextCert && nextCert < curr) nextCert = XSTRSTR(curr, "-----BEGIN CERTIFICATE-----"); + if (nextTrusted && nextTrusted < curr) nextTrusted = XSTRSTR(curr, "-----BEGIN TRUSTED CERTIFICATE-----"); + if (nextCrl && nextCrl < curr) nextCrl = XSTRSTR(curr, "-----BEGIN X509 CRL-----"); + + if (nextCert) { best = nextCert; isCert = 1; } + if (nextTrusted && (!best || nextTrusted < best)) { best = nextTrusted; isCert = 1; isCrl = 0; isTrusted = 1; } + if (nextCrl && (!best || nextCrl < best)) { best = nextCrl; isCrl = 1; isCert = 0; isTrusted = 0; } + + if (!best) { + break; + } - if (error != ASN_SELF_SIGNED_E) { - wolfCLU_LogError("CA file is not root CA"); - ret = WOLFCLU_FATAL_ERROR; + if (isCert) { + long remain = pem + sz - best; + WOLFSSL_BIO* memBio = wolfSSL_BIO_new_mem_buf(best, + (remain > (long)INT_MAX) ? INT_MAX : (int)remain); + if (memBio) { + /*TRUSTED CERTIFICATE blocks carry trailing trust attributes after the DER; only the _AUX reader understands that footer/format. */ + caX509 = isTrusted ? + wolfSSL_PEM_read_bio_X509_AUX(memBio, NULL, NULL, NULL) : + wolfSSL_PEM_read_bio_X509(memBio, NULL, NULL, NULL); + if (caX509 != NULL) { + int skipCert = 0; + /*Counts certs found in the file (to decide whether to fall back to a DER parse below), not certs added to the trust store. */ + loaded++; + if (!partialChain && + should_skip_non_ca_cert(caX509)) { + skipCert = 1; + } + if (!skipCert && !partialChain && !foundRoot) { + if (cert_is_self_signed_root(caX509, &hardErr)) { + foundRoot = 1; + } + if (hardErr != WOLFCLU_SUCCESS && rootErr == WOLFCLU_SUCCESS) { + rootErr = hardErr; + } + } + if (!skipCert && wolfSSL_X509_STORE_add_cert(store, caX509) != WOLFSSL_SUCCESS) { + wolfCLU_LogError("Failed to add CA cert to trust store"); + ret = WOLFCLU_FATAL_ERROR; + } + wolfSSL_X509_free(caX509); + } else { + wolfCLU_LogError("CA bundle contains corrupt or truncated certificate; aborting verification"); + ret = WOLFCLU_FATAL_ERROR; + } + wolfSSL_BIO_free(memBio); + } else { + wolfCLU_LogError("Failed to allocate memory BIO for CA certificate"); + ret = WOLFCLU_FATAL_ERROR; + } + + char* footer = XSTRSTR(best, "-----END "); + if (footer) { + curr = footer + 9; + } else { + curr = pem + sz; + } + } + else if (isCrl) { +#ifdef HAVE_CRL + if (crlCheck) { + long remain = pem + sz - best; + WOLFSSL_BIO* memBio = wolfSSL_BIO_new_mem_buf(best, + (remain > (long)INT_MAX) ? INT_MAX : (int)remain); + if (memBio) { + WOLFSSL_X509_CRL* crl = wolfSSL_PEM_read_bio_X509_CRL(memBio, NULL, NULL, NULL); + if (crl != NULL) { + if (wolfSSL_X509_STORE_add_crl(store, crl) != WOLFSSL_SUCCESS) { + wolfCLU_LogError("Failed to add CRL to trust store"); + ret = WOLFCLU_FATAL_ERROR; + } + wolfSSL_X509_CRL_free(crl); + } else { + wolfCLU_LogError("CRL data in CA file is corrupt or truncated; aborting verification"); + ret = WOLFCLU_FATAL_ERROR; + } + wolfSSL_BIO_free(memBio); + } else { + wolfCLU_LogError("Failed to allocate memory BIO for CRL"); + ret = WOLFCLU_FATAL_ERROR; + } + } +#endif /* HAVE_CRL */ + char* footer = XSTRSTR(best, "-----END "); + if (footer) { + curr = footer + 9; + } else { + curr = pem + sz; + } + } + } + } + if (pem) { + XFREE(pem, HEAP_HINT, DYNAMIC_TYPE_TMP_BUFFER); } - else { - /* - * We're expecting these errors, since root certs are - * self-signed so remove them from the error queue. - */ - if (wolfSSL_ERR_peek_error() == -ASN_NO_SIGNER_E) { - wolfSSL_ERR_get_error(); - if (wolfSSL_ERR_peek_error() == -ASN_SELF_SIGNED_E) { - wolfSSL_ERR_get_error(); + + /* fall back to DER if file opened but had no PEM certs. */ + if (ret == WOLFCLU_SUCCESS && loaded == 0) { + caX509 = load_cert_from_file(caCert); + if (caX509 == NULL) { + wolfCLU_LogError("Failed to load CA file %s", caCert); + ret = WOLFCLU_FATAL_ERROR; + } + /* Same CA:TRUE requirement as the PEM bundle path above. */ + if (ret == WOLFCLU_SUCCESS && !partialChain && + should_skip_non_ca_cert(caX509)) { + wolfCLU_LogError("CA file does not assert " + "basicConstraints CA:TRUE"); + ret = WOLFCLU_FATAL_ERROR; + } + if (ret == WOLFCLU_SUCCESS && !partialChain && !foundRoot) { + if (cert_is_self_signed_root(caX509, &hardErr)) { + foundRoot = 1; + } + /* Defer: a later cert may still be a valid root. */ + if (hardErr != WOLFCLU_SUCCESS && + rootErr == WOLFCLU_SUCCESS) { + rootErr = hardErr; } } + if (ret == WOLFCLU_SUCCESS && + wolfSSL_X509_STORE_add_cert(store, caX509) + != WOLFSSL_SUCCESS) { + wolfCLU_LogError("Failed to add CA cert to trust store"); + ret = WOLFCLU_FATAL_ERROR; + } + wolfSSL_X509_free(caX509); } } } - if (ret == WOLFCLU_SUCCESS && caCert != NULL) { - if (wolfSSL_X509_LOOKUP_load_file(lookup, caCert, X509_FILETYPE_PEM) - != WOLFSSL_SUCCESS) { - wolfCLU_LogError("Failed to load CA file via lookup"); + if (ret == WOLFCLU_SUCCESS && !partialChain && caCert != NULL && + !foundRoot) { + if (rootErr != WOLFCLU_SUCCESS) { + wolfCLU_LogError("Error while checking CA bundle for a " + "self-signed root CA"); + ret = rootErr; + } + else { + wolfCLU_LogError("CA file does not contain a self-signed root CA " + "(use -partial_chain to trust an intermediate)"); ret = WOLFCLU_FATAL_ERROR; } } @@ -276,5 +454,5 @@ int wolfCLU_x509Verify(int argc, char** argv) (void)argv; WOLFCLU_LOG(WOLFCLU_E0, "No filesystem support"); return WOLFCLU_FATAL_ERROR; -#endif +#endif /* !WOLFCLU_NO_FILESYSTEM */ } diff --git a/tests/x509/x509-verify-test.py b/tests/x509/x509-verify-test.py index a8c5763f..dd06818c 100644 --- a/tests/x509/x509-verify-test.py +++ b/tests/x509/x509-verify-test.py @@ -1,14 +1,18 @@ #!/usr/bin/env python3 """Tests for wolfssl verify (converted from x509-verify-test.sh).""" +import functools import os import sys +import shutil +import tempfile import unittest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from wolfclu_test import CERTS_DIR, run_wolfssl, test_main +@functools.lru_cache(maxsize=None) def _has_crl(): """Check whether CRL support is compiled in.""" r = run_wolfssl("verify", "-CAfile", @@ -19,6 +23,27 @@ def _has_crl(): return "recompile wolfSSL with CRL" not in combined +@functools.lru_cache(maxsize=None) +def _can_print_mldsa_cert(): + """True if `x509 -text` can render an ML-DSA certificate.""" + if not _has_dilithium(): + return False + import tempfile + with tempfile.TemporaryDirectory() as d: + key = os.path.join(d, "probe") + cert = os.path.join(d, "probe.pem") + r = run_wolfssl("-genkey", "ml-dsa", "-level", "2", "-out", key, + "-output", "keypair", "-outform", "PEM") + if r.returncode != 0: + return False + r = run_wolfssl("req", "-x509", "-key", key + ".priv", + "-subj", "/CN=probe", "-days", "1", "-out", cert) + if r.returncode != 0: + return False + r = run_wolfssl("x509", "-in", cert, "-text", "-noout") + return r.returncode == 0 + + class TestX509Verify(unittest.TestCase): """Certificate verification tests.""" @@ -67,11 +92,18 @@ def test_verify_rsa_again(self): self.assertEqual(r.returncode, 0, r.stderr) def test_verify_self_as_ca_fails(self): - """verify server-cert.pem as its own CA should fail.""" - r = run_wolfssl("verify", "-CAfile", - os.path.join(CERTS_DIR, "server-cert.pem"), - os.path.join(CERTS_DIR, "server-cert.pem")) - self.assertNotEqual(r.returncode, 0) + """A non-root leaf used as its own -CAfile should fail.""" + with open(os.path.join(CERTS_DIR, "server-cert.pem"), + encoding="utf-8") as f: + pem = f.read() + leaf_end = pem.index("-----END CERTIFICATE-----") + \ + len("-----END CERTIFICATE-----") + with tempfile.TemporaryDirectory() as d: + leaf_only = os.path.join(d, "leaf-only.pem") + with open(leaf_only, "w", encoding="utf-8") as f: + f.write(pem[:leaf_end] + "\n") + r = run_wolfssl("verify", "-CAfile", leaf_only, leaf_only) + self.assertNotEqual(r.returncode, 0) def test_help_trailing_h(self): """verify -h (as the final argument) prints usage and exits 0.""" @@ -172,6 +204,36 @@ def test_untrusted_chain(self): os.path.join(CERTS_DIR, "ca-int2-cert.pem")) self.assertEqual(r.returncode, 0, r.stderr) + def test_cafile_bundle_root_order_independent(self): + """-CAfile with [intermediate, root] (root not first) still succeeds.""" + with tempfile.TemporaryDirectory() as d: + bundle = os.path.join(d, "int-then-root.pem") + with open(bundle, "w", encoding="utf-8") as out: + for name in ("ca-int-cert.pem", "ca-cert.pem"): + with open(os.path.join(CERTS_DIR, name), + encoding="utf-8") as f: + out.write(f.read()) + r = run_wolfssl("verify", "-CAfile", bundle, + os.path.join(CERTS_DIR, "ca-int2-cert.pem")) + self.assertEqual(r.returncode, 0, r.stderr) + + def test_cafile_bundle_skips_non_ca_cert_to_find_root(self): + """-CAfile bundle with a non-CA cert before the root still finds it.""" + r = run_wolfssl("verify", "-CAfile", + os.path.join(CERTS_DIR, "server-cert.pem"), + os.path.join(CERTS_DIR, "server-cert.pem")) + self.assertEqual(r.returncode, 0, r.stderr) + + def test_cafile_bundle_no_root_fails_with_message(self): + """-CAfile with no self-signed root fails with a dedicated error.""" + r = run_wolfssl("verify", "-CAfile", + os.path.join(CERTS_DIR, "ca-int-cert.pem"), + os.path.join(CERTS_DIR, "ca-int2-cert.pem")) + self.assertNotEqual(r.returncode, 0) + combined = r.stdout + r.stderr + self.assertIn("does not contain a self-signed root CA", combined) + + # NOTE: cert_is_self_signed_root()'s hard-error path needs fault + # injection to trigger, so it's not covered by black-box tests here. + -if __name__ == "__main__": - test_main()