diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index 1055d30..9b4a630 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -106,7 +106,17 @@ jobs: sudo apt-get install -y libssl-dev git clone --depth 1 https://github.com/cisco/libest "$RUNNER_TEMP/libest" cd "$RUNNER_TEMP/libest" - ./configure --prefix="$RUNNER_TEMP/libest-install" --with-ssl-dir=/usr + # libest is archived and built for OpenSSL 1.x. OpenSSL 3.0 removed + # FIPS_mode()/FIPS_mode_set(), which libest still calls, so the link + # fails. Force-include a compat header that turns both into no-ops; + # doing it in a header (not -D) keeps the macro parens off gcc's + # command line. --disable-safec: libest now requires an explicit + # safec choice and we don't need the bounds-checked string library. + printf '%s\n' '#define FIPS_mode() 0' '#define FIPS_mode_set(x) 0' \ + > "$RUNNER_TEMP/fips_compat.h" + ./configure --prefix="$RUNNER_TEMP/libest-install" --with-ssl-dir=/usr \ + --disable-safec \ + CFLAGS="-g -O2 -include $RUNNER_TEMP/fips_compat.h" make -j "$(nproc)" make install echo "$RUNNER_TEMP/libest-install/bin" >> "$GITHUB_PATH" @@ -132,6 +142,9 @@ jobs: - name: Install step-ca and step CLI continue-on-error: true run: | + # step-ca's cgo build links libpcsclite (PC/SC, for PIV/YubiKey + # provisioners); without its pkg-config file the build aborts. + sudo apt-get install -y libpcsclite-dev go install github.com/smallstep/certificates/cmd/step-ca@latest go install github.com/smallstep/cli/cmd/step@latest - name: Run est_stepca.sh diff --git a/docs/INTEROP.md b/docs/INTEROP.md new file mode 100644 index 0000000..5bdd46d --- /dev/null +++ b/docs/INTEROP.md @@ -0,0 +1,38 @@ +# Third-party interoperability + +wolfCert's EST (RFC 7030) and SCEP (RFC 8894) clients and test server are exercised against independent implementations under `tests/interop/`. These scripts are **hand-run and best-effort** - they are driven by the nightly `Interop` GitHub workflow (`.github/workflows/interop.yml`) but are not part of `ctest`. + +## Best-effort / skip convention + +Every interop script sources `tests/interop/lib/common.sh`, whose `need ` helper exits **77** when a required third-party binary is missing. The workflow treats 77 as a neutral skip (a dependency that could not be installed), so a genuinely-absent tool does not fail the job. Any *other* non-zero exit is a real interop regression and fails loudly. + +Because a failed dependency *build* also ends in a 77 skip (the binary never lands in `PATH`), a broken build recipe can masquerade as a green run. Keep the dependency build steps in `interop.yml` working, not just the scripts. + +## Targets + +| 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. | +| `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. | + +## Dependency build notes + +- **cisco/libest** is archived and written for OpenSSL 1.x. On a modern OpenSSL 3.0 host it needs two build tweaks (applied in `interop.yml`): `--disable-safec` (its configure now demands an explicit safec choice) and a force-included compat header defining `FIPS_mode()` / `FIPS_mode_set()` as no-ops (OpenSSL 3.0 removed both symbols, which libest still references). +- **smallstep/step-ca** links `libpcsclite` via cgo (PC/SC, for its PIV/YubiKey provisioners); install `libpcsclite-dev` before `go install`, or the build aborts at pkg-config. + +## Notes + +1. **micromdm D1 signed-attribute set.** `wolfcert-server`'s SCEP CertRep emits the full RFC 8894 signed-attribute set (including `recipientNonce`). On a malloc-enabled wolfSSL its PKCS#7 encoder grows the signed-attribute array on the heap past the inline `MAX_SIGNED_ATTRIBS_SZ`, so D1 passes as-is. Only a `WOLFSSL_NO_MALLOC` build needs wolfSSL rebuilt with `-DMAX_SIGNED_ATTRIBS_SZ>=9`. + +2. **micromdm content encryption.** micromdm content-encrypts its `pkcsPKIEnvelope` with single DES-CBC (`1.3.14.3.2.7`), which wolfSSL compiles out by default (`NO_DES3`). The `full` CI wolfSSL build enables `--enable-des3` so `wc_PKCS7_DecodeEnvelopedData` can decode both directions. + +3. **Server-side TLS is native.** The EST/SCEP test server terminates TLS itself via `--tls-cert` / `--tls-key`; no external fronting terminator is needed. Scripts mint a throwaway loopback identity for it. + +4. **step-ca has no open-source EST.** RFC 7030 EST endpoints ship only in Smallstep's commercial Certificate Manager; open-source `step-ca` returns HTTP 404 on `/.well-known/est/*`. `est_stepca.sh` probes the endpoint and skips EST if absent, then exercises SCEP. + +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. diff --git a/src/scep/scep_msg.c b/src/scep/scep_msg.c index b6964d1..aa69b59 100644 --- a/src/scep/scep_msg.c +++ b/src/scep/scep_msg.c @@ -239,6 +239,18 @@ WOLFCERT_TEST_VIS int wolfcert_scep_envelop(const uint8_t* ra_cert_der, return WOLFCERT_ERR_WC(rc, "scep", "InitWithCert"); } + /* RFC 8894 is RSA-only: the pkcsPKIEnvelope is encrypted to the RA/CA + * public key with CMS key transport, which wolfSSL only supports for an + * RSA recipient. A non-RSA (e.g. ECC) RA certificate would otherwise fall + * through to the key-agreement path and fail deep in the encoder with + * BAD_KEYWRAP_ALG_E; reject it up front with an actionable diagnostic. */ + if (p7->publicKeyOID != RSAk) { + wc_PKCS7_Free(p7); + return WOLFCERT_ERR(WOLFCERT_ERR_UNSUPPORTED, "scep", + "SCEP requires an RSA RA/CA certificate (RFC 8894); the server " + "presented a non-RSA key"); + } + WC_RNG rng; if (wc_InitRng_ex(&rng, heap, WOLFCERT_DEVID_SOFTWARE) != 0) { wc_PKCS7_Free(p7); diff --git a/tests/interop/est_libest.sh b/tests/interop/est_libest.sh index ed88151..eed8dbd 100755 --- a/tests/interop/est_libest.sh +++ b/tests/interop/est_libest.sh @@ -8,7 +8,8 @@ # `estserver` and `estclient` in PATH. Install by: # # git clone https://github.com/cisco/libest -# cd libest && ./configure --with-ssl-dir=$(pkg-config --variable prefix openssl) +# cd libest && ./configure --disable-safec \ +# --with-ssl-dir=$(pkg-config --variable prefix openssl) # make && sudo make install # # The script exercises: @@ -117,9 +118,7 @@ kill_if "$ES_PID"; ES_PID="" echo "[2] libest estclient -> wolfcert-server (HTTPS via built-in TLS)" EST_PORT=$(free_port) -# Mint a loopback server identity specifically for wolfcert-server's -# --tls-cert/--tls-key (no external fronting terminator required; this -# was note 4 in docs/INTEROP.md before server-side TLS landed). +# Mint a loopback server identity for wolfcert-server's --tls-cert/--tls-key. openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ -keyout wc-srv.key -out wc-srv.crt -subj "/CN=127.0.0.1" \ -addext "subjectAltName=IP:127.0.0.1" >/dev/null 2>&1 @@ -131,27 +130,28 @@ WC_PID=$! trap 'kill_if "$WC_PID"' EXIT wait_port 127.0.0.1 "$EST_PORT" -# estclient needs to trust wc-srv.crt as the server bootstrap anchor; -# pass it explicitly. Flag spellings vary between libest releases, so -# try a couple and skip cleanly if none match. -set +e +# estclient pins the server cert as its trust anchor via --trustanchor, +# auto-generates an RSA identity key when none is supplied (-x), and writes the +# enrolled cert to /cert--.pkcs7 as base64 PKCS#7. -o is +# an output DIRECTORY, not a file. +mkdir -p d2out estclient -e -s "127.0.0.1" -p "$EST_PORT" \ --common-name "libest-cli-1" \ - --pem-file wc-srv.crt -o libest-cli.crt >estclient.log 2>&1 -RC=$? -if [ "$RC" -ne 0 ]; then - estclient -e -s "127.0.0.1" -p "$EST_PORT" \ - --common-name "libest-cli-1" \ - -c wc-srv.crt -o libest-cli.crt >>estclient.log 2>&1 - RC=$? -fi -set -e -if [ "$RC" -eq 0 ] && [ -s libest-cli.crt ]; then - echo " PASS" -else - echo " SKIP (estclient invocation did not match this libest build;" - echo " adjust the trust-anchor flag for your version.)" -fi + --trustanchor wc-srv.crt \ + -o d2out >estclient.log 2>&1 \ + || { echo " FAIL (estclient enroll failed):"; cat estclient.log; exit 1; } + +# Decode the base64 PKCS#7 response and confirm wolfcert-server issued the +# requested identity. +# `|| true`: under `set -e`+pipefail an unmatched glob makes ls exit non-zero +# and would abort here before the friendly guard below could report it. +cert_p7=$(ls d2out/cert-*.pkcs7 2>/dev/null | head -1 || true) +[ -n "$cert_p7" ] || { echo " FAIL (no cert emitted)"; cat estclient.log; exit 1; } +openssl base64 -d -in "$cert_p7" -out d2out/cert.p7der +openssl pkcs7 -inform DER -in d2out/cert.p7der -print_certs -out d2out/cert.pem +openssl x509 -in d2out/cert.pem -noout -subject \ + | grep -q "CN *= *libest-cli-1" +echo " PASS" kill_if "$WC_PID" echo "OK" diff --git a/tests/interop/est_stepca.sh b/tests/interop/est_stepca.sh index b2d0ce7..d8af294 100755 --- a/tests/interop/est_stepca.sh +++ b/tests/interop/est_stepca.sh @@ -44,13 +44,36 @@ step ca init \ --deployment-type standalone \ >init.log 2>&1 +# RFC 8894 SCEP is RSA-only: the CA decrypts the pkcsPKIEnvelope with its +# private key, which step-ca's default ECDSA chain cannot do (see +# docs/INTEROP.md note 5). Swap in an RSA root + intermediate so the SCEP +# decrypter - and every cert GetCACert hands the client - is RSA. step-ca uses +# the intermediate as the SCEP decrypter when none is configured explicitly. +# The intermediate key stays encrypted under STEP_PASSWORD (step-ca loads it at +# startup); the throwaway root key is only needed here to sign the intermediate. +ROOT_CRT="$STEPPATH/certs/root_ca.crt" +ROOT_KEY="$STEPPATH/secrets/root_ca_key" +INT_CRT="$STEPPATH/certs/intermediate_ca.crt" +INT_KEY="$STEPPATH/secrets/intermediate_ca_key" +step certificate create "wolfCert-interop RSA Root" "$ROOT_CRT" "$ROOT_KEY" \ + --profile root-ca --kty RSA --size 2048 --not-after 87600h \ + --force --no-password --insecure >rsa-root.log 2>&1 +step certificate create "wolfCert-interop RSA Intermediate" "$INT_CRT" "$INT_KEY" \ + --profile intermediate-ca --kty RSA --size 2048 --not-after 87600h \ + --ca "$ROOT_CRT" --ca-key "$ROOT_KEY" \ + --force --password-file <(echo -n "$STEP_PASSWORD") >rsa-int.log 2>&1 + # Enable SCEP provisioner (EST is on by default in recent step-ca). # Configure a challenge so we exercise RFC 8894 section 2.9 end-to-end. +# --encryption-algorithm-identifier 1 selects AES-128-CBC for the CertRep +# content encryption (step-ca defaults to legacy DES-CBC), matching what +# wolfcert-client advertises via GetCACaps. SCEP_CHALLENGE="wolfcert-interop-challenge" step ca provisioner add "SCEP" --type SCEP \ --force-cn \ --challenge "$SCEP_CHALLENGE" \ - >scep-add.log 2>&1 || echo " (scep provisioner add skipped: $?)" + --encryption-algorithm-identifier 1 \ + >scep-add.log 2>&1 CA_PORT=$(grep -oE '127\.0\.0\.1:[0-9]+' "$STEPPATH/config/ca.json" | cut -d: -f2 | head -1) echo "[setup] step-ca will listen on :$CA_PORT" @@ -98,8 +121,13 @@ 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" -set +e "$WC_CLIENT" enroll --proto scep \ --url "$SCEP_URL" \ --trust ca-root.pem \ @@ -107,18 +135,11 @@ set +e --key-type rsa:2048 \ --subject "CN=stepca-interop-scep" \ --out-key scep.key --out-cert scep.crt \ - >wc-scep.log 2>&1 -RC=$? -set -e -if [ "$RC" -eq 0 ]; then - openssl x509 -in scep.crt -noout -subject \ - | grep -q "CN *= *stepca-interop-scep" - echo " PASS (cert returned; challengePassword accepted by step-ca)" -else - WCDETAIL=$(grep -m1 "detail" wc-scep.log 2>/dev/null || true) - echo " KNOWN-FAIL (wolfcert-client rc=$RC - see docs/INTEROP.md note 6." - echo " ${WCDETAIL:-(no detail))}" -fi + >wc-scep.log 2>&1 \ + || { echo " FAIL (scep enroll failed):"; cat wc-scep.log; exit 1; } +openssl x509 -in scep.crt -noout -subject \ + | grep -q "CN *= *stepca-interop-scep" +echo " PASS (cert returned; challengePassword accepted by step-ca)" kill_if "$CA_PID" echo "OK" diff --git a/tests/unit/test_scep_msg.c b/tests/unit/test_scep_msg.c index f780ec8..616f568 100644 --- a/tests/unit/test_scep_msg.c +++ b/tests/unit/test_scep_msg.c @@ -42,6 +42,9 @@ #include #include #include +#ifdef HAVE_ECC +#include +#endif #include #ifndef NO_SHA #include @@ -138,6 +141,131 @@ static int make_ca(uint8_t** cert_out, size_t* cert_out_len, return ret; } +#ifdef HAVE_ECC +/* Generate a throwaway self-signed ECC (P-256) CA cert, DER. Ownership of the + * returned buffer passes to the caller (free with free()). */ +static int make_ecc_ca(uint8_t** cert_out, size_t* cert_out_len) +{ + ecc_key key; + WC_RNG rng; + Cert* cert = NULL; + uint8_t* cert_der = NULL; + int ret = 0; + int body_n = 0; + int cert_n = 0; + + if (wc_InitRng(&rng) != 0) + return -1; + if (wc_ecc_init(&key) != 0) { + wc_FreeRng(&rng); + return -1; + } + + if (ret == 0 && wc_ecc_make_key(&rng, 32, &key) != 0) /* 32 bytes = P-256 */ + ret = -1; + + if (ret == 0) { + cert_der = (uint8_t*)malloc(4096); + if (cert_der == NULL) + ret = -1; + } + + if (ret == 0) { + cert = wc_CertNew(NULL); + if (cert == NULL) + ret = -1; + } + + if (ret == 0) { + wc_InitCert_ex(cert, NULL, INVALID_DEVID); + strncpy(cert->subject.commonName, "wolfCert Test ECC CA", + CTC_NAME_SIZE - 1); + cert->subject.commonName[CTC_NAME_SIZE - 1] = '\0'; + cert->isCA = 1; + cert->selfSigned = 1; + cert->sigType = CTC_SHA256wECDSA; + cert->daysValid = 2; + + body_n = wc_MakeCert(cert, cert_der, 4096, NULL, &key, &rng); + if (body_n <= 0) + ret = -1; + } + + if (ret == 0) { + cert_n = wc_SignCert(cert->bodySz, cert->sigType, cert_der, 4096, + NULL, &key, &rng); + if (cert_n <= 0) + ret = -1; + } + + if (ret == 0) { + *cert_out = cert_der; + *cert_out_len = (size_t)cert_n; + cert_der = NULL; /* ownership transferred */ + } + + if (cert != NULL) + wc_CertFree(cert); + free(cert_der); + wc_ecc_free(&key); + wc_FreeRng(&rng); + return ret; +} + +/* wolfcert_scep_envelop must reject a non-RSA RA certificate up front with a + * clear WOLFCERT_ERR_UNSUPPORTED, not fail deep in the PKCS#7 encoder with + * BAD_KEYWRAP_ALG_E - RFC 8894 is RSA-only. An RSA RA cert still succeeds. */ +static int test_envelop_rejects_ecc_ra(void) +{ + static const uint8_t payload[] = { 0x30, 0x03, 0x02, 0x01, 0x00 }; + uint8_t* ecc_ca = NULL; + size_t ecc_ca_len = 0; + uint8_t* rsa_ca = NULL; + size_t rsa_ca_len = 0; + uint8_t* rsa_key = NULL; + size_t rsa_key_len = 0; + WolfCertBuffer ecc_env = { 0 }; + WolfCertBuffer rsa_env = { 0 }; + int made_ecc, made_rsa; + int ecc_rc = 0, rsa_rc = 0; + int ecc_env_empty = 0, rsa_env_ok = 0; + + made_ecc = make_ecc_ca(&ecc_ca, &ecc_ca_len); + made_rsa = make_ca(&rsa_ca, &rsa_ca_len, &rsa_key, &rsa_key_len); + + /* enc_oid is irrelevant for the ECC case: the recipient is rejected up + * front, before it is used. The RSA cert is the contrast that must still + * envelop. */ + if (made_ecc == 0) { + ecc_rc = wolfcert_scep_envelop(ecc_ca, ecc_ca_len, payload, + sizeof(payload), AES128CBCb, &ecc_env, + NULL); + ecc_env_empty = (ecc_env.data == NULL); + } + if (made_rsa == 0) { + rsa_rc = wolfcert_scep_envelop(rsa_ca, rsa_ca_len, payload, + sizeof(payload), AES128CBCb, &rsa_env, + NULL); + rsa_env_ok = (rsa_env.data != NULL && rsa_env.len > 0); + } + + /* Free everything before asserting so a failed REQUIRE cannot leak. */ + wolfcert_buffer_free(&ecc_env); + wolfcert_buffer_free(&rsa_env); + free(ecc_ca); + free(rsa_ca); + free(rsa_key); + + REQUIRE(made_ecc == 0); + REQUIRE(made_rsa == 0); + REQUIRE(ecc_rc == WOLFCERT_ERR_UNSUPPORTED); + REQUIRE(ecc_env_empty); + REQUIRE(rsa_rc == WOLFCERT_OK); + REQUIRE(rsa_env_ok); + return 0; +} +#endif /* HAVE_ECC */ + /* Build a FAILURE CertRep signed with hash_oid and confirm it carries no * pkcsPKIEnvelope and still round-trips through the parser - which must * discover the signer's digest rather than assume one. */ @@ -962,6 +1090,10 @@ int main(void) return 1; if (test_signer_matches_any_bundle_cert()) return 1; +#ifdef HAVE_ECC + if (test_envelop_rejects_ecc_ra()) + return 1; +#endif wolfcert_cleanup(); printf("OK\n"); return 0;