wolfSCEP interop gaps, and scope the server config per protocol - #14
Conversation
8a57a0d to
c071012
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #14
Scan targets checked: wolfcert-bugs, wolfcert-src
Findings: 2
1 finding(s) posted as inline comments (see file-level comments below)
Medium (1)
Basic-auth credential scrub bypassed on early-return paths
File: src/http.c:883
Function: http_write_request
Category: Resource leaks on error paths
The new wc_ForceZero(auth, sizeof(auth)) only runs after head is allocated and filled. If basic_auth_header returns a completed header but WOLFCERT_XMALLOC for head then fails (or basic_auth_header itself truncates and returns negative), the function returns immediately, leaving a full or partial plaintext/base64 Basic-auth credential unscrubbed on the stack. The identical pattern exists in build_head.
Recommendation: Zeroize auth on every return path after it may hold data, e.g. via a single cleanup label or by scrubbing immediately after use in basic_auth_header's caller.
Referenced code: src/http.c:883-885 (3 lines)
This review was generated automatically by Fenrir. Findings are non-blocking.
c071012 to
9de58b5
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #14
Scan targets checked: wolfcert-bugs, wolfcert-src
Findings: 1
Low (1)
Truncated Basic-auth header left unscrubbed on the stack on overflow
File: src/http.c:869
Function: http_write_request
Category: Missing ForceZero
basic_auth_header writes the base64 credential into the caller's auth stack buffer via snprintf before checking n >= out_cap, so an overlong user/pass leaves a truncated but recoverable credential in auth. Both http_write_request and build_head return n on this path without zeroing auth, unlike every other exit this PR added scrubbing for.
Recommendation: Call wc_ForceZero(auth, sizeof(auth)) before returning when basic_auth_header reports n < 0, in both http_write_request and build_head.
Referenced code: src/http.c:869-874 (6 lines)
This review was generated automatically by Fenrir. Findings are non-blocking.
9de58b5 to
0c92acc
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #14
Scan targets checked: wolfcert-bugs, wolfcert-src
Findings: 1
1 finding(s) posted as inline comments (see file-level comments below)
This review was generated automatically by Fenrir. Findings are non-blocking.
0c92acc to
4b84b02
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #14
Scan targets checked: wolfcert-bugs, wolfcert-src
No new issues found in the changed files. ✅
philljj
left a comment
There was a problem hiding this comment.
First pass, looks good so far. Just a few observations and questions.
There was a problem hiding this comment.
Pull request overview
This PR closes several SCEP interoperability gaps (CA identifier propagation, transactionID derivation, and content-cipher forcing), tightens transport security behavior for SCEP over TLS, and refactors WolfCertServerCfg so protocol-specific knobs are scoped under a proto_opts union selected by protocol.
Changes:
- Add SCEP client options:
ca_id,txid_mode(random vs pubkey-hash),content_cipher(auto/aes128/aes256/des3), andrenewal_msg_type(17 vs 19), with corresponding URL building and on-the-wire validation tests. - Reshape server/client config API to
WolfCertServerCfg.proto_opts.{est|scep}and enforce protocol scoping/keyword validation in the CLI. - Fix and harden credential handling in HTTP/EST sessions (propagation + scrubbing/zeroization) and align one-shot SCEP TLS verification gating with session behavior.
Reviewed changes
Copilot reviewed 28 out of 28 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| wolfcert/types.h | Introduces protocol-scoped option structs/enums and the proto_opts union on WolfCertServerCfg. |
| wolfcert/server.h | Updates server-side test-server comment references for proto_opts.est.auto_csrattrs. |
| wolfcert/scep.h | Clarifies SCEP authentication (no HTTP Basic; challengePassword in CSR) and documents proto_opts placement. |
| wolfcert/est.h | Updates PHA configuration docs and documents HTTP Basic behavior for EST sessions via proto_opts.est. |
| src/client.c | Fixes wolfcert_client_enroll union-member access ordering for auto_csrattrs under EST only. |
| src/est/est_client.c | Copies Basic credentials into EST sessions, replays them on every request, and scrubs on close/error paths. |
| src/http.c | Strengthens Basic-auth header construction and request/session header scrubbing (zeroization on all exit paths). |
| src/scep/scep_client.c | Adds verified-TLS gate for one-shot SCEP over https, CA-id URL builder, txid derivation modes, cipher override, and renewal messageType selection. |
| src/internal.h | Exposes new internal helpers (wolfcert_hex_encode, wolfcert_scep_build_getca_url) for white-box tests. |
| src/internal.c | Implements shared wolfcert_hex_encode helper. |
| cli/wolfcert_client.c | Adds SCEP flags (--ca-id, --txid-mode, --content-cipher) and enforces strict cross-protocol option scoping. |
| tests/unit/test_est.c | Adds unit coverage for wolfcert_hex_encode. |
| tests/unit/test_scep_msg.c | Adds unit coverage for GetCA URL building and AES128/AES256 enveloping OID selection. |
| tests/integration/test_est_roundtrip.c | Adds end-to-end coverage that EST keep-alive sessions honor HTTP Basic credentials. |
| tests/integration/test_est_async_roundtrip.c | Adds end-to-end coverage that async EST sessions honor HTTP Basic credentials. |
| tests/integration/test_est_pha_roundtrip.c | Updates PHA tests to use proto_opts.est.allow_post_handshake_auth. |
| tests/integration/test_est_mldsa_roundtrip.c | Updates EST ML-DSA roundtrip config to proto_opts.est for Basic auth. |
| tests/integration/test_est_csr_attrs_apply_roundtrip.c | Updates csrattrs auto-apply test to set proto_opts.est.auto_csrattrs. |
| tests/integration/test_scep_roundtrip.c | Adds end-to-end SCEP coverage for pubkey-hash txid, cipher override (wire + roundtrip), renewal messageType, GetNextCACert ca_id, and TLS verify gate. |
| tests/integration/cli_proto_scoping.sh | Adds CLI regression coverage for protocol-specific option rejection/validation. |
| tests/CMakeLists.txt | Registers the new CLI scoping test and marks 77 as skip for NO_MALLOC builds. |
| tests/interop/scep_micromdm.sh | Extends micromdm interop script to exercise --ca-id, --txid-mode pubkey, and --content-cipher aes256. |
| tests/interop/est_stepca.sh | Adds step-ca --ca-id GetCACert check. |
| README.md | Documents new CLI SCEP knobs and points to the migration guide. |
| docs/MIGRATING-FROM-WOLFSCEP.md | Adds a wolfSCEP-to-wolfCert migration guide and explains behavioral differences/options. |
| docs/INTEROP.md | Updates interop matrix notes to reflect new SCEP option coverage (incl. AES-256 case). |
| docs/ARCHITECTURE.md | Documents proto_opts union design and the new SCEP client options at a high level. |
| CLAUDE.md | Updates project guidance to reflect SCEP content-cipher override and migration guide pointer. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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.
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.
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.
4b84b02 to
6d91577
Compare
wolfSSL-Fenrir-bot
left a comment
There was a problem hiding this comment.
Fenrir Automated Review — PR #14
Scan targets checked: wolfcert-bugs, wolfcert-src
No new issues found in the changed files. ✅
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.
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.
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.
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.
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.
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.
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.
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.
6d91577 to
73a5cd5
Compare
Close the wolfSCEP interop gaps, and scope the server config per protocol
Three wolfSCEP deployment shapes were unreachable: a multi-CA responder needs the CA identifier in
message=on GetCACaps and GetCACert (RFC 8894 section 3.5.2), wolfSCEP derives the transactionID from the signer public key so retries of one key share an ID, and no GetCACaps keyword advertises AES-256, so a peer requiring it could not be asked. This adds those three options, then reshapesWolfCertServerCfgaround the fact that a connection is either EST or SCEP and never both. Two bugs surfaced along the way and are fixed here.It also ships
docs/MIGRATING-FROM-WOLFSCEP.md, since this is the change that makes wolfCert a viable target for an existing wolfSCEP integration, and adds the one option that writing the guide showed was still missing.11 commits, 28 files, +1857/-216. Each commit builds clean and passes the full suite on its own, so it is worth reading in order.
API changes
Backward compatibility was deliberately waived. The per-protocol fields move into a
proto_optsunion ofWolfCertEstServerOptsandWolfCertScepServerOpts, selected by the existingprotocolfield. Both arms are zero-init safe. The union member is named rather than anonymous, because anonymous unions are C11 and the public headers must stay usable at-std=c99on embedded toolchains.srv.allow_post_handshake_authsrv.proto_opts.est.allow_post_handshake_authsrv.auto_csrattrssrv.proto_opts.est.auto_csrattrssrv.username/srv.passwordsrv.proto_opts.est.username/.passwordsrv.proto_opts.scep.ca_idsrv.proto_opts.scep.txid_modesrv.proto_opts.scep.content_ciphersrv.proto_opts.scep.renewal_msg_typeOne behaviour change to be aware of. An
https://SCEP endpoint withverify_serveroff used to complete a silent anonymous handshake on the one-shot calls; only the session open refused it. All six one-shot entry points now apply the same gate, so that combination returnsWOLFCERT_ERR_TLSinstead of connecting.verify_serveris the transport's only peer-verification switch, so the old behaviour was an unauthenticated TLS session that looked like a secure one.HTTP Basic is now EST-only: RFC 8894 authenticates inside the pkiMessage and defines nothing at the HTTP layer, so the SCEP entry points no longer send credentials. Both interop targets agree, using
--challengerather than HTTP auth.One migration hazard:
est.usernameandscep.ca_idare bothconst char*at offset 0 of their arms, so rewriting.usernameto.proto_opts.est.usernamewhile leavingprotocol = WOLFCERT_PROTO_SCEPcompiles silently and puts the username into a GetCACaps URL.protocolis the discriminator and the library always reads the matching arm; the CLI rejects the combination outright.Bugs fixed
WolfCertHttpRequest.basic_userunset, so moving from the one-shot calls to a session silently dropped the Authorization header. It now copies the pair at open, replays it on every request in both the blocking and async forms, and zeroizes both halves on close.wolfcert_client_enrollread an inactive union member, testingauto_csrattrsbefore the protocol. Harmless today only because the EST arm is longer than the SCEP arm; two more SCEP fields would have made a SCEP option switch on/csrattrsdiscovery.A second Skoll pass over the finished branch turned up five more, all fixed here: the CA identifier never reached
GetNextCACert, the one-shothttps://gate above, the explicit AES cipher arms lacked the build guard their 3DES neighbour has,basic_auth_headerleft the cleartext credential intact on its allocation-failure path, and the new test's capture thread had an unterminatedstrstr, a libcfree()of wolfCert-heap memory and no receive timeout.Fenrir's automated review on the PR caught three more, all folded into the commits that introduced them: a
DecodedCertleft unfreed on thewc_ParseCertfailure path of a test helper, and two gaps in the credential scrubbing above.basic_auth_headerwrites into the caller's stack buffer withsnprintfbefore checking for truncation, so its error path left a partial credential behind; and the scrub in both callers only ran once the request head was built, so thebasic_auth_headerfailure and header-allocation failure exits bypassed it, the latter with the complete credential still in the buffer. Every exit clears it now, and so does the assembled request head, on the truncated-head path, after the one-shot write, and insm_reset. What is left is wolfSSL's own write buffer, which is its concern rather than wolfCert's.Two smaller ones from the first review:
basic_auth_headerfreed the literaluser:passbuffer and its base64 form without clearing them, and the SCEP transactionID lost itswc_ForceZeroon the RNG-failure path.Also in here
A shared
wolfcert_hex_encodereplaces three copies of the same table, the transactionID inputs collapse from threescep_preparearguments into one selector with the random draw moved inside the branch that consumes it, and theDecodedCertbehind the public-key derivation moves off the stack (2384 bytes).The CLI gains
--ca-id,--txid-mode random|pubkeyand--content-cipher auto|aes128|aes256|des3, and now rejects an option belonging to the other protocol in both directions.--phaand--csrattrs-autoused to be accepted and ignored under SCEP.--challengestays unscoped, since a challengePassword is legitimate in an EST CSR when/csrattrsasks for one.Migration guide
docs/MIGRATING-FROM-WOLFSCEP.mdmaps the wolfSCEP calls onto wolfCert and covers the differences that bite. The framing matters more than the table: wolfSCEP is HTTP transport, building and parsing no pkiMessages at all, so every integration on it carries its ownwc_PKCS7code for the EnvelopedData, the SignedData wrapper and the signed attributes, plus its own CertRep parse, fingerprint check and polling loop. A migration therefore deletes the cryptographic message layer, not just the plumbing, and the guide leads with that.Four things need a decision from the reader: an MD5 CA fingerprint has to be re-pinned (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 unlesstxid_modeasks for the public-key derivation, and the content cipher is caps-driven so an AES-256 peer has to be told.Writing it turned up a fifth, which is fixed here rather than documented as a caveat. An enrollment request pairs a
messageTypewith a signing certificate, and RFC 8894 pairs them as 19 with a throwaway self-signed cert or 17 with the certificate being replaced. CAs predatingRenewalReqexpect 19 signed by the old certificate, and wolfCert had no way to send it.proto_opts.scep.renewal_msg_typenow selects it, defaulting to the RFC form, withWolfCertScepCaps.renewalas the way to tell which a CA wants.Testing
New coverage: the pubkey-hash transactionID (recomputed from the CSR, plus determinism across two enrollments), AES-128 and AES-256 content ciphers, the GetCACaps and GetCACert URL builder, the hex helper, HTTP Basic over both the blocking and async EST session, and the renewal messageType read off the wire.
That last one needs a loopback capture rather than a round trip, because the in-tree server routes 19 and 17 through the same handler: the test parses the POSTed pkiMessage with
wolfcert_scep_parse_pki_messageand asserts the attribute. Forcing the helper to return 17 unconditionally fails it, so it discriminates.-DWOLFCERT_WERROR=ONwith-Wall -Wextra -Wshadow -Wpedantic: zero warnings. ASan and UBSan: clean.leaks: 50 failed session opens give0 leaksafter the fix,100 leaks for 2400 total leaked bytesinwolfcert_strdupwith it backed out. Both new EST credential tests also confirmed to fail with the plumbing removed.AUTObranch fails it.wolfcert-server, including--ca-id "my CA/1"captured on the wire asGET /scep?operation=GetCACert&message=my%20CA%2F1.Interop
scep_micromdm.shgains all three options, all strict.--content-cipher aes256started as a non-fatal probe, because RFC 8894 defines no GetCACaps keyword for AES-256 and it was unknown whether the peer could decrypt it; a manual Interop run on 2026-07-30 answered that (micromdm accepts it), so it is a plain assertion now.est_stepca.shgets--ca-idonly, its SCEP being the same smallstep/micromdm code path.These have now run against the real peers, not just locally: a manual Interop dispatch on 2026-07-30 passed all five jobs with nothing skipped, including
--ca-id,--txid-mode pubkeyand AES-256 against micromdm, and--ca-idagainst step-ca. A Sanitizers dispatch passed as well, which matters because it runs LeakSanitizer, unavailable on macOS where the rest of the local verification was done.