From 1fce09302a72ad67489d27005692e4d3f58a011a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Thu, 30 Jul 2026 10:01:17 +0200 Subject: [PATCH 01/11] scep: add the wolfSCEP interop options Three wolfSCEP deployment shapes were unreachable. Add the client-side options that cover them, all zero-init to the previous behavior. scep_ca_id is sent as the message= query parameter on GetCACaps and GetCACert (RFC 8894 section 3.5.2), which is how a multi-CA responder is told which CA the request is for. wolfcert_scep_build_getca_url builds both URLs so the percent-encoding rule lives in one place. scep_txid_mode selects how the enrollment transactionID is derived. WOLFCERT_SCEP_TXID_PUBKEY_HASH takes the SHA-256 of the signer SubjectPublicKeyInfo (RFC 8894 section 3.2.1) and hex-encodes it in upper case, matching wolfSCEP, so that repeated enrollments of one key reuse a single transactionID and the server can recognise the retry. The default stays a fresh random value. scep_content_cipher forces the content encryption of the request EnvelopedData. The caps-driven default picks AES-128-CBC when the CA advertises AES and triple DES otherwise, but no GetCACaps keyword advertises AES-256, so a peer that requires it can only be reached by asking for it explicitly. --- CLAUDE.md | 6 +- docs/ARCHITECTURE.md | 13 ++ src/internal.h | 6 + src/scep/scep_client.c | 205 ++++++++++++++++++++---- src/scep/scep_server.c | 14 +- tests/integration/test_scep_roundtrip.c | 134 ++++++++++++++++ tests/unit/test_scep_msg.c | 85 ++++++++++ wolfcert/types.h | 35 ++++ 8 files changed, 461 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 28d06b4..6e1b1c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,11 +40,13 @@ ED25519, ED448, MLDSA). RSA, ECC, Ed25519, Ed448 and ML-DSA are each `WOLFCERT_ERR_UNSUPPORTED`), with two constraints: at least one key algorithm must be present, and **SCEP requires RSA** (RFC 8894 is RSA-only) so a `NO_RSA` wolfSSL hard-fails unless SCEP is disabled. SCEP -content encryption uses AES-128-CBC (the RFC 8894 `AES` capability); when +content encryption defaults to AES-128-CBC (the RFC 8894 `AES` capability); when a legacy peer does not advertise `AES` the client falls back to triple DES-CBC, which needs a wolfSSL built with 3DES support. A wolfSSL built `NO_DES3` still interoperates with any AES-advertising peer, but the -client rejects a non-AES peer with `WOLFCERT_ERR_UNSUPPORTED`. TLS: +client rejects a non-AES peer with `WOLFCERT_ERR_UNSUPPORTED`. A caller can +override this per-connection via `WolfCertServerCfg.scep_content_cipher` +(e.g. force AES-256-CBC for a peer that requires it). TLS: the HTTPS transport pins its floor to TLS 1.2, or TLS 1.3 when wolfSSL is built `WOLFSSL_NO_TLS12`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 80f98a3..acccfda 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -214,6 +214,19 @@ prepared (envelope + sign), sent, and its CertRep parsed as one logical step; in async mode only the HTTP transport is pumped through `WANT_READ`/`WANT_WRITE` while the crypto stays synchronous. +**Client options** (`WolfCertServerCfg`, all zero-init to the default behavior): +- `scep_ca_id` — CA identifier sent as `message=` on GetCACaps / GetCACert + to select a specific CA on a multi-CA responder; omitted when NULL. +- `scep_txid_mode` — `WOLFCERT_SCEP_TXID_RANDOM` (default) or `..._PUBKEY_HASH`, + which derives the transactionID as the SHA-256 of the signer + public key (RFC 8894 §3.2.1) so retries of the same key reuse one + ID. Matches wolfSCEP's derivation. +- `scep_content_cipher` — `WOLFCERT_SCEP_CIPHER_AUTO` (default: the caps-driven + AES-128-CBC / 3DES choice) or an explicit `AES128` / `AES256` / `DES3`. + There is no GetCACaps token for AES-256, so forcing it is a deliberate choice + for a peer that requires it (e.g. a wolfSCEP deployment); the envelope is + self-describing, so any AES-capable recipient decrypts it by OID. + **Signed attributes.** The CertRep carries the full RFC 8894 §3.1 signed-attribute set (including `recipientNonce`) — up to 9 entries alongside the CMS auto-defaults. wolfSSL's PKCS#7 encoder grows its signed-attribute diff --git a/src/internal.h b/src/internal.h index e895d4c..e7e8ed3 100644 --- a/src/internal.h +++ b/src/internal.h @@ -355,6 +355,12 @@ WOLFCERT_TEST_VIS int wolfcert_scep_envelop(const uint8_t* ra_cert_der, * WOLFCERT_SCEP_MAX_GET_URL. Exposed for white-box testing. */ WOLFCERT_TEST_VIS int wolfcert_scep_build_pki_get_url(const char* base, const uint8_t* pki_msg, size_t pki_len, void* heap, char** out_url); + +/* Build a GetCACaps / GetCACert URL: base?operation=[&message=]. + * The CA identifier is appended (URL-encoded) only when ca_id is non-NULL and + * non-empty. Returns a heap-allocated URL owned by the caller, or NULL. */ +WOLFCERT_TEST_VIS char* wolfcert_scep_build_getca_url(const char* base, + const char* op, const char* ca_id, void* heap); int wolfcert_scep_deenvelop(const uint8_t* recipient_cert_der, size_t recipient_cert_len, const uint8_t* recipient_key_der, size_t recipient_key_len, const uint8_t* env_der, size_t env_len, diff --git a/src/scep/scep_client.c b/src/scep/scep_client.c index 57759b1..1e8306e 100644 --- a/src/scep/scep_client.c +++ b/src/scep/scep_client.c @@ -128,7 +128,8 @@ int wolfcert_scep_get_ca_caps(const WolfCertServerCfg* srv, WolfCertScepCaps* ou void* heap = srv->heap ? srv->heap : wolfcert_default_heap(); memset(out, 0, sizeof(*out)); - char* url = append_query(srv->server_url, "GetCACaps", heap); + char* url = wolfcert_scep_build_getca_url(srv->server_url, "GetCACaps", + srv->scep_ca_id, heap); if (url == NULL) return WOLFCERT_ERR_MEMORY; @@ -188,7 +189,8 @@ int wolfcert_scep_get_ca_cert_enc(const WolfCertServerCfg* srv, WolfCertEncoding void* heap = srv->heap ? srv->heap : wolfcert_default_heap(); - char* url = append_query(srv->server_url, "GetCACert", heap); + char* url = wolfcert_scep_build_getca_url(srv->server_url, "GetCACert", + srv->scep_ca_id, heap); if (url == NULL) return WOLFCERT_ERR_MEMORY; @@ -365,6 +367,38 @@ static char* url_encode(const uint8_t* in, size_t in_len, void* heap) return out; } +WOLFCERT_TEST_VIS char* wolfcert_scep_build_getca_url(const char* base, + const char* op, const char* ca_id, void* heap) +{ + char* head = append_query(base, op, heap); + if (head == NULL) + return NULL; + + /* RFC 8894 section 4.2/4.5: the `message` for GetCACert / GetCACaps is the + * CA identifier, which the caller may omit entirely. */ + if (ca_id == NULL || ca_id[0] == '\0') + return head; + + char* enc = url_encode((const uint8_t*)ca_id, strlen(ca_id), heap); + if (enc == NULL) { + WOLFCERT_XFREE(head, heap); + return NULL; + } + + size_t need = strlen(head) + strlen("&message=") + strlen(enc) + 1; + char* url = (char*)WOLFCERT_XMALLOC(need, heap); + if (url == NULL) { + WOLFCERT_XFREE(head, heap); + WOLFCERT_XFREE(enc, heap); + return NULL; + } + snprintf(url, need, "%s&message=%s", head, enc); + + WOLFCERT_XFREE(head, heap); + WOLFCERT_XFREE(enc, heap); + return url; +} + /* Build the HTTP GET URL for a PKIOperation fallback (RFC 8894 section 4.1): * base?operation=PKIOperation&message= * Returns WOLFCERT_OK with *out_url owned by the caller, WOLFCERT_ERR_MEMORY, @@ -483,12 +517,52 @@ static int run_pki_op(const WolfCertServerCfg* srv, /* Shared SCEP round-trip sizes. */ #define SCEP_NONCE_SZ 16 +/* Derive a transactionID from the signer's public key (RFC 8894 section 3.2.1): + * SHA-256 over the subjectPublicKey BIT STRING contents, which is what + * DecodedCert.publicKey spans, upper-case hex encoded (64 chars). That is the + * same input wolfSSL's PKCS7.publicKey carries, so the value matches what a + * wolfSCEP-based peer derives, and it is the key itself rather than the + * enclosing SubjectPublicKeyInfo with its AlgorithmIdentifier. Retries of the + * same key therefore reuse one transactionID. *out_txid is heap-allocated and + * owned by the caller. */ +static int derive_txid_pubkey(const uint8_t* signer_cert, size_t signer_cert_len, + uint8_t** out_txid, size_t* out_txid_len, void* heap) +{ + DecodedCert dc; + wc_InitDecodedCert(&dc, signer_cert, (word32)signer_cert_len, heap); + int rc = wc_ParseCert(&dc, CERT_TYPE, NO_VERIFY, NULL); + if (rc != 0) { + wc_FreeDecodedCert(&dc); + return WOLFCERT_ERR_WC(rc, "scep", "parse signer cert for transactionID"); + } + + uint8_t digest[WC_SHA256_DIGEST_SIZE]; + rc = wc_Sha256Hash(dc.publicKey, dc.pubKeySize, digest); + wc_FreeDecodedCert(&dc); + if (rc != 0) + return WOLFCERT_ERR_WC(rc, "scep", "hash signer public key"); + + size_t hexlen = WC_SHA256_DIGEST_SIZE * 2; + uint8_t* txid = (uint8_t*)WOLFCERT_XMALLOC(hexlen, heap); + if (txid == NULL) + return WOLFCERT_ERR_MEMORY; + + static const char HEX[] = "0123456789ABCDEF"; /* upper-case, per wolfSCEP */ + for (size_t i = 0; i < WC_SHA256_DIGEST_SIZE; ++i) { + txid[i*2] = (uint8_t)HEX[digest[i] >> 4]; + txid[i*2+1] = (uint8_t)HEX[digest[i] & 0x0F]; + } + *out_txid = txid; + *out_txid_len = hexlen; + return WOLFCERT_OK; +} + /* Build the enveloped + signed pkiMessage for one SCEP round trip. Produces the - * DER message in *out_pki, the effective transactionID in *out_txid (a fresh - * 32-hex value when txid_override is NULL, else a copy of the override; owned - * by the caller, free with WOLFCERT_XFREE) and the senderNonce in out_nonce - - * both retained by the caller to validate the CertRep after the transport - * completes. */ + * DER message in *out_pki, the effective transactionID in *out_txid (owned by + * the caller, free with WOLFCERT_XFREE) and the senderNonce in out_nonce - both + * retained by the caller to validate the CertRep after the transport completes. + * `cipher` overrides the content-encryption algorithm; `txid_mode` selects the + * transactionID derivation when txid_override is NULL. */ static int scep_prepare(void* heap, const WolfCertScepCaps* caps, const uint8_t* ra_cert, size_t ra_cert_len, const uint8_t* signer_cert, size_t signer_cert_len, @@ -496,6 +570,8 @@ static int scep_prepare(void* heap, const WolfCertScepCaps* caps, const char* msg_type, const uint8_t* envelope_content, size_t envelope_content_len, const uint8_t* txid_override, size_t txid_override_len, + WolfCertScepTxidMode txid_mode, + WolfCertScepContentCipher cipher, WolfCertBuffer* out_pki, uint8_t** out_txid, size_t* out_txid_len, uint8_t* out_nonce) @@ -503,21 +579,60 @@ static int scep_prepare(void* heap, const WolfCertScepCaps* caps, int hash_oid = pick_hash_oid(caps); int enc_oid; - /* RFC 8894: the GetCACaps "AES" keyword advertises AES-128-CBC as the - * content cipher. Honour it when offered; otherwise fall back to the - * mandatory-to-implement triple DES-CBC. A wolfSSL built without 3DES - * cannot serve that fallback, so reject the legacy peer with a clear - * error instead of a cryptic encoder failure. */ - if (caps != NULL && caps->aes) { - enc_oid = AES128CBCb; - } - else { + /* An explicit content cipher overrides the caps-driven choice, so a caller + * can talk to a peer that requires a particular algorithm (e.g. a wolfSCEP + * deployment expecting AES-256). AUTO keeps the RFC 8894 default: the + * GetCACaps "AES" keyword advertises AES-128-CBC; otherwise fall back to + * the mandatory-to-implement triple DES-CBC. A wolfSSL built without 3DES + * cannot serve 3DES, so reject that request/fallback with a clear error + * instead of a cryptic encoder failure. */ + switch (cipher) { + case WOLFCERT_SCEP_CIPHER_AES128: +#if !defined(WOLFSSL_AES_128) || !defined(HAVE_AES_CBC) + return WOLFCERT_ERR(WOLFCERT_ERR_UNSUPPORTED, "scep", + "AES-128-CBC content cipher requested but wolfSSL lacks it"); +#else + enc_oid = AES128CBCb; + break; +#endif + case WOLFCERT_SCEP_CIPHER_AES256: +#if !defined(WOLFSSL_AES_256) || !defined(HAVE_AES_CBC) + return WOLFCERT_ERR(WOLFCERT_ERR_UNSUPPORTED, "scep", + "AES-256-CBC content cipher requested but wolfSSL lacks it"); +#else + enc_oid = AES256CBCb; + break; +#endif + case WOLFCERT_SCEP_CIPHER_DES3: #ifdef NO_DES3 - return WOLFCERT_ERR(WOLFCERT_ERR_UNSUPPORTED, "scep", - "peer does not advertise AES and wolfSSL lacks 3DES fallback"); + return WOLFCERT_ERR(WOLFCERT_ERR_UNSUPPORTED, "scep", + "3DES content cipher requested but wolfSSL was built NO_DES3"); +#else + enc_oid = DES3b; + break; +#endif + case WOLFCERT_SCEP_CIPHER_AUTO: + default: + /* The "AES" capability names AES-128-CBC and nothing else, so a + * wolfSSL that cannot do AES-128 has to take the 3DES path even + * against an AES-advertising peer rather than silently substitute + * a cipher the CA never offered. */ +#if defined(WOLFSSL_AES_128) && defined(HAVE_AES_CBC) + if (caps != NULL && caps->aes) { + enc_oid = AES128CBCb; + } + else +#endif + { +#ifdef NO_DES3 + return WOLFCERT_ERR(WOLFCERT_ERR_UNSUPPORTED, "scep", + "no usable content cipher: AES-128-CBC unavailable or " + "unadvertised, and wolfSSL lacks the 3DES fallback"); #else - enc_oid = DES3b; + enc_oid = DES3b; #endif + } + break; } WolfCertBuffer env = { 0 }; @@ -557,22 +672,37 @@ static int scep_prepare(void* heap, const WolfCertScepCaps* caps, } /* Hold the transactionID on the heap rather than in a fixed buffer: an - * override is whatever the server chose for the earlier request, and - * RFC 8894 puts no length bound on it. */ - size_t txid_len = (txid_override != NULL) ? txid_override_len - : sizeof(txid_gen) * 2; - uint8_t* txid = (uint8_t*)WOLFCERT_XMALLOC(txid_len, heap); - if (txid == NULL) { - wc_ForceZero(txid_gen, (word32)sizeof(txid_gen)); - wolfcert_buffer_free(&env); - return WOLFCERT_ERR_MEMORY; - } - + * override is whatever the server chose for the earlier request (RFC 8894 + * puts no length bound on it), and the pubkey-hash form is 64 hex chars. */ + size_t txid_len; + uint8_t* txid; if (txid_override != NULL) { + txid_len = txid_override_len; + txid = (uint8_t*)WOLFCERT_XMALLOC(txid_len, heap); + if (txid == NULL) { + wc_ForceZero(txid_gen, (word32)sizeof(txid_gen)); + wolfcert_buffer_free(&env); + return WOLFCERT_ERR_MEMORY; + } memcpy(txid, txid_override, txid_override_len); } + else if (txid_mode == WOLFCERT_SCEP_TXID_PUBKEY_HASH) { + rc = derive_txid_pubkey(signer_cert, signer_cert_len, &txid, &txid_len, heap); + if (rc != WOLFCERT_OK) { + wc_ForceZero(txid_gen, (word32)sizeof(txid_gen)); + wolfcert_buffer_free(&env); + return rc; + } + } else { static const char HEX[] = "0123456789abcdef"; + txid_len = sizeof(txid_gen) * 2; + txid = (uint8_t*)WOLFCERT_XMALLOC(txid_len, heap); + if (txid == NULL) { + wc_ForceZero(txid_gen, (word32)sizeof(txid_gen)); + wolfcert_buffer_free(&env); + return WOLFCERT_ERR_MEMORY; + } for (size_t i = 0; i < sizeof(txid_gen); ++i) { txid[i*2] = (uint8_t)HEX[txid_gen[i] >> 4]; txid[i*2+1] = (uint8_t)HEX[txid_gen[i] & 0x0F]; @@ -747,6 +877,7 @@ static int do_scep_round_trip(const WolfCertServerCfg* srv, signer_cert, signer_cert_len, signer_key, signer_key_len, msg_type, envelope_content, envelope_content_len, txid_override, txid_override_len, + srv->scep_txid_mode, srv->scep_content_cipher, &pki, &txid, &txid_len, nonce); if (rc != WOLFCERT_OK) return rc; @@ -1044,7 +1175,8 @@ int wolfcert_scep_get_next_ca_cert(const WolfCertServerCfg* srv, void* heap = srv->heap ? srv->heap : wolfcert_default_heap(); - char* url = append_query(srv->server_url, "GetNextCACert", heap); + char* url = wolfcert_scep_build_getca_url(srv->server_url, "GetNextCACert", + srv->scep_ca_id, heap); if (url == NULL) return WOLFCERT_ERR_MEMORY; @@ -1096,6 +1228,8 @@ struct WolfCertScepSession { char* server_url; /* full SCEP endpoint URL, owned */ void* heap; int nonblocking; /* opened via _open_async (_nb calls) vs _open (_ex) */ + WolfCertScepTxidMode txid_mode; /* captured from cfg at open */ + WolfCertScepContentCipher content_cipher; /* captured from cfg at open */ /* Async in-flight state: one round trip at a time. */ int in_active; @@ -1165,9 +1299,11 @@ static int scep_session_open_common(const WolfCertServerCfg* srv, int nonblockin } memset(s, 0, sizeof(*s)); - s->heap = heap; - s->nonblocking = nonblocking; - s->server_url = wolfcert_strdup(srv->server_url, heap); + s->heap = heap; + s->nonblocking = nonblocking; + s->txid_mode = srv->scep_txid_mode; + s->content_cipher = srv->scep_content_cipher; + s->server_url = wolfcert_strdup(srv->server_url, heap); if (s->server_url == NULL) { WOLFCERT_XFREE(s, heap); WOLFCERT_XFREE(origin, heap); @@ -1305,6 +1441,7 @@ static int scep_session_begin(WolfCertScepSession* s, const WolfCertScepCaps* ca signer_cert, signer_cert_len, signer_key, signer_key_len, msg_type, envelope_content, envelope_content_len, txid_override, txid_override_len, + s->txid_mode, s->content_cipher, &pki, &s->in_txid, &s->in_txid_len, s->in_nonce); if (rc != WOLFCERT_OK) { scep_async_reset(s); diff --git a/src/scep/scep_server.c b/src/scep/scep_server.c index f754270..d51eab0 100644 --- a/src/scep/scep_server.c +++ b/src/scep/scep_server.c @@ -45,6 +45,18 @@ #include #include +/* Content-encryption cipher the CertRep EnvelopedData carries. Mirrors the + * client's AUTO choice: RFC 8894 section 3.5.2's "AES" capability names + * AES-128-CBC and nothing else, with the mandatory-to-implement triple DES-CBC + * as the fallback for a wolfSSL that cannot do AES-128. */ +#if defined(WOLFSSL_AES_128) && defined(HAVE_AES_CBC) + #define SCEP_SRV_ENC_OID AES128CBCb +#elif !defined(NO_DES3) + #define SCEP_SRV_ENC_OID DES3b +#else + #error "wolfCert's SCEP test server needs AES-128-CBC or 3DES-CBC; rebuild wolfSSL with one of them, or configure without the test server" +#endif + typedef struct { /* rawbuf owns the request-line + header bytes read off the wire. It is * heap-allocated (REQ_BUF_SZ + QUERY_SZ) so an RFC 8894 GET PKIOperation, @@ -503,7 +515,7 @@ static int send_cert_rep(WolfCertServer* s, int fd, } rc = wolfcert_scep_envelop(env_target, env_target_len, - p7.data, p7.len, AES128CBCb, &resp_env, + p7.data, p7.len, SCEP_SRV_ENC_OID, &resp_env, s->heap); wolfcert_buffer_free(&p7); diff --git a/tests/integration/test_scep_roundtrip.c b/tests/integration/test_scep_roundtrip.c index 6d8a0ea..c3f07e4 100644 --- a/tests/integration/test_scep_roundtrip.c +++ b/tests/integration/test_scep_roundtrip.c @@ -185,6 +185,122 @@ static int check_get_fallback(const WolfCertServerCfg* cli, return rc; } +/* WolfCertServerCfg.scep_txid_mode = PUBKEY_HASH: the transactionID must be the + * 64-char upper-case hex SHA-256 of the enrollee SubjectPublicKeyInfo, must + * match a value recomputed from the CSR, and must be deterministic (a second + * enrollment of the same key reuses it). Owns and frees everything it makes. */ +static int check_pubkey_txid(const WolfCertServerCfg* cli, + const WolfCertScepCaps* caps, + const WolfCertKeyCfg* kcfg, + const uint8_t* ca_der_buf, size_t ca_der_len) +{ + WolfCertServerCfg cli_ph = *cli; + WolfCertCertMeta meta = { .subject_dn = "CN=device-txid" }; + WolfCertKey* key = NULL; + WolfCertBuffer csr = { 0 }; + WolfCertScepResult r1 = { 0 }, r2 = { 0 }; + int rc; + + cli_ph.scep_txid_mode = WOLFCERT_SCEP_TXID_PUBKEY_HASH; + + rc = wolfcert_key_generate(kcfg, &key); + if (rc == WOLFCERT_OK) + rc = wolfcert_csr_build(key, &meta, &csr); + if (rc == WOLFCERT_OK) + rc = wolfcert_scep_pkcs_req_ex(&cli_ph, caps, ca_der_buf, ca_der_len, + ca_der_buf, ca_der_len, key, + csr.data, csr.len, &r1); + if (rc == WOLFCERT_OK && r1.status != WOLFCERT_SCEP_STATUS_SUCCESS) + rc = -1; + if (rc == WOLFCERT_OK && r1.transaction_id_len != 64) /* SHA-256 hex */ + rc = -1; + + /* Recompute SHA-256(SPKI) from the CSR and compare, upper-case hex. */ + if (rc == WOLFCERT_OK) { + DecodedCert dc; + wc_InitDecodedCert(&dc, csr.data, (word32)csr.len, NULL); + if (wc_ParseCert(&dc, CERTREQ_TYPE, NO_VERIFY, NULL) != 0) { + rc = -1; + } + else { + uint8_t digest[WC_SHA256_DIGEST_SIZE]; + if (wc_Sha256Hash(dc.publicKey, dc.pubKeySize, digest) != 0) { + rc = -1; + } + else { + static const char H[] = "0123456789ABCDEF"; + char hex[2 * WC_SHA256_DIGEST_SIZE]; + for (int i = 0; i < WC_SHA256_DIGEST_SIZE; i++) { + hex[i*2] = H[digest[i] >> 4]; + hex[i*2+1] = H[digest[i] & 0x0F]; + } + if (memcmp(r1.transaction_id, hex, sizeof(hex)) != 0) + rc = -1; + } + } + /* Freed on both branches: wc_ParseCert allocates before it can fail. */ + wc_FreeDecodedCert(&dc); + } + + /* Deterministic: enrolling the same key again reuses the transactionID. */ + if (rc == WOLFCERT_OK) + rc = wolfcert_scep_pkcs_req_ex(&cli_ph, caps, ca_der_buf, ca_der_len, + ca_der_buf, ca_der_len, key, + csr.data, csr.len, &r2); + if (rc == WOLFCERT_OK && + (r2.transaction_id_len != r1.transaction_id_len || + memcmp(r1.transaction_id, r2.transaction_id, r1.transaction_id_len) != 0)) + rc = -1; + + wolfcert_scep_result_free(&r1); + wolfcert_scep_result_free(&r2); + wolfcert_buffer_free(&csr); + wolfcert_key_free(key); + return rc; +} + +/* The content-cipher checks force an AES-CBC cipher, so they only exist when + * wolfSSL can supply one. */ +#if defined(HAVE_AES_CBC) && \ + (defined(WOLFSSL_AES_128) || defined(WOLFSSL_AES_256)) +#define WOLFCERT_TEST_HAVE_CIPHER_OVERRIDE +#endif + +#ifdef WOLFCERT_TEST_HAVE_CIPHER_OVERRIDE +/* WolfCertServerCfg.scep_content_cipher override: enrolling with an explicit + * cipher must still issue a cert - the server de-envelops whatever OID the + * request carries - proving AES-256 (and explicit AES-128) interoperate. */ +static int check_content_cipher(const WolfCertServerCfg* cli, + const WolfCertScepCaps* caps, + const WolfCertKeyCfg* kcfg, + const uint8_t* ca_der_buf, size_t ca_der_len, + WolfCertScepContentCipher cipher) +{ + WolfCertServerCfg c = *cli; + WolfCertCertMeta meta = { .subject_dn = "CN=device-cipher" }; + WolfCertKey* key = NULL; + WolfCertBuffer csr = { 0 }, issued = { 0 }; + int rc; + + c.scep_content_cipher = cipher; + + rc = wolfcert_key_generate(kcfg, &key); + if (rc == WOLFCERT_OK) + rc = wolfcert_csr_build(key, &meta, &csr); + if (rc == WOLFCERT_OK) + rc = wolfcert_scep_pkcs_req(&c, caps, ca_der_buf, ca_der_len, key, + csr.data, csr.len, &issued); + if (rc == WOLFCERT_OK && + memmem(issued.data, issued.len, "BEGIN CERTIFICATE", 17) == NULL) + rc = -1; + + wolfcert_buffer_free(&issued); + wolfcert_buffer_free(&csr); + wolfcert_key_free(key); + return rc; +} +#endif /* WOLFCERT_TEST_HAVE_CIPHER_OVERRIDE */ + /* 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. */ @@ -346,6 +462,24 @@ int main(void) REQUIRE(check_get_fallback(&cli, &caps, &kcfg, ca_der->buffer, ca_der->length) == WOLFCERT_OK); + /* ---- Public-key-hash transactionID (RFC 8894 section 3.2.1) ----------- */ + REQUIRE(check_pubkey_txid(&cli, &caps, &kcfg, + ca_der->buffer, ca_der->length) == WOLFCERT_OK); + + /* ---- Content-cipher override: explicit AES-256 and AES-128 both enroll. + * Each half needs the cipher wolfSSL was actually built with; scep_prepare + * returns WOLFCERT_ERR_UNSUPPORTED for one the library cannot do. */ +#if defined(WOLFSSL_AES_256) && defined(HAVE_AES_CBC) + REQUIRE(check_content_cipher(&cli, &caps, &kcfg, ca_der->buffer, + ca_der->length, WOLFCERT_SCEP_CIPHER_AES256) + == WOLFCERT_OK); +#endif +#if defined(WOLFSSL_AES_128) && defined(HAVE_AES_CBC) + REQUIRE(check_content_cipher(&cli, &caps, &kcfg, ca_der->buffer, + ca_der->length, WOLFCERT_SCEP_CIPHER_AES128) + == WOLFCERT_OK); +#endif + /* Negative GET PKIOperation branches (RFC 8894 section 4.1): the server must * reject each malformed request with 400. These cannot be produced by the * client API, so drive the running server over a raw socket. */ diff --git a/tests/unit/test_scep_msg.c b/tests/unit/test_scep_msg.c index 2f73a50..66c62a8 100644 --- a/tests/unit/test_scep_msg.c +++ b/tests/unit/test_scep_msg.c @@ -1131,10 +1131,95 @@ static int test_pki_get_url(void) return 0; } +/* wolfcert_scep_build_getca_url: omits message= when no CA identifier is set, + * appends it (URL-encoded) when one is. */ +static int test_getca_url(void) +{ + /* The URLs come from the wolfCert heap, so they are released with + * WOLFCERT_XFREE and not free(): under WOLFSSL_NO_MALLOC that heap is a + * static pool and libc never saw the pointer. */ + char* u; + + u = wolfcert_scep_build_getca_url("http://ca.example/scep", "GetCACert", NULL, NULL); + REQUIRE(u != NULL); + REQUIRE(strcmp(u, "http://ca.example/scep?operation=GetCACert") == 0); + WOLFCERT_XFREE(u, NULL); + + /* An empty identifier is treated as unset. */ + u = wolfcert_scep_build_getca_url("http://ca.example/scep", "GetCACaps", "", NULL); + REQUIRE(u != NULL); + REQUIRE(strcmp(u, "http://ca.example/scep?operation=GetCACaps") == 0); + WOLFCERT_XFREE(u, NULL); + + u = wolfcert_scep_build_getca_url("http://ca.example/scep", "GetCACert", "MyCA", NULL); + REQUIRE(u != NULL); + REQUIRE(strcmp(u, "http://ca.example/scep?operation=GetCACert&message=MyCA") == 0); + WOLFCERT_XFREE(u, NULL); + + /* Reserved characters in the identifier must be percent-encoded. */ + u = wolfcert_scep_build_getca_url("http://ca.example/scep", "GetCACert", "a b/c", NULL); + REQUIRE(u != NULL); + REQUIRE(strcmp(u, + "http://ca.example/scep?operation=GetCACert&message=a%20b%2Fc") == 0); + WOLFCERT_XFREE(u, NULL); + + return 0; +} + +/* Only meaningful where wolfSSL can actually run an AES-CBC content cipher. */ +#if defined(HAVE_AES_CBC) && \ + (defined(WOLFSSL_AES_128) || defined(WOLFSSL_AES_256)) +/* The content-cipher choice reaches the wire: enveloping with AES256CBCb / + * AES128CBCb yields a message carrying the matching AES-CBC OID. */ +static int test_envelop_cipher_oid(void) +{ +#if defined(WOLFSSL_AES_256) + static const uint8_t OID_AES256[] = + { 0x06,0x09,0x60,0x86,0x48,0x01,0x65,0x03,0x04,0x01,0x2a }; +#endif + static const uint8_t OID_AES128[] = + { 0x06,0x09,0x60,0x86,0x48,0x01,0x65,0x03,0x04,0x01,0x02 }; + const uint8_t payload[] = "content-encryption OID probe"; + + uint8_t* ca_der = NULL; + uint8_t* key_der = NULL; + size_t ca_len = 0, key_len = 0; + REQUIRE(make_ca(&ca_der, &ca_len, &key_der, &key_len) == 0); + +#if defined(WOLFSSL_AES_256) && defined(HAVE_AES_CBC) + WolfCertBuffer env256 = { 0 }; + REQUIRE(wolfcert_scep_envelop(ca_der, ca_len, payload, sizeof(payload), + AES256CBCb, &env256, NULL) == WOLFCERT_OK); + REQUIRE(memmem(env256.data, env256.len, OID_AES256, sizeof(OID_AES256)) != NULL); + REQUIRE(memmem(env256.data, env256.len, OID_AES128, sizeof(OID_AES128)) == NULL); + wolfcert_buffer_free(&env256); +#endif + +#if defined(WOLFSSL_AES_128) && defined(HAVE_AES_CBC) + WolfCertBuffer env128 = { 0 }; + REQUIRE(wolfcert_scep_envelop(ca_der, ca_len, payload, sizeof(payload), + AES128CBCb, &env128, NULL) == WOLFCERT_OK); + REQUIRE(memmem(env128.data, env128.len, OID_AES128, sizeof(OID_AES128)) != NULL); + wolfcert_buffer_free(&env128); +#endif + + free(ca_der); + free(key_der); + return 0; +} +#endif /* HAVE_AES_CBC && (WOLFSSL_AES_128 || WOLFSSL_AES_256) */ + int main(void) { REQUIRE(test_static_mem_init() == 0); REQUIRE(wolfcert_init(NULL) == WOLFCERT_OK); + if (test_getca_url()) + return 1; +#if defined(HAVE_AES_CBC) && \ + (defined(WOLFSSL_AES_128) || defined(WOLFSSL_AES_256)) + if (test_envelop_cipher_oid()) + return 1; +#endif if (test_ca_fingerprint()) return 1; if (test_pki_get_url()) diff --git a/wolfcert/types.h b/wolfcert/types.h index 015177c..5715f6b 100644 --- a/wolfcert/types.h +++ b/wolfcert/types.h @@ -153,6 +153,22 @@ typedef struct { void* customize_ctx; } WolfCertCertMeta; +/* SCEP transactionID derivation (WolfCertServerCfg.scep_txid_mode). */ +typedef enum { + WOLFCERT_SCEP_TXID_RANDOM = 0, /* random 16-byte value, hex-encoded (default) */ + WOLFCERT_SCEP_TXID_PUBKEY_HASH = 1 /* SHA-256 of the signer public key */ +} WolfCertScepTxidMode; + +/* SCEP request content-encryption cipher (WolfCertServerCfg.scep_content_cipher). + * AUTO keeps the RFC 8894 caps-driven default; the explicit values force a + * cipher for a known peer (e.g. a wolfSCEP deployment that requires AES-256). */ +typedef enum { + WOLFCERT_SCEP_CIPHER_AUTO = 0, /* AES-128-CBC when the CA advertises AES, else 3DES */ + WOLFCERT_SCEP_CIPHER_AES128 = 1, + WOLFCERT_SCEP_CIPHER_AES256 = 2, + WOLFCERT_SCEP_CIPHER_DES3 = 3 +} WolfCertScepContentCipher; + typedef struct { WolfCertProtocol protocol; const char* server_url; /* e.g. https://ca.example/.well-known/est */ @@ -214,6 +230,25 @@ typedef struct { WolfCertConnectFn connect_cb; void* connect_ctx; + /* ---- SCEP-only options (ignored by EST) ---------------------------- + * All three are zero-init-safe: the default value preserves wolfCert's + * pre-existing behavior. */ + + /* CA identifier sent as the `message` query parameter on GetCACaps and + * GetCACert (RFC 8894 section 3.5.2 / 4.2), used to select a specific CA on + * a multi-CA responder. NULL (the default) omits the parameter. */ + const char* scep_ca_id; + + /* How the enrollment pkiMessage transactionID is derived. RANDOM (default) + * uses fresh RNG bytes; PUBKEY_HASH derives it from the signer public key + * (RFC 8894 section 3.2.1) so retries of the same key reuse one ID. */ + WolfCertScepTxidMode scep_txid_mode; + + /* Content-encryption cipher for the request EnvelopedData. AUTO (default) + * keeps the RFC 8894 caps-driven choice (AES-128-CBC, else 3DES); an + * explicit value forces that cipher for a peer that requires it. */ + WolfCertScepContentCipher scep_content_cipher; + /* Heap hint for any library-internal allocations made while servicing * this request. NULL = library default. */ void* heap; From 12acf6e7c97bd14f415f43d99fb0c53be34a0534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Thu, 30 Jul 2026 10:03:25 +0200 Subject: [PATCH 02/11] Extract a shared hex encoder Three copies of the same 16-byte lookup table had accumulated in scep_client.c: one in url_encode for percent-encoding, and one each in the two transactionID builders, which differ only in letter case. Replace them with wolfcert_hex_encode next to the base64 helpers in internal.c. It writes two characters per input byte and no NUL, so it serves both the string builders, which terminate the buffer themselves, and the fixed-length wire fields such as the transactionID. The `upper` flag covers the one case difference: RFC 3986 asks for upper-case percent-encoding and wolfSCEP derives an upper-case transactionID, while the random transactionID keeps its existing lower-case form. The unit test checks both cases, the single-byte call shape url_encode uses, and that nothing is written past 2 * in_len. The expected value in the SCEP round-trip test keeps its own local table on purpose, so that the test does not check the encoder against itself. --- src/internal.c | 17 +++++++++++++++++ src/internal.h | 7 +++++++ src/scep/scep_client.c | 19 ++++++------------- tests/unit/test_est.c | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 13 deletions(-) diff --git a/src/internal.c b/src/internal.c index 7074e4a..70e9831 100644 --- a/src/internal.c +++ b/src/internal.c @@ -287,6 +287,23 @@ WOLFCERT_TEST_VIS int wolfcert_base64_decode(const uint8_t* in, size_t in_len, return WOLFCERT_OK; } +WOLFCERT_TEST_VIS void wolfcert_hex_encode(const uint8_t* in, size_t in_len, + int upper, char* out) +{ + static const char HEX_LOWER[] = "0123456789abcdef"; + static const char HEX_UPPER[] = "0123456789ABCDEF"; + const char* hex = upper ? HEX_UPPER : HEX_LOWER; + size_t i; + + if (in == NULL || out == NULL) + return; + + for (i = 0; i < in_len; ++i) { + out[i*2] = hex[in[i] >> 4]; + out[i*2+1] = hex[in[i] & 0x0F]; + } +} + WOLFCERT_TEST_VIS int wolfcert_buffer_is_der(const uint8_t* buf, size_t len) { size_t i = 0; diff --git a/src/internal.h b/src/internal.h index e7e8ed3..e92f8ce 100644 --- a/src/internal.h +++ b/src/internal.h @@ -292,6 +292,13 @@ WOLFCERT_TEST_VIS int wolfcert_base64_encode_mime(const uint8_t* in, size_t in_l WOLFCERT_TEST_VIS int wolfcert_base64_decode(const uint8_t* in, size_t in_len, WolfCertBuffer* out, void* heap); +/* Hex-encode `in_len` bytes into `out`, which must hold at least 2 * in_len + * characters. `upper` selects upper-case digits. No NUL terminator is written, + * so the same helper serves both string building (caller terminates) and + * fixed-length wire fields such as the SCEP transactionID. */ +WOLFCERT_TEST_VIS void wolfcert_hex_encode(const uint8_t* in, size_t in_len, + int upper, char* out); + int wolfcert_pem_cert_to_der(const uint8_t* pem, size_t pem_len, WolfCertBuffer* out_der, void* heap); diff --git a/src/scep/scep_client.c b/src/scep/scep_client.c index 1e8306e..723a8c1 100644 --- a/src/scep/scep_client.c +++ b/src/scep/scep_client.c @@ -342,7 +342,6 @@ static int pick_hash_oid(const WolfCertScepCaps* caps) * safe inside a URL query value. Returns NULL on allocation failure. */ static char* url_encode(const uint8_t* in, size_t in_len, void* heap) { - static const char HEX[] = "0123456789ABCDEF"; /* Worst case each byte expands to "%XX" (3 chars), plus the NUL. */ char* out = (char*)WOLFCERT_XMALLOC(in_len * 3 + 1, heap); if (out == NULL) @@ -358,8 +357,9 @@ static char* url_encode(const uint8_t* in, size_t in_len, void* heap) } else { out[o++] = '%'; - out[o++] = HEX[c >> 4]; - out[o++] = HEX[c & 0x0F]; + /* RFC 3986 section 2.1: upper-case hex digits are the normal form. */ + wolfcert_hex_encode(&c, 1, 1, &out[o]); + o += 2; } } out[o] = '\0'; @@ -547,11 +547,8 @@ static int derive_txid_pubkey(const uint8_t* signer_cert, size_t signer_cert_len if (txid == NULL) return WOLFCERT_ERR_MEMORY; - static const char HEX[] = "0123456789ABCDEF"; /* upper-case, per wolfSCEP */ - for (size_t i = 0; i < WC_SHA256_DIGEST_SIZE; ++i) { - txid[i*2] = (uint8_t)HEX[digest[i] >> 4]; - txid[i*2+1] = (uint8_t)HEX[digest[i] & 0x0F]; - } + /* Upper-case hex, per wolfSCEP. */ + wolfcert_hex_encode(digest, WC_SHA256_DIGEST_SIZE, 1, (char*)txid); *out_txid = txid; *out_txid_len = hexlen; return WOLFCERT_OK; @@ -695,7 +692,6 @@ static int scep_prepare(void* heap, const WolfCertScepCaps* caps, } } else { - static const char HEX[] = "0123456789abcdef"; txid_len = sizeof(txid_gen) * 2; txid = (uint8_t*)WOLFCERT_XMALLOC(txid_len, heap); if (txid == NULL) { @@ -703,10 +699,7 @@ static int scep_prepare(void* heap, const WolfCertScepCaps* caps, wolfcert_buffer_free(&env); return WOLFCERT_ERR_MEMORY; } - for (size_t i = 0; i < sizeof(txid_gen); ++i) { - txid[i*2] = (uint8_t)HEX[txid_gen[i] >> 4]; - txid[i*2+1] = (uint8_t)HEX[txid_gen[i] & 0x0F]; - } + wolfcert_hex_encode(txid_gen, sizeof(txid_gen), 0, (char*)txid); } wc_ForceZero(txid_gen, (word32)sizeof(txid_gen)); /* dead once expanded */ diff --git a/tests/unit/test_est.c b/tests/unit/test_est.c index f71ad4a..bc24668 100644 --- a/tests/unit/test_est.c +++ b/tests/unit/test_est.c @@ -255,6 +255,38 @@ static int test_oid_to_dotted(void) return 0; } +/* wolfcert_hex_encode writes exactly 2 * in_len characters in the requested + * case and touches nothing beyond them (callers such as url_encode and the SCEP + * transactionID builders rely on both properties). */ +static int test_hex_encode(void) +{ + const uint8_t in[] = { 0x00, 0x0f, 0xa5, 0xff }; + char out[16]; + + memset(out, 'x', sizeof(out)); + wolfcert_hex_encode(in, sizeof(in), 0, out); + REQUIRE(memcmp(out, "000fa5ff", 8) == 0); + REQUIRE(out[8] == 'x'); /* no NUL terminator, no overrun */ + + memset(out, 'x', sizeof(out)); + wolfcert_hex_encode(in, sizeof(in), 1, out); + REQUIRE(memcmp(out, "000FA5FF", 8) == 0); + REQUIRE(out[8] == 'x'); + + /* Single-byte encoding, the shape url_encode uses per escaped byte. */ + memset(out, 'x', sizeof(out)); + wolfcert_hex_encode(in + 2, 1, 1, out); + REQUIRE(memcmp(out, "A5", 2) == 0); + REQUIRE(out[2] == 'x'); + + /* Zero length and NULL input must leave the buffer alone. */ + wolfcert_hex_encode(in, 0, 1, out); + wolfcert_hex_encode(NULL, sizeof(in), 1, out); + REQUIRE(out[0] == 'A' && out[1] == '5' && out[2] == 'x'); + + return 0; +} + /* RFC 7030 mandates that an EST client authenticate the server. In this * transport verify_server is the only switch that turns on peer verification, * so any config that leaves it at its zero default must be refused - with or @@ -355,6 +387,9 @@ int main(void) if (test_oid_to_dotted()) return 1; + if (test_hex_encode()) + return 1; + if (test_est_require_server_auth()) return 1; From dd0237fa78adeeeac186229e07fe1b99fbb62334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Thu, 30 Jul 2026 10:07:01 +0200 Subject: [PATCH 03/11] scep: collapse the transactionID inputs and defer the random draw scep_prepare took the inherited transactionID, its length and the derivation mode as three separate arguments, then chose between them in a three-way branch in the middle of the function. Gather them into one ScepTxidSel and move the choice into scep_build_txid, so the two callers that know both halves, the one-shot round trip and the session begin, build the selector and the rest of scep_prepare stops caring. The random draw now happens inside the branch that consumes it, rather than unconditionally before the branch. The senderNonce still always comes from the RNG, so there is still exactly one wc_InitRng_ex, but an inherited or public-key-derived transactionID no longer spends RNG output it will not use. Doing it this way, instead of guarding the draw with a predicate, means the buffer lives in the one branch that fills it and cannot be read uninitialised if the guard and the branch ever disagree. The DecodedCert behind the public-key derivation moves to the heap. It is a little over 2 KB, which is more than an MCU task stack should carry for a transient parse, and it takes the caller's heap hint like every other sizeable allocation in the library. --- src/scep/scep_client.c | 199 +++++++++++++++++++++++++---------------- 1 file changed, 123 insertions(+), 76 deletions(-) diff --git a/src/scep/scep_client.c b/src/scep/scep_client.c index 723a8c1..0743389 100644 --- a/src/scep/scep_client.c +++ b/src/scep/scep_client.c @@ -516,6 +516,17 @@ static int run_pki_op(const WolfCertServerCfg* srv, /* Shared SCEP round-trip sizes. */ #define SCEP_NONCE_SZ 16 +/* A random transactionID is 16 RNG bytes expanded to 32 hex characters. */ +#define SCEP_TXID_RAND_SZ 16 + +/* Which transactionID one round trip carries. An `id` inherited from an earlier + * request in the same transaction (the GetCertInitial poll that follows a + * pending PKCSReq) always wins; otherwise the ID is derived per `mode`. */ +typedef struct { + const uint8_t* id; /* explicit transactionID, or NULL to derive */ + size_t id_len; + WolfCertScepTxidMode mode; /* derivation used when `id` is NULL */ +} ScepTxidSel; /* Derive a transactionID from the signer's public key (RFC 8894 section 3.2.1): * SHA-256 over the subjectPublicKey BIT STRING contents, which is what @@ -528,17 +539,25 @@ static int run_pki_op(const WolfCertServerCfg* srv, static int derive_txid_pubkey(const uint8_t* signer_cert, size_t signer_cert_len, uint8_t** out_txid, size_t* out_txid_len, void* heap) { - DecodedCert dc; - wc_InitDecodedCert(&dc, signer_cert, (word32)signer_cert_len, heap); - int rc = wc_ParseCert(&dc, CERT_TYPE, NO_VERIFY, NULL); + /* DecodedCert is a couple of KiB - more than an MCU task stack wants to + * carry - so it goes on the heap-hint-aware heap like every other sizeable + * wolfCert allocation. */ + DecodedCert* dc = (DecodedCert*)WOLFCERT_XMALLOC(sizeof(*dc), heap); + if (dc == NULL) + return WOLFCERT_ERR_MEMORY; + + wc_InitDecodedCert(dc, signer_cert, (word32)signer_cert_len, heap); + int rc = wc_ParseCert(dc, CERT_TYPE, NO_VERIFY, NULL); if (rc != 0) { - wc_FreeDecodedCert(&dc); + wc_FreeDecodedCert(dc); + WOLFCERT_XFREE(dc, heap); return WOLFCERT_ERR_WC(rc, "scep", "parse signer cert for transactionID"); } uint8_t digest[WC_SHA256_DIGEST_SIZE]; - rc = wc_Sha256Hash(dc.publicKey, dc.pubKeySize, digest); - wc_FreeDecodedCert(&dc); + rc = wc_Sha256Hash(dc->publicKey, dc->pubKeySize, digest); + wc_FreeDecodedCert(dc); + WOLFCERT_XFREE(dc, heap); if (rc != 0) return WOLFCERT_ERR_WC(rc, "scep", "hash signer public key"); @@ -554,20 +573,74 @@ static int derive_txid_pubkey(const uint8_t* signer_cert, size_t signer_cert_len return WOLFCERT_OK; } +/* Produce the transactionID for one round trip per `sel`: copy an inherited ID + * verbatim, derive it from the signer public key, or draw a fresh random one. + * Only that last path touches `rng`, so a caller whose selection is inherited + * or pubkey-derived never spends RNG output. *out_txid is heap-allocated and + * owned by the caller. */ +static int scep_build_txid(const ScepTxidSel* sel, + const uint8_t* signer_cert, size_t signer_cert_len, + WC_RNG* rng, void* heap, + uint8_t** out_txid, size_t* out_txid_len) +{ + /* Hold the transactionID on the heap rather than in a fixed buffer: an + * inherited ID is whatever the server chose for the earlier request (RFC + * 8894 puts no length bound on it), and the pubkey-hash form is 64 hex + * chars against the random form's 32. */ + if (sel->id != NULL) { + uint8_t* txid = (uint8_t*)WOLFCERT_XMALLOC(sel->id_len, heap); + if (txid == NULL) + return WOLFCERT_ERR_MEMORY; + + memcpy(txid, sel->id, sel->id_len); + *out_txid = txid; + *out_txid_len = sel->id_len; + return WOLFCERT_OK; + } + + if (sel->mode == WOLFCERT_SCEP_TXID_PUBKEY_HASH) + return derive_txid_pubkey(signer_cert, signer_cert_len, + out_txid, out_txid_len, heap); + + uint8_t rand_bytes[SCEP_TXID_RAND_SZ]; + int rng_rc = wc_RNG_GenerateBlock(rng, rand_bytes, sizeof(rand_bytes)); + if (rng_rc != 0) { + /* A partially filled draw shares a frame with the senderNonce the same + * generator just produced, so clear it here too, not only on success. */ + wc_ForceZero(rand_bytes, (word32)sizeof(rand_bytes)); + return WOLFCERT_ERR_WC(rng_rc, "scep", + "RNG failed generating transactionID"); + } + + size_t hexlen = sizeof(rand_bytes) * 2; + uint8_t* txid = (uint8_t*)WOLFCERT_XMALLOC(hexlen, heap); + if (txid != NULL) + wolfcert_hex_encode(rand_bytes, sizeof(rand_bytes), 0, (char*)txid); + + /* Not a secret - the transactionID travels in the clear as a signed + * attribute - but the raw draw has no reason to outlive its expansion. */ + wc_ForceZero(rand_bytes, (word32)sizeof(rand_bytes)); + if (txid == NULL) + return WOLFCERT_ERR_MEMORY; + + *out_txid = txid; + *out_txid_len = hexlen; + return WOLFCERT_OK; +} + /* Build the enveloped + signed pkiMessage for one SCEP round trip. Produces the * DER message in *out_pki, the effective transactionID in *out_txid (owned by * the caller, free with WOLFCERT_XFREE) and the senderNonce in out_nonce - both * retained by the caller to validate the CertRep after the transport completes. - * `cipher` overrides the content-encryption algorithm; `txid_mode` selects the - * transactionID derivation when txid_override is NULL. */ + * `cipher` overrides the content-encryption algorithm; `txid_sel` picks the + * transactionID. */ static int scep_prepare(void* heap, const WolfCertScepCaps* caps, const uint8_t* ra_cert, size_t ra_cert_len, const uint8_t* signer_cert, size_t signer_cert_len, const uint8_t* signer_key, size_t signer_key_len, const char* msg_type, const uint8_t* envelope_content, size_t envelope_content_len, - const uint8_t* txid_override, size_t txid_override_len, - WolfCertScepTxidMode txid_mode, + const ScepTxidSel* txid_sel, WolfCertScepContentCipher cipher, WolfCertBuffer* out_pki, uint8_t** out_txid, size_t* out_txid_len, @@ -640,68 +713,35 @@ static int scep_prepare(void* heap, const WolfCertScepCaps* caps, return rc; WC_RNG rng; - uint8_t txid_gen[16]; int rng_rc = wc_InitRng_ex(&rng, heap, WOLFCERT_DEVID_SOFTWARE); if (rng_rc != 0) { wolfcert_buffer_free(&env); - return WOLFCERT_ERR_WC(rng_rc, "scep", - "RNG init failed for transactionID/nonce"); + return WOLFCERT_ERR_WC(rng_rc, "scep", "RNG init failed"); } - /* The transactionID and the anti-replay senderNonce must both come from the - * RNG. Ignoring a failure here would build the pkiMessage over - * uninitialized stack memory and silently weaken replay protection, so - * surface any generation error instead. */ - rng_rc = wc_RNG_GenerateBlock(&rng, txid_gen, sizeof(txid_gen)); - if (rng_rc == 0) - rng_rc = wc_RNG_GenerateBlock(&rng, out_nonce, SCEP_NONCE_SZ); - wc_FreeRng(&rng); + /* The anti-replay senderNonce always comes from the RNG. Ignoring a failure + * here would build the pkiMessage over uninitialized stack memory and + * silently weaken replay protection, so surface any generation error + * instead. Clearing out_nonce on the way out keeps the caller from + * mistaking leftovers for a usable senderNonce. */ + rng_rc = wc_RNG_GenerateBlock(&rng, out_nonce, SCEP_NONCE_SZ); if (rng_rc != 0) { - /* Neither value is a secret - both travel in the clear as pkiMessage - * signed attributes - but a half-filled buffer of RNG output has no - * reason to outlive the failure, and clearing out_nonce keeps the - * caller from mistaking leftovers for a usable senderNonce. */ - wc_ForceZero(txid_gen, (word32)sizeof(txid_gen)); wc_ForceZero(out_nonce, SCEP_NONCE_SZ); + wc_FreeRng(&rng); wolfcert_buffer_free(&env); - return WOLFCERT_ERR_WC(rng_rc, "scep", - "RNG failed generating transactionID/nonce"); + return WOLFCERT_ERR_WC(rng_rc, "scep", "RNG failed generating senderNonce"); } - /* Hold the transactionID on the heap rather than in a fixed buffer: an - * override is whatever the server chose for the earlier request (RFC 8894 - * puts no length bound on it), and the pubkey-hash form is 64 hex chars. */ - size_t txid_len; - uint8_t* txid; - if (txid_override != NULL) { - txid_len = txid_override_len; - txid = (uint8_t*)WOLFCERT_XMALLOC(txid_len, heap); - if (txid == NULL) { - wc_ForceZero(txid_gen, (word32)sizeof(txid_gen)); - wolfcert_buffer_free(&env); - return WOLFCERT_ERR_MEMORY; - } - memcpy(txid, txid_override, txid_override_len); - } - else if (txid_mode == WOLFCERT_SCEP_TXID_PUBKEY_HASH) { - rc = derive_txid_pubkey(signer_cert, signer_cert_len, &txid, &txid_len, heap); - if (rc != WOLFCERT_OK) { - wc_ForceZero(txid_gen, (word32)sizeof(txid_gen)); - wolfcert_buffer_free(&env); - return rc; - } - } - else { - txid_len = sizeof(txid_gen) * 2; - txid = (uint8_t*)WOLFCERT_XMALLOC(txid_len, heap); - if (txid == NULL) { - wc_ForceZero(txid_gen, (word32)sizeof(txid_gen)); - wolfcert_buffer_free(&env); - return WOLFCERT_ERR_MEMORY; - } - wolfcert_hex_encode(txid_gen, sizeof(txid_gen), 0, (char*)txid); + uint8_t* txid = NULL; + size_t txid_len = 0; + rc = scep_build_txid(txid_sel, signer_cert, signer_cert_len, &rng, heap, + &txid, &txid_len); + wc_FreeRng(&rng); + if (rc != WOLFCERT_OK) { + wc_ForceZero(out_nonce, SCEP_NONCE_SZ); + wolfcert_buffer_free(&env); + return rc; } - wc_ForceZero(txid_gen, (word32)sizeof(txid_gen)); /* dead once expanded */ WolfCertScepAttrs attrs = { .transaction_id = txid, .transaction_id_len = txid_len, @@ -845,8 +885,8 @@ static int scep_finish(void* heap, /* One-shot SCEP round trip for PKCSReq / RenewalReq / GetCertInitial: build the * enveloped + signed pkiMessage (scep_prepare), POST or base64-GET it * (run_pki_op), then parse + verify the CertRep and fill `out` (scep_finish). - * Caller retains ownership of `txid_override`; when NULL a fresh 32-hex - * transactionID is generated. */ + * Caller retains ownership of `txid_override`; when NULL the transactionID is + * derived per srv->scep_txid_mode. */ static int do_scep_round_trip(const WolfCertServerCfg* srv, const WolfCertScepCaps* caps, const uint8_t* ra_cert, size_t ra_cert_len, @@ -862,6 +902,11 @@ static int do_scep_round_trip(const WolfCertServerCfg* srv, out->heap = heap; out->fail_info = -1; + ScepTxidSel txid_sel = { + .id = txid_override, .id_len = txid_override_len, + .mode = srv->scep_txid_mode + }; + WolfCertBuffer pki = { 0 }; uint8_t* txid = NULL; size_t txid_len = 0; @@ -869,8 +914,7 @@ static int do_scep_round_trip(const WolfCertServerCfg* srv, int rc = scep_prepare(heap, caps, ra_cert, ra_cert_len, signer_cert, signer_cert_len, signer_key, signer_key_len, msg_type, envelope_content, envelope_content_len, - txid_override, txid_override_len, - srv->scep_txid_mode, srv->scep_content_cipher, + &txid_sel, srv->scep_content_cipher, &pki, &txid, &txid_len, nonce); if (rc != WOLFCERT_OK) return rc; @@ -1217,17 +1261,17 @@ enum scep_session_op { }; struct WolfCertScepSession { - WolfCertHttpSession* http; - char* server_url; /* full SCEP endpoint URL, owned */ - void* heap; - int nonblocking; /* opened via _open_async (_nb calls) vs _open (_ex) */ - WolfCertScepTxidMode txid_mode; /* captured from cfg at open */ - WolfCertScepContentCipher content_cipher; /* captured from cfg at open */ + WolfCertHttpSession* http; + char* server_url; /* full SCEP endpoint URL, owned */ + void* heap; + int nonblocking; /* opened via _open_async (_nb calls) vs _open (_ex) */ + WolfCertScepTxidMode txid_mode; /* captured from cfg at open */ + WolfCertScepContentCipher content_cipher; /* captured from cfg at open */ /* Async in-flight state: one round trip at a time. */ - int in_active; - int in_op; /* enum scep_session_op, valid when in_active */ - char* in_url; /* owned request URL */ + int in_active; + int in_op; /* enum scep_session_op, valid when in_active */ + char* in_url; /* owned request URL */ WolfCertBuffer in_pki; /* built pkiMessage, owned (POST body) */ WolfCertHttpRequest in_req; WolfCertHttpResponse in_resp; @@ -1429,12 +1473,15 @@ static int scep_session_begin(WolfCertScepSession* s, const WolfCertScepCaps* ca return WOLFCERT_ERR_MEMORY; } + ScepTxidSel txid_sel = { + .id = txid_override, .id_len = txid_override_len, .mode = s->txid_mode + }; + WolfCertBuffer pki = { 0 }; int rc = scep_prepare(heap, caps, ra_cert, ra_cert_len, signer_cert, signer_cert_len, signer_key, signer_key_len, msg_type, envelope_content, envelope_content_len, - txid_override, txid_override_len, - s->txid_mode, s->content_cipher, + &txid_sel, s->content_cipher, &pki, &s->in_txid, &s->in_txid_len, s->in_nonce); if (rc != WOLFCERT_OK) { scep_async_reset(s); From bea24ffef9cf498e598dc93595664ffe475bbaa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Thu, 30 Jul 2026 10:09:43 +0200 Subject: [PATCH 04/11] Split the per-protocol server options into a proto_opts union WolfCertServerCfg had grown EST-only and SCEP-only fields side by side, which does not match how it is used: a connection is either EST or SCEP, never both, so at any moment roughly half the struct is dead weight that a reader still has to reason about. Move the per-protocol knobs into a proto_opts union of the new WolfCertEstServerOpts and WolfCertScepServerOpts, selected by the protocol field that already exists. Both arms stay zero-init safe, so leaving the union untouched keeps the defaults of either protocol. The union member is named rather than anonymous: anonymous unions are C11, and the public headers have to stay usable at -std=c99 on embedded toolchains. The one place that had to change beyond a rename is wolfcert_client_enroll, the only caller that dispatches on protocol at run time. It tested auto_csrattrs before testing the protocol, which on a SCEP config reads an arm of the union that is not the active member. Test the protocol first. The WOLFCERT_ERR_UNSUPPORTED that used to follow is gone with it, because the flag now exists only on the EST arm and cannot be asked for under SCEP in the first place. --- CLAUDE.md | 2 +- cli/wolfcert_client.c | 2 +- docs/ARCHITECTURE.md | 39 ++++--- src/client.c | 21 ++-- src/est/est_client.c | 32 ++++-- src/internal.c | 12 ++ src/internal.h | 7 ++ src/scep/scep_client.c | 16 +-- tests/integration/test_est_async_roundtrip.c | 20 ++-- .../test_est_csr_attrs_apply_roundtrip.c | 6 +- tests/integration/test_est_pha_roundtrip.c | 32 +++--- tests/integration/test_scep_roundtrip.c | 8 +- tests/unit/test_est.c | 55 +++++++++ wolfcert/est.h | 7 +- wolfcert/server.h | 4 +- wolfcert/types.h | 106 +++++++++++------- 16 files changed, 243 insertions(+), 126 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6e1b1c5..6d736cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ a legacy peer does not advertise `AES` the client falls back to triple DES-CBC, which needs a wolfSSL built with 3DES support. A wolfSSL built `NO_DES3` still interoperates with any AES-advertising peer, but the client rejects a non-AES peer with `WOLFCERT_ERR_UNSUPPORTED`. A caller can -override this per-connection via `WolfCertServerCfg.scep_content_cipher` +override this per-connection via `WolfCertServerCfg.proto_opts.scep.content_cipher` (e.g. force AES-256-CBC for a peer that requires it). TLS: the HTTPS transport pins its floor to TLS 1.2, or TLS 1.3 when wolfSSL is built `WOLFSSL_NO_TLS12`. diff --git a/cli/wolfcert_client.c b/cli/wolfcert_client.c index 401a79b..1d1b1df 100644 --- a/cli/wolfcert_client.c +++ b/cli/wolfcert_client.c @@ -648,7 +648,7 @@ static int cmd_enroll(int argc, char** argv) if (opts.pha) { /* Keep-alive + TLS 1.3 post-handshake auth: open one session, * fetch /cacerts anonymously, then let PHA drive /simpleenroll. */ - srv.allow_post_handshake_auth = 1; + srv.proto_opts.est.allow_post_handshake_auth = 1; WolfCertEstSession* es = NULL; rc = wolfcert_est_session_open(&srv, &es); if (rc == WOLFCERT_OK) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index acccfda..f366fa4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -113,6 +113,15 @@ The layering rules that matter to an integrator: server lives below the public API — an embedder can hand it an already-accepted socket via `wolfcert_server_serve_fd()` instead of using its accept loop. +- **`WolfCertServerCfg` carries the shared transport settings plus one + protocol-specific arm.** A connection is either EST or SCEP, so the + per-protocol knobs live in the `proto_opts` union (`WolfCertEstServerOpts` / + `WolfCertScepServerOpts`) selected by `protocol`; the arm that does not match + is never read. Both arms are zero-init-safe, so leaving the union untouched + keeps the default behavior. Because `protocol` is the discriminator, every + `wolfcert_est_*` and `wolfcert_scep_*` entry point validates it up front and + returns `WOLFCERT_ERR_BAD_ARG` on a mismatch, so a config built for one + protocol can never be reinterpreted through the other's arm. ## 3. Protocols: EST and SCEP @@ -140,7 +149,7 @@ what enables the post-handshake-auth bootstrap below. 2. **mTLS up front** — set `client_cert` / `client_key`; they're presented during the handshake. 3. **TLS 1.3 post-handshake auth** — set `client_cert` / `client_key` *and* - `allow_post_handshake_auth = 1`, and use the session API. The first request + `proto_opts.est.allow_post_handshake_auth = 1`, and use the session API. The first request (`/cacerts`) rides an anonymous handshake; the server triggers a mid-session `CertificateRequest` when the client first hits a protected endpoint, and wolfSSL answers from the pre-loaded identity with no further @@ -161,7 +170,7 @@ signature hash, preferred key algorithm + size). `wolfcert_csr_attrs_apply` overlays those hints onto a caller-supplied `WolfCertKeyCfg` / `WolfCertCertMeta` *one-way* — each field is filled only when the caller left it at its zero-value default, so an explicit choice always wins. With -`WolfCertServerCfg.auto_csrattrs = 1`, `wolfcert_client_enroll` runs +`WolfCertServerCfg.proto_opts.est.auto_csrattrs = 1`, `wolfcert_client_enroll` runs fetch+parse+apply before keygen, so a caller can hand in an empty `WolfCertKeyCfg{0}` and let the server pin the algorithm. @@ -214,14 +223,14 @@ prepared (envelope + sign), sent, and its CertRep parsed as one logical step; in async mode only the HTTP transport is pumped through `WANT_READ`/`WANT_WRITE` while the crypto stays synchronous. -**Client options** (`WolfCertServerCfg`, all zero-init to the default behavior): -- `scep_ca_id` — CA identifier sent as `message=` on GetCACaps / GetCACert +**Client options** (`WolfCertServerCfg.proto_opts.scep`, a `WolfCertScepServerOpts`; all zero-init to the default behavior): +- `ca_id` — CA identifier sent as `message=` on GetCACaps / GetCACert to select a specific CA on a multi-CA responder; omitted when NULL. -- `scep_txid_mode` — `WOLFCERT_SCEP_TXID_RANDOM` (default) or `..._PUBKEY_HASH`, +- `txid_mode` — `WOLFCERT_SCEP_TXID_RANDOM` (default) or `..._PUBKEY_HASH`, which derives the transactionID as the SHA-256 of the signer public key (RFC 8894 §3.2.1) so retries of the same key reuse one ID. Matches wolfSCEP's derivation. -- `scep_content_cipher` — `WOLFCERT_SCEP_CIPHER_AUTO` (default: the caps-driven +- `content_cipher` — `WOLFCERT_SCEP_CIPHER_AUTO` (default: the caps-driven AES-128-CBC / 3DES choice) or an explicit `AES128` / `AES256` / `DES3`. There is no GetCACaps token for AES-256, so forcing it is a deliberate choice for a peer that requires it (e.g. a wolfSCEP deployment); the envelope is @@ -406,15 +415,15 @@ wolfcert_csr_build(key, &meta, &csr); /* 4. Non-blocking enrollment over HTTPS with factory mTLS */ WolfCertServerCfg srv = { - .protocol = WOLFCERT_PROTO_EST, - .server_url = "https://ca.example/.well-known/est", - .trust_anchors = bootstrap_ca_pem, - .trust_anchors_len = bootstrap_ca_len, - .verify_server = 1, - .client_cert = factory_cert, .client_cert_len = factory_cert_len, - .client_key = factory_key, .client_key_len = factory_key_len, - .max_response_bytes = 8 * 1024, /* tighten for MCU */ - .allow_post_handshake_auth = 1, + .protocol = WOLFCERT_PROTO_EST, + .server_url = "https://ca.example/.well-known/est", + .trust_anchors = bootstrap_ca_pem, + .trust_anchors_len = bootstrap_ca_len, + .verify_server = 1, + .client_cert = factory_cert, .client_cert_len = factory_cert_len, + .client_key = factory_key, .client_key_len = factory_key_len, + .max_response_bytes = 8 * 1024, /* tighten for MCU */ + .proto_opts.est = { .allow_post_handshake_auth = 1 }, }; WolfCertEstSession* s; wolfcert_est_session_open_async(&srv, &s); diff --git a/src/client.c b/src/client.c index 7dfb723..a8dbcd6 100644 --- a/src/client.c +++ b/src/client.c @@ -168,19 +168,18 @@ int wolfcert_client_enroll(WolfCertClient* client, const WolfCertServerCfg* srv, WolfCertKeyCfg eff_key = *key_cfg; WolfCertCertMeta eff_meta = *meta; - if (srv->auto_csrattrs) { #ifdef WOLFCERT_HAVE_EST - if (srv->protocol == WOLFCERT_PROTO_EST) { - int rc = wolfcert_client_auto_csrattrs(srv, &eff_key, &eff_meta); - if (rc != WOLFCERT_OK) - return rc; - } - else -#endif - { - return WOLFCERT_ERR_UNSUPPORTED; - } + /* auto_csrattrs lives in the EST arm of proto_opts, so the protocol test + * has to come first: on a SCEP config that arm is not the active union + * member and its contents mean nothing. There is no SCEP equivalent of + * /csrattrs, and no way to ask for one - the flag simply does not exist + * on WolfCertScepServerOpts. */ + if (srv->protocol == WOLFCERT_PROTO_EST && srv->proto_opts.est.auto_csrattrs) { + int rc = wolfcert_client_auto_csrattrs(srv, &eff_key, &eff_meta); + if (rc != WOLFCERT_OK) + return rc; } +#endif WolfCertKey* key = NULL; int rc = wolfcert_key_generate(&eff_key, &key); diff --git a/src/est/est_client.c b/src/est/est_client.c index f742a96..e255fbd 100644 --- a/src/est/est_client.c +++ b/src/est/est_client.c @@ -68,7 +68,11 @@ static void fill_common(const WolfCertServerCfg* srv, WolfCertHttpRequest* req) req->connect_ctx = srv->connect_ctx; } -/* RFC 7030 mandates EST over TLS *and* that the client authenticate the +/* Validate the config before it is used. The protocol check comes first: it + * gates every read of proto_opts.est below, which would otherwise reinterpret + * a SCEP arm's storage as the HTTP Basic credentials. + * + * RFC 7030 then mandates EST over TLS *and* that the client authenticate the * server on every request. Reject an explicitly non-TLS (http://) server URL * first; a schemeless URL already defaults to TLS in wolfcert_http_url_parse(), * so only an explicit http:// scheme is refused. Then require server @@ -77,10 +81,14 @@ static void fill_common(const WolfCertServerCfg* srv, WolfCertHttpRequest* req) * verify_server off always completes an unauthenticated handshake - a pinned * trust anchor is loaded but never enforced - which would leak the HTTP Basic * credentials and the CSR to a MITM. */ -static int est_require_tls(const WolfCertServerCfg* srv, void* heap) +static int est_check_cfg(const WolfCertServerCfg* srv, void* heap) { WolfCertUrl u; - int rc = wolfcert_http_url_parse(srv->server_url, &u, heap); + int rc = wolfcert_cfg_require_proto(srv, WOLFCERT_PROTO_EST, "est"); + if (rc != WOLFCERT_OK) + return rc; + + rc = wolfcert_http_url_parse(srv->server_url, &u, heap); if (rc != WOLFCERT_OK) return rc; @@ -111,7 +119,7 @@ int wolfcert_est_get_cacerts_enc(const WolfCertServerCfg* srv, WolfCertEncoding return WOLFCERT_ERR_BAD_ARG; void* heap = srv->heap ? srv->heap : wolfcert_default_heap(); - int trc = est_require_tls(srv, heap); + int trc = est_check_cfg(srv, heap); if (trc != WOLFCERT_OK) return trc; @@ -168,7 +176,7 @@ static int post_enroll_ex(const WolfCertServerCfg* srv, void* heap = srv->heap ? srv->heap : wolfcert_default_heap(); memset(out, 0, sizeof(*out)); out->heap = heap; - int trc = est_require_tls(srv, heap); + int trc = est_check_cfg(srv, heap); if (trc != WOLFCERT_OK) return trc; @@ -399,10 +407,16 @@ static int est_session_open_common(const WolfCertServerCfg* srv, int nonblocking void* heap = srv->heap ? srv->heap : wolfcert_default_heap(); + /* The session copies proto_opts.est below, so confirm the discriminator + * before reading that arm. */ + int rc = wolfcert_cfg_require_proto(srv, WOLFCERT_PROTO_EST, "est"); + if (rc != WOLFCERT_OK) + return rc; + /* Split the base URL into scheme://host[:port] for the HTTP session * vs the path suffix, so per-endpoint joins still work. */ WolfCertUrl u; - int rc = wolfcert_http_url_parse(srv->server_url, &u, heap); + rc = wolfcert_http_url_parse(srv->server_url, &u, heap); if (rc != WOLFCERT_OK) return rc; @@ -414,7 +428,7 @@ static int est_session_open_common(const WolfCertServerCfg* srv, int nonblocking /* EST also requires authenticating the server (RFC 7030); refuse a session * that would run an unauthenticated (verify_server off) handshake, matching - * the one-shot est_require_tls() gate. */ + * the one-shot est_check_cfg() gate. */ if (!srv->verify_server) { wolfcert_http_url_free(&u); return WOLFCERT_ERR(WOLFCERT_ERR_TLS, "est", @@ -455,7 +469,7 @@ static int est_session_open_common(const WolfCertServerCfg* srv, int nonblocking .client_cert_len = srv->client_cert_len, .client_key = srv->client_key, .client_key_len = srv->client_key_len, - .allow_post_handshake_auth = srv->allow_post_handshake_auth, + .allow_post_handshake_auth = srv->proto_opts.est.allow_post_handshake_auth, .nonblocking = nonblocking, .connect_cb = srv->connect_cb, .connect_ctx = srv->connect_ctx, @@ -744,7 +758,7 @@ int wolfcert_est_get_csr_attrs(const WolfCertServerCfg* srv, void* heap = srv->heap ? srv->heap : wolfcert_default_heap(); memset(out_attrs_der, 0, sizeof(*out_attrs_der)); out_attrs_der->heap = heap; - int trc = est_require_tls(srv, heap); + int trc = est_check_cfg(srv, heap); if (trc != WOLFCERT_OK) return trc; diff --git a/src/internal.c b/src/internal.c index 70e9831..3691d4d 100644 --- a/src/internal.c +++ b/src/internal.c @@ -158,6 +158,18 @@ int wolfcert_rng_new(WC_RNG* rng) return rc == 0 ? WOLFCERT_OK : wolfcert_map_wc_err(rc); } +int wolfcert_cfg_require_proto(const WolfCertServerCfg* srv, + WolfCertProtocol want, const char* module) +{ + if (srv->protocol == want) + return WOLFCERT_OK; + + return WOLFCERT_ERR(WOLFCERT_ERR_BAD_ARG, module, + "WolfCertServerCfg.protocol is %d, not %d: it selects the proto_opts " + "arm, so a %s entry point cannot read a config built for another " + "protocol", (int)srv->protocol, (int)want, module); +} + int wolfcert_map_wc_err(int wc_rc) { if (wc_rc == 0) diff --git a/src/internal.h b/src/internal.h index e92f8ce..225f93f 100644 --- a/src/internal.h +++ b/src/internal.h @@ -264,6 +264,13 @@ int wolfcert_rng_new(WC_RNG* rng); int wolfcert_ecc_curve_from_param(int param, int* out_curve_id, int* out_key_size); #endif +/* WolfCertServerCfg.protocol is the discriminator for the proto_opts union, so + * a protocol-specific entry point must confirm it before reading its arm: the + * other arm's members overlay incompatible types and would be reinterpreted. + * Every EST and SCEP entry point that takes a WolfCertServerCfg calls this. */ +int wolfcert_cfg_require_proto(const WolfCertServerCfg* srv, + WolfCertProtocol want, const char* module); + typedef struct { char* scheme; char* host; diff --git a/src/scep/scep_client.c b/src/scep/scep_client.c index 0743389..3ae5cb0 100644 --- a/src/scep/scep_client.c +++ b/src/scep/scep_client.c @@ -129,7 +129,7 @@ int wolfcert_scep_get_ca_caps(const WolfCertServerCfg* srv, WolfCertScepCaps* ou memset(out, 0, sizeof(*out)); char* url = wolfcert_scep_build_getca_url(srv->server_url, "GetCACaps", - srv->scep_ca_id, heap); + srv->proto_opts.scep.ca_id, heap); if (url == NULL) return WOLFCERT_ERR_MEMORY; @@ -190,7 +190,7 @@ int wolfcert_scep_get_ca_cert_enc(const WolfCertServerCfg* srv, WolfCertEncoding void* heap = srv->heap ? srv->heap : wolfcert_default_heap(); char* url = wolfcert_scep_build_getca_url(srv->server_url, "GetCACert", - srv->scep_ca_id, heap); + srv->proto_opts.scep.ca_id, heap); if (url == NULL) return WOLFCERT_ERR_MEMORY; @@ -886,7 +886,7 @@ static int scep_finish(void* heap, * enveloped + signed pkiMessage (scep_prepare), POST or base64-GET it * (run_pki_op), then parse + verify the CertRep and fill `out` (scep_finish). * Caller retains ownership of `txid_override`; when NULL the transactionID is - * derived per srv->scep_txid_mode. */ + * derived per srv->proto_opts.scep.txid_mode. */ static int do_scep_round_trip(const WolfCertServerCfg* srv, const WolfCertScepCaps* caps, const uint8_t* ra_cert, size_t ra_cert_len, @@ -904,7 +904,7 @@ static int do_scep_round_trip(const WolfCertServerCfg* srv, ScepTxidSel txid_sel = { .id = txid_override, .id_len = txid_override_len, - .mode = srv->scep_txid_mode + .mode = srv->proto_opts.scep.txid_mode }; WolfCertBuffer pki = { 0 }; @@ -914,7 +914,7 @@ static int do_scep_round_trip(const WolfCertServerCfg* srv, int rc = scep_prepare(heap, caps, ra_cert, ra_cert_len, signer_cert, signer_cert_len, signer_key, signer_key_len, msg_type, envelope_content, envelope_content_len, - &txid_sel, srv->scep_content_cipher, + &txid_sel, srv->proto_opts.scep.content_cipher, &pki, &txid, &txid_len, nonce); if (rc != WOLFCERT_OK) return rc; @@ -1213,7 +1213,7 @@ int wolfcert_scep_get_next_ca_cert(const WolfCertServerCfg* srv, void* heap = srv->heap ? srv->heap : wolfcert_default_heap(); char* url = wolfcert_scep_build_getca_url(srv->server_url, "GetNextCACert", - srv->scep_ca_id, heap); + srv->proto_opts.scep.ca_id, heap); if (url == NULL) return WOLFCERT_ERR_MEMORY; @@ -1338,8 +1338,8 @@ static int scep_session_open_common(const WolfCertServerCfg* srv, int nonblockin memset(s, 0, sizeof(*s)); s->heap = heap; s->nonblocking = nonblocking; - s->txid_mode = srv->scep_txid_mode; - s->content_cipher = srv->scep_content_cipher; + s->txid_mode = srv->proto_opts.scep.txid_mode; + s->content_cipher = srv->proto_opts.scep.content_cipher; s->server_url = wolfcert_strdup(srv->server_url, heap); if (s->server_url == NULL) { WOLFCERT_XFREE(s, heap); diff --git a/tests/integration/test_est_async_roundtrip.c b/tests/integration/test_est_async_roundtrip.c index 2c87c43..c4df704 100644 --- a/tests/integration/test_est_async_roundtrip.c +++ b/tests/integration/test_est_async_roundtrip.c @@ -152,16 +152,16 @@ int main(void) wolfcert_server_port(srv)); WolfCertServerCfg cli = { - .protocol = WOLFCERT_PROTO_EST, - .server_url = url, - .trust_anchors = tls_cert, - .trust_anchors_len = tls_cert_len, - .verify_server = 1, - .client_cert = cli_cert, - .client_cert_len = cli_cert_len, - .client_key = cli_key, - .client_key_len = cli_key_len, - .allow_post_handshake_auth = 1, + .protocol = WOLFCERT_PROTO_EST, + .server_url = url, + .trust_anchors = tls_cert, + .trust_anchors_len = tls_cert_len, + .verify_server = 1, + .client_cert = cli_cert, + .client_cert_len = cli_cert_len, + .client_key = cli_key, + .client_key_len = cli_key_len, + .proto_opts.est = { .allow_post_handshake_auth = 1 }, }; WolfCertEstSession* es = NULL; REQUIRE(wolfcert_est_session_open_async(&cli, &es) == WOLFCERT_OK); diff --git a/tests/integration/test_est_csr_attrs_apply_roundtrip.c b/tests/integration/test_est_csr_attrs_apply_roundtrip.c index 822e839..2f501df 100644 --- a/tests/integration/test_est_csr_attrs_apply_roundtrip.c +++ b/tests/integration/test_est_csr_attrs_apply_roundtrip.c @@ -21,7 +21,7 @@ * End-to-end coverage for auto-apply of /csrattrs hints during * enrolment. The test server publishes a CsrAttrs policy pinning * ECC P-384 + SHA-384 + challengePassword-required; the client - * runs `wolfcert_client_enroll` with `srv.auto_csrattrs = 1` and an + * runs `wolfcert_client_enroll` with `srv.proto_opts.est.auto_csrattrs = 1` and an * empty `key_cfg` (type = 0). The result must be: * * - effective key is ECC on curve P-384 (server pin applied); @@ -156,7 +156,7 @@ static int auto_apply_pins_everything(WolfCertServer* s) WolfCertServerCfg srv = { .protocol = WOLFCERT_PROTO_EST, .server_url = url, - .auto_csrattrs = 1, + .proto_opts.est = { .auto_csrattrs = 1 }, .trust_anchors = g_ca, .trust_anchors_len = g_ca_len, .verify_server = 1, }; @@ -201,7 +201,7 @@ static int explicit_caller_wins(WolfCertServer* s) WolfCertServerCfg srv = { .protocol = WOLFCERT_PROTO_EST, .server_url = url, - .auto_csrattrs = 1, + .proto_opts.est = { .auto_csrattrs = 1 }, .trust_anchors = g_ca, .trust_anchors_len = g_ca_len, .verify_server = 1, }; diff --git a/tests/integration/test_est_pha_roundtrip.c b/tests/integration/test_est_pha_roundtrip.c index 06fb64d..321dd09 100644 --- a/tests/integration/test_est_pha_roundtrip.c +++ b/tests/integration/test_est_pha_roundtrip.c @@ -111,16 +111,16 @@ int main(void) /* --- Positive: session with client identity + PHA opt-in. */ { WolfCertServerCfg cli = { - .protocol = WOLFCERT_PROTO_EST, - .server_url = url, - .trust_anchors = tls_cert, - .trust_anchors_len = tls_cert_len, - .verify_server = 1, - .client_cert = cli_cert, - .client_cert_len = cli_cert_len, - .client_key = cli_key, - .client_key_len = cli_key_len, - .allow_post_handshake_auth = 1, + .protocol = WOLFCERT_PROTO_EST, + .server_url = url, + .trust_anchors = tls_cert, + .trust_anchors_len = tls_cert_len, + .verify_server = 1, + .client_cert = cli_cert, + .client_cert_len = cli_cert_len, + .client_key = cli_key, + .client_key_len = cli_key_len, + .proto_opts.est = { .allow_post_handshake_auth = 1 }, }; WolfCertEstSession* s = NULL; REQUIRE(wolfcert_est_session_open(&cli, &s) == WOLFCERT_OK); @@ -160,12 +160,12 @@ int main(void) * because the PHA prompt finds nothing to send. */ { WolfCertServerCfg cli = { - .protocol = WOLFCERT_PROTO_EST, - .server_url = url, - .trust_anchors = tls_cert, - .trust_anchors_len = tls_cert_len, - .verify_server = 1, - .allow_post_handshake_auth = 1, + .protocol = WOLFCERT_PROTO_EST, + .server_url = url, + .trust_anchors = tls_cert, + .trust_anchors_len = tls_cert_len, + .verify_server = 1, + .proto_opts.est = { .allow_post_handshake_auth = 1 }, }; WolfCertEstSession* s = NULL; REQUIRE(wolfcert_est_session_open(&cli, &s) == WOLFCERT_OK); diff --git a/tests/integration/test_scep_roundtrip.c b/tests/integration/test_scep_roundtrip.c index c3f07e4..f53ceb6 100644 --- a/tests/integration/test_scep_roundtrip.c +++ b/tests/integration/test_scep_roundtrip.c @@ -185,7 +185,7 @@ static int check_get_fallback(const WolfCertServerCfg* cli, return rc; } -/* WolfCertServerCfg.scep_txid_mode = PUBKEY_HASH: the transactionID must be the +/* proto_opts.scep.txid_mode = PUBKEY_HASH: the transactionID must be the * 64-char upper-case hex SHA-256 of the enrollee SubjectPublicKeyInfo, must * match a value recomputed from the CSR, and must be deterministic (a second * enrollment of the same key reuses it). Owns and frees everything it makes. */ @@ -201,7 +201,7 @@ static int check_pubkey_txid(const WolfCertServerCfg* cli, WolfCertScepResult r1 = { 0 }, r2 = { 0 }; int rc; - cli_ph.scep_txid_mode = WOLFCERT_SCEP_TXID_PUBKEY_HASH; + cli_ph.proto_opts.scep.txid_mode = WOLFCERT_SCEP_TXID_PUBKEY_HASH; rc = wolfcert_key_generate(kcfg, &key); if (rc == WOLFCERT_OK) @@ -267,7 +267,7 @@ static int check_pubkey_txid(const WolfCertServerCfg* cli, #endif #ifdef WOLFCERT_TEST_HAVE_CIPHER_OVERRIDE -/* WolfCertServerCfg.scep_content_cipher override: enrolling with an explicit +/* proto_opts.scep.content_cipher override: enrolling with an explicit * cipher must still issue a cert - the server de-envelops whatever OID the * request carries - proving AES-256 (and explicit AES-128) interoperate. */ static int check_content_cipher(const WolfCertServerCfg* cli, @@ -282,7 +282,7 @@ static int check_content_cipher(const WolfCertServerCfg* cli, WolfCertBuffer csr = { 0 }, issued = { 0 }; int rc; - c.scep_content_cipher = cipher; + c.proto_opts.scep.content_cipher = cipher; rc = wolfcert_key_generate(kcfg, &key); if (rc == WOLFCERT_OK) diff --git a/tests/unit/test_est.c b/tests/unit/test_est.c index bc24668..6eb9751 100644 --- a/tests/unit/test_est.c +++ b/tests/unit/test_est.c @@ -350,6 +350,58 @@ static int test_est_require_server_auth(void) return 0; } +/* WolfCertServerCfg.protocol discriminates the proto_opts union, so an EST + * entry point handed a SCEP config must refuse it rather than read the wrong + * arm. The overlay is actively dangerous: proto_opts.scep.txid_mode and + * .content_cipher share storage with proto_opts.est.password, so reading the + * EST arm here would hand basic_auth_header a pointer fabricated from two + * enum values. Every rejection happens before any network access. */ +static int test_est_rejects_scep_cfg(void) +{ + static const uint8_t dummy_csr[] = { 0x30, 0x03, 0x02, 0x01, 0x00 }; + WolfCertServerCfg srv = { + .protocol = WOLFCERT_PROTO_SCEP, + .server_url = "https://127.0.0.1:1/scep", + .verify_server = 1, + .proto_opts.scep = { + .ca_id = "RolloverCA", + .txid_mode = WOLFCERT_SCEP_TXID_PUBKEY_HASH, + .content_cipher = WOLFCERT_SCEP_CIPHER_AES256 + } + }; + WolfCertBuffer out = { 0 }; + WolfCertEstSession* sess = NULL; + WolfCertKeyCfg kcfg = { .type = TEST_ENROLL_KEY_TYPE, .param = TEST_ENROLL_KEY_PARAM, + .dev_id = WOLFCERT_DEVID_SOFTWARE }; + WolfCertKey* rk = NULL; + + REQUIRE(wolfcert_est_get_cacerts(&srv, &out) == WOLFCERT_ERR_BAD_ARG); + REQUIRE(wolfcert_est_get_csr_attrs(&srv, &out) == WOLFCERT_ERR_BAD_ARG); + REQUIRE(wolfcert_est_simple_enroll(&srv, dummy_csr, sizeof(dummy_csr), + &out) == WOLFCERT_ERR_BAD_ARG); + + REQUIRE(wolfcert_key_generate(&kcfg, &rk) == WOLFCERT_OK); + REQUIRE(wolfcert_est_simple_reenroll(&srv, dummy_csr, sizeof(dummy_csr), rk, + dummy_csr, sizeof(dummy_csr), &out) + == WOLFCERT_ERR_BAD_ARG); + wolfcert_key_free(rk); + + /* Both session-open paths gate on the discriminator too, before they copy + * the credentials out of the union. */ + REQUIRE(wolfcert_est_session_open(&srv, &sess) == WOLFCERT_ERR_BAD_ARG); + REQUIRE(sess == NULL); + REQUIRE(wolfcert_est_session_open_async(&srv, &sess) == WOLFCERT_ERR_BAD_ARG); + REQUIRE(sess == NULL); + + /* An unset discriminator is refused for the same reason: nothing says + * which arm of the union the caller populated. */ + srv.protocol = (WolfCertProtocol)0; + REQUIRE(wolfcert_est_get_cacerts(&srv, &out) == WOLFCERT_ERR_BAD_ARG); + REQUIRE(wolfcert_est_session_open(&srv, &sess) == WOLFCERT_ERR_BAD_ARG); + + 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, @@ -393,6 +445,9 @@ int main(void) if (test_est_require_server_auth()) return 1; + if (test_est_rejects_scep_cfg()) + return 1; + uint8_t ca_der[4096]; size_t ca_len = 0; REQUIRE(make_test_ca(ca_der, sizeof(ca_der), &ca_len) == 0); diff --git a/wolfcert/est.h b/wolfcert/est.h index f289fe6..bb25bb2 100644 --- a/wolfcert/est.h +++ b/wolfcert/est.h @@ -233,7 +233,7 @@ WOLFCERT_API int wolfcert_est_simple_reenroll_ex(const WolfCertServerCfg* srv, * * 1. Open the session with a client cert/key (factory identity), * WolfCertServerCfg.verify_server on, and - * wolfcert_est_session_cfg.allow_post_handshake_auth = 1. + * WolfCertServerCfg.proto_opts.est.allow_post_handshake_auth = 1. * 2. Call wolfcert_est_session_get_cacerts - goes out on an anonymous * TLS connection (server doesn't ask for the identity yet). * 3. Call wolfcert_est_session_simple_enroll - server requests the @@ -263,8 +263,9 @@ WOLFCERT_API int wolfcert_est_session_fd(const WolfCertEstSession* s); /* Async variants. The session must have been opened via a * WolfCertServerCfg built from a WolfCertHttpSessionCfg with - * nonblocking=1; at that API level, pass srv->allow_post_handshake_auth - * and/or other options plus the new wolfcert_est_session_open_async(). + * nonblocking=1; at that API level, pass + * srv->proto_opts.est.allow_post_handshake_auth and/or other options plus + * the new wolfcert_est_session_open_async(). * * Each call drives the HTTP session state machine forward and returns * WOLFCERT_OK - `out_*` populated. diff --git a/wolfcert/server.h b/wolfcert/server.h index 4aff980..706053a 100644 --- a/wolfcert/server.h +++ b/wolfcert/server.h @@ -130,8 +130,8 @@ typedef struct { * csr_attributes_der is empty (nothing to enforce). * * Test-server convenience for exercising the client-side - * `srv->auto_csrattrs` round-trip end to end; real deployments - * wire this policy into a proper RA. */ + * `srv->proto_opts.est.auto_csrattrs` round-trip end to end; + * real deployments wire this policy into a proper RA. */ int est_require_csr_attributes; /* Heap hint for server-internal allocations. */ diff --git a/wolfcert/types.h b/wolfcert/types.h index 5715f6b..af896f7 100644 --- a/wolfcert/types.h +++ b/wolfcert/types.h @@ -153,13 +153,13 @@ typedef struct { void* customize_ctx; } WolfCertCertMeta; -/* SCEP transactionID derivation (WolfCertServerCfg.scep_txid_mode). */ +/* SCEP transactionID derivation (WolfCertScepServerOpts.txid_mode). */ typedef enum { WOLFCERT_SCEP_TXID_RANDOM = 0, /* random 16-byte value, hex-encoded (default) */ WOLFCERT_SCEP_TXID_PUBKEY_HASH = 1 /* SHA-256 of the signer public key */ } WolfCertScepTxidMode; -/* SCEP request content-encryption cipher (WolfCertServerCfg.scep_content_cipher). +/* SCEP request content-encryption cipher (WolfCertScepServerOpts.content_cipher). * AUTO keeps the RFC 8894 caps-driven default; the explicit values force a * cipher for a known peer (e.g. a wolfSCEP deployment that requires AES-256). */ typedef enum { @@ -169,6 +169,50 @@ typedef enum { WOLFCERT_SCEP_CIPHER_DES3 = 3 } WolfCertScepContentCipher; +/* EST-only knobs, reached through WolfCertServerCfg.proto_opts.est. */ +typedef struct { + /* TLS 1.3 post-handshake authentication opt-in (RFC 8446 section 4.6.2). + * Read only by the keep-alive EST session API; the one-shot + * per-request transports don't keep a session to re-auth on. When + * set, client_cert / client_key are loaded on the SSL even + * when the initial handshake is anonymous so that wolfSSL can + * answer a later CertificateRequest from the server transparently. */ + int allow_post_handshake_auth; + + /* Auto-discovery of /csrattrs hints before enrolment. When + * non-zero, `wolfcert_client_enroll` fetches the server's + * /.well-known/est/csrattrs response, parses it, and applies the + * typed hints (preferred key type + curve / RSA bits, + * preferred signature hash) to a writable copy of the caller's + * `key_cfg` and `meta` before generating the key and building the + * CSR. Caller-supplied explicit values always win - this only + * fills fields the caller left at their zero defaults. + * + * Silently a no-op when the server responds with 204 No Content + * (no policy advertised). */ + int auto_csrattrs; +} WolfCertEstServerOpts; + +/* SCEP-only knobs, reached through WolfCertServerCfg.proto_opts.scep. + * All three are zero-init-safe: the default value preserves wolfCert's + * pre-existing behavior. */ +typedef struct { + /* CA identifier sent as the `message` query parameter on GetCACaps and + * GetCACert (RFC 8894 section 3.5.2 / 4.2), used to select a specific CA on + * a multi-CA responder. NULL (the default) omits the parameter. */ + const char* ca_id; + + /* How the enrollment pkiMessage transactionID is derived. RANDOM (default) + * uses fresh RNG bytes; PUBKEY_HASH derives it from the signer public key + * (RFC 8894 section 3.2.1) so retries of the same key reuse one ID. */ + WolfCertScepTxidMode txid_mode; + + /* Content-encryption cipher for the request EnvelopedData. AUTO (default) + * keeps the RFC 8894 caps-driven choice (AES-128-CBC, else 3DES); an + * explicit value forces that cipher for a peer that requires it. */ + WolfCertScepContentCipher content_cipher; +} WolfCertScepServerOpts; + typedef struct { WolfCertProtocol protocol; const char* server_url; /* e.g. https://ca.example/.well-known/est */ @@ -200,54 +244,30 @@ typedef struct { * typically sets this explicitly. */ size_t max_response_bytes; - /* TLS 1.3 post-handshake authentication opt-in (RFC 8446 section 4.6.2). - * Read only by the keep-alive EST session API; the one-shot - * per-request transports don't keep a session to re-auth on. When - * set, client_cert / client_key are loaded on the SSL even - * when the initial handshake is anonymous so that wolfSSL can - * answer a later CertificateRequest from the server transparently. */ - int allow_post_handshake_auth; - - /* Auto-discovery of /csrattrs hints before enrolment. When - * non-zero, `wolfcert_client_enroll` fetches the server's - * /.well-known/est/csrattrs response, parses it, and applies the - * typed hints (preferred key type + curve / RSA bits, - * preferred signature hash) to a writable copy of the caller's - * `key_cfg` and `meta` before generating the key and building the - * CSR. Caller-supplied explicit values always win - this only - * fills fields the caller left at their zero defaults. - * - * EST only; wolfcert_client_enroll returns WOLFCERT_ERR_UNSUPPORTED - * if the protocol is SCEP. Silently a no-op when the server - * responds with 204 No Content (no policy advertised). */ - int auto_csrattrs; - /* Optional pluggable transport. When connect_cb is set, wolfCert calls it * (instead of the built-in wolfcert_posix_connect) to open every TCP * connection for this server; connect_ctx is passed through untouched. * Lets an application own DNS/socket policy or supply a custom transport * while wolfCert keeps doing the HTTP and (via wolfSSL) TLS I/O. */ WolfCertConnectFn connect_cb; - void* connect_ctx; - - /* ---- SCEP-only options (ignored by EST) ---------------------------- - * All three are zero-init-safe: the default value preserves wolfCert's - * pre-existing behavior. */ - - /* CA identifier sent as the `message` query parameter on GetCACaps and - * GetCACert (RFC 8894 section 3.5.2 / 4.2), used to select a specific CA on - * a multi-CA responder. NULL (the default) omits the parameter. */ - const char* scep_ca_id; + void* connect_ctx; - /* How the enrollment pkiMessage transactionID is derived. RANDOM (default) - * uses fresh RNG bytes; PUBKEY_HASH derives it from the signer public key - * (RFC 8894 section 3.2.1) so retries of the same key reuse one ID. */ - WolfCertScepTxidMode scep_txid_mode; - - /* Content-encryption cipher for the request EnvelopedData. AUTO (default) - * keeps the RFC 8894 caps-driven choice (AES-128-CBC, else 3DES); an - * explicit value forces that cipher for a peer that requires it. */ - WolfCertScepContentCipher scep_content_cipher; + /* Protocol-specific options, selected by `protocol`: one connection is + * either EST or SCEP, never both, so the two option sets share storage. + * Every member is zero-init-safe - leaving the union untouched keeps the + * default behavior of both protocols. The arm that does not match + * `protocol` is never read. + * + * Because `protocol` is the discriminator, it must be set on every config + * handed to a protocol-specific entry point, not just to the generic + * wolfcert_client_* calls. A wolfcert_est_* call rejects a config whose + * protocol is not WOLFCERT_PROTO_EST with WOLFCERT_ERR_BAD_ARG, and the + * wolfcert_scep_* calls do the same for WOLFCERT_PROTO_SCEP, rather than + * reinterpret the other arm's storage. */ + union { + WolfCertEstServerOpts est; + WolfCertScepServerOpts scep; + } proto_opts; /* Heap hint for any library-internal allocations made while servicing * this request. NULL = library default. */ From 1153af56fa4838e54256f00a63982ca4114dcd77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Thu, 30 Jul 2026 10:11:07 +0200 Subject: [PATCH 05/11] est: carry HTTP Basic credentials on sessions, and scope them to EST The keep-alive EST session never sent the credentials it was given. Its four request builders left WolfCertHttpRequest.basic_user unset, so a caller that moved from the one-shot calls to a session silently lost the Authorization header, even though the transport would have emitted it. RFC 7030 section 3.2.3 lists HTTP Basic as a client authentication mechanism, so the session has to carry it across every request on the connection, not just the first. The session now copies the pair at open, since the caller's WolfCertServerCfg need not outlive it, replays it on every request in both the blocking and the async form, and zeroizes both halves on close. The teardown after a failed transport open goes through that same close helper. It previously freed only the URL and the session, leaking the two credential copies and leaving the password unscrubbed in the heap. That path is routine rather than exotic: EST requires server verification, so any DNS failure, refused connection or untrusted server certificate lands there, and an enrolment retry loop would strand one plaintext copy per attempt. username and password move into the EST arm of proto_opts at the same time, and the SCEP entry points stop setting them. RFC 8894 authenticates the enrollment inside the pkiMessage, through the CMS signature bound to the CA/RA bundle plus the PKCS#9 challengePassword, and defines nothing at the HTTP layer. Neither SCEP interop target uses HTTP credentials either: est_stepca.sh passes --user and --pass for EST but --challenge for SCEP, and scep_micromdm.sh passes none at all. The CLI grows the matching guard, so asking for --user with --proto scep is an error rather than a flag that quietly does nothing. Both new tests fail without the plumbing: the blocking one enrolls with the good credentials and then requires a rejection with a wrong password, so a session that dropped the header cannot pass both halves, and the async one pumps /cacerts and /simpleenroll against a second Basic-only server. --- cli/wolfcert_client.c | 69 ++++++++++++++++++-- docs/ARCHITECTURE.md | 5 +- src/est/est_client.c | 51 +++++++++++++-- src/scep/scep_client.c | 6 +- tests/integration/test_est_async_roundtrip.c | 52 ++++++++++++++- tests/integration/test_est_mldsa_roundtrip.c | 3 +- tests/integration/test_est_roundtrip.c | 67 +++++++++++++++++-- wolfcert/est.h | 5 ++ wolfcert/scep.h | 16 +++-- wolfcert/types.h | 8 ++- 10 files changed, 252 insertions(+), 30 deletions(-) diff --git a/cli/wolfcert_client.c b/cli/wolfcert_client.c index 1d1b1df..7114695 100644 --- a/cli/wolfcert_client.c +++ b/cli/wolfcert_client.c @@ -84,8 +84,8 @@ static void print_usage(FILE* out) " --trust PEMFILE Trust anchors for TLS; turns on server\n" " certificate verification. Required for EST\n" " (RFC 7030 mandates authenticating the server).\n" - " --user USER HTTP Basic user (EST)\n" - " --pass PASS HTTP Basic password (EST)\n" + " --user USER HTTP Basic user (EST only)\n" + " --pass PASS HTTP Basic password (EST only)\n" " --challenge PASS SCEP challengePassword (CSR attribute, RFC 8894 section 2.9)\n" " --client-cert PEMFILE Client certificate for mutual TLS\n" " --client-key PEMFILE Private key for --client-cert\n" @@ -437,6 +437,51 @@ static void fill_trust(const Opts* opts, WolfCertServerCfg* cfg, *trust_hold = trust_buf; } +/* Reject the EST-only options when the caller picked SCEP, rather than quietly + * ignoring something they asked for. Each has no SCEP counterpart: RFC 8894 + * authenticates the enrollment inside the pkiMessage (--challenge) and defines + * nothing at the HTTP layer, has no attribute-hint endpoint to fetch, and no + * keep-alive TLS identity to re-authenticate mid-session. Single home for this + * policy, so every command scopes the same flags the same way. */ +static int check_est_only_opts(const Opts* opts, WolfCertProtocol p) +{ + if (p == WOLFCERT_PROTO_EST) + return 0; + + if (opts->user != NULL || opts->pass != NULL) { + fprintf(stderr, "--user/--pass are EST-only; SCEP authenticates with " + "--challenge (RFC 8894 section 2.9)\n"); + return -1; + } + + if (opts->csrattrs_auto) { + fprintf(stderr, "--csrattrs-auto is EST-only; SCEP has no /csrattrs " + "endpoint\n"); + return -1; + } + + if (opts->pha) { + fprintf(stderr, "--pha is EST-only; SCEP authenticates at the " + "pkiMessage layer, not in the TLS handshake\n"); + return -1; + } + + return 0; +} + +/* Copy the HTTP Basic credentials into the EST arm of proto_opts. The protocol + * test is what keeps this from writing an inactive union member - a SCEP config + * would alias them onto proto_opts.scep - and check_est_only_opts has already + * rejected credentials supplied under SCEP. cfg->protocol must be set. */ +static void fill_basic_auth(const Opts* opts, WolfCertServerCfg* cfg) +{ + if (cfg->protocol != WOLFCERT_PROTO_EST) + return; + + cfg->proto_opts.est.username = opts->user; + cfg->proto_opts.est.password = opts->pass; +} + static int fill_client_ident(const Opts* opts, WolfCertServerCfg* cfg, uint8_t** cert_hold, uint8_t** key_hold) { @@ -486,12 +531,15 @@ static int cmd_getcacerts(int argc, char** argv) if (ret == 0 && proto_of(opts.proto, &p) != 0) ret = 1; + if (ret == 0 && check_est_only_opts(&opts, p) != 0) + ret = 1; + WolfCertServerCfg srv = { .protocol = p, .server_url = opts.url, - .username = opts.user, .password = opts.pass, .connect_cb = wolfcert_posix_connect }; if (ret == 0) { fill_trust(&opts, &srv, &trust_hold); + fill_basic_auth(&opts, &srv); if (fill_client_ident(&opts, &srv, &mt_cert, &mt_key) != 0) ret = 1; } @@ -552,6 +600,9 @@ static int cmd_enroll(int argc, char** argv) if (ret == 0 && proto_of(opts.proto, &p) != 0) ret = 1; + if (ret == 0 && check_est_only_opts(&opts, p) != 0) + ret = 1; + if (ret == 0 && opts.subject == NULL) { fprintf(stderr, "enroll: --subject required\n"); ret = 1; @@ -561,7 +612,6 @@ static int cmd_enroll(int argc, char** argv) * pick a key type. The key cfg + meta are populated below, then * optionally overlaid with /csrattrs hints, then used to generate. */ WolfCertServerCfg srv = { .protocol = p, .server_url = opts.url, - .username = opts.user, .password = opts.pass, .connect_cb = wolfcert_posix_connect }; WolfCertCertMeta meta = { .subject_dn = opts.subject, .san_dns = opts.san_dns, .san_dns_len = opts.san_dns_len, @@ -572,6 +622,7 @@ static int cmd_enroll(int argc, char** argv) if (ret == 0) { fill_trust(&opts, &srv, &trust_hold); + fill_basic_auth(&opts, &srv); if (fill_client_ident(&opts, &srv, &mt_cert, &mt_key) != 0) ret = 1; } @@ -852,6 +903,9 @@ static int cmd_reenroll(int argc, char** argv) if (ret == 0 && proto_of(opts.proto, &p) != 0) ret = 1; + if (ret == 0 && check_est_only_opts(&opts, p) != 0) + ret = 1; + if (ret == 0 && p != WOLFCERT_PROTO_EST) { fprintf(stderr, "reenroll: only EST is supported in the CLI today\n"); ret = 2; @@ -893,11 +947,11 @@ static int cmd_reenroll(int argc, char** argv) } WolfCertServerCfg srv = { .protocol = p, .server_url = opts.url, - .username = opts.user, .password = opts.pass, .connect_cb = wolfcert_posix_connect }; if (ret == 0) { fill_trust(&opts, &srv, &trust_hold); + fill_basic_auth(&opts, &srv); if (fill_client_ident(&opts, &srv, &mt_cert, &mt_key) != 0) ret = 1; } @@ -965,17 +1019,20 @@ static int cmd_getnextca(int argc, char** argv) if (ret == 0 && proto_of(opts.proto, &p) != 0) ret = 1; + if (ret == 0 && check_est_only_opts(&opts, p) != 0) + ret = 1; + if (ret == 0 && p != WOLFCERT_PROTO_SCEP) { fprintf(stderr, "getnextca: only --proto scep is supported\n"); ret = 1; } WolfCertServerCfg srv = { .protocol = p, .server_url = opts.url, - .username = opts.user, .password = opts.pass, .connect_cb = wolfcert_posix_connect }; if (ret == 0) { fill_trust(&opts, &srv, &trust_hold); + fill_basic_auth(&opts, &srv); if (fill_client_ident(&opts, &srv, &mt_cert, &mt_key) != 0) ret = 1; } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f366fa4..b85ddf2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -145,7 +145,10 @@ what enables the post-handshake-auth bootstrap below. **Authentication shapes.** EST supports three, all through the same `WolfCertServerCfg`: -1. **HTTP Basic over TLS** — set `username` / `password`. +1. **HTTP Basic over TLS** — set `proto_opts.est.username` / `.password`. Sent + on the one-shot calls and on every request a keep-alive session issues. + EST-only: SCEP authenticates inside the pkiMessage, so the fields live in + the EST arm of the union and no SCEP entry point reads them. 2. **mTLS up front** — set `client_cert` / `client_key`; they're presented during the handshake. 3. **TLS 1.3 post-handshake auth** — set `client_cert` / `client_key` *and* diff --git a/src/est/est_client.c b/src/est/est_client.c index e255fbd..1724dc1 100644 --- a/src/est/est_client.c +++ b/src/est/est_client.c @@ -48,8 +48,8 @@ static char* join_path(const char* base, const char* suffix, void* heap) static void fill_common(const WolfCertServerCfg* srv, WolfCertHttpRequest* req) { - req->basic_user = srv->username; - req->basic_pass = srv->password; + req->basic_user = srv->proto_opts.est.username; + req->basic_pass = srv->proto_opts.est.password; req->trust_anchors = srv->trust_anchors; req->trust_anchors_len = srv->trust_anchors_len; req->verify_server = srv->verify_server; @@ -389,6 +389,12 @@ struct WolfCertEstSession { size_t max_body; void* heap; + /* HTTP Basic credentials, copied from the config at open (the caller's + * WolfCertServerCfg need not outlive the session) and replayed on every + * request the session issues. NULL when the caller supplied none. */ + char* basic_user; + char* basic_pass; + /* Async in-flight state: at most one request at a time. */ int in_active; char* in_url; @@ -458,6 +464,22 @@ static int est_session_open_common(const WolfCertServerCfg* srv, int nonblocking return WOLFCERT_ERR_MEMORY; } + /* RFC 7030 section 3.2.3: HTTP Basic is one of the client authentication + * mechanisms an EST server may demand, so the session has to carry the + * credentials across every request on the connection, not just the first. */ + const char* user = srv->proto_opts.est.username; + const char* pass = srv->proto_opts.est.password; + if (user != NULL) + s->basic_user = wolfcert_strdup(user, heap); + if (pass != NULL) + s->basic_pass = wolfcert_strdup(pass, heap); + if ((user != NULL && s->basic_user == NULL) || + (pass != NULL && s->basic_pass == NULL)) { + wolfcert_est_session_close(s); + WOLFCERT_XFREE(origin, heap); + return WOLFCERT_ERR_MEMORY; + } + WolfCertHttpSessionCfg hcfg = { .base_url = origin, .trust_anchors = srv->trust_anchors, @@ -479,8 +501,13 @@ static int est_session_open_common(const WolfCertServerCfg* srv, int nonblocking WOLFCERT_XFREE(origin, heap); if (rc != WOLFCERT_OK) { - WOLFCERT_XFREE(s->base_url, heap); - WOLFCERT_XFREE(s, heap); + /* Tear down through the close helper rather than freeing by hand: it + * is the one place that zeroizes the Basic password copy, and a dial + * failure here (DNS, connect, or a rejected server certificate) is + * routine enough that an enrolment retry loop would otherwise strand + * one plaintext copy per attempt. s->http is NULL, so the helper's + * `if (s->http)` guard makes it safe on a half-built session. */ + wolfcert_est_session_close(s); return rc; } @@ -527,6 +554,15 @@ void wolfcert_est_session_close(WolfCertEstSession* s) wolfcert_http_session_close(s->http); WOLFCERT_XFREE(s->base_url, s->heap); + + /* The credential pair has no reason to outlive the session; the user half + * counts too, since half a credential still narrows an attacker's search. */ + if (s->basic_user != NULL) + wc_ForceZero(s->basic_user, (word32)strlen(s->basic_user)); + WOLFCERT_XFREE(s->basic_user, s->heap); + if (s->basic_pass != NULL) + wc_ForceZero(s->basic_pass, (word32)strlen(s->basic_pass)); + WOLFCERT_XFREE(s->basic_pass, s->heap); WOLFCERT_XFREE(s, s->heap); } @@ -544,6 +580,7 @@ int wolfcert_est_session_get_cacerts_nb(WolfCertEstSession* s, s->in_req = (WolfCertHttpRequest){ .method = "GET", .url = s->in_url, .accept = "application/pkcs7-mime", + .basic_user = s->basic_user, .basic_pass = s->basic_pass, .max_response_bytes = s->max_body, .heap = s->heap, }; @@ -603,6 +640,8 @@ int wolfcert_est_session_simple_enroll_nb(WolfCertEstSession* s, .content_type = "application/pkcs10", .content_transfer_encoding = "base64", .accept = "application/pkcs7-mime", + .basic_user = s->basic_user, + .basic_pass = s->basic_pass, .body = s->in_body.data, .body_len = s->in_body.len, .max_response_bytes = s->max_body, @@ -658,6 +697,8 @@ int wolfcert_est_session_get_cacerts(WolfCertEstSession* s, WolfCertHttpRequest req = { .method = "GET", .url = url, .accept = "application/pkcs7-mime", + .basic_user = s->basic_user, + .basic_pass = s->basic_pass, .max_response_bytes = s->max_body, .heap = s->heap }; WolfCertHttpResponse resp = { 0 }; @@ -710,6 +751,8 @@ int wolfcert_est_session_simple_enroll(WolfCertEstSession* s, .content_type = "application/pkcs10", .content_transfer_encoding = "base64", .accept = "application/pkcs7-mime", + .basic_user = s->basic_user, + .basic_pass = s->basic_pass, .body = b64.data, .body_len = b64.len, .max_response_bytes = s->max_body, diff --git a/src/scep/scep_client.c b/src/scep/scep_client.c index 3ae5cb0..77a1138 100644 --- a/src/scep/scep_client.c +++ b/src/scep/scep_client.c @@ -57,10 +57,12 @@ static char* append_query(const char* base, const char* op, void* heap) return url; } +/* No HTTP Basic credentials here, unlike EST: RFC 8894 authenticates the + * enrollment inside the pkiMessage - the CMS signature bound to the CA/RA + * bundle plus the PKCS#9 challengePassword - and defines nothing at the HTTP + * layer. */ static void fill_common(const WolfCertServerCfg* srv, WolfCertHttpRequest* req) { - req->basic_user = srv->username; - req->basic_pass = srv->password; req->trust_anchors = srv->trust_anchors; req->trust_anchors_len = srv->trust_anchors_len; req->verify_server = srv->verify_server; diff --git a/tests/integration/test_est_async_roundtrip.c b/tests/integration/test_est_async_roundtrip.c index c4df704..5d4d194 100644 --- a/tests/integration/test_est_async_roundtrip.c +++ b/tests/integration/test_est_async_roundtrip.c @@ -189,15 +189,63 @@ int main(void) REQUIRE(memmem(issued.data, issued.len, "BEGIN CERTIFICATE", 17) != NULL); wolfcert_buffer_free(&ca_pem); - wolfcert_buffer_free(&csr); wolfcert_buffer_free(&issued); - wolfcert_key_free(dk); wolfcert_est_session_close(es); wolfcert_server_stop(srv); pthread_join(tid, NULL); wolfcert_server_free(srv); + /* --- HTTP Basic on the async session (RFC 7030 section 3.2.3). The + * credentials must ride every request the session pumps out, so /cacerts + * and /simpleenroll both have to satisfy a server that demands them. + * Reuses the CSR built above against a second, Basic-only server. */ + WolfCertServerCfgSrv bcfg = { + .protocol = WOLFCERT_PROTO_EST, + .bind_host = "127.0.0.1", + .bind_port = 0, + .http_basic_user = "alice", + .http_basic_pass = "hunter2", + .tls_cert_pem = tls_cert, .tls_cert_pem_len = tls_cert_len, + .tls_key_pem = tls_key, .tls_key_pem_len = tls_key_len, + }; + WolfCertServer* bsrv = NULL; + REQUIRE(wolfcert_server_start(&bcfg, &bsrv) == WOLFCERT_OK); + pthread_t btid; + REQUIRE(pthread_create(&btid, NULL, server_thread, bsrv) == 0); + + char burl[128]; + snprintf(burl, sizeof(burl), "https://127.0.0.1:%u/.well-known/est", + wolfcert_server_port(bsrv)); + + WolfCertServerCfg bcli = { + .protocol = WOLFCERT_PROTO_EST, + .server_url = burl, + .proto_opts.est = { .username = "alice", .password = "hunter2" }, + .trust_anchors = tls_cert, + .trust_anchors_len = tls_cert_len, + .verify_server = 1, + }; + WolfCertEstSession* bes = NULL; + REQUIRE(wolfcert_est_session_open_async(&bcli, &bes) == WOLFCERT_OK); + + WolfCertBuffer bca = { 0 }, bissued = { 0 }; + REQUIRE(pump_get_cacerts(bes, &bca) == 0); + REQUIRE(bca.len > 0); + REQUIRE(pump_simple_enroll(bes, csr.data, csr.len, &bissued) == 0); + REQUIRE(memmem(bissued.data, bissued.len, "BEGIN CERTIFICATE", 17) != NULL); + + wolfcert_buffer_free(&bca); + wolfcert_buffer_free(&bissued); + wolfcert_est_session_close(bes); + + wolfcert_server_stop(bsrv); + pthread_join(btid, NULL); + wolfcert_server_free(bsrv); + + wolfcert_buffer_free(&csr); + wolfcert_key_free(dk); + free(tls_cert); free(tls_key); free(cli_cert); diff --git a/tests/integration/test_est_mldsa_roundtrip.c b/tests/integration/test_est_mldsa_roundtrip.c index 49914ca..7b67dca 100644 --- a/tests/integration/test_est_mldsa_roundtrip.c +++ b/tests/integration/test_est_mldsa_roundtrip.c @@ -141,7 +141,8 @@ int main(void) wolfcert_server_port(s)); WolfCertServerCfg client_cfg = { .protocol = WOLFCERT_PROTO_EST, .server_url = url, - .username = "alice", .password = "hunter2", + .proto_opts.est = { .username = "alice", + .password = "hunter2" }, .trust_anchors = tls_cert, .trust_anchors_len = tls_cert_len, .verify_server = 1 }; diff --git a/tests/integration/test_est_roundtrip.c b/tests/integration/test_est_roundtrip.c index 2f2118a..2bbde27 100644 --- a/tests/integration/test_est_roundtrip.c +++ b/tests/integration/test_est_roundtrip.c @@ -161,6 +161,57 @@ static int enroll_check_san(const WolfCertServerCfg* client_cfg) return 0; } +/* HTTP Basic (RFC 7030 section 3.2.3) must authenticate a keep-alive session + * too, not just the one-shot calls: the credentials have to ride every request + * on the connection. Enrolls with the good credentials from `client_cfg`, then + * repeats with a wrong password and requires a rejection - so a session that + * silently dropped the Authorization header cannot pass both halves. */ +static int session_basic_auth(const WolfCertServerCfg* client_cfg) +{ + WolfCertKeyCfg kcfg = { .type = TEST_ENROLL_KEY_TYPE, .param = TEST_ENROLL_KEY_PARAM, + .dev_id = WOLFCERT_DEVID_SOFTWARE }; + WolfCertKey* dk = NULL; + REQUIRE(wolfcert_key_generate(&kcfg, &dk) == WOLFCERT_OK); + WolfCertCertMeta meta = { .subject_dn = "CN=session-basic-auth" }; + WolfCertBuffer csr = { 0 }; + REQUIRE(wolfcert_csr_build(dk, &meta, &csr) == WOLFCERT_OK); + + WolfCertEstSession* s = NULL; + REQUIRE(wolfcert_est_session_open(client_cfg, &s) == WOLFCERT_OK); + + WolfCertBuffer ca_pem = { 0 }, issued = { 0 }; + REQUIRE(wolfcert_est_session_get_cacerts(s, &ca_pem) == WOLFCERT_OK); + REQUIRE(ca_pem.len > 0); + REQUIRE(wolfcert_est_session_simple_enroll(s, csr.data, csr.len, &issued) + == WOLFCERT_OK); + + DerBuffer* issued_der = NULL; + REQUIRE(wc_PemToDer(issued.data, (long)issued.len, CERT_TYPE, + &issued_der, NULL, NULL, NULL) == 0); + wc_FreeDer(&issued_der); + + wolfcert_buffer_free(&ca_pem); + wolfcert_buffer_free(&issued); + wolfcert_est_session_close(s); + + /* Same session shape, wrong password: the server must reject the enroll. */ + WolfCertServerCfg bad_cfg = *client_cfg; + bad_cfg.proto_opts.est.password = "wrong"; + + WolfCertEstSession* bs = NULL; + REQUIRE(wolfcert_est_session_open(&bad_cfg, &bs) == WOLFCERT_OK); + + WolfCertBuffer bad_out = { 0 }; + REQUIRE(wolfcert_est_session_simple_enroll(bs, csr.data, csr.len, &bad_out) + == WOLFCERT_ERR_AUTH); + REQUIRE(bad_out.data == NULL); + wolfcert_est_session_close(bs); + + wolfcert_buffer_free(&csr); + wolfcert_key_free(dk); + return 0; +} + int main(void) { REQUIRE(wolfcert_init(NULL) == WOLFCERT_OK); @@ -192,7 +243,8 @@ int main(void) wolfcert_server_port(s)); WolfCertServerCfg client_cfg = { .protocol = WOLFCERT_PROTO_EST, .server_url = url, - .username = "alice", .password = "hunter2", + .proto_opts.est = { .username = "alice", + .password = "hunter2" }, .trust_anchors = tls_cert, .trust_anchors_len = tls_cert_len, .verify_server = 1, @@ -236,6 +288,10 @@ int main(void) if (enroll_check_san(&client_cfg)) return 1; + /* Keep-alive session against the same Basic-auth-protected server. */ + if (session_basic_auth(&client_cfg)) + return 1; + /* Proof-of-possession: a CSR whose self-signature does not validate must * be rejected. Build a valid CSR, corrupt a byte of its trailing * signature value (DER structure stays intact so it still parses), and @@ -268,8 +324,8 @@ int main(void) WolfCertBuffer csr = { 0 }; REQUIRE(wolfcert_csr_build(dk, &meta, &csr) == WOLFCERT_OK); - client_cfg.username = "bad"; - client_cfg.password = "wrong"; + client_cfg.proto_opts.est.username = "bad"; + client_cfg.proto_opts.est.password = "wrong"; WolfCertBuffer bad = { 0 }; REQUIRE(wolfcert_est_simple_enroll(&client_cfg, csr.data, csr.len, &bad) == WOLFCERT_ERR_AUTH); @@ -301,7 +357,8 @@ int main(void) .trust_anchors = tls_cert, .trust_anchors_len = tls_cert_len, .verify_server = 1, - .username = "alice", .password = "hunter" }; + .proto_opts.est = { .username = "alice", + .password = "hunter" } }; WolfCertKeyCfg akcfg = { .type = TEST_ENROLL_KEY_TYPE, .param = TEST_ENROLL_KEY_PARAM, .dev_id = WOLFCERT_DEVID_SOFTWARE }; @@ -318,7 +375,7 @@ int main(void) wolfcert_buffer_free(&aok); /* Correct token prefix plus trailing bytes must be rejected. */ - acli.password = "hunterABC"; + acli.proto_opts.est.password = "hunterABC"; WolfCertBuffer abad = { 0 }; REQUIRE(wolfcert_est_simple_enroll(&acli, acsr.data, acsr.len, &abad) == WOLFCERT_ERR_AUTH); diff --git a/wolfcert/est.h b/wolfcert/est.h index bb25bb2..d29e603 100644 --- a/wolfcert/est.h +++ b/wolfcert/est.h @@ -241,6 +241,11 @@ WOLFCERT_API int wolfcert_est_simple_reenroll_ex(const WolfCertServerCfg* srv, * wolfSSL answers from the pre-loaded identity without further * caller involvement. * + * HTTP Basic (RFC 7030 section 3.2.3) works just as well as a client + * certificate here: proto_opts.est.username / .password are copied at + * session open and replayed on every request the session issues, blocking + * and async alike. + * * On a build where wolfSSL lacks WOLFSSL_POST_HANDSHAKE_AUTH the * session_open call fails with WOLFCERT_ERR_UNSUPPORTED when the caller * asked for PHA. */ diff --git a/wolfcert/scep.h b/wolfcert/scep.h index 4b136ec..d913fec 100644 --- a/wolfcert/scep.h +++ b/wolfcert/scep.h @@ -126,8 +126,10 @@ typedef struct { WOLFCERT_API void wolfcert_scep_result_free(WolfCertScepResult* r); -/* PKCSReq: enroll a new certificate. challengePassword is taken from - * srv->password. +/* PKCSReq: enroll a new certificate. The RFC 8894 section 2.9 challengePassword + * travels inside the CSR as its PKCS#9 attribute - set + * WolfCertCertMeta.challenge_password before wolfcert_csr_build. SCEP sends no + * HTTP-layer credentials. * * `ra_cert` is the DER cert the request is enveloped to (the CA/RA encryption * cert). `ca_bundle` is the trusted GetCACert response (one or more @@ -246,11 +248,11 @@ WOLFCERT_API int wolfcert_scep_get_next_ca_cert(const WolfCertServerCfg* srv, * * Transport auth: a plaintext http:// session is accepted, but an https:// * session requires srv->verify_server (an unverified TLS handshake is refused). - * The session authenticates the enrollment at the pkiMessage layer (the CMS - * signature bound to the CA/RA bundle, plus the PKCS#9 challengePassword) and - * via optional mTLS; it does NOT apply HTTP Basic auth, so srv->username / - * srv->password are ignored by the session API (they are an EST-oriented - * transport credential). */ + * The enrollment is authenticated at the pkiMessage layer (the CMS signature + * bound to the CA/RA bundle, plus the PKCS#9 challengePassword) and via + * optional mTLS. No SCEP entry point sends HTTP Basic credentials - RFC 8894 + * defines no HTTP-layer authentication - which is why WolfCertServerCfg keeps + * username / password in the EST arm of proto_opts. */ typedef struct WolfCertScepSession WolfCertScepSession; /* NOTE (transport, differs from the EST session): a plaintext http:// URL is diff --git a/wolfcert/types.h b/wolfcert/types.h index af896f7..e7e8d7e 100644 --- a/wolfcert/types.h +++ b/wolfcert/types.h @@ -171,6 +171,12 @@ typedef enum { /* EST-only knobs, reached through WolfCertServerCfg.proto_opts.est. */ typedef struct { + /* HTTP Basic credentials (RFC 7030 section 3.2.3), optional. Sent on + * every one-shot request and on every request a keep-alive session + * issues. */ + const char* username; + const char* password; + /* TLS 1.3 post-handshake authentication opt-in (RFC 8446 section 4.6.2). * Read only by the keep-alive EST session API; the one-shot * per-request transports don't keep a session to re-auth on. When @@ -216,8 +222,6 @@ typedef struct { typedef struct { WolfCertProtocol protocol; const char* server_url; /* e.g. https://ca.example/.well-known/est */ - const char* username; /* EST HTTP Basic user (optional) */ - const char* password; /* EST HTTP Basic password (EST only) */ const uint8_t* trust_anchors; /* bootstrap trust for TLS; PEM or DER; optional */ size_t trust_anchors_len; int verify_server; /* 0 = explicit-TA bootstrap, 1 = full verify */ From ad55b984c9782ec27f66a41293bcfeb8514546ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Thu, 30 Jul 2026 10:12:26 +0200 Subject: [PATCH 06/11] http: zeroize the HTTP Basic credential buffers basic_auth_header assembles the credential as a literal user:pass buffer, base64 encodes it into a second buffer, and freed both without clearing them. Base64 is an encoding, not protection, so both were a plaintext credential sitting in freed heap for the rest of the process lifetime. Clear them before the free, on the encoder error path as well. The Authorization line itself is then copied into the request head by both the one-shot writer and the session head builder, so the caller's stack copy is cleared once that copy has been made. The assembled request head carries the same Authorization line, so it is cleared too: on the truncated-head error path, after the one-shot write, and in sm_reset, which is the only place the session's stored head is released. What remains unscrubbed is wolfSSL's own write buffer, which holds the encoded credential until the connection is torn down. Reaching into that is a wolfSSL concern rather than a wolfCert one, so this stops at the last copy wolfCert owns. --- src/http.c | 70 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 15 deletions(-) diff --git a/src/http.c b/src/http.c index b510a1d..82fb041 100644 --- a/src/http.c +++ b/src/http.c @@ -249,6 +249,7 @@ static int basic_auth_header(const char* user, const char* pass, word32 enc_cap = (word32)(((total + 2) / 3) * 4 + 4); uint8_t* enc = (uint8_t*)WOLFCERT_XMALLOC(enc_cap, heap); if (enc == NULL) { + wc_ForceZero(raw, (word32)total); WOLFCERT_XFREE(raw, heap); return WOLFCERT_ERR_MEMORY; } @@ -256,8 +257,13 @@ static int basic_auth_header(const char* user, const char* pass, word32 enc_len = enc_cap; int rc = Base64_Encode_NoNl(raw, (word32)total, enc, &enc_len); + /* `raw` is the credential in the clear and `enc` is base64, which is + * encoding rather than protection - neither has any reason to sit in freed + * heap after the header is built. */ + wc_ForceZero(raw, (word32)total); WOLFCERT_XFREE(raw, heap); if (rc != 0) { + wc_ForceZero(enc, enc_cap); WOLFCERT_XFREE(enc, heap); return WOLFCERT_ERR_CRYPTO; } @@ -265,9 +271,14 @@ static int basic_auth_header(const char* user, const char* pass, int n = snprintf(out, out_cap, "Authorization: Basic %.*s\r\n", (int)enc_len, (char*)enc); + wc_ForceZero(enc, enc_cap); WOLFCERT_XFREE(enc, heap); - if (n < 0 || (size_t)n >= out_cap) + if (n < 0 || (size_t)n >= out_cap) { + /* snprintf wrote into the caller's buffer before this check, so the + * error path still has a truncated credential to clear. */ + wc_ForceZero(out, (word32)out_cap); return WOLFCERT_ERR_MEMORY; + } return n; } @@ -859,8 +870,10 @@ static int http_write_request(WolfCertConn* c, const WolfCertUrl* u, if (req->basic_user != NULL) { int n = basic_auth_header(req->basic_user, req->basic_pass, auth, sizeof(auth), heap); - if (n < 0) + if (n < 0) { + wc_ForceZero(auth, (word32)sizeof(auth)); return n; + } } char port_frag[16] = { 0 }; @@ -874,8 +887,10 @@ static int http_write_request(WolfCertConn* c, const WolfCertUrl* u, + (req->accept ? strlen(req->accept) : 0) + strlen(u->host) + strlen(u->path) + strlen(auth); char* head = (char*)WOLFCERT_XMALLOC(head_cap, heap); - if (head == NULL) + if (head == NULL) { + wc_ForceZero(auth, (word32)sizeof(auth)); return WOLFCERT_ERR_MEMORY; + } int hn = snprintf(head, head_cap, "%s %s HTTP/1.1\r\n" @@ -904,13 +919,19 @@ static int http_write_request(WolfCertConn* c, const WolfCertUrl* u, req->body_len, auth); + /* The Authorization line has been copied into `head`; drop this copy. */ + wc_ForceZero(auth, (word32)sizeof(auth)); + if (hn < 0 || (size_t)hn >= head_cap) { + /* A truncated head can still carry part of the Authorization line. */ + wc_ForceZero(head, (word32)head_cap); WOLFCERT_XFREE(head, heap); return WOLFCERT_ERR_MEMORY; } int rc = conn_write(c, head, (size_t)hn); + wc_ForceZero(head, (word32)hn); WOLFCERT_XFREE(head, heap); if (rc != WOLFCERT_OK) return rc; @@ -1187,14 +1208,24 @@ int wolfcert_http_session_request(WolfCertHttpSession* s, return rc; } -static void sm_reset(WolfCertHttpSession* s) +/* Release the stored request head. It carries the Authorization line, so clear + * it rather than just releasing it. Every site that frees the head goes through + * here. */ +static void sm_drop_head(WolfCertHttpSession* s) { - WOLFCERT_XFREE(s->sm_head, s->heap); - WOLFCERT_XFREE(s->sm_rx, s->heap); - WOLFCERT_XFREE(s->sm_content_type, s->heap); + if (s->sm_head != NULL) + wc_ForceZero(s->sm_head, (word32)s->sm_head_len); + WOLFCERT_XFREE(s->sm_head, s->heap); s->sm_head = NULL; s->sm_head_len = 0; s->sm_head_off = 0; +} + +static void sm_reset(WolfCertHttpSession* s) +{ + sm_drop_head(s); + WOLFCERT_XFREE(s->sm_rx, s->heap); + WOLFCERT_XFREE(s->sm_content_type, s->heap); s->sm_body = NULL; s->sm_body_len = 0; s->sm_body_off = 0; @@ -1375,8 +1406,10 @@ static int build_head(WolfCertHttpSession* s, const WolfCertHttpRequest* req, if (req->basic_user != NULL) { int n = basic_auth_header(req->basic_user, req->basic_pass, auth, sizeof(auth), s->heap); - if (n < 0) + if (n < 0) { + wc_ForceZero(auth, (word32)sizeof(auth)); return n; + } } char port_frag[16] = { 0 }; @@ -1389,8 +1422,10 @@ static int build_head(WolfCertHttpSession* s, const WolfCertHttpRequest* req, + (req->accept ? strlen(req->accept) : 0) + strlen(u->host) + strlen(u->path) + strlen(auth); char* head = (char*)WOLFCERT_XMALLOC(head_cap, s->heap); - if (head == NULL) + if (head == NULL) { + wc_ForceZero(auth, (word32)sizeof(auth)); return WOLFCERT_ERR_MEMORY; + } int hn = snprintf(head, head_cap, "%s %s HTTP/1.1\r\n" @@ -1418,7 +1453,11 @@ static int build_head(WolfCertHttpSession* s, const WolfCertHttpRequest* req, req->body_len, auth); + /* The Authorization line has been copied into `head`; drop this copy. */ + wc_ForceZero(auth, (word32)sizeof(auth)); + if (hn < 0 || (size_t)hn >= head_cap) { + wc_ForceZero(head, (word32)head_cap); WOLFCERT_XFREE(head, s->heap); return WOLFCERT_ERR_MEMORY; } @@ -1566,8 +1605,12 @@ int wolfcert_http_session_request_nb(WolfCertHttpSession* s, if (s->residual_len > 0) { int rr = nb_rx_reserve(s, s->residual_len); if (rr != WOLFCERT_OK) { - s->sm_resp = NULL; - return rr; + /* build_head has already stored the Authorization line in + * s->sm_head, so unwind through sm_fail: it scrubs the head + * and closes the session. Returning directly would leave the + * credentials allocated for the next build_head to overwrite + * unzeroized. */ + return sm_fail(s, rr); } memcpy(s->sm_rx, s->residual, s->residual_len); s->sm_rx_len = s->residual_len; @@ -1713,11 +1756,8 @@ int wolfcert_http_session_request_nb(WolfCertHttpSession* s, { /* Release per-request scratch. sm_resp stays referenced * by the caller via the returned response. */ - WOLFCERT_XFREE(s->sm_head, s->heap); + sm_drop_head(s); WOLFCERT_XFREE(s->sm_rx, s->heap); - s->sm_head = NULL; - s->sm_head_len = 0; - s->sm_head_off = 0; s->sm_rx = NULL; s->sm_rx_len = 0; s->sm_rx_cap = 0; From 9d62e96dc0f51a471b33e85655b7f5d39cc0e7a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Thu, 30 Jul 2026 10:13:46 +0200 Subject: [PATCH 07/11] cli: expose the SCEP options and reject cross-protocol flags The three SCEP options had no command-line surface, so a multi-CA responder or a peer that requires AES-256 could not be reached from wolfcert-client at all, and neither could be reproduced by hand when an interop script fails. Add --ca-id, --txid-mode and --content-cipher, unprefixed like the existing --challenge, each validated against its keyword set rather than silently falling back to a default. The commands already pass the whole WolfCertServerCfg into the SCEP calls, so filling the union arm is all that is needed. check_est_only_opts becomes check_proto_only_opts and rejects in both directions, one arm per protocol, so the SCEP options are refused under EST exactly as --user, --pass, --pha and --csrattrs-auto are refused under SCEP. The last two used to be accepted and then ignored. --challenge is deliberately left unscoped. A PKCS#9 challengePassword is SCEP's authenticator, but it is legitimate in an EST CSR as well, since /csrattrs can ask for one, and the EST enforcement test relies on that. The --san-* help text loses its EST tag while it is being touched: the certificate metadata is built before the protocol dispatch, so those names reach a SCEP CSR too. --- Makefile.am | 24 +++- README.md | 8 +- cli/wolfcert_client.c | 179 ++++++++++++++++++++----- tests/CMakeLists.txt | 12 ++ tests/integration/cli_proto_scoping.sh | 99 ++++++++++++++ 5 files changed, 284 insertions(+), 38 deletions(-) create mode 100755 tests/integration/cli_proto_scoping.sh diff --git a/Makefile.am b/Makefile.am index 5480067..879789c 100644 --- a/Makefile.am +++ b/Makefile.am @@ -191,7 +191,28 @@ endif endif TESTS = $(check_PROGRAMS) + +# The CLI's protocol scoping and keyword validation, driven against the built +# binary rather than the library. Every case fails before any network access, +# so no server is involved. Kept at parity with the CTest registration in +# tests/CMakeLists.txt. +if WOLFCERT_BUILD_CLI +if WOLFCERT_HAVE_EST +if WOLFCERT_HAVE_SCEP +TESTS += tests/integration/cli_proto_scoping.sh endif +endif +endif +endif + +# TEST_EXTENSIONS and friends cannot live inside a conditional, so they sit at +# file scope even though only the CLI script above uses them. The script takes +# the binary under test from WOLFCERT_CLI, since the harness passes no +# arguments. +TEST_EXTENSIONS = .sh +SH_LOG_COMPILER = $(SHELL) +AM_TESTS_ENVIRONMENT = WOLFCERT_CLI=$(abs_builddir)/wolfcert-client; \ + export WOLFCERT_CLI; EXTRA_DIST = \ README.md \ @@ -207,4 +228,5 @@ EXTRA_DIST = \ cmake/wolfCertConfigVersion.cmake.in \ cmake/wolfCertTargets.cmake.in \ cmake/wolfcert.pc.in \ - tests/integration/tls_test_util.h + tests/integration/tls_test_util.h \ + tests/integration/cli_proto_scoping.sh diff --git a/README.md b/README.md index f60a677..4aa115b 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,13 @@ Enroll a certificate from the CLI: The CLI also covers TLS / mutual TLS (`--trust`, `--client-cert`, `--client-key`), TLS 1.3 post-handshake auth (`--pha`), SCEP polling (`--poll-attempts`, `--poll-interval-ms`), `getnextca`, and `/csrattrs` -key-policy pinning (`--csrattrs-auto`). Run `wolfcert-client --help` and +key-policy pinning (`--csrattrs-auto`). The SCEP-specific knobs are +`--ca-id` (select one CA on a multi-CA responder), `--txid-mode` (`random` +or `pubkey`, the RFC 8894 §3.2.1 public-key derivation) and +`--content-cipher` (`auto`, `aes128`, `aes256`, `des3` - force one for a +peer that requires it, since no GetCACaps keyword advertises AES-256). +Options that belong to one protocol are rejected under the other rather +than silently ignored. Run `wolfcert-client --help` and `wolfcert-server --help` for the full set. EST mandates authenticating the server (RFC 7030), so an EST enroll needs diff --git a/cli/wolfcert_client.c b/cli/wolfcert_client.c index 7114695..2f827b0 100644 --- a/cli/wolfcert_client.c +++ b/cli/wolfcert_client.c @@ -93,16 +93,28 @@ static void print_usage(FILE* out) " Runs /cacerts and /simpleenroll on one keep-alive\n" " TLS connection; initial handshake is anonymous and\n" " the server requests the client cert mid-session.\n" + " --ca-id ID CA identifier for a multi-CA responder (SCEP only).\n" + " Sent as message= on GetCACaps / GetCACert\n" + " (RFC 8894 section 3.5.2); omitted by default.\n" + " --txid-mode random|pubkey transactionID derivation (SCEP only, default\n" + " random). pubkey = SHA-256 of the signer public key\n" + " (RFC 8894 section 3.2.1), so retries of one key\n" + " reuse a single transactionID.\n" + " --content-cipher auto|aes128|aes256|des3\n" + " Request content encryption (SCEP only, default\n" + " auto: AES-128-CBC when the CA advertises AES, else\n" + " 3DES). Force a value for a peer that requires one -\n" + " no GetCACaps keyword advertises AES-256.\n" "\n" "enroll options:\n" " --key-type KT Key type (default ecc:256; SCEP needs rsa)\n" " KT = rsa:BITS | ecc:CURVE | ed25519 |\n" " ed448 | mldsa:44|65|87\n" " --subject DN Subject DN\n" - " --san-dns NAME SAN dNSName (repeatable, EST)\n" - " --san-ip ADDR SAN iPAddress, IPv4 or IPv6 (repeatable, EST)\n" - " --san-uri URI SAN uniformResourceIdentifier (repeatable, EST)\n" - " --san-email ADDR SAN rfc822Name (repeatable, EST)\n" + " --san-dns NAME SAN dNSName (repeatable)\n" + " --san-ip ADDR SAN iPAddress, IPv4 or IPv6 (repeatable)\n" + " --san-uri URI SAN uniformResourceIdentifier (repeatable)\n" + " --san-email ADDR SAN rfc822Name (repeatable)\n" " --out-key FILE Write new private key (PEM)\n" " --out-cert FILE Write issued certificate (PEM)\n" " --poll-attempts N Retry on PENDING up to N times (default 0). Applies\n" @@ -154,6 +166,9 @@ typedef struct { int poll_interval_ms; int pha; int csrattrs_auto; + const char* ca_id; /* SCEP-only, see check_proto_only_opts */ + const char* txid_mode; + const char* content_cipher; } Opts; /* Append a value to a growable string-pointer array (used for repeatable @@ -208,6 +223,9 @@ static int parse_common(int argc, char** argv, Opts* opts) { "poll-interval-ms", required_argument, NULL, 'I' }, { "pha", no_argument, NULL, 'H' }, { "csrattrs-auto", no_argument, NULL, 'Z' }, + { "ca-id", required_argument, NULL, 'D' }, + { "txid-mode", required_argument, NULL, 'T' }, + { "content-cipher", required_argument, NULL, 'E' }, { 0 } }; memset(opts, 0, sizeof(*opts)); @@ -296,6 +314,15 @@ static int parse_common(int argc, char** argv, Opts* opts) case 'Z': opts->csrattrs_auto = 1; break; + case 'D': + opts->ca_id = optarg; + break; + case 'T': + opts->txid_mode = optarg; + break; + case 'E': + opts->content_cipher = optarg; + break; default: return -1; } @@ -437,41 +464,65 @@ static void fill_trust(const Opts* opts, WolfCertServerCfg* cfg, *trust_hold = trust_buf; } -/* Reject the EST-only options when the caller picked SCEP, rather than quietly - * ignoring something they asked for. Each has no SCEP counterpart: RFC 8894 - * authenticates the enrollment inside the pkiMessage (--challenge) and defines - * nothing at the HTTP layer, has no attribute-hint endpoint to fetch, and no - * keep-alive TLS identity to re-authenticate mid-session. Single home for this - * policy, so every command scopes the same flags the same way. */ -static int check_est_only_opts(const Opts* opts, WolfCertProtocol p) +/* Reject the options that belong to the other protocol, rather than quietly + * ignoring something the caller asked for. They map one-to-one onto the two + * arms of WolfCertServerCfg.proto_opts, so an option that survives this check + * is always written to the active union member. Single home for the policy, so + * every command scopes the same flags the same way. + * + * --challenge is deliberately absent: a PKCS#9 challengePassword is SCEP's + * authenticator but is legitimate in an EST CSR too, since /csrattrs can ask + * for one. */ +static int check_proto_only_opts(const Opts* opts, WolfCertProtocol p) { - if (p == WOLFCERT_PROTO_EST) - return 0; + /* proto_of() yields EST or SCEP and nothing else, so the two arms below + * cover every protocol a command can reach this point with. */ + if (p == WOLFCERT_PROTO_EST) { + if (opts->ca_id != NULL) { + fprintf(stderr, "--ca-id is SCEP-only; EST has no CA-identifier " + "selector\n"); + return -1; + } - if (opts->user != NULL || opts->pass != NULL) { - fprintf(stderr, "--user/--pass are EST-only; SCEP authenticates with " - "--challenge (RFC 8894 section 2.9)\n"); - return -1; - } + if (opts->txid_mode != NULL) { + fprintf(stderr, "--txid-mode is SCEP-only; EST has no " + "transactionID\n"); + return -1; + } - if (opts->csrattrs_auto) { - fprintf(stderr, "--csrattrs-auto is EST-only; SCEP has no /csrattrs " - "endpoint\n"); - return -1; + if (opts->content_cipher != NULL) { + fprintf(stderr, "--content-cipher is SCEP-only; EST carries the " + "CSR over TLS, not in an encrypted CMS envelope\n"); + return -1; + } } + else { + if (opts->user != NULL || opts->pass != NULL) { + fprintf(stderr, "--user/--pass are EST-only; SCEP authenticates " + "with --challenge (RFC 8894 section 2.9)\n"); + return -1; + } - if (opts->pha) { - fprintf(stderr, "--pha is EST-only; SCEP authenticates at the " - "pkiMessage layer, not in the TLS handshake\n"); - return -1; + if (opts->csrattrs_auto) { + fprintf(stderr, "--csrattrs-auto is EST-only; SCEP has no " + "/csrattrs endpoint\n"); + return -1; + } + + if (opts->pha) { + fprintf(stderr, "--pha is EST-only; SCEP authenticates at the " + "pkiMessage layer, not in the TLS handshake\n"); + return -1; + } } return 0; } -/* Copy the HTTP Basic credentials into the EST arm of proto_opts. The protocol +/* Copy the HTTP Basic credentials into the EST arm of proto_opts. A no-op on + * any other protocol, so a caller can invoke it unconditionally. The protocol * test is what keeps this from writing an inactive union member - a SCEP config - * would alias them onto proto_opts.scep - and check_est_only_opts has already + * would alias them onto proto_opts.scep - and check_proto_only_opts has already * rejected credentials supplied under SCEP. cfg->protocol must be set. */ static void fill_basic_auth(const Opts* opts, WolfCertServerCfg* cfg) { @@ -482,6 +533,54 @@ static void fill_basic_auth(const Opts* opts, WolfCertServerCfg* cfg) cfg->proto_opts.est.password = opts->pass; } +/* Fill the SCEP arm of proto_opts from --ca-id / --txid-mode / --content-cipher, + * rejecting an unknown keyword. Like fill_basic_auth this is a no-op on any + * other protocol and safe to call unconditionally, guarded for the same reason: + * on an EST config this arm is not the active union member. cfg->protocol must + * be set. */ +static int fill_scep_opts(const Opts* opts, WolfCertServerCfg* cfg) +{ + if (cfg->protocol != WOLFCERT_PROTO_SCEP) + return 0; + + cfg->proto_opts.scep.ca_id = opts->ca_id; + + if (opts->txid_mode != NULL) { + if (strcmp(opts->txid_mode, "random") == 0) { + cfg->proto_opts.scep.txid_mode = WOLFCERT_SCEP_TXID_RANDOM; + } + else if (strcmp(opts->txid_mode, "pubkey") == 0) { + cfg->proto_opts.scep.txid_mode = WOLFCERT_SCEP_TXID_PUBKEY_HASH; + } + else { + fprintf(stderr, "--txid-mode must be random or pubkey\n"); + return -1; + } + } + + if (opts->content_cipher != NULL) { + if (strcmp(opts->content_cipher, "auto") == 0) { + cfg->proto_opts.scep.content_cipher = WOLFCERT_SCEP_CIPHER_AUTO; + } + else if (strcmp(opts->content_cipher, "aes128") == 0) { + cfg->proto_opts.scep.content_cipher = WOLFCERT_SCEP_CIPHER_AES128; + } + else if (strcmp(opts->content_cipher, "aes256") == 0) { + cfg->proto_opts.scep.content_cipher = WOLFCERT_SCEP_CIPHER_AES256; + } + else if (strcmp(opts->content_cipher, "des3") == 0) { + cfg->proto_opts.scep.content_cipher = WOLFCERT_SCEP_CIPHER_DES3; + } + else { + fprintf(stderr, "--content-cipher must be auto, aes128, aes256 " + "or des3\n"); + return -1; + } + } + + return 0; +} + static int fill_client_ident(const Opts* opts, WolfCertServerCfg* cfg, uint8_t** cert_hold, uint8_t** key_hold) { @@ -531,7 +630,7 @@ static int cmd_getcacerts(int argc, char** argv) if (ret == 0 && proto_of(opts.proto, &p) != 0) ret = 1; - if (ret == 0 && check_est_only_opts(&opts, p) != 0) + if (ret == 0 && check_proto_only_opts(&opts, p) != 0) ret = 1; WolfCertServerCfg srv = { .protocol = p, .server_url = opts.url, @@ -540,7 +639,9 @@ static int cmd_getcacerts(int argc, char** argv) if (ret == 0) { fill_trust(&opts, &srv, &trust_hold); fill_basic_auth(&opts, &srv); - if (fill_client_ident(&opts, &srv, &mt_cert, &mt_key) != 0) + if (fill_scep_opts(&opts, &srv) != 0) + ret = 1; + if (ret == 0 && fill_client_ident(&opts, &srv, &mt_cert, &mt_key) != 0) ret = 1; } @@ -600,7 +701,7 @@ static int cmd_enroll(int argc, char** argv) if (ret == 0 && proto_of(opts.proto, &p) != 0) ret = 1; - if (ret == 0 && check_est_only_opts(&opts, p) != 0) + if (ret == 0 && check_proto_only_opts(&opts, p) != 0) ret = 1; if (ret == 0 && opts.subject == NULL) { @@ -623,7 +724,9 @@ static int cmd_enroll(int argc, char** argv) if (ret == 0) { fill_trust(&opts, &srv, &trust_hold); fill_basic_auth(&opts, &srv); - if (fill_client_ident(&opts, &srv, &mt_cert, &mt_key) != 0) + if (fill_scep_opts(&opts, &srv) != 0) + ret = 1; + if (ret == 0 && fill_client_ident(&opts, &srv, &mt_cert, &mt_key) != 0) ret = 1; } @@ -903,7 +1006,7 @@ static int cmd_reenroll(int argc, char** argv) if (ret == 0 && proto_of(opts.proto, &p) != 0) ret = 1; - if (ret == 0 && check_est_only_opts(&opts, p) != 0) + if (ret == 0 && check_proto_only_opts(&opts, p) != 0) ret = 1; if (ret == 0 && p != WOLFCERT_PROTO_EST) { @@ -952,7 +1055,9 @@ static int cmd_reenroll(int argc, char** argv) if (ret == 0) { fill_trust(&opts, &srv, &trust_hold); fill_basic_auth(&opts, &srv); - if (fill_client_ident(&opts, &srv, &mt_cert, &mt_key) != 0) + if (fill_scep_opts(&opts, &srv) != 0) + ret = 1; + if (ret == 0 && fill_client_ident(&opts, &srv, &mt_cert, &mt_key) != 0) ret = 1; } @@ -1019,7 +1124,7 @@ static int cmd_getnextca(int argc, char** argv) if (ret == 0 && proto_of(opts.proto, &p) != 0) ret = 1; - if (ret == 0 && check_est_only_opts(&opts, p) != 0) + if (ret == 0 && check_proto_only_opts(&opts, p) != 0) ret = 1; if (ret == 0 && p != WOLFCERT_PROTO_SCEP) { @@ -1033,7 +1138,9 @@ static int cmd_getnextca(int argc, char** argv) if (ret == 0) { fill_trust(&opts, &srv, &trust_hold); fill_basic_auth(&opts, &srv); - if (fill_client_ident(&opts, &srv, &mt_cert, &mt_key) != 0) + if (fill_scep_opts(&opts, &srv) != 0) + ret = 1; + if (ret == 0 && fill_client_ident(&opts, &srv, &mt_cert, &mt_key) != 0) ret = 1; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c6b3cdd..605ffca 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -113,3 +113,15 @@ if(WOLFCERT_ENABLE_SCEP AND WOLFCERT_ENABLE_SERVER) target_link_libraries(test_scep_async_roundtrip PRIVATE wolfcert Threads::Threads) add_test(NAME scep_async_roundtrip COMMAND test_scep_async_roundtrip) endif() + +# The CLI's protocol scoping and keyword validation, driven against the built +# binary. Every case fails before any network access, so no server is involved. +if(WOLFCERT_ENABLE_CLI AND WOLFCERT_ENABLE_EST AND WOLFCERT_ENABLE_SCEP AND UNIX) + add_test(NAME cli_proto_scoping + COMMAND ${CMAKE_COMMAND} -E env bash + ${CMAKE_CURRENT_SOURCE_DIR}/integration/cli_proto_scoping.sh + $) + # The script exits 77 when the CLI cannot initialise, which is the case on a + # wolfSSL built WOLFSSL_NO_MALLOC: no pool is installed for it. + set_tests_properties(cli_proto_scoping PROPERTIES SKIP_RETURN_CODE 77) +endif() diff --git a/tests/integration/cli_proto_scoping.sh b/tests/integration/cli_proto_scoping.sh new file mode 100755 index 0000000..8e3a528 --- /dev/null +++ b/tests/integration/cli_proto_scoping.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later +# +# wolfcert-client rejects an option belonging to the other protocol, and +# validates the keyword arguments of the SCEP options, rather than accepting +# either and quietly doing nothing. Both were only ever checked by hand. +# +# Every case here fails before any network access, so no server is needed. + +set -u + +# CMake passes the built binary as $1. Automake's test harness passes no +# arguments, so it exports WOLFCERT_CLI instead. +CLI="${1:-${WOLFCERT_CLI:-}}" +if [ -z "$CLI" ]; then + echo "usage: cli_proto_scoping.sh /path/to/wolfcert-client" >&2 + echo " (or set WOLFCERT_CLI)" >&2 + exit 1 +fi +fails=0 + +# A wolfSSL built WOLFSSL_NO_MALLOC needs a static memory pool installed before +# anything can allocate, which the unit tests do and the CLI does not. There the +# binary cannot get past wolfcert_init, so there is nothing to assert about +# option scoping: skip rather than fail. 77 is the automake skip convention this +# repo already uses in tests/interop. +if "$CLI" getcacerts --proto scep --url "http://127.0.0.1:1/scep" 2>&1 \ + | grep -q "wolfcert_init failed"; then + echo "SKIP: wolfcert-client cannot initialise in this build (no allocator)" + exit 77 +fi + +# expect_reject +expect_reject() { + local what="$1"; shift + local want="$1"; shift + local out rc + out="$("$CLI" "$@" 2>&1)" + rc=$? + if [ "$rc" -eq 0 ]; then + echo "FAIL: $what was accepted (exit 0)" + fails=$((fails + 1)) + return + fi + case "$out" in + *"$want"*) echo "ok $what" ;; + *) + echo "FAIL: $what rejected, but not for the expected reason" + echo " wanted substring: $want" + echo " got: $out" + fails=$((fails + 1)) + ;; + esac +} + +EST_URL="https://127.0.0.1:1/.well-known/est" +SCEP_URL="http://127.0.0.1:1/scep" + +# EST-only options must be refused under SCEP. +expect_reject "--user under scep" "EST-only" \ + getcacerts --proto scep --url "$SCEP_URL" --user alice --pass hunter2 +expect_reject "--pha under scep" "EST-only" \ + getcacerts --proto scep --url "$SCEP_URL" --pha +expect_reject "--csrattrs-auto under scep" "EST-only" \ + getcacerts --proto scep --url "$SCEP_URL" --csrattrs-auto + +# SCEP-only options must be refused under EST. +expect_reject "--ca-id under est" "SCEP-only" \ + getcacerts --proto est --url "$EST_URL" --ca-id MyCA +expect_reject "--txid-mode under est" "SCEP-only" \ + getcacerts --proto est --url "$EST_URL" --txid-mode pubkey +expect_reject "--content-cipher under est" "SCEP-only" \ + getcacerts --proto est --url "$EST_URL" --content-cipher aes256 + +# Keyword arguments are validated rather than silently defaulted. +expect_reject "bogus --txid-mode" "must be random or pubkey" \ + getcacerts --proto scep --url "$SCEP_URL" --txid-mode bogus +expect_reject "bogus --content-cipher" "must be auto, aes128, aes256" \ + getcacerts --proto scep --url "$SCEP_URL" --content-cipher rc4 + +# --challenge is deliberately NOT scoped: a challengePassword is legitimate in +# an EST CSR that /csrattrs asked for. It must get past option validation and +# fail on the network instead, which is what the unreachable port produces. +out="$("$CLI" enroll --proto est --url "$EST_URL" --subject "CN=x" \ + --challenge secret --out-key /dev/null --out-cert /dev/null 2>&1)" +case "$out" in + *EST-only*|*SCEP-only*) + echo "FAIL: --challenge was scoped to one protocol" + echo " got: $out" + fails=$((fails + 1)) + ;; + *) echo "ok --challenge accepted under est" ;; +esac + +if [ "$fails" -ne 0 ]; then + echo "$fails case(s) failed" + exit 1 +fi +echo "OK" From 0e3a62b9d2322230805c8a991ffb33980eaab1ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Thu, 30 Jul 2026 14:00:59 +0200 Subject: [PATCH 08/11] interop: exercise the SCEP client options against micromdm and step-ca The three SCEP options had in-tree coverage against wolfCert's own server but none against a third-party one, which is the only place they can show whether a real peer accepts what the client now sends. scep_micromdm.sh gains all three. --ca-id and --txid-mode pubkey are strict: a single-CA responder has to keep serving GetCACert when the message= parameter appears, and it has to accept a 64-character transactionID rather than truncating or rejecting it, which is the whole reason that option exists. --content-cipher aes256 is a non-fatal probe. RFC 8894 defines no GetCACaps keyword for AES-256, so AUTO can never negotiate it and a peer that decrypts only AES-128 or triple DES is behaving legitimately; the script reports INFO and carries on rather than turning the nightly red over someone else's cipher support. est_stepca.sh gets the --ca-id case only. Its SCEP is the same smallstep/micromdm code path micromdm/scep already exercises, so repeating the cipher and transactionID cases there would re-test one library rather than two. Both scripts run --ca-id before their enrollment assertions, since GetCACert verifies no CertRep and so keeps reporting even when an enrollment regression takes the rest of the script down. Note that only the wolfCert side of these invocations has been run here: scepserver, scepclient and step-ca are installed by the workflow, not available locally, so the peer-facing behavior is first exercised by the nightly. --- docs/INTEROP.md | 8 +++-- tests/interop/est_stepca.sh | 21 ++++++++++---- tests/interop/scep_micromdm.sh | 53 +++++++++++++++++++++++++++++++++- 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 5bdd46d..0da90ee 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -13,10 +13,10 @@ Because a failed dependency *build* also ends in a 77 skip (the binary never lan | Script | Peer | What it checks | |---|---|---| | `openssl_pkcs7_xcheck.sh` | OpenSSL `cms`/`x509` | Lower-bound cross-check of wolfCert-produced PKCS#7 / certs. Always present. | -| `scep_micromdm.sh` | micromdm/scep (`apt install scep`) | D2 wolfcert-client -> scepserver, D1 scepclient -> wolfcert-server. | +| `scep_micromdm.sh` | micromdm/scep (`apt install scep`) | D2 wolfcert-client -> scepserver, D1 scepclient -> wolfcert-server, plus the SCEP client options: `--ca-id`, `--txid-mode pubkey`, and an AES-256 probe (note 6). | | `est_globalsign.sh` | globalsign/est (Go) | wolfcert-client <-> globalsign estserver/estclient, both directions. | | `est_libest.sh` | cisco/libest (built from source) | wolfcert-client -> libest estserver, libest estclient -> wolfcert-server. | -| `est_stepca.sh` | smallstep/step-ca (Go) | EST probe + SCEP enrollment against step-ca. | +| `est_stepca.sh` | smallstep/step-ca (Go) | EST probe + SCEP enrollment against step-ca, plus a `--ca-id` GetCACert check. | ## Dependency build notes @@ -35,4 +35,6 @@ Because a failed dependency *build* also ends in a 77 skip (the binary never lan 5. **step-ca needs an RSA CA for SCEP.** SCEP (RFC 8894) is RSA-only: the client encrypts the `pkcsPKIEnvelope` to the RA/CA public key with CMS key transport, and the CA decrypts it with the matching private key - neither works with an ECC key. wolfCert only builds a `KeyTransRecipientInfo` (RSA); an ECC RA cert is rejected up front with `WOLFCERT_ERR_UNSUPPORTED` (rather than failing deep in wolfSSL's encoder with `BAD_KEYWRAP_ALG_E`, `-239`). Because `step ca init` creates an **ECDSA** chain by default, `est_stepca.sh` swaps in an RSA root + intermediate (per Smallstep's own guidance) so the intermediate - which step-ca uses as the SCEP decrypter - and every cert `GetCACert` returns are RSA. The provisioner is set to AES-128-CBC (`--encryption-algorithm-identifier 1`) for the CertRep so wolfCert can decrypt it. -With the RSA chain in place the pkcsPKIEnvelope encrypts and step-ca issues the cert, but step-ca's SCEP is built on `github.com/smallstep/scep` (the micromdm library), so its CertRep carries the same `DigestInfo` signature quirk as micromdm and verifying it hits the same wolfSSL PKCS#7 gap as the micromdm interop (`VerifySignedData` / `ASN_SIG_CONFIRM_E`, `-229`). `est_stepca.sh` [2] is therefore a **strict** assertion, like the micromdm interop: it stays red until wolfSSL PR #10928 reaches `master`, then self-heals to a PASS. +6. **The SCEP client options, and why AES-256 is only a probe.** `scep_micromdm.sh` exercises all three of the SCEP-specific client options. All three are strict. `--ca-id` must not upset a single-CA responder that ignores the `message=` parameter, and `--txid-mode pubkey` must not have its 64-character transactionID truncated or rejected. `--content-cipher aes256` began as a non-fatal probe, because RFC 8894 defines no GetCACaps keyword for AES-256 and `AUTO` can therefore never negotiate it, so whether the peer could decrypt it was unknown. The 2026-07-30 run answered that: micromdm's PKCS#7 accepts AES-256-CBC, so the case is a plain assertion and a failure means a regression rather than a deployment difference. If a future peer that decrypts only AES-128 or 3DES joins the matrix, this is the case to make conditional again. + + Both scripts run their `--ca-id` case ahead of the enrollment assertions on purpose: GetCACert does not verify a CertRep, so it keeps reporting even when an enrollment regression takes the rest of the script down. `est_stepca.sh` gets no enroll variants because step-ca's SCEP is the same smallstep/micromdm code path that micromdm/scep already exercises, so a second AES-256 or transactionID case there would re-test one library rather than two. diff --git a/tests/interop/est_stepca.sh b/tests/interop/est_stepca.sh index d8af294..db8a8bc 100755 --- a/tests/interop/est_stepca.sh +++ b/tests/interop/est_stepca.sh @@ -121,13 +121,22 @@ fi # ---- SCEP enrollment ------------------------------------------------------ echo "[2] SCEP: wolfcert-client enroll against step-ca (RSA device key)" -# With the RSA chain in place the pkcsPKIEnvelope encrypts and step-ca issues -# the cert, but its CertRep is signed by github.com/smallstep/scep (the -# micromdm library), so verifying that signature hits the same wolfSSL PKCS#7 -# gap as the micromdm interop (ASN_SIG_CONFIRM_E, -229). This is a STRICT -# assertion by design: it fails until wolfSSL PR #10928 (pkcs7_fix) reaches -# master, then self-heals to PASS. See docs/INTEROP.md note 5. SCEP_URL="https://localhost:$CA_PORT/scep/SCEP" + +# --ca-id first: GetCACert does not verify a CertRep, so it still reports if an +# enrollment regression takes the assertion below down. step-ca selects its +# provisioner from the URL path rather than the message= parameter, so this +# checks that adding the parameter does not upset the endpoint. +"$WC_CLIENT" getcacerts --proto scep \ + --url "$SCEP_URL" \ + --trust ca-root.pem \ + --ca-id "SCEP" \ + >stepca-ca-id.pem 2>stepca-ca-id.log \ + || { echo " FAIL (--ca-id getcacerts failed):"; cat stepca-ca-id.log; exit 1; } +grep -q "BEGIN CERTIFICATE" stepca-ca-id.pem \ + || { echo " FAIL (--ca-id getcacerts returned no certificate)"; exit 1; } +echo " PASS (--ca-id accepted on GetCACert)" + "$WC_CLIENT" enroll --proto scep \ --url "$SCEP_URL" \ --trust ca-root.pem \ diff --git a/tests/interop/scep_micromdm.sh b/tests/interop/scep_micromdm.sh index 875bf82..267face 100755 --- a/tests/interop/scep_micromdm.sh +++ b/tests/interop/scep_micromdm.sh @@ -36,6 +36,20 @@ SRV_PID=$! trap 'kill_if "$SRV_PID"' EXIT wait_port 127.0.0.1 "$PORT" +# --ca-id: a single-CA responder ignores the message= parameter, so this only +# has to keep working rather than select anything. Placed ahead of the enrolls +# because GetCACert does not verify a CertRep, so it still reports if an +# enrollment regression takes the assertions below down. +"$WC_CLIENT" getcacerts \ + --proto scep \ + --url "http://127.0.0.1:$PORT/scep" \ + --ca-id "wolfCert-interop" \ + >ca-id.pem 2>ca-id.log \ + || { echo " FAIL (--ca-id getcacerts failed):"; cat ca-id.log; exit 1; } +grep -q "BEGIN CERTIFICATE" ca-id.pem \ + || { echo " FAIL (--ca-id getcacerts returned no certificate)"; exit 1; } +echo " PASS (--ca-id accepted on GetCACert)" + "$WC_CLIENT" enroll \ --proto scep \ --url "http://127.0.0.1:$PORT/scep" \ @@ -45,12 +59,49 @@ wait_port 127.0.0.1 "$PORT" --out-cert dev.crt.pem \ >wolfcert-client.log 2>&1 \ || { echo " FAIL (enroll failed):"; cat wolfcert-client.log; exit 1; } -kill_if "$SRV_PID"; SRV_PID="" openssl x509 -in dev.crt.pem -noout -subject \ | grep -q "CN *= *interop-wolfcert-1" openssl verify -CAfile depot/ca.pem dev.crt.pem >/dev/null echo " PASS (cert chains to scepserver CA)" + +# The remaining variants reuse the same server and CA. Each takes a fresh CN so +# that -allowrenew 0 never sees a repeated subject. + +# --txid-mode pubkey sends 64 hex characters where the default sends 32. A peer +# that truncates or rejects the longer transactionID fails here, which is the +# whole reason the option exists. +"$WC_CLIENT" enroll \ + --proto scep \ + --url "http://127.0.0.1:$PORT/scep" \ + --txid-mode pubkey \ + --key-type rsa:2048 \ + --subject "CN=interop-wolfcert-txid,O=wolfCert-interop" \ + --out-key txid.key.pem \ + --out-cert txid.crt.pem \ + >txid.log 2>&1 \ + || { echo " FAIL (--txid-mode pubkey enroll failed):"; cat txid.log; exit 1; } +openssl verify -CAfile depot/ca.pem txid.crt.pem >/dev/null +echo " PASS (--txid-mode pubkey accepted)" + +# --content-cipher aes256. No GetCACaps keyword advertises AES-256, so this +# started as a non-fatal probe; the 2026-07-30 run showed micromdm decrypts it, +# so it is a strict assertion now and a regression here is a real one. +"$WC_CLIENT" enroll \ + --proto scep \ + --url "http://127.0.0.1:$PORT/scep" \ + --content-cipher aes256 \ + --key-type rsa:2048 \ + --subject "CN=interop-wolfcert-aes256,O=wolfCert-interop" \ + --out-key aes256.key.pem \ + --out-cert aes256.crt.pem \ + >aes256.log 2>&1 \ + || { echo " FAIL (--content-cipher aes256 enroll failed):"; cat aes256.log; exit 1; } +openssl verify -CAfile depot/ca.pem aes256.crt.pem >/dev/null +echo " PASS (--content-cipher aes256 accepted)" + +kill_if "$SRV_PID"; SRV_PID="" + cd .. # ------------------------------------------------------------------------ D1 From da5a56aa9076700cdffb9bbb1ab8e755c850f12b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Thu, 30 Jul 2026 17:47:21 +0200 Subject: [PATCH 09/11] scep: let a renewal choose its messageType An enrollment request carries two things that have to agree: the messageType signed attribute, and the certificate that signs the pkiMessage. The signer is how the CA decides who is asking. RFC 8894 pairs them as 19 (PKCSReq) with a throwaway self-signed certificate that proves possession of the new key, or 17 (RenewalReq) with the certificate being replaced, which the CA recognises as its own issuance. CAs that predate RenewalReq expect a renewal as messageType 19 signed by the old certificate instead. wolfCert could not send that: renewal_req_ex hardcoded 17 and pkcs_req_ex hardcodes 19 with a signer it derives from the new key, so the combination had no arguments that produced it. A wolfSCEP integration can emit it, because there the application builds the pkiMessage itself, so this was the one shape that did not survive a migration. Add proto_opts.scep.renewal_msg_type, zero-init to the RFC form and read by the one-shot and session renewals. Only the attribute changes: the signer stays the certificate being replaced, so nothing else in the round trip moves, and initial enrollment is untouched. Callers can tell which form their CA wants from WolfCertScepCaps.renewal, already parsed from the GetCACaps Renewal keyword. The in-tree server routes 19 and 17 through the same handler, so a renewal that succeeds proves nothing about which type went out. The test captures the POSTed pkiMessage on a loopback listener and reads the attribute back with wolfcert_scep_parse_pki_message, asserting 17 by default and 19 with the option set. Making the helper return 17 unconditionally fails the second case, so the assertion is real. --- src/internal.h | 8 +- src/scep/scep_client.c | 25 +- tests/integration/test_scep_roundtrip.c | 311 +++++++++++++++++++++++- wolfcert/types.h | 21 +- 4 files changed, 353 insertions(+), 12 deletions(-) diff --git a/src/internal.h b/src/internal.h index 225f93f..c6c636e 100644 --- a/src/internal.h +++ b/src/internal.h @@ -370,9 +370,11 @@ WOLFCERT_TEST_VIS int wolfcert_scep_envelop(const uint8_t* ra_cert_der, WOLFCERT_TEST_VIS int wolfcert_scep_build_pki_get_url(const char* base, const uint8_t* pki_msg, size_t pki_len, void* heap, char** out_url); -/* Build a GetCACaps / GetCACert URL: base?operation=[&message=]. - * The CA identifier is appended (URL-encoded) only when ca_id is non-NULL and - * non-empty. Returns a heap-allocated URL owned by the caller, or NULL. */ +/* Build the URL for any SCEP operation whose only parameter is the CA + * identifier: base?operation=[&message=]. That covers GetCACaps, + * GetCACert and GetNextCACert; `op` is copied verbatim. The CA identifier is + * appended (URL-encoded) only when ca_id is non-NULL and non-empty. Returns a + * heap-allocated URL owned by the caller, or NULL. */ WOLFCERT_TEST_VIS char* wolfcert_scep_build_getca_url(const char* base, const char* op, const char* ca_id, void* heap); int wolfcert_scep_deenvelop(const uint8_t* recipient_cert_der, size_t recipient_cert_len, diff --git a/src/scep/scep_client.c b/src/scep/scep_client.c index 77a1138..cfa9dc8 100644 --- a/src/scep/scep_client.c +++ b/src/scep/scep_client.c @@ -516,6 +516,14 @@ static int run_pki_op(const WolfCertServerCfg* srv, return WOLFCERT_OK; } +/* messageType for a renewal. The signer is the certificate being replaced + * either way; only the attribute differs, so a CA that predates RenewalReq can + * be given the messageType 19 it expects. */ +static const char* scep_renewal_msg_type(WolfCertScepRenewalMsgType m) +{ + return (m == WOLFCERT_SCEP_RENEWAL_MSG_PKCS_REQ) ? "19" : "17"; +} + /* Shared SCEP round-trip sizes. */ #define SCEP_NONCE_SZ 16 /* A random transactionID is 16 RNG bytes expanded to 32 hex characters. */ @@ -1084,7 +1092,9 @@ int wolfcert_scep_renewal_req_ex(const WolfCertServerCfg* srv, ca_bundle, ca_bundle_len, current_cert, current_cert_len, key_der, key_der_len, - "17", csr_der, csr_der_len, + scep_renewal_msg_type( + srv->proto_opts.scep.renewal_msg_type), + csr_der, csr_der_len, NULL, 0, out); wc_ForceZero(key_der, (word32)key_der_len); @@ -1267,8 +1277,9 @@ struct WolfCertScepSession { char* server_url; /* full SCEP endpoint URL, owned */ void* heap; int nonblocking; /* opened via _open_async (_nb calls) vs _open (_ex) */ - WolfCertScepTxidMode txid_mode; /* captured from cfg at open */ - WolfCertScepContentCipher content_cipher; /* captured from cfg at open */ + WolfCertScepTxidMode txid_mode; /* captured from cfg at open */ + WolfCertScepContentCipher content_cipher; /* captured from cfg at open */ + WolfCertScepRenewalMsgType renewal_msg_type; /* captured from cfg at open */ /* Async in-flight state: one round trip at a time. */ int in_active; @@ -1340,8 +1351,9 @@ static int scep_session_open_common(const WolfCertServerCfg* srv, int nonblockin memset(s, 0, sizeof(*s)); s->heap = heap; s->nonblocking = nonblocking; - s->txid_mode = srv->proto_opts.scep.txid_mode; - s->content_cipher = srv->proto_opts.scep.content_cipher; + s->txid_mode = srv->proto_opts.scep.txid_mode; + s->content_cipher = srv->proto_opts.scep.content_cipher; + s->renewal_msg_type = srv->proto_opts.scep.renewal_msg_type; s->server_url = wolfcert_strdup(srv->server_url, heap); if (s->server_url == NULL) { WOLFCERT_XFREE(s, heap); @@ -1645,7 +1657,8 @@ static int scep_session_begin_renewal(WolfCertScepSession* s, rc = scep_session_begin(s, caps, ra_cert, ra_cert_len, ca_bundle, ca_bundle_len, current_cert, current_cert_len, key_der, key_der_len, - "17", SCEP_SESS_OP_RENEWAL, + scep_renewal_msg_type(s->renewal_msg_type), + SCEP_SESS_OP_RENEWAL, csr_der, csr_der_len, NULL, 0, out); wc_ForceZero(key_der, (word32)key_der_len); diff --git a/tests/integration/test_scep_roundtrip.c b/tests/integration/test_scep_roundtrip.c index f53ceb6..64a2a77 100644 --- a/tests/integration/test_scep_roundtrip.c +++ b/tests/integration/test_scep_roundtrip.c @@ -186,7 +186,7 @@ static int check_get_fallback(const WolfCertServerCfg* cli, } /* proto_opts.scep.txid_mode = PUBKEY_HASH: the transactionID must be the - * 64-char upper-case hex SHA-256 of the enrollee SubjectPublicKeyInfo, must + * 64-char upper-case hex SHA-256 of the enrollee public keyInfo, must * match a value recomputed from the CSR, and must be deterministic (a second * enrollment of the same key reuses it). Owns and frees everything it makes. */ static int check_pubkey_txid(const WolfCertServerCfg* cli, @@ -399,6 +399,277 @@ static int test_caps_token_matching(void) return 0; } +/* Captures one POSTed pkiMessage and reports the messageType it carried. The + * in-tree server routes 19 and 17 through the same handler, so only a look at + * the wire can tell the two renewal shapes apart. + * + * Every declaration below initializes .listen_fd, which zero-fills the rest of + * the struct (C99 6.7.9p19), so the char buffers are empty strings even when + * the thread bails out before parsing and the assertions compare cleanly. */ +struct msgtype_ctx { + int listen_fd; + char seen[8]; /* the messageType attribute, or "" if not reached */ + char reqline[256]; /* the HTTP request line, for the GET operations */ + size_t tid_len; /* transactionID length, 0 if not reached */ + char cipher[8]; /* content-encryption OID seen in the EnvelopedData */ +}; + +static void* msgtype_srv_thread(void* arg) +{ + struct msgtype_ctx* mc = (struct msgtype_ctx*)arg; + int cs = accept(mc->listen_fd, NULL, NULL); + close(mc->listen_fd); + if (cs < 0) + return NULL; + + /* Bound the read so a client that never POSTs fails the assertion instead + * of hanging until the ctest timeout. */ + struct timeval tv = { .tv_sec = 10, .tv_usec = 0 }; + setsockopt(cs, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + + /* Read headers, find Content-Length, then the body that follows. One byte + * is held back so the header scan below can NUL-terminate what arrived: + * recv() does not, and strstr must not run off the end. */ + uint8_t buf[16384]; + size_t n = 0; + size_t hdr_end = 0, want = 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; + if (hdr_end == 0) { + for (size_t i = 3; i < n; i++) { + if (memcmp(buf + i - 3, "\r\n\r\n", 4) == 0) { + hdr_end = i + 1; + break; + } + } + if (hdr_end != 0) { + buf[hdr_end - 1] = '\0'; /* terminate the header block only */ + const char* cl = strstr((const char*)buf, "Content-Length:"); + if (cl != NULL) + want = (size_t)strtoul(cl + 15, NULL, 10); + buf[hdr_end - 1] = '\n'; /* restore; the body starts after */ + } + } + if (hdr_end != 0 && n >= hdr_end + want) + break; + } + + /* The request line, which is all a GET operation carries. */ + for (size_t i = 0; i < n && i < sizeof(mc->reqline) - 1; i++) { + if (buf[i] == '\r' || buf[i] == '\n') + break; + mc->reqline[i] = (char)buf[i]; + mc->reqline[i + 1] = '\0'; + } + + if (hdr_end != 0 && want > 0 && n >= hdr_end + want) { + char* mt = NULL; + uint8_t* tid = NULL; + size_t tid_len = 0; + WolfCertBuffer env = { 0 }; + if (wolfcert_scep_parse_pki_message(buf + hdr_end, want, &env, + &tid, &tid_len, /* txid */ + NULL, NULL, /* senderNonce */ + NULL, NULL, /* recipNonce */ + &mt, /* messageType */ + NULL, /* pkiStatus */ + NULL, NULL, /* signer cert */ + NULL, /* failInfo */ + NULL) == WOLFCERT_OK) { + if (mt != NULL) + snprintf(mc->seen, sizeof(mc->seen), "%s", mt); + mc->tid_len = tid_len; + + /* The content-encryption AlgorithmIdentifier inside the + * EnvelopedData: the option is only honoured if this changes. */ + static const uint8_t OID_AES128[] = + { 0x06,0x09,0x60,0x86,0x48,0x01,0x65,0x03,0x04,0x01,0x02 }; + static const uint8_t OID_AES256[] = + { 0x06,0x09,0x60,0x86,0x48,0x01,0x65,0x03,0x04,0x01,0x2a }; + static const uint8_t OID_DES3[] = + { 0x06,0x08,0x2a,0x86,0x48,0x86,0xf7,0x0d,0x03,0x07 }; + if (env.data != NULL) { + if (memmem(env.data, env.len, OID_AES256, sizeof(OID_AES256))) + snprintf(mc->cipher, sizeof(mc->cipher), "aes256"); + else if (memmem(env.data, env.len, OID_AES128, sizeof(OID_AES128))) + snprintf(mc->cipher, sizeof(mc->cipher), "aes128"); + else if (memmem(env.data, env.len, OID_DES3, sizeof(OID_DES3))) + snprintf(mc->cipher, sizeof(mc->cipher), "des3"); + } + } + /* Parser output comes from the wolfCert heap, not libc. */ + WOLFCERT_XFREE(mt, NULL); + WOLFCERT_XFREE(tid, NULL); + wolfcert_buffer_free(&env); + } + + /* The client's round trip fails from here; the request is all we wanted. */ + close(cs); + return NULL; +} + +#ifdef WOLFCERT_TEST_HAVE_CIPHER_OVERRIDE +/* The end-to-end cipher check above only proves the server de-enveloped + * whatever arrived, which it does for any OID, so it would pass even if the + * override were ignored. Read the algorithm off the wire instead. */ +static int check_content_cipher_wire(const WolfCertScepCaps* caps, + const WolfCertKeyCfg* kcfg, + const uint8_t* ca_der_buf, size_t ca_der_len, + WolfCertScepContentCipher cipher, + const char* expect) +{ + struct msgtype_ctx mc = { .listen_fd = -1 }; + pthread_t tid; + int port = 0; + mc.listen_fd = listen_loopback(&port); + REQUIRE(mc.listen_fd >= 0); + REQUIRE(pthread_create(&tid, NULL, msgtype_srv_thread, &mc) == 0); + + char url[128]; + snprintf(url, sizeof(url), "http://127.0.0.1:%d/scep", port); + WolfCertServerCfg cli = { + .protocol = WOLFCERT_PROTO_SCEP, + .server_url = url, + .proto_opts.scep = { .content_cipher = cipher }, + }; + + WolfCertCertMeta meta = { .subject_dn = "CN=device-cipher-wire" }; + WolfCertKey* key = NULL; + WolfCertBuffer csr = { 0 }, issued = { 0 }; + REQUIRE(wolfcert_key_generate(kcfg, &key) == WOLFCERT_OK); + REQUIRE(wolfcert_csr_build(key, &meta, &csr) == WOLFCERT_OK); + + /* The listener never answers, so the call fails; the request is the point. */ + (void)wolfcert_scep_pkcs_req(&cli, caps, ca_der_buf, ca_der_len, key, + csr.data, csr.len, &issued); + wolfcert_buffer_free(&issued); + wolfcert_buffer_free(&csr); + wolfcert_key_free(key); + pthread_join(tid, NULL); + + REQUIRE(strcmp(mc.cipher, expect) == 0); + return 0; +} +#endif /* WOLFCERT_TEST_HAVE_CIPHER_OVERRIDE */ + +/* proto_opts.scep.renewal_msg_type picks the messageType a renewal carries, + * while the signer stays the certificate being replaced either way. Default is + * RFC 8894's RenewalReq (17); PKCS_REQ sends 19 for a CA that predates it. */ +static int check_renewal_msg_type(const WolfCertScepCaps* caps, + const uint8_t* ca_der_buf, size_t ca_der_len, + const uint8_t* cur_cert, size_t cur_cert_len, + const WolfCertKey* cur_key, + const uint8_t* csr, size_t csr_len, + WolfCertScepRenewalMsgType mode, + const char* expect) +{ + struct msgtype_ctx mc = { .listen_fd = -1 }; + pthread_t tid; + int port = 0; + mc.listen_fd = listen_loopback(&port); + REQUIRE(mc.listen_fd >= 0); + REQUIRE(pthread_create(&tid, NULL, msgtype_srv_thread, &mc) == 0); + + char url[128]; + snprintf(url, sizeof(url), "http://127.0.0.1:%d/scep", port); + WolfCertServerCfg cli = { + .protocol = WOLFCERT_PROTO_SCEP, + .server_url = url, + .proto_opts.scep = { .renewal_msg_type = mode }, + }; + + WolfCertScepResult r = { 0 }; + /* The capture server never answers, so the call fails; the assertion is + * about what it put on the wire before that. */ + (void)wolfcert_scep_renewal_req_ex(&cli, caps, ca_der_buf, ca_der_len, + ca_der_buf, ca_der_len, + cur_cert, cur_cert_len, cur_key, + csr, csr_len, &r); + wolfcert_scep_result_free(&r); + pthread_join(tid, NULL); + + REQUIRE(strcmp(mc.seen, expect) == 0); + return 0; +} + +/* The session captures the SCEP options at open rather than reading the config + * per request, so the capture has its own coverage: drive a session renewal + * against the recording listener and check both the messageType it chose and + * the transactionID form it derived. */ +static int check_session_opts_capture(const WolfCertScepCaps* caps, + const uint8_t* ca_der_buf, size_t ca_der_len, + const uint8_t* cur_cert, size_t cur_cert_len, + const WolfCertKey* cur_key, + const uint8_t* csr, size_t csr_len) +{ + struct msgtype_ctx mc = { .listen_fd = -1 }; + pthread_t tid; + int port = 0; + mc.listen_fd = listen_loopback(&port); + REQUIRE(mc.listen_fd >= 0); + REQUIRE(pthread_create(&tid, NULL, msgtype_srv_thread, &mc) == 0); + + char url[128]; + snprintf(url, sizeof(url), "http://127.0.0.1:%d/scep", port); + WolfCertServerCfg cli = { + .protocol = WOLFCERT_PROTO_SCEP, + .server_url = url, + .proto_opts.scep = { + .txid_mode = WOLFCERT_SCEP_TXID_PUBKEY_HASH, + .renewal_msg_type = WOLFCERT_SCEP_RENEWAL_MSG_PKCS_REQ, + }, + }; + + WolfCertScepSession* sess = NULL; + REQUIRE(wolfcert_scep_session_open(&cli, &sess) == WOLFCERT_OK); + + WolfCertScepResult r = { 0 }; + /* The listener never answers, so this fails; the request is the assertion. */ + (void)wolfcert_scep_session_renewal_req_ex(sess, caps, ca_der_buf, ca_der_len, + ca_der_buf, ca_der_len, + cur_cert, cur_cert_len, cur_key, + csr, csr_len, &r); + wolfcert_scep_result_free(&r); + wolfcert_scep_session_close(sess); + pthread_join(tid, NULL); + + REQUIRE(strcmp(mc.seen, "19") == 0); /* renewal_msg_type captured */ + REQUIRE(mc.tid_len == 64); /* txid_mode captured */ + return 0; +} + +/* RFC 8894 section 4.6.1: GetNextCACert takes the CA identifier too, so a + * multi-CA responder can be told which rollover certificate is wanted. */ +static int check_getnextca_ca_id(const uint8_t* ca_der_buf, size_t ca_der_len) +{ + struct msgtype_ctx mc = { .listen_fd = -1 }; + pthread_t tid; + int port = 0; + mc.listen_fd = listen_loopback(&port); + REQUIRE(mc.listen_fd >= 0); + REQUIRE(pthread_create(&tid, NULL, msgtype_srv_thread, &mc) == 0); + + char url[128]; + snprintf(url, sizeof(url), "http://127.0.0.1:%d/scep", port); + WolfCertServerCfg cli = { + .protocol = WOLFCERT_PROTO_SCEP, + .server_url = url, + .proto_opts.scep = { .ca_id = "RolloverCA" }, + }; + + WolfCertBuffer next = { 0 }; + (void)wolfcert_scep_get_next_ca_cert(&cli, ca_der_buf, ca_der_len, &next); + wolfcert_buffer_free(&next); + pthread_join(tid, NULL); + + REQUIRE(strstr(mc.reqline, "operation=GetNextCACert") != NULL); + REQUIRE(strstr(mc.reqline, "message=RolloverCA") != NULL); + return 0; +} + int main(void) { REQUIRE(wolfcert_init(NULL) == WOLFCERT_OK); @@ -480,6 +751,44 @@ int main(void) == WOLFCERT_OK); #endif + /* ---- Renewal messageType. The signer is the certificate being replaced + * in both cases; only the attribute changes, and the in-tree server routes + * 19 and 17 through one handler, so this reads the value off the wire. */ + REQUIRE(check_renewal_msg_type(&caps, ca_der->buffer, ca_der->length, + issued_der->buffer, issued_der->length, dk, + csr.data, csr.len, + WOLFCERT_SCEP_RENEWAL_MSG_RENEWAL_REQ, + "17") == 0); + REQUIRE(check_renewal_msg_type(&caps, ca_der->buffer, ca_der->length, + issued_der->buffer, issued_der->length, dk, + csr.data, csr.len, + WOLFCERT_SCEP_RENEWAL_MSG_PKCS_REQ, + "19") == 0); + + /* ...and the same options read off the wire, since the server de-envelops + * any OID and so cannot tell an honoured override from an ignored one. */ +#if defined(WOLFSSL_AES_256) && defined(HAVE_AES_CBC) + REQUIRE(check_content_cipher_wire(&caps, &kcfg, ca_der->buffer, + ca_der->length, + WOLFCERT_SCEP_CIPHER_AES256, + "aes256") == 0); +#endif +#if defined(WOLFSSL_AES_128) && defined(HAVE_AES_CBC) + REQUIRE(check_content_cipher_wire(&caps, &kcfg, ca_der->buffer, + ca_der->length, + WOLFCERT_SCEP_CIPHER_AES128, + "aes128") == 0); +#endif + + /* The session captures the SCEP options at open, so that path needs its own + * check rather than inheriting the one-shot coverage above. */ + REQUIRE(check_session_opts_capture(&caps, ca_der->buffer, ca_der->length, + issued_der->buffer, issued_der->length, + dk, csr.data, csr.len) == 0); + + /* The CA identifier belongs on GetNextCACert as well (RFC 8894 4.6.1). */ + REQUIRE(check_getnextca_ca_id(ca_der->buffer, ca_der->length) == 0); + /* Negative GET PKIOperation branches (RFC 8894 section 4.1): the server must * reject each malformed request with 400. These cannot be produced by the * client API, so drive the running server over a raw socket. */ diff --git a/wolfcert/types.h b/wolfcert/types.h index e7e8d7e..9118dbd 100644 --- a/wolfcert/types.h +++ b/wolfcert/types.h @@ -169,6 +169,16 @@ typedef enum { WOLFCERT_SCEP_CIPHER_DES3 = 3 } WolfCertScepContentCipher; +/* Which messageType a SCEP renewal carries (WolfCertScepServerOpts.renewal_msg_type). + * Either way the pkiMessage is signed by the certificate being replaced; only the + * messageType attribute changes. RENEWAL_REQ is RFC 8894 section 3.3.1's + * messageType 17. PKCS_REQ sends 19 instead, which is what CAs that predate + * RenewalReq expect for a renewal. */ +typedef enum { + WOLFCERT_SCEP_RENEWAL_MSG_RENEWAL_REQ = 0, /* messageType 17 (default) */ + WOLFCERT_SCEP_RENEWAL_MSG_PKCS_REQ = 1 /* messageType 19 */ +} WolfCertScepRenewalMsgType; + /* EST-only knobs, reached through WolfCertServerCfg.proto_opts.est. */ typedef struct { /* HTTP Basic credentials (RFC 7030 section 3.2.3), optional. Sent on @@ -200,8 +210,8 @@ typedef struct { } WolfCertEstServerOpts; /* SCEP-only knobs, reached through WolfCertServerCfg.proto_opts.scep. - * All three are zero-init-safe: the default value preserves wolfCert's - * pre-existing behavior. */ + * Every field is zero-init-safe: the all-zero value of each preserves + * wolfCert's pre-existing behavior, so a caller only sets what it needs. */ typedef struct { /* CA identifier sent as the `message` query parameter on GetCACaps and * GetCACert (RFC 8894 section 3.5.2 / 4.2), used to select a specific CA on @@ -217,6 +227,13 @@ typedef struct { * keeps the RFC 8894 caps-driven choice (AES-128-CBC, else 3DES); an * explicit value forces that cipher for a peer that requires it. */ WolfCertScepContentCipher content_cipher; + + /* messageType a renewal carries. The default is RFC 8894's RenewalReq; + * PKCS_REQ sends messageType 19 with the same existing-certificate signer, + * for a CA that predates RenewalReq. Read only by the renewal entry + * points. Check WolfCertScepCaps.renewal to see whether the CA advertises + * the RFC form before choosing. */ + WolfCertScepRenewalMsgType renewal_msg_type; } WolfCertScepServerOpts; typedef struct { From 4459ef13afe7544ec47068c720d8938436566b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Thu, 30 Jul 2026 17:47:37 +0200 Subject: [PATCH 10/11] docs: add a wolfSCEP migration guide wolfCert now matches wolfSCEP on the three behaviours that are not in RFC 8894, and on the renewal messageType, so an existing wolfSCEP integration has somewhere to go. Nothing told those users how to get there. The guide ships with the change that makes the claim true rather than following it. The migration is not a rename table, because the two libraries divide the work differently. wolfSCEP is HTTP transport: it frames each request, moves the bytes and hands back the reply body. It builds and parses no pkiMessages at all, so every wolfSCEP integration carries its own wc_PKCS7 code for the EnvelopedData, the SignedData wrapper and the transactionID, senderNonce and messageType attributes, plus its own CertRep parse, fingerprint check and polling loop. wolfCert owns all of that. The document therefore leads with what the caller gets to delete, which is mostly the cryptographic message layer rather than the plumbing, and only then maps the calls. Four differences will actually bite, and each is called out with what to do: an MD5 CA fingerprint has to be re-pinned, because wolfCert offers SHA-1, SHA-256 and SHA-512 and refuses a 16-byte value; the CA identifier is no longer sent by default, where wolfSCEP always sent one defaulting to the literal CAIdentifier; the transactionID is random unless txid_mode asks for the public-key derivation; and the content cipher is caps-driven, so a peer requiring AES-256 has to be told, since no GetCACaps keyword advertises it. Renewal gets its own section, since which messageType a CA expects is not something the reader can guess and WolfCertScepCaps.renewal is the way to find out. Every wolfCert and wolfSCEP symbol named in the guide was checked against the respective headers, and the worked example plus the fingerprint, transactionID and cipher claims were run against the in-tree SCEP server. --- CLAUDE.md | 3 + README.md | 3 + docs/ARCHITECTURE.md | 3 + docs/MIGRATING-FROM-WOLFSCEP.md | 213 ++++++++++++++++++++++++++++++++ 4 files changed, 222 insertions(+) create mode 100644 docs/MIGRATING-FROM-WOLFSCEP.md diff --git a/CLAUDE.md b/CLAUDE.md index 6d736cb..bd52ffa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,5 +185,8 @@ Minimal orientation: MCU / CryptoCb integration guide (start here for non-trivial changes). - `docs/EMBEDDED.md` - RAM-sizing knobs for constrained targets (`Cert`/`CertName` shrinking, tunable HTTP stack buffers). +- `docs/MIGRATING-FROM-WOLFSCEP.md` - call mapping and behavioural + differences for an existing wolfSCEP integration, including which + messageType a renewal should carry. - `wolfcert/*.h` - authoritative API reference (inline comments document every field and function contract). diff --git a/README.md b/README.md index 4aa115b..f7d5213 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,9 @@ caveats apply to SCEP and to some servers: - [`docs/CI.md`](docs/CI.md) — the GitHub Actions pipeline, the shared `scripts/ci/build-wolfssl.sh` helper, and how to reproduce a CI config locally. +- [`docs/MIGRATING-FROM-WOLFSCEP.md`](docs/MIGRATING-FROM-WOLFSCEP.md) — moving an + existing wolfSCEP integration to wolfCert: call mapping, the behavioural + differences, and a worked enrollment. ## License diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b85ddf2..f1ff2a8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -203,6 +203,9 @@ The server's `pkiStatus` maps to a `WolfCertScepResult.status` of `SUCCESS` (cert in `cert_pem`), `PENDING` (poll with `GetCertInitial`, quoting the returned transaction ID), or `FAILURE`. +Moving an existing wolfSCEP integration across is covered separately in +[`MIGRATING-FROM-WOLFSCEP.md`](MIGRATING-FROM-WOLFSCEP.md). + **SCEP is RSA-only.** The entry points reject non-RSA keys with `WOLFCERT_ERR_UNSUPPORTED` — this is a protocol constraint, not a wolfCert limitation. Use EST for Ed25519 / Ed448 / ML-DSA. diff --git a/docs/MIGRATING-FROM-WOLFSCEP.md b/docs/MIGRATING-FROM-WOLFSCEP.md new file mode 100644 index 0000000..1ad1fe6 --- /dev/null +++ b/docs/MIGRATING-FROM-WOLFSCEP.md @@ -0,0 +1,213 @@ +# Migrating from wolfSCEP to wolfCert + +wolfCert is the successor to wolfSCEP for SCEP client work. It speaks the same protocol (RFC 8894) against the same servers, and as of the wolfSCEP-compatibility work it also matches wolfSCEP on the three behaviours that are not in the RFC: the CA identifier sent on `GetCACaps` and `GetCACert`, the transactionID derived from the enrollee public key, and an explicitly chosen content-encryption cipher. + +This document is for someone with a working wolfSCEP integration who wants to move it. It assumes you know your own call sites; it does not assume you know wolfCert. For the design behind wolfCert, read `ARCHITECTURE.md` after this. + +## At a glance + +| | wolfSCEP | wolfCert | +|---|---|---| +| Build | autotools, separate library | CMake (primary) or autotools | +| wolfSSL prerequisite | `--enable-scep` | a feature set checked at configure time, see below | +| Protocols | SCEP | SCEP and EST (RFC 7030) | +| Transport | yours: socket, HTTP framing, TLS | wolfCert's: HTTP and TLS included | +| Shape | request and response primitives | one call per PKI operation | +| Key algorithms | RSA (SCEP is RSA-only) | RSA for SCEP; also ECC, Ed25519, Ed448 and ML-DSA over EST | + +## The change that matters + +wolfSCEP is HTTP transport. It frames the request for each SCEP operation, moves the bytes, reads the reply and hands you the body. It does not build or parse a single pkiMessage: there is no `wc_PKCS7_EncodeSignedData` or `wc_PKCS7_DecodeSignedData` call anywhere in it. Your application does that work, which is why a wolfSCEP integration always carries a slab of `wc_PKCS7_*` code that builds the EnvelopedData, wraps it in SignedData, and sets the `transactionID`, `senderNonce` and `messageType` signed attributes by hand. + +So on top of wolfSCEP you also own: the socket (`wolfSCEP_set_fd()`, or `WS_CallbackIORecv` and `WS_CallbackIOSend` that you pump yourself), the host, port and user agent (`wolfSCEP_set_httpField()`), the CGI path (`wolfSCEP_set_cgi()`), the wolfSSL `SSL*` for HTTPS, the whole request pkiMessage handed over as a bundle, the CertRep parse, the CA fingerprint check, and the polling loop. + +wolfCert takes a URL and returns a certificate. HTTP, TLS, the CMS round trip and the response checks are inside the library. + +The practical consequence is that a migration deletes code rather than translating it, and the largest part of what goes is the cryptographic message layer, not the plumbing. The `wc_PKCS7_*` request builder, the signed-attribute handling, the CertRep parse and de-envelope, the fingerprint helper, the socket setup, the TLS wiring, the response buffer sizing and the polling loop all go. What remains is a config struct and one call per operation. + +If you must keep your own transport, for example on an RTOS with a proprietary stack, set `WolfCertServerCfg.connect_cb`. wolfCert then asks you for a connected socket and still does the HTTP and TLS on top of it. + +## Call mapping + +| wolfSCEP | wolfCert | +|---|---| +| `wolfSCEP_CTX_new` / `wolfSCEP_new` / `_free` | none needed; `WolfCertServerCfg` is a plain value you fill in | +| `wolfSCEP_set_fd`, `wolfSCEP_SetIORecv`, `wolfSCEP_SetIOSend`, `wolfSCEP_SetIOReadCtx` | handled internally; `srv.connect_cb` if you need your own transport | +| `wolfSCEP_set_httpField(name, host, port)`, `wolfSCEP_set_cgi` | all part of `srv.server_url` | +| `wolfSCEP_set_getCaId` | `srv.proto_opts.scep.ca_id` | +| `wolfSCEP_set_bundle` (the pkiMessage your application built) | nothing to pass: wolfCert builds the pkiMessage. You supply `csr_der`, `ra_cert` and `ca_bundle` and it does the rest | +| `wolfSCEP_request(WS_REQUEST_CACAPS)` then `wolfSCEP_response` | `wolfcert_scep_get_ca_caps` | +| `wolfSCEP_request(WS_REQUEST_CA)` then `wolfSCEP_response` | `wolfcert_scep_get_ca_cert` or `wolfcert_scep_get_ca_cert_enc` | +| `wolfSCEP_request(WS_REQUEST_ENROLL)` then `wolfSCEP_response` | `wolfcert_scep_pkcs_req` or `wolfcert_scep_pkcs_req_ex` | +| `WS_POST_FLAG` | automatic: POST by default, with the RFC 8894 section 4.1 GET fallback when `GetCACaps` does not advertise `POSTPKIOperation` | +| `wolfSCEP_reply_status` (the HTTP status code) | checked internally; a non-200 surfaces as `WOLFCERT_ERR_HTTP` | +| your own pkiStatus parse against `WS_PKI_SUCCESS` / `WS_PKI_FAILURE` / `WS_PKI_PENDING` | `WolfCertScepResult.status`, `WOLFCERT_SCEP_STATUS_SUCCESS` / `_FAILURE` / `_PENDING` | +| `wolfSCEP_reply_error`, `wolfSCEP_get_error` | `wolfcert_strerror` and `wolfcert_last_error_message` | +| `wolfSCEP_Debugging_ON` / `_OFF` | `wolfcert_set_log_cb` and `wolfcert_set_log_level` | +| your own CA fingerprint check | `wolfcert_scep_verify_ca_fingerprint` | +| no equivalent | `wolfcert_scep_renewal_req` (either messageType, see below), `wolfcert_scep_get_cert_initial`, `wolfcert_scep_get_next_ca_cert`, the keep-alive and async session API, and all of EST | + +`WS_REQUEST_CERT` and `WS_REQUEST_CRL` need no entry. Both fall through to the default case of `wolfSCEP_request()` and return `WS_BAD_ARGUMENT`, so no working integration can be using them. + +## A worked enrollment + +The wolfSCEP shape, elided to its skeleton: + +```c +ctx = wolfSCEP_CTX_new(NULL); +scep = wolfSCEP_new(ctx); + +wolfSCEP_set_cgi(scep, "/certsrv/mscep/mscep.dll"); +wolfSCEP_set_getCaId(scep, caId, caIdSz); +wolfSCEP_set_httpField(scep, agent, agentSz, host, hostSz, port); + +/* your socket, and for HTTPS your SSL object as the IO context */ +sfd = connect_to_server(host, port); +wolfSCEP_set_fd(scep, sfd); + +wolfSCEP_request(scep, WS_REQUEST_CA, 0); +wolfSCEP_response(scep, answer, &answerSz, 0); +/* your own fingerprint check over the returned CA certificate */ +/* your own CSR build */ +wolfSCEP_set_bundle(scep, request, reqSz); +wolfSCEP_request(scep, WS_REQUEST_ENROLL, 0); +wolfSCEP_response(scep, answer, &answerSz, 0); +if (wolfSCEP_reply_status(scep) == WS_PKI_SUCCESS) { /* ... */ } +``` + +The same enrollment in wolfCert, which is `examples/enroll_scep.c` with the fingerprint check added: + +```c +WolfCertServerCfg srv = { + .protocol = WOLFCERT_PROTO_SCEP, + .server_url = "http://ca.example/certsrv/mscep/mscep.dll", + .proto_opts.scep = { .ca_id = "CAIdentifier" }, +}; + +/* 1) GetCACert, then pin it against a fingerprint you hold out of band. */ +WolfCertBuffer ca_der = { 0 }; +wolfcert_scep_get_ca_cert_enc(&srv, WOLFCERT_ENCODING_DER, &ca_der); +if (wolfcert_scep_verify_ca_fingerprint(ca_der.data, ca_der.len, + expected_fp, sizeof(expected_fp), + WOLFCERT_SCEP_FP_SHA256) != WOLFCERT_OK) + return -1; + +/* 2) Capabilities drive the hash, the content cipher and POST versus GET. */ +WolfCertScepCaps caps = { 0 }; +wolfcert_scep_get_ca_caps(&srv, &caps); + +/* 3) Key and CSR. */ +WolfCertKeyCfg kcfg = { .type = WOLFCERT_KEY_RSA, .param = 2048, + .dev_id = WOLFCERT_DEVID_SOFTWARE }; +WolfCertKey* key = NULL; +wolfcert_key_generate(&kcfg, &key); + +WolfCertCertMeta meta = { .subject_dn = "CN=device-1", + .challenge_password = challenge }; +WolfCertBuffer csr = { 0 }; +wolfcert_csr_build(key, &meta, &csr); + +/* 4) PKCSReq. One call: envelope, sign, POST, verify, de-envelope. */ +WolfCertScepResult res = { 0 }; +int rc = wolfcert_scep_pkcs_req_ex(&srv, &caps, + ca_der.data, ca_der.len, /* envelope target */ + ca_der.data, ca_der.len, /* trusted bundle */ + key, csr.data, csr.len, &res); +``` + +`res.status` is then `SUCCESS` with the certificate in `res.cert_pem`, `PENDING` with the transactionID in `res.transaction_id` to poll with `wolfcert_scep_get_cert_initial`, or `FAILURE` with `res.fail_info` carrying the RFC 8894 failInfo. + +In a split CA and RA deployment, pass the whole `GetCACert` bundle as `ca_bundle` and the RA encryption certificate as `ra_cert`; they are separate arguments because the response signer and the envelope recipient are not the same certificate. + +For a quick check against a server before touching code, the CLI covers the same ground: + +```sh +wolfcert-client enroll --proto scep --url http://ca.example/certsrv/mscep/mscep.dll \ + --ca-id CAIdentifier --challenge "$OTP" \ + --key-type rsa:2048 --subject "CN=device-1" \ + --out-key dev.key --out-cert dev.crt +``` + +## Differences that will bite + +**An MD5 CA fingerprint has to be re-pinned.** This is the one item that can block a migration outright, so check it first. `wolfcert_scep_verify_ca_fingerprint` supports SHA-256, SHA-1 and SHA-512, and `WOLFCERT_SCEP_FP_AUTO` picks the digest from the length: 20, 32 or 64 bytes. MD5 is deliberately not offered, so a 16-byte fingerprint returns `WOLFCERT_ERR_BAD_ARG` no matter which algorithm you name. Length-based selection is a legacy convenience anyway: pass `WOLFCERT_SCEP_FP_SHA256` explicitly when you know the digest, since a 20-byte value silently selects SHA-1, whose collision resistance is broken. + +**The CA identifier is no longer sent by default.** wolfSCEP always sends one and defaults it to the literal string `CAIdentifier`. wolfCert omits the `message=` parameter entirely unless you set `proto_opts.scep.ca_id`. If your server expects the parameter, or expects that particular default, set it explicitly. A single-CA responder that ignores the parameter needs nothing. + +**The CGI path moves into the URL.** wolfSCEP defaults to `/cgi-bin/pkiclient.exe` on port 11111 and takes the path through `wolfSCEP_set_cgi()`. In wolfCert the path, host and port are simply the `server_url` you pass. + +**The transactionID default differs.** wolfCert generates a fresh random transactionID per request. wolfSCEP derives it from the enrollee public key, which means repeated enrollments of one key share an ID and a CA can recognise the retry. To keep that, set: + +```c +srv.proto_opts.scep.txid_mode = WOLFCERT_SCEP_TXID_PUBKEY_HASH; +``` + +That produces the SHA-256 of the signing certificate's public key, upper-case hex encoded, 64 characters, per RFC 8894 section 3.2.1. It hashes the `subjectPublicKey` BIT STRING contents, not the enclosing `SubjectPublicKeyInfo` with its AlgorithmIdentifier, which is the same input a wolfSCEP-based peer feeds in through `PKCS7.publicKey`, so the two derive the same value. On a renewal the signer is the certificate being replaced, so the ID follows the old key. + +**Content encryption is negotiated, not fixed.** By default wolfCert uses AES-128-CBC when the CA advertises the `AES` capability and triple DES otherwise. No `GetCACaps` keyword advertises AES-256, so a server that requires it can only be reached by asking: + +```c +srv.proto_opts.scep.content_cipher = WOLFCERT_SCEP_CIPHER_AES256; +``` + +A wolfSSL built `NO_DES3` cannot produce the triple DES fallback and will report `WOLFCERT_ERR_UNSUPPORTED` against a CA that advertises no AES. + +**Fingerprint verification is now the library's job.** Delete your helper and call `wolfcert_scep_verify_ca_fingerprint`, which hashes the DER certificate and compares in constant time. + +**Error codes are a different, smaller set.** wolfSCEP's `WS_*` codes map onto `wolfcert/errors.h`: `WS_BAD_ARGUMENT` to `WOLFCERT_ERR_BAD_ARG`, `WS_MEMORY_E` to `WOLFCERT_ERR_MEMORY`, `WS_PARSE_E` to `WOLFCERT_ERR_PARSE`, the `WS_CBIO_ERR_*` family to `WOLFCERT_ERR_IO` and, in non-blocking mode only, to `WOLFCERT_ERR_WANT_READ` and `WOLFCERT_ERR_WANT_WRITE`. There is no per-call error string; use `wolfcert_strerror` for the code and `wolfcert_last_error_message` for the per-thread detail. + +**TLS is optional for SCEP and mandatory for EST.** SCEP authenticates inside the pkiMessage, so a plaintext `http://` endpoint is accepted. An `https://` endpoint requires `srv.verify_server`: every SCEP entry point, one-shot and session alike, refuses to run unverified rather than completing a silent anonymous handshake, because `verify_server` is the only peer-verification switch in the transport. + +## Renewals: which messageType your CA expects + +Two things travel together in an enrollment request, the `messageType` signed attribute and the certificate that signs the pkiMessage, and the signer is how the CA decides who is asking. + +RFC 8894 pairs them. An initial enrollment is messageType 19 (`PKCSReq`) signed by a throwaway self-signed certificate whose key is the one being enrolled, so the signature proves possession of the new private key and the challengePassword in the CSR carries the authorization. A renewal is messageType 17 (`RenewalReq`) signed by the certificate being replaced, which the CA recognises as its own issuance. + +CAs that predate `RenewalReq` expect a renewal as messageType 19 signed by the old certificate instead. wolfCert covers both, since the signer is the same either way and only the attribute differs: + +```c +srv.proto_opts.scep.renewal_msg_type = WOLFCERT_SCEP_RENEWAL_MSG_PKCS_REQ; +``` + +The default, `WOLFCERT_SCEP_RENEWAL_MSG_RENEWAL_REQ`, sends 17. The option is read by `wolfcert_scep_renewal_req_ex` and by the session renewals; initial enrollment is always messageType 19 with the self-signed signer and is unaffected. + +To decide which your CA wants, read `WolfCertScepCaps.renewal`, which `wolfcert_scep_get_ca_caps` fills from the GetCACaps `Renewal` keyword. A CA that advertises it accepts `RenewalReq`, so the default is right. If it does not advertise `Renewal` and your existing integration renews successfully anyway, you are relying on the older shape and want `PKCS_REQ`. wolfCert does not test the field for you. + +## Building against wolfCert + +wolfSSL no longer needs `--enable-scep`. wolfCert checks its own requirements at configure time and fails loudly if any are missing. The canonical wolfSSL build is in `README.md`; `--enable-des3` matters only for CAs that advertise no AES. + +```sh +cmake -S . -B build +cmake --build build -j +sudo cmake --install build +``` + +Autotools is kept at parity (`./autogen.sh && ./configure && make`). For a target with no configure step, define `WOLFCERT_USER_SETTINGS` and supply a `user_settings.h`; see `EMBEDDED.md` and `examples/user_settings.h.example`. + +Link against `libwolfcert` and include ``, which pulls in the protocol headers that were compiled in. + +## What you get on top + +- **EST (RFC 7030)** for the same lifecycle over TLS, with ECC, Ed25519, Ed448 and ML-DSA keys, none of which SCEP permits. +- **Keep-alive and non-blocking sessions**, so several operations share one connection and an event loop can drive them through `WOLFCERT_ERR_WANT_READ` and `WOLFCERT_ERR_WANT_WRITE`. +- **CryptoCb offload.** Register your TPM, HSM or PKCS#11 backend with wolfSSL and pass the resulting `dev_id` in `WolfCertKeyCfg`; the private key never has to be in memory. +- **Pluggable storage** through `WolfCertStoreOps` for flash and NVM targets. +- **Static memory.** Every allocation carries a heap hint, so wolfCert runs on a wolfSSL built `WOLFSSL_NO_MALLOC` with a static pool. +- **A tested interop matrix**, described in `INTEROP.md`. + +## Checklist + +1. Obtain a SHA-256 fingerprint for your CA out of band, and confirm it is not MD5. +2. Fold host, port and CGI path into one `server_url`. +3. Set `protocol` to `WOLFCERT_PROTO_SCEP`. It selects the `proto_opts` arm, so the `wolfcert_scep_*` calls reject a config that leaves it unset with `WOLFCERT_ERR_BAD_ARG`. +4. Set `proto_opts.scep.ca_id` if your server expects the `message=` parameter. +5. Set `txid_mode` to `WOLFCERT_SCEP_TXID_PUBKEY_HASH` if your CA deduplicates retries by transactionID. +6. Set `content_cipher` if your CA requires AES-256. +7. Delete the socket, HTTP, TLS wiring and response buffer management. +8. Replace your fingerprint helper with `wolfcert_scep_verify_ca_fingerprint`. +9. Replace the request and response pairs with the matching `wolfcert_scep_*` call. +10. Handle `PENDING` through `wolfcert_scep_get_cert_initial` rather than your own retry loop. +11. Set `renewal_msg_type` if your CA predates `RenewalReq`; check `WolfCertScepCaps.renewal` to find out. +12. Map your error handling onto `wolfcert/errors.h`. From 73a5cd5459895bf0cb59f5091d0963f099097909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Frauenschl=C3=A4ger?= Date: Thu, 30 Jul 2026 18:45:20 +0200 Subject: [PATCH 11/11] scep: refuse an unverified TLS endpoint in the one-shot calls too wolfcert_scep_session_open already declined to open an https:// endpoint with verify_server off, because verify_server is the transport's only peer verification switch and running without it completes a handshake that authenticates nothing while looking like TLS. The one-shot entry points did not: they forwarded the flag into the request and left the decision there, so the same URL and config that the session refuses would connect anonymously and carry the pkiMessage over it. SCEP survives that better than most protocols, since RFC 8894 authenticates the enrollment inside the pkiMessage, which is why plaintext http:// is accepted here at all. But a caller who wrote https:// asked for an authenticated channel and did not get one, and the two halves of the same API disagreeing about it is worse than either rule on its own. Move the check into a helper and call it from all six one-shot operations. A caller relying on https:// with verify_server off now gets WOLFCERT_ERR_TLS, which is the intent: the fix is the behaviour change. The test asserts both directions, since a gate that fires unconditionally would pass a one-sided check: unverified is refused, and the same URL gets past the gate and fails on the network once verify_server is set. --- src/scep/scep_client.c | 64 ++++++++++++++++++++++++- tests/integration/test_scep_roundtrip.c | 18 +++++++ tests/unit/test_scep_msg.c | 46 ++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/scep/scep_client.c b/src/scep/scep_client.c index cfa9dc8..262be38 100644 --- a/src/scep/scep_client.c +++ b/src/scep/scep_client.c @@ -122,12 +122,48 @@ static int has_cap(const char* body, size_t len, const char* needle) return 0; } +/* Validate the config before it is used. The protocol check comes first: it + * gates every read of proto_opts.scep below, which would otherwise reinterpret + * an EST arm's storage as the CA identifier and the cipher selectors. + * + * SCEP itself does not require TLS: RFC 8894 authenticates at the pkiMessage + * layer and plaintext http:// is legitimate. But an https:// endpoint must + * still be authenticated, since verify_server is the sole peer-verification + * switch and leaving it off would complete a silent, unauthenticated + * handshake. The session open applies the same rules; this is the one-shot + * half of it. */ +static int scep_check_cfg(const WolfCertServerCfg* srv, void* heap) +{ + WolfCertUrl u; + int rc = wolfcert_cfg_require_proto(srv, WOLFCERT_PROTO_SCEP, "scep"); + if (rc != WOLFCERT_OK) + return rc; + + rc = wolfcert_http_url_parse(srv->server_url, &u, heap); + if (rc != WOLFCERT_OK) + return rc; + + int tls = u.tls; + wolfcert_http_url_free(&u); + + if (tls && !srv->verify_server) + return WOLFCERT_ERR(WOLFCERT_ERR_TLS, "scep", + "TLS SCEP endpoint requires server authentication: set verify_server " + "or use a plaintext http:// URL"); + + return WOLFCERT_OK; +} + int wolfcert_scep_get_ca_caps(const WolfCertServerCfg* srv, WolfCertScepCaps* out) { if (srv == NULL || srv->server_url == NULL || out == NULL) return WOLFCERT_ERR_BAD_ARG; void* heap = srv->heap ? srv->heap : wolfcert_default_heap(); + int trc = scep_check_cfg(srv, heap); + if (trc != WOLFCERT_OK) + return trc; + memset(out, 0, sizeof(*out)); char* url = wolfcert_scep_build_getca_url(srv->server_url, "GetCACaps", @@ -190,6 +226,10 @@ int wolfcert_scep_get_ca_cert_enc(const WolfCertServerCfg* srv, WolfCertEncoding return WOLFCERT_ERR_BAD_ARG; void* heap = srv->heap ? srv->heap : wolfcert_default_heap(); + int trc = scep_check_cfg(srv, heap); + if (trc != WOLFCERT_OK) + return trc; + char* url = wolfcert_scep_build_getca_url(srv->server_url, "GetCACert", srv->proto_opts.scep.ca_id, heap); @@ -996,6 +1036,10 @@ int wolfcert_scep_pkcs_req_ex(const WolfCertServerCfg* srv, memset(out, 0, sizeof(*out)); out->fail_info = -1; void* heap = srv->heap ? srv->heap : wolfcert_default_heap(); + int trc = scep_check_cfg(srv, heap); + if (trc != WOLFCERT_OK) + return trc; + uint8_t* signer_der = NULL; size_t signer_len = 0; int rc = wolfcert_scep_self_signed_rsa((RsaKey*)new_key->impl, @@ -1082,6 +1126,10 @@ int wolfcert_scep_renewal_req_ex(const WolfCertServerCfg* srv, memset(out, 0, sizeof(*out)); out->fail_info = -1; void* heap = srv->heap ? srv->heap : wolfcert_default_heap(); + int trc = scep_check_cfg(srv, heap); + if (trc != WOLFCERT_OK) + return trc; + uint8_t* key_der = NULL; size_t key_der_len = 0; int rc = rsa_key_to_der(current_key, heap, &key_der, &key_der_len); @@ -1165,6 +1213,10 @@ int wolfcert_scep_get_cert_initial(const WolfCertServerCfg* srv, memset(out, 0, sizeof(*out)); out->fail_info = -1; void* heap = srv->heap ? srv->heap : wolfcert_default_heap(); + int trc = scep_check_cfg(srv, heap); + if (trc != WOLFCERT_OK) + return trc; + WolfCertBuffer ias = { 0 }; uint8_t* key_der = NULL; @@ -1223,6 +1275,10 @@ int wolfcert_scep_get_next_ca_cert(const WolfCertServerCfg* srv, return WOLFCERT_ERR_BAD_ARG; void* heap = srv->heap ? srv->heap : wolfcert_default_heap(); + int trc = scep_check_cfg(srv, heap); + if (trc != WOLFCERT_OK) + return trc; + char* url = wolfcert_scep_build_getca_url(srv->server_url, "GetNextCACert", srv->proto_opts.scep.ca_id, heap); @@ -1316,12 +1372,18 @@ static int scep_session_open_common(const WolfCertServerCfg* srv, int nonblockin void* heap = srv->heap ? srv->heap : wolfcert_default_heap(); + /* The session copies proto_opts.scep below, so confirm the discriminator + * before reading that arm. */ + int rc = wolfcert_cfg_require_proto(srv, WOLFCERT_PROTO_SCEP, "scep"); + if (rc != WOLFCERT_OK) + return rc; + /* Split the SCEP URL into scheme://host[:port] for the HTTP session vs the * path we keep for building per-operation query strings. Unlike EST there * is deliberately no TLS-required gate: RFC 8894 authenticates at the * pkiMessage layer and commonly runs over plaintext http://. */ WolfCertUrl u; - int rc = wolfcert_http_url_parse(srv->server_url, &u, heap); + rc = wolfcert_http_url_parse(srv->server_url, &u, heap); if (rc != WOLFCERT_OK) return rc; diff --git a/tests/integration/test_scep_roundtrip.c b/tests/integration/test_scep_roundtrip.c index 64a2a77..0ccb3d0 100644 --- a/tests/integration/test_scep_roundtrip.c +++ b/tests/integration/test_scep_roundtrip.c @@ -789,6 +789,24 @@ int main(void) /* The CA identifier belongs on GetNextCACert as well (RFC 8894 4.6.1). */ REQUIRE(check_getnextca_ca_id(ca_der->buffer, ca_der->length) == 0); + /* One-shot SCEP over https:// must refuse to run unverified, the same rule + * the session open applies: verify_server is the only peer-verification + * switch, so leaving it off would complete a silent anonymous handshake. */ + { + WolfCertServerCfg tls_cli = { .protocol = WOLFCERT_PROTO_SCEP, + .server_url = "https://127.0.0.1:1/scep" }; + WolfCertScepCaps tls_caps = { 0 }; + WolfCertBuffer tls_ca = { 0 }; + REQUIRE(wolfcert_scep_get_ca_caps(&tls_cli, &tls_caps) == WOLFCERT_ERR_TLS); + REQUIRE(wolfcert_scep_get_ca_cert(&tls_cli, &tls_ca) == WOLFCERT_ERR_TLS); + wolfcert_buffer_free(&tls_ca); + + /* With verification on it must get past the gate and fail on the + * network instead, so the check cannot be firing indiscriminately. */ + tls_cli.verify_server = 1; + REQUIRE(wolfcert_scep_get_ca_caps(&tls_cli, &tls_caps) != WOLFCERT_ERR_TLS); + } + /* Negative GET PKIOperation branches (RFC 8894 section 4.1): the server must * reject each malformed request with 400. These cannot be produced by the * client API, so drive the running server over a raw socket. */ diff --git a/tests/unit/test_scep_msg.c b/tests/unit/test_scep_msg.c index 66c62a8..5508346 100644 --- a/tests/unit/test_scep_msg.c +++ b/tests/unit/test_scep_msg.c @@ -1166,6 +1166,50 @@ static int test_getca_url(void) return 0; } +/* The mirror of test_est_rejects_scep_cfg: WolfCertServerCfg.protocol + * discriminates the proto_opts union, so a SCEP entry point handed an EST + * config must refuse it rather than read the wrong arm. Reading the SCEP arm + * here would send the EST username as the CA identifier and derive the txid + * mode and content cipher from the bytes of the password pointer. The + * enrollment entry points share the same gate; the ones exercised below are + * those reachable without a live RSA signer. Nothing here touches the + * network. */ +static int test_scep_rejects_est_cfg(void) +{ + static const uint8_t dummy_ca[] = { 0x30, 0x03, 0x02, 0x01, 0x00 }; + WolfCertServerCfg srv = { + .protocol = WOLFCERT_PROTO_EST, + .server_url = "http://127.0.0.1:1/scep", + .verify_server = 0, + .proto_opts.est = { .username = "alice", .password = "hunter2" } + }; + WolfCertScepCaps caps = { 0 }; + WolfCertBuffer out = { 0 }; + WolfCertScepSession* sess = NULL; + + REQUIRE(wolfcert_scep_get_ca_caps(&srv, &caps) == WOLFCERT_ERR_BAD_ARG); + REQUIRE(wolfcert_scep_get_ca_cert(&srv, &out) == WOLFCERT_ERR_BAD_ARG); + REQUIRE(wolfcert_scep_get_ca_cert_enc(&srv, WOLFCERT_ENCODING_DER, &out) + == WOLFCERT_ERR_BAD_ARG); + REQUIRE(wolfcert_scep_get_next_ca_cert(&srv, dummy_ca, sizeof(dummy_ca), + &out) == WOLFCERT_ERR_BAD_ARG); + + /* Both session-open paths gate on the discriminator too, before they copy + * the SCEP options out of the union. */ + REQUIRE(wolfcert_scep_session_open(&srv, &sess) == WOLFCERT_ERR_BAD_ARG); + REQUIRE(sess == NULL); + REQUIRE(wolfcert_scep_session_open_async(&srv, &sess) == WOLFCERT_ERR_BAD_ARG); + REQUIRE(sess == NULL); + + /* An unset discriminator is refused for the same reason: nothing says + * which arm of the union the caller populated. */ + srv.protocol = (WolfCertProtocol)0; + REQUIRE(wolfcert_scep_get_ca_caps(&srv, &caps) == WOLFCERT_ERR_BAD_ARG); + REQUIRE(wolfcert_scep_session_open(&srv, &sess) == WOLFCERT_ERR_BAD_ARG); + + return 0; +} + /* Only meaningful where wolfSSL can actually run an AES-CBC content cipher. */ #if defined(HAVE_AES_CBC) && \ (defined(WOLFSSL_AES_128) || defined(WOLFSSL_AES_256)) @@ -1215,6 +1259,8 @@ int main(void) REQUIRE(wolfcert_init(NULL) == WOLFCERT_OK); if (test_getca_url()) return 1; + if (test_scep_rejects_est_cfg()) + return 1; #if defined(HAVE_AES_CBC) && \ (defined(WOLFSSL_AES_128) || defined(WOLFSSL_AES_256)) if (test_envelop_cipher_oid())