Skip to content

Various hardening and fixes - #45

Open
Frauschi wants to merge 10 commits into
wolfSSL:mainfrom
Frauschi:fenrir
Open

Frauschi wants to merge 10 commits into
wolfSSL:mainfrom
Frauschi:fenrir

Conversation

@Frauschi

@Frauschi Frauschi commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Problem

A batch of Fenrir findings against the in-tree EST test server and the HTTP client as well as some issues found along:

  • Reenroll identity (F-8034): /simplereenroll issued whatever Subject and SAN the CSR carried, so a client authenticated over mTLS as one identity could renew as another, contrary to RFC 7030 §4.2.2.
  • Open enrollment (F-8036): with no Basic credentials and no client CA, /simpleenroll issued a certificate for any CSR, and the README quick start ran the server in exactly that mode.
  • Approval queue (F-8038): with est_require_approval, the server answered 202 before decoding or verifying the CSR. Arbitrary bytes got a pending response, and eight of them filled the queue so real clients got 503.
  • Error responses (F-8035): 4xx/5xx went out with an empty body and no media type, which RFC 7030 §4.2.3 forbids. The 204 from /csrattrs carried Content-Length: 0, and no 401 carried WWW-Authenticate.
  • Header leak (F-8047): each repeated Authorization header orphaned the previous copy, so an unauthenticated client could leak close to a header buffer per connection.
  • Retry-After (F-8039): the client parsers only read delay-seconds. An HTTP-date left retry_after_sec at 0, so a 202 told the caller to re-POST at once.
  • client, csr: copy the renewed cert's subject and SAN into reenroll CSRs #44 follow-ups: wolfcert_client_reenroll() generated a rekey's new key before refusing a meta that sets the Subject or SAN, and no test reached the two size limits on the Subject and SAN it copies, which also guard a memcpy.
  • Client stack use: several SCEP client paths kept a DecodedCert (about 2.3 KiB) on the stack, one of them two at once, while the client runs on MCU task stacks.

Fix

  • ca: issue the CSR's subjectAltName verbatim: the test CA rebuilt the issued SAN from wolfSSL's per-type alt-name lists, which lost the CSR's entry order and criticality. It now copies the CSR's SAN extension value byte for byte with its criticality, and, as before, refuses an otherName, x400Address, directoryName or ediPartyName, through wolfcert_find_san(): the renewal CSR's find_san(), moved from csr.c to internal.c, now takes the DecodedCert and uses its isCSR to read a CSR's unwrapped extensions.
  • est: bind simplereenroll to the certificate being renewed: for a reenroll, handler_enroll() compares the CSR with the TLS client cert. The CSR must verify, and its raw Subject and raw SAN extension value must both match byte for byte, with the same SAN criticality, otherwise 400. The raw comparison covers every GeneralName type, including those wolfSSL does not parse into its alt-name lists. With no client cert to renew, the server answers 403; on a client-CA server a missing cert is not the client's fault (a resumed session without SESSION_CERTS has none), so it is a 500 reported as a TLS error. A wolfSSL without KEEP_PEER_CERT can't read the peer cert: an mTLS server then answers 500, and one with no client CA answers 403.
  • est: send a plaintext reason with every server error response: send_error() emits text/plain and a one-line reason at every error site.
  • est: fix 204 and 401 response headers: every response goes through one send_response(), which drops Content-Length for a 204 and sends only the headers for a NULL body. HEAD /cacerts and HEAD /csrattrs get the GET handler's headers without the content, and a HEAD 404 carries GET's Content-Length without the body. A failed Basic check answers 401 with WWW-Authenticate: Basic realm="estrealm". Client-cert failures (PHA not completed, reenroll without a peer cert) now answer 403, since no HTTP challenge can satisfy them. The EST client maps 401 and 403 to the same WOLFCERT_ERR_AUTH.
  • est: refuse to start an EST server with no client authentication: wolfcert_server_start() returns WOLFCERT_ERR_BAD_ARG for EST unless http_basic_user, tls_client_ca_pem or the new est_allow_anonymous_enroll (added at the end of WolfCertServerCfgSrv, so existing members keep their offsets) is set. Empty Basic credentials are rejected too, and a failed copy of any configured string fails the start with WOLFCERT_ERR_MEMORY instead of leaving the Basic user NULL (read as no authentication). wolfcert_server_serve_fd() runs no TLS, so enrollment there refuses with 403 unless Basic or the anonymous opt-in is configured. wolfcert-server gains --est-allow-anonymous and rejects an empty --basic.
  • Validate the CSR before parking it in the EST approval queue: the approval gate now runs after base64 decoding, the reenroll identity check, the CSR-attributes policy and a self-signature check (for a reenroll, the identity check already verified it). The pending key is the SHA-256 of the decoded DER, so a retry with different line wrapping still matches.
  • Parse the HTTP-date form of Retry-After: both parsers share parse_retry_after(), which accepts IMF-fixdate, RFC 850 and asctime, and caps the delay at 86400 s. It measures against wc_Time(), so a port's clock override applies. RFC 850 two-digit years resolve with the RFC 9110 §5.6.7 50-year rule, counted in calendar years. Delay-seconds must be digits alone apart from trailing whitespace; the old strtol() read 120junk as 120. Under NO_ASN_TIME, or while wc_Time() reads before 2026 (a device clock not yet set), the date form yields 0 rather than a full day's wait.
  • Reject EST requests carrying more than one Authorization header: a second occurrence fails the parse with 400. Header names are now matched exactly up to their colon, so Authorization-Foo is not mistaken for Authorization, nor Content-Length-Foo for Content-Length, and a field with whitespace before its colon gets 400 (RFC 9112 §5.1) instead of being skipped.
  • scep: heap-allocate DecodedCert on the client paths: pem_has_cert, issuer_and_subject, issuer_and_serial, self_signed_rsa and extract_spki allocate from the heap hint. Running out of memory anywhere in the GetCert bundle search, or in the CertRep signer check, returns WOLFCERT_ERR_MEMORY instead of reporting the certificate as missing or the CertRep as unsigned. Server-only code keeps its stack DecodedCerts.
  • client, csr: refuse a renewal's identity meta before the rekey: the check runs with the other argument checks, before any key generation, through a helper wolfcert_csr_build_ex() shares. test_csr pins both size limits from each side.

Closes F-8034, F-8035, F-8036, F-8038, F-8039, F-8047.

Behaviour changes for callers

  • An EST test server configured with no Basic user and no client CA now fails to start. Set est_allow_anonymous_enroll (CLI --est-allow-anonymous) to keep open enrollment. The tests, interop scripts and README quick start now opt in explicitly.
  • /simplereenroll on the test server needs a client CA (else 403) and a KEEP_PEER_CERT wolfSSL (else 500), as the tls_client_ca_pem comment in server.h states.
  • The test CA issues the CSR's SAN exactly as requested: entry order and criticality are kept. otherName, x400Address, directoryName and ediPartyName are still refused.
  • HEAD /cacerts and HEAD /csrattrs now answer 200 and 204 instead of 404.
  • A reenroll CSR must carry the Subject and SAN of the cert being renewed. wolfcert_client_reenroll() does that since client, csr: copy the renewed cert's subject and SAN into reenroll CSRs #44; callers of wolfcert_est_simple_reenroll_ex build the CSR themselves and must match it byte for byte, which a CSR rebuilt from the same names does for a cert the test CA issued.

Tests

  • est_mtls_roundtrip: a reenroll with a changed CN or an added SAN is refused. A server-issued multi-SAN cert renews, but not with one dNSName changed or its SAN marked critical. A non-CSR body gets 400. A mismatched reenroll in approval mode gets 400 and is never parked. The reenroll cases skip on a wolfSSL without KEEP_PEER_CERT, where one case checks the mTLS server answers 500.
  • est_csr_attrs_enforce: text/plain error bodies, the 204 headers, the 401 challenge and the reenroll 403.
  • est_pha_roundtrip: a raw TLS client renews its cert with /simplereenroll over post-handshake auth.
  • est_tls_roundtrip: refused start with no client auth, empty Basic credentials and a Basic user with an empty password. On serve_fd, an enroll is refused with only a client CA and let through to the CA with Basic credentials or the anonymous opt-in. The client reenroll cases from client, csr: copy the renewed cert's subject and SAN into reenroll CSRs #44 now run against their own server that trusts the cert being renewed, so the client's copied identity passes the server's comparison end to end, and a rekey with an identity-setting meta is refused before key generation.
  • csr unit: a Subject of sizeof(CertName) - 1 bytes and a SAN of sizeof(altNames) bytes renew, one byte more of either is refused.
  • est_pending_roundtrip: a non-CSR body and a CSR with a broken signature are rejected instead of parked, and a re-POST of a parked CSR with different base64 line wrapping is issued.
  • est_chunked_robustness: duplicate Authorization gets 400 while Authorization-Foo next to Authorization does not, Content-Length : 0 gets 400, and HEAD to an unknown path, /cacerts and /csrattrs gets exactly GET's headers and no body.
  • transport unit: all three HTTP-date layouts, the RFC 850 century window on both sides of exactly 50 years, the 86400 cap, 14 malformed dates, malformed delay-seconds, and a date against a clock still near 1970.
  • cli_proto_scoping.sh: wolfcert-server --proto est refuses to start without client auth, gets past that check with --est-allow-anonymous, and rejects an empty --basic.
  • scep_msg unit: failing each allocation of a GetCert bundle search, or of a CertRep signer check, in turn gives WOLFCERT_ERR_MEMORY, never "not found" or "not signed". Skipped under OPENSSL_EXTRA, whose GetCertName reports a failed X509_NAME allocation as ASN_PARSE_E.
  • server_ca_store unit: a CSR whose SAN holds a dNSName, an rfc822Name and an iPAddress is issued with an identical SAN, and a directoryName SAN is refused. Failing each allocation of wolfcert_server_start() once; any start that still succeeds kept its Basic credentials and challenge password.

Verification

Against wolfSSL master (5.9.4):

  • Every commit builds clean with -Wall -Wextra -Wshadow -Werror on every target.
  • The tip passes ctest 30/30, also on the full-opensslextra and full-all CI configs, under ASan+UBSan, and through the autotools make check with no warnings.
  • A wolfSSL built from the canonical configure minus -DKEEP_PEER_CERT: 30/30, warning-free, with the reenroll cases skipped except the 500 check.
  • WOLFSSL_NO_MALLOC config: 11/11, including the 16 KB SAN renewal.
  • leaks --atExit: 0 leaks on test_scep_msg, test_server_ca_store, test_transport, test_est_chunked_robustness, test_est_pending_roundtrip, test_est_pha_roundtrip, test_est_mtls_roundtrip and test_est_tls_roundtrip.
  • Each new test fails with its fix reverted, including an off-by-one on either size limit, the SAN criticality comparison, the credential-copy check, the 50-year window, the delay-seconds tail, the SAN byte comparison, the unset-clock floor, the serve_fd guard, HEAD without a body, exact header names, the signer-check OOM mapping and the pending queue keyed on the decoded CSR.
  • Not run: the Zephyr QEMU tests and the hand-run interop scripts under tests/interop/.

@Frauschi Frauschi self-assigned this Sep 30, 2026
Copilot AI balanced review requested due to automatic review settings September 30, 2026 09:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unchecked credential-copy failures can bypass EST authentication, and Retry-After and SAN validation contain correctness gaps.

Review effort: Balanced
Findings: 1 High severity · 3 Medium severity

Open (4)
What changed in this PR

Hardens the EST test server and HTTP/SCEP clients against authentication, validation, resource, and protocol-handling issues.

Changes:

  • Enforces EST authentication and reenrollment identity checks.
  • Validates CSRs before approval and improves HTTP error/Retry-After handling.
  • Reduces SCEP stack usage and expands regression coverage.
File Description
wolfcert/​server.h Documents anonymous EST enrollment controls.
wolfcert/​http.h Documents HTTP-date Retry-After support.
wolfcert/​est.h Updates pending-response documentation.
tests/​unit/​test_transport.c Tests Retry-After date parsing.
tests/​unit/​test_server_ca_store.c Opts test server into anonymous enrollment.
tests/​unit/​test_scep_msg.c Tests SCEP allocation failures.
tests/​unit/​test_csr.c Tests renewal identity size limits.
tests/​interop/​openssl_pkcs7_xcheck.sh Enables anonymous EST interop.
tests/​interop/​est_libest.sh Enables anonymous libest interop.
tests/​interop/​est_globalsign.sh Enables anonymous GlobalSign interop.
tests/​integration/​test_server_stop_idle.c Updates EST server configuration.
tests/​integration/​test_est_tls_roundtrip.c Tests authentication and early metadata rejection.
tests/​integration/​test_est_pha_roundtrip.c Updates PHA failure expectations.
tests/​integration/​test_est_pending_roundtrip.c Tests invalid CSR rejection before queuing.
tests/​integration/​test_est_mtls_roundtrip.c Tests reenrollment identity enforcement.
tests/​integration/​test_est_csr_attrs_roundtrip.c Updates anonymous test configuration.
tests/​integration/​test_est_csr_attrs_enforce.c Tests error bodies and status headers.
tests/​integration/​test_est_csr_attrs_apply_roundtrip.c Updates anonymous test configuration.
tests/​integration/​test_est_chunked_robustness.c Tests HEAD and duplicate authorization handling.
tests/​integration/​cli_proto_scoping.sh Tests new server CLI validation.
tests/​CMakeLists.txt Exposes internal headers to transport tests.
src/​server.c Rejects unsafe EST server configurations.
src/​scep/​scep_msg.c Heap-allocates large certificate parsers.
src/​scep/​scep_client.c Propagates GetCert allocation failures.
src/​internal.h Adds internal identity and result contracts.
src/​internal.c Preserves PEM conversion OOM errors.
src/​http.c Parses HTTP-date Retry-After values.
src/​est/​est_server.c Hardens enrollment, errors, and approval handling.
src/​csr.c Shares renewal identity validation.
src/​client.c Rejects identity metadata before rekeying.
README.md Documents secure EST server startup.
Makefile.am Adds internal include path for tests.
examples/​certs/​README.md Updates EST authentication examples.
examples/​certs/​gen-certs.sh Updates generated certificate guidance.
docs/​ARCHITECTURE.md Documents reenrollment and 403 behavior.
cli/​wolfcert_server.c Adds --est-allow-anonymous.
CLAUDE.md Updates build and server guidance.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/server.c
Comment thread src/est/est_server.c Outdated
Comment thread src/http.c Outdated
Comment thread src/http.c
@Frauschi

Copy link
Copy Markdown
Member Author

@wolfSSL-Fenrir-bot review balanced

@wolfSSL-Fenrir-bot wolfSSL-Fenrir-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fenrir Automated Review — PR #45

Scan targets checked: wolfcert-src, wolfcert-bugs
Coverage: 15 of 20 in-scope changed file(s) opened by the reviewer; not opened: tests/integration/test_est_csr_attrs_apply_roundtrip.c, tests/integration/test_server_stop_idle.c, tests/unit/test_csr.c, wolfcert/est.h, wolfcert/http.h

Fenrir result: Approved ✅

No new issues found in the changed files.

Advisory only — this automated result does not count as a GitHub approval.

Review tier: Balanced

@yosuke-wolfssl yosuke-wolfssl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fixes look right to me. The branch builds clean with -Wall -Wextra -Werror, and ctest passes 30/30 against a KEEP_PEER_CERT wolfSSL, so the reenroll cases ran.

Main points, inline below:

  • Reenroll SAN check: no test reaches a SAN-content mismatch, and the parsed-list comparison misses some GeneralName types.
  • Retry-After HTTP-date: a device whose clock is not set yet waits a full day.
  • est_allow_anonymous_enroll is inserted in the middle of WolfCertServerCfgSrv.
  • send_* helpers: the three new ones can become one.
  • Wording: "epoch" and delta-seconds.
  • Duplicated contracts: a few comments restate a contract the header already owns. The copies of the Retry-After rules have already drifted apart.

Not on diff lines:

  • wolfcert/est.h: the section header still shows Retry-After: <delta>, and the WolfCertEstResult.retry_after_sec field comment still says "0 when the server did not send Retry-After". Both predate the HTTP-date parsing.
  • wolfcert/scep.h: the wolfcert_scep_get_cert() doc names WOLFCERT_ERR_PROTOCOL as the only error after a verified round trip. WOLFCERT_ERR_MEMORY from wolfcert_scep_pem_has_cert() can now arrive there too.
  • When wolfcert_extract_spki() runs out of memory, the SCEP client still reports "CertRep is not signed by the CA/RA certificate". That is the same misreport this PR fixes in wolfcert_scep_pem_has_cert().
  • parse_request() matches header names by prefix, so a request carrying an Authorization-Foo header and an Authorization header now gets 400. The prefix matching is old; the 400 is new.
  • The requirement that /simplereenroll needs a client CA and a KEEP_PEER_CERT wolfSSL is now written in six places: wolfcert/server.h:51, the wolfcert-server help text, README.md (twice, at lines 75 and 93-94), docs/ARCHITECTURE.md:141 and CLAUDE.md:46. The copies have already drifted:
    • README.md:94 says reenroll "works only with --tls-client-ca" and leaves out KEEP_PEER_CERT.
    • ARCHITECTURE.md gives only the 403 for a server with no client CA, not the 500 for a wolfSSL without KEEP_PEER_CERT.
    • CLAUDE.md:46 nearly repeats README.md:75 and could point to it instead.
  • Test cases worth adding:
    • a non-empty Basic user with an empty password
    • an enroll over serve_fd that the guard should let through (Basic or the anonymous opt-in)
    • /simplereenroll on a post-handshake-auth server
    • an approval re-POST with different base64 line wrapping, which the DER-keyed queue now promises to match

Comment thread src/est/est_server.c Outdated
Comment thread src/est/est_server.c Outdated
Comment thread src/est/est_server.c
Comment thread src/est/est_server.c Outdated
Comment thread src/est/est_server.c Outdated
Comment thread src/http.c
Comment thread wolfcert/http.h Outdated
Comment thread wolfcert/server.h Outdated
Comment thread wolfcert/server.h Outdated
Comment thread src/internal.h Outdated
@Frauschi

Frauschi commented Oct 1, 2026

Copy link
Copy Markdown
Member Author

Thanks! All addressed, including the off-diff points:

  • est.h and scep.h docs are fixed.
  • extract_spki out-of-memory is now reported as WOLFCERT_ERR_MEMORY.
  • Header names are matched exactly.
  • The reenroll requirements are stated once, in server.h.

All four suggested tests are added, the PHA one with a raw TLS client.

@Frauschi Frauschi assigned yosuke-wolfssl and unassigned Frauschi Oct 1, 2026

@yosuke-wolfssl yosuke-wolfssl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this addresses everything from the last round. The branch builds clean with -Wall -Wextra -Werror and ctest passes 30/30. With the SAN byte comparison in reenroll_identity_check() disabled, the new "one dNSName changed" case fails, so the comparison is covered now.

A few small things inline, none blocking:

  • wolfcert_find_san() could take the DecodedCert directly.
  • A missing client certificate on a resumed TLS session is reported as out of memory.
  • hdr_is() could reuse wolfcert_ascii_ncasecmp().
  • The /simplereenroll requirements sit on an unrelated field in server.h.

Nits:

  • The comment at src/est/est_server.c:1059 is 89 columns, which came from my wording last round. Could you wrap it?
  • copy_csr_san() returns WOLFCERT_ERR_BAD_ARG for a SAN larger than Cert.altNames, while copy_cert_identity() returns WOLFCERT_ERR_UNSUPPORTED for the same limit.

Comment thread src/internal.h Outdated

/* subjectAltName value in DecodedCert.extensions, which carry a [3] wrapper
* unless is_csr; *san stays NULL when there is none. */
WOLFCERT_TEST_VIS int wolfcert_find_san(const byte* ext, int ext_sz,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every caller passes dc->extensions, dc->extensionsSz from a DecodedCert it has already parsed, and wolfSSL sets dc->isCSR when it parses a CSR (that field is under WOLFSSL_CERT_REQ, which wolfCert already requires). Taking the DecodedCert removes two arguments and the bare 0/1 at the call sites:

/* GeneralNames of the subjectAltName in dc, or *san NULL when there is
 * none. Returns WOLFCERT_OK or WOLFCERT_ERR_PARSE. */
WOLFCERT_TEST_VIS int wolfcert_find_san(const DecodedCert* dc,
                                        const byte** san, word32* san_len);

Call sites then read wolfcert_find_san(&cc, &csan, &csan_len).

The suggested comment also fixes two gaps in the current one: it doesn't name the return codes, and "value" doesn't say that it means the OCTET STRING contents, i.e. the GeneralNames SEQUENCE.

Optional: the test CA and the EST server now depend on a helper that lives in csr.c, the client's CSR builder. src/internal.c would be a more neutral home.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: it takes the DecodedCert and reads isCSR, with your comment, and moved to internal.c.

Comment thread src/est/est_server.c Outdated
if (peer_der == NULL || peer_len <= 0) {
if (peer != NULL)
wolfSSL_FreeX509(peer);
/* A client CA means the handshake or PHA already required a cert. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The premise of this comment doesn't hold on a resumed TLS session. A resumed handshake exchanges no client certificate, and wolfSSL_get_peer_certificate() copies ssl->peerCert only when the handshake filled it in. It falls back to the session's chain only when wolfSSL is built with SESSION_CERTS. So a third-party client that resumes on reconnect, for example after a 202, gets a 500 logged as out of memory.

The request is still refused, so this is about the diagnostic. A code that doesn't claim out of memory would be accurate, and WOLFCERT_ERR_TLS still maps to 500 in handler_enroll():

        /* With a client CA set, a missing cert is not the client's fault. */
        if (s->tls_current != NULL && s->cfg.tls_client_ca_pem != NULL &&
                s->cfg.tls_client_ca_pem_len > 0)
            return WOLFCERT_ERR(WOLFCERT_ERR_TLS, "est",
                "reenroll: cannot read the client certificate");

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. It's WOLFCERT_ERR_TLS now, with your wording.

Comment thread src/est/est_server.c Outdated
return 0;
}

/* 1 when the header line is field `name`, which a colon ends with no space. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

strncasecmp() here is locale-dependent, and it is POSIX rather than C89 (MSVC lacks it). wolfCert already has wolfcert_ascii_ncasecmp() in src/http.c for exactly this case, and find_header() there applies the same rule: name, then ':' immediately after it.

Two related points:

  • parse_request() computes hc and hdr_is() guarantees line[n] == ':', yet each branch below still calls memchr(line, ':', llen) again.
  • The comment could be plainer:
    /* 1 when the line's field name is `name`, followed directly by ':'. */

Possible follow-up rather than this PR: src/scep/scep_server.c:234 and :239 still prefix-match (llen > 14 && strncasecmp(line, "Content-Length", 14)), so Content-Length-Foo: 99999 sets the body length there. One shared internal helper could serve both servers.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to wolfcert_ascii_ncasecmp(), and so are the chunked/close value checks next to it. The branches use hc instead of a second memchr(), and I took your comment. I'll track the SCEP server's prefix match as a follow-up.

Comment thread wolfcert/server.h Outdated
/* Heap hint for server-internal allocations. */
void* heap;

/* Lets EST start with neither Basic nor tls_client_ca_pem. /simplereenroll

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"/simplereenroll needs tls_client_ca_pem (else 403) and KEEP_PEER_CERT (else 500)" doesn't relate to anonymous enrollment. A caller setting up mTLS wouldn't look for it on this field, and docs/ARCHITECTURE.md now points readers here for the reenroll requirements. The tls_client_ca_pem paragraph at the top of the TLS block (around line 57) seems like the natural home, and this comment can then stay with what the field does:

    /* Lets EST start with neither Basic nor tls_client_ca_pem. */

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to the tls_client_ca_pem paragraph; ARCHITECTURE.md points there now.

The test CA rebuilt the issued SAN from wolfSSL's parsed alt-name
lists. Those lists are split by type, so the issued SAN did not keep
the entry order the CSR asked for and its criticality was dropped.

Copy the subjectAltName extension value from the CSR into the issued
certificate byte for byte, with its criticality. Like before, a SAN
holding an otherName, x400Address, directoryName or ediPartyName is
refused: only rfc822Name, dNSName, URI, iPAddress and registeredID are
issued, so a client cannot get, say, a UPN otherName signed.

find_san() in csr.c, which already pulled that value out of a
certificate for a renewal CSR, moves to internal.c as
wolfcert_find_san(). It takes the DecodedCert and uses its isCSR to
tell a CSR's extensions, which carry no [3] wrapper, from a
certificate's.

A renewal CSR built from the same names as the original request now
carries the same SAN bytes as the certificate it renews.

server_ca_store issues a CSR whose SAN holds a dNSName, an rfc822Name
and an iPAddress in that order and checks that the issued SAN is
identical, and checks that a directoryName SAN is refused.
The test server sent /simpleenroll and /simplereenroll down the same
handler and issued whatever Subject and SAN the CSR carried. A client
authenticated over mutual TLS as one identity could therefore reenroll
as another, contrary to RFC 7030 section 4.2.2, which requires the
reenroll request to keep the Subject and SubjectAltName of the
certificate being renewed.

handler_enroll() now knows which operation it serves. For a reenroll it
verifies the CSR and compares it against the TLS client certificate:
the raw Subject and the raw subjectAltName extension value must both
match byte for byte, with the same SAN criticality, otherwise the
request gets 400. Comparing the raw value covers every GeneralName
type, including those wolfSSL does not parse into its alt-name lists.
A body that is not a valid, signed PKCS#10 request also gets 400, and
a reenroll with no client certificate to renew gets 401. On a server
with a client CA a missing certificate is not the client's fault, for
example on a resumed session without SESSION_CERTS, so it gets 500.

Reading the peer certificate needs KEEP_PEER_CERT. Without it, a
connection that never asked for a client certificate still gets 401,
while an mTLS server, whose handshake did demand one, refuses the
reenroll rather than issue without the comparison. README.md and
CLAUDE.md list the new KEEP_PEER_CERT requirement.

The mutual TLS integration test gains cases that reenroll with a
changed CN and with an added SAN and expect both to be refused, that
post a body which is not a CSR, and that renew a server-issued
certificate carrying several SANs, so the comparison is checked
against the CA's own encoding. Renewing that certificate with one
dNSName changed, or with its SAN marked critical, is refused. On a
wolfSSL without KEEP_PEER_CERT the reenroll cases skip, and one case
checks that the mTLS server answers 500 instead.
est_pha_roundtrip renews the client certificate over post-handshake
auth with a raw TLS client; the client API cannot, since only sessions
offer PHA and they have no reenroll.

The TLS integration test's client reenroll cases, which asked a server
with no client CA to renew, now start their own server that trusts the
certificate being renewed, so a renewal that keeps the identity is
checked end to end against the comparison.

Fixes F-8034.
RFC 7030 section 4.2.3 requires an error response that carries no
media type to include a plaintext, human-readable explanation of why
the request was rejected. The EST test server answered every 4xx and
5xx with an empty body and no Content-Type, except for the missing
CSR-attribute case.

Add send_error(), which emits Content-Type: text/plain and a one-line
reason, and use it at every error site in the request dispatcher and
the enroll, cacerts and csrattrs handlers. send_missing_oid() now
formats its message and hands it to send_error(). send_status() is
kept only for the body-less 204 from /csrattrs. A HEAD request to an
unknown path gets its 404 without a body, as RFC 9110 section 9.3.2
requires.

Each reenroll failure now names its cause: a missing client certificate
stays 401, a CSR that does not parse or does not match gets 400, and
a failure on the server side, such as a client certificate it cannot
parse or an mTLS server built without KEEP_PEER_CERT, gets 500.

Extend the CSR-attribute enforcement test to check that a 404 and an
empty-body 400 both come back as text/plain with a non-empty body, and
est_chunked_robustness to check that a HEAD for an unknown path gets
no body.

Fixes F-8035.
RFC 9110 forbids Content-Length on a 204 and requires every 401 to
carry at least one WWW-Authenticate challenge. The EST test server
sent "Content-Length: 0" with the 204 from /csrattrs, and none of its
401 responses carried a challenge.

Every response now goes through one send_response(), which omits
Content-Length for a 204 and sends only the headers for a NULL body, so
the headers are formatted in one place. A failed HTTP Basic check now
answers 401 with WWW-Authenticate: Basic realm="estrealm". The two
client-certificate failures, post-handshake auth that does not complete
and a simplereenroll without a peer certificate to renew, run after
Basic auth has already passed and cannot be satisfied by any HTTP
challenge, so they now answer 403 Forbidden instead of an unchallenged
401. The EST client maps 401 and 403 to the same WOLFCERT_ERR_AUTH, so
callers see no change.

A HEAD for /cacerts or /csrattrs is now answered by the GET handler
without the content, and a HEAD for an unknown path gets the same 404
headers as a GET, including the Content-Length of the plaintext
reason, but no body: RFC 9110 section 8.6 forbids a HEAD
Content-Length that differs from GET's.

Extend the CSR-attribute enforcement test with a Basic-auth server
that checks the 204 headers, the 401 challenge and the reenroll 403.
est_chunked_robustness compares the HEAD headers with GET's for an
unknown path, /cacerts and /csrattrs and checks that nothing follows
them. The post-handshake-auth test and ARCHITECTURE.md expect 403.
The EST test server issued a certificate for any CSR posted to
/simpleenroll when it had no HTTP Basic credentials and no client CA
configured, because check_basic_auth() passes every request when no
username is set and TLS never asks for a client certificate without a
client CA. The README quick start ran the server in exactly that mode.

wolfcert_server_start() now returns WOLFCERT_ERR_BAD_ARG for EST unless
http_basic_user or tls_client_ca_pem is set, or the caller opts in to
open enrollment with the new est_allow_anonymous_enroll field, added at
the end of WolfCertServerCfgSrv so the existing members keep their
offsets. The check runs before the CA is minted, so a refused start
leaves no CA behind.
An empty Basic user or password is refused as well, since the header
it produces is one any client can send. A failed copy of any configured
string now fails the start with WOLFCERT_ERR_MEMORY: a Basic user lost
that way was read as no authentication at all.

wolfcert_server_serve_fd() runs no TLS, so a server started on a client
CA alone would still issue to anyone there. handler_enroll() now
answers 403 when the connection has no TLS and neither Basic
credentials nor the anonymous opt-in are configured. /simplereenroll
already refuses a request without a TLS client certificate and is
unchanged.

wolfcert-server gains --est-allow-anonymous, fails early with a clear
message when none of the three is given, and rejects an empty --basic.
Tests, interop scripts and the quick start that relied on open
enrollment now opt in explicitly. est_tls_roundtrip covers the refused
start, the empty credentials, a Basic user with an empty password, and
enrollment over serve_fd: refused with only a client CA, let through to
the CA with Basic credentials or the anonymous opt-in. The
tls_client_ca_pem comment in server.h states what /simplereenroll
needs, and ARCHITECTURE.md points to it. cli_proto_scoping.sh covers
the CLI checks. server_ca_store fails each
allocation of a start in turn and checks that any start which still
succeeds kept its credentials.

Fixes F-8036.
With est_require_approval set, handler_enroll hashed the raw request
body and answered 202 Accepted before decoding the base64 or looking
at the PKCS#10 request. Arbitrary bytes, a CSR with a forged
signature, or a reenroll whose identity did not match the TLS peer
all got a pending response instead of an error, and eight such posts
filled the fixed-size queue so legitimate clients got 503 until an
entry was approved.

Move the gate after base64 decoding, the reenroll identity check and
the CSR attributes policy, and verify the CSR self-signature before
queueing it; a reenroll CSR was already verified by the identity
check. A CSR that does not parse or verify gets 400; only running
out of memory while checking it gets 500. Key the pending entry on the
decoded DER so a retry with different base64 line wrapping still
matches.

est_pending_roundtrip posts a body that is not a CSR and a CSR with a
broken signature and expects both to be refused, and
est_mtls_roundtrip checks that a mismatched reenroll on an approval
server gets 400 without being parked. est_pending_roundtrip also
re-POSTs a parked CSR with different base64 line wrapping and expects
it to be issued.

Fixes F-8038.
RFC 9110 section 10.2.3 allows Retry-After to carry either delay-seconds
or an HTTP-date. Both the blocking and the non-blocking response parsers
only recognised a leading digit, so a date left retry_after_sec at 0 and
an EST 202 Accepted told the caller to re-POST at once, against the
RFC 7030 section 4.2.3 requirement to wait at least that long. A delay
above one day was also discarded as 0 instead of being bounded.

Both parsers now share parse_retry_after(), which accepts all three
HTTP-date layouts (IMF-fixdate, RFC 850 and asctime), converts the date
to a delay against wc_Time() so a port's clock override applies, and
caps the result at 86400 seconds. A date in the past still yields 0, as
does any date when wolfSSL is built with NO_ASN_TIME and has no clock,
or while wc_Time() reads earlier than 2026. That is a device clock not
yet set (no RTC, before SNTP), against which every date is decades
ahead and would clamp to a full day.
The two-digit year of an RFC 850 date resolves to the most recent year
no more than 50 calendar years ahead, per RFC 9110 section 5.6.7.
Delay-seconds must be digits alone apart from trailing whitespace; the
strtol() parse it replaces read 120junk as 120.

The transport unit test covers all three layouts, the RFC 850 century
window on both sides of exactly 50 years, the cap, a set of malformed
dates, malformed delay-seconds and a clock still near 1970.

Fixes F-8039.
The EST test server's request parser allocated a fresh copy of every
Authorization header line into the same field, so each repeat orphaned
the previous allocation. An unauthenticated client could leak close to
a full request-header buffer per connection by sending a long
Authorization header followed by a short one.

Authorization is a singleton field (RFC 9110), so a second occurrence
now fails the parse and the client gets 400. The existing error path
frees the first value. Header names are now matched exactly, up to their
colon, with the ASCII-only wolfcert_ascii_ncasecmp() that the HTTP
client already uses: the old prefix match read an Authorization-Foo
field as Authorization, so it and a real Authorization header together
would be refused as a duplicate, and a Content-Length-Foo field could
set the body length. A field with whitespace before its colon now gets 400, as
RFC 9112 section 5.1 requires; ignoring it would leave its body to be
read as the next request on a kept-alive connection.

A regression case in est_chunked_robustness sends the duplicate and
expects the 400, checks that an Authorization-Foo field next to
Authorization is accepted, and that "Content-Length : 0" gets 400.

Fixes F-8047.
A DecodedCert is about 2.3 KiB, and the SCEP client runs on MCU task
stacks. derive_txid_pubkey() already allocated its DecodedCert with
WOLFCERT_XMALLOC; the other client-side parses kept theirs on the stack,
wolfcert_scep_issuer_and_subject() two at once.

Move them to the heap hint:

- wolfcert_scep_pem_has_cert() allocates one DecodedCert and reuses it
  for every PEM entry. Running out of memory, for the DecodedCert, the
  DER copy or inside wc_ParseCert(), returns WOLFCERT_ERR_MEMORY, which
  GetCert passes on instead of reporting a response without the
  certificate. Only an entry that does not parse is skipped. For that,
  wolfcert_pem_cert_to_der() now reports a MEMORY_E from wc_PemToDer()
  as WOLFCERT_ERR_MEMORY rather than a parse error.
- wolfcert_scep_issuer_and_subject() and wolfcert_scep_issuer_and_serial()
  allocate in a thin wrapper and pass the structs to a static helper
  holding the old body, so every early return keeps its cleanup.
- wolfcert_scep_self_signed_rsa() and wolfcert_extract_spki() allocate
  inline. wolfcert_extract_spki() is shared with the server, but the
  client reaches it through wolfcert_scep_verify_rep_signer(). It now
  reports a MEMORY_E from wc_ParseCert() as WOLFCERT_ERR_MEMORY, and
  wolfcert_scep_verify_rep_signer() passes that on, so the client
  reports running out of memory instead of a CertRep that is not
  signed by the CA.

test_scep_msg fails each allocation of a GetCert bundle search, and
of a CertRep signer check, in turn, where the wolfSSL allocator can be
swapped, and expects WOLFCERT_ERR_MEMORY every time until the call
succeeds.

Server-only code (the EST and SCEP servers, ca_issue.c and
wolfcert_scep_issuer_name_matches()) keeps its DecodedCerts on the
stack; the server runs on a host.
wolfcert_client_reenroll() generated the new key before
wolfcert_csr_build_ex() refused a meta that sets subject_dn or a SAN.
A caller reusing its enroll meta for a rekeying renewal therefore paid
for a full key generation, seconds for RSA-4096 or ML-DSA on an MCU or
a key created on a CryptoCb device, only to get WOLFCERT_ERR_BAD_ARG.
The check now runs with the other argument checks, through a helper
that wolfcert_csr_build_ex() shares. The TLS integration test proves
the order with a rekey config whose generation would fail UNSUPPORTED.

test_csr also gains boundary cases for the two size refusals in
copy_cert_identity(), which no test reached and which also guard a
memcpy: a Subject whose contents are sizeof(CertName) - 1 bytes is
copied byte for byte and one byte more is refused, and a SAN of
sizeof(altNames) bytes is copied and one byte more is refused. The
certificates are built by hand, since wc_MakeCert() cannot produce the
oversized ones.
@Frauschi

Frauschi commented Oct 1, 2026

Copy link
Copy Markdown
Member Author

Thanks! Everything inline is addressed, plus both nits: the comment at est_server.c:1059 is wrapped, and copy_csr_san() returns WOLFCERT_ERR_UNSUPPORTED for an oversized SAN. I also brought the other comments this PR adds back under 80 columns. The commit binding /simplereenroll had a bisect gap: on a wolfSSL without KEEP_PEER_CERT its own 500 check failed until the plaintext-errors commit added the 500 mapping, so that commit now maps server-side failures to 500 itself. The branch is rebased onto current main.

@Frauschi Frauschi assigned yosuke-wolfssl and unassigned Frauschi Oct 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants