diff --git a/CLAUDE.md b/CLAUDE.md index 28d06b4..bd52ffa 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.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`. @@ -183,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/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..f7d5213 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 @@ -183,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/cli/wolfcert_client.c b/cli/wolfcert_client.c index 401a79b..2f827b0 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" @@ -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,6 +464,123 @@ static void fill_trust(const Opts* opts, WolfCertServerCfg* cfg, *trust_hold = trust_buf; } +/* 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) +{ + /* 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->txid_mode != NULL) { + fprintf(stderr, "--txid-mode is SCEP-only; EST has no " + "transactionID\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->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. 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_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) +{ + if (cfg->protocol != WOLFCERT_PROTO_EST) + return; + + cfg->proto_opts.est.username = opts->user; + 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) { @@ -486,13 +630,18 @@ static int cmd_getcacerts(int argc, char** argv) if (ret == 0 && proto_of(opts.proto, &p) != 0) ret = 1; + if (ret == 0 && check_proto_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); - if (fill_client_ident(&opts, &srv, &mt_cert, &mt_key) != 0) + fill_basic_auth(&opts, &srv); + if (fill_scep_opts(&opts, &srv) != 0) + ret = 1; + if (ret == 0 && fill_client_ident(&opts, &srv, &mt_cert, &mt_key) != 0) ret = 1; } @@ -552,6 +701,9 @@ static int cmd_enroll(int argc, char** argv) if (ret == 0 && proto_of(opts.proto, &p) != 0) ret = 1; + if (ret == 0 && check_proto_only_opts(&opts, p) != 0) + ret = 1; + if (ret == 0 && opts.subject == NULL) { fprintf(stderr, "enroll: --subject required\n"); ret = 1; @@ -561,7 +713,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,7 +723,10 @@ static int cmd_enroll(int argc, char** argv) if (ret == 0) { fill_trust(&opts, &srv, &trust_hold); - if (fill_client_ident(&opts, &srv, &mt_cert, &mt_key) != 0) + fill_basic_auth(&opts, &srv); + if (fill_scep_opts(&opts, &srv) != 0) + ret = 1; + if (ret == 0 && fill_client_ident(&opts, &srv, &mt_cert, &mt_key) != 0) ret = 1; } @@ -648,7 +802,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) { @@ -852,6 +1006,9 @@ static int cmd_reenroll(int argc, char** argv) if (ret == 0 && proto_of(opts.proto, &p) != 0) ret = 1; + if (ret == 0 && check_proto_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,12 +1050,14 @@ 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); - if (fill_client_ident(&opts, &srv, &mt_cert, &mt_key) != 0) + fill_basic_auth(&opts, &srv); + if (fill_scep_opts(&opts, &srv) != 0) + ret = 1; + if (ret == 0 && fill_client_ident(&opts, &srv, &mt_cert, &mt_key) != 0) ret = 1; } @@ -965,18 +1124,23 @@ static int cmd_getnextca(int argc, char** argv) if (ret == 0 && proto_of(opts.proto, &p) != 0) ret = 1; + if (ret == 0 && check_proto_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); - if (fill_client_ident(&opts, &srv, &mt_cert, &mt_key) != 0) + fill_basic_auth(&opts, &srv); + 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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 80f98a3..f1ff2a8 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 @@ -136,11 +145,14 @@ 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* - `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 +173,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. @@ -191,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. @@ -214,6 +229,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.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. +- `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. +- `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 @@ -393,15 +421,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/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/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`. 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..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; @@ -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; @@ -381,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; @@ -399,10 +413,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 +434,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", @@ -444,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, @@ -455,7 +491,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, @@ -465,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; } @@ -513,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); } @@ -530,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, }; @@ -589,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, @@ -644,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 }; @@ -696,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, @@ -744,7 +801,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/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; diff --git a/src/internal.c b/src/internal.c index 7074e4a..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) @@ -287,6 +299,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 e895d4c..c6c636e 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; @@ -292,6 +299,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); @@ -355,6 +369,14 @@ 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 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, 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..262be38 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; @@ -120,15 +122,52 @@ 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 = append_query(srv->server_url, "GetCACaps", heap); + char* url = wolfcert_scep_build_getca_url(srv->server_url, "GetCACaps", + srv->proto_opts.scep.ca_id, heap); if (url == NULL) return WOLFCERT_ERR_MEMORY; @@ -187,8 +226,13 @@ 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 = append_query(srv->server_url, "GetCACert", heap); + char* url = wolfcert_scep_build_getca_url(srv->server_url, "GetCACert", + srv->proto_opts.scep.ca_id, heap); if (url == NULL) return WOLFCERT_ERR_MEMORY; @@ -340,7 +384,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) @@ -356,8 +399,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'; @@ -365,6 +409,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, @@ -480,22 +556,142 @@ 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. */ +#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 + * 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 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); + 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); + WOLFCERT_XFREE(dc, heap); + 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; + + /* 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; +} + +/* 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 (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_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, + const ScepTxidSel* txid_sel, + WolfCertScepContentCipher cipher, WolfCertBuffer* out_pki, uint8_t** out_txid, size_t* out_txid_len, uint8_t* out_nonce) @@ -503,21 +699,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 }; @@ -528,57 +763,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, 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)); + 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 WOLFCERT_ERR_MEMORY; - } - - if (txid_override != NULL) { - memcpy(txid, txid_override, txid_override_len); - } - else { - static const char HEX[] = "0123456789abcdef"; - 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]; - } + return rc; } - wc_ForceZero(txid_gen, (word32)sizeof(txid_gen)); /* dead once expanded */ WolfCertScepAttrs attrs = { .transaction_id = txid, .transaction_id_len = txid_len, @@ -722,8 +935,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->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, @@ -739,6 +952,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->proto_opts.scep.txid_mode + }; + WolfCertBuffer pki = { 0 }; uint8_t* txid = NULL; size_t txid_len = 0; @@ -746,7 +964,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, + &txid_sel, srv->proto_opts.scep.content_cipher, &pki, &txid, &txid_len, nonce); if (rc != WOLFCERT_OK) return rc; @@ -818,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, @@ -904,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); @@ -914,7 +1140,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); @@ -985,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; @@ -1043,8 +1275,13 @@ 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 = append_query(srv->server_url, "GetNextCACert", heap); + char* url = wolfcert_scep_build_getca_url(srv->server_url, "GetNextCACert", + srv->proto_opts.scep.ca_id, heap); if (url == NULL) return WOLFCERT_ERR_MEMORY; @@ -1092,15 +1329,18 @@ 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) */ + 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 */ + WolfCertScepRenewalMsgType renewal_msg_type; /* 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; @@ -1132,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; @@ -1165,9 +1411,12 @@ 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->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); WOLFCERT_XFREE(origin, heap); @@ -1300,11 +1549,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, + &txid_sel, s->content_cipher, &pki, &s->in_txid, &s->in_txid_len, s->in_nonce); if (rc != WOLFCERT_OK) { scep_async_reset(s); @@ -1466,7 +1719,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/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/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" diff --git a/tests/integration/test_est_async_roundtrip.c b/tests/integration/test_est_async_roundtrip.c index 2c87c43..5d4d194 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); @@ -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_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_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_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_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/tests/integration/test_scep_roundtrip.c b/tests/integration/test_scep_roundtrip.c index 6d8a0ea..0ccb3d0 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; } +/* proto_opts.scep.txid_mode = PUBKEY_HASH: the transactionID must be the + * 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, + 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.proto_opts.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 +/* 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, + 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.proto_opts.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. */ @@ -283,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); @@ -346,6 +733,80 @@ 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 + + /* ---- 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); + + /* 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/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 diff --git a/tests/unit/test_est.c b/tests/unit/test_est.c index f71ad4a..6eb9751 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 @@ -318,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, @@ -355,9 +439,15 @@ int main(void) if (test_oid_to_dotted()) return 1; + if (test_hex_encode()) + return 1; + 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/tests/unit/test_scep_msg.c b/tests/unit/test_scep_msg.c index 2f73a50..5508346 100644 --- a/tests/unit/test_scep_msg.c +++ b/tests/unit/test_scep_msg.c @@ -1131,10 +1131,141 @@ 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; +} + +/* 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)) +/* 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 (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()) + return 1; +#endif if (test_ca_fingerprint()) return 1; if (test_pki_get_url()) diff --git a/wolfcert/est.h b/wolfcert/est.h index f289fe6..d29e603 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 @@ -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. */ @@ -263,8 +268,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/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/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 015177c..9118dbd 100644 --- a/wolfcert/types.h +++ b/wolfcert/types.h @@ -153,11 +153,92 @@ typedef struct { void* customize_ctx; } WolfCertCertMeta; +/* 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 (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 { + 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; + +/* 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 + * 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 + * 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. + * 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 + * 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; + + /* 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 { 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 */ @@ -184,35 +265,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; + void* connect_ctx; + + /* 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. */