From fd64f4a8509fc26065eb9537a1d66116b81f4ca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Mon, 20 Jul 2026 17:55:09 +0200 Subject: [PATCH 1/7] Track chunk framing incrementally in EST server The chunked Transfer-Encoding read loop in parse_request decided the body was complete by scanning the whole accumulated buffer for the byte sequence "0\r\n" at any offset. Those bytes are not unique to the terminator: a chunk-size line whose value is a multiple of 16, such as "10\r\n" or "800\r\n", ends in them, and chunk data can contain them too. A single-buffer request masked the problem because the full body was already present when the false match fired. When a client streams a large CSR across several TCP segments, the false match stopped the loop before the remaining segments arrived. The decode pass then rejected the request as a protocol error or produced a truncated body, so enrollment failed. Walk the chunk framing positionally instead. Parse each chunk-size line, skip exactly that many payload bytes plus the trailing CRLF, and treat the message as complete only when a genuine zero-length chunk is reached at a chunk-header boundary. Keep reading until that point. Add a multi-segment regression test that delivers a well-formed chunked body in three timed segments with a multiple-of-16 chunk size, and assert the full body reaches the CSR layer. Fixes F-6876. --- src/est/est_server.c | 169 ++++++++----- .../integration/test_est_chunked_robustness.c | 226 ++++++++++++++++++ 2 files changed, 341 insertions(+), 54 deletions(-) diff --git a/src/est/est_server.c b/src/est/est_server.c index dbda31d..87c46be 100644 --- a/src/est/est_server.c +++ b/src/est/est_server.c @@ -148,6 +148,113 @@ static int read_line(const char** p, const char* end, char** ls, size_t* ll) return 0; } +/* Parse the chunk-size line at raw[ri..]. On success store the chunk + * length in *csz and the offset just past its terminating CRLF in + * *next. The hex-digit count is capped at 8 so a crafted long size line + * cannot wrap the value past a later bounds check; 8 digits cover every + * chunk we accept (BODY_CAP is 1 MiB). Shared by the completeness scan + * and the decode pass so the two cannot drift apart. + * + * returns 0 on success + * returns 1 when the CRLF ending the size line has not arrived yet + * (need more bytes) + * returns -1 when the line is malformed */ +static int read_chunk_size(const uint8_t* raw, size_t raw_len, size_t ri, + size_t* csz, size_t* next) +{ + size_t he = ri; + size_t hex_digits = 0; + size_t v = 0; + int parsed = 0; + + while (he + 1 < raw_len && !(raw[he] == '\r' && raw[he+1] == '\n')) { + ++he; + } + + if (he + 1 >= raw_len) + return 1; /* size line not fully received yet */ + + for (size_t k = ri; k < he; ++k) { + char c = (char)raw[k]; + if (c == ';') + break; /* chunk-ext */ + + int d = (c >= '0' && c <= '9') ? c - '0' + : (c >= 'a' && c <= 'f') ? c - 'a' + 10 + : (c >= 'A' && c <= 'F') ? c - 'A' + 10 : -1; + if (d < 0 || ++hex_digits > 8) + return -1; /* bad hex digit or oversized length */ + + v = (v << 4) | (size_t)d; + parsed = 1; + } + if (parsed == 0) + return -1; /* empty chunk-size line */ + + *csz = v; + *next = he + 2; + + return 0; +} + +/* Walk chunk framing over the bytes received so far and report whether + * the message is complete, meaning a genuine zero-length chunk has been + * parsed at a chunk-header boundary. Returns 1 when reading can stop + * (complete, or the framing is malformed and the decode pass below will + * reject it) and 0 when more bytes are still needed. A substring scan + * for "0\r\n" cannot decide this: a chunk-size line that is a multiple + * of 16 and chunk data both contain those bytes. */ +static int chunked_body_complete(const uint8_t* raw, size_t raw_len) +{ + size_t ri = 0; + size_t ls = 0; + + while (ri < raw_len) { + size_t csz = 0; + size_t next = 0; + int r = read_chunk_size(raw, raw_len, ri, &csz, &next); + if (r > 0) + return 0; /* chunk-size line not fully received yet */ + if (r < 0) + return 1; /* malformed line - the decode pass rejects it */ + + /* read_chunk_size guarantees next <= raw_len, so ri stays + * <= raw_len and the raw_len - ri math below cannot underflow. */ + ri = next; + if (csz == 0) { + /* Last-chunk marker. RFC 7230: the message ends only after + * the CRLF terminating the trailer section (minimum + * "0\r\n\r\n"). Stopping at "0\r\n" would leave that CRLF + * unread on the socket and corrupt the next request on a + * keep-alive connection. Consume any trailer field lines up + * to the terminating blank line before declaring complete. */ + while (ri < raw_len) { + ls = ri; + while (ri + 1 < raw_len && + !(raw[ri] == '\r' && raw[ri + 1] == '\n')) { + ++ri; + } + + if (ri + 1 >= raw_len) + return 0; /* trailer line CRLF not fully received */ + if (ri == ls) + return 1; /* blank line terminates the trailers */ + + ri += 2; /* skip this trailer field line, scan the next */ + } + + return 0; /* trailer terminator not yet received */ + } + + if (raw_len - ri < 2 || csz > raw_len - ri - 2) + return 0; /* chunk payload plus trailing CRLF not yet received */ + + ri += csz + 2; + } + + return 0; +} + static int parse_request(WolfCertServer* s, int fd, EstRequest* out, void* heap) { memset(out, 0, sizeof(*out)); @@ -266,20 +373,7 @@ static int parse_request(WolfCertServer* s, int fd, EstRequest* out, void* heap) raw_cap = body_have; } - int saw_end = 0; - while (!saw_end) { - /* Any "0\r\n" at a chunk-header position marks the end. */ - for (size_t i = 0; i + 2 < raw_len; ++i) { - if (raw[i] == '0' && raw[i+1] == '\r' && raw[i+2] == '\n') { - /* Cheap heuristic: treat the last occurrence as the - * terminator. The decode pass below revalidates. */ - saw_end = 1; - } - } - - if (saw_end) - break; - + while (!chunked_body_complete(raw, raw_len)) { size_t grow = raw_len < 2048 ? 2048 : raw_len; if (raw_len + grow > BODY_CAP + 64 * 1024) { WOLFCERT_XFREE(raw, heap); @@ -312,51 +406,18 @@ static int parse_request(WolfCertServer* s, int fd, EstRequest* out, void* heap) size_t body_sz = 0, ri = 0; while (ri < raw_len) { - /* Locate the end of the chunk-size line. */ - size_t he = ri; - while (he + 1 < raw_len && !(raw[he] == '\r' && raw[he+1] == '\n')) { - ++he; - } - - if (he + 1 >= raw_len) - break; - - unsigned long csz = 0; - int parsed = 0; - size_t hex_digits = 0; - for (size_t k = ri; k < he; ++k) { - char c = (char)raw[k]; - if (c == ';') - break; /* chunk-ext */ - - int d = (c >= '0' && c <= '9') ? c - '0' - : (c >= 'a' && c <= 'f') ? c - 'a' + 10 - : (c >= 'A' && c <= 'F') ? c - 'A' + 10 : -1; - if (d < 0) { - parsed = -1; - break; - } - - /* Cap the hex-digit count so a crafted long chunk-size - * line can't silently wrap `csz` to a small value that - * then sneaks past the `ri + csz > raw_len` check. 8 - * digits cover every legitimate chunk we'd ever accept - * (BODY_CAP is 1 MiB). */ - if (++hex_digits > 8) { - parsed = -1; - break; - } - - csz = (csz << 4) | (unsigned long)d; - parsed = 1; - } - if (parsed <= 0) { + size_t csz = 0; + size_t next = 0; + int r = read_chunk_size(raw, raw_len, ri, &csz, &next); + if (r > 0) + break; /* chunk-size line not fully received */ + if (r < 0) { WOLFCERT_XFREE(body, heap); WOLFCERT_XFREE(raw, heap); return WOLFCERT_ERR_PROTOCOL; } - ri = he + 2; + ri = next; if (csz == 0) break; diff --git a/tests/integration/test_est_chunked_robustness.c b/tests/integration/test_est_chunked_robustness.c index bb6f223..7c2702d 100644 --- a/tests/integration/test_est_chunked_robustness.c +++ b/tests/integration/test_est_chunked_robustness.c @@ -29,6 +29,10 @@ * 2. Corrupt inter-chunk trailer (bytes where CRLF should be). * 3. Well-formed sanity baseline so the test fails loudly if the * server stops accepting chunked altogether. + * 4. Well-formed body split across three TCP segments with a + * chunk-size line ending in the bytes '0' '\r' '\n'. + * 5. Keep-alive correctness when the last-chunk trailer CRLF arrives + * in its own segment, so a following request is not corrupted. * * The target is `src/est/est_server.c`'s parse_request chunked path; * no TLS is involved so we can script the byte-exact request here. @@ -42,13 +46,19 @@ #include #include +#include "tls_test_util.h" /* TEST_ENROLL_KEY_TYPE / _PARAM */ + +#include /* Base64_Encode_NoNl */ + #include #include #include +#include #include #include #include #include +#include #include #define REQUIRE(cond) \ @@ -108,6 +118,18 @@ static int send_and_read_status(uint16_t port, return (int)n; } +/* Sleep helper that nudges the request segments below toward landing in + * distinct recv() calls rather than being coalesced into one buffer. + * Best-effort only: TCP guarantees no recv() boundaries, so this just + * makes the intended segmentation likely, not certain. */ +static void nap_ms(long ms) +{ + struct timespec ts; + ts.tv_sec = ms / 1000; + ts.tv_nsec = (ms % 1000) * 1000000L; + nanosleep(&ts, NULL); +} + /* Shape #1: a chunk-size line longer than 8 hex digits. The parser * must reject this rather than letting the shift-accumulate silently * wrap. */ @@ -176,8 +198,208 @@ static int accept_wellformed_chunks(uint16_t port) return 0; } +/* Shape #4: a well-formed chunked body delivered across three TCP + * segments, with a chunk-size line ("10" = 16 bytes) that ends in the + * bytes '0' '\r' '\n'. A completion check that scans for "0\r\n" + * anywhere in the accumulated buffer trips on that size line the moment + * the first partial segment lands, stops reading before the rest of the + * body arrives, and the request is truncated. The framer must instead + * track chunk framing and keep reading until a genuine zero-length + * chunk, so the full body reaches the CSR layer and comes back + * "400 Bad CSR" rather than the "400 Bad Request" a truncated request + * produces. */ +static int accept_multisegment_chunked_body(uint16_t port) +{ + const char* hdr = + "POST /.well-known/est/simpleenroll HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + "Content-Type: application/pkcs10\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n"; + const char* seg2 = "10\r\nAAAA"; /* size line + 4/16 bytes */ + const char* seg3 = "AAAAAAAAAAAA\r\n0\r\n\r\n"; /* last 12 bytes + terminator */ + struct sockaddr_in sa = { .sin_family = AF_INET, + .sin_port = htons(port), + .sin_addr.s_addr = htonl(INADDR_LOOPBACK) }; + char status[128] = { 0 }; + size_t n = 0; + int cs = socket(AF_INET, SOCK_STREAM, 0); + + REQUIRE(cs >= 0); + REQUIRE(connect(cs, (struct sockaddr*)&sa, sizeof(sa)) == 0); + + REQUIRE(send(cs, hdr, strlen(hdr), 0) == (ssize_t)strlen(hdr)); + nap_ms(80); + (void)send(cs, seg2, strlen(seg2), 0); + nap_ms(80); + (void)send(cs, seg3, strlen(seg3), 0); + + while (n + 1 < sizeof(status)) { + ssize_t r = recv(cs, status + n, sizeof(status) - 1 - n, 0); + if (r <= 0) + break; + n += (size_t)r; + status[n] = '\0'; + if (memchr(status, '\n', n) != NULL) + break; + } + close(cs); + + REQUIRE(strstr(status, "Bad CSR") != NULL); + return 0; +} + +/* Build a chunked simpleenroll request whose body carries a real, + * base64-encoded CSR in a single chunk, but split so the last-chunk line + * ("0\r\n") is delivered separately from its terminating trailer CRLF. + * *head gets "\r\n\r\n\r\n0\r\n" and *tail gets the + * lone "\r\n". Both are heap-allocated; the caller frees them. */ +static int build_split_enroll(char** head, size_t* head_len, char** tail) +{ + WolfCertKeyCfg kcfg = { .type = TEST_ENROLL_KEY_TYPE, + .param = TEST_ENROLL_KEY_PARAM, + .dev_id = WOLFCERT_DEVID_SOFTWARE }; + WolfCertCertMeta meta = { .subject_dn = "CN=keepalive-test" }; + WolfCertKey* key = NULL; + WolfCertBuffer csr = { 0 }; + byte* b64 = NULL; + word32 b64_len = 0; + char* buf = NULL; + char* trl = NULL; + size_t hdr_len = 0; + int rc; + + rc = wolfcert_key_generate(&kcfg, &key); + if (rc == WOLFCERT_OK) + rc = wolfcert_csr_build(key, &meta, &csr); + + if (rc == WOLFCERT_OK) { + b64_len = (word32)(((csr.len + 2) / 3) * 4 + 4); + b64 = (byte*)malloc(b64_len); + if (b64 == NULL || + Base64_Encode_NoNl(csr.data, (word32)csr.len, b64, &b64_len) != 0) + rc = WOLFCERT_ERR_CRYPTO; + } + + if (rc == WOLFCERT_OK) { + /* headers + chunk-size line + base64 body + CRLF + "0\r\n" */ + buf = (char*)malloc(512 + b64_len); + trl = strdup("\r\n"); /* trailer terminator, sent as its own segment */ + if (buf == NULL || trl == NULL) + rc = WOLFCERT_ERR_MEMORY; + } + + if (rc == WOLFCERT_OK) { + hdr_len = (size_t)snprintf(buf, 256, + "POST /.well-known/est/simpleenroll HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + "Content-Type: application/pkcs10\r\n" + "Content-Transfer-Encoding: base64\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n" + "%x\r\n", (unsigned)b64_len); + memcpy(buf + hdr_len, b64, b64_len); + hdr_len += b64_len; + memcpy(buf + hdr_len, "\r\n0\r\n", 5); /* end chunk, last-chunk line */ + hdr_len += 5; + + *head = buf; + *head_len = hdr_len; + *tail = trl; + buf = NULL; /* ownership handed to caller */ + trl = NULL; + } + + free(b64); + free(buf); /* NULL on success; frees the partial build on failure */ + free(trl); + wolfcert_buffer_free(&csr); + wolfcert_key_free(key); + return (rc == WOLFCERT_OK) ? 0 : 1; +} + +/* Shape #5: keep-alive correctness when a chunked request's terminating + * trailer CRLF arrives in its own TCP segment. A framer that treats + * "0\r\n" as complete before that final CRLF stops one byte-pair short + * and leaves "\r\n" on the socket; the next request on the same + * keep-alive connection then parses the stray CRLF as an empty request + * line and comes back "400 Bad Request". The corruption is only + * observable once request #1 succeeds and keeps the connection alive, so + * request #1 enrolls a real CSR (the in-tree server issues against its + * generated CA -> "200"). Request #2 is a body-less GET so a mis-parse + * closes cleanly with the "400 Bad Request" visible (no reset). With the + * framer consuming the trailer terminator, request #2 reaches the + * cacerts handler and no "Bad Request" appears. */ +static int keepalive_after_split_trailer(uint16_t port) +{ + const char* req2 = + "GET /.well-known/est/cacerts HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + "Connection: close\r\n" + "\r\n"; + struct sockaddr_in sa = { .sin_family = AF_INET, + .sin_port = htons(port), + .sin_addr.s_addr = htonl(INADDR_LOOPBACK) }; + char* req1_head = NULL; + char* req1_tail = NULL; + size_t req1_head_len = 0; + char resp[4096] = { 0 }; + size_t n = 0; + int cs; + + REQUIRE(build_split_enroll(&req1_head, &req1_head_len, &req1_tail) == 0); + + cs = socket(AF_INET, SOCK_STREAM, 0); + REQUIRE(cs >= 0); + REQUIRE(connect(cs, (struct sockaddr*)&sa, sizeof(sa)) == 0); + + /* Request #1: last-chunk line first, trailer CRLF withheld into its + * own segment so a premature "0\r\n" completion leaves it unread. */ + REQUIRE(send(cs, req1_head, req1_head_len, 0) == (ssize_t)req1_head_len); + nap_ms(80); + REQUIRE(send(cs, req1_tail, strlen(req1_tail), 0) + == (ssize_t)strlen(req1_tail)); + + /* Wait for request #1's response head before sending request #2. */ + while (n + 1 < sizeof(resp)) { + ssize_t r = recv(cs, resp + n, sizeof(resp) - 1 - n, 0); + if (r <= 0) + break; + n += (size_t)r; + resp[n] = '\0'; + if (strstr(resp, "\r\n\r\n") != NULL) + break; + } + REQUIRE(strstr(resp, "200") != NULL); /* enrollment issued a cert */ + + REQUIRE(send(cs, req2, strlen(req2), 0) == (ssize_t)strlen(req2)); + + /* Drain until the server closes (request #2 asked for Connection: + * close), appending onto the same buffer. */ + while (n + 1 < sizeof(resp)) { + ssize_t r = recv(cs, resp + n, sizeof(resp) - 1 - n, 0); + if (r <= 0) + break; + n += (size_t)r; + resp[n] = '\0'; + } + close(cs); + free(req1_head); + free(req1_tail); + + /* Request #2 must have reached the cacerts handler, not the framer: + * a "Bad Request" means the stray trailer CRLF corrupted it. */ + REQUIRE(strstr(resp, "Bad Request") == NULL); + return 0; +} + int main(void) { + /* A truncated request makes the server respond and close while the + * client is still writing later segments; ignore the resulting + * SIGPIPE and read the response back instead. */ + signal(SIGPIPE, SIG_IGN); + REQUIRE(wolfcert_init(NULL) == WOLFCERT_OK); WolfCertServerCfgSrv cfg = { @@ -196,6 +418,10 @@ int main(void) rc = reject_corrupt_chunk_trailer(port); if (rc == 0) rc = accept_wellformed_chunks(port); + if (rc == 0) + rc = accept_multisegment_chunked_body(port); + if (rc == 0) + rc = keepalive_after_split_trailer(port); wolfcert_server_stop(srv); pthread_join(tid, NULL); From 18db572b498e1a7530cf3954891c0b33deca9a81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Mon, 20 Jul 2026 18:22:57 +0200 Subject: [PATCH 2/7] Emit Content-Transfer-Encoding on EST session enroll The blocking and non-blocking session enroll variants base64-encode the CSR body with wolfcert_base64_encode_mime but left content_transfer_ encoding unset in their WolfCertHttpRequest initializers. The HTTP layer emits the Content-Transfer-Encoding header only when that field is set, so both session paths sent a base64 body with no header, contrary to RFC 7030 section 4.2.1. The one-shot enroll path already set it; the two session entry points were missed. Set content_transfer_encoding to base64 in both session initializers so they match the one-shot path. Extend the EST unit test to capture the request a session enroll puts on the wire and assert the header is present. Fixes F-6880. --- src/est/est_client.c | 30 +++++++++------- tests/unit/test_est.c | 82 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 15 deletions(-) diff --git a/src/est/est_client.c b/src/est/est_client.c index fc1b36f..193fa5b 100644 --- a/src/est/est_client.c +++ b/src/est/est_client.c @@ -592,12 +592,15 @@ int wolfcert_est_session_simple_enroll_nb(WolfCertEstSession* s, } s->in_req = (WolfCertHttpRequest){ - .method = "POST", .url = s->in_url, - .content_type = "application/pkcs10", - .accept = "application/pkcs7-mime", - .body = s->in_body.data, .body_len = s->in_body.len, - .max_response_bytes = s->max_body, - .heap = s->heap, + .method = "POST", + .url = s->in_url, + .content_type = "application/pkcs10", + .content_transfer_encoding = "base64", + .accept = "application/pkcs7-mime", + .body = s->in_body.data, + .body_len = s->in_body.len, + .max_response_bytes = s->max_body, + .heap = s->heap, }; s->in_out = out_cert_pem; s->in_op = EST_OP_SIMPLE_ENROLL; @@ -696,12 +699,15 @@ int wolfcert_est_session_simple_enroll(WolfCertEstSession* s, } WolfCertHttpRequest req = { - .method = "POST", .url = url, - .content_type = "application/pkcs10", - .accept = "application/pkcs7-mime", - .body = b64.data, .body_len = b64.len, - .max_response_bytes = s->max_body, - .heap = s->heap, + .method = "POST", + .url = url, + .content_type = "application/pkcs10", + .content_transfer_encoding = "base64", + .accept = "application/pkcs7-mime", + .body = b64.data, + .body_len = b64.len, + .max_response_bytes = s->max_body, + .heap = s->heap, }; WolfCertHttpResponse resp = { 0 }; rc = wolfcert_http_session_request(s->http, &req, &resp); diff --git a/tests/unit/test_est.c b/tests/unit/test_est.c index b5b26b9..a537971 100644 --- a/tests/unit/test_est.c +++ b/tests/unit/test_est.c @@ -40,6 +40,7 @@ #include "../integration/tls_test_util.h" #include +#include #include #include #include @@ -93,7 +94,9 @@ static int make_test_ca(uint8_t* out, size_t cap, size_t* out_len) /* EST is TLS-only (RFC 7030), so the mock responder terminates TLS using a * self-signed identity the client pins as its trust anchor. */ -struct srv_ctx { int port; uint8_t* body; size_t len; WOLFSSL_CTX* ctx; }; +struct srv_ctx { int port; uint8_t* body; size_t len; WOLFSSL_CTX* ctx; + char request[8192]; size_t request_len; + int post_missing_ctenc; }; static void tls_write_all(WOLFSSL* ssl, const void* buf, int len) { @@ -107,7 +110,7 @@ static void tls_write_all(WOLFSSL* ssl, const void* buf, int len) } } -static void handle_conn(int cs, const struct srv_ctx* sc) +static void handle_conn(int cs, struct srv_ctx* sc) { WOLFSSL* ssl = wolfSSL_new(sc->ctx); if (ssl == NULL) { @@ -156,6 +159,23 @@ static void handle_conn(int cs, const struct srv_ctx* sc) } } + /* Record the raw request so a test can inspect which headers the + * client emitted on the wire. The last connection wins. */ + if (n > 0) { + size_t cap = sizeof(sc->request) - 1; + size_t cpy = (size_t)n < cap ? (size_t)n : cap; + memcpy(sc->request, buf, cpy); + sc->request[cpy] = '\0'; + sc->request_len = cpy; + + /* Every base64 enrollment POST must advertise the encoding. Count + * any POST that does not, so a regression in either session enroll + * variant is caught, not only the last request captured above. */ + if (strncmp(sc->request, "POST", 4) == 0 && + strstr(sc->request, "Content-Transfer-Encoding: base64") == NULL) + ++sc->post_missing_ctenc; + } + char hdr[256]; int hn = snprintf(hdr, sizeof(hdr), "HTTP/1.1 200 OK\r\n" @@ -186,7 +206,7 @@ static void* srv_thread(void* arg) getsockname(ls, (struct sockaddr*)&sa, &slen); sc->port = ntohs(sa.sin_port); - for (int i = 0; i < 3; ++i) { + for (int i = 0; i < 5; ++i) { int cs = accept(ls, NULL, NULL); if (cs < 0) break; @@ -282,6 +302,30 @@ static int test_est_require_server_auth(void) return 0; } +/* Drive a non-blocking session enroll to completion, poll()ing on the + * session fd between WANT_READ / WANT_WRITE returns. */ +static int pump_simple_enroll(WolfCertEstSession* s, + const uint8_t* csr, size_t csr_len, + WolfCertBuffer* out) +{ + int fd = wolfcert_est_session_fd(s); + for (;;) { + int rc = wolfcert_est_session_simple_enroll_nb(s, csr, csr_len, out); + if (rc == WOLFCERT_OK) + return 0; + if (rc == WOLFCERT_ERR_WANT_READ || rc == WOLFCERT_ERR_WANT_WRITE) { + struct pollfd p = { + .fd = fd, + .events = (rc == WOLFCERT_ERR_WANT_WRITE) ? POLLOUT : POLLIN, + }; + if (poll(&p, 1, 5000) <= 0) + return -1; + continue; + } + return -1; + } +} + int main(void) { /* The mock TLS responder may wolfSSL_write() after the client has read its @@ -363,10 +407,42 @@ int main(void) csr.data, csr.len, &reenrolled) == WOLFCERT_OK); wolfcert_buffer_free(&reenrolled); + /* The session-based enroll base64-encodes the CSR too, so it must + * emit Content-Transfer-Encoding: base64 like the one-shot path + * (RFC 7030 section 4.2.1). */ + WolfCertEstSession* sess = NULL; + REQUIRE(wolfcert_est_session_open(&srv, &sess) == WOLFCERT_OK); + WolfCertBuffer sess_enrolled = { 0 }; + REQUIRE(wolfcert_est_session_simple_enroll(sess, csr.data, csr.len, + &sess_enrolled) == WOLFCERT_OK); + REQUIRE(memmem(sess_enrolled.data, sess_enrolled.len, + "BEGIN CERTIFICATE", 17) != NULL); + wolfcert_buffer_free(&sess_enrolled); + wolfcert_est_session_close(sess); + + /* Same requirement for the non-blocking session enroll, which shares the + * base64 body path: drive it through a poll loop and confirm its request + * carries the header too. */ + WolfCertEstSession* sess_nb = NULL; + REQUIRE(wolfcert_est_session_open_async(&srv, &sess_nb) == WOLFCERT_OK); + WolfCertBuffer sess_nb_enrolled = { 0 }; + REQUIRE(pump_simple_enroll(sess_nb, csr.data, csr.len, + &sess_nb_enrolled) == 0); + REQUIRE(memmem(sess_nb_enrolled.data, sess_nb_enrolled.len, + "BEGIN CERTIFICATE", 17) != NULL); + wolfcert_buffer_free(&sess_nb_enrolled); + wolfcert_est_session_close(sess_nb); + wolfcert_buffer_free(&csr); wolfcert_key_free(dk); wolfcert_buffer_free(&b64); pthread_join(tid, NULL); + + /* sc.request holds the last connection, the non-blocking enroll; + * post_missing_ctenc catches any POST enrollment, the blocking session + * variant included, that dropped the header. */ + REQUIRE(strstr(sc.request, "Content-Transfer-Encoding: base64") != NULL); + REQUIRE(sc.post_missing_ctenc == 0); wolfSSL_CTX_free(ctx); free(tls_cert); free(tls_key); From a70348d9f5c6c369ecbb644908d7bd9e1450dbd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Mon, 20 Jul 2026 19:02:47 +0200 Subject: [PATCH 3/7] Use overflow-safe DER length bounds check in CSR parsers csr__take_tl in the EST server and der_take_tl in the CSR-attributes parser validated a decoded DER length with "hdr + len > avail". A long-form length can be up to 0xFFFFFFFF, so on a 32-bit target that addition wraps: a length of FF FF FF FF makes hdr + len truncate to a small value that passes the check, and the parser then walks with a bogus multi-gigabyte length and reads past the end of a truncated, attacker-supplied CSR. The EST enrollment path base64-decodes the request body and reaches csr__has_attr_oid before issuance, so on a 32-bit build with csr-attribute enforcement and no Basic auth this is an unauthenticated out-of-bounds read. Switch both checks to the subtraction form already used by der_seq_len, which cannot overflow because the header size is guaranteed to be no greater than the available bytes at that point. Fixes F-6886 --- src/est/csr_attrs.c | 2 +- src/est/est_server.c | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/est/csr_attrs.c b/src/est/csr_attrs.c index 49f6bd9..5f7c2ee 100644 --- a/src/est/csr_attrs.c +++ b/src/est/csr_attrs.c @@ -73,7 +73,7 @@ static int der_take_tl(const uint8_t* p, size_t avail, hdr = 2 + n; } - if (hdr + len > avail) + if (len > avail - hdr) return WOLFCERT_ERR_PARSE; *out_len = len; diff --git a/src/est/est_server.c b/src/est/est_server.c index 87c46be..90dd88b 100644 --- a/src/est/est_server.c +++ b/src/est/est_server.c @@ -724,7 +724,7 @@ static int csr__take_tl(const uint8_t* p, size_t avail, h = 2 + n; } - if (h + l > avail) + if (l > avail - h) return -1; *len = l; From 6de9638847685789bec592384ca1df2a9ee2225b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Mon, 20 Jul 2026 19:08:40 +0200 Subject: [PATCH 4/7] Zeroize private-key PEM after reading it from the store wolfcert_store_read_key decoded the private key PEM into a heap buffer and then released it with wolfcert_buffer_free, which is a plain free with no zeroization, leaving the plaintext key material in freed heap. The companion wolfcert_store_write_key already forces the buffer to zero before freeing it. Force the decoded PEM to zero before freeing it on the read path so both directions clear key material consistently. Fixes F-6888. --- src/store.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/store.c b/src/store.c index ce1974a..60f0bff 100644 --- a/src/store.c +++ b/src/store.c @@ -440,6 +440,7 @@ int wolfcert_store_read_key(WolfCertStoreOps* store, const char* key_name, return rc; rc = wolfcert_key_from_pem(pem.data, pem.len, store->heap, out_key); + wc_ForceZero(pem.data, (word32)pem.len); wolfcert_buffer_free(&pem); return rc; From 159671c6ff18dd91e9cbed1629a2c62787534f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Mon, 20 Jul 2026 19:18:21 +0200 Subject: [PATCH 5/7] Return a CertRep FAILURE on SCEP enrollment rejection handle_enroll answered a signer/CSR key mismatch and a challenge password failure with a plain HTTP 400 or 403. By that point the server has already parsed the transaction ID, senderNonce and signer certificate from the request, so it can build a proper signed CertRep with pkiStatus FAILURE. A bare HTTP error instead is opaque to a conforming SCEP client, whose transport layer maps any non-200 to a generic HTTP error and never surfaces the SCEP failInfo. The pending queue paths in the same handler already reply with a CertRep FAILURE, so these two sites were inconsistent. Reply with send_cert_rep using pkiStatus 2 and failInfo 2 (badRequest) at both sites, reusing the transaction ID, senderNonce and envelope target already in scope. Tighten the challenge password roundtrip test to assert the client now sees a SCEP protocol failure rather than a transport level HTTP error. Fixes F-6881. --- src/scep/scep_server.c | 14 ++++++++++---- tests/integration/test_scep_roundtrip.c | 10 ++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/scep/scep_server.c b/src/scep/scep_server.c index d9477a1..b9053c9 100644 --- a/src/scep/scep_server.c +++ b/src/scep/scep_server.c @@ -637,14 +637,20 @@ static int handle_enroll(WolfCertServer* s, int fd, const char* mt, if (signer_cert != NULL && signer_matches_csr(signer_cert, signer_cert_len, csr->data, csr->len, s->heap) != WOLFCERT_OK) { - send_text(s, fd, 400, "Signer/CSR Key Mismatch", "text/plain", ""); - return WOLFCERT_ERR_AUTH; + /* Report the failure as a CertRep, then close the connection. */ + s->keep_alive = 0; + return send_cert_rep(s, fd, NULL, 0, env_target, env_target_len, + tid, tid_len, snonce, snonce_len, + "2", "2" /* badRequest */); } if (check_challenge(csr->data, csr->len, s->cfg_challenge, s->heap) != WOLFCERT_OK) { - send_text(s, fd, 403, "Invalid Challenge", "text/plain", ""); - return WOLFCERT_ERR_AUTH; + /* Report the failure as a CertRep, then close the connection. */ + s->keep_alive = 0; + return send_cert_rep(s, fd, NULL, 0, env_target, env_target_len, + tid, tid_len, snonce, snonce_len, + "2", "2" /* badRequest */); } if (s->cfg.scep_require_approval) { diff --git a/tests/integration/test_scep_roundtrip.c b/tests/integration/test_scep_roundtrip.c index 11c1dda..01defd1 100644 --- a/tests/integration/test_scep_roundtrip.c +++ b/tests/integration/test_scep_roundtrip.c @@ -309,17 +309,19 @@ int main(void) WolfCertKey* dk2 = NULL; REQUIRE(wolfcert_key_generate(&kcfg, &dk2) == WOLFCERT_OK); - /* 1) no challenge in CSR -> server 403 -> HTTP layer returns ERR_HTTP */ + /* 1) no challenge in CSR -> server answers with a signed CertRep FAILURE + * (RFC 8894 pkiStatus=2), which the client surfaces as ERR_PROTOCOL. + * A plain HTTP 4xx here would instead read back as ERR_HTTP. */ WolfCertCertMeta meta_none = { .subject_dn = "CN=chal-none" }; WolfCertBuffer csr_none = { 0 }; REQUIRE(wolfcert_csr_build(dk2, &meta_none, &csr_none) == WOLFCERT_OK); WolfCertBuffer out_none = { 0 }; REQUIRE(wolfcert_scep_pkcs_req(&cli2, &caps2, ca2_der->buffer, ca2_der->length, dk2, csr_none.data, csr_none.len, &out_none) - != WOLFCERT_OK); + == WOLFCERT_ERR_PROTOCOL); wolfcert_buffer_free(&csr_none); - /* 2) wrong challenge -> same rejection */ + /* 2) wrong challenge -> same CertRep FAILURE path */ WolfCertCertMeta meta_bad = { .subject_dn = "CN=chal-bad", .challenge_password = "battery-staple" }; WolfCertBuffer csr_bad = { 0 }; @@ -327,7 +329,7 @@ int main(void) WolfCertBuffer out_bad = { 0 }; REQUIRE(wolfcert_scep_pkcs_req(&cli2, &caps2, ca2_der->buffer, ca2_der->length, dk2, csr_bad.data, csr_bad.len, &out_bad) - != WOLFCERT_OK); + == WOLFCERT_ERR_PROTOCOL); wolfcert_buffer_free(&csr_bad); /* 3) correct challenge -> issuance succeeds */ From b46fa795bfb7030ae87b325b49346e7e806f4766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Mon, 20 Jul 2026 19:22:47 +0200 Subject: [PATCH 6/7] Check RNG return values for SCEP transactionID and nonces do_scep_round_trip generated the transactionID and senderNonce with an unchecked wc_InitRng_ex followed by two unchecked wc_RNG_GenerateBlock calls, and send_cert_rep did the same for its senderNonce. If RNG initialization fails, wc_RNG_GenerateBlock returns an error without writing the buffer, so the code went on to send uninitialized stack bytes on the wire as the transactionID and nonce, producing a request or response the peer cannot correctly match. The rest of the SCEP code in scep_msg.c already checks these calls and returns WOLFCERT_ERR_CRYPTO. Check the return of wc_InitRng_ex and each wc_RNG_GenerateBlock at both sites. On failure free the RNG when it was initialized, release the live envelope buffer, and return WOLFCERT_ERR_CRYPTO. Fixes F-6877 and F-6887. --- src/internal.h | 9 +++-- src/scep/scep_client.c | 15 ++++++-- src/scep/scep_server.c | 23 ++++++++++-- tests/integration/test_scep_roundtrip.c | 50 ++++++++++++++++++++++++- 4 files changed, 85 insertions(+), 12 deletions(-) diff --git a/src/internal.h b/src/internal.h index 71ed19d..2e48c0f 100644 --- a/src/internal.h +++ b/src/internal.h @@ -213,11 +213,12 @@ WOLFCERT_API const WolfCertServerOps* wolfcert_scep_server_ops(void); #if defined(WOLFCERT_BUILD_TESTING) /* Test-only fault injection for a started SCEP server: make it emit a CertRep - * without a recipientNonce, and/or sign the CertRep with a throwaway key - * instead of the CA key. Used to drive the client-side rejection paths. Call - * after wolfcert_server_start and before the client request. */ + * without a recipientNonce, sign the CertRep with a throwaway key instead of + * the CA key, and/or force the CertRep senderNonce RNG draw to fail. Used to + * drive the client-side rejection and server error paths. Call after + * wolfcert_server_start and before the client request. */ WOLFCERT_TEST_VIS void wolfcert_scep_server_set_faults(WolfCertServer* s, - int omit_recipient_nonce, int sign_with_wrong_key); + int omit_recipient_nonce, int sign_with_wrong_key, int rng_fail); #endif /* ---- error reporting --------------------------------------------------- */ diff --git a/src/scep/scep_client.c b/src/scep/scep_client.c index 2be0e9d..3c3732a 100644 --- a/src/scep/scep_client.c +++ b/src/scep/scep_client.c @@ -487,11 +487,20 @@ static int do_scep_round_trip(const WolfCertServerCfg* srv, return rc; WC_RNG rng; - wc_InitRng_ex(&rng, heap, WOLFCERT_DEVID_SOFTWARE); + if (wc_InitRng_ex(&rng, heap, WOLFCERT_DEVID_SOFTWARE) != 0) { + wolfcert_buffer_free(&env); + return WOLFCERT_ERR(WOLFCERT_ERR_CRYPTO, "scep", + "RNG init failed for transactionID/nonce"); + } uint8_t txid_gen[16], nonce[16]; - wc_RNG_GenerateBlock(&rng, txid_gen, sizeof(txid_gen)); - wc_RNG_GenerateBlock(&rng, nonce, sizeof(nonce)); + if (wc_RNG_GenerateBlock(&rng, txid_gen, sizeof(txid_gen)) != 0 || + wc_RNG_GenerateBlock(&rng, nonce, sizeof(nonce)) != 0) { + wc_FreeRng(&rng); + wolfcert_buffer_free(&env); + return WOLFCERT_ERR(WOLFCERT_ERR_CRYPTO, "scep", + "RNG failed generating transactionID/nonce"); + } wc_FreeRng(&rng); diff --git a/src/scep/scep_server.c b/src/scep/scep_server.c index b9053c9..f754270 100644 --- a/src/scep/scep_server.c +++ b/src/scep/scep_server.c @@ -89,6 +89,7 @@ typedef struct { * production build. wrong_ca is a throwaway signer generated on first use. */ int fault_omit_recipient_nonce; int fault_sign_with_wrong_key; + int fault_rng_fail; WolfCertCa wrong_ca; int wrong_ca_ready; #endif @@ -96,12 +97,13 @@ typedef struct { #if defined(WOLFCERT_BUILD_TESTING) WOLFCERT_TEST_VIS void wolfcert_scep_server_set_faults(WolfCertServer* s, - int omit_recipient_nonce, int sign_with_wrong_key) + int omit_recipient_nonce, int sign_with_wrong_key, int rng_fail) { ScepPriv* p = (ScepPriv*)s->priv; p->fault_omit_recipient_nonce = omit_recipient_nonce; p->fault_sign_with_wrong_key = sign_with_wrong_key; + p->fault_rng_fail = rng_fail; } #endif @@ -515,10 +517,25 @@ static int send_cert_rep(WolfCertServer* s, int fd, * so the signed pkiMessage is built with an absent pkcsPKIEnvelope. */ WC_RNG rng; - wc_InitRng_ex(&rng, s->heap, WOLFCERT_DEVID_SOFTWARE); + if (wc_InitRng_ex(&rng, s->heap, WOLFCERT_DEVID_SOFTWARE) != 0) { + wolfcert_buffer_free(&resp_env); + send_text(s, fd, 500, "Server Error", "text/plain", ""); + return WOLFCERT_ERR_CRYPTO; + } uint8_t my_nonce[16]; - wc_RNG_GenerateBlock(&rng, my_nonce, sizeof(my_nonce)); + int nonce_rc = wc_RNG_GenerateBlock(&rng, my_nonce, sizeof(my_nonce)); +#if defined(WOLFCERT_BUILD_TESTING) + if (((ScepPriv*)s->priv)->fault_rng_fail) + nonce_rc = -1; +#endif + if (nonce_rc != 0) { + wc_FreeRng(&rng); + wolfcert_buffer_free(&resp_env); + send_text(s, fd, 500, "Server Error", "text/plain", ""); + return WOLFCERT_ERR_CRYPTO; + } + wc_FreeRng(&rng); /* RFC 8894 section 3.1: CertRep MUST carry messageType, pkiStatus, diff --git a/tests/integration/test_scep_roundtrip.c b/tests/integration/test_scep_roundtrip.c index 01defd1..36cb633 100644 --- a/tests/integration/test_scep_roundtrip.c +++ b/tests/integration/test_scep_roundtrip.c @@ -423,7 +423,7 @@ int main(void) .bind_host = "127.0.0.1", .bind_port = 0 }; WolfCertServer* s3 = NULL; REQUIRE(wolfcert_server_start(&cfg3, &s3) == WOLFCERT_OK); - wolfcert_scep_server_set_faults(s3, 1 /* omit recipientNonce */, 0); + wolfcert_scep_server_set_faults(s3, 1 /* omit recipientNonce */, 0, 0); pthread_t tid3; REQUIRE(pthread_create(&tid3, NULL, server_thread, s3) == 0); @@ -468,7 +468,7 @@ int main(void) .bind_host = "127.0.0.1", .bind_port = 0 }; WolfCertServer* s4 = NULL; REQUIRE(wolfcert_server_start(&cfg4, &s4) == WOLFCERT_OK); - wolfcert_scep_server_set_faults(s4, 0, 1 /* sign with wrong key */); + wolfcert_scep_server_set_faults(s4, 0, 1 /* sign with wrong key */, 0); pthread_t tid4; REQUIRE(pthread_create(&tid4, NULL, server_thread, s4) == 0); @@ -503,6 +503,52 @@ int main(void) wolfcert_buffer_free(&out4); wolfcert_key_free(dk4); + /* ---- senderNonce RNG failure must abort, not leak stack ------------- + * If the RNG draw for the CertRep senderNonce fails, the server must not + * build a CertRep over an uninitialized buffer. It frees its scratch, + * answers HTTP 500, and returns an error, which the client sees as a + * transport failure. Drive a server whose nonce draw is forced to fail + * and confirm the enrollment does not succeed. This also exercises the + * error-path cleanup under the sanitizer builds. */ + WolfCertServerCfgSrv cfg5 = { .protocol = WOLFCERT_PROTO_SCEP, + .bind_host = "127.0.0.1", .bind_port = 0 }; + WolfCertServer* s5 = NULL; + REQUIRE(wolfcert_server_start(&cfg5, &s5) == WOLFCERT_OK); + wolfcert_scep_server_set_faults(s5, 0, 0, 1 /* RNG draw fails */); + pthread_t tid5; + REQUIRE(pthread_create(&tid5, NULL, server_thread, s5) == 0); + + char url5[128]; + snprintf(url5, sizeof(url5), "http://127.0.0.1:%u/scep", wolfcert_server_port(s5)); + WolfCertServerCfg cli5 = { .protocol = WOLFCERT_PROTO_SCEP, .server_url = url5 }; + + WolfCertScepCaps caps5 = { 0 }; + REQUIRE(wolfcert_scep_get_ca_caps(&cli5, &caps5) == WOLFCERT_OK); + WolfCertBuffer ca5_pem = { 0 }; + REQUIRE(wolfcert_scep_get_ca_cert(&cli5, &ca5_pem) == WOLFCERT_OK); + DerBuffer* ca5_der = NULL; + REQUIRE(wc_PemToDer(ca5_pem.data, (long)ca5_pem.len, CERT_TYPE, + &ca5_der, NULL, NULL, NULL) == 0); + + WolfCertKey* dk5 = NULL; + REQUIRE(wolfcert_key_generate(&kcfg, &dk5) == WOLFCERT_OK); + WolfCertCertMeta meta5 = { .subject_dn = "CN=scep-rng-fail" }; + WolfCertBuffer csr5 = { 0 }; + REQUIRE(wolfcert_csr_build(dk5, &meta5, &csr5) == WOLFCERT_OK); + WolfCertBuffer out5 = { 0 }; + REQUIRE(wolfcert_scep_pkcs_req(&cli5, &caps5, ca5_der->buffer, ca5_der->length, + dk5, csr5.data, csr5.len, &out5) + == WOLFCERT_ERR_HTTP); + + wolfcert_server_stop(s5); + pthread_join(tid5, NULL); + wolfcert_server_free(s5); + wc_FreeDer(&ca5_der); + wolfcert_buffer_free(&ca5_pem); + wolfcert_buffer_free(&csr5); + wolfcert_buffer_free(&out5); + wolfcert_key_free(dk5); + wc_FreeDer(&ca_der); wc_FreeDer(&issued_der); wolfcert_buffer_free(&ca_pem); From 7ac927a8a0c6e14975d5428730d5e76b4fc609c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Mon, 20 Jul 2026 19:28:36 +0200 Subject: [PATCH 7/7] Match SCEP GetCACaps tokens per line, not by substring has_cap scanned the entire GetCACaps response for the capability name as a substring. RFC 8894 section 3.5.2 defines the response as a newline-delimited list of exact tokens, so a server returning an unknown future token that contains a known one as a substring, such as Renewal-Extra or AESGCM, would make the client believe the server advertised Renewal or AES and possibly pick a code path the server does not support. Walk the body line by line, strip a trailing carriage return, and match a line only when it equals the capability token exactly and case-insensitively. Add a GetCACaps parsing test that serves substring-only tokens alongside one exact token and asserts only the exact token is detected. Fixes F-6882. --- src/scep/scep_client.c | 35 ++++++++- tests/integration/test_scep_roundtrip.c | 99 ++++++++++++++++++++++++- 2 files changed, 128 insertions(+), 6 deletions(-) diff --git a/src/scep/scep_client.c b/src/scep/scep_client.c index 3c3732a..9ddf25c 100644 --- a/src/scep/scep_client.c +++ b/src/scep/scep_client.c @@ -81,13 +81,40 @@ static void fill_common(const WolfCertServerCfg* srv, WolfCertHttpRequest* req) /* ---- GetCACaps ---------------------------------------------------------- */ +/* RFC 8894 section 3.5.2: GetCACaps is a newline-delimited list of exact + * capability tokens. Match a whole line, not a substring, so an unknown + * token that merely contains a known one is not mistaken for it. */ static int has_cap(const char* body, size_t len, const char* needle) { - size_t nl = strlen(needle); - for (size_t i = 0; i + nl <= len; ++i) { - if (strncasecmp(body + i, needle, nl) == 0) { + size_t nl; + size_t i = 0; + + /* Guard both pointers before dereferencing either: strlen(needle) + * below and body[] indexing in the scan. `len` needs no separate + * bound - every body[] access is gated by `i < len`, and the + * strncasecmp only fires when the matched token length equals nl, + * so it never reads past body + len. */ + if (body == NULL || needle == NULL) + return 0; + + nl = strlen(needle); + + while (i < len) { + size_t start = i; + size_t tlen; + + while (i < len && body[i] != '\n') + ++i; + + tlen = i - start; + if (tlen > 0 && body[start + tlen - 1] == '\r') + --tlen; + + if (tlen == nl && strncasecmp(body + start, needle, nl) == 0) return 1; - } + + if (i < len) + ++i; } return 0; diff --git a/tests/integration/test_scep_roundtrip.c b/tests/integration/test_scep_roundtrip.c index 36cb633..ac5fea1 100644 --- a/tests/integration/test_scep_roundtrip.c +++ b/tests/integration/test_scep_roundtrip.c @@ -35,14 +35,15 @@ #include "internal.h" /* whitebox SCEP pkiMessage helpers */ +#include +#include #include #include #include #include #include #include -#include -#include +#include #include #define REQUIRE(cond) \ @@ -185,9 +186,103 @@ static int check_get_fallback(const WolfCertServerCfg* cli, return rc; } +/* Minimal single-shot HTTP responder that answers any request with a + * caller-supplied GetCACaps body, so a test can drive capability parsing + * with a body the real server would never emit. */ +struct caps_ctx { int port; const char* body; }; + +static void* caps_srv_thread(void* arg) +{ + struct caps_ctx* cc = (struct caps_ctx*)arg; + int ls = socket(AF_INET, SOCK_STREAM, 0); + if (ls < 0) + return NULL; + int yes = 1; + setsockopt(ls, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); + struct sockaddr_in sa = { .sin_family = AF_INET, .sin_port = htons(0), + .sin_addr.s_addr = htonl(INADDR_LOOPBACK) }; + if (bind(ls, (struct sockaddr*)&sa, sizeof(sa)) < 0) { + close(ls); + return NULL; + } + if (listen(ls, 1) < 0) { + close(ls); + return NULL; + } + socklen_t slen = sizeof(sa); + getsockname(ls, (struct sockaddr*)&sa, &slen); + cc->port = ntohs(sa.sin_port); + + int cs = accept(ls, NULL, NULL); + close(ls); + if (cs < 0) + return NULL; + + char buf[1024]; + size_t n = 0; + while (n < sizeof(buf) - 1) { + ssize_t r = recv(cs, buf + n, sizeof(buf) - 1 - n, 0); + if (r <= 0) + break; + n += (size_t)r; + buf[n] = '\0'; + if (strstr(buf, "\r\n\r\n") != NULL) + break; + } + + char resp[512]; + int rn = snprintf(resp, sizeof(resp), + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/plain\r\n" + "Content-Length: %zu\r\n" + "Connection: close\r\n\r\n%s", + strlen(cc->body), cc->body); + if (rn > 0) + send(cs, resp, (size_t)rn, 0); + shutdown(cs, SHUT_WR); + close(cs); + return NULL; +} + +/* RFC 8894 section 3.5.2: GetCACaps is a newline-delimited list of exact + * tokens. A future token that merely contains a known one as a substring + * (Renewal-Extra, AESGCM) must not be read as advertising that capability. */ +static int test_caps_token_matching(void) +{ + const char* caps_body = + "POSTPKIOperation\r\n" + "Renewal-Extra\r\n" + "AESGCM\r\n"; + struct caps_ctx cc = { .port = 0, .body = caps_body }; + pthread_t tid; + REQUIRE(pthread_create(&tid, NULL, caps_srv_thread, &cc) == 0); + for (int i = 0; i < 200 && cc.port == 0; ++i) { + const struct timespec ts = { 0, 5 * 1000 * 1000 }; + nanosleep(&ts, NULL); + } + REQUIRE(cc.port != 0); + + char url[128]; + snprintf(url, sizeof(url), "http://127.0.0.1:%d/scep", cc.port); + WolfCertServerCfg cli = { .protocol = WOLFCERT_PROTO_SCEP, .server_url = url }; + WolfCertScepCaps caps = { 0 }; + REQUIRE(wolfcert_scep_get_ca_caps(&cli, &caps) == WOLFCERT_OK); + pthread_join(tid, NULL); + + /* Exact token still matches; substring-only lines do not. */ + REQUIRE(caps.post_pki_operation == 1); + REQUIRE(caps.renewal == 0); + REQUIRE(caps.aes == 0); + return 0; +} + int main(void) { REQUIRE(wolfcert_init(NULL) == WOLFCERT_OK); + + if (test_caps_token_matching()) + return 1; + WolfCertServerCfgSrv cfg = { .protocol = WOLFCERT_PROTO_SCEP, .bind_host = "127.0.0.1", .bind_port = 0 }; WolfCertServer* s = NULL;